The fascia anchors have said collateral:"gutter" since Sprint 6 and nobody
priced one — collateralFor('gutter') returns null, backyard_01's house is a
free failure. Proposal rides in the asset like the carport's did: 90, between
the gnome (25) and the carport (180), with the band reasoning next to the
constant. A rules the number.
house_yardside_wrecked_v1: same origin/footprint/facade (shared _house_facade
helper — intact house BIN chunk byte-identical to the committed GLB, only the
three new price extras differ), fascia torn through the anchored span, left
run hanging by its last bracket, right run kinked ON the grass, downpipe out
of plumb, offcuts flat on the lawn. No fascia_anchor_* empties survive — you
cannot re-tie to a ripped eave.
collateral_key is new and explicit: unlike the carport, the structure and the
thing you take have different names, so the key names which collateral string
the value prices.
All 33 GLBs byte-identical across two full factory runs; the house pair also
byte-identical between --only and full runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2864 lines
128 KiB
Python
2864 lines
128 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
|
||
"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 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_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"
|
||
|
||
# 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.
|
||
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_value"] = CARPORT_COLLATERAL
|
||
return root
|
||
|
||
|
||
def build_shed_01(name):
|
||
"""Colorbond garden shed, skillion roof. Spare hardware lives in here."""
|
||
root = add_empty(name)
|
||
sheet = get_material("Mat_Colorbond", PAL["colorbond"], 0.45, metallic=0.5)
|
||
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.5, metallic=0.5)
|
||
slab = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||
W, D, H = 2.40, 1.80, 2.05
|
||
fall = 0.22 # skillion drop front-to-back
|
||
|
||
parts = [add_box(f"{name}_slab", (W + 0.16, D + 0.16, 0.08),
|
||
(0, 0, 0.04), slab)]
|
||
parts.append(add_box(f"{name}_back", (W, 0.04, H),
|
||
(0, D / 2, 0.08 + H / 2), sheet))
|
||
parts.append(add_box(f"{name}_left", (0.04, D, H - fall / 2),
|
||
(-W / 2, 0, 0.08 + (H - fall / 2) / 2), sheet))
|
||
parts.append(add_box(f"{name}_right", (0.04, D, H - fall / 2),
|
||
(W / 2, 0, 0.08 + (H - fall / 2) / 2), sheet))
|
||
parts.append(add_box(f"{name}_front", (W, 0.04, H - fall),
|
||
(0, -D / 2, 0.08 + (H - fall) / 2), sheet))
|
||
join_group(parts, "shell", root)
|
||
|
||
join_group([add_box(f"{name}_roof", (W + 0.18, D + 0.18, 0.045),
|
||
(0, 0, 0.08 + H - fall / 2 + 0.06), sheet,
|
||
rot=(math.radians(math.degrees(math.atan2(fall, D))),
|
||
0, 0))], "roof", root)
|
||
join_group([
|
||
add_box(f"{name}_door_l", (W / 2 - 0.06, 0.02, H - fall - 0.16),
|
||
(-W / 4, -D / 2 - 0.03, 0.08 + (H - fall - 0.16) / 2), dark),
|
||
add_box(f"{name}_door_r", (W / 2 - 0.06, 0.02, H - fall - 0.16),
|
||
(W / 4, -D / 2 - 0.03, 0.08 + (H - fall - 0.16) / 2), dark),
|
||
], "doors", root)
|
||
add_empty("door_anchor", (0, -D / 2 - 0.6, 0.9), root, size=0.2)
|
||
stamp(root, name, "structure")
|
||
return root
|
||
|
||
|
||
def build_shed_table(name):
|
||
"""The spare-hardware pickup point. `pickup_anchor` is where Lane D should
|
||
register the hold-E, so the prompt lands on the bench top, not the floor."""
|
||
root = add_empty(name)
|
||
timber = get_material("Mat_Timber", PAL["timber"], 0.8)
|
||
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
|
||
W, D, H = 1.60, 0.60, 0.90
|
||
top = add_box("table_top", (W, D, 0.045), (0, 0, H - 0.0225), timber,
|
||
parent=root)
|
||
legs = []
|
||
for sx in (-1, 1):
|
||
for sy in (-1, 1):
|
||
legs.append(add_box(f"{name}_leg_{sx}_{sy}", (0.05, 0.05, H - 0.045),
|
||
(sx * (W / 2 - 0.07), sy * (D / 2 - 0.07),
|
||
(H - 0.045) / 2), steel))
|
||
legs.append(add_box(f"{name}_shelf", (W - 0.16, D - 0.12, 0.03),
|
||
(0, 0, 0.22), timber))
|
||
join_group(legs, "table_frame", root)
|
||
add_empty("pickup_anchor", (0, 0, H + 0.05), root, size=0.2)
|
||
stamp(root, name, "prop")
|
||
return root
|
||
|
||
|
||
def _plant_tuft(prefix, origin, mat, rng, blades, height, lean, parts):
|
||
"""A tuft of tapered blades fanning from a point. Cheap, and reads as a
|
||
plant instead of the green X's you get from crossed quads."""
|
||
for b in range(blades):
|
||
ang = math.tau * b / blades + rng.uniform(-0.25, 0.25)
|
||
hgt = height * rng.uniform(0.7, 1.15)
|
||
tip = (origin[0] + math.cos(ang) * lean * hgt,
|
||
origin[1] + math.sin(ang) * lean * hgt,
|
||
origin[2] + hgt)
|
||
mid = (origin[0] + math.cos(ang) * lean * hgt * 0.35,
|
||
origin[1] + math.sin(ang) * lean * hgt * 0.35,
|
||
origin[2] + hgt * 0.6)
|
||
parts.append(add_tube_between(f"{prefix}_b{b}_lo", origin, mid,
|
||
0.012, mat, verts=4))
|
||
parts.append(add_tube_between(f"{prefix}_b{b}_hi", mid, tip,
|
||
0.005, mat, verts=4))
|
||
|
||
|
||
def build_garden_bed(name):
|
||
"""Raised sleeper bed + THREE plant states as sibling nodes in one GLB:
|
||
plants_full / plants_tattered / plants_dead. Lane A toggles .visible — one
|
||
load, instant swap, no pop-in, and no morph-target export risk."""
|
||
rng = rng_for(name)
|
||
root = add_empty(name)
|
||
sleeper = get_material("Mat_Timber", PAL["timber_dark"], 0.9)
|
||
soil = get_material("Mat_Soil", PAL["soil"], 1.0)
|
||
W, D, H = 3.0, 1.2, 0.40
|
||
|
||
frame = []
|
||
for sy in (-1, 1):
|
||
frame.append(add_box(f"{name}_side_{sy}", (W, 0.05, H),
|
||
(0, sy * (D / 2 - 0.025), H / 2), sleeper))
|
||
for sx in (-1, 1):
|
||
frame.append(add_box(f"{name}_end_{sx}", (0.05, D - 0.1, H),
|
||
(sx * (W / 2 - 0.025), 0, H / 2), sleeper))
|
||
join_group(frame, "bed", root)
|
||
join_group([add_box(f"{name}_soil", (W - 0.1, D - 0.1, 0.06),
|
||
(0, 0, H - 0.05), soil)], "soil", root)
|
||
|
||
# Same tuft positions across all three states — the bed must not appear to
|
||
# rearrange itself when it takes damage, only to wilt.
|
||
spots = []
|
||
for i in range(7):
|
||
spots.append((-W / 2 + 0.35 + i * ((W - 0.7) / 6.0),
|
||
rng.uniform(-D / 4, D / 4), H - 0.02))
|
||
|
||
for state, mat_hex, blades, hgt, lean in (
|
||
("full", PAL["plant_full"], 7, 0.42, 0.30),
|
||
("tattered", PAL["plant_tatty"], 5, 0.26, 0.55),
|
||
("dead", PAL["plant_dead"], 3, 0.15, 0.85)):
|
||
mat = get_material(f"Mat_Plant_{state}", mat_hex, 0.9)
|
||
srng = rng_for(f"{name}:{state}")
|
||
parts = []
|
||
for i, sp in enumerate(spots):
|
||
_plant_tuft(f"{name}_{state}_{i}", sp, mat, srng, blades, hgt,
|
||
lean, parts)
|
||
node = join_group(parts, f"plants_{state}", root)
|
||
if node:
|
||
node["damage_state"] = state
|
||
# Only `full` 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
|
||
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"
|
||
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"]),
|
||
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"]),
|
||
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()
|