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.
This commit is contained in:
2026-07-09 00:57:29 +02:00
parent 9b33a89318
commit 1d939a939a
129 changed files with 26324 additions and 842 deletions
+370
View File
@@ -0,0 +1,370 @@
// 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;
}