HardYards/tools/blender/build_yard_assets.py
type-two 6e0608a6fb Lane E S17 gate 3.1: THE CORRODED TIER — mutations restored, tell verified at 20 m, droop pinned to geometry
Recovers an interrupted session and finishes its verification honestly.

RESTORED (the predecessor was killed mid-mutation-check):
- build_sail_post_corroded still had negative control M1 APPLIED — clean
  galvanised steel, DROOP=0 — so both shipped GLBs were the untelled post.
  Restored: steel_weathered/rust/rust_deep, DROOP_DEG = 38.
- asset_report.json held 1 of 37 assets and contact_sheet.png was a 196 KB
  truncated write. Both were interrupted artifacts, not decisions; a clean
  rebuild restores 37 assets and a 5.07 MB 1680x4200 sheet.

DETERMINISM: 37/37 GLBs byte-identical across two consecutive full runs;
report and contact sheet byte-identical too. Zero churn on the 35 pre-existing
GLBs.

ASSERT THAT COULD NOT FAIL, KILLED: the sag pin (honestY - corrodedY > 0.05)
reads 0.060 at ZERO droop, because the eye hangs off a weld line 160 mm down
the post — measured on the M-B build, anchor at (0, 3.920, 0). It measured the
weld offset and called it a sag. Replaced with the droop ANGLE derived from
the exported anchor and pinned to baked extras (padeye_droop_deg / arm_m /
weld_y_threejs / dir_threejs), all stated in three.js coords — the axis trap
closed the swing_set way, so note and mesh can only lie together.

ASSERT WHOSE REASON WAS BACKWARDS, FIXED: the 20 m pin claimed the corroded
shaft read darker. Measured off look.html's canvas at 20 m: honest lum 50,
corroded lum 94 — it renders twice as BRIGHT. Base colour is darker but
metalness drives the pixels (gal 0.90/0.35 goes near-black with no env map;
weathered 0.55/0.60 is diffuse). Test now pins both halves and names the
mechanism the player actually sees.

Three tells split into three tests so one failure cannot mask the others —
pre-split, the M-A mutation went red on rust area and never reached the
lightness or droop asserts at all.

Mutation-checked in the browser, all three witnessed red: M-A clean steel
(1/3 + 2/3 red, 3/3 green, factory GREEN throughout — the trap it cannot see,
confirmed); M-B flat eye (3/3 red, others green); M-C baked note lies while
mesh is honest (3/3 red, proving the pin reads the extra, not a literal).

Selftest 483/0/0 (474 baseline + 9 exactly). Tell verified by looking at
20 m at the game's own camera height, screenshots taken.

editor.js untouched — palette hunk filed verbatim in THREADS for A.
contracts.js ANCHOR_TYPE widening flagged for A.
Lane D flagged: the tier is placeable, gate 3.2 unblocked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 01:51:57 +10:00

4311 lines
204 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
blender -b -P tools/blender/build_yard_assets.py -- --cards # re-render the end cards
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")
# SPRINT15 gate 1.2 — the ratings manifest the RUNTIME reads. A baked
# rating_hint used to reach the sim only through adoptAnchor, which only runs
# for anchors that name a GLB `node` — every plain-JSON post defaulted to
# `?? 1` no matter what this factory baked, and factory and sim silently
# disagreed for fourteen sprints. This module is the missing wire: per
# anchor_type, the factory's baked extras, generated HERE so it cannot be
# hand-edited into a second source of truth. world.js resolves a node-less
# JSON anchor's type through it; e.test.js pins runtime === manifest === GLB.
RATINGS_GEN_JS = os.path.join(REPO_ROOT, "web", "world", "js",
"anchor_ratings.gen.js")
# 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
"window_warm": "#FFC98A", # someone is home
"bark_jac": "#A79C90", # jacaranda: thin grey bark, browner than a gum
"leaf_jac": "#8C7FC0", # in flower — lilac, and unmistakable at 30 m
"leaf_jac_2": "#6E5FA8",
"bike_kid": "#D8483C", # the Henderson kid's bike — bought bright on purpose
"bike_grip": "#3B4048", # grips and saddle
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
"steel_weathered": "#8D8A7E", # gal gone matte: the corroded post's shaft.
# Next to steel_gal (#B6BCC2) it reads DIRTY at
# 20 m before you can see a single bloom — the
# colour is the long-range half of the tell.
"rust_bloom": "#9C5B33", # active surface rust — orange enough to read
# against every steel/timber in the palette
"rust_deep": "#67351F", # the worst of it: fold lines, weld collars,
# streak stains — near-brown, high saturation
"alu_frame": "#C9CED2", # powder-coated aluminium — glasshouse + pool fence
"pool_water": "#4FA8BE", # chlorinated blue, NOT the pond's grey-green —
# a pool is the one water in the game that is
# supposed to look inviting
"deck_board": "#A8845C", # decking oil, a shade lighter than fence timber
}
# 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 hide_by_default(obj):
"""Mark a node as "present but off until the game says otherwise".
glTF has NO standard node-visibility flag, and Blender's `hide_render` does
not survive the export — verified in three r175: every node arrives
`visible: true`. This shipped as a real bug from Sprint 1 to Sprint 6, with
garden_bed rendering plants_full, plants_tattered AND plants_dead
superimposed, and my own THREADS note claiming the opposite.
userData DOES survive, so the flag rides in extras and the consumer applies
it in one line (see THREADS [E]):
gltf.scene.traverse(o => { if (o.userData?.hidden_by_default) o.visible = false; });
hide_render is still set so Blender's own contact-sheet renders match the game.
"""
obj["hidden_by_default"] = True
obj.hide_render = True
return obj
def _emissive(mat, color_hex, strength=1.0):
"""Emissive on the Principled BSDF. glTF carries it as emissiveFactor (plus
KHR_materials_emissive_strength above 1.0), so the pane reads with no light
in the scene at all — which is the point on a black storm night."""
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if not bsdf:
return mat
if "Emission Color" in bsdf.inputs:
bsdf.inputs["Emission Color"].default_value = hex_to_rgba(color_hex)
if "Emission Strength" in bsdf.inputs:
bsdf.inputs["Emission Strength"].default_value = strength
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 not_a_tie_off(empty, role, why):
"""Mark an `*_anchor` empty that is NOT something you can strap a sail to.
SPRINT14 palette audit. `world.js:adoptAnchor` reads `rating_hint` off the
node and falls back to **1** when it is absent — so any empty whose name
ends in `_anchor` and carries no hint is, the moment a site names it, the
BEST tie-off in the game: better than a gum fork (1.0 is the ceiling), a
perfect anchor conjured out of a missing field. Four of those were sitting
in the palette (`door_anchor`, `pickup_anchor`, `grip_anchor`,
`window_light_anchor`), every one of them a carry point or a light hint.
Silence read as "flawless steel"; that is the free-failure bug inverted,
and the editor is about to offer these nodes to an author by name.
So say it in the data. `tie_off: False` is the explicit denial the missing
field only pretended to be; e.test.js pins that every `*_anchor` node in
every GLB carries either a rating_hint or this flag, so a new anchor cannot
arrive silent again.
"""
empty["tie_off"] = False
empty["anchor_role"] = role
empty["why"] = why
return empty
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 _limb(name, base, tip, r_base, r_tip, mat, segs=3, sweep=0.30):
"""A tapered, up-swept limb from base to tip.
A single straight uniform tube is what made these read as coat hooks in
docs/yard_day.jpg — a peg poking out of a pole. Real gum limbs leave the
trunk thick, sweep upward, and thin to nothing.
The tip is a quadratic-bezier ENDPOINT, so it lands on `tip` exactly. That
matters more than the look: branch_anchor_* sit on these tips, Lane A's
winning line rigs off t2, and every balance number in the repo assumes those
points don't move. Consumes NO rng — the caller's draw order is untouched.
"""
b, t = Vector(base), Vector(tip)
ctrl = (b + t) / 2 + Vector((0, 0, (t.z - b.z) * sweep + 0.12))
parts, prev = [], b
for i in range(1, segs + 1):
u = i / segs
pt = (1 - u) ** 2 * b + 2 * (1 - u) * u * ctrl + u ** 2 * t # u=1 -> exactly t
r = r_base + (r_tip - r_base) * ((i - 0.5) / segs)
parts.append(add_tube_between(f"{name}_s{i}", prev, pt, r, mat, verts=6))
prev = pt
return parts
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))
# Same four rng draws, same order, so every tip is bit-for-bit where it
# was — only the geometry hanging off them changed.
r_limb = r_top * rng.uniform(0.5, 0.7)
trunk_parts.extend(_limb(f"{name}_branch_{i:02d}", base, tip,
r_limb * 1.9, r_limb * 0.42, bark))
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_tree_jacaranda_01(name):
"""The second species — and the point of it is the LADDER, not the leaves.
SPRINT14 gate 3.1. Both existing trees are gums, and both carry the same
branch ladder: 1.0 / 0.88 / 0.76, twelve points a rung. That ladder is
forgiving by design (Sprint 7) — on a gum you can climb for height and pay
almost nothing for it, so "which tree, and how high" was never really a
question, only "is there a tree".
A jacaranda answers it differently, and honestly. It forks low and heavy:
the union at 2.4 m is a genuine two-hands-around-it fork, as good as
anything in the yard (0.95). Above that it is a different tree. Jacaranda
wood is famously brittle — fast-grown, light, and the long straight leaders
above the fork are the first things down in any real storm. So the ladder
falls off a cliff instead of stepping down:
gum 1.00 / 0.88 / 0.76 climb freely, pay 24%
jacaranda 0.95 / 0.52 / 0.40 climb at all, pay 58%
THAT is the decision the palette was missing. A sail wants height on its
high corner (rain has to run off somewhere, DESIGN.md), and on a gum height
is nearly free. Put a jacaranda in the same spot and the author has bought
a real dilemma for the player: tie low into excellent steel and cut the
sail flat, or reach for the height and rig off a limb rated 0.40.
It is not priced and has no wreck, deliberately — same ruling as the bike.
Nothing in the sim brings a limb down as an event the player can watch, and
billing collateral for a thing nobody sees break is the lie the invoice
exists to kill. When limb failure is a visible event, price it then.
Shape follows the ladder rather than decorating it: low fork, three long
leaders, and a wide flat crown (7.2 m of spread on a 6.6 m tree — a
jacaranda is broader than it is tall, which is also why it makes such
good shade and such tempting bad anchors).
"""
rng = rng_for(name)
root = add_empty(name)
height, spread = 6.6, 7.2
bark = get_material("Mat_BarkJac", PAL["bark_jac"], 0.85)
bark_d = get_material("Mat_BarkShadow", PAL["bark_shadow"], 0.9)
leaf_a = get_material("Mat_LeafJac", PAL["leaf_jac"], 0.8)
leaf_b = get_material("Mat_LeafJac2", PAL["leaf_jac_2"], 0.8)
# The fork is LOW — 0.36 of the tree's height, where a gum's is 0.62. This
# single number is most of why the two species read differently at a
# glance, and all of why the good anchor is reachable off a ladder.
fork_h = height * 0.36
r_base, r_fork = height * 0.046, height * 0.027
lean = rng.uniform(-0.03, 0.03)
parts = [add_cone(f"{name}_trunk", r_base, r_fork, fork_h,
(lean * fork_h * 0.5, 0, fork_h / 2), bark,
verts=10, rot=(0, lean, 0))]
parts.append(add_cone(f"{name}_flare", r_base * 1.4, r_base,
height * 0.045, (0, 0, height * 0.0225), bark_d,
verts=10))
# Three leaders off the fork, sweeping out and up. These are the brittle
# part; the anchors on them are what the low fork is being compared to.
fork_pt = (lean * fork_h, 0, fork_h)
leader_tips = []
for i in range(3):
ang = math.tau * i / 3 + rng.uniform(-0.25, 0.25)
reach = spread * rng.uniform(0.26, 0.34)
tip = (fork_pt[0] + math.cos(ang) * reach,
fork_pt[1] + math.sin(ang) * reach,
fork_h + height * rng.uniform(0.22, 0.34))
parts.extend(_limb(f"{name}_leader_{i:02d}", fork_pt, tip,
r_fork * 0.72, r_fork * 0.30, bark, segs=4,
sweep=0.45))
leader_tips.append(tip)
join_group(parts, "trunk", root)
# Canopy: wide, flat, and low-domed. Blobs are squashed hard on Z (0.300.42
# against a gum's 0.550.75) because that flat umbrella IS the silhouette.
top = (fork_pt[0], fork_pt[1], height * 0.68)
canopy_grp = add_empty("canopy", top, root, size=0.7)
canopy_grp["sway_amp"] = 1.05
canopy_grp["sway_phase"] = round(rng_for(f"{name}:sway").uniform(0, math.tau), 3)
canopy_grp["sway_pivot_y"] = round(height * 0.68, 3)
for i in range(4):
ang = math.tau * i / 4 + rng.uniform(-0.25, 0.25)
off = spread * rng.uniform(0.16, 0.30)
cz = height * 0.68 + height * rng.uniform(0.02, 0.12)
r = spread * rng.uniform(0.22, 0.30)
blob = add_ico(f"canopy_{i + 1:02d}", r,
(top[0] + math.cos(ang) * off, top[1] + math.sin(ang) * off, cz),
leaf_a if i % 2 == 0 else leaf_b, subdiv=2,
scale=(1.0, 1.0, rng.uniform(0.30, 0.42)),
jitter=r * 0.10, rng=rng)
parent_keep_transform(blob, canopy_grp)
blob["sway_amp"] = round(0.6 + 0.4 * (cz / height), 3)
# The ladder, as data. branch_anchor_01 is the FORK — the one piece of
# honest steel this tree has — and 02/03 are out on the leaders.
LADDER = [
(fork_pt, 0.95,
"the main fork at 2.4 m: a two-hands-around-it union, and the best "
"thing this tree will ever offer you"),
(leader_tips[0], 0.52,
"a jacaranda leader — fast-grown, light, brittle; it is holding "
"itself up and not much else"),
(leader_tips[1], 0.40,
"further out on the same kind of limb; this is the rung that ends "
"the night"),
]
for i, (pt, hint, why) in enumerate(LADDER):
e = add_empty(f"branch_anchor_{i + 1:02d}", pt, root, size=0.25)
e["anchor_type"] = "tree"
e["rating_hint"] = hint
e["why"] = why
stamp(root, name, "tree")
root["canopy_count"] = 4
root["species"] = "jacaranda"
root["branch_ladder"] = "0.95/0.52/0.40"
root["ladder_note"] = ("steeper than the gums' 1.0/0.88/0.76 on purpose — "
"on this tree, height costs you")
root["priced"] = False
root["unpriced_why"] = "no limb-failure event exists for the player to see"
return root
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 _house_facade(name, root):
"""The rear façade shared VERBATIM by the intact house and its torn-gutter
wreck — wall ring, door, window, night glow, roof. Split out in Sprint 12
because a house does not fall down when a sail rips its eave line off (the
carport folds; brick veneer shrugs), so the two variants have to be the SAME
building or the aftermath swap reads as the house moving. One set of numbers,
two eave lines.
Returns the measurements and materials the eave line is built from, because
the eave is exactly the part that differs between the two callers."""
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)
# Night dressing (SPRINT6 §Lane E-1). `window_glow` ships HIDDEN — Lane A
# flips .visible on the night storms. It's an unlit emissive pane, so it
# needs no light to read and costs nothing when it's off.
#
# This is the cheapest storytelling in the yard: a warm window means someone
# is inside, which is the whole reason the player is out here in the dark
# fighting to keep the sail on. An empty yard at night is just weather.
glow_m = get_material("Mat_WindowGlow", PAL["window_warm"], 1.0)
_emissive(glow_m, PAL["window_warm"], 2.4)
glow = add_box("window_glow", (win_w - 0.10, 0.01, win_h - 0.10),
(win_x, -D / 2 + 0.055, win_z + win_h / 2), glow_m,
parent=root)
hide_by_default(glow)
glow["night_only"] = True
e = add_empty("window_light_anchor", (win_x, -D / 2 - 0.35, win_z + win_h / 2),
root, size=0.2)
e["light_hint"] = "warm PointLight ~2700K, spills onto the grass under the eave"
not_a_tie_off(e, "where Lane A hangs the warm window light",
"a lighting hint at a window pane — the fascia anchors are "
"the house's tie-offs, and they rate 0.35 for a reason")
# The roof and its eave. 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)
return dict(W=W, H=H, D=D, eave_y=eave_y, fascia_z=fascia_z,
trim=trim, gutter_m=gutter_m)
# What it costs to take the client's gutter — and the fascia it hangs off —
# with you. Same deal as CARPORT_COLLATERAL below, second time around: Lane A
# owns this number, it's a design call, not an art one. But the fascia anchors
# have said collateral:"gutter" since Sprint 6 and NOBODY has ever priced one —
# collateralFor('gutter') returns null, so backyard_01's house is a free
# failure: tie 25 m² to a 0.35 fascia, rip it off, pay for a shackle. My
# proposal and the reasoning, to argue with rather than adopt:
# · the band is established now: gnome 25 at the floor, carport 180, my
# stated ceiling ~250. The gutter goes between the ornament and the
# structure, because that is what it is;
# · a night's shop budget is 80 and the wild night's fee is ~76 (feeFor at a
# 32 m/s peak). At 90 the gutter costs slightly more than EITHER — the
# night it fires on is wiped, kit and fee together. That is the fascia's
# lesson in one number: the "free" anchors were the dearest kit in the yard;
# · under ~50 it's a shrug — less than the fee alone, so the trap couldn't
# even wipe its own night;
# · over ~130 the backyard outbids half a carport for a smaller lie: the
# fascia (0.35) is honestly better steel than the beam (0.22), it's three
# anchors not four, and it's the tutorial yard — the first trap a player
# can hit should cost them a night, not the week.
GUTTER_COLLATERAL = 90
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)
f = _house_facade(name, root)
W, H, eave_y, fascia_z = f["W"], f["H"], f["eave_y"], f["fascia_z"]
trim, gutter_m = f["trim"], f["gutter_m"]
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
# The proposal rides in the asset, exactly like the carport's did (and A
# adopted that one into site JSON, where the canonical number now lives —
# sites are data; this is the fallback and the argument). collateral_key is
# NEW and explicit because the carport never needed it: there, the priced
# thing and the structure share a name. Here the structure is "house" and
# the thing you take is the GUTTER — the key names which collateral string
# this value prices, so Lane A's wiring never has to guess.
root["collateral_key"] = "gutter"
root["collateral_value"] = GUTTER_COLLATERAL
root["collateral_label"] = "the gutter"
return root
def build_house_yardside_wrecked(name):
"""The house after the fascia let go (SPRINT12 §gate 3.3) — DESIGN.md's line
made geometry: "holds until the first real gust, then rips off taking the
gutter with it".
Same origin, same footprint, same façade — _house_facade, shared verbatim —
because a house does not fall down when a sail takes its eave line; only
what the anchors betrayed changes. The fascia is torn through the span the
fixings held, and the gutter it carried is in two states at once: the left
run still hangs off the surviving board by its last bracket, torn end sagged
toward the grass, and the right run is ON the grass out in the yard, kinked
where it landed, with the downpipe leaning out of plumb after it. A gutter
doesn't shatter — it unzips, springs, and lands where the wind was going.
NO fascia_anchor_* empties survive into this file. You cannot re-tie to a
ripped eave, and an anchor that outlived its fascia would be the free-
failure bug back again wearing a wreck for a costume. e.test.js pins that,
and that the gutter is DOWN here while its intact twin's is UP at the eave.
"""
rng = rng_for(name)
root = add_empty(name)
f = _house_facade(name, root)
W, eave_y, fascia_z = f["W"], f["eave_y"], f["fascia_z"]
trim, gutter_m = f["trim"], f["gutter_m"]
# The fascia, minus what the sail took: a 3.4 m board survives on the left,
# a stub clings to the far right corner, and both torn ends splinter past
# the break. The gap in between is where fascia_anchor_02 used to be.
fascia = [
add_box(f"{name}_fascia_left", (3.4, 0.035, 0.20),
(-2.9, eave_y, fascia_z), trim),
add_box(f"{name}_fascia_stub", (0.6, 0.035, 0.20),
(4.3, eave_y, fascia_z), trim),
]
for i, (bx, ang) in enumerate(((-1.05, 14), (3.85, -18))):
fascia.append(add_box(f"{name}_fascia_splinter_{i}", (0.38, 0.028, 0.09),
(bx, eave_y, fascia_z + 0.02), trim,
rot=(0, math.radians(ang + rng.uniform(-3, 3)), 0)))
join_group(fascia, "fascia_torn", root)
# The left run: still bracketed at the healthy end, torn free at the break,
# hanging diagonally with the freed end sprung out over the grass.
join_group([add_tube_between(
f"{name}_gutter_hang",
(-4.4, eave_y - 0.05, fascia_z - 0.12),
(-0.55, eave_y - 0.34, 1.28), 0.055, gutter_m, verts=8)],
"gutter_torn", root)
# The right run went where the wind was going: out over the yard, down on
# the grass, kinked where it hit. Flat ON the grass — the carport wreck's
# first-pass lesson (a corner sunk to z=-0.41) is why nothing here sinks.
kx = 2.4 + rng.uniform(-0.3, 0.3)
join_group([
add_tube_between(f"{name}_gutter_ground_a",
(0.35, -1.35, 0.055), (kx, -1.72, 0.055),
0.055, gutter_m, verts=8),
add_tube_between(f"{name}_gutter_ground_b",
(kx, -1.72, 0.055), (4.85, -1.48, 0.055),
0.055, gutter_m, verts=8),
], "gutter_down", root)
# The downpipe keeps its bottom strap and loses the top one with the run —
# so it leans out of plumb into the yard. Cheapest possible "this eave is
# finished" tell: it reads from across the lawn at night.
join_group([add_tube_between(
f"{name}_downpipe_loose",
(W / 2 - 0.25, eave_y - 0.05, 0.02),
(W / 2 - 0.12, eave_y - 0.62, 2.58), 0.04, gutter_m, verts=8)],
"downpipe_loose", root)
# What a ripped fascia leaves on the lawn: offcuts scattered near where the
# run came down, every board flat on the grass.
debris = []
for i, (dx, dy, ln, ang) in enumerate(((1.35, -1.15, 0.85, 22),
(3.25, -1.58, 0.55, -34),
(2.15, -0.92, 0.34, 63))):
debris.append(add_box(f"{name}_fascia_bit_{i}", (ln, 0.20, 0.035),
(dx + rng.uniform(-0.08, 0.08),
dy + rng.uniform(-0.08, 0.08), 0.0175), trim,
rot=(0, 0, math.radians(ang + rng.uniform(-6, 6)))))
join_group(debris, "debris_fascia", root)
stamp(root, name, "structure")
root["broken_variant_of"] = "house_yardside"
root["facade_width"] = W
# Same price as the state it used to be, so scoring can read either —
# the carport wreck's rule, verbatim.
root["collateral_key"] = "gutter"
root["collateral_value"] = GUTTER_COLLATERAL
root["collateral_label"] = "the gutter"
return root
# What it costs to take the client's carport with you. Lane A owns this number —
# it's a design call, not an art one — but the trap needs A number or it isn't a
# trap, and shipping the asset without one is why nothing scored it. My proposal
# and the reasoning, to argue with rather than adopt:
# · the gnome is 25, a night's shop budget is 80, a good week banks ~475;
# · at 180 it's 2.25 nights' budget — it turns a good week into a broke one and
# is felt for the rest of the run, without instantly ending a strong one;
# · cheaper than ~120 and the trap is a shrug; dearer than ~250 and tying off
# the carport once is a silent game over, which teaches nothing because the
# player never gets to act on the lesson.
# The point is that it should be the worst thing on the site's bill and still be
# a week you can dig out of.
CARPORT_COLLATERAL = 180
def build_carport_01(name):
"""A single-car carport — the corner block's whole personality (SPRINT9 §E).
DESIGN.md says that site is anchor-poor: "nowhere to tie off". The interesting
way to build that is NOT to give the yard nothing — an empty yard is just a
smaller yard. It's to give it something that LOOKS like four free anchors and
isn't. A carport is exactly that lie, and it's the same lie the house fascia
tells: light C-section posts on shallow pads, a roof beam sized to hold up a
sheet of Colorbond and precisely nothing else. Tie a 25 m² sail to it in a
southerly and you don't break the shackle, you take the carport.
So the anchors ship with honest, terrible numbers:
· `beam_anchor_01..02` — rating_hint 0.22, the worst in the game (the house
fascia is 0.35). collateral="carport": pull these and the roof goes.
· `post_anchor_01..02` — 0.30. Better, because a post at least stands on a
pad, but it's still a 90 mm post in 200 mm of concrete.
Lane A/B: these are meant to be TAKEN and to hurt. The site is winnable off
ground anchors and the one tree; the carport is the trap that teaches why.
"""
root = add_empty(name)
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
sheet = get_material("Mat_Colorbond", PAL["colorbond"], 0.45, metallic=0.5)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
W, D = 3.0, 5.4 # one car, tight — it's a corner block
H_HI, H_LO = 2.45, 2.20 # skillion, falling away from the street
posts, pads = [], []
for sx in (-1, 1):
for sy in (-1, 1):
x, y = sx * (W / 2 - 0.09), sy * (D / 2 - 0.09)
h = H_HI if sy < 0 else H_LO
pads.append(add_box(f"{name}_pad_{sx}_{sy}", (0.26, 0.26, 0.09),
(x, y, 0.045), conc))
# 90 mm box section. Light, and meant to look it.
posts.append(add_box(f"{name}_post_{sx}_{sy}", (0.09, 0.09, h),
(x, y, h / 2 + 0.09), steel))
join_group(pads, "footings", root)
join_group(posts, "posts", root)
beams = []
for sy in (-1, 1):
h = H_HI if sy < 0 else H_LO
beams.append(add_box(f"{name}_beam_{sy}", (W, 0.05, 0.14),
(0, sy * (D / 2 - 0.09), h + 0.09), dark))
for sx in (-1, 1):
beams.append(add_tube_between(
f"{name}_rafter_{sx}",
(sx * (W / 2 - 0.09), -D / 2 + 0.09, H_HI + 0.16),
(sx * (W / 2 - 0.09), D / 2 - 0.09, H_LO + 0.16), 0.035, dark, verts=6))
join_group(beams, "beams", root)
fall = math.atan2(H_HI - H_LO, D)
join_group([add_box(f"{name}_roof", (W + 0.24, D + 0.20, 0.04),
(0, 0, (H_HI + H_LO) / 2 + 0.22), sheet,
rot=(fall, 0, 0))], "roof", root)
# The trap, wired as data. Numbers are deliberately the worst in the game.
for i, sx in enumerate((-1, 1)):
e = add_empty(f"beam_anchor_{i + 1:02d}",
(sx * (W / 2 - 0.09), 0.0, H_LO + 0.16), root, size=0.18)
e["anchor_type"] = "carport"
e["rating_hint"] = 0.22 # worse than the house fascia's 0.35
e["collateral"] = "carport"
e["why"] = "sized for one sheet of roofing; a loaded sail takes the lot"
for i, sy in enumerate((-1, 1)):
e = add_empty(f"post_anchor_{i + 1:02d}",
(-(W / 2 - 0.09), sy * (D / 2 - 0.09), 1.75), root, size=0.18)
e["anchor_type"] = "carport_post"
e["rating_hint"] = 0.30
e["collateral"] = "carport"
e["why"] = "90 mm post on a 200 mm pad — better than the beam, still a lie"
stamp(root, name, "structure")
root["site_hint"] = "corner_block"
root["is_anchor_trap"] = True
# Without this the trap is unscoreable: the anchors say collateral="carport"
# but nothing said what a carport COSTS, so Lane A's aftermath had no number
# to reach for. main.js already reads world.gnome.collateralValue — same
# shape, same place.
#
# SPRINT14 palette audit — `collateral_key` added, and the new audit assert
# is what found it. The price used to resolve only because site_02 happens
# to name its structure "carport", matching the string on the anchors:
# `collateralFor(key)` looks for a STRUCTURE whose site-JSON id === key.
# That held for exactly one yard. The moment the editor places a second one
# — and it will generate "carport_2" for uniqueness, because it must — the
# anchors still say collateral:"carport", no structure carries that id, and
# collateralFor returns null: the carport becomes a FREE failure, which is
# the gutter bug reborn in the sprint meant to bury it. The GLB now names
# which collateral string its price answers to, exactly as the house does
# for "gutter". Lane A: the runtime half is yours — collateralFor could
# fall back to `glb.userData.collateral_key` when no structure id matches.
root["collateral_key"] = "carport"
root["collateral_value"] = CARPORT_COLLATERAL
root["collateral_label"] = "the carport"
return root
def build_carport_01_wrecked(name):
"""The carport after you tied a sail to it (SPRINT10 §E).
Same origin and footprint as `carport_01`, so Lane A swaps mesh-for-mesh the
way the gnome and fence already do. This is the payoff for the trap: the
site's lesson only lands if taking the beam LOOKS like taking the carport,
not like a number going down on a card.
It fails the way a light structure actually fails — not flattened. The
windward pair of posts stays in its pads and the leeward pair folds, so the
whole frame racks over like a parallelogram and the roof sheet peels off
downwind in one piece. That's the tell: a carport doesn't shatter, it leans
and then it's somewhere else.
"""
rng = rng_for(name)
root = add_empty(name)
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
sheet = get_material("Mat_Colorbond", PAL["colorbond"], 0.45, metallic=0.5)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
W, D = 3.0, 5.4
H_HI, H_LO = 2.45, 2.20
RACK = math.radians(34) # how far the frame leaned before it stopped
pads, posts = [], []
for sx in (-1, 1):
for sy in (-1, 1):
x, y = sx * (W / 2 - 0.09), sy * (D / 2 - 0.09)
h = H_HI if sy < 0 else H_LO
# The pads stay: they were never the weak part. That's the joke.
pads.append(add_box(f"{name}_pad_{sx}_{sy}", (0.26, 0.26, 0.09),
(x, y, 0.045), conc))
lean = RACK * rng.uniform(0.86, 1.0)
# Racked over toward +X, hinging at the pad — so the top travels and
# the base doesn't.
posts.append(add_box(f"{name}_post_{sx}_{sy}", (0.09, 0.09, h),
(x + math.sin(lean) * h / 2, y,
0.09 + math.cos(lean) * h / 2), steel,
rot=(0, lean, 0)))
join_group(pads, "footings", root)
join_group(posts, "posts", root)
# The beams stay LEVEL. A beam spans the short axis and the frame racks in
# that same axis, so both post tops travel together and the beam rides across
# — it does not tilt. Rotating it swung one end to 2.96 m, i.e. the wreck came
# out TALLER than the carport it used to be. Measured, not reasoned.
beams = []
for sy in (-1, 1):
h = H_HI if sy < 0 else H_LO
beams.append(add_box(f"{name}_beam_{sy}", (W, 0.05, 0.14),
(math.sin(RACK) * h, sy * (D / 2 - 0.09),
0.09 + math.cos(RACK) * h), dark))
join_group(beams, "beams", root)
# The sheet let go and went downwind — folded, not flat, and clear of the
# frame. Nobody's parking under this again.
# Flat-ish and ON the grass. The first pass tilted them enough to sink a
# corner to z=-0.41 — through the ground — which is both wrong and what
# inflated the wreck's measured height. Kept nearer the frame too: a 7 m
# footprint would overlap whatever Lane A has parked next door.
join_group([add_box(f"{name}_sheet_a", (W + 0.2, D * 0.55, 0.04),
(W * 0.95, -D * 0.18, 0.12), sheet,
rot=(0, math.radians(3), math.radians(-13))),
add_box(f"{name}_sheet_b", (W * 0.8, D * 0.38, 0.04),
(W * 0.88, D * 0.30, 0.28), sheet,
rot=(math.radians(9), math.radians(-4), math.radians(8)))],
"roof_down", root)
stamp(root, name, "structure")
root["broken_variant_of"] = "carport_01"
root["collateral_key"] = "carport" # SPRINT14 audit — see the intact twin
root["collateral_value"] = CARPORT_COLLATERAL
return root
# ---------------------------------------------------------------------------
# THE SWING SET (SPRINT14 gate 3.1 — a temptation prop for D's palette)
# ---------------------------------------------------------------------------
# One shape, stated once, used by the intact set AND the wreck. The carport and
# the house both taught this: a wreck built from re-typed numbers drifts away
# from its twin one edit at a time, and the swap starts needing a fudge offset.
SWING = dict(
SPAN=2.30, # crossbar, apex to apex (a two-seat domestic A-frame)
SPLAY=0.45, # each leg foot this far fore/aft of the apex
H=2.05, # top rail height — a shade over head height, which is
# exactly why it reads as a tie-off
R_LEG=0.024, # 48 mm OD galvanised tube
R_RAIL=0.019, # 38 mm OD top rail
SEAT_Z=0.45,
SEAT_X=(-0.55, 0.55),
)
# What it costs to bend the client's swing set. Proposal — Lane A owns the
# number, same as the carport's 180 and the gutter's 90:
# · the band is a ladder now, and this has to slot into it honestly:
# gnome 25 (ornament) < gutter 90 (a run of one trade's work) < SWING 140
# < carport 180 (a structure with a roof on it);
# · what actually fails is the two apex junctions and the legs under them —
# bent tube, not matchwood. That's a frame replacement and a re-hang on a
# set that costs $250350 new, so 140 is the repair, not the receipt;
# · under ~90 it's cheaper than the gutter, which would say a whole item of
# the kid's play equipment is worth less than a length of guttering —
# the client would not agree and neither would the bill;
# · over ~180 it outbids the carport, and a swing set is a toy: the carport
# must stay the worst thing on any yard's bill or the corner block's
# lesson gets quietly outranked by a prop.
# It is priced (unlike the bike) because the sim CAN destroy it: the frame
# anchors carry collateral="swing_set", so losing that corner bills it through
# exactly the chain the carport already proved, and the wreck is the thing the
# player sees.
SWING_COLLATERAL = 140
def _swing_frame(name, root, steel, seat_m, chain_m, *, wrecked=False):
"""Build the set. `wrecked=False` is the standing one; True tips it over.
Returns the two apex points (Blender coords) so the caller hangs the
anchors on the exact points the geometry ended up at, rather than on a
second copy of the arithmetic.
HOW IT FAILS, and why the wreck is a transform rather than a second model:
an A-frame swing set is not bolted to anything. The feet sit on the grass
(the ground pegs are in the shed; they always are). Load a corner of a
25 m² sail onto it and nothing snaps — the frame walks, then it goes over
sideways in one piece.
So the wreck is the same triangle, rotated about the foot line it tips
OVER (y = SPLAY), by 100° — past horizontal. That angle is not a look, it
is the resting position: the frame comes to rest on that foot rail and on
the crossbar, which puts the far pair of legs in the air at ~0.89 m,
pointing up. That is what a fallen A-frame looks like in every yard I have
ever seen one in, and it is unmistakable at a glance from across a yard —
which is the job, because the player has to read "I did that" instantly.
Tipping about the far foot instead (the obvious first try) drives the near
feet 0.88 m through the lawn — the same class of error as a wreck that
stands taller than its twin, and the reason both are asserted.
"""
S = SWING
half = S["SPAN"] / 2
apex_l, apex_r = (-half, 0.0, S["H"]), (half, 0.0, S["H"])
if wrecked:
th = math.radians(100.0)
c, s = math.cos(th), math.sin(th)
y0 = -S["SPLAY"] # the foot line it goes over
# It rests ON its tubes, so lift by a leg radius: without this the
# capped ends of the now-near-horizontal legs sit ~24 mm under the
# grass, and "broken variants sit on the ground" is a real assert.
lift = S["R_LEG"] + 0.002
def R(p):
x, y, z = p
yr = y - y0
return (x, yr * c - z * s + y0, yr * s + z * c + lift)
else:
def R(p):
return p
legs, feet = [], []
for sx in (-1, 1):
ax = sx * half
for sy in (-1, 1):
legs.append(add_tube_between(
f"{name}_leg_{sx}_{sy}", R((ax, sy * S["SPLAY"], 0.0)),
R((ax, 0.0, S["H"])), S["R_LEG"], steel, verts=6))
# The foot rail — the bit that is supposed to be pegged down and is
# not. It survives the wreck: it is the part that dragged.
feet.append(add_tube_between(
f"{name}_footrail_{sx}", R((ax, -S["SPLAY"], 0.03)),
R((ax, S["SPLAY"], 0.03)), S["R_LEG"] * 0.8, steel, verts=6))
join_group(legs + feet, "frame", root)
# The crossbar gets its OWN node, and that is a gameplay decision, not a
# modelling one: it is the single most tempting-looking thing on the prop
# (a straight steel rail at 2.05 m, dead level, right where a sail corner
# wants to be) and it is not an anchor. Keeping it separate means the data
# can say so on the node itself, and means the wreck can lay it on the
# grass as one recognisable piece.
rail = join_group([add_tube_between(
f"{name}_rail", R(apex_l), R(apex_r), S["R_RAIL"], steel, verts=8)],
"crossbar", root)
not_a_tie_off(
rail, "the top rail — a swing hangs off it, a sail does not",
"38 mm tube spanning 2.3 m between two unpegged A-frames: it holds a "
"child in bending, and a sail corner pulls it sideways, which is the "
"one direction nothing here resists")
swing_parts = []
for i, sx in enumerate(S["SEAT_X"]):
if wrecked:
# Chains do not stay rigid when the frame they hang from is on the
# grass. The rail is down at (y≈2.55, z≈0.09); the seats ended up
# just past it, flat, with the chains slack across the lawn.
hang = R((sx, 0.0, S["H"]))
seat_at = (sx, hang[1] - 0.28, 0.03)
for sy in (-1, 1):
swing_parts.append(add_tube_between(
f"{name}_chain_{i}_{sy}", (hang[0] + sy * 0.05, hang[1], hang[2]),
(seat_at[0] + sy * 0.07, seat_at[1], seat_at[2] + 0.02),
0.006, chain_m, verts=4))
swing_parts.append(add_box(
f"{name}_seat_{i}", (0.44, 0.16, 0.03), seat_at, seat_m,
rot=(0, 0, math.radians(7 * (1 if i else -1)))))
else:
for sy in (-1, 1):
swing_parts.append(add_tube_between(
f"{name}_chain_{i}_{sy}", (sx, 0.0, S["H"]),
(sx, sy * 0.09, S["SEAT_Z"]), 0.006, chain_m, verts=4))
swing_parts.append(add_box(
f"{name}_seat_{i}", (0.44, 0.16, 0.03), (sx, 0.0, S["SEAT_Z"]),
seat_m))
join_group(swing_parts, "swings", root)
return [R(apex_l), R(apex_r)]
def build_swing_set_01(name):
"""A two-seat backyard swing set — the palette's honest middle option.
SPRINT14 gate 3.1. D needs things worth placing, and "worth placing" means
the author has a real decision to make. The palette had a ceiling (a gum
fork, 1.0), a floor (the carport beam, 0.22, a pure trap) and almost
nothing in between — so every yard was either "there is good steel here" or
"there is a lie here". The swing set is the middle: it genuinely holds, and
it costs you if you lean on it.
THE TEMPTATION IS THE CROSSBAR. A dead-level steel rail at 2.05 m, spanning
2.3 m, at the exact height a sail corner wants — it is the most anchor-
looking object I have built. It is not an anchor, and the data says so
(`tie_off: False` on the `crossbar` node). What IS offered is the two apex
junctions, where four legs and the rail all meet a welded corner casting:
`frame_anchor_01/02`, rating_hint 0.45, typed `swing_frame`.
WHY 0.45, and why its own enum type. The junction itself is sound steel —
better than the house fascia (0.35) and much better than the carport beam
(0.22). What it is NOT is anchored: the whole set stands on four feet on
grass, with the ground pegs still in the shed. So it holds a moderate pull
and then the SET moves, which is a completely different failure from a
post pulling out of concrete. That is also why it is not typed `post`: the
enum string is what the player reads before they commit (MANUAL, "the enum
gives it its pre-rig read"), and calling this a post would promise 4 m of
concreted steel. It is a swing frame. It says swing frame.
Priced at SWING_COLLATERAL, with a wreck, because the sim can actually do
it: `collateral="swing_set"` on both anchors, the value keyed on the root,
the same chain the carport proved. (Compare the bike, which stays unpriced
because nothing can knock it over yet.)
"""
root = add_empty(name)
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
seat_m = get_material("Mat_SwingSeat", PAL["bike_kid"], 0.75)
chain_m = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
apexes = _swing_frame(name, root, steel, seat_m, chain_m, wrecked=False)
for i, ap in enumerate(apexes):
e = add_empty(f"frame_anchor_{i + 1:02d}", ap, root, size=0.18)
e["anchor_type"] = "swing_frame"
e["rating_hint"] = 0.45
e["collateral"] = "swing_set"
e["why"] = ("welded apex casting — sound steel on a frame that is "
"standing on grass, not pegged into it")
stamp(root, name, "prop")
root["collateral_key"] = "swing_set"
root["collateral_value"] = SWING_COLLATERAL
root["collateral_label"] = "the swing set"
root["breakable"] = True
root["mass_hint"] = 38.0
# ** WHICH WAY IT FALLS — a placement fact, MEASURED, not reasoned. **
# In Blender the wreck goes over toward Y. You do not work in Blender: the
# exporter maps (x, y, z) → (x, z, y), so in three.js it lands on +Z. I am
# only willing to write that down because I loaded the GLB in the browser
# and read the crossbar's world box: centre (0.00, 0.11, +2.55), footprint
# z = 0.45 … 2.93. My bike docstring lied about exactly this axis and only a
# browser-coords assert caught it, so these two extras are pinned by one in
# e.test.js as well — the claim and the geometry now go red together.
#
# Lane A / D: leave ~3 m clear on the prop's +Z side or the wreck lays
# itself through whatever is standing there. The intact footprint is only
# ~0.95 m deep, so the editor cannot infer this from the standing bounds.
root["wreck_falls_toward"] = "+Z"
root["wreck_clearance_m"] = 3.0
return root
def build_swing_set_01_wrecked(name):
"""The swing set after you tied a sail corner to it.
Same origin, same parts, one number different (`wrecked=True` racks the
frame 62° about the ground line) — so intact and wrecked cannot drift, and
the swap is mesh-for-mesh like the carport's and the house's.
It is racked over, not flattened: the rail is on the grass with both seats
still hanging off it and the feet have dragged. A swing set that came apart
into pieces would read as vandalism; one lying on its side with the swings
tangled reads as exactly what it is, which is a thing you pulled over.
"""
root = add_empty(name)
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
seat_m = get_material("Mat_SwingSeat", PAL["bike_kid"], 0.75)
chain_m = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
_swing_frame(name, root, steel, seat_m, chain_m, wrecked=True)
# No frame_anchor_* survives, for the same reason no fascia_anchor survives
# the torn eave: you cannot re-tie to a frame lying on the grass, and an
# anchor that outlives its structure is the free-failure bug in a costume.
stamp(root, name, "prop")
root["broken_variant_of"] = "swing_set_01"
root["collateral_key"] = "swing_set"
root["collateral_value"] = SWING_COLLATERAL
root["collateral_label"] = "the swing set"
return root
# ---------------------------------------------------------------------------
# THE PERGOLA (SPRINT16 gate 5 — the honest middle rung, D-endorsed)
# ---------------------------------------------------------------------------
# One shape, stated once, used by the intact pergola AND the wreck — the
# _swing_frame precedent, kept on purpose: a wreck built from re-typed numbers
# drifts from its twin one edit at a time.
PERGOLA = dict(
DECK_W=3.2, # deck span, x
DECK_D=2.6, # deck depth, y
DECK_H=0.30, # two steps up — the deck IS the footing, that's the point
POST=0.09, # 90x90 treated pine
POST_H=2.20, # above the deck
INSET=0.18, # posts in from the deck edge
BEAM_H=0.14, # 140x45 beams along x on front/back post pairs
N_RAFTERS=7, # 70x35 rafters spanning y, the open roof
)
# What it costs when the timber cracks. Proposal — Lane A owns the number, the
# ladder it slots into is gnome 25 < gutter 90 < PERGOLA 120 < swing 140 <
# carport 180 (< glasshouse 320, below):
# · the failure is PARTIAL by design: the bolts hold, the timber breaks — a
# torn-off beam and a snapped post on a structure that stays standing. The
# bill is one beam, one post and the labour to re-bolt, not a rebuild;
# · under ~90 it undercuts the gutter, and a structural timber repair does
# not cost less than a run of guttering;
# · over ~140 it outbids the swing set, whose failure is the WHOLE frame on
# the grass — a worse day than a cracked beam, and the prices must agree
# with the wrecks the player actually sees.
PERGOLA_COLLATERAL = 120
def _pergola(name, root, deck_m, timber_m, dark_m, *, wrecked=False):
"""Build the pergola. Returns the two anchor points (Blender coords) so the
caller hangs anchors on the geometry's own numbers, not a second copy of
the arithmetic. Intact and wrecked share every dimension through PERGOLA.
HOW IT FAILS, and why that is the 0.65 story told twice: the posts are
BOLTED to the deck — real M12s through the joists, and the bolts hold.
What gives is the timber and the deck's own stiffness: the loaded front
beam tears off its post tops, the front posts snap at half height above
the racking bolts, and the rafters — still fixed at the back beam — hinge
down like a lean-to until their front ends hit the deck. The back half
stands. A pergola fails PARTIALLY, which is exactly why its rating (0.65)
sits above the unbolted swing frame (0.45) and below concrete (1.00), and
why its bill (a beam and a post) sits below the swing's (a whole frame).
"""
P = PERGOLA
hw, hd = P["DECK_W"] / 2, P["DECK_D"] / 2
px, py = hw - P["INSET"], hd - P["INSET"] # post centres
top = P["DECK_H"] + P["POST_H"] # post top z
beam_z = top + P["BEAM_H"] / 2 # beam centre z
# --- the deck: joists + boards. Identical in the wreck — it was never the
# weak part; it is the footing the rating is ABOUT.
deck = [add_box(f"{name}_deck_slab", (P["DECK_W"], P["DECK_D"], 0.045),
(0, 0, P["DECK_H"] - 0.0225), deck_m)]
for i in range(4):
y = -hd + 0.12 + i * (P["DECK_D"] - 0.24) / 3
deck.append(add_box(f"{name}_joist_{i}", (P["DECK_W"] - 0.1, 0.045, P["DECK_H"] - 0.05),
(0, y, (P["DECK_H"] - 0.05) / 2), dark_m))
# Board grooves read as lines; three thin gaps of darker timber.
for i in range(5):
x = -hw + 0.3 + i * (P["DECK_W"] - 0.6) / 4
deck.append(add_box(f"{name}_board_gap_{i}", (0.015, P["DECK_D"] - 0.02, 0.008),
(x, 0, P["DECK_H"] + 0.004), dark_m))
join_group(deck, "deck", root)
# --- posts. Front pair (y, the yard side, where the anchors live) snaps
# in the wreck; back pair always stands.
posts = []
for sx in (-1, 1):
posts.append(add_box(f"{name}_post_b_{sx}", (P["POST"], P["POST"], P["POST_H"]),
(sx * px, py, P["DECK_H"] + P["POST_H"] / 2), timber_m))
if wrecked:
# Snapped at 55% height: the bolted stub stands plumb, the top
# half lies on the deck with its beam end, split face up.
stub = P["POST_H"] * 0.55
posts.append(add_box(f"{name}_post_f_{sx}", (P["POST"], P["POST"], stub),
(sx * px, -py, P["DECK_H"] + stub / 2), timber_m))
posts.append(add_box(f"{name}_post_top_{sx}",
(P["POST"], P["POST"], P["POST_H"] - stub),
(sx * (px - 0.35), -py + 0.42,
P["DECK_H"] + P["POST"] / 2), timber_m,
rot=(math.radians(88), 0, math.radians(20 * sx))))
else:
posts.append(add_box(f"{name}_post_f_{sx}", (P["POST"], P["POST"], P["POST_H"]),
(sx * px, -py, P["DECK_H"] + P["POST_H"] / 2), timber_m))
join_group(posts, "posts", root)
# --- beams. Back beam always up; front beam is the one that tears off.
beams = [add_box(f"{name}_beam_b", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]),
(0, py, beam_z), timber_m)]
if not wrecked:
beams.append(add_box(f"{name}_beam_f", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]),
(0, -py, beam_z), timber_m))
join_group(beams, "beams", root)
if wrecked:
# The torn beam, flat on the deck along the front edge, slightly askew.
# Rolled 90° about its own long axis so the 140 mm face lies on the
# boards (a beam at rest lies on its side; on edge it would balance).
join_group([add_box(f"{name}_beam_down", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]),
(0.18, -py + 0.15, P["DECK_H"] + 0.03),
timber_m, rot=(math.radians(90), 0, math.radians(6)))],
"beam_down", root)
# --- rafters: up in the intact roof; hinged down at the back beam in the
# wreck, front ends resting on the deck — the lean-to collapse.
rafters = []
for i in range(P["N_RAFTERS"]):
x = -hw + 0.35 + i * (P["DECK_W"] - 0.7) / (P["N_RAFTERS"] - 1)
if wrecked:
drop = math.atan2(top + P["BEAM_H"] - (P["DECK_H"] + 0.05), P["DECK_D"] - 0.3)
L = math.hypot(top + P["BEAM_H"] - (P["DECK_H"] + 0.05), P["DECK_D"] - 0.3)
rafters.append(add_box(f"{name}_rafter_{i}", (0.035, L, 0.07),
(x, py - L / 2 * math.cos(drop),
(top + P["BEAM_H"] + P["DECK_H"] + 0.05) / 2),
timber_m, rot=(-drop, 0, 0)))
else:
rafters.append(add_box(f"{name}_rafter_{i}", (0.035, P["DECK_D"] - 0.15, 0.07),
(x, 0, top + P["BEAM_H"] + 0.035), timber_m))
join_group(rafters, "rafters_down" if wrecked else "rafters", root)
# Anchor points: where the front beam meets the post tops — the bolted
# corner an author would genuinely strap to.
return [(-px, -py, top + P["BEAM_H"] * 0.6), (px, -py, top + P["BEAM_H"] * 0.6)]
def build_pergola_01(name):
"""A timber pergola on a bolted deck — the ladder's missing middle rung.
SPRINT16 gate 5, proposed in S15 and carried on D's playtester endorsement:
"the ladder jumps 0.45-on-grass straight to 1.00-in-concrete; a 0.65
bolted-to-deck mid-rung turns fascia-or-nothing into a real decision."
WHY 0.65, and why one rating for the whole type (the manifest rule my own
S15 entry proposed: new types ship ONE rating per type, so a node-less
JSON anchor can resolve it unambiguously): the post-beam corner is real
90x90 timber with real M12 bolts through a deck — genuinely better than a
welded frame standing loose on grass (swing_frame 0.45), because the load
path reaches something fixed. And genuinely worse than 300 mm of concrete
(post 1.00), because what it reaches is a DECK: the deck flexes, the
timber cracks before the bolts let go, and 0.65 is that sentence as a
number. Typed `pergola` in its own enum word — calling it a post would
promise concrete the deck cannot deliver (the swing_frame lesson, kept).
"""
root = add_empty(name)
deck_m = get_material("Mat_Deck", PAL["deck_board"], 0.8)
timber_m = get_material("Mat_Timber", PAL["timber"], 0.75)
dark_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.8)
anchors = _pergola(name, root, deck_m, timber_m, dark_m, wrecked=False)
for i, ap in enumerate(anchors):
e = add_empty(f"pergola_anchor_{i + 1:02d}", ap, root, size=0.18)
e["anchor_type"] = "pergola"
e["rating_hint"] = 0.65
e["collateral"] = "pergola"
e["why"] = ("bolted post-beam corner: the bolts are real, the deck is "
"the footing — better than loose-on-grass, worse than "
"concrete, and the number argues exactly that")
stamp(root, name, "structure")
root["collateral_key"] = "pergola"
root["collateral_value"] = PERGOLA_COLLATERAL
root["collateral_label"] = "the pergola"
root["breakable"] = True
return root
def build_pergola_01_wrecked(name):
"""The pergola after a loaded corner won: front beam torn off, front posts
snapped above their bolts, rafters hinged down onto the deck like a
lean-to. The back half — and the deck, and every bolt — still standing,
because a bolted structure fails PARTIALLY, which is the whole 0.65 story.
Same origin, same PERGOLA numbers, one flag different, so intact and
wrecked cannot drift (the _swing_frame pattern)."""
root = add_empty(name)
deck_m = get_material("Mat_Deck", PAL["deck_board"], 0.8)
timber_m = get_material("Mat_Timber", PAL["timber"], 0.75)
dark_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.8)
_pergola(name, root, deck_m, timber_m, dark_m, wrecked=True)
# No pergola_anchor_* survives — you cannot re-bolt to a snapped post from
# inside a storm, and an anchor that outlives its structure is the
# free-failure bug in a costume (fascia rule, third application).
stamp(root, name, "structure")
root["broken_variant_of"] = "pergola_01"
root["collateral_key"] = "pergola"
root["collateral_value"] = PERGOLA_COLLATERAL
root["collateral_label"] = "the pergola"
return root
# ---------------------------------------------------------------------------
# THE GLASSHOUSE (SPRINT16 gate 5 — the collateral apex)
# ---------------------------------------------------------------------------
GLASSHOUSE = dict(
W=2.6, # x — ridge runs along x
D=1.9, # y
WALL_H=1.70,
RIDGE_H=2.28,
BAR=0.045, # aluminium extrusion section
)
# The apex, priced as one. Proposal — Lane A owns the number:
# · it must top the carport (180) by a distance, or "apex" is a word: the
# carport is one sheet of Colorbond; this is ~14 panes of horticultural
# glass, and glass does not bend, it all goes at once. 320 is the panes,
# the reglaze labour, and a racked door that never closes right again;
# · it must stay under a six-night week's gross (~$540 in fees alone), so
# one catastrophe is a week worked for nothing, not an automatic BROKE —
# the carport ruling ("the worst thing on the bill and still a week you
# can dig out of"), scaled to the thing that costs a week;
# · unlike the bike (unpriced, nothing can break it) this ships priced ON
# THE SPEC'S OWN ORDER: arc 2's glasshouse yard is the event's home, and
# the price chain has to exist before that yard can be authored against
# it. Until an event lands, nothing bills it — the value is inert data,
# not a lie on an invoice. The tripwire is written in THREADS: the arc-2
# yard must wire the wreck event before this number reaches a bill.
GLASSHOUSE_COLLATERAL = 320
def _glasshouse_frame(name, root, alu):
"""The aluminium skeleton, shared by intact and wreck — the frame is the
part that SURVIVES (aluminium racks, glass shatters), which is why the
wreck stands at full height and reads as emptied, not felled."""
G = GLASSHOUSE
hw, hd, B = G["W"] / 2, G["D"] / 2, G["BAR"]
parts = []
# Corner + mid posts.
for x in (-hw, 0, hw):
for sy in (-1, 1):
parts.append(add_box(f"{name}_post_{x:.1f}_{sy}", (B, B, G["WALL_H"]),
(x, sy * hd, G["WALL_H"] / 2), alu))
# Eave beams (long sides) + end sills.
for sy in (-1, 1):
parts.append(add_box(f"{name}_eave_{sy}", (G["W"], B, B),
(0, sy * hd, G["WALL_H"]), alu))
for sx in (-1, 1):
parts.append(add_box(f"{name}_end_{sx}", (B, G["D"], B),
(sx * hw, 0, G["WALL_H"]), alu))
# Gable rafters up to the ridge.
for sy in (-1, 1):
p0 = (sx * hw, sy * hd, G["WALL_H"])
p1 = (sx * hw, 0, G["RIDGE_H"])
parts.append(add_tube_between(f"{name}_gable_{sx}_{sy}", p0, p1,
B * 0.45, alu, verts=6))
# The ridge — the straightest, most anchor-looking bar in any yard, which
# is exactly why it gets a named node and a denial (below, on the empty).
parts.append(add_box(f"{name}_ridge", (G["W"], B, B), (0, 0, G["RIDGE_H"]), alu))
join_group(parts, "frame", root)
# Door frame on the -x gable end.
join_group([
add_box(f"{name}_door_l", (B * 0.7, B * 0.7, 1.55), (-hw - 0.001, -0.36, 0.775), alu),
add_box(f"{name}_door_r", (B * 0.7, B * 0.7, 1.55), (-hw - 0.001, 0.36, 0.775), alu),
add_box(f"{name}_door_t", (B * 0.7, 0.76, B * 0.7), (-hw - 0.001, 0, 1.55), alu),
], "door", root)
def build_glasshouse_01(name):
"""A backyard glasshouse — the collateral apex, and NOT an anchor.
SPRINT16 gate 5. The palette's other structures are temptations because
they offer bad steel; this one is a temptation because it offers NO steel
while being ringed in members that look like it — an aluminium ridge bar
at 2.28 m is the most tie-off-shaped object in the game. Every reading of
it must come back "don't": the ridge carries an explicit tie_off denial,
and NO node anywhere on either variant carries a rating_hint, which
e.test pins — nothing about a glasshouse may adopt as an anchor, because
30 kg of glass in a storm is not a thing you add load to, it is a thing
you keep everything away from.
The panes read fragile at a glance because they are TRANSLUCENT — the only
see-through walls in the palette — and that read survives export as a
material fact (transparent + alpha), which e.test also pins: a repaint
that turns the glass opaque has silently deleted the tell.
"""
root = add_empty(name)
alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7)
glass = get_material("Mat_Glass", PAL["glass"], 0.05, metallic=0.0, opacity=0.30)
G = GLASSHOUSE
hw, hd = G["W"] / 2, G["D"] / 2
_glasshouse_frame(name, root, alu)
panes = []
# Wall panes: long sides + gable ends (leaving the door opening).
for sy in (-1, 1):
panes.append(add_box(f"{name}_wall_{sy}", (G["W"] - 0.06, 0.012, G["WALL_H"] - 0.08),
(0, sy * (hd - 0.012), G["WALL_H"] / 2), glass))
panes.append(add_box(f"{name}_endwall_e", (0.012, G["D"] - 0.06, G["WALL_H"] - 0.08),
(hw - 0.012, 0, G["WALL_H"] / 2), glass))
for sy in (-1, 1): # west end: two strips beside the door
panes.append(add_box(f"{name}_endwall_w_{sy}", (0.012, (G["D"] - 0.8) / 2 - 0.05, G["WALL_H"] - 0.08),
(-hw + 0.012, sy * (0.4 + (G["D"] - 0.8) / 4), G["WALL_H"] / 2), glass))
# Roof panes: two slopes from eave to ridge.
slope = math.atan2(G["RIDGE_H"] - G["WALL_H"], hd)
run = math.hypot(G["RIDGE_H"] - G["WALL_H"], hd)
for sy in (-1, 1):
panes.append(add_box(f"{name}_roof_{sy}", (G["W"] - 0.04, run - 0.03, 0.012),
(0, sy * hd / 2, (G["WALL_H"] + G["RIDGE_H"]) / 2 + 0.01),
glass, rot=(sy * slope, 0, 0)))
# Gable triangles, approximated as narrow strips under the rafters.
for sx in (-1, 1):
panes.append(add_box(f"{name}_gable_pane_{sx}", (0.012, G["D"] * 0.5, (G["RIDGE_H"] - G["WALL_H"]) * 0.8),
(sx * (hw - 0.012), 0, G["WALL_H"] + (G["RIDGE_H"] - G["WALL_H"]) * 0.42), glass))
join_group(panes, "panes", root)
# The temptation, denied ON the data. Same mechanism as the swing set's
# crossbar: the most anchor-looking member carries the explicit no.
not_a_tie_off(
add_empty("ridge_anchor", (0, 0, G["RIDGE_H"]), root, size=0.2),
"the ridge bar — the straightest steel-looking line in the yard",
"a 45 mm aluminium extrusion whose whole job is holding glass; load a "
"sail corner on it and you are not testing the bar, you are testing "
"fourteen panes at once")
stamp(root, name, "structure")
root["collateral_key"] = "glasshouse"
root["collateral_value"] = GLASSHOUSE_COLLATERAL
root["collateral_label"] = "the glasshouse"
root["fragile"] = True
root["pane_count"] = 14
return root
def build_glasshouse_01_wrecked(name):
"""The glasshouse after the storm found it. The frame is aluminium — it
stays up, near enough at full height — but the glass is GONE: two cracked
panes still hanging, the rest a glitter field of shards on the ground,
thrown wider than the footprint ever was. A glasshouse doesn't fall, it
empties; a wreck that read as a collapsed building would be lying about
what glass does. Same frame helper as the intact one, so the two cannot
drift."""
rng = rng_for(name)
root = add_empty(name)
alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7)
glass = get_material("Mat_Glass", PAL["glass"], 0.05, metallic=0.0, opacity=0.30)
G = GLASSHOUSE
hw, hd = G["W"] / 2, G["D"] / 2
_glasshouse_frame(name, root, alu)
# Two survivors, one visibly askew in its frame.
join_group([
add_box(f"{name}_pane_left_a", (0.012, G["D"] * 0.4, G["WALL_H"] * 0.5),
(hw - 0.012, -0.3, G["WALL_H"] * 0.35), glass),
add_box(f"{name}_pane_left_b", (G["W"] * 0.3, 0.012, G["WALL_H"] * 0.45),
(0.4, hd - 0.012, G["WALL_H"] * 0.3), glass,
rot=(0, math.radians(8), 0)),
], "panes_left", root)
# The shard field. Deterministic scatter, thrown mostly downwind (+x, the
# storm convention every other wreck in the palette follows) and WIDER
# than the standing footprint — the measured claim e.test pins.
shards = []
for i in range(26):
a = rng.uniform(0, math.tau)
r = rng.uniform(0.2, 1.0)
x = math.cos(a) * (hw + r * 0.9) * (1.25 if math.cos(a) > 0 else 0.8)
y = math.sin(a) * (hd + r * 0.6)
s = rng.uniform(0.06, 0.22)
shards.append(add_box(f"{name}_shard_{i}", (s, s * rng.uniform(0.4, 0.9), 0.008),
(x, y, 0.006), glass,
rot=(0, 0, rng.uniform(0, math.pi))))
# A few inside on the beds' footprint too — the glass fell in as well as out.
for i in range(8):
x, y = rng.uniform(-hw * 0.8, hw * 0.8), rng.uniform(-hd * 0.7, hd * 0.7)
s = rng.uniform(0.05, 0.16)
shards.append(add_box(f"{name}_shard_in_{i}", (s, s * 0.7, 0.008),
(x, y, 0.006), glass, rot=(0, 0, rng.uniform(0, math.pi))))
join_group(shards, "shards", root)
stamp(root, name, "structure")
root["broken_variant_of"] = "glasshouse_01"
root["collateral_key"] = "glasshouse"
root["collateral_value"] = GLASSHOUSE_COLLATERAL
root["collateral_label"] = "the glasshouse"
return root
# ---------------------------------------------------------------------------
# THE POOL KIT (SPRINT16 gate 5 — the compliant ring whose steel all says no)
# ---------------------------------------------------------------------------
POOL_KIT = dict(
RING_W=5.6, # fence ring, x
RING_D=4.2, # fence ring, y
FENCE_H=1.22, # AS 1926.1 minimum is 1.2 m — compliance is the flavour
POOL_W=3.6,
POOL_D=2.2,
COPING_H=0.18, # raised fibreglass lip; keeps every vertex above grade
POST=0.045, # 40 mm aluminium tube posts
)
def _pool_fence_posts():
"""Post centres round the ring, deterministic order. Module-level so the
builder and the ASSETS node list can never disagree about the count."""
P = POOL_KIT
hx, hy = P["RING_W"] / 2, P["RING_D"] / 2
pts = []
for x in (-hx, -hx / 2, 0, hx / 2, hx): # long sides, 5 each
for sy in (-1, 1):
pts.append((x, sy * hy))
for y in (-hy / 2, hy / 2): # short sides, 2 each
for sx in (-1, 1):
pts.append((sx * hx, y))
return pts # 14 posts
def build_pool_kit_01(name):
"""A backyard pool inside its compliant fence ring — the yard the palette
could not dress, and the puzzle is the fence.
SPRINT16 gate 5. Fourteen 40 mm aluminium posts stand in a perfect ring
at exactly the height a low tie line wants, core-mounted, evenly spaced —
they LOOK like the most generous anchor field in the game. Every single
one carries an explicit `tie_off: false`, because silence is not neutral:
`adoptAnchor` reads `rating_hint ?? 1`, so an unrated node a site names
becomes the best steel in the game by omission (the S14 audit lesson, and
the reason the spec says "honest on every one"). A pool fence holds a
child OUT; it does not hold a sail DOWN — it is 1.2 m of child-proofing
on core-drilled tube, and a loaded corner would have the whole panel out
of the ground before the shackle noticed.
That denial field is the level design: the pool yard's whole puzzle (arc
2) is that the legal tie-offs are all in the wrong places, and the ring
of honest nos in the middle of the yard is what makes it true.
UNPRICED, and that is a statement (the bike/jacaranda ruling, not the
glasshouse's spec order): nothing in the sim can bend a pool fence or
crack a coping yet, and billing an event the player cannot see is the lie
the invoice exists to kill. `priced: False` + the reason ride in extras;
price it the day debris can take a panel out.
"""
root = add_empty(name)
alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
water = get_material("Mat_PoolWater", PAL["pool_water"], 0.08, opacity=0.88)
P = POOL_KIT
hx, hy = P["RING_W"] / 2, P["RING_D"] / 2
pw, pd = P["POOL_W"] / 2, P["POOL_D"] / 2
# --- the pool: raised coping ring + water just below the lip -----------
join_group([
add_box(f"{name}_coping_n", (P["POOL_W"] + 0.3, 0.15, P["COPING_H"]),
(0, pd + 0.075, P["COPING_H"] / 2), conc),
add_box(f"{name}_coping_s", (P["POOL_W"] + 0.3, 0.15, P["COPING_H"]),
(0, -pd - 0.075, P["COPING_H"] / 2), conc),
add_box(f"{name}_coping_e", (0.15, P["POOL_D"], P["COPING_H"]),
(pw + 0.075, 0, P["COPING_H"] / 2), conc),
add_box(f"{name}_coping_w", (0.15, P["POOL_D"], P["COPING_H"]),
(-pw - 0.075, 0, P["COPING_H"] / 2), conc),
], "pool_shell", root)
join_group([add_box(f"{name}_water", (P["POOL_W"], P["POOL_D"], 0.02),
(0, 0, P["COPING_H"] - 0.045), water)], "pool_water", root)
# --- the fence: rails + pickets, joined; POSTS as individual named nodes
# so a site (or the editor's node list, if it ever offers per-node
# anchors) meets the denial ON the node it would name. RULE 1's regex
# only guards *_anchor names; these are guarded by their own pin in
# e.test — same rule, wider net.
gate_span = (0.25, 1.15) # gate opening on the south run, x range
rails = []
for sy in (-1, 1):
for z in (0.12, P["FENCE_H"] - 0.03):
if sy < 0: # south run has the gate opening
rails.append(add_box(f"{name}_rail_s{z:.2f}_a", (gate_span[0] + hx, 0.04, 0.04),
((gate_span[0] - hx) / 2, -hy, z), alu))
rails.append(add_box(f"{name}_rail_s{z:.2f}_b", (hx - gate_span[1], 0.04, 0.04),
((gate_span[1] + hx) / 2, -hy, z), alu))
else:
rails.append(add_box(f"{name}_rail_n{z:.2f}", (P["RING_W"], 0.04, 0.04),
(0, hy, z), alu))
for sx in (-1, 1):
for z in (0.12, P["FENCE_H"] - 0.03):
rails.append(add_box(f"{name}_rail_{'e' if sx > 0 else 'w'}{z:.2f}",
(0.04, P["RING_D"], 0.04), (sx * hx, 0, z), alu))
# Pickets every ~0.1 m — the non-climbable read.
for sy in (-1, 1):
n = int(P["RING_W"] / 0.105)
for i in range(1, n):
x = -hx + i * P["RING_W"] / n
if sy < 0 and gate_span[0] < x < gate_span[1]:
continue
rails.append(add_box(f"{name}_pk_y{sy}_{i}", (0.014, 0.014, P["FENCE_H"] - 0.14),
(x, sy * hy, (P["FENCE_H"] - 0.14) / 2 + 0.1), alu))
for sx in (-1, 1):
n = int(P["RING_D"] / 0.105)
for i in range(1, n):
y = -hy + i * P["RING_D"] / n
rails.append(add_box(f"{name}_pk_x{sx}_{i}", (0.014, 0.014, P["FENCE_H"] - 0.14),
(sx * hx, y, (P["FENCE_H"] - 0.14) / 2 + 0.1), alu))
join_group(rails, "fence_rails", root)
# The posts — fourteen honest nos, each its own node.
for i, (x, y) in enumerate(_pool_fence_posts()):
post = join_group([add_cyl(f"{name}_postmesh_{i}", P["POST"] / 2, P["FENCE_H"],
(x, y, P["FENCE_H"] / 2), alu, verts=8)],
f"fence_post_{i + 1:02d}", root)
not_a_tie_off(
post, "pool fence post — compliance steel, not rigging steel",
"40 mm core-mounted aluminium tube holding a child-proof panel; "
"it resists a climbing toddler, not a loaded sail corner — the "
"ring LOOKS like fourteen perfect anchors and every one says no")
# The gate: self-closing, opens outward, latch high — the compliance
# tells, in geometry and in data.
gx0, gx1 = gate_span
gate = join_group([
add_box(f"{name}_gate_frame_l", (0.03, 0.03, P["FENCE_H"] - 0.1),
(gx0 + 0.03, -hy - 0.06, (P["FENCE_H"] - 0.1) / 2 + 0.05), alu,
rot=(0, 0, math.radians(-14))),
add_box(f"{name}_gate_frame_r", (0.03, 0.03, P["FENCE_H"] - 0.1),
(gx1 - 0.15, -hy - 0.28, (P["FENCE_H"] - 0.1) / 2 + 0.05), alu,
rot=(0, 0, math.radians(-14))),
add_box(f"{name}_gate_top", (gx1 - gx0 - 0.1, 0.03, 0.03),
((gx0 + gx1) / 2 - 0.06, -hy - 0.17, P["FENCE_H"] - 0.08), alu,
rot=(0, 0, math.radians(-14))),
*[add_box(f"{name}_gate_pk_{i}", (0.014, 0.014, P["FENCE_H"] - 0.22),
(gx0 + 0.1 + i * (gx1 - gx0 - 0.2) / 7,
-hy - 0.08 - i * 0.028, (P["FENCE_H"] - 0.22) / 2 + 0.08), alu)
for i in range(8)],
], "pool_gate", root)
gate["self_closing"] = True
gate["opens"] = "outward, ajar — someone left it swinging, which is the tell"
gate["latch_height_m"] = 1.5
stamp(root, name, "structure")
root["priced"] = False
root["unpriced_why"] = ("nothing in the sim can bend a pool fence or crack "
"a coping yet — billing an unseen event is the lie "
"the invoice exists to kill (the bike ruling); "
"price it when debris can take a panel out")
root["compliance"] = "AS 1926.1 flavour: 1.2 m, non-climbable, self-closing outward gate"
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)
not_a_tie_off(add_empty("door_anchor", (0, -D / 2 - 0.6, 0.9), root, size=0.2),
"stand point in front of the shed doors",
"a Colorbond door skin on a sheet-metal shed; there is no "
"steel here to strap to, only 0.5 mm of cladding")
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)
not_a_tie_off(add_empty("pickup_anchor", (0, 0, H + 0.05), root, size=0.2),
"where spare hardware sits and where the hold-E prompt lands",
"a bench top, not a bollard — the table would come with you")
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` starts on; Lane A swaps by toggling .visible.
if state != "full":
hide_by_default(node)
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"
# SPRINT15 gate 1.1 RULING (THREADS [A] 2026-07-18): the honest post is
# 1.00 — the shipped value becomes canon. The 0.90 baked here from Sprint 1
# never reached the sim: every shipped post is a plain JSON entry with no
# `node`, so adoptAnchor never ran and `?? 1` won. Every measured number in
# the repo (separation pins, winnable-lines tables, night 5's whisker) was
# measured at effective 1.00; 0.90 was design intent that never shipped.
# A weaker honest post later is a DESIGN CHANGE proposed with evidence,
# wearing its own name — not a bugfix. Do not lower this without
# re-opening that ruling.
e["rating_hint"] = 1.0
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
# ---------------------------------------------------------------------------
# THE CORRODED TIER (SPRINT17 gate 3.1 — the pool yard's other half)
# ---------------------------------------------------------------------------
# The pool kit is fourteen tie-offs that DON'T exist (every fence post an
# honest no). This is the tie-off that DOES exist and shouldn't be trusted:
# a real sail post, real concrete footing, real pad eye — gone rusty.
#
# WHY 0.55, argued on the ladder it joins (0.22 carport < 0.30 carport_post <
# 0.35 fascia < 0.45 swing_frame < [0.55] < 0.65 pergola < 1.00 post):
# · ABOVE the swing frame (0.45): the footing is still 300 mm of concrete
# and the load path still reaches the ground. A sound welded frame
# standing loose on grass drags; a rusty post in a real footing holds
# until its steel gives — corroded anchored beats sound unanchored.
# · BELOW the pergola (0.65): the pergola's members are SOUND and its
# weakness (deck flex) is visible and bounded. Corrosion's weakness is
# section loss you cannot see the bottom of — the bloom on the outside is
# pitting on the inside, so the visible rust is a FLOOR on the damage,
# not a ceiling. You de-rate below sound timber because you're rating
# what you can't inspect.
# · And it FILLS the 0.45→0.65 gap: rungs create decisions (D's S15
# endorsement logic, the reason the pergola exists). With this rung the
# ladder steps 0.45 / 0.55 / 0.65 — three different kinds of "not quite":
# unanchored steel, eaten steel, flexing timber.
CORRODED_POST_RATING = 0.55
# The bill when it snaps. Proposal — Lane A confirms (my S17 prompt says rule
# it and let A confirm): a post IS the client's property, so a snapped one
# bills — "it was already scrap" is a warranty argument, not a free pass. But
# the steel was condemned before you touched it, so what you're billing is
# the MAKE-SAFE, not the post: cutting the fold, core-drilling the stub out
# of the footing. That's under the gutter's $90 (a full trade's morning run)
# and above the gnome's $25. Cheap ON PURPOSE: the real price of trusting
# corroded steel is the corner you lose mid-storm — the trap costs you in
# sail HP and garden exposure, and the invoice line is honest small change
# beside it. If the bill were big, the lesson would read "don't touch rust";
# at $45 it reads "rust is a bet, and here's the stake".
CORRODED_POST_COLLATERAL = 45
def build_sail_post_corroded(name):
"""The corroded sail-post variant — same bones as build_sail_post (H, R,
footing, rake_pivot mechanism all identical, so it rakes and places like
any post), different truth.
THE TELL, designed to read at two ranges (the bike lesson: LOOK at it,
and look from where the player actually stands):
· 20 m / colour: the whole shaft is weathered matte (steel_weathered)
against the honest post's bright galvanised, the footing collar wears
a rust stain skirt, and the cap sits askew. You don't see rust from
the back fence — you see a DIRTY post next to clean ones.
· 3 m / detail: rust blooms where posts actually rust (the base band
where water pools on the collar, the head collar around the pad-eye
weld), deep-rust fold line just above the footing, streaks bleeding
down the shaft, and the pad eye SAGS — the weld ate through and the
eye drooped toward the yard. The anchor empty sits at the DROOPED
eye, so the sag is a measured fact, not set dressing: e.test derives
the droop ANGLE back out of the exported anchor position and pins it
to `padeye_droop_deg` below. (Pinning "the corroded anchor is lower
than the honest one" would NOT have worked — the eye hangs off a weld
line 160 mm down the post, so that test reads 0.06 m and passes at
zero droop. Measure the angle, not the altitude.)
Corrosion a player can't see is a trap with no tell, and this repo
doesn't ship those — the three tells are pinned as three SEPARATE tests
in e.test.js (rust area, shaft lightness, eye droop) so that one failure
can't mask the other two. Rebuild this in clean steel at the same 0.55
hint and all three go red; flatten only the eye and only the third does.
"""
root = add_empty(name)
steel = get_material("Mat_SteelWeathered", PAL["steel_weathered"], 0.6,
metallic=0.55)
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
rust = get_material("Mat_Rust", PAL["rust_bloom"], 0.9, metallic=0.1)
rust_d = get_material("Mat_RustDeep", PAL["rust_deep"], 0.95, metallic=0.05)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
H, R = 4.0, 0.048 # same post the honest one is — that's the trap
DROOP_DEG = 38.0 # pad-eye sag off vertical. 38° is slumped
# past argument but still recognisably an eye
# you COULD clip to — which is the whole trap.
DROOP = math.radians(DROOP_DEG)
EYE_ARM = 0.08 # eye centre's distance from the weld line
WELD_Z = H - 0.16 # the slumped weld line the eye hinges at
# Footing identical to the honest post's, plus the stain skirt: rust
# bleeds onto the collar top, so even the GROUND LINE reads brown.
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),
add_cyl(f"{name}_stain", 0.23, 0.012, (0, 0, 0.148), rust_d, verts=14),
], "footing", root)
# Same rake mechanism as build_sail_post — rake is a runtime decision and
# a corroded post rakes like any other; the lean you might be tempted to
# bake here would double-apply the moment a player rakes it.
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),
# The cap sits askew — rust let the friction fit go. Silhouette tell.
add_cyl(f"{name}_cap", R * 1.2, 0.02, (0, 0, H + 0.008), dark, verts=12,
rot=(math.radians(14), 0, 0)),
], "post"))
# The corrosion itself, its own node so the mutation control can kill it
# cleanly: blooms slightly proud of the shaft (rust swells), streaks
# bleeding DOWN from each bloom. Positions are hand-placed, not rng — the
# blooms sit where posts actually rust, and determinism costs nothing.
corrosion = [
add_cyl(f"{name}_rust_base", R + 0.004, 0.34, (0, 0, 0.34), rust, verts=12),
add_cyl(f"{name}_rust_line", R + 0.007, 0.11, (0, 0, 0.185), rust_d, verts=12),
add_cyl(f"{name}_rust_head", R + 0.004, 0.26, (0, 0, H - 0.16), rust, verts=12),
add_cyl(f"{name}_rust_weld", R + 0.006, 0.06, (0, 0, WELD_Z), rust_d, verts=12),
]
for i, (ang_deg, length, z_top) in enumerate([
(200, 1.45, H - 0.24), # off the weld collar, yard side (-y)
(335, 0.95, H - 0.30),
(25, 0.70, H - 0.35),
(160, 0.80, 0.95), # wicking up off the base band
(300, 0.60, 0.85)]):
a = math.radians(ang_deg)
corrosion.append(add_box(
f"{name}_streak_{i}", (0.016, 0.016, length),
(math.cos(a) * (R + 0.003), math.sin(a) * (R + 0.003),
z_top - length / 2), rust_d, rot=(0, 0, a)))
above.append(join_group(corrosion, "rust"))
# The sagging pad eye: plate and ring both pitched DROOP about the weld
# line, hanging toward -y (three.js +Z, the yard side — same face the
# pergola presents). Built in the tilted plane directly, no post-rotation.
ring_c = (0, -math.sin(DROOP) * EYE_ARM,
WELD_Z + math.cos(DROOP) * EYE_ARM)
eye_parts = [add_box(f"{name}_padeye", (0.012, 0.07, 0.09),
(0, -math.sin(DROOP) * EYE_ARM / 2,
WELD_Z + math.cos(DROOP) * EYE_ARM / 2), rust_d,
rot=(-DROOP, 0, 0))]
r_eye, segs = 0.026, 10
pts = []
for i in range(segs + 1):
t = math.tau * i / segs
pts.append((math.cos(t) * r_eye,
ring_c[1] - math.sin(t) * r_eye * math.sin(DROOP),
ring_c[2] + math.sin(t) * r_eye * math.cos(DROOP)))
for i in range(segs):
eye_parts.append(add_tube_between(f"{name}_eye_s{i}", pts[i], pts[i + 1],
0.008, rust, verts=8))
above.append(join_group(eye_parts, "pad_eye"))
# The anchor sits AT the drooped eye — the sag is where you'd clip on,
# so the sag is data the sim sees, not paint.
e = add_empty("top_anchor", ring_c, size=0.2)
e["anchor_type"] = "corroded_post"
e["rating_hint"] = CORRODED_POST_RATING
e["collateral"] = "corroded_post"
e["why"] = ("a real post in a real footing, eaten: above the swing frame "
"(0.45) because concrete beats loose-on-grass, below the "
"pergola (0.65) because the bloom outside is pitting inside — "
"you de-rate what you can't inspect. The rung fills 0.45→0.65; "
"rungs create decisions")
above.append(e)
for o in above:
parent_keep_transform(o, rake)
stamp(root, name, "structure")
root["post_height"] = H
root["rake_note"] = "rotate about rake_pivot; rake away from the load"
root["collateral_key"] = "corroded_post"
root["collateral_value"] = CORRODED_POST_COLLATERAL
root["collateral_label"] = "the corroded post"
root["breakable"] = True
root["corrosion_note"] = ("the tell is load-bearing: e.test pins rust "
"area, shaft dullness and the eye droop — a "
"clean-steel rebuild at the same hint goes red")
# THE AXIS TRAP (E's swing_set precedent, fourth application). The droop
# is an ORIENTATION CLAIM, and orientation claims in this repo are the
# ones that rot: the exporter maps Blender (x,y,z) → three.js (x,z,y), so
# a docstring saying "-y" and a mesh leaning +Z agree only by luck. These
# extras are stated in THREE.JS coords and e.test recomputes them off the
# exported geometry — so note and mesh can only ever lie together.
# Blender ring_c (0, sinD·ARM, WELD_Z+cosD·ARM)
# → three.js (0, WELD_Z+cosD·ARM, +sinD·ARM)
# i.e. the eye hangs toward +Z, the yard side, the face the pergola
# presents — so the sag is pointed at the player who has to judge it.
root["padeye_droop_deg"] = DROOP_DEG
root["padeye_arm_m"] = EYE_ARM
root["padeye_droop_dir_threejs"] = "+Z"
root["padeye_weld_y_threejs"] = WELD_Z # blender z → three.js y
root["padeye_droop_note"] = (
"top_anchor sits EYE_ARM from the weld line, pitched DROOP_DEG off "
"vertical toward three.js +Z (the yard). e.test derives the angle "
"back out of the exported anchor position and pins it to this number: "
"flatten the eye and the note goes red with it")
return root
def build_sail_post_corroded_wrecked(name):
"""The corroded post after a loaded corner won: FOLDED at the deep-rust
line just above the footing (where the intact variant wears its worst
band — the wreck fails exactly where the tell said it would), stub
standing plumb with a torn rim, the rest of the post flat on the grass
reaching ~3.8 m into the yard (-y here, three.js +Z), pad eye still
drooped at the far end with nothing left to hold. Same footing, same
numbers, same price as its twin (the carport/gutter chain, application
six). No anchor empty survives — an anchor that outlives its structure
is the free-failure bug in a costume (fascia rule, fourth application)."""
root = add_empty(name)
steel = get_material("Mat_SteelWeathered", PAL["steel_weathered"], 0.6,
metallic=0.55)
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
rust = get_material("Mat_Rust", PAL["rust_bloom"], 0.9, metallic=0.1)
rust_d = get_material("Mat_RustDeep", PAL["rust_deep"], 0.95, metallic=0.05)
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
H, R = 4.0, 0.048
FOLD = 0.46 # it let go at the rust line, not the weld:
# the fold is where the section was thinnest
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),
add_cyl(f"{name}_stain", 0.23, 0.012, (0, 0, 0.148), rust_d, verts=14),
], "footing", root)
# The stub: plumb (the footing held — that half of the 0.55 was honest),
# deep rust at the fold, torn rim leaning the way the post went.
join_group([
add_cyl(f"{name}_stub", R, FOLD, (0, 0, 0.14 + FOLD / 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}_stub_rust", R + 0.007, 0.14, (0, 0, 0.14 + FOLD - 0.07),
rust_d, verts=12),
add_cone(f"{name}_tear", R + 0.004, 0.012, 0.09,
(0.005, -0.02, 0.14 + FOLD + 0.035), rust_d, verts=10,
rot=(math.radians(18), 0, 0)),
], "post_stub", root)
# The fallen length, resting on its radius, slightly yawed — nothing real
# falls on axis. Blooms ride along it where the intact bands were.
L = H - FOLD
yaw = math.radians(7)
rotv = (math.radians(90), 0, yaw)
def along(s): # a point s metres from the break end, down the fallen shaft
return (-math.sin(yaw) * (0.30 + s), -math.cos(yaw) * (0.30 + s), R)
down = [
add_cyl(f"{name}_shaft_down", R, L, along(L / 2), steel, verts=12, rot=rotv),
add_cyl(f"{name}_down_break", R + 0.007, 0.18, along(0.10), rust_d,
verts=12, rot=rotv),
add_cyl(f"{name}_down_mid", R + 0.004, 0.30, along(L * 0.55), rust,
verts=12, rot=rotv),
add_cyl(f"{name}_down_head", R + 0.004, 0.26, along(L - 0.35), rust,
verts=12, rot=rotv),
add_cyl(f"{name}_down_cap", R * 1.2, 0.02, along(L - 0.01), dark,
verts=12, rot=rotv),
]
join_group(down, "post_down", root)
# The pad eye at the far end, on the grass, still drooped, still rusty —
# the one part of this post that was never going to hold anything again.
ex, ey, _ = along(L - 0.10)
eye = [add_box(f"{name}_padeye_down", (0.012, 0.09, 0.07),
(ex + 0.09, ey, 0.035), rust_d,
rot=(0, math.radians(80), 0)),
add_arc_tube(f"{name}_eye_down", 0.026, 0.008, 0, math.tau, rust,
segs=10, center=(ex + 0.16, ey, 0.012), plane='XY')]
join_group(eye, "pad_eye_down", root)
stamp(root, name, "structure")
root["broken_variant_of"] = "sail_post_corroded"
root["collateral_key"] = "corroded_post"
root["collateral_value"] = CORRODED_POST_COLLATERAL
root["collateral_label"] = "the corroded post"
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
# SPRINT14 audit — the trampoline is UNPRICED, and that is a statement, not
# an omission. It carries no anchor node (nothing here is a tie-off: a rim
# on six unpegged legs is the least trustworthy steel in any yard), and
# nothing in the runtime spawns it yet, so no player-visible event can
# destroy it. Same ruling as the bike: billing collateral for a thing the
# player never sees break is the lie the invoice exists to kill. If Lane C
# ever throws one, price it THEN — the number is easy, the event is the
# hard part.
root["priced"] = False
root["unpriced_why"] = "no anchor, and nothing in the sim can wreck it yet"
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_bike_kid_01(name):
"""A kid's 16-inch bike, dropped against the fence (SPRINT11 §Lane E — the
per-client prop). The Hendersons have a kid; the kid has a bike; the bike is
against the fence because that is where bikes live. It exists to make the
job sheet's brief — "the seedlings have to be alive when we get back" — land
on a yard that visibly belongs to somebody.
LEAN IS BAKED IN, and that's deliberate. A bike does not stand up on its own,
so an upright GLB would be a prop that reads as a bug the moment it's placed.
The whole thing is built through `_tilt()`, a rotation about X applied to
every point at author time.
The origin is the ground line, and the tilt is about the X axis THROUGH that
origin — so the tyre contact points (z = 0) map to themselves and the bike
sits on the ground, not through it or above it. Drop it at ground level like
the gnome and rotate about the up axis to aim it.
** WHICH WAY IT LEANS — read this before you place it, Lane A. **
In THIS file the bike leans toward +Y, because Blender is Z-up. You do not
work in Blender. The exporter maps Blender (x, y, z) -> glTF (x, z, -y), so
in three.js the bike leans toward **-Z**, and -Z is the side the fence goes.
Put the fence on +Z and the bike will lean away from it into thin air, which
is a bug that looks exactly like a physics bug and isn't one.
I wrote "+Y is the fence side" here first and it would have been a lie by the
time it reached you — this is the axis trap the header of e.test.js exists to
catch, and it caught me writing the docstring, not the geometry. There's now
an assert in that file pinning the lean to -Z in browser coords, so if anyone
ever un-tilts this or flips the export, the suite says so instead of you
finding out by eye. Stand it ~0.10 m off the palings — the bars, not the
tyres, are what touch a fence.
Not breakable and not priced: it's juice, not a trap. See my THREADS note if
you want it to be collateral — that's your call and it has a number in it.
"""
root = add_empty(name)
frame_m = get_material("Mat_BikeFrame", PAL["bike_kid"], 0.55)
tyre_m = get_material("Mat_BikeTyre", PAL["mat_black"], 0.9)
steel = get_material("Mat_SteelDark", PAL["steel_dark"], 0.5, metallic=0.6)
grip_m = get_material("Mat_BikeGrip", PAL["bike_grip"], 0.85)
# The lean. 11° about X tips the bike toward +Y. Shallow on purpose: past
# about 15° it reads as "knocked over" rather than "parked", and this bike
# is meant to be resting, not already a casualty of the storm.
lean = math.radians(-11.0)
cl, sl = math.cos(lean), math.sin(lean)
def _tilt(p):
x, y, z = p
return (x, y * cl - z * sl, y * sl + z * cl)
R = 0.203 # 16" wheel: 406 mm diameter, the real kid-bike size
AX = 0.355 # axle from centre — 0.71 m wheelbase
# --- wheels. arc_points gives me the rim in the upright XZ plane; every
# point then goes through _tilt, which is the same thing add_arc_tube does
# internally minus the lean. Ten segments reads round at yard distance and
# keeps the pair under ~450 tris.
for side, cx in (("rear", -AX), ("front", AX)):
pts = arc_points(R, 0, math.tau, 10, center=(cx, 0, R), plane='XZ')
segs = [add_tube_between(f"{name}_{side}_tyre_s{i}", _tilt(pts[i]),
_tilt(pts[(i + 1) % len(pts)]), 0.019, tyre_m, verts=6)
for i in range(len(pts))]
segs.append(add_tube_between(f"{name}_{side}_hub", _tilt((cx, -0.028, R)),
_tilt((cx, 0.028, R)), 0.016, steel, verts=6))
join_group(segs, f"wheel_{side}", root)
# --- frame. Upright coordinates, tilted on the way in.
#
# It's a STEP-THROUGH: the tube from the head drops to the MIDDLE of the seat
# tube instead of running level to the top of it. That dropped diagonal is
# the entire reason this reads as a kid's bike from across the yard rather
# than as a small adult one — at 20 m you cannot judge absolute size, so the
# silhouette has to carry it. Scale alone would not.
#
# First pass built a diamond frame while the comment above it claimed a
# step-through, and I only caught it by looking at the render — the dims
# passed either way, because "is it the right shape" is not something a
# bounding box can answer. Same lesson as the Sprint 10 phantom post.
bb = (-0.05, 0, 0.175) # bottom bracket
seat_top = (-0.165, 0, 0.600)
head_top = (0.245, 0, 0.615)
head_bot = (0.285, 0, 0.430)
# 45% up the seat tube — where the step-through's diagonal lands.
step_join = (-0.102, 0, 0.366)
tubes = [
("seat_tube", bb, seat_top, 0.017),
("down_tube", bb, head_bot, 0.018),
("step_tube", head_top, step_join, 0.015), # the step-through drop
("chain_stay", bb, (-AX, 0, R), 0.012),
("seat_stay", seat_top, (-AX, 0, R), 0.011),
# The fork is RAKED — the axle sits ~21 mm FORWARD of the head tube's
# axis extended to axle height. Sign matters and I got it backwards
# first: with the axle behind that line, head tube and fork drew as one
# straight pole down through the front wheel, which is a pogo stick with
# wheels. Rake is what puts the bend in the silhouette.
("fork", head_bot, (AX, 0, R), 0.013),
("head_tube", head_bot, head_top, 0.016),
]
parts = [add_tube_between(f"{name}_{n}", _tilt(a), _tilt(b), r, frame_m, verts=6)
for (n, a, b, r) in tubes]
join_group(parts, "frame", root)
# --- the bits a kid actually touches. Bars sit across Y; the grips are the
# only two things on this bike that are worn, and they're the reason it
# reads as USED rather than as a showroom asset dropped in a yard.
bars = [
add_tube_between(f"{name}_stem", _tilt(head_top), _tilt((0.225, 0, 0.685)),
0.013, steel, verts=6),
add_tube_between(f"{name}_bar", _tilt((0.225, -0.155, 0.685)),
_tilt((0.225, 0.155, 0.685)), 0.012, steel, verts=6),
add_tube_between(f"{name}_grip_l", _tilt((0.225, -0.155, 0.685)),
_tilt((0.225, -0.095, 0.685)), 0.016, grip_m, verts=6),
add_tube_between(f"{name}_grip_r", _tilt((0.225, 0.095, 0.685)),
_tilt((0.225, 0.155, 0.685)), 0.016, grip_m, verts=6),
add_tube_between(f"{name}_saddle", _tilt((-0.200, 0, 0.615)),
_tilt((-0.130, 0, 0.622)), 0.030, grip_m, verts=6),
add_tube_between(f"{name}_crank", _tilt((-0.05, -0.075, 0.175)),
_tilt((-0.05, 0.075, 0.175)), 0.010, steel, verts=6),
]
join_group(bars, "bars", root)
stamp(root, name, "prop")
# A 16" kid's bike is ~8 kg. Lane A/C: this is here so the wind CAN have it
# later — a bike that ignores a 32 m/s gust is a worse lie than no bike.
root["mass_hint"] = 8.0
root["breakable"] = False
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"
not_a_tie_off(g, "where the player's hand takes the broom",
"a carry point on a 1.2 kg tool; it is the thing that blows "
"away, not the thing that holds")
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_moon_texture():
"""Moon disc + halo for the night storms (SPRINT6 §Lane E-1).
The halo is the point, not the disc. On storm_02 the moon sits behind a
cloud dome and what you'd actually see is a bright smear; on storm_01 the
disc resolves. One sprite does both — Lane C fades opacity with cloud cover
and the halo carries the low end.
"""
import numpy as np
SIZE = 256
Y, X = np.mgrid[0:SIZE, 0:SIZE]
c = (SIZE - 1) / 2.0
nx, ny = (X - c) / c, (Y - c) / c
r = np.sqrt(nx * nx + ny * ny)
R_DISC = 0.34
disc = np.clip((R_DISC - r) / 0.02, 0, 1) # soft-edged disc
halo = np.exp(-((r - R_DISC) ** 2) / 0.045) * 0.55 # the bit that survives cloud
halo = np.where(r > R_DISC, halo, 0.0)
# Maria: a few soft dark blotches, seeded. Craters at this size are a lie —
# what you see from a back yard is patches.
shade = np.ones_like(r)
mrng = rng_for("moon_maria")
for _ in range(6):
mx, my = mrng.uniform(-0.18, 0.18), mrng.uniform(-0.18, 0.18)
mr = mrng.uniform(0.06, 0.13)
d = np.sqrt((nx - mx) ** 2 + (ny - my) ** 2)
shade -= np.exp(-(d ** 2) / (mr ** 2)) * mrng.uniform(0.06, 0.13)
shade = np.clip(shade, 0.72, 1.0)
a = np.clip(disc + halo, 0, 1)
lum = np.clip(np.where(disc > 0, shade, 1.0), 0, 1)
img = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
img[:, :, 0] = lum * 0.96
img[:, :, 1] = lum * 0.97
img[:, :, 2] = lum
img[:, :, 3] = a
p, kb = save_png(img, "moon")
print(f" moon.png {SIZE}x{SIZE}, disc+halo, {kb} KB")
return p
# ============================================================================
# END CARDS — rendered from the game's own props, not drawn (SPRINT6 §Lane E-2)
# ============================================================================
def _card_scene(state):
"""One vignette, built from the same yard objects the player spent the week
protecting — that's the whole idea. A gradient with a font on it says
nothing; the gnome you failed to protect, lying in pieces, says it without a
word.
Three endings, one camera (SPRINT9 gate 1 adds the pyrrhic):
· win — everything stands, dawn. You got away with it.
· pyrrhic — the bed is green and the gnome is fine, but a post is on its
face and the fence is gone. DESIGN.md's actual thesis: the sail is not
the point, the garden is, and a rig that dies saving it did its job.
Warm light, because you WON — the wreckage is the price, not the verdict.
· lose — gnome in bits, bed dead, flat grey. Nothing was saved.
"""
reset_to_empty()
lost = state == "lose"
wrecked = state != "win" # the fence goes in both bad endings
grass = get_material("Mat_Grass", "#4A6B36" if lost else "#5C8A3A", 1.0)
add_box("ground", (60, 60, 0.4), (0, 0, -0.2), grass)
if lost:
gnome = build_garden_gnome_01_broken("gnome")
gnome.rotation_mode = 'XYZ'
gnome.rotation_euler = (0, 0, math.radians(-24))
else:
gnome = build_garden_gnome_01("gnome")
gnome.location = (0.82, -0.78, 0)
fence = (build_fence_panel_snapped if wrecked else build_fence_panel)("fence")
fence.location = (-0.35, 2.6, 0)
bed = build_garden_bed("bed")
bed.location = (-3.4, 1.2, 0)
if lost:
for o in bpy.data.objects: # only a loss shows the dead bed
if o.name == "plants_full":
o.hide_render = True
elif o.name == "plants_dead":
o.hide_render = False
if state == "pyrrhic":
# The post came out of the ground rather than the shackle letting go —
# DESIGN.md's "a post in wet ground pulls out slowly, with creaking".
# Rotating the ROOT (not rake_pivot) tips the footing with it, which is
# exactly right here: the concrete is out of the dirt.
#
# Placement is the whole trick, and three measured failures got here:
# stood up, the head sits 41.7 deg above the camera axis when the
# vertical half-FOV is 11.4, so it clips off the top; based at the fence
# line the footing hides behind the palings; and Rx(t) sends +Z to
# (0, -sin t, cos t), so a NEGATIVE angle lays it AWAY from camera and
# out of shot. Flat, forward of the fence, crossing behind the gnome:
# the rig let go, he didn't.
post = build_sail_post("post_down")
post.location = (2.60, 2.00, 0.05)
post.rotation_mode = 'XYZ'
post.rotation_euler = (math.radians(88), 0, math.radians(-60))
elif state == "win":
p = build_fence_post("post")
p.location = (0.85, 2.6, 0)
shed = build_shed_01("shed")
shed.location = (3.6, 2.2, 0)
def build_end_cards():
"""NOT part of the default build — pass `--cards`.
EEVEE's SHADOW rendering is not byte-reproducible across processes in
Blender 5.1: two identical runs of the same scene give slightly different
pixels. Measured, with a minimal cube-plus-plane repro, and it happens even
with a hard (0-degree) sun. The contact sheet escapes it only because its
thumbnails have no ground plane to receive a shadow — verified still
byte-identical 3/3.
The long raking dawn shadow IS the win card's mood, so dropping shadows to
win determinism would be trading the art for the guarantee. Instead these
are treated as what they are: art, rendered deliberately and committed, not
build output. Everything the game actually loads — every GLB and every
generated texture — stays byte-identical on every run, which is the promise
other lanes rely on.
"""
out = []
for name, state in (("card_win", "win"),
("card_pyrrhic", "pyrrhic"),
("card_gameover", "lose")):
_card_scene(state)
warm = state != "lose"
scn = bpy.context.scene
scn.render.engine = 'BLENDER_EEVEE'
scn.render.resolution_x, scn.render.resolution_y = 1200, 675
scn.render.image_settings.file_format = 'JPEG'
scn.render.image_settings.quality = 88
scn.world = bpy.data.worlds.new(f"W_{name}")
scn.world.use_nodes = True
bg = scn.world.node_tree.nodes.get("Background")
if bg:
# Dawn after a night you survived, vs the flat grey of a morning you
# have to explain to a client. The pyrrhic card gets the dawn: it is
# a win, and the light should say so before the text does.
bg.inputs[0].default_value = ((0.92, 0.55, 0.32, 1.0) if warm
else (0.34, 0.37, 0.41, 1.0))
# Low strength on purpose. The world is a huge ambient fill, and at
# 1.15 it flooded both cards flat — the sun stopped casting anything
# and dawn read as fog. Keep the fill down and let the sun do it.
bg.inputs[1].default_value = 0.30 if warm else 0.42
bpy.ops.object.light_add(type='SUN', location=(-6, -8, 4))
sun = _active()
sun.data.energy = 7.0 if warm else 2.6
sun.data.angle = math.radians(2 if warm else 40) # crisp dawn vs overcast
sun.data.color = (1.0, 0.78, 0.52) if warm else (0.82, 0.86, 0.92)
sun.rotation_mode = 'XYZ'
# Low and raking on the win card: the long shadows are the mood.
sun.rotation_euler = ((math.radians(74), 0, math.radians(-52)) if warm
else (math.radians(28), 0, math.radians(30)))
bpy.ops.object.camera_add(location=(0, 0, 0))
cam = _active()
cam.data.lens = 50
scn.camera = cam
# IDENTICAL camera on every card, deliberately. They're a set: same yard,
# same framing, and the only thing that changed overnight is what happened
# to it. A player who has seen one reads the next instantly, which no
# amount of headline text would do as fast. Low and close, so the gnome is
# the subject; the left third stays empty for Lane A's text.
cam.location = (2.00, -2.86, 0.94)
cam.rotation_mode = 'XYZ'
target = Vector((0.66, -0.42, 0.30))
cam.rotation_euler = (target - cam.location).to_track_quat('-Z', 'Y').to_euler()
os.makedirs(TEXTURES_DIR, exist_ok=True)
out_path = os.path.join(TEXTURES_DIR, f"{name}.jpg")
scn.render.filepath = out_path
bpy.ops.render.render(write_still=True)
kb = os.path.getsize(out_path) // 1024
print(f" {name}.jpg{' ' * max(1, 18 - len(name))}1200x675, from props, {kb} KB")
out.append(out_path)
reset_to_empty()
return out
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"]),
# The second species (SPRINT14). Broader than it is tall — if x/y ever
# measure under the height, someone has quietly turned it back into a gum.
dict(name="tree_jacaranda_01", fn=build_tree_jacaranda_01,
dims=((7.0, 8.2), (6.1, 7.2), (5.7, 6.5)),
nodes=["trunk", "canopy", "canopy_01", "canopy_02", "canopy_03",
"canopy_04", "branch_anchor_01", "branch_anchor_02",
"branch_anchor_03"]),
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",
"window_glow", "window_light_anchor",
"fascia_anchor_01", "fascia_anchor_02", "fascia_anchor_03"]),
# The torn-gutter aftermath (SPRINT12 §gate 3.3). Same height as the intact
# house — a house doesn't fall, only its eave line does — but DEEPER (y),
# because the right gutter run and the fascia offcuts are on the grass out
# in the yard. If y ever measures ~1.2 again someone un-tore it.
dict(name="house_yardside_wrecked", fn=build_house_yardside_wrecked,
dims=((9.0, 9.9), (1.7, 2.6), (2.8, 3.3)),
nodes=["wall", "door", "window", "roof", "window_glow",
"fascia_torn", "gutter_torn", "gutter_down",
"downpipe_loose", "debris_fascia"]),
# site_02 (corner block): the anchor trap, not just dressing.
dict(name="carport_01", fn=build_carport_01,
dims=((3.0, 3.5), (5.2, 5.8), (2.4, 2.8)),
nodes=["footings", "posts", "beams", "roof",
"beam_anchor_01", "beam_anchor_02",
"post_anchor_01", "post_anchor_02"]),
# Wider than the intact carport (the sheet goes downwind) but SHORTER — a
# wreck that stands taller than the thing it was is a bug, not a wreck.
dict(name="carport_01_wrecked", fn=build_carport_01_wrecked,
dims=((4.5, 6.6), (5.2, 5.8), (1.9, 2.45)),
nodes=["footings", "posts", "beams", "roof_down"]),
# The temptation prop (SPRINT14). Spans the crossbar on x, splays on y,
# and stands a shade over head height — the three numbers that make the
# rail look like an anchor.
dict(name="swing_set_01", fn=build_swing_set_01,
dims=((2.30, 2.45), (0.85, 1.05), (2.00, 2.15)),
nodes=["frame", "crossbar", "swings",
"frame_anchor_01", "frame_anchor_02"]),
# Over on its side: it reaches FURTHER on y than it ever stood on z. A
# wreck that still measures ~2.05 tall is a wreck that never fell.
dict(name="swing_set_01_wrecked", fn=build_swing_set_01_wrecked,
dims=((2.30, 2.45), (2.30, 3.20), (0.80, 1.05)),
nodes=["frame", "crossbar", "swings"]),
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"]),
# The per-client prop. Leaning, so the Y span is wider and the Z shorter than
# an upright bike's — that asymmetry IS the lean, and if it ever measures
# square again someone has quietly un-tilted it.
dict(name="bike_kid_01", fn=build_bike_kid_01,
dims=((1.00, 1.25), (0.24, 0.52), (0.60, 0.84)),
nodes=["wheel_rear", "wheel_front", "frame", "bars"]),
# 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"]),
# SPRINT16 gate 5 — the next temptations. The pergola is the ladder's
# honest middle rung (D-endorsed, S15 THREADS): TALLER than the swing set
# it out-rates, and its z floor is above the swing's 2.15 ceiling on
# purpose — if it ever measures swing-height, someone shrank the rung.
dict(name="pergola_01", fn=build_pergola_01,
dims=((3.1, 3.4), (2.4, 2.8), (2.55, 2.85)),
nodes=["deck", "posts", "beams", "rafters",
"pergola_anchor_01", "pergola_anchor_02"]),
# The wreck keeps the deck footprint but spills the torn beam past the
# edge (x floor ABOVE the intact deck's 3.2) and its roof line is the
# rafters hinged down — near intact height at the surviving back beam.
dict(name="pergola_01_wrecked", fn=build_pergola_01_wrecked,
dims=((3.2, 3.6), (2.4, 2.8), (2.55, 2.85)),
nodes=["deck", "posts", "beams", "beam_down", "rafters_down"]),
# The collateral apex. Narrow bands on z because the ridge IS the claim:
# a glasshouse that grew would stop reading as the small fragile thing.
dict(name="glasshouse_01", fn=build_glasshouse_01,
dims=((2.5, 2.8), (1.8, 2.1), (2.15, 2.40)),
nodes=["frame", "panes", "door", "ridge_anchor"]),
# Emptied, not felled: SAME height band as the intact one (the frame is
# aluminium and stands), but the shard field throws the plan dims WIDE —
# the x/y floors sit above the intact ceilings, which is the "it emptied
# outward" claim as a number.
dict(name="glasshouse_01_wrecked", fn=build_glasshouse_01_wrecked,
dims=((3.4, 5.4), (2.6, 3.8), (2.15, 2.40)),
nodes=["frame", "door", "panes_left", "shards"]),
# The compliance ring. z is pinned tight around 1.2 m because the height
# IS the flavour (AS 1926.1); y reaches past the ring for the ajar gate.
dict(name="pool_kit_01", fn=build_pool_kit_01,
dims=((5.4, 5.9), (4.1, 4.8), (1.12, 1.32)),
nodes=["pool_shell", "pool_water", "fence_rails", "pool_gate",
"fence_post_01", "fence_post_14"]),
# SPRINT17 gate 3.1 — the corroded tier. The intact variant must stand
# POST height (its lie is "I'm a sail post", so it stands like one; only
# the colour and the drooped eye say otherwise). The wreck's z ceiling is
# under a metre because the post FOLDED at the rust line — a corroded
# wreck still standing tall is a wreck that never told the truth — and
# its y floor is past 3.5 because the fallen shaft is out in the yard.
dict(name="sail_post_corroded", fn=build_sail_post_corroded,
dims=((0.40, 0.60), (0.40, 0.65), (3.90, 4.15)),
nodes=["footing", "post", "rust", "pad_eye", "top_anchor",
"rake_pivot"]),
dict(name="sail_post_corroded_wrecked", fn=build_sail_post_corroded_wrecked,
dims=((0.40, 0.80), (3.50, 4.40), (0.45, 0.95)),
nodes=["footing", "post_stub", "post_down", "pad_eye_down"]),
]
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 extract_anchor_meta(objs):
"""The anchor-family custom props (`anchor_type`, `rating_hint`, `tie_off`,
`collateral`), read off the RE-IMPORTED GLB rather than the build graph —
so the report describes what actually shipped through the exporter, not
what this script intended. Same provenance rule as the dims/nodes checks.
"""
out = []
for o in objs:
keys = set(o.keys())
if "anchor_type" not in keys and "tie_off" not in keys:
continue
m = {"node": o.name}
if "anchor_type" in keys:
m["anchor_type"] = str(o["anchor_type"])
if "rating_hint" in keys:
m["rating_hint"] = round(float(o["rating_hint"]), 4)
if "tie_off" in keys:
m["tie_off"] = bool(o["tie_off"])
if "collateral" in keys:
m["collateral"] = str(o["collateral"])
out.append(m)
return sorted(out, key=lambda m: m["node"])
def build_ratings_manifest(report):
"""Collapse the per-asset anchor meta into per-TYPE ratings the runtime can
resolve. The contract:
· `tie_off: false` nodes are DENIALS, not anchors — excluded.
· a type where every rated node in the palette agrees on
(rating_hint, collateral) is UNAMBIGUOUS and lands in `types` — a
node-less JSON anchor of that type takes these extras at runtime.
· a type whose nodes disagree (tree: 1.0/0.88/0.76 down the trunk) is
AMBIGUOUS — listed with every variant so the runtime can refuse to
guess. A node-less anchor of an ambiguous type is a site bug; e.test
pins that no shipped site declares one.
· a node that carries `anchor_type` but no `rating_hint` and no denial
violates RULE 1 (no silent anchors) and fails the build loudly here,
before the GLB can ship.
"""
per_type = {}
for a in report:
for m in a.get("anchors", []):
if m.get("tie_off") is False:
continue # explicit denial: not an anchor
ty = m.get("anchor_type")
if ty is None:
continue
if "rating_hint" not in m:
raise RuntimeError(
f"{a['name']}/{m['node']} carries anchor_type={ty!r} with "
f"no rating_hint and no tie_off denial — RULE 1 (no silent "
f"anchors). Rate it or not_a_tie_off() it.")
key = (m["rating_hint"], m.get("collateral"))
per_type.setdefault(ty, {}).setdefault(key, []).append(
f"{a['name']}/{m['node']}")
types, ambiguous = {}, {}
for ty in sorted(per_type):
variants = per_type[ty]
if len(variants) == 1:
(hint, coll), sources = next(iter(variants.items()))
entry = {"rating_hint": hint, "sources": sorted(sources)}
if coll is not None:
entry["collateral"] = coll
types[ty] = entry
else:
ambiguous[ty] = {"variants": [
dict(rating_hint=h, sources=sorted(srcs),
**({"collateral": c} if c is not None else {}))
for (h, c), srcs in sorted(variants.items(),
key=lambda kv: (-kv[0][0],
kv[0][1] or ""))
]}
return {"types": types, "ambiguous": ambiguous}
def write_ratings_module(manifest):
body = json.dumps(manifest, indent=2, sort_keys=True)
header = (
"// GENERATED by tools/blender/build_yard_assets.py — do NOT hand-edit.\n"
"// Regenerate: blender -b -P tools/blender/build_yard_assets.py\n"
"//\n"
"// SPRINT15 gate 1.2 — the wire between the factory and the sim.\n"
"// adoptAnchor only carries a baked rating_hint to anchors that name a\n"
"// GLB `node`; a plain-JSON anchor (every shipped sail post) defaulted\n"
"// to `?? 1` regardless of the bake, and nobody could see the\n"
"// disagreement. This module is the factory's bakes, resolvable by\n"
"// anchor TYPE at runtime: `types` are unambiguous (every rated node\n"
"// of that type agrees), `ambiguous` lists the disagreements so the\n"
"// runtime can refuse to guess. ratings.js is the reader; e.test.js\n"
"// pins runtime === this module === the GLBs, so none of the three\n"
"// can drift from the others silently again.\n"
)
with open(RATINGS_GEN_JS, "w") as f:
f.write(header + "export const FACTORY_ANCHOR_RATINGS = "
+ "Object.freeze(" + body + ");\n")
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), anchors=extract_anchor_meta(objs),
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, cards = None, False, 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
if "--cards" in argv:
cards = True
return only, no_verify, no_debris, cards
def main():
only, no_verify, no_debris, cards = 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()
build_moon_texture()
if cards:
build_end_cards()
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}")
# The runtime manifest is only honest when built from the WHOLE
# palette — an --only run would collapse "every node of this type
# agrees" to a sample of one and could flip an ambiguous type to
# unambiguous. So partial runs leave it untouched, loudly.
if only is None:
write_ratings_module(build_ratings_manifest(report))
print(f" ratings -> {RATINGS_GEN_JS}")
else:
print(" ratings -> NOT rewritten (--only run is a partial "
"palette; run the full factory to regenerate)")
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()