Add the pond visual, the broom, and rename the snapped panel
Pond (SPRINT4 §E-1, decision 10): pond_water.png and pond_normal.png, for a patch Lane B builds from the cloth's own nodes — same ride-the-nodes rule as the tears, since a rigid disc would sit still while the belly moves under it. Alpha is a radial feather and RGB darkens toward the centre, both encoded radially so the shading survives being scaled per pondMass (radius ~ sqrt(mass)). Water is grey-green, not blue: rain caught in a sail is shallow, murky and mirrors an overcast sky. Both at 256², not 512²: smooth low-frequency content, and at 512 they were 256 KB + 320 KB against ~20 KB for every other texture here. The chop is seven waves at golden-angle directions rather than three crossed sines, which interfered into a lattice and read as basketweave; the normal map gets six integer-frequency waves for the same reason, and still asserts its own tiling. broom_01 (§E-2): poke_tip sits on the bristle end deliberately — a broomstick jabbed at a loaded sail holes it. Renames fence_panel_broken to fence_panel_snapped to match SPRINT4 §A-4/§E-3. Lane A's dress() guards every load, so the mismatch would not have crashed — the wreckage would silently never have appeared, which is worse. All 21 assets pass, 33 output files byte-identical across two runs, 186/0/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5d8264f13f
commit
4bb0efcfd1
70
THREADS.md
70
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.
|
||||
|
||||
@ -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": []
|
||||
}
|
||||
@ -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 = []
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 193 KiB |
@ -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,
|
||||
|
||||
BIN
web/world/models/broom_01_v1.glb
Normal file
BIN
web/world/models/broom_01_v1.glb
Normal file
Binary file not shown.
Binary file not shown.
BIN
web/world/models/fence_panel_snapped_v1.glb
Normal file
BIN
web/world/models/fence_panel_snapped_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/textures/pond_normal.png
Normal file
BIN
web/world/models/textures/pond_normal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
BIN
web/world/models/textures/pond_water.png
Normal file
BIN
web/world/models/textures/pond_water.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Loading…
Reference in New Issue
Block a user