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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 17:49:38 +10:00
parent c182fc14cb
commit e46ff18648
4 changed files with 399 additions and 3 deletions

View File

@ -14,6 +14,7 @@
"ref_capsule", "ref_capsule",
"ref_capsule_mesh" "ref_capsule_mesh"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -36,6 +37,23 @@
"tree_gum_01", "tree_gum_01",
"trunk" "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", "status": "PASS",
"problems": [] "problems": []
}, },
@ -56,6 +74,18 @@
"tree_gum_02", "tree_gum_02",
"trunk" "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", "status": "PASS",
"problems": [] "problems": []
}, },
@ -79,6 +109,23 @@
"tree_jacaranda_01", "tree_jacaranda_01",
"trunk" "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", "status": "PASS",
"problems": [] "problems": []
}, },
@ -94,6 +141,7 @@
"fence_post", "fence_post",
"post" "post"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -110,6 +158,7 @@
"palings", "palings",
"rails" "rails"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -128,6 +177,7 @@
"hinge_axis", "hinge_axis",
"hinges" "hinges"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -153,6 +203,30 @@
"window_glow", "window_glow",
"window_light_anchor" "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", "status": "PASS",
"problems": [] "problems": []
}, },
@ -178,6 +252,12 @@
"window_glow", "window_glow",
"window_light_anchor" "window_light_anchor"
], ],
"anchors": [
{
"node": "window_light_anchor",
"tie_off": false
}
],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -200,6 +280,32 @@
"posts", "posts",
"roof" "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", "status": "PASS",
"problems": [] "problems": []
}, },
@ -218,6 +324,7 @@
"posts", "posts",
"roof_down" "roof_down"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -237,6 +344,24 @@
"swing_set_01", "swing_set_01",
"swings" "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", "status": "PASS",
"problems": [] "problems": []
}, },
@ -254,6 +379,12 @@
"swing_set_01_wrecked", "swing_set_01_wrecked",
"swings" "swings"
], ],
"anchors": [
{
"node": "crossbar",
"tie_off": false
}
],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -272,6 +403,12 @@
"shed_01", "shed_01",
"shell" "shell"
], ],
"anchors": [
{
"node": "door_anchor",
"tie_off": false
}
],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -289,6 +426,12 @@
"table_frame", "table_frame",
"table_top" "table_top"
], ],
"anchors": [
{
"node": "pickup_anchor",
"tie_off": false
}
],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -308,6 +451,7 @@
"plants_tattered", "plants_tattered",
"soil" "soil"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -327,6 +471,13 @@
"sail_post", "sail_post",
"top_anchor" "top_anchor"
], ],
"anchors": [
{
"node": "top_anchor",
"anchor_type": "post",
"rating_hint": 1.0
}
],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -344,6 +495,7 @@
"ladder_base", "ladder_base",
"ladder_top" "ladder_top"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -360,6 +512,7 @@
"pin", "pin",
"shackle" "shackle"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -376,6 +529,7 @@
"carabiner", "carabiner",
"gate" "gate"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -393,6 +547,7 @@
"eye_b", "eye_b",
"turnbuckle" "turnbuckle"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -411,6 +566,7 @@
"rim", "rim",
"tramp_01" "tramp_01"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -429,6 +585,7 @@
"wheelie_bin_01", "wheelie_bin_01",
"wheels" "wheels"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -446,6 +603,7 @@
"mast", "mast",
"washing_line_01" "washing_line_01"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -461,6 +619,7 @@
"garden_gnome_01", "garden_gnome_01",
"gnome" "gnome"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -479,6 +638,7 @@
"wheel_front", "wheel_front",
"wheel_rear" "wheel_rear"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -497,6 +657,7 @@
"shards", "shards",
"stump" "stump"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -512,6 +673,7 @@
"hail_stone_01", "hail_stone_01",
"stone" "stone"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -531,6 +693,12 @@
"head", "head",
"poke_tip" "poke_tip"
], ],
"anchors": [
{
"node": "grip_anchor",
"tie_off": false
}
],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
}, },
@ -548,6 +716,7 @@
"palings", "palings",
"rails" "rails"
], ],
"anchors": [],
"status": "PASS", "status": "PASS",
"problems": [] "problems": []
} }

View File

@ -70,6 +70,16 @@ DEBRIS_DIR = os.path.join(MODELS_DIR, "debris")
TEXTURES_DIR = os.path.join(MODELS_DIR, "textures") TEXTURES_DIR = os.path.join(MODELS_DIR, "textures")
CONTACT_SHEET = os.path.join(SCRIPT_DIR, "contact_sheet.png") CONTACT_SHEET = os.path.join(SCRIPT_DIR, "contact_sheet.png")
REPORT_JSON = os.path.join(SCRIPT_DIR, "asset_report.json") 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 # 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 # 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 = add_empty("top_anchor", (0, 0, H - 0.02), size=0.2)
e["anchor_type"] = "post" 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) above.append(e)
for o in above: for o in above:
parent_keep_transform(o, rake) parent_keep_transform(o, rake)
@ -3137,6 +3156,104 @@ def add_label(cam, text):
return t 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): def verify_all(only=None):
todo = [a for a in ASSETS if only is None or a["name"] in only] 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") 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: for p in problems:
print(f" -> {p}") print(f" -> {p}")
report.append(dict(name=name, dims=dims, tris=tris, report.append(dict(name=name, dims=dims, tris=tris,
nodes=sorted(names), status=status, nodes=sorted(names), anchors=extract_anchor_meta(objs),
problems=problems)) status=status, problems=problems))
return report, failures, thumbs return report, failures, thumbs
@ -3293,6 +3410,16 @@ def main():
json.dump(dict(blender=bpy.app.version_string, assets=report, json.dump(dict(blender=bpy.app.version_string, assets=report,
debris=debris), f, indent=2) debris=debris), f, indent=2)
print(f" report -> {REPORT_JSON}") 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) print("\n" + "=" * 72)
if failures: if failures:

View File

@ -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"
]
}
}
});

Binary file not shown.