Files
security-game/tools/model_pipeline.py
T
zwitschi afa1d6beec
Full build / Import + headless tests (push) Successful in 36s
Full build / Full desktop build + optional release (push) Successful in 4m17s
v0.3.0: rendering pass, generated props, selection model and Web build
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.
2026-10-31 23:07:41 +01:00

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())