Add aftermath wreckage props and verify glTF extras survive

The aftermath screen needs something to point at (SPRINT3 §Lane E-2).
garden_gnome_01_broken snaps at the ankles with the base left where the player
last saw him, head rolled clear with the beard still on, hat off — recognisably
him rather than a shattered pile, because wreckage nobody can identify is just
gravel. fence_panel_broken loses a few palings and hangs one off a nail; most of
it stays standing, which is what makes the hole read as damage rather than as a
design choice. Both keep their intact twin's origin and ground plane so Lane A
swaps mesh-for-mesh with no offsets, asserted both ways.

Also asserts something claimed since Sprint 1 and never checked: that glTF
extras reach three's userData. They do — the gnome's collateral_value, the
canopy's sway_amp, branch_anchor rating_hint and the bin's mass_hint all arrive
as numbers. Had that silently dropped, Lane A's gnome would score $0 and every
anchor would rate identical, which reads as a gameplay decision rather than a
missing field.

Contact-sheet framing now keys the capsule off height, not max(dims): the broken
gnome is 0.39 m across but stands 0.11, so spread-based framing buried it the
same way it once buried the shackle.

Selftest 175/0/0, 30 output files byte-identical across two runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 01:23:10 +10:00
parent 823dbb942b
commit fccbeb9319
7 changed files with 295 additions and 4 deletions

View File

@ -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 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 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. 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.

View File

@ -337,6 +337,41 @@
], ],
"status": "PASS", "status": "PASS",
"problems": [] "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": [ "debris": [

View File

@ -1123,6 +1123,131 @@ def build_garden_gnome_01(name):
return root 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) # 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, dict(name="garden_gnome_01", fn=build_garden_gnome_01,
dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)), dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)),
nodes=["gnome"]), 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") problems.append(f"{tris} tris > {TRI_BUDGET} budget")
# The capsule beside it — the actual acceptance criterion. Skipped for # 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 # small things: a 1.7 m human next to a 60 mm shackle tells you nothing
# zooms the shackle down to one pixel. Below 0.30 m the printed dims are # and zooms the shackle down to one pixel. Below the cut the printed dims
# the scale check, and the tile's job is proving the thing READS. # are the scale check, and the tile's job is proving the thing READS.
show_capsule = name != "ref_capsule" and max(dims) >= 0.30 #
# 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: if show_capsule:
build_ref_capsule("ref_capsule") build_ref_capsule("ref_capsule")
for o in bpy.data.objects: for o in bpy.data.objects:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@ -73,6 +73,10 @@ const ASSETS = [
nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' }, nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' },
{ name: 'washing_line_01', h: [2.0, 2.4], nodes: ['mast', 'head', 'arms'] }, { 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', 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) { 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'); 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 // One GLB carries three wilt states as siblings; Lane A toggles .visible
// rather than reloading, so all three have to be present at once. // rather than reloading, so all three have to be present at once.
t.test('garden_bed carries all 3 damage states in one GLB', () => { t.test('garden_bed carries all 3 damage states in one GLB', () => {

Binary file not shown.

Binary file not shown.