Files
feedBack/plugins/highway_3d/src/utils.js
T
byrongamatosandClaude Sonnet 4.6 6050b6262b fix(h3d): thread maxStrings param through resolveStringCount (Toby r1)
Toby r1 on c7f7c88: MAX_RENDER_STRINGS=6 hardcoded in utils.js is a
stale compile-time copy — if S_COL grows to 7 entries, resolveStringCount
silently clamps a 7-string chart to 6 and no test fails.

Fix: delegator-param pattern (mirrors fretX / geoFretX):
  - Remove const MAX_RENDER_STRINGS from utils.js
  - resolveStringCount(bundle, maxStrings) — param replaces the copy
  - _openStringPitchLabelsForTuning(bundle, songInfo, n, maxStrings) — same
  - screen.js import aliases (_resolveStringCountBase, _openStringPitchLabelsForTuningBase)
  - 1-line delegators in IIFE supply MAX_RENDER_STRINGS (= S_COL.length); zero call sites change
  - NSTR=6 kept (it is a fixed semantic fact about standard guitar, not a palette ceiling)

New test: 'maxStrings param is authoritative, not a hardcoded 6'
  — resolveStringCount({stringCount:7}, 7)=7; re-hardcode mutation → RED.
Wiring tests: delegator lines asserting _resolveStringCountBase/
_openStringPitchLabelsForTuningBase each receive MAX_RENDER_STRINGS.

Mutation-verified: re-hardcode 6 → 1 RED; original → 210/210 GREEN.
Suite: node --test tests/js/highway_3d*.test.js plugins/highway_3d/tests/*.test.js
207→210/210 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 08:02:00 +02:00

149 lines
7.0 KiB
JavaScript

/**
* Color utilities, tuning helpers, and splitscreen predicates — h3d-carve-3.
*
* All exports are pure functions or compile-time constants; none capture
* factory-scope state. NSTR and MAX_RENDER_STRINGS are compile-time copies
* of the matching IIFE constants (both = 6 for the current 6-entry
* PALETTES.default / S_COL layout).
*/
// ── Compile-time IIFE constant ────────────────────────────────────────────────
// NSTR: standard guitar default (not a palette ceiling — safe as a fixed fact).
// MAX_RENDER_STRINGS is NOT duplicated here; it is the authority of S_COL.length
// in screen.js and flows in via the maxStrings parameter of resolveStringCount
// and _openStringPitchLabelsForTuning. screen.js keeps 1-line delegators that
// supply MAX_RENDER_STRINGS so zero call-sites change.
const NSTR = 6;
// ── Color utilities ───────────────────────────────────────────────────────────
/**
* Parse a CSS hex color string ('#rrggbb', '#rgb', or bare variants) to a
* packed 0xRRGGBB integer. Returns null on any parse failure.
*/
export function _h3dHexToInt(hex) {
if (typeof hex !== 'string') return null;
const t = hex.trim().replace(/^#/, '');
const full = t.length === 3 ? t[0] + t[0] + t[1] + t[1] + t[2] + t[2] : t;
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
return parseInt(full, 16);
}
export function _clampByteI(n) { return n < 0 ? 0 : (n > 255 ? 255 : Math.round(n)); }
export function _darkenInt(hex, factor) {
const r = (hex >> 16) & 0xff, g = (hex >> 8) & 0xff, b = hex & 0xff;
return (_clampByteI(r * factor) << 16) | (_clampByteI(g * factor) << 8) | _clampByteI(b * factor);
}
export function _lightenInt(hex, t) {
const r = (hex >> 16) & 0xff, g = (hex >> 8) & 0xff, b = hex & 0xff;
return (_clampByteI(r + (255 - r) * t) << 16) | (_clampByteI(g + (255 - g) * t) << 8) | _clampByteI(b + (255 - b) * t);
}
// ── String-count resolution ───────────────────────────────────────────────────
/**
* Resolve the string count for the active arrangement. Prefer
* bundle.stringCount (exposed by feedBack core since #93 — derived from
* notes/chords/tuning, works for 5-string bass, 7- and 8-string guitar).
* Falls back to arrangement-name detection for older feedBack cores.
* Clamped to MAX_RENDER_STRINGS so a malformed bundle doesn't index past
* the per-string material arrays.
*/
export function resolveStringCount(bundle, maxStrings) {
const sc = bundle && bundle.stringCount;
if (Number.isFinite(sc) && sc >= 1) {
return Math.min(Math.trunc(sc), maxStrings);
}
return /bass/i.test(bundle?.songInfo?.arrangement || '') ? 4 : NSTR;
}
// ── Tuning / pitch-label helpers ──────────────────────────────────────────────
/** Chart-format tuning entries are semitone offsets from instrument standard. */
export const _NOTE_NAMES_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
// Open-string MIDI (thick → thin), matched to RS string index 0 low.
export const _BASE_OPEN_MIDI_BASS4 = Object.freeze([28, 33, 38, 43]);
export const _BASE_OPEN_MIDI_BASS5 = Object.freeze([23, 28, 33, 38, 43]);
export const _BASE_OPEN_MIDI_GUITAR6 = Object.freeze([40, 45, 50, 55, 59, 64]);
export const _BASE_OPEN_MIDI_GUITAR7 = Object.freeze([35, 40, 45, 50, 55, 59, 64]);
// F#/B/E standard extension — low string is a fifth below RS 7-string low B.
export const _BASE_OPEN_MIDI_GUITAR8 = Object.freeze([28, 35, 40, 45, 50, 55, 59, 64]);
export function _baseOpenStringMidis(sc, arrangement) {
const isBass = /bass/i.test(arrangement || '');
if (sc === 4 && isBass) return _BASE_OPEN_MIDI_BASS4.slice();
if (sc === 4) return _BASE_OPEN_MIDI_GUITAR6.slice(0, 4);
if (sc === 5 && isBass) return _BASE_OPEN_MIDI_BASS5.slice();
if (sc === 5) return _BASE_OPEN_MIDI_GUITAR6.slice(0, 5);
if (sc === 7) return _BASE_OPEN_MIDI_GUITAR7.slice();
if (sc === 8) return _BASE_OPEN_MIDI_GUITAR8.slice();
if (Number.isFinite(sc) && sc > 8) {
const out = Array.from(_BASE_OPEN_MIDI_GUITAR8);
let last = out[out.length - 1];
while (out.length < sc) {
last += 5;
out.push(last);
}
return out.slice(0, sc);
}
const g6 = _BASE_OPEN_MIDI_GUITAR6.slice();
if (Number.isFinite(sc) && sc < 6 && sc >= 1) return g6.slice(0, sc);
return g6;
}
export function _midiToPitchLabel(midi) {
const m = Math.round(midi);
const octave = Math.floor(m / 12) - 1;
const n = _NOTE_NAMES_SHARP[(m % 12 + 12) % 12];
return n + octave;
}
/**
* @param {object} bundle Highway render bundle (tuning, capo, stringCount)
* @param {object} songInfo WS song_info blob (arrangement, tuning, capo)
* @param {number} nEffective String count clamped like nStr / resolveStringCount
*/
export function _openStringPitchLabelsForTuning(bundle, songInfo, nEffective, maxStrings) {
const n = Number.isFinite(nEffective) ? Math.min(Math.max(1, Math.trunc(nEffective)), maxStrings) : resolveStringCount(bundle, maxStrings);
// bundle first: chart-transform substitutes tuning/capo there, while
// songInfo keeps the chart's originals by contract. A malformed
// (non-array) bundle.tuning falls back to songInfo instead of
// blanking the labels.
let tuning = Array.isArray(bundle.tuning) ? bundle.tuning : (songInfo && songInfo.tuning);
let cap = bundle.capo;
cap = Number.isFinite(cap) ? cap : (songInfo && Number.isFinite(songInfo.capo) ? songInfo.capo : 0);
if (!Array.isArray(tuning)) tuning = [];
const base = _baseOpenStringMidis(n, songInfo?.arrangement);
const labels = [];
for (let s = 0; s < n; s++) {
const offRaw = tuning[s];
const off = Number.isFinite(offRaw) ? offRaw : 0;
const midi = (base[s] !== undefined ? base[s] : 40) + off + cap;
labels.push(_midiToPitchLabel(midi));
}
return labels;
}
// ── Splitscreen predicates ────────────────────────────────────────────────────
// window.feedBackSplitscreen is read live each call — never captured at
// module or factory scope.
export function _ssActive() {
const ss = window.feedBackSplitscreen;
if (!ss || typeof ss.isActive !== 'function' || !ss.isActive()) return false;
return typeof ss.isCanvasFocused === 'function'
&& typeof ss.onFocusChange === 'function'
&& typeof ss.offFocusChange === 'function';
}
export function _ssIsCanvasFocused(highwayCanvas) {
const ss = window.feedBackSplitscreen;
if (!_ssActive()) return true;
return !!(ss && typeof ss.isCanvasFocused === 'function' &&
ss.isCanvasFocused(highwayCanvas));
}