Files
DOSSIER-STANDALONE/src/plan/wallMeshCut.ts
T
karim 1d939a939a Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)
Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md ✅-Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
2026-07-09 00:57:29 +02:00

371 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Gemeinsamer Wand-Mesh-Generator MIT echtem Loch-Ausschnitt (Fenster/Türen).
//
// Portierung der Rust-Referenz `render3d/src/mesh.rs::extrude_layer_segment_
// with_holes` nach TS: aus EINEM RWall-Band (Achse `start→end`, `thickness`,
// `baseElevation`, `height` + rechteckige `holes`) wird das Dreiecks-Mesh
// erzeugt, das die Öffnungen ECHT ausschneidet (statt der vollen Box). Damit
// sehen die Datei-Exporte (STL/OBJ/IFC) genauso aus wie die 3D-Ansicht: die
// Löcher sind sichtbar ausgestanzt, inklusive der Laibungs-/Jamb-Flächen.
//
// WICHTIG — WARUM KEINE GEHRUNG HIER: Die RWall-Bänder aus
// `toWalls3d.ts::pickGeometry` sind bereits an ihren Achsenden auf die
// Gehrungs-/Anschluss-Schnittlinie GEKÜRZT (die Box endet aber achsparallel,
// keine gekippte Stirnfläche — siehe `bandCutDistance` in toWalls3d.ts). Die
// Ecken-Verschneidung (Joins) steckt also schon in `start`/`end` jedes Bandes.
// Deshalb entfällt hier die `start_cut`/`end_cut`-Gehrungslogik der Rust-Funktion
// (im Rust-Pfad wird sie nur gebraucht, weil dort EIN ungekürztes Band mit
// Gehrungslinien extrudiert wird). Das Ergebnis: dieselbe Rechteck-Zerlegung der
// Langseiten + dieselben 4 Laibungsquads je Loch wie in Rust, nur mit rechten
// (ungekippten) Stirnkappen — exakt die Geometrie, die der Viewer schon zeigt.
//
// ACHSEN: Modell (x, y) → Welt (x, Höhe, y), Y-UP — identisch zu exportMesh.ts /
// selectionHighlightLines / dem 3D-Viewer. Ein Weltvertex (vx,vy,vz) ist also
// (Modell-x, Meter-Höhe, Modell-y).
import type { RWall } from "./toWalls3d";
/** Rundungs-/Entartungsschwelle (1e-4 m), 1:1 wie `openings::MIN_SPAN` in Rust. */
const MIN_SPAN = 1e-4;
/** Ein Weltpunkt (x, Höhe, y). */
type V3 = [number, number, number];
/**
* Ein Dreiecks-Mesh in LOKALEN (0-basierten) Indizes: `positions` flach
* (x,y,z, …) in Weltmetern (Y-up), `indices` je 3 = ein Dreieck.
*/
export interface CutMesh {
positions: number[];
indices: number[];
}
/**
* Aussen-Normale eines planaren, CCW-von-aussen georderten Quads über die ersten
* drei Ecken: `normalize(cross(b-a, d-a))` — 1:1 wie `mesh.rs::quad_normal`.
* Nulllange Flächen liefern den Nullvektor (Aufrufer nutzt sie dann nicht).
*/
function quadNormal(a: V3, b: V3, d: V3): V3 {
const ab: V3 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const ad: V3 = [d[0] - a[0], d[1] - a[1], d[2] - a[2]];
const c: V3 = [
ab[1] * ad[2] - ab[2] * ad[1],
ab[2] * ad[0] - ab[0] * ad[2],
ab[0] * ad[1] - ab[1] * ad[0],
];
const len = Math.max(Math.hypot(c[0], c[1], c[2]), 1e-9);
return [c[0] / len, c[1] / len, c[2] / len];
}
/**
* Hängt ein Dreieck an `mesh` an, dessen Winding an `wantNormal` ausgerichtet
* wird (robust gegen die Eck-Reihenfolge) — 1:1 wie `mesh.rs::push_tri_oriented`.
*/
function pushTriOriented(mesh: CutMesh, a: V3, b: V3, c: V3, wantNormal: V3): void {
const ab: V3 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const ac: V3 = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
const gn: V3 = [
ab[1] * ac[2] - ab[2] * ac[1],
ab[2] * ac[0] - ab[0] * ac[2],
ab[0] * ac[1] - ab[1] * ac[0],
];
const dot = gn[0] * wantNormal[0] + gn[1] * wantNormal[1] + gn[2] * wantNormal[2];
const [v0, v1, v2] = dot < 0 ? [a, c, b] : [a, b, c];
const base = mesh.positions.length / 3;
mesh.positions.push(v0[0], v0[1], v0[2], v1[0], v1[1], v1[2], v2[0], v2[1], v2[2]);
mesh.indices.push(base, base + 1, base + 2);
}
/** Hängt ein Quad (vier Ecken als Ring) als zwei orientierte Dreiecke an. */
function pushQuadOriented(mesh: CutMesh, a: V3, b: V3, c: V3, d: V3, wantNormal: V3): void {
pushTriOriented(mesh, a, b, c, wantNormal);
pushTriOriented(mesh, a, c, d, wantNormal);
}
/**
* Zerlegt das Rechteck `[uLo,uHi]×[zLo,zHi]` MINUS `holes` (`[u0,u1,z0,z1]`) in
* achsparallele, disjunkte SOLIDE Teilrechtecke — Koordinaten-Kompression, 1:1
* wie `mesh.rs::solid_subrects` (jede Gitterzelle, deren Mittelpunkt in keinem
* Loch liegt, ist ein solides Teilrechteck). Exportiert für die Zerlegungs-Tests
* (gleiche Rechteck-Anzahl wie die Rust-Zerlegung).
*/
export function solidSubrects(
uLo: number,
uHi: number,
zLo: number,
zHi: number,
holes: Array<[number, number, number, number]>,
): Array<[number, number, number, number]> {
const sortDedup = (v: number[]): number[] => {
const s = [...v].sort((a, b) => a - b);
const out: number[] = [];
for (const x of s) {
if (out.length === 0 || Math.abs(x - out[out.length - 1]) > MIN_SPAN) out.push(x);
}
return out;
};
const usRaw = [uLo, uHi];
const zsRaw = [zLo, zHi];
for (const [a0, a1, hb, ht] of holes) {
usRaw.push(a0, a1);
zsRaw.push(hb, ht);
}
const us = sortDedup(usRaw);
const zs = sortDedup(zsRaw);
const out: Array<[number, number, number, number]> = [];
for (let i = 0; i < us.length - 1; i++) {
const ua = us[i];
const ub = us[i + 1];
if (ub - ua <= MIN_SPAN) continue;
for (let j = 0; j < zs.length - 1; j++) {
const za = zs[j];
const zb = zs[j + 1];
if (zb - za <= MIN_SPAN) continue;
const cu = (ua + ub) * 0.5;
const cz = (za + zb) * 0.5;
const inHole = holes.some(([a0, a1, hb, ht]) => cu > a0 && cu < a1 && cz > hb && cz < ht);
if (!inHole) out.push([ua, ub, za, zb]);
}
}
return out;
}
/**
* Zieht die (ggf. überlappenden) `gaps` vom Intervall `[lo,hi]` ab und liefert
* die verbleibenden, sortierten, disjunkten Teilintervalle — 1:1 wie
* `mesh.rs::subtract_intervals` (für Deckel-/Boden-/Kappen-Lücken randständiger
* Löcher).
*/
function subtractIntervals(lo: number, hi: number, gaps: Array<[number, number]>): Array<[number, number]> {
const gs = gaps
.map(([a, b]): [number, number] => [Math.max(a, lo), Math.min(b, hi)])
.filter(([a, b]) => b - a > MIN_SPAN)
.sort((p, q) => p[0] - q[0]);
const out: Array<[number, number]> = [];
let cursor = lo;
for (const [a, b] of gs) {
if (a > cursor + MIN_SPAN) out.push([cursor, a]);
cursor = Math.max(cursor, b);
}
if (hi > cursor + MIN_SPAN) out.push([cursor, hi]);
return out;
}
/**
* Prüft, ob ein Mesh eine GESCHLOSSENE, KONSISTENT NACH AUSSEN gewundene Fläche
* ist (bounds ein Volumen ohne Löcher) — über das Gauss-/Divergenz-Kriterium
* statt eines Kanten-Manifold-Zählers:
* • ∮ n dA = 0 ⟺ Σ (v1−v0)×(v2−v0) = 0 über alle Dreiecke (jede offene
* Lücke ODER jedes falsch gewundene/fehlende Dreieck verletzt die Summe).
* • signiertes Volumen Σ v0·(v1×v2)/6 > 0 ⟺ Normalen zeigen NACH AUSSEN.
*
* BEWUSST NICHT der naive „jede ungerichtete Kante genau 2×"-Manifold-Check:
* dieses Mesh nutzt (wie die Rust-Referenz) VOLLE Deckel/Boden-Streifen über die
* ganze Breite, während die Langseiten an den Loch-Spalten unterteilt sind — das
* erzeugt legitime T-Stösse (eine lange Kante trifft mehrere kurze), die KEINE
* echten Lücken sind. Der naive Kantenzähler würde diese korrekte, wasserdichte
* Geometrie fälschlich als „offen" melden; das Gauss-Kriterium ist T-Stoss-robust
* und misst genau die Eigenschaft, auf die es ankommt (dichtes Volumen + Aussen-
* Orientierung — die Ursache des „hohl/offen"-Bugs war eine invertierte Wicklung
* im IFC-Export, nicht eine fehlende Fläche).
*/
export function isWatertight(mesh: CutMesh, eps = 1e-6): boolean {
const p = mesh.positions;
let ax = 0;
let ay = 0;
let az = 0;
let vol6 = 0;
for (let t = 0; t < mesh.indices.length; t += 3) {
const i0 = mesh.indices[t] * 3;
const i1 = mesh.indices[t + 1] * 3;
const i2 = mesh.indices[t + 2] * 3;
const e1x = p[i1] - p[i0];
const e1y = p[i1 + 1] - p[i0 + 1];
const e1z = p[i1 + 2] - p[i0 + 2];
const e2x = p[i2] - p[i0];
const e2y = p[i2 + 1] - p[i0 + 1];
const e2z = p[i2 + 2] - p[i0 + 2];
const cx = e1y * e2z - e1z * e2y;
const cy = e1z * e2x - e1x * e2z;
const cz = e1x * e2y - e1y * e2x;
ax += cx;
ay += cy;
az += cz;
// v0 · (v1 × v2) = v0 · (e1 × e2) (die reinen v0-Terme heben sich auf).
vol6 += p[i0] * cx + p[i0 + 1] * cy + p[i0 + 2] * cz;
}
return Math.hypot(ax, ay, az) < eps && vol6 > eps;
}
/**
* Erzeugt das Wand-Mesh EINES RWall-Bandes mit ECHTEN Loch-Ausschnitten — der
* TS-Port von `mesh.rs::extrude_layer_segment_with_holes` (ohne Gehrung, siehe
* Moduldoc). Aufbau:
* • Langseiten (+n / −n): Flächen-Rechteck MINUS der Löcher, per {@link
* solidSubrects} in Teilrechtecke zerlegt (je 2 Dreiecke).
* • Deckel (y1) / Boden (y0): voller Streifen minus der u-Intervalle rand-
* ständiger Löcher (z. B. eine Tür am Boden).
* • Stirnkappen (a=0 / a=length): volle Höhe minus z-Intervalle randständiger
* Löcher.
* • Laibungen je Loch: bis zu vier Quads (links/rechts/unten/oben), nur für die
* im Wand-Inneren liegenden Seiten.
*
* Ohne `holes` (undefined/leer) liefert das die volle Box (Regressionsfall);
* Aufrufer, die für lochlose Bänder die klassische 8-Ecken-Box wollen, prüfen
* das selbst (siehe exportMesh.ts).
*/
export function wallCutMesh(w: RWall): CutMesh {
const mesh: CutMesh = { positions: [], indices: [] };
const [sx, sy] = w.start;
const [ex, ey] = w.end;
const dx = ex - sx;
const dy = ey - sy;
const length = Math.hypot(dx, dy);
if (length < 1e-9 || w.height <= 1e-9 || w.thickness <= 1e-9) return mesh;
const ux = dx / length;
const uy = dy / length;
// Links-Normale n = (−uy, ux) — dieselbe Konvention wie mesh.rs::left_normal.
const n: [number, number] = [-uy, ux];
const y0 = w.baseElevation;
const y1 = w.baseElevation + w.height;
const offA = w.thickness / 2;
const offB = -w.thickness / 2;
const off = (p: [number, number], s: number): [number, number] => [p[0] + n[0] * s, p[1] + n[1] * s];
const axisPoint = (a: number): [number, number] => [sx + ux * a, sy + uy * a];
// Plan-Punkt bei Achsen-Position `a`, Dicken-Versatz `offv` (keine Gehrung).
const gAt = (a: number, offv: number): [number, number] => off(axisPoint(a), offv);
const wpt = (g: [number, number], y: number): V3 => [g[0], y, g[1]];
// Löcher auf [0,length]×[y0,y1] klemmen, entartete verwerfen.
const hs: Array<[number, number, number, number]> = [];
for (const h of w.holes ?? []) {
const a0 = Math.max(h.from, 0);
const a1 = Math.min(h.to, length);
const zb = Math.max(h.zBottom, y0);
const zt = Math.min(h.zTop, y1);
if (a1 - a0 > MIN_SPAN && zt - zb > MIN_SPAN) hs.push([a0, a1, zb, zt]);
}
// ── Langseiten (+n / −n): Rechteck-Gitter minus Löcher. ────────────────────
const np: V3 = [n[0], 0, n[1]];
const nn: V3 = [-n[0], 0, -n[1]];
for (const [ua, ub, za, zb] of solidSubrects(0, length, y0, y1, hs)) {
pushQuadOriented(
mesh,
wpt(gAt(ua, offA), za),
wpt(gAt(ub, offA), za),
wpt(gAt(ub, offA), zb),
wpt(gAt(ua, offA), zb),
np,
);
pushQuadOriented(
mesh,
wpt(gAt(ua, offB), za),
wpt(gAt(ub, offB), za),
wpt(gAt(ub, offB), zb),
wpt(gAt(ua, offB), zb),
nn,
);
}
// ── Deckel (y1) / Boden (y0): voller Streifen minus randständiger Löcher. ──
const topGaps = hs.filter(([, , , zt]) => zt >= y1 - MIN_SPAN).map(([a0, a1]): [number, number] => [a0, a1]);
for (const [ua, ub] of subtractIntervals(0, length, topGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(ua, offA), y1),
wpt(gAt(ub, offA), y1),
wpt(gAt(ub, offB), y1),
wpt(gAt(ua, offB), y1),
[0, 1, 0],
);
}
const botGaps = hs.filter(([, , zb]) => zb <= y0 + MIN_SPAN).map(([a0, a1]): [number, number] => [a0, a1]);
for (const [ua, ub] of subtractIntervals(0, length, botGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(ua, offA), y0),
wpt(gAt(ub, offA), y0),
wpt(gAt(ub, offB), y0),
wpt(gAt(ua, offB), y0),
[0, -1, 0],
);
}
// ── Stirnkappen (a=0 / a=length): volle Höhe minus randständiger Löcher. ───
const startNormal = quadNormal(wpt(gAt(0, offB), y0), wpt(gAt(0, offA), y0), wpt(gAt(0, offB), y1));
const startGaps = hs.filter(([a0]) => a0 <= MIN_SPAN).map(([, , zb, zt]): [number, number] => [zb, zt]);
for (const [za, zb] of subtractIntervals(y0, y1, startGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(0, offB), za),
wpt(gAt(0, offA), za),
wpt(gAt(0, offA), zb),
wpt(gAt(0, offB), zb),
startNormal,
);
}
const endNormal = quadNormal(wpt(gAt(length, offA), y0), wpt(gAt(length, offB), y0), wpt(gAt(length, offA), y1));
const endGaps = hs.filter(([, a1]) => a1 >= length - MIN_SPAN).map(([, , zb, zt]): [number, number] => [zb, zt]);
for (const [za, zb] of subtractIntervals(y0, y1, endGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(length, offA), za),
wpt(gAt(length, offB), za),
wpt(gAt(length, offB), zb),
wpt(gAt(length, offA), zb),
endNormal,
);
}
// ── Laibungen je Loch: nur die im Wand-Inneren liegenden Seiten. ──────────
for (const [a0, a1, zb, zt] of hs) {
// Linke Laibung (bei a0), Normale +u (in die Öffnung).
if (a0 > MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a0, offA), zb),
wpt(gAt(a0, offB), zb),
wpt(gAt(a0, offB), zt),
wpt(gAt(a0, offA), zt),
[ux, 0, uy],
);
}
// Rechte Laibung (bei a1), Normale −u.
if (a1 < length - MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a1, offA), zb),
wpt(gAt(a1, offB), zb),
wpt(gAt(a1, offB), zt),
wpt(gAt(a1, offA), zt),
[-ux, 0, -uy],
);
}
// Untere Laibung (bei zb = Brüstungsoberkante), Normale +Y.
if (zb > y0 + MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a0, offA), zb),
wpt(gAt(a1, offA), zb),
wpt(gAt(a1, offB), zb),
wpt(gAt(a0, offB), zb),
[0, 1, 0],
);
}
// Obere Laibung (bei zt = Sturzunterkante), Normale −Y.
if (zt < y1 - MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a0, offA), zt),
wpt(gAt(a1, offA), zt),
wpt(gAt(a1, offB), zt),
wpt(gAt(a0, offB), zt),
[0, -1, 0],
);
}
}
return mesh;
}