diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index 870fad6..aa1dcea 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -1,22 +1,6 @@ { "blender": "5.1.2", "assets": [ - { - "name": "ref_capsule", - "dims": [ - 0.4, - 0.4, - 1.7 - ], - "tris": 220, - "nodes": [ - "head_height", - "ref_capsule", - "ref_capsule_mesh" - ], - "status": "PASS", - "problems": [] - }, { "name": "tree_gum_01", "dims": [ @@ -29,6 +13,7 @@ "branch_anchor_01", "branch_anchor_02", "branch_anchor_03", + "canopy", "canopy_01", "canopy_02", "canopy_03", @@ -49,6 +34,7 @@ "nodes": [ "branch_anchor_01", "branch_anchor_02", + "canopy", "canopy_01", "canopy_02", "tree_gum_02", @@ -56,235 +42,6 @@ ], "status": "PASS", "problems": [] - }, - { - "name": "fence_post", - "dims": [ - 0.13, - 0.13, - 2.03 - ], - "tris": 24, - "nodes": [ - "fence_post", - "post" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "fence_panel", - "dims": [ - 2.4, - 0.054, - 1.8194 - ], - "tris": 324, - "nodes": [ - "fence_panel", - "palings", - "rails" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "gate", - "dims": [ - 1.045, - 0.0615, - 1.75 - ], - "tris": 220, - "nodes": [ - "gate", - "gate_frame", - "gate_palings", - "hinge_axis", - "hinges" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "house_yardside", - "dims": [ - 9.2, - 1.0547, - 2.9 - ], - "tris": 200, - "nodes": [ - "door", - "fascia", - "fascia_anchor_01", - "fascia_anchor_02", - "fascia_anchor_03", - "gutter", - "house_yardside", - "roof", - "wall", - "window" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "shed_01", - "dims": [ - 2.58, - 1.9708, - 2.2224 - ], - "tris": 96, - "nodes": [ - "door_anchor", - "doors", - "roof", - "shed_01", - "shell" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "shed_table", - "dims": [ - 1.6, - 0.6, - 0.9 - ], - "tris": 72, - "nodes": [ - "pickup_anchor", - "shed_table", - "table_frame", - "table_top" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "garden_bed", - "dims": [ - 3.0, - 1.2, - 0.8609 - ], - "tris": 2580, - "nodes": [ - "bed", - "garden_bed", - "plants_dead", - "plants_full", - "plants_tattered", - "soil" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "sail_post", - "dims": [ - 0.507, - 0.52, - 4.0327 - ], - "tris": 528, - "nodes": [ - "footing", - "pad_eye", - "post", - "rake_pivot", - "sail_post", - "top_anchor" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "ladder_01", - "dims": [ - 0.455, - 0.075, - 3.0 - ], - "tris": 276, - "nodes": [ - "ladder", - "ladder_01", - "ladder_base", - "ladder_top" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "shackle", - "dims": [ - 0.0569, - 0.019, - 0.0744 - ], - "tris": 560, - "nodes": [ - "bow", - "pin", - "shackle" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "carabiner", - "dims": [ - 0.049, - 0.009, - 0.1027 - ], - "tris": 476, - "nodes": [ - "body", - "carabiner", - "gate" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "turnbuckle", - "dims": [ - 0.0292, - 0.0341, - 0.1955 - ], - "tris": 728, - "nodes": [ - "body", - "eye_a", - "eye_b", - "turnbuckle" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "tramp_01", - "dims": [ - 2.9555, - 2.9555, - 0.78 - ], - "tris": 976, - "nodes": [ - "legs", - "mat", - "pad", - "rim", - "tramp_01" - ], - "status": "PASS", - "problems": [] } ], "debris": [ diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 16c950e..6691add 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -252,6 +252,20 @@ def add_tube_between(name, p0, p1, radius, material, parent=None, verts=8): 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() @@ -403,7 +417,8 @@ def build_ref_capsule(name): return root -def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name): +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) @@ -442,8 +457,23 @@ def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name): join_group(trunk_parts, "trunk", root) - # Canopy: separate nodes — Lane A sways these, and only these. + # 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) @@ -453,9 +483,10 @@ def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name): 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)), + 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. @@ -471,13 +502,17 @@ def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name): 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) + 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) + anchor_heights=[2.3, 3.1], seed_name=name, + sway_amp=1.20) def build_fence_post(name): @@ -1020,11 +1055,11 @@ ASSETS = [ 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", + 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_01", "canopy_02", + 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)), diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png index b603db2..59e4204 100644 Binary files a/tools/blender/contact_sheet.png and b/tools/blender/contact_sheet.png differ diff --git a/web/world/js/tests/e.test.js b/web/world/js/tests/e.test.js index 2fc8de3..d7f637a 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -148,6 +148,34 @@ export default async function run(t) { assert(Number.isFinite(p.x) && Number.isFinite(p.z), 'anchor world position is not finite'); }); + // A canopy that can't sway is just decoration, and the gust front the player + // reads a beat before it hits the sail (world.js) is the canopy leaning. Lane A + // rotates a `canopy` group; the blobs must swing about the TRUNK, not spin + // about their own centres — a sphere spinning in place is invisible, which is + // exactly the failure this catches. + t.test('canopy sways about the trunk when Lane A rotates it', () => { + const g = loaded.get('tree_gum_01'); + assert(g, 'tree_gum_01 did not load'); + const canopy = g.scene.getObjectByName('canopy'); + assert(canopy, 'no `canopy` group node — world.js rotates this to sway the tree'); + const blob = g.scene.getObjectByName('canopy_01'); + assert(blob, 'canopy_01 missing'); + + g.scene.updateWorldMatrix(true, true); + const before = new THREE.Vector3().setFromMatrixPosition(blob.matrixWorld); + const restZ = canopy.rotation.z; + canopy.rotation.z = restZ + 0.20; // ≈ world.js's max lean of 0.22 rad + canopy.updateWorldMatrix(true, true); + const after = new THREE.Vector3().setFromMatrixPosition(blob.matrixWorld); + canopy.rotation.z = restZ; + canopy.updateWorldMatrix(true, true); + + const moved = before.distanceTo(after); + assert(moved > 0.15, + `canopy_01 moved only ${moved.toFixed(3)} m under a 0.2 rad lean — the pivot is at ` + + 'the blob centre, not the trunk top, so the tree cannot visibly sway'); + }); + // One GLB carries three wilt states as siblings; Lane A toggles .visible // rather than reloading, so all three have to be present at once. t.test('garden_bed carries all 3 damage states in one GLB', () => { diff --git a/web/world/models/tree_gum_01_v1.glb b/web/world/models/tree_gum_01_v1.glb index f88b844..159f9b2 100644 Binary files a/web/world/models/tree_gum_01_v1.glb and b/web/world/models/tree_gum_01_v1.glb differ diff --git a/web/world/models/tree_gum_02_v1.glb b/web/world/models/tree_gum_02_v1.glb index e0705a7..c52ec85 100644 Binary files a/web/world/models/tree_gum_02_v1.glb and b/web/world/models/tree_gum_02_v1.glb differ