diff --git a/.gitignore b/.gitignore index d9586d3..8598e15 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,11 @@ *.obj *.mtl +# Lane E: per-asset verification renders — regenerable, and 3 MB of churn. +# The tiled tools/blender/contact_sheet.png IS committed; it's the acceptance +# evidence for §5-E, and it renders deterministically so it never churns. +tools/blender/thumbs/ + # macOS / python noise .DS_Store __pycache__/ diff --git a/THREADS.md b/THREADS.md index 6382914..815eb57 100644 --- a/THREADS.md +++ b/THREADS.md @@ -177,3 +177,80 @@ Format: `[lane letter] YYYY-MM-DD — note` ~7 s — which is how the cloth got proven before M0 existed. Same array both ways, so the two harnesses cannot drift. `contracts.js` importing three is no obstacle to that: node imports `three.module.js` fine. +[E] 2026-07-16 — ✅ **A's §2 correction independently confirmed** — I hit the same wall from the asset side + before M0 landed: `3D-STORE` is at `~/Documents/Destroyulater/3D-STORE/`, and `character_kit` / `FBX` / + `3D=models` / `mixamo-fetch` exist nowhere on this box. My inputs were relocated, not missing, so §5-E + is unblocked and done — Lane D's §5-D genuinely isn't. Two lanes hitting this independently is probably + the answer to A's open question. + +[E] 2026-07-16 — **§5-E LANDED: 16 GLBs + grass atlas, all from one script.** + `blender -b -P tools/blender/build_yard_assets.py` (flags: `--only ` / `--no-verify` / + `--no-debris`). Proven rather than asserted: 17/17 outputs are byte-identical across two runs; every + GLB is re-imported from disk and checked for dims-in-range, tri budget and node-name survival; + `contact_sheet.png` renders each beside the 1.7 m ref capsule. Heaviest is garden_bed at 2,580 tris — + everything far under the 15 k budget. Machine-readable manifest: `tools/blender/asset_report.json`. + +[E] 2026-07-16 — **NODE CONTRACTS — the names your code queries.** Every empty survives the export; + verified in three.js, not just Blender. + · trees: `trunk` (trunk+branches, rigid) + `canopy_01..03` as SEPARATE nodes — Lane A, sway the + canopies only. `branch_anchor_01..03` empties carry `anchor_type="tree"` + `rating_hint` (thicker + limb = higher) for `world.anchors`. + · `house_yardside`: `fascia_anchor_01..03` carry `rating_hint=0.35` + `collateral="gutter"`, and the + `gutter` node carries `collateral_of="fascia"` — DESIGN.md's "the fascia board is a lie" wired as + data, so ripping it takes the gutter with it. Facade only, 9.20 × 1.05 × 2.90 m, no interior. + · `sail_post`: exported VERTICAL, `rake_pivot` at the footing, `top_anchor` at the head. Rake is a + player decision (DESIGN.md: rake away from the load), so it's a runtime rotation, never baked. + **Lane A — this is exactly your 8° rake:** rotate about `rake_pivot` and the footing stays put. + · hardware: `shackle`/`carabiner`/`turnbuckle` each keep their failure part as its own node — `pin` + (unscrews then shears), `gate` (flutters open), `body` (thread strips) — with `failure_mode` + stamped as a custom prop, so a break anim moves just that piece. + · `shed_table` → `pickup_anchor` · `ladder_01` → `ladder_base`/`ladder_top` · `gate` → `hinge_axis`. + +[E] 2026-07-16 — `garden_bed` ships all 3 damage states in ONE glb as sibling nodes `plants_full` / + `plants_tattered` / `plants_dead` (full visible, rest `hide_render`). Lane A: toggle `.visible`, don't + reload — instant swap, no pop-in. Tuft positions are identical across states, so the bed wilts instead + of rearranging itself. + +[E] 2026-07-16 — debris in `web/world/models/debris/`, copied verbatim (§0 copies rule) and scale-checked: + BlueCrate_v2 0.36×0.36×0.29 · BlackTub_v2 + WhiteTub_v2 0.36×0.54×0.20 · WoodenBin_v2 0.35×0.36×0.31 m + — all plausible real-world sizes. Plus `tramp_01_v1.glb` (2.96×2.96×0.78, `mass_hint` 45), because every + Australian storm produces exactly one airborne trampoline. **Lane C: glob the dir, don't hardcode + names** — §0's `*_v1.glb` rule beats §5-E's "tramp_01.glb" spelling. Grass is a texture, not geometry + (§5-E item 9): `models/textures/grass_atlas.png`, 512², 2×2 tufts, alpha — instance billboards off it. + +[E] 2026-07-16 — ⚠️ **LANE A + LANE C, BOUNDING BOXES.** `THREE.Box3.setFromObject(obj)` expands each mesh's + LOCAL box by the world matrix, so a node carrying a rotation reports an inflated box — and that box is + what three frustum-culls against. Blender's `obj.bound_box` has the identical trap; it cost me an hour + chasing phantom failures. Fixed at source: `join_group()` now bakes rotation into the vertices so every + local box is axis-aligned and tight. Before the fix, three reported `tramp_01` as 3.29 × 1.27 m against + a true 2.96 × 0.78. Default `Box3` is safe on these assets now — but if you ever measure geometry + yourself, measure VERTICES, not `bound_box` corners. + +[E] 2026-07-16 — filled in `js/tests/e.test.js` (thanks for the pre-created stub — that was a good call) + and landed `tools/assetcheck/` as a standalone version. Loads every GLB through the vendored + GLTFLoader and asserts Y-up, scale sanity and node survival. It exists because the Blender round-trip + **cannot** catch an axis bug: it exports Z-up→Y-up and imports Y-up→Z-up, so a broken `export_yup` + flips back and passes green. Only a native glTF reader can prove it. Green: 16/16, with + `branch_anchor_01` at (-0.96, 3.64, -1.46) — height correctly on +Y. + +[E] 2026-07-16 — ⚠️ **LANE A — three lines needed in selftest.html + index.html. Blocks Lane D too.** + No page in the repo has an ` + I did **not** edit your file (§6 says post the need instead, and you'd asked for selftest.html to stay + out of the merge path). `e.test.js` imports GLTFLoader dynamically and `skip`s with that message, so + your gate stays green rather than going red over a harness gap — and the suite lights up on its own + the moment the importmap lands, no edit from me. Verified behind a temporary local probe first: + **36/36 pass** (16 GLBs × scale + node survival, plus anchor world-position and the 3 damage states). + Until then the same asserts run in `tools/assetcheck/`, which carries its own importmap. + +[E] 2026-07-16 — ❓ open q for Lane A: your yard puts house fascia anchors at y=2.6, but `house_yardside`'s + fascia sits at 2.80 (2.90 m ridge), and the facade is 9.20 m against a 30 m north edge. Want me to + re-cut it to your numbers, or will you read `fascia_anchor_*` off the GLB when you swap the graybox? + Either way it's one constant for me — the script regenerates everything. diff --git a/tools/assetcheck/README.md b/tools/assetcheck/README.md new file mode 100644 index 0000000..0f194e5 --- /dev/null +++ b/tools/assetcheck/README.md @@ -0,0 +1,50 @@ +# assets_in_three.html — verify the GLBs in the real consumer + +`build_yard_assets.py` already re-imports every GLB into Blender and asserts +dims, tri budget, and node names. That is necessary but **structurally cannot +catch an axis error**: Blender exports Z-up→Y-up and imports Y-up→Z-up, so a +broken `export_yup` flips back on the way in and round-trips green. Only +something that reads glTF natively can prove the file is right. + +This page is that check. It loads each GLB with three.js r175's `GLTFLoader` and +asserts: + +- **Y-up**: a Blender asset measuring `(dx, dy, dz)` must arrive as `(dx, dz, dy)`. +- **node survival**: `branch_anchor_*`, `fascia_anchor_*`, `plants_*` etc. still + exist after the export — glTF has no "empty", anchors arrive as bare + `Object3D`, and exporters have been known to prune childless nodes. +- **anchors are usable**: `branch_anchor_01` resolves to a sane world position + with height on +Y. + +Expectations are read from `tools/blender/asset_report.json`, so this stays in +sync with the factory automatically. + +## Why it uses the default (non-precise) Box3 + +`THREE.Box3.setFromObject(obj)` expands each mesh's **local** bounding box by the +world matrix, so a node carrying a rotation reports an inflated box. That is the +same trap Blender's `obj.bound_box` sets, and it is what three uses for frustum +culling. `join_group()` therefore bakes rotation into the vertices so the local +box is axis-aligned and tight. Passing `precise: true` here would hide exactly +the regression this page exists to catch — so don't. + +Before that fix, three reported `tramp_01` as 3.29 × 1.27 m against a true +2.96 × 0.78 m. + +## Running it + +Needs three.js on `/world/vendor/` and the models on `/models/`. Once Lane A's +`server.py` lands, serve `web/` and this can move next to `selftest.html` +(Lane A: happy to fold it in — see THREADS). + +Until then, the standalone recipe: + +```sh +D=$(mktemp -d) && mkdir -p "$D/world" +ln -s ~/Documents/90sDJsim/web/world/vendor "$D/world/vendor" +ln -s "$PWD/web/world/models" "$D/models" +ln -s "$PWD/tools/blender/asset_report.json" "$D/asset_report.json" +cp tools/assetcheck/assets_in_three.html "$D/index.html" +python3 -m http.server 8805 --directory "$D" +# open http://127.0.0.1:8805 — look for "SUMMARY: ALL PASS IN THREE.JS" +``` diff --git a/tools/assetcheck/assets_in_three.html b/tools/assetcheck/assets_in_three.html new file mode 100644 index 0000000..3afa682 --- /dev/null +++ b/tools/assetcheck/assets_in_three.html @@ -0,0 +1,82 @@ + + +Lane E — GLB check in the real consumer (three.js r175) + +

GLB verification — loaded by three.js GLTFLoader, not Blender

+
loading…
+ + + diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json new file mode 100644 index 0000000..870fad6 --- /dev/null +++ b/tools/blender/asset_report.json @@ -0,0 +1,328 @@ +{ + "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" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "tree_gum_01", + "dims": [ + 4.5522, + 4.956, + 7.9702 + ], + "tris": 396, + "nodes": [ + "branch_anchor_01", + "branch_anchor_02", + "branch_anchor_03", + "canopy_01", + "canopy_02", + "canopy_03", + "tree_gum_01", + "trunk" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "tree_gum_02", + "dims": [ + 3.8871, + 2.7787, + 5.4972 + ], + "tris": 288, + "nodes": [ + "branch_anchor_01", + "branch_anchor_02", + "canopy_01", + "canopy_02", + "tree_gum_02", + "trunk" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "fence_post", + "dims": [ + 0.13, + 0.13, + 2.03 + ], + "tris": 24, + "nodes": [ + "fence_post", + "post" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "fence_panel", + "dims": [ + 2.4, + 0.054, + 1.8194 + ], + "tris": 324, + "nodes": [ + "fence_panel", + "palings", + "rails" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "gate", + "dims": [ + 1.045, + 0.0615, + 1.75 + ], + "tris": 220, + "nodes": [ + "gate", + "gate_frame", + "gate_palings", + "hinge_axis", + "hinges" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "house_yardside", + "dims": [ + 9.2, + 1.0547, + 2.9 + ], + "tris": 200, + "nodes": [ + "door", + "fascia", + "fascia_anchor_01", + "fascia_anchor_02", + "fascia_anchor_03", + "gutter", + "house_yardside", + "roof", + "wall", + "window" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "shed_01", + "dims": [ + 2.58, + 1.9708, + 2.2224 + ], + "tris": 96, + "nodes": [ + "door_anchor", + "doors", + "roof", + "shed_01", + "shell" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "shed_table", + "dims": [ + 1.6, + 0.6, + 0.9 + ], + "tris": 72, + "nodes": [ + "pickup_anchor", + "shed_table", + "table_frame", + "table_top" + ], + "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" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "sail_post", + "dims": [ + 0.507, + 0.52, + 4.0327 + ], + "tris": 528, + "nodes": [ + "footing", + "pad_eye", + "post", + "rake_pivot", + "sail_post", + "top_anchor" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "ladder_01", + "dims": [ + 0.455, + 0.075, + 3.0 + ], + "tris": 276, + "nodes": [ + "ladder", + "ladder_01", + "ladder_base", + "ladder_top" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "shackle", + "dims": [ + 0.0569, + 0.019, + 0.0744 + ], + "tris": 560, + "nodes": [ + "bow", + "pin", + "shackle" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "carabiner", + "dims": [ + 0.049, + 0.009, + 0.1027 + ], + "tris": 476, + "nodes": [ + "body", + "carabiner", + "gate" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "turnbuckle", + "dims": [ + 0.0292, + 0.0341, + 0.1955 + ], + "tris": 728, + "nodes": [ + "body", + "eye_a", + "eye_b", + "turnbuckle" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "tramp_01", + "dims": [ + 2.9555, + 2.9555, + 0.78 + ], + "tris": 976, + "nodes": [ + "legs", + "mat", + "pad", + "rim", + "tramp_01" + ], + "status": "PASS", + "problems": [] + } + ], + "debris": [ + { + "file": "BlueCrate_v2.glb", + "dims": [ + 0.36, + 0.36, + 0.29 + ], + "sane": true + }, + { + "file": "BlackTub_v2.glb", + "dims": [ + 0.36, + 0.54, + 0.2 + ], + "sane": true + }, + { + "file": "WhiteTub_v2.glb", + "dims": [ + 0.36, + 0.54, + 0.2 + ], + "sane": true + }, + { + "file": "WoodenBin_v2.glb", + "dims": [ + 0.35, + 0.36, + 0.31 + ], + "sane": true + } + ] +} \ No newline at end of file diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py new file mode 100644 index 0000000..16c950e --- /dev/null +++ b/tools/blender/build_yard_assets.py @@ -0,0 +1,1409 @@ +""" +SHADES — yard asset factory (Lane E) +==================================== + +ONE deterministic script that regenerates every nature + hardware GLB the game +needs. Nothing here is hand-edited afterwards: change the script, re-run, commit +the new GLBs (PLAN3D §0, "asset copies rule"). + +Run: + blender -b -P tools/blender/build_yard_assets.py + blender -b -P tools/blender/build_yard_assets.py -- --only tree_gum_01 + blender -b -P tools/blender/build_yard_assets.py -- --no-verify + blender -b -P tools/blender/build_yard_assets.py -- --no-debris + +Outputs (resolved from this file, so no absolute home paths): + web/world/models/*.glb nature, hardware, ref_capsule + web/world/models/debris/*.glb copied verbatim from the 3D-STORE library + web/world/models/textures/grass_atlas.png + tools/blender/contact_sheet.png verification render vs the 1.7 m capsule + tools/blender/asset_report.json measured dims / tris / node names + +Idiom follows ~/Documents/Destroyulater/3D-STORE/racks_to_glb.py: + reset_to_empty() per asset -> build under a root empty AT THE ORIGIN -> + join by group -> stamp custom props -> export_scene.gltf(export_yup=True, + export_extras=True, export_apply=True). + +Gotchas this script respects (all learned the hard way elsewhere in the house): + - Blender's glTF importer leaves objects at rotation_mode='QUATERNION', and + assigning .rotation_euler is then SILENTLY IGNORED. That is the bug that hid + every fix across booth_room v3..v18. import_glb() forces 'XYZ' immediately. + - Material.blend_method is deprecated under EEVEE Next; prefer + surface_render_method when it exists. + - Blender is Z-up, glTF is Y-up. Build Z-up here; export_yup=True flips it. + A branch_anchor at Blender (0, 0, 3) arrives in three.js at (0, 3, 0). + - Root empties stay at the world origin so `obj.parent = root` needs no + parent-inverse juggling. + +Contract notes for other lanes (see THREADS.md): + - Trees expose `trunk` + `canopy_01..03` as separate nodes so Lane A can sway + canopies without moving the trunk, plus `branch_anchor_*` empties for + world.anchors. + - house_yardside exposes `fascia_anchor_01..03` — the fascia is a lie + (DESIGN.md), and these are the anchors that are supposed to betray you. + - sail_post is exported VERTICAL with a `rake_pivot` empty at the footing and + a `top_anchor` at the head. Rake is a gameplay decision, so it is a runtime + rotation about rake_pivot, not baked into the mesh. + - shackle/carabiner/turnbuckle keep their failure-mode part as its own node + (`pin`, `gate`, `body`) so the break animation can move just that piece. +""" + +import bpy +import bmesh # noqa: F401 (imported for parity with the house scripts) +import json +import math +import os +import random +import shutil +import sys + +from mathutils import Vector + +# ============================================================================ +# CONFIG +# ============================================================================ +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "..")) +MODELS_DIR = os.path.join(REPO_ROOT, "web", "world", "models") +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") + +# 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 +# order, first hit wins, missing -> debris copy is skipped with a warning. +DEBRIS_SOURCES = [ + os.path.expanduser("~/Documents/Destroyulater/3D-STORE/clean_glbs"), + os.path.expanduser("~/Documents/3D-STORE/clean_glbs"), +] +DEBRIS_FILES = [ + "BlueCrate_v2.glb", + "BlackTub_v2.glb", + "WhiteTub_v2.glb", + "WoodenBin_v2.glb", +] + +TRI_BUDGET = 15000 # PLAN3D §5-E +REF_HEIGHT = 1.70 # the person the whole yard is scaled against +SEED_SALT = "shades-lane-e" + +# ============================================================================ +# PALETTE — low-poly stylized, flat colours, sits beside the 90sDJsim ped fleet +# ============================================================================ +PAL = { + "bark_gum": "#BFB8A8", # eucalypt: pale, chalky, not brown + "bark_shadow": "#8C8577", + "leaf_gum": "#7C8F5E", # sage/olive, not lawn green + "leaf_gum_2": "#6B7E52", + "timber": "#B08A5E", # palings, sleepers + "timber_dark": "#8A6B47", + "steel_gal": "#B6BCC2", # galvanised: posts, hardware + "steel_dark": "#7E858C", + "colorbond": "#9AA5A0", # shed / fence sheet + "concrete": "#B9B6AE", + "brick": "#A8705C", + "render_wall": "#D8D2C4", + "roof_tile": "#6E6A66", + "glass": "#8FB3C4", + "soil": "#5B4436", + "plant_full": "#5F8A3E", + "plant_tatty": "#7A8446", + "plant_dead": "#8A7550", + "mat_black": "#2E2E30", # trampoline mat + "ref_pink": "#E85C8A", # the reference capsule — deliberately loud +} + + +# ============================================================================ +# UTILS — lifted from racks_to_glb.py, kept deliberately close to the original +# ============================================================================ +def hex_to_rgba(hex_str, alpha=1.0): + h = (hex_str or "#888888").lstrip("#") + if len(h) != 6: + h = "888888" + return (int(h[0:2], 16) / 255.0, int(h[2:4], 16) / 255.0, + int(h[4:6], 16) / 255.0, alpha) + + +_MAT_CACHE = {} + + +def get_material(name, color_hex, roughness=0.7, metallic=0.0, opacity=1.0): + if name in _MAT_CACHE and _MAT_CACHE[name].name in bpy.data.materials: + return _MAT_CACHE[name] + mat = bpy.data.materials.new(name=name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf: + bsdf.inputs["Base Color"].default_value = hex_to_rgba(color_hex, opacity) + bsdf.inputs["Roughness"].default_value = max(0.0, min(1.0, roughness)) + bsdf.inputs["Metallic"].default_value = max(0.0, min(1.0, metallic)) + if "Alpha" in bsdf.inputs: + bsdf.inputs["Alpha"].default_value = max(0.0, min(1.0, opacity)) + if opacity < 1.0: + # EEVEE Next renamed this; keep both paths so the script survives both. + if hasattr(mat, "surface_render_method"): + mat.surface_render_method = 'BLENDED' + elif hasattr(mat, "blend_method"): + mat.blend_method = 'BLEND' + _MAT_CACHE[name] = mat + return mat + + +def deselect_all_no_ops(): + """Avoid bpy.ops.object.select_all — works without a proper context.""" + for o in bpy.data.objects: + try: + o.select_set(False) + except Exception: + pass + + +def _active(): + return bpy.context.view_layer.objects.active + + +def _apply_transform(obj, location=False, rotation=False, scale=True): + deselect_all_no_ops() + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.transform_apply(location=location, rotation=rotation, + scale=scale) + + +def add_box(name, dims, location, material, parent=None, rot=None): + sx, sy, sz = dims + bpy.ops.mesh.primitive_cube_add(size=1.0, location=location) + obj = _active() + obj.name = name + obj.scale = (sx, sy, sz) + obj.rotation_mode = 'XYZ' + if rot: + obj.rotation_euler = rot + _apply_transform(obj, scale=True) + obj.data.materials.append(material) + if parent is not None: + obj.parent = parent + return obj + + +def add_cyl(name, radius, depth, location, material, parent=None, verts=10, + rot=None): + bpy.ops.mesh.primitive_cylinder_add(vertices=verts, radius=radius, + depth=depth, location=location) + obj = _active() + obj.name = name + obj.rotation_mode = 'XYZ' + if rot: + obj.rotation_euler = rot + obj.data.materials.append(material) + if parent is not None: + obj.parent = parent + return obj + + +def add_cone(name, r1, r2, depth, location, material, parent=None, verts=10, + rot=None): + bpy.ops.mesh.primitive_cone_add(vertices=verts, radius1=r1, radius2=r2, + depth=depth, location=location) + obj = _active() + obj.name = name + obj.rotation_mode = 'XYZ' + if rot: + obj.rotation_euler = rot + obj.data.materials.append(material) + if parent is not None: + obj.parent = parent + return obj + + +def add_ico(name, radius, location, material, parent=None, subdiv=2, + scale=(1, 1, 1), jitter=0.0, rng=None): + bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=subdiv, radius=radius, + location=location) + obj = _active() + obj.name = name + obj.scale = scale + _apply_transform(obj, scale=True) + if jitter > 0.0 and rng is not None: + # Seeded per-vertex nudge: organic silhouette, still byte-deterministic. + for v in obj.data.vertices: + v.co += Vector((rng.uniform(-jitter, jitter), + rng.uniform(-jitter, jitter), + rng.uniform(-jitter, jitter))) + obj.data.materials.append(material) + if parent is not None: + obj.parent = parent + return obj + + +def add_tube_between(name, p0, p1, radius, material, parent=None, verts=8): + """Cylinder spanning p0->p1. The workhorse for arcs, branches, rungs.""" + a, b = Vector(p0), Vector(p1) + d = b - a + length = d.length + if length < 1e-6: + return None + obj = add_cyl(name, radius, length, tuple((a + b) / 2.0), material, + parent=parent, verts=verts) + obj.rotation_mode = 'XYZ' + obj.rotation_euler = d.to_track_quat('Z', 'Y').to_euler() + return obj + + +def add_empty(name, location=(0, 0, 0), parent=None, size=0.15): + bpy.ops.object.empty_add(type='PLAIN_AXES', location=location) + obj = _active() + obj.name = name + obj.empty_display_size = size + if parent is not None: + obj.parent = parent + return obj + + +def join_group(objs, name, parent=None): + """Join a list of meshes into one named node. Groups are the sway/animation + unit, so this is per-group, NOT per-asset like racks_to_glb.py — Lane A has + to be able to move canopy_01 without moving the trunk.""" + objs = [o for o in objs if o is not None] + if not objs: + return None + deselect_all_no_ops() + for o in objs: + o.select_set(True) + bpy.context.view_layer.objects.active = objs[0] + if len(objs) > 1: + try: + bpy.ops.object.join() + except Exception as e: + print(f" ! join failed for {name}: {e}") + res = _active() + res.name = name + # Bake the rotation objs[0] contributed into the vertices, so the node's + # LOCAL box is world-axis-aligned and therefore tight. + # + # This is not cosmetic. THREE.Box3.setFromObject() (and Blender's + # obj.bound_box, and three's frustum culling) expand the LOCAL bounding box + # by the world matrix. A joined node inherits objs[0]'s rotation — for an + # arc, that's half a segment step off-axis — so every consumer computing + # bounds the normal way would over-report these assets by ~11% and cull + # them late. Verified against three.js r175: without this, Box3 reports + # tramp_01 as 3.29 x 1.27 instead of its true 2.96 x 0.78. + _apply_transform(res, rotation=True, scale=True) + if parent is not None: + res.parent = parent + return res + + +def arc_points(radius, a0, a1, segs, center=(0, 0, 0), plane='XZ', + radius2=None): + """Points along a circular arc, or an elliptical one when radius2 is given + (radius = first axis, radius2 = second). The ellipse is what turns a ring + into a D — a carabiner is ~100 mm long and ~55 mm wide, never round.""" + r2 = radius if radius2 is None else radius2 + pts = [] + for i in range(segs + 1): + t = a0 + (a1 - a0) * i / float(segs) + c, s = math.cos(t) * radius, math.sin(t) * r2 + if plane == 'XZ': + pts.append((center[0] + c, center[1], center[2] + s)) + else: + pts.append((center[0] + c, center[1] + s, center[2])) + return pts + + +def add_arc_tube(name, radius, tube_r, a0, a1, material, parent=None, segs=12, + center=(0, 0, 0), plane='XZ', radius2=None): + pts = arc_points(radius, a0, a1, segs, center, plane, radius2) + parts = [add_tube_between(f"{name}_s{i}", pts[i], pts[i + 1], tube_r, + material, verts=8) + for i in range(segs)] + return join_group(parts, name, parent) + + +def reset_to_empty(): + deselect_all_no_ops() + for obj in list(bpy.data.objects): + bpy.data.objects.remove(obj, do_unlink=True) + for mesh in list(bpy.data.meshes): + bpy.data.meshes.remove(mesh, do_unlink=True) + for mat in list(bpy.data.materials): + bpy.data.materials.remove(mat, do_unlink=True) + for cam in list(bpy.data.cameras): + bpy.data.cameras.remove(cam, do_unlink=True) + for light in list(bpy.data.lights): + bpy.data.lights.remove(light, do_unlink=True) + for txt in list(bpy.data.curves): + bpy.data.curves.remove(txt, do_unlink=True) + scn = bpy.context.scene + for coll in list(bpy.data.collections): + if coll != scn.collection: + bpy.data.collections.remove(coll) + _MAT_CACHE.clear() + + +def rng_for(name): + """Deterministic per-asset RNG: asset N never depends on asset N-1's draws, + so --only produces byte-identical output to a full run.""" + return random.Random(f"{SEED_SALT}:{name}") + + +def stamp(root, asset_name, kind): + root["shades_asset"] = asset_name + root["shades_kind"] = kind + root["shades_source"] = "build_yard_assets.py" + + +def export_asset(root, out_path): + deselect_all_no_ops() + + def sel(o): + try: + o.select_set(True) + except Exception: + pass + for c in o.children: + sel(c) + + sel(root) + bpy.context.view_layer.objects.active = root + bpy.ops.export_scene.gltf( + filepath=out_path, + use_selection=True, + export_format='GLB', + export_yup=True, + export_extras=True, + export_apply=True, + export_materials='EXPORT', + export_image_format='AUTO', + export_lights=False, + export_cameras=False, + ) + + +# ============================================================================ +# BUILDERS — one per asset, each returns the root empty +# ============================================================================ +def build_ref_capsule(name): + """The 1.7 m person every other asset is judged against. Loud pink on + purpose: if you can't see it in a contact sheet, the framing is wrong.""" + root = add_empty(name) + mat = get_material("Mat_Ref", PAL["ref_pink"], 0.5) + r = 0.20 + parts = [ + add_cyl(f"{name}_body", r, REF_HEIGHT - 2 * r, (0, 0, REF_HEIGHT / 2), + mat, verts=16), + add_ico(f"{name}_bot", r, (0, 0, r), mat, subdiv=2), + add_ico(f"{name}_top", r, (0, 0, REF_HEIGHT - r), mat, subdiv=2), + ] + join_group(parts, "ref_capsule_mesh", root) + add_empty("head_height", (0, 0, REF_HEIGHT), root, size=0.1) + stamp(root, name, "reference") + return root + + +def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name): + """Eucalypt: pale chalky trunk, sparse olive canopy, low branches that a + landscaper would actually strap a sail to.""" + rng = rng_for(seed_name) + root = add_empty(name) + bark = get_material("Mat_Bark", PAL["bark_gum"], 0.85) + bark_d = get_material("Mat_BarkShadow", PAL["bark_shadow"], 0.9) + leaf_a = get_material("Mat_Leaf", PAL["leaf_gum"], 0.8) + leaf_b = get_material("Mat_Leaf2", PAL["leaf_gum_2"], 0.8) + + # Trunk: tapered, slight lean — no gum ever grew plumb. + r_base, r_top = height * 0.045, height * 0.022 + trunk_h = height * 0.62 + lean = rng.uniform(-0.04, 0.04) + trunk_parts = [add_cone(f"{name}_trunk_main", r_base, r_top, trunk_h, + (lean * trunk_h * 0.5, 0, trunk_h / 2), bark, + verts=10, rot=(0, lean, 0))] + # Root flare, so it doesn't look like a pipe stuck in the lawn. + trunk_parts.append(add_cone(f"{name}_flare", r_base * 1.55, r_base, + height * 0.05, (0, 0, height * 0.025), bark_d, + verts=10)) + + # Branches: each anchor height gets a real limb to hang off. + anchors = [] + for i, ah in enumerate(anchor_heights): + ang = rng.uniform(0, math.tau) + reach = spread * rng.uniform(0.20, 0.30) + z0 = ah + base = (lean * z0, 0, z0) + tip = (base[0] + math.cos(ang) * reach, + base[1] + math.sin(ang) * reach, + z0 + reach * rng.uniform(0.35, 0.6)) + trunk_parts.append(add_tube_between( + f"{name}_branch_{i:02d}", base, tip, r_top * rng.uniform(0.5, 0.7), + bark, verts=8)) + anchors.append(tip) + + join_group(trunk_parts, "trunk", root) + + # Canopy: separate nodes — Lane A sways these, and only these. + top = (lean * trunk_h, 0, trunk_h) + for i in range(canopy_blobs): + ang = math.tau * i / canopy_blobs + rng.uniform(-0.3, 0.3) + off = spread * rng.uniform(0.10, 0.24) + cx = top[0] + math.cos(ang) * off + cy = top[1] + math.sin(ang) * off + cz = trunk_h + height * rng.uniform(0.08, 0.22) + r = spread * rng.uniform(0.24, 0.32) + blob = add_ico(f"canopy_{i + 1:02d}", r, (cx, cy, cz), + leaf_a if i % 2 == 0 else leaf_b, + parent=root, subdiv=2, + scale=(1.0, 1.0, rng.uniform(0.55, 0.75)), + jitter=r * 0.10, rng=rng) + blob["sway_amp"] = round(0.6 + 0.4 * (cz / height), 3) + + # branch_anchor_* — what Lane B queries. Empties, at the limb tips. + for i, tip in enumerate(anchors): + e = add_empty(f"branch_anchor_{i + 1:02d}", tip, root, size=0.25) + e["anchor_type"] = "tree" + # Thicker limb = more trustworthy. Free intel for the inspection layer. + e["rating_hint"] = round(1.0 - 0.12 * i, 2) + + stamp(root, name, "tree") + root["canopy_count"] = canopy_blobs + return root + + +def build_tree_gum_01(name): + return _gum_tree(name, height=8.4, canopy_blobs=3, spread=6.0, + anchor_heights=[2.6, 3.4, 4.3], seed_name=name) + + +def build_tree_gum_02(name): + return _gum_tree(name, height=5.6, canopy_blobs=2, spread=4.4, + anchor_heights=[2.3, 3.1], seed_name=name) + + +def build_fence_post(name): + root = add_empty(name) + timber = get_material("Mat_Timber", PAL["timber"], 0.85) + cap = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85) + h = 2.0 + parts = [ + add_box(f"{name}_shaft", (0.10, 0.10, h), (0, 0, h / 2), timber), + add_box(f"{name}_cap", (0.13, 0.13, 0.03), (0, 0, h + 0.015), cap), + ] + join_group(parts, "post", root) + stamp(root, name, "fence") + root["tile_step"] = 2.4 # matches fence_panel width + return root + + +def build_fence_panel(name): + """Tileable: exactly 2.4 m in X, centred on the origin, so Lane A can + instance at x = i * 2.4 with no seam arithmetic.""" + rng = rng_for(name) + root = add_empty(name) + timber = get_material("Mat_Timber", PAL["timber"], 0.85) + rail_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85) + width, h = 2.4, 1.8 + pw, gap = 0.09, 0.006 + step = pw + gap + n = int(width / step) + # Distribute the rounding slop into the gaps so the panel is exactly 2.4. + step = width / n + + palings = [] + for i in range(n): + x = -width / 2 + step * (i + 0.5) + # Palings weather unevenly; a few mm of height scatter kills the + # picket-fence-perfect look for free. + ph = h + rng.uniform(-0.02, 0.02) + palings.append(add_box(f"{name}_paling_{i:02d}", (pw, 0.019, ph), + (x, 0, ph / 2), timber)) + join_group(palings, "palings", root) + + rails = [add_box(f"{name}_rail_{j}", (width, 0.035, 0.07), + (0, 0.027, z), rail_m) + for j, z in ((0, 0.35), (1, 1.45))] + join_group(rails, "rails", root) + stamp(root, name, "fence") + root["tile_step"] = width + return root + + +def build_gate(name): + """Origin at the hinge edge, base — so Lane A swings it by rotating the root + about Z. `hinge_axis` marks it explicitly.""" + root = add_empty(name) + timber = get_material("Mat_Timber", PAL["timber"], 0.85) + rail_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85) + steel = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.9) + w, h = 1.0, 1.75 + pw, gap = 0.09, 0.008 + step = pw + gap + n = int(w / step) + step = w / n + + palings = [add_box(f"{name}_paling_{i:02d}", (pw, 0.019, h), + (step * (i + 0.5), 0, h / 2 + 0.08), timber) + for i in range(n)] + join_group(palings, "gate_palings", root) + + frame = [ + add_box(f"{name}_rail_top", (w, 0.032, 0.07), (w / 2, 0.026, 1.70), + rail_m), + add_box(f"{name}_rail_bot", (w, 0.032, 0.07), (w / 2, 0.026, 0.24), + rail_m), + ] + # The diagonal brace runs from the bottom hinge corner UP to the far top — + # that's the direction that carries the leaf in compression. Get it backwards + # and a real gate droops within a season. + frame.append(add_tube_between(f"{name}_brace", (0.06, 0.026, 0.26), + (w - 0.06, 0.026, 1.68), 0.022, rail_m, + verts=6)) + join_group(frame, "gate_frame", root) + + hinges = [add_cyl(f"{name}_hinge_{i}", 0.022, 0.09, (0.0, 0.03, z), steel, + verts=8, rot=(0, math.pi / 2, 0)) + for i, z in ((0, 0.30), (1, 1.62))] + join_group(hinges, "hinges", root) + + add_empty("hinge_axis", (0, 0, 0), root, size=0.3) + stamp(root, name, "fence") + root["swing_deg"] = 100 + return root + + +def build_house_yardside(name): + """Rear façade only — no interior. The fascia is the point: DESIGN.md says + it holds until the first real gust, then leaves with the gutter.""" + root = add_empty(name) + wall_m = get_material("Mat_Render", PAL["render_wall"], 0.9) + brick_m = get_material("Mat_Brick", PAL["brick"], 0.9) + trim = get_material("Mat_Timber", PAL["timber_dark"], 0.8) + roof_m = get_material("Mat_Roof", PAL["roof_tile"], 0.85) + glass_m = get_material("Mat_Glass", PAL["glass"], 0.15, opacity=0.55) + gutter_m = get_material("Mat_Colorbond", PAL["colorbond"], 0.5, + metallic=0.6) + + W, H, D = 9.0, 2.70, 0.30 + # Wall as a ring of boxes around the openings — cheaper than a boolean and + # it never produces the n-gon mess booleans leave behind. + door_w, door_h, door_x = 0.90, 2.05, -2.4 + win_w, win_h, win_z, win_x = 1.80, 1.10, 1.55, 1.9 + + wall = [] + wall.append(add_box(f"{name}_plinth", (W, D + 0.06, 0.35), + (0, 0, 0.175), brick_m)) + seg_l = door_x - door_w / 2 - (-W / 2) + wall.append(add_box(f"{name}_w_left", (seg_l, D, H - 0.35), + (-W / 2 + seg_l / 2, 0, 0.35 + (H - 0.35) / 2), wall_m)) + mid_l = win_x - win_w / 2 - (door_x + door_w / 2) + wall.append(add_box(f"{name}_w_mid", (mid_l, D, H - 0.35), + (door_x + door_w / 2 + mid_l / 2, 0, + 0.35 + (H - 0.35) / 2), wall_m)) + seg_r = W / 2 - (win_x + win_w / 2) + wall.append(add_box(f"{name}_w_right", (seg_r, D, H - 0.35), + (win_x + win_w / 2 + seg_r / 2, 0, + 0.35 + (H - 0.35) / 2), wall_m)) + wall.append(add_box(f"{name}_w_overdoor", (door_w, D, H - door_h), + (door_x, 0, door_h + (H - door_h) / 2), wall_m)) + wall.append(add_box(f"{name}_w_underwin", (win_w, D, win_z - 0.35), + (win_x, 0, 0.35 + (win_z - 0.35) / 2), wall_m)) + wall.append(add_box(f"{name}_w_overwin", (win_w, D, H - win_z - win_h), + (win_x, 0, win_z + win_h + (H - win_z - win_h) / 2), + wall_m)) + join_group(wall, "wall", root) + + join_group([add_box(f"{name}_door_leaf", (door_w - 0.04, 0.05, + door_h - 0.04), + (door_x, -D / 2 + 0.03, (door_h - 0.04) / 2 + 0.02), + trim)], "door", root) + join_group([add_box(f"{name}_win_glass", (win_w - 0.08, 0.02, + win_h - 0.08), + (win_x, -D / 2 + 0.04, win_z + win_h / 2), glass_m), + add_box(f"{name}_win_frame", (win_w, 0.04, win_h), + (win_x, -D / 2 + 0.02, win_z + win_h / 2), trim)], + "window", root) + + # Eave + fascia + gutter. The eave overhangs 0.55 into the yard (-Y). + eave_y = -0.55 + fascia_z = H + 0.10 + join_group([add_box(f"{name}_roof", (W + 0.2, D + 0.75, 0.10), + (0, (eave_y + D / 2) / 2, H + 0.05), roof_m, + rot=(math.radians(-6), 0, 0))], "roof", root) + fascia = add_box("fascia", (W + 0.2, 0.035, 0.20), (0, eave_y, fascia_z), + trim, parent=root) + gutter = join_group([ + add_cyl(f"{name}_gutter_run", 0.055, W + 0.2, + (0, eave_y - 0.05, fascia_z - 0.12), gutter_m, verts=8, + rot=(0, math.pi / 2, 0)), + add_cyl(f"{name}_downpipe", 0.04, H, + (W / 2 - 0.25, eave_y - 0.05, H / 2), gutter_m, verts=8), + ], "gutter", root) + gutter["collateral_of"] = "fascia" # rip the fascia, the gutter goes too + + # fascia_anchor_* — scarce, fixed, and a lie. Three of them, spread wide. + for i, fx in enumerate((-3.0, 0.0, 3.0)): + e = add_empty(f"fascia_anchor_{i + 1:02d}", (fx, eave_y, fascia_z - 0.06), + root, size=0.2) + e["anchor_type"] = "house" + e["rating_hint"] = 0.35 # low: this is the trap anchor + e["collateral"] = "gutter" + stamp(root, name, "structure") + root["facade_width"] = W + return root + + +def build_shed_01(name): + """Colorbond garden shed, skillion roof. Spare hardware lives in here.""" + root = add_empty(name) + sheet = get_material("Mat_Colorbond", PAL["colorbond"], 0.45, metallic=0.5) + dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.5, metallic=0.5) + slab = get_material("Mat_Concrete", PAL["concrete"], 0.95) + W, D, H = 2.40, 1.80, 2.05 + fall = 0.22 # skillion drop front-to-back + + parts = [add_box(f"{name}_slab", (W + 0.16, D + 0.16, 0.08), + (0, 0, 0.04), slab)] + parts.append(add_box(f"{name}_back", (W, 0.04, H), + (0, D / 2, 0.08 + H / 2), sheet)) + parts.append(add_box(f"{name}_left", (0.04, D, H - fall / 2), + (-W / 2, 0, 0.08 + (H - fall / 2) / 2), sheet)) + parts.append(add_box(f"{name}_right", (0.04, D, H - fall / 2), + (W / 2, 0, 0.08 + (H - fall / 2) / 2), sheet)) + parts.append(add_box(f"{name}_front", (W, 0.04, H - fall), + (0, -D / 2, 0.08 + (H - fall) / 2), sheet)) + join_group(parts, "shell", root) + + join_group([add_box(f"{name}_roof", (W + 0.18, D + 0.18, 0.045), + (0, 0, 0.08 + H - fall / 2 + 0.06), sheet, + rot=(math.radians(math.degrees(math.atan2(fall, D))), + 0, 0))], "roof", root) + join_group([ + add_box(f"{name}_door_l", (W / 2 - 0.06, 0.02, H - fall - 0.16), + (-W / 4, -D / 2 - 0.03, 0.08 + (H - fall - 0.16) / 2), dark), + add_box(f"{name}_door_r", (W / 2 - 0.06, 0.02, H - fall - 0.16), + (W / 4, -D / 2 - 0.03, 0.08 + (H - fall - 0.16) / 2), dark), + ], "doors", root) + add_empty("door_anchor", (0, -D / 2 - 0.6, 0.9), root, size=0.2) + stamp(root, name, "structure") + return root + + +def build_shed_table(name): + """The spare-hardware pickup point. `pickup_anchor` is where Lane D should + register the hold-E, so the prompt lands on the bench top, not the floor.""" + root = add_empty(name) + timber = get_material("Mat_Timber", PAL["timber"], 0.8) + steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85) + W, D, H = 1.60, 0.60, 0.90 + top = add_box("table_top", (W, D, 0.045), (0, 0, H - 0.0225), timber, + parent=root) + legs = [] + for sx in (-1, 1): + for sy in (-1, 1): + legs.append(add_box(f"{name}_leg_{sx}_{sy}", (0.05, 0.05, H - 0.045), + (sx * (W / 2 - 0.07), sy * (D / 2 - 0.07), + (H - 0.045) / 2), steel)) + legs.append(add_box(f"{name}_shelf", (W - 0.16, D - 0.12, 0.03), + (0, 0, 0.22), timber)) + join_group(legs, "table_frame", root) + add_empty("pickup_anchor", (0, 0, H + 0.05), root, size=0.2) + stamp(root, name, "prop") + return root + + +def _plant_tuft(prefix, origin, mat, rng, blades, height, lean, parts): + """A tuft of tapered blades fanning from a point. Cheap, and reads as a + plant instead of the green X's you get from crossed quads.""" + for b in range(blades): + ang = math.tau * b / blades + rng.uniform(-0.25, 0.25) + hgt = height * rng.uniform(0.7, 1.15) + tip = (origin[0] + math.cos(ang) * lean * hgt, + origin[1] + math.sin(ang) * lean * hgt, + origin[2] + hgt) + mid = (origin[0] + math.cos(ang) * lean * hgt * 0.35, + origin[1] + math.sin(ang) * lean * hgt * 0.35, + origin[2] + hgt * 0.6) + parts.append(add_tube_between(f"{prefix}_b{b}_lo", origin, mid, + 0.012, mat, verts=4)) + parts.append(add_tube_between(f"{prefix}_b{b}_hi", mid, tip, + 0.005, mat, verts=4)) + + +def build_garden_bed(name): + """Raised sleeper bed + THREE plant states as sibling nodes in one GLB: + plants_full / plants_tattered / plants_dead. Lane A toggles .visible — one + load, instant swap, no pop-in, and no morph-target export risk.""" + rng = rng_for(name) + root = add_empty(name) + sleeper = get_material("Mat_Timber", PAL["timber_dark"], 0.9) + soil = get_material("Mat_Soil", PAL["soil"], 1.0) + W, D, H = 3.0, 1.2, 0.40 + + frame = [] + for sy in (-1, 1): + frame.append(add_box(f"{name}_side_{sy}", (W, 0.05, H), + (0, sy * (D / 2 - 0.025), H / 2), sleeper)) + for sx in (-1, 1): + frame.append(add_box(f"{name}_end_{sx}", (0.05, D - 0.1, H), + (sx * (W / 2 - 0.025), 0, H / 2), sleeper)) + join_group(frame, "bed", root) + join_group([add_box(f"{name}_soil", (W - 0.1, D - 0.1, 0.06), + (0, 0, H - 0.05), soil)], "soil", root) + + # Same tuft positions across all three states — the bed must not appear to + # rearrange itself when it takes damage, only to wilt. + spots = [] + for i in range(7): + spots.append((-W / 2 + 0.35 + i * ((W - 0.7) / 6.0), + rng.uniform(-D / 4, D / 4), H - 0.02)) + + for state, mat_hex, blades, hgt, lean in ( + ("full", PAL["plant_full"], 7, 0.42, 0.30), + ("tattered", PAL["plant_tatty"], 5, 0.26, 0.55), + ("dead", PAL["plant_dead"], 3, 0.15, 0.85)): + mat = get_material(f"Mat_Plant_{state}", mat_hex, 0.9) + srng = rng_for(f"{name}:{state}") + parts = [] + for i, sp in enumerate(spots): + _plant_tuft(f"{name}_{state}_{i}", sp, mat, srng, blades, hgt, + lean, parts) + node = join_group(parts, f"plants_{state}", root) + if node: + node["damage_state"] = state + # Only `full` ships visible; Lane A swaps by toggling these. + node.hide_render = (state != "full") + stamp(root, name, "garden") + root["states"] = "full,tattered,dead" + root["bed_size"] = f"{W}x{D}" + return root + + +def build_sail_post(name): + """Exported VERTICAL. DESIGN.md says correct practice is to rake the post + away from the load — but that's the player's call, so rake is a runtime + rotation about `rake_pivot`, not baked geometry.""" + root = add_empty(name) + steel = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.9) + dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8) + conc = get_material("Mat_Concrete", PAL["concrete"], 0.95) + H, R = 4.0, 0.048 + + 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)], "footing", root) + 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), + add_cyl(f"{name}_cap", R * 1.15, 0.02, (0, 0, H), dark, + verts=12)], "post", root) + # Pad eye at the head — where the corner chain actually clips on. + join_group([add_box(f"{name}_padeye", (0.012, 0.07, 0.09), + (0, 0, H - 0.10), dark), + add_arc_tube(f"{name}_eye", 0.026, 0.008, 0, math.tau, dark, + segs=10, center=(0, 0, H - 0.02), plane='XZ')], + "pad_eye", root) + + e = add_empty("top_anchor", (0, 0, H - 0.02), root, size=0.2) + e["anchor_type"] = "post" + e["rating_hint"] = 0.9 + add_empty("rake_pivot", (0, 0, 0.12), root, size=0.25) + stamp(root, name, "hardware") + root["post_height"] = H + root["rake_note"] = "rotate about rake_pivot; rake away from the load" + return root + + +def build_ladder_01(name): + root = add_empty(name) + alu = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.85) + H, W = 3.0, 0.42 + parts = [] + for sx in (-1, 1): + parts.append(add_box(f"{name}_rail_{sx}", (0.035, 0.075, H), + (sx * W / 2, 0, H / 2), alu)) + n = int(H / 0.28) + for i in range(1, n): + parts.append(add_cyl(f"{name}_rung_{i:02d}", 0.016, W, + (0, 0, i * 0.28), alu, verts=8, + rot=(0, math.pi / 2, 0))) + join_group(parts, "ladder", root) + add_empty("ladder_base", (0, 0, 0.05), root, size=0.2) + add_empty("ladder_top", (0, 0, H - 0.1), root, size=0.2) + stamp(root, name, "prop") + root["climb_height"] = H + return root + + +def build_shackle(name): + """Bow shackle, ~80 mm. The `pin` is its own node because the pin is the + whole story: unmoused, flogging unscrews it, and then it shears.""" + root = add_empty(name) + steel = get_material("Mat_Steel", PAL["steel_gal"], 0.3, metallic=0.95) + pin_m = get_material("Mat_SteelDark", PAL["steel_dark"], 0.35, metallic=0.95) + R, tr = 0.022, 0.005 + body = [add_arc_tube(f"{name}_bow", R, tr, math.radians(-28), + math.radians(208), steel, segs=14, + center=(0, 0, 0.048), plane='XZ')] + # Straight legs down from the bow ends to the pin eyes. + for sx in (-1, 1): + x = sx * R * math.cos(math.radians(28)) + body.append(add_tube_between(f"{name}_leg_{sx}", + (x, 0, 0.048 - R * math.sin(math.radians(28))), + (x, 0, 0.012), tr, steel, verts=8)) + body.append(add_cyl(f"{name}_ear_{sx}", tr * 1.9, 0.006, (x, 0, 0.010), + steel, verts=8, rot=(0, math.pi / 2, 0))) + join_group(body, "bow", root) + + pin = join_group([ + add_cyl(f"{name}_pin_shaft", 0.0042, R * 2.4, (0, 0, 0.010), pin_m, + verts=8, rot=(0, math.pi / 2, 0)), + add_cyl(f"{name}_pin_head", 0.0095, 0.005, (-R * 1.25, 0, 0.010), + pin_m, verts=8, rot=(0, math.pi / 2, 0)), + ], "pin", root) + pin["failure_mode"] = "unscrews_then_shears" + stamp(root, name, "hardware") + root["hw_class"] = "shackle" + return root + + +def build_carabiner(name): + """~100 mm. `gate` is its own node — DESIGN.md: the gate flutters open.""" + root = add_empty(name) + steel = get_material("Mat_Steel", PAL["steel_gal"], 0.3, metallic=0.95) + gate_m = get_material("Mat_SteelDark", PAL["steel_dark"], 0.35, + metallic=0.9) + # Elliptical: 52 mm across, 94 mm long. A circle here reads as a keyring. + # The gap is on a LONG SIDE, not the bottom — the gate is the straight bar + # chording the curved spine, and that silhouette is the whole tell. + RX, RZ, CZ, tr = 0.026, 0.047, 0.052, 0.0045 + a0, a1 = math.radians(55), math.radians(305) + body = [add_arc_tube(f"{name}_spine", RX, tr, a0, a1, steel, segs=16, + center=(0, 0, CZ), plane='XZ', radius2=RZ)] + join_group(body, "body", root) + p0 = (RX * math.cos(a1), 0, CZ + RZ * math.sin(a1)) + p1 = (RX * math.cos(a0), 0, CZ + RZ * math.sin(a0)) + gate = join_group([add_tube_between(f"{name}_gate_bar", p0, p1, tr * 0.8, + gate_m, verts=8)], "gate", root) + gate["failure_mode"] = "gate_flutters_open" + stamp(root, name, "hardware") + root["hw_class"] = "carabiner" + return root + + +def build_turnbuckle(name): + """~160 mm closed. `body` spins to tension — it is both the adjuster and, + when it's cheap, the thing whose thread strips.""" + root = add_empty(name) + steel = get_material("Mat_Steel", PAL["steel_gal"], 0.3, metallic=0.95) + dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.4, metallic=0.9) + body_len, br = 0.075, 0.011 + frame = [ + add_cyl(f"{name}_frame_a", br * 0.55, body_len, (0, br, 0.08), steel, + verts=6), + add_cyl(f"{name}_frame_b", br * 0.55, body_len, (0, -br, 0.08), steel, + verts=6), + ] + for sz in (-1, 1): + frame.append(add_cyl(f"{name}_boss_{sz}", br, 0.012, + (0, 0, 0.08 + sz * body_len / 2), steel, verts=10)) + body = join_group(frame, "body", root) + body["failure_mode"] = "thread_strips_or_bends" + + for i, sz in enumerate((-1, 1)): + z_end = 0.08 + sz * (body_len / 2) + eye_z = z_end + sz * 0.035 + join_group([ + add_cyl(f"{name}_thread_{i}", 0.0045, 0.030, (0, 0, z_end + sz * 0.016), + dark, verts=8), + add_arc_tube(f"{name}_eyering_{i}", 0.011, 0.0038, 0, math.tau, + dark, segs=10, center=(0, 0, eye_z + sz * 0.011), + plane='XZ'), + ], f"eye_{'a' if i == 0 else 'b'}", root) + stamp(root, name, "hardware") + root["hw_class"] = "turnbuckle" + return root + + +def build_tramp_01(name): + """The funniest debris in the game. Every Australian storm produces at least + one airborne trampoline; the physics are Lane C's problem.""" + root = add_empty(name) + steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85) + mat_m = get_material("Mat_TrampMat", PAL["mat_black"], 0.9) + pad = get_material("Mat_Pad", PAL["leaf_gum_2"], 0.9) + R, H = 1.45, 0.75 + join_group([add_cyl(f"{name}_mat", R * 0.80, 0.015, (0, 0, H), mat_m, + verts=24)], "mat", root) + join_group([add_arc_tube(f"{name}_rim", R, 0.028, 0, math.tau, steel, + segs=24, center=(0, 0, H), plane='XY')], "rim", + root) + join_group([add_cyl(f"{name}_pad", R * 0.93, 0.05, (0, 0, H - 0.01), pad, + verts=24)], "pad", root) + legs = [] + for i in range(6): + a = math.tau * i / 6 + x, y = math.cos(a) * R * 0.86, math.sin(a) * R * 0.86 + legs.append(add_tube_between(f"{name}_leg_{i}", (x, y, H), + (x * 1.06, y * 1.06, 0), 0.020, steel, + verts=6)) + join_group(legs, "legs", root) + stamp(root, name, "debris") + root["mass_hint"] = 45.0 + return root + + +# ============================================================================ +# GRASS ATLAS — a texture, not geometry (PLAN3D §5-E item 9) +# ============================================================================ +def build_grass_atlas(): + """4-tuft billboard atlas, 2x2 cells. Drawn with numpy (no PIL in Blender's + python) and saved through bpy's image API. Lane A instances quads with this.""" + import numpy as np + + SIZE, CELLS = 512, 2 + cell = SIZE // CELLS + img = np.zeros((SIZE, SIZE, 4), dtype=np.float32) + + def blade(px, py, cx, base_y, height, lean, w0, rgb): + steps = max(24, int(height)) + for s in range(steps + 1): + t = s / steps + x = cx + lean * (t ** 2) + y = base_y + height * t + hw = max(0.6, w0 * ((1.0 - t) ** 0.7)) + shade = 0.55 + 0.45 * t # darker at the base + x0, x1 = int(x - hw), int(math.ceil(x + hw)) + yi = int(y) + if yi < 0 or yi >= SIZE: + continue + for xi in range(max(px, x0), min(px + cell, x1 + 1)): + if 0 <= xi < SIZE: + img[yi, xi, 0:3] = [c * shade for c in rgb] + img[yi, xi, 3] = 1.0 + + for cy in range(CELLS): + for cx_i in range(CELLS): + idx = cy * CELLS + cx_i + rng = rng_for(f"grass_tuft_{idx}") + px, py = cx_i * cell, cy * cell + n = 5 + idx + for b in range(n): + base_x = px + cell * rng.uniform(0.28, 0.72) + h = cell * rng.uniform(0.55, 0.92) + lean = cell * rng.uniform(-0.30, 0.30) + g = rng.uniform(0.42, 0.62) + rgb = (g * 0.55, g, g * 0.38) + blade(px, py, base_x, py + 2, h, lean, + cell * rng.uniform(0.012, 0.022), rgb) + + os.makedirs(TEXTURES_DIR, exist_ok=True) + out = os.path.join(TEXTURES_DIR, "grass_atlas.png") + bimg = bpy.data.images.new("grass_atlas", SIZE, SIZE, alpha=True) + bimg.pixels.foreach_set(img.reshape(-1)) + bimg.filepath_raw = out + bimg.file_format = 'PNG' + bimg.save() + bpy.data.images.remove(bimg) + kb = os.path.getsize(out) // 1024 + print(f" grass_atlas.png {SIZE}x{SIZE}, {CELLS*CELLS} tufts, {kb} KB") + return out + + +# ============================================================================ +# REGISTRY — expected dims are asserted in the verify pass, so a silent scale +# regression can never reach main. (dx, dy, dz) ranges in metres. +# ============================================================================ +ASSETS = [ + dict(name="ref_capsule", fn=build_ref_capsule, + dims=((0.38, 0.42), (0.38, 0.42), (1.68, 1.72)), + nodes=["ref_capsule_mesh", "head_height"]), + dict(name="tree_gum_01", fn=build_tree_gum_01, + dims=((3.0, 7.5), (3.0, 7.5), (7.5, 9.5)), + nodes=["trunk", "canopy_01", "canopy_02", "canopy_03", + "branch_anchor_01", "branch_anchor_02", "branch_anchor_03"]), + dict(name="tree_gum_02", fn=build_tree_gum_02, + dims=((2.0, 5.5), (2.0, 5.5), (5.0, 6.5)), + nodes=["trunk", "canopy_01", "canopy_02", + "branch_anchor_01", "branch_anchor_02"]), + dict(name="fence_post", fn=build_fence_post, + dims=((0.10, 0.16), (0.10, 0.16), (1.95, 2.10)), + nodes=["post"]), + dict(name="fence_panel", fn=build_fence_panel, + dims=((2.38, 2.42), (0.03, 0.10), (1.75, 1.85)), + nodes=["palings", "rails"]), + dict(name="gate", fn=build_gate, + dims=((0.95, 1.10), (0.03, 0.12), (1.70, 1.85)), + nodes=["gate_palings", "gate_frame", "hinges", "hinge_axis"]), + dict(name="house_yardside", fn=build_house_yardside, + dims=((9.0, 9.5), (0.8, 1.6), (2.8, 3.3)), + nodes=["wall", "door", "window", "roof", "fascia", "gutter", + "fascia_anchor_01", "fascia_anchor_02", "fascia_anchor_03"]), + dict(name="shed_01", fn=build_shed_01, + dims=((2.4, 2.7), (1.8, 2.1), (1.95, 2.25)), + nodes=["shell", "roof", "doors", "door_anchor"]), + dict(name="shed_table", fn=build_shed_table, + dims=((1.55, 1.65), (0.55, 0.65), (0.85, 0.95)), + nodes=["table_top", "table_frame", "pickup_anchor"]), + dict(name="garden_bed", fn=build_garden_bed, + dims=((2.95, 3.15), (1.15, 1.35), (0.55, 1.00)), + nodes=["bed", "soil", "plants_full", "plants_tattered", + "plants_dead"]), + dict(name="sail_post", fn=build_sail_post, + dims=((0.40, 0.60), (0.40, 0.60), (3.95, 4.10)), + nodes=["footing", "post", "pad_eye", "top_anchor", "rake_pivot"]), + dict(name="ladder_01", fn=build_ladder_01, + dims=((0.40, 0.50), (0.05, 0.20), (2.95, 3.05)), + nodes=["ladder", "ladder_base", "ladder_top"]), + dict(name="shackle", fn=build_shackle, + dims=((0.03, 0.07), (0.005, 0.02), (0.05, 0.09)), + nodes=["bow", "pin"]), + dict(name="carabiner", fn=build_carabiner, + dims=((0.045, 0.07), (0.005, 0.02), (0.085, 0.11)), + nodes=["body", "gate"]), + dict(name="turnbuckle", fn=build_turnbuckle, + dims=((0.015, 0.05), (0.015, 0.05), (0.12, 0.20)), + nodes=["body", "eye_a", "eye_b"]), + # Lands in models/debris/ — Lane C spawns debris from that directory. + dict(name="tramp_01", fn=build_tramp_01, dir=DEBRIS_DIR, + dims=((2.8, 3.1), (2.8, 3.1), (0.70, 0.85)), + nodes=["mat", "rim", "pad", "legs"]), +] + + +def asset_path(a): + return os.path.join(a.get("dir", MODELS_DIR), f"{a['name']}_v1.glb") + + +# ============================================================================ +# BUILD +# ============================================================================ +def build_all(only=None): + os.makedirs(MODELS_DIR, exist_ok=True) + os.makedirs(DEBRIS_DIR, exist_ok=True) + todo = [a for a in ASSETS if only is None or a["name"] in only] + print(f"\n--- BUILD {len(todo)} ASSETS -> {MODELS_DIR} ---\n") + built = [] + for a in todo: + name = a["name"] + reset_to_empty() + root = a["fn"](name) + out = asset_path(a) + export_asset(root, out) + tris = count_tris_in_scene() + kb = os.path.getsize(out) // 1024 + flag = "" if tris <= TRI_BUDGET else f" ** OVER {TRI_BUDGET} TRI BUDGET **" + print(f" {name:<18s} -> {os.path.basename(out):<26s} " + f"({tris:>6,d} tris, {kb:>4d} KB){flag}") + built.append(name) + return built + + +def count_tris_in_scene(): + total = 0 + dg = bpy.context.evaluated_depsgraph_get() + for o in bpy.data.objects: + if o.type != 'MESH': + continue + eo = o.evaluated_get(dg) + me = eo.to_mesh() + me.calc_loop_triangles() + total += len(me.loop_triangles) + eo.to_mesh_clear() + return total + + +# ============================================================================ +# DEBRIS — copy verbatim from the library, then measure. House rule: runtime +# GLBs are COPIES; if the scale is wrong we fix it at source, not here. +# ============================================================================ +def import_glb(path): + before = set(bpy.data.objects) + bpy.ops.import_scene.gltf(filepath=path) + new = [o for o in bpy.data.objects if o not in before] + for o in new: + # THE gotcha: the glTF importer leaves rotation_mode='QUATERNION' and + # every later rotation_euler write is silently dropped. Force XYZ now, + # BEFORE anything downstream touches rotation. + o.rotation_mode = 'XYZ' + return new + + +def measure_bounds(objs): + """World-space AABB over real VERTICES. + + Do not be tempted by obj.bound_box here: that is the LOCAL box, and pushing + its 8 corners through matrix_world over-estimates for any rotated object — + you get the AABB of the rotated box, not of the geometry. join_group leaves + each joined node carrying parts[0]'s rotation, so every tube-built asset + (arcs, branches, plant blades) measured ~11% too wide that way, purely as a + reporting artefact. Vertices are exact and cheap at these poly counts. + """ + lo = Vector((1e9, 1e9, 1e9)) + hi = Vector((-1e9, -1e9, -1e9)) + found = False + dg = bpy.context.evaluated_depsgraph_get() + for o in objs: + if o.type != 'MESH': + continue + eo = o.evaluated_get(dg) + me = eo.to_mesh() + mw = o.matrix_world + for v in me.vertices: + w = mw @ v.co + lo = Vector((min(lo[i], w[i]) for i in range(3))) + hi = Vector((max(hi[i], w[i]) for i in range(3))) + found = True + eo.to_mesh_clear() + return (lo, hi) if found else None + + +def measure_objects(objs): + b = measure_bounds(objs) + if b is None: + return (0.0, 0.0, 0.0) + lo, hi = b + return tuple(round(hi[i] - lo[i], 4) for i in range(3)) + + +def copy_debris(): + src = next((d for d in DEBRIS_SOURCES if os.path.isdir(d)), None) + if src is None: + print("\n ! debris library not found; checked:") + for d in DEBRIS_SOURCES: + print(f" {d}") + print(" skipping debris copy (models/debris/ left as-is)\n") + return [] + os.makedirs(DEBRIS_DIR, exist_ok=True) + print(f"\n--- DEBRIS (copies from {src}) ---\n") + out = [] + for fn in DEBRIS_FILES: + s = os.path.join(src, fn) + if not os.path.isfile(s): + print(f" ! missing in library: {fn}") + continue + d = os.path.join(DEBRIS_DIR, fn) + shutil.copy2(s, d) + reset_to_empty() + objs = import_glb(d) + dims = measure_objects(objs) + # A storm projectile has to be a believable real-world object; anything + # outside this is authored in the wrong unit and needs a source fix. + sane = all(0.15 <= v <= 1.5 for v in dims) + print(f" {fn:<20s} {dims[0]:.2f} x {dims[1]:.2f} x {dims[2]:.2f} m " + f"{'ok' if sane else '** SCALE SUSPECT — fix at source **'}") + out.append(dict(file=fn, dims=dims, sane=sane)) + reset_to_empty() + return out + + +# ============================================================================ +# VERIFY — re-import each exported GLB fresh and prove it, then render it +# against the 1.7 m capsule. This checks the FILE, not the in-memory scene. +# ============================================================================ +def setup_render_scene(): + scn = bpy.context.scene + scn.render.engine = 'BLENDER_EEVEE' + scn.render.resolution_x = 420 + scn.render.resolution_y = 420 + scn.render.film_transparent = False + scn.world = bpy.data.worlds.new("W") + scn.world.use_nodes = True + bg = scn.world.node_tree.nodes.get("Background") + if bg: + bg.inputs[0].default_value = (0.16, 0.17, 0.19, 1.0) + bpy.ops.object.light_add(type='SUN', location=(4, -6, 9)) + sun = _active() + sun.data.energy = 4.0 + sun.rotation_mode = 'XYZ' + sun.rotation_euler = (math.radians(52), 0, math.radians(35)) + bpy.ops.object.camera_add(location=(0, -6, 2)) + cam = _active() + cam.rotation_mode = 'XYZ' + scn.camera = cam + return cam + + +def frame_camera(cam, target, radius): + """3/4 view fitted to a bounding sphere, so a 0.06 m shackle and an 8.4 m gum + each fill their own tile. Distance comes from the lens, not a magic number: + to fit radius R at half-FOV a, you need R / sin(a).""" + half_fov = cam.data.angle / 2.0 + d = max(radius / max(math.sin(half_fov), 1e-3) * 1.15, 0.05) + az, el = math.radians(52), math.radians(20) + pos = Vector(target) + Vector((math.sin(az) * d * math.cos(el), + -math.cos(az) * d * math.cos(el), + math.sin(el) * d)) + cam.location = pos + cam.rotation_euler = (Vector(target) - pos).to_track_quat('-Z', 'Y').to_euler() + # Small assets need the near clip pulled in or a shackle vanishes entirely. + cam.data.clip_start = min(0.1, d * 0.05) + + +def add_label(cam, text): + bpy.ops.object.text_add(location=(0, 0, 0)) + t = _active() + t.data.body = text + t.data.align_x = 'CENTER' + t.data.size = 0.030 # camera frame at z=-1 is only ~±0.36 wide + t.parent = cam # parented to the camera = always in frame + t.location = (0.0, -0.29, -1.0) + t.rotation_mode = 'XYZ' + t.rotation_euler = (0, 0, 0) + m = get_material("Mat_Label", "#FFFFFF", 1.0) + t.data.materials.append(m) + return t + + +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") + os.makedirs(os.path.join(SCRIPT_DIR, "thumbs"), exist_ok=True) + report, failures, thumbs = [], [], [] + + for a in todo: + name = a["name"] + path = asset_path(a) + if not os.path.isfile(path): + failures.append(f"{name}: GLB missing at {path}") + continue + reset_to_empty() + cam = setup_render_scene() + objs = import_glb(path) + names = {o.name for o in objs} + dims = measure_objects(objs) + tris = count_tris_in_scene() + + problems = [] + for i, axis in enumerate("xyz"): + lo, hi = a["dims"][i] + if not (lo <= dims[i] <= hi): + problems.append(f"{axis}={dims[i]:.3f} outside [{lo}, {hi}]") + missing = [n for n in a["nodes"] if n not in names] + if missing: + problems.append(f"nodes missing after round-trip: {missing}") + if tris > TRI_BUDGET: + problems.append(f"{tris} tris > {TRI_BUDGET} budget") + + # The capsule beside it — the actual acceptance criterion. Skipped for + # hardware: a 1.7 m human next to a 60 mm shackle tells you nothing and + # zooms the shackle down to one pixel. Below 0.30 m the printed dims are + # the scale check, and the tile's job is proving the thing READS. + show_capsule = name != "ref_capsule" and max(dims) >= 0.30 + if show_capsule: + build_ref_capsule("ref_capsule") + for o in bpy.data.objects: + if o.name.startswith("ref_capsule_mesh"): + o.location.x = dims[0] / 2 + 0.55 + + bounds = measure_bounds([o for o in bpy.data.objects + if o.type == 'MESH']) + lo, hi = bounds + frame_camera(cam, (lo + hi) / 2.0, max((hi - lo).length / 2.0, 0.04)) + add_label(cam, f"{name}\n{dims[0]:.2f} x {dims[1]:.2f} x {dims[2]:.2f} m" + f"\n{tris:,} tris") + thumb = os.path.join(SCRIPT_DIR, "thumbs", f"{name}.png") + bpy.context.scene.render.filepath = thumb + bpy.ops.render.render(write_still=True) + thumbs.append(thumb) + + status = "PASS" if not problems else "FAIL" + if problems: + failures.append(f"{name}: " + "; ".join(problems)) + print(f" [{status}] {name:<18s} {dims[0]:6.2f} x {dims[1]:5.2f} x " + f"{dims[2]:5.2f} m {tris:>6,d} tris") + for p in problems: + print(f" -> {p}") + report.append(dict(name=name, dims=dims, tris=tris, + nodes=sorted(names), status=status, + problems=problems)) + return report, failures, thumbs + + +def make_contact_sheet(thumbs): + """Tile the thumbnails into one sheet. numpy only — Blender ships no PIL.""" + import numpy as np + if not thumbs: + return None + tiles = [] + for t in thumbs: + if not os.path.isfile(t): + continue + im = bpy.data.images.load(t) + w, h = im.size + buf = np.empty(w * h * 4, dtype=np.float32) + im.pixels.foreach_get(buf) + tiles.append(buf.reshape(h, w, 4)[::-1]) # bpy rows are bottom-up + bpy.data.images.remove(im) + if not tiles: + return None + cols = 4 + rows = (len(tiles) + cols - 1) // cols + th, tw = tiles[0].shape[0], tiles[0].shape[1] + sheet = np.zeros((rows * th, cols * tw, 4), dtype=np.float32) + sheet[:, :, 3] = 1.0 + for i, tile in enumerate(tiles): + r, c = i // cols, i % cols + sheet[r * th:(r + 1) * th, c * tw:(c + 1) * tw] = tile + out_img = bpy.data.images.new("contact_sheet", cols * tw, rows * th, + alpha=True) + out_img.pixels.foreach_set(sheet[::-1].reshape(-1)) + out_img.filepath_raw = CONTACT_SHEET + out_img.file_format = 'PNG' + out_img.save() + bpy.data.images.remove(out_img) + print(f"\n contact sheet -> {CONTACT_SHEET} ({cols}x{rows} tiles)") + return CONTACT_SHEET + + +# ============================================================================ +# MAIN +# ============================================================================ +def parse_args(): + argv = sys.argv + argv = argv[argv.index("--") + 1:] if "--" in argv else [] + only, no_verify, no_debris = None, False, False + if "--only" in argv: + only = set(argv[argv.index("--only") + 1].split(",")) + if "--no-verify" in argv: + no_verify = True + if "--no-debris" in argv: + no_debris = True + return only, no_verify, no_debris + + +def main(): + only, no_verify, no_debris = parse_args() + print("\n" + "=" * 72) + print("SHADES yard asset factory — Lane E") + print(f"Blender {bpy.app.version_string} repo: {REPO_ROOT}") + print("=" * 72) + + build_all(only) + reset_to_empty() + build_grass_atlas() + debris = [] if no_debris else copy_debris() + + failures = [] + if not no_verify: + report, failures, thumbs = verify_all(only) + reset_to_empty() + make_contact_sheet(thumbs) + with open(REPORT_JSON, "w") as f: + json.dump(dict(blender=bpy.app.version_string, assets=report, + debris=debris), f, indent=2) + print(f" report -> {REPORT_JSON}") + + print("\n" + "=" * 72) + if failures: + print(f"FAILED ({len(failures)}):") + for f_ in failures: + print(f" - {f_}") + else: + print("ALL ASSETS PASS — dims, tri budget, and node names all survive " + "the GLB round-trip.") + print("=" * 72 + "\n") + return 1 if failures else 0 + + +main() diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png new file mode 100644 index 0000000..b603db2 Binary files /dev/null and b/tools/blender/contact_sheet.png differ diff --git a/web/world/js/tests/e.test.js b/web/world/js/tests/e.test.js index 2a73b3f..2fc8de3 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -1,28 +1,160 @@ /** * Lane E selftests — asset sanity. + * Lane E owns this file. Other lanes: yours is js/tests/.test.js. * - * Lane E owns this file. Lane A pre-created it so adding your suite never means - * editing selftest.html. + * Why this exists when build_yard_assets.py already verifies: the Blender side + * re-imports every GLB and asserts dims, tri budget and node names, but it + * CANNOT catch an axis error. Blender exports Z-up→Y-up and imports Y-up→Z-up, + * so a broken `export_yup` flips back on the way in and round-trips green. Only + * a native glTF reader can prove the file is right, and this is the only one in + * the repo. So this suite targets the failures that silently break other lanes: + * 1. the GLB loads at all through the vendored loader; + * 2. it's in metres with its height on +Y — a model exported in centimetres + * looks fine alone and absurd next to a person; + * 3. the nodes other lanes query by name survived the export. glTF has no + * "empty" type, so anchors arrive as bare Object3D and are exactly what an + * exporter prunes. * - * Most of Lane E's verification is the Blender-side contact sheet against the - * 1.7 m ref capsule (PLAN3D §5-E), which this harness can't see. What IS worth - * asserting here, once the GLBs land, is the stuff that silently breaks the - * other lanes: - * 1. every GLB loads without error through the vendored GLTFLoader. - * 2. scale sanity — a loaded tree's bounding box is 4–9 m tall, the fence - * panel is ~1.6 m, the shackle is ~0.1 m. A model exported in centimetres - * looks fine alone and absurd next to a person. - * 3. the named nodes the other lanes query actually exist: - * tree_gum_01 → `trunk`, `canopy_*`, `branch_anchor_*` - * house_yardside → `fascia_anchor_*` - * Lane A sways the canopies by name; Lane B reads branch anchors. - * - * Loading is async — `export default async function run(t)` is supported. - * Useful import: - * import { GLTFLoader } from '../../vendor/addons/loaders/GLTFLoader.js'; + * Standalone version with a fuller report: tools/assetcheck/. */ -/** @param {import('../testkit.js').Suite} t */ -export default function run(t) { - t.skip('yard asset GLBs not landed yet — Lane E'); +import * as THREE from '../../vendor/three.module.js'; +import { assert } from '../testkit.js'; + +// GLTFLoader is imported DYNAMICALLY, below, and that is deliberate. +// +// Every three.js addon imports the bare specifier `three`, which only resolves +// via an