import { createTroikaLabel, updateTroikaLabel, getLabelBounds, getTroika } from './troikaLabel.js';
import { stripLeadingAxisUnitWhitespace, contrastTextColor } from '../shared/config.js';
import { getFallbackChartNumberFormatter, shouldFormatRowUnitSeparately } from '../shared/numberFormat.js';

/**
 * Same family as assets/style.css @font-face + .ce-diagram-tooltip (Barlow Semi Condensed).
 * Bleibt aus Kompatibilitaetsgruenden exportiert; Troika nutzt diese Stelle nicht direkt
 * (Schrift kommt aus den TTF-URLs in troikaLabel.js / __CE_PD_TROIKA__).
 */
export const DEFAULT_DIAGRAM_CANVAS_FONT_FAMILY = '"Barlow Semi Condensed", sans-serif';

/**
 * @param {HTMLElement | null | undefined} wrapper - .ce-diagram-wrapper
 * @returns {string} font stack — only used for legacy callers (legend/tooltip canvas).
 */
export function resolveCanvasFontFamily(wrapper) {
  if (typeof document === 'undefined' || !wrapper) return DEFAULT_DIAGRAM_CANVAS_FONT_FAMILY;
  try {
    const v = getComputedStyle(wrapper).getPropertyValue('--ce-diagram-font-body').trim();
    if (v) return v;
  } catch (_) { /* ignore */ }
  return DEFAULT_DIAGRAM_CANVAS_FONT_FAMILY;
}

/** Scale for bar/wave/horizontal data captions (editor slider); applied to world-space label sizing. */
export function clampDataLabelFontScale(v) {
  const n = Number(v);
  if (!Number.isFinite(n)) return 1;
  return Math.min(1.6, Math.max(0.5, n));
}

/** Entspricht block.json default für rulerColor — bei softModern + hellem Hintergrund Autokontrast. */
export const RULER_COLOR_BLOCK_DEFAULT = '#000000';

export function normalizeRulerHex(hex) {
  if (hex == null || typeof hex !== 'string') return '#000000';
  let s = hex.trim();
  if (s === '') return '';
  if (!s.startsWith('#')) s = `#${s}`;
  if (s.length === 4) {
    s = `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}`;
  }
  return (/^#[0-9a-f]{6}$/i.test(s) ? s.slice(0, 7) : s).toLowerCase();
}

function relativeLuminanceFromHex(hex) {
  const h = normalizeRulerHex(hex);
  if (h.length !== 7) return 0.5;
  const r = parseInt(h.slice(1, 3), 16) / 255;
  const g = parseInt(h.slice(3, 5), 16) / 255;
  const b = parseInt(h.slice(5, 7), 16) / 255;
  const f = (c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
  return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}

/** Zulaessige gespeicherte Werte → interne Stil-IDs. */
export function normalizeRulerStyle(v) {
  if (v === 'softModern') return 'softModern';
  if (v === 'dotGrid') return 'dotGrid';
  if (v === 'tickBands') return 'tickBands';
  if (v === 'stripeOnly') return 'stripeOnly';
  if (v === 'caliper') return 'caliper';
  if (v === 'gradient') return 'gradient';
  return 'default';
}

/**
 * Effektive Lineal-Farbe … bei softModern / dotGrid + fehlender Nutzerfarbe …
 */
export function resolveRulerPaintHex({
  rulerStyle,
  rulerColorResolved,
  rulerColorAttr,
  backgroundColor,
  backgroundTransparent,
}) {
  const hex = normalizeRulerHex(rulerColorResolved);
  const safeHex = hex && hex.length === 7 ? hex : '#858585';
  const rs = normalizeRulerStyle(rulerStyle);
  /* gradient nutzt bewusst KEINE Auto-Kontrast-Logik: die niedrigen Band-
   * Opazitaeten (0.05–0.22) machen jede Farbe automatisch "transparent-soft",
   * d.h. auch reines Schwarz wirkt als sanftes Grau. Edge-Linien-Farbe bleibt
   * exakt der gewaehlte rulerColor (konsistent mit 'default'-Stil). */
  if (rs !== 'softModern' && rs !== 'dotGrid' && rs !== 'tickBands' && rs !== 'stripeOnly' && rs !== 'caliper') return safeHex;
  if (backgroundTransparent) return safeHex;
  const attr = typeof rulerColorAttr === 'string' ? rulerColorAttr.trim() : '';
  const unset = attr === '' || normalizeRulerHex(attr) === normalizeRulerHex(RULER_COLOR_BLOCK_DEFAULT);
  if (!unset) return safeHex;
  const L = relativeLuminanceFromHex(typeof backgroundColor === 'string' ? backgroundColor : '#f8f9fa');
  if (L > 0.88) return '#4a4a4a';
  if (L < 0.12) return '#d4d4d4';
  return safeHex;
}

/** Geometrie- und Opazitäts-Multiplikatoren; InstancedMesh bleibt pro Stufe erhalten. */
export function getRulerStyleParams(style) {
  const s = normalizeRulerStyle(style);
  if (s === 'softModern') {
    return {
      useDotGrid: false,
      useTickBands: false,
      mainLineWidthScale: 0.72,
      tickThicknessScale: 0.78,
      intermediateThicknessScale: 0.82,
      tickLengthScale: 0.92,
      solidOpacityMul: 0.92,
      intermediateOpacityFactor: 0.34,
      tickLabelFontScale: 1,
      tickLabelWeight: 'bold',
    };
  }
  if (s === 'dotGrid') {
    return {
      useDotGrid: true,
      useTickBands: false,
      mainLineWidthScale: 0.46,
      floorDashCountScale: 0.44,
      dotMajor: 0.019,
      dotMinor: 0.012,
      solidOpacityMul: 0.87,
      intermediateOpacityFactor: 0.22,
      tickLabelFontScale: 0.9,
      tickLabelWeight: 'light',
      tickThicknessScale: 1,
      intermediateThicknessScale: 1,
      tickLengthScale: 1,
    };
  }
  if (s === 'tickBands') {
    return {
      useDotGrid: false,
      useTickBands: true,
      hideRulerRails: false,
      hideRulerTicks: false,
      mainLineWidthScale: 0.52,
      tickThicknessScale: 0.62,
      intermediateThicknessScale: 0.72,
      tickLengthScale: 0.86,
      solidOpacityMul: 0.9,
      intermediateOpacityFactor: 0.12,
      tickLabelFontScale: 0.98,
      tickLabelWeight: 'bold',
      bandStrongOpacity: 0.14,
      bandWeakOpacity: 0.034,
    };
  }
  if (s === 'stripeOnly') {
    return {
      useDotGrid: false,
      useTickBands: true,
      hideRulerRails: true,
      hideRulerTicks: true,
      mainLineWidthScale: 0.52,
      tickThicknessScale: 0.62,
      intermediateThicknessScale: 0.72,
      tickLengthScale: 0.86,
      solidOpacityMul: 0.9,
      intermediateOpacityFactor: 0.12,
      tickLabelFontScale: 0.96,
      tickLabelWeight: 'light',
      bandStrongOpacity: 0.15,
      bandWeakOpacity: 0.036,
    };
  }
  if (s === 'gradient') {
    /* Gradient: pro Tick-Segment ein getoentes Band (gefuelltes Plane)
     * zwischen zwei Tick-Levels, mit Opazitaets-Verlauf von unten (am
     * staerksten) nach oben (am schwaechsten). Plus klare Edge-Linien an
     * jedem Tick-Level (gemeinsam mit useSolidLines erzeugt) als sichtbare
     * Band-Grenze. Optisch ein abgestufter Hintergrund-Verlauf. */
    return {
      useDotGrid: false,
      useTickBands: false,
      useSolidLines: true,
      useGradient: true,
      hideSideRails: true,
      mainLineWidthScale: 0.9,
      tickThicknessScale: 0.9,
      intermediateThicknessScale: 0.55,
      tickLengthScale: 1,
      gradientBottomOpacity: 0.22,
      gradientTopOpacity: 0.05,
      solidOpacityMul: 1,
      intermediateOpacityFactor: 0.4,
      tickLabelFontScale: 1,
      tickLabelWeight: 'bold',
    };
  }
  if (s === 'caliper') {
    /* Schieblehre-Look: kurze Tick-Marker direkt an der Y-Achsen-Schiene (bzw.
     * unter dem Diagramm bei horizontal). Keine Linien durchs Diagramm. Rails
     * bleiben sichtbar — sie sind die "Lineal-Kante". */
    return {
      useDotGrid: false,
      useTickBands: false,
      useSolidLines: false,
      useCaliperTicks: true,
      hideSideRails: false,
      mainLineWidthScale: 0.85,
      tickThicknessScale: 1.0,
      intermediateThicknessScale: 0.6,
      tickLengthScale: 1,
      caliperTickLenMajor: 0.12,
      caliperTickLenIntermediate: 0.06,
      solidOpacityMul: 1,
      intermediateOpacityFactor: 0.55,
      tickLabelFontScale: 1,
      tickLabelWeight: 'bold',
    };
  }
  return {
    useDotGrid: false,
    useTickBands: false,
    useSolidLines: true,
    hideSideRails: true,
    mainLineWidthScale: 0.75,
    tickThicknessScale: 0.75,
    intermediateThicknessScale: 0.7,
    tickLengthScale: 1,
    solidOpacityMul: 1,
    intermediateOpacityFactor: 0.4,
    tickLabelFontScale: 1,
    tickLabelWeight: 'bold',
  };
}

/** Pie-Segment-Beschriftung: eigene Obergrenze (groesser als Balken-Beschriftungen). */
export function clampPieSliceLabelFontScale(v) {
  const n = Number(v);
  if (!Number.isFinite(n)) return 1;
  return Math.min(3, Math.max(0.5, n));
}

/** Gap from left Y-rail to right edge of %-tick label (Straight layout). */
export const TICK_LABEL_GAP = 0.14;
/** Horizontal space between Y-axis title and left edge of %-tick text (Straight). */
const AXIS_Y_LABEL_GAP = 0.05;
/** Arc layout gets more outward distance because curved ticks sit closer to the title. */
const AXIS_Y_LABEL_GAP_ARC = 0.14;
/** Ueber Boden/Waenden (-2), uebrigen Ruler-Planes (-1) und Soft-Shadow-InstMesh (1). */
/** Troika-Texte im Lineal: nach Chart-Geometrie, mit depthTest (nicht durch Balken/Pyramide). */
const RULER_LABEL_RENDER_ORDER = 1000;
/** Welt-Hoehe der Series-Axis-Titel (Y/X) — Troika-fontSize ergibt sich aus PLANE_H × FONT_RATIO. */
export const AXIS_PAIR_LABEL_PLANE_H = 0.22;
/** Glyph-Hoehe (em) als Anteil der "Plane-Hoehe" der alten Canvas-Renderung — passt Series-Axis-Titel an. */
const AXIS_PAIR_LABEL_FONT_RATIO = 24 / 28; /* = 0.857 */

/** Extra local Z in ruler space so x-labels sit in front of the bar in *world* space. */
export const STRAIGHT_XLABEL_Z_MARGIN = 0.07;
/** Ruler-local Y for headlines under straight bars. */
export const STRAIGHT_HEADLINE_Y = -0.02;
/** Straight Boden-Beschriftung: zusätzliches lokales Z (negativ = näher am Balken, weiter von der vorderen Kamera weg). */
export const STRAIGHT_FLOOR_LABEL_Z_NUDGE = -0.022;
/** Straight Boden-Beschriftung: zusätzliches lokales Y (negativ = tiefer). */
export const STRAIGHT_FLOOR_LABEL_Y_NUDGE = -0.058;
/** Wave Boden-Beschriftung (schräg): näher an der vordersten Serienebene in Z, etwas tiefer in Y. */
export const WAVE_FLOOR_LABEL_Z_NUDGE = -0.11;
export const WAVE_FLOOR_LABEL_Y_NUDGE = -0.046;
/** Welt-Hoehe der Headline-Plane (Bar-Beschriftung). */
export const STRAIGHT_XLABEL_PLANE_H = 0.1875;
/** Welt-Breite-Reservierung der Headline-Plane (Layout-Frame Fallback). */
export const STRAIGHT_XLABEL_PLANE_W = 0.825;
/** Canvas line height (logical px) for headline textures — bleibt fuer evtl. externe Konsumenten exportiert. */
export const HEADLINE_CANVAS_LOGICAL_H = 42;
/** Glyph-Hoehe / Canvas-Hoehe der alten Renderung (31.5 / 42 = 0.75). */
const HEADLINE_FONT_RATIO = 31.5 / 42;
/** Tick-Labels: Plane-Hoehe in World-Units + Glyph-Anteil (21 / 24 ≈ 0.875). */
const TICK_LABEL_PLANE_H = 0.16;
const TICK_LABEL_FONT_RATIO = 21 / 24;
/** Zahl–Einheit am Lineal: Abstand in Welt-Längen ≈ Anteil von fontSize (nicht Unicode-Space — Barlow/Troika). */
export const TICK_VALUE_UNIT_GAP_FRAC = 0.1;
/** Präfix (z. B. €) – Zahl: fester Weltabstand (≈ 0,7× der Lücke Zahl–Suffix). */
export const TICK_PREFIX_UNIT_GAP_FRAC = 0.07;
/** Gap from right Y-rail to left (inner) edge of series X label. */
const SERIES_X_GAP = 0.14;
/** Horizontal-Layout: Welt-Abstand zwischen %-Tick-Reihe und Wert-Achsen-Titel. */
const HORIZONTAL_AXIS_X_LABEL_GAP = 0.14;
export const SERIES_AXIS_X_MARGIN_WORLD = SERIES_X_GAP;

/**
 * Layout-spezifische Feinjustierung des Series-X-Titels. Free liefert hier
 * nur den Identity-Pfad — Add-on-Plugins koennen ueber
 * `window.__CE_PD_SCENE_EXT__.ruler.applySeriesXFinetune(layout, x, y, z)`
 * eine Verschiebung zurueckliefern (z. B. fuer Arc).
 */
function applySeriesXArcFinetune(layout, x, y, z) {
  const ext = (typeof window !== 'undefined' && window.__CE_PD_SCENE_EXT__ && window.__CE_PD_SCENE_EXT__.ruler) || null;
  const fn = ext && ext.applySeriesXFinetune;
  if (typeof fn === 'function') {
    const r = fn(layout, x, y, z);
    if (r && typeof r === 'object') return r;
  }
  return { x, y, z };
}

/**
 * Ruler-Layout-Deskriptor: zentralisiert alle layoutabhaengigen Ruler-Entscheidungen,
 * damit der Free-Core keine Pro-Layout-Namen mehr hartcodiert. Free liefert die Defaults
 * fuer seine eigenen Layouts (straight/stacked); Add-on-Plugins steuern ihre Layouts ueber
 * `window.__CE_PD_SCENE_EXT__.ruler.layoutDescriptor(layout)` bei (arc/grouped/horizontal/wave).
 *
 * Felder:
 *  - isArc                 : gekruemmte Polar-Ruler-Geometrie.
 *  - isHorizontal          : liegender Ruler (Balken flach am Boden).
 *  - hasVerticalSeriesAxis : vertikale Layouts mit Series-X-Label + Y-Achsentitel.
 *  - usesGroupedFloorLabels: Boden-Headlines per `labelsUnderBars` (Straight-Nudge).
 *  - usesWaveFloorLabels   : Boden-Headlines per `waveLabelAlongFloor` (Wave-Nudge).
 */
function resolveRulerLayoutDescriptor(layout) {
  if (layout === 'wave') {
    return {
      isArc: false,
      isHorizontal: false,
      hasVerticalSeriesAxis: true,
      usesGroupedFloorLabels: false,
      usesWaveFloorLabels: true,
    };
  }
  const ext = (typeof window !== 'undefined' && window.__CE_PD_SCENE_EXT__ && window.__CE_PD_SCENE_EXT__.ruler) || null;
  const fromPro = ext && typeof ext.layoutDescriptor === 'function' ? ext.layoutDescriptor(layout) : null;
  const d = (fromPro && typeof fromPro === 'object') ? fromPro : {};
  return {
    isArc: d.isArc === true,
    isHorizontal: d.isHorizontal === true,
    /* Free-eigene vertikale Layouts haben eine Series-Achse; Pro ergaenzt arc/grouped/wave. */
    hasVerticalSeriesAxis: layout === 'straight' || layout === 'stacked' || d.hasVerticalSeriesAxis === true,
    usesGroupedFloorLabels: d.usesGroupedFloorLabels === true,
    usesWaveFloorLabels: d.usesWaveFloorLabels === true,
  };
}

/** Segmentierte Wert+Einheit (Balken oberhalb / Lineal): Präfix | Zahl | Suffix mit festen Lücken. */
export function getBarValueLabelParts(item, axisYPrefix, axisYSuffix, formatChartNumber) {
  const ft = typeof formatChartNumber === 'function' ? formatChartNumber : getFallbackChartNumberFormatter().formatTick;
  if (!item) return null;
  const n = Number(item.value);
  if (!Number.isFinite(n)) return { prefix: null, numberStr: '', suffix: null };
  const uRaw = item.unit != null ? String(item.unit).trim() : '';
  if (!shouldFormatRowUnitSeparately(uRaw, axisYPrefix, axisYSuffix)) {
    return fmtTickLabelParts(item.value, axisYPrefix, axisYSuffix, ft);
  }
  const rowSuf = stripLeadingAxisUnitWhitespace(uRaw);
  return {
    prefix: null,
    numberStr: ft(item.value),
    suffix: rowSuf || null,
  };
}

function fmtTickLabelParts(val, prefix, suffix, formatTick) {
  const n = Number(val);
  if (!Number.isFinite(n)) return { prefix: null, numberStr: '', suffix: null };
  const ft = typeof formatTick === 'function' ? formatTick : getFallbackChartNumberFormatter().formatTick;
  const numberStr = ft(val);
  const pfxRaw = prefix != null ? String(prefix).trim() : '';
  const prefixSeg = pfxRaw !== '' ? pfxRaw : null;
  const suf = stripLeadingAxisUnitWhitespace(suffix);
  return { prefix: prefixSeg, numberStr, suffix: suf || null };
}

function fmtTickLabel(val, prefix, suffix, formatTick) {
  const parts = fmtTickLabelParts(val, prefix, suffix, formatTick);
  const mid = parts.numberStr;
  const withPre = parts.prefix ? parts.prefix + '\u200A' + mid : mid;
  return parts.suffix ? withPre + parts.suffix : withPre;
}

export function getRulerTickGroupWidth(group) {
  const p = group.userData._tickPair;
  if (!p || !p.bodyMesh) return 0;
  const wP = p.preMesh ? getLabelBounds(p.preMesh).width : 0;
  const wN = getLabelBounds(p.bodyMesh).width;
  const wS = p.sufMesh ? getLabelBounds(p.sufMesh).width : 0;
  return wP + (p.gapPrefixWorld || 0) + wN + (p.gapSuffixWorld || 0) + wS;
}

export function layoutRulerTickGroup(group) {
  const p = group.userData._tickPair;
  if (!p || !p.bodyMesh || !p.bodyMesh.textRenderInfo) return;
  const pre = p.preMesh;
  const num = p.bodyMesh;
  const suf = p.sufMesh;
  if (pre && !pre.textRenderInfo) return;
  if (suf && !suf.textRenderInfo) return;
  const gapP = p.gapPrefixWorld || 0;
  const gapS = p.gapSuffixWorld || 0;
  const wP = pre ? getLabelBounds(pre).width : 0;
  const wN = getLabelBounds(num).width;
  const wS = suf ? getLabelBounds(suf).width : 0;
  if (p.anchorMode === 'left') {
    let x = 0;
    if (pre) {
      pre.position.set(x, 0, 0);
      x += wP + gapP;
    }
    num.position.set(x, 0, 0);
    x += wN + gapS;
    if (suf) suf.position.set(x, 0, 0);
    return;
  }
  if (p.anchorMode === 'right') {
    if (suf) {
      suf.position.set(0, 0, 0);
      num.position.set(-(wS + gapS), 0, 0);
      if (pre) pre.position.set(-(wS + gapS + wN + gapP), 0, 0);
    } else {
      num.position.set(0, 0, 0);
      if (pre) pre.position.set(-(wN + gapP), 0, 0);
    }
    return;
  }
  const T = wP + gapP + wN + gapS + wS;
  if (pre) pre.position.set(-T / 2 + wP / 2, 0, 0);
  num.position.set(-T / 2 + wP + gapP + wN / 2, 0, 0);
  if (suf) suf.position.set(-T / 2 + wP + gapP + wN + gapS + wS / 2, 0, 0);
}

/**
 * `_tickLabelW` = exaktes Max aller per Mesh/Group tatsächlich gemessenen
 * Tick-Breiten (Troika-`textRenderInfo`). Der initiale Heuristik-Wert
 * (`estimateTickLabelWidth`) bleibt als Fallback, solange noch nicht alle
 * Ticks gesynct sind — die echte Max-Breite ist meist KLEINER als der
 * Estimate (z. B. „€120.000" Estimate ≈ 0.63 vs. Troika ≈ 0.55), daher
 * darf der Wert nach Sync auch SINKEN, nicht nur steigen.
 */
function recomputeRulerTickLabelW(rulerGroup) {
  const m = rulerGroup.userData._tickLabelWidthByEntry;
  if (!(m instanceof Map) || m.size === 0) return;
  let maxW = 0;
  m.forEach((w) => { if (w > maxW) maxW = w; });
  if (maxW > 0) rulerGroup.userData._tickLabelW = maxW;
}

function wireRulerTickGroupSync(group, rulerGroup) {
  const p = group.userData._tickPair;
  if (!p) return;
  const meshList = [p.preMesh, p.bodyMesh, p.sufMesh].filter(Boolean);
  const bump = () => {
    for (let i = 0; i < meshList.length; i += 1) {
      if (!meshList[i].textRenderInfo) return;
    }
    layoutRulerTickGroup(group);
    const w = getRulerTickGroupWidth(group);
    rulerGroup.userData._tickLabelWidthByEntry.set(group, w);
    recomputeRulerTickLabelW(rulerGroup);
  };
  const wrap = (prev) => (mesh) => {
    try {
      if (typeof prev === 'function') prev(mesh);
    } catch (_) { /* ignore */ }
    bump();
  };
  meshList.forEach((m) => {
    m.userData._onSized = wrap(m.userData._onSized);
  });
}

/** Lineal-Suffix: nur „%“ bei Bold-Ticks ExtraBold; bei Light-Ticks immer Light. */
export function rulerTickSuffixFontUrl(suffix, weight) {
  try {
    const { fontBoldUrl, fontLightUrl, fontExtraBoldUrl } = getTroika();
    if (weight === 'light') {
      return fontLightUrl;
    }
    if (suffix === '%' && fontExtraBoldUrl && fontExtraBoldUrl !== fontBoldUrl) {
      return fontExtraBoldUrl;
    }
    return fontBoldUrl;
  } catch (_) {
    return undefined;
  }
}

/**
 * Lineal-Tick: ein Mesh nur Zahl; oder Group mit festem Abstand Präfix|Zahl|Suffix (ohne Unicode-Zwischenräume in Troika).
 */
function makeRulerTickLabelEntry(THREE, rulerGroup, opts) {
  const {
    tickVal, axisYPrefix, axisYSuffix, color, fontSize, weight, anchorMode, renderOrder,
    formatChartNumber: fmtNum,
  } = opts;
  const parts = fmtTickLabelParts(tickVal, axisYPrefix, axisYSuffix, fmtNum);
  const anchorX = anchorMode === 'right' ? 'right' : anchorMode === 'left' ? 'left' : 'center';
  const hasPre = !!parts.prefix;
  const hasSuf = !!parts.suffix;
  if (!hasPre && !hasSuf) {
    const mesh = makeRulerLabel(THREE, rulerGroup, {
      text: parts.numberStr,
      color,
      fontSize,
      anchorX,
      anchorY: 'middle',
      weight,
      renderOrder,
    });
    mesh.userData._isTickLabel = true;
    return mesh;
  }
  const gapPrefixWorld = hasPre ? fontSize * TICK_PREFIX_UNIT_GAP_FRAC : 0;
  const gapSuffixWorld = hasSuf ? fontSize * TICK_VALUE_UNIT_GAP_FRAC : 0;
  const group = new THREE.Group();
  group.userData._tickPair = {
    preMesh: null,
    bodyMesh: null,
    sufMesh: null,
    gapPrefixWorld,
    gapSuffixWorld,
    anchorMode,
  };
  if (hasPre) {
    const preMesh = makeRulerLabel(THREE, rulerGroup, {
      text: parts.prefix,
      color,
      fontSize,
      anchorX,
      anchorY: 'middle',
      weight,
      renderOrder,
    });
    group.userData._tickPair.preMesh = preMesh;
    group.add(preMesh);
  }
  const numMesh = makeRulerLabel(THREE, rulerGroup, {
    text: parts.numberStr,
    color,
    fontSize,
    anchorX,
    anchorY: 'middle',
    weight,
    renderOrder,
  });
  group.userData._tickPair.bodyMesh = numMesh;
  group.add(numMesh);
  if (hasSuf) {
    const sufUrl = rulerTickSuffixFontUrl(parts.suffix, weight);
    const sufMesh = makeRulerLabel(THREE, rulerGroup, {
      text: parts.suffix,
      color,
      fontSize,
      anchorX,
      anchorY: 'middle',
      weight,
      renderOrder,
      ...(sufUrl ? { fontUrl: sufUrl } : {}),
    });
    group.userData._tickPair.sufMesh = sufMesh;
    group.add(sufMesh);
  }
  group.userData._isTickLabel = true;
  return group;
}

/** Wie Tooltip/init: Zeilen-eigene Einheit oder Achsen-Prefix/Suffix. */
export function formatBarValueLineForRow(item, axisYPrefix, axisYSuffix, formatChartNumber) {
  const ft = typeof formatChartNumber === 'function' ? formatChartNumber : getFallbackChartNumberFormatter().formatTick;
  if (!item) return '';
  const uRaw = item.unit != null ? String(item.unit).trim() : '';
  if (!shouldFormatRowUnitSeparately(uRaw, axisYPrefix, axisYSuffix)) {
    return fmtTickLabel(item.value, axisYPrefix, axisYSuffix, ft);
  }
  return fmtTickLabel(item.value, '', '', ft) + stripLeadingAxisUnitWhitespace(uRaw);
}

/** Inner (left) plane edge at diagramRightEdgeX + gap; with anchorX='left' gilt: position.x = innen-Kante. */
function seriesAxisXLabelLeftXZ(diagramRightEdgeX) {
  return { x: diagramRightEdgeX + SERIES_X_GAP, z: 0.01 };
}

/**
 * Heuristische Tick-Label-Welt-Breite vor Sync.
 * `chars × fontSize × 0.55` ist eine konservative Schaetzung fuer Barlow Semi Condensed.
 */
function estimateTickLabelWidth(tickCount, axisYMin, axisYMax, axisYPrefix, axisYSuffix, fontSize, formatTick) {
  let maxW = 0;
  const gapS = fontSize * TICK_VALUE_UNIT_GAP_FRAC;
  const gapP = fontSize * TICK_PREFIX_UNIT_GAP_FRAC;
  const ch = fontSize * 0.55;
  for (let ti = 0; ti <= tickCount; ti++) {
    const val = axisYMin + (ti / tickCount) * (axisYMax - axisYMin);
    const parts = fmtTickLabelParts(val, axisYPrefix, axisYSuffix, formatTick);
    let w = parts.numberStr.length * ch;
    if (parts.prefix) w += parts.prefix.length * ch + gapP;
    if (parts.suffix) w += gapS + parts.suffix.length * ch;
    if (w > maxW) maxW = w;
  }
  return Math.max(maxW, fontSize * 0.55);
}

/**
 * Lokaler lookAt-Factory — orientiert Mesh so, dass Normale auf Target zeigt.
 * Reicht fuer rulerGroup (nur Position-Offset). Argument-Reihenfolge wie Object3D.lookAt().
 */
function makeLocalLookAt(THREE) {
  const _mat = new THREE.Matrix4();
  const _target = new THREE.Vector3();
  return (obj, lx, ly, lz) => {
    _target.set(lx, ly, lz);
    _mat.lookAt(_target, obj.position, obj.up);
    obj.quaternion.setFromRotationMatrix(_mat);
  };
}


/**
 * Pro-only: gekruemmte Tick-Band-Geometrie entlang eines Boden-Arcs.
 * Implementierung in Add-on-Plugins via `__CE_PD_SCENE_EXT__.ruler.arcTickBandCurvedGeometry`.
 * Free liefert null zurueck, wenn Pro nicht geladen ist (Aufrufer behandelt Fallback).
 */
function rulerArcTickBandCurvedGeometry(THREE, arcRadius, arcZCenter, arcStartAngle, arcEndAngle, y0, y1, segmentCount) {
  const ext = (typeof window !== 'undefined' && window.__CE_PD_SCENE_EXT__ && window.__CE_PD_SCENE_EXT__.ruler) || null;
  const fn = ext && ext.arcTickBandCurvedGeometry;
  return typeof fn === 'function'
    ? fn(THREE, arcRadius, arcZCenter, arcStartAngle, arcEndAngle, y0, y1, segmentCount)
    : null;
}

/**
 * Hilfsfunktion: erstellt einen Troika-Tick-/Headline-Text und registriert das Material
 * im `_rulerMaterials`-Array, damit der Opacity-Fade des Rulers automatisch greift.
 */
function makeRulerLabel(THREE, rulerGroup, args) {
  const mesh = createTroikaLabel({
    THREE,
    depthTest: true,
    depthWrite: false,
    ...args,
  });
  if (mesh.material) {
    mesh.material.opacity = 0;
    rulerGroup.userData._rulerMaterials.push(mesh.material);
  }
  return mesh;
}

export function createRuler(ctx) {
  const {
    THREE, rulerHeight, rulerLineWidth, tickLineThickness, diagramWidth, rulerX, diagramRightEdgeX,
    arcRadius, arcZCenter, arcStartAngle, arcEndAngle,
    arcLeftX, arcLeftZ, arcRightX, arcRightZ, arcRulerZOffset,
    count, straightSpacing, horizontalCenterY, horizontalRulerBottom,
    horizontalLeftEdge, maxBarHeight, getCategoryLabel, DATA,
    axisYMin, axisYMax, axisYPrefix, axisYSuffix, rulerEnabled, rulerColor = '#858585',
    rulerStyle = 'default',
    backgroundColor: ctxBg = '#f8f9fa',
    backgroundTransparent: ctxBgTrans = false,
    rulerColorAttr: ctxRulerColorAttr = '',
    groupLabelColor,
    layout,
    seriesAxisXLabel = '',
    /* seriesAxisXLabelTransform: bleibt fuer API-Kompatibilitaet uebernommen, wird aber
     * nicht mehr genutzt (Canvas-Pfad entfaellt). */
    labelsUnderBars = false,
    straightBarLabelBelow,
    straightBarCenterZ = 0,
    barDepth = 0.4,
  } = ctx;

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

  const dataLabelFontScale = clampDataLabelFontScale(ctx.dataLabelFontScale);

  const rd = resolveRulerLayoutDescriptor(layout);

  const straightBelowSlot = layout === 'straight'
    ? (['off', 'value', 'headline'].includes(straightBarLabelBelow) ? straightBarLabelBelow : 'off')
    : null;

  const horizontalLeftSlot = rd.isHorizontal
    ? (['off', 'value', 'headline'].includes(ctx.horizontalBarLabelLeft) ? ctx.horizontalBarLabelLeft : 'off')
    : 'off';
  const horizontalRightSlot = rd.isHorizontal
    ? (['off', 'value', 'headline'].includes(ctx.horizontalBarLabelRight) ? ctx.horizontalBarLabelRight : 'off')
    : 'off';
  const horizontalInsideSlot = rd.isHorizontal
    ? (['off', 'value', 'headline'].includes(ctx.horizontalBarLabelInside) ? ctx.horizontalBarLabelInside : 'off')
    : 'off';

  const waveFloorSlot = rd.usesWaveFloorLabels
    ? (['off', 'value', 'headline'].includes(ctx.waveLabelAlongFloor) ? ctx.waveLabelAlongFloor : 'off')
    : 'off';

  const showHeadlinesAlongXAxis = straightBelowSlot != null
    ? (straightBelowSlot === 'headline' || straightBelowSlot === 'value')
    : (rd.usesGroupedFloorLabels || layout === 'stacked')
      ? labelsUnderBars
      : rd.usesWaveFloorLabels
        ? waveFloorSlot !== 'off'
        : false;

  const useHeadlinePlaneLeft = (straightBelowSlot != null && (straightBelowSlot === 'headline' || straightBelowSlot === 'value'))
    || (rd.isHorizontal && horizontalLeftSlot !== 'off')
    || showHeadlinesAlongXAxis;
  const straightXLabelLocalZ = layout === 'stacked'
    ? arcRulerZOffset + straightBarCenterZ + barDepth * 0.55
    : arcRulerZOffset + straightBarCenterZ + barDepth * 0.5 + STRAIGHT_XLABEL_Z_MARGIN;
  const stackedHeadlineY = layout === 'stacked' ? STRAIGHT_HEADLINE_Y - 0.12 : STRAIGHT_HEADLINE_Y;

  const style = normalizeRulerStyle(rulerStyle);
  const styleParams = getRulerStyleParams(style);
  const rulerColorHexRaw = rulerColor.startsWith('#') ? rulerColor : '#' + rulerColor;
  const rulerColorHex = resolveRulerPaintHex({
    rulerStyle: style,
    rulerColorResolved: rulerColorHexRaw,
    rulerColorAttr: ctxRulerColorAttr,
    backgroundColor: ctxBg,
    backgroundTransparent: ctxBgTrans === true,
  });
  const groupLabelColorHex = (groupLabelColor != null && groupLabelColor !== '')
    ? (String(groupLabelColor).startsWith('#') ? String(groupLabelColor) : '#' + groupLabelColor)
    : rulerColorHex;
  const rulerColorThree = new THREE.Color(rulerColorHex);
  const rulerMat = new THREE.MeshBasicMaterial({ color: rulerColorThree, transparent: true, depthWrite: false });
  const rulerGroup = new THREE.Group();
  rulerGroup.renderOrder = 1000;
  rulerGroup.position.set(0, 0, -arcRulerZOffset);
  rulerGroup.layers.set(1);
  rulerGroup.userData._rulerOpacity = 0;
  /* createRuler wird nur fuer Ruler-tragende Layouts aufgerufen (init.js: isBarLayout
   * || wave), daher ist die Ziel-Opacity hier schlicht an rulerEnabled gekoppelt. */
  rulerGroup.userData._rulerTargetOpacity = rulerEnabled ? 1 : 0;
  const xCategoryCount = ctx.xCategoryCount != null ? ctx.xCategoryCount : count;
  const groupedCategoryCenters = ctx.groupedCategoryCenters || null;
  const xCategoryLabels = ctx.xCategoryLabels || null;
  rulerGroup.userData._rulerMaterials = [];
  rulerGroup.userData._rulerSolidMaterials = [];
  rulerGroup.userData._layout = layout;
  const isArcLayout = rd.isArc;
  const isHorizontalLayout = rd.isHorizontal;
  rulerGroup.userData._dataLabelFontScale = dataLabelFontScale;

  const applyLocalLookAt = makeLocalLookAt(THREE);

  const horizontalRulerHeight = ctx.horizontalRulerHeight;

  const effRulerLineW = rulerLineWidth * styleParams.mainLineWidthScale;
  const effTickThick = tickLineThickness * (styleParams.tickThicknessScale ?? 1);
  const lenScale = styleParams.tickLengthScale ?? 1;
  const useDot = styleParams.useDotGrid === true;
  const useTickBands = styleParams.useTickBands === true;
  const useSolid = styleParams.useSolidLines === true;
  const useCaliper = styleParams.useCaliperTicks === true;
  const caliperMarkLenMajor = styleParams.caliperTickLenMajor ?? 0.12;
  const caliperMarkLenInt = styleParams.caliperTickLenIntermediate ?? 0.06;
  const useGradient = styleParams.useGradient === true;
  const gradientBottomOp = styleParams.gradientBottomOpacity ?? 0.12;
  const gradientTopOp = styleParams.gradientTopOpacity ?? 0.025;
  const hideRulerRails = styleParams.hideRulerRails === true;
  /* hideSideRails: nur in vertikalen Layouts (Straight/Arc/Grouped/Stacked/Wave)
   * blenden wir die seitlichen Y-Achsen-Schienen aus. Im Horizontal-Layout sind
   * mainLine/mainLineRight die 0%-/100%-Vertikallinien an den Balkenenden und
   * muessen sichtbar bleiben. */
  const hideSideRails = styleParams.hideSideRails === true && !isHorizontalLayout;
  const hideRulerTicks = styleParams.hideRulerTicks === true;
  const dotMaj = styleParams.dotMajor ?? 0.019;
  const dotMin = styleParams.dotMinor ?? 0.012;

  const mainLine = new THREE.Mesh(new THREE.BoxGeometry(effRulerLineW, rulerHeight, effRulerLineW), rulerMat);
  mainLine.renderOrder = -1;
  if (isArcLayout) {
    mainLine.position.set(arcLeftX, rulerHeight / 2, arcLeftZ);
  } else if (isHorizontalLayout) {
    mainLine.position.set(horizontalLeftEdge, horizontalCenterY, 0);
    mainLine.scale.y = horizontalRulerHeight / rulerHeight;
  } else {
    mainLine.position.set(rulerX, rulerHeight / 2, 0);
  }
  rulerGroup.add(mainLine);
  rulerGroup.userData._rulerMaterials.push(rulerMat);
  rulerGroup.userData._rulerSolidMaterials.push(rulerMat);

  const mainLineRight = new THREE.Mesh(new THREE.BoxGeometry(effRulerLineW, rulerHeight, effRulerLineW), rulerMat.clone());
  mainLineRight.renderOrder = -1;
  if (isArcLayout) {
    mainLineRight.position.set(arcRightX, rulerHeight / 2, arcRightZ);
  } else if (isHorizontalLayout) {
    mainLineRight.position.set(horizontalLeftEdge + maxBarHeight, horizontalCenterY, 0);
    mainLineRight.scale.y = horizontalRulerHeight / rulerHeight;
  } else {
    mainLineRight.position.set(rulerX + diagramWidth, rulerHeight / 2, 0);
  }
  rulerGroup.add(mainLineRight);
  rulerGroup.userData._rulerMaterials.push(mainLineRight.material);
  rulerGroup.userData._rulerSolidMaterials.push(mainLineRight.material);

  if (hideRulerRails || hideSideRails) {
    mainLine.visible = false;
    mainLineRight.visible = false;
  }

  const tickCount = 5;

  rulerGroup.userData._rulerBandMaterials = [];
  if (useTickBands) {
    const bandZ = -0.006;
    const bandStrongOp = styleParams.bandStrongOpacity ?? 0.12;
    const bandWeakOp = styleParams.bandWeakOpacity ?? 0.03;
    const bandStrongMat = new THREE.MeshBasicMaterial({
      color: rulerColorThree.clone(),
      transparent: true,
      opacity: 0,
      depthWrite: false,
      side: THREE.DoubleSide,
    });
    bandStrongMat.userData._ceBandOpacityBase = bandStrongOp;
    const bandWeakMat = new THREE.MeshBasicMaterial({
      color: rulerColorThree.clone(),
      transparent: true,
      opacity: 0,
      depthWrite: false,
      side: THREE.DoubleSide,
    });
    bandWeakMat.userData._ceBandOpacityBase = bandWeakOp;
    rulerGroup.userData._rulerBandMaterials.push(bandStrongMat, bandWeakMat);

    const evenJs = [];
    const oddJs = [];
    for (let j = 0; j < tickCount; j++) {
      (j % 2 === 0 ? evenJs : oddJs).push(j);
    }

    const _bPos = new THREE.Vector3();
    const _bQuat = new THREE.Quaternion();
    const _bScl = new THREE.Vector3();
    const _bMat = new THREE.Matrix4();

    if (isArcLayout) {
      const arcSpan = arcEndAngle - arcStartAngle;
      const arcLenB = arcRadius * arcSpan;
      const bandArcSegments = Math.max(16, Math.min(56, Math.ceil(arcLenB / Math.max(0.035, diagramWidth * 0.06))));
      for (let j = 0; j < tickCount; j++) {
        const y0 = (j / tickCount) * rulerHeight;
        const y1 = ((j + 1) / tickCount) * rulerHeight;
        const geom = rulerArcTickBandCurvedGeometry(
          THREE, arcRadius, arcZCenter, arcStartAngle, arcEndAngle, y0, y1, bandArcSegments,
        );
        const mat = j % 2 === 0 ? bandStrongMat : bandWeakMat;
        const mesh = new THREE.Mesh(geom, mat);
        mesh.renderOrder = -2;
        rulerGroup.add(mesh);
      }
    } else {
      const plane = new THREE.PlaneGeometry(1, 1);
      if (evenJs.length) {
        const instE = new THREE.InstancedMesh(plane, bandStrongMat, evenJs.length);
        instE.instanceMatrix.setUsage(THREE.StaticDrawUsage);
        let ix = 0;
        for (const j of evenJs) {
          if (isHorizontalLayout) {
            const segW = maxBarHeight / tickCount;
            const xMid = horizontalLeftEdge + (j + 0.5) * segW;
            const yMid = horizontalRulerBottom + horizontalRulerHeight / 2;
            _bPos.set(xMid, yMid, bandZ);
            _bQuat.identity();
            _bScl.set(segW, horizontalRulerHeight, 1);
          } else {
            const segH = rulerHeight / tickCount;
            const yMid = (j + 0.5) * segH;
            _bPos.set(rulerX + diagramWidth / 2, yMid, bandZ);
            _bQuat.identity();
            _bScl.set(diagramWidth, segH, 1);
          }
          _bMat.compose(_bPos, _bQuat, _bScl);
          instE.setMatrixAt(ix++, _bMat);
        }
        instE.instanceMatrix.needsUpdate = true;
        instE.computeBoundingSphere();
        instE.renderOrder = -2;
        rulerGroup.add(instE);
      }
      if (oddJs.length) {
        const instO = new THREE.InstancedMesh(plane, bandWeakMat, oddJs.length);
        instO.instanceMatrix.setUsage(THREE.StaticDrawUsage);
        let ix = 0;
        for (const j of oddJs) {
          if (isHorizontalLayout) {
            const segW = maxBarHeight / tickCount;
            const xMid = horizontalLeftEdge + (j + 0.5) * segW;
            const yMid = horizontalRulerBottom + horizontalRulerHeight / 2;
            _bPos.set(xMid, yMid, bandZ);
            _bQuat.identity();
            _bScl.set(segW, horizontalRulerHeight, 1);
          } else {
            const segH = rulerHeight / tickCount;
            const yMid = (j + 0.5) * segH;
            _bPos.set(rulerX + diagramWidth / 2, yMid, bandZ);
            _bQuat.identity();
            _bScl.set(diagramWidth, segH, 1);
          }
          _bMat.compose(_bPos, _bQuat, _bScl);
          instO.setMatrixAt(ix++, _bMat);
        }
        instO.instanceMatrix.needsUpdate = true;
        instO.computeBoundingSphere();
        instO.renderOrder = -2;
        rulerGroup.add(instO);
      }
    }
  }

  /* Gradient: pro Tick-Segment ein gefuelltes Plane-Band mit eigener Opazitaet.
   * Lerp von gradientBottomOp (unten/links = staerkstes Band) zu
   * gradientTopOp (oben/rechts = schwaechstes Band). Materialien werden ans
   * _rulerBandMaterials-Array gehaengt, damit der Ruler-Fade per Frame greift. */
  if (useGradient) {
    const bandZ = -0.004;
    const bandSegments = tickCount;
    const arcSpanG = arcEndAngle - arcStartAngle;
    const arcLenG = arcRadius * arcSpanG;
    const arcSegCount = isArcLayout
      ? Math.max(16, Math.min(56, Math.ceil(arcLenG / Math.max(0.035, diagramWidth * 0.06))))
      : 0;
    for (let j = 0; j < bandSegments; j++) {
      const t = bandSegments === 1 ? 0 : j / (bandSegments - 1);
      const bandOp = gradientBottomOp * (1 - t) + gradientTopOp * t;
      const bandMat = new THREE.MeshBasicMaterial({
        color: rulerColorThree.clone(),
        transparent: true,
        opacity: 0,
        depthWrite: false,
        side: THREE.DoubleSide,
      });
      bandMat.userData._ceBandOpacityBase = bandOp;
      rulerGroup.userData._rulerBandMaterials.push(bandMat);
      if (isArcLayout) {
        const y0 = (j / bandSegments) * rulerHeight;
        const y1 = ((j + 1) / bandSegments) * rulerHeight;
        const geom = rulerArcTickBandCurvedGeometry(
          THREE, arcRadius, arcZCenter, arcStartAngle, arcEndAngle, y0, y1, arcSegCount,
        );
        /* Pro liefert die gekruemmte Geometrie; in Free ist sie null und wir
         * ueberspringen das Band fuer Arc-Layout (Edge-Linien rendern weiter
         * via useSolidLines mit vielen kleinen Boxen). */
        if (geom) {
          const mesh = new THREE.Mesh(geom, bandMat);
          mesh.renderOrder = -2;
          rulerGroup.add(mesh);
        }
      } else if (isHorizontalLayout) {
        const segW = maxBarHeight / bandSegments;
        const xMid = horizontalLeftEdge + (j + 0.5) * segW;
        const yMid = horizontalRulerBottom + horizontalRulerHeight / 2;
        const plane = new THREE.PlaneGeometry(segW, horizontalRulerHeight);
        const mesh = new THREE.Mesh(plane, bandMat);
        mesh.position.set(xMid, yMid, bandZ);
        mesh.renderOrder = -2;
        rulerGroup.add(mesh);
      } else {
        const segH = rulerHeight / bandSegments;
        const yMid = (j + 0.5) * segH;
        const plane = new THREE.PlaneGeometry(diagramWidth, segH);
        const mesh = new THREE.Mesh(plane, bandMat);
        mesh.position.set(rulerX + diagramWidth / 2, yMid, bandZ);
        mesh.renderOrder = -2;
        rulerGroup.add(mesh);
      }
    }
  }

  if (!hideRulerTicks) {
  const arcLength = arcRadius * (arcEndAngle - arcStartAngle);
  const dashCountStraightBase = 28;
  /* useSolid: durchgaengige Linien statt gestrichelt.
   *   - Straight/Grouped/Stacked/Horizontal: 1 Box pro Tick-Linie, volle Laenge.
   *   - Arc: viele angrenzende Boxen (Polyline-Approximation der Kurve).
   * Sonst: bisherige Logik (dotGrid-Reduktion oder Standard-Dashes). */
  let dashCount;
  if (useCaliper) {
    /* Schieblehre: ein kurzer Marker pro Tick-Level, an der Schiene positioniert. */
    dashCount = 1;
  } else if (useSolid && isArcLayout) {
    /* Polyline-Approximation der Kurve: ~50 Segmente pro Welt-Einheit Bogenlaenge,
     * gedeckelt bei 200 (reicht fuer optisch glatte Kurven auch bei grossen Arcs). */
    dashCount = Math.min(200, Math.max(40, Math.ceil(arcLength * 50)));
  } else if (useSolid) {
    dashCount = 1;
  } else if (useDot) {
    dashCount = Math.max(6, Math.round(dashCountStraightBase * (styleParams.floorDashCountScale ?? 0.44)));
  } else {
    const dashCountArc = Math.max(dashCountStraightBase, Math.ceil((arcLength / diagramWidth) * dashCountStraightBase));
    dashCount = Math.max(2, dashCountArc);
  }
  /* Bei useSolid butten die Dashes ohne Luecken aneinander (Laenge = Achse / Anzahl);
   * sonst bisherige "Dash + gleich grosse Luecke"-Formel (Achse / (2N - 1)). */
  const dashLenStraight = useSolid ? diagramWidth / dashCount : diagramWidth / (2 * dashCount - 1);
  const dashLenArc = useSolid ? arcLength / dashCount : arcLength / (2 * dashCount - 1);
  const dashLen = Math.max(dashLenStraight, dashLenArc);
  const horizontalDashSpacing = horizontalRulerHeight / (dashCount - 1 || 1);
  /* Horizontal + Solid: ein einzelner Dash spannt die volle Lineal-Hoehe; sonst kurze
   * "Punkt"-Dashes mit 0.45-Faktor (klassischer Strichel-Look). */
  const horizontalDashScale = useSolid && isHorizontalLayout
    ? horizontalRulerHeight / dashLen
    : Math.min(1, (horizontalDashSpacing * 0.45) / dashLen);

  const tickMajorMat = rulerMat.clone();
  const tickIntermediateMat = rulerMat.clone();
  const unitBox = new THREE.BoxGeometry(1, 1, 1);
  /* Caliper: Major-Marker laenger als Intermediate-Marker (echte Lineal-Hierarchie).
   * Sonst nutzen Major und Intermediate dieselbe Dash-Laenge. */
  const tickGeoLenMajor = useCaliper ? caliperMarkLenMajor : dashLen;
  const tickGeoLenInt = useCaliper ? caliperMarkLenInt : dashLen;
  const tickGeo = useDot ? unitBox : new THREE.BoxGeometry(tickGeoLenMajor, effTickThick, effTickThick);
  const intThick = effTickThick * (styleParams.intermediateThicknessScale ?? 1);
  const tickGeoInt = useDot ? unitBox : new THREE.BoxGeometry(tickGeoLenInt, intThick, intThick);

  const scaleXStraight = dashLenStraight / dashLen;
  const scaleXArc = dashLenArc / dashLen;

  const _tickMat = new THREE.Matrix4();
  const _tickPos = new THREE.Vector3();
  const _tickQuat = new THREE.Quaternion();
  const _tickEul = new THREE.Euler();
  const _tickScale = new THREE.Vector3();

  const intermediatePcts = [0.1, 0.3, 0.5, 0.7, 0.9];
  /* Horizontal-Layout ohne Caliper: i=0 und i=tickCount entfallen (waeren mit den
   * Rails identisch). Mit Caliper sitzen die Marker UNTER dem Diagramm an allen 6
   * Tickpositionen, das sieht "lineal-haft" aus. */
  const majorYLevels = (isHorizontalLayout && !useCaliper) ? (tickCount + 1 - 2) : (tickCount + 1);
  const majorCount = majorYLevels * dashCount;
  const intermediateCount = intermediatePcts.length * dashCount;

  const tickMajorInst = new THREE.InstancedMesh(tickGeo, tickMajorMat, majorCount);
  tickMajorInst.renderOrder = -1;
  tickMajorInst.instanceMatrix.setUsage(THREE.StaticDrawUsage);
  const tickIntermediateInst = new THREE.InstancedMesh(tickGeoInt, tickIntermediateMat, intermediateCount);
  tickIntermediateInst.renderOrder = -1;
  tickIntermediateInst.instanceMatrix.setUsage(THREE.StaticDrawUsage);

  const writeTickMatrix = (inst, idx, layoutX, layoutY, layoutZ, rotAxis, rotAngle, scaleX) => {
    _tickPos.set(layoutX, layoutY, layoutZ);
    if (rotAxis === 'y') _tickEul.set(0, rotAngle, 0);
    else if (rotAxis === 'z') _tickEul.set(0, 0, rotAngle);
    else _tickEul.set(0, 0, 0);
    _tickQuat.setFromEuler(_tickEul);
    _tickScale.set(scaleX, 1, 1);
    _tickMat.compose(_tickPos, _tickQuat, _tickScale);
    inst.setMatrixAt(idx, _tickMat);
  };

  const writeDotMatrix = (inst, idx, layoutX, layoutY, layoutZ, rotAxis, rotAngle, uScale) => {
    _tickPos.set(layoutX, layoutY, layoutZ);
    if (rotAxis === 'y') _tickEul.set(0, rotAngle, 0);
    else if (rotAxis === 'z') _tickEul.set(0, 0, rotAngle);
    else _tickEul.set(0, 0, 0);
    _tickQuat.setFromEuler(_tickEul);
    _tickScale.set(uScale, uScale, uScale);
    _tickMat.compose(_tickPos, _tickQuat, _tickScale);
    inst.setMatrixAt(idx, _tickMat);
  };

  const putMajor = useDot ? writeDotMatrix : writeTickMatrix;
  const putInt = useDot ? writeDotMatrix : writeTickMatrix;

  let majorIdx = 0;
  for (let i = 0; i <= tickCount; i++) {
    const y = (i / tickCount) * rulerHeight;
    const hideInHorizontal = (i === 0 || i === tickCount);
    if (isHorizontalLayout && hideInHorizontal && !useCaliper) continue;
    for (let s = 0; s < dashCount; s++) {
      if (isArcLayout) {
        if (useCaliper) {
          /* Caliper: kurzer horizontaler Marker am linken Arc-Endpunkt, nach
           * aussen (-X) extendiert. Z leicht nach vorne, damit der Marker
           * nicht in der Rail-Plane versinkt. */
          putMajor(tickMajorInst, majorIdx++, arcLeftX - caliperMarkLenMajor / 2, y, arcLeftZ + 0.005, null, 0, 1);
        } else {
          const arcS = dashCount === 1 ? arcLength / 2 : dashLenArc / 2 + s * (arcLength - dashLenArc) / (dashCount - 1);
          const angle = arcStartAngle + arcS / arcRadius;
          putMajor(tickMajorInst, majorIdx++, Math.sin(angle) * arcRadius, y, -Math.cos(angle) * arcRadius + arcZCenter, 'y', -angle, useDot ? dotMaj : scaleXArc * lenScale);
        }
      } else if (isHorizontalLayout) {
        if (useCaliper) {
          /* Caliper horizontal: kurzer vertikaler Marker UNTER dem Diagramm
           * (zwischen Diagramm-Boden und Tick-Label). Rotation Math.PI/2 um Z
           * macht die X-Achse der Box vertikal. */
          putMajor(tickMajorInst, majorIdx++, horizontalLeftEdge + (i / tickCount) * maxBarHeight, horizontalRulerBottom - caliperMarkLenMajor / 2, 0, 'z', Math.PI / 2, 1);
        } else {
          const hY = useSolid
            ? horizontalRulerBottom + horizontalRulerHeight / 2
            : horizontalRulerBottom + (s / (dashCount - 1 || 1)) * horizontalRulerHeight;
          putMajor(tickMajorInst, majorIdx++, horizontalLeftEdge + (i / tickCount) * maxBarHeight, hY, 0, 'z', Math.PI / 2, useDot ? dotMaj : horizontalDashScale * lenScale);
        }
      } else {
        if (useCaliper) {
          /* Caliper Straight/Grouped/Stacked/Wave: kurzer horizontaler Marker
           * direkt links der Y-Achsen-Schiene (innere Markerkante an rulerX). */
          putMajor(tickMajorInst, majorIdx++, rulerX - caliperMarkLenMajor / 2, y, 0, null, 0, 1);
        } else {
          const straightX = dashCount === 1 ? rulerX + diagramWidth / 2 : rulerX + dashLenStraight / 2 + s * (diagramWidth - dashLenStraight) / (dashCount - 1);
          putMajor(tickMajorInst, majorIdx++, straightX, y, 0, null, 0, useDot ? dotMaj : scaleXStraight * lenScale);
        }
      }
    }
  }
  tickMajorInst.instanceMatrix.needsUpdate = true;
  tickMajorInst.computeBoundingSphere();
  tickMajorInst.computeBoundingBox();
  rulerGroup.add(tickMajorInst);
  rulerGroup.userData._rulerMaterials.push(tickMajorMat);
  rulerGroup.userData._rulerSolidMaterials.push(tickMajorMat);

  rulerGroup.userData._rulerMaterialsIntermediate = [];
  let intermediateIdx = 0;
  for (const pct of intermediatePcts) {
    const y = pct * rulerHeight;
    for (let s = 0; s < dashCount; s++) {
      if (isArcLayout) {
        if (useCaliper) {
          putInt(tickIntermediateInst, intermediateIdx++, arcLeftX - caliperMarkLenInt / 2, y, arcLeftZ + 0.005, null, 0, 1);
          continue;
        }
        const arcS = dashCount === 1 ? arcLength / 2 : dashLenArc / 2 + s * (arcLength - dashLenArc) / (dashCount - 1);
        const angle = arcStartAngle + arcS / arcRadius;
        putInt(tickIntermediateInst, intermediateIdx++, Math.sin(angle) * arcRadius, y, -Math.cos(angle) * arcRadius + arcZCenter, 'y', -angle, useDot ? dotMin : scaleXArc * lenScale);
      } else if (isHorizontalLayout) {
        if (useCaliper) {
          putInt(tickIntermediateInst, intermediateIdx++, horizontalLeftEdge + pct * maxBarHeight, horizontalRulerBottom - caliperMarkLenInt / 2, 0, 'z', Math.PI / 2, 1);
          continue;
        }
        const hYInt = useSolid
          ? horizontalRulerBottom + horizontalRulerHeight / 2
          : horizontalRulerBottom + (s / (dashCount - 1 || 1)) * horizontalRulerHeight;
        putInt(tickIntermediateInst, intermediateIdx++, horizontalLeftEdge + pct * maxBarHeight, hYInt, 0, 'z', Math.PI / 2, useDot ? dotMin : horizontalDashScale * lenScale);
      } else {
        if (useCaliper) {
          putInt(tickIntermediateInst, intermediateIdx++, rulerX - caliperMarkLenInt / 2, y, 0, null, 0, 1);
          continue;
        }
        const straightX = dashCount === 1 ? rulerX + diagramWidth / 2 : rulerX + dashLenStraight / 2 + s * (diagramWidth - dashLenStraight) / (dashCount - 1);
        putInt(tickIntermediateInst, intermediateIdx++, straightX, y, 0, null, 0, useDot ? dotMin : scaleXStraight * lenScale);
      }
    }
  }
  tickIntermediateInst.instanceMatrix.needsUpdate = true;
  tickIntermediateInst.computeBoundingSphere();
  tickIntermediateInst.computeBoundingBox();
  rulerGroup.add(tickIntermediateInst);
  rulerGroup.userData._rulerMaterialsIntermediate.push(tickIntermediateMat);
  } else {
    rulerGroup.userData._rulerMaterialsIntermediate = [];
  }

  const labelH = TICK_LABEL_PLANE_H, labelYOffset = 0.05;
  const tickW = styleParams.tickLabelWeight === 'light' ? 'light' : 'bold';
  const tickFontSize = TICK_LABEL_PLANE_H * TICK_LABEL_FONT_RATIO * (styleParams.tickLabelFontScale ?? 1);
  /* Schaetzung fuer Initial-Pose; nach Sync wird _tickLabelW per
   * `_tickLabelWidthByEntry`-Map exakt auf das echte Maximum gesetzt (und
   * darf dabei auch SINKEN — der Estimate ist konservativ-breit). */
  const initialLabelW = estimateTickLabelWidth(tickCount, axisYMin, axisYMax, axisYPrefix, axisYSuffix, tickFontSize, formatChartNumber);
  rulerGroup.userData._tickLabelW = initialLabelW;
  rulerGroup.userData._tickLabelWidthByEntry = new Map();
  const labelAngle = arcStartAngle - 0.15;
  const labelArcOffset = 0.12;
  const labelArcX = Math.sin(labelAngle) * (arcRadius + labelArcOffset);
  const labelArcZ = -Math.cos(labelAngle) * (arcRadius + labelArcOffset) + arcZCenter;

  rulerGroup.userData._labels = [];
  /* anchorX-Wahl pro Layout:
   *  - Straight/Grouped/Stacked/Wave (vertikal): rechte Textkante an "rulerX - GAP".
   *  - Horizontal: zentriert ueber Tick-Position.
   *  - Arc: zentriert auf Arc-Punkt (Polar-Symmetrie).
   * lookAt-Pose dreht das Mesh um 180 Grad um Y (Normale +Z). */
  const tickAnchorMode = isArcLayout ? 'center' : (isHorizontalLayout ? 'center' : 'right');
  for (let i = 0; i <= tickCount; i++) {
    const y = (i / tickCount) * rulerHeight + labelYOffset;
    const tickVal = axisYMin + (i / tickCount) * (axisYMax - axisYMin);
    const label = makeRulerTickLabelEntry(THREE, rulerGroup, {
      tickVal,
      axisYPrefix,
      axisYSuffix,
      color: rulerColorHex,
      fontSize: tickFontSize,
      weight: tickW,
      anchorMode: tickAnchorMode,
      renderOrder: RULER_LABEL_RENDER_ORDER,
      formatChartNumber,
    });
    if (isArcLayout) {
      label.position.set(labelArcX, y, labelArcZ + 0.01);
    } else if (isHorizontalLayout) {
      label.position.set(horizontalLeftEdge + (i / tickCount) * maxBarHeight, horizontalRulerBottom - 0.2, 0.01);
    } else {
      label.position.set(rulerX - TICK_LABEL_GAP, y, 0.01);
    }
    applyLocalLookAt(label, label.position.x, label.position.y, label.position.z + 10);
    rulerGroup.add(label);
    rulerGroup.userData._labels.push(label);
  }
  if (isHorizontalLayout) {
    rulerGroup.userData._horizontalTickLabelRowY = horizontalRulerBottom - 0.2;
    rulerGroup.userData._horizontalTickLabelLocalZ = 0.01;
    rulerGroup.userData._horizontalTickLabelFontWorld = tickFontSize;
  }
  /* Nach jedem Sync: echte Troika-Breite ins Map eintragen, _tickLabelW
   * exakt als Max neu berechnen (= darf auch SINKEN gegenüber Estimate). */
  rulerGroup.userData._labels.forEach((m) => {
    if (m.isGroup === true && m.userData._tickPair) {
      wireRulerTickGroupSync(m, rulerGroup);
      return;
    }
    m.userData._afterTickSync = (mm) => {
      const w = getLabelBounds(mm).width;
      rulerGroup.userData._tickLabelWidthByEntry.set(mm, w);
      recomputeRulerTickLabelW(rulerGroup);
    };
    m.userData._onSized = m.userData._afterTickSync;
  });

  rulerGroup.userData._seriesAxisYLabel = null;
  rulerGroup.userData._seriesAxisYLabelPlane = null;
  rulerGroup.userData._axisYLabelFramePad = 0;
  rulerGroup.userData._axisYLabelArcGeom = null;
  const axisYLabelRaw = typeof ctx.axisYLabel === 'string' ? ctx.axisYLabel.trim() : '';
  /* Auch leer anlegen (Editor: Y-Titel nachträglich tippen); sonst bleibt userData null und updateRulerColor greift nicht. */
  if (rd.hasVerticalSeriesAxis) {
    const yDisplay = axisYLabelRaw || '\u200b';
    const seriesLabelH = ctx.seriesAxisLabelPlaneH ?? AXIS_PAIR_LABEL_PLANE_H;
    const seriesFontSize = seriesLabelH * AXIS_PAIR_LABEL_FONT_RATIO;
    const tickStackLeftX = rulerX - initialLabelW - TICK_LABEL_GAP;
    const yCenter = ctx.rulerHeight / 2 + labelYOffset;
    const xCenter = tickStackLeftX - AXIS_Y_LABEL_GAP - seriesLabelH / 2;
    const tickStackR = arcRadius + labelArcOffset;
    const outwardExtra = initialLabelW / 2 + AXIS_Y_LABEL_GAP_ARC + seriesLabelH / 2;
    const yTitleR = tickStackR + outwardExtra;
    const yAxisArcX = Math.sin(labelAngle) * yTitleR;
    const yAxisArcZ = -Math.cos(labelAngle) * yTitleR + arcZCenter;
    const yPlane = makeRulerLabel(THREE, rulerGroup, {
      text: yDisplay,
      color: rulerColorHex,
      fontSize: seriesFontSize,
      anchorX: 'center',
      anchorY: 'middle',
      weight: 'bold',
      renderOrder: RULER_LABEL_RENDER_ORDER,
    });
    yPlane.rotation.z = Math.PI / 2;
    yPlane.userData._axisYTitle = true;
    const yGroup = new THREE.Group();
    yGroup.add(yPlane);
    if (isArcLayout) {
      yGroup.position.set(yAxisArcX, yCenter, yAxisArcZ + 0.01);
    } else {
      yGroup.position.set(xCenter, yCenter, 0.01);
    }
    applyLocalLookAt(yGroup, yGroup.position.x, yGroup.position.y, yGroup.position.z + 10);
    yGroup.renderOrder = RULER_LABEL_RENDER_ORDER;
    rulerGroup.add(yGroup);
    rulerGroup.userData._seriesAxisYLabel = yGroup;
    rulerGroup.userData._seriesAxisYLabelPlane = yPlane;
    if (axisYLabelRaw !== '') {
      rulerGroup.userData._axisYLabelFramePad = seriesLabelH + AXIS_Y_LABEL_GAP + 0.02;
    }
    rulerGroup.userData._axisYLabelArcGeom = { labelAngle, tickStackR, arcZCenter, labelW: initialLabelW };
  }

  rulerGroup.userData._xLabels = [];
  rulerGroup.userData._xLabelsHorizontalRight = [];
  rulerGroup.userData._xLabelsHorizontalInside = [];
  const barLenForRow = (dataVal) => {
    const t = (Number(dataVal) - axisYMin) / (axisYMax - axisYMin);
    const n = Math.max(0, Math.min(1, Number.isFinite(t) ? t : 0));
    return n * rulerHeight;
  };
  const HORIZONTAL_BAR_LABEL_GAP = 0.06;
  const HORIZONTAL_BAR_LABEL_LEFT_OUTSET = 0.11;
  /* Inside-Labels: kleines Padding ab Balken-Linkskante. Kompromiss zwischen
   * Abstand zum Balkenrand und visueller Naehe (etwas mehr als der Outside-Gap). */
  const HORIZONTAL_BAR_INSIDE_PADDING = 0.08;

  const headlineFontSize = STRAIGHT_XLABEL_PLANE_H * HEADLINE_FONT_RATIO * dataLabelFontScale;

  for (let i = 0; i < xCategoryCount; i++) {
    const headlineText = getCategoryLabel(i, (xCategoryLabels && xCategoryLabels[i] != null) ? xCategoryLabels[i] : DATA[i]?.label);
    let labelText;
    if (straightBelowSlot === 'value') {
      labelText = formatBarValueLineForRow(DATA[i], axisYPrefix, axisYSuffix, formatChartNumber);
    } else if (rd.isHorizontal && horizontalLeftSlot === 'value') {
      labelText = formatBarValueLineForRow(DATA[i], axisYPrefix, axisYSuffix, formatChartNumber);
    } else if (rd.usesWaveFloorLabels && waveFloorSlot === 'value') {
      labelText = formatBarValueLineForRow(DATA[i], axisYPrefix, axisYSuffix, formatChartNumber);
    } else {
      labelText = headlineText;
    }
    const straightX = groupedCategoryCenters ? groupedCategoryCenters[i] : (i - (xCategoryCount - 1) / 2) * straightSpacing;
    const angleStep = xCategoryCount > 1 ? (Math.PI * 0.55) / (xCategoryCount - 1) : 0;
    const startAngle = -Math.PI * 0.275;
    const angle = startAngle + angleStep * i;
    const arcX = Math.sin(angle) * (arcRadius + 0.25);
    const arcZ = -Math.cos(angle) * (arcRadius + 0.25) + arcZCenter;
    const hY = ((xCategoryCount - 1) / 2 - i) * straightSpacing + horizontalCenterY;
    /* anchorX:
     *  - useHeadlinePlaneLeft (horizontal-left ODER showHeadlinesAlongXAxis): rechte Textkante an position.x
     *  - sonst: zentriert. */
    const headlineAnchor = (rd.isHorizontal && horizontalLeftSlot !== 'off')
      ? 'right'
      : (showHeadlinesAlongXAxis ? 'right' : 'center');
    const xLabel = makeRulerLabel(THREE, rulerGroup, {
      text: clampHeadlineText(labelText),
      color: groupLabelColorHex,
      fontSize: headlineFontSize,
      anchorX: headlineAnchor,
      anchorY: 'middle',
      weight: 'bold',
      renderOrder: RULER_LABEL_RENDER_ORDER,
    });
    if (useHeadlinePlaneLeft) {
      xLabel.userData._straightHeadlinePlaneW = getLabelBounds(xLabel).width;
      xLabel.userData._onSized = (m) => { m.userData._straightHeadlinePlaneW = getLabelBounds(m).width; };
    }
    const xLabelHZ = horizontalLeftEdge - HORIZONTAL_BAR_LABEL_LEFT_OUTSET;
    if (isArcLayout) {
      xLabel.position.set(arcX, STRAIGHT_HEADLINE_Y, arcZ + 0.01);
      applyLocalLookAt(xLabel, 0, 2, 4);
    } else if (isHorizontalLayout) {
      xLabel.position.set(xLabelHZ, hY, arcRulerZOffset + straightBarCenterZ);
      applyLocalLookAt(xLabel, xLabelHZ, hY, 10);
    } else {
      const straightFloor = layout === 'straight';
      const groupedFloorLabels = rd.usesGroupedFloorLabels && labelsUnderBars;
      const waveFloorLabels = rd.usesWaveFloorLabels && (waveFloorSlot === 'headline' || waveFloorSlot === 'value');
      let floorY = stackedHeadlineY;
      let floorZ = straightXLabelLocalZ;
      if (straightFloor || groupedFloorLabels) {
        floorY += STRAIGHT_FLOOR_LABEL_Y_NUDGE;
        floorZ += STRAIGHT_FLOOR_LABEL_Z_NUDGE;
      } else if (waveFloorLabels) {
        floorY += WAVE_FLOOR_LABEL_Y_NUDGE;
        floorZ += WAVE_FLOOR_LABEL_Z_NUDGE;
      }
      xLabel.position.set(straightX, floorY, floorZ);
      applyLocalLookAt(xLabel, straightX, floorY, 10);
    }
    const straightLike = !isArcLayout && !isHorizontalLayout;
    if (showHeadlinesAlongXAxis && straightLike) {
      xLabel.rotation.z = Math.PI / 4;
      xLabel.userData._barLabelTiltRad = Math.PI / 4;
    } else {
      xLabel.userData._barLabelTiltRad = 0;
    }
    xLabel.visible = straightBelowSlot != null
      ? straightBelowSlot !== 'off'
      : isHorizontalLayout
        ? horizontalLeftSlot !== 'off'
        : showHeadlinesAlongXAxis;
    xLabel.userData._horizontalBarLabelMode = isHorizontalLayout ? horizontalLeftSlot : undefined;
    rulerGroup.add(xLabel);
    rulerGroup.userData._xLabels.push(xLabel);
  }

  if (rd.isHorizontal && horizontalRightSlot !== 'off') {
    for (let i = 0; i < xCategoryCount; i++) {
      const headlineText = getCategoryLabel(i, (xCategoryLabels && xCategoryLabels[i] != null) ? xCategoryLabels[i] : DATA[i]?.label);
      const labelText = horizontalRightSlot === 'value'
        ? formatBarValueLineForRow(DATA[i], axisYPrefix, axisYSuffix, formatChartNumber)
        : headlineText;
      if (!labelText) continue;
      const hY = ((xCategoryCount - 1) / 2 - i) * straightSpacing + horizontalCenterY;
      const barLen = barLenForRow(DATA[i]?.value);
      const xr = makeRulerLabel(THREE, rulerGroup, {
        text: clampHeadlineText(labelText),
        color: groupLabelColorHex,
        fontSize: headlineFontSize,
        /* Linke Textkante an rightX (Bar-Ende + GAP). */
        anchorX: 'left',
        anchorY: 'middle',
        weight: 'bold',
        renderOrder: RULER_LABEL_RENDER_ORDER,
      });
      xr.userData._straightHeadlinePlaneW = getLabelBounds(xr).width;
      xr.userData._onSized = (m) => { m.userData._straightHeadlinePlaneW = getLabelBounds(m).width; };
      const rightX = horizontalLeftEdge + barLen + HORIZONTAL_BAR_LABEL_GAP;
      xr.position.set(rightX, hY, arcRulerZOffset + straightBarCenterZ);
      applyLocalLookAt(xr, rightX, hY, 10);
      xr.userData._barLabelTiltRad = 0;
      xr.userData._horizontalBarLabelMode = horizontalRightSlot;
      xr.userData._dataIndex = i;
      xr.userData._horizontalRightLabelRowY = hY;
      xr.visible = true;
      rulerGroup.add(xr);
      rulerGroup.userData._xLabelsHorizontalRight.push(xr);
    }
  }

  /* Inside-Labels: links innen am Balken (anchorX='left'), Padding nach innen.
   * Auto-Contrast-Farbe basiert auf der statischen Bar-Farbe pro Zeile.
   * Per-Frame-Sync (Pro plugin) repositioniert + entscheidet ob Outside-Fallback noetig ist. */
  if (rd.isHorizontal && horizontalInsideSlot !== 'off') {
    const barColorHexList = Array.isArray(ctx.barColorHexForRuler) ? ctx.barColorHexForRuler : null;
    /* Z-Versatz vor die Balken-Vorderfront: Mesh ist `barDepth * 0.87` tief (siehe init.js
     * `horizontalBarDepthFactor`), Vorderfront also bei `barDepth * 0.435 + ~0.008` local-Z.
     * `barDepth * 0.6` legt das Label `barDepth * 0.165` (~0.066 bei default) davor – genug
     * Sicherheit gegen depth-buffer z-fighting auch bei sehr kleinen Diagrammen / weit
     * entfernter Kamera (Depth-Buffer-Praezision sinkt linear mit Kamera-Distanz). */
    const insideLabelLocalZ = arcRulerZOffset + straightBarCenterZ + barDepth * 0.6;
    for (let i = 0; i < xCategoryCount; i++) {
      const headlineText = getCategoryLabel(i, (xCategoryLabels && xCategoryLabels[i] != null) ? xCategoryLabels[i] : DATA[i]?.label);
      const labelText = horizontalInsideSlot === 'value'
        ? formatBarValueLineForRow(DATA[i], axisYPrefix, axisYSuffix, formatChartNumber)
        : headlineText;
      if (!labelText) continue;
      const hY = ((xCategoryCount - 1) / 2 - i) * straightSpacing + horizontalCenterY;
      const barHex = barColorHexList && barColorHexList[i] ? barColorHexList[i] : '#888888';
      const insideHex = contrastTextColor(barHex) || '#ffffff';
      const xin = makeRulerLabel(THREE, rulerGroup, {
        text: clampHeadlineText(labelText),
        color: insideHex,
        fontSize: headlineFontSize,
        anchorX: 'left',
        anchorY: 'middle',
        weight: 'bold',
        renderOrder: RULER_LABEL_RENDER_ORDER,
      });
      xin.userData._straightHeadlinePlaneW = getLabelBounds(xin).width;
      xin.userData._onSized = (m) => { m.userData._straightHeadlinePlaneW = getLabelBounds(m).width; };
      /* Initialposition: links innen am Balken. Sync ueberschreibt das pro Frame. */
      const startX = horizontalLeftEdge + HORIZONTAL_BAR_INSIDE_PADDING;
      xin.position.set(startX, hY, insideLabelLocalZ);
      applyLocalLookAt(xin, startX, hY, 10);
      xin.userData._barLabelTiltRad = 0;
      xin.userData._horizontalBarLabelMode = horizontalInsideSlot;
      xin.userData._dataIndex = i;
      xin.userData._horizontalInsideLabelRowY = hY;
      /* Vorberechnete Farben fuer Inside (Auto-Contrast vs. Bar-Color) und
       * Outside-Fallback (Standard Group-Label-Color, wie left/right). */
      xin.userData._horizontalInsideColorHex = insideHex;
      xin.userData._horizontalOutsideColorHex = groupLabelColorHex;
      xin.userData._horizontalInsidePadding = HORIZONTAL_BAR_INSIDE_PADDING;
      xin.userData._horizontalOutsideGap = HORIZONTAL_BAR_LABEL_GAP;
      /* Eigener Z-Offset, weil das Inside-Label vor der Balken-Front sitzen muss,
       * waehrend Right-Labels weiterhin auf der Balken-Mittelebene bleiben duerfen. */
      xin.userData._horizontalInsideLabelLocalZ = insideLabelLocalZ;
      xin.visible = true;
      rulerGroup.add(xin);
      rulerGroup.userData._xLabelsHorizontalInside.push(xin);
    }
  }

  rulerGroup.userData._seriesAxisXLabel = null;
  rulerGroup.userData._seriesAxisXLabelWidthWorld = null;
  const seriesText = typeof seriesAxisXLabel === 'string' ? seriesAxisXLabel.trim() : '';
  const seriesDisplay = seriesText || '\u200b';
  if (rd.hasVerticalSeriesAxis) {
    const seriesLabelH = AXIS_PAIR_LABEL_PLANE_H;
    const seriesFontSize = seriesLabelH * AXIS_PAIR_LABEL_FONT_RATIO;
    const seriesY = labelYOffset;
    const edgeX = diagramRightEdgeX ?? rulerX + diagramWidth;
    const { x: sx0, z: sz0 } = seriesAxisXLabelLeftXZ(edgeX);
    const { x: sx, y: sy, z: sz } = applySeriesXArcFinetune(layout, sx0, seriesY, sz0);
    const seriesMesh = makeRulerLabel(THREE, rulerGroup, {
      text: seriesDisplay,
      color: rulerColorHex,
      fontSize: seriesFontSize,
      anchorX: 'left',
      anchorY: 'middle',
      weight: 'bold',
      renderOrder: RULER_LABEL_RENDER_ORDER,
    });
    rulerGroup.userData._diagramRightEdgeX = edgeX;
    rulerGroup.userData._seriesAxisXIsArc = isArcLayout;
    seriesMesh.userData._seriesLabelBaseY = labelYOffset;
    seriesMesh.position.set(sx, sy, sz);
    applyLocalLookAt(seriesMesh, sx, sy, 10);
    seriesMesh.userData._onSized = (m) => {
      rulerGroup.userData._seriesAxisXLabelWidthWorld = (!m.visible) ? 0 : getLabelBounds(m).width;
    };
    rulerGroup.add(seriesMesh);
    rulerGroup.userData._seriesAxisXLabel = seriesMesh;
    rulerGroup.userData._seriesAxisXLabelWidthWorld = seriesText ? getLabelBounds(seriesMesh).width : 0;
    rulerGroup.userData._seriesAxisXMarginWorld = SERIES_AXIS_X_MARGIN_WORLD;
  }

  rulerGroup.userData._horizontalAxisXLabel = null;
  rulerGroup.userData._horizontalAxisXLabelCtx = null;
  if (rd.hasVerticalSeriesAxis || rd.isHorizontal) {
    const seriesLabelH = AXIS_PAIR_LABEL_PLANE_H;
    const seriesFontSize = seriesLabelH * AXIS_PAIR_LABEL_FONT_RATIO;
    const tickRowY = horizontalRulerBottom - 0.2;
    const hx = horizontalLeftEdge + maxBarHeight / 2;
    const hy = tickRowY - labelH / 2 - HORIZONTAL_AXIS_X_LABEL_GAP - seriesLabelH / 2;
    const midAngle = (arcStartAngle + arcEndAngle) * 0.5;
    const arcAxisR = arcRadius + labelArcOffset;
    const hArcX = Math.sin(midAngle) * arcAxisR;
    const hArcZ = -Math.cos(midAngle) * arcAxisR + arcZCenter;
    const straightCenterX = rulerX + diagramWidth / 2;
    const hAxisMesh = makeRulerLabel(THREE, rulerGroup, {
      text: seriesDisplay,
      color: rulerColorHex,
      fontSize: seriesFontSize,
      anchorX: 'center',
      anchorY: 'middle',
      weight: 'bold',
      renderOrder: RULER_LABEL_RENDER_ORDER,
    });
    if (isArcLayout) {
      hAxisMesh.position.set(hArcX, labelYOffset, hArcZ + 0.01);
      applyLocalLookAt(hAxisMesh, hArcX, labelYOffset, hArcZ + 10);
    } else if (isHorizontalLayout) {
      hAxisMesh.position.set(hx, hy, 0.01);
      applyLocalLookAt(hAxisMesh, hx, hy, 10);
    } else {
      hAxisMesh.position.set(straightCenterX, labelYOffset, 0.01);
      applyLocalLookAt(hAxisMesh, straightCenterX, labelYOffset, 10);
    }
    rulerGroup.add(hAxisMesh);
    rulerGroup.userData._horizontalAxisXLabel = hAxisMesh;
    rulerGroup.userData._horizontalAxisXLabelCtx = {
      horizontalLeftEdge,
      maxBarHeight,
      horizontalRulerBottom,
      rulerX,
      diagramWidth,
      arcStartAngle,
      arcEndAngle,
      arcRadius,
      arcZCenter,
      labelYOffset,
      labelArcOffset,
      labelH,
    };
  }

  rulerGroup.userData._barLabelsUnderBars = straightBelowSlot != null
    ? (straightBelowSlot === 'headline' || straightBelowSlot === 'value')
    : rd.isHorizontal
      ? (horizontalLeftSlot !== 'off' || horizontalRightSlot !== 'off' || horizontalInsideSlot !== 'off')
      : showHeadlinesAlongXAxis;
  rulerGroup.userData._straightBelowIsValue = straightBelowSlot === 'value';
  rulerGroup.userData._waveFloorIsValue = rd.usesWaveFloorLabels && waveFloorSlot === 'value';
  rulerGroup.userData._arcRulerZOffset = arcRulerZOffset;
  rulerGroup.userData._horizontalBarLabelLocalZ = rd.isHorizontal ? arcRulerZOffset + straightBarCenterZ : undefined;

  const showSeriesAxisX = !isHorizontalLayout && rd.hasVerticalSeriesAxis;
  const showAxisYLabel = !isHorizontalLayout && rd.hasVerticalSeriesAxis;
  const showHorizontalAxisX = isHorizontalLayout;
  if (rulerGroup.userData._seriesAxisXLabel) rulerGroup.userData._seriesAxisXLabel.visible = showSeriesAxisX && !!seriesText;
  if (rulerGroup.userData._seriesAxisYLabel) rulerGroup.userData._seriesAxisYLabel.visible = showAxisYLabel && !!axisYLabelRaw;
  if (rulerGroup.userData._horizontalAxisXLabel) rulerGroup.userData._horizontalAxisXLabel.visible = showHorizontalAxisX && !!seriesText;

  rulerGroup.userData._rulerStyleSolidMul = styleParams.solidOpacityMul;
  rulerGroup.userData._rulerStyleIntermediateFactor = styleParams.intermediateOpacityFactor;

  return { rulerGroup, mainLine, mainLineRight };
}

/** Limitiert Headline-Texte auf 36 Zeichen + Ellipsis (alte Verhalten). */
function clampHeadlineText(raw) {
  const s = String(raw == null ? '' : raw);
  if (s.length <= 36) return s;
  return s.slice(0, 35) + '\u2026';
}

/**
 * Pro Frame: Position der rechten Horizontal-Labels an aktuelles Bar-Ende anpassen.
 */
/**
 * Pro-only: live-Sync der rechten X-Achsen-Labels (Horizontal-Layout).
 * Implementierung in Add-on-Plugins via `__CE_PD_SCENE_EXT__.ruler.syncHorizontalRightLabels`.
 * Free behaelt den Export-Namen als duennen Stub, damit init.js unveraendert
 * importieren kann.
 */
export function syncHorizontalRightLabels(rulerGroup, bars) {
  const ext = (typeof window !== 'undefined' && window.__CE_PD_SCENE_EXT__ && window.__CE_PD_SCENE_EXT__.ruler) || null;
  const fn = ext && ext.syncHorizontalRightLabels;
  if (typeof fn === 'function') fn(rulerGroup, bars);
}

/**
 * Live-Edit-Update aus dem Gutenberg-Editor: Farben, Texte und Schrift-Skala der Ruler-
 * Beschriftungen aktualisieren — keine Texturen mehr neu bauen, einfach Troika-Properties setzen.
 */
export function updateRulerColor(rulerGroup, rulerColor, ctx) {
  if (!rulerGroup || !ctx?.THREE) return;
  const { THREE, axisYMin, axisYMax, axisYPrefix, axisYSuffix, getCategoryLabel, DATA, seriesAxisXLabel = '' } = ctx;
  const formatChartNumber = typeof ctx.formatChartNumber === 'function'
    ? ctx.formatChartNumber
    : getFallbackChartNumberFormatter().formatTick;
  const dataLabelFontScale = clampDataLabelFontScale(ctx.dataLabelFontScale ?? rulerGroup.userData._dataLabelFontScale);
  const rulerColorHexIn = rulerColor.startsWith('#') ? rulerColor : '#' + rulerColor;
  const rsNorm = normalizeRulerStyle(ctx.rulerStyle);
  const attrRaw = ctx.rulerColorAttr != null ? String(ctx.rulerColorAttr).trim() : '';
  const rulerColorAttrForPaint = attrRaw !== '' ? (attrRaw.startsWith('#') ? attrRaw : `#${attrRaw}`) : '';
  const paintHex = resolveRulerPaintHex({
    rulerStyle: rsNorm,
    rulerColorResolved: rulerColorHexIn,
    rulerColorAttr: rulerColorAttrForPaint,
    backgroundColor: ctx.backgroundColor || '#f8f9fa',
    backgroundTransparent: ctx.backgroundTransparent === true,
  });
  const rulerColorThree = new THREE.Color(paintHex);
  const groupLabelHex = (ctx.groupLabelColor != null && ctx.groupLabelColor !== '')
    ? (String(ctx.groupLabelColor).startsWith('#') ? String(ctx.groupLabelColor) : '#' + ctx.groupLabelColor)
    : paintHex;
  const tickCount = 5;
  const layout = rulerGroup.userData._layout;
  const rd = resolveRulerLayoutDescriptor(layout);
  const isArcLayout = rd.isArc;
  const isHorizontalLayout = rd.isHorizontal;
  const showSeriesAxisX = !isHorizontalLayout && rd.hasVerticalSeriesAxis;
  const showAxisYLabelPlane = !isHorizontalLayout && rd.hasVerticalSeriesAxis;
  const showHorizontalAxisX = isHorizontalLayout;
  const localLookAt = makeLocalLookAt(THREE);
  (rulerGroup.userData._rulerSolidMaterials || []).forEach((mat) => { mat.color.copy(rulerColorThree); });
  (rulerGroup.userData._rulerMaterialsIntermediate || []).forEach((mat) => { mat.color.copy(rulerColorThree); });
  (rulerGroup.userData._rulerBandMaterials || []).forEach((mat) => { mat.color.copy(rulerColorThree); });
  rulerGroup.userData._dataLabelFontScale = dataLabelFontScale;

  /* Tick-Labels: Text + Farbe via Troika; Position wird nach Sync ueber tickAnchor=right
   * automatisch korrekt. Fuer Y-Achsen-Titel reicht ein Text-Update. */
  const tickStyle = getRulerStyleParams(rsNorm);
  const tickW = tickStyle.tickLabelWeight === 'light' ? 'light' : 'bold';
  const tickFontSize = TICK_LABEL_PLANE_H * TICK_LABEL_FONT_RATIO * (tickStyle.tickLabelFontScale ?? 1);
  /* Map vor Re-Sync zuruecksetzen — sonst bleibt eine alte (groessere) Tick-Breite
   * im Max-Vergleich stehen, obwohl die neuen Achsenwerte schmaler rendern. */
  if (rulerGroup.userData._tickLabelWidthByEntry instanceof Map) {
    rulerGroup.userData._tickLabelWidthByEntry.clear();
  } else {
    rulerGroup.userData._tickLabelWidthByEntry = new Map();
  }
  (rulerGroup.userData._labels || []).forEach((label, i) => {
    const tickVal = axisYMin + (i / tickCount) * (axisYMax - axisYMin);
    const parts = fmtTickLabelParts(tickVal, axisYPrefix, axisYSuffix, formatChartNumber);
    if (label.isGroup === true && label.userData._tickPair) {
      const p = label.userData._tickPair;
      p.gapPrefixWorld = parts.prefix ? tickFontSize * TICK_PREFIX_UNIT_GAP_FRAC : 0;
      p.gapSuffixWorld = parts.suffix ? tickFontSize * TICK_VALUE_UNIT_GAP_FRAC : 0;
      if (p.preMesh) {
        updateTroikaLabel(p.preMesh, { text: parts.prefix, color: paintHex, fontSize: tickFontSize, weight: tickW });
      }
      updateTroikaLabel(p.bodyMesh, { text: parts.numberStr, color: paintHex, fontSize: tickFontSize, weight: tickW });
      if (p.sufMesh) {
        const sufUrl = rulerTickSuffixFontUrl(parts.suffix, tickW);
        updateTroikaLabel(p.sufMesh, {
          text: parts.suffix,
          color: paintHex,
          fontSize: tickFontSize,
          weight: tickW,
          ...(sufUrl ? { fontUrl: sufUrl } : {}),
        });
      }
      layoutRulerTickGroup(label);
      rulerGroup.userData._tickLabelWidthByEntry.set(label, getRulerTickGroupWidth(label));
    } else {
      const tickText = fmtTickLabel(tickVal, axisYPrefix, axisYSuffix, formatChartNumber);
      updateTroikaLabel(label, { text: tickText, color: paintHex, fontSize: tickFontSize, weight: tickW });
      rulerGroup.userData._tickLabelWidthByEntry.set(label, getLabelBounds(label).width);
    }
  });
  recomputeRulerTickLabelW(rulerGroup);
  if (isHorizontalLayout) rulerGroup.userData._horizontalTickLabelFontWorld = tickFontSize;

  const headlineFontSize = STRAIGHT_XLABEL_PLANE_H * HEADLINE_FONT_RATIO * dataLabelFontScale;
  const headlineMode = rulerGroup.userData._barLabelsUnderBars === true;
  const belowIsValue = rulerGroup.userData._straightBelowIsValue === true || rulerGroup.userData._waveFloorIsValue === true;
  (rulerGroup.userData._xLabels || []).forEach((xLabel, i) => {
    const xLab = ctx.xCategoryLabels;
    const headlineText = getCategoryLabel(i, (xLab && xLab[i] != null) ? xLab[i] : DATA[i]?.label);
    const hLeft = xLabel.userData._horizontalBarLabelMode;
    let labelText;
    if (belowIsValue) {
      labelText = formatBarValueLineForRow(DATA[i], ctx.axisYPrefix, ctx.axisYSuffix, formatChartNumber);
    } else if (hLeft === 'value') {
      labelText = formatBarValueLineForRow(DATA[i], ctx.axisYPrefix, ctx.axisYSuffix, formatChartNumber);
    } else {
      labelText = headlineText;
    }
    updateTroikaLabel(xLabel, {
      text: clampHeadlineText(labelText),
      color: groupLabelHex,
      fontSize: headlineFontSize,
    });
    if (xLabel.userData._straightHeadlinePlaneW != null) {
      xLabel.userData._straightHeadlinePlaneW = getLabelBounds(xLabel).width;
    }
  });
  (rulerGroup.userData._xLabelsHorizontalRight || []).forEach((xr) => {
    const i = xr.userData._dataIndex;
    if (i == null || DATA[i] == null) return;
    const xLab = ctx.xCategoryLabels;
    const headlineText = getCategoryLabel(i, (xLab && xLab[i] != null) ? xLab[i] : DATA[i]?.label);
    const hRight = xr.userData._horizontalBarLabelMode;
    const labelText = hRight === 'value'
      ? formatBarValueLineForRow(DATA[i], ctx.axisYPrefix, ctx.axisYSuffix, formatChartNumber)
      : headlineText;
    updateTroikaLabel(xr, {
      text: clampHeadlineText(labelText),
      color: groupLabelHex,
      fontSize: headlineFontSize,
    });
    xr.userData._straightHeadlinePlaneW = getLabelBounds(xr).width;
  });
  /* Inside-Labels: Text + Schrift aktualisieren. Auto-Contrast-Farbe wird
   * aus der pro Zeile vorberechneten Bar-Farbe abgeleitet (kein Re-Compute
   * noetig, weil ctx.barColorHexForRuler bei Live-Edit-Updates ebenfalls
   * vom init.js neu durchgereicht wird). Farbe-Sync laeuft per-Frame via
   * syncHorizontalRightLabels (Pro) und entscheidet auch Inside-vs-Outside. */
  (rulerGroup.userData._xLabelsHorizontalInside || []).forEach((xin) => {
    const i = xin.userData._dataIndex;
    if (i == null || DATA[i] == null) return;
    const xLab = ctx.xCategoryLabels;
    const headlineText = getCategoryLabel(i, (xLab && xLab[i] != null) ? xLab[i] : DATA[i]?.label);
    const hInside = xin.userData._horizontalBarLabelMode;
    const labelText = hInside === 'value'
      ? formatBarValueLineForRow(DATA[i], ctx.axisYPrefix, ctx.axisYSuffix, formatChartNumber)
      : headlineText;
    const barHexList = Array.isArray(ctx.barColorHexForRuler) ? ctx.barColorHexForRuler : null;
    if (barHexList && barHexList[i]) {
      const insideHex = contrastTextColor(barHexList[i]) || '#ffffff';
      xin.userData._horizontalInsideColorHex = insideHex;
    }
    xin.userData._horizontalOutsideColorHex = groupLabelHex;
    updateTroikaLabel(xin, {
      text: clampHeadlineText(labelText),
      fontSize: headlineFontSize,
    });
    xin.userData._straightHeadlinePlaneW = getLabelBounds(xin).width;
  });

  /* Series-X-Titel: Text + Farbe — Position nutzt anchorX='left'; bei Achsen-Beschriftungswechsel
   * bleibt die Position links bei edgeX + GAP. */
  const seriesMesh = rulerGroup.userData._seriesAxisXLabel;
  if (seriesMesh) {
    const st = typeof seriesAxisXLabel === 'string' ? seriesAxisXLabel.trim() : '';
    if (st && showSeriesAxisX) {
      seriesMesh.visible = true;
      updateTroikaLabel(seriesMesh, { text: st, color: paintHex });
      const { rulerX, diagramWidth, diagramRightEdgeX: ctxEdge } = ctx;
      const edgeX = ctxEdge ?? rulerGroup.userData._diagramRightEdgeX ?? (rulerX != null && diagramWidth != null ? rulerX + diagramWidth : null);
      if (edgeX != null) {
        const { x: sx0, z: sz0 } = seriesAxisXLabelLeftXZ(edgeX);
        const baseY = seriesMesh.userData._seriesLabelBaseY ?? 0.05;
        const { x: sx, y: sy, z: sz } = applySeriesXArcFinetune(layout, sx0, baseY, sz0);
        seriesMesh.position.set(sx, sy, sz);
        localLookAt(seriesMesh, sx, sy, 10);
        rulerGroup.userData._seriesAxisXLabelWidthWorld = getLabelBounds(seriesMesh).width;
      }
    } else {
      seriesMesh.visible = false;
      updateTroikaLabel(seriesMesh, { text: '\u200b', color: paintHex });
      rulerGroup.userData._seriesAxisXLabelWidthWorld = 0;
    }
  }

  /* Y-Axis-Titel: Text + Farbe + Position relativ zur aktuellen Tick-Breite. */
  const yAxisGroup = rulerGroup.userData._seriesAxisYLabel;
  const yAxisPlane = rulerGroup.userData._seriesAxisYLabelPlane;
  if (yAxisGroup && yAxisPlane && ctx.rulerHeight != null && ctx.rulerX != null) {
    const yt = typeof ctx.axisYLabel === 'string' ? ctx.axisYLabel.trim() : '';
    if (yt && showAxisYLabelPlane) {
      yAxisGroup.visible = true;
      const labelW = rulerGroup.userData._tickLabelW || 0.65;
      const labelYOffset = 0.05;
      updateTroikaLabel(yAxisPlane, { text: yt, color: paintHex });
      const seriesLabelH = ctx.seriesAxisLabelPlaneH ?? AXIS_PAIR_LABEL_PLANE_H;
      const tickStackLeftX = ctx.rulerX - labelW - 0.14;
      const yCenter = ctx.rulerHeight / 2 + labelYOffset;
      const xCenter = tickStackLeftX - AXIS_Y_LABEL_GAP - seriesLabelH / 2;
      rulerGroup.userData._axisYLabelFramePad = seriesLabelH + AXIS_Y_LABEL_GAP + 0.02;
      if (isArcLayout) {
        const arcG = rulerGroup.userData._axisYLabelArcGeom;
        if (arcG) {
          const outwardExtra = labelW / 2 + AXIS_Y_LABEL_GAP_ARC + seriesLabelH / 2;
          const yTitleR = arcG.tickStackR + outwardExtra;
          const ax = Math.sin(arcG.labelAngle) * yTitleR;
          const az = -Math.cos(arcG.labelAngle) * yTitleR + arcG.arcZCenter;
          yAxisGroup.position.set(ax, yCenter, az + 0.01);
          localLookAt(yAxisGroup, ax, yCenter, az + 10);
        }
      } else {
        yAxisGroup.position.set(xCenter, yCenter, 0.01);
        localLookAt(yAxisGroup, xCenter, yCenter, 10);
      }
    } else {
      yAxisGroup.visible = false;
      updateTroikaLabel(yAxisPlane, { text: '\u200b', color: paintHex });
      rulerGroup.userData._axisYLabelFramePad = 0;
    }
  }
  /* Horizontal-Achsen-Titel: Text + Farbe; Position via gespeichertem ctx. */
  const hAxisMesh = rulerGroup.userData._horizontalAxisXLabel;
  const hAxisCtx = rulerGroup.userData._horizontalAxisXLabelCtx;
  if (hAxisMesh && hAxisCtx) {
    const st = typeof seriesAxisXLabel === 'string' ? seriesAxisXLabel.trim() : '';
    if (st && showHorizontalAxisX) {
      hAxisMesh.visible = true;
      const g = hAxisCtx;
      updateTroikaLabel(hAxisMesh, { text: st, color: paintHex });
      const planeH = ctx.seriesAxisLabelPlaneH ?? AXIS_PAIR_LABEL_PLANE_H;
      if (isArcLayout) {
        const midAngle = (g.arcStartAngle + g.arcEndAngle) * 0.5;
        const arcAxisR = g.arcRadius + g.labelArcOffset;
        const hArcX = Math.sin(midAngle) * arcAxisR;
        const hArcZ = -Math.cos(midAngle) * arcAxisR + g.arcZCenter;
        hAxisMesh.position.set(hArcX, g.labelYOffset, hArcZ + 0.01);
        localLookAt(hAxisMesh, hArcX, g.labelYOffset, hArcZ + 10);
      } else if (isHorizontalLayout) {
        const tickRowY = g.horizontalRulerBottom - 0.2;
        const hx = g.horizontalLeftEdge + g.maxBarHeight / 2;
        const hy = tickRowY - g.labelH / 2 - HORIZONTAL_AXIS_X_LABEL_GAP - planeH / 2;
        hAxisMesh.position.set(hx, hy, 0.01);
        localLookAt(hAxisMesh, hx, hy, 10);
      } else {
        const straightCenterX = g.rulerX + g.diagramWidth / 2;
        hAxisMesh.position.set(straightCenterX, g.labelYOffset, 0.01);
        localLookAt(hAxisMesh, straightCenterX, g.labelYOffset, 10);
      }
    } else {
      hAxisMesh.visible = false;
      updateTroikaLabel(hAxisMesh, { text: '\u200b', color: paintHex });
    }
  }
}
