@pascal-app/ifc-converter 1.0.1 → 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.
Files changed (43) hide show
  1. package/README.md +64 -4
  2. package/dist/beam-geometry.d.ts.map +1 -1
  3. package/dist/beam-geometry.js +3 -2
  4. package/dist/cleanup.d.ts +8 -1
  5. package/dist/cleanup.d.ts.map +1 -1
  6. package/dist/cleanup.js +13 -4
  7. package/dist/collections.d.ts +4 -0
  8. package/dist/collections.d.ts.map +1 -0
  9. package/dist/collections.js +66 -0
  10. package/dist/export/door-operation.d.ts +13 -0
  11. package/dist/export/door-operation.d.ts.map +1 -0
  12. package/dist/export/door-operation.js +45 -0
  13. package/dist/export/guid.d.ts +15 -0
  14. package/dist/export/guid.d.ts.map +1 -0
  15. package/dist/export/guid.js +80 -0
  16. package/dist/export/ifc-model.d.ts +87 -0
  17. package/dist/export/ifc-model.d.ts.map +1 -0
  18. package/dist/export/ifc-model.js +183 -0
  19. package/dist/export/index.d.ts +3 -0
  20. package/dist/export/index.d.ts.map +1 -0
  21. package/dist/export/index.js +2 -0
  22. package/dist/export/scene-to-ifc.d.ts +45 -0
  23. package/dist/export/scene-to-ifc.d.ts.map +1 -0
  24. package/dist/export/scene-to-ifc.js +1123 -0
  25. package/dist/export/step.d.ts +64 -0
  26. package/dist/export/step.d.ts.map +1 -0
  27. package/dist/export/step.js +158 -0
  28. package/dist/export/wall-base.d.ts +25 -0
  29. package/dist/export/wall-base.d.ts.map +1 -0
  30. package/dist/export/wall-base.js +91 -0
  31. package/dist/ids.d.ts +3 -0
  32. package/dist/ids.d.ts.map +1 -0
  33. package/dist/ids.js +35 -0
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +570 -151
  37. package/dist/room-first.d.ts +35 -0
  38. package/dist/room-first.d.ts.map +1 -0
  39. package/dist/room-first.js +885 -0
  40. package/dist/wall-joins.d.ts +21 -0
  41. package/dist/wall-joins.d.ts.map +1 -0
  42. package/dist/wall-joins.js +367 -0
  43. package/package.json +9 -5
@@ -0,0 +1,885 @@
1
+ import { area, calculateLevelMiters, difference, FloorOpeningNode, getWallPlanFootprint, intersection, SeparatorNode, union, } from '@pascal-app/core';
2
+ import { migrateRoomZones } from '@pascal-app/core/scene-migrations';
3
+ import { nextId } from './ids';
4
+ /** Thickest slab still read as a finish layer on a structural floor. */
5
+ const MAX_FINISH_THICKNESS = 0.06;
6
+ /** Largest top-height difference between a floor and the level's main floor. */
7
+ const FLOOR_TOP_TOLERANCE = 0.02;
8
+ /** Floor area allowed past the rooms and walls before a floor is not a room floor. */
9
+ const MAX_OVERHANG_AREA = 1;
10
+ const MAX_OVERHANG_SHARE = 0.05;
11
+ /** Clear room floor a floor may leave uncovered and still be the room's floor. */
12
+ const MAX_UNCOVERED_FLOOR = 0.25;
13
+ const MAX_UNCOVERED_SHARE = 0.03;
14
+ /** Widest gap between two spaces' edges bridged by one separator (a missing partition). */
15
+ const MAX_SEPARATOR_GAP = 0.35;
16
+ const MAX_SEPARATOR_REACH = 0.6;
17
+ const PARALLEL_SINE = Math.sin((3 * Math.PI) / 180);
18
+ /** Distance within which centreline wall ends count as one junction. */
19
+ const JUNCTION_EPSILON = 1e-4;
20
+ const cross = (a, b) => a[0] * b[1] - a[1] * b[0];
21
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1];
22
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1]];
23
+ const add = (a, b, scale = 1) => [a[0] + b[0] * scale, a[1] + b[1] * scale];
24
+ function footprint(node) {
25
+ return { outer: node.polygon, holes: node.holes ?? [] };
26
+ }
27
+ function safeArea(run) {
28
+ try {
29
+ return area(run());
30
+ }
31
+ catch {
32
+ return 0;
33
+ }
34
+ }
35
+ function meta(node) {
36
+ return (node.metadata ?? {});
37
+ }
38
+ const isIfcSpace = (node) => node.type === 'zone' && meta(node).ifcType === 'IFCSPACE';
39
+ function attach(nodes, node) {
40
+ nodes[node.id] = node;
41
+ const parent = node.parentId ? nodes[node.parentId] : undefined;
42
+ if (parent && 'children' in parent && Array.isArray(parent.children))
43
+ parent.children.push(node.id);
44
+ }
45
+ function detach(nodes, id) {
46
+ const node = nodes[id];
47
+ const parent = node?.parentId ? nodes[node.parentId] : undefined;
48
+ if (parent && 'children' in parent && Array.isArray(parent.children))
49
+ parent.children = parent.children.filter((childId) => childId !== id);
50
+ delete nodes[id];
51
+ }
52
+ /**
53
+ * Walls are processed on their body centrelines; an IFC reference line on a
54
+ * wall face comes back here as Pascal `justification` (a = body left of the
55
+ * directed line, b = right), with start/end on that face line.
56
+ *
57
+ * The wall graph joins walls where their reference endpoints meet, so each
58
+ * junction the centrelines made is rebuilt on the reference lines: an L at the
59
+ * two lines' intersection, a T where the stem's line meets the host's. A
60
+ * junction whose reference lines cannot meet in one point (three or more ends,
61
+ * or in-line walls offset differently) keeps its walls centred.
62
+ */
63
+ export function applyWallReferenceLines(nodes, bodySides) {
64
+ const walls = Object.values(nodes).filter((node) => node.type === 'wall' &&
65
+ !(node.curveOffset && Math.abs(node.curveOffset) > 1e-6) &&
66
+ Math.hypot(node.end[0] - node.start[0], node.end[1] - node.start[1]) > 1e-6);
67
+ const sides = new Map();
68
+ for (const [id, side] of bodySides)
69
+ if (walls.some((wall) => wall.id === id))
70
+ sides.set(id, side);
71
+ if (!sides.size)
72
+ return;
73
+ const dirOf = (wall) => {
74
+ const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]);
75
+ return [(wall.end[0] - wall.start[0]) / length, (wall.end[1] - wall.start[1]) / length];
76
+ };
77
+ const offsetOf = (wall) => {
78
+ const side = sides.get(wall.id);
79
+ const half = (wall.thickness ?? 0.1) / 2;
80
+ return side ? [-side[0] * half, -side[1] * half] : [0, 0];
81
+ };
82
+ const junctions = [];
83
+ for (const wall of walls)
84
+ for (const atEnd of [false, true]) {
85
+ const point = atEnd ? wall.end : wall.start;
86
+ const junction = junctions.find((candidate) => candidate.ends[0].wall.parentId === wall.parentId &&
87
+ Math.hypot(...sub(candidate.ends[0].point, point)) <= JUNCTION_EPSILON);
88
+ if (junction)
89
+ junction.ends.push({ wall, atEnd, point });
90
+ else
91
+ junctions.push({ ends: [{ wall, atEnd, point }] });
92
+ }
93
+ for (const junction of junctions) {
94
+ const point = junction.ends[0].point;
95
+ junction.host = walls.find((wall) => {
96
+ if (junction.ends.some((end) => end.wall === wall))
97
+ return false;
98
+ if (wall.parentId !== junction.ends[0].wall.parentId)
99
+ return false;
100
+ const dir = dirOf(wall);
101
+ const offset = sub(point, wall.start);
102
+ const along = dot(offset, dir);
103
+ const length = Math.hypot(...sub(wall.end, wall.start));
104
+ return (Math.abs(cross(offset, dir)) <= JUNCTION_EPSILON &&
105
+ along > JUNCTION_EPSILON &&
106
+ along < length - JUNCTION_EPSILON);
107
+ });
108
+ }
109
+ const parallel = (a, b) => Math.abs(cross(dirOf(a), dirOf(b))) < 1e-6;
110
+ for (let changed = true; changed;) {
111
+ changed = false;
112
+ for (const { ends, host } of junctions) {
113
+ if (!ends.some((end) => sides.has(end.wall.id)) && !(host && sides.has(host.id)))
114
+ continue;
115
+ const [a, b] = ends;
116
+ const closable = host
117
+ ? ends.every((end) => !parallel(end.wall, host))
118
+ : ends.length === 1 ||
119
+ (ends.length === 2 &&
120
+ (!parallel(a.wall, b.wall) ||
121
+ Math.hypot(...sub(offsetOf(a.wall), offsetOf(b.wall))) <= JUNCTION_EPSILON));
122
+ if (closable)
123
+ continue;
124
+ for (const wall of [...ends.map((end) => end.wall), ...(host ? [host] : [])])
125
+ if (sides.delete(wall.id))
126
+ changed = true;
127
+ }
128
+ }
129
+ const lineIntersection = (a, b) => {
130
+ const da = dirOf(a);
131
+ const db = dirOf(b);
132
+ const pa = add(a.start, offsetOf(a));
133
+ const pb = add(b.start, offsetOf(b));
134
+ return add(pa, da, cross(sub(pb, pa), db) / cross(da, db));
135
+ };
136
+ const targets = new Map();
137
+ for (const { ends, host } of junctions)
138
+ for (const end of ends) {
139
+ const [other] = ends.filter((candidate) => candidate !== end);
140
+ targets.set(end, host
141
+ ? lineIntersection(end.wall, host)
142
+ : ends.length === 2 && other && !parallel(end.wall, other.wall)
143
+ ? lineIntersection(end.wall, other.wall)
144
+ : add(end.point, offsetOf(end.wall)));
145
+ }
146
+ for (const { ends } of junctions)
147
+ for (const end of ends) {
148
+ const target = targets.get(end);
149
+ const { wall } = end;
150
+ if (!end.atEnd) {
151
+ // Openings are placed by their distance from the wall start.
152
+ const moved = dot(sub(target, add(wall.start, offsetOf(wall))), dirOf(wall));
153
+ for (const id of wall.children) {
154
+ const child = nodes[id];
155
+ if (child?.type !== 'door' && child?.type !== 'window')
156
+ continue;
157
+ child.position = [child.position[0] - moved, child.position[1], child.position[2]];
158
+ }
159
+ }
160
+ }
161
+ for (const { ends } of junctions)
162
+ for (const end of ends) {
163
+ if (end.atEnd)
164
+ end.wall.end = targets.get(end);
165
+ else
166
+ end.wall.start = targets.get(end);
167
+ }
168
+ for (const [id, side] of sides) {
169
+ const wall = nodes[id];
170
+ const dir = dirOf(wall);
171
+ wall.justification = dot(side, [-dir[1], dir[0]]) > 0 ? 'a' : 'b';
172
+ }
173
+ }
174
+ /** The rooms the M3 load migration will make of the current walls and zones. */
175
+ function previewRooms(nodes) {
176
+ const { nodes: migrated } = migrateRoomZones(nodes);
177
+ return Object.values(migrated).flatMap((node) => {
178
+ if (node.type !== 'zone' || !node.autoFromWalls || !node.parentId)
179
+ return [];
180
+ const polygon = footprint(node);
181
+ const room = {
182
+ levelId: node.parentId,
183
+ preview: node,
184
+ polygon,
185
+ area: safeArea(() => [polygon]),
186
+ };
187
+ if (nodes[node.id])
188
+ room.zoneId = node.id;
189
+ return [room];
190
+ });
191
+ }
192
+ /**
193
+ * Wall loops no IFC space claims become rooms here, exactly as M3 would make
194
+ * them on load, so their floor and ceiling follow the same rules as the IFC
195
+ * rooms (a shaft or foundation cell has no floor the IFC never modelled).
196
+ */
197
+ function materializeRooms(nodes, rooms) {
198
+ for (const room of rooms) {
199
+ if (room.zoneId)
200
+ continue;
201
+ attach(nodes, {
202
+ ...room.preview,
203
+ metadata: { ...room.preview.metadata, ifcDerived: 'wall-loop' },
204
+ });
205
+ room.zoneId = room.preview.id;
206
+ }
207
+ }
208
+ function wallLines(nodes, levelId) {
209
+ return Object.values(nodes).flatMap((node) => node.type === 'wall' &&
210
+ node.parentId === levelId &&
211
+ !(node.curveOffset && Math.abs(node.curveOffset) > 1e-6)
212
+ ? [{ start: node.start, end: node.end, thickness: node.thickness ?? 0.1 }]
213
+ : []);
214
+ }
215
+ /**
216
+ * IFC separates open-plan spaces with virtual boundaries Pascal cannot read.
217
+ * Two spaces that fall in one wall face and border each other along an
218
+ * edge get a separator on the midline of that border, run on to the walls.
219
+ */
220
+ function addSpaceSeparators(nodes, rooms) {
221
+ let added = 0;
222
+ const spaces = Object.values(nodes).filter(isIfcSpace);
223
+ const levelIds = new Set(spaces.flatMap((space) => (space.parentId ? [space.parentId] : [])));
224
+ for (const levelId of levelIds) {
225
+ const faces = rooms.filter((room) => room.levelId === levelId);
226
+ const groups = new Map();
227
+ const open = [];
228
+ for (const space of spaces) {
229
+ if (space.parentId !== levelId || space.polygon.length < 3)
230
+ continue;
231
+ const spaceArea = safeArea(() => [footprint(space)]);
232
+ let best;
233
+ for (const face of faces) {
234
+ const overlap = safeArea(() => intersection(footprint(space), face.polygon));
235
+ if (!best || overlap > best.overlap)
236
+ best = { face, overlap };
237
+ }
238
+ if (!best || best.overlap < spaceArea * 0.5)
239
+ open.push(space);
240
+ else
241
+ groups.set(best.face, [...(groups.get(best.face) ?? []), space]);
242
+ }
243
+ const walls = wallLines(nodes, levelId);
244
+ const alongWalls = walls;
245
+ const borders = [];
246
+ const members = [];
247
+ for (const group of groups.values()) {
248
+ if (group.length < 2)
249
+ continue;
250
+ members.push(...group);
251
+ for (let i = 0; i < group.length; i++)
252
+ for (let j = i + 1; j < group.length; j++)
253
+ borders.push(...sharedBorders(group[i].polygon, group[j].polygon));
254
+ }
255
+ const lines = mergeCollinear(borders).flatMap((line) => clipAlongWalls(line, walls));
256
+ // A space edge running along neither a wall nor another space crosses a
257
+ // gap in the walls (a service void, a wall stopping short of its
258
+ // neighbour's face) that would join two spaces into one room.
259
+ const openEdges = (space) => space.polygon.flatMap((point, index) => clipAlongWalls({ start: point, end: space.polygon[(index + 1) % space.polygon.length] }, [
260
+ ...alongWalls,
261
+ ...lines.map((line) => ({ ...line, thickness: MAX_SEPARATOR_GAP + 0.04 })),
262
+ ], 0.05));
263
+ const gaps = mergeCollinear(members.flatMap(openEdges));
264
+ // A space mostly bounded by walls but open on one side (a glazed facade
265
+ // exported as a curtain wall) is closed along its own open edges.
266
+ const closers = open.flatMap((space) => {
267
+ const edges = openEdges(space);
268
+ const perimeter = space.polygon.reduce((sum, point, index) => sum + Math.hypot(...sub(space.polygon[(index + 1) % space.polygon.length], point)), 0);
269
+ const openLength = edges.reduce((sum, edge) => sum + Math.hypot(...sub(edge.end, edge.start)), 0);
270
+ return perimeter > 0 && openLength <= perimeter * 0.7 ? edges : [];
271
+ });
272
+ const connected = connectSeparators([...lines, ...gaps, ...closers], walls);
273
+ for (const [index, entry] of connected.entries()) {
274
+ const { line } = entry;
275
+ const length = Math.hypot(...sub(line.end, line.start));
276
+ if (length < 0.1 || !entry.attached.every(Boolean))
277
+ continue;
278
+ const gap = index >= lines.length && index < lines.length + gaps.length;
279
+ if (gap && (length > MAX_SEPARATOR_REACH || !entry.onWall.every(Boolean)))
280
+ continue;
281
+ attach(nodes, SeparatorNode.parse({
282
+ id: nextId('separator'),
283
+ type: 'separator',
284
+ name: 'Space boundary',
285
+ parentId: levelId,
286
+ start: line.start,
287
+ end: line.end,
288
+ metadata: { ifcDerived: 'space-boundary' },
289
+ }));
290
+ added++;
291
+ }
292
+ }
293
+ return added;
294
+ }
295
+ function sharedBorders(a, b) {
296
+ const out = [];
297
+ for (let i = 0; i < a.length; i++) {
298
+ const a0 = a[i];
299
+ const a1 = a[(i + 1) % a.length];
300
+ const lengthA = Math.hypot(...sub(a1, a0));
301
+ if (lengthA < 0.05)
302
+ continue;
303
+ const dir = [(a1[0] - a0[0]) / lengthA, (a1[1] - a0[1]) / lengthA];
304
+ const normal = [-dir[1], dir[0]];
305
+ for (let j = 0; j < b.length; j++) {
306
+ const b0 = b[j];
307
+ const b1 = b[(j + 1) % b.length];
308
+ const lengthB = Math.hypot(...sub(b1, b0));
309
+ if (lengthB < 0.05)
310
+ continue;
311
+ if (Math.abs(cross(dir, [(b1[0] - b0[0]) / lengthB, (b1[1] - b0[1]) / lengthB])) > PARALLEL_SINE)
312
+ continue;
313
+ const offset = dot(sub(b0, a0), normal);
314
+ if (Math.abs(offset) > MAX_SEPARATOR_GAP)
315
+ continue;
316
+ const t0 = dot(sub(b0, a0), dir);
317
+ const t1 = dot(sub(b1, a0), dir);
318
+ const lo = Math.max(0, Math.min(t0, t1));
319
+ const hi = Math.min(lengthA, Math.max(t0, t1));
320
+ if (hi - lo < 0.05)
321
+ continue;
322
+ const base = add(a0, normal, offset / 2);
323
+ out.push({ start: add(base, dir, lo), end: add(base, dir, hi) });
324
+ }
325
+ }
326
+ return out;
327
+ }
328
+ /** Unions overlapping collinear pieces (the same border found from both spaces). */
329
+ function mergeCollinear(pieces) {
330
+ const merged = [];
331
+ const used = new Set();
332
+ for (let i = 0; i < pieces.length; i++) {
333
+ if (used.has(i))
334
+ continue;
335
+ const line = pieces[i];
336
+ const length = Math.hypot(...sub(line.end, line.start));
337
+ const dir = [
338
+ (line.end[0] - line.start[0]) / length,
339
+ (line.end[1] - line.start[1]) / length,
340
+ ];
341
+ let lo = 0;
342
+ let hi = length;
343
+ for (let changed = true; changed;) {
344
+ changed = false;
345
+ for (let j = i + 1; j < pieces.length; j++) {
346
+ if (used.has(j))
347
+ continue;
348
+ const other = pieces[j];
349
+ const otherDir = sub(other.end, other.start);
350
+ const otherLength = Math.hypot(...otherDir);
351
+ if (Math.abs(cross(dir, otherDir)) > PARALLEL_SINE * otherLength)
352
+ continue;
353
+ if (Math.abs(cross(sub(other.start, line.start), dir)) > 0.03)
354
+ continue;
355
+ const t0 = dot(sub(other.start, line.start), dir);
356
+ const t1 = dot(sub(other.end, line.start), dir);
357
+ if (Math.min(t0, t1) > hi + 0.05 || Math.max(t0, t1) < lo - 0.05)
358
+ continue;
359
+ lo = Math.min(lo, t0, t1);
360
+ hi = Math.max(hi, t0, t1);
361
+ used.add(j);
362
+ changed = true;
363
+ }
364
+ }
365
+ merged.push({ start: add(line.start, dir, lo), end: add(line.start, dir, hi) });
366
+ }
367
+ return merged;
368
+ }
369
+ /** Drops the parts of a border that already run inside a wall body. */
370
+ function clipAlongWalls(line, walls, minLength = 0.1) {
371
+ const length = Math.hypot(...sub(line.end, line.start));
372
+ const dir = [
373
+ (line.end[0] - line.start[0]) / length,
374
+ (line.end[1] - line.start[1]) / length,
375
+ ];
376
+ let ranges = [[0, length]];
377
+ for (const wall of walls) {
378
+ const wallDir = sub(wall.end, wall.start);
379
+ const wallLength = Math.hypot(...wallDir);
380
+ if (wallLength < 1e-6 || Math.abs(cross(dir, wallDir)) > PARALLEL_SINE * wallLength)
381
+ continue;
382
+ if (Math.abs(cross(sub(wall.start, line.start), dir)) > wall.thickness / 2 + 0.08)
383
+ continue;
384
+ const t0 = dot(sub(wall.start, line.start), dir);
385
+ const t1 = dot(sub(wall.end, line.start), dir);
386
+ const lo = Math.min(t0, t1);
387
+ const hi = Math.max(t0, t1);
388
+ ranges = ranges.flatMap(([start, end]) => hi <= start || lo >= end
389
+ ? [[start, end]]
390
+ : [
391
+ ...(lo > start ? [[start, lo]] : []),
392
+ ...(hi < end ? [[hi, end]] : []),
393
+ ]);
394
+ }
395
+ return ranges
396
+ .filter(([start, end]) => end - start >= minLength)
397
+ .map(([start, end]) => ({ start: add(line.start, dir, start), end: add(line.start, dir, end) }));
398
+ }
399
+ /** Farthest a separator end snaps onto a boundary end (a wall stub at a doorway). */
400
+ const SEPARATOR_END_SNAP = 0.12;
401
+ /**
402
+ * Moves one separator end onto a target: a target end within the snap
403
+ * distance, or the target line it points at (just past a wall's end, within
404
+ * its body, lands on that end). The cheapest move wins. The room graph only
405
+ * joins a separator whose end lies exactly on a boundary.
406
+ */
407
+ function reachTarget(point, other, out, length, targets) {
408
+ let best;
409
+ const consider = (candidate, cost) => {
410
+ if (Math.hypot(...sub(candidate, other)) < 0.1)
411
+ return;
412
+ if (!best || cost < best.cost)
413
+ best = { point: candidate, cost };
414
+ };
415
+ for (const target of targets) {
416
+ for (const end of [target.start, target.end]) {
417
+ const distance = Math.hypot(...sub(end, point));
418
+ if (distance <= SEPARATOR_END_SNAP)
419
+ consider(end, distance);
420
+ }
421
+ const targetDir = sub(target.end, target.start);
422
+ const targetLength = Math.hypot(...targetDir);
423
+ if (targetLength < 1e-6)
424
+ continue;
425
+ const denominator = cross(out, targetDir) / targetLength;
426
+ if (Math.abs(denominator) < 0.25)
427
+ continue;
428
+ const toTarget = sub(target.start, point);
429
+ const move = cross(toTarget, targetDir) / targetLength / denominator;
430
+ const along = cross(toTarget, out) / denominator;
431
+ if (Math.abs(move) > MAX_SEPARATOR_REACH || length + move < 0.1)
432
+ continue;
433
+ const past = Math.max(0, -along, along - targetLength);
434
+ if (past > target.thickness / 2 + 0.05)
435
+ continue;
436
+ const t = Math.max(0, Math.min(1, along / targetLength));
437
+ consider(add(target.start, targetDir, t), Math.abs(move) + past);
438
+ }
439
+ return best?.point ?? null;
440
+ }
441
+ function reachTargets(entry, targets) {
442
+ const { line } = entry;
443
+ const length = Math.hypot(...sub(line.end, line.start));
444
+ if (length < 1e-6)
445
+ return entry;
446
+ const dir = [
447
+ (line.end[0] - line.start[0]) / length,
448
+ (line.end[1] - line.start[1]) / length,
449
+ ];
450
+ const ends = [line.start, line.end];
451
+ const attached = [...entry.attached];
452
+ for (const index of [0, 1]) {
453
+ if (attached[index])
454
+ continue;
455
+ const out = index === 0 ? [-dir[0], -dir[1]] : dir;
456
+ const point = reachTarget(ends[index], ends[1 - index], out, length, targets);
457
+ if (!point)
458
+ continue;
459
+ ends[index] = point;
460
+ attached[index] = true;
461
+ }
462
+ return { ...entry, line: { start: ends[0], end: ends[1] }, attached };
463
+ }
464
+ /** Walls first; ends still loose then join the other separators as they now lie. */
465
+ function connectSeparators(lines, walls) {
466
+ const entries = lines.map((line) => {
467
+ const entry = reachTargets({ line, attached: [false, false], onWall: [false, false] }, walls);
468
+ return { ...entry, onWall: [...entry.attached] };
469
+ });
470
+ for (const [index, entry] of entries.entries())
471
+ entries[index] = reachTargets(entry, entries
472
+ .filter((_, other) => other !== index)
473
+ .map((other) => ({ ...other.line, thickness: 0 })));
474
+ return entries;
475
+ }
476
+ function finishRef(name) {
477
+ const text = name.toLowerCase();
478
+ if (/wood|timber|parquet|oak|plank|hardwood|laminate/.test(text))
479
+ return 'library:wood-woodplank48';
480
+ if (/marble/.test(text))
481
+ return 'library:flooring-statuarettowhite';
482
+ if (/tile|ceramic|porcelain/.test(text))
483
+ return 'library:flooring-lightceramic24';
484
+ if (/terrazzo/.test(text))
485
+ return 'library:flooring-terrazzo19';
486
+ if (/stone|granite|slate/.test(text))
487
+ return 'library:flooring-wallstone1';
488
+ if (/concrete|screed|cement/.test(text))
489
+ return 'library:concrete-polished';
490
+ return undefined;
491
+ }
492
+ /** A room floor finish from the IFC material or type name, else its surface colour. */
493
+ function floorFinish(slab) {
494
+ const metadata = meta(slab);
495
+ const names = [metadata.material, metadata.typeName, metadata.objectType, slab.name];
496
+ for (const name of names) {
497
+ const ref = typeof name === 'string' ? finishRef(name) : undefined;
498
+ if (ref)
499
+ return ref;
500
+ }
501
+ const color = typeof metadata.sourceColor === 'string' ? metadata.sourceColor : '#c8c2b8';
502
+ return { preset: 'custom', properties: { color, roughness: 0.7, metalness: 0 } };
503
+ }
504
+ function finishLabel(slab) {
505
+ const metadata = meta(slab);
506
+ const name = (typeof metadata.typeName === 'string' && metadata.typeName) ||
507
+ (typeof metadata.material === 'string' && metadata.material) ||
508
+ (slab.name ?? '').replace(/:\d+$/, '');
509
+ return name.slice(0, 120);
510
+ }
511
+ function classifySlabs(slabs, rooms) {
512
+ const roomArea = union(rooms.map((room) => room.polygon));
513
+ const infos = slabs.map((slab) => {
514
+ const polygon = footprint(slab);
515
+ return {
516
+ slab,
517
+ top: slab.elevation,
518
+ bottom: slab.elevation - slab.thickness,
519
+ area: safeArea(() => [polygon]),
520
+ polygon,
521
+ kind: 'other',
522
+ };
523
+ });
524
+ for (const info of infos) {
525
+ const type = String(meta(info.slab).predefinedType ?? '').toUpperCase();
526
+ const covering = meta(info.slab).ifcType === 'IFCCOVERING';
527
+ if (!covering && (type === 'ROOF' || type === 'LANDING'))
528
+ continue;
529
+ // A flooring covering is a finish or nothing: it never carries a room.
530
+ info.kind = covering ? 'other' : 'structural';
531
+ if (!covering && info.slab.thickness > MAX_FINISH_THICKNESS + 1e-6)
532
+ continue;
533
+ const support = infos
534
+ .filter((other) => other !== info &&
535
+ other.slab.thickness > MAX_FINISH_THICKNESS + 1e-6 &&
536
+ Math.abs(other.top - info.bottom) <= 0.01)
537
+ .map((other) => ({
538
+ other,
539
+ overlap: safeArea(() => intersection(info.polygon, other.polygon)),
540
+ }))
541
+ .sort((a, b) => b.overlap - a.overlap)[0];
542
+ if (!support || support.overlap < info.area * 0.8)
543
+ continue;
544
+ if (safeArea(() => intersection(info.polygon, roomArea)) < info.area * 0.5)
545
+ continue;
546
+ info.kind = 'finish';
547
+ info.support = support.other;
548
+ }
549
+ return infos;
550
+ }
551
+ function insidePart(polygon, occupied) {
552
+ try {
553
+ const [largest] = intersection(polygon, occupied).sort((a, b) => area([b]) - area([a]));
554
+ return largest ? { polygon: largest.outer } : {};
555
+ }
556
+ catch {
557
+ return {};
558
+ }
559
+ }
560
+ function dominantTop(entries) {
561
+ const weights = new Map();
562
+ for (const { top, weight } of entries) {
563
+ const key = Math.round(top * 100);
564
+ weights.set(key, (weights.get(key) ?? 0) + weight);
565
+ }
566
+ const [best] = [...weights].sort((a, b) => b[1] - a[1] || b[0] - a[0]);
567
+ if (!best)
568
+ return undefined;
569
+ // The exact top of the heaviest entry in the winning centimetre.
570
+ return entries
571
+ .filter(({ top }) => Math.round(top * 100) === best[0])
572
+ .sort((a, b) => b.weight - a.weight)[0].top;
573
+ }
574
+ /**
575
+ * Level floors: IFC floors that line up with the level's rooms become Pascal
576
+ * room floors (base plate templates the kernel re-derives from the rooms,
577
+ * carrying the IFC thickness and walking surface); thin finish slabs on them
578
+ * become the rooms' floor finish. Everything else stays a hand-drawn slab.
579
+ */
580
+ function assignLevelFloors(nodes, levelId, rooms, lowestLevel, stats) {
581
+ const slabs = Object.values(nodes).filter((node) => node.type === 'slab' && node.parentId === levelId && !node.plateRole);
582
+ const infos = classifySlabs(slabs, rooms);
583
+ const structural = infos.filter((info) => info.kind === 'structural');
584
+ const finishes = infos.filter((info) => info.kind === 'finish');
585
+ const walls = Object.values(nodes).filter((node) => node.type === 'wall' && node.parentId === levelId);
586
+ const miters = calculateLevelMiters(walls);
587
+ const wallArea = union(walls.map((wall) => ({
588
+ outer: getWallPlanFootprint(wall, miters).map(({ x, y }) => [x, y]),
589
+ holes: [],
590
+ })));
591
+ const occupied = union([...rooms.map((room) => room.polygon), ...wallArea]);
592
+ const floors = [];
593
+ for (const room of rooms) {
594
+ if (!(room.area > 1e-6))
595
+ continue;
596
+ // Holes in a floor are openings (they stay cut), not missing floor: a room
597
+ // counts on the slab's outline, but must stand on some of its surface.
598
+ const [slab] = structural
599
+ .map((info) => ({
600
+ info,
601
+ cover: safeArea(() => intersection(room.polygon, info.polygon)) / room.area,
602
+ outline: safeArea(() => intersection(room.polygon, { outer: info.polygon.outer, holes: [] })) /
603
+ room.area,
604
+ }))
605
+ .filter((entry) => entry.outline >= 0.5 && entry.cover >= 0.1)
606
+ .sort((a, b) => b.cover - a.cover || b.info.top - a.info.top);
607
+ if (!slab)
608
+ continue;
609
+ let clear = [room.polygon];
610
+ try {
611
+ clear = difference(room.polygon, wallArea, { throwOnError: true });
612
+ }
613
+ catch {
614
+ clear = [room.polygon];
615
+ }
616
+ const clearArea = area(clear);
617
+ if (!(clearArea > 1e-6))
618
+ continue;
619
+ const level = union(structural
620
+ .filter((info) => Math.abs(info.top - slab.info.top) <= FLOOR_TOP_TOLERANCE)
621
+ .map((info) => ({ outer: info.polygon.outer, holes: [] })));
622
+ const uncovered = safeArea(() => difference(clear, level));
623
+ const finishParts = finishes
624
+ .filter((info) => info.support === slab.info)
625
+ .map((info) => {
626
+ const parts = intersection(clear, info.polygon);
627
+ return { info, parts, share: area(parts) / clearArea };
628
+ })
629
+ .filter((entry) => entry.share >= 0.02)
630
+ .sort((a, b) => b.share - a.share);
631
+ const dominant = finishParts[0];
632
+ floors.push({
633
+ room,
634
+ slab: slab.info,
635
+ finishes: finishParts,
636
+ top: dominant && dominant.share >= 0.5 ? dominant.info.top : slab.info.top,
637
+ covered: uncovered <= Math.max(MAX_UNCOVERED_FLOOR, clearArea * MAX_UNCOVERED_SHARE),
638
+ clear,
639
+ });
640
+ }
641
+ const mainTop = dominantTop(floors.map((floor) => ({ top: floor.top, weight: floor.room.area })));
642
+ const plates = new Set();
643
+ for (const info of structural) {
644
+ // A slab under part of a room stays hand-drawn rather than grow into a
645
+ // plate under all of it; once it carries whole rooms, a room it only
646
+ // partly floors joins the plate with its missing floor left open.
647
+ const own = floors.filter((floor) => floor.slab === info);
648
+ if (!own.some((floor) => floor.covered) || mainTop === undefined)
649
+ continue;
650
+ const top = dominantTop(own.map((floor) => ({ top: floor.top, weight: floor.room.area })));
651
+ if (Math.abs(top - mainTop) > FLOOR_TOP_TOLERANCE)
652
+ continue;
653
+ plates.add(info);
654
+ const slab = nodes[info.slab.id];
655
+ const bottom = info.bottom;
656
+ // Floor reaching well past the rooms (a terrace or plinth around the
657
+ // house) stays a hand-drawn slab; the rooms' part becomes their floor.
658
+ let outside = [];
659
+ try {
660
+ outside = difference(info.polygon, occupied, { throwOnError: true });
661
+ }
662
+ catch {
663
+ outside = [];
664
+ }
665
+ const split = area(outside) > Math.max(MAX_OVERHANG_AREA, info.area * MAX_OVERHANG_SHARE);
666
+ if (split) {
667
+ for (const [index, part] of outside.entries()) {
668
+ if (area([part]) < MAX_OVERHANG_AREA / 2)
669
+ continue;
670
+ attach(nodes, {
671
+ ...slab,
672
+ id: nextId('slab'),
673
+ name: `${slab.name ?? 'Slab'} (outside rooms${index ? ` ${index + 1}` : ''})`,
674
+ polygon: part.outer,
675
+ holes: part.holes,
676
+ holeMetadata: part.holes.map(() => ({ source: 'manual' })),
677
+ metadata: { ...slab.metadata, ifcSplit: 'outside-rooms' },
678
+ });
679
+ stats.manualFloors++;
680
+ }
681
+ }
682
+ const anyFloor = union(infos.map((other) => ({ outer: other.polygon.outer, holes: [] })));
683
+ for (const floor of own) {
684
+ if (floor.covered)
685
+ continue;
686
+ for (const part of difference(floor.clear, anyFloor)) {
687
+ if (area([part]) <= MAX_UNCOVERED_FLOOR)
688
+ continue;
689
+ attach(nodes, FloorOpeningNode.parse({
690
+ id: nextId('floor-opening'),
691
+ type: 'floor-opening',
692
+ name: 'Floor opening',
693
+ parentId: levelId,
694
+ polygon: part.outer,
695
+ source: 'manual',
696
+ drawnOn: 'floor',
697
+ metadata: { ifcDerived: 'open-floor' },
698
+ }));
699
+ stats.floorOpenings++;
700
+ }
701
+ }
702
+ for (const [index, hole] of slab.holes.entries()) {
703
+ if (slab.holeMetadata[index]?.source !== 'manual' || hole.length < 3)
704
+ continue;
705
+ const holeArea = safeArea(() => [{ outer: hole, holes: [] }]);
706
+ if (safeArea(() => intersection({ outer: hole, holes: [] }, occupied)) < holeArea * 0.5)
707
+ continue;
708
+ attach(nodes, FloorOpeningNode.parse({
709
+ id: nextId('floor-opening'),
710
+ type: 'floor-opening',
711
+ name: 'Floor opening',
712
+ parentId: levelId,
713
+ polygon: hole,
714
+ source: 'manual',
715
+ drawnOn: 'floor',
716
+ metadata: { ifcHostExpressID: meta(slab).expressID },
717
+ }));
718
+ stats.floorOpenings++;
719
+ }
720
+ nodes[slab.id] = {
721
+ ...slab,
722
+ boundary: 'auto',
723
+ plateRole: 'base',
724
+ autoFromWalls: true,
725
+ zoneIds: own.flatMap((floor) => (floor.room.zoneId ? [floor.room.zoneId] : [])),
726
+ elevation: top,
727
+ thickness: top - bottom,
728
+ referenceFloorElevation: top,
729
+ // A ground floor raised off the grade keeps a solid base under it.
730
+ foundation: { type: lowestLevel && bottom > 0.05 ? 'solid' : 'none' },
731
+ // The kernel re-derives the outline from the rooms; until then the
732
+ // plate covers only its rooms' part, beside the split-off remainder.
733
+ ...(split ? insidePart(info.polygon, occupied) : {}),
734
+ holes: [],
735
+ holeMetadata: [],
736
+ };
737
+ stats.floorPlates++;
738
+ }
739
+ const represented = new Map();
740
+ for (const floor of floors) {
741
+ const zone = floor.room.zoneId ? nodes[floor.room.zoneId] : undefined;
742
+ if (zone?.type !== 'zone')
743
+ continue;
744
+ if (plates.has(floor.slab)) {
745
+ if (!floor.finishes.length)
746
+ continue;
747
+ // The finish under most of the room is its floor finish; others (or all
748
+ // of them, when none dominates) are painted regions clipped to the room.
749
+ const [dominant, ...rest] = floor.finishes;
750
+ const whole = dominant.share >= 0.5 ? dominant : undefined;
751
+ const regions = (whole ? rest : floor.finishes).flatMap((entry) => entry.parts.map((part, index) => ({
752
+ id: `${entry.info.slab.id}_${zone.id}${index ? `_${index}` : ''}`,
753
+ polygon: part.outer,
754
+ finish: floorFinish(entry.info.slab),
755
+ })));
756
+ zone.floor = {
757
+ ...zone.floor,
758
+ ...(whole ? { finish: floorFinish(whole.info.slab) } : {}),
759
+ ...(regions.length ? { regions: [...(zone.floor?.regions ?? []), ...regions] } : {}),
760
+ };
761
+ zone.floorFinish = finishLabel((whole ?? dominant).info.slab);
762
+ for (const entry of floor.finishes)
763
+ represented.set(entry.info, (represented.get(entry.info) ?? 0) + area(entry.parts));
764
+ stats.roomFinishes++;
765
+ continue;
766
+ }
767
+ // The room stands on a hand-drawn slab: the topmost one under most of it.
768
+ const [finish] = floor.finishes;
769
+ zone.floor = {
770
+ ...zone.floor,
771
+ sourceSlabId: (finish && finish.share >= 0.5 ? finish.info : floor.slab).slab.id,
772
+ };
773
+ stats.manualFloors++;
774
+ }
775
+ // A finish every part of which is now room finish no longer needs its slab.
776
+ for (const info of finishes) {
777
+ if (!info.support || !plates.has(info.support))
778
+ continue;
779
+ const visible = safeArea(() => difference(info.polygon, wallArea));
780
+ if ((represented.get(info) ?? 0) >= visible * 0.97)
781
+ detach(nodes, info.slab.id);
782
+ }
783
+ // Rooms and IFC spaces with no floor under them keep no floor.
784
+ const floorArea = union(infos.filter((info) => info.kind !== 'other').map((info) => info.polygon));
785
+ for (const zone of Object.values(nodes)) {
786
+ if (zone.type !== 'zone' || zone.parentId !== levelId || zone.floor?.sourceSlabId)
787
+ continue;
788
+ if (!isIfcSpace(zone) && meta(zone).ifcDerived !== 'wall-loop')
789
+ continue;
790
+ const zoneArea = safeArea(() => [footprint(zone)]);
791
+ const covered = safeArea(() => intersection(footprint(zone), floorArea));
792
+ if (zoneArea > 0 && covered < zoneArea * 0.1)
793
+ zone.hasFloor = false;
794
+ }
795
+ }
796
+ function manualHolesWithin(ceiling, polygon) {
797
+ const holes = ceiling.holes
798
+ .filter((_, index) => (ceiling.holeMetadata[index]?.source ?? 'manual') === 'manual')
799
+ .flatMap((hole) => {
800
+ try {
801
+ return intersection({ outer: hole, holes: [] }, polygon).map((part) => part.outer);
802
+ }
803
+ catch {
804
+ return [];
805
+ }
806
+ })
807
+ .filter((hole) => hole.length >= 3);
808
+ return { holes, holeMetadata: holes.map(() => ({ source: 'manual' })) };
809
+ }
810
+ /**
811
+ * IFC ceiling coverings become the ceilings of the rooms they cover, at the
812
+ * covering's underside. When the file models ceilings, a room without one
813
+ * has none; a file without any keeps Pascal's default room ceilings, except
814
+ * for rooms the IFC gave no floor either (foundation cells, voids).
815
+ */
816
+ function assignCeilings(nodes, rooms, stats) {
817
+ const coverings = Object.values(nodes).filter((node) => node.type === 'ceiling' && meta(node).ifcType === 'IFCCOVERING');
818
+ const used = new Set();
819
+ for (const room of rooms) {
820
+ const zone = room.zoneId ? nodes[room.zoneId] : undefined;
821
+ if (zone?.type !== 'zone')
822
+ continue;
823
+ const [best] = coverings
824
+ .filter((ceiling) => ceiling.parentId === room.levelId)
825
+ .map((ceiling) => ({
826
+ ceiling,
827
+ cover: safeArea(() => intersection(room.polygon, footprint(ceiling))) / room.area,
828
+ }))
829
+ .filter((entry) => entry.cover >= 0.4)
830
+ .sort((a, b) => b.cover - a.cover);
831
+ if (!best) {
832
+ if (coverings.length || zone.hasFloor === false)
833
+ zone.hasCeiling = false;
834
+ continue;
835
+ }
836
+ const linked = {
837
+ ...best.ceiling,
838
+ id: used.has(best.ceiling.id) ? nextId('ceiling') : best.ceiling.id,
839
+ name: `${zone.name} Ceiling`,
840
+ polygon: room.polygon.outer,
841
+ // The room redraws the outline; the covering's own cut-outs (hatches,
842
+ // voids) stay as manual holes where they fall inside this room.
843
+ ...manualHolesWithin(best.ceiling, room.polygon),
844
+ boundary: 'auto',
845
+ autoFromWalls: true,
846
+ zoneId: zone.id,
847
+ };
848
+ if (used.has(best.ceiling.id))
849
+ attach(nodes, linked);
850
+ else
851
+ nodes[linked.id] = linked;
852
+ used.add(best.ceiling.id);
853
+ zone.ceilingFinish = (best.ceiling.name ?? '').replace(/:\d+$/, '').slice(0, 120);
854
+ stats.linkedCeilings++;
855
+ }
856
+ }
857
+ export function applyRoomFirstStructure(nodes, wallBodySides) {
858
+ const stats = {
859
+ separators: 0,
860
+ floorPlates: 0,
861
+ manualFloors: 0,
862
+ roomFinishes: 0,
863
+ linkedCeilings: 0,
864
+ floorOpenings: 0,
865
+ };
866
+ applyWallReferenceLines(nodes, wallBodySides);
867
+ let rooms = previewRooms(nodes);
868
+ stats.separators = addSpaceSeparators(nodes, rooms);
869
+ if (stats.separators)
870
+ rooms = previewRooms(nodes);
871
+ materializeRooms(nodes, rooms);
872
+ const levels = Object.values(nodes).filter((node) => node.type === 'level');
873
+ for (const level of levels) {
874
+ const lowest = !levels.some((other) => other.parentId === level.parentId && other.level < level.level);
875
+ assignLevelFloors(nodes, level.id, rooms.filter((room) => room.levelId === level.id), lowest, stats);
876
+ // The level's floors are decided: the load migration's guesses for legacy
877
+ // hand-drawn floors (adopting pieces, absorbing whole levels) stay off.
878
+ nodes[level.id] = {
879
+ ...level,
880
+ metadata: { ...(level.metadata ?? {}), floorOwnershipMigrated: true },
881
+ };
882
+ }
883
+ assignCeilings(nodes, rooms, stats);
884
+ return stats;
885
+ }