refactor(h3d-carve-6): move N-section material builders to src/materials.js

Extract TXT_STYLES + 13 material builder functions (txtMat, pinchHarmonicMat,
naturalHarmonicMat, palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
triMat, bendChevronMat, darkenHex, slideArrowMat, _meshMatForGhostFretDigit,
_spriteMat2MeshMat) and pool() from screen.js N-section into:
  plugins/highway_3d/src/materials.js  (createMaterialBuilders factory)

screen.js drops ~600 lines (15302→14705).

Surprises vs plan §4:
  • DI is 4 params { getT, getTxtCache, techMatCache, techMeshMatClones } not 1
  • _syncOpenStringPitchLabels cluster excluded (20+ factory-scope deps)
  • _techMatCache stays in screen.js factory scope for teardown .values()/.clear()

Beyond-subst (4):
  1. Factory wrapper createMaterialBuilders({...})
  2. T → const T = getT() inside each function body (live accessor)
  3. txtCache[k] → const cache = getTxtCache(); cache[k]
  4. _techMatCache/_techMeshMatClones → DI param names techMatCache/techMeshMatClones

Tests: 16 new class-killer tests in highway_3d_materials.test.js;
pool warm tests retargeted to src/materials.js; panel_controls stub added.
Full suite: 1246/1248 pass (2 pre-existing: network + nut-labels).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
This commit is contained in:
byrongamatos
2026-09-05 10:24:40 +02:00
co-authored by Claude Sonnet 4.6
parent 733d772154
commit e6b2f86068
6 changed files with 15745 additions and 15314 deletions
+9 -2
View File
@@ -1,13 +1,20 @@
{ {
"id": "highway_3d", "id": "highway_3d",
"name": "3D Highway", "name": "3D Highway",
"version": "3.41.0", "version": "3.42.0",
"type": "visualization", "type": "visualization",
"scriptType": "module", "scriptType": "module",
"bundled": true, "bundled": true,
"script": "screen.js", "script": "screen.js",
"styles": "assets/plugin.css", "styles": "assets/plugin.css",
"settings": { "html": "settings.html", "category": "graphics", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] }, "settings": {
"html": "settings.html",
"category": "graphics",
"server_files": [
"plugin_uploads/highway_3d/current.mp4",
"plugin_uploads/highway_3d/current.webm"
]
},
"routes": "routes.py", "routes": "routes.py",
"tour": "tour.json" "tour": "tour.json"
} }
+20 -616
View File
@@ -11,6 +11,7 @@ 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 { _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 { _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx } from './src/bc-panel.js'; // h3d-carve-4
import { createBgControl } from './src/bg-control.js'; // h3d-carve-5 import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
(function () { (function () {
'use strict'; 'use strict';
@@ -4119,591 +4120,27 @@ import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
// ── String-to-Y (respects invert) ───────────────────────────────── // ── String-to-Y (respects invert) ─────────────────────────────────
const sY = s => S_BASE + (_invertedCached ? s : (nStr - 1 - s)) * S_GAP; const sY = s => S_BASE + (_invertedCached ? s : (nStr - 1 - s)) * S_GAP;
// ── Text-sprite cache ────────────────────────────────────────────── // ── h3d-carve-6: material builders ───────────────────────────────────
// ── Text-sprite style presets ───────────────────────────────────── // TXT_STYLES, txtMat, pinchHarmonicMat, naturalHarmonicMat,
// Each preset describes how a class of label is rasterised. // palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
// Tweak per-class look here (font, outline color/width, source // triMat, bendChevronMat, darkenHex, slideArrowMat,
// canvas size). `wide` toggles a long aspect ratio for multi-char // _meshMatForGhostFretDigit, _spriteMat2MeshMat, pool
// labels (chord/section names, "↑1/2", "~~~"). // → src/materials.js (createMaterialBuilders)
// //
// Knobs: // Surprises vs plan §4:
// font — full CSS font shorthand (weight + size + family) // • _syncOpenStringPitchLabels cluster (20+ factory-scope deps) stays in screen.js
// wideFont — same, used when caller passes wide=true // • DI is 4 params { getT, getTxtCache, techMatCache, techMeshMatClones }, not 1
// srcH — source-canvas height in px (square; wide=4×). // • _techMatCache stays here; teardown accesses .values()/.clear() directly
// Keep power-of-two so WebGL1 / Three.js retain
// mipmaps + linear-mip-linear filtering — NPOT
// textures silently fall back to no-mipmap and
// shimmer at distance.
// stroke — outline color (null = no outline)
// strokeW — outline line-width in source-canvas px
// shadow — { color, blur, dx, dy } or null
const TXT_STYLES = {
// The two fret-number sets the user wants to pop hardest.
fretRow: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
noteFret: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
// Ghost-fret labels on the board projection: same weight/size/outline
// as noteFret, but uses textAlign='center'; textBaseline='middle'
// (the standard branch in txtMat) so the glyph is truly centred on
// the PlaneGeometry UV. inkCenterFret's actualBoundingBox path is
// intentionally NOT activated for this style — that path was designed
// for Sprites and shifts the canvas origin, which causes visible
// lower-left drift on Mesh + MeshBasicMaterial (UV-direct mapping).
ghostFret: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
// Chord names — gold script-style label, lighter outline keeps
// the colour readable.
chord: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Section banners ("Verse", "Chorus") — same as chord weight.
section: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Technique markers (pinch-harmonic icon, PM, AC, H/P/T, etc.).
technique: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Open-string "0" label on the note body itself.
open: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
};
function txtMat(text, col, wide, style) {
const sName = style || 'technique';
const k = sName + '|' + (wide ? 'W' : '') + text + '|' + col;
if (txtCache[k]) return txtCache[k];
const sp = TXT_STYLES[sName] || TXT_STYLES.technique;
const h = sp.srcH;
const str = String(text);
const font = wide ? sp.wideFont : sp.font;
let w = wide ? h * 4 : h;
if (!wide && sName === 'noteFret') {
// Wide labels (D#2, Bb3) need a canvas wider than srcH; cap so
// glyphs stay centred at (w/2, h/2) without edge clipping.
const probe = document.createElement('canvas').getContext('2d');
probe.font = font;
const tw = probe.measureText(str).width;
let pad = 0;
if (sp.stroke && sp.strokeW > 0) pad += sp.strokeW * 2;
if (sp.shadow) {
pad += Math.abs(sp.shadow.dx) + sp.shadow.blur * 2;
}
w = Math.min(12 * h, Math.max(h, Math.ceil(tw + pad)));
}
const c = document.createElement('canvas');
c.width = w; c.height = h;
const x = c.getContext('2d');
x.font = font;
// Fret / open-string digits: anchor from actualBoundingBox so the
// glyph sits at the true optical centre of the canvas (fixes
// sprites looking off-centre inside the board ghost and elsewhere).
const inkCenterFret = !wide && (sName === 'noteFret' || sName === 'open');
// Ghost fret labels live on a PlaneGeometry Mesh (UV-direct), not a Sprite
// billboard. Sprites tolerate slight canvas off-centering because Three.js
// centres them at their world position; a Mesh does not — the digit lands
// wherever it sits in UV space. Use the advance-width centre as the initial
// pen position and then correct for any ink asymmetry via actualBoundingBox.
const inkCenterGhost = !wide && sName === 'ghostFret';
let drawX = w / 2;
let drawY = h / 2;
if (inkCenterFret) {
x.textAlign = 'left';
x.textBaseline = 'alphabetic';
const m = x.measureText(str);
const L = m.actualBoundingBoxLeft;
const R = m.actualBoundingBoxRight;
const A = m.actualBoundingBoxAscent;
const D = m.actualBoundingBoxDescent;
if (
L != null && R != null && A != null && D != null &&
Number.isFinite(L) && Number.isFinite(R) &&
Number.isFinite(A) && Number.isFinite(D)
) {
const inkW = R - L;
drawX = (w - inkW) / 2 - L;
drawY = (h + A - D) / 2;
// Tab digits sit visually a hair low vs bbox (stroke/shadow);
// small canvas nudge keeps sprites centred on the board ghost.
if (sName === 'noteFret') drawY -= h * 0.028;
} else {
x.textAlign = 'center';
x.textBaseline = 'middle';
drawX = w / 2;
drawY = h / 2;
}
} else if (inkCenterGhost) {
// Alpha-weighted centroid approach on FILL-ONLY ink (no shadow, no
// stroke) to find the true ink centre of mass without contamination
// from the isotropic shadow blur. For Arial Black "1" the shadow from
// the thin upper-left flag bleeds leftward and cancels part of the
// rightward correction when we include it in the scan. Measuring fill
// alone isolates the actual glyph shape.
// 1. Draw fill-only (no shadow, no stroke) at (w/2, h/2) on temp canvas.
// 2. Compute Σ(px·alpha) / Σ(alpha) → ink centroid.
// 3. Shift drawX/drawY so centroid lands exactly at (w/2, h/2).
// Max 4 unique digits (14) → cache-miss runs at most 4 times ever.
x.textAlign = 'center';
x.textBaseline = 'middle';
try {
const tmpC = document.createElement('canvas');
tmpC.width = w; tmpC.height = h;
const tc = tmpC.getContext('2d');
tc.font = font;
tc.textAlign = 'center';
tc.textBaseline = 'middle';
// Deliberately NO shadow and NO stroke — shadow spreads isotropically
// and muddles the centroid; fill alone gives the cleanest reading.
tc.fillStyle = '#ffffff';
tc.fillText(str, w / 2, h / 2);
const id = tc.getImageData(0, 0, w, h).data;
// Alpha-weighted centroid — heavier ink pixels (thick vertical stem
// of "1") outweigh thin/sparse pixels (diagonal flag), producing the
// correct perceptual centre rather than the geometric bbox midpoint.
let sumX = 0, sumY = 0, sumA = 0;
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
const a = id[(py * w + px) * 4 + 3];
if (a > 4) { sumX += px * a; sumY += py * a; sumA += a; }
}
}
if (sumA > 0) {
// shift pen so centroid → canvas centre, then add a small
// extra rightward nudge (8 %) so the vertical stroke of
// narrow digits like "1" sits visually at gem centre rather
// than the advance-width centre (which may be slightly left
// of the dominant ink mass for Arial Black numerals).
drawX = w / 2 + (w / 2 - sumX / sumA) + w * 0.08;
drawY = h / 2 + (h / 2 - sumY / sumA);
}
} catch (_) { /* fallback: draw at (w/2, h/2) */ }
// x (real canvas) still has textAlign='center'; textBaseline='middle'
} else {
x.textAlign = 'center';
x.textBaseline = 'middle';
}
if (sp.shadow) {
x.shadowColor = sp.shadow.color;
x.shadowBlur = sp.shadow.blur;
x.shadowOffsetX = sp.shadow.dx;
x.shadowOffsetY = sp.shadow.dy;
}
if (sp.stroke && sp.strokeW > 0) {
x.lineJoin = 'round';
x.miterLimit = 2;
x.strokeStyle = sp.stroke;
x.lineWidth = sp.strokeW;
x.strokeText(str, drawX, drawY);
}
x.fillStyle = col;
x.fillText(str, drawX, drawY);
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
// depthTest:false means later geometry never *fails* depth
// against these sprites, but without depthWrite:false the
// sprites still write to the depth buffer (Three.js default
// is depthWrite:true even for SpriteMaterial). That can
// make subsequent sprites/labels vanish — match the
// pattern used by the other sprite materials in this file.
depthTest: false,
depthWrite: false,
});
txtCache[k] = mat;
return mat;
}
function pinchHarmonicMat(col) {
const baseCol = new T.Color(col != null ? col : '#ffd84d');
// v5 — compact concentric ellipses:
// 1. black outer border rx=0.430h ry=0.255h
// 2. string-color body rx=0.418h ry=0.232h
// 3. black inner ring rx=0.407h ry=0.218h
// 4. string-color inner rx=0.264h ry=0.218h
// 5. black center dot rx=0.134h ry=0.120h
const k = 'technique|pinchHarmonicIcon|rs2014-v5b|' + baseCol.getHexString();
if (txtCache[k]) return txtCache[k];
const h = 512;
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
const TAU = Math.PI * 2;
const colStr = `rgb(${Math.round(baseCol.r * 255)},${Math.round(baseCol.g * 255)},${Math.round(baseCol.b * 255)})`;
x.clearRect(0, 0, h, h);
x.save();
x.translate(h / 2, h / 2);
// Form 1 — black outer border
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.430, h * 0.255, 0, 0, TAU); x.fill();
// Form 2 — string-color main body
x.fillStyle = colStr;
x.beginPath(); x.ellipse(0, 0, h * 0.418, h * 0.232, 0, 0, TAU); x.fill();
// Form 3 — black inner ring
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.407, h * 0.218, 0, 0, TAU); x.fill();
// Form 4 — string-color inner spot (narrower)
x.fillStyle = colStr;
x.beginPath(); x.ellipse(0, 0, h * 0.2637, h * 0.218, 0, 0, TAU); x.fill();
// Form 5 — black center dot
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.134, h * 0.120, 0, 0, TAU); x.fill();
x.restore();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
});
txtCache[k] = mat;
return mat;
}
function naturalHarmonicMat() {
const k = 'technique|naturalHarmonicIcon|pink-ring-v3';
if (txtCache[k]) return txtCache[k];
const h = 256;
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
const cx = h / 2;
const cy = h / 2;
const TAU = Math.PI * 2;
x.clearRect(0, 0, h, h);
const glow = x.createRadialGradient(cx, cy, h * 0.03, cx, cy, h * 0.47);
glow.addColorStop(0, 'rgba(255,170,255,0.14)');
glow.addColorStop(0.55, 'rgba(0,0,0,0.22)');
glow.addColorStop(1, 'rgba(0,0,0,0)');
x.fillStyle = glow;
x.beginPath();
x.arc(cx, cy, h * 0.44, 0, TAU);
x.fill();
x.shadowColor = 'rgba(0,0,0,0.85)';
x.shadowBlur = 14;
x.fillStyle = 'rgba(255, 255, 255, 0.96)';
x.beginPath();
x.arc(cx, cy, h * 0.31, 0, TAU);
x.fill();
// Punch out the inner gap so the icon reads as a bright ring.
x.shadowBlur = 0;
x.globalCompositeOperation = 'destination-out';
x.beginPath();
x.arc(cx, cy, h * 0.20, 0, TAU);
x.fill();
x.globalCompositeOperation = 'source-over';
x.shadowColor = 'rgba(0, 0, 0, 0.7)';
x.shadowBlur = 10;
x.strokeStyle = 'rgba(255, 255, 255, 0.98)';
x.lineWidth = 8;
x.beginPath();
x.arc(cx, cy, h * 0.255, 0, TAU);
x.stroke();
x.shadowColor = 'rgba(0,0,0,0)';
x.fillStyle = 'rgba(255, 255, 255, 0.98)';
x.beginPath();
x.arc(cx, cy, h * 0.12, 0, TAU);
x.fill();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
opacity: 0.96,
});
txtCache[k] = mat;
return mat;
}
// Only two PM/FH variants exist (palm-mute = black-on-white,
// fret-hand mute = white-on-black). drawNote() hits muteXMat per
// muted chord-note per frame, so dense PM/FH passages were paying
// for a string concat + Map lookup on every call. Hoist both
// SpriteMaterial refs and short-circuit before touching the cache.
// They're populated lazily on first use; teardown still reaches
// them via the shared ``txtCache`` because muteXMat writes there.
let _pmXSpriteMat = null;
let _fhXSpriteMat = null;
function palmMuteXSpriteMat() {
return _pmXSpriteMat ?? (_pmXSpriteMat = muteXMat('#000000', '#ffffff'));
}
function fretHandMuteXSpriteMat() {
return _fhXSpriteMat ?? (_fhXSpriteMat = muteXMat('#ffffff', '#000000'));
}
function muteXMat(fillCol, strokeCol) {
const k = 'technique|muteX|v2|' + String(fillCol) + '|' + String(strokeCol);
if (txtCache[k]) return txtCache[k];
// lineCap:'square' gives flat tips. For a 45° diagonal the square-cap
// corners sit at ±outerW/2 rotated 45° from the endpoint — they land
// outside the canvas unless pad ≥ outerW/√2 (the common mistake is
// using outerW/2, which is too small). With the correct pad the white
// cap is fully inside the canvas and the border is visible at every tip.
const h = 512;
const outerW = 132, innerW = 114;
// pad must satisfy: pad ≥ outerW / Math.SQRT2 (≈ outerW × 0.707)
const pad = Math.ceil(outerW / Math.SQRT2) + 2; // 96
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
x.clearRect(0, 0, h, h);
x.lineCap = 'square';
// Draw each diagonal in its own stroke() call — caps of the two
// diagonals don't interact, and the white outer is drawn before the
// black inner so the border is clean at every edge and tip.
x.strokeStyle = strokeCol;
x.lineWidth = outerW;
x.beginPath(); x.moveTo(pad, pad); x.lineTo(h - pad, h - pad); x.stroke();
x.beginPath(); x.moveTo(h - pad, pad); x.lineTo(pad, h - pad); x.stroke();
x.strokeStyle = fillCol;
x.lineWidth = innerW;
x.beginPath(); x.moveTo(pad, pad); x.lineTo(h - pad, h - pad); x.stroke();
x.beginPath(); x.moveTo(h - pad, pad); x.lineTo(pad, h - pad); x.stroke();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
});
txtCache[k] = mat;
return mat;
}
// Technique-marker sprite materials (triangle / chevron). Keyed by a
// packed NUMBER, not a string — triMat/bendChevronMat are called from
// the drawNote hot path, so a string cache key would allocate per
// note per frame. Disposed in teardown. `hex` is a 0xRRGGBB number;
// the low nibble of the key tags the variant (0 ▲, 1 ▼, 3-6 chevron
// step-count) so triangle and chevron entries can't collide.
const _techMatCache = new Map(); const _techMatCache = new Map();
const { txtMat, pinchHarmonicMat, naturalHarmonicMat,
// Hammer-on / pull-off triangle marker: a white ▲ (up) / ▼ (down) palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
// with a thick border in the gem's string colour. triMat, bendChevronMat, darkenHex, slideArrowMat,
function triMat(up, hex) { _meshMatForGhostFretDigit, _spriteMat2MeshMat, pool } = createMaterialBuilders({
const h = (hex >>> 0) & 0xffffff; getT: () => T,
const key = h * 16 + (up ? 0 : 1); getTxtCache: () => txtCache,
const cached = _techMatCache.get(key); techMatCache: _techMatCache,
if (cached) return cached; techMeshMatClones: _techMeshMatClones,
const S = 256, m = S * 0.15;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.beginPath();
if (up) { g.moveTo(S / 2, m); g.lineTo(S - m, S - m); g.lineTo(m, S - m); }
else { g.moveTo(S / 2, S - m); g.lineTo(S - m, m); g.lineTo(m, m); }
g.closePath();
g.lineJoin = 'round';
g.fillStyle = '#ffffff';
g.fill();
g.lineWidth = S * 0.122;
g.strokeStyle = '#' + (hex >>> 0).toString(16).padStart(6, '0');
g.stroke();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
}); });
_techMatCache.set(key, mat);
return mat;
}
// Strength-of-bend chevron stack: `steps` (1-4) chevrons in the gem's
// string colour (chart-format bend notation — 1 per half-step).
function bendChevronMat(steps, hex) {
const h = (hex >>> 0) & 0xffffff;
const key = h * 16 + 2 + steps; // steps 1-4 → low nibble 3-6
const cached = _techMatCache.get(key);
if (cached) return cached;
const S = 256;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.strokeStyle = '#' + (hex >>> 0).toString(16).padStart(6, '0');
g.lineWidth = S * 0.10;
g.lineJoin = g.lineCap = 'round';
const padX = S * 0.18;
const rowH = S / steps;
const amp = Math.min(rowH * 0.55, S * 0.24);
for (let i = 0; i < steps; i++) {
const cy = (i + 0.5) * rowH;
g.beginPath();
g.moveTo(padX, cy + amp * 0.5);
g.lineTo(S / 2, cy - amp * 0.5);
g.lineTo(S - padX, cy + amp * 0.5);
g.stroke();
}
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
});
_techMatCache.set(key, mat);
return mat;
}
// Darken a 0xRRGGBB colour by `factor` (0..1) for the slide-arrow
// marker — full string colour is too bright next to the gem.
function darkenHex(hex, factor) {
const h = (hex >>> 0) & 0xffffff;
const r = Math.round(((h >> 16) & 0xff) * factor);
const g = Math.round(((h >> 8) & 0xff) * factor);
const b = Math.round((h & 0xff) * factor);
return (r << 16) | (g << 8) | b;
}
// Slide-direction arrow (/): a filled triangle pointing toward the
// slide's destination fret, in the gem's (darkened) string colour.
// `hex` here is already the darkened colour — keep its own cache-key
// nibble range (8/9) so it can't collide with triMat (0/1) or
// bendChevronMat (3-6).
function slideArrowMat(pointRight, hex) {
const h = (hex >>> 0) & 0xffffff;
const key = h * 16 + 8 + (pointRight ? 0 : 1);
const cached = _techMatCache.get(key);
if (cached) return cached;
const S = 256, m = S * 0.18;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.beginPath();
if (pointRight) { g.moveTo(S - m, S / 2); g.lineTo(m, m); g.lineTo(m, S - m); }
else { g.moveTo(m, S / 2); g.lineTo(S - m, m); g.lineTo(S - m, S - m); }
g.closePath();
g.fillStyle = '#' + h.toString(16).padStart(6, '0');
g.fill();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
});
_techMatCache.set(key, mat);
return mat;
}
function _meshMatForGhostFretDigit(spriteMat) {
let mb = spriteMat.userData.h3dGhostFretMeshMat;
if (!mb) {
mb = new T.MeshBasicMaterial({
map: spriteMat.map,
transparent: true,
depthTest: false,
depthWrite: false,
});
spriteMat.userData.h3dGhostFretMeshMat = mb;
}
return mb;
}
/**
* Convert any SpriteMaterial to a MeshBasicMaterial that shares its canvas
* texture, so technique markers can be applied to a rotatable PlaneGeometry
* mesh instead of a billboard Sprite. Cached on userData to avoid allocations.
*
* The cache is multi-entry: each pTechPlane mesh holds a Map<sm.map,
* clone> so a recycled mesh that's used for several techniques
* (hammer-on, palm-mute, harmonic, bend...) across frames keeps a
* clone for each one rather than disposing-and-recloning on every
* switch. With nStr-wide chords containing mixed PM/FH/HO/HP
* markers this collapses the per-frame allocation entirely while
* still being bounded the per-mesh Map has at most one entry per
* distinct technique × colour the mesh has ever been used for.
*/
function _spriteMat2MeshMat(mesh, sm) {
let perMesh = mesh.userData.h3dTechMeshMatCloneByMap;
if (perMesh) {
const hit = perMesh.get(sm.map);
if (hit) return hit;
}
let base = sm.userData.h3dTechMeshMat;
if (!base) {
base = new T.MeshBasicMaterial({
map: sm.map,
transparent: true,
// depthTest: false — cross-note Z ordering is handled by
// per-note renderOrderForLayerAtZ(...) calls rather than the
// depth buffer. This is necessary because close notes often use
// mGlow (depthWrite:false), so the depth buffer can't reliably
// occlude far markers near the hit line. With per-note renderOrder,
// far labels render first and close note geometry renders last,
// appearing on top without depthTest.
depthTest: false,
depthWrite: false,
// forceSinglePass accompanies EVERY transparent DoubleSide
// material in this file: without it, Three r158+ renders
// each such object in TWO passes (back side then front),
// setting material.needsUpdate on both — which forces a
// full getParameters/program-cache lookup per object per
// frame (profiled at ~4% of throttled main-thread time)
// and doubles the draw calls. The two-pass path exists to
// fix self-occlusion sorting on closed transparent meshes;
// all our DoubleSide materials are flat unlit quads
// (labels, rails, frames, lanes) where it buys nothing.
side: T.DoubleSide, forceSinglePass: true,
});
sm.userData.h3dTechMeshMat = base;
}
// First conversion for this mesh: the pTechPlane pool factory gave
// it a placeholder MeshBasicMaterial that the caller is about to
// overwrite with the clone below. Dispose it now — once
// mesh.material is reassigned the placeholder is orphaned and
// teardown's scene.traverse() pass can no longer reach it, so it
// would leak one GPU material per pooled mesh for the renderer's
// lifetime.
if (!perMesh && mesh.material && mesh.material !== base) {
mesh.material.dispose?.();
}
if (!perMesh) {
perMesh = new Map();
mesh.userData.h3dTechMeshMatCloneByMap = perMesh;
}
const clone = base.clone();
perMesh.set(sm.map, clone);
_techMeshMatClones.add(clone);
return clone;
}
function _disposeOpenStringPitchSprites() { function _disposeOpenStringPitchSprites() {
// Tuning-label materials are clones of cached txtMat() entries, so // Tuning-label materials are clones of cached txtMat() entries, so
@@ -4890,40 +4327,7 @@ import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
pbBeg = pbEnd = pbReportTick = function () {}; pbBeg = pbEnd = pbReportTick = function () {};
} }
function pool(parent, mk) {
const a = [];
let n = 0;
return {
get() {
if (n < a.length) {
const o = a[n++];
o.visible = true;
if (o.center && o.center.isVector2) o.center.set(0.5, 0.5);
return o;
}
const o = mk(); parent.add(o); a.push(o); n++; return o;
},
reset() { for (let i = 0; i < n; i++) a[i].visible = false; n = 0; },
// Pre-allocate `cap` slots at construction so the first dense
// playback frames don't pay the new-Mesh allocation cost
// mid-RAF (felt as a stall on 7/8-string charts where the
// visible-note count outruns the lazy-grow path). Lazy growth
// past `cap` still works — this is amortisation, not a cap.
//
// Coerce `cap` to a non-negative int32: a float would still
// work but a callsite passing `Infinity` (or `NaN`) would
// otherwise spin the while-loop until OOM. `cap | 0`
// truncates floats, clamps Infinity → 0, and turns NaN → 0;
// Math.max(0, …) keeps negatives out.
warm(cap) {
// Local rename to avoid shadowing the pool's outer
// `n` (the in-use index advanced by get() / reset()).
const targetLen = Math.max(0, cap | 0);
while (a.length < targetLen) { const o = mk(); o.visible = false; parent.add(o); a.push(o); }
return this;
},
};
}
// Returns indices of the longest consecutive run in a sorted integer // Returns indices of the longest consecutive run in a sorted integer
// array as { start, len } — `sorted[start..start+len)` is the run. // array as { start, len } — `sorted[start..start+len)` is the run.
+670
View File
@@ -0,0 +1,670 @@
// src/materials.js — h3d-carve-6
// Material builders extracted from screen.js N-section.
//
// screen.js usage (factory scope, once per createFactory() invocation):
// const _techMatCache = new Map();
// const { txtMat, pinchHarmonicMat, naturalHarmonicMat,
// palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
// triMat, bendChevronMat, darkenHex, slideArrowMat,
// _meshMatForGhostFretDigit, _spriteMat2MeshMat, pool } =
// createMaterialBuilders({
// getT, // () => T — live accessor (T is null before loadThree resolves)
// getTxtCache, // () => txtCache — live accessor (txtCache reassigned to {} on teardown)
// techMatCache, // stable const Map ref (screen.js factory-scope; teardown .values()/.clear())
// techMeshMatClones, // stable const Set ref (screen.js factory-scope; teardown .clear())
// });
//
// Surprises vs plan §4:
// • DI is 4 params, not just { getT } — declared before commit
// • _syncOpenStringPitchLabels cluster (20+ factory-scope deps) excluded from this cut
// • _techMatCache stays in screen.js factory scope so teardown can call .values()/.clear() directly
//
// Beyond-subst rewires (4):
// 1. Factory wrapper createMaterialBuilders({...})
// 2. T → const T = getT() at the top of each function that needs Three.js
// 3. txtCache[k] → const cache = getTxtCache(); cache[k]
// 4. _techMatCache → techMatCache / _techMeshMatClones → techMeshMatClones (DI param names)
// 5. _pmXSpriteMat, _fhXSpriteMat promoted to module closure (scope change only)
export function createMaterialBuilders({ getT, getTxtCache, techMatCache, techMeshMatClones }) {
// ── Private closure vars (were factory-scope lets in screen.js N-section) ─
let _pmXSpriteMat = null;
let _fhXSpriteMat = null;
// ── Text-sprite style presets ─────────────────────────────────────────────
// Each preset describes how a class of label is rasterised.
// Tweak per-class look here (font, outline color/width, source
// canvas size). `wide` toggles a long aspect ratio for multi-char
// labels (chord/section names, "↑1/2", "~~~").
//
// Knobs:
// font — full CSS font shorthand (weight + size + family)
// wideFont — same, used when caller passes wide=true
// srcH — source-canvas height in px (square; wide=4×).
// Keep power-of-two so WebGL1 / Three.js retain
// mipmaps + linear-mip-linear filtering — NPOT
// textures silently fall back to no-mipmap and
// shimmer at distance.
// stroke — outline color (null = no outline)
// strokeW — outline line-width in source-canvas px
// shadow — { color, blur, dx, dy } or null
const TXT_STYLES = {
// The two fret-number sets the user wants to pop hardest.
fretRow: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
noteFret: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
// Ghost-fret labels on the board projection: same weight/size/outline
// as noteFret, but uses textAlign='center'; textBaseline='middle'
// (the standard branch in txtMat) so the glyph is truly centred on
// the PlaneGeometry UV. inkCenterFret's actualBoundingBox path is
// intentionally NOT activated for this style — that path was designed
// for Sprites and shifts the canvas origin, which causes visible
// lower-left drift on Mesh + MeshBasicMaterial (UV-direct mapping).
ghostFret: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
// Chord names — gold script-style label, lighter outline keeps
// the colour readable.
chord: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Section banners ("Verse", "Chorus") — same as chord weight.
section: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Technique markers (pinch-harmonic icon, PM, AC, H/P/T, etc.).
technique: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Open-string "0" label on the note body itself.
open: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
};
function txtMat(text, col, wide, style) {
const T = getT(); // beyond-subst: T via live accessor
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
const sName = style || 'technique';
const k = sName + '|' + (wide ? 'W' : '') + text + '|' + col;
if (cache[k]) return cache[k];
const sp = TXT_STYLES[sName] || TXT_STYLES.technique;
const h = sp.srcH;
const str = String(text);
const font = wide ? sp.wideFont : sp.font;
let w = wide ? h * 4 : h;
if (!wide && sName === 'noteFret') {
// Wide labels (D#2, Bb3) need a canvas wider than srcH; cap so
// glyphs stay centred at (w/2, h/2) without edge clipping.
const probe = document.createElement('canvas').getContext('2d');
probe.font = font;
const tw = probe.measureText(str).width;
let pad = 0;
if (sp.stroke && sp.strokeW > 0) pad += sp.strokeW * 2;
if (sp.shadow) {
pad += Math.abs(sp.shadow.dx) + sp.shadow.blur * 2;
}
w = Math.min(12 * h, Math.max(h, Math.ceil(tw + pad)));
}
const c = document.createElement('canvas');
c.width = w; c.height = h;
const x = c.getContext('2d');
x.font = font;
// Fret / open-string digits: anchor from actualBoundingBox so the
// glyph sits at the true optical centre of the canvas (fixes
// sprites looking off-centre inside the board ghost and elsewhere).
const inkCenterFret = !wide && (sName === 'noteFret' || sName === 'open');
// Ghost fret labels live on a PlaneGeometry Mesh (UV-direct), not a Sprite
// billboard. Sprites tolerate slight canvas off-centering because Three.js
// centres them at their world position; a Mesh does not — the digit lands
// wherever it sits in UV space. Use the advance-width centre as the initial
// pen position and then correct for any ink asymmetry via actualBoundingBox.
const inkCenterGhost = !wide && sName === 'ghostFret';
let drawX = w / 2;
let drawY = h / 2;
if (inkCenterFret) {
x.textAlign = 'left';
x.textBaseline = 'alphabetic';
const m = x.measureText(str);
const L = m.actualBoundingBoxLeft;
const R = m.actualBoundingBoxRight;
const A = m.actualBoundingBoxAscent;
const D = m.actualBoundingBoxDescent;
if (
L != null && R != null && A != null && D != null &&
Number.isFinite(L) && Number.isFinite(R) &&
Number.isFinite(A) && Number.isFinite(D)
) {
const inkW = R - L;
drawX = (w - inkW) / 2 - L;
drawY = (h + A - D) / 2;
// Tab digits sit visually a hair low vs bbox (stroke/shadow);
// small canvas nudge keeps sprites centred on the board ghost.
if (sName === 'noteFret') drawY -= h * 0.028;
} else {
x.textAlign = 'center';
x.textBaseline = 'middle';
drawX = w / 2;
drawY = h / 2;
}
} else if (inkCenterGhost) {
// Alpha-weighted centroid approach on FILL-ONLY ink (no shadow, no
// stroke) to find the true ink centre of mass without contamination
// from the isotropic shadow blur. For Arial Black "1" the shadow from
// the thin upper-left flag bleeds leftward and cancels part of the
// rightward correction when we include it in the scan. Measuring fill
// alone isolates the actual glyph shape.
// 1. Draw fill-only (no shadow, no stroke) at (w/2, h/2) on temp canvas.
// 2. Compute Σ(px·alpha) / Σ(alpha) → ink centroid.
// 3. Shift drawX/drawY so centroid lands exactly at (w/2, h/2).
// Max 4 unique digits (14) → cache-miss runs at most 4 times ever.
x.textAlign = 'center';
x.textBaseline = 'middle';
try {
const tmpC = document.createElement('canvas');
tmpC.width = w; tmpC.height = h;
const tc = tmpC.getContext('2d');
tc.font = font;
tc.textAlign = 'center';
tc.textBaseline = 'middle';
// Deliberately NO shadow and NO stroke — shadow spreads isotropically
// and muddles the centroid; fill alone gives the cleanest reading.
tc.fillStyle = '#ffffff';
tc.fillText(str, w / 2, h / 2);
const id = tc.getImageData(0, 0, w, h).data;
// Alpha-weighted centroid — heavier ink pixels (thick vertical stem
// of "1") outweigh thin/sparse pixels (diagonal flag), producing the
// correct perceptual centre rather than the geometric bbox midpoint.
let sumX = 0, sumY = 0, sumA = 0;
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
const a = id[(py * w + px) * 4 + 3];
if (a > 4) { sumX += px * a; sumY += py * a; sumA += a; }
}
}
if (sumA > 0) {
// shift pen so centroid → canvas centre, then add a small
// extra rightward nudge (8 %) so the vertical stroke of
// narrow digits like "1" sits visually at gem centre rather
// than the advance-width centre (which may be slightly left
// of the dominant ink mass for Arial Black numerals).
drawX = w / 2 + (w / 2 - sumX / sumA) + w * 0.08;
drawY = h / 2 + (h / 2 - sumY / sumA);
}
} catch (_) { /* fallback: draw at (w/2, h/2) */ }
// x (real canvas) still has textAlign='center'; textBaseline='middle'
} else {
x.textAlign = 'center';
x.textBaseline = 'middle';
}
if (sp.shadow) {
x.shadowColor = sp.shadow.color;
x.shadowBlur = sp.shadow.blur;
x.shadowOffsetX = sp.shadow.dx;
x.shadowOffsetY = sp.shadow.dy;
}
if (sp.stroke && sp.strokeW > 0) {
x.lineJoin = 'round';
x.miterLimit = 2;
x.strokeStyle = sp.stroke;
x.lineWidth = sp.strokeW;
x.strokeText(str, drawX, drawY);
}
x.fillStyle = col;
x.fillText(str, drawX, drawY);
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
// depthTest:false means later geometry never *fails* depth
// against these sprites, but without depthWrite:false the
// sprites still write to the depth buffer (Three.js default
// is depthWrite:true even for SpriteMaterial). That can
// make subsequent sprites/labels vanish — match the
// pattern used by the other sprite materials in this file.
depthTest: false,
depthWrite: false,
});
cache[k] = mat;
return mat;
}
function pinchHarmonicMat(col) {
const T = getT(); // beyond-subst: T via live accessor
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
const baseCol = new T.Color(col != null ? col : '#ffd84d');
// v5 — compact concentric ellipses:
// 1. black outer border rx=0.430h ry=0.255h
// 2. string-color body rx=0.418h ry=0.232h
// 3. black inner ring rx=0.407h ry=0.218h
// 4. string-color inner rx=0.264h ry=0.218h
// 5. black center dot rx=0.134h ry=0.120h
const k = 'technique|pinchHarmonicIcon|rs2014-v5b|' + baseCol.getHexString();
if (cache[k]) return cache[k];
const h = 512;
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
const TAU = Math.PI * 2;
const colStr = `rgb(${Math.round(baseCol.r * 255)},${Math.round(baseCol.g * 255)},${Math.round(baseCol.b * 255)})`;
x.clearRect(0, 0, h, h);
x.save();
x.translate(h / 2, h / 2);
// Form 1 — black outer border
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.430, h * 0.255, 0, 0, TAU); x.fill();
// Form 2 — string-color main body
x.fillStyle = colStr;
x.beginPath(); x.ellipse(0, 0, h * 0.418, h * 0.232, 0, 0, TAU); x.fill();
// Form 3 — black inner ring
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.407, h * 0.218, 0, 0, TAU); x.fill();
// Form 4 — string-color inner spot (narrower)
x.fillStyle = colStr;
x.beginPath(); x.ellipse(0, 0, h * 0.2637, h * 0.218, 0, 0, TAU); x.fill();
// Form 5 — black center dot
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.134, h * 0.120, 0, 0, TAU); x.fill();
x.restore();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
});
cache[k] = mat;
return mat;
}
function naturalHarmonicMat() {
const T = getT(); // beyond-subst: T via live accessor
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
const k = 'technique|naturalHarmonicIcon|pink-ring-v3';
if (cache[k]) return cache[k];
const h = 256;
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
const cx = h / 2;
const cy = h / 2;
const TAU = Math.PI * 2;
x.clearRect(0, 0, h, h);
const glow = x.createRadialGradient(cx, cy, h * 0.03, cx, cy, h * 0.47);
glow.addColorStop(0, 'rgba(255,170,255,0.14)');
glow.addColorStop(0.55, 'rgba(0,0,0,0.22)');
glow.addColorStop(1, 'rgba(0,0,0,0)');
x.fillStyle = glow;
x.beginPath();
x.arc(cx, cy, h * 0.44, 0, TAU);
x.fill();
x.shadowColor = 'rgba(0,0,0,0.85)';
x.shadowBlur = 14;
x.fillStyle = 'rgba(255, 255, 255, 0.96)';
x.beginPath();
x.arc(cx, cy, h * 0.31, 0, TAU);
x.fill();
// Punch out the inner gap so the icon reads as a bright ring.
x.shadowBlur = 0;
x.globalCompositeOperation = 'destination-out';
x.beginPath();
x.arc(cx, cy, h * 0.20, 0, TAU);
x.fill();
x.globalCompositeOperation = 'source-over';
x.shadowColor = 'rgba(0, 0, 0, 0.7)';
x.shadowBlur = 10;
x.strokeStyle = 'rgba(255, 255, 255, 0.98)';
x.lineWidth = 8;
x.beginPath();
x.arc(cx, cy, h * 0.255, 0, TAU);
x.stroke();
x.shadowColor = 'rgba(0,0,0,0)';
x.fillStyle = 'rgba(255, 255, 255, 0.98)';
x.beginPath();
x.arc(cx, cy, h * 0.12, 0, TAU);
x.fill();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
opacity: 0.96,
});
cache[k] = mat;
return mat;
}
// Only two PM/FH variants exist (palm-mute = black-on-white,
// fret-hand mute = white-on-black). drawNote() hits muteXMat per
// muted chord-note per frame, so dense PM/FH passages were paying
// for a string concat + Map lookup on every call. Hoist both
// SpriteMaterial refs and short-circuit before touching the cache.
// They're populated lazily on first use; teardown still reaches
// them via the shared ``txtCache`` because muteXMat writes there.
function palmMuteXSpriteMat() {
return _pmXSpriteMat ?? (_pmXSpriteMat = muteXMat('#000000', '#ffffff'));
}
function fretHandMuteXSpriteMat() {
return _fhXSpriteMat ?? (_fhXSpriteMat = muteXMat('#ffffff', '#000000'));
}
function muteXMat(fillCol, strokeCol) {
const T = getT(); // beyond-subst: T via live accessor
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
const k = 'technique|muteX|v2|' + String(fillCol) + '|' + String(strokeCol);
if (cache[k]) return cache[k];
// lineCap:'square' gives flat tips. For a 45° diagonal the square-cap
// corners sit at ±outerW/2 rotated 45° from the endpoint — they land
// outside the canvas unless pad ≥ outerW/√2 (the common mistake is
// using outerW/2, which is too small). With the correct pad the white
// cap is fully inside the canvas and the border is visible at every tip.
const h = 512;
const outerW = 132, innerW = 114;
// pad must satisfy: pad ≥ outerW / Math.SQRT2 (≈ outerW × 0.707)
const pad = Math.ceil(outerW / Math.SQRT2) + 2; // 96
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
x.clearRect(0, 0, h, h);
x.lineCap = 'square';
// Draw each diagonal in its own stroke() call — caps of the two
// diagonals don't interact, and the white outer is drawn before the
// black inner so the border is clean at every edge and tip.
x.strokeStyle = strokeCol;
x.lineWidth = outerW;
x.beginPath(); x.moveTo(pad, pad); x.lineTo(h - pad, h - pad); x.stroke();
x.beginPath(); x.moveTo(h - pad, pad); x.lineTo(pad, h - pad); x.stroke();
x.strokeStyle = fillCol;
x.lineWidth = innerW;
x.beginPath(); x.moveTo(pad, pad); x.lineTo(h - pad, h - pad); x.stroke();
x.beginPath(); x.moveTo(h - pad, pad); x.lineTo(pad, h - pad); x.stroke();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
});
cache[k] = mat;
return mat;
}
// Technique-marker sprite materials (triangle / chevron). Keyed by a
// packed NUMBER, not a string — triMat/bendChevronMat are called from
// the drawNote hot path, so a string cache key would allocate per
// note per frame. Disposed in teardown. `hex` is a 0xRRGGBB number;
// the low nibble of the key tags the variant (0 ▲, 1 ▼, 3-6 chevron
// step-count) so triangle and chevron entries can't collide.
// Hammer-on / pull-off triangle marker: a white ▲ (up) / ▼ (down)
// with a thick border in the gem's string colour.
function triMat(up, hex) {
const T = getT(); // beyond-subst: T via live accessor
const h = (hex >>> 0) & 0xffffff;
const key = h * 16 + (up ? 0 : 1);
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
if (cached) return cached;
const S = 256, m = S * 0.15;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.beginPath();
if (up) { g.moveTo(S / 2, m); g.lineTo(S - m, S - m); g.lineTo(m, S - m); }
else { g.moveTo(S / 2, S - m); g.lineTo(S - m, m); g.lineTo(m, m); }
g.closePath();
g.lineJoin = 'round';
g.fillStyle = '#ffffff';
g.fill();
g.lineWidth = S * 0.122;
g.strokeStyle = '#' + (hex >>> 0).toString(16).padStart(6, '0');
g.stroke();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
});
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
return mat;
}
// Strength-of-bend chevron stack: `steps` (1-4) chevrons in the gem's
// string colour (chart-format bend notation — 1 per half-step).
function bendChevronMat(steps, hex) {
const T = getT(); // beyond-subst: T via live accessor
const h = (hex >>> 0) & 0xffffff;
const key = h * 16 + 2 + steps; // steps 1-4 → low nibble 3-6
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
if (cached) return cached;
const S = 256;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.strokeStyle = '#' + (hex >>> 0).toString(16).padStart(6, '0');
g.lineWidth = S * 0.10;
g.lineJoin = g.lineCap = 'round';
const padX = S * 0.18;
const rowH = S / steps;
const amp = Math.min(rowH * 0.55, S * 0.24);
for (let i = 0; i < steps; i++) {
const cy = (i + 0.5) * rowH;
g.beginPath();
g.moveTo(padX, cy + amp * 0.5);
g.lineTo(S / 2, cy - amp * 0.5);
g.lineTo(S - padX, cy + amp * 0.5);
g.stroke();
}
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
});
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
return mat;
}
// Darken a 0xRRGGBB colour by `factor` (0..1) for the slide-arrow
// marker — full string colour is too bright next to the gem.
function darkenHex(hex, factor) {
const h = (hex >>> 0) & 0xffffff;
const r = Math.round(((h >> 16) & 0xff) * factor);
const g = Math.round(((h >> 8) & 0xff) * factor);
const b = Math.round((h & 0xff) * factor);
return (r << 16) | (g << 8) | b;
}
// Slide-direction arrow (/): a filled triangle pointing toward the
// slide's destination fret, in the gem's (darkened) string colour.
// `hex` here is already the darkened colour — keep its own cache-key
// nibble range (8/9) so it can't collide with triMat (0/1) or
// bendChevronMat (3-6).
function slideArrowMat(pointRight, hex) {
const T = getT(); // beyond-subst: T via live accessor
const h = (hex >>> 0) & 0xffffff;
const key = h * 16 + 8 + (pointRight ? 0 : 1);
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
if (cached) return cached;
const S = 256, m = S * 0.18;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.beginPath();
if (pointRight) { g.moveTo(S - m, S / 2); g.lineTo(m, m); g.lineTo(m, S - m); }
else { g.moveTo(m, S / 2); g.lineTo(S - m, m); g.lineTo(S - m, S - m); }
g.closePath();
g.fillStyle = '#' + h.toString(16).padStart(6, '0');
g.fill();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
});
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
return mat;
}
function _meshMatForGhostFretDigit(spriteMat) {
const T = getT(); // beyond-subst: T via live accessor
let mb = spriteMat.userData.h3dGhostFretMeshMat;
if (!mb) {
mb = new T.MeshBasicMaterial({
map: spriteMat.map,
transparent: true,
depthTest: false,
depthWrite: false,
});
spriteMat.userData.h3dGhostFretMeshMat = mb;
}
return mb;
}
/**
* Convert any SpriteMaterial to a MeshBasicMaterial that shares its canvas
* texture, so technique markers can be applied to a rotatable PlaneGeometry
* mesh instead of a billboard Sprite. Cached on userData to avoid allocations.
*
* The cache is multi-entry: each pTechPlane mesh holds a Map<sm.map,
* clone> so a recycled mesh that's used for several techniques
* (hammer-on, palm-mute, harmonic, bend...) across frames keeps a
* clone for each one rather than disposing-and-recloning on every
* switch. With nStr-wide chords containing mixed PM/FH/HO/HP
* markers this collapses the per-frame allocation entirely while
* still being bounded — the per-mesh Map has at most one entry per
* distinct technique × colour the mesh has ever been used for.
*/
function _spriteMat2MeshMat(mesh, sm) {
const T = getT(); // beyond-subst: T via live accessor
let perMesh = mesh.userData.h3dTechMeshMatCloneByMap;
if (perMesh) {
const hit = perMesh.get(sm.map);
if (hit) return hit;
}
let base = sm.userData.h3dTechMeshMat;
if (!base) {
base = new T.MeshBasicMaterial({
map: sm.map,
transparent: true,
// depthTest: false — cross-note Z ordering is handled by
// per-note renderOrderForLayerAtZ(...) calls rather than the
// depth buffer. This is necessary because close notes often use
// mGlow (depthWrite:false), so the depth buffer can't reliably
// occlude far markers near the hit line. With per-note renderOrder,
// far labels render first and close note geometry renders last,
// appearing on top without depthTest.
depthTest: false,
depthWrite: false,
// forceSinglePass accompanies EVERY transparent DoubleSide
// material in this file: without it, Three r158+ renders
// each such object in TWO passes (back side then front),
// setting material.needsUpdate on both — which forces a
// full getParameters/program-cache lookup per object per
// frame (profiled at ~4% of throttled main-thread time)
// and doubles the draw calls. The two-pass path exists to
// fix self-occlusion sorting on closed transparent meshes;
// all our DoubleSide materials are flat unlit quads
// (labels, rails, frames, lanes) where it buys nothing.
side: T.DoubleSide, forceSinglePass: true,
});
sm.userData.h3dTechMeshMat = base;
}
// First conversion for this mesh: the pTechPlane pool factory gave
// it a placeholder MeshBasicMaterial that the caller is about to
// overwrite with the clone below. Dispose it now — once
// mesh.material is reassigned the placeholder is orphaned and
// teardown's scene.traverse() pass can no longer reach it, so it
// would leak one GPU material per pooled mesh for the renderer's
// lifetime.
if (!perMesh && mesh.material && mesh.material !== base) {
mesh.material.dispose?.();
}
if (!perMesh) {
perMesh = new Map();
mesh.userData.h3dTechMeshMatCloneByMap = perMesh;
}
const clone = base.clone();
perMesh.set(sm.map, clone);
techMeshMatClones.add(clone); // beyond-subst: _techMeshMatClones → techMeshMatClones
return clone;
}
function pool(parent, mk) {
const a = [];
let n = 0;
return {
get() {
if (n < a.length) {
const o = a[n++];
o.visible = true;
if (o.center && o.center.isVector2) o.center.set(0.5, 0.5);
return o;
}
const o = mk(); parent.add(o); a.push(o); n++; return o;
},
reset() { for (let i = 0; i < n; i++) a[i].visible = false; n = 0; },
// Pre-allocate `cap` slots at construction so the first dense
// playback frames don't pay the new-Mesh allocation cost
// mid-RAF (felt as a stall on 7/8-string charts where the
// visible-note count outruns the lazy-grow path). Lazy growth
// past `cap` still works — this is amortisation, not a cap.
//
// Coerce `cap` to a non-negative int32: a float would still
// work but a callsite passing `Infinity` (or `NaN`) would
// otherwise spin the while-loop until OOM. `cap | 0`
// truncates floats, clamps Infinity → 0, and turns NaN → 0;
// Math.max(0, …) keeps negatives out.
warm(cap) {
// Local rename to avoid shadowing the pool's outer
// `n` (the in-use index advanced by get() / reset()).
const targetLen = Math.max(0, cap | 0);
while (a.length < targetLen) { const o = mk(); o.visible = false; parent.add(o); a.push(o); }
return this;
},
};
}
return {
txtMat, pinchHarmonicMat, naturalHarmonicMat,
palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
triMat, bendChevronMat, darkenHex, slideArrowMat,
_meshMatForGhostFretDigit, _spriteMat2MeshMat, pool,
};
}
+338
View File
@@ -0,0 +1,338 @@
// Contract tests for h3d-carve-6: src/materials.js (createMaterialBuilders).
//
// Class-killer tests — each names the mutation that makes it RED.
// Source-scan + vm-sandbox pattern (no canvas, no WebGL lifecycle).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const MATERIALS_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'materials.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
function src() { return fs.readFileSync(MATERIALS_JS, 'utf8'); }
function screenSrc() { return fs.readFileSync(SCREEN_JS, 'utf8'); }
// Strip block and line comments from JS source for identifier-presence checks.
function stripComments(s) {
return s
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/\/\/[^\n]*/g, ''); // line comments
}
// ── vm sandbox helpers ──────────────────────────────────────────────────────
// Evaluate materials.js in a sandbox and return the createMaterialBuilders export.
// Three.js is stubbed; canvas ops are no-ops.
function loadFactory() {
const raw = src();
// materials.js uses `export function` — strip the `export` keyword for vm.
const code = raw.replace(/^export\s+/m, '');
const sandbox = {
document: {
createElement: () => ({
getContext: () => ({
font: '', textAlign: '', textBaseline: '',
clearRect() {}, beginPath() {}, moveTo() {}, lineTo() {},
closePath() {}, fill() {}, stroke() {}, fillText() {},
strokeText() {}, save() {}, restore() {}, translate() {},
ellipse() {}, arc() {}, createRadialGradient: () => ({
addColorStop() {},
}),
measureText: () => ({
width: 10,
actualBoundingBoxLeft: 0, actualBoundingBoxRight: 10,
actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2,
}),
getImageData: (x, y, w, h) => ({ data: new Uint8Array(w * h * 4) }),
fillStyle: '', strokeStyle: '', lineWidth: 0,
lineJoin: '', lineCap: '', shadowColor: '',
shadowBlur: 0, shadowOffsetX: 0, shadowOffsetY: 0,
miterLimit: 0, globalCompositeOperation: '',
}),
width: 0, height: 0,
}),
},
Math,
Number,
Map,
Set,
String,
Object,
Array,
};
vm.createContext(sandbox);
vm.runInContext(code, sandbox, { filename: MATERIALS_JS });
return sandbox.createMaterialBuilders;
}
// Build a minimal DI bundle for testing factory internals.
function makeDI(overrides = {}) {
let txtCache = {};
const techMatCache = new Map();
const techMeshMatClones = new Set();
const T = {
SpriteMaterial: class { constructor(o) { Object.assign(this, o); this.map = o.map; this.userData = {}; } clone() { const c = new T.SpriteMaterial(this); return c; } dispose() {} },
MeshBasicMaterial: class { constructor(o) { Object.assign(this, o); this.userData = {}; } clone() { return new T.MeshBasicMaterial(this); } dispose() {} },
CanvasTexture: class { constructor(c) { this._c = c; } dispose() {} },
Color: class { constructor(v) { this.r = 1; this.g = 1; this.b = 1; } getHexString() { return 'ffffff'; } },
DoubleSide: 2,
};
return {
getT: () => T,
getTxtCache: () => txtCache,
techMatCache,
techMeshMatClones,
_resetCache: () => { txtCache = {}; },
...overrides,
};
}
// ── 1. Module exports createMaterialBuilders ────────────────────────────────
test('src/materials.js exports createMaterialBuilders', () => {
// Mutation: rename to createMaterials → RED.
assert.match(src(), /export\s+function\s+createMaterialBuilders\s*\(/, 'must export createMaterialBuilders');
});
// ── 2. Return set covers all expected symbols ───────────────────────────────
test('createMaterialBuilders returns all required symbols', () => {
// Mutation: remove `pool` from return {...} → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const result = createMaterialBuilders(di);
const EXPECTED = [
'txtMat', 'pinchHarmonicMat', 'naturalHarmonicMat',
'palmMuteXSpriteMat', 'fretHandMuteXSpriteMat', 'muteXMat',
'triMat', 'bendChevronMat', 'darkenHex', 'slideArrowMat',
'_meshMatForGhostFretDigit', '_spriteMat2MeshMat', 'pool',
];
for (const sym of EXPECTED) {
assert.ok(sym in result, `createMaterialBuilders must return ${sym}`);
}
});
// ── 3. Stranded-caller: every returned symbol in screen.js destructure ───────
test('every symbol returned by createMaterialBuilders is in the screen.js destructure', () => {
// Mutation: add _newHelper to return{} but not the screen.js destructure → leaked=['_newHelper'] → RED.
const matSrc = stripComments(src());
const scr = screenSrc();
// Extract the module-level return block — anchored by the first symbol
// (txtMat) so pool's inner return { get, reset, warm } can't shadow it.
// Mutation: remove txtMat from the return → anchor fails → RED.
const retMatch = matSrc.match(/return\s*\{\s*\n\s*(txtMat\s*,[\s\S]+?)\n\s*\};/);
assert.ok(retMatch, 'createMaterialBuilders must end with return { txtMat, ... }');
const returned = new Set(
retMatch[1].split(',').map(s => s.trim()).filter(Boolean)
);
// Extract destructured names from the screen.js tombstone.
const dsMatch = scr.match(/const\s*\{([^}]+)\}\s*=\s*createMaterialBuilders\s*\(/);
assert.ok(dsMatch, 'screen.js must have createMaterialBuilders destructure');
const destructured = new Set(
dsMatch[1].split(',').map(s => s.trim().split(/\s+/).pop()).filter(Boolean)
);
const leaked = [...returned].filter(sym => !destructured.has(sym));
assert.deepStrictEqual(leaked, [],
'createMaterialBuilders returns symbols not in screen.js destructure: ' + leaked.join(', '));
});
// ── 4. Stale-private guard: _pm/_fhXSpriteMat not bare in screen.js ──────────
test('_pmXSpriteMat and _fhXSpriteMat do not appear bare in screen.js IIFE body', () => {
// Mutation: reference _pmXSpriteMat directly in screen.js body → RED.
// These two let vars were factory-scope in the old N-section; now they live
// in the materials.js module closure and must not appear in screen.js.
const scr = screenSrc();
let scrStripped = scr.replace(/^import\s+.*\n/gm, '');
scrStripped = stripComments(scrStripped);
scrStripped = scrStripped.replace(/const\s*\{[^}]+\}\s*=\s*createMaterialBuilders\s*\([^)]*\)\s*;/, '');
for (const sym of ['_pmXSpriteMat', '_fhXSpriteMat']) {
assert.doesNotMatch(scrStripped, new RegExp('\\b' + sym + '\\b'),
`screen.js must not reference private module var ${sym}`);
}
});
// ── 5. DI: T is accessed via getT() at call time, not factory construction ───
test('material builder functions call getT() at call time', () => {
// Mutation: top-level `const T = getT()` at factory construction → RED.
// Each function must call getT() inside its own body.
const s = src();
// Must NOT have `const T = getT()` at the top level of the factory
// (outside any function body). Check that it's scoped inside function bodies.
assert.doesNotMatch(
s,
/createMaterialBuilders\s*\([^)]*\)\s*\{[^}]*const T = getT\(\)/,
'T must not be captured at factory construction — only inside function bodies'
);
// Each T-using function must contain getT() in its body.
for (const fn of ['txtMat', 'pinchHarmonicMat', 'naturalHarmonicMat', 'muteXMat',
'triMat', 'bendChevronMat', 'slideArrowMat',
'_meshMatForGhostFretDigit', '_spriteMat2MeshMat']) {
const fnIdx = s.indexOf(`function ${fn}(`);
assert.ok(fnIdx !== -1, `${fn} must exist in materials.js`);
// Find the body of this function (brace-balanced).
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.match(body, /const T = getT\(\)/, `${fn} must call getT() inside its body`);
}
});
// ── 6. DI: txtCache is accessed via getTxtCache() inside each function ────────
test('txtCache-using functions access cache via getTxtCache()', () => {
// Mutation: use bare `txtCache[k]` instead → RED (and also a runtime bug).
const s = stripComments(src());
// The module code must never reference a bare `txtCache` identifier.
assert.doesNotMatch(s, /\btxtCache\b/, 'materials.js code must not reference bare txtCache — use getTxtCache()');
// Each cache-using function must call getTxtCache().
for (const fn of ['txtMat', 'pinchHarmonicMat', 'naturalHarmonicMat', 'muteXMat']) {
assert.ok(s.includes(`function ${fn}(`), `${fn} must exist`);
const fnIdx = s.indexOf(`function ${fn}(`);
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.match(body, /getTxtCache\(\)/, `${fn} must call getTxtCache() inside its body`);
}
});
// ── 7. DI: techMatCache param used (not bare _techMatCache) ──────────────────
test('triMat/bendChevronMat/slideArrowMat use techMatCache DI param', () => {
// Mutation: use `_techMatCache.get(key)` → RED (and runtime ReferenceError).
const s = stripComments(src());
assert.doesNotMatch(s, /\b_techMatCache\b/, 'materials.js code must not reference _techMatCache — use DI param techMatCache');
});
// ── 8. DI: techMeshMatClones param used (not bare _techMeshMatClones) ─────────
test('_spriteMat2MeshMat uses techMeshMatClones DI param', () => {
// Mutation: use `_techMeshMatClones.add(clone)` → RED.
const s = stripComments(src());
assert.doesNotMatch(s, /\b_techMeshMatClones\b/, 'materials.js code must not reference _techMeshMatClones — use DI param');
});
// ── 9. TXT_STYLES literal pin ────────────────────────────────────────────────
test('TXT_STYLES presets match known-good values', () => {
// Mutation: change technique.srcH from 128 to 256 → RED.
const s = src();
// fretRow / noteFret / ghostFret — srcH 256, strokeW 18
for (const key of ['fretRow', 'noteFret', 'ghostFret']) {
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}srcH:\\s*256'), `${key}.srcH must be 256`);
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}strokeW:\\s*18'), `${key}.strokeW must be 18`);
}
// All three large presets share this stroke color.
assert.ok(s.includes("stroke: '#0a1018'"), "fretRow/noteFret/ghostFret stroke must be '#0a1018'");
// chord / section / technique / open — srcH 128, strokeW 6
for (const key of ['chord', 'section', 'technique', 'open']) {
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}srcH:\\s*128'), `${key}.srcH must be 128`);
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}strokeW:\\s*6'), `${key}.strokeW must be 6`);
}
});
// ── 10. darkenHex is pure (no T dependency) ──────────────────────────────────
test('darkenHex has no getT() call', () => {
// Mutation: add getT() call → RED (no T needed for a pure bit-twiddler).
const s = src();
const fnIdx = s.indexOf('function darkenHex(');
assert.ok(fnIdx !== -1, 'darkenHex must exist');
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.doesNotMatch(body, /getT\(\)/, 'darkenHex must not call getT() — it is a pure bit-twiddler');
});
// ── 11. pool has no getT() call ──────────────────────────────────────────────
test('pool has no getT() call', () => {
// Mutation: add getT() call → RED (pool creates no Three.js objects).
const s = src();
const fnIdx = s.indexOf('function pool(parent, mk)');
assert.ok(fnIdx !== -1, 'pool must exist in materials.js');
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.doesNotMatch(body, /getT\(\)/, 'pool must not call getT() — it is a pure container factory');
});
// ── 12. screen.js tombstone is present ───────────────────────────────────────
test('screen.js has the h3d-carve-6 tombstone comment', () => {
// Mutation: delete the tombstone block → RED.
assert.match(screenSrc(), /h3d-carve-6: material builders/, 'tombstone comment must be present');
assert.match(screenSrc(), /const _techMatCache = new Map\(\)/, '_techMatCache must be hoisted to screen.js factory scope');
});
// ── 13. _techMatCache NOT declared in materials.js code ───────────────────────
test('_techMatCache is not declared in materials.js code', () => {
// Mutation: move const _techMatCache = new Map() into materials.js → RED
// (teardown accesses it directly via the factory-scope const).
assert.doesNotMatch(stripComments(src()), /const _techMatCache\s*=/,
'_techMatCache must not be declared in materials.js — it stays in screen.js factory scope for teardown');
});
// ── 14. txtMat caches and returns a SpriteMaterial ───────────────────────────
test('txtMat creates and caches a SpriteMaterial on cache miss', () => {
// Mutation: remove `cache[k] = mat` → txtMat allocates a new material every call → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const { txtMat } = createMaterialBuilders(di);
const m1 = txtMat('5', '#ff0000', false, 'noteFret');
assert.ok(m1, 'txtMat must return a material');
const m2 = txtMat('5', '#ff0000', false, 'noteFret');
assert.strictEqual(m1, m2, 'txtMat must return the same instance on cache hit');
const m3 = txtMat('5', '#00ff00', false, 'noteFret');
assert.notStrictEqual(m1, m3, 'different color → different material');
});
// ── 15. triMat cache uses techMatCache (DI param), not a local Map ───────────
test('triMat stores results in techMatCache and returns cached entry', () => {
// Mutation: return a fresh material every call → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const { triMat } = createMaterialBuilders(di);
assert.strictEqual(di.techMatCache.size, 0, 'techMatCache starts empty');
const m1 = triMat(true, 0xff0000);
assert.strictEqual(di.techMatCache.size, 1, 'triMat must populate techMatCache');
const m2 = triMat(true, 0xff0000);
assert.strictEqual(m1, m2, 'triMat cache hit must return same object');
});
// ── 16. pool warm() pre-allocates and warm() is idempotent ───────────────────
test('pool.warm pre-allocates up to cap and is idempotent past cap', () => {
// Mutation: remove while-loop in warm() → warm() allocates nothing → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const { pool } = createMaterialBuilders(di);
const parent = { add() {} };
let mkCount = 0;
const p = pool(parent, () => { mkCount++; return { visible: true, center: null }; });
p.warm(5);
assert.strictEqual(mkCount, 5, 'warm(5) must pre-allocate 5 objects');
p.warm(3); // below current length — must be idempotent
assert.strictEqual(mkCount, 5, 'warm(3) after warm(5) must not allocate more');
p.warm(8);
assert.strictEqual(mkCount, 8, 'warm(8) after warm(5) must allocate 3 more');
});
@@ -104,6 +104,14 @@ function loadHighway3dStatics() {
createBgControl: () => ({ createBgControl: () => ({
_pcAcquire() {}, _pcRelease() {}, _pcAcquire() {}, _pcRelease() {},
}), }),
// h3d-carve-6: material builders moved to src/materials.js.
createMaterialBuilders: () => ({
txtMat() {}, pinchHarmonicMat() {}, naturalHarmonicMat() {},
palmMuteXSpriteMat() {}, fretHandMuteXSpriteMat() {}, muteXMat() {},
triMat() {}, bendChevronMat() {}, darkenHex: (hex) => hex, slideArrowMat() {},
_meshMatForGhostFretDigit() {}, _spriteMat2MeshMat() {},
pool: () => ({ get() {}, reset() {}, warm() { return this; } }),
}),
}; };
vm.createContext(sandbox); vm.createContext(sandbox);
vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS }); vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS });
+6 -2
View File
@@ -15,6 +15,8 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// h3d-carve-6: pool() moved to src/materials.js; warm() call-sites remain in screen.js.
const MATERIALS_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'materials.js');
// Brace-balanced extraction so warm() / coercion checks scope to the // Brace-balanced extraction so warm() / coercion checks scope to the
// `function pool(...)` body (matching the helper shape used in // `function pool(...)` body (matching the helper shape used in
@@ -39,7 +41,8 @@ function extractBlock(src, signature) {
} }
test('pool factory exposes warm(cap)', () => { test('pool factory exposes warm(cap)', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8'); // h3d-carve-6: pool() lives in src/materials.js (createMaterialBuilders).
const src = fs.readFileSync(MATERIALS_JS, 'utf8');
// The pool() factory's return object must include a `warm(cap)` // The pool() factory's return object must include a `warm(cap)`
// method. Scope the match to the factory body so an unrelated // method. Scope the match to the factory body so an unrelated
// future `warm(cap)` helper elsewhere in the file can't satisfy // future `warm(cap)` helper elsewhere in the file can't satisfy
@@ -49,7 +52,8 @@ test('pool factory exposes warm(cap)', () => {
}); });
test('pool.warm coerces cap to a non-negative integer', () => { test('pool.warm coerces cap to a non-negative integer', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8'); // h3d-carve-6: pool() lives in src/materials.js (createMaterialBuilders).
const src = fs.readFileSync(MATERIALS_JS, 'utf8');
// Same scoping discipline as above — the coercion must live // Same scoping discipline as above — the coercion must live
// inside the pool factory's warm() body, not anywhere else. // inside the pool factory's warm() body, not anywhere else.
const poolBody = extractBlock(src, 'function pool(parent, mk)'); const poolBody = extractBlock(src, 'function pool(parent, mk)');