import { paletteIndexForDataItem, resolveDataItemColor, contrastTextColor, PIE_STAIRS_SHADOW_OPACITY, PIE_STAIRS_CONTACT_STRENGTH } from '../../shared/config.js';
import { getFallbackChartNumberFormatter } from '../../shared/numberFormat.js';
import {
  formatBarValueLineForRow,
  clampPieSliceLabelFontScale,
  STRAIGHT_XLABEL_PLANE_H,
} from '../ruler.js';
import { createTroikaLabel, createOverlayLineMaterial, getLabelBounds } from '../troikaLabel.js';

/** Mindest-Bogenwinkel (rad): darunter kein Slice-Label (schmale „Spitzen“). */
const MIN_SLICE_ANGLE_FOR_LABEL = 0.23;
/** Sicherheitsabstand: Text darf die Sehnenbreite am Labelradius nicht überschreiten. */
const CHORD_WIDTH_MARGIN = 0.88;
/** Max. Höhe des Label-Planes relativ zur Ringbreite. */
const RING_HEIGHT_MARGIN = 0.82;

/** Dezenter Kantenbevel der Pie/Donut-Slices (Extrude), klein gegen pieDepth + Slice-Luecke. */
const PIE_SLICE_BEVEL = 0.012;
const PIE_CALLOUT_LINE_THICKNESS = 0.006;
const PIE_CALLOUT_LABEL_GAP = 0.02;
/** Wie Wave-Annotation-Titel (37/42). */
const PIE_CALLOUT_TITLE_FONT_RATIO = 37 / 42;
const PIE_CALLOUT_MAX_WIDTH_WORLD = 1.7;

function hexForPieCallout(barLabelColor, rulerColor) {
  const base = (typeof barLabelColor === 'string' && barLabelColor.trim() !== '')
    ? barLabelColor.trim()
    : (typeof rulerColor === 'string' && rulerColor.trim() !== '' ? rulerColor.trim() : '#858585');
  return base.startsWith('#') ? base : '#' + base;
}

function resolvePieCalloutPaintHex(pieCalloutColor, backgroundColor, backgroundTransparent, barLabelColor, rulerColor) {
  const user = typeof pieCalloutColor === 'string' ? pieCalloutColor.trim() : '';
  if (user && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(user)) {
    return user.startsWith('#') ? user : '#' + user;
  }
  const bgHex = backgroundTransparent ? '#ffffff' : (typeof backgroundColor === 'string' && backgroundColor.trim() ? backgroundColor.trim() : '#f8f9fa');
  const fromBg = contrastTextColor(bgHex);
  if (fromBg) return fromBg;
  return hexForPieCallout(barLabelColor, rulerColor);
}

/**
 * Pie-Slice-Glyph-Hoehe relativ zur alten Plane-Hoehe. Alte Werte:
 *   PIE_SLICE_BASE_FONT (25) / HEADLINE_CANVAS_LOGICAL_H (42) ≈ 0.595
 * mal `fs * 0.92` (Plane-Skalierung in der alten Renderung).
 */
const PIE_SLICE_FONT_RATIO = 25 / 42;
const PIE_SLICE_FONT_PLANE_FACTOR = 0.92;

/** Hex-Int (0xRRGGBB) → '#rrggbb' für Autokontrast. */
function intToCssHex(c) {
  const n = (c >>> 0) & 0xffffff;
  return '#' + n.toString(16).padStart(6, '0');
}

export function createPie(ctx) {
  const {
    THREE,
    DATA,
    count,
    barDepth,
    HOVER_PALETTE,
    GRAY_PALETTE,
    alwaysColorful,
    uniformDataColor,
    axisYPrefix = '',
    axisYSuffix = '',
    dataLabelFontScale = 1,
    pieSliceLabelMode = 'value',
    pieInnerRadius = 0.5,
    pieThickness = 1,
    pieAngleBasis = 'sum',
    pieLabelAutoContrast = true,
    barLabelColor = '',
    rulerColor = '#858585',
    pieCalloutLineLength = 0.25,
    pieCalloutFontScale = 1,
    pieCalloutColor = '',
    backgroundColor = '#f8f9fa',
    backgroundTransparent = false,
    formatChartNumber: formatChartNumberArg,
    /* Stairs-Variante: Ring liegt flach (sitzt) statt aufrecht, jedes Segment
     * wird treppenförmig höher extrudiert. */
    stairs = false,
    stairStepHeight = 0.18,
  } = ctx;

  const formatChartNumber = typeof formatChartNumberArg === 'function'
    ? formatChartNumberArg
    : getFallbackChartNumberFormatter().formatTick;

  const pieRadius = 2.2;
  const pieInnerR = pieRadius * pieInnerRadius;
  const pieDepth = barDepth * 0.6 * pieThickness;
  const pieGapWidth = 0.035;
  const pieStartAngle = -Math.PI / 2;
  const hasHole = pieInnerR > 0.01;
  const totalVal = DATA.reduce((s, d) => s + (d.value > 0 ? d.value : 0), 0) || 1;
  const useHundredBasis = pieAngleBasis === 'hundred';
  const hundredScale = useHundredBasis && totalVal > 0 ? Math.min(1, 100 / totalVal) : 1;
  const activeCount = DATA.filter((d) => d.value > 0 && Number.isFinite(d.value)).length;
  const gapAngleInner = hasHole ? pieGapWidth / pieInnerR : 0;
  const gapAngleOuter = pieGapWidth / pieRadius;
  const pieMidR = hasHole ? (pieInnerR + pieRadius) * 0.5 : 0;
  const gapAngleMid = hasHole && pieMidR > 1e-6 ? pieGapWidth / pieMidR : 0;
  const pieTotalInner = Math.PI * 2 - activeCount * gapAngleInner;
  const pieTotalOuter = Math.PI * 2 - activeCount * gapAngleOuter;
  const pieTotalMid = hasHole ? Math.PI * 2 - activeCount * gapAngleMid : 0;
  const pieGroup = new THREE.Group();
  pieGroup.userData._pieCalloutMeshes = [];
  pieGroup.userData._pieCalloutLines = [];
  /* Inner-Labels (Text im Slice / Ring). Pro Legenden-Toggle ueber
   *  `_dataIndex` ausgeblendet → hier sammeln. */
  pieGroup.userData._pieInnerLabels = [];
  /* Stairs-Fit-Hull (lokale Koordinaten, vor der -90°-Drehung der pieGroup):
   *  Boden-Rand + pro Stufe der Ober-Rand auf der jeweiligen Stufenhöhe. Wird vom
   *  Layout-Frame in Weltkoordinaten transformiert und treibt den Kamera-Fit, damit
   *  dieser auf die echte gestufte Silhouette passt statt auf ein gleich hohes Prisma. */
  pieGroup.userData._fitHullLocal = [];
  const pieMeshes = [];

  const fs = clampPieSliceLabelFontScale(dataLabelFontScale);
  const sliceFontSize = STRAIGHT_XLABEL_PLANE_H * PIE_SLICE_FONT_RATIO * fs * PIE_SLICE_FONT_PLANE_FACTOR;
  /* Plane-Hoehe im alten Pfad = STRAIGHT_XLABEL_PLANE_H * fs * 0.92; bleibt fuer den
   * Ringthickness-Check der Ringbreite identisch. */
  const slicePlaneH = STRAIGHT_XLABEL_PLANE_H * fs * PIE_SLICE_FONT_PLANE_FACTOR;

  const labelR = hasHole ? (pieInnerR + pieRadius) * 0.5 : pieRadius * 0.55;
  const ringThickness = pieRadius - pieInnerR;
  /* Aufrecht: Label sitzt auf der zur Kamera zeigenden Vorderseite (Mittelebene
   * der Extrusion). Stairs: pro Stufe auf der Oberseite (per-Slice gesetzt). */
  const labelZBase = pieDepth / 2 + 0.028;

  let innerAngle = pieStartAngle;
  let outerAngle = pieStartAngle;
  /** Mitte Ring (nur Auswertung für 100 %-Lückenführungslinie). */
  let midAngleTrail = pieStartAngle;
  /** Treppen-Stufenindex (zählt nur aktive Segmente). */
  let stepIndex = 0;

  for (let i = 0; i < count; i++) {
    const val = DATA[i].value;
    if (val <= 0 || !Number.isFinite(val)) continue;
    /* Stairs: Stufe i wird um stairStepHeight höher extrudiert; Unterseite bei
     * z=0, damit nach der Flachlegung alle Stufen auf dem Boden stehen. */
    const sliceDepth = stairs ? (pieDepth + stepIndex * stairStepHeight) : pieDepth;
    const labelZ = stairs ? (sliceDepth + 0.028) : labelZBase;
    stepIndex++;
    const p = useHundredBasis ? ((val / 100) * hundredScale) : (val / totalVal);
    const sliceInner = p * pieTotalInner;
    const sliceOuter = p * pieTotalOuter;
    const innerStart = innerAngle;
    const innerEnd = innerAngle + sliceInner;
    const outerStart = outerAngle;
    const outerEnd = outerAngle + sliceOuter;
    innerAngle = innerEnd + gapAngleInner;
    outerAngle = outerEnd + gapAngleOuter;
    if (stairs) {
      /* Aussenkante dieser Stufe abtasten: z=0 (Boden) für den runden Grundriss,
       * z=sliceDepth (Stufenhöhe) für die Treppen-Oberkante. */
      const hull = pieGroup.userData._fitHullLocal;
      const span = outerEnd - outerStart;
      const segs = Math.max(2, Math.ceil(span / (Math.PI / 6)));
      for (let s = 0; s <= segs; s++) {
        const a = outerStart + (span * s) / segs;
        const rx = pieRadius * Math.cos(a);
        const ry = pieRadius * Math.sin(a);
        hull.push([rx, ry, 0], [rx, ry, sliceDepth]);
      }
    }
    if (hasHole && useHundredBasis) {
      const sliceMid = p * pieTotalMid;
      midAngleTrail += sliceMid + gapAngleMid;
    }
    const shape = new THREE.Shape();
    if (hasHole) {
      shape.moveTo(pieInnerR * Math.cos(innerStart), pieInnerR * Math.sin(innerStart));
      shape.lineTo(pieRadius * Math.cos(outerStart), pieRadius * Math.sin(outerStart));
      shape.absarc(0, 0, pieRadius, outerStart, outerEnd, false);
      shape.lineTo(pieInnerR * Math.cos(innerEnd), pieInnerR * Math.sin(innerEnd));
      shape.absarc(0, 0, pieInnerR, innerEnd, innerStart, true);
    } else {
      shape.moveTo(0, 0);
      shape.lineTo(pieRadius * Math.cos(outerStart), pieRadius * Math.sin(outerStart));
      shape.absarc(0, 0, pieRadius, outerStart, outerEnd, false);
      shape.lineTo(0, 0);
    }
    shape.closePath();
    /* Dezenter, fest verdrahteter Bevel rundet die Slice-Kanten leicht ab.
     * `bevelOffset: -bevelSize` setzt den Bevel nach INNEN: er startet um bevelSize
     * innerhalb der Kontur und waechst bis exakt zur Original-Slice-Kontur zurueck →
     * die radiale Aussen-/Innenkontur (pieRadius/pieInnerR) und die Slice-Luecken
     * bleiben unveraendert, das Konstrukt wird nicht groesser. (Die Extrusionstiefe
     * waechst noch minimal um bevelThickness je Ende; beim Pie kaum sichtbar.) */
    const sliceGeo = new THREE.ExtrudeGeometry(shape, {
      depth: sliceDepth,
      bevelEnabled: true,
      bevelThickness: PIE_SLICE_BEVEL,
      bevelSize: PIE_SLICE_BEVEL,
      bevelOffset: -PIE_SLICE_BEVEL,
      bevelSegments: 2,
      curveSegments: 32,
    });
    /* Aufrecht: symmetrisch um z=0 zentrieren. Stairs: Unterseite bei z=0 lassen,
     * damit die Stufen nach der −90°-Drehung gemeinsam auf dem Boden aufsetzen. */
    if (!stairs) sliceGeo.translate(0, 0, -pieDepth / 2);
    const pi = paletteIndexForDataItem(DATA[i], i);
    const barColor = resolveDataItemColor(DATA[i], i, HOVER_PALETTE, uniformDataColor);
    const grayC = GRAY_PALETTE[pi % GRAY_PALETTE.length];
    const initialColor = alwaysColorful ? new THREE.Color(barColor).getHex() : grayC;
    const sliceMat = new THREE.MeshPhongMaterial({ color: initialColor, emissive: initialColor, emissiveIntensity: 0.4, shininess: 120, specular: 0x444444 });
    const sliceMesh = new THREE.Mesh(sliceGeo, sliceMat);
    /* Beide Varianten werfen einen echten Schlagschatten auf den Boden (Setup in
     * scene/lights.js + Empfänger-Plane unten): pieStairs auf seinen flachliegenden
     * Empfänger, das flache Pie/Donut (aufrechte Scheibe) auf den radial-artigen
     * Empfänger im else-Zweig. receiveShadow=true → gegenseitige Verschattung
     * (Stairs-Stufen bzw. Donut-Tiefe). */
    sliceMesh.castShadow = true;
    sliceMesh.receiveShadow = true;
    sliceMesh.userData._DATA = DATA;
    sliceMesh.userData._dataIndex = i;
    sliceMesh.userData._defaultColor = new THREE.Color(barColor).getHex();
    sliceMesh.userData.color = grayC;
    sliceMesh.userData._targetColor = initialColor;
    pieGroup.add(sliceMesh);
    pieMeshes.push(sliceMesh);

    if (pieSliceLabelMode === 'off') continue;

    const innerAsValue = pieSliceLabelMode === 'value' || pieSliceLabelMode === 'value_callout';
    const innerAsHeadline = pieSliceLabelMode === 'headline';

    let lineText = '';
    if (innerAsValue) {
      lineText = formatBarValueLineForRow(DATA[i], axisYPrefix, axisYSuffix, formatChartNumber);
    } else if (innerAsHeadline) {
      lineText = String(DATA[i].label || '').trim();
    }
    if (!lineText || sliceOuter < MIN_SLICE_ANGLE_FOR_LABEL) {
      lineText = '';
    }

    const chord = 2 * labelR * Math.sin(sliceOuter / 2);
    const midAngle = outerStart + sliceOuter / 2;

    if (lineText) {
      const sliceFillCss = intToCssHex(initialColor);
      const textFill = pieLabelAutoContrast
        ? (contrastTextColor(sliceFillCss) || '#ffffff')
        : '#ffffff';
      const slicePlaneHCheck = slicePlaneH;
      const estW = String(lineText).length * sliceFontSize * 0.55;
      if (slicePlaneHCheck <= ringThickness * RING_HEIGHT_MARGIN && estW <= chord * CHORD_WIDTH_MARGIN) {
        const labelMesh = createTroikaLabel({
          THREE,
          text: String(lineText).length > 28 ? String(lineText).slice(0, 27) + '\u2026' : lineText,
          color: textFill,
          fontSize: sliceFontSize,
          anchorX: 'center',
          anchorY: 'middle',
          weight: 'bold',
          renderOrder: 1000,
          depthTest: true,
          onSync: (m) => {
            const b = getLabelBounds(m);
            if (b.width > chord * CHORD_WIDTH_MARGIN || b.height > ringThickness * RING_HEIGHT_MARGIN) {
              m.visible = false;
            }
          },
        });
        const lx = labelR * Math.cos(midAngle);
        const ly = labelR * Math.sin(midAngle);
        labelMesh.position.set(lx, ly, labelZ);
        labelMesh.userData._dataIndex = i;
        pieGroup.userData._pieInnerLabels.push(labelMesh);
        pieGroup.add(labelMesh);
      }
    }

    if (pieSliceLabelMode === 'value_callout') {
      const calloutText = String(DATA[i].label || '').trim();
      if (calloutText && sliceOuter >= MIN_SLICE_ANGLE_FOR_LABEL) {
        const lineLen = typeof pieCalloutLineLength === 'number' ? pieCalloutLineLength : 0.25;
        const calloutFs = typeof pieCalloutFontScale === 'number' ? pieCalloutFontScale : 1;
        const cz = labelZ;
        const sx = pieRadius * Math.cos(midAngle);
        const sy = pieRadius * Math.sin(midAngle);
        const ex = (pieRadius + lineLen) * Math.cos(midAngle);
        const ey = (pieRadius + lineLen) * Math.sin(midAngle);
        const mx = (sx + ex) / 2;
        const my = (sy + ey) / 2;

        const rulerColorHex = resolvePieCalloutPaintHex(pieCalloutColor, backgroundColor, backgroundTransparent, barLabelColor, rulerColor);
        /* contrastTextColor liefert #fff/#000 (3-stellig); parseInt('fff',16) wäre 4095 → falsch blau in setHex. */
        const lineColorNum = new THREE.Color(rulerColorHex).getHex();

        const lineGeo = new THREE.BoxGeometry(lineLen, PIE_CALLOUT_LINE_THICKNESS, PIE_CALLOUT_LINE_THICKNESS);
        const lineMat = createOverlayLineMaterial(THREE, lineColorNum, 0);
        const lineMesh = new THREE.Mesh(lineGeo, lineMat);
        lineMesh.position.set(mx, my, cz);
        lineMesh.rotation.z = midAngle;
        lineMesh.renderOrder = 1002;
        lineMesh.layers.set(1);
        lineMesh.userData._dataIndex = i;
        pieGroup.userData._pieCalloutLines.push(lineMesh);
        pieGroup.add(lineMesh);

        const calloutFontSize = STRAIGHT_XLABEL_PLANE_H * PIE_CALLOUT_TITLE_FONT_RATIO * fs * calloutFs;
        const anchorRight = Math.cos(midAngle) < 0;
        const tx = ex + PIE_CALLOUT_LABEL_GAP * Math.cos(midAngle);
        const ty = ey + PIE_CALLOUT_LABEL_GAP * Math.sin(midAngle);
        const disp = calloutText.length > 36 ? calloutText.slice(0, 35) + '\u2026' : calloutText;
        const calloutMesh = createTroikaLabel({
          THREE,
          text: disp,
          color: rulerColorHex,
          fontSize: calloutFontSize,
          anchorX: anchorRight ? 'right' : 'left',
          anchorY: 'middle',
          weight: 'bold',
          maxWidth: PIE_CALLOUT_MAX_WIDTH_WORLD * calloutFs,
          textAlign: anchorRight ? 'right' : 'left',
          renderOrder: 1002,
          onSync: () => {
            try {
              const fn = pieGroup.userData._onPieCalloutLayoutDirty;
              if (typeof fn === 'function') fn();
            } catch (_) { /* ignore */ }
          },
        });
        if (calloutMesh.material) calloutMesh.material.opacity = 0;
        calloutMesh.userData._dataIndex = i;
        calloutMesh.position.set(tx, ty, cz);
        calloutMesh.quaternion.identity();
        calloutMesh.layers.set(1);
        pieGroup.userData._pieCalloutMeshes.push(calloutMesh);
        pieGroup.add(calloutMesh);
      }
    }
  }

  if (hasHole && useHundredBasis && activeCount > 0) {
    const consumedMid = midAngleTrail - pieStartAngle;
    const gapSweep = Math.PI * 2 - consumedMid;
    /** Nach dem letzten Slice folgt bereits gapAngleMid; vor Slice 1 fehlt der symmetrische Abstand ohne Verkürzung. */
    const guideSweep = Math.max(0, gapSweep - gapAngleMid);
    if (guideSweep > 8e-4) {
      /* Mittelebene der Extrusion (Nach translate liegt z=0 im „Fleisch“ der Scheibe). */
      const zGuide = 0;
      const nSeg = Math.min(144, Math.max(16, Math.ceil(guideSweep * pieMidR * 24)));
      const gapPts = [];
      for (let s = 0; s <= nSeg; s++) {
        const t = midAngleTrail + (guideSweep * s) / nSeg;
        gapPts.push(new THREE.Vector3(pieMidR * Math.cos(t), pieMidR * Math.sin(t), zGuide));
      }
      const guideGeo = new THREE.BufferGeometry().setFromPoints(gapPts);
      const guideMat = new THREE.LineBasicMaterial({
        color: 0x000000,
        depthTest: true,
        depthWrite: false,
      });
      const guideLine = new THREE.Line(guideGeo, guideMat);
      guideLine.castShadow = false;
      guideLine.receiveShadow = false;
      guideLine.layers.set(0);
      pieGroup.add(guideLine);
    }
  }

  if (stairs) {
    /* Flach legen: lokale +z-Extrusion (Stufenhöhe) zeigt nach Welt-+y (oben),
     * die runde Ringfläche liegt in der XZ-Ebene. Unterseiten bei y=0 → sitzt
     * auf dem Boden. */
    pieGroup.rotation.x = -Math.PI / 2;
    pieGroup.position.y = 0;

    /* ── Schatten (A + C) ──
     * Durch die -90°-Drehung der pieGroup wird lokales +z → Welt +y (oben). Eine
     * Plane in der lokalen XY-Ebene liegt damit automatisch waagrecht auf dem Boden;
     * ein kleines lokales z hebt sie minimal über das floorOverlay (Welt y≈0.002),
     * um Z-Fighting zu vermeiden. Beide Planes sind vom Kamera-Fit ausgenommen
     * (_excludeFromPieFit) und nicht raycast-bar. */

    /* A — Schatten-Empfänger: ShadowMaterial ist überall transparent und zeigt NUR
     * den vom keyLight projizierten weichen Schatten. Funktioniert über jedem
     * Hintergrund (grid/gradient/transparent). Opacity = Dezenz-Regler. */
    const shadowReceiver = new THREE.Mesh(
      new THREE.PlaneGeometry(18, 18),
      new THREE.ShadowMaterial({ opacity: PIE_STAIRS_SHADOW_OPACITY, transparent: true, depthWrite: false })
    );
    shadowReceiver.receiveShadow = true;
    shadowReceiver.position.z = 0.006;
    shadowReceiver.renderOrder = 3;
    shadowReceiver.raycast = () => {};
    shadowReceiver.userData._excludeFromPieFit = true;
    pieGroup.add(shadowReceiver);

    /* C — Kontakt-AO-Saum: weicher dunkler Ring, der den Scheibenrand „erdet“. Der
     * Gradient ist im Zentrum (unter der Scheibe, ohnehin verdeckt) klar und am
     * Außenradius (≈ pieRadius) am dunkelsten, danach ausgeblendet. */
    const aoCanvas = document.createElement('canvas');
    aoCanvas.width = aoCanvas.height = 128;
    const aoCtx = aoCanvas.getContext('2d');
    const aoGrad = aoCtx.createRadialGradient(64, 64, 0, 64, 64, 64);
    aoGrad.addColorStop(0.00, 'rgba(0,0,0,0)');
    aoGrad.addColorStop(0.62, 'rgba(0,0,0,0)');
    aoGrad.addColorStop(0.80, `rgba(0,0,0,${PIE_STAIRS_CONTACT_STRENGTH})`);
    aoGrad.addColorStop(1.00, 'rgba(0,0,0,0)');
    aoCtx.fillStyle = aoGrad;
    aoCtx.fillRect(0, 0, 128, 128);
    const aoTex = new THREE.CanvasTexture(aoCanvas);
    aoTex.colorSpace = THREE.SRGBColorSpace;
    aoTex.needsUpdate = true;
    /* Gradient-Maximum (0.80) auf den Scheibenrand legen → planeHalf = pieRadius / 0.80. */
    const aoPlaneSize = (pieRadius / 0.8) * 2;
    const contactAo = new THREE.Mesh(
      new THREE.PlaneGeometry(aoPlaneSize, aoPlaneSize),
      new THREE.MeshBasicMaterial({ map: aoTex, transparent: true, depthWrite: false })
    );
    contactAo.position.z = 0.004;
    contactAo.renderOrder = 2;
    contactAo.raycast = () => {};
    contactAo.userData._excludeFromPieFit = true;
    pieGroup.add(contactAo);
  } else {
    pieGroup.position.y = pieRadius + 0.12;

    /* ── Echter Bodenschatten (flaches Pie/Donut) ──
     * Rezept analog radial.js: die aufrechte Scheibe schwebt knapp ueber dem Boden
     * (Welt-y≈0, da pieGroup.position.y die Unterkante anhebt). Das keyLight steht
     * vorn-oben-rechts → der Schlagschatten faellt nach hinten-links und wird zur
     * Scheibenoberkante hin lang. Empfaenger = grosse, flach am Boden liegende
     * ShadowMaterial-Plane inkl. Back-Fade (blendet nach hinten aus). Sie haengt in der
     * pieGroup und ist via `_excludeFromPieFit` vom Camera-Fit ausgenommen.
     * Projektionsfaktoren wie ueberall: pro Hoeheneinheit 1.5 in −Z und 0.5 in −X. */
    const groundMinX = -pieRadius, groundMaxX = pieRadius;
    const groundMinZ = -pieDepth / 2, groundMaxZ = pieDepth / 2;
    const groundMaxH = pieGroup.position.y + pieRadius;
    const groundDropZ = 1.5 * groundMaxH, groundDropX = 0.5 * groundMaxH;
    const groundFrontZ = groundMaxZ + 1.0;
    const groundBackZ = groundMinZ - groundDropZ - 1.5;
    const groundLeftX = groundMinX - groundDropX - 1.5;
    const groundRightX = groundMaxX + 1.5;
    const groundCx = (groundLeftX + groundRightX) / 2;
    const groundCz = (groundFrontZ + groundBackZ) / 2;
    const groundPlaneW = groundRightX - groundLeftX;
    const groundPlaneD = groundFrontZ - groundBackZ;
    /* Back-Fade im lokalen Geometrie-Raum der um −90° gedrehten Plane: lokales position.y
     * laeuft entlang der Tiefe (worldZ = groundCz − localY). Vorne (Scheibenfuss) voll,
     * nach hinten ausblendend. */
    const groundFadeFullY = (groundCz - (groundMaxZ + 0.5)).toFixed(4);
    const groundFadeEndY = (groundCz - (groundBackZ + 0.5)).toFixed(4);
    const groundMat = new THREE.ShadowMaterial({ opacity: 0.24, transparent: true, depthWrite: false });
    groundMat.onBeforeCompile = (shader) => {
      shader.vertexShader = shader.vertexShader
        .replace('#include <common>', '#include <common>\nvarying float vShadowFadeY;')
        .replace('#include <begin_vertex>', '#include <begin_vertex>\nvShadowFadeY = position.y;');
      shader.fragmentShader = shader.fragmentShader
        .replace('#include <common>', '#include <common>\nvarying float vShadowFadeY;')
        .replace(
          'gl_FragColor = vec4( color, opacity * ( 1.0 - getShadowMask() ) );',
          'gl_FragColor = vec4( color, opacity * ( 1.0 - getShadowMask() ) );\n\tgl_FragColor.a *= 1.0 - smoothstep(' + groundFadeFullY + ', ' + groundFadeEndY + ', vShadowFadeY);',
        );
    };
    const groundMesh = new THREE.Mesh(new THREE.PlaneGeometry(groundPlaneW, groundPlaneD), groundMat);
    groundMesh.rotation.x = -Math.PI / 2;
    /* Welt-y ≈ 0 (Boden): lokales y = −pieGroup.position.y kompensiert den Y-Offset der Gruppe. */
    groundMesh.position.set(groundCx, -pieGroup.position.y - 0.003, groundCz);
    groundMesh.receiveShadow = true;
    groundMesh.castShadow = false;
    groundMesh.renderOrder = 1;
    groundMesh.raycast = () => {};
    groundMesh.userData._excludeFromPieFit = true;
    pieGroup.add(groundMesh);
  }

  return { pieGroup, pieMeshes };
}
