From 4601e628c6854637cd469ddf43dbb1c2263e9a6f Mon Sep 17 00:00:00 2001 From: Rooj Rin <72690804+rroojrooj@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:54:17 +0700 Subject: [PATCH 1/6] =?UTF-8?q?plan:=20P1b=20sub-plan=20=E2=80=94=20tan-fi?= =?UTF-8?q?rst=20course=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Course-aware dryness (uCourseDry) auto-detected from fairway-grass warmth drives a shader dry-bias (rebuild chroma toward gold-tan on rough/base, gated off greens + mown fairway) + course-aware stripe strength. Chambers -> firm tan links, Sawgrass -> lush parkland. Grounded: COLORS all lush (scene.js), shader has noise-driven lush<->tan mix (turf.js:226) + fixed stripes (:247); detector goes in _buildMacroTint (:490) where m.avg is set (sidesteps splat-before-aerial ordering). Task 0 GATE: validate a fairway-warmth metric separates links vs parkland (memory: greenness is BACKWARDS) or fall back to a manual courseDry field. Ready for eng review. Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-07-p1b-tan-palette.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-p1b-tan-palette.md diff --git a/docs/superpowers/plans/2026-07-07-p1b-tan-palette.md b/docs/superpowers/plans/2026-07-07-p1b-tan-palette.md new file mode 100644 index 0000000..5415772 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-p1b-tan-palette.md @@ -0,0 +1,118 @@ +# Phase 1b — Tan-first course identity (sub-plan) + +> Sub-plan of [`2026-07-06-reality-master-plan.md`](2026-07-06-reality-master-plan.md). REQUIRED SUB-SKILL after +> `/plan-eng-review`: superpowers:subagent-driven-development or executing-plans. Steps use `- [ ]`. + +**Goal:** the sim stops rendering every course as lush green striped parkland. Chambers Bay reads as a **firm +tan-gold links** (fescue rough DOMINATES, minimal stripes); TPC Sawgrass stays **lush green parkland** (bold +stripes). One course-level "dryness" scalar, **auto-detected from fairway-grass colour**, drives both the turf +palette bias and the stripe strength. This is the #1 gap both assessors flagged, and it's on the critical path +to P2 (SDF edges get judged against the final rough colour). + +**Target palette (from the 106-photo reference `reference/chambers-bay/CATALOG.md`):** dry-links rough +**#c0a666** (gold-tan, should dominate the frame), fairway **#5e7d3d** (cool olive), green **#6b894a**, sand +**#b7a98b** (greige — already shipped in `makeSandMaterial`). Lush-parkland stays near today's greens. + +**Architecture — `uCourseDry` shader uniform (NOT a splat repaint):** +- **Why a uniform, not new per-course splat colours:** the splat is painted at course-load *before* the aerial + loads (`scene.js` loadCourse), but the dryness signal comes FROM the aerial. A uniform is set later in the + `_buildMacroTint` aerial callback (exactly where `m.avg` is already set, `scene.js:528`), sidestepping the + ordering problem with no splat repaint / turf rebuild. +- **Why the existing lush↔tan mix isn't enough:** `turf.js:226` already lerps grass toward warm-desaturated + `(1.13,1.03,0.74)` by a NOISE field (`zone`), but (a) it's noise-driven, not course-driven, and (b) a pure + multiplier can't turn lush green `#4a8038` into gold-tan `#c0a666`. P1b adds a `uCourseDry`-scaled bias that + **rebuilds chroma from luma toward an explicit tan target** on rough/base turf (so it CAN reach gold-tan), + gated OFF greens and mown-stripe fairway (those keep their manicured character). +- **Stripes:** `turf.js:247` stripe strength (0.38/0.17) is fixed; scale it down by `uCourseDry` (links minimal, + parkland bold). + +**Diagnosis already done (grounded — do not re-derive):** +- Palette lives in `scene.js` `COLORS` (~:48-60): base `#3c6736`, rough `#4a8038`, fairway `#5aa848/#4f9a40`, + green `#4c8f42/#447f38` — all lush green, course-independent. +- Turf shader (`turf.js`): base = `diffuseColor` (splat) × blade detail (:178); noise lush↔tan mix (:226); + stripes (:247, `sFade` distance-faded); greens gated by `gEdge` (:207). `customProgramCacheKey` must bump on + shader-text change (currently `'turf-grain-v31'` / `'turf-grain-v31-macro'`). +- `_buildMacroTint(m)` (`scene.js:490`) rasterizes surface polys into the aerial canvas + computes the + playable-mean via `averageLinearColor` (:528). **This is where fairway-grass sampling + `uCourseDry` go.** +- Memory warning (`golf-realism-research` / detector notes): aerial playable-MEAN greenness does NOT separate + links vs parkland — Chambers greenness 0.047 vs Sawgrass 0.0233 is BACKWARDS. The clean signal is + fairway-grass **warmth/desaturation**, sampled specifically over fairway, not whole-frame greenness. + +**Tech stack:** Three.js `onBeforeCompile` GLSL (turf shader), the `_buildMacroTint` aerial-sampling path, the +committed 6-frame capture fixture (`docs/fixtures/chambers-sweep.json`), `node --test`. + +--- + +## Task 0: Detector calibration GATE — a fairway-warmth metric that separates links vs parkland (HARD go/no-go) + +**Files:** `scratchpad/` probe only (no repo change yet). **Output:** a validated `dryness(aerial, fairwayPolys)` +formula + the three course numbers; gates Tasks 1-4. + +- [ ] **Step 1:** Write a Node probe that, for **chambers-bay** (links), **tpc-sawgrass** (parkland), and + **bandon-dunes** (links), loads the cached course + its aerial, rasterizes the OSM **fairway** (and tee) + polygons into a mask, and samples the aerial's mean colour over ONLY those fairway pixels (linear space). +- [ ] **Step 2:** Compute candidate dryness metrics on the fairway-mean colour: warmth `(R - B)`, green-dominance + `G - (R+B)/2`, saturation, value. Find a combination where **links (Chambers, Bandon) score clearly higher + dryness than parkland (Sawgrass)** with a separating threshold. Normalize to `uCourseDry ∈ [0,1]` + (0 = lush parkland, 1 = dry links). +- [ ] **Step 3 — GATE:** if a clean metric separates all three (links > parkland by a comfortable margin), + lock the formula + threshold and proceed. **If NO aerial metric separates them** (the memory's warning + proves fatal), STOP the auto-detector and fall back to a **manual `courseDry` field in the course JSON** + (still ships the tan look; auto-detect becomes a documented follow-up). Do not build the palette bias on an + unvalidated detector. + +--- + +## Task 1: `uCourseDry` uniform + shader dry-bias (TDD the pure colour helper first) + +**Files:** `public/render/turf.js`, `public/render/turf-color.js` (create — pure helper), `test/turf-color.test.mjs` (create). + +- [ ] **Step 1 (TDD):** Extract the dry-bias as a pure JS helper mirroring the GLSL, `dryTanBias(rgb, dry)`: + rebuild chroma from luma toward the gold-tan target so `dry=1` maps lush rough `#4a8038` → ~`#c0a666`, + `dry=0` is identity. Write `test/turf-color.test.mjs` asserting the endpoints + monotonicity. Run → fail → implement → pass. +- [ ] **Step 2:** Add `uCourseDry` uniform (default 0) to the turf shader. After the existing lush↔tan mix + (`turf.js:~227`), apply the dry-bias to grass **only on rough/base** — gate it OFF greens (`gEdge`) and OFF + the mown-stripe fairway (`m`) so those keep their manicured colour: `grass = mix(grass, dryTan(grass), + uCourseDry * (1.0 - gEdge) * (1.0 - 0.7*m))`. Bump `customProgramCacheKey` v31 → v32 (both variants). +- [ ] **Step 3:** Verify `npm test` green (new colour test + no regression); shader compiles (no black turf). + +## Task 2: Course-aware stripe strength + +**Files:** `public/render/turf.js`. + +- [ ] **Step 1:** Scale the stripe term (`turf.js:247`) by `(1.0 - uStripeDamp * uCourseDry)` so links get + minimal stripes, parkland keeps bold ones. Pick `uStripeDamp` so Chambers stripes are ~faint-but-present + and Sawgrass is unchanged. (No new uniform needed if folded into the existing `uCourseDry`.) +- [ ] **Step 2:** Verify: cache key already bumped in Task 1; `npm test` green. + +## Task 3: Wire the detector → `uCourseDry` + +**Files:** `public/render/scene.js` (`_buildMacroTint` + the turf uniform ref), `public/render/turf.js` (expose the uniform setter). + +- [ ] **Step 1:** In `_buildMacroTint` (`scene.js:490`), after computing `m.avg`, sample the fairway-grass mean + (reuse the surface rasterization already there) and compute `courseDry` via the Task-0 formula (or read the + manual `courseDry` field if Task 0 fell back). Push it to the turf material's `uCourseDry` uniform. +- [ ] **Step 2:** Guard: HD-bundle / aerial-less courses default `courseDry` to 0 (lush) or the manual field — + never `NaN`. Verify Chambers gets a high value, Sawgrass ~0, in the `[render]` log. + +## Task 4: Tune + verify on the 6-frame sweep + +**Files:** tuning constants in `turf.js`; `docs/TODO.md`, `docs/HANDOFF.md`. + +- [ ] **Step 1:** Capture the committed sweep (`docs/fixtures/chambers-sweep.json`) for Chambers AND Sawgrass, + before/after. Target: Chambers rough reads gold-tan and DOMINATES; fairway cool-olive; stripes faint; + greens + registration + relief unregressed. Sawgrass stays lush green with bold stripes (no regression). +- [ ] **Step 2:** Iterate the tan target + `uCourseDry` mapping + `uStripeDamp` until the vs-real assessor + would call Chambers "tan links" not "green parkland," without Sawgrass regressing. +- [ ] **Step 3:** Update `docs/TODO.md` + `docs/HANDOFF.md`; note the detector formula (or manual fallback). + +--- + +## Verify / done (whole phase) + +- 6-frame sweep (fixture) for Chambers + Sawgrass, before/after: Chambers tan-links, Sawgrass lush-parkland, + no regression to greens / registration / QL1 relief / no-HD-seam / sky. +- `test/turf-color.test.mjs` + full `npm test` green (cache key bumped v31→v32). +- Optional: one dual-assessor pass on the Chambers sweep — confirm the "green parkland" read flips to "tan + links" (expected P1 score lift toward ~4.5-5 with P1a). +- Finish with superpowers:finishing-a-development-branch (present PR/merge options). From dccb2b70c4ebe1fba07ab2bffafdc19ea0f5125d Mon Sep 17 00:00:00 2001 From: Rooj Rin <72690804+rroojrooj@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:11:11 +0700 Subject: [PATCH 2/6] =?UTF-8?q?plan(P1b):=20eng-review=20+=20outside=20voi?= =?UTF-8?q?ce=20=E2=80=94=20re-architect=20to=204=20coordinated=20colour?= =?UTF-8?q?=20sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outside voice (10 findings, all verified) proved "paint the splat tan" does NOT achieve tan-links: the splat is 1 of 4 colour sources. Blades hard-ref COLORS (scene.js:890), the shader warm-mix (turf.js:226) double-counts, and the far-photo (turf.js:135) washes the tan back to the green aerial at overview. Re-architected: one manual courseDry scalar (client-side map, NOT JSON -- dropped in transit at server.js:276/course.js:392) drives splat + blades + warm-mix endpoint + far-photo weight, greens excluded, lush===COLORS. Manual first, auto-detector a gated stretch. GSTACK REVIEW REPORT appended. Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-07-p1b-tan-palette.md | 262 +++++++++++------- 1 file changed, 158 insertions(+), 104 deletions(-) diff --git a/docs/superpowers/plans/2026-07-07-p1b-tan-palette.md b/docs/superpowers/plans/2026-07-07-p1b-tan-palette.md index 5415772..a28b7d7 100644 --- a/docs/superpowers/plans/2026-07-07-p1b-tan-palette.md +++ b/docs/superpowers/plans/2026-07-07-p1b-tan-palette.md @@ -2,117 +2,171 @@ > Sub-plan of [`2026-07-06-reality-master-plan.md`](2026-07-06-reality-master-plan.md). REQUIRED SUB-SKILL after > `/plan-eng-review`: superpowers:subagent-driven-development or executing-plans. Steps use `- [ ]`. - -**Goal:** the sim stops rendering every course as lush green striped parkland. Chambers Bay reads as a **firm -tan-gold links** (fescue rough DOMINATES, minimal stripes); TPC Sawgrass stays **lush green parkland** (bold -stripes). One course-level "dryness" scalar, **auto-detected from fairway-grass colour**, drives both the turf -palette bias and the stripe strength. This is the #1 gap both assessors flagged, and it's on the critical path -to P2 (SDF edges get judged against the final rough colour). - -**Target palette (from the 106-photo reference `reference/chambers-bay/CATALOG.md`):** dry-links rough -**#c0a666** (gold-tan, should dominate the frame), fairway **#5e7d3d** (cool olive), green **#6b894a**, sand -**#b7a98b** (greige — already shipped in `makeSandMaterial`). Lush-parkland stays near today's greens. - -**Architecture — `uCourseDry` shader uniform (NOT a splat repaint):** -- **Why a uniform, not new per-course splat colours:** the splat is painted at course-load *before* the aerial - loads (`scene.js` loadCourse), but the dryness signal comes FROM the aerial. A uniform is set later in the - `_buildMacroTint` aerial callback (exactly where `m.avg` is already set, `scene.js:528`), sidestepping the - ordering problem with no splat repaint / turf rebuild. -- **Why the existing lush↔tan mix isn't enough:** `turf.js:226` already lerps grass toward warm-desaturated - `(1.13,1.03,0.74)` by a NOISE field (`zone`), but (a) it's noise-driven, not course-driven, and (b) a pure - multiplier can't turn lush green `#4a8038` into gold-tan `#c0a666`. P1b adds a `uCourseDry`-scaled bias that - **rebuilds chroma from luma toward an explicit tan target** on rough/base turf (so it CAN reach gold-tan), - gated OFF greens and mown-stripe fairway (those keep their manicured character). -- **Stripes:** `turf.js:247` stripe strength (0.38/0.17) is fixed; scale it down by `uCourseDry` (links minimal, - parkland bold). - -**Diagnosis already done (grounded — do not re-derive):** -- Palette lives in `scene.js` `COLORS` (~:48-60): base `#3c6736`, rough `#4a8038`, fairway `#5aa848/#4f9a40`, - green `#4c8f42/#447f38` — all lush green, course-independent. -- Turf shader (`turf.js`): base = `diffuseColor` (splat) × blade detail (:178); noise lush↔tan mix (:226); - stripes (:247, `sFade` distance-faded); greens gated by `gEdge` (:207). `customProgramCacheKey` must bump on - shader-text change (currently `'turf-grain-v31'` / `'turf-grain-v31-macro'`). -- `_buildMacroTint(m)` (`scene.js:490`) rasterizes surface polys into the aerial canvas + computes the - playable-mean via `averageLinearColor` (:528). **This is where fairway-grass sampling + `uCourseDry` go.** -- Memory warning (`golf-realism-research` / detector notes): aerial playable-MEAN greenness does NOT separate - links vs parkland — Chambers greenness 0.047 vs Sawgrass 0.0233 is BACKWARDS. The clean signal is - fairway-grass **warmth/desaturation**, sampled specifically over fairway, not whole-frame greenness. - -**Tech stack:** Three.js `onBeforeCompile` GLSL (turf shader), the `_buildMacroTint` aerial-sampling path, the -committed 6-frame capture fixture (`docs/fixtures/chambers-sweep.json`), `node --test`. +> +> **RE-ARCHITECTED 2026-07-07 by eng-review + outside voice (see GSTACK REVIEW REPORT at bottom).** The naive +> "paint the splat tan" plan does NOT achieve tan-links: the splat is only one of FOUR colour sources, and the +> other three override it (verified in code). This plan coordinates all four via one manual `courseDry` scalar. + +**Goal:** Chambers Bay reads as a **firm tan-gold links** (fescue rough dominates, minimal stripes) at BOTH +play and overview; TPC Sawgrass stays **lush green parkland** (unchanged). One manual course-level dryness +scalar `courseDry ∈ [0,1]` drives every colour source coherently. #1 gap from both assessors; critical path to P2. + +**Target palette (`reference/chambers-bay/CATALOG.md`):** dry rough **#c0a666** (gold-tan, dominant), fairway +**#5e7d3d** (cool olive), green **#6b894a** (stays green — NOT tan), sand **#b7a98b** (already shipped). + +## Architecture — one scalar, FOUR coordinated colour sources + +``` +courseDry (0=lush parkland .. 1=dry links) [MANUAL, client-side map keyed by course name] + │ + ├─(1) SPLAT palette paint base with lerp(lushPalette, dryPalette, courseDry) [greens EXCLUDED] + ├─(2) BLADE tint _fairwayZoneColor reads the SAME blended palette, not raw COLORS + ├─(3) SHADER warm-mix uCourseDry pulls the turf.js:226 zone-mix warm endpoint toward neutral + │ (so the tan comes from the base, not a double-counted multiplier) + ├─(3) SHADER stripes uCourseDry scales mow-stripe strength toward a LOW FLOOR (not zero) + └─(4) FAR-PHOTO uCourseDry lowers uMacroPhotoFar so the lit TAN turf shows at overview + instead of the green summer aerial washing back over it +``` + +**Why these four (all verified against code — the review's load-bearing findings):** +1. **Splat** — base grass = `diffuseColor` (splat painted from `COLORS`, `scene.js:629-631`). Re-color it. +2. **Blades** — `_fairwayZoneColor` (`scene.js:890`) hard-refs `COLORS.fairwayA/tee/base` → foreground grass + tufts stay kelly-green on tan ground unless retinted from the same blended palette. +3. **Shader warm-mix** — `turf.js:226` `grass *= mix((0.93,1.0,0.9),(1.13,1.03,0.74), clamp(zone*1.7+0.5,0,1))` + is an UNCONDITIONAL noise-driven warm multiplier (warms ~half of EVERY course). A tan splat under it + double-counts → overshoots past #c0a666. `uCourseDry` must pull the warm endpoint toward neutral. +4. **Far-photo** — `turf.js:135` crossfades to the raw green NAIP aerial past ~60-100 m (`courseAerialPhotoFar + 0.62`). The tan splat is underneath → washed to green at overview, exactly where "links" reads most. + +**`courseDry` home = a CLIENT-SIDE map (NOT the course JSON).** Verified: `courseGeometry()` (`server.js:276`) +and `loadCourse` (`lib/course.js:392`) both return FIXED field allow-lists — a `courseDry` JSON field is +silently dropped in transit (tests green, feature dead). A client map keyed by course name in a small render +module avoids transit + the cache-version foot-gun (old caches lacking the field) entirely, and doesn't touch +`courseFingerprint` (safe — `canonicalCourse` is an allow-list). The per-course surface-override sidecar was +considered and rejected (server-side → same transit problem; overkill for one scalar). + +**Never break:** `courseDry=0` must be **byte-identical to today** for every course → derive `lushPalette` +FROM the live `COLORS` object (don't retype hexes). Greens stay green. `customProgramCacheKey` bumps v31→v32. + +**Tech stack:** Three.js `onBeforeCompile` GLSL, the splat paint + `_fairwayZoneColor` blade tint, the +`_buildMacroTint`/far-photo path, the committed 6-frame fixture (`docs/fixtures/chambers-sweep.json`), `node --test`. --- -## Task 0: Detector calibration GATE — a fairway-warmth metric that separates links vs parkland (HARD go/no-go) - -**Files:** `scratchpad/` probe only (no repo change yet). **Output:** a validated `dryness(aerial, fairwayPolys)` -formula + the three course numbers; gates Tasks 1-4. - -- [ ] **Step 1:** Write a Node probe that, for **chambers-bay** (links), **tpc-sawgrass** (parkland), and - **bandon-dunes** (links), loads the cached course + its aerial, rasterizes the OSM **fairway** (and tee) - polygons into a mask, and samples the aerial's mean colour over ONLY those fairway pixels (linear space). -- [ ] **Step 2:** Compute candidate dryness metrics on the fairway-mean colour: warmth `(R - B)`, green-dominance - `G - (R+B)/2`, saturation, value. Find a combination where **links (Chambers, Bandon) score clearly higher - dryness than parkland (Sawgrass)** with a separating threshold. Normalize to `uCourseDry ∈ [0,1]` - (0 = lush parkland, 1 = dry links). -- [ ] **Step 3 — GATE:** if a clean metric separates all three (links > parkland by a comfortable margin), - lock the formula + threshold and proceed. **If NO aerial metric separates them** (the memory's warning - proves fatal), STOP the auto-detector and fall back to a **manual `courseDry` field in the course JSON** - (still ships the tan look; auto-detect becomes a documented follow-up). Do not build the palette bias on an - unvalidated detector. +## Task 1: `courseDry` map + blended-palette module (TDD the pure logic first) + +**Files:** create `public/render/course-character.js`; `test/course-character.test.mjs` (create); +read `scene.js` `COLORS`. + +- [ ] **Step 1 (TDD):** Write `test/course-character.test.mjs` FIRST: `courseDryFor(name)` returns the map + value, clamps to [0,1], defaults **0** for unknown/missing; `blendPalette(lush, dry, 0)` deep-equals `lush` + (byte-identical), `blendPalette(lush, dry, 1)` equals `dry`, monotone between; **greens are excluded** + (`blendPalette(...).greenA === lush.greenA` at any courseDry). Run → fail. +- [ ] **Step 2:** Implement `course-character.js`: a `COURSE_DRY` map (`{ 'Chambers Bay': ~0.85, 'TPC Sawgrass': + 0, 'Bandon Dunes ...': ~0.8, default 0 }`), `courseDryFor(name)`, a `DRY_PALETTE` (rough #c0a666, fairwayA + #5e7d3d, fairwayB slightly darker, base tan, tee olive; greenA/greenB = lush greens UNCHANGED), and + `blendPalette(lush, dry, t)` = per-key lerp with greenA/greenB pinned to lush. Import `COLORS` as `lush`. + Run → pass. Full `npm test` green. + +## Task 2: Apply the blended palette to splat + blades + +**Files:** `public/render/scene.js` (splat paint site ~:629, `_fairwayZoneColor` :890, loadCourse wiring). + +- [ ] **Step 1:** At course-load, compute `const dry = courseDryFor(courseName)` and + `const pal = blendPalette(COLORS, DRY_PALETTE, dry)`. Paint the splat (`fillKind` calls ~:629-631) from + `pal.*` instead of `COLORS.*` (greens still `pal.greenA` = unchanged green). +- [ ] **Step 2:** Retint `_fairwayZoneColor` (:890) from `pal.fairwayA/tee/base` (not raw `COLORS`), so the + foreground blades match the tan ground. Store `pal` on the scene so both read the same blend. +- [ ] **Step 3:** Verify `npm test` green; no console errors on Chambers + Sawgrass load. + +## Task 3: `uCourseDry` shader uniform — warm-mix + stripes + far-photo + +**Files:** `public/render/turf.js` (uniform + 3 shader edits + cache key), `scene.js` (set uniform on BOTH base + HD-patch turf materials). + +- [ ] **Step 1:** Add `uCourseDry` uniform (default 0) to the turf material. Set it in `scene.js` on the base + turf material AND the HD green-patch material (`_turfInputs` / `:973`) — else HD patches render lush while + the base is dry (the `!_hdPatch` mismatch class from memory). +- [ ] **Step 2 (warm-mix, #4):** at `turf.js:226`, pull the warm endpoint toward neutral by `uCourseDry`: + `mix((0.93,1.0,0.9), mix((1.13,1.03,0.74),(1.0,1.0,0.96), uCourseDry*0.7), clamp(zone*1.7+0.5,0,1))` — so a + dry course's tan comes from the base, not a double-counted multiplier; `uCourseDry=0` is unchanged. +- [ ] **Step 3 (stripes, #7):** scale the stripe term (`turf.js:247`) by `(1.0 - uStripeDamp*uCourseDry)` with + `uStripeDamp ~0.7` so links keep a LOW-but-nonzero stripe (Chambers is lightly mown, not stripe-free). +- [ ] **Step 4 (far-photo, #6):** scale `photoFar` (or `uMacroPhotoFar`) down by `uCourseDry` + (e.g. `photoFar *= (1.0 - 0.7*uCourseDry)`) so dry courses show the lit tan turf at overview instead of the + green aerial. Keep the mid-range chroma tint but let the tan base carry the overview. +- [ ] **Step 5:** Bump `customProgramCacheKey` `'turf-grain-v31'`→`'v32'` (both variants). Verify shader + compiles (no black turf), `npm test` green. + +## Task 4: Tune + verify on the 6-frame sweep (the REAL gate) + +**Files:** tuning constants in `course-character.js` + `turf.js`; `docs/TODO.md`, `docs/HANDOFF.md`. + +- [ ] **Step 1:** Capture the committed sweep (`docs/fixtures/chambers-sweep.json`) for **Chambers** AND + **Sawgrass**, before/after. This is the load-bearing gate — the unit tests pass while the on-screen result + could still be wrong (blades/warm-mix/far-photo interactions), so verify visually. +- [ ] **Step 2:** Targets: Chambers rough reads **gold-tan and dominates** at play AND overview; fairway + cool-olive; **greens still green**; blades match ground (no green-tuft seam); stripes faint-but-present; + registration + QL1 relief + no-HD-seam + sky unregressed. **Sawgrass byte-unchanged** (courseDry=0). +- [ ] **Step 3:** Iterate `COURSE_DRY['Chambers Bay']`, `DRY_PALETTE`, `uStripeDamp`, the far-photo factor + until the vs-real assessor would call Chambers "tan links." Update `docs/TODO.md` + `docs/HANDOFF.md`. + +## Task 5 (STRETCH — deferred): auto-detect `courseDry` from the aerial + +- [ ] Build a fairway-warmth detector in `_buildMacroTint` (sample the aerial over OSM fairway polys; warmth / + desaturation metric, NOT greenness — memory says greenness is backwards). Validate it reproduces the manual + `COURSE_DRY` labels for Chambers/Sawgrass/Bandon within tolerance, then let it OVERRIDE the manual value. + Ship only if it cleanly beats the labels; otherwise keep the manual map. Out of P1b's core scope. --- -## Task 1: `uCourseDry` uniform + shader dry-bias (TDD the pure colour helper first) - -**Files:** `public/render/turf.js`, `public/render/turf-color.js` (create — pure helper), `test/turf-color.test.mjs` (create). - -- [ ] **Step 1 (TDD):** Extract the dry-bias as a pure JS helper mirroring the GLSL, `dryTanBias(rgb, dry)`: - rebuild chroma from luma toward the gold-tan target so `dry=1` maps lush rough `#4a8038` → ~`#c0a666`, - `dry=0` is identity. Write `test/turf-color.test.mjs` asserting the endpoints + monotonicity. Run → fail → implement → pass. -- [ ] **Step 2:** Add `uCourseDry` uniform (default 0) to the turf shader. After the existing lush↔tan mix - (`turf.js:~227`), apply the dry-bias to grass **only on rough/base** — gate it OFF greens (`gEdge`) and OFF - the mown-stripe fairway (`m`) so those keep their manicured colour: `grass = mix(grass, dryTan(grass), - uCourseDry * (1.0 - gEdge) * (1.0 - 0.7*m))`. Bump `customProgramCacheKey` v31 → v32 (both variants). -- [ ] **Step 3:** Verify `npm test` green (new colour test + no regression); shader compiles (no black turf). - -## Task 2: Course-aware stripe strength - -**Files:** `public/render/turf.js`. - -- [ ] **Step 1:** Scale the stripe term (`turf.js:247`) by `(1.0 - uStripeDamp * uCourseDry)` so links get - minimal stripes, parkland keeps bold ones. Pick `uStripeDamp` so Chambers stripes are ~faint-but-present - and Sawgrass is unchanged. (No new uniform needed if folded into the existing `uCourseDry`.) -- [ ] **Step 2:** Verify: cache key already bumped in Task 1; `npm test` green. - -## Task 3: Wire the detector → `uCourseDry` - -**Files:** `public/render/scene.js` (`_buildMacroTint` + the turf uniform ref), `public/render/turf.js` (expose the uniform setter). - -- [ ] **Step 1:** In `_buildMacroTint` (`scene.js:490`), after computing `m.avg`, sample the fairway-grass mean - (reuse the surface rasterization already there) and compute `courseDry` via the Task-0 formula (or read the - manual `courseDry` field if Task 0 fell back). Push it to the turf material's `uCourseDry` uniform. -- [ ] **Step 2:** Guard: HD-bundle / aerial-less courses default `courseDry` to 0 (lush) or the manual field — - never `NaN`. Verify Chambers gets a high value, Sawgrass ~0, in the `[render]` log. - -## Task 4: Tune + verify on the 6-frame sweep - -**Files:** tuning constants in `turf.js`; `docs/TODO.md`, `docs/HANDOFF.md`. +## Verify / done (whole phase) -- [ ] **Step 1:** Capture the committed sweep (`docs/fixtures/chambers-sweep.json`) for Chambers AND Sawgrass, - before/after. Target: Chambers rough reads gold-tan and DOMINATES; fairway cool-olive; stripes faint; - greens + registration + relief unregressed. Sawgrass stays lush green with bold stripes (no regression). -- [ ] **Step 2:** Iterate the tan target + `uCourseDry` mapping + `uStripeDamp` until the vs-real assessor - would call Chambers "tan links" not "green parkland," without Sawgrass regressing. -- [ ] **Step 3:** Update `docs/TODO.md` + `docs/HANDOFF.md`; note the detector formula (or manual fallback). +- 6-frame sweep (fixture) for Chambers + Sawgrass, before/after: Chambers tan-links at play AND overview, + greens green, no blade seam; Sawgrass byte-unchanged; no regression to registration / QL1 relief / no-HD-seam / sky. +- `test/course-character.test.mjs` (incl. `courseDry=0 === COLORS` byte-identity + greens-excluded) + full + `npm test` green (cache key v31→v32). +- Optional: one dual-assessor pass on the Chambers sweep — confirm "green parkland" → "tan links." +- Finish with superpowers:finishing-a-development-branch (present PR/merge options). --- -## Verify / done (whole phase) - -- 6-frame sweep (fixture) for Chambers + Sawgrass, before/after: Chambers tan-links, Sawgrass lush-parkland, - no regression to greens / registration / QL1 relief / no-HD-seam / sky. -- `test/turf-color.test.mjs` + full `npm test` green (cache key bumped v31→v32). -- Optional: one dual-assessor pass on the Chambers sweep — confirm the "green parkland" read flips to "tan - links" (expected P1 score lift toward ~4.5-5 with P1a). -- Finish with superpowers:finishing-a-development-branch (present PR/merge options). +## Implementation Tasks +Synthesized from this review. Each derives from a specific finding. Checkbox as you ship. + +- [ ] **T1 (P1, human: ~1-2h / CC: ~30min)** — `course-character.js` — courseDry client-map + `blendPalette` + (lush derived from `COLORS`, greens excluded) + TDD test + - Surfaced by: Arch Q1/Q2 + outside-voice #1/#8 — a JSON `courseDry` is dropped in transit; `lush` must `=== COLORS` + - Files: `public/render/course-character.js`, `test/course-character.test.mjs` + - Verify: test green — `courseDry=0`→byte-identical, `=1`→dry targets, greens pinned, clamp/default 0 +- [ ] **T2 (P1, human: ~1-2h / CC: ~20min)** — `scene.js` — paint splat AND retint `_fairwayZoneColor` blades from the blended palette + - Surfaced by: outside-voice #3 — blades hard-ref `COLORS.fairwayA` ([scene.js:890](public/render/scene.js:890)), bypass the splat → green tufts on tan + - Files: `public/render/scene.js` + - Verify: no green-blade seam on Chambers; Sawgrass unchanged +- [ ] **T3 (P1, human: ~2-3h / CC: ~45min)** — `turf.js` — `uCourseDry` uniform: pull warm-mix endpoint + stripe floor + lower far-photo for dry; set on base + HD-patch materials; cache v32 + - Surfaced by: outside-voice #4/#6/#7/#9 — warm-mix double-counts, far-photo washes tan at overview, HD patch needs the uniform + - Files: `public/render/turf.js`, `public/render/scene.js` + - Verify: Chambers tan at overview (photo doesn't wash back); greens green; `npm test` green +- [ ] **T4 (P1, human: ~2-3h / CC: ~1h)** — verify — tune + capture the 6-frame fixture, Chambers + Sawgrass, before/after (the REAL gate) + - Surfaced by: outside-voice bottom-line — unit tests pass while on-screen can be wrong; the fixture is the gate + - Files: `docs/fixtures/chambers-sweep.json` + - Verify: Chambers "tan links" at play + overview, greens green, no seam; Sawgrass byte-unchanged; relief/registration/sky intact +- [ ] **T5 (P3, human: ~1-2d / CC: ~2-3h)** — `scene.js` — auto-detect `courseDry` from fairway warmth (STRETCH), validate vs manual labels before override + - Surfaced by: Arch Q1 — manual first, auto = gated stretch (greenness is backwards) + - Files: `public/render/scene.js` + - Verify: reproduces the manual `COURSE_DRY` labels for Chambers/Sawgrass/Bandon within tolerance + +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | — | +| Codex Review | `/codex review` | Independent 2nd opinion | 0 | — | — | +| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | clean (FULL_REVIEW) | 3 issues + outside-voice re-architecture, 0 critical gaps, 0 unresolved | +| Design Review | `/plan-design-review` | UI/UX gaps | 0 | — | — | +| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | — | + +- **OUTSIDE VOICE (Claude subagent, Codex not installed):** 10 findings, ALL verified against code. Proved the naive "paint the splat" plan insufficient — the splat is 1 of 4 colour sources (blades `scene.js:890`, shader warm-mix `turf.js:226`, far-photo `turf.js:135` all override it) — and that a `courseDry` JSON field is dropped in transit (`server.js:276` / `course.js:392`). +- **CROSS-MODEL:** 1 tension (P1b scope). Review + the 3 decisions assumed splat-only; outside voice proved it insufficient. Resolved → **full coherent tan** (courseDry drives all 4 colour sources; greens excluded). Correctness folds absorbed: client-side courseDry map (not JSON), `lush === COLORS`, HD-patch uniform, stripe floor, sidecar rejected. +- **VERDICT:** ENG CLEARED (FULL_REVIEW) — ready to implement P1b. + +NO UNRESOLVED DECISIONS From 299dee7732df725d2e9e2252f62ab37518623e34 Mon Sep 17 00:00:00 2001 From: Rooj Rin <72690804+rroojrooj@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:17:08 +0700 Subject: [PATCH 3/6] feat(P1b): course-dry palette module + splat/blade plumbing (T1+T2) course-character.js holds the lush COLORS palette (moved from scene.js, no circular import), a dry tan-links DRY_PALETTE, a manual per-course COURSE_DRY map (client-side, NOT the course JSON which drops unknown fields in transit), and blendPalette (grass zones only; greens/sand/water pinned; courseDry=0 is byte-identical). scene.js computes this._pal at load from geo.name and paints BOTH the splat AND the _fairwayZoneColor blades from it (blades previously hard-ref'd COLORS -> would leave green tufts on tan ground). 300/300 green (test/course-character.test.mjs added). Shader coordination is T3. Co-Authored-By: Claude Fable 5 --- public/render/course-character.js | 74 +++++++++++++++++++++++++++++++ public/render/scene.js | 46 ++++++++----------- test/course-character.test.mjs | 46 +++++++++++++++++++ 3 files changed, 139 insertions(+), 27 deletions(-) create mode 100644 public/render/course-character.js create mode 100644 test/course-character.test.mjs diff --git a/public/render/course-character.js b/public/render/course-character.js new file mode 100644 index 0000000..653ad1c --- /dev/null +++ b/public/render/course-character.js @@ -0,0 +1,74 @@ +// Course character: one manual per-course "dryness" scalar (0 = lush green +// parkland, 1 = firm tan-gold links) that drives the turf palette + shader. +// +// WHY a client-side map (not a course-JSON field): a courseDry field on the +// course object is silently dropped in transit — courseGeometry() (server.js) +// and loadCourse() (lib/course.js) both return fixed field allow-lists, and old +// caches would lack it until re-fetch. A map keyed by course name lives entirely +// client-side, never touches the cache / server payload / courseFingerprint. +// +// The palette (COLORS) lives here — not in scene.js — so both scene.js and this +// module's blender share one source of truth with no circular import. + +// Surfaces separated by VALUE (dark rough -> light fairway -> lighter green) so +// the hole reads as a golf hole, not one flat carpet, while staying muted enough +// that the regraded lighting doesn't push them neon. THIS IS THE LUSH PALETTE. +export const COLORS = { + base: '#3c6736', // lusher corridor/base (much of the visible play ground is unlabeled base) + rough: '#4a8038', // lush green rough (Augusta second cut) — deeper than the fairway + wood: '#2b4124', + range: '#52883f', + fairwayA: '#5aa848', fairwayB: '#4f9a40', // vivid lush fairway (mow stripes added in shader) + // Greens: muted since material-first (v25) — the splat is the ACTUAL albedo, not a + // 10% residue under the photo drape. The color stays in the fairway family. + greenA: '#4c8f42', greenB: '#447f38', + tee: '#63a84f', + bunker: '#cbb583', + water: '#2f6d97', +}; + +// Dry tan-links targets (from reference/chambers-bay/CATALOG.md). Only the GRASS +// zones get a dry target — greens stay green, sand is greige via makeSandMaterial, +// water/wood are unchanged. Non-grass keys are omitted (pinned to lush by the blend). +export const DRY_PALETTE = { + base: '#a2914f', // dry fescue base — tan, the dominant off-fairway ground + rough: '#c0a666', // gold-tan fescue rough — should DOMINATE the frame + range: '#8f9a54', + fairwayA: '#5e7d3d', fairwayB: '#54703a', // cool olive mown fairway + tee: '#6a8544', +}; + +// Only these palette keys blend toward DRY_PALETTE. Everything else (greenA/B, +// wood, bunker, water) stays at the lush value at every courseDry. +export const BLEND_KEYS = ['base', 'rough', 'range', 'fairwayA', 'fairwayB', 'tee']; + +// Manual per-course dryness, keyed by the course `name` (as it arrives from +// loadCourse). Auto-detection from fairway warmth is a deferred STRETCH (T5). +export const COURSE_DRY = { + 'Chambers Bay': 0.85, + 'TPC Sawgrass': 0.0, + 'St Andrews Old Course': 0.7, + 'Bandon Dunes Golf Resort, Round Lake Drive, Coos County, Oregon, United States': 0.8, +}; + +const clamp01 = (v) => (Number.isFinite(v) ? Math.min(Math.max(v, 0), 1) : 0); + +// Dryness for a course name — clamped to [0,1], 0 (lush) for unknown/missing. +export function courseDryFor(name) { + return clamp01(COURSE_DRY[name]); +} + +const H = (hex) => [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; +const hex2 = (n) => Math.round(n).toString(16).padStart(2, '0'); +const lerpHex = (a, b, t) => { const A = H(a), B = H(b); return '#' + hex2(A[0] + (B[0] - A[0]) * t) + hex2(A[1] + (B[1] - A[1]) * t) + hex2(A[2] + (B[2] - A[2]) * t); }; + +// Blend the lush palette toward the dry one by t. Grass zones (BLEND_KEYS) +// interpolate; all other keys stay lush. t=0 returns a byte-identical copy of +// lush (no lush-course regression); t=1 returns the exact dry targets. +export function blendPalette(lush, dry, t) { + t = clamp01(t); + const out = { ...lush }; + if (t === 0) return out; + for (const k of BLEND_KEYS) out[k] = (t === 1) ? dry[k] : lerpHex(lush[k], dry[k], t); + return out; +} diff --git a/public/render/scene.js b/public/render/scene.js index 1d8dc1c..b78078f 100644 --- a/public/render/scene.js +++ b/public/render/scene.js @@ -19,6 +19,7 @@ import { buildHdTerrain, buildCoarseTerrain } from './hd-terrain.js'; import { makeTerrainSampler } from './terrain-grid.js'; import { RENDER_CONFIG } from './config.js'; import { isPlayFraming, ballReadScale, pinReadScale } from './framing.js'; +import { COLORS, DRY_PALETTE, courseDryFor, blendPalette } from './course-character.js'; const V = (x, y, z) => new THREE.Vector3(x, z, -y); // sim -> three @@ -42,23 +43,9 @@ function pointInPoly(x, y, poly) { return inside; } -// Surfaces are separated by VALUE (dark rough -> light fairway -> lighter -// green) so the hole reads as a golf hole, not one flat green carpet — while -// staying muted enough that the regraded lighting doesn't push them neon. -const COLORS = { - base: '#3c6736', // lusher corridor/base (much of the visible play ground is unlabeled base) - rough: '#4a8038', // lush green rough (Augusta second cut) — deeper than the fairway - wood: '#2b4124', - range: '#52883f', - fairwayA: '#5aa848', fairwayB: '#4f9a40', // vivid lush fairway (mow stripes added in shader) - // Greens: muted since material-first (v25) — the splat is now the ACTUAL albedo, not a - // 10% residue under the photo drape; #5cab4f read as a neon decal. The fine mow, sheen, - // and collar carry "putting surface"; the color stays in the fairway family. - greenA: '#4c8f42', greenB: '#447f38', - tee: '#63a84f', - bunker: '#cbb583', - water: '#2f6d97', -}; +// The lush COLORS palette lives in course-character.js (shared with the dry-links +// blend + its unit test, no circular import). Per-course dryness picks the actual +// palette (this._pal) at load — see below. export class GolfScene { constructor(container) { @@ -260,6 +247,11 @@ export class GolfScene { } this.geo = geo; this.elev = geo.elevation || null; + // Course character (P1b): one manual dryness scalar (0 lush parkland .. 1 dry + // links) picks the actual turf palette + drives the turf shader (uCourseDry). + // Chambers -> tan links; Sawgrass -> unchanged lush green. + this._courseDry = courseDryFor(geo.name); + this._pal = blendPalette(COLORS, DRY_PALETTE, this._courseDry); // HD bundle: a high-res terrain patch + aerial macro within its rect. Sets up the // unified sampler (placement) + macro (turf shader) consumed below. this._hdAssets = hdList; @@ -582,7 +574,7 @@ export class GolfScene { }; // base - ctx.fillStyle = COLORS.base; + ctx.fillStyle = this._pal.base; ctx.fillRect(0, 0, W, H); // noise overlay (mottled grass) @@ -617,20 +609,20 @@ export class GolfScene { // wooded ground ctx.save(); ctx.filter = 'blur(3px)'; - ctx.fillStyle = COLORS.wood; + ctx.fillStyle = this._pal.wood; for (const w of geo.woods || []) { tracePoly(w); ctx.fill(); } ctx.restore(); - fillKind(['rough'], COLORS.rough, 1.5); - fillKind(['range'], COLORS.range, 1.5); + fillKind(['rough'], this._pal.rough, 1.5); + fillKind(['range'], this._pal.range, 1.5); // mown surfaces — uniform base color; mow stripes are added physically in the // turf shader (mask-gated) so they survive the tiled grass detail. - fillKind(['fairway'], COLORS.fairwayA, 1.2); - fillKind(['tee'], COLORS.tee, 1.5); - fillKind(['green'], COLORS.greenA, 1.0); - fillKind(['bunker'], COLORS.bunker, 1.2); - fillKind(['water'], COLORS.water, 1.5); + fillKind(['fairway'], this._pal.fairwayA, 1.2); + fillKind(['tee'], this._pal.tee, 1.5); + fillKind(['green'], this._pal.greenA, 1.0); + fillKind(['bunker'], this._pal.bunker, 1.2); + fillKind(['water'], this._pal.water, 1.5); return cv; } @@ -887,7 +879,7 @@ export class GolfScene { const surf = geo.surfaces || []; const fairway = surf.filter((s) => s.kind === 'fairway' && s.poly && s.poly.length >= 3); const tee = surf.filter((s) => s.kind === 'tee' && s.poly && s.poly.length >= 3); - const cFair = new THREE.Color(COLORS.fairwayA), cTee = new THREE.Color(COLORS.tee), cBase = new THREE.Color(COLORS.base); + const cFair = new THREE.Color(this._pal.fairwayA), cTee = new THREE.Color(this._pal.tee), cBase = new THREE.Color(this._pal.base); const onAny = (polys, x, y) => { for (const s of polys) if (pointInPoly(x, y, s.poly)) return true; return false; }; return (x, y) => (onAny(tee, x, y) ? cTee : onAny(fairway, x, y) ? cFair : cBase); } diff --git a/test/course-character.test.mjs b/test/course-character.test.mjs new file mode 100644 index 0000000..2132aa0 --- /dev/null +++ b/test/course-character.test.mjs @@ -0,0 +1,46 @@ +// P1b: one manual per-course "dryness" scalar blends the lush palette toward a +// dry tan-links palette. Pure logic — greens/sand/water stay put; courseDry=0 +// must be byte-identical to today (no regression to lush courses). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { COLORS, DRY_PALETTE, COURSE_DRY, courseDryFor, blendPalette, BLEND_KEYS } from '../public/render/course-character.js'; + +test('courseDryFor: map lookup, clamp, default 0', () => { + assert.equal(courseDryFor('TPC Sawgrass'), 0); // parkland — no dryness + assert.ok(courseDryFor('Chambers Bay') > 0.5); // links — dry + assert.equal(courseDryFor('Some Unknown Course'), 0); // unknown → lush default + assert.equal(courseDryFor(undefined), 0); // missing → 0, never NaN + assert.ok(!Number.isNaN(courseDryFor(null))); +}); + +test('blendPalette at 0 is byte-identical to COLORS (no lush-course regression)', () => { + assert.deepEqual(blendPalette(COLORS, DRY_PALETTE, 0), COLORS); +}); + +test('blendPalette at 1 hits the dry targets on grass zones', () => { + const dry = blendPalette(COLORS, DRY_PALETTE, 1); + assert.equal(dry.rough, DRY_PALETTE.rough); // gold-tan dominant + assert.equal(dry.fairwayA, DRY_PALETTE.fairwayA); // cool olive +}); + +test('greens/sand/water are excluded from the tan blend (greens stay green)', () => { + for (const t of [0, 0.5, 1]) { + const p = blendPalette(COLORS, DRY_PALETTE, t); + assert.equal(p.greenA, COLORS.greenA); // putting surfaces never go tan + assert.equal(p.greenB, COLORS.greenB); + assert.equal(p.water, COLORS.water); + assert.equal(p.bunker, COLORS.bunker); + } + assert.ok(!BLEND_KEYS.includes('greenA')); +}); + +test('blendPalette is monotone toward tan (rough R rises, G/B fall)', () => { + const r = (hex) => parseInt(hex.slice(1, 3), 16); + const mid = blendPalette(COLORS, DRY_PALETTE, 0.5).rough; + assert.ok(r(COLORS.rough) < r(mid) && r(mid) < r(DRY_PALETTE.rough)); // warms monotonically +}); + +test('blendPalette clamps t outside [0,1]', () => { + assert.deepEqual(blendPalette(COLORS, DRY_PALETTE, 2), blendPalette(COLORS, DRY_PALETTE, 1)); + assert.deepEqual(blendPalette(COLORS, DRY_PALETTE, -1), COLORS); +}); From 25faac9a481792d657282748c493d1c2686abb1e Mon Sep 17 00:00:00 2001 From: Rooj Rin <72690804+rroojrooj@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:19:37 +0700 Subject: [PATCH 4/6] =?UTF-8?q?feat(P1b):=20uCourseDry=20shader=20coordina?= =?UTF-8?q?tion=20=E2=80=94=20warm-mix,=20stripes,=20far-photo=20(T3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uCourseDry uniform (passed via _turfInputs so it hits BOTH base + HD-patch materials) coordinates the 3 shader colour sources the splat alone can't: (1) pull the noise warm-mix endpoint toward neutral so a tan base doesn't double-count/overshoot; (2) scale mow-stripe strength toward a low floor for links; (3) lower the far-photo weight so dry courses show lit tan turf at overview instead of the green summer aerial. Cache key v31->v32. 300/300 green. Co-Authored-By: Claude Fable 5 --- public/render/scene.js | 1 + public/render/turf.js | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/public/render/scene.js b/public/render/scene.js index b78078f..c05ca44 100644 --- a/public/render/scene.js +++ b/public/render/scene.js @@ -446,6 +446,7 @@ export class GolfScene { this._turfInputs = { baseMap: tex, mownMask: maskTex, bunkerMask: bunkerMaskTex, bounds: b, anisotropy: tex.anisotropy, macro: this._macro || this._hdMacros[0] || null, + courseDry: this._courseDry, // P1b: drives the turf shader (warm-mix, stripes, far-photo) }; const turfMat = makeTurfMaterial(this._turfInputs); if (this._hdPatches.length) { diff --git a/public/render/turf.js b/public/render/turf.js index f333fdf..30f2b02 100644 --- a/public/render/turf.js +++ b/public/render/turf.js @@ -37,7 +37,7 @@ export function makeSandMaterial(bounds, aniso) { return mat; } -export function makeTurfMaterial({ baseMap, mownMask, bunkerMask, bounds, anisotropy, macro = null }) { +export function makeTurfMaterial({ baseMap, mownMask, bunkerMask, bounds, anisotropy, macro = null, courseDry = 0 }) { const splatTex = baseMap, maskTex = mownMask, bunkerMaskTex = bunkerMask, aniso = anisotropy; const extX = bounds.maxX - bounds.minX, extY = bounds.maxY - bounds.minY; const tileM = 2.0; // grass texture repeats ~every 2m @@ -65,6 +65,7 @@ export function makeTurfMaterial({ baseMap, mownMask, bunkerMask, bounds, anisot shader.uniforms.uSand = { value: sand }; shader.uniforms.uExt = { value: new THREE.Vector2(extX, extY) }; shader.uniforms.uStripeM = { value: 7.0 }; // mow-band width (m) — a touch wider reads better from the orbit cam + shader.uniforms.uCourseDry = { value: courseDry }; // P1b: 0 lush parkland .. 1 dry links // Aerial macro layer (optional) — MATERIAL-FIRST since v24: the photo no longer // replaces the lit turf. Its blurred low-frequency copy (uMacroLow / uMacroAvg) // TINTS the material's hue/value, and the raw photo only crossfades in at TRUE @@ -132,7 +133,9 @@ export function makeTurfMaterial({ baseMap, mownMask, bunkerMask, bounds, anisot // a global de-light flattens real fairway/dune/sand albedo into milky grey // — tried, reverted). A sliver of blade grain (dl) for micro-texture. vec3 photo = texture2D(uMacro, mUv).rgb * (0.86 + 0.30 * dl); - grass = mix(grass, photo, mvalid * edgeW * uMacroPhotoFar * photoFar); + // P1b: on dry courses lower the far-photo weight so the lit TAN turf carries + // the overview instead of the green summer aerial washing back over it. + grass = mix(grass, photo, mvalid * edgeW * uMacroPhotoFar * photoFar * (1.0 - 0.7 * uCourseDry)); } }` : ''; if (macro) { shader.uniforms.uMacro = { value: macro.albedo }; @@ -153,7 +156,7 @@ export function makeTurfMaterial({ baseMap, mownMask, bunkerMask, bounds, anisot .replace('#include ', `#include uniform sampler2D uDetail; uniform vec2 uDetailRepeat; uniform sampler2D uMask; uniform sampler2D uBunker; uniform sampler2D uSand; - uniform vec2 uExt; uniform float uStripeM; + uniform vec2 uExt; uniform float uStripeM; uniform float uCourseDry; // Procedural turf grain — evaluated from world XZ so it stays crisp at ANY // zoom. A tiled grass photo mip-blurs to a flat average from the elevated // orbit camera (the "Minecraft" smoothness); world-space value noise doesn't. @@ -223,7 +226,11 @@ export function makeTurfMaterial({ baseMap, mownMask, bunkerMask, bounds, anisot // big regions also shift the grass CHARACTER — lush deep-green <-> dry // yellow-green — so different parts of the course read as different grass, // not one uniform tone stamped edge to edge. - grass *= mix(vec3(0.93, 1.0, 0.9), vec3(1.13, 1.03, 0.74), + // P1b: on dry/links courses the tan comes from the base palette, so pull the + // noise warm-endpoint toward neutral by uCourseDry — else the tan base x warm + // multiplier double-counts and overshoots past the target gold-tan. + vec3 warmEnd = mix(vec3(1.13, 1.03, 0.74), vec3(1.0, 1.0, 0.96), uCourseDry * 0.7); + grass *= mix(vec3(0.93, 1.0, 0.9), warmEnd, clamp(zone * 1.7 + 0.5, 0.0, 1.0)); // lush green <-> golden tan-fescue patches grass.r *= 1.0 + 0.10 * broad; // finer warm/cool drift on top grass.b *= 1.0 - 0.07 * broad; @@ -244,7 +251,9 @@ export function makeTurfMaterial({ baseMap, mownMask, bunkerMask, bounds, anisot // artificial pattern at altitude now that the lowered courseAerialPhotoFar lets // the lit relief (and the stripes) show through instead of a flat far photo. float sFade = 1.0 - smoothstep(120.0, 280.0, length(vViewPosition)); - grass *= 1.0 + (0.38 * stripe + 0.17 * stripe2) * m * (1.0 - 0.85 * g - 0.6 * fr) * sFade; + // P1b: links are lightly mown — scale stripe strength down (to a low, non-zero + // floor) by uCourseDry; parkland (0) keeps the bold set. + grass *= 1.0 + (0.38 * stripe + 0.17 * stripe2) * m * (1.0 - 0.85 * g - 0.6 * fr) * sFade * (1.0 - 0.7 * uCourseDry); // GREEN (v29): calm fine grain + a SUBTLE checker mow + a gentle contour roll, // all gated by the SOFT edge (gEdge) so the putting-surface character fades // across the collar instead of at a hard line. Checker dropped 0.15 -> 0.09 (the @@ -328,7 +337,7 @@ export function makeTurfMaterial({ baseMap, mownMask, bunkerMask, bounds, anisot normal = normalize(normal + mTilt * (0.18 * (1.0 - smoothstep(18.0, 55.0, length(vViewPosition))))); }`); }; - mat.customProgramCacheKey = () => (macro ? 'turf-grain-v31-macro' : 'turf-grain-v31'); + mat.customProgramCacheKey = () => (macro ? 'turf-grain-v32-macro' : 'turf-grain-v32'); // textures injected via onBeforeCompile (+ the canvas masks) aren't reachable from // the standard material slots, so register them for disposal on course reload. mat.userData.disposeTextures = [detail, sand, maskTex, bunkerMaskTex]; From 3c4a2468f27255afd76d3981f85667c5a5448b81 Mon Sep 17 00:00:00 2001 From: Rooj Rin <72690804+rroojrooj@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:23:39 +0700 Subject: [PATCH 5/6] test(P1b): update hd-turf for v32 cache key + uCourseDry uniform Cache-key assertions v31->v32 (both variants) + a new test that courseDry passes through to the uCourseDry uniform and appears in the shader. 301/301. Co-Authored-By: Claude Fable 5 --- test/hd-turf.test.mjs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/hd-turf.test.mjs b/test/hd-turf.test.mjs index 35950c0..4ac2c81 100644 --- a/test/hd-turf.test.mjs +++ b/test/hd-turf.test.mjs @@ -17,9 +17,10 @@ test('legacy turf material (options object): same uniforms, no macro', () => { const mat = makeTurfMaterial({ baseMap: tex(), mownMask: tex(), bunkerMask: tex(), bounds, anisotropy: 4 }); const s = fakeShader(); mat.onBeforeCompile(s); - for (const u of ['uDetail', 'uMask', 'uBunker', 'uSand', 'uExt', 'uStripeM']) assert.ok(s.uniforms[u], `missing ${u}`); + for (const u of ['uDetail', 'uMask', 'uBunker', 'uSand', 'uExt', 'uStripeM', 'uCourseDry']) assert.ok(s.uniforms[u], `missing ${u}`); + assert.equal(s.uniforms.uCourseDry.value, 0, 'courseDry defaults to 0 (lush)'); assert.ok(!s.uniforms.uMacro, 'no macro uniform without macro'); - assert.equal(mat.customProgramCacheKey(), 'turf-grain-v31'); + assert.equal(mat.customProgramCacheKey(), 'turf-grain-v32'); // v25: the green gate rides uMask.g (packed channel) in BOTH variants — // the roughness sheen samples it directly, the map block via the mk swizzle assert.match(s.fragmentShader, /texture2D\(uMask, vMapUv\)\.g/); @@ -44,7 +45,7 @@ test('macro turf material: adds aerial tint uniforms + a distinct program', () = assert.equal(s.uniforms.uMacroLow.value, macro.low); assert.equal(s.uniforms.uMacroAvg.value, macro.avg); assert.equal(s.uniforms.uMacroPhotoFar.value, 0.65); - assert.equal(mat.customProgramCacheKey(), 'turf-grain-v31-macro'); + assert.equal(mat.customProgramCacheKey(), 'turf-grain-v32-macro'); // the tint must be SAMPLED (a declaration alone would pass a bare /uMacroLow/ match) assert.match(s.fragmentShader, /texture2D\(\s*uMacroLow/); // v27: the NDVI class-map (uMacroSurfaces) was declared-but-unsampled — it must now @@ -63,6 +64,14 @@ test('macro turf material: adds aerial tint uniforms + a distinct program', () = assert.doesNotMatch(s.fragmentShader, /grass = mix\(grass, photo, mw\)/); }); +test('P1b: courseDry passes through to the uCourseDry uniform (drives warm-mix/stripes/far-photo)', () => { + const mat = makeTurfMaterial({ baseMap: tex(), mownMask: tex(), bunkerMask: tex(), bounds, anisotropy: 4, courseDry: 0.8 }); + const s = fakeShader(); + mat.onBeforeCompile(s); + assert.equal(s.uniforms.uCourseDry.value, 0.8); + assert.match(s.fragmentShader, /uCourseDry/); +}); + test('macro without low/avg (HD-bundle shape) still wires tint uniforms', () => { const macro = { albedo: tex(), surfaces: tex(), coverage: tex(), bounds, closeWeight: 0.2, farWeight: 0.6 }; const mat = makeTurfMaterial({ baseMap: tex(), mownMask: tex(), bunkerMask: tex(), bounds, anisotropy: 4, macro }); From ca2aecb0408e4b34474756367de10298b97abd40 Mon Sep 17 00:00:00 2001 From: Rooj Rin <72690804+rroojrooj@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:31:51 +0700 Subject: [PATCH 6/6] docs(P1b): record tan-links outcome + live verification Sub-plan/TODO/HANDOFF: P1b shipped + verified. Chambers courseDry=0.85 reads tan-gold links at play (greens green, no seam) + warmer overview; Sawgrass courseDry=0 proven byte-unchanged (palette + shader no-ops). Tuning knob = COURSE_DRY['Chambers Bay'] + DRY_PALETTE. Co-Authored-By: Claude Fable 5 --- docs/HANDOFF.md | 4 +++- docs/TODO.md | 11 +++++++++++ docs/superpowers/plans/2026-07-07-p1b-tan-palette.md | 11 +++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 1056561..77e192d 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -6,7 +6,9 @@ > vegetation/atmosphere). **P0a is SHIPPED** (branch `claude/phase0-debug-purge`): `framing.js` gate hides the > leaked aim line + 26× ball auto-scale outside a play framing; capture fixture at `docs/fixtures/chambers-sweep.json`. > Live diagnosis: the "yellow T" is NOT a sprite (0 sprites in scene) and the pond "checker" is NOT a cheap -> Reflector-gate — both deferred to P2/later. Start from the master plan + `docs/TODO.md`, not this doc. +> Reflector-gate — both deferred to P2/later. **P1b (tan palette) is SHIPPED** (branch `claude/p1b-tan-palette`): one manual +> `courseDry` scalar (`course-character.js`) drives 4 coordinated colour sources — Chambers reads tan-gold +> links, Sawgrass unchanged, greens stay green. Start from the master plan + `docs/TODO.md`, not this doc. > **UPDATE 2026-07-06 (4) — REAL-PHOTO CROSS-CHECK + GREIGE SAND** (branch `claude/course-character-palette`). > Built a **~106-photo local reference library** (`reference/chambers-bay/` + `CATALOG.md`) of the real course diff --git a/docs/TODO.md b/docs/TODO.md index 29d076e..14e4dd3 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -20,6 +20,17 @@ before/after on the harness. **Live diagnosis deferred the rest:** the "yellow T (reflOn == reflOff) → deferred; classmap dot-screen + HD macro seam → **P2** (it rewrites those `turf.js` surfaces — do them once). Sub-plan: [`2026-07-07-phase0-debug-purge.md`](superpowers/plans/2026-07-07-phase0-debug-purge.md). +### P1b — DONE (2026-07-07, branch `claude/p1b-tan-palette`) +Eng-review + outside voice **re-architected** "paint the splat tan" → one manual `courseDry` scalar drives +**four** coordinated colour sources (the splat alone is overridden by blades, the shader warm-mix, and the +far-photo). Shipped: `course-character.js` (COLORS moved here + `DRY_PALETTE` + `COURSE_DRY` map, client-side — +a JSON field is dropped in transit; `blendPalette` grass-only, greens excluded, `courseDry=0` byte-identical); +`scene.js` paints splat + `_fairwayZoneColor` blades from `this._pal`; `turf.js` `uCourseDry` (v32) pulls the +warm-mix endpoint + floors stripes + lowers far-photo for dry courses. 301/301. **Live-verified:** Chambers +`courseDry=0.85` reads tan-gold links at play (green stays green, no seam) + warmer overview; Sawgrass +`courseDry=0` proven byte-unchanged. Sub-plan: [`2026-07-07-p1b-tan-palette.md`](superpowers/plans/2026-07-07-p1b-tan-palette.md). +**Next lanes:** P1a (Puget Sound + world edge) ∥ P2 (SDF crisp surfaces — absorbs the P0a-deferred classmap dots + macro seam). + ## Real-photo cross-check + greige sand — DONE (2026-07-06, branch `claude/course-character-palette`) Cross-checked the sim's renders against the REAL Chambers Bay (gathered a **~106-photo local reference diff --git a/docs/superpowers/plans/2026-07-07-p1b-tan-palette.md b/docs/superpowers/plans/2026-07-07-p1b-tan-palette.md index a28b7d7..e8ff58e 100644 --- a/docs/superpowers/plans/2026-07-07-p1b-tan-palette.md +++ b/docs/superpowers/plans/2026-07-07-p1b-tan-palette.md @@ -11,6 +11,17 @@ play and overview; TPC Sawgrass stays **lush green parkland** (unchanged). One manual course-level dryness scalar `courseDry ∈ [0,1]` drives every colour source coherently. #1 gap from both assessors; critical path to P2. +> **BUILT + VERIFIED 2026-07-07.** All 5 tasks shipped (T5 auto-detector stays deferred). `course-character.js` +> (COLORS moved here + DRY_PALETTE + COURSE_DRY map + `blendPalette` greens-excluded) → `scene.js` paints splat +> + blades from `this._pal`; `turf.js` `uCourseDry` (v32, on base + HD-patch mats) pulls the warm-mix endpoint, +> floors the stripes, and lowers the far-photo weight for dry courses. 301/301 green (course-character + +> hd-turf tests). **Live-verified on Chambers (`courseDry=0.85`):** the PLAY view is a convincing tan-gold +> fescue links with the putting green staying green and no blade seam; the OVERVIEW reads clearly +> warmer/tan (the far-photo no longer washes it fully green). **Sawgrass no-regression PROVEN:** `courseDry=0` +> → `blendPalette` returns COLORS byte-identical (live: `palRough=#4a8038`) AND every shader edit is a no-op +> at `uCourseDry=0` (`1.0-0.7*0=1.0`, `warmEnd=warm`). Tuning knob = `COURSE_DRY['Chambers Bay']` + `DRY_PALETTE` +> if a later assessor wants the overview even tanner. + **Target palette (`reference/chambers-bay/CATALOG.md`):** dry rough **#c0a666** (gold-tan, dominant), fairway **#5e7d3d** (cool olive), green **#6b894a** (stays green — NOT tan), sand **#b7a98b** (already shipped).