#!/usr/bin/env python3 """Build the low-poly prop models used in the Gig 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 and stay inside the art budget (<= 400 triangles, <= 600 vertices per prop, no degenerate triangles, UVs in the atlas, base at y ~= 0, one material). 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/.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.05, 0], "role": "metal"}, {"shape": "box", "size": [0.42, 0.72, 0.34], "at": [0, 0.46, 0], "role": "body"}, {"shape": "box", "size": [0.34, 0.30, 0.03], "at": [0, 0.57, 0.18], "role": "glass"}, {"shape": "box", "size": [0.44, 0.10, 0.36], "at": [0, 0.87, 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. # --------------------------------------------------------------------------- ATLAS_CELL = 8 def _next_pow2(value: int) -> int: size = 1 while size < value: size *= 2 return size def _png_bytes(width: int, height: int, rgba: bytearray) -> bytes: """Encode raw RGBA rows as a PNG without requiring Pillow.""" import zlib rows = bytearray() stride = width * 4 for y in range(height): rows.append(0) # filter type 0 rows.extend(rgba[y * stride:(y + 1) * stride]) def chunk(tag: bytes, payload: bytes) -> bytes: return (struct.pack(">I", len(payload)) + tag + payload + struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF)) header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(bytes(rows), 9)) + chunk(b"IEND", b"")) def atlas_for(prop: dict, palette: dict = None) -> dict: """Pack every colour this prop uses into one tiny texture atlas. Each part gets a constant UV pointing at its palette cell, so all parts can share a single material instead of one material per part. The same layout is written into a metallic/roughness atlas (glTF reads roughness from G and metallic from B), keeping the per-role surface response. """ palette = palette or PALETTE slots = {} for part in prop["parts"]: role = part.get("role", "body") colour = tuple(round(float(c), 5) for c in palette.get(role, PALETTE[role])[:3]) metallic, roughness = PBR.get(role, PBR["body"]) slots[role] = (colour, metallic, roughness) roles = sorted(slots.keys()) grid = max(1, int(math.ceil(math.sqrt(len(roles))))) size = _next_pow2(grid * ATLAS_CELL) base = bytearray(size * size * 4) metal_rough = bytearray(size * size * 4) role_uv = {} for index, role in enumerate(roles): colour, metallic, roughness = slots[role] col = index % grid row = index // grid x0 = col * ATLAS_CELL y0 = row * ATLAS_CELL rgb = bytes((int(round(colour[0] * 255)), int(round(colour[1] * 255)), int(round(colour[2] * 255)), 255)) mr = bytes((0, int(round(roughness * 255)), int(round(metallic * 255)), 255)) for y in range(ATLAS_CELL): for x in range(ATLAS_CELL): offset = ((y0 + y) * size + (x0 + x)) * 4 base[offset:offset + 4] = rgb metal_rough[offset:offset + 4] = mr role_uv[role] = ((x0 + ATLAS_CELL / 2.0) / size, (y0 + ATLAS_CELL / 2.0) / size) return { "size": size, "roles": roles, "role_uv": role_uv, "colours": {role: slots[role][0] for role in roles}, "base_png": _png_bytes(size, size, base), "metal_rough_png": _png_bytes(size, size, metal_rough), } def _pad(data: bytes, fill: bytes) -> bytes: remainder = len(data) % 4 return data if remainder == 0 else data + fill * (4 - remainder) def build_glb(prop: dict, palette: dict = None, atlas: dict = None) -> bytes: """Serialise one prop as a self-contained .glb (glTF 2.0) file. All parts share one material backed by a colour atlas and a metallic/roughness atlas, so the prop draws with a single material. """ palette = palette or PALETTE atlas = atlas or atlas_for(prop, palette) positions = [] normals = [] uvs = [] indices = [] for part in prop["parts"]: mesh = build_part_mesh(part) base = len(positions) uv = atlas["role_uv"][part.get("role", "body")] for position, normal in zip(mesh.positions, mesh.normals): positions.append(position) normals.append(normal) uvs.append(uv) indices.extend(index + base for index in mesh.indices) binary = bytearray() buffer_views = [] accessors = [] def add_view(payload: bytes, target=None) -> int: view = { "buffer": 0, "byteOffset": len(binary), "byteLength": len(payload), } if target is not None: view["target"] = target buffer_views.append(view) binary.extend(_pad(payload, b"\x00")) return len(buffer_views) - 1 pos_payload = b"".join(struct.pack("<3f", *p) for p in positions) nrm_payload = b"".join(struct.pack("<3f", *n) for n in normals) uv_payload = b"".join(struct.pack("<2f", *uv) for uv in uvs) idx_payload = b"".join(struct.pack(" 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. # --------------------------------------------------------------------------- MAX_TRIANGLES = 400 MAX_VERTICES = 600 _DEGENERATE_AREA2 = 1e-9 def _read_float_accessor(gltf: dict, binary: bytes, index: int) -> list: """Decode a float SCALAR/VEC2/VEC3 accessor into a list of tuples/values.""" accessor = gltf["accessors"][index] count = int(accessor["count"]) kind = accessor["type"] width = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}[kind] view = gltf["bufferViews"][accessor["bufferView"]] offset = int(view.get("byteOffset", 0)) + int(accessor.get("byteOffset", 0)) stride = int(view.get("byteStride", width * 4)) values = [] for i in range(count): base = offset + i * stride row = struct.unpack_from("<" + "f" * width, binary, base) values.append(row[0] if width == 1 else row) return values def _read_index_accessor(gltf: dict, binary: bytes, index: int) -> list: accessor = gltf["accessors"][index] width = {5121: 1, 5123: 2, 5125: 4}.get(int(accessor["componentType"]), 2) fmt = {1: "B", 2: "H", 4: "I"}[width] view = gltf["bufferViews"][accessor["bufferView"]] offset = int(view.get("byteOffset", 0)) + int(accessor.get("byteOffset", 0)) stride = int(view.get("byteStride", width)) out = [] for i in range(int(accessor["count"])): out.append(struct.unpack_from("<" + fmt, binary, offset + i * stride)[0]) return out def _triangle_area2(a, b, c) -> float: ab = (b[0] - a[0], b[1] - a[1], b[2] - a[2]) ac = (c[0] - a[0], c[1] - a[1], c[2] - a[2]) cross = (ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0]) return cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2] def audit_glb(path: Path) -> dict: """Check a .glb file's container, JSON chunk, and mesh contract. The mesh must stay in the prop budget, have no degenerate triangles, keep atlas UVs inside [0, 1], sit on the ground (min y ~= 0), and use exactly one atlas material. """ 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(" len(data): issues.append("buffer larger than the file") if triangles > MAX_TRIANGLES: issues.append(f"triangle budget exceeded ({triangles} > {MAX_TRIANGLES})") # Decode geometry for the deeper checks. A malformed buffer view is already # reported above, so skip quietly when the binary chunk is unusable. try: _, bin_len, _ = struct.unpack_from(" 0.02: issues.append( f"prop does not sit on the ground (min y {min_y:.3f})") uvs = _read_float_accessor( gltf, binary, attributes["TEXCOORD_0"]) if any(not (0.0 <= u <= 1.0 and 0.0 <= v <= 1.0) for u, v in uvs): issues.append("atlas UV outside [0, 1]") indices = _read_index_accessor( gltf, binary, primitive["indices"]) for tri in range(0, len(indices), 3): a, b, c = (positions[indices[tri + i]] for i in range(3)) if _triangle_area2(a, b, c) < _DEGENERATE_AREA2: issues.append("degenerate triangle") break if vertices > MAX_VERTICES: issues.append(f"vertex budget exceeded ({vertices} > {MAX_VERTICES})") except (KeyError, IndexError, ValueError, struct.error) as exc: issues.append(f"unreadable geometry: {exc}") 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 Gig 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())