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.
This commit is contained in:
1 parent
f763cff744
commit
afa1d6beec
48 files changed
+2602
-147
No files matched your search
@@ -0,0 +1,732 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user