# 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-`<pattern>`,
  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-`<pattern>`-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-`<pattern>`) 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 <dpi>` 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.
