Hail (SPRINT5 §E-1) ships two ways because Lane C's rain is a BoxGeometry with a
flat material and skyfx loads no external texture at all: hail_stone_01 as
geometry that drops into their existing InstancedMesh pattern, and hail_pips.png
as a 4-cell atlas for impacts. A flat quad can't be round and an impact is round
— that's the only reason the atlas is a texture. Both come with a copy-paste
recipe, because a grep showed exactly one of my five shipped textures is
consumed: sail_weave, the one I wrote a recipe for. grass_atlas has sat
unreferenced for four sprints.
plant_shred (§E-2) is elongated after the first pass came out radial and read as
green potatoes — it's the long axis plus the midrib that says "leaf".
tools/yardshot/ finally lands the DESIGN.md pictures, carried since Sprint 2. It
does NOT touch server.py or main.js: it's a Lane E stdlib server that serves the
repo and takes POST /shot, so the browser posts a Blob and bytes go straight to
disk instead of base64 through a console. do_POST is ~25 lines and Lane A is
welcome to lift it and delete this.
Three traps found on the way, all documented: toBlob is async so the WebGL buffer
is already cleared (toDataURL is sync); a backgrounded tab lays the canvas out at
0x0 and toDataURL then returns "data:,"; rAF is paused there so step() must be
driven by hand.
Selftest 209/0/0, 36 output files byte-identical across two runs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pond (SPRINT4 §E-1, decision 10): pond_water.png and pond_normal.png, for a
patch Lane B builds from the cloth's own nodes — same ride-the-nodes rule as the
tears, since a rigid disc would sit still while the belly moves under it. Alpha
is a radial feather and RGB darkens toward the centre, both encoded radially so
the shading survives being scaled per pondMass (radius ~ sqrt(mass)). Water is
grey-green, not blue: rain caught in a sail is shallow, murky and mirrors an
overcast sky.
Both at 256², not 512²: smooth low-frequency content, and at 512 they were
256 KB + 320 KB against ~20 KB for every other texture here. The chop is seven
waves at golden-angle directions rather than three crossed sines, which
interfered into a lattice and read as basketweave; the normal map gets six
integer-frequency waves for the same reason, and still asserts its own tiling.
broom_01 (§E-2): poke_tip sits on the bristle end deliberately — a broomstick
jabbed at a loaded sail holes it.
Renames fence_panel_broken to fence_panel_snapped to match SPRINT4 §A-4/§E-3.
Lane A's dress() guards every load, so the mismatch would not have crashed —
the wreckage would silently never have appeared, which is worse.
All 21 assets pass, 33 output files byte-identical across two runs, 186/0/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The --only runs during the sprint left a partial sheet committed. Also fills the
empty slots in a partly-filled last row with the background sampled from a
tile's corner, so 19 assets in a 4x5 grid no longer leaves a black hole. The
world colour can't be reused for this — the render is sRGB-encoded, the scene
value is linear.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
rake_pivot shipped as a childless empty, so rotating it moved nothing — and
rotating the whole GLB instead, which is the only alternative, tips the concrete
footing out of the ground along with the post. DESIGN.md makes raking away from
the load a player decision, so the handle has to actually work.
It now holds post + pad_eye + top_anchor, with footing left on the root: rake it
8° and the post leans while the concrete stays planted. Asserted both directions
in e.test.js — the head must travel over 0.3 m and the footing under 0.01.
Same class as the canopy bug, found the same way: by driving the handle in a
test instead of eyeballing the model. Exported dims unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Textures (SPRINT2 §Lane E-2). sail_weave.png is a seamless 512² knitted
shade-cloth weave with the stripe banding real cloth has; the script proves the
wrap by evaluating a second tile and requiring an exact match, because Lane B is
being told to set RepeatWrapping and a bad wrap would seam across the whole
sail. Luminance rides in a narrow band so it multiplies the base colour instead
of replacing it — high contrast reads as burlap, not HDPE. Dropped the per-pixel
noise: invisible at ±0.012 and incompressible, it cost 305 KB of the 323.
sail_tears.png is a strip of 4 escalating rips. Each is a lens, not a slit —
fabric under tension parts widest in the middle and tapers to a point, and a
constant-width gap reads as a drawn line. Threads bridge the gap, scaled to the
local width; without them a dark lens is a hole rather than a tear.
Dressing (§Lane E-3): wheelie_bin_01 (240 L, 12 kg, `lid` on its own node so it
flaps before the bin goes over), washing_line_01 (a Hills Hoist — the `head`
freewheels, giving a second wind tell at head height), garden_gnome_01
(collateral bait: a smashed gnome reads where a damage number doesn't).
Bin and tramp land in models/debris/ for Lane C to glob. All 19 assets pass, 28
output files byte-identical across two runs, selftest 129/0/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
world.js sways a tree by rotating a `canopy` group whose origin sits at the
trunk top, so the blobs swing about the trunk. The trees shipped canopy_01..03
as siblings of `trunk` with each origin at its own blob centre — rotating one
spins a sphere in place, which is visually nothing. Lane A could not have swayed
these trees, and the canopy lean is the gust telegraph the player reads a beat
before it hits the sail, so the tell would have gone missing rather than looked
wrong.
Adds the `canopy` empty at the trunk top with the blobs parented under it, so
A's existing code works unchanged, plus sway_amp / sway_phase / sway_pivot_y
for the per-tree tuning SPRINT2 §Lane E asks for (gum_01 is big and leans less
at 0.85; gum_02 is whippy at 1.20 and shows a gust first).
sway_phase draws from its own RNG stream: taking it from the shared one would
consume a value and shift every blob draw after it, resilhouetting a tree the
other lanes have already tuned against. Exported dims are identical to Sprint 1.
e.test.js asserts the pivot by rotating the handle and measuring that a blob
actually travels — red before this change, green after.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verifying the GLBs in three.js (not just Blender) showed four assets reporting
inflated bounds: tramp_01 came back 3.29 x 1.27 m against a true 2.96 x 0.78.
Box3.setFromObject expands each mesh's LOCAL box by the world matrix, so a node
carrying a rotation over-reports — the same trap Blender's obj.bound_box sets,
and what three uses for frustum culling. Joined nodes inherited parts[0]'s
rotation, which for an arc is half a segment step off-axis.
Applying rotation at join time makes every local box axis-aligned, so the
default Box3 path is now correct for consumers and culling is tight. World
geometry is unchanged; the exported dims are identical.
Adds tools/assetcheck/, the three.js harness that caught this. It's the only
check that can: a Blender round-trip exports Z-up->Y-up and imports Y-up->Z-up,
so a broken export_yup flips back and passes green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16 generated assets plus the grass billboard atlas and the four debris models
copied verbatim from the 3D-STORE library (copies rule, §0). All meter-scale,
Y-up, and far under the 15k tri budget — garden_bed is heaviest at 2,580.
Regeneration is byte-deterministic: two consecutive runs produce identical
files, so re-running the factory causes no churn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One script regenerates every nature/hardware asset in PLAN3D §5-E, following
the house idiom from 3D-STORE/racks_to_glb.py: reset per asset, build under a
root empty at the origin, join by group, stamp props, export Y-up GLB.
Groups are joined per sway-unit rather than per-asset, so trees keep trunk and
canopy_* as separate nodes for Lane A to animate. Paths resolve from __file__
instead of a hardcoded home dir, since the library lives elsewhere on this box.
Verification re-imports each exported GLB from disk and asserts dims, tri
budget, and node-name survival, then renders it against the 1.7 m ref capsule.
Checking the file rather than the in-memory scene is what makes it a real test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>