Verifying the GLBs in three.js (not just Blender) showed four assets reporting inflated bounds: tramp_01 came back 3.29 x 1.27 m against a true 2.96 x 0.78. Box3.setFromObject expands each mesh's LOCAL box by the world matrix, so a node carrying a rotation over-reports — the same trap Blender's obj.bound_box sets, and what three uses for frustum culling. Joined nodes inherited parts[0]'s rotation, which for an arc is half a segment step off-axis. Applying rotation at join time makes every local box axis-aligned, so the default Box3 path is now correct for consumers and culling is tight. World geometry is unchanged; the exported dims are identical. Adds tools/assetcheck/, the three.js harness that caught this. It's the only check that can: a Blender round-trip exports Z-up->Y-up and imports Y-up->Z-up, so a broken export_yup flips back and passes green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1410 lines
57 KiB
Python
1410 lines
57 KiB
Python
"""
|
|
SHADES — yard asset factory (Lane E)
|
|
====================================
|
|
|
|
ONE deterministic script that regenerates every nature + hardware GLB the game
|
|
needs. Nothing here is hand-edited afterwards: change the script, re-run, commit
|
|
the new GLBs (PLAN3D §0, "asset copies rule").
|
|
|
|
Run:
|
|
blender -b -P tools/blender/build_yard_assets.py
|
|
blender -b -P tools/blender/build_yard_assets.py -- --only tree_gum_01
|
|
blender -b -P tools/blender/build_yard_assets.py -- --no-verify
|
|
blender -b -P tools/blender/build_yard_assets.py -- --no-debris
|
|
|
|
Outputs (resolved from this file, so no absolute home paths):
|
|
web/world/models/*.glb nature, hardware, ref_capsule
|
|
web/world/models/debris/*.glb copied verbatim from the 3D-STORE library
|
|
web/world/models/textures/grass_atlas.png
|
|
tools/blender/contact_sheet.png verification render vs the 1.7 m capsule
|
|
tools/blender/asset_report.json measured dims / tris / node names
|
|
|
|
Idiom follows ~/Documents/Destroyulater/3D-STORE/racks_to_glb.py:
|
|
reset_to_empty() per asset -> build under a root empty AT THE ORIGIN ->
|
|
join by group -> stamp custom props -> export_scene.gltf(export_yup=True,
|
|
export_extras=True, export_apply=True).
|
|
|
|
Gotchas this script respects (all learned the hard way elsewhere in the house):
|
|
- Blender's glTF importer leaves objects at rotation_mode='QUATERNION', and
|
|
assigning .rotation_euler is then SILENTLY IGNORED. That is the bug that hid
|
|
every fix across booth_room v3..v18. import_glb() forces 'XYZ' immediately.
|
|
- Material.blend_method is deprecated under EEVEE Next; prefer
|
|
surface_render_method when it exists.
|
|
- Blender is Z-up, glTF is Y-up. Build Z-up here; export_yup=True flips it.
|
|
A branch_anchor at Blender (0, 0, 3) arrives in three.js at (0, 3, 0).
|
|
- Root empties stay at the world origin so `obj.parent = root` needs no
|
|
parent-inverse juggling.
|
|
|
|
Contract notes for other lanes (see THREADS.md):
|
|
- Trees expose `trunk` + `canopy_01..03` as separate nodes so Lane A can sway
|
|
canopies without moving the trunk, plus `branch_anchor_*` empties for
|
|
world.anchors.
|
|
- house_yardside exposes `fascia_anchor_01..03` — the fascia is a lie
|
|
(DESIGN.md), and these are the anchors that are supposed to betray you.
|
|
- sail_post is exported VERTICAL with a `rake_pivot` empty at the footing and
|
|
a `top_anchor` at the head. Rake is a gameplay decision, so it is a runtime
|
|
rotation about rake_pivot, not baked into the mesh.
|
|
- shackle/carabiner/turnbuckle keep their failure-mode part as its own node
|
|
(`pin`, `gate`, `body`) so the break animation can move just that piece.
|
|
"""
|
|
|
|
import bpy
|
|
import bmesh # noqa: F401 (imported for parity with the house scripts)
|
|
import json
|
|
import math
|
|
import os
|
|
import random
|
|
import shutil
|
|
import sys
|
|
|
|
from mathutils import Vector
|
|
|
|
# ============================================================================
|
|
# CONFIG
|
|
# ============================================================================
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", ".."))
|
|
MODELS_DIR = os.path.join(REPO_ROOT, "web", "world", "models")
|
|
DEBRIS_DIR = os.path.join(MODELS_DIR, "debris")
|
|
TEXTURES_DIR = os.path.join(MODELS_DIR, "textures")
|
|
CONTACT_SHEET = os.path.join(SCRIPT_DIR, "contact_sheet.png")
|
|
REPORT_JSON = os.path.join(SCRIPT_DIR, "asset_report.json")
|
|
|
|
# The 3D-STORE library on this box. PLAN3D §2 lists ~/Documents/3D-STORE (that
|
|
# path is from the M1 Ultra); on the M3 Ultra the library lives here. Checked in
|
|
# order, first hit wins, missing -> debris copy is skipped with a warning.
|
|
DEBRIS_SOURCES = [
|
|
os.path.expanduser("~/Documents/Destroyulater/3D-STORE/clean_glbs"),
|
|
os.path.expanduser("~/Documents/3D-STORE/clean_glbs"),
|
|
]
|
|
DEBRIS_FILES = [
|
|
"BlueCrate_v2.glb",
|
|
"BlackTub_v2.glb",
|
|
"WhiteTub_v2.glb",
|
|
"WoodenBin_v2.glb",
|
|
]
|
|
|
|
TRI_BUDGET = 15000 # PLAN3D §5-E
|
|
REF_HEIGHT = 1.70 # the person the whole yard is scaled against
|
|
SEED_SALT = "shades-lane-e"
|
|
|
|
# ============================================================================
|
|
# PALETTE — low-poly stylized, flat colours, sits beside the 90sDJsim ped fleet
|
|
# ============================================================================
|
|
PAL = {
|
|
"bark_gum": "#BFB8A8", # eucalypt: pale, chalky, not brown
|
|
"bark_shadow": "#8C8577",
|
|
"leaf_gum": "#7C8F5E", # sage/olive, not lawn green
|
|
"leaf_gum_2": "#6B7E52",
|
|
"timber": "#B08A5E", # palings, sleepers
|
|
"timber_dark": "#8A6B47",
|
|
"steel_gal": "#B6BCC2", # galvanised: posts, hardware
|
|
"steel_dark": "#7E858C",
|
|
"colorbond": "#9AA5A0", # shed / fence sheet
|
|
"concrete": "#B9B6AE",
|
|
"brick": "#A8705C",
|
|
"render_wall": "#D8D2C4",
|
|
"roof_tile": "#6E6A66",
|
|
"glass": "#8FB3C4",
|
|
"soil": "#5B4436",
|
|
"plant_full": "#5F8A3E",
|
|
"plant_tatty": "#7A8446",
|
|
"plant_dead": "#8A7550",
|
|
"mat_black": "#2E2E30", # trampoline mat
|
|
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# UTILS — lifted from racks_to_glb.py, kept deliberately close to the original
|
|
# ============================================================================
|
|
def hex_to_rgba(hex_str, alpha=1.0):
|
|
h = (hex_str or "#888888").lstrip("#")
|
|
if len(h) != 6:
|
|
h = "888888"
|
|
return (int(h[0:2], 16) / 255.0, int(h[2:4], 16) / 255.0,
|
|
int(h[4:6], 16) / 255.0, alpha)
|
|
|
|
|
|
_MAT_CACHE = {}
|
|
|
|
|
|
def get_material(name, color_hex, roughness=0.7, metallic=0.0, opacity=1.0):
|
|
if name in _MAT_CACHE and _MAT_CACHE[name].name in bpy.data.materials:
|
|
return _MAT_CACHE[name]
|
|
mat = bpy.data.materials.new(name=name)
|
|
mat.use_nodes = True
|
|
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
|
if bsdf:
|
|
bsdf.inputs["Base Color"].default_value = hex_to_rgba(color_hex, opacity)
|
|
bsdf.inputs["Roughness"].default_value = max(0.0, min(1.0, roughness))
|
|
bsdf.inputs["Metallic"].default_value = max(0.0, min(1.0, metallic))
|
|
if "Alpha" in bsdf.inputs:
|
|
bsdf.inputs["Alpha"].default_value = max(0.0, min(1.0, opacity))
|
|
if opacity < 1.0:
|
|
# EEVEE Next renamed this; keep both paths so the script survives both.
|
|
if hasattr(mat, "surface_render_method"):
|
|
mat.surface_render_method = 'BLENDED'
|
|
elif hasattr(mat, "blend_method"):
|
|
mat.blend_method = 'BLEND'
|
|
_MAT_CACHE[name] = mat
|
|
return mat
|
|
|
|
|
|
def deselect_all_no_ops():
|
|
"""Avoid bpy.ops.object.select_all — works without a proper context."""
|
|
for o in bpy.data.objects:
|
|
try:
|
|
o.select_set(False)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _active():
|
|
return bpy.context.view_layer.objects.active
|
|
|
|
|
|
def _apply_transform(obj, location=False, rotation=False, scale=True):
|
|
deselect_all_no_ops()
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = obj
|
|
bpy.ops.object.transform_apply(location=location, rotation=rotation,
|
|
scale=scale)
|
|
|
|
|
|
def add_box(name, dims, location, material, parent=None, rot=None):
|
|
sx, sy, sz = dims
|
|
bpy.ops.mesh.primitive_cube_add(size=1.0, location=location)
|
|
obj = _active()
|
|
obj.name = name
|
|
obj.scale = (sx, sy, sz)
|
|
obj.rotation_mode = 'XYZ'
|
|
if rot:
|
|
obj.rotation_euler = rot
|
|
_apply_transform(obj, scale=True)
|
|
obj.data.materials.append(material)
|
|
if parent is not None:
|
|
obj.parent = parent
|
|
return obj
|
|
|
|
|
|
def add_cyl(name, radius, depth, location, material, parent=None, verts=10,
|
|
rot=None):
|
|
bpy.ops.mesh.primitive_cylinder_add(vertices=verts, radius=radius,
|
|
depth=depth, location=location)
|
|
obj = _active()
|
|
obj.name = name
|
|
obj.rotation_mode = 'XYZ'
|
|
if rot:
|
|
obj.rotation_euler = rot
|
|
obj.data.materials.append(material)
|
|
if parent is not None:
|
|
obj.parent = parent
|
|
return obj
|
|
|
|
|
|
def add_cone(name, r1, r2, depth, location, material, parent=None, verts=10,
|
|
rot=None):
|
|
bpy.ops.mesh.primitive_cone_add(vertices=verts, radius1=r1, radius2=r2,
|
|
depth=depth, location=location)
|
|
obj = _active()
|
|
obj.name = name
|
|
obj.rotation_mode = 'XYZ'
|
|
if rot:
|
|
obj.rotation_euler = rot
|
|
obj.data.materials.append(material)
|
|
if parent is not None:
|
|
obj.parent = parent
|
|
return obj
|
|
|
|
|
|
def add_ico(name, radius, location, material, parent=None, subdiv=2,
|
|
scale=(1, 1, 1), jitter=0.0, rng=None):
|
|
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=subdiv, radius=radius,
|
|
location=location)
|
|
obj = _active()
|
|
obj.name = name
|
|
obj.scale = scale
|
|
_apply_transform(obj, scale=True)
|
|
if jitter > 0.0 and rng is not None:
|
|
# Seeded per-vertex nudge: organic silhouette, still byte-deterministic.
|
|
for v in obj.data.vertices:
|
|
v.co += Vector((rng.uniform(-jitter, jitter),
|
|
rng.uniform(-jitter, jitter),
|
|
rng.uniform(-jitter, jitter)))
|
|
obj.data.materials.append(material)
|
|
if parent is not None:
|
|
obj.parent = parent
|
|
return obj
|
|
|
|
|
|
def add_tube_between(name, p0, p1, radius, material, parent=None, verts=8):
|
|
"""Cylinder spanning p0->p1. The workhorse for arcs, branches, rungs."""
|
|
a, b = Vector(p0), Vector(p1)
|
|
d = b - a
|
|
length = d.length
|
|
if length < 1e-6:
|
|
return None
|
|
obj = add_cyl(name, radius, length, tuple((a + b) / 2.0), material,
|
|
parent=parent, verts=verts)
|
|
obj.rotation_mode = 'XYZ'
|
|
obj.rotation_euler = d.to_track_quat('Z', 'Y').to_euler()
|
|
return obj
|
|
|
|
|
|
def add_empty(name, location=(0, 0, 0), parent=None, size=0.15):
|
|
bpy.ops.object.empty_add(type='PLAIN_AXES', location=location)
|
|
obj = _active()
|
|
obj.name = name
|
|
obj.empty_display_size = size
|
|
if parent is not None:
|
|
obj.parent = parent
|
|
return obj
|
|
|
|
|
|
def join_group(objs, name, parent=None):
|
|
"""Join a list of meshes into one named node. Groups are the sway/animation
|
|
unit, so this is per-group, NOT per-asset like racks_to_glb.py — Lane A has
|
|
to be able to move canopy_01 without moving the trunk."""
|
|
objs = [o for o in objs if o is not None]
|
|
if not objs:
|
|
return None
|
|
deselect_all_no_ops()
|
|
for o in objs:
|
|
o.select_set(True)
|
|
bpy.context.view_layer.objects.active = objs[0]
|
|
if len(objs) > 1:
|
|
try:
|
|
bpy.ops.object.join()
|
|
except Exception as e:
|
|
print(f" ! join failed for {name}: {e}")
|
|
res = _active()
|
|
res.name = name
|
|
# Bake the rotation objs[0] contributed into the vertices, so the node's
|
|
# LOCAL box is world-axis-aligned and therefore tight.
|
|
#
|
|
# This is not cosmetic. THREE.Box3.setFromObject() (and Blender's
|
|
# obj.bound_box, and three's frustum culling) expand the LOCAL bounding box
|
|
# by the world matrix. A joined node inherits objs[0]'s rotation — for an
|
|
# arc, that's half a segment step off-axis — so every consumer computing
|
|
# bounds the normal way would over-report these assets by ~11% and cull
|
|
# them late. Verified against three.js r175: without this, Box3 reports
|
|
# tramp_01 as 3.29 x 1.27 instead of its true 2.96 x 0.78.
|
|
_apply_transform(res, rotation=True, scale=True)
|
|
if parent is not None:
|
|
res.parent = parent
|
|
return res
|
|
|
|
|
|
def arc_points(radius, a0, a1, segs, center=(0, 0, 0), plane='XZ',
|
|
radius2=None):
|
|
"""Points along a circular arc, or an elliptical one when radius2 is given
|
|
(radius = first axis, radius2 = second). The ellipse is what turns a ring
|
|
into a D — a carabiner is ~100 mm long and ~55 mm wide, never round."""
|
|
r2 = radius if radius2 is None else radius2
|
|
pts = []
|
|
for i in range(segs + 1):
|
|
t = a0 + (a1 - a0) * i / float(segs)
|
|
c, s = math.cos(t) * radius, math.sin(t) * r2
|
|
if plane == 'XZ':
|
|
pts.append((center[0] + c, center[1], center[2] + s))
|
|
else:
|
|
pts.append((center[0] + c, center[1] + s, center[2]))
|
|
return pts
|
|
|
|
|
|
def add_arc_tube(name, radius, tube_r, a0, a1, material, parent=None, segs=12,
|
|
center=(0, 0, 0), plane='XZ', radius2=None):
|
|
pts = arc_points(radius, a0, a1, segs, center, plane, radius2)
|
|
parts = [add_tube_between(f"{name}_s{i}", pts[i], pts[i + 1], tube_r,
|
|
material, verts=8)
|
|
for i in range(segs)]
|
|
return join_group(parts, name, parent)
|
|
|
|
|
|
def reset_to_empty():
|
|
deselect_all_no_ops()
|
|
for obj in list(bpy.data.objects):
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
for mesh in list(bpy.data.meshes):
|
|
bpy.data.meshes.remove(mesh, do_unlink=True)
|
|
for mat in list(bpy.data.materials):
|
|
bpy.data.materials.remove(mat, do_unlink=True)
|
|
for cam in list(bpy.data.cameras):
|
|
bpy.data.cameras.remove(cam, do_unlink=True)
|
|
for light in list(bpy.data.lights):
|
|
bpy.data.lights.remove(light, do_unlink=True)
|
|
for txt in list(bpy.data.curves):
|
|
bpy.data.curves.remove(txt, do_unlink=True)
|
|
scn = bpy.context.scene
|
|
for coll in list(bpy.data.collections):
|
|
if coll != scn.collection:
|
|
bpy.data.collections.remove(coll)
|
|
_MAT_CACHE.clear()
|
|
|
|
|
|
def rng_for(name):
|
|
"""Deterministic per-asset RNG: asset N never depends on asset N-1's draws,
|
|
so --only <asset> produces byte-identical output to a full run."""
|
|
return random.Random(f"{SEED_SALT}:{name}")
|
|
|
|
|
|
def stamp(root, asset_name, kind):
|
|
root["shades_asset"] = asset_name
|
|
root["shades_kind"] = kind
|
|
root["shades_source"] = "build_yard_assets.py"
|
|
|
|
|
|
def export_asset(root, out_path):
|
|
deselect_all_no_ops()
|
|
|
|
def sel(o):
|
|
try:
|
|
o.select_set(True)
|
|
except Exception:
|
|
pass
|
|
for c in o.children:
|
|
sel(c)
|
|
|
|
sel(root)
|
|
bpy.context.view_layer.objects.active = root
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=out_path,
|
|
use_selection=True,
|
|
export_format='GLB',
|
|
export_yup=True,
|
|
export_extras=True,
|
|
export_apply=True,
|
|
export_materials='EXPORT',
|
|
export_image_format='AUTO',
|
|
export_lights=False,
|
|
export_cameras=False,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# BUILDERS — one per asset, each returns the root empty
|
|
# ============================================================================
|
|
def build_ref_capsule(name):
|
|
"""The 1.7 m person every other asset is judged against. Loud pink on
|
|
purpose: if you can't see it in a contact sheet, the framing is wrong."""
|
|
root = add_empty(name)
|
|
mat = get_material("Mat_Ref", PAL["ref_pink"], 0.5)
|
|
r = 0.20
|
|
parts = [
|
|
add_cyl(f"{name}_body", r, REF_HEIGHT - 2 * r, (0, 0, REF_HEIGHT / 2),
|
|
mat, verts=16),
|
|
add_ico(f"{name}_bot", r, (0, 0, r), mat, subdiv=2),
|
|
add_ico(f"{name}_top", r, (0, 0, REF_HEIGHT - r), mat, subdiv=2),
|
|
]
|
|
join_group(parts, "ref_capsule_mesh", root)
|
|
add_empty("head_height", (0, 0, REF_HEIGHT), root, size=0.1)
|
|
stamp(root, name, "reference")
|
|
return root
|
|
|
|
|
|
def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name):
|
|
"""Eucalypt: pale chalky trunk, sparse olive canopy, low branches that a
|
|
landscaper would actually strap a sail to."""
|
|
rng = rng_for(seed_name)
|
|
root = add_empty(name)
|
|
bark = get_material("Mat_Bark", PAL["bark_gum"], 0.85)
|
|
bark_d = get_material("Mat_BarkShadow", PAL["bark_shadow"], 0.9)
|
|
leaf_a = get_material("Mat_Leaf", PAL["leaf_gum"], 0.8)
|
|
leaf_b = get_material("Mat_Leaf2", PAL["leaf_gum_2"], 0.8)
|
|
|
|
# Trunk: tapered, slight lean — no gum ever grew plumb.
|
|
r_base, r_top = height * 0.045, height * 0.022
|
|
trunk_h = height * 0.62
|
|
lean = rng.uniform(-0.04, 0.04)
|
|
trunk_parts = [add_cone(f"{name}_trunk_main", r_base, r_top, trunk_h,
|
|
(lean * trunk_h * 0.5, 0, trunk_h / 2), bark,
|
|
verts=10, rot=(0, lean, 0))]
|
|
# Root flare, so it doesn't look like a pipe stuck in the lawn.
|
|
trunk_parts.append(add_cone(f"{name}_flare", r_base * 1.55, r_base,
|
|
height * 0.05, (0, 0, height * 0.025), bark_d,
|
|
verts=10))
|
|
|
|
# Branches: each anchor height gets a real limb to hang off.
|
|
anchors = []
|
|
for i, ah in enumerate(anchor_heights):
|
|
ang = rng.uniform(0, math.tau)
|
|
reach = spread * rng.uniform(0.20, 0.30)
|
|
z0 = ah
|
|
base = (lean * z0, 0, z0)
|
|
tip = (base[0] + math.cos(ang) * reach,
|
|
base[1] + math.sin(ang) * reach,
|
|
z0 + reach * rng.uniform(0.35, 0.6))
|
|
trunk_parts.append(add_tube_between(
|
|
f"{name}_branch_{i:02d}", base, tip, r_top * rng.uniform(0.5, 0.7),
|
|
bark, verts=8))
|
|
anchors.append(tip)
|
|
|
|
join_group(trunk_parts, "trunk", root)
|
|
|
|
# Canopy: separate nodes — Lane A sways these, and only these.
|
|
top = (lean * trunk_h, 0, trunk_h)
|
|
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,
|
|
parent=root, subdiv=2,
|
|
scale=(1.0, 1.0, rng.uniform(0.55, 0.75)),
|
|
jitter=r * 0.10, rng=rng)
|
|
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):
|
|
return _gum_tree(name, height=8.4, canopy_blobs=3, spread=6.0,
|
|
anchor_heights=[2.6, 3.4, 4.3], seed_name=name)
|
|
|
|
|
|
def build_tree_gum_02(name):
|
|
return _gum_tree(name, height=5.6, canopy_blobs=2, spread=4.4,
|
|
anchor_heights=[2.3, 3.1], seed_name=name)
|
|
|
|
|
|
def build_fence_post(name):
|
|
root = add_empty(name)
|
|
timber = get_material("Mat_Timber", PAL["timber"], 0.85)
|
|
cap = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85)
|
|
h = 2.0
|
|
parts = [
|
|
add_box(f"{name}_shaft", (0.10, 0.10, h), (0, 0, h / 2), timber),
|
|
add_box(f"{name}_cap", (0.13, 0.13, 0.03), (0, 0, h + 0.015), cap),
|
|
]
|
|
join_group(parts, "post", root)
|
|
stamp(root, name, "fence")
|
|
root["tile_step"] = 2.4 # matches fence_panel width
|
|
return root
|
|
|
|
|
|
def build_fence_panel(name):
|
|
"""Tileable: exactly 2.4 m in X, centred on the origin, so Lane A can
|
|
instance at x = i * 2.4 with no seam arithmetic."""
|
|
rng = rng_for(name)
|
|
root = add_empty(name)
|
|
timber = get_material("Mat_Timber", PAL["timber"], 0.85)
|
|
rail_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85)
|
|
width, h = 2.4, 1.8
|
|
pw, gap = 0.09, 0.006
|
|
step = pw + gap
|
|
n = int(width / step)
|
|
# Distribute the rounding slop into the gaps so the panel is exactly 2.4.
|
|
step = width / n
|
|
|
|
palings = []
|
|
for i in range(n):
|
|
x = -width / 2 + step * (i + 0.5)
|
|
# Palings weather unevenly; a few mm of height scatter kills the
|
|
# picket-fence-perfect look for free.
|
|
ph = h + rng.uniform(-0.02, 0.02)
|
|
palings.append(add_box(f"{name}_paling_{i:02d}", (pw, 0.019, ph),
|
|
(x, 0, ph / 2), timber))
|
|
join_group(palings, "palings", root)
|
|
|
|
rails = [add_box(f"{name}_rail_{j}", (width, 0.035, 0.07),
|
|
(0, 0.027, z), rail_m)
|
|
for j, z in ((0, 0.35), (1, 1.45))]
|
|
join_group(rails, "rails", root)
|
|
stamp(root, name, "fence")
|
|
root["tile_step"] = width
|
|
return root
|
|
|
|
|
|
def build_gate(name):
|
|
"""Origin at the hinge edge, base — so Lane A swings it by rotating the root
|
|
about Z. `hinge_axis` marks it explicitly."""
|
|
root = add_empty(name)
|
|
timber = get_material("Mat_Timber", PAL["timber"], 0.85)
|
|
rail_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85)
|
|
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.9)
|
|
w, h = 1.0, 1.75
|
|
pw, gap = 0.09, 0.008
|
|
step = pw + gap
|
|
n = int(w / step)
|
|
step = w / n
|
|
|
|
palings = [add_box(f"{name}_paling_{i:02d}", (pw, 0.019, h),
|
|
(step * (i + 0.5), 0, h / 2 + 0.08), timber)
|
|
for i in range(n)]
|
|
join_group(palings, "gate_palings", root)
|
|
|
|
frame = [
|
|
add_box(f"{name}_rail_top", (w, 0.032, 0.07), (w / 2, 0.026, 1.70),
|
|
rail_m),
|
|
add_box(f"{name}_rail_bot", (w, 0.032, 0.07), (w / 2, 0.026, 0.24),
|
|
rail_m),
|
|
]
|
|
# The diagonal brace runs from the bottom hinge corner UP to the far top —
|
|
# that's the direction that carries the leaf in compression. Get it backwards
|
|
# and a real gate droops within a season.
|
|
frame.append(add_tube_between(f"{name}_brace", (0.06, 0.026, 0.26),
|
|
(w - 0.06, 0.026, 1.68), 0.022, rail_m,
|
|
verts=6))
|
|
join_group(frame, "gate_frame", root)
|
|
|
|
hinges = [add_cyl(f"{name}_hinge_{i}", 0.022, 0.09, (0.0, 0.03, z), steel,
|
|
verts=8, rot=(0, math.pi / 2, 0))
|
|
for i, z in ((0, 0.30), (1, 1.62))]
|
|
join_group(hinges, "hinges", root)
|
|
|
|
add_empty("hinge_axis", (0, 0, 0), root, size=0.3)
|
|
stamp(root, name, "fence")
|
|
root["swing_deg"] = 100
|
|
return root
|
|
|
|
|
|
def build_house_yardside(name):
|
|
"""Rear façade only — no interior. The fascia is the point: DESIGN.md says
|
|
it holds until the first real gust, then leaves with the gutter."""
|
|
root = add_empty(name)
|
|
wall_m = get_material("Mat_Render", PAL["render_wall"], 0.9)
|
|
brick_m = get_material("Mat_Brick", PAL["brick"], 0.9)
|
|
trim = get_material("Mat_Timber", PAL["timber_dark"], 0.8)
|
|
roof_m = get_material("Mat_Roof", PAL["roof_tile"], 0.85)
|
|
glass_m = get_material("Mat_Glass", PAL["glass"], 0.15, opacity=0.55)
|
|
gutter_m = get_material("Mat_Colorbond", PAL["colorbond"], 0.5,
|
|
metallic=0.6)
|
|
|
|
W, H, D = 9.0, 2.70, 0.30
|
|
# Wall as a ring of boxes around the openings — cheaper than a boolean and
|
|
# it never produces the n-gon mess booleans leave behind.
|
|
door_w, door_h, door_x = 0.90, 2.05, -2.4
|
|
win_w, win_h, win_z, win_x = 1.80, 1.10, 1.55, 1.9
|
|
|
|
wall = []
|
|
wall.append(add_box(f"{name}_plinth", (W, D + 0.06, 0.35),
|
|
(0, 0, 0.175), brick_m))
|
|
seg_l = door_x - door_w / 2 - (-W / 2)
|
|
wall.append(add_box(f"{name}_w_left", (seg_l, D, H - 0.35),
|
|
(-W / 2 + seg_l / 2, 0, 0.35 + (H - 0.35) / 2), wall_m))
|
|
mid_l = win_x - win_w / 2 - (door_x + door_w / 2)
|
|
wall.append(add_box(f"{name}_w_mid", (mid_l, D, H - 0.35),
|
|
(door_x + door_w / 2 + mid_l / 2, 0,
|
|
0.35 + (H - 0.35) / 2), wall_m))
|
|
seg_r = W / 2 - (win_x + win_w / 2)
|
|
wall.append(add_box(f"{name}_w_right", (seg_r, D, H - 0.35),
|
|
(win_x + win_w / 2 + seg_r / 2, 0,
|
|
0.35 + (H - 0.35) / 2), wall_m))
|
|
wall.append(add_box(f"{name}_w_overdoor", (door_w, D, H - door_h),
|
|
(door_x, 0, door_h + (H - door_h) / 2), wall_m))
|
|
wall.append(add_box(f"{name}_w_underwin", (win_w, D, win_z - 0.35),
|
|
(win_x, 0, 0.35 + (win_z - 0.35) / 2), wall_m))
|
|
wall.append(add_box(f"{name}_w_overwin", (win_w, D, H - win_z - win_h),
|
|
(win_x, 0, win_z + win_h + (H - win_z - win_h) / 2),
|
|
wall_m))
|
|
join_group(wall, "wall", root)
|
|
|
|
join_group([add_box(f"{name}_door_leaf", (door_w - 0.04, 0.05,
|
|
door_h - 0.04),
|
|
(door_x, -D / 2 + 0.03, (door_h - 0.04) / 2 + 0.02),
|
|
trim)], "door", root)
|
|
join_group([add_box(f"{name}_win_glass", (win_w - 0.08, 0.02,
|
|
win_h - 0.08),
|
|
(win_x, -D / 2 + 0.04, win_z + win_h / 2), glass_m),
|
|
add_box(f"{name}_win_frame", (win_w, 0.04, win_h),
|
|
(win_x, -D / 2 + 0.02, win_z + win_h / 2), trim)],
|
|
"window", root)
|
|
|
|
# Eave + fascia + gutter. The eave overhangs 0.55 into the yard (-Y).
|
|
eave_y = -0.55
|
|
fascia_z = H + 0.10
|
|
join_group([add_box(f"{name}_roof", (W + 0.2, D + 0.75, 0.10),
|
|
(0, (eave_y + D / 2) / 2, H + 0.05), roof_m,
|
|
rot=(math.radians(-6), 0, 0))], "roof", root)
|
|
fascia = add_box("fascia", (W + 0.2, 0.035, 0.20), (0, eave_y, fascia_z),
|
|
trim, parent=root)
|
|
gutter = join_group([
|
|
add_cyl(f"{name}_gutter_run", 0.055, W + 0.2,
|
|
(0, eave_y - 0.05, fascia_z - 0.12), gutter_m, verts=8,
|
|
rot=(0, math.pi / 2, 0)),
|
|
add_cyl(f"{name}_downpipe", 0.04, H,
|
|
(W / 2 - 0.25, eave_y - 0.05, H / 2), gutter_m, verts=8),
|
|
], "gutter", root)
|
|
gutter["collateral_of"] = "fascia" # rip the fascia, the gutter goes too
|
|
|
|
# fascia_anchor_* — scarce, fixed, and a lie. Three of them, spread wide.
|
|
for i, fx in enumerate((-3.0, 0.0, 3.0)):
|
|
e = add_empty(f"fascia_anchor_{i + 1:02d}", (fx, eave_y, fascia_z - 0.06),
|
|
root, size=0.2)
|
|
e["anchor_type"] = "house"
|
|
e["rating_hint"] = 0.35 # low: this is the trap anchor
|
|
e["collateral"] = "gutter"
|
|
stamp(root, name, "structure")
|
|
root["facade_width"] = W
|
|
return root
|
|
|
|
|
|
def build_shed_01(name):
|
|
"""Colorbond garden shed, skillion roof. Spare hardware lives in here."""
|
|
root = add_empty(name)
|
|
sheet = get_material("Mat_Colorbond", PAL["colorbond"], 0.45, metallic=0.5)
|
|
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.5, metallic=0.5)
|
|
slab = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
|
W, D, H = 2.40, 1.80, 2.05
|
|
fall = 0.22 # skillion drop front-to-back
|
|
|
|
parts = [add_box(f"{name}_slab", (W + 0.16, D + 0.16, 0.08),
|
|
(0, 0, 0.04), slab)]
|
|
parts.append(add_box(f"{name}_back", (W, 0.04, H),
|
|
(0, D / 2, 0.08 + H / 2), sheet))
|
|
parts.append(add_box(f"{name}_left", (0.04, D, H - fall / 2),
|
|
(-W / 2, 0, 0.08 + (H - fall / 2) / 2), sheet))
|
|
parts.append(add_box(f"{name}_right", (0.04, D, H - fall / 2),
|
|
(W / 2, 0, 0.08 + (H - fall / 2) / 2), sheet))
|
|
parts.append(add_box(f"{name}_front", (W, 0.04, H - fall),
|
|
(0, -D / 2, 0.08 + (H - fall) / 2), sheet))
|
|
join_group(parts, "shell", root)
|
|
|
|
join_group([add_box(f"{name}_roof", (W + 0.18, D + 0.18, 0.045),
|
|
(0, 0, 0.08 + H - fall / 2 + 0.06), sheet,
|
|
rot=(math.radians(math.degrees(math.atan2(fall, D))),
|
|
0, 0))], "roof", root)
|
|
join_group([
|
|
add_box(f"{name}_door_l", (W / 2 - 0.06, 0.02, H - fall - 0.16),
|
|
(-W / 4, -D / 2 - 0.03, 0.08 + (H - fall - 0.16) / 2), dark),
|
|
add_box(f"{name}_door_r", (W / 2 - 0.06, 0.02, H - fall - 0.16),
|
|
(W / 4, -D / 2 - 0.03, 0.08 + (H - fall - 0.16) / 2), dark),
|
|
], "doors", root)
|
|
add_empty("door_anchor", (0, -D / 2 - 0.6, 0.9), root, size=0.2)
|
|
stamp(root, name, "structure")
|
|
return root
|
|
|
|
|
|
def build_shed_table(name):
|
|
"""The spare-hardware pickup point. `pickup_anchor` is where Lane D should
|
|
register the hold-E, so the prompt lands on the bench top, not the floor."""
|
|
root = add_empty(name)
|
|
timber = get_material("Mat_Timber", PAL["timber"], 0.8)
|
|
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
|
|
W, D, H = 1.60, 0.60, 0.90
|
|
top = add_box("table_top", (W, D, 0.045), (0, 0, H - 0.0225), timber,
|
|
parent=root)
|
|
legs = []
|
|
for sx in (-1, 1):
|
|
for sy in (-1, 1):
|
|
legs.append(add_box(f"{name}_leg_{sx}_{sy}", (0.05, 0.05, H - 0.045),
|
|
(sx * (W / 2 - 0.07), sy * (D / 2 - 0.07),
|
|
(H - 0.045) / 2), steel))
|
|
legs.append(add_box(f"{name}_shelf", (W - 0.16, D - 0.12, 0.03),
|
|
(0, 0, 0.22), timber))
|
|
join_group(legs, "table_frame", root)
|
|
add_empty("pickup_anchor", (0, 0, H + 0.05), root, size=0.2)
|
|
stamp(root, name, "prop")
|
|
return root
|
|
|
|
|
|
def _plant_tuft(prefix, origin, mat, rng, blades, height, lean, parts):
|
|
"""A tuft of tapered blades fanning from a point. Cheap, and reads as a
|
|
plant instead of the green X's you get from crossed quads."""
|
|
for b in range(blades):
|
|
ang = math.tau * b / blades + rng.uniform(-0.25, 0.25)
|
|
hgt = height * rng.uniform(0.7, 1.15)
|
|
tip = (origin[0] + math.cos(ang) * lean * hgt,
|
|
origin[1] + math.sin(ang) * lean * hgt,
|
|
origin[2] + hgt)
|
|
mid = (origin[0] + math.cos(ang) * lean * hgt * 0.35,
|
|
origin[1] + math.sin(ang) * lean * hgt * 0.35,
|
|
origin[2] + hgt * 0.6)
|
|
parts.append(add_tube_between(f"{prefix}_b{b}_lo", origin, mid,
|
|
0.012, mat, verts=4))
|
|
parts.append(add_tube_between(f"{prefix}_b{b}_hi", mid, tip,
|
|
0.005, mat, verts=4))
|
|
|
|
|
|
def build_garden_bed(name):
|
|
"""Raised sleeper bed + THREE plant states as sibling nodes in one GLB:
|
|
plants_full / plants_tattered / plants_dead. Lane A toggles .visible — one
|
|
load, instant swap, no pop-in, and no morph-target export risk."""
|
|
rng = rng_for(name)
|
|
root = add_empty(name)
|
|
sleeper = get_material("Mat_Timber", PAL["timber_dark"], 0.9)
|
|
soil = get_material("Mat_Soil", PAL["soil"], 1.0)
|
|
W, D, H = 3.0, 1.2, 0.40
|
|
|
|
frame = []
|
|
for sy in (-1, 1):
|
|
frame.append(add_box(f"{name}_side_{sy}", (W, 0.05, H),
|
|
(0, sy * (D / 2 - 0.025), H / 2), sleeper))
|
|
for sx in (-1, 1):
|
|
frame.append(add_box(f"{name}_end_{sx}", (0.05, D - 0.1, H),
|
|
(sx * (W / 2 - 0.025), 0, H / 2), sleeper))
|
|
join_group(frame, "bed", root)
|
|
join_group([add_box(f"{name}_soil", (W - 0.1, D - 0.1, 0.06),
|
|
(0, 0, H - 0.05), soil)], "soil", root)
|
|
|
|
# Same tuft positions across all three states — the bed must not appear to
|
|
# rearrange itself when it takes damage, only to wilt.
|
|
spots = []
|
|
for i in range(7):
|
|
spots.append((-W / 2 + 0.35 + i * ((W - 0.7) / 6.0),
|
|
rng.uniform(-D / 4, D / 4), H - 0.02))
|
|
|
|
for state, mat_hex, blades, hgt, lean in (
|
|
("full", PAL["plant_full"], 7, 0.42, 0.30),
|
|
("tattered", PAL["plant_tatty"], 5, 0.26, 0.55),
|
|
("dead", PAL["plant_dead"], 3, 0.15, 0.85)):
|
|
mat = get_material(f"Mat_Plant_{state}", mat_hex, 0.9)
|
|
srng = rng_for(f"{name}:{state}")
|
|
parts = []
|
|
for i, sp in enumerate(spots):
|
|
_plant_tuft(f"{name}_{state}_{i}", sp, mat, srng, blades, hgt,
|
|
lean, parts)
|
|
node = join_group(parts, f"plants_{state}", root)
|
|
if node:
|
|
node["damage_state"] = state
|
|
# Only `full` ships visible; Lane A swaps by toggling these.
|
|
node.hide_render = (state != "full")
|
|
stamp(root, name, "garden")
|
|
root["states"] = "full,tattered,dead"
|
|
root["bed_size"] = f"{W}x{D}"
|
|
return root
|
|
|
|
|
|
def build_sail_post(name):
|
|
"""Exported VERTICAL. DESIGN.md says correct practice is to rake the post
|
|
away from the load — but that's the player's call, so rake is a runtime
|
|
rotation about `rake_pivot`, not baked geometry."""
|
|
root = add_empty(name)
|
|
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.35, metallic=0.9)
|
|
dark = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
|
|
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
|
H, R = 4.0, 0.048
|
|
|
|
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)
|
|
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", root)
|
|
# Pad eye at the head — where the corner chain actually clips on.
|
|
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", root)
|
|
|
|
e = add_empty("top_anchor", (0, 0, H - 0.02), root, size=0.2)
|
|
e["anchor_type"] = "post"
|
|
e["rating_hint"] = 0.9
|
|
add_empty("rake_pivot", (0, 0, 0.12), root, size=0.25)
|
|
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
|
|
|
|
|
|
# ============================================================================
|
|
# GRASS ATLAS — a texture, not geometry (PLAN3D §5-E item 9)
|
|
# ============================================================================
|
|
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)
|
|
|
|
os.makedirs(TEXTURES_DIR, exist_ok=True)
|
|
out = os.path.join(TEXTURES_DIR, "grass_atlas.png")
|
|
bimg = bpy.data.images.new("grass_atlas", SIZE, SIZE, alpha=True)
|
|
bimg.pixels.foreach_set(img.reshape(-1))
|
|
bimg.filepath_raw = out
|
|
bimg.file_format = 'PNG'
|
|
bimg.save()
|
|
bpy.data.images.remove(bimg)
|
|
kb = os.path.getsize(out) // 1024
|
|
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_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_01", "canopy_02",
|
|
"branch_anchor_01", "branch_anchor_02"]),
|
|
dict(name="fence_post", fn=build_fence_post,
|
|
dims=((0.10, 0.16), (0.10, 0.16), (1.95, 2.10)),
|
|
nodes=["post"]),
|
|
dict(name="fence_panel", fn=build_fence_panel,
|
|
dims=((2.38, 2.42), (0.03, 0.10), (1.75, 1.85)),
|
|
nodes=["palings", "rails"]),
|
|
dict(name="gate", fn=build_gate,
|
|
dims=((0.95, 1.10), (0.03, 0.12), (1.70, 1.85)),
|
|
nodes=["gate_palings", "gate_frame", "hinges", "hinge_axis"]),
|
|
dict(name="house_yardside", fn=build_house_yardside,
|
|
dims=((9.0, 9.5), (0.8, 1.6), (2.8, 3.3)),
|
|
nodes=["wall", "door", "window", "roof", "fascia", "gutter",
|
|
"fascia_anchor_01", "fascia_anchor_02", "fascia_anchor_03"]),
|
|
dict(name="shed_01", fn=build_shed_01,
|
|
dims=((2.4, 2.7), (1.8, 2.1), (1.95, 2.25)),
|
|
nodes=["shell", "roof", "doors", "door_anchor"]),
|
|
dict(name="shed_table", fn=build_shed_table,
|
|
dims=((1.55, 1.65), (0.55, 0.65), (0.85, 0.95)),
|
|
nodes=["table_top", "table_frame", "pickup_anchor"]),
|
|
dict(name="garden_bed", fn=build_garden_bed,
|
|
dims=((2.95, 3.15), (1.15, 1.35), (0.55, 1.00)),
|
|
nodes=["bed", "soil", "plants_full", "plants_tattered",
|
|
"plants_dead"]),
|
|
dict(name="sail_post", fn=build_sail_post,
|
|
dims=((0.40, 0.60), (0.40, 0.60), (3.95, 4.10)),
|
|
nodes=["footing", "post", "pad_eye", "top_anchor", "rake_pivot"]),
|
|
dict(name="ladder_01", fn=build_ladder_01,
|
|
dims=((0.40, 0.50), (0.05, 0.20), (2.95, 3.05)),
|
|
nodes=["ladder", "ladder_base", "ladder_top"]),
|
|
dict(name="shackle", fn=build_shackle,
|
|
dims=((0.03, 0.07), (0.005, 0.02), (0.05, 0.09)),
|
|
nodes=["bow", "pin"]),
|
|
dict(name="carabiner", fn=build_carabiner,
|
|
dims=((0.045, 0.07), (0.005, 0.02), (0.085, 0.11)),
|
|
nodes=["body", "gate"]),
|
|
dict(name="turnbuckle", fn=build_turnbuckle,
|
|
dims=((0.015, 0.05), (0.015, 0.05), (0.12, 0.20)),
|
|
nodes=["body", "eye_a", "eye_b"]),
|
|
# Lands in models/debris/ — Lane C spawns debris from that directory.
|
|
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"]),
|
|
]
|
|
|
|
|
|
def asset_path(a):
|
|
return os.path.join(a.get("dir", MODELS_DIR), f"{a['name']}_v1.glb")
|
|
|
|
|
|
# ============================================================================
|
|
# BUILD
|
|
# ============================================================================
|
|
def build_all(only=None):
|
|
os.makedirs(MODELS_DIR, exist_ok=True)
|
|
os.makedirs(DEBRIS_DIR, exist_ok=True)
|
|
todo = [a for a in ASSETS if only is None or a["name"] in only]
|
|
print(f"\n--- BUILD {len(todo)} ASSETS -> {MODELS_DIR} ---\n")
|
|
built = []
|
|
for a in todo:
|
|
name = a["name"]
|
|
reset_to_empty()
|
|
root = a["fn"](name)
|
|
out = asset_path(a)
|
|
export_asset(root, out)
|
|
tris = count_tris_in_scene()
|
|
kb = os.path.getsize(out) // 1024
|
|
flag = "" if tris <= TRI_BUDGET else f" ** OVER {TRI_BUDGET} TRI BUDGET **"
|
|
print(f" {name:<18s} -> {os.path.basename(out):<26s} "
|
|
f"({tris:>6,d} tris, {kb:>4d} KB){flag}")
|
|
built.append(name)
|
|
return built
|
|
|
|
|
|
def count_tris_in_scene():
|
|
total = 0
|
|
dg = bpy.context.evaluated_depsgraph_get()
|
|
for o in bpy.data.objects:
|
|
if o.type != 'MESH':
|
|
continue
|
|
eo = o.evaluated_get(dg)
|
|
me = eo.to_mesh()
|
|
me.calc_loop_triangles()
|
|
total += len(me.loop_triangles)
|
|
eo.to_mesh_clear()
|
|
return total
|
|
|
|
|
|
# ============================================================================
|
|
# DEBRIS — copy verbatim from the library, then measure. House rule: runtime
|
|
# GLBs are COPIES; if the scale is wrong we fix it at source, not here.
|
|
# ============================================================================
|
|
def import_glb(path):
|
|
before = set(bpy.data.objects)
|
|
bpy.ops.import_scene.gltf(filepath=path)
|
|
new = [o for o in bpy.data.objects if o not in before]
|
|
for o in new:
|
|
# THE gotcha: the glTF importer leaves rotation_mode='QUATERNION' and
|
|
# every later rotation_euler write is silently dropped. Force XYZ now,
|
|
# BEFORE anything downstream touches rotation.
|
|
o.rotation_mode = 'XYZ'
|
|
return new
|
|
|
|
|
|
def measure_bounds(objs):
|
|
"""World-space AABB over real VERTICES.
|
|
|
|
Do not be tempted by obj.bound_box here: that is the LOCAL box, and pushing
|
|
its 8 corners through matrix_world over-estimates for any rotated object —
|
|
you get the AABB of the rotated box, not of the geometry. join_group leaves
|
|
each joined node carrying parts[0]'s rotation, so every tube-built asset
|
|
(arcs, branches, plant blades) measured ~11% too wide that way, purely as a
|
|
reporting artefact. Vertices are exact and cheap at these poly counts.
|
|
"""
|
|
lo = Vector((1e9, 1e9, 1e9))
|
|
hi = Vector((-1e9, -1e9, -1e9))
|
|
found = False
|
|
dg = bpy.context.evaluated_depsgraph_get()
|
|
for o in objs:
|
|
if o.type != 'MESH':
|
|
continue
|
|
eo = o.evaluated_get(dg)
|
|
me = eo.to_mesh()
|
|
mw = o.matrix_world
|
|
for v in me.vertices:
|
|
w = mw @ v.co
|
|
lo = Vector((min(lo[i], w[i]) for i in range(3)))
|
|
hi = Vector((max(hi[i], w[i]) for i in range(3)))
|
|
found = True
|
|
eo.to_mesh_clear()
|
|
return (lo, hi) if found else None
|
|
|
|
|
|
def measure_objects(objs):
|
|
b = measure_bounds(objs)
|
|
if b is None:
|
|
return (0.0, 0.0, 0.0)
|
|
lo, hi = b
|
|
return tuple(round(hi[i] - lo[i], 4) for i in range(3))
|
|
|
|
|
|
def copy_debris():
|
|
src = next((d for d in DEBRIS_SOURCES if os.path.isdir(d)), None)
|
|
if src is None:
|
|
print("\n ! debris library not found; checked:")
|
|
for d in DEBRIS_SOURCES:
|
|
print(f" {d}")
|
|
print(" skipping debris copy (models/debris/ left as-is)\n")
|
|
return []
|
|
os.makedirs(DEBRIS_DIR, exist_ok=True)
|
|
print(f"\n--- DEBRIS (copies from {src}) ---\n")
|
|
out = []
|
|
for fn in DEBRIS_FILES:
|
|
s = os.path.join(src, fn)
|
|
if not os.path.isfile(s):
|
|
print(f" ! missing in library: {fn}")
|
|
continue
|
|
d = os.path.join(DEBRIS_DIR, fn)
|
|
shutil.copy2(s, d)
|
|
reset_to_empty()
|
|
objs = import_glb(d)
|
|
dims = measure_objects(objs)
|
|
# A storm projectile has to be a believable real-world object; anything
|
|
# outside this is authored in the wrong unit and needs a source fix.
|
|
sane = all(0.15 <= v <= 1.5 for v in dims)
|
|
print(f" {fn:<20s} {dims[0]:.2f} x {dims[1]:.2f} x {dims[2]:.2f} m "
|
|
f"{'ok' if sane else '** SCALE SUSPECT — fix at source **'}")
|
|
out.append(dict(file=fn, dims=dims, sane=sane))
|
|
reset_to_empty()
|
|
return out
|
|
|
|
|
|
# ============================================================================
|
|
# VERIFY — re-import each exported GLB fresh and prove it, then render it
|
|
# against the 1.7 m capsule. This checks the FILE, not the in-memory scene.
|
|
# ============================================================================
|
|
def setup_render_scene():
|
|
scn = bpy.context.scene
|
|
scn.render.engine = 'BLENDER_EEVEE'
|
|
scn.render.resolution_x = 420
|
|
scn.render.resolution_y = 420
|
|
scn.render.film_transparent = False
|
|
scn.world = bpy.data.worlds.new("W")
|
|
scn.world.use_nodes = True
|
|
bg = scn.world.node_tree.nodes.get("Background")
|
|
if bg:
|
|
bg.inputs[0].default_value = (0.16, 0.17, 0.19, 1.0)
|
|
bpy.ops.object.light_add(type='SUN', location=(4, -6, 9))
|
|
sun = _active()
|
|
sun.data.energy = 4.0
|
|
sun.rotation_mode = 'XYZ'
|
|
sun.rotation_euler = (math.radians(52), 0, math.radians(35))
|
|
bpy.ops.object.camera_add(location=(0, -6, 2))
|
|
cam = _active()
|
|
cam.rotation_mode = 'XYZ'
|
|
scn.camera = cam
|
|
return cam
|
|
|
|
|
|
def frame_camera(cam, target, radius):
|
|
"""3/4 view fitted to a bounding sphere, so a 0.06 m shackle and an 8.4 m gum
|
|
each fill their own tile. Distance comes from the lens, not a magic number:
|
|
to fit radius R at half-FOV a, you need R / sin(a)."""
|
|
half_fov = cam.data.angle / 2.0
|
|
d = max(radius / max(math.sin(half_fov), 1e-3) * 1.15, 0.05)
|
|
az, el = math.radians(52), math.radians(20)
|
|
pos = Vector(target) + Vector((math.sin(az) * d * math.cos(el),
|
|
-math.cos(az) * d * math.cos(el),
|
|
math.sin(el) * d))
|
|
cam.location = pos
|
|
cam.rotation_euler = (Vector(target) - pos).to_track_quat('-Z', 'Y').to_euler()
|
|
# Small assets need the near clip pulled in or a shackle vanishes entirely.
|
|
cam.data.clip_start = min(0.1, d * 0.05)
|
|
|
|
|
|
def add_label(cam, text):
|
|
bpy.ops.object.text_add(location=(0, 0, 0))
|
|
t = _active()
|
|
t.data.body = text
|
|
t.data.align_x = 'CENTER'
|
|
t.data.size = 0.030 # camera frame at z=-1 is only ~±0.36 wide
|
|
t.parent = cam # parented to the camera = always in frame
|
|
t.location = (0.0, -0.29, -1.0)
|
|
t.rotation_mode = 'XYZ'
|
|
t.rotation_euler = (0, 0, 0)
|
|
m = get_material("Mat_Label", "#FFFFFF", 1.0)
|
|
t.data.materials.append(m)
|
|
return t
|
|
|
|
|
|
def verify_all(only=None):
|
|
todo = [a for a in ASSETS if only is None or a["name"] in only]
|
|
print(f"\n--- VERIFY {len(todo)} GLBs (re-import + assert + render) ---\n")
|
|
os.makedirs(os.path.join(SCRIPT_DIR, "thumbs"), exist_ok=True)
|
|
report, failures, thumbs = [], [], []
|
|
|
|
for a in todo:
|
|
name = a["name"]
|
|
path = asset_path(a)
|
|
if not os.path.isfile(path):
|
|
failures.append(f"{name}: GLB missing at {path}")
|
|
continue
|
|
reset_to_empty()
|
|
cam = setup_render_scene()
|
|
objs = import_glb(path)
|
|
names = {o.name for o in objs}
|
|
dims = measure_objects(objs)
|
|
tris = count_tris_in_scene()
|
|
|
|
problems = []
|
|
for i, axis in enumerate("xyz"):
|
|
lo, hi = a["dims"][i]
|
|
if not (lo <= dims[i] <= hi):
|
|
problems.append(f"{axis}={dims[i]:.3f} outside [{lo}, {hi}]")
|
|
missing = [n for n in a["nodes"] if n not in names]
|
|
if missing:
|
|
problems.append(f"nodes missing after round-trip: {missing}")
|
|
if tris > TRI_BUDGET:
|
|
problems.append(f"{tris} tris > {TRI_BUDGET} budget")
|
|
|
|
# The capsule beside it — the actual acceptance criterion. Skipped for
|
|
# hardware: a 1.7 m human next to a 60 mm shackle tells you nothing and
|
|
# zooms the shackle down to one pixel. Below 0.30 m the printed dims are
|
|
# the scale check, and the tile's job is proving the thing READS.
|
|
show_capsule = name != "ref_capsule" and max(dims) >= 0.30
|
|
if show_capsule:
|
|
build_ref_capsule("ref_capsule")
|
|
for o in bpy.data.objects:
|
|
if o.name.startswith("ref_capsule_mesh"):
|
|
o.location.x = dims[0] / 2 + 0.55
|
|
|
|
bounds = measure_bounds([o for o in bpy.data.objects
|
|
if o.type == 'MESH'])
|
|
lo, hi = bounds
|
|
frame_camera(cam, (lo + hi) / 2.0, max((hi - lo).length / 2.0, 0.04))
|
|
add_label(cam, f"{name}\n{dims[0]:.2f} x {dims[1]:.2f} x {dims[2]:.2f} m"
|
|
f"\n{tris:,} tris")
|
|
thumb = os.path.join(SCRIPT_DIR, "thumbs", f"{name}.png")
|
|
bpy.context.scene.render.filepath = thumb
|
|
bpy.ops.render.render(write_still=True)
|
|
thumbs.append(thumb)
|
|
|
|
status = "PASS" if not problems else "FAIL"
|
|
if problems:
|
|
failures.append(f"{name}: " + "; ".join(problems))
|
|
print(f" [{status}] {name:<18s} {dims[0]:6.2f} x {dims[1]:5.2f} x "
|
|
f"{dims[2]:5.2f} m {tris:>6,d} tris")
|
|
for p in problems:
|
|
print(f" -> {p}")
|
|
report.append(dict(name=name, dims=dims, tris=tris,
|
|
nodes=sorted(names), status=status,
|
|
problems=problems))
|
|
return report, failures, thumbs
|
|
|
|
|
|
def make_contact_sheet(thumbs):
|
|
"""Tile the thumbnails into one sheet. numpy only — Blender ships no PIL."""
|
|
import numpy as np
|
|
if not thumbs:
|
|
return None
|
|
tiles = []
|
|
for t in thumbs:
|
|
if not os.path.isfile(t):
|
|
continue
|
|
im = bpy.data.images.load(t)
|
|
w, h = im.size
|
|
buf = np.empty(w * h * 4, dtype=np.float32)
|
|
im.pixels.foreach_get(buf)
|
|
tiles.append(buf.reshape(h, w, 4)[::-1]) # bpy rows are bottom-up
|
|
bpy.data.images.remove(im)
|
|
if not tiles:
|
|
return None
|
|
cols = 4
|
|
rows = (len(tiles) + cols - 1) // cols
|
|
th, tw = tiles[0].shape[0], tiles[0].shape[1]
|
|
sheet = np.zeros((rows * th, cols * tw, 4), dtype=np.float32)
|
|
sheet[:, :, 3] = 1.0
|
|
for i, tile in enumerate(tiles):
|
|
r, c = i // cols, i % cols
|
|
sheet[r * th:(r + 1) * th, c * tw:(c + 1) * tw] = tile
|
|
out_img = bpy.data.images.new("contact_sheet", cols * tw, rows * th,
|
|
alpha=True)
|
|
out_img.pixels.foreach_set(sheet[::-1].reshape(-1))
|
|
out_img.filepath_raw = CONTACT_SHEET
|
|
out_img.file_format = 'PNG'
|
|
out_img.save()
|
|
bpy.data.images.remove(out_img)
|
|
print(f"\n contact sheet -> {CONTACT_SHEET} ({cols}x{rows} tiles)")
|
|
return CONTACT_SHEET
|
|
|
|
|
|
# ============================================================================
|
|
# MAIN
|
|
# ============================================================================
|
|
def parse_args():
|
|
argv = sys.argv
|
|
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
|
only, no_verify, no_debris = None, False, False
|
|
if "--only" in argv:
|
|
only = set(argv[argv.index("--only") + 1].split(","))
|
|
if "--no-verify" in argv:
|
|
no_verify = True
|
|
if "--no-debris" in argv:
|
|
no_debris = True
|
|
return only, no_verify, no_debris
|
|
|
|
|
|
def main():
|
|
only, no_verify, no_debris = parse_args()
|
|
print("\n" + "=" * 72)
|
|
print("SHADES yard asset factory — Lane E")
|
|
print(f"Blender {bpy.app.version_string} repo: {REPO_ROOT}")
|
|
print("=" * 72)
|
|
|
|
build_all(only)
|
|
reset_to_empty()
|
|
build_grass_atlas()
|
|
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()
|