// 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 -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 (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 -Element (Vektor-Text, kein Raster). `pos` ist * bereits die Baseline (siehe `RText`-Kommentar in `toRenderScene.ts`) — SVG- * `` 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); }