@pascal-app/ifc-converter 0.1.0 → 0.1.2
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/dist/cleanup.d.ts +25 -0
- package/dist/cleanup.d.ts.map +1 -0
- package/dist/cleanup.js +434 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -2
- package/package.json +3 -3
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type AnyNode } from '@pascal-app/core';
|
|
2
|
+
type SceneNodes = Record<string, AnyNode>;
|
|
3
|
+
export type IfcConversionSimplificationOptions = {
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
maxWallJoinGap?: number;
|
|
6
|
+
};
|
|
7
|
+
export type IfcConversionSimplificationStats = {
|
|
8
|
+
input: {
|
|
9
|
+
walls: number;
|
|
10
|
+
doors: number;
|
|
11
|
+
windows: number;
|
|
12
|
+
};
|
|
13
|
+
output: {
|
|
14
|
+
walls: number;
|
|
15
|
+
doors: number;
|
|
16
|
+
windows: number;
|
|
17
|
+
};
|
|
18
|
+
removedTinyWalls: number;
|
|
19
|
+
mergedWallGroups: number;
|
|
20
|
+
removedMergedWalls: number;
|
|
21
|
+
removedDuplicateOpenings: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function simplifyConvertedSceneGraph(nodes: SceneNodes, options?: IfcConversionSimplificationOptions): IfcConversionSimplificationStats;
|
|
24
|
+
export {};
|
|
25
|
+
//# sourceMappingURL=cleanup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cleanup.d.ts","sourceRoot":"","sources":["../src/cleanup.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,OAAO,EAMb,MAAM,kBAAkB,CAAA;AAEzB,KAAK,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAGzC,MAAM,MAAM,kCAAkC,GAAG;IAC/C,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,gCAAgC,GAAG;IAC7C,KAAK,EAAE;QACL,KAAK,EAAE,MAAM,CAAA;QACb,KAAK,EAAE,MAAM,CAAA;QACb,OAAO,EAAE,MAAM,CAAA;KAChB,CAAA;IACD,MAAM,EAAE;QACN,KAAK,EAAE,MAAM,CAAA;QACb,KAAK,EAAE,MAAM,CAAA;QACb,OAAO,EAAE,MAAM,CAAA;KAChB,CAAA;IACD,gBAAgB,EAAE,MAAM,CAAA;IACxB,gBAAgB,EAAE,MAAM,CAAA;IACxB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,wBAAwB,EAAE,MAAM,CAAA;CACjC,CAAA;AA6cD,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,UAAU,EACjB,OAAO,GAAE,kCAAuC,GAC/C,gCAAgC,CAoBlC"}
|
package/dist/cleanup.js
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS, } from '@pascal-app/core';
|
|
2
|
+
const MIN_WALL_LENGTH = 0.08;
|
|
3
|
+
const WALL_ANGLE_BUCKET_RAD = Math.PI / 180;
|
|
4
|
+
const DEFAULT_MAX_WALL_JOIN_GAP = 1.25;
|
|
5
|
+
const WALL_HEIGHT_TOLERANCE = 0.35;
|
|
6
|
+
const OPENING_DUPLICATE_TOLERANCE = 0.05;
|
|
7
|
+
function countNodes(nodes, type) {
|
|
8
|
+
return Object.values(nodes).filter((node) => node.type === type).length;
|
|
9
|
+
}
|
|
10
|
+
function getInitialStats(nodes) {
|
|
11
|
+
return {
|
|
12
|
+
input: {
|
|
13
|
+
walls: countNodes(nodes, 'wall'),
|
|
14
|
+
doors: countNodes(nodes, 'door'),
|
|
15
|
+
windows: countNodes(nodes, 'window'),
|
|
16
|
+
},
|
|
17
|
+
output: {
|
|
18
|
+
walls: 0,
|
|
19
|
+
doors: 0,
|
|
20
|
+
windows: 0,
|
|
21
|
+
},
|
|
22
|
+
removedTinyWalls: 0,
|
|
23
|
+
mergedWallGroups: 0,
|
|
24
|
+
removedMergedWalls: 0,
|
|
25
|
+
removedDuplicateOpenings: 0,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function finishStats(nodes, stats) {
|
|
29
|
+
stats.output = {
|
|
30
|
+
walls: countNodes(nodes, 'wall'),
|
|
31
|
+
doors: countNodes(nodes, 'door'),
|
|
32
|
+
windows: countNodes(nodes, 'window'),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function isOpeningNode(node) {
|
|
36
|
+
return node?.type === 'door' || node?.type === 'window';
|
|
37
|
+
}
|
|
38
|
+
function getOpeningWallId(opening, nodes) {
|
|
39
|
+
if (opening.wallId && nodes[opening.wallId]?.type === 'wall')
|
|
40
|
+
return opening.wallId;
|
|
41
|
+
if (opening.parentId && nodes[opening.parentId]?.type === 'wall')
|
|
42
|
+
return opening.parentId;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function uniqueExistingChildren(children, nodes) {
|
|
46
|
+
const next = [];
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
for (const childId of children ?? []) {
|
|
49
|
+
if (!(childId in nodes) || seen.has(childId))
|
|
50
|
+
continue;
|
|
51
|
+
seen.add(childId);
|
|
52
|
+
next.push(childId);
|
|
53
|
+
}
|
|
54
|
+
return next;
|
|
55
|
+
}
|
|
56
|
+
function normalizeChildren(nodes) {
|
|
57
|
+
for (const node of Object.values(nodes)) {
|
|
58
|
+
const withChildren = node;
|
|
59
|
+
if (Array.isArray(withChildren.children)) {
|
|
60
|
+
withChildren.children = uniqueExistingChildren(withChildren.children, nodes);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function syncWallOpeningChildren(nodes) {
|
|
65
|
+
for (const node of Object.values(nodes)) {
|
|
66
|
+
if (node.type !== 'wall')
|
|
67
|
+
continue;
|
|
68
|
+
node.children = uniqueExistingChildren(node.children, nodes);
|
|
69
|
+
}
|
|
70
|
+
for (const node of Object.values(nodes)) {
|
|
71
|
+
if (!isOpeningNode(node))
|
|
72
|
+
continue;
|
|
73
|
+
const wallId = getOpeningWallId(node, nodes);
|
|
74
|
+
const wall = wallId ? nodes[wallId] : undefined;
|
|
75
|
+
if (wall?.type !== 'wall')
|
|
76
|
+
continue;
|
|
77
|
+
if (!wall.children.includes(node.id)) {
|
|
78
|
+
wall.children.push(node.id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function removeNodeFromParents(nodes, nodeId) {
|
|
83
|
+
for (const node of Object.values(nodes)) {
|
|
84
|
+
const withChildren = node;
|
|
85
|
+
if (!Array.isArray(withChildren.children))
|
|
86
|
+
continue;
|
|
87
|
+
withChildren.children = withChildren.children.filter((childId) => childId !== nodeId);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function wallLength(wall) {
|
|
91
|
+
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]);
|
|
92
|
+
}
|
|
93
|
+
function pruneTinyWalls(nodes, stats) {
|
|
94
|
+
for (const node of Object.values(nodes)) {
|
|
95
|
+
if (node.type !== 'wall')
|
|
96
|
+
continue;
|
|
97
|
+
if (wallLength(node) >= MIN_WALL_LENGTH)
|
|
98
|
+
continue;
|
|
99
|
+
if (node.children.length > 0)
|
|
100
|
+
continue;
|
|
101
|
+
delete nodes[node.id];
|
|
102
|
+
removeNodeFromParents(nodes, node.id);
|
|
103
|
+
stats.removedTinyWalls += 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function toWallSegment(wall) {
|
|
107
|
+
if (wall.curveOffset !== undefined && Math.abs(wall.curveOffset) > 1e-6)
|
|
108
|
+
return null;
|
|
109
|
+
const dx = wall.end[0] - wall.start[0];
|
|
110
|
+
const dy = wall.end[1] - wall.start[1];
|
|
111
|
+
const length = Math.hypot(dx, dy);
|
|
112
|
+
if (length < MIN_WALL_LENGTH)
|
|
113
|
+
return null;
|
|
114
|
+
let axisX = dx / length;
|
|
115
|
+
let axisY = dy / length;
|
|
116
|
+
if (axisX < -1e-6 || (Math.abs(axisX) <= 1e-6 && axisY < 0)) {
|
|
117
|
+
axisX = -axisX;
|
|
118
|
+
axisY = -axisY;
|
|
119
|
+
}
|
|
120
|
+
const normalX = -axisY;
|
|
121
|
+
const normalY = axisX;
|
|
122
|
+
const startT = wall.start[0] * axisX + wall.start[1] * axisY;
|
|
123
|
+
const endT = wall.end[0] * axisX + wall.end[1] * axisY;
|
|
124
|
+
const startOffset = wall.start[0] * normalX + wall.start[1] * normalY;
|
|
125
|
+
const endOffset = wall.end[0] * normalX + wall.end[1] * normalY;
|
|
126
|
+
const angle = Math.atan2(axisY, axisX);
|
|
127
|
+
return {
|
|
128
|
+
id: wall.id,
|
|
129
|
+
wall,
|
|
130
|
+
parentId: wall.parentId ?? null,
|
|
131
|
+
axisX,
|
|
132
|
+
axisY,
|
|
133
|
+
normalX,
|
|
134
|
+
normalY,
|
|
135
|
+
offset: (startOffset + endOffset) / 2,
|
|
136
|
+
t0: Math.min(startT, endT),
|
|
137
|
+
t1: Math.max(startT, endT),
|
|
138
|
+
length,
|
|
139
|
+
height: wall.height ?? DEFAULT_WALL_HEIGHT,
|
|
140
|
+
thickness: wall.thickness ?? DEFAULT_WALL_THICKNESS,
|
|
141
|
+
angleBucket: Math.round(angle / WALL_ANGLE_BUCKET_RAD),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function wallLineTolerance(a, b) {
|
|
145
|
+
return Math.max(0.06, Math.min(0.14, Math.max(a.thickness, b.thickness) * 0.5));
|
|
146
|
+
}
|
|
147
|
+
function wallHeightCompatible(a, b) {
|
|
148
|
+
return Math.abs(a.height - b.height) <= WALL_HEIGHT_TOLERANCE;
|
|
149
|
+
}
|
|
150
|
+
function wallIntervalsCompatible(a, b, maxJoinGap) {
|
|
151
|
+
const gap = Math.max(a.t0, b.t0) - Math.min(a.t1, b.t1);
|
|
152
|
+
if (gap <= maxJoinGap)
|
|
153
|
+
return true;
|
|
154
|
+
const overlap = Math.min(a.t1, b.t1) - Math.max(a.t0, b.t0);
|
|
155
|
+
if (overlap <= 0)
|
|
156
|
+
return false;
|
|
157
|
+
return overlap / Math.min(a.length, b.length) >= 0.5;
|
|
158
|
+
}
|
|
159
|
+
function wallsCanMerge(a, b, maxJoinGap) {
|
|
160
|
+
if (a.parentId !== b.parentId)
|
|
161
|
+
return false;
|
|
162
|
+
if (Math.abs(a.angleBucket - b.angleBucket) > 1)
|
|
163
|
+
return false;
|
|
164
|
+
if (Math.abs(a.offset - b.offset) > wallLineTolerance(a, b))
|
|
165
|
+
return false;
|
|
166
|
+
if (!wallHeightCompatible(a, b))
|
|
167
|
+
return false;
|
|
168
|
+
return wallIntervalsCompatible(a, b, maxJoinGap);
|
|
169
|
+
}
|
|
170
|
+
function find(parent, index) {
|
|
171
|
+
let current = index;
|
|
172
|
+
while (parent[current] !== current) {
|
|
173
|
+
parent[current] = parent[parent[current]];
|
|
174
|
+
current = parent[current];
|
|
175
|
+
}
|
|
176
|
+
return current;
|
|
177
|
+
}
|
|
178
|
+
function union(parent, a, b) {
|
|
179
|
+
const rootA = find(parent, a);
|
|
180
|
+
const rootB = find(parent, b);
|
|
181
|
+
if (rootA !== rootB)
|
|
182
|
+
parent[rootB] = rootA;
|
|
183
|
+
}
|
|
184
|
+
function openingWorldPosition(opening, wall) {
|
|
185
|
+
const length = wallLength(wall);
|
|
186
|
+
if (length < 1e-6)
|
|
187
|
+
return [wall.start[0], wall.start[1]];
|
|
188
|
+
const axisX = (wall.end[0] - wall.start[0]) / length;
|
|
189
|
+
const axisY = (wall.end[1] - wall.start[1]) / length;
|
|
190
|
+
const normalX = -axisY;
|
|
191
|
+
const normalY = axisX;
|
|
192
|
+
const [localX, , localZ] = opening.position;
|
|
193
|
+
return [
|
|
194
|
+
wall.start[0] + axisX * localX + normalX * localZ,
|
|
195
|
+
wall.start[1] + axisY * localX + normalY * localZ,
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
function clampOpeningAlongWall(opening, along, wallLengthValue) {
|
|
199
|
+
const width = opening.width ?? (opening.type === 'door' ? 0.9 : 1.0);
|
|
200
|
+
const half = width / 2;
|
|
201
|
+
const lo = Math.min(half, wallLengthValue / 2);
|
|
202
|
+
const hi = Math.max(wallLengthValue - half, wallLengthValue / 2);
|
|
203
|
+
return Math.max(lo, Math.min(hi, along));
|
|
204
|
+
}
|
|
205
|
+
function rehostOpeningToWall(opening, oldWall, newWall) {
|
|
206
|
+
const [worldX, worldY] = openingWorldPosition(opening, oldWall);
|
|
207
|
+
const newLength = wallLength(newWall);
|
|
208
|
+
if (newLength < 1e-6)
|
|
209
|
+
return;
|
|
210
|
+
const axisX = (newWall.end[0] - newWall.start[0]) / newLength;
|
|
211
|
+
const axisY = (newWall.end[1] - newWall.start[1]) / newLength;
|
|
212
|
+
const normalX = -axisY;
|
|
213
|
+
const normalY = axisX;
|
|
214
|
+
const relX = worldX - newWall.start[0];
|
|
215
|
+
const relY = worldY - newWall.start[1];
|
|
216
|
+
const along = clampOpeningAlongWall(opening, relX * axisX + relY * axisY, newLength);
|
|
217
|
+
const across = relX * normalX + relY * normalY;
|
|
218
|
+
opening.parentId = newWall.id;
|
|
219
|
+
opening.wallId = newWall.id;
|
|
220
|
+
opening.position = [along, opening.position[1], across];
|
|
221
|
+
}
|
|
222
|
+
function pointOnLine(segment, t, offset) {
|
|
223
|
+
return [
|
|
224
|
+
segment.axisX * t + segment.normalX * offset,
|
|
225
|
+
segment.axisY * t + segment.normalY * offset,
|
|
226
|
+
];
|
|
227
|
+
}
|
|
228
|
+
function chooseKeptSegment(cluster) {
|
|
229
|
+
return cluster.reduce((best, current) => {
|
|
230
|
+
if (current.wall.children.length !== best.wall.children.length) {
|
|
231
|
+
return current.wall.children.length > best.wall.children.length ? current : best;
|
|
232
|
+
}
|
|
233
|
+
return current.length > best.length ? current : best;
|
|
234
|
+
}, cluster[0]);
|
|
235
|
+
}
|
|
236
|
+
function collectOpeningIdsForWalls(nodes, wallIds) {
|
|
237
|
+
const childIds = new Set();
|
|
238
|
+
const childOriginWall = new Map();
|
|
239
|
+
for (const wallId of wallIds) {
|
|
240
|
+
const wall = nodes[wallId];
|
|
241
|
+
if (wall?.type !== 'wall')
|
|
242
|
+
continue;
|
|
243
|
+
for (const childId of wall.children) {
|
|
244
|
+
childIds.add(childId);
|
|
245
|
+
childOriginWall.set(childId, wallId);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (const node of Object.values(nodes)) {
|
|
249
|
+
if (!isOpeningNode(node))
|
|
250
|
+
continue;
|
|
251
|
+
const wallId = getOpeningWallId(node, nodes);
|
|
252
|
+
if (!wallId || !wallIds.has(wallId))
|
|
253
|
+
continue;
|
|
254
|
+
childIds.add(node.id);
|
|
255
|
+
childOriginWall.set(node.id, wallId);
|
|
256
|
+
}
|
|
257
|
+
return { childIds, childOriginWall };
|
|
258
|
+
}
|
|
259
|
+
function mergeWallCluster(nodes, cluster, stats) {
|
|
260
|
+
const kept = chooseKeptSegment(cluster);
|
|
261
|
+
const keptWall = kept.wall;
|
|
262
|
+
const wallIds = new Set(cluster.map((segment) => segment.id));
|
|
263
|
+
const originalWalls = new Map(cluster.map((segment) => [
|
|
264
|
+
segment.id,
|
|
265
|
+
{
|
|
266
|
+
...segment.wall,
|
|
267
|
+
start: [...segment.wall.start],
|
|
268
|
+
end: [...segment.wall.end],
|
|
269
|
+
children: [...segment.wall.children],
|
|
270
|
+
},
|
|
271
|
+
]));
|
|
272
|
+
const { childIds, childOriginWall } = collectOpeningIdsForWalls(nodes, wallIds);
|
|
273
|
+
const t0 = Math.min(...cluster.map((segment) => segment.t0));
|
|
274
|
+
const t1 = Math.max(...cluster.map((segment) => segment.t1));
|
|
275
|
+
const crossMin = Math.min(...cluster.map((segment) => segment.offset - segment.thickness / 2));
|
|
276
|
+
const crossMax = Math.max(...cluster.map((segment) => segment.offset + segment.thickness / 2));
|
|
277
|
+
const offset = (crossMin + crossMax) / 2;
|
|
278
|
+
const thickness = Math.max(...cluster.map((segment) => segment.thickness), crossMax - crossMin);
|
|
279
|
+
const height = Math.max(...cluster.map((segment) => segment.height));
|
|
280
|
+
const mergedExpressIds = cluster
|
|
281
|
+
.map((segment) => segment.wall.metadata?.expressID)
|
|
282
|
+
.filter((expressID) => typeof expressID === 'number');
|
|
283
|
+
keptWall.start = pointOnLine(kept, t0, offset);
|
|
284
|
+
keptWall.end = pointOnLine(kept, t1, offset);
|
|
285
|
+
keptWall.thickness = thickness;
|
|
286
|
+
keptWall.height = height;
|
|
287
|
+
keptWall.metadata = {
|
|
288
|
+
...(keptWall.metadata ?? {}),
|
|
289
|
+
ifcSimplification: {
|
|
290
|
+
mergedWallCount: cluster.length,
|
|
291
|
+
mergedExpressIDs: mergedExpressIds,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
const nextChildren = new Set();
|
|
295
|
+
for (const childId of childIds) {
|
|
296
|
+
const child = nodes[childId];
|
|
297
|
+
if (!child)
|
|
298
|
+
continue;
|
|
299
|
+
if (isOpeningNode(child)) {
|
|
300
|
+
const originWallId = childOriginWall.get(childId);
|
|
301
|
+
const originWall = originWallId ? originalWalls.get(originWallId) : undefined;
|
|
302
|
+
if (originWall) {
|
|
303
|
+
rehostOpeningToWall(child, originWall, keptWall);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
child.parentId = keptWall.id;
|
|
307
|
+
child.wallId = keptWall.id;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
child.parentId = keptWall.id;
|
|
312
|
+
}
|
|
313
|
+
nextChildren.add(childId);
|
|
314
|
+
}
|
|
315
|
+
keptWall.children = Array.from(nextChildren);
|
|
316
|
+
for (const segment of cluster) {
|
|
317
|
+
if (segment.id === keptWall.id)
|
|
318
|
+
continue;
|
|
319
|
+
delete nodes[segment.id];
|
|
320
|
+
removeNodeFromParents(nodes, segment.id);
|
|
321
|
+
}
|
|
322
|
+
const parent = keptWall.parentId ? nodes[keptWall.parentId] : undefined;
|
|
323
|
+
const parentWithChildren = parent;
|
|
324
|
+
if (parentWithChildren?.children && !parentWithChildren.children.includes(keptWall.id)) {
|
|
325
|
+
parentWithChildren.children.push(keptWall.id);
|
|
326
|
+
}
|
|
327
|
+
stats.mergedWallGroups += 1;
|
|
328
|
+
stats.removedMergedWalls += cluster.length - 1;
|
|
329
|
+
}
|
|
330
|
+
function mergeWallFragments(nodes, stats, options) {
|
|
331
|
+
const segments = Object.values(nodes)
|
|
332
|
+
.filter((node) => node.type === 'wall')
|
|
333
|
+
.map(toWallSegment)
|
|
334
|
+
.filter((segment) => segment !== null);
|
|
335
|
+
const groups = new Map();
|
|
336
|
+
for (const segment of segments) {
|
|
337
|
+
const key = `${segment.parentId ?? 'root'}:${segment.angleBucket}`;
|
|
338
|
+
const group = groups.get(key);
|
|
339
|
+
if (group)
|
|
340
|
+
group.push(segment);
|
|
341
|
+
else
|
|
342
|
+
groups.set(key, [segment]);
|
|
343
|
+
}
|
|
344
|
+
for (const group of groups.values()) {
|
|
345
|
+
if (group.length < 2)
|
|
346
|
+
continue;
|
|
347
|
+
const parent = group.map((_, index) => index);
|
|
348
|
+
for (let i = 0; i < group.length; i++) {
|
|
349
|
+
for (let j = i + 1; j < group.length; j++) {
|
|
350
|
+
if (wallsCanMerge(group[i], group[j], options.maxWallJoinGap)) {
|
|
351
|
+
union(parent, i, j);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const clusters = new Map();
|
|
356
|
+
for (let i = 0; i < group.length; i++) {
|
|
357
|
+
const root = find(parent, i);
|
|
358
|
+
const cluster = clusters.get(root);
|
|
359
|
+
if (cluster)
|
|
360
|
+
cluster.push(group[i]);
|
|
361
|
+
else
|
|
362
|
+
clusters.set(root, [group[i]]);
|
|
363
|
+
}
|
|
364
|
+
for (const cluster of clusters.values()) {
|
|
365
|
+
if (cluster.length < 2)
|
|
366
|
+
continue;
|
|
367
|
+
mergeWallCluster(nodes, cluster, stats);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function signatureNumber(value, tolerance) {
|
|
372
|
+
return Math.round((value ?? 0) / tolerance);
|
|
373
|
+
}
|
|
374
|
+
function openingSignature(opening) {
|
|
375
|
+
const [x, y, z] = opening.position;
|
|
376
|
+
const family = opening.type === 'door'
|
|
377
|
+
? `${opening.openingKind}:${opening.openingShape}:${opening.doorType}`
|
|
378
|
+
: `${opening.openingKind}:${opening.openingShape}:${opening.windowType}`;
|
|
379
|
+
return [
|
|
380
|
+
opening.type,
|
|
381
|
+
family,
|
|
382
|
+
signatureNumber(x, OPENING_DUPLICATE_TOLERANCE),
|
|
383
|
+
signatureNumber(y, OPENING_DUPLICATE_TOLERANCE),
|
|
384
|
+
signatureNumber(z, OPENING_DUPLICATE_TOLERANCE),
|
|
385
|
+
signatureNumber(opening.width, OPENING_DUPLICATE_TOLERANCE),
|
|
386
|
+
signatureNumber(opening.height, OPENING_DUPLICATE_TOLERANCE),
|
|
387
|
+
].join(':');
|
|
388
|
+
}
|
|
389
|
+
function dedupeOpenings(nodes, stats) {
|
|
390
|
+
const byWall = new Map();
|
|
391
|
+
for (const node of Object.values(nodes)) {
|
|
392
|
+
if (!isOpeningNode(node))
|
|
393
|
+
continue;
|
|
394
|
+
const wallId = getOpeningWallId(node, nodes);
|
|
395
|
+
if (!wallId)
|
|
396
|
+
continue;
|
|
397
|
+
const openings = byWall.get(wallId);
|
|
398
|
+
if (openings)
|
|
399
|
+
openings.push(node);
|
|
400
|
+
else
|
|
401
|
+
byWall.set(wallId, [node]);
|
|
402
|
+
}
|
|
403
|
+
for (const openings of byWall.values()) {
|
|
404
|
+
const seen = new Set();
|
|
405
|
+
for (const opening of openings) {
|
|
406
|
+
const signature = openingSignature(opening);
|
|
407
|
+
if (!seen.has(signature)) {
|
|
408
|
+
seen.add(signature);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
delete nodes[opening.id];
|
|
412
|
+
removeNodeFromParents(nodes, opening.id);
|
|
413
|
+
stats.removedDuplicateOpenings += 1;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
export function simplifyConvertedSceneGraph(nodes, options = {}) {
|
|
418
|
+
const stats = getInitialStats(nodes);
|
|
419
|
+
if (options.enabled === false) {
|
|
420
|
+
finishStats(nodes, stats);
|
|
421
|
+
return stats;
|
|
422
|
+
}
|
|
423
|
+
const resolvedOptions = {
|
|
424
|
+
enabled: true,
|
|
425
|
+
maxWallJoinGap: options.maxWallJoinGap ?? DEFAULT_MAX_WALL_JOIN_GAP,
|
|
426
|
+
};
|
|
427
|
+
pruneTinyWalls(nodes, stats);
|
|
428
|
+
mergeWallFragments(nodes, stats, resolvedOptions);
|
|
429
|
+
syncWallOpeningChildren(nodes);
|
|
430
|
+
dedupeOpenings(nodes, stats);
|
|
431
|
+
normalizeChildren(nodes);
|
|
432
|
+
finishStats(nodes, stats);
|
|
433
|
+
return stats;
|
|
434
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type AnyNode, type AnyNodeId } from '@pascal-app/core';
|
|
2
|
+
import { type IfcConversionSimplificationOptions } from './cleanup';
|
|
3
|
+
export type { IfcConversionSimplificationOptions, IfcConversionSimplificationStats, } from './cleanup';
|
|
2
4
|
export type PascalNode = AnyNode;
|
|
3
5
|
export interface PascalSceneGraph {
|
|
4
6
|
nodes: Record<AnyNodeId, AnyNode>;
|
|
@@ -9,6 +11,7 @@ export interface ConversionOptions {
|
|
|
9
11
|
swapYZ?: boolean;
|
|
10
12
|
extrusionDepthIsHeight?: boolean;
|
|
11
13
|
swapProfileDimensions?: boolean;
|
|
14
|
+
simplify?: boolean | IfcConversionSimplificationOptions;
|
|
12
15
|
label?: string;
|
|
13
16
|
}
|
|
14
17
|
export declare const VARIANT_PRESETS: Record<string, ConversionOptions>;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,OAAO,EACZ,KAAK,SAAS,EAaf,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,OAAO,EACZ,KAAK,SAAS,EAaf,MAAM,kBAAkB,CAAA;AAGzB,OAAO,EAAE,KAAK,kCAAkC,EAA+B,MAAM,WAAW,CAAA;AAEhG,YAAY,EACV,kCAAkC,EAClC,gCAAgC,GACjC,MAAM,WAAW,CAAA;AAElB,MAAM,MAAM,UAAU,GAAG,OAAO,CAAA;AAEhC,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,CAAA;IACjC,WAAW,EAAE,SAAS,EAAE,CAAA;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACtC;AAimBD,MAAM,WAAW,iBAAiB;IAChC,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,sBAAsB,CAAC,EAAE,OAAO,CAAA;IAChC,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B,QAAQ,CAAC,EAAE,OAAO,GAAG,kCAAkC,CAAA;IACvD,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAa7D,CAAA;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,UAAU,EACnB,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,EACvD,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,CAAC,gBAAgB,CAAC,CA05C3B"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BuildingNode, ColumnNode, DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS, DoorNode, LevelNode, RoofNode, SiteNode, SlabNode, StairNode, WallNode, WindowNode, } from '@pascal-app/core';
|
|
2
2
|
import { customAlphabet } from 'nanoid';
|
|
3
3
|
import * as WebIFC from 'web-ifc';
|
|
4
|
+
import { simplifyConvertedSceneGraph } from './cleanup';
|
|
4
5
|
function meta(node) {
|
|
5
6
|
return (node?.metadata ?? {});
|
|
6
7
|
}
|
|
@@ -554,6 +555,11 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
554
555
|
extrusionDepthIsHeight: options?.extrusionDepthIsHeight ?? true,
|
|
555
556
|
swapProfileDimensions: options?.swapProfileDimensions ?? false,
|
|
556
557
|
};
|
|
558
|
+
const simplificationOptions = options?.simplify === false
|
|
559
|
+
? { enabled: false }
|
|
560
|
+
: typeof options?.simplify === 'object'
|
|
561
|
+
? options.simplify
|
|
562
|
+
: undefined;
|
|
557
563
|
const progress = (msg, pct) => {
|
|
558
564
|
console.log(`[IFC→Pascal] ${msg} (${pct}%)`);
|
|
559
565
|
onProgress?.(msg, pct);
|
|
@@ -947,7 +953,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
947
953
|
if (!wallNodeId)
|
|
948
954
|
continue;
|
|
949
955
|
const wallNode = nodes[wallNodeId];
|
|
950
|
-
if (
|
|
956
|
+
if (wallNode?.type !== 'wall')
|
|
951
957
|
continue;
|
|
952
958
|
const wallDx = wallNode.end[0] - wallNode.start[0];
|
|
953
959
|
const wallDy = wallNode.end[1] - wallNode.start[1];
|
|
@@ -1106,7 +1112,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1106
1112
|
const wallInfos = [];
|
|
1107
1113
|
for (const [wallExpressId, wallNodeId] of expressIdToNodeId) {
|
|
1108
1114
|
const node = nodes[wallNodeId];
|
|
1109
|
-
if (
|
|
1115
|
+
if (node?.type !== 'wall')
|
|
1110
1116
|
continue;
|
|
1111
1117
|
const w = node;
|
|
1112
1118
|
const length = Math.hypot(w.end[0] - w.start[0], w.end[1] - w.start[1]);
|
|
@@ -1873,6 +1879,13 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
|
|
|
1873
1879
|
catch {
|
|
1874
1880
|
/* no type rels */
|
|
1875
1881
|
}
|
|
1882
|
+
progress('Simplifying converted scene...', 94);
|
|
1883
|
+
const simplificationStats = simplifyConvertedSceneGraph(nodes, simplificationOptions);
|
|
1884
|
+
if (simplificationStats.removedTinyWalls > 0 ||
|
|
1885
|
+
simplificationStats.removedMergedWalls > 0 ||
|
|
1886
|
+
simplificationStats.removedDuplicateOpenings > 0) {
|
|
1887
|
+
console.log('[IFC→Pascal] Simplification:', simplificationStats);
|
|
1888
|
+
}
|
|
1876
1889
|
ifcApi.CloseModel(modelID);
|
|
1877
1890
|
progress('Building scene graph...', 95);
|
|
1878
1891
|
const totalNodes = Object.keys(nodes).length;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pascal-app/ifc-converter",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "IFC → Pascal scene-graph conversion. Pure logic — no DOM, no React.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "tsc --build",
|
|
21
|
-
"dev": "
|
|
21
|
+
"dev": "tsgo --build --watch",
|
|
22
22
|
"prepublishOnly": "npm run build"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@pascal/typescript-config": "*",
|
|
31
31
|
"@types/bun": "^1.3.0",
|
|
32
|
-
"typescript": "6.0.
|
|
32
|
+
"typescript": "6.0.3"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|