Files
DOSSIER-STANDALONE/src/export/sceneToPrintSvg.ts
T
karim ec2568276d PDF/Print: Haarlinien unter der Stift-Leiter exakt drucken
quantizePen hob bisher jede Breite <= 0.13 mm auf die unterste Stift-Stufe
(0.13 mm) an. Dadurch druckte die 0.02-mm-Schraffur/Schichtfuge im PDF und
im SVG-Print-Modus als 0.13 mm - im Viewport duenn, im Export dick.
Breiten unter MIN_PEN_MM gelten jetzt als bewusste Haarlinien und werden
exakt (unquantelt) durchgereicht; ab 0.13 mm rundet die Leiter wie gehabt.
2026-07-03 20:15:10 +02:00

349 lines
14 KiB
TypeScript

// Druck-SVG aus der RENDER-SZENE (`src/plan/toRenderScene.ts`) — dieselbe Quelle,
// die der native/WASM-Viewport zeichnet (siehe `useWasmPlanRenderer.ts`,
// `nativeSync.ts`: beide rufen `planToRenderScene(plan)` unveraendert auf). Der
// Vektor-PDF-Export ist damit nur ein ANDERES ZIEL derselben Szene (Papier-SVG
// statt GPU-Framebuffer) — kein zweiter, eigenstaendig interpretierender
// Serializer mehr (das war `planToPrintSvg.ts`, jetzt Referenz/Altpfad, siehe
// dessen Kopfkommentar).
//
// Kern-Idee (unveraendert ggue. `planToPrintSvg.ts`, vgl. CONVENTIONS.md,
// docs/design/plans-output.md §3): Welt-Meter werden im gewaehlten Massstab 1:N
// nach Papier-Millimetern abgebildet (1 m = 1000/N mm). Das SVG-Benutzer-
// koordinatensystem ist „mm": die viewBox ist in mm, Strichstaerken sind echte
// physische mm — KEIN non-scaling-stroke.
//
// Reihenfolge (Maler-Algorithmus) EXAKT wie der native Renderer
// (`render2d::tessellate::compile_scene`): Fuellungen/Umrisse/offene
// Polylinien/freie Linien werden ZUSAMMEN nach ihrem `z`-Feld sortiert
// (stabil — Einfuegereihenfolge = `seq` in Rust), danach Boegen (eigene
// Pipeline, immer ÜBER der z-sortierten Geometrie), danach Texte (Text-Pass
// zuoberst, siehe `gpu.rs`). So zeichnet das PDF strukturell dieselbe Stapelung
// wie der Viewport.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type {
RArc,
RFill,
RLine,
ROutline,
RPoint,
RPolyline,
RRgba,
RScene,
RText,
} from "../plan/toRenderScene";
const SVG_NS = "http://www.w3.org/2000/svg";
/**
* Standard-Stift-Stufen (mm), identisch zu `planToPrintSvg.ts`. Beliebige
* mm-Strichstaerken werden auf die NAECHSTHOEHERE dieser ISO-nahen Stufen
* gequantelt.
*/
const PEN_STEPS = [0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0] as const;
/** Untere Stift-Stufe (mm) — ab hier greift die Quantelung auf die Leiter. */
const MIN_PEN_MM = 0.13;
/**
* Quantelt eine mm-Strichstaerke auf die naechste Stift-Stufe. Breiten UNTER
* `MIN_PEN_MM` gelten als bewusste HAARLINIEN (Schraffur/Schichtfugen, 0.02 mm)
* und werden EXAKT durchgereicht — sonst hoebe die Leiter sie im Druck auf
* 0.13 mm, und die Haarlinie waere im PDF sichtbar zu dick (== im Viewport
* duenn, im Export dick). Ab `MIN_PEN_MM` rundet die ISO-nahe Leiter wie gehabt
* auf die naechsthoehere Stufe.
*/
export function quantizePen(mm: number): number {
if (mm > 0 && mm < MIN_PEN_MM) return mm; // echte Haarlinie: unquantelt exakt drucken
for (const step of PEN_STEPS) if (mm <= step + 1e-6) return step;
return PEN_STEPS[PEN_STEPS.length - 1];
}
/**
* Papier-mm je Bildschirm-viewBox-Einheit fuer Schraffur-Strichbreiten
* (`RPolyline.widthScreen === true`) — die Umkehrung von `hatchPx = max(0.6,
* hatchMm * (1/HATCH_DENSITY_MM))` in `toRenderScene.ts`. Dieser Wert MUSS mit
* dem dortigen `1/0.13`-Faktor uebereinstimmen, sonst driftet die Schraffur-
* Dichte des PDFs von der des Viewports ab. Anders als der Rest der
* Strichbreiten-Logik ist dies bewusst NICHT von `scaleDenominator` (N)
* abhaengig: eine Schraffur-Strichbreite ist eine feste Papier-mm-Groesse,
* unabhaengig vom gewaehlten Export-Massstab (Audit
* `docs/design/engine-linewidth-audit.md`, Finding 4).
*/
const HATCH_DENSITY_MM = 0.13;
export interface PrintSceneOptions {
/** Massstab-Nenner N (1:N). 1 m = 1000/N mm auf dem Papier. */
scaleDenominator: number;
/** Blattbreite in mm (Papierformat, schon Hoch/Quer beruecksichtigt). */
pageWidthMm: number;
/** Blatthoehe in mm. */
pageHeightMm: number;
/** Rand um den Plan-Inhalt in mm (weisser Blattrand). */
marginMm?: number;
}
/** Ergebnis des Print-SVG-Aufbaus: das Element + Blattmasse (mm). */
export interface PrintScene {
/** Das fertige, in mm bemasste <svg>-Element (fuer svg2pdf). */
svg: SVGSVGElement;
/** Blattbreite in mm (= viewBox-Breite). */
widthMm: number;
/** Blatthoehe in mm (= viewBox-Hoehe). */
heightMm: number;
/** Massstaeblicher Inhalts-Rahmen (mm) innerhalb des Blattes (Plan-Bounds). */
content: { x: number; y: number; w: number; h: number };
}
/** Welt-Punkt (Modell-Meter) -> Papier-mm-Punkt, im gewaehlten Massstab zentriert. */
interface Mapper {
(p: RPoint): RPoint;
}
/**
* Erzeugt das Druck-SVG aus einer render2d-Szene. Reihenfolge/Optik siehe
* Kopfkommentar; die einzige weitere Eingabe ist der gewaehlte Papier-
* Massstab N + das Blattformat (der Viewport selbst kennt kein Blatt).
*/
export function sceneToPrintSvg(scene: RScene, opts: PrintSceneOptions): PrintScene {
const n = opts.scaleDenominator;
const k = 1000 / n; // mm je Meter auf dem Papier
// `marginMm` ist Teil des Vertrags (Aufrufer kann einen Rand wuenschen); da
// der Inhalt zentriert wird, liegt er ohnehin innerhalb des Blattes (siehe
// `planToPrintSvg.ts`, identisches Verhalten).
void (opts.marginMm ?? 10);
const b = scene.bounds;
const contentWmm = (b.maxX - b.minX) * k;
const contentHmm = (b.maxY - b.minY) * k;
const offsetXmm = (opts.pageWidthMm - contentWmm) / 2;
const offsetYmm = (opts.pageHeightMm - contentHmm) / 2;
// Plan-Y zeigt nach oben, SVG-Y nach unten -> Spiegelung (b.maxY - p[1]).
const map: Mapper = (p) => [
offsetXmm + (p[0] - b.minX) * k,
offsetYmm + (b.maxY - p[1]) * k,
];
const svg = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
svg.setAttribute("xmlns", SVG_NS);
// Reine Zahlen (= viewBox-Einheiten), KEINE „mm"-Einheit — siehe
// `planToPrintSvg.ts` fuer die Begruendung (svg2pdf-Skalierungsfalle).
svg.setAttribute("width", String(opts.pageWidthMm));
svg.setAttribute("height", String(opts.pageHeightMm));
svg.setAttribute("viewBox", `0 0 ${opts.pageWidthMm} ${opts.pageHeightMm}`);
const bg = document.createElementNS(SVG_NS, "rect");
bg.setAttribute("x", "0");
bg.setAttribute("y", "0");
bg.setAttribute("width", String(opts.pageWidthMm));
bg.setAttribute("height", String(opts.pageHeightMm));
bg.setAttribute("fill", "#ffffff");
svg.appendChild(bg);
// ── Maler-Reihenfolge: Fuellungen/Umrisse/Polylinien/Linien gemeinsam nach
// `z` sortieren (stabiler Sort => Einfuegereihenfolge bricht Gleichstaende
// genau wie `seq` in `compile_scene`).
type Item =
| { z: number; kind: "fill"; v: RFill }
| { z: number; kind: "outline"; v: ROutline }
| { z: number; kind: "polyline"; v: RPolyline }
| { z: number; kind: "line"; v: RLine };
const items: Item[] = [
...scene.fills.map((v): Item => ({ z: v.z, kind: "fill", v })),
...scene.outlines.map((v): Item => ({ z: v.z, kind: "outline", v })),
...scene.polylines.map((v): Item => ({ z: v.z, kind: "polyline", v })),
...scene.lines.map((v): Item => ({ z: v.z, kind: "line", v })),
];
items.sort((a, c) => a.z - c.z);
for (const it of items) {
switch (it.kind) {
case "fill":
appendFill(svg, it.v, map);
break;
case "outline":
appendStroke(svg, it.v.pts, true, it.v.color, it.v.widthMm, it.v.dash, map, false);
break;
case "polyline":
appendStroke(
svg,
it.v.pts,
false,
it.v.color,
it.v.widthMm,
it.v.dash,
map,
it.v.widthScreen ?? false,
);
break;
case "line":
appendLine(svg, it.v, map, k);
break;
}
}
// Boegen: NACH der z-sortierten Geometrie (wie `compile_scene`: eigene
// Pipeline, immer ueber Fuellung/Umriss/Linie — Tuerschwenk liegt oben).
for (const a of scene.arcs) appendArc(svg, a, map, k);
// Texte: zuoberst (wie `gpu.rs`: Text-Pass als letztes im selben Frame).
for (const t of scene.texts) appendText(svg, t, map);
return {
svg,
widthMm: opts.pageWidthMm,
heightMm: opts.pageHeightMm,
content: { x: offsetXmm, y: offsetYmm, w: contentWmm, h: contentHmm },
};
}
/** [r,g,b,a] (0..1) -> "rgb(r,g,b)" (0..255, gerundet). */
function rgbCss(c: RRgba): string {
const to255 = (v: number) => Math.round(Math.max(0, Math.min(1, v)) * 255);
return `rgb(${to255(c[0])},${to255(c[1])},${to255(c[2])})`;
}
/** Setzt `attr`-opacity, falls Alpha spuerbar < 1 ist (vermeidet Rauschen). */
function setOpacityAttr(el: SVGElement, attr: string, alpha: number): void {
if (alpha < 0.999) el.setAttribute(attr, String(round(alpha)));
}
/** Auf 3 Nachkommastellen runden (kompaktes, exaktes SVG). */
function round(v: number): number {
return Math.round(v * 1000) / 1000;
}
function ptsAttrOf(pts: RPoint[], map: Mapper): string {
return pts.map((p) => map(p)).map(([x, y]) => `${round(x)},${round(y)}`).join(" ");
}
/** Gefuelltes Polygon (Wand-Poche, Raeume, Decken, Grundfuellung vor Schraffur). */
function appendFill(svg: SVGSVGElement, f: RFill, map: Mapper): void {
if (f.pts.length < 3) return;
const el = document.createElementNS(SVG_NS, "polygon");
el.setAttribute("points", ptsAttrOf(f.pts, map));
el.setAttribute("fill", rgbCss(f.color));
setOpacityAttr(el, "fill-opacity", f.color[3]);
el.setAttribute("stroke", "none");
svg.appendChild(el);
}
/**
* Umriss (geschlossener Ring) oder offene Polylinie (2D-Zeichenzug,
* Schraffur-Musterlinie). `widthScreen`: `widthMm` traegt Bildschirm-viewBox-
* Einheiten (Schraffur, siehe `HATCH_DENSITY_MM`-Kommentar oben) statt
* Papier-mm.
*/
function appendStroke(
svg: SVGSVGElement,
pts: RPoint[],
closed: boolean,
color: RRgba,
widthMm: number,
dash: number[] | null | undefined,
map: Mapper,
widthScreen: boolean,
): void {
if (pts.length < 2 || widthMm <= 0) return;
// Schraffur-Strichbreite ist eine feste Papier-mm-Groesse (Stift), NICHT vom
// gewaehlten Export-Massstab N abhaengig — sonst wuerde dieselbe Quell-
// `lineWeight` je nach `scaleDenominator` unterschiedlich dick gedruckt
// (Audit `docs/design/engine-linewidth-audit.md`, Finding 4). `widthMm` ist
// hier in Bildschirm-viewBox-Einheiten kodiert, exakt `hatchPx = max(0.6,
// hatchMm * (1/HATCH_DENSITY_MM))` aus `toRenderScene.ts` — die Umkehrung
// `* HATCH_DENSITY_MM` liefert wieder die Papier-mm-Quell-`lineWeight`,
// unabhaengig von N.
const effectiveMm = widthScreen ? widthMm * HATCH_DENSITY_MM : widthMm;
const strokeMm = quantizePen(effectiveMm);
const el = document.createElementNS(SVG_NS, closed ? "polygon" : "polyline");
el.setAttribute("points", ptsAttrOf(pts, map));
el.setAttribute("fill", "none");
el.setAttribute("stroke", rgbCss(color));
setOpacityAttr(el, "stroke-opacity", color[3]);
el.setAttribute("stroke-width", String(strokeMm));
el.setAttribute("stroke-linejoin", "round");
el.setAttribute("stroke-linecap", "round");
if (dash && dash.length) {
// Strichmuster ist bereits in Papier-mm definiert (siehe `toRenderScene.ts`:
// „unveraendert durchgereicht"), also direkt uebernehmen.
el.setAttribute("stroke-dasharray", dash.map((d) => round(d)).join(" "));
}
svg.appendChild(el);
}
/** Freies Liniensegment (Tuerblatt, Referenzlinie, Symbol). */
function appendLine(svg: SVGSVGElement, l: RLine, map: Mapper, _mmPerM: number): void {
if (l.widthMm <= 0) return;
const [ax, ay] = map(l.a);
const [bx, by] = map(l.b);
const el = document.createElementNS(SVG_NS, "line");
el.setAttribute("x1", String(round(ax)));
el.setAttribute("y1", String(round(ay)));
el.setAttribute("x2", String(round(bx)));
el.setAttribute("y2", String(round(by)));
el.setAttribute("stroke", rgbCss(l.color));
setOpacityAttr(el, "stroke-opacity", l.color[3]);
el.setAttribute("stroke-width", String(quantizePen(l.widthMm)));
el.setAttribute("stroke-linecap", "round");
if (l.dash && l.dash.length) {
el.setAttribute("stroke-dasharray", l.dash.map((d) => round(d)).join(" "));
}
svg.appendChild(el);
}
/**
* Kreisbogen als <path> (A-Kommando). Radius in Welt-Metern -> Papier-mm;
* Drehrichtung aus dem Kreuzprodukt im Papier-Raum (Y nach unten).
*/
function appendArc(svg: SVGSVGElement, a: RArc, map: Mapper, mmPerM: number): void {
const [cx, cy] = map(a.center);
const [fx, fy] = map(a.from);
const [tx, ty] = map(a.to);
const r = a.r * mmPerM;
const v1 = { x: fx - cx, y: fy - cy };
const v2 = { x: tx - cx, y: ty - cy };
const cross = v1.x * v2.y - v1.y * v2.x;
const sweep = cross > 0 ? 1 : 0;
const d = `M ${round(fx)} ${round(fy)} A ${round(r)} ${round(r)} 0 0 ${sweep} ${round(tx)} ${round(ty)}`;
const el = document.createElementNS(SVG_NS, "path");
el.setAttribute("d", d);
el.setAttribute("fill", "none");
el.setAttribute("stroke", rgbCss(a.color));
setOpacityAttr(el, "stroke-opacity", a.color[3]);
el.setAttribute("stroke-width", String(quantizePen(a.widthMm)));
if (a.dash && a.dash.length) {
el.setAttribute("stroke-dasharray", a.dash.map((x) => round(x)).join(" "));
}
svg.appendChild(el);
}
/** SVG-Schriftfamilie fuer Plan-Beschriftung (Vektor-Text im PDF). */
const TEXT_FONT_FAMILY = "Helvetica, Arial, sans-serif";
/**
* Textzeile als echtes <text>-Element (Vektor-Text, kein Raster). `pos` ist
* bereits die Baseline (siehe `RText`-Kommentar in `toRenderScene.ts`) — SVG-
* `<text>` verankert `y` ebenfalls an der Baseline, daher direkte Uebernahme.
* `sizeMm` ist eine absolute Papier-mm-Groesse (wie `widthMm` bei Strichen)
* und wird NICHT nochmals mit dem Massstab reskaliert.
*/
function appendText(svg: SVGSVGElement, t: RText, map: Mapper): void {
if (!t.content) return;
const [x, y] = map(t.pos);
const el = document.createElementNS(SVG_NS, "text");
el.setAttribute("x", String(round(x)));
el.setAttribute("y", String(round(y)));
el.setAttribute("font-family", TEXT_FONT_FAMILY);
el.setAttribute("font-size", String(round(t.sizeMm)));
el.setAttribute(
"text-anchor",
t.align === "left" ? "start" : t.align === "right" ? "end" : "middle",
);
el.setAttribute("fill", rgbCss(t.color));
setOpacityAttr(el, "fill-opacity", t.color[3]);
el.textContent = t.content;
svg.appendChild(el);
}