Merge remote-tracking branch 'origin/lane/e'
# Conflicts: # THREADS.md
This commit is contained in:
commit
44f019f31d
141
THREADS.md
141
THREADS.md
@ -8816,3 +8816,144 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
|
||||
Pushed lane/b: aa0b36a (gate 0.2), 1bcfcda (gates 0.3+2), this entry. Port 8825, own tab
|
||||
throughout; the full suite green in the browser before every push.
|
||||
[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.
|
||||
|
||||
@ -906,6 +906,53 @@
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "sail_post_corroded",
|
||||
"dims": [
|
||||
0.507,
|
||||
0.52,
|
||||
4.0516
|
||||
],
|
||||
"tris": 816,
|
||||
"nodes": [
|
||||
"footing",
|
||||
"pad_eye",
|
||||
"post",
|
||||
"rake_pivot",
|
||||
"rust",
|
||||
"sail_post_corroded",
|
||||
"top_anchor"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "top_anchor",
|
||||
"anchor_type": "corroded_post",
|
||||
"rating_hint": 0.55,
|
||||
"collateral": "corroded_post"
|
||||
}
|
||||
],
|
||||
"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": []
|
||||
|
||||
@ -137,6 +137,14 @@ PAL = {
|
||||
"bike_kid": "#D8483C", # the Henderson kid's bike — bought bright on purpose
|
||||
"bike_grip": "#3B4048", # grips and saddle
|
||||
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
|
||||
"steel_weathered": "#8D8A7E", # gal gone matte: the corroded post's shaft.
|
||||
# Next to steel_gal (#B6BCC2) it reads DIRTY at
|
||||
# 20 m before you can see a single bloom — the
|
||||
# colour is the long-range half of the tell.
|
||||
"rust_bloom": "#9C5B33", # active surface rust — orange enough to read
|
||||
# against every steel/timber in the palette
|
||||
"rust_deep": "#67351F", # the worst of it: fold lines, weld collars,
|
||||
# streak stains — near-brown, high saturation
|
||||
"alu_frame": "#C9CED2", # powder-coated aluminium — glasshouse + pool fence
|
||||
"pool_water": "#4FA8BE", # chlorinated blue, NOT the pond's grey-green —
|
||||
# a pool is the one water in the game that is
|
||||
@ -2256,6 +2264,284 @@ def build_sail_post(name):
|
||||
return root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE CORRODED TIER (SPRINT17 gate 3.1 — 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 tie-off that DOES exist and shouldn't be trusted:
|
||||
# a real sail post, real concrete footing, real pad eye — gone rusty.
|
||||
#
|
||||
# WHY 0.55, argued on the ladder it joins (0.22 carport < 0.30 carport_post <
|
||||
# 0.35 fascia < 0.45 swing_frame < [0.55] < 0.65 pergola < 1.00 post):
|
||||
# · ABOVE the swing frame (0.45): the footing is still 300 mm of concrete
|
||||
# and the load path still reaches the ground. A sound welded frame
|
||||
# standing loose on grass drags; a rusty post in a real footing holds
|
||||
# until its steel gives — corroded anchored beats sound unanchored.
|
||||
# · BELOW the pergola (0.65): the pergola's members are SOUND and its
|
||||
# weakness (deck flex) is visible and bounded. Corrosion's weakness is
|
||||
# section loss you cannot see the bottom of — the bloom on the outside is
|
||||
# pitting on the inside, so the visible rust is a FLOOR on the damage,
|
||||
# not a ceiling. You de-rate below sound timber because you're rating
|
||||
# what you can't inspect.
|
||||
# · And it FILLS the 0.45→0.65 gap: rungs create decisions (D's S15
|
||||
# endorsement logic, the reason the pergola exists). With this rung the
|
||||
# ladder steps 0.45 / 0.55 / 0.65 — three different kinds of "not quite":
|
||||
# unanchored steel, eaten steel, flexing timber.
|
||||
CORRODED_POST_RATING = 0.55
|
||||
|
||||
# The bill when it snaps. Proposal — Lane A confirms (my S17 prompt says rule
|
||||
# it and let A confirm): a post IS the client's property, so a snapped one
|
||||
# bills — "it was already scrap" is a warranty argument, not a free pass. But
|
||||
# the steel was condemned before you touched it, so what you're billing is
|
||||
# the MAKE-SAFE, not the post: cutting the fold, core-drilling the stub out
|
||||
# of the footing. That's under the gutter's $90 (a full trade's morning run)
|
||||
# and above the gnome's $25. Cheap ON PURPOSE: the real price of trusting
|
||||
# corroded steel is the corner you lose mid-storm — the trap costs you in
|
||||
# sail HP and garden exposure, and the invoice line is honest small change
|
||||
# beside it. If the bill were big, the lesson would read "don't touch rust";
|
||||
# at $45 it reads "rust is a bet, and here's the stake".
|
||||
CORRODED_POST_COLLATERAL = 45
|
||||
|
||||
|
||||
def build_sail_post_corroded(name):
|
||||
"""The corroded sail-post variant — same bones as build_sail_post (H, R,
|
||||
footing, rake_pivot mechanism all identical, so it rakes and places like
|
||||
any post), different truth.
|
||||
|
||||
THE TELL, designed to read at two ranges (the bike lesson: LOOK at it,
|
||||
and look from where the player actually stands):
|
||||
· 20 m / colour: the whole shaft is weathered matte (steel_weathered)
|
||||
against the honest post's bright galvanised, the footing collar wears
|
||||
a rust stain skirt, and the cap sits askew. You don't see rust from
|
||||
the back fence — you see a DIRTY post next to clean ones.
|
||||
· 3 m / detail: rust blooms where posts actually rust (the base band
|
||||
where water pools on the collar, the head collar around the pad-eye
|
||||
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, 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 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,
|
||||
metallic=0.55)
|
||||
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
|
||||
rust = get_material("Mat_Rust", PAL["rust_bloom"], 0.9, metallic=0.1)
|
||||
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
|
||||
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
|
||||
# bleeds onto the collar top, so even the GROUND LINE reads brown.
|
||||
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),
|
||||
add_cyl(f"{name}_stain", 0.23, 0.012, (0, 0, 0.148), rust_d, verts=14),
|
||||
], "footing", root)
|
||||
|
||||
# Same rake mechanism as build_sail_post — rake is a runtime decision and
|
||||
# a corroded post rakes like any other; the lean you might be tempted to
|
||||
# bake here would double-apply the moment a player rakes it.
|
||||
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),
|
||||
# The cap sits askew — rust let the friction fit go. Silhouette tell.
|
||||
add_cyl(f"{name}_cap", R * 1.2, 0.02, (0, 0, H + 0.008), dark, verts=12,
|
||||
rot=(math.radians(14), 0, 0)),
|
||||
], "post"))
|
||||
|
||||
# The corrosion itself, its own node so the mutation control can kill it
|
||||
# cleanly: blooms slightly proud of the shaft (rust swells), streaks
|
||||
# bleeding DOWN from each bloom. Positions are hand-placed, not rng — the
|
||||
# blooms sit where posts actually rust, and determinism costs nothing.
|
||||
corrosion = [
|
||||
add_cyl(f"{name}_rust_base", R + 0.004, 0.34, (0, 0, 0.34), rust, verts=12),
|
||||
add_cyl(f"{name}_rust_line", R + 0.007, 0.11, (0, 0, 0.185), rust_d, verts=12),
|
||||
add_cyl(f"{name}_rust_head", R + 0.004, 0.26, (0, 0, H - 0.16), rust, verts=12),
|
||||
add_cyl(f"{name}_rust_weld", R + 0.006, 0.06, (0, 0, WELD_Z), rust_d, verts=12),
|
||||
]
|
||||
for i, (ang_deg, length, z_top) in enumerate([
|
||||
(200, 1.45, H - 0.24), # off the weld collar, yard side (-y)
|
||||
(335, 0.95, H - 0.30),
|
||||
(25, 0.70, H - 0.35),
|
||||
(160, 0.80, 0.95), # wicking up off the base band
|
||||
(300, 0.60, 0.85)]):
|
||||
a = math.radians(ang_deg)
|
||||
corrosion.append(add_box(
|
||||
f"{name}_streak_{i}", (0.016, 0.016, length),
|
||||
(math.cos(a) * (R + 0.003), math.sin(a) * (R + 0.003),
|
||||
z_top - length / 2), rust_d, rot=(0, 0, a)))
|
||||
above.append(join_group(corrosion, "rust"))
|
||||
|
||||
# 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) * 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) * EYE_ARM / 2,
|
||||
WELD_Z + math.cos(DROOP) * EYE_ARM / 2), rust_d,
|
||||
rot=(-DROOP, 0, 0))]
|
||||
r_eye, segs = 0.026, 10
|
||||
pts = []
|
||||
for i in range(segs + 1):
|
||||
t = math.tau * i / segs
|
||||
pts.append((math.cos(t) * r_eye,
|
||||
ring_c[1] - math.sin(t) * r_eye * math.sin(DROOP),
|
||||
ring_c[2] + math.sin(t) * r_eye * math.cos(DROOP)))
|
||||
for i in range(segs):
|
||||
eye_parts.append(add_tube_between(f"{name}_eye_s{i}", pts[i], pts[i + 1],
|
||||
0.008, rust, verts=8))
|
||||
above.append(join_group(eye_parts, "pad_eye"))
|
||||
|
||||
# The anchor sits AT the drooped eye — the sag is where you'd clip on,
|
||||
# so the sag is data the sim sees, not paint.
|
||||
e = add_empty("top_anchor", ring_c, size=0.2)
|
||||
e["anchor_type"] = "corroded_post"
|
||||
e["rating_hint"] = CORRODED_POST_RATING
|
||||
e["collateral"] = "corroded_post"
|
||||
e["why"] = ("a real post in a real footing, eaten: above the swing frame "
|
||||
"(0.45) because concrete beats loose-on-grass, below the "
|
||||
"pergola (0.65) because the bloom outside is pitting inside — "
|
||||
"you de-rate what you can't inspect. The rung fills 0.45→0.65; "
|
||||
"rungs create decisions")
|
||||
above.append(e)
|
||||
for o in above:
|
||||
parent_keep_transform(o, rake)
|
||||
|
||||
stamp(root, name, "structure")
|
||||
root["post_height"] = H
|
||||
root["rake_note"] = "rotate about rake_pivot; rake away from the load"
|
||||
root["collateral_key"] = "corroded_post"
|
||||
root["collateral_value"] = CORRODED_POST_COLLATERAL
|
||||
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 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
|
||||
|
||||
|
||||
def build_sail_post_corroded_wrecked(name):
|
||||
"""The corroded post after a loaded corner won: FOLDED at the deep-rust
|
||||
line just above the footing (where the intact variant wears its worst
|
||||
band — the wreck fails exactly where the tell said it would), stub
|
||||
standing plumb with a torn rim, the rest of the post flat on the grass
|
||||
reaching ~3.8 m into the yard (-y here, three.js +Z), pad eye still
|
||||
drooped at the far end with nothing left to hold. Same footing, same
|
||||
numbers, same price as its twin (the carport/gutter chain, application
|
||||
six). No anchor empty survives — an anchor that outlives its structure
|
||||
is the free-failure bug in a costume (fascia rule, fourth application)."""
|
||||
root = add_empty(name)
|
||||
steel = get_material("Mat_SteelWeathered", PAL["steel_weathered"], 0.6,
|
||||
metallic=0.55)
|
||||
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
|
||||
rust = get_material("Mat_Rust", PAL["rust_bloom"], 0.9, metallic=0.1)
|
||||
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
|
||||
FOLD = 0.46 # it let go at the rust line, not the weld:
|
||||
# the fold is where the section was thinnest
|
||||
|
||||
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),
|
||||
add_cyl(f"{name}_stain", 0.23, 0.012, (0, 0, 0.148), rust_d, verts=14),
|
||||
], "footing", root)
|
||||
|
||||
# The stub: plumb (the footing held — that half of the 0.55 was honest),
|
||||
# deep rust at the fold, torn rim leaning the way the post went.
|
||||
join_group([
|
||||
add_cyl(f"{name}_stub", R, FOLD, (0, 0, 0.14 + FOLD / 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}_stub_rust", R + 0.007, 0.14, (0, 0, 0.14 + FOLD - 0.07),
|
||||
rust_d, verts=12),
|
||||
add_cone(f"{name}_tear", R + 0.004, 0.012, 0.09,
|
||||
(0.005, -0.02, 0.14 + FOLD + 0.035), rust_d, verts=10,
|
||||
rot=(math.radians(18), 0, 0)),
|
||||
], "post_stub", root)
|
||||
|
||||
# The fallen length, resting on its radius, slightly yawed — nothing real
|
||||
# falls on axis. Blooms ride along it where the intact bands were.
|
||||
L = H - FOLD
|
||||
yaw = math.radians(7)
|
||||
rotv = (math.radians(90), 0, yaw)
|
||||
|
||||
def along(s): # a point s metres from the break end, down the fallen shaft
|
||||
return (-math.sin(yaw) * (0.30 + s), -math.cos(yaw) * (0.30 + s), R)
|
||||
|
||||
down = [
|
||||
add_cyl(f"{name}_shaft_down", R, L, along(L / 2), steel, verts=12, rot=rotv),
|
||||
add_cyl(f"{name}_down_break", R + 0.007, 0.18, along(0.10), rust_d,
|
||||
verts=12, rot=rotv),
|
||||
add_cyl(f"{name}_down_mid", R + 0.004, 0.30, along(L * 0.55), rust,
|
||||
verts=12, rot=rotv),
|
||||
add_cyl(f"{name}_down_head", R + 0.004, 0.26, along(L - 0.35), rust,
|
||||
verts=12, rot=rotv),
|
||||
add_cyl(f"{name}_down_cap", R * 1.2, 0.02, along(L - 0.01), dark,
|
||||
verts=12, rot=rotv),
|
||||
]
|
||||
join_group(down, "post_down", root)
|
||||
|
||||
# The pad eye at the far end, on the grass, still drooped, still rusty —
|
||||
# the one part of this post that was never going to hold anything again.
|
||||
ex, ey, _ = along(L - 0.10)
|
||||
eye = [add_box(f"{name}_padeye_down", (0.012, 0.09, 0.07),
|
||||
(ex + 0.09, ey, 0.035), rust_d,
|
||||
rot=(0, math.radians(80), 0)),
|
||||
add_arc_tube(f"{name}_eye_down", 0.026, 0.008, 0, math.tau, rust,
|
||||
segs=10, center=(ex + 0.16, ey, 0.012), plane='XY')]
|
||||
join_group(eye, "pad_eye_down", root)
|
||||
|
||||
stamp(root, name, "structure")
|
||||
root["broken_variant_of"] = "sail_post_corroded"
|
||||
root["collateral_key"] = "corroded_post"
|
||||
root["collateral_value"] = CORRODED_POST_COLLATERAL
|
||||
root["collateral_label"] = "the corroded post"
|
||||
return root
|
||||
|
||||
|
||||
def build_ladder_01(name):
|
||||
root = add_empty(name)
|
||||
alu = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.85)
|
||||
@ -3544,6 +3830,19 @@ ASSETS = [
|
||||
dims=((5.4, 5.9), (4.1, 4.8), (1.12, 1.32)),
|
||||
nodes=["pool_shell", "pool_water", "fence_rails", "pool_gate",
|
||||
"fence_post_01", "fence_post_14"]),
|
||||
# SPRINT17 gate 3.1 — the corroded tier. The intact variant must stand
|
||||
# POST height (its lie is "I'm a sail post", so it stands like one; only
|
||||
# the colour and the drooped eye say otherwise). The wreck's z ceiling is
|
||||
# under a metre because the post FOLDED at the rust line — a corroded
|
||||
# wreck still standing tall is a wreck that never told the truth — and
|
||||
# its y floor is past 3.5 because the fallen shaft is out in the yard.
|
||||
dict(name="sail_post_corroded", fn=build_sail_post_corroded,
|
||||
dims=((0.40, 0.60), (0.40, 0.65), (3.90, 4.15)),
|
||||
nodes=["footing", "post", "rust", "pad_eye", "top_anchor",
|
||||
"rake_pivot"]),
|
||||
dict(name="sail_post_corroded_wrecked", fn=build_sail_post_corroded_wrecked,
|
||||
dims=((0.40, 0.80), (3.50, 4.40), (0.45, 0.95)),
|
||||
nodes=["footing", "post_stub", "post_down", "pad_eye_down"]),
|
||||
]
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 4.8 MiB |
@ -73,6 +73,13 @@ export const FACTORY_ANCHOR_RATINGS = Object.freeze({
|
||||
"carport_01/post_anchor_02"
|
||||
]
|
||||
},
|
||||
"corroded_post": {
|
||||
"collateral": "corroded_post",
|
||||
"rating_hint": 0.55,
|
||||
"sources": [
|
||||
"sail_post_corroded/top_anchor"
|
||||
]
|
||||
},
|
||||
"house": {
|
||||
"collateral": "gutter",
|
||||
"rating_hint": 0.35,
|
||||
|
||||
@ -89,9 +89,21 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
|
||||
* loose-on-grass (0.45), but the deck flexes and the timber cracks before
|
||||
* concrete would notice (1.00). One rating for the whole type, per the
|
||||
* manifest rule (THREADS [E] S15): unambiguous types stay node-less-safe.
|
||||
*
|
||||
* `corroded_post` (SPRINT17 gate 3.1, E): a sail post gone rusty — the pool
|
||||
* yard's "corroded cheap hardware" thesis as an anchor word. NOT a `post`,
|
||||
* and this time the word isn't about the footing (the footing is fine —
|
||||
* concrete, same as ever): it's about the STEEL. "post" promises sound
|
||||
* galvanised tube; this is eaten tube whose visible bloom is a floor on the
|
||||
* damage, not a ceiling. rating_hint 0.55 — above the swing frame (footed
|
||||
* beats loose), below the pergola (sound flexing timber beats steel you
|
||||
* can't inspect the inside of). One rating for the whole type, per the
|
||||
* manifest rule. The GLB wears the tell (rust, a sagging pad eye); the type
|
||||
* string is the player's pre-rig read of the same fact.
|
||||
*/
|
||||
export const ANCHOR_TYPE = Object.freeze([
|
||||
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola',
|
||||
'corroded_post',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -116,6 +116,16 @@ const ASSETS = [
|
||||
{ name: 'pool_kit_01', h: [1.12, 1.32],
|
||||
nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate',
|
||||
'fence_post_01', 'fence_post_14'] },
|
||||
// SPRINT17 gate 3.1 — the corroded tier. The intact variant stands POST
|
||||
// height on purpose: its lie is "I'm a sail post", so it must stand like
|
||||
// one, and only the colour and the drooped eye say otherwise. The wreck's
|
||||
// ceiling is under a metre because the post FOLDED at the rust line — a
|
||||
// corroded wreck still standing 4 m tall is a wreck that never told the
|
||||
// truth about where it was weakest.
|
||||
{ name: 'sail_post_corroded', h: [3.90, 4.15],
|
||||
nodes: ['footing', 'post', 'rust', 'pad_eye', 'top_anchor', 'rake_pivot'] },
|
||||
{ name: 'sail_post_corroded_wrecked', h: [0.45, 0.95],
|
||||
nodes: ['footing', 'post_stub', 'post_down', 'pad_eye_down'] },
|
||||
];
|
||||
|
||||
function sizeOf(gltf) {
|
||||
@ -1050,6 +1060,273 @@ export default async function run(t) {
|
||||
+ 'lie the invoice exists to kill; when debris can take a panel out, price it THEN');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SPRINT17 gate 3.1 — THE CORRODED TIER. The pool kit is fourteen tie-offs
|
||||
// that don't exist; this is the one that exists and shouldn't be trusted.
|
||||
// The design claim is "corrosion a player can't see is a trap with no tell,
|
||||
// and this repo doesn't ship those" — so the TELL itself is pinned as
|
||||
// numbers (area, lightness, sag), not adjectives. An untelled corroded post
|
||||
// (same 0.55 hint, clean steel) passes the factory, passes RULE 4a/4b, and
|
||||
// passes every dims/nodes check — this test is the ONLY thing that goes red
|
||||
// on it, which is exactly why it exists. (Proven red in the S17 negative
|
||||
// controls; receipts in THREADS.)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Total triangle area of every mesh under a GLB scene, split by predicate
|
||||
* on the mesh's material. World transforms applied; area is exact. */
|
||||
const areaBy = (scene, pred) => {
|
||||
scene.updateMatrixWorld(true);
|
||||
let hit = 0, total = 0;
|
||||
const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
|
||||
scene.traverse((o) => {
|
||||
if (!o.isMesh) return;
|
||||
const pos = o.geometry.attributes.position;
|
||||
const idx = o.geometry.index;
|
||||
const n = idx ? idx.count : pos.count;
|
||||
let area = 0;
|
||||
for (let i = 0; i < n; i += 3) {
|
||||
const ia = idx ? idx.getX(i) : i, ib = idx ? idx.getX(i + 1) : i + 1,
|
||||
ic = idx ? idx.getX(i + 2) : i + 2;
|
||||
a.fromBufferAttribute(pos, ia).applyMatrix4(o.matrixWorld);
|
||||
b.fromBufferAttribute(pos, ib).applyMatrix4(o.matrixWorld);
|
||||
c.fromBufferAttribute(pos, ic).applyMatrix4(o.matrixWorld);
|
||||
b.sub(a); c.sub(a);
|
||||
area += b.cross(c).length() / 2;
|
||||
}
|
||||
total += area;
|
||||
if (pred(o.material)) hit += area;
|
||||
});
|
||||
return { hit, total };
|
||||
};
|
||||
|
||||
/** Rust, as a colour band: strongly saturated orange-brown, darker than
|
||||
* mid. Nothing else in this palette is saturated in that hue (weathered
|
||||
* steel and concrete are near-grey, s < 0.15; timber is lighter). Colours
|
||||
* are read in the loader's working space — thresholds chosen with margin
|
||||
* on both sides (rust s ≈ 0.8, everything else ≤ 0.15). */
|
||||
const isRust = (m) => {
|
||||
const hsl = { h: 0, s: 0, l: 0 };
|
||||
(m.color ?? new THREE.Color(0xffffff)).getHSL(hsl);
|
||||
return hsl.h <= 0.12 && hsl.s >= 0.35 && hsl.l <= 0.45;
|
||||
};
|
||||
|
||||
// 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');
|
||||
|
||||
// (a) The at-a-glance read, as a number: rust-coloured surface area. 5%
|
||||
// of a 4 m post is roughly the base band + head bloom + streaks — under
|
||||
// that and the corrosion is a caption, not a tell. (Shipped value ~19%.)
|
||||
const { hit, total } = areaBy(g.scene, isRust);
|
||||
assert(total > 0, 'no measurable surface at all');
|
||||
const frac = hit / total;
|
||||
assert(frac >= 0.05,
|
||||
`rust covers ${(frac * 100).toFixed(1)}% of the corroded post's surface — under 5% ` +
|
||||
'is corrosion nobody can read from the lawn, a trap with no tell');
|
||||
// And the honest post must NOT read rusty — the tell only works if the
|
||||
// clean one is clean. (Also guards the colour thresholds themselves.)
|
||||
const h = areaBy(honest.scene, isRust);
|
||||
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');
|
||||
});
|
||||
|
||||
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);
|
||||
if (!best || hsl.l > best.l) best = { l: hsl.l, m: o.material };
|
||||
});
|
||||
return best;
|
||||
};
|
||||
const sHonest = shaftOf(honest), sCorroded = shaftOf(g);
|
||||
assert(sHonest && sCorroded, 'could not find shaft materials on the post nodes');
|
||||
|
||||
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`);
|
||||
});
|
||||
|
||||
t.test('corroded steel is a RUNG: above the swing frame, below sound timber, resolvable node-less', () => {
|
||||
const T = FACTORY_ANCHOR_RATINGS.types;
|
||||
const entry = T.corroded_post;
|
||||
assert(entry, 'corroded_post is not in the ratings manifest — the type cannot resolve node-less');
|
||||
assert(!FACTORY_ANCHOR_RATINGS.ambiguous.corroded_post,
|
||||
'corroded_post is manifest-AMBIGUOUS — the one-rating-per-type rule (THREADS [E] S15) broke');
|
||||
// The ladder claims, pinned against the MANIFEST, never against literals
|
||||
// (a literal pin would stay green while the whole ladder reshuffled):
|
||||
assert(T.swing_frame.rating_hint < entry.rating_hint,
|
||||
`corroded post (${entry.rating_hint}) must out-rate the swing frame (${T.swing_frame.rating_hint}) — ` +
|
||||
'a real concrete footing beats sound steel standing loose on grass');
|
||||
assert(entry.rating_hint < T.pergola.rating_hint,
|
||||
`corroded post (${entry.rating_hint}) must rate under the pergola (${T.pergola.rating_hint}) — ` +
|
||||
'sound flexing timber beats steel whose bloom outside means pitting inside');
|
||||
assert(entry.rating_hint < T.post.rating_hint,
|
||||
'a corroded post rating at or above the honest post erases the tier entirely');
|
||||
// It fails through the client's property: collateral named, priced, and
|
||||
// priced UNDER the gutter — the bill is the make-safe, not the steel; the
|
||||
// real price of trusting rust is the corner you lose mid-storm.
|
||||
assert(entry.collateral === 'corroded_post',
|
||||
'the manifest must carry the collateral so node-less anchors bill on failure');
|
||||
const root = g('sail_post_corroded');
|
||||
const gutter = g('house_yardside');
|
||||
assert(typeof root.userData.collateral_value === 'number' && root.userData.collateral_value > 0,
|
||||
'the corroded post names collateral nothing prices — free failure, the gutter bug');
|
||||
assert(root.userData.collateral_value < gutter.userData.collateral_value,
|
||||
`a snapped corroded post ($${root.userData.collateral_value}) must bill under the gutter ` +
|
||||
`($${gutter.userData.collateral_value}) — condemned steel is a make-safe charge, not a rebuild`);
|
||||
|
||||
function g(name) {
|
||||
const glb = loaded.get(name);
|
||||
assert(glb, `${name} did not load`);
|
||||
return glb.scene.getObjectByName(name);
|
||||
}
|
||||
});
|
||||
|
||||
t.test('the corroded wreck folded at the rust line: stub stands, shaft in the yard, nothing left to tie to', () => {
|
||||
const w = loaded.get('sail_post_corroded_wrecked');
|
||||
assert(w, 'sail_post_corroded_wrecked did not load');
|
||||
const box = new THREE.Box3().setFromObject(w.scene);
|
||||
// It FELL: nothing stands past the stub, and the fallen shaft reaches
|
||||
// into the yard on +Z (the same face the pergola presents — placement
|
||||
// facts in THREADS). Both directions measured, not asserted by name.
|
||||
assert(box.max.y < 1.0,
|
||||
`the wreck stands ${box.max.y.toFixed(2)} m — a corroded post that still stands never folded`);
|
||||
assert(box.max.z > 3.2,
|
||||
`the fallen shaft reaches z=${box.max.z.toFixed(2)} — it is meant to be ~3.5 m out in the yard (+Z)`);
|
||||
// No anchor survives, nothing rates, nothing enrols — an anchor that
|
||||
// outlives its structure is the free-failure bug in a costume (the
|
||||
// fascia rule; fourth application).
|
||||
assert(!w.scene.getObjectByName('top_anchor'),
|
||||
'top_anchor survives the wreck — you cannot clip to a post that is lying on the grass');
|
||||
const offers = [];
|
||||
w.scene.traverse((o) => {
|
||||
if (typeof o.userData?.rating_hint === 'number' || o.userData?.anchor_type !== undefined) {
|
||||
offers.push(o.name);
|
||||
}
|
||||
});
|
||||
assert(offers.length === 0,
|
||||
`the wreck offers [${offers.join(', ')}] — a wreck must offer nothing`);
|
||||
// Twin-priced, and it names its twin — the carport/gutter chain, sixth
|
||||
// application, compared between the GLBs and never against a literal.
|
||||
const wr = w.scene.getObjectByName('sail_post_corroded_wrecked');
|
||||
const ir = loaded.get('sail_post_corroded')?.scene.getObjectByName('sail_post_corroded');
|
||||
assert(wr?.userData?.broken_variant_of === 'sail_post_corroded',
|
||||
'the wreck must name its intact twin so A can pair the swap');
|
||||
assert(wr?.userData?.collateral_value === ir?.userData?.collateral_value,
|
||||
'the wrecked post must be priced the same as the one it used to be');
|
||||
assert(wr?.userData?.collateral_key === 'corroded_post',
|
||||
'the wreck must keep the collateral key or the swap changes what the bill is about');
|
||||
});
|
||||
|
||||
// One GLB carries three wilt states as siblings; Lane A toggles .visible
|
||||
// rather than reloading, so all three have to be present at once.
|
||||
t.test('garden_bed carries all 3 damage states in one GLB', () => {
|
||||
|
||||
BIN
web/world/models/sail_post_corroded_v1.glb
Normal file
BIN
web/world/models/sail_post_corroded_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/sail_post_corroded_wrecked_v1.glb
Normal file
BIN
web/world/models/sail_post_corroded_wrecked_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user