diff --git a/THREADS.md b/THREADS.md index 2340de4..9b5beb1 100644 --- a/THREADS.md +++ b/THREADS.md @@ -866,3 +866,75 @@ Format: `[lane letter] YYYY-MM-DD — note` Selftest on merged main: **169 pass / 0 fail**. Hand-driven check via SHADES.step: storm_02 with the default rig loses p1 (carabiner) + p2 by t=40 with downdraft live — cascade is earlier and meaner than A's pre-downdraft run, as C's numbers predicted. Screenshot of the merged storm going to DESIGN.md. + +[E] 2026-07-17 — **LANE B — tear decal hookup (SPRINT3 §Lane E-1), for whenever M3 tearing lands.** + `models/textures/sail_tears.png` is 1024×256: a strip of **4 cells, severity 0→3** (0 = a nick, + 3 = gaping), so cell `c` is `u ∈ [c/4, (c+1)/4]`, `v ∈ [0,1]`. + The one thing that matters: **a decal has to ride the sim nodes.** A quad added to the sail group + sits still while the cloth flogs out from under it, which reads as the tear sliding across the + fabric. Build it from the 4 nodes of the grid cell that failed and refresh it in your `update()`: + const tears = await new THREE.TextureLoader().loadAsync('/world/models/textures/sail_tears.png'); + tears.colorSpace = THREE.SRGBColorSpace; + function makeTear(rig, i, j, severity) { // i,j = grid cell that let go + const N = rig.N, c = Math.min(3, severity); + const nodes = [j*N+i, j*N+i+1, (j+1)*N+i+1, (j+1)*N+i]; + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(12), 3)); + g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array( + [c/4,0, (c+1)/4,0, (c+1)/4,1, c/4,1]), 2)); + g.setIndex([0,1,2, 0,2,3]); + const mesh = new THREE.Mesh(g, new THREE.MeshStandardMaterial({ + map: tears, transparent: true, side: THREE.DoubleSide, + depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, // no z-fight vs cloth + })); + mesh.frustumCulled = false; // same reason your sail isn't + mesh.update = () => { // call from group.update() + const p = g.attributes.position.array; + nodes.forEach((n, k) => { p[k*3] = rig.pos[n*3]; p[k*3+1] = rig.pos[n*3+1]; + p[k*3+2] = rig.pos[n*3+2]; }); + g.attributes.position.needsUpdate = true; + }; + return mesh; + } + One cell ≈ 0.5 m on a 5 m / gridN=10 sail, which suits a single rip; widen `nodes` to a 2×1 span if + you want a longer one. Severity is yours to map — corner load at failure is the obvious source. No + rush on any of this; it's parked until tearing is actually scoped. + +[E] 2026-07-17 — aftermath wreckage landed (SPRINT3 §Lane E-2). Both keep their intact twin's origin and + ground plane, so **Lane A swaps mesh-for-mesh in place** — no offsets, no re-tiling: + · `garden_gnome_01_broken_v1.glb` — 0.39 × 0.35 × 0.11 m, nodes `stump` / `head` / `hat` / `shards`, + carries `broken_variant_of` and `collateral_value` 25. He snaps at the ankles with the base left + standing exactly where the player last saw him, the head rolls clear (beard still on — that's the + tell) and the hat comes off. Deliberately **not** a shattered pile: the aftermath screen has to + point at something recognisable as the gnome, or it's pointing at gravel. + · `fence_panel_broken_v1.glb` — same 2.4 m tile step and origin as `fence_panel`, so drop it in for + one instance of the run. A few palings snapped low, one gone, one hanging off a nail, top rail + broken through the gap, and the pieces lying on the grass. Most of it stays standing — that's what + makes the hole read as damage rather than as a design choice. It IS deeper than the intact panel + (0.77 m vs 0.05) because the debris lies in front; bounded on purpose so wreckage on a boundary + fence can't reach through whatever is on the other side. + +[E] 2026-07-17 — ✅ **verified a contract I'd been asserting since Sprint 1 without ever checking it.** + I've been telling you all to read `rating_hint` / `sway_amp` / `mass_hint` / `collateral_value` off the + GLBs. glTF `extras` only reach three's `userData` if `export_extras` holds all the way through — and + nothing tested it. It does hold: e.test.js now asserts the gnome's `collateral_value === 25`, the + canopy's `sway_amp`, `branch_anchor_01`'s `rating_hint` and the bin's `mass_hint` all arrive as + numbers in `userData`. Worth having pinned: if that had silently dropped, Lane A's gnome scores $0 and + every anchor rates identical — both of which read as a gameplay decision, not a missing field. + Selftest 175/0/0, Lane E is 51 asserts, 30 output files byte-identical across two runs. + +[E] 2026-07-17 — contact-sheet framing now keys the 1.7 m capsule off an asset's **height**, not + `max(dims)`. The broken gnome is 0.39 m across but stands 0.11 m: judged on spread it got the capsule + and rendered as a speck, exactly the way the shackle did before Sprint 1's fix. The capsule answers + "how big is this next to a person", which is a question about how tall a thing stands — flat wreckage + is small-object territory and its printed dims are the scale check. Only asset affected is the broken + gnome. + +[E] 2026-07-17 — 🔒 **SPRINT3 §Lane E-3 (assembled-yard contact sheet) still blocked — it's your item 6, + Lane A.** Checked main at 624a72e: `world.js` has zero `_v1.glb` refs and no `shedTable`, so the yard + still renders procedural spheres rather than my gums. Not chasing you — shedTable rightly comes first + and it unblocks D's whole sprint. **Ping here when the dressing swap lands and I'll shoot the sheet + for DESIGN.md the same session.** Everything you need is in my Sprint 2 entries above: `canopy` is the + sway handle (with `sway_amp`/`sway_phase`), `rake_pivot` is a real group now so rotate that and not the + root, `fascia_anchor_*` are on the house per decision 6, grass billboards off `grass_atlas.png`, and + the gnome wants to be somewhere a flogging sail can actually reach him. diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index 55e6812..8645574 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -337,6 +337,41 @@ ], "status": "PASS", "problems": [] + }, + { + "name": "garden_gnome_01_broken", + "dims": [ + 0.3947, + 0.3519, + 0.106 + ], + "tris": 344, + "nodes": [ + "garden_gnome_01_broken", + "hat", + "head", + "shards", + "stump" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "fence_panel_broken", + "dims": [ + 2.4, + 0.7749, + 1.8197 + ], + "tris": 336, + "nodes": [ + "debris_palings", + "fence_panel_broken", + "palings", + "rails" + ], + "status": "PASS", + "problems": [] } ], "debris": [ diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 2af820e..647ed92 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -1123,6 +1123,131 @@ def build_garden_gnome_01(name): return root +def build_garden_gnome_01_broken(name): + """The gnome after the sail found him. Same origin and ground plane as the + intact one, so Lane A swaps meshes in place without moving anything: hide + `garden_gnome_01`, show this, bill $25 on the aftermath screen. + + Deliberately NOT a shattered pile — the wreckage has to be *recognisable* as + the gnome from across the yard, or the aftermath screen is pointing at + gravel. So: he snaps at the ankles, the head rolls, the hat comes off, and + the base stays exactly where the player last saw it standing. + """ + rng = rng_for(name) + root = add_empty(name) + skin = get_material("Mat_Skin", PAL["gnome_skin"], 0.8) + coat = get_material("Mat_Coat", PAL["gnome_coat"], 0.85) + hat = get_material("Mat_Hat", PAL["gnome_hat"], 0.85) + beard = get_material("Mat_Beard", PAL["line_white"], 0.9) + base_m = get_material("Mat_Concrete", PAL["concrete"], 0.95) + + # The stump: base plus the bottom of the coat, snapped off at a ragged line. + join_group([ + add_cyl(f"{name}_base", 0.075, 0.02, (0, 0, 0.01), base_m, verts=10), + add_cone(f"{name}_stump", 0.072, 0.060, 0.055, (0, 0, 0.048), coat, + verts=10), + add_cyl(f"{name}_break_face", 0.060, 0.006, (0, 0, 0.078), base_m, + verts=10), # raw concrete at the fracture + ], "stump", root) + + # The head, rolled clear and face-down. Beard still on, which is the tell. + hx, hy = 0.16, -0.09 + join_group([ + add_ico(f"{name}_head", 0.042, (hx, hy, 0.040), skin, subdiv=2), + add_cone(f"{name}_beard", 0.038, 0.004, 0.075, (hx + 0.02, hy - 0.03, 0.030), + beard, verts=8, rot=(math.radians(96), 0, math.radians(20))), + add_ico(f"{name}_nose", 0.011, (hx + 0.01, hy - 0.035, 0.046), skin, subdiv=1), + ], "head", root) + + # The hat, off and on its side — the single most legible piece of him. + join_group([add_cone(f"{name}_hat", 0.050, 0.002, 0.14, (-0.15, 0.07, 0.026), + hat, verts=10, rot=(math.radians(90), 0, + math.radians(-35)))], "hat", root) + + shards = [] + for i in range(6): + a = math.tau * rng.random() + d = rng.uniform(0.10, 0.26) + s = rng.uniform(0.010, 0.022) + shards.append(add_box(f"{name}_shard_{i}", (s, s * 1.4, s * 0.7), + (math.cos(a) * d, math.sin(a) * d, s * 0.35), + coat if i % 2 else base_m, + rot=(0, 0, rng.uniform(0, math.tau)))) + join_group(shards, "shards", root) + + stamp(root, name, "prop") + root["broken_variant_of"] = "garden_gnome_01" + root["collateral_value"] = 25 + return root + + +def build_fence_panel_broken(name): + """A panel the storm went through. Same 2.4 m tile footprint and origin as + fence_panel, so Lane A drops it into the run in place of one instance rather + than re-tiling the fence. + + A panel does not disintegrate — it loses a few palings and hangs off one + rail. Keeping most of it standing is what makes the gap read as damage + instead of as a design choice. + """ + rng = rng_for(name) + root = add_empty(name) + timber = get_material("Mat_Timber", PAL["timber"], 0.85) + rail_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85) + width, h = 2.4, 1.8 + pw = 0.09 + n = 24 + step = width / n + + standing, ground = [], [] + for i in range(n): + x = -width / 2 + step * (i + 0.5) + roll = rng.random() + if 9 <= i <= 13 and roll < 0.75: + # The hole: snapped low, or gone entirely onto the grass. + if roll < 0.42: + continue + ph = rng.uniform(0.35, 0.72) # jagged stump + standing.append(add_box(f"{name}_snapped_{i:02d}", (pw, 0.019, ph), + (x, 0, ph / 2), timber)) + elif roll < 0.10: + # One paling hanging by a single nail, swung off vertical. + standing.append(add_box(f"{name}_hanging_{i:02d}", (pw, 0.019, h * 0.8), + (x + 0.06, 0.01, h * 0.42), timber, + rot=(0, rng.uniform(0.25, 0.5), 0))) + else: + ph = h + rng.uniform(-0.02, 0.02) + standing.append(add_box(f"{name}_paling_{i:02d}", (pw, 0.019, ph), + (x, 0, ph / 2), timber)) + join_group(standing, "palings", root) + + # Top rail snapped through the gap; bottom rail survives. + rails = [add_box(f"{name}_rail_bot", (width, 0.035, 0.07), (0, 0.027, 0.35), + rail_m), + add_box(f"{name}_rail_top_l", (width * 0.42, 0.035, 0.07), + (-width * 0.29, 0.027, 1.45), rail_m), + add_box(f"{name}_rail_top_r", (width * 0.30, 0.035, 0.07), + (width * 0.35, 0.027, 1.45), rail_m, + rot=(rng.uniform(0.05, 0.14), 0, 0))] + join_group(rails, "rails", root) + + # The pieces that left, lying on the grass in front of the hole. Kept to + # snapped lengths and tucked close: the fence sits on the yard boundary, so + # a full-length paling flung a metre out pokes through whatever is on the + # other side of it. Wreckage should read as wreckage, not reach. + for i in range(3): + ground.append(add_box(f"{name}_down_{i}", (pw, 0.019, rng.uniform(0.5, 0.95)), + (rng.uniform(-0.2, 0.6), rng.uniform(-0.40, -0.15), + 0.012), + timber, rot=(math.pi / 2, 0, rng.uniform(-0.5, 0.5)))) + join_group(ground, "debris_palings", root) + + stamp(root, name, "fence") + root["broken_variant_of"] = "fence_panel" + root["tile_step"] = width + return root + + # ============================================================================ # GRASS ATLAS — a texture, not geometry (PLAN3D §5-E item 9) # ============================================================================ @@ -1354,6 +1479,17 @@ ASSETS = [ dict(name="garden_gnome_01", fn=build_garden_gnome_01, dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)), nodes=["gnome"]), + # Aftermath wreckage (SPRINT3 §Lane E-2). Each keeps its intact twin's origin + # and footprint so Lane A swaps in place. + dict(name="garden_gnome_01_broken", fn=build_garden_gnome_01_broken, + dims=((0.30, 0.70), (0.25, 0.65), (0.08, 0.20)), + nodes=["stump", "head", "hat", "shards"]), + # Deeper than fence_panel on purpose: the snapped palings lie on the grass in + # front of it. Bounded so wreckage on a boundary fence can't reach through + # whatever is behind it. + dict(name="fence_panel_broken", fn=build_fence_panel_broken, + dims=((2.38, 2.60), (0.03, 1.05), (1.70, 1.90)), + nodes=["palings", "rails", "debris_palings"]), ] @@ -1572,10 +1708,15 @@ def verify_all(only=None): problems.append(f"{tris} tris > {TRI_BUDGET} budget") # The capsule beside it — the actual acceptance criterion. Skipped for - # hardware: a 1.7 m human next to a 60 mm shackle tells you nothing and - # zooms the shackle down to one pixel. Below 0.30 m the printed dims are - # the scale check, and the tile's job is proving the thing READS. - show_capsule = name != "ref_capsule" and max(dims) >= 0.30 + # small things: a 1.7 m human next to a 60 mm shackle tells you nothing + # and zooms the shackle down to one pixel. Below the cut the printed dims + # are the scale check, and the tile's job is proving the thing READS. + # + # Keyed on HEIGHT, not max(dims): the capsule answers "how big is this + # next to a person", which is a question about how tall it stands. Flat + # wreckage spread 0.39 m across the grass but standing 0.11 m is small- + # object territory — measuring its scatter against a human just buries it. + show_capsule = name != "ref_capsule" and dims[2] >= 0.30 if show_capsule: build_ref_capsule("ref_capsule") for o in bpy.data.objects: diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png index 70152e0..32af97e 100644 Binary files a/tools/blender/contact_sheet.png and b/tools/blender/contact_sheet.png differ diff --git a/web/world/js/tests/e.test.js b/web/world/js/tests/e.test.js index 9810f55..3525000 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -73,6 +73,10 @@ const ASSETS = [ nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' }, { name: 'washing_line_01', h: [2.0, 2.4], nodes: ['mast', 'head', 'arms'] }, { name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] }, + { name: 'garden_gnome_01_broken', h: [0.08, 0.20], + nodes: ['stump', 'head', 'hat', 'shards'] }, + { name: 'fence_panel_broken', h: [1.70, 1.90], + nodes: ['palings', 'rails', 'debris_palings'] }, ]; function sizeOf(gltf) { @@ -234,6 +238,45 @@ export default async function run(t) { assert(Number.isFinite(before.x) && Number.isFinite(after.x), 'arms world position is not finite'); }); + // Custom props are a contract, not decoration — and I have been telling other + // lanes to read these since Sprint 1 without ever checking they survive the + // export. glTF `extras` arrive as three's userData, but only if export_extras + // held all the way through; if it silently dropped, Lane A's gnome scores $0 + // and Lane B's anchors all rate the same, both of which would look like a + // gameplay decision rather than a missing field. + t.test('glTF extras survive as userData — the props other lanes read', () => { + const gnome = loaded.get('garden_gnome_01')?.scene.getObjectByName('garden_gnome_01'); + assert(gnome, 'gnome root node missing'); + assert(gnome.userData?.collateral_value === 25, + `collateral_value lost (userData=${JSON.stringify(gnome.userData)}) — Lane A scores off this`); + + const canopy = loaded.get('tree_gum_01')?.scene.getObjectByName('canopy'); + assert(typeof canopy?.userData?.sway_amp === 'number', + 'canopy.sway_amp lost — world.js per-tree sway tuning reads it'); + + const branch = loaded.get('tree_gum_01')?.scene.getObjectByName('branch_anchor_01'); + assert(typeof branch?.userData?.rating_hint === 'number', + 'branch_anchor_01.rating_hint lost — Lane B picks anchors on it'); + + const bin = loaded.get('wheelie_bin_01')?.scene.getObjectByName('wheelie_bin_01'); + assert(typeof bin?.userData?.mass_hint === 'number', + 'wheelie_bin mass_hint lost — Lane C throws it with this'); + }); + + // The wreckage has to drop into the intact asset's place, so both variants + // stand on the same ground plane. If the broken one floats or sinks, Lane A's + // swap needs a fudge offset per prop and will grow one. + t.test('broken variants sit on the same ground plane as their intact twin', () => { + for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'], + ['fence_panel', 'fence_panel_broken']]) { + for (const n of [intact, broken]) { + const box = new THREE.Box3().setFromObject(loaded.get(n).scene); + assert(Math.abs(box.min.y) < 0.03, + `${n} rests at y=${box.min.y.toFixed(3)}, not on the ground`); + } + } + }); + // One GLB carries three wilt states as siblings; Lane A toggles .visible // rather than reloading, so all three have to be present at once. t.test('garden_bed carries all 3 damage states in one GLB', () => { diff --git a/web/world/models/fence_panel_broken_v1.glb b/web/world/models/fence_panel_broken_v1.glb new file mode 100644 index 0000000..dca5ef7 Binary files /dev/null and b/web/world/models/fence_panel_broken_v1.glb differ diff --git a/web/world/models/garden_gnome_01_broken_v1.glb b/web/world/models/garden_gnome_01_broken_v1.glb new file mode 100644 index 0000000..cdcc281 Binary files /dev/null and b/web/world/models/garden_gnome_01_broken_v1.glb differ