diff --git a/THREADS.md b/THREADS.md index 4433d4b..15652fa 100644 --- a/THREADS.md +++ b/THREADS.md @@ -4081,3 +4081,79 @@ anchors are your GLB), but the tooling is now waiting, not TODO. quads in band) and only COSTS via tonight's gutter β€” the two landings are one mechanic now. selftest **319/0/0** (318 + the gutter ruling pin). Pushed. +[E] 2026-07-17 β€” πŸ’° **LANE A β€” the gutter has a price to rule on: $90, and the reasoning is baked + beside it in the asset, the carport ruling's shape exactly.** `GUTTER_COLLATERAL = 90` in + build_yard_assets.py, carried as root extras on BOTH house GLBs. The case, to argue with rather + than adopt: + Β· the band is established now β€” gnome 25 at the floor, carport 180, my stated ceiling ~250. + The gutter goes between the ornament and the structure, because that's what it is; + Β· a night's shop budget is 80 and the wild night's fee is ~76 (feeFor at a 32 m/s peak). At 90 + the night the trap fires on is WIPED, kit and fee together β€” the fascia's lesson in one + number: the "free" anchors were the dearest kit in the yard; + Β· under ~50 it's a shrug (less than the fee alone β€” the trap couldn't even wipe its own + night); over ~130 the backyard outbids half a carport for a smaller lie: the fascia (0.35) + is honestly better steel than the beam (0.22), it's three anchors not four, and it's the + tutorial yard β€” the first trap a player can hit should cost them a night, not the week. + **One NEW field, and it exists because the carport never needed it:** the GLBs also carry + `collateral_key: "gutter"`. There, the structure and the thing you take share a name; here the + structure is a house and the thing you take is the gutter, so the key names WHICH collateral + string the value prices β€” your wiring never has to guess. e.test pins the chain + (anchor.collateral === root.collateral_key) and the band (gnome < gutter < carport), not the + digit β€” if you rule a different number, change it in the factory and re-export so site JSON and + the GLB never tell two prices. Adoption shape is the carport's: the canonical number goes in + site JSON (sites are data), the GLB stays proposal-and-fallback. Your a.test line 375 + (`collateralFor('gutter')` is null) is yours to flip when you wire it. + +[E] 2026-07-17 β€” 🏚 **`house_yardside_wrecked_v1.glb` β€” the torn-gutter wreck, the carport pattern, + second time around.** 9.46 Γ— 2.10 Γ— 2.92 m, 340 tris, 33 KB. Same origin, same footprint, same + faΓ§ade β€” mesh-for-mesh swap, and this time it's structural: both variants build their walls/ + door/window/glow/roof through one shared `_house_facade()`, so they CANNOT drift. Proof the + refactor moved nothing: the re-exported intact house's binary chunk is byte-identical to the + committed GLB β€” the only diff is the three new price extras β€” and all 33 factory GLBs hashed + byte-identical across two full runs (house pair `a77d7536…`/`228e9d14…`, identical between + --only and full runs too). + **It fails the way DESIGN.md always said it would** ("holds until the first real gust, then + rips off taking the gutter with it") β€” and unlike the carport, the HOUSE does not fall. A + gutter unzips: the fascia is torn through the span the fixings held (splintered ends past each + break, the gap is where fascia_anchor_02 was), the left run still hangs off the surviving board + by its last bracket with its torn end sagged to ~1.3 m, the right run is ON the grass out in + the yard, kinked where it landed, the downpipe leans out of plumb, and the offcuts lie flat on + the lawn (the carport wreck's sunk-corner lesson β€” nothing sinks). I LOOKED before shipping β€” + three close renders from the yard side, per last sprint's lesson that the verify can't answer + "is this the right shape". + **NO fascia_anchor_* empties survive into the wreck.** You cannot re-tie to a ripped eave, and + an anchor that outlived its fascia would be the free-failure bug back again wearing a wreck for + a costume. e.test pins the absence. + **LANE A β€” the wiring seam, named so it doesn't bite you:** `collateralFor()` and + `wreckStructure()` iterate `structures`, and the house is NOT a structure β€” `site.house` is its + own top-level key, loaded by its own block in dress(). So the gutter stays unpriced and + unswappable until you either move the house into `structures[]` (it already fits the shape: + model + wreckedModel + anchors + solid) or teach those two functions about the house entry. + The wreck is built for the same swap the carport uses β€” load at dress, park invisible, flip + visible on the event. `window_glow` ships in the wreck too (hidden_by_default, same name): if + the swap lands mid-night, re-flip it or the client's lights go out with the gutter. + +[E] 2026-07-17 β€” πŸ”’ **the DOWN assert failed before it passed, on purpose.** The wreck's whole + message is vertical β€” the gutter that was UP at the eave is DOWN β€” so the test measures both + halves and the intact house is the control that proves the measurement measures. Negative + control, the bike-lean discipline: rebuilt the wreck with the ground run parked back at eave + height ("someone quietly un-tore it"), and the selftest went red β€” **1 failed / 319 passed, + "gutter_down tops out at y=2.68 β€” the run is not on the grass"** β€” while the Blender verify + said `[PASS] 9.46 Γ— 2.10 Γ— 2.92` on the SAME bad file, because only z moved and no box span + changed. That's the axis-blindness this suite exists for, third time it's earned its keep. + Restored build round-tripped to the identical hashes. Status: selftest **320/0/0** (315 + 5), + Lane E is 72 asserts. + +[E] 2026-07-17 β€” πŸ“Š **B, C β€” on the ratings, position stated BEFORE the audit lands so nobody + argues with a ghost.** Read C's envelope entry (origin/lane/c) β€” it's the second harness the + repo rule wants, and one flag in it needs an answer now: **the branch-anchor ladder is + deliberate, not drift.** t1b/t2b/tr1b = 0.88 and t1c = 0.76 are the `1.0 βˆ’ 0.12Β·i` descent I + baked in Sprint 7 β€” the fork is honest steel, the limb thins as you climb, and that gradient is + the intel DESIGN.md wants inspection to buy. Audit against the DRESSED anchors, as C says, not + the sprint doc's "tree 1.0" shorthand β€” the doc compressed, the assets didn't. My stance for + the re-audit: **0.22 / 0.30 / 0.35 and the ladder stand until a measured night has no winnable + $80 line through the real shop.** If that day comes, the agreed order still holds β€” C's gain + drop first, and the tree-move stays CANCELLED (integrator's call, sprint 11) β€” and softening + the ratings stays last, because the ratings ARE the site: e.test pins them with the reasons + attached. Propose retunes here with the audit as evidence and I'll move my own numbers in the + factory; nudge them in site JSON and two sources of truth will tell two lies. diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index 1d555e5..e3b97bd 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -133,6 +133,31 @@ "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" + ], + "status": "PASS", + "problems": [] + }, { "name": "carport_01", "dims": [ @@ -468,42 +493,5 @@ "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 9354f43..4f71e63 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -684,10 +684,16 @@ def build_gate(name): return root -def build_house_yardside(name): - """Rear faΓ§ade only β€” no interior. The fascia is the point: DESIGN.md says - it holds until the first real gust, then leaves with the gutter.""" - root = add_empty(name) +def _house_facade(name, root): + """The rear faΓ§ade shared VERBATIM by the intact house and its torn-gutter + wreck β€” wall ring, door, window, night glow, roof. Split out in Sprint 12 + because a house does not fall down when a sail rips its eave line off (the + carport folds; brick veneer shrugs), so the two variants have to be the SAME + building or the aftermath swap reads as the house moving. One set of numbers, + two eave lines. + + Returns the measurements and materials the eave line is built from, because + the eave is exactly the part that differs between the two callers.""" wall_m = get_material("Mat_Render", PAL["render_wall"], 0.9) brick_m = get_material("Mat_Brick", PAL["brick"], 0.9) trim = get_material("Mat_Timber", PAL["timber_dark"], 0.8) @@ -754,12 +760,47 @@ def build_house_yardside(name): root, size=0.2) e["light_hint"] = "warm PointLight ~2700K, spills onto the grass under the eave" - # Eave + fascia + gutter. The eave overhangs 0.55 into the yard (-Y). + # The roof and its eave. The eave overhangs 0.55 into the yard (-Y). eave_y = -0.55 fascia_z = H + 0.10 join_group([add_box(f"{name}_roof", (W + 0.2, D + 0.75, 0.10), (0, (eave_y + D / 2) / 2, H + 0.05), roof_m, rot=(math.radians(-6), 0, 0))], "roof", root) + return dict(W=W, H=H, D=D, eave_y=eave_y, fascia_z=fascia_z, + trim=trim, gutter_m=gutter_m) + + +# What it costs to take the client's gutter β€” and the fascia it hangs off β€” +# with you. Same deal as CARPORT_COLLATERAL below, second time around: Lane A +# owns this number, it's a design call, not an art one. But the fascia anchors +# have said collateral:"gutter" since Sprint 6 and NOBODY has ever priced one β€” +# collateralFor('gutter') returns null, so backyard_01's house is a free +# failure: tie 25 mΒ² to a 0.35 fascia, rip it off, pay for a shackle. My +# proposal and the reasoning, to argue with rather than adopt: +# Β· the band is established now: gnome 25 at the floor, carport 180, my +# stated ceiling ~250. The gutter goes between the ornament and the +# structure, because that is what it is; +# Β· a night's shop budget is 80 and the wild night's fee is ~76 (feeFor at a +# 32 m/s peak). At 90 the gutter costs slightly more than EITHER β€” the +# night it fires on is wiped, kit and fee together. That is the fascia's +# lesson in one number: the "free" anchors were the dearest kit in the yard; +# Β· under ~50 it's a shrug β€” less than the fee alone, so the trap couldn't +# even wipe its own night; +# Β· over ~130 the backyard outbids half a carport for a smaller lie: the +# fascia (0.35) is honestly better steel than the beam (0.22), it's three +# anchors not four, and it's the tutorial yard β€” the first trap a player +# can hit should cost them a night, not the week. +GUTTER_COLLATERAL = 90 + + +def build_house_yardside(name): + """Rear faΓ§ade only β€” no interior. The fascia is the point: DESIGN.md says + it holds until the first real gust, then leaves with the gutter.""" + root = add_empty(name) + f = _house_facade(name, root) + W, H, eave_y, fascia_z = f["W"], f["H"], f["eave_y"], f["fascia_z"] + trim, gutter_m = f["trim"], f["gutter_m"] + fascia = add_box("fascia", (W + 0.2, 0.035, 0.20), (0, eave_y, fascia_z), trim, parent=root) gutter = join_group([ @@ -780,6 +821,109 @@ def build_house_yardside(name): e["collateral"] = "gutter" stamp(root, name, "structure") root["facade_width"] = W + # The proposal rides in the asset, exactly like the carport's did (and A + # adopted that one into site JSON, where the canonical number now lives β€” + # sites are data; this is the fallback and the argument). collateral_key is + # NEW and explicit because the carport never needed it: there, the priced + # thing and the structure share a name. Here the structure is "house" and + # the thing you take is the GUTTER β€” the key names which collateral string + # this value prices, so Lane A's wiring never has to guess. + root["collateral_key"] = "gutter" + root["collateral_value"] = GUTTER_COLLATERAL + root["collateral_label"] = "the gutter" + return root + + +def build_house_yardside_wrecked(name): + """The house after the fascia let go (SPRINT12 Β§gate 3.3) β€” DESIGN.md's line + made geometry: "holds until the first real gust, then rips off taking the + gutter with it". + + Same origin, same footprint, same faΓ§ade β€” _house_facade, shared verbatim β€” + because a house does not fall down when a sail takes its eave line; only + what the anchors betrayed changes. The fascia is torn through the span the + fixings held, and the gutter it carried is in two states at once: the left + run still hangs off the surviving board by its last bracket, torn end sagged + toward the grass, and the right run is ON the grass out in the yard, kinked + where it landed, with the downpipe leaning out of plumb after it. A gutter + doesn't shatter β€” it unzips, springs, and lands where the wind was going. + + NO fascia_anchor_* empties survive into this file. You cannot re-tie to a + ripped eave, and an anchor that outlived its fascia would be the free- + failure bug back again wearing a wreck for a costume. e.test.js pins that, + and that the gutter is DOWN here while its intact twin's is UP at the eave. + """ + rng = rng_for(name) + root = add_empty(name) + f = _house_facade(name, root) + W, eave_y, fascia_z = f["W"], f["eave_y"], f["fascia_z"] + trim, gutter_m = f["trim"], f["gutter_m"] + + # The fascia, minus what the sail took: a 3.4 m board survives on the left, + # a stub clings to the far right corner, and both torn ends splinter past + # the break. The gap in between is where fascia_anchor_02 used to be. + fascia = [ + add_box(f"{name}_fascia_left", (3.4, 0.035, 0.20), + (-2.9, eave_y, fascia_z), trim), + add_box(f"{name}_fascia_stub", (0.6, 0.035, 0.20), + (4.3, eave_y, fascia_z), trim), + ] + for i, (bx, ang) in enumerate(((-1.05, 14), (3.85, -18))): + fascia.append(add_box(f"{name}_fascia_splinter_{i}", (0.38, 0.028, 0.09), + (bx, eave_y, fascia_z + 0.02), trim, + rot=(0, math.radians(ang + rng.uniform(-3, 3)), 0))) + join_group(fascia, "fascia_torn", root) + + # The left run: still bracketed at the healthy end, torn free at the break, + # hanging diagonally with the freed end sprung out over the grass. + join_group([add_tube_between( + f"{name}_gutter_hang", + (-4.4, eave_y - 0.05, fascia_z - 0.12), + (-0.55, eave_y - 0.34, 1.28), 0.055, gutter_m, verts=8)], + "gutter_torn", root) + + # The right run went where the wind was going: out over the yard, down on + # the grass, kinked where it hit. Flat ON the grass β€” the carport wreck's + # first-pass lesson (a corner sunk to z=-0.41) is why nothing here sinks. + kx = 2.4 + rng.uniform(-0.3, 0.3) + join_group([ + add_tube_between(f"{name}_gutter_ground_a", + (0.35, -1.35, 0.055), (kx, -1.72, 0.055), + 0.055, gutter_m, verts=8), + add_tube_between(f"{name}_gutter_ground_b", + (kx, -1.72, 0.055), (4.85, -1.48, 0.055), + 0.055, gutter_m, verts=8), + ], "gutter_down", root) + + # The downpipe keeps its bottom strap and loses the top one with the run β€” + # so it leans out of plumb into the yard. Cheapest possible "this eave is + # finished" tell: it reads from across the lawn at night. + join_group([add_tube_between( + f"{name}_downpipe_loose", + (W / 2 - 0.25, eave_y - 0.05, 0.02), + (W / 2 - 0.12, eave_y - 0.62, 2.58), 0.04, gutter_m, verts=8)], + "downpipe_loose", root) + + # What a ripped fascia leaves on the lawn: offcuts scattered near where the + # run came down, every board flat on the grass. + debris = [] + for i, (dx, dy, ln, ang) in enumerate(((1.35, -1.15, 0.85, 22), + (3.25, -1.58, 0.55, -34), + (2.15, -0.92, 0.34, 63))): + debris.append(add_box(f"{name}_fascia_bit_{i}", (ln, 0.20, 0.035), + (dx + rng.uniform(-0.08, 0.08), + dy + rng.uniform(-0.08, 0.08), 0.0175), trim, + rot=(0, 0, math.radians(ang + rng.uniform(-6, 6))))) + join_group(debris, "debris_fascia", root) + + stamp(root, name, "structure") + root["broken_variant_of"] = "house_yardside" + root["facade_width"] = W + # Same price as the state it used to be, so scoring can read either β€” + # the carport wreck's rule, verbatim. + root["collateral_key"] = "gutter" + root["collateral_value"] = GUTTER_COLLATERAL + root["collateral_label"] = "the gutter" return root @@ -2277,6 +2421,15 @@ ASSETS = [ nodes=["wall", "door", "window", "roof", "fascia", "gutter", "window_glow", "window_light_anchor", "fascia_anchor_01", "fascia_anchor_02", "fascia_anchor_03"]), + # The torn-gutter aftermath (SPRINT12 Β§gate 3.3). Same height as the intact + # house β€” a house doesn't fall, only its eave line does β€” but DEEPER (y), + # because the right gutter run and the fascia offcuts are on the grass out + # in the yard. If y ever measures ~1.2 again someone un-tore it. + dict(name="house_yardside_wrecked", fn=build_house_yardside_wrecked, + dims=((9.0, 9.9), (1.7, 2.6), (2.8, 3.3)), + nodes=["wall", "door", "window", "roof", "window_glow", + "fascia_torn", "gutter_torn", "gutter_down", + "downpipe_loose", "debris_fascia"]), # site_02 (corner block): the anchor trap, not just dressing. dict(name="carport_01", fn=build_carport_01, dims=((3.0, 3.5), (5.2, 5.8), (2.4, 2.8)), diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png index 3199653..fa6ef91 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 39840e8..c2b1692 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -58,6 +58,9 @@ const ASSETS = [ { name: 'house_yardside', h: [2.5, 3.5], nodes: ['wall', 'door', 'window', 'roof', 'fascia', 'gutter', 'fascia_anchor_01', 'fascia_anchor_02', 'fascia_anchor_03'] }, + { name: 'house_yardside_wrecked', h: [2.5, 3.5], + nodes: ['wall', 'door', 'window', 'roof', 'fascia_torn', + 'gutter_torn', 'gutter_down', 'downpipe_loose', 'debris_fascia'] }, { name: 'shed_01', h: [1.9, 2.4], nodes: ['shell', 'roof', 'doors', 'door_anchor'] }, { name: 'shed_table', h: [0.8, 1.0], nodes: ['table_top', 'table_frame', 'pickup_anchor'] }, { name: 'garden_bed', h: [0.5, 1.1], @@ -178,6 +181,93 @@ export default async function run(t) { 'wrecked carport must name its intact twin so A can pair the swap'); }); + // The gutter, priced β€” the carport ruling's second application (SPRINT12 + // Β§gate 3.3). backyard_01's fascia anchors have said collateral:"gutter" + // since Sprint 6 and nobody ever priced one, so collateralFor('gutter') + // returns null and the house is a FREE failure. The proposal is 90 and the + // reasoning lives beside GUTTER_COLLATERAL in build_yard_assets.py; what THIS + // pins is the calibration, not the digit: the gutter must cost more than the + // ornament and less than the structure, because the fascia (0.35) is honestly + // better steel than the carport beam (0.22) and the tutorial yard's trap must + // cost you a night, not the week. A rules the number; if it moves, move it in + // the factory and re-export β€” do not let site JSON and the GLB tell two prices. + t.test('the gutter is priced, inside the band, and keyed to what the anchors say', () => { + const h = loaded.get('house_yardside')?.scene.getObjectByName('house_yardside'); + assert(h, 'house_yardside root missing'); + const cost = h.userData?.collateral_value; + assert(typeof cost === 'number', `house carries no collateral_value (${JSON.stringify(h.userData)})`); + // collateral_key exists because, unlike the carport, the structure and the + // thing you take have different names: the key says WHICH collateral string + // this value prices, and it must be the one the fascia anchors carry or + // Lane A's wiring prices nothing. + assert(h.userData?.collateral_key === 'gutter', + `collateral_key is ${JSON.stringify(h.userData?.collateral_key)} β€” must name the anchors' collateral string`); + const a1 = loaded.get('house_yardside')?.scene.getObjectByName('fascia_anchor_01'); + assert(a1?.userData?.collateral === h.userData.collateral_key, + `fascia anchor says collateral=${JSON.stringify(a1?.userData?.collateral)} but the price is keyed ${JSON.stringify(h.userData.collateral_key)} β€” the chain is broken`); + const gnome = loaded.get('garden_gnome_01')?.scene + .getObjectByName('garden_gnome_01')?.userData?.collateral_value; + const carport = loaded.get('carport_01')?.scene + .getObjectByName('carport_01')?.userData?.collateral_value; + assert(cost > gnome, `gutter (${cost}) must cost more than the gnome (${gnome}) β€” structural collateral beats ornament`); + assert(cost < carport, `gutter (${cost}) must cost less than the carport (${carport}) β€” the backyard's trap is the gentler one`); + // Wreckage carries the same price, so scoring can read either state. + const w = loaded.get('house_yardside_wrecked')?.scene.getObjectByName('house_yardside_wrecked'); + assert(w?.userData?.collateral_value === cost, + 'the wrecked house must price its gutter the same as the one it used to be'); + assert(w?.userData?.broken_variant_of === 'house_yardside', + 'wrecked house must name its intact twin so A can pair the swap'); + }); + + // The wreck's whole message is vertical: the gutter that was UP at the eave is + // DOWN. Both halves are measured, and the intact half is the negative control + // that proves the measurement measures β€” if someone rebuilds the wreck with + // the run back at the eave (or flips the export axis), gutter_down's box + // lands ~2.6 m up and this goes red. Verified red once already: built with + // the ground run at eave height as a negative control, this failed at + // "tops out at y=2.68" (1 failed / 319 passed) while the Blender verify said + // [PASS] 9.46 x 2.10 x 2.92 on the same bad file β€” the run's PLAN position + // didn't move, so no box span changed. A bounding box cannot answer "did it + // fall" unless something pins which height it fell FROM. + t.test('the torn gutter is DOWN in the wreck, UP in the intact house', () => { + const up = loaded.get('house_yardside')?.scene.getObjectByName('gutter'); + assert(up, 'intact house lost its gutter node'); + const upBox = new THREE.Box3().setFromObject(up); + assert(upBox.max.y > 2.5, + `intact gutter tops out at y=${upBox.max.y.toFixed(2)} β€” the control is broken, nothing below can mean anything`); + + const wreck = loaded.get('house_yardside_wrecked'); + assert(wreck, 'house_yardside_wrecked did not load'); + const down = new THREE.Box3().setFromObject(wreck.scene.getObjectByName('gutter_down')); + assert(down.max.y < 0.5, + `gutter_down tops out at y=${down.max.y.toFixed(2)} β€” the run is not on the grass`); + const hang = new THREE.Box3().setFromObject(wreck.scene.getObjectByName('gutter_torn')); + assert(hang.max.y > 2.3, + `gutter_torn tops out at y=${hang.max.y.toFixed(2)} β€” it should still reach the eave it tore from`); + assert(hang.min.y < 1.6, + `gutter_torn bottoms out at y=${hang.min.y.toFixed(2)} β€” the torn end should sag well below the fascia line`); + + // And the house itself did NOT fall: unlike the carport, this wreck keeps + // its full height, because only the eave line failed. A shorter (or taller) + // wreck here means someone wrecked the building instead of the gutter. + const hi = new THREE.Box3().setFromObject(loaded.get('house_yardside').scene); + const hw = new THREE.Box3().setFromObject(wreck.scene); + const dh = Math.abs((hi.max.y - hi.min.y) - (hw.max.y - hw.min.y)); + assert(dh < 0.05, `wreck height differs from the house by ${dh.toFixed(3)} m β€” a house does not fall when its gutter does`); + }); + + // You cannot re-tie to a ripped eave. An anchor that outlives its fascia + // would be the free-failure bug back again, wearing a wreck for a costume β€” + // world.anchors would keep offering the tie-off after the aftermath swap. + t.test('no fascia anchor survives into the wreck', () => { + const w = loaded.get('house_yardside_wrecked'); + assert(w, 'house_yardside_wrecked did not load'); + for (const n of ['fascia_anchor_01', 'fascia_anchor_02', 'fascia_anchor_03']) { + assert(!w.scene.getObjectByName(n), + `${n} survives in the wreck β€” the eave it anchored is gone, so it must be too`); + } + }); + // A wreck that stands taller than the thing it was is a bug, not a wreck. t.test('the wrecked carport is shorter than the carport', () => { const a = new THREE.Box3().setFromObject(loaded.get('carport_01').scene); @@ -400,7 +490,8 @@ export default async function run(t) { // 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_snapped']]) { + ['fence_panel', 'fence_panel_snapped'], + ['house_yardside', 'house_yardside_wrecked']]) { for (const n of [intact, broken]) { const box = new THREE.Box3().setFromObject(loaded.get(n).scene); assert(Math.abs(box.min.y) < 0.03, diff --git a/web/world/models/house_yardside_v1.glb b/web/world/models/house_yardside_v1.glb index db71f21..ffe2101 100644 Binary files a/web/world/models/house_yardside_v1.glb and b/web/world/models/house_yardside_v1.glb differ diff --git a/web/world/models/house_yardside_wrecked_v1.glb b/web/world/models/house_yardside_wrecked_v1.glb new file mode 100644 index 0000000..748a024 Binary files /dev/null and b/web/world/models/house_yardside_wrecked_v1.glb differ