feat(highway_3d): colour theming — string presets + Background/Highway scene themes (#596)

* feat(highway_3d): add one-click string-color presets

Adds 12 named string-color presets (Warm→Cool, Vivid, Colorblind-friendly,
Neon, Accessible, Warm Ember, Tape Deck, CRT Green/Amber, Pitch Ramp, Sunrise)
selectable from the 3D Highway settings panel.

Extends the existing core HWC (highway-color) subsystem in static/app.js with
HWC_PRESETS + applyHighwayStringPreset(), exposed on the existing facade as
window.feedBack.highwayColors.{presets, applyPreset}. The plugin settings page
renders the preset buttons from that core list and refreshes the per-string
pickers on apply. Purely additive — stock behavior is unchanged.

Scope: core static/app.js (the shared HWC facade both highways consume) plus the
highway_3d plugin's settings.html / screen.js / CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* fix(highway_3d): address review of colour-theming PR

- Rebuild assets/plugin.css so the new `flex-wrap` (preset row) and
  `text-[10px]` (theme-dropdown helper) Tailwind classes are actually
  compiled, and bump plugin.json 3.26.0 -> 3.27.0 so the <link>'s ?v=
  cache-buster fetches the fresh CSS (per the plugin's build rule).
- Replace the mirror-at-every-read hwTheme migration with a one-time
  backfill (persist hwTheme := bgTheme on first load, no emit). The two
  scene-color axes are now genuinely independent: changing the Background
  dropdown no longer silently retints the Highway surface/lane, and the
  rendered highway can't disagree with the Highway dropdown value.
- Collapse the duplicated theme id-set in settings.html (two identical
  <option> lists + VALID_BG_THEMES) into a single SCENE_THEMES source the
  dropdowns and validator are generated from; sync points 4 -> 2.
- Update CLAUDE.md to document the backfill + reduced sync contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou 2026-06-26 04:05:31 -05:00 committed by GitHub
parent b70fde9b02
commit 97dae88860
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 415 additions and 11 deletions

View File

@ -108,9 +108,19 @@ Each entry names the function or banner you should grep for, plus key sub-blocks
- **Beat lines** (downbeats highlighted) → `update()`, `// ── Beat lines ──` block. `mBeatM` (full opacity 0.25) for measure starts, `mBeatQ` (0.07) for other beats.
- **Section labels**`update()`, `// ── Section labels ──` block. Cyan (`#00cccc`) sprite at fret 12, above the highest string.
### Scene colors (two independent axes: Background + Highway)
- **Scene-color themes**`BG_THEMES` table near the top of `createFactory()`. One combined table is the single source of truth, but it drives **two independent axes that share the same id-set**:
- **Background axis** — setting key `bgTheme`, setter `window.h3dBgSetBgTheme`, state `bgThemeId`. Owns `clear` (WebGL clear color) + `fog`.
- **Highway axis** — setting key `hwTheme`, setter `window.h3dBgSetHwTheme`, state `hwThemeId`. Owns `board` (fretboard/highway-surface plane) + optional `lane`/`laneDim` (the lit lane strip).
Any background id can mix with any highway id; picking the same id in both gives the original "matched" look. Per-axis accessors are `_bgBackgroundColors(id)` / `_bgHighwayColors(id)` (both alias `_bgThemeColors`). Both axes default to `'default'` (byte-identical to the original look).
- **Applying a theme**`_applyBgTheme()`. Background half sets clear+fog from `bgThemeId` (skipped under the venue scene); highway half sets the board plane + lane materials (`mLaneOdd`/`mLaneEven`) from `hwThemeId`. Re-run on init, `buildBoard()`, and the settings listener (which fires for **both** `bgTheme` and `hwTheme`), so changing either dropdown retints only its half live.
- **Backward-compat migration**`_bgLoadSettings()`: the first time it loads with no stored `hwTheme` (`_bgHasStored` false), it seeds `hwThemeId` from `bgThemeId` **and persists `hwTheme` once** (a one-time backfill, written without `_bgEmitChange`). So a pre-split single-`bgTheme` pick is byte-identical right after the upgrade, and from then on the two axes are fully independent — changing the Background dropdown never drags the Highway surface, and the settings UI's Highway value can't disagree with what's rendered. settings.html shows the same first-load value via `storedHwTheme == null ? bgTheme : coerceHwTheme(...)`.
- **Adding/removing a theme** → edit `BG_THEMES` (the colors) AND `settings.html`'s `SCENE_THEMES` array (the `{id,label}` list — the single source the two dropdowns' `<option>`s and the `VALID_BG_THEMES` validator are both generated from). Keep the two id-sets aligned.
### Highway lane (the highlighted strip under active frets)
- **Lane drawing**`update()`, `// ── Dynamic highway lane ──` block. `pLane` is a single quad on the fretboard plane; `pLaneDivider` is thin vertical lines at each fret inside the lane. Width keys off the active-fret range; min width ≈ 4 frets.
- **Lane intensity**`highwayIntensity` accumulated from upcoming notes (further notes dim it, near notes light it). `_laneTargetColor = 0x4488ff` (set in `initScene()`) is the "lit" color, blended toward from `0x112233`.
- **Lane intensity**`highwayIntensity` accumulated from upcoming notes (further notes dim it, near notes light it).
- **Lane color** → the lit quad color is `mLaneOdd.color` (stock `HWY_LANE_STRIPE_ODD_HEX = 0x103B5C`), the dimmer alternating row `mLaneEven.color` (`HWY_LANE_STRIPE_EVEN_HEX = 0x08283C`). These are now **theme-aware**: `_applyBgTheme()` recolors them from the active HIGHWAY theme's optional `lane`/`laneDim` fields, falling back to the stock hexes when a highway theme omits them. (`_laneTargetColor`, set in `initScene()`, is kept in sync with the lit color but has no live consumer today.)
### Lyrics & overlays
- **Lyrics overlay**`drawLyrics()`. 2D canvas, top centre, semi-transparent rounded background, syllable-level highlighting (current syllable in white, played in muted, upcoming in dim).
@ -201,7 +211,7 @@ The eight-color palette `S_COL` is the single source of truth for per-string col
If a planned color-palette feature lands (issue #10), expect it to swap the palette source array but keep this single-array indirection. Anything that hardcodes color today will break that swap; flag it during review.
Non-string colors (lane target `0x4488ff`, fret-row label colors `#ffe84d` / `#9ab8cc`, fret-dot color `0x556677`, lyrics box rgba, chord-name gold `#e8d080`, etc.) are scattered as literals — that's intentional for now, since they're scene-wide accents rather than per-string. Pulling them into named constants is fine if you're already in that area.
Non-string colors (the stock lane hexes `HWY_LANE_STRIPE_ODD_HEX`/`_EVEN_HEX` — now overridable per Highway theme, see "Scene colors" above; fret-row label colors `#ffe84d` / `#9ab8cc`, fret-dot color `0x556677`, lyrics box rgba, chord-name gold `#e8d080`, etc.) are scattered as literals — that's intentional for now, since they're scene-wide accents rather than per-string. Pulling them into named constants is fine if you're already in that area.
## Tweaking text-sprite styling

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.26.0",
"version": "3.27.0",
"type": "visualization",
"bundled": true,
"script": "screen.js",

View File

@ -1123,7 +1123,7 @@
return _bgBandsCache;
}
const BG_DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
const BG_DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', bgTheme: 'default', hwTheme: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
// User-selectable, persistable bg styles — must mirror settings.html's
// VALID_STYLES. 'venue' is deliberately NOT here: it is an internal effective
// style reached only via _venueSceneOverride (the viz-picker Venue flow), so
@ -1131,6 +1131,83 @@
// mount outside that flow and settings.html (which can't represent 'venue')
// would be unable to switch back. BG_STYLES still has a 'venue' renderer entry.
const BG_STYLE_IDS = ['off', 'particles', 'silhouettes', 'lights', 'geometric', 'image', 'video'];
// Scene color themes — TWO INDEPENDENT AXES sharing one palette family.
// The combined `BG_THEMES` table below is the single source of truth; each
// entry carries the colors for BOTH axes, but the two axes are selected and
// applied SEPARATELY (two dropdowns, two settings keys):
// • BACKGROUND axis (setting key `bgTheme`) owns:
// clear — WebGL clear color (the empty background behind everything)
// fog — distance fog tint (kept === clear so the horizon dissolves
// cleanly instead of showing a seam)
// • HIGHWAY axis (setting key `hwTheme`) owns:
// board — the fretboard / highway-surface plane color
// lane — the lit highway lane strip under the gems (optional)
// laneDim — the lane's dimmer alternating row (optional)
// Because both axes read from the SAME id-set (the keys of this table), ANY
// background id can mix with ANY highway id (e.g. Deep Focus background +
// Cathode Green highway); picking the SAME id in both gives the original
// "matched" combined look. _bgBackgroundColors()/_bgHighwayColors() below
// are the per-axis accessors; both fall back to 'default' for unknown ids.
// 'default' reproduces the original look byte-for-byte on BOTH axes, so
// existing users (and anyone who never touches either setting) see no
// change. A migration in _bgLoadSettings() makes an existing single-`bgTheme`
// pick drive BOTH axes until the user diverges them, so upgrades are
// visually identical too. All themes keep the board very dark and the
// background dark so the bright per-string note gems, lane, and labels
// retain contrast. NOTE: settings.html mirrors these ids in its
// VALID_BG_THEMES set (shared by both dropdowns) — keep them in sync.
// Optional `lane` / `laneDim` fields retint the lit highway lane strip + its
// dimmer alternating row. A theme that omits them falls back to the stock
// blue lane (HWY_LANE_STRIPE_ODD_HEX / _EVEN_HEX); only 'default' relies on
// that fallback (so its output stays byte-identical). Every other theme sets
// its own lane so the Highway axis is visibly distinct entry-to-entry — the
// near-black neutral boards alone aren't separable, so the lane carries it.
// See _applyBgTheme().
const BG_THEMES = {
default: { clear: 0x101820, fog: 0x101820, board: 0x08080e },
// Cool navy surface + a brighter pure-blue lane, so it reads distinct
// from 'default' (neutral board + stock teal-blue lane) on the Highway axis.
midnight: { clear: 0x0a0e1a, fog: 0x0a0e1a, board: 0x080d1c, lane: 0x244fae, laneDim: 0x122a5e },
// Lighter NEUTRAL-grey surface + a steel-grey lane — the only mid-dark
// neutral board, so the surface itself is visibly different from the
// near-black neutrals around it (board kept dark enough for gem contrast).
charcoal: { clear: 0x16181c, fog: 0x16181c, board: 0x141417, lane: 0x525a66, laneDim: 0x282d34 },
deeppurple: { clear: 0x140a1e, fog: 0x140a1e, board: 0x0b0610, lane: 0x3a1f6e, laneDim: 0x1f1040 },
forest: { clear: 0x0a1614, fog: 0x0a1614, board: 0x06100c, lane: 0x15602a, laneDim: 0x0a3318 },
// Warm dark neutral (espresso/umber) — the first non-cool scene.
warmslate: { clear: 0x1c130b, fog: 0x1c130b, board: 0x0e0805, lane: 0x5e3a12, laneDim: 0x341f0a },
// Recessive near-black neutral (a hair above #000000, ~zero chroma) —
// maximizes gem-vs-board contrast; a clean stage/stream look. Purest-dark
// board + a clean steel-cyan lane (brighter/cooler than 'default's muted
// teal-blue) so the Highway axis reads clearly distinct from default.
deepfocus: { clear: 0x0c0c0d, fog: 0x0c0c0d, board: 0x060606, lane: 0x2f7fa0, laneDim: 0x163c4e },
// Calm dark teal — blue-dominant so it reads distinct from the navy
// 'midnight' and the green 'forest'.
deepsea: { clear: 0x06222b, fog: 0x06222b, board: 0x03141a, lane: 0x0e5a63, laneDim: 0x063338 },
// Retro CRT glow — a warm AMBER phosphor cast (the classic amber
// terminal). Amber rather than green so a phosphor board can't crush
// green/teal gems, and so it stays clearly distinct from 'forest' and
// 'deepsea'. Board stays very dark / low-chroma to keep gems popping.
cathode: { clear: 0x140b03, fog: 0x140b03, board: 0x0c0702, lane: 0x6e4a0e, laneDim: 0x3a2806 },
// Retro CRT GREEN phosphor — leaned more saturated / cyan-green than
// 'forest' so it reads as a terminal, not woodland (dRGB 35 vs forest,
// 32 vs deepsea). Phosphor-green board + green lane. Verified to keep
// green/teal gems legible (green-on-green floor CR ~2.2).
cathodegreen: { clear: 0x07301a, fog: 0x07301a, board: 0x031a0c, lane: 0x0e6e2a, laneDim: 0x073a18 },
// Warm hearth — the first warm-RED scene, pairs with the Ember/Sunrise
// strings. Deep red, pushed away from the amber 'cathode'/'warmslate'
// (dRGB ~26 from cathode). Ember-red lane.
hearth: { clear: 0x280806, fog: 0x280806, board: 0x1a0606, lane: 0x7a2410, laneDim: 0x3f1409 },
};
const BG_THEME_IDS = Object.keys(BG_THEMES);
// Shared lookup for the combined entry (both axes are keyed by the same id
// set, so a single id list / coerce check validates either axis).
function _bgThemeColors(id) { return BG_THEMES[id] || BG_THEMES.default; }
// Per-axis accessors. Background reads clear/fog; highway reads
// board/lane/laneDim. They alias the same table — splitting at read-time
// keeps one source of truth while letting the two dropdowns pick freely.
function _bgBackgroundColors(id) { return _bgThemeColors(id); }
function _bgHighwayColors(id) { return _bgThemeColors(id); }
const VENUE_SCENE_ASSET_BASE = '/static/assets/venue/themes/small-club/';
const VENUE_BG_PLATE_PNG = 'bg-plate.png';
const VENUE_BG_PLATE_WEBP = 'bg-plate.webp';
@ -1412,6 +1489,9 @@
if (_BG_BOOL_KEYS.has(key)) return _bgCoerceBool(val, BG_DEFAULTS[key]);
if (key === 'style') return BG_STYLE_IDS.includes(val) ? val : BG_DEFAULTS.style;
if (key === 'palette') return (PALETTE_IDS.includes(val) || val === 'custom') ? val : BG_DEFAULTS.palette;
if (key === 'bgTheme') return BG_THEME_IDS.includes(val) ? val : BG_DEFAULTS.bgTheme;
// Highway axis shares the same id-set as the background axis.
if (key === 'hwTheme') return BG_THEME_IDS.includes(val) ? val : BG_DEFAULTS.hwTheme;
if (key === 'chordDiagramPosition')
return CHORD_DIAG_POSITION_IDS.includes(val) ? val : BG_DEFAULTS.chordDiagramPosition;
if (key === 'sectionHudPosition')
@ -1483,6 +1563,20 @@
window.h3dBgSetIntensity = (v) => _bgWriteGlobal('intensity', v);
window.h3dBgSetReactive = (v) => _bgWriteGlobal('reactive', !!v);
window.h3dBgSetPalette = (v) => _bgWriteGlobal('palette', v);
// BACKGROUND scene-color axis (clear + fog only). Validated against
// BG_THEME_IDS in _bgCoerce; the listener re-applies clear/fog live and
// independently of the highway axis.
window.h3dBgSetBgTheme = (v) => {
const s = String(v);
_bgWriteGlobal('bgTheme', BG_THEME_IDS.includes(s) ? s : BG_DEFAULTS.bgTheme);
};
// HIGHWAY scene-color axis (board + lane + laneDim). Same id-set as the
// background axis, so any highway can mix with any background. The listener
// re-applies the board plane + lane live and independently.
window.h3dBgSetHwTheme = (v) => {
const s = String(v);
_bgWriteGlobal('hwTheme', BG_THEME_IDS.includes(s) ? s : BG_DEFAULTS.hwTheme);
};
// Apply a user-defined per-string color set (core theming UI). `hexArray`
// is up to 8 hex strings; invalid/missing entries fall back to the default
// palette per index. Writes the colors, then flips the palette to 'custom'
@ -2738,6 +2832,13 @@
let bgGroup = null, bgStage = null, bgState = null;
let bgMountedStyleId = null;
let bgStyleId = 'particles', bgIntensity = 0.5, bgReactive = true;
// Active scene color theme (background + highway surface). Read in
// _bgLoadSettings, applied by _applyBgTheme (clear + fog + board plane).
let bgThemeId = 'default'; // BACKGROUND axis (clear + fog)
let hwThemeId = 'default'; // HIGHWAY axis (board + lane + laneDim)
// Board (fretboard/highway-surface) plane material — kept so the theme
// can recolor it live without rebuilding the board. Set in buildBoard().
let _boardPlaneMat = null;
// Per-render opt-out for plugins borrowing the highway as a viz: when the
// mount bundle sets bgReactive === false, suppress the audio-reactive
// background for THIS instance only (no shared h3d_bg_* write). Captured
@ -6220,6 +6321,11 @@
_bgLoadSettings();
buildBoard();
// Apply the scene color theme now that settings + board exist. Sets
// the clear color + fog tint (board plane was themed in buildBoard).
// For the default theme this is identical to the hardcoded values
// initScene seeded above, so nothing changes for existing users.
_applyBgTheme();
// Background animations (#13). Read settings keyed by this
// panel and mount the active style's meshes. Subscribe to
@ -6337,6 +6443,17 @@
if (bgStyleId === 'lights') _bgRebuild();
return;
}
if (changedKey === 'bgTheme' || changedKey === 'hwTheme') {
// A scene-color axis changed (background = bgTheme:
// clear+fog; highway = hwTheme: board plane + lane). Recolor
// in place — no mesh rebuild needed (the board plane material
// is mutated via _boardPlaneMat, the lane via mLaneOdd/Even).
// _applyBgTheme reapplies both axes from their own keys, so
// changing one dropdown retints only its half.
_bgLoadSettings();
_applyBgTheme();
return;
}
if (changedKey === 'customImageDataUrl') {
// Asset bytes changed. Rebuild only when the image
// style is active — otherwise the new bytes will
@ -6543,6 +6660,25 @@
_bgPaletteSig = newSig;
_applyPaletteToMaterials();
}
bgThemeId = _bgReadSetting(panelKey, 'bgTheme');
// Highway axis. ONE-TIME BACKWARD-COMPAT BACKFILL: the first time we
// load with no stored hwTheme (pre-split installs, or anyone who only
// ever touched the old single "Scene colors" control), seed hwTheme
// FROM the background pick AND PERSIST it, so an existing 'cathode'
// selection looks byte-identical right after the upgrade. Persisting
// immediately (rather than re-inheriting on every read) is what keeps
// the two axes truly INDEPENDENT thereafter: once hwTheme is stored,
// changing the Background dropdown no longer drags the Highway
// surface along, and the settings UI's Highway value can never
// disagree with what's rendered. Written without _bgEmitChange so the
// backfill can't re-enter the change listener.
if (_bgHasStored(panelKey, 'hwTheme')) {
hwThemeId = _bgReadSetting(panelKey, 'hwTheme');
} else {
hwThemeId = bgThemeId;
_bgMemFallback.hwTheme = String(bgThemeId);
try { localStorage.setItem('h3d_bg_hwTheme', String(bgThemeId)); } catch (_) { /* storage blocked — mem fallback still seeds the read */ }
}
showFretOnNote = _bgReadSetting(panelKey, 'showFretOnNote');
fretNumberGhostScope = _bgReadSetting(panelKey, 'fretNumberGhostScope');
cameraSmoothing = _bgReadSetting(panelKey, 'cameraSmoothing');
@ -6916,14 +7052,54 @@
if (ren) ren.setClearColor(0x080c12);
if (ambLight) ambLight.intensity = 0.68;
} else {
scene.fog.color.setHex(0x101820);
// Restore the user's scene-color theme (clear + fog) rather than
// the old hardcoded gray, so deactivating venue doesn't wipe a
// chosen background theme. _applyBgTheme reads the current theme.
scene.fog.near = FOG_START * 0.8;
scene.fog.far = FOG_END * 1.2;
if (ren) ren.setClearColor(0x101820);
if (ambLight) ambLight.intensity = 0.85;
_applyBgTheme();
}
}
// Apply BOTH scene-color axes, each from its own setting key:
// • BACKGROUND (bgThemeId): the WebGL clear color + the distance-fog
// tint. Skipped while the venue scene is active (venue owns those —
// see _bgApplyVenueSceneFog).
// • HIGHWAY (hwThemeId): the fretboard/highway-surface plane + the lit
// highway lane strip (the bright quad under the gems) + its dimmer
// alternating row. Always themed; venue doesn't touch them.
// The two axes are independent, so picking a different id in each mixes
// freely. Safe to call any time; called from initScene, buildBoard, and
// the scene-theme listener (so a live switch of EITHER dropdown retints
// only its half immediately).
//
// The lane fields are OPTIONAL on a highway theme: one that omits `lane`
// / `laneDim` falls back to the stock lit/dim lane hexes, so every
// existing/neutral highway theme stays byte-identical (default blue lane
// unchanged). Only colored highway themes opt into a coordinated lane.
function _applyBgTheme() {
// --- Background axis: clear + fog ---
const bg = _bgBackgroundColors(bgThemeId);
if (!_venueSceneOverride) {
if (scene && scene.fog) scene.fog.color.setHex(bg.fog);
if (ren) ren.setClearColor(bg.clear);
}
// --- Highway axis: board plane + lane ---
const hw = _bgHighwayColors(hwThemeId);
if (_boardPlaneMat) _boardPlaneMat.color.setHex(hw.board);
// Lit lane strip + its dimmer alternating row. Fall back to the
// hardcoded stock lane colors when the highway theme omits them.
const laneLit = (typeof hw.lane === 'number') ? hw.lane : HWY_LANE_STRIPE_ODD_HEX;
const laneDim = (typeof hw.laneDim === 'number') ? hw.laneDim : HWY_LANE_STRIPE_EVEN_HEX;
if (mLaneOdd) mLaneOdd.color.setHex(laneLit);
if (mLaneEven) mLaneEven.color.setHex(laneDim);
// Keep the (otherwise vestigial) lane target color in sync with the
// lit lane so any future lane-blend consumer reads the themed value.
if (_laneTargetColor) _laneTargetColor.setHex(laneLit);
else _laneTargetColor = new T.Color(laneLit);
}
/* ── Fretboard (static geometry) ────────────────────────────────── */
function _h3dHexOrDefault(hexStr, defHex) {
const d = defHex || BG_DEFAULTS.nutColor;
@ -6967,7 +7143,12 @@
// spawn horizon (-AHEAD * TS), so the far edge aligns with AHEAD.
const blAhead = TS * AHEAD;
const pg = new T.PlaneGeometry(bw, blAhead);
const pm = new T.MeshLambertMaterial({ color: 0x08080e, transparent: true, opacity: 0.6 });
// Board (highway-surface) color comes from the active HIGHWAY scene
// theme (default theme = the original 0x08080e). Kept on
// _boardPlaneMat so _applyBgTheme can recolor it live without
// rebuilding the board.
const pm = new T.MeshLambertMaterial({ color: _bgHighwayColors(hwThemeId).board, transparent: true, opacity: 0.6 });
_boardPlaneMat = pm;
const p = new T.Mesh(pg, pm);
p.rotation.x = -Math.PI / 2;
p.position.set(board.center, S_BASE - NH / 2 - 2 * K, -blAhead / 2);
@ -13156,7 +13337,7 @@
if (ren) { ren.dispose(); ren = null; }
scene = cam = noteG = beatG = lblG = fretG = tuningLblG = null;
ambLight = dirLight = null;
mStr = []; mGlow = []; mSus = []; mStrHitOutline = []; mAccentOutline = []; mAccentCore = []; mAccentHaloNear = []; mAccentHaloMid = []; mAccentHaloFar = []; _accentShellsByString = []; mWhiteOutline = mSusOutline = null; mMissOutline = null; mHitSusOutline = null; stringLines = []; stringLineGlows = []; fretWireMats = []; fretTubeGeo?.dispose?.(); fretTubeGeo = null;
mStr = []; mGlow = []; mSus = []; mStrHitOutline = []; mAccentOutline = []; mAccentCore = []; mAccentHaloNear = []; mAccentHaloMid = []; mAccentHaloFar = []; _accentShellsByString = []; mWhiteOutline = mSusOutline = null; mMissOutline = null; mHitSusOutline = null; stringLines = []; stringLineGlows = []; _boardPlaneMat = null; fretWireMats = []; fretTubeGeo?.dispose?.(); fretTubeGeo = null;
for (const m of _inlayMats) m?.dispose?.(); _inlayMats = []; _inlayLabels = [];
// mTapChevron: dispose explicitly — if no tap marker ever
// spawned a pooled mesh, the scene.traverse() pass above never

View File

@ -14,6 +14,15 @@
<button type="button" onclick="window.hwcSaveTheme?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300">Save as…</button>
<button type="button" onclick="window.hwcDeleteTheme?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-400">Delete</button>
</div>
<!-- One-click presets: apply a whole named palette to every string at once.
Buttons are generated from window.feedBack.highwayColors.presets so the
list stays in sync with core (app.js HWC_PRESETS). Each calls
applyPreset(id), which persists + applies to both highways and refreshes
the pickers below. Older core without the presets API leaves this empty. -->
<div class="mb-3">
<span class="text-xs text-gray-500 mb-1 block">Quick presets</span>
<div id="hwc-presets" class="flex flex-wrap gap-2"></div>
</div>
<div id="hwc-pickers" class="mb-3" style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;"></div>
<div class="flex items-center gap-2 mb-2">
<button type="button" onclick="window.hwcReset?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300">Reset to defaults</button>
@ -36,6 +45,33 @@
if (typeof window.hwcInitSettingsUI === 'function') {
try { window.hwcInitSettingsUI(); } catch (e) { console.warn('[3D-Hwy] hwcInitSettingsUI failed', e); }
}
// Render the one-click preset buttons from core's preset list.
try {
const api = window.feedBack && window.feedBack.highwayColors;
const host = document.getElementById('hwc-presets');
if (api && Array.isArray(api.presets) && host) {
host.innerHTML = '';
// Slot order low → high for the preview swatch (bass-side first).
const order = ['lowE', 'A', 'D', 'G', 'B', 'highE'];
for (const p of api.presets) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'flex items-center gap-2 bg-dark-700 border border-gray-800 rounded-lg px-2 py-1 text-xs text-gray-300';
const sw = document.createElement('span');
const stops = order.map((k) => p.colors[k]).filter(Boolean).join(',');
sw.style.cssText = 'width:2.5rem;height:0.85rem;border-radius:3px;border:1px solid #0006;'
+ 'background:linear-gradient(90deg,' + stops + ');';
btn.appendChild(sw);
const txt = document.createElement('span');
txt.textContent = p.label;
btn.appendChild(txt);
btn.addEventListener('click', function () {
try { api.applyPreset(p.id); } catch (e) { console.warn('[3D-Hwy] applyPreset failed', e); }
});
host.appendChild(btn);
}
}
} catch (e) { console.warn('[3D-Hwy] preset render failed', e); }
})();
</script>
</div>
@ -93,6 +129,36 @@
</select>
</div>
<!-- Scene colors — TWO independent axes sharing one palette family:
Background (clear + fog) and Highway (board surface + lit lane). Same
id-set in both, so any background can mix with any highway; picking the
same in both gives the original "matched" look. "Default" is the
original look on both. -->
<div class="mt-3">
<label for="h3d-bg-theme" class="text-xs font-medium text-gray-400 mb-1 block">Background</label>
<!-- Options are populated from SCENE_THEMES (the single id+label source)
in the hydration script below, so the two dropdowns can't drift. -->
<select id="h3d-bg-theme"
onchange="window.h3dBgSetBgTheme && window.h3dBgSetBgTheme(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
</select>
<p class="text-[10px] text-gray-500 mt-1">
Tints the background + distance fog. Applies to the 3D highway immediately.
</p>
</div>
<div class="mt-3">
<label for="h3d-hw-theme" class="text-xs font-medium text-gray-400 mb-1 block">Highway (surface + lane)</label>
<!-- Options populated from SCENE_THEMES (see below), same source as the
Background dropdown so the two stay in lockstep. -->
<select id="h3d-hw-theme"
onchange="window.h3dBgSetHwTheme && window.h3dBgSetHwTheme(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
</select>
<p class="text-[10px] text-gray-500 mt-1">
Tints the fretboard surface + the lit lane. Applies to the 3D highway immediately.
</p>
</div>
<!-- Intensity -->
<div class="mt-3">
<label for="h3d-bg-intensity" class="text-xs font-medium text-gray-400 mb-1 block">
@ -712,8 +778,27 @@
no-selection / NaN state. -->
<script>
(function () {
const DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
const DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', bgTheme: 'default', hwTheme: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
const VALID_STYLES = new Set(['off', 'particles', 'silhouettes', 'lights', 'geometric', 'image', 'video']);
// Scene color themes — the single id+label source for BOTH the
// Background and Highway dropdowns AND the validator below. The
// <option>s are generated from this list (see hydration), so the two
// dropdowns can't drift from each other or from VALID_BG_THEMES.
// Mirror these ids with screen.js BG_THEMES (the color table).
const SCENE_THEMES = [
{ id: 'default', label: 'Default (blue-black)' },
{ id: 'midnight', label: 'Midnight (deep blue)' },
{ id: 'charcoal', label: 'Charcoal (neutral gray)' },
{ id: 'deeppurple', label: 'Deep purple' },
{ id: 'forest', label: 'Forest (dark green)' },
{ id: 'warmslate', label: 'Warm Slate (espresso)' },
{ id: 'deepfocus', label: 'Deep Focus (near-black)' },
{ id: 'deepsea', label: 'Deep Sea (dark teal)' },
{ id: 'cathode', label: 'Cathode (amber CRT)' },
{ id: 'cathodegreen', label: 'Cathode Green (green CRT)' },
{ id: 'hearth', label: 'Hearth (warm red)' },
];
const VALID_BG_THEMES = new Set(SCENE_THEMES.map((t) => t.id));
const VALID_CAMERA_MODES = new Set(['steady', 'lookahead']);
// Chord diagram is top-only (bl/br removed).
const VALID_CHORD_DIAG_POSITIONS = new Set(['tl', 'tr']);
@ -771,6 +856,9 @@
const VIDEO_UPLOAD_URL = '/api/plugins/highway_3d/files';
function coerceStyle(v) { return VALID_STYLES.has(v) ? v : DEFAULTS.style; }
function coerceBgTheme(v) { return VALID_BG_THEMES.has(v) ? v : DEFAULTS.bgTheme; }
// Highway axis shares the same valid id-set as the background axis.
function coerceHwTheme(v) { return VALID_BG_THEMES.has(v) ? v : DEFAULTS.hwTheme; }
function coerceCameraMode(v) {
if (v === 'classic') v = 'steady';
return VALID_CAMERA_MODES.has(v) ? v : DEFAULTS.cameraMode;
@ -812,7 +900,7 @@
return fallback;
}
let storedStyle = null, storedI = null, storedR = null, storedFretOnNote = null, storedFretNumberGhostScope = null;
let storedStyle = null, storedBgTheme = null, storedHwTheme = null, storedI = null, storedR = null, storedFretOnNote = null, storedFretNumberGhostScope = null;
let storedCameraSmoothing = null;
let storedCameraMode = null;
let storedCustomImageDataUrl = null, storedCustomImageName = null;
@ -848,6 +936,8 @@
let storedSlideArrowChainPreviewVisible = null;
try {
storedStyle = localStorage.getItem('h3d_bg_style');
storedBgTheme = localStorage.getItem('h3d_bg_bgTheme');
storedHwTheme = localStorage.getItem('h3d_bg_hwTheme');
storedI = localStorage.getItem('h3d_bg_intensity');
storedR = localStorage.getItem('h3d_bg_reactive');
storedFretOnNote = localStorage.getItem('h3d_bg_showFretOnNote');
@ -889,6 +979,15 @@
} catch (_) { /* storage blocked */ }
const style = coerceStyle(storedStyle);
const bgTheme = coerceBgTheme(storedBgTheme);
// Highway axis. BACKWARD-COMPAT: when hwTheme was never written
// (pre-split installs, or the panel opened before the highway has
// mounted and run its one-time backfill), show the background pick —
// exactly the value screen.js will persist on first load — so the
// dropdown matches what's rendered. Once hwTheme is stored (which the
// renderer's backfill or any explicit pick does) it reads
// independently and the two axes no longer track each other.
const hwTheme = (storedHwTheme == null) ? bgTheme : coerceHwTheme(storedHwTheme);
const intensity = coerceIntensity(storedI);
const reactive = coerceBool(storedR, DEFAULTS.reactive);
const showFretOnNote = coerceBool(storedFretOnNote, DEFAULTS.showFretOnNote);
@ -959,6 +1058,20 @@
const customVideoName = (typeof storedCustomVideoName === 'string') ? storedCustomVideoName : DEFAULTS.customVideoName;
const sel = document.getElementById('h3d-bg-style');
const bgThemeSel = document.getElementById('h3d-bg-theme');
const hwThemeSel = document.getElementById('h3d-hw-theme');
// Populate both scene-theme dropdowns from the single SCENE_THEMES
// source so they can't drift. (Values are selected further below.)
for (const selEl of [bgThemeSel, hwThemeSel]) {
if (!selEl) continue;
selEl.innerHTML = '';
for (const t of SCENE_THEMES) {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.label;
selEl.appendChild(opt);
}
}
const sli = document.getElementById('h3d-bg-intensity');
const lbl = document.getElementById('h3d-bg-intensity-label');
const rea = document.getElementById('h3d-bg-reactive');
@ -1200,6 +1313,8 @@
sel.value = style;
}
}
if (bgThemeSel) bgThemeSel.value = bgTheme;
if (hwThemeSel) hwThemeSel.value = hwTheme;
if (fileInput) {
fileInput.addEventListener('change', function () {

View File

@ -2795,6 +2795,88 @@ const HWC_SLOT_KEYS = HWC_SLOTS.map((s) => s.key);
// is queryable.
const HWC_DEFAULT_FALLBACK = { lowE: '#cc0000', A: '#cca800', D: '#0066cc', G: '#cc6600', B: '#00cc66', highE: '#9900cc', low7: '#cc00aa', low8: '#00cccc' };
// One-click string-color presets. Each is a full named-slot → hex map (every
// slot, so 7/8-string charts get a sensible color too) keyed by the same slot
// names as HWC_SLOTS, so "Low E" always lands on the lowE slot regardless of
// string count. Hues are chosen for the dark scene (~#080810): each color is
// bright enough to read on black and distinct from its neighbours.
// - warmcool: an ordered low→high spectrum (warm reds at the bass end →
// cool blues/violet at the treble end) so pitch reads as color temperature.
// - vivid: punchier, higher-saturation take on the classic mapping for a
// stage-bright look.
// - colorblind: the OkabeIto accessible qualitative palette (vermillion,
// orange, yellow, bluish-green, sky-blue, blue, reddish-purple), the most
// distinguishable option for deuteranopia/protanopia.
// - neon: electric, max-saturation hues whose LIGHTNESS deliberately zig-zags
// between neighbours (bright→bright→brightest→dark blue→bright green→dark
// violet) so adjacent strings separate harder than vivid — a stage/stream
// "pop" set, not a vivid duplicate.
// - accessible: a CVD-safe set ORDERED by ascending lightness low→high (deep
// blue → vermilion → azure → orange → yellow → cream). Unlike the unordered
// OkabeIto 'colorblind' set, the value ramp teaches pitch low→high AND
// survives grayscale/colorblindness; no red/green pair carries meaning.
// - ember: a warm, lower-intensity family for long sessions, luminance-stepped
// from rust/ember at the bass through warm gold to cream at the treble. The
// bass embers stay light enough to clear the near-black scene.
// - tapedeck: a vintage-print, slightly desaturated ochre-tinted family
// (rust-red → mustard → avocado → teal → faded denim → dusty plum). Muted
// hues collapse, so neighbour LIGHTNESS deliberately zig-zags to keep the
// dusty mid-strings (avocado/teal/denim) distinct on the dark board.
// - crtgreen / crtamber: monochrome CRT-phosphor families (green / amber)
// stepped by STRICT ASCENDING LIGHTNESS low→high. Mono sets collapse on hue,
// so lightness alone carries the ordering. Verified to stay legible even on
// the matching phosphor scene board (green-on-green / amber-on-amber).
// - pitchramp: a smooth low→high hue sweep (violet → blue → teal → green →
// yellow → warm-white) with rising lightness — memorable + teaches order.
// - sunrise: a soft dawn gradient (plum → rose → coral → amber → gold → cream),
// warm and lower-intensity, lightness-stepped low→high.
const HWC_PRESETS = [
{
id: 'warmcool', label: 'Warm → Cool',
colors: { lowE: '#ff3b30', A: '#ff7a18', D: '#ffc400', G: '#36c46a', B: '#2196f3', highE: '#9b5cff', low7: '#ff2d78', low8: '#00c2c7' },
},
{
id: 'vivid', label: 'Vivid',
colors: { lowE: '#ff2222', A: '#ffd000', D: '#1e8bff', G: '#ff7a00', B: '#16d65a', highE: '#b24bff', low7: '#ff3cc0', low8: '#15d8d8' },
},
{
id: 'colorblind', label: 'Colorblind-friendly',
colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' },
},
{
id: 'neon', label: 'Neon',
colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' },
},
{
id: 'accessible', label: 'Accessible (ordered)',
colors: { lowE: '#2453c0', A: '#c44a00', D: '#3f93cf', G: '#ec9a1e', B: '#f2d43c', highE: '#f5eecb', low7: '#173f96', low8: '#0f2c6b' },
},
{
id: 'ember', label: 'Warm Ember',
colors: { lowE: '#c0392b', A: '#e0552a', D: '#ef7d2e', G: '#f6a13a', B: '#f4c95d', highE: '#f7e3a8', low7: '#9e2f23', low8: '#7d2418' },
},
{
id: 'tapedeck', label: 'Tape Deck',
colors: { lowE: '#b04632', A: '#d8ad42', D: '#5f7a34', G: '#54b3a6', B: '#5e83ad', highE: '#b98abb', low7: '#8f3526', low8: '#6f2a1e' },
},
{
id: 'crtgreen', label: 'CRT Green',
colors: { lowE: '#0a5a23', A: '#108a30', D: '#1fb53f', G: '#3ad94f', B: '#74f06a', highE: '#c7ffb0', low7: '#08491c', low8: '#063514' },
},
{
id: 'crtamber', label: 'CRT Amber',
colors: { lowE: '#7a3a02', A: '#a85f06', D: '#cf8410', G: '#e8a82a', B: '#f4cf5e', highE: '#ffeeb8', low7: '#5f2d01', low8: '#471f00' },
},
{
id: 'pitchramp', label: 'Pitch Ramp',
colors: { lowE: '#7a2390', A: '#2f5ad8', D: '#1f9bc4', G: '#2fb84a', B: '#cfd22a', highE: '#f3e0c0', low7: '#5e1a78', low8: '#440f5e' },
},
{
id: 'sunrise', label: 'Sunrise',
colors: { lowE: '#8a3a6e', A: '#bf4a5e', D: '#e0664f', G: '#f29a55', B: '#f7c873', highE: '#fce8b8', low7: '#6e2c5c', low8: '#54214a' },
},
];
// Translation table: chart string index → named slot, for a given string count
// and bass/guitar family. Mirrors the 3D highway's _baseOpenStringMidis: bass
// shares the low strings (E A D G), 7/8-string guitars prepend lower strings,
@ -2891,6 +2973,18 @@ function applyHighwayStringColors(slotMap, opts) {
reapplyHighwayStringColors();
}
// Apply a named one-click string-color preset (see HWC_PRESETS) to all strings.
// Persists + applies to both highways (via applyHighwayStringColors), then —
// when the Settings UI is mounted — refreshes the per-string pickers so their
// swatches show the preset's colors. Unknown id is a no-op.
function applyHighwayStringPreset(id) {
const preset = HWC_PRESETS.find((p) => p.id === id);
if (!preset) return false;
applyHighwayStringColors(preset.colors);
try { if (typeof hwcRenderPickers === 'function') hwcRenderPickers(); } catch (_) {}
return true;
}
// Apply colors by NAMED string to both highways for the current arrangement.
// Colors follow the string name regardless of count: Low E stays Low E's color
// on a 6-, 7-, or 8-string. Defaults map identically to the stock order for
@ -3062,6 +3156,10 @@ function _hwcInstallFacade() {
// Set colors programmatically (persists + applies to both highways).
// Pass a named slot map, or null/{} to revert to defaults.
apply(slotMap) { return applyHighwayStringColors(slotMap); },
// One-click presets: [{ id, label, colors }] (full named-slot maps).
presets: HWC_PRESETS.map((p) => ({ id: p.id, label: p.label, colors: { ...p.colors } })),
// Apply a preset by id (persists + applies to both highways).
applyPreset(id) { return applyHighwayStringPreset(id); },
// Share-code interop (the "SLOPHWY2." copy/paste format).
encodeShare(name, slotMap) { return encodeHighwayColorShare(name, slotMap); },
decodeShare(code) { return decodeHighwayColorShare(code); },