v0.3.0: rendering pass, generated props, selection model and Web build
Full build / Import + headless tests (push) Successful in 36s
Full build / Full desktop build + optional release (push) Successful in 4m17s

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:
zwitschi committed 2026-10-31 23:07:41 +01:00
1 parent f763cff744
commit afa1d6beec
48 files changed
+2602 -147

No files matched your search

+21
View File
@@ -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}"*
+732
View File
@@ -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())
+1
View File
@@ -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
View File
@@ -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
+257
View File
@@ -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()
+46 -3
View File
@@ -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)