SPRINT14 gate 3.1. The editor is about to offer these GLBs to an author by name, which turns every silent field in them into a trap for whoever places one. Audited the placeable set, fixed the gaps in the factory (nothing hand-edited), and added the two props D needs to author a yard worth playing. THE AUDIT, and what it found: - Four `*_anchor` nodes carried no rating_hint at all — door_anchor, pickup_anchor, grip_anchor, window_light_anchor. adoptAnchor does `rating_hint ?? 1`, so silence there does not mean "unrated", it means RATED PERFECT: a shed door skin outranking a concreted post, conjured out of a missing field. They now deny it explicitly (`tie_off: false`). - The carport's price resolved only by coincidence: collateralFor() matches a STRUCTURE whose site id equals the anchors' collateral string, and site_02 happens to name its structure "carport". The editor will generate "carport_2" for the second one, and the trap would have gone free — the gutter bug reborn in the sprint meant to bury it. The GLB now carries `collateral_key`, like the house does for "gutter". Lane A: the runtime half is yours (collateralFor could fall back to it). - The trampoline is unpriced, and now says so rather than omitting it. Three new rules make the audit permanent, and each is proven able to fail: no silent anchors, no collateral string nobody prices, no baked anchor_type outside the checked ANCHOR_TYPE enum. Rule 2 found the carport gap on its first run — an unfaked negative control. THE PROPS: - swing_set_01 (+ wrecked) — the palette's honest middle. It had a ceiling (gum fork 1.0) and a trap (carport beam 0.22) and nothing in between. Two apex anchors at 0.45, typed `swing_frame` (NOT `post`: the type string is what the player reads before committing, and "post" promises concrete), and a crossbar that is the most anchor-looking object in the game and is not one. Priced at $140 — proposal, reasoning baked beside it, A rules the number: gnome 25 < gutter 90 < swing 140 < carport 180. - tree_jacaranda_01 — a second species whose LADDER is the feature. Gum: 1.00/0.88/0.76, climb freely, pay 24%. Jacaranda: 0.95/0.52/0.40, climb at all, pay 58%. That makes tree choice a real decision instead of "is there a tree". Unpriced by ruling, same as the bike: no limb-failure event exists to see. Both wreck/orientation claims are measured in browser coords, not reasoned — `wreck_falls_toward: "+Z"` is baked AND asserted against the mesh. Selftest 376/0/0. Two consecutive full factory runs: 44 files byte-identical.
3310 lines
151 KiB
Python
3310 lines
151 KiB
Python
"""
|
||
SHADES — yard asset factory (Lane E)
|
||
====================================
|
||
|
||
ONE deterministic script that regenerates every nature + hardware GLB the game
|
||
needs. Nothing here is hand-edited afterwards: change the script, re-run, commit
|
||
the new GLBs (PLAN3D §0, "asset copies rule").
|
||
|
||
Run:
|
||
blender -b -P tools/blender/build_yard_assets.py
|
||
blender -b -P tools/blender/build_yard_assets.py -- --only tree_gum_01
|
||
blender -b -P tools/blender/build_yard_assets.py -- --no-verify
|
||
blender -b -P tools/blender/build_yard_assets.py -- --no-debris
|
||
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")
|
||
|
||
# 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
|
||
}
|
||
|
||
# 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.30–0.42
|
||
# against a gum's 0.55–0.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 $250–350 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
|
||
|
||
|
||
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"
|
||
e["rating_hint"] = 0.9
|
||
above.append(e)
|
||
for o in above:
|
||
parent_keep_transform(o, rake)
|
||
stamp(root, name, "hardware")
|
||
root["post_height"] = H
|
||
root["rake_note"] = "rotate about rake_pivot; rake away from the load"
|
||
return root
|
||
|
||
|
||
def build_ladder_01(name):
|
||
root = add_empty(name)
|
||
alu = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.85)
|
||
H, W = 3.0, 0.42
|
||
parts = []
|
||
for sx in (-1, 1):
|
||
parts.append(add_box(f"{name}_rail_{sx}", (0.035, 0.075, H),
|
||
(sx * W / 2, 0, H / 2), alu))
|
||
n = int(H / 0.28)
|
||
for i in range(1, n):
|
||
parts.append(add_cyl(f"{name}_rung_{i:02d}", 0.016, W,
|
||
(0, 0, i * 0.28), alu, verts=8,
|
||
rot=(0, math.pi / 2, 0)))
|
||
join_group(parts, "ladder", root)
|
||
add_empty("ladder_base", (0, 0, 0.05), root, size=0.2)
|
||
add_empty("ladder_top", (0, 0, H - 0.1), root, size=0.2)
|
||
stamp(root, name, "prop")
|
||
root["climb_height"] = H
|
||
return root
|
||
|
||
|
||
def build_shackle(name):
|
||
"""Bow shackle, ~80 mm. The `pin` is its own node because the pin is the
|
||
whole story: unmoused, flogging unscrews it, and then it shears."""
|
||
root = add_empty(name)
|
||
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.3, metallic=0.95)
|
||
pin_m = get_material("Mat_SteelDark", PAL["steel_dark"], 0.35, metallic=0.95)
|
||
R, tr = 0.022, 0.005
|
||
body = [add_arc_tube(f"{name}_bow", R, tr, math.radians(-28),
|
||
math.radians(208), steel, segs=14,
|
||
center=(0, 0, 0.048), plane='XZ')]
|
||
# Straight legs down from the bow ends to the pin eyes.
|
||
for sx in (-1, 1):
|
||
x = sx * R * math.cos(math.radians(28))
|
||
body.append(add_tube_between(f"{name}_leg_{sx}",
|
||
(x, 0, 0.048 - R * math.sin(math.radians(28))),
|
||
(x, 0, 0.012), tr, steel, verts=8))
|
||
body.append(add_cyl(f"{name}_ear_{sx}", tr * 1.9, 0.006, (x, 0, 0.010),
|
||
steel, verts=8, rot=(0, math.pi / 2, 0)))
|
||
join_group(body, "bow", root)
|
||
|
||
pin = join_group([
|
||
add_cyl(f"{name}_pin_shaft", 0.0042, R * 2.4, (0, 0, 0.010), pin_m,
|
||
verts=8, rot=(0, math.pi / 2, 0)),
|
||
add_cyl(f"{name}_pin_head", 0.0095, 0.005, (-R * 1.25, 0, 0.010),
|
||
pin_m, verts=8, rot=(0, math.pi / 2, 0)),
|
||
], "pin", root)
|
||
pin["failure_mode"] = "unscrews_then_shears"
|
||
stamp(root, name, "hardware")
|
||
root["hw_class"] = "shackle"
|
||
return root
|
||
|
||
|
||
def build_carabiner(name):
|
||
"""~100 mm. `gate` is its own node — DESIGN.md: the gate flutters open."""
|
||
root = add_empty(name)
|
||
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.3, metallic=0.95)
|
||
gate_m = get_material("Mat_SteelDark", PAL["steel_dark"], 0.35,
|
||
metallic=0.9)
|
||
# Elliptical: 52 mm across, 94 mm long. A circle here reads as a keyring.
|
||
# The gap is on a LONG SIDE, not the bottom — the gate is the straight bar
|
||
# chording the curved spine, and that silhouette is the whole tell.
|
||
RX, RZ, CZ, tr = 0.026, 0.047, 0.052, 0.0045
|
||
a0, a1 = math.radians(55), math.radians(305)
|
||
body = [add_arc_tube(f"{name}_spine", RX, tr, a0, a1, steel, segs=16,
|
||
center=(0, 0, CZ), plane='XZ', radius2=RZ)]
|
||
join_group(body, "body", root)
|
||
p0 = (RX * math.cos(a1), 0, CZ + RZ * math.sin(a1))
|
||
p1 = (RX * math.cos(a0), 0, CZ + RZ * math.sin(a0))
|
||
gate = join_group([add_tube_between(f"{name}_gate_bar", p0, p1, tr * 0.8,
|
||
gate_m, verts=8)], "gate", root)
|
||
gate["failure_mode"] = "gate_flutters_open"
|
||
stamp(root, name, "hardware")
|
||
root["hw_class"] = "carabiner"
|
||
return root
|
||
|
||
|
||
def build_turnbuckle(name):
|
||
"""~160 mm closed. `body` spins to tension — it is both the adjuster and,
|
||
when it's cheap, the thing whose thread strips."""
|
||
root = add_empty(name)
|
||
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.3, metallic=0.95)
|
||
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.4, metallic=0.9)
|
||
body_len, br = 0.075, 0.011
|
||
frame = [
|
||
add_cyl(f"{name}_frame_a", br * 0.55, body_len, (0, br, 0.08), steel,
|
||
verts=6),
|
||
add_cyl(f"{name}_frame_b", br * 0.55, body_len, (0, -br, 0.08), steel,
|
||
verts=6),
|
||
]
|
||
for sz in (-1, 1):
|
||
frame.append(add_cyl(f"{name}_boss_{sz}", br, 0.012,
|
||
(0, 0, 0.08 + sz * body_len / 2), steel, verts=10))
|
||
body = join_group(frame, "body", root)
|
||
body["failure_mode"] = "thread_strips_or_bends"
|
||
|
||
for i, sz in enumerate((-1, 1)):
|
||
z_end = 0.08 + sz * (body_len / 2)
|
||
eye_z = z_end + sz * 0.035
|
||
join_group([
|
||
add_cyl(f"{name}_thread_{i}", 0.0045, 0.030, (0, 0, z_end + sz * 0.016),
|
||
dark, verts=8),
|
||
add_arc_tube(f"{name}_eyering_{i}", 0.011, 0.0038, 0, math.tau,
|
||
dark, segs=10, center=(0, 0, eye_z + sz * 0.011),
|
||
plane='XZ'),
|
||
], f"eye_{'a' if i == 0 else 'b'}", root)
|
||
stamp(root, name, "hardware")
|
||
root["hw_class"] = "turnbuckle"
|
||
return root
|
||
|
||
|
||
def build_tramp_01(name):
|
||
"""The funniest debris in the game. Every Australian storm produces at least
|
||
one airborne trampoline; the physics are Lane C's problem."""
|
||
root = add_empty(name)
|
||
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
|
||
mat_m = get_material("Mat_TrampMat", PAL["mat_black"], 0.9)
|
||
pad = get_material("Mat_Pad", PAL["leaf_gum_2"], 0.9)
|
||
R, H = 1.45, 0.75
|
||
join_group([add_cyl(f"{name}_mat", R * 0.80, 0.015, (0, 0, H), mat_m,
|
||
verts=24)], "mat", root)
|
||
join_group([add_arc_tube(f"{name}_rim", R, 0.028, 0, math.tau, steel,
|
||
segs=24, center=(0, 0, H), plane='XY')], "rim",
|
||
root)
|
||
join_group([add_cyl(f"{name}_pad", R * 0.93, 0.05, (0, 0, H - 0.01), pad,
|
||
verts=24)], "pad", root)
|
||
legs = []
|
||
for i in range(6):
|
||
a = math.tau * i / 6
|
||
x, y = math.cos(a) * R * 0.86, math.sin(a) * R * 0.86
|
||
legs.append(add_tube_between(f"{name}_leg_{i}", (x, y, H),
|
||
(x * 1.06, y * 1.06, 0), 0.020, steel,
|
||
verts=6))
|
||
join_group(legs, "legs", root)
|
||
stamp(root, name, "debris")
|
||
root["mass_hint"] = 45.0
|
||
# 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.30–0.45 m across.
|
||
dict(name="hail_stone_01", fn=build_hail_stone_01,
|
||
dims=((0.015, 0.030), (0.012, 0.028), (0.012, 0.028)),
|
||
nodes=["stone"]),
|
||
dict(name="broom_01", fn=build_broom_01,
|
||
dims=((0.28, 0.45), (0.04, 0.12), (1.35, 1.50)),
|
||
nodes=["handle", "head", "bristles", "grip_anchor", "poke_tip"]),
|
||
dict(name="fence_panel_snapped", fn=build_fence_panel_snapped,
|
||
dims=((2.38, 2.60), (0.03, 1.05), (1.70, 1.90)),
|
||
nodes=["palings", "rails", "debris_palings"]),
|
||
]
|
||
|
||
|
||
def asset_path(a):
|
||
return os.path.join(a.get("dir", MODELS_DIR), f"{a['name']}_v1.glb")
|
||
|
||
|
||
# ============================================================================
|
||
# BUILD
|
||
# ============================================================================
|
||
def build_all(only=None):
|
||
os.makedirs(MODELS_DIR, exist_ok=True)
|
||
os.makedirs(DEBRIS_DIR, exist_ok=True)
|
||
todo = [a for a in ASSETS if only is None or a["name"] in only]
|
||
print(f"\n--- BUILD {len(todo)} ASSETS -> {MODELS_DIR} ---\n")
|
||
built = []
|
||
for a in todo:
|
||
name = a["name"]
|
||
reset_to_empty()
|
||
root = a["fn"](name)
|
||
out = asset_path(a)
|
||
export_asset(root, out)
|
||
tris = count_tris_in_scene()
|
||
kb = os.path.getsize(out) // 1024
|
||
flag = "" if tris <= TRI_BUDGET else f" ** OVER {TRI_BUDGET} TRI BUDGET **"
|
||
print(f" {name:<18s} -> {os.path.basename(out):<26s} "
|
||
f"({tris:>6,d} tris, {kb:>4d} KB){flag}")
|
||
built.append(name)
|
||
return built
|
||
|
||
|
||
def count_tris_in_scene():
|
||
total = 0
|
||
dg = bpy.context.evaluated_depsgraph_get()
|
||
for o in bpy.data.objects:
|
||
if o.type != 'MESH':
|
||
continue
|
||
eo = o.evaluated_get(dg)
|
||
me = eo.to_mesh()
|
||
me.calc_loop_triangles()
|
||
total += len(me.loop_triangles)
|
||
eo.to_mesh_clear()
|
||
return total
|
||
|
||
|
||
# ============================================================================
|
||
# DEBRIS — copy verbatim from the library, then measure. House rule: runtime
|
||
# GLBs are COPIES; if the scale is wrong we fix it at source, not here.
|
||
# ============================================================================
|
||
def import_glb(path):
|
||
before = set(bpy.data.objects)
|
||
bpy.ops.import_scene.gltf(filepath=path)
|
||
new = [o for o in bpy.data.objects if o not in before]
|
||
for o in new:
|
||
# THE gotcha: the glTF importer leaves rotation_mode='QUATERNION' and
|
||
# every later rotation_euler write is silently dropped. Force XYZ now,
|
||
# BEFORE anything downstream touches rotation.
|
||
o.rotation_mode = 'XYZ'
|
||
return new
|
||
|
||
|
||
def measure_bounds(objs):
|
||
"""World-space AABB over real VERTICES.
|
||
|
||
Do not be tempted by obj.bound_box here: that is the LOCAL box, and pushing
|
||
its 8 corners through matrix_world over-estimates for any rotated object —
|
||
you get the AABB of the rotated box, not of the geometry. join_group leaves
|
||
each joined node carrying parts[0]'s rotation, so every tube-built asset
|
||
(arcs, branches, plant blades) measured ~11% too wide that way, purely as a
|
||
reporting artefact. Vertices are exact and cheap at these poly counts.
|
||
"""
|
||
lo = Vector((1e9, 1e9, 1e9))
|
||
hi = Vector((-1e9, -1e9, -1e9))
|
||
found = False
|
||
dg = bpy.context.evaluated_depsgraph_get()
|
||
for o in objs:
|
||
if o.type != 'MESH':
|
||
continue
|
||
eo = o.evaluated_get(dg)
|
||
me = eo.to_mesh()
|
||
mw = o.matrix_world
|
||
for v in me.vertices:
|
||
w = mw @ v.co
|
||
lo = Vector((min(lo[i], w[i]) for i in range(3)))
|
||
hi = Vector((max(hi[i], w[i]) for i in range(3)))
|
||
found = True
|
||
eo.to_mesh_clear()
|
||
return (lo, hi) if found else None
|
||
|
||
|
||
def measure_objects(objs):
|
||
b = measure_bounds(objs)
|
||
if b is None:
|
||
return (0.0, 0.0, 0.0)
|
||
lo, hi = b
|
||
return tuple(round(hi[i] - lo[i], 4) for i in range(3))
|
||
|
||
|
||
def copy_debris():
|
||
src = next((d for d in DEBRIS_SOURCES if os.path.isdir(d)), None)
|
||
if src is None:
|
||
print("\n ! debris library not found; checked:")
|
||
for d in DEBRIS_SOURCES:
|
||
print(f" {d}")
|
||
print(" skipping debris copy (models/debris/ left as-is)\n")
|
||
return []
|
||
os.makedirs(DEBRIS_DIR, exist_ok=True)
|
||
print(f"\n--- DEBRIS (copies from {src}) ---\n")
|
||
out = []
|
||
for fn in DEBRIS_FILES:
|
||
s = os.path.join(src, fn)
|
||
if not os.path.isfile(s):
|
||
print(f" ! missing in library: {fn}")
|
||
continue
|
||
d = os.path.join(DEBRIS_DIR, fn)
|
||
shutil.copy2(s, d)
|
||
reset_to_empty()
|
||
objs = import_glb(d)
|
||
dims = measure_objects(objs)
|
||
# A storm projectile has to be a believable real-world object; anything
|
||
# outside this is authored in the wrong unit and needs a source fix.
|
||
sane = all(0.15 <= v <= 1.5 for v in dims)
|
||
print(f" {fn:<20s} {dims[0]:.2f} x {dims[1]:.2f} x {dims[2]:.2f} m "
|
||
f"{'ok' if sane else '** SCALE SUSPECT — fix at source **'}")
|
||
out.append(dict(file=fn, dims=dims, sane=sane))
|
||
reset_to_empty()
|
||
return out
|
||
|
||
|
||
# ============================================================================
|
||
# VERIFY — re-import each exported GLB fresh and prove it, then render it
|
||
# against the 1.7 m capsule. This checks the FILE, not the in-memory scene.
|
||
# ============================================================================
|
||
def setup_render_scene():
|
||
scn = bpy.context.scene
|
||
scn.render.engine = 'BLENDER_EEVEE'
|
||
scn.render.resolution_x = 420
|
||
scn.render.resolution_y = 420
|
||
scn.render.film_transparent = False
|
||
scn.world = bpy.data.worlds.new("W")
|
||
scn.world.use_nodes = True
|
||
bg = scn.world.node_tree.nodes.get("Background")
|
||
if bg:
|
||
bg.inputs[0].default_value = (0.16, 0.17, 0.19, 1.0)
|
||
bpy.ops.object.light_add(type='SUN', location=(4, -6, 9))
|
||
sun = _active()
|
||
sun.data.energy = 4.0
|
||
sun.rotation_mode = 'XYZ'
|
||
sun.rotation_euler = (math.radians(52), 0, math.radians(35))
|
||
bpy.ops.object.camera_add(location=(0, -6, 2))
|
||
cam = _active()
|
||
cam.rotation_mode = 'XYZ'
|
||
scn.camera = cam
|
||
return cam
|
||
|
||
|
||
def frame_camera(cam, target, radius):
|
||
"""3/4 view fitted to a bounding sphere, so a 0.06 m shackle and an 8.4 m gum
|
||
each fill their own tile. Distance comes from the lens, not a magic number:
|
||
to fit radius R at half-FOV a, you need R / sin(a)."""
|
||
half_fov = cam.data.angle / 2.0
|
||
d = max(radius / max(math.sin(half_fov), 1e-3) * 1.15, 0.05)
|
||
az, el = math.radians(52), math.radians(20)
|
||
pos = Vector(target) + Vector((math.sin(az) * d * math.cos(el),
|
||
-math.cos(az) * d * math.cos(el),
|
||
math.sin(el) * d))
|
||
cam.location = pos
|
||
cam.rotation_euler = (Vector(target) - pos).to_track_quat('-Z', 'Y').to_euler()
|
||
# Small assets need the near clip pulled in or a shackle vanishes entirely.
|
||
cam.data.clip_start = min(0.1, d * 0.05)
|
||
|
||
|
||
def add_label(cam, text):
|
||
bpy.ops.object.text_add(location=(0, 0, 0))
|
||
t = _active()
|
||
t.data.body = text
|
||
t.data.align_x = 'CENTER'
|
||
t.data.size = 0.030 # camera frame at z=-1 is only ~±0.36 wide
|
||
t.parent = cam # parented to the camera = always in frame
|
||
t.location = (0.0, -0.29, -1.0)
|
||
t.rotation_mode = 'XYZ'
|
||
t.rotation_euler = (0, 0, 0)
|
||
m = get_material("Mat_Label", "#FFFFFF", 1.0)
|
||
t.data.materials.append(m)
|
||
return t
|
||
|
||
|
||
def verify_all(only=None):
|
||
todo = [a for a in ASSETS if only is None or a["name"] in only]
|
||
print(f"\n--- VERIFY {len(todo)} GLBs (re-import + assert + render) ---\n")
|
||
os.makedirs(os.path.join(SCRIPT_DIR, "thumbs"), exist_ok=True)
|
||
report, failures, thumbs = [], [], []
|
||
|
||
for a in todo:
|
||
name = a["name"]
|
||
path = asset_path(a)
|
||
if not os.path.isfile(path):
|
||
failures.append(f"{name}: GLB missing at {path}")
|
||
continue
|
||
reset_to_empty()
|
||
cam = setup_render_scene()
|
||
objs = import_glb(path)
|
||
names = {o.name for o in objs}
|
||
dims = measure_objects(objs)
|
||
tris = count_tris_in_scene()
|
||
|
||
problems = []
|
||
for i, axis in enumerate("xyz"):
|
||
lo, hi = a["dims"][i]
|
||
if not (lo <= dims[i] <= hi):
|
||
problems.append(f"{axis}={dims[i]:.3f} outside [{lo}, {hi}]")
|
||
missing = [n for n in a["nodes"] if n not in names]
|
||
if missing:
|
||
problems.append(f"nodes missing after round-trip: {missing}")
|
||
if tris > TRI_BUDGET:
|
||
problems.append(f"{tris} tris > {TRI_BUDGET} budget")
|
||
|
||
# The capsule beside it — the actual acceptance criterion. Skipped for
|
||
# small things: a 1.7 m human next to a 60 mm shackle tells you nothing
|
||
# and zooms the shackle down to one pixel. Below the cut the printed dims
|
||
# are the scale check, and the tile's job is proving the thing READS.
|
||
#
|
||
# Keyed on HEIGHT, not max(dims): the capsule answers "how big is this
|
||
# next to a person", which is a question about how tall it stands. Flat
|
||
# wreckage spread 0.39 m across the grass but standing 0.11 m is small-
|
||
# object territory — measuring its scatter against a human just buries it.
|
||
show_capsule = name != "ref_capsule" and dims[2] >= 0.30
|
||
if show_capsule:
|
||
build_ref_capsule("ref_capsule")
|
||
for o in bpy.data.objects:
|
||
if o.name.startswith("ref_capsule_mesh"):
|
||
o.location.x = dims[0] / 2 + 0.55
|
||
|
||
bounds = measure_bounds([o for o in bpy.data.objects
|
||
if o.type == 'MESH'])
|
||
lo, hi = bounds
|
||
frame_camera(cam, (lo + hi) / 2.0, max((hi - lo).length / 2.0, 0.04))
|
||
add_label(cam, f"{name}\n{dims[0]:.2f} x {dims[1]:.2f} x {dims[2]:.2f} m"
|
||
f"\n{tris:,} tris")
|
||
thumb = os.path.join(SCRIPT_DIR, "thumbs", f"{name}.png")
|
||
bpy.context.scene.render.filepath = thumb
|
||
bpy.ops.render.render(write_still=True)
|
||
thumbs.append(thumb)
|
||
|
||
status = "PASS" if not problems else "FAIL"
|
||
if problems:
|
||
failures.append(f"{name}: " + "; ".join(problems))
|
||
print(f" [{status}] {name:<18s} {dims[0]:6.2f} x {dims[1]:5.2f} x "
|
||
f"{dims[2]:5.2f} m {tris:>6,d} tris")
|
||
for p in problems:
|
||
print(f" -> {p}")
|
||
report.append(dict(name=name, dims=dims, tris=tris,
|
||
nodes=sorted(names), status=status,
|
||
problems=problems))
|
||
return report, failures, thumbs
|
||
|
||
|
||
def make_contact_sheet(thumbs):
|
||
"""Tile the thumbnails into one sheet. numpy only — Blender ships no PIL."""
|
||
import numpy as np
|
||
if not thumbs:
|
||
return None
|
||
tiles = []
|
||
for t in thumbs:
|
||
if not os.path.isfile(t):
|
||
continue
|
||
im = bpy.data.images.load(t)
|
||
w, h = im.size
|
||
buf = np.empty(w * h * 4, dtype=np.float32)
|
||
im.pixels.foreach_get(buf)
|
||
tiles.append(buf.reshape(h, w, 4)[::-1]) # bpy rows are bottom-up
|
||
bpy.data.images.remove(im)
|
||
if not tiles:
|
||
return None
|
||
cols = 4
|
||
rows = (len(tiles) + cols - 1) // cols
|
||
th, tw = tiles[0].shape[0], tiles[0].shape[1]
|
||
# Prefill with the render background, sampled from a tile's corner rather
|
||
# than guessed — the PNG is sRGB-encoded and the scene colour is linear, so
|
||
# reusing the world constant here would not match. Otherwise the unused
|
||
# slots in a partly-filled last row read as black holes.
|
||
sheet = np.empty((rows * th, cols * tw, 4), dtype=np.float32)
|
||
sheet[:, :] = tiles[0][0, 0]
|
||
sheet[:, :, 3] = 1.0
|
||
for i, tile in enumerate(tiles):
|
||
r, c = i // cols, i % cols
|
||
sheet[r * th:(r + 1) * th, c * tw:(c + 1) * tw] = tile
|
||
out_img = bpy.data.images.new("contact_sheet", cols * tw, rows * th,
|
||
alpha=True)
|
||
out_img.pixels.foreach_set(sheet[::-1].reshape(-1))
|
||
out_img.filepath_raw = CONTACT_SHEET
|
||
out_img.file_format = 'PNG'
|
||
out_img.save()
|
||
bpy.data.images.remove(out_img)
|
||
print(f"\n contact sheet -> {CONTACT_SHEET} ({cols}x{rows} tiles)")
|
||
return CONTACT_SHEET
|
||
|
||
|
||
# ============================================================================
|
||
# MAIN
|
||
# ============================================================================
|
||
def parse_args():
|
||
argv = sys.argv
|
||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||
only, no_verify, no_debris, 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}")
|
||
|
||
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()
|