2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand

Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
2026-07-02 00:12:39 +02:00
parent cfe5249440
commit 3d2d4d6321
184 changed files with 29421 additions and 669 deletions
+270
View File
@@ -0,0 +1,270 @@
// Hidden-Line-Removal (HLR) über OCCT-WASM — Welle-C-Spike.
//
// Ziel: aus einfachen Volumenkörpern (Wände, Decke) eine 2D-Vektor-Projektion
// (Schnitt/Ansicht) mit ENTFERNTEN verdeckten Kanten erzeugen — die Grundlage,
// aus der später die bestehende SVG-Plan-Pipeline Schnitte/Ansichten zeichnet.
//
// Kernbefund des Spikes (siehe Report): der opencascade.js-Vollbuild 1.1.1
// exportiert die klassischen HLR-Klassen (`HLRBRep_Algo`/`HLRBRep_HLRToShape`,
// `HLRBRep_PolyAlgo`/`HLRBRep_PolyHLRToShape`) NICHT als konstruierbare Klassen,
// sondern nur deren `Handle_…`-Wrapper. Konstruierbar ist aber der High-Level-
// Wrapper `HLRAppli_ReflectLines`, der intern GENAU den exakten HLR-Algorithmus
// (`HLRBRep_Algo`) fährt und sichtbare/verdeckte Kanten getrennt liefert —
// aufgeteilt nach Kanten-Typ (Sharp/OutLine/…). Das ist der hier genutzte Pfad.
//
// Identifier englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: METER.
import {
loadOcct,
type GpPnt,
type OpenCascadeInstance,
type TopoDS_Shape,
} from "./occt";
/** 2D-Punkt in der Projektionsebene (Meter). */
export interface Pt2 {
x: number;
y: number;
}
/** Achsparalleler Quader: Ursprung (min-Ecke) + Größe (Meter). */
export interface BoxSpec {
origin: [number, number, number];
size: [number, number, number];
}
/**
* Orthographische Blickrichtung. `dir` = Blickrichtung (worauf die Kamera
* schaut), `up` = Hoch-Achse der Projektionsebene. Voreinstellungen decken die
* Ansichtstypen der Roadmap ab (Grundriss = Top, Schnitt/Ansicht = Front/…).
*/
export interface ViewDir {
dir: [number, number, number];
up: [number, number, number];
}
/** Häufige Projektionen (rechtshändig, Z = oben). */
export const VIEWS = {
/** Grundriss: von oben (Z), Hoch-Achse = +Y. */
top: { dir: [0, 0, -1], up: [0, 1, 0] } as ViewDir,
/** Ansicht/Schnitt von vorne (Y), Hoch-Achse = +Z. */
front: { dir: [0, -1, 0], up: [0, 0, 1] } as ViewDir,
/** Ansicht/Schnitt von der Seite (X), Hoch-Achse = +Z. */
side: { dir: [-1, 0, 0], up: [0, 0, 1] } as ViewDir,
} as const;
/** Eine projizierte Polylinie (2D) mit Sichtbarkeits-Flag. */
export interface HlrPolyline {
pts: Pt2[];
/** `true` = sichtbare Kante, `false` = verdeckte Kante. */
visible: boolean;
/** Kanten-Herkunft (für spätere Stil-/Linien-Zuordnung). */
kind: "sharp" | "outline";
}
/** Ergebnis eines HLR-Laufs. */
export interface HlrResult {
visible: HlrPolyline[];
hidden: HlrPolyline[];
/** Reine Rechenzeit des HLR (ohne WASM-Init), Millisekunden. */
hlrMs: number;
}
export interface HlrOptions {
/** Verdeckte Kanten mitberechnen (Default: true). */
includeHidden?: boolean;
/** Silhouetten-/OutLine-Kanten mitnehmen (Default: true). */
includeOutline?: boolean;
}
// ── Geometrie-Aufbau ─────────────────────────────────────────────────────────
function makeBox(oc: OpenCascadeInstance, spec: BoxSpec): TopoDS_Shape {
const [ox, oy, oz] = spec.origin;
const [sx, sy, sz] = spec.size;
const p0 = new oc.gp_Pnt_3(ox, oy, oz);
const p1 = new oc.gp_Pnt_3(ox + sx, oy + sy, oz + sz);
return new oc.BRepPrimAPI_MakeBox_3(p0, p1).Shape();
}
/** Mehrere Boxen zu EINEM Solid verschmelzen (BRepAlgoAPI_Fuse, sequenziell). */
function fuseBoxes(oc: OpenCascadeInstance, specs: BoxSpec[]): TopoDS_Shape {
if (specs.length === 0) throw new Error("hlr: keine Box-Spezifikation");
let acc = makeBox(oc, specs[0]);
for (let i = 1; i < specs.length; i++) {
const next = makeBox(oc, specs[i]);
const op = new oc.BRepAlgoAPI_Fuse_3(acc, next);
op.Build?.();
acc = op.Shape();
}
return acc;
}
// ── Kanten-Extraktion ────────────────────────────────────────────────────────
/**
* Eine (bereits 2D-projizierte) OCCT-Kante als Polylinie ausgeben. Die HLR-
* Kanten sind gerade Segmente (Box-Fusion), daher genügen Start-/Endpunkt der
* Kurve; für spätere gekrümmte Geometrie hier zusätzlich tessellieren.
*/
function edgeToPolyline(
oc: OpenCascadeInstance,
edgeShape: TopoDS_Shape,
): Pt2[] {
const edge = oc.TopoDS.Edge_1(edgeShape);
const adaptor = new oc.BRepAdaptor_Curve_2(edge);
const u0 = adaptor.FirstParameter();
const u1 = adaptor.LastParameter();
const a: GpPnt = adaptor.Value(u0);
const b: GpPnt = adaptor.Value(u1);
// 2D-Projektion: X = horizontal, Y = vertikal (Z≈0 in der Projektionsebene).
return [
{ x: a.X(), y: a.Y() },
{ x: b.X(), y: b.Y() },
];
}
function collectEdges(
oc: OpenCascadeInstance,
compound: TopoDS_Shape,
visible: boolean,
kind: "sharp" | "outline",
): HlrPolyline[] {
const out: HlrPolyline[] = [];
const exp = new oc.TopExp_Explorer_2(
compound,
oc.TopAbs_ShapeEnum.TopAbs_EDGE,
oc.TopAbs_ShapeEnum.TopAbs_SHAPE,
);
for (; exp.More(); exp.Next()) {
const pts = edgeToPolyline(oc, exp.Current());
if (pts.length >= 2) out.push({ pts, visible, kind });
}
return out;
}
// ── Öffentliche API ──────────────────────────────────────────────────────────
/**
* HLR direkt auf einem bereits aufgebauten OCCT-Shape ausführen. Liefert
* sichtbare (+ optional verdeckte) 2D-Polylinien in der Projektionsebene.
*/
export function hlrShape(
oc: OpenCascadeInstance,
shape: TopoDS_Shape,
view: ViewDir,
opts: HlrOptions = {},
): HlrResult {
const includeHidden = opts.includeHidden ?? true;
const includeOutline = opts.includeOutline ?? true;
const t0 = performance.now();
const rl = new oc.HLRAppli_ReflectLines(shape);
const [dx, dy, dz] = view.dir;
const [ux, uy, uz] = view.up;
// SetAxes(projDir, projPoint(at), upDir). Blickpunkt = Ursprung genügt für
// eine orthographische Projektion (Lage in der Ebene ist translationsfrei).
rl.SetAxes(dx, dy, dz, 0, 0, 0, ux, uy, uz);
rl.Perform();
const T = oc.HLRBRep_TypeOfResultingEdge;
const visible: HlrPolyline[] = [];
const hidden: HlrPolyline[] = [];
// in3d=false → 2D-projizierte Kanten.
visible.push(
...collectEdges(
oc,
rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, true, false),
true,
"sharp",
),
);
if (includeOutline) {
visible.push(
...collectEdges(
oc,
rl.GetCompoundOf3dEdges(T.HLRBRep_OutLine, true, false),
true,
"outline",
),
);
}
if (includeHidden) {
hidden.push(
...collectEdges(
oc,
rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, false, false),
false,
"sharp",
),
);
if (includeOutline) {
hidden.push(
...collectEdges(
oc,
rl.GetCompoundOf3dEdges(T.HLRBRep_OutLine, false, false),
false,
"outline",
),
);
}
}
const hlrMs = performance.now() - t0;
return { visible, hidden, hlrMs };
}
/**
* Komfort-Einstieg: aus Box-Spezifikationen (Wände/Decke) einen Solid fusen und
* HLR für die gegebene Blickrichtung laufen lassen. Lädt OCCT lazy.
*/
export async function hlrFromBoxes(
boxes: BoxSpec[],
view: ViewDir,
opts: HlrOptions = {},
): Promise<HlrResult> {
const oc = await loadOcct();
const shape = fuseBoxes(oc, boxes);
return hlrShape(oc, shape, view, opts);
}
// ── SVG-Ausgabe (Diagnose/Proof) ─────────────────────────────────────────────
/**
* Minimaler SVG-Dump zur visuellen Kontrolle: sichtbare Kanten durchgezogen,
* verdeckte gestrichelt. Kein Bestandteil der Plan-Pipeline — nur Proof/Debug.
*/
export function hlrToSvg(result: HlrResult, padding = 0.5): string {
const all = [...result.visible, ...result.hidden];
const xs: number[] = [];
const ys: number[] = [];
for (const pl of all)
for (const p of pl.pts) {
xs.push(p.x);
ys.push(p.y);
}
const minX = Math.min(...xs) - padding;
const maxX = Math.max(...xs) + padding;
const minY = Math.min(...ys) - padding;
const maxY = Math.max(...ys) + padding;
const w = maxX - minX;
const h = maxY - minY;
// SVG-Y zeigt nach unten → Projektions-Y spiegeln.
const line = (pl: HlrPolyline): string => {
const d = pl.pts
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${maxY - (p.y - minY)}`)
.join(" ");
const stroke = pl.visible ? "#111" : "#999";
const dash = pl.visible ? "" : ' stroke-dasharray="0.1 0.1"';
const sw = pl.kind === "outline" ? 0.04 : 0.02;
return `<path d="${d}" fill="none" stroke="${stroke}" stroke-width="${sw}"${dash}/>`;
};
const paths = all.map(line).join("\n ");
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${w} ${h}" width="800">
<rect x="${minX}" y="${minY}" width="${w}" height="${h}" fill="#fff"/>
${paths}
</svg>`;
}
+25
View File
@@ -0,0 +1,25 @@
// Ambient-Deklarationen für die WASM-Glue-/Asset-Importe von opencascade.js
// (Welle-C-Spike: Hidden-Line-Removal). Analog zu `src/io/libredwg-web.d.ts`:
// das Paket liefert seine `dist/`-Dateien nicht über sinnvolle `exports`, daher
// laden wir Emscripten-Glue + `.wasm`-URL über zwei virtuelle Module (Aliase in
// vite.config.ts → echte Dateien im Paket). Hier nur schmale Modul-Stubs.
// (Identifier englisch, Kommentare deutsch — CONVENTIONS.md.)
declare module "virtual:occt-glue" {
/**
* Emscripten-Modul-Fabrik von opencascade.js. Nimmt Modul-Overrides
* (u. a. `locateFile`, um die `.wasm`-URL aufzulösen) und liefert das
* initialisierte Modul (das gesamte OCCT-Binding als `unknown`).
*/
const createModule: (opts?: {
locateFile?: (filename: string, scriptDir: string) => string;
wasmBinary?: ArrayBuffer | Uint8Array;
}) => Promise<unknown>;
export default createModule;
}
declare module "virtual:occt-wasm-url" {
/** Von Vite aufgelöste URL der `.wasm`-Datei (gehashtes Asset im Build). */
const url: string;
export default url;
}
+178
View File
@@ -0,0 +1,178 @@
// Lazy-Loader + schmale Typ-Fassade für opencascade.js (OCCT-WASM).
//
// Welle-C-Spike (Hidden-Line-Removal). Das Paket `opencascade.js` (Vollbuild
// 1.1.1) liefert KEINE TypeScript-Typen und exportiert ~20 000 embind-Symbole.
// Wir tippen hier NUR die Klassen/Methoden, die `hlr.ts` tatsächlich benutzt —
// verifiziert gegen die reale Laufzeit-API (embind versioniert überladene
// Konstruktoren als `_1`, `_2`, …).
//
// Laden erfolgt bewusst über einen dynamischen Import (siehe `loadOcct`), damit
// die 66-MB-WASM NICHT ins Haupt-Bundle wandert und nur bei tatsächlichem
// Bedarf (Schnitt-/Ansichts-Generierung) geholt wird.
//
// Identifier englisch, Kommentare deutsch (CONVENTIONS.md).
// ── Schmale Typ-Fassade (nur das Benutzte) ───────────────────────────────────
export interface GpPnt {
X(): number;
Y(): number;
Z(): number;
}
export interface TopoDS_Shape {
ShapeType(): unknown;
IsNull(): boolean;
}
interface TopoDS_Edge extends TopoDS_Shape {}
interface Ctor1<T, A> {
new (a: A): T;
}
interface Ctor3<T, A, B, C> {
new (a: A, b: B, c: C): T;
}
interface ShapeEnum {
TopAbs_EDGE: unknown;
TopAbs_FACE: unknown;
TopAbs_SHAPE: unknown;
TopAbs_SOLID: unknown;
}
interface ResultingEdgeEnum {
HLRBRep_Sharp: unknown;
HLRBRep_OutLine: unknown;
HLRBRep_IsoLine: unknown;
HLRBRep_Rg1Line: unknown;
HLRBRep_RgNLine: unknown;
}
interface TopExpExplorer {
More(): boolean;
Next(): void;
Current(): TopoDS_Shape;
}
interface BRepAdaptorCurve {
FirstParameter(): number;
LastParameter(): number;
Value(u: number): GpPnt;
}
interface MakeBox {
Shape(): TopoDS_Shape;
}
interface BooleanOp {
Shape(): TopoDS_Shape;
Build?(): void;
}
interface ReflectLines {
SetAxes(
nx: number,
ny: number,
nz: number,
xAt: number,
yAt: number,
zAt: number,
xUp: number,
yUp: number,
zUp: number,
): void;
Perform(): void;
/**
* Kanten-Compound holen. `visible`=true → sichtbar, false → verdeckt.
* `in3d`=true → 3D-Kanten, false → 2D-projizierte Kanten (Z≈0 in der
* Projektionsebene: X = horizontal, Y = vertikal).
*/
GetCompoundOf3dEdges(
type: unknown,
visible: boolean,
in3d: boolean,
): TopoDS_Shape;
}
/** Nur die von `hlr.ts` genutzten OCCT-Symbole. */
export interface OpenCascadeInstance {
gp_Pnt_3: Ctor3<GpPnt, number, number, number>;
BRepPrimAPI_MakeBox_1: Ctor3<MakeBox, number, number, number>;
BRepPrimAPI_MakeBox_3: Ctor1<MakeBox, GpPnt> & {
new (origin: GpPnt, corner: GpPnt): MakeBox;
};
BRepAlgoAPI_Fuse_3: {
new (a: TopoDS_Shape, b: TopoDS_Shape): BooleanOp;
};
HLRAppli_ReflectLines: Ctor1<ReflectLines, TopoDS_Shape>;
TopExp_Explorer_2: {
new (s: TopoDS_Shape, toFind: unknown, toAvoid: unknown): TopExpExplorer;
};
BRepAdaptor_Curve_2: Ctor1<BRepAdaptorCurve, TopoDS_Edge>;
TopoDS: { Edge_1(s: TopoDS_Shape): TopoDS_Edge };
TopAbs_ShapeEnum: ShapeEnum;
HLRBRep_TypeOfResultingEdge: ResultingEdgeEnum;
}
// ── Lazy-Loader ──────────────────────────────────────────────────────────────
type OcctFactory = (opts?: {
locateFile?: (filename: string, scriptDir: string) => string;
wasmBinary?: ArrayBuffer | Uint8Array;
}) => Promise<unknown>;
let occtPromise: Promise<OpenCascadeInstance> | null = null;
/** Optionale Metriken des letzten Ladevorgangs (für den Feasibility-Report). */
export interface LoadMetrics {
wasmBytes: number;
fetchMs: number;
initMs: number;
}
let lastMetrics: LoadMetrics | null = null;
export const getLoadMetrics = (): LoadMetrics | null => lastMetrics;
/**
* Lädt + initialisiert OCCT-WASM (lazy, einmalig). Glue + `.wasm`-URL werden —
* wie bei LibreDWG — über virtuelle Vite-Module aufgelöst, damit die WASM in Dev
* UND Build mit korrektem MIME-Type geladen wird. Der dynamische Import hält die
* 66-MB-WASM aus dem Haupt-Bundle heraus.
*/
export function loadOcct(): Promise<OpenCascadeInstance> {
if (!occtPromise) {
occtPromise = (async () => {
const [glueMod, wasmUrlMod] = await Promise.all([
import("virtual:occt-glue"),
import("virtual:occt-wasm-url"),
]);
const createModule = glueMod.default as OcctFactory;
const wasmUrl = wasmUrlMod.default;
// WASM einmal explizit holen → Größe messen + als `wasmBinary` reichen
// (spart Emscripten den zweiten Fetch, macht die Größe messbar).
const tFetch = performance.now();
const resp = await fetch(wasmUrl);
const wasmBinary = await resp.arrayBuffer();
const fetchMs = performance.now() - tFetch;
const tInit = performance.now();
const instance = (await createModule({
locateFile: () => wasmUrl,
wasmBinary,
})) as OpenCascadeInstance;
const initMs = performance.now() - tInit;
lastMetrics = { wasmBytes: wasmBinary.byteLength, fetchMs, initMs };
return instance;
})();
}
return occtPromise;
}