← back to Quadrille Showroom
public/js/rack.js
227 lines
// ============================================================
// rack.js — PJ WINGBOARD ARC RACK (canonical target, Image #6)
// ------------------------------------------------------------
// Owns the geometry math for the corner-wrapping arc of single-hinge
// wingboards in the Phillip Jeffries Dallas reference. Pure functions +
// a thin state object; showroom.js builds the meshes and calls in here for
// (a) where each board sits on the arc, (b) its closed RAKE (sliver) angle,
// and (c) the per-frame flip easing between raked-closed and open-flat.
//
// Single vertical hinge model (NOT the old centre-spine butterfly):
// • Each board is ONE full-width face. Its pivot sits at the board's
// LEFT vertical edge (the hinge) on the curved rail.
// • CLOSED: the board is raked CCW about its hinge so the next board
// (downstream on the arc) occludes all but a thin vertical sliver —
// occlusion gives the sliver for free, no image slicing.
// • OPEN: the board swings CW about that same hinge until its face points
// straight at the room centre (dead-on hero), lying ~flat to the viewer.
//
// The reveal slider drives the closed rake: tighter rake = thinner slivers =
// more boards read at once; looser rake = wider slivers = fewer boards.
// ============================================================
(function () {
'use strict';
// REVEAL 0..100 → closed rake (radians) off the board's "open/flat" pose.
// 0 = boards nearly flat to the rail tangent → WIDE sliver (few boards read)
// 100 = boards steeply raked → THIN sliver (many boards read)
// Range chosen so the default (~62) lands on the photo's medium-thin slivers.
function revealToRake(reveal) {
const r = Math.max(0, Math.min(100, reveal));
// 42°..84° — pushed steeper toward the PJ Dallas photo's RAZOR-THIN slivers. At the
// default (~74) boards rake ~73° off dead-on so packed neighbours occlude all but a
// hairline leading SLIVER — the dense fanned-deck read of the reference.
return (42 + (r / 100) * 42) * Math.PI / 180;
}
// Continuous CAROUSEL index-offset (Chunk I — the dry-cleaner / tie-rack conveyor).
// Every board's effective slot index is shifted by this value, so the whole rack
// slides along the curved rail as one unit. carouselTo(K) eases this toward a target
// that brings board K's effective index to the centre slot ((n-1)/2); each frame
// re-placeBoard()s every board at its shifted arc-angle so they glide along the rail.
// 0 = no shift (board i sits in slot i, the natural boot layout).
let indexOffset = 0;
function setIndexOffset(v) { indexOffset = (typeof v === 'number' && isFinite(v)) ? v : 0; }
function getIndexOffset() { return indexOffset; }
// Centre angle (radians, around the arc centre) of slot i of n boards.
// The sweep centres on cfg.arcCentreDeg (default 90° = +Z legacy). For the Slice-1
// fixed-centre layout we centre on -90° (-Z) so the boards sit in FRONT of a viewer
// standing at the arc-centre (concave bank facing the centred viewer).
// The continuous `indexOffset` (Chunk I carousel) shifts every board by the same
// fractional number of slots so the rack conveyor-slides as one piece.
function arcAngleFor(i, n, cfg) {
// LINEAR mode (Steve 2026-07-02): NO semicircular arc — the wings hang on a
// STRAIGHT rail centred against the back wall, one per peg-hole, 2" apart.
// Here "angle" degenerates to the board's X position on that straight rail so
// railPointFor/faceCentreYaw stay the only consumers (call sites unchanged).
if (cfg && cfg.layout === 'linear') {
const pitch = (cfg.pegPitchM || 0.0508) * (cfg.sliverPitchScale || 1); // 2" default
const run = (n > 1 ? (n - 1) * pitch : 0);
return -run / 2 + (i + indexOffset) * pitch; // centred on x=0
}
const span = cfg.arcSpanDeg * Math.PI / 180;
const pitch = (n > 1 ? span / (n - 1) : span) * (cfg.sliverPitchScale || 1);
const centre = (typeof cfg.arcCentreDeg === 'number' ? cfg.arcCentreDeg : 90) * Math.PI / 180;
const start = centre - span / 2;
return start + (i + indexOffset) * pitch;
}
// The fractional index offset that lands board K's effective index on the centre slot.
// Centre slot index = (n-1)/2 (the dead-front board the resting-open middle occupies);
// we want K + offset === (n-1)/2, so offset = (n-1)/2 - K.
function centerOffsetFor(targetIndex, n) {
return ((n - 1) / 2) - targetIndex;
}
// World-space rail point for a given arc angle. The board hangs from this point.
function railPointFor(angle, cfg) {
// LINEAR mode: `angle` IS the board's X on the straight back-wall rail; Z is
// fixed just in front of the back wall, the rail runs flat (tangentY = 0).
if (cfg && cfg.layout === 'linear') {
const z = (typeof cfg.backWallZ === 'number') ? cfg.backWallZ : -2.87;
return { x: angle, z, tangentY: 0 };
}
const R = cfg.arcRadius, cx = cfg.arcCenter.x, cz = cfg.arcCenter.z;
const x = cx + Math.cos(angle) * R;
const z = cz + Math.sin(angle) * R;
const tangentY = Math.atan2(Math.cos(angle), -Math.sin(angle));
return { x, z, tangentY };
}
// Base yaw that makes a board face the arc CENTRE (dead-on / open pose).
// A board at angle θ should face inward toward (cx,cz): the inward vector is
// (cx-x, cz-z); its Three.js Y-rotation (atan2(dx, dz)) orients the +Z face
// of the plane to point that way.
function faceCentreYaw(angle, cfg) {
// LINEAR mode: every board faces the viewer dead-on (+Z), so the open hero is
// flat to the room and the closed neighbours rake off that shared front pose.
if (cfg && cfg.layout === 'linear') return 0;
const R = cfg.arcRadius, cx = cfg.arcCenter.x, cz = cfg.arcCenter.z;
const x = cx + Math.cos(angle) * R, z = cz + Math.sin(angle) * R;
return Math.atan2(cx - x, cz - z);
}
// Place a board's hinge pivot on the arc and store the closed/open yaw targets
// on its userData so the flip ease (flipUpdate) can blend between them.
// pivot.position = the hinge point (left edge) on the rail
// ud.openYaw = yaw that faces the arc centre (flat hero)
// ud.closedYaw = openYaw + rake (raked away → packed sliver)
// The board MESH is offset +halfWidth in local X so the pivot is its left edge.
function placeBoard(pivot, i, n, cfg, reveal) {
const ud = pivot.userData;
const angle = arcAngleFor(i, n, cfg);
const rp = railPointFor(angle, cfg);
const openYaw = faceCentreYaw(angle, cfg);
const linear = !!(cfg && cfg.layout === 'linear');
// LINEAR pegboard: a gentler rake so the wings lie NEAR-FLAT against the back
// wall (each showing a ~2" sliver of the next). They lean FORWARD off the wall
// (closedYaw uses −rake below) so a front board occludes its left neighbour —
// never raking back THROUGH the wall (the arc's 73° rake did, clipping them).
const rake = revealToRake(reveal) * (linear ? 0.34 : 1);
// BASE rail position (no open-forward offset). flipUpdate adds the forward push for
// the open hero ON TOP of this base, so the next relayout (carousel shift) re-reads a
// clean rail point rather than compounding the offset.
ud.baseX = rp.x; ud.baseZ = rp.z;
// Inward unit normal toward the arc centre (cx,cz) — the direction a board pushes when
// it opens so its FACE comes forward toward the room centre / viewer, clearing the
// raked neighbour slivers + frames out from in front of its pattern.
if (cfg && cfg.layout === 'linear') {
// Straight rail: the hero comes forward off the back wall toward the viewer
// (+Z), no lateral drift — so it stays over its own peg slot as it opens.
ud.inX = 0; ud.inZ = 1;
} else {
const cx = cfg.arcCenter.x, cz = cfg.arcCenter.z;
let inx = cx - rp.x, inz = cz - rp.z;
const ilen = Math.hypot(inx, inz) || 1;
ud.inX = inx / ilen; ud.inZ = inz / ilen;
}
// Half the board face width (face sits +halfWidth from the left hinge) — used by
// flipUpdate to re-centre the open hero's face on the slot centre.
ud.faceHalf = (cfg && cfg.boardWidthM ? cfg.boardWidthM : 0.762) / 2;
pivot.position.set(rp.x, 0, rp.z);
ud.arcAngle = angle;
ud.openYaw = openYaw;
ud.rake = rake;
// Arc rakes CCW (+) into the fan; the straight rail rakes FORWARD (−) off the
// wall so packed neighbours occlude to a sliver without clipping the back wall.
ud.closedYaw = openYaw + (linear ? -rake : rake);
if (typeof ud.flip !== 'number') ud.flip = 0; // 0 closed → 1 open
pivot.rotation.y = ud.closedYaw + (ud.openYaw - ud.closedYaw) * ud.flip;
}
// Re-place every board for a new reveal value (live slider). Keeps each board's
// current flip state so an open hero stays open while the rest re-rake.
function relayout(boards, cfg, reveal) {
const n = boards.length;
for (let i = 0; i < n; i++) placeBoard(boards[i], i, n, cfg, reveal);
}
// Per-frame flip ease. focusedPivot (if any) targets flip=1 (open/flat);
// all others target flip=0 (raked/closed). Returns true if anything moved.
// openFlip lets a RESTING-OPEN board (Slice-1 boot middle) ease to a partial angle
// (e.g. 0.6) instead of fully flat, so it reads as "open at an angle", not "selected".
// How far (metres, toward the arc centre / viewer) a FULLY-open hero is pushed forward
// out of the rail plane so its 30"×6' face reads as ONE clean full-bleed pattern with
// nothing crossing it — the raked neighbour slivers + their dark frame bars stay BEHIND.
// 0.42m brings the hero clear of the steeply-raked (~73°) 0.762m neighbours — even their
// wide-swinging top/bottom corners stay behind — so the 30"×6' face reads full and clean
// edge-to-edge with only its own thin dark frame, nothing crossing the pattern. (Verified
// by the screen-grid occlusion raycast: every sample over the face hits the hero first.)
const OPEN_FORWARD_M = 0.42;
function flipUpdate(boards, focusedPivot, dt, openFlip) {
if (!boards.length) return false;
const k = Math.min(1, dt * 7); // ease rate
const of = (typeof openFlip === 'number') ? openFlip : 1;
let moved = false;
for (let i = 0; i < boards.length; i++) {
const p = boards[i], ud = p.userData;
const isHero = (p === focusedPivot && !ud.panelClosed);
const target = isHero ? of : 0;
let flipMoved = false;
if (Math.abs(ud.flip - target) > 0.0008) {
ud.flip += (target - ud.flip) * k;
flipMoved = true;
} else if (ud.flip !== target) {
ud.flip = target;
flipMoved = true;
}
// Forward push + lateral re-centre: ONLY the opening hero leaves the rail plane,
// scaled by its flip. (1) Forward along the inward normal toward the room centre so
// the face clears the raked neighbour faces/frames out from in front of its pattern.
// (2) The board face sits +halfWidth from its LEFT hinge, so a flat-open board's face
// centre lands halfWidth to the RIGHT of the hinge/slot centre — shift the hero LEFT
// by faceOffX×flip (along its open-flat right-axis) so the 30" face is centred dead-front
// ("same pattern left & right of the open middle"). Neighbours stay on the rail base.
if (typeof ud.baseX === 'number') {
const fwd = isHero ? OPEN_FORWARD_M * ud.flip : 0;
// Open-flat the board faces the centre; its local +X (face-offset axis) maps to the
// world right-axis perpendicular to the inward normal: rightX=+inZ, rightZ=-inX.
const half = isHero ? (ud.faceHalf || 0.381) * ud.flip : 0;
const nx = ud.baseX + (ud.inX || 0) * fwd - (ud.inZ || 0) * half;
const nz = ud.baseZ + (ud.inZ || 0) * fwd + (ud.inX || 0) * half;
if (Math.abs(p.position.x - nx) > 1e-5 || Math.abs(p.position.z - nz) > 1e-5) {
p.position.x = nx; p.position.z = nz; flipMoved = true;
}
}
if (flipMoved) { moved = true; p.rotation.y = ud.closedYaw + (ud.openYaw - ud.closedYaw) * ud.flip; }
}
return moved;
}
window.QHRack = {
revealToRake,
arcAngleFor,
railPointFor,
faceCentreYaw,
placeBoard,
relayout,
flipUpdate,
setIndexOffset,
getIndexOffset,
centerOffsetFor
};
})();