Nordstern-3D: Fenster/Tueren als echte Boolean-Loecher in EINEM Wandkoerper

Bisher zerlegte emitWall eine Wand um jede Oeffnung in Pfeiler/Bruestung/Sturz-
Teilquader -> sichtbare Segment-Naehte im 3D, Oeffnung war semantisch kein Loch,
und versetzt uebereinanderliegende Fenster liessen sich gar nicht abbilden. Neu:
im 3D-Pfad (layered=true) EIN RWall pro Schicht-Band ueber die volle Achse mit
allen Oeffnungen als rechteckige holes; render3d stanzt sie per achsparalleler
Rechteck-Gitter-Zerlegung der Langseiten aus und setzt 4 Laibungsquads je Loch.
holes und openings schliessen sich im Emitter gegenseitig aus. Tuer = Loch bis
zum Wand-zBottom. Schnitt-Pfad (layered=false) segmentiert unveraendert weiter
(wallSegmentOwners bleibt synchron, toSection.ts unangetastet).

Zusatz: trimWallTopForCeilings (Deckentrim, e4b8df6) gilt jetzt NUR im 3D-Pfad
(layered=true, Z-Fighting-Vermeidung) und wird im Schnitt-Pfad uebersprungen -
der Schnitt braucht die volle Wandhoehe fuer seine schichtweise Prioritaets-
Subtraktion (Kontrolle von Schichteinzug/Bodenaufbau am Wand-Decken-Anschluss).

RWall.holes / WallInput.holes additiv (#[serde(default)]). Pflicht-Testfaelle:
zwei ueberlappende, hoehenversetzte Fenster in einem Koerper; Tuer-Loch bis Boden
ohne untere Laibung. cargo test 56 gruen, vitest 230 (+3), build:engine3d + tsc
sauber.
This commit is contained in:
2026-07-04 22:17:21 +02:00
parent dd9984d3c4
commit 1407c68232
8 changed files with 670 additions and 17 deletions
+83 -4
View File
@@ -59,6 +59,24 @@ export interface RLayer {
color: RRgb;
}
/**
* Ein rechteckiges LOCH (Fenster/Tür) in EINEM Wandkörper — matcht 1:1
* render3d::types::Hole. KONVENTION: `from`/`to` sind Achsen-Meter ab
* `RWall.start` (0..Achslänge, dieselbe Achse wie die Öffnungs-Intervalle);
* `zBottom`/`zTop` sind ABSOLUTE Weltmeter (NICHT relativ zur Wand-UK — anders
* als `Opening.sillHeight`). Eine Tür ist der Sonderfall
* `zBottom == RWall.baseElevation` (Loch bis zur Wand-UK). render3d stanzt die
* Löcher als echte Aussparungen in die beiden Langseiten (Rechteck-Gitter-
* Zerlegung + Laibungsflächen), statt die Wand in Pfeiler/Brüstung/Sturz zu
* zerlegen.
*/
export interface RHole {
from: number;
to: number;
zBottom: number;
zTop: number;
}
export interface RWall {
start: RVec2;
end: RVec2;
@@ -79,6 +97,19 @@ export interface RWall {
* serde verwirft unbekannte Felder).
*/
wallId: string;
/**
* Echte rechteckige Löcher (Fenster/Türen) in DIESEM Wandkörper (siehe
* {@link RHole}). Nur im 3D-Viewer-Pfad ({@link projectToModel3d} mit
* `layeredWalls:true`) gesetzt: dort emittiert {@link emitWall} EINEN RWall pro
* Schicht-Band über die volle Achse mit ALLEN Öffnungen als `holes` — statt der
* Pfeiler/Brüstung/Sturz-Segment-Boxen. So bildet EIN Körper auch versetzt
* übereinanderliegende Fenster ab (das kann die Segment-Zerlegung nicht). Der
* Schnitt-Pfad (`layeredWalls:false`) setzt `holes` NICHT und segmentiert
* unverändert weiter (Owner-Kompatibilität mit `toSection.ts`, s. u.). Fehlt/
* leer ⇒ Vollkörper ohne Aussparung (render3d: `#[serde(default)]`). `holes`
* und die Segment-Zerlegung schliessen sich damit gegenseitig aus.
*/
holes?: RHole[];
}
/** Eine extrudierte Deckenplatte: geschlossener Grundriss-Umriss + Z-Ausdehnung. */
@@ -224,6 +255,13 @@ function resolveWallBands(project: Project, wall: Wall, layered = true): WallBan
* mit Schicht-`thickness`/`color`. Entartete Stücke (Länge ≤ 0 oder Höhe ≤ 0)
* werden übersprungen — so überleben Öffnungen dicht an den Wandenden ohne
* Nullquader.
*
* Optionale `holes`: echte rechteckige Aussparungen in DIESER Box (siehe
* {@link RHole}). Ihre `from`/`to` sind Achsen-Meter ab Wand-Start (dieselbe
* Achse wie `from`/`to` dieser Box), `zBottom`/`zTop` absolute Weltmeter — beides
* unverändert an render3d durchgereicht (der Querversatz `normalOffset` wirkt nur
* senkrecht zur Achse und lässt die Loch-Parametrisierung unberührt). Werden nur
* im 3D-Pfad (voller Achsen-Quader) gesetzt.
*/
function pushSegment(
out: RWall[],
@@ -236,6 +274,7 @@ function pushSegment(
normalOffset: number,
color: RRgb,
wallId: string,
holes?: RHole[],
): void {
if (to - from <= EPS) return;
if (zTop - zBottom <= EPS) return;
@@ -269,6 +308,7 @@ function pushSegment(
color,
layers: [],
wallId,
...(holes && holes.length > 0 ? { holes } : {}),
});
}
@@ -422,11 +462,31 @@ function trimWallTopForCeilings(
return Math.max(zBottom, top);
}
/** Emittiert die Teilquader EINER Wand (mit oder ohne Öffnungen). */
/**
* Emittiert die Wandkörper EINER Wand (mit oder ohne Öffnungen).
*
* ZWEI PFADE je `layered`:
* • layered=true (3D-Viewer, Default): EIN Körper pro Schicht-Band über die
* VOLLE Achse mit allen Öffnungen als echte `holes` (render3d stanzt sie in
* die Langseiten). Ermöglicht auch versetzt übereinanderliegende Fenster in
* EINEM Körper (keine Segment-Nähte).
* • layered=false (Schnitt-Pfad, `computeSection`): UNVERÄNDERTE Pfeiler/
* Brüstung/Sturz-Segmentierung (EIN Voll-Band je Segment), damit die
* `ComponentRef.index`-Reihenfolge exakt zu `toSection.ts::wallSegmentOwners`
* passt (Owner-Kompatibilität; toSection.ts bleibt unangetastet). Löcher
* werden hier NICHT emittiert.
*/
function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): void {
const bands = resolveWallBands(project, wall, layered);
const { zBottom, zTop: rawZTop } = wallVerticalExtent(project, wall);
const zTop = trimWallTopForCeilings(project, wall, zBottom, rawZTop);
// Decken-Trim (kappt die Wand an der Unterkante einer dominanteren Decke) NUR
// im 3D-Viewer-Pfad (layered=true): dort vermeidet das Kappen das Z-Fighting
// Wand/Decke am bündigen Anschluss. Der Schnitt-Pfad (layered=false) will die
// UNGEKAPPTE volle Wandhöhe, weil er seine EIGENE prioritätsbasierte Schicht-
// Dominanz (toSection.ts::subtractDominantBands) auf Rohgeometrie anwendet —
// ein vorab gekürzter Wandkopf würde dort Schichteinzug/Bodenaufbau am Wand-
// Decken-Anschluss verfälschen.
const zTop = layered ? trimWallTopForCeilings(project, wall, zBottom, rawZTop) : rawZTop;
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9 || zTop - zBottom <= EPS) return;
@@ -461,8 +521,27 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): v
}
cutouts.sort((a, b) => a.from - b.from);
// Ohne Aussparungen: ein durchgehender Quader je Schicht (wie bisher, aber
// in Lagen aufgeteilt).
// ── 3D-Viewer-Pfad (layered=true): EIN Körper je Schicht-Band über die volle
// Achse mit allen Öffnungen als echte Löcher. Die Segment-Zerlegung entfällt
// komplett — render3d stanzt die Löcher (Rechteck-Gitter + Laibungen). Türen
// sind Löcher bis zur Wand-UK (`oBottom == zBottom`), Fenster ab Brüstung. So
// bildet EIN Körper auch versetzt übereinanderliegende Fenster ab.
if (layered) {
const holes: RHole[] = cutouts.map((c) => ({
from: c.from,
to: c.to,
zBottom: c.oBottom,
zTop: c.oTop,
}));
for (const b of bands) {
pushSegment(out, wall, 0, axisLen, zBottom, zTop, b.thickness, b.offset, b.color, wall.id, holes);
}
return;
}
// ── Schnitt-Pfad (layered=false): unveränderte Pfeiler/Brüstung/Sturz-
// Segmentierung (Owner-Kompatibilität mit toSection.ts::wallSegmentOwners).
// Ohne Aussparungen: ein durchgehender Quader je Schicht.
if (cutouts.length === 0) {
emitSeg(0, axisLen, zBottom, zTop);
return;