diff --git a/scripts/probe-engine-parity.mjs b/scripts/probe-engine-parity.mjs new file mode 100644 index 0000000..4794de7 --- /dev/null +++ b/scripts/probe-engine-parity.mjs @@ -0,0 +1,60 @@ +// Vergleicht den SVG-Referenzpfad gegen den nativen render2d-WASM/WebGPU-Pfad +// (?engine=wasm): lädt die App einmal ohne Engine-Param (SVG) und einmal mit +// ?engine=wasm, gleicher Viewport/Zoom, schießt je einen Screenshot. Prüft +// zusätzlich, ob WebGPU verfügbar ist und der native Renderer tatsächlich +// initialisiert hat (statt still auf SVG zurückzufallen). + +import puppeteer from "puppeteer"; + +const BASE = process.env.PROBE_URL || "http://localhost:5187/"; + +const browser = await puppeteer.launch({ + headless: "new", + args: [ + "--no-sandbox", + "--enable-unsafe-webgpu", + "--enable-features=Vulkan", + "--use-angle=vulkan", + "--ignore-gpu-blocklist", + ], +}); + +async function shoot(url, path, tag) { + const page = await browser.newPage(); + await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 }); + const logs = []; + page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); + page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + try { + await page.goto(url, { waitUntil: "networkidle0", timeout: 30000 }); + } catch (e) { + logs.push(`[GOTO] ${e.message}`); + } + // Warten, bis Plan + evtl. WASM/WebGPU-Renderer initialisiert und gezeichnet hat. + await new Promise((r) => setTimeout(r, 2500)); + + const info = await page.evaluate(() => { + const svg = document.querySelector(".plan-svg"); + const canvases = document.querySelectorAll("canvas"); + return { + hasGpu: typeof navigator !== "undefined" && "gpu" in navigator, + hasSvg: !!svg, + svgChildren: svg ? svg.querySelectorAll("path,polygon,polyline,line,rect,text").length : 0, + canvasCount: canvases.length, + }; + }); + + await page.screenshot({ path }); + console.log(`\n=== ${tag} (${url}) ===`); + console.log(JSON.stringify(info, null, 2)); + const warns = logs.filter((l) => /warn|error|PAGEERROR/i.test(l)); + console.log("Warnungen/Fehler:", warns.length ? "\n" + warns.join("\n") : "(keine)"); + await page.close(); + return info; +} + +await shoot(BASE + "?gl=0", "scripts/probe-parity-svg.png", "SVG-Referenzpfad"); +await shoot(BASE + "?engine=wasm", "scripts/probe-parity-wasm.png", "WASM/WebGPU-Engine"); + +await browser.close(); +console.log("\nScreenshots: scripts/probe-parity-svg.png vs scripts/probe-parity-wasm.png"); diff --git a/src-tauri/render2d/src/demo.rs b/src-tauri/render2d/src/demo.rs index 02ff947..8d2eb28 100644 --- a/src-tauri/render2d/src/demo.rs +++ b/src-tauri/render2d/src/demo.rs @@ -32,10 +32,12 @@ pub fn demo_scene() -> Scene { FillPolygon { pts: l.clone(), color: wall_grey, + z: 0, }, FillPolygon { pts: room.clone(), color: room_blue, + z: 2, }, ], outlines: vec![ @@ -44,12 +46,14 @@ pub fn demo_scene() -> Scene { color: ink, width_mm: 0.35, dash: None, + z: 1, }, Outline { pts: room, color: ink, width_mm: 0.18, dash: None, + z: 3, }, ], polylines: vec![], @@ -60,6 +64,7 @@ pub fn demo_scene() -> Scene { color: ink, width_mm: 0.25, dash: None, + z: 4, }], // Ein Raumstempel-artiger Text (echte Glyphen via Atlas), mittig im Raum. texts: vec![Text { diff --git a/src-tauri/render2d/src/gpu.rs b/src-tauri/render2d/src/gpu.rs index ceed5c1..defe206 100644 --- a/src-tauri/render2d/src/gpu.rs +++ b/src-tauri/render2d/src/gpu.rs @@ -19,7 +19,7 @@ use wgpu::util::DeviceExt; use crate::ortho::{compute_ortho_matrix, corrected_view_box, meet_scale, mm_to_device_px, Mat4}; use crate::shaders::{ARC_WGSL, FILL_WGSL, LINE_WGSL}; -use crate::tessellate::{compile_scene, to_screen, ArcInstanceData, GpuGeometry, MAX_ARC_DASH}; +use crate::tessellate::{compile_scene, to_screen, ArcInstanceData, BatchKind, GpuGeometry, MAX_ARC_DASH}; use crate::types::{Scene, Text, TextAlign, ViewBox}; /// MSAA-Faktor: 4x Multisampling fuer glatte Linien-/Fuellkanten (wie im Browser). @@ -170,6 +170,16 @@ pub struct Renderer { pub clear_color: wgpu::Color, /// Papier-Massstab-Nenner (1:N) fuer echte mm-Strichbreiten. pub paper_scale_n: f32, + /// Papier-Massstab-Nenner (1:N) NUR fuer die Text-Schriftgroesse — bewusst + /// vom geometrischen `paper_scale_n` ENTKOPPELT: der SVG-Referenzpfad haelt + /// die Raumstempel-Schriftgroesse im normalen Anzeigemodus auf einer festen + /// Referenzskala 1:100 (lesbar, unabhaengig vom zufaelligen Einpass-Massstab + /// der aktuellen Ansicht) und wechselt erst im Druck-Modus auf den echten + /// gemessenen Massstab; siehe PlanView.tsx `textScaleForGl`/case "text". Ohne + /// diese Trennung wuerde Text hier mit dem live aus dem Ausschnitt + /// abgeleiteten `paper_scale_n` skalieren (z.B. 1:32 statt 1:100) und damit + /// sichtbar kleiner/groesser als im SVG-Pfad erscheinen. + pub text_scale_n: f32, } /// Ein Uniform-Puffer, der `capacity` Globals-Bloecke fasst, plus Bind-Group. @@ -410,6 +420,7 @@ impl Renderer { a: 1.0, }, paper_scale_n: 100.0, + text_scale_n: 100.0, } } @@ -499,7 +510,18 @@ impl Renderer { } let cache = glyphon::Cache::new(device); let viewport = glyphon::Viewport::new(device, &cache); - let mut atlas = glyphon::TextAtlas::new(device, queue, &cache, self.format); + // ColorMode::Web statt Default (Accurate): die Surface ist non-sRGB und + // ALLE Geometrie-Passes schreiben ihre sRGB-Farbwerte unkonvertiert — + // Accurate wuerde die Textfarbe nach linear wandeln und die Glyphen + // deutlich dunkler zeichnen als der SVG-Referenzpfad (z.B. #5a7a9a -> + // (26,50,82) statt (90,122,154)). + let mut atlas = glyphon::TextAtlas::with_color_mode( + device, + queue, + &cache, + self.format, + glyphon::ColorMode::Web, + ); let renderer = glyphon::TextRenderer::new( &mut atlas, device, @@ -592,6 +614,9 @@ impl Renderer { let (vw, vh) = (viewport.0 as f32, viewport.1 as f32); let proj: Mat4 = compute_ortho_matrix(view_box, vw, vh); let mm_px = mm_to_device_px(view_box, vw, vh, self.paper_scale_n); + // Eigene mm->px-Umrechnung fuer Text (siehe `text_scale_n`-Doku): gleiche + // Formel, aber am Text-Referenz-Massstab statt am Geometrie-Massstab. + let text_mm_px = mm_to_device_px(view_box, vw, vh, self.text_scale_n); // Geraete-px je Bildschirm-Einheit (== meet-Skala) — der Bogen-Shader braucht // beides: px_per_screen fuer Radial-/Kappen-/Dash-AA, stroke_scale fuer die // Papier-mm-Breite (identisch zu den Linien). @@ -609,27 +634,38 @@ impl Renderer { }), ); - // Alle Globals-Bloecke der Reihenfolge nach (erst Fuell-, dann Linien-Batches) - // sammeln, den Puffer einmal schreiben, danach nur noch dynamisch binden. + // Alle Globals-Bloecke in MALER-Reihenfolge (`draw_sequence`, interleaved + // Fuell-/Linien-Batches) sammeln, den Puffer einmal schreiben, danach nur + // noch dynamisch binden — Reihenfolge identisch zur Draw-Schleife unten. let mut blocks: Vec = Vec::new(); if let Some(scene) = &self.scene { - for b in &scene.geo.fill_batches { - blocks.push(Globals { - view_proj: proj, - color: b.color, - viewport_px: [vw, vh], - stroke_px: 0.0, - stroke_scale: mm_px, - }); - } - for b in &scene.geo.line_batches { - blocks.push(Globals { - view_proj: proj, - color: b.color, - viewport_px: [vw, vh], - stroke_px: b.stroke_mm, - stroke_scale: mm_px, - }); + for &(kind, idx) in &scene.geo.draw_sequence { + match kind { + BatchKind::Fill => { + let b = &scene.geo.fill_batches[idx as usize]; + blocks.push(Globals { + view_proj: proj, + color: b.color, + viewport_px: [vw, vh], + stroke_px: 0.0, + stroke_scale: mm_px, + }); + } + BatchKind::Line => { + let b = &scene.geo.line_batches[idx as usize]; + blocks.push(Globals { + view_proj: proj, + color: b.color, + viewport_px: [vw, vh], + stroke_px: b.stroke_mm, + // Schraffur-Striche (`width_screen`) sind an die + // Musterkachel gekoppelt (Bildschirm-Einheiten, + // zoom-skalierend wie der SVG-``-Strich) — + // Geraete-px = Breite * meet-Skala statt * mm->px. + stroke_scale: if b.width_screen { px_per_screen } else { mm_px }, + }); + } + } } } @@ -665,7 +701,7 @@ impl Renderer { let attrs = glyphon::Attrs::new() .family(glyphon::Family::Name(&family)); for t in &scene.texts { - let font_px = t.size_mm * mm_px; + let font_px = t.size_mm * text_mm_px; // Unlesbar kleine Glyphen ueberspringen (spart Shaping/Atlas). if !(font_px >= 1.0) || t.content.is_empty() { continue; @@ -794,33 +830,46 @@ impl Renderer { if let (Some(scene), Some(u)) = (&self.scene, &self.uniform) { let stride = self.uniform_stride as u32; - let mut block = 0u32; // Laufindex ueber Fuell- dann Linien-Batches - // 1) Fuellungen. - if let (Some(vbo), Some(ibo)) = (&scene.fill_vbo, &scene.fill_ibo) { - pass.set_pipeline(&self.fill_pipeline); - pass.set_vertex_buffer(0, vbo.slice(..)); - pass.set_index_buffer(ibo.slice(..), wgpu::IndexFormat::Uint32); - for b in &scene.geo.fill_batches { - pass.set_bind_group(0, &u.bind_group, &[block * stride]); - let start = b.start_index; - pass.draw_indexed(start..start + b.index_count, 0, 0..1); - block += 1; - } - } else { - block += scene.geo.fill_batches.len() as u32; - } - - // 2) Linien (Umrisse + freie Linien). - if let (Some(vbo), Some(ibo)) = (&scene.line_vbo, &scene.line_ibo) { - pass.set_pipeline(&self.line_pipeline); - pass.set_vertex_buffer(0, vbo.slice(..)); - pass.set_index_buffer(ibo.slice(..), wgpu::IndexFormat::Uint32); - for b in &scene.geo.line_batches { - pass.set_bind_group(0, &u.bind_group, &[block * stride]); - let start = b.start_index; - pass.draw_indexed(start..start + b.index_count, 0, 0..1); - block += 1; + // Geometrie in MALER-Reihenfolge (`draw_sequence`): Fuell- und + // Linien-Batches interleaved, Pipeline-/Puffer-Wechsel nur beim + // Wechsel der Batch-Art. Nur so deckt eine spaetere Fuellung + // einen frueheren Strich ab (SVG-Paritaet, Primitive-Reihenfolge). + let mut bound: Option = None; + for (block, &(kind, idx)) in scene.geo.draw_sequence.iter().enumerate() { + match kind { + BatchKind::Fill => { + let (Some(vbo), Some(ibo)) = (&scene.fill_vbo, &scene.fill_ibo) + else { + continue; + }; + if bound != Some(BatchKind::Fill) { + pass.set_pipeline(&self.fill_pipeline); + pass.set_vertex_buffer(0, vbo.slice(..)); + pass.set_index_buffer(ibo.slice(..), wgpu::IndexFormat::Uint32); + bound = Some(BatchKind::Fill); + } + let b = &scene.geo.fill_batches[idx as usize]; + pass.set_bind_group(0, &u.bind_group, &[block as u32 * stride]); + let start = b.start_index; + pass.draw_indexed(start..start + b.index_count, 0, 0..1); + } + BatchKind::Line => { + let (Some(vbo), Some(ibo)) = (&scene.line_vbo, &scene.line_ibo) + else { + continue; + }; + if bound != Some(BatchKind::Line) { + pass.set_pipeline(&self.line_pipeline); + pass.set_vertex_buffer(0, vbo.slice(..)); + pass.set_index_buffer(ibo.slice(..), wgpu::IndexFormat::Uint32); + bound = Some(BatchKind::Line); + } + let b = &scene.geo.line_batches[idx as usize]; + pass.set_bind_group(0, &u.bind_group, &[block as u32 * stride]); + let start = b.start_index; + pass.draw_indexed(start..start + b.index_count, 0, 0..1); + } } } } diff --git a/src-tauri/render2d/src/lib.rs b/src-tauri/render2d/src/lib.rs index e78394b..11ea7ca 100644 --- a/src-tauri/render2d/src/lib.rs +++ b/src-tauri/render2d/src/lib.rs @@ -30,8 +30,8 @@ pub mod web; pub use demo::{demo_scene, initial_view_box}; pub use ortho::{compute_ortho_matrix, meet_scale, mm_to_device_px, Mat4}; pub use tessellate::{ - arc_screen_params, compile_scene, prepare_arc_dash, triangulate, ArcInstanceData, GpuGeometry, - MAX_ARC_DASH, PX_PER_M, + arc_screen_params, compile_scene, prepare_arc_dash, triangulate, ArcInstanceData, BatchKind, + GpuGeometry, MAX_ARC_DASH, PX_PER_M, }; pub use types::{Arc, FillPolygon, Line, Outline, Point, Rgba, Scene, Text, TextAlign, ViewBox}; diff --git a/src-tauri/render2d/src/tessellate.rs b/src-tauri/render2d/src/tessellate.rs index 3cd2321..b0467ff 100644 --- a/src-tauri/render2d/src/tessellate.rs +++ b/src-tauri/render2d/src/tessellate.rs @@ -218,6 +218,7 @@ fn stroke_dashed_or_solid( closed: bool, color: Rgba, stroke_mm: f32, + width_screen: bool, dash: Option<&[f32]>, bounds: &mut Bounds, ) { @@ -225,11 +226,11 @@ fn stroke_dashed_or_solid( Some(d) if !d.is_empty() => { for piece in split_dash(pts, d) { if piece.len() >= 2 { - geo.stroke_polyline(&piece, false, color, stroke_mm, bounds); + geo.stroke_polyline(&piece, false, color, stroke_mm, width_screen, bounds); } } } - _ => geo.stroke_polyline(pts, closed, color, stroke_mm, bounds), + _ => geo.stroke_polyline(pts, closed, color, stroke_mm, width_screen, bounds), } } @@ -351,6 +352,13 @@ pub fn triangulate(pts: &[Point]) -> Vec { tris } +/// Pipeline-Art eines Batches in der Maler-Reihenfolge (`GpuGeometry::draw_sequence`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BatchKind { + Fill, + Line, +} + /// Ein zusammenhaengender Zeichen-Batch (Index-Bereich) mit einer Farbe. #[derive(Debug, Clone)] pub struct FillBatch { @@ -365,8 +373,12 @@ pub struct LineBatch { pub start_index: u32, pub index_count: u32, pub color: Rgba, - /// ECHTE Strichbreite in Papier-Millimeter. + /// ECHTE Strichbreite in Papier-Millimeter — bzw. in Bildschirm-Einheiten + /// (viewBox-px), wenn `width_screen` gesetzt ist (Schraffur-Muster). pub stroke_mm: f32, + /// true: `stroke_mm` traegt Bildschirm-Einheiten (zoom-skalierend); der + /// Renderer nutzt dann die meet-Skala statt der mm->px-Umrechnung. + pub width_screen: bool, } /// GPU-fertige Geometrie: getrennte Puffer fuer Flaechen (Poche) und Striche. @@ -385,6 +397,12 @@ pub struct GpuGeometry { /// Analytisch gezeichnete Boegen (je EINE Instanz, kein Segment-Mesh mehr): /// die GPU rendert sie pro Frame exakt rund per SDF-Fragment-Shader. pub arcs: Vec, + /// Maler-Reihenfolge ueber BEIDE Batch-Arten hinweg: (Art, Index in + /// `fill_batches` bzw. `line_batches`). Der Renderer zeichnet exakt in dieser + /// Reihenfolge (Pipeline-Wechsel pro Eintrag) — nur so deckt eine spaetere + /// Fuellung (Wand-Poche) einen frueheren Strich (Raum-Umriss) ab, wie im + /// SVG-Referenzpfad (Primitive strikt in Array-Reihenfolge). + pub draw_sequence: Vec<(BatchKind, u32)>, /// Modell-Bounds (Meter) fuer Debug/Einpassen; nicht render-kritisch. pub bounds: [f32; 4], // [min_x, min_y, max_x, max_y] } @@ -396,12 +414,18 @@ fn same_rgba(a: Rgba, b: Rgba) -> bool { impl GpuGeometry { /// Ordnungserhaltendes Batch-Merging fuer Fuellungen: aufeinanderfolgende - /// Indizes gleicher Farbe werden zu EINEM Draw-Call zusammengefasst. + /// Indizes gleicher Farbe werden zu EINEM Draw-Call zusammengefasst. Gemergt + /// wird nur, wenn der letzte Eintrag der MALER-Reihenfolge (`draw_sequence`) + /// derselbe Fuell-Batch ist — sonst wuerde ein dazwischenliegender Linien- + /// Batch uebersprungen und die Z-Reihenfolge verfaelscht. fn add_fill_batch(&mut self, count: u32, color: Rgba) { - if let Some(last) = self.fill_batches.last_mut() { - if same_rgba(last.color, color) { - last.index_count += count; - return; + let last_idx = self.fill_batches.len().wrapping_sub(1) as u32; + if self.draw_sequence.last() == Some(&(BatchKind::Fill, last_idx)) { + if let Some(last) = self.fill_batches.last_mut() { + if same_rgba(last.color, color) { + last.index_count += count; + return; + } } } self.fill_batches.push(FillBatch { @@ -409,13 +433,21 @@ impl GpuGeometry { index_count: count, color, }); + self.draw_sequence + .push((BatchKind::Fill, (self.fill_batches.len() - 1) as u32)); } - fn add_line_batch(&mut self, count: u32, color: Rgba, stroke_mm: f32) { - if let Some(last) = self.line_batches.last_mut() { - if last.stroke_mm == stroke_mm && same_rgba(last.color, color) { - last.index_count += count; - return; + fn add_line_batch(&mut self, count: u32, color: Rgba, stroke_mm: f32, width_screen: bool) { + let last_idx = self.line_batches.len().wrapping_sub(1) as u32; + if self.draw_sequence.last() == Some(&(BatchKind::Line, last_idx)) { + if let Some(last) = self.line_batches.last_mut() { + if last.stroke_mm == stroke_mm + && last.width_screen == width_screen + && same_rgba(last.color, color) + { + last.index_count += count; + return; + } } } self.line_batches.push(LineBatch { @@ -423,7 +455,10 @@ impl GpuGeometry { index_count: count, color, stroke_mm, + width_screen, }); + self.draw_sequence + .push((BatchKind::Line, (self.line_batches.len() - 1) as u32)); } /// Zeichnet einen zusammenhaengenden Linienzug (Modell-Meter) als EINEN @@ -439,6 +474,7 @@ impl GpuGeometry { closed: bool, color: Rgba, stroke_mm: f32, + width_screen: bool, bounds: &mut Bounds, ) { // Auf Bildschirm-Raum abbilden + aufeinanderfolgende Duplikate entfernen. @@ -527,7 +563,7 @@ impl GpuGeometry { self.line_idx .extend_from_slice(&[a, a + 1, b, a + 1, b + 1, b]); } - self.add_line_batch((segs * 6) as u32, color, stroke_mm); + self.add_line_batch((segs * 6) as u32, color, stroke_mm, width_screen); } } @@ -583,32 +619,63 @@ pub fn compile_scene(scene: &Scene) -> GpuGeometry { let mut geo = GpuGeometry::default(); let mut bounds = Bounds::new(); - // 1) Gefuellte Polygone. - for poly in &scene.fills { - compile_fill(&mut geo, poly, &mut bounds); + // Maler-Reihenfolge herstellen: alle Elemente (Fuellungen, Umrisse, offene + // Polylinien, freie Linien) stabil nach ihrem `z` sortieren. Bei gleichem z + // (insb. Alt-Szenen mit z=0 ueberall) gilt die bisherige Kategorien- + // Reihenfolge Fuellungen -> Umrisse -> Polylinien -> Linien (`seq`). + enum Item<'a> { + Fill(&'a crate::types::FillPolygon), + Outline(&'a crate::types::Outline), + Poly(&'a crate::types::Polyline), + Line(&'a Line), + } + let mut items: Vec<(u32, usize, Item)> = Vec::with_capacity( + scene.fills.len() + scene.outlines.len() + scene.polylines.len() + scene.lines.len(), + ); + let mut seq = 0usize; + for f in &scene.fills { + items.push((f.z, seq, Item::Fill(f))); + seq += 1; } - // 2) Umrisse (geschlossene Ringe als EIN gehrter Streifen, inkl. Schluss-Kante - // — ausser bei Dash, siehe `stroke_dashed_or_solid`). for o in &scene.outlines { - if o.width_mm > 0.0 && o.pts.len() >= 2 { - stroke_dashed_or_solid(&mut geo, &o.pts, true, o.color, o.width_mm, o.dash.as_deref(), &mut bounds); - } + items.push((o.z, seq, Item::Outline(o))); + seq += 1; } - // 3) Offene Polylinien (verbundene Umrisskanten / 2D-Zeichnungszuege) als EIN - // gehrter Streifen — die inneren Ecken bekommen so eine Gehrung statt - // Stumpfkappen (closed=false). for pl in &scene.polylines { - if pl.width_mm > 0.0 && pl.pts.len() >= 2 { - stroke_dashed_or_solid(&mut geo, &pl.pts, false, pl.color, pl.width_mm, pl.dash.as_deref(), &mut bounds); + items.push((pl.z, seq, Item::Poly(pl))); + seq += 1; + } + for l in &scene.lines { + items.push((l.z, seq, Item::Line(l))); + seq += 1; + } + items.sort_by_key(|(z, s, _)| (*z, *s)); + + for (_, _, item) in &items { + match item { + // Gefuellte Polygone. + Item::Fill(poly) => compile_fill(&mut geo, poly, &mut bounds), + // Umrisse (geschlossene Ringe als EIN gehrter Streifen, inkl. + // Schluss-Kante — ausser bei Dash, siehe `stroke_dashed_or_solid`). + Item::Outline(o) => { + if o.width_mm > 0.0 && o.pts.len() >= 2 { + stroke_dashed_or_solid(&mut geo, &o.pts, true, o.color, o.width_mm, false, o.dash.as_deref(), &mut bounds); + } + } + // Offene Polylinien (verbundene Umrisskanten / 2D-Zeichnungszuege) als + // EIN gehrter Streifen — innere Ecken gehrt statt Stumpfkappen. + Item::Poly(pl) => { + if pl.width_mm > 0.0 && pl.pts.len() >= 2 { + stroke_dashed_or_solid(&mut geo, &pl.pts, false, pl.color, pl.width_mm, pl.width_screen, pl.dash.as_deref(), &mut bounds); + } + } + // Freie Einzel-Linien (Tuerblaetter, Referenzlinien). + Item::Line(l) => compile_line(&mut geo, l, &mut bounds), } } - // 4) Freie Einzel-Linien (Tuerblaetter, Referenzlinien). - for l in &scene.lines { - compile_line(&mut geo, l, &mut bounds); - } - // 5) Kreisboegen: NICHT mehr in Segmente zerlegt — je Bogen eine analytische - // Instanz (`ArcInstanceData`), die die GPU per SDF-Fragment-Shader exakt - // rund rendert (siehe `gpu::Renderer` Arc-Pipeline). + // Kreisboegen: NICHT in Segmente zerlegt — je Bogen eine analytische Instanz + // (`ArcInstanceData`), die die GPU per SDF-Fragment-Shader exakt rund rendert + // (eigene Pipeline, nach der uebrigen Geometrie — Tuerschwenk liegt oben). for a in &scene.arcs { compile_arc(&mut geo, a, &mut bounds); } @@ -667,7 +734,7 @@ fn compile_arc(geo: &mut GpuGeometry, a: &Arc, bounds: &mut Bounds) { fn compile_line(geo: &mut GpuGeometry, l: &Line, bounds: &mut Bounds) { let w = if l.width_mm > 0.0 { l.width_mm } else { 0.18 }; - stroke_dashed_or_solid(geo, &[l.a, l.b], false, l.color, w, l.dash.as_deref(), bounds); + stroke_dashed_or_solid(geo, &[l.a, l.b], false, l.color, w, false, l.dash.as_deref(), bounds); } #[cfg(test)] @@ -815,6 +882,7 @@ mod tests { color: [0.0, 0.0, 0.0, 1.0], width_mm: 0.2, dash: None, + z: 0, }], ..Default::default() }; @@ -824,6 +892,7 @@ mod tests { color: [0.0, 0.0, 0.0, 1.0], width_mm: 0.2, dash: Some(vec![10.0, 10.0]), + z: 0, }], ..Default::default() }; diff --git a/src-tauri/render2d/src/types.rs b/src-tauri/render2d/src/types.rs index ba8b8be..d8a1d9c 100644 --- a/src-tauri/render2d/src/types.rs +++ b/src-tauri/render2d/src/types.rs @@ -22,6 +22,13 @@ pub struct FillPolygon { pub pts: Vec, /// Fuellfarbe (RGBA 0..1). pub color: Rgba, + /// Maler-Reihenfolge (kleiner = frueher/unten). Der SVG-Referenzpfad zeichnet + /// die Primitive strikt in Array-Reihenfolge (Fuellung/Umriss INTERLEAVED) — + /// ein spaeteres Fuellpolygon (Wand-Poche) deckt z.B. den frueheren Raum- + /// Umriss ab. `compile_scene` stellt diese Reihenfolge kategorien-uebergreifend + /// wieder her. Default 0 (Alt-Szenen: bisherige Kategorien-Reihenfolge). + #[serde(default)] + pub z: u32, } /// Ein Liniensegment mit echter Papier-mm-Breite. @@ -39,6 +46,9 @@ pub struct Line { /// Strichmuster in Papier-mm; None/leer = durchgezogen (siehe `tessellate::split_dash`). #[serde(default)] pub dash: Option>, + /// Maler-Reihenfolge (kleiner = frueher/unten), siehe `FillPolygon::z`. + #[serde(default)] + pub z: u32, } /// Ein geschlossenes Polygon als Umriss (nur Kanten, keine Fuellung) — bequemer @@ -58,6 +68,9 @@ pub struct Outline { /// ueber die Dash-Luecke hinweg, siehe `tessellate::split_dash`). #[serde(default)] pub dash: Option>, + /// Maler-Reihenfolge (kleiner = frueher/unten), siehe `FillPolygon::z`. + #[serde(default)] + pub z: u32, } /// Ein OFFENER Linienzug mit echter Papier-mm-Breite. Anders als `Outline` @@ -71,12 +84,23 @@ pub struct Polyline { pub pts: Vec, /// Strichfarbe (RGBA 0..1). pub color: Rgba, - /// Strichbreite in echten Papier-Millimetern. + /// Strichbreite in echten Papier-Millimetern — bzw. in BILDSCHIRM-EinheITEN + /// (viewBox-px), wenn `width_screen` gesetzt ist. #[serde(rename = "widthMm")] pub width_mm: f32, + /// true: `width_mm` ist eine Breite in BILDSCHIRM-Einheiten (viewBox-px), + /// die MIT dem Zoom skaliert — wie die SVG-Schraffur-Muster: deren Strich + /// (`hatchStrokePx`) ist an die Musterkachel gekoppelt, nicht an Papier-mm. + /// Der Renderer rechnet dann Geraete-px = Breite * meet-Skala (statt + /// * mm->px). Default false (echte Papier-mm). + #[serde(default, rename = "widthScreen")] + pub width_screen: bool, /// Strichmuster in Papier-mm; None/leer = durchgezogen. #[serde(default)] pub dash: Option>, + /// Maler-Reihenfolge (kleiner = frueher/unten), siehe `FillPolygon::z`. + #[serde(default)] + pub z: u32, } /// Ein Kreisbogen (kuerzerer Sweep von `from` nach `to` um `center`) in Modell- diff --git a/src-tauri/render2d/src/web.rs b/src-tauri/render2d/src/web.rs index 03e26db..2e91da7 100644 --- a/src-tauri/render2d/src/web.rs +++ b/src-tauri/render2d/src/web.rs @@ -148,6 +148,14 @@ impl WebPlanRenderer { } } + /// Papier-Massstab-Nenner (1:N) NUR fuer die Text-Schriftgroesse setzen — + /// entkoppelt von `set_paper_scale` (siehe `Renderer::text_scale_n`-Doku). + pub fn set_text_scale(&mut self, n: f32) { + if n > 0.0 { + self.renderer.text_scale_n = n; + } + } + /// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer). pub fn resize(&mut self, width: u32, height: u32) { let (w, h) = (width.max(1), height.max(1)); diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 686caa4..e168b93 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -475,11 +475,23 @@ export const PlanView = forwardRef( const paperScaleForGl = (v: ViewBox): number => paperScaleRef.current ?? scaleFromView(v, svgRef.current) ?? 100; + // Referenz-Nenner (1:N) NUR für die Text-Schriftgröße der Engine — spiegelt + // EXAKT die SVG-Formel (case "text" unten, gleiche `print`-Bedingung): im + // normalen Anzeigemodus fest auf die Referenzskala 1:100 (Raumstempel bleibt + // lesbar, unabhängig vom zufälligen Einpass-Massstab der Ansicht), erst im + // Druck-Modus der echte gemessene `paperScale`. BEWUSST NICHT `paperScaleForGl` + // (das dort verwendete live abgeleitete N wäre für Text willkürlich/unlesbar + // und würde vom SVG-Referenzpfad abweichen — siehe Doku in `gpu::Renderer`). + const textScaleForGl = (): number => { + const print = !hairline && paperScale != null && paperScale > 0; + return print ? paperScale! : 100; + }; + // GL-Geometrie beim Plan-Wechsel neu tessellieren + sofort neu zeichnen. useEffect(() => { if (!useGpuRenderer) return; updateGeometry(plan); - renderGl(viewRef.current, paperScaleForGl(viewRef.current)); + renderGl(viewRef.current, paperScaleForGl(viewRef.current), textScaleForGl()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [useGpuRenderer, plan, updateGeometry, renderGl]); @@ -487,7 +499,7 @@ export const PlanView = forwardRef( // keine Re-Tessellierung. Auch bei paperScale-Wechsel neu (Strichbreiten). useEffect(() => { if (useGpuRenderer) { - renderGl(view, paperScaleForGl(view)); + renderGl(view, paperScaleForGl(view), textScaleForGl()); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [useGpuRenderer, renderGl, view, paperScale]); @@ -1065,7 +1077,7 @@ export const PlanView = forwardRef( if (!v || !v0) return; viewRef.current = v; // abgeleitete Reads (Snap-Toleranz etc.) frisch halten applySvgTransform(v0, v); // SVG per GPU-Composit (kein Re-Raster) - renderGl(v, paperScaleForGl(v)); // GL exakt auf V (Strich in echten Papier-mm) + renderGl(v, paperScaleForGl(v), textScaleForGl()); // GL exakt auf V (Strich in echten Papier-mm) }; /** Pan-/Zoom-Ziel imperativ anwenden (rAF-gedrosselt), ohne React-Render. */ const enqueuePan = (v: ViewBox) => { diff --git a/src/plan/glPlan/glPlanHatch.ts b/src/plan/glPlan/glPlanHatch.ts index 60945a7..140ddd1 100644 --- a/src/plan/glPlan/glPlanHatch.ts +++ b/src/plan/glPlan/glPlanHatch.ts @@ -240,10 +240,12 @@ export function hatchLineFamily( const nrm: Vec2 = { x: -dir.y, y: dir.x }; // Linien-Normale (Scan-Richtung) // Rechtwinklige Projektion aller Ecken auf die Normale → Abdeckungsbereich. - const center: Vec2 = { - x: (bb.minX + bb.maxX) / 2, - y: (bb.minY + bb.maxY) / 2, - }; + // ANKER = MODELL-URSPRUNG (nicht die Polygon-Mitte): der SVG-`` ist + // `userSpaceOnUse`, sein Kachelgitter liegt global im viewBox-Raum fest + // (Ursprung (0,0) = Modell-Ursprung). Nur mit demselben Anker liegen die + // Musterlinien deckungsgleich zum SVG-Referenzpfad — und benachbarte Polygone + // teilen dieselbe Muster-Phase statt je eigener (bbox-abhängiger) Versätze. + const center: Vec2 = { x: 0, y: 0 }; let dMin = Infinity, dMax = -Infinity; for (const p of poly) { const d = (p.x - center.x) * nrm.x + (p.y - center.y) * nrm.y; @@ -415,24 +417,24 @@ export function buildHatchRuns(poly: Vec2[], hatch: HatchRender): HatchRun[] { * endet exakt an den Polygonkanten (even-odd-Clipping, konkav-fähig). */ function insulationRuns(poly: Vec2[], hatch: HatchRender, scale: number): HatchRun[] { - const bb = bbox(poly); const rot = toModelAngleRad(hatch.angle); // Muster-Parameter in Metern (aus den SVG-px-Werten der Kachel). const wavelength = 14 * scale * PX_TO_M; // Kachelbreite const rowGap = 10 * scale * PX_TO_M; // Kachelhöhe → Zeilenabstand - const amplitude = 3 * scale * PX_TO_M; // Wellen-Amplitude (weich) // Lokales Muster-Koordinatensystem: u entlang der Welle, v quer (Zeilen). + // ANKER = MODELL-URSPRUNG, wie beim SVG-`userSpaceOnUse`-Pattern (siehe + // hatchLineFamily): so liegen Wellen-Phase UND Zeilenraster deckungsgleich + // zum SVG-Referenzpfad und über alle Polygone hinweg in EINEM Gitter. const uDir = rotate({ x: 1, y: 0 }, rot); const vDir = rotate({ x: 0, y: 1 }, rot); // Abdeckungsbereich in (u,v) über die Polygon-Ecken bestimmen. - const center: Vec2 = { x: (bb.minX + bb.maxX) / 2, y: (bb.minY + bb.maxY) / 2 }; let uMin = Infinity, uMax = -Infinity, vMin = Infinity, vMax = -Infinity; for (const p of poly) { - const u = (p.x - center.x) * uDir.x + (p.y - center.y) * uDir.y; - const v = (p.x - center.x) * vDir.x + (p.y - center.y) * vDir.y; + const u = p.x * uDir.x + p.y * uDir.y; + const v = p.x * vDir.x + p.y * vDir.y; if (u < uMin) uMin = u; if (u > uMax) uMax = u; if (v < vMin) vMin = v; @@ -447,22 +449,38 @@ function insulationRuns(poly: Vec2[], hatch: HatchRender, scale: number): HatchR const runs: HatchRun[] = []; const toWorld = (u: number, v: number): Vec2 => ({ - x: center.x + uDir.x * u + vDir.x * v, - y: center.y + uDir.y * u + vDir.y * v, + x: uDir.x * u + vDir.x * v, + y: uDir.y * u + vDir.y * v, }); - const firstRow = Math.floor(vMin / rowGap) - 1; - const lastRow = Math.ceil(vMax / rowGap) + 1; + // Die Wellen-Mittellinie liegt in der SVG-Kachel bei y = h/2 (my = 5·scale), + // die Zeilen also auf HALBEN Rasterlinien: v = (r + 0.5)·rowGap. + const firstRow = Math.floor(vMin / rowGap - 0.5) - 1; + const lastRow = Math.ceil(vMax / rowGap - 0.5) + 1; const uStart = Math.floor((uMin - wavelength) / du) * du; const uEnd = uMax + wavelength; + // EXAKT die SVG-Wellenform: die Kachel zeichnet zwei quadratische Béziers + // `M0 5s Q 3.5s -1s 7s 5s Q 10.5s 11s 14s 5s`. Deren x(t) ist LINEAR (7s·t je + // Halbwelle), die Auslenkung eine Parabel ±12s·t(1−t) (Scheitel ±3s) — also + // Halbwellen-Parabeln statt einer Sinuskurve (flachere Kämme, steilere + // Nulldurchgänge). Erste Halbwelle: Kamm nach OBEN (Bildschirm) = +v (Modell). + const half = wavelength / 2; + const waveAt = (u: number): number => { + const phase = ((u % wavelength) + wavelength) % wavelength; + const t = (phase % half) / half; + const mag = 12 * scale * PX_TO_M * t * (1 - t); + return phase < half ? mag : -mag; + }; + for (let r = firstRow; r <= lastRow; r++) { - const vBase = r * rowGap; + const vBase = (r + 0.5) * rowGap; // Ganze Welle als EINE Polylinie tessellieren, dann DURCHGEHEND clippen. + // Das u-Raster ist an Vielfachen von du = λ/22 ausgerichtet → die Null- + // durchgänge (Vielfache von λ/2 = 11·du) liegen exakt auf Stützpunkten. const wavePts: Vec2[] = []; for (let u = uStart; u <= uEnd + 1e-9; u += du) { - const wave = amplitude * Math.sin((2 * Math.PI * u) / wavelength); - wavePts.push(toWorld(u, vBase + wave)); + wavePts.push(toWorld(u, vBase + waveAt(u))); } for (const run of clipPolylineToPolygon(wavePts, poly)) runs.push(run); } diff --git a/src/plan/toRenderScene.ts b/src/plan/toRenderScene.ts index 5526159..cd712f2 100644 --- a/src/plan/toRenderScene.ts +++ b/src/plan/toRenderScene.ts @@ -31,18 +31,36 @@ export type RRgba = [number, number, number, number]; export interface RFill { pts: RPoint[]; color: RRgba; + /** + * Maler-Reihenfolge (kleiner = früher/unten). Der SVG-Pfad zeichnet die + * Primitive strikt in Array-Reihenfolge (Füllung/Schraffur/Umriss + * interleaved) — eine spätere Wand-Poché deckt z. B. den früheren + * Raum-Umriss ab. Der native Renderer (`compile_scene`) stellt diese + * Reihenfolge über die getrennten Szenen-Arrays hinweg wieder her. + */ + z: number; } export interface ROutline { pts: RPoint[]; color: RRgba; widthMm: number; dash?: number[] | null; + /** Maler-Reihenfolge, siehe {@link RFill.z}. */ + z: number; } export interface RPolyline { pts: RPoint[]; color: RRgba; widthMm: number; dash?: number[] | null; + /** Maler-Reihenfolge, siehe {@link RFill.z}. */ + z: number; + /** + * true: `widthMm` trägt BILDSCHIRM-Einheiten (viewBox-px, zoom-skalierend) + * statt Papier-mm — für Schraffur-Striche, deren Breite im SVG an die + * Musterkachel gekoppelt ist (`hatchStrokePx`), nicht an Papier-mm. + */ + widthScreen?: boolean; } export interface RArc { center: RPoint; @@ -59,6 +77,8 @@ export interface RLine { color: RRgba; widthMm: number; dash?: number[] | null; + /** Maler-Reihenfolge, siehe {@link RFill.z}. */ + z: number; } export type RTextAlign = "left" | "center" | "right"; /** EINE Textzeile; serde-kompatibel zu render2d::types::Text. */ @@ -82,6 +102,41 @@ export interface RScene { const DEFAULT_LINE = "#1a1a1a"; +/** + * Strichfarben/-deckkraft je CSS-Klasse — Spiegel der `.plan-svg .`-Regeln + * in styles.css: Im SVG-Pfad ÜBERSTIMMT eine CSS-`stroke`-Property das inline + * gesetzte `stroke`-Attribut (Präsentationsattribut), d. h. für diese Klassen + * gilt IMMER die CSS-Farbe. Klassen ohne CSS-`stroke` (draw2d, stair-*, + * context-line) behalten die Modell-Farbe; context-line trägt zusätzlich + * `opacity: 0.7`, wall-axis `opacity: 0.85`. + */ +const CLS_STROKE: Record = { + "door-leaf": "#2b3039", + "door-swing": "#6b7280", + "door-frame": "#2b3039", + "window-glass": "#4a86c7", + "wall-axis": "#2f5d54", // var(--accent) des hellen Standard-Themes +}; +const CLS_OPACITY: Record = { + "wall-axis": 0.85, + "context-line": 0.7, +}; + +/** Deckkraft gedimmter (greyed) Primitive — wie GREYED_OPACITY in PlanView. */ +const GREYED_ALPHA = 0.3; + +/** Farbe mit Klassen-Deckkraft und Greyed-Dimmung multiplizieren. */ +function withOpacity(col: RRgba, cls: string | undefined, greyed: boolean | undefined): RRgba { + const o = (cls ? CLS_OPACITY[cls] ?? 1 : 1) * (greyed ? GREYED_ALPHA : 1); + return o === 1 ? col : [col[0], col[1], col[2], col[3] * o]; +} + +/** Strichfarbe einer line/arc-Primitive: CSS-Klassen-Farbe > Modell-Farbe > Tinte. */ +function strokeColorFor(cls: string | undefined, color: string | undefined): RRgba { + const css = cls ? CLS_STROKE[cls] : undefined; + return toRgba(css ?? color ?? DEFAULT_LINE, 1) ?? [0.1, 0.1, 0.1, 1]; +} + /** 1 Punkt (pt) in Papier-Millimetern. */ const PT_TO_MM = 25.4 / 72; /** @@ -241,29 +296,36 @@ export function planToRenderScene(plan: Plan): RScene { // Sichtbare Umriss-Läufe werden erst GESAMMELT (nach Stil gruppiert) und am // Ende über Polygon-Grenzen hinweg zusammengenäht — erst dadurch bekommen // Wand-zu-Wand-Ecken (zwei Polygone!) eine Gehrung statt einer Kerbe. - const edgeRunGroups = new Map(); - const collectEdgeRun = (run: RPoint[], color: RRgba, widthMm: number) => { + const edgeRunGroups = new Map(); + const collectEdgeRun = (run: RPoint[], color: RRgba, widthMm: number, z: number) => { const k = `${color.join(",")}|${widthMm}`; let g = edgeRunGroups.get(k); if (!g) { - g = { color, widthMm, runs: [] }; + g = { color, widthMm, runs: [], z }; edgeRunGroups.set(k, g); } g.runs.push(run); + // Die Kette zeichnet, wenn ihr SPÄTESTER Beitrag im SVG zeichnen würde. + g.z = Math.max(g.z, z); }; + // Maler-Reihenfolge: läuft in EXAKT der Reihenfolge mit, in der der SVG-Pfad + // die Elemente zeichnet (Primitive in Array-Reihenfolge; je Polygon Füllung → + // Schraffur → Umriss). Jedes emittierte Szenen-Element bekommt das nächste z. + let zc = 0; + // Laufender, verketteter 2D-Zeichnungs-Zug (gleiche drawingId + Stil, End-an-Start). let curPts: RPoint[] | null = null; let curSrc: LinePrim | null = null; const flushRun = () => { if (curPts && curSrc && curPts.length >= 2) { - const col = toRgba(curSrc.color ?? DEFAULT_LINE, 1) ?? [0.1, 0.1, 0.1, 1]; + const col = withOpacity(strokeColorFor(curSrc.cls, curSrc.color), curSrc.cls, curSrc.greyed); const closed = curPts.length > 2 && samePt(curPts[0], curPts[curPts.length - 1]); const dash = curSrc.dash ?? null; if (closed) { - outlines.push({ pts: curPts.slice(0, -1), color: col, widthMm: curSrc.weightMm, dash }); + outlines.push({ pts: curPts.slice(0, -1), color: col, widthMm: curSrc.weightMm, dash, z: zc++ }); } else { - polylines.push({ pts: curPts, color: col, widthMm: curSrc.weightMm, dash }); + polylines.push({ pts: curPts, color: col, widthMm: curSrc.weightMm, dash, z: zc++ }); } } curPts = null; @@ -278,33 +340,53 @@ export function planToRenderScene(plan: Plan): RScene { // Füllung const fillCol = toRgba(p.fill, 1); - if (fillCol && pts.length >= 3) fills.push({ pts, color: fillCol }); + if (fillCol && pts.length >= 3) { + fills.push({ pts, color: withOpacity(fillCol, undefined, p.greyed), z: zc++ }); + } // Schraffur: die aufs Polygon geclippten Musterlinien wie im Browser // (glPlanHatch — identische Geometrie), als Polylinien. "none"/"solid" // brauchen keine Linien (solid deckt die Füllung farbig ab). if (p.hatch && p.hatch.pattern !== "none" && p.hatch.pattern !== "solid") { - const hatchCol = toRgba(p.hatch.color, 1) ?? [0.1, 0.1, 0.1, 1]; + const hatchCol = withOpacity( + toRgba(p.hatch.color, 1) ?? [0.1, 0.1, 0.1, 1], + undefined, + p.greyed, + ); + // Strichbreite wie das SVG-Muster (`hatchStrokePx`): in viewBox-px, an + // die Kachel gekoppelt und mit dem Zoom skalierend — NICHT Papier-mm + // (sonst wird die Schraffur bei kleinen Masstäben zur Haarlinie). const hatchMm = p.hatch.lineWeight > 0 ? p.hatch.lineWeight : 0.13; + const hatchPx = Math.max(0.6, hatchMm * (1 / 0.13)); const runs = applyDashRuns(buildHatchRuns(p.pts, p.hatch), p.hatch.dash); + // Alle Musterläufe eines Polygons teilen EIN z (überlappen einander nicht). + const hatchZ = zc++; for (const run of runs) { if (run.length >= 2) { - polylines.push({ pts: run.map((v) => [v.x, v.y]), color: hatchCol, widthMm: hatchMm }); + polylines.push({ + pts: run.map((v) => [v.x, v.y]), + color: hatchCol, + widthMm: hatchPx, + widthScreen: true, + z: hatchZ, + }); } } } // Umriss - const strokeCol = toRgba(p.stroke, 1); - if (strokeCol && p.strokeWidthMm > 0) { + const strokeColRaw = toRgba(p.stroke, 1); + if (strokeColRaw && p.strokeWidthMm > 0) { + const strokeCol = withOpacity(strokeColRaw, undefined, p.greyed); const suppressed = p.noStrokeEdges ?? []; if (suppressed.length === 0) { // Ganzer Ring → geschlossener, gehrter Umriss. - outlines.push({ pts, color: strokeCol, widthMm: p.strokeWidthMm }); + outlines.push({ pts, color: strokeCol, widthMm: p.strokeWidthMm, z: zc++ }); } else { // Nur die sichtbaren Kanten — sammeln, die Naht ans Ende verschoben. + const runZ = zc++; for (const run of visibleEdgeRuns(pts, suppressed)) { - collectEdgeRun(run, strokeCol, p.strokeWidthMm); + collectEdgeRun(run, strokeCol, p.strokeWidthMm, runZ); } } } @@ -323,12 +405,13 @@ export function planToRenderScene(plan: Plan): RScene { } else { // Genuin einzelnes Segment (Türblatt, Referenzlinie, Symbol) → Stumpfkappen ok. flushRun(); - const col = toRgba(p.color ?? DEFAULT_LINE, 1) ?? [0.1, 0.1, 0.1, 1]; - lines.push({ a: [p.a.x, p.a.y], b: [p.b.x, p.b.y], color: col, widthMm: p.weightMm, dash: p.dash ?? null }); + const col = withOpacity(strokeColorFor(p.cls, p.color), p.cls, p.greyed); + lines.push({ a: [p.a.x, p.a.y], b: [p.b.x, p.b.y], color: col, widthMm: p.weightMm, dash: p.dash ?? null, z: zc++ }); } } else if (p.kind === "arc") { flushRun(); - const col = toRgba(DEFAULT_LINE, 1) ?? [0.1, 0.1, 0.1, 1]; + // Farbe wie im SVG: CSS-Klassen-Regel (z. B. .door-swing → #6b7280). + const col = withOpacity(strokeColorFor(p.cls, undefined), p.cls, p.greyed); // Bogen unvortessellliert an Rust übergeben (`RArc`) — die exakte runde // Darstellung (analytischer SDF-Shader, `ARC_WGSL`) und ein evtl. Strich- // muster übernimmt der native Renderer (`compile_scene`) drüben. @@ -348,7 +431,7 @@ export function planToRenderScene(plan: Plan): RScene { // "text"): Rich-Text-Zeilen + Live-Zusatzzeilen, Block vertikal um den // Anker zentriert, Zeilenvorschub 1.3·Basisgröße. flushRun(); - const defCol = toRgba(p.color, 1) ?? [0.1, 0.1, 0.1, 1]; + const defCol = withOpacity(toRgba(p.color, 1) ?? [0.1, 0.1, 0.1, 1], undefined, p.greyed); // unitPerPt = PT_TO_MM → tspan.fontSize ist direkt die Papier-mm-Größe. const docLines = docToLines(p.doc, { x: 0, @@ -368,7 +451,11 @@ export function planToRenderScene(plan: Plan): RScene { sizeMm: line.tspans.length ? Math.max(...line.tspans.map((t) => t.fontSize)) : baseMm, - color: toRgba(line.tspans[0]?.fill ?? p.color, 1) ?? defCol, + color: withOpacity( + toRgba(line.tspans[0]?.fill ?? p.color, 1) ?? defCol, + undefined, + p.greyed, + ), align: line.align, }); } @@ -402,9 +489,9 @@ export function planToRenderScene(plan: Plan): RScene { for (const chain of stitchRuns(g.runs)) { if (chain.pts.length < 2) continue; if (chain.closed) { - outlines.push({ pts: chain.pts, color: g.color, widthMm: g.widthMm }); + outlines.push({ pts: chain.pts, color: g.color, widthMm: g.widthMm, z: g.z }); } else { - polylines.push({ pts: chain.pts, color: g.color, widthMm: g.widthMm }); + polylines.push({ pts: chain.pts, color: g.color, widthMm: g.widthMm, z: g.z }); } } } diff --git a/src/plan/useWasmPlanRenderer.ts b/src/plan/useWasmPlanRenderer.ts index b9c4381..378e168 100644 --- a/src/plan/useWasmPlanRenderer.ts +++ b/src/plan/useWasmPlanRenderer.ts @@ -15,6 +15,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Plan } from "./generatePlan"; import { planToRenderScene } from "./toRenderScene"; +// wgpu-22-Limit-Shim (maxInterStageShaderComponents), geteilt mit useWasm3dRenderer. +import { patchRequestDeviceLimits } from "../engine/requestDeviceShim"; interface ViewBox { x: number; @@ -23,39 +25,12 @@ interface ViewBox { h: number; } -/** - * Kompatibilitäts-Shim: wgpu 22 sendet in `requestDevice` noch das Limit - * `maxInterStageShaderComponents`, das AUS DER WebGPU-Spec ENTFERNT wurde. Neuere - * Chromium-Builds lehnen ein nicht-`undefined`-Wert dafür mit `OperationError` - * ab (→ Device-Erzeugung schlägt fehl). Wir entfernen den Schlüssel EINMAL global - * aus dem Descriptor, bevor wgpu ihn stellt. Entfällt, sobald render2d auf ein - * wgpu (+ glyphon) hochgezogen wird, das dieses Limit nicht mehr mitschickt. - */ -let requestDevicePatched = false; -function patchRequestDeviceLimits(): void { - if (requestDevicePatched) return; - const AdapterCtor = (globalThis as any).GPUAdapter; - if (!AdapterCtor?.prototype?.requestDevice) return; - const orig = AdapterCtor.prototype.requestDevice; - AdapterCtor.prototype.requestDevice = function ( - this: unknown, - desc?: any, - ) { - if (desc?.requiredLimits && "maxInterStageShaderComponents" in desc.requiredLimits) { - const requiredLimits = { ...desc.requiredLimits }; - delete requiredLimits.maxInterStageShaderComponents; - desc = { ...desc, requiredLimits }; - } - return orig.call(this, desc); - }; - requestDevicePatched = true; -} - /** Minimal-Typ des WASM-Exports (das echte .d.ts entsteht erst mit `build:engine`). */ interface WebPlanRendererLike { set_scene(json: string): void; set_view_box(x: number, y: number, w: number, h: number): void; set_paper_scale(n: number): void; + set_text_scale(n: number): void; resize(w: number, h: number): void; render(): void; } @@ -89,7 +64,7 @@ function getRenderer(canvas: HTMLCanvasElement): Promise { let p = rendererCache.get(canvas); if (!p) { p = (async () => { - patchRequestDeviceLimits(); // wgpu-22-Limit-Shim (siehe oben) + patchRequestDeviceLimits(); // wgpu-22-Limit-Shim (src/engine/requestDeviceShim) const mod = await loadEngine(); // Canvas VOR dem Surface-Erzeugen auf die Zielpixel (DPR) bringen. const rect = canvas.getBoundingClientRect(); @@ -149,9 +124,15 @@ export function useWasmPlanRenderer( } }, []); - /** Einen Frame zeichnen (Canvas-Größe + dpr hier abgleichen). */ + /** + * Einen Frame zeichnen (Canvas-Größe + dpr hier abgleichen). `textScaleN` + * bewusst getrennt von `paperScaleN`: er treibt NUR die Schriftgröße der + * Raumstempel (spiegelt die SVG-Formel — feste Referenzskala 1:100 im + * Anzeigemodus, siehe PlanView.tsx `textScaleForGl`), während `paperScaleN` + * weiter die echten Papier-mm-Strichbreiten der Geometrie treibt. + */ const render = useCallback( - (viewBox: ViewBox, paperScaleN = 100) => { + (viewBox: ViewBox, paperScaleN = 100, textScaleN = 100) => { const r = rendererRef.current; const canvas = canvasRef.current; if (!r || !canvas) return; @@ -166,6 +147,7 @@ export function useWasmPlanRenderer( try { r.resize(w, h); r.set_paper_scale(paperScaleN); + r.set_text_scale(textScaleN); r.set_view_box(viewBox.x, viewBox.y, viewBox.w, viewBox.h); r.render(); } catch (e) {