diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index 87ceeac..02e0dbd 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -2,906 +2,28 @@ "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", + "name": "sail_post_corroded", "dims": [ 0.507, 0.52, - 4.0327 + 4.0516 ], - "tris": 528, + "tris": 816, "nodes": [ "footing", "pad_eye", "post", "rake_pivot", - "sail_post", + "rust", + "sail_post_corroded", "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 + "anchor_type": "corroded_post", + "rating_hint": 0.55, + "collateral": "corroded_post" } ], "status": "PASS", diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 32b673c..610876c 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -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,258 @@ 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 (e.test pins corroded top_anchor + below the honest one), not set dressing. + + 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. + """ + 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 + # 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 + 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) * 0.08, WELD_Z + math.cos(DROOP) * 0.08) + 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, + 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 sag — a " + "clean-steel rebuild at the same hint goes red") + 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 +3804,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"]), ] diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png index 5e12d2a..ce89cac 100644 Binary files a/tools/blender/contact_sheet.png and b/tools/blender/contact_sheet.png differ diff --git a/web/world/js/anchor_ratings.gen.js b/web/world/js/anchor_ratings.gen.js index cc9f21f..a669582 100644 --- a/web/world/js/anchor_ratings.gen.js +++ b/web/world/js/anchor_ratings.gen.js @@ -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, diff --git a/web/world/js/contracts.js b/web/world/js/contracts.js index 989fd0b..772cd43 100644 --- a/web/world/js/contracts.js +++ b/web/world/js/contracts.js @@ -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', ]); // --------------------------------------------------------------------------- diff --git a/web/world/js/tests/e.test.js b/web/world/js/tests/e.test.js index 714e921..5db2a1b 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -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,180 @@ 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; + }; + + t.test('the corroded post TELLS: rust area, duller steel, and a pad eye that sags — all measured', () => { + 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'); + + // (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; + 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); + }); + 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'); + + // (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(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'); + }); + + 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', () => { diff --git a/web/world/models/sail_post_corroded_v1.glb b/web/world/models/sail_post_corroded_v1.glb new file mode 100644 index 0000000..e5fe733 Binary files /dev/null and b/web/world/models/sail_post_corroded_v1.glb differ diff --git a/web/world/models/sail_post_corroded_wrecked_v1.glb b/web/world/models/sail_post_corroded_wrecked_v1.glb new file mode 100644 index 0000000..69be741 Binary files /dev/null and b/web/world/models/sail_post_corroded_wrecked_v1.glb differ