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
+15 -3
View File
@@ -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) => {
+34 -16
View File
@@ -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(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++) {
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
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 });
}
}
}
+13 -31
View File
@@ -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) {