""" 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, bin wheels "bin_green": "#3F5B44", # kerbside wheelie bin "bin_lid": "#C4A63A", # recycling-yellow lid "line_white": "#DCD9CF", # clothes line, gnome beard "gnome_skin": "#E0A986", "gnome_coat": "#3E6FA8", "gnome_hat": "#B33C36", "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 parent_keep_transform(child, parent): """Blender's Ctrl+P "Keep Transform": reparent without moving the child. Everything else in this script keeps its root empty at the origin so that `obj.parent = root` needs no parent-inverse juggling. The canopy handle is the one exception — its pivot has to sit at the trunk top — so the blobs need the inverse or they leap upward by the trunk height on parenting. """ bpy.context.view_layer.update() child.parent = parent child.matrix_parent_inverse = parent.matrix_world.inverted() return child 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, sway_amp=1.0): """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. `canopy` is the SWAY HANDLE: an empty at the trunk top that world.js # rotates, with the blobs hanging off it as children so they swing about the # trunk the way a real canopy does. Parenting them to the root instead — which # is what shipped in Sprint 1 — leaves each blob's pivot at its own centre, so # a lean just spins a sphere in place and the tree never visibly moves. The # canopy lean IS the gust telegraph the player reads (world.js), so a canopy # that can't sway silently costs the game its tell. Asserted in e.test.js. top = (lean * trunk_h, 0, trunk_h) canopy_grp = add_empty("canopy", top, root, size=0.6) canopy_grp["sway_amp"] = sway_amp # per-tree lean multiplier # Own RNG stream on purpose: drawing sway_phase from `rng` would consume a # value and shift every blob draw after it, silently reshaping a tree the # other lanes have already tuned against. Adding a handle must not move # geometry. canopy_grp["sway_phase"] = round(rng_for(f"{seed_name}:sway").uniform(0, math.tau), 3) canopy_grp["sway_pivot_y"] = round(trunk_h, 3) 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, subdiv=2, scale=(1.0, 1.0, rng.uniform(0.55, 0.75)), jitter=r * 0.10, rng=rng) parent_keep_transform(blob, canopy_grp) # Secondary motion if Lane A wants it: outer/higher blobs travel further. 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): # Big, heavy-limbed: leans less for the same wind. return _gum_tree(name, height=8.4, canopy_blobs=3, spread=6.0, anchor_heights=[2.6, 3.4, 4.3], seed_name=name, sway_amp=0.85) def build_tree_gum_02(name): # Smaller and whippier — it should show a gust front first. return _gum_tree(name, height=5.6, canopy_blobs=2, spread=4.4, anchor_heights=[2.3, 3.1], seed_name=name, sway_amp=1.20) 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 # The footing is cast into the ground and stays put — only the post rakes. 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) # rake_pivot is a GROUP, not a marker. Everything above the footing hangs off # it, so rotating it rakes the post while the concrete stays level in the # ground. Shipping it as a childless empty (as Sprint 1 did) means rotating # it moves nothing, and rotating the whole GLB instead tips the footing out # of the dirt with it. Same trap as the canopy handle. Asserted in e.test.js. rake = add_empty("rake_pivot", (0, 0, 0.12), root, size=0.25) rake["rake_axis"] = "x/z — rake AWAY from the load (DESIGN.md)" rake["rake_default_deg"] = 8 above = [] above.append(join_group([ add_cyl(f"{name}_shaft", R, H, (0, 0, H / 2), steel, verts=12), add_cyl(f"{name}_base_plate", 0.11, 0.02, (0, 0, 0.13), dark, verts=12), add_cyl(f"{name}_cap", R * 1.15, 0.02, (0, 0, H), dark, verts=12), ], "post")) # Pad eye at the head — where the corner chain actually clips on. above.append(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")) e = add_empty("top_anchor", (0, 0, H - 0.02), size=0.2) e["anchor_type"] = "post" e["rating_hint"] = 0.9 above.append(e) for o in above: parent_keep_transform(o, rake) 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 def build_wheelie_bin_01(name): """240 L kerbside bin — 1.10 m, ~12 kg empty. The `lid` is its own node: it flaps before the bin goes over, which is a free tell that the wind is up.""" root = add_empty(name) body_m = get_material("Mat_BinBody", PAL["bin_green"], 0.75) lid_m = get_material("Mat_BinLid", PAL["bin_lid"], 0.7) wheel_m = get_material("Mat_Rubber", PAL["mat_black"], 0.95) W, D, H = 0.58, 0.74, 1.02 body = [add_cone(f"{name}_shell", 0.40, 0.34, H, (0, 0, H / 2 + 0.06), body_m, verts=4, rot=(0, 0, math.radians(45)))] body.append(add_box(f"{name}_spine", (0.10, 0.06, H * 0.8), (0, D / 2 - 0.06, H * 0.5), body_m)) join_group(body, "bin_body", root) lid_pivot = (0, D / 2 - 0.10, H + 0.07) lid_grp = add_empty("lid", lid_pivot, root, size=0.2) lid = join_group([ add_box(f"{name}_lid_plate", (W, D * 0.92, 0.035), (0, 0.02, H + 0.085), lid_m), add_box(f"{name}_lid_lip", (W, 0.04, 0.05), (0, -D / 2 + 0.10, H + 0.07), lid_m), ], "lid_plate") parent_keep_transform(lid, lid_grp) lid_grp["flap_axis"] = "x" lid_grp["flap_max_deg"] = 75 wheels = [add_cyl(f"{name}_wheel_{sx}", 0.075, 0.05, (sx * (W / 2 - 0.06), D / 2 - 0.10, 0.075), wheel_m, verts=10, rot=(0, math.pi / 2, 0)) for sx in (-1, 1)] join_group(wheels, "wheels", root) stamp(root, name, "debris") root["mass_hint"] = 12.0 # empty; a full one does not blow over root["tumble_hint"] = "topples about the wheel axle first" return root def build_washing_line_01(name): """A Hills Hoist. Australian back yards have exactly one, and it is the perfect storm prop: the `head` freewheels, so it spins up in a gust — a second wind tell, at head height, right where the player is working.""" root = add_empty(name) steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85) conc = get_material("Mat_Concrete", PAL["concrete"], 0.95) line_m = get_material("Mat_Line", PAL["line_white"], 0.9) H, ARM = 2.05, 1.42 join_group([ add_cyl(f"{name}_socket", 0.14, 0.10, (0, 0, 0.05), conc, verts=12), add_cyl(f"{name}_mast", 0.038, H, (0, 0, H / 2), steel, verts=10), ], "mast", root) # Everything above the collar spins. head = add_empty("head", (0, 0, H), root, size=0.4) head["spin_axis"] = "y" head["free_spin"] = True head["spin_hint"] = "freewheels; spin rate ~ wind speed" parts = [] for i in range(4): a = math.tau * i / 4 tip = (math.cos(a) * ARM, math.sin(a) * ARM, H - 0.16) parts.append(add_tube_between(f"{name}_arm_{i}", (0, 0, H), tip, 0.018, steel, verts=6)) parts.append(add_tube_between(f"{name}_stay_{i}", (0, 0, H + 0.22), tip, 0.008, steel, verts=4)) # Four courses of line between the arm tips. for ring in range(4): rr = ARM * (0.45 + 0.18 * ring) for i in range(4): a0, a1 = math.tau * i / 4, math.tau * (i + 1) / 4 z = H - 0.16 + 0.02 * ring parts.append(add_tube_between( f"{name}_line_{ring}_{i}", (math.cos(a0) * rr, math.sin(a0) * rr, z), (math.cos(a1) * rr, math.sin(a1) * rr, z), 0.004, line_m, verts=3)) spun = join_group(parts, "arms", None) parent_keep_transform(spun, head) stamp(root, name, "prop") root["height"] = H return root def build_garden_gnome_01(name): """37 cm of painted concrete. He is scoring bait: DESIGN.md's collateral rule wants something the player can fail to protect, and a smashed gnome reads instantly where a damage number does not.""" root = add_empty(name) skin = get_material("Mat_Skin", PAL["gnome_skin"], 0.8) coat = get_material("Mat_Coat", PAL["gnome_coat"], 0.85) hat = get_material("Mat_Hat", PAL["gnome_hat"], 0.85) beard = get_material("Mat_Beard", PAL["line_white"], 0.9) base_m = get_material("Mat_Concrete", PAL["concrete"], 0.95) parts = [ add_cyl(f"{name}_base", 0.075, 0.02, (0, 0, 0.01), base_m, verts=10), add_cone(f"{name}_body", 0.072, 0.045, 0.16, (0, 0, 0.10), coat, verts=10), add_ico(f"{name}_head", 0.042, (0, 0, 0.205), skin, subdiv=2), add_cone(f"{name}_beard", 0.038, 0.004, 0.075, (0, -0.020, 0.176), beard, verts=8, rot=(math.radians(14), 0, 0)), add_cone(f"{name}_hat", 0.050, 0.002, 0.14, (0, 0.004, 0.295), hat, verts=10), add_ico(f"{name}_nose", 0.011, (0, -0.038, 0.208), skin, subdiv=1), ] join_group(parts, "gnome", root) stamp(root, name, "prop") root["mass_hint"] = 4.5 root["collateral_value"] = 25 # $ — Lane A's aftermath screen root["breakable"] = True return root def build_garden_gnome_01_broken(name): """The gnome after the sail found him. Same origin and ground plane as the intact one, so Lane A swaps meshes in place without moving anything: hide `garden_gnome_01`, show this, bill $25 on the aftermath screen. Deliberately NOT a shattered pile — the wreckage has to be *recognisable* as the gnome from across the yard, or the aftermath screen is pointing at gravel. So: he snaps at the ankles, the head rolls, the hat comes off, and the base stays exactly where the player last saw it standing. """ rng = rng_for(name) root = add_empty(name) skin = get_material("Mat_Skin", PAL["gnome_skin"], 0.8) coat = get_material("Mat_Coat", PAL["gnome_coat"], 0.85) hat = get_material("Mat_Hat", PAL["gnome_hat"], 0.85) beard = get_material("Mat_Beard", PAL["line_white"], 0.9) base_m = get_material("Mat_Concrete", PAL["concrete"], 0.95) # The stump: base plus the bottom of the coat, snapped off at a ragged line. join_group([ add_cyl(f"{name}_base", 0.075, 0.02, (0, 0, 0.01), base_m, verts=10), add_cone(f"{name}_stump", 0.072, 0.060, 0.055, (0, 0, 0.048), coat, verts=10), add_cyl(f"{name}_break_face", 0.060, 0.006, (0, 0, 0.078), base_m, verts=10), # raw concrete at the fracture ], "stump", root) # The head, rolled clear and face-down. Beard still on, which is the tell. hx, hy = 0.16, -0.09 join_group([ add_ico(f"{name}_head", 0.042, (hx, hy, 0.040), skin, subdiv=2), add_cone(f"{name}_beard", 0.038, 0.004, 0.075, (hx + 0.02, hy - 0.03, 0.030), beard, verts=8, rot=(math.radians(96), 0, math.radians(20))), add_ico(f"{name}_nose", 0.011, (hx + 0.01, hy - 0.035, 0.046), skin, subdiv=1), ], "head", root) # The hat, off and on its side — the single most legible piece of him. join_group([add_cone(f"{name}_hat", 0.050, 0.002, 0.14, (-0.15, 0.07, 0.026), hat, verts=10, rot=(math.radians(90), 0, math.radians(-35)))], "hat", root) shards = [] for i in range(6): a = math.tau * rng.random() d = rng.uniform(0.10, 0.26) s = rng.uniform(0.010, 0.022) shards.append(add_box(f"{name}_shard_{i}", (s, s * 1.4, s * 0.7), (math.cos(a) * d, math.sin(a) * d, s * 0.35), coat if i % 2 else base_m, rot=(0, 0, rng.uniform(0, math.tau)))) join_group(shards, "shards", root) stamp(root, name, "prop") root["broken_variant_of"] = "garden_gnome_01" root["collateral_value"] = 25 return root def build_fence_panel_broken(name): """A panel the storm went through. Same 2.4 m tile footprint and origin as fence_panel, so Lane A drops it into the run in place of one instance rather than re-tiling the fence. A panel does not disintegrate — it loses a few palings and hangs off one rail. Keeping most of it standing is what makes the gap read as damage instead of as a design choice. """ 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 = 0.09 n = 24 step = width / n standing, ground = [], [] for i in range(n): x = -width / 2 + step * (i + 0.5) roll = rng.random() if 9 <= i <= 13 and roll < 0.75: # The hole: snapped low, or gone entirely onto the grass. if roll < 0.42: continue ph = rng.uniform(0.35, 0.72) # jagged stump standing.append(add_box(f"{name}_snapped_{i:02d}", (pw, 0.019, ph), (x, 0, ph / 2), timber)) elif roll < 0.10: # One paling hanging by a single nail, swung off vertical. standing.append(add_box(f"{name}_hanging_{i:02d}", (pw, 0.019, h * 0.8), (x + 0.06, 0.01, h * 0.42), timber, rot=(0, rng.uniform(0.25, 0.5), 0))) else: ph = h + rng.uniform(-0.02, 0.02) standing.append(add_box(f"{name}_paling_{i:02d}", (pw, 0.019, ph), (x, 0, ph / 2), timber)) join_group(standing, "palings", root) # Top rail snapped through the gap; bottom rail survives. rails = [add_box(f"{name}_rail_bot", (width, 0.035, 0.07), (0, 0.027, 0.35), rail_m), add_box(f"{name}_rail_top_l", (width * 0.42, 0.035, 0.07), (-width * 0.29, 0.027, 1.45), rail_m), add_box(f"{name}_rail_top_r", (width * 0.30, 0.035, 0.07), (width * 0.35, 0.027, 1.45), rail_m, rot=(rng.uniform(0.05, 0.14), 0, 0))] join_group(rails, "rails", root) # The pieces that left, lying on the grass in front of the hole. Kept to # snapped lengths and tucked close: the fence sits on the yard boundary, so # a full-length paling flung a metre out pokes through whatever is on the # other side of it. Wreckage should read as wreckage, not reach. for i in range(3): ground.append(add_box(f"{name}_down_{i}", (pw, 0.019, rng.uniform(0.5, 0.95)), (rng.uniform(-0.2, 0.6), rng.uniform(-0.40, -0.15), 0.012), timber, rot=(math.pi / 2, 0, rng.uniform(-0.5, 0.5)))) join_group(ground, "debris_palings", root) stamp(root, name, "fence") root["broken_variant_of"] = "fence_panel" root["tile_step"] = width return root # ============================================================================ # GRASS ATLAS — a texture, not geometry (PLAN3D §5-E item 9) # ============================================================================ def save_png(arr, name): """arr: (h, w, 4) float32 RGBA in 0..1, row 0 = BOTTOM (bpy's convention). Blender ships no PIL, so every texture here is numpy -> bpy's image API.""" import numpy as np # noqa: F401 h, w = arr.shape[0], arr.shape[1] os.makedirs(TEXTURES_DIR, exist_ok=True) out = os.path.join(TEXTURES_DIR, f"{name}.png") img = bpy.data.images.new(name, w, h, alpha=True) img.pixels.foreach_set(arr.reshape(-1)) img.filepath_raw = out img.file_format = 'PNG' img.save() bpy.data.images.remove(img) return out, os.path.getsize(out) // 1024 def build_sail_textures(): """Shade-cloth weave + tear decals (SPRINT2 §Lane E-2). sail_weave.png is SEAMLESS and meant to tile: every frequency is an integer number of cycles across the image, so the wrap is exact. Lane B sets wrapS/wrapT = RepeatWrapping and repeat ≈ (6,6) on a ~5 m sail. Deliberately subtle — luminance rides in a narrow band so it multiplies the base colour rather than replacing it. A high-contrast weave reads as burlap, and this is knitted HDPE shade cloth. """ import numpy as np SIZE, K = 512, 64 # K threads across; 512/64 = 8 px per thread def weave_lum(X, Y): # Over-under: in one checker cell the weft rides on top, in the next the # warp. Every frequency is an integer number of cycles across SIZE, which # is what makes the wrap exact. warp = 0.5 + 0.5 * np.cos(2 * np.pi * K * X / SIZE) weft = 0.5 + 0.5 * np.cos(2 * np.pi * K * Y / SIZE) over = (((X * K) // SIZE) + ((Y * K) // SIZE)) % 2 == 0 knit = np.where(over, weft, warp) # The knit banding real shade cloth has, every 8th thread — the "UV stripe". stripe = 1.0 - 0.045 * ((((X * K) // SIZE) % 8) == 0) stripe *= 1.0 - 0.030 * ((((Y * K) // SIZE) % 8) == 0) # No per-pixel noise: at ±0.012 it was invisible, but it is incompressible # and took the PNG from 18 KB to 323 KB. The knit carries it alone. return np.clip((0.80 + 0.20 * knit) * stripe, 0.0, 1.0).astype(np.float32) Y, X = np.mgrid[0:SIZE, 0:SIZE] lum = weave_lum(X, Y) # Prove it tiles. Lane B is being told "RepeatWrapping, repeat ~(6,6)" — if # the wrap isn't exact that's a visible seam every tile across the whole sail, # so evaluating one tile to the right must reproduce this one exactly. Y2, X2 = np.mgrid[0:SIZE, SIZE:2 * SIZE] if not np.array_equal(lum, weave_lum(X2, Y2)): raise AssertionError("sail_weave is not seamless — it would seam on repeat") weave = np.zeros((SIZE, SIZE, 4), dtype=np.float32) weave[:, :, 0] = lum weave[:, :, 1] = lum weave[:, :, 2] = lum * 0.985 # a hair warm, so white cloth isn't clinical weave[:, :, 3] = 1.0 p1, kb1 = save_png(weave, "sail_weave") print(f" sail_weave.png {SIZE}x{SIZE}, seamless, {K} threads, {kb1} KB") # --- tear decals ------------------------------------------------------ # A strip of 4, RGBA, alpha 0 everywhere but the rip. Overlay on a damaged # panel for M3. Each tear = a jagged slit with frayed threads pulling out of # both lips, because fabric fails along the weave, not in a clean line. TW, TH = 1024, 256 cell = TH tears = np.zeros((TH, TW, 4), dtype=np.float32) def stamp(px, x, y, rgb, a): xi, yi = int(round(x)), int(round(y)) if px <= xi < px + cell and 0 <= yi < TH: # clip inside this decal's cell tears[yi, xi, 0:3] = rgb tears[yi, xi, 3] = a # Four escalating rips. Each is a LENS, not a slit: fabric under tension # parts widest in the middle and tapers to a point at both ends. A # constant-width gap reads as a drawn line, which is what the first pass did. for c in range(4): r = rng_for(f"sail_tear_{c}") px = c * cell length = cell * (0.48 + 0.09 * c) max_gap = cell * (0.055 + 0.042 * c) # the 4th gapes ~4x the 1st x0 = px + (cell - length) / 2 steps = int(length) yy = cell * 0.5 lips = [] for s in range(steps): t = s / max(1, steps - 1) yy = max(cell * 0.3, min(cell * 0.7, yy + r.uniform(-1.1, 1.1))) half = max_gap * (math.sin(math.pi * t) ** 0.7) jag = r.uniform(-0.08, 0.08) * max_gap # ragged, not spiky top, bot = yy - half + jag, yy + half + jag for y in np.arange(top, bot, 0.5): stamp(px, x0 + s, y, (0.10, 0.09, 0.08), 1.0) # the gap if half > 1.5: lips.append((x0 + s, top, +1, half)) # +1 = toward the gap lips.append((x0 + s, bot, -1, half)) # Threads pulling off both lips and bridging the gap. These are the tell: # without them a lens of dark pixels is a hole, not a tear. Length scales # with the LOCAL gap so some strands span it completely. for _ in range(int(55 + c * 30)): x, y, into, half = lips[r.randrange(len(lips))] span = half * r.uniform(0.5, 1.9) for s in np.arange(0.0, span, 0.5): stamp(px, x + r.uniform(-0.6, 0.6), y + into * (s + 1.0), (0.82, 0.76, 0.62), 1.0) p2, kb2 = save_png(tears, "sail_tears") print(f" sail_tears.png {TW}x{TH}, 4 decals, alpha, {kb2} KB") return [p1, p2] 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) out, kb = save_png(img, "grass_atlas") 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", "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", "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"]), # These land in models/debris/ — Lane C globs that directory to spawn from. 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"]), dict(name="wheelie_bin_01", fn=build_wheelie_bin_01, dir=DEBRIS_DIR, dims=((0.50, 0.70), (0.65, 0.85), (1.00, 1.20)), nodes=["bin_body", "lid", "lid_plate", "wheels"]), dict(name="washing_line_01", fn=build_washing_line_01, dims=((2.7, 3.1), (2.7, 3.1), (2.0, 2.4)), nodes=["mast", "head", "arms"]), dict(name="garden_gnome_01", fn=build_garden_gnome_01, dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)), nodes=["gnome"]), # Aftermath wreckage (SPRINT3 §Lane E-2). Each keeps its intact twin's origin # and footprint so Lane A swaps in place. dict(name="garden_gnome_01_broken", fn=build_garden_gnome_01_broken, dims=((0.30, 0.70), (0.25, 0.65), (0.08, 0.20)), nodes=["stump", "head", "hat", "shards"]), # Deeper than fence_panel on purpose: the snapped palings lie on the grass in # front of it. Bounded so wreckage on a boundary fence can't reach through # whatever is behind it. dict(name="fence_panel_broken", fn=build_fence_panel_broken, dims=((2.38, 2.60), (0.03, 1.05), (1.70, 1.90)), nodes=["palings", "rails", "debris_palings"]), ] 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 # small things: a 1.7 m human next to a 60 mm shackle tells you nothing # and zooms the shackle down to one pixel. Below the cut the printed dims # are the scale check, and the tile's job is proving the thing READS. # # Keyed on HEIGHT, not max(dims): the capsule answers "how big is this # next to a person", which is a question about how tall it stands. Flat # wreckage spread 0.39 m across the grass but standing 0.11 m is small- # object territory — measuring its scatter against a human just buries it. show_capsule = name != "ref_capsule" and dims[2] >= 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] # Prefill with the render background, sampled from a tile's corner rather # than guessed — the PNG is sRGB-encoded and the scene colour is linear, so # reusing the world constant here would not match. Otherwise the unused # slots in a partly-filled last row read as black holes. sheet = np.empty((rows * th, cols * tw, 4), dtype=np.float32) sheet[:, :] = tiles[0][0, 0] 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() build_sail_textures() 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()