God-Files aufgeteilt (ResourceManager/generatePlan/toWalls3d/App.tsx-Handler), Totcode entfernt (geometry-Crate, OCCT/HLR-Spike), PDF/Bild-Import ergänzt.

ResourceManager.tsx, generatePlan.ts und toWalls3d.ts sind nach Bauteil-
Domäne in src/ui/resourceManager/, src/plan/generatePlan/ und
src/plan/toWalls3d/ aufgeteilt (reine Verschiebung, keine Verhaltens-
änderung). App.tsx verliert weitere Handler-Gruppen an eigene Hooks
(useExportHandlers, useImportHandling, useKeyboardShortcuts) sowie
Hit-Testing an src/viewport/planHitTest.ts.

Unbenutzte Rust-geometry-Crate (durch WASM-Joins abgelöst) sowie der alte
OCCT/HLR-Schnitt-Spike (hlr.ts/occt.ts, vite occt-Aliases) entfernt.

Neu: PDF-Import als Rasterbild (src/io/pdfImport.ts) + gemeinsamer
Bild-Asset-Helfer (src/io/imageAsset.ts) für Plan- und Layout-Bilder.

tsc -b sauber, vitest 891/891 grün.
This commit is contained in:
2026-08-19 22:45:41 +02:00
parent 06c0143d32
commit 1507c2bf00
85 changed files with 12461 additions and 11280 deletions
+162 -84
View File
@@ -15,7 +15,7 @@
// Es werden NUR Eigenschaften angeboten, die der Host-Kontrakt tatsächlich
// setzen kann — keine Stubs/Fake-Setter (Konvention wire-dont-stub). Felder ohne
// echten Setter (Linienstil) werden read-only/informativ gezeigt; Felder, die
// das Modell gar nicht trägt (Deckkraft, Caps, Schlagschatten), werden bewusst
// das Modell gar nicht trägt (Caps, Schlagschatten), werden bewusst
// weggelassen, um die Palette ehrlich und schlank zu halten.
//
// DOSSIER-Tabellen-Look: KEINE wiederholten Feld-Labels pro Zeile — ein kompaktes
@@ -107,10 +107,20 @@ export function AttributesPanel() {
}
const isDrawing = sel.kind === "drawing2d";
// Layout-Annotation (Linie/Rechteck/Text/Bild auf einem Blatt) — kein
// Projekt-Bauteil, daher ohne Ebene/Bauteil-Vererbung: Farbe/Strichstärke
// sind IMMER ein eigener Wert (kein 3-Quellen-Dropdown, keine Füllung/
// Schraffur/Masse — s. Ausblendungen weiter unten).
const isAnnotation = sel.kind === "layoutAnnotation";
// Strichstärke: Wand/Decke/Drawing2D tragen einen eigenen Override
// (`strokeWeight`/`weightMm`) + Quellen-Dropdown; andere Elementarten erben
// stets aus der Kategorie (kein Setter im Kontrakt → nicht editierbar).
const weightEditable = sel.kind === "wall" || sel.kind === "ceiling" || isDrawing;
// stets aus der Kategorie (kein Setter im Kontrakt → nicht editierbar). Bei
// Annotationen nur Linie/Rechteck (Text/Bild kennen keine Strichstärke).
const weightEditable =
sel.kind === "wall" ||
sel.kind === "ceiling" ||
isDrawing ||
(isAnnotation && (sel.annotKind === "line" || sel.annotKind === "rect"));
// Schraffur: Wand/Decke (Schnitt-/Ansichts-Schraffur) oder geschlossene 2D-Form.
const fillEditable =
sel.kind === "wall" || sel.kind === "ceiling" || (isDrawing && sel.closed === true);
@@ -223,97 +233,165 @@ export function AttributesPanel() {
<ColorHexField value={sel.color} onChange={host.onSetSelectionColor} />
</span>
{/* Strichstärke (mm) — Wand/Decke/Drawing2D, mit Nach-Ebene/Nach-Bauteil/
eigener-Wert-Quelle; andere Elementarten erben stets aus der Kategorie. */}
{sourceRow(
"attr.weight",
weightEditable,
weightUi,
onWeightSourceChange,
<>
<input
className="attr-num"
style={{ width: 72 }}
type="number"
step={0.01}
min={0}
list="attr-pen-weights"
value={sel.weightMm}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetSelectionWeight(v);
}}
/>
<datalist id="attr-pen-weights">
{PEN_WEIGHTS.map((w) => (
<option key={w} value={w} />
))}
</datalist>
</>,
{/* Strichstärke (mm): Wand/Decke/Drawing2D mit Nach-Ebene/Nach-Bauteil/
eigener-Wert-Quelle; eine Layout-Annotation kennt diese Vererbung
nicht (kein Bauteil/keine Ebene) — dort ein schlichtes Zahlenfeld
ohne Quellen-Dropdown, wenn die Art überhaupt eine Strichstärke
trägt (Linie/Rechteck, nicht Text/Bild). */}
{isAnnotation ? (
weightEditable && (
<>
<span className="attr-key">{t("attr.weight")}</span>
<span className="attr-val">
<input
className="attr-num"
style={{ width: 72 }}
type="number"
step={0.01}
min={0}
list="attr-pen-weights"
value={sel.weightMm}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetSelectionWeight(v);
}}
/>
</span>
</>
)
) : (
sourceRow(
"attr.weight",
weightEditable,
weightUi,
onWeightSourceChange,
<>
<input
className="attr-num"
style={{ width: 72 }}
type="number"
step={0.01}
min={0}
list="attr-pen-weights"
value={sel.weightMm}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetSelectionWeight(v);
}}
/>
<datalist id="attr-pen-weights">
{PEN_WEIGHTS.map((w) => (
<option key={w} value={w} />
))}
</datalist>
</>,
)
)}
{/* Linienstil — nur Drawing2D trägt ihn (Wand/Decke/etc. read-only). */}
<span className="attr-key">{t("attr.lineStyle")}</span>
<span className="attr-val">
{isDrawing ? (
<Dropdown
value={drawing?.lineStyleId ?? "__none__"}
onChange={(v) =>
host.onSetSelectionLineStyle(v === "__none__" ? undefined : v)
}
options={[
{ value: "__none__", label: t("attr.lineStyle.none") },
...project.lineStyles.map((l) => ({ value: l.id, label: l.name })),
]}
/>
) : (
<span className="attr-readonly">{lineStyle ? lineStyle.name : "—"}</span>
)}
</span>
{/* Deckkraft (0..1) — nur Bild-Elemente (Drawing2D `{shape:"image"}`
im Plan, Layout-Annotation `kind:"image"`). Schlichtes Zahlenfeld
wie die Strichstärke bei Annotationen: kein Bauteil/keine Ebene,
also keine Quellen-Vererbung. */}
{sel.isImage && (
<>
<span className="attr-key">{t("attr.opacity")}</span>
<span className="attr-val">
<input
className="attr-num"
style={{ width: 72 }}
type="number"
step={0.05}
min={0}
max={1}
value={sel.opacity ?? 1}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetSelectionOpacity(Math.max(0, Math.min(1, v)));
}}
/>
</span>
</>
)}
{/* Linienstil — nur Drawing2D trägt ihn (Wand/Decke/Annotation etc.
read-only), Rest wie gehabt. */}
{!isAnnotation && (
<>
<span className="attr-key">{t("attr.lineStyle")}</span>
<span className="attr-val">
{isDrawing ? (
<Dropdown
value={drawing?.lineStyleId ?? "__none__"}
onChange={(v) =>
host.onSetSelectionLineStyle(v === "__none__" ? undefined : v)
}
options={[
{ value: "__none__", label: t("attr.lineStyle.none") },
...project.lineStyles.map((l) => ({ value: l.id, label: l.name })),
]}
/>
) : (
<span className="attr-readonly">{lineStyle ? lineStyle.name : "—"}</span>
)}
</span>
</>
)}
{/* ── Füllung (Vordergrund/Hintergrund + Schraffur) ─────────── */}
{/* Vordergrund = Muster-/Schraffurfarbe, Hintergrund = Füllfarbe/
Poché. Jedes Feld: Nach Ebene (LayerCategory erzwingt den Wert) /
Nach Bauteil (erbt vom Component-Manager, Default) / eigener Wert. */}
<div className="attr-section-label">{t("attr.fill")}</div>
Nach Bauteil (erbt vom Component-Manager, Default) / eigener Wert.
Eine Layout-Annotation kennt keine Füllung (nur Umriss) — Sektion
entfällt komplett statt drei bedeutungslose Dropdowns zu zeigen. */}
{!isAnnotation && (
<>
<div className="attr-section-label">{t("attr.fill")}</div>
{sourceRow(
"attr.foreground",
pocheEditable,
fgUi,
onForegroundSourceChange,
colorValue(sel.foreground, host.onSetSelectionForeground),
{sourceRow(
"attr.foreground",
pocheEditable,
fgUi,
onForegroundSourceChange,
colorValue(sel.foreground, host.onSetSelectionForeground),
)}
{sourceRow(
"attr.background",
pocheEditable,
bgUi,
onBackgroundSourceChange,
colorValue(sel.background, host.onSetSelectionBackground),
)}
{sourceRow(
"attr.hatch",
fillEditable,
hatchUi,
onHatchSourceChange,
<Dropdown
value={sel.fillHatchId ?? ""}
onChange={(v) => host.onSetSelectionFill(v || null)}
options={[
{ value: "", label: t("attr.none") },
...project.hatches.map((h) => ({ value: h.id, label: h.name })),
]}
/>,
)}
</>
)}
{sourceRow(
"attr.background",
pocheEditable,
bgUi,
onBackgroundSourceChange,
colorValue(sel.background, host.onSetSelectionBackground),
{/* ── Masse (informativ, aus der bbox) — bei einer Layout-Annotation
in Papier-mm statt Modell-Metern geführt (`formatM` passt nicht),
daher hier weggelassen; Grösse steht bereits im Blatt-HUD. */}
{!isAnnotation && (
<>
<div className="attr-section-label">{t("attr.size")}</div>
<span className="attr-key">{t("attr.width")}</span>
<span className="attr-val attr-readonly">{formatM(bboxW)}</span>
<span className="attr-key">{t("attr.height")}</span>
<span className="attr-val attr-readonly">{formatM(bboxH)}</span>
</>
)}
{sourceRow(
"attr.hatch",
fillEditable,
hatchUi,
onHatchSourceChange,
<Dropdown
value={sel.fillHatchId ?? ""}
onChange={(v) => host.onSetSelectionFill(v || null)}
options={[
{ value: "", label: t("attr.none") },
...project.hatches.map((h) => ({ value: h.id, label: h.name })),
]}
/>,
)}
{/* ── Maße (informativ, aus der bbox) ───────────────────────── */}
<div className="attr-section-label">{t("attr.size")}</div>
<span className="attr-key">{t("attr.width")}</span>
<span className="attr-val attr-readonly">{formatM(bboxW)}</span>
<span className="attr-key">{t("attr.height")}</span>
<span className="attr-val attr-readonly">{formatM(bboxH)}</span>
</div>
{/* Element-spezifische Abschnitte (aus ObjectInfo hierher verschoben) —
+92 -79
View File
@@ -158,89 +158,102 @@ export function ObjectInfoPanel() {
<div className="objinfo-sep" />
{/* Bezugspunkt-Würfel (3×3, immer quadratisch) links, X/Y/Z als Spalte
rechts daneben (Nutzer-Wunsch). */}
<div className="objinfo-section-label">{t("objinfo.refpoint")}</div>
<div className="objinfo-refrow">
<div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}>
{FRACTIONS.map((fy) =>
FRACTIONS.map((fx) => {
const active = anchor.fx === fx && anchor.fy === fy;
return (
<button
key={anchorKey(fx, fy)}
type="button"
className={"objinfo-anchor" + (active ? " active" : "")}
title={t("objinfo.setRefpoint")}
aria-pressed={active}
onClick={() => setAnchor({ fx, fy })}
>
<span className="objinfo-dot" />
</button>
);
}),
)}
</div>
{/* Bezugspunkt/X-Y/Maße sind MODELL-Meter (Grundriss) — für eine Layout-
Annotation (Papier-Millimeter, eigenes Koordinatensystem) gibt es
keinen sinnvollen Bezugspunkt hier; Position/Grösse editiert man
direkt am Rahmen auf dem Blatt (Greifpunkte + das dortige HUD in
mm — `layout-sheet-hud` in LayoutSheet.tsx). Sonst zeigte dieses
Panel für sie fest 0/0 (Selection.bbox ist für sie ein Platzhalter,
s. App.tsx) — verwirrend statt nur „nicht anwendbar". */}
{sel.kind === "layoutAnnotation" ? (
<div className="objinfo-empty">{t("objinfo.layoutAnnotationHint")}</div>
) : (
<>
{/* Bezugspunkt-Würfel (3×3, immer quadratisch) links, X/Y/Z als Spalte
rechts daneben (Nutzer-Wunsch). */}
<div className="objinfo-section-label">{t("objinfo.refpoint")}</div>
<div className="objinfo-refrow">
<div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}>
{FRACTIONS.map((fy) =>
FRACTIONS.map((fx) => {
const active = anchor.fx === fx && anchor.fy === fy;
return (
<button
key={anchorKey(fx, fy)}
type="button"
className={"objinfo-anchor" + (active ? " active" : "")}
title={t("objinfo.setRefpoint")}
aria-pressed={active}
onClick={() => setAnchor({ fx, fy })}
>
<span className="objinfo-dot" />
</button>
);
}),
)}
</div>
{/* X / Y des gewählten Bezugspunkts (editierbar) + Z (read-only) + ein
Dreh-Feld, als Spalte. */}
<div className="objinfo-xyzcol">
<DimensionField
label={t("objinfo.x")}
value={px}
min={-Infinity}
onCommit={(nx) => host.onMoveSelectionBy(nx - px, 0)}
/>
<DimensionField
label={t("objinfo.y")}
value={py}
min={-Infinity}
onCommit={(ny) => host.onMoveSelectionBy(0, ny - py)}
/>
{/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element.
Daher „—" statt einer gefakten 0, mit kurzem Hinweis im Titel. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.z")}</span>
<span className="objinfo-fval objinfo-muted" title={t("objinfo.zHint")}>
—
</span>
{/* X / Y des gewählten Bezugspunkts (editierbar) + Z (read-only) + ein
Dreh-Feld, als Spalte. */}
<div className="objinfo-xyzcol">
<DimensionField
label={t("objinfo.x")}
value={px}
min={-Infinity}
onCommit={(nx) => host.onMoveSelectionBy(nx - px, 0)}
/>
<DimensionField
label={t("objinfo.y")}
value={py}
min={-Infinity}
onCommit={(ny) => host.onMoveSelectionBy(0, ny - py)}
/>
{/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element.
Daher „—" statt einer gefakten 0, mit kurzem Hinweis im Titel. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.z")}</span>
<span className="objinfo-fval objinfo-muted" title={t("objinfo.zHint")}>
—
</span>
</div>
{/* Drehen: Delta-Winkel (Grad) um den gewählten Bezugspunkt — kein
persistenter Zustand, das Feld zeigt nach dem Commit wieder „0". */}
<RotateField onCommit={(deg) => host.onRotateSelectionAround(px, py, deg)} />
</div>
</div>
{/* Drehen: Delta-Winkel (Grad) um den gewählten Bezugspunkt — kein
persistenter Zustand, das Feld zeigt nach dem Commit wieder „0". */}
<RotateField onCommit={(deg) => host.onRotateSelectionAround(px, py, deg)} />
</div>
</div>
<div className="objinfo-sep" />
<div className="objinfo-sep" />
{/* Maße (editierbar) — Commit auf blur/Enter skaliert um den Anker. */}
<div className="objinfo-section-label">{t("objinfo.dimensions")}</div>
<DimensionField
label={t("objinfo.width")}
value={width}
onCommit={(w) => commitSize(w, height)}
/>
<DimensionField
label={t("objinfo.height")}
value={height}
onCommit={(h) => commitSize(width, h)}
/>
{/* Linien-Länge bzw. Kreis-Radius (nur wenn die Selektion genau eine
Linie/ein Kreis ist — das Modell gibt das direkt her, andere Formen
bleiben ohne Zusatzfeld). */}
{drawingShape === "line" && (
<DimensionField
label={t("objinfo.drawing.length")}
value={Math.hypot(width, height)}
onCommit={(v) => host.onSetDrawingLineLength(v)}
/>
)}
{drawingShape === "circle" && (
<DimensionField
label={t("objinfo.drawing.radius")}
value={width / 2}
onCommit={(v) => host.onSetDrawingCircleRadius(v)}
/>
{/* Maße (editierbar) — Commit auf blur/Enter skaliert um den Anker. */}
<div className="objinfo-section-label">{t("objinfo.dimensions")}</div>
<DimensionField
label={t("objinfo.width")}
value={width}
onCommit={(w) => commitSize(w, height)}
/>
<DimensionField
label={t("objinfo.height")}
value={height}
onCommit={(h) => commitSize(width, h)}
/>
{/* Linien-Länge bzw. Kreis-Radius (nur wenn die Selektion genau eine
Linie/ein Kreis ist — das Modell gibt das direkt her, andere Formen
bleiben ohne Zusatzfeld). */}
{drawingShape === "line" && (
<DimensionField
label={t("objinfo.drawing.length")}
value={Math.hypot(width, height)}
onCommit={(v) => host.onSetDrawingLineLength(v)}
/>
)}
{drawingShape === "circle" && (
<DimensionField
label={t("objinfo.drawing.radius")}
value={width / 2}
onCommit={(v) => host.onSetDrawingCircleRadius(v)}
/>
)}
</>
)}
{/* Kontext-Objekt (importiertes Gebäude/Terrain/Konturen): reale LV95-
Koordinate des Bbox-Mittelpunkts — direkt sichtbar, ob/wohin
+10 -1
View File
@@ -156,6 +156,15 @@ export function ToolIcon({ id }: { id: ToolId }) {
<line x1="4.5" y1="11" x2="9" y2="11" />
</svg>
);
case "image":
// Bild-Rahmen mit Bergsilhouette + Sonne (klassisches Bild-Icon).
return (
<svg {...common}>
<rect x="2.5" y="3" width="11" height="10" fill="none" />
<circle cx="6" cy="6.5" r="1" fill="currentColor" stroke="none" />
<path d="M 3 12 L 7 8 L 9.5 10.5 L 11 9 L 13 12" fill="none" />
</svg>
);
default:
return null;
}
@@ -166,7 +175,7 @@ export function ToolIcon({ id }: { id: ToolId }) {
// und 3D und Bearbeiten"). Befehlsnamen spiegeln die Ribbon-Registry
// (ui/ribbon/ribbonItems.ts) — dort liegt die kanonische Liste; hier nur die
// Panel-Gruppierung (2D-Zeichnen · 3D/BIM-Bauteile · Bearbeiten-Aktionen).
const TOOLS_2D: ToolId[] = ["line", "polyline", "rect", "circle", "arc", "text", "textbox"];
const TOOLS_2D: ToolId[] = ["line", "polyline", "rect", "circle", "arc", "text", "textbox", "image"];
const TOOLS_3D: ToolId[] = ["wall", "ceiling", "window", "door", "stair", "room"];
const COMMANDS_3D = ["column", "ceilingopening", "roof", "extrude", "sectionline", "viewline"];
const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "trim", "join", "measure"];
+31 -1
View File
@@ -13,7 +13,7 @@
import { useContext } from "react";
import { PanelHostContext } from "./types";
import type { DisplayMode } from "./types";
import type { DisplayMode, PanelHost } from "./types";
import type {
AttributeSource,
CeilingType,
@@ -197,6 +197,11 @@ export interface PanelHostValue {
* `null` entfernt die Füllfarbe (Fläche wieder transparent).
*/
onSetSelectionFillColor: (color: string | null) => void;
/**
* Setzt die Deckkraft (0..1) — wirkt NUR auf Bild-Elemente (Drawing2D
* `{shape:"image"}` oder Layout-Annotation `kind:"image"`), sonst No-op.
*/
onSetSelectionOpacity: (opacity: number) => void;
/**
* Setzt/entfernt den Vordergrund-Override (Muster-/Schraffurfarbe, „eigener
* Wert") der Selektion — Wand, Decke oder geschlossene Drawing2D. `null` =
@@ -653,3 +658,28 @@ export function usePanelHost(): PanelHostValue {
}
return host as unknown as PanelHostValue;
}
/**
* Baut den fertigen {@link PanelHost}-Wert aus dem memoisierten `base` +
* live injizierten Feldern (Messwerte, aktiver Darstellungsmodus je Tab).
*/
export function hostWithMode(
base: Omit<PanelHostValue, "displayMode">,
activeTab: string | null,
modeFor: (id: string | null) => DisplayMode,
setModeFor: (id: string | null, mode: DisplayMode) => void,
measurement: MeasurementReadout | null = null,
): PanelHost {
const value: PanelHostValue = {
...base,
// Live-Messwerte NICHT aus dem memoisierten `base` (dort wären sie eingefroren,
// da `draft` bewusst kein Memo-Dep ist — sonst würde der ganze Host bei jeder
// Mausbewegung neu gebaut). Hier bei jedem Render frisch injiziert.
measurement,
displayMode: {
mode: modeFor(activeTab),
setMode: (mode) => setModeFor(activeTab, mode),
},
};
return value as unknown as PanelHost;
}
+14 -1
View File
@@ -163,6 +163,19 @@ describe("createAnnotation — Defaults je Art", () => {
const a = createAnnotation("a3", "text", { text: "EG", heightMm: 5 });
expect(a).toMatchObject({ text: "EG", heightMm: 5 });
});
it("text: init mit doc/widthMm (Rich-Text-Frame) wird übernommen", () => {
const doc = { version: 1 as const, paragraphs: [{ runs: [{ text: "Hi", marks: {} }] }] };
const a = createAnnotation("a3", "text", { doc, widthMm: 50 });
expect(a).toMatchObject({ doc, widthMm: 50 });
});
it("image: Default-Grösse, leere Quelle", () => {
const a = createAnnotation("a4", "image");
expect(a).toEqual({ id: "a4", kind: "image", xMm: 20, yMm: 20, widthMm: 60, heightMm: 40, src: "" });
});
it("image: init setzt Quelle + Grösse + Deckkraft", () => {
const a = createAnnotation("a4", "image", { src: "data:image/png;base64,x", widthMm: 30, heightMm: 20, opacity: 0.5 });
expect(a).toMatchObject({ src: "data:image/png;base64,x", widthMm: 30, heightMm: 20, opacity: 0.5 });
});
});
describe("Annotations-CRUD (immutabel)", () => {
@@ -199,7 +212,7 @@ describe("Annotations-CRUD (immutabel)", () => {
const l = addAnnotation(base, createAnnotation("a1", "line", { color: "#111" }));
const patched = patchAnnotation(l, "a1", { x1Mm: 42, color: "#0f0" });
expect(patched.annotations![0]).toMatchObject({ x1Mm: 42, color: "#0f0" });
expect(l.annotations![0].color).toBe("#111");
expect((l.annotations![0] as { color?: string }).color).toBe("#111");
});
it("patchAnnotation: unbekannte Id → No-op (gleiche Werte, neue Liste)", () => {
+14
View File
@@ -546,6 +546,18 @@ export function createAnnotation(
...(init.weightMm !== undefined ? { weightMm: init.weightMm } : {}),
};
}
if (kind === "image") {
return {
id,
kind: "image",
xMm: init.xMm ?? 20,
yMm: init.yMm ?? 20,
widthMm: init.widthMm ?? 60,
heightMm: init.heightMm ?? 40,
src: init.src ?? "",
...(init.opacity !== undefined ? { opacity: init.opacity } : {}),
};
}
return {
id,
kind: "text",
@@ -554,6 +566,8 @@ export function createAnnotation(
text: init.text ?? "",
heightMm: init.heightMm ?? 3.5,
...colorPatch,
...(init.doc !== undefined ? { doc: init.doc } : {}),
...(init.widthMm !== undefined ? { widthMm: init.widthMm } : {}),
};
}