ec2568276d
quantizePen hob bisher jede Breite <= 0.13 mm auf die unterste Stift-Stufe (0.13 mm) an. Dadurch druckte die 0.02-mm-Schraffur/Schichtfuge im PDF und im SVG-Print-Modus als 0.13 mm - im Viewport duenn, im Export dick. Breiten unter MIN_PEN_MM gelten jetzt als bewusste Haarlinien und werden exakt (unquantelt) durchgereicht; ab 0.13 mm rundet die Leiter wie gehabt.
349 lines
14 KiB
TypeScript
349 lines
14 KiB
TypeScript
// Druck-SVG aus der RENDER-SZENE (`src/plan/toRenderScene.ts`) — dieselbe Quelle,
|
|
// die der native/WASM-Viewport zeichnet (siehe `useWasmPlanRenderer.ts`,
|
|
// `nativeSync.ts`: beide rufen `planToRenderScene(plan)` unveraendert auf). Der
|
|
// Vektor-PDF-Export ist damit nur ein ANDERES ZIEL derselben Szene (Papier-SVG
|
|
// statt GPU-Framebuffer) — kein zweiter, eigenstaendig interpretierender
|
|
// Serializer mehr (das war `planToPrintSvg.ts`, jetzt Referenz/Altpfad, siehe
|
|
// dessen Kopfkommentar).
|
|
//
|
|
// Kern-Idee (unveraendert ggue. `planToPrintSvg.ts`, vgl. CONVENTIONS.md,
|
|
// docs/design/plans-output.md §3): Welt-Meter werden im gewaehlten Massstab 1:N
|
|
// nach Papier-Millimetern abgebildet (1 m = 1000/N mm). Das SVG-Benutzer-
|
|
// koordinatensystem ist „mm": die viewBox ist in mm, Strichstaerken sind echte
|
|
// physische mm — KEIN non-scaling-stroke.
|
|
//
|
|
// Reihenfolge (Maler-Algorithmus) EXAKT wie der native Renderer
|
|
// (`render2d::tessellate::compile_scene`): Fuellungen/Umrisse/offene
|
|
// Polylinien/freie Linien werden ZUSAMMEN nach ihrem `z`-Feld sortiert
|
|
// (stabil — Einfuegereihenfolge = `seq` in Rust), danach Boegen (eigene
|
|
// Pipeline, immer ÜBER der z-sortierten Geometrie), danach Texte (Text-Pass
|
|
// zuoberst, siehe `gpu.rs`). So zeichnet das PDF strukturell dieselbe Stapelung
|
|
// wie der Viewport.
|
|
//
|
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
|
|
|
import type {
|
|
RArc,
|
|
RFill,
|
|
RLine,
|
|
ROutline,
|
|
RPoint,
|
|
RPolyline,
|
|
RRgba,
|
|
RScene,
|
|
RText,
|
|
} from "../plan/toRenderScene";
|
|
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
/**
|
|
* Standard-Stift-Stufen (mm), identisch zu `planToPrintSvg.ts`. Beliebige
|
|
* mm-Strichstaerken werden auf die NAECHSTHOEHERE dieser ISO-nahen Stufen
|
|
* gequantelt.
|
|
*/
|
|
const PEN_STEPS = [0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0] as const;
|
|
|
|
/** Untere Stift-Stufe (mm) — ab hier greift die Quantelung auf die Leiter. */
|
|
const MIN_PEN_MM = 0.13;
|
|
|
|
/**
|
|
* Quantelt eine mm-Strichstaerke auf die naechste Stift-Stufe. Breiten UNTER
|
|
* `MIN_PEN_MM` gelten als bewusste HAARLINIEN (Schraffur/Schichtfugen, 0.02 mm)
|
|
* und werden EXAKT durchgereicht — sonst hoebe die Leiter sie im Druck auf
|
|
* 0.13 mm, und die Haarlinie waere im PDF sichtbar zu dick (== im Viewport
|
|
* duenn, im Export dick). Ab `MIN_PEN_MM` rundet die ISO-nahe Leiter wie gehabt
|
|
* auf die naechsthoehere Stufe.
|
|
*/
|
|
export function quantizePen(mm: number): number {
|
|
if (mm > 0 && mm < MIN_PEN_MM) return mm; // echte Haarlinie: unquantelt exakt drucken
|
|
for (const step of PEN_STEPS) if (mm <= step + 1e-6) return step;
|
|
return PEN_STEPS[PEN_STEPS.length - 1];
|
|
}
|
|
|
|
/**
|
|
* Papier-mm je Bildschirm-viewBox-Einheit fuer Schraffur-Strichbreiten
|
|
* (`RPolyline.widthScreen === true`) — die Umkehrung von `hatchPx = max(0.6,
|
|
* hatchMm * (1/HATCH_DENSITY_MM))` in `toRenderScene.ts`. Dieser Wert MUSS mit
|
|
* dem dortigen `1/0.13`-Faktor uebereinstimmen, sonst driftet die Schraffur-
|
|
* Dichte des PDFs von der des Viewports ab. Anders als der Rest der
|
|
* Strichbreiten-Logik ist dies bewusst NICHT von `scaleDenominator` (N)
|
|
* abhaengig: eine Schraffur-Strichbreite ist eine feste Papier-mm-Groesse,
|
|
* unabhaengig vom gewaehlten Export-Massstab (Audit
|
|
* `docs/design/engine-linewidth-audit.md`, Finding 4).
|
|
*/
|
|
const HATCH_DENSITY_MM = 0.13;
|
|
|
|
export interface PrintSceneOptions {
|
|
/** Massstab-Nenner N (1:N). 1 m = 1000/N mm auf dem Papier. */
|
|
scaleDenominator: number;
|
|
/** Blattbreite in mm (Papierformat, schon Hoch/Quer beruecksichtigt). */
|
|
pageWidthMm: number;
|
|
/** Blatthoehe in mm. */
|
|
pageHeightMm: number;
|
|
/** Rand um den Plan-Inhalt in mm (weisser Blattrand). */
|
|
marginMm?: number;
|
|
}
|
|
|
|
/** Ergebnis des Print-SVG-Aufbaus: das Element + Blattmasse (mm). */
|
|
export interface PrintScene {
|
|
/** Das fertige, in mm bemasste <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, false);
|
|
break;
|
|
case "polyline":
|
|
appendStroke(
|
|
svg,
|
|
it.v.pts,
|
|
false,
|
|
it.v.color,
|
|
it.v.widthMm,
|
|
it.v.dash,
|
|
map,
|
|
it.v.widthScreen ?? false,
|
|
);
|
|
break;
|
|
case "line":
|
|
appendLine(svg, it.v, map, k);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Boegen: NACH der z-sortierten Geometrie (wie `compile_scene`: eigene
|
|
// Pipeline, immer ueber Fuellung/Umriss/Linie — Tuerschwenk liegt oben).
|
|
for (const a of scene.arcs) appendArc(svg, a, map, k);
|
|
// Texte: zuoberst (wie `gpu.rs`: Text-Pass als letztes im selben Frame).
|
|
for (const t of scene.texts) appendText(svg, t, map);
|
|
|
|
return {
|
|
svg,
|
|
widthMm: opts.pageWidthMm,
|
|
heightMm: opts.pageHeightMm,
|
|
content: { x: offsetXmm, y: offsetYmm, w: contentWmm, h: contentHmm },
|
|
};
|
|
}
|
|
|
|
/** [r,g,b,a] (0..1) -> "rgb(r,g,b)" (0..255, gerundet). */
|
|
function rgbCss(c: RRgba): string {
|
|
const to255 = (v: number) => Math.round(Math.max(0, Math.min(1, v)) * 255);
|
|
return `rgb(${to255(c[0])},${to255(c[1])},${to255(c[2])})`;
|
|
}
|
|
|
|
/** Setzt `attr`-opacity, falls Alpha spuerbar < 1 ist (vermeidet Rauschen). */
|
|
function setOpacityAttr(el: SVGElement, attr: string, alpha: number): void {
|
|
if (alpha < 0.999) el.setAttribute(attr, String(round(alpha)));
|
|
}
|
|
|
|
/** Auf 3 Nachkommastellen runden (kompaktes, exaktes SVG). */
|
|
function round(v: number): number {
|
|
return Math.round(v * 1000) / 1000;
|
|
}
|
|
|
|
function ptsAttrOf(pts: RPoint[], map: Mapper): string {
|
|
return pts.map((p) => map(p)).map(([x, y]) => `${round(x)},${round(y)}`).join(" ");
|
|
}
|
|
|
|
/** Gefuelltes Polygon (Wand-Poche, Raeume, Decken, Grundfuellung vor Schraffur). */
|
|
function appendFill(svg: SVGSVGElement, f: RFill, map: Mapper): void {
|
|
if (f.pts.length < 3) return;
|
|
const el = document.createElementNS(SVG_NS, "polygon");
|
|
el.setAttribute("points", ptsAttrOf(f.pts, map));
|
|
el.setAttribute("fill", rgbCss(f.color));
|
|
setOpacityAttr(el, "fill-opacity", f.color[3]);
|
|
el.setAttribute("stroke", "none");
|
|
svg.appendChild(el);
|
|
}
|
|
|
|
/**
|
|
* Umriss (geschlossener Ring) oder offene Polylinie (2D-Zeichenzug,
|
|
* Schraffur-Musterlinie). `widthScreen`: `widthMm` traegt Bildschirm-viewBox-
|
|
* Einheiten (Schraffur, siehe `HATCH_DENSITY_MM`-Kommentar oben) statt
|
|
* Papier-mm.
|
|
*/
|
|
function appendStroke(
|
|
svg: SVGSVGElement,
|
|
pts: RPoint[],
|
|
closed: boolean,
|
|
color: RRgba,
|
|
widthMm: number,
|
|
dash: number[] | null | undefined,
|
|
map: Mapper,
|
|
widthScreen: boolean,
|
|
): void {
|
|
if (pts.length < 2 || widthMm <= 0) return;
|
|
// Schraffur-Strichbreite ist eine feste Papier-mm-Groesse (Stift), NICHT vom
|
|
// gewaehlten Export-Massstab N abhaengig — sonst wuerde dieselbe Quell-
|
|
// `lineWeight` je nach `scaleDenominator` unterschiedlich dick gedruckt
|
|
// (Audit `docs/design/engine-linewidth-audit.md`, Finding 4). `widthMm` ist
|
|
// hier in Bildschirm-viewBox-Einheiten kodiert, exakt `hatchPx = max(0.6,
|
|
// hatchMm * (1/HATCH_DENSITY_MM))` aus `toRenderScene.ts` — die Umkehrung
|
|
// `* HATCH_DENSITY_MM` liefert wieder die Papier-mm-Quell-`lineWeight`,
|
|
// unabhaengig von N.
|
|
const effectiveMm = widthScreen ? widthMm * HATCH_DENSITY_MM : widthMm;
|
|
const strokeMm = quantizePen(effectiveMm);
|
|
|
|
const el = document.createElementNS(SVG_NS, closed ? "polygon" : "polyline");
|
|
el.setAttribute("points", ptsAttrOf(pts, map));
|
|
el.setAttribute("fill", "none");
|
|
el.setAttribute("stroke", rgbCss(color));
|
|
setOpacityAttr(el, "stroke-opacity", color[3]);
|
|
el.setAttribute("stroke-width", String(strokeMm));
|
|
el.setAttribute("stroke-linejoin", "round");
|
|
el.setAttribute("stroke-linecap", "round");
|
|
if (dash && dash.length) {
|
|
// Strichmuster ist bereits in Papier-mm definiert (siehe `toRenderScene.ts`:
|
|
// „unveraendert durchgereicht"), also direkt uebernehmen.
|
|
el.setAttribute("stroke-dasharray", dash.map((d) => round(d)).join(" "));
|
|
}
|
|
svg.appendChild(el);
|
|
}
|
|
|
|
/** Freies Liniensegment (Tuerblatt, Referenzlinie, Symbol). */
|
|
function appendLine(svg: SVGSVGElement, l: RLine, map: Mapper, _mmPerM: number): void {
|
|
if (l.widthMm <= 0) return;
|
|
const [ax, ay] = map(l.a);
|
|
const [bx, by] = map(l.b);
|
|
const el = document.createElementNS(SVG_NS, "line");
|
|
el.setAttribute("x1", String(round(ax)));
|
|
el.setAttribute("y1", String(round(ay)));
|
|
el.setAttribute("x2", String(round(bx)));
|
|
el.setAttribute("y2", String(round(by)));
|
|
el.setAttribute("stroke", rgbCss(l.color));
|
|
setOpacityAttr(el, "stroke-opacity", l.color[3]);
|
|
el.setAttribute("stroke-width", String(quantizePen(l.widthMm)));
|
|
el.setAttribute("stroke-linecap", "round");
|
|
if (l.dash && l.dash.length) {
|
|
el.setAttribute("stroke-dasharray", l.dash.map((d) => round(d)).join(" "));
|
|
}
|
|
svg.appendChild(el);
|
|
}
|
|
|
|
/**
|
|
* Kreisbogen als <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);
|
|
}
|