Files
DOSSIER-STANDALONE/docs/design/engine-linewidth-audit.md
T
karim e3f460c9f8 Doku: mm-Strichbreiten-Audit über den gesamten Engine-Pfad (Nordstern 1)
Traceanalyse der Strichbreiten von Modell → Bildschirm-bei-Massstab → Druck-mm
über alle vier Pfade (SVG-Display, SVG-Print, Vektor-PDF, render2d/WASM).
Befunde nach Schwere geordnet inkl. konkreter Fix-Vorschläge (Datei:Zeile) und
Verifikations-Rezept. Kernbefund: Hairline/Display-Modus wird von beiden
GPU-Renderern ignoriert; PEN_STEPS-Quantisierung existiert nur im PDF-Pfad.
2026-07-03 18:37:49 +02:00

512 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.