import {
  paletteIndexForDataItem,
  resolveDataItemColor,
  contrastTextColor,
} 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';
import { enableProjectionFlatMorph } from '../projectionFlatMaterial.js';

/** Grundriss-Breite unten (Welt-Einheiten); Spitze ≈ PYRAMID_APEX_RATIO × Basis. */
const PYRAMID_BASE_WIDTH = 3.6;
const PYRAMID_APEX_RATIO = 0.04;
/** Summe der Segmenthöhen (ohne Gaps); Gaps kommen zusätzlich dazu. */
const PYRAMID_TOTAL_HEIGHT = 4.0;
/** Abstand Unterkante → Boden (Welt-y≈0); erzeugt eine sichtbare Schattenfuge. */
const PYRAMID_GROUND_CLEARANCE = 0.02;
/** Dezenter Chamfer nur an horizontalen Segment-Schnittflächen (nicht an den Schrägen). */
const PYRAMID_CUT_BEVEL = 0.012;
/** Phong-Glanz der Segmente (niedriger als Pie/Balken: 120 / 0x444444). */
const PYRAMID_SLICE_SHININESS = 48;
const PYRAMID_SLICE_SPECULAR = 0x222222;

const PYRAMID_CALLOUT_LINE_THICKNESS = 0.006;
const PYRAMID_CALLOUT_LABEL_GAP = 0.02;
const PYRAMID_SLICE_FONT_RATIO = 25 / 42;
const PYRAMID_SLICE_FONT_PLANE_FACTOR = 0.92;
const ANNOTATION_TITLE_FONT_RATIO = 37 / 42;
const ANNOTATION_DESC_FONT_RATIO = 27 / 42;
const ANNOTATION_SOURCE_FONT_RATIO = 22 / 42;
const ANNOTATION_MAX_WIDTH_WORLD = 1.7;
/** Fallback-Zeilenhöhe vor Troika-Sync; Abstände Headline↔Desc / Desc↔Source getrennt. */
const ANNOTATION_STACK_LINE_FACTOR = 1.1;
const ANNOTATION_GAP_HEADLINE_DESC = 0.02;
const ANNOTATION_GAP_DESC_SOURCE = 0.04;
const MIN_SEGMENT_HEIGHT_FOR_LABEL = 0.14;
const SEGMENT_WIDTH_MARGIN = 0.82;
const SEGMENT_HEIGHT_MARGIN = 0.78;
const LABEL_Z_OFFSET = 0.028;

/** Abstand Mitte → Kante auf X/Z (CylinderGeometry(4): Kantenlänge w, Radius r = w/√2). */
function pyramidAxisRadius(w) {
  return w / Math.SQRT2;
}

/**
 * Breite des Pyramidenquerschnitts bei Höhe y (0 = Boden, totalStructH = Spitze).
 * Standard: unten breit, oben spitz. inverted: unten spitz, oben breit (auf dem Kopf).
 */
function pyramidWidthAtY(y, totalStructH, inverted = false) {
  const t = totalStructH > 1e-6 ? Math.min(1, Math.max(0, y / totalStructH)) : 0;
  const tEff = inverted ? (1 - t) : t;
  const apexW = PYRAMID_BASE_WIDTH * PYRAMID_APEX_RATIO;
  return PYRAMID_BASE_WIDTH - (PYRAMID_BASE_WIDTH - apexW) * tEff;
}

/**
 * Fit-Hull: vier Eckpunkte eines Rings bei Höhe y (identisch zu CylinderGeometry(4):
 * w = Kantenlänge der Fläche → Zylinder-Radius r = w/√2, Vertices auf den Achsen).
 */
function pushPyramidRingHull(hull, w, y) {
  const r = w / Math.SQRT2;
  hull.push([r, y, 0], [-r, y, 0], [0, y, r], [0, y, -r]);
}

function pyramidCutBevelSize(wBottom, wTop, height) {
  return Math.min(
    PYRAMID_CUT_BEVEL,
    height * 0.35,
    Math.max((wBottom - wTop) * 0.2, 0.002),
    wBottom * 0.08,
    Math.max(wTop * 0.2, 0.002),
  );
}

/**
 * Shadow-Map-Tiefe ohne projectionFlat-onBeforeCompile (sonst fehlen Cast/Receive zwischen Segmenten).
 */
function attachPyramidShadowDepthMaterial(THREE, visibleMat, colorHex, opacity) {
  visibleMat.customDepthMaterial = new THREE.MeshPhongMaterial({
    color: colorHex,
    emissive: colorHex,
    emissiveIntensity: 0.4,
    shininess: PYRAMID_SLICE_SHININESS,
    specular: PYRAMID_SLICE_SPECULAR,
    transparent: opacity < 0.999,
    opacity,
    depthWrite: opacity >= 0.999,
  });
  visibleMat.shadowSide = THREE.DoubleSide;
}

function mergeBufferGeometries(THREE, geometries) {
  const pos = [];
  const norm = [];
  const idx = [];
  let vOff = 0;
  for (let g = 0; g < geometries.length; g++) {
    const geo = geometries[g];
    geo.computeVertexNormals();
    const p = geo.attributes.position;
    const n = geo.attributes.normal;
    for (let i = 0; i < p.count; i++) {
      pos.push(p.getX(i), p.getY(i), p.getZ(i));
      norm.push(n.getX(i), n.getY(i), n.getZ(i));
    }
    if (geo.index) {
      const ind = geo.index;
      for (let i = 0; i < ind.count; i++) idx.push(ind.getX(i) + vOff);
    } else {
      for (let i = 0; i < p.count; i += 3) {
        idx.push(vOff + i, vOff + i + 1, vOff + i + 2);
      }
    }
    vOff += p.count;
    geo.dispose();
  }
  const merged = new THREE.BufferGeometry();
  merged.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  merged.setAttribute('normal', new THREE.Float32BufferAttribute(norm, 3));
  merged.setIndex(idx);
  merged.computeVertexNormals();
  return merged;
}

/**
 * Quadratischer Pyramidenfrustum entlang +Y. Schräge Mantelflächen geschlossen;
 * nur horizontale Schnittflächen (oben/unten) mit dezentem Chamfer.
 */
function createPyramidFrustum(THREE, wBottom, wTop, height, opts = {}) {
  const { bevelTop = true, bevelBottom = true } = opts;
  const rBot = wBottom / Math.SQRT2;
  const rTop = wTop / Math.SQRT2;
  const bFull = pyramidCutBevelSize(wBottom, wTop, height);
  const bTop = bevelTop ? bFull : 0;
  const bBot = bevelBottom ? bFull : 0;

  if (bTop + bBot < 0.001 || height < bTop + bBot + 0.02) {
    const geo = new THREE.CylinderGeometry(rTop, rBot, height, 4);
    geo.translate(0, height / 2, 0);
    return geo;
  }

  const coreH = height - bTop - bBot;
  const geos = [];
  const body = new THREE.CylinderGeometry(rTop, rBot, coreH, 4);
  body.translate(0, bBot + coreH / 2, 0);
  geos.push(body);

  if (bTop > 0.001) {
    const rTopInner = Math.max(rTop - bTop / Math.SQRT2, rTop * 0.55);
    const topBevel = new THREE.CylinderGeometry(rTopInner, rTop, bTop, 4);
    topBevel.translate(0, height - bTop / 2, 0);
    geos.push(topBevel);
  }
  if (bBot > 0.001) {
    const rBotInner = Math.max(rBot - bBot / Math.SQRT2, rBot * 0.55);
    const botBevel = new THREE.CylinderGeometry(rBot, rBotInner, bBot, 4);
    botBevel.translate(0, bBot / 2, 0);
    geos.push(botBevel);
  }

  return mergeBufferGeometries(THREE, geos);
}

function hexForPyramidCallout(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 intToCssHex(c) {
  const n = (c >>> 0) & 0xffffff;
  return '#' + n.toString(16).padStart(6, '0');
}

function pyramidAnnotationGapAfter(kind) {
  if (kind === 'title') return ANNOTATION_GAP_HEADLINE_DESC;
  if (kind === 'desc') return ANNOTATION_GAP_DESC_SOURCE;
  return 0;
}

function pyramidAnnotationRowHeight(mesh) {
  const b = getLabelBounds(mesh);
  if (b.height > 1e-5) return b.height;
  const fs = mesh.fontSize || 0.1;
  return fs * ANNOTATION_STACK_LINE_FACTOR;
}

/** Headline oben, Description, Source unten — Abstände nach echter Troika-Höhe (mehrzeiliges Desc). */
function layoutPyramidAnnotationStack({ meshes, yMid, textX, zSide }) {
  if (!meshes.length) return;
  const heights = meshes.map((m) => pyramidAnnotationRowHeight(m));
  let total = heights[0];
  for (let si = 0; si < meshes.length - 1; si++) {
    const kind = meshes[si].userData._pyramidAnnKind;
    total += pyramidAnnotationGapAfter(kind) + heights[si + 1];
  }
  let yTop = yMid + total * 0.5;
  for (let si = 0; si < meshes.length; si++) {
    meshes[si].position.set(textX, yTop, zSide);
    if (si < meshes.length - 1) {
      yTop -= heights[si] + pyramidAnnotationGapAfter(meshes[si].userData._pyramidAnnKind);
    }
  }
}

function addPyramidSideAnnotation({
  THREE,
  pieGroup,
  side,
  seg,
  yMid,
  rMid,
  lineLen,
  titleFontSize,
  descFontSize,
  sourceFontSize,
  rulerColorHex,
  lineColorNum,
}) {
  const i = seg.index;
  const title = String(seg.data.label || '').trim();
  const desc = String(seg.data.desc || '').trim();
  const source = String(seg.data.source || '').trim();
  if (!title && !desc && !source) return;

  /* Linke/rechte Fläche (z≈0), nicht Vorderkante — sonst wächst der Abstand nach unten mit rMid. */
  const zSide = LABEL_Z_OFFSET;
  const sign = side === 'left' ? -1 : 1;
  const sx = sign * rMid;
  const ex = sx + sign * lineLen;
  const mx = (sx + ex) / 2;
  const anchorX = side === 'left' ? 'right' : 'left';
  const textX = side === 'left' ? ex - PYRAMID_CALLOUT_LABEL_GAP : ex + PYRAMID_CALLOUT_LABEL_GAP;
  const textAlign = anchorX;

  const lineGeo = new THREE.BoxGeometry(lineLen, PYRAMID_CALLOUT_LINE_THICKNESS, PYRAMID_CALLOUT_LINE_THICKNESS);
  const lineMat = createOverlayLineMaterial(THREE, lineColorNum, 0);
  const lineMesh = new THREE.Mesh(lineGeo, lineMat);
  lineMesh.position.set(mx, yMid, zSide);
  lineMesh.renderOrder = 1002;
  lineMesh.layers.set(1);
  lineMesh.userData._dataIndex = i;
  pieGroup.userData._pyramidAnnotationLines.push(lineMesh);
  pieGroup.add(lineMesh);

  const stack = [];
  if (title) {
    stack.push({
      kind: 'title',
      text: title.length > 40 ? title.slice(0, 39) + '\u2026' : title,
      fontSize: titleFontSize,
      weight: 'bold',
      isDesc: false,
      isSource: false,
    });
  }
  if (desc) {
    stack.push({
      kind: 'desc',
      text: desc.length > 56 ? desc.slice(0, 55) + '\u2026' : desc,
      fontSize: descFontSize,
      weight: 'light',
      isDesc: true,
      isSource: false,
      fillOpacity: 0.72,
    });
  }
  if (source) {
    stack.push({
      kind: 'source',
      text: source.length > 48 ? source.slice(0, 47) + '\u2026' : source,
      fontSize: sourceFontSize,
      weight: 'light',
      isDesc: false,
      isSource: true,
      fillOpacity: 0.55,
    });
  }

  const annMeshes = [];
  const layoutCtx = { meshes: annMeshes, yMid, textX, zSide };
  const onAnnSync = () => {
    layoutPyramidAnnotationStack(layoutCtx);
    try {
      const fn = pieGroup.userData._onPieCalloutLayoutDirty;
      if (typeof fn === 'function') fn();
    } catch (_) { /* ignore */ }
  };

  for (let si = 0; si < stack.length; si++) {
    const row = stack[si];
    const mesh = createTroikaLabel({
      THREE,
      text: row.text,
      color: rulerColorHex,
      fontSize: row.fontSize,
      anchorX,
      anchorY: 'top',
      weight: row.weight,
      maxWidth: ANNOTATION_MAX_WIDTH_WORLD,
      textAlign,
      renderOrder: 1002,
      onSync: onAnnSync,
    });
    if (mesh.material) {
      mesh.material.opacity = 0;
      if (row.fillOpacity != null) mesh.material.fillOpacity = row.fillOpacity;
    }
    mesh.userData._dataIndex = i;
    mesh.userData._pyramidAnnKind = row.kind;
    if (row.isDesc) mesh.userData._isAnnotationDesc = true;
    if (row.isSource) mesh.userData._isAnnotationSource = true;
    mesh.quaternion.identity();
    mesh.layers.set(1);
    annMeshes.push(mesh);
    pieGroup.userData._pyramidAnnotationMeshes.push(mesh);
    pieGroup.add(mesh);
  }
  layoutPyramidAnnotationStack(layoutCtx);
}

/**
 * Gestapelte 3D-Pyramiden-Segmente aus bars[] (Wert → Segmenthöhe).
 * Slice-Labels, Callouts und seitliche Annotationen (wie Pie/Wave).
 */
export function createPyramid(ctx) {
  const {
    THREE,
    DATA,
    count,
    HOVER_PALETTE,
    GRAY_PALETTE,
    alwaysColorful,
    uniformDataColor,
    pyramidSegmentGap = 0,
    pyramidInverted = false,
    pyramidSliceLabelMode = 'off',
    pyramidLabelAutoContrast = true,
    axisYPrefix = '',
    axisYSuffix = '',
    dataLabelFontScale = 1,
    barLabelColor = '',
    rulerColor = '#858585',
    pyramidAnnotation = false,
    pyramidAnnotationLineLength = 0.25,
    pyramidAnnotationFontScale = 1,
    formatChartNumber: formatChartNumberArg,
    projectionFlatRegistry,
  } = ctx;

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

  const gap = typeof pyramidSegmentGap === 'number' ? Math.max(0, pyramidSegmentGap) : 0;
  const opacity = 1;
  const inverted = pyramidInverted === true;

  const segments = [];
  for (let i = 0; i < count; i++) {
    const val = DATA[i].value;
    if (val > 0 && Number.isFinite(val)) {
      segments.push({ index: i, value: val, data: DATA[i] });
    }
  }
  const totalVal = segments.reduce((s, seg) => s + seg.value, 0) || 1;
  const segHeights = segments.map((seg) => (seg.value / totalVal) * PYRAMID_TOTAL_HEIGHT);
  let totalStructH = 0;
  for (let s = 0; s < segHeights.length; s++) {
    totalStructH += segHeights[s];
    if (s > 0) totalStructH += gap;
  }
  if (totalStructH < 1e-6) totalStructH = PYRAMID_TOTAL_HEIGHT;

  const pieGroup = new THREE.Group();
  pieGroup.position.y = PYRAMID_GROUND_CLEARANCE;
  pieGroup.userData._fitHullLocal = [];
  pieGroup.userData._pieCalloutMeshes = [];
  pieGroup.userData._pieCalloutLines = [];
  pieGroup.userData._pieInnerLabels = [];
  pieGroup.userData._pyramidAnnotationMeshes = [];
  pieGroup.userData._pyramidAnnotationLines = [];
  const pieMeshes = [];

  const fs = clampPieSliceLabelFontScale(dataLabelFontScale);
  const sliceFontSize = STRAIGHT_XLABEL_PLANE_H * PYRAMID_SLICE_FONT_RATIO * fs * PYRAMID_SLICE_FONT_PLANE_FACTOR;
  const slicePlaneH = STRAIGHT_XLABEL_PLANE_H * fs * PYRAMID_SLICE_FONT_PLANE_FACTOR;
  const annLineLen = typeof pyramidAnnotationLineLength === 'number' ? pyramidAnnotationLineLength : 0.25;
  const annFontScale = typeof pyramidAnnotationFontScale === 'number' ? pyramidAnnotationFontScale : 1;
  const annTitleFs = STRAIGHT_XLABEL_PLANE_H * ANNOTATION_TITLE_FONT_RATIO * fs * annFontScale;
  const annDescFs = STRAIGHT_XLABEL_PLANE_H * ANNOTATION_DESC_FONT_RATIO * fs * annFontScale;
  const annSourceFs = STRAIGHT_XLABEL_PLANE_H * ANNOTATION_SOURCE_FONT_RATIO * fs * annFontScale;
  const annColorHex = hexForPyramidCallout(barLabelColor, rulerColor);
  const annLineColorNum = new THREE.Color(annColorHex).getHex();

  let yCursor = 0;
  for (let s = 0; s < segments.length; s++) {
    if (s > 0) yCursor += gap;
    const segH = segHeights[s];
    const yBot = yCursor;
    const yTop = yCursor + segH;
    const yMid = (yBot + yTop) * 0.5;
    const wBot = pyramidWidthAtY(yBot, totalStructH, inverted);
    const wTop = pyramidWidthAtY(yTop, totalStructH, inverted);
    const wMid = pyramidWidthAtY(yMid, totalStructH, inverted);
    const rBot = pyramidAxisRadius(wBot);
    const rTop = pyramidAxisRadius(wTop);
    const rMidSeg = (rBot + rTop) * 0.5;
    const zFront = rMidSeg + LABEL_Z_OFFSET;

    const hull = pieGroup.userData._fitHullLocal;
    pushPyramidRingHull(hull, wBot, yBot);
    pushPyramidRingHull(hull, wTop, yTop);

    const sliceGeo = createPyramidFrustum(THREE, wBot, wTop, segH);
    const i = segments[s].index;
    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: PYRAMID_SLICE_SHININESS,
      specular: PYRAMID_SLICE_SPECULAR,
      transparent: opacity < 0.999,
      opacity,
      depthWrite: opacity >= 0.999,
    });
    if (projectionFlatRegistry) enableProjectionFlatMorph(sliceMat, projectionFlatRegistry);
    attachPyramidShadowDepthMaterial(THREE, sliceMat, initialColor, opacity);
    const sliceMesh = new THREE.Mesh(sliceGeo, sliceMat);
    sliceMesh.position.y = yBot;
    /* castShadow + receiveShadow: Segmente verschatteten sich gegenseitig (Schnittflächen + Mantel). */
    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 (pyramidSliceLabelMode !== 'off' && segH >= MIN_SEGMENT_HEIGHT_FOR_LABEL) {
      let lineText = '';
      if (pyramidSliceLabelMode === 'value') {
        lineText = formatBarValueLineForRow(DATA[i], axisYPrefix, axisYSuffix, formatChartNumber);
      } else if (pyramidSliceLabelMode === 'headline') {
        lineText = String(DATA[i].label || '').trim();
      }
      if (lineText) {
        const sliceFillCss = intToCssHex(initialColor);
        const textFill = pyramidLabelAutoContrast
          ? (contrastTextColor(sliceFillCss) || '#ffffff')
          : '#ffffff';
        const estW = String(lineText).length * sliceFontSize * 0.55;
        if (slicePlaneH <= segH * SEGMENT_HEIGHT_MARGIN && estW <= wMid * SEGMENT_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 > wMid * SEGMENT_WIDTH_MARGIN || b.height > segH * SEGMENT_HEIGHT_MARGIN) {
                m.visible = false;
              }
            },
          });
          labelMesh.position.set(0, yMid, zFront);
          labelMesh.userData._dataIndex = i;
          pieGroup.userData._pieInnerLabels.push(labelMesh);
          pieGroup.add(labelMesh);
        }
      }
    }

    if (pyramidAnnotation) {
      const side = s % 2 === 0 ? 'left' : 'right';
      addPyramidSideAnnotation({
        THREE,
        pieGroup,
        side,
        seg: segments[s],
        yMid,
        rMid: rMidSeg,
        lineLen: annLineLen,
        titleFontSize: annTitleFs,
        descFontSize: annDescFs,
        sourceFontSize: annSourceFs,
        rulerColorHex: annColorHex,
        lineColorNum: annLineColorNum,
      });
    }

    yCursor = yTop;
  }

  const baseHalf = PYRAMID_BASE_WIDTH / 2;
  /* keyLight vorn-oben-rechts → Schatten nach hinten-links; Plane + Shadow-Frustum
   * muessen mit der Pyramidenhoehe wachsen (sonst Abschnitt bei vielen Segmenten). */
  const groundMaxH = Math.max(totalStructH, yCursor, 0.001) + PYRAMID_GROUND_CLEARANCE;
  const groundDropZ = 1.5 * groundMaxH;
  const groundDropX = 0.5 * groundMaxH;
  const groundMinX = -baseHalf;
  const groundMaxX = baseHalf;
  const groundMinZ = -baseHalf;
  const groundMaxZ = baseHalf;
  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;
  const shadowHoriz = Math.max(groundPlaneW, groundPlaneD) * 0.5 + 1;
  const shadowVert = groundMaxH * 0.65 + 2;
  pieGroup.userData._pyramidShadowExtent = Math.max(10, Math.ceil(Math.max(shadowHoriz, shadowVert)));

  /* Back-Fade wie flaches Pie / Wave / Balken: lokales position.y der um −90° gedrehten
   * Plane läuft entlang der Tiefe (worldZ = groundCz − localY). Vorne voll, nach hinten aus. */
  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 shadowReceiver = new THREE.Mesh(
    new THREE.PlaneGeometry(groundPlaneW, groundPlaneD),
    groundMat,
  );
  shadowReceiver.rotation.x = -Math.PI / 2;
  shadowReceiver.receiveShadow = true;
  /* Boden bleibt bei Welt-y≈0 (wie flaches Pie), Geometrie schwebt in der Gruppe darüber. */
  shadowReceiver.position.set(groundCx, -PYRAMID_GROUND_CLEARANCE - 0.003, groundCz);
  shadowReceiver.renderOrder = 3;
  shadowReceiver.raycast = () => {};
  shadowReceiver.userData._excludeFromPieFit = true;
  pieGroup.add(shadowReceiver);

  return { pieGroup, pieMeshes };
}
