mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 11:08:32 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
733d772154
commit
e6b2f86068
@@ -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": {
|
||||||
"routes": "routes.py",
|
"html": "settings.html",
|
||||||
"tour": "tour.json"
|
"category": "graphics",
|
||||||
|
"server_files": [
|
||||||
|
"plugin_uploads/highway_3d/current.mp4",
|
||||||
|
"plugin_uploads/highway_3d/current.webm"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"routes": "routes.py",
|
||||||
|
"tour": "tour.json"
|
||||||
}
|
}
|
||||||
|
|||||||
+14705
-15301
File diff suppressed because it is too large
Load Diff
@@ -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 (1–4) → 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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)');
|
||||||
|
|||||||
Reference in New Issue
Block a user