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
+107 -20
View File
@@ -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 });
}
}
}