diff --git a/DONE.md b/DONE.md index 1fd2cec..664df09 100644 --- a/DONE.md +++ b/DONE.md @@ -734,3 +734,21 @@ Completed 2026-11-01 (v0.4.0). Headless **187/187**, Python tooling **53/53**, t remove collision; a unit test covers the fallback body, shape, width, and navigation obstacle. - [x] Tests: placement validation and its failure modes, variant determinism/range, colliders on every placed prop (including obstacle presence), and the fallback collider path. + +## Prop Pipeline Checks + +Completed 2026-11-01. Python tooling **56/56**, headless **188/188**, prop gallery **match** +(0.90) in the vision judge, `compare` **10 good / 0 bad**. + +- [x] `audit_glb()` now decodes positions/UVs/indices and enforces the art budget + (`MAX_TRIANGLES = 400`, `MAX_VERTICES = 600`), rejects degenerate triangles, UVs outside + `[0, 1]`, a prop that floats above or sinks below the ground (`min y ~= 0`), and the + single-material/atlas contract. The newspaper box sat 3 cm high; its catalogue parts moved + down so it rests on y = 0. +- [x] Added `capture_prop_gallery.gd`: renders all six props on a studio backdrop (two rows on a + display platform) to `docs/screenshots/10-prop-gallery.png`, with a `prop_gallery` + catalogue entry and AI mockup, so `compare`/`judge` cover close-range prop art. +- [x] Confirmed instances share the imported atlas material (headless test) and documented the + triangle/vertex budget in the pipeline docstring. +- [x] README lists the gallery capture command and image; the pipeline docstring and audit docs + describe the budget checks. diff --git a/MEMORY.md b/MEMORY.md index 4995dc3..4d852f9 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -52,6 +52,9 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o flat plane, so avoidance (not a baked mesh) routes guests around props. Placements live in `PROP_PLACEMENTS` with `placement_problems()` validation; variants come from `variant_for(index)`. `smoke_props.gd` must be windowed because the guard reads captured-mouse input. +- **Prop pipeline (done):** audit enforces 400 tris / 600 verts, ground contact, UV range, no + degenerate tris, one material. `capture_prop_gallery.gd` writes `10-prop-gallery.png` (3x2 studio + line-up, back row on a platform) and the `prop_gallery` catalogue entry compares it. - **Presentation pass remaining (TODO):** NPC characters and player movement. Grounding facts: the NPC rig is rigid capsules with a whole-limb swing, and `guard_controller.gd` uses one `accel` value. Each task should get its own windowed smoke or capture where it changes feel/looks. diff --git a/README.md b/README.md index c526ff8..5aa0cd1 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ The central design goal is simple: | ![Access inspection: wristband/credential check with allow/deny prompt](docs/screenshots/03-access-inspection.png) | ![Radio report form: emergency dispatch panel](docs/screenshots/04-radio-report.png) | | ![Late night: street lamps lit, marquee glowing, dark Hollywood Boulevard](docs/screenshots/05-late-night-avenue.png) | ![Night shift: guard flashlight cutting through the dark](docs/screenshots/06-night-flashlight.png) | | ![Wand screening at The Joke Shop: false alarm on keys](docs/screenshots/07-wand-screening.png) | ![Escalation HUD at L3 with body camera armed and reaction log](docs/screenshots/08-escalation-hud.png) | -| ![Body-camera timeline: formal warning, body camera on, blocking](docs/screenshots/09-body-camera-timeline.png) | | +| ![Body-camera timeline: formal warning, body camera on, blocking](docs/screenshots/09-body-camera-timeline.png) | ![Prop gallery: the six generated street-furniture models on a studio backdrop](docs/screenshots/10-prop-gallery.png) | -*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build — day, late-night, wand screening, escalation, and shift-review timeline.* +*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) and [`src/test/capture_prop_gallery.gd`](src/test/capture_prop_gallery.gd) from the current build — day, late-night, wand screening, escalation, shift-review timeline, and the prop lineup.* ## ⬇️ Downloads @@ -248,6 +248,9 @@ python3 tools/screen_designs.py generate --draft --only wand --force # (requires OPENROUTER_API_KEY) python3 tools/screen_designs.py generate +# Prop gallery (studio line-up) for close-range prop checks +godot --path . --script res://src/test/capture_prop_gallery.gd + # Compare docs/screenshots/*.png against the designs python3 tools/screen_designs.py compare python3 tools/screen_designs.py compare --json diff --git a/TODO.md b/TODO.md index 9f75eeb..8d341ac 100644 --- a/TODO.md +++ b/TODO.md @@ -5,20 +5,8 @@ suite **187/187** plus **53** Python tooling tests; the session, PLAN-verb, crow timeline, audio, scenario-content, props, and windowed focus/radial smokes pass. Anonymous downloads are enabled on the host. -The sections below are the rest of the presentation pass: prop pipeline checks, NPC characters, -and player movement. Each task is meant to be a small, reviewable slice with its own verification. - -## Better Props - -### Pipeline and checks - -- [ ] Extend `audit_glb()` with a triangle budget, degenerate-triangle test, `TEXCOORD_0` inside - the atlas, minimum-y ≈ 0, and a material-count check per prop. -- [ ] Add a `prop_gallery` capture to `capture_screenshots.gd` (each prop close-up on a plain - backdrop) and wire it to `screen_designs.py` so prop regressions show up in `compare`. -- [ ] Verify all prop instances share their imported material (no per-instance duplication) and - keep a documented triangle/vertex budget in the pipeline docstring. -- [ ] Update the README prop list and the `model_pipeline.py` catalogue docs when new props land. +The sections below are the rest of the presentation pass: NPC characters and player movement. +Each task is meant to be a small, reviewable slice with its own verification. ## Better NPC Characters diff --git a/assets/models/generated/newspaper_box.glb b/assets/models/generated/newspaper_box.glb index 2d04770..442d031 100644 Binary files a/assets/models/generated/newspaper_box.glb and b/assets/models/generated/newspaper_box.glb differ diff --git a/docs/screens/designs/prop_gallery.png b/docs/screens/designs/prop_gallery.png new file mode 100644 index 0000000..9e94724 Binary files /dev/null and b/docs/screens/designs/prop_gallery.png differ diff --git a/docs/screenshots/10-prop-gallery.png b/docs/screenshots/10-prop-gallery.png new file mode 100644 index 0000000..ebb19f0 Binary files /dev/null and b/docs/screenshots/10-prop-gallery.png differ diff --git a/src/test/capture_prop_gallery.gd b/src/test/capture_prop_gallery.gd new file mode 100644 index 0000000..fef5e4b --- /dev/null +++ b/src/test/capture_prop_gallery.gd @@ -0,0 +1,104 @@ +extends SceneTree +## Dev helper: render every generated prop side by side on a plain backdrop and +## save user://screenshots/10-prop-gallery.png. The image feeds +## `tools/screen_designs.py compare` so a prop regression is visible. +## +## Usage: godot --path . --script res://src/test/capture_prop_gallery.gd + +const OUT_DIR := "user://screenshots" +const MODEL_DIR := "res://assets/models/generated/" +const PROPS := ["bollard", "bench", "trash_bin", "planter_box", + "newspaper_box", "fire_hydrant"] +const SPACING := 1.9 + +var frames := 0 + +func _initialize() -> void: + root.size = Vector2i(1280, 720) + _build_stage() + +func _build_stage() -> void: + var stage := Node3D.new() + stage.name = "PropGallery" + root.add_child(stage) + + var environment := Environment.new() + environment.background_mode = Environment.BG_COLOR + environment.background_color = Color(0.52, 0.54, 0.58) + environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + environment.ambient_light_color = Color(0.78, 0.80, 0.84) + environment.ambient_light_energy = 0.8 + var world_env := WorldEnvironment.new() + world_env.environment = environment + stage.add_child(world_env) + + # Floor and a plain backdrop wall: a studio line-up with no street context. + var floor_mesh := PlaneMesh.new() + floor_mesh.size = Vector2(14, 8) + var floor := MeshInstance3D.new() + floor.mesh = floor_mesh + floor.material_override = _flat(Color(0.50, 0.51, 0.54), 0.9) + stage.add_child(floor) + + var backdrop_mesh := BoxMesh.new() + backdrop_mesh.size = Vector3(14, 6, 0.1) + var backdrop := MeshInstance3D.new() + backdrop.mesh = backdrop_mesh + backdrop.material_override = _flat(Color(0.55, 0.57, 0.61), 0.95) + backdrop.position = Vector3(0, 3.0, -3.4) + stage.add_child(backdrop) + + # Raised back row so both rows read in a single shot. + var platform_mesh := BoxMesh.new() + platform_mesh.size = Vector3(6.8, 0.8, 1.9) + var platform := MeshInstance3D.new() + platform.mesh = platform_mesh + platform.material_override = _flat(Color(0.38, 0.39, 0.42), 0.85) + platform.position = Vector3(0, 0.4, -2.1) + stage.add_child(platform) + + var light := DirectionalLight3D.new() + light.rotation_degrees = Vector3(-48, -35, 0) + light.light_energy = 0.85 + light.shadow_enabled = true + stage.add_child(light) + + var camera := Camera3D.new() + camera.position = Vector3(0, 2.1, 6.2) + camera.fov = 50.0 + camera.look_at_from_position(camera.position, Vector3(0, 0.75, -1.0)) + camera.current = true + stage.add_child(camera) + + # 3 x 2 grid: three props on the front row, three on the back row. + var columns := [-1.95, 0.0, 1.95] + for index in PROPS.size(): + var prop_name: String = PROPS[index] + var packed := ResourceLoader.load(MODEL_DIR + prop_name + ".glb") as PackedScene + if packed == null: + continue + var model := packed.instantiate() as Node3D + var col: int = index % 3 + var row: int = index / 3 + model.position = Vector3(columns[col], 0.8 if row == 1 else 0.0, -2.1 * row) + model.rotation_degrees.y = 14.0 + stage.add_child(model) + +func _flat(color: Color, roughness: float) -> StandardMaterial3D: + var material := StandardMaterial3D.new() + material.albedo_color = color + material.roughness = roughness + return material + +func _process(_delta: float) -> bool: + frames += 1 + if frames < 20: + return false + var dir := DirAccess.open("user://") + if dir == null or not dir.dir_exists("screenshots"): + dir.make_dir_recursive("screenshots") + var image := root.get_texture().get_image() + var err := image.save_png(OUT_DIR.path_join("10-prop-gallery.png")) + print("10-prop-gallery.png -> err=%d (%dx%d)" % [err, image.get_width(), image.get_height()]) + quit(0) + return true diff --git a/src/test/tests/test_generated_props.gd b/src/test/tests/test_generated_props.gd index 46202d3..ca99933 100644 --- a/src/test/tests/test_generated_props.gd +++ b/src/test/tests/test_generated_props.gd @@ -68,6 +68,34 @@ func test_prop_scenes_share_one_atlas_material() -> bool: instance.free() return TestUtils.check(problems.is_empty(), "one atlas material per prop (%s)" % str(problems)) +func test_prop_instances_share_the_imported_material() -> bool: + ## Instancing a prop must not duplicate its atlas material; every copy uses + ## the same resource, so many props stay cheap to draw. + var problems: Array[String] = [] + for prop in WorldBuilder.MODEL_PROPS: + var packed := ResourceLoader.load(MODEL_DIR + str(prop) + ".glb") as PackedScene + if packed == null: + problems.append("%s: no scene" % prop) + continue + var first := packed.instantiate() + var second := packed.instantiate() + var a := _first_material(first) + var b := _first_material(second) + if a == null or b == null: + problems.append("%s: missing material" % prop) + elif a.get_instance_id() != b.get_instance_id(): + problems.append("%s: material is duplicated per instance" % prop) + first.free() + second.free() + return TestUtils.check(problems.is_empty(), "prop instances share materials (%s)" % str(problems)) + +func _first_material(instance: Node) -> Material: + for node in instance.find_children("*", "MeshInstance3D", true, false): + var mesh: Mesh = (node as MeshInstance3D).mesh + if mesh != null and mesh.get_surface_count() > 0: + return mesh.surface_get_material(0) + return null + func test_world_places_generated_props() -> bool: var root := Node3D.new() var builder := WorldBuilder.new(root) diff --git a/tools/model_pipeline.py b/tools/model_pipeline.py index 99dd98d..6b4e1cf 100644 --- a/tools/model_pipeline.py +++ b/tools/model_pipeline.py @@ -11,7 +11,9 @@ keeps that art direction in one place: `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. + `PackedScene`s, and `audit` checks the files are structurally valid and stay + inside the art budget (<= 400 triangles, <= 600 vertices per prop, no + degenerate triangles, UVs in the atlas, base at y ~= 0, one material). Commands (run from the repo root): @@ -118,13 +120,13 @@ PROPS = [ "short stand, plain light grey studio background"), "parts": [ {"shape": "box", "size": [0.44, 0.10, 0.38], - "at": [0, 0.08, 0], "role": "metal"}, + "at": [0, 0.05, 0], "role": "metal"}, {"shape": "box", "size": [0.42, 0.72, 0.34], - "at": [0, 0.49, 0], "role": "body"}, + "at": [0, 0.46, 0], "role": "body"}, {"shape": "box", "size": [0.34, 0.30, 0.03], - "at": [0, 0.60, 0.18], "role": "glass"}, + "at": [0, 0.57, 0.18], "role": "glass"}, {"shape": "box", "size": [0.44, 0.10, 0.36], - "at": [0, 0.90, 0], "role": "accent"}, + "at": [0, 0.87, 0], "role": "accent"}, ], }, { @@ -705,8 +707,58 @@ def build(only="", quiet=False) -> list: # Audit. # --------------------------------------------------------------------------- +MAX_TRIANGLES = 400 +MAX_VERTICES = 600 +_DEGENERATE_AREA2 = 1e-9 + + +def _read_float_accessor(gltf: dict, binary: bytes, index: int) -> list: + """Decode a float SCALAR/VEC2/VEC3 accessor into a list of tuples/values.""" + accessor = gltf["accessors"][index] + count = int(accessor["count"]) + kind = accessor["type"] + width = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}[kind] + view = gltf["bufferViews"][accessor["bufferView"]] + offset = int(view.get("byteOffset", 0)) + int(accessor.get("byteOffset", 0)) + stride = int(view.get("byteStride", width * 4)) + values = [] + for i in range(count): + base = offset + i * stride + row = struct.unpack_from("<" + "f" * width, binary, base) + values.append(row[0] if width == 1 else row) + return values + + +def _read_index_accessor(gltf: dict, binary: bytes, index: int) -> list: + accessor = gltf["accessors"][index] + width = {5121: 1, 5123: 2, 5125: 4}.get(int(accessor["componentType"]), 2) + fmt = {1: "B", 2: "H", 4: "I"}[width] + view = gltf["bufferViews"][accessor["bufferView"]] + offset = int(view.get("byteOffset", 0)) + int(accessor.get("byteOffset", 0)) + stride = int(view.get("byteStride", width)) + out = [] + for i in range(int(accessor["count"])): + out.append(struct.unpack_from("<" + fmt, binary, + offset + i * stride)[0]) + return out + + +def _triangle_area2(a, b, c) -> float: + ab = (b[0] - a[0], b[1] - a[1], b[2] - a[2]) + ac = (c[0] - a[0], c[1] - a[1], c[2] - a[2]) + cross = (ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0]) + return cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2] + + def audit_glb(path: Path) -> dict: - """Check a .glb file's container, JSON chunk, and triangle count.""" + """Check a .glb file's container, JSON chunk, and mesh contract. + + The mesh must stay in the prop budget, have no degenerate triangles, keep + atlas UVs inside [0, 1], sit on the ground (min y ~= 0), and use exactly one + atlas material. + """ issues = [] if not path.is_file(): return {"path": str(path), "ok": False, @@ -765,6 +817,41 @@ def audit_glb(path: Path) -> dict: for buffer in gltf.get("buffers", []): if int(buffer.get("byteLength", 0)) > len(data): issues.append("buffer larger than the file") + if triangles > MAX_TRIANGLES: + issues.append(f"triangle budget exceeded ({triangles} > {MAX_TRIANGLES})") + # Decode geometry for the deeper checks. A malformed buffer view is already + # reported above, so skip quietly when the binary chunk is unusable. + try: + _, bin_len, _ = struct.unpack_from(" 0.02: + issues.append( + f"prop does not sit on the ground (min y {min_y:.3f})") + uvs = _read_float_accessor( + gltf, binary, attributes["TEXCOORD_0"]) + if any(not (0.0 <= u <= 1.0 and 0.0 <= v <= 1.0) + for u, v in uvs): + issues.append("atlas UV outside [0, 1]") + indices = _read_index_accessor( + gltf, binary, primitive["indices"]) + for tri in range(0, len(indices), 3): + a, b, c = (positions[indices[tri + i]] for i in range(3)) + if _triangle_area2(a, b, c) < _DEGENERATE_AREA2: + issues.append("degenerate triangle") + break + if vertices > MAX_VERTICES: + issues.append(f"vertex budget exceeded ({vertices} > {MAX_VERTICES})") + except (KeyError, IndexError, ValueError, struct.error) as exc: + issues.append(f"unreadable geometry: {exc}") return {"path": str(path), "ok": not issues, "issues": issues, "triangles": triangles} diff --git a/tools/screen_designs.py b/tools/screen_designs.py index 9667c26..0b3d737 100644 --- a/tools/screen_designs.py +++ b/tools/screen_designs.py @@ -495,6 +495,26 @@ SCREENS = [ (0.02, 0.20, 0.36, 0.65, "reaction log"), (0.02, 0.94, 0.98, 0.99, "control hints"), ], + }, { + "name": "prop_gallery", + "kind": "scene", + "title": "Prop gallery", + "source": "tools/model_pipeline.py · docs screenshot set", + "ref": "10-prop-gallery.png", + "description": "Line-up of the six generated street-furniture props — a bollard, " + "bench, trash bin, planter box, newspaper box and fire hydrant — on " + "a plain studio backdrop, used as a close-range art check.", + "prompt": ( + "Product line-up photograph of six low-poly 3D street-furniture props on a " + "plain light grey studio backdrop with a low display platform: a black steel " + "bollard, a wooden bench with black legs, a dark green trash bin, a white " + "planter box with a green hedge, a blue newspaper vending box and a red fire " + "hydrant. Even soft studio lighting, straight-on wide shot, muted realistic " + "colours, no people, no street context"), + "regions": [ + (0.22, 0.14, 0.80, 0.47, "back row: planter, newspaper box, hydrant"), + (0.22, 0.38, 0.80, 0.68, "front row: bollard, bench, bin"), + ], }, ] diff --git a/tools/test_model_pipeline.py b/tools/test_model_pipeline.py index 68829e1..a36022e 100644 --- a/tools/test_model_pipeline.py +++ b/tools/test_model_pipeline.py @@ -195,6 +195,43 @@ class AuditTests(unittest.TestCase): self.assertFalse(result["ok"]) self.assertTrue(result["issues"]) + def test_audit_enforces_the_triangle_budget(self): + old = model_pipeline.MAX_TRIANGLES + model_pipeline.MAX_TRIANGLES = 10 + try: + path = self.root / "bench.glb" + path.write_bytes(model_pipeline.build_glb(prop("bench"))) + result = model_pipeline.audit_glb(path) + finally: + model_pipeline.MAX_TRIANGLES = old + self.assertFalse(result["ok"]) + self.assertTrue(any("triangle budget" in issue for issue in result["issues"]), + result["issues"]) + + def test_degenerate_triangle_helper(self): + self.assertEqual( + model_pipeline._triangle_area2((0, 0, 0), (1, 0, 0), (2, 0, 0)), 0.0) + self.assertGreater( + model_pipeline._triangle_area2((0, 0, 0), (1, 0, 0), (0, 1, 0)), 0.0) + + def test_audit_reads_positions_and_uvs_from_the_binary(self): + parsed = parse_glb(model_pipeline.build_glb(prop("bollard"))) + gltf, binary = parsed["gltf"], parsed["binary"] + primitive = gltf["meshes"][0]["primitives"][0] + positions = model_pipeline._read_float_accessor( + gltf, binary, primitive["attributes"]["POSITION"]) + uvs = model_pipeline._read_float_accessor( + gltf, binary, primitive["attributes"]["TEXCOORD_0"]) + indices = model_pipeline._read_index_accessor( + gltf, binary, primitive["indices"]) + self.assertEqual(len(positions), gltf["accessors"][ + primitive["attributes"]["POSITION"]]["count"]) + self.assertEqual(len(uvs), len(positions)) + self.assertEqual(len(indices) % 3, 0) + self.assertLess(min(p[1] for p in positions), 0.01) + for u, v in uvs: + self.assertTrue(0.0 <= u <= 1.0 and 0.0 <= v <= 1.0) + def test_build_writes_into_the_models_directory(self): old = model_pipeline.MODELS_DIR model_pipeline.MODELS_DIR = self.root