@pascal-app/ifc-converter 1.0.0-beta.4 → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pascal Group Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1 +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"}
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;AAseD,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,UAAU,EACjB,OAAO,GAAE,kCAAuC,GAC/C,gCAAgC,CAoBlC"}
package/dist/cleanup.js CHANGED
@@ -142,11 +142,32 @@ function toWallSegment(wall) {
142
142
  };
143
143
  }
144
144
  function wallLineTolerance(a, b) {
145
- return Math.max(0.06, Math.min(0.14, Math.max(a.thickness, b.thickness) * 0.5));
145
+ // Fragments must share essentially the same centerline. A tolerance based on
146
+ // half the wall thickness can collapse adjacent walls whose faces merely meet.
147
+ return Math.max(0.005, Math.min(0.025, Math.max(a.thickness, b.thickness) * 0.1));
146
148
  }
147
149
  function wallHeightCompatible(a, b) {
148
150
  return Math.abs(a.height - b.height) <= WALL_HEIGHT_TOLERANCE;
149
151
  }
152
+ function wallMaterialSignature(segment) {
153
+ const metadata = segment.wall.metadata;
154
+ const material = typeof metadata?.material === 'string' ? metadata.material : null;
155
+ const layers = Array.isArray(metadata?.materialLayers)
156
+ ? metadata.materialLayers.map((layer) => {
157
+ const value = layer;
158
+ return [
159
+ typeof value.name === 'string' ? value.name : null,
160
+ typeof value.thickness === 'number' ? value.thickness : null,
161
+ ];
162
+ })
163
+ : [];
164
+ if (material === null && layers.length === 0)
165
+ return null;
166
+ return JSON.stringify({ material, layers });
167
+ }
168
+ function wallMaterialCompatible(a, b) {
169
+ return wallMaterialSignature(a) === wallMaterialSignature(b);
170
+ }
150
171
  function wallIntervalsCompatible(a, b, maxJoinGap) {
151
172
  const gap = Math.max(a.t0, b.t0) - Math.min(a.t1, b.t1);
152
173
  if (gap <= maxJoinGap)
@@ -165,6 +186,8 @@ function wallsCanMerge(a, b, maxJoinGap) {
165
186
  return false;
166
187
  if (!wallHeightCompatible(a, b))
167
188
  return false;
189
+ if (!wallMaterialCompatible(a, b))
190
+ return false;
168
191
  return wallIntervalsCompatible(a, b, maxJoinGap);
169
192
  }
170
193
  function find(parent, index) {
@@ -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;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"}
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,CA65C3B"}
package/dist/index.js CHANGED
@@ -5,11 +5,10 @@ import { simplifyConvertedSceneGraph } from './cleanup';
5
5
  function meta(node) {
6
6
  return (node?.metadata ?? {});
7
7
  }
8
- // Pascal's `BaseNode.metadata` is `z.json()` — a recursive JSON value
9
- // type that doesn't accept `undefined` (JSON has `null`, not undefined).
10
- // The converter pulls many fields from optional IFC properties that
11
- // often return `undefined`; stripping them at the boundary keeps the
12
- // schemas happy without spraying `?? null` through every assignment.
8
+ // The converter pulls many metadata fields from optional IFC properties
9
+ // that often return `undefined`. Those keys vanish the moment the graph is
10
+ // serialized, so stripping them here keeps the in-memory scene identical to
11
+ // the persisted one instead of spraying `?? null` through every assignment.
13
12
  function buildMetadata(input) {
14
13
  const out = {};
15
14
  for (const [key, value] of Object.entries(input)) {
@@ -621,9 +620,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
621
620
  parentMap.set(childID, parentExpressID);
622
621
  });
623
622
  if (!childrenMap.has(parentExpressID)) {
624
- childrenMap.set(parentExpressID, []);
623
+ childrenMap.set(parentExpressID, new Set());
625
624
  }
626
- childrenMap.get(parentExpressID)?.push(...children);
625
+ for (const childID of children)
626
+ childrenMap.get(parentExpressID).add(childID);
627
627
  }
628
628
  }
629
629
  // Second pass: collect spatial containment
@@ -638,9 +638,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
638
638
  parentMap.set(childID, parentExpressID);
639
639
  });
640
640
  if (!childrenMap.has(parentExpressID)) {
641
- childrenMap.set(parentExpressID, []);
641
+ childrenMap.set(parentExpressID, new Set());
642
642
  }
643
- childrenMap.get(parentExpressID)?.push(...children);
643
+ for (const childID of children)
644
+ childrenMap.get(parentExpressID).add(childID);
644
645
  }
645
646
  }
646
647
  // Resolve containing storey for an element by walking the parent chain
@@ -973,6 +974,10 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
973
974
  const fillId = openingToFill.get(openingId);
974
975
  if (!fillId)
975
976
  continue;
977
+ // IFC fills belong to at most one opening, which voids one host element.
978
+ // Repeated or conflicting relationships must not emit another node.
979
+ if (expressIdToNodeId.has(fillId))
980
+ continue;
976
981
  const isDoor = doorExpressIds.has(fillId);
977
982
  const isWindow = windowExpressIds.has(fillId);
978
983
  if (!isDoor && !isWindow)
@@ -1035,7 +1040,6 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1035
1040
  }
1036
1041
  if (isDoor) {
1037
1042
  const nodeId = generateId('door');
1038
- expressIdToNodeId.set(fillId, nodeId);
1039
1043
  // Vertical centering is now handled: door center Y = height/2 so the
1040
1044
  // opening sits at the correct position. Remaining caveat: door bottom
1041
1045
  // is assumed at floor y=0 (i.e. the door starts at the wall's base).
@@ -1059,11 +1063,11 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1059
1063
  }),
1060
1064
  });
1061
1065
  nodes[nodeId] = doorNode;
1066
+ expressIdToNodeId.set(fillId, nodeId);
1062
1067
  wallNode.children.push(nodeId);
1063
1068
  }
1064
1069
  else {
1065
1070
  const nodeId = generateId('window');
1066
- expressIdToNodeId.set(fillId, nodeId);
1067
1071
  // TODO(ifc-fix): same scalar-vs-tuple position issue as door above.
1068
1072
  // sillHeight stays read-only metadata until we resolve the window
1069
1073
  // schema (Pascal's WindowNode doesn't have sillHeight today —
@@ -1092,6 +1096,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1092
1096
  }),
1093
1097
  });
1094
1098
  nodes[nodeId] = windowNode;
1099
+ expressIdToNodeId.set(fillId, nodeId);
1095
1100
  wallNode.children.push(nodeId);
1096
1101
  }
1097
1102
  }
@@ -1209,7 +1214,6 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1209
1214
  if (isDoor) {
1210
1215
  const h = height ?? 2.1;
1211
1216
  const nodeId = generateId('door');
1212
- expressIdToNodeId.set(fillId, nodeId);
1213
1217
  const doorNode = tryParse(DoorNode, 'door', {
1214
1218
  object: 'node',
1215
1219
  id: nodeId,
@@ -1230,6 +1234,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1230
1234
  }),
1231
1235
  });
1232
1236
  nodes[nodeId] = doorNode;
1237
+ expressIdToNodeId.set(fillId, nodeId);
1233
1238
  if (parentNodeId && nodes[parentNodeId]) {
1234
1239
  ;
1235
1240
  nodes[parentNodeId].children?.push(nodeId);
@@ -1239,7 +1244,6 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1239
1244
  const h = height ?? 1.2;
1240
1245
  const sill = hosted && scene ? Math.max(0, scene[2] - hosted.info.baseY) : 0;
1241
1246
  const nodeId = generateId('window');
1242
- expressIdToNodeId.set(fillId, nodeId);
1243
1247
  const windowNode = tryParse(WindowNode, 'window', {
1244
1248
  object: 'node',
1245
1249
  id: nodeId,
@@ -1259,6 +1263,7 @@ export async function convertIfcToPascal(ifcData, onProgress, options) {
1259
1263
  }),
1260
1264
  });
1261
1265
  nodes[nodeId] = windowNode;
1266
+ expressIdToNodeId.set(fillId, nodeId);
1262
1267
  if (parentNodeId && nodes[parentNodeId]) {
1263
1268
  ;
1264
1269
  nodes[parentNodeId].children?.push(nodeId);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pascal-app/ifc-converter",
3
- "version": "1.0.0-beta.4",
4
- "description": "IFC → Pascal scene-graph conversion. Pure logic — no DOM, no React.",
3
+ "version": "1.0.0",
4
+ "description": "IFC \u2192 Pascal scene-graph conversion. Pure logic \u2014 no DOM, no React.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -19,10 +19,11 @@
19
19
  "scripts": {
20
20
  "build": "tsc --build",
21
21
  "dev": "tsgo --build --watch",
22
+ "test": "bun test tests",
22
23
  "prepublishOnly": "npm run build"
23
24
  },
24
25
  "dependencies": {
25
- "@pascal-app/core": "*",
26
+ "@pascal-app/core": "^1.0.0",
26
27
  "nanoid": "^5.1.6",
27
28
  "web-ifc": "^0.0.77"
28
29
  },