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 98994c96aa
commit 23410f9b9e
11 changed files with 470 additions and 156 deletions
+104 -35
View File
@@ -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,12 +414,18 @@ fn same_rgba(a: Rgba, b: Rgba) -> bool {
impl GpuGeometry {
/// Ordnungserhaltendes Batch-Merging fuer Fuellungen: aufeinanderfolgende
/// Indizes gleicher Farbe werden zu EINEM Draw-Call zusammengefasst.
/// Indizes gleicher Farbe werden zu EINEM Draw-Call zusammengefasst. Gemergt
/// wird nur, wenn der letzte Eintrag der MALER-Reihenfolge (`draw_sequence`)
/// derselbe Fuell-Batch ist — sonst wuerde ein dazwischenliegender Linien-
/// Batch uebersprungen und die Z-Reihenfolge verfaelscht.
fn add_fill_batch(&mut self, count: u32, color: Rgba) {
if let Some(last) = self.fill_batches.last_mut() {
if same_rgba(last.color, color) {
last.index_count += count;
return;
let last_idx = self.fill_batches.len().wrapping_sub(1) as u32;
if self.draw_sequence.last() == Some(&(BatchKind::Fill, last_idx)) {
if let Some(last) = self.fill_batches.last_mut() {
if same_rgba(last.color, color) {
last.index_count += count;
return;
}
}
}
self.fill_batches.push(FillBatch {
@@ -409,13 +433,21 @@ impl GpuGeometry {
index_count: count,
color,
});
self.draw_sequence
.push((BatchKind::Fill, (self.fill_batches.len() - 1) as u32));
}
fn add_line_batch(&mut self, count: u32, color: Rgba, stroke_mm: f32) {
if let Some(last) = self.line_batches.last_mut() {
if last.stroke_mm == stroke_mm && same_rgba(last.color, color) {
last.index_count += count;
return;
fn add_line_batch(&mut self, count: u32, color: Rgba, stroke_mm: f32, width_screen: bool) {
let last_idx = self.line_batches.len().wrapping_sub(1) as u32;
if self.draw_sequence.last() == Some(&(BatchKind::Line, last_idx)) {
if let Some(last) = self.line_batches.last_mut() {
if last.stroke_mm == stroke_mm
&& last.width_screen == width_screen
&& same_rgba(last.color, color)
{
last.index_count += count;
return;
}
}
}
self.line_batches.push(LineBatch {
@@ -423,7 +455,10 @@ impl GpuGeometry {
index_count: count,
color,
stroke_mm,
width_screen,
});
self.draw_sequence
.push((BatchKind::Line, (self.line_batches.len() - 1) as u32));
}
/// Zeichnet einen zusammenhaengenden Linienzug (Modell-Meter) als EINEN
@@ -439,6 +474,7 @@ impl GpuGeometry {
closed: bool,
color: Rgba,
stroke_mm: f32,
width_screen: bool,
bounds: &mut Bounds,
) {
// Auf Bildschirm-Raum abbilden + aufeinanderfolgende Duplikate entfernen.
@@ -527,7 +563,7 @@ impl GpuGeometry {
self.line_idx
.extend_from_slice(&[a, a + 1, b, a + 1, b + 1, b]);
}
self.add_line_batch((segs * 6) as u32, color, stroke_mm);
self.add_line_batch((segs * 6) as u32, color, stroke_mm, width_screen);
}
}
@@ -583,32 +619,63 @@ pub fn compile_scene(scene: &Scene) -> GpuGeometry {
let mut geo = GpuGeometry::default();
let mut bounds = Bounds::new();
// 1) Gefuellte Polygone.
for poly in &scene.fills {
compile_fill(&mut geo, poly, &mut bounds);
// Maler-Reihenfolge herstellen: alle Elemente (Fuellungen, Umrisse, offene
// Polylinien, freie Linien) stabil nach ihrem `z` sortieren. Bei gleichem z
// (insb. Alt-Szenen mit z=0 ueberall) gilt die bisherige Kategorien-
// Reihenfolge Fuellungen -> Umrisse -> Polylinien -> Linien (`seq`).
enum Item<'a> {
Fill(&'a crate::types::FillPolygon),
Outline(&'a crate::types::Outline),
Poly(&'a crate::types::Polyline),
Line(&'a Line),
}
let mut items: Vec<(u32, usize, Item)> = Vec::with_capacity(
scene.fills.len() + scene.outlines.len() + scene.polylines.len() + scene.lines.len(),
);
let mut seq = 0usize;
for f in &scene.fills {
items.push((f.z, seq, Item::Fill(f)));
seq += 1;
}
// 2) Umrisse (geschlossene Ringe als EIN gehrter Streifen, inkl. Schluss-Kante
// — ausser bei Dash, siehe `stroke_dashed_or_solid`).
for o in &scene.outlines {
if o.width_mm > 0.0 && o.pts.len() >= 2 {
stroke_dashed_or_solid(&mut geo, &o.pts, true, o.color, o.width_mm, o.dash.as_deref(), &mut bounds);
}
items.push((o.z, seq, Item::Outline(o)));
seq += 1;
}
// 3) Offene Polylinien (verbundene Umrisskanten / 2D-Zeichnungszuege) als EIN
// gehrter Streifen — die inneren Ecken bekommen so eine Gehrung statt
// Stumpfkappen (closed=false).
for pl in &scene.polylines {
if pl.width_mm > 0.0 && pl.pts.len() >= 2 {
stroke_dashed_or_solid(&mut geo, &pl.pts, false, pl.color, pl.width_mm, pl.dash.as_deref(), &mut bounds);
items.push((pl.z, seq, Item::Poly(pl)));
seq += 1;
}
for l in &scene.lines {
items.push((l.z, seq, Item::Line(l)));
seq += 1;
}
items.sort_by_key(|(z, s, _)| (*z, *s));
for (_, _, item) in &items {
match item {
// Gefuellte Polygone.
Item::Fill(poly) => compile_fill(&mut geo, poly, &mut bounds),
// Umrisse (geschlossene Ringe als EIN gehrter Streifen, inkl.
// Schluss-Kante — ausser bei Dash, siehe `stroke_dashed_or_solid`).
Item::Outline(o) => {
if o.width_mm > 0.0 && o.pts.len() >= 2 {
stroke_dashed_or_solid(&mut geo, &o.pts, true, o.color, o.width_mm, false, o.dash.as_deref(), &mut bounds);
}
}
// Offene Polylinien (verbundene Umrisskanten / 2D-Zeichnungszuege) als
// EIN gehrter Streifen — innere Ecken gehrt statt Stumpfkappen.
Item::Poly(pl) => {
if pl.width_mm > 0.0 && pl.pts.len() >= 2 {
stroke_dashed_or_solid(&mut geo, &pl.pts, false, pl.color, pl.width_mm, pl.width_screen, pl.dash.as_deref(), &mut bounds);
}
}
// Freie Einzel-Linien (Tuerblaetter, Referenzlinien).
Item::Line(l) => compile_line(&mut geo, l, &mut bounds),
}
}
// 4) Freie Einzel-Linien (Tuerblaetter, Referenzlinien).
for l in &scene.lines {
compile_line(&mut geo, l, &mut bounds);
}
// 5) Kreisboegen: NICHT mehr in Segmente zerlegt — je Bogen eine analytische
// Instanz (`ArcInstanceData`), die die GPU per SDF-Fragment-Shader exakt
// rund rendert (siehe `gpu::Renderer` Arc-Pipeline).
// Kreisboegen: NICHT in Segmente zerlegt — je Bogen eine analytische Instanz
// (`ArcInstanceData`), die die GPU per SDF-Fragment-Shader exakt rund rendert
// (eigene Pipeline, nach der uebrigen Geometrie — Tuerschwenk liegt oben).
for a in &scene.arcs {
compile_arc(&mut geo, a, &mut bounds);
}
@@ -667,7 +734,7 @@ fn compile_arc(geo: &mut GpuGeometry, a: &Arc, bounds: &mut Bounds) {
fn compile_line(geo: &mut GpuGeometry, l: &Line, bounds: &mut Bounds) {
let w = if l.width_mm > 0.0 { l.width_mm } else { 0.18 };
stroke_dashed_or_solid(geo, &[l.a, l.b], false, l.color, w, l.dash.as_deref(), bounds);
stroke_dashed_or_solid(geo, &[l.a, l.b], false, l.color, w, false, l.dash.as_deref(), bounds);
}
#[cfg(test)]
@@ -815,6 +882,7 @@ mod tests {
color: [0.0, 0.0, 0.0, 1.0],
width_mm: 0.2,
dash: None,
z: 0,
}],
..Default::default()
};
@@ -824,6 +892,7 @@ mod tests {
color: [0.0, 0.0, 0.0, 1.0],
width_mm: 0.2,
dash: Some(vec![10.0, 10.0]),
z: 0,
}],
..Default::default()
};