// ============================================================
// Floor Plan 3D — digital-twin style mirroring RACK tab aesthetic.
// Rack chassis, slot plates, port grids, electric power feed.
// Robot path remains planar (uses CONFIG as-is, z=0).
// ============================================================
const { useRef: useRef3D, useEffect: useEffect3D, useMemo: useMemo3D } = React;

function _accent(tweaks) {
  return (window.ACCENTS && window.ACCENTS[tweaks?.accent]) || { accent: '#4fd1ff', dim: '#2a7fa0', glow: 'rgba(79,209,255,0.55)' };
}
function _bg(tweaks) {
  return (window.BG_TONES && window.BG_TONES[tweaks?.bgTone]) || { b0: '#0e131c', b1: '#141a25', b2: '#1a2130' };
}
function _col(c) { return new THREE.Color(c); }

// Keep aligned with CSS .rack tokens — emphasizes data/clinical feel.
const STATUS_HEX  = { ok: '#00d9a3', warn: '#f5b454', bad: '#ff5470', off: '#3a4658', empty: '#1a2030', absent: '#1a2030' };
const SLOT_KIND_HEX = { server: '#4fd1ff', switch: '#a88bff', storage: '#00d9a3' };
const KIND_LABEL   = { server: 'SRV', switch: 'SW', storage: 'STO' };
const ELECTRIC = '#ffd64a';
const ELECTRIC_HOT = '#fff4b0';
const RACK_BG_TOP = '#0e1826';
const RACK_BG_BOT = '#0a1220';
const SLOT_PLATE_COL = '#0c1420';

function makeLabel(text, className = 'f3d-label') {
  const div = document.createElement('div');
  div.className = className;
  div.textContent = text;
  return new THREE.CSS2DObject(div);
}

function makeRackHeader(rack, group) {
  const wrap = document.createElement('div');
  wrap.className = 'f3d-rack-head';
  const id = document.createElement('span');
  id.className = 'f3d-rack-id';
  id.textContent = aliasOr(rack, rack.label);
  wrap.appendChild(id);
  if (rack.alias && rack.alias.trim()) {
    const orig = document.createElement('span');
    orig.className = 'f3d-rack-orig';
    orig.textContent = rack.label;
    wrap.appendChild(orig);
  }
  return new THREE.CSS2DObject(wrap);
}

function makeSlotLabel(slot, K, unit) {
  const wrap = document.createElement('div');
  wrap.className = 'f3d-slot-label';
  const u = document.createElement('span');
  u.className = 'f3d-slot-u';
  u.textContent = `U${String(unit).padStart(2, '0')}`;
  wrap.appendChild(u);
  if (slot.kind !== 'empty') {
    const t = document.createElement('span');
    t.className = `f3d-slot-type ${slot.kind}`;
    t.textContent = KIND_LABEL[slot.kind] || '?';
    wrap.appendChild(t);
    const n = document.createElement('span');
    n.className = 'f3d-slot-name';
    n.textContent = aliasOr(slot, slot.name);
    wrap.appendChild(n);
  } else {
    const t = document.createElement('span');
    t.className = 'f3d-slot-type empty';
    t.textContent = 'EMPTY';
    wrap.appendChild(t);
  }
  return new THREE.CSS2DObject(wrap);
}

// Port indicator texture — 2D grid (M cols × 2 rows), matches the RACK tab.
function makePortTexture(ports, M) {
  const PX = 12;
  const w = M * PX;
  const h = 2 * PX;
  const canvas = document.createElement('canvas');
  canvas.width = w; canvas.height = h;
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#06090f';
  ctx.fillRect(0, 0, w, h);

  for (let i = 0; i < ports.length; i++) {
    const row = Math.floor(i / M);
    const col = i % M;
    if (row >= 2) break;
    const x = col * PX;
    const y = row * PX;
    const inset = 2;
    const port = ports[i];
    let bg = null;
    let border = '#5e6b80';
    if (port === 'connected')      { bg = '#00d9a3'; border = '#00d9a3'; }
    else if (port === 'suspicious'){ bg = '#f5b454'; border = '#f5b454'; }
    if (bg) {
      ctx.fillStyle = bg;
      ctx.fillRect(x + inset, y + inset, PX - inset * 2, PX - inset * 2);
    }
    ctx.strokeStyle = border;
    ctx.lineWidth = 1;
    ctx.strokeRect(x + inset + 0.5, y + inset + 0.5, PX - inset * 2 - 1, PX - inset * 2 - 1);
  }

  const tex = new THREE.CanvasTexture(canvas);
  tex.minFilter = THREE.LinearFilter;
  tex.magFilter = THREE.NearestFilter;
  tex.colorSpace = THREE.SRGBColorSpace || tex.colorSpace;
  return tex;
}

// Power panel — vivid teal-green ON / dim grey OFF.
// Pure RGB so cyan scene lights cannot tint it (MeshBasicMaterial + toneMapped:false).
const POWER_ON_COL  = '#00e676';
const POWER_ON_HOT  = '#69ffb0';
const POWER_OFF_COL = '#5e6b80';

// Power panel size — fraction of slot width / slot band height.
// Auto-scales with rack/asset size; tweak these to globally resize the panel.
const POWER_PANEL_W = 0.07;
const POWER_PANEL_H = 0.31;
// Panel x-center as fraction of SLOT_W from the slot's center (0 = center, 0.5 = right edge)
const POWER_PANEL_X = 0.40;

// Port panel size — fraction of slot width / slot band height (tunable).
const PORT_PANEL_W = 0.48;
const PORT_PANEL_H = 0.36;

// Power "console panel" face — frame + bolt + ON/OFF label baked in.
function makeBoltTexture(on, s3d) {
  const W = 96, H = 128;
  const canvas = document.createElement('canvas');
  canvas.width = W; canvas.height = H;
  const ctx = canvas.getContext('2d');

  // Outer dark bezel (looks like the panel mount frame)
  ctx.fillStyle = '#06090f';
  ctx.fillRect(0, 0, W, H);
  const onCol = s3d?.powerOnColor || POWER_ON_COL;
  const offCol = s3d?.powerOffColor || POWER_OFF_COL;
  // Inner panel area (the "screen")
  const inset = 6;
  ctx.fillStyle = on ? onCol : '#101620';
  ctx.fillRect(inset, inset, W - inset * 2, H - inset * 2);
  // Inner panel border (bright when on, dim when off)
  ctx.strokeStyle = on ? POWER_ON_HOT : '#3a4658';
  ctx.lineWidth = 2;
  ctx.strokeRect(inset + 1, inset + 1, W - inset * 2 - 2, H - inset * 2 - 2);

  // Bolt glyph — vertically biased to upper portion
  ctx.beginPath();
  ctx.moveTo(60, 18);
  ctx.lineTo(20, 60);
  ctx.lineTo(46, 60);
  ctx.lineTo(34, 92);
  ctx.lineTo(80, 50);
  ctx.lineTo(56, 50);
  ctx.closePath();
  ctx.fillStyle = on ? '#0a1220' : offCol;
  ctx.fill();

  // ON / OFF label below
  ctx.font = 'bold 13px monospace';
  ctx.fillStyle = on ? '#0a1220' : offCol;
  ctx.textAlign = 'center';
  ctx.fillText(on ? 'ON' : 'OFF', W / 2, H - 14);

  // Two small indicator pips at corners of panel
  ctx.fillStyle = on ? POWER_ON_HOT : '#3a4658';
  ctx.fillRect(inset + 4, H - inset - 8, 4, 4);
  ctx.fillRect(W - inset - 8, H - inset - 8, 4, 4);

  const tex = new THREE.CanvasTexture(canvas);
  tex.minFilter = THREE.LinearFilter;
  tex.magFilter = THREE.LinearFilter;
  tex.colorSpace = THREE.SRGBColorSpace || tex.colorSpace;
  return tex;
}

// Hatched empty-slot texture
function makeEmptyHatchTexture() {
  const W = 64, H = 32;
  const canvas = document.createElement('canvas');
  canvas.width = W; canvas.height = H;
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#080c14';
  ctx.fillRect(0, 0, W, H);
  ctx.strokeStyle = 'rgba(150, 180, 220, 0.10)';
  ctx.lineWidth = 1;
  for (let x = -H; x < W + H; x += 6) {
    ctx.beginPath();
    ctx.moveTo(x, 0);
    ctx.lineTo(x + H, H);
    ctx.stroke();
  }
  const tex = new THREE.CanvasTexture(canvas);
  tex.wrapS = THREE.RepeatWrapping;
  tex.wrapT = THREE.RepeatWrapping;
  tex.repeat.set(1, 1);
  tex.colorSpace = THREE.SRGBColorSpace || tex.colorSpace;
  return tex;
}

// Rack chassis canvas texture — vertical gradient like .rack background
function makeRackChassisTexture() {
  const W = 64, H = 256;
  const canvas = document.createElement('canvas');
  canvas.width = W; canvas.height = H;
  const ctx = canvas.getContext('2d');
  const grad = ctx.createLinearGradient(0, 0, 0, H);
  grad.addColorStop(0, RACK_BG_TOP);
  grad.addColorStop(1, RACK_BG_BOT);
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, W, H);
  // Subtle scanlines
  ctx.fillStyle = 'rgba(255,255,255,0.012)';
  for (let y = 0; y < H; y += 3) ctx.fillRect(0, y, W, 1);
  const tex = new THREE.CanvasTexture(canvas);
  tex.colorSpace = THREE.SRGBColorSpace || tex.colorSpace;
  return tex;
}

// Floor blueprint texture
function makeFloorTexture(gridW, gridH, bg, acc) {
  const px = 96;
  const w = Math.round(gridW * px);
  const h = Math.round(gridH * px);
  const canvas = document.createElement('canvas');
  canvas.width = w; canvas.height = h;
  const x = canvas.getContext('2d');

  x.fillStyle = bg.b1;
  x.fillRect(0, 0, w, h);

  const grad = x.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, Math.max(w, h) * 0.7);
  grad.addColorStop(0, 'rgba(255,255,255,0.04)');
  grad.addColorStop(1, 'rgba(0,0,0,0.5)');
  x.fillStyle = grad;
  x.fillRect(0, 0, w, h);

  x.strokeStyle = acc.dim;
  x.lineWidth = 0.6;
  x.globalAlpha = 0.18;
  for (let i = 0; i <= gridW * 4; i++) { x.beginPath(); x.moveTo(i * px / 4, 0); x.lineTo(i * px / 4, h); x.stroke(); }
  for (let j = 0; j <= gridH * 4; j++) { x.beginPath(); x.moveTo(0, j * px / 4); x.lineTo(w, j * px / 4); x.stroke(); }

  x.strokeStyle = acc.dim;
  x.lineWidth = 1.4;
  x.globalAlpha = 0.5;
  for (let i = 0; i <= gridW; i++) { x.beginPath(); x.moveTo(i * px, 0); x.lineTo(i * px, h); x.stroke(); }
  for (let j = 0; j <= gridH; j++) { x.beginPath(); x.moveTo(0, j * px); x.lineTo(w, j * px); x.stroke(); }

  x.fillStyle = acc.accent;
  x.globalAlpha = 0.7;
  for (let i = 0; i <= gridW; i += 4) {
    for (let j = 0; j <= gridH; j += 4) { x.beginPath(); x.arc(i * px, j * px, 4, 0, Math.PI * 2); x.fill(); }
  }
  x.strokeStyle = acc.dim;
  x.lineWidth = 1;
  x.globalAlpha = 0.35;
  const cm = 6;
  for (let i = 0; i <= gridW; i++) {
    for (let j = 0; j <= gridH; j++) {
      const cx = i * px, cy = j * px;
      x.beginPath();
      x.moveTo(cx - cm, cy); x.lineTo(cx + cm, cy);
      x.moveTo(cx, cy - cm); x.lineTo(cx, cy + cm);
      x.stroke();
    }
  }

  const tex = new THREE.CanvasTexture(canvas);
  tex.anisotropy = 4;
  tex.colorSpace = THREE.SRGBColorSpace || tex.colorSpace;
  tex.needsUpdate = true;
  return tex;
}

function rackTopColor(level) { return STATUS_HEX[level] || STATUS_HEX.empty; }

// Build a group's complete visual.
function buildGroupVisual(g, M, accCol, s3d) {
  const N = g.racks.length;
  const groupW = groupWidth(g);
  const groupH = g.h;
  const cx = g.x + groupW / 2;
  const cz = g.y + groupH / 2;

  const grp = new THREE.Group();
  grp.position.set(cx, 0, cz);
  grp.userData = { kind: 'group', groupId: g.id, baseY: 0 };

  const RACK_H = 1.7;
  const GAP_X = 0.10;
  const RACK_W = (groupW - GAP_X * (N + 1)) / N;
  const RACK_D = groupH * 0.85;
  const BASE_H = 0.06;
  const RACK_BOTTOM = BASE_H;

  // Group base platform
  const baseGeom = new THREE.BoxGeometry(groupW * 0.96, BASE_H, groupH * 0.92);
  const baseMat = new THREE.MeshStandardMaterial({
    color: 0x080c14, roughness: 0.85, metalness: 0.2,
    emissive: _col(accCol), emissiveIntensity: 0.04,
  });
  const baseSlab = new THREE.Mesh(baseGeom, baseMat);
  baseSlab.position.set(0, BASE_H / 2, 0);
  grp.add(baseSlab);

  const baseEdgesGeom = new THREE.EdgesGeometry(baseGeom);
  const baseEdgesMat = new THREE.LineBasicMaterial({
    color: _col(accCol), transparent: true, opacity: 0.55,
  });
  const baseEdges = new THREE.LineSegments(baseEdgesGeom, baseEdgesMat);
  baseEdges.position.copy(baseSlab.position);
  grp.add(baseEdges);

  const rackEntities = [];
  const rackChassisTex = makeRackChassisTexture();

  for (let i = 0; i < N; i++) {
    const rack = g.racks[i];
    const rxLocal = -groupW / 2 + GAP_X + RACK_W / 2 + i * (RACK_W + GAP_X);
    const rzLocal = 0;

    if (rack.absent) {
      const phBoxGeom = new THREE.BoxGeometry(RACK_W * 0.92, RACK_H * 0.55, RACK_D * 0.92);
      const phGeom = new THREE.EdgesGeometry(phBoxGeom);
      phBoxGeom.dispose();
      const phMat = new THREE.LineDashedMaterial({
        color: _col(accCol), dashSize: 0.1, gapSize: 0.08,
        transparent: true, opacity: 0.32,
      });
      const ph = new THREE.LineSegments(phGeom, phMat);
      ph.computeLineDistances();
      ph.position.set(rxLocal, RACK_BOTTOM + RACK_H * 0.275, rzLocal);
      grp.add(ph);
      rackEntities.push({
        rackId: rack.id, absent: true,
        placeholder: ph, placeholderGeom: phGeom, placeholderMat: phMat,
      });
      continue;
    }

    const rh = rackHealth(rack);
    const topColor = rackTopColor(rh.level);
    const anyOn = rack.slots.some(s => s.power === 'on');

    // Rack chassis — flat, screen-like (low metalness)
    const frameGeom = new THREE.BoxGeometry(RACK_W, RACK_H, RACK_D);
    const frameMat = new THREE.MeshStandardMaterial({
      map: rackChassisTex, roughness: 0.85, metalness: 0.08,
    });
    const frame = new THREE.Mesh(frameGeom, frameMat);
    frame.position.set(rxLocal, RACK_BOTTOM + RACK_H / 2, rzLocal);
    frame.userData = { kind: 'group', groupId: g.id, rackId: rack.id };
    grp.add(frame);

    const edgesGeom = new THREE.EdgesGeometry(frameGeom);
    const edgesMat = new THREE.LineBasicMaterial({
      color: _col(accCol), transparent: true, opacity: 0.55,
    });
    const edges = new THREE.LineSegments(edgesGeom, edgesMat);
    edges.position.copy(frame.position);
    grp.add(edges);

    // Top status strip
    const topGeom = new THREE.BoxGeometry(RACK_W * 0.9, 0.04, RACK_D * 0.9);
    const isLiveStatus = rh.level === 'ok' || rh.level === 'warn' || rh.level === 'bad';
    const topMat = new THREE.MeshStandardMaterial({
      color: _col(topColor), emissive: _col(topColor),
      emissiveIntensity: isLiveStatus ? 0.85 : 0.18,
      roughness: 0.4, metalness: 0.05,
    });
    const top = new THREE.Mesh(topGeom, topMat);
    top.position.set(rxLocal, RACK_BOTTOM + RACK_H + 0.025, rzLocal);
    grp.add(top);

    // Vertical DC rail on rack's left side (RACK tab .rack-rail)
    const railGeom = new THREE.BoxGeometry(0.025, RACK_H * 0.92, 0.03);
    const railMat = new THREE.MeshStandardMaterial({
      color: _col(anyOn ? ELECTRIC : '#1a2030'),
      emissive: _col(anyOn ? ELECTRIC : '#000000'),
      emissiveIntensity: anyOn ? 0.95 : 0.0,
      roughness: 0.3, metalness: 0.2,
    });
    const rail = new THREE.Mesh(railGeom, railMat);
    rail.position.set(rxLocal - RACK_W / 2 + 0.014, RACK_BOTTOM + RACK_H / 2, rzLocal + RACK_D / 2 - 0.05);
    grp.add(rail);

    // Power feed (vertical thin glow above rack when any slot on)
    const feedGeom = new THREE.CylinderGeometry(0.012, 0.012, 0.55, 8);
    const feedMat = new THREE.MeshBasicMaterial({
      color: _col(anyOn ? ELECTRIC : '#3a4658'),
      transparent: true, opacity: anyOn ? 0.85 : 0.4,
      blending: anyOn ? THREE.AdditiveBlending : THREE.NormalBlending,
      depthWrite: !anyOn,
    });
    const feed = new THREE.Mesh(feedGeom, feedMat);
    feed.position.set(rxLocal, RACK_BOTTOM + RACK_H + 0.27, rzLocal);
    grp.add(feed);

    // Rack header label (inside chassis, just below the top strip)
    const rackHeader = makeRackHeader(rack, g);
    rackHeader.position.set(rxLocal, RACK_BOTTOM + RACK_H * 0.99, rzLocal + RACK_D / 2 + 0.05);
    grp.add(rackHeader);

    // Slot stack
    const K = rack.slots.length;
    const slotsTopY = RACK_BOTTOM + RACK_H * 0.94;
    const slotsBotY = RACK_BOTTOM + RACK_H * 0.06;
    const slotBandH = (slotsTopY - slotsBotY) / K;

    const SLOT_W = RACK_W * 0.86;
    const slotPlateGeom = new THREE.BoxGeometry(SLOT_W, slotBandH * 0.92, 0.018);
    const slotPlateMat = new THREE.MeshStandardMaterial({
      color: _col(SLOT_PLATE_COL), roughness: 0.85, metalness: 0.05,
    });
    const slotPlateMatEmpty = new THREE.MeshStandardMaterial({
      color: 0x080c14, roughness: 0.9, metalness: 0.0,
    });
    const typeBandGeom = new THREE.BoxGeometry(0.025, slotBandH * 0.86, 0.022);
    const _pw         = s3d?.powerPanelW     ?? POWER_PANEL_W;
    const _ph         = s3d?.powerPanelH     ?? POWER_PANEL_H;
    const _panelX     = s3d?.powerPanelX     ?? POWER_PANEL_X;
    const _powerDepth = s3d?.powerPanelDepth ?? 0.012;
    const _portW      = s3d?.portPanelW      ?? PORT_PANEL_W;
    const _portH      = s3d?.portPanelH      ?? PORT_PANEL_H;
    const _portXOff   = s3d?.portPanelXOff   ?? 0.03;
    const _portYOff   = s3d?.portPanelYOff   ?? -0.18;
    const _portDepth  = s3d?.portPanelDepth  ?? 0.010;
    const powerGeom = new THREE.BoxGeometry(SLOT_W * _pw, slotBandH * _ph, _powerDepth);

    const slotEntities = [];

    for (let s = 0; s < K; s++) {
      const slot = rack.slots[s];
      const yLocal = slotsTopY - (s + 0.5) * slotBandH;
      const isEmpty = slot.kind === 'empty';
      const isOn = slot.power === 'on' && !isEmpty;
      const unit = K - s;

      // Slot dark plate
      const slotMat = isEmpty
        ? slotPlateMatEmpty.clone()
        : slotPlateMat.clone();
      const slotMesh = new THREE.Mesh(slotPlateGeom, slotMat);
      slotMesh.position.set(rxLocal, yLocal, rzLocal + RACK_D / 2 + 0.011);
      slotMesh.userData = { kind: 'group', groupId: g.id, rackId: rack.id, slotIndex: s };
      grp.add(slotMesh);

      // Type band (left, kind-colored, glowing)
      let typeBand = null, typeBandMat = null;
      if (!isEmpty) {
        const kindCol = SLOT_KIND_HEX[slot.kind] || '#4fd1ff';
        typeBandMat = new THREE.MeshStandardMaterial({
          color: _col(kindCol), emissive: _col(kindCol),
          emissiveIntensity: isOn ? 1.0 : 0.4,
          roughness: 0.3, metalness: 0.05,
          transparent: true, opacity: isOn ? 1 : 0.65,
        });
        typeBand = new THREE.Mesh(typeBandGeom, typeBandMat);
        typeBand.position.set(rxLocal - SLOT_W / 2 + 0.018, yLocal, rzLocal + RACK_D / 2 + 0.013);
        grp.add(typeBand);
      }

      // Slot text label
      const slotLabel = makeSlotLabel(slot, K, unit);
      slotLabel.position.set(rxLocal - SLOT_W * 0.35, yLocal + slotBandH * 0.15, rzLocal + RACK_D / 2 + 0.04);
      slotLabel.element.classList.toggle('off', !isOn && !isEmpty);
      grp.add(slotLabel);

      // Port grid (M cols × 2 rows) for non-empty, non-storage slots
      let portStrip = null, portStripGeom = null, portStripMat = null, portTex = null;
      if (!isEmpty && slot.kind !== 'storage' && Array.isArray(slot.ports) && slot.ports.length > 0) {
        const stripW = SLOT_W * _portW;
        const stripH = slotBandH * _portH;
        portStripGeom = new THREE.BoxGeometry(stripW, stripH, _portDepth);
        portTex = makePortTexture(slot.ports, M);
        portStripMat = new THREE.MeshBasicMaterial({
          map: portTex,
          toneMapped: false,
        });
        portStrip = new THREE.Mesh(portStripGeom, portStripMat);
        portStrip.position.set(
          rxLocal + SLOT_W * _portXOff,
          yLocal + slotBandH * _portYOff,
          rzLocal + RACK_D / 2 + 0.020 + _portDepth / 2
        );
        grp.add(portStrip);
      }

      // Power "console panel" — embedded in slot's right portion, no protrusion, no overspill.
      // MeshBasicMaterial + toneMapped:false → pure RGB out, no cyan light tint.
      let powerLED = null, powerMat = null, powerTex = null;
      let powerHalo = null, powerHaloMat = null, powerHaloGeom = null;
      if (!isEmpty) {
        const panelX = rxLocal + SLOT_W * _panelX;
        powerTex = makeBoltTexture(isOn, s3d);
        powerMat = new THREE.MeshBasicMaterial({
          map: powerTex,
          toneMapped: false,
          side: THREE.DoubleSide,
        });
        powerLED = new THREE.Mesh(powerGeom, powerMat);
        // Sit just in front of the slot face (slot front at z = +0.020)
        powerLED.position.set(panelX, yLocal, rzLocal + RACK_D / 2 + 0.020 + _powerDepth / 2);
        powerLED.renderOrder = 10;
        grp.add(powerLED);

        if (isOn) {
          // Halo — same footprint as the panel, sits flush behind it for subtle pulse only.
          // No outer bloom; nothing extends beyond the panel boundary.
          powerHaloGeom = new THREE.PlaneGeometry(SLOT_W * _pw, slotBandH * _ph);
          powerHaloMat = new THREE.MeshBasicMaterial({
            color: _col(s3d?.powerOnColor || POWER_ON_COL), transparent: true, opacity: 0.4,
            blending: THREE.AdditiveBlending, depthWrite: false,
            toneMapped: false,
          });
          powerHalo = new THREE.Mesh(powerHaloGeom, powerHaloMat);
          powerHalo.position.set(panelX, yLocal, rzLocal + RACK_D / 2 + 0.020 + _powerDepth + 0.001);
          powerHalo.renderOrder = 9;
          grp.add(powerHalo);
        }
      }

      slotEntities.push({
        slotMesh, slotMat,
        typeBand, typeBandMat,
        slotLabel,
        portStrip, portStripGeom, portStripMat, portTex,
        powerLED, powerMat, powerTex,
        powerHalo, powerHaloMat, powerHaloGeom,
        isOn, isEmpty,
      });
    }

    // Per-rack scan ring (hidden)
    const scanBoxGeom = new THREE.BoxGeometry(RACK_W * 1.12, RACK_H * 1.12, RACK_D * 1.08);
    const scanRingGeom = new THREE.EdgesGeometry(scanBoxGeom);
    scanBoxGeom.dispose();
    const scanRingMat = new THREE.LineBasicMaterial({
      color: _col('#f5b454'), transparent: true, opacity: 0,
    });
    const scanRing = new THREE.LineSegments(scanRingGeom, scanRingMat);
    scanRing.position.set(rxLocal, RACK_BOTTOM + RACK_H / 2, rzLocal);
    scanRing.visible = false;
    grp.add(scanRing);

    const beamGeom = new THREE.CylinderGeometry(0.18, 0.06, 3.2, 18, 1, true);
    const beamMat = new THREE.MeshBasicMaterial({
      color: _col('#f5b454'), transparent: true, opacity: 0,
      side: THREE.DoubleSide, depthWrite: false,
      blending: THREE.AdditiveBlending,
    });
    const scanBeam = new THREE.Mesh(beamGeom, beamMat);
    scanBeam.position.set(rxLocal, RACK_BOTTOM + RACK_H + 1.6, rzLocal);
    scanBeam.visible = false;
    grp.add(scanBeam);

    const rackDisplayName = aliasOr(rack, rack.label);
    const scanLabel = makeLabel(`▶ SCANNING · ${rackDisplayName}`, 'f3d-scan-label');
    scanLabel.position.set(rxLocal, RACK_BOTTOM + RACK_H + 0.62, rzLocal);
    scanLabel.element.style.display = 'none';
    grp.add(scanLabel);

    rackEntities.push({
      rackId: rack.id, rackDisplayName, absent: false,
      anyOn,
      frame, frameGeom, frameMat,
      edges, edgesGeom, edgesMat,
      top, topGeom, topMat, topColor, topIsLive: isLiveStatus,
      rail, railGeom, railMat,
      feed, feedGeom, feedMat,
      rackHeader,
      slotPlateGeom, slotPlateMat, slotPlateMatEmpty,
      typeBandGeom, powerGeom,
      slotEntities,
      scanRing, scanRingGeom, scanRingMat,
      scanBeam, beamGeom, beamMat,
      scanLabel,
    });
  }

  return {
    grp,
    rackEntities,
    RACK_H, BASE_H,
    baseSlab, baseGeom, baseMat,
    baseEdges, baseEdgesGeom, baseEdgesMat,
    rackChassisTex,
  };
}

// Industrial robot — boxy chassis, sensor mast, no cute accents
function buildRobotVisual(robot, accCol) {
  const grp = new THREE.Group();
  grp.position.set(robot.path[0]?.x || 0, 0, robot.path[0]?.y || 0);
  grp.userData = { kind: 'robot', baseY: 0 };

  const ROBOT_BASE = 0.10;

  const wheelGeom = new THREE.CylinderGeometry(0.085, 0.085, 0.06, 16);
  const wheelMat = new THREE.MeshStandardMaterial({
    color: 0x080c14, roughness: 0.9, metalness: 0.2,
  });
  const wheels = [];
  for (const [wx, wz] of [[-0.22, -0.18], [0.22, -0.18], [-0.22, 0.18], [0.22, 0.18]]) {
    const wheel = new THREE.Mesh(wheelGeom, wheelMat);
    wheel.rotation.z = Math.PI / 2;
    wheel.position.set(wx, ROBOT_BASE, wz);
    grp.add(wheel);
    wheels.push(wheel);
  }

  // Lower deck — flat, industrial
  const lowerGeom = new THREE.BoxGeometry(0.6, 0.08, 0.46);
  const lowerMat = new THREE.MeshStandardMaterial({
    color: 0x0e1826, roughness: 0.7, metalness: 0.4,
  });
  const lower = new THREE.Mesh(lowerGeom, lowerMat);
  lower.position.y = ROBOT_BASE + 0.06;
  grp.add(lower);

  // Equipment block — rectangular, no curves
  const bodyGeom = new THREE.BoxGeometry(0.46, 0.16, 0.36);
  const bodyMat = new THREE.MeshStandardMaterial({
    color: 0x141f33, roughness: 0.65, metalness: 0.35,
    emissive: _col(accCol), emissiveIntensity: 0.18,
  });
  const body = new THREE.Mesh(bodyGeom, bodyMat);
  body.position.y = ROBOT_BASE + 0.18;
  grp.add(body);

  // Accent stripe (thin, accent color)
  const stripeGeom = new THREE.BoxGeometry(0.47, 0.018, 0.37);
  const stripeMat = new THREE.MeshBasicMaterial({ color: _col(accCol), transparent: true, opacity: 0.85 });
  const stripe = new THREE.Mesh(stripeGeom, stripeMat);
  stripe.position.y = ROBOT_BASE + 0.245;
  grp.add(stripe);

  // Sensor mast (cylinder, taller and narrower like a real LIDAR/sensor module)
  const mastGeom = new THREE.CylinderGeometry(0.075, 0.085, 0.16, 18);
  const mastMat = new THREE.MeshStandardMaterial({
    color: 0x0a0e15, roughness: 0.5, metalness: 0.6,
  });
  const mast = new THREE.Mesh(mastGeom, mastMat);
  mast.position.set(0, ROBOT_BASE + 0.34, 0);
  grp.add(mast);

  // Sensor lens (thin emissive ring on the mast)
  const lensGeom = new THREE.CylinderGeometry(0.085, 0.085, 0.022, 18);
  const lensMat = new THREE.MeshStandardMaterial({
    color: _col(accCol), emissive: _col(accCol),
    emissiveIntensity: 0.85,
    roughness: 0.3, metalness: 0.3,
  });
  const lens = new THREE.Mesh(lensGeom, lensMat);
  lens.position.set(0, ROBOT_BASE + 0.36, 0);
  grp.add(lens);

  // Top antenna (slim, neutral grey)
  const antennaGeom = new THREE.CylinderGeometry(0.006, 0.006, 0.18, 6);
  const antennaMat = new THREE.MeshStandardMaterial({ color: 0x6b7a8f, roughness: 0.6, metalness: 0.6 });
  const antenna = new THREE.Mesh(antennaGeom, antennaMat);
  antenna.position.set(0, ROBOT_BASE + 0.51, 0);
  grp.add(antenna);

  const antennaTipGeom = new THREE.SphereGeometry(0.012, 6, 4);
  const antennaTipMat = new THREE.MeshBasicMaterial({ color: 0xaab4c4 });
  const antennaTip = new THREE.Mesh(antennaTipGeom, antennaTipMat);
  antennaTip.position.set(0, ROBOT_BASE + 0.60, 0);
  grp.add(antennaTip);

  // Forward indicator (small flat plate, accent emissive)
  const pointerGeom = new THREE.BoxGeometry(0.04, 0.08, 0.10);
  const pointerMat = new THREE.MeshStandardMaterial({
    color: _col(accCol), emissive: _col(accCol), emissiveIntensity: 0.85,
    roughness: 0.4, metalness: 0.2,
  });
  const pointer = new THREE.Mesh(pointerGeom, pointerMat);
  pointer.position.set(0.25, ROBOT_BASE + 0.18, 0);
  grp.add(pointer);

  // Scan cone
  const scanConeGeom = new THREE.ConeGeometry(1.3, 2.2, 18, 1, true);
  const scanConeMat = new THREE.MeshBasicMaterial({
    color: _col('#f5b454'), transparent: true, opacity: 0.35,
    side: THREE.DoubleSide, depthWrite: false,
  });
  const scanCone = new THREE.Mesh(scanConeGeom, scanConeMat);
  scanCone.rotation.z = -Math.PI / 2;
  scanCone.position.x = 1.1;
  const coneHolder = new THREE.Group();
  coneHolder.add(scanCone);
  coneHolder.position.y = ROBOT_BASE + 0.18;
  coneHolder.visible = false;
  grp.add(coneHolder);

  return {
    grp,
    wheels, wheelGeom, wheelMat,
    lower, lowerGeom, lowerMat,
    body, bodyGeom, bodyMat,
    stripe, stripeGeom, stripeMat,
    mast, mastGeom, mastMat,
    lens, lensGeom, lensMat,
    antenna, antennaGeom, antennaMat,
    antennaTip, antennaTipGeom, antennaTipMat,
    pointer, pointerGeom, pointerMat,
    scanCone: coneHolder, scanConeGeom, scanConeMat,
  };
}

function disposeGroupEntry(ge, scene) {
  scene.remove(ge.grp);
  if (ge.label) scene.remove(ge.label);
  if (ge.driftRing) scene.remove(ge.driftRing);
  ge.baseGeom?.dispose(); ge.baseMat?.dispose();
  ge.baseEdgesGeom?.dispose(); ge.baseEdgesMat?.dispose();
  ge.rackChassisTex?.dispose();
  for (const re of ge.rackEntities) {
    if (re.absent) {
      re.placeholderGeom?.dispose(); re.placeholderMat?.dispose();
      continue;
    }
    re.frameGeom?.dispose(); re.frameMat?.dispose();
    re.edgesGeom?.dispose(); re.edgesMat?.dispose();
    re.topGeom?.dispose(); re.topMat?.dispose();
    re.railGeom?.dispose(); re.railMat?.dispose();
    re.feedGeom?.dispose(); re.feedMat?.dispose();
    re.slotPlateGeom?.dispose();
    re.slotPlateMat?.dispose();
    re.slotPlateMatEmpty?.dispose();
    re.typeBandGeom?.dispose();
    re.powerGeom?.dispose();
    re.scanRingGeom?.dispose(); re.scanRingMat?.dispose();
    re.beamGeom?.dispose(); re.beamMat?.dispose();
    for (const se of re.slotEntities) {
      se.slotMat?.dispose();
      se.typeBandMat?.dispose();
      se.portStripGeom?.dispose();
      se.portStripMat?.dispose();
      se.portTex?.dispose();
      se.powerMat?.dispose();
      se.powerTex?.dispose();
      se.powerHaloGeom?.dispose();
      se.powerHaloMat?.dispose();
    }
  }
  ge.driftRingGeom?.dispose(); ge.driftRingMat?.dispose();
}

function FloorPlan3D({ cfg, setCfg, onPickGroup, speed, scanState, tweaks }) {
  const mountRef = useRef3D(null);
  const sideTickRef = useRef3D(0);
  const [ctxMenu, setCtxMenu] = React.useState(null);
  const ctxMenuSetterRef = useRef3D(null);
  ctxMenuSetterRef.current = setCtxMenu;

  const refs = useRef3D({
    renderer: null, labelRenderer: null, scene: null, camera: null, controls: null,
    raf: 0,
    groupMeshes: [],
    robotEntities: [],
    groupById: new Map(),
    pickables: [],
    speedRef: speed, scanStateRef: scanState, tweaksRef: tweaks, cfgRef: cfg, onPickGroupRef: onPickGroup,
    motionStates: {},
    raycaster: null, mouseNDC: null,
    hoveredId: null,
    particles: null, particleVels: null,
    accentLight: null, rimLight: null,
  });

  useEffect3D(() => { refs.current.speedRef = speed; }, [speed]);
  useEffect3D(() => { refs.current.scanStateRef = scanState; }, [scanState]);
  useEffect3D(() => { refs.current.tweaksRef = tweaks; }, [tweaks]);
  useEffect3D(() => { refs.current.cfgRef = cfg; }, [cfg]);
  useEffect3D(() => { refs.current.onPickGroupRef = onPickGroup; }, [onPickGroup]);

  const [sideTick, setSideTick] = React.useState(0);

  // Mount
  useEffect3D(() => {
    const mount = mountRef.current;
    if (!mount) return;
    const r = refs.current;

    const w = mount.clientWidth || 800;
    const h = mount.clientHeight || 600;

    const acc = _accent(r.tweaksRef);
    const bg = _bg(r.tweaksRef);
    const { gridW, gridH } = cfg.params;

    const scene = new THREE.Scene();
    scene.background = _col(bg.b0);
    scene.fog = new THREE.Fog(bg.b0, 22, 70);

    const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 200);
    camera.position.set(gridW * 0.55, Math.max(gridW, gridH) * 0.7, gridH * 1.1);
    camera.lookAt(gridW / 2, 0, gridH / 2);

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    renderer.setSize(w, h);
    if (renderer.outputColorSpace !== undefined) {
      renderer.outputColorSpace = THREE.SRGBColorSpace || renderer.outputColorSpace;
    } else {
      renderer.outputEncoding = THREE.sRGBEncoding || renderer.outputEncoding;
    }
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.05;
    renderer.domElement.style.display = 'block';
    mount.appendChild(renderer.domElement);

    const labelRenderer = new THREE.CSS2DRenderer();
    labelRenderer.setSize(w, h);
    labelRenderer.domElement.style.position = 'absolute';
    labelRenderer.domElement.style.top = '0';
    labelRenderer.domElement.style.left = '0';
    labelRenderer.domElement.style.pointerEvents = 'none';
    mount.appendChild(labelRenderer.domElement);

    const hemi = new THREE.HemisphereLight(0xb0c8e8, 0x080c14, 0.4);
    scene.add(hemi);
    const dir = new THREE.DirectionalLight(0xffffff, 0.7);
    dir.position.set(gridW * 0.7, Math.max(gridW, gridH) * 1.4, gridH * 0.5);
    scene.add(dir);

    const accentLight = new THREE.PointLight(_col(acc.accent), 1.0, 25);
    accentLight.position.set(gridW * 0.5, 4.5, gridH * 0.5);
    scene.add(accentLight);

    const rimLight = new THREE.PointLight(_col(acc.dim), 0.6, 30);
    rimLight.position.set(gridW * 0.1, 0.5, gridH * 0.2);
    scene.add(rimLight);

    const rimLight2 = new THREE.PointLight(_col(acc.dim), 0.45, 25);
    rimLight2.position.set(gridW * 0.9, 0.5, gridH * 0.8);
    scene.add(rimLight2);

    const floorTex = makeFloorTexture(gridW, gridH, bg, acc);
    const floorGeom = new THREE.PlaneGeometry(gridW, gridH);
    const floorMat = new THREE.MeshStandardMaterial({
      map: floorTex, roughness: 0.85, metalness: 0.1, color: 0xffffff,
    });
    const floor = new THREE.Mesh(floorGeom, floorMat);
    floor.rotation.x = -Math.PI / 2;
    floor.position.set(gridW / 2, 0, gridH / 2);
    scene.add(floor);

    const frameGeom = new THREE.EdgesGeometry(new THREE.BoxGeometry(gridW, 0.02, gridH));
    const frameMat = new THREE.LineBasicMaterial({ color: _col(acc.accent), opacity: 0.55, transparent: true });
    const frame = new THREE.LineSegments(frameGeom, frameMat);
    frame.position.set(gridW / 2, 0.02, gridH / 2);
    scene.add(frame);

    const cornerLines = [];
    const cornerMat = new THREE.LineBasicMaterial({
      color: _col(acc.accent), transparent: true, opacity: 0.85,
    });
    const CORNER = 0.6;
    for (const [cx, cz, dx, dz] of [
      [0, 0, 1, 1], [gridW, 0, -1, 1],
      [0, gridH, 1, -1], [gridW, gridH, -1, -1],
    ]) {
      const pts = [
        new THREE.Vector3(cx + dx * CORNER, 0.025, cz),
        new THREE.Vector3(cx, 0.025, cz),
        new THREE.Vector3(cx, 0.025, cz + dz * CORNER),
      ];
      const geom = new THREE.BufferGeometry().setFromPoints(pts);
      const ln = new THREE.Line(geom, cornerMat);
      scene.add(ln);
      cornerLines.push({ line: ln, geom });
    }

    for (let i = 0; i <= gridW; i++) {
      const lbl = makeLabel(String(i).padStart(2, '0'), 'f3d-tick');
      lbl.position.set(i, 0.02, -0.4);
      scene.add(lbl);
    }
    for (let j = 0; j <= gridH; j++) {
      const lbl = makeLabel(String.fromCharCode(65 + j), 'f3d-tick');
      lbl.position.set(-0.4, 0.02, j);
      scene.add(lbl);
    }

    const PARTICLE_COUNT = 60;
    const pPos = new Float32Array(PARTICLE_COUNT * 3);
    const pVel = new Float32Array(PARTICLE_COUNT);
    for (let i = 0; i < PARTICLE_COUNT; i++) {
      pPos[i * 3 + 0] = Math.random() * gridW;
      pPos[i * 3 + 1] = Math.random() * 5;
      pPos[i * 3 + 2] = Math.random() * gridH;
      pVel[i] = 0.0025 + Math.random() * 0.005;
    }
    const particleGeom = new THREE.BufferGeometry();
    particleGeom.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
    const particleMat = new THREE.PointsMaterial({
      color: _col(acc.accent), size: 0.04, transparent: true, opacity: 0.4,
      blending: THREE.AdditiveBlending, depthWrite: false,
    });
    const particles = new THREE.Points(particleGeom, particleMat);
    scene.add(particles);

    const controls = new THREE.OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;
    controls.dampingFactor = 0.08;
    controls.minDistance = 4;
    controls.maxDistance = 80;
    controls.maxPolarAngle = Math.PI / 2 - 0.05;
    controls.target.set(gridW / 2, 0, gridH / 2);
    controls.update();

    const raycaster = new THREE.Raycaster();
    const mouseNDC = new THREE.Vector2();

    const onContextMenu = (e) => {
      e.preventDefault();
      ctxMenuSetterRef.current(null); // close any existing
      const rect = renderer.domElement.getBoundingClientRect();
      mouseNDC.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
      mouseNDC.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
      raycaster.setFromCamera(mouseNDC, camera);
      const hits = raycaster.intersectObjects(r.pickables, false);
      if (hits.length > 0) {
        const gid = hits[0].object.userData.groupId;
        if (gid) {
          const grp = r.cfgRef.groups.find(g => g.id === gid);
          if (grp) {
            ctxMenuSetterRef.current({
              x: e.clientX - rect.left,
              y: e.clientY - rect.top,
              groupId: gid,
              label: aliasOr(grp, grp.tag),
            });
          }
        }
      }
    };
    renderer.domElement.addEventListener('contextmenu', onContextMenu);

    const onCanvasClick = () => ctxMenuSetterRef.current(null);
    renderer.domElement.addEventListener('click', onCanvasClick);

    const onMove = (e) => {
      const rect = renderer.domElement.getBoundingClientRect();
      mouseNDC.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
      mouseNDC.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
      raycaster.setFromCamera(mouseNDC, camera);
      const hits = raycaster.intersectObjects(r.pickables, false);
      const id = hits[0]?.object?.userData?.groupId || null;
      r.hoveredId = id;
      renderer.domElement.style.cursor = id ? 'pointer' : '';
    };
    renderer.domElement.addEventListener('mousemove', onMove);

    const ro = new ResizeObserver(([entry]) => {
      const W = entry.contentRect.width;
      const H = entry.contentRect.height;
      if (!W || !H) return;
      camera.aspect = W / H;
      camera.updateProjectionMatrix();
      renderer.setSize(W, H);
      labelRenderer.setSize(W, H);
    });
    ro.observe(mount);

    Object.assign(r, {
      renderer, labelRenderer, scene, camera, controls,
      raycaster, mouseNDC,
      particles, particleVels: pVel,
      accentLight, rimLight,
    });

    const entranceObjs = [];
    cfg.entrances.forEach(e => {
      const ge = new THREE.Mesh(
        new THREE.PlaneGeometry(e.w, e.h),
        new THREE.MeshBasicMaterial({ color: _col(acc.dim), transparent: true, opacity: 0.3 })
      );
      ge.rotation.x = -Math.PI / 2;
      ge.position.set(e.x + e.w / 2, 0.018, e.y + e.h / 2);
      scene.add(ge);
      const edges = new THREE.LineSegments(
        new THREE.EdgesGeometry(new THREE.PlaneGeometry(e.w, e.h)),
        new THREE.LineDashedMaterial({ color: _col(acc.accent), dashSize: 0.18, gapSize: 0.12, opacity: 0.85, transparent: true })
      );
      edges.computeLineDistances();
      edges.rotation.x = -Math.PI / 2;
      edges.position.set(e.x + e.w / 2, 0.022, e.y + e.h / 2);
      scene.add(edges);
      const lbl = makeLabel('ENTRY', 'f3d-entry-label');
      lbl.position.set(e.x + e.w / 2, 0.05, e.y + e.h / 2);
      scene.add(lbl);
      entranceObjs.push({ mesh: ge, edges, lbl });
    });

    // RAF
    const tick = () => {
      const cur = refs.current;
      const cfgNow = cur.cfgRef;
      const speedNow = cur.speedRef;
      const scanNow = cur.scanStateRef;
      const t = performance.now() / 1000 * speedNow;

      for (const ge of cur.groupMeshes) {
        if (!ge) continue;
        const isHoveredGroup = cur.hoveredId === ge.groupId;
        const targetY = isHoveredGroup ? 0.16 : 0;
        ge.grp.position.y = THREE.MathUtils.lerp(ge.grp.position.y, targetY, 0.16);

        if (ge.driftRingMat) {
          ge.driftRingMat.opacity = 0.4 + 0.5 * Math.abs(Math.sin(t * 1.5));
        }

        for (const re of ge.rackEntities) {
          if (re.absent) continue;
          const isScanning = scanNow && scanNow.groupId === ge.groupId && scanNow.rackId === re.rackId;

          // Power feed pulse when any slot on
          if (re.anyOn && re.feedMat) {
            re.feedMat.opacity = 0.65 + 0.3 * Math.abs(Math.sin(t * 2));
          }
          // DC rail pulse when any slot on
          if (re.anyOn && re.railMat) {
            re.railMat.emissiveIntensity = 0.7 + 0.4 * Math.abs(Math.sin(t * 2));
          }

          if (isScanning) {
            const k = 0.7 + 0.5 * Math.abs(Math.sin(t * 4));
            re.topMat.emissiveIntensity = k;
            re.edgesMat.opacity = 0.55 + 0.4 * Math.abs(Math.sin(t * 4));
            re.scanRingMat.opacity = 0.45 + 0.45 * Math.abs(Math.sin(t * 4));
            re.scanRing.visible = true;
            re.scanBeam.visible = true;
            re.beamMat.opacity = 0.18 + 0.22 * Math.abs(Math.sin(t * 4));
            if (re.scanLabel.element.style.display !== '') {
              re.scanLabel.element.style.display = '';
            }
          } else {
            const baseEm = re.topIsLive ? 0.85 : 0.18;
            re.topMat.emissiveIntensity = THREE.MathUtils.lerp(re.topMat.emissiveIntensity, baseEm, 0.1);
            re.edgesMat.opacity = THREE.MathUtils.lerp(re.edgesMat.opacity, isHoveredGroup ? 0.85 : 0.55, 0.16);
            re.scanRing.visible = false;
            re.scanBeam.visible = false;
            if (re.scanLabel.element.style.display !== 'none') {
              re.scanLabel.element.style.display = 'none';
            }
          }

          // Power halo pulse when ON (panel-sized, contained — no overspill)
          for (const se of re.slotEntities) {
            if (se.powerHaloMat) {
              se.powerHaloMat.opacity = 0.32 + 0.22 * Math.abs(Math.sin(t * 3));
            }
          }
        }
      }

      // Robots
      for (const re of cur.robotEntities) {
        const robot = (cfgNow.robots || []).find(rr => rr.id === re.robotId);
        if (!robot) continue;
        const m = robotMotion(robot.path, robot.speed, t);
        cur.motionStates[re.robotId] = { motion: m, trail: cur.motionStates[re.robotId]?.trail || [] };

        re.grp.position.set(m.x, 0, m.y);
        re.grp.rotation.y = -m.angle;

        const moving = m.state === 'moving';
        const bobble = moving ? Math.sin(t * 6) * 0.025 : 0;
        re.grp.position.y = bobble;

        // Rotate sensor lens like a scanning LIDAR
        if (re.lens) re.lens.rotation.y = t * 1.5;

        const inspecting = m.state === 'inspecting';
        re.scanCone.visible = inspecting;
        if (inspecting) {
          const sweep = Math.sin(m.scanPhase * Math.PI * 4) * (Math.PI / 4);
          re.scanCone.rotation.y = sweep;
          re.scanConeMat.opacity = 0.45 + 0.35 * Math.abs(Math.sin(t * 6));
        }

        re.bodyMat.emissiveIntensity = inspecting ? 0.55 : 0.18;
        re.lensMat.emissiveIntensity = inspecting ? 1.4 : 0.85;

        const tr = cur.motionStates[re.robotId].trail;
        if (m.state === 'moving') {
          tr.push([m.x, m.y]);
          if (tr.length > 32) tr.shift();
        } else if (tr.length > 0) {
          tr.shift();
        }
        const attr = re.trailGeom.attributes.position;
        for (let i = 0; i < tr.length; i++) {
          attr.array[i * 3 + 0] = tr[i][0];
          attr.array[i * 3 + 1] = 0.05;
          attr.array[i * 3 + 2] = tr[i][1];
        }
        attr.needsUpdate = true;
        re.trailGeom.setDrawRange(0, tr.length);

        const stateText = inspecting ? 'SCAN' : 'MOVE';
        re.label.element.textContent = `${robot.name} · ${stateText}`;
        re.label.element.classList.toggle('warn', inspecting);
      }

      // Particles
      if (cur.particles && cur.particleVels) {
        const pAttr = cur.particles.geometry.attributes.position;
        for (let i = 0; i < cur.particleVels.length; i++) {
          pAttr.array[i * 3 + 1] += cur.particleVels[i];
          pAttr.array[i * 3 + 0] += Math.sin(t + i) * 0.0008;
          if (pAttr.array[i * 3 + 1] > 5) {
            pAttr.array[i * 3 + 1] = 0.1;
            pAttr.array[i * 3 + 0] = Math.random() * cfgNow.params.gridW;
            pAttr.array[i * 3 + 2] = Math.random() * cfgNow.params.gridH;
          }
        }
        pAttr.needsUpdate = true;
      }

      if (cur.accentLight) {
        cur.accentLight.intensity = 0.85 + 0.3 * Math.abs(Math.sin(t * 0.6));
      }

      cur.controls.update();
      cur.renderer.render(cur.scene, cur.camera);
      cur.labelRenderer.render(cur.scene, cur.camera);

      sideTickRef.current++;
      if (sideTickRef.current >= 6) {
        sideTickRef.current = 0;
        setSideTick(x => (x + 1) % 1000);
      }

      cur.raf = requestAnimationFrame(tick);
    };
    r.raf = requestAnimationFrame(tick);

    return () => {
      cancelAnimationFrame(r.raf);
      ro.disconnect();
      renderer.domElement.removeEventListener('contextmenu', onContextMenu);
      renderer.domElement.removeEventListener('click', onCanvasClick);
      renderer.domElement.removeEventListener('mousemove', onMove);
      controls.dispose();

      for (const ge of r.groupMeshes) disposeGroupEntry(ge, scene);
      for (const re of r.robotEntities) {
        re.wheelGeom?.dispose(); re.wheelMat?.dispose();
        re.lowerGeom?.dispose(); re.lowerMat?.dispose();
        re.bodyGeom?.dispose(); re.bodyMat?.dispose();
        re.stripeGeom?.dispose(); re.stripeMat?.dispose();
        re.mastGeom?.dispose(); re.mastMat?.dispose();
        re.lensGeom?.dispose(); re.lensMat?.dispose();
        re.antennaGeom?.dispose(); re.antennaMat?.dispose();
        re.antennaTipGeom?.dispose(); re.antennaTipMat?.dispose();
        re.pointerGeom?.dispose(); re.pointerMat?.dispose();
        re.scanConeGeom?.dispose(); re.scanConeMat?.dispose();
        re.trailGeom?.dispose(); re.trailMat?.dispose();
        re.pathGeom?.dispose(); re.pathMat?.dispose();
        for (const wp of (re.waypoints || [])) {
          wp.geometry?.dispose(); wp.material?.dispose();
        }
      }
      for (const ent of entranceObjs) {
        ent.mesh.geometry.dispose(); ent.mesh.material.dispose();
        ent.edges.geometry.dispose(); ent.edges.material.dispose();
      }
      for (const cl of cornerLines) cl.geom.dispose();
      cornerMat.dispose();
      floorGeom.dispose(); floorMat.dispose(); floorTex.dispose();
      frameGeom.dispose(); frameMat.dispose();
      particleGeom.dispose(); particleMat.dispose();
      renderer.dispose();
      if (renderer.domElement.parentNode === mount) mount.removeChild(renderer.domElement);
      if (labelRenderer.domElement.parentNode === mount) mount.removeChild(labelRenderer.domElement);
      r.groupMeshes = []; r.robotEntities = []; r.pickables = [];
      r.groupById.clear();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Build groups
  useEffect3D(() => {
    const r = refs.current;
    if (!r.scene) return;

    for (const ge of r.groupMeshes) disposeGroupEntry(ge, r.scene);
    r.groupMeshes = [];
    r.pickables = [];
    r.groupById.clear();

    const acc = _accent(r.tweaksRef);
    const M = cfg.params.M;

    for (const g of cfg.groups) {
      const w = groupWidth(g);
      const h = g.h;
      const cx = g.x + w / 2;
      const cz = g.y + h / 2;
      const health = groupHealth(g);

      const v = buildGroupVisual(g, M, acc.accent, cfg.scene3d);
      r.scene.add(v.grp);

      for (const re of v.rackEntities) {
        if (re.absent) continue;
        r.pickables.push(re.frame);
        for (const se of re.slotEntities) {
          r.pickables.push(se.slotMesh);
        }
      }

      const lbl = makeLabel(`${aliasOr(g, g.tag)}  ${health.on}/${health.total}`, 'f3d-group-label');
      lbl.position.set(cx, v.RACK_H + 0.95, cz);
      r.scene.add(lbl);

      let driftRing = null, driftRingGeom = null, driftRingMat = null;
      const driftFlag = !!g._drift?.any;
      if (driftFlag) {
        const drBoxGeom = new THREE.BoxGeometry(w * 1.04, v.RACK_H + v.BASE_H + 0.2, h * 1.0);
        driftRingGeom = new THREE.EdgesGeometry(drBoxGeom);
        drBoxGeom.dispose();
        driftRingMat = new THREE.LineBasicMaterial({ color: _col('#ff5470'), transparent: true, opacity: 0.85 });
        driftRing = new THREE.LineSegments(driftRingGeom, driftRingMat);
        driftRing.position.set(cx, (v.RACK_H + v.BASE_H) / 2, cz);
        r.scene.add(driftRing);
      }

      const entry = {
        groupId: g.id,
        ...v,
        label: lbl,
        driftRing, driftRingGeom, driftRingMat, driftFlag,
      };
      r.groupMeshes.push(entry);
      r.groupById.set(g.id, entry);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cfg.groups]);

  // Build robots
  useEffect3D(() => {
    const r = refs.current;
    if (!r.scene) return;

    for (const re of r.robotEntities) {
      r.scene.remove(re.grp);
      r.scene.remove(re.pathLine);
      r.scene.remove(re.trail);
      for (const wp of (re.waypoints || [])) r.scene.remove(wp);
      re.wheelGeom?.dispose(); re.wheelMat?.dispose();
      re.lowerGeom?.dispose(); re.lowerMat?.dispose();
      re.bodyGeom?.dispose(); re.bodyMat?.dispose();
      re.stripeGeom?.dispose(); re.stripeMat?.dispose();
      re.mastGeom?.dispose(); re.mastMat?.dispose();
      re.lensGeom?.dispose(); re.lensMat?.dispose();
      re.antennaGeom?.dispose(); re.antennaMat?.dispose();
      re.antennaTipGeom?.dispose(); re.antennaTipMat?.dispose();
      re.pointerGeom?.dispose(); re.pointerMat?.dispose();
      re.scanConeGeom?.dispose(); re.scanConeMat?.dispose();
      re.trailGeom?.dispose(); re.trailMat?.dispose();
      re.pathGeom?.dispose(); re.pathMat?.dispose();
      for (const wp of (re.waypoints || [])) {
        wp.geometry?.dispose(); wp.material?.dispose();
      }
    }
    r.robotEntities = [];

    const acc = _accent(r.tweaksRef);

    for (const robot of (cfg.robots || [])) {
      const v = buildRobotVisual(robot, acc.accent);
      r.scene.add(v.grp);

      const label = makeLabel(`${robot.name} · MOVE`, 'f3d-robot-label');
      label.position.set(0, 0.92, 0);
      v.grp.add(label);

      const pathPts = robot.path.map(p => new THREE.Vector3(p.x, 0.04, p.y));
      if (pathPts.length) pathPts.push(pathPts[0].clone());
      const pathGeom = new THREE.BufferGeometry().setFromPoints(pathPts);
      const pathMat = new THREE.LineDashedMaterial({
        color: _col(acc.dim), dashSize: 0.25, gapSize: 0.18, transparent: true, opacity: 0.55,
      });
      const pathLine = new THREE.Line(pathGeom, pathMat);
      pathLine.computeLineDistances();
      r.scene.add(pathLine);

      const waypoints = [];
      for (const p of robot.path) {
        const inspect = !!p.inspect;
        const wpGeom = inspect
          ? new THREE.SphereGeometry(0.13, 12, 10)
          : new THREE.SphereGeometry(0.07, 10, 8);
        const wpMat = new THREE.MeshBasicMaterial({
          color: _col(inspect ? '#f5b454' : acc.dim),
          transparent: true, opacity: 0.85,
        });
        const wp = new THREE.Mesh(wpGeom, wpMat);
        wp.position.set(p.x, 0.06, p.y);
        r.scene.add(wp);
        waypoints.push(wp);
        if (inspect) {
          const facing = p.inspect.facing || 0;
          const fx = Math.cos(facing) * 0.6;
          const fz = Math.sin(facing) * 0.6;
          const arrowGeom = new THREE.BufferGeometry().setFromPoints([
            new THREE.Vector3(p.x, 0.06, p.y),
            new THREE.Vector3(p.x + fx, 0.06, p.y + fz),
          ]);
          const arrowMat = new THREE.LineBasicMaterial({ color: _col('#f5b454'), transparent: true, opacity: 0.8 });
          const arrow = new THREE.Line(arrowGeom, arrowMat);
          r.scene.add(arrow);
          waypoints.push(arrow);
        }
      }

      const trailMaxPts = 32;
      const trailGeom = new THREE.BufferGeometry();
      trailGeom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(trailMaxPts * 3), 3));
      trailGeom.setDrawRange(0, 0);
      const trailMat = new THREE.LineBasicMaterial({
        color: _col(acc.accent), transparent: true, opacity: 0.7,
        blending: THREE.AdditiveBlending,
      });
      const trail = new THREE.Line(trailGeom, trailMat);
      r.scene.add(trail);

      r.robotEntities.push({
        robotId: robot.id, ...v,
        label, pathLine, pathGeom, pathMat,
        waypoints, trail, trailGeom, trailMat,
      });
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cfg.robots]);

  // Tweaks → recolor
  useEffect3D(() => {
    const r = refs.current;
    if (!r.scene) return;
    const acc = _accent(tweaks);
    const bg = _bg(tweaks);
    r.scene.background = _col(bg.b0);
    if (r.scene.fog) r.scene.fog.color = _col(bg.b0);

    for (const ge of r.groupMeshes) {
      ge.baseEdgesMat.color = _col(acc.accent);
      for (const re of ge.rackEntities) {
        if (re.absent) {
          re.placeholderMat.color = _col(acc.accent);
          continue;
        }
        re.edgesMat.color = _col(acc.accent);
      }
    }
    for (const re of r.robotEntities) {
      re.bodyMat.emissive = _col(acc.accent);
      re.stripeMat.color = _col(acc.accent);
      re.lensMat.color = _col(acc.accent);
      re.lensMat.emissive = _col(acc.accent);
      re.pointerMat.color = _col(acc.accent);
      re.pointerMat.emissive = _col(acc.accent);
      re.pathMat.color = _col(acc.dim);
      re.trailMat.color = _col(acc.accent);
    }
    if (r.accentLight) r.accentLight.color = _col(acc.accent);
    if (r.rimLight) r.rimLight.color = _col(acc.dim);
    if (r.particles) r.particles.material.color = _col(acc.accent);
  }, [tweaks]);

  const focusGroup = (groupId) => {
    const r = refs.current;
    if (!r.camera || !r.controls) return;
    const grp = cfg.groups.find(g => g.id === groupId);
    if (!grp) return;
    const cx = grp.x + groupWidth(grp) / 2;
    const cz = grp.y + grp.h / 2;
    r.controls.target.set(cx, 0, cz);
    r.camera.position.set(cx + 2, 4, cz + 5);
    r.controls.update();
  };

  const toggleGroupPower = (groupId, on) => {
    if (!setCfg) return;
    const pwr = on ? 'on' : 'off';
    setCfg(c => ({
      ...c,
      groups: c.groups.map(g => g.id !== groupId ? g : {
        ...g,
        racks: g.racks.map(rack => ({
          ...rack,
          slots: (rack.slots || []).map(s => s.kind !== 'empty' ? { ...s, power: pwr } : s),
        })),
      }),
    }));
  };

  const resetCamera = () => {
    const r = refs.current;
    if (!r.camera || !r.controls) return;
    const { gridW, gridH } = cfg.params;
    r.camera.position.set(gridW * 0.55, Math.max(gridW, gridH) * 0.7, gridH * 1.1);
    r.controls.target.set(gridW / 2, 0, gridH / 2);
    r.controls.update();
  };

  const topDown = () => {
    const r = refs.current;
    if (!r.camera || !r.controls) return;
    const { gridW, gridH } = cfg.params;
    r.camera.position.set(gridW / 2, Math.max(gridW, gridH) * 1.6, gridH / 2 + 0.001);
    r.controls.target.set(gridW / 2, 0, gridH / 2);
    r.controls.update();
  };

  const sideStates = useMemo3D(() => {
    const out = {};
    for (const robot of (cfg.robots || [])) {
      const m = refs.current.motionStates[robot.id]?.motion
        || { x: robot.path[0]?.x || 0, y: robot.path[0]?.y || 0, angle: 0, state: 'moving', wpIdx: 0 };
      out[robot.id] = { motion: m };
    }
    return out;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cfg.robots, sideTick]);

  return (
    <div className="floor-wrap view-enter">
      <div className="floor-canvas f3d-canvas" style={{ position: 'relative', overflow: 'hidden' }}>
        <div className="f3d-corner tl" />
        <div className="f3d-corner tr" />
        <div className="f3d-corner bl" />
        <div className="f3d-corner br" />
        <div ref={mountRef} style={{
          position: 'absolute', inset: 0, width: '100%', height: '100%',
        }} />
        <div className="f3d-overlay">
          <button className="btn small" onClick={resetCamera} title="Reset camera">⟲ Iso</button>
          <button className="btn small" onClick={topDown} title="Top-down view">⤓ Top</button>
          <div className="f3d-hint mono">
            <span>Drag · Rotate</span>
            <span>Right-click · 메뉴</span>
            <span>Wheel · Zoom</span>
          </div>
        </div>
        {ctxMenu && (
          <div className="f3d-ctx-menu" style={{ left: ctxMenu.x, top: ctxMenu.y }}>
            <div className="f3d-ctx-title">{ctxMenu.label}</div>
            <button className="f3d-ctx-item" onClick={() => { onPickGroup(ctxMenu.groupId); setCtxMenu(null); }}>
              Rack 탭으로 이동
            </button>
            <button className="f3d-ctx-item" onClick={() => { focusGroup(ctxMenu.groupId); setCtxMenu(null); }}>
              카메라 포커스
            </button>
            <div className="f3d-ctx-sep" />
            <button className="f3d-ctx-item power-on" onClick={() => { toggleGroupPower(ctxMenu.groupId, true); setCtxMenu(null); }}>
              전원 전체 ON
            </button>
            <button className="f3d-ctx-item power-off" onClick={() => { toggleGroupPower(ctxMenu.groupId, false); setCtxMenu(null); }}>
              전원 전체 OFF
            </button>
            <div className="f3d-ctx-sep" />
            <button className="f3d-ctx-item" onClick={() => setCtxMenu(null)}>닫기</button>
          </div>
        )}
      </div>
      <FloorSide cfg={cfg} states={sideStates} onPickGroup={onPickGroup} />
    </div>
  );
}

Object.assign(window, { FloorPlan3D });
