HardYards/tools/blender/build_yard_assets.py
m3ultra af2694257a Regenerate the full contact sheet; fill unused tiles
The --only runs during the sprint left a partial sheet committed. Also fills the
empty slots in a partly-filled last row with the background sampled from a
tile's corner, so 19 assets in a 4x5 grid no longer leaves a black hole. The
world colour can't be reused for this — the render is sRGB-encoded, the scene
value is linear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:57:38 +10:00

1702 lines
71 KiB
Python

"""
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 <asset> 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
# ============================================================================
# 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"]),
]
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]
# 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()