e3f460c9f8
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.
512 lines
28 KiB
Markdown
512 lines
28 KiB
Markdown
# 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.
|