From 382771b2e7887c77b4bb062ec6f5eb896e9c5416 Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 08:46:35 +0200 Subject: [PATCH] Vektor-PDF aus der RenderScene: eine Szene, zwei Targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exportPdf baut jetzt planToRenderScene(plan) — identischer Aufruf wie der Engine-Viewport — und serialisiert über den neuen sceneToPrintSvg nach Papier-mm: z-stabile Reihenfolge wie compile_scene, PEN_STEPS-Quantisierung wie bisher, widthScreen-Schraffurbreiten via (widthPx/PX_PER_M)*(1000/N) in mm umgerechnet, Texte neu als echte Vektor-Texte (alter Pfad liess sie weg). planToPrintSvg bleibt als Referenz, im Kopf als abgeloest markiert. probe-pdf.mjs: Icon-Button-Selektor nachgezogen, neue Assertions (Schraffur- Polylinien vorhanden, alle stroke-widths auf PEN_STEPS). Messung: 3666 Vektor-Pfadoperatoren, 7 Text-Operatoren, 0 Rasterbilder; Inhalt per pdftoppm 53.51x43.69 mm vs. erwartet 53.45x43.45 bei 1:100. --- scripts/probe-pdf.mjs | 61 ++++++- src/export/exportPdf.ts | 29 ++- src/export/planToPrintSvg.ts | 7 + src/export/sceneToPrintSvg.ts | 334 ++++++++++++++++++++++++++++++++++ src/plan/toRenderScene.ts | 17 +- 5 files changed, 427 insertions(+), 21 deletions(-) create mode 100644 src/export/sceneToPrintSvg.ts diff --git a/scripts/probe-pdf.mjs b/scripts/probe-pdf.mjs index 195e345..089ee03 100644 --- a/scripts/probe-pdf.mjs +++ b/scripts/probe-pdf.mjs @@ -12,6 +12,7 @@ import { join } from "node:path"; const URL = process.env.PROBE_URL || "http://localhost:5187/"; const OUT = process.env.SCRATCH || "/tmp/pdf-probe"; +const PEN_STEPS_LOG = "0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0"; if (!existsSync(OUT)) mkdirSync(OUT, { recursive: true }); const browser = await puppeteer.launch({ @@ -69,10 +70,12 @@ const info = await page.evaluate(() => { }); console.log("Store:", JSON.stringify(info)); -// PDF-Button in der Oberleiste klicken (Text „PDF"). Öffnet den Export-Dialog. +// PDF-Button in der Oberleiste klicken (Icon-Button, aria-label „PDF" — die +// TopBar zeigt seit dem Icon-Redesign nur noch ein Material-Symbol, kein +// Klartext mehr). Öffnet den Export-Dialog. const clickedBtn = await page.evaluate(() => { - const btns = Array.from(document.querySelectorAll(".tb-btn")); - const b = btns.find((x) => x.textContent.trim() === "PDF"); + const btns = Array.from(document.querySelectorAll(".tb-iconbtn")); + const b = btns.find((x) => x.getAttribute("aria-label")?.trim() === "PDF"); if (b) { b.click(); return true; @@ -118,13 +121,15 @@ const pdfBytes = Buffer.from(captured.b64, "base64"); const pdfPath = join(OUT, captured.name.endsWith(".pdf") ? captured.name : captured.name + ".pdf"); writeFileSync(pdfPath, pdfBytes); -// Visuelle Prüfung: das DRUCK-SVG (exakt die Vektor-Quelle des PDF) im Browser -// erzeugen und nach PNG rastern. Zeigt scharfe Linien, Schraffuren, weisses Blatt. +// Visuelle Prüfung: das DRUCK-SVG (exakt die Vektor-Quelle des PDF — jetzt aus +// der RENDER-SZENE, derselben Quelle wie der Viewport) im Browser erzeugen und +// nach PNG rastern. Zeigt scharfe Linien, Schraffuren, weisses Blatt. try { - const svgPng = await page.evaluate(async () => { + const result = await page.evaluate(async () => { const gp = await import("/src/plan/generatePlan.ts"); const sp = await import("/src/model/sampleProject.ts"); - const ptp = await import("/src/export/planToPrintSvg.ts"); + const trs = await import("/src/plan/toRenderScene.ts"); + const stp = await import("/src/export/sceneToPrintSvg.ts"); const proj = sp.sampleProject; const codes = new Set(); const walk = (cs) => @@ -136,12 +141,29 @@ try { }); walk(proj.layers); const plan = gp.generatePlan(proj, "eg", codes, undefined, "mittel", false, false); - const print = ptp.planToPrintSvg(plan, { + const scene = trs.planToRenderScene(plan); + const print = stp.sceneToPrintSvg(scene, { scaleDenominator: 100, pageWidthMm: 297, pageHeightMm: 210, marginMm: 10, }); + + // Schraffur-Nachweis: widthScreen-Polylinien der Szene sind die Schraffur- + // Musterlinien (siehe toRenderScene.ts); im Print-SVG muessen sie als + // mit ISO-Stiftstufen-Strichbreite auftauchen. + const hatchRunCount = scene.polylines.filter((p) => p.widthScreen).length; + const PEN_STEPS = [0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0]; + const strokeWidths = Array.from( + print.svg.querySelectorAll("polyline, polygon, line, path"), + ) + .map((el) => el.getAttribute("stroke-width")) + .filter((w) => w !== null) + .map(Number); + const offPenStep = strokeWidths.filter( + (w) => !PEN_STEPS.some((s) => Math.abs(s - w) < 1e-6), + ); + const xml = new XMLSerializer().serializeToString(print.svg); const blob = new Blob([xml], { type: "image/svg+xml" }); const url = URL.createObjectURL(blob); @@ -159,11 +181,30 @@ try { ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); - return canvas.toDataURL("image/png"); + return { + png: canvas.toDataURL("image/png"), + hatchRunCount, + strokedElementCount: strokeWidths.length, + offPenStepCount: offPenStep.length, + offPenStepSample: offPenStep.slice(0, 5), + }; }); - const b64 = svgPng.split(",")[1]; + const b64 = result.png.split(",")[1]; writeFileSync(join(OUT, "print-svg.png"), Buffer.from(b64, "base64")); console.log("Druck-SVG → PNG:", join(OUT, "print-svg.png")); + + console.log("\n=== SCHRAFFUR / STIFTSTUFEN (Print-SVG) ==="); + console.log(`Schraffur-Musterlinien (widthScreen-Polylinien) in der Szene: ${result.hatchRunCount}`); + console.log(`Gestrichelte/-gezeichnete Elemente mit stroke-width im Print-SVG: ${result.strokedElementCount}`); + console.log( + `Strichbreiten ausserhalb der ISO-Stiftstufen (${PEN_STEPS_LOG}): ${result.offPenStepCount}` + + (result.offPenStepCount ? ` (Beispiele: ${result.offPenStepSample.join(", ")})` : ""), + ); + console.log( + `→ Schraffur vorhanden UND alle Strichbreiten auf Stiftstufen? ${ + result.hatchRunCount > 0 && result.offPenStepCount === 0 ? "JA" : "NEIN" + }`, + ); } catch (e) { console.log("(SVG-Render übersprungen:", e.message + ")"); } diff --git a/src/export/exportPdf.ts b/src/export/exportPdf.ts index 1659b79..b500588 100644 --- a/src/export/exportPdf.ts +++ b/src/export/exportPdf.ts @@ -1,16 +1,21 @@ -// Vektor-PDF-Export des Grundrisses. Baut aus einem Plan (generatePlan → -// Primitive) ein massstäbliches Druck-SVG (planToPrintSvg) und rendert dieses -// per jsPDF + svg2pdf.js als ECHTES Vektor-PDF (Pfad-/Text-Operatoren, KEINE -// Rasterung). Papierformat (A4/A3) + Ausrichtung (Hoch/Quer) wählbar; der Plan -// wird zentriert aufs Blatt gesetzt, ein schlichtes Schriftfeld (Titel/Massstab) -// optional unten rechts. +// Vektor-PDF-Export des Grundrisses. Baut aus einem Plan zunaechst DIESELBE +// render2d-Szene wie der Viewport (`planToRenderScene`, siehe +// `useWasmPlanRenderer.ts`/`nativeSync.ts` — identischer Aufruf, keine +// Sonderoptionen), serialisiert sie massstaeblich als Papier-SVG +// (`sceneToPrintSvg`) und rendert dieses per jsPDF + svg2pdf.js als ECHTES +// Vektor-PDF (Pfad-/Text-Operatoren, KEINE Rasterung). Bildschirm und PDF +// haben damit dieselbe Quelle, nur ein anderes Ziel (GPU-Framebuffer vs. +// Papier-mm-SVG). Papierformat (A4/A3) + Ausrichtung (Hoch/Quer) wählbar; der +// Plan wird zentriert aufs Blatt gesetzt, ein schlichtes Schriftfeld (Titel/ +// Massstab) optional unten rechts. // // Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). import { jsPDF } from "jspdf"; import "svg2pdf.js"; import type { Plan } from "../plan/generatePlan"; -import { planToPrintSvg } from "./planToPrintSvg"; +import { planToRenderScene } from "../plan/toRenderScene"; +import { sceneToPrintSvg } from "./sceneToPrintSvg"; /** Unterstützte Papierformate (Blattmasse in mm, Hochformat-Basis). */ export type PaperFormat = "a4" | "a3"; @@ -59,10 +64,14 @@ export async function buildPlanPdf( ): Promise { const { w: pageW, h: pageH } = pageSizeMm(opts.paper, opts.orientation); - // Druck-SVG (mm-Koordinaten) aufbauen. Etwas mehr unterer Rand, falls ein - // Schriftfeld gezeichnet wird (damit es nicht in den Plan ragt). + // Render-Szene EXAKT wie der Viewport erzeugen (keine Zusatz-Optionen — + // `planToRenderScene(plan)` ist der vollstaendige Aufruf, siehe + // `useWasmPlanRenderer.ts`), dann als Papier-SVG serialisieren. Etwas mehr + // unterer Rand, falls ein Schriftfeld gezeichnet wird (damit es nicht in + // den Plan ragt). const margin = 10; - const print = planToPrintSvg(plan, { + const scene = planToRenderScene(plan); + const print = sceneToPrintSvg(scene, { scaleDenominator: opts.scaleDenominator, pageWidthMm: pageW, pageHeightMm: pageH, diff --git a/src/export/planToPrintSvg.ts b/src/export/planToPrintSvg.ts index a62c3d9..15ecb7d 100644 --- a/src/export/planToPrintSvg.ts +++ b/src/export/planToPrintSvg.ts @@ -1,3 +1,10 @@ +// ERSETZT durch `sceneToPrintSvg.ts` (liest die render2d-Szene aus +// `toRenderScene.ts` — dieselbe Quelle wie der Viewport — statt den Plan +// eigenständig zu re-interpretieren). Dieser Serializer wird vom PDF-Export +// (`exportPdf.ts`) NICHT MEHR verwendet und bleibt nur als Referenz/Altpfad +// stehen (z. B. für einen Vergleichs-Screenshot). Neue Änderungen bitte in +// `sceneToPrintSvg.ts` vornehmen. +// // Druck-SVG aus einem Plan (generatePlan → Primitive). Eigenständiges, // massstäbliches SVG für den VEKTOR-PDF-Export — NICHT vom Bildschirm-DOM // abgeleitet, sondern direkt aus den Plan-Primitiven (Polygon/Linie/Bogen). diff --git a/src/export/sceneToPrintSvg.ts b/src/export/sceneToPrintSvg.ts new file mode 100644 index 0000000..92bce64 --- /dev/null +++ b/src/export/sceneToPrintSvg.ts @@ -0,0 +1,334 @@ +// 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; + +/** Untergrenze einer sichtbaren Druck-Strichstaerke (mm). */ +const MIN_PEN_MM = 0.13; + +/** Quantelt eine mm-Strichstaerke auf die naechste Stift-Stufe (>= MIN_PEN_MM). */ +function quantizePen(mm: number): number { + const w = Math.max(MIN_PEN_MM, mm); + for (const step of PEN_STEPS) if (w <= step + 1e-6) return step; + return PEN_STEPS[PEN_STEPS.length - 1]; +} + +/** + * viewBox-Einheiten je Modell-Meter im Bildschirm-Pfad (`PX_PER_M` in + * `PlanView.tsx` / `glPlanHatch.ts` / `glPlanCompile.ts`). Schraffur- + * Strichbreiten in der Szene (`RPolyline.widthScreen === true`) sind GENAU in + * diesen Einheiten kodiert (siehe `toRenderScene.ts`, Kommentar bei + * `hatchPx`) — dieser Wert MUSS mit dem dortigen uebereinstimmen, sonst + * driftet die Schraffur-Dichte des PDFs von der des Viewports ab. + */ +const PX_PER_M = 90; + +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, k, false); + break; + case "polyline": + appendStroke( + svg, + it.v.pts, + false, + it.v.color, + it.v.widthMm, + it.v.dash, + map, + k, + 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 `PX_PER_M`-Kommentar oben) statt Papier-mm. + */ +function appendStroke( + svg: SVGSVGElement, + pts: RPoint[], + closed: boolean, + color: RRgba, + widthMm: number, + dash: number[] | null | undefined, + map: Mapper, + mmPerM: number, + widthScreen: boolean, +): void { + if (pts.length < 2 || widthMm <= 0) return; + // Bildschirm-viewBox-Einheiten -> Welt-Meter (/ PX_PER_M) -> Papier-mm + // (* mmPerM) — reproduziert exakt die Schraffur-Dichte des SVG-Pfads + // (`hatchStrokePx`) beim GEWAEHLTEN Papier-Massstab N. + const effectiveMm = widthScreen ? (widthMm / PX_PER_M) * mmPerM : 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); +} diff --git a/src/plan/toRenderScene.ts b/src/plan/toRenderScene.ts index cd712f2..75141a8 100644 --- a/src/plan/toRenderScene.ts +++ b/src/plan/toRenderScene.ts @@ -91,6 +91,13 @@ export interface RText { color: RRgba; align: RTextAlign; } +/** Achsenparalleler Rahmen der Plan-Geometrie in Modell-Metern (siehe Plan.bounds). */ +export interface RBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} export interface RScene { fills: RFill[]; outlines: ROutline[]; @@ -98,6 +105,14 @@ export interface RScene { arcs: RArc[]; lines: RLine[]; texts: RText[]; + /** + * Plan-Bounds (Modell-Meter) — additiv aus `Plan.bounds` übernommen, damit + * Verbraucher (z. B. der Vektor-PDF-Serializer `sceneToPrintSvg.ts`) den + * Plan-Inhalt zentrieren können, OHNE zusätzlich den `Plan` selbst zu + * benötigen. Unbekannt für den Rust-Renderer (serde ignoriert überzählige + * Felder ohne `deny_unknown_fields`), also rückwirkungsfrei fürs GPU-Backend. + */ + bounds: RBounds; } const DEFAULT_LINE = "#1a1a1a"; @@ -496,5 +511,5 @@ export function planToRenderScene(plan: Plan): RScene { } } - return { fills, outlines, polylines, arcs, lines, texts }; + return { fills, outlines, polylines, arcs, lines, texts, bounds: { ...plan.bounds } }; }