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
@@ -10,6 +10,7 @@
|
||||
# build/step-line-linux-<version>.x86_64
|
||||
# build/step-line-windows-<version>.exe
|
||||
# build/step-line-macos-<version>.zip
|
||||
# build/step-line-web-<version>.zip (Godot web build: static hosting, no threads)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
@@ -28,6 +29,26 @@ echo "==> Exporting Windows Desktop"
|
||||
echo "==> Exporting macOS"
|
||||
"$GODOT" --headless --export-release "macOS" "build/step-line-macos-${VERSION}.zip"
|
||||
|
||||
echo "==> Exporting Web (no threads, static-hosting friendly)"
|
||||
rm -rf build/web
|
||||
mkdir -p build/web
|
||||
"$GODOT" --headless --export-release "Web" "build/web/index.html"
|
||||
# Ship the web build as a single zip so it can be downloaded and served as-is.
|
||||
rm -f "build/step-line-web-${VERSION}.zip"
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
(cd build/web && zip -q -r "../step-line-web-${VERSION}.zip" .)
|
||||
else
|
||||
python3 - "$PWD/build/web" "$PWD/build/step-line-web-${VERSION}.zip" <<'PYZIP'
|
||||
import os, sys, zipfile
|
||||
src, out = sys.argv[1], sys.argv[2]
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for root, _dirs, files in os.walk(src):
|
||||
for name in files:
|
||||
path = os.path.join(root, name)
|
||||
archive.write(path, os.path.relpath(path, src))
|
||||
PYZIP
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Artifacts:"
|
||||
ls -la build/*"${VERSION}"*
|
||||
|
||||
@@ -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())
|
||||
@@ -66,6 +66,7 @@ portables=(
|
||||
extras=(
|
||||
"build/step-line-setup-$VERSION.exe"
|
||||
"build/step-line-$VERSION-x86_64.AppImage"
|
||||
"build/step-line-web-$VERSION.zip"
|
||||
)
|
||||
all_artifacts=("${portables[@]}")
|
||||
for f in "${extras[@]}"; do
|
||||
|
||||
+208
-10
@@ -31,8 +31,12 @@ Two commands:
|
||||
regressions; they are NOT a pixel-exact or semantic match. A
|
||||
vision-model pass is the documented next step (see PLAN.md).
|
||||
|
||||
list print the catalogue: name, kind, whether a reference design
|
||||
exists, and which capture pairs with it.
|
||||
|
||||
Run from the repo root:
|
||||
|
||||
python3 tools/screen_designs.py list
|
||||
python3 tools/screen_designs.py generate
|
||||
python3 tools/screen_designs.py generate --draft
|
||||
python3 tools/screen_designs.py compare
|
||||
@@ -333,6 +337,162 @@ SCREENS = [
|
||||
(0.36, 0.64, 0.64, 0.68, "cooldown"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "wand_screening",
|
||||
"kind": "ui",
|
||||
"title": "Wand screening",
|
||||
"source": "README screenshot gallery · PLAN The Joke Shop · src/core/wand_screener.gd",
|
||||
"ref": None,
|
||||
"description": "Handheld wand screening panel for The Joke Shop: the alert "
|
||||
"result, the items the guest is carrying, and allow/deny "
|
||||
"buttons. A phone, keys or a belt buckle can raise a false "
|
||||
"alarm, so the alert alone never proves a weapon.",
|
||||
"prompt": (
|
||||
"Realistic 3D game screenshot with an in-game security wand screening "
|
||||
"panel overlaid: a handheld metal-detector alert indicator, a short "
|
||||
"list of carried items such as keys, phone and belt buckle, a warning "
|
||||
"note that an alert can be a false alarm, and two buttons labelled "
|
||||
"Allow and Deny, dark translucent panel over a softly blurred comedy "
|
||||
"club entrance at night"),
|
||||
"regions": [
|
||||
(0.26, 0.12, 0.74, 0.88, "wand panel"),
|
||||
(0.32, 0.20, 0.68, 0.32, "alert result"),
|
||||
(0.32, 0.38, 0.68, 0.56, "carried items"),
|
||||
(0.30, 0.70, 0.48, 0.78, "allow"),
|
||||
(0.52, 0.70, 0.70, 0.78, "deny"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "joke_shop_queue",
|
||||
"kind": "scene",
|
||||
"title": "Joke Shop door queue",
|
||||
"source": "PLAN.md The Joke Shop — long line, ID and ticket checks",
|
||||
"ref": None,
|
||||
"description": "Evening view of a small comedy-club entrance: a short queue "
|
||||
"on the sidewalk, a door person checking an ID and a ticket "
|
||||
"under a warm bulb, a red awning, and the show board.",
|
||||
"prompt": (
|
||||
"Realistic 3D first-person game scene: a narrow comedy club entrance at "
|
||||
"dusk with a short queue of guests on the sidewalk, a door person "
|
||||
"checking an ID and a paper ticket under a warm lamp, red fabric awning, "
|
||||
"a hand-lettered show board, wet pavement reflections, warm interior "
|
||||
"light spilling through the door"),
|
||||
"regions": [
|
||||
(0.33, 0.30, 0.67, 0.86, "door + door person"),
|
||||
(0.00, 0.55, 0.30, 0.95, "queue"),
|
||||
(0.24, 0.16, 0.76, 0.36, "awning + show board"),
|
||||
(0.72, 0.55, 1.00, 0.95, "sidewalk"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "studio_arrival",
|
||||
"kind": "scene",
|
||||
"title": "TV Studio celebrity arrival",
|
||||
"source": "PLAN.md Late Night TV Studio — crowd gathers for an arrival",
|
||||
"ref": None,
|
||||
"description": "A celebrity arrival at the studio entrance: tourists and "
|
||||
"influencers pressing toward the door, cameras raised, the "
|
||||
"guard holding a line on the steps, studio marquee behind.",
|
||||
"prompt": (
|
||||
"Realistic 3D first-person game scene: a busy late-night television "
|
||||
"studio entrance with a small crowd of tourists and influencers raising "
|
||||
"cameras toward a black car at the curb, a security guard holding a line "
|
||||
"on the steps, glowing studio marquee and lit canopy, night time"),
|
||||
"regions": [
|
||||
(0.30, 0.30, 0.70, 0.80, "crowd + cameras"),
|
||||
(0.40, 0.10, 0.60, 0.34, "studio marquee"),
|
||||
(0.00, 0.60, 0.25, 0.95, "car at the curb"),
|
||||
(0.78, 0.55, 1.00, 0.90, "guard line"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "crowd_panic",
|
||||
"kind": "scene",
|
||||
"title": "Crowd panic",
|
||||
"source": "PLAN.md Public Crowd Reaction · CrowdSystem.reaction_for_event",
|
||||
"ref": None,
|
||||
"description": "After a panic event the sidewalk empties away from the "
|
||||
"door: people running toward the street, one person still "
|
||||
"near the steps, the guard between them and the entrance.",
|
||||
"prompt": (
|
||||
"Realistic 3D first-person game scene: bystanders scattering away from a "
|
||||
"building entrance across a wide sidewalk, motion blur on the fleeing "
|
||||
"figures, one person left near the steps, a security guard standing "
|
||||
"between them and the door, tense evening light, long shadows"),
|
||||
"regions": [
|
||||
(0.00, 0.45, 0.42, 0.90, "fleeing crowd"),
|
||||
(0.55, 0.40, 0.78, 0.85, "guard between crowd and door"),
|
||||
(0.38, 0.10, 0.80, 0.45, "building entrance"),
|
||||
(0.72, 0.50, 1.00, 0.95, "empty sidewalk"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "responder_arrival",
|
||||
"kind": "scene",
|
||||
"title": "Responder arrival",
|
||||
"source": "PLAN.md Level 5 Emergency Response · radio_system.gd responder dispatch",
|
||||
"ref": None,
|
||||
"description": "A dispatched responder walks up the steps toward a locked "
|
||||
"door while the entrance is being cleared; blue light washes "
|
||||
"the facade and the lock indicator reads red.",
|
||||
"prompt": (
|
||||
"Realistic 3D first-person game scene: an emergency responder in dark "
|
||||
"uniform walking up wide concrete steps toward a locked building door, "
|
||||
"blue light washing the brick facade, a red lock indicator beside the "
|
||||
"door, people clearing from the entrance, night"),
|
||||
"regions": [
|
||||
(0.40, 0.28, 0.60, 0.85, "responder on the steps"),
|
||||
(0.42, 0.14, 0.58, 0.32, "locked door + indicator"),
|
||||
(0.00, 0.00, 1.00, 0.30, "facade + blue light"),
|
||||
(0.00, 0.70, 0.28, 0.98, "people clearing"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "body_camera_timeline",
|
||||
"kind": "ui",
|
||||
"title": "Body-camera timeline",
|
||||
"source": "README screenshot gallery · ADR-0005 · src/game/ui/event_timeline.gd",
|
||||
"ref": None,
|
||||
"description": "The shift review's body-camera timeline: a text list of every "
|
||||
"recorded incident event stamped mm:ss with the person and the "
|
||||
"action or outcome, one row highlighted, and previous/next "
|
||||
"buttons.",
|
||||
"prompt": (
|
||||
"Realistic 3D game screenshot with an in-game body-camera event timeline "
|
||||
"overlaid: a tall dark panel listing time-stamped rows such as "
|
||||
"02:14 formal warning, 02:31 body camera on and 03:02 access denied, one "
|
||||
"row highlighted, Previous and Next buttons at the bottom, over a "
|
||||
"softly blurred night street background"),
|
||||
"regions": [
|
||||
(0.28, 0.10, 0.72, 0.90, "timeline panel"),
|
||||
(0.32, 0.16, 0.68, 0.26, "timeline header"),
|
||||
(0.32, 0.28, 0.68, 0.72, "timestamped rows"),
|
||||
(0.32, 0.78, 0.68, 0.85, "previous / next"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "escalation_hud",
|
||||
"kind": "ui",
|
||||
"title": "Escalation HUD",
|
||||
"source": "README accessibility note · Hud.escalation_meter (shape meter · level)",
|
||||
"ref": None,
|
||||
"description": "The in-shift HUD: a shape meter plus numeric escalation level "
|
||||
"(`Escalation: Blocking [###--] L3/5`), the targeted person's "
|
||||
"name, flashlight / door / body-camera indicators, shift clock, "
|
||||
"and the reaction log.",
|
||||
"prompt": (
|
||||
"Realistic 3D game screenshot with a minimal in-game HUD overlaid: an "
|
||||
"escalation meter drawn as a shape bar with the text Blocking and L3/5, "
|
||||
"a target name at the bottom centre, small indicator icons for flashlight, "
|
||||
"door lock and body camera, a shift clock at the top right and a short "
|
||||
"reaction log at the left, over a softly blurred daytime street"),
|
||||
"regions": [
|
||||
(0.30, 0.02, 0.70, 0.10, "escalation shape meter"),
|
||||
(0.30, 0.86, 0.70, 0.96, "target name + hint"),
|
||||
(0.78, 0.02, 0.98, 0.14, "shift clock"),
|
||||
(0.02, 0.60, 0.26, 0.95, "reaction log"),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -341,6 +501,20 @@ def resolve_path(value):
|
||||
return path if path.is_absolute() else ROOT / path
|
||||
|
||||
|
||||
def list_screens(args):
|
||||
"""Print the screen catalogue and what state each entry is in."""
|
||||
for screen in SCREENS:
|
||||
design = _design_path(screen["name"])
|
||||
state = "design" if design.is_file() else "- "
|
||||
capture = screen["ref"] or "-"
|
||||
print(f"{screen['name']:22} {screen['kind']:5} {state} "
|
||||
f"capture={capture:24} {screen['title']}")
|
||||
scenes = sum(1 for screen in SCREENS if screen["kind"] == "scene")
|
||||
print(f"Total: {len(SCREENS)} screens "
|
||||
f"({scenes} scene, {len(SCREENS) - scenes} ui)")
|
||||
return 0
|
||||
|
||||
|
||||
def _design_path(name):
|
||||
return DESIGNS_DIR / f"{name}.png"
|
||||
|
||||
@@ -378,10 +552,11 @@ def _draw_wireframe(screen, path):
|
||||
int(x1 * WIDTH), int(y1 * HEIGHT))
|
||||
draw.rounded_rectangle(box, radius=10,
|
||||
outline=(120, 130, 170), width=2)
|
||||
cx = (box[0] + box[2]) / 2
|
||||
cy = (box[1] + box[3]) / 2
|
||||
draw.text((cx - draw.textlength(label, font=label_font) / 2,
|
||||
cy - 14), label, font=label_font, fill=(170, 180, 210))
|
||||
# Anchor the label to the top-left corner: nested regions (a panel plus
|
||||
# the cards inside it) would otherwise stack their centred labels on top
|
||||
# of each other.
|
||||
draw.text((box[0] + 10, box[1] + 6), label,
|
||||
font=label_font, fill=(170, 180, 210))
|
||||
|
||||
image.save(path)
|
||||
shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
|
||||
@@ -419,11 +594,23 @@ def _generate_ai(screen, model, request_size):
|
||||
def generate(args):
|
||||
DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
failed = []
|
||||
for screen in SCREENS:
|
||||
selected = [screen for screen in SCREENS
|
||||
if not args.only or args.only in screen["name"]]
|
||||
if not selected:
|
||||
print(f"no screen matches --only {args.only!r}", file=sys.stderr)
|
||||
return 2
|
||||
for screen in selected:
|
||||
path = _design_path(screen["name"])
|
||||
# Draft mode is the offline fallback; do not clobber existing AI-crafted
|
||||
# references unless the caller explicitly asks for it.
|
||||
if path.is_file() and not args.force:
|
||||
print(
|
||||
f"skip {screen['name']} (reference exists; --force to replace)")
|
||||
continue
|
||||
print(f"generating {screen['name']} ...")
|
||||
try:
|
||||
if args.draft:
|
||||
_draw_wireframe(screen, _design_path(screen["name"]))
|
||||
_draw_wireframe(screen, path)
|
||||
else:
|
||||
_generate_ai(screen, args.model, args.request_size)
|
||||
except SystemExit as exc:
|
||||
@@ -643,8 +830,10 @@ def build_judge_request(model, screen, capture_path, design_path):
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": _data_url(design_path)}},
|
||||
{"type": "image_url", "image_url": {"url": _data_url(capture_path)}},
|
||||
{"type": "image_url", "image_url": {
|
||||
"url": _data_url(design_path)}},
|
||||
{"type": "image_url", "image_url": {
|
||||
"url": _data_url(capture_path)}},
|
||||
],
|
||||
}],
|
||||
"temperature": 0,
|
||||
@@ -725,7 +914,8 @@ def judge(args):
|
||||
_load_env_file()
|
||||
if not SCREENSHOTS_DIR.is_dir():
|
||||
sys.exit(f"error: no screenshots directory at {SCREENSHOTS_DIR}")
|
||||
refs = {screen["name"]: screen["ref"] for screen in SCREENS if screen["ref"]}
|
||||
refs = {screen["name"]: screen["ref"]
|
||||
for screen in SCREENS if screen["ref"]}
|
||||
by_name = {screen["name"]: screen for screen in SCREENS}
|
||||
results = []
|
||||
for capture in sorted(SCREENSHOTS_DIR.glob("*.png")):
|
||||
@@ -749,7 +939,7 @@ def judge(args):
|
||||
if args.dry_run:
|
||||
results.append({
|
||||
"capture": capture.name, "screen": design_name, "match": None,
|
||||
"confidence": 0.0, "model": body["model"],
|
||||
"confidence": 0.0, "model": body["model"], "class": "unknown",
|
||||
"differences": [f"dry run: {len(body['messages'][0]['content'])} parts"],
|
||||
})
|
||||
continue
|
||||
@@ -795,6 +985,10 @@ def build_parser():
|
||||
gen.add_argument("--model", default=DEFAULT_MODEL)
|
||||
gen.add_argument("--request-size", type=int,
|
||||
default=generate_textures.GEN_SIZE)
|
||||
gen.add_argument("--only", default="",
|
||||
help="only generate screens whose name contains this text")
|
||||
gen.add_argument("--force", action="store_true",
|
||||
help="replace reference designs that already exist")
|
||||
gen.set_defaults(handler=generate, draft=False)
|
||||
|
||||
judge_cmd = commands.add_parser(
|
||||
@@ -814,6 +1008,10 @@ def build_parser():
|
||||
cmp.add_argument("--json", action="store_true")
|
||||
cmp.set_defaults(handler=compare)
|
||||
|
||||
listing = commands.add_parser(
|
||||
"list", help="print the screen catalogue and its state")
|
||||
listing.set_defaults(handler=list_screens)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import json
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import model_pipeline
|
||||
|
||||
|
||||
def parse_glb(payload: bytes):
|
||||
magic, version, length = struct.unpack_from("<III", payload, 0)
|
||||
json_len, json_type = struct.unpack_from("<II", payload, 12)
|
||||
gltf = json.loads(payload[20:20 + json_len].decode("utf-8"))
|
||||
bin_offset = 20 + json_len
|
||||
bin_len, bin_type = struct.unpack_from("<II", payload, bin_offset)
|
||||
binary = payload[bin_offset + 8:bin_offset + 8 + bin_len]
|
||||
return {
|
||||
"magic": magic,
|
||||
"version": version,
|
||||
"length": length,
|
||||
"json_type": json_type,
|
||||
"bin_type": bin_type,
|
||||
"bin_len": bin_len,
|
||||
"binary": binary,
|
||||
"gltf": gltf,
|
||||
}
|
||||
|
||||
|
||||
def prop(name):
|
||||
return model_pipeline.DIRECTORY_BY_NAME[name]
|
||||
|
||||
|
||||
class CatalogueTests(unittest.TestCase):
|
||||
def test_props_are_well_formed(self):
|
||||
names = [p["name"] for p in model_pipeline.PROPS]
|
||||
self.assertEqual(len(names), len(set(names)),
|
||||
"prop names must be unique")
|
||||
for entry in model_pipeline.PROPS:
|
||||
self.assertTrue(entry["concept"].strip())
|
||||
self.assertTrue(entry["parts"])
|
||||
for part in entry["parts"]:
|
||||
self.assertIn(part["shape"], ("box", "cylinder"))
|
||||
self.assertEqual(len(part["at"]), 3)
|
||||
self.assertIn(part["role"], model_pipeline.PALETTE)
|
||||
if part["shape"] == "box":
|
||||
self.assertEqual(len(part["size"]), 3)
|
||||
self.assertTrue(all(value > 0 for value in part["size"]))
|
||||
else:
|
||||
self.assertGreater(part["radius"], 0)
|
||||
self.assertGreater(part["height"], 0)
|
||||
|
||||
def test_every_prop_has_triangles(self):
|
||||
for entry in model_pipeline.PROPS:
|
||||
mesh = model_pipeline.build_prop_mesh(entry)
|
||||
self.assertGreater(mesh.triangle_count(), 0, entry["name"])
|
||||
self.assertLess(mesh.triangle_count(), 4000, entry["name"])
|
||||
self.assertEqual(len(mesh.positions), len(mesh.normals))
|
||||
|
||||
|
||||
class MeshWindingTests(unittest.TestCase):
|
||||
def test_box_face_normals_point_outward(self):
|
||||
mesh = model_pipeline.MeshData()
|
||||
model_pipeline._add_box(mesh, (2.0, 2.0, 2.0), (0.0, 0.0, 0.0))
|
||||
for tri in range(0, len(mesh.indices), 3):
|
||||
a, b, c = (mesh.positions[mesh.indices[tri + i]] for i in range(3))
|
||||
normal = mesh.normals[mesh.indices[tri]]
|
||||
centroid = tuple((a[i] + b[i] + c[i]) / 3.0 for i in range(3))
|
||||
self.assertGreater(
|
||||
model_pipeline._dot(normal, centroid), 0.0,
|
||||
"box face normal must point away from the centre")
|
||||
|
||||
def test_cylinder_caps_face_up_and_down(self):
|
||||
mesh = model_pipeline.MeshData()
|
||||
model_pipeline._add_cylinder(mesh, 0.5, 1.0, (0.0, 0.5, 0.0), 8)
|
||||
ups = [n for n in mesh.normals if n[1] > 0.9]
|
||||
downs = [n for n in mesh.normals if n[1] < -0.9]
|
||||
self.assertTrue(ups, "cylinder needs an upward cap")
|
||||
self.assertTrue(downs, "cylinder needs a downward cap")
|
||||
|
||||
|
||||
class GlbTests(unittest.TestCase):
|
||||
def test_glb_container_and_structure(self):
|
||||
payload = model_pipeline.build_glb(prop("bench"))
|
||||
parsed = parse_glb(payload)
|
||||
self.assertEqual(parsed["magic"], 0x46546C67)
|
||||
self.assertEqual(parsed["version"], 2)
|
||||
self.assertEqual(parsed["length"], len(payload))
|
||||
self.assertEqual(parsed["json_type"], 0x4E4F534A)
|
||||
self.assertEqual(parsed["bin_type"], 0x004E4942)
|
||||
self.assertEqual(len(payload) % 4, 0)
|
||||
gltf = parsed["gltf"]
|
||||
self.assertEqual(gltf["asset"]["version"], "2.0")
|
||||
self.assertEqual(len(gltf["scenes"]), 1)
|
||||
primitives = gltf["meshes"][0]["primitives"]
|
||||
self.assertEqual(len(primitives), len(prop("bench")["parts"]),
|
||||
"one primitive per part")
|
||||
self.assertEqual(len(gltf["materials"]), len(primitives))
|
||||
for primitive in primitives:
|
||||
self.assertIn("POSITION", primitive["attributes"])
|
||||
self.assertIn("NORMAL", primitive["attributes"])
|
||||
self.assertIsNotNone(primitive["indices"])
|
||||
self.assertLess(primitive["material"], len(gltf["materials"]))
|
||||
for accessor in gltf["accessors"]:
|
||||
self.assertIn(accessor["bufferView"],
|
||||
range(len(gltf["bufferViews"])))
|
||||
self.assertGreater(accessor["count"], 0)
|
||||
self.assertLessEqual(gltf["buffers"][0]["byteLength"],
|
||||
len(parsed["binary"]))
|
||||
|
||||
def test_glb_is_deterministic(self):
|
||||
first = model_pipeline.build_glb(prop("bollard"))
|
||||
second = model_pipeline.build_glb(prop("bollard"))
|
||||
self.assertEqual(first, second)
|
||||
|
||||
def test_palette_override_changes_material_colour(self):
|
||||
palette = {role: list(rgb)
|
||||
for role, rgb in model_pipeline.PALETTE.items()}
|
||||
palette["metal"] = [0.9, 0.1, 0.1]
|
||||
gltf = parse_glb(model_pipeline.build_glb(
|
||||
prop("bollard"), palette))["gltf"]
|
||||
factors = [m["pbrMetallicRoughness"]["baseColorFactor"]
|
||||
for m in gltf["materials"]]
|
||||
self.assertIn(model_pipeline._linear_rgba([0.9, 0.1, 0.1]), factors)
|
||||
|
||||
def test_glb_colours_are_written_in_linear_space(self):
|
||||
# glTF baseColorFactor is linear; writing sRGB made props render pastel.
|
||||
gltf = parse_glb(model_pipeline.build_glb(prop("bollard")))["gltf"]
|
||||
factor = gltf["materials"][0]["pbrMetallicRoughness"]["baseColorFactor"]
|
||||
srgb_metal = model_pipeline.PALETTE["metal"][0]
|
||||
self.assertLess(factor[0], srgb_metal)
|
||||
self.assertAlmostEqual(factor[0],
|
||||
model_pipeline.srgb_to_linear(srgb_metal),
|
||||
places=4)
|
||||
|
||||
def test_srgb_to_linear_reference_values(self):
|
||||
self.assertAlmostEqual(model_pipeline.srgb_to_linear(0.0), 0.0)
|
||||
self.assertAlmostEqual(model_pipeline.srgb_to_linear(1.0), 1.0)
|
||||
self.assertAlmostEqual(model_pipeline.srgb_to_linear(0.5), 0.2140,
|
||||
places=4)
|
||||
|
||||
def test_indices_stay_inside_their_primitive(self):
|
||||
payload = model_pipeline.build_glb(prop("planter_box"))
|
||||
parsed = parse_glb(payload)
|
||||
gltf = parsed["gltf"]
|
||||
binary = parsed["binary"]
|
||||
for primitive in gltf["meshes"][0]["primitives"]:
|
||||
position_accessor = gltf["accessors"][primitive["attributes"]["POSITION"]]
|
||||
index_accessor = gltf["accessors"][primitive["indices"]]
|
||||
view = gltf["bufferViews"][index_accessor["bufferView"]]
|
||||
raw = binary[view["byteOffset"] :view["byteOffset"] + view["byteLength"]]
|
||||
indices = struct.unpack(f"<{len(raw) // 2}H", raw)
|
||||
self.assertLess(max(indices), position_accessor["count"])
|
||||
self.assertEqual(index_accessor["count"], len(indices))
|
||||
|
||||
|
||||
class AuditTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp_dir.name)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_audit_accepts_a_written_file(self):
|
||||
path = self.root / "bench.glb"
|
||||
path.write_bytes(model_pipeline.build_glb(prop("bench")))
|
||||
result = model_pipeline.audit_glb(path)
|
||||
self.assertTrue(result["ok"], result["issues"])
|
||||
self.assertGreater(result["triangles"], 0)
|
||||
|
||||
def test_audit_reports_missing_file(self):
|
||||
result = model_pipeline.audit_glb(self.root / "nope.glb")
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertIn("missing file", result["issues"])
|
||||
|
||||
def test_audit_reports_truncated_file(self):
|
||||
path = self.root / "cut.glb"
|
||||
path.write_bytes(model_pipeline.build_glb(prop("bench"))[:40])
|
||||
result = model_pipeline.audit_glb(path)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertTrue(result["issues"])
|
||||
|
||||
def test_build_writes_into_the_models_directory(self):
|
||||
old = model_pipeline.MODELS_DIR
|
||||
model_pipeline.MODELS_DIR = self.root
|
||||
try:
|
||||
written = model_pipeline.build("bollard", quiet=True)
|
||||
self.assertEqual(len(written), 1)
|
||||
self.assertTrue(written[0].is_file())
|
||||
self.assertTrue(model_pipeline.audit_glb(written[0])["ok"])
|
||||
finally:
|
||||
model_pipeline.MODELS_DIR = old
|
||||
|
||||
|
||||
class PaletteTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp_dir.name)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_dominant_colors_ignores_white_background(self):
|
||||
image = Image.new("RGB", (96, 96), (250, 250, 250))
|
||||
for x in range(20, 70):
|
||||
for y in range(20, 70):
|
||||
image.putpixel((x, y), (150, 40, 40))
|
||||
colors = model_pipeline.dominant_colors(image)
|
||||
self.assertTrue(colors)
|
||||
self.assertLess(max(colors[0]), 0.9,
|
||||
"background must not win the vote")
|
||||
|
||||
def test_palette_uses_concept_image_when_present(self):
|
||||
old = model_pipeline.CONCEPTS_DIR
|
||||
model_pipeline.CONCEPTS_DIR = self.root
|
||||
try:
|
||||
image = Image.new("RGB", (96, 96), (250, 250, 250))
|
||||
for x in range(20, 70):
|
||||
for y in range(20, 70):
|
||||
image.putpixel((x, y), (30, 90, 40))
|
||||
image.save(self.root / "bench.png")
|
||||
palette = model_pipeline.palette_for_prop("bench")
|
||||
self.assertLess(palette["body"][1], 0.6)
|
||||
self.assertGreater(palette["body"][1], palette["body"][0])
|
||||
finally:
|
||||
model_pipeline.CONCEPTS_DIR = old
|
||||
|
||||
def test_palette_falls_back_to_defaults(self):
|
||||
old = model_pipeline.CONCEPTS_DIR
|
||||
model_pipeline.CONCEPTS_DIR = self.root
|
||||
try:
|
||||
palette = model_pipeline.palette_for_prop("bench")
|
||||
self.assertEqual(palette["wood"], model_pipeline.PALETTE["wood"])
|
||||
finally:
|
||||
model_pipeline.CONCEPTS_DIR = old
|
||||
|
||||
def test_assign_roles_separates_wood_from_metal(self):
|
||||
colors = [[0.62, 0.37, 0.18], [0.05, 0.05, 0.05], [0.70, 0.70, 0.70]]
|
||||
palette = model_pipeline.assign_roles(colors)
|
||||
self.assertAlmostEqual(palette["wood"][0], 0.62, places=3)
|
||||
self.assertLess(model_pipeline._luminance(palette["metal"]), 0.1)
|
||||
|
||||
def test_assign_roles_finds_foliage_and_stone(self):
|
||||
colors = [[0.55, 0.55, 0.53], [0.18, 0.42, 0.20]]
|
||||
palette = model_pipeline.assign_roles(colors)
|
||||
self.assertGreater(palette["foliage"][1], palette["foliage"][0])
|
||||
self.assertGreater(palette["stone"][0], 0.5)
|
||||
|
||||
def test_assign_roles_without_colours_keeps_defaults(self):
|
||||
palette = model_pipeline.assign_roles([])
|
||||
self.assertEqual(palette["body"], model_pipeline.PALETTE["body"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -43,6 +43,48 @@ class ScreenDesignsTests(unittest.TestCase):
|
||||
self.assertEqual(image.size,
|
||||
(screen_designs.WIDTH, screen_designs.HEIGHT))
|
||||
|
||||
def test_catalogue_covers_the_shipped_screens(self):
|
||||
names = [screen["name"] for screen in screen_designs.SCREENS]
|
||||
required = [
|
||||
"venue_overview", "targeting_npc", "access_inspection",
|
||||
"radio_report", "late_night_avenue", "night_flashlight",
|
||||
"radial_menu", "shift_review", "start_menu", "pause_overlay",
|
||||
"settings_panel", "emergency_radio", "wand_screening",
|
||||
"joke_shop_queue", "studio_arrival", "crowd_panic",
|
||||
"responder_arrival", "body_camera_timeline", "escalation_hud",
|
||||
]
|
||||
for name in required:
|
||||
self.assertIn(name, names)
|
||||
self.assertGreaterEqual(len(screen_designs.SCREENS), len(required))
|
||||
for screen in screen_designs.SCREENS:
|
||||
self.assertTrue(screen["title"].strip())
|
||||
|
||||
def test_list_command_prints_the_catalogue(self):
|
||||
import contextlib
|
||||
import io
|
||||
buffer = io.StringIO()
|
||||
with contextlib.redirect_stdout(buffer):
|
||||
code = screen_designs.main(["list"])
|
||||
self.assertEqual(code, 0)
|
||||
output = buffer.getvalue()
|
||||
self.assertIn("escalation_hud", output)
|
||||
self.assertIn("wand_screening", output)
|
||||
self.assertIn("Total:", output)
|
||||
|
||||
def test_judge_dry_run_builds_requests_without_network(self):
|
||||
import contextlib
|
||||
import io
|
||||
if not screen_designs.SCREENSHOTS_DIR.is_dir():
|
||||
self.skipTest("no screenshots checked out")
|
||||
buffer = io.StringIO()
|
||||
with contextlib.redirect_stdout(buffer):
|
||||
code = screen_designs.main(["judge", "--dry-run"])
|
||||
# A dry run must not call the API and must not crash on missing verdicts.
|
||||
self.assertEqual(code, 0)
|
||||
output = buffer.getvalue()
|
||||
self.assertIn("dry run", output)
|
||||
self.assertIn("Summary:", output)
|
||||
|
||||
def test_compose_prompt_appends_kind_specific_style(self):
|
||||
scene = {"kind": "scene", "prompt": "a street"}
|
||||
ui = {"kind": "ui", "prompt": "a panel"}
|
||||
@@ -108,7 +150,8 @@ class VisionJudgeTests(unittest.TestCase):
|
||||
parts = body["messages"][0]["content"]
|
||||
self.assertEqual(parts[0]["type"], "text")
|
||||
images = [p for p in parts if p["type"] == "image_url"]
|
||||
self.assertEqual(len(images), 2, "design and capture are both attached")
|
||||
self.assertEqual(
|
||||
len(images), 2, "design and capture are both attached")
|
||||
for image in images:
|
||||
self.assertTrue(image["image_url"]["url"].startswith(
|
||||
"data:image/png;base64,"))
|
||||
@@ -117,7 +160,7 @@ class VisionJudgeTests(unittest.TestCase):
|
||||
|
||||
def test_parse_plain_json_verdict(self):
|
||||
payload = {"choices": [{"message": {"content":
|
||||
'{"match": true, "confidence": 0.9, "differences": ["style"]}'}}]}
|
||||
'{"match": true, "confidence": 0.9, "differences": ["style"]}'}}]}
|
||||
verdict = screen_designs.parse_judge_response(payload)
|
||||
self.assertTrue(verdict["match"])
|
||||
self.assertAlmostEqual(verdict["confidence"], 0.9)
|
||||
@@ -125,7 +168,7 @@ class VisionJudgeTests(unittest.TestCase):
|
||||
|
||||
def test_parse_fenced_json_verdict(self):
|
||||
payload = {"choices": [{"message": {"content":
|
||||
'Sure:\n```json\n{"match": false, "confidence": 0.7}\n```'}}]}
|
||||
'Sure:\n```json\n{"match": false, "confidence": 0.7}\n```'}}]}
|
||||
verdict = screen_designs.parse_judge_response(payload)
|
||||
self.assertFalse(verdict["match"])
|
||||
self.assertAlmostEqual(verdict["confidence"], 0.7)
|
||||
|
||||
Reference in new issue
Block a user