// 3D Highway visualization plugin — Three.js note highway. // Visual layer from joel's prototype (vibrant palette, glowing strings, // fret heat, dynamic lane, chord frame-boxes, per-note connector labels, // board projection, outline+core note meshes) adapted into the // feedBackViz setRenderer contract (feedBack#36) so it works in the // main player and per-panel in splitscreen without any architectural // changes. import { geoFretX, dZ, slideTrailEnd, camBaseDistU, camLowFretPullbackU, computeBPM, _makeGaussTex, RENDER_ORDER_LAYER_STACK, RENDER_ORDER_LAYER_INDEX, RENDER_ORDER_AT_Z_ZERO, RENDER_ORDER_FAR_CLAMP, renderOrderForLayerAtZ, _noteKey, lowerBoundT, hwyFirstRelevantFrettedTime, geoFretMid } from './src/geometry.js'; // h3d-carve-1b import { loadThree, T } from './src/three-loader.js'; // h3d-carve-2 import { _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt, resolveStringCount as _resolveStringCountBase, _NOTE_NAMES_SHARP, _BASE_OPEN_MIDI_BASS4, _BASE_OPEN_MIDI_BASS5, _BASE_OPEN_MIDI_GUITAR6, _BASE_OPEN_MIDI_GUITAR7, _BASE_OPEN_MIDI_GUITAR8, _baseOpenStringMidis, _midiToPitchLabel, _openStringPitchLabelsForTuning as _openStringPitchLabelsForTuningBase, _ssActive, _ssIsCanvasFocused } from './src/utils.js'; // h3d-carve-3 import { _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx } from './src/bc-panel.js'; // h3d-carve-4 import { createBgControl } from './src/bg-control.js'; // h3d-carve-5 import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6 import { createOverlay } from './src/overlay.js'; // h3d-carve-7 (function () { 'use strict'; /* ====================================================================== * Constants * ====================================================================== */ // Three.js is vendored under static/vendor/three/ in core (pinned r170 — // see static/vendor/three/VERSION). The bundled plugin loads from the // same origin to avoid the first-launch CDN round-trip and to pin the // version against breakages from upstream Three.js drift. // THREE_URL / THREE_CDN — h3d-carve-4: dead code (three-loader.js owns its own URLs). Tombstoned. // ── B-section: Butterchurn control panel — h3d-carve-4 ────────────── // Moved to src/bc-panel.js. Imports: _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx. // window.h3dBcApplySettings assigned at bc-panel.js module scope (R5). // ──────────────────────────────────────────────────────────────────── // TOMBSTONE: BC_VENDOR, BC_FRAME, BC_WORKLET, _bcMeters, BC_BTN — h3d-carve-4: moved to src/bc-panel.js. // TOMBSTONE: _bcLoading — h3d-carve-4: moved to src/bc-panel.js. // TOMBSTONE: _bcLoadLib() — h3d-carve-4: moved to src/bc-panel.js. // TOMBSTONE: _bcResolve, _bcPresets, _bcIsDesktop, _bcFfIdx, _bcReleaseCanvasGL — h3d-carve-4: moved to src/bc-panel.js. // TOMBSTONE: _bcGuitarFeed — h3d-carve-4: moved to src/bc-panel.js. // TOMBSTONE: BC_LS, BC_DEFAULTS, _bcSettings, _bcLoadSettings, _bcSaveSettings — h3d-carve-4: moved to src/bc-panel.js. // TOMBSTONE: _bcControllers, _bcApplyAll — h3d-carve-4: moved to src/bc-panel.js. // TOMBSTONE: window.h3dBcApplySettings — h3d-carve-4: assigned at src/bc-panel.js module scope (R5; 1 beyond-subst). // TOMBSTONE: BC_DEFAULT_FAVORITES, BC_DEFAULT_BANS, _bcFavorites, _bcBanned, _bcListsLoaded — h3d-carve-4: moved to src/bc-panel.js. // TOMBSTONE: _bcLoadLists, _bcSaveLists, _bcRestoreDefaults — h3d-carve-4: moved to src/bc-panel.js. // TOMBSTONE: _bcPrimary — h3d-carve-4: moved to src/bc-panel.js. // TOMBSTONE: _bcPane, _bcListEl, _bcFilterEl, _bcPaneOpen, _bcCollapsed — h3d-carve-4: moved to src/bc-panel.js. // TOMBSTONE: _bcStatusMark, _bcSetHold, _bcLayout, _bcSetPane — h3d-carve-4: moved to src/bc-panel.js. // TOMBSTONE: _bcRenderList, _bcUpdatePanelPreset — h3d-carve-4: moved to src/bc-panel.js. // TOMBSTONE: _bcPanel, _bcPanelKeyBound — h3d-carve-4: moved to src/bc-panel.js. // TOMBSTONE: _bcEnsurePanel, _bcCreateController — h3d-carve-4: moved to src/bc-panel.js (exported as _bcIsDesktop, _bcCreateController). // Selectable per-string color palettes (issue #10). Each palette has // 8 entries to match MAX_RENDER_STRINGS so 6/7/8-string arrangements // all index safely. Default is the canonical chart-format classic // mapping (low E=red, A=yellow, D=blue, G=orange, B=green, // high E=purple); Neon pushes saturation harder; Pastel desaturates // for long-session comfort; Colorblind (high contrast) is derived from // the chart format's built-in colorblind-mode palette, but this preset // intentionally keeps some entries tuned for feedBack rather than // reproducing every original hex value verbatim. The chart-format base // values came from community reverse-engineering of the original chart // files; do not treat the tuned values below as the exact original // palette. // In feedBack's index convention s=0 is the low E (thickest) and // s=5 is the high E (thinnest), matching the chart format's native string // indexing. Per-index ordering is preserved across all palettes so // switching between them never reassigns a string to a different // colour family. Indices 6/7 are supplementary slots used for // 7/8-string arrangements. // NOTE: settings.html mirrors these arrays in its hydration script // for the palette-preview swatches — keep them in sync. const PALETTES = { default: [ 0xe61f26, 0xecd234, 0x1096e6, 0xf18313, 0x3fc413, 0xb518d9, 0xff6bd5, 0x6bffe6, ], neon: [ 0xff0030, 0xffe800, 0x0080ff, 0xff8030, 0x40ff50, 0xb050ff, 0xff40d0, 0x40ffd0, ], pastel: [ 0xe89aa0, 0xefdf90, 0x9adfee, 0xefb898, 0xa6e0a8, 0xc4a6e0, 0xe0a6c8, 0xa6e0d8, ], colorblind_hc: [ 0xa42424, 0xa3f300, 0x19abfc, 0xda7e41, 0x30d0a0, 0x7648a7, 0xff6bd5, 0x6bffe6, ], }; const PALETTE_IDS = Object.keys(PALETTES); // User-defined per-string colors (core "Highway String Colors" theming). // Persisted as a JSON hex array under the bg setting key 'customColors'; // when the active palette id is 'custom' the renderer resolves this into // numeric hex, falling back to the default palette per missing index. // Mutated in place by _resolveCustomPalette so the reference stays stable. let _customPalette = PALETTES.default.slice(); // _h3dHexToInt, _clampByteI, _darkenInt, _lightenInt — moved to src/utils.js (h3d-carve-3). // Default per-string gem gradient stops [topHighlight, bottomShade] — // sampled from the original colour PNGs. Used verbatim for the built-in // palettes (and for unchanged slots of a custom palette) so the stock look // is byte-for-byte preserved; custom slots derive their stops from the // chosen base color via _lightenInt/_darkenInt. Strings 6/7 have no entry // and fall back to flat gNote. const DEFAULT_GEM_GRADIENTS = [ [0xec0816, 0xbd0400], // 0 red [0xefd20b, 0xceaa00], // 1 yellow [0x0b93e9, 0x0e69b2], // 2 blue [0xf77b0b, 0xdb5808], // 3 orange [0x37c40b, 0x139305], // 4 green [0xaf10db, 0x8907af], // 5 violet ]; // Default palette at module scope so out-of-IIFE consumers (e.g. the // out-of-range warning's reference to "palette size") still have a // canonical length to compare against. const S_COL = PALETTES.default; const SCALE = 2.25; const K = SCALE / 300; // Horizontal stretch factor for fret X positions. Increasing this widens // the lane (frets, board plane, strings, notes, lane strip) without // affecting K-based vertical dimensions (string gap, note height, camera). const FRET_SCALE = SCALE * 1.1; const NFRETS = 24; const NSTR = 6; /** * Pure 12-semitone spacing compresses toward the bridge; multiply each * segment **above** this fret by the factor so high positions stay * slightly more playable/readable in 3D. */ const FRET_SPACING_STRETCH_ABOVE12 = 1.1; const FRET_SPACING_ANCHOR_F = 12; // Per-string materials and projection meshes are built via S_COL.map(), // so the renderer can only address strings 0..S_COL.length-1. Using a // higher count would index undefined into mGlow/mStr/mSus/projMeshArr. // Extend S_COL above to support more strings. const MAX_RENDER_STRINGS = S_COL.length; // resolveStringCount — moved to src/utils.js (h3d-carve-3). const resolveStringCount = bundle => _resolveStringCountBase(bundle, MAX_RENDER_STRINGS); // h3d-carve-3: delegator (1 beyond-subst; passes authoritative S_COL.length ceiling) // _NOTE_NAMES_SHARP, _BASE_OPEN_MIDI_BASS4/5, _BASE_OPEN_MIDI_GUITAR6/7/8 — moved to src/utils.js (h3d-carve-3). // _baseOpenStringMidis, _midiToPitchLabel, _openStringPitchLabelsForTuning — moved to src/utils.js (h3d-carve-3). const _openStringPitchLabelsForTuning = (bundle, songInfo, n) => _openStringPitchLabelsForTuningBase(bundle, songInfo, n, MAX_RENDER_STRINGS); // h3d-carve-3: delegator (1 beyond-subst) const STR_THICK = 0.25 * K; // Fret wires — bowed metal tubes (backported from highway_babylon's // "hit-zone fret bars"). All frets share one bowed TubeGeometry whose // middle (the middle strings) pushes away from the camera so the row of // frets reads as wrapping a cylindrical neck — chart-format depth cue. // Negative Z = away from camera (into the highway). All tunable. const FRET_BOW_DZ = -1.2 * K; // middle-of-span Z offset const FRET_TUBE_RADIUS = STR_THICK * 0.75; // slightly thicker than a string const FRET_TUBE_SEG = 12; // tubular segments along the curve // Radial segments (cross-section). 8 rather than 6: at FRET_TUBE_RADIUS the // hexagonal facets of a 6-segment tube are visible along the top highlight. // One shared geometry for all frets, so the extra segments are ~free. const FRET_TUBE_RADIAL = 8; // metalness kept moderate, NOT ~1.0: MeshStandardMaterial is PBR and the // scene has no envMap, so a full-metal fret would reflect black and render // dark (the nut/headstock use metalness 0.02 for the same reason). At ~0.4 // the lit albedo body survives while the directional light still throws a // glossy specular streak across the rounded tube. The dim emissive floor // keeps frets from going muddy far down the (fogged) neck. const FRET_METALNESS = 0.4; // lit steel / brass when gold const FRET_ROUGHNESS = 0.3; const FRET_EMISSIVE = 0x12141a; // cool dim floor, never fully black // Fret-wire tiers. Wires inside the active anchor lane (the frets the player // is actually reading) sit bright; everything outside recedes. Kept far // apart on purpose — a narrow gap reads as noise rather than as a focus cue. const FRET_WIRE_ACTIVE_HEX = 0xD8A636; // gold; numeric twin of FRET_LABEL_GOLD_HEX const FRET_WIRE_ACTIVE_OP = 0.9; const FRET_WIRE_IDLE_HEX = 0x4A4A60; const FRET_WIRE_IDLE_OP = 0.28; // Hit flash: when a scorer (feedBack#254) confirms a note, the two wires // bracketing its fret (f-1 and f) flash bright. Emissive is boosted as well // as albedo — a MeshStandard fret with no envMap barely brightens from // albedo alone, so without the emissive lift the "flash" reads as a shrug. const FRET_WIRE_HIT_HEX = 0xFFFFFF; // blown out to white at full flash const FRET_WIRE_HIT_EMISSIVE = 0xFFE9B0; // hot warm-white glow const FRET_WIRE_HIT_OP = 1.0; // Emissive multiplier at full flash (baseline is 1). Pushing emissive past // 1.0 is what actually makes the wire read as a light source rather than a // brightly-lit object — the color alone saturates and stops there. const FRET_WIRE_HIT_INTENSITY = 4.2; // Seconds for a flash to fall to ~1/e once the provider stops reporting. // The provider already fades its own `alpha` on a struck note; this tail // just keeps the hand-off from popping, and smooths the frame-to-frame // jitter of a held sustain (whose alpha tracks live input level). const FRET_WIRE_HIT_DECAY = 0.32; const S_BASE = 3 * K; const S_GAP = 4 * K; const AHEAD = 3.0; const BEHIND = 0.5; // How long a note/chord-frame stays renderable past the hit line while a // note-state provider (feedBack#254) is attached. The provider's // hit/miss verdict is asynchronous — the engine-side verifier reports it // ~0.35-0.5 s after the line — so the default ~50 ms note linger / // ~0.48 s chord linger lapses before the tint can apply. Drives both // the outer-loop cull (ndVerdictT0) and the smart drawNote cull below. const NOTEDETECT_GEM_VERDICT_WINDOW = 0.75; // chDt threshold past the hit line at which the chord-frame scan // gives up on an arpeggio-style frame whose constituents never come // in. Must be < NOTEDETECT_GEM_VERDICT_WINDOW (the rim's draw life // in detect mode); placing it at 0.55 s leaves ~0.2 s of the visible // window for the latch to fire and skip subsequent scans. const _ND_UNMATCHED_LATCH_AFTER = 0.55; // Sample approach offsets dt in [0, AHEAD] into strips. Lane quads use // z = dZ(dt) + TS*BEHIND = TS*(BEHIND - dt), while notes use z = dZ(n.t-now). // So note hit line (z=0) aligns with dt=BEHIND, not dt=0. Chart time at // lane parameter dt is now + dt - BEHIND (same z as a note at that time). // Each strip’s uses that chart time so the blue lane doesn’t // switch ~BEHIND seconds before the XML . const HWY_LANE_TIME_SLICES = 96; /** Odd columns (1st/3rd/…) darker teal; even columns brighter blue. */ const HWY_LANE_STRIPE_ODD_HEX = 0x103B5C; const HWY_LANE_STRIPE_EVEN_HEX = 0x08283C; /** Lane quad alpha: base + highwayIntensity * scale (readable on dark floor). */ const HWY_LANE_STRIPE_OP_BASE = 1.0; const HWY_LANE_STRIPE_OP_INT = 0; /** Venue mode: slight near-lane contrast boost (visual only). */ const VENUE_LANE_OP_BOOST = 1.1; /** Venue mode: gem emissive pop (~12%, visual only). */ const VENUE_GEM_EMISSIVE_MUL = 1.12; /** Venue steady-state haze coefficient — kept low for raster bg plate. */ const VENUE_HAZE_STEADY = 0.008; /** Venue backdrop pushed slightly farther for parallax depth. */ const VENUE_BACKDROP_DISTANCE_MUL = 1.06; /** Note travel speed. */ const TS = 230 * K; // RENDER_ORDER_LAYER_STACK, RENDER_ORDER_LAYER_INDEX, RENDER_ORDER_AT_Z_ZERO, // RENDER_ORDER_FAR_CLAMP, renderOrderForLayerAtZ — moved to src/geometry.js (h3d-carve-1b). /** Match `nextNoteByString` onset to this note (float + chart rounding; avoids ghost / glow flicker). */ const NEXT_ON_STRING_T_EPS = 0.06; /** Fixed pre-impact ramp window for lead-note board ghosts (Primary + Upcoming slots). */ const GHOST_UPCOMING_WIN = 0.6; /** Ghost starts at this fraction of full size/brightness and grows to 1.0 as it approaches. */ const PROJ_GROW_MIN = 0.45; /** * 3D highway post-strum tail — chord frame + ghost fret digit share the same * hold and fade so timing stays consistent. */ const CHORD_HWY_LINGER_S = 0.75; /** Linear fade at end of `CHORD_HWY_LINGER_S` (applies to chord UI and board ghost numbers). */ const CHORD_HWY_FADE_S = 0.32; const GHOST_HOLD_AFTER_ONSET = CHORD_HWY_LINGER_S; const GHOST_FRET_LBL_FADE_S = CHORD_HWY_FADE_S; /** Purple lane rails: extend past last matched chord/note so Z reaches frame end. */ const ARP_HWY_RAIL_END_TAIL_S = 0.38; /** Keep 0 — chord/note-based ``shapeLo`` already aligns to the visible frame. */ const ARP_HWY_RAIL_START_LEAD_S = 0; /** Drives emissive (`mGlow` / accent fill) for notes with `.ac`; matches drawNote `linger` cutoff (0.05). */ const ACCENT_NOTE_STR_GLOW = 3.55; const ACCENT_NOTE_LINGER_EPS = 0.05; /** Extra emissive layered on accent-only body material (`mAccentCore`), after `strGlow * glowMul`. */ const ACCENT_NOTE_FILL_BOOST = 2.55; /** Accent rim draws brighter than normal string-coloured outlines (`mStrHitOutline`). */ const ACCENT_RIM_BASE_EMISSIVE = 3.45; /** Outline / core scale bump vs normal gems (accent reads slightly larger). */ const ACCENT_RIM_XY_SCALE_MUL = 1.09; const ACCENT_RIM_Z_SCALE_MUL = 1.06; // Soft neon-style outer bloom (AdditiveBlending) — layered shells behind outline/core. const ACCENT_HALO_OP_NEAR = 0.68; const ACCENT_HALO_OP_MID = 0.42; const ACCENT_HALO_OP_FAR = 0.24; const ACCENT_HALO_XY_INNER = 1.36; const ACCENT_HALO_XY_MID = 1.82; const ACCENT_HALO_XY_OUTER = 2.32; const ACCENT_HALO_Z_INNER = 1.05; const ACCENT_HALO_Z_MID = 1.12; const ACCENT_HALO_Z_OUTER = 1.22; /** * Post-hit tail fade shared by ghost fret digits and 3D chord UI: full * opacity until (holdS − fadeS) after onset, then linear fade over fadeS; * canceled when `nextSoon` — for ghosts: next note within `fadeS` of `now`; * for chord frame: next chord onset lies in chart time [hold − fade, hold] * after the current chord (so fade does not run into a same-window handoff). * @param {number} dt chart time minus now (negative once struck) * @param {number} fadeS linear fade duration (default: GHOST_FRET_LBL_FADE_S) */ function hwyPostHitTailFadeMul(dt, holdS, nextSoon, fadeS = GHOST_FRET_LBL_FADE_S) { if (nextSoon || dt >= 0) return 1; const gone = -dt; if (gone >= holdS) return 0; const fS = Math.min(Math.max(fadeS, 1e-6), holdS); const fadeStartT = Math.max(0, holdS - fS); if (gone < fadeStartT) return 1; return Math.max(0, 1 - (gone - fadeStartT) / fS); } // Shorter, flatter notes (joel style) const NW = 5 * K, NH = 3 * K, ND = 0.25 * K; // Sustain-trail X offset for fretted notes. Module-scoped + frozen // so the hot path's `offsets.length` loop sees a stable singleton // reference. The standalone-open-string path builds a fresh pair // each call because its offset magnitude depends on the per-note // `openWScale` (set in drawNote at line 7367 from the open-string // body's lane width), so a module-scoped constant can't capture // it; the allocation is the same one the prior code did via // `const baseOff = NW * 3 * openWScale` plus the inline `[-, +]` // literal in the chord-member branch — just consolidated. const SINGLE_SUS_OFFSETS = Object.freeze([0]); const BEND_HALFSTEP_WORLD_Y = S_GAP * 0.8; const VIBRATO_HALF_WAVE_S = 0.08; // Bend ribbon envelope: fraction of the sustain spent ramping up to // the bent pitch, and releasing back down (rest is the held plateau). const BEND_ENV_RISE_FRAC = 0.35; const BEND_ENV_RELEASE_FRAC = 0.30; const TREMOLO_BUMP_S = 0.06; /** Longitudinal samples for sustain-technique prism (indexed BufferGeometry). */ const SLIDE_RIBBON_SAMPLES = 96; /** Pre-built index buffer: `SLIDE_RIBBON_SAMPLES` × 8 tris × 3 verts. */ const SLIDE_RIBBON_INDICES = (() => { const S = SLIDE_RIBBON_SAMPLES; const idx = new Uint16Array(S * 24); let o = 0; for (let k = 0; k < S; k++) { const b = k * 4; const nx = (k + 1) * 4; // Bottom (-Y outward) idx[o++] = b; idx[o++] = b + 1; idx[o++] = nx + 1; idx[o++] = b; idx[o++] = nx + 1; idx[o++] = nx; // Top (+Y outward) idx[o++] = b + 3; idx[o++] = nx + 3; idx[o++] = nx + 2; idx[o++] = b + 3; idx[o++] = nx + 2; idx[o++] = b + 2; // Left (-X outward) idx[o++] = b; idx[o++] = nx; idx[o++] = nx + 3; idx[o++] = b; idx[o++] = nx + 3; idx[o++] = b + 3; // Right (+X outward) idx[o++] = b + 1; idx[o++] = b + 2; idx[o++] = nx + 2; idx[o++] = b + 1; idx[o++] = nx + 2; idx[o++] = nx + 1; } return idx; })(); // Three r170's setIndex() only wraps plain Arrays into Uint16BufferAttribute; // typed-array input gets assigned raw onto .index, which trips WebGL's // byteLength check. Convert once at module init so each pooled geometry // reuses the same Array reference instead of allocating per mesh. const SLIDE_RIBBON_INDICES_ARR = Array.from(SLIDE_RIBBON_INDICES); const N_RAD = 1.5 * K; const SW = 2 * K, SH = 1.5 * K; const CAM_H_BASE = 190 * K; const CAM_DIST_BASE = 240 * K; const REF_ASPECT = 16 / 9; const FOCUS_D = 600 * K; const CAM_LERP_BASE = 0.02; // Base vertical field of view (deg). THREE's PerspectiveCamera fov is the // VERTICAL angle; horizontal follows from the aspect ratio. At a normal // ~16:9 pane this gives a ~102° horizontal cone. On an ultra-wide pane // (top/bottom 2-player split → full-width/half-height → ~32:9) that // horizontal cone balloons past 130° and squeezes the fixed-width neck into // a central sliver. The optional horizontal-FOV-hold path below counters // that by lowering the effective vertical fov as the pane widens. const BASE_VFOV = 70; // Horizontal-FOV-hold ("Hor+") defaults. At/under HORPLUS_START_ASPECT the // effective vertical fov equals BASE_VFOV (exact no-op); past it the // vertical fov drops to keep the horizontal cone ~constant so the neck // fills a wide pane. HORPLUS_MIN_VFOV floors the result on pathological // aspects. Engaged only via the window.__h3dAspectTune bridge (default off). const HORPLUS_START_ASPECT = 16 / 9; const HORPLUS_MIN_VFOV = 28; // Zoom-dependent framing — height (h*) and depth (dist*) multipliers // applied to cam.position. Interpolated by `dist`: // NEAR = tight view (nut position, span<=4 -> dist~=93*K): lower/closer. // FAR = wide view (midpoint fret 1<->20 -> dist~=141*K): higher/pulled back // to fit the whole neck. // Outside this range the values clamp at the endpoints. const CAM_FRAME_DIST_NEAR = 93 * K; const CAM_FRAME_DIST_FAR = 141 * K; const CAM_FRAME_H_NEAR = 0.75; const CAM_FRAME_H_FAR = 1.00; const CAM_FRAME_D_NEAR = 0.575; const CAM_FRAME_D_FAR = 0.60; // Fret-row fit guard. The heat-coloured fret-number row is a band drawn // BELOW the board (at sY(lowest) - S_GAP*1.4). The lower-third framing // anchors the board CENTRE, not that row, so a tight zoom on a centred span // (worst mid-neck — fine pushed to either end of the neck) drops the row off // the bottom edge. Tilt can't add vertical room there (it would only trade a // bottom clip for a top clip), so camUpdate dollies the camera back just // enough to bring the row back into frame — auto-sized, capped, hysteretic. const FRET_ROW_FIT_NDC_MIN = -0.86; // keep the row anchor at/above this NDC y (>-1 = on screen) const FRET_ROW_FIT_DEADBAND = 0.06; // headroom past the min before the dolly relaxes (anti-hunt) const FRET_ROW_FIT_BOOST_MAX = 1.6; // cap the pull-back so the zoom can't pop (never dolly back > +60%) // Camera-X targeting (issue #34). The visible AHEAD = 4.0 s window is // far too coarse for picking where the camera should sit — a single // 17th-fret bend 2.5 s away yanks tgtX several frets even though the // immediate playing area hasn't moved. These constants are bounds for // a smoothing dial (0 = twitchy, 1 = calm); the runtime lerps between // the pair using the user's `cameraSmoothing` setting. const CAM_TGT_BEHIND = 0.2; // s behind hit line for X targeting const CAM_TGT_AHEAD_T = 2.0; // s — twitchy: longer lookahead (more reactive) const CAM_TGT_AHEAD_C = 0.7; // s — calm: shorter lookahead (ignore distant outliers) const CAM_TGT_TAU_T = 0.35; // s — twitchy: short recency time-constant const CAM_TGT_TAU_C = 0.9; // s — calm: longer time-constant (averages more) const CAM_TGT_HYST_T = 0.25; // frets — twitchy: tiny dead zone const CAM_TGT_HYST_C = 5.0; // frets — calm: ~5-fret dead zone, wide // enough to swallow chord-to-chord // alternations across a 6-fret span // (e.g. Am ↔ D in first position). // Zoom (tgtDist) damping. Controlled by its own `zoomSmoothing` setting // so X-pan and zoom-pull-back can be tuned independently. New users // (and existing users who never wrote zoomSmoothing) inherit // cameraSmoothing's value on first read, so default behaviour is // unchanged from when zoom + X shared a single slider. const CAM_DIST_HYST_T = 0.5; // fret-span — twitchy: minimal dead zone const CAM_DIST_HYST_C = 5.0; // fret-span — calm: 5-fret span change required // Vertical-tilt damping. Drives the tgtLookY self-correction loop in // camUpdate(): how far the fretboard's NDC Y can drift from // DESIRED_NDC_Y before we nudge the camera, and how strongly each // nudge corrects. Twitchy = narrow band + strong correction (re-frame // aggressively); calm = wide band + weak correction (let small drift // ride). Driven by `tiltSmoothing`, mirrors cameraSmoothing on first // read like zoomSmoothing does. // Bounds chosen so the midpoint (tiltSmoothing=0.5) reproduces the // pre-PR hardcoded behaviour (band=0.15, str=0.5). Without that, a // fresh install would silently change the vertical-tilt feel even // though the PR description promises "default behaviour unchanged." const CAM_TILT_BAND_T = 0.05; // NDC — twitchy: narrow tolerance const CAM_TILT_BAND_C = 0.25; // NDC — calm: wide tolerance, fewer corrections const CAM_TILT_STR_T = 0.8; // multiplier — twitchy: strong nudge per correction const CAM_TILT_STR_C = 0.2; // multiplier — calm: weak nudge per correction // Lock-low zoom range. The cameraLockZoom slider (0..1) blends between // these two multipliers and scales the locked tgtDist. Defaults pick // 1.0× at slider=0.5 so the previous locked view is the midpoint. const CAM_LOCK_ZOOM_MIN = 0.55; // slider=0 — closest, biggest fretboard const CAM_LOCK_ZOOM_MAX = 1.45; // slider=1 — furthest const CAM_LOCK_CENTER_FRET = 6; // default camera X center (first-position midpoint) // ── 3D preview: lookahead fret bounds + smoothed focal X / span ───────── /** User-selectable via `cameraMode`. Legacy `classic` in storage maps to `steady`. */ const CAMERA_MODE_IDS = ['steady', 'lookahead']; const CAM_LOOKAHEAD_SEC = 3.0; // fallback when no beats/measures are available const CAM_LOOKAHEAD_MEASURES = 9; // lookahead window = N measures ahead const CAM_FOCUS_BLEND_RATE = 0.7; const CAM_FRET_EDGE_BLEND = 0.1; const DEFAULT_LOOKAHEAD_FRET_SPAN = 4; /** Schmitt: avoid lock↔dynamic flicker when lookahead maxF jitters at the 12th fret. */ const LOOKAHEAD_LOCK_RELEASE_MAXF = 13; const LOOKAHEAD_LOCK_ENGAGE_MAXF = 10; // Note: we deliberately do NOT scale the camUpdate lerp speed with // cameraSmoothing. Smoothing widens the hysteresis dead zones so the // camera stays put through small/repetitive shifts; but when a shift // *does* clear the gate (a real jump to a far fret), we want the slide // to be snappy, not lethargic. The dead zone gates "should we move?", // the BPM-scaled lerp answers "how fast" — keeping those orthogonal // gives the right feel. const FOG_START = 200 * K; const FOG_END = 670 * K; const DOTS = [3, 5, 7, 9, 12, 15, 17, 19, 21, 24]; const DDOTS = new Set([12, 24]); const INLAY_LABEL_FRETS = [3, 5, 7, 9, 12, 15, 17, 19, 22, 24]; // 22 not 21: intentional display choice // Fret-column reference markers: floor-aligned fret-number sprites // that scroll toward the hit line every Nth measure. When the chart // has , the row uses the inlay cadence (DOTS) around the // anchor fret: two marker positions before and three after the // snapped cadence cell (e.g. anchor fret 7 → 3,5,7,9,12,15). const FRET_COL_MARKER_ANCHOR_BACK = 2; const FRET_COL_MARKER_ANCHOR_FWD = 3; /** * @param {number} anchorFret Chart anchor `.fret` (world start fret). * @param {number[]} [cadence] Ascending frets (e.g. DOTS). * @returns {number[]} */ function fretColumnMarkersForAnchor(anchorFret, cadence = DOTS) { const f0 = Math.round(Number(anchorFret)); if (!Number.isFinite(f0) || cadence.length === 0) return cadence.slice(); let iBest = 0; let dBest = Infinity; for (let i = 0; i < cadence.length; i++) { const d = Math.abs(cadence[i] - f0); if (d < dBest || (d === dBest && cadence[i] < cadence[iBest])) { dBest = d; iBest = i; } } const i0 = Math.max(0, iBest - FRET_COL_MARKER_ANCHOR_BACK); const i1 = Math.min(cadence.length, iBest + FRET_COL_MARKER_ANCHOR_FWD + 1); return cadence.slice(i0, i1); } // _noteKey, lowerBoundT, hwyFirstRelevantFrettedTime — moved to src/geometry.js (h3d-carve-1b). // Last arrangement at or before chart time `t` (sorted by .time). // Mirrors static/highway.js getAnchorAt — until t reaches the first anchor’s // time, the first anchor still defines fret/width. // Binary search: this is called inside per-frame loops (lane slicing, // lookahead sampling, marker spawning), so the linear scan was O(samples * // numAnchors) on dense charts. function getChartAnchorAt(anchorArr, t) { if (!anchorArr || !anchorArr.length) return null; let lo = 0, hi = anchorArr.length; while (lo < hi) { const mid = (lo + hi) >>> 1; if (anchorArr[mid].time <= t) lo = mid + 1; else hi = mid; } return lo === 0 ? anchorArr[0] : anchorArr[lo - 1]; } /** @returns {{ dMin: number, dMax: number } | null} */ function laneBoundsFromAnchor(anc) { if (!anc) return null; let fStart = Math.round(Number(anc.fret)); // Match anchorPlayedFretInclusiveSpan(): fret 0 (and below) clamps // to 1, otherwise the lane span ends up one fret narrower than the // played-fret span / label highlighting on charts that emit // . if (!Number.isFinite(fStart) || fStart < 1) fStart = 1; let w = Number(anc.width); if (!Number.isFinite(w)) w = 4; w = Math.max(1, Math.round(w)); const fLast = Math.min(NFRETS, fStart + w - 1); const dMin = Math.max(0, fStart - 1); const dMax = Math.min(NFRETS, fLast); return { dMin, dMax }; } /** Same horizontal span as the dynamic highway lane: anchor at chart time `t`. */ function anchorLaneBoundsAt(anchorArr, t) { if (!anchorArr || !anchorArr.length) return null; return laneBoundsFromAnchor(getChartAnchorAt(anchorArr, t)); } /** * Inclusive chart-fret indices for the playing window (anchor `fret` + `width`), * e.g. fret=5 width=4 → 5..8. Unlike {@link laneBoundsFromAnchor}'s `dMin`/`dMax` * (diagram wire span), these are the labels shown on gems / row numbers. * @returns {{ f0: number, f1: number } | null} */ function anchorPlayedFretInclusiveSpan(anc) { if (!anc) return null; let f0 = Math.round(Number(anc.fret)); if (!Number.isFinite(f0) || f0 < 1) f0 = 1; let w = Number(anc.width); if (!Number.isFinite(w)) w = 4; w = Math.max(1, Math.round(w)); const f1 = Math.min(NFRETS, f0 + w - 1); return { f0, f1 }; } function anchorPlayedFretSpanAt(anchorArr, t) { if (!anchorArr || !anchorArr.length) return null; return anchorPlayedFretInclusiveSpan(getChartAnchorAt(anchorArr, t)); } const FRET_COOLDOWN = 0.5; // seconds a lane fret stays active after last note const DIAG_LINGER_S = 0.55; const DIAG_ENTRANCE_S = 0.20; const DIAG_CROSSFADE_S = 0.15; // 'bl' and 'br' removed — diagram is top-only. Legacy localStorage values // that contain 'bl'/'br' will fall back to BG_DEFAULTS.chordDiagramPosition // via _bgCoerce (which rejects values not in this list). const CHORD_DIAG_POSITION_IDS = ['tl', 'tr']; /** Default chord-box rim / fill gradient (teal family). */ const CHORD_BOX_TEAL_HEX = 0x00d2d5; const CHORD_BOX_TEAL_DARK_HEX = 0x003c3d; /** Frame edge quads: premultiplied-ish alpha match (~128/255). */ const CHORD_BOX_EDGE_ALPHA = 128 / 255; /** Interior gradient strip alpha on both stops (~32/255). */ const CHORD_BOX_FILL_GRAD_ALPHA = 32 / 255; /** Arpeggio interior wash; dedicated gradient tex so teal map doesn’t dominate. */ const ARPEGGIO_BOX_BLUE_HEX = 0x454BB6; const ARPEGGIO_BOX_BLUE_DARK_HEX = 0x2D3190; /** Arpeggio rim accent and lane tint. */ const ARPEGGIO_RIM_BLUE_HEX = 0x454BB6; /** Post-hit chord-frame rim tints driven by the note-state provider * (feedBack#254). Applied only to the teal frame during the linger * fade (chDt <= 0) when a scorer is attached. * Matches the gem hit/miss colours so chord frame and note body * give a consistent signal: * hit → neon spring-green 0x22ff88 (same as mHitBright). * miss → hot magenta-red 0xff0066 (same as mMissOutline). */ const CHORD_BOX_HIT_BRIGHT_HEX = 0x22ff88; const CHORD_BOX_MISS_DARK_HEX = 0xff0066; /** Fret-number label tints — gold on approaching/active notes, muted blue when idle. */ const FRET_LABEL_GOLD_HEX = '#D8A636'; const FRET_LABEL_IDLE_HEX = '#9ab8cc'; /** 3D chord-box rim bars (thin on all chords, including repeats in a sequence). */ const CHORD_FRAME_RIM_MIN = 0.055; // × K — floor thickness const CHORD_FRAME_RIM_FRAC_H = 0.028; // × fullChordBoxH const CHORD_FRAME_RIM_Z_MIN = 0.048; // × K — depth squash const CHORD_FRAME_RIM_Z_SCAL = 0.68; // thickZ scales with ft /** * Highway arpeggio frame uses ``inferArpeggioFromNotePattern`` only inside this * window around ``ch.t``. Hand-shape spans can cover many seconds and several * separate strums of the same voicing; a full-span scan mis-detects arpeggio * from beats that belong to different chord rows. */ const ARP_FRAME_ONSET_PAD_S = 0.06; const ARP_FRAME_ONSET_CLUSTER_S = 0.26; /** * The chart format encodes fast alternating power chords (e.g. D5/D#5 gallops) as * very short ```` rows (~0.05–0.2 s). Note-stream arpeggio * inference must not treat strum spread across strings as arpeggio there — * it false-triggers lavender highway rails / frames (see Frantic ~2:36). */ const ARP_INFER_MIN_HAND_SHAPE_SPAN_S = 0.21; /** * In a **short** chart window, chord strums (same voicing, strings picked * within ~30–45 ms) barely exceed this total spread; real arpeggios in that * window are usually slower across strings OR have 4+ plucks. */ const ARP_INFER_STRUM_VS_ARP_SPREAD_MIN_S = 0.047; /** * If more than ``shape.size + ARP_INFER_MULTI_STRUM_HIT_SLACKS`` matching picks * sit inside a non-trivial hand-shape window, the chart is almost certainly * **repeated strums** of the same chord (or gallops), not one arpeggio sweep. */ const ARP_INFER_MULTI_STRUM_HIT_SLACK = 2; /** ``timeWin`` span above which we apply the multi-strum hit-count cap. */ const ARP_INFER_MULTI_STRUM_WIN_MIN_S = 0.26; /** * Minimum staggered hits inside a hand-shape window for note-stream arpeggio * inference. A genuine arpeggio sweeps several strings of the held shape; * a 2-note melodic motif inside a multi-string ```` (e.g. Jackson 5 * "I Want You Back" ~0:27 — Fm7 transition fingering with two plucks on * strings 4–5) earlier registered as arpeggio and produced a stray lavender * chord frame + purple lane outer dividers. Cap at ``min(shape.size, 3)`` * so 2-string voicings still infer normally and 3+ string templates need * a real sweep. */ const ARP_INFER_MIN_HITS_VS_SHAPE_CAP = 3; /* ====================================================================== * Pure helpers * ====================================================================== */ // _fretXLog, _fretXUniStep, _fretXUni — moved to src/geometry.js (h3d-carve-1). let _h3dFretUniform = true; try { _h3dFretUniform = localStorage.getItem('highway_3d.fretSpacing') !== 'logarithmic'; } catch (_) {} const fretX = f => geoFretX(f, _h3dFretUniform); // h3d-carve-1: delegator (1 beyond-subst) window.h3dSetFretSpacing = mode => { // Validate against the two supported modes before persisting so an // unexpected input can't leave an invalid value in localStorage // (mirrors h3dBgSetFretNumberGhostScope's allowlist guard). No-op // when the stored mode is already what was requested. const m = mode === 'logarithmic' ? 'logarithmic' : 'uniform'; try { if (localStorage.getItem('highway_3d.fretSpacing') === m) return; localStorage.setItem('highway_3d.fretSpacing', m); } catch (_) {} // Apply live rather than reloading the page — a full page reload // reboots the SPA to the home screen (index.html's `.screen.active`), // ejecting the user from Settings. Rebind the module-scope flag so // panels mounted later this session pick up the new mode, recompute // the fretX-derived scalars, then broadcast a change so every mounted // panel rebuilds its board. Same live-update path as every other // 3D-highway setting. _h3dFretUniform = (m !== 'logarithmic'); _recomputeFretSpacingDerived(); _bgEmitChange('fretSpacing'); }; const fretMid = f => geoFretMid(f, _h3dFretUniform); // h3d-carve-1b: delegator (1 beyond-subst) /** World-space width of fret column (wires f−1 .. f); used to scale row markers past ~12. */ function fretColumnWorldW(f) { const fi = Math.round(Number(f)); if (!Number.isFinite(fi) || fi <= 0) return Math.abs(fretX(1) - fretX(0)); const lo = Math.min(NFRETS, Math.max(1, fi)); return Math.abs(fretX(lo) - fretX(lo - 1)); } /** Reference column (~mid board): prior fixed K-based sprites matched this neighborhood. */ const FRET_LABEL_SCALE_REF_FRET = 5; // `let` (not `const`): recomputed by _recomputeFretSpacingDerived when the // user flips Uniform/Logarithmic at runtime so label scaling tracks the // new geometry without a page reload. let _fretLabelScaleRefW = Math.max(1e-8, fretColumnWorldW(FRET_LABEL_SCALE_REF_FRET)); function fretLabelScaleForFret(f) { const w = fretColumnWorldW(f); const m = w / _fretLabelScaleRefW; return Math.max(0.32, Math.min(1.45, m)); } // dZ — moved to src/geometry.js (h3d-carve-1). // slideTrailEnd — moved to src/geometry.js (h3d-carve-1). /** * Lateral slide offset along the fretboard during sustain — easing * mirrors the pitched/unpitched slide offset convention above. * @param {{ endFret: number, unpitched: boolean } | null} [st_] from slideTrailEnd */ function slideOffsetWorldX(n, chartTime, st_) { const st = st_ || slideTrailEnd(n); if (!st || n.f <= 0 || !(n.sus > 0)) return 0; const denom = Math.max(n.sus, 1e-6); const p = Math.max(0, Math.min(1, (chartTime - n.t) / denom)); const startX = fretMid(n.f); const endX = fretMid(st.endFret); const w = st.unpitched ? 1 - Math.sin((1 - p) * Math.PI / 2) : Math.pow(Math.sin(p * Math.PI / 2), 3); return (endX - startX) * w; } // camBaseDistU, camLowFretPullbackU — moved to src/geometry.js (h3d-carve-1). // World-units-per-fret near mid-neck. Used by the camera-X hysteresis // gate (issue #34) to convert a fret-equivalent dead zone into world // units. Pure function of SCALE — hoist out of update()'s hot path. // `let` (not `const`): recomputed alongside _fretLabelScaleRefW when the // fret-spacing mode flips at runtime — see _recomputeFretSpacingDerived. let FRET_WIDTH_MID = fretX(7) - fretX(6); // Recompute the fretX-derived scalars baked at module init. Called from // h3dSetFretSpacing after _h3dFretUniform flips so label scaling and the // camera hysteresis threshold track the newly chosen spacing — the live // alternative to the old location.reload(), which ejected the user from // Settings back to the home screen. function _recomputeFretSpacingDerived() { _fretLabelScaleRefW = Math.max(1e-8, fretColumnWorldW(FRET_LABEL_SCALE_REF_FRET)); FRET_WIDTH_MID = fretX(7) - fretX(6); } // computeBPM, _makeGaussTex — moved to src/geometry.js (h3d-carve-1). // T, loadThree — moved to src/three-loader.js (h3d-carve-2). // T is a live-binding export; the IIFE reads the updated value after loadThree() resolves. // _ssActive, _ssIsCanvasFocused — moved to src/utils.js (h3d-carve-3). // Shortcut for the wide-pane framing tuner. Opens/closes the floating panel // (the A/B on/off and the per-pane target live inside it now). Registered // once per session via a module-level guard (it drives shared module state, // so per-instance registration would stack duplicate handlers and cancel // itself out); it's a harmless debug control, so it is never unregistered. // No-ops where the core shortcut API isn't present (older core / borrowed // contexts). let _tunerShortcutRegistered = false; function _registerTunerShortcut() { if (_tunerShortcutRegistered) return; if (typeof window.registerShortcut !== 'function') return; _tunerShortcutRegistered = true; try { window.registerShortcut({ key: 'A', // uppercase e.key → produced with Shift held (Shift+A) description: '3D Highway: open/close wide-pane framing tuner (Shift+A)', scope: 'player', handler: () => { // Open/close the live tuner panel. The A/B on/off and the // per-pane target now live in the panel itself, so the // shortcut is just a dismiss/reveal. _toggleAspectPanel(); }, }); } catch (e) { _tunerShortcutRegistered = false; // allow a later retry if it threw } } // ── Wide-pane framing: live tuner bridge + panel ────────────────────────── // window.__h3dAspectTune is the single source of truth the renderer reads // each frame (see effectiveVfov + camUpdate). The defaults reproduce the // current framing exactly (enabled:false). Values persist to localStorage so // a tuning session survives reloads; the floating panel (Shift+A) writes the // same object live. All of this is a debug aid — none of it runs unless the // user opts in. // Versioned key: the first iteration shipped a broken default (enabled:true, // baseVfov:30) and may have persisted it. Bumping the key ignores that stale // state so the corrected default-off config actually takes effect. const _ASPECT_LS = 'h3d_aspect_tune2'; // Working defaults. Default OFF, so out of the box this is an exact no-op — // every pane renders byte-for-byte as before (effectiveVfov returns // BASE_VFOV and the pose nudges gate off). The config is also coherent when // a tester turns it ON via Shift+A: baseVfov == BASE_VFOV so normal ~16:9 // panes (single-player, most 2x2) stay at 70° even enabled, and only panes // wider than startAspect (2.25) engage the Hor+ hold; blend:1 makes that // hold actually take effect; minVfovDeg (28) sits below baseVfov so the floor // is a real floor. The pose nudges are the in-progress wide-pane look a // tester sees once enabled. localStorage overrides all of this per machine. const _ASPECT_DEFAULTS = { enabled: false, baseVfov: BASE_VFOV, startAspect: 2.25, hfovDeg: null, blend: 1, minVfovDeg: HORPLUS_MIN_VFOV, splitOnly: false, heightMul: 0.30, distMul: 0.95, pitchAdd: -1.5, lookDepthMul: 1, }; // Slider specs (numeric fields). Checkboxes (enabled/splitOnly) + the hfov // override are handled separately in the panel builder. Ranges are wide on // purpose — this is a tuning aid, the no-op default sits mid-range. const _ASPECT_FIELDS = [ { k: 'baseVfov', label: 'Base vFOV°', min: 18, max: 90, step: 1 }, { k: 'startAspect', label: 'Start aspect', min: 1.0, max: 4.0, step: 0.05 }, { k: 'blend', label: 'Blend', min: 0, max: 1, step: 0.05 }, { k: 'minVfovDeg', label: 'Min vFOV°', min: 10, max: 60, step: 1 }, { k: 'heightMul', label: 'Height ×', min: 0.1, max: 2.5, step: 0.05 }, { k: 'distMul', label: 'Dolly ×', min: 0.2, max: 3.0, step: 0.05 }, { k: 'pitchAdd', label: 'Pitch +', min: -40, max: 40, step: 0.5 }, // Aims the camera further down the neck (>1) or pulls the aim back (<1). // This is the lever that flattens the mid-distance "hump" toward a // straight gradual recede. { k: 'lookDepthMul', label: 'Look depth', min: 0.2, max: 3.0, step: 0.05 }, ]; let _aspectPanelEl = null; // the floating panel root (built once) let _aspectPanelRO = null; // readout
let _aspectPanelRAF = 0; // readout poll handle let _aspectTargetSel = null; // the "Target" . let _aspectPanesDirty = true; // Monotonic counter for the per-instance fallback key (when a pane has no // arrangement name to key by). let _aspectPaneCounter = 0; function _aspectNowMs() { try { if (performance && performance.now) return performance.now(); } catch (e) {} try { return Date.now(); } catch (e) { return 0; } // keep pruning functional } // Pane key: prefer the arrangement name ('arr:Bass') so a pane's framing is // stable across songs AND distinct between split panes, with no dependency on // the external splitscreen panel index (which isn't always available). Fall // back to a per-instance id ('pane:3') when there's no arrangement. function _aspectPaneKey(arrangement, uid) { const a = (typeof arrangement === 'string') ? arrangement.trim() : ''; return a ? ('arr:' + a) : ('pane:' + uid); } // Human label derived from the key. function _aspectPaneLabel(paneKey) { if (paneKey.slice(0, 4) === 'arr:') return paneKey.slice(4); if (paneKey.slice(0, 5) === 'pane:') return 'Pane ' + paneKey.slice(5); return paneKey; } // Get-or-create the shared bridge object, seeded from defaults + localStorage. // May carry a sparse `__panels` map of per-pane overrides. function _aspectTune() { let t = window.__h3dAspectTune; if (!t || typeof t !== 'object') { t = Object.assign({}, _ASPECT_DEFAULTS); try { const raw = localStorage.getItem(_ASPECT_LS); if (raw) Object.assign(t, JSON.parse(raw)); } catch (e) {} window.__h3dAspectTune = t; } return t; } // Bumped on every tune mutation (all writes funnel through _aspectPersist) so // the per-pane resolve cache below can invalidate cheaply. let _aspectRev = 0; function _aspectPersist() { _aspectRev++; try { const t = _aspectTune(), out = {}; Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = t[k]; }); // Persist per-pane overrides keyed by arrangement ('arr:*') only, so a // pane's framing carries across songs. Instance-id fallback keys // ('pane:*') are session-only — persisting them would leak a new key // every reload. if (t.__panels) { const p = {}; let any = false; Object.keys(t.__panels).forEach((k) => { if (k.slice(0, 4) === 'arr:') { p[k] = t.__panels[k]; any = true; } }); if (any) out.__panels = p; } localStorage.setItem(_ASPECT_LS, JSON.stringify(out)); } catch (e) {} } // Resolve the effective tune for a pane: the shared base, with that pane's // override keys (if any) laid on top. Called every frame per renderer, so the // merged object is memoized per pane and only rebuilt when the tune mutates // (_aspectRev changes). Panes with no override return the base directly (no // allocation). const _aspectResolveCache = new Map(); // paneKey -> { rev, obj } function _resolveTuneFor(paneKey) { const base = _aspectTune(); const ov = base.__panels && base.__panels[paneKey]; if (!ov) return base; const c = _aspectResolveCache.get(paneKey); if (c && c.rev === _aspectRev) return c.obj; const out = {}; Object.keys(_ASPECT_DEFAULTS).forEach((k) => { out[k] = (k in ov) ? ov[k] : base[k]; }); _aspectResolveCache.set(paneKey, { rev: _aspectRev, obj: out }); return out; } // Record a live pane so the Target dropdown can list it. Called every frame // by each renderer with its pane key. `seen` is refreshed each call for // pruning; the dropdown is only marked dirty when a pane is newly added — not // on every re-report, which would flicker the