@pascal-app/ifc-converter 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -4
- package/dist/beam-geometry.d.ts.map +1 -1
- package/dist/beam-geometry.js +3 -2
- package/dist/cleanup.d.ts +8 -1
- package/dist/cleanup.d.ts.map +1 -1
- package/dist/cleanup.js +13 -4
- package/dist/collections.d.ts +4 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +66 -0
- package/dist/export/door-operation.d.ts +13 -0
- package/dist/export/door-operation.d.ts.map +1 -0
- package/dist/export/door-operation.js +45 -0
- package/dist/export/guid.d.ts +15 -0
- package/dist/export/guid.d.ts.map +1 -0
- package/dist/export/guid.js +80 -0
- package/dist/export/ifc-model.d.ts +87 -0
- package/dist/export/ifc-model.d.ts.map +1 -0
- package/dist/export/ifc-model.js +183 -0
- package/dist/export/index.d.ts +3 -0
- package/dist/export/index.d.ts.map +1 -0
- package/dist/export/index.js +2 -0
- package/dist/export/scene-to-ifc.d.ts +45 -0
- package/dist/export/scene-to-ifc.d.ts.map +1 -0
- package/dist/export/scene-to-ifc.js +1123 -0
- package/dist/export/step.d.ts +64 -0
- package/dist/export/step.d.ts.map +1 -0
- package/dist/export/step.js +158 -0
- package/dist/export/wall-base.d.ts +25 -0
- package/dist/export/wall-base.d.ts.map +1 -0
- package/dist/export/wall-base.js +91 -0
- package/dist/ids.d.ts +3 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +35 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +570 -151
- package/dist/room-first.d.ts +35 -0
- package/dist/room-first.d.ts.map +1 -0
- package/dist/room-first.js +885 -0
- package/dist/wall-joins.d.ts +21 -0
- package/dist/wall-joins.d.ts.map +1 -0
- package/dist/wall-joins.js +367 -0
- package/package.json +8 -4
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
import { calculateLevelMiters, DEFAULT_WALL_THICKNESS, difference, getLevelElevations, getOpeningWallCut, getRenderableSlabPolygon, getWallBodyCenterOffset, getWallCurveFrameAt, getWallCurveLength, getWallFaceOffsets, getWallPlaneTop, getWallPlanFootprint, hidesDescendants, intersection, isCurvedWall, liftedManualSlab, measureStair, prepareSlabPolygonContext, resolveCeilingHeight, resolveWallTop, sampleWallCenterline, union, wallSupportForNodes, } from '@pascal-app/core';
|
|
2
|
+
import { doorGlazingFraction, doorOperationForIfc } from './door-operation';
|
|
3
|
+
import { bool, cleanRing, DERIVED, enumValue, frameToLocal, IDENTITY_FRAME, IfcModel, identifier, int, label, ratio, relativeFrame, } from './ifc-model';
|
|
4
|
+
import { typed } from './step';
|
|
5
|
+
import { wallBaseCells, wallCellBand } from './wall-base';
|
|
6
|
+
const NON_PHYSICAL_TYPES = new Set([
|
|
7
|
+
'site',
|
|
8
|
+
'building',
|
|
9
|
+
'level',
|
|
10
|
+
'zone',
|
|
11
|
+
'unit',
|
|
12
|
+
'separator',
|
|
13
|
+
'guide',
|
|
14
|
+
'measurement',
|
|
15
|
+
'construction-dimension',
|
|
16
|
+
'spawn',
|
|
17
|
+
'scan',
|
|
18
|
+
'structural-grid',
|
|
19
|
+
'floor-opening',
|
|
20
|
+
]);
|
|
21
|
+
const NATIVE_TYPES = new Set(['wall', 'slab', 'ceiling', 'column', 'door', 'window']);
|
|
22
|
+
const FURNISHING_TYPES = new Set(['item', 'procedural-item', 'cabinet', 'cabinet-module', 'shelf']);
|
|
23
|
+
// Planting from the first-party trees plugin. IFC4 has no vegetation enum
|
|
24
|
+
// (IFC4.3 adds VEGETATION), so it is a user-defined geographic element.
|
|
25
|
+
const VEGETATION_TYPES = new Set(['trees:tree', 'trees:grass', 'trees:flower']);
|
|
26
|
+
const withPredefined = (entity, predefined = 'NOTDEFINED') => (tag, node) => {
|
|
27
|
+
const source = node ? node.metadata : undefined;
|
|
28
|
+
const sourceType = source?.predefinedType;
|
|
29
|
+
// Keep an imported element's own IFC enum when it round-trips to the same class.
|
|
30
|
+
const value = typeof sourceType === 'string' &&
|
|
31
|
+
/^[A-Z_]+$/.test(sourceType) &&
|
|
32
|
+
sourceType !== 'USERDEFINED' &&
|
|
33
|
+
String(source?.ifcType ?? '').startsWith(entity)
|
|
34
|
+
? sourceType
|
|
35
|
+
: predefined;
|
|
36
|
+
return [tag, enumValue(value)];
|
|
37
|
+
};
|
|
38
|
+
const PROXY = {
|
|
39
|
+
entity: 'IFCBUILDINGELEMENTPROXY',
|
|
40
|
+
tail: withPredefined('IFCBUILDINGELEMENTPROXY'),
|
|
41
|
+
};
|
|
42
|
+
const FURNISHING = { entity: 'IFCFURNISHINGELEMENT', tail: (tag) => [tag] };
|
|
43
|
+
const VEGETATION = {
|
|
44
|
+
entity: 'IFCGEOGRAPHICELEMENT',
|
|
45
|
+
tail: (tag) => [tag, enumValue('USERDEFINED')],
|
|
46
|
+
objectType: 'Vegetation',
|
|
47
|
+
};
|
|
48
|
+
const ROOF_SLAB = { entity: 'IFCSLAB', tail: (tag) => [tag, enumValue('ROOF')] };
|
|
49
|
+
const STAIR_FLIGHT = {
|
|
50
|
+
entity: 'IFCSTAIRFLIGHT',
|
|
51
|
+
tail: (tag) => [tag, null, null, null, null, enumValue('NOTDEFINED')],
|
|
52
|
+
};
|
|
53
|
+
/** IFC classes an imported mesh may keep on export; others become proxies. */
|
|
54
|
+
const IMPORTED_CLASSES = {
|
|
55
|
+
IFCBEAM: { entity: 'IFCBEAM', tail: withPredefined('IFCBEAM') },
|
|
56
|
+
IFCBEAMSTANDARDCASE: { entity: 'IFCBEAM', tail: withPredefined('IFCBEAM') },
|
|
57
|
+
IFCRAILING: { entity: 'IFCRAILING', tail: withPredefined('IFCRAILING') },
|
|
58
|
+
IFCCOVERING: { entity: 'IFCCOVERING', tail: withPredefined('IFCCOVERING') },
|
|
59
|
+
IFCCURTAINWALL: { entity: 'IFCCURTAINWALL', tail: withPredefined('IFCCURTAINWALL') },
|
|
60
|
+
IFCPLATE: { entity: 'IFCPLATE', tail: withPredefined('IFCPLATE') },
|
|
61
|
+
IFCMEMBER: { entity: 'IFCMEMBER', tail: withPredefined('IFCMEMBER') },
|
|
62
|
+
IFCFOOTING: { entity: 'IFCFOOTING', tail: withPredefined('IFCFOOTING') },
|
|
63
|
+
IFCSTAIRFLIGHT: STAIR_FLIGHT,
|
|
64
|
+
IFCFURNISHINGELEMENT: FURNISHING,
|
|
65
|
+
IFCBUILDINGELEMENTPROXY: PROXY,
|
|
66
|
+
IFCWALL: { entity: 'IFCWALL', tail: withPredefined('IFCWALL') },
|
|
67
|
+
IFCWALLSTANDARDCASE: { entity: 'IFCWALL', tail: withPredefined('IFCWALL') },
|
|
68
|
+
IFCSLAB: { entity: 'IFCSLAB', tail: withPredefined('IFCSLAB') },
|
|
69
|
+
};
|
|
70
|
+
function meshClassFor(node) {
|
|
71
|
+
if (node.type === 'imported-mesh' || node.type === 'block') {
|
|
72
|
+
const ifcType = node.metadata?.ifcType;
|
|
73
|
+
const known = typeof ifcType === 'string' ? IMPORTED_CLASSES[ifcType] : undefined;
|
|
74
|
+
if (known)
|
|
75
|
+
return known;
|
|
76
|
+
}
|
|
77
|
+
if (FURNISHING_TYPES.has(node.type))
|
|
78
|
+
return FURNISHING;
|
|
79
|
+
if (VEGETATION_TYPES.has(node.type))
|
|
80
|
+
return VEGETATION;
|
|
81
|
+
switch (node.type) {
|
|
82
|
+
case 'fence':
|
|
83
|
+
return { entity: 'IFCRAILING', tail: (tag) => [tag, enumValue('NOTDEFINED')] };
|
|
84
|
+
case 'curtain-wall':
|
|
85
|
+
return { entity: 'IFCCURTAINWALL', tail: (tag) => [tag, enumValue('NOTDEFINED')] };
|
|
86
|
+
case 'skylight':
|
|
87
|
+
return {
|
|
88
|
+
entity: 'IFCWINDOW',
|
|
89
|
+
tail: (tag) => [tag, null, null, enumValue('SKYLIGHT'), enumValue('NOTDEFINED'), null],
|
|
90
|
+
};
|
|
91
|
+
case 'column':
|
|
92
|
+
return { entity: 'IFCCOLUMN', tail: (tag) => [tag, enumValue('COLUMN')] };
|
|
93
|
+
case 'slab':
|
|
94
|
+
return { entity: 'IFCSLAB', tail: (tag) => [tag, enumValue('FLOOR')] };
|
|
95
|
+
case 'door':
|
|
96
|
+
return {
|
|
97
|
+
entity: 'IFCDOOR',
|
|
98
|
+
tail: (tag, node) => {
|
|
99
|
+
const door = node;
|
|
100
|
+
const operation = doorOperationForIfc(door);
|
|
101
|
+
return [
|
|
102
|
+
tag,
|
|
103
|
+
door.height,
|
|
104
|
+
door.width,
|
|
105
|
+
enumValue('DOOR'),
|
|
106
|
+
enumValue(operation.operationType),
|
|
107
|
+
operation.userDefined ?? null,
|
|
108
|
+
];
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
case 'window':
|
|
112
|
+
return {
|
|
113
|
+
entity: 'IFCWINDOW',
|
|
114
|
+
tail: (tag, node) => [
|
|
115
|
+
tag,
|
|
116
|
+
node.height,
|
|
117
|
+
node.width,
|
|
118
|
+
enumValue('WINDOW'),
|
|
119
|
+
enumValue('NOTDEFINED'),
|
|
120
|
+
null,
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
default:
|
|
124
|
+
return PROXY;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Pascal is Y-up with plan axes (x, z); IFC is Z-up. Seen from above, Pascal
|
|
128
|
+
// +z is IFC -Y — the exact inverse of the importer's `worldToScene`.
|
|
129
|
+
const planToIfc = (x, z) => [x, -z];
|
|
130
|
+
const localToIfc = (p) => [p[0], -p[2], p[1]];
|
|
131
|
+
function hexToRgb(hex) {
|
|
132
|
+
const match = /^#?([0-9a-f]{6})$/i.exec(hex);
|
|
133
|
+
if (!match)
|
|
134
|
+
return undefined;
|
|
135
|
+
const value = Number.parseInt(match[1], 16);
|
|
136
|
+
return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];
|
|
137
|
+
}
|
|
138
|
+
function eulerXYZ(rotation) {
|
|
139
|
+
const [a, b, c] = rotation;
|
|
140
|
+
const ca = Math.cos(a ?? 0);
|
|
141
|
+
const sa = Math.sin(a ?? 0);
|
|
142
|
+
const cb = Math.cos(b ?? 0);
|
|
143
|
+
const sb = Math.sin(b ?? 0);
|
|
144
|
+
const cc = Math.cos(c ?? 0);
|
|
145
|
+
const sc = Math.sin(c ?? 0);
|
|
146
|
+
// three.js 'XYZ' order: v' = Rx · Ry · Rz · v
|
|
147
|
+
return ([x, y, z]) => {
|
|
148
|
+
const x1 = x * cc - y * sc;
|
|
149
|
+
const y1 = x * sc + y * cc;
|
|
150
|
+
const x2 = x1 * cb + z * sb;
|
|
151
|
+
const z2 = -x1 * sb + z * cb;
|
|
152
|
+
return [x2, y1 * ca - z2 * sa, y1 * sa + z2 * ca];
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function buildingWorldToLocal(transform, baseY) {
|
|
156
|
+
const c = Math.cos(transform.yaw);
|
|
157
|
+
const s = Math.sin(transform.yaw);
|
|
158
|
+
const [bx, by, bz] = transform.position;
|
|
159
|
+
return ([x, y, z]) => {
|
|
160
|
+
const qx = x - bx;
|
|
161
|
+
const qz = z - bz;
|
|
162
|
+
return localToIfc([c * qx - s * qz, y - by - baseY, s * qx + c * qz]);
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** Serialize a Pascal scene graph as an IFC4 STEP file plus an export report. */
|
|
166
|
+
export function buildIfcExport(input) {
|
|
167
|
+
const nodes = input.nodes;
|
|
168
|
+
const meshes = input.meshes ?? new Map();
|
|
169
|
+
const model = new IfcModel();
|
|
170
|
+
const step = model.step;
|
|
171
|
+
const skipped = [];
|
|
172
|
+
const timestamp = input.timestamp ?? new Date();
|
|
173
|
+
const projectName = input.projectName?.trim() || 'Pascal project';
|
|
174
|
+
const excludedTypes = new Set(input.excludedNodeTypes ?? []);
|
|
175
|
+
const parentOf = (node) => node.parentId ? nodes[node.parentId] : undefined;
|
|
176
|
+
// Core's shared rule: a hidden node hides its subtree, except a Site, which
|
|
177
|
+
// only hides its own ground and boundary (`hidesDescendants`).
|
|
178
|
+
const hiddenCache = new Map();
|
|
179
|
+
const isHidden = (node) => {
|
|
180
|
+
const cached = hiddenCache.get(node.id);
|
|
181
|
+
if (cached !== undefined)
|
|
182
|
+
return cached;
|
|
183
|
+
const parent = parentOf(node);
|
|
184
|
+
const result = node.visible === false || (parent && hidesDescendants(parent) ? isHidden(parent) : false);
|
|
185
|
+
hiddenCache.set(node.id, result);
|
|
186
|
+
return result;
|
|
187
|
+
};
|
|
188
|
+
const typeExcludedCache = new Map();
|
|
189
|
+
const isTypeExcluded = (node) => {
|
|
190
|
+
const cached = typeExcludedCache.get(node.id);
|
|
191
|
+
if (cached !== undefined)
|
|
192
|
+
return cached;
|
|
193
|
+
const parent = parentOf(node);
|
|
194
|
+
const result = excludedTypes.has(node.type) || (parent ? isTypeExcluded(parent) : false);
|
|
195
|
+
typeExcludedCache.set(node.id, result);
|
|
196
|
+
return result;
|
|
197
|
+
};
|
|
198
|
+
const isExcluded = (node) => isTypeExcluded(node) || (input.onlyVisible === true && isHidden(node));
|
|
199
|
+
const ancestorOfType = (node, type) => {
|
|
200
|
+
let current = node;
|
|
201
|
+
for (let guard = 0; current && guard < 64; guard++) {
|
|
202
|
+
if (current.type === type)
|
|
203
|
+
return current;
|
|
204
|
+
current = parentOf(current);
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
};
|
|
208
|
+
const sortedNodes = Object.values(nodes).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
209
|
+
const nodesByType = new Map();
|
|
210
|
+
for (const node of sortedNodes)
|
|
211
|
+
pushTo(nodesByType, node.type, node);
|
|
212
|
+
const ofType = (type) => (nodesByType.get(type) ?? []);
|
|
213
|
+
// ── Project, units, contexts ──────────────────────────────────────────
|
|
214
|
+
// Sites are spatial containers only (their ground is not exported), so a
|
|
215
|
+
// hidden Site still anchors the buildings on it.
|
|
216
|
+
const sites = ofType('site');
|
|
217
|
+
const buildings = ofType('building').filter((building) => !isExcluded(building));
|
|
218
|
+
const levels = ofType('level').filter((level) => !isExcluded(level));
|
|
219
|
+
const projectSeed = sites[0]?.id ?? buildings[0]?.id ?? levels[0]?.id ?? projectName;
|
|
220
|
+
// IfcPerson needs an identification or a name (IdentifiablePerson rule).
|
|
221
|
+
const person = step.add('IFCPERSON', 'Pascal user', input.author?.trim() || null, null, null, null, null, null, null);
|
|
222
|
+
const organization = step.add('IFCORGANIZATION', null, input.organization?.trim() || 'Pascal', null, null, null);
|
|
223
|
+
const personOrg = step.add('IFCPERSONANDORGANIZATION', person, organization, null);
|
|
224
|
+
const application = step.add('IFCAPPLICATION', organization, '1.0', 'Pascal Editor', 'Pascal Editor');
|
|
225
|
+
model.ownerHistory = step.add('IFCOWNERHISTORY', personOrg, application, null, enumValue('NOCHANGE'), null, null, null, int(Math.floor(timestamp.getTime() / 1000)));
|
|
226
|
+
const ownerHistory = model.ownerHistory;
|
|
227
|
+
const units = step.add('IFCUNITASSIGNMENT', [
|
|
228
|
+
step.add('IFCSIUNIT', DERIVED, enumValue('LENGTHUNIT'), null, enumValue('METRE')),
|
|
229
|
+
step.add('IFCSIUNIT', DERIVED, enumValue('AREAUNIT'), null, enumValue('SQUARE_METRE')),
|
|
230
|
+
step.add('IFCSIUNIT', DERIVED, enumValue('VOLUMEUNIT'), null, enumValue('CUBIC_METRE')),
|
|
231
|
+
step.add('IFCSIUNIT', DERIVED, enumValue('PLANEANGLEUNIT'), null, enumValue('RADIAN')),
|
|
232
|
+
]);
|
|
233
|
+
const modelContext = step.add('IFCGEOMETRICREPRESENTATIONCONTEXT', null, 'Model', int(3), 1e-5, model.axis2Placement3D(IDENTITY_FRAME), model.direction([0, 1]));
|
|
234
|
+
model.bodyContext = step.add('IFCGEOMETRICREPRESENTATIONSUBCONTEXT', 'Body', 'Model', DERIVED, DERIVED, DERIVED, DERIVED, modelContext, null, enumValue('MODEL_VIEW'), null);
|
|
235
|
+
model.axisContext = step.add('IFCGEOMETRICREPRESENTATIONSUBCONTEXT', 'Axis', 'Model', DERIVED, DERIVED, DERIVED, DERIVED, modelContext, null, enumValue('GRAPH_VIEW'), null);
|
|
236
|
+
const project = step.add('IFCPROJECT', model.guid(`project:${projectSeed}`), ownerHistory, projectName, null, null, null, null, [modelContext], units);
|
|
237
|
+
const productsByNodeId = new Map();
|
|
238
|
+
const pascalIdentity = (node, refs) => {
|
|
239
|
+
if (node.type !== 'unit') {
|
|
240
|
+
for (const ref of refs)
|
|
241
|
+
pushTo(productsByNodeId, node.id, ref);
|
|
242
|
+
}
|
|
243
|
+
model.propertySet(node.id, 'Pascal', [
|
|
244
|
+
['NodeId', identifier(node.id)],
|
|
245
|
+
['NodeType', label(node.type)],
|
|
246
|
+
], refs);
|
|
247
|
+
};
|
|
248
|
+
const nodeName = (node, fallback) => {
|
|
249
|
+
const name = node.name;
|
|
250
|
+
return typeof name === 'string' && name.trim() ? name : fallback;
|
|
251
|
+
};
|
|
252
|
+
const elementCounts = {};
|
|
253
|
+
const countElement = (entity) => {
|
|
254
|
+
elementCounts[entity] = (elementCounts[entity] ?? 0) + 1;
|
|
255
|
+
};
|
|
256
|
+
// ── Spatial structure ────────────────────────────────────────────────
|
|
257
|
+
const siteContexts = new Map();
|
|
258
|
+
const siteNodes = sites.length > 0 ? sites : [undefined];
|
|
259
|
+
for (const site of siteNodes) {
|
|
260
|
+
const seed = site?.id ?? `site:${projectSeed}`;
|
|
261
|
+
const placement = model.localPlacement(null, IDENTITY_FRAME);
|
|
262
|
+
const ref = step.add('IFCSITE', model.guid(seed, site?.metadata?.globalId), ownerHistory, site ? nodeName(site, 'Site') : 'Site', null, null, placement, null, null, enumValue('ELEMENT'), null, null, null, null, null);
|
|
263
|
+
if (site)
|
|
264
|
+
pascalIdentity(site, [ref]);
|
|
265
|
+
siteContexts.set(seed, {
|
|
266
|
+
seed,
|
|
267
|
+
ref,
|
|
268
|
+
placement,
|
|
269
|
+
worldToLocal: (p) => localToIfc(p),
|
|
270
|
+
contained: [],
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const defaultSite = siteContexts.values().next().value;
|
|
274
|
+
model.rel('IFCRELAGGREGATES', `project-sites:${projectSeed}`, project, [
|
|
275
|
+
...[...siteContexts.values()].map((context) => context.ref),
|
|
276
|
+
]);
|
|
277
|
+
const elevations = getLevelElevations(nodes);
|
|
278
|
+
const buildingContexts = new Map();
|
|
279
|
+
const levelsByBuilding = new Map();
|
|
280
|
+
for (const level of levels) {
|
|
281
|
+
const buildingId = elevations.get(level.id)?.buildingId ?? level.parentId;
|
|
282
|
+
const key = buildingId && buildings.some((building) => building.id === buildingId)
|
|
283
|
+
? buildingId
|
|
284
|
+
: `building:${projectSeed}`;
|
|
285
|
+
pushTo(levelsByBuilding, key, level);
|
|
286
|
+
}
|
|
287
|
+
const buildingEntries = buildings.map((building) => ({ key: building.id, node: building }));
|
|
288
|
+
if (levelsByBuilding.has(`building:${projectSeed}`) || buildingEntries.length === 0) {
|
|
289
|
+
buildingEntries.push({ key: `building:${projectSeed}` });
|
|
290
|
+
}
|
|
291
|
+
const buildingsBySite = new Map();
|
|
292
|
+
for (const { key, node } of buildingEntries) {
|
|
293
|
+
const site = (node?.parentId ? siteContexts.get(node.parentId) : undefined) ??
|
|
294
|
+
(node ? siteContexts.get(ancestorOfType(node, 'site')?.id ?? '') : undefined) ??
|
|
295
|
+
defaultSite;
|
|
296
|
+
const transform = {
|
|
297
|
+
position: node ? [node.position[0], node.position[1], node.position[2]] : [0, 0, 0],
|
|
298
|
+
yaw: node ? (node.rotation[1] ?? 0) : 0,
|
|
299
|
+
};
|
|
300
|
+
const frame = { origin: localToIfc(transform.position), angle: transform.yaw };
|
|
301
|
+
const placement = model.localPlacement(site.placement, frame);
|
|
302
|
+
const ref = step.add('IFCBUILDING', model.guid(key, node?.metadata?.globalId), ownerHistory, node ? nodeName(node, 'Building') : 'Building', null, null, placement, null, null, enumValue('ELEMENT'), null, null, null);
|
|
303
|
+
if (node)
|
|
304
|
+
pascalIdentity(node, [ref]);
|
|
305
|
+
pushTo(buildingsBySite, site, ref);
|
|
306
|
+
buildingContexts.set(key, {
|
|
307
|
+
seed: key,
|
|
308
|
+
ref,
|
|
309
|
+
placement,
|
|
310
|
+
transform,
|
|
311
|
+
worldToLocal: buildingWorldToLocal(transform, 0),
|
|
312
|
+
contained: [],
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
for (const [site, refs] of buildingsBySite) {
|
|
316
|
+
model.rel('IFCRELAGGREGATES', `aggregates:${site.seed}`, site.ref, refs);
|
|
317
|
+
}
|
|
318
|
+
const storeyContexts = new Map();
|
|
319
|
+
const lowestLevelByBuilding = new Map();
|
|
320
|
+
for (const [buildingKey, buildingLevels] of levelsByBuilding) {
|
|
321
|
+
const building = buildingContexts.get(buildingKey);
|
|
322
|
+
const ordered = [...buildingLevels].sort((a, b) => (elevations.get(a.id)?.baseY ?? 0) - (elevations.get(b.id)?.baseY ?? 0) ||
|
|
323
|
+
a.level - b.level);
|
|
324
|
+
if (ordered[0])
|
|
325
|
+
lowestLevelByBuilding.set(buildingKey, ordered[0].id);
|
|
326
|
+
const storeyRefs = [];
|
|
327
|
+
for (const level of ordered) {
|
|
328
|
+
const elevation = elevations.get(level.id);
|
|
329
|
+
const baseY = elevation?.baseY ?? 0;
|
|
330
|
+
const placement = model.localPlacement(building.placement, {
|
|
331
|
+
origin: [0, 0, baseY],
|
|
332
|
+
angle: 0,
|
|
333
|
+
});
|
|
334
|
+
const ref = step.add('IFCBUILDINGSTOREY', model.guid(level.id, level.metadata?.globalId), ownerHistory, nodeName(level, `Level ${level.level}`), null, null, placement, null, null, enumValue('ELEMENT'), baseY);
|
|
335
|
+
storeyRefs.push(ref);
|
|
336
|
+
pascalIdentity(level, [ref]);
|
|
337
|
+
if (elevation) {
|
|
338
|
+
model.lengthQuantities(level.id, 'Qto_BuildingStoreyBaseQuantities', [['GrossHeight', elevation.height]], [ref]);
|
|
339
|
+
}
|
|
340
|
+
storeyContexts.set(level.id, {
|
|
341
|
+
seed: level.id,
|
|
342
|
+
ref,
|
|
343
|
+
placement,
|
|
344
|
+
worldToLocal: buildingWorldToLocal(building.transform, baseY),
|
|
345
|
+
contained: [],
|
|
346
|
+
levelId: level.id,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (storeyRefs.length > 0) {
|
|
350
|
+
model.rel('IFCRELAGGREGATES', `aggregates:${buildingKey}`, building.ref, storeyRefs);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const contextFor = (node) => {
|
|
354
|
+
let current = parentOf(node);
|
|
355
|
+
for (let guard = 0; current && guard < 64; guard++) {
|
|
356
|
+
if (current.type === 'level') {
|
|
357
|
+
const storey = storeyContexts.get(current.id);
|
|
358
|
+
if (storey)
|
|
359
|
+
return storey;
|
|
360
|
+
}
|
|
361
|
+
if (current.type === 'building') {
|
|
362
|
+
const building = buildingContexts.get(current.id);
|
|
363
|
+
if (building)
|
|
364
|
+
return building;
|
|
365
|
+
}
|
|
366
|
+
if (current.type === 'site') {
|
|
367
|
+
const site = siteContexts.get(current.id);
|
|
368
|
+
if (site)
|
|
369
|
+
return site;
|
|
370
|
+
}
|
|
371
|
+
current = parentOf(current);
|
|
372
|
+
}
|
|
373
|
+
return defaultSite;
|
|
374
|
+
};
|
|
375
|
+
// ── Element helpers ──────────────────────────────────────────────────
|
|
376
|
+
const emitElement = (cls, seed, node, name, placement, representation) => {
|
|
377
|
+
const ref = step.add(cls.entity, model.guid(seed, node?.metadata?.globalId), ownerHistory, name, null, cls.objectType ?? null, placement, representation, ...cls.tail(node?.id ?? seed, node));
|
|
378
|
+
countElement(cls.entity);
|
|
379
|
+
if (node)
|
|
380
|
+
pascalIdentity(node, [ref]);
|
|
381
|
+
return ref;
|
|
382
|
+
};
|
|
383
|
+
const colorOf = (part) => part.color ? { rgb: part.color, opacity: part.opacity ?? 1 } : undefined;
|
|
384
|
+
/** Mesh parts mapped into `toLocal`'s frame, dropping parts with fewer than three vertices. */
|
|
385
|
+
const localTriangleSets = (parts, toLocal) => parts.flatMap((part) => {
|
|
386
|
+
const count = Math.floor(part.positions.length / 3);
|
|
387
|
+
if (count < 3)
|
|
388
|
+
return [];
|
|
389
|
+
const positions = new Float64Array(count * 3);
|
|
390
|
+
for (let i = 0; i < count; i++) {
|
|
391
|
+
const local = toLocal([
|
|
392
|
+
part.positions[i * 3],
|
|
393
|
+
part.positions[i * 3 + 1],
|
|
394
|
+
part.positions[i * 3 + 2],
|
|
395
|
+
]);
|
|
396
|
+
positions[i * 3] = local[0];
|
|
397
|
+
positions[i * 3 + 1] = local[1];
|
|
398
|
+
positions[i * 3 + 2] = local[2];
|
|
399
|
+
}
|
|
400
|
+
return [{ positions, indices: part.indices ?? [], color: colorOf(part) }];
|
|
401
|
+
});
|
|
402
|
+
/** Mesh parts for a node: rendered geometry, else an imported mesh's own primitives. */
|
|
403
|
+
const partsFor = (node, context) => {
|
|
404
|
+
const rendered = meshes.get(node.id);
|
|
405
|
+
if (rendered && rendered.length > 0)
|
|
406
|
+
return localTriangleSets(rendered, context.worldToLocal);
|
|
407
|
+
if (node.type !== 'imported-mesh')
|
|
408
|
+
return [];
|
|
409
|
+
const imported = node;
|
|
410
|
+
const rotate = eulerXYZ(imported.rotation ?? [0, 0, 0]);
|
|
411
|
+
const [px, py, pz] = imported.position ?? [0, 0, 0];
|
|
412
|
+
return localTriangleSets(imported.primitives.map((primitive) => ({
|
|
413
|
+
positions: primitive.positions,
|
|
414
|
+
indices: primitive.indices,
|
|
415
|
+
color: hexToRgb(primitive.color),
|
|
416
|
+
opacity: primitive.opacity,
|
|
417
|
+
})), (p) => {
|
|
418
|
+
const r = rotate(p);
|
|
419
|
+
return localToIfc([r[0] + px, r[1] + py, r[2] + pz]);
|
|
420
|
+
});
|
|
421
|
+
};
|
|
422
|
+
/** Re-base triangle sets on their footprint centre so each element's origin sits at its geometry. */
|
|
423
|
+
const recentre = (sets) => {
|
|
424
|
+
let minX = Infinity;
|
|
425
|
+
let minY = Infinity;
|
|
426
|
+
let minZ = Infinity;
|
|
427
|
+
let maxX = -Infinity;
|
|
428
|
+
let maxY = -Infinity;
|
|
429
|
+
for (const set of sets) {
|
|
430
|
+
for (let i = 0; i + 2 < set.positions.length; i += 3) {
|
|
431
|
+
minX = Math.min(minX, set.positions[i]);
|
|
432
|
+
maxX = Math.max(maxX, set.positions[i]);
|
|
433
|
+
minY = Math.min(minY, set.positions[i + 1]);
|
|
434
|
+
maxY = Math.max(maxY, set.positions[i + 1]);
|
|
435
|
+
minZ = Math.min(minZ, set.positions[i + 2]);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const origin = [(minX + maxX) / 2, (minY + maxY) / 2, minZ];
|
|
439
|
+
return {
|
|
440
|
+
frame: { origin, angle: 0 },
|
|
441
|
+
sets: sets.map((set) => {
|
|
442
|
+
const positions = Float64Array.from(set.positions);
|
|
443
|
+
for (let i = 0; i + 2 < positions.length; i += 3) {
|
|
444
|
+
positions[i] = positions[i] - origin[0];
|
|
445
|
+
positions[i + 1] = positions[i + 1] - origin[1];
|
|
446
|
+
positions[i + 2] = positions[i + 2] - origin[2];
|
|
447
|
+
}
|
|
448
|
+
return { ...set, positions };
|
|
449
|
+
}),
|
|
450
|
+
};
|
|
451
|
+
};
|
|
452
|
+
const tessellatedShape = (sets) => {
|
|
453
|
+
const items = sets.flatMap((set) => {
|
|
454
|
+
const item = model.triangulatedFaceSet(set);
|
|
455
|
+
return item ? [item] : [];
|
|
456
|
+
});
|
|
457
|
+
return items.length > 0 ? model.shape([model.bodyRepresentation('Tessellation', items)]) : null;
|
|
458
|
+
};
|
|
459
|
+
/** Emit a tessellated element placed in `relativeTo`; returns null if it has no triangles. */
|
|
460
|
+
const emitMeshElement = (cls, node, sets, relativeTo, seed = node.id) => {
|
|
461
|
+
if (sets.length === 0)
|
|
462
|
+
return null;
|
|
463
|
+
const { frame, sets: local } = recentre(sets);
|
|
464
|
+
const shape = tessellatedShape(local);
|
|
465
|
+
if (!shape)
|
|
466
|
+
return null;
|
|
467
|
+
return emitElement(cls, seed, seed === node.id ? node : { ...node, metadata: { ...node.metadata, globalId: undefined } }, nodeName(node, cls.entity), model.localPlacement(relativeTo, frame), shape);
|
|
468
|
+
};
|
|
469
|
+
/**
|
|
470
|
+
* Plan area of `outer` minus its cutouts, as IFC plan regions. Pascal
|
|
471
|
+
* cutouts may cross the boundary or overlap one another; IFC inner curves
|
|
472
|
+
* must be enclosed and disjoint, so the area is normalised with booleans.
|
|
473
|
+
*/
|
|
474
|
+
const areaRegions = (outer, holes) => {
|
|
475
|
+
const toIfc = (ring) => cleanRing(ring.map(([x, z]) => planToIfc(x, z)));
|
|
476
|
+
const cutouts = holes.filter((hole) => hole.length >= 3);
|
|
477
|
+
if (cutouts.length === 0) {
|
|
478
|
+
const ring = toIfc(outer);
|
|
479
|
+
return ring.length > 0 ? [{ outer: ring, holes: [] }] : [];
|
|
480
|
+
}
|
|
481
|
+
const asRing = (ring) => ring.map(([x, z]) => [x, z]);
|
|
482
|
+
return difference(asRing(outer), union(cutouts.map(asRing))).flatMap((region) => {
|
|
483
|
+
const ring = toIfc(region.outer);
|
|
484
|
+
if (ring.length === 0)
|
|
485
|
+
return [];
|
|
486
|
+
return [{ outer: ring, holes: region.holes.map(toIfc).filter((hole) => hole.length > 0) }];
|
|
487
|
+
});
|
|
488
|
+
};
|
|
489
|
+
const regionSolids = (regions, bottom, depth) => regions.map((region) => model.extrusion(model.polygonProfile(region.outer, region.holes), bottom, depth));
|
|
490
|
+
const hostWallIsExternal = (wall) => {
|
|
491
|
+
if (!wall)
|
|
492
|
+
return undefined;
|
|
493
|
+
const sides = [wall.frontSide, wall.backSide];
|
|
494
|
+
if (sides.includes('exterior'))
|
|
495
|
+
return true;
|
|
496
|
+
if (sides.every((side) => side === 'interior'))
|
|
497
|
+
return false;
|
|
498
|
+
return undefined;
|
|
499
|
+
};
|
|
500
|
+
const spaceByZoneId = new Map();
|
|
501
|
+
const handled = new Set();
|
|
502
|
+
// ── Walls, openings, doors, windows ──────────────────────────────────
|
|
503
|
+
const openingsByWall = new Map();
|
|
504
|
+
for (const opening of [...ofType('door'), ...ofType('window')]) {
|
|
505
|
+
if (isExcluded(opening))
|
|
506
|
+
continue;
|
|
507
|
+
if (opening.roofSegmentId)
|
|
508
|
+
continue;
|
|
509
|
+
const hostId = opening.wallId ?? opening.parentId;
|
|
510
|
+
const host = hostId ? nodes[hostId] : undefined;
|
|
511
|
+
if (host?.type !== 'wall' || isExcluded(host))
|
|
512
|
+
continue;
|
|
513
|
+
pushTo(openingsByWall, host.id, opening);
|
|
514
|
+
}
|
|
515
|
+
const layerUsages = new Map();
|
|
516
|
+
const wallMaterial = step.add('IFCMATERIAL', 'Wall', null, null);
|
|
517
|
+
const wallsByLevel = new Map();
|
|
518
|
+
for (const wall of ofType('wall'))
|
|
519
|
+
if (wall.parentId)
|
|
520
|
+
pushTo(wallsByLevel, wall.parentId, wall);
|
|
521
|
+
const slabsByLevel = new Map();
|
|
522
|
+
for (const slab of ofType('slab'))
|
|
523
|
+
if (slab.parentId)
|
|
524
|
+
pushTo(slabsByLevel, slab.parentId, slab);
|
|
525
|
+
for (const [levelId, storey] of storeyContexts) {
|
|
526
|
+
const levelWalls = wallsByLevel.get(levelId) ?? [];
|
|
527
|
+
if (levelWalls.length === 0)
|
|
528
|
+
continue;
|
|
529
|
+
const miters = calculateLevelMiters(levelWalls);
|
|
530
|
+
for (const wall of levelWalls) {
|
|
531
|
+
if (isExcluded(wall))
|
|
532
|
+
continue;
|
|
533
|
+
handled.add(wall.id);
|
|
534
|
+
const startPlan = planToIfc(wall.start[0], wall.start[1]);
|
|
535
|
+
const endPlan = planToIfc(wall.end[0], wall.end[1]);
|
|
536
|
+
const chord = Math.hypot(endPlan[0] - startPlan[0], endPlan[1] - startPlan[1]);
|
|
537
|
+
const support = wallSupportForNodes(wall, nodes);
|
|
538
|
+
const base = support.elevation;
|
|
539
|
+
const top = resolveWallTop(wall, getWallPlaneTop(wall, levelId, nodes), base);
|
|
540
|
+
const cells = wallBaseCells(wall, support, top);
|
|
541
|
+
if (chord < 1e-6 || cells.every((cell) => top - cell.bottom <= 1e-4)) {
|
|
542
|
+
skipped.push({ nodeId: wall.id, type: wall.type, reason: 'degenerate' });
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const frame = {
|
|
546
|
+
origin: [startPlan[0], startPlan[1], base],
|
|
547
|
+
angle: Math.atan2(endPlan[1] - startPlan[1], endPlan[0] - startPlan[0]),
|
|
548
|
+
};
|
|
549
|
+
const toWallLocal = (x, z) => {
|
|
550
|
+
const [ix, iy] = planToIfc(x, z);
|
|
551
|
+
const local = frameToLocal(frame, [ix, iy, 0]);
|
|
552
|
+
return [local[0], local[1]];
|
|
553
|
+
};
|
|
554
|
+
const axisPoints = isCurvedWall(wall)
|
|
555
|
+
? sampleWallCenterline(wall, 24).map((p) => toWallLocal(p.x, p.y))
|
|
556
|
+
: [[0, 0], [chord, 0]];
|
|
557
|
+
const thickness = wall.thickness ?? DEFAULT_WALL_THICKNESS;
|
|
558
|
+
const faces = getWallFaceOffsets(wall);
|
|
559
|
+
const planFootprint = getWallPlanFootprint(wall, miters).map((p) => [
|
|
560
|
+
p.x,
|
|
561
|
+
p.y,
|
|
562
|
+
]);
|
|
563
|
+
let footprint = cleanRing(planFootprint.map(([x, z]) => toWallLocal(x, z)));
|
|
564
|
+
if (footprint.length === 0) {
|
|
565
|
+
footprint = [
|
|
566
|
+
[0, -faces.a],
|
|
567
|
+
[chord, -faces.a],
|
|
568
|
+
[chord, -faces.b],
|
|
569
|
+
[0, -faces.b],
|
|
570
|
+
];
|
|
571
|
+
}
|
|
572
|
+
// A stepped base (runs on different supports, or faces on different
|
|
573
|
+
// floors) becomes one prism per run, each clipped from the footprint.
|
|
574
|
+
const solids = cells.length === 1
|
|
575
|
+
? [
|
|
576
|
+
model.extrusion(model.polygonProfile(footprint), cells[0].bottom - base, top - cells[0].bottom),
|
|
577
|
+
]
|
|
578
|
+
: cells.flatMap((cell) => top - cell.bottom <= 1e-4
|
|
579
|
+
? []
|
|
580
|
+
: intersection(planFootprint, wallCellBand(wall, cell)).flatMap((region) => {
|
|
581
|
+
const ring = cleanRing(region.outer.map(([x, z]) => toWallLocal(x, z)));
|
|
582
|
+
return ring.length > 0
|
|
583
|
+
? [
|
|
584
|
+
model.extrusion(model.polygonProfile(ring), cell.bottom - base, top - cell.bottom),
|
|
585
|
+
]
|
|
586
|
+
: [];
|
|
587
|
+
}));
|
|
588
|
+
const shape = model.shape([
|
|
589
|
+
model.axisRepresentation(model.polyline2(axisPoints, false)),
|
|
590
|
+
model.bodyRepresentation('SweptSolid', solids),
|
|
591
|
+
]);
|
|
592
|
+
const wallPlacement = model.localPlacement(storey.placement, frame);
|
|
593
|
+
const wallRef = emitElement({ entity: 'IFCWALL', tail: (tag) => [tag, enumValue('STANDARD')] }, wall.id, wall, nodeName(wall, 'Wall'), wallPlacement, shape);
|
|
594
|
+
storey.contained.push(wallRef);
|
|
595
|
+
const isExternal = hostWallIsExternal(wall);
|
|
596
|
+
model.propertySet(wall.id, 'Pset_WallCommon', [['IsExternal', isExternal === undefined ? null : bool(isExternal)]], [wallRef]);
|
|
597
|
+
// Pascal's body spans [b, a] along its left normal; that normal is
|
|
598
|
+
// IFC -Y in the wall frame, so the first layer starts at -a.
|
|
599
|
+
const offset = -faces.a;
|
|
600
|
+
const usageKey = `${thickness.toFixed(6)}|${offset.toFixed(6)}`;
|
|
601
|
+
let usage = layerUsages.get(usageKey);
|
|
602
|
+
if (!usage) {
|
|
603
|
+
const layer = step.add('IFCMATERIALLAYER', wallMaterial, thickness, null, 'Core', null, null, null);
|
|
604
|
+
const layerSet = step.add('IFCMATERIALLAYERSET', [layer], `Wall ${Math.round(thickness * 1000)} mm`, null);
|
|
605
|
+
usage = {
|
|
606
|
+
usage: step.add('IFCMATERIALLAYERSETUSAGE', layerSet, enumValue('AXIS2'), enumValue('POSITIVE'), offset, null),
|
|
607
|
+
walls: [],
|
|
608
|
+
};
|
|
609
|
+
layerUsages.set(usageKey, usage);
|
|
610
|
+
}
|
|
611
|
+
usage.walls.push(wallRef);
|
|
612
|
+
const wallLength = getWallCurveLength(wall);
|
|
613
|
+
const bodyOffset = getWallBodyCenterOffset(wall);
|
|
614
|
+
for (const opening of openingsByWall.get(wall.id) ?? []) {
|
|
615
|
+
handled.add(opening.id);
|
|
616
|
+
let cut = null;
|
|
617
|
+
try {
|
|
618
|
+
cut = getOpeningWallCut(wall, opening, nodes, support);
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
cut = null;
|
|
622
|
+
}
|
|
623
|
+
const datum = cut?.datum ?? base;
|
|
624
|
+
const nominalBottom = datum + opening.position[1] - opening.height / 2;
|
|
625
|
+
const t = wallLength > 0 ? Math.max(0, Math.min(1, opening.position[0] / wallLength)) : 0;
|
|
626
|
+
const curve = getWallCurveFrameAt(wall, t);
|
|
627
|
+
const center = planToIfc(curve.point.x + curve.normal.x * bodyOffset, curve.point.y + curve.normal.y * bodyOffset);
|
|
628
|
+
const openingFrame = {
|
|
629
|
+
origin: [center[0], center[1], nominalBottom],
|
|
630
|
+
angle: Math.atan2(-curve.tangent.y, curve.tangent.x),
|
|
631
|
+
};
|
|
632
|
+
const cutBottom = (cut?.bottom ?? nominalBottom) - nominalBottom;
|
|
633
|
+
const cutDepth = (cut?.top ?? nominalBottom + opening.height) - (cut?.bottom ?? nominalBottom);
|
|
634
|
+
let openingRef = null;
|
|
635
|
+
let openingPlacement = null;
|
|
636
|
+
if (cutDepth > 1e-4 && opening.width > 1e-4) {
|
|
637
|
+
let profile;
|
|
638
|
+
const band = isCurvedWall(wall) && cut
|
|
639
|
+
? cleanRing(cut.band.map(([x, z]) => {
|
|
640
|
+
const [ix, iy] = planToIfc(x, z);
|
|
641
|
+
const local = frameToLocal(openingFrame, [ix, iy, 0]);
|
|
642
|
+
return [local[0], local[1]];
|
|
643
|
+
}))
|
|
644
|
+
: [];
|
|
645
|
+
if (band.length > 0)
|
|
646
|
+
profile = model.polygonProfile(band);
|
|
647
|
+
else
|
|
648
|
+
profile = model.rectangleProfile(opening.width, thickness + 0.2);
|
|
649
|
+
openingPlacement = model.localPlacement(wallPlacement, relativeFrame(frame, openingFrame));
|
|
650
|
+
openingRef = step.add('IFCOPENINGELEMENT', model.guid(`${opening.id}:opening`), ownerHistory, `${nodeName(opening, opening.type === 'door' ? 'Door' : 'Window')} opening`, null, null, openingPlacement, model.shape([
|
|
651
|
+
model.bodyRepresentation('SweptSolid', [
|
|
652
|
+
model.extrusion(profile, cutBottom, cutDepth),
|
|
653
|
+
]),
|
|
654
|
+
]), null, enumValue('OPENING'));
|
|
655
|
+
model.rel('IFCRELVOIDSELEMENT', `${opening.id}:voids`, wallRef, openingRef);
|
|
656
|
+
}
|
|
657
|
+
const rendered = meshes.get(opening.id);
|
|
658
|
+
const bodySets = rendered?.length
|
|
659
|
+
? localTriangleSets(rendered, (p) => frameToLocal(openingFrame, storey.worldToLocal(p)))
|
|
660
|
+
: [];
|
|
661
|
+
let body = tessellatedShape(bodySets);
|
|
662
|
+
if (!body) {
|
|
663
|
+
const panel = Math.min(0.05, thickness);
|
|
664
|
+
body = model.shape([
|
|
665
|
+
model.bodyRepresentation('SweptSolid', [
|
|
666
|
+
model.extrusion(model.rectangleProfile(opening.width, panel), 0, opening.height, opening.type === 'window' ? { rgb: [0.62, 0.8, 0.9], opacity: 0.4 } : undefined),
|
|
667
|
+
]),
|
|
668
|
+
]);
|
|
669
|
+
}
|
|
670
|
+
const elementPlacement = openingPlacement
|
|
671
|
+
? model.localPlacement(openingPlacement, IDENTITY_FRAME)
|
|
672
|
+
: model.localPlacement(storey.placement, openingFrame);
|
|
673
|
+
const fill = emitElement(meshClassFor(opening), opening.id, opening, nodeName(opening, opening.type === 'door' ? 'Door' : 'Window'), elementPlacement, body);
|
|
674
|
+
storey.contained.push(fill);
|
|
675
|
+
if (openingRef) {
|
|
676
|
+
model.rel('IFCRELFILLSELEMENT', `${opening.id}:fills`, openingRef, fill);
|
|
677
|
+
}
|
|
678
|
+
const external = isExternal === undefined ? null : bool(isExternal);
|
|
679
|
+
if (opening.type === 'door') {
|
|
680
|
+
const glazing = doorGlazingFraction(opening);
|
|
681
|
+
model.propertySet(opening.id, 'Pset_DoorCommon', [
|
|
682
|
+
['IsExternal', external],
|
|
683
|
+
['GlazingAreaFraction', glazing > 0 ? ratio(glazing) : null],
|
|
684
|
+
], [fill]);
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
model.propertySet(opening.id, 'Pset_WindowCommon', [['IsExternal', external]], [fill]);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
for (const [key, { usage, walls }] of layerUsages) {
|
|
693
|
+
model.rel('IFCRELASSOCIATESMATERIAL', `material:${key}:${projectSeed}`, walls, usage);
|
|
694
|
+
}
|
|
695
|
+
// ── Slabs ────────────────────────────────────────────────────────────
|
|
696
|
+
const polygonContexts = new Map();
|
|
697
|
+
const slabPolygonContext = (levelId) => {
|
|
698
|
+
const key = levelId ?? '';
|
|
699
|
+
let context = polygonContexts.get(key);
|
|
700
|
+
if (!context) {
|
|
701
|
+
context = prepareSlabPolygonContext({
|
|
702
|
+
walls: levelId ? (wallsByLevel.get(levelId) ?? []) : [],
|
|
703
|
+
siblingSlabs: levelId ? (slabsByLevel.get(levelId) ?? []) : [],
|
|
704
|
+
});
|
|
705
|
+
polygonContexts.set(key, context);
|
|
706
|
+
}
|
|
707
|
+
return context;
|
|
708
|
+
};
|
|
709
|
+
for (const slab of ofType('slab')) {
|
|
710
|
+
if (isExcluded(slab))
|
|
711
|
+
continue;
|
|
712
|
+
const context = contextFor(slab);
|
|
713
|
+
handled.add(slab.id);
|
|
714
|
+
// Same body the slab renderer builds: construction-lifted manual slabs,
|
|
715
|
+
// wall-face-adopted outline.
|
|
716
|
+
const body = liftedManualSlab(nodes, slab);
|
|
717
|
+
const polygon = getRenderableSlabPolygon(body, slabPolygonContext(slab.parentId));
|
|
718
|
+
const regions = areaRegions(polygon, slab.holes ?? []);
|
|
719
|
+
const thickness = body.thickness ?? 0.05;
|
|
720
|
+
const top = body.elevation ?? 0.05;
|
|
721
|
+
const rendered = partsFor(slab, context);
|
|
722
|
+
// Pools are open shells, legacy zero-thickness slabs have no valid
|
|
723
|
+
// extrusion, and terrain fill, foundations or platform fill reach below
|
|
724
|
+
// the [top − thickness, top] band: all keep their rendered shape.
|
|
725
|
+
if (slab.recessed ||
|
|
726
|
+
thickness <= 1e-4 ||
|
|
727
|
+
regions.length === 0 ||
|
|
728
|
+
!withinBand(rendered, top - thickness, top)) {
|
|
729
|
+
const ref = emitMeshElement(meshClassFor(slab), slab, rendered, context.placement);
|
|
730
|
+
if (ref)
|
|
731
|
+
context.contained.push(ref);
|
|
732
|
+
else {
|
|
733
|
+
skipped.push({
|
|
734
|
+
nodeId: slab.id,
|
|
735
|
+
type: slab.type,
|
|
736
|
+
reason: slab.recessed ? 'no-geometry' : 'degenerate',
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const levelId = context.levelId;
|
|
742
|
+
const isGround = slab.plateRole === 'base' &&
|
|
743
|
+
levelId !== undefined &&
|
|
744
|
+
[...lowestLevelByBuilding.values()].includes(levelId);
|
|
745
|
+
const ref = emitElement({ entity: 'IFCSLAB', tail: (tag) => [tag, enumValue(isGround ? 'BASESLAB' : 'FLOOR')] }, slab.id, slab, nodeName(slab, 'Slab'), model.localPlacement(context.placement, { origin: [0, 0, top], angle: 0 }), model.shape([
|
|
746
|
+
model.bodyRepresentation('SweptSolid', regionSolids(regions, -thickness, thickness)),
|
|
747
|
+
]));
|
|
748
|
+
context.contained.push(ref);
|
|
749
|
+
}
|
|
750
|
+
// ── Spaces (rooms) ───────────────────────────────────────────────────
|
|
751
|
+
const ceilings = ofType('ceiling');
|
|
752
|
+
const ceilingByZone = new Map();
|
|
753
|
+
for (const ceiling of ceilings) {
|
|
754
|
+
if (ceiling.zoneId && !ceilingByZone.has(ceiling.zoneId))
|
|
755
|
+
ceilingByZone.set(ceiling.zoneId, ceiling);
|
|
756
|
+
}
|
|
757
|
+
const plateTopByZone = new Map();
|
|
758
|
+
for (const slab of ofType('slab')) {
|
|
759
|
+
if (slab.recessed)
|
|
760
|
+
continue;
|
|
761
|
+
for (const zoneId of slab.zoneIds ?? []) {
|
|
762
|
+
const top = slab.elevation ?? 0;
|
|
763
|
+
plateTopByZone.set(zoneId, Math.max(top, plateTopByZone.get(zoneId) ?? -Infinity));
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const spacesByStorey = new Map();
|
|
767
|
+
for (const zone of ofType('zone')) {
|
|
768
|
+
if (isExcluded(zone))
|
|
769
|
+
continue;
|
|
770
|
+
const context = contextFor(zone);
|
|
771
|
+
if (!context.levelId) {
|
|
772
|
+
skipped.push({ nodeId: zone.id, type: zone.type, reason: 'no-level' });
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
const regions = areaRegions(zone.polygon, zone.holes ?? []);
|
|
776
|
+
if (regions.length === 0) {
|
|
777
|
+
skipped.push({ nodeId: zone.id, type: zone.type, reason: 'degenerate' });
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
const plateTop = plateTopByZone.get(zone.id);
|
|
781
|
+
const floor = zone.floor?.elevation ?? plateTop ?? 0;
|
|
782
|
+
const ceiling = ceilingByZone.get(zone.id);
|
|
783
|
+
const clearHeight = ceiling
|
|
784
|
+
? resolveCeilingHeight(ceiling, nodes) - floor
|
|
785
|
+
: zone.ceilingHeight;
|
|
786
|
+
const height = Math.max(0.1, clearHeight);
|
|
787
|
+
const roomNumber = zone.roomNumber?.trim() ?? '';
|
|
788
|
+
const ref = step.add('IFCSPACE', model.guid(zone.id, zone.metadata?.globalId), ownerHistory, roomNumber || zone.name, null, null, model.localPlacement(context.placement, { origin: [0, 0, floor], angle: 0 }), model.shape([model.bodyRepresentation('SweptSolid', regionSolids(regions, 0, height))]), zone.name, enumValue('ELEMENT'), enumValue(zone.spaceRole === 'room' ? 'SPACE' : 'NOTDEFINED'), null);
|
|
789
|
+
countElement('IFCSPACE');
|
|
790
|
+
pascalIdentity(zone, [ref]);
|
|
791
|
+
spaceByZoneId.set(zone.id, ref);
|
|
792
|
+
pushTo(spacesByStorey, context, ref);
|
|
793
|
+
}
|
|
794
|
+
for (const [storey, spaces] of spacesByStorey) {
|
|
795
|
+
model.rel('IFCRELAGGREGATES', `spaces:${storey.seed}`, storey.ref, spaces);
|
|
796
|
+
}
|
|
797
|
+
// ── Ceilings ─────────────────────────────────────────────────────────
|
|
798
|
+
for (const ceiling of ceilings) {
|
|
799
|
+
if (isExcluded(ceiling))
|
|
800
|
+
continue;
|
|
801
|
+
handled.add(ceiling.id);
|
|
802
|
+
const context = contextFor(ceiling);
|
|
803
|
+
const regions = areaRegions(ceiling.polygon, ceiling.holes ?? []);
|
|
804
|
+
if (regions.length === 0) {
|
|
805
|
+
skipped.push({ nodeId: ceiling.id, type: ceiling.type, reason: 'degenerate' });
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
const height = resolveCeilingHeight(ceiling, nodes);
|
|
809
|
+
const ref = emitElement({ entity: 'IFCCOVERING', tail: (tag) => [tag, enumValue('CEILING')] }, ceiling.id, ceiling, nodeName(ceiling, 'Ceiling'), model.localPlacement(context.placement, { origin: [0, 0, height], angle: 0 }), model.shape([
|
|
810
|
+
// The ceiling surface is the underside; the covering bound keeps a
|
|
811
|
+
// 1 cm margin below the next floor, which this thickness fills.
|
|
812
|
+
model.bodyRepresentation('SweptSolid', regionSolids(regions, 0, CEILING_THICKNESS)),
|
|
813
|
+
]));
|
|
814
|
+
context.contained.push(ref);
|
|
815
|
+
const space = ceiling.zoneId ? spaceByZoneId.get(ceiling.zoneId) : undefined;
|
|
816
|
+
if (space)
|
|
817
|
+
model.rel('IFCRELCOVERSSPACES', `${ceiling.id}:covers`, space, [ref]);
|
|
818
|
+
}
|
|
819
|
+
// ── Columns ──────────────────────────────────────────────────────────
|
|
820
|
+
for (const column of ofType('column')) {
|
|
821
|
+
if (isExcluded(column))
|
|
822
|
+
continue;
|
|
823
|
+
handled.add(column.id);
|
|
824
|
+
const context = contextFor(column);
|
|
825
|
+
const sets = partsFor(column, context);
|
|
826
|
+
const cls = meshClassFor(column);
|
|
827
|
+
if (column.source && sets.length === 0) {
|
|
828
|
+
skipped.push({ nodeId: column.id, type: column.type, reason: 'no-geometry' });
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
if ((column.source || !isPlainColumn(column)) && sets.length > 0) {
|
|
832
|
+
const ref = emitMeshElement(cls, column, sets, context.placement);
|
|
833
|
+
if (ref)
|
|
834
|
+
context.contained.push(ref);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
let baseZ = column.position[1];
|
|
838
|
+
if (sets.length > 0) {
|
|
839
|
+
baseZ = Infinity;
|
|
840
|
+
for (const set of sets) {
|
|
841
|
+
for (let i = 2; i < set.positions.length; i += 3) {
|
|
842
|
+
baseZ = Math.min(baseZ, set.positions[i]);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const [x, y] = planToIfc(column.position[0], column.position[2]);
|
|
847
|
+
const profile = column.crossSection === 'rectangular' || column.crossSection === 'square'
|
|
848
|
+
? model.rectangleProfile(column.width, column.crossSection === 'square' ? column.width : column.depth)
|
|
849
|
+
: model.circleProfile(column.radius);
|
|
850
|
+
const ref = emitElement(cls, column.id, column, nodeName(column, 'Column'), model.localPlacement(context.placement, {
|
|
851
|
+
origin: [x, y, baseZ],
|
|
852
|
+
angle: column.rotation ?? 0,
|
|
853
|
+
}), model.shape([
|
|
854
|
+
model.bodyRepresentation('SweptSolid', [model.extrusion(profile, 0, column.height)]),
|
|
855
|
+
]));
|
|
856
|
+
context.contained.push(ref);
|
|
857
|
+
}
|
|
858
|
+
// ── Everything else: tessellated elements ────────────────────────────
|
|
859
|
+
const hasOwnGeometry = (node) => (meshes.get(node.id)?.length ?? 0) > 0 ||
|
|
860
|
+
(node.type === 'imported-mesh' && node.primitives.length > 0);
|
|
861
|
+
const geometryInSubtree = new Set();
|
|
862
|
+
for (const node of sortedNodes) {
|
|
863
|
+
if (!hasOwnGeometry(node))
|
|
864
|
+
continue;
|
|
865
|
+
let current = node;
|
|
866
|
+
for (let guard = 0; current && guard < 64; guard++) {
|
|
867
|
+
if (geometryInSubtree.has(current.id))
|
|
868
|
+
break;
|
|
869
|
+
geometryInSubtree.add(current.id);
|
|
870
|
+
current = parentOf(current);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
const partIndex = new Map();
|
|
874
|
+
const aggregateParts = (container, containerClass, partType, partClass) => {
|
|
875
|
+
const context = contextFor(container);
|
|
876
|
+
let partsByContainer = partIndex.get(partType);
|
|
877
|
+
if (!partsByContainer) {
|
|
878
|
+
partsByContainer = new Map();
|
|
879
|
+
for (const part of ofType(partType)) {
|
|
880
|
+
const owner = ancestorOfType(part, container.type);
|
|
881
|
+
if (owner && !isExcluded(part))
|
|
882
|
+
pushTo(partsByContainer, owner.id, part);
|
|
883
|
+
}
|
|
884
|
+
partIndex.set(partType, partsByContainer);
|
|
885
|
+
}
|
|
886
|
+
const members = [container, ...(partsByContainer.get(container.id) ?? [])];
|
|
887
|
+
const parts = [];
|
|
888
|
+
const placement = model.localPlacement(context.placement, IDENTITY_FRAME);
|
|
889
|
+
for (const member of members) {
|
|
890
|
+
if (member !== container)
|
|
891
|
+
handled.add(member.id);
|
|
892
|
+
const ref = emitMeshElement(partClass, member, partsFor(member, context), placement);
|
|
893
|
+
if (ref)
|
|
894
|
+
parts.push(ref);
|
|
895
|
+
}
|
|
896
|
+
if (parts.length === 0) {
|
|
897
|
+
skipped.push({ nodeId: container.id, type: container.type, reason: 'no-geometry' });
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
const ref = step.add(containerClass.entity, model.guid(`${container.id}:assembly`, container.metadata?.globalId), ownerHistory, nodeName(container, containerClass.entity), null, null, placement, null, ...containerClass.tail(container.id, undefined));
|
|
901
|
+
countElement(containerClass.entity);
|
|
902
|
+
pascalIdentity(container, [ref]);
|
|
903
|
+
model.rel('IFCRELAGGREGATES', `${container.id}:parts`, ref, parts);
|
|
904
|
+
context.contained.push(ref);
|
|
905
|
+
};
|
|
906
|
+
for (const roof of ofType('roof')) {
|
|
907
|
+
if (isExcluded(roof))
|
|
908
|
+
continue;
|
|
909
|
+
handled.add(roof.id);
|
|
910
|
+
aggregateParts(roof, { entity: 'IFCROOF', tail: (tag) => [tag, enumValue('NOTDEFINED')] }, 'roof-segment', ROOF_SLAB);
|
|
911
|
+
}
|
|
912
|
+
for (const stairNode of ofType('stair')) {
|
|
913
|
+
if (isExcluded(stairNode))
|
|
914
|
+
continue;
|
|
915
|
+
const stair = stairNode;
|
|
916
|
+
handled.add(stair.id);
|
|
917
|
+
const context = contextFor(stair);
|
|
918
|
+
const placement = model.localPlacement(context.placement, IDENTITY_FRAME);
|
|
919
|
+
const children = ofType('stair-segment').filter((child) => ancestorOfType(child, 'stair')?.id === stair.id);
|
|
920
|
+
const measurement = Array.isArray(stair.position) && Array.isArray(stair.children)
|
|
921
|
+
? measureStair(stair, nodes)
|
|
922
|
+
: null;
|
|
923
|
+
const refs = [];
|
|
924
|
+
for (const member of [stair, ...children]) {
|
|
925
|
+
handled.add(member.id);
|
|
926
|
+
if (isExcluded(member))
|
|
927
|
+
continue;
|
|
928
|
+
const groups = new Map();
|
|
929
|
+
for (const part of meshes.get(member.id) ?? []) {
|
|
930
|
+
const role = part.role === 'railing' || part.role === 'handrail'
|
|
931
|
+
? part.role
|
|
932
|
+
: part.role === 'landing' ||
|
|
933
|
+
(member.type === 'stair-segment' &&
|
|
934
|
+
member.segmentType === 'landing')
|
|
935
|
+
? 'landing'
|
|
936
|
+
: 'flight';
|
|
937
|
+
pushTo(groups, role, part);
|
|
938
|
+
}
|
|
939
|
+
for (const [role, parts] of groups) {
|
|
940
|
+
const cls = role === 'landing'
|
|
941
|
+
? { entity: 'IFCSLAB', tail: (tag) => [tag, enumValue('LANDING')] }
|
|
942
|
+
: role === 'railing' || role === 'handrail'
|
|
943
|
+
? {
|
|
944
|
+
entity: 'IFCRAILING',
|
|
945
|
+
tail: (tag) => [
|
|
946
|
+
tag,
|
|
947
|
+
enumValue(role === 'handrail' ? 'HANDRAIL' : 'GUARDRAIL'),
|
|
948
|
+
],
|
|
949
|
+
}
|
|
950
|
+
: {
|
|
951
|
+
...STAIR_FLIGHT,
|
|
952
|
+
tail: (tag) => [
|
|
953
|
+
tag,
|
|
954
|
+
null,
|
|
955
|
+
null,
|
|
956
|
+
null,
|
|
957
|
+
null,
|
|
958
|
+
enumValue(member.type === 'stair-segment' && member.winder
|
|
959
|
+
? 'WINDER'
|
|
960
|
+
: stair.stairType === 'straight'
|
|
961
|
+
? 'STRAIGHT'
|
|
962
|
+
: stair.stairType === 'spiral'
|
|
963
|
+
? 'SPIRAL'
|
|
964
|
+
: 'CURVED'),
|
|
965
|
+
],
|
|
966
|
+
};
|
|
967
|
+
const seed = member !== stair && (role === 'flight' || role === 'landing')
|
|
968
|
+
? member.id
|
|
969
|
+
: `${member.id}:${role}`;
|
|
970
|
+
const ref = emitMeshElement(cls, member, localTriangleSets(parts, context.worldToLocal), placement, seed);
|
|
971
|
+
if (!ref)
|
|
972
|
+
continue;
|
|
973
|
+
refs.push(ref);
|
|
974
|
+
if (role !== 'flight')
|
|
975
|
+
continue;
|
|
976
|
+
const flight = measurement?.flights.find((entry) => entry.nodeId === member.id);
|
|
977
|
+
if (!flight || flight.riserHeight === null || flight.going === null)
|
|
978
|
+
continue;
|
|
979
|
+
const properties = [
|
|
980
|
+
['NumberOfRiser', typed('IFCCOUNTMEASURE', flight.count)],
|
|
981
|
+
['NumberOfTreads', typed('IFCCOUNTMEASURE', flight.count)],
|
|
982
|
+
['RiserHeight', typed('IFCPOSITIVELENGTHMEASURE', flight.riserHeight)],
|
|
983
|
+
['TreadLength', typed('IFCPOSITIVELENGTHMEASURE', flight.going)],
|
|
984
|
+
['NosingLength', typed('IFCLENGTHMEASURE', flight.construction?.nosing ?? 0)],
|
|
985
|
+
[
|
|
986
|
+
'WalkingLineOffset',
|
|
987
|
+
typed('IFCPOSITIVELENGTHMEASURE', member.type === 'stair-segment'
|
|
988
|
+
? (member.winder?.walkingLineOffset ?? flight.width / 2)
|
|
989
|
+
: flight.width / 2),
|
|
990
|
+
],
|
|
991
|
+
];
|
|
992
|
+
if (flight.innerGoing !== null && flight.innerGoing > 0)
|
|
993
|
+
properties.push([
|
|
994
|
+
'TreadLengthAtInnerSide',
|
|
995
|
+
typed('IFCPOSITIVELENGTHMEASURE', flight.innerGoing),
|
|
996
|
+
]);
|
|
997
|
+
if (flight.construction?.mode === 'waist' && stair.stairType === 'straight')
|
|
998
|
+
properties.push([
|
|
999
|
+
'WaistThickness',
|
|
1000
|
+
typed('IFCPOSITIVELENGTHMEASURE', flight.construction.waistThickness),
|
|
1001
|
+
]);
|
|
1002
|
+
const headroom = measurement?.headroom.flightMinimum[member.id];
|
|
1003
|
+
if (measurement?.headroom.status === 'evaluated' && headroom !== undefined && headroom > 0)
|
|
1004
|
+
properties.push(['Headroom', typed('IFCPOSITIVELENGTHMEASURE', headroom)]);
|
|
1005
|
+
model.propertySet(seed, 'Pset_StairFlightCommon', properties, [ref]);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if (!refs.length) {
|
|
1009
|
+
skipped.push({ nodeId: stair.id, type: stair.type, reason: 'no-geometry' });
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
const winders = children.filter((child) => child.winder);
|
|
1013
|
+
const windingType = children.some((child) => child.segmentType === 'landing')
|
|
1014
|
+
? 'NOTDEFINED'
|
|
1015
|
+
: winders.length === 1
|
|
1016
|
+
? 'QUARTER_WINDING_STAIR'
|
|
1017
|
+
: winders.length === 2 && winders[0].winder.turn === winders[1].winder.turn
|
|
1018
|
+
? 'TWO_QUARTER_WINDING_STAIR'
|
|
1019
|
+
: 'NOTDEFINED';
|
|
1020
|
+
const ref = emitElement({
|
|
1021
|
+
entity: 'IFCSTAIR',
|
|
1022
|
+
tail: (tag) => [tag, enumValue(windingType)],
|
|
1023
|
+
}, `${stair.id}:assembly`, stair, nodeName(stair, 'IFCSTAIR'), placement, null);
|
|
1024
|
+
model.rel('IFCRELAGGREGATES', `${stair.id}:parts`, ref, refs);
|
|
1025
|
+
context.contained.push(ref);
|
|
1026
|
+
}
|
|
1027
|
+
for (const node of sortedNodes) {
|
|
1028
|
+
if (handled.has(node.id) || isExcluded(node))
|
|
1029
|
+
continue;
|
|
1030
|
+
if (NON_PHYSICAL_TYPES.has(node.type))
|
|
1031
|
+
continue;
|
|
1032
|
+
if (NATIVE_TYPES.has(node.type) && node.type !== 'door' && node.type !== 'window')
|
|
1033
|
+
continue;
|
|
1034
|
+
const context = contextFor(node);
|
|
1035
|
+
const ref = emitMeshElement(meshClassFor(node), node, partsFor(node, context), context.placement);
|
|
1036
|
+
if (ref) {
|
|
1037
|
+
context.contained.push(ref);
|
|
1038
|
+
}
|
|
1039
|
+
else if (!geometryInSubtree.has(node.id)) {
|
|
1040
|
+
skipped.push({ nodeId: node.id, type: node.type, reason: 'no-geometry' });
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
// ── Units as IfcZone groups ──────────────────────────────────────────
|
|
1044
|
+
for (const unit of ofType('unit')) {
|
|
1045
|
+
if (isExcluded(unit))
|
|
1046
|
+
continue;
|
|
1047
|
+
const members = unit.members.flatMap((zoneId) => {
|
|
1048
|
+
const space = spaceByZoneId.get(zoneId);
|
|
1049
|
+
return space ? [space] : [];
|
|
1050
|
+
});
|
|
1051
|
+
if (members.length === 0)
|
|
1052
|
+
continue;
|
|
1053
|
+
const zone = step.add('IFCZONE', model.guid(unit.id, unit.metadata?.globalId), ownerHistory, nodeName(unit, 'Unit'), null, unit.kind, null);
|
|
1054
|
+
pascalIdentity(unit, [zone]);
|
|
1055
|
+
step.add('IFCRELASSIGNSTOGROUP', model.guid(`${unit.id}:members`), ownerHistory, null, null, members, null, zone);
|
|
1056
|
+
}
|
|
1057
|
+
for (const collection of Object.values(input.collections ?? {}).sort((a, b) => a.id.localeCompare(b.id))) {
|
|
1058
|
+
const group = step.add('IFCGROUP', model.guid(collection.id), ownerHistory, collection.name, null, null);
|
|
1059
|
+
model.propertySet(collection.id, 'PascalCollection', [
|
|
1060
|
+
['CollectionId', identifier(collection.id)],
|
|
1061
|
+
['Template', collection.template ? identifier(collection.template) : null],
|
|
1062
|
+
['Color', collection.color ? label(collection.color) : null],
|
|
1063
|
+
], [group]);
|
|
1064
|
+
const members = [...new Set(collection.nodeIds)].flatMap((id) => productsByNodeId.get(id) ?? []);
|
|
1065
|
+
if (members.length > 0) {
|
|
1066
|
+
model.rel('IFCRELASSIGNSTOGROUP', `${collection.id}:members`, members, null, group);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
// ── Spatial containment ──────────────────────────────────────────────
|
|
1070
|
+
const allContexts = [
|
|
1071
|
+
...siteContexts.values(),
|
|
1072
|
+
...buildingContexts.values(),
|
|
1073
|
+
...storeyContexts.values(),
|
|
1074
|
+
];
|
|
1075
|
+
for (const context of allContexts) {
|
|
1076
|
+
if (context.contained.length === 0)
|
|
1077
|
+
continue;
|
|
1078
|
+
model.rel('IFCRELCONTAINEDINSPATIALSTRUCTURE', `contains:${context.seed}`, context.contained, context.ref);
|
|
1079
|
+
}
|
|
1080
|
+
const ifc = step.serialize({
|
|
1081
|
+
description: 'ViewDefinition [DesignTransferView_V1.0]',
|
|
1082
|
+
fileName: `${projectName}.ifc`,
|
|
1083
|
+
timestamp: timestamp.toISOString().replace(/\.\d{3}Z$/, ''),
|
|
1084
|
+
author: input.author?.trim() || '',
|
|
1085
|
+
organization: input.organization?.trim() || '',
|
|
1086
|
+
preprocessor: 'Pascal IFC writer',
|
|
1087
|
+
originatingSystem: 'Pascal Editor',
|
|
1088
|
+
schema: 'IFC4',
|
|
1089
|
+
});
|
|
1090
|
+
return { ifc, summary: { elements: elementCounts, skipped } };
|
|
1091
|
+
}
|
|
1092
|
+
const CEILING_THICKNESS = 0.01;
|
|
1093
|
+
/** Whether every vertex lies in [bottom, top] (storey-local Z); true without geometry. */
|
|
1094
|
+
function withinBand(sets, bottom, top) {
|
|
1095
|
+
for (const set of sets) {
|
|
1096
|
+
for (let i = 2; i < set.positions.length; i += 3) {
|
|
1097
|
+
const z = set.positions[i];
|
|
1098
|
+
if (z < bottom - 0.005 || z > top + 0.005)
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|
|
1104
|
+
function pushTo(map, key, value) {
|
|
1105
|
+
const list = map.get(key);
|
|
1106
|
+
if (list)
|
|
1107
|
+
list.push(value);
|
|
1108
|
+
else
|
|
1109
|
+
map.set(key, [value]);
|
|
1110
|
+
}
|
|
1111
|
+
/** Columns whose rendered shape is a plain prism; ornate ones export their mesh. */
|
|
1112
|
+
function isPlainColumn(column) {
|
|
1113
|
+
return (column.style === 'plain' &&
|
|
1114
|
+
column.shaftProfile === 'straight' &&
|
|
1115
|
+
column.baseStyle === 'none' &&
|
|
1116
|
+
column.capitalStyle === 'none' &&
|
|
1117
|
+
(column.supportStyle ?? 'vertical') === 'vertical' &&
|
|
1118
|
+
['round', 'square', 'rectangular'].includes(column.crossSection));
|
|
1119
|
+
}
|
|
1120
|
+
/** Serialize a Pascal scene graph as IFC4 STEP text. */
|
|
1121
|
+
export function exportSceneToIfc(input) {
|
|
1122
|
+
return buildIfcExport(input).ifc;
|
|
1123
|
+
}
|