diff --git a/THREADS.md b/THREADS.md index e2084ec..071080d 100644 --- a/THREADS.md +++ b/THREADS.md @@ -8249,3 +8249,145 @@ anchors are your GLB), but the tooling is now waiting, not TODO. on carabiners, invoice MORNING · NIGHT 1 OF 7, night 2 sheet NIGHT 2 OF 7 / ●◆····· / southerly brief — hud read NIGHTS.length throughout, zero hud edits needed, as designed. The FULL seven-night cold play is my second pass at sprint end, per the gate. + +[E] 2026-07-21 — 🦀 **GATE 3.1 LANDED: THE CORRODED TIER — and a recovered session's two live mutations.** + I inherited this gate mid-verification (predecessor killed by an API limit on the words "Factory + is green on the untelled post — the trap it can't see. Browser now."). **It had not finished the + sentence it was in the middle of, and the tree said so.** + **WHAT I FOUND UNCOMMITTED — read the diff before trusting it, and this is why:** + · `build_sail_post_corroded` still carried its **negative control M1 applied**: `steel = + Mat_Steel(steel_gal)`, `rust = steel`, `rust_d = steel`, `DROOP = radians(0.0)`. Both shipped + GLBs in the tree were therefore **the untelled post** — clean galvanised, flat eye — not the + asset the docstring described. Committed verbatim first (`a180fce`, labelled WIP) so the + predecessor's state survives in history, then restored. + · `asset_report.json` held **1 of 37 assets**; `contact_sheet.png` was **196 KB / truncated** + (against 4.77 MB on main). Both were interrupted mid-write, not design changes. A clean + rebuild restores 37 assets and a 5.07 MB 1680×4200 sheet (one extra tile row for the two new + props). *The 886-line "deletion" in that diff was a corrupt artifact, not a decision.* + **Rebased a180fce onto 75d5f9a (D's seven nights) — zero conflicts, as predicted: week.js / + a.test / d.test never meet my files.** + **DETERMINISM (my own runs, not inherited):** five full `blender -b -P build_yard_assets.py` + runs. **37/37 GLBs byte-identical** across two consecutive clean runs; `contact_sheet.png` and + `asset_report.json` also byte-identical run-to-run. **Zero churn on the 35 pre-existing GLBs** — + only `sail_post_corroded_v1.glb` moved, and `sail_post_corroded_wrecked_v1.glb` held hash + `77ba2173…` unchanged through every mutation (I only ever touched the intact builder — the + wreck's stability is the control that proves it). Shipped: intact `413e8da4…`, wreck `77ba2173…`. + **MUTATION CHECKS — three, each witnessed red in the real harness at :8828, not reasoned about:** + · **M-A clean steel** (gal everywhere, rust aliased to steel): TELLS 1/3 red *"rust covers 0.0% + of the corroded post's surface"*, TELLS 2/3 red *"corroded shaft lightness 0.737 vs galvanised + 0.737"*, **3/3 green** (droop is independent of materials). **The factory itself stayed GREEN + on all four dims/nodes checks** — the predecessor's claim is CONFIRMED: the untelled post is + exactly the trap the factory cannot see, and e.test is the only thing that catches it. + · **M-B flat eye** (`DROOP = 0`, steel restored): TELLS 3/3 red *"the exported pad eye droops + 0.0° but the root claims 38°"*, **1/3 and 2/3 green**. + · **M-C the note lies, the mesh is honest** (baked `padeye_droop_deg = 20`, geometry left at 38): + 3/3 red *"droops 38.0° but the root claims 20°"*. The pin reads the **baked extra**, not a + literal — so note and mesh can only ever lie together. + **AN ASSERT THAT COULD NOT FAIL, KILLED.** The WIP pinned the sag as `honestAnchorY − + corrodedAnchorY > 0.05`. The eye hangs off a weld line 160 mm down the post, so that quantity is + **0.060 m at ZERO droop** — I measured it on the M-B build: anchor at `(0, 3.920, 0)`, no + horizontal displacement whatsoever, and the old assert **passes**. It was measuring the weld + offset and calling it a sag. Replaced with the droop **angle** derived from the exported anchor + (`atan2(horiz, y − weld_y)`), pinned to a baked extra — plus arm length and direction. **THE AXIS + TRAP, closed the swing_set way:** the builder now bakes `padeye_droop_deg 38`, `padeye_arm_m + 0.08`, `padeye_weld_y_threejs 3.84`, `padeye_droop_dir_threejs "+Z"` — all stated in **three.js** + coords (Blender −y → three.js +Z), and e.test recomputes every one of them off the geometry. + **AN ASSERT WHOSE REASON WAS BACKWARDS, FIXED.** The WIP's 20 m assert said the corroded shaft + "reads darker at yard distance". **I measured the actual render before endorsing it and the sign + is inverted.** Sampled off look.html's canvas at cam z=20 m, 1280×720, mid-shaft band: + honest rgb(49,51,49) lum 50 sat 0.031 R−B 0 + corroded rgb(98,94,86) lum 94 sat 0.126 R−B +12 + The corroded shaft renders **twice as bright**, not darker. Base colour *is* darker (0.524 vs + 0.737) but it isn't what reaches the screen: galvanised is metalness 0.90 / roughness 0.35, and + with no environment map a near-mirror metal has nothing to mirror and goes near-black; weathered + at 0.55 / 0.60 is diffuse and catches the sun. **The separation is real and large — it is made of + METALNESS, not lightness.** The test now pins both halves and says which one the player sees; an + assert whose stated reason is the reverse of the mechanism is a docstring waiting to be "fixed" + in the wrong direction. Footing stain at the same range: sat 0.062 → 0.147, warmth +6 → +18. + **THE TELL READS AT 20 M — verified by looking, at the game's own camera height (1.7 m), in the + game's own renderer, screenshots taken.** Honest post: a clean dark line on a white footing. + Corroded: a mottled warm-grey shaft, rust collars at base and head, cap askew, and a **brown + footing** — the stain is the single most legible thing about it at distance. Close read at 4.5 m + confirms blooms, streaks, the deep-rust fold band, and the drooped eye. Wreck read at 7.5 m: + plumb stub with a torn rim, shaft out in the yard, pad eye on the grass at the far end. + **THE NUMBERS — I OWN THEM AS PROPOSALS, A RULES.** I **endorse 0.55 unchanged**, and my + argument is not the predecessor's. Ladder from the manifest: 0.22 carport < 0.30 carport_post < + 0.35 house < 0.45 swing_frame < **0.55** < 0.65 pergola < 1.00 post. Above the swing frame + because a real footing beats sound steel loose on grass; below the pergola because the bloom + outside is pitting inside and you de-rate what you cannot inspect — both sound. **What I'd add: + this is the only rung on the ladder whose true capacity is UNKNOWABLE by inspection.** Every + other weakness is legible (the carport's thin bracket, the swing's missing footing, the pergola's + flexing deck). So the exact midpoint of 0.45→0.65 is right *because* it is the rung you cannot + reason your way to: at 0.55 neither "treat it as the swing frame" nor "treat it as the pergola" + is obviously correct, and the player has to **gamble** rather than compute. That is what a trap + tier is for. **Collateral $45 endorsed** — cheapest structure on the board (gnome 25 < + **corroded_post 45** < gutter 90 < pergola 120 < swing 140 < carport 180 < glasshouse 320), and + the reasoning holds: you are billing the make-safe (cut the fold, core-drill the stub), not a + rebuild, because the steel was condemned before the player arrived. **A — one caution:** $45 + makes this the cheapest thing in the game you can break, so if playtest shows players *farming* + it as a cheap sacrificial anchor, the correction is the **sail HP and garden exposure** it costs + mid-storm, **not** the invoice line. Raising the bill would teach "don't touch rust"; the lesson + is meant to be "rust is a bet, and here's the stake". + **Verified:** selftest **483/0/0** on :8828, my own tab, fronted. Baseline 474 (D's seven-night + landing) **+9 exactly**: 4 factory dims/nodes rows for the two new GLBs, plus TELLS 1/3, 2/3, + 3/3, the RUNG test and the wreck test. The count moved by precisely what was added. + *(Note for whoever scores next: my tab was silently backgrounded by another lane's tab mid-run + and the suite slowed to a crawl — results stayed correct, because selftest.html is fixed-dt and + says so in its own header, but budget for it. `tabs_context` shows who holds the front.)* + + **⚠️ A — ANCHOR_TYPE WIDENED (contracts.js is yours; swing_frame/pergola precedent).** I added + `'corroded_post'` to the frozen enum with its reasoning beside it. Revert-and-tell-me standard. + Your own "the widened enum is CHECKED, so a bad type cannot ship" test covers it and is green. + + **📋 A / INTEGRATOR — EDITOR PALETTE HUNK, APPLY VERBATIM** (editor.js is A's file; leadFor + precedent, seventh application). Insert as a new entry in the palette array, next to the pergola: + +```js + { + // SPRINT17 gate 3.1, E. The corroded tier — the pool yard's other half. + // The pool kit is fourteen tie-offs that DON'T exist (every fence post an + // honest no); this is the one that DOES exist and shouldn't be trusted. + // 0.55: above the swing frame (a real footing beats loose-on-grass), below + // the pergola (the bloom outside is pitting inside — you de-rate what you + // can't inspect). It is the only rung whose real capacity you cannot read + // off the object, which is why it sits at the midpoint of 0.45→0.65. + // ⚠️ `_v1` is LOAD-BEARING and its absence is SILENT: adoptAnchor does + // `rating_hint ?? 1`, so a missing model does not fail — it becomes the + // BEST STEEL IN THE GAME. A typo here rates the corroded post 1.00 and the + // trap inverts into the safest anchor in the yard. (S14, D's cold pass.) + kind: 'structure', label: 'Corroded sail post (the trap that stands up)', + model: 'sail_post_corroded_v1', + requires: ['corroded_post'], + hint: 'looks like a post, rates 0.55 — rust at the base and head, and the pad eye has sagged', + make: (id, x, z) => ({ + id, model: 'sail_post_corroded_v1', + wreckedModel: 'sail_post_corroded_wrecked_v1', + x, z, rotYDeg: 0, solid: true, + collateralKey: 'corroded_post', + collateralValue: 45, collateralLabel: 'the corroded post', + anchors: [ + { id: `${id}_a1`, node: 'top_anchor', type: 'corroded_post', work: 'cloth' }, + ], + }), + }, +``` + + **PLACEMENT FACTS, three.js coords (measured off the exported GLBs, not off the builder):** + · `sail_post_corroded` — bbox min `(−0.253, −0.020, −0.260)` max `(0.253, 4.032, 0.260)`; + footprint **0.51 × 0.52 m**, height **4.03 m** (stands POST height on purpose: its lie is + "I'm a sail post"). Single anchor `top_anchor` at **(0, 3.903, +0.049)** — 0.077 m below the + honest post's 3.980 and displaced **+Z**, the yard side, because the eye has drooped 38°. + `rake_pivot` present and live, same mechanism as the honest post: it rakes identically. + · `sail_post_corroded_wrecked` — bbox min `(−0.525, −0.020, −0.260)` max `(0.253, 0.682, 3.818)`. + **WRECK APRON: reserve ~3.9 m on +Z and ~0.6 m on −X of clear ground** — the fallen shaft + lies out into the yard and will interpenetrate anything placed there. Max height 0.682 m. + **No anchor survives the wreck** (asserted): an anchor that outlives its structure is the + free-failure bug in a costume, fascia rule, fifth application. + + **🌊 D — THE TIER IS PLACEABLE, YOUR GATE 3.2 IS UNBLOCKED.** `sail_post_corroded` / + `..._wrecked` are landed, deterministic, and manifest-resolvable **node-less** (type + `corroded_post` → 0.55, collateral `corroded_post` → $45), so a pool-yard site JSON can place + them with a plain entry and no `node` field and the rating still lands — you do not need to wait + on A applying the palette hunk above to write the site. Anchor node is `top_anchor` if you do + want it explicit. Mind the wreck apron (+Z ~3.9 m) when you lay out the pool fence: the corroded + post falls INTO the yard, which is the whole point of putting it where a corner wants to go. diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index 02e0dbd..900cc5e 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -1,6 +1,912 @@ { "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" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "tree_gum_01", + "dims": [ + 4.5522, + 4.956, + 7.9702 + ], + "tris": 492, + "nodes": [ + "branch_anchor_01", + "branch_anchor_02", + "branch_anchor_03", + "canopy", + "canopy_01", + "canopy_02", + "canopy_03", + "tree_gum_01", + "trunk" + ], + "anchors": [ + { + "node": "branch_anchor_01", + "anchor_type": "tree", + "rating_hint": 1.0 + }, + { + "node": "branch_anchor_02", + "anchor_type": "tree", + "rating_hint": 0.88 + }, + { + "node": "branch_anchor_03", + "anchor_type": "tree", + "rating_hint": 0.76 + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "tree_gum_02", + "dims": [ + 3.8871, + 2.7787, + 5.4972 + ], + "tris": 352, + "nodes": [ + "branch_anchor_01", + "branch_anchor_02", + "canopy", + "canopy_01", + "canopy_02", + "tree_gum_02", + "trunk" + ], + "anchors": [ + { + "node": "branch_anchor_01", + "anchor_type": "tree", + "rating_hint": 1.0 + }, + { + "node": "branch_anchor_02", + "anchor_type": "tree", + "rating_hint": 0.88 + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "tree_jacaranda_01", + "dims": [ + 7.4789, + 6.5035, + 6.0535 + ], + "tris": 632, + "nodes": [ + "branch_anchor_01", + "branch_anchor_02", + "branch_anchor_03", + "canopy", + "canopy_01", + "canopy_02", + "canopy_03", + "canopy_04", + "tree_jacaranda_01", + "trunk" + ], + "anchors": [ + { + "node": "branch_anchor_01", + "anchor_type": "tree", + "rating_hint": 0.95 + }, + { + "node": "branch_anchor_02", + "anchor_type": "tree", + "rating_hint": 0.52 + }, + { + "node": "branch_anchor_03", + "anchor_type": "tree", + "rating_hint": 0.4 + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "fence_post", + "dims": [ + 0.13, + 0.13, + 2.03 + ], + "tris": 24, + "nodes": [ + "fence_post", + "post" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "fence_panel", + "dims": [ + 2.4, + 0.054, + 1.8194 + ], + "tris": 324, + "nodes": [ + "fence_panel", + "palings", + "rails" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "gate", + "dims": [ + 1.045, + 0.0615, + 1.75 + ], + "tris": 220, + "nodes": [ + "gate", + "gate_frame", + "gate_palings", + "hinge_axis", + "hinges" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "house_yardside", + "dims": [ + 9.2, + 1.0547, + 2.9 + ], + "tris": 212, + "nodes": [ + "door", + "fascia", + "fascia_anchor_01", + "fascia_anchor_02", + "fascia_anchor_03", + "gutter", + "house_yardside", + "roof", + "wall", + "window", + "window_glow", + "window_light_anchor" + ], + "anchors": [ + { + "node": "fascia_anchor_01", + "anchor_type": "house", + "rating_hint": 0.35, + "collateral": "gutter" + }, + { + "node": "fascia_anchor_02", + "anchor_type": "house", + "rating_hint": 0.35, + "collateral": "gutter" + }, + { + "node": "fascia_anchor_03", + "anchor_type": "house", + "rating_hint": 0.35, + "collateral": "gutter" + }, + { + "node": "window_light_anchor", + "tie_off": false + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "house_yardside_wrecked", + "dims": [ + 9.4552, + 2.1021, + 2.9172 + ], + "tris": 340, + "nodes": [ + "debris_fascia", + "door", + "downpipe_loose", + "fascia_torn", + "gutter_down", + "gutter_torn", + "house_yardside_wrecked", + "roof", + "wall", + "window", + "window_glow", + "window_light_anchor" + ], + "anchors": [ + { + "node": "window_light_anchor", + "tie_off": false + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "carport_01", + "dims": [ + 3.24, + 5.5959, + 2.6945 + ], + "tris": 172, + "nodes": [ + "beam_anchor_01", + "beam_anchor_02", + "beams", + "carport_01", + "footings", + "post_anchor_01", + "post_anchor_02", + "posts", + "roof" + ], + "anchors": [ + { + "node": "beam_anchor_01", + "anchor_type": "carport", + "rating_hint": 0.22, + "collateral": "carport" + }, + { + "node": "beam_anchor_02", + "anchor_type": "carport", + "rating_hint": 0.22, + "collateral": "carport" + }, + { + "node": "post_anchor_01", + "anchor_type": "carport_post", + "rating_hint": 0.3, + "collateral": "carport" + }, + { + "node": "post_anchor_02", + "anchor_type": "carport_post", + "rating_hint": 0.3, + "collateral": "carport" + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "carport_01_wrecked", + "dims": [ + 6.2819, + 5.5704, + 2.1983 + ], + "tris": 144, + "nodes": [ + "beams", + "carport_01_wrecked", + "footings", + "posts", + "roof_down" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "swing_set_01", + "dims": [ + 2.3416, + 0.9469, + 2.0741 + ], + "tris": 220, + "nodes": [ + "crossbar", + "frame", + "frame_anchor_01", + "frame_anchor_02", + "swing_set_01", + "swings" + ], + "anchors": [ + { + "node": "crossbar", + "tie_off": false + }, + { + "node": "frame_anchor_01", + "anchor_type": "swing_frame", + "rating_hint": 0.45, + "collateral": "swing_set" + }, + { + "node": "frame_anchor_02", + "anchor_type": "swing_frame", + "rating_hint": 0.45, + "collateral": "swing_set" + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "swing_set_01_wrecked", + "dims": [ + 2.3416, + 2.4842, + 0.9325 + ], + "tris": 220, + "nodes": [ + "crossbar", + "frame", + "swing_set_01_wrecked", + "swings" + ], + "anchors": [ + { + "node": "crossbar", + "tie_off": false + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "shed_01", + "dims": [ + 2.58, + 1.9708, + 2.2224 + ], + "tris": 96, + "nodes": [ + "door_anchor", + "doors", + "roof", + "shed_01", + "shell" + ], + "anchors": [ + { + "node": "door_anchor", + "tie_off": false + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "shed_table", + "dims": [ + 1.6, + 0.6, + 0.9 + ], + "tris": 72, + "nodes": [ + "pickup_anchor", + "shed_table", + "table_frame", + "table_top" + ], + "anchors": [ + { + "node": "pickup_anchor", + "tie_off": false + } + ], + "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" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "sail_post", + "dims": [ + 0.507, + 0.52, + 4.0327 + ], + "tris": 528, + "nodes": [ + "footing", + "pad_eye", + "post", + "rake_pivot", + "sail_post", + "top_anchor" + ], + "anchors": [ + { + "node": "top_anchor", + "anchor_type": "post", + "rating_hint": 1.0 + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "ladder_01", + "dims": [ + 0.455, + 0.075, + 3.0 + ], + "tris": 276, + "nodes": [ + "ladder", + "ladder_01", + "ladder_base", + "ladder_top" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "shackle", + "dims": [ + 0.0569, + 0.019, + 0.0744 + ], + "tris": 560, + "nodes": [ + "bow", + "pin", + "shackle" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "carabiner", + "dims": [ + 0.049, + 0.009, + 0.1027 + ], + "tris": 476, + "nodes": [ + "body", + "carabiner", + "gate" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "turnbuckle", + "dims": [ + 0.0292, + 0.0341, + 0.1955 + ], + "tris": 728, + "nodes": [ + "body", + "eye_a", + "eye_b", + "turnbuckle" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "tramp_01", + "dims": [ + 2.9555, + 2.9555, + 0.78 + ], + "tris": 976, + "nodes": [ + "legs", + "mat", + "pad", + "rim", + "tramp_01" + ], + "anchors": [], + "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" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "washing_line_01", + "dims": [ + 2.8441, + 2.8441, + 2.2777 + ], + "tris": 336, + "nodes": [ + "arms", + "head", + "mast", + "washing_line_01" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "garden_gnome_01", + "dims": [ + 0.1427, + 0.15, + 0.365 + ], + "tris": 236, + "nodes": [ + "garden_gnome_01", + "gnome" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "bike_kid_01", + "dims": [ + 1.148, + 0.328, + 0.7269 + ], + "tris": 700, + "nodes": [ + "bars", + "bike_kid_01", + "frame", + "wheel_front", + "wheel_rear" + ], + "anchors": [], + "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" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "hail_stone_01", + "dims": [ + 0.0198, + 0.0176, + 0.0197 + ], + "tris": 20, + "nodes": [ + "hail_stone_01", + "stone" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "broom_01", + "dims": [ + 0.3891, + 0.055, + 1.4213 + ], + "tris": 200, + "nodes": [ + "bristles", + "broom_01", + "grip_anchor", + "handle", + "head", + "poke_tip" + ], + "anchors": [ + { + "node": "grip_anchor", + "tie_off": false + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "fence_panel_snapped", + "dims": [ + 2.4125, + 1.0144, + 1.8181 + ], + "tris": 312, + "nodes": [ + "debris_palings", + "fence_panel_snapped", + "palings", + "rails" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "pergola_01", + "dims": [ + 3.2, + 2.6, + 2.71 + ], + "tris": 276, + "nodes": [ + "beams", + "deck", + "pergola_01", + "pergola_anchor_01", + "pergola_anchor_02", + "posts", + "rafters" + ], + "anchors": [ + { + "node": "pergola_anchor_01", + "anchor_type": "pergola", + "rating_hint": 0.65, + "collateral": "pergola" + }, + { + "node": "pergola_anchor_02", + "anchor_type": "pergola", + "rating_hint": 0.65, + "collateral": "pergola" + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "pergola_01_wrecked", + "dims": [ + 3.3288, + 2.6, + 2.6648 + ], + "tris": 300, + "nodes": [ + "beam_down", + "beams", + "deck", + "pergola_01_wrecked", + "posts", + "rafters_down" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "glasshouse_01", + "dims": [ + 2.645, + 1.945, + 2.3025 + ], + "tris": 356, + "nodes": [ + "door", + "frame", + "glasshouse_01", + "panes", + "ridge_anchor" + ], + "anchors": [ + { + "node": "ridge_anchor", + "tie_off": false + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "glasshouse_01_wrecked", + "dims": [ + 4.3356, + 2.9387, + 2.3025 + ], + "tris": 680, + "nodes": [ + "door", + "frame", + "glasshouse_01_wrecked", + "panes_left", + "shards" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "pool_kit_01", + "dims": [ + 5.645, + 4.5207, + 1.22 + ], + "tris": 2780, + "nodes": [ + "fence_post_01", + "fence_post_02", + "fence_post_03", + "fence_post_04", + "fence_post_05", + "fence_post_06", + "fence_post_07", + "fence_post_08", + "fence_post_09", + "fence_post_10", + "fence_post_11", + "fence_post_12", + "fence_post_13", + "fence_post_14", + "fence_rails", + "pool_gate", + "pool_kit_01", + "pool_shell", + "pool_water" + ], + "anchors": [ + { + "node": "fence_post_01", + "tie_off": false + }, + { + "node": "fence_post_02", + "tie_off": false + }, + { + "node": "fence_post_03", + "tie_off": false + }, + { + "node": "fence_post_04", + "tie_off": false + }, + { + "node": "fence_post_05", + "tie_off": false + }, + { + "node": "fence_post_06", + "tie_off": false + }, + { + "node": "fence_post_07", + "tie_off": false + }, + { + "node": "fence_post_08", + "tie_off": false + }, + { + "node": "fence_post_09", + "tie_off": false + }, + { + "node": "fence_post_10", + "tie_off": false + }, + { + "node": "fence_post_11", + "tie_off": false + }, + { + "node": "fence_post_12", + "tie_off": false + }, + { + "node": "fence_post_13", + "tie_off": false + }, + { + "node": "fence_post_14", + "tie_off": false + } + ], + "status": "PASS", + "problems": [] + }, { "name": "sail_post_corroded", "dims": [ @@ -28,6 +934,25 @@ ], "status": "PASS", "problems": [] + }, + { + "name": "sail_post_corroded_wrecked", + "dims": [ + 0.7786, + 4.0784, + 0.7015 + ], + "tris": 836, + "nodes": [ + "footing", + "pad_eye_down", + "post_down", + "post_stub", + "sail_post_corroded_wrecked" + ], + "anchors": [], + "status": "PASS", + "problems": [] } ], "debris": [] diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 610876c..394c399 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -2319,12 +2319,18 @@ def build_sail_post_corroded(name): weld), deep-rust fold line just above the footing, streaks bleeding down the shaft, and the pad eye SAGS — the weld ate through and the eye drooped toward the yard. The anchor empty sits at the DROOPED - eye, so the sag is a measured fact (e.test pins corroded top_anchor - below the honest one), not set dressing. + eye, so the sag is a measured fact, not set dressing: e.test derives + the droop ANGLE back out of the exported anchor position and pins it + to `padeye_droop_deg` below. (Pinning "the corroded anchor is lower + than the honest one" would NOT have worked — the eye hangs off a weld + line 160 mm down the post, so that test reads 0.06 m and passes at + zero droop. Measure the angle, not the altitude.) Corrosion a player can't see is a trap with no tell, and this repo - doesn't ship those — the tell is pinned by AREA in e.test.js: rebuild - this in clean steel with the same 0.55 hint and the suite goes red. + doesn't ship those — the three tells are pinned as three SEPARATE tests + in e.test.js (rust area, shaft lightness, eye droop) so that one failure + can't mask the other two. Rebuild this in clean steel at the same 0.55 + hint and all three go red; flatten only the eye and only the third does. """ root = add_empty(name) steel = get_material("Mat_SteelWeathered", PAL["steel_weathered"], 0.6, @@ -2334,12 +2340,11 @@ def build_sail_post_corroded(name): rust_d = get_material("Mat_RustDeep", PAL["rust_deep"], 0.95, metallic=0.05) conc = get_material("Mat_Concrete", PAL["concrete"], 0.95) H, R = 4.0, 0.048 # same post the honest one is — that's the trap - # NEGATIVE CONTROL M1 (S17): the untelled corroded post — same 0.55 hint, - # no visual. Clean galvanised everywhere, no droop. - steel = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.9) - rust = steel - rust_d = steel - DROOP = math.radians(0.0) # pad-eye sag off vertical + DROOP_DEG = 38.0 # pad-eye sag off vertical. 38° is slumped + # past argument but still recognisably an eye + # you COULD clip to — which is the whole trap. + DROOP = math.radians(DROOP_DEG) + EYE_ARM = 0.08 # eye centre's distance from the weld line WELD_Z = H - 0.16 # the slumped weld line the eye hinges at # Footing identical to the honest post's, plus the stain skirt: rust @@ -2392,10 +2397,11 @@ def build_sail_post_corroded(name): # The sagging pad eye: plate and ring both pitched DROOP about the weld # line, hanging toward -y (three.js +Z, the yard side — same face the # pergola presents). Built in the tilted plane directly, no post-rotation. - ring_c = (0, -math.sin(DROOP) * 0.08, WELD_Z + math.cos(DROOP) * 0.08) + ring_c = (0, -math.sin(DROOP) * EYE_ARM, + WELD_Z + math.cos(DROOP) * EYE_ARM) eye_parts = [add_box(f"{name}_padeye", (0.012, 0.07, 0.09), - (0, -math.sin(DROOP) * 0.04, - WELD_Z + math.cos(DROOP) * 0.04), rust_d, + (0, -math.sin(DROOP) * EYE_ARM / 2, + WELD_Z + math.cos(DROOP) * EYE_ARM / 2), rust_d, rot=(-DROOP, 0, 0))] r_eye, segs = 0.026, 10 pts = [] @@ -2432,8 +2438,28 @@ def build_sail_post_corroded(name): root["collateral_label"] = "the corroded post" root["breakable"] = True root["corrosion_note"] = ("the tell is load-bearing: e.test pins rust " - "area, shaft dullness and the eye sag — a " + "area, shaft dullness and the eye droop — a " "clean-steel rebuild at the same hint goes red") + + # THE AXIS TRAP (E's swing_set precedent, fourth application). The droop + # is an ORIENTATION CLAIM, and orientation claims in this repo are the + # ones that rot: the exporter maps Blender (x,y,z) → three.js (x,z,−y), so + # a docstring saying "-y" and a mesh leaning +Z agree only by luck. These + # extras are stated in THREE.JS coords and e.test recomputes them off the + # exported geometry — so note and mesh can only ever lie together. + # Blender ring_c (0, −sinD·ARM, WELD_Z+cosD·ARM) + # → three.js (0, WELD_Z+cosD·ARM, +sinD·ARM) + # i.e. the eye hangs toward +Z, the yard side, the face the pergola + # presents — so the sag is pointed at the player who has to judge it. + root["padeye_droop_deg"] = DROOP_DEG + root["padeye_arm_m"] = EYE_ARM + root["padeye_droop_dir_threejs"] = "+Z" + root["padeye_weld_y_threejs"] = WELD_Z # blender z → three.js y + root["padeye_droop_note"] = ( + "top_anchor sits EYE_ARM from the weld line, pitched DROOP_DEG off " + "vertical toward three.js +Z (the yard). e.test derives the angle " + "back out of the exported anchor position and pins it to this number: " + "flatten the eye and the note goes red with it") return root diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png index ce89cac..963346c 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 5db2a1b..fc71306 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -1110,7 +1110,13 @@ export default async function run(t) { return hsl.h <= 0.12 && hsl.s >= 0.35 && hsl.l <= 0.45; }; - t.test('the corroded post TELLS: rust area, duller steel, and a pad eye that sags — all measured', () => { + // The three tells are three SEPARATE tests on purpose. Shipped as one + // block, the first failing assert masked the other two — the S17 M1 + // negative control (clean steel, flat eye) went red on rust area alone and + // never reached the lightness or droop checks, so their strength was + // unproven. Split, each tell is independently red-checkable, which is the + // only way "mutation-checked" means anything. + t.test('the corroded post TELLS (1/3): rust reads as AREA, and the honest post stays clean', () => { const g = loaded.get('sail_post_corroded'); const honest = loaded.get('sail_post'); assert(g && honest, 'both post variants must load — the tell is a comparison'); @@ -1130,36 +1136,123 @@ export default async function run(t) { assert(h.hit === 0, `the HONEST sail post carries ${(h.hit / h.total * 100).toFixed(1)}% rust-coloured area — ` + 'either the post rusted or the rust predicate is reading galvanised steel as rust'); + }); - // (b) The 20 m read: the corroded SHAFT is measurably duller/darker than - // galvanised. Compare the lightest non-rust material on each 'post' node — - // that's the shaft steel on both (dark trim is darker by construction). - const lightestL = (gl) => { - let best = -1; + t.test('the corroded post TELLS (2/3): the shaft is NOT galvanised — base colour and PBR both moved', () => { + const g = loaded.get('sail_post_corroded'); + const honest = loaded.get('sail_post'); + assert(g && honest, 'both post variants must load — the tell is a comparison'); + + // (b) The 20 m read. This assert shipped in the S17 WIP saying the + // corroded shaft "reads darker at yard distance"; I measured the actual + // render before endorsing it and the SIGN IS BACKWARDS. Sampled off + // look.html's canvas at cam z=20 m, 1280×720, over the mid-shaft band: + // + // honest rgb(49,51,49) lum 50 sat 0.031 R−B 0 + // corroded rgb(98,94,86) lum 94 sat 0.126 R−B +12 + // + // The corroded shaft renders TWICE AS BRIGHT, not darker. Base colour is + // genuinely darker (0.524 vs 0.737) but it does not drive the pixels: + // galvanised is metalness 0.90 / roughness 0.35 and, with no environment + // map in this renderer, a near-mirror metal has almost nothing to mirror + // and goes near-black. The weathered shaft at 0.55 / 0.60 is far more + // diffuse, so it actually catches the sun. The separation is real and + // large — it is just made of METALNESS, not lightness. + // + // So the test now pins both halves, and the comment states which one the + // player is actually seeing. An assert whose stated reason is the reverse + // of the mechanism is a docstring waiting to be "fixed" in the wrong + // direction by whoever tunes this next. + const shaftOf = (gl) => { + let best = null; gl.scene.getObjectByName('post').traverse((o) => { if (!o.isMesh || isRust(o.material)) return; const hsl = { h: 0, s: 0, l: 0 }; o.material.color.getHSL(hsl); - best = Math.max(best, hsl.l); + if (!best || hsl.l > best.l) best = { l: hsl.l, m: o.material }; }); return best; }; - const lHonest = lightestL(honest), lCorroded = lightestL(g); - assert(lHonest >= 0 && lCorroded >= 0, 'could not find shaft materials on the post nodes'); - assert(lCorroded < lHonest - 0.08, - `corroded shaft lightness ${lCorroded.toFixed(3)} vs galvanised ${lHonest.toFixed(3)} — ` + - 'the shafts read the same at yard distance, which erases the long-range half of the tell'); + const sHonest = shaftOf(honest), sCorroded = shaftOf(g); + assert(sHonest && sCorroded, 'could not find shaft materials on the post nodes'); - // (c) The sag is geometry the sim SEES: the anchor sits at the drooped - // eye, below where the honest post carries its. Not set dressing. - const anchorY = (gl) => { - gl.scene.updateMatrixWorld(true); - return gl.scene.getObjectByName('top_anchor').getWorldPosition(new THREE.Vector3()).y; - }; - const sag = anchorY(honest) - anchorY(g); + assert(sCorroded.l < sHonest.l - 0.08, + `corroded shaft base lightness ${sCorroded.l.toFixed(3)} vs galvanised ${sHonest.l.toFixed(3)} — ` + + 'the corroded shaft is wearing galvanised paint, so nothing distinguishes them but the rust'); + + // The half that actually reaches the screen. Weathered steel is rougher + // and less metallic than gal; if a future edit "tidies" these back to the + // galvanised values the post keeps its rust and loses its 20 m silhouette. + assert(sCorroded.m.roughness > sHonest.m.roughness + 0.15, + `corroded shaft roughness ${sCorroded.m.roughness} vs galvanised ${sHonest.m.roughness} — ` + + 'weathered steel that is as polished as gal loses the long-range read (this is the ' + + 'property that actually drove lum 94 vs 50 at 20 m, measured)'); + assert(sCorroded.m.metalness < sHonest.m.metalness - 0.15, + `corroded shaft metalness ${sCorroded.m.metalness} vs galvanised ${sHonest.m.metalness} — ` + + 'corroded steel must be the more diffuse of the two or the yard-distance contrast inverts'); + }); + + t.test('the corroded post TELLS (3/3): the pad eye DROOPS — angle derived from geometry, pinned to the baked note', () => { + const g = loaded.get('sail_post_corroded'); + const honest = loaded.get('sail_post'); + assert(g && honest, 'both post variants must load — the tell is a comparison'); + g.scene.updateMatrixWorld(true); + honest.scene.updateMatrixWorld(true); + + const root = g.scene.getObjectByName('sail_post_corroded'); + const anchor = g.scene.getObjectByName('top_anchor'); + assert(root && anchor, 'root and top_anchor must both survive the export'); + const u = root.userData; + + // THE AXIS TRAP (E's swing_set precedent). The builder writes the droop + // as a claim in THREE.JS coords; this derives the same numbers back out + // of the exported anchor position. Note and mesh can only lie together. + // + // Why not "the corroded anchor sits lower than the honest one" — the + // assert this REPLACES? Because the eye hangs off a weld line 160 mm + // down the post, so that comparison reads 0.06 m at ZERO droop and sails + // past a `> 0.05` threshold. It measured the weld offset, not the sag: + // an assert that cannot fail on the thing it names is decoration. This + // one drives the derived angle to 0° when the eye is flat. + const baked = u.padeye_droop_deg; + const arm = u.padeye_arm_m; + const weldY = u.padeye_weld_y_threejs; + assert(typeof baked === 'number' && typeof arm === 'number' && typeof weldY === 'number', + 'the droop must be BAKED as numbers on the root (deg, arm, weld y in three.js) — ' + + 'an orientation claim that lives only in a docstring is the axis trap waiting to happen'); + assert(baked > 0, `padeye_droop_deg is ${baked} — a corroded eye that does not droop has no tell`); + + const p = anchor.getWorldPosition(new THREE.Vector3()); + // The post axis is x=z=0; the eye hangs off the weld line at (0, weldY, 0). + const horiz = Math.hypot(p.x, p.z); // out from the axis + const vert = p.y - weldY; // up from the weld + const armMeasured = Math.hypot(horiz, vert); + assert(Math.abs(armMeasured - arm) < 0.004, + `the eye sits ${armMeasured.toFixed(4)} m off the weld line but the note says ${arm} m — ` + + 'the baked arm and the exported geometry disagree'); + + const derivedDeg = Math.atan2(horiz, vert) * 180 / Math.PI; + assert(Math.abs(derivedDeg - baked) < 1.5, + `the exported pad eye droops ${derivedDeg.toFixed(1)}° but the root claims ${baked}° — ` + + 'the mesh and its own note disagree about the sag (flatten the eye and this is what you see)'); + + // DIRECTION, measured — not asserted by reading the string. Blender −y + // maps to three.js +Z, so the eye must hang toward the YARD, the face the + // player judges it from. A sag pointing into the fence is a tell nobody sees. + assert(u.padeye_droop_dir_threejs === '+Z', + `the baked droop direction is ${u.padeye_droop_dir_threejs} — this test only knows how to check +Z`); + assert(p.z > 0.5 * horiz, + `the eye hangs toward (x=${p.x.toFixed(3)}, z=${p.z.toFixed(3)}) — the note says +Z (the yard side), ` + + 'so either the exporter axis mapping moved or the note is wrong'); + assert(Math.abs(p.x) < 0.006, + `the eye is displaced ${p.x.toFixed(3)} m on X — the droop is meant to be purely +Z`); + + // And the weaker claim, kept as a sanity check now that it is no longer + // carrying the argument: the drooped anchor does sit below the honest one. + const sag = honest.scene.getObjectByName('top_anchor') + .getWorldPosition(new THREE.Vector3()).y - p.y; assert(sag > 0.05, - `the corroded top_anchor sits only ${sag.toFixed(3)} m below the honest one — ` + - 'the pad eye is meant to have SAGGED, visibly and measurably'); + `the corroded top_anchor sits only ${sag.toFixed(3)} m below the honest one`); }); t.test('corroded steel is a RUNG: above the swing frame, below sound timber, resolvable node-less', () => { diff --git a/web/world/models/sail_post_corroded_v1.glb b/web/world/models/sail_post_corroded_v1.glb index e5fe733..7453546 100644 Binary files a/web/world/models/sail_post_corroded_v1.glb and b/web/world/models/sail_post_corroded_v1.glb differ