@pascal-app/ifc-converter 0.1.0
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 +7 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1899 -0
- package/package.json +42 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1899 @@
|
|
|
1
|
+
import { BuildingNode, ColumnNode, DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS, DoorNode, LevelNode, RoofNode, SiteNode, SlabNode, StairNode, WallNode, WindowNode, } from '@pascal-app/core';
|
|
2
|
+
import { customAlphabet } from 'nanoid';
|
|
3
|
+
import * as WebIFC from 'web-ifc';
|
|
4
|
+
function meta(node) {
|
|
5
|
+
return (node?.metadata ?? {});
|
|
6
|
+
}
|
|
7
|
+
// Pascal's `BaseNode.metadata` is `z.json()` — a recursive JSON value
|
|
8
|
+
// type that doesn't accept `undefined` (JSON has `null`, not undefined).
|
|
9
|
+
// The converter pulls many fields from optional IFC properties that
|
|
10
|
+
// often return `undefined`; stripping them at the boundary keeps the
|
|
11
|
+
// schemas happy without spraying `?? null` through every assignment.
|
|
12
|
+
function buildMetadata(input) {
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const [key, value] of Object.entries(input)) {
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
continue;
|
|
17
|
+
out[key] = value;
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
// Wraps a Zod `.parse()` call and surfaces a single-line readable error
|
|
22
|
+
// instead of the framework's JSON-stringified issue list. The first
|
|
23
|
+
// issue is usually the actionable one; we mention the count if there
|
|
24
|
+
// are more so the user knows there's a deeper problem.
|
|
25
|
+
function tryParse(schema, kind, input) {
|
|
26
|
+
try {
|
|
27
|
+
return schema.parse(input);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
const issues = err.issues;
|
|
31
|
+
if (Array.isArray(issues) && issues.length > 0) {
|
|
32
|
+
const first = issues[0];
|
|
33
|
+
const path = first && Array.isArray(first.path) && first.path.length > 0
|
|
34
|
+
? ` at "${first.path.join('.')}"`
|
|
35
|
+
: '';
|
|
36
|
+
const more = issues.length > 1 ? ` (+${issues.length - 1} more)` : '';
|
|
37
|
+
throw new Error(`Could not build ${kind} node${path}: ${first?.message ?? 'schema mismatch'}${more}`);
|
|
38
|
+
}
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16);
|
|
43
|
+
function generateId(prefix) {
|
|
44
|
+
return `${prefix}_${nanoid()}`;
|
|
45
|
+
}
|
|
46
|
+
// --- Unit detection ---
|
|
47
|
+
function getLengthUnitFactor(ifcApi, modelID) {
|
|
48
|
+
const prefixFactors = {
|
|
49
|
+
EXA: 1e18,
|
|
50
|
+
PETA: 1e15,
|
|
51
|
+
TERA: 1e12,
|
|
52
|
+
GIGA: 1e9,
|
|
53
|
+
MEGA: 1e6,
|
|
54
|
+
KILO: 1e3,
|
|
55
|
+
HECTO: 1e2,
|
|
56
|
+
DECA: 1e1,
|
|
57
|
+
DECI: 1e-1,
|
|
58
|
+
CENTI: 1e-2,
|
|
59
|
+
MILLI: 1e-3,
|
|
60
|
+
MICRO: 1e-6,
|
|
61
|
+
NANO: 1e-9,
|
|
62
|
+
PICO: 1e-12,
|
|
63
|
+
FEMTO: 1e-15,
|
|
64
|
+
ATTO: 1e-18,
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
const projects = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCPROJECT);
|
|
68
|
+
if (projects.size() === 0)
|
|
69
|
+
return 1;
|
|
70
|
+
const proj = ifcApi.GetLine(modelID, projects.get(0));
|
|
71
|
+
if (!proj.UnitsInContext?.value)
|
|
72
|
+
return 1;
|
|
73
|
+
const unitAssign = ifcApi.GetLine(modelID, proj.UnitsInContext.value);
|
|
74
|
+
if (!unitAssign.Units)
|
|
75
|
+
return 1;
|
|
76
|
+
for (const unitRef of unitAssign.Units) {
|
|
77
|
+
const unit = ifcApi.GetLine(modelID, unitRef.value);
|
|
78
|
+
if (unit.UnitType?.value !== 'LENGTHUNIT')
|
|
79
|
+
continue;
|
|
80
|
+
if (unit.Name?.value === 'METRE' || unit.Name?.value === 'METER') {
|
|
81
|
+
const prefix = unit.Prefix?.value;
|
|
82
|
+
return prefix ? (prefixFactors[prefix] ?? 1) : 1;
|
|
83
|
+
}
|
|
84
|
+
if (unit.Name?.value === 'FOOT' || unit.Name?.value === 'FEET') {
|
|
85
|
+
return 0.3048;
|
|
86
|
+
}
|
|
87
|
+
if (unit.Name?.value === 'INCH') {
|
|
88
|
+
return 0.0254;
|
|
89
|
+
}
|
|
90
|
+
// ConversionBasedUnit — read the conversion factor
|
|
91
|
+
if (unit.ConversionFactor?.value) {
|
|
92
|
+
const factor = ifcApi.GetLine(modelID, unit.ConversionFactor.value);
|
|
93
|
+
if (factor.ValueComponent?.value) {
|
|
94
|
+
return factor.ValueComponent.value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// fall through
|
|
101
|
+
}
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
function identity() {
|
|
105
|
+
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
106
|
+
}
|
|
107
|
+
function multiply(a, b) {
|
|
108
|
+
const r = new Array(16).fill(0);
|
|
109
|
+
for (let row = 0; row < 4; row++) {
|
|
110
|
+
for (let col = 0; col < 4; col++) {
|
|
111
|
+
for (let k = 0; k < 4; k++) {
|
|
112
|
+
r[row * 4 + col] += a[row * 4 + k] * b[k * 4 + col];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return r;
|
|
117
|
+
}
|
|
118
|
+
function transformPoint3(m, p) {
|
|
119
|
+
return [
|
|
120
|
+
m[0] * p[0] + m[1] * p[1] + m[2] * p[2] + m[3],
|
|
121
|
+
m[4] * p[0] + m[5] * p[1] + m[6] * p[2] + m[7],
|
|
122
|
+
m[8] * p[0] + m[9] * p[1] + m[10] * p[2] + m[11],
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
function buildAxis2Placement3DMatrix(ifcApi, modelID, axis2Id) {
|
|
126
|
+
const axis2 = ifcApi.GetLine(modelID, axis2Id);
|
|
127
|
+
const num = (v) => {
|
|
128
|
+
if (v == null)
|
|
129
|
+
return undefined;
|
|
130
|
+
if (typeof v === 'number')
|
|
131
|
+
return v;
|
|
132
|
+
if (v.value != null)
|
|
133
|
+
return v.value;
|
|
134
|
+
return undefined;
|
|
135
|
+
};
|
|
136
|
+
let ox = 0, oy = 0, oz = 0;
|
|
137
|
+
if (axis2.Location?.value) {
|
|
138
|
+
const loc = ifcApi.GetLine(modelID, axis2.Location.value);
|
|
139
|
+
const coords = loc.Coordinates.map((c) => num(c));
|
|
140
|
+
ox = coords[0] ?? 0;
|
|
141
|
+
oy = coords[1] ?? 0;
|
|
142
|
+
oz = coords[2] ?? 0;
|
|
143
|
+
}
|
|
144
|
+
// Z-axis (Axis direction) — default (0,0,1)
|
|
145
|
+
let zx = 0, zy = 0, zz = 1;
|
|
146
|
+
if (axis2.Axis?.value) {
|
|
147
|
+
const ax = ifcApi.GetLine(modelID, axis2.Axis.value);
|
|
148
|
+
const d = ax.DirectionRatios.map((c) => num(c));
|
|
149
|
+
if (d[0] != null) {
|
|
150
|
+
zx = d[0];
|
|
151
|
+
zy = d[1] ?? 0;
|
|
152
|
+
zz = d[2] ?? 0;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// X-axis (RefDirection) — default (1,0,0)
|
|
156
|
+
let xx = 1, xy = 0, xz = 0;
|
|
157
|
+
if (axis2.RefDirection?.value) {
|
|
158
|
+
const rd = ifcApi.GetLine(modelID, axis2.RefDirection.value);
|
|
159
|
+
const d = rd.DirectionRatios.map((c) => num(c));
|
|
160
|
+
if (d[0] != null) {
|
|
161
|
+
xx = d[0];
|
|
162
|
+
xy = d[1] ?? 0;
|
|
163
|
+
xz = d[2] ?? 0;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Normalize Z
|
|
167
|
+
const zLen = Math.sqrt(zx * zx + zy * zy + zz * zz) || 1;
|
|
168
|
+
zx /= zLen;
|
|
169
|
+
zy /= zLen;
|
|
170
|
+
zz /= zLen;
|
|
171
|
+
// Y = Z cross X, then normalize
|
|
172
|
+
let yx = zy * xz - zz * xy;
|
|
173
|
+
let yy = zz * xx - zx * xz;
|
|
174
|
+
let yz = zx * xy - zy * xx;
|
|
175
|
+
const yLen = Math.sqrt(yx * yx + yy * yy + yz * yz) || 1;
|
|
176
|
+
yx /= yLen;
|
|
177
|
+
yy /= yLen;
|
|
178
|
+
yz /= yLen;
|
|
179
|
+
// Recompute X = Y cross Z to ensure orthonormal
|
|
180
|
+
xx = yy * zz - yz * zy;
|
|
181
|
+
xy = yz * zx - yx * zz;
|
|
182
|
+
xz = yx * zy - yy * zx;
|
|
183
|
+
// Row-major 4x4
|
|
184
|
+
return [xx, yx, zx, ox, xy, yy, zy, oy, xz, yz, zz, oz, 0, 0, 0, 1];
|
|
185
|
+
}
|
|
186
|
+
// --- Placement chain resolver ---
|
|
187
|
+
function resolveWorldTransform(ifcApi, modelID, placementId) {
|
|
188
|
+
const chain = [];
|
|
189
|
+
let current = placementId;
|
|
190
|
+
while (current) {
|
|
191
|
+
const placement = ifcApi.GetLine(modelID, current);
|
|
192
|
+
if (placement.RelativePlacement?.value) {
|
|
193
|
+
chain.push(placement.RelativePlacement.value);
|
|
194
|
+
}
|
|
195
|
+
current = placement.PlacementRelTo?.value ?? null;
|
|
196
|
+
}
|
|
197
|
+
// Multiply from root to leaf
|
|
198
|
+
let result = identity();
|
|
199
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
200
|
+
const mat = buildAxis2Placement3DMatrix(ifcApi, modelID, chain[i]);
|
|
201
|
+
result = multiply(result, mat);
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
// --- Geometry extraction helpers ---
|
|
206
|
+
function getAxisPolyline(ifcApi, modelID, element) {
|
|
207
|
+
try {
|
|
208
|
+
if (!element.Representation?.value)
|
|
209
|
+
return null;
|
|
210
|
+
const prodRep = ifcApi.GetLine(modelID, element.Representation.value);
|
|
211
|
+
for (const repRef of prodRep.Representations) {
|
|
212
|
+
const rep = ifcApi.GetLine(modelID, repRef.value);
|
|
213
|
+
if (rep.RepresentationIdentifier?.value !== 'Axis')
|
|
214
|
+
continue;
|
|
215
|
+
for (const itemRef of rep.Items) {
|
|
216
|
+
const item = ifcApi.GetLine(modelID, itemRef.value);
|
|
217
|
+
// IFCPOLYLINE — Points is an array of CartesianPoint references
|
|
218
|
+
if (Array.isArray(item.Points)) {
|
|
219
|
+
const points = [];
|
|
220
|
+
for (const ptRef of item.Points) {
|
|
221
|
+
const pt = ifcApi.GetLine(modelID, ptRef.value);
|
|
222
|
+
const coords = pt.Coordinates.map((c) => typeof c === 'number' ? c : (c?.value ?? 0));
|
|
223
|
+
points.push([coords[0] ?? 0, coords[1] ?? 0, coords[2] ?? 0]);
|
|
224
|
+
}
|
|
225
|
+
if (points.length >= 2)
|
|
226
|
+
return points;
|
|
227
|
+
}
|
|
228
|
+
// IFCINDEXEDPOLYCURVE — Points is a reference to a point list entity
|
|
229
|
+
if (item.Points?.value && !Array.isArray(item.Points)) {
|
|
230
|
+
const ptList = ifcApi.GetLine(modelID, item.Points.value);
|
|
231
|
+
if (ptList.CoordList) {
|
|
232
|
+
const points = [];
|
|
233
|
+
for (const coords of ptList.CoordList) {
|
|
234
|
+
const c = Array.isArray(coords)
|
|
235
|
+
? coords.map((v) => (typeof v === 'number' ? v : (v?.value ?? 0)))
|
|
236
|
+
: [];
|
|
237
|
+
points.push([c[0] ?? 0, c[1] ?? 0, c[2] ?? 0]);
|
|
238
|
+
}
|
|
239
|
+
if (points.length >= 2)
|
|
240
|
+
return points;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// IFCGEOMETRICSET — unwrap to find an inner curve
|
|
244
|
+
if (item.Elements) {
|
|
245
|
+
for (const elemRef of item.Elements) {
|
|
246
|
+
const inner = ifcApi.GetLine(modelID, elemRef.value);
|
|
247
|
+
if (inner.Points?.value) {
|
|
248
|
+
const ptList = ifcApi.GetLine(modelID, inner.Points.value);
|
|
249
|
+
if (ptList.CoordList) {
|
|
250
|
+
const points = [];
|
|
251
|
+
for (const coords of ptList.CoordList) {
|
|
252
|
+
const c = Array.isArray(coords)
|
|
253
|
+
? coords.map((v) => (typeof v === 'number' ? v : (v?.value ?? 0)))
|
|
254
|
+
: [];
|
|
255
|
+
points.push([c[0] ?? 0, c[1] ?? 0, c[2] ?? 0]);
|
|
256
|
+
}
|
|
257
|
+
if (points.length >= 2)
|
|
258
|
+
return points;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// fall through
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
function extractFromExtrusionItem(ifcApi, modelID, item, result) {
|
|
272
|
+
// Unwrap BooleanClippingResult → follow FirstOperand chain to find extrusion
|
|
273
|
+
let current = item;
|
|
274
|
+
for (let guard = 0; guard < 10; guard++) {
|
|
275
|
+
if (current.Depth?.value)
|
|
276
|
+
break;
|
|
277
|
+
if (current.FirstOperand?.value) {
|
|
278
|
+
current = ifcApi.GetLine(modelID, current.FirstOperand.value);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (current.Depth?.value) {
|
|
285
|
+
result.depth = current.Depth.value;
|
|
286
|
+
}
|
|
287
|
+
if (current.SweptArea?.value) {
|
|
288
|
+
const profile = ifcApi.GetLine(modelID, current.SweptArea.value);
|
|
289
|
+
// Detect the profile shape by the fields present rather than the IFC
|
|
290
|
+
// type id — robust across web-ifc versions. IfcCircleProfileDef has a
|
|
291
|
+
// Radius; IfcRectangleProfileDef has XDim/YDim.
|
|
292
|
+
if (profile.Radius?.value != null) {
|
|
293
|
+
result.profileShape = 'round';
|
|
294
|
+
result.radius = profile.Radius.value;
|
|
295
|
+
}
|
|
296
|
+
if (profile.XDim?.value) {
|
|
297
|
+
result.xDim = profile.XDim.value;
|
|
298
|
+
result.yDim = profile.YDim?.value ?? null;
|
|
299
|
+
if (result.profileShape === null)
|
|
300
|
+
result.profileShape = 'rectangular';
|
|
301
|
+
}
|
|
302
|
+
// Extract profile points — OuterCurve for ArbitraryClosedProfileDef
|
|
303
|
+
const curveRef = profile.OuterCurve?.value;
|
|
304
|
+
if (curveRef) {
|
|
305
|
+
const curve = ifcApi.GetLine(modelID, curveRef);
|
|
306
|
+
if (curve.Points) {
|
|
307
|
+
const pts = [];
|
|
308
|
+
// IFCPOLYLINE — Points is array of CartesianPoint refs
|
|
309
|
+
if (Array.isArray(curve.Points) &&
|
|
310
|
+
curve.Points.length > 0 &&
|
|
311
|
+
curve.Points[0]?.value != null) {
|
|
312
|
+
for (const ptRef of curve.Points) {
|
|
313
|
+
const pt = ifcApi.GetLine(modelID, ptRef.value);
|
|
314
|
+
const coords = pt.Coordinates.map((c) => typeof c === 'number' ? c : (c?.value ?? 0));
|
|
315
|
+
pts.push([coords[0] ?? 0, coords[1] ?? 0]);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// IFCINDEXEDPOLYCURVE — Points is a reference to a point list
|
|
319
|
+
else if (curve.Points?.value) {
|
|
320
|
+
const ptList = ifcApi.GetLine(modelID, curve.Points.value);
|
|
321
|
+
if (ptList.CoordList) {
|
|
322
|
+
for (const coords of ptList.CoordList) {
|
|
323
|
+
const c = Array.isArray(coords)
|
|
324
|
+
? coords.map((v) => (typeof v === 'number' ? v : (v?.value ?? 0)))
|
|
325
|
+
: [];
|
|
326
|
+
pts.push([c[0] ?? 0, c[1] ?? 0]);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (pts.length >= 3)
|
|
331
|
+
result.profilePoints = pts;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return result.depth !== null;
|
|
336
|
+
}
|
|
337
|
+
function getBodyExtrusionData(ifcApi, modelID, element) {
|
|
338
|
+
const result = {
|
|
339
|
+
depth: null,
|
|
340
|
+
xDim: null,
|
|
341
|
+
yDim: null,
|
|
342
|
+
profilePoints: null,
|
|
343
|
+
profileShape: null,
|
|
344
|
+
radius: null,
|
|
345
|
+
};
|
|
346
|
+
try {
|
|
347
|
+
if (!element.Representation?.value)
|
|
348
|
+
return result;
|
|
349
|
+
const prodRep = ifcApi.GetLine(modelID, element.Representation.value);
|
|
350
|
+
for (const repRef of prodRep.Representations) {
|
|
351
|
+
const rep = ifcApi.GetLine(modelID, repRef.value);
|
|
352
|
+
if (rep.RepresentationIdentifier?.value !== 'Body')
|
|
353
|
+
continue;
|
|
354
|
+
for (const itemRef of rep.Items) {
|
|
355
|
+
const item = ifcApi.GetLine(modelID, itemRef.value);
|
|
356
|
+
// Direct extrusion or BooleanClippingResult
|
|
357
|
+
if (extractFromExtrusionItem(ifcApi, modelID, item, result))
|
|
358
|
+
return result;
|
|
359
|
+
// MappedRepresentation → unwrap to inner items
|
|
360
|
+
if (item.MappingSource?.value) {
|
|
361
|
+
const src = ifcApi.GetLine(modelID, item.MappingSource.value);
|
|
362
|
+
if (src.MappedRepresentation?.value) {
|
|
363
|
+
const mapped = ifcApi.GetLine(modelID, src.MappedRepresentation.value);
|
|
364
|
+
if (mapped.Items) {
|
|
365
|
+
for (const mItemRef of mapped.Items) {
|
|
366
|
+
const mItem = ifcApi.GetLine(modelID, mItemRef.value);
|
|
367
|
+
if (extractFromExtrusionItem(ifcApi, modelID, mItem, result))
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// fall through
|
|
378
|
+
}
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
function findExtrusionPosition(ifcApi, modelID, item) {
|
|
382
|
+
let current = item;
|
|
383
|
+
for (let guard = 0; guard < 10; guard++) {
|
|
384
|
+
if (current.Position?.value) {
|
|
385
|
+
return buildAxis2Placement3DMatrix(ifcApi, modelID, current.Position.value);
|
|
386
|
+
}
|
|
387
|
+
if (current.FirstOperand?.value) {
|
|
388
|
+
current = ifcApi.GetLine(modelID, current.FirstOperand.value);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
// Mesh extents of an element measured in the WALL'S OWN frame: along the
|
|
397
|
+
// wall axis (length), horizontally perpendicular to it (thickness), and
|
|
398
|
+
// vertical (height). Used to recover height/thickness for plain IFCWALL
|
|
399
|
+
// whose Brep/mapped geometry getBodyExtrusionData can't read.
|
|
400
|
+
//
|
|
401
|
+
// Each vertex is transformed to world space (flatTransformation) and
|
|
402
|
+
// then projected onto the known wall-axis direction — NOT read as a raw
|
|
403
|
+
// world AABB. A world AABB conflates length and thickness for any wall
|
|
404
|
+
// the placement rotates (a 37°-rotated 0.2m wall would read ~1.9m
|
|
405
|
+
// thick); projecting onto the actual axis is rotation-invariant.
|
|
406
|
+
// (axisX, axisY) is the unit wall direction in the converter's
|
|
407
|
+
// horizontal frame, which is parallel to web-ifc world XY — both are IFC
|
|
408
|
+
// world coords, differing only by origin/scale, which cancel in extents.
|
|
409
|
+
// Returns extents in the geometry's native units (caller resolves
|
|
410
|
+
// scale), or null on any failure.
|
|
411
|
+
function measureWallLocalExtents(ifcApi, modelID, expressID, axisX, axisY) {
|
|
412
|
+
const perpX = -axisY;
|
|
413
|
+
const perpY = axisX;
|
|
414
|
+
let mesh;
|
|
415
|
+
try {
|
|
416
|
+
mesh = ifcApi.GetFlatMesh(modelID, expressID);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
const geoms = mesh.geometries;
|
|
423
|
+
let minA = Number.POSITIVE_INFINITY;
|
|
424
|
+
let maxA = Number.NEGATIVE_INFINITY;
|
|
425
|
+
let minP = Number.POSITIVE_INFINITY;
|
|
426
|
+
let maxP = Number.NEGATIVE_INFINITY;
|
|
427
|
+
let minV = Number.POSITIVE_INFINITY;
|
|
428
|
+
let maxV = Number.NEGATIVE_INFINITY;
|
|
429
|
+
let any = false;
|
|
430
|
+
for (let g = 0; g < geoms.size(); g++) {
|
|
431
|
+
const pg = geoms.get(g);
|
|
432
|
+
const m = pg.flatTransformation;
|
|
433
|
+
const geo = ifcApi.GetGeometry(modelID, pg.geometryExpressID);
|
|
434
|
+
try {
|
|
435
|
+
const verts = ifcApi.GetVertexArray(geo.GetVertexData(), geo.GetVertexDataSize());
|
|
436
|
+
// 6 floats per vertex: local position xyz + normal xyz.
|
|
437
|
+
for (let v = 0; v + 2 < verts.length; v += 6) {
|
|
438
|
+
const x = verts[v];
|
|
439
|
+
const y = verts[v + 1];
|
|
440
|
+
const z = verts[v + 2];
|
|
441
|
+
// flatTransformation is column-major 4x4 → world position.
|
|
442
|
+
const wx = m[0] * x + m[4] * y + m[8] * z + m[12];
|
|
443
|
+
const wy = m[1] * x + m[5] * y + m[9] * z + m[13];
|
|
444
|
+
const wz = m[2] * x + m[6] * y + m[10] * z + m[14];
|
|
445
|
+
const a = wx * axisX + wy * axisY;
|
|
446
|
+
const p = wx * perpX + wy * perpY;
|
|
447
|
+
if (a < minA)
|
|
448
|
+
minA = a;
|
|
449
|
+
if (a > maxA)
|
|
450
|
+
maxA = a;
|
|
451
|
+
if (p < minP)
|
|
452
|
+
minP = p;
|
|
453
|
+
if (p > maxP)
|
|
454
|
+
maxP = p;
|
|
455
|
+
if (wz < minV)
|
|
456
|
+
minV = wz;
|
|
457
|
+
if (wz > maxV)
|
|
458
|
+
maxV = wz;
|
|
459
|
+
any = true;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
finally {
|
|
463
|
+
;
|
|
464
|
+
geo.delete?.();
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (!any)
|
|
468
|
+
return null;
|
|
469
|
+
return { along: maxA - minA, across: maxP - minP, vertical: maxV - minV };
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
mesh.delete?.();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// Resolve wall height + thickness from the wall-frame extents. The along
|
|
479
|
+
// extent must match the wall's already-known length (within tolerance)
|
|
480
|
+
// for the measurement to be trusted — that gate confirms both the unit
|
|
481
|
+
// scale (tried raw and unit-scaled) and frame alignment. Height is the
|
|
482
|
+
// vertical extent (rotation-invariant); thickness is the perpendicular
|
|
483
|
+
// extent. Returns null (→ caller uses defaults) when the gate fails or
|
|
484
|
+
// the dims are implausible.
|
|
485
|
+
function wallHeightThicknessFromExtents(extents, knownLengthMeters, unitFactor) {
|
|
486
|
+
if (knownLengthMeters <= 1e-6)
|
|
487
|
+
return null;
|
|
488
|
+
const tol = Math.max(0.3, 0.15 * knownLengthMeters);
|
|
489
|
+
for (const scale of [unitFactor, 1]) {
|
|
490
|
+
const along = extents.along * scale;
|
|
491
|
+
if (Math.abs(along - knownLengthMeters) > tol)
|
|
492
|
+
continue;
|
|
493
|
+
const height = extents.vertical * scale;
|
|
494
|
+
const thickness = extents.across * scale;
|
|
495
|
+
if (height >= 0.2 && height <= 20 && thickness >= 0.02 && thickness <= 2) {
|
|
496
|
+
return { height, thickness };
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
function getExtrusionPosition(ifcApi, modelID, element) {
|
|
502
|
+
try {
|
|
503
|
+
if (!element.Representation?.value)
|
|
504
|
+
return null;
|
|
505
|
+
const prodRep = ifcApi.GetLine(modelID, element.Representation.value);
|
|
506
|
+
for (const repRef of prodRep.Representations) {
|
|
507
|
+
const rep = ifcApi.GetLine(modelID, repRef.value);
|
|
508
|
+
if (rep.RepresentationIdentifier?.value !== 'Body')
|
|
509
|
+
continue;
|
|
510
|
+
for (const itemRef of rep.Items) {
|
|
511
|
+
const item = ifcApi.GetLine(modelID, itemRef.value);
|
|
512
|
+
const pos = findExtrusionPosition(ifcApi, modelID, item);
|
|
513
|
+
if (pos)
|
|
514
|
+
return pos;
|
|
515
|
+
if (item.MappingSource?.value) {
|
|
516
|
+
const src = ifcApi.GetLine(modelID, item.MappingSource.value);
|
|
517
|
+
if (src.MappedRepresentation?.value) {
|
|
518
|
+
const mapped = ifcApi.GetLine(modelID, src.MappedRepresentation.value);
|
|
519
|
+
if (mapped.Items) {
|
|
520
|
+
for (const mItemRef of mapped.Items) {
|
|
521
|
+
const mItem = ifcApi.GetLine(modelID, mItemRef.value);
|
|
522
|
+
const mPos = findExtrusionPosition(ifcApi, modelID, mItem);
|
|
523
|
+
if (mPos)
|
|
524
|
+
return mPos;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
// fall through
|
|
534
|
+
}
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
export const VARIANT_PRESETS = {
|
|
538
|
+
A: {
|
|
539
|
+
swapYZ: true,
|
|
540
|
+
extrusionDepthIsHeight: true,
|
|
541
|
+
swapProfileDimensions: false,
|
|
542
|
+
label: 'Default (Y-up, depth=height)',
|
|
543
|
+
},
|
|
544
|
+
B: {
|
|
545
|
+
swapYZ: false,
|
|
546
|
+
extrusionDepthIsHeight: true,
|
|
547
|
+
swapProfileDimensions: false,
|
|
548
|
+
label: 'Z-Up (no axis swap)',
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
552
|
+
const opts = {
|
|
553
|
+
swapYZ: options?.swapYZ ?? true,
|
|
554
|
+
extrusionDepthIsHeight: options?.extrusionDepthIsHeight ?? true,
|
|
555
|
+
swapProfileDimensions: options?.swapProfileDimensions ?? false,
|
|
556
|
+
};
|
|
557
|
+
const progress = (msg, pct) => {
|
|
558
|
+
console.log(`[IFC→Pascal] ${msg} (${pct}%)`);
|
|
559
|
+
onProgress?.(msg, pct);
|
|
560
|
+
};
|
|
561
|
+
progress('Initializing IFC parser...', 0);
|
|
562
|
+
const ifcApi = new WebIFC.IfcAPI();
|
|
563
|
+
ifcApi.SetWasmPath('/', true);
|
|
564
|
+
await ifcApi.Init();
|
|
565
|
+
progress('Opening IFC model...', 10);
|
|
566
|
+
const modelID = ifcApi.OpenModel(ifcData);
|
|
567
|
+
console.log(`[IFC→Pascal] Model opened, ID: ${modelID}, File size: ${(ifcData.length / 1024).toFixed(1)} KB`);
|
|
568
|
+
const nodes = {};
|
|
569
|
+
const rootNodeIds = [];
|
|
570
|
+
// Maps to track relationships
|
|
571
|
+
const parentMap = new Map();
|
|
572
|
+
const childrenMap = new Map();
|
|
573
|
+
const expressIdToNodeId = new Map();
|
|
574
|
+
progress('Analyzing spatial relationships...', 20);
|
|
575
|
+
// Detect length unit → meters conversion factor
|
|
576
|
+
const unitFactor = getLengthUnitFactor(ifcApi, modelID);
|
|
577
|
+
// Compute scene origin offset to center georeferenced models near (0,0,0)
|
|
578
|
+
let originOffset = [0, 0, 0];
|
|
579
|
+
try {
|
|
580
|
+
const siteIds = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE);
|
|
581
|
+
const anchorId = siteIds.size() > 0 ? siteIds.get(0) : null;
|
|
582
|
+
if (anchorId) {
|
|
583
|
+
const anchor = ifcApi.GetLine(modelID, anchorId);
|
|
584
|
+
if (anchor.ObjectPlacement?.value) {
|
|
585
|
+
const mat = resolveWorldTransform(ifcApi, modelID, anchor.ObjectPlacement.value);
|
|
586
|
+
originOffset = transformPoint3(mat, [0, 0, 0]);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
/* keep zero offset */
|
|
592
|
+
}
|
|
593
|
+
function worldToScene(worldPt) {
|
|
594
|
+
return [
|
|
595
|
+
(worldPt[0] - originOffset[0]) * unitFactor,
|
|
596
|
+
(worldPt[1] - originOffset[1]) * unitFactor,
|
|
597
|
+
(worldPt[2] - originOffset[2]) * unitFactor,
|
|
598
|
+
];
|
|
599
|
+
}
|
|
600
|
+
// Collect storey expressIDs for level mapping
|
|
601
|
+
const storeyExpressIds = new Set();
|
|
602
|
+
const storeyIds = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDINGSTOREY);
|
|
603
|
+
for (let i = 0; i < storeyIds.size(); i++) {
|
|
604
|
+
storeyExpressIds.add(storeyIds.get(i));
|
|
605
|
+
}
|
|
606
|
+
// First pass: collect spatial hierarchy relationships
|
|
607
|
+
const relAggregates = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELAGGREGATES);
|
|
608
|
+
for (let i = 0; i < relAggregates.size(); i++) {
|
|
609
|
+
const relID = relAggregates.get(i);
|
|
610
|
+
const rel = ifcApi.GetLine(modelID, relID);
|
|
611
|
+
if (rel.RelatingObject && rel.RelatedObjects) {
|
|
612
|
+
const parentExpressID = rel.RelatingObject.value;
|
|
613
|
+
const children = rel.RelatedObjects.map((obj) => obj.value);
|
|
614
|
+
children.forEach((childID) => {
|
|
615
|
+
parentMap.set(childID, parentExpressID);
|
|
616
|
+
});
|
|
617
|
+
if (!childrenMap.has(parentExpressID)) {
|
|
618
|
+
childrenMap.set(parentExpressID, []);
|
|
619
|
+
}
|
|
620
|
+
childrenMap.get(parentExpressID)?.push(...children);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// Second pass: collect spatial containment
|
|
624
|
+
const relContained = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELCONTAINEDINSPATIALSTRUCTURE);
|
|
625
|
+
for (let i = 0; i < relContained.size(); i++) {
|
|
626
|
+
const relID = relContained.get(i);
|
|
627
|
+
const rel = ifcApi.GetLine(modelID, relID);
|
|
628
|
+
if (rel.RelatingStructure && rel.RelatedElements) {
|
|
629
|
+
const parentExpressID = rel.RelatingStructure.value;
|
|
630
|
+
const children = rel.RelatedElements.map((obj) => obj.value);
|
|
631
|
+
children.forEach((childID) => {
|
|
632
|
+
parentMap.set(childID, parentExpressID);
|
|
633
|
+
});
|
|
634
|
+
if (!childrenMap.has(parentExpressID)) {
|
|
635
|
+
childrenMap.set(parentExpressID, []);
|
|
636
|
+
}
|
|
637
|
+
childrenMap.get(parentExpressID)?.push(...children);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// Resolve containing storey for an element by walking the parent chain
|
|
641
|
+
function findStoreyForElement(expressId) {
|
|
642
|
+
let current = expressId;
|
|
643
|
+
for (let guard = 0; guard < 20 && current != null; guard++) {
|
|
644
|
+
if (storeyExpressIds.has(current))
|
|
645
|
+
return current;
|
|
646
|
+
current = parentMap.get(current);
|
|
647
|
+
}
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
progress('Processing sites...', 30);
|
|
651
|
+
// Process sites
|
|
652
|
+
const sites = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE);
|
|
653
|
+
console.log(`[IFC→Pascal] Found ${sites.size()} sites`);
|
|
654
|
+
for (let i = 0; i < sites.size(); i++) {
|
|
655
|
+
const siteExpressID = sites.get(i);
|
|
656
|
+
const site = ifcApi.GetLine(modelID, siteExpressID);
|
|
657
|
+
const nodeId = generateId('site');
|
|
658
|
+
expressIdToNodeId.set(siteExpressID, nodeId);
|
|
659
|
+
rootNodeIds.push(nodeId);
|
|
660
|
+
const siteNode = tryParse(SiteNode, 'site', {
|
|
661
|
+
object: 'node',
|
|
662
|
+
id: nodeId,
|
|
663
|
+
type: 'site',
|
|
664
|
+
name: site.Name?.value || site.LongName?.value || 'Site',
|
|
665
|
+
parentId: null,
|
|
666
|
+
visible: true,
|
|
667
|
+
polygon: {
|
|
668
|
+
// Pascal SiteNode requires a property-line polygon. The
|
|
669
|
+
// converter doesn't read IFC site geometry yet, so seed the
|
|
670
|
+
// editor's default 30x30 square here.
|
|
671
|
+
// TODO(ifc-fix): derive from IfcSite.SiteAddress or building footprints.
|
|
672
|
+
type: 'polygon',
|
|
673
|
+
points: [
|
|
674
|
+
[-15, -15],
|
|
675
|
+
[15, -15],
|
|
676
|
+
[15, 15],
|
|
677
|
+
[-15, 15],
|
|
678
|
+
],
|
|
679
|
+
},
|
|
680
|
+
children: [],
|
|
681
|
+
metadata: buildMetadata({
|
|
682
|
+
ifcType: 'IFCSITE',
|
|
683
|
+
expressID: siteExpressID,
|
|
684
|
+
globalId: site.GlobalId?.value,
|
|
685
|
+
}),
|
|
686
|
+
});
|
|
687
|
+
nodes[nodeId] = siteNode;
|
|
688
|
+
}
|
|
689
|
+
progress('Processing buildings...', 40);
|
|
690
|
+
// Process buildings
|
|
691
|
+
const buildings = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDING);
|
|
692
|
+
console.log(`[IFC→Pascal] Found ${buildings.size()} buildings`);
|
|
693
|
+
for (let i = 0; i < buildings.size(); i++) {
|
|
694
|
+
const buildingExpressID = buildings.get(i);
|
|
695
|
+
const building = ifcApi.GetLine(modelID, buildingExpressID);
|
|
696
|
+
const nodeId = generateId('building');
|
|
697
|
+
expressIdToNodeId.set(buildingExpressID, nodeId);
|
|
698
|
+
const parentExpressID = parentMap.get(buildingExpressID);
|
|
699
|
+
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
|
|
700
|
+
const buildingNode = tryParse(BuildingNode, 'building', {
|
|
701
|
+
object: 'node',
|
|
702
|
+
id: nodeId,
|
|
703
|
+
type: 'building',
|
|
704
|
+
name: building.Name?.value || building.LongName?.value || 'Building',
|
|
705
|
+
parentId: parentNodeId || null,
|
|
706
|
+
visible: true,
|
|
707
|
+
position: [0, 0, 0],
|
|
708
|
+
rotation: [0, 0, 0],
|
|
709
|
+
children: [],
|
|
710
|
+
metadata: buildMetadata({
|
|
711
|
+
ifcType: 'IFCBUILDING',
|
|
712
|
+
expressID: buildingExpressID,
|
|
713
|
+
globalId: building.GlobalId?.value,
|
|
714
|
+
}),
|
|
715
|
+
});
|
|
716
|
+
nodes[nodeId] = buildingNode;
|
|
717
|
+
if (parentNodeId && nodes[parentNodeId]) {
|
|
718
|
+
;
|
|
719
|
+
nodes[parentNodeId].children?.push(nodeId);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
progress('Processing levels...', 50);
|
|
723
|
+
// Process building storeys (levels)
|
|
724
|
+
const storeys = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBUILDINGSTOREY);
|
|
725
|
+
console.log(`[IFC→Pascal] Found ${storeys.size()} levels`);
|
|
726
|
+
for (let i = 0; i < storeys.size(); i++) {
|
|
727
|
+
const storeyExpressID = storeys.get(i);
|
|
728
|
+
const storey = ifcApi.GetLine(modelID, storeyExpressID);
|
|
729
|
+
const nodeId = generateId('level');
|
|
730
|
+
expressIdToNodeId.set(storeyExpressID, nodeId);
|
|
731
|
+
const parentExpressID = parentMap.get(storeyExpressID);
|
|
732
|
+
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
|
|
733
|
+
// Resolve storey elevation from placement chain
|
|
734
|
+
let elevation = storey.Elevation?.value ?? 0;
|
|
735
|
+
if (storey.ObjectPlacement?.value) {
|
|
736
|
+
try {
|
|
737
|
+
const worldMat = resolveWorldTransform(ifcApi, modelID, storey.ObjectPlacement.value);
|
|
738
|
+
const worldOrigin = transformPoint3(worldMat, [0, 0, 0]);
|
|
739
|
+
elevation = worldToScene(worldOrigin)[2];
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
elevation = (storey.Elevation?.value ?? 0) * unitFactor;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
elevation *= unitFactor;
|
|
747
|
+
}
|
|
748
|
+
const levelNode = tryParse(LevelNode, 'level', {
|
|
749
|
+
object: 'node',
|
|
750
|
+
id: nodeId,
|
|
751
|
+
type: 'level',
|
|
752
|
+
name: storey.Name?.value || storey.LongName?.value || `Level ${i}`,
|
|
753
|
+
level: i,
|
|
754
|
+
parentId: parentNodeId || null,
|
|
755
|
+
visible: true,
|
|
756
|
+
children: [],
|
|
757
|
+
metadata: buildMetadata({
|
|
758
|
+
ifcType: 'IFCBUILDINGSTOREY',
|
|
759
|
+
expressID: storeyExpressID,
|
|
760
|
+
globalId: storey.GlobalId?.value,
|
|
761
|
+
elevation,
|
|
762
|
+
}),
|
|
763
|
+
});
|
|
764
|
+
nodes[nodeId] = levelNode;
|
|
765
|
+
if (parentNodeId && nodes[parentNodeId]) {
|
|
766
|
+
;
|
|
767
|
+
nodes[parentNodeId].children?.push(nodeId);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
progress('Processing walls...', 60);
|
|
771
|
+
// Build void/fill relationship maps for doors and windows
|
|
772
|
+
// IFCRELVOIDSELEMENT: wall (RelatingBuildingElement) → opening (RelatedOpeningElement)
|
|
773
|
+
const wallToOpenings = new Map();
|
|
774
|
+
const relVoids = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELVOIDSELEMENT);
|
|
775
|
+
for (let i = 0; i < relVoids.size(); i++) {
|
|
776
|
+
const rel = ifcApi.GetLine(modelID, relVoids.get(i));
|
|
777
|
+
const wallId = rel.RelatingBuildingElement?.value;
|
|
778
|
+
const openingId = rel.RelatedOpeningElement?.value;
|
|
779
|
+
if (wallId && openingId) {
|
|
780
|
+
if (!wallToOpenings.has(wallId))
|
|
781
|
+
wallToOpenings.set(wallId, []);
|
|
782
|
+
wallToOpenings.get(wallId).push(openingId);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
// IFCRELFILLSELEMENT: opening (RelatingOpeningElement) → door/window (RelatedBuildingElement)
|
|
786
|
+
const openingToFill = new Map();
|
|
787
|
+
const relFills = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELFILLSELEMENT);
|
|
788
|
+
for (let i = 0; i < relFills.size(); i++) {
|
|
789
|
+
const rel = ifcApi.GetLine(modelID, relFills.get(i));
|
|
790
|
+
const openingId = rel.RelatingOpeningElement?.value;
|
|
791
|
+
const fillId = rel.RelatedBuildingElement?.value;
|
|
792
|
+
if (openingId && fillId) {
|
|
793
|
+
openingToFill.set(openingId, fillId);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
// Collect all door and window expressIDs for type checking
|
|
797
|
+
const doorExpressIds = new Set();
|
|
798
|
+
const windowExpressIds = new Set();
|
|
799
|
+
for (const doorType of [WebIFC.IFCDOOR, WebIFC.IFCDOORSTANDARDCASE]) {
|
|
800
|
+
const ids = ifcApi.GetLineIDsWithType(modelID, doorType);
|
|
801
|
+
for (let i = 0; i < ids.size(); i++)
|
|
802
|
+
doorExpressIds.add(ids.get(i));
|
|
803
|
+
}
|
|
804
|
+
for (const winType of [WebIFC.IFCWINDOW, WebIFC.IFCWINDOWSTANDARDCASE]) {
|
|
805
|
+
const ids = ifcApi.GetLineIDsWithType(modelID, winType);
|
|
806
|
+
for (let i = 0; i < ids.size(); i++)
|
|
807
|
+
windowExpressIds.add(ids.get(i));
|
|
808
|
+
}
|
|
809
|
+
// Process walls (both IFCWALL and IFCWALLSTANDARDCASE)
|
|
810
|
+
const wallTypes = [WebIFC.IFCWALL, WebIFC.IFCWALLSTANDARDCASE];
|
|
811
|
+
for (const wallType of wallTypes) {
|
|
812
|
+
const walls = ifcApi.GetLineIDsWithType(modelID, wallType);
|
|
813
|
+
for (let i = 0; i < walls.size(); i++) {
|
|
814
|
+
const wallExpressID = walls.get(i);
|
|
815
|
+
if (expressIdToNodeId.has(wallExpressID))
|
|
816
|
+
continue;
|
|
817
|
+
const wall = ifcApi.GetLine(modelID, wallExpressID);
|
|
818
|
+
const nodeId = generateId('wall');
|
|
819
|
+
expressIdToNodeId.set(wallExpressID, nodeId);
|
|
820
|
+
const parentExpressID = parentMap.get(wallExpressID);
|
|
821
|
+
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
|
|
822
|
+
let start = [0, 0];
|
|
823
|
+
let end = null;
|
|
824
|
+
let thickness;
|
|
825
|
+
let height;
|
|
826
|
+
try {
|
|
827
|
+
// Resolve world placement
|
|
828
|
+
const worldMat = wall.ObjectPlacement?.value
|
|
829
|
+
? resolveWorldTransform(ifcApi, modelID, wall.ObjectPlacement.value)
|
|
830
|
+
: identity();
|
|
831
|
+
// Try to get axis polyline (local 2D line along wall)
|
|
832
|
+
const axisPts = getAxisPolyline(ifcApi, modelID, wall);
|
|
833
|
+
if (axisPts && axisPts.length >= 2) {
|
|
834
|
+
const s0 = worldToScene(transformPoint3(worldMat, axisPts[0]));
|
|
835
|
+
const s1 = worldToScene(transformPoint3(worldMat, axisPts[axisPts.length - 1]));
|
|
836
|
+
start = [s0[0], s0[1]];
|
|
837
|
+
end = [s1[0], s1[1]];
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
// Fallback: use placement origin + body XDim for length
|
|
841
|
+
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
|
|
842
|
+
start = [s[0], s[1]];
|
|
843
|
+
// Will try to get length from body below
|
|
844
|
+
}
|
|
845
|
+
// Get body extrusion data for thickness/height
|
|
846
|
+
const body = getBodyExtrusionData(ifcApi, modelID, wall);
|
|
847
|
+
if (body.depth) {
|
|
848
|
+
if (opts.extrusionDepthIsHeight) {
|
|
849
|
+
height = body.depth * unitFactor;
|
|
850
|
+
}
|
|
851
|
+
else {
|
|
852
|
+
thickness = body.depth * unitFactor;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
const dimForThickness = opts.swapProfileDimensions ? body.xDim : body.yDim;
|
|
856
|
+
if (dimForThickness) {
|
|
857
|
+
thickness = dimForThickness * unitFactor;
|
|
858
|
+
}
|
|
859
|
+
else if (body.profilePoints && body.profilePoints.length >= 3) {
|
|
860
|
+
const ys = body.profilePoints.map((p) => p[1]);
|
|
861
|
+
thickness = (Math.max(...ys) - Math.min(...ys)) * unitFactor;
|
|
862
|
+
}
|
|
863
|
+
// If no axis polyline, derive wall length from profile or XDim
|
|
864
|
+
if (!axisPts) {
|
|
865
|
+
let wallLength = body.xDim;
|
|
866
|
+
if (!wallLength && body.profilePoints && body.profilePoints.length >= 3) {
|
|
867
|
+
const xs = body.profilePoints.map((p) => p[0]);
|
|
868
|
+
wallLength = Math.max(...xs) - Math.min(...xs);
|
|
869
|
+
}
|
|
870
|
+
if (wallLength) {
|
|
871
|
+
const se = worldToScene(transformPoint3(worldMat, [wallLength, 0, 0]));
|
|
872
|
+
end = [se[0], se[1]];
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
// keep defaults
|
|
878
|
+
}
|
|
879
|
+
// Skip walls where we couldn't determine geometry
|
|
880
|
+
if (!end)
|
|
881
|
+
continue;
|
|
882
|
+
// Plain IFCWALL frequently carries Brep / mapped geometry rather
|
|
883
|
+
// than a clean IfcExtrudedAreaSolid, so getBodyExtrusionData can't
|
|
884
|
+
// read its height/thickness (only IFCWALLSTANDARDCASE reliably
|
|
885
|
+
// works). First try to recover real dims from the element's mesh
|
|
886
|
+
// bounding box; only then fall back to the editor's wall defaults
|
|
887
|
+
// so the wall renders at a sensible size instead of collapsing to
|
|
888
|
+
// a zero-height sliver (which also breaks the door/window CSG
|
|
889
|
+
// cutouts punched into it).
|
|
890
|
+
if (height === undefined || thickness === undefined) {
|
|
891
|
+
const wallLenM = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
|
892
|
+
if (wallLenM > 1e-6) {
|
|
893
|
+
// Project the wall mesh onto its own axis so length, thickness
|
|
894
|
+
// and height are read in the wall frame (rotation-invariant) —
|
|
895
|
+
// a world-space AABB conflates length and thickness on rotated
|
|
896
|
+
// walls. Axis is the normalised start→end direction in the
|
|
897
|
+
// IFC ground plane, which is also the mapping used for the
|
|
898
|
+
// wall's start/end above.
|
|
899
|
+
const axisX = (end[0] - start[0]) / wallLenM;
|
|
900
|
+
const axisY = (end[1] - start[1]) / wallLenM;
|
|
901
|
+
const extents = measureWallLocalExtents(ifcApi, modelID, wallExpressID, axisX, axisY);
|
|
902
|
+
const geom = extents
|
|
903
|
+
? wallHeightThicknessFromExtents(extents, wallLenM, unitFactor)
|
|
904
|
+
: null;
|
|
905
|
+
if (geom) {
|
|
906
|
+
if (height === undefined)
|
|
907
|
+
height = geom.height;
|
|
908
|
+
if (thickness === undefined)
|
|
909
|
+
thickness = geom.thickness;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (height === undefined)
|
|
914
|
+
height = DEFAULT_WALL_HEIGHT;
|
|
915
|
+
if (thickness === undefined)
|
|
916
|
+
thickness = DEFAULT_WALL_THICKNESS;
|
|
917
|
+
const wallNode = tryParse(WallNode, 'wall', {
|
|
918
|
+
object: 'node',
|
|
919
|
+
id: nodeId,
|
|
920
|
+
type: 'wall',
|
|
921
|
+
name: wall.Name?.value || `Wall ${i + 1}`,
|
|
922
|
+
parentId: parentNodeId || null,
|
|
923
|
+
visible: true,
|
|
924
|
+
start,
|
|
925
|
+
end,
|
|
926
|
+
thickness,
|
|
927
|
+
height,
|
|
928
|
+
frontSide: 'unknown',
|
|
929
|
+
backSide: 'unknown',
|
|
930
|
+
children: [],
|
|
931
|
+
metadata: buildMetadata({
|
|
932
|
+
ifcType: wallType === WebIFC.IFCWALL ? 'IFCWALL' : 'IFCWALLSTANDARDCASE',
|
|
933
|
+
expressID: wallExpressID,
|
|
934
|
+
globalId: wall.GlobalId?.value,
|
|
935
|
+
}),
|
|
936
|
+
});
|
|
937
|
+
nodes[nodeId] = wallNode;
|
|
938
|
+
if (parentNodeId && nodes[parentNodeId]) {
|
|
939
|
+
;
|
|
940
|
+
nodes[parentNodeId].children?.push(nodeId);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
// Process doors and windows via void/fill relationships
|
|
945
|
+
for (const [wallExpressID, openingIds] of wallToOpenings) {
|
|
946
|
+
const wallNodeId = expressIdToNodeId.get(wallExpressID);
|
|
947
|
+
if (!wallNodeId)
|
|
948
|
+
continue;
|
|
949
|
+
const wallNode = nodes[wallNodeId];
|
|
950
|
+
if (!wallNode || wallNode.type !== 'wall')
|
|
951
|
+
continue;
|
|
952
|
+
const wallDx = wallNode.end[0] - wallNode.start[0];
|
|
953
|
+
const wallDy = wallNode.end[1] - wallNode.start[1];
|
|
954
|
+
const wallLength = Math.sqrt(wallDx * wallDx + wallDy * wallDy);
|
|
955
|
+
// Wall world matrix for projecting opening positions
|
|
956
|
+
let wallWorldMat = null;
|
|
957
|
+
try {
|
|
958
|
+
const wall = ifcApi.GetLine(modelID, wallExpressID);
|
|
959
|
+
if (wall.ObjectPlacement?.value) {
|
|
960
|
+
wallWorldMat = resolveWorldTransform(ifcApi, modelID, wall.ObjectPlacement.value);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
/* ignore */
|
|
965
|
+
}
|
|
966
|
+
for (const openingId of openingIds) {
|
|
967
|
+
const fillId = openingToFill.get(openingId);
|
|
968
|
+
if (!fillId)
|
|
969
|
+
continue;
|
|
970
|
+
const isDoor = doorExpressIds.has(fillId);
|
|
971
|
+
const isWindow = windowExpressIds.has(fillId);
|
|
972
|
+
if (!isDoor && !isWindow)
|
|
973
|
+
continue;
|
|
974
|
+
try {
|
|
975
|
+
const element = ifcApi.GetLine(modelID, fillId);
|
|
976
|
+
// Get dimensions from OverallWidth/OverallHeight
|
|
977
|
+
let width;
|
|
978
|
+
let height;
|
|
979
|
+
if (element.OverallWidth?.value)
|
|
980
|
+
width = element.OverallWidth.value * unitFactor;
|
|
981
|
+
if (element.OverallHeight?.value)
|
|
982
|
+
height = element.OverallHeight.value * unitFactor;
|
|
983
|
+
// Compute position along wall from opening placement
|
|
984
|
+
let position;
|
|
985
|
+
try {
|
|
986
|
+
const opening = ifcApi.GetLine(modelID, openingId);
|
|
987
|
+
if (opening.ObjectPlacement?.value) {
|
|
988
|
+
const openingWorldMat = resolveWorldTransform(ifcApi, modelID, opening.ObjectPlacement.value);
|
|
989
|
+
const openingScene = worldToScene(transformPoint3(openingWorldMat, [0, 0, 0]));
|
|
990
|
+
const ox = openingScene[0] - wallNode.start[0];
|
|
991
|
+
const oy = openingScene[1] - wallNode.start[1];
|
|
992
|
+
if (wallLength > 1e-6) {
|
|
993
|
+
const dot = (ox * wallDx + oy * wallDy) / wallLength;
|
|
994
|
+
// Clamp so the opening's [pos - w/2, pos + w/2] footprint
|
|
995
|
+
// stays inside the wall — an overflowing CSG cutout breaks
|
|
996
|
+
// the wall mesh.
|
|
997
|
+
const half = (width ?? 0) / 2;
|
|
998
|
+
const lo = Math.min(half, wallLength / 2);
|
|
999
|
+
const hi = Math.max(wallLength - half, wallLength / 2);
|
|
1000
|
+
position = Math.max(lo, Math.min(hi, dot));
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
catch {
|
|
1005
|
+
/* ignore */
|
|
1006
|
+
}
|
|
1007
|
+
// Get sill height for windows from opening placement Z relative to wall
|
|
1008
|
+
let sillHeight;
|
|
1009
|
+
if (isWindow) {
|
|
1010
|
+
try {
|
|
1011
|
+
const opening = ifcApi.GetLine(modelID, openingId);
|
|
1012
|
+
if (opening.ObjectPlacement?.value) {
|
|
1013
|
+
const openingWorldMat = resolveWorldTransform(ifcApi, modelID, opening.ObjectPlacement.value);
|
|
1014
|
+
const openingScene = worldToScene(transformPoint3(openingWorldMat, [0, 0, 0]));
|
|
1015
|
+
if (wallWorldMat) {
|
|
1016
|
+
const wallScene = worldToScene(transformPoint3(wallWorldMat, [0, 0, 0]));
|
|
1017
|
+
sillHeight = openingScene[2] - wallScene[2];
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
sillHeight = openingScene[2];
|
|
1021
|
+
}
|
|
1022
|
+
if (sillHeight !== undefined && sillHeight < 0.01)
|
|
1023
|
+
sillHeight = undefined;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
/* ignore */
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
if (isDoor) {
|
|
1031
|
+
const nodeId = generateId('door');
|
|
1032
|
+
expressIdToNodeId.set(fillId, nodeId);
|
|
1033
|
+
// Vertical centering is now handled: door center Y = height/2 so the
|
|
1034
|
+
// opening sits at the correct position. Remaining caveat: door bottom
|
|
1035
|
+
// is assumed at floor y=0 (i.e. the door starts at the wall's base).
|
|
1036
|
+
// Defaults match @pascal-app/core door schema fallbacks.
|
|
1037
|
+
const doorPosition = [position ?? 0, (height ?? 2.1) / 2, 0];
|
|
1038
|
+
const doorNode = tryParse(DoorNode, 'door', {
|
|
1039
|
+
object: 'node',
|
|
1040
|
+
id: nodeId,
|
|
1041
|
+
type: 'door',
|
|
1042
|
+
name: element.Name?.value || `Door`,
|
|
1043
|
+
parentId: wallNodeId,
|
|
1044
|
+
visible: true,
|
|
1045
|
+
width: width ?? 0.9,
|
|
1046
|
+
height: height ?? 2.1,
|
|
1047
|
+
position: doorPosition,
|
|
1048
|
+
metadata: buildMetadata({
|
|
1049
|
+
ifcType: 'IFCDOOR',
|
|
1050
|
+
expressID: fillId,
|
|
1051
|
+
globalId: element.GlobalId?.value,
|
|
1052
|
+
hostWallExpressID: wallExpressID,
|
|
1053
|
+
}),
|
|
1054
|
+
});
|
|
1055
|
+
nodes[nodeId] = doorNode;
|
|
1056
|
+
wallNode.children.push(nodeId);
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
const nodeId = generateId('window');
|
|
1060
|
+
expressIdToNodeId.set(fillId, nodeId);
|
|
1061
|
+
// TODO(ifc-fix): same scalar-vs-tuple position issue as door above.
|
|
1062
|
+
// sillHeight stays read-only metadata until we resolve the window
|
|
1063
|
+
// schema (Pascal's WindowNode doesn't have sillHeight today —
|
|
1064
|
+
// moved to metadata for now so we don't lose the value).
|
|
1065
|
+
const windowPosition = [
|
|
1066
|
+
position ?? 0,
|
|
1067
|
+
(sillHeight ?? 0) + (height ?? 1.2) / 2,
|
|
1068
|
+
0,
|
|
1069
|
+
];
|
|
1070
|
+
const windowNode = tryParse(WindowNode, 'window', {
|
|
1071
|
+
object: 'node',
|
|
1072
|
+
id: nodeId,
|
|
1073
|
+
type: 'window',
|
|
1074
|
+
name: element.Name?.value || `Window`,
|
|
1075
|
+
parentId: wallNodeId,
|
|
1076
|
+
visible: true,
|
|
1077
|
+
width: width ?? 1.0,
|
|
1078
|
+
height: height ?? 1.2,
|
|
1079
|
+
position: windowPosition,
|
|
1080
|
+
metadata: buildMetadata({
|
|
1081
|
+
ifcType: 'IFCWINDOW',
|
|
1082
|
+
expressID: fillId,
|
|
1083
|
+
globalId: element.GlobalId?.value,
|
|
1084
|
+
hostWallExpressID: wallExpressID,
|
|
1085
|
+
sillHeight,
|
|
1086
|
+
}),
|
|
1087
|
+
});
|
|
1088
|
+
nodes[nodeId] = windowNode;
|
|
1089
|
+
wallNode.children.push(nodeId);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
// skip this opening
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
// Process any doors/windows NOT linked via the void/fill chain. Some
|
|
1098
|
+
// exporters (e.g. the Paris sample) carve openings with
|
|
1099
|
+
// IFCRELVOIDSELEMENT but omit IFCRELFILLSELEMENT, so the
|
|
1100
|
+
// door/window → wall link is only implicit in the element's world
|
|
1101
|
+
// placement. We recover it by projecting the element's world position
|
|
1102
|
+
// onto the nearest wall segment. Anything farther than
|
|
1103
|
+
// HOST_WALL_MAX_DIST from every wall stays parented to its spatial
|
|
1104
|
+
// container at the origin — we have no basis to place it on a wall.
|
|
1105
|
+
const HOST_WALL_MAX_DIST = 1.0; // metres
|
|
1106
|
+
const wallInfos = [];
|
|
1107
|
+
for (const [wallExpressId, wallNodeId] of expressIdToNodeId) {
|
|
1108
|
+
const node = nodes[wallNodeId];
|
|
1109
|
+
if (!node || node.type !== 'wall')
|
|
1110
|
+
continue;
|
|
1111
|
+
const w = node;
|
|
1112
|
+
const length = Math.hypot(w.end[0] - w.start[0], w.end[1] - w.start[1]);
|
|
1113
|
+
if (length < 1e-6)
|
|
1114
|
+
continue;
|
|
1115
|
+
let baseY = 0;
|
|
1116
|
+
try {
|
|
1117
|
+
const wall = ifcApi.GetLine(modelID, wallExpressId);
|
|
1118
|
+
if (wall.ObjectPlacement?.value) {
|
|
1119
|
+
const m = resolveWorldTransform(ifcApi, modelID, wall.ObjectPlacement.value);
|
|
1120
|
+
baseY = worldToScene(transformPoint3(m, [0, 0, 0]))[2];
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
catch {
|
|
1124
|
+
/* keep baseY = 0 */
|
|
1125
|
+
}
|
|
1126
|
+
wallInfos.push({ nodeId: wallNodeId, start: w.start, end: w.end, length, baseY });
|
|
1127
|
+
}
|
|
1128
|
+
// Pick the host wall for an opening at ground-plane point [x, y] with
|
|
1129
|
+
// the given width. Among walls within HOST_WALL_MAX_DIST
|
|
1130
|
+
// perpendicular, prefer one long enough to contain the opening — the
|
|
1131
|
+
// castle's IFCWALL decomposition leaves tiny stub walls near corners,
|
|
1132
|
+
// and a plain nearest-by-distance match would snap (say) a 0.7m door
|
|
1133
|
+
// onto a 0.4m stub, so its CSG cutout overflows and breaks the wall.
|
|
1134
|
+
// Returns the along-wall position already clamped so the opening
|
|
1135
|
+
// footprint stays inside the wall.
|
|
1136
|
+
const findHostWall = (x, y, width) => {
|
|
1137
|
+
let best = null;
|
|
1138
|
+
for (const info of wallInfos) {
|
|
1139
|
+
const dx = info.end[0] - info.start[0];
|
|
1140
|
+
const dy = info.end[1] - info.start[1];
|
|
1141
|
+
const lenSq = dx * dx + dy * dy;
|
|
1142
|
+
if (lenSq < 1e-9)
|
|
1143
|
+
continue;
|
|
1144
|
+
const t = Math.max(0, Math.min(1, ((x - info.start[0]) * dx + (y - info.start[1]) * dy) / lenSq));
|
|
1145
|
+
const footX = info.start[0] + t * dx;
|
|
1146
|
+
const footY = info.start[1] + t * dy;
|
|
1147
|
+
const dist = Math.hypot(x - footX, y - footY);
|
|
1148
|
+
if (dist > HOST_WALL_MAX_DIST)
|
|
1149
|
+
continue;
|
|
1150
|
+
// Keep the [along - width/2, along + width/2] span inside the wall.
|
|
1151
|
+
const half = width / 2;
|
|
1152
|
+
const lo = Math.min(half, info.length / 2);
|
|
1153
|
+
const hi = Math.max(info.length - half, info.length / 2);
|
|
1154
|
+
const along = Math.max(lo, Math.min(hi, t * info.length));
|
|
1155
|
+
const fits = info.length + 1e-6 >= width;
|
|
1156
|
+
const cand = { info, along, dist, fits };
|
|
1157
|
+
if (!best) {
|
|
1158
|
+
best = cand;
|
|
1159
|
+
}
|
|
1160
|
+
else if (cand.fits !== best.fits) {
|
|
1161
|
+
if (cand.fits)
|
|
1162
|
+
best = cand;
|
|
1163
|
+
}
|
|
1164
|
+
else if (cand.dist < best.dist) {
|
|
1165
|
+
best = cand;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
return best;
|
|
1169
|
+
};
|
|
1170
|
+
for (const fillId of [...doorExpressIds, ...windowExpressIds]) {
|
|
1171
|
+
if (expressIdToNodeId.has(fillId))
|
|
1172
|
+
continue;
|
|
1173
|
+
try {
|
|
1174
|
+
const element = ifcApi.GetLine(modelID, fillId);
|
|
1175
|
+
const isDoor = doorExpressIds.has(fillId);
|
|
1176
|
+
let width;
|
|
1177
|
+
let height;
|
|
1178
|
+
if (element.OverallWidth?.value)
|
|
1179
|
+
width = element.OverallWidth.value * unitFactor;
|
|
1180
|
+
if (element.OverallHeight?.value)
|
|
1181
|
+
height = element.OverallHeight.value * unitFactor;
|
|
1182
|
+
// Element world placement → scene point. Ground plane is [x, y]
|
|
1183
|
+
// (IFC X/Y, matching wall start/end); vertical is component [2].
|
|
1184
|
+
let scene = null;
|
|
1185
|
+
try {
|
|
1186
|
+
if (element.ObjectPlacement?.value) {
|
|
1187
|
+
const m = resolveWorldTransform(ifcApi, modelID, element.ObjectPlacement.value);
|
|
1188
|
+
scene = worldToScene(transformPoint3(m, [0, 0, 0]));
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
/* no placement */
|
|
1193
|
+
}
|
|
1194
|
+
const effWidth = width ?? (isDoor ? 0.9 : 1.0);
|
|
1195
|
+
const hosted = scene ? findHostWall(scene[0], scene[1], effWidth) : null;
|
|
1196
|
+
// When hosted, parent to (and live inside) the wall — same as the
|
|
1197
|
+
// void/fill path. Otherwise fall back to the spatial container.
|
|
1198
|
+
const containerExpressID = parentMap.get(fillId);
|
|
1199
|
+
const containerNodeId = containerExpressID
|
|
1200
|
+
? (expressIdToNodeId.get(containerExpressID) ?? null)
|
|
1201
|
+
: null;
|
|
1202
|
+
const parentNodeId = hosted ? hosted.info.nodeId : containerNodeId;
|
|
1203
|
+
if (isDoor) {
|
|
1204
|
+
const h = height ?? 2.1;
|
|
1205
|
+
const nodeId = generateId('door');
|
|
1206
|
+
expressIdToNodeId.set(fillId, nodeId);
|
|
1207
|
+
const doorNode = tryParse(DoorNode, 'door', {
|
|
1208
|
+
object: 'node',
|
|
1209
|
+
id: nodeId,
|
|
1210
|
+
type: 'door',
|
|
1211
|
+
name: element.Name?.value || `Door`,
|
|
1212
|
+
parentId: parentNodeId,
|
|
1213
|
+
visible: true,
|
|
1214
|
+
width: width ?? 0.9,
|
|
1215
|
+
height: h,
|
|
1216
|
+
// Placed by nearest-wall projection; [0,0,0] only when no wall
|
|
1217
|
+
// is within range (then it sits on its spatial container).
|
|
1218
|
+
position: hosted ? [hosted.along, h / 2, 0] : [0, 0, 0],
|
|
1219
|
+
...(hosted ? { wallId: hosted.info.nodeId } : {}),
|
|
1220
|
+
metadata: buildMetadata({
|
|
1221
|
+
ifcType: 'IFCDOOR',
|
|
1222
|
+
expressID: fillId,
|
|
1223
|
+
globalId: element.GlobalId?.value,
|
|
1224
|
+
}),
|
|
1225
|
+
});
|
|
1226
|
+
nodes[nodeId] = doorNode;
|
|
1227
|
+
if (parentNodeId && nodes[parentNodeId]) {
|
|
1228
|
+
;
|
|
1229
|
+
nodes[parentNodeId].children?.push(nodeId);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
else {
|
|
1233
|
+
const h = height ?? 1.2;
|
|
1234
|
+
const sill = hosted && scene ? Math.max(0, scene[2] - hosted.info.baseY) : 0;
|
|
1235
|
+
const nodeId = generateId('window');
|
|
1236
|
+
expressIdToNodeId.set(fillId, nodeId);
|
|
1237
|
+
const windowNode = tryParse(WindowNode, 'window', {
|
|
1238
|
+
object: 'node',
|
|
1239
|
+
id: nodeId,
|
|
1240
|
+
type: 'window',
|
|
1241
|
+
name: element.Name?.value || `Window`,
|
|
1242
|
+
parentId: parentNodeId,
|
|
1243
|
+
visible: true,
|
|
1244
|
+
width: width ?? 1.0,
|
|
1245
|
+
height: h,
|
|
1246
|
+
position: hosted ? [hosted.along, sill + h / 2, 0] : [0, 0, 0],
|
|
1247
|
+
...(hosted ? { wallId: hosted.info.nodeId } : {}),
|
|
1248
|
+
metadata: buildMetadata({
|
|
1249
|
+
ifcType: 'IFCWINDOW',
|
|
1250
|
+
expressID: fillId,
|
|
1251
|
+
globalId: element.GlobalId?.value,
|
|
1252
|
+
...(hosted ? { sillHeight: sill } : {}),
|
|
1253
|
+
}),
|
|
1254
|
+
});
|
|
1255
|
+
nodes[nodeId] = windowNode;
|
|
1256
|
+
if (parentNodeId && nodes[parentNodeId]) {
|
|
1257
|
+
;
|
|
1258
|
+
nodes[parentNodeId].children?.push(nodeId);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
catch {
|
|
1263
|
+
// skip
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
// Process slabs
|
|
1267
|
+
const slabs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSLAB);
|
|
1268
|
+
console.log(`[IFC→Pascal] Found ${slabs.size()} slabs`);
|
|
1269
|
+
for (let i = 0; i < slabs.size(); i++) {
|
|
1270
|
+
const slabExpressID = slabs.get(i);
|
|
1271
|
+
const slab = ifcApi.GetLine(modelID, slabExpressID);
|
|
1272
|
+
const nodeId = generateId('slab');
|
|
1273
|
+
expressIdToNodeId.set(slabExpressID, nodeId);
|
|
1274
|
+
const parentExpressID = parentMap.get(slabExpressID);
|
|
1275
|
+
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
|
|
1276
|
+
let polygon = null;
|
|
1277
|
+
let elevation = 0;
|
|
1278
|
+
let thickness;
|
|
1279
|
+
try {
|
|
1280
|
+
// Resolve world placement for the slab element
|
|
1281
|
+
const worldMat = slab.ObjectPlacement?.value
|
|
1282
|
+
? resolveWorldTransform(ifcApi, modelID, slab.ObjectPlacement.value)
|
|
1283
|
+
: identity();
|
|
1284
|
+
// Get elevation from placement Z
|
|
1285
|
+
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
|
|
1286
|
+
elevation = s[2];
|
|
1287
|
+
// Get body extrusion data
|
|
1288
|
+
const body = getBodyExtrusionData(ifcApi, modelID, slab);
|
|
1289
|
+
// Extrusion depth is slab thickness
|
|
1290
|
+
if (body.depth) {
|
|
1291
|
+
thickness = body.depth * unitFactor;
|
|
1292
|
+
}
|
|
1293
|
+
// Extrusion Position provides an additional local offset for the profile
|
|
1294
|
+
const extrusionMat = getExtrusionPosition(ifcApi, modelID, slab);
|
|
1295
|
+
if (body.profilePoints && body.profilePoints.length >= 3) {
|
|
1296
|
+
const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat;
|
|
1297
|
+
polygon = body.profilePoints.map((pt) => {
|
|
1298
|
+
const sc = worldToScene(transformPoint3(combinedMat, [pt[0], pt[1], 0]));
|
|
1299
|
+
return [sc[0], sc[1]];
|
|
1300
|
+
});
|
|
1301
|
+
const first = polygon[0];
|
|
1302
|
+
const last = polygon[polygon.length - 1];
|
|
1303
|
+
if (polygon.length > 3 &&
|
|
1304
|
+
Math.abs(first[0] - last[0]) < 1e-6 &&
|
|
1305
|
+
Math.abs(first[1] - last[1]) < 1e-6) {
|
|
1306
|
+
polygon.pop();
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
else if (body.xDim && body.yDim) {
|
|
1310
|
+
const hw = body.xDim / 2;
|
|
1311
|
+
const hh = body.yDim / 2;
|
|
1312
|
+
const corners = [
|
|
1313
|
+
[-hw, -hh, 0],
|
|
1314
|
+
[hw, -hh, 0],
|
|
1315
|
+
[hw, hh, 0],
|
|
1316
|
+
[-hw, hh, 0],
|
|
1317
|
+
];
|
|
1318
|
+
const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat;
|
|
1319
|
+
polygon = corners.map((c) => {
|
|
1320
|
+
const sc = worldToScene(transformPoint3(combinedMat, c));
|
|
1321
|
+
return [sc[0], sc[1]];
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
catch {
|
|
1326
|
+
// keep defaults
|
|
1327
|
+
}
|
|
1328
|
+
// Skip slabs where we couldn't extract a polygon
|
|
1329
|
+
if (!polygon || polygon.length < 3)
|
|
1330
|
+
continue;
|
|
1331
|
+
const slabNode = tryParse(SlabNode, 'slab', {
|
|
1332
|
+
object: 'node',
|
|
1333
|
+
id: nodeId,
|
|
1334
|
+
type: 'slab',
|
|
1335
|
+
name: slab.Name?.value || `Slab ${i + 1}`,
|
|
1336
|
+
parentId: parentNodeId || null,
|
|
1337
|
+
visible: true,
|
|
1338
|
+
polygon,
|
|
1339
|
+
holes: [],
|
|
1340
|
+
elevation,
|
|
1341
|
+
// TODO(ifc-fix): Pascal SlabNode has no `thickness` field — moved
|
|
1342
|
+
// to metadata so the IFC value isn't lost.
|
|
1343
|
+
metadata: buildMetadata({
|
|
1344
|
+
ifcType: 'IFCSLAB',
|
|
1345
|
+
expressID: slabExpressID,
|
|
1346
|
+
globalId: slab.GlobalId?.value,
|
|
1347
|
+
thickness,
|
|
1348
|
+
}),
|
|
1349
|
+
});
|
|
1350
|
+
nodes[nodeId] = slabNode;
|
|
1351
|
+
if (parentNodeId && nodes[parentNodeId]) {
|
|
1352
|
+
;
|
|
1353
|
+
nodes[parentNodeId].children?.push(nodeId);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
// Process stairs
|
|
1357
|
+
const stairs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSTAIR);
|
|
1358
|
+
for (let i = 0; i < stairs.size(); i++) {
|
|
1359
|
+
const stairExpressID = stairs.get(i);
|
|
1360
|
+
if (expressIdToNodeId.has(stairExpressID))
|
|
1361
|
+
continue;
|
|
1362
|
+
const stair = ifcApi.GetLine(modelID, stairExpressID);
|
|
1363
|
+
const nodeId = generateId('stair');
|
|
1364
|
+
expressIdToNodeId.set(stairExpressID, nodeId);
|
|
1365
|
+
const parentExpressID = parentMap.get(stairExpressID);
|
|
1366
|
+
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
|
|
1367
|
+
let position = [0, 0, 0];
|
|
1368
|
+
let boundingBox;
|
|
1369
|
+
try {
|
|
1370
|
+
const worldMat = stair.ObjectPlacement?.value
|
|
1371
|
+
? resolveWorldTransform(ifcApi, modelID, stair.ObjectPlacement.value)
|
|
1372
|
+
: identity();
|
|
1373
|
+
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
|
|
1374
|
+
position = opts.swapYZ ? [s[0], s[2], s[1]] : [s[0], s[1], s[2]];
|
|
1375
|
+
// Try stair's own body first
|
|
1376
|
+
const body = getBodyExtrusionData(ifcApi, modelID, stair);
|
|
1377
|
+
if (body.xDim && body.yDim && body.depth) {
|
|
1378
|
+
boundingBox = opts.swapYZ
|
|
1379
|
+
? [body.xDim * unitFactor, body.depth * unitFactor, body.yDim * unitFactor]
|
|
1380
|
+
: [body.xDim * unitFactor, body.yDim * unitFactor, body.depth * unitFactor];
|
|
1381
|
+
}
|
|
1382
|
+
// If no body, try to derive from stair flight children
|
|
1383
|
+
if (!boundingBox) {
|
|
1384
|
+
const stairChildren = childrenMap.get(stairExpressID) ?? [];
|
|
1385
|
+
for (const childId of stairChildren) {
|
|
1386
|
+
try {
|
|
1387
|
+
const child = ifcApi.GetLine(modelID, childId);
|
|
1388
|
+
// Check for NumberOfRisers / RiserHeight / TreadLength
|
|
1389
|
+
const nRisers = child.NumberOfRisers?.value ?? child.NumberOfRiser?.value;
|
|
1390
|
+
const riserHeight = child.RiserHeight?.value;
|
|
1391
|
+
const treadLength = child.TreadLength?.value;
|
|
1392
|
+
if (nRisers && riserHeight && treadLength) {
|
|
1393
|
+
const totalHeight = nRisers * riserHeight * unitFactor;
|
|
1394
|
+
const totalRun = (nRisers - 1) * treadLength * unitFactor;
|
|
1395
|
+
const width = 1.0; // Default stair width
|
|
1396
|
+
const flightBody = getBodyExtrusionData(ifcApi, modelID, child);
|
|
1397
|
+
const stairWidth = flightBody.yDim ? flightBody.yDim * unitFactor : width;
|
|
1398
|
+
boundingBox = opts.swapYZ
|
|
1399
|
+
? [totalRun || 1, totalHeight, stairWidth]
|
|
1400
|
+
: [totalRun || 1, stairWidth, totalHeight];
|
|
1401
|
+
break;
|
|
1402
|
+
}
|
|
1403
|
+
// Fallback: try flight body extrusion
|
|
1404
|
+
const flightBody = getBodyExtrusionData(ifcApi, modelID, child);
|
|
1405
|
+
if (flightBody.xDim && flightBody.yDim && flightBody.depth) {
|
|
1406
|
+
boundingBox = opts.swapYZ
|
|
1407
|
+
? [
|
|
1408
|
+
flightBody.xDim * unitFactor,
|
|
1409
|
+
flightBody.depth * unitFactor,
|
|
1410
|
+
flightBody.yDim * unitFactor,
|
|
1411
|
+
]
|
|
1412
|
+
: [
|
|
1413
|
+
flightBody.xDim * unitFactor,
|
|
1414
|
+
flightBody.yDim * unitFactor,
|
|
1415
|
+
flightBody.depth * unitFactor,
|
|
1416
|
+
];
|
|
1417
|
+
break;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
catch {
|
|
1421
|
+
/* skip child */
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
catch {
|
|
1427
|
+
/* keep defaults */
|
|
1428
|
+
}
|
|
1429
|
+
const stairNode = tryParse(StairNode, 'stair', {
|
|
1430
|
+
object: 'node',
|
|
1431
|
+
id: nodeId,
|
|
1432
|
+
type: 'stair',
|
|
1433
|
+
name: stair.Name?.value || `Stair ${i + 1}`,
|
|
1434
|
+
parentId: parentNodeId || null,
|
|
1435
|
+
visible: true,
|
|
1436
|
+
position,
|
|
1437
|
+
children: [],
|
|
1438
|
+
// TODO(ifc-fix): Pascal StairNode is parametric (segments / treads /
|
|
1439
|
+
// risers). The converter only knows the bounding box right now;
|
|
1440
|
+
// keep it in metadata until we map IFC stairs onto the parametric
|
|
1441
|
+
// shape (or extend StairNode with a raw-geometry escape hatch).
|
|
1442
|
+
metadata: buildMetadata({
|
|
1443
|
+
ifcType: 'IFCSTAIR',
|
|
1444
|
+
expressID: stairExpressID,
|
|
1445
|
+
globalId: stair.GlobalId?.value,
|
|
1446
|
+
predefinedType: stair.PredefinedType?.value,
|
|
1447
|
+
boundingBox,
|
|
1448
|
+
}),
|
|
1449
|
+
});
|
|
1450
|
+
nodes[nodeId] = stairNode;
|
|
1451
|
+
if (parentNodeId && nodes[parentNodeId]) {
|
|
1452
|
+
;
|
|
1453
|
+
nodes[parentNodeId].children?.push(nodeId);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
// Process roofs
|
|
1457
|
+
const roofs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCROOF);
|
|
1458
|
+
for (let i = 0; i < roofs.size(); i++) {
|
|
1459
|
+
const roofExpressID = roofs.get(i);
|
|
1460
|
+
if (expressIdToNodeId.has(roofExpressID))
|
|
1461
|
+
continue;
|
|
1462
|
+
const roof = ifcApi.GetLine(modelID, roofExpressID);
|
|
1463
|
+
const nodeId = generateId('roof');
|
|
1464
|
+
expressIdToNodeId.set(roofExpressID, nodeId);
|
|
1465
|
+
const parentExpressID = parentMap.get(roofExpressID);
|
|
1466
|
+
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
|
|
1467
|
+
let polygon;
|
|
1468
|
+
let elevation;
|
|
1469
|
+
let height;
|
|
1470
|
+
try {
|
|
1471
|
+
const worldMat = roof.ObjectPlacement?.value
|
|
1472
|
+
? resolveWorldTransform(ifcApi, modelID, roof.ObjectPlacement.value)
|
|
1473
|
+
: identity();
|
|
1474
|
+
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
|
|
1475
|
+
elevation = s[2];
|
|
1476
|
+
const body = getBodyExtrusionData(ifcApi, modelID, roof);
|
|
1477
|
+
if (body.depth)
|
|
1478
|
+
height = body.depth * unitFactor;
|
|
1479
|
+
const extrusionMat = getExtrusionPosition(ifcApi, modelID, roof);
|
|
1480
|
+
if (body.profilePoints && body.profilePoints.length >= 3) {
|
|
1481
|
+
const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat;
|
|
1482
|
+
polygon = body.profilePoints.map((pt) => {
|
|
1483
|
+
const sc = worldToScene(transformPoint3(combinedMat, [pt[0], pt[1], 0]));
|
|
1484
|
+
return [sc[0], sc[1]];
|
|
1485
|
+
});
|
|
1486
|
+
const first = polygon[0];
|
|
1487
|
+
const last = polygon[polygon.length - 1];
|
|
1488
|
+
if (polygon.length > 3 &&
|
|
1489
|
+
Math.abs(first[0] - last[0]) < 1e-6 &&
|
|
1490
|
+
Math.abs(first[1] - last[1]) < 1e-6) {
|
|
1491
|
+
polygon.pop();
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
else if (body.xDim && body.yDim) {
|
|
1495
|
+
const hw = body.xDim / 2;
|
|
1496
|
+
const hh = body.yDim / 2;
|
|
1497
|
+
const corners = [
|
|
1498
|
+
[-hw, -hh, 0],
|
|
1499
|
+
[hw, -hh, 0],
|
|
1500
|
+
[hw, hh, 0],
|
|
1501
|
+
[-hw, hh, 0],
|
|
1502
|
+
];
|
|
1503
|
+
const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat;
|
|
1504
|
+
polygon = corners.map((c) => {
|
|
1505
|
+
const sc = worldToScene(transformPoint3(combinedMat, c));
|
|
1506
|
+
return [sc[0], sc[1]];
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
catch {
|
|
1511
|
+
/* keep defaults */
|
|
1512
|
+
}
|
|
1513
|
+
const roofNode = tryParse(RoofNode, 'roof', {
|
|
1514
|
+
object: 'node',
|
|
1515
|
+
id: nodeId,
|
|
1516
|
+
type: 'roof',
|
|
1517
|
+
name: roof.Name?.value || `Roof ${i + 1}`,
|
|
1518
|
+
parentId: parentNodeId || null,
|
|
1519
|
+
visible: true,
|
|
1520
|
+
elevation,
|
|
1521
|
+
// TODO(ifc-fix): Pascal RoofNode is composed of roof-segments. The
|
|
1522
|
+
// converter only has the flat polygon + height; pass them through
|
|
1523
|
+
// metadata until we map the IFC roof onto the segment-based shape.
|
|
1524
|
+
metadata: buildMetadata({
|
|
1525
|
+
ifcType: 'IFCROOF',
|
|
1526
|
+
expressID: roofExpressID,
|
|
1527
|
+
globalId: roof.GlobalId?.value,
|
|
1528
|
+
predefinedType: roof.PredefinedType?.value,
|
|
1529
|
+
polygon,
|
|
1530
|
+
height,
|
|
1531
|
+
}),
|
|
1532
|
+
});
|
|
1533
|
+
nodes[nodeId] = roofNode;
|
|
1534
|
+
if (parentNodeId && nodes[parentNodeId]) {
|
|
1535
|
+
;
|
|
1536
|
+
nodes[parentNodeId].children?.push(nodeId);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
// Process columns
|
|
1540
|
+
const columnTypes = [WebIFC.IFCCOLUMN];
|
|
1541
|
+
try {
|
|
1542
|
+
columnTypes.push(WebIFC.IFCCOLUMNSTANDARDCASE);
|
|
1543
|
+
}
|
|
1544
|
+
catch {
|
|
1545
|
+
/* not in all versions */
|
|
1546
|
+
}
|
|
1547
|
+
for (const colType of columnTypes) {
|
|
1548
|
+
let cols;
|
|
1549
|
+
try {
|
|
1550
|
+
cols = ifcApi.GetLineIDsWithType(modelID, colType);
|
|
1551
|
+
}
|
|
1552
|
+
catch {
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
for (let i = 0; i < cols.size(); i++) {
|
|
1556
|
+
const colExpressID = cols.get(i);
|
|
1557
|
+
if (expressIdToNodeId.has(colExpressID))
|
|
1558
|
+
continue;
|
|
1559
|
+
const col = ifcApi.GetLine(modelID, colExpressID);
|
|
1560
|
+
const nodeId = generateId('column');
|
|
1561
|
+
expressIdToNodeId.set(colExpressID, nodeId);
|
|
1562
|
+
const parentExpressID = parentMap.get(colExpressID);
|
|
1563
|
+
const parentNodeId = parentExpressID ? expressIdToNodeId.get(parentExpressID) : null;
|
|
1564
|
+
let position = [0, 0, 0];
|
|
1565
|
+
let width;
|
|
1566
|
+
let depth;
|
|
1567
|
+
let height;
|
|
1568
|
+
let profileShape = null;
|
|
1569
|
+
let profileRadius;
|
|
1570
|
+
try {
|
|
1571
|
+
const worldMat = col.ObjectPlacement?.value
|
|
1572
|
+
? resolveWorldTransform(ifcApi, modelID, col.ObjectPlacement.value)
|
|
1573
|
+
: identity();
|
|
1574
|
+
const s = worldToScene(transformPoint3(worldMat, [0, 0, 0]));
|
|
1575
|
+
position = opts.swapYZ ? [s[0], s[2], s[1]] : [s[0], s[1], s[2]];
|
|
1576
|
+
const body = getBodyExtrusionData(ifcApi, modelID, col);
|
|
1577
|
+
if (body.depth)
|
|
1578
|
+
height = body.depth * unitFactor;
|
|
1579
|
+
if (opts.swapProfileDimensions) {
|
|
1580
|
+
if (body.yDim)
|
|
1581
|
+
width = body.yDim * unitFactor;
|
|
1582
|
+
if (body.xDim)
|
|
1583
|
+
depth = body.xDim * unitFactor;
|
|
1584
|
+
}
|
|
1585
|
+
else {
|
|
1586
|
+
if (body.xDim)
|
|
1587
|
+
width = body.xDim * unitFactor;
|
|
1588
|
+
if (body.yDim)
|
|
1589
|
+
depth = body.yDim * unitFactor;
|
|
1590
|
+
}
|
|
1591
|
+
profileShape = body.profileShape;
|
|
1592
|
+
if (body.radius != null)
|
|
1593
|
+
profileRadius = body.radius * unitFactor;
|
|
1594
|
+
}
|
|
1595
|
+
catch {
|
|
1596
|
+
/* keep defaults */
|
|
1597
|
+
}
|
|
1598
|
+
// Structural IFC columns are plain shafts. The ColumnNode defaults
|
|
1599
|
+
// are decorative (round-rings base + simple capital + a necked
|
|
1600
|
+
// shaft → a classical/Greek look), and the default round
|
|
1601
|
+
// cross-section ignores width/depth in favour of `radius`. Strip
|
|
1602
|
+
// the ornament, keep the shaft full-width, and size from the IFC
|
|
1603
|
+
// profile: use the real swept-area profile type when known
|
|
1604
|
+
// (IfcCircleProfileDef → round + radius, IfcRectangleProfileDef →
|
|
1605
|
+
// rectangular + width/depth), falling back to the width/depth
|
|
1606
|
+
// ratio when the profile type isn't recognised.
|
|
1607
|
+
const isRect = profileShape === 'rectangular' ||
|
|
1608
|
+
(profileShape === null &&
|
|
1609
|
+
width !== undefined &&
|
|
1610
|
+
depth !== undefined &&
|
|
1611
|
+
Math.abs(width - depth) > 0.15 * Math.max(width, depth));
|
|
1612
|
+
const columnNode = tryParse(ColumnNode, 'column', {
|
|
1613
|
+
object: 'node',
|
|
1614
|
+
id: nodeId,
|
|
1615
|
+
type: 'column',
|
|
1616
|
+
name: col.Name?.value || `Column ${i + 1}`,
|
|
1617
|
+
parentId: parentNodeId || null,
|
|
1618
|
+
visible: true,
|
|
1619
|
+
position,
|
|
1620
|
+
width,
|
|
1621
|
+
depth,
|
|
1622
|
+
height,
|
|
1623
|
+
crossSection: isRect ? 'rectangular' : 'round',
|
|
1624
|
+
radius: profileRadius ?? Math.max(width ?? 0.44, depth ?? 0.44) / 2,
|
|
1625
|
+
style: 'plain',
|
|
1626
|
+
shaftProfile: 'straight',
|
|
1627
|
+
shaftStartScale: 1,
|
|
1628
|
+
shaftEndScale: 1,
|
|
1629
|
+
shaftSegmentCount: 1,
|
|
1630
|
+
baseStyle: 'none',
|
|
1631
|
+
capitalStyle: 'none',
|
|
1632
|
+
baseHeight: 0,
|
|
1633
|
+
capitalHeight: 0,
|
|
1634
|
+
metadata: buildMetadata({
|
|
1635
|
+
ifcType: 'IFCCOLUMN',
|
|
1636
|
+
expressID: colExpressID,
|
|
1637
|
+
globalId: col.GlobalId?.value,
|
|
1638
|
+
}),
|
|
1639
|
+
});
|
|
1640
|
+
nodes[nodeId] = columnNode;
|
|
1641
|
+
if (parentNodeId && nodes[parentNodeId]) {
|
|
1642
|
+
;
|
|
1643
|
+
nodes[parentNodeId].children?.push(nodeId);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
// Beams: skipped for now — Pascal has no `beam` node type yet. When it
|
|
1648
|
+
// lands in @pascal-app/core, restore the IFCBEAM → BeamNode mapping
|
|
1649
|
+
// (axis polyline → start/end [x,y,z], profile XDim/YDim → width/depth,
|
|
1650
|
+
// extrusion depth → axis length). Reference implementation lives in
|
|
1651
|
+
// git history of this file. We still walk the entities to log how
|
|
1652
|
+
// many beams the IFC contained so the conversion summary is accurate.
|
|
1653
|
+
let skippedBeamCount = 0;
|
|
1654
|
+
const beamTypes = [WebIFC.IFCBEAM];
|
|
1655
|
+
try {
|
|
1656
|
+
beamTypes.push(WebIFC.IFCBEAMSTANDARDCASE);
|
|
1657
|
+
}
|
|
1658
|
+
catch {
|
|
1659
|
+
/* not in all versions */
|
|
1660
|
+
}
|
|
1661
|
+
for (const beamType of beamTypes) {
|
|
1662
|
+
try {
|
|
1663
|
+
const beams = ifcApi.GetLineIDsWithType(modelID, beamType);
|
|
1664
|
+
skippedBeamCount += beams.size();
|
|
1665
|
+
}
|
|
1666
|
+
catch {
|
|
1667
|
+
/* type not present in this file */
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
if (skippedBeamCount > 0) {
|
|
1671
|
+
console.warn(`[IFC→Pascal] Skipped ${skippedBeamCount} beam${skippedBeamCount === 1 ? '' : 's'} — Pascal has no beam node yet.`);
|
|
1672
|
+
}
|
|
1673
|
+
// Items: skipped for now — Pascal's ItemNode requires a full `asset`
|
|
1674
|
+
// (catalog reference with id/src/dimensions/etc.) that the converter
|
|
1675
|
+
// can't synthesise from raw IFC geometry. When the editor grows a
|
|
1676
|
+
// raw-geometry escape hatch (or we add a placeholder-asset registry),
|
|
1677
|
+
// restore the mapping from the pre-migration git history. We still
|
|
1678
|
+
// walk the entities to log a count for diagnostics.
|
|
1679
|
+
let skippedItemCount = 0;
|
|
1680
|
+
const itemTypeKeys = [
|
|
1681
|
+
WebIFC.IFCFURNISHINGELEMENT,
|
|
1682
|
+
WebIFC.IFCBUILDINGELEMENTPROXY,
|
|
1683
|
+
WebIFC.IFCRAILING,
|
|
1684
|
+
WebIFC.IFCCOVERING,
|
|
1685
|
+
WebIFC.IFCCURTAINWALL,
|
|
1686
|
+
WebIFC.IFCPLATE,
|
|
1687
|
+
WebIFC.IFCMEMBER,
|
|
1688
|
+
WebIFC.IFCFOOTING,
|
|
1689
|
+
];
|
|
1690
|
+
for (const itemType of itemTypeKeys) {
|
|
1691
|
+
try {
|
|
1692
|
+
const items = ifcApi.GetLineIDsWithType(modelID, itemType);
|
|
1693
|
+
skippedItemCount += items.size();
|
|
1694
|
+
}
|
|
1695
|
+
catch {
|
|
1696
|
+
/* type not present in this file */
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
if (skippedItemCount > 0) {
|
|
1700
|
+
console.warn(`[IFC→Pascal] Skipped ${skippedItemCount} item${skippedItemCount === 1 ? '' : 's'} — Pascal items require a catalog asset the converter can't synthesise yet.`);
|
|
1701
|
+
}
|
|
1702
|
+
// Post-process: resolve levelId for all element nodes
|
|
1703
|
+
for (const node of Object.values(nodes)) {
|
|
1704
|
+
const m = meta(node);
|
|
1705
|
+
if (!m.expressID)
|
|
1706
|
+
continue;
|
|
1707
|
+
const storeyExpId = findStoreyForElement(m.expressID);
|
|
1708
|
+
if (storeyExpId != null) {
|
|
1709
|
+
m.levelId = expressIdToNodeId.get(storeyExpId) ?? undefined;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
// Post-process: extract property sets and materials
|
|
1713
|
+
const elementExpressIds = new Set();
|
|
1714
|
+
const expressIdToNode = new Map();
|
|
1715
|
+
for (const node of Object.values(nodes)) {
|
|
1716
|
+
const m = meta(node);
|
|
1717
|
+
if (m.expressID != null) {
|
|
1718
|
+
elementExpressIds.add(m.expressID);
|
|
1719
|
+
expressIdToNode.set(m.expressID, node);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
// Property sets via IFCRELDEFINESBYPROPERTIES
|
|
1723
|
+
try {
|
|
1724
|
+
const relDefines = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELDEFINESBYPROPERTIES);
|
|
1725
|
+
for (let i = 0; i < relDefines.size(); i++) {
|
|
1726
|
+
try {
|
|
1727
|
+
const rel = ifcApi.GetLine(modelID, relDefines.get(i));
|
|
1728
|
+
if (!rel.RelatedObjects || !rel.RelatingPropertyDefinition?.value)
|
|
1729
|
+
continue;
|
|
1730
|
+
const psetId = rel.RelatingPropertyDefinition.value;
|
|
1731
|
+
const pset = ifcApi.GetLine(modelID, psetId);
|
|
1732
|
+
const psetName = pset.Name?.value ?? 'Properties';
|
|
1733
|
+
const props = {};
|
|
1734
|
+
if (pset.HasProperties) {
|
|
1735
|
+
for (const propRef of pset.HasProperties) {
|
|
1736
|
+
try {
|
|
1737
|
+
const prop = ifcApi.GetLine(modelID, propRef.value);
|
|
1738
|
+
const name = prop.Name?.value;
|
|
1739
|
+
const val = prop.NominalValue?.value;
|
|
1740
|
+
if (name != null && val != null)
|
|
1741
|
+
props[name] = val;
|
|
1742
|
+
}
|
|
1743
|
+
catch {
|
|
1744
|
+
/* skip */
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
if (pset.Quantities) {
|
|
1749
|
+
for (const qRef of pset.Quantities) {
|
|
1750
|
+
try {
|
|
1751
|
+
const q = ifcApi.GetLine(modelID, qRef.value);
|
|
1752
|
+
const name = q.Name?.value;
|
|
1753
|
+
const val = q.LengthValue?.value ??
|
|
1754
|
+
q.AreaValue?.value ??
|
|
1755
|
+
q.VolumeValue?.value ??
|
|
1756
|
+
q.WeightValue?.value ??
|
|
1757
|
+
q.CountValue?.value;
|
|
1758
|
+
if (name != null && val != null)
|
|
1759
|
+
props[name] = val;
|
|
1760
|
+
}
|
|
1761
|
+
catch {
|
|
1762
|
+
/* skip */
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
if (Object.keys(props).length === 0)
|
|
1767
|
+
continue;
|
|
1768
|
+
for (const objRef of rel.RelatedObjects) {
|
|
1769
|
+
const node = expressIdToNode.get(objRef.value);
|
|
1770
|
+
if (!node)
|
|
1771
|
+
continue;
|
|
1772
|
+
const m = meta(node);
|
|
1773
|
+
if (!m.properties)
|
|
1774
|
+
m.properties = {};
|
|
1775
|
+
m.properties[psetName] = props;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
catch {
|
|
1779
|
+
/* skip rel */
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
catch {
|
|
1784
|
+
/* no property rels */
|
|
1785
|
+
}
|
|
1786
|
+
// Materials via IFCRELASSOCIATESMATERIAL
|
|
1787
|
+
try {
|
|
1788
|
+
const relMat = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELASSOCIATESMATERIAL);
|
|
1789
|
+
for (let i = 0; i < relMat.size(); i++) {
|
|
1790
|
+
try {
|
|
1791
|
+
const rel = ifcApi.GetLine(modelID, relMat.get(i));
|
|
1792
|
+
if (!rel.RelatedObjects || !rel.RelatingMaterial?.value)
|
|
1793
|
+
continue;
|
|
1794
|
+
const mat = ifcApi.GetLine(modelID, rel.RelatingMaterial.value);
|
|
1795
|
+
let materialName = null;
|
|
1796
|
+
const layers = [];
|
|
1797
|
+
const extractLayers = (layersArr) => {
|
|
1798
|
+
for (const lRef of layersArr) {
|
|
1799
|
+
try {
|
|
1800
|
+
const layer = ifcApi.GetLine(modelID, lRef.value);
|
|
1801
|
+
const layerMat = layer.Material?.value
|
|
1802
|
+
? ifcApi.GetLine(modelID, layer.Material.value)
|
|
1803
|
+
: null;
|
|
1804
|
+
layers.push({
|
|
1805
|
+
name: layerMat?.Name?.value ?? 'Unknown',
|
|
1806
|
+
thickness: layer.LayerThickness?.value != null
|
|
1807
|
+
? layer.LayerThickness.value * unitFactor
|
|
1808
|
+
: undefined,
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
catch {
|
|
1812
|
+
/* skip */
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
};
|
|
1816
|
+
if (mat.ForLayerSet?.value) {
|
|
1817
|
+
const layerSet = ifcApi.GetLine(modelID, mat.ForLayerSet.value);
|
|
1818
|
+
materialName = layerSet.LayerSetName?.value ?? null;
|
|
1819
|
+
if (layerSet.MaterialLayers)
|
|
1820
|
+
extractLayers(layerSet.MaterialLayers);
|
|
1821
|
+
}
|
|
1822
|
+
else if (mat.MaterialLayers) {
|
|
1823
|
+
materialName = mat.LayerSetName?.value ?? null;
|
|
1824
|
+
extractLayers(mat.MaterialLayers);
|
|
1825
|
+
}
|
|
1826
|
+
else if (mat.Name?.value) {
|
|
1827
|
+
materialName = mat.Name.value;
|
|
1828
|
+
}
|
|
1829
|
+
if (!materialName && layers.length === 0)
|
|
1830
|
+
continue;
|
|
1831
|
+
for (const objRef of rel.RelatedObjects) {
|
|
1832
|
+
const node = expressIdToNode.get(objRef.value);
|
|
1833
|
+
if (!node)
|
|
1834
|
+
continue;
|
|
1835
|
+
const m = meta(node);
|
|
1836
|
+
if (materialName)
|
|
1837
|
+
m.material = materialName;
|
|
1838
|
+
if (layers.length > 0)
|
|
1839
|
+
m.materialLayers = layers;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
catch {
|
|
1843
|
+
/* skip rel */
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
catch {
|
|
1848
|
+
/* no material rels */
|
|
1849
|
+
}
|
|
1850
|
+
// Type names via IFCRELDEFINESBYTYPE
|
|
1851
|
+
try {
|
|
1852
|
+
const relDefType = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELDEFINESBYTYPE);
|
|
1853
|
+
for (let i = 0; i < relDefType.size(); i++) {
|
|
1854
|
+
try {
|
|
1855
|
+
const rel = ifcApi.GetLine(modelID, relDefType.get(i));
|
|
1856
|
+
if (!rel.RelatedObjects || !rel.RelatingType?.value)
|
|
1857
|
+
continue;
|
|
1858
|
+
const type = ifcApi.GetLine(modelID, rel.RelatingType.value);
|
|
1859
|
+
const typeName = type.Name?.value;
|
|
1860
|
+
if (!typeName)
|
|
1861
|
+
continue;
|
|
1862
|
+
for (const objRef of rel.RelatedObjects) {
|
|
1863
|
+
const node = expressIdToNode.get(objRef.value);
|
|
1864
|
+
if (node)
|
|
1865
|
+
meta(node).typeName = typeName;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
catch {
|
|
1869
|
+
/* skip */
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
catch {
|
|
1874
|
+
/* no type rels */
|
|
1875
|
+
}
|
|
1876
|
+
ifcApi.CloseModel(modelID);
|
|
1877
|
+
progress('Building scene graph...', 95);
|
|
1878
|
+
const totalNodes = Object.keys(nodes).length;
|
|
1879
|
+
console.log(`[IFC→Pascal] Conversion complete! Generated ${totalNodes} nodes`);
|
|
1880
|
+
console.log(`[IFC→Pascal] Node breakdown:`, {
|
|
1881
|
+
sites: Object.values(nodes).filter((n) => n.type === 'site').length,
|
|
1882
|
+
buildings: Object.values(nodes).filter((n) => n.type === 'building').length,
|
|
1883
|
+
levels: Object.values(nodes).filter((n) => n.type === 'level').length,
|
|
1884
|
+
walls: Object.values(nodes).filter((n) => n.type === 'wall').length,
|
|
1885
|
+
slabs: Object.values(nodes).filter((n) => n.type === 'slab').length,
|
|
1886
|
+
doors: Object.values(nodes).filter((n) => n.type === 'door').length,
|
|
1887
|
+
windows: Object.values(nodes).filter((n) => n.type === 'window').length,
|
|
1888
|
+
stairs: Object.values(nodes).filter((n) => n.type === 'stair').length,
|
|
1889
|
+
roofs: Object.values(nodes).filter((n) => n.type === 'roof').length,
|
|
1890
|
+
columns: Object.values(nodes).filter((n) => n.type === 'column').length,
|
|
1891
|
+
skippedBeams: skippedBeamCount,
|
|
1892
|
+
skippedItems: skippedItemCount,
|
|
1893
|
+
});
|
|
1894
|
+
progress('Complete!', 100);
|
|
1895
|
+
return {
|
|
1896
|
+
nodes,
|
|
1897
|
+
rootNodeIds: rootNodeIds,
|
|
1898
|
+
};
|
|
1899
|
+
}
|