Tür/Fenster 3D: Rahmen, Sprossen/Kämpfer, Oberlicht, Schichteinzug als Mesh
Emittiert die vertieften Tür-/Fensterparameter als wgpu-Geometrie: - emitOpeningFrames: Rahmenriegel (Laibung + Sturz + Sohlbank), wingCount−1 senkrechte + mullionRows−1 waagrechte Sprossen, Kämpferbalken bei transomHeight>0. Rahmenfarbe hell-warmgrau [0.85,0.85,0.83]. - emitOpeningGlass erweitert: typisierte Öffnungen bekommen einzugs-/ oberlicht-bewusste Scheiben (Haupt- + Oberlichtscheibe), typlose behalten die alte zentrierte Scheibe (Rückwärtskompatibilität). - Schichteinzug: Rahmen/Glas entlang der Wandnormale ab der gewählten Fläche (aussen/innen) um insetFromFace versetzt, auf Wanddicke geklemmt. +5 Tests. tsc sauber, vitest 614/614 grün.
This commit is contained in:
+248
-3
@@ -275,11 +275,14 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
|
|||||||
expect(glass.color).toEqual([0.62, 0.76, 0.85]);
|
expect(glass.color).toEqual([0.62, 0.76, 0.85]);
|
||||||
|
|
||||||
// Vertikale Ausdehnung (z = 3. Komponente jeder Ecke, MODELL-Höhe):
|
// Vertikale Ausdehnung (z = 3. Komponente jeder Ecke, MODELL-Höhe):
|
||||||
// O1 sillHeight 0.9, height 1.2, Wand-UK 0 -> UK 0.9 .. OK 2.1.
|
// O1 sillHeight 0.9, height 1.2, Wand-UK 0 -> Öffnung UK 0.9 .. OK 2.1.
|
||||||
|
// O1 referenziert den Typ "window-standard" (frameWidth 0.06, kein Oberlicht)
|
||||||
|
// -> die Scheibe sitzt jetzt INNERHALB des Rahmens (Schwelle/Sturz ziehen je
|
||||||
|
// frameWidth ab), s. emitOpeningFrames/glassPanesForOpening: 0.96 .. 2.04.
|
||||||
const zs: number[] = [];
|
const zs: number[] = [];
|
||||||
for (let i = 2; i < glass.positions.length; i += 3) zs.push(glass.positions[i]);
|
for (let i = 2; i < glass.positions.length; i += 3) zs.push(glass.positions[i]);
|
||||||
expect(Math.min(...zs)).toBeCloseTo(0.9, 6);
|
expect(Math.min(...zs)).toBeCloseTo(0.96, 6);
|
||||||
expect(Math.max(...zs)).toBeCloseTo(2.1, 6);
|
expect(Math.max(...zs)).toBeCloseTo(2.04, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Türen bekommen keine Scheibe; je Fenster genau eine", () => {
|
it("Türen bekommen keine Scheibe; je Fenster genau eine", () => {
|
||||||
@@ -294,6 +297,248 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Rahmen-/Sprossen-Riegel typisierter Öffnungen (emitOpeningFrames)", () => {
|
||||||
|
// Farbfilter analog GLASS_RGB oben: FRAME_RGB (toWalls3d.ts) ist bewusst NICHT
|
||||||
|
// exportiert -> Tests filtern per Literalwert, wie es der bestehende Glas-Test
|
||||||
|
// schon für GLASS_RGB tut.
|
||||||
|
const isFrame = (m: { color: [number, number, number] }): boolean =>
|
||||||
|
m.color[0] === 0.85 && m.color[1] === 0.85 && m.color[2] === 0.83;
|
||||||
|
const isGlass = (m: { color: [number, number, number] }): boolean =>
|
||||||
|
m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85;
|
||||||
|
const zRange = (m: { positions: number[] }): { min: number; max: number } => {
|
||||||
|
const zs: number[] = [];
|
||||||
|
for (let i = 2; i < m.positions.length; i += 3) zs.push(m.positions[i]);
|
||||||
|
return { min: Math.min(...zs), max: Math.max(...zs) };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("typisiertes Fenster (O1, Typ window-standard) -> Rahmen-Riegel mit endlichen Positionen innerhalb der Öffnungshöhe", () => {
|
||||||
|
// O1: sillHeight 0.9, height 1.2 -> Öffnung UK 0.9 .. OK 2.1. Typ "window-
|
||||||
|
// standard" hat wingCount 1, kein mullionRows/transomHeight -> nur die 4
|
||||||
|
// Basis-Riegel (2 Pfosten + Sturz + Schwelle), keine Sprossen/Kämpfer.
|
||||||
|
const p: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"),
|
||||||
|
doors: [],
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
const { meshes } = projectToModel3d(p);
|
||||||
|
const frames = meshes.filter(isFrame);
|
||||||
|
expect(frames.length).toBe(4);
|
||||||
|
for (const f of frames) {
|
||||||
|
expect(f.positions.every((v) => Number.isFinite(v))).toBe(true);
|
||||||
|
const { min, max } = zRange(f);
|
||||||
|
expect(min).toBeGreaterThanOrEqual(0.9 - 1e-6);
|
||||||
|
expect(max).toBeLessThanOrEqual(2.1 + 1e-6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transomHeight > 0 -> zusätzlicher Kämpfer-Riegel + zweite Glasscheibe im Oberlicht-Band", () => {
|
||||||
|
const p: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
windowTypes: [
|
||||||
|
...(sampleProject.windowTypes ?? []),
|
||||||
|
{
|
||||||
|
id: "win-transom-test",
|
||||||
|
name: "Test Oberlicht",
|
||||||
|
kind: "fest",
|
||||||
|
wingCount: 1,
|
||||||
|
glazing: "einfach",
|
||||||
|
frameThickness: 0.06,
|
||||||
|
frameWidth: 0.06,
|
||||||
|
transomHeight: 0.4,
|
||||||
|
defaultSillHeight: 0.9,
|
||||||
|
defaultWidth: 1.0,
|
||||||
|
defaultHeight: 1.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
openings: [
|
||||||
|
{
|
||||||
|
id: "OT",
|
||||||
|
type: "opening",
|
||||||
|
hostWallId: "W1",
|
||||||
|
categoryCode: "21",
|
||||||
|
kind: "window",
|
||||||
|
typeId: "win-transom-test",
|
||||||
|
position: 3.0,
|
||||||
|
width: 1.0,
|
||||||
|
height: 1.2,
|
||||||
|
sillHeight: 0.9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
doors: [],
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
const { meshes } = projectToModel3d(p);
|
||||||
|
const frames = meshes.filter(isFrame);
|
||||||
|
const glass = meshes.filter(isGlass);
|
||||||
|
// 2 Pfosten + Sturz + Schwelle + Kämpfer = 5 Riegel (gegenüber 4 ohne Oberlicht).
|
||||||
|
expect(frames.length).toBe(5);
|
||||||
|
// Hauptscheibe (unterhalb des Kämpfers) + Oberlicht-Scheibe (oberhalb) = 2.
|
||||||
|
expect(glass.length).toBe(2);
|
||||||
|
// Kämpfer sitzt bei zTop - transomHeight = 2.1 - 0.4 = 1.7 und muss diese
|
||||||
|
// Höhe im eigenen z-Intervall einschliessen.
|
||||||
|
const splitZ = 1.7;
|
||||||
|
const kaempfer = frames.find((f) => {
|
||||||
|
const { min, max } = zRange(f);
|
||||||
|
return min < splitZ && max > splitZ;
|
||||||
|
});
|
||||||
|
expect(kaempfer).toBeDefined();
|
||||||
|
// Eine der beiden Scheiben liegt VOLLSTÄNDIG oberhalb des Kämpfers (Oberlicht).
|
||||||
|
const hasUpperPane = glass.some((g) => zRange(g).min > splitZ);
|
||||||
|
expect(hasUpperPane).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wingCount=2 + mullionRows=2 -> je ein zusätzlicher Mittelpfosten (vertikal) und Kämpfer (horizontal)", () => {
|
||||||
|
const baseline: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
openings: (sampleProject.openings ?? []).filter((o) => o.id === "O1"),
|
||||||
|
doors: [],
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
const withMullions: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
windowTypes: [
|
||||||
|
...(sampleProject.windowTypes ?? []),
|
||||||
|
{
|
||||||
|
id: "win-mullion-test",
|
||||||
|
name: "Test Sprossen",
|
||||||
|
kind: "fest",
|
||||||
|
wingCount: 2,
|
||||||
|
mullionRows: 2,
|
||||||
|
glazing: "einfach",
|
||||||
|
frameThickness: 0.06,
|
||||||
|
frameWidth: 0.06,
|
||||||
|
defaultSillHeight: 0.9,
|
||||||
|
defaultWidth: 1.0,
|
||||||
|
defaultHeight: 1.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
openings: [
|
||||||
|
{
|
||||||
|
id: "OM",
|
||||||
|
type: "opening",
|
||||||
|
hostWallId: "W1",
|
||||||
|
categoryCode: "21",
|
||||||
|
kind: "window",
|
||||||
|
typeId: "win-mullion-test",
|
||||||
|
position: 3.0,
|
||||||
|
width: 1.0,
|
||||||
|
height: 1.2,
|
||||||
|
sillHeight: 0.9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
doors: [],
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
const baseCount = projectToModel3d(baseline).meshes.filter(isFrame).length;
|
||||||
|
const mullionCount = projectToModel3d(withMullions).meshes.filter(isFrame).length;
|
||||||
|
// +1 Mittelpfosten (wingCount-1) +1 Kämpfer-Zeile (mullionRows-1).
|
||||||
|
expect(mullionCount).toBe(baseCount + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("insetFromFace verschiebt Rahmen-Position entlang der Wand-Normalen gegenüber bündig (kein Einzug)", () => {
|
||||||
|
// W1-Achse (0,0)->(5,0): u=(1,0) -> Normale n=(0,-1); der Quer-Versatz
|
||||||
|
// bildet sich rein in der Y-Koordinate ab (y = -t). insetFromFace 0.1 muss
|
||||||
|
// die Rahmen-Aussenkante (min y, s. resolveFrameNormalRange) um ~0.1 m
|
||||||
|
// gegenüber bündig (insetFromFace 0) verschieben.
|
||||||
|
const flushWindow = (project: Project): Project => ({
|
||||||
|
...project,
|
||||||
|
windowTypes: [
|
||||||
|
...(project.windowTypes ?? []),
|
||||||
|
{
|
||||||
|
id: "win-flush-test",
|
||||||
|
name: "Test bündig",
|
||||||
|
kind: "fest",
|
||||||
|
wingCount: 1,
|
||||||
|
glazing: "einfach",
|
||||||
|
frameThickness: 0.06,
|
||||||
|
frameWidth: 0.06,
|
||||||
|
insetFromFace: 0,
|
||||||
|
insetFace: "aussen",
|
||||||
|
defaultSillHeight: 0.9,
|
||||||
|
defaultWidth: 1.0,
|
||||||
|
defaultHeight: 1.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const insetWindow = (project: Project): Project => ({
|
||||||
|
...project,
|
||||||
|
windowTypes: [
|
||||||
|
...(project.windowTypes ?? []),
|
||||||
|
{
|
||||||
|
id: "win-inset-test",
|
||||||
|
name: "Test Schichteinzug",
|
||||||
|
kind: "fest",
|
||||||
|
wingCount: 1,
|
||||||
|
glazing: "einfach",
|
||||||
|
frameThickness: 0.06,
|
||||||
|
frameWidth: 0.06,
|
||||||
|
insetFromFace: 0.1,
|
||||||
|
insetFace: "aussen",
|
||||||
|
defaultSillHeight: 0.9,
|
||||||
|
defaultWidth: 1.0,
|
||||||
|
defaultHeight: 1.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const openingWithType = (typeId: string): Project["openings"] => [
|
||||||
|
{
|
||||||
|
id: "OI",
|
||||||
|
type: "opening",
|
||||||
|
hostWallId: "W1",
|
||||||
|
categoryCode: "21",
|
||||||
|
kind: "window",
|
||||||
|
typeId,
|
||||||
|
position: 3.0,
|
||||||
|
width: 1.0,
|
||||||
|
height: 1.2,
|
||||||
|
sillHeight: 0.9,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const pFlush: Project = { ...flushWindow(sampleProject), openings: openingWithType("win-flush-test"), doors: [], context: [] };
|
||||||
|
const pInset: Project = { ...insetWindow(sampleProject), openings: openingWithType("win-inset-test"), doors: [], context: [] };
|
||||||
|
|
||||||
|
const minY = (meshes: { positions: number[]; color: [number, number, number] }[]): number => {
|
||||||
|
let m = Infinity;
|
||||||
|
for (const mesh of meshes.filter(isFrame)) {
|
||||||
|
for (let i = 1; i < mesh.positions.length; i += 3) m = Math.min(m, mesh.positions[i]);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
const flushMinY = minY(projectToModel3d(pFlush).meshes);
|
||||||
|
const insetMinY = minY(projectToModel3d(pInset).meshes);
|
||||||
|
// Bündig sitzt die Rahmen-Aussenkante weiter aussen (negativeres y); mit
|
||||||
|
// Einzug rückt sie um insetFromFace (0.1 m) Richtung Wandmitte (grösseres y).
|
||||||
|
expect(insetMinY - flushMinY).toBeCloseTo(0.1, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Öffnung ohne auflösbaren Typ -> KEIN Rahmen-Mesh (Rückwärtskompatibilität)", () => {
|
||||||
|
const p: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
openings: [
|
||||||
|
{
|
||||||
|
id: "OX",
|
||||||
|
type: "opening",
|
||||||
|
hostWallId: "W1",
|
||||||
|
categoryCode: "21",
|
||||||
|
kind: "window",
|
||||||
|
// Kein typeId -> kein auflösbarer WindowType.
|
||||||
|
position: 3.0,
|
||||||
|
width: 1.0,
|
||||||
|
height: 1.2,
|
||||||
|
sillHeight: 0.9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
doors: [],
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
const { meshes } = projectToModel3d(p);
|
||||||
|
expect(meshes.some(isFrame)).toBe(false);
|
||||||
|
// Alt-Verhalten bleibt unverändert: weiterhin genau EINE Vollscheibe.
|
||||||
|
expect(meshes.filter(isGlass).length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Per-Schicht-Decken-Dominanz Wand/Decke (3D-Entsprechung der Schnitt-Boolean-Dominanz)", () => {
|
describe("Per-Schicht-Decken-Dominanz Wand/Decke (3D-Entsprechung der Schnitt-Boolean-Dominanz)", () => {
|
||||||
// Sample-Projekt: W1-W4 (EG, Wandtyp "aw": render-ext 10, insulation 20,
|
// Sample-Projekt: W1-W4 (EG, Wandtyp "aw": render-ext 10, insulation 20,
|
||||||
// brick 50, render-int 10) tragen Wandhöhe 2.6 m = Geschosshöhe. Decke C1
|
// brick 50, render-int 10) tragen Wandhöhe 2.6 m = Geschosshöhe. Decke C1
|
||||||
|
|||||||
+357
-48
@@ -32,7 +32,7 @@
|
|||||||
// Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle
|
// Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle
|
||||||
// Dicke im neutralen Grundton (heutiges Fallback-Verhalten).
|
// Dicke im neutralen Grundton (heutiges Fallback-Verhalten).
|
||||||
|
|
||||||
import type { Project, Wall, Ceiling, Column, Layer, ExtrudedSolid, Drawing2D, Vec2, HatchPattern, SliceTermination } from "../model/types";
|
import type { Project, Wall, Ceiling, Column, Layer, ExtrudedSolid, Drawing2D, Vec2, HatchPattern, SliceTermination, Opening } from "../model/types";
|
||||||
import {
|
import {
|
||||||
getComponent,
|
getComponent,
|
||||||
getWallType,
|
getWallType,
|
||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
openingsOfWall,
|
openingsOfWall,
|
||||||
getFloor,
|
getFloor,
|
||||||
flattenCategories,
|
flattenCategories,
|
||||||
|
getDoorType,
|
||||||
|
getWindowType,
|
||||||
} from "../model/types";
|
} from "../model/types";
|
||||||
import { wallVerticalExtent, ceilingVerticalExtent, columnVerticalExtent, stairVerticalExtent } from "../model/wall";
|
import { wallVerticalExtent, ceilingVerticalExtent, columnVerticalExtent, stairVerticalExtent } from "../model/wall";
|
||||||
import { columnFootprint } from "../geometry/column";
|
import { columnFootprint } from "../geometry/column";
|
||||||
@@ -381,6 +383,15 @@ const COLUMN_RGB: RRgb = [0.7, 0.7, 0.72];
|
|||||||
const GLASS_RGB: RRgb = [0.62, 0.76, 0.85];
|
const GLASS_RGB: RRgb = [0.62, 0.76, 0.85];
|
||||||
/** Halbe Dicke der Fensterscheibe (Meter) — sehr dünn, mittig in der Wand. */
|
/** Halbe Dicke der Fensterscheibe (Meter) — sehr dünn, mittig in der Wand. */
|
||||||
const GLASS_HALF_THICK = 0.01;
|
const GLASS_HALF_THICK = 0.01;
|
||||||
|
/**
|
||||||
|
* Helles, warmes Rahmen-Grau (Zarge/Blendrahmen/Kämpfer/Sprossen) — bewusst
|
||||||
|
* deutlich heller als Wand ({@link WALL_RGB}) und klar von der bläulichen
|
||||||
|
* Scheibe ({@link GLASS_RGB}) unterscheidbar, damit der Rahmen im 3D als
|
||||||
|
* eigenes Bauteil lesbar ist (s. {@link emitOpeningFrames}).
|
||||||
|
*/
|
||||||
|
const FRAME_RGB: RRgb = [0.85, 0.85, 0.83];
|
||||||
|
/** Default-Ansichtsbreite des Rahmenprofils (Meter), wenn `TYPE.frameWidth` fehlt. */
|
||||||
|
const DEFAULT_FRAME_WIDTH = 0.06;
|
||||||
/** Fallback-Farbe für 2D-Zeichnungen ohne color/lineStyle/Kategorie (wie drawing2DColor in Viewport3D.tsx, "#888888"). */
|
/** Fallback-Farbe für 2D-Zeichnungen ohne color/lineStyle/Kategorie (wie drawing2DColor in Viewport3D.tsx, "#888888"). */
|
||||||
const DRAWING_LINE_FALLBACK_RGB: RRgb = [0x88 / 255, 0x88 / 255, 0x88 / 255];
|
const DRAWING_LINE_FALLBACK_RGB: RRgb = [0x88 / 255, 0x88 / 255, 0x88 / 255];
|
||||||
/** Halbe Linienbreite der 2D-Boden-Spuren im 3D (Meter) — liest sich als dünne Linie. */
|
/** Halbe Linienbreite der 2D-Boden-Spuren im 3D (Meter) — liest sich als dünne Linie. */
|
||||||
@@ -1648,17 +1659,298 @@ function emitMeshes(project: Project): RMesh[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Faces eines Boxen-Quaders als Dreiecks-Indizes (Ecken-Index-Bits: bit0 =
|
||||||
|
// Achse from/to, bit1 = Höhe UK/OK, bit2 = Quer-Versatz nMin/nMax). Doppelseitig
|
||||||
|
// gerendert (append_context_mesh) ⇒ Winding egal; je Fläche 2 Dreiecke, 6
|
||||||
|
// Flächen = 12 Dreiecke. Gemeinsame Topologie für Glasscheiben UND Rahmen-/
|
||||||
|
// Sprossen-Riegel (s. {@link openingAxisBox}).
|
||||||
|
const OPENING_BOX_TRIS: number[] = [
|
||||||
|
0, 1, 3, 0, 3, 2, // Quer-Versatz nMin
|
||||||
|
4, 5, 7, 4, 7, 6, // Quer-Versatz nMax
|
||||||
|
0, 1, 5, 0, 5, 4, // UK
|
||||||
|
2, 3, 7, 2, 7, 6, // OK
|
||||||
|
0, 2, 6, 0, 6, 4, // from
|
||||||
|
1, 3, 7, 1, 7, 5, // to
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emittiert je Fensteröffnung eine dünne Glasscheibe als rohes Quader-Mesh —
|
* Baut EINEN geschlossenen Achsen-Quader (8 Ecken, 12 Dreiecke) für eine
|
||||||
* damit Fenster im 3D als Fenster lesbar sind statt als blosse Löcher in der
|
* Öffnung — die gemeinsame Basis von {@link emitOpeningGlass} (Scheiben) und
|
||||||
* Wand. Nur `kind === "window"`; Türen bleiben offen (keine Scheibe).
|
* {@link emitOpeningFrames} (Rahmen-/Sprossen-Riegel). Achsen-Intervall
|
||||||
|
* `[from,to]` (Meter ab `wall.start`, entlang der Wandrichtung u), Höhen-
|
||||||
|
* Intervall `[zBottom,zTop]` (absolute Weltmeter) und Quer-Versatz-Intervall
|
||||||
|
* `[nMin,nMax]` (Meter entlang der Grundriss-Normalen n=(uy,−ux), dieselbe
|
||||||
|
* Konvention wie die bisherige Glas-Box — `nMin`/`nMax` MÜSSEN nicht sortiert
|
||||||
|
* übergeben werden, die Box spannt zwischen beiden Werten). `positions` in
|
||||||
|
* MODELL-Metern (x, y, z=Höhe) wie {@link emitMeshes}. Liefert `null` bei
|
||||||
|
* entarteten Massen (Achse/Höhe/Dicke ≤ EPS) statt eines Nullquaders.
|
||||||
|
*/
|
||||||
|
function openingAxisBox(
|
||||||
|
wall: Wall,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
zBottom: number,
|
||||||
|
zTop: number,
|
||||||
|
nMin: number,
|
||||||
|
nMax: number,
|
||||||
|
color: RRgb,
|
||||||
|
): RMesh | null {
|
||||||
|
if (to - from <= EPS) return null;
|
||||||
|
if (zTop - zBottom <= EPS) return null;
|
||||||
|
if (Math.abs(nMax - nMin) <= EPS) return null;
|
||||||
|
const dx = wall.end.x - wall.start.x;
|
||||||
|
const dy = wall.end.y - wall.start.y;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < 1e-9) return null;
|
||||||
|
const ux = dx / len;
|
||||||
|
const uy = dy / len;
|
||||||
|
// Grundriss-Normale (Dicken-Achse) — dieselbe Konvention wie pushSegment.
|
||||||
|
const nx = uy;
|
||||||
|
const ny = -ux;
|
||||||
|
const positions: number[] = [];
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const s = i & 1 ? to : from;
|
||||||
|
const z = i & 2 ? zTop : zBottom;
|
||||||
|
const t = i & 4 ? nMax : nMin;
|
||||||
|
positions.push(wall.start.x + ux * s + nx * t, wall.start.y + uy * s + ny * t, z);
|
||||||
|
}
|
||||||
|
return { positions, indices: [...OPENING_BOX_TRIS], kind: "imported", color };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Klemmt `v` auf `[lo,hi]`. */
|
||||||
|
function clampRange(v: number, lo: number, hi: number): number {
|
||||||
|
return Math.max(lo, Math.min(hi, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rahmen-Tiefe quer zur Wand (Meter): `frameThickness` des Typs, sonst dessen
|
||||||
|
* `frameDepth`, sonst die volle Wanddicke (Fallback, wenn der Typ keine
|
||||||
|
* brauchbare Tiefe nennt). Beide Typ-Felder sind wörtlich Meter ≤ 0 ⇒ ungültig
|
||||||
|
* (übersprungen, nächster Fallback greift).
|
||||||
|
*/
|
||||||
|
function resolveAcrossWallDepth(
|
||||||
|
frameThickness: number | undefined,
|
||||||
|
frameDepth: number | undefined,
|
||||||
|
wallThickness: number,
|
||||||
|
): number {
|
||||||
|
if (frameThickness !== undefined && frameThickness > EPS) return frameThickness;
|
||||||
|
if (frameDepth !== undefined && frameDepth > EPS) return frameDepth;
|
||||||
|
return wallThickness;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quer-Versatz-Intervall `[nMin,nMax]` (Meter entlang der Wand-Normalen, s.
|
||||||
|
* {@link openingAxisBox}) des Rahmenkörpers — der 3D-Niederschlag des
|
||||||
|
* Schichteinzugs (`insetFromFace`/`insetFace`): die Rahmen-VORDERKANTE liegt
|
||||||
|
* bündig mit der gewählten Wandfläche, zurückversetzt um `insetFromFace`
|
||||||
|
* (0/fehlt ⇒ bündig), der Rahmenkörper erstreckt sich von dort aus `depth`
|
||||||
|
* Meter zur Wandmitte hin. "aussen" liegt an der POSITIVEN Normalen-Seite
|
||||||
|
* (kleinster Schicht-Offset in {@link pushSegment}, s. dessen Kommentar zur
|
||||||
|
* Aussenseiten-Konvention via `leftNormal`), "innen" an der negativen. Das
|
||||||
|
* Ergebnis wird auf die halbe Wanddicke geklemmt — ein Schichteinzug darf den
|
||||||
|
* Rahmen nie aus der Wand hinausschieben.
|
||||||
|
*/
|
||||||
|
function resolveFrameNormalRange(
|
||||||
|
halfThickness: number,
|
||||||
|
depth: number,
|
||||||
|
insetFromFace: number,
|
||||||
|
face: "aussen" | "innen",
|
||||||
|
): { nMin: number; nMax: number } {
|
||||||
|
const sign = face === "innen" ? -1 : 1;
|
||||||
|
const front = sign * (halfThickness - insetFromFace);
|
||||||
|
const back = front - sign * depth;
|
||||||
|
const a = clampRange(front, -halfThickness, halfThickness);
|
||||||
|
const b = clampRange(back, -halfThickness, halfThickness);
|
||||||
|
return { nMin: Math.min(a, b), nMax: Math.max(a, b) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufgelöste Rahmen-/Sprossen-Parameter EINER Öffnung (Tür oder Fenster) aus
|
||||||
|
* ihrem {@link DoorType}/{@link WindowType} (via `getDoorType`/`getWindowType`).
|
||||||
|
* `null`, wenn kein Typ auflösbar ist (`Opening.typeId` fehlt/verwaist) — DANN
|
||||||
|
* emittieren weder {@link emitOpeningFrames} noch die Rahmen-/Sprossen-Logik in
|
||||||
|
* {@link emitOpeningGlass} irgendetwas (Rückwärtskompatibilität: unveränderte
|
||||||
|
* Alt-Darstellung, s. Moduldoc-Kommentar dort).
|
||||||
|
*/
|
||||||
|
interface OpeningFrameParams {
|
||||||
|
/** Sichtbare Rahmen-/Flügelprofil-Breite in der Öffnungsebene (Meter). */
|
||||||
|
frameWidth: number;
|
||||||
|
/** Rahmen-Tiefe quer zur Wand (Meter, informativ — steckt bereits in nMin/nMax). */
|
||||||
|
acrossWallDepth: number;
|
||||||
|
/** Quer-Versatz-Intervall des Rahmenkörpers (s. {@link resolveFrameNormalRange}). */
|
||||||
|
nMin: number;
|
||||||
|
nMax: number;
|
||||||
|
/** Oberlicht-Höhe (Meter); 0 = kein Oberlicht/Kämpfer. */
|
||||||
|
transomHeight: number;
|
||||||
|
/** Schwelle/Sohlbank zeichnen: Fenster immer, Tür nur mit `threshold`. */
|
||||||
|
hasSill: boolean;
|
||||||
|
/** Flügelzahl (vertikale Mittelpfosten = wingCount−1); Türen immer 1. */
|
||||||
|
wingCount: number;
|
||||||
|
/** Kämpfer-Zeilen (horizontale Teilung = mullionRows−1); Türen immer 1. */
|
||||||
|
mullionRows: number;
|
||||||
|
/** Ob eine Glasscheibe gezeichnet wird (Fenster immer, Tür nur `leafStyle: "glas"`). */
|
||||||
|
glazed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): OpeningFrameParams | null {
|
||||||
|
const wallThickness = wallThicknessSafe(project, wall);
|
||||||
|
const halfThickness = wallThickness / 2;
|
||||||
|
if (op.kind === "door") {
|
||||||
|
const dt = getDoorType(project, op);
|
||||||
|
if (!dt) return null;
|
||||||
|
const acrossWallDepth = resolveAcrossWallDepth(dt.frameThickness, dt.frameDepth, wallThickness);
|
||||||
|
const { nMin, nMax } = resolveFrameNormalRange(
|
||||||
|
halfThickness,
|
||||||
|
acrossWallDepth,
|
||||||
|
dt.insetFromFace ?? 0,
|
||||||
|
dt.insetFace ?? "aussen",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
frameWidth: dt.frameWidth ?? DEFAULT_FRAME_WIDTH,
|
||||||
|
acrossWallDepth,
|
||||||
|
nMin,
|
||||||
|
nMax,
|
||||||
|
transomHeight: Math.max(0, dt.transomHeight ?? 0),
|
||||||
|
hasSill: dt.threshold === true,
|
||||||
|
wingCount: 1,
|
||||||
|
mullionRows: 1,
|
||||||
|
glazed: dt.leafStyle === "glas",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const wt = getWindowType(project, op);
|
||||||
|
if (!wt) return null;
|
||||||
|
const acrossWallDepth = resolveAcrossWallDepth(wt.frameThickness, wt.frameDepth, wallThickness);
|
||||||
|
const { nMin, nMax } = resolveFrameNormalRange(
|
||||||
|
halfThickness,
|
||||||
|
acrossWallDepth,
|
||||||
|
wt.insetFromFace ?? 0,
|
||||||
|
wt.insetFace ?? "aussen",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
frameWidth: wt.frameWidth ?? DEFAULT_FRAME_WIDTH,
|
||||||
|
acrossWallDepth,
|
||||||
|
nMin,
|
||||||
|
nMax,
|
||||||
|
transomHeight: Math.max(0, wt.transomHeight ?? 0),
|
||||||
|
hasSill: true,
|
||||||
|
// Inline-Override `Opening.wingCount` gewinnt vor dem Typ-Default (analog
|
||||||
|
// `windowSymbol` im 2D, das dieselbe Priorität kennt), sonst Typ, sonst 1.
|
||||||
|
wingCount: Math.max(1, Math.round(op.wingCount ?? wt.wingCount ?? 1)),
|
||||||
|
mullionRows: Math.max(1, Math.round(wt.mullionRows ?? 1)),
|
||||||
|
glazed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rahmen-/Sprossen-Riegel EINER Öffnung: Pfosten (links/rechts, volle
|
||||||
|
* Öffnungshöhe), Sturz (oben), Schwelle (nur wenn `params.hasSill`), optional
|
||||||
|
* ein Kämpfer-Riegel (Oberlicht-Trennung, `transomHeight>0`) sowie die
|
||||||
|
* Flügel-Mittelpfosten (`wingCount−1`, vertikal) und Kämpfer-Zeilen
|
||||||
|
* (`mullionRows−1`, horizontal) im Flügel-Bereich zwischen Schwelle/Sturz
|
||||||
|
* bzw. Schwelle/Kämpfer. Alle Riegel in {@link FRAME_RGB}.
|
||||||
|
*/
|
||||||
|
function frameMeshesForOpening(
|
||||||
|
wall: Wall,
|
||||||
|
iv: { from: number; to: number },
|
||||||
|
vert: { zBottom: number; zTop: number },
|
||||||
|
params: OpeningFrameParams,
|
||||||
|
): RMesh[] {
|
||||||
|
const out: RMesh[] = [];
|
||||||
|
const fw = params.frameWidth;
|
||||||
|
const push = (from: number, to: number, zBottom: number, zTop: number): void => {
|
||||||
|
const mesh = openingAxisBox(wall, from, to, zBottom, zTop, params.nMin, params.nMax, FRAME_RGB);
|
||||||
|
if (mesh) out.push(mesh);
|
||||||
|
};
|
||||||
|
// Pfosten (Laibungsseiten), über die volle Öffnungshöhe durchlaufend.
|
||||||
|
push(iv.from, iv.from + fw, vert.zBottom, vert.zTop);
|
||||||
|
push(iv.to - fw, iv.to, vert.zBottom, vert.zTop);
|
||||||
|
// Sturz (oberer Riegel).
|
||||||
|
push(iv.from + fw, iv.to - fw, vert.zTop - fw, vert.zTop);
|
||||||
|
// Schwelle: Fenster immer, Tür nur mit Bodenanschlag (threshold).
|
||||||
|
if (params.hasSill) push(iv.from + fw, iv.to - fw, vert.zBottom, vert.zBottom + fw);
|
||||||
|
|
||||||
|
// Flügel-/Sash-Bereich zwischen Schwelle und Sturz, ggf. durch den Kämpfer
|
||||||
|
// (Oberlicht-Trennung) oben beschnitten.
|
||||||
|
const sashFrom = iv.from + fw;
|
||||||
|
const sashTo = iv.to - fw;
|
||||||
|
const sashBottom = vert.zBottom + (params.hasSill ? fw : 0);
|
||||||
|
let sashTop = vert.zTop - fw;
|
||||||
|
if (params.transomHeight > EPS) {
|
||||||
|
const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTop);
|
||||||
|
push(sashFrom, sashTo, splitZ - fw / 2, splitZ + fw / 2); // Kämpfer-Riegel
|
||||||
|
sashTop = splitZ - fw / 2;
|
||||||
|
}
|
||||||
|
// Flügel-Mittelpfosten (vertikal, wingCount−1) im Sash-Bereich.
|
||||||
|
if (params.wingCount > 1 && sashTo - sashFrom > EPS) {
|
||||||
|
const cell = (sashTo - sashFrom) / params.wingCount;
|
||||||
|
for (let i = 1; i < params.wingCount; i++) {
|
||||||
|
const center = sashFrom + i * cell;
|
||||||
|
push(center - fw / 2, center + fw / 2, sashBottom, sashTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Kämpfer-Zeilen (horizontal, mullionRows−1) im Sash-Bereich.
|
||||||
|
if (params.mullionRows > 1 && sashTop - sashBottom > EPS) {
|
||||||
|
const cell = (sashTop - sashBottom) / params.mullionRows;
|
||||||
|
for (let i = 1; i < params.mullionRows; i++) {
|
||||||
|
const center = sashBottom + i * cell;
|
||||||
|
push(sashFrom, sashTo, center - fw / 2, center + fw / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glasscheibe(n) EINER typisierten Öffnung (Fenster immer, Tür nur bei
|
||||||
|
* `params.glazed`): eine Hauptscheibe im Flügel-Bereich (zwischen Schwelle und
|
||||||
|
* Sturz bzw. Kämpfer), plus — bei `transomHeight>0` — eine ZWEITE Scheibe im
|
||||||
|
* Oberlicht-Band oberhalb des Kämpfers. Die Sprossen/Mittelpfosten aus
|
||||||
|
* {@link frameMeshesForOpening} liegen VOR dieser einen Hauptscheibe (keine
|
||||||
|
* Pro-Feld-Aufteilung — bewusst einfach gehalten, s. Datei-weite Erläuterung).
|
||||||
|
*/
|
||||||
|
function glassPanesForOpening(
|
||||||
|
wall: Wall,
|
||||||
|
iv: { from: number; to: number },
|
||||||
|
vert: { zBottom: number; zTop: number },
|
||||||
|
params: OpeningFrameParams,
|
||||||
|
): RMesh[] {
|
||||||
|
const out: RMesh[] = [];
|
||||||
|
const fw = params.frameWidth;
|
||||||
|
const sashFrom = iv.from + fw;
|
||||||
|
const sashTo = iv.to - fw;
|
||||||
|
const sashBottom = vert.zBottom + (params.hasSill ? fw : 0);
|
||||||
|
const sashTopFull = vert.zTop - fw;
|
||||||
|
const push = (zBottom: number, zTop: number): void => {
|
||||||
|
const mesh = openingAxisBox(wall, sashFrom, sashTo, zBottom, zTop, params.nMin, params.nMax, GLASS_RGB);
|
||||||
|
if (mesh) out.push(mesh);
|
||||||
|
};
|
||||||
|
if (params.transomHeight > EPS) {
|
||||||
|
const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTopFull);
|
||||||
|
push(sashBottom, splitZ - fw / 2); // Hauptscheibe/-blatt unterhalb des Kämpfers.
|
||||||
|
push(splitZ + fw / 2, sashTopFull); // Oberlicht-Scheibe oberhalb des Kämpfers.
|
||||||
|
} else {
|
||||||
|
push(sashBottom, sashTopFull);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emittiert je Fensteröffnung eine Glasscheibe (Türen nur bei glasfülligem
|
||||||
|
* Blatt, `leafStyle: "glas"`) als rohes Quader-Mesh — damit Fenster im 3D als
|
||||||
|
* Fenster lesbar sind statt als blosse Löcher in der Wand.
|
||||||
*
|
*
|
||||||
* Für jede Scheibe wird ein geschlossener Quader (8 Ecken, 12 Dreiecke) gebaut:
|
* OHNE auflösbaren Typ (`getWindowType`/`getDoorType` liefert `undefined`,
|
||||||
* • entlang der Wandachse: das geklemmte Öffnungs-Intervall (openingInterval),
|
* z. B. `Opening.typeId` fehlt): unverändertes ALT-Verhalten — EINE Vollscheibe
|
||||||
* • vertikal: die absolute Öffnungs-Ausdehnung (openingVerticalExtent, also
|
* über die geklemmte Öffnungs-Achse ({@link openingInterval}) und -Höhe
|
||||||
* UK = baseElevation+sillHeight … OK = +height, an den Wandkopf geklemmt) —
|
* ({@link openingVerticalExtent}), quer ±{@link GLASS_HALF_THICK} mittig in der
|
||||||
* exakt der Bereich, den emitWall als Loch freilässt,
|
* Wand (Rückwärtskompatibilität, s. Moduldoc-Kommentar).
|
||||||
* • quer zur Wand: ±GLASS_HALF_THICK um die Wandachse (mittig in der Dicke).
|
*
|
||||||
|
* MIT Typ: die Scheibe(n) sitzen im Flügel-Bereich INNERHALB des Rahmens (s.
|
||||||
|
* {@link glassPanesForOpening}), quer entlang des über den Schichteinzug
|
||||||
|
* bestimmten Rahmen-Versatzes (s. {@link resolveFrameNormalRange}) statt fix
|
||||||
|
* mittig — bei Oberlicht (`transomHeight>0`) kommt eine zweite Scheibe im
|
||||||
|
* Oberlicht-Band dazu. Die zugehörigen Rahmen-/Sprossen-Riegel emittiert
|
||||||
|
* separat {@link emitOpeningFrames} (eigene Farbe, eigene Funktion — leichter
|
||||||
|
* einzeln zu testen/filtern als eine gemeinsame Emission).
|
||||||
*
|
*
|
||||||
* `positions` liegen — wie bei {@link emitMeshes} (Terrain/Import) — in MODELL-
|
* `positions` liegen — wie bei {@link emitMeshes} (Terrain/Import) — in MODELL-
|
||||||
* Metern als flaches (x, y, z=Höhe)-Array; render3d bildet sie selbst nach world
|
* Metern als flaches (x, y, z=Höhe)-Array; render3d bildet sie selbst nach world
|
||||||
@@ -1667,46 +1959,60 @@ function emitMeshes(project: Project): RMesh[] {
|
|||||||
* mit gesetzter `color` ⇒ render3d zeichnet die Scheibe in GLASS_RGB.
|
* mit gesetzter `color` ⇒ render3d zeichnet die Scheibe in GLASS_RGB.
|
||||||
*/
|
*/
|
||||||
function emitOpeningGlass(project: Project): RMesh[] {
|
function emitOpeningGlass(project: Project): RMesh[] {
|
||||||
// Faces eines Boxen-Quaders als Dreiecks-Indizes (Ecken-Index-Bits:
|
|
||||||
// bit0 = Achse from/to, bit1 = Höhe UK/OK, bit2 = Dicke −/+). Doppelseitig
|
|
||||||
// gerendert ⇒ Winding egal; je Fläche 2 Dreiecke, 6 Flächen = 12 Dreiecke.
|
|
||||||
const BOX_TRIS: number[] = [
|
|
||||||
0, 1, 3, 0, 3, 2, // Dicke −
|
|
||||||
4, 5, 7, 4, 7, 6, // Dicke +
|
|
||||||
0, 1, 5, 0, 5, 4, // UK
|
|
||||||
2, 3, 7, 2, 7, 6, // OK
|
|
||||||
0, 2, 6, 0, 6, 4, // from
|
|
||||||
1, 3, 7, 1, 7, 5, // to
|
|
||||||
];
|
|
||||||
const out: RMesh[] = [];
|
const out: RMesh[] = [];
|
||||||
for (const wall of project.walls) {
|
for (const wall of project.walls) {
|
||||||
const dx = wall.end.x - wall.start.x;
|
|
||||||
const dy = wall.end.y - wall.start.y;
|
|
||||||
const len = Math.hypot(dx, dy);
|
|
||||||
if (len < 1e-9) continue;
|
|
||||||
const ux = dx / len;
|
|
||||||
const uy = dy / len;
|
|
||||||
// Grundriss-Normale (Dicken-Achse) — dieselbe Konvention wie pushSegment.
|
|
||||||
const nx = uy;
|
|
||||||
const ny = -ux;
|
|
||||||
for (const op of openingsOfWall(project, wall.id)) {
|
for (const op of openingsOfWall(project, wall.id)) {
|
||||||
if (op.kind !== "window") continue; // Türen bekommen keine Scheibe.
|
|
||||||
const iv = openingInterval(wall, op);
|
const iv = openingInterval(wall, op);
|
||||||
if (!iv) continue;
|
if (!iv) continue;
|
||||||
const { zBottom, zTop } = openingVerticalExtent(project, wall, op);
|
const vert = openingVerticalExtent(project, wall, op);
|
||||||
if (zTop - zBottom <= EPS) continue;
|
if (vert.zTop - vert.zBottom <= EPS) continue;
|
||||||
const positions: number[] = [];
|
if (op.kind === "window") {
|
||||||
// 8 Ecken: Achse s ∈ {from,to} × Höhe z ∈ {UK,OK} × Dicke t ∈ {−,+}.
|
const params = resolveOpeningFrame(project, wall, op);
|
||||||
for (let i = 0; i < 8; i++) {
|
if (!params) {
|
||||||
const s = i & 1 ? iv.to : iv.from;
|
// Kein Typ auflösbar -> Alt-Verhalten (fixe Vollscheibe, mittig).
|
||||||
const z = i & 2 ? zTop : zBottom;
|
const mesh = openingAxisBox(
|
||||||
const t = i & 4 ? GLASS_HALF_THICK : -GLASS_HALF_THICK;
|
wall,
|
||||||
const px = wall.start.x + ux * s + nx * t;
|
iv.from,
|
||||||
const py = wall.start.y + uy * s + ny * t;
|
iv.to,
|
||||||
// MODELL-Koordinaten (x, y, z=Höhe) wie emitMeshes.
|
vert.zBottom,
|
||||||
positions.push(px, py, z);
|
vert.zTop,
|
||||||
|
-GLASS_HALF_THICK,
|
||||||
|
GLASS_HALF_THICK,
|
||||||
|
GLASS_RGB,
|
||||||
|
);
|
||||||
|
if (mesh) out.push(mesh);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
out.push({ positions, indices: [...BOX_TRIS], kind: "imported", color: GLASS_RGB });
|
out.push(...glassPanesForOpening(wall, iv, vert, params));
|
||||||
|
} else {
|
||||||
|
// Tür: nur bei glasfülligem Blatt eine Scheibe (Alt-Verhalten: offen).
|
||||||
|
const params = resolveOpeningFrame(project, wall, op);
|
||||||
|
if (params?.glazed) out.push(...glassPanesForOpening(wall, iv, vert, params));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emittiert je Öffnung MIT auflösbarem Typ ({@link getDoorType}/
|
||||||
|
* {@link getWindowType}) die Rahmen-/Sprossen-Riegel (Zarge/Blendrahmen, Sturz,
|
||||||
|
* Schwelle, Kämpfer, Mittelpfosten — s. {@link frameMeshesForOpening}) als rohe
|
||||||
|
* Quader-Meshes in {@link FRAME_RGB}. Öffnungen OHNE auflösbaren Typ (`typeId`
|
||||||
|
* fehlt/verwaist) bekommen KEINEN Rahmen — exakt das Alt-Verhalten vor der
|
||||||
|
* Typ-Vertiefung (Rückwärtskompatibilität).
|
||||||
|
*/
|
||||||
|
function emitOpeningFrames(project: Project): RMesh[] {
|
||||||
|
const out: RMesh[] = [];
|
||||||
|
for (const wall of project.walls) {
|
||||||
|
for (const op of openingsOfWall(project, wall.id)) {
|
||||||
|
const iv = openingInterval(wall, op);
|
||||||
|
if (!iv) continue;
|
||||||
|
const vert = openingVerticalExtent(project, wall, op);
|
||||||
|
if (vert.zTop - vert.zBottom <= EPS) continue;
|
||||||
|
const params = resolveOpeningFrame(project, wall, op);
|
||||||
|
if (!params) continue;
|
||||||
|
out.push(...frameMeshesForOpening(wall, iv, vert, params));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -1774,13 +2080,16 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R
|
|||||||
// Decken + Treppen (Treppentritte als extrudierte Slabs) — so wird die Treppe
|
// Decken + Treppen (Treppentritte als extrudierte Slabs) — so wird die Treppe
|
||||||
// auch im WASM/wgpu-Renderer sichtbar (der three.js-Pfad rendert sie separat).
|
// auch im WASM/wgpu-Renderer sichtbar (der three.js-Pfad rendert sie separat).
|
||||||
slabs: [...emitSlabs(project), ...emitStairs(project)],
|
slabs: [...emitSlabs(project), ...emitStairs(project)],
|
||||||
// Kontext-Meshes (Terrain/Import) + Fenster-Glasscheiben (eigenes Mesh je
|
// Kontext-Meshes (Terrain/Import) + Fenster-/Tür-Glasscheiben (eigenes Mesh
|
||||||
// Fensteröffnung) + glatte Beton-Laufplatten (gerade Betontreppen als Mesh)
|
// je Scheibe) + Rahmen-/Sprossen-Riegel typisierter Öffnungen (Zarge/
|
||||||
// — pickGeometry/Highlight nutzen `meshes` bewusst NICHT (man wählt Wand/
|
// Blendrahmen/Kämpfer/Mittelpfosten, s. emitOpeningFrames) + glatte Beton-
|
||||||
// Öffnung/Treppe; Treppen-Pick läuft über die pickGeometry-Boxen).
|
// Laufplatten (gerade Betontreppen als Mesh) — pickGeometry/Highlight nutzen
|
||||||
|
// `meshes` bewusst NICHT (man wählt Wand/Öffnung/Treppe; Treppen-Pick läuft
|
||||||
|
// über die pickGeometry-Boxen).
|
||||||
meshes: [
|
meshes: [
|
||||||
...emitMeshes(project),
|
...emitMeshes(project),
|
||||||
...emitOpeningGlass(project),
|
...emitOpeningGlass(project),
|
||||||
|
...emitOpeningFrames(project),
|
||||||
...emitStairMeshes(project),
|
...emitStairMeshes(project),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user