(function(){
'use strict';
const THREE = window.THREE;
const OrbitControls = window.OrbitControls;

// GLTFLoader for uploaded 3D models is loaded via a CONTAINED dynamic
// import() (never a static top-level import), so a broken/missing loader source
// only disables model loading and never aborts this whole module — the same
// rule the gyro DeviceOrientationControls handling follows. Attached to each
// piece's instrumented THREE so generated code can call `new THREE.GLTFLoader()`
// without a forbidden import/fetch token. Loaded once, module-wide.
// OBJ is not a supported upload/generation format (dropped in favor of
// GLTF/GLB, which are self-contained single files; OBJ typically needs
// companion .mtl/texture files this app's upload flow doesn't support).
let _GLTFLoaderCtor = window.GLTFLoader || null;
// DeviceOrientationControls was removed from three.js's own examples/jsm
// bundle as of 0.160.0 (confirmed: that path 404s on the CDN). A static
// top-level import of a 404'ing module aborts loading this ENTIRE file —
// breaking Three.js, A-Frame, and p5/c2/svg immersive mounting alike, since
// they're all exported from here. Loaded lazily inside setupGyroControls()
// instead, wrapped in try/catch, so a missing/broken source only disables
// the gyro feature itself and never the rest of this module.

// Constants
const WALL_CENTER = new THREE.Vector3(0, 1.35, -1.08);
const TARGET_OFFSET = new THREE.Vector3(0, -0.16, 0);
const MAX_ART_WIDTH = 6.4;
const MAX_ART_HEIGHT = 4.6;
const PRESENTATION_MAX_ART_WIDTH = 5.2;
const PRESENTATION_MAX_ART_HEIGHT = 3.9;

const NORMALIZED_PRESENTATION_GALLERY_PROFILE = {
  maxArtWidth: PRESENTATION_MAX_ART_WIDTH,
  maxArtHeight: PRESENTATION_MAX_ART_HEIGHT,
  framingMultiplier: 1.58,
  targetOffset: { x: 0, y: 0, z: 0 },
  cameraYOffset: 0.02,
};

const WALL_FRAME_ART_WIDTH = 2.2;
const WALL_FRAME_ART_HEIGHT = 1.65;
const WALL_FRAME_SLOT_WIDTH = 3.2;
const WALL_FRAME_SLOT_HEIGHT = 2.4;
const WALL_LABEL_HEIGHT = WALL_FRAME_ART_WIDTH * (80 / 512); // = 0.34375
const WALL_LABEL_GAP = 0.08;
const EXHIBIT_FLOOR_CLEARANCE = 0.2;
const EXHIBIT_FRAME_ASPECT = WALL_FRAME_ART_WIDTH / WALL_FRAME_ART_HEIGHT;

function readViewVector(value) {
  if (!value || typeof value !== "object") return null;
  const x = Number(value.x);
  const y = Number(value.y);
  const z = Number(value.z);
  if (![x, y, z].every(Number.isFinite)) return null;
  return new THREE.Vector3(x, y, z);
}

function applyShellViewState(shell, viewState) {
  const camera = readViewVector(viewState?.camera);
  const target = readViewVector(viewState?.target);
  if (!shell?.camera || !shell?.controls || !camera || !target) return false;
  shell.camera.position.copy(camera);
  shell.controls.target.copy(target);
  shell.camera.lookAt(target);
  shell.camera.updateMatrixWorld?.(true);
  shell.controls.update();
  return true;
}

function shellViewState(shell, extra = {}) {
  const state = { ...extra };
  if (shell?.camera) {
    state.camera = {
      x: Number(shell.camera.position.x.toFixed(5)),
      y: Number(shell.camera.position.y.toFixed(5)),
      z: Number(shell.camera.position.z.toFixed(5)),
    };
  }
  if (shell?.controls?.target) {
    state.target = {
      x: Number(shell.controls.target.x.toFixed(5)),
      y: Number(shell.controls.target.y.toFixed(5)),
      z: Number(shell.controls.target.z.toFixed(5)),
    };
  }
  return state;
}

function bakeOrbitHandPose(camera, controls, gyroController) {
  if (!camera?.position || !controls?.target) return false;
  const distance = Math.max(0.001, camera.position.distanceTo(controls.target));
  const forward = new THREE.Vector3();
  camera.getWorldDirection(forward);
  if (forward.lengthSq() < 1e-8) return false;

  // The displayed quaternion already contains the additive hand offset.
  // Relinquish only that helper's bookkeeping, then make the displayed
  // direction OrbitControls' new base so ordinary input resumes in place.
  gyroController?.releaseHandOffset?.();
  controls.target.copy(camera.position).addScaledVector(forward.normalize(), distance);
  controls.update?.();
  return true;
}

function animateShellViewReset(shell, viewState, duration = 360) {
  const destinationCamera = readViewVector(viewState?.camera);
  const destinationTarget = readViewVector(viewState?.target);
  if (!shell?.camera || !shell?.controls || !destinationCamera || !destinationTarget) return Promise.resolve(false);
  const fromCamera = shell.camera.position.clone();
  const fromTarget = shell.controls.target.clone();
  const startedAt = performance.now();
  return new Promise((resolve) => {
    function step(now) {
      const t = Math.min(1, (now - startedAt) / duration);
      const eased = 1 - Math.pow(1 - t, 3);
      shell.camera.position.lerpVectors(fromCamera, destinationCamera, eased);
      shell.controls.target.lerpVectors(fromTarget, destinationTarget, eased);
      shell.camera.lookAt(shell.controls.target);
      shell.controls.update();
      if (t < 1) requestAnimationFrame(step);
      else resolve(true);
    }
    requestAnimationFrame(step);
  });
}

function encodeViewState(viewState) {
  try {
    const json = JSON.stringify(viewState || {});
    const bytes = new TextEncoder().encode(json);
    let binary = "";
    bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
    return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
  } catch (_) {
    return "";
  }
}

function computeMountedArtworkLayout(aspect, profile = {}) {
  const safeAspect = Math.max(aspect, 0.35);
  const maxArtWidth = profile.maxArtWidth ?? MAX_ART_WIDTH;
  const maxArtHeight = profile.maxArtHeight ?? MAX_ART_HEIGHT;
  let width = maxArtWidth;
  let height = width / safeAspect;
  if (height > maxArtHeight) {
    height = maxArtHeight;
    width = height * safeAspect;
  }
  return { width, height, aspect: safeAspect };
}

function isCompactImmersiveViewport(width) {
  return width < 1024;
}

function computeMountedArtCenterY(artHeight) {
  return Math.max(WALL_CENTER.y, artHeight / 2 + EXHIBIT_FLOOR_CLEARANCE);
}

function computeExhibitGridCenterY(rows) {
  const gridRows = Math.max(1, rows);
  const minBottomSlotCenterY = (WALL_FRAME_ART_HEIGHT / 2) + (WALL_LABEL_HEIGHT / 2) + WALL_LABEL_GAP + EXHIBIT_FLOOR_CLEARANCE;
  return Math.max(WALL_CENTER.y, ((gridRows - 1) / 2) * WALL_FRAME_SLOT_HEIGHT + minBottomSlotCenterY);
}

function computeExhibitBottomVisibleY(rows) {
  const gridRows = Math.max(1, rows);
  const gridCenterY = computeExhibitGridCenterY(gridRows);
  const bottomSlotCenterY = gridCenterY - (((gridRows - 1) / 2) * WALL_FRAME_SLOT_HEIGHT);
  return bottomSlotCenterY - (WALL_FRAME_ART_HEIGHT / 2) - (WALL_LABEL_HEIGHT / 2) - WALL_LABEL_GAP;
}

function createFloorClickNavigation(camera, controls, floorMesh, domElement, options = {}) {
  const { minZ = 0.5, maxZ = 8, maxX = 8, duration = 350 } = options;
  const raycaster = new THREE.Raycaster();
  let animFromTarget = null, animToTarget = null, animFromCam = null, animToCam = null, animStart = 0;
  let downX = 0, downY = 0;
  let enabled = true;

  function onPointerDown(e) {
    if (!enabled) return;
    downX = e.clientX;
    downY = e.clientY;
  }

  function onPointerUp(e) {
    if (!enabled) return;
    if (Math.hypot(e.clientX - downX, e.clientY - downY) >= 6) return;
    const rect = domElement.getBoundingClientRect();
    raycaster.setFromCamera(
      new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1),
      camera,
    );
    const hits = raycaster.intersectObject(floorMesh, false);
    if (!hits.length) return;
    const hit = hits[0].point;
    const shift = new THREE.Vector3(
      Math.max(-maxX, Math.min(maxX, hit.x)) - camera.position.x,
      0,
      Math.max(minZ, Math.min(maxZ, hit.z)) - camera.position.z,
    );
    if (shift.lengthSq() < 0.003) return;
    animFromTarget = controls.target.clone();
    animToTarget = animFromTarget.clone().add(shift);
    animFromCam = camera.position.clone();
    animToCam = animFromCam.clone().add(shift);
    animStart = performance.now();
    controls.enabled = false;
  }

  function update() {
    if (!animFromTarget || !animToTarget) return;
    const t = Math.min((performance.now() - animStart) / duration, 1);
    const eased = 1 - (1 - t) ** 3;
    controls.target.lerpVectors(animFromTarget, animToTarget, eased);
    camera.position.lerpVectors(animFromCam, animToCam, eased);
    controls.update();
    if (t >= 1) {
      controls.enabled = true;
      animFromTarget = animToTarget = animFromCam = animToCam = null;
    }
  }

  function dispose() {
    domElement.removeEventListener("pointerdown", onPointerDown);
    domElement.removeEventListener("pointerup", onPointerUp);
    controls.enabled = true;
  }

  domElement.addEventListener("pointerdown", onPointerDown);
  domElement.addEventListener("pointerup", onPointerUp);
  function setEnabled(next) {
    enabled = !!next;
    if (!enabled) {
      animFromTarget = animToTarget = animFromCam = animToCam = null;
    }
  }

  return { update, dispose, setEnabled };
}

function computeOrbitKeyboardMotion(forward, keys, speed) {
  const activeKeys = keys instanceof Set ? keys : new Set(keys);
  let fwdScale = 0, rightScale = 0;
  if (activeKeys.has("ArrowUp")) fwdScale += speed;
  if (activeKeys.has("ArrowDown")) fwdScale -= speed;
  if (activeKeys.has("ArrowLeft")) rightScale -= speed;
  if (activeKeys.has("ArrowRight")) rightScale += speed;
  if (fwdScale === 0 && rightScale === 0) return { dx: 0, dy: 0, dz: 0 };

  const horizontalLength = Math.sqrt(forward.x ** 2 + forward.z ** 2);
  const right = horizontalLength > 1e-6
    ? { x: -forward.z / horizontalLength, y: 0, z: forward.x / horizontalLength }
    : { x: 1, y: 0, z: 0 };

  return {
    dx: (forward.x * fwdScale) + (right.x * rightScale),
    dy: forward.y * fwdScale,
    dz: (forward.z * fwdScale) + (right.z * rightScale),
  };
}

function createKeyboardNavigation(controls, options = {}) {
  const { speed = 0.05, minX = -8, maxX = 8, minY = -Infinity, maxY = Infinity, minZ = 0.5, maxZ = Infinity, container, isEnabled } = options;
  const keys = new Set();
  const target = window;

  function mapMovementKey(e) {
    if (e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown") return e.key;
    return null;
  }

  function keyboardNavEnabled() {
    if (typeof isEnabled === "function") return isEnabled() !== false;
    if (!container) return true;
    return container.dataset.keyboardNavigationDisabled !== "true";
  }

  function shouldIgnoreKeyEventTarget(eventTarget) {
    if (!(eventTarget instanceof Element)) return false;
    if (eventTarget.tagName === "IFRAME") return true;
    if (eventTarget instanceof HTMLInputElement || eventTarget instanceof HTMLTextAreaElement || eventTarget instanceof HTMLSelectElement) return true;
    if (eventTarget.isContentEditable) return true;
    return Boolean(eventTarget.closest("[contenteditable=\"true\"], [contenteditable=\"\"], input, textarea, select"));
  }

  function onKeyDown(e) {
    const mappedKey = mapMovementKey(e);
    if (!mappedKey) return;
    if (!keyboardNavEnabled() || shouldIgnoreKeyEventTarget(e.target)) return;
    e.preventDefault();
    keys.add(mappedKey);
  }

  function onKeyUp(e) {
    const mappedKey = mapMovementKey(e);
    if (!mappedKey) return;
    if (!keyboardNavEnabled() || shouldIgnoreKeyEventTarget(e.target)) {
      keys.delete(mappedKey);
      return;
    }
    keys.delete(mappedKey);
    keys.delete(e.key);
  }

  function clearKeys() {
    keys.clear();
  }

  function onWindowBlur() {
    clearKeys();
  }

  const _fwd = new THREE.Vector3();
  // `speed` is tuned as a per-tick step at a steady 60fps. animateControls()'s
  // actual tick rate varies with device/browser/fullscreen state, so scale by
  // elapsed real time to keep navigation speed consistent regardless of frame
  // rate — without this, the exact same key-hold duration could move the
  // camera a little or a lot depending on how fast frames happen to tick.
  const TARGET_FRAME_MS = 1000 / 60;
  const MAX_FRAME_SCALE = 4; // cap the catch-up after a stutter/tab-background gap
  let lastUpdateAt = null;

  function update() {
    if (!keyboardNavEnabled()) {
      clearKeys();
      return false;
    }
    const now = performance.now();
    const frameScale = lastUpdateAt === null
      ? 1
      : Math.min(MAX_FRAME_SCALE, Math.max(0, (now - lastUpdateAt) / TARGET_FRAME_MS));
    lastUpdateAt = now;
    if (!controls.enabled || keys.size === 0) return false;
    controls.object.getWorldDirection(_fwd);
    const resolvedSpeed = (typeof speed === "function" ? speed(controls) : speed) * frameScale;
    const { dx, dy, dz } = computeOrbitKeyboardMotion(_fwd, keys, resolvedSpeed);
    const newCamX = Math.max(minX, Math.min(maxX, controls.object.position.x + dx));
    const newCamY = Math.max(minY, Math.min(maxY, controls.object.position.y + dy));
    const newCamZ = Math.max(minZ, Math.min(maxZ, controls.object.position.z + dz));
    const actualDx = newCamX - controls.object.position.x;
    const actualDy = newCamY - controls.object.position.y;
    const actualDz = newCamZ - controls.object.position.z;
    if (Math.abs(actualDx) < 1e-6 && Math.abs(actualDy) < 1e-6 && Math.abs(actualDz) < 1e-6) return false;
    controls.object.position.x = newCamX;
    controls.object.position.y = newCamY;
    controls.object.position.z = newCamZ;
    controls.target.x += actualDx;
    controls.target.y += actualDy;
    controls.target.z += actualDz;
    return true;
  }

  function onContainerClick() { container?.focus(); }
  if (container) {
    container.tabIndex = 0;
    container.addEventListener("click", onContainerClick, { passive: true });
  }

  function dispose() {
    target.removeEventListener("keydown", onKeyDown);
    target.removeEventListener("keyup", onKeyUp);
    window.removeEventListener("blur", onWindowBlur);
    if (container) container.removeEventListener("click", onContainerClick);
    clearKeys();
  }

  target.addEventListener("keydown", onKeyDown);
  target.addEventListener("keyup", onKeyUp);
  window.addEventListener("blur", onWindowBlur);
  return { update, dispose, clearKeys };
}

// Hand navigation for the gallery ROOM camera (exhibit wall): exposes the
// same interaction-controller interface the stage chrome's hand-control
// toggle drives for single pieces ({supportsHandControl, setHandSteering,
// handPoint}), but steers the room's OrbitControls instead of a piece.
// Hand position maps to a velocity joystick: hold the hand right of center
// to strafe right, above center to walk forward — with a center deadzone so
// a resting hand parks the camera. Reuses computeOrbitKeyboardMotion()'s
// ground-plane math and createKeyboardNavigation()'s clamps/frame scaling.
function createRoomHandNavigation(controls, options = {}) {
  const { speed = 0.09, minX = -8, maxX = 8, minZ = 0.5, maxZ = Infinity } = options;
  const DEADZONE = 0.12;
  let steering = false;
  let hand = null; // latest {nx, ny}, nulled when the hand disappears
  let gestureTravel = null;
  const _fwd = new THREE.Vector3();
  const TARGET_FRAME_MS = 1000 / 60;
  const MAX_FRAME_SCALE = 4;
  let lastUpdateAt = null;

  function axis(value) {
    const offset = value - 0.5;
    if (Math.abs(offset) < DEADZONE) return 0;
    // Rescale past the deadzone so motion ramps smoothly from 0 to 1.
    return (offset - Math.sign(offset) * DEADZONE) / (0.5 - DEADZONE);
  }

  function update() {
    const now = performance.now();
    const frameScale = lastUpdateAt === null
      ? 1
      : Math.min(MAX_FRAME_SCALE, Math.max(0, (now - lastUpdateAt) / TARGET_FRAME_MS));
    lastUpdateAt = now;
    if (!steering || (!hand && !gestureTravel) || !controls.enabled) return false;
    const rightScale = (gestureTravel ? gestureTravel.right : axis(hand.nx)) * speed * frameScale;
    const fwdScale = (gestureTravel ? gestureTravel.forward : axis(1 - hand.ny)) * speed * frameScale; // hand up = forward
    if (rightScale === 0 && fwdScale === 0) return false;
    controls.object.getWorldDirection(_fwd);
    const horizontal = Math.sqrt(_fwd.x ** 2 + _fwd.z ** 2) || 1;
    const right = { x: -_fwd.z / horizontal, z: _fwd.x / horizontal };
    const dx = (_fwd.x * fwdScale) + (right.x * rightScale);
    const dz = (_fwd.z * fwdScale) + (right.z * rightScale);
    const newCamX = Math.max(minX, Math.min(maxX, controls.object.position.x + dx));
    const newCamZ = Math.max(minZ, Math.min(maxZ, controls.object.position.z + dz));
    const actualDx = newCamX - controls.object.position.x;
    const actualDz = newCamZ - controls.object.position.z;
    if (Math.abs(actualDx) < 1e-6 && Math.abs(actualDz) < 1e-6) return false;
    controls.object.position.x = newCamX;
    controls.object.position.z = newCamZ;
    controls.target.x += actualDx;
    controls.target.z += actualDz;
    return true;
  }

  return {
    // Stage-chrome interaction-controller interface:
    supportsHandControl: true,
    supportsCameraBackground: false,
    setHandSteering(on) { steering = !!on; if (!on) { hand = null; gestureTravel = null; } },
    handPoint(nx, ny) { hand = { nx, ny }; },
    handCommand(command) {
      if (!steering || !command) return;
      if (command.type === "look") {
        gestureTravel = null;
        const offset = controls.object.position.clone().sub(controls.target);
        const spherical = new THREE.Spherical().setFromVector3(offset);
        const desiredTheta = (command.x - 0.5) * Math.PI * 1.5;
        const desiredPhi = Math.PI / 2 + (command.y - 0.5) * Math.PI * 0.7;
        spherical.theta += (desiredTheta - spherical.theta) * 0.1;
        spherical.phi += (desiredPhi - spherical.phi) * 0.1;
        spherical.phi = Math.max(0.15, Math.min(Math.PI - 0.15, spherical.phi));
        offset.setFromSpherical(spherical);
        controls.object.position.copy(controls.target).add(offset);
      } else if (command.type === "orbit") {
        gestureTravel = null;
        const offset = controls.object.position.clone().sub(controls.target);
        const spherical = new THREE.Spherical().setFromVector3(offset);
        spherical.theta += command.yaw || 0;
        spherical.phi = Math.max(0.15, Math.min(Math.PI - 0.15, spherical.phi + (command.pitch || 0)));
        controls.object.position.copy(controls.target).add(offset.setFromSpherical(spherical));
      } else if (command.type === "travel") {
        gestureTravel = { forward: command.forward || 0, right: command.right || 0 };
      } else if (command.type === "zoom") {
        const offset = controls.object.position.clone().sub(controls.target);
        const distance = Math.max(0.45, Math.min(120, offset.length() * (1 - (command.delta || 0))));
        controls.object.position.copy(controls.target).add(offset.setLength(distance));
      } else if (command.type === "stop") {
        gestureTravel = null;
      }
      controls.update();
    },
    handLost() { hand = null; gestureTravel = null; },
    resetView() {
      hand = null;
      gestureTravel = null;
      return typeof options.resetView === "function" ? options.resetView() : Promise.resolve(false);
    },
    setBackgroundVideo() { return false; },
    clearBackgroundVideo() {},
    // Room-side integration:
    update,
    dispose() { steering = false; hand = null; gestureTravel = null; },
  };
}

// Camera feed as a blended, camera-attached background quad for the mounted
// Three.js/A-Frame viewers — the immersive twin of piece-runtime.js's
// createCameraBlendQuadHooks(). Supports the opacity slider (the old
// `scene.background = VideoTexture` swap could not blend); at opacity 1 it
// looks identical to the old opaque swap. Getters (not captured refs) so
// A-Frame can hand over its scene/camera lazily.
function createCameraBlendQuadController(getThree, getScene, getCamera) {
  let cameraQuad = null;
  let videoTexture = null;
  let cameraOpacity = 1;
  return {
    setBackgroundVideo(video) {
      const T = getThree();
      const scene = getScene();
      const camera = getCamera();
      if (!video || !scene || !camera || !T?.VideoTexture) return false;
      this.clearBackgroundVideo();
      videoTexture = new T.VideoTexture(video);
      if (T.SRGBColorSpace) videoTexture.colorSpace = T.SRGBColorSpace;
      // Mirror horizontally — matches the regular view's selfie-style overlay.
      videoTexture.wrapS = T.RepeatWrapping;
      videoTexture.repeat.x = -1;
      videoTexture.offset.x = 1;
      const material = new T.MeshBasicMaterial({
        map: videoTexture, transparent: true, opacity: cameraOpacity,
        depthTest: false, depthWrite: false, toneMapped: false, fog: false,
      });
      cameraQuad = new T.Mesh(new T.PlaneGeometry(1, 1), material);
      cameraQuad.renderOrder = -1;
      cameraQuad.frustumCulled = false;
      cameraQuad.onBeforeRender = (_renderer, _scene, renderCamera) => {
        const cam = renderCamera && renderCamera.isPerspectiveCamera ? renderCamera : null;
        if (!cam) return;
        const dist = Math.max(cam.near * 2, 0.05);
        cameraQuad.position.set(0, 0, -dist);
        const quadHeight = 2 * dist * Math.tan((cam.fov * Math.PI) / 360);
        cameraQuad.scale.set(quadHeight * (cam.aspect || 1), quadHeight, 1);
      };
      if (!camera.parent) scene.add(camera);
      camera.add(cameraQuad);
      return true;
    },
    clearBackgroundVideo() {
      if (cameraQuad) {
        try { cameraQuad.parent?.remove(cameraQuad); } catch (_) {}
        try { cameraQuad.geometry.dispose(); } catch (_) {}
        try { cameraQuad.material.dispose(); } catch (_) {}
        cameraQuad = null;
      }
      if (videoTexture) {
        try { videoTexture.dispose(); } catch (_) {}
        videoTexture = null;
      }
    },
    setBackgroundOpacity(value) {
      cameraOpacity = Math.max(0, Math.min(1, Number(value)));
      if (cameraQuad) cameraQuad.material.opacity = cameraOpacity;
    },
    getBackgroundOpacity() { return cameraOpacity; },
  };
}

// Camera feed projected onto the mounted gallery room's back wall (the
// user-facing framing: the framed piece hangs in the visitor's own space).
// The mirrored VideoTexture replaces the wall material's map, blended over
// the original wall color by opacity; toggle-off restores the wall exactly.
function createGalleryWallCameraController(shell) {
  let videoTexture = null;
  let savedWall = null;
  let cameraOpacity = 1;
  return {
    setBackgroundVideo(video) {
      const material = shell?.backWall?.material;
      if (!video || !material) return false;
      this.clearBackgroundVideo();
      videoTexture = new THREE.VideoTexture(video);
      if (THREE.SRGBColorSpace) videoTexture.colorSpace = THREE.SRGBColorSpace;
      videoTexture.wrapS = THREE.RepeatWrapping;
      videoTexture.repeat.x = -1;
      videoTexture.offset.x = 1;
      savedWall = {
        map: material.map ?? null,
        color: material.color?.clone?.() ?? null,
        transparent: material.transparent,
        opacity: material.opacity,
      };
      material.map = videoTexture;
      // A tinted wall would color-cast the feed; white keeps it faithful.
      material.color?.set?.('#ffffff');
      material.transparent = true;
      material.opacity = cameraOpacity;
      material.needsUpdate = true;
      return true;
    },
    clearBackgroundVideo() {
      const material = shell?.backWall?.material;
      if (material && savedWall) {
        material.map = savedWall.map;
        if (savedWall.color) material.color?.copy?.(savedWall.color);
        material.transparent = savedWall.transparent;
        material.opacity = savedWall.opacity;
        material.needsUpdate = true;
        savedWall = null;
      }
      if (videoTexture) {
        try { videoTexture.dispose(); } catch (_) {}
        videoTexture = null;
      }
    },
    setBackgroundOpacity(value) {
      cameraOpacity = Math.max(0, Math.min(1, Number(value)));
      const material = shell?.backWall?.material;
      if (material && videoTexture) material.opacity = cameraOpacity;
    },
    getBackgroundOpacity() { return cameraOpacity; },
  };
}

function createMountedGalleryShell(stage, aspect, profile = {}) {
  const layout = computeMountedArtworkLayout(aspect, profile);
  const canvas = document.createElement("canvas");
  canvas.style.width = "100%";
  canvas.style.height = "100%";
  canvas.style.display = "block";
  canvas.style.touchAction = "none";
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  stage.innerHTML = "";
  stage.appendChild(canvas);

  const scene = new THREE.Scene();
  scene.background = new THREE.Color("#f1ece2");
  scene.fog = new THREE.Fog("#f1ece2", 13, 34);

  const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 100);
  const controls = new OrbitControls(camera, canvas);
  controls.enableDamping = true;
  controls.enablePan = true;
  controls.minPolarAngle = 0.01;
  controls.maxPolarAngle = Math.PI - 0.01;

  scene.add(new THREE.AmbientLight(0xffffff, 1.38));
  const keyLight = new THREE.DirectionalLight(0xfffcf6, 0.9);
  keyLight.position.set(0.8, 4.8, 5.2);
  scene.add(keyLight);
  const fillLight = new THREE.DirectionalLight(0xf3ede2, 0.35);
  fillLight.position.set(-3.1, 2.4, 1.8);
  scene.add(fillLight);

  const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(22, 18),
    new THREE.MeshStandardMaterial({ color: "#d7d0c4", roughness: 0.98, metalness: 0 }),
  );
  floor.rotation.x = -Math.PI / 2;
  floor.position.set(0, 0, 1.9);
  scene.add(floor);

  const backWall = new THREE.Mesh(
    new THREE.PlaneGeometry(20, 11),
    new THREE.MeshStandardMaterial({ color: "#f8f5ee", roughness: 1, metalness: 0 }),
  );
  backWall.position.set(0, 2.7, -1.35);
  scene.add(backWall);

  const framePanel = new THREE.Mesh(
    new THREE.BoxGeometry(layout.width + 0.3, layout.height + 0.3, 0.05),
    new THREE.MeshStandardMaterial({ color: "#fcfaf6", roughness: 0.96, metalness: 0 }),
  );
  const artCenterY = computeMountedArtCenterY(layout.height);
  framePanel.position.set(WALL_CENTER.x, artCenterY, -1.16);
  scene.add(framePanel);

  const artMaterial = new THREE.MeshBasicMaterial({ color: "#ffffff" });
  const artMesh = new THREE.Mesh(
    new THREE.PlaneGeometry(layout.width, layout.height),
    artMaterial,
  );
  artMesh.position.set(WALL_CENTER.x, artCenterY, WALL_CENTER.z);
  scene.add(artMesh);

  const frameMesh = new THREE.Mesh(
    new THREE.BoxGeometry(layout.width + 0.12, layout.height + 0.12, 0.03),
    new THREE.MeshStandardMaterial({ color: "#d8d1c7", roughness: 0.92, metalness: 0 }),
  );
  frameMesh.position.set(WALL_CENTER.x, artCenterY, -1.12);
  scene.add(frameMesh);

  const shell = {
    canvas,
    renderer,
    scene,
    camera,
    controls,
    floor,
    backWall,
    framePanel,
    artMesh,
    artMaterial,
    frameMesh,
    layout,
    profile,
    artCenterY,
  };
  fitMountedGalleryCamera(shell, stage);
  return shell;
}

function updateMountedGalleryLayout(shell, aspect) {
  const layout = computeMountedArtworkLayout(aspect, shell.profile);
  shell.layout = layout;
  const artCenterY = computeMountedArtCenterY(layout.height);
  shell.artCenterY = artCenterY;
  shell.artMesh.geometry.dispose();
  shell.artMesh.geometry = new THREE.PlaneGeometry(layout.width, layout.height);
  shell.artMesh.position.y = artCenterY;
  shell.frameMesh.geometry.dispose();
  shell.frameMesh.geometry = new THREE.BoxGeometry(layout.width + 0.12, layout.height + 0.12, 0.03);
  shell.frameMesh.position.y = artCenterY;
  shell.framePanel.geometry.dispose();
  shell.framePanel.geometry = new THREE.BoxGeometry(layout.width + 0.3, layout.height + 0.3, 0.05);
  shell.framePanel.position.y = artCenterY;
}

function fitMountedGalleryCamera(shell, stage, framingMultiplier = shell.profile.framingMultiplier ?? 1.28, resetCamera = true) {
  const width = stage.clientWidth >= 50 ? stage.clientWidth : window.innerWidth;
  const height = stage.clientHeight >= 50 ? stage.clientHeight : window.innerHeight;
  shell.camera.aspect = width / Math.max(height, 1);
  shell.camera.updateProjectionMatrix();
  shell.renderer.setSize(width, height, false);

  const artCenterY = shell.artCenterY ?? WALL_CENTER.y;
  const targetOffset = shell.profile.targetOffset
    ? new THREE.Vector3(shell.profile.targetOffset.x, shell.profile.targetOffset.y, shell.profile.targetOffset.z)
    : TARGET_OFFSET;
  const artCenter = new THREE.Vector3(WALL_CENTER.x, artCenterY, WALL_CENTER.z);
  const target = artCenter.clone().add(targetOffset);
  const verticalFov = THREE.MathUtils.degToRad(shell.camera.fov);
  const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * shell.camera.aspect);
  const distanceForHeight = (shell.layout.height / 2) / Math.tan(verticalFov / 2);
  const distanceForWidth = (shell.layout.width / 2) / Math.tan(horizontalFov / 2);
  const distance = Math.max(distanceForHeight, distanceForWidth) * framingMultiplier;

  shell.controls.minDistance = Math.max(1.25, distance * 0.34);
  shell.controls.maxDistance = Math.max(18, distance * 5.5);
  shell.controls.minPolarAngle = 0.01;
  shell.controls.maxPolarAngle = Math.PI - 0.01;

  if (resetCamera) {
    shell.camera.position.set(
      WALL_CENTER.x,
      artCenterY + (shell.profile.cameraYOffset ?? 0.2),
      WALL_CENTER.z + distance,
    );
    shell.camera.lookAt(target);
    shell.controls.target.copy(target);
  }
  shell.controls.update();
}

function createPresentationSurface(width, height, padding = 48) {
  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;
  const context = canvas.getContext("2d");
  if (!context) {
    throw new Error("Presentation surface could not create a 2D context.");
  }
  return { canvas, context, width, height, padding };
}

function drawContainedIntoPresentationSurface(surface, sourceWidth, sourceHeight, draw, background = "#f8f5ee") {
  const ctx = surface.context;
  ctx.save();
  ctx.clearRect(0, 0, surface.width, surface.height);
  ctx.fillStyle = background;
  ctx.fillRect(0, 0, surface.width, surface.height);

  const availableWidth = Math.max(surface.width - (surface.padding * 2), 1);
  const availableHeight = Math.max(surface.height - (surface.padding * 2), 1);
  const aspect = sourceWidth / Math.max(sourceHeight, 1);
  let drawWidth = availableWidth;
  let drawHeight = drawWidth / Math.max(aspect, 0.0001);
  if (drawHeight > availableHeight) {
    drawHeight = availableHeight;
    drawWidth = drawHeight * aspect;
  }
  const x = (surface.width - drawWidth) / 2;
  const y = (surface.height - drawHeight) / 2;
  draw(ctx, x, y, drawWidth, drawHeight);
  ctx.restore();
}

function syncThreeRendererBackground(renderer, scene, fallbackColor) {
  if (!renderer?.setClearColor) return;
  const background = scene?.background;
  if (background) {
    renderer.setClearColor(background, 1);
    renderer.setClearAlpha?.(1);
    return;
  }
  if (fallbackColor != null) {
    renderer.setClearColor(fallbackColor, 1);
    renderer.setClearAlpha?.(1);
    return;
  }
  renderer.setClearAlpha?.(0);
}

function computeThreeAutoFitView(center, size, aspect, fovDegrees, compactViewport) {
  const verticalFov = (fovDegrees * Math.PI) / 180;
  const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * Math.max(aspect, 0.1));
  const fitWidth = Math.max(size.x, size.z, 1);
  const fitHeight = Math.max(size.y, size.z * 1.08, 1);
  const distanceForHeight = (fitHeight / 2) / Math.tan(verticalFov / 2);
  const distanceForWidth = (fitWidth / 2) / Math.tan(horizontalFov / 2);
  const cameraZ = (Math.max(distanceForHeight, distanceForWidth) * (compactViewport ? 1.46 : 1.34)) / 3.5;
  const targetY = center.y + (fitHeight * (compactViewport ? 0.08 : 0.12));
  const cameraY = targetY + (fitHeight * (compactViewport ? 0.02 : 0.04));
  return { camera: { x: center.x, y: cameraY, z: center.z + cameraZ }, target: { x: center.x, y: targetY, z: center.z } };
}

function engineLabel(engine) {
  if (engine === "p5") return "P5.js";
  if (engine === "c2") return "C2.js";
  if (engine === "three") return "Three.js";
  if (engine === "svg") return "SVG";
  if (engine === "aframe") return "A-Frame";
  return engine;
}

function getProgressiveExhibitLiveBudget(viewportWidth, staticMode = false) {
  if (staticMode) return 1;
  if (viewportWidth < 640) return 1;   // mobile
  if (viewportWidth < 1180) return 2;  // tablet
  return 3;                            // desktop
}

// A-Frame ships a default `wasd-controls` component that moves the camera on
// W/A/S/D. Those same keys are used as piano-key triggers when sound is
// enabled, so camera movement must be decoupled from them in every view that
// supports sound — regular /pieces/{id}, immersive /immersive/pieces/{id},
// and the derived ZIP exports. Arrow keys remain the canonical camera-move
// input.
function disableAFrameWASD() {
  if (!window.AFRAME || !window.AFRAME.components || !window.AFRAME.components["wasd-controls"]) return;
  const proto = window.AFRAME.components["wasd-controls"].Component.prototype;
  if (proto.__creatrWASDsafeguard) return;
  proto.__creatrWASDsafeguard = true;
  const origKeyDown = proto.onKeyDown;
  const origKeyUp = proto.onKeyUp;
  proto.onKeyDown = function (e) {
    if (e.code === "KeyW" || e.code === "KeyA" || e.code === "KeyS" || e.code === "KeyD") return;
    origKeyDown.call(this, e);
  };
  proto.onKeyUp = function (e) {
    if (e.code === "KeyW" || e.code === "KeyA" || e.code === "KeyS" || e.code === "KeyD") return;
    origKeyUp.call(this, e);
  };
}

function createImmersiveViewerControls(stageEl, handlers = {}) {
  if (!handlers.onZoomSliderInput) return null;
  const root = document.createElement("div");
  root.className = "immersive-viewer-controls";
  root.setAttribute("aria-label", "Viewer controls");
  root.style.cssText = "position:absolute;inset:0;z-index:132;pointer-events:none;opacity:0.34;transition:opacity 180ms ease;";
  let fadeTimer = 0;
  const activateControls = () => {
    root.style.opacity = "1";
    clearTimeout(fadeTimer);
    fadeTimer = setTimeout(() => { root.style.opacity = "0.34"; }, 2400);
  };
  ["pointermove", "pointerdown", "focusin", "mouseenter"].forEach((eventName) => {
    root.addEventListener(eventName, activateControls);
  });

  const leftEdge = document.createElement("div");
  leftEdge.className = "immersive-edge-hud immersive-edge-hud-left";
  leftEdge.style.cssText = "position:absolute;left:calc(0.55rem + env(safe-area-inset-left));top:50%;transform:translateY(-50%);display:flex;flex-direction:column;align-items:center;gap:0.55rem;pointer-events:auto;";
  root.appendChild(leftEdge);

  const movePad = document.createElement("div");
  movePad.className = "immersive-move-pad";
  movePad.style.cssText = "display:grid;grid-template-columns:repeat(3,2.25rem);grid-template-rows:repeat(3,2.25rem);gap:0.28rem;align-items:center;justify-items:center;";

  const floatPad = document.createElement("div");
  floatPad.className = "immersive-float-pad";
  floatPad.style.cssText = "display:flex;gap:0.35rem;";

  const zoomEdge = document.createElement("div");
  zoomEdge.className = "immersive-edge-hud immersive-edge-hud-right";
  zoomEdge.style.cssText = "position:absolute;right:calc(1rem + env(safe-area-inset-right));top:50%;width:2.75rem;transform:translateY(-50%);display:flex;flex-direction:column;align-items:center;gap:0.45rem;pointer-events:auto;";

  function createButton(parent, label, ariaLabel, onClick, options = {}) {
    const btn = document.createElement("button");
    let holdTimer = 0;
    let usedPointer = false;
    btn.type = "button";
    btn.textContent = label;
    btn.setAttribute("aria-label", ariaLabel);
    btn.style.cssText = "display:inline-flex;align-items:center;justify-content:center;height:2.25rem;width:2.25rem;border:1px solid rgba(255,255,255,0.2);background:rgba(0,0,0,0.42);color:#fff;border-radius:9999px;box-shadow:0 4px 12px rgba(0,0,0,0.45);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);font-size:1rem;font-weight:900;line-height:1;cursor:pointer;touch-action:none;user-select:none;-webkit-user-select:none;";
    if (options.gridArea) btn.style.gridArea = options.gridArea;
    const stopHold = () => {
      if (holdTimer) {
        clearInterval(holdTimer);
        holdTimer = 0;
      }
    };
    btn.addEventListener("pointerdown", (event) => {
      event.preventDefault();
      event.stopPropagation();
      usedPointer = true;
      onClick();
      if (options.hold) {
        stopHold();
        holdTimer = setInterval(onClick, 90);
      }
      try { btn.setPointerCapture(event.pointerId); } catch (_) {}
    });
    btn.addEventListener("pointerup", stopHold);
    btn.addEventListener("pointercancel", stopHold);
    btn.addEventListener("lostpointercapture", stopHold);
    btn.addEventListener("click", (event) => {
      event.preventDefault();
      event.stopPropagation();
      if (usedPointer) {
        usedPointer = false;
        return;
      }
      onClick();
    });
    parent.appendChild(btn);
    return btn;
  }

  if (handlers.onMoveForward && handlers.onMoveBackward && handlers.onMoveLeft && handlers.onMoveRight) {
    createButton(movePad, "^", "Move forward", handlers.onMoveForward, { hold: true, gridArea: "1 / 2" });
    createButton(movePad, "<", "Move left", handlers.onMoveLeft, { hold: true, gridArea: "2 / 1" });
    createButton(movePad, ">", "Move right", handlers.onMoveRight, { hold: true, gridArea: "2 / 3" });
    createButton(movePad, "v", "Move backward", handlers.onMoveBackward, { hold: true, gridArea: "3 / 2" });
    leftEdge.appendChild(movePad);
  }
  if (handlers.onFloatUp && handlers.onFloatDown) {
    createButton(floatPad, "Up", "Float up", handlers.onFloatUp, { hold: true });
    createButton(floatPad, "Dn", "Float down", handlers.onFloatDown, { hold: true });
    leftEdge.appendChild(floatPad);
  }

  const zoomIcon = document.createElement("span");
  zoomIcon.className = "immersive-zoom-icon";
  zoomIcon.setAttribute("aria-hidden", "true");
  zoomIcon.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="6"></circle><path d="m16 16 4 4"></path></svg>';
  zoomIcon.style.cssText = "display:inline-flex;align-items:center;justify-content:center;width:2.75rem;height:2.25rem;color:rgba(255,255,255,0.82);filter:drop-shadow(0 2px 6px rgba(0,0,0,0.65));";
  const sliderSlot = document.createElement("div");
  sliderSlot.className = "immersive-zoom-slider-slot";
  sliderSlot.style.cssText = "position:relative;width:2.75rem;height:9rem;display:flex;align-items:center;justify-content:center;";
  const zoomSlider = document.createElement("input");
  zoomSlider.type = "range";
  zoomSlider.min = "0";
  zoomSlider.max = "100";
  zoomSlider.value = String(handlers.initialZoomValue ?? 50);
  zoomSlider.setAttribute("aria-label", "Zoom");
  zoomSlider.className = "immersive-zoom-slider";
  zoomSlider.style.cssText = "position:absolute;left:50%;top:50%;width:9rem;height:2rem;transform:translate(-50%,-50%) rotate(-90deg);transform-origin:center;accent-color:#fff;cursor:pointer;touch-action:none;";
  zoomSlider.addEventListener("pointerdown", (event) => {
    event.stopPropagation();
    activateControls();
  });
  zoomSlider.addEventListener("input", (event) => {
    event.stopPropagation();
    handlers.onZoomSliderInput(Number(zoomSlider.value));
    activateControls();
  });
  sliderSlot.appendChild(zoomSlider);
  zoomEdge.appendChild(zoomIcon);
  zoomEdge.appendChild(sliderSlot);
  root.appendChild(zoomEdge);
  stageEl.appendChild(root);
  return {
    setEnabled(enabled) {
      root.querySelectorAll("button, input, select").forEach((control) => {
        control.disabled = !enabled;
      });
    },
    setZoomValue(value) {
      if (Number.isFinite(value)) zoomSlider.value = String(Math.max(0, Math.min(100, value)));
    },
    remove() {
      clearTimeout(fadeTimer);
      root.remove();
    },
  };
}

function setImmersiveViewerControlsEnabled(controller, enabled) {
  controller?.setEnabled?.(enabled);
}

function createSharedGyroController(stageEl, camera, options = {}) {
  if (!stageEl || !camera) {
    return {
      update() {},
      dispose() {},
      isActive() { return false; },
      suspend() { return false; },
      resume() {},
      deactivate() {},
      async setup() { return false; },
      requestCalibration() {},
    };
  }

  let deviceControls = null;
  let gyroActive = false;
  let gyroNeedsCalibration = false;
  let gyroToggleBtn = null;
  let enableMotionBtn = null;
  let disposed = false;
  let activationPromise = null;
  let resumeActiveAfterSuspend = false;
  let suspended = false;
  const baselineQuat = new THREE.Quaternion();
  const yawProbe = new THREE.Vector3();
  const handOffsetQuat = new THREE.Quaternion();
  const handOffsetApplied = new THREE.Quaternion();
  const handEuler = new THREE.Euler(0, 0, 0, "YXZ");
  let handTargetYaw = 0;
  let handTargetPitch = 0;
  let handYaw = 0;
  let handPitch = 0;
  let handOffsetWasApplied = false;
  const stabilizeHand = options.stabilizeHand === true;
  const handSmoothing = Number.isFinite(options.handSmoothing)
    ? Math.max(0.01, Math.min(1, options.handSmoothing))
    : 0.14;
  const handInputDeadband = Number.isFinite(options.handInputDeadband)
    ? Math.max(0, options.handInputDeadband)
    : 0;
  const handMaxAngularStep = Number.isFinite(options.handMaxAngularStep)
    ? Math.max(0.001, options.handMaxAngularStep)
    : Infinity;
  let handUpdateAt = null;

  function advanceStableHandAxis(current, target, frameScale) {
    const alpha = 1 - Math.pow(1 - handSmoothing, frameScale);
    const proposed = (target - current) * alpha;
    const maxStep = handMaxAngularStep * frameScale;
    return current + Math.max(-maxStep, Math.min(maxStep, proposed));
  }

  function requestCalibration() {
    baselineQuat.copy(camera.quaternion);
    gyroNeedsCalibration = true;
  }

  function hasDeviceOrientationAngles(deviceControlsRef) {
    const orientation = deviceControlsRef?.deviceOrientation;
    return !!orientation && (
      orientation.alpha !== null && orientation.alpha !== undefined
      || orientation.beta !== null && orientation.beta !== undefined
      || orientation.gamma !== null && orientation.gamma !== undefined
    );
  }

  function yawFromQuaternion(quaternion) {
    yawProbe.set(0, 0, -1).applyQuaternion(quaternion);
    yawProbe.y = 0;
    if (yawProbe.lengthSq() < 1e-8) return null;
    yawProbe.normalize();
    return Math.atan2(yawProbe.x, yawProbe.z);
  }

  function normalizeAngleRadians(angle) {
    let normalized = angle;
    while (normalized > Math.PI) normalized -= Math.PI * 2;
    while (normalized < -Math.PI) normalized += Math.PI * 2;
    return normalized;
  }

  function calibrateGyroToCurrentView() {
    if (!gyroNeedsCalibration || !deviceControls || !hasDeviceOrientationAngles(deviceControls)) return;

    const desiredYaw = yawFromQuaternion(baselineQuat);
    if (desiredYaw === null) {
      gyroNeedsCalibration = false;
      return;
    }

    deviceControls.alphaOffset = 0;
    deviceControls.update();
    const rawYaw = yawFromQuaternion(camera.quaternion);
    if (rawYaw === null) {
      camera.quaternion.copy(baselineQuat);
      gyroNeedsCalibration = false;
      return;
    }

    const yawDelta = normalizeAngleRadians(desiredYaw - rawYaw);
    let bestOffset = yawDelta;
    let bestError = Infinity;
    [yawDelta, -yawDelta].forEach((candidateOffset) => {
      deviceControls.alphaOffset = candidateOffset;
      deviceControls.update();
      const candidateYaw = yawFromQuaternion(camera.quaternion);
      if (candidateYaw === null) return;
      const candidateError = Math.abs(normalizeAngleRadians(desiredYaw - candidateYaw));
      if (candidateError < bestError) {
        bestError = candidateError;
        bestOffset = candidateOffset;
      }
    });

    deviceControls.alphaOffset = bestOffset;
    gyroNeedsCalibration = false;
  }

  function setGyroActive(nextActive) {
    gyroActive = nextActive;
    gyroToggleBtn?.setAttribute("aria-pressed", String(gyroActive));
    if (gyroToggleBtn) {
      gyroToggleBtn.style.background = gyroActive ? "rgba(89,184,201,0.85)" : "rgba(0,0,0,0.55)";
    }
    options.onStateChange?.(gyroActive);
  }

  function createGyroToggleButton() {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.setAttribute("aria-label", "Toggle gyroscope camera control");
    btn.textContent = "⟲";
    // The stage toolbar reserves a slot so the gyro toggle never overlaps the
    // top-left control group; stages without a toolbar keep the absolute
    // top-left placement.
    const slot = document.querySelector("[data-immersive-gyro-slot]");
    const placement = slot
      ? "position:static;"
      : "position:absolute;top:calc(0.75rem + env(safe-area-inset-top));left:calc(0.75rem + env(safe-area-inset-left));z-index:130;";
    btn.style.cssText = placement + "display:inline-flex;align-items:center;justify-content:center;height:2.5rem;width:2.5rem;border:1px solid rgba(255,255,255,0.15);background:rgba(89,184,201,0.85);color:#fff;border-radius:9999px;font-size:1.1rem;cursor:pointer;";
    btn.addEventListener("click", () => {
      const nextActive = !gyroActive;
      if (nextActive) {
        requestCalibration();
        options.onActivated?.();
      }
      setGyroActive(nextActive);
    });
    (slot || stageEl).appendChild(btn);
    return btn;
  }

  function createEnableMotionButton(onGranted) {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.textContent = "Enable Motion Controls";
    btn.style.cssText = "position:absolute;bottom:calc(1rem + env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);z-index:130;border:1px solid rgba(255,255,255,0.15);background:rgba(0,0,0,0.65);color:#fff;padding:0.6rem 1.1rem;border-radius:9999px;font-size:0.85rem;cursor:pointer;";
    btn.addEventListener("click", async () => {
      try {
        const result = await DeviceOrientationEvent.requestPermission();
        if (result === "granted") {
          btn.remove();
          if (enableMotionBtn === btn) enableMotionBtn = null;
          await onGranted();
        }
      } catch (_) {
        // Leave the button in place so the user can try again later.
      }
    });
    stageEl.appendChild(btn);
    return btn;
  }

  function waitForRealOrientationData(timeoutMs = 1500) {
    return new Promise((resolve) => {
      const onFirstEvent = (event) => {
        if (event.alpha !== null || event.beta !== null || event.gamma !== null) {
          window.removeEventListener("deviceorientation", onFirstEvent);
          resolve(true);
        }
      };
      window.addEventListener("deviceorientation", onFirstEvent);
      setTimeout(() => {
        window.removeEventListener("deviceorientation", onFirstEvent);
        resolve(false);
      }, timeoutMs);
    });
  }

  async function activateGyro() {
    if (disposed) return false;
    if (activationPromise) return activationPromise;
    activationPromise = (async () => {
      if (!deviceControls) {
        const DeviceOrientationControls = window.DeviceOrientationControls;
        deviceControls = new DeviceOrientationControls(camera);
      }
      requestCalibration();
      if (!gyroToggleBtn) gyroToggleBtn = createGyroToggleButton();
      options.onActivated?.();
      setGyroActive(true);
      return true;
    })();
    try {
      return await activationPromise;
    } finally {
      activationPromise = null;
    }
  }

  async function setup() {
    if (disposed || typeof window.DeviceOrientationEvent === "undefined") return false;
    try {
      if (typeof DeviceOrientationEvent.requestPermission === "function") {
        try {
          const result = await DeviceOrientationEvent.requestPermission();
          if (result === "granted") {
            return await activateGyro();
          }
        } catch (_) {
          // Fall through to the manual permission button.
        }
        if (!enableMotionBtn) {
          enableMotionBtn = createEnableMotionButton(activateGyro);
        }
        return false;
      }

      const hasRealOrientationData = await waitForRealOrientationData();
      if (hasRealOrientationData) {
        return await activateGyro();
      }
    } catch (_) {
      // A failed dynamic import or unsupported browser should only disable motion.
    }
    return false;
  }

  return {
    update() {
      if (disposed) return;
      // Remove the prior additive hand offset before controls establish this
      // frame's base orientation. Device motion and hand motion then compose
      // deterministically instead of competing as two quaternion writers.
      if (handOffsetWasApplied) {
        camera.quaternion.multiply(handOffsetApplied.clone().invert());
        handOffsetWasApplied = false;
      }
      if (!suspended && gyroActive && deviceControls && hasDeviceOrientationAngles(deviceControls)) {
        calibrateGyroToCurrentView();
        deviceControls.update();
      }
      if (stabilizeHand) {
        const now = performance.now();
        const frameScale = handUpdateAt === null
          ? 1
          : Math.max(0.25, Math.min(4, (now - handUpdateAt) / (1000 / 60)));
        handUpdateAt = now;
        handYaw = advanceStableHandAxis(handYaw, handTargetYaw, frameScale);
        handPitch = advanceStableHandAxis(handPitch, handTargetPitch, frameScale);
      } else {
        // Preserve the established direct Three.js/A-Frame response exactly.
        handYaw += (handTargetYaw - handYaw) * 0.14;
        handPitch += (handTargetPitch - handPitch) * 0.14;
      }
      if (Math.abs(handYaw) > 0.0001 || Math.abs(handPitch) > 0.0001) {
        handEuler.set(handPitch, handYaw, 0, "YXZ");
        handOffsetQuat.setFromEuler(handEuler);
        camera.quaternion.multiply(handOffsetQuat);
        handOffsetApplied.copy(handOffsetQuat);
        handOffsetWasApplied = true;
      }
    },
    dispose() {
      disposed = true;
      enableMotionBtn?.remove();
      gyroToggleBtn?.remove();
      deviceControls?.disconnect?.();
      enableMotionBtn = null;
      gyroToggleBtn = null;
      deviceControls = null;
      activationPromise = null;
      gyroActive = false;
      gyroNeedsCalibration = false;
    },
    isActive() {
      return gyroActive;
    },
    suspend() {
      if (disposed || suspended) return resumeActiveAfterSuspend;
      suspended = true;
      resumeActiveAfterSuspend = gyroActive;
      if (gyroActive) setGyroActive(false);
      return resumeActiveAfterSuspend;
    },
    resume() {
      if (disposed || !suspended) return;
      suspended = false;
      if (resumeActiveAfterSuspend) {
        requestCalibration();
        setGyroActive(true);
      }
      resumeActiveAfterSuspend = false;
    },
    deactivate() {
      suspended = false;
      resumeActiveAfterSuspend = false;
      if (gyroActive) setGyroActive(false);
    },
    setup,
    requestCalibration,
    setHandOffset(nx, ny) {
      const nextYaw = Math.max(-0.45, Math.min(0.45, (nx - 0.5) * 0.9));
      const nextPitch = Math.max(-0.32, Math.min(0.32, -(ny - 0.5) * 0.64));
      if (!stabilizeHand || Math.abs(nextYaw - handTargetYaw) >= handInputDeadband) {
        handTargetYaw = nextYaw;
      }
      if (!stabilizeHand || Math.abs(nextPitch - handTargetPitch) >= handInputDeadband) {
        handTargetPitch = nextPitch;
      }
    },
    clearHandOffset() {
      handTargetYaw = 0;
      handTargetPitch = 0;
      handUpdateAt = null;
    },
    releaseHandOffset() {
      handTargetYaw = 0;
      handTargetPitch = 0;
      handYaw = 0;
      handPitch = 0;
      handUpdateAt = null;
      handOffsetWasApplied = false;
      handOffsetApplied.identity();
      handOffsetQuat.identity();
    },
  };
}

function createReadOnlyFullViewOverlay(stageEl, items, options = {}) {
  const overlayItems = Array.isArray(items) ? items : [];
  const singleItemMode = options.layout === "single";
  const soundControl = options.soundControl && typeof options.soundControl === "object"
    ? options.soundControl
    : null;
  // Piece full view keeps download controls in the stage toolbar instead of
  // duplicating them inside the overlay shell.
  const showDownloadControls = options.showDownloadControls !== false;
  const host = stageEl.parentElement ?? stageEl;
  if (!overlayItems.length) {
    return {
      openAt() {},
      close() {},
      remove() {},
      isOpen() { return false; },
    };
  }

  const root = document.createElement("div");
  root.hidden = true;
  root.setAttribute("aria-hidden", "true");
  root.style.cssText = "position:fixed;inset:0;z-index:145;background:rgba(5,7,15,0.92);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);display:none;align-items:stretch;justify-content:center;padding:1rem;pointer-events:auto;";

  const shell = document.createElement("div");
  shell.style.cssText = "position:relative;display:flex;flex-direction:column;width:min(100%,1100px);height:100%;max-height:100%;border:1px solid rgba(255,255,255,0.14);border-radius:1.2rem;background:rgba(9,14,24,0.96);box-shadow:0 24px 80px rgba(0,0,0,0.35);overflow:hidden;";
  root.appendChild(shell);

  const topBar = document.createElement("div");
  topBar.style.cssText = "display:flex;flex-direction:column;align-items:stretch;gap:0.65rem;padding:0.9rem 1rem;border-bottom:1px solid rgba(255,255,255,0.08);";
  shell.appendChild(topBar);

  const metaWrap = document.createElement("div");
  metaWrap.style.cssText = "min-width:0;width:100%;display:flex;flex-direction:column;gap:0.2rem;";
  topBar.appendChild(metaWrap);

  const titleEl = document.createElement("div");
  titleEl.style.cssText = "font-size:1rem;font-weight:700;color:#f8f5ee;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
  metaWrap.appendChild(titleEl);

  const subtitleEl = document.createElement("div");
  subtitleEl.style.cssText = "font-size:0.76rem;letter-spacing:0.12em;text-transform:uppercase;color:rgba(255,255,255,0.54);";
  metaWrap.appendChild(subtitleEl);

  function chromeButton(label, ariaLabel) {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.textContent = label;
    btn.setAttribute("aria-label", ariaLabel || label);
    btn.style.cssText = "display:inline-flex;align-items:center;justify-content:center;min-width:2.75rem;height:2.75rem;padding:0 0.85rem;border:1px solid rgba(255,255,255,0.16);border-radius:9999px;background:rgba(255,255,255,0.06);color:#fff;font-size:0.9rem;font-weight:600;cursor:pointer;";
    btn.addEventListener("pointerenter", () => { btn.style.background = "rgba(255,255,255,0.14)"; });
    btn.addEventListener("pointerleave", () => { btn.style.background = "rgba(255,255,255,0.06)"; });
    return btn;
  }

  const controlsWrap = document.createElement("div");
  controlsWrap.style.cssText = "display:flex;align-items:center;justify-content:flex-end;gap:0.5rem;flex-wrap:wrap;width:100%;min-width:0;";
  topBar.appendChild(controlsWrap);

  const soundBtn = soundControl ? chromeButton("Sound", "Enable collection sound") : null;
  if (soundBtn) controlsWrap.appendChild(soundBtn);

  const downloadPieceLink = document.createElement("a");
  downloadPieceLink.textContent = "Download ZIP";
  downloadPieceLink.setAttribute("download", "");
  downloadPieceLink.style.cssText = "display:none;align-items:center;justify-content:center;min-height:2.75rem;padding:0 0.85rem;border:1px solid rgba(255,255,255,0.16);border-radius:9999px;background:rgba(255,255,255,0.06);color:#fff;font-size:0.9rem;font-weight:600;text-decoration:none;";
  controlsWrap.appendChild(downloadPieceLink);

  const downloadPngBtn = chromeButton("PNG", "Download PNG");
  controlsWrap.appendChild(downloadPngBtn);

  const prevBtn = chromeButton("Prev", "Show previous work");
  const nextBtn = chromeButton("Next", "Show next work");
  const closeBtn = chromeButton("×", "Close full view");
  controlsWrap.appendChild(prevBtn);
  controlsWrap.appendChild(nextBtn);
  controlsWrap.appendChild(closeBtn);

  const contentWrap = document.createElement("div");
  contentWrap.style.cssText = "flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:1rem 1rem 0.75rem;";
  shell.appendChild(contentWrap);

  const footer = document.createElement("div");
  footer.style.cssText = "flex:0 0 auto;max-height:min(36vh,18rem);overflow:auto;padding:0 1rem 1rem;";
  shell.appendChild(footer);

  const descriptionEl = document.createElement("p");
  descriptionEl.style.cssText = "margin:0;color:rgba(255,255,255,0.78);font-size:0.95rem;line-height:1.6;display:none;";
  footer.appendChild(descriptionEl);

  let currentStartIndex = 0;
  let priorKeyboardDisabled = false;
  let previouslyFocused = null;
  let currentRenderedItem = null;

  function normalizeIndex(index) {
    const total = overlayItems.length;
    if (!total) return 0;
    return ((index % total) + total) % total;
  }

  function getColumns() {
    if (singleItemMode) return 1;
    return Math.min(getProgressiveExhibitLiveBudget(window.innerWidth), overlayItems.length);
  }

  function getStepSize() {
    return singleItemMode ? 1 : getColumns();
  }

  function clearViewport() {
    contentWrap.replaceChildren();
  }

  function renderItemIntoEl(item, containerEl) {
    if (item.type === "iframe" && item.srcdoc) {
      const interactive = item.interactive === true;
      const iframe = document.createElement("iframe");
      iframe.setAttribute("title", item.title || "Full view");
      iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
      // Without an explicit Permissions-Policy allowance, getUserMedia is
      // blocked inside the iframe even same-origin — the piece's own sound
      // UI (camera theremin, live mic) needs both.
      iframe.setAttribute("allow", "camera; microphone");
      iframe.setAttribute("tabindex", interactive ? "0" : "-1");
      iframe.srcdoc = item.srcdoc;
      iframe.style.cssText = "width:100%;height:100%;border:0;display:block;pointer-events:" + (interactive ? "auto" : "none") + ";background:#05070f;";
      containerEl.appendChild(iframe);
    } else if (item.type === "image" && item.src) {
      const image = document.createElement("img");
      image.src = item.src;
      image.alt = item.alt || item.title || "";
      image.style.cssText = "max-width:100%;max-height:100%;width:auto;height:auto;display:block;object-fit:contain;background:#05070f;margin:auto;";
      containerEl.appendChild(image);
    } else {
      const fallback = document.createElement("div");
      fallback.textContent = item.title || "Unable to preview this work.";
      fallback.style.cssText = "padding:1rem;color:#fff;font-weight:600;";
      containerEl.appendChild(fallback);
    }
  }

  function getActiveItem() {
    return overlayItems[normalizeIndex(currentStartIndex)] || null;
  }

  async function captureActiveItemPng() {
    const dl = window.CreatrPieceDownload;
    if (!dl) {
      throw new Error("PNG download tools are not available yet.");
    }
    const viewport = contentWrap.querySelector("[data-full-view-viewport]");
    const iframe = viewport?.querySelector("iframe");
    if (iframe?.contentDocument) {
      const iframeDoc = iframe.contentDocument;
      const exportCapture = iframe.contentWindow?.__creatrExportCapture;
      const surface = exportCapture && typeof exportCapture.getSurface === "function"
        ? await exportCapture.getSurface()
        : await dl.waitForCaptureReady(iframeDoc);
      return surface.type === "svg"
        ? dl.exportSvg(surface.node)
        : (((exportCapture && exportCapture.requiresCanvasValidation === true)
            || (iframeDoc.querySelector("a-scene#scene, a-scene") && surface.node instanceof HTMLCanvasElement))
          ? dl.exportCanvasWithValidation(iframeDoc, surface.node)
          : dl.exportCanvas(surface.node));
    }
    const image = viewport?.querySelector("img");
    if (image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0) {
      const canvas = document.createElement("canvas");
      canvas.width = image.naturalWidth;
      canvas.height = image.naturalHeight;
      const context = canvas.getContext("2d");
      if (!context) throw new Error("PNG export is unavailable in this browser.");
      context.drawImage(image, 0, 0);
      return canvas;
    }
    throw new Error("No downloadable slide surface is available yet.");
  }

  function syncDownloadControls() {
    const item = getActiveItem();
    currentRenderedItem = item;
    const downloadUrl = typeof item?.download_url === "string" ? item.download_url : "";
    downloadPieceLink.href = downloadUrl;
    downloadPieceLink.style.display = showDownloadControls && downloadUrl ? "inline-flex" : "none";
    downloadPngBtn.style.display = showDownloadControls && (item?.type === "iframe" || item?.type === "image") ? "" : "none";
    if (typeof options.onActiveItemChange === "function") {
      options.onActiveItemChange(item, normalizeIndex(currentStartIndex));
    }
    syncOverlaySoundControl();
  }

  function syncOverlaySoundControl() {
    if (!soundBtn || !soundControl) return;
    const state = typeof soundControl.getState === "function" ? soundControl.getState() : {};
    const enabled = state?.enabled === true;
    const hasActiveSound = state?.hasActiveSound !== false;
    soundBtn.disabled = false;
    soundBtn.style.opacity = "";
    syncAudioToggleButton(soundBtn, !enabled, {
      off: hasActiveSound ? "Enable sound for this work" : "Enable collection sound for sounding works",
      on: hasActiveSound ? "Mute sound for this work" : "Mute collection sound",
    });
  }

  async function onOverlaySoundToggle() {
    if (!soundControl || typeof soundControl.toggle !== "function") return;
    soundBtn.disabled = true;
    try {
      await soundControl.toggle();
    } finally {
      syncOverlaySoundControl();
    }
  }
  soundBtn?.addEventListener("click", onOverlaySoundToggle);

  function renderCurrentItems() {
    clearViewport();
    const cols = getColumns();
    const multi = overlayItems.length > 1;
    prevBtn.style.display = multi ? "" : "none";
    nextBtn.style.display = multi ? "" : "none";

    if (cols === 1) {
      contentWrap.style.cssText = "flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:1rem 1rem 0.75rem;";
      const viewport = document.createElement("div");
      viewport.dataset.fullViewViewport = "true";
      viewport.style.cssText = "width:100%;height:100%;display:flex;align-items:center;justify-content:center;border-radius:1rem;background:#05070f;overflow:hidden;";
      contentWrap.appendChild(viewport);
      const item = overlayItems[normalizeIndex(currentStartIndex)];
      renderItemIntoEl(item, viewport);
      titleEl.textContent = item.title || "";
      titleEl.style.display = titleEl.textContent ? "" : "none";
      subtitleEl.textContent = item.subtitle || "";
      subtitleEl.style.display = subtitleEl.textContent ? "" : "none";
      metaWrap.style.display = (titleEl.textContent || subtitleEl.textContent) ? "" : "none";
      descriptionEl.textContent = item.description || "";
      descriptionEl.style.display = descriptionEl.textContent ? "" : "none";
      syncDownloadControls();
    } else {
      contentWrap.style.cssText = `flex:1;min-height:0;display:grid;grid-template-columns:repeat(${cols},1fr);gap:0.75rem;padding:1rem 1rem 0.75rem;`;
      titleEl.textContent = "";
      subtitleEl.style.display = "none";
      descriptionEl.style.display = "none";
      syncDownloadControls();
      for (let i = 0; i < cols; i++) {
        const item = overlayItems[normalizeIndex(currentStartIndex + i)];
        const slot = document.createElement("div");
        slot.style.cssText = "display:flex;flex-direction:column;min-height:0;overflow:hidden;border-radius:0.75rem;background:#05070f;";
        const pieceEl = document.createElement("div");
        pieceEl.style.cssText = "flex:1;min-height:0;overflow:hidden;";
        renderItemIntoEl(item, pieceEl);
        slot.appendChild(pieceEl);
        const label = document.createElement("div");
        label.style.cssText = "flex:0 0 auto;padding:0.4rem 0.6rem;border-top:1px solid rgba(255,255,255,0.08);";
        const titleSpan = document.createElement("div");
        titleSpan.textContent = item.title || "";
        titleSpan.style.cssText = "font-size:0.8rem;font-weight:600;color:#f8f5ee;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
        label.appendChild(titleSpan);
        if (item.subtitle) {
          const subtitleSpan = document.createElement("div");
          subtitleSpan.textContent = item.subtitle;
          subtitleSpan.style.cssText = "font-size:0.68rem;letter-spacing:0.1em;text-transform:uppercase;color:rgba(255,255,255,0.48);";
          label.appendChild(subtitleSpan);
        }
        slot.appendChild(label);
        contentWrap.appendChild(slot);
      }
    }
  }

  let blockCloseUntil = 0;

  // The shared stage toolbar's sound button lives behind this fixed overlay.
  // Hide that inaccessible duplicate while open; collection slides can
  // expose the same parent-owned state through soundControl above.
  function setSharedSoundToggleHidden(hide) {
    const toggleBtn = document.querySelector("[data-immersive-sound-toggle]");
    if (!toggleBtn) return;
    toggleBtn.style.display = hide ? "none" : "";
  }

  function openAt(index = 0) {
    currentStartIndex = normalizeIndex(index);
    priorKeyboardDisabled = stageEl.dataset.keyboardNavigationDisabled === "true";
    stageEl.dataset.keyboardNavigationDisabled = "true";
    previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
    options.onOpen?.(getActiveItem(), normalizeIndex(currentStartIndex));
    renderCurrentItems();
    root.hidden = false;
    root.setAttribute("aria-hidden", "false");
    root.style.display = "flex";
    setSharedSoundToggleHidden(true);
    // Absorb any ghost/synthetic click events the browser fires after a
    // touch/pointerup. Must be set BEFORE the close handlers can fire.
    blockCloseUntil = Date.now() + 600;
    closeBtn.focus();
  }

  function close() {
    if (root.hidden && root.style.display === "none") return;
    root.hidden = true;
    root.setAttribute("aria-hidden", "true");
    root.style.display = "none";
    setSharedSoundToggleHidden(false);
    clearViewport();
    if (priorKeyboardDisabled) {
      stageEl.dataset.keyboardNavigationDisabled = "true";
    } else {
      delete stageEl.dataset.keyboardNavigationDisabled;
    }
    Promise.resolve(options.onClose?.(currentRenderedItem, normalizeIndex(currentStartIndex))).finally(() => requestAnimationFrame(() => {
      if (previouslyFocused?.focus) {
        previouslyFocused.focus();
      } else {
        stageEl.focus?.();
      }
    }));
  }

  function showPrevious() {
    currentStartIndex = normalizeIndex(currentStartIndex - getStepSize());
    renderCurrentItems();
  }

  function showNext() {
    currentStartIndex = normalizeIndex(currentStartIndex + getStepSize());
    renderCurrentItems();
  }

  function isOpen() {
    return root.hidden === false && root.style.display !== "none";
  }

  function onWindowKeyDown(event) {
    if (!isOpen()) return;
    if (event.key === "Escape") {
      event.preventDefault();
      close();
      return;
    }
    if (overlayItems.length > 1 && event.key === "ArrowLeft") {
      event.preventDefault();
      showPrevious();
      return;
    }
    if (overlayItems.length > 1 && event.key === "ArrowRight") {
      event.preventDefault();
      showNext();
    }
  }

  prevBtn.addEventListener("click", showPrevious);
  nextBtn.addEventListener("click", showNext);
  closeBtn.addEventListener("click", close);
  downloadPngBtn.addEventListener("click", async () => {
    if (downloadPngBtn.disabled) return;
    const originalLabel = downloadPngBtn.textContent;
    const item = getActiveItem();
    const filename = item?.png_filename || item?.title?.toLowerCase?.().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + ".png" || "piece.png";
    downloadPngBtn.disabled = true;
    downloadPngBtn.textContent = "Preparing...";
    try {
      const dl = window.CreatrPieceDownload;
      const canvas = await captureActiveItemPng();
      const blob = await dl.canvasToBlob(canvas);
      dl.downloadBlob(blob, filename);
      downloadPngBtn.textContent = originalLabel;
    } catch (error) {
      downloadPngBtn.textContent = "Try Again";
      window.setTimeout(() => {
        if (!downloadPngBtn.disabled) downloadPngBtn.textContent = originalLabel;
      }, 1800);
      console.error(error);
    } finally {
      downloadPngBtn.disabled = false;
    }
  });
  root.addEventListener("click", (event) => {
    if (Date.now() < blockCloseUntil) return;   // absorb ghost clicks
    if (event.target === root) close();
  });
  shell.addEventListener("click", (event) => {
    event.stopPropagation();
  });
  let resizeRafId = 0;
  const onResize = () => {
    cancelAnimationFrame(resizeRafId);
    resizeRafId = requestAnimationFrame(() => { if (isOpen()) renderCurrentItems(); });
  };
  window.addEventListener("keydown", onWindowKeyDown);
  window.addEventListener("resize", onResize);
  host.appendChild(root);

  return {
    openAt,
    close,
    remove() {
      close();
      window.removeEventListener("keydown", onWindowKeyDown);
      window.removeEventListener("resize", onResize);
      root.remove();
    },
    isOpen,
  };
}

// Wires the immersive stage toolbar (view/slideshow trigger + download menu)
// rendered by immersive_stage_toolbar_markup(). Every lookup is null-safe so
// surfaces without a view button (three/aframe) or without a download menu
// (image view, static embeds) can share the same call.
function setupImmersiveStageChrome(stageEl, options = {}) {
  const root = options.root ?? document;
  const viewTrigger = root.querySelector("[data-immersive-view-trigger]");
  const downloadTrigger = root.querySelector("[data-immersive-download-trigger]");
  const downloadMenu = root.querySelector("[data-immersive-download-menu]");
  const downloadWrap = downloadTrigger?.closest(".immersive-stage-download-wrap") ?? null;

  const onViewClick = () => options.onViewAction?.();
  viewTrigger?.addEventListener("click", onViewClick);

  function isMenuOpen() {
    return !!downloadMenu && !downloadMenu.hidden;
  }

  function setMenuOpen(open, { focusTrigger = false } = {}) {
    if (!downloadTrigger || !downloadMenu) return;
    downloadMenu.hidden = !open;
    downloadTrigger.setAttribute("aria-expanded", open ? "true" : "false");
    if (open) {
      downloadMenu.querySelector("a, button, input")?.focus?.();
    } else if (focusTrigger) {
      downloadTrigger.focus?.();
    }
  }

  const onDownloadToggle = () => setMenuOpen(!isMenuOpen());

  function updateDownloadEstimates() {
    if (!downloadMenu) return;
    const fullBase = Number(downloadMenu.dataset.downloadEstimateFull || 0);
    const noCameraBase = Number(downloadMenu.dataset.downloadEstimateNoCamera || 0);
    let voiceCosts = {};
    try { voiceCosts = JSON.parse(downloadMenu.dataset.downloadEstimateVoiceCosts || '{}') || {}; } catch (_) {}
    const choices = [...downloadMenu.querySelectorAll('[data-piece-download-voice]')];
    const reduction = choices.filter((choice) => !choice.checked)
      .reduce((sum, choice) => sum + Number(voiceCosts[choice.dataset.pieceDownloadVoice] || 0), 0);
    const format = (bytes) => {
      if (!Number.isFinite(bytes) || bytes < 1) return 'size varies';
      return '≈' + Math.max(1, Math.round(bytes / (1024 * 1024))) + ' MB';
    };
    downloadMenu.querySelectorAll('[data-piece-download-label]').forEach((label) => {
      const noCamera = label.dataset.pieceDownloadLabel === 'no-camera';
      label.querySelector('span')?.replaceChildren(document.createTextNode(
        'Download ' + (noCamera ? 'Non-Camera' : 'Full') + ' ZIP (' + format((noCamera ? noCameraBase : fullBase) - reduction) + ')'
      ));
    });
  }

  downloadMenu?.querySelectorAll('[data-piece-download-voice]').forEach((choice) => {
    choice.addEventListener('change', updateDownloadEstimates);
  });
  updateDownloadEstimates();

  // Capture-phase pointerdown so an outside tap/click closes the menu before
  // whatever it lands on handles the event (mouse + touch + pen).
  const onDocumentPointerDown = (event) => {
    if (!isMenuOpen()) return;
    if (downloadWrap && event.target instanceof Node && downloadWrap.contains(event.target)) return;
    setMenuOpen(false);
  };

  const onDocumentKeyDown = (event) => {
    if (event.key === "Escape" && isMenuOpen()) {
      event.stopPropagation();
      setMenuOpen(false, { focusTrigger: true });
    }
  };

  // Let menu item handlers (download link URL rewriting, PNG capture) run
  // before the menu hides.
  const onMenuClick = (event) => {
    if (!(event.target instanceof Element)) return;
    if (!event.target.closest("a, button")) return;
    window.setTimeout(() => setMenuOpen(false), 120);
  };

  downloadTrigger?.addEventListener("click", onDownloadToggle);
  downloadMenu?.addEventListener("click", onMenuClick);
  if (downloadTrigger && downloadMenu) {
    document.addEventListener("pointerdown", onDocumentPointerDown, { capture: true });
    document.addEventListener("keydown", onDocumentKeyDown);
  }

  // --- Sound settings popover (volume / keyboard mode / note buttons) -------
  // A separate small chevron button (not the mute/unmute icon itself) opens
  // this panel, so the existing mute/unmute click wiring owned directly by
  // createAudioController() (and, on the exhibit wall, its own persistent
  // listener — see mountExhibitWall) never has to change or share a listener.
  // getAudioController is a live getter (not a snapshot) so the wall's
  // rebind-on-focus-change pattern is reflected automatically.
  const soundWrap = root.querySelector(".immersive-stage-sound-wrap");
  const soundPanelTrigger = root.querySelector("[data-immersive-sound-panel-trigger]");
  const soundPanel = root.querySelector("[data-immersive-sound-panel]");
  const soundMuteToggle = root.querySelector("[data-immersive-sound-mute-toggle]");
  const soundVolume = root.querySelector("[data-immersive-sound-volume]");
  const soundKeyboardRow = root.querySelector("[data-immersive-sound-keyboard-row]");
  const soundKeyboardToggle = root.querySelector("[data-immersive-sound-keyboard-toggle]");
  const soundKeysWrap = root.querySelector("[data-immersive-sound-keys]");
  const pianoKeysGroup = root.querySelector("[data-immersive-piano-keys]");
  const pianoOctaveDisplay = root.querySelector("[data-immersive-piano-octave-display]");
  const pianoOctaveDown = root.querySelector("[data-immersive-piano-octave-down]");
  const pianoOctaveUp = root.querySelector("[data-immersive-piano-octave-up]");
  const soundHandRow = root.querySelector("[data-immersive-sound-hand-row]");
  const soundHandToggle = root.querySelector("[data-immersive-sound-hand-toggle]");
  const handControlRow = root.querySelector("[data-immersive-sound-hand-control-row]");
  const handControlToggle = root.querySelector("[data-immersive-sound-hand-control-toggle]");
  const handControlStatus = root.querySelector("[data-immersive-hand-control-status]");
  const resetViewButton = root.querySelector("[data-immersive-reset-view]");
  const cameraBgRow = root.querySelector("[data-immersive-sound-camera-bg-row]");
  const cameraBgToggle = root.querySelector("[data-immersive-sound-camera-bg-toggle]");
  const cameraOpacityRow = root.querySelector("[data-immersive-sound-camera-opacity-row]");
  const cameraOpacityInput = root.querySelector("[data-immersive-sound-camera-opacity]");
  const voicePickerRows = root.querySelectorAll("[data-immersive-voice-picker-row]");
  const voicePickerSelects = root.querySelectorAll("[data-immersive-voice-picker-select]");
  const micRow = root.querySelector("[data-immersive-mic-row]");
  const micToggle = root.querySelector("[data-immersive-mic-toggle]");
  const micFxWrap = root.querySelector("[data-immersive-mic-fx]");
  const micFxToggles = root.querySelectorAll("[data-immersive-mic-fx-toggle]");
  const getAudioController = typeof options.getAudioController === "function" ? options.getAudioController : () => null;
  const getPieceInteractionController = typeof options.getPieceInteractionController === "function"
    ? options.getPieceInteractionController
    : () => null;
  let detachPianoKeyListener = null;
  let micSupportChecked = false;
  let handControlActive = false;
  let handActivationEpoch = 0;
  let handActivationPending = false;
  let activeHandControlEngine = null;
  let gestureRouter = null;
  let cameraBgActive = false;
  let tiltFallbackActive = false;

  function setGestureModeIndicator(mode) {
    let indicator = root.querySelector('[data-hand-gesture-mode]');
    if (!indicator) {
      indicator = document.createElement('div');
      indicator.dataset.handGestureMode = '';
      indicator.setAttribute('role', 'status');
      indicator.setAttribute('aria-live', 'polite');
      indicator.style.cssText = 'position:absolute;left:50%;bottom:calc(1rem + env(safe-area-inset-bottom));transform:translateX(-50%);z-index:132;padding:.38rem .72rem;border:1px solid rgba(255,255,255,.2);border-radius:999px;background:rgba(0,0,0,.68);color:#fff;font:600 .72rem/1.2 system-ui,sans-serif;letter-spacing:.06em;text-transform:uppercase;pointer-events:none;';
      // NOT root.appendChild(): root defaults to `document` itself when the
      // caller passes no options.root (true for every export bootstrap and
      // some live call sites), and appendChild-ing an element directly onto
      // the Document node throws HierarchyRequestError ("Only one element on
      // document allowed") — an uncaught exception inside the hand-control
      // toggle handler that aborts it mid-way, leaving pointer/orbit state
      // stuck. stageEl is always a real element, never the Document.
      (stageEl || document.body).appendChild(indicator);
    }
    const labels = { look: 'Look', orbit: 'Orbit', travel: 'Move', 'travel-ready': 'Point + pinch to move', 'orbit-ready': 'Pinch to orbit' };
    indicator.textContent = labels[mode] || '';
    indicator.hidden = !labels[mode];
  }

  function isSoundPanelOpen() {
    return !!soundPanel && !soundPanel.hidden;
  }

  // Camera overlay permission — its own per-piece flag (Metadata tab), no
  // longer implied by the hand-tracking voice, and valid without any audio
  // controller at all (camera-only pieces). handControlAllowed mirrors the
  // server contract's hand_control capability the same way; surfaces that
  // don't pass it keep the legacy hand_tracking-voice gating below.
  const cameraOverlayAllowed = options.cameraOverlayAllowed === true;
  const handControlAllowed = options.handControlAllowed === true;
  const dedicatedHandControl = options.dedicatedHandControl === true;
  // Silent engine for hand control on pieces with no audio controller
  // (sound-less pieces): enableHandControl never touches audio.
  let silentHandEngine = null;
  let handPreparationPromise = null;
  let handControlStatusTimer = 0;
  let handControlPreparingAt = 0;
  // Raw-text twin of setGestureModeIndicator: steering status renders in the
  // same bottom-center stage pill the gesture-mode labels use, so the visitor
  // sees it in the view itself rather than inside the control panel.
  function setStageStatusText(text) {
    let indicator = root.querySelector('[data-hand-gesture-mode]');
    if (!indicator) {
      setGestureModeIndicator(null);
      indicator = root.querySelector('[data-hand-gesture-mode]') || (stageEl || document.body).querySelector('[data-hand-gesture-mode]');
    }
    if (!indicator) return;
    indicator.textContent = text || '';
    indicator.hidden = !text;
  }
  function setHandControlStatus(message, { removeAfter = 0 } = {}) {
    window.clearTimeout(handControlStatusTimer);
    setStageStatusText(message || "");
    // The panel element remains a hidden mirror for assistive tech reading
    // the control panel.
    if (handControlStatus) {
      handControlStatus.textContent = message || "";
      handControlStatus.hidden = true;
    }
    if (message && removeAfter > 0) {
      handControlStatusTimer = window.setTimeout(() => {
        setStageStatusText("");
        if (handControlStatus) handControlStatus.textContent = "";
      }, removeAfter);
    }
  }
  function prepareHandControl() {
    if (!handControlAllowed) return Promise.resolve(false);
    // Always delegate to preloadHandTracker() rather than short-circuiting
    // on a locally cached promise — preloadHandTracker() already tracks the
    // document-scoped landmarker/preload state and re-emits the correct
    // capabilityState (ready/loading/unavailable) on every call, which is
    // what corrects this status text. A local promise cache here previously
    // skipped that re-emission on repeat focus/click events once the first
    // prepare had settled, leaving the UI stuck on "Preparing hand
    // steering…" even though steering already worked.
    handControlPreparingAt = Date.now();
    handControlStatus?.setAttribute("aria-busy", "true");
    if (handControlStatus) handControlStatus.dataset.state = "loading";
    setHandControlStatus("Preparing hand steering…");
    handPreparationPromise = loadSonicControllerOnce().then((CSC) => CSC.preloadHandTracker?.({
      mediaPipeVisionSrc: window.__creatrMediaPipeVisionSrc,
      mediaPipeWasmDir: window.__creatrMediaPipeWasmDir,
      mediaPipeModelSrc: window.__creatrMediaPipeModelSrc,
    }) || false).catch(() => false);
    return handPreparationPromise;
  }
  async function getHandControlEngine() {
    const ctrl = dedicatedHandControl ? null : getAudioController();
    if (ctrl) return ctrl;
    if (!silentHandEngine) {
      const CSC = window.CreatrSonicController || await loadSonicControllerOnce();
      silentHandEngine = CSC.create({}, {
        allowHandControl: true,
        mediaPipeVisionSrc: window.__creatrMediaPipeVisionSrc,
        mediaPipeWasmDir: window.__creatrMediaPipeWasmDir,
        mediaPipeModelSrc: window.__creatrMediaPipeModelSrc,
      });
    }
    return silentHandEngine;
  }

  function syncCameraRow() {
    const interaction = getPieceInteractionController();
    if (cameraBgRow) cameraBgRow.hidden = !cameraOverlayAllowed || !interaction?.supportsCameraBackground;
    if (cameraBgToggle) cameraBgToggle.setAttribute("aria-pressed", cameraBgActive ? "true" : "false");
    if (cameraOpacityRow) {
      const opacitySupported = typeof interaction?.setBackgroundOpacity === "function";
      cameraOpacityRow.hidden = !cameraBgActive || !opacitySupported;
      // Initialize the slider from the controller's real default (1.0 for
      // wall projection / blended quad) whenever the camera is active.
      if (cameraBgActive && opacitySupported && cameraOpacityInput && typeof interaction.getBackgroundOpacity === "function") {
        cameraOpacityInput.value = String(Math.round(interaction.getBackgroundOpacity() * 100));
      }
    }
    if (handControlRow && handControlAllowed) {
      handControlRow.hidden = !interaction?.supportsHandControl;
    }
    if (handControlToggle) handControlToggle.setAttribute("aria-pressed", handControlActive ? "true" : "false");
    if (resetViewButton && resetViewButton.getAttribute("aria-busy") !== "true") {
      // Reset view must work in every state — steering, tilt, camera, mic,
      // or any combination. The runtime ignores steering commands during the
      // reset animation, so no lockout is needed while control is armed.
      resetViewButton.disabled = false;
    }
  }

  const onCameraOpacityInput = () => {
    const interaction = getPieceInteractionController();
    if (cameraOpacityInput && typeof interaction?.setBackgroundOpacity === "function") {
      interaction.setBackgroundOpacity(Number(cameraOpacityInput.value) / 100);
    }
  };

  function syncSoundPanel() {
    syncCameraRow();
    const ctrl = getAudioController();
    const sharedState = typeof options.getSoundState === "function" ? options.getSoundState() : null;
    const enabled = sharedState ? sharedState.enabled === true : !!ctrl?.isEnabled();
    if (soundMuteToggle) {
      soundMuteToggle.setAttribute("aria-checked", enabled ? "true" : "false");
      soundMuteToggle.textContent = enabled ? "On" : "Off";
    }
    if (!ctrl) return;
    if (soundVolume) soundVolume.value = String(ctrl.getVolume());
    const voices = ctrl.getVoices();
    if (soundKeyboardRow) soundKeyboardRow.hidden = !voices.melodic;
    const keyboardOn = voices.melodic && ctrl.getInputMode() === "keyboard";
    if (soundKeyboardToggle) soundKeyboardToggle.setAttribute("aria-pressed", keyboardOn ? "true" : "false");
    if (soundKeysWrap) soundKeysWrap.hidden = !keyboardOn;
    if (pianoOctaveDisplay) pianoOctaveDisplay.textContent = String(ctrl.getOctave());
    if (soundHandRow) soundHandRow.hidden = !voices.hand_tracking;
    if (soundHandToggle) soundHandToggle.setAttribute("aria-pressed", ctrl.getInputMode() === "hand" ? "true" : "false");
    const interaction = getPieceInteractionController();
    // handControlAllowed (server contract) wins when passed; legacy
    // surfaces without the flag keep the hand_tracking-voice gate. The
    // allowed case is synced in syncCameraRow() (also runs ctrl-less).
    if (handControlRow && !handControlAllowed) {
      handControlRow.hidden = !voices.hand_tracking || !interaction?.supportsHandControl;
    }
    if (!micSupportChecked) {
      micSupportChecked = true;
      if (micRow) micRow.hidden = !ctrl.isMicSupported();
    }
    if (micToggle) micToggle.setAttribute("aria-pressed", ctrl.isMicEnabled() ? "true" : "false");
    if (micFxWrap) micFxWrap.hidden = !ctrl.isMicEnabled();
    const ambientIsSample = ctrl.isAmbientSample();
    voicePickerRows.forEach((row) => {
      const voiceName = row.dataset.immersiveVoicePickerRow;
      row.hidden = !voices[voiceName] || (voiceName === "ambient" && ambientIsSample);
    });
    voicePickerSelects.forEach((select) => {
      select.value = ctrl.getVoiceInstrument(select.dataset.voice);
    });
  }

  function setSoundPanelOpen(open, { focusTrigger = false } = {}) {
    if (!soundPanelTrigger || !soundPanel) return;
    soundPanel.hidden = !open;
    soundPanelTrigger.setAttribute("aria-expanded", open ? "true" : "false");
    if (open) {
      syncSoundPanel();
      prepareHandControl();
    } else if (focusTrigger) {
      soundPanelTrigger.focus?.();
    }
  }

  const onSoundPanelToggle = () => setSoundPanelOpen(!isSoundPanelOpen());

  const onSoundMuteToggle = async () => {
    if (typeof options.onSoundToggle === "function") {
      await options.onSoundToggle();
      syncSoundPanel();
      return;
    }
    const ctrl = getAudioController();
    if (!ctrl) return;
    await ctrl.toggleEnabled();
    syncSoundPanel();
  };

  const onSoundVolumeInput = (event) => {
    getAudioController()?.setVolume(Number(event.target.value));
  };

  const onSoundKeyboardToggle = async () => {
    const ctrl = getAudioController();
    if (!ctrl) return;
    const turningOn = ctrl.getInputMode() !== "keyboard";
    if (turningOn) {
      // Force the lazy engine/Tone.js load now (this click is the
      // autoplay-unlocking gesture) so physical key presses work
      // immediately, rather than only after the first mouse-clicked key.
      await ctrl.ensureAudioReady();
      detachPianoKeyListener?.();
      detachPianoKeyListener = ctrl.attachPianoKeyListener((semitone, pressed) => {
        const keyBtn = pianoKeysGroup?.querySelector(`[data-semitone="${semitone}"]`);
        if (keyBtn) {
          keyBtn.classList.toggle('is-pressed', pressed);
        }
      });
    } else {
      detachPianoKeyListener?.();
      detachPianoKeyListener = null;
      pianoKeysGroup?.querySelectorAll('[data-semitone]').forEach(k => k.classList.remove('is-pressed'));
    }
    ctrl.setInputMode(turningOn ? "keyboard" : "motion");
    syncSoundPanel();
  };

  const onSoundHandToggle = async () => {
    const ctrl = getAudioController();
    if (!ctrl) return;
    const turningOn = ctrl.getInputMode() !== "hand";
    if (turningOn) {
      // getUserMedia's own permission prompt is the user gesture here; on
      // denial/error this silently stays off — no error banner, matching
      // this codebase's "approximate rather than fail" philosophy.
      const ok = await ctrl.enableHandTracking();
      if (ok) ctrl.setInputMode("hand");
    } else {
      ctrl.disableHandTracking();
      ctrl.setInputMode("motion");
    }
    syncSoundPanel();
  };

  const onHandControlToggle = async () => {
    const activationEpoch = ++handActivationEpoch;
    const interaction = getPieceInteractionController();
    if (!interaction?.supportsHandControl) return;
    if (handControlToggle?.dataset.capabilityFallback === "device_tilt") {
      if (tiltFallbackActive) {
        interaction.disableTiltFallback?.();
        tiltFallbackActive = false;
      } else {
        tiltFallbackActive = !!(await interaction.enableTiltFallback?.());
      }
      handControlToggle.setAttribute("aria-pressed", tiltFallbackActive ? "true" : "false");
      return;
    }
    if (handControlActive || handActivationPending) {
      handActivationPending = false;
      handControlActive = false;
      const ctrl = activeHandControlEngine || getAudioController() || silentHandEngine;
      ctrl?.onHandFrame(null);
      ctrl?.disableHandControl();
      activeHandControlEngine = null;
      gestureRouter?.reset?.('disabled');
      gestureRouter = null;
      setGestureModeIndicator('idle');
      interaction.setHandSteering(false);
      syncSoundPanel();
      return;
    }
    // Audio controller when the piece has sound, silent engine otherwise —
    // hand control itself never needs audio.
    handActivationPending = true;
    handControlToggle?.setAttribute("aria-pressed", "true");
    const ctrl = await getHandControlEngine();
    if (!ctrl) { handActivationPending = false; return; }
    const ok = await ctrl.enableHandControl();
    handActivationPending = false;
    if (activationEpoch !== handActivationEpoch) {
      ctrl.onHandFrame(null);
      ctrl.disableHandControl();
      return;
    }
    if (!ok) {
      interaction.setHandSteering(false);
      syncSoundPanel();
      return;
    }
    activeHandControlEngine = ctrl;
    handControlActive = true;
    interaction.setHandSteering(true);
    let handPinched = false;
    ctrl.onHandFrame((hand) => {
      if (!handControlActive) return;
      if (typeof interaction.handCommand === 'function' && window.CreatrSonicController?.createClutchedGestureRouter) {
        if (!gestureRouter) {
          gestureRouter = window.CreatrSonicController.createClutchedGestureRouter({
            engine: interaction.engine || 'room',
            onCommand: (command) => interaction.handCommand(command),
            onMode: setGestureModeIndicator,
          });
        }
        gestureRouter.update(hand);
        return;
      }
      if (!hand?.[0]) {
        // Hand left the frame: release any held pinch and let velocity-style
        // controllers (the gallery room's hand navigation) stop coasting.
        if (handPinched) { handPinched = false; try { interaction.handPress?.(false); } catch (_) {} }
        try { interaction.handLost?.(); } catch (_) {}
        return;
      }
      try { interaction.handPoint(1 - hand[0].x, hand[0].y); } catch (_) {}
      // Pinch (thumb tip ↔ index tip) = pointer button for controllers that
      // register handPress (interactive c2 wall mounts) — same gesture and
      // hysteresis as piece-runtime.js's handControlBinding; keep in sync.
      if (typeof interaction.handPress === "function" && hand[4] && hand[8]) {
        const gap = Math.hypot(hand[4].x - hand[8].x, hand[4].y - hand[8].y, (hand[4].z || 0) - (hand[8].z || 0));
        const next = handPinched ? gap < 0.09 : gap < 0.055;
        if (next !== handPinched) {
          handPinched = next;
          try { interaction.handPress(next); } catch (_) {}
        }
      }
    });
    syncSoundPanel();
  };

  const onCameraBgToggle = async () => {
    const interaction = getPieceInteractionController();
    if (!cameraOverlayAllowed || !interaction?.supportsCameraBackground) return;
    // Module-level shared camera feed (CreatrSonicController.cameraFeed) —
    // same ref-counted stream the hand pipeline uses, but usable with no
    // audio controller at all (camera-only pieces). Acquire is the first
    // await in this gesture task, per the WebKit activation rule.
    if (cameraBgActive) {
      cameraBgActive = false;
      interaction.clearBackgroundVideo();
      try { window.CreatrSonicController?.cameraFeed.release(); } catch (_) {}
      syncSoundPanel();
      return;
    }
    try {
      const CSC = window.CreatrSonicController || await loadSonicControllerOnce();
      const video = await CSC.cameraFeed.acquire();
      cameraBgActive = !!interaction.setBackgroundVideo(video);
      if (!cameraBgActive) CSC.cameraFeed.release();
    } catch (_) {
      cameraBgActive = false;
    }
    syncSoundPanel();
  };

  const onResetView = async () => {
    // No handControlActive/handActivationPending/tiltFallbackActive guard —
    // every resetView() implementation now sets its own resettingView flag
    // during the animation, which handPoint/handCommand check to ignore
    // concurrent hand-frame updates. Blocking the button itself instead
    // silently no-op'd every click while steering was active, with zero
    // feedback that it had done so.
    const interaction = getPieceInteractionController();
    if (typeof interaction?.resetView !== "function" || !resetViewButton) return;
    resetViewButton.disabled = true;
    resetViewButton.setAttribute("aria-busy", "true");
    try { await interaction.resetView(); }
    finally {
      resetViewButton.disabled = false;
      resetViewButton.removeAttribute("aria-busy");
    }
  };

  const onMicToggle = async () => {
    const ctrl = getAudioController();
    if (!ctrl) return;
    if (ctrl.isMicEnabled()) {
      ctrl.disableMic();
    } else {
      // getUserMedia's own permission prompt is the user gesture here; on
      // denial/error this silently stays off, same as hand-tracking above.
      await ctrl.enableMic();
    }
    syncSoundPanel();
  };

  const onCapabilityState = (event) => {
    const detail = event.detail || {};
    if (detail.capability === "hand_control_model" || detail.capability === "hand_control") {
      if (detail.state === "loading") {
        if (!handControlPreparingAt) handControlPreparingAt = Date.now();
        handControlStatus?.setAttribute("aria-busy", "true");
        if (handControlStatus) handControlStatus.dataset.state = "loading";
        setHandControlStatus("Preparing hand steering…");
      } else if (detail.state === "ready" || detail.state === "active") {
        handControlStatus?.setAttribute("aria-busy", "false");
        if (handControlStatus) handControlStatus.dataset.state = "ready";
        const elapsed = handControlPreparingAt ? Date.now() - handControlPreparingAt : 0;
        setHandControlStatus("Hand steering ready.", { removeAfter: Math.max(1600, 2400 - elapsed) });
        handControlPreparingAt = 0;
      } else if (detail.state === "inactive") {
        setHandControlStatus("");
      } else if (detail.state === "unavailable") {
        handControlStatus?.setAttribute("aria-busy", "false");
        if (handControlStatus) handControlStatus.dataset.state = "unavailable";
        setHandControlStatus(detail.reason || "Hand steering is unavailable on this device.");
        handControlPreparingAt = 0;
      }
    }
    const target = detail.capability === "hand_tracking"
      ? soundHandToggle
      : detail.capability === "hand_control"
        ? handControlToggle
        : detail.capability === "mic"
          ? micToggle
          : null;
    if (!target) return;
    target.dataset.capabilityState = detail.state || "";
    target.setAttribute("aria-busy", detail.state === "loading" ? "true" : "false");
    if (detail.state === "loading") target.disabled = detail.capability !== "hand_control";
    if (detail.state === "active") { target.disabled = false; target.setAttribute("aria-pressed", "true"); }
    if (detail.state === "inactive") { target.disabled = false; target.setAttribute("aria-pressed", "false"); }
    if (detail.state === "unavailable") {
      target.setAttribute("aria-pressed", "false");
      target.title = detail.reason || "Unavailable on this device";
      if (detail.capability === "hand_control" && detail.fallback === "device_tilt") {
        target.disabled = false;
        target.dataset.capabilityFallback = "device_tilt";
        target.textContent = "Use device tilt";
      } else {
        target.disabled = true;
        target.textContent = detail.capability === "mic" ? "Mic unavailable" : "Hand tracking unavailable";
      }
    }
  };

  const onMicFxToggle = (event) => {
    const checkbox = event.target;
    if (!(checkbox instanceof Element) || !checkbox.matches("[data-immersive-mic-fx-toggle]")) return;
    getAudioController()?.setMicEffect(checkbox.dataset.effect, checkbox.checked);
  };

  const onPianoKeysClick = (event) => {
    if (!(event.target instanceof Element)) return;
    const keyBtn = event.target.closest("[data-immersive-piano-key]");
    if (!keyBtn) return;
    getAudioController()?.playChromaticNote(Number(keyBtn.dataset.semitone || 0));
  };

  const onPianoOctaveDown = () => {
    const ctrl = getAudioController();
    if (!ctrl) return;
    ctrl.setOctave(ctrl.getOctave() - 1);
    if (pianoOctaveDisplay) pianoOctaveDisplay.textContent = String(ctrl.getOctave());
  };

  const onPianoOctaveUp = () => {
    const ctrl = getAudioController();
    if (!ctrl) return;
    ctrl.setOctave(ctrl.getOctave() + 1);
    if (pianoOctaveDisplay) pianoOctaveDisplay.textContent = String(ctrl.getOctave());
  };

  const onVoicePickerChange = async (event) => {
    const select = event.target;
    if (!(select instanceof Element) || !select.matches("[data-immersive-voice-picker-select]")) return;
    const ctrl = getAudioController();
    if (!ctrl) return;
    await ctrl.setVoiceInstrument(select.dataset.voice, select.value);
  };

  const onSoundDocumentPointerDown = (event) => {
    if (!isSoundPanelOpen()) return;
    if (soundWrap && event.target instanceof Node && soundWrap.contains(event.target)) return;
    setSoundPanelOpen(false);
  };

  const onSoundDocumentKeyDown = (event) => {
    if (event.key === "Escape" && isSoundPanelOpen()) {
      event.stopPropagation();
      setSoundPanelOpen(false, { focusTrigger: true });
    }
  };

  soundPanelTrigger?.addEventListener("click", onSoundPanelToggle);
  soundMuteToggle?.addEventListener("click", onSoundMuteToggle);
  soundVolume?.addEventListener("input", onSoundVolumeInput);
  soundKeyboardToggle?.addEventListener("click", onSoundKeyboardToggle);
  pianoKeysGroup?.addEventListener("pointerdown", onPianoKeysClick);
  pianoOctaveDown?.addEventListener("click", onPianoOctaveDown);
  pianoOctaveUp?.addEventListener("click", onPianoOctaveUp);
  soundHandToggle?.addEventListener("click", onSoundHandToggle);
  handControlToggle?.addEventListener("focus", prepareHandControl);
  handControlToggle?.addEventListener("click", onHandControlToggle);
  resetViewButton?.addEventListener("click", onResetView);
  cameraBgToggle?.addEventListener("click", onCameraBgToggle);
  cameraOpacityInput?.addEventListener("input", onCameraOpacityInput);
  micToggle?.addEventListener("click", onMicToggle);
  micFxToggles.forEach((checkbox) => checkbox.addEventListener("change", onMicFxToggle));
  voicePickerSelects.forEach((select) => select.addEventListener("change", onVoicePickerChange));
  document.addEventListener("creatr-sonic-capability-state", onCapabilityState);
  if (soundPanelTrigger && soundPanel) {
    document.addEventListener("pointerdown", onSoundDocumentPointerDown, { capture: true });
    document.addEventListener("keydown", onSoundDocumentKeyDown);
  }

  return {
    closeDownloadMenu() {
      setMenuOpen(false);
    },
    dispose() {
      viewTrigger?.removeEventListener("click", onViewClick);
      downloadTrigger?.removeEventListener("click", onDownloadToggle);
      downloadMenu?.removeEventListener("click", onMenuClick);
      if (downloadTrigger && downloadMenu) {
        document.removeEventListener("pointerdown", onDocumentPointerDown, { capture: true });
        document.removeEventListener("keydown", onDocumentKeyDown);
      }
      soundPanelTrigger?.removeEventListener("click", onSoundPanelToggle);
      soundMuteToggle?.removeEventListener("click", onSoundMuteToggle);
      soundVolume?.removeEventListener("input", onSoundVolumeInput);
      soundKeyboardToggle?.removeEventListener("click", onSoundKeyboardToggle);
      pianoKeysGroup?.removeEventListener("pointerdown", onPianoKeysClick);
      pianoOctaveDown?.removeEventListener("click", onPianoOctaveDown);
      pianoOctaveUp?.removeEventListener("click", onPianoOctaveUp);
      soundHandToggle?.removeEventListener("click", onSoundHandToggle);
      handControlToggle?.removeEventListener("click", onHandControlToggle);
      resetViewButton?.removeEventListener("click", onResetView);
      cameraBgToggle?.removeEventListener("click", onCameraBgToggle);
      cameraOpacityInput?.removeEventListener("input", onCameraOpacityInput);
      if (handControlActive) {
        handControlActive = false;
        const handCtrl = activeHandControlEngine || getAudioController() || silentHandEngine;
        handCtrl?.onHandFrame(null);
        handCtrl?.disableHandControl();
        activeHandControlEngine = null;
        gestureRouter?.reset?.('destroyed');
        gestureRouter = null;
        setGestureModeIndicator('idle');
        getPieceInteractionController()?.setHandSteering(false);
      }
      if (silentHandEngine) {
        try { silentHandEngine.dispose(); } catch (_) {}
        silentHandEngine = null;
      }
      if (cameraBgActive) {
        cameraBgActive = false;
        getPieceInteractionController()?.clearBackgroundVideo();
        try { window.CreatrSonicController?.cameraFeed.release(); } catch (_) {}
      }
      micToggle?.removeEventListener("click", onMicToggle);
      micFxToggles.forEach((checkbox) => checkbox.removeEventListener("change", onMicFxToggle));
      voicePickerSelects.forEach((select) => select.removeEventListener("change", onVoicePickerChange));
      document.removeEventListener("creatr-sonic-capability-state", onCapabilityState);
      if (tiltFallbackActive) getPieceInteractionController()?.disableTiltFallback?.();
      detachPianoKeyListener?.();
      detachPianoKeyListener = null;
      if (soundPanelTrigger && soundPanel) {
        document.removeEventListener("pointerdown", onSoundDocumentPointerDown, { capture: true });
        document.removeEventListener("keydown", onSoundDocumentKeyDown);
      }
    },
  };
}

function selectProgressiveExhibitSlots(items, centers, target, liveBudget) {
  if (liveBudget <= 0) return new Set();
  return new Set(
    items
      .map((item, index) => {
        const center = centers[index];
        if (item.kind !== "piece" || !center) return null;
        const dx = center.x - target.x;
        const dy = center.y - target.y;
        const dz = center.z - target.z;
        return { index, distance: (dx * dx) + (dy * dy) + (dz * dz * 0.35) };
      })
      .filter((entry) => Boolean(entry))
      .sort((a, b) => a.distance - b.distance)
      .slice(0, liveBudget)
      .map((entry) => entry.index),
  );
}

// Helpers from art-piece-runtime / immersive-piece-runtime
function sanitizeArtPieceHtml(htmlCode, fallbackHtml) {
  const source = htmlCode?.trim() ? htmlCode : fallbackHtml;
  const temp = document.createElement('div');
  temp.innerHTML = source;
  
  const walk = (node) => {
    if (node.nodeType === Node.ELEMENT_NODE) {
      const tag = node.tagName.toUpperCase();
      if (tag !== 'DIV' && tag !== 'CANVAS') {
        const frag = document.createDocumentFragment();
        while (node.firstChild) {
          frag.appendChild(walk(node.removeChild(node.firstChild)));
        }
        return frag;
      }
      const cloned = document.createElement(tag.toLowerCase());
      for (const attr of Array.from(node.attributes)) {
        const name = attr.name.toLowerCase();
        if (['id', 'class', 'style', 'width', 'height'].includes(name) || name.startsWith('data-')) {
          cloned.setAttribute(name, attr.value);
        }
      }
      while (node.firstChild) {
        cloned.appendChild(walk(node.removeChild(node.firstChild)));
      }
      return cloned;
    }
    return node.cloneNode(true);
  };

  const frag = document.createDocumentFragment();
  while (temp.firstChild) {
    frag.appendChild(walk(temp.removeChild(temp.firstChild)));
  }
  const wrapper = document.createElement('div');
  wrapper.appendChild(frag);
  return wrapper.innerHTML || fallbackHtml;
}

function createImmersiveHost(htmlCode, cssCode, defaultHtml, size, engine) {
  const host = document.createElement("div");
  host.style.position = "fixed";
  host.style.left = "-10000px";
  host.style.top = "0";
  host.style.width = `${size.width}px`;
  host.style.height = `${size.height}px`;
  host.style.overflow = "hidden";
  host.style.pointerEvents = "none";

  const style = document.createElement("style");
  style.textContent = `
    canvas {
      display: block;
      max-width: none;
      position: static !important;
      top: auto !important;
      left: auto !important;
      bottom: auto !important;
      right: auto !important;
      z-index: auto !important;
    }
  `;
  host.appendChild(style);

  const markup = document.createElement("div");
  markup.style.width = "100%";
  markup.style.height = "100%";
  markup.innerHTML = engine === "svg"
    ? (htmlCode?.trim() ? htmlCode : defaultHtml)
    : sanitizeArtPieceHtml(htmlCode, defaultHtml);
  host.appendChild(markup);
  document.body.appendChild(host);
  return host;
}

function resolveSketchFactory(code) {
  const previousSketch = window.sketch;
  try {
    try {
      const expressionFactory = new Function(`return (${code})`)();
      if (typeof expressionFactory === "function") {
        return expressionFactory;
      }
    } catch (e) {
      // Fall through
    }
    window.sketch = undefined;
    new Function(code)();
    if (typeof window.sketch === "function") {
      return window.sketch;
    }
    throw new Error("Generated code did not define window.sketch or evaluate to a function");
  } finally {
    if (previousSketch === undefined) {
      delete window.sketch;
    } else {
      window.sketch = previousSketch;
    }
  }
}

let aframeRuntimePromise = null;
let aframeModelRuntimePromise = null;
function loadAFrameRuntime() {
  if (window.AFRAME) {
    disableAFrameWASD();
    return Promise.resolve(window.AFRAME);
  }
  if (aframeRuntimePromise) return aframeRuntimePromise;
  aframeRuntimePromise = new Promise((resolve, reject) => {
    const existing = Array.from(document.scripts).find((script) => script.src && script.src.endsWith("runtime/aframe/aframe.min.js"));
    if (existing) {
      existing.addEventListener("load", () => {
        disableAFrameWASD();
        resolve(window.AFRAME);
      }, { once: true });
      existing.addEventListener("error", () => reject(new Error("Could not load self-hosted A-Frame runtime.")), { once: true });
      return;
    }
    const script = document.createElement("script");
    script.src = "runtime/aframe/aframe.min.js";
    script.onload = () => {
      disableAFrameWASD();
      resolve(window.AFRAME);
    };
    script.onerror = () => reject(new Error("Could not load self-hosted A-Frame runtime."));
    document.head.appendChild(script);
  });
  return aframeRuntimePromise;
}

function loadAFrameModelRuntime() {
  if (window.CreatrAFrameModelRuntime) return Promise.resolve(window.CreatrAFrameModelRuntime);
  if (aframeModelRuntimePromise) return aframeModelRuntimePromise;
  aframeModelRuntimePromise = new Promise((resolve, reject) => {
    const existing = Array.from(document.scripts).find((script) => script.src && script.src.includes('/assets/js/aframe-model-runtime.js'));
    if (existing) {
      existing.addEventListener('load', () => resolve(window.CreatrAFrameModelRuntime), { once: true });
      existing.addEventListener('error', () => reject(new Error('Could not load the A-Frame model runtime.')), { once: true });
      return;
    }
    const script = document.createElement('script');
    const configuredSource = typeof window.__creatrAFrameModelRuntimeSrc === 'string'
      ? window.__creatrAFrameModelRuntimeSrc.trim()
      : '';
    script.src = configuredSource || ('/assets/js/aframe-model-runtime.js?v=' + Date.now());
    script.onload = () => resolve(window.CreatrAFrameModelRuntime);
    script.onerror = () => reject(new Error('Could not load the A-Frame model runtime.'));
    document.head.appendChild(script);
  });
  return aframeModelRuntimePromise;
}

let p5RuntimePromise = null;
function loadP5Runtime() {
  if (typeof window.p5 === "function") return Promise.resolve(window.p5);
  if (p5RuntimePromise) return p5RuntimePromise;
  p5RuntimePromise = new Promise((resolve, reject) => {
    const script = document.createElement("script");
    script.src = "runtime/p5/p5.min.js";
    script.onload = () => {
      if (typeof window.p5 === "function") {
        resolve(window.p5);
      } else {
        reject(new Error("p5.js loaded but did not expose window.p5."));
      }
    };
    script.onerror = () => reject(new Error("Could not load p5.js runtime."));
    document.head.appendChild(script);
  });
  return p5RuntimePromise;
}

let c2RuntimePromise = null;
function loadC2Runtime() {
  if (window.c2 && typeof window.c2.Renderer === "function") return Promise.resolve(window.c2);
  if (c2RuntimePromise) return c2RuntimePromise;
  c2RuntimePromise = new Promise((resolve, reject) => {
    const script = document.createElement("script");
    script.src = "runtime/c2/c2.min.js";
    script.onload = () => {
      if (window.c2 && typeof window.c2.Renderer === "function") {
        resolve(window.c2);
      } else {
        reject(new Error("c2.js loaded but did not expose window.c2.Renderer."));
      }
    };
    script.onerror = () => reject(new Error("Could not load c2.js runtime."));
    document.head.appendChild(script);
  });
  return c2RuntimePromise;
}

// KEEP IN SYNC (creatr-media-path-guard): piece-runtime.js / immersive-gallery.js
function isCmsMediaPath(src) {
  return typeof src === "string" && /^\/(?:image\/[0-9]+|api\/media-assets\/[0-9]+|media\/[A-Za-z0-9._~/%+-]+)(?:\?[A-Za-z0-9._~%=&+-]*)?$/.test(src);
}

// Server-side capture-safe rewriting substitutes data: URLs for CMS media
// refs before code reaches this helper — accept those alongside CMS paths.
function isInlineMediaSrc(src) {
  return typeof src === "string" && (/^data:image\//i.test(src) || /^blob:/i.test(src));
}

function normalizeCmsMediaPath(src) {
  if (typeof src !== "string" || src === "") return "";
  if (isCmsMediaPath(src)) return src;
  try {
    const resolved = new URL(src, window.location.href);
    if (resolved.origin !== window.location.origin) return "";
    const path = resolved.pathname + resolved.search;
    return isCmsMediaPath(path) ? path : "";
  } catch (_) {
    return "";
  }
}

function resolveRuntimeMediaSrc(src) {
  if (isInlineMediaSrc(src)) return src;
  return normalizeCmsMediaPath(src);
}

function describeMediaSrc(src) {
  if (typeof src !== "string") return String(src);
  return isInlineMediaSrc(src) ? src.slice(0, 64) + "… (inline media data)" : src;
}

function createC2MediaHelpers(canvas, onError = console.error) {
  const mediaContext = canvas.getContext("2d");
  const imageCache = new Map();

  function reportError(error) {
    if (typeof onError === "function") onError(error);
  }

  function loadImage(src) {
    const resolved = resolveRuntimeMediaSrc(src);
    if (!resolved) {
      reportError("C2 media helpers may only load same-origin CMS media paths such as /image/2, /media/..., or /api/media-assets/2.");
      return null;
    }
    src = resolved;
    if (imageCache.has(src)) return imageCache.get(src);
    const image = new Image();
    image.decoding = "async";
    image.loading = "eager";
    image.dataset.creatrLoaded = "0";
    // Same contract as piece-runtime.js loadImage: a Promise (await/.then
    // both work) carrying the element for the draw helpers to unwrap.
    const loaded = new Promise((resolve, reject) => {
      image.onload = () => { image.dataset.creatrLoaded = "1"; resolve(image); };
      image.onerror = () => {
        const message = "Could not load CMS media asset: " + describeMediaSrc(src);
        reportError(message);
        reject(new Error(message));
      };
    });
    loaded.catch(() => {});
    loaded.__creatrImage = image;
    image.src = src;
    imageCache.set(src, loaded);
    return loaded;
  }

  function resolveImageRef(image) {
    return image && image.__creatrImage ? image.__creatrImage : image;
  }

  function drawImage(image, x, y, width, height) {
    image = resolveImageRef(image);
    if (!mediaContext || !image || image.dataset?.creatrLoaded !== "1") return false;
    try {
      mediaContext.drawImage(image, x, y, width, height);
      return true;
    } catch (error) {
      reportError(error);
      return false;
    }
  }

  function drawImageCover(image, x, y, width, height) {
    image = resolveImageRef(image);
    if (!mediaContext || !image || image.dataset?.creatrLoaded !== "1") return false;
    const sourceWidth = image.naturalWidth || image.width;
    const sourceHeight = image.naturalHeight || image.height;
    if (!sourceWidth || !sourceHeight || !width || !height) return false;
    const sourceAspect = sourceWidth / sourceHeight;
    const targetAspect = width / height;
    let sx = 0;
    let sy = 0;
    let sw = sourceWidth;
    let sh = sourceHeight;
    if (sourceAspect > targetAspect) {
      sw = sourceHeight * targetAspect;
      sx = (sourceWidth - sw) / 2;
    } else {
      sh = sourceWidth / targetAspect;
      sy = (sourceHeight - sh) / 2;
    }
    try {
      mediaContext.drawImage(image, sx, sy, sw, sh, x, y, width, height);
      return true;
    } catch (error) {
      reportError(error);
      return false;
    }
  }

  return { loadImage, drawImage, drawImageCover };
}

function createFrameLabel(title, subtitle) {
  const cw = 512, ch = 80;
  const canvas = document.createElement("canvas");
  canvas.width = cw; canvas.height = ch;
  const ctx = canvas.getContext("2d");
  ctx.clearRect(0, 0, cw, ch);
  ctx.fillStyle = "rgba(255,255,255,0.82)";
  ctx.fillRect(0, 0, cw, ch);
  ctx.fillStyle = "rgba(0,0,0,0.82)";
  ctx.font = "bold 22px sans-serif";
  ctx.textBaseline = "middle";
  ctx.fillText(title.slice(0, 38), 16, ch * 0.38);
  ctx.fillStyle = "rgba(0,0,0,0.52)";
  ctx.font = "16px sans-serif";
  ctx.fillText(subtitle.slice(0, 42), 16, ch * 0.72);

  const texture = new THREE.CanvasTexture(canvas);
  const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false });
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(WALL_FRAME_ART_WIDTH, WALL_LABEL_HEIGHT), material);
  return { mesh, material };
}

function createMultiFrameExhibitWall(stage, frameCount, rows = 1, cols = frameCount, labels, options = {}) {
  const n = Math.max(1, frameCount);
  const gridRows = Math.max(1, rows);
  const gridCols = Math.max(1, cols);
  const labelPosition = options.labelPosition === "above" ? "above" : "below";
  const wallWidth = Math.max(22, gridCols * WALL_FRAME_SLOT_WIDTH + 2);
  const wallMeshHeight = Math.max(11, gridRows * WALL_FRAME_SLOT_HEIGHT + 5);
  const gridCenterY = computeExhibitGridCenterY(gridRows);

  const canvas = document.createElement("canvas");
  canvas.style.width = "100%"; canvas.style.height = "100%"; canvas.style.display = "block"; canvas.style.touchAction = "none";
  const urlParams = new URLSearchParams(window.location.search);
  const isCloseup = urlParams.get('closeup') === '1' || urlParams.get('thumbnail') === '1';
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: isCloseup });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  stage.innerHTML = "";
  stage.appendChild(canvas);

  const scene = new THREE.Scene();
  scene.background = new THREE.Color("#f1ece2");
  scene.fog = new THREE.Fog("#f1ece2", Math.max(20, wallWidth + 4), Math.max(40, wallWidth * 3));

  const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 200);
  const controls = new OrbitControls(camera, canvas);
  controls.enableDamping = true;
  controls.enablePan = true;
  controls.minPolarAngle = 0.01;
  controls.maxPolarAngle = Math.PI - 0.01;

  scene.add(new THREE.AmbientLight(0xffffff, 1.38));
  const keyLight = new THREE.DirectionalLight(0xfffcf6, 0.9);
  keyLight.position.set(0.8, 4.8, 5.2);
  scene.add(keyLight);
  const fillLight = new THREE.DirectionalLight(0xf3ede2, 0.35);
  fillLight.position.set(-3.1, 2.4, 1.8);
  scene.add(fillLight);

  const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(wallWidth + 8, 18),
    new THREE.MeshStandardMaterial({ color: "#d7d0c4", roughness: 0.98, metalness: 0 }),
  );
  floor.rotation.x = -Math.PI / 2;
  floor.position.set(0, 0, 1.9);
  scene.add(floor);

  const backWall = new THREE.Mesh(
    new THREE.PlaneGeometry(wallWidth + 4, wallMeshHeight),
    new THREE.MeshStandardMaterial({ color: "#f8f5ee", roughness: 1, metalness: 0 }),
  );
  backWall.position.set(0, gridCenterY, -1.35);
  scene.add(backWall);

  const wallCenterZ = WALL_CENTER.z;
  const slots = [];
  for (let i = 0; i < n; i++) {
    const row = Math.floor(i / gridCols);
    const col = i % gridCols;
    const slotX = (col - (gridCols - 1) / 2) * WALL_FRAME_SLOT_WIDTH;
    const slotY = gridCenterY + ((gridRows - 1) / 2 - row) * WALL_FRAME_SLOT_HEIGHT;

    const framePanel = new THREE.Mesh(
      new THREE.BoxGeometry(WALL_FRAME_ART_WIDTH + 0.3, WALL_FRAME_ART_HEIGHT + 0.3, 0.05),
      new THREE.MeshStandardMaterial({ color: "#fcfaf6", roughness: 0.96, metalness: 0 }),
    );
    framePanel.position.set(slotX, slotY, -1.16);
    scene.add(framePanel);

    const artMaterial = new THREE.MeshBasicMaterial({ color: "#e8e4de" });
    const artMesh = new THREE.Mesh(new THREE.PlaneGeometry(WALL_FRAME_ART_WIDTH, WALL_FRAME_ART_HEIGHT), artMaterial);
    artMesh.position.set(slotX, slotY, wallCenterZ);
    scene.add(artMesh);

    const frameMesh = new THREE.Mesh(
      new THREE.BoxGeometry(WALL_FRAME_ART_WIDTH + 0.12, WALL_FRAME_ART_HEIGHT + 0.12, 0.03),
      new THREE.MeshStandardMaterial({ color: "#d8d1c7", roughness: 0.92, metalness: 0 }),
    );
    frameMesh.position.set(slotX, slotY, -1.12);
    scene.add(frameMesh);

    const slot = { artMesh, artMaterial, frameMesh, framePanel, center: { x: slotX, y: slotY, z: wallCenterZ } };

    const label = labels?.[i];
    if (label) {
      const { mesh: labelMesh, material: labelMaterial } = createFrameLabel(label.title, label.subtitle);
      const labelY = labelPosition === "above"
        ? slotY + WALL_FRAME_ART_HEIGHT / 2 + WALL_LABEL_HEIGHT / 2 + WALL_LABEL_GAP
        : slotY - WALL_FRAME_ART_HEIGHT / 2 - WALL_LABEL_HEIGHT / 2 - WALL_LABEL_GAP;
      labelMesh.position.set(slotX, labelY, wallCenterZ + 0.01);
      scene.add(labelMesh);
      slot.labelMesh = labelMesh;
      slot.labelMaterial = labelMaterial;
    }
    slots.push(slot);
  }

  const shell = { canvas, renderer, scene, camera, controls, floor, backWall, slots, gridRows, gridCols, gridCenterY };
  fitMultiFrameExhibitCamera(shell, stage);
  return shell;
}

function fitMultiFrameExhibitCamera(shell, stage, resetCamera = true) {
  const width = stage.clientWidth >= 50 ? stage.clientWidth : window.innerWidth;
  const height = stage.clientHeight >= 50 ? stage.clientHeight : window.innerHeight;
  shell.camera.aspect = width / Math.max(height, 1);
  shell.camera.updateProjectionMatrix();
  shell.renderer.setSize(width, height, false);

  const { gridRows, gridCols, gridCenterY } = shell;
  const totalWidth = Math.max(WALL_FRAME_ART_WIDTH, (gridCols - 1) * WALL_FRAME_SLOT_WIDTH + WALL_FRAME_ART_WIDTH);
  const totalHeight = Math.max(WALL_FRAME_ART_HEIGHT, (gridRows - 1) * WALL_FRAME_SLOT_HEIGHT + WALL_FRAME_ART_HEIGHT);

  const target = new THREE.Vector3(0, gridCenterY - 0.16, WALL_CENTER.z);
  const verticalFov = THREE.MathUtils.degToRad(shell.camera.fov);
  const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * shell.camera.aspect);
  const distanceForHeight = (totalHeight / 2) / Math.tan(verticalFov / 2);
  const distanceForWidth = (totalWidth / 2) / Math.tan(horizontalFov / 2);
  const urlParams = new URLSearchParams(window.location.search);
  const isCloseup = urlParams.get('closeup') === '1' || urlParams.get('thumbnail') === '1';
  const multiplier = isCloseup ? 0.55 : 1.45;
  const distance = Math.max(distanceForHeight, distanceForWidth) * multiplier;

  shell.controls.minDistance = Math.max(1.2, distance * 0.25);
  shell.controls.maxDistance = Math.max(20, distance * 6);
  shell.controls.minAzimuthAngle = -Math.PI;
  shell.controls.maxAzimuthAngle = Math.PI;

  if (resetCamera) {
    shell.camera.position.set(0, gridCenterY + 0.2, WALL_CENTER.z + distance);
    shell.camera.lookAt(target);
    shell.controls.target.copy(target);
  }
  shell.controls.update();
}

// --- Movement/keyboard sonification (Tone.js) ------------------------------
// The actual synth/scale/motion/idle/volume/keyboard engine lives in the
// shared classic-script module sonic-controller.js (window.CreatrSonicController),
// consumed here the same way Tone.js itself is: lazy-loaded on first use, so
// pieces without sonification never pay for it. Sonic params ({tempo, scale,
// instrument, feel}) come from the piece's stored AI-generated sonic block; the
// generated piece code never touches audio. This wrapper keeps the toggle
// button/postMessage-free direct-click wiring that's specific to this
// (parent-page) context; piece-runtime.js and the ZIP export bootstrap wrap
// the same shared engine with their own wiring.
let _sonicControllerLoadPromise = null;
function loadSonicControllerOnce() {
  if (window.CreatrSonicController) return Promise.resolve(window.CreatrSonicController);
  if (_sonicControllerLoadPromise) return _sonicControllerLoadPromise;
  _sonicControllerLoadPromise = new Promise((resolve, reject) => {
    const s = document.createElement("script");
    // Standalone/ZIP exports set window.__creatrSonicControllerSrc to a
    // ZIP-local runtime/ path (mirrors how Tone.js's own src differs between
    // the live site and bundle mode) so exported pieces stay fully offline.
    s.src = window.__creatrSonicControllerSrc || "/assets/js/sonic-controller.js";
    s.onload = () => (window.CreatrSonicController ? resolve(window.CreatrSonicController) : reject(new Error("sonic-controller.js loaded but window.CreatrSonicController missing")));
    s.onerror = () => reject(new Error("sonic-controller.js failed to load"));
    document.head.appendChild(s);
  });
  return _sonicControllerLoadPromise;
}

// Returns a controller with update(motionVector)/setVolume()/setInputMode()/
// triggerNote()/dispose(), bound to the shared toolbar's mute/unmute toggle
// (`[data-immersive-sound-toggle]`). Browsers block audio autoplay until a
// user gesture — tapping the unmute button IS that gesture (the shared
// engine's Tone.start() runs on the click). Mute stops triggering notes but
// keeps the synth instantiated for instant re-unmute. Returns null when there
// are no usable sonic params — callers no-op safely.
// opts.attachListener (default true): single-piece mounts (three/aframe/
// gallery) each own exactly one controller for the page's lifetime, so the
// controller can safely own the toggle button's click listener directly.
// mountExhibitWall passes attachListener:false — it tears down and rebuilds
// a controller every time camera focus moves to a different wall item, and
// a controller for an item with no sonicParams is `null` entirely (see the
// early return above), so a listener living inside the controller would be
// attached/detached unpredictably as focus moves, silently going dead
// whenever the focused item happens to have no sound. In that mode the wall
// owns ONE persistent listener across all rebinds and drives this
// controller via the exposed toggleEnabled()/syncButton() methods instead.
// Visitor-chosen per-voice instrument overrides, session-local only (never
// sent to the server / never touches sonicParams) — one localStorage entry
// per piece so different pieces don't collide. Read/write helpers live here
// rather than in sonic-controller.js, which owns no DOM/storage access by
// design (see that file's header comment).
function voiceInstrumentStorageKey(pieceId) {
  return "creatr-sonic-voice-instruments:" + pieceId;
}
function readVoiceInstrumentOverrides(pieceId) {
  if (pieceId == null) return {};
  try {
    const raw = window.localStorage.getItem(voiceInstrumentStorageKey(pieceId));
    const parsed = raw ? JSON.parse(raw) : null;
    return (parsed && typeof parsed === "object") ? parsed : {};
  } catch (_e) {
    return {};
  }
}
function writeVoiceInstrumentOverride(pieceId, voiceName, instrumentKey) {
  if (pieceId == null) return;
  try {
    const overrides = readVoiceInstrumentOverrides(pieceId);
    overrides[voiceName] = instrumentKey;
    window.localStorage.setItem(voiceInstrumentStorageKey(pieceId), JSON.stringify(overrides));
  } catch (_e) {
    // Storage unavailable/full — override simply won't persist across reloads.
  }
}

function syncAudioToggleButton(toggleBtn, muted, labels = {}) {
  if (!toggleBtn) return;
  toggleBtn.setAttribute("aria-pressed", muted ? "false" : "true");
  toggleBtn.setAttribute("aria-label", muted ? (labels.off || "Unmute sound") : (labels.on || "Mute sound"));
  // Render muted (off) state: bar-with-slash. Unmuted (on): waves.
  if (toggleBtn.replaceChildren) {
    const svgNS = "http://www.w3.org/2000/svg";
    const svg = document.createElementNS(svgNS, "svg");
    svg.setAttribute("viewBox", "0 0 24 24");
    svg.setAttribute("width", "19");
    svg.setAttribute("height", "19");
    svg.setAttribute("fill", "none");
    svg.setAttribute("stroke", "currentColor");
    svg.setAttribute("stroke-width", "1.9");
    svg.setAttribute("stroke-linecap", "round");
    svg.setAttribute("stroke-linejoin", "round");
    svg.setAttribute("aria-hidden", "true");
    const speaker = document.createElementNS(svgNS, "path");
    speaker.setAttribute("d", "M11 5 6 9H3v6h3l5 4z");
    svg.appendChild(speaker);
    if (muted) {
      const l1 = document.createElementNS(svgNS, "line");
      l1.setAttribute("x1", "22"); l1.setAttribute("y1", "9");
      l1.setAttribute("x2", "16"); l1.setAttribute("y2", "15");
      const l2 = document.createElementNS(svgNS, "line");
      l2.setAttribute("x1", "16"); l2.setAttribute("y1", "9");
      l2.setAttribute("x2", "22"); l2.setAttribute("y2", "15");
      svg.appendChild(l1); svg.appendChild(l2);
    } else {
      const w1 = document.createElementNS(svgNS, "path");
      w1.setAttribute("d", "M16 9a4 4 0 0 1 0 6");
      const w2 = document.createElementNS(svgNS, "path");
      w2.setAttribute("d", "M19 6a8 8 0 0 1 0 12");
      svg.appendChild(w1); svg.appendChild(w2);
    }
    toggleBtn.replaceChildren(svg);
  }
}

function createAudioController(sonicParams, stageEl, opts = {}) {
  if (!sonicParams || typeof sonicParams !== "object") return null;
  const attachListener = opts.attachListener !== false;
  const pieceId = opts.pieceId != null ? opts.pieceId : null;

  let engine = null, disposed = false;
  // The toolbar (immersive_stage_toolbar_markup) renders as a SIBLING of
  // stageEl, not a descendant — stageEl.querySelector can never find it, so
  // this looks it up from the document instead. One immersive stage per page.
  const toggleBtn = document.querySelector("[data-immersive-sound-toggle]") || null;

  function ensureEngineSync() {
    if (disposed) return null;
    if (engine) return engine;
    const CSC = window.CreatrSonicController;
    if (!CSC) return null;
    engine = CSC.create(sonicParams, {
      allowHandControl: opts.allowHandControl === true,
      toneSrc: window.__creatrToneSrc,
      mediaPipeVisionSrc: window.__creatrMediaPipeVisionSrc,
      mediaPipeWasmDir: window.__creatrMediaPipeWasmDir,
      mediaPipeModelSrc: window.__creatrMediaPipeModelSrc,
    });
    return engine;
  }

  function setBtnState(muted) {
    syncAudioToggleButton(toggleBtn, muted);
  }

  // Shared unmute path: lazily loads the sonic-controller module (and, inside
  // it, Tone.js) on first use, then enables the engine. Used both by the
  // mute/unmute toggle and by the keyboard-mode note buttons, since tapping a
  // note before ever unmuting should itself count as the autoplay-unlocking
  // user gesture and just start playing.
  async function ensureEnabled() {
    if (disposed) return false;
    if (engine && engine.isEnabled()) return true;
    try {
      if (toggleBtn) toggleBtn.disabled = true;
      if (!engine) {
        const CSC = await loadSonicControllerOnce();
        if (disposed) return false;
        // window.__creatrToneSrc, when set by a standalone/ZIP export's
        // bootstrap script, points Tone.js loading at the bundle-local
        // runtime/tone/Tone.js copy instead of the live site's vendor path.
        engine = CSC.create(sonicParams, {
          allowHandControl: opts.allowHandControl === true,
          toneSrc: window.__creatrToneSrc,
          mediaPipeVisionSrc: window.__creatrMediaPipeVisionSrc,
          mediaPipeWasmDir: window.__creatrMediaPipeWasmDir,
          mediaPipeModelSrc: window.__creatrMediaPipeModelSrc,
        });
        if (!engine) return false;
      }
      const ok = await engine.enable();
      if (disposed) return false;
      if (ok) {
        // Re-apply any visitor-chosen per-voice instrument overrides now that
        // the engine's synths actually exist (setVoiceInstrument() no-ops
        // before enable()). Safe to call every ensureEnabled() — it's a
        // cheap no-op once already applied to the current engine instance.
        const overrides = readVoiceInstrumentOverrides(pieceId);
        Object.keys(overrides).forEach((voiceName) => {
          engine.setVoiceInstrument(voiceName, overrides[voiceName]);
        });
        setBtnState(false);
      } else if (toggleBtn) {
        toggleBtn.setAttribute("aria-label", "Sound unavailable");
      }
      return ok;
    } catch (_e) {
      if (toggleBtn) toggleBtn.setAttribute("aria-label", "Sound unavailable");
      return false;
    } finally {
      if (toggleBtn) toggleBtn.disabled = false;
    }
  }

  async function toggleEnabled() {
    if (disposed) return;
    // Mute path: engine already exists, just turn off triggering.
    if (engine && engine.isEnabled()) {
      engine.disable();
      setBtnState(true);
      return;
    }
    await ensureEnabled();
  }

  if (attachListener && toggleBtn) {
    setBtnState(true);
    toggleBtn.addEventListener("click", toggleEnabled);
  }

  return {
    toggleEnabled,
    syncButton: setBtnState,
    isEnabled: () => !!engine && engine.isEnabled(),
    update(motion) { if (engine) engine.update(motion); },
    setVolume(percent) { if (engine) engine.setVolume(percent); },
    getVolume: () => (engine ? engine.getVolume() : (opts.defaultVolume ?? 50)),
    setInputMode(mode) { if (engine) engine.setInputMode(mode); },
    getInputMode: () => (engine ? engine.getInputMode() : "motion"),
    getScaleLength: () => (engine ? engine.getScaleLength() : 7),
    // Read directly off sonicParams (available immediately, no need to wait
    // for the lazily-created engine) so popover wiring can decide which
    // voice controls to show before the user has ever unmuted.
    getVoices: () => {
      const voices = sonicParams && sonicParams.extras && sonicParams.extras.voices;
      const allowHandTracking = opts.allowHandTracking !== false;
      return {
        ambient: !voices || voices.ambient !== false,
        movement: !voices || voices.movement !== false,
        melodic: !voices || voices.melodic !== false,
        hand_tracking: allowHandTracking && !!(voices && voices.hand_tracking),
      };
    },
    // Public alias so callers (e.g. the keyboard-mode toggle, before
    // attaching a physical-key listener) can force the lazy engine/Tone.js
    // load without actually playing a note.
    ensureAudioReady: () => ensureEnabled(),
    async playNote(degree, octaveOffset) {
      const ok = await ensureEnabled();
      if (ok && engine) engine.triggerNote(degree, octaveOffset);
    },
    async playChromaticNote(semitoneIndex) {
      const ok = await ensureEnabled();
      if (ok && engine) engine.triggerChromaticNote(semitoneIndex);
    },
    setOctave(octave) { if (engine) engine.setOctave(octave); },
    getOctave: () => (engine ? engine.getOctave() : 3),
    getOctaveRange: () => (engine ? engine.getOctaveRange() : { min: 1, max: 5 }),
    // Visitor-facing per-voice instrument override — session-local only, see
    // readVoiceInstrumentOverrides()/writeVoiceInstrumentOverride() above.
    async setVoiceInstrument(voiceName, instrumentKey) {
      const ok = await ensureEnabled();
      if (!ok || !engine) return false;
      const applied = engine.setVoiceInstrument(voiceName, instrumentKey);
      if (applied) writeVoiceInstrumentOverride(pieceId, voiceName, instrumentKey);
      return applied;
    },
    getVoiceInstrument(voiceName) {
      if (engine) return engine.getVoiceInstrument(voiceName);
      const overrides = readVoiceInstrumentOverrides(pieceId);
      return overrides[voiceName] || (sonicParams && sonicParams.instrument) || "synth";
    },
    // A sample-backed ambient voice has no meaningful synth-instrument
    // choice — UI surfaces use this to hide/disable that dropdown outright.
    isAmbientSample() {
      if (engine) return engine.isAmbientSample();
      const s = sonicParams && sonicParams.extras && sonicParams.extras.synth && sonicParams.extras.synth.ambient_sample;
      return !!(s && s.enabled && s.media_id);
    },
    attachPianoKeyListener(onNoteStateChange) { return engine ? window.CreatrSonicController.attachPianoKeyListener(engine, onNoteStateChange) : () => {}; },
    async enableHandTracking() {
      if (opts.allowHandTracking === false) return false;
      const ok = await ensureEnabled();
      if (!ok || !engine) return false;
      return engine.enableHandTracking();
    },
    disableHandTracking() { if (engine) engine.disableHandTracking(); },
    async enableHandControl() {
      if (opts.allowHandTracking === false) return false;
      if (!engine) ensureEngineSync();
      return engine ? engine.enableHandControl() : false;
    },
    disableHandControl() { if (engine) engine.disableHandControl(); },
    onHandFrame(callback) { if (engine) engine.onHandFrame(callback); },
    async acquireCameraFeed() {
      if (opts.allowHandTracking === false) throw new Error("camera unavailable");
      if (!engine) ensureEngineSync();
      if (!engine) throw new Error("camera unavailable");
      return engine.acquireCameraFeed();
    },
    releaseCameraFeed() { if (engine) engine.releaseCameraFeed(); },
    // Live human-voice input (mic) — visitor-facing, off by default, never
    // persisted. isMicSupported() is a plain feature check that works
    // before the engine/Tone.js exist, so the UI can decide whether to
    // reveal the toggle at all before the visitor has ever unmuted.
    isMicSupported() {
      return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
    },
    isMicEnabled: () => (engine ? engine.isMicEnabled() : false),
    async enableMic() {
      const ok = await ensureEnabled();
      if (!ok || !engine) return false;
      return engine.enableMic();
    },
    disableMic() { if (engine) engine.disableMic(); },
    setMicEffect(key, enabled, params) { return engine ? engine.setMicEffect(key, enabled, params) : false; },
    dispose() {
      disposed = true;
      if (attachListener && toggleBtn) toggleBtn.removeEventListener("click", toggleEnabled);
      if (engine) engine.dispose();
    },
  };
}

// Phase 1 Entry Point: mountThreeImmersivePiece
function mountThreeImmersivePiece(stageEl, code, htmlCode, cssCode, onError = console.error, options = {}) {
  const runtimeSize = { width: 1280, height: 720 };
  const host = createImmersiveHost(htmlCode, cssCode, '<div id="container"></div>', runtimeSize);

  const canvas = host.querySelector("canvas") || document.createElement("canvas");
  canvas.width = runtimeSize.width;
  canvas.height = runtimeSize.height;
  canvas.style.width = "100%";
  canvas.style.height = "100%";
  canvas.style.display = "block";
  canvas.style.touchAction = "none";

  stageEl.innerHTML = "";
  Array.from(host.childNodes).map(node => node.cloneNode(true)).forEach(child => stageEl.appendChild(child));
  const mount = stageEl.querySelector("#container") || stageEl.querySelector("#canvas-container") || stageEl.querySelector("#sketch-container") || stageEl.querySelector(":scope > div") || stageEl;
  stageEl.querySelectorAll("canvas").forEach(c => { if (c !== canvas) c.remove(); });
  if (canvas.parentElement !== mount) mount.appendChild(canvas);

  let cleanup;
  let frameId = 0;
  let consecutiveErrors = 0;
  let controlFrame = 0;
  let pieceDrivesOwnRender = false;
  const stopFrameHandles = new Set();
  const state = { scene: null, camera: null, renderer: null, objects: [] };
  let initialThreeViewState = null;

  const instrumentedThree = { ...THREE };
  const OriginalScene = THREE.Scene;
  instrumentedThree.Scene = class extends OriginalScene {
    constructor(...args) { super(...args); state.scene = this; }
    add(...objects) {
      objects.forEach(obj => {
        if (obj?.geometry) {
          state.objects.push(obj);
          if (state.objects.length === 5000) {
            console.warn("Three.js immersive piece has 5000+ tracked mesh objects — render performance may degrade.");
          }
        }
      });
      return super.add(...objects);
    }
  };

  const OriginalPerspectiveCamera = THREE.PerspectiveCamera;
  instrumentedThree.PerspectiveCamera = class extends OriginalPerspectiveCamera {
    constructor(...args) { super(...args); state.camera = this; }
  };

  if ("OrthographicCamera" in THREE) {
    const OriginalOrthographicCamera = THREE.OrthographicCamera;
    instrumentedThree.OrthographicCamera = class extends OriginalOrthographicCamera {
      constructor(...args) { super(...args); state.camera = this; }
    };
  }

  const OriginalRenderer = THREE.WebGLRenderer;
  instrumentedThree.WebGLRenderer = class extends OriginalRenderer {
    constructor(input) {
      // preserveDrawingBuffer keeps the frame readable for PNG capture,
      // matching the regular-view three bootstrap in piece-runtime.js.
      super({ ...input, canvas, preserveDrawingBuffer: true });
      state.renderer = this;
      this.setPixelRatio?.(Math.min(window.devicePixelRatio, 2));
      const _origSetSize = this.setSize.bind(this);
      this.setSize = (w, h) => _origSetSize(w, h, false);
      const _origRender = this.render.bind(this);
      this.render = (sc, cam) => {
        if (sc) state.scene = sc;
        if (cam) state.camera = cam;
        return _origRender(sc, cam);
      };
    }
  };

  // Loader for uploaded 3D models (module-level, contained dynamic import).
  // Generated code references it as THREE.GLTFLoader (no import/fetch tokens
  // → passes preflight); it loads the model by its /media/{id} URL at
  // runtime. Absent only if its source failed to load.
  if (_GLTFLoaderCtor) instrumentedThree.GLTFLoader = _GLTFLoaderCtor;

  window.THREE = instrumentedThree;

  function autoFitCamera(viewportWidth = stageEl.clientWidth || window.innerWidth) {
    if (!state.scene || !state.camera) return;
    const box = new THREE.Box3();
    if (state.scene.traverse) {
      state.scene.traverse(obj => {
        if (obj.isHelper || obj.isLight || obj.isCamera) return;
        if (obj.isPoints) return;
        if (obj.material) {
          const mat = obj.material;
          if (mat.side === 1 || (Array.isArray(mat) && mat.some(m => m.side === 1))) return;
        }
        const name = (obj.name || '').toLowerCase();
        if (name.includes('sky') || name.includes('background') || name.includes('env') || name.includes('floor') || name.includes('ground') || name.includes('grid') || name.includes('dome') || name.includes('space') || name.includes('star')) return;
        if ((obj.isMesh || obj.isLine || obj.isSprite) && obj.geometry) {
          obj.geometry.computeBoundingBox?.();
          if (obj.geometry.boundingBox) {
            const worldBox = obj.geometry.boundingBox.clone().applyMatrix4(obj.matrixWorld);
            const worldSize = new THREE.Vector3();
            worldBox.getSize(worldSize);
            if (worldSize.x >= 30 || worldSize.y >= 30 || worldSize.z >= 30) return;
            if (obj.geometry.type === 'PlaneGeometry' || obj.geometry.type === 'PlaneBufferGeometry') {
              if (worldSize.x >= 15 || worldSize.y >= 15 || worldSize.z >= 15) return;
            }
            box.union(worldBox);
          }
        }
      });
    }
    if (box.isEmpty()) return;

    const center = new THREE.Vector3();
    box.getCenter(center);
    const size = new THREE.Vector3();
    box.getSize(size);
    if (state.camera.position.lengthSq() > 0.01) {
      if (controls) {
        controls.target.copy(center);
        controls.update();
        saveOrbitState();
      }
      return;
    }
    const nextView = computeThreeAutoFitView(
      center, size, state.camera.aspect || 1, state.camera.fov || 45, isCompactImmersiveViewport(viewportWidth)
    );
    state.camera.position.set(nextView.camera.x, nextView.camera.y, nextView.camera.z);
    state.camera.lookAt(nextView.target.x, nextView.target.y, nextView.target.z);

    const maxDim = Math.max(size.x, size.y, size.z) || 1;
    const dist = state.camera.position.distanceTo(center);
    state.camera.near = Math.max(0.01, dist / 1000);
    state.camera.far = Math.max(1000, dist * 100 + maxDim * 100);
    state.camera.updateProjectionMatrix?.();
    state.camera.updateMatrixWorld?.(true);

    if (controls) {
      controls.target.set(nextView.target.x, nextView.target.y, nextView.target.z);
      controls.update();
      saveOrbitState();
    }
  }

  const startFrame = (handler) => {
    pieceDrivesOwnRender = true;
    let frameCount = 0;
    let rafId = 0;
    function tick() {
      frameCount += 1;
      try {
        handler(frameCount);
      } catch (err) {
        // The piece's own render loop just died — hand rendering back to
        // the bootstrap loop so the canvas doesn't freeze on its last frame
        // with no further visual feedback (e.g. while dragging/orbiting).
        pieceDrivesOwnRender = false;
        onError(err);
        return;
      }
      if (frameCount === 15) autoFitCamera();
      rafId = window.requestAnimationFrame(tick);
    }
    rafId = window.requestAnimationFrame(tick);
    const stop = () => window.cancelAnimationFrame(rafId);
    stopFrameHandles.add(stop);
    return () => {
      stop();
      stopFrameHandles.delete(stop);
    };
  };

  let controls = null;
  let keyNav = null;
  let isOrbitActive = false;
  let userHasInteracted = false;
  let gyroController = null;
  let viewerControls = null;
  let handSteeringExclusive = false;
  let resettingView = false;
  let controlsEnabledBeforeHand = true;
  const audioController = createAudioController(options.sonicParams, stageEl, { pieceId: options.pieceId, allowHandControl: options.handControl === true });
  const _audioPrevPos = new THREE.Vector3();
  let _audioPrevInit = false;
  const _orbitCamPos = new THREE.Vector3();
  const _orbitTarget = new THREE.Vector3();
  const _buttonForward = new THREE.Vector3();
  const _buttonRight = new THREE.Vector3();
  let threeNavLimit = 5;

  let threeAnimFromTarget = null, threeAnimToTarget = null, threeAnimFromCam = null, threeAnimToCam = null, threeAnimStart = 0;
  const threeRaycaster = new THREE.Raycaster();
  const _pointerState = new Map();
  let _hadMultiTouchGesture = false;

  function saveOrbitState() {
    if (!controls || !state.camera) return;
    _orbitCamPos.copy(state.camera.position);
    _orbitTarget.copy(controls.target);
  }

  function cancelThreeNavigationAnimation() {
    threeAnimFromTarget = threeAnimToTarget = threeAnimFromCam = threeAnimToCam = null;
    if (controls) controls.enabled = true;
  }

  function reassertThreeCanvasContainment() {
    if (canvas.parentElement !== mount) mount.appendChild(canvas);
    if (canvas.style.position || canvas.style.zIndex || canvas.style.width !== "100%" || canvas.style.height !== "100%") {
      canvas.style.position = canvas.style.top = canvas.style.left = canvas.style.bottom = canvas.style.right = canvas.style.zIndex = canvas.style.pointerEvents = "";
      canvas.style.width = canvas.style.height = "100%";
    }
    canvas.style.touchAction = "none";
    stageEl.querySelectorAll("canvas").forEach(c => {
      if (c !== canvas) { c.style.display = "none"; c.setAttribute("aria-hidden", "true"); }
    });

    // Resolve back ground
    let bg = null;
    for (let el of [canvas, mount, stageEl.querySelector("div"), stageEl, host.querySelector("#container"), host]) {
      if (!el) continue;
      let val = el.style.backgroundColor || el.style.background;
      if (val && val.trim() !== "" && val.trim() !== "transparent" && val.trim() !== "rgba(0, 0, 0, 0)") { bg = val; break; }
      let cmp = window.getComputedStyle(el).backgroundColor;
      if (cmp && cmp.trim() !== "" && cmp.trim() !== "transparent" && cmp.trim() !== "rgba(0, 0, 0, 0)") { bg = cmp; break; }
    }
    bg = bg ?? "#000000";
    stageEl.style.background = bg;
  }

  function getThreeNavigationLimit() {
    const box = new THREE.Box3();
    if (state.scene?.traverse) {
      state.scene.traverse(obj => {
        if (obj.isHelper || obj.isLight || obj.isCamera) return;
        if ((obj.isMesh || obj.isLine || obj.isPoints || obj.isSprite) && obj.geometry) {
          obj.geometry.computeBoundingBox?.();
          if (obj.geometry.boundingBox) box.union(obj.geometry.boundingBox.clone().applyMatrix4(obj.matrixWorld));
        }
      });
    }
    if (box.isEmpty()) {
      try { box.setFromObject(state.scene); } catch (_) { return 5; }
    }
    if (box.isEmpty()) return 5;
    const size = new THREE.Vector3();
    box.getSize(size);
    return Math.max(size.x, size.z, 1) * 0.7;
  }

  function moveThreeOrbitTo(hitPoint) {
    if (!controls || !state.camera) return;
    const dx = hitPoint.x - controls.target.x;
    const dz = hitPoint.z - controls.target.z;
    const maxOffset = getThreeNavigationLimit();
    const shift = new THREE.Vector3(Math.max(-maxOffset, Math.min(maxOffset, dx)), 0, Math.max(-maxOffset, Math.min(maxOffset, dz)));
    if (shift.lengthSq() < 0.003) return;

    cancelThreeNavigationAnimation();
    threeAnimFromTarget = controls.target.clone();
    threeAnimToTarget = threeAnimFromTarget.clone().add(shift);
    threeAnimFromCam = state.camera.position.clone();
    threeAnimToCam = threeAnimFromCam.clone().add(shift);
    threeAnimStart = performance.now();
    controls.enabled = false;
  }

  function activeTouchPointerCount() {
    let count = 0;
    _pointerState.forEach(pointer => {
      if (pointer.pointerType === "touch") count += 1;
    });
    return count;
  }

  function onThreePointerDown(e) {
    if (handSteeringExclusive) return;
    _pointerState.set(e.pointerId, {
      pointerType: e.pointerType || "mouse",
      button: e.button,
      startX: e.clientX,
      startY: e.clientY,
      moved: false,
    });
    if ((e.pointerType || "mouse") === "touch" && activeTouchPointerCount() > 1) {
      _hadMultiTouchGesture = true;
    }
  }

  function onThreePointerMove(e) {
    const pointer = _pointerState.get(e.pointerId);
    if (!pointer) return;
    if (Math.hypot(e.clientX - pointer.startX, e.clientY - pointer.startY) >= 6) {
      pointer.moved = true;
    }
    if (pointer.pointerType === "touch" && activeTouchPointerCount() > 1) {
      _hadMultiTouchGesture = true;
    }
  }

  function clearThreePointer(e) {
    const pointer = _pointerState.get(e.pointerId);
    _pointerState.delete(e.pointerId);
    if (pointer?.pointerType === "touch" && activeTouchPointerCount() === 0) {
      _hadMultiTouchGesture = false;
    }
  }

  function onThreePointerUp(e) {
    if (handSteeringExclusive || !controls || !state.camera) return;
    const pointer = _pointerState.get(e.pointerId);
    const wasMultiTouch = _hadMultiTouchGesture || activeTouchPointerCount() > 1;
    clearThreePointer(e);
    if (!pointer || wasMultiTouch || pointer.button !== 0 || e.button !== 0 || pointer.moved) return;

    const rect = canvas.getBoundingClientRect();
    threeRaycaster.setFromCamera(
      new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1),
      state.camera
    );

    let hitPoint = null;
    if (state.scene?.children?.length) {
      const hits = threeRaycaster.intersectObjects(state.scene.children, true);
      if (hits.length > 0) hitPoint = hits[0].point;
    }
    const floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
    const planeHit = new THREE.Vector3();
    if (!hitPoint && threeRaycaster.ray.intersectPlane(floorPlane, planeHit)) {
      hitPoint = planeHit;
    }
    if (hitPoint) moveThreeOrbitTo(hitPoint);
  }

  function applyThreeZoom(zoomScale) {
    // OrbitControls' own wheel/dolly handling changes camera.position
    // directly without dispatching "start"/"end", so isOrbitActive never
    // flips and saveOrbitState() never captures the new zoom distance —
    // the very next frame's "snap back to last saved state" logic then
    // reverts the zoom. Handling wheel ourselves (and saving state after)
    // closes that gap. Viewer buttons use this same path for parity.
    if (handSteeringExclusive || !controls || !state.camera || !Number.isFinite(zoomScale) || zoomScale <= 0) return;
    const cameraPosition = state.camera.position;
    const direction = cameraPosition.clone().sub(controls.target);
    const currentDistance = direction.length();
    if (currentDistance < 1e-6) return;
    const minDistance = controls.minDistance || 0.6;
    const maxDistance = controls.maxDistance || Math.max(40, currentDistance * 4);
    const nextDistance = Math.max(minDistance, Math.min(maxDistance, currentDistance * zoomScale));
    direction.setLength(nextDistance);
    cameraPosition.copy(controls.target).add(direction);
    controls.update();
    saveOrbitState();
    viewerControls?.setZoomValue(threeZoomValueFromDistance(nextDistance));
    userHasInteracted = true;
  }

  function threeZoomValueFromDistance(distance) {
    if (!controls || !Number.isFinite(distance)) return 50;
    const minDistance = controls.minDistance || 0.6;
    const maxDistance = controls.maxDistance || Math.max(40, distance * 4);
    if (maxDistance <= minDistance) return 50;
    return ((maxDistance - Math.max(minDistance, Math.min(maxDistance, distance))) / (maxDistance - minDistance)) * 100;
  }

  function applyThreeZoomValue(value) {
    if (handSteeringExclusive || !controls || !state.camera || !Number.isFinite(value)) return;
    const minDistance = controls.minDistance || 0.6;
    const currentDistance = state.camera.position.distanceTo(controls.target);
    const maxDistance = controls.maxDistance || Math.max(40, currentDistance * 4);
    const t = Math.max(0, Math.min(100, value)) / 100;
    const nextDistance = maxDistance - ((maxDistance - minDistance) * t);
    const direction = state.camera.position.clone().sub(controls.target);
    if (direction.lengthSq() < 1e-8) return;
    direction.setLength(nextDistance);
    state.camera.position.copy(controls.target).add(direction);
    controls.update();
    saveOrbitState();
    userHasInteracted = true;
  }

  function applyThreeDirectionalMove(forwardScale, rightScale) {
    if (handSteeringExclusive || !controls || !state.camera) return;
    cancelThreeNavigationAnimation();
    state.camera.getWorldDirection(_buttonForward);
    _buttonForward.y = 0;
    if (_buttonForward.lengthSq() < 1e-6) {
      _buttonForward.set(0, 0, -1);
    } else {
      _buttonForward.normalize();
    }
    _buttonRight.set(-_buttonForward.z, 0, _buttonForward.x);
    const step = Math.max(0.08, controls.target.distanceTo(state.camera.position) * 0.035);
    const shift = _buttonForward.multiplyScalar(forwardScale * step).add(_buttonRight.multiplyScalar(rightScale * step));
    if (shift.lengthSq() < 1e-8) return;
    const nextCamX = Math.max(-threeNavLimit, Math.min(threeNavLimit, state.camera.position.x + shift.x));
    const nextCamZ = Math.max(0.5, Math.min(threeNavLimit, state.camera.position.z + shift.z));
    const dx = nextCamX - state.camera.position.x;
    const dz = nextCamZ - state.camera.position.z;
    if (Math.abs(dx) < 1e-6 && Math.abs(dz) < 1e-6) return;
    state.camera.position.x += dx;
    state.camera.position.z += dz;
    controls.target.x += dx;
    controls.target.z += dz;
    controls.update();
    saveOrbitState();
    userHasInteracted = true;
  }

  function applyThreeFloatMove(verticalScale) {
    if (handSteeringExclusive || !controls || !state.camera || !Number.isFinite(verticalScale)) return;
    cancelThreeNavigationAnimation();
    const step = Math.max(0.08, controls.target.distanceTo(state.camera.position) * 0.03);
    const nextCamY = Math.max(0.1, Math.min(threeNavLimit, state.camera.position.y + (verticalScale * step)));
    const dy = nextCamY - state.camera.position.y;
    if (Math.abs(dy) < 1e-6) return;
    state.camera.position.y += dy;
    controls.target.y += dy;
    controls.update();
    saveOrbitState();
    userHasInteracted = true;
  }

  function onThreeWheel(e) {
    if (handSteeringExclusive) return;
    if (!controls || !state.camera) return;
    e.preventDefault();
    e.stopPropagation();
    applyThreeZoom(Math.exp(Math.max(-1, Math.min(1, e.deltaY / 600))));
  }

  function resize() {
    const width = stageEl.clientWidth || window.innerWidth;
    const height = stageEl.clientHeight || window.innerHeight;
    if (state.renderer?.setSize) state.renderer.setSize(width, height, false);
    if (state.camera) {
      if ("aspect" in state.camera) state.camera.aspect = width / Math.max(height, 1);
      state.camera.updateProjectionMatrix?.();
    }
  }

  function animateControls() {
    frameId = requestAnimationFrame(animateControls);
    try {
      controlFrame += 1;
      reassertThreeCanvasContainment();
      if (controls && state.camera) {
        if (!isOrbitActive) {
          state.camera.position.copy(_orbitCamPos);
          controls.target.copy(_orbitTarget);
        }
        let externalMotion = false;

        if (threeAnimToTarget && threeAnimFromTarget) {
          const t = Math.min((performance.now() - threeAnimStart) / 350, 1);
          const eased = 1 - (1 - t) ** 3;
          controls.target.lerpVectors(threeAnimFromTarget, threeAnimToTarget, eased);
          state.camera.position.lerpVectors(threeAnimFromCam, threeAnimToCam, eased);
          externalMotion = true;
          if (t >= 1) {
            controls.enabled = true;
            threeAnimFromTarget = threeAnimToTarget = threeAnimFromCam = threeAnimToCam = null;
          }
        }

        if (!handSteeringExclusive && keyNav?.update()) {
          externalMotion = true;
        }

        // Always let OrbitControls reconcile its internal spherical state *after*
        // keyboard/click navigation has translated the camera + target together.
        // This prevents the next pan from reviving an older zoom distance.
        controls.update();

        // DeviceOrientationControls only ever touches camera.quaternion, never
        // position — it runs after OrbitControls.update() so a device's tilt
        // has the final say on look direction, while drag-to-pan/pinch-zoom
        // (a separate degree of freedom, untouched by this) keep working
        // exactly as before. enableRotate is already false (see setup below),
        // so there's no fight over rotation between the two.
        gyroController?.update();

        // Sonification leg: drive Tone.js from the same per-frame camera motion
        // the navigation legs above produced. No-ops until the user enables sound.
        if (audioController && state.camera) {
          if (_audioPrevInit) {
            audioController.update({
              dx: state.camera.position.x - _audioPrevPos.x,
              dy: state.camera.position.y - _audioPrevPos.y,
              dz: state.camera.position.z - _audioPrevPos.z,
            });
          }
          _audioPrevPos.copy(state.camera.position);
          _audioPrevInit = true;
        }

        // Many pieces script their own ambient camera motion every frame
        // (e.g. a slow sway via camera.position.x = ... ; camera.lookAt(...)),
        // and since only the piece's own render call paints pixels when
        // pieceDrivesOwnRender is true, that scripted view always wins over
        // whatever the user just dragged/panned/keyed to — drag interaction
        // looks completely inert even though state.camera *is* updating
        // correctly underneath. Once the user has ever taken control of the
        // camera, latch on a forced bootstrap render (below) for the rest of
        // the session so user input reliably wins from then on.
        if (isOrbitActive || externalMotion) {
          userHasInteracted = true;
        }

        saveOrbitState();
      }

      if (state.renderer && state.scene && state.camera) {
        if ("aspect" in state.camera) {
          const width = stageEl.clientWidth || window.innerWidth;
          const height = stageEl.clientHeight || window.innerHeight;
          const aspect = width / Math.max(height, 1);
          if (Math.abs(state.camera.aspect - aspect) > 0.001) {
            state.camera.aspect = aspect;
            state.camera.updateProjectionMatrix?.();
          }
        }

        state.renderer.autoClear = true;
        state.renderer.localClippingEnabled = false;
        if (state.renderer.shadowMap) state.renderer.shadowMap.enabled = false;

        // Fallback light
        let hasRealLight = false, hasFallback = false;
        state.scene.traverse(obj => {
          if (!obj.isLight) return;
          if (obj.name?.startsWith("__viewer_fallback_")) hasFallback = true;
          else hasRealLight = true;
        });
        if (hasRealLight) {
          state.scene.children.filter(obj => obj.name?.startsWith("__viewer_fallback_")).forEach(obj => state.scene.remove(obj));
        } else if (!hasFallback) {
          const amb = new THREE.AmbientLight(0xffffff, 0.7);
          amb.name = "__viewer_fallback_ambient__";
          state.scene.add(amb);
          const dir = new THREE.DirectionalLight(0xffffff, 0.8);
          dir.position.set(5, 10, 7.5);
          dir.name = "__viewer_fallback_dir__";
          state.scene.add(dir);
        }

        // Visibility/material normalization is expensive on large scenes — run
        // every frame during initial settle-in, then throttle to every 30th frame.
        if (controlFrame <= 60 || controlFrame % 30 === 0) {
          state.scene.traverse(object => {
            object.frustumCulled = false;
            object.layers?.enableAll?.();
            if ((object.isMesh || object.isLine || object.isPoints || object.isSprite) && object.visible === false) {
              object.visible = true;
            }
            if (object.material) {
              const mats = Array.isArray(object.material) ? object.material : [object.material];
              mats.forEach(mat => {
                if (!mat) return;
                mat.clippingPlanes = null;
                mat.clipIntersection = false;
                mat.visible = true;
                if (mat.opacity !== undefined && mat.opacity < 0.05) {
                  mat.opacity = 1; mat.transparent = false;
                }
              });
            }
          });
        }

        // Background sync
        let bg = null;
        for (let el of [canvas, mount, stageEl.querySelector("div"), stageEl, host.querySelector("#container"), host]) {
          if (!el) continue;
          let val = el.style.backgroundColor || el.style.background;
          if (val && val.trim() !== "" && val.trim() !== "transparent" && val.trim() !== "rgba(0, 0, 0, 0)") { bg = val; break; }
          let cmp = window.getComputedStyle(el).backgroundColor;
          if (cmp && cmp.trim() !== "" && cmp.trim() !== "transparent" && cmp.trim() !== "rgba(0, 0, 0, 0)") { bg = cmp; break; }
        }
        bg = bg ?? "#000000";
        // The camera feed is a camera-attached quad now (never
        // scene.background), so this sync can no longer clobber it.
        syncThreeRendererBackground(state.renderer, state.scene, bg);
        // When the piece drives its own startFrame render loop (the documented
        // contract), it already calls renderer.render() every frame — rendering
        // here too would just duplicate that full-scene draw every tick. The
        // one exception is once the user has taken control of the camera
        // (userHasInteracted): our render call here runs after controls.update()
        // above, so it reflects the user's drag/key state, not the piece's own
        // scripted camera — that's the one that needs to reach the screen.
        if (!pieceDrivesOwnRender || userHasInteracted) {
          state.renderer.render(state.scene, state.camera);
        }
      }
      consecutiveErrors = 0;
    } catch (err) {
      consecutiveErrors += 1;
      if (consecutiveErrors === 1) onError(err);
      if (consecutiveErrors >= 5) cancelAnimationFrame(frameId);
    }
  }

  try {
    const sketchFactory = resolveSketchFactory(code);
    try {
      cleanup = sketchFactory({
        THREE: instrumentedThree,
        canvas,
        startFrame,
        size: runtimeSize,
        width: runtimeSize.width,
        height: runtimeSize.height,
      });
    } catch (artworkError) {
      // Shield platform interaction ownership from artwork-authored startup
      // failures. If the sketch established its core Three.js objects before
      // failing, manual controls and steering can still operate them.
      onError(artworkError);
    }

    if (!state.renderer || !state.camera) {
      throw new Error("This Three.js piece did not initialize a renderer and camera for immersive mode.");
    }

    reassertThreeCanvasContainment();
    requestAnimationFrame(() => resize());
    controls = new OrbitControls(state.camera, canvas);
    controls.enableDamping = true;
    controls.enablePan = true;
    controls.enableRotate = false;
    controls.screenSpacePanning = true;
    controls.mouseButtons.LEFT = THREE.MOUSE.PAN;
    controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY;
    controls.mouseButtons.RIGHT = THREE.MOUSE.PAN;
    controls.touches.ONE = THREE.TOUCH.PAN;
    controls.touches.TWO = THREE.TOUCH.DOLLY_PAN;
    controls.minDistance = 0.6;
    if ("zoomToCursor" in controls) {
      controls.zoomToCursor = true;
    }
    const _initDir = new THREE.Vector3();
    state.camera.getWorldDirection(_initDir);
    const initialCamDist = state.camera.position.length();
    const targetDist = Math.max(initialCamDist * 0.8, 3);
    controls.target.copy(state.camera.position).addScaledVector(_initDir, targetDist);
    const initialTargetDist = state.camera.position.distanceTo(controls.target);
    controls.maxDistance = Math.max(40, initialTargetDist * 4);
    controls.update();
    initialThreeViewState = shellViewState({ camera: state.camera, controls });
    if (applyShellViewState({ camera: state.camera, controls }, options.initialViewState)) {
      saveOrbitState();
      userHasInteracted = true;
    }

    threeNavLimit = getThreeNavigationLimit();
    keyNav = createKeyboardNavigation(controls, {
      container: stageEl,
      speed: (act) => Math.max(0.05, act.target.distanceTo(act.object.position) * 0.03),
      minX: -threeNavLimit,
      maxX: threeNavLimit,
      minZ: 0.5,
      maxZ: threeNavLimit,
    });

    _orbitCamPos.copy(state.camera.position);
    _orbitTarget.copy(controls.target);
    canvas.style.cursor = "grab";
    canvas.addEventListener("pointerdown", onThreePointerDown);
    canvas.addEventListener("pointermove", onThreePointerMove);
    canvas.addEventListener("pointerup", onThreePointerUp);
    canvas.addEventListener("pointercancel", clearThreePointer);
    canvas.addEventListener("lostpointercapture", clearThreePointer);
    canvas.addEventListener("wheel", onThreeWheel, { passive: false, capture: true });

    const preventNativeGesture = (event) => {
      if (event.cancelable) event.preventDefault();
    };
    canvas.addEventListener("gesturestart", preventNativeGesture);
    canvas.addEventListener("gesturechange", preventNativeGesture);
    canvas.addEventListener("gestureend", preventNativeGesture);
    canvas.addEventListener("touchmove", preventNativeGesture, { passive: false });

    if (options.showViewerControls) {
      viewerControls = createImmersiveViewerControls(stageEl, {
        initialZoomValue: threeZoomValueFromDistance(state.camera.position.distanceTo(controls.target)),
        onZoomSliderInput: (value) => applyThreeZoomValue(value),
        onMoveForward: () => applyThreeDirectionalMove(1, 0),
        onMoveBackward: () => applyThreeDirectionalMove(-1, 0),
        onMoveLeft: () => applyThreeDirectionalMove(0, -1),
        onMoveRight: () => applyThreeDirectionalMove(0, 1),
        onFloatUp: () => applyThreeFloatMove(1),
        onFloatDown: () => applyThreeFloatMove(-1),
      });
      setImmersiveViewerControlsEnabled(viewerControls, !handSteeringExclusive);
    }

    controls.addEventListener("start", () => {
      isOrbitActive = true;
      canvas.style.cursor = "grabbing";
    });
    controls.addEventListener("end", () => {
      isOrbitActive = false;
      canvas.style.cursor = "grab";
      saveOrbitState();
    });

    gyroController = createSharedGyroController(stageEl, state.camera, {
      onActivated() {
        // Tilting the phone to look around is taking control of the camera,
        // exactly as much as a drag or a key press is — without this, pieces
        // that drive their own render loop can silently overwrite the device
        // orientation before the user ever sees it.
        userHasInteracted = true;
      },
    });
    gyroController.setup();

    frameId = requestAnimationFrame(animateControls);

    const resizeObserver = new ResizeObserver(() => resize());
    resizeObserver.observe(stageEl);
    window.addEventListener("resize", resize);

    const pieceInteractionController = {
      supportsHandControl: true,
      supportsCameraBackground: true,
      async enableTiltFallback() {
        this.setHandSteering(false);
        return !!(await gyroController?.setup?.());
      },
      disableTiltFallback() { gyroController?.deactivate?.(); },
      setHandSteering(active) {
        const next = !!active;
        if (next === handSteeringExclusive) return;
        setImmersiveViewerControlsEnabled(viewerControls, !next);
        if (next) controlsEnabledBeforeHand = controls.enabled;
        handSteeringExclusive = next;
        cancelThreeNavigationAnimation();
        if (next) {
          controls.enabled = false;
          keyNav?.clearKeys?.();
          isOrbitActive = false;
        } else {
          bakeOrbitHandPose(state.camera, controls, gyroController);
          controls.enabled = controlsEnabledBeforeHand;
          saveOrbitState();
        }
      },
      handPoint(nx, ny) {
        if (resettingView || !handSteeringExclusive || !controls || !state.camera) return;
        gyroController?.setHandOffset?.(nx, ny);
        saveOrbitState();
        userHasInteracted = true;
      },
      handCommand(command) {
        if (resettingView || !handSteeringExclusive || !controls || !state.camera || !command) return;
        if (command.type === "look") {
          this.handPoint(command.x, command.y);
          return;
        }
        if (command.type === "start") gyroController?.clearHandOffset?.();
        if (command.type === "orbit") {
          const offset = state.camera.position.clone().sub(controls.target);
          const spherical = new THREE.Spherical().setFromVector3(offset);
          spherical.theta += command.yaw || 0;
          spherical.phi = Math.max(0.15, Math.min(Math.PI - 0.15, spherical.phi + (command.pitch || 0)));
          offset.setFromSpherical(spherical);
          state.camera.position.copy(controls.target).add(offset);
        } else if (command.type === "travel") {
          const forward = new THREE.Vector3();
          state.camera.getWorldDirection(forward);
          forward.y = 0;
          if (forward.lengthSq() > 1e-6) forward.normalize();
          const right = new THREE.Vector3(-forward.z, 0, forward.x);
          const delta = forward.multiplyScalar((command.forward || 0) * 0.11)
            .add(right.multiplyScalar((command.right || 0) * 0.09));
          state.camera.position.add(delta);
          controls.target.add(delta);
        } else if (command.type === "zoom") {
          const distance = state.camera.position.distanceTo(controls.target);
          const nextDistance = Math.max(0.35, Math.min(threeNavLimit, distance * (1 - (command.delta || 0))));
          const direction = state.camera.position.clone().sub(controls.target).normalize();
          state.camera.position.copy(controls.target).addScaledVector(direction, nextDistance);
        }
        saveOrbitState();
        userHasInteracted = true;
      },
      handLost() { gyroController?.clearHandOffset?.(); },
      async resetView() {
        gyroController?.clearHandOffset?.();
        resettingView = true;
        const reset = await animateShellViewReset({ camera: state.camera, controls }, initialThreeViewState);
        resettingView = false;
        saveOrbitState();
        userHasInteracted = true;
        return reset;
      },
      ...createCameraBlendQuadController(() => THREE, () => state.scene, () => state.camera),
    };

    const destroy = () => {
      pieceInteractionController.setHandSteering(false);
      pieceInteractionController.clearBackgroundVideo();
      resizeObserver.disconnect();
      window.removeEventListener("resize", resize);
      cancelAnimationFrame(frameId);
      controls.dispose();
      viewerControls?.remove();
      gyroController?.dispose();
      audioController?.dispose();
      canvas.removeEventListener("pointerdown", onThreePointerDown);
      canvas.removeEventListener("pointermove", onThreePointerMove);
      canvas.removeEventListener("pointerup", onThreePointerUp);
      canvas.removeEventListener("pointercancel", clearThreePointer);
      canvas.removeEventListener("lostpointercapture", clearThreePointer);
      canvas.removeEventListener("wheel", onThreeWheel, { capture: true });
      canvas.removeEventListener("gesturestart", preventNativeGesture);
      canvas.removeEventListener("gesturechange", preventNativeGesture);
      canvas.removeEventListener("gestureend", preventNativeGesture);
      canvas.removeEventListener("touchmove", preventNativeGesture);
      stopFrameHandles.forEach(stop => stop());
      if (typeof cleanup === "function") cleanup();
      host.remove();
      stageEl.innerHTML = "";
    };
    return {
      destroy,
      // PNG capture: the stage canvas as currently seen (user's perspective).
      getCaptureSurface: () => ({
        canvas,
        beforeCapture: () => {
          try {
            if (state.renderer && state.scene && state.camera) {
              state.renderer.render(state.scene, state.camera);
            }
          } catch (_) {}
        },
      }),
      getViewState: () => shellViewState({ camera: state.camera, controls }),
      getAudioController: () => audioController,
      getPieceInteractionController: () => pieceInteractionController,
    };
  } catch (err) {
    onError(err);
  }
}

function mountAFrameImmersivePiece(stageEl, code, htmlCode, cssCode, onError = console.error, options = {}) {
  stageEl.innerHTML = "";

  const host = document.createElement("div");
  host.style.cssText = "position:absolute;inset:0;width:100%;height:100%;overflow:hidden;background:#000;";

  const style = document.createElement("style");
  style.textContent = `
    a-scene {
      display: block;
      width: 100%;
      height: 100%;
    }
    .a-canvas {
      display: block;
      width: 100% !important;
      height: 100% !important;
    }
    .a-enter-vr {
      display: none !important;
    }
    ${cssCode || ""}
  `;
  host.appendChild(style);

  const mount = document.createElement("div");
  mount.style.cssText = "position:absolute;inset:0;width:100%;height:100%;overflow:hidden;";
  host.appendChild(mount);
  stageEl.appendChild(host);

  let disposed = false;
  let scene = null;
  let resizeObserver = null;
  let viewerControls = null;
  let pointerTarget = null;
  let frameId = 0;
  let handSteeringExclusive = false;
  let resettingView = false;
  let aframeControlsBeforeHand = [];
  let aframeLookControlsEnabledBeforeHand = null;
  let aframeTiltController = null;
  const aframeKeyboardKeys = new Set();
  let aframeKeyboardLastUpdate = null;
  const audioController = createAudioController(options.sonicParams, stageEl, { pieceId: options.pieceId, allowHandControl: options.handControl === true });
  let _aframeAudioPrev = null;
  const stopFrameHandles = [];
  const aframeNav = {
    animFrom: null,
    animTo: null,
    animStart: 0,
    pointer: null,
    hadMultiTouch: false,
    activeTouches: new Set(),
  };
  let aframeZoomSliderValue = 50;
  let initialAFrameViewState = null;

  const startFrame = (handler) => {
    let frameCount = 0;
    let rafId = 0;
    function tick() {
      if (disposed) return;
      frameCount += 1;
      try {
        handler(frameCount);
      } catch (err) {
        onError(err);
        return;
      }
      rafId = requestAnimationFrame(tick);
    }
    rafId = requestAnimationFrame(tick);
    const stop = () => cancelAnimationFrame(rafId);
    stopFrameHandles.push(stop);
    return stop;
  };

  function resizeScene() {
    if (!scene) return;
    scene.style.width = `${Math.max(stageEl.clientWidth, 1)}px`;
    scene.style.height = `${Math.max(stageEl.clientHeight, 1)}px`;
    if (scene.renderer && typeof scene.resize === "function") {
      try {
        scene.resize();
      } catch (err) {
        onError(err);
      }
    }
    window.dispatchEvent(new Event("resize"));
  }

  function getAFrameThree() {
    return window.AFRAME?.THREE || window.THREE;
  }

  function getAFrameCameraObject() {
    if (scene?.camera) return scene.camera;
    const cameraEl = scene?.querySelector("[camera]") || scene?.querySelector("a-camera");
    return cameraEl?.object3D || null;
  }

  function getAFrameCameraMover() {
    const cameraObject = getAFrameCameraObject();
    if (!cameraObject) return null;
    const cameraEl = cameraObject.el || scene?.querySelector("[camera]") || scene?.querySelector("a-camera");
    return cameraEl?.object3D || cameraObject;
  }

  function moveAFrameCameraByWorldDelta(delta) {
    const THREE_NS = getAFrameThree();
    const mover = getAFrameCameraMover();
    if (!THREE_NS || !mover) return false;
    const worldPosition = new THREE_NS.Vector3();
    mover.getWorldPosition(worldPosition);
    worldPosition.add(delta);
    if (mover.parent) {
      mover.parent.worldToLocal(worldPosition);
    }
    mover.position.copy(worldPosition);
    return true;
  }

  function applyAFrameZoom(distance) {
    if (handSteeringExclusive) return;
    const THREE_NS = getAFrameThree();
    const cameraObject = getAFrameCameraObject();
    if (!THREE_NS || !cameraObject) return;
    const forward = new THREE_NS.Vector3();
    cameraObject.getWorldDirection(forward);
    moveAFrameCameraByWorldDelta(forward.multiplyScalar(distance));
  }

  function applyAFrameZoomSliderValue(value) {
    if (!Number.isFinite(value)) return;
    const nextValue = Math.max(0, Math.min(100, value));
    const delta = nextValue - aframeZoomSliderValue;
    aframeZoomSliderValue = nextValue;
    applyAFrameZoom(delta * 0.045);
  }

  function applyAFrameDirectionalMove(forwardScale, rightScale) {
    if (handSteeringExclusive) return;
    const THREE_NS = getAFrameThree();
    const cameraObject = getAFrameCameraObject();
    if (!THREE_NS || !cameraObject) return;
    const forward = new THREE_NS.Vector3();
    cameraObject.getWorldDirection(forward);
    forward.y = 0;
    if (forward.lengthSq() < 1e-6) {
      forward.set(0, 0, -1);
    } else {
      forward.normalize();
    }
    const right = new THREE_NS.Vector3(-forward.z, 0, forward.x);
    const step = 0.18;
    const shift = forward.multiplyScalar(forwardScale * step).add(right.multiplyScalar(rightScale * step));
    moveAFrameCameraByWorldDelta(shift);
  }

  function applyAFrameFloatMove(verticalScale) {
    if (handSteeringExclusive) return;
    const THREE_NS = getAFrameThree();
    if (!THREE_NS || !Number.isFinite(verticalScale)) return;
    moveAFrameCameraByWorldDelta(new THREE_NS.Vector3(0, verticalScale * 0.14, 0));
  }

  function shouldIgnoreAFrameKeyboardTarget(target) {
    if (!(target instanceof Element)) return false;
    if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return true;
    return target.isContentEditable || Boolean(target.closest('[contenteditable="true"], [contenteditable=""], input, textarea, select'));
  }

  function onAFrameKeyDown(event) {
    if (!event.key.startsWith('Arrow') || shouldIgnoreAFrameKeyboardTarget(event.target)) return;
    event.preventDefault();
    aframeKeyboardKeys.add(event.key);
  }

  function onAFrameKeyUp(event) {
    if (!event.key.startsWith('Arrow')) return;
    aframeKeyboardKeys.delete(event.key);
  }

  function clearAFrameKeyboard() {
    aframeKeyboardKeys.clear();
    aframeKeyboardLastUpdate = null;
  }

  function updateAFrameKeyboardNavigation() {
    if (aframeKeyboardKeys.size === 0) {
      aframeKeyboardLastUpdate = performance.now();
      return;
    }
    const now = performance.now();
    const frameScale = aframeKeyboardLastUpdate === null
      ? 1
      : Math.min(4, Math.max(0, (now - aframeKeyboardLastUpdate) / (1000 / 60)));
    aframeKeyboardLastUpdate = now;
    const forwardScale = (aframeKeyboardKeys.has('ArrowUp') ? 1 : 0) + (aframeKeyboardKeys.has('ArrowDown') ? -1 : 0);
    const rightScale = (aframeKeyboardKeys.has('ArrowRight') ? 1 : 0) + (aframeKeyboardKeys.has('ArrowLeft') ? -1 : 0);
    applyAFrameDirectionalMove(forwardScale * frameScale, rightScale * frameScale);
  }

  function applyAFrameInitialViewState() {
    const camera = readViewVector(options.initialViewState?.camera);
    const target = readViewVector(options.initialViewState?.target);
    const mover = getAFrameCameraMover();
    if (!camera || !mover) return false;
    if (mover.parent) {
      const localCamera = camera.clone();
      mover.parent.worldToLocal(localCamera);
      mover.position.copy(localCamera);
    } else {
      mover.position.copy(camera);
    }
    if (target && typeof mover.lookAt === "function") {
      mover.lookAt(target);
    }
    return true;
  }

  function getAFrameViewState() {
    const THREE_NS = getAFrameThree();
    const mover = getAFrameCameraMover();
    if (!THREE_NS || !mover) return {};
    const camera = new THREE_NS.Vector3();
    mover.getWorldPosition(camera);
    const forward = new THREE_NS.Vector3();
    const cameraObject = getAFrameCameraObject();
    cameraObject?.getWorldDirection?.(forward);
    const target = camera.clone().add(forward.lengthSq() > 0 ? forward.multiplyScalar(4) : new THREE_NS.Vector3(0, 0, -4));
    return shellViewState({ camera: { position: camera }, controls: { target } });
  }

  function animateAFrameReset(viewState, duration = 360) {
    const THREE_NS = getAFrameThree();
    const mover = getAFrameCameraMover();
    const toCamera = readViewVector(viewState?.camera);
    const toTarget = readViewVector(viewState?.target);
    if (!THREE_NS || !mover || !toCamera || !toTarget) return Promise.resolve(false);
    const fromCamera = new THREE_NS.Vector3();
    mover.getWorldPosition(fromCamera);
    const currentState = getAFrameViewState();
    const fromTarget = readViewVector(currentState.target) || toTarget.clone();
    const startedAt = performance.now();
    return new Promise((resolve) => {
      function step(now) {
        const t = Math.min(1, (now - startedAt) / duration);
        const eased = 1 - Math.pow(1 - t, 3);
        const worldCamera = fromCamera.clone().lerp(toCamera, eased);
        const worldTarget = fromTarget.clone().lerp(toTarget, eased);
        if (mover.parent) mover.parent.worldToLocal(worldCamera);
        mover.position.copy(worldCamera);
        mover.lookAt(worldTarget);
        if (t < 1) requestAnimationFrame(step); else resolve(true);
      }
      requestAnimationFrame(step);
    });
  }

  function activeAFrameTouchCount() {
    return aframeNav.activeTouches.size;
  }

  function onAFramePointerDown(event) {
    if (handSteeringExclusive) return;
    if ((event.pointerType || "mouse") === "touch") {
      aframeNav.activeTouches.add(event.pointerId);
      if (activeAFrameTouchCount() > 1) aframeNav.hadMultiTouch = true;
    }
    aframeNav.pointer = {
      id: event.pointerId,
      pointerType: event.pointerType || "mouse",
      button: event.button,
      startX: event.clientX,
      startY: event.clientY,
      moved: false,
    };
  }

  function onAFramePointerMove(event) {
    if (!aframeNav.pointer || aframeNav.pointer.id !== event.pointerId) return;
    if (Math.hypot(event.clientX - aframeNav.pointer.startX, event.clientY - aframeNav.pointer.startY) >= 6) {
      aframeNav.pointer.moved = true;
    }
    if ((event.pointerType || "mouse") === "touch" && activeAFrameTouchCount() > 1) {
      aframeNav.hadMultiTouch = true;
    }
  }

  function clearAFramePointer(event) {
    if ((event.pointerType || "mouse") === "touch") {
      aframeNav.activeTouches.delete(event.pointerId);
      if (activeAFrameTouchCount() === 0) aframeNav.hadMultiTouch = false;
    }
    if (aframeNav.pointer?.id === event.pointerId) {
      aframeNav.pointer = null;
    }
  }

  function moveAFrameViewTo(hitPoint) {
    const THREE_NS = getAFrameThree();
    const mover = getAFrameCameraMover();
    if (!THREE_NS || !mover || !hitPoint) return;
    const cameraWorld = new THREE_NS.Vector3();
    mover.getWorldPosition(cameraWorld);
    const shift = new THREE_NS.Vector3(
      Math.max(-12, Math.min(12, hitPoint.x - cameraWorld.x)),
      0,
      Math.max(-12, Math.min(12, hitPoint.z - cameraWorld.z)),
    );
    if (shift.lengthSq() < 0.003) return;
    aframeNav.animFrom = cameraWorld.clone();
    aframeNav.animTo = cameraWorld.clone().add(shift);
    aframeNav.animStart = performance.now();
  }

  function onAFramePointerUp(event) {
    if (handSteeringExclusive) return;
    const THREE_NS = getAFrameThree();
    const pointer = aframeNav.pointer;
    const wasMultiTouch = aframeNav.hadMultiTouch || activeAFrameTouchCount() > 1;
    clearAFramePointer(event);
    if (!THREE_NS || !scene || !pointer || wasMultiTouch || pointer.button !== 0 || event.button !== 0 || pointer.moved) return;
    const cameraObject = getAFrameCameraObject();
    if (!cameraObject) return;
    const rect = (pointerTarget || host).getBoundingClientRect();
    const raycaster = new THREE_NS.Raycaster();
    raycaster.setFromCamera(
      new THREE_NS.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1),
      cameraObject,
    );

    let hitPoint = null;
    const hits = raycaster.intersectObjects(scene.object3D?.children || [], true)
      .filter(hit => {
        if (hit.object === cameraObject || cameraObject.children?.includes(hit.object)) return false;
        const tagName = hit.object.el?.tagName?.toUpperCase?.() || "";
        const name = (hit.object.name || hit.object.el?.id || "").toLowerCase();
        if (tagName === "A-SKY" || name.includes("sky") || name.includes("background") || name.includes("env")) return false;
        return true;
      });
    if (hits.length > 0) {
      hitPoint = hits[0].point;
    } else {
      const floorPlane = new THREE_NS.Plane(new THREE_NS.Vector3(0, 1, 0), 0);
      const planeHit = new THREE_NS.Vector3();
      if (raycaster.ray.intersectPlane(floorPlane, planeHit)) hitPoint = planeHit;
    }
    if (hitPoint) moveAFrameViewTo(hitPoint);
  }

  function animateAFrameViewerControls() {
    if (disposed) return;
    frameId = requestAnimationFrame(animateAFrameViewerControls);
    updateAFrameKeyboardNavigation();
    const THREE_NS = getAFrameThree();
    const mover = getAFrameCameraMover();
    // Sonification: sample camera world motion every frame (this runs even when
    // no click-to-walk animation is active). No-op until sound is enabled.
    if (audioController && THREE_NS && mover) {
      const wp = mover.getWorldPosition(new THREE_NS.Vector3());
      if (_aframeAudioPrev) {
        audioController.update({ dx: wp.x - _aframeAudioPrev.x, dy: wp.y - _aframeAudioPrev.y, dz: wp.z - _aframeAudioPrev.z });
      }
      _aframeAudioPrev = { x: wp.x, y: wp.y, z: wp.z };
    }
    if (handSteeringExclusive || !THREE_NS || !mover || !aframeNav.animFrom || !aframeNav.animTo) return;
    const t = Math.min((performance.now() - aframeNav.animStart) / 350, 1);
    const eased = 1 - (1 - t) ** 3;
    const nextWorld = new THREE_NS.Vector3().lerpVectors(aframeNav.animFrom, aframeNav.animTo, eased);
    if (mover.parent) {
      mover.parent.worldToLocal(nextWorld);
    }
    mover.position.copy(nextWorld);
    if (t >= 1) {
      aframeNav.animFrom = aframeNav.animTo = null;
    }
  }

  function bindAFramePointerControls() {
    if (pointerTarget) return;
    pointerTarget = scene?.canvas || scene?.querySelector("canvas") || host;
    pointerTarget.style.touchAction = "none";
    pointerTarget.addEventListener("pointerdown", onAFramePointerDown);
    pointerTarget.addEventListener("pointermove", onAFramePointerMove);
    pointerTarget.addEventListener("pointerup", onAFramePointerUp);
    pointerTarget.addEventListener("pointercancel", clearAFramePointer);
    pointerTarget.addEventListener("lostpointercapture", clearAFramePointer);
  }

  window.addEventListener('keydown', onAFrameKeyDown);
  window.addEventListener('keyup', onAFrameKeyUp);
  window.addEventListener('blur', clearAFrameKeyboard);

  loadAFrameRuntime().then(() => loadAFrameModelRuntime()).then(() => {
    if (disposed) return;
    mount.innerHTML = htmlCode?.trim() ? htmlCode : '<a-scene id="scene" embedded><a-sky color="#10151f"></a-sky><a-entity camera position="0 1.6 4"></a-entity></a-scene>';
    scene = mount.querySelector("a-scene#scene") || mount.querySelector("a-scene");
    if (!scene) {
      throw new Error('This A-Frame piece did not provide an <a-scene id="scene"> root.');
    }
    if (!scene.id) scene.id = "scene";
    if (!scene.hasAttribute("embedded")) scene.setAttribute("embedded", "");
    scene.setAttribute("vr-mode-ui", "enabled: false");
    // preserveDrawingBuffer keeps frames readable for PNG capture — same
    // renderer-attr merge as the regular-view aframe bootstrap in
    // piece-render.php (piece_export_bootstrap 'aframe').
    {
      const currentRenderer = scene.getAttribute("renderer");
      const rendererValue = typeof currentRenderer === "string" && currentRenderer.trim() !== ""
        ? currentRenderer.replace(/\s*;?\s*$/, "; ") + "preserveDrawingBuffer: true"
        : "preserveDrawingBuffer: true";
      scene.setAttribute("renderer", rendererValue);
    }
    scene.style.display = "block";
    scene.style.width = "100%";
    scene.style.height = "100%";

    if ((code || "").trim()) {
      const sketchFactory = resolveSketchFactory(code);
      try {
        const cleanup = sketchFactory({
          AFRAME: window.AFRAME,
          scene,
          startFrame,
          size: { width: Math.max(stageEl.clientWidth, 1), height: Math.max(stageEl.clientHeight, 1) },
        });
        if (typeof cleanup === "function") {
          stopFrameHandles.push(cleanup);
        }
      } catch (artworkError) {
        // The scene remains a platform-controlled interaction surface even
        // when an authored optional effect fails during startup.
        onError(artworkError);
      }
    }
    window.CreatrAFrameModelRuntime.install(scene, {
      fail: onError,
      report: (detail) => { if (detail.status === 'error') onError(new Error(detail.message)); },
    });

    resizeObserver = new ResizeObserver(resizeScene);
    resizeObserver.observe(stageEl);
    requestAnimationFrame(resizeScene);
    // Capturing the home pose one animation frame after mount is too early.
    // A-Frame may not have created its camera yet, let alone applied an
    // authored <a-camera position="…"> to the object3D, so the capture records
    // nothing — and Reset View then silently does nothing, forever, because
    // animateAFrameReset() bails when the stored state has no camera/target.
    //
    // piece-runtime.js already hit this and documents it (see
    // captureInitialAFramePose there): capture on renderstart AND loaded, and
    // handle the case where renderstart already fired before the listener was
    // attached — a large document can take long enough to parse that the
    // once-only event is missed permanently. Mirror that here rather than
    // inventing a second approach.
    // Both steps move together, and the order between them is load-bearing: the
    // canonical home pose must be recorded BEFORE any downloaded launch state
    // is applied, or Reset View returns to the shared view instead of the
    // piece's own starting pose. Deferring only the capture would have broken
    // that, so the apply waits for the same moment.
    let initialAFrameViewCaptured = false;
    let launchViewStateApplied = false;

    function captureAFrameInitialViewState() {
      if (initialAFrameViewCaptured) return true;
      const state = getAFrameViewState();
      // An empty state is what made this silent: storing it leaves Reset View
      // with nothing to animate towards, and it never tries again.
      if (!state?.camera || !state?.target) return false;
      initialAFrameViewState = getAFrameViewState();
      initialAFrameViewCaptured = true;
      return true;
    }

    function initializeAFrameViewState() {
      if (!captureAFrameInitialViewState()) return false;
      if (launchViewStateApplied) return true;
      launchViewStateApplied = true;
      applyAFrameInitialViewState();
      return true;
    }

    requestAnimationFrame(() => {
      bindAFramePointerControls();
      initializeAFrameViewState();
    });
    scene.addEventListener("renderstart", bindAFramePointerControls, { once: true });
    scene.addEventListener("renderstart", initializeAFrameViewState, { once: true });
    scene.addEventListener("loaded", initializeAFrameViewState, { once: true });
    if (scene.renderStarted) {
      bindAFramePointerControls();
      initializeAFrameViewState();
    }
    frameId = requestAnimationFrame(animateAFrameViewerControls);
    if (options.showViewerControls) {
      viewerControls = createImmersiveViewerControls(stageEl, {
        initialZoomValue: aframeZoomSliderValue,
        onZoomSliderInput: (value) => applyAFrameZoomSliderValue(value),
        onMoveForward: () => applyAFrameDirectionalMove(1, 0),
        onMoveBackward: () => applyAFrameDirectionalMove(-1, 0),
        onMoveLeft: () => applyAFrameDirectionalMove(0, -1),
        onMoveRight: () => applyAFrameDirectionalMove(0, 1),
        onFloatUp: () => applyAFrameFloatMove(1),
        onFloatDown: () => applyAFrameFloatMove(-1),
      });
      setImmersiveViewerControlsEnabled(viewerControls, !handSteeringExclusive);
    }
  }).catch(onError);

  const pieceInteractionController = {
    supportsHandControl: true,
    supportsCameraBackground: true,
    async enableTiltFallback() {
      if (!window.CreatrSonicController?.createDeviceTiltController) return false;
      this.setHandSteering(true);
      if (!aframeTiltController) {
        aframeTiltController = window.CreatrSonicController.createDeviceTiltController((nx, ny) => this.handPoint(nx, ny, true));
      }
      const enabled = await aframeTiltController.enable();
      if (!enabled) this.setHandSteering(false);
      return enabled;
    },
    disableTiltFallback() { aframeTiltController?.disable(); this.setHandSteering(false); },
    setHandSteering(active) {
      const next = !!active;
      if (next === handSteeringExclusive) return;
      setImmersiveViewerControlsEnabled(viewerControls, !next);
      aframeNav.animFrom = aframeNav.animTo = null;
      aframeNav.pointer = null;
      aframeNav.activeTouches.clear();
      const cameraEl = scene?.querySelector("[camera]") || scene?.querySelector("a-camera");
      const lookControls = cameraEl?.components?.["look-controls"];
      if (next) {
        aframeLookControlsEnabledBeforeHand = lookControls ? lookControls.data.enabled !== false : null;
        if (lookControls) cameraEl.setAttribute("look-controls", "enabled", false);
        const wasd = cameraEl?.components?.["wasd-controls"];
        aframeControlsBeforeHand = wasd ? [{ component: wasd, wasPlaying: wasd.isPlaying !== false }] : [];
        aframeControlsBeforeHand.forEach(({ component }) => component?.pause?.());
      } else {
        const mover = getAFrameCameraMover();
        if (lookControls && mover) {
          if (lookControls.pitchObject) lookControls.pitchObject.rotation.x = mover.rotation.x;
          if (lookControls.yawObject) lookControls.yawObject.rotation.y = mover.rotation.y;
        }
        if (lookControls && aframeLookControlsEnabledBeforeHand !== null) {
          cameraEl.setAttribute("look-controls", "enabled", aframeLookControlsEnabledBeforeHand);
        }
        aframeLookControlsEnabledBeforeHand = null;
        aframeControlsBeforeHand.forEach(({ component, wasPlaying }) => {
          if (wasPlaying) component?.play?.();
        });
        aframeControlsBeforeHand = [];
      }
      handSteeringExclusive = next;
      window.CreatrSonicController?.traceSteering?.('immersive-aframe-ownership', {
        enabled: next,
        moverUuid: getAFrameCameraMover()?.uuid || null,
        renderCameraUuid: getAFrameCameraObject()?.uuid || null,
        renderCameraParentUuid: getAFrameCameraObject()?.parent?.uuid || null,
        lookEnabled: lookControls?.data?.enabled,
      });
    },
    handPoint(nx, ny, fromTilt) {
      if (resettingView || (!handSteeringExclusive && !fromTilt)) return;
      const cameraObject = getAFrameCameraMover();
      if (!cameraObject) return;
      const desiredYaw = -(nx - 0.5) * Math.PI * 1.5;
      const desiredPitch = -(ny - 0.5) * Math.PI * 0.7;
      cameraObject.rotation.order = "YXZ";
      cameraObject.rotation.y += (desiredYaw - cameraObject.rotation.y) * 0.12;
      cameraObject.rotation.x += (desiredPitch - cameraObject.rotation.x) * 0.12;
      cameraObject.rotation.x = Math.max(-Math.PI * 0.45, Math.min(Math.PI * 0.45, cameraObject.rotation.x));
    },
    handCommand(command) {
      if (resettingView || !command) return;
      if (command.type === "look") {
        this.handPoint(command.x, command.y);
        return;
      }
      const cameraObject = getAFrameCameraMover();
      if (!cameraObject) return;
      const beforeRotation = [cameraObject.rotation.x, cameraObject.rotation.y, cameraObject.rotation.z];
      if (command.type === "orbit") {
        cameraObject.rotation.order = "YXZ";
        cameraObject.rotation.y += command.yaw || 0;
        cameraObject.rotation.x = Math.max(-Math.PI * 0.45, Math.min(Math.PI * 0.45, cameraObject.rotation.x + (command.pitch || 0)));
      } else if (command.type === "travel") {
        cameraObject.translateX((command.right || 0) * 0.08);
        cameraObject.translateZ(-(command.forward || 0) * 0.11);
      } else if (command.type === "zoom") {
        cameraObject.translateZ((command.delta || 0) * 1.4);
      }
      window.CreatrSonicController?.traceSteering?.('immersive-aframe-pose-applied', {
        command,
        moverUuid: cameraObject.uuid || null,
        renderCameraUuid: getAFrameCameraObject()?.uuid || null,
        renderCameraParentUuid: getAFrameCameraObject()?.parent?.uuid || null,
        beforeRotation,
        afterRotation: [cameraObject.rotation.x, cameraObject.rotation.y, cameraObject.rotation.z],
      });
    },
    async resetView() {
      resettingView = true;
      try { return await animateAFrameReset(initialAFrameViewState); }
      finally { resettingView = false; }
    },
    ...createCameraBlendQuadController(() => getAFrameThree(), () => scene?.object3D, () => scene?.camera),
  };

  const destroy = () => {
    aframeTiltController?.disable();
    pieceInteractionController.setHandSteering(false);
    pieceInteractionController.clearBackgroundVideo();
    disposed = true;
    cancelAnimationFrame(frameId);
    window.removeEventListener('keydown', onAFrameKeyDown);
    window.removeEventListener('keyup', onAFrameKeyUp);
    window.removeEventListener('blur', clearAFrameKeyboard);
    clearAFrameKeyboard();
    resizeObserver?.disconnect();
    viewerControls?.remove();
    if (pointerTarget) {
      pointerTarget.removeEventListener("pointerdown", onAFramePointerDown);
      pointerTarget.removeEventListener("pointermove", onAFramePointerMove);
      pointerTarget.removeEventListener("pointerup", onAFramePointerUp);
      pointerTarget.removeEventListener("pointercancel", clearAFramePointer);
      pointerTarget.removeEventListener("lostpointercapture", clearAFramePointer);
    }
    stopFrameHandles.forEach((stop) => stop());
    audioController?.dispose();
    try {
      scene?.pause?.();
    } catch (e) {}
    host.remove();
    stageEl.innerHTML = "";
  };
  return {
    destroy,
    // PNG capture: the WebXR scene canvas as currently seen. beforeCapture
    // forces a render so the frame is fresh even if the loop is idle —
    // same fallback as tryForceAframeRender in public-piece-download.js.
    getCaptureSurface: () => ({
      canvas: scene?.canvas || scene?.querySelector?.("canvas.a-canvas") || null,
      beforeCapture: () => {
        try {
          const renderer = scene?.renderer;
          const sceneObject = scene?.object3D;
          const camera = scene?.camera;
          if (renderer && sceneObject && camera) {
            renderer.render(sceneObject, camera);
          }
        } catch (_) {}
      },
      }),
    getViewState: getAFrameViewState,
    getAudioController: () => audioController,
    getPieceInteractionController: () => pieceInteractionController,
  };
}

// Phase 2 Entry Point: mountGalleryPiece
function mountGalleryPiece(stageEl, code, htmlCode, cssCode, engine, title, sourceUrl, prompt, description, onError = console.error, onInteractiveClick = null, options = {}) {
  const runtimeSize = { width: 1280, height: 720 };
  const presentationSurface = engine === "p5" ? createPresentationSurface(1200, 900, 72) : null;
  const shell = createMountedGalleryShell(
    stageEl,
    presentationSurface ? presentationSurface.width / presentationSurface.height : runtimeSize.width / runtimeSize.height,
    presentationSurface ? NORMALIZED_PRESENTATION_GALLERY_PROFILE : undefined
  );

  const host = createImmersiveHost(
    htmlCode, cssCode,
    engine === "p5" ? '<div id="canvas-container"></div>' : engine === "svg" ? '<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"></svg>' : '<canvas id="piece-canvas"></canvas>',
    runtimeSize,
    engine
  );

  let sourceCanvas = null;
  let artTexture = null;
  let frameId = 0;
  let detectCanvasTimer = null;
  let detectCanvasAttempts = 0;
  let stopSourceLoop = null;
  let p5Instance = null;
  let disposed = false;
  let viewerControls = null;
  let readOnlyOverlay = null;
  let gyroController = null;
  let initialGalleryViewState = null;
  // Set by bootRuntime for interactive c2: the off-screen sketch canvas the
  // hand-control controller drives with synthetic pointer events.
  let interactiveSourceCanvas = null;
  const audioController = createAudioController(options.sonicParams, stageEl, { pieceId: options.pieceId, allowHandControl: options.handControl === true });
  const _galleryAudioPrevPos = new THREE.Vector3();
  let _galleryAudioPrevInit = false;
  const galleryButtonForward = new THREE.Vector3();
  const galleryButtonRight = new THREE.Vector3();

  function syncCanvas(nextCanvas) {
    sourceCanvas = nextCanvas;
    const displayCanvas = presentationSurface?.canvas ?? nextCanvas;
    if (!artTexture) {
      artTexture = new THREE.CanvasTexture(displayCanvas);
      artTexture.colorSpace = THREE.SRGBColorSpace;
      shell.artMaterial.map = artTexture;
      shell.artMaterial.needsUpdate = true;
    }

    if (presentationSurface) {
      // Resolve background
      let bg = null;
      for (let el of [nextCanvas, nextCanvas.parentElement, host.querySelector("#canvas-container"), host]) {
        if (!el) continue;
        let val = el.style.backgroundColor || el.style.background;
        if (val && val.trim() !== "" && val.trim() !== "transparent" && val.trim() !== "rgba(0, 0, 0, 0)") { bg = val; break; }
        let cmp = window.getComputedStyle(el).backgroundColor;
        if (cmp && cmp.trim() !== "" && cmp.trim() !== "transparent" && cmp.trim() !== "rgba(0, 0, 0, 0)") { bg = cmp; break; }
      }
      bg = bg ?? "#05070f";
      drawContainedIntoPresentationSurface(
        presentationSurface, nextCanvas.width || runtimeSize.width, nextCanvas.height || runtimeSize.height,
        (ctx, x, y, width, height) => { ctx.drawImage(nextCanvas, x, y, width, height); },
        bg
      );
    }
    const width = displayCanvas.width || runtimeSize.width;
    const height = displayCanvas.height || runtimeSize.height;
    const aspect = Math.max(width / Math.max(height, 1), 0.45);
    updateMountedGalleryLayout(shell, aspect);
    fitMountedGalleryCamera(shell, stageEl);
  }

  function pollForCanvas(root, onMissing, preExisting) {
    let candidate = p5Instance?.canvas instanceof HTMLCanvasElement ? p5Instance.canvas : null;
    if (!candidate) candidate = root.querySelector("canvas") || host.querySelector("canvas");
    if (!candidate && preExisting) {
      candidate = Array.from(document.querySelectorAll("canvas")).find((c) => !preExisting.has(c)) || null;
      if (candidate && candidate.parentElement !== root) root.appendChild(candidate);
    }
    if (candidate instanceof HTMLCanvasElement) {
      if (candidate.width === 0 || candidate.height === 0) {
        candidate.width = runtimeSize.width;
        candidate.height = runtimeSize.height;
      }
      syncCanvas(candidate);
      return;
    }
    if (detectCanvasAttempts >= 80) { onError(onMissing); return; }
    detectCanvasAttempts += 1;
    detectCanvasTimer = window.setTimeout(() => pollForCanvas(root, onMissing, preExisting), 100);
  }

  function galleryZoomValueFromDistance(distance) {
    const minDistance = shell.controls.minDistance || 0.6;
    const maxDistance = shell.controls.maxDistance || Math.max(40, distance * 4);
    if (!Number.isFinite(distance) || maxDistance <= minDistance) return 50;
    return ((maxDistance - Math.max(minDistance, Math.min(maxDistance, distance))) / (maxDistance - minDistance)) * 100;
  }

  function syncViewerControlZoom() {
    viewerControls?.setZoomValue(galleryZoomValueFromDistance(shell.camera.position.distanceTo(shell.controls.target)));
  }

  let galleryHandSteering = false;
  let galleryResettingView = false;

  function applyGalleryZoomValue(value) {
    if (galleryHandSteering) return;
    if (!Number.isFinite(value)) return;
    const minDistance = shell.controls.minDistance || 0.6;
    const currentDistance = shell.camera.position.distanceTo(shell.controls.target);
    const maxDistance = shell.controls.maxDistance || Math.max(40, currentDistance * 4);
    const t = Math.max(0, Math.min(100, value)) / 100;
    const nextDistance = maxDistance - ((maxDistance - minDistance) * t);
    const direction = shell.camera.position.clone().sub(shell.controls.target);
    if (direction.lengthSq() < 1e-8) return;
    direction.setLength(nextDistance);
    shell.camera.position.copy(shell.controls.target).add(direction);
    shell.controls.update();
    syncViewerControlZoom();
  }

  function applyGalleryDirectionalMove(forwardScale, rightScale, fromSteering = false) {
    if (galleryHandSteering && !fromSteering) return;
    shell.camera.getWorldDirection(galleryButtonForward);
    galleryButtonForward.y = 0;
    if (galleryButtonForward.lengthSq() < 1e-6) {
      galleryButtonForward.set(0, 0, -1);
    } else {
      galleryButtonForward.normalize();
    }
    galleryButtonRight.set(-galleryButtonForward.z, 0, galleryButtonForward.x);
    const step = Math.max(0.08, shell.controls.target.distanceTo(shell.camera.position) * 0.035);
    const shift = galleryButtonForward.clone()
      .multiplyScalar(forwardScale * step)
      .add(galleryButtonRight.clone().multiplyScalar(rightScale * step));
    if (shift.lengthSq() < 1e-8) return;
    const nextCamX = Math.max(-8, Math.min(8, shell.camera.position.x + shift.x));
    const nextCamZ = Math.max(0.5, shell.camera.position.z + shift.z);
    const dx = nextCamX - shell.camera.position.x;
    const dz = nextCamZ - shell.camera.position.z;
    if (Math.abs(dx) < 1e-6 && Math.abs(dz) < 1e-6) return;
    shell.camera.position.x += dx;
    shell.camera.position.z += dz;
    shell.controls.target.x += dx;
    shell.controls.target.z += dz;
    shell.controls.update();
  }

  function applyGalleryFloatMove(verticalScale) {
    if (galleryHandSteering) return;
    if (!Number.isFinite(verticalScale)) return;
    const step = Math.max(0.08, shell.controls.target.distanceTo(shell.camera.position) * 0.03);
    const nextCamY = Math.max(0.1, Math.min(24, shell.camera.position.y + (verticalScale * step)));
    const dy = nextCamY - shell.camera.position.y;
    if (Math.abs(dy) < 1e-6) return;
    shell.camera.position.y += dy;
    shell.controls.target.y += dy;
    shell.controls.update();
  }

  async function bootRuntime() {
    try {
      if (engine === "p5") {
        const P5 = await loadP5Runtime();
        const sketchFactory = resolveSketchFactory(code);
        const mountNode = host.querySelector("#canvas-container") || host.querySelector("#sketch-container") || host;
        const preExistingCanvases = new Set(document.querySelectorAll("canvas"));
        p5Instance = new P5(sketchFactory, mountNode);
        pollForCanvas(mountNode, "This p5 piece did not produce a canvas for immersive mode.", preExistingCanvases);
        return;
      }

      if (engine === "svg") {
        const shadowHost = document.createElement("div");
        shadowHost.style.cssText = `position:fixed;left:-10000px;top:0;width:${runtimeSize.width}px;height:${runtimeSize.height}px;pointer-events:none;`;
        const shadowRoot = shadowHost.attachShadow({ mode: "open" });
        if (cssCode) {
          const styleEl = document.createElement("style");
          styleEl.textContent = cssCode;
          shadowRoot.appendChild(styleEl);
        }
        const svgContainer = document.createElement("div");
        svgContainer.style.cssText = "width:100%;height:100%;";
        svgContainer.innerHTML = htmlCode?.trim() ? htmlCode : '<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"></svg>';
        shadowRoot.appendChild(svgContainer);
        document.body.appendChild(shadowHost);

        const svgEl = shadowRoot.querySelector("svg");
        if (!svgEl) { onError("This SVG piece has no <svg> element."); shadowHost.remove(); return; }

        const vb = svgEl.viewBox?.baseVal;
        const svgNatW = (vb && vb.width > 0) ? vb.width : runtimeSize.width;
        const svgNatH = (vb && vb.height > 0) ? vb.height : runtimeSize.height;
        const svgAspect = svgNatW / Math.max(svgNatH, 1);
        let canvasW = runtimeSize.width;
        let canvasH = Math.round(canvasW / svgAspect);
        if (canvasH < runtimeSize.height) { canvasH = runtimeSize.height; canvasW = Math.round(canvasH * svgAspect); }

        const svgCanvas = document.createElement("canvas");
        svgCanvas.width = canvasW;
        svgCanvas.height = canvasH;
        syncCanvas(svgCanvas);

        window.svgRoot = svgEl;
        const _origGetById = document.getElementById.bind(document);
        document.getElementById = function(id) {
          const found = _origGetById(id);
          if (!found && (id === "container" || id === "canvas-container" || id === "sketch-container")) return window.svgRoot ?? null;
          return found;
        };
        const _origQuerySelector = document.querySelector.bind(document);
        document.querySelector = function(sel) {
          const found = _origQuerySelector(sel);
          if (!found && sel === "svg") return window.svgRoot ?? null;
          return found;
        };

        const sketchFactory = resolveSketchFactory(code);
        if (typeof sketchFactory === "function") {
          try { sketchFactory(); } catch (_) {}
        }

        let drawPending = false;
        async function drawSvgSnapshot() {
          if (drawPending || disposed) return;
          drawPending = true;
          try {
            const svgClone = svgEl.cloneNode(true);
            const liveEls = Array.from(svgEl.querySelectorAll("*"));
            const cloneEls = Array.from(svgClone.querySelectorAll("*"));
            const propertiesToSync = ["transform", "transform-origin", "opacity", "fill", "stroke", "stroke-width", "stroke-dasharray", "stroke-dashoffset", "fill-opacity", "stroke-opacity", "cx", "cy", "r", "rx", "ry", "x", "y", "width", "height", "d", "stop-color", "stop-opacity", "offset", "filter", "clip-path", "mask", "display", "visibility"];
            liveEls.forEach((liveEl, i) => {
              const cloneEl = cloneEls[i];
              if (!cloneEl) return;
              const s = window.getComputedStyle(liveEl);
              propertiesToSync.forEach((prop) => {
                const val = s.getPropertyValue(prop);
                if (val !== undefined && val !== null && val !== "") {
                  if (prop === "transform" && (val === "none" || val === "matrix(1, 0, 0, 1, 0, 0)")) return;
                  if (prop === "opacity" && val === "1") return;
                  if (prop === "fill" && (val === "none" || val === "rgb(0, 0, 0)")) return;
                  if (prop === "stroke" && val === "none") return;
                  cloneEl.style.setProperty(prop, val);
                }
              });
            });
            const styleEl = document.createElementNS("http://www.w3.org/2000/svg", "style");
            styleEl.textContent = (cssCode || "") + "\n* { animation: none !important; transition: none !important; }";
            svgClone.insertBefore(styleEl, svgClone.firstChild);
            const serialized = new XMLSerializer().serializeToString(svgClone);
            const dataUrl = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(serialized);
            await new Promise((resolve) => {
              const img = new Image();
              img.onload = () => {
                const ctx = svgCanvas.getContext("2d");
                if (ctx) { ctx.clearRect(0, 0, svgCanvas.width, svgCanvas.height); ctx.drawImage(img, 0, 0, svgCanvas.width, svgCanvas.height); }
                if (artTexture) artTexture.needsUpdate = true;
                resolve();
              };
              img.onerror = () => resolve();
              img.src = dataUrl;
            });
          } finally {
            drawPending = false;
          }
        }

        await drawSvgSnapshot();
        const intervalId = window.setInterval(() => { drawSvgSnapshot().catch(() => {}); }, 100);

        stopSourceLoop = () => {
          window.clearInterval(intervalId);
          document.getElementById = _origGetById;
          document.querySelector = _origQuerySelector;
          shadowHost.remove();
          delete window.svgRoot;
        };
        return;
      }

      // c2 and generic
      const c2Runtime = engine === "c2" ? await loadC2Runtime() : window.c2;
      const sketchFactory = resolveSketchFactory(code);
      const managedCanvas = host.querySelector("canvas") || document.createElement("canvas");
      managedCanvas.width = runtimeSize.width;
      managedCanvas.height = runtimeSize.height;
      managedCanvas.style.width = `${runtimeSize.width}px`;
      managedCanvas.style.height = `${runtimeSize.height}px`;
      if (!managedCanvas.parentNode) host.appendChild(managedCanvas);
      syncCanvas(managedCanvas);

      let rafId = 0;
      const startFrame = (handler) => {
        let frameCount = 0;
        function tick() {
          frameCount += 1;
          try { handler(frameCount); } catch (err) { onError(err); return; }
          rafId = window.requestAnimationFrame(tick);
        }
        rafId = window.requestAnimationFrame(tick);
        return () => window.cancelAnimationFrame(rafId);
      };

      const c2MediaHelpers = engine === "c2" ? createC2MediaHelpers(managedCanvas, onError) : {};
      // Interactive c2 sketches attach their own pointer listeners to this
      // (off-screen) canvas — remember it so the hand-control controller can
      // drive them with synthetic pointer events.
      if (engine === "c2" && options.c2Interactive) interactiveSourceCanvas = managedCanvas;
      const cleanup = sketchFactory({
        c2: c2Runtime,
        canvas: managedCanvas,
        startFrame,
        ...c2MediaHelpers,
        size: runtimeSize,
        width: runtimeSize.width,
        height: runtimeSize.height,
      });

      stopSourceLoop = typeof cleanup === "function" ? cleanup : () => window.cancelAnimationFrame(rafId);

    } catch (err) {
      onError(err);
    }
  }

  const floorNav = createFloorClickNavigation(shell.camera, shell.controls, shell.floor, stageEl);
  const keyNav = createKeyboardNavigation(shell.controls, { container: stageEl });
  gyroController = createSharedGyroController(stageEl, shell.camera, {
    // Gallery-mounted 2D pieces use a texture-projected room camera. Its
    // OrbitControls remain available for navigation, so raw wrist landmarks
    // need a small deadband plus frame-rate-independent angular damping to
    // match the stable feel of direct immersive Three.js/A-Frame pieces.
    // These values are opt-in here; direct 3D viewers keep their established
    // shared-controller response unchanged.
    stabilizeHand: true,
    handSmoothing: 0.08,
    handInputDeadband: 0.012,
    handMaxAngularStep: 0.022,
  });

  // Gallery pieces run in an off-screen canvas (createImmersiveHost) and are
  // texture-projected onto shell.artMesh, so no pointer events ever reach
  // the piece's own click/touch/drag listeners. A click on the art frame
  // dispatches to whichever fullscreen path applies: onInteractiveClick for
  // interactive C2 pieces, readOnlyOverlay.openAt(0) for static engines
  // (P5, SVG, non-interactive C2) that have a fullView overlay configured.
  let disposeInteractiveClick = null;
  if (typeof onInteractiveClick === "function" ||
      (Array.isArray(options.fullView?.items) && options.fullView.items.length > 0)) {
    const clickRaycaster = new THREE.Raycaster();
    let downX = 0, downY = 0;
    const onPointerDown = (e) => {
      if (galleryHandSteering) return;
      downX = e.clientX;
      downY = e.clientY;
    };
    const onPointerUp = (e) => {
      if (galleryHandSteering) return;
      if (Math.hypot(e.clientX - downX, e.clientY - downY) >= 6) return;
      const rect = stageEl.getBoundingClientRect();
      clickRaycaster.setFromCamera(
        new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1),
        shell.camera,
      );
      if (clickRaycaster.intersectObject(shell.artMesh, false).length) {
        if (typeof onInteractiveClick === "function") {
          onInteractiveClick();
        } else {
          readOnlyOverlay?.openAt(0);
        }
      }
    };
    stageEl.addEventListener("pointerdown", onPointerDown);
    stageEl.addEventListener("pointerup", onPointerUp);
    disposeInteractiveClick = () => {
      stageEl.removeEventListener("pointerdown", onPointerDown);
      stageEl.removeEventListener("pointerup", onPointerUp);
    };
  }

  if (options.showViewerControls) {
    viewerControls = createImmersiveViewerControls(stageEl, {
      initialZoomValue: galleryZoomValueFromDistance(shell.camera.position.distanceTo(shell.controls.target)),
      onZoomSliderInput: (value) => applyGalleryZoomValue(value),
      onMoveForward: () => applyGalleryDirectionalMove(1, 0),
      onMoveBackward: () => applyGalleryDirectionalMove(-1, 0),
      onMoveLeft: () => applyGalleryDirectionalMove(0, -1),
      onMoveRight: () => applyGalleryDirectionalMove(0, 1),
      onFloatUp: () => applyGalleryFloatMove(1),
      onFloatDown: () => applyGalleryFloatMove(-1),
    });
    setImmersiveViewerControlsEnabled(viewerControls, !galleryHandSteering);
  }

  if (Array.isArray(options.fullView?.items) && options.fullView.items.length > 0) {
    readOnlyOverlay = createReadOnlyFullViewOverlay(stageEl, options.fullView.items, options.fullView.overlayOptions || {});
  }

  function animate() {
    frameId = requestAnimationFrame(animate);
    floorNav.update();
    keyNav.update();
    const activeSourceCanvas = sourceCanvas;
    if (activeSourceCanvas && artTexture) {
      if (engine !== "p5") {
        const bg = activeSourceCanvas.style.background || activeSourceCanvas.style.backgroundColor;
        if (bg) {
          const ctx = activeSourceCanvas.getContext("2d");
          if (ctx) {
            ctx.save();
            ctx.globalCompositeOperation = "destination-over";
            ctx.fillStyle = bg;
            ctx.fillRect(0, 0, activeSourceCanvas.width, activeSourceCanvas.height);
            ctx.restore();
          }
        }
      }
      if (presentationSurface) {
        drawContainedIntoPresentationSurface(
          presentationSurface, activeSourceCanvas.width || runtimeSize.width, activeSourceCanvas.height || runtimeSize.height,
          (ctx, x, y, width, height) => { ctx.drawImage(activeSourceCanvas, x, y, width, height); },
          "#05070f"
        );
      }
      artTexture.needsUpdate = true;
    }
    shell.controls.update();
    gyroController?.update();
    // Sonification leg: drive Tone.js from the gallery-room camera motion, so
    // ANY piece type (p5/c2/svg shown as a wall, or three/aframe) sonifies in
    // the immersive view. No-op until the user enables sound.
    if (audioController && shell.camera) {
      if (_galleryAudioPrevInit) {
        audioController.update({
          dx: shell.camera.position.x - _galleryAudioPrevPos.x,
          dy: shell.camera.position.y - _galleryAudioPrevPos.y,
          dz: shell.camera.position.z - _galleryAudioPrevPos.z,
        });
      }
      _galleryAudioPrevPos.copy(shell.camera.position);
      _galleryAudioPrevInit = true;
    }
    syncViewerControlZoom();
    shell.renderer.render(shell.scene, shell.camera);
  }

  bootRuntime();
  fitMountedGalleryCamera(shell, stageEl);
  initialGalleryViewState = shellViewState(shell);
  applyShellViewState(shell, options.initialViewState);
  gyroController.setup();
  animate();

  const resizeObserver = new ResizeObserver(() => fitMountedGalleryCamera(shell, stageEl, undefined, false));
  resizeObserver.observe(stageEl);

  // Camera view for the gallery room: the visitor's mirrored feed projects
  // onto the room's back wall behind the framed piece (blended by opacity),
  // so the art reads as hanging in their own space. Hand steering is only
  // supported for interactive c2 pieces, whose live off-screen canvas can be
  // driven with synthetic pointer events (move + pinch-to-press) exactly
  // like the regular view's piece-runtime does; other wall-textured engines
  // still have no piece-pointer surface.
  const dispatchGalleryHandPointer = (type, nx, ny) => {
    const canvas = interactiveSourceCanvas;
    if (!canvas) return;
    const rect = canvas.getBoundingClientRect();
    if (!rect.width || !rect.height) return;
    const clientX = rect.left + nx * rect.width;
    const clientY = rect.top + ny * rect.height;
    try {
      canvas.dispatchEvent(new PointerEvent(type, { clientX, clientY, bubbles: true, isPrimary: true, pointerType: "touch", button: 0, buttons: type === "pointerup" ? 0 : 1 }));
    } catch (_) {}
  };
  let galleryLastHandPoint = null;
  let galleryControlsEnabledBeforeHand = shell.controls.enabled;
  const pieceInteractionController = {
    supportsHandControl: true,
    supportsCameraBackground: true,
    setHandSteering(active) {
      const next = !!active;
      if (next === galleryHandSteering) return;
      setImmersiveViewerControlsEnabled(viewerControls, !next);
      galleryHandSteering = next;
      floorNav.setEnabled(!next);
      if (next) {
        galleryControlsEnabledBeforeHand = shell.controls.enabled;
        shell.controls.enabled = false;
        keyNav?.clearKeys?.();
      } else {
        bakeOrbitHandPose(shell.camera, shell.controls, gyroController);
        shell.controls.enabled = galleryControlsEnabledBeforeHand;
      }
    },
    handPoint(nx, ny) {
      if (galleryResettingView || !galleryHandSteering) return;
      gyroController?.setHandOffset?.(nx, ny);
      galleryLastHandPoint = { nx, ny };
      dispatchGalleryHandPointer("pointermove", nx, ny);
    },
    handCommand(command) {
      if (galleryResettingView || !galleryHandSteering || !command) return;
      if (command.type === "look") {
        this.handPoint(command.x, command.y);
        return;
      }
      if (command.type === "start") gyroController?.clearHandOffset?.();
      if (command.type === "orbit") {
        const offset = shell.camera.position.clone().sub(shell.controls.target);
        const spherical = new THREE.Spherical().setFromVector3(offset);
        spherical.theta += command.yaw || 0;
        spherical.phi = Math.max(0.15, Math.min(Math.PI - 0.15, spherical.phi + (command.pitch || 0)));
        offset.setFromSpherical(spherical);
        shell.camera.position.copy(shell.controls.target).add(offset);
      } else if (command.type === "travel") {
        applyGalleryDirectionalMove(command.forward || 0, command.right || 0, true);
      } else if (command.type === "zoom") {
        const offset = shell.camera.position.clone().sub(shell.controls.target);
        const distance = Math.max(0.45, Math.min(80, offset.length() * (1 - (command.delta || 0))));
        shell.camera.position.copy(shell.controls.target).add(offset.setLength(distance));
      }
      shell.controls.update();
      syncViewerControlZoom();
    },
    handPress(down) {
      if (!galleryHandSteering || !galleryLastHandPoint) return;
      dispatchGalleryHandPointer(down ? "pointerdown" : "pointerup", galleryLastHandPoint.nx, galleryLastHandPoint.ny);
    },
    handLost() { galleryLastHandPoint = null; gyroController?.clearHandOffset?.(); },
    async resetView() {
      gyroController?.clearHandOffset?.();
      galleryResettingView = true;
      try { return await animateShellViewReset(shell, initialGalleryViewState); }
      finally { galleryResettingView = false; }
    },
    ...createGalleryWallCameraController(shell),
  };

  function destroy() {
    disposed = true;
    pieceInteractionController.setHandSteering(false);
    pieceInteractionController.clearBackgroundVideo();
    resizeObserver.disconnect();
    if (detectCanvasTimer) window.clearTimeout(detectCanvasTimer);
    cancelAnimationFrame(frameId);
    artTexture?.dispose?.();
    shell.controls.dispose();
    shell.floor.geometry.dispose();
    shell.floor.material.dispose();
    shell.backWall.geometry.dispose();
    shell.backWall.material.dispose();
    shell.framePanel.geometry.dispose();
    shell.framePanel.material.dispose();
    shell.artMesh.geometry.dispose();
    shell.artMaterial.dispose();
    shell.frameMesh.geometry.dispose();
    shell.frameMesh.material.dispose();
    shell.renderer.dispose();
    stopSourceLoop?.();
    p5Instance?.remove?.();
    floorNav.dispose();
    keyNav.dispose();
    gyroController?.dispose();
    audioController?.dispose();
    viewerControls?.remove();
    readOnlyOverlay?.remove();
    disposeInteractiveClick?.();
    host.remove();
    stageEl.innerHTML = "";
  }

  return {
    destroy,
    openFullViewAt(index = 0) {
      readOnlyOverlay?.openAt(index);
    },
    closeFullView() {
      readOnlyOverlay?.close();
    },
    isFullViewOpen() {
      return readOnlyOverlay?.isOpen?.() ?? false;
    },
    // PNG capture: the gallery renderer as currently seen from the user's perspective.
    getCaptureSurface: () => ({
      canvas: shell.renderer.domElement,
      beforeCapture: () => {
        try {
          shell.renderer.render(shell.scene, shell.camera);
        } catch (_) {}
      },
    }),
    getViewState: () => shellViewState(shell),
    getAudioController: () => audioController,
    getPieceInteractionController: () => pieceInteractionController,
  };
}

// Standalone image gallery mounting helper (for MediaAsset in Phase 2)
function mountGalleryImage(stageEl, imageUrl, aspect, title, prompt, description) {
  const shell = createMountedGalleryShell(stageEl, aspect, NORMALIZED_PRESENTATION_GALLERY_PROFILE);
  const texture = new THREE.TextureLoader().load(imageUrl, () => {
    shell.artMaterial.map = texture;
    shell.artMaterial.needsUpdate = true;
  });

  const floorNav = createFloorClickNavigation(shell.camera, shell.controls, shell.floor, stageEl);
  const keyNav = createKeyboardNavigation(shell.controls, { container: stageEl });

  let frameId = 0;
  function animate() {
    frameId = requestAnimationFrame(animate);
    floorNav.update();
    keyNav.update();
    shell.controls.update();
    shell.renderer.render(shell.scene, shell.camera);
  }
  animate();

  const resizeObserver = new ResizeObserver(() => fitMountedGalleryCamera(shell, stageEl, undefined, false));
  resizeObserver.observe(stageEl);

  return () => {
    resizeObserver.disconnect();
    cancelAnimationFrame(frameId);
    texture.dispose();
    shell.controls.dispose();
    shell.floor.geometry.dispose();
    shell.floor.material.dispose();
    shell.backWall.geometry.dispose();
    shell.backWall.material.dispose();
    shell.framePanel.geometry.dispose();
    shell.framePanel.material.dispose();
    shell.artMesh.geometry.dispose();
    shell.artMaterial.dispose();
    shell.frameMesh.geometry.dispose();
    shell.frameMesh.material.dispose();
    shell.renderer.dispose();
    floorNav.dispose();
    keyNav.dispose();
    stageEl.innerHTML = "";
  };
}

// Phase 3 Entry Point: mountExhibitWall
function mountExhibitWall(stageEl, items, rows, cols, options = {}) {
  const wallWidth = Math.max(22, cols * WALL_FRAME_SLOT_WIDTH + 2);
  const labels = items.map(item => ({
    title: item.title || "Untitled",
    subtitle: item.kind === "piece" ? engineLabel(item.engine) : "Image"
  }));

  const shell = createMultiFrameExhibitWall(stageEl, items.length, rows, cols, labels, {
    labelPosition: options.labelPosition,
  });
  const floorNav = createFloorClickNavigation(shell.camera, shell.controls, shell.floor, stageEl, { maxX: wallWidth / 2 });
  const keyNav = createKeyboardNavigation(shell.controls, { container: stageEl, minX: -wallWidth / 2, maxX: wallWidth / 2 });
  // Hand navigation of the room camera (MediaPipe wrist → velocity
  // joystick), driven by the stage chrome's hand-control toggle through the
  // same interaction-controller interface single pieces use.
  let initialExhibitViewState = null;
  const roomHandNav = createRoomHandNavigation(shell.controls, {
    minX: -wallWidth / 2,
    maxX: wallWidth / 2,
    resetView: () => animateShellViewReset(shell, initialExhibitViewState),
  });
  const roomCameraView = createGalleryWallCameraController(shell);
  const roomInteractionController = {
    ...roomHandNav,
    ...roomCameraView,
    supportsCameraBackground: true,
  };

  // Progressive rendering state
  const runtimeSize = { width: 400, height: 300 }; // small size for grid items
  const activeRuntimes = new Map(); // index -> { host, canvas, stop, texture, p5Instance }

  let frameId = 0;
  let disposed = false;
  let presentationSuspended = false;
  let runtimeEpoch = 0;
  let viewerControls = null;
  let readOnlyOverlay = null;
  let disposeSlotFullViewClick = null;
  let gyroController = null;
  const exhibitButtonForward = new THREE.Vector3();
  const exhibitButtonRight = new THREE.Vector3();
  gyroController = createSharedGyroController(stageEl, shell.camera);

  // Sonification: only the item nearest the current camera target sonifies
  // (mirrors the single-piece mounts, which have exactly one item to pick).
  // The controller is torn down and rebuilt whenever focus moves to a
  // different item, since createAudioController binds one fixed sonicParams
  // for its lifetime.
  let audioController = null;
  let audioControllerIndex = -1;
  let wallSoundRequested = false;
  let lastAudioRebindAt = 0;
  // Belt-and-suspenders alongside the distance margin below: a click's
  // unmute path awaits Tone.start(), which takes at least one more
  // animation frame to resolve — without a floor on how often the
  // controller can actually be rebuilt, a focus flip in that window can
  // dispose the very controller the click just enabled before it's audible.
  const AUDIO_REBIND_COOLDOWN_MS = 500;
  const exhibitAudioPrevPos = new THREE.Vector3();
  let exhibitAudioPrevInit = false;

  // A margin (not just raw nearest-wins) so OrbitControls damping near a
  // boundary between two similarly-distant slots doesn't flip the focused
  // index — and therefore the audio controller — every animation frame.
  // The current slot has to be measurably beaten before focus moves on.
  const FOCUS_SWITCH_MARGIN = 0.85;

  function computeFocusedSlotIndex(currentIndex = -1) {
    let closestIndex = -1;
    let minDistance = Infinity;
    let currentDistance = Infinity;
    const target = shell.controls.target;
    items.forEach((item, index) => {
      if (item.kind !== "piece" && item.kind !== "image") return;
      const slot = shell.slots[index];
      const center = slot?.center;
      if (!center) return;
      const dx = center.x - target.x;
      const dy = center.y - target.y;
      const dz = center.z - target.z;
      const dist = dx * dx + dy * dy + dz * dz * 0.35;
      if (dist < minDistance) { minDistance = dist; closestIndex = index; }
      if (index === currentIndex) currentDistance = dist;
    });
    if (currentIndex >= 0 && Number.isFinite(currentDistance) && closestIndex !== currentIndex) {
      // Require the new candidate to beat the current focus by a margin,
      // not just edge it out — otherwise stick with the current focus.
      if (minDistance >= currentDistance * FOCUS_SWITCH_MARGIN) return currentIndex;
    }
    return closestIndex;
  }

  // The toggle button is shared across every wall item, but which item
  // (if any) currently has sonicParams changes as focus moves — a
  // per-focus-change controller (attachListener:false) has no listener of
  // its own, so the wall owns exactly ONE persistent listener here instead
  // of one that's silently attached/detached as createAudioController is
  // torn down and rebuilt. The button remains a collection-level master
  // control even when the active item is silent: visitors can arm sound for
  // the next sounding work without losing their intent on a silent slide.
  const wallSoundToggleBtn = document.querySelector("[data-immersive-sound-toggle]");
  function syncWallSoundButton() {
    if (!wallSoundToggleBtn) return;
    wallSoundToggleBtn.disabled = false;
    wallSoundToggleBtn.style.opacity = "";
    if (!audioController) {
      syncAudioToggleButton(wallSoundToggleBtn, !wallSoundRequested, {
        off: "Enable collection sound for sounding works",
        on: "Mute collection sound",
      });
    } else {
      audioController.syncButton(!wallSoundRequested);
    }
  }
  async function onWallSoundToggleClick() {
    if (!audioController) {
      wallSoundRequested = !wallSoundRequested;
      syncWallSoundButton();
      return;
    }
    await audioController.toggleEnabled();
  }
  if (wallSoundToggleBtn) {
    wallSoundToggleBtn.addEventListener("click", onWallSoundToggleClick);
    syncWallSoundButton(); // establish the initial disabled/enabled state before the first animate() frame
  }

  function bindWallAudioController(index) {
    if (index === audioControllerIndex) return;
    audioController?.dispose();
    const focusedItem = index >= 0 ? items[index] : null;
    audioController = createAudioController(focusedItem?.sonicParams, stageEl, {
      attachListener: false,
      allowHandTracking: false,
      pieceId: focusedItem?.piece_id,
    });
    audioControllerIndex = index;
    lastAudioRebindAt = performance.now();
    exhibitAudioPrevInit = false;
    if (audioController) {
      const controller = audioController;
      const toggleEnabled = controller.toggleEnabled.bind(controller);
      const ensureAudioReady = controller.ensureAudioReady.bind(controller);
      controller.toggleEnabled = async () => {
        await toggleEnabled();
        if (audioController !== controller) return;
        wallSoundRequested = controller.isEnabled();
        syncWallSoundButton();
      };
      controller.ensureAudioReady = async () => {
        const ok = await ensureAudioReady();
        if (audioController === controller && ok) wallSoundRequested = true;
        syncWallSoundButton();
        return ok;
      };
    }
    syncWallSoundButton();
    if (audioController && wallSoundRequested) {
      const reboundController = audioController;
      reboundController.ensureAudioReady().then((ok) => {
        if (audioController !== reboundController) return;
        if (!ok) wallSoundRequested = false;
        syncWallSoundButton();
      });
    }
  }

  function getWallSoundState() {
    return {
      enabled: wallSoundRequested,
      hasActiveSound: audioController !== null,
    };
  }

  function getLiveSlots() {
    const budget = getProgressiveExhibitLiveBudget(window.innerWidth);
    return selectProgressiveExhibitSlots(items, shell.slots.map(s => s.center), shell.controls.target, budget);
  }

  function exhibitZoomValueFromDistance(distance) {
    const minDistance = shell.controls.minDistance || 0.6;
    const maxDistance = shell.controls.maxDistance || Math.max(40, distance * 4);
    if (!Number.isFinite(distance) || maxDistance <= minDistance) return 50;
    return ((maxDistance - Math.max(minDistance, Math.min(maxDistance, distance))) / (maxDistance - minDistance)) * 100;
  }

  function syncExhibitViewerZoom() {
    viewerControls?.setZoomValue(exhibitZoomValueFromDistance(shell.camera.position.distanceTo(shell.controls.target)));
  }

  function applyExhibitZoomValue(value) {
    if (!Number.isFinite(value)) return;
    const minDistance = shell.controls.minDistance || 0.6;
    const currentDistance = shell.camera.position.distanceTo(shell.controls.target);
    const maxDistance = shell.controls.maxDistance || Math.max(40, currentDistance * 4);
    const t = Math.max(0, Math.min(100, value)) / 100;
    const nextDistance = maxDistance - ((maxDistance - minDistance) * t);
    const direction = shell.camera.position.clone().sub(shell.controls.target);
    if (direction.lengthSq() < 1e-8) return;
    direction.setLength(nextDistance);
    shell.camera.position.copy(shell.controls.target).add(direction);
    shell.controls.update();
    syncExhibitViewerZoom();
  }

  function applyExhibitDirectionalMove(forwardScale, rightScale) {
    shell.camera.getWorldDirection(exhibitButtonForward);
    exhibitButtonForward.y = 0;
    if (exhibitButtonForward.lengthSq() < 1e-6) {
      exhibitButtonForward.set(0, 0, -1);
    } else {
      exhibitButtonForward.normalize();
    }
    exhibitButtonRight.set(-exhibitButtonForward.z, 0, exhibitButtonForward.x);
    const step = Math.max(0.08, shell.controls.target.distanceTo(shell.camera.position) * 0.035);
    const shift = exhibitButtonForward.clone()
      .multiplyScalar(forwardScale * step)
      .add(exhibitButtonRight.clone().multiplyScalar(rightScale * step));
    if (shift.lengthSq() < 1e-8) return;
    const nextCamX = Math.max(-wallWidth / 2, Math.min(wallWidth / 2, shell.camera.position.x + shift.x));
    const nextCamZ = Math.max(0.5, shell.camera.position.z + shift.z);
    const dx = nextCamX - shell.camera.position.x;
    const dz = nextCamZ - shell.camera.position.z;
    if (Math.abs(dx) < 1e-6 && Math.abs(dz) < 1e-6) return;
    shell.camera.position.x += dx;
    shell.camera.position.z += dz;
    shell.controls.target.x += dx;
    shell.controls.target.z += dz;
    shell.controls.update();
  }

  function applyExhibitFloatMove(verticalScale) {
    if (!Number.isFinite(verticalScale)) return;
    const step = Math.max(0.08, shell.controls.target.distanceTo(shell.camera.position) * 0.03);
    const nextCamY = Math.max(0.1, Math.min(24, shell.camera.position.y + (verticalScale * step)));
    const dy = nextCamY - shell.camera.position.y;
    if (Math.abs(dy) < 1e-6) return;
    shell.camera.position.y += dy;
    shell.controls.target.y += dy;
    shell.controls.update();
  }

  function updateProgressiveLoading() {
    if (disposed || presentationSuspended) return;
    const updateEpoch = runtimeEpoch;
    const liveSlots = getLiveSlots();
    
    // Boot up newly live slots
    liveSlots.forEach(async index => {
      const existing = activeRuntimes.get(index);
      if (existing) {
        if (!existing.failed) return; // already active and healthy
        // Previously-failed slot occupying this live budget slot — tear down and retry.
        existing.stop?.();
        existing.texture?.dispose();
        existing.host?.remove();
        activeRuntimes.delete(index);
      }

      const item = items[index];
      const slot = shell.slots[index];

      // Three.js / A-Frame: boot in off-screen srcdoc iframe, copy canvas to proxy
      if (item.engine === 'three' || item.engine === 'aframe') {
        const srcdoc = item.full_view?.srcdoc;
        if (!srcdoc) {
          activeRuntimes.set(index, { host: null, sourceCanvas: null, stop: () => {}, texture: null, p5Instance: null, svgInterval: null, failed: true });
          return;
        }

        const iframe = document.createElement('iframe');
        iframe.style.cssText = `position:fixed;left:-10000px;top:0;width:${runtimeSize.width}px;height:${runtimeSize.height}px;pointer-events:none;border:none;`;
        iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
        iframe.setAttribute('allow', 'camera; microphone');

        const proxyCanvas = document.createElement('canvas');
        proxyCanvas.width = runtimeSize.width;
        proxyCanvas.height = runtimeSize.height;

        let iframeCanvas = null;
        let liveTexture = null;
        let iframeRafId = 0;

        iframe.addEventListener('load', () => { iframeRafId = requestAnimationFrame(syncFrame); }, { once: true });
        iframe.srcdoc = srcdoc;
        document.body.appendChild(iframe);

        // Show thumbnail as placeholder while the iframe piece boots
        if (item.thumbnail_url) {
          new THREE.TextureLoader().load(item.thumbnail_url, (thumbTex) => {
            thumbTex.colorSpace = THREE.SRGBColorSpace;
            if (!liveTexture) {
              slot.artMaterial.map = thumbTex;
              slot.artMaterial.color.set('#ffffff');
              slot.artMaterial.needsUpdate = true;
            } else {
              thumbTex.dispose();
            }
          });
        }

        function syncFrame() {
          if (!iframeCanvas) {
            try {
              const candidate = iframe.contentDocument?.querySelector('canvas');
              if (candidate instanceof HTMLCanvasElement && candidate.width > 0 && candidate.height > 0) {
                iframeCanvas = candidate;
              }
            } catch (_) { /* cross-origin guard */ }
          }
          if (iframeCanvas) {
            try {
              const ctx = proxyCanvas.getContext('2d');
              ctx?.drawImage(iframeCanvas, 0, 0, proxyCanvas.width, proxyCanvas.height);
              if (!liveTexture) {
                liveTexture = new THREE.CanvasTexture(proxyCanvas);
                liveTexture.colorSpace = THREE.SRGBColorSpace;
                slot.artMaterial.map = liveTexture;
                slot.artMaterial.color.set('#ffffff');
                slot.artMaterial.needsUpdate = true;
                const r = activeRuntimes.get(index);
                if (r) r.texture = liveTexture;
              }
              liveTexture.needsUpdate = true;
            } catch (_) { /* tainted canvas guard */ }
          }
          iframeRafId = requestAnimationFrame(syncFrame);
        }

        activeRuntimes.set(index, {
          host: iframe,
          sourceCanvas: proxyCanvas,
          stop: () => { cancelAnimationFrame(iframeRafId); iframe.remove(); liveTexture?.dispose(); },
          texture: null,
          p5Instance: null,
          svgInterval: null,
          failed: false,
        });

        return;
      }

      const host = createImmersiveHost(
        item.html_code, item.css_code,
        item.engine === "p5" ? '<div id="canvas-container"></div>' : item.engine === "svg" ? '<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"></svg>' : '<canvas id="piece-canvas"></canvas>',
        runtimeSize,
        item.engine
      );
      
      let sourceCanvas = null;
      let texture = null;
      let p5Instance = null;
      let stop = null;
      let svgInterval = null;

      function syncSlotCanvas(canvasElement) {
        sourceCanvas = canvasElement;
        texture = new THREE.CanvasTexture(canvasElement);
        texture.colorSpace = THREE.SRGBColorSpace;
        slot.artMaterial.map = texture;
        slot.artMaterial.needsUpdate = true;
      }

      activeRuntimes.set(index, {
        host,
        sourceCanvas: null,
        stop: () => host.remove(),
        texture: null,
        p5Instance: null,
        svgInterval: null,
        failed: false,
        pending: true,
      });

      try {
        if (item.engine === "p5") {
          const P5 = await loadP5Runtime();
          if (disposed || presentationSuspended || runtimeEpoch !== updateEpoch || !activeRuntimes.has(index)) {
            host.remove();
            return;
          }
          const sketchFactory = resolveSketchFactory(item.generated_code);
          const mount = host.querySelector("#canvas-container") || host.querySelector("#sketch-container") || host;
          const preExistingCanvases = new Set(document.querySelectorAll("canvas"));
          p5Instance = new P5(sketchFactory, mount);

          let attempts = 0;
          const poll = () => {
            let canv = p5Instance?.canvas instanceof HTMLCanvasElement ? p5Instance.canvas : null;
            if (!canv) canv = mount.querySelector("canvas") || host.querySelector("canvas");
            if (!canv) {
              canv = Array.from(document.querySelectorAll("canvas")).find((c) => !preExistingCanvases.has(c)) || null;
              if (canv && canv.parentElement !== mount) mount.appendChild(canv);
            }
            if (canv) {
              if (canv.width === 0) { canv.width = runtimeSize.width; canv.height = runtimeSize.height; }
              syncSlotCanvas(canv);
              const runtime = activeRuntimes.get(index);
              if (runtime) { runtime.sourceCanvas = canv; runtime.texture = texture; runtime.failed = false; }
            } else if (attempts < 50) {
              attempts++;
              setTimeout(poll, 100);
            } else {
              console.warn(`Progressive slot ${index} (${item.title || "untitled"}, p5) never produced a canvas.`);
              const runtime = activeRuntimes.get(index);
              if (runtime) runtime.failed = true;
            }
          };
          poll();

          stop = () => {
            p5Instance?.remove?.();
          };
        } else if (item.engine === "svg") {
          const shadowHost = document.createElement("div");
          shadowHost.style.cssText = `position:fixed;left:-10000px;top:0;width:${runtimeSize.width}px;height:${runtimeSize.height}px;pointer-events:none;`;
          const shadowRoot = shadowHost.attachShadow({ mode: "open" });
          if (item.css_code) {
            const styleEl = document.createElement("style");
            styleEl.textContent = item.css_code;
            shadowRoot.appendChild(styleEl);
          }
          const svgContainer = document.createElement("div");
          svgContainer.innerHTML = item.html_code?.trim() ? item.html_code : '<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"></svg>';
          shadowRoot.appendChild(svgContainer);
          document.body.appendChild(shadowHost);

          const svgEl = shadowRoot.querySelector("svg");
          if (svgEl) {
            const svgCanvas = document.createElement("canvas");
            svgCanvas.width = runtimeSize.width;
            svgCanvas.height = runtimeSize.height;
            syncSlotCanvas(svgCanvas);

            window.svgRoot = svgEl;
            const sketchFactory = resolveSketchFactory(item.generated_code);
            if (typeof sketchFactory === "function") {
              try { sketchFactory(); } catch (sketchErr) {
                console.warn(`Progressive slot ${index} (${item.title || "untitled"}, svg) sketch init failed:`, sketchErr);
              }
            }

            let drawPending = false;
            const drawSvg = async () => {
              if (drawPending) return;
              drawPending = true;
              try {
                const svgClone = svgEl.cloneNode(true);
                const liveEls = Array.from(svgEl.querySelectorAll("*"));
                const cloneEls = Array.from(svgClone.querySelectorAll("*"));
                const props = ["transform", "transform-origin", "opacity", "fill", "stroke", "stroke-width", "cx", "cy", "r", "x", "y", "width", "height", "d", "stop-color", "offset", "filter", "display"];
                liveEls.forEach((liveEl, i) => {
                  const cloneEl = cloneEls[i];
                  if (!cloneEl) return;
                  const s = window.getComputedStyle(liveEl);
                  props.forEach(p => {
                    const val = s.getPropertyValue(p);
                    if (val) cloneEl.style.setProperty(p, val);
                  });
                });
                const styleEl = document.createElementNS("http://www.w3.org/2000/svg", "style");
                styleEl.textContent = "* { animation: none !important; transition: none !important; }";
                svgClone.insertBefore(styleEl, svgClone.firstChild);
                const serialized = new XMLSerializer().serializeToString(svgClone);
                const dataUrl = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(serialized);
                await new Promise(res => {
                  const img = new Image();
                  img.onload = () => {
                    const ctx = svgCanvas.getContext("2d");
                    if (ctx) { ctx.clearRect(0, 0, svgCanvas.width, svgCanvas.height); ctx.drawImage(img, 0, 0, svgCanvas.width, svgCanvas.height); }
                    if (texture) texture.needsUpdate = true;
                    res();
                  };
                  img.onerror = () => res();
                  img.src = dataUrl;
                });
              } finally {
                drawPending = false;
              }
            };

            drawSvg();
            svgInterval = setInterval(drawSvg, 150);

            stop = () => {
              clearInterval(svgInterval);
              shadowHost.remove();
              delete window.svgRoot;
            };
          }
        } else {
          // c2 / three / generic
          const c2Runtime = item.engine === "c2" ? await loadC2Runtime() : window.c2;
          if (disposed || presentationSuspended || runtimeEpoch !== updateEpoch || !activeRuntimes.has(index)) {
            host.remove();
            return;
          }
          const sketchFactory = resolveSketchFactory(item.generated_code);
          const managedCanvas = host.querySelector("canvas") || document.createElement("canvas");
          managedCanvas.width = runtimeSize.width;
          managedCanvas.height = runtimeSize.height;
          if (!managedCanvas.parentNode) host.appendChild(managedCanvas);
          syncSlotCanvas(managedCanvas);

          let rafId = 0;
          let tickErrors = 0;
          const startFrame = (handler) => {
            let count = 0;
            function tick() {
              count++;
              try {
                handler(count);
                tickErrors = 0;
              } catch (err) {
                tickErrors++;
                if (tickErrors === 1) {
                  console.warn(`Progressive slot ${index} (${item.title || "untitled"}, ${item.engine}) frame handler error:`, err);
                }
                if (tickErrors >= 5) return;
              }
              rafId = requestAnimationFrame(tick);
            }
            rafId = requestAnimationFrame(tick);
            return () => cancelAnimationFrame(rafId);
          };

          const c2MediaHelpers = item.engine === "c2" ? createC2MediaHelpers(managedCanvas, (err) => {
            console.warn(`Progressive slot ${index} (${item.title || "untitled"}, c2) media helper error:`, err);
          }) : {};
          if (!window.THREE) window.THREE = THREE;
          const cleanup = sketchFactory({
            THREE: THREE, // Hand global THREE for inline three pieces on exhibit walls
            c2: c2Runtime,
            canvas: managedCanvas,
            startFrame,
            ...c2MediaHelpers,
            size: runtimeSize,
            width: runtimeSize.width,
            height: runtimeSize.height
          });

          stop = () => {
            if (typeof cleanup === "function") cleanup();
            else cancelAnimationFrame(rafId);
          };
        }
      } catch (err) {
        console.warn(`Progressive slot ${index} (${item.title || "untitled"}, ${item.engine}) failed to boot:`, err);
        const runtime = activeRuntimes.get(index);
        if (runtime) runtime.failed = true;
      }

      if (disposed || presentationSuspended || runtimeEpoch !== updateEpoch || !activeRuntimes.has(index)) {
        stop?.();
        texture?.dispose?.();
        host?.remove?.();
        return;
      }
      const currentRuntime = activeRuntimes.get(index);
      activeRuntimes.set(index, {
        host,
        sourceCanvas,
        stop,
        texture,
        p5Instance,
        svgInterval,
        failed: Boolean(currentRuntime?.failed) || (!sourceCanvas && !stop),
        pending: false,
      });
    });

    // Tear down no-longer-live slots
    activeRuntimes.forEach((runtime, index) => {
      if (liveSlots.has(index)) return; // keeps being live
      
      // Stop and clean up
      runtime.stop?.();
      runtime.texture?.dispose();
      runtime.host?.remove();
      
      // Revert to thumbnail or solid color
      const item = items[index];
      const slot = shell.slots[index];
      slot.artMaterial.map = null;
      
      if (item.thumbnail_url && (item.thumbnail_url.startsWith("/") || item.thumbnail_url.startsWith("http"))) {
        new THREE.TextureLoader().load(item.thumbnail_url, tex => {
          // Check if slot wasn't booted back in the meantime
          if (!activeRuntimes.has(index)) {
            slot.artMaterial.map = tex;
            slot.artMaterial.needsUpdate = true;
          } else {
            tex.dispose();
          }
        });
      } else {
        slot.artMaterial.color.set("#e8e4de");
      }
      
      activeRuntimes.delete(index);
    });
  }

  // Populate images and non-live thumbnails on initial load
  items.forEach((item, index) => {
    const slot = shell.slots[index];
    if (item.kind === "image" && item.imageUrl) {
      new THREE.TextureLoader().load(item.imageUrl, tex => {
        slot.artMaterial.map = tex;
        slot.artMaterial.color.set("#ffffff");
        slot.artMaterial.needsUpdate = true;
      });
    } else if (item.kind === "piece" && item.thumbnail_url) {
      new THREE.TextureLoader().load(item.thumbnail_url, tex => {
        if (!activeRuntimes.has(index)) {
          slot.artMaterial.map = tex;
          slot.artMaterial.color.set("#ffffff");
          slot.artMaterial.needsUpdate = true;
        } else {
          tex.dispose();
        }
      });
    }
  });

  function restoreSlotFallback(index) {
    const item = items[index];
    const slot = shell.slots[index];
    if (!item || !slot || item.kind !== "piece") return;
    slot.artMaterial.map = null;
    if (item.thumbnail_url && (item.thumbnail_url.startsWith("/") || item.thumbnail_url.startsWith("http"))) {
      const restoreEpoch = runtimeEpoch;
      new THREE.TextureLoader().load(item.thumbnail_url, tex => {
        if (disposed || runtimeEpoch !== restoreEpoch || activeRuntimes.has(index)) {
          tex.dispose();
          return;
        }
        slot.artMaterial.map = tex;
        slot.artMaterial.color.set("#ffffff");
        slot.artMaterial.needsUpdate = true;
        try { shell.renderer.render(shell.scene, shell.camera); } catch (_) {}
      });
    } else {
      slot.artMaterial.color.set("#e8e4de");
      slot.artMaterial.needsUpdate = true;
    }
  }

  function releaseActiveRuntimes() {
    runtimeEpoch += 1;
    activeRuntimes.forEach((runtime, index) => {
      runtime.stop?.();
      runtime.texture?.dispose?.();
      runtime.host?.remove?.();
      restoreSlotFallback(index);
    });
    activeRuntimes.clear();
  }

  function suspendPresentation() {
    if (disposed || presentationSuspended) return;
    presentationSuspended = true;
    cancelAnimationFrame(frameId);
    frameId = 0;
    releaseActiveRuntimes();
  }

  function resumePresentation() {
    if (disposed || !presentationSuspended) return;
    presentationSuspended = false;
    lastTarget.copy(shell.controls.target);
    updateProgressiveLoading();
    try { shell.renderer.render(shell.scene, shell.camera); } catch (_) {}
    if (!frameId) animate();
  }

  // Track if camera or target moved to update progressive loading
  const lastTarget = new THREE.Vector3().copy(shell.controls.target);

  if (options.showViewerControls) {
    viewerControls = createImmersiveViewerControls(stageEl, {
      initialZoomValue: exhibitZoomValueFromDistance(shell.camera.position.distanceTo(shell.controls.target)),
      onZoomSliderInput: (value) => applyExhibitZoomValue(value),
      onMoveForward: () => applyExhibitDirectionalMove(1, 0),
      onMoveBackward: () => applyExhibitDirectionalMove(-1, 0),
      onMoveLeft: () => applyExhibitDirectionalMove(0, -1),
      onMoveRight: () => applyExhibitDirectionalMove(0, 1),
      onFloatUp: () => applyExhibitFloatMove(1),
      onFloatDown: () => applyExhibitFloatMove(-1),
    });
  }

  const fullViewItems = items.map((item) => item?.full_view || null);
  const immersiveHrefs = items.map((item) => item?.immersive_href || null);
  let selectedSourceIndex = items.findIndex((item) => item?.download_url);
  if (selectedSourceIndex < 0) selectedSourceIndex = 0;
  const hasFullViewItems = fullViewItems.some((item) => Boolean(item));
  const hasImmersiveHrefs = immersiveHrefs.some((href) => Boolean(href));
  const slideshowEntries = fullViewItems
    .map((item, sourceIndex) => item ? { item, sourceIndex } : null)
    .filter((entry) => Boolean(entry));
  const slideshowIndexBySourceIndex = new Map(
    slideshowEntries.map((entry, slideshowIndex) => [entry.sourceIndex, slideshowIndex]),
  );
  let wallAudioIndexBeforeSlideshow = -1;
  if (hasFullViewItems) {
    readOnlyOverlay = createReadOnlyFullViewOverlay(stageEl, slideshowEntries.map((entry) => entry.item), {
      layout: "single",
      soundControl: {
        toggle: onWallSoundToggleClick,
        getState: getWallSoundState,
      },
      onActiveItemChange(item) {
        const matchedIndex = items.findIndex((candidate) => candidate?.full_view === item);
        if (matchedIndex >= 0) {
          selectedSourceIndex = matchedIndex;
          bindWallAudioController(matchedIndex);
        }
      },
      onOpen() {
        wallAudioIndexBeforeSlideshow = computeFocusedSlotIndex(audioControllerIndex);
        // Arm sound for the displayed slide: the onActiveItemChange callback
        // fires next (via renderCurrentItems → syncDownloadControls) and calls
        // bindWallAudioController, which starts ensureAudioReady() only when
        // wallSoundRequested is true. Setting it here fulfils the user's
        // expectation that clicking a piece plays its sound immediately, and
        // means Prev/Next navigation and the close-restore path also carry
        // sound forward without a separate button press.
        wallSoundRequested = true;
        suspendPresentation();
      },
      onClose() {
        const restoreIndex = wallAudioIndexBeforeSlideshow;
        wallAudioIndexBeforeSlideshow = -1;
        bindWallAudioController(restoreIndex);
        resumePresentation();
      },
    });
  }
  if (hasFullViewItems || hasImmersiveHrefs) {

    const clickRaycaster = new THREE.Raycaster();
    let downX = 0;
    let downY = 0;
    const onPointerDown = (event) => {
      downX = event.clientX;
      downY = event.clientY;
    };
    const onPointerUp = (event) => {
      if (readOnlyOverlay?.isOpen()) return;
      if (Math.hypot(event.clientX - downX, event.clientY - downY) >= 6) return;
      const rect = stageEl.getBoundingClientRect();
      clickRaycaster.setFromCamera(
        new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1),
        shell.camera,
      );
      const slotMeshes = shell.slots.map((slot) => slot.artMesh);
      const hits = clickRaycaster.intersectObjects(slotMeshes, false);
      if (!hits.length) return;
      const slotIndex = slotMeshes.indexOf(hits[0].object);
      if (slotIndex < 0) return;
      selectedSourceIndex = slotIndex;
      if (fullViewItems[slotIndex]) {
        const slideshowIndex = slideshowIndexBySourceIndex.get(slotIndex);
        if (Number.isFinite(slideshowIndex)) {
          readOnlyOverlay.openAt(slideshowIndex);
          return;
        }
      }
      if (immersiveHrefs[slotIndex]) {
        window.location.assign(immersiveHrefs[slotIndex]);
      }
    };
    stageEl.addEventListener("pointerdown", onPointerDown);
    stageEl.addEventListener("pointerup", onPointerUp);
    disposeSlotFullViewClick = () => {
      stageEl.removeEventListener("pointerdown", onPointerDown);
      stageEl.removeEventListener("pointerup", onPointerUp);
    };
  }
  
  function animate() {
    if (disposed || presentationSuspended) {
      frameId = 0;
      return;
    }
    frameId = requestAnimationFrame(animate);
    floorNav.update();
    keyNav.update();
    roomHandNav.update();
    shell.controls.update();
    gyroController?.update();
    syncExhibitViewerZoom();

    // Sonification: rebind the controller whenever focus moves to a
    // different item, then drive it from wall-camera motion like the
    // single-piece mounts do.
    const focusedIndex = computeFocusedSlotIndex(audioControllerIndex);
    if (focusedIndex >= 0 && !readOnlyOverlay?.isOpen?.()) selectedSourceIndex = focusedIndex;
    const nowTs = performance.now();
    if (focusedIndex !== audioControllerIndex && nowTs - lastAudioRebindAt >= AUDIO_REBIND_COOLDOWN_MS) {
      // Hand-tracking remains excluded from the multiplexed wall controller;
      // collection hand navigation has its own dedicated silent engine.
      bindWallAudioController(focusedIndex);
    }
    if (audioController && shell.camera) {
      if (exhibitAudioPrevInit) {
        audioController.update({
          dx: shell.camera.position.x - exhibitAudioPrevPos.x,
          dy: shell.camera.position.y - exhibitAudioPrevPos.y,
          dz: shell.camera.position.z - exhibitAudioPrevPos.z,
        });
      }
      exhibitAudioPrevPos.copy(shell.camera.position);
      exhibitAudioPrevInit = true;
    }

    // Update live textures
    activeRuntimes.forEach((runtime) => {
      if (runtime.sourceCanvas && runtime.texture) {
        runtime.texture.needsUpdate = true;
      }
    });

    if (lastTarget.distanceTo(shell.controls.target) > 0.05) {
      lastTarget.copy(shell.controls.target);
      updateProgressiveLoading();
    }
    
    shell.renderer.render(shell.scene, shell.camera);
  }

  updateProgressiveLoading();
  gyroController.setup();
  initialExhibitViewState = shellViewState(shell);
  applyShellViewState(shell, options.initialViewState);
  animate();

  const resizeObserver = new ResizeObserver(() => {
    fitMultiFrameExhibitCamera(shell, stageEl, false);
    updateProgressiveLoading();
  });
  resizeObserver.observe(stageEl);

  function destroy() {
    disposed = true;
    presentationSuspended = false;
    resizeObserver.disconnect();
    cancelAnimationFrame(frameId);
    audioController?.dispose();
    if (wallSoundToggleBtn) wallSoundToggleBtn.removeEventListener("click", onWallSoundToggleClick);
    releaseActiveRuntimes();
    shell.controls.dispose();
    shell.floor.geometry.dispose();
    shell.floor.material.dispose();
    shell.backWall.geometry.dispose();
    shell.backWall.material.dispose();
    shell.slots.forEach(slot => {
      slot.artMesh.geometry.dispose();
      slot.artMaterial.dispose();
      slot.frameMesh.geometry.dispose();
      slot.frameMesh.material.dispose();
      slot.framePanel.geometry.dispose();
      slot.framePanel.material.dispose();
      if (slot.labelMesh) {
        slot.labelMesh.geometry.dispose();
        slot.labelMaterial.dispose();
      }
    });
    shell.renderer.dispose();
    floorNav.dispose();
    keyNav.dispose();
    roomInteractionController.setHandSteering(false);
    roomInteractionController.clearBackgroundVideo();
    roomHandNav.dispose();
    gyroController?.dispose();
    viewerControls?.remove();
    readOnlyOverlay?.remove();
    disposeSlotFullViewClick?.();
    stageEl.innerHTML = "";
  }

  return {
    destroy,
    getAudioController: () => audioController,
    toggleSound: onWallSoundToggleClick,
    getSoundState: getWallSoundState,
    getRoomInteractionController: () => roomInteractionController,
    getSelectedItem() {
      return items[selectedSourceIndex] || null;
    },
    getActiveIndex() {
      // Return the index of the wall slot closest to the camera's look-at target.
      // Used by the slideshow button to open at the piece the user is focused on.
      const closestIndex = computeFocusedSlotIndex();
      return closestIndex >= 0 ? closestIndex : selectedSourceIndex;
    },
    getViewState() {
      return shellViewState(shell, { activeIndex: selectedSourceIndex });
    },
    getCaptureSurface: () => ({
      canvas: shell.renderer.domElement,
      beforeCapture: () => {
        try {
          shell.renderer.render(shell.scene, shell.camera);
        } catch (_) {}
      },
    }),
    openSlideshowAt(index = 0) {
      if (!slideshowEntries.length && !immersiveHrefs.some(Boolean)) return;
      const safeIndex = Math.max(0, Math.min(items.length - 1, index));
      selectedSourceIndex = safeIndex;
      const slideshowIndex = slideshowIndexBySourceIndex.get(safeIndex);
      if (Number.isFinite(slideshowIndex)) {
        readOnlyOverlay?.openAt(slideshowIndex);
        return;
      }
      // Pieces like Three.js and A-Frame can't render in the overlay — navigate to their
      // canonical immersive route instead (same as clicking them on the exhibit wall).
      if (immersiveHrefs[safeIndex]) {
        window.location.assign(immersiveHrefs[safeIndex]);
        return;
      }
      readOnlyOverlay?.openAt(0);
    },
    openFullViewAt(index = 0) {
      this.openSlideshowAt(index);
    },
    closeFullView() {
      readOnlyOverlay?.close();
    },
    isFullViewOpen() {
      return readOnlyOverlay?.isOpen?.() ?? false;
    },
  };
}

window.CreatrImmersiveGallery = {
  NORMALIZED_PRESENTATION_GALLERY_PROFILE,
  EXHIBIT_FRAME_ASPECT,
  bakeOrbitHandPose,
  computeMountedArtworkLayout,
  isCompactImmersiveViewport,
  computeExhibitGridCenterY,
  computeExhibitBottomVisibleY,
  createFloorClickNavigation,
  computeOrbitKeyboardMotion,
  createKeyboardNavigation,
  createRoomHandNavigation,
  createMountedGalleryShell,
  updateMountedGalleryLayout,
  fitMountedGalleryCamera,
  createPresentationSurface,
  drawContainedIntoPresentationSurface,
  syncThreeRendererBackground,
  computeThreeAutoFitView,
  engineLabel,
  getProgressiveExhibitLiveBudget,
  createReadOnlyFullViewOverlay,
  setupImmersiveStageChrome,
  selectProgressiveExhibitSlots,
  sanitizeArtPieceHtml,
  createImmersiveHost,
  resolveSketchFactory,
  createFrameLabel,
  createMultiFrameExhibitWall,
  fitMultiFrameExhibitCamera,
  mountThreeImmersivePiece,
  mountAFrameImmersivePiece,
  mountGalleryPiece,
  mountGalleryImage,
  mountExhibitWall
};
})();
