From 056ca2c288808293c012f986b2ad10e0665a6fbf Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 3 Jul 2026 18:37:49 +0200 Subject: [PATCH] Schnitt/Ansicht: SectionOutput aus render3d an die Zeichenebenen anbinden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - render3d/web.rs: GPU-freies WASM-Binding cut_section_json(model_json, p, n) → JSON {cutPolygons, visibleEdges, hiddenEdges} in (u,v)-Metern. - src/plan/toSection.ts (neu): SectionOutput-Typen, sectionPlaneFromLevel() leitet die Schnittebene aus linePoints/directionSign ab, computeSection() flacht über projectToModel3d ab und ruft das Binding. - src/engine/engine3d.ts (neu): geteilter memoisierter pkg3d-Loader, damit 3D-Viewport und Schnittpfad EINE mod.default()-Init teilen. - generatePlan.ts: generateSectionPlan() übersetzt SectionOutput in bestehende Primitive (Cut = polygon mit Poché-Schraffur, sichtbare Kante = line solid, verdeckte Kante = line dashed) — keine neue Primitive-Art nötig. - App.tsx: SectionPlanView ersetzt den Stub für Ebenen vom Typ Schnitt/Ansicht; rendert dasselbe PlanView, mit lokalisierten Hinweisen (lädt/keine Linie/Fehler). - i18n de/en: section.*-Schlüssel. - Öffnungen erscheinen korrekt (Brüstung/Sturz-Teilrechtecke) über die projectToModel3d-Teilkörper-Zerlegung. Gates: tsc -b 0, build grün (WASM = eigener Lazy-Chunk), render3d cargo test 29/29, wasm32 --features web check grün. --- src-tauri/render3d/src/web.rs | 36 ++++++ src/App.tsx | 202 ++++++++++++++++++++++++++++-- src/engine/engine3d.ts | 23 ++++ src/i18n/de.ts | 5 + src/i18n/en.ts | 5 + src/plan/generatePlan.ts | 116 +++++++++++++++++ src/plan/toSection.ts | 137 ++++++++++++++++++++ src/viewport/useWasm3dRenderer.ts | 20 +-- 8 files changed, 514 insertions(+), 30 deletions(-) create mode 100644 src/engine/engine3d.ts create mode 100644 src/plan/toSection.ts diff --git a/src-tauri/render3d/src/web.rs b/src-tauri/render3d/src/web.rs index 45d9962..4d9d7b7 100644 --- a/src-tauri/render3d/src/web.rs +++ b/src-tauri/render3d/src/web.rs @@ -25,6 +25,7 @@ use wasm_bindgen::prelude::*; use web_sys::HtmlCanvasElement; use crate::gpu::Renderer; +use crate::section::{cut_section, SectionPlane}; use crate::types::{Camera, Projection, SlabInput, WallInput}; /// JSON-Eingabe des Modells: Waende (bereits in Teilquader zerlegt, siehe @@ -37,6 +38,41 @@ struct ModelInput { slabs: Vec, } +/// Berechnet einen Schnitt/eine Ansicht (`section::cut_section`) gegen das Modell +/// und gibt das `SectionOutput` als JSON-String zurueck. GPU-frei — reine +/// Prismen-Arithmetik (siehe `section.rs`); braucht KEINEN `WebModelRenderer` +/// (kein WebGPU-Kontext), nur das geladene WASM-Modul. Aufruf aus JS: +/// +/// ```js +/// const json = mod.cut_section_json( +/// JSON.stringify({ walls, slabs }), // gleiches Format wie set_model +/// px, py, pz, // Punkt auf der Schnittebene (world-Meter, Y-up) +/// nx, ny, nz, // Ebenennormale = Blickrichtung (wird normalisiert) +/// ); +/// const out = JSON.parse(json); // { cutPolygons, visibleEdges, hiddenEdges } +/// ``` +/// +/// Koordinaten der Ausgabe: (u, v) in Metern — `u` horizontal entlang der +/// Ebene, `v = world.y` (absolute Hoehe). Siehe `section.rs`-Moduldoc. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn cut_section_json( + model_json: &str, + px: f32, + py: f32, + pz: f32, + nx: f32, + ny: f32, + nz: f32, +) -> Result { + let model: ModelInput = serde_json::from_str(model_json) + .map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?; + let plane = SectionPlane::new([px, py, pz], [nx, ny, nz]); + let out = cut_section(&plane, &model.walls, &model.slabs); + serde_json::to_string(&out) + .map_err(|e| JsValue::from_str(&format!("SectionOutput serialisieren: {e}"))) +} + /// Der Browser-Renderer: haelt Surface + Device/Queue + den geteilten `Renderer` /// und die aktuelle Kamera. Wird per `WebModelRenderer.new(canvas)` aus JS erzeugt. #[wasm_bindgen] diff --git a/src/App.tsx b/src/App.tsx index c056fe0..6af317f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,8 +35,10 @@ import type { Vec2, Wall, } from "./model/types"; -import { generatePlan } from "./plan/generatePlan"; +import { generatePlan, generateSectionPlan } from "./plan/generatePlan"; import type { CategoryDisplayResolver } from "./plan/generatePlan"; +import { computeSection } from "./plan/toSection"; +import type { SectionOutput } from "./plan/toSection"; import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync"; import { parseDxf } from "./io/dxfParser"; import type { DxfImportResult } from "./io/dxfParser"; @@ -3836,7 +3838,40 @@ function Content({ onEdit3dEnd: () => void; }) { if (level.kind === "section" || level.kind === "elevation") { - return ; + return ( +
+ +
+ ); } // Freie Zeichnung: dieselbe Plan-Ansicht wie ein Geschoss (generatePlan filtert @@ -4154,18 +4189,161 @@ function LevelPlanView({ ); } -/** Platzhalter für Schnitt/Ansicht (kommt in einer späteren Phase). */ -function StubView({ level }: { level: DrawingLevel }) { - return ( -
+/** + * Schnitt-/Ansichts-Ebene: der render3d-Schnitt-Extraktor (WASM, plan/toSection) + * durchschneidet das Modell entlang der Schnittlinie der Ebene und übersetzt das + * Ergebnis (Cut-Polygone + sichtbare/verdeckte Kanten) über generateSectionPlan + * in Plan-Primitive, die dieselbe PlanView wie ein Grundriss zeichnet. + * + * Die Berechnung ist asynchron (WASM lazy geladen); bis das Ergebnis da ist, + * bzw. wenn die Ebene keine Schnittlinie trägt oder das WASM-Modul den Export + * (noch) nicht kennt, wird ein Hinweis eingeblendet. + */ +function SectionPlanView({ + project, + level, + hairline, + mono, + planViewRef, + onCursor, + onZoom, + onScale, + selectedWallIds, + selectedCeilingIds, + selectedOpeningIds, + selectedStairIds, + selectedRoomIds, + roomStamp, + onStampMove, + onStampEdit, + onPlanSelect, + onPlanMarquee, + onSegmentCut, + onPlanContextMenu, + activeTool, + toolInputActive, + toolHandlers, + grips, + edgeGrips, + selectedDrawingId, + selectedDrawingIds, + gripHandlers, +}: { + project: Project; + level: DrawingLevel; + hairline: boolean; + mono: boolean; + planViewRef: React.RefObject; + onCursor: (model: Vec2 | null) => void; + onZoom: (percent: number) => void; + onScale: (n: number) => void; + selectedWallIds: string[]; + selectedCeilingIds: string[]; + selectedOpeningIds: string[]; + selectedStairIds: string[]; + selectedRoomIds: string[]; + roomStamp: { roomId: string; anchor: Vec2 } | null; + onStampMove: (roomId: string, delta: Vec2) => void; + onStampEdit: (roomId: string) => void; + onPlanSelect: (sel: PlanSelection) => void; + onPlanMarquee: (sel: MarqueeSelection) => void; + onSegmentCut: (drawingId: string, model: Vec2) => void; + onPlanContextMenu: (info: { clientX: number; clientY: number; model: Vec2 }) => void; + activeTool: ToolId; + toolInputActive: boolean; + toolHandlers: ToolHandlers; + grips: Vec2[]; + edgeGrips: EdgeGrip[]; + selectedDrawingId: string | null; + selectedDrawingIds?: string[]; + gripHandlers: GripHandlers; +}) { + const [output, setOutput] = useState(null); + const [status, setStatus] = useState<"loading" | "ready" | "empty" | "error">( + "loading", + ); + + useEffect(() => { + let disposed = false; + setStatus("loading"); + setOutput(null); + void (async () => { + try { + const out = await computeSection(project, level); + if (disposed) return; + if (!out) { + setStatus("empty"); + return; + } + setOutput(out); + setStatus("ready"); + } catch (e) { + console.warn("render3d-Schnitt fehlgeschlagen:", e); + if (!disposed) setStatus("error"); + } + })(); + return () => { + disposed = true; + }; + }, [project, level]); + + const plan = useMemo( + () => (output ? generateSectionPlan(output, mono) : null), + [output, mono], + ); + + const kindLabel = level.kind === "section" ? t("stub.section") : t("stub.elevation"); + + if (!plan || status !== "ready") { + const note = + status === "empty" + ? t("section.empty", { kind: kindLabel }) + : status === "error" + ? t("section.error", { kind: kindLabel }) + : t("section.loading", { kind: kindLabel }); + return (
{level.name} - - {t("stub.note", { - kind: level.kind === "section" ? t("stub.section") : t("stub.elevation"), - })} - + {note}
-
+ ); + } + + return ( +
+
+ {t("content.sectionTitle", { name: level.name, kind: kindLabel })} +
+ +
{t("section.legend", { kind: kindLabel })}
+
); } diff --git a/src/engine/engine3d.ts b/src/engine/engine3d.ts new file mode 100644 index 0000000..43de6c2 --- /dev/null +++ b/src/engine/engine3d.ts @@ -0,0 +1,23 @@ +// Gemeinsamer Lade-Einstieg für das render3d-WASM-Modul (pkg3d, gebaut über +// `npm run build:engine3d`). EINMAL geladen + initialisiert und memoisiert: +// `mod.default()` (der wasm-bindgen-Init) darf nur EINMAL laufen — ein zweiter +// Init würde die Closures des ersten verwerfen („closure invoked recursively or +// after being dropped"). Sowohl der 3D-Viewport (useWasm3dRenderer) als auch die +// Schnitt-/Ansichts-Pipeline (plan/toSection) teilen sich daher DIESE Instanz. +// +// Bewusst schwach typisiert (`any`): das echte `.d.ts` entsteht erst beim +// wasm-Build; die Aufrufer legen ihre eigene Minimal-Schnittstelle darüber. + +let enginePromise: Promise | null = null; + +/** Lädt das WASM-Modul einmalig und gibt das initialisierte Modul zurück. */ +export function loadEngine3d(): Promise { + if (!enginePromise) { + enginePromise = (async () => { + const mod: any = await import("./pkg3d/render3d.js"); + await mod.default(); // lädt render3d_bg.wasm + return mod; + })(); + } + return enginePromise; +} diff --git a/src/i18n/de.ts b/src/i18n/de.ts index fc35e49..a4e73d8 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -397,6 +397,11 @@ export const de = { "content.drawingTitle": "Zeichnung · {name}", "content.drawingCanvas": "Zeichenfläche", "content.drawingLegend": "Freie 2D-Zeichnung", + "content.sectionTitle": "{kind} · {name}", + "section.loading": "{kind} wird berechnet …", + "section.empty": "{kind} — keine Schnittlinie im Grundriss gesetzt", + "section.error": "{kind} — Berechnung fehlgeschlagen (render3d-WASM neu bauen?)", + "section.legend": "{kind} · abgeleitete Projektion (render3d)", "props.floorHeight": "Geschosshöhe", "props.cutHeight": "Schnitthöhe", "props.okff": "OKFF", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 97a653d..0126070 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -394,6 +394,11 @@ export const en: Record = { "content.drawingTitle": "Drawing · {name}", "content.drawingCanvas": "Drawing canvas", "content.drawingLegend": "Free 2D drawing", + "content.sectionTitle": "{kind} · {name}", + "section.loading": "Computing {kind} …", + "section.empty": "{kind} — no section line set in the plan", + "section.error": "{kind} — computation failed (rebuild render3d WASM?)", + "section.legend": "{kind} · derived projection (render3d)", "props.floorHeight": "Floor height", "props.cutHeight": "Cut height", "props.okff": "FFL", diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index c690fe0..339d46c 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -66,6 +66,7 @@ import type { Line } from "../model/geometry"; export type DetailLevel = "grob" | "mittel" | "fein"; import { computeJoins } from "../model/joins"; import type { WallCuts } from "../model/joins"; +import type { SectionOutput } from "./toSection"; /** Stroke-Farbe der Schicht-Polygone (dunkles Grau). */ const POCHE_STROKE = "#2b3039"; @@ -401,6 +402,121 @@ export function generatePlan( }; } +// ── Schnitt/Ansicht (abgeleitete Projektion aus render3d::section) ────────────── +// Die Rohdaten (Cut-Polygone + sichtbare/verdeckte projizierte Kanten) liefert +// der WASM-Extraktor (plan/toSection.ts → section.rs). Hier werden sie 1:1 in +// die BESTEHENDEN Plan-Primitive übersetzt — kein neues Primitiv nötig: +// • Cut-Polygon → `polygon` (kräftige Poché-Umrisslinie + Schnitt-Schraffur), +// • sichtbare Kante → `line` (durchgezogen), +// • verdeckte Kante → `line` (gestrichelt). +// Damit rendern PlanView (SVG) UND toRenderScene (Engine/WASM-2D) den Schnitt +// ohne jede Sonderbehandlung, mit voller Papier-mm-/Parität-Infrastruktur. + +/** Tinte der Schnitt-Poché-Umrisslinie + Schraffur (dunkles Grau). */ +const SECTION_INK = "#1f242c"; +/** Tinte der sichtbaren Ansichtskanten. */ +const SECTION_VISIBLE_INK = "#2b3039"; +/** Tinte der verdeckten (gestrichelten) Ansichtskanten (etwas heller). */ +const SECTION_HIDDEN_INK = "#8a929c"; +/** Strichstärke der Cut-Poché-Umrisslinie (mm Papier, kräftig wie eine Wand). */ +const SECTION_CUT_OUTLINE_MM = 0.35; +/** Strichstärke sichtbarer Ansichtskanten (mm Papier). */ +const SECTION_VISIBLE_MM = 0.18; +/** Strichstärke verdeckter Ansichtskanten (mm Papier, dünner). */ +const SECTION_HIDDEN_MM = 0.13; +/** Strichmuster verdeckter Kanten (mm Papier). */ +const SECTION_HIDDEN_DASH: number[] = [0.12, 0.08]; + +/** Schnitt-Schraffur der Cut-Flächen (diagonale Poché wie ein Wandschnitt). */ +const SECTION_HATCH: HatchRender = { + pattern: "diagonal", + scale: 1, + angle: 45, + color: SECTION_INK, + lineWeight: 0.13, + dash: null, +}; + +/** RGB (0..1) → 6-stelliges Hex, für die Cut-Poché-Füllfarbe. */ +function rgbToHex(rgb: [number, number, number]): string { + const c = (v: number) => + Math.max(0, Math.min(255, Math.round(v * 255))) + .toString(16) + .padStart(2, "0"); + return `#${c(rgb[0])}${c(rgb[1])}${c(rgb[2])}`; +} + +/** + * Baut aus einem {@link SectionOutput} (render3d::section, (u, v)-Meter) den + * Plan eines Schnitts/einer Ansicht. `u` wird zur Plan-X, `v` (absolute Höhe) + * zur Plan-Y — die PlanView zeigt Y nach oben, also erscheint der Schnitt + * aufrecht. Rein darstellend; kennt das Projekt nicht. + */ +export function generateSectionPlan(output: SectionOutput, mono = false): Plan { + const primitives: Primitive[] = []; + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + const acc = (x: number, y: number) => { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + }; + + // Cut-Polygone: gefüllte Poché-Fläche (Bauteilfarbe) + Schnitt-Schraffur + + // kräftige Umrisslinie — wie eine geschnittene Wand im Grundriss. + for (const cp of output.cutPolygons) { + if (cp.pts.length < 3) continue; + const pts: Vec2[] = cp.pts.map(([u, v]) => { + acc(u, v); + return { x: u, y: v }; + }); + // Mono: reines Weiss statt Bauteilfarbe (nur Umriss + Schraffur tragen). + primitives.push({ + kind: "polygon", + pts, + fill: mono ? "#ffffff" : rgbToHex(cp.color), + stroke: SECTION_INK, + strokeWidthMm: SECTION_CUT_OUTLINE_MM, + hatch: SECTION_HATCH, + }); + } + + // Verdeckte Kanten zuerst (liegen unter den sichtbaren), gestrichelt. + for (const e of output.hiddenEdges) { + acc(e.a[0], e.a[1]); + acc(e.b[0], e.b[1]); + primitives.push({ + kind: "line", + a: { x: e.a[0], y: e.a[1] }, + b: { x: e.b[0], y: e.b[1] }, + cls: "section-hidden", + weightMm: SECTION_HIDDEN_MM, + dash: SECTION_HIDDEN_DASH, + color: SECTION_HIDDEN_INK, + }); + } + + // Sichtbare Ansichtskanten, durchgezogen. + for (const e of output.visibleEdges) { + acc(e.a[0], e.a[1]); + acc(e.b[0], e.b[1]); + primitives.push({ + kind: "line", + a: { x: e.a[0], y: e.a[1] }, + b: { x: e.b[0], y: e.b[1] }, + cls: "section-visible", + weightMm: SECTION_VISIBLE_MM, + color: SECTION_VISIBLE_INK, + }); + } + + if (!isFinite(minX)) return { primitives, bounds: { minX: 0, minY: 0, maxX: 1, maxY: 1 } }; + return { primitives, bounds: { minX, minY, maxX, maxY } }; +} + /** Dezente Plan-Farbe der Kontext-Konturen (gedämpftes Grau). */ const CONTEXT_LINE_COLOR = "#9aa3ad"; /** Strichstärke der Kontext-Konturen (mm Papier, dünn). */ diff --git a/src/plan/toSection.ts b/src/plan/toSection.ts new file mode 100644 index 0000000..8d3113f --- /dev/null +++ b/src/plan/toSection.ts @@ -0,0 +1,137 @@ +// Schnitt-/Ansichts-Pipeline: bindet den analytischen render3d-Schnitt-Extraktor +// (`section.rs`, WASM-Export `cut_section_json`) an das Dokumentmodell an. +// +// Ablauf: +// 1. Aus einer Zeichnungsebene (DrawingLevel, kind "section"/"elevation") wird +// eine Schnittebene abgeleitet — die Grundriss-Schnittlinie `linePoints` +// plus `directionSign` (Blickrichtung). +// 2. Das Projekt wird über `projectToModel3d` (dieselbe Wand-/Decken-Zerlegung +// wie der 3D-Viewport) zu Wänden + Decken geflacht und als JSON an +// `cut_section_json` gereicht. +// 3. Das zurückgegebene `SectionOutput` (Cut-Polygone + sichtbare/verdeckte +// Kanten, alles in (u, v)-Metern) wird in `generateSectionPlan` +// (generatePlan.ts) zu Plan-Primitiven übersetzt. +// +// Einheiten: METER. Koordinaten der Ausgabe: u = horizontal entlang der Ebene, +// v = absolute Höhe (world.y). Siehe src-tauri/render3d/src/section.rs. + +import type { DrawingLevel, Project } from "../model/types"; +import { projectToModel3d } from "./toWalls3d"; +import { loadEngine3d } from "../engine/engine3d"; + +/** Bauteil-Referenz eines Cut-Polygons/einer Kante (Art + Eingabe-Index). */ +export interface SectionComponentRef { + kind: "wall" | "slab"; + index: number; +} + +/** Ein geschnittenes Bauteil-Rechteck in (u, v)-Metern (Ring, implizit geschlossen). */ +export interface SectionCutPolygon { + component: SectionComponentRef; + /** Albedo-Farbe des Bauteils (RGB 0..1). */ + color: [number, number, number]; + /** Ring-Ecken (u, v). */ + pts: Array<[number, number]>; +} + +/** Ein projiziertes Liniensegment in (u, v)-Metern. */ +export interface SectionEdge { + component: SectionComponentRef; + a: [number, number]; + b: [number, number]; +} + +/** Vollständige Ausgabe eines Schnitt-/Ansichts-Laufs (JSON von `cut_section_json`). */ +export interface SectionOutput { + cutPolygons: SectionCutPolygon[]; + visibleEdges: SectionEdge[]; + hiddenEdges: SectionEdge[]; +} + +/** Minimal-Typ des WASM-Exports (das echte `.d.ts` entsteht erst beim wasm-Build). */ +interface Engine3dSection { + cut_section_json( + modelJson: string, + px: number, + py: number, + pz: number, + nx: number, + ny: number, + nz: number, + ): string; +} + +/** + * Eine Schnittebene in world-Koordinaten (Y-up): Punkt auf der Ebene + + * Normale (= Blickrichtung, zeigt vom Betrachter ins Modell). + */ +export interface SectionPlaneSpec { + point: [number, number, number]; + normal: [number, number, number]; +} + +/** + * Leitet aus einer Schnitt-/Ansichtsebene die world-Schnittebene ab. Die + * Grundriss-Schnittlinie `linePoints` [p0, p1] (Modell-Meter) liegt in der + * XZ-Ebene (world.x = model.x, world.z = model.y); die Ebenennormale steht + * senkrecht auf der Linie in der Horizontalen und wird über `directionSign` + * (Voreinstellung +1) in die gewünschte Blickrichtung gedreht. + * + * Gibt `null` zurück, wenn die Ebene (noch) keine Schnittlinie trägt (z. B. eine + * frisch angelegte Ansicht ohne gesetzte Linie) — der Aufrufer zeigt dann einen + * Hinweis statt eines leeren Plans. + */ +export function sectionPlaneFromLevel(level: DrawingLevel): SectionPlaneSpec | null { + const line = level.linePoints; + if (!line) return null; + const [p0, p1] = line; + const dx = p1.x - p0.x; + const dy = p1.y - p0.y; + const len = Math.hypot(dx, dy); + if (len < 1e-9) return null; + const ux = dx / len; + const uy = dy / len; + // Links-Normale der Linie im Grundriss (n = (-u.y, u.x)), per directionSign + // in die Blickrichtung gedreht. Modell (nx, ny) → world (nx, 0, ny). + const sign = level.directionSign ?? 1; + const nx = -uy * sign; + const ny = ux * sign; + return { + point: [p0.x, 0, p0.y], + normal: [nx, 0, ny], + }; +} + +/** + * Berechnet den Schnitt/die Ansicht der Ebene `level` gegen das Modell `project` + * über den WASM-Extraktor. Asynchron (das render3d-WASM-Modul wird lazy geladen). + * + * Gibt `null` zurück, wenn die Ebene keine gültige Schnittlinie hat. Wirft, wenn + * das WASM-Modul den Export (noch) nicht bereitstellt — der Aufrufer fängt das ab + * und blendet einen Fallback ein (pkg3d muss dann über `npm run build:engine3d` + * neu gebaut werden, damit `cut_section_json` verfügbar ist). + */ +export async function computeSection( + project: Project, + level: DrawingLevel, +): Promise { + const plane = sectionPlaneFromLevel(level); + if (!plane) return null; + const mod = (await loadEngine3d()) as Engine3dSection; + if (typeof mod.cut_section_json !== "function") { + throw new Error( + "render3d-WASM ohne cut_section_json — pkg3d neu bauen (npm run build:engine3d)", + ); + } + const modelJson = JSON.stringify(projectToModel3d(project)); + const json = mod.cut_section_json( + modelJson, + plane.point[0], + plane.point[1], + plane.point[2], + plane.normal[0], + plane.normal[1], + plane.normal[2], + ); + return JSON.parse(json) as SectionOutput; +} diff --git a/src/viewport/useWasm3dRenderer.ts b/src/viewport/useWasm3dRenderer.ts index 0286b8e..208c285 100644 --- a/src/viewport/useWasm3dRenderer.ts +++ b/src/viewport/useWasm3dRenderer.ts @@ -19,6 +19,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Project } from "../model/types"; import { projectToModel3d } from "../plan/toWalls3d"; +import { loadEngine3d } from "../engine/engine3d"; /** Fertige Kamera für einen Frame (world-Meter, Y-up — wie render3d::types::Camera). */ export interface Camera3d { @@ -54,23 +55,6 @@ interface WebModelRendererLike { render(): void; } -/** - * WASM-Modul EINMAL laden + initialisieren (memoisiert). `mod.default()` darf - * nur einmal laufen — ein zweiter Init (React-StrictMode-Doppelmount) verwirft - * die Closures des ersten („closure invoked recursively or after being dropped"). - */ -let enginePromise: Promise | null = null; -function loadEngine(): Promise { - if (!enginePromise) { - enginePromise = (async () => { - const mod: any = await import("../engine/pkg3d/render3d.js"); - await mod.default(); // lädt render3d_bg.wasm - return mod; - })(); - } - return enginePromise; -} - /** * Renderer je Canvas EINMAL erzeugen (memoisiert). Ein Canvas kann nur EINEN * WebGPU-Kontext tragen; ein zweiter `WebModelRenderer.new` auf demselben @@ -82,7 +66,7 @@ function getRenderer(canvas: HTMLCanvasElement): Promise { let p = rendererCache.get(canvas); if (!p) { p = (async () => { - const mod = await loadEngine(); + const mod = await loadEngine3d(); // Canvas VOR dem Surface-Erzeugen auf die Zielpixel (DPR) bringen. const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1;