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:
+51
-10
@@ -12,6 +12,7 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
const URL = process.env.PROBE_URL || "http://localhost:5187/";
|
const URL = process.env.PROBE_URL || "http://localhost:5187/";
|
||||||
const OUT = process.env.SCRATCH || "/tmp/pdf-probe";
|
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 });
|
if (!existsSync(OUT)) mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
@@ -69,10 +70,12 @@ const info = await page.evaluate(() => {
|
|||||||
});
|
});
|
||||||
console.log("Store:", JSON.stringify(info));
|
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 clickedBtn = await page.evaluate(() => {
|
||||||
const btns = Array.from(document.querySelectorAll(".tb-btn"));
|
const btns = Array.from(document.querySelectorAll(".tb-iconbtn"));
|
||||||
const b = btns.find((x) => x.textContent.trim() === "PDF");
|
const b = btns.find((x) => x.getAttribute("aria-label")?.trim() === "PDF");
|
||||||
if (b) {
|
if (b) {
|
||||||
b.click();
|
b.click();
|
||||||
return true;
|
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");
|
const pdfPath = join(OUT, captured.name.endsWith(".pdf") ? captured.name : captured.name + ".pdf");
|
||||||
writeFileSync(pdfPath, pdfBytes);
|
writeFileSync(pdfPath, pdfBytes);
|
||||||
|
|
||||||
// Visuelle Prüfung: das DRUCK-SVG (exakt die Vektor-Quelle des PDF) im Browser
|
// Visuelle Prüfung: das DRUCK-SVG (exakt die Vektor-Quelle des PDF — jetzt aus
|
||||||
// erzeugen und nach PNG rastern. Zeigt scharfe Linien, Schraffuren, weisses Blatt.
|
// der RENDER-SZENE, derselben Quelle wie der Viewport) im Browser erzeugen und
|
||||||
|
// nach PNG rastern. Zeigt scharfe Linien, Schraffuren, weisses Blatt.
|
||||||
try {
|
try {
|
||||||
const svgPng = await page.evaluate(async () => {
|
const result = await page.evaluate(async () => {
|
||||||
const gp = await import("/src/plan/generatePlan.ts");
|
const gp = await import("/src/plan/generatePlan.ts");
|
||||||
const sp = await import("/src/model/sampleProject.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 proj = sp.sampleProject;
|
||||||
const codes = new Set();
|
const codes = new Set();
|
||||||
const walk = (cs) =>
|
const walk = (cs) =>
|
||||||
@@ -136,12 +141,29 @@ try {
|
|||||||
});
|
});
|
||||||
walk(proj.layers);
|
walk(proj.layers);
|
||||||
const plan = gp.generatePlan(proj, "eg", codes, undefined, "mittel", false, false);
|
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,
|
scaleDenominator: 100,
|
||||||
pageWidthMm: 297,
|
pageWidthMm: 297,
|
||||||
pageHeightMm: 210,
|
pageHeightMm: 210,
|
||||||
marginMm: 10,
|
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 xml = new XMLSerializer().serializeToString(print.svg);
|
||||||
const blob = new Blob([xml], { type: "image/svg+xml" });
|
const blob = new Blob([xml], { type: "image/svg+xml" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -159,11 +181,30 @@ try {
|
|||||||
ctx.fillStyle = "#ffffff";
|
ctx.fillStyle = "#ffffff";
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.drawImage(img, 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"));
|
writeFileSync(join(OUT, "print-svg.png"), Buffer.from(b64, "base64"));
|
||||||
console.log("Druck-SVG → PNG:", join(OUT, "print-svg.png"));
|
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) {
|
} catch (e) {
|
||||||
console.log("(SVG-Render übersprungen:", e.message + ")");
|
console.log("(SVG-Render übersprungen:", e.message + ")");
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-10
@@ -1,16 +1,21 @@
|
|||||||
// Vektor-PDF-Export des Grundrisses. Baut aus einem Plan (generatePlan →
|
// Vektor-PDF-Export des Grundrisses. Baut aus einem Plan zunaechst DIESELBE
|
||||||
// Primitive) ein massstäbliches Druck-SVG (planToPrintSvg) und rendert dieses
|
// render2d-Szene wie der Viewport (`planToRenderScene`, siehe
|
||||||
// per jsPDF + svg2pdf.js als ECHTES Vektor-PDF (Pfad-/Text-Operatoren, KEINE
|
// `useWasmPlanRenderer.ts`/`nativeSync.ts` — identischer Aufruf, keine
|
||||||
// Rasterung). Papierformat (A4/A3) + Ausrichtung (Hoch/Quer) wählbar; der Plan
|
// Sonderoptionen), serialisiert sie massstaeblich als Papier-SVG
|
||||||
// wird zentriert aufs Blatt gesetzt, ein schlichtes Schriftfeld (Titel/Massstab)
|
// (`sceneToPrintSvg`) und rendert dieses per jsPDF + svg2pdf.js als ECHTES
|
||||||
// optional unten rechts.
|
// 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).
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
import { jsPDF } from "jspdf";
|
import { jsPDF } from "jspdf";
|
||||||
import "svg2pdf.js";
|
import "svg2pdf.js";
|
||||||
import type { Plan } from "../plan/generatePlan";
|
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). */
|
/** Unterstützte Papierformate (Blattmasse in mm, Hochformat-Basis). */
|
||||||
export type PaperFormat = "a4" | "a3";
|
export type PaperFormat = "a4" | "a3";
|
||||||
@@ -59,10 +64,14 @@ export async function buildPlanPdf(
|
|||||||
): Promise<jsPDF> {
|
): Promise<jsPDF> {
|
||||||
const { w: pageW, h: pageH } = pageSizeMm(opts.paper, opts.orientation);
|
const { w: pageW, h: pageH } = pageSizeMm(opts.paper, opts.orientation);
|
||||||
|
|
||||||
// Druck-SVG (mm-Koordinaten) aufbauen. Etwas mehr unterer Rand, falls ein
|
// Render-Szene EXAKT wie der Viewport erzeugen (keine Zusatz-Optionen —
|
||||||
// Schriftfeld gezeichnet wird (damit es nicht in den Plan ragt).
|
// `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 margin = 10;
|
||||||
const print = planToPrintSvg(plan, {
|
const scene = planToRenderScene(plan);
|
||||||
|
const print = sceneToPrintSvg(scene, {
|
||||||
scaleDenominator: opts.scaleDenominator,
|
scaleDenominator: opts.scaleDenominator,
|
||||||
pageWidthMm: pageW,
|
pageWidthMm: pageW,
|
||||||
pageHeightMm: pageH,
|
pageHeightMm: pageH,
|
||||||
|
|||||||
@@ -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,
|
// Druck-SVG aus einem Plan (generatePlan → Primitive). Eigenständiges,
|
||||||
// massstäbliches SVG für den VEKTOR-PDF-Export — NICHT vom Bildschirm-DOM
|
// massstäbliches SVG für den VEKTOR-PDF-Export — NICHT vom Bildschirm-DOM
|
||||||
// abgeleitet, sondern direkt aus den Plan-Primitiven (Polygon/Linie/Bogen).
|
// abgeleitet, sondern direkt aus den Plan-Primitiven (Polygon/Linie/Bogen).
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -91,6 +91,13 @@ export interface RText {
|
|||||||
color: RRgba;
|
color: RRgba;
|
||||||
align: RTextAlign;
|
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 {
|
export interface RScene {
|
||||||
fills: RFill[];
|
fills: RFill[];
|
||||||
outlines: ROutline[];
|
outlines: ROutline[];
|
||||||
@@ -98,6 +105,14 @@ export interface RScene {
|
|||||||
arcs: RArc[];
|
arcs: RArc[];
|
||||||
lines: RLine[];
|
lines: RLine[];
|
||||||
texts: RText[];
|
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";
|
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 } };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user