Files
zwitschi 3989d7b2e5 Rename Step Line to Gig Line
Update the game name in project.godot, export presets, UI/marquee/title strings, packaging (AppImage/NSIS/desktop/appdata), tools, docs, and the generated prop GLBs. Packaging assets and README screenshots are regenerated. Headless suite 209/209 and Python tooling tests pass.
2026-11-01 12:52:17 +01:00

934 lines
36 KiB
Python

#!/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/<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.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("<H", i) for i in indices)
pos_view = add_view(pos_payload, 34962)
nrm_view = add_view(nrm_payload, 34962)
uv_view = add_view(uv_payload, 34962)
idx_view = add_view(idx_payload, 34963)
xs = [p[0] for p in positions] or [0.0]
ys = [p[1] for p in positions] or [0.0]
zs = [p[2] for p in positions] or [0.0]
pos_accessor = len(accessors)
accessors.append({"bufferView": pos_view, "componentType": 5126,
"count": len(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(normals), "type": "VEC3"})
uv_accessor = len(accessors)
accessors.append({"bufferView": uv_view, "componentType": 5126,
"count": len(uvs), "type": "VEC2"})
idx_accessor = len(accessors)
accessors.append({"bufferView": idx_view, "componentType": 5123,
"count": len(indices), "type": "SCALAR"})
base_view = add_view(atlas["base_png"])
mr_view = add_view(atlas["metal_rough_png"])
gltf = {
"asset": {"version": "2.0", "generator": "gig-line model_pipeline"},
"scene": 0,
"scenes": [{"name": prop["name"], "nodes": [0]}],
"nodes": [{"name": prop["name"], "mesh": 0}],
"meshes": [{
"name": prop["name"],
"primitives": [{
"attributes": {"POSITION": pos_accessor,
"NORMAL": nrm_accessor,
"TEXCOORD_0": uv_accessor},
"indices": idx_accessor,
"material": 0,
}],
}],
"materials": [{
"name": "prop_palette",
"doubleSided": False,
"pbrMetallicRoughness": {
"baseColorTexture": {"index": 0},
"metallicRoughnessTexture": {"index": 1},
"metallicFactor": 1.0,
"roughnessFactor": 1.0,
},
}],
"textures": [
{"sampler": 0, "source": 0},
{"sampler": 0, "source": 1},
],
"samplers": [{
"magFilter": 9728, # NEAREST, so a palette cell stays a flat colour
"minFilter": 9728,
"wrapS": 33071, # CLAMP_TO_EDGE
"wrapT": 33071,
}],
"images": [
{"bufferView": base_view, "mimeType": "image/png"},
{"bufferView": mr_view, "mimeType": "image/png"},
],
"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.
# ---------------------------------------------------------------------------
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("<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")
materials = gltf.get("materials", [])
if not materials:
issues.append("no materials")
elif len(materials) != 1:
issues.append(f"expected 1 shared material, found {len(materials)}")
else:
pbr = materials[0].get("pbrMetallicRoughness", {})
if "baseColorTexture" not in pbr:
issues.append("shared material has no baseColorTexture")
if "metallicRoughnessTexture" not in pbr:
issues.append("shared material has no metallicRoughnessTexture")
if len(gltf.get("textures", [])) < 2:
issues.append("expected colour and metallic/roughness textures")
if len(gltf.get("images", [])) < 2:
issues.append("expected colour and metallic/roughness atlas images")
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
if "TEXCOORD_0" not in primitive.get("attributes", {}):
issues.append("primitive has no atlas UVs (TEXCOORD_0)")
for buffer in gltf.get("buffers", []):
if int(buffer.get("byteLength", 0)) > 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("<III", data, 12 + 8 + json_len)
binary = data[12 + 8 + json_len + 8:12 + 8 + json_len + 8 + bin_len]
vertices = 0
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
attributes = primitive.get("attributes", {})
positions = _read_float_accessor(
gltf, binary, attributes["POSITION"])
vertices += len(positions)
if positions:
min_y = min(p[1] for p in positions)
if abs(min_y) > 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())