diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index aa1dcea..55e6812 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -1,6 +1,22 @@ { "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": [ @@ -42,6 +58,285 @@ ], "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": [] + }, + { + "name": "wheelie_bin_01", + "dims": [ + 0.58, + 0.6808, + 1.1188 + ], + "tris": 120, + "nodes": [ + "bin_body", + "lid", + "lid_plate", + "wheelie_bin_01", + "wheels" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "washing_line_01", + "dims": [ + 2.8441, + 2.8441, + 2.2777 + ], + "tris": 336, + "nodes": [ + "arms", + "head", + "mast", + "washing_line_01" + ], + "status": "PASS", + "problems": [] + }, + { + "name": "garden_gnome_01", + "dims": [ + 0.1427, + 0.15, + 0.365 + ], + "tris": 236, + "nodes": [ + "garden_gnome_01", + "gnome" + ], + "status": "PASS", + "problems": [] } ], "debris": [ diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 6691add..57ff64f 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -110,7 +110,13 @@ PAL = { "plant_full": "#5F8A3E", "plant_tatty": "#7A8446", "plant_dead": "#8A7550", - "mat_black": "#2E2E30", # trampoline mat + "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", "ref_pink": "#E85C8A", # the reference capsule — deliberately loud } @@ -988,9 +994,239 @@ def build_tramp_01(name): 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 + + # ============================================================================ # 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_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.""" @@ -1032,16 +1268,8 @@ def build_grass_atlas(): 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") + out, kb = save_png(img, "grass_atlas") + print(f" grass_atlas.png {SIZE}x{SIZE}, {CELLS * CELLS} tufts, {kb} KB") return out @@ -1099,10 +1327,19 @@ ASSETS = [ 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. + # 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"]), ] @@ -1417,6 +1654,7 @@ def main(): build_all(only) reset_to_empty() build_grass_atlas() + build_sail_textures() debris = [] if no_debris else copy_debris() failures = [] diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png index 59e4204..ce72d50 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 d7f637a..562470e 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -69,6 +69,10 @@ const ASSETS = [ { name: 'carabiner', h: [0.06, 0.15], nodes: ['body', 'gate'] }, { name: 'turnbuckle', h: [0.12, 0.25], nodes: ['body', 'eye_a', 'eye_b'] }, { name: 'tramp_01', h: [0.6, 1.0], nodes: ['mat', 'rim', 'pad', 'legs'], sub: 'debris/' }, + { name: 'wheelie_bin_01', h: [1.0, 1.2], + nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' }, + { name: 'washing_line_01', h: [2.0, 2.4], nodes: ['mast', 'head', 'arms'] }, + { name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] }, ]; function sizeOf(gltf) { @@ -176,6 +180,30 @@ export default async function run(t) { 'the blob centre, not the trunk top, so the tree cannot visibly sway'); }); + // Same pivot class as the canopy: the Hills Hoist head freewheels, so spinning + // it has to carry the arms round. If the arms were parented to the root, the + // head would turn and nothing would move. + t.test('washing line head carries the arms round when spun', () => { + const g = loaded.get('washing_line_01'); + assert(g, 'washing_line_01 did not load'); + const head = g.scene.getObjectByName('head'); + const arms = g.scene.getObjectByName('arms'); + assert(head && arms, 'washing_line_01 needs both `head` and `arms`'); + assert(arms.parent === head || arms.parent?.parent === head, + '`arms` is not under `head` — spinning the head would move nothing'); + + g.scene.updateWorldMatrix(true, true); + const before = new THREE.Vector3().setFromMatrixPosition(arms.matrixWorld); + head.rotation.y += Math.PI / 2; + head.updateWorldMatrix(true, true); + const after = new THREE.Vector3().setFromMatrixPosition(arms.matrixWorld); + head.rotation.y -= Math.PI / 2; + head.updateWorldMatrix(true, true); + // The arms group's own origin sits on the spin axis, so its centre barely + // moves — what must hold is that it is genuinely under the rotating node. + assert(Number.isFinite(before.x) && Number.isFinite(after.x), 'arms world position is not finite'); + }); + // 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/debris/wheelie_bin_01_v1.glb b/web/world/models/debris/wheelie_bin_01_v1.glb new file mode 100644 index 0000000..01b89f3 Binary files /dev/null and b/web/world/models/debris/wheelie_bin_01_v1.glb differ diff --git a/web/world/models/garden_gnome_01_v1.glb b/web/world/models/garden_gnome_01_v1.glb new file mode 100644 index 0000000..f74f106 Binary files /dev/null and b/web/world/models/garden_gnome_01_v1.glb differ diff --git a/web/world/models/textures/sail_tears.png b/web/world/models/textures/sail_tears.png new file mode 100644 index 0000000..525c236 Binary files /dev/null and b/web/world/models/textures/sail_tears.png differ diff --git a/web/world/models/textures/sail_weave.png b/web/world/models/textures/sail_weave.png new file mode 100644 index 0000000..cdc5063 Binary files /dev/null and b/web/world/models/textures/sail_weave.png differ diff --git a/web/world/models/washing_line_01_v1.glb b/web/world/models/washing_line_01_v1.glb new file mode 100644 index 0000000..f212d8f Binary files /dev/null and b/web/world/models/washing_line_01_v1.glb differ