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.
28 KiB
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:
- Quelle:
generatePlan.tslegt jede Strichbreite alsweightMm/strokeWidthMmin 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. - Bildschirm bei Massstab 1:N: 1 Modell-Meter entspricht auf Papier
1000/Nmm. Der Bildschirm zeigtPX_PER_M = 90viewBox-Einheiten je Modell-Meter (PlanView.tsx:23). Einemm-Breite belegt dahermm · N/1000 · PX_PER_MviewBox-Einheiten — das ist exaktprintStrokeVb()(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össeresmeet) 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). - Druck/PDF: dieselbe Formel, nur ohne Geräte-px-Zwischenschritt — direkt
mmbleibtmm— 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. - Eine Wahrheit: Seit
382771bbauen sowohl der Viewport (useWasmPlanRenderer.ts/nativeSync.ts) als auch der PDF-Export (exportPdf.ts:73) dieselbeplanToRenderScene(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— mithairline=true(Default) istprintimmerfalse.PlanView.tsx:2617-2618: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 mitvector-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):function printStrokeVb(mm: number, n: number): number { return Math.max(1e-4, (mm * n) / 1000) * PX_PER_M; }vectorEffectentfällt (vfx = undefined,PlanView.tsx:2621) — die Linie skaliert MIT der Geometrie beim Zoomen, wie physisches Papier unter der Lupe.paperScaleselbst 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 rohemm-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
paperScaleNunverä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, keintextScaleN-Parameter überhaupt, s. Finding 3). PlanView.tsxruft in JEDEM Render-Aufruf (PlanView.tsx:494,502,1080)renderGl(view, paperScaleForGl(view), textScaleForGl()).paperScaleForGl(PlanView.tsx:475-476):Keinconst paperScaleForGl = (v: ViewBox): number => paperScaleRef.current ?? scaleFromView(v, svgRef.current) ?? 100;hairline-Zweig. Egal oblineMode"display" oder "print" ist, hier kommt immer ein echter 1:N-Papier-Nenner heraus.- Rust-Seite (
gpu.rs:606-616):let mm_px = mm_to_device_px(view_box, vw, vh, self.paper_scale_n);mm_to_device_px(ortho.rs:96-99) reproduziert exaktprintStrokeVb:(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 überwidth_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ähltenlineModeder Ansicht.sceneToPrintSvg.ts:243-244: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 * mmPerMzurück in Weltmeter und dann in Papier-mm beim GEWÄHLTENopts.scaleDenominatorübersetzt — nicht beim Massstab, den die Live-Ansicht gerade zeigt (Finding 4).- Text:
sizeMmkommt unverändert ausRText.sizeMm(sceneToPrintSvg.ts:325,t.sizeMm, keine Nachskalierung) — aber die VERTIKALE Zeilenposition dieser Texte wurde intoRenderScene.tsmit einem festenSTAMP_REF_N = 100in 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:
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 viaquantizePenaufMIN_PEN_MM = 0.13mm — +43 % dicker im Druck als in der Vorschau.- Türschwenkbogen:
doorLwMm * 0.6(generatePlan.ts:902) mit dem üblichen FallbackdoorLwMm = LAYER_LINE_MM = 0.13(generatePlan.ts:354) ergibt0.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 FallbackWALL_FALLBACK_MM = 0.18(generatePlan.ts:97) ergibt das0.288 mm.quantizePen(0.288)rundet auf die nächste Stufe0.35(PEN_STEPS, da0.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):const nRef = print ? paperScale! : 100; const unitPerPt = (1 / 72) * 0.0254 * nRef * PX_PER_M;nReffolgt im Druckmodus dem TATSÄCHLICH aktivenpaperScale— der Zeilenabstand (lineGap = baseFs * 1.3,PlanView.tsx:2741) ist für jedesNkorrekt in viewBox-Einheiten, weil er direkt ausunitPerPt(dasNenthält) abgeleitet wird. - RenderScene (
toRenderScene.ts:163-165):und (const STAMP_REF_N = 100; const MM_TO_M = STAMP_REF_N / 1000;toRenderScene.ts:481-483):Hier ist der Umrechnungsfaktorconst baseH = baseMm * MM_TO_M; // Basisgröße in Modell-Metern const lineGap = baseH * 1.3;MM_TO_MFEST aufN = 100verdrahtet, unabhängig davon, mit welchemNder WASM-Viewport oder der PDF-Export tatsächlich rendert (paper_scale_ningpu.rs,opts.scaleDenominatorinsceneToPrintSvg.ts). BeiN = 100sind beide Formeln identisch (das war offenbar der Verifikationsfall in382771b); bei jedem anderenN— z. B. 1:50 — driftet der vertikale Zeilenabstand der Stempel-Mehrzeiler proportional zum VerhältnisN_wahr/100auseinander, während die einzelne Zeilenhöhe (sizeMm) selbst korrekt bleibt. Betroffen: WASM-Viewport (2c) UND PDF-Export (2d) gleichermassen (beide beziehenRTextaus derselbentoRenderScene-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:
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→ aufMIN_PEN_MMgeklemmt → 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.13mm (sceneToPrintSvg.ts:38inplanToPrintSvg.ts, äquivalentsceneToPrintSvg.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 Feldpub hairline: boolaufRenderer(Defaultfalse, nebenpaper_scale_nbeigpu.rs:422). InRenderer::render(gpu.rs:606-616): wennself.hairline,mm_pxNICHT ausmm_to_device_px(...)berechnen, sondern auf einen konstanten Geräte-px-Wert setzen (z. B.1.0), UND dafür sorgen, dass der Shaderwidth_mmin 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 neueshairline: u32-Feld in die pro-Frame-Uniform (Globals,shaders.rs:20-23undArcGlobals), im Fragment-/Vertex-Shader (shaders.rs:91,LINE_WGSL;shaders.rs:209,ARC_WGSL) perselect(...)auf einen festen1.0-px-Wert umschalten stattmax(0.6, stroke_px * stroke_scale). src-tauri/render2d/src/web.rs: neue Methodeset_hairline(&mut self, on: bool)nebenset_paper_scale/set_text_scale(web.rs:146-155-Nachbarschaft).src/plan/useWasmPlanRenderer.ts:render()(useWasmPlanRenderer.ts:131-155) um einenhairline: boolean-Parameter erweitern,r.set_hairline(hairline)vorr.render()aufrufen.src/plan/useGlPlanRenderer.ts: analog —render()(Signatur aktuell(viewBox, paperScaleN=100)) umhairlineerweitern; im WebGL2-Shader-Uniform-Pfad (glPlanRender.ts:159-165,213-215)mmToDevicePxbeihairline===truedurch einen konstanten Wert ersetzen und im Fragment-Shader denselbenselect-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 dreirenderGl(...)-Aufrufstellen (PlanView.tsx:494,502,1080) zusätzlichhairlinedurchreichen: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, exportiertPEN_STEPS,MIN_PEN_MM,quantizePen), vonsceneToPrintSvg.tsUND vonPlanView.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 (intoRenderScene.ts, aufwidthMmjederRLine/ROutline/RPolyline/RArc, ausgenommenwidthScreen-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 jedemOUTLINE_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 einerPEN_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.mjsvergleicht?gl=0gegen?engine=wasmrein visuell (Screenshot-Diff von Auge,probe-parity-svg.pngvs.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.rsvergleicht die Demo-Szene Pixel-für-Pixel gegentests/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:
- Für eine feste Test-Szene (idealerweise die bestehende
demo::demo_sceneaussrc-tauri/render2d/src/demo.rs, die bereits sowohl einenwidth_mm- als auch einenwidth_screen-Strich enthält,demo.rs:34,49-50) bei mehrerenN(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 identischempaper_scale_nrendert 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.
- den PDF-Export erzeugt und via
- 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 (?glohne=0, App-Default) als auch für WASM (?engine=wasm). probe-engine-parity.mjsum eine tatsächliche Pixel-Differenz-Metrik erweitert (statt nur Kindanzahl/Warnungen zu loggen wie aktuellprobe-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.