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 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
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
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 } };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user