From e46ff18648e58d20e354c0e280ee0384aef16611 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 17:49:38 +1000 Subject: [PATCH] Lane E S15 gate 1.2: re-bake sail_post top_anchor 0.90 -> 1.00 per the gate 1.1 ruling; factory emits the per-type anchor ratings manifest (anchor_ratings.gen.js) from the re-imported GLBs, ambiguity-aware, --only-guarded; asset_report carries per-asset anchor extras Co-Authored-By: Claude Opus 4.8 --- tools/blender/asset_report.json | 169 +++++++++++++++++++++++++++++ tools/blender/build_yard_assets.py | 133 ++++++++++++++++++++++- web/world/js/anchor_ratings.gen.js | 100 +++++++++++++++++ web/world/models/sail_post_v1.glb | Bin 36192 -> 36192 bytes 4 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 web/world/js/anchor_ratings.gen.js diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index 35e0e67..043f3fe 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -14,6 +14,7 @@ "ref_capsule", "ref_capsule_mesh" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -36,6 +37,23 @@ "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": [] }, @@ -56,6 +74,18 @@ "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": [] }, @@ -79,6 +109,23 @@ "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": [] }, @@ -94,6 +141,7 @@ "fence_post", "post" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -110,6 +158,7 @@ "palings", "rails" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -128,6 +177,7 @@ "hinge_axis", "hinges" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -153,6 +203,30 @@ "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": [] }, @@ -178,6 +252,12 @@ "window_glow", "window_light_anchor" ], + "anchors": [ + { + "node": "window_light_anchor", + "tie_off": false + } + ], "status": "PASS", "problems": [] }, @@ -200,6 +280,32 @@ "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": [] }, @@ -218,6 +324,7 @@ "posts", "roof_down" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -237,6 +344,24 @@ "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": [] }, @@ -254,6 +379,12 @@ "swing_set_01_wrecked", "swings" ], + "anchors": [ + { + "node": "crossbar", + "tie_off": false + } + ], "status": "PASS", "problems": [] }, @@ -272,6 +403,12 @@ "shed_01", "shell" ], + "anchors": [ + { + "node": "door_anchor", + "tie_off": false + } + ], "status": "PASS", "problems": [] }, @@ -289,6 +426,12 @@ "table_frame", "table_top" ], + "anchors": [ + { + "node": "pickup_anchor", + "tie_off": false + } + ], "status": "PASS", "problems": [] }, @@ -308,6 +451,7 @@ "plants_tattered", "soil" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -327,6 +471,13 @@ "sail_post", "top_anchor" ], + "anchors": [ + { + "node": "top_anchor", + "anchor_type": "post", + "rating_hint": 1.0 + } + ], "status": "PASS", "problems": [] }, @@ -344,6 +495,7 @@ "ladder_base", "ladder_top" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -360,6 +512,7 @@ "pin", "shackle" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -376,6 +529,7 @@ "carabiner", "gate" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -393,6 +547,7 @@ "eye_b", "turnbuckle" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -411,6 +566,7 @@ "rim", "tramp_01" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -429,6 +585,7 @@ "wheelie_bin_01", "wheels" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -446,6 +603,7 @@ "mast", "washing_line_01" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -461,6 +619,7 @@ "garden_gnome_01", "gnome" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -479,6 +638,7 @@ "wheel_front", "wheel_rear" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -497,6 +657,7 @@ "shards", "stump" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -512,6 +673,7 @@ "hail_stone_01", "stone" ], + "anchors": [], "status": "PASS", "problems": [] }, @@ -531,6 +693,12 @@ "head", "poke_tip" ], + "anchors": [ + { + "node": "grip_anchor", + "tie_off": false + } + ], "status": "PASS", "problems": [] }, @@ -548,6 +716,7 @@ "palings", "rails" ], + "anchors": [], "status": "PASS", "problems": [] } diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index fd26e8e..2108280 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -70,6 +70,16 @@ DEBRIS_DIR = os.path.join(MODELS_DIR, "debris") TEXTURES_DIR = os.path.join(MODELS_DIR, "textures") CONTACT_SHEET = os.path.join(SCRIPT_DIR, "contact_sheet.png") REPORT_JSON = os.path.join(SCRIPT_DIR, "asset_report.json") +# SPRINT15 gate 1.2 — the ratings manifest the RUNTIME reads. A baked +# rating_hint used to reach the sim only through adoptAnchor, which only runs +# for anchors that name a GLB `node` — every plain-JSON post defaulted to +# `?? 1` no matter what this factory baked, and factory and sim silently +# disagreed for fourteen sprints. This module is the missing wire: per +# anchor_type, the factory's baked extras, generated HERE so it cannot be +# hand-edited into a second source of truth. world.js resolves a node-less +# JSON anchor's type through it; e.test.js pins runtime === manifest === GLB. +RATINGS_GEN_JS = os.path.join(REPO_ROOT, "web", "world", "js", + "anchor_ratings.gen.js") # The 3D-STORE library on this box. PLAN3D §2 lists ~/Documents/3D-STORE (that # path is from the M1 Ultra); on the M3 Ultra the library lives here. Checked in @@ -1684,7 +1694,16 @@ def build_sail_post(name): e = add_empty("top_anchor", (0, 0, H - 0.02), size=0.2) e["anchor_type"] = "post" - e["rating_hint"] = 0.9 + # SPRINT15 gate 1.1 RULING (THREADS [A] 2026-07-18): the honest post is + # 1.00 — the shipped value becomes canon. The 0.90 baked here from Sprint 1 + # never reached the sim: every shipped post is a plain JSON entry with no + # `node`, so adoptAnchor never ran and `?? 1` won. Every measured number in + # the repo (separation pins, winnable-lines tables, night 5's whisker) was + # measured at effective 1.00; 0.90 was design intent that never shipped. + # A weaker honest post later is a DESIGN CHANGE proposed with evidence, + # wearing its own name — not a bugfix. Do not lower this without + # re-opening that ruling. + e["rating_hint"] = 1.0 above.append(e) for o in above: parent_keep_transform(o, rake) @@ -3137,6 +3156,104 @@ def add_label(cam, text): return t +def extract_anchor_meta(objs): + """The anchor-family custom props (`anchor_type`, `rating_hint`, `tie_off`, + `collateral`), read off the RE-IMPORTED GLB rather than the build graph — + so the report describes what actually shipped through the exporter, not + what this script intended. Same provenance rule as the dims/nodes checks. + """ + out = [] + for o in objs: + keys = set(o.keys()) + if "anchor_type" not in keys and "tie_off" not in keys: + continue + m = {"node": o.name} + if "anchor_type" in keys: + m["anchor_type"] = str(o["anchor_type"]) + if "rating_hint" in keys: + m["rating_hint"] = round(float(o["rating_hint"]), 4) + if "tie_off" in keys: + m["tie_off"] = bool(o["tie_off"]) + if "collateral" in keys: + m["collateral"] = str(o["collateral"]) + out.append(m) + return sorted(out, key=lambda m: m["node"]) + + +def build_ratings_manifest(report): + """Collapse the per-asset anchor meta into per-TYPE ratings the runtime can + resolve. The contract: + + · `tie_off: false` nodes are DENIALS, not anchors — excluded. + · a type where every rated node in the palette agrees on + (rating_hint, collateral) is UNAMBIGUOUS and lands in `types` — a + node-less JSON anchor of that type takes these extras at runtime. + · a type whose nodes disagree (tree: 1.0/0.88/0.76 down the trunk) is + AMBIGUOUS — listed with every variant so the runtime can refuse to + guess. A node-less anchor of an ambiguous type is a site bug; e.test + pins that no shipped site declares one. + · a node that carries `anchor_type` but no `rating_hint` and no denial + violates RULE 1 (no silent anchors) and fails the build loudly here, + before the GLB can ship. + """ + per_type = {} + for a in report: + for m in a.get("anchors", []): + if m.get("tie_off") is False: + continue # explicit denial: not an anchor + ty = m.get("anchor_type") + if ty is None: + continue + if "rating_hint" not in m: + raise RuntimeError( + f"{a['name']}/{m['node']} carries anchor_type={ty!r} with " + f"no rating_hint and no tie_off denial — RULE 1 (no silent " + f"anchors). Rate it or not_a_tie_off() it.") + key = (m["rating_hint"], m.get("collateral")) + per_type.setdefault(ty, {}).setdefault(key, []).append( + f"{a['name']}/{m['node']}") + types, ambiguous = {}, {} + for ty in sorted(per_type): + variants = per_type[ty] + if len(variants) == 1: + (hint, coll), sources = next(iter(variants.items())) + entry = {"rating_hint": hint, "sources": sorted(sources)} + if coll is not None: + entry["collateral"] = coll + types[ty] = entry + else: + ambiguous[ty] = {"variants": [ + dict(rating_hint=h, sources=sorted(srcs), + **({"collateral": c} if c is not None else {})) + for (h, c), srcs in sorted(variants.items(), + key=lambda kv: (-kv[0][0], + kv[0][1] or "")) + ]} + return {"types": types, "ambiguous": ambiguous} + + +def write_ratings_module(manifest): + body = json.dumps(manifest, indent=2, sort_keys=True) + header = ( + "// GENERATED by tools/blender/build_yard_assets.py — do NOT hand-edit.\n" + "// Regenerate: blender -b -P tools/blender/build_yard_assets.py\n" + "//\n" + "// SPRINT15 gate 1.2 — the wire between the factory and the sim.\n" + "// adoptAnchor only carries a baked rating_hint to anchors that name a\n" + "// GLB `node`; a plain-JSON anchor (every shipped sail post) defaulted\n" + "// to `?? 1` regardless of the bake, and nobody could see the\n" + "// disagreement. This module is the factory's bakes, resolvable by\n" + "// anchor TYPE at runtime: `types` are unambiguous (every rated node\n" + "// of that type agrees), `ambiguous` lists the disagreements so the\n" + "// runtime can refuse to guess. ratings.js is the reader; e.test.js\n" + "// pins runtime === this module === the GLBs, so none of the three\n" + "// can drift from the others silently again.\n" + ) + with open(RATINGS_GEN_JS, "w") as f: + f.write(header + "export const FACTORY_ANCHOR_RATINGS = " + + "Object.freeze(" + body + ");\n") + + def verify_all(only=None): todo = [a for a in ASSETS if only is None or a["name"] in only] print(f"\n--- VERIFY {len(todo)} GLBs (re-import + assert + render) ---\n") @@ -3202,8 +3319,8 @@ def verify_all(only=None): for p in problems: print(f" -> {p}") report.append(dict(name=name, dims=dims, tris=tris, - nodes=sorted(names), status=status, - problems=problems)) + nodes=sorted(names), anchors=extract_anchor_meta(objs), + status=status, problems=problems)) return report, failures, thumbs @@ -3293,6 +3410,16 @@ def main(): json.dump(dict(blender=bpy.app.version_string, assets=report, debris=debris), f, indent=2) print(f" report -> {REPORT_JSON}") + # The runtime manifest is only honest when built from the WHOLE + # palette — an --only run would collapse "every node of this type + # agrees" to a sample of one and could flip an ambiguous type to + # unambiguous. So partial runs leave it untouched, loudly. + if only is None: + write_ratings_module(build_ratings_manifest(report)) + print(f" ratings -> {RATINGS_GEN_JS}") + else: + print(" ratings -> NOT rewritten (--only run is a partial " + "palette; run the full factory to regenerate)") print("\n" + "=" * 72) if failures: diff --git a/web/world/js/anchor_ratings.gen.js b/web/world/js/anchor_ratings.gen.js new file mode 100644 index 0000000..bcaec8a --- /dev/null +++ b/web/world/js/anchor_ratings.gen.js @@ -0,0 +1,100 @@ +// GENERATED by tools/blender/build_yard_assets.py — do NOT hand-edit. +// Regenerate: blender -b -P tools/blender/build_yard_assets.py +// +// SPRINT15 gate 1.2 — the wire between the factory and the sim. +// adoptAnchor only carries a baked rating_hint to anchors that name a +// GLB `node`; a plain-JSON anchor (every shipped sail post) defaulted +// to `?? 1` regardless of the bake, and nobody could see the +// disagreement. This module is the factory's bakes, resolvable by +// anchor TYPE at runtime: `types` are unambiguous (every rated node +// of that type agrees), `ambiguous` lists the disagreements so the +// runtime can refuse to guess. ratings.js is the reader; e.test.js +// pins runtime === this module === the GLBs, so none of the three +// can drift from the others silently again. +export const FACTORY_ANCHOR_RATINGS = Object.freeze({ + "ambiguous": { + "tree": { + "variants": [ + { + "rating_hint": 1.0, + "sources": [ + "tree_gum_01/branch_anchor_01", + "tree_gum_02/branch_anchor_01" + ] + }, + { + "rating_hint": 0.95, + "sources": [ + "tree_jacaranda_01/branch_anchor_01" + ] + }, + { + "rating_hint": 0.88, + "sources": [ + "tree_gum_01/branch_anchor_02", + "tree_gum_02/branch_anchor_02" + ] + }, + { + "rating_hint": 0.76, + "sources": [ + "tree_gum_01/branch_anchor_03" + ] + }, + { + "rating_hint": 0.52, + "sources": [ + "tree_jacaranda_01/branch_anchor_02" + ] + }, + { + "rating_hint": 0.4, + "sources": [ + "tree_jacaranda_01/branch_anchor_03" + ] + } + ] + } + }, + "types": { + "carport": { + "collateral": "carport", + "rating_hint": 0.22, + "sources": [ + "carport_01/beam_anchor_01", + "carport_01/beam_anchor_02" + ] + }, + "carport_post": { + "collateral": "carport", + "rating_hint": 0.3, + "sources": [ + "carport_01/post_anchor_01", + "carport_01/post_anchor_02" + ] + }, + "house": { + "collateral": "gutter", + "rating_hint": 0.35, + "sources": [ + "house_yardside/fascia_anchor_01", + "house_yardside/fascia_anchor_02", + "house_yardside/fascia_anchor_03" + ] + }, + "post": { + "rating_hint": 1.0, + "sources": [ + "sail_post/top_anchor" + ] + }, + "swing_frame": { + "collateral": "swing_set", + "rating_hint": 0.45, + "sources": [ + "swing_set_01/frame_anchor_01", + "swing_set_01/frame_anchor_02" + ] + } + } +}); diff --git a/web/world/models/sail_post_v1.glb b/web/world/models/sail_post_v1.glb index 878e3cd75f0372ca81bdc27fa44a23e7dd7726fe..55d4dfb2a5093ebc942975bfa8648995a357efb0 100644 GIT binary patch delta 18 acmaDbi|N5ErVV|J%!Yafn