/**
 * Axis-aligned Bounding Box Frame Computation für Camera-Fit.
 *
 * Für jedes Layout wird die wirksame Szenen-Bounding-Box berechnet (inkl. Bars, Ruler,
 * Labels, Wave-Curves, Radial-Full-Circle usw.) und das Ergebnis `{ center, extentH, extentV, size }`
 * gecached bis `invalidateFrameCache()` aufgerufen wird (z. B. bei Resize, Orbit-Reset,
 * Ruler-Color-Update, Layout-Wechsel).
 *
 * Wird vom CameraController (refitPerspectiveCamera, _applyPseudoOrthoPose) und StageHeight
 * konsumiert; bei Cache-Hit ist die Funktion O(1).
 *
 * Debug-Hook: `window.__ceShowBBox = true` oder URL `?ce_bbox=1` (siehe init.js): halbtransparente
 * grüne Füllung + grünes Kantengerüst um den berechneten Frame, rote Sphere am Center.
 */
import { isEmptyBar } from '../shared/config.js';
import {
  SERIES_AXIS_X_MARGIN_WORLD,
  STRAIGHT_XLABEL_PLANE_W,
  STRAIGHT_XLABEL_PLANE_H,
  AXIS_PAIR_LABEL_PLANE_H,
  TICK_LABEL_GAP,
} from './ruler.js';

const LABEL_ABOVE_GAP_FRAME = 0.045;

/**
 * Frame-Layout-Deskriptor: zentralisiert alle layoutabhaengigen Camera-Fit-Bbox-
 * Entscheidungen, damit der Free-Core keine Pro-Layout-Namen mehr hartcodiert. Free
 * liefert die Defaults fuer seine eigenen vertikalen Bar-Layouts (straight/stacked);
 * Add-on-Plugins steuern ihre Layouts ueber
 * `window.__CE_PD_SCENE_EXT__.frame.layoutDescriptor(layout)` bei (arc/grouped/horizontal/
 * wave/radial/bubblePack/surface). Felder spiegeln die frueheren Namen-Verzweigungen:
 *  - usesBoxBars          : Box-Bars in den Frame aufnehmen (Bar-Loop).
 *  - includesRulerInFrame : Ruler-Geometrie in den Frame aufnehmen.
 *  - usesVerticalRulerBox : vertikale Y-Achsen-Ruler-Box (wie straight) + Bottom-Row.
 *  - hasFloorXLabels      : X-Labels am Boden in den Frame aufnehmen.
 *  - isArc / isHorizontal / isWave / isRadial / isBubblePack / isSurface : layout-eigene Geometrie.
 *  - usesGroupedTopLabels : gruppierte Bar-Headline-Meshes oberhalb der Balken.
 */
function resolveFrameLayoutDescriptor(layout) {
  if (layout === 'wave') {
    return {
      usesBoxBars: false,
      includesRulerInFrame: true,
      usesVerticalRulerBox: true,
      hasFloorXLabels: true,
      isArc: false,
      isHorizontal: false,
      isWave: true,
      isRadial: false,
      isBubblePack: false,
      isSurface: false,
      usesGroupedTopLabels: false,
    };
  }
  const ext = (typeof window !== 'undefined' && window.__CE_PD_SCENE_EXT__ && window.__CE_PD_SCENE_EXT__.frame) || null;
  const fromPro = ext && typeof ext.layoutDescriptor === 'function' ? ext.layoutDescriptor(layout) : null;
  const d = (fromPro && typeof fromPro === 'object') ? fromPro : {};
  /* Free-eigene vertikale Bar-Layouts teilen Bar-Loop, Vertikal-Ruler-Box und Floor-X-Labels. */
  const isOwnBar = layout === 'straight' || layout === 'stacked';
  return {
    usesBoxBars:          isOwnBar || d.usesBoxBars === true,
    includesRulerInFrame: isOwnBar || d.includesRulerInFrame === true,
    usesVerticalRulerBox: isOwnBar || d.usesVerticalRulerBox === true,
    hasFloorXLabels:      isOwnBar || d.hasFloorXLabels === true,
    isArc:                d.isArc === true,
    isHorizontal:         d.isHorizontal === true,
    isWave:               d.isWave === true,
    isRadial:             d.isRadial === true,
    isBubblePack:         d.isBubblePack === true,
    isSurface:            d.isSurface === true,
    usesGroupedTopLabels: d.usesGroupedTopLabels === true,
  };
}

export function createLayoutFrame(ctx) {
  const {
    THREE, scene,
    DATA, valueToNorm, getBarLayout,
    barWidth, barDepth, maxBarHeight,
    rulerEnabled, arcRulerZOffset,
    rulerX, diagramWidth,
    arcLeftX, arcLeftZ, arcRightX, arcRightZ,
    arcStartAngle, arcEndAngle, arcRadius, arcZCenter,
    horizontalLeftEdge, horizontalCenterY, horizontalRulerBottom, horizontalRulerTop,
    rulerGroup, waveGroup, bubblePackGroup, radialGroup, pieGroup, surfaceGroup,
    radialMaxR, radialCenterY,
    hiddenDataIndices,
    getBubblePackInstMesh,
    bubblePackInstances,
  } = ctx;

  const _frameV = new THREE.Vector3();
  const _frameBox = new THREE.Box3();
  const _textPlaneBox = new THREE.Box3();
  /* Bbox-Rekonstruktion fuer per-Legende versteckte BubblePack-Instanzen:
   *  scale(0,0,0)-Matrizen kollabieren beim Three.js-setFromObject die Instanz
   *  auf den Ursprung -> die Frame-Bbox wuerde nach jedem Hide schrumpfen und
   *  recalcFit() de-zentriert das Diagramm. Mit `_cePdRestMatrix` (in init.js
   *  beim Verstecken gespeichert) rechnen wir den urspruenglichen Beitrag der
   *  Instanz nachtraeglich wieder dazu. */
  const _bpInstMat = new THREE.Matrix4();
  const _bpInstBox = new THREE.Box3();

  /* Helper: gilt `mesh` als "vom User per Legenden-Klick versteckt"?
   *  Bbox-stabil halten heisst: solche Meshes trotz `visible=false` in den
   *  Frame einbeziehen, damit das Diagramm nach Scroll-Out/-In zentriert
   *  bleibt. Echte (z. B. wegen `isEmptyBar`) ausgeblendete Meshes weiterhin
   *  ignorieren. */
  function isUserHidden(mesh) {
    if (!hiddenDataIndices) return false;
    const di = mesh && mesh.userData ? mesh.userData._dataIndex : null;
    return di != null && hiddenDataIndices.has(di);
  }
  let _frameCacheLayout = null;
  let _frameCacheResult = null;

  function invalidateFrameCache() { _frameCacheLayout = null; _frameCacheResult = null; }

  function disposeCeDebugBoxGroup() {
    const grp = window.__ceDebugBox;
    if (!grp) return;
    scene.remove(grp);
    grp.traverse((obj) => {
      if (obj.geometry) obj.geometry.dispose();
      if (obj.material) {
        if (Array.isArray(obj.material)) obj.material.forEach((m) => m.dispose());
        else obj.material.dispose();
      }
    });
    window.__ceDebugBox = null;
  }

  function isFiniteTroikaRect(r) {
    if (!Array.isArray(r) || r.length < 4) return false;
    const [a, b, c, d] = r;
    return [a, b, c, d].every((n) => Number.isFinite(n)) && c > a && d > b;
  }

  /**
   * Troika: Mesh-Geometrie-/`blockBounds`-AABB können breiter sein als sichtbare Glyphen
   * (vgl. Pie-Callouts); `visibleBounds` = Union der Glyph-Quads.
   */
  function expandBoxByTroikaVisibleBounds(box, mesh) {
    const info = mesh.textRenderInfo;
    if (!info || !Array.isArray(info.visibleBounds) || !isFiniteTroikaRect(info.visibleBounds)) return false;
    const [minX, minY, maxX, maxY] = info.visibleBounds;
    mesh.updateMatrixWorld(true);
    const mw = mesh.matrixWorld;
    const zLo = -0.03;
    const zHi = 0.03;
    const corners = [
      [minX, minY, zLo], [maxX, minY, zLo], [minX, maxY, zLo], [maxX, maxY, zLo],
      [minX, minY, zHi], [maxX, minY, zHi], [minX, maxY, zHi], [maxX, maxY, zHi],
    ];
    for (let i = 0; i < corners.length; i += 1) {
      const [lx, ly, lz] = corners[i];
      _frameV.set(lx, ly, lz).applyMatrix4(mw);
      box.expandByPoint(_frameV);
    }
    return true;
  }

  function expandBoxByTextPlanes(box, group) {
    /* Wichtig: rulerGroup wird nach dem Erzeugen per `position.set(0,0,-arcRulerZOffset)` versetzt,
     * matrixWorld wird aber erst im naechsten Render aktualisiert. Ohne expliziten Sync liefert
     * setFromObject beim ersten Frame falsche Welt-Z-Koordinaten (Label-Planes erscheinen in
     * der Bbox bei z=+0.54 statt +0.19). */
    group.updateMatrixWorld(true);
    group.traverse((obj) => {
      if (!obj.isMesh || !obj.visible || !obj.geometry) return;
      if (obj.geometry.type !== 'PlaneGeometry') return;
      _textPlaneBox.setFromObject(obj);
      box.union(_textPlaneBox);
    });
  }

  function getLayoutFrame(layout) {
    if (_frameCacheLayout === layout && _frameCacheResult) return _frameCacheResult;
    const fd = resolveFrameLayoutDescriptor(layout);
    const v = _frameV;
    const box = _frameBox.makeEmpty();
    /* Optionale, layout-spezifische Fit-Punktwolke (Welt). Wenn gesetzt, fittet die
     * Kamera auf diese Punkte statt auf die 8 AABB-Ecken (pieStairs: gestufte Silhouette). */
    let layoutFitPoints = null;
    const expandBar = (lx, ly, lz, h, rotY, rotZ) => {
      const hw = barWidth / 2, hh = h / 2, hd = barDepth / 2;
      const corners = [[-hw,-hh,-hd],[hw,-hh,-hd],[hw,-hh,hd],[-hw,-hh,hd],[-hw,hh,-hd],[hw,hh,-hd],[hw,hh,hd],[-hw,hh,hd]];
      if (rotZ !== 0) { const c = Math.cos(rotZ), s = Math.sin(rotZ); corners.forEach((c0, i) => { const [x,y,z]=c0; corners[i]=[x*c-y*s,x*s+y*c,z]; }); }
      if (rotY !== 0) { const c = Math.cos(rotY), s = Math.sin(rotY); corners.forEach((c0, i) => { const [x,y,z]=c0; corners[i]=[x*c+z*s,y,-x*s+z*c]; }); }
      corners.forEach(([ox,oy,oz]) => box.expandByPoint(v.set(lx+ox,ly+oy,lz+oz)));
    };
    if (fd.usesBoxBars) {
      DATA.forEach((item, i) => {
        if (isEmptyBar(item.value)) return;
        const h = valueToNorm(item.value) * maxBarHeight;
        const l = getBarLayout(layout, i, h);
        const barCenterY = layout === 'stacked' ? l.y + h / 2 : l.y;
        expandBar(l.x, barCenterY, l.z, h, l.rotationY ?? 0, l.rotationZ ?? 0);
      });
    }
    const includeRulerInFrame = rulerEnabled && rulerGroup && fd.includesRulerInFrame;
    const yLabelH = 0.16;
    const yLabelTop = maxBarHeight + 0.05 + yLabelH / 2;
    if (includeRulerInFrame) {
      if (fd.isArc) {
        /* Y-Tick-Position / -Radius: ruler.js labelAngle, labelArcOffset — Troika-Ticks haben keine
         * PlaneGeometry → expandBoxByTextPlanes ignoriert sie; center-anchored Text braucht ~halfWidth in X. */
        const LABEL_ANGLE_DELTA = 0.15;
        const LABEL_ARC_OFFSET = 0.12;
        const labelAngle = arcStartAngle - LABEL_ANGLE_DELTA;
        const labelArcX = Math.sin(labelAngle) * (arcRadius + LABEL_ARC_OFFSET);
        const labelArcZ = -Math.cos(labelAngle) * (arcRadius + LABEL_ARC_OFFSET) + arcZCenter;
        const tickLabelW = rulerGroup.userData._tickLabelW ?? 0.65;
        const tickHalfW = tickLabelW / 2;
        const labelYOffset = 0.05;
        const yTickLo = labelYOffset;
        const yTickHi = maxBarHeight + labelYOffset;
        box.expandByPoint(v.set(labelArcX - tickHalfW, yTickLo, labelArcZ - arcRulerZOffset));
        box.expandByPoint(v.set(labelArcX - tickHalfW, yTickHi, labelArcZ - arcRulerZOffset));
        box.expandByPoint(v.set(arcLeftX, maxBarHeight / 2, arcLeftZ - arcRulerZOffset));
        box.expandByPoint(v.set(arcRightX, maxBarHeight / 2, arcRightZ - arcRulerZOffset));
        const arcSpan = arcEndAngle - arcStartAngle;
        for (const frac of [0.25, 0.5, 0.75]) {
          const a = arcStartAngle + frac * arcSpan;
          const rz = -Math.cos(a) * arcRadius + arcZCenter;
          box.expandByPoint(v.set(Math.sin(a) * arcRadius, 0, rz - arcRulerZOffset));
          box.expandByPoint(v.set(Math.sin(a) * arcRadius, yLabelTop, rz - arcRulerZOffset));
        }
        const labelR = arcRadius;
        box.expandByPoint(v.set(Math.sin(arcStartAngle - 0.15) * labelR, yLabelTop, (-Math.cos(arcStartAngle - 0.15) * labelR + arcZCenter) - arcRulerZOffset));
        box.expandByPoint(v.set(Math.sin(arcEndAngle + 0.15) * labelR, yLabelTop, (-Math.cos(arcEndAngle + 0.15) * labelR + arcZCenter) - arcRulerZOffset));
        const yTitle = rulerGroup?.userData._seriesAxisYLabel;
        if (yTitle && yTitle.visible) {
          /* Wie straight/wave/grouped: Y-Titel ist um 90° gedreht — halbe „Plane“-Höhe in Welt-X (siehe unten). */
          const halfX = AXIS_PAIR_LABEL_PLANE_H / 2;
          const slack = 0;
          box.expandByPoint(v.set(yTitle.position.x - halfX - slack, yTitle.position.y, yTitle.position.z - arcRulerZOffset));
        }
        /* Bottom row: 0% left (arc pos) & series X right (Series-X-Label sitzt auch im Arc-Layout an der "straight"-Position). */
        const bottomLabelY = 0.05;
        const bh = yLabelH * 0.5;
        box.expandByPoint(v.set(Math.sin(arcStartAngle - 0.15) * labelR, bottomLabelY - bh, (-Math.cos(arcStartAngle - 0.15) * labelR + arcZCenter) - arcRulerZOffset));
        box.expandByPoint(v.set(Math.sin(arcStartAngle - 0.15) * labelR, bottomLabelY + bh, (-Math.cos(arcStartAngle - 0.15) * labelR + arcZCenter) - arcRulerZOffset));
        const seriesXLabel = rulerGroup.userData._seriesAxisXLabel;
        const seriesXW = rulerGroup.userData._seriesAxisXLabelWidthWorld ?? 0;
        /* Series-X: anchorX='left' → position.x ist die linke innere Kante (siehe ruler.js), nicht die Mitte.
         * Kein seriesMargin im Frame: Kamera-Fit soll exakt bis zur gemessenen Textbreite gehen (Straight behält Margin separat). */
        const seriesHalfH = (AXIS_PAIR_LABEL_PLANE_H * (24 / 28)) / 2;
        if (seriesXLabel?.visible && seriesXW > 0) {
          const zx = seriesXLabel.position.z - arcRulerZOffset;
          const yx = seriesXLabel.position.y;
          const xL = seriesXLabel.position.x;
          const xR = xL + seriesXW;
          box.expandByPoint(v.set(xL, yx - seriesHalfH, zx));
          box.expandByPoint(v.set(xR, yx + seriesHalfH, zx));
        } else {
          const seriesXRightEdge = Math.sin(arcEndAngle + 0.15) * labelR;
          box.expandByPoint(v.set(seriesXRightEdge, bottomLabelY - bh, -arcRulerZOffset));
          box.expandByPoint(v.set(seriesXRightEdge, bottomLabelY + bh, -arcRulerZOffset));
        }
      } else if (fd.usesVerticalRulerBox) {
        const seriesMargin = rulerGroup.userData._seriesAxisXMarginWorld ?? SERIES_AXIS_X_MARGIN_WORLD;
        const sxMesh = rulerGroup.userData._seriesAxisXLabel;
        const sxW = rulerGroup.userData._seriesAxisXLabelWidthWorld;
        /* Series-X-Rechteck nur wenn sichtbar: bei leerem axisX bleibt ein Troika-Mesh (\u200b) unsichtbar;
         * ohne visible-Check wuerden Breite oder der alte Fallback 0.55 rechts Luft vorspiegeln. */
        let rulerRightExtent = 0;
        if (sxMesh && sxMesh.visible && sxW != null && Number.isFinite(sxW) && sxW > 1e-4) {
          rulerRightExtent = sxW + seriesMargin;
        }
        /* Linke BBox-Kante = linke Kante der breitesten Y-Tick-Beschriftung
         * (rulerX - GAP - tatsächliche Tick-Breite). Vorher: hardcoded `0.55`
         * + zusätzliches `axisYLabelFramePad` — bei schmalen Ticks (z. B. „3h"
         * in grouped) ergab das einen sichtbaren Innenrand zur BBox. Der echte
         * Y-Achsentitel wird unten via `box.expandByPoint(yAxisLabel...)`
         * separat eingerechnet — kein doppeltes Padding mehr nötig. */
        const tickLabelW = rulerGroup?.userData._tickLabelW ?? 0.41;
        const leftRulerX = rulerX - TICK_LABEL_GAP - tickLabelW;
        box.expandByPoint(v.set(rulerX, 0, -arcRulerZOffset));
        box.expandByPoint(v.set(rulerX + diagramWidth, 0, -arcRulerZOffset));
        box.expandByPoint(v.set(rulerX, maxBarHeight / 2, -arcRulerZOffset));
        box.expandByPoint(v.set(rulerX + diagramWidth, maxBarHeight / 2, -arcRulerZOffset));
        box.expandByPoint(v.set(leftRulerX, yLabelTop, -arcRulerZOffset));
        box.expandByPoint(v.set(rulerX + diagramWidth + rulerRightExtent, yLabelTop, -arcRulerZOffset));
        if (fd.usesVerticalRulerBox) {
          const by = 0.05;
          const bh = yLabelH * 0.5;
          box.expandByPoint(v.set(leftRulerX, by - bh, -arcRulerZOffset));
          box.expandByPoint(v.set(leftRulerX, by + bh, -arcRulerZOffset));
          box.expandByPoint(v.set(rulerX + diagramWidth + rulerRightExtent, by - bh, -arcRulerZOffset));
          box.expandByPoint(v.set(rulerX + diagramWidth + rulerRightExtent, by + bh, -arcRulerZOffset));
        }
        const yAxisLabel = rulerGroup?.userData._seriesAxisYLabel;
        if (yAxisLabel && yAxisLabel.visible) {
          /* Mit Troika gibt es keine PlaneGeometry mehr — der Y-Achsen-Titel ist um 90° gedreht,
           * also entspricht halfX in Welt-X der halben Plane-Höhe (= halbe Welt-Höhe der alten Plane). */
          const halfX = AXIS_PAIR_LABEL_PLANE_H / 2;
          const slack = 0;
          box.expandByPoint(v.set(yAxisLabel.position.x - halfX - slack, yAxisLabel.position.y, yAxisLabel.position.z));
        }
      } else if (fd.isHorizontal) {
        box.expandByPoint(v.set(horizontalLeftEdge, horizontalCenterY, -arcRulerZOffset));
        box.expandByPoint(v.set(horizontalLeftEdge + maxBarHeight, horizontalCenterY, -arcRulerZOffset));
        box.expandByPoint(v.set(horizontalLeftEdge, horizontalRulerBottom, -arcRulerZOffset));
        box.expandByPoint(v.set(horizontalLeftEdge + maxBarHeight, horizontalRulerTop, -arcRulerZOffset));
        /* Untere %-Achse: Tick-Labels zentriert auf 0%/100%-Position — halbe Weltbreite uebersteht Platte/ruler-X. */
        const tickLabelW = rulerGroup.userData._tickLabelW ?? 0.65;
        const tickHalfW = tickLabelW / 2;
        const horizontalTickRowY = rulerGroup.userData._horizontalTickLabelRowY ?? (horizontalRulerBottom - 0.2);
        const tickZloc = rulerGroup.userData._horizontalTickLabelLocalZ ?? 0.01;
        const tickZs = -arcRulerZOffset + tickZloc;
        const tickFs = rulerGroup.userData._horizontalTickLabelFontWorld ?? (0.16 * (21 / 24));
        const tickHalfH = Math.max(tickFs * 0.62, 0.048);
        box.expandByPoint(v.set(horizontalLeftEdge - tickHalfW, horizontalTickRowY - tickHalfH, tickZs));
        box.expandByPoint(v.set(horizontalLeftEdge + maxBarHeight + tickHalfW, horizontalTickRowY + tickHalfH, tickZs));
        const hAxMesh = rulerGroup?.userData._horizontalAxisXLabel;
        if (hAxMesh && hAxMesh.visible) {
          const slack = 0;
          box.expandByPoint(v.set(hAxMesh.position.x + slack, hAxMesh.position.y + slack, hAxMesh.position.z - arcRulerZOffset + slack));
          box.expandByPoint(v.set(hAxMesh.position.x - slack, hAxMesh.position.y - slack, hAxMesh.position.z - arcRulerZOffset - slack));
        }
      }
    }
    if (rulerEnabled && rulerGroup?.userData._xLabels && fd.hasFloorXLabels) {
      rulerGroup.userData._xLabels.forEach((xLabel) => {
        if (!xLabel.visible) return;
        const wx = xLabel.position.x;
        const wy = xLabel.position.y;
        const wz = xLabel.position.z - arcRulerZOffset;
        if (fd.hasFloorXLabels && rulerGroup.userData._barLabelsUnderBars) {
          /* Heuristik (W,H,tilt): Default-Breite 0.825 kann rechts ueber rulerX+diagramWidth liegen —
           * Troika `visibleBounds` nutzt echte Glyph-Masse. */
          if (!expandBoxByTroikaVisibleBounds(box, xLabel)) {
            const W = xLabel.userData._straightHeadlinePlaneW ?? STRAIGHT_XLABEL_PLANE_W;
            const H = STRAIGHT_XLABEL_PLANE_H;
            const tilt = xLabel.userData._barLabelTiltRad || 0;
            const cosT = Math.cos(tilt); const sinT = Math.sin(tilt);
            const extX = W * cosT + H * sinT;
            const extY = W * sinT + H * cosT;
            box.expandByPoint(v.set(wx - extX, wy - extY, wz));
            box.expandByPoint(v.set(wx + extX, wy + extY, wz));
          }
        } else {
          const yPad = 0;
          box.expandByPoint(v.set(wx, wy - yPad, wz));
          box.expandByPoint(v.set(wx, wy + yPad, wz));
        }
      });
    }
    if (rulerEnabled && fd.isHorizontal && rulerGroup?.userData._xLabelsHorizontalRight?.length) {
      rulerGroup.userData._xLabelsHorizontalRight.forEach((xr) => {
        if (!xr.visible && !isUserHidden(xr)) return;
        const wx = xr.position.x;
        const wy = xr.position.y;
        const wz = xr.position.z - arcRulerZOffset;
        const W = xr.userData._straightHeadlinePlaneW ?? STRAIGHT_XLABEL_PLANE_W;
        const H = STRAIGHT_XLABEL_PLANE_H;
        const xSlack = 0;
        const ySlack = 0;
        const zSlack = 0;
        box.expandByPoint(v.set(wx - xSlack, wy - H - ySlack, wz - zSlack));
        box.expandByPoint(v.set(wx + W + xSlack, wy + H + ySlack, wz + zSlack));
      });
    }
    /* Inside-Labels: position folgt der Bar-Animation (Pro syncHorizontalRightLabels).
     * Inside passt im Balken-Innenraum (extendiert die Frame nicht). Outside-Fallback
     * dagegen sitzt rechts vom Balken wie ein right-Slot Label – gleiche BBox-Logik. */
    if (rulerEnabled && fd.isHorizontal && rulerGroup?.userData._xLabelsHorizontalInside?.length) {
      rulerGroup.userData._xLabelsHorizontalInside.forEach((xin) => {
        if (!xin.visible && !isUserHidden(xin)) return;
        if (xin.userData._horizontalLabelLastPlacement !== 'outside') return;
        const wx = xin.position.x;
        const wy = xin.position.y;
        const wz = xin.position.z - arcRulerZOffset;
        const W = xin.userData._straightHeadlinePlaneW ?? STRAIGHT_XLABEL_PLANE_W;
        const H = STRAIGHT_XLABEL_PLANE_H;
        box.expandByPoint(v.set(wx, wy - H, wz));
        box.expandByPoint(v.set(wx + W, wy + H, wz));
      });
    }
    const expandBoxByTopLabel = (mesh) => {
      if (!mesh.visible && !isUserHidden(mesh)) return;
      mesh.updateMatrixWorld(true);
      _textPlaneBox.setFromObject(mesh);
      const tsz = _textPlaneBox.getSize(_frameV);
      const vol = tsz.x * tsz.y * tsz.z;
      if (vol > 1e-9 && !_textPlaneBox.isEmpty()) {
        box.union(_textPlaneBox);
        return;
      }
      /* Pre-Troika-Sync / leere Geometrie: alte achsenparallele Schätzung */
      const pw = mesh.userData._planeW ?? 0;
      const ph = mesh.userData._frustumHalfW != null ? mesh.userData._frustumHalfW * 2 : (STRAIGHT_XLABEL_PLANE_H);
      const barTop = mesh.userData._barTopY ?? mesh.position.y;
      const finalY = barTop + LABEL_ABOVE_GAP_FRAME + pw / 2;
      const hw = ph / 2;
      const hh = pw / 2 + LABEL_ABOVE_GAP_FRAME;
      box.expandByPoint(v.set(mesh.position.x - hw, finalY - hh, mesh.position.z));
      box.expandByPoint(v.set(mesh.position.x + hw, finalY + hh, mesh.position.z));
    };
    if (fd.usesGroupedTopLabels && rulerGroup?.userData._groupedBarHeadlineMeshes) {
      rulerGroup.userData._groupedBarHeadlineMeshes.forEach(expandBoxByTopLabel);
    }
    if (layout === 'stacked' && rulerGroup?.userData._stackedBarSumMeshes) {
      rulerGroup.userData._stackedBarSumMeshes.forEach(expandBoxByTopLabel);
    }
    if (layout === 'straight' && rulerGroup?.userData._straightBarTopLabelMeshes) {
      rulerGroup.userData._straightBarTopLabelMeshes.forEach(expandBoxByTopLabel);
    }
    if (fd.isArc && rulerGroup?.userData._arcBarTopLabelMeshes) {
      rulerGroup.userData._arcBarTopLabelMeshes.forEach(expandBoxByTopLabel);
    }
    if (fd.isWave) {
      waveGroup.updateMatrixWorld(true);
      /* Kurven-/Band-Geometrie sammeln. Die Textlabel-Gruppen sind per
         _excludeFromWaveFit ausgenommen und werden direkt darunter exakt addiert. */
      (function expandWaveFiltered(node) {
        for (const child of node.children) {
          if (child.userData._excludeFromWaveFit) continue;
          if (child.isMesh || child.isInstancedMesh) box.expandByObject(child);
          if (child.children && child.children.length) expandWaveFiltered(child);
        }
      })(waveGroup);
      /* Alle SICHTBAREN Wave-Textlabels EXAKT in den Frame aufnehmen — keine
         Naeherung: pro Troika-Mesh die echten Glyph-Quads (visibleBounds), bei
         Label-Gruppen (Tickwerte mit Prefix/Suffix, Value-Mode-Segmente) rekursiv.
         Umfasst Node-Labels (Datenpunkte), Serien-/Gruppenlabels, Annotationen
         (+Fuehrungslinien), Y-Achsen-Tickwerte sowie Y-/X-Achsentitel. */
      const expandWaveLabelExact = (obj) => {
        if (!obj || obj.visible === false) return;
        if (obj.isGroup) { obj.children.forEach(expandWaveLabelExact); return; }
        if (obj.isMesh) {
          if (expandBoxByTroikaVisibleBounds(box, obj)) return;
        } else if (!obj.isLine) {
          return;
        }
        obj.updateMatrixWorld(true);
        _textPlaneBox.setFromObject(obj);
        if (!_textPlaneBox.isEmpty()) box.union(_textPlaneBox);
      };
      (waveGroup.userData._waveNodeLabelMeshes || []).forEach(expandWaveLabelExact);
      (waveGroup.userData._waveSeriesLabelMeshes || []).forEach(expandWaveLabelExact);
      (waveGroup.userData._waveAnnotationMeshes || []).forEach(expandWaveLabelExact);
      (waveGroup.userData._waveAnnotationLines || []).forEach(expandWaveLabelExact);
      if (rulerEnabled && rulerGroup) {
        rulerGroup.updateMatrixWorld(true);
        (rulerGroup.userData._labels || []).forEach(expandWaveLabelExact);
        expandWaveLabelExact(rulerGroup.userData._seriesAxisYLabel);
        expandWaveLabelExact(rulerGroup.userData._seriesAxisXLabel);
      }
      const xLabelsNeedSpaceBelowFloor =
        rulerEnabled &&
        rulerGroup?.userData._barLabelsUnderBars === true &&
        (rulerGroup.userData._xLabels || []).some((l) => l.visible);
      if (!xLabelsNeedSpaceBelowFloor) box.min.y = Math.max(box.min.y, 0);

      /* Liefert ein Add-on eine eigene Band-Hülle (`_fitHullLocal`, z. B. eine gewundene,
       * diagonal liegende Bahn), dann umschließt die achsenparallele Bbox viel Leerraum und
       * die Kamera zoomt zu weit heraus. Wie bei pieStairs daher eine echte Fit-Punktwolke
       * liefern (Band-Eckpunkte + sichtbare Label-Ecken), die der Kamera-Fit projiziert →
       * enger Rahmen unabhängig von der Rotation. Ohne Add-on bleibt die Hülle leer. */
      const hullLocal = waveGroup.userData._fitHullLocal;
      if (Array.isArray(hullLocal) && hullLocal.length) {
        const mw = waveGroup.matrixWorld;
        const pts = [];
        for (let hi = 0; hi < hullLocal.length; hi += 3) {
          v.set(hullLocal[hi], hullLocal[hi + 1], hullLocal[hi + 2]).applyMatrix4(mw);
          pts.push(v.x, v.y, v.z);
        }
        const pushLabelPts = (obj) => {
          if (!obj || obj.visible === false) return;
          if (obj.isGroup) { obj.children.forEach(pushLabelPts); return; }
          if (!obj.isMesh && !obj.isLine) return;
          obj.updateMatrixWorld(true);
          _textPlaneBox.setFromObject(obj);
          if (_textPlaneBox.isEmpty()) return;
          const bb = _textPlaneBox;
          for (let xi = 0; xi <= 1; xi++) for (let yi = 0; yi <= 1; yi++) for (let zi = 0; zi <= 1; zi++) {
            pts.push(xi ? bb.max.x : bb.min.x, yi ? bb.max.y : bb.min.y, zi ? bb.max.z : bb.min.z);
          }
        };
        (waveGroup.userData._waveNodeLabelMeshes || []).forEach(pushLabelPts);
        (waveGroup.userData._waveSeriesLabelMeshes || []).forEach(pushLabelPts);
        (waveGroup.userData._waveAnnotationMeshes || []).forEach(pushLabelPts);
        (waveGroup.userData._waveAnnotationLines || []).forEach(pushLabelPts);
        layoutFitPoints = new Float32Array(pts);
      }
    }
    if (fd.isRadial) {
      const s = radialGroup.scale.clone(); radialGroup.scale.set(1,1,1); radialGroup.updateMatrixWorld(true);
      /* Wie der wave-Zweig: per `_excludeFromWaveFit` markierte Kinder (z. B. der grosse
       * ShadowMaterial-Boden-Empfaenger aus radial.js) NICHT in den Camera-Fit aufnehmen,
       * sonst blaeht die Schatten-Plane die Bbox auf und das Diagramm zoomt heraus. */
      box.makeEmpty();
      (function expandRadialFiltered(node) {
        for (const child of node.children) {
          if (child.userData && child.userData._excludeFromWaveFit) continue;
          if (child.isMesh || child.isInstancedMesh) box.expandByObject(child);
          if (child.children && child.children.length) expandRadialFiltered(child);
        }
      })(radialGroup);
      /* Partial arcs have a tight AABB; camera then zooms in as if the chart were only the wedge.
         Union with the annulus' full-circle footprint (same origin as rings, XY plane, Z = extrude depth). */
      const radialFrameR = radialMaxR;
      const radialHalfZ = barDepth * 0.25;
      const mw = radialGroup.matrixWorld;
      const corners = [
        [-radialFrameR, -radialFrameR, -radialHalfZ], [radialFrameR, -radialFrameR, -radialHalfZ],
        [-radialFrameR, radialFrameR, -radialHalfZ], [radialFrameR, radialFrameR, -radialHalfZ],
        [-radialFrameR, -radialFrameR, radialHalfZ], [radialFrameR, -radialFrameR, radialHalfZ],
        [-radialFrameR, radialFrameR, radialHalfZ], [radialFrameR, radialFrameR, radialHalfZ],
      ];
      corners.forEach(([cx, cy, cz]) => {
        v.set(cx, cy, cz).applyMatrix4(mw);
        box.expandByPoint(v);
      });
      radialGroup.scale.copy(s);
      radialGroup.updateMatrixWorld(true);
    } else if (layout === 'pie' || layout === 'pieStairs' || layout === 'pyramid') {
      /* Callouts nicht im ersten expandByObject-Schritt: Geometrie-BBox zu breit — spaetere union()
       * mit tightem Rechteck wuerde die Box nicht verkleinern. Callouts nur per visibleBounds. */
      const s = pieGroup.scale.clone(); pieGroup.scale.set(1, 1, 1); pieGroup.updateMatrixWorld(true);
      const calloutSet = new Set(pieGroup.userData._pieCalloutMeshes || []);
      const calloutLineSet = new Set(pieGroup.userData._pieCalloutLines || []);
      const pyramidAnnSet = new Set(pieGroup.userData._pyramidAnnotationMeshes || []);
      const pyramidAnnLineSet = new Set(pieGroup.userData._pyramidAnnotationLines || []);
      let pieFitStarted = false;
      pieGroup.traverse((obj) => {
        if (calloutSet.has(obj) || calloutLineSet.has(obj) || pyramidAnnSet.has(obj) || pyramidAnnLineSet.has(obj)) return;
        /* Schatten-Empfänger + Kontakt-AO-Ring (pieStairs) sind groß und würden den
         * Fit aufblähen → komplett ausnehmen. */
        if (obj.userData && obj.userData._excludeFromPieFit) return;
        /* visible=false per Legende → trotzdem einrechnen, sonst schrumpft die
         *  Bbox nach jedem Slice-Hide und das Pie wird nicht mehr zentriert. */
        if (!obj.visible && !isUserHidden(obj)) return;
        if (obj.isMesh) {
          if (!pieFitStarted) {
            box.setFromObject(obj);
            pieFitStarted = true;
          } else box.expandByObject(obj);
        } else if (obj.isLine) {
          if (!pieFitStarted) {
            box.setFromObject(obj);
            pieFitStarted = true;
          } else box.expandByObject(obj);
        }
      });
      const com = pieGroup.userData._pieCalloutMeshes;
      if (Array.isArray(com)) {
        com.forEach((mesh) => {
          if (!mesh) return;
          /* Callouts der per Legende versteckten Slices weiterhin in die Bbox
           *  einrechnen — sie liegen am Pie-Aussenrand und dominieren die
           *  horizontale Bbox; ohne sie schrumpft der Frame deutlich und
           *  recalcFit() de-zentriert das Pie beim Wieder-Reinscrollen. */
          if (!mesh.visible && !isUserHidden(mesh)) return;
          if (expandBoxByTroikaVisibleBounds(box, mesh)) return;
          mesh.updateMatrixWorld(true);
          _textPlaneBox.setFromObject(mesh);
          const tsz = _textPlaneBox.getSize(_frameV);
          if (tsz.x * tsz.y * tsz.z > 1e-12 && !_textPlaneBox.isEmpty()) box.union(_textPlaneBox);
        });
      }
      if (layout === 'pieStairs' || layout === 'pyramid') {
        const hull = pieGroup.userData._fitHullLocal;
        if (Array.isArray(hull) && hull.length) {
          /* pieGroup steht hier auf scale=1 (oben gesetzt) → matrixWorld ist die
           * unskalierte Layout-Pose, in der auch der Fit rechnet. */
          const mw = pieGroup.matrixWorld;
          const pts = [];
          for (let hi = 0; hi < hull.length; hi++) {
            const p = hull[hi];
            v.set(p[0], p[1], p[2]).applyMatrix4(mw);
            pts.push(v.x, v.y, v.z);
          }
          const pushLabelFitCorners = (mesh) => {
            if (!mesh) return;
            if (!mesh.visible && !isUserHidden(mesh)) return;
            mesh.updateMatrixWorld(true);
            _textPlaneBox.setFromObject(mesh);
            if (_textPlaneBox.isEmpty()) return;
            const bb = _textPlaneBox;
            for (let xi = 0; xi <= 1; xi++) for (let yi = 0; yi <= 1; yi++) for (let zi = 0; zi <= 1; zi++) {
              pts.push(xi ? bb.max.x : bb.min.x, yi ? bb.max.y : bb.min.y, zi ? bb.max.z : bb.min.z);
            }
          };
          const calloutMeshes = pieGroup.userData._pieCalloutMeshes;
          if (Array.isArray(calloutMeshes)) calloutMeshes.forEach(pushLabelFitCorners);
          const pyramidAnnMeshes = pieGroup.userData._pyramidAnnotationMeshes;
          if (Array.isArray(pyramidAnnMeshes)) pyramidAnnMeshes.forEach(pushLabelFitCorners);
          layoutFitPoints = new Float32Array(pts);
        }
      }
      pieGroup.scale.copy(s);
      pieGroup.updateMatrixWorld(true);
    } else if (fd.isBubblePack) {
      const s = bubblePackGroup.scale.clone(); bubblePackGroup.scale.set(1, 1, 1); bubblePackGroup.updateMatrixWorld(true);
      /* Wie der wave-/radial-Zweig: per `_excludeFromWaveFit` markierte Kinder (z. B. der grosse
       * ShadowMaterial-Boden-Empfaenger aus packedBubble.js) NICHT in den Camera-Fit aufnehmen,
       * sonst blaeht die Schatten-Plane die Bbox auf und das Diagramm zoomt heraus. Verhaltens-
       * neutral fuer bestehende Szenen (bislang keine markierten Kinder in der bubblePackGroup). */
      box.makeEmpty();
      (function expandBubblePackFiltered(node) {
        for (const child of node.children) {
          if (child.userData && child.userData._excludeFromWaveFit) continue;
          if (child.isMesh || child.isInstancedMesh) box.expandByObject(child);
          if (child.children && child.children.length) expandBubblePackFiltered(child);
        }
      })(bubblePackGroup);
      /* Per-Legende versteckte Bubbles haben scale(0,0,0) im instanceMatrix —
       *  Three.js' Box3.expandByObject (intern fuer InstancedMesh) kollabiert
       *  diese Instanzen auf den Ursprung und schrumpft die Bbox. Rest-Matrix
       *  pro Instanz wurde in setDataIndexHidden vor dem Nullen gesichert;
       *  Box union mit deren Originalbeitrag rekonstruiert die Bbox so, als
       *  waeren die Bubbles noch sichtbar -> Auto-Centering bleibt stabil. */
      const bpInstMesh = typeof getBubblePackInstMesh === 'function' ? getBubblePackInstMesh() : null;
      if (bpInstMesh && hiddenDataIndices && hiddenDataIndices.size > 0
        && Array.isArray(bubblePackInstances) && bubblePackInstances.length) {
        const geom = bpInstMesh.geometry;
        if (geom && !geom.boundingBox) geom.computeBoundingBox();
        if (geom && geom.boundingBox) {
          bpInstMesh.updateMatrixWorld(true);
          const bpWorld = bpInstMesh.matrixWorld;
          for (let bi = 0; bi < bubblePackInstances.length; bi++) {
            const inst = bubblePackInstances[bi];
            if (!inst || !inst._cePdRestMatrix) continue;
            const di = inst._dataIndex != null ? inst._dataIndex : (inst.dataIndex != null ? inst.dataIndex : bi);
            if (!hiddenDataIndices.has(di)) continue;
            _bpInstMat.copy(inst._cePdRestMatrix).premultiply(bpWorld);
            _bpInstBox.copy(geom.boundingBox).applyMatrix4(_bpInstMat);
            box.union(_bpInstBox);
          }
        }
      }
      bubblePackGroup.scale.copy(s);
    } else if (fd.isSurface) {
      const s = surfaceGroup.scale.clone();
      surfaceGroup.scale.set(1, 1, 1);
      surfaceGroup.updateMatrixWorld(true);
      let surfaceFitStarted = false;
      surfaceGroup.traverse((obj) => {
        if (!obj.isMesh || obj.userData._excludeFromSurfaceFit) return;
        if (!surfaceFitStarted) {
          box.setFromObject(obj);
          surfaceFitStarted = true;
        } else {
          box.expandByObject(obj);
        }
      });
      box.min.y = Math.max(box.min.y, 0);
      surfaceGroup.scale.copy(s);
      surfaceGroup.updateMatrixWorld(true);
    }
    if (rulerEnabled) expandBoxByTextPlanes(box, rulerGroup);
    const center = box.getCenter(new THREE.Vector3());
    if (fd.isRadial) { center.x = 0; center.z = 0; center.y = radialCenterY; }
    /* Wave: KEINE erzwungene X-Symmetrie mehr. Frueher wurde center.x=0 gesetzt und
     * extentH auf maxAbsX*2 aufgeblasen — bei einseitig langen Labels (z. B. „Revenue")
     * sass die Box dann zu weit links (Label ragt rechts raus, Y-Skala links nicht snug).
     * Die echte AABB rahmt jetzt alle sichtbaren Inhalte exakt und auf beiden Seiten snug. */
    const size = box.getSize(new THREE.Vector3());
    const extentH = Math.max(size.x, size.z);
    const extentV = size.y;

    /* DEBUG: semi-transparent bbox fill + green edges + center — window.__ceShowBBox / ?ce_bbox */
    if (!window.__ceShowBBox) {
      disposeCeDebugBoxGroup();
      if (window.__ceDebugSphere) { scene.remove(window.__ceDebugSphere); window.__ceDebugSphere = null; }
      _frameCacheLayout = layout;
      _frameCacheResult = { center, extentH: Math.max(extentH, 0.5), extentV: Math.max(extentV, 0.5), size, fitPoints: layoutFitPoints };
      return _frameCacheResult;
    }
    disposeCeDebugBoxGroup();
    if (window.__ceDebugSphere) { scene.remove(window.__ceDebugSphere); window.__ceDebugSphere.geometry.dispose(); window.__ceDebugSphere.material.dispose(); }
    const dbgFillMat = new THREE.MeshBasicMaterial({
      color: 0x00ff00,
      transparent: true,
      opacity: 0.2,
      depthTest: false,
      depthWrite: false,
      side: THREE.DoubleSide,
    });
    const dbgLineMat = new THREE.LineBasicMaterial({ color: 0x00ff00, linewidth: 2, depthTest: false });
    const dbgGrp = new THREE.Group();
    if (layoutFitPoints && layoutFitPoints.length) {
      /* Echte Fit-Punktwolke (gestufte Silhouette + Callout-Ecken), die der Kamera-Fit
       * projiziert — als grüne Punkte, damit die Form direkt prüfbar ist. Punkte sind
       * bereits in Weltkoordinaten ⇒ Gruppe bleibt bei (0,0,0). */
      const ptsGeo = new THREE.BufferGeometry();
      ptsGeo.setAttribute('position', new THREE.Float32BufferAttribute(layoutFitPoints, 3));
      const ptsMat = new THREE.PointsMaterial({ color: 0x00ff00, size: 0.07, depthTest: false });
      const dbgPts = new THREE.Points(ptsGeo, ptsMat);
      dbgPts.renderOrder = 999;
      dbgGrp.add(dbgPts);
    } else {
      const dbgGeo = new THREE.BoxGeometry(size.x, size.y, size.z);
      const dbgFill = new THREE.Mesh(dbgGeo, dbgFillMat);
      dbgFill.renderOrder = 998;
      const dbgEdges = new THREE.EdgesGeometry(dbgGeo);
      const dbgLines = new THREE.LineSegments(dbgEdges, dbgLineMat);
      dbgLines.renderOrder = 999;
      dbgGrp.add(dbgFill);
      dbgGrp.add(dbgLines);
      dbgGrp.position.copy(center);
    }
    scene.add(dbgGrp);
    window.__ceDebugBox = dbgGrp;
    const sphGeo = new THREE.SphereGeometry(0.08, 16, 16);
    const sphMat = new THREE.MeshBasicMaterial({ color: 0xff0000, depthTest: false });
    window.__ceDebugSphere = new THREE.Mesh(sphGeo, sphMat);
    window.__ceDebugSphere.position.copy(center);
    window.__ceDebugSphere.renderOrder = 999;
    scene.add(window.__ceDebugSphere);

    _frameCacheLayout = layout;
    _frameCacheResult = { center, extentH: Math.max(extentH, 0.5), extentV: Math.max(extentV, 0.5), size, fitPoints: layoutFitPoints };
    return _frameCacheResult;
  }

  return { getLayoutFrame, invalidateFrameCache };
}
