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:
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,11 +634,15 @@ 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<Globals> = Vec::new();
|
||||
if let Some(scene) = &self.scene {
|
||||
for b in &scene.geo.fill_batches {
|
||||
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,
|
||||
@@ -622,16 +651,23 @@ impl Renderer {
|
||||
stroke_scale: mm_px,
|
||||
});
|
||||
}
|
||||
for b in &scene.geo.line_batches {
|
||||
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,
|
||||
stroke_scale: mm_px,
|
||||
// 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.ensure_uniform_capacity(device, blocks.len() as u64);
|
||||
|
||||
@@ -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) {
|
||||
// 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<BatchKind> = 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);
|
||||
for b in &scene.geo.fill_batches {
|
||||
pass.set_bind_group(0, &u.bind_group, &[block * stride]);
|
||||
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);
|
||||
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) {
|
||||
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);
|
||||
for b in &scene.geo.line_batches {
|
||||
pass.set_bind_group(0, &u.bind_group, &[block * stride]);
|
||||
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);
|
||||
block += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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<u32> {
|
||||
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<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.
|
||||
pub bounds: [f32; 4], // [min_x, min_y, max_x, max_y]
|
||||
}
|
||||
@@ -396,34 +414,51 @@ 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) {
|
||||
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 {
|
||||
start_index: self.fill_idx.len() as u32 - count,
|
||||
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) {
|
||||
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 && same_rgba(last.color, color) {
|
||||
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 {
|
||||
start_index: self.line_idx.len() as u32 - count,
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 4) Freie Einzel-Linien (Tuerblaetter, Referenzlinien).
|
||||
for l in &scene.lines {
|
||||
compile_line(&mut geo, l, &mut bounds);
|
||||
items.push((l.z, seq, Item::Line(l)));
|
||||
seq += 1;
|
||||
}
|
||||
// 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).
|
||||
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),
|
||||
}
|
||||
}
|
||||
// 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()
|
||||
};
|
||||
|
||||
@@ -22,6 +22,13 @@ pub struct FillPolygon {
|
||||
pub pts: Vec<Point>,
|
||||
/// 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<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
|
||||
@@ -58,6 +68,9 @@ pub struct Outline {
|
||||
/// ueber die Dash-Luecke hinweg, siehe `tessellate::split_dash`).
|
||||
#[serde(default)]
|
||||
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`
|
||||
@@ -71,12 +84,23 @@ pub struct Polyline {
|
||||
pub pts: Vec<Point>,
|
||||
/// 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<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-
|
||||
|
||||
@@ -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));
|
||||
|
||||
+15
-3
@@ -475,11 +475,23 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
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<PlanViewHandle, PlanViewProps>(
|
||||
// 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<PlanViewHandle, PlanViewProps>(
|
||||
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) => {
|
||||
|
||||
@@ -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-`<pattern>` 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);
|
||||
}
|
||||
|
||||
+107
-20
@@ -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 .<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. */
|
||||
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<string, { color: RRgba; widthMm: number; runs: RPoint[][] }>();
|
||||
const collectEdgeRun = (run: RPoint[], color: RRgba, widthMm: number) => {
|
||||
const edgeRunGroups = new Map<string, { color: RRgba; widthMm: number; runs: RPoint[][]; z: number }>();
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WebPlanRendererLike> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user