1d939a939a
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.
371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
// 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;
|
||
}
|