Rendering and presentation - Shared surface-material module (src/game/materials.gd) with per-surface roughness/metallic/uv profiles; ACES tonemapping, glow, fog and phase-driven sky energy. WorldBuilder rebuilt on those profiles with facade windows that light up, door trim/handles, curb, lane dashes, bollards, bench, bin and planter foliage. - NPC silhouettes: per-role clothing palettes, caps/hoods/backpacks/sunglasses, hands and shoes, arm/leg walk swing. - Six generated low-poly GLB props (bollards, bench, bin, planter, newspaper box, hydrant) built by tools/model_pipeline.py, loaded by WorldBuilder with a procedural fallback; ADR-0012. Selection and movement - Focus ranking by view angle then distance (NpcFocus.rank_indices); Tab and the mouse wheel cycle nearby people initially, right click clears focus with a re-acquire grace, HUD shows the candidate count, Shift walks at half speed. ADR-0011. Windowed smoke extended to cover wheel cycling and clearing. QA tooling - screen_designs.py catalogue grown to 19 screens (8 scene, 11 UI) plus a `list` subcommand; draft generation keeps existing AI references unless --force. Web export - New Web preset using the non-threaded Godot template (static hosting, no COOP/COEP). tools/build_releases.sh exports build/web and packages build/step-line-web-<ver>.zip; publish_release.sh uploads it with the desktop artifacts. test_web_export.gd covers the preset and release wiring. Verification - Headless suite 177 -> 181, Python tooling 53 tests, all eight smokes pass (including the windowed focus/wheel smoke). Web bundle serves index.html, index.wasm (35 MB) and index.pck (30 MB) with HTTP 200 from a static server. - Version 0.3.0; README, PLAN, RELEASING, TODO/DONE and MEMORY updated.
733 lines
28 KiB
Python
733 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""Build the low-poly prop models used in the Step Line street scene.
|
|
|
|
The scene needs street furniture (bollards, benches, bins, planters, newspaper
|
|
boxes, a hydrant) that reads as solid geometry instead of flat boxes. This tool
|
|
keeps that art direction in one place:
|
|
|
|
* every prop is described as a small list of boxes and cylinders, so the
|
|
build is deterministic and runs offline in CI;
|
|
* an optional AI concept image per prop (OpenRouter, the same backend as
|
|
`generate_textures.py`) supplies the colour palette, so a model can look
|
|
like the concept without a 3D-model API;
|
|
* `build` writes standard glTF binary (`.glb`) files that Godot 4 imports as
|
|
`PackedScene`s, and `audit` checks the files are structurally valid.
|
|
|
|
Commands (run from the repo root):
|
|
|
|
python3 tools/model_pipeline.py list
|
|
python3 tools/model_pipeline.py build
|
|
python3 tools/model_pipeline.py audit
|
|
python3 tools/model_pipeline.py concept # needs OPENROUTER_API_KEY
|
|
python3 tools/model_pipeline.py concept --only bench
|
|
|
|
The concept step is optional: `build` never needs the network. If
|
|
`assets/models/concepts/<prop>.png` exists, its dominant colours replace the
|
|
role colours from the catalogue, which is the only AI influence on the mesh.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import struct
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
MODELS_DIR = ROOT / "assets" / "models" / "generated"
|
|
CONCEPTS_DIR = ROOT / "assets" / "models" / "concepts"
|
|
|
|
# Reuse the validated OpenRouter image backend; keep the import lazy so `list`,
|
|
# `build` and `audit` work without Pillow installed? Pillow is needed for the
|
|
# concept palette step, so it stays a normal import inside those functions.
|
|
DEFAULT_CONCEPT_MODEL = "openai/gpt-5-image-mini"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Catalogue. `role` names the palette slot a part uses:
|
|
# body / accent / metal / wood / foliage / stone / glass
|
|
# `concept` prompts ask for a single object on a plain background.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PROPS = [
|
|
{
|
|
"name": "bollard",
|
|
"concept": ("product photo of a single short black steel street bollard "
|
|
"with a reflective band, plain light grey studio background"),
|
|
"parts": [
|
|
{"shape": "cylinder", "radius": 0.09, "height": 0.92,
|
|
"at": [0, 0.46, 0], "segments": 12, "role": "metal"},
|
|
{"shape": "cylinder", "radius": 0.095, "height": 0.07,
|
|
"at": [0, 0.80, 0], "segments": 12, "role": "accent"},
|
|
{"shape": "cylinder", "radius": 0.10, "height": 0.05,
|
|
"at": [0, 0.94, 0], "segments": 12, "role": "metal"},
|
|
],
|
|
},
|
|
{
|
|
"name": "bench",
|
|
"concept": ("product photo of a single park bench with wooden slats and "
|
|
"dark metal legs, plain light grey studio background"),
|
|
"parts": [
|
|
{"shape": "box", "size": [1.80, 0.08, 0.52],
|
|
"at": [0, 0.46, 0], "role": "wood"},
|
|
{"shape": "box", "size": [1.80, 0.42, 0.07],
|
|
"at": [0, 0.72, -0.22], "role": "wood"},
|
|
{"shape": "box", "size": [0.09, 0.46, 0.46],
|
|
"at": [-0.72, 0.23, 0], "role": "metal"},
|
|
{"shape": "box", "size": [0.09, 0.46, 0.46],
|
|
"at": [0.72, 0.23, 0], "role": "metal"},
|
|
{"shape": "box", "size": [0.07, 0.40, 0.07],
|
|
"at": [-0.72, 0.72, -0.20], "role": "metal"},
|
|
{"shape": "box", "size": [0.07, 0.40, 0.07],
|
|
"at": [0.72, 0.72, -0.20], "role": "metal"},
|
|
],
|
|
},
|
|
{
|
|
"name": "trash_bin",
|
|
"concept": ("product photo of a single dark green cylindrical street "
|
|
"litter bin with a domed lid, plain light grey studio background"),
|
|
"parts": [
|
|
{"shape": "cylinder", "radius": 0.27, "height": 0.78,
|
|
"at": [0, 0.39, 0], "segments": 14, "role": "body"},
|
|
{"shape": "cylinder", "radius": 0.29, "height": 0.06,
|
|
"at": [0, 0.81, 0], "segments": 14, "role": "metal"},
|
|
{"shape": "cylinder", "radius": 0.24, "height": 0.04,
|
|
"at": [0, 0.86, 0], "segments": 14, "role": "accent"},
|
|
],
|
|
},
|
|
{
|
|
"name": "planter_box",
|
|
"concept": ("product photo of a single square concrete planter box with a "
|
|
"small green shrub, plain light grey studio background"),
|
|
"parts": [
|
|
{"shape": "box", "size": [0.96, 0.54, 0.96],
|
|
"at": [0, 0.27, 0], "role": "stone"},
|
|
{"shape": "box", "size": [0.84, 0.04, 0.84],
|
|
"at": [0, 0.55, 0], "role": "body"},
|
|
{"shape": "cylinder", "radius": 0.30, "height": 0.55,
|
|
"at": [0, 0.86, 0], "segments": 9, "role": "foliage"},
|
|
{"shape": "cylinder", "radius": 0.22, "height": 0.42,
|
|
"at": [-0.18, 1.10, 0.10], "segments": 8, "role": "foliage"},
|
|
{"shape": "cylinder", "radius": 0.18, "height": 0.34,
|
|
"at": [0.20, 1.06, -0.12], "segments": 8, "role": "foliage"},
|
|
],
|
|
},
|
|
{
|
|
"name": "newspaper_box",
|
|
"concept": ("product photo of a single blue newspaper vending box on a "
|
|
"short stand, plain light grey studio background"),
|
|
"parts": [
|
|
{"shape": "box", "size": [0.44, 0.10, 0.38],
|
|
"at": [0, 0.08, 0], "role": "metal"},
|
|
{"shape": "box", "size": [0.42, 0.72, 0.34],
|
|
"at": [0, 0.49, 0], "role": "body"},
|
|
{"shape": "box", "size": [0.34, 0.30, 0.03],
|
|
"at": [0, 0.60, 0.18], "role": "glass"},
|
|
{"shape": "box", "size": [0.44, 0.10, 0.36],
|
|
"at": [0, 0.90, 0], "role": "accent"},
|
|
],
|
|
},
|
|
{
|
|
"name": "fire_hydrant",
|
|
"concept": ("product photo of a single red fire hydrant with silver caps, "
|
|
"plain light grey studio background"),
|
|
"parts": [
|
|
{"shape": "cylinder", "radius": 0.15, "height": 0.62,
|
|
"at": [0, 0.31, 0], "segments": 12, "role": "body"},
|
|
{"shape": "cylinder", "radius": 0.17, "height": 0.06,
|
|
"at": [0, 0.65, 0], "segments": 12, "role": "accent"},
|
|
{"shape": "cylinder", "radius": 0.19, "height": 0.09,
|
|
"at": [0, 0.05, 0], "segments": 12, "role": "metal"},
|
|
{"shape": "box", "size": [0.52, 0.10, 0.12],
|
|
"at": [0, 0.42, 0], "role": "metal"},
|
|
],
|
|
},
|
|
]
|
|
|
|
# Default palette per role (linear-ish sRGB values the GLB stores directly).
|
|
PALETTE = {
|
|
"body": [0.22, 0.30, 0.24],
|
|
"accent": [0.85, 0.72, 0.28],
|
|
"metal": [0.16, 0.17, 0.19],
|
|
"wood": [0.42, 0.28, 0.17],
|
|
"foliage": [0.18, 0.42, 0.20],
|
|
"stone": [0.52, 0.51, 0.48],
|
|
"glass": [0.35, 0.48, 0.62],
|
|
}
|
|
|
|
PBR = {
|
|
"metal": (0.75, 0.35),
|
|
"wood": (0.0, 0.75),
|
|
"foliage": (0.0, 0.9),
|
|
"stone": (0.0, 0.85),
|
|
"glass": (0.1, 0.12),
|
|
"body": (0.15, 0.65),
|
|
"accent": (0.25, 0.5),
|
|
}
|
|
|
|
DIRECTORY_BY_NAME = {prop["name"]: prop for prop in PROPS}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Geometry helpers. Every quad/triangle is wound so its geometric normal agrees
|
|
# with the intended outward normal — Godot culls back faces, so this matters.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _sub(a, b):
|
|
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
|
|
|
|
|
|
def _cross(a, b):
|
|
return (a[1] * b[2] - a[2] * b[1],
|
|
a[2] * b[0] - a[0] * b[2],
|
|
a[0] * b[1] - a[1] * b[0])
|
|
|
|
|
|
def _dot(a, b):
|
|
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
|
|
|
|
def _normalize(v):
|
|
length = math.sqrt(_dot(v, v))
|
|
if length <= 1e-9:
|
|
return (0.0, 1.0, 0.0)
|
|
return (v[0] / length, v[1] / length, v[2] / length)
|
|
|
|
|
|
class MeshData:
|
|
"""Accumulates one flat-shaded, indexed triangle list."""
|
|
|
|
def __init__(self):
|
|
self.positions = []
|
|
self.normals = []
|
|
self.indices = []
|
|
|
|
def triangle_count(self) -> int:
|
|
return len(self.indices) // 3
|
|
|
|
def add_quad(self, corners, want_normal):
|
|
a, b, c = corners[0], corners[1], corners[2]
|
|
normal = _cross(_sub(b, a), _sub(c, a))
|
|
if _dot(normal, want_normal) < 0.0:
|
|
corners = [corners[0], corners[3], corners[2], corners[1]]
|
|
normal = (-normal[0], -normal[1], -normal[2])
|
|
normal = _normalize(normal)
|
|
base = len(self.positions)
|
|
for corner in corners:
|
|
self.positions.append(corner)
|
|
self.normals.append(normal)
|
|
self.indices.extend([base, base + 1, base + 2,
|
|
base, base + 2, base + 3])
|
|
|
|
def add_triangle(self, v0, v1, v2, want_normal):
|
|
normal = _cross(_sub(v1, v0), _sub(v2, v0))
|
|
if _dot(normal, want_normal) < 0.0:
|
|
v1, v2 = v2, v1
|
|
normal = (-normal[0], -normal[1], -normal[2])
|
|
normal = _normalize(normal)
|
|
base = len(self.positions)
|
|
for vertex in (v0, v1, v2):
|
|
self.positions.append(vertex)
|
|
self.normals.append(normal)
|
|
self.indices.extend([base, base + 1, base + 2])
|
|
|
|
|
|
def _add_box(mesh: MeshData, size, at):
|
|
hx, hy, hz = size[0] / 2.0, size[1] / 2.0, size[2] / 2.0
|
|
cx, cy, cz = at
|
|
faces = [
|
|
((0, 0, 1), [(-hx, -hy, hz), (hx, -hy, hz),
|
|
(hx, hy, hz), (-hx, hy, hz)]),
|
|
((0, 0, -1), [(hx, -hy, -hz), (-hx, -hy, -hz),
|
|
(-hx, hy, -hz), (hx, hy, -hz)]),
|
|
((1, 0, 0), [(hx, -hy, hz), (hx, -hy, -hz),
|
|
(hx, hy, -hz), (hx, hy, hz)]),
|
|
((-1, 0, 0), [(-hx, -hy, -hz), (-hx, -hy, hz),
|
|
(-hx, hy, hz), (-hx, hy, -hz)]),
|
|
((0, 1, 0), [(-hx, hy, hz), (hx, hy, hz),
|
|
(hx, hy, -hz), (-hx, hy, -hz)]),
|
|
((0, -1, 0), [(-hx, -hy, -hz), (hx, -hy, -hz),
|
|
(hx, -hy, hz), (-hx, -hy, hz)]),
|
|
]
|
|
for normal, corners in faces:
|
|
mesh.add_quad([(cx + x, cy + y, cz + z)
|
|
for x, y, z in corners], normal)
|
|
|
|
|
|
def _add_cylinder(mesh: MeshData, radius, height, at, segments=12, top_radius=None):
|
|
r0 = float(radius)
|
|
r1 = r0 if top_radius is None else float(top_radius)
|
|
cx, cy, cz = at
|
|
y0 = cy - height / 2.0
|
|
y1 = cy + height / 2.0
|
|
segments = max(3, int(segments))
|
|
for i in range(segments):
|
|
t0 = 2.0 * math.pi * i / segments
|
|
t1 = 2.0 * math.pi * (i + 1) / segments
|
|
b0 = (cx + r0 * math.cos(t0), y0, cz + r0 * math.sin(t0))
|
|
b1 = (cx + r0 * math.cos(t1), y0, cz + r0 * math.sin(t1))
|
|
u1 = (cx + r1 * math.cos(t1), y1, cz + r1 * math.sin(t1))
|
|
u0 = (cx + r1 * math.cos(t0), y1, cz + r1 * math.sin(t0))
|
|
want = ((math.cos(t0) + math.cos(t1)) * 0.5, 0.0,
|
|
(math.sin(t0) + math.sin(t1)) * 0.5)
|
|
mesh.add_quad([b0, b1, u1, u0], want)
|
|
bottom_center = (cx, y0, cz)
|
|
top_center = (cx, y1, cz)
|
|
mesh.add_triangle(bottom_center, b0, b1, (0.0, -1.0, 0.0))
|
|
mesh.add_triangle(top_center, u0, u1, (0.0, 1.0, 0.0))
|
|
|
|
|
|
def build_part_mesh(part: dict) -> MeshData:
|
|
"""Geometry for a single part. Each part becomes one glTF primitive so it
|
|
can carry its own material."""
|
|
mesh = MeshData()
|
|
if part["shape"] == "box":
|
|
_add_box(mesh, part["size"], part["at"])
|
|
elif part["shape"] == "cylinder":
|
|
_add_cylinder(mesh, part["radius"], part["height"], part["at"],
|
|
part.get("segments", 12), part.get("top_radius"))
|
|
else:
|
|
raise ValueError(f"unknown shape {part['shape']!r}")
|
|
return mesh
|
|
|
|
|
|
def build_prop_mesh(prop: dict) -> MeshData:
|
|
"""All parts merged into one mesh (stats and tests; the GLB keeps them
|
|
separate so each part keeps its material)."""
|
|
merged = MeshData()
|
|
for part in prop["parts"]:
|
|
mesh = build_part_mesh(part)
|
|
base = len(merged.positions)
|
|
merged.positions.extend(mesh.positions)
|
|
merged.normals.extend(mesh.normals)
|
|
merged.indices.extend(index + base for index in mesh.indices)
|
|
return merged
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AI concept palette (optional). The image only supplies colours.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_env_file(path=None):
|
|
path = Path(path) if path else ROOT / ".env"
|
|
if not path.is_file():
|
|
return
|
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
os.environ.setdefault(key.strip(), value.strip().strip("\"'"))
|
|
|
|
|
|
def _hsv(rgb):
|
|
"""Hue (0..1), saturation (0..1), value (0..1) for a 0..1 RGB triple."""
|
|
r, g, b = rgb
|
|
mx, mn = max(rgb), min(rgb)
|
|
delta = mx - mn
|
|
if mx <= 0.0:
|
|
return 0.0, 0.0, 0.0
|
|
sat = delta / mx
|
|
if delta <= 1e-6:
|
|
return 0.0, sat, mx
|
|
if mx == r:
|
|
hue = ((g - b) / delta) % 6.0
|
|
elif mx == g:
|
|
hue = (b - r) / delta + 2.0
|
|
else:
|
|
hue = (r - g) / delta + 4.0
|
|
return hue / 6.0, sat, mx
|
|
|
|
|
|
def _luminance(rgb):
|
|
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
|
|
|
|
|
|
def dominant_colors(image, count=4):
|
|
"""Most characteristic colours of the concept subject, 0..1 RGB triples.
|
|
|
|
A studio backdrop is a large grey gradient, so raw pixel frequency just
|
|
returns the background. Two things fix that: read the centre of the frame
|
|
(the subject), and rank colours by ``count * (0.25 + saturation)`` so a
|
|
saturated wood or paint beats a big flat grey.
|
|
"""
|
|
source = image.convert("RGB")
|
|
width, height = source.size
|
|
box = (int(width * 0.22), int(height * 0.22),
|
|
int(width * 0.78), int(height * 0.78))
|
|
small = source.crop(box).resize((96, 96))
|
|
quantized = small.quantize(colors=max(8, count * 4), method=2)
|
|
palette = quantized.getpalette() or []
|
|
scored = []
|
|
for tally, index in (quantized.getcolors() or []):
|
|
r, g, b = palette[index * 3:index * 3 + 3]
|
|
rgb = [r / 255.0, g / 255.0, b / 255.0]
|
|
_hue, sat, value = _hsv(rgb)
|
|
if value > 0.96 and sat < 0.05:
|
|
continue # white studio backdrop
|
|
if value < 0.05:
|
|
continue # pure shadow
|
|
scored.append((tally * (0.25 + sat), rgb))
|
|
scored.sort(key=lambda item: item[0], reverse=True)
|
|
return [rgb for _score, rgb in scored[:count]]
|
|
|
|
|
|
def assign_roles(colors, base=None):
|
|
"""Map sampled colours onto material roles using their character.
|
|
|
|
Frequency alone cannot tell a black cast-iron leg from a darker wood slat.
|
|
The bench concept has warm teak slats and near-black legs; the planter
|
|
concept has grey concrete and a green shrub. Assign by hue and value
|
|
instead: the darkest tone is metal, a warm saturated tone is wood, a light
|
|
desaturated tone is stone, a green tone is foliage.
|
|
"""
|
|
palette = {role: list(rgb) for role, rgb in (base or PALETTE).items()}
|
|
if not colors:
|
|
return palette
|
|
|
|
def sat(rgb):
|
|
return _hsv(rgb)[1]
|
|
|
|
def hue(rgb):
|
|
return _hsv(rgb)[0]
|
|
|
|
darkest = min(colors, key=_luminance)
|
|
if _luminance(darkest) < 0.35:
|
|
palette["metal"] = darkest
|
|
|
|
warm = [c for c in colors
|
|
if 0.02 <= hue(c) <= 0.16 and sat(c) >= 0.25
|
|
and 0.06 <= _luminance(c) <= 0.8]
|
|
if warm:
|
|
palette["wood"] = max(warm, key=sat)
|
|
|
|
greens = [c for c in colors
|
|
if 0.20 <= hue(c) <= 0.45 and sat(c) >= 0.2
|
|
and _luminance(c) >= 0.10]
|
|
if greens:
|
|
palette["foliage"] = max(greens, key=sat)
|
|
|
|
greys = [c for c in colors
|
|
if sat(c) < 0.18 and 0.08 <= _luminance(c) <= 0.85]
|
|
if greys:
|
|
# Clamp concrete: a near-white sample renders as a glowing crate under the
|
|
# street lamps, so keep stone a believable mid grey.
|
|
stone = max(greys, key=_luminance)
|
|
palette["stone"] = [min(0.60, channel) for channel in stone]
|
|
|
|
candidates = [c for c in colors
|
|
if sat(c) >= 0.12 and _luminance(c) >= 0.08]
|
|
if candidates:
|
|
body = max(candidates, key=lambda c: sat(c) * (0.3 + _luminance(c)))
|
|
palette["body"] = body
|
|
others = [c for c in candidates
|
|
if c is not body and sat(c) > sat(body) + 0.05]
|
|
if others:
|
|
palette["accent"] = max(others, key=sat)
|
|
else:
|
|
palette["accent"] = [min(1.0, channel * 1.35 + 0.05)
|
|
for channel in body]
|
|
return palette
|
|
|
|
|
|
def palette_for_prop(prop_name: str) -> dict:
|
|
"""Role palette for a prop: defaults, refined by its AI concept image."""
|
|
concept = CONCEPTS_DIR / f"{prop_name}.png"
|
|
if not concept.is_file():
|
|
return {role: list(rgb) for role, rgb in PALETTE.items()}
|
|
try:
|
|
from PIL import Image
|
|
with Image.open(concept) as image:
|
|
image.load()
|
|
colors = dominant_colors(image)
|
|
except Exception as exc: # noqa: BLE001 - a bad concept must not break build
|
|
print(
|
|
f"warning: could not read {concept.name}: {exc}", file=sys.stderr)
|
|
return {role: list(rgb) for role, rgb in PALETTE.items()}
|
|
return assign_roles(colors)
|
|
|
|
|
|
def generate_concepts(only="", model=DEFAULT_CONCEPT_MODEL, size=1024,
|
|
force=False) -> list:
|
|
"""Ask the image model for one concept sheet per prop (needs API key)."""
|
|
_load_env_file()
|
|
import generate_textures
|
|
CONCEPTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
written = []
|
|
for prop in PROPS:
|
|
if only and only not in prop["name"]:
|
|
continue
|
|
path = CONCEPTS_DIR / f"{prop['name']}.png"
|
|
if path.is_file() and not force:
|
|
print(f"skip concept {prop['name']} (exists; --force to replace)")
|
|
continue
|
|
print(f"concept {prop['name']} ...")
|
|
image = generate_textures.generate_image(
|
|
f"{prop['concept']}. Three-quarter view, soft studio lighting, "
|
|
"no text, no watermark.", model, size, transparent=False)
|
|
image.save(path)
|
|
written.append(path)
|
|
print(f"wrote {path.relative_to(ROOT)} {image.size}")
|
|
return written
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# glTF binary writer.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _pad(data: bytes, fill: bytes) -> bytes:
|
|
remainder = len(data) % 4
|
|
return data if remainder == 0 else data + fill * (4 - remainder)
|
|
|
|
|
|
def srgb_to_linear(channel: float) -> float:
|
|
"""Convert one 0..1 sRGB channel to linear.
|
|
|
|
glTF stores `baseColorFactor` in linear space, while the catalogue palettes
|
|
(and the colours sampled from a concept PNG) are sRGB. Writing sRGB numbers
|
|
straight into the factor makes every prop render several shades too light,
|
|
which is why the first build's bins and newspaper boxes looked pastel.
|
|
"""
|
|
if channel <= 0.04045:
|
|
return channel / 12.92
|
|
return ((channel + 0.055) / 1.055) ** 2.4
|
|
|
|
|
|
def _linear_rgba(color) -> list:
|
|
return [round(srgb_to_linear(float(c)), 5) for c in color[:3]] + [1.0]
|
|
|
|
|
|
def build_glb(prop: dict, palette: dict = None) -> bytes:
|
|
"""Serialise one prop as a self-contained .glb (glTF 2.0) file."""
|
|
palette = palette or PALETTE
|
|
|
|
binary = bytearray()
|
|
buffer_views = []
|
|
accessors = []
|
|
materials = []
|
|
primitives = []
|
|
|
|
def add_view(payload: bytes, target: int) -> int:
|
|
buffer_views.append({
|
|
"buffer": 0,
|
|
"byteOffset": len(binary),
|
|
"byteLength": len(payload),
|
|
"target": target,
|
|
})
|
|
binary.extend(_pad(payload, b"\x00"))
|
|
return len(buffer_views) - 1
|
|
|
|
# One primitive per part: the parts share a mesh but need separate
|
|
# materials, and a glTF primitive is the unit of material assignment.
|
|
for part in prop["parts"]:
|
|
mesh = build_part_mesh(part)
|
|
positions = b"".join(struct.pack("<3f", *p) for p in mesh.positions)
|
|
normals = b"".join(struct.pack("<3f", *n) for n in mesh.normals)
|
|
indices = b"".join(struct.pack("<H", i) for i in mesh.indices)
|
|
pos_view = add_view(positions, 34962)
|
|
nrm_view = add_view(normals, 34962)
|
|
idx_view = add_view(indices, 34963)
|
|
|
|
xs = [p[0] for p in mesh.positions] or [0.0]
|
|
ys = [p[1] for p in mesh.positions] or [0.0]
|
|
zs = [p[2] for p in mesh.positions] or [0.0]
|
|
pos_accessor = len(accessors)
|
|
accessors.append({"bufferView": pos_view, "componentType": 5126,
|
|
"count": len(mesh.positions), "type": "VEC3",
|
|
"min": [min(xs), min(ys), min(zs)],
|
|
"max": [max(xs), max(ys), max(zs)]})
|
|
nrm_accessor = len(accessors)
|
|
accessors.append({"bufferView": nrm_view, "componentType": 5126,
|
|
"count": len(mesh.normals), "type": "VEC3"})
|
|
idx_accessor = len(accessors)
|
|
accessors.append({"bufferView": idx_view, "componentType": 5123,
|
|
"count": len(mesh.indices), "type": "SCALAR"})
|
|
|
|
role = part.get("role", "body")
|
|
color = palette.get(role, PALETTE[role])
|
|
metallic, roughness = PBR.get(role, PBR["body"])
|
|
materials.append({
|
|
"name": role,
|
|
"doubleSided": False,
|
|
"pbrMetallicRoughness": {
|
|
"baseColorFactor": _linear_rgba(color),
|
|
"metallicFactor": metallic,
|
|
"roughnessFactor": roughness,
|
|
},
|
|
})
|
|
primitives.append({
|
|
"attributes": {"POSITION": pos_accessor, "NORMAL": nrm_accessor},
|
|
"indices": idx_accessor,
|
|
"material": len(materials) - 1,
|
|
})
|
|
|
|
gltf = {
|
|
"asset": {"version": "2.0", "generator": "step-line model_pipeline"},
|
|
"scene": 0,
|
|
"scenes": [{"name": prop["name"], "nodes": [0]}],
|
|
"nodes": [{"name": prop["name"], "mesh": 0}],
|
|
"meshes": [{"name": prop["name"], "primitives": primitives}],
|
|
"materials": materials,
|
|
"accessors": accessors,
|
|
"bufferViews": buffer_views,
|
|
"buffers": [{"byteLength": len(binary)}],
|
|
}
|
|
|
|
json_chunk = _pad(json.dumps(gltf, separators=(",", ":")).encode("utf-8"),
|
|
b" ")
|
|
bin_chunk = _pad(bytes(binary), b"\x00")
|
|
total = 12 + 8 + len(json_chunk) + 8 + len(bin_chunk)
|
|
out = bytearray()
|
|
out.extend(struct.pack("<III", 0x46546C67, 2, total))
|
|
out.extend(struct.pack("<II", len(json_chunk), 0x4E4F534A))
|
|
out.extend(json_chunk)
|
|
out.extend(struct.pack("<II", len(bin_chunk), 0x004E4942))
|
|
out.extend(bin_chunk)
|
|
return bytes(out)
|
|
|
|
|
|
def build(only="", quiet=False) -> list:
|
|
MODELS_DIR.mkdir(parents=True, exist_ok=True)
|
|
written = []
|
|
for prop in PROPS:
|
|
if only and only not in prop["name"]:
|
|
continue
|
|
palette = palette_for_prop(prop["name"])
|
|
payload = build_glb(prop, palette)
|
|
path = MODELS_DIR / f"{prop['name']}.glb"
|
|
path.write_bytes(payload)
|
|
written.append(path)
|
|
if not quiet:
|
|
mesh = build_prop_mesh(prop)
|
|
print(f"wrote {path.relative_to(ROOT)} "
|
|
f"{mesh.triangle_count()} tris {len(payload)} bytes")
|
|
return written
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Audit.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def audit_glb(path: Path) -> dict:
|
|
"""Check a .glb file's container, JSON chunk, and triangle count."""
|
|
issues = []
|
|
if not path.is_file():
|
|
return {"path": str(path), "ok": False,
|
|
"issues": ["missing file"], "triangles": 0}
|
|
data = path.read_bytes()
|
|
if len(data) < 20:
|
|
return {"path": str(path), "ok": False,
|
|
"issues": ["file too short"], "triangles": 0}
|
|
magic, version, length = struct.unpack_from("<III", data, 0)
|
|
if magic != 0x46546C67:
|
|
issues.append("bad magic (not a glTF binary)")
|
|
if version != 2:
|
|
issues.append(f"unexpected glTF version {version}")
|
|
if length != len(data):
|
|
issues.append(f"header length {length} != file size {len(data)}")
|
|
json_len, json_type = struct.unpack_from("<II", data, 12)
|
|
if json_type != 0x4E4F534A:
|
|
issues.append("first chunk is not JSON")
|
|
gltf = {}
|
|
try:
|
|
gltf = json.loads(data[20:20 + json_len].decode("utf-8"))
|
|
except (ValueError, UnicodeDecodeError) as exc:
|
|
issues.append(f"unreadable JSON chunk: {exc}")
|
|
triangles = 0
|
|
for mesh in gltf.get("meshes", []):
|
|
for primitive in mesh.get("primitives", []):
|
|
indices = primitive.get("indices")
|
|
if indices is None:
|
|
issues.append("primitive without indices")
|
|
continue
|
|
try:
|
|
triangles += gltf["accessors"][indices]["count"] // 3
|
|
except (KeyError, IndexError, TypeError):
|
|
issues.append("primitive references a missing accessor")
|
|
if triangles <= 0:
|
|
issues.append("no triangles")
|
|
if not gltf.get("materials"):
|
|
issues.append("no materials")
|
|
for buffer in gltf.get("buffers", []):
|
|
if int(buffer.get("byteLength", 0)) > len(data):
|
|
issues.append("buffer larger than the file")
|
|
return {"path": str(path), "ok": not issues, "issues": issues,
|
|
"triangles": triangles}
|
|
|
|
|
|
def audit(only="", as_json=False) -> int:
|
|
results = []
|
|
for prop in PROPS:
|
|
if only and only not in prop["name"]:
|
|
continue
|
|
results.append(audit_glb(MODELS_DIR / f"{prop['name']}.glb"))
|
|
if as_json:
|
|
print(json.dumps(results, indent=2))
|
|
else:
|
|
for result in results:
|
|
name = Path(result["path"]).name
|
|
state = "OK " if result["ok"] else "BAD "
|
|
print(f"{state} {name:24} tris={result['triangles']}")
|
|
for issue in result["issues"]:
|
|
print(f" - {issue}")
|
|
return 0 if all(result["ok"] for result in results) else 1
|
|
|
|
|
|
def list_props(args) -> int:
|
|
for prop in PROPS:
|
|
concept = CONCEPTS_DIR / f"{prop['name']}.png"
|
|
built = MODELS_DIR / f"{prop['name']}.glb"
|
|
mesh = build_prop_mesh(prop)
|
|
print(f"{prop['name']:16} parts={len(prop['parts']):2} "
|
|
f"tris={mesh.triangle_count():4} "
|
|
f"glb={'yes' if built.is_file() else '- '} "
|
|
f"concept={'yes' if concept.is_file() else '-'}")
|
|
print(f"Total: {len(PROPS)} props")
|
|
return 0
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description="Build low-poly prop models for Step Line.")
|
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
|
|
listing = commands.add_parser("list", help="print the prop catalogue")
|
|
listing.set_defaults(handler=list_props)
|
|
|
|
building = commands.add_parser(
|
|
"build", help="write assets/models/generated/*.glb")
|
|
building.add_argument("--only", default="",
|
|
help="only build props whose name contains this text")
|
|
building.set_defaults(handler=lambda args: 0 if build(args.only) else 1)
|
|
|
|
auditing = commands.add_parser(
|
|
"audit", help="validate the built GLB files")
|
|
auditing.add_argument("--only", default="")
|
|
auditing.add_argument("--json", action="store_true")
|
|
auditing.set_defaults(handler=lambda args: audit(args.only, args.json))
|
|
|
|
concept = commands.add_parser(
|
|
"concept", help="generate concept images via OpenRouter")
|
|
concept.add_argument("--only", default="")
|
|
concept.add_argument("--model", default=DEFAULT_CONCEPT_MODEL)
|
|
concept.add_argument("--size", type=int, default=1024)
|
|
concept.add_argument("--force", action="store_true")
|
|
concept.set_defaults(
|
|
handler=lambda args: 0 if generate_concepts(
|
|
args.only, args.model, args.size, args.force) is not None else 1)
|
|
|
|
return parser
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
try:
|
|
return args.handler(args)
|
|
except (OSError, ValueError) as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|