HardYards/tools/blender/build_yard_assets.py
m3ultra f2527055a5 Add hail juice, plant shred, and a way to screenshot the game
Hail (SPRINT5 §E-1) ships two ways because Lane C's rain is a BoxGeometry with a
flat material and skyfx loads no external texture at all: hail_stone_01 as
geometry that drops into their existing InstancedMesh pattern, and hail_pips.png
as a 4-cell atlas for impacts. A flat quad can't be round and an impact is round
— that's the only reason the atlas is a texture. Both come with a copy-paste
recipe, because a grep showed exactly one of my five shipped textures is
consumed: sail_weave, the one I wrote a recipe for. grass_atlas has sat
unreferenced for four sprints.

plant_shred (§E-2) is elongated after the first pass came out radial and read as
green potatoes — it's the long axis plus the midrib that says "leaf".

tools/yardshot/ finally lands the DESIGN.md pictures, carried since Sprint 2. It
does NOT touch server.py or main.js: it's a Lane E stdlib server that serves the
repo and takes POST /shot, so the browser posts a Blob and bytes go straight to
disk instead of base64 through a console. do_POST is ~25 lines and Lane A is
welcome to lift it and delete this.

Three traps found on the way, all documented: toBlob is async so the WebGL buffer
is already cleared (toDataURL is sync); a backgrounded tab lays the canvas out at
0x0 and toDataURL then returns "data:,"; rAF is paused there so step() must be
driven by hand.

Selftest 209/0/0, 36 output files byte-identical across two runs.

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

2109 lines
90 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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",
"bristle": "#C9A659", # broom straw
"hail_ice": "#DCEAF2", # hailstone
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
}
# Rainwater caught in a sail is not swimming-pool blue. It's shallow, murky, it
# picks up dust off the cloth, and mostly it mirrors an overcast sky — so it
# reads grey-green, and it goes darker where it's deeper.
WATER_SHALLOW = (0.46, 0.51, 0.46)
WATER_DEEP = (0.22, 0.28, 0.26)
# ============================================================================
# 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
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_hail_stone_01(name):
"""One hailstone, ~22 mm (SPRINT5 §Lane E-1, "stone mesh if C wants geometry
over sprites").
Lane C — this is offered, not imposed: your rain is a BoxGeometry with a flat
material and no texture, and this drops into that exact pattern
(`new THREE.InstancedMesh(stoneGeo, stoneMat, n)`). If you'd rather stones be
a box like the streaks, ignore this and the pip atlas still stands on its own.
Lumpy on purpose — a sphere at this size reads as a bubble, and real stones
are accreted knobbles. 80 tris.
"""
rng = rng_for(name)
root = add_empty(name)
ice = get_material("Mat_Ice", PAL["hail_ice"], 0.25)
stone = add_ico(f"{name}_stone", 0.011, (0, 0, 0.011), ice, subdiv=1,
scale=(1.0, rng.uniform(0.82, 0.95), rng.uniform(0.78, 0.92)),
jitter=0.0018, rng=rng)
join_group([stone], "stone", root)
stamp(root, name, "weather")
root["diameter_m"] = 0.022
root["mass_hint"] = 0.006
return root
def build_broom_01(name):
"""The poke-the-pond tool (SPRINT4 §Lane E-2).
Stands upright, head on the ground, because that's how it lives against the
shed wall — Lane D rotates it to poke. `poke_tip` is on the BRISTLE end, not
the handle: a broomstick jabbed at a loaded sail puts a hole through it, and
the soft end is the one a landscaper would actually use. `grip_anchor` is
where the hand goes, two thirds up.
"""
rng = rng_for(name)
root = add_empty(name)
dowel = get_material("Mat_Timber", PAL["timber"], 0.7)
head_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85)
bristle = get_material("Mat_Bristle", PAL["bristle"], 0.95)
H, HEAD_W = 1.42, 0.30
join_group([add_cyl(f"{name}_handle", 0.014, H - 0.10, (0, 0, 0.10 + (H - 0.10) / 2),
dowel, verts=8)], "handle", root)
join_group([add_box(f"{name}_head", (HEAD_W, 0.055, 0.05), (0, 0, 0.125), head_m),
add_cone(f"{name}_ferrule", 0.020, 0.014, 0.05, (0, 0, 0.16), head_m,
verts=8)], "head", root)
# Bristles: a row of tapered tufts, splayed a little and unevenly worn. A
# solid block reads as a paint roller.
tufts = []
n = 11
for i in range(n):
x = -HEAD_W / 2 + 0.02 + i * ((HEAD_W - 0.04) / (n - 1))
ln = rng.uniform(0.085, 0.105)
lean = (x / (HEAD_W / 2)) * rng.uniform(0.04, 0.09)
tufts.append(add_tube_between(f"{name}_tuft_{i:02d}", (x, 0, 0.10),
(x + lean, rng.uniform(-0.01, 0.01), 0.10 - ln),
0.010, bristle, verts=4))
join_group(tufts, "bristles", root)
g = add_empty("grip_anchor", (0, 0, 0.95), root, size=0.12)
g["carry_type"] = "broom"
p = add_empty("poke_tip", (0, 0, 0.02), root, size=0.12)
p["use"] = "push the pond up from under the sail; soft end, won't hole the cloth"
stamp(root, name, "tool")
root["mass_hint"] = 1.2
root["anim_hint"] = "reuse Crank/Dig for the poke — no new Mixamo needed"
return root
def build_fence_panel_snapped(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_pond_textures():
"""The pond in a flat sail's belly (SPRINT4 §Lane E-1, decision 10).
Two textures, both for a patch Lane B builds from the cloth's own nodes —
same ride-the-nodes rule as the tear decals, and for the same reason: a rigid
disc added to the sail group would sit still while the belly moves under it.
pond_water.png — RGBA decal. Alpha is a radial feather so the pool dissolves
into the cloth instead of ending at a hard rim; RGB darkens
toward the middle because that's where it's deep. Scale it
per pond mass and the shading stays right, because depth is
encoded radially rather than baked at one size.
pond_normal.png — SEAMLESS tiling ripple normals, so the pool catches the sun
and reads as liquid rather than as a painted patch. Tiles
because B will repeat it across whatever area the pond has.
"""
import numpy as np
# 256², not 512²: both of these are smooth, low-frequency content (a radial
# gradient and some sine ripples), so the extra resolution buys nothing you
# can see and costs 4x the bytes. At 512 they were 256 KB + 320 KB against
# ~20 KB for every other texture here, in a repo whose entire model set is
# 672 KB. The fastest pond is the one that isn't most of the download.
SIZE = 256
Y, X = np.mgrid[0:SIZE, 0:SIZE]
c = (SIZE - 1) / 2.0
nx, ny = (X - c) / (SIZE / 2.0), (Y - c) / (SIZE / 2.0)
r = np.clip(np.sqrt(nx * nx + ny * ny), 0.0, 1.0)
# Wind chop. Not concentric rings — a puddle ringed like a dartboard reads as
# a target. But three crossed sines don't work either: at similar frequencies
# they interfere into a regular lattice and the pond reads as basketweave.
# Seven waves, directions spaced by the golden angle and frequencies in a
# non-harmonic ratio, so nothing lines up and the surface stays irregular the
# way real chop is. Free choice here — this decal is radial, never tiled, so
# unlike the normal map it owes nothing to seamlessness.
chop = np.zeros((SIZE, SIZE), dtype=np.float32)
rw = rng_for("pond_chop")
total = 0.0
for i in range(7):
ang = i * 2.39996 # golden angle: maximally non-repeating
freq = 5.0 * (1.37 ** i) # non-harmonic progression
amp = 1.0 / (1.0 + i * 0.8)
chop += amp * np.sin((nx * math.cos(ang) + ny * math.sin(ang)) * freq * math.pi
+ rw.uniform(0, math.tau))
total += amp
chop = np.clip(0.5 + 0.5 * chop / total, 0.0, 1.0)
depth = np.clip(1.0 - r, 0.0, 1.0) ** 0.7
water = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
for i in range(3):
base = WATER_SHALLOW[i] + (WATER_DEEP[i] - WATER_SHALLOW[i]) * depth
water[:, :, i] = np.clip(base * (0.86 + 0.28 * chop), 0.0, 1.0)
# Feather the last quarter of the radius: a hard edge would read as a decal.
a = np.clip((1.0 - r) / 0.25, 0.0, 1.0)
water[:, :, 3] = (a * a * (3.0 - 2.0 * a)).astype(np.float32) # smoothstep
p1, kb1 = save_png(water, "pond_water")
print(f" pond_water.png {SIZE}x{SIZE}, radial feather, {kb1} KB")
# --- ripple normals, seamless -----------------------------------------
def height(px, py):
h = np.zeros_like(px, dtype=np.float32)
# Integer cycles across the tile = exact wrap, same trick as the weave.
# Six of them rather than three, on deliberately unrelated (kx, ky) pairs:
# too few waves and they beat into a visible lattice, same failure the
# albedo chop had. Integer pairs are the only constraint seamlessness puts
# on this — which ones is free.
for kx, ky, amp in ((3, 5, 1.0), (7, 2, 0.62), (11, 9, 0.36),
(2, 13, 0.28), (13, 4, 0.20), (5, 11, 0.16)):
h += amp * np.sin(2 * np.pi * (kx * px + ky * py) / SIZE)
return h
Yn, Xn = np.mgrid[0:SIZE, 0:SIZE]
e = 1.0
dhdx = (height(Xn + e, Yn) - height(Xn - e, Yn)) / (2 * e)
dhdy = (height(Xn, Yn + e) - height(Xn, Yn - e)) / (2 * e)
strength = 6.0
nxv, nyv, nzv = -dhdx * strength, -dhdy * strength, np.ones_like(dhdx)
ln = np.sqrt(nxv * nxv + nyv * nyv + nzv * nzv)
normal = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
normal[:, :, 0] = (nxv / ln) * 0.5 + 0.5
normal[:, :, 1] = (nyv / ln) * 0.5 + 0.5
normal[:, :, 2] = (nzv / ln) * 0.5 + 0.5
normal[:, :, 3] = 1.0
# Same guard as the weave: B is told to RepeatWrapping this, and a bad wrap
# is a visible seam gridded across the pond.
if not np.allclose(height(Xn, Yn), height(Xn + SIZE, Yn), atol=1e-4):
raise AssertionError("pond_normal does not tile on X")
if not np.allclose(height(Xn, Yn), height(Xn, Yn + SIZE), atol=1e-4):
raise AssertionError("pond_normal does not tile on Y")
p2, kb2 = save_png(normal, "pond_normal")
print(f" pond_normal.png {SIZE}x{SIZE}, seamless ripples, {kb2} KB")
return [p1, p2]
def build_hail_and_shred_atlases():
"""Hail impact pips + plant shred fragments (SPRINT5 §Lane E-1/2).
Both are 2x2 atlases of alpha sprites for InstancedMesh billboards, which is
the shape Lane C's rain already has — instanced quads, DynamicDrawUsage,
depthWrite off. The one thing a flat-coloured quad cannot do is be round, and
an impact is round, which is the whole reason these are textures at all.
Cells (hail_pips): 0 sharp pip, 1 spiked burst, 2 splash ring (the ground
decal), 3 soft fading pip. Pick per age so one impact can play 0 -> 1 -> 3
and a ground hit can just use 2.
"""
import numpy as np
SIZE, CELL = 256, 128
pips = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
for idx in range(4):
cy, cx = (idx // 2) * CELL, (idx % 2) * CELL
Y, X = np.mgrid[0:CELL, 0:CELL]
c = (CELL - 1) / 2.0
nx, ny = (X - c) / c, (Y - c) / c
r = np.sqrt(nx * nx + ny * ny)
th = np.arctan2(ny, nx)
if idx == 0: # sharp pip: hot core, fast falloff
a = np.clip(1.0 - r, 0, 1) ** 3.2
elif idx == 1: # burst: core plus radiating spikes
spikes = 0.5 + 0.5 * np.cos(th * 8.0)
a = np.clip(1.0 - r, 0, 1) ** 2.6 + 0.5 * spikes * np.clip(1.0 - r, 0, 1) ** 5.0
elif idx == 2: # splash ring — the ground decal
a = np.exp(-((r - 0.62) ** 2) / 0.012) * np.clip(1.0 - r, 0, 1) ** 0.4
else: # soft, dying
a = np.exp(-(r ** 2) / 0.20) * 0.75
a = np.clip(a, 0, 1)
# Hail is ice: near-white with a cold rim, so it reads against both the
# sand-coloured cloth and dark wet grass.
pips[cy:cy + CELL, cx:cx + CELL, 0] = 0.88 + 0.12 * a
pips[cy:cy + CELL, cx:cx + CELL, 1] = 0.94 + 0.06 * a
pips[cy:cy + CELL, cx:cx + CELL, 2] = 1.0
pips[cy:cy + CELL, cx:cx + CELL, 3] = a
p1, kb1 = save_png(pips, "hail_pips")
print(f" hail_pips.png {SIZE}x{SIZE}, 4 cells (pip/burst/ring/soft), {kb1} KB")
# --- plant shred ------------------------------------------------------
# Torn leaf fragments, not dots: the bed is being shredded, and a green dot
# reads as a bug. Each cell is one ragged blade-scrap with a darker midrib.
shred = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
for idx in range(4):
r_ = rng_for(f"plant_shred_{idx}")
cy, cx = (idx // 2) * CELL, (idx % 2) * CELL
Y, X = np.mgrid[0:CELL, 0:CELL]
c = (CELL - 1) / 2.0
nx, ny = (X - c) / c, (Y - c) / c
th = np.arctan2(ny, nx)
r = np.sqrt(nx * nx + ny * ny)
# A torn blade-scrap: genuinely elongated, then ripped along the edge.
# A radial lobe alone gives a teardrop, which at particle size reads as a
# green potato — it's the long axis plus the midrib that says "leaf".
# Aspect varies per cell so one burst isn't four copies of a shape.
aspect = r_.uniform(0.38, 0.62)
ex, ey = nx * aspect, ny / aspect
er = np.sqrt(ex * ex + ey * ey)
tear = np.zeros_like(th)
for k in range(1, 5):
tear += (0.06 / k) * np.sin(th * (2 * k + 1) + r_.uniform(0, math.tau))
inside = er < (0.42 + tear)
g = r_.uniform(0.42, 0.62)
rib = np.abs(ny) < 0.035 # the midrib, darker
col = np.where(rib, 0.65, 1.0)
shred[cy:cy + CELL, cx:cx + CELL, 0] = np.where(inside, g * 0.55 * col, 0)
shred[cy:cy + CELL, cx:cx + CELL, 1] = np.where(inside, g * col, 0)
shred[cy:cy + CELL, cx:cx + CELL, 2] = np.where(inside, g * 0.34 * col, 0)
shred[cy:cy + CELL, cx:cx + CELL, 3] = np.where(inside, 1.0, 0.0)
p2, kb2 = save_png(shred, "plant_shred")
print(f" plant_shred.png {SIZE}x{SIZE}, 4 leaf scraps, {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.
# Wider than the 0.30 head: the bristles splay past it, which is what a worn
# broom does. A real yard broom is 0.300.45 m across.
dict(name="hail_stone_01", fn=build_hail_stone_01,
dims=((0.015, 0.030), (0.012, 0.028), (0.012, 0.028)),
nodes=["stone"]),
dict(name="broom_01", fn=build_broom_01,
dims=((0.28, 0.45), (0.04, 0.12), (1.35, 1.50)),
nodes=["handle", "head", "bristles", "grip_anchor", "poke_tip"]),
dict(name="fence_panel_snapped", fn=build_fence_panel_snapped,
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()
build_pond_textures()
build_hail_and_shred_atlases()
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()