2D-Engine-Parität: WASM-Pfad rendert deckungsgleich zum SVG-Referenzpfad

Sechs Lücken geschlossen: Text-Massstab vom Geometrie-Massstab entkoppelt
(set_text_scale, spiegelt SVG-Referenzskala); glyphon auf ColorMode::Web
(Text war linear-konvertiert zu dunkel); z-basierte Maler-Reihenfolge
(interleavte draw_sequence statt fills-vor-lines, Alt-Szenen unverändert);
CSS-Klassenfarben/-Opacities in toRenderScene gespiegelt (Türschwenk etc.);
greyed-Dimmung 0.3 auf allen Primitiven; Dämmschraffur am Modell-Ursprung
verankert (userSpaceOnUse), exakte Bézier-Wellenform statt Sinus und
kachelgekoppelte Strichbreite (widthScreen-Modus). Probe
scripts/probe-engine-parity.mjs vergleicht ?gl=0 gegen ?engine=wasm;
Rest-Diff nur AA/Glyphen-Rasterung. cargo 17/17, vitest 94/94, Builds grün.
This commit is contained in:
2026-07-03 08:17:01 +02:00
parent 54211b1443
commit ce6bd26ff7
11 changed files with 470 additions and 156 deletions
+60
View File
@@ -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");
+5
View File
@@ -32,10 +32,12 @@ pub fn demo_scene() -> Scene {
FillPolygon { FillPolygon {
pts: l.clone(), pts: l.clone(),
color: wall_grey, color: wall_grey,
z: 0,
}, },
FillPolygon { FillPolygon {
pts: room.clone(), pts: room.clone(),
color: room_blue, color: room_blue,
z: 2,
}, },
], ],
outlines: vec![ outlines: vec![
@@ -44,12 +46,14 @@ pub fn demo_scene() -> Scene {
color: ink, color: ink,
width_mm: 0.35, width_mm: 0.35,
dash: None, dash: None,
z: 1,
}, },
Outline { Outline {
pts: room, pts: room,
color: ink, color: ink,
width_mm: 0.18, width_mm: 0.18,
dash: None, dash: None,
z: 3,
}, },
], ],
polylines: vec![], polylines: vec![],
@@ -60,6 +64,7 @@ pub fn demo_scene() -> Scene {
color: ink, color: ink,
width_mm: 0.25, width_mm: 0.25,
dash: None, dash: None,
z: 4,
}], }],
// Ein Raumstempel-artiger Text (echte Glyphen via Atlas), mittig im Raum. // Ein Raumstempel-artiger Text (echte Glyphen via Atlas), mittig im Raum.
texts: vec![Text { texts: vec![Text {
+97 -48
View File
@@ -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::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::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}; use crate::types::{Scene, Text, TextAlign, ViewBox};
/// MSAA-Faktor: 4x Multisampling fuer glatte Linien-/Fuellkanten (wie im Browser). /// MSAA-Faktor: 4x Multisampling fuer glatte Linien-/Fuellkanten (wie im Browser).
@@ -170,6 +170,16 @@ pub struct Renderer {
pub clear_color: wgpu::Color, pub clear_color: wgpu::Color,
/// Papier-Massstab-Nenner (1:N) fuer echte mm-Strichbreiten. /// Papier-Massstab-Nenner (1:N) fuer echte mm-Strichbreiten.
pub paper_scale_n: f32, 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. /// Ein Uniform-Puffer, der `capacity` Globals-Bloecke fasst, plus Bind-Group.
@@ -410,6 +420,7 @@ impl Renderer {
a: 1.0, a: 1.0,
}, },
paper_scale_n: 100.0, paper_scale_n: 100.0,
text_scale_n: 100.0,
} }
} }
@@ -499,7 +510,18 @@ impl Renderer {
} }
let cache = glyphon::Cache::new(device); let cache = glyphon::Cache::new(device);
let viewport = glyphon::Viewport::new(device, &cache); 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( let renderer = glyphon::TextRenderer::new(
&mut atlas, &mut atlas,
device, device,
@@ -592,6 +614,9 @@ impl Renderer {
let (vw, vh) = (viewport.0 as f32, viewport.1 as f32); let (vw, vh) = (viewport.0 as f32, viewport.1 as f32);
let proj: Mat4 = compute_ortho_matrix(view_box, vw, vh); 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); 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 // Geraete-px je Bildschirm-Einheit (== meet-Skala) — der Bogen-Shader braucht
// beides: px_per_screen fuer Radial-/Kappen-/Dash-AA, stroke_scale fuer die // beides: px_per_screen fuer Radial-/Kappen-/Dash-AA, stroke_scale fuer die
// Papier-mm-Breite (identisch zu den Linien). // Papier-mm-Breite (identisch zu den Linien).
@@ -609,27 +634,38 @@ impl Renderer {
}), }),
); );
// Alle Globals-Bloecke der Reihenfolge nach (erst Fuell-, dann Linien-Batches) // Alle Globals-Bloecke in MALER-Reihenfolge (`draw_sequence`, interleaved
// sammeln, den Puffer einmal schreiben, danach nur noch dynamisch binden. // Fuell-/Linien-Batches) sammeln, den Puffer einmal schreiben, danach nur
// noch dynamisch binden — Reihenfolge identisch zur Draw-Schleife unten.
let mut blocks: Vec<Globals> = Vec::new(); let mut blocks: Vec<Globals> = Vec::new();
if let Some(scene) = &self.scene { if let Some(scene) = &self.scene {
for b in &scene.geo.fill_batches { for &(kind, idx) in &scene.geo.draw_sequence {
blocks.push(Globals { match kind {
view_proj: proj, BatchKind::Fill => {
color: b.color, let b = &scene.geo.fill_batches[idx as usize];
viewport_px: [vw, vh], blocks.push(Globals {
stroke_px: 0.0, view_proj: proj,
stroke_scale: mm_px, color: b.color,
}); viewport_px: [vw, vh],
} stroke_px: 0.0,
for b in &scene.geo.line_batches { stroke_scale: mm_px,
blocks.push(Globals { });
view_proj: proj, }
color: b.color, BatchKind::Line => {
viewport_px: [vw, vh], let b = &scene.geo.line_batches[idx as usize];
stroke_px: b.stroke_mm, blocks.push(Globals {
stroke_scale: mm_px, 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-`<pattern>`-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() let attrs = glyphon::Attrs::new()
.family(glyphon::Family::Name(&family)); .family(glyphon::Family::Name(&family));
for t in &scene.texts { 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). // Unlesbar kleine Glyphen ueberspringen (spart Shaping/Atlas).
if !(font_px >= 1.0) || t.content.is_empty() { if !(font_px >= 1.0) || t.content.is_empty() {
continue; continue;
@@ -794,33 +830,46 @@ impl Renderer {
if let (Some(scene), Some(u)) = (&self.scene, &self.uniform) { if let (Some(scene), Some(u)) = (&self.scene, &self.uniform) {
let stride = self.uniform_stride as u32; let stride = self.uniform_stride as u32;
let mut block = 0u32; // Laufindex ueber Fuell- dann Linien-Batches
// 1) Fuellungen. // Geometrie in MALER-Reihenfolge (`draw_sequence`): Fuell- und
if let (Some(vbo), Some(ibo)) = (&scene.fill_vbo, &scene.fill_ibo) { // Linien-Batches interleaved, Pipeline-/Puffer-Wechsel nur beim
pass.set_pipeline(&self.fill_pipeline); // Wechsel der Batch-Art. Nur so deckt eine spaetere Fuellung
pass.set_vertex_buffer(0, vbo.slice(..)); // einen frueheren Strich ab (SVG-Paritaet, Primitive-Reihenfolge).
pass.set_index_buffer(ibo.slice(..), wgpu::IndexFormat::Uint32); let mut bound: Option<BatchKind> = None;
for b in &scene.geo.fill_batches { for (block, &(kind, idx)) in scene.geo.draw_sequence.iter().enumerate() {
pass.set_bind_group(0, &u.bind_group, &[block * stride]); match kind {
let start = b.start_index; BatchKind::Fill => {
pass.draw_indexed(start..start + b.index_count, 0, 0..1); let (Some(vbo), Some(ibo)) = (&scene.fill_vbo, &scene.fill_ibo)
block += 1; else {
} continue;
} else { };
block += scene.geo.fill_batches.len() as u32; if bound != Some(BatchKind::Fill) {
} pass.set_pipeline(&self.fill_pipeline);
pass.set_vertex_buffer(0, vbo.slice(..));
// 2) Linien (Umrisse + freie Linien). pass.set_index_buffer(ibo.slice(..), wgpu::IndexFormat::Uint32);
if let (Some(vbo), Some(ibo)) = (&scene.line_vbo, &scene.line_ibo) { bound = Some(BatchKind::Fill);
pass.set_pipeline(&self.line_pipeline); }
pass.set_vertex_buffer(0, vbo.slice(..)); let b = &scene.geo.fill_batches[idx as usize];
pass.set_index_buffer(ibo.slice(..), wgpu::IndexFormat::Uint32); pass.set_bind_group(0, &u.bind_group, &[block as u32 * stride]);
for b in &scene.geo.line_batches { let start = b.start_index;
pass.set_bind_group(0, &u.bind_group, &[block * stride]); pass.draw_indexed(start..start + b.index_count, 0, 0..1);
let start = b.start_index; }
pass.draw_indexed(start..start + b.index_count, 0, 0..1); BatchKind::Line => {
block += 1; 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);
}
} }
} }
} }
+2 -2
View File
@@ -30,8 +30,8 @@ pub mod web;
pub use demo::{demo_scene, initial_view_box}; pub use demo::{demo_scene, initial_view_box};
pub use ortho::{compute_ortho_matrix, meet_scale, mm_to_device_px, Mat4}; pub use ortho::{compute_ortho_matrix, meet_scale, mm_to_device_px, Mat4};
pub use tessellate::{ pub use tessellate::{
arc_screen_params, compile_scene, prepare_arc_dash, triangulate, ArcInstanceData, GpuGeometry, arc_screen_params, compile_scene, prepare_arc_dash, triangulate, ArcInstanceData, BatchKind,
MAX_ARC_DASH, PX_PER_M, GpuGeometry, MAX_ARC_DASH, PX_PER_M,
}; };
pub use types::{Arc, FillPolygon, Line, Outline, Point, Rgba, Scene, Text, TextAlign, ViewBox}; pub use types::{Arc, FillPolygon, Line, Outline, Point, Rgba, Scene, Text, TextAlign, ViewBox};
+104 -35
View File
@@ -218,6 +218,7 @@ fn stroke_dashed_or_solid(
closed: bool, closed: bool,
color: Rgba, color: Rgba,
stroke_mm: f32, stroke_mm: f32,
width_screen: bool,
dash: Option<&[f32]>, dash: Option<&[f32]>,
bounds: &mut Bounds, bounds: &mut Bounds,
) { ) {
@@ -225,11 +226,11 @@ fn stroke_dashed_or_solid(
Some(d) if !d.is_empty() => { Some(d) if !d.is_empty() => {
for piece in split_dash(pts, d) { for piece in split_dash(pts, d) {
if piece.len() >= 2 { 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<u32> {
tris 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. /// Ein zusammenhaengender Zeichen-Batch (Index-Bereich) mit einer Farbe.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FillBatch { pub struct FillBatch {
@@ -365,8 +373,12 @@ pub struct LineBatch {
pub start_index: u32, pub start_index: u32,
pub index_count: u32, pub index_count: u32,
pub color: Rgba, 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, 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. /// 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): /// Analytisch gezeichnete Boegen (je EINE Instanz, kein Segment-Mesh mehr):
/// die GPU rendert sie pro Frame exakt rund per SDF-Fragment-Shader. /// die GPU rendert sie pro Frame exakt rund per SDF-Fragment-Shader.
pub arcs: Vec<ArcInstanceData>, pub arcs: Vec<ArcInstanceData>,
/// 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. /// Modell-Bounds (Meter) fuer Debug/Einpassen; nicht render-kritisch.
pub bounds: [f32; 4], // [min_x, min_y, max_x, max_y] 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 { impl GpuGeometry {
/// Ordnungserhaltendes Batch-Merging fuer Fuellungen: aufeinanderfolgende /// 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) { fn add_fill_batch(&mut self, count: u32, color: Rgba) {
if let Some(last) = self.fill_batches.last_mut() { let last_idx = self.fill_batches.len().wrapping_sub(1) as u32;
if same_rgba(last.color, color) { if self.draw_sequence.last() == Some(&(BatchKind::Fill, last_idx)) {
last.index_count += count; if let Some(last) = self.fill_batches.last_mut() {
return; if same_rgba(last.color, color) {
last.index_count += count;
return;
}
} }
} }
self.fill_batches.push(FillBatch { self.fill_batches.push(FillBatch {
@@ -409,13 +433,21 @@ impl GpuGeometry {
index_count: count, index_count: count,
color, 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) { fn add_line_batch(&mut self, count: u32, color: Rgba, stroke_mm: f32, width_screen: bool) {
if let Some(last) = self.line_batches.last_mut() { let last_idx = self.line_batches.len().wrapping_sub(1) as u32;
if last.stroke_mm == stroke_mm && same_rgba(last.color, color) { if self.draw_sequence.last() == Some(&(BatchKind::Line, last_idx)) {
last.index_count += count; if let Some(last) = self.line_batches.last_mut() {
return; 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 { self.line_batches.push(LineBatch {
@@ -423,7 +455,10 @@ impl GpuGeometry {
index_count: count, index_count: count,
color, color,
stroke_mm, 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 /// Zeichnet einen zusammenhaengenden Linienzug (Modell-Meter) als EINEN
@@ -439,6 +474,7 @@ impl GpuGeometry {
closed: bool, closed: bool,
color: Rgba, color: Rgba,
stroke_mm: f32, stroke_mm: f32,
width_screen: bool,
bounds: &mut Bounds, bounds: &mut Bounds,
) { ) {
// Auf Bildschirm-Raum abbilden + aufeinanderfolgende Duplikate entfernen. // Auf Bildschirm-Raum abbilden + aufeinanderfolgende Duplikate entfernen.
@@ -527,7 +563,7 @@ impl GpuGeometry {
self.line_idx self.line_idx
.extend_from_slice(&[a, a + 1, b, a + 1, b + 1, b]); .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 geo = GpuGeometry::default();
let mut bounds = Bounds::new(); let mut bounds = Bounds::new();
// 1) Gefuellte Polygone. // Maler-Reihenfolge herstellen: alle Elemente (Fuellungen, Umrisse, offene
for poly in &scene.fills { // Polylinien, freie Linien) stabil nach ihrem `z` sortieren. Bei gleichem z
compile_fill(&mut geo, poly, &mut bounds); // (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 { for o in &scene.outlines {
if o.width_mm > 0.0 && o.pts.len() >= 2 { items.push((o.z, seq, Item::Outline(o)));
stroke_dashed_or_solid(&mut geo, &o.pts, true, o.color, o.width_mm, o.dash.as_deref(), &mut bounds); 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 { for pl in &scene.polylines {
if pl.width_mm > 0.0 && pl.pts.len() >= 2 { items.push((pl.z, seq, Item::Poly(pl)));
stroke_dashed_or_solid(&mut geo, &pl.pts, false, pl.color, pl.width_mm, pl.dash.as_deref(), &mut bounds); 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). // Kreisboegen: NICHT in Segmente zerlegt — je Bogen eine analytische Instanz
for l in &scene.lines { // (`ArcInstanceData`), die die GPU per SDF-Fragment-Shader exakt rund rendert
compile_line(&mut geo, l, &mut bounds); // (eigene Pipeline, nach der uebrigen Geometrie — Tuerschwenk liegt oben).
}
// 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).
for a in &scene.arcs { for a in &scene.arcs {
compile_arc(&mut geo, a, &mut bounds); 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) { 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 }; 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)] #[cfg(test)]
@@ -815,6 +882,7 @@ mod tests {
color: [0.0, 0.0, 0.0, 1.0], color: [0.0, 0.0, 0.0, 1.0],
width_mm: 0.2, width_mm: 0.2,
dash: None, dash: None,
z: 0,
}], }],
..Default::default() ..Default::default()
}; };
@@ -824,6 +892,7 @@ mod tests {
color: [0.0, 0.0, 0.0, 1.0], color: [0.0, 0.0, 0.0, 1.0],
width_mm: 0.2, width_mm: 0.2,
dash: Some(vec![10.0, 10.0]), dash: Some(vec![10.0, 10.0]),
z: 0,
}], }],
..Default::default() ..Default::default()
}; };
+25 -1
View File
@@ -22,6 +22,13 @@ pub struct FillPolygon {
pub pts: Vec<Point>, pub pts: Vec<Point>,
/// Fuellfarbe (RGBA 0..1). /// Fuellfarbe (RGBA 0..1).
pub color: Rgba, 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. /// 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`). /// Strichmuster in Papier-mm; None/leer = durchgezogen (siehe `tessellate::split_dash`).
#[serde(default)] #[serde(default)]
pub dash: Option<Vec<f32>>, pub dash: Option<Vec<f32>>,
/// Maler-Reihenfolge (kleiner = frueher/unten), siehe `FillPolygon::z`.
#[serde(default)]
pub z: u32,
} }
/// Ein geschlossenes Polygon als Umriss (nur Kanten, keine Fuellung) — bequemer /// 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`). /// ueber die Dash-Luecke hinweg, siehe `tessellate::split_dash`).
#[serde(default)] #[serde(default)]
pub dash: Option<Vec<f32>>, pub dash: Option<Vec<f32>>,
/// Maler-Reihenfolge (kleiner = frueher/unten), siehe `FillPolygon::z`.
#[serde(default)]
pub z: u32,
} }
/// Ein OFFENER Linienzug mit echter Papier-mm-Breite. Anders als `Outline` /// Ein OFFENER Linienzug mit echter Papier-mm-Breite. Anders als `Outline`
@@ -71,12 +84,23 @@ pub struct Polyline {
pub pts: Vec<Point>, pub pts: Vec<Point>,
/// Strichfarbe (RGBA 0..1). /// Strichfarbe (RGBA 0..1).
pub color: Rgba, 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")] #[serde(rename = "widthMm")]
pub width_mm: f32, 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. /// Strichmuster in Papier-mm; None/leer = durchgezogen.
#[serde(default)] #[serde(default)]
pub dash: Option<Vec<f32>>, pub dash: Option<Vec<f32>>,
/// 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- /// Ein Kreisbogen (kuerzerer Sweep von `from` nach `to` um `center`) in Modell-
+8
View File
@@ -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). /// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
pub fn resize(&mut self, width: u32, height: u32) { pub fn resize(&mut self, width: u32, height: u32) {
let (w, h) = (width.max(1), height.max(1)); let (w, h) = (width.max(1), height.max(1));
+15 -3
View File
@@ -475,11 +475,23 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
const paperScaleForGl = (v: ViewBox): number => const paperScaleForGl = (v: ViewBox): number =>
paperScaleRef.current ?? scaleFromView(v, svgRef.current) ?? 100; 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. // GL-Geometrie beim Plan-Wechsel neu tessellieren + sofort neu zeichnen.
useEffect(() => { useEffect(() => {
if (!useGpuRenderer) return; if (!useGpuRenderer) return;
updateGeometry(plan); updateGeometry(plan);
renderGl(viewRef.current, paperScaleForGl(viewRef.current)); renderGl(viewRef.current, paperScaleForGl(viewRef.current), textScaleForGl());
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [useGpuRenderer, plan, updateGeometry, renderGl]); }, [useGpuRenderer, plan, updateGeometry, renderGl]);
@@ -487,7 +499,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
// keine Re-Tessellierung. Auch bei paperScale-Wechsel neu (Strichbreiten). // keine Re-Tessellierung. Auch bei paperScale-Wechsel neu (Strichbreiten).
useEffect(() => { useEffect(() => {
if (useGpuRenderer) { if (useGpuRenderer) {
renderGl(view, paperScaleForGl(view)); renderGl(view, paperScaleForGl(view), textScaleForGl());
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [useGpuRenderer, renderGl, view, paperScale]); }, [useGpuRenderer, renderGl, view, paperScale]);
@@ -1065,7 +1077,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
if (!v || !v0) return; if (!v || !v0) return;
viewRef.current = v; // abgeleitete Reads (Snap-Toleranz etc.) frisch halten viewRef.current = v; // abgeleitete Reads (Snap-Toleranz etc.) frisch halten
applySvgTransform(v0, v); // SVG per GPU-Composit (kein Re-Raster) 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. */ /** Pan-/Zoom-Ziel imperativ anwenden (rAF-gedrosselt), ohne React-Render. */
const enqueuePan = (v: ViewBox) => { const enqueuePan = (v: ViewBox) => {
+34 -16
View File
@@ -240,10 +240,12 @@ export function hatchLineFamily(
const nrm: Vec2 = { x: -dir.y, y: dir.x }; // Linien-Normale (Scan-Richtung) const nrm: Vec2 = { x: -dir.y, y: dir.x }; // Linien-Normale (Scan-Richtung)
// Rechtwinklige Projektion aller Ecken auf die Normale → Abdeckungsbereich. // Rechtwinklige Projektion aller Ecken auf die Normale → Abdeckungsbereich.
const center: Vec2 = { // ANKER = MODELL-URSPRUNG (nicht die Polygon-Mitte): der SVG-`<pattern>` ist
x: (bb.minX + bb.maxX) / 2, // `userSpaceOnUse`, sein Kachelgitter liegt global im viewBox-Raum fest
y: (bb.minY + bb.maxY) / 2, // (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; let dMin = Infinity, dMax = -Infinity;
for (const p of poly) { for (const p of poly) {
const d = (p.x - center.x) * nrm.x + (p.y - center.y) * nrm.y; 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). * endet exakt an den Polygonkanten (even-odd-Clipping, konkav-fähig).
*/ */
function insulationRuns(poly: Vec2[], hatch: HatchRender, scale: number): HatchRun[] { function insulationRuns(poly: Vec2[], hatch: HatchRender, scale: number): HatchRun[] {
const bb = bbox(poly);
const rot = toModelAngleRad(hatch.angle); const rot = toModelAngleRad(hatch.angle);
// Muster-Parameter in Metern (aus den SVG-px-Werten der Kachel). // Muster-Parameter in Metern (aus den SVG-px-Werten der Kachel).
const wavelength = 14 * scale * PX_TO_M; // Kachelbreite const wavelength = 14 * scale * PX_TO_M; // Kachelbreite
const rowGap = 10 * scale * PX_TO_M; // Kachelhöhe → Zeilenabstand 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). // 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 uDir = rotate({ x: 1, y: 0 }, rot);
const vDir = rotate({ x: 0, y: 1 }, rot); const vDir = rotate({ x: 0, y: 1 }, rot);
// Abdeckungsbereich in (u,v) über die Polygon-Ecken bestimmen. // 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; let uMin = Infinity, uMax = -Infinity, vMin = Infinity, vMax = -Infinity;
for (const p of poly) { for (const p of poly) {
const u = (p.x - center.x) * uDir.x + (p.y - center.y) * uDir.y; const u = p.x * uDir.x + p.y * uDir.y;
const v = (p.x - center.x) * vDir.x + (p.y - center.y) * vDir.y; const v = p.x * vDir.x + p.y * vDir.y;
if (u < uMin) uMin = u; if (u < uMin) uMin = u;
if (u > uMax) uMax = u; if (u > uMax) uMax = u;
if (v < vMin) vMin = v; if (v < vMin) vMin = v;
@@ -447,22 +449,38 @@ function insulationRuns(poly: Vec2[], hatch: HatchRender, scale: number): HatchR
const runs: HatchRun[] = []; const runs: HatchRun[] = [];
const toWorld = (u: number, v: number): Vec2 => ({ const toWorld = (u: number, v: number): Vec2 => ({
x: center.x + uDir.x * u + vDir.x * v, x: uDir.x * u + vDir.x * v,
y: center.y + uDir.y * u + vDir.y * v, y: uDir.y * u + vDir.y * v,
}); });
const firstRow = Math.floor(vMin / rowGap) - 1; // Die Wellen-Mittellinie liegt in der SVG-Kachel bei y = h/2 (my = 5·scale),
const lastRow = Math.ceil(vMax / rowGap) + 1; // 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 uStart = Math.floor((uMin - wavelength) / du) * du;
const uEnd = uMax + wavelength; 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(1t) (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++) { 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. // 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[] = []; const wavePts: Vec2[] = [];
for (let u = uStart; u <= uEnd + 1e-9; u += du) { 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 + waveAt(u)));
wavePts.push(toWorld(u, vBase + wave));
} }
for (const run of clipPolylineToPolygon(wavePts, poly)) runs.push(run); for (const run of clipPolylineToPolygon(wavePts, poly)) runs.push(run);
} }
+107 -20
View File
@@ -31,18 +31,36 @@ export type RRgba = [number, number, number, number];
export interface RFill { export interface RFill {
pts: RPoint[]; pts: RPoint[];
color: RRgba; 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 { export interface ROutline {
pts: RPoint[]; pts: RPoint[];
color: RRgba; color: RRgba;
widthMm: number; widthMm: number;
dash?: number[] | null; dash?: number[] | null;
/** Maler-Reihenfolge, siehe {@link RFill.z}. */
z: number;
} }
export interface RPolyline { export interface RPolyline {
pts: RPoint[]; pts: RPoint[];
color: RRgba; color: RRgba;
widthMm: number; widthMm: number;
dash?: number[] | null; 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 { export interface RArc {
center: RPoint; center: RPoint;
@@ -59,6 +77,8 @@ export interface RLine {
color: RRgba; color: RRgba;
widthMm: number; widthMm: number;
dash?: number[] | null; dash?: number[] | null;
/** Maler-Reihenfolge, siehe {@link RFill.z}. */
z: number;
} }
export type RTextAlign = "left" | "center" | "right"; export type RTextAlign = "left" | "center" | "right";
/** EINE Textzeile; serde-kompatibel zu render2d::types::Text. */ /** EINE Textzeile; serde-kompatibel zu render2d::types::Text. */
@@ -82,6 +102,41 @@ export interface RScene {
const DEFAULT_LINE = "#1a1a1a"; const DEFAULT_LINE = "#1a1a1a";
/**
* Strichfarben/-deckkraft je CSS-Klasse — Spiegel der `.plan-svg .<cls>`-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<string, string> = {
"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<string, number> = {
"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. */ /** 1 Punkt (pt) in Papier-Millimetern. */
const PT_TO_MM = 25.4 / 72; 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 // Sichtbare Umriss-Läufe werden erst GESAMMELT (nach Stil gruppiert) und am
// Ende über Polygon-Grenzen hinweg zusammengenäht — erst dadurch bekommen // Ende über Polygon-Grenzen hinweg zusammengenäht — erst dadurch bekommen
// Wand-zu-Wand-Ecken (zwei Polygone!) eine Gehrung statt einer Kerbe. // Wand-zu-Wand-Ecken (zwei Polygone!) eine Gehrung statt einer Kerbe.
const edgeRunGroups = new Map<string, { color: RRgba; widthMm: number; runs: RPoint[][] }>(); const edgeRunGroups = new Map<string, { color: RRgba; widthMm: number; runs: RPoint[][]; z: number }>();
const collectEdgeRun = (run: RPoint[], color: RRgba, widthMm: number) => { const collectEdgeRun = (run: RPoint[], color: RRgba, widthMm: number, z: number) => {
const k = `${color.join(",")}|${widthMm}`; const k = `${color.join(",")}|${widthMm}`;
let g = edgeRunGroups.get(k); let g = edgeRunGroups.get(k);
if (!g) { if (!g) {
g = { color, widthMm, runs: [] }; g = { color, widthMm, runs: [], z };
edgeRunGroups.set(k, g); edgeRunGroups.set(k, g);
} }
g.runs.push(run); 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). // Laufender, verketteter 2D-Zeichnungs-Zug (gleiche drawingId + Stil, End-an-Start).
let curPts: RPoint[] | null = null; let curPts: RPoint[] | null = null;
let curSrc: LinePrim | null = null; let curSrc: LinePrim | null = null;
const flushRun = () => { const flushRun = () => {
if (curPts && curSrc && curPts.length >= 2) { 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 closed = curPts.length > 2 && samePt(curPts[0], curPts[curPts.length - 1]);
const dash = curSrc.dash ?? null; const dash = curSrc.dash ?? null;
if (closed) { 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 { } 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; curPts = null;
@@ -278,33 +340,53 @@ export function planToRenderScene(plan: Plan): RScene {
// Füllung // Füllung
const fillCol = toRgba(p.fill, 1); 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 // Schraffur: die aufs Polygon geclippten Musterlinien wie im Browser
// (glPlanHatch — identische Geometrie), als Polylinien. "none"/"solid" // (glPlanHatch — identische Geometrie), als Polylinien. "none"/"solid"
// brauchen keine Linien (solid deckt die Füllung farbig ab). // brauchen keine Linien (solid deckt die Füllung farbig ab).
if (p.hatch && p.hatch.pattern !== "none" && p.hatch.pattern !== "solid") { 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 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); 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) { for (const run of runs) {
if (run.length >= 2) { 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 // Umriss
const strokeCol = toRgba(p.stroke, 1); const strokeColRaw = toRgba(p.stroke, 1);
if (strokeCol && p.strokeWidthMm > 0) { if (strokeColRaw && p.strokeWidthMm > 0) {
const strokeCol = withOpacity(strokeColRaw, undefined, p.greyed);
const suppressed = p.noStrokeEdges ?? []; const suppressed = p.noStrokeEdges ?? [];
if (suppressed.length === 0) { if (suppressed.length === 0) {
// Ganzer Ring → geschlossener, gehrter Umriss. // Ganzer Ring → geschlossener, gehrter Umriss.
outlines.push({ pts, color: strokeCol, widthMm: p.strokeWidthMm }); outlines.push({ pts, color: strokeCol, widthMm: p.strokeWidthMm, z: zc++ });
} else { } else {
// Nur die sichtbaren Kanten — sammeln, die Naht ans Ende verschoben. // Nur die sichtbaren Kanten — sammeln, die Naht ans Ende verschoben.
const runZ = zc++;
for (const run of visibleEdgeRuns(pts, suppressed)) { 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 { } else {
// Genuin einzelnes Segment (Türblatt, Referenzlinie, Symbol) → Stumpfkappen ok. // Genuin einzelnes Segment (Türblatt, Referenzlinie, Symbol) → Stumpfkappen ok.
flushRun(); flushRun();
const col = toRgba(p.color ?? DEFAULT_LINE, 1) ?? [0.1, 0.1, 0.1, 1]; 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 }); 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") { } else if (p.kind === "arc") {
flushRun(); 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 // Bogen unvortessellliert an Rust übergeben (`RArc`) — die exakte runde
// Darstellung (analytischer SDF-Shader, `ARC_WGSL`) und ein evtl. Strich- // Darstellung (analytischer SDF-Shader, `ARC_WGSL`) und ein evtl. Strich-
// muster übernimmt der native Renderer (`compile_scene`) drüben. // 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 // "text"): Rich-Text-Zeilen + Live-Zusatzzeilen, Block vertikal um den
// Anker zentriert, Zeilenvorschub 1.3·Basisgröße. // Anker zentriert, Zeilenvorschub 1.3·Basisgröße.
flushRun(); 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. // unitPerPt = PT_TO_MM → tspan.fontSize ist direkt die Papier-mm-Größe.
const docLines = docToLines(p.doc, { const docLines = docToLines(p.doc, {
x: 0, x: 0,
@@ -368,7 +451,11 @@ export function planToRenderScene(plan: Plan): RScene {
sizeMm: line.tspans.length sizeMm: line.tspans.length
? Math.max(...line.tspans.map((t) => t.fontSize)) ? Math.max(...line.tspans.map((t) => t.fontSize))
: baseMm, : 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, align: line.align,
}); });
} }
@@ -402,9 +489,9 @@ export function planToRenderScene(plan: Plan): RScene {
for (const chain of stitchRuns(g.runs)) { for (const chain of stitchRuns(g.runs)) {
if (chain.pts.length < 2) continue; if (chain.pts.length < 2) continue;
if (chain.closed) { 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 { } 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 });
} }
} }
} }
+13 -31
View File
@@ -15,6 +15,8 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { Plan } from "./generatePlan"; import type { Plan } from "./generatePlan";
import { planToRenderScene } from "./toRenderScene"; import { planToRenderScene } from "./toRenderScene";
// wgpu-22-Limit-Shim (maxInterStageShaderComponents), geteilt mit useWasm3dRenderer.
import { patchRequestDeviceLimits } from "../engine/requestDeviceShim";
interface ViewBox { interface ViewBox {
x: number; x: number;
@@ -23,39 +25,12 @@ interface ViewBox {
h: number; 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`). */ /** Minimal-Typ des WASM-Exports (das echte .d.ts entsteht erst mit `build:engine`). */
interface WebPlanRendererLike { interface WebPlanRendererLike {
set_scene(json: string): void; set_scene(json: string): void;
set_view_box(x: number, y: number, w: number, h: number): void; set_view_box(x: number, y: number, w: number, h: number): void;
set_paper_scale(n: number): void; set_paper_scale(n: number): void;
set_text_scale(n: number): void;
resize(w: number, h: number): void; resize(w: number, h: number): void;
render(): void; render(): void;
} }
@@ -89,7 +64,7 @@ function getRenderer(canvas: HTMLCanvasElement): Promise<WebPlanRendererLike> {
let p = rendererCache.get(canvas); let p = rendererCache.get(canvas);
if (!p) { if (!p) {
p = (async () => { p = (async () => {
patchRequestDeviceLimits(); // wgpu-22-Limit-Shim (siehe oben) patchRequestDeviceLimits(); // wgpu-22-Limit-Shim (src/engine/requestDeviceShim)
const mod = await loadEngine(); const mod = await loadEngine();
// Canvas VOR dem Surface-Erzeugen auf die Zielpixel (DPR) bringen. // Canvas VOR dem Surface-Erzeugen auf die Zielpixel (DPR) bringen.
const rect = canvas.getBoundingClientRect(); 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( const render = useCallback(
(viewBox: ViewBox, paperScaleN = 100) => { (viewBox: ViewBox, paperScaleN = 100, textScaleN = 100) => {
const r = rendererRef.current; const r = rendererRef.current;
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!r || !canvas) return; if (!r || !canvas) return;
@@ -166,6 +147,7 @@ export function useWasmPlanRenderer(
try { try {
r.resize(w, h); r.resize(w, h);
r.set_paper_scale(paperScaleN); r.set_paper_scale(paperScaleN);
r.set_text_scale(textScaleN);
r.set_view_box(viewBox.x, viewBox.y, viewBox.w, viewBox.h); r.set_view_box(viewBox.x, viewBox.y, viewBox.w, viewBox.h);
r.render(); r.render();
} catch (e) { } catch (e) {