/*
 * Camera controller: perspective + orthographic cameras, OrbitControls config,
 * zoom/pinch/wheel input, yaw math, layout fit and per-frame camera stepping.
 *
 * Single Source of Truth: `computeDesiredCamera(layout, spherical, userZoomFactor)`
 * is a pure function that iteratively solves for the camera pose that tightly
 * frames the layout bounding box. Initial-fit, refit, resize, layout-change
 * and per-frame updates all go through this one function; the result is hard
 * set (no soft-lerp drift correction).
 */

import { CAMERA_VIEWPORT_INSET_MULTIPLIER, DIAGRAM_PAN_PADDING_WORLD } from '../shared/config.js';
import { normalizeCameraPreset } from '../shared/cameraPresetIds.js';
import { octagonFootprintXZ } from './octagonHull.js';

export const USER_ZOOM_PERSP_MIN = 0.5;
export const USER_ZOOM_PERSP_MAX = 2;

/** Feste horizontale Kameradrehung (Radiant): leicht von rechts (Default fuer alle Standard-Layouts).
 *  Add-on-Layouts koennen via `__CE_PD_SCENE_EXT__.camera.getLayoutProfile(layout)` ein eigenes
 *  `yawFromRight` zurueckliefern. */
export const VIEW_YAW_FROM_RIGHT_RAD_DEFAULT = (10 * Math.PI) / 180;

export const FOV_RAD = (50 * Math.PI) / 180;
export const TAN_HALF_FOV = Math.tan(FOV_RAD / 2);

/* FOV-Morph für den 2D⇄3D-Tween:
 * Beim Projection-Swap würde ein direkter Wechsel Ortho↔Perspective (bei STANDARD-FOV=50°)
 * sichtbar springen, weil Ortho keine Fluchtpunkte hat (parallele Strahlen) und Perspective
 * bei 50° FOV deutliche Verzerrungen zeigt (Balken vorne größer als hinten). Trick:
 * Der Swap findet bei einem sehr kleinen FOV (`PROJECTION_TWEEN_FOV_START_DEG`, derzeit
 * testweise 4°) statt — dort ist Perspective visuell nahe Ortho (Fluchtpunkte weit weg).
 * Während des Tweens fährt der FOV von diesem Low-Wert auf 50° (bzw. umgekehrt), die
 * Kamera-Distanz wird gekoppelt via radius = halfHeight / tan(fov/2), damit das Framing
 * konstant bleibt. */
export const PROJECTION_TWEEN_FOV_START_DEG = 4;
export const PROJECTION_TWEEN_FOV_START_RAD = (PROJECTION_TWEEN_FOV_START_DEG * Math.PI) / 180;
export const PROJECTION_TWEEN_TAN_HALF_FOV_START = Math.tan(PROJECTION_TWEEN_FOV_START_RAD / 2);
export const STANDARD_FOV_DEG = 50;

/** Obere Kappe: Pseudo-Ortho (2D) kann weiter zurück als das rein frontale Fit — bis zur
 *  Default-3D-Distanz (schräg braucht oft mehr Abstand), damit 2D nicht „enger“ wirkt. */
const PSEUDO_ORTHO_MATCH_3D_ZOOM_MAX_RATIO = 1.35;

/* Standard-Polar-Limits für die 3D-Ansicht (OrbitControls).
 * Im Pseudo-Ortho-Modus werden die Limits in configureControls auf [0, π] gelockert
 * (siehe dort), damit controls.update() die frontale phi=π/2-Pose nicht klemmt.
 * Deshalb werden diese festen Werte als SSOT statt `controls.maxPolarAngle` gelesen
 * — sonst würden Pfade, die nach dem Pseudo-Ortho-Exit laufen (z.B. der 2D→3D-Tween),
 * den gelockerten π-Wert erwischen. */
const STANDARD_MIN_POLAR_RAD = 0.1;
const STANDARD_DEFAULT_POLAR_RAD = Math.PI / 2 - 0.1;  /* Original-Startansicht */
const STANDARD_MAX_POLAR_RAD = Math.PI / 2 + 0.15;  /* Kleine Erweiterung für etwas Froschperspektive */

/* Liegender Stairs-Pie: liegt flach in der XZ-Ebene, Stufen wachsen nach oben (+Y).
 * Der horizontale Standard-Polar (≈π/2) würde ihn von der Kante zeigen. Stattdessen
 * schräg von oben herabschauen (~35° von der Vertikalen ⇒ ~55° Elevation), damit Ring
 * und Stufen gut sichtbar sind. */
export const PIE_STAIRS_DEFAULT_POLAR_RAD = (35 * Math.PI) / 180;
/* Max-Polar (untere Orbit-Grenze) für den Stairs-Pie: streifende Fast-Horizontal-Winkel
 * lässt der Tight-Fit-Solver bei der flachen, asymmetrischen Stufen-Bbox zwischen Lösungen
 * springen (sichtbares „Poppen" beim Rotieren). Knapp über dem Boden cappen verhindert das
 * und passt zum „von oben"-Design; der User kann weiterhin gut kippen. */
const PIE_STAIRS_MAX_POLAR_RAD = (68 * Math.PI) / 180;

/** Untere Orbit-Grenze (max. Polarwinkel) pro Layout. Standard = leicht unter Horizont. */
function maxPolarForLayout(layout) {
  if (layout === 'pieStairs') return PIE_STAIRS_MAX_POLAR_RAD;
  return STANDARD_MAX_POLAR_RAD;
}

/**
 * Liest das von Add-ons registrierte Camera-Profil eines Layouts. Add-on-Plugins
 * via `__CE_PD_SCENE_EXT__.camera.getLayoutProfile(layout)` bereitgestellt
 * und kann u. a. enthalten: `yawFromRight`, `initialPolar`, `padMultiplier`,
 * `maxDCap`, `disablePresets`. Free liefert hier null → Default-Pfad.
 */
function getLayoutCameraProfile(layout) {
  const ext = (typeof window !== 'undefined' && window.__CE_PD_SCENE_EXT__ && window.__CE_PD_SCENE_EXT__.camera) || null;
  const fn = ext && ext.getLayoutProfile;
  if (typeof fn === 'function') {
    const p = fn(layout);
    if (p && typeof p === 'object') return p;
  }
  /* pyramid: enge Fit-Punktwolke (_fitHullLocal) — ohne frontalOrthoPointFit nutzt
   * getFrontalOrthoCameraSolve die breitere Mesh-AABB-Mitte und rutscht in 2D nach oben. */
  if (layout === 'pyramid') return { frontalOrthoPointFit: true };
  return null;
}

export function viewYawFromRightRad(layout) {
  const p = getLayoutCameraProfile(layout);
  return p && typeof p.yawFromRight === 'number' ? p.yawFromRight : VIEW_YAW_FROM_RIGHT_RAD_DEFAULT;
}

export function normalizeWheelDeltaY(e) {
  if (e.deltaMode === 1) return e.deltaY * 12;
  if (e.deltaMode === 2) return e.deltaY * 80;
  return e.deltaY;
}

function padForLayout(layout) {
  const p = getLayoutCameraProfile(layout);
  return p && typeof p.padMultiplier === 'number' ? p.padMultiplier : 1;
}

/** Perspective: gleicher relativer Rand wie Pseudo-Ortho, solange Layout kein höheres Pad trägt. */
function perspEffectivePad(layout) {
  const base = padForLayout(layout);
  return base > 1.0001 ? base : base * CAMERA_VIEWPORT_INSET_MULTIPLIER;
}

function defaultPolarFor(layout) {
  const p = getLayoutCameraProfile(layout);
  if (p && typeof p.initialPolar === 'number') return p.initialPolar;
  if (layout === 'pieStairs') return PIE_STAIRS_DEFAULT_POLAR_RAD;
  return STANDARD_DEFAULT_POLAR_RAD;
}

/**
 * User-Raum-Spherical für OrbitControls / computeDesiredCamera: `theta` ist **ohne** Layout-Yaw
 * (yaw wird in computeDesiredCamera addiert). Alle φ liegen in den festen Standard-Orbit-Limits.
 *
 * Surface: Presets greifen nicht — immer dieselbe Standard-Pose wie vor der Preset-Funktion.
 *
 * @param {string} layout
 * @param {string} presetRaw
 * @returns {{ phi: number, theta: number }}
 */
export function resolvePresetSphericalUser(layout, presetRaw) {
  const phi0 = defaultPolarFor(layout);
  const profile = getLayoutCameraProfile(layout);
  /* Add-on-Layouts koennen Presets komplett deaktivieren — dann
   * gilt die Standard-Pose unabhaengig vom uebergebenen Preset. */
  if (profile && profile.disablePresets === true) {
    return { phi: phi0, theta: 0 };
  }
  const key = normalizeCameraPreset(presetRaw);
  if (key === 'default') {
    return { phi: phi0, theta: 0 };
  }
  const yaw = viewYawFromRightRad(layout);
  const clampPhi = (p) => Math.max(STANDARD_MIN_POLAR_RAD, Math.min(STANDARD_MAX_POLAR_RAD, p));
  const dPhiFrog = 0.145;
  const dPhiEl = 0.18;
  const thSide = 0.26;
  const thEl = 0.26;
  switch (key) {
    case 'frog_right':
      return { phi: clampPhi(phi0 + dPhiFrog), theta: thSide };
    case 'frog_left':
      return { phi: clampPhi(phi0 + dPhiFrog), theta: -thSide };
    case 'elevated_right':
      return { phi: clampPhi(phi0 - dPhiEl), theta: thEl };
    case 'elevated_left':
      return { phi: clampPhi(phi0 - dPhiEl), theta: -thEl };
    case 'frontal':
      return { phi: phi0, theta: -yaw };
    default:
      return { phi: phi0, theta: 0 };
  }
}

export function createCameraController({
  THREE,
  state,
  container,
  getLayoutFrame,
  markDirty,
  DAMPING_SETTLE_FRAMES,
  interactionEnabled,
  /** Optional: pro Projection-Tween-Frame aufgerufen mit (easedT, targetProjection),
   *  wobei easedT ∈ [0,1]. Im Reduced-Motion-/Direct-Swap-Pfad einmalig mit t=1.
   *  Wird z.B. für den Screen-Space-Grid-BG-Crossfade in background.js genutzt. */
  onProjectionTweenProgress,
}) {
  /* Es gibt nur eine einzige Projektionskamera. Der "2D-Modus" (state.isOrthographic = true)
   * ist architektonisch eine Pseudo-Ortho: dieselbe PerspectiveCamera mit einem sehr kleinen
   * FOV (`PROJECTION_TWEEN_FOV_START_RAD` / `PROJECTION_TWEEN_FOV_START_DEG`) + frontaler Pose.
   * Bei diesem FOV ist die perspektivische Verzerrung so gering, dass das Bild visuell
   * ununterscheidbar von einer echten OrthographicCamera ist. Vorteil: der 2D⇄3D-Tween kennt keine Projektions-Swap-
   * Unstetigkeit mehr, alles ist eine kontinuierliche FOV- und Pose-Interpolation.
   *
   * Der Init-Pfad (init.js) setzt nach `setCameraTargetFromLayout` bei state.isOrthographic=true
   * den FOV zusätzlich auf den Low-Wert, damit das erste gerenderte Frame den Pseudo-Ortho-
   * Look zeigt. */
  const perspectiveCamera = new THREE.PerspectiveCamera(STANDARD_FOV_DEG, state.width / state.height, 0.1, 1000);
  /* Placeholder; Perspective wird nach `setCameraTargetFromLayout` gesetzt. */
  perspectiveCamera.position.set(0, 4, 12);
  perspectiveCamera.layers.enable(1);

  /* Scratch camera used inside computeDesiredCamera for NDC projection during the
   * distance-fit iteration; keeps the active camera state untouched while solving. */
  const _fitCamera = new THREE.PerspectiveCamera(STANDARD_FOV_DEG, 1, 0.1, 1000);

  const _projPt = new THREE.Vector3();
  const _bMin = new THREE.Vector3();
  const _bMax = new THREE.Vector3();
  /* Scratch-Puffer für die Fit-Punktwolke: 8 AABB-Ecken (Standard) bzw. 16 Punkte
   * eines Achteck-Prismas (pieStairs). Wird pro computeDesiredCamera-Aufruf einmal
   * befüllt und über die Iterationen wiederverwendet (keine Allokation pro Frame). */
  const _fitPts = [];
  for (let i = 0; i < 16; i++) _fitPts.push(new THREE.Vector3());
  const _target = new THREE.Vector3();
  const _position = new THREE.Vector3();
  const _dirScratch = new THREE.Vector3();
  const _camR = new THREE.Vector3();
  const _camU = new THREE.Vector3();
  const _orbitSph = new THREE.Spherical();
  /* Scratch fuer getRestingPose() (read-only Pose-Abfrage durch externe Steuerung). */
  const _restingDir = new THREE.Vector3();
  const _restingSph = new THREE.Spherical();

  /** Zusatz zum gewünschten Fit-Target (Welt), damit Nutzer schieben kann; nach jedem Schritt geklemmt. */
  const _userPanOffset = new THREE.Vector3();
  const _aimTarget = new THREE.Vector3();

  /* PT1-Glättung (erste Ordnung, kritisch gedämpft) für `fitDist` und `target`,
   * damit die Rotation um Bbox-Kanten "kugelartig" weich wirkt. Winkel selbst
   * werden NICHT gefiltert (OrbitControls-Damping reicht); hier wird nur das
   * "Atmen" der tight-fit-Distanz und der Target-Re-Center-Shifts geglättet.
   * SMOOTH_RATE = Anteil pro Frame bei 60 FPS (Zeitkonstante τ ≈ -ln(1-0.2)/60 s ≈ 85 ms). */
  const SMOOTH_RATE = 0.2;
  let _fitDistSmoothed = 0;
  const _targetSmoothed = new THREE.Vector3();
  let _smoothInitialized = false;
  /** Perspektive nach Wake: PT1-alpha kappen, weiche Annäherung an neuen Layout-Frame. */
  const POST_WAKE_PERSP_ALPHA_CAP = 0.14;
  let _postWakePerspSoftFrames = 0;

  function snapSmoothingTo(desired) {
    _fitDistSmoothed = desired.fitDist;
    _targetSmoothed.copy(desired.target);
    _smoothInitialized = true;
  }

  /* OrbitControls-Referenz; der Aufrufer instanziiert das OrbitControls-Objekt selbst
   * (wegen context-loss / hibernate / wake-Lifecycle, der init.js-lokalen Renderer-State
   * benötigt) und reicht es hier per `attachControls()` weiter. */
  let controls = null;

  /* Inertia-Rollout-Fenster: mit dampingFactor 0.05 klingt die Restbewegung in
   * ~90 Frames (1.5 s @ 60fps) auf <1 % ab. Scroll-Freeze soll den Render-Loop
   * während dieses Fensters nicht abwürgen. */
  const INERTIA_ROLLOUT_MS = 1500;
  let _inertiaUntil = 0;
  const _onOrbitStart = () => { _inertiaUntil = 0; };
  const _onOrbitEnd = () => { _inertiaUntil = performance.now() + INERTIA_ROLLOUT_MS; };

  function attachControls(ctrl) {
    /* Alte Listener detachen, falls Controls neu angehängt werden (Wake/Context-Loss). */
    if (controls) {
      try { controls.removeEventListener('start', _onOrbitStart); } catch (_) { /* ignore */ }
      try { controls.removeEventListener('end', _onOrbitEnd); } catch (_) { /* ignore */ }
    }
    controls = ctrl;
    _inertiaUntil = 0;
    controls.addEventListener('start', _onOrbitStart);
    controls.addEventListener('end', _onOrbitEnd);
    /* Controls werden nur in "externe Kamera-Pose"-Pfaden neu attached
     * (Init, Wake nach Hibernate, WebGL-Context-Recover). Alter PT1-Filter-State
     * passt nicht — invalidieren. Init: erster stepPerspectiveCamera snappt auf desired.
     * Wake/Recover: nach Restore von Position/Target seedPerspectiveSmoothingToCurrentPose()
     * aufrufen, sonst ersetzt snapSmoothingTo(desired) die Hibernate-Distanz → Sprung. */
    _smoothInitialized = false;
  }

  function hasActiveInertia() {
    return _inertiaUntil > 0 && performance.now() < _inertiaUntil;
  }

  function configureControls(ctrl) {
    ctrl.enableDamping = true;
    ctrl.dampingFactor = 0.05;
    ctrl.autoRotate = false;
    if (state.isOrthographic) {
      /* Pseudo-Ortho steht per Design bei phi=π/2 (Welt-frontal). Würden hier die
       * strengen 3D-Limits (0.1 bis π/2-0.1 ≈ 1.47) gesetzt, würde das nächste
       * controls.update() die Kamera auf phi=1.47 klemmen → sichtbarer Sprung
       * (~5.7°) am Ende des 3D→2D-Tweens. Rotation ist im 2D-Modus ohnehin
       * deaktiviert (enableRotate=false unten), also dürfen die Limits hier
       * unbeschwert komplett geöffnet werden. */
      ctrl.minPolarAngle = 0;
      ctrl.maxPolarAngle = Math.PI;
    } else {
      ctrl.minPolarAngle = STANDARD_MIN_POLAR_RAD;
      ctrl.maxPolarAngle = maxPolarForLayout(state.layout);
    }
    ctrl.autoRotateSpeed = 0.4;
    if ('ontouchstart' in window) { ctrl.rotateSpeed = 0.4; ctrl.panSpeed = 0.4; }
    ctrl.enableZoom = false;
    ctrl.enableRotate = interactionEnabled && !state.isOrthographic;
    /* Pannen läuft über eigene Pointer-Handler + Offsets (nicht OrbitControls.pan). */
    ctrl.enablePan = false;
    /* Kein `touches.TWO`: zwei Finger werden vom Diagramm-Pinch/Pan übernommen. */
    if (THREE.TOUCH) {
      ctrl.touches = { ONE: THREE.TOUCH.ROTATE };
    }
  }

  function setZoomLimits() {
    /* Einheitlicher Pfad für 2D- und 3D-Modus: zoom-property der Kamera wird nicht genutzt
     * (enableZoom=false; Zoom läuft immer über userZoomFactorPersp via applyZoom). Die
     * Distance-Limits dienen defensiv für Dolly-Fallbacks. */
    const d = Math.max(0.05, state.layoutDistancePersp * Math.max(USER_ZOOM_PERSP_MIN, state.userZoomFactorPersp));
    controls.minDistance = d * 0.02;
    controls.maxDistance = d * 500;
    controls.minZoom = 0;
    controls.maxZoom = Infinity;
  }

  /** Resume-Pfad: restaurierte Kamera-Distanz -> layoutDistancePersp ableiten.
   *  Im 2D-Modus deaktiviert: dort ist die Kamera-Distanz nicht die "tight-fit"-Distance
   *  sondern die fov-low-skalierte Pseudo-Ortho-Distance, und state.layoutDistancePersp
   *  wurde beim letzten setCameraTargetFromLayout korrekt gesetzt. */
  function syncLayoutDistanceFromCamera() {
    if (state.isOrthographic) return;
    const uf = Math.max(USER_ZOOM_PERSP_MIN, state.userZoomFactorPersp);
    const dAct = perspectiveCamera.position.distanceTo(controls.target);
    state.layoutDistancePersp = dAct / uf;
    state.layoutDistanceRefPersp = state.layoutDistancePersp;
  }

  /**
   * Nach Hibernate/Wake oder Context-Recover: `attachControls` invalidiert den PT1-State
   * (`_smoothInitialized = false`). Ohne Re-Seed ersetzt der erste `stepPerspectiveCamera`
   * die wiederhergestellte Pose durch `snapSmoothingTo(desired)` → Sprung vs. Snapshot.
   * Hier werden `_fitDistSmoothed` / `_targetSmoothed` aus der aktuellen Kamera abgeleitet.
   */
  function seedPerspectiveSmoothingToCurrentPose() {
    if (state.isOrthographic || !controls) return;
    const dAct = perspectiveCamera.position.distanceTo(controls.target);
    if (dAct < 1e-10) return;
    const uf = Math.max(USER_ZOOM_PERSP_MIN, state.userZoomFactorPersp);
    _fitDistSmoothed = dAct / uf;
    _targetSmoothed.copy(controls.target);
    _smoothInitialized = true;
    state.layoutDistancePersp = _fitDistSmoothed;
    state.layoutDistanceRefPersp = _fitDistSmoothed;
  }

  function schedulePostWakePerspSoftBlend(frameCount) {
    if (state.isOrthographic) return;
    const n = Math.max(0, Math.floor(Number(frameCount)) || 0);
    if (n <= 0) return;
    _postWakePerspSoftFrames = Math.max(_postWakePerspSoftFrames, n);
  }

  /** Pseudo-Ortho: Frontal-Pose aus Fit-Distanz + Target (ohne erneuten Layout-Solve). */
  function _applyOrthoPoseFromFitAndTarget(fitDist, target) {
    if (!controls) return;
    const userZ = Math.max(USER_ZOOM_PERSP_MIN, state.userZoomFactorPersp);
    const halfHAtTarget = fitDist * userZ * TAN_HALF_FOV;
    const radius = halfHAtTarget / PROJECTION_TWEEN_TAN_HALF_FOV_START;
    controls.target.copy(target);
    perspectiveCamera.position.set(target.x, target.y, target.z + radius);
    perspectiveCamera.up.set(0, 1, 0);
    perspectiveCamera.lookAt(target);
    perspectiveCamera.updateMatrixWorld(true);
  }

  /* Nach Wake (2D): PT1 gibt es nicht — der erste `applyPseudoOrthoPose` snapt hart von Snapshot-Pose
   *  auf den neuen Layout-Frame. Kurz weich einpendeln, solange Overlay schon weg ist. */
  let _orthoWakeBlendRemain = 0;
  const _orthoWakeTargetSm = new THREE.Vector3();
  let _orthoWakeFitSm = 0;

  function _seedOrthoWakeFromCurrentPose() {
    const t = controls.target;
    const p = perspectiveCamera.position;
    const dz = p.z - t.z;
    if (!Number.isFinite(dz) || Math.abs(dz) < 1e-8) return false;
    const userZ = Math.max(USER_ZOOM_PERSP_MIN, state.userZoomFactorPersp);
    const halfHAtTarget = dz * PROJECTION_TWEEN_TAN_HALF_FOV_START;
    _orthoWakeFitSm = halfHAtTarget / (userZ * TAN_HALF_FOV);
    _orthoWakeTargetSm.copy(t);
    return true;
  }

  function beginOrthoWakeCameraBlend(frameBudget = 16) {
    if (!state.isOrthographic || !controls) return;
    const n = Math.max(0, Math.floor(Number(frameBudget)) || 0);
    if (n <= 0) return;
    if (!_seedOrthoWakeFromCurrentPose()) return;
    _orthoWakeBlendRemain = Math.max(_orthoWakeBlendRemain, n);
  }

  function isOrthoWakeBlending() {
    return _orthoWakeBlendRemain > 0;
  }

  /**
   * @returns {boolean} true solange noch Blend-Frames übrig (Scene weiter animieren).
   */
  function stepOrthoWakeCameraBlend(dtFactor) {
    if (_orthoWakeBlendRemain <= 0 || !state.isOrthographic || !controls) return false;
    if (_orthoWakeBlendRemain === 1) {
      _orthoWakeBlendRemain = 0;
      _applyPseudoOrthoPose();
      markDirty(1);
      return false;
    }
    const sol = getFrontalOrthoCameraSolve();
    /* Ziel = Frontal-Fit + Nutzer-Pan — nicht nur sol.target (sonst Sprung zur Mitte). */
    const aimEnd = sol.target.clone().add(_userPanOffset);
    clampWorldTargetToLayoutFrame(aimEnd, state.layout);
    const alpha = Math.min(1, SMOOTH_RATE * 0.48 * dtFactor);
    _orthoWakeFitSm += (sol.fitDist - _orthoWakeFitSm) * alpha;
    _orthoWakeTargetSm.lerp(aimEnd, alpha);
    _applyOrthoPoseFromFitAndTarget(_orthoWakeFitSm, _orthoWakeTargetSm);
    state.layoutDistancePersp = _orthoWakeFitSm;
    state.layoutDistanceRefPersp = _orthoWakeFitSm;
    setZoomLimits();
    _orthoWakeBlendRemain -= 1;
    markDirty(1);
    return _orthoWakeBlendRemain > 0;
  }

  function applyZoom(scale) {
    state.userZoomFactorPersp = Math.max(USER_ZOOM_PERSP_MIN, Math.min(USER_ZOOM_PERSP_MAX, state.userZoomFactorPersp * scale));
    if (state.isOrthographic) {
      /* Pseudo-Ortho: userZoomFactorPersp-Änderung muss direkt in die Kamera-Position
       * gespiegelt werden, da stepPerspectiveCamera im 2D-Modus pausiert. Im 3D-Modus
       * übernimmt der PT1-gefilterte stepPerspectiveCamera das automatisch. */
      _orthoWakeBlendRemain = 0;
      _applyPseudoOrthoPose();
    }
    markDirty(DAMPING_SETTLE_FRAMES);
  }

  /** Klemme Look-at-Ziel an erweiterte Layout-AABB (Diagramm + Padding). */
  function clampWorldTargetToLayoutFrame(target, layout) {
    const fr = getLayoutFrame(layout);
    if (!fr) return;
    const pad = DIAGRAM_PAN_PADDING_WORLD;
    const hx = fr.size.x / 2 + pad;
    const hy = fr.size.y / 2 + pad;
    const hz = fr.size.z / 2 + pad;
    const c = fr.center;
    target.x = Math.max(c.x - hx, Math.min(c.x + hx, target.x));
    target.y = Math.max(c.y - hy, Math.min(c.y + hy, target.y));
    target.z = Math.max(c.z - hz, Math.min(c.z + hz, target.z));
  }

  function applyPanPixelDelta(dx, dy) {
    if (!controls) return;
    const dist = perspectiveCamera.position.distanceTo(controls.target);
    const td = dist * Math.tan((perspectiveCamera.fov * Math.PI / 180) / 2);
    const h = Math.max(1, container.clientHeight);
    const f = (2 * td) / h;
    _camR.setFromMatrixColumn(perspectiveCamera.matrix, 0);
    _camU.setFromMatrixColumn(perspectiveCamera.matrix, 1);
    _userPanOffset.addScaledVector(_camR, -dx * f);
    _userPanOffset.addScaledVector(_camU, dy * f);
    state.userHasOrbited = true;
    /* Pseudo-Ortho: kein stepPerspectiveCamera — Pose sofort aus Offset neu bauen. */
    if (state.isOrthographic) {
      _applyPseudoOrthoPose();
    }
    markDirty(DAMPING_SETTLE_FRAMES);
  }

  function onPerspectiveWheel(e) {
    if (!interactionEnabled) return;
    e.preventDefault();
    applyZoom(Math.pow(0.92, -normalizeWheelDeltaY(e) * 0.01));
  }

  const touchGesturePointers = new Map();
  let lastPinchSep = 0;
  let lastTouchCentroid = null;
  /** @type {{ pointerId: number, lx: number, ly: number } | null} */
  let orthoSingleTouchPan = null;
  let mousePanActive = false;
  let mousePanLastX = 0;
  let mousePanLastY = 0;

  function touchCentroid() {
    if (touchGesturePointers.size !== 2) return null;
    const pts = [...touchGesturePointers.values()];
    return { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 };
  }
  function touchSeparation() {
    if (touchGesturePointers.size !== 2) return 0;
    const pts = [...touchGesturePointers.values()];
    return Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
  }

  function onGesturePointerDownCapture(e) {
    if (!interactionEnabled) return;
    if (e.pointerType !== 'touch') return;
    touchGesturePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
    if (touchGesturePointers.size === 2) {
      lastPinchSep = touchSeparation();
      lastTouchCentroid = touchCentroid();
      orthoSingleTouchPan = null;
    } else if (touchGesturePointers.size === 1 && state.isOrthographic) {
      orthoSingleTouchPan = { pointerId: e.pointerId, lx: e.clientX, ly: e.clientY };
    }
  }

  function onGesturePointerMoveCapture(e) {
    if (!interactionEnabled) return;
    if (e.pointerType !== 'touch') return;
    if (!touchGesturePointers.has(e.pointerId)) return;
    touchGesturePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
    if (touchGesturePointers.size === 2) {
      e.preventDefault();
      try { e.stopImmediatePropagation(); } catch (_) { /* ignore */ }
      const c = touchCentroid();
      const d = touchSeparation();
      if (lastTouchCentroid && c) {
        applyPanPixelDelta(c.x - lastTouchCentroid.x, c.y - lastTouchCentroid.y);
      }
      if (lastPinchSep > 1e-6 && d > 1e-6) {
        applyZoom(lastPinchSep / d);
      }
      lastTouchCentroid = c;
      lastPinchSep = d;
      return;
    }
    if (state.isOrthographic && orthoSingleTouchPan && e.pointerId === orthoSingleTouchPan.pointerId) {
      const dx = e.clientX - orthoSingleTouchPan.lx;
      const dy = e.clientY - orthoSingleTouchPan.ly;
      orthoSingleTouchPan.lx = e.clientX;
      orthoSingleTouchPan.ly = e.clientY;
      applyPanPixelDelta(dx, dy);
      e.preventDefault();
    }
  }

  function onGesturePointerUpCapture(e) {
    touchGesturePointers.delete(e.pointerId);
    if (orthoSingleTouchPan && orthoSingleTouchPan.pointerId === e.pointerId) {
      orthoSingleTouchPan = null;
    }
    if (touchGesturePointers.size < 2) {
      lastPinchSep = 0;
      lastTouchCentroid = null;
    }
  }

  function onMousePanDownCapture(e) {
    if (!interactionEnabled) return;
    if (e.pointerType === 'touch') return;
    if (state.isOrthographic) {
      if (e.button !== 0 && e.button !== 2) return;
    } else if (e.button !== 2) {
      return;
    }
    mousePanActive = true;
    mousePanLastX = e.clientX;
    mousePanLastY = e.clientY;
    try { e.preventDefault(); } catch (_) { /* ignore */ }
  }

  function onMousePanMoveCapture(e) {
    if (!mousePanActive || e.pointerType === 'touch') return;
    if (state.isOrthographic) {
      if (((e.buttons & 1) === 0) && ((e.buttons & 2) === 0)) {
        mousePanActive = false;
        return;
      }
    } else if ((e.buttons & 2) === 0) {
      mousePanActive = false;
      return;
    }
    const dx = e.clientX - mousePanLastX;
    const dy = e.clientY - mousePanLastY;
    mousePanLastX = e.clientX;
    mousePanLastY = e.clientY;
    applyPanPixelDelta(dx, dy);
  }

  function onMousePanUpCapture(e) {
    if (e.pointerType === 'touch') return;
    mousePanActive = false;
  }

  function onCanvasContextMenu(e) {
    if (!interactionEnabled) return;
    /* 2D+3D: Rechtsklick zum Pannen — Browser-Menü unterbinden. */
    e.preventDefault();
  }

  const gestureOpts = { capture: true, passive: false };
  function bindPerspectivePinch(el) {
    el.addEventListener('pointerdown', onGesturePointerDownCapture, gestureOpts);
    el.addEventListener('pointermove', onGesturePointerMoveCapture, gestureOpts);
    el.addEventListener('pointerup', onGesturePointerUpCapture, gestureOpts);
    el.addEventListener('pointercancel', onGesturePointerUpCapture, gestureOpts);
    el.addEventListener('pointerdown', onMousePanDownCapture, gestureOpts);
    el.addEventListener('pointermove', onMousePanMoveCapture, gestureOpts);
    el.addEventListener('pointerup', onMousePanUpCapture, gestureOpts);
    el.addEventListener('pointercancel', onMousePanUpCapture, gestureOpts);
    el.addEventListener('contextmenu', onCanvasContextMenu);
  }
  function unbindPerspectivePinch(el) {
    el.removeEventListener('pointerdown', onGesturePointerDownCapture, gestureOpts);
    el.removeEventListener('pointermove', onGesturePointerMoveCapture, gestureOpts);
    el.removeEventListener('pointerup', onGesturePointerUpCapture, gestureOpts);
    el.removeEventListener('pointercancel', onGesturePointerUpCapture, gestureOpts);
    el.removeEventListener('pointerdown', onMousePanDownCapture, gestureOpts);
    el.removeEventListener('pointermove', onMousePanMoveCapture, gestureOpts);
    el.removeEventListener('pointerup', onMousePanUpCapture, gestureOpts);
    el.removeEventListener('pointercancel', onMousePanUpCapture, gestureOpts);
    el.removeEventListener('contextmenu', onCanvasContextMenu);
  }

  /** Setzt Kamera-Position und -Orientierung für den Pseudo-Ortho-Modus: frontale Welt-Pose
   *  (theta=0, phi=π/2) mit fov-low-gekoppeltem Radius, sodass die sichtbare halfHeight am
   *  Target-Z der "echten" Ortho-Ansicht bei aktuellem userZoomFactorPersp entspricht.
   *  Aufgerufen aus applyZoom (2D-Mode Zoom), aus setCameraTargetFromLayout beim Init und
   *  Layout-Wechsel sowie aus recalcFit bei Resize im 2D-Modus. */
  function _applyPseudoOrthoPose() {
    if (!controls) return;
    const { fitDist, target } = getFrontalOrthoCameraSolve();
    const aim = target.clone().add(_userPanOffset);
    clampWorldTargetToLayoutFrame(aim, state.layout);
    _userPanOffset.copy(aim).sub(target);
    _applyOrthoPoseFromFitAndTarget(fitDist, aim);
  }

  function currentAspect() {
    const w = container.clientWidth || state.width;
    const h = container.clientHeight || state.height;
    return h > 0 ? w / h : 1;
  }

  /**
   * Füllt `_fitPts` mit den Punkten, deren NDC-Projektion den Fit treibt, und gibt
   * deren Anzahl zurück. Standard: die 8 AABB-Ecken. Für `pieStairs` (flach liegende
   * runde Scheibe) stattdessen ein Achteck-Prisma (16 Punkte) — die 4 leeren Grundriss-
   * Ecken des Quaders fallen weg, sodass die Kamera enger und mittiger fittet.
   */
  function buildFitPoints(layout) {
    if (layout === 'pieStairs') {
      const fp = octagonFootprintXZ(_bMin.x, _bMax.x, _bMin.z, _bMax.z);
      let n = 0;
      for (let i = 0; i < 8; i++) {
        const x = fp[i][0], z = fp[i][1];
        _fitPts[n++].set(x, _bMin.y, z);
        _fitPts[n++].set(x, _bMax.y, z);
      }
      return n;
    }
    let n = 0;
    for (let xi = 0; xi <= 1; xi++) for (let yi = 0; yi <= 1; yi++) for (let zi = 0; zi <= 1; zi++) {
      _fitPts[n++].set(xi ? _bMax.x : _bMin.x, yi ? _bMax.y : _bMin.y, zi ? _bMax.z : _bMin.z);
    }
    return n;
  }

  /**
   * Single Source of Truth für die Perspektiv-Kamera.
   *
   * Iteriert **gemeinsam** Target-Position und Fit-Distanz so, dass die
   * projizierte Bounding-Box exakt mittig (NDC-Schwerpunkt bei (0,0)) und
   * auf den Pad-Limit gefittet sitzt:
   *  - pro Schritt: Bbox-Ecken projizieren → (nMin+nMax)/2 ergibt NDC-Offset
   *    → Target entlang Kamera-Right/Up shiften, um Offset zu eliminieren
   *  - dann Distanz skalieren, so dass max(halfExtentX, halfExtentY) = padLimit
   *  - Abbruch sobald Shift < 1e-4 und |scale-1| < 1e-4
   *
   * Dadurch erscheint jedes Layout bei jedem Orbit-Winkel perfekt zentriert.
   *
   * - theta = 0 entspricht "frontal mit Layout-Yaw"; User-Rotation ist additiv.
   * - Die Layout-Yaw-Korrektur wird *innerhalb* angewandt. Aufrufer übergeben
   *   daher die Yaw-freie (reine User-)theta.
   */
  function computeDesiredCamera(layout, spherical, userZoomFactor) {
    const frame = getLayoutFrame(layout);
    const target = _target.copy(frame.center);

    _bMin.set(
      frame.center.x - frame.size.x / 2,
      frame.center.y - frame.size.y / 2,
      frame.center.z - frame.size.z / 2,
    );
    _bMax.set(
      frame.center.x + frame.size.x / 2,
      frame.center.y + frame.size.y / 2,
      frame.center.z + frame.size.z / 2,
    );

    const aspect = currentAspect();
    const extent = Math.max(frame.extentV, frame.extentH / aspect);
    const effPad = perspEffectivePad(layout);
    const padLimit = 1 / effPad;
    let fitDist = effPad * extent / (2 * TAN_HALF_FOV);
    /* Distanz-Cap haengt vom Layout-Type ab; Add-on-Layouts liefern ein eigenes Cap im
     * Camera-Profil (radial/bubblePack: 18, surface: 20) — Free's Default ist 12. */
    const __camProfile = getLayoutCameraProfile(layout);
    const maxDCap = (__camProfile && typeof __camProfile.maxDCap === 'number') ? __camProfile.maxDCap : 12;
    fitDist = Math.max(0.5, Math.min(maxDCap, fitDist));

    const yaw = viewYawFromRightRad(layout);
    const phi = spherical.phi;
    const theta = spherical.theta + yaw;
    const sinPhi = Math.sin(phi), cosPhi = Math.cos(phi);
    const sinTh = Math.sin(theta), cosTh = Math.cos(theta);

    /* Fit-Punktwolke (konstant über die Iteration, nur die Kamera bewegt sich):
     *  1. Layout liefert eine eigene Punktwolke (frame.fitPoints, z. B. pieStairs:
     *     gestufte Silhouette) → die treibt den Fit.
     *  2. Sonst Fallback auf 8 AABB-Ecken bzw. 16 Achteck-Prisma-Punkte (buildFitPoints). */
    const fitArr = frame.fitPoints && frame.fitPoints.length ? frame.fitPoints : null;
    const nFitPts = fitArr ? (fitArr.length / 3) : buildFitPoints(layout);

    _fitCamera.aspect = aspect;
    _fitCamera.updateProjectionMatrix();

    /* Iteration läuft bewusst ohne userZoomFactor: fitDist ist die "tight-fit"-
     * Distanz bei Zoom = 1. Andernfalls würde die Fit-Schleife den User-Zoom
     * durch Distanz-Anpassung wieder aufheben. userZoomFactor wird erst am
     * Ende als Multiplikator auf die finale Position angewandt. */
    const position = _position;
    /* Under-Relaxation (ω): stabilisiert die Fixpunkt-Iteration bei flachem phi +
     * breitem aspect + hochkant-Bbox. In dieser Konstellation ist camera.right/up
     * numerisch wacklig (Kamera-Up fast parallel zur Blickrichtung), und ohne
     * Dämpfung überschießt jeder Shift/Scale-Schritt in die Gegenrichtung →
     * Divergenz. ω=0.5 halbiert jede Korrektur, das verdoppelt die nötigen Iter,
     * garantiert aber Konvergenz (typisch bei aspect≈2.7 + phi≈0.1rad). */
    /* Add-on-Layouts koennen die Daempfung/Iterationszahl der Fixpunkt-Iteration ueber das
     * Camera-Profil anpassen. Ein langes, diagonal liegendes Band (Roadmap) treibt die
     * gekoppelte Shift/Scale-Iteration bei flachem Winkel sonst in einen Grenzzyklus →
     * die Kamera „zappelt" (mehr Iterationen helfen nicht, nur staerkere Daempfung).
     * Default bleibt 0.5 / 24 fuer alle anderen Layouts. */
    const OMEGA = (__camProfile && typeof __camProfile.fitOmega === 'number') ? __camProfile.fitOmega : 0.5;
    const fitMaxIter = (__camProfile && typeof __camProfile.fitMaxIter === 'number') ? __camProfile.fitMaxIter : 24;
    for (let iter = 0; iter < fitMaxIter; iter++) {
      position.set(
        target.x + fitDist * sinPhi * sinTh,
        target.y + fitDist * cosPhi,
        target.z + fitDist * sinPhi * cosTh,
      );
      _fitCamera.position.copy(position);
      _fitCamera.lookAt(target);
      _fitCamera.updateMatrixWorld(true);

      let nMinX = Infinity, nMaxX = -Infinity, nMinY = Infinity, nMaxY = -Infinity;
      for (let pi = 0; pi < nFitPts; pi++) {
        if (fitArr) _projPt.set(fitArr[pi * 3], fitArr[pi * 3 + 1], fitArr[pi * 3 + 2]);
        else _projPt.copy(_fitPts[pi]);
        _projPt.project(_fitCamera);
        if (_projPt.x < nMinX) nMinX = _projPt.x;
        if (_projPt.x > nMaxX) nMaxX = _projPt.x;
        if (_projPt.y < nMinY) nMinY = _projPt.y;
        if (_projPt.y > nMaxY) nMaxY = _projPt.y;
      }

      const offX = (nMinX + nMaxX) / 2;
      const offY = (nMinY + nMaxY) / 2;
      const halfNX = (nMaxX - nMinX) / 2;
      const halfNY = (nMaxY - nMinY) / 2;
      const maxHalf = Math.max(halfNX, halfNY);

      /* Re-center: NDC-Offset -> Welt-Shift entlang Kamera-Right/Up (relaxiert). */
      let shifted = false;
      if (offX * offX + offY * offY > 1e-8) {
        _camR.setFromMatrixColumn(_fitCamera.matrixWorld, 0);
        _camU.setFromMatrixColumn(_fitCamera.matrixWorld, 1);
        const shiftX = OMEGA * offX * fitDist * TAN_HALF_FOV * aspect;
        const shiftY = OMEGA * offY * fitDist * TAN_HALF_FOV;
        target.addScaledVector(_camR, shiftX);
        target.addScaledVector(_camU, shiftY);
        shifted = true;
      }

      /* Distanz-Skalierung: max-Halbextent auf padLimit bringen (relaxiert). */
      let scaled = false;
      if (maxHalf > 1e-6) {
        const scale = maxHalf / padLimit;
        if (Math.abs(scale - 1) >= 1e-4) {
          fitDist *= 1 + (scale - 1) * OMEGA;
          scaled = true;
        }
      }

      if (!shifted && !scaled) break;
    }

    /* Finale Position: fitDist bleibt als "tight fit"-Referenz; userZoomFactor
     * skaliert nur die tatsächliche Kamera-Distanz (Zoom in = < 1 = näher). */
    {
      const effDist = fitDist * userZoomFactor;
      position.set(
        target.x + effDist * sinPhi * sinTh,
        target.y + effDist * cosPhi,
        target.z + effDist * sinPhi * cosTh,
      );
    }

    return { target: target.clone(), position: position.clone(), fitDist };
  }

  /**
   * Frontales Kamera-Fit (phi=π/2, User-theta=-Layout-Yaw → Welt frontal wie Pseudo-Ortho)
   *  via dieselbe NDC-Iteration wie 3D. `fitDist` wird optional bis zur Default-3D-Distanz
   *  angehoben (gekappelt), damit der 2D-Modus nicht enger wirkt als die schräge Ansicht.
   *
   * @returns {{ fitDist: number, target: import('three').Vector3 }}
   */
  function getFrontalOrthoCameraSolve() {
    const layout = state.layout;
    const yaw = viewYawFromRightRad(layout);
    const frontalDesired = computeDesiredCamera(layout, { phi: Math.PI / 2, theta: -yaw }, 1);
    let { fitDist } = frontalDesired;

    /* Layouts mit `frontalOrthoPointFit` (vom Add-on im Camera-Profil gemeldet):
     * Die 2D-Pose blickt frontal entlang −Z (up=+Y) und wird mit sehr kleinem FOV
     * (ortho-artig) gerendert. Ein in die Tiefe gespanntes/diagonales Diagramm hat
     * große Z-Tiefe, daher ist die perspektivische FOV-50-Zentrierung gegenüber der
     * Ortho-Darstellung verschoben (Inhalt rutscht aus dem Canvas). Mitte + Skalierung
     * deshalb direkt aus der Fit-Punktwolke in Welt-XY ableiten — exakt und
     * FOV-unabhängig (Blick entlang −Z ⇒ Welt-X→Screen-X, Welt-Y→Screen-Y). */
    const __profile = getLayoutCameraProfile(layout);
    const fp = getLayoutFrame(layout).fitPoints;
    if (__profile && __profile.frontalOrthoPointFit && fp && fp.length >= 3) {
      let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity;
      for (let i = 0; i < fp.length; i += 3) {
        const x = fp[i]; const y = fp[i + 1];
        if (x < minX) minX = x; if (x > maxX) maxX = x;
        if (y < minY) minY = y; if (y > maxY) maxY = y;
      }
      const target = frontalDesired.target.clone();
      target.x = (minX + maxX) / 2;
      target.y = (minY + maxY) / 2;
      const aspect = currentAspect();
      const effPad = perspEffectivePad(layout);
      const halfNeeded = Math.max((maxY - minY) / 2, ((maxX - minX) / 2) / aspect) * effPad;
      const cap = (typeof __profile.maxDCap === 'number') ? __profile.maxDCap : 12;
      const orthoFit = Math.max(0.5, Math.min(cap, halfNeeded / TAN_HALF_FOV));
      return { fitDist: orthoFit, target };
    }

    const presetSph = resolvePresetSphericalUser(layout, state.cameraPreset);
    const obliqueDesired = computeDesiredCamera(layout, presetSph, 1);
    const f = frontalDesired.fitDist;
    const o = obliqueDesired.fitDist;
    if (f > 1e-8 && o > 0) {
      const ratio = o / f;
      const scale = Math.max(1, Math.min(ratio, PSEUDO_ORTHO_MATCH_3D_ZOOM_MAX_RATIO));
      fitDist = f * scale;
    }
    return { fitDist, target: frontalDesired.target };
  }

  function getFrontalPerspectiveFitDist() {
    return getFrontalOrthoCameraSolve().fitDist;
  }

  /**
   * Initial-/Refit-Pfad: berechnet die Default-Pose (phi = Layout-Polar,
   * theta = 0 → nur Yaw) und mutiert `controls.target` + Distanz-State.
   * Liefert {position, lookAt} für Aufrufer, die die Kamera selbst setzen
   * (Perspektive: refitPerspectiveCamera; Ortho: applyCameraResult).
   */
  function setCameraTargetFromLayout(layout) {
    if (!state.userHasOrbited) {
      _userPanOffset.set(0, 0, 0);
    }
    if (state.isOrthographic) {
      /* Pseudo-Ortho: frontale Pose bei fov-low-gekoppeltem Radius. Die konkrete
       * Kamera-Positionierung passiert hier rein als Datenrückgabe; der Aufrufer
       * setzt Position/lookAt anschließend selbst (analog zum 3D-Branch). */
      const { fitDist, target: center } = getFrontalOrthoCameraSolve();
      const aim = center.clone().add(_userPanOffset);
      clampWorldTargetToLayoutFrame(aim, layout);
      _userPanOffset.copy(aim).sub(center);
      const userZ = Math.max(USER_ZOOM_PERSP_MIN, state.userZoomFactorPersp);
      const halfHAtTarget = fitDist * userZ * TAN_HALF_FOV;
      const radius = halfHAtTarget / PROJECTION_TWEEN_TAN_HALF_FOV_START;
      state.layoutDistancePersp = fitDist;
      state.layoutDistanceRefPersp = fitDist;
      controls.target.copy(aim);
      /* PT1-Filter wird im 2D-Modus nicht genutzt (stepPerspectiveCamera pausiert),
       * aber beim späteren 2D→3D-Wechsel greift er wieder — daher jetzt konsistent
       * seeden, damit der Tween ohne Spike startet. */
      snapSmoothingTo({ fitDist, target: aim.clone() });
      return { position: new THREE.Vector3(aim.x, aim.y, aim.z + radius), lookAt: aim.clone() };
    }
    const presetSph = resolvePresetSphericalUser(layout, state.cameraPreset);
    _orbitSph.phi = presetSph.phi;
    _orbitSph.theta = presetSph.theta;
    _orbitSph.radius = 1;
    const desired = computeDesiredCamera(layout, _orbitSph, 1);
    state.layoutDistancePersp = desired.fitDist;
    state.layoutDistanceRefPersp = desired.fitDist;
    /* Initial-/Layout-/Refit-Pfad fällt auf den konfigurierten Start-Zoom zurück
     * (Default-Zoom aus dem Backend), NICHT hart auf 1. Der 2D/3D-Toggle läuft
     * hier nicht durch — er bewahrt den aktuellen User-Zoom (siehe tweenToProjection). */
    state.userZoomFactorPersp = (typeof state.defaultZoom === 'number' && state.defaultZoom > 0) ? state.defaultZoom : 1;
    const aim = desired.target.clone().add(_userPanOffset);
    clampWorldTargetToLayoutFrame(aim, layout);
    _userPanOffset.copy(aim).sub(desired.target);
    const delta = aim.clone().sub(desired.target);
    const newPos = desired.position.clone().add(delta);
    controls.target.copy(aim);
    /* Filter hart auf Ziel snappen, damit Initial-Load / Layoutwechsel / Resize
     * nicht durch die PT1-Glättung laufen (würde Jiggle reintragen). */
    snapSmoothingTo({ fitDist: desired.fitDist, target: aim.clone() });
    return { position: newPos, lookAt: aim.clone() };
  }

  function applyCameraResult(cam) {
    perspectiveCamera.position.copy(cam.position);
    controls.target.copy(cam.lookAt);
    if (cam.up) perspectiveCamera.up.copy(cam.up);
    else perspectiveCamera.up.set(0, 1, 0);
    perspectiveCamera.lookAt(controls.target);
  }

  /**
   * Per-Frame-Update: User-Orbit-Input über OrbitControls aufnehmen, Winkel
   * extrahieren, Desired-Pose via computeDesiredCamera berechnen und hart
   * snappen. Keine Soft-Lerp-Korrekturen — der erste Frame sitzt schon korrekt.
   *
   * `dtFactor` wird nur noch für Stabilität bei Orbit-Damping weitergereicht
   * (OrbitControls selbst), spielt im Fit-Pfad keine Rolle mehr.
   */
  function stepPerspectiveCamera(dtFactor) {
    /* Während des 2D/3D-Toggle-Tweens steuert die Tween-Routine die Kamera-Pose direkt;
     * der PT1-Filter hier würde die Interpolation sonst in jedem Frame überschreiben. */
    if (_tweenActive) return;
    /* Externe Steuerung aktiv: Pose wird von aussen gesetzt, nicht antasten. */
    if (_externalControl) return;
    /* Im Pseudo-Ortho-Modus (state.isOrthographic=true) wird die Kamera direkt über
     * _applyPseudoOrthoPose verwaltet; der Orbit-Fit-Filter würde sie auf die Default-
     * 3D-Pose (inkl. Layout-Yaw) zurückziehen. Der animate()-Loop ruft diese Funktion
     * im 2D-Modus ohnehin nicht — dieser Guard ist Defense-in-Depth. */
    if (state.isOrthographic) return;
    controls.update();
    _dirScratch.subVectors(perspectiveCamera.position, controls.target);
    if (_dirScratch.lengthSq() < 1e-10) {
      /* Degenerate: camera sitzt auf Target. Preset-Spherical nehmen. */
      const presetSph = resolvePresetSphericalUser(state.layout, state.cameraPreset);
      _orbitSph.phi = presetSph.phi;
      _orbitSph.theta = presetSph.theta;
    } else {
      _orbitSph.setFromVector3(_dirScratch);
      /* Layout-Yaw abziehen: computeDesiredCamera addiert ihn intern wieder. */
      _orbitSph.theta -= viewYawFromRightRad(state.layout);
    }
    const desired = computeDesiredCamera(state.layout, _orbitSph, state.userZoomFactorPersp);
    _aimTarget.copy(desired.target).add(_userPanOffset);
    clampWorldTargetToLayoutFrame(_aimTarget, state.layout);
    _userPanOffset.copy(_aimTarget).sub(desired.target);

    /* PT1-Glättung: nur fitDist und target werden gefiltert, Winkel kommen
     * von OrbitControls (eigenes Damping). Beim ersten Frame nach Reset hart snappen. */
    if (!_smoothInitialized) {
      snapSmoothingTo({ fitDist: desired.fitDist, target: _aimTarget.clone() });
    } else {
      let alpha = Math.min(1, SMOOTH_RATE * dtFactor);
      if (_postWakePerspSoftFrames > 0) {
        alpha = Math.min(alpha, POST_WAKE_PERSP_ALPHA_CAP);
        _postWakePerspSoftFrames -= 1;
      }
      _fitDistSmoothed += (desired.fitDist - _fitDistSmoothed) * alpha;
      _targetSmoothed.lerp(_aimTarget, alpha);
    }

    /* Pose aus geglätteten Werten ableiten. userZoomFactor bleibt externer
     * Multiplikator (kein Filter — Zoom soll direkt reagieren). */
    const yaw = viewYawFromRightRad(state.layout);
    const phi = _orbitSph.phi;
    const theta = _orbitSph.theta + yaw;
    const sinPhi = Math.sin(phi), cosPhi = Math.cos(phi);
    const sinTh = Math.sin(theta), cosTh = Math.cos(theta);
    const effDist = _fitDistSmoothed * state.userZoomFactorPersp;
    perspectiveCamera.position.set(
      _targetSmoothed.x + effDist * sinPhi * sinTh,
      _targetSmoothed.y + effDist * cosPhi,
      _targetSmoothed.z + effDist * sinPhi * cosTh,
    );
    controls.target.copy(_targetSmoothed);
    perspectiveCamera.lookAt(_targetSmoothed);

    state.layoutDistancePersp = _fitDistSmoothed;
    state.layoutDistanceRefPersp = _fitDistSmoothed;
    setZoomLimits();

    /* Solange der Filter noch nicht konvergiert ist, Scene wachhalten. */
    const dFit = Math.abs(desired.fitDist - _fitDistSmoothed);
    const dTgt = _targetSmoothed.distanceTo(_aimTarget);
    if (dFit > 1e-4 || dTgt > 1e-4) markDirty(1);
  }

  /**
   * PT1-Filter in einem Schritt kollabieren: alpha = 1 → Kamera steht exakt auf
   * `desired`, nicht auf einem Zwischenwert. Nötig vor dem Hibernate-Final-Render,
   * damit der Snapshot die Ziel-Pose zeigt und beim Wake nicht wegspringt.
   */
  function collapseSmoothing() {
    if (state.isOrthographic) return;
    if (!controls) return;
    /* dtFactor gross genug, dass alpha = min(1, SMOOTH_RATE * dtFactor) = 1 */
    stepPerspectiveCamera(1e6);
  }

  function refitPerspectiveCamera(sceneInitComplete) {
    const cam = setCameraTargetFromLayout(state.layout);
    controls.target.copy(cam.lookAt);
    perspectiveCamera.position.copy(cam.position);
    perspectiveCamera.lookAt(cam.lookAt);
    perspectiveCamera.updateMatrixWorld(true);
    if (sceneInitComplete) {
      controls.enableDamping = false;
      controls.update();
      controls.enableDamping = true;
    }
  }

  /* ───────────────────── 2D ⇄ 3D Toggle (einheitliche FOV-Morph) ─────────────────────
   * Der 2D-Modus ist eine **Pseudo-Ortho**: dieselbe PerspectiveCamera mit Low-FOV (siehe
   * PROJECTION_TWEEN_FOV_START_RAD / PROJECTION_TWEEN_FOV_START_DEG) und frontaler Welt-Pose.
   * Bei diesem FOV ist die perspektivische Verzerrung so minimal, dass das Bild visuell
   * ununterscheidbar von einer echten OrthographicCamera ist — und der 2D⇄3D-Übergang ist
   * eine einzige, unterbrechungsfreie FOV- und Pose-Interpolation (keine Projektions-Swap-
   * Kante mehr).
   *
   *   3D → 2D: FOV 50° → PROJECTION_TWEEN_FOV_START_DEG, Pose rotiert zur Welt-frontalen
   *            Pose (phi=π/2, theta=0), radius wächst proportional (halfHeight-Kopplung).
   *            Am Ende: Pseudo-Ortho aktiv (state.isOrthographic=true, enableRotate=false).
   *   2D → 3D: FOV Low → 50°, Pose rotiert aus der frontalen Pose in die Default-3D-Pose
   *            (inkl. Layout-Yaw), radius verkleinert sich proportional.
   *
   * Der User-Zoom (userZoomFactorPersp) bleibt über beide Richtungen kontinuierlich erhalten. */

  /**
   * Schreibt die Perspective-Kamera in **Welt-Koordinaten** (Spherical relativ zum
   * übergebenen Target). Kein Layout-Yaw-Offset — Aufrufer übergeben bereits die
   * gewünschte Welt-theta. Der Tween interpoliert Spherical-Werte direkt zwischen
   * `samplePerspectiveWorldSpherical()` und einem ausgewählten Ziel-Spherical.
   */
  function _applyPerspectiveWorldPose(target, sph) {
    const sinPhi = Math.sin(sph.phi), cosPhi = Math.cos(sph.phi);
    const sinTh = Math.sin(sph.theta), cosTh = Math.cos(sph.theta);
    perspectiveCamera.position.set(
      target.x + sph.radius * sinPhi * sinTh,
      target.y + sph.radius * cosPhi,
      target.z + sph.radius * sinPhi * cosTh,
    );
    perspectiveCamera.up.set(0, 1, 0);
    perspectiveCamera.lookAt(target);
    perspectiveCamera.updateMatrixWorld(true);
  }

  function _samplePerspectiveWorldSpherical() {
    _dirScratch.subVectors(perspectiveCamera.position, controls.target);
    return new THREE.Spherical().setFromVector3(_dirScratch);
  }

  /* Tween-State: läuft der Render-Loop weiter (via markDirty pro Frame), während
   * stepPerspectiveCamera während des Tweens einen Early-Return macht, damit unsere
   * manuelle Kamera-Interpolation nicht vom PT1-Filter überschrieben wird. */
  let _tweenActive = false;
  let _tweenRaf = null;

  /* Externe Kamerasteuerung (z. B. Add-on-Intro-Animation): solange aktiv, haelt sich
   * der per-Frame-Fit (stepPerspectiveCamera) komplett heraus und ueberschreibt die
   * von aussen via applyWorldPose gesetzte Pose nicht. Neutraler, generischer
   * Baustein — Free selbst nutzt ihn nicht. */
  let _externalControl = false;

  function isProjectionTweening() { return _tweenActive; }

  function _cancelProjectionTween() {
    if (_tweenRaf) {
      cancelAnimationFrame(_tweenRaf);
      _tweenRaf = null;
    }
    _tweenActive = false;
  }

  function _easeInOutCubic(t) {
    return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
  }

  /** Tritt in den Pseudo-Ortho-Modus ein: FOV auf Low-Wert, Flag setzen, Controls neu
   *  konfigurieren (enableRotate=false). Die Pose wird vom Aufrufer gesetzt (Tween-End
   *  bzw. Reduced-Motion-Snap via _applyPseudoOrthoPose). */
  function _enterPseudoOrtho() {
    _userPanOffset.set(0, 0, 0);
    state.isOrthographic = true;
    perspectiveCamera.fov = (PROJECTION_TWEEN_FOV_START_RAD * 180) / Math.PI;
    perspectiveCamera.updateProjectionMatrix();
    if (controls) {
      configureControls(controls);
      setZoomLimits();
      /* OrbitControls: `enableDamping=false` wendet `sphericalDelta` in **einem** Schritt
       * voll an (nicht verwerfen) und setzt sie danach auf 0. Ohne Korrektur springt die
       * Kamera nach dem Tween auf diese „auf einmal gedrehte“ Pose. Anschließend daher
       * die 2D-Pose aus SSOT neu setzen — gleiches Ergebnis wie Tween-Ende, aber ohne
       * Rest-Impuls für die folgenden `update()`-Aufrufe im Ortho-Loop. */
      controls.enableDamping = false;
      controls.update();
      controls.enableDamping = true;
      _applyPseudoOrthoPose();
    }
  }

  /** Verlässt den Pseudo-Ortho-Modus — bewusst minimal: nur das state-Flag. Controls-
   *  Reconfiguration und controls.update() passieren erst, nachdem die Kamera wieder
   *  in einer gültigen 3D-Pose (phi im Standard-Limit-Range) steht; sonst würde
   *  controls.update() die aktuelle phi=π/2-Pose auf maxPolarAngle ≈ 1.47 klemmen
   *  und einen sichtbaren Sprung zu Tween-Beginn erzeugen. Der Aufrufer
   *  (tweenToProjection bzw. der Reduced-Motion-Pfad) ruft configureControls+
   *  controls.update() am Tween-Ende explizit auf. */
  function _exitPseudoOrtho() {
    state.isOrthographic = false;
  }

  /**
   * Cinematic-Cheat-Tween zwischen den Projektionen.
   *
   * @param {'2d'|'3d'} targetProjection
   * @param {{ durationMs?: number, onComplete?: Function }} [opts]
   */
  function tweenToProjection(targetProjection, opts) {
    const o = opts || {};
    const durationMs = o.durationMs != null ? Math.max(0, o.durationMs) : 800;
    const onComplete = typeof o.onComplete === 'function' ? o.onComplete : null;

    _cancelProjectionTween();
    /* Klick gewinnt: Inertia-Fenster schließen, sonst würde der Rollout den Tween
     * überlagern, sobald die Kamera wieder auf perspectiveCamera zeigt. */
    _inertiaUntil = 0;

    /* Ziel-Zoom jedes Toggles = der im Backend konfigurierte Start-Zoom (Default-Zoom).
     * Hat der Besucher zwischenzeitlich rein-/rausgezoomt, animiert der Toggle zurück
     * auf genau diesen Wert — NICHT auf den aktuellen User-Zoom und NICHT hart auf 1. */
    const defaultZoom = (typeof state.defaultZoom === 'number' && state.defaultZoom > 0) ? state.defaultZoom : 1;

    const reportProgress = (t) => {
      if (typeof onProjectionTweenProgress === 'function') {
        try { onProjectionTweenProgress(t, targetProjection); } catch (_) { /* ignore */ }
      }
    };

    if (!controls) {
      if (onComplete) onComplete();
      return;
    }

    const { fitDist: frontalFit, target: frontalLookTarget } = getFrontalOrthoCameraSolve();
    /* `frontalTarget` = aktueller controls.target (Start-Target). Das 2D-Endziel ist das
     * frontale Fit-Target aus derselben NDC-Iteration wie _applyPseudoOrthoPose — im 3D-Modus
     * weicht controls.target davon ab. Der 3D→2D-Tween lerpt das Target mit, sonst landet die
     * 2D-Pose off-center und das Diagramm wird angeschnitten. */
    const frontalTarget = controls.target.clone();

    /* Shortest-path für Spherical-theta (Wrap um ±π). Essentiell für Surface (45°)
     * und um zu vermeiden, dass z.B. 355° → 10° einmal um die ganze Szene rauscht. */
    function _shortestThetaDelta(startTheta, endTheta) {
      let d = endTheta - startTheta;
      if (d > Math.PI) d -= 2 * Math.PI;
      if (d < -Math.PI) d += 2 * Math.PI;
      return d;
    }

    /* FOV-Morph-Endpunkte — siehe Doc-Kommentar bei PROJECTION_TWEEN_FOV_START_RAD.
     * Wir morphen FOV und radius synchron, sodass halfHeight am Target-Z linear zwischen
     * Start- und End-Wert interpoliert. Das hält das Framing stabil, während die perspek-
     * tivische Verzerrung sanft auf-/abgebaut wird. */
    const fovHigh = FOV_RAD;                     /* Standard-FOV 50° = volle 3D-Perspektive */
    const fovLow = PROJECTION_TWEEN_FOV_START_RAD; /* sehr kleiner FOV ≈ Ortho-Look */

    if (targetProjection === '2d') {
      if (state.isOrthographic) { if (onComplete) onComplete(); return; }
      if (durationMs < 16) {
        /* Reduced-Motion: direkte frontale Pseudo-Ortho-Pose ohne Tween. Zoom auf den
         * konfigurierten Start-Zoom zurücksetzen — _enterPseudoOrtho baut die Pose aus
         * state.userZoomFactorPersp auf, das Framing ist daher identisch zum tween-Ende. */
        state.userZoomFactorPersp = defaultZoom;
        _enterPseudoOrtho();
        reportProgress(1);
        if (onComplete) onComplete();
        return;
      }
      /* Start in Welt-Koordinaten — die 3D-Default-Pose hat per Design einen
       * Layout-Yaw (10° bzw. 45° bei Surface), und wir müssen von genau dort
       * zur echten frontalen Weltposition (theta=0) rotieren. */
      const startSph = _samplePerspectiveWorldSpherical();
      const endSph = { phi: Math.PI / 2, theta: 0 };
      const thetaDelta = _shortestThetaDelta(startSph.theta, endSph.theta);

      /* halfHeight am Target-Z: Start = aktuelles Perspective-Framing (bei FOV=50°),
       * inkl. beliebigem User-Zoom-Faktor aus 3D. End = frontales Framing beim konfigurierten
       * Start-Zoom — der Toggle animiert also auf den im Backend eingestellten Zoom zurück. */
      const halfHStart = startSph.radius * TAN_HALF_FOV;
      const halfHEnd = frontalFit * defaultZoom * TAN_HALF_FOV;

      controls.enableRotate = false;
      _tweenActive = true;
      const t0 = performance.now();
      const _lerpedTarget = new THREE.Vector3();
      const step = (now) => {
        const raw = Math.min(1, (now - t0) / durationMs);
        const t = _easeInOutCubic(raw);
        const fov = fovHigh + (fovLow - fovHigh) * t;
        const tanHalf = Math.tan(fov / 2);
        const halfH = halfHStart + (halfHEnd - halfHStart) * t;
        _lerpedTarget.lerpVectors(frontalTarget, frontalLookTarget, t);
        const sph = {
          phi: startSph.phi + (endSph.phi - startSph.phi) * t,
          theta: startSph.theta + thetaDelta * t,
          radius: halfH / tanHalf,
        };
        perspectiveCamera.fov = (fov * 180) / Math.PI;
        perspectiveCamera.updateProjectionMatrix();
        _applyPerspectiveWorldPose(_lerpedTarget, sph);
        reportProgress(t);
        markDirty(1);
        if (raw >= 1) {
          _tweenRaf = null;
          _tweenActive = false;
          /* Start-Zoom als SSOT festschreiben: das 2D-Ziel-Framing wurde oben mit genau
           * diesem defaultZoom berechnet (halfHEnd), und _enterPseudoOrtho baut die Pose
           * aus state.userZoomFactorPersp — also kein Sprung. */
          state.userZoomFactorPersp = defaultZoom;
          /* controls.target auf frontales Fit-Target, damit der anschließende
           * controls.update() in _enterPseudoOrtho die Pose nicht korrigiert. */
          controls.target.copy(frontalLookTarget);
          /* Tween hat fov bereits auf fovLow gestellt; _enterPseudoOrtho setzt nur
           * das Flag und re-konfiguriert die Controls (enableRotate=false). */
          _enterPseudoOrtho();
          reportProgress(1);
          markDirty(2);
          if (onComplete) onComplete();
          return;
        }
        _tweenRaf = requestAnimationFrame(step);
      };
      _tweenRaf = requestAnimationFrame(step);
      return;
    }

    /* → 3D */
    if (!state.isOrthographic) { if (onComplete) onComplete(); return; }
    /* Aktuellen 2D-User-Zoom als Start-Faktor festhalten (für halfHStart), damit der
     * Tween von der faktisch sichtbaren 2D-Framing aus losläuft, nicht von Default-1. */
    const startUserZ = state.userZoomFactorPersp;
    /* Flag + Controls zurückstellen (enableRotate=true). Kamera steht bereits frontal
     * mit fov=fovLow (vom letzten 2D-Zustand bzw. vom initialen _applyPseudoOrthoPose) —
     * von dort startet der Tween in die Default-3D-Pose. */
    _exitPseudoOrtho();
    /* Default-3D-Pose in Welt-Spherical berechnen (inkl. Layout-Yaw). Ziel-userZ ist der
     * konfigurierte Start-Zoom — der Toggle animiert auf den im Backend eingestellten Zoom
     * zurück, unabhängig davon, wie der Besucher im 2D-Modus gezoomt hat.
     * endFit (= fitDist) bleibt zoom-unabhängig; nur endSph.radius trägt den Zoom. */
    const endPresetSph = resolvePresetSphericalUser(state.layout, state.cameraPreset);
    _orbitSph.phi = endPresetSph.phi;
    _orbitSph.theta = endPresetSph.theta;
    _orbitSph.radius = 1;
    const endDesired = computeDesiredCamera(state.layout, _orbitSph, defaultZoom);
    const endTarget = endDesired.target.clone();
    const endOffset = new THREE.Vector3().subVectors(endDesired.position, endDesired.target);
    const endSph = new THREE.Spherical().setFromVector3(endOffset);
    const endFit = endDesired.fitDist;

    state.layoutDistancePersp = endFit;
    state.layoutDistanceRefPersp = endFit;

    if (durationMs < 16) {
      /* Reduced-Motion: direkt auf Default-3D mit Standard-FOV und konfiguriertem Start-Zoom
       * (endSph.radius wurde mit defaultZoom berechnet). */
      _userPanOffset.set(0, 0, 0);
      state.userZoomFactorPersp = defaultZoom;
      perspectiveCamera.fov = STANDARD_FOV_DEG;
      perspectiveCamera.updateProjectionMatrix();
      controls.target.copy(endTarget);
      _applyPerspectiveWorldPose(endTarget, { phi: endSph.phi, theta: endSph.theta, radius: endSph.radius });
      snapSmoothingTo({ fitDist: endFit, target: endTarget });
      configureControls(controls);
      setZoomLimits();
      controls.update();
      reportProgress(1);
      if (onComplete) onComplete();
      return;
    }

    const startTarget = frontalTarget.clone();
    const startSph = { phi: Math.PI / 2, theta: 0 };
    const thetaDelta = _shortestThetaDelta(startSph.theta, endSph.theta);
    /* halfHeight-Lerp-Endpunkte: Start = aktuelle Pseudo-Ortho-halfHeight (inkl. 2D-User-
     * Zoom), End = halfHeight der Default-3D-Pose bei userZ=1. */
    const halfHStart = frontalFit * startUserZ * TAN_HALF_FOV;
    const halfHEnd = endSph.radius * TAN_HALF_FOV;
    controls.target.copy(endTarget);
    controls.enableRotate = false;
    _tweenActive = true;
    const t0 = performance.now();
    const _lerpedTarget = new THREE.Vector3();
    const step = (now) => {
      const raw = Math.min(1, (now - t0) / durationMs);
      const t = _easeInOutCubic(raw);
      const fov = fovLow + (fovHigh - fovLow) * t;
      const tanHalf = Math.tan(fov / 2);
      const halfH = halfHStart + (halfHEnd - halfHStart) * t;
      _lerpedTarget.lerpVectors(startTarget, endTarget, t);
      const sph = {
        phi: startSph.phi + (endSph.phi - startSph.phi) * t,
        theta: startSph.theta + thetaDelta * t,
        radius: halfH / tanHalf,
      };
      perspectiveCamera.fov = (fov * 180) / Math.PI;
      perspectiveCamera.updateProjectionMatrix();
      _applyPerspectiveWorldPose(_lerpedTarget, sph);
      reportProgress(t);
      markDirty(1);
      if (raw >= 1) {
        _tweenRaf = null;
        _tweenActive = false;
        /* Start-Zoom festschreiben: das 3D-Ziel-Framing (endSph/endFit) wurde mit diesem
         * defaultZoom berechnet. Sicherheitsnetz: exakter Standard-FOV am Ende. */
        _userPanOffset.set(0, 0, 0);
        state.userZoomFactorPersp = defaultZoom;
        perspectiveCamera.fov = STANDARD_FOV_DEG;
        perspectiveCamera.updateProjectionMatrix();
        snapSmoothingTo({ fitDist: endFit, target: endTarget });
        configureControls(controls);
        setZoomLimits();
        controls.update();
        reportProgress(1);
        markDirty(2);
        if (onComplete) onComplete();
        return;
      }
      _tweenRaf = requestAnimationFrame(step);
    };
    _tweenRaf = requestAnimationFrame(step);
  }

  function syncUserPanOffsetFromRestoredPose() {
    if (!controls) return;
    if (state.isOrthographic) {
      const { target: fitT } = getFrontalOrthoCameraSolve();
      _userPanOffset.copy(controls.target).sub(fitT);
      const aim = fitT.clone().add(_userPanOffset);
      clampWorldTargetToLayoutFrame(aim, state.layout);
      _userPanOffset.copy(aim).sub(fitT);
      return;
    }
    _dirScratch.subVectors(perspectiveCamera.position, controls.target);
    if (_dirScratch.lengthSq() < 1e-10) return;
    _orbitSph.setFromVector3(_dirScratch);
    _orbitSph.theta -= viewYawFromRightRad(state.layout);
    const desired = computeDesiredCamera(state.layout, { phi: _orbitSph.phi, theta: _orbitSph.theta }, state.userZoomFactorPersp);
    _userPanOffset.copy(controls.target).sub(desired.target);
    _aimTarget.copy(desired.target).add(_userPanOffset);
    clampWorldTargetToLayoutFrame(_aimTarget, state.layout);
    _userPanOffset.copy(_aimTarget).sub(desired.target);
  }

  /* ───────────────────── Neutrale externe Kamerasteuerung ─────────────────────
   * Generische Bausteine, damit Add-ons (Pro) die Kamera fuer eine eigene
   * Animation steuern koennen, ohne dass Free deren Logik kennt. */

  /**
   * Read-only: aktuelle Default-/Ruhepose (3D) in Welt-Koordinaten. Nutzt dieselbe
   * Mathematik wie der Initial-/Refit-Pfad (Preset-Spherical + tight-fit + Start-Zoom),
   * mutiert aber keinen State. Liefert die Ziel-Pose, auf die ein externer Tween
   * hinanimieren kann.
   *
   * @returns {{ target: import('three').Vector3, phi: number, theta: number, radius: number }}
   */
  function getRestingPose() {
    const layout = state.layout;
    const presetSph = resolvePresetSphericalUser(layout, state.cameraPreset);
    const zoom = (typeof state.defaultZoom === 'number' && state.defaultZoom > 0) ? state.defaultZoom : 1;
    const desired = computeDesiredCamera(layout, { phi: presetSph.phi, theta: presetSph.theta }, zoom);
    _restingDir.subVectors(desired.position, desired.target);
    _restingSph.setFromVector3(_restingDir);
    return {
      target: desired.target.clone(),
      phi: _restingSph.phi,
      theta: _restingSph.theta,
      radius: _restingSph.radius,
    };
  }

  /** Setzt die Perspektiv-Kamera auf eine Welt-Pose (Spherical relativ zum Target). */
  function applyWorldPose(target, sph) {
    if (controls) controls.target.copy(target);
    _applyPerspectiveWorldPose(target, sph);
  }

  function isExternallyControlled() { return _externalControl; }

  /** Externe Steuerung beginnen: laufenden Projection-Tween + Inertia abbrechen. */
  function beginExternalControl() {
    _cancelProjectionTween();
    _inertiaUntil = 0;
    _externalControl = true;
  }

  /** Externe Steuerung beenden: PT1-Glaettung auf die Ruhepose seeden, damit der
   *  per-Frame-Fit ohne Sprung weiterlaeuft. */
  function endExternalControl() {
    _externalControl = false;
    if (!controls) return;
    const pose = getRestingPose();
    const zoom = Math.max(USER_ZOOM_PERSP_MIN, state.userZoomFactorPersp);
    const fitDist = pose.radius / (zoom > 0 ? zoom : 1);
    controls.target.copy(pose.target);
    snapSmoothingTo({ fitDist, target: pose.target.clone() });
    state.layoutDistancePersp = fitDist;
    state.layoutDistanceRefPersp = fitDist;
  }

  return {
    perspectiveCamera,
    /* API-Kompat-Alias: bis zum Pseudo-Ortho-Refactor gab es hier einen Live-Swap zwischen
     * PerspectiveCamera und OrthographicCamera. Seit dem FOV-Morph-Refactor ist die aktive
     * Kamera immer perspectiveCamera — der Getter bleibt für stabile Consumer-APIs. */
    get activeCamera() { return perspectiveCamera; },
    getFrontalPerspectiveFitDist,
    tweenToProjection,
    isProjectionTweening,
    get controls() { return controls; },
    attachControls,
    snapSmoothingTo,
    configureControls,
    setZoomLimits,
    maxPolarForLayout,
    syncLayoutDistanceFromCamera,
    seedPerspectiveSmoothingToCurrentPose,
    schedulePostWakePerspSoftBlend,
    beginOrthoWakeCameraBlend,
    stepOrthoWakeCameraBlend,
    isOrthoWakeBlending,
    syncUserPanOffsetFromRestoredPose,
    applyZoom,
    onPerspectiveWheel,
    bindPerspectivePinch,
    unbindPerspectivePinch,
    applyPseudoOrthoPose: _applyPseudoOrthoPose,
    setCameraTargetFromLayout,
    applyCameraResult,
    stepPerspectiveCamera,
    refitPerspectiveCamera,
    collapseSmoothing,
    hasActiveInertia,
    /* Neutrale externe Kamerasteuerung (Add-on-Animationen). */
    getRestingPose,
    applyWorldPose,
    beginExternalControl,
    endExternalControl,
    isExternallyControlled,
  };
}
