← 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
  };
})();