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 87a30b7061
commit 05cfade483
8 changed files with 670 additions and 17 deletions
+90 -6
View File
@@ -63,12 +63,96 @@ describe("selectionHighlightLines", () => {
expect(two.length).toBe(one.length * 2);
});
it("eine Wand MIT Öffnungen -> mehrere Teilquader (mehr als 12 Kanten)", () => {
// W1 trägt Tür D1 + Fenster O1 -> Pfeiler/Brüstung/Sturz-Segmente, also
// ein Vielfaches von 12 Kanten (mehr als ein einzelner Quader).
const lines = selectionHighlightLines(sampleProject, ["W1"], [], RGB);
expect(lines.length).toBeGreaterThan(12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
expect(lines.length % (12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX)).toBe(0);
it("eine Wand MIT Öffnungen -> EIN Körper je Schicht-Band mit Löchern (kein Segment-Vielfaches mehr)", () => {
// NEUES Verhalten (3D-Pfad, layered=true): W1 (Tür D1 + Fenster O1) wird
// NICHT mehr in Pfeiler/Brüstung/Sturz zerlegt, sondern als EIN Körper pro
// Schicht-Band mit echten Löchern emittiert. Das Highlight umrandet daher
// genau die Schicht-Bänder (Wandtyp "aw" = 4 Schichten) — exakt so viele
// Kanten wie eine öffnungslose Wand desselben Typs (W2), NICHT ein Vielfaches
// durch Segmente. (Die Löcher selbst fügen im Highlight keine Kanten hinzu.)
const w1 = selectionHighlightLines(sampleProject, ["W1"], [], RGB);
const w2 = selectionHighlightLines(sampleProject, ["W2"], [], RGB);
expect(w1.length).toBe(4 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
expect(w1.length).toBe(w2.length);
});
});
describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:true)", () => {
// Wand W1 (EG, Wandtyp "aw" = 4 Schichten) mit ZWEI Fenstern, deren Achsen-
// Intervalle sich ÜBERLAPPEN, aber auf UNTERSCHIEDLICHER Höhe liegen:
// Fenster A: u=[1.0,2.0] (position 1.0, width 1.0), z=[0.9,2.1] (sill 0.9, h 1.2)
// Fenster B: u=[1.5,2.5] (position 1.5, width 1.0), z=[0.3,0.7] (sill 0.3, h 0.4)
// Die alte Segment-Zerlegung kann diesen versetzt-überlappenden Fall nicht
// abbilden; als Löcher in EINEM Körper müssen beide sauber als `holes`
// erscheinen. W1: start (0,0)->(5,0), floor "eg" (baseElevation 0).
const twoOffsetWindows: Project = {
...sampleProject,
doors: [],
openings: [
{
id: "WA",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "window",
position: 1.0,
width: 1.0,
height: 1.2,
sillHeight: 0.9,
},
{
id: "WB",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "window",
position: 1.5,
width: 1.0,
height: 0.4,
sillHeight: 0.3,
},
],
};
it("layered=true: 1 RWall je Schicht-Band mit BEIDEN Fenstern als holes", () => {
const w1 = projectToWalls3d(twoOffsetWindows, true).filter((w) => w.wallId === "W1");
// Genau ein Körper je Schicht-Band (aw = 4 Schichten), KEINE Segment-Boxen.
expect(w1.length).toBe(4);
for (const body of w1) {
// Jeder Band-Körper trägt BEIDE Löcher (in EINEM Körper).
expect(body.holes).toBeDefined();
expect(body.holes!.length).toBe(2);
const byFrom = [...body.holes!].sort((a, b) => a.from - b.from);
// Fenster A: u=[1,2], z=[0.9,2.1].
expect(byFrom[0].from).toBeCloseTo(1.0, 6);
expect(byFrom[0].to).toBeCloseTo(2.0, 6);
expect(byFrom[0].zBottom).toBeCloseTo(0.9, 6);
expect(byFrom[0].zTop).toBeCloseTo(2.1, 6);
// Fenster B: u=[1.5,2.5], z=[0.3,0.7] — überlappt A in u, liegt tiefer.
expect(byFrom[1].from).toBeCloseTo(1.5, 6);
expect(byFrom[1].to).toBeCloseTo(2.5, 6);
expect(byFrom[1].zBottom).toBeCloseTo(0.3, 6);
expect(byFrom[1].zTop).toBeCloseTo(0.7, 6);
}
});
it("layered=false (Schnitt-Pfad): unverändert segmentiert, KEINE holes", () => {
const w1 = projectToWalls3d(twoOffsetWindows, false).filter((w) => w.wallId === "W1");
// Der Schnitt-Pfad zerlegt weiter in Pfeiler/Brüstung/Sturz-Segmente (mehr
// Boxen als die 4 Schicht-Bänder des 3D-Pfads) und setzt NIE `holes`.
expect(w1.length).toBeGreaterThan(4);
for (const seg of w1) expect(seg.holes).toBeUndefined();
});
it("Tür = Loch bis zur Wand-UK (zBottom == baseElevation)", () => {
// Eine Tür (D1 auf W1) muss im 3D-Pfad als Loch bis zum Boden erscheinen:
// zBottom des Lochs == Wand-UK (0), NICHT ab einer Brüstung.
const p: Project = { ...sampleProject, openings: [] };
const w1 = projectToWalls3d(p, true).filter((w) => w.wallId === "W1");
expect(w1.length).toBe(4);
const door = w1[0].holes?.find((h) => h.zBottom < 1e-6);
expect(door).toBeDefined();
expect(door!.zBottom).toBeCloseTo(0, 6); // Loch bis zur Wand-UK.
});
});
+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;