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 new file mode 100644 index 0000000..e8ff58e --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-p1b-tan-palette.md @@ -0,0 +1,183 @@ +# 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 `- [ ]`. +> +> **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. + +> **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). + +## 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 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. + +--- + +## Verify / done (whole phase) + +- 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). + +--- + +## 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 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..c05ca44 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; @@ -454,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) { @@ -582,7 +575,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 +610,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 +880,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/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]; 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); +}); 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 });