refactor(h3d-carve-8): extract Q-helpers (lighting/FX) → src/fx.js

Six functions extracted from Q-section of screen.js into a new
src/fx.js ES module behind a createFx({...}) factory:
  _h3dHexOrDefault, _applyCinematic, _timingHex,
  _sparkBurst, _sparkUpdate, _bloomEnsure

buildBoard (330 lines) deferred to plan §3 row 16 (P-section cut):
its write-back surface spans 9 factory-scope vars owned by P/U/Y —
premature extraction would require ~50 DI params. See plans/highway3d-carve.md.

DI surface (26 beyond-subst rewires):
  BG_DEFAULTS, K          — plain IIFE-scope constants
  getT                    — live-accessor (Three.js, lazy)
  getAmbLight/getDirLight/getCinematic/getTimingFx — live-accessors
  getSparkPts/.../getSparkN — live-accessors (per-call, not init-cached)
  setSparkPts/setSparkPos/setSparkVel/setSparkCol/setSparkLife — setters
  getComposer/setComposer — getter+setter (_bloomEnsure lazy reassigns)
  getBloomLoad/setBloomLoad/getBloomPass/setBloomPass — getter+setter pairs
  getBloomW/setBloomW/getBloomH/setBloomH — getter+setter pairs
  getRen/getScene/getCam/getHighwayCanvas — live-accessors (null pre-init)
  canvasSize              — factory function ref (stable)

Class-killers (all mutations verified RED before commit):
  - _timingHex EARLY branch removed → RED
  - _sparkBurst stale init-time sparkPos cache → RED (live-accessor required)
  - setBloomLoad silenced → RED (synchronous write-back path)
  - setComposer getComposer init-cached → RED (live getComposer() required)
  GAP declared: setComposer(comp) inside .then() (async, no import() mock)

Tests: 1266/1268 (2 pre-existing failures unrelated to this cut)
plugin.json: 3.43.0 → 3.44.0

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 15:37:46 +02:00
co-authored by Claude Sonnet 4.6
parent 4b87db0c17
commit 9b98d78cbe
5 changed files with 426 additions and 88 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.43.0",
"version": "3.44.0",
"type": "visualization",
"scriptType": "module",
"bundled": true,
+21 -87
View File
@@ -13,6 +13,7 @@ import { _bcIsDesktop, _bcCreateController, _bcLoadSettings, _bcFfIdx } from './
import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
import { createMaterialBuilders } from './src/materials.js'; // h3d-carve-6
import { createOverlay } from './src/overlay.js'; // h3d-carve-7
import { createFx } from './src/fx.js'; // h3d-carve-8
(function () {
'use strict';
@@ -6378,93 +6379,26 @@ import { createOverlay } from './src/overlay.js'; // h3d-carve-7
else _laneTargetColor = new T.Color(laneLit);
}
/* ── Fretboard (static geometry) ────────────────────────────────── */
function _h3dHexOrDefault(hexStr, defHex) {
const d = defHex || BG_DEFAULTS.nutColor;
const s = (typeof hexStr === 'string' && /^#[0-9a-fA-F]{6}$/.test(hexStr.trim()))
? hexStr.trim().toLowerCase()
: d;
return parseInt(s.slice(1), 16);
}
// Cinematic lighting (#2): darken ambient so emissive gems have a dark
// surround to pop against; strengthen the key light for modelling.
// Toggle via the 'cinematic' setting so it's directly comparable.
function _applyCinematic() {
if (!ambLight || !dirLight) return;
ambLight.intensity = _cinematic ? 0.45 : 0.85;
dirLight.intensity = _cinematic ? 1.15 : 0.8;
}
// #5 early/late: tint the hit feedback by timing — on-time green, early cyan,
// late amber. Falls back to green when timing is unknown (pure-provider path).
function _timingHex(ts) {
if (!_timingFx || !ts || ts === 'OK') return 0x22ff88;
if (ts === 'EARLY') return 0x35d6ff;
if (ts === 'LATE') return 0xffb84d;
return 0x22ff88;
}
function _sparkBurst(x, y, z, hex, count) {
if (!_sparkPts || count <= 0) return;
const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255;
let made = 0;
for (let i = 0; i < _SPARK_N && made < count; i++) {
if (_sparkLife[i] > 0) continue;
const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K;
_sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z;
_sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55;
_sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b;
_sparkLife[i] = 0.30 + Math.random() * 0.16; made++;
}
}
function _sparkUpdate(dt) {
if (!_sparkPts) return;
const grav = 55 * K; let any = false;
for (let i = 0; i < _SPARK_N; i++) {
if (_sparkLife[i] <= 0) continue;
const j = i * 3;
_sparkLife[i] -= dt;
if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; }
any = true;
_sparkVel[j + 1] -= grav * dt;
_sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt;
const fade = 1 - Math.min(1, dt * 3.2);
_sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade;
}
_sparkPts.geometry.attributes.position.needsUpdate = true;
_sparkPts.geometry.attributes.color.needsUpdate = true;
_sparkPts.visible = any;
}
// #4 Bloom: lazy-load the vendored postprocessing addons and build an
// EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES). Returns
// the composer once ready, or null (caller falls back to a direct render).
function _bloomEnsure() {
if (_composer) return _composer;
if (_bloomLoad || !ren || !scene || !cam) return null;
const A = '/static/vendor/three/addons/';
_bloomLoad = Promise.all([
import(A + 'postprocessing/EffectComposer.js'),
import(A + 'postprocessing/RenderPass.js'),
import(A + 'postprocessing/UnrealBloomPass.js'),
import(A + 'postprocessing/OutputPass.js'),
]).then(([EC, RP, UB, OP]) => {
try {
const sz = canvasSize(highwayCanvas) || { w: 1280, h: 720 };
const w = Math.max(2, sz.w | 0), h = Math.max(2, sz.h | 0);
// Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing
// survives the bloom path — EffectComposer's default target has no
// `samples`, which is why bloom-on looked jagged (worst on non-Retina
// DPR1 displays that have no supersampling cushion).
const _bloomRT = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 });
const comp = new EC.EffectComposer(ren, _bloomRT);
comp.addPass(new RP.RenderPass(scene, cam));
_bloomPass = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms)
comp.addPass(_bloomPass);
comp.addPass(new OP.OutputPass());
comp.setSize(w, h);
_bloomW = w; _bloomH = h; _composer = comp;
} catch (e) { console.warn('[3D-Hwy] bloom init failed', e); _composer = null; }
}).catch((e) => console.warn('[3D-Hwy] bloom modules failed', e));
return null;
}
/* ── h3d-carve-8: Q-helpers (lighting/FX) → src/fx.js ─────────── */
/* buildBoard deferred: see plans/highway3d-carve.md §3 row 16. */
const { _h3dHexOrDefault, _applyCinematic, _timingHex, _sparkBurst, _sparkUpdate, _bloomEnsure } = createFx({
BG_DEFAULTS, K,
getT: () => T,
getAmbLight: () => ambLight, getDirLight: () => dirLight, getCinematic: () => _cinematic,
getTimingFx: () => _timingFx,
getSparkPts: () => _sparkPts, setSparkPts: (v) => { _sparkPts = v; }, getSparkN: () => _SPARK_N,
getSparkPos: () => _sparkPos, setSparkPos: (v) => { _sparkPos = v; },
getSparkVel: () => _sparkVel, setSparkVel: (v) => { _sparkVel = v; },
getSparkCol: () => _sparkCol, setSparkCol: (v) => { _sparkCol = v; },
getSparkLife: () => _sparkLife, setSparkLife: (v) => { _sparkLife = v; },
getComposer: () => _composer, setComposer: (v) => { _composer = v; },
getBloomLoad: () => _bloomLoad, setBloomLoad: (v) => { _bloomLoad = v; },
getBloomPass: () => _bloomPass, setBloomPass: (v) => { _bloomPass = v; },
getBloomW: () => _bloomW, setBloomW: (v) => { _bloomW = v; },
getBloomH: () => _bloomH, setBloomH: (v) => { _bloomH = v; },
getRen: () => ren, getScene: () => scene, getCam: () => cam, getHighwayCanvas: () => highwayCanvas,
canvasSize,
});
function buildBoard() {
// Dispose before clearing (traverse: nut/headstock may live in a Group).
while (fretG.children.length) {
+142
View File
@@ -0,0 +1,142 @@
// h3d-carve-8: Q-section partial — lighting/FX utilities.
// buildBoard stays in screen.js (deferred to plan §3 row 16, P-section cut)
// because its write-back surface spans 9 factory-scope vars that P/U/Y also
// own; premature extraction would require a ~50-param DI. See plans/highway3d-carve.md.
//
// DI surface (per-call live-accessors — never cache at factory init):
// BG_DEFAULTS, K — plain IIFE-scope constants
// getT — live-accessor (Three.js, lazy-loaded)
// getAmbLight/getDirLight — live-accessors (null until initScene)
// getCinematic/getTimingFx — live-accessors (toggle flags)
// getSparkPts/setSparkPts — live-accessor + setter (Points object, set by buildBoard)
// getSparkN/getSparkPos/… — live-accessors for particle buffers (element-mutated in place)
// getComposer/setComposer — getter+setter (lazy-assigned inside _bloomEnsure)
// getBloomLoad/setBloomLoad — getter+setter (Promise, assigned inside _bloomEnsure)
// getBloomPass/setBloomPass — getter+setter (pass object, assigned inside _bloomEnsure)
// getBloomW/setBloomW/H — getter+setter (dimensions, assigned inside _bloomEnsure)
// getRen/getScene/getCam/getHighwayCanvas — live-accessors (null until initScene)
// canvasSize — factory-scope function ref (stable)
export function createFx({
BG_DEFAULTS, K,
getT,
getAmbLight, getDirLight, getCinematic,
getTimingFx,
getSparkPts, setSparkPts, getSparkN,
getSparkPos, setSparkPos, getSparkVel, setSparkVel,
getSparkCol, setSparkCol, getSparkLife, setSparkLife,
getComposer, setComposer,
getBloomLoad, setBloomLoad,
getBloomPass, setBloomPass,
getBloomW, setBloomW, getBloomH, setBloomH,
getRen, getScene, getCam, getHighwayCanvas,
canvasSize,
}) {
function _h3dHexOrDefault(hexStr, defHex) {
// VERBATIM MOVE. BG_DEFAULTS from plain DI param.
const d = defHex || BG_DEFAULTS.nutColor;
const s = (typeof hexStr === 'string' && /^#[0-9a-fA-F]{6}$/.test(hexStr.trim()))
? hexStr.trim().toLowerCase()
: d;
return parseInt(s.slice(1), 16);
}
// Cinematic lighting (#2): darken ambient so emissive gems have a dark
// surround to pop against; strengthen the key light for modelling.
// Toggle via the 'cinematic' setting so it's directly comparable.
function _applyCinematic() {
// VERBATIM MOVE. DI rewire: ambLight/dirLight/_cinematic from live-accessors.
const ambLight = getAmbLight(), dirLight = getDirLight(), _cinematic = getCinematic();
if (!ambLight || !dirLight) return;
ambLight.intensity = _cinematic ? 0.45 : 0.85;
dirLight.intensity = _cinematic ? 1.15 : 0.8;
}
// #5 early/late: tint the hit feedback by timing — on-time green, early cyan,
// late amber. Falls back to green when timing is unknown (pure-provider path).
function _timingHex(ts) {
// VERBATIM MOVE. DI rewire: _timingFx from getTimingFx().
if (!getTimingFx() || !ts || ts === 'OK') return 0x22ff88;
if (ts === 'EARLY') return 0x35d6ff;
if (ts === 'LATE') return 0xffb84d;
return 0x22ff88;
}
function _sparkBurst(x, y, z, hex, count) {
// VERBATIM MOVE. DI rewire: spark vars from live-accessors (per-call,
// not cached at factory init — buildBoard may reassign the refs).
const _sparkPts = getSparkPts();
if (!_sparkPts || count <= 0) return;
const _sparkPos = getSparkPos(), _sparkVel = getSparkVel(), _sparkCol = getSparkCol(), _sparkLife = getSparkLife();
const _SPARK_N = getSparkN();
const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255;
let made = 0;
for (let i = 0; i < _SPARK_N && made < count; i++) {
if (_sparkLife[i] > 0) continue;
const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K;
_sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z;
_sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55;
_sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b;
_sparkLife[i] = 0.30 + Math.random() * 0.16; made++;
}
}
function _sparkUpdate(dt) {
// VERBATIM MOVE. DI rewire: spark vars from live-accessors (per-call).
const _sparkPts = getSparkPts();
if (!_sparkPts) return;
const _sparkPos = getSparkPos(), _sparkVel = getSparkVel(), _sparkCol = getSparkCol(), _sparkLife = getSparkLife();
const _SPARK_N = getSparkN();
const grav = 55 * K; let any = false;
for (let i = 0; i < _SPARK_N; i++) {
if (_sparkLife[i] <= 0) continue;
const j = i * 3;
_sparkLife[i] -= dt;
if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; }
any = true;
_sparkVel[j + 1] -= grav * dt;
_sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt;
const fade = 1 - Math.min(1, dt * 3.2);
_sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade;
}
_sparkPts.geometry.attributes.position.needsUpdate = true;
_sparkPts.geometry.attributes.color.needsUpdate = true;
_sparkPts.visible = any;
}
// #4 Bloom: lazy-load the vendored postprocessing addons and build an
// EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES). Returns
// the composer once ready, or null (caller falls back to a direct render).
function _bloomEnsure() {
// VERBATIM MOVE. DI rewire: all factory-scope vars via get/set accessors.
// _composer, _bloomLoad, _bloomPass, _bloomW, _bloomH are REASSIGNED here
// so setters are required — these must NOT silently become locals.
if (getComposer()) return getComposer();
const ren = getRen(), scene = getScene(), cam = getCam();
if (getBloomLoad() || !ren || !scene || !cam) return null;
const T = getT(), highwayCanvas = getHighwayCanvas();
const A = '/static/vendor/three/addons/';
setBloomLoad(Promise.all([
import(A + 'postprocessing/EffectComposer.js'),
import(A + 'postprocessing/RenderPass.js'),
import(A + 'postprocessing/UnrealBloomPass.js'),
import(A + 'postprocessing/OutputPass.js'),
]).then(([EC, RP, UB, OP]) => {
try {
const sz = canvasSize(highwayCanvas) || { w: 1280, h: 720 };
const w = Math.max(2, sz.w | 0), h = Math.max(2, sz.h | 0);
// Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing
// survives the bloom path — EffectComposer's default target has no
// `samples`, which is why bloom-on looked jagged (worst on non-Retina
// DPR1 displays that have no supersampling cushion).
const _bloomRT = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 });
const comp = new EC.EffectComposer(ren, _bloomRT);
comp.addPass(new RP.RenderPass(scene, cam));
const bp = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms)
setBloomPass(bp);
comp.addPass(bp);
comp.addPass(new OP.OutputPass());
comp.setSize(w, h);
setBloomW(w); setBloomH(h); setComposer(comp);
} catch (e) { console.warn('[3D-Hwy] bloom init failed', e); setComposer(null); }
}).catch((e) => console.warn('[3D-Hwy] bloom modules failed', e)));
return null;
}
return { _h3dHexOrDefault, _applyCinematic, _timingHex, _sparkBurst, _sparkUpdate, _bloomEnsure };
}