Vektor-PDF aus der RenderScene: eine Szene, zwei Targets

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.
This commit is contained in:
2026-07-03 08:46:35 +02:00
parent e8f5e7272d
commit 9371b65b3b
5 changed files with 427 additions and 21 deletions
+51 -10
View File
@@ -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
// <polyline> 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 + ")");
}
+19 -10
View File
@@ -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<jsPDF> {
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,
+7
View File
@@ -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).
+334
View File
@@ -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 <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, 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 <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);
}
+16 -1
View File
@@ -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 } };
}