diff --git a/THREADS.md b/THREADS.md index da7c55f..adfa0b9 100644 --- a/THREADS.md +++ b/THREADS.md @@ -505,3 +505,74 @@ Format: `[lane letter] YYYY-MM-DD β€” note` a storm from a headless browser β€” which is what this sprint's "90 s storm_02 run captured" acceptance needs. Same code path the rAF loop uses; no test-only branch that can drift. Everything I reported above was measured through them. + +[E] 2026-07-16 β€” πŸ› **LANE A β€” two of my "handles" were broken and are now fixed. Read before you dress + the yard (your step 5), because both would have failed silently rather than loudly.** + Β· **Canopy sway.** world.js sways a tree by rotating a `canopy` group whose origin is at the trunk + top, so the blobs swing about the trunk. My trees shipped `canopy_01..03` as siblings of `trunk`, + each with its origin at its OWN centre β€” rotating one spins a sphere in place, which renders as + nothing. You could not have swayed my trees, and since the canopy lean IS the gust telegraph the + player reads a beat before it hits the sail, the tell would have gone *missing*, not gone wrong. + Fixed: there is now a `canopy` empty at the trunk top with the blobs parented under it, so your + existing code works **unchanged** β€” `getObjectByName('canopy')` and rotate. + Β· **`rake_pivot`.** Same trap, worse. It shipped as a childless empty: rotating it moved nothing, + and rotating the whole GLB instead would have tipped the concrete footing out of the ground along + with the post. Fixed: `rake_pivot` is now a real group holding `post` + `pad_eye` + `top_anchor`, + with `footing` left on the root. Rotate `rake_pivot` by your 8Β° and the post rakes while the + concrete stays planted. Asserted both ways in e.test.js (head must move >0.3 m, footing <0.01 m). + Both are the same class of bug and I only found them by driving the handles in a test rather than + eyeballing the model. If you add a handle to anything, rotate it in an assert. + +[E] 2026-07-16 β€” per-tree sway tuning (SPRINT2 Β§Lane E-1): the `canopy` group carries `sway_amp`, + `sway_phase` and `sway_pivot_y` as glTF extras. gum_01 is big and heavy-limbed at amp 0.85; gum_02 is + whippy at 1.20 and should show a gust front first β€” free readability if you multiply your `lean` by it + and use `sway_phase` instead of the hardcoded 0.7 / 2.9. Individual blobs also carry their own + `sway_amp` (outer/higher = larger) if you ever want secondary motion. Geometry is byte-identical to + Sprint 1 β€” `sway_phase` draws from its own RNG stream precisely so adding a handle couldn't + resilhouette a tree you'd already tuned against. + +[E] 2026-07-16 β€” ⚠️ **LANE B β€” the sail can't take a texture yet: `createSailView` builds `position` and + `index` only, no `uv`.** three defaults a missing UV to (0,0), so `map` would sample one texel and the + whole membrane would read as flat colour β€” it'd look like the texture "didn't work" rather than like + a bug. `sail_weave.png` (512Β², seamless, knitted HDPE with the stripe banding real shade cloth has) is + in `models/textures/`. The recipe, against your `N*N` grid: + const N = rig.N, uv = new Float32Array(N * N * 2); + for (let j = 0, k = 0; j < N; j++) + for (let i = 0; i < N; i++, k += 2) { uv[k] = i / (N - 1); uv[k + 1] = j / (N - 1); } + geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); + const tex = await new THREE.TextureLoader().loadAsync('/world/models/textures/sail_weave.png'); + tex.wrapS = tex.wrapT = THREE.RepeatWrapping; + tex.repeat.set(6, 6); // ~6 tiles across a 5 m sail + tex.colorSpace = THREE.SRGBColorSpace; // r175: colorSpace, not encoding + mat.map = tex; // keep mat.color β€” the weave multiplies it + The tile is seamless *by construction* and the build asserts it (it evaluates a second tile and + requires an exact match), because a bad wrap is a seam every tile across the whole sail. Shout if you'd + rather I ship it at a different density. `sail_tears.png` (1024Γ—256, 4 escalating rips w/ alpha) is + there for M3 whenever tearing lands β€” no rush. + +[E] 2026-07-16 β€” dressing set landed (SPRINT2 Β§Lane E-3), all deterministic + contact-sheeted as usual: + Β· `debris/wheelie_bin_01_v1.glb` β€” 240 L kerbside bin, 0.58Γ—0.68Γ—1.12 m, `mass_hint` 12 (empty; a + full one doesn't blow over). `lid` is its own pivot group with `flap_max_deg` 75 β€” it flaps before + the bin goes over, which is a free "wind is up" tell. Lane C: it's in debris/, so your glob has it. + Β· `washing_line_01_v1.glb` β€” a Hills Hoist, 2.84Γ—2.84Γ—2.28 m. `head` is a free-spin pivot group + carrying `arms`: it spins up in a gust, giving a second wind tell at head height, right where the + player is working. Not debris β€” it's cemented in. + Β· `garden_gnome_01_v1.glb` β€” 0.36 m, `mass_hint` 4.5, `breakable`, `collateral_value` 25. Scoring + bait per DESIGN.md's collateral rule: a smashed gnome reads instantly where a damage number + doesn't. Lane A, he wants to be somewhere a flogging sail can reach him. + +[E] 2026-07-16 β€” πŸ”’ **SPRINT2 Β§Lane E-4 (contact-sheet the assembled yard) is BLOCKED on Lane A's step 5.** + Checked main at de86aa1: `world.js` and `main.js` contain zero `_v1.glb` references, so the yard is + still graybox β€” the trees the game renders are procedural spheres, not my gums. I've captured the + gate-1 yard as a baseline (player + live wind + garden bed, looks genuinely assembled) but a dressing + contact sheet of graybox would be a picture of nothing. **Ping me here the moment your dressing swap + lands and I'll shoot it for DESIGN.md same session.** Everything you need is above; nothing of mine is + blocking you. + +[E] 2026-07-16 β€” πŸ‘€ art note for whoever owns lighting (A?), from actually looking at the running game: + with the sun in the north the tree canopies read as near-black slabs from the yard. That's physically + right β€” you called the backlit house wall correct and not a bug, and this is the same thing β€” but + backlit foliage going flat black is the one place it costs more than it teaches, because the canopy is + the gust tell. Cheap fixes in your court: lift the hemisphere light's ground colour, or I can bake a + little emissive into the leaf material so gums stay readable from underneath. Say the word and it's + one constant in my palette β€” not touching it unprompted since lighting is yours. diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index 55e6812..e9fe59f 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -1,190 +1,6 @@ { "blender": "5.1.2", "assets": [ - { - "name": "ref_capsule", - "dims": [ - 0.4, - 0.4, - 1.7 - ], - "tris": 220, - "nodes": [ - "head_height", - "ref_capsule", - "ref_capsule_mesh" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "tree_gum_01", - "dims": [ - 4.5522, - 4.956, - 7.9702 - ], - "tris": 396, - "nodes": [ - "branch_anchor_01", - "branch_anchor_02", - "branch_anchor_03", - "canopy", - "canopy_01", - "canopy_02", - "canopy_03", - "tree_gum_01", - "trunk" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "tree_gum_02", - "dims": [ - 3.8871, - 2.7787, - 5.4972 - ], - "tris": 288, - "nodes": [ - "branch_anchor_01", - "branch_anchor_02", - "canopy", - "canopy_01", - "canopy_02", - "tree_gum_02", - "trunk" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "fence_post", - "dims": [ - 0.13, - 0.13, - 2.03 - ], - "tris": 24, - "nodes": [ - "fence_post", - "post" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "fence_panel", - "dims": [ - 2.4, - 0.054, - 1.8194 - ], - "tris": 324, - "nodes": [ - "fence_panel", - "palings", - "rails" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "gate", - "dims": [ - 1.045, - 0.0615, - 1.75 - ], - "tris": 220, - "nodes": [ - "gate", - "gate_frame", - "gate_palings", - "hinge_axis", - "hinges" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "house_yardside", - "dims": [ - 9.2, - 1.0547, - 2.9 - ], - "tris": 200, - "nodes": [ - "door", - "fascia", - "fascia_anchor_01", - "fascia_anchor_02", - "fascia_anchor_03", - "gutter", - "house_yardside", - "roof", - "wall", - "window" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "shed_01", - "dims": [ - 2.58, - 1.9708, - 2.2224 - ], - "tris": 96, - "nodes": [ - "door_anchor", - "doors", - "roof", - "shed_01", - "shell" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "shed_table", - "dims": [ - 1.6, - 0.6, - 0.9 - ], - "tris": 72, - "nodes": [ - "pickup_anchor", - "shed_table", - "table_frame", - "table_top" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "garden_bed", - "dims": [ - 3.0, - 1.2, - 0.8609 - ], - "tris": 2580, - "nodes": [ - "bed", - "garden_bed", - "plants_dead", - "plants_full", - "plants_tattered", - "soil" - ], - "status": "PASS", - "problems": [] - }, { "name": "sail_post", "dims": [ @@ -203,178 +19,7 @@ ], "status": "PASS", "problems": [] - }, - { - "name": "ladder_01", - "dims": [ - 0.455, - 0.075, - 3.0 - ], - "tris": 276, - "nodes": [ - "ladder", - "ladder_01", - "ladder_base", - "ladder_top" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "shackle", - "dims": [ - 0.0569, - 0.019, - 0.0744 - ], - "tris": 560, - "nodes": [ - "bow", - "pin", - "shackle" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "carabiner", - "dims": [ - 0.049, - 0.009, - 0.1027 - ], - "tris": 476, - "nodes": [ - "body", - "carabiner", - "gate" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "turnbuckle", - "dims": [ - 0.0292, - 0.0341, - 0.1955 - ], - "tris": 728, - "nodes": [ - "body", - "eye_a", - "eye_b", - "turnbuckle" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "tramp_01", - "dims": [ - 2.9555, - 2.9555, - 0.78 - ], - "tris": 976, - "nodes": [ - "legs", - "mat", - "pad", - "rim", - "tramp_01" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "wheelie_bin_01", - "dims": [ - 0.58, - 0.6808, - 1.1188 - ], - "tris": 120, - "nodes": [ - "bin_body", - "lid", - "lid_plate", - "wheelie_bin_01", - "wheels" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "washing_line_01", - "dims": [ - 2.8441, - 2.8441, - 2.2777 - ], - "tris": 336, - "nodes": [ - "arms", - "head", - "mast", - "washing_line_01" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "garden_gnome_01", - "dims": [ - 0.1427, - 0.15, - 0.365 - ], - "tris": 236, - "nodes": [ - "garden_gnome_01", - "gnome" - ], - "status": "PASS", - "problems": [] } ], - "debris": [ - { - "file": "BlueCrate_v2.glb", - "dims": [ - 0.36, - 0.36, - 0.29 - ], - "sane": true - }, - { - "file": "BlackTub_v2.glb", - "dims": [ - 0.36, - 0.54, - 0.2 - ], - "sane": true - }, - { - "file": "WhiteTub_v2.glb", - "dims": [ - 0.36, - 0.54, - 0.2 - ], - "sane": true - }, - { - "file": "WoodenBin_v2.glb", - "dims": [ - 0.35, - 0.36, - 0.31 - ], - "sane": true - } - ] + "debris": [] } \ No newline at end of file diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 57ff64f..d6662a2 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -829,26 +829,40 @@ def build_sail_post(name): conc = get_material("Mat_Concrete", PAL["concrete"], 0.95) H, R = 4.0, 0.048 + # The footing is cast into the ground and stays put β€” only the post rakes. join_group([add_cyl(f"{name}_collar", 0.26, 0.14, (0, 0, 0.05), conc, verts=14), add_cyl(f"{name}_collar_top", 0.22, 0.04, (0, 0, 0.13), conc, verts=14)], "footing", root) - join_group([add_cyl(f"{name}_shaft", R, H, (0, 0, H / 2), steel, verts=12), - add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, - verts=12), - add_cyl(f"{name}_cap", R * 1.15, 0.02, (0, 0, H), dark, - verts=12)], "post", root) - # Pad eye at the head β€” where the corner chain actually clips on. - join_group([add_box(f"{name}_padeye", (0.012, 0.07, 0.09), - (0, 0, H - 0.10), dark), - add_arc_tube(f"{name}_eye", 0.026, 0.008, 0, math.tau, dark, - segs=10, center=(0, 0, H - 0.02), plane='XZ')], - "pad_eye", root) - e = add_empty("top_anchor", (0, 0, H - 0.02), root, size=0.2) + # rake_pivot is a GROUP, not a marker. Everything above the footing hangs off + # it, so rotating it rakes the post while the concrete stays level in the + # ground. Shipping it as a childless empty (as Sprint 1 did) means rotating + # it moves nothing, and rotating the whole GLB instead tips the footing out + # of the dirt with it. Same trap as the canopy handle. Asserted in e.test.js. + rake = add_empty("rake_pivot", (0, 0, 0.12), root, size=0.25) + rake["rake_axis"] = "x/z β€” rake AWAY from the load (DESIGN.md)" + rake["rake_default_deg"] = 8 + + above = [] + above.append(join_group([ + add_cyl(f"{name}_shaft", R, H, (0, 0, H / 2), steel, verts=12), + add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, verts=12), + add_cyl(f"{name}_cap", R * 1.15, 0.02, (0, 0, H), dark, verts=12), + ], "post")) + # Pad eye at the head β€” where the corner chain actually clips on. + above.append(join_group([ + add_box(f"{name}_padeye", (0.012, 0.07, 0.09), (0, 0, H - 0.10), dark), + add_arc_tube(f"{name}_eye", 0.026, 0.008, 0, math.tau, dark, segs=10, + center=(0, 0, H - 0.02), plane='XZ'), + ], "pad_eye")) + + e = add_empty("top_anchor", (0, 0, H - 0.02), size=0.2) e["anchor_type"] = "post" e["rating_hint"] = 0.9 - add_empty("rake_pivot", (0, 0, 0.12), root, size=0.25) + above.append(e) + for o in above: + parent_keep_transform(o, rake) stamp(root, name, "hardware") root["post_height"] = H root["rake_note"] = "rotate about rake_pivot; rake away from the load" diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png index ce72d50..127a6bf 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 562470e..9810f55 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -180,6 +180,36 @@ export default async function run(t) { 'the blob centre, not the trunk top, so the tree cannot visibly sway'); }); + // DESIGN.md: rake the post away from the load β€” so rake is a runtime rotation, + // not baked. Rotating rake_pivot must carry the post and its top_anchor over + // while the concrete footing stays level in the ground. A post whose footing + // tips out of the dirt with it isn't raked, it's falling. + t.test('sail_post rakes about rake_pivot with the footing left planted', () => { + const g = loaded.get('sail_post'); + assert(g, 'sail_post did not load'); + const rake = g.scene.getObjectByName('rake_pivot'); + const anchor = g.scene.getObjectByName('top_anchor'); + const footing = g.scene.getObjectByName('footing'); + assert(rake && anchor && footing, 'sail_post needs rake_pivot, top_anchor and footing'); + + const at = () => { + g.scene.updateWorldMatrix(true, true); + return [new THREE.Vector3().setFromMatrixPosition(anchor.matrixWorld), + new THREE.Vector3().setFromMatrixPosition(footing.matrixWorld)]; + }; + const [a0, f0] = at(); + rake.rotation.x += (8 * Math.PI) / 180; // Lane A rakes 8Β° + const [a1, f1] = at(); + rake.rotation.x -= (8 * Math.PI) / 180; + g.scene.updateWorldMatrix(true, true); + + const head = a0.distanceTo(a1), foot = f0.distanceTo(f1); + assert(head > 0.3, + `an 8Β° rake moved the head only ${head.toFixed(3)} m β€” rake_pivot has no children`); + assert(foot < 0.01, + `the footing moved ${foot.toFixed(3)} m β€” concrete should stay planted`); + }); + // Same pivot class as the canopy: the Hills Hoist head freewheels, so spinning // it has to carry the arms round. If the arms were parented to the root, the // head would turn and nothing would move. diff --git a/web/world/models/sail_post_v1.glb b/web/world/models/sail_post_v1.glb index df9fd96..878e3cd 100644 Binary files a/web/world/models/sail_post_v1.glb and b/web/world/models/sail_post_v1.glb differ