68a0459d0e
- Schnittebenen: 3D-Live-Schnitt folgt der gewählten Grundriss-Schnittlinie (section3dCutId/sectionPlaneFromLevel), Schalter "Im 3D schneiden" im Objekt-Info, Doppelklick auf Schnittlinie springt in 2D-Schnittansicht, unsichtbare Ebenen blenden ihre Führungslinie aus - Eigene native Tauri-Fenster für Kontext-Import/Zeichnungsebenen/ Ebenen-Einstellungen/Ressourcen/Einstellungen + klassische Menüleiste (AppMenuBar) neben der Wortmarke - Hell/Dunkel-Umschalter (Einstellungen → Darstellung), persistiert, flackerfrei vor erstem Render gesetzt - SWISSIMAGE-Luftbild-Import (swisstopo WMS) als Kontext-Hintergrundebene - UI-Politur: Werkzeug-Panel Symbole/Liste umschaltbar, Topbar-Quick-Access- Icons entfernt, Zahnrad→Einstellungen in Panel-Köpfen, Footerbar/ Snap-Marker/Maß-HUD auf helle Pillen-Sprache umgestellt - Neues Dachziegel-Material (RoofingTiles013A)
700 lines
29 KiB
TypeScript
700 lines
29 KiB
TypeScript
// IFC4-Export (STEP Physical File / ISO-10303-21) des semantischen Modells.
|
||
// Reiner Rechen-/Serialisierungskern: keine UI, kein Datei-IO, kein WASM, keine
|
||
// neue Dependency — IFC wird direkt als Text geschrieben (analog exportDxf.ts/
|
||
// exportSchedule.ts). Der Download (Blob+Anchor) passiert im App-Layer.
|
||
//
|
||
// Abbildung (erste vollständige Scheibe):
|
||
// Project → IfcProject → IfcSite → IfcBuilding → je "floor"-Geschoss ein
|
||
// IfcBuildingStorey (IfcRelAggregates-Kette). Bauteile hängen über
|
||
// IfcRelContainedInSpatialStructure am jeweiligen Geschoss (verwaiste
|
||
// Geschossreferenzen fallen defensiv auf IfcBuilding zurück).
|
||
//
|
||
// Decken/Treppen/Extrusionen als IfcExtrudedAreaSolid (unser Modell IST
|
||
// Extrusion): Profil = IfcArbitraryClosedProfileDef(IfcPolyline) in der
|
||
// XY-Ebene, extrudiert entlang +Z. Die horizontale Objekt-Platzierungskette
|
||
// (Site/Building/Storey/Element) trägt bewusst NUR die Z-Verschiebung
|
||
// (Geschoss-Elevation); die Profilpunkte tragen direkt die Welt-X/Y-Koordinaten.
|
||
//
|
||
// • Wand → IfcWall mit ÖFFNUNGSGENAUEM Dreiecks-Mesh (IfcTriangulatedFace
|
||
// Set, IFC4: IfcCartesianPointList3D + CoordIndex) statt einer Profil-
|
||
// Extrusion. Gespeist aus DEMSELBEN Loch-Ausschnitt-Mesh wie STL/OBJ
|
||
// (`pickGeometry` → `plan/wallMeshCut.ts`): Joins/Gehrungen UND ausgeschnittene
|
||
// Fenster/Türen (inkl. Laibungen) sind im Körper enthalten. ABWÄGUNG (bewusst,
|
||
// Nutzer-Priorität "so wie im 3D"): dadurch verliert die Wand die parametrische
|
||
// IfcWall-Profil-Extrusion + IfcOpeningElement-Void-Semantik zugunsten
|
||
// VISUELLER PARITÄT in JEDEM Viewer (der Loch schon im Mesh sieht, ohne eine
|
||
// Boolean-Subtraktion ausführen zu müssen — genau der Bug des Nutzers: "das
|
||
// Fenster ist als Objekt da im IFC, aber die Löcher sind nicht da").
|
||
// • Decke → IfcSlab (outline-Polygon, PredefinedType FLOOR).
|
||
// • Öffnung → KEIN IfcOpeningElement/Void mehr (das Loch steckt im Wand-Mesh);
|
||
// Tür/Fenster bleiben als eigenes Objekt IfcDoor/IfcWindow mit eigener Box-
|
||
// Geometrie erhalten (füllt das ausgeschnittene Loch, "sieht aus wie 3D").
|
||
// • Extrusion → IfcBuildingElementProxy aus points+height.
|
||
// • Treppe → IfcStair, GEOMETRISCH bewusst vereinfacht auf einen
|
||
// extrudierten Bounding-Footprint (Lauf-Rechteck bei "straight"; Achsen-
|
||
// ausgerichtete Bounding-Box der Kontrollpunkte bei "L"/"spiral") — die
|
||
// echte Stufengeometrie ist ausgelassen (siehe stairFootprint()).
|
||
//
|
||
// Material-Layer (IfcMaterialLayerSet/-Usage) sind NICHT enthalten — die
|
||
// korrekte Direction/Offset-Semantik von IfcMaterialLayerSetUsage ließ sich
|
||
// ohne Gegenprüfung an einem echten Viewer nicht mit ausreichender Sicherheit
|
||
// umsetzen; Geometrie/Hierarchie hatten Vorrang (siehe Bericht/PENDENZEN).
|
||
//
|
||
// GUIDs: deterministisch aus der Element-ID über einen 128-Bit-Hash (zwei
|
||
// FNV-1a-64-Läufe) + Standard-IFC-GUID-Kompression (Base64-Variante,
|
||
// Zeichensatz 0-9,A-Z,a-z,_,$) — stabil über Re-Exporte hinweg.
|
||
//
|
||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheit: METER.
|
||
|
||
import type {
|
||
Opening,
|
||
Project,
|
||
Stair,
|
||
Vec2,
|
||
Wall,
|
||
} from "../model/types";
|
||
import {
|
||
getCeilingType,
|
||
getWallType,
|
||
openingLabel,
|
||
wallTypeThickness,
|
||
} from "../model/types";
|
||
import { projectToWorldCoords } from "../model/geoRebase";
|
||
import {
|
||
ceilingVerticalExtent,
|
||
stairVerticalExtent,
|
||
wallReferenceOffset,
|
||
wallVerticalExtent,
|
||
} from "../model/wall";
|
||
import { pickGeometry } from "../plan/toWalls3d";
|
||
import type { RWall } from "../plan/toWalls3d";
|
||
import { isWatertight, wallCutMesh } from "../plan/wallMeshCut";
|
||
|
||
// ── IFC-GUID (Base64-Kompression, 22 Zeichen) ───────────────────────────────
|
||
// Standard-Kompressionsalgorithmus (IfcOpenShell guid.compress): das erste
|
||
// Byte des 128-Bit-Werts wird auf 2 Zeichen abgebildet, die restlichen 15
|
||
// Byte in 5 Dreiergruppen zu je 4 Zeichen — macht 2 + 5×4 = 22 Zeichen.
|
||
|
||
const IFC_GUID_CHARS =
|
||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$";
|
||
|
||
/** Kodiert `v` big-endian in `len` IFC-GUID-Zeichen (Basis 64). */
|
||
function ifcGuidB64(v: number, len: number): string {
|
||
let out = "";
|
||
for (let i = len - 1; i >= 0; i--) {
|
||
out += IFC_GUID_CHARS[Math.floor(v / 64 ** i) % 64];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Komprimiert einen 128-Bit-Wert (32 Hex-Zeichen) zur 22-stelligen IFC-GUID. */
|
||
function compressGuidHex(hex32: string): string {
|
||
const bytes: number[] = [];
|
||
for (let i = 0; i < 32; i += 2) bytes.push(parseInt(hex32.slice(i, i + 2), 16));
|
||
let out = ifcGuidB64(bytes[0], 2);
|
||
for (let i = 1; i < 16; i += 3) {
|
||
const v = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2];
|
||
out += ifcGuidB64(v, 4);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** FNV-1a-64 (BigInt) — reines Determinismus-/Streuungs-Werkzeug, keine Kryptografie. */
|
||
function fnv1a64(str: string, seed: bigint): bigint {
|
||
const prime = 0x100000001b3n;
|
||
const mask = 0xffffffffffffffffn;
|
||
let hash = seed & mask;
|
||
for (let i = 0; i < str.length; i++) {
|
||
hash ^= BigInt(str.charCodeAt(i));
|
||
hash = (hash * prime) & mask;
|
||
}
|
||
return hash;
|
||
}
|
||
|
||
/** Leitet aus einer stabilen Element-ID einen deterministischen 128-Bit-Hex-Wert ab. */
|
||
function idToHex32(id: string): string {
|
||
const h1 = fnv1a64(id, 0xcbf29ce484222325n);
|
||
const h2 = fnv1a64(`${id}salt`, 0x9e3779b97f4a7c15n);
|
||
return h1.toString(16).padStart(16, "0") + h2.toString(16).padStart(16, "0");
|
||
}
|
||
|
||
/** Deterministische 22-stellige IFC-GUID aus einer beliebigen Element-ID. */
|
||
export function ifcGuid(id: string): string {
|
||
return compressGuidHex(idToHex32(id));
|
||
}
|
||
|
||
// ── STEP-Formatierung ───────────────────────────────────────────────────────
|
||
|
||
/** STEP-String-Literal ('…', Apostroph verdoppelt, Backslash verdoppelt). */
|
||
function S(s: string): string {
|
||
const escaped = s.replace(/\\/g, "\\\\").replace(/'/g, "''");
|
||
return `'${escaped}'`;
|
||
}
|
||
|
||
/** STEP-REAL-Literal — immer mit Dezimalpunkt, ohne unnötige Nachkommastellen. */
|
||
function R(x: number): string {
|
||
const v = Object.is(x, -0) ? 0 : x;
|
||
let s = v.toFixed(6);
|
||
s = s.replace(/0+$/, "");
|
||
if (s.endsWith(".")) s += "0";
|
||
if (!s.includes(".")) s += ".0";
|
||
return s;
|
||
}
|
||
|
||
/** STEP-Enumerationswert `.WERT.`. */
|
||
function ENUM(v: string): string {
|
||
return `.${v}.`;
|
||
}
|
||
|
||
/** STEP-Liste `(a,b,c)`. */
|
||
function LIST(items: string[]): string {
|
||
return `(${items.join(",")})`;
|
||
}
|
||
|
||
// ── STEP-Writer ──────────────────────────────────────────────────────────────
|
||
|
||
class StepWriter {
|
||
private lines: string[] = [];
|
||
private nextId = 1;
|
||
|
||
/** Schreibt eine neue Entity-Zeile und liefert ihre `#id`. */
|
||
add(type: string, params: string): number {
|
||
const id = this.nextId++;
|
||
this.lines.push(`#${id}=${type}(${params});`);
|
||
return id;
|
||
}
|
||
|
||
get entityLines(): readonly string[] {
|
||
return this.lines;
|
||
}
|
||
}
|
||
|
||
// ── Geometrie-Helfer (reines 2D-Vec2-Rechnen, Welt-Meter) ───────────────────
|
||
|
||
function sub(a: Vec2, b: Vec2): Vec2 {
|
||
return { x: a.x - b.x, y: a.y - b.y };
|
||
}
|
||
function normalize(v: Vec2): Vec2 {
|
||
const len = Math.hypot(v.x, v.y) || 1;
|
||
return { x: v.x / len, y: v.y / len };
|
||
}
|
||
function leftNormal(u: Vec2): Vec2 {
|
||
return { x: -u.y, y: u.x };
|
||
}
|
||
function addScaled(p: Vec2, d: Vec2, s: number): Vec2 {
|
||
return { x: p.x + d.x * s, y: p.y + d.y * s };
|
||
}
|
||
|
||
/** Rechteck-Footprint einer Öffnung im Wandloch (volle Wanddicke tief), CCW. */
|
||
function openingFootprint(project: Project, wall: Wall, opening: Opening): Vec2[] {
|
||
const u = normalize(sub(wall.end, wall.start));
|
||
const n = leftNormal(u);
|
||
const t = wallTypeThickness(getWallType(project, wall));
|
||
const off = wallReferenceOffset(wall, t);
|
||
const inner = -t / 2 + off;
|
||
const outer = t / 2 + off;
|
||
const a = addScaled(wall.start, u, opening.position);
|
||
const b = addScaled(wall.start, u, opening.position + opening.width);
|
||
return [
|
||
addScaled(a, n, inner),
|
||
addScaled(b, n, inner),
|
||
addScaled(b, n, outer),
|
||
addScaled(a, n, outer),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Vereinfachter Bounding-Footprint einer Treppe (bewusst NICHT die echte
|
||
* Stufen-/Podestkontur, siehe Dateikopf):
|
||
* • "straight" — echtes, ausgerichtetes Lauf-Rechteck (Länge × Breite).
|
||
* • "L"/"spiral" — achsenausgerichtete Bounding-Box der Kontrollpunkte
|
||
* (Start/Eckpunkt/Ende bzw. Wendel-Zentrum±Radius), um die halbe
|
||
* Laufbreite erweitert.
|
||
*/
|
||
function stairFootprint(stair: Stair): Vec2[] {
|
||
const halfW = Math.max(stair.width, 0) / 2;
|
||
if (stair.shape === "straight") {
|
||
const u = normalize(stair.dir);
|
||
const n = leftNormal(u);
|
||
const end = addScaled(stair.start, u, stair.runLength);
|
||
return [
|
||
addScaled(stair.start, n, -halfW),
|
||
addScaled(end, n, -halfW),
|
||
addScaled(end, n, halfW),
|
||
addScaled(stair.start, n, halfW),
|
||
];
|
||
}
|
||
const pts: Vec2[] = [stair.start];
|
||
const u = normalize(stair.dir);
|
||
const corner = addScaled(stair.start, u, stair.runLength);
|
||
pts.push(corner);
|
||
if (stair.shape === "L" && stair.run2Length && stair.turn) {
|
||
const n = leftNormal(u);
|
||
const turnDir: Vec2 = { x: n.x * stair.turn, y: n.y * stair.turn };
|
||
pts.push(addScaled(corner, turnDir, stair.run2Length));
|
||
}
|
||
if (stair.shape === "spiral" && stair.center) {
|
||
const r = (stair.radius ?? 0) + halfW;
|
||
const c = stair.center;
|
||
return [
|
||
{ x: c.x - r, y: c.y - r },
|
||
{ x: c.x + r, y: c.y - r },
|
||
{ x: c.x + r, y: c.y + r },
|
||
{ x: c.x - r, y: c.y + r },
|
||
];
|
||
}
|
||
let minX = Infinity;
|
||
let minY = Infinity;
|
||
let maxX = -Infinity;
|
||
let maxY = -Infinity;
|
||
for (const p of pts) {
|
||
minX = Math.min(minX, p.x);
|
||
minY = Math.min(minY, p.y);
|
||
maxX = Math.max(maxX, p.x);
|
||
maxY = Math.max(maxY, p.y);
|
||
}
|
||
minX -= halfW;
|
||
minY -= halfW;
|
||
maxX += halfW;
|
||
maxY += halfW;
|
||
return [
|
||
{ x: minX, y: minY },
|
||
{ x: maxX, y: minY },
|
||
{ x: maxX, y: maxY },
|
||
{ x: minX, y: maxY },
|
||
];
|
||
}
|
||
|
||
// ── IFC4-Export ──────────────────────────────────────────────────────────────
|
||
|
||
interface StoreyRef {
|
||
entityId: number;
|
||
placementId: number;
|
||
baseElevation: number;
|
||
}
|
||
|
||
interface Structure {
|
||
entityId: number;
|
||
placementId: number;
|
||
baseElevation: number;
|
||
}
|
||
|
||
/**
|
||
* Baut aus einem Projekt einen vollständigen IFC4-SPF-String (STEP Physical
|
||
* File). Reiner Rechenkern — kein Datei-IO. Leeres Projekt ⇒ valider Minimal-
|
||
* IFC (Project/Site/Building, kein Crash).
|
||
*/
|
||
export function exportIfcSpf(projectIn: Project): string {
|
||
// Georeferenziert exportieren: ist ein Standort-Bezug gesetzt (geoAnchor),
|
||
// wird die gesamte Geometrie auf die ECHTEN LV95-Weltkoordinaten verschoben,
|
||
// damit die IFC-Datei in anderer Software lagerichtig sitzt — auch wenn das
|
||
// Modell lokal nahe (0,0) bearbeitet wurde (s. projectToWorldCoords / der
|
||
// „Neuer Bezugspunkt"-Workflow). Ohne Bezug bleibt alles lokal (Identität).
|
||
const project = projectToWorldCoords(projectIn);
|
||
const w = new StepWriter();
|
||
|
||
// Geteilte Grundgeometrie: Ursprung, Z-Extrusionsrichtung, Identitäts-
|
||
// Placement (Position aller ExtrudedAreaSolid — Profile tragen direkt
|
||
// Welt-X/Y, siehe Dateikopf).
|
||
const originPoint = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(0)]));
|
||
const extrudeDir = w.add("IFCDIRECTION", LIST([R(0), R(0), R(1)]));
|
||
const identityAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
|
||
|
||
// Owner-History (minimal, aber vorhanden — manche Importer verlangen sie).
|
||
const org = w.add("IFCORGANIZATION", `$,${S("dossier")},$,$,$`);
|
||
const person = w.add(
|
||
"IFCPERSON",
|
||
`${S("dossier")},$,$,$,$,$,$,$`,
|
||
);
|
||
const personOrg = w.add("IFCPERSONANDORGANIZATION", `#${person},#${org},$`);
|
||
const app = w.add(
|
||
"IFCAPPLICATION",
|
||
`#${org},${S("1.0")},${S("dossier")},${S("dossier")}`,
|
||
);
|
||
const ownerHistory = w.add(
|
||
"IFCOWNERHISTORY",
|
||
`#${personOrg},#${app},$,${ENUM("ADDED")},$,$,$,${Math.floor(Date.now() / 1000)}`,
|
||
);
|
||
|
||
// Einheiten (Meter, Radiant, m², m³).
|
||
const lenUnit = w.add("IFCSIUNIT", `*,${ENUM("LENGTHUNIT")},$,${ENUM("METRE")}`);
|
||
const areaUnit = w.add("IFCSIUNIT", `*,${ENUM("AREAUNIT")},$,${ENUM("SQUARE_METRE")}`);
|
||
const volUnit = w.add("IFCSIUNIT", `*,${ENUM("VOLUMEUNIT")},$,${ENUM("CUBIC_METRE")}`);
|
||
const angleUnit = w.add(
|
||
"IFCSIUNIT",
|
||
`*,${ENUM("PLANEANGLEUNIT")},$,${ENUM("RADIAN")}`,
|
||
);
|
||
const unitAssignment = w.add(
|
||
"IFCUNITASSIGNMENT",
|
||
LIST([`#${lenUnit}`, `#${areaUnit}`, `#${volUnit}`, `#${angleUnit}`]),
|
||
);
|
||
|
||
// Geometrischer Kontext (3D, Precision 1e-5).
|
||
const context = w.add(
|
||
"IFCGEOMETRICREPRESENTATIONCONTEXT",
|
||
`$,${S("Model")},3,${R(0.00001)},#${identityAxis},$`,
|
||
);
|
||
|
||
// Räumliche Hierarchie: Project → Site → Building → Storeys.
|
||
const siteAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
|
||
const sitePlacement = w.add("IFCLOCALPLACEMENT", `$,#${siteAxis}`);
|
||
const buildingAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
|
||
const buildingPlacement = w.add(
|
||
"IFCLOCALPLACEMENT",
|
||
`#${sitePlacement},#${buildingAxis}`,
|
||
);
|
||
|
||
const projectId = w.add(
|
||
"IFCPROJECT",
|
||
`${S(ifcGuid(`${project.id}:project`))},#${ownerHistory},${S(project.name || "Projekt")},$,$,$,$,${LIST([`#${context}`])},#${unitAssignment}`,
|
||
);
|
||
// RefElevation (m ü. M.): reale Datumshöhe des Standorts aus dem
|
||
// Georeferenzierungs-Bezug (wie ArchiCADs Vermessungspunkt). Rein informativ —
|
||
// die Geometrie wird NICHT verschoben (das Terrain trägt bereits reale Höhen).
|
||
const refElevation = project.geoAnchor?.height;
|
||
const siteRefElev = typeof refElevation === "number" ? R(refElevation) : "$";
|
||
const siteId = w.add(
|
||
"IFCSITE",
|
||
`${S(ifcGuid(`${project.id}:site`))},#${ownerHistory},${S("Standort")},$,$,#${sitePlacement},$,$,${ENUM("ELEMENT")},$,$,${siteRefElev},$,$`,
|
||
);
|
||
const buildingId = w.add(
|
||
"IFCBUILDING",
|
||
`${S(ifcGuid(`${project.id}:building`))},#${ownerHistory},${S(project.name || "Gebäude")},$,$,#${buildingPlacement},$,$,${ENUM("ELEMENT")},$,$,$`,
|
||
);
|
||
w.add(
|
||
"IFCRELAGGREGATES",
|
||
`${S(ifcGuid(`${project.id}:agg-site`))},#${ownerHistory},$,$,#${projectId},${LIST([`#${siteId}`])}`,
|
||
);
|
||
w.add(
|
||
"IFCRELAGGREGATES",
|
||
`${S(ifcGuid(`${project.id}:agg-building`))},#${ownerHistory},$,$,#${siteId},${LIST([`#${buildingId}`])}`,
|
||
);
|
||
|
||
// Je "floor"-Geschoss ein IfcBuildingStorey (Elevation = baseElevation).
|
||
const floors = project.drawingLevels.filter((l) => l.kind === "floor");
|
||
const storeyByFloorId = new Map<string, StoreyRef>();
|
||
const storeyEntityIds: number[] = [];
|
||
for (const floor of floors) {
|
||
const base = floor.baseElevation ?? 0;
|
||
const pt = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(base)]));
|
||
const axis = w.add("IFCAXIS2PLACEMENT3D", `#${pt},$,$`);
|
||
const placementId = w.add("IFCLOCALPLACEMENT", `#${buildingPlacement},#${axis}`);
|
||
const entityId = w.add(
|
||
"IFCBUILDINGSTOREY",
|
||
`${S(ifcGuid(`${floor.id}:storey`))},#${ownerHistory},${S(floor.name)},$,$,#${placementId},$,$,${ENUM("ELEMENT")},${R(base)}`,
|
||
);
|
||
storeyByFloorId.set(floor.id, { entityId, placementId, baseElevation: base });
|
||
storeyEntityIds.push(entityId);
|
||
}
|
||
if (storeyEntityIds.length > 0) {
|
||
w.add(
|
||
"IFCRELAGGREGATES",
|
||
`${S(ifcGuid(`${project.id}:agg-storeys`))},#${ownerHistory},$,$,#${buildingId},${LIST(storeyEntityIds.map((id) => `#${id}`))}`,
|
||
);
|
||
}
|
||
|
||
/** Geschoss → Trägerstruktur (Storey), oder defensiv das Gebäude (verwaiste floorId). */
|
||
const resolveStructure = (floorId: string): Structure => {
|
||
const s = storeyByFloorId.get(floorId);
|
||
if (s) return { entityId: s.entityId, placementId: s.placementId, baseElevation: s.baseElevation };
|
||
return { entityId: buildingId, placementId: buildingPlacement, baseElevation: 0 };
|
||
};
|
||
|
||
// Räumliche Eingliederung sammelt sich je Trägerstruktur (Storey/Building)
|
||
// und wird am Ende in EINE IfcRelContainedInSpatialStructure je Struktur
|
||
// gebündelt (Öffnungen NICHT — die hängen nur über RelVoidsElement an ihrer
|
||
// Wand, wie in IFC üblich).
|
||
const containment = new Map<number, number[]>();
|
||
const addToContainment = (structureId: number, elementId: number): void => {
|
||
const arr = containment.get(structureId);
|
||
if (arr) arr.push(elementId);
|
||
else containment.set(structureId, [elementId]);
|
||
};
|
||
|
||
/** Baut Profil+Extrusion+Shape+Placement für einen geschlossenen Footprint. */
|
||
const emitBoxProduct = (
|
||
footprint: Vec2[],
|
||
zBottomRel: number,
|
||
depth: number,
|
||
placementRelTo: number,
|
||
): { placementId: number; shapeId: number } => {
|
||
const closed = [...footprint, footprint[0]];
|
||
const ptIds = closed.map((p) => w.add("IFCCARTESIANPOINT", LIST([R(p.x), R(p.y)])));
|
||
const polylineId = w.add("IFCPOLYLINE", LIST(ptIds.map((id) => `#${id}`)));
|
||
const profileId = w.add(
|
||
"IFCARBITRARYCLOSEDPROFILEDEF",
|
||
`${ENUM("AREA")},$,#${polylineId}`,
|
||
);
|
||
const solidId = w.add(
|
||
"IFCEXTRUDEDAREASOLID",
|
||
`#${profileId},#${identityAxis},#${extrudeDir},${R(Math.max(depth, 0.001))}`,
|
||
);
|
||
const shapeRepId = w.add(
|
||
"IFCSHAPEREPRESENTATION",
|
||
`#${context},${S("Body")},${S("SweptSolid")},${LIST([`#${solidId}`])}`,
|
||
);
|
||
const shapeId = w.add("IFCPRODUCTDEFINITIONSHAPE", `$,$,${LIST([`#${shapeRepId}`])}`);
|
||
const elemOrigin = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(zBottomRel)]));
|
||
const elemAxis = w.add("IFCAXIS2PLACEMENT3D", `#${elemOrigin},$,$`);
|
||
const placementId = w.add("IFCLOCALPLACEMENT", `#${placementRelTo},#${elemAxis}`);
|
||
return { placementId, shapeId };
|
||
};
|
||
|
||
/**
|
||
* Baut aus einem Dreiecks-Mesh (`positions` flach x,y,z in IFC-Koordinaten —
|
||
* bereits Z-up und relativ zur `placementRelTo`-Herkunft, `indices` je 3 =
|
||
* 1-basiert-1 CoordIndex-Tripel) ein IfcTriangulatedFaceSet + Shape + Placement.
|
||
* IFC4-Tessellierung: IfcCartesianPointList3D (CoordList) + IfcTriangulatedFace
|
||
* Set (CoordIndex, 1-basiert). Das Element-Placement sitzt im Ursprung der
|
||
* Trägerstruktur (die Punkte tragen die Geometrie bereits absolut in deren Frame).
|
||
*/
|
||
const emitTriangulatedProduct = (
|
||
positions: number[],
|
||
indices: number[],
|
||
placementRelTo: number,
|
||
closed: boolean,
|
||
): { placementId: number; shapeId: number } => {
|
||
const coords: string[] = [];
|
||
for (let i = 0; i < positions.length; i += 3) {
|
||
coords.push(`(${R(positions[i])},${R(positions[i + 1])},${R(positions[i + 2])})`);
|
||
}
|
||
const pointListId = w.add("IFCCARTESIANPOINTLIST3D", `(${coords.join(",")})`);
|
||
const tris: string[] = [];
|
||
for (let i = 0; i < indices.length; i += 3) {
|
||
tris.push(`(${indices[i] + 1},${indices[i + 1] + 1},${indices[i + 2] + 1})`);
|
||
}
|
||
// Closed=.T. NUR wenn das Mesh nachweislich ein dichtes, aussen orientiertes
|
||
// Volumen ist (siehe isWatertight): Wände sind extrudierte Querschnitts-
|
||
// Polygone (Fenster = Durchgangsloch, Tür = umlaufende П-Kerbe) → geschlossene
|
||
// Körper → `.T.` (Viewer rendern sie als Solid statt als offene Fläche). Der
|
||
// Guard fängt echte Defekte ab (invertierte Wicklung → Volumen < 0 → `$`).
|
||
// Normals=$ (Viewer leitet sie aus der — jetzt aussen orientierten — Wicklung ab).
|
||
const closedFlag = closed ? ".T." : "$";
|
||
const faceSetId = w.add("IFCTRIANGULATEDFACESET", `#${pointListId},$,${closedFlag},(${tris.join(",")}),$`);
|
||
const shapeRepId = w.add(
|
||
"IFCSHAPEREPRESENTATION",
|
||
`#${context},${S("Body")},${S("Tessellation")},${LIST([`#${faceSetId}`])}`,
|
||
);
|
||
const shapeId = w.add("IFCPRODUCTDEFINITIONSHAPE", `$,$,${LIST([`#${shapeRepId}`])}`);
|
||
const elemOrigin = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(0)]));
|
||
const elemAxis = w.add("IFCAXIS2PLACEMENT3D", `#${elemOrigin},$,$`);
|
||
const placementId = w.add("IFCLOCALPLACEMENT", `#${placementRelTo},#${elemAxis}`);
|
||
return { placementId, shapeId };
|
||
};
|
||
|
||
/** Baut ein IfcBuildingElement-Subtyp mit dem üblichen 9-Attribut-Flatten. */
|
||
const emitBuildingElement = (
|
||
type: string,
|
||
guid: string,
|
||
name: string,
|
||
placementId: number,
|
||
shapeId: number,
|
||
predefinedType: string | null,
|
||
): number =>
|
||
w.add(
|
||
type,
|
||
`${S(guid)},#${ownerHistory},${S(name)},$,$,#${placementId},#${shapeId},$,${predefinedType ?? "$"}`,
|
||
);
|
||
|
||
// ── Wände (öffnungsgenaues Dreiecks-Mesh statt Profil-Extrusion) ────────
|
||
// Die Wand-Schicht-Bänder kommen aus DEMSELBEN geflachten Modell wie STL/OBJ
|
||
// (`pickGeometry`, Joins/Gehrungen + Öffnungs-`holes` bereits aufgelöst). Alle
|
||
// Bänder einer Wand-Id werden zu EINEM Face-Set vereint. IFC-Koordinaten: das
|
||
// Mesh liegt in Welt (Modell-x, Höhe, Modell-y) mit Y-up → IFC (x, y, z=Höhe)
|
||
// mit Z-up, also (mx, mz, my); Z relativ zur Geschoss-UK, damit die Storey-
|
||
// Placement-Elevation nicht doppelt zählt.
|
||
//
|
||
// WICHTIG — WICKLUNG: der Achsen-Swap (x,y,z)→(x,z,y) ist eine REFLEXION
|
||
// (Determinante −1) und KEHRT die Dreiecks-Wicklung UM → aus aussen orientierten
|
||
// würden innen orientierte Normalen, der Viewer cullt dann die Vorderseiten und
|
||
// die Wand wirkt HOHL/offen (genau der gemeldete Bug). Deshalb wird beim Swap
|
||
// die Wicklung jedes Dreiecks umgedreht (i0,i2,i1), damit die Aussen-Normalen
|
||
// aussen bleiben. Watertightness (isWatertight) misst das anschliessend am
|
||
// fertigen IFC-Mesh → treibt das Closed-Flag des Face-Sets.
|
||
const bandsByWallId = new Map<string, RWall[]>();
|
||
for (const band of pickGeometry(project).walls) {
|
||
const list = bandsByWallId.get(band.wallId);
|
||
if (list) list.push(band);
|
||
else bandsByWallId.set(band.wallId, [band]);
|
||
}
|
||
const wallEntityIdByWallId = new Map<string, number>();
|
||
for (const wall of project.walls ?? []) {
|
||
const bands = bandsByWallId.get(wall.id);
|
||
if (!bands || bands.length === 0) continue; // degenerierte Wand / keine Geometrie
|
||
const structure = resolveStructure(wall.floorId);
|
||
const positions: number[] = [];
|
||
const indices: number[] = [];
|
||
for (const band of bands) {
|
||
const cut = wallCutMesh(band);
|
||
const base = positions.length / 3;
|
||
for (let i = 0; i < cut.positions.length; i += 3) {
|
||
positions.push(
|
||
cut.positions[i],
|
||
cut.positions[i + 2],
|
||
cut.positions[i + 1] - structure.baseElevation,
|
||
);
|
||
}
|
||
// Wicklung umkehren (Reflexions-Ausgleich, s. o.): (a,b,c) → (a,c,b).
|
||
for (let i = 0; i < cut.indices.length; i += 3) {
|
||
indices.push(base + cut.indices[i], base + cut.indices[i + 2], base + cut.indices[i + 1]);
|
||
}
|
||
}
|
||
if (indices.length === 0) continue;
|
||
const closed = isWatertight({ positions, indices });
|
||
const { placementId, shapeId } = emitTriangulatedProduct(positions, indices, structure.placementId, closed);
|
||
let name = wall.id;
|
||
try {
|
||
name = getWallType(project, wall).name;
|
||
} catch {
|
||
/* verwaister Wandtyp — Roh-ID als Name */
|
||
}
|
||
const entityId = emitBuildingElement("IFCWALL", ifcGuid(wall.id), name, placementId, shapeId, null);
|
||
wallEntityIdByWallId.set(wall.id, entityId);
|
||
addToContainment(structure.entityId, entityId);
|
||
}
|
||
|
||
// ── Decken ─────────────────────────────────────────────────────────────
|
||
for (const ceiling of project.ceilings ?? []) {
|
||
if (ceiling.outline.length < 3) continue;
|
||
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
|
||
const structure = resolveStructure(ceiling.floorId);
|
||
const { placementId, shapeId } = emitBoxProduct(
|
||
ceiling.outline,
|
||
zBottom - structure.baseElevation,
|
||
zTop - zBottom,
|
||
structure.placementId,
|
||
);
|
||
let name: string = ceiling.id;
|
||
try {
|
||
name = getCeilingType(project, ceiling).name;
|
||
} catch {
|
||
/* verwaister Deckentyp — Roh-ID als Name */
|
||
}
|
||
const entityId = emitBuildingElement(
|
||
"IFCSLAB",
|
||
ifcGuid(ceiling.id),
|
||
name,
|
||
placementId,
|
||
shapeId,
|
||
ENUM("FLOOR"),
|
||
);
|
||
addToContainment(structure.entityId, entityId);
|
||
}
|
||
|
||
// ── Fenster/Türen als eigene Objekte (IfcDoor/IfcWindow) ────────────────
|
||
// KEIN IfcOpeningElement/IfcRelVoidsElement/IfcRelFillsElement mehr: das Loch
|
||
// steckt bereits im Wand-Face-Set (s. o.). Eine Void-Relation beschriebe eine
|
||
// Boolean-Subtraktion gegen einen Swept-Solid, den es nicht mehr gibt — sie
|
||
// brächte in den Viewern nur Verwirrung (der Nutzer-Bug war genau, dass die
|
||
// Void nicht subtrahiert wurde). Tür/Fenster bleiben als EIGENES Objekt mit
|
||
// eigener Box-Geometrie erhalten, die das ausgeschnittene Loch füllt ("sieht
|
||
// aus wie 3D"): kein Blatt-/Rahmendetail (bewusste Vereinfachung).
|
||
for (const opening of project.openings ?? []) {
|
||
const wall = (project.walls ?? []).find((wl) => wl.id === opening.hostWallId);
|
||
if (!wall) continue; // verwaiste Wirtswand — keine Geometrie ableitbar
|
||
if (!wallEntityIdByWallId.has(wall.id)) continue; // Wirtswand übersprungen (degeneriert)
|
||
|
||
const wallExtent = wallVerticalExtent(project, wall);
|
||
const structure = resolveStructure(wall.floorId);
|
||
const footprint = openingFootprint(project, wall, opening);
|
||
const zSillAbs = wallExtent.zBottom + opening.sillHeight;
|
||
|
||
const { placementId: fillPlacement, shapeId: fillShape } = emitBoxProduct(
|
||
footprint,
|
||
zSillAbs - structure.baseElevation,
|
||
opening.height,
|
||
structure.placementId,
|
||
);
|
||
const fillGuid = ifcGuid(`${opening.id}:fill`);
|
||
const fillName = openingLabel(opening);
|
||
const fillEntityId =
|
||
opening.kind === "door"
|
||
? w.add(
|
||
"IFCDOOR",
|
||
`${S(fillGuid)},#${ownerHistory},${S(fillName)},$,$,#${fillPlacement},#${fillShape},$,${R(opening.height)},${R(opening.width)},${ENUM("DOOR")},$,$`,
|
||
)
|
||
: w.add(
|
||
"IFCWINDOW",
|
||
`${S(fillGuid)},#${ownerHistory},${S(fillName)},$,$,#${fillPlacement},#${fillShape},$,${R(opening.height)},${R(opening.width)},${ENUM("WINDOW")},$,$`,
|
||
);
|
||
addToContainment(structure.entityId, fillEntityId);
|
||
}
|
||
|
||
// ── Treppen (vereinfachter Bounding-Footprint, siehe Dateikopf) ────────
|
||
for (const stair of project.stairs ?? []) {
|
||
const footprint = stairFootprint(stair);
|
||
if (footprint.length < 3) continue;
|
||
const { zBottom, zTop } = stairVerticalExtent(project, stair);
|
||
const structure = resolveStructure(stair.floorId);
|
||
const { placementId, shapeId } = emitBoxProduct(
|
||
footprint,
|
||
zBottom - structure.baseElevation,
|
||
zTop - zBottom,
|
||
structure.placementId,
|
||
);
|
||
const predefinedType =
|
||
stair.shape === "straight"
|
||
? ENUM("STRAIGHT_RUN_STAIR")
|
||
: stair.shape === "spiral"
|
||
? ENUM("SPIRAL_STAIR")
|
||
: ENUM("QUARTER_TURN_STAIR");
|
||
const entityId = emitBuildingElement(
|
||
"IFCSTAIR",
|
||
ifcGuid(stair.id),
|
||
"Treppe",
|
||
placementId,
|
||
shapeId,
|
||
predefinedType,
|
||
);
|
||
addToContainment(structure.entityId, entityId);
|
||
}
|
||
|
||
// ── Extrudierte Körper (truck-Integration) → IfcBuildingElementProxy ───
|
||
for (const solid of project.extrudedSolids ?? []) {
|
||
if (solid.points.length < 3) continue;
|
||
const floor = floors.find((f) => f.id === solid.levelId);
|
||
const base = floor?.baseElevation ?? 0;
|
||
const structure = resolveStructure(solid.levelId);
|
||
const { placementId, shapeId } = emitBoxProduct(
|
||
solid.points,
|
||
base - structure.baseElevation,
|
||
solid.height,
|
||
structure.placementId,
|
||
);
|
||
const entityId = emitBuildingElement(
|
||
"IFCBUILDINGELEMENTPROXY",
|
||
ifcGuid(solid.id),
|
||
"Extrusion",
|
||
placementId,
|
||
shapeId,
|
||
null,
|
||
);
|
||
addToContainment(structure.entityId, entityId);
|
||
}
|
||
|
||
// ── Räumliche Eingliederung (gebündelt je Trägerstruktur) ──────────────
|
||
for (const [structureId, elementIds] of containment) {
|
||
w.add(
|
||
"IFCRELCONTAINEDINSPATIALSTRUCTURE",
|
||
`${S(ifcGuid(`contain:${structureId}`))},#${ownerHistory},$,$,${LIST(elementIds.map((id) => `#${id}`))},#${structureId}`,
|
||
);
|
||
}
|
||
|
||
// ── Kopf + Zusammenbau ───────────────────────────────────────────────────
|
||
const iso = new Date().toISOString().replace(/\.\d+Z$/, "");
|
||
const fileName = `${project.name || "modell"}.ifc`;
|
||
const header = [
|
||
"ISO-10303-21;",
|
||
"HEADER;",
|
||
`FILE_DESCRIPTION(${LIST([S("")])},${S("2;1")});`,
|
||
`FILE_NAME(${S(fileName)},${S(iso)},${LIST([S("dossier")])},${LIST([S("dossier")])},${S("dossier")},${S("dossier")},${S("")});`,
|
||
"FILE_SCHEMA(('IFC4'));",
|
||
"ENDSEC;",
|
||
"",
|
||
"DATA;",
|
||
];
|
||
const footer = ["ENDSEC;", "END-ISO-10303-21;"];
|
||
|
||
return [...header, ...w.entityLines, ...footer].join("\n") + "\n";
|
||
}
|