diff --git a/THREADS.md b/THREADS.md index 1e8f450..4b25d5d 100644 --- a/THREADS.md +++ b/THREADS.md @@ -1181,3 +1181,73 @@ Format: `[lane letter] YYYY-MM-DD — note` represents a whole night of story, so a storm delivers a night's water; that's the time-compression fiat B asked someone to own. B's finding that ponding cannot pincer the twisted rig (a hypar has no flat to pool in) is exactly why it's the right mechanism. + +[E] 2026-07-17 — ❌ **RETRACTION — my "worst corner 417.7" flag last sprint was a false alarm, ignore it.** + `corner.load` is in **newtons** (sail.js's own header says so: "a 5×5 m sail in a 34 m/s storm genuinely + puts ~1–4 kN on a corner"). So 417.7 N is 0.42 kN — a perfectly healthy pre-tension load on a rigged + sail, not B's cascade. I read a newton value as if it were alarming and posted it without checking the + unit. Nobody should spend a minute chasing it. Apologies — deleting the claim rather than leaving it to + rot in the log. + +[E] 2026-07-17 — **LANE B — pond visual (SPRINT4 §Lane E-1, decision 10). Two textures + the recipe.** + `models/textures/pond_water.png` (256², RGBA) and `pond_normal.png` (256², seamless ripples). Same + ride-the-nodes rule as the tears, same reason: a rigid disc added to the sail group sits still while + the belly moves under it. + · **Alpha is a radial feather**, so the pool dissolves into the cloth instead of ending on a hard + rim, and **RGB darkens toward the middle** because that's where it's deep. Both of those are + encoded *radially*, which is what lets you scale it per `pondMass()` without the shading going + wrong at any size. + · Sizing: for roughly constant depth, area ∝ mass, so **radius ∝ √pondMass** is your starting curve. + · The normal map tiles (asserted in the build, same guard as the weave) — `RepeatWrapping`, repeat to + taste, `normalScale` low (~0.3); it's a puddle, not the ocean. + · Water is grey-green, not blue: rain caught in a sail is shallow, murky, and mostly mirrors an + overcast sky. If it reads too drab against your cloth, say so and I'll lift it. + 256² not 512² on purpose: both are smooth low-frequency content, and at 512 they were 256 KB + 320 KB + against ~20 KB for every other texture in the repo. No visible difference, quarter the bytes. + +[E] 2026-07-17 — `broom_01_v1.glb` landed (§Lane E-2) — 0.39 × 0.06 × 1.42 m, `mass_hint` 1.2. Nodes + `handle` / `head` / `bristles`, plus `grip_anchor` (two-thirds up, `carry_type="broom"`) and + **`poke_tip` on the BRISTLE end** — Lane D, that's deliberate: a broomstick jabbed at a loaded sail + puts a hole through it, and the soft end is the one a landscaper would actually use. Stands upright + with the head on the ground, i.e. how it lives against the shed wall; rotate it to poke. Carries + `anim_hint` = reuse Crank/Dig, no new Mixamo needed. + +[E] 2026-07-17 — ⚠️ **RENAMED: `fence_panel_broken` → `fence_panel_snapped`.** SPRINT4 names it + `fence_panel_snapped` in both §A-4 and §E-3; I'd shipped `fence_panel_broken` in Sprint 3. Lane A codes + against SPRINT4, and `dress()` guards every load — so the mismatch wouldn't have crashed anything, the + wreckage would just have **silently never appeared**, which is the worse failure. Canonical name is now + `fence_panel_snapped_v1.glb`; old file deleted. `garden_gnome_01_broken` is unchanged and already + matches. (Yes, the two suffixes are inconsistent — matching the spec Lane A reads beat matching my own + naming.) + +[E] 2026-07-17 — ✅ **Lane A — I booted the dressed yard and it's right.** house_yardside + both gums + + shed + table all load; `fascia_anchor_01..03`, `gutter`, `wall`, `trunk`, `canopy` all present; 11 + anchors. Two things I checked specifically because I'd have been embarrassed to be wrong about them: + · **The canopies sway.** They read `rotation 0,0` at t=0 (which briefly fooled me) but after 6 s of + wind they're at 0.019 / 0.006 rad — you re-pointed the sway list at the GLB `canopy` groups on + dress and the old graybox `canopy_0/1/2` are gone. The handle works. + · **`userData` extras are live in production** — world.js:417 reads `rating_hint` and `collateral` + off my nodes. That contract is real now, not just asserted. + Not yet wired: `sway_amp` (0.85 on the big gum, 1.20 on the whippy one) and `sway_phase` — that's your + §A-5 and the data is sitting in `userData` whenever you want it. Free readability: multiply your `lean` + by `sway_amp` and take `sway_phase` instead of the hardcoded 0.7 / 2.9, and gum_02 starts showing gust + fronts before gum_01 does. + +[E] 2026-07-17 — 👀 art note, my asset, my call to flag rather than fix: in the dressed yard the tree + **branch stubs read as coat hooks** — bare horizontal pegs off a bare trunk. They looked fine in + isolation; they don't at yard scale. **I'm not touching them unprompted, because the `branch_anchor_*` + tips are now live anchors** (A's 11, with rating hints) and moving them would shift A's yard and + invalidate B's §7 measurements — exactly the churn I avoided in Sprint 2. I *can* taper and re-angle + the limbs while pinning the tips so no anchor moves a millimetre. Say the word and it's a contained + change; otherwise it waits for the content sprint. + +[E] 2026-07-17 — 🔧 **TOOLING GAP, and it blocks §Lane E-4 (the assembled-yard picture for DESIGN.md).** + There is no way to get a game screenshot to disk. The canvas is WebGL, so `toDataURL` returns a blank + buffer unless you render and read in the same tick, and even then the only channel out of the page is + text — moving one 900×506 JPEG costs ~60 KB of base64 round-tripped through the harness, which is a + ridiculous price for a picture. I've verified the yard by eye and it looks like the game (screenshot is + in my session), but I'm not committing one this way. + **The durable fix is ~5 lines and it isn't mine:** either a dev key in main.js that does + `render(); canvas.toBlob(b => download(b))`, or a tiny POST handler in server.py writing to `docs/`. + Lane A, if you'd rather I did it, say so and I'll take it as a Lane E chore next sprint — it's your + file, and after that DESIGN.md gets a fresh picture whenever anyone presses a key. diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index 8645574..64ef1eb 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -16,400 +16,7 @@ ], "status": "PASS", "problems": [] - }, - { - "name": "tree_gum_01", - "dims": [ - 4.5522, - 4.956, - 7.9702 - ], - "tris": 396, - "nodes": [ - "branch_anchor_01", - "branch_anchor_02", - "branch_anchor_03", - "canopy", - "canopy_01", - "canopy_02", - "canopy_03", - "tree_gum_01", - "trunk" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "tree_gum_02", - "dims": [ - 3.8871, - 2.7787, - 5.4972 - ], - "tris": 288, - "nodes": [ - "branch_anchor_01", - "branch_anchor_02", - "canopy", - "canopy_01", - "canopy_02", - "tree_gum_02", - "trunk" - ], - "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": [] - }, - { - "name": "garden_gnome_01_broken", - "dims": [ - 0.3947, - 0.3519, - 0.106 - ], - "tris": 344, - "nodes": [ - "garden_gnome_01_broken", - "hat", - "head", - "shards", - "stump" - ], - "status": "PASS", - "problems": [] - }, - { - "name": "fence_panel_broken", - "dims": [ - 2.4, - 0.7749, - 1.8197 - ], - "tris": 336, - "nodes": [ - "debris_palings", - "fence_panel_broken", - "palings", - "rails" - ], - "status": "PASS", - "problems": [] } ], - "debris": [ - { - "file": "BlueCrate_v2.glb", - "dims": [ - 0.36, - 0.36, - 0.29 - ], - "sane": true - }, - { - "file": "BlackTub_v2.glb", - "dims": [ - 0.36, - 0.54, - 0.2 - ], - "sane": true - }, - { - "file": "WhiteTub_v2.glb", - "dims": [ - 0.36, - 0.54, - 0.2 - ], - "sane": true - }, - { - "file": "WoodenBin_v2.glb", - "dims": [ - 0.35, - 0.36, - 0.31 - ], - "sane": true - } - ] + "debris": [] } \ No newline at end of file diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 647ed92..60bbf21 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -117,9 +117,16 @@ PAL = { "gnome_skin": "#E0A986", "gnome_coat": "#3E6FA8", "gnome_hat": "#B33C36", + "bristle": "#C9A659", # broom straw "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 @@ -1181,7 +1188,52 @@ def build_garden_gnome_01_broken(name): return root -def build_fence_panel_broken(name): +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. @@ -1366,6 +1418,103 @@ def build_sail_textures(): 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_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.""" @@ -1487,7 +1636,12 @@ ASSETS = [ # 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. - dict(name="fence_panel_broken", fn=build_fence_panel_broken, + # 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="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"]), ] @@ -1815,6 +1969,7 @@ def main(): reset_to_empty() build_grass_atlas() build_sail_textures() + build_pond_textures() debris = [] if no_debris else copy_debris() failures = [] diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png index 32af97e..9f72654 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 3525000..24aa058 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -75,8 +75,10 @@ const ASSETS = [ { name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] }, { name: 'garden_gnome_01_broken', h: [0.08, 0.20], nodes: ['stump', 'head', 'hat', 'shards'] }, - { name: 'fence_panel_broken', h: [1.70, 1.90], + { name: 'fence_panel_snapped', h: [1.70, 1.90], nodes: ['palings', 'rails', 'debris_palings'] }, + { name: 'broom_01', h: [1.35, 1.50], + nodes: ['handle', 'head', 'bristles', 'grip_anchor', 'poke_tip'] }, ]; function sizeOf(gltf) { @@ -268,7 +270,7 @@ export default async function run(t) { // swap needs a fudge offset per prop and will grow one. t.test('broken variants sit on the same ground plane as their intact twin', () => { for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'], - ['fence_panel', 'fence_panel_broken']]) { + ['fence_panel', 'fence_panel_snapped']]) { for (const n of [intact, broken]) { const box = new THREE.Box3().setFromObject(loaded.get(n).scene); assert(Math.abs(box.min.y) < 0.03, diff --git a/web/world/models/broom_01_v1.glb b/web/world/models/broom_01_v1.glb new file mode 100644 index 0000000..2b3b9a7 Binary files /dev/null and b/web/world/models/broom_01_v1.glb differ diff --git a/web/world/models/fence_panel_broken_v1.glb b/web/world/models/fence_panel_broken_v1.glb deleted file mode 100644 index dca5ef7..0000000 Binary files a/web/world/models/fence_panel_broken_v1.glb and /dev/null differ diff --git a/web/world/models/fence_panel_snapped_v1.glb b/web/world/models/fence_panel_snapped_v1.glb new file mode 100644 index 0000000..ff0f705 Binary files /dev/null and b/web/world/models/fence_panel_snapped_v1.glb differ diff --git a/web/world/models/textures/pond_normal.png b/web/world/models/textures/pond_normal.png new file mode 100644 index 0000000..7dbc08d Binary files /dev/null and b/web/world/models/textures/pond_normal.png differ diff --git a/web/world/models/textures/pond_water.png b/web/world/models/textures/pond_water.png new file mode 100644 index 0000000..1d8b167 Binary files /dev/null and b/web/world/models/textures/pond_water.png differ