# Engine-Nordstern 1 — Strichbreiten-Audit (Papier-mm end-to-end) > Bezug: HANDOVER.md, Abschnitt ENGINE-NORDSTERN Punkt 1 ("Papier-mm-exakte > Strichbreiten überall — Bildschirm bei jedem Massstab/Zoom = Druck"). Dieses > Dokument ist ein Prüfbericht, kein Umbau — es fasst zusammen, wie die > Strichbreite heute vom Modell bis zum Papier läuft, wo sie divergieren kann, > und was konkret zu tun wäre. Nichts hieraus wurde umgesetzt. Betroffene Pfade: `src/plan/PlanView.tsx` (SVG-Default + Print-Vorschau), `src/plan/glPlan/*` (WebGL2, aktueller Default-GPU-Pfad), `src-tauri/render2d` (WASM/WebGPU, `?engine=wasm`), `src/export/sceneToPrintSvg.ts` + `src/export/exportPdf.ts` (Vektor-PDF). Referenz-Baseline für die 2D-Engine-Parität ist laut Commit `ce6bd26` **`?gl=0`** (reiner SVG-Pfad) — NICHT der App-Default (der ist WebGL2, siehe Finding 1). ## 1. Modell der Wahrheit So sollte eine Strichbreite in diesem Code korrekt fliessen: 1. **Quelle**: `generatePlan.ts` legt jede Strichbreite als `weightMm` / `strokeWidthMm` in **echten Papier-Millimetern** ab (Kommentarkopf, `generatePlan.ts:76-79`: "Alle Stricharten sind in mm Papier definiert"). Diese Zahl ist unabhängig von Zoom, Massstab und Render-Pfad — eine 0.18 mm Wand-Umrisslinie bleibt 0.18 mm, ganz gleich wo sie später landet. 2. **Bildschirm bei Massstab 1:N**: 1 Modell-Meter entspricht auf Papier `1000/N` mm. Der Bildschirm zeigt `PX_PER_M = 90` viewBox-Einheiten je Modell-Meter (`PlanView.tsx:23`). Eine `mm`-Breite belegt daher `mm · N/1000 · PX_PER_M` viewBox-Einheiten — das ist exakt `printStrokeVb()` (`PlanView.tsx:2598-2600`). Multipliziert mit der Geräte-px-je-viewBox-Einheit-Skala (`meet`, `PlanView.tsx:2074-2078`) ergibt das die tatsächliche Bildschirmbreite in Geräte-Pixeln. Reinzoomen (kleinere viewBox-Breite, grösseres `meet`) macht die Linie dicker — genau wie beim Herausvergrössern eines gedruckten Plans mit der Lupe. Das gilt für JEDE Zoomstufe gleichermassen: 250 % Zoom bei 1:50 zeigt exakt die 5-fache Pixelbreite von 100 % Zoom bei 1:50, und bei gegebenem Zoom ist 1:50 exakt doppelt so dick wie 1:100 (halber Nenner → doppelt so viele Weltmeter je Papiermm). 3. **Druck/PDF**: dieselbe Formel, nur ohne Geräte-px-Zwischenschritt — direkt `mm` bleibt `mm` — die Seite ist direkt in echten Papiermillimetern aufgespannt (`sceneToPrintSvg.ts:99-125`). Zusätzlich wird auf ISO-nahe Stiftstufen gerundet (`PEN_STEPS`, `sceneToPrintSvg.ts:44`), weil ein reales Zeichengerät/Plotter nur endlich viele Stiftbreiten kennt. 4. **Eine Wahrheit**: Seit `382771b` bauen sowohl der Viewport (`useWasmPlanRenderer.ts`/`nativeSync.ts`) als auch der PDF-Export (`exportPdf.ts:73`) dieselbe `planToRenderScene(plan)`-Szene — der PDF-Pfad ist nur ein anderes *Ziel* derselben Szene, kein zweiter Interpret. Korrekt hiesse also: **jede** der vier Anzeige-/Exportarten (SVG-Default, SVG-Print-Vorschau, GPU-Viewport, PDF) muss aus **derselben** `weightMm`, für **denselben** `N`, exakt dieselbe Papier-mm-Breite ergeben — bis auf die bewusste PEN_STEPS-Rundung im Druckpfad, die dokumentiert und überall gleichermassen sichtbar sein sollte (ist sie nicht, siehe Finding 2). Ausdrücklich **kein** Teil dieses Modells: der Haarlinien-Modus (`lineMode: "display"`, App-Default, `viewSlice.ts:38,74`). Er ist als bewusster Papier-mm-*Ausstieg* gedacht ("Display: all lines as constant hairlines (calm editing)", `en.ts:173`) — 1 Geräte-px, konstant, unabhängig von Zoom/Massstab. Er muss also NICHT der Papier-mm-Formel folgen, aber er muss in JEDEM Render-Pfad *gleichermassen* als Ausstieg wirken. Tut er nicht (Finding 1). ## 2. Pfad-für-Pfad-Trace ### 2a. SVG-Default (App-Start ohne `?gl=0`, `lineMode:"display"`) Nur aktiv, wenn WebGL2 fehlschlägt (`wantGl` ist sonst `true`, s. Finding 1) — de facto der reine Fallback-Pfad. - `PlanView.tsx:2613`: `print = !hairline && paperScale != null && paperScale > 0` — mit `hairline=true` (Default) ist `print` immer `false`. - `PlanView.tsx:2617-2618`: ```ts const weight = (mm: number): number => hairline ? HAIRLINE_PX : print ? printStrokeVb(mm, paperScale!) : mmToPx(mm); ``` `HAIRLINE_PX = 1` (`PlanView.tsx:2583`), Einheit: Geräte-unabhängiger CSS-Pixel, gezeichnet mit `vector-effect="non-scaling-stroke"` (`PlanView.tsx:2621`, `vfx`) → bleibt beim Pan/Zoom optisch exakt 1 px, egal wie stark reingezoomt wird. Papier-mm spielt hier explizit KEINE Rolle. ### 2b. SVG-Print-Vorschau (`lineMode:"print"`, weiterhin `?gl=0` oder WebGL2-Fallback) - `print = true`, `weight(mm) = printStrokeVb(mm, paperScale!)` (`PlanView.tsx:2598-2600`): ```ts function printStrokeVb(mm: number, n: number): number { return Math.max(1e-4, (mm * n) / 1000) * PX_PER_M; } ``` `vectorEffect` entfällt (`vfx = undefined`, `PlanView.tsx:2621`) — die Linie skaliert MIT der Geometrie beim Zoomen, wie physisches Papier unter der Lupe. `paperScale` selbst ist der **stabile, gemessene** 1:N-Nenner (`PlanView.tsx:458-460`), nicht der live aus jedem Radzoom abgeleitete Wert — sonst würde Reinzoomen die Linien nicht dicker, sondern konstant halten (das wäre wieder Haarlinien-Verhalten). Keine PEN_STEPS-Rundung — die rohe `mm`-Zahl wird 1:1 in viewBox-Einheiten übersetzt. ### 2c. GPU-Viewport (WebGL2 `useGlPlanRenderer.ts` — App-DEFAULT; WASM `useWasmPlanRenderer.ts` bei `?engine=wasm`) - Beide Hooks reichen `paperScaleN` unverändert an die Engine durch: `useWasmPlanRenderer.ts:131-149` (`render(viewBox, paperScaleN=100, textScaleN=100)` → `r.set_paper_scale(paperScaleN)`); WebGL2 analog (`useGlPlanRenderer.ts:102-116`, kein `textScaleN`-Parameter überhaupt, s. Finding 3). - `PlanView.tsx` ruft in JEDEM Render-Aufruf (`PlanView.tsx:494`,`502`,`1080`) `renderGl(view, paperScaleForGl(view), textScaleForGl())`. `paperScaleForGl` (`PlanView.tsx:475-476`): ```ts const paperScaleForGl = (v: ViewBox): number => paperScaleRef.current ?? scaleFromView(v, svgRef.current) ?? 100; ``` **Kein `hairline`-Zweig.** Egal ob `lineMode` "display" oder "print" ist, hier kommt immer ein echter 1:N-Papier-Nenner heraus. - Rust-Seite (`gpu.rs:606-616`): ```rust let mm_px = mm_to_device_px(view_box, vw, vh, self.paper_scale_n); ``` `mm_to_device_px` (`ortho.rs:96-99`) reproduziert exakt `printStrokeVb`: `(paper_scale_n/1000.0) * PX_PER_M * meet` — `PX_PER_M = 90.0` (`tessellate.rs:22`, identisch zu TS). Die reine Mathematik ist also deckungsgleich zum SVG-Print-Pfad (2b) — aber sie läuft **immer**, auch wenn der Nutzer "Display: Haarlinien" gewählt hat. Siehe Finding 1. - Stiftbreite pro Batch im Shader (`shaders.rs:91`, `LINE_WGSL`): `width_px = max(0.6, stroke_px * stroke_scale) * miter` — harte Untergrenze 0.6 Geräte-px, die weder die SVG- noch die PDF-Seite kennt (Finding 6). Identische Formel für Bögen (`shaders.rs:209`, `ARC_WGSL`) und im WebGL2-Pfad (`glPlanRender.ts:243-249`, Kommentar "klemmt bei ~0.6 px"). - Schraffur-Musterlinien laufen NICHT über `mm_px`, sondern über `width_screen`/`px_per_screen` (`gpu.rs:661-665`): Geräte-px = Breite × `meet`-Skala statt × mm→px — bewusst zoom-skalierend wie das SVG-``, nicht papierkonstant (s. `toRenderScene.ts:371-375`, Finding 4). ### 2d. Vektor-PDF (`exportPdf.ts` → `sceneToPrintSvg.ts`) - `exportPdf.ts:73`: `const scene = planToRenderScene(plan);` — dieselbe Szene wie 2c, unabhängig vom aktuell gewählten `lineMode` der Ansicht. - `sceneToPrintSvg.ts:243-244`: ```ts const effectiveMm = widthScreen ? (widthMm / PX_PER_M) * mmPerM : widthMm; const strokeMm = quantizePen(effectiveMm); ``` `PEN_STEPS = [0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0]`, `MIN_PEN_MM = 0.13` (`sceneToPrintSvg.ts:44,47`). JEDE Linie/Umriss/Bogen/Schraffur wird auf die nächsthöhere Stufe gerundet, bevor sie ins SVG/PDF geht (`sceneToPrintSvg.ts:251,274,301`). Das ist der EINZIGE der vier Pfade, der überhaupt quantisiert. - `widthScreen`-Konvertierung: `widthMm` (hier eigentlich viewBox-Einheiten, siehe 2c) wird über `/PX_PER_M * mmPerM` zurück in Weltmeter und dann in Papier-mm beim GEWÄHLTEN `opts.scaleDenominator` übersetzt — nicht beim Massstab, den die Live-Ansicht gerade zeigt (Finding 4). - Text: `sizeMm` kommt unverändert aus `RText.sizeMm` (`sceneToPrintSvg.ts:325`, `t.sizeMm`, keine Nachskalierung) — aber die VERTIKALE Zeilenposition dieser Texte wurde in `toRenderScene.ts` mit einem festen `STAMP_REF_N = 100` in Weltmeter umgerechnet (`toRenderScene.ts:163-165, 481-483`), unabhängig vom tatsächlich gewählten Export-`N` (Finding 3). ## 3. Findings (nach Schwere geordnet) ### 1. Haarlinien-Modus (App-Default) wird vom GPU-Renderer komplett ignoriert — betrifft den Standard-Zustand der App `lineMode` startet auf `"display"` (`viewSlice.ts:74`), und WebGL2 ist der Default-Renderer (`wantGl` ist `true`, ausser `?gl=0`, `PlanView.tsx:433-437`) — **d. h. im frisch geladenen, unkonfigurierten App- Zustand rendert bereits der GPU-Pfad, und die "Display: Haarlinien"-Option tut nichts.** `paperScaleForGl` (`PlanView.tsx:475-476`) liest nur `paperScaleRef.current ?? scaleFromView(...) ?? 100` — der `hairline`-Boolean aus den Props (`PlanView.tsx:2571`) wird an dieser Stelle nie geprüft, obwohl das benachbarte `textScaleForGl` (`PlanView.tsx:485-488`) exakt diesen Zweig korrekt hat: ```ts const textScaleForGl = (): number => { const print = !hairline && paperScale != null && paperScale > 0; return print ? paperScale! : 100; }; ``` Ergebnis: Text (Raumstempel) fällt im Haarlinien-Modus korrekt auf die Referenzskala 100 zurück, aber jede Wand-/Tür-/2D-Zeichenlinie wird trotzdem mit dem echten (gemessenen oder geschätzten) `paperScale` gezeichnet — sie wird beim Reinzoomen dicker statt konstant zu bleiben, exakt das Gegenteil dessen, was der Menüpunkt verspricht ("calm editing", `en.ts:173`). Der Bug betrifft sowohl `useGlPlanRenderer.ts` (Default) als auch `useWasmPlanRenderer.ts` (`?engine=wasm`) — keiner der beiden `render()`- Aufrufe kennt einen `hairline`-Parameter überhaupt. ### 2. PEN_STEPS-Quantisierung existiert NUR im PDF-Export — beide Live-Vorschauen (SVG-Print und GPU) zeigen unquantisierte Rohwerte Konkret, mit den tatsächlichen Konstanten aus `generatePlan.ts`: - `LAYER_LINE_MM = 0.13` (`generatePlan.ts:82`), `LAYER_DETAIL_FACTOR.fein = 0.7` (`generatePlan.ts:90-93`) → Schichtfuge im Detailgrad "fein": `0.13 · 0.7 = 0.091 mm` (`generatePlan.ts:1022`, `strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail]`). Die Bildschirm-Print-Vorschau zeigt genau diese 0.091 mm (`printStrokeVb(0.091, N)`); der PDF-Export klemmt via `quantizePen` auf `MIN_PEN_MM = 0.13` mm — **+43 % dicker im Druck als in der Vorschau.** - Türschwenkbogen: `doorLwMm * 0.6` (`generatePlan.ts:902`) mit dem üblichen Fallback `doorLwMm = LAYER_LINE_MM = 0.13` (`generatePlan.ts:354`) ergibt `0.078 mm`. Screen zeigt 0.078 mm (nahezu unsichtbar dünn bei kleinen Massstäben), PDF klemmt auf 0.13 mm — **+67 %.** - Wand-Umriss im Detailgrad "grob": `outlineMm = wallLwMm · OUTLINE_DETAIL_FACTOR.grob (1.6)` (`generatePlan.ts:84-88, 648, 796`); beim häufigen Fallback `WALL_FALLBACK_MM = 0.18` (`generatePlan.ts:97`) ergibt das `0.288 mm`. `quantizePen(0.288)` rundet auf die nächste Stufe `0.35` (`PEN_STEPS`, da `0.25 < 0.288 ≤ 0.35`) — **+21.5 % im Druck.** Das systematische Muster: der Detailgrad "fein" existiert explizit, um Nebenlinien DÜNNER zu machen (`generatePlan.ts:52-58`, `LAYER_DETAIL_FACTOR.fein = 0.7`) — aber sobald der berechnete Wert unter `MIN_PEN_MM` fällt, hebt die PDF-Quantisierung ihn wieder auf die Untergrenze an, ohne dass die Bildschirm-Vorschau (weder SVG-Print noch GPU) davon irgendetwas zeigt. Wer die Druckvorschau am Bildschirm beurteilt, sieht NICHT, was tatsächlich gedruckt wird. ### 3. Stempel-Zeilenabstand ist nur bei Massstab 1:100 exakt — SVG- und RenderScene-Pfad nutzen zwei verschiedene Formeln für dieselbe Grösse Bereits als bekannte Alt-Lücke in HANDOVER.md (Commit `382771b`, Punkt 6) vermerkt; hier präzise verortet: - SVG (`PlanView.tsx:2738-2739`): ```ts const nRef = print ? paperScale! : 100; const unitPerPt = (1 / 72) * 0.0254 * nRef * PX_PER_M; ``` `nRef` folgt im Druckmodus dem TATSÄCHLICH aktiven `paperScale` — der Zeilenabstand (`lineGap = baseFs * 1.3`, `PlanView.tsx:2741`) ist für jedes `N` korrekt in viewBox-Einheiten, weil er direkt aus `unitPerPt` (das `N` enthält) abgeleitet wird. - RenderScene (`toRenderScene.ts:163-165`): ```ts const STAMP_REF_N = 100; const MM_TO_M = STAMP_REF_N / 1000; ``` und (`toRenderScene.ts:481-483`): ```ts const baseH = baseMm * MM_TO_M; // Basisgröße in Modell-Metern const lineGap = baseH * 1.3; ``` Hier ist der Umrechnungsfaktor `MM_TO_M` FEST auf `N = 100` verdrahtet, unabhängig davon, mit welchem `N` der WASM-Viewport oder der PDF-Export tatsächlich rendert (`paper_scale_n` in `gpu.rs`, `opts.scaleDenominator` in `sceneToPrintSvg.ts`). Bei `N = 100` sind beide Formeln identisch (das war offenbar der Verifikationsfall in `382771b`); bei jedem anderen `N` — z. B. 1:50 — driftet der vertikale Zeilenabstand der Stempel-Mehrzeiler proportional zum Verhältnis `N_wahr/100` auseinander, während die einzelne Zeilenhöhe (`sizeMm`) selbst korrekt bleibt. Betroffen: WASM-Viewport (2c) UND PDF-Export (2d) gleichermassen (beide beziehen `RText` aus derselben `toRenderScene`-Funktion); die WebGL2-Ansicht ist NICHT betroffen, weil sie Text grundsätzlich nicht selbst zeichnet, sondern die SVG-Overlay-Ebene weiterverwendet (`PlanView.tsx:1592`, `useGpuRenderer ? wantWasm || p.kind !== "text" : ...` — bei WebGL2 (`wantWasm=false`) werden Text-Primitive NIE aus dem SVG-Rendering herausgefiltert). ### 4. Schraffur-Strichbreite im PDF hängt vom GEWÄHLTEN Export-Massstab ab, nicht vom Massstab der Live-Vorschau `hatchPx` wird in `toRenderScene.ts:374-375` genau einmal, massstabsunabhängig berechnet: ```ts const hatchMm = p.hatch.lineWeight > 0 ? p.hatch.lineWeight : 0.13; const hatchPx = Math.max(0.6, hatchMm * (1 / 0.13)); ``` — eine reine viewBox-Grösse (analog zum SVG-``-Strich, der absichtlich mit dem Zoom mitskaliert, damit die Schraffur bei jedem Zoom gleich dicht aussieht: Kommentar `toRenderScene.ts:371-373`). Erst beim Export wird daraus in `sceneToPrintSvg.ts:243` `effectiveMm = (widthMm / PX_PER_M) * mmPerM` — und `mmPerM = 1000 / opts.scaleDenominator` (`sceneToPrintSvg.ts:101`) verwendet den vom Export-Dialog gewählten Massstab, NICHT den `paperScale`, den der Nutzer gerade in der "Print"-Live-Vorschau sieht (`exportPdf.ts` ruft `sceneToPrintSvg` mit `opts.scaleDenominator` aus den Export-Optionen, `exportPdf.ts:61-79`, völlig unabhängig vom `PlanView`-State). Beispiel: `lineWeight = 0.13` mm (Standard-Fuge, `getHatch`-Default) → `hatchPx = max(0.6, 0.13 · (1/0.13)) = 1.0` viewBox-Einheiten. - Export bei 1:50 (`mmPerM = 20`): `effectiveMm = (1.0/90)·20 = 0.222 mm` → `quantizePen` → **0.25 mm**. - Export bei 1:200 (`mmPerM = 5`): `effectiveMm = (1.0/90)·5 = 0.056 mm` → auf `MIN_PEN_MM` geklemmt → **0.13 mm**. Fast die doppelte Strichstärke allein durch die Wahl des Export-Massstabs, bei UNVERÄNDERTER Quell-`lineWeight` — und ohne dass die Bildschirm- "Print"-Vorschau (die ja mit dem live gemessenen `paperScale` arbeitet, nicht mit `opts.scaleDenominator`) das anzeigen könnte, solange beide Werte nicht zufällig übereinstimmen. ### 5. Dieselbe Formel existiert vierfach, nur durch Kommentare (nicht durch Typen/Code) synchron gehalten `PX_PER_M = 90` ist unabhängig deklariert in: `PlanView.tsx:23` (SVG), `glPlan/glPlanRender.ts:12` (WebGL2), `tessellate.rs:22` (Rust, geteilt zwischen WASM-Viewport und Headless/Golden-Test) und `sceneToPrintSvg.ts:64` (PDF-Serializer, mit explizitem Kommentar "MUSS mit dem dortigen uebereinstimmen, sonst driftet die Schraffur-Dichte des PDFs vom Viewport ab", `sceneToPrintSvg.ts:61-62`). Die Hatch-Dichte-Formel `Math.max(0.6, weightMm · (1/0.13))` ist separat dupliziert in `PlanView.tsx:2344-2345` (`hatchStrokePx`, SVG-``) und `toRenderScene.ts:375` (`hatchPx`, GPU + PDF) — beide mit demselben "magischen" Faktor `1/0.13`, ohne gemeinsame Konstante. Aktuell sind alle vier Kopien konsistent; das Risiko ist rein prospektiv (nächste Tuning-Änderung an einer Stelle vergisst die anderen drei) — aber genau das ist der Mechanismus, über den Findings wie 1-4 überhaupt erst entstehen können, ohne dass ein Typfehler oder Test anschlägt. ### 6. Drei unabhängige, nicht aufeinander abgestimmte Mindestbreiten-Politiken - SVG-Print: `Math.max(1e-4, ...)` (`PlanView.tsx:2599`) — praktisch keine Untergrenze, verlässt sich auf das Antialiasing des Browsers. - GPU (WebGL2 UND WASM, Linien UND Bögen): harter Floor von **0.6 Geräte-Pixel** (`shaders.rs:91,209`; `glPlanRender.ts:243-249`, Kommentar "klemmt bei ~0.6 px"). - PDF: **`MIN_PEN_MM = 0.13` mm** (`sceneToPrintSvg.ts:38` in `planToPrintSvg.ts`, äquivalent `sceneToPrintSvg.ts:47`) — eine Papier-mm-Grösse, kein Pixelwert, konzeptionell etwas anderes als die GPU-Pixel-Untergrenze. Auswirkung: bei sehr kleinem Massstab (weit rausgezoomt oder grosses `N`, z. B. Übersichtsplan 1:500) hält der GPU-Pfad sehr dünne Linien künstlich bei 0.6 px sichtbar, während dieselbe Linie im SVG-Pfad fast verschwindet und im PDF auf eine ganz andere (mm-basierte, massstabsabhängige) Grösse geklemmt wird. Kein Pfad kennt die Politik der anderen beiden. Niedrigere Priorität als 1-4, weil hier keine der drei Politiken die *exportierte* Papier-mm-Wahrheit verändert (die bleibt PDF-exklusiv über `MIN_PEN_MM`) — es geht nur um Bildschirm-Konsistenz zwischen SVG/GPU bei Extremzoom. ### 7. Toter Zweitpfad `planToPrintSvg.ts` dupliziert PEN_STEPS/mm-Logik komplett, unbenutzt aber vorhanden `src/export/planToPrintSvg.ts` trägt seit `382771b` einen Kopfkommentar "ERSETZT durch sceneToPrintSvg.ts … wird vom PDF-Export NICHT MEHR verwendet" (`planToPrintSvg.ts:1-6`) und ist tatsächlich nirgends mehr importiert (verifiziert per Grep über `src/`). Er enthält jedoch weiterhin eine eigene, unabhängige Kopie von `PEN_STEPS`/`MIN_PEN_MM`/`quantizePen` (`planToPrintSvg.ts:35,38,40-45`) und einer kompletten Plan→SVG-Serialisierung inkl. eigener `PX_PER_M`-Handhabung (`planToPrintSvg.ts:326`). Kein aktiver Bug, aber eine Falle: Copy-Paste-Wiederverwendung dieses Altpfads (z. B. für einen zukünftigen DXF/PNG-Export) würde eine dritte, potenziell abweichende Quantisierungs-Tabelle in den Baum ziehen. ## 4. Vorgeschlagene Fixes ### zu Finding 1 (Haarlinien-Modus im GPU-Pfad) Kleinste, korrekte Lösung: den `hairline`-Zustand als eigenen Parameter bis in den Shader durchreichen, analog zu `paper_scale_n`/`text_scale_n`. - **Rust (`src-tauri/render2d/src/gpu.rs`)**: neues Feld `pub hairline: bool` auf `Renderer` (Default `false`, neben `paper_scale_n` bei `gpu.rs:422`). In `Renderer::render` (`gpu.rs:606-616`): wenn `self.hairline`, `mm_px` NICHT aus `mm_to_device_px(...)` berechnen, sondern auf einen konstanten Geräte-px-Wert setzen (z. B. `1.0`), UND dafür sorgen, dass der Shader `width_mm` in diesem Fall ignoriert (sonst bleibt die RELATIVE Differenz zwischen z. B. 0.13 mm und 0.35 mm bestehen, nur global skaliert — nicht das gewünschte "alle Linien exakt 1 px"). Sauberster Weg: ein neues `hairline: u32`-Feld in die pro-Frame-Uniform (`Globals`, `shaders.rs:20-23` und `ArcGlobals`), im Fragment-/Vertex-Shader (`shaders.rs:91`, `LINE_WGSL`; `shaders.rs:209`, `ARC_WGSL`) per `select(...)` auf einen festen `1.0`-px-Wert umschalten statt `max(0.6, stroke_px * stroke_scale)`. - **`src-tauri/render2d/src/web.rs`**: neue Methode `set_hairline(&mut self, on: bool)` neben `set_paper_scale`/`set_text_scale` (`web.rs:146-155`-Nachbarschaft). - **`src/plan/useWasmPlanRenderer.ts`**: `render()` (`useWasmPlanRenderer.ts:131-155`) um einen `hairline: boolean`-Parameter erweitern, `r.set_hairline(hairline)` vor `r.render()` aufrufen. - **`src/plan/useGlPlanRenderer.ts`**: analog — `render()` (Signatur aktuell `(viewBox, paperScaleN=100)`) um `hairline` erweitern; im WebGL2-Shader-Uniform-Pfad (`glPlanRender.ts:159-165,213-215`) `mmToDevicePx` bei `hairline===true` durch einen konstanten Wert ersetzen und im Fragment-Shader denselben `select`-Trick wie oben anwenden (`glPlanShaders.ts`, dort wo "~0.6 px"-Klemmung passiert, siehe Finding 6). - **`src/plan/PlanView.tsx`**: `paperScaleForGl` (`PlanView.tsx:475-476`) bleibt wie sie ist (wird weiter für den Massstab-Nenner gebraucht, sobald der Nutzer zurück auf "Print" schaltet); stattdessen an allen drei `renderGl(...)`-Aufrufstellen (`PlanView.tsx:494,502,1080`) zusätzlich `hairline` durchreichen: `renderGl(view, paperScaleForGl(view), textScaleForGl(), hairline)`. ### zu Finding 2 (PEN_STEPS nur im PDF) Zwei mögliche Richtungen, je nach gewünschtem Produktverhalten: - **Option A (Vorschau matcht Druck)**: `quantizePen` (`sceneToPrintSvg.ts:50-54`) in ein gemeinsames Modul auslagern (z. B. `src/plan/penSteps.ts`, exportiert `PEN_STEPS`, `MIN_PEN_MM`, `quantizePen`), von `sceneToPrintSvg.ts` UND von `PlanView.tsx` (`printStrokeVb`, `PlanView.tsx:2598-2600`) importieren und dort ebenfalls anwenden: `printStrokeVb(mm, n) = Math.max(1e-4, (quantizePen(mm) * n) / 1000) * PX_PER_M`. Für den GPU-Pfad müsste die Quantisierung dann VOR dem Scene-Bau passieren (in `toRenderScene.ts`, auf `widthMm` jeder `RLine`/`ROutline`/`RPolyline`/`RArc`, ausgenommen `widthScreen`-Einträge), damit WASM/WebGL2-Viewport dieselben Stufen zeigen wie SVG und PDF. - **Option B (Vorschau bleibt Rohgrösse, aber sichtbar gemacht)**: falls die feinkörnige Vorschau bewusst erhalten bleiben soll, zumindest den Stiftstufen-Sprung an der Stelle sichtbar machen, an der die Detailgrad- Faktoren definiert werden (`generatePlan.ts:84-93`) — z. B. ein Entwickler-/Lint-Test, der bei jedem `OUTLINE_DETAIL_FACTOR`/ `LAYER_DETAIL_FACTOR`-Wert prüft, ob das Produkt mit den üblichen Fallback-`weightMm`-Werten (`WALL_FALLBACK_MM`, `LAYER_LINE_MM`) exakt auf einer `PEN_STEPS`-Stufe landet, und sonst warnt. Empfehlung: Option A — sie erfüllt den Nordstern wörtlich ("Bildschirm bei jedem Massstab = Druck"). ### zu Finding 3 (STAMP_REF_N) In `toRenderScene.ts` `STAMP_REF_N = 100` (`toRenderScene.ts:163`) durch den tatsächlichen Ziel-Massstab ersetzen. `planToRenderScene(plan)` kennt aktuell keinen `N`-Parameter (`exportPdf.ts:73` ruft es ohne Massstabsangabe auf) — die Funktion müsste ein optionales `paperScaleN`-Argument bekommen (Default 100, um den Viewport-Aufruf ohne Massstabskontext — `nativeSync.ts` — nicht zu brechen, dort ist 100 ohnehin die richtige Referenz für den WASM-Viewport im Anzeigemodus, s. `gpu.rs:422`), und `exportPdf.ts:73` müsste `planToRenderScene(plan, opts.scaleDenominator)` aufrufen. `MM_TO_M` (`toRenderScene.ts:165`) dann aus diesem Parameter statt aus der Konstante ableiten. ### zu Finding 4 (Hatch-mm im PDF folgt dem Export-N, nicht dem Vorschau-N) Kein Bug im engeren Sinn (die Formel ist in sich konsistent — die Schraffur soll ja pro *gewähltem Blatt-Massstab* eine sinnvolle Dichte haben), aber die Bildschirm-„Print"-Vorschau sollte denselben `N` verwenden, den der Export-Dialog tatsächlich benutzen wird, sonst lügt die Vorschau. Fix: `paperScale` in `PlanView.tsx` beim Öffnen des Export-Dialogs (bzw. der Export-Dialog selbst) mit `opts.scaleDenominator` vorbelegen/synchronisieren, statt zwei unabhängige State-Quellen zu pflegen — Ort: dort, wo der Export-Dialog `scaleDenominator` initialisiert (App.tsx, PDF-Export-UI) einen Default aus `paperScale`/`liveScale` (`App.tsx`, `onScale`-Callback, `PlanView.tsx:794`) übernehmen. ### zu Finding 5 (vierfache Formel-Duplikation) Eine gemeinsame TS-Konstantendatei `src/plan/renderConstants.ts` mit `PX_PER_M = 90`, `HATCH_DENSITY_FACTOR = 1/0.13`, `HATCH_MIN_PX = 0.6` anlegen; `PlanView.tsx`, `glPlan/glPlanRender.ts`, `toRenderScene.ts`, `sceneToPrintSvg.ts` importieren daraus statt eigener Literale. Für die Rust-Seite (`tessellate.rs:22`, `shaders.rs:142`) ist echtes Teilen über die Sprachgrenze hinweg nicht trivial — dort bleibt nur ein Kommentar-Link auf die TS-Konstante plus ein Parity-Test (siehe Abschnitt 5), der bei Abweichung fehlschlägt statt bei stillem Drift. ### zu Finding 6 (drei Mindestbreiten-Politiken) Niedrige Priorität, aber falls angegangen: den 0.6-px-Floor aus den Shadern (`shaders.rs:91,209`, `glPlanShaders.ts`) als benannte Konstante exportieren/dokumentieren und explizit von `1e-4` (SVG) und `MIN_PEN_MM=0.13` (PDF) abgrenzen — mindestens per Kommentar klarstellen, dass die drei UNTERSCHIEDLICHE Zwecke haben (GPU: Pixel-Sichtbarkeits-Floor; PDF: Papier-mm-Stiftgrenze) und NICHT synchronisiert werden müssen, damit niemand versehentlich versucht, sie anzugleichen und dabei die jeweils andere Semantik bricht. ### zu Finding 7 (toter Pfad) `src/export/planToPrintSvg.ts` entfernen (nach kurzer Rücksprache, ob der Kopfkommentar-Vermerk "bleibt nur als Referenz stehen" noch gewollt ist) oder, falls er als Referenz bleiben soll, seine `PEN_STEPS`/`quantizePen`-Kopie durch einen Import aus der in Finding 2 vorgeschlagenen gemeinsamen `penSteps.ts` ersetzen. ## 5. Verifikations-Rezept Was schon existiert: - `scripts/probe-engine-parity.mjs` vergleicht `?gl=0` gegen `?engine=wasm` rein visuell (Screenshot-Diff von Auge, `probe-parity-svg.png` vs. `probe-parity-wasm.png`) — prüft NICHT quantitativ, ob Strichbreiten in Pixeln übereinstimmen, und deckt weder den Haarlinien-Modus noch den WebGL2-Default-Pfad noch den PDF-Export ab. - `src-tauri/render2d/tests/golden.rs` vergleicht die Demo-Szene Pixel-für-Pixel gegen `tests/golden/demo.png` (Toleranz: Kanal-Delta > 2 auf < 0.5 % der Pixel, `docs/design/engine-headless.md`) — ein reiner GPU-Regressionstest, ohne SVG- oder PDF-Vergleich. - Die einzige bisher dokumentierte mm-genaue Messung ist manuell: HANDOVER.md, Commit `382771b` — `pdftoppm`-Messung eines exportierten PDFs (53.51×43.69 mm gegen erwartete 53.45×43.45 mm bei 1:100), einmalig, nicht als wiederholbares Skript hinterlegt. Um den Nordstern ("Bildschirm bei jedem Zoom/Massstab = Druck") tatsächlich beweisbar zu machen, fehlt ein quantitativer End-to-End-Test, der: 1. Für eine feste Test-Szene (idealerweise die bestehende `demo::demo_scene` aus `src-tauri/render2d/src/demo.rs`, die bereits sowohl einen `width_mm`- als auch einen `width_screen`-Strich enthält, `demo.rs:34,49-50`) bei mehreren `N` (1:10, 1:50, 1:100) UND mehreren Zoomstufen: - den PDF-Export erzeugt und via `pdftoppm -r ` in ein PNG rendert, dort die Strichbreite in Pixeln misst und in mm zurückrechnet (bekannte DPI ⇒ bekannte mm/px); - denselben Frame headless über `HeadlessRenderer::render_to_image` (`src-tauri/render2d/src/headless.rs`) mit identischem `paper_scale_n` rendert und dieselbe Pixel-Breite misst; - beide mm-Werte gegen den erwarteten `mm · N/1000`-Sollwert UND gegeneinander vergleicht, mit einer Toleranz, die die PEN_STEPS-Rundung (Finding 2, sobald behoben: siehe Option A) einbezieht. 2. Den Haarlinien-Modus separat abdeckt: ein Screenshot-Vergleich bei zwei verschiedenen Zoomstufen im `lineMode:"display"` — die gemessene Pixelbreite MUSS bei beiden Zoomstufen identisch sein (Beweis, dass Finding 1 behoben ist), sowohl für WebGL2 (`?gl` ohne `=0`, App-Default) als auch für WASM (`?engine=wasm`). 3. `probe-engine-parity.mjs` um eine tatsächliche Pixel-Differenz-Metrik erweitert (statt nur Kindanzahl/Warnungen zu loggen wie aktuell `probe-engine-parity.mjs:36-44`) — z. B. eine bekannte Referenzlinie im Testmodell an fester Bildschirmposition, deren Strichbreite in beiden Screenshots per Pixel-Sampling gemessen und verglichen wird. Bis dieser Test existiert, bleibt jede Aussage über "Bildschirm = Druck" eine Behauptung, die nur durch manuelles `pdftoppm`-Nachmessen einzelner Stichproben gedeckt ist — die in diesem Audit gefundenen Divergenzen (v. a. Finding 1 und 2) hätten mit den heutigen Probes nicht auffallen können, weil keiner von ihnen den Default-Zustand der App (`lineMode:"display"`, WebGL2) gegen den Druckpfad misst.