Each generated prop used one material per part (body, accent, metal...). The model pipeline now packs a prop's palette into one power-of-two colour atlas plus a matching metallic/roughness atlas and emits a single material: - atlas_for(): 8x8 cells per palette role, colours in sRGB, roughness in the G and metallic in the B channel of the second atlas; NEAREST sampling keeps cells flat. - build_glb(): every part merges into one primitive with constant per-vertex atlas UVs; the material uses baseColorTexture + metallicRoughnessTexture with factors 1.0. - audit_glb(): fails unless a prop has exactly one material, both atlas textures, two images, and TEXCOORD_0 on every primitive. - Removed the now-unused sRGB->linear factor helpers; colour travels in the texture. Verification: headless 181 -> 182 (new "one material per prop scene" test), Python tooling 53/53 (atlas determinism/size/UV/palette-override tests replace the old factor tests), model audit OK for all six props, screenshots regenerated and screen_designs.py compare still 6 good / 0 bad. Version 0.3.1.
847 lines
32 KiB
Python
847 lines
32 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.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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": "step-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.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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")
|
|
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")
|
|
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())
|