Native wgpu-Viewports (2D+3D) im Tauri-Prozess: echtes Modell + gehrte Ecken

- native.rs: EINE winit-EventLoop hostet 2D- und 3D-Fenster (winit erlaubt nur
  eine Loop pro Prozess) — loest den RecreationAttempt-Panic zweier Loops; ersetzt
  native2d.rs/native3d.rs. Feature-gegated (native2d/native3d, einzeln oder zusammen).
- render2d/render3d laden das ECHTE Modell aus assets/native2d_scene.json bzw.
  native3d_walls.json (Demo-Szene als Fallback); initialer Ausschnitt/Kamera aus
  den Modell-Grenzen gerahmt, initialer Redraw + gesetzte Fenstergroesse.
- TS-Konverter toRenderScene/toWalls3d + scripts/dump-native-scene erzeugen die
  JSON aus sampleProject/generatePlan (npm run dump:native).
- render2d: Scene.polylines fuer zusammenhaengende Umriss-/Zeichnungslaeufe →
  Gehrung statt Stumpfkappen an Wandecken/2D-Geometrien; MITER_LIMIT 4→8
  (deckungsgleich mit SVG stroke-miterlimit:8 und WebGL2).
- native3d-Feature + render3d-Pfad-Dep in der Tauri-Crate.
This commit is contained in:
2026-07-02 08:51:59 +02:00
parent c93b168f5c
commit bda256d3a4
15 changed files with 1045 additions and 276 deletions
+226
View File
@@ -0,0 +1,226 @@
// Flacht einen {@link Plan} (die eine Wahrheit aus generatePlan) auf das
// GPU-nahe Szenenformat des nativen render2d-Renderers ab: Füllpolygone,
// Umrisse und Linien. Hatch und Text bleiben (wie im Rust-Renderer, siehe
// render2d/src/types.rs) vorerst außen vor — sie sind Overlay/späteres M3.
//
// Das erzeugte Objekt matcht 1:1 die serde-Structs render2d::types::Scene
// { fills:[{pts,color}], outlines:[{pts,color,widthMm}], lines:[{a,b,color,widthMm}] }
// mit Point = [x,y] (Meter) und Rgba = [r,g,b,a] (0..1).
import type { Plan, Primitive } from "./generatePlan";
type LinePrim = Extract<Primitive, { kind: "line" }>;
export type RPoint = [number, number];
export type RRgba = [number, number, number, number];
export interface RFill {
pts: RPoint[];
color: RRgba;
}
export interface ROutline {
pts: RPoint[];
color: RRgba;
widthMm: number;
}
export interface RPolyline {
pts: RPoint[];
color: RRgba;
widthMm: number;
}
export interface RLine {
a: RPoint;
b: RPoint;
color: RRgba;
widthMm: number;
}
export interface RScene {
fills: RFill[];
outlines: ROutline[];
polylines: RPolyline[];
lines: RLine[];
}
const DEFAULT_LINE = "#1a1a1a";
/** Wenige benannte Farben, die in Klassen/Defaults vorkommen können. */
const NAMED: Record<string, string> = {
black: "#000000",
white: "#ffffff",
red: "#ff0000",
none: "none",
transparent: "none",
};
/** Hex/Named-Farbe → [r,g,b,a] in 0..1, oder null bei "none"/leer. */
function toRgba(input: string | undefined, alpha = 1): RRgba | null {
if (!input) return null;
let h = input.trim().toLowerCase();
if (h === "none" || h === "transparent") return null;
if (h[0] !== "#") {
const mapped = NAMED[h];
if (!mapped) return [0, 0, 0, alpha];
if (mapped === "none") return null;
h = mapped;
}
h = h.slice(1);
// Kurzformen auf Langform expandieren (#rgb → #rrggbb, #rgba → #rrggbbaa).
if (h.length === 3 || h.length === 4) h = h.split("").map((c) => c + c).join("");
if (h.length !== 6 && h.length !== 8) return [0, 0, 0, alpha];
const r = parseInt(h.slice(0, 2), 16) / 255;
const g = parseInt(h.slice(2, 4), 16) / 255;
const b = parseInt(h.slice(4, 6), 16) / 255;
// 8-stelliges Hex trägt den Alpha-Kanal selbst; sonst der Parameter.
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : alpha;
if (![r, g, b, a].every((v) => Number.isFinite(v))) return [0, 0, 0, alpha];
return [r, g, b, a];
}
/** Kreisbogen in Liniensegmente zerlegen (kürzerer Sweep, ~24 Segmente). */
function tessellateArc(
center: { x: number; y: number },
from: { x: number; y: number },
to: { x: number; y: number },
r: number,
): RPoint[] {
const a0 = Math.atan2(from.y - center.y, from.x - center.x);
let a1 = Math.atan2(to.y - center.y, to.x - center.x);
// Kürzeren Bogen wählen (generatePlan liefert keine largeArc-Info mit).
let delta = a1 - a0;
while (delta > Math.PI) delta -= 2 * Math.PI;
while (delta < -Math.PI) delta += 2 * Math.PI;
a1 = a0 + delta;
const segs = Math.max(2, Math.ceil((Math.abs(delta) / (Math.PI * 2)) * 48));
const pts: RPoint[] = [];
for (let i = 0; i <= segs; i++) {
const t = a0 + (delta * i) / segs;
pts.push([center.x + Math.cos(t) * r, center.y + Math.sin(t) * r]);
}
return pts;
}
/** Zwei Modell-Punkte (Meter) als gleich behandeln (Verkettungs-Toleranz). */
function samePt(a: RPoint, b: RPoint): boolean {
return Math.abs(a[0] - b[0]) < 1e-6 && Math.abs(a[1] - b[1]) < 1e-6;
}
/** Gleicher Linienstil (für das Verketten aufeinanderfolgender 2D-Zeichenlinien). */
function sameLineStyle(a: LinePrim, b: LinePrim): boolean {
return (
a.cls === b.cls &&
a.weightMm === b.weightMm &&
(a.color ?? "") === (b.color ?? "") &&
JSON.stringify(a.dash ?? null) === JSON.stringify(b.dash ?? null)
);
}
/**
* Zerlegt einen Polygon-Ring in zusammenhängende Läufe SICHTBARER Kanten (die
* `noStroke`-Kanten werden ausgelassen). Jeder Lauf beginnt an einer sichtbaren
* Kante, deren Vorgänger unterdrückt ist. Portiert aus PlanView.visibleEdgeRuns —
* so bekommen die inneren Ecken eine Gehrung statt Stumpfkappen.
*/
function visibleEdgeRuns(pts: RPoint[], noStroke: number[]): RPoint[][] {
const n = pts.length;
const skip = new Set(noStroke.map((i) => ((i % n) + n) % n));
if (skip.size >= n || n < 2) return [];
const visible = (i: number) => !skip.has(((i % n) + n) % n);
const runs: RPoint[][] = [];
for (let s = 0; s < n; s++) {
if (!visible(s) || visible(s - 1 + n)) continue; // kein Lauf-Anfang
const run: RPoint[] = [pts[s]];
let j = s;
while (visible(j) && j - s < n) {
run.push(pts[(j + 1) % n]);
j++;
}
runs.push(run);
}
return runs;
}
/**
* Wandelt einen Plan in eine native render2d-Szene um. Zusammenhängende Umriss-
* und Zeichnungskanten werden zu OFFENEN Polylinien verkettet (gehrte Ecken);
* nur genuin einzelne Segmente (Türblätter, Referenzlinien) bleiben `lines`.
*/
export function planToRenderScene(plan: Plan): RScene {
const fills: RFill[] = [];
const outlines: ROutline[] = [];
const polylines: RPolyline[] = [];
const lines: RLine[] = [];
// 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 closed = curPts.length > 2 && samePt(curPts[0], curPts[curPts.length - 1]);
if (closed) {
outlines.push({ pts: curPts.slice(0, -1), color: col, widthMm: curSrc.weightMm });
} else {
polylines.push({ pts: curPts, color: col, widthMm: curSrc.weightMm });
}
}
curPts = null;
curSrc = null;
};
for (const p of plan.primitives) {
if (p.kind === "polygon") {
flushRun();
const pts: RPoint[] = p.pts.map((v) => [v.x, v.y]);
if (pts.length < 2) continue;
// Füllung
const fillCol = toRgba(p.fill, 1);
if (fillCol && pts.length >= 3) fills.push({ pts, color: fillCol });
// Umriss
const strokeCol = toRgba(p.stroke, 1);
if (strokeCol && p.strokeWidthMm > 0) {
const suppressed = p.noStrokeEdges ?? [];
if (suppressed.length === 0) {
// Ganzer Ring → geschlossener, gehrter Umriss.
outlines.push({ pts, color: strokeCol, widthMm: p.strokeWidthMm });
} else {
// Nur die sichtbaren Kanten, als zusammenhängende (offene) Läufe.
for (const run of visibleEdgeRuns(pts, suppressed)) {
polylines.push({ pts: run, color: strokeCol, widthMm: p.strokeWidthMm });
}
}
}
} else if (p.kind === "line") {
if (p.drawingId) {
// Aufeinanderfolgende 2D-Zeichenlinien gleicher drawingId/Stil verketten.
const a: RPoint = [p.a.x, p.a.y];
const b: RPoint = [p.b.x, p.b.y];
if (curPts && curSrc && sameLineStyle(curSrc, p) && samePt(curPts[curPts.length - 1], a)) {
curPts.push(b);
} else {
flushRun();
curPts = [a, b];
curSrc = p;
}
} 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 });
}
} else if (p.kind === "arc") {
flushRun();
const col = toRgba(DEFAULT_LINE, 1) ?? [0.1, 0.1, 0.1, 1];
// Bogen als EINE zusammenhängende Polylinie (gehrte Sehnen-Ecken).
const poly = tessellateArc(p.center, p.from, p.to, p.r);
if (poly.length >= 2) polylines.push({ pts: poly, color: col, widthMm: p.weightMm });
} else {
// "text" wird bewusst übersprungen (render2d rendert keinen Text).
flushRun();
}
}
flushRun();
return { fills, outlines, polylines, lines };
}