From d8a017ad7db1132fbb2d43d8d0298606ed0c30b7 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 21:32:42 +1000 Subject: [PATCH] Add deterministic Blender yard-asset factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One script regenerates every nature/hardware asset in PLAN3D §5-E, following the house idiom from 3D-STORE/racks_to_glb.py: reset per asset, build under a root empty at the origin, join by group, stamp props, export Y-up GLB. Groups are joined per sway-unit rather than per-asset, so trees keep trunk and canopy_* as separate nodes for Lane A to animate. Paths resolve from __file__ instead of a hardcoded home dir, since the library lives elsewhere on this box. Verification re-imports each exported GLB from disk and asserts dims, tri budget, and node-name survival, then renders it against the 1.7 m ref capsule. Checking the file rather than the in-memory scene is what makes it a real test. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 5 + tools/blender/build_yard_assets.py | 1398 ++++++++++++++++++++++++++++ 2 files changed, 1403 insertions(+) create mode 100644 tools/blender/build_yard_assets.py 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/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py new file mode 100644 index 0000000..93f36e9 --- /dev/null +++ b/tools/blender/build_yard_assets.py @@ -0,0 +1,1398 @@ +""" +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 + 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()