diff --git a/THREADS.md b/THREADS.md index 0d3ee62..7ed6d19 100644 --- a/THREADS.md +++ b/THREADS.md @@ -9546,3 +9546,60 @@ anchors are your GLB), but the tooling is now waiting, not TODO. arrive 10ร— finer on a paling's edge than its face. **UV-unwrapping the factory is its own gate** โ€” it must land with a texel-density assert, or the next lane ships stretched wood and calls it detail. + +[E+A] 2026-07-28 โ€” ๐ŸŒฑ **THE LAWN HAS GRAIN โ€” the biggest surface in the game stops being paint, + and the disposal hole that would have leaked it is closed in the same commit.** + Selftest **524/0/0**. Both new asserts mutation-checked red-then-green. + + **WHY THE LAWN AND NOT SOMETHING PRETTIER.** Measured across five camera poses, the ground + plane is **25โ€“50% of frame** โ€” more than twice any other surface โ€” and it is what the sail's + shadow falls on. sail.js says "the shadow IS the product"; a shadow reads as a SHAPE only if + the thing it lands on has something to interrupt. It was also the cheapest wiring available: + `PlaneGeometry` emits clean 0..1 UVs, so this needed **no factory UV work at all** (see the + warning at the end โ€” nothing else in the yard can say that). + + **THE TILE** โ€” `lawn_grass.png`, 512ยฒ, 205 KB, `build_lawn_texture()`. A **multiplier, not a + colour**: luminance in a narrow band (0.86โ€“1.12) so `mat.color = COLORS.grass` survives and + Lane A can retune the lawn without re-exporting anything โ€” exactly what `sail_weave` does to + the cloth. Five layers, all seamless by construction: blade grain (128 cells), thatch mottle + (32/16), big soft blotches (8/4) whose only job is to defeat the eye seeing a 2.5 m tile + repeat across a 24 m yard, and **4 integer cycles of mower stripe**, which is the cheapest + realism in the file โ€” it is what makes a lawn read as MOWN. Dry patches shift hue toward + yellow (+13% R, โˆ’16% B) rather than just darkening, because dead grass is not grey grass. + + **SEAMLESSNESS IS ASSERTED, NOT EYEBALLED**, and the control is the honest one: the edge-to- + edge step must be no worse than the biggest step INSIDE the tile (a flat tile would sail + through a bare "seam is small" check while proving nothing). Measured **h 0.00243 vs interior + max 0.03379 โ€” a 14ร— margin.** New helper `_tileable_noise()` wraps by indexing the far edge + back to lattice 0; it deliberately uses python's `random.Random`, matching build_grass_atlas, + NOT numpy's generator, because the factory's promise is byte-identical output and numpy's + default generator does not contract to a stable stream. **Verified: two runs, shasum + identical (4a1fc18d2d03a44c).** + + **โš ๏ธ THE DISPOSAL HOLE, FOUND BY ADDING THE FEATURE.** `world.dispose()` disposed geometry + and material but NOT `material.map` โ€” which was harmless while no world material had a map, + and became a leak the instant the lawn got one. **main.js:705 already carries this exact scar + for the sail** ("a material's `.map` is a separate GPU objectโ€ฆ the weave was leaking one per + re-rig"). A seven-night week rebuilds the world on every site change, so this would have + leaked a 512ยฒ lawn per yard, silently. Closed with the line and the reason beside it. + + **THE ASSERT IS THE ANTI-ORPHAN GUARANTEE** (a.test): the lawn must have a map after dress(), + it must be lawn_grass, sRGB (an albedo decoded linear renders washed out โ€” r175 dropped + `encoding`), RepeatWrapping (ClampToEdge would smear one tile over the whole yard), and its + repeat must be **DERIVED from the yard's own size** at 2.5 m/tile โ€” checked by deriving it, + not by pinning a literal, because a literal would pass on a yard of any size and the corner + block is not the backyard. Mutation: hide the PNG โ†’ **"the lawn has NO map after dress() โ€” + the grass texture is an orphan again"**, 523 pass / 1 fail. That run also proved the + `try/catch` degrades honestly: every other test stayed green, i.e. a missing texture leaves a + flat green lawn instead of taking the yard down. + + **STILL THE OPEN GATE, restated because it is the thing that blocks everything else:** + the other surfaces cannot have maps until the factory UV-unwraps. Every factory mesh carries + Blender's untouched default primitive unwrap โ€” `uvArea` *exactly* 0.3750, U *exactly* + [0.125, 0.875] on fence, shed, table, pergola, carport and all four debris tubs โ€” so texel + density varies **95.8ร— within a single `fence_panel_v1` mesh**. A `hasAttribute('uv')` check + passes and wood grain still lands 10ร— finer on a paling's edge than its face. That gate needs + `smart_project` in the factory AND a texel-density assert, or the next lane ships stretched + timber and calls it detail. **Six orphan textures also still await consumers** (`grass_atlas` + at ten sprints, `hail_pips`, `moon`, `plant_shred`, `pond_water`, `pond_normal`, + `sail_tears`) โ€” every one of their systems is live now. diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index cead034..cf9d52e 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -3703,6 +3703,113 @@ def build_grass_atlas(): return out +def _tileable_noise(size, cells, rng): + """Value noise that wraps EXACTLY, because a lawn tiles ~10x across the yard. + + A `cells x cells` lattice of seeded randoms, smoothstepped and bilinearly + interpolated, with the far edge indexed back to lattice 0 (`% cells`). That + modulo IS the seam guarantee โ€” it is the same discipline sail_weave states + as "every frequency is an integer number of cycles across the image", + expressed for noise instead of sinusoids. `cells` must divide `size`. + + Randomness comes from python's `random.Random`, matching build_grass_atlas, + NOT numpy's generator: the factory's promise is byte-identical output, and + python's Mersenne stream is stable across versions in a way numpy's default + generator does not contract to be. + """ + import numpy as np + assert size % cells == 0, f"{cells} must divide {size} or the tile will not wrap" + g = np.array([[rng.random() for _ in range(cells)] for _ in range(cells)], + dtype=np.float32) + u = (np.arange(size, dtype=np.float32) / size) * cells + i0 = np.floor(u).astype(np.int64) % cells + i1 = (i0 + 1) % cells # the wrap: last cell blends into the first + f = (u - np.floor(u)).astype(np.float32) + f = f * f * (3.0 - 2.0 * f) # smoothstep, so lattice lines don't read as a grid + fy, fx = f[:, None], f[None, :] + a, b = g[np.ix_(i0, i0)], g[np.ix_(i0, i1)] + c, d = g[np.ix_(i1, i0)], g[np.ix_(i1, i1)] + return (a * (1 - fx) + b * fx) * (1 - fy) + (c * (1 - fx) + d * fx) * fy + + +def build_lawn_texture(): + """The lawn โ€” the biggest surface in the game, and until now one flat colour. + + Measured across five camera poses, the ground plane is 25-50% of frame: more + than twice any other surface, and it is what the sail's shadow lands on + ("the shadow IS the product", sail.js). A shadow reads as a shape only if + the thing it falls on has a texture to interrupt. + + IT IS A MULTIPLIER, NOT A COLOUR. Luminance in a narrow band around 1.0, so + `mat.map = tex` keeps `mat.color = COLORS.grass` and modulates it โ€” exactly + what sail_weave does to the cloth, and the reason this is ~40 KB instead of + a full albedo. Lane A can retune the lawn's colour afterwards without + re-exporting anything, which is the whole point of doing it this way. + + Four things a real backyard has, layered, all seamless by construction: + ยท blade-scale grain (128 cells) โ€” reads as turf under the player's feet + ยท thatch/clover mottle (32, 16) โ€” reads at walking distance + ยท big soft blotches (4, 8) โ€” the ONLY defence against seeing the + tile repeat across a 24 m yard + ยท mower stripes โ€” 4 integer cycles, very low amplitude; + it is what makes a lawn look MOWN, and + it is the cheapest realism in the file + Dry patches also pull the hue toward yellow (up R, down B) rather than just + darkening, because dead grass is not grey grass. + """ + import numpy as np + import random + SIZE = 512 + rng = random.Random(0x4C41574E) # "LAWN" in ascii; the fixed seed IS the determinism + + fine = _tileable_noise(SIZE, 128, rng) + mid = _tileable_noise(SIZE, 32, rng) + coarse = _tileable_noise(SIZE, 16, rng) + blotch = _tileable_noise(SIZE, 8, rng) + huge = _tileable_noise(SIZE, 4, rng) + + # Mower stripes: integer cycles across the tile, so they wrap. Sign flips + # per stripe like a real roller laying the blades one way then the other. + y = np.arange(SIZE, dtype=np.float32)[:, None] / SIZE + stripes = np.sin(2.0 * np.pi * 4.0 * y) + + lum = (1.0 + + 0.085 * (fine - 0.5) + + 0.070 * (mid - 0.5) + + 0.055 * (coarse - 0.5) + + 0.045 * (blotch - 0.5) + + 0.040 * (huge - 0.5) + + 0.022 * stripes) + lum = np.clip(lum, 0.70, 1.12).astype(np.float32) + + # Dryness rides the big shapes only โ€” patchy lawn, not noisy lawn. + dry = np.clip((blotch * 0.6 + huge * 0.4 - 0.42) * 1.9, 0.0, 1.0).astype(np.float32) + + img = np.zeros((SIZE, SIZE, 4), dtype=np.float32) + img[..., 0] = np.clip(lum * (1.0 + 0.13 * dry), 0.0, 1.0) + img[..., 1] = np.clip(lum * (1.0 + 0.02 * dry), 0.0, 1.0) + img[..., 2] = np.clip(lum * (1.0 - 0.16 * dry), 0.0, 1.0) + img[..., 3] = 1.0 + + # The seam check, asserted rather than eyeballed: with exact wrapping the + # first column must be the natural continuation of the last, so the + # edge-to-edge step can be no worse than the biggest step INSIDE the tile. + # (The interior max is the honest control โ€” a flat tile would pass a + # "seam is small" test while being no evidence at all.) + h_seam = float(np.abs(img[:, 0, :3] - img[:, -1, :3]).mean()) + v_seam = float(np.abs(img[0, :, :3] - img[-1, :, :3]).mean()) + h_in = float(np.abs(np.diff(img[..., :3], axis=1)).max()) + v_in = float(np.abs(np.diff(img[..., :3], axis=0)).max()) + assert h_seam <= h_in and v_seam <= v_in, ( + f"lawn tile does not wrap: seam h={h_seam:.5f} v={v_seam:.5f} vs " + f"interior max h={h_in:.5f} v={v_in:.5f}") + + out, kb = save_png(img, "lawn_grass") + print(f" lawn_grass.png {SIZE}x{SIZE}, seamless (h {h_seam:.5f} <= {h_in:.5f}), " + f"multiplier {lum.min():.2f}-{lum.max():.2f}, {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. @@ -4304,6 +4411,7 @@ def main(): build_all(only) reset_to_empty() build_grass_atlas() + build_lawn_texture() build_sail_textures() build_pond_textures() build_hail_and_shred_atlases() diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index 8a907fd..954081b 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -1881,6 +1881,44 @@ export default async function run(t) { } }); + /** + * THE LAWN IS ACTUALLY WEARING ITS TEXTURE โ€” the anti-orphan assert. + * + * Written because of the repo's own worst asset habit: 7 of the 8 textures + * the factory generates have never had a runtime consumer, `grass_atlas` + * for ten sprints, with Lane E asking every sprint to wire it or bin it. A + * committed texture that nothing loads is the "looks wired, isn't" defect + * class in its purest form, and the only cure that cannot rot is an assert + * that fails when the wiring goes away. + * + * It checks the FOUR things that are each silently wrong-able: + * map present โ€” the wiring exists at all + * SRGBColorSpace โ€” an albedo decoded as linear renders washed out (r175 + * dropped `encoding`, so this is easy to get wrong) + * RepeatWrapping โ€” ClampToEdge would smear one tile over the whole yard + * repeat from YARD โ€” a hardcoded repeat makes a 30x20 m yard's grass a + * different scale from a 24x16 m one + */ + t.test('the lawn wears its grain, at the right scale for THIS yard', () => { + if (!rtDressed) return 'SKIPPED โ€” no server โ€” dress() unavailable'; + const ground = rtWorld.root?.getObjectByName('ground') + ?? (() => { let g = null; rtWorld.root?.traverse((o) => { if (o.name === 'ground') g = o; }); return g; })(); + assert(ground, 'no mesh named "ground" โ€” the lawn assert is aimed at something that moved'); + const map = ground.material?.map; + assert(map, 'the lawn has NO map after dress() โ€” the grass texture is an orphan again'); + assert(/lawn_grass/.test(map.image?.src ?? map.source?.data?.src ?? ''), + 'the ground is wearing some other texture than lawn_grass.png'); + assertEq(map.colorSpace, THREE.SRGBColorSpace, 'lawn albedo must be sRGB or it renders washed out'); + assertEq(map.wrapS, THREE.RepeatWrapping, 'lawn must repeat, not clamp'); + assertEq(map.wrapT, THREE.RepeatWrapping, 'lawn must repeat, not clamp'); + // The scale is DERIVED, so the check derives it too rather than copying a + // number โ€” a pinned literal here would pass on a yard of any size. + const TILE_M = 2.5; + assertClose(map.repeat.x, reloaded.yard.width / TILE_M, 1e-6, 'lawn tile scale is not the yard width'); + assertClose(map.repeat.y, reloaded.yard.depth / TILE_M, 1e-6, 'lawn tile scale is not the yard depth'); + return `${reloaded.yard.width}x${reloaded.yard.depth} m yard -> repeat ${map.repeat.x}x${map.repeat.y} @ ${TILE_M} m/tile`; + }); + t.test('editor: GLB-backed anchors ADOPT, and the carport is still a trap', () => { if (!rtDressed) return 'SKIPPED โ€” no server โ€” dress() unavailable'; // `collateral` is set by adoptAnchor and by nothing else, so its presence diff --git a/web/world/js/world.js b/web/world/js/world.js index dbad5f1..52c816f 100644 --- a/web/world/js/world.js +++ b/web/world/js/world.js @@ -662,6 +662,42 @@ export function createWorld(scene, opts = {}) { const { GLTFLoader } = await import('../vendor/addons/loaders/GLTFLoader.js'); const loader = new GLTFLoader(); + /** + * The lawn gets its grain. Biggest surface in the game (25-50% of frame, + * measured over five camera poses) and it was one flat colour, which is + * also what made the sail's shadow read as a soft blob rather than a shape. + * + * Wired exactly like sail.js's weave, for the same reasons: module-relative + * URL, `RepeatWrapping`, SRGBColorSpace (r175 spelling โ€” `encoding` is + * gone), anisotropy because the ground is *always* viewed at a raking + * angle, and a try/catch so a missing texture leaves a flat green lawn + * instead of taking the yard down. + * + * `mat.color` is KEPT: E's tile is luminance in a narrow band, so it + * multiplies COLORS.grass rather than replacing it. Retuning the lawn's + * colour stays a one-line change in this file, with no re-export. + * + * repeat is computed from the yard's real size at a fixed metres-per-tile, + * NOT hardcoded: yards differ (24x16 vs 30x20) and a constant repeat would + * make the corner block's grass a different scale from the backyard's โ€” + * the same class of bug as a texture atlas addressed by cell index. + */ + const groundMat = ground.material; + try { + const TILE_M = 2.5; // one tile per 2.5 m of lawn + const tex = await new THREE.TextureLoader().loadAsync( + new URL('../models/textures/lawn_grass.png', import.meta.url).href, + ); + tex.wrapS = tex.wrapT = THREE.RepeatWrapping; + tex.repeat.set(YARD.width / TILE_M, YARD.depth / TILE_M); + tex.colorSpace = THREE.SRGBColorSpace; + tex.anisotropy = 8; // grazing angle over 20+ m of ground + groundMat.map = tex; + groundMat.needsUpdate = true; + } catch (err) { + console.warn('[world] lawn texture missing, keeping flat grass:', err.message); + } + /** Take a graybox stand-in out of the scene AND out of `solids`. */ const retire = (obj) => { if (!obj) return; @@ -1012,7 +1048,18 @@ export function createWorld(scene, opts = {}) { * lights and hands them back on its own dispose, so by here they're ours). */ dispose() { - root.traverse((o) => { o.geometry?.dispose?.(); o.material?.dispose?.(); }); + // `.map` is disposed EXPLICITLY, and it is not pedantry: a material's + // texture is a separate GPU object that `material.dispose()` does not + // free. main.js:705 carries the same line with the scar attached ("the + // weave was leaking one per re-rig"), and the moment the lawn got a map + // this traversal inherited that bug โ€” seven nights of a week rebuild the + // world on every site change, so it would have leaked a 512ยฒ lawn per + // yard, silently, exactly like the sail did. + root.traverse((o) => { + o.geometry?.dispose?.(); + o.material?.map?.dispose?.(); + o.material?.dispose?.(); + }); root.parent?.remove(root); sun.parent?.remove(sun); sun.target.parent?.remove(sun.target); diff --git a/web/world/models/textures/lawn_grass.png b/web/world/models/textures/lawn_grass.png new file mode 100644 index 0000000..7e41c4d Binary files /dev/null and b/web/world/models/textures/lawn_grass.png differ