@ifc-lite/export 1.21.0 → 2.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 +12 -9
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/lod1-generator.d.ts.map +1 -1
- package/dist/lod1-generator.js +10 -11
- package/dist/lod1-generator.js.map +1 -1
- package/dist/merged-exporter.d.ts +146 -6
- package/dist/merged-exporter.d.ts.map +1 -1
- package/dist/merged-exporter.js +428 -186
- package/dist/merged-exporter.js.map +1 -1
- package/package.json +5 -5
- package/dist/csv-exporter.d.ts +0 -54
- package/dist/csv-exporter.d.ts.map +0 -1
- package/dist/csv-exporter.js +0 -254
- package/dist/csv-exporter.js.map +0 -1
- package/dist/gltf-exporter.d.ts +0 -57
- package/dist/gltf-exporter.d.ts.map +0 -1
- package/dist/gltf-exporter.js +0 -352
- package/dist/gltf-exporter.js.map +0 -1
- package/dist/jsonld-exporter.d.ts +0 -36
- package/dist/jsonld-exporter.d.ts.map +0 -1
- package/dist/jsonld-exporter.js +0 -167
- package/dist/jsonld-exporter.js.map +0 -1
package/dist/merged-exporter.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
2
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
-
import { generateHeader } from '@ifc-lite/parser';
|
|
4
|
+
import { generateHeader, deterministicGlobalId } from '@ifc-lite/parser';
|
|
5
5
|
import { decodeIfcString } from '@ifc-lite/encoding';
|
|
6
6
|
import { safeUtf8Decode } from '@ifc-lite/data';
|
|
7
7
|
import { collectReferencedEntityIds, getVisibleEntityIds, collectStyleEntities } from './reference-collector.js';
|
|
@@ -14,18 +14,97 @@ const SHARED_INFRASTRUCTURE_TYPES = new Set([
|
|
|
14
14
|
'IFCGEOMETRICREPRESENTATIONCONTEXT',
|
|
15
15
|
'IFCGEOMETRICREPRESENTATIONSUBCONTEXT',
|
|
16
16
|
]);
|
|
17
|
+
/**
|
|
18
|
+
* An IfcGloballyUniqueId is exactly 22 characters of the buildingSMART base64
|
|
19
|
+
* alphabet. We use this to recognise a rooted entity (IfcRoot subtype) by its
|
|
20
|
+
* first attribute. Geometry/list entities never carry a string there, but some
|
|
21
|
+
* non-rooted RESOURCE entities lead with a Name/Identifier string that can
|
|
22
|
+
* legitimately be 22 charset chars (e.g. a coded property key). Those are
|
|
23
|
+
* excluded by type ({@link NON_ROOTED_STRING_TYPES}) so their Name is never
|
|
24
|
+
* mistaken for a GlobalId — otherwise the GlobalId reconciliation could drop or
|
|
25
|
+
* rename them.
|
|
26
|
+
*/
|
|
27
|
+
const GLOBAL_ID_RE = /^[0-9A-Za-z_$]{22}$/;
|
|
28
|
+
/**
|
|
29
|
+
* Non-IfcRoot entity types whose first attribute is (or can be) a quoted
|
|
30
|
+
* Name/Identifier string. They must NOT be treated as rooted by GlobalId, even
|
|
31
|
+
* when that string happens to be 22 charset characters. (IfcRoot property
|
|
32
|
+
* containers like IFCPROPERTYSET / IFCELEMENTQUANTITY are deliberately absent —
|
|
33
|
+
* they ARE rooted and carry a real GlobalId at attribute 0.)
|
|
34
|
+
*
|
|
35
|
+
* This is a best-effort denylist, not an exhaustive IfcRoot classifier — the
|
|
36
|
+
* merge works off raw STEP text and has no schema table. It covers the resource
|
|
37
|
+
* families that realistically appear in federated models; an unlisted
|
|
38
|
+
* string-leading resource type is only ever a problem if two models share an
|
|
39
|
+
* identical 22-char charset Name for it AND it collides, which is negligible. A
|
|
40
|
+
* miss in the other direction (treating a real root as non-rooted) is safe — it
|
|
41
|
+
* just skips one GlobalId reconciliation.
|
|
42
|
+
*/
|
|
43
|
+
const NON_ROOTED_STRING_TYPES = new Set([
|
|
44
|
+
// IfcSimpleProperty / IfcComplexProperty (IfcPropertyAbstraction — not rooted)
|
|
45
|
+
'IFCPROPERTYSINGLEVALUE', 'IFCPROPERTYENUMERATEDVALUE', 'IFCPROPERTYLISTVALUE',
|
|
46
|
+
'IFCPROPERTYBOUNDEDVALUE', 'IFCPROPERTYTABLEVALUE', 'IFCPROPERTYREFERENCEVALUE',
|
|
47
|
+
'IFCCOMPLEXPROPERTY',
|
|
48
|
+
// IfcPhysicalQuantity (not rooted)
|
|
49
|
+
'IFCQUANTITYLENGTH', 'IFCQUANTITYAREA', 'IFCQUANTITYVOLUME', 'IFCQUANTITYCOUNT',
|
|
50
|
+
'IFCQUANTITYWEIGHT', 'IFCQUANTITYTIME', 'IFCQUANTITYNUMBER', 'IFCPHYSICALCOMPLEXQUANTITY',
|
|
51
|
+
// Materials & their constituents (IfcMaterialDefinition — not rooted; lead with a Name)
|
|
52
|
+
'IFCMATERIAL', 'IFCMATERIALPROFILE', 'IFCMATERIALPROFILESET',
|
|
53
|
+
'IFCMATERIALCONSTITUENT', 'IFCMATERIALCONSTITUENTSET',
|
|
54
|
+
// Classification, library & document refs (IfcExternalInformation/Reference)
|
|
55
|
+
'IFCCLASSIFICATION', 'IFCCLASSIFICATIONREFERENCE',
|
|
56
|
+
'IFCLIBRARYINFORMATION', 'IFCLIBRARYREFERENCE', 'IFCEXTERNALREFERENCE',
|
|
57
|
+
'IFCDOCUMENTINFORMATION', 'IFCDOCUMENTREFERENCE',
|
|
58
|
+
// Constraints & approvals (lead with a Name/Identifier)
|
|
59
|
+
'IFCMETRIC', 'IFCOBJECTIVE', 'IFCAPPROVAL', 'IFCTABLE',
|
|
60
|
+
// Actors (IfcPerson/IfcOrganization lead with an Identification string)
|
|
61
|
+
'IFCPERSON', 'IFCORGANIZATION',
|
|
62
|
+
// Presentation layers, styles & text literals (lead with a Name/Literal string)
|
|
63
|
+
'IFCPRESENTATIONLAYERASSIGNMENT', 'IFCPRESENTATIONLAYERWITHSTYLE',
|
|
64
|
+
'IFCSURFACESTYLE', 'IFCCURVESTYLE', 'IFCTEXTSTYLE', 'IFCFILLAREASTYLE',
|
|
65
|
+
'IFCTEXTLITERAL', 'IFCTEXTLITERALWITHEXTENT',
|
|
66
|
+
]);
|
|
67
|
+
/** True for IfcRelationship subtypes (objectified relationships). */
|
|
68
|
+
function isRelationshipType(typeUpper) {
|
|
69
|
+
return typeUpper.startsWith('IFCREL');
|
|
70
|
+
}
|
|
71
|
+
/** Relative tolerance for comparing two length unit scale factors. */
|
|
72
|
+
const UNIT_SCALE_TOLERANCE = 1e-6;
|
|
17
73
|
/**
|
|
18
74
|
* Merges multiple IFC models into a single STEP file.
|
|
19
75
|
*
|
|
20
76
|
* Uses the same approach as IfcOpenShell's MergeProjects recipe, extended
|
|
21
|
-
* with spatial hierarchy unification:
|
|
77
|
+
* with spatial hierarchy unification and unit-aware federation:
|
|
22
78
|
* 1. First model's entities use their original IDs
|
|
23
79
|
* 2. Subsequent models' IDs are offset to avoid collisions
|
|
24
|
-
* 3.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
80
|
+
* 3. A model that shares the first model's length unit is *unified*: its
|
|
81
|
+
* IfcProject is remapped to the first model's, spatial structure (Site,
|
|
82
|
+
* Building, Storey) is unified by name/elevation, and shared infrastructure
|
|
83
|
+
* (units, contexts) is deduplicated.
|
|
84
|
+
* 4. A model with a *different* length unit is *federated*: it keeps its own
|
|
85
|
+
* IfcProject, IfcUnitAssignment and representation contexts, so its raw
|
|
86
|
+
* coordinates remain correctly scaled rather than being reinterpreted under
|
|
87
|
+
* the first model's unit (the mis-scale bug, issue #1332).
|
|
88
|
+
* 5. GlobalIds are reconciled, not blindly duplicated: a non-relationship
|
|
89
|
+
* rooted entity that repeats a GlobalId already emitted *in the same unit
|
|
90
|
+
* space* is unified (references remapped to the one instance). Otherwise —
|
|
91
|
+
* a federated/different-unit instance, or an objectified relationship whose
|
|
92
|
+
* payload (RelatedObjects) may differ — it is kept and re-stamped with a
|
|
93
|
+
* fresh deterministic GlobalId so the file has no duplicate-GlobalId errors
|
|
94
|
+
* and no relationship membership is lost.
|
|
95
|
+
*
|
|
96
|
+
* Conformance trade-off: when federation triggers, the file contains more than
|
|
97
|
+
* one IfcProject, which intentionally relaxes the IfcSingleProjectInstance
|
|
98
|
+
* EXPRESS rule (SIZEOF(IfcProject) <= 1). This is the only way to keep two
|
|
99
|
+
* different length units in one STEP file without rewriting every length-valued
|
|
100
|
+
* coordinate, and it is strictly better than the previous silent mis-scale.
|
|
101
|
+
* `MergeExportResult.stats.warnings` flags it; pass
|
|
102
|
+
* `unitReconciliation: 'assume-shared'` to force a single project when units
|
|
103
|
+
* are already normalised.
|
|
104
|
+
*
|
|
105
|
+
* Limitation: federation only unifies a model against the *first* model's unit
|
|
106
|
+
* group. Two non-first models that share a unit different from the first are
|
|
107
|
+
* each kept as independent projects (correct, just less deduplicated).
|
|
29
108
|
*/
|
|
30
109
|
export class MergedExporter {
|
|
31
110
|
models;
|
|
@@ -38,117 +117,39 @@ export class MergedExporter {
|
|
|
38
117
|
export(options) {
|
|
39
118
|
const onProgress = options.onProgress;
|
|
40
119
|
const schema = (options.schema || 'IFC4');
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// header rather than picking one model's FILE_DESCRIPTION arbitrarily.
|
|
44
|
-
const header = generateHeader({
|
|
45
|
-
schema,
|
|
46
|
-
description: options.description || `Merged export of ${this.models.length} models from ifc-lite`,
|
|
47
|
-
author: options.author || '',
|
|
48
|
-
organization: options.organization || '',
|
|
49
|
-
application: options.application || 'ifc-lite',
|
|
50
|
-
filename: options.filename || 'merged.ifc',
|
|
51
|
-
});
|
|
120
|
+
const header = this.buildHeader(options, schema);
|
|
121
|
+
const setup = this.buildMergeSetup(options);
|
|
52
122
|
const allEntityLines = [];
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// deferred property atoms) so the next model's offset clears every id this
|
|
58
|
-
// model will emit — otherwise a deferred atom at a high id collides.
|
|
59
|
-
for (const model of this.models) {
|
|
60
|
-
modelOffsets.set(model.id, nextAvailableId - 1); // offset = nextAvailableId - 1 so IDs start at nextAvailableId
|
|
61
|
-
nextAvailableId += getMaxExpressId(getCompleteEntityIndex(model.dataStore));
|
|
62
|
-
}
|
|
63
|
-
// Collect first model's info for deduplication
|
|
64
|
-
const firstModel = this.models[0];
|
|
65
|
-
const firstModelOffset = modelOffsets.get(firstModel.id);
|
|
66
|
-
const firstModelInfraMap = this.findInfrastructureEntities(firstModel.dataStore);
|
|
67
|
-
const firstProjectIds = this.findEntitiesByType(firstModel.dataStore, 'IFCPROJECT');
|
|
68
|
-
// Build spatial lookup from first model for Site/Building/Storey unification
|
|
69
|
-
const spatialLookup = this.buildSpatialLookup(firstModel.dataStore);
|
|
70
|
-
// Process each model
|
|
123
|
+
// Tracks every GlobalId already emitted → its final express id + unit scale,
|
|
124
|
+
// so later models can unify against (shared unit) or stay unique from
|
|
125
|
+
// (federated / different unit) it.
|
|
126
|
+
const guidToFinalId = new Map();
|
|
71
127
|
let isFirstModel = true;
|
|
128
|
+
let federatedModelCount = 0;
|
|
72
129
|
for (const model of this.models) {
|
|
73
|
-
const offset = modelOffsets.get(model.id);
|
|
130
|
+
const offset = setup.modelOffsets.get(model.id);
|
|
74
131
|
const source = model.dataStore.source;
|
|
75
132
|
if (!source || source.length === 0)
|
|
76
133
|
continue;
|
|
77
134
|
// Complete view over byId + any deferred property atoms, so the closure
|
|
78
135
|
// walk and the emit loop both reach every entity the source defines.
|
|
79
136
|
const completeIndex = getCompleteEntityIndex(model.dataStore);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
collectStyleEntities(includedEntityIds, source, {
|
|
89
|
-
byId: completeIndex,
|
|
90
|
-
byType: model.dataStore.entityIndex.byType,
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
// Build remap table (references to remap) and skip set (entities to omit)
|
|
94
|
-
const sharedRemap = new Map();
|
|
95
|
-
const skipEntityIds = new Set();
|
|
96
|
-
if (!isFirstModel) {
|
|
97
|
-
// Remap this model's IfcProject references → first model's IfcProject.
|
|
98
|
-
const projectIds = this.findEntitiesByType(model.dataStore, 'IFCPROJECT');
|
|
99
|
-
if (firstProjectIds.length > 0) {
|
|
100
|
-
for (const pid of projectIds) {
|
|
101
|
-
sharedRemap.set(pid, firstProjectIds[0] + firstModelOffset);
|
|
102
|
-
skipEntityIds.add(pid);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
// Remap and skip duplicate infrastructure (units, contexts)
|
|
106
|
-
const modelInfra = this.findInfrastructureEntities(model.dataStore);
|
|
107
|
-
for (const [type, firstIds] of firstModelInfraMap) {
|
|
108
|
-
const thisIds = modelInfra.get(type);
|
|
109
|
-
if (thisIds && firstIds.length > 0 && thisIds.length > 0) {
|
|
110
|
-
sharedRemap.set(thisIds[0], firstIds[0] + firstModelOffset);
|
|
111
|
-
skipEntityIds.add(thisIds[0]);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
// Unify spatial hierarchy: match Site, Building, Storey to first model
|
|
115
|
-
this.unifySpatialEntities(model.dataStore, spatialLookup, firstModelOffset, sharedRemap, skipEntityIds);
|
|
116
|
-
// Skip IfcRelAggregates that become fully redundant after unification.
|
|
117
|
-
// e.g. Model2's Project→Site becomes FirstProject→FirstSite which
|
|
118
|
-
// already exists from Model1, causing duplicate tree nodes.
|
|
119
|
-
this.skipRedundantRelAggregates(model.dataStore, sharedRemap, skipEntityIds);
|
|
120
|
-
}
|
|
121
|
-
// Emit entities for this model
|
|
137
|
+
const includedEntityIds = this.computeIncludedEntityIds(model, options, completeIndex, source);
|
|
138
|
+
const modelScale = this.resolveUnitScale(model);
|
|
139
|
+
const compatible = isFirstModel || setup.assumeShared
|
|
140
|
+
|| this.unitsCompatible(modelScale, setup.primaryScale);
|
|
141
|
+
if (!isFirstModel && !compatible)
|
|
142
|
+
federatedModelCount++;
|
|
143
|
+
const plan = this.planModel(model, completeIndex, isFirstModel, compatible, setup, guidToFinalId);
|
|
144
|
+
const sourceSchema = model.dataStore.schemaVersion || 'IFC4';
|
|
122
145
|
for (const [expressId, entityRef] of completeIndex) {
|
|
123
|
-
|
|
124
|
-
if (includedEntityIds !== null && !includedEntityIds.has(expressId)) {
|
|
146
|
+
if (includedEntityIds !== null && !includedEntityIds.has(expressId))
|
|
125
147
|
continue;
|
|
126
|
-
|
|
127
|
-
// Skip duplicate entities (project, infrastructure)
|
|
128
|
-
if (skipEntityIds.has(expressId)) {
|
|
148
|
+
if (plan.skipEntityIds.has(expressId))
|
|
129
149
|
continue;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// Remap IDs if this is not the first model or if offset is non-zero
|
|
134
|
-
let finalText;
|
|
135
|
-
if (offset === 0 && sharedRemap.size === 0) {
|
|
136
|
-
finalText = entityText;
|
|
137
|
-
}
|
|
138
|
-
else {
|
|
139
|
-
finalText = this.remapEntityText(entityText, offset, sharedRemap);
|
|
140
|
-
}
|
|
141
|
-
// Apply schema conversion if target differs from source
|
|
142
|
-
const sourceSchema = model.dataStore.schemaVersion || 'IFC4';
|
|
143
|
-
if (needsConversion(sourceSchema, schema)) {
|
|
144
|
-
const converted = convertStepLine(finalText, sourceSchema, schema);
|
|
145
|
-
if (converted !== null) {
|
|
146
|
-
allEntityLines.push(converted);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
allEntityLines.push(finalText);
|
|
151
|
-
}
|
|
150
|
+
const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId, modelScale);
|
|
151
|
+
if (line !== null)
|
|
152
|
+
allEntityLines.push(line);
|
|
152
153
|
}
|
|
153
154
|
isFirstModel = false;
|
|
154
155
|
}
|
|
@@ -158,11 +159,7 @@ export class MergedExporter {
|
|
|
158
159
|
const content = assembleStepBytes(header, allEntityLines);
|
|
159
160
|
return {
|
|
160
161
|
content,
|
|
161
|
-
stats:
|
|
162
|
-
modelCount: this.models.length,
|
|
163
|
-
totalEntityCount: allEntityLines.length,
|
|
164
|
-
fileSize: content.byteLength,
|
|
165
|
-
},
|
|
162
|
+
stats: this.buildStats(allEntityLines.length, content.byteLength, federatedModelCount),
|
|
166
163
|
};
|
|
167
164
|
}
|
|
168
165
|
/**
|
|
@@ -175,38 +172,23 @@ export class MergedExporter {
|
|
|
175
172
|
const schema = (options.schema || 'IFC4');
|
|
176
173
|
// See export(): merged files emit an ifc-lite provenance header by policy
|
|
177
174
|
// (no single source header to preserve across federated models).
|
|
178
|
-
const header =
|
|
179
|
-
|
|
180
|
-
description: options.description || `Merged export of ${this.models.length} models from ifc-lite`,
|
|
181
|
-
author: options.author || '',
|
|
182
|
-
organization: options.organization || '',
|
|
183
|
-
application: options.application || 'ifc-lite',
|
|
184
|
-
filename: options.filename || 'merged.ifc',
|
|
185
|
-
});
|
|
175
|
+
const header = this.buildHeader(options, schema);
|
|
176
|
+
const setup = this.buildMergeSetup(options);
|
|
186
177
|
const allEntityLines = [];
|
|
178
|
+
const guidToFinalId = new Map();
|
|
187
179
|
// First pass: count total entities for progress
|
|
188
180
|
let totalEntities = 0;
|
|
189
181
|
for (const model of this.models) {
|
|
190
182
|
totalEntities += getCompleteEntityIndex(model.dataStore).size;
|
|
191
183
|
}
|
|
192
|
-
let nextAvailableId = 1;
|
|
193
|
-
const modelOffsets = new Map();
|
|
194
|
-
for (const model of this.models) {
|
|
195
|
-
modelOffsets.set(model.id, nextAvailableId - 1);
|
|
196
|
-
nextAvailableId += getMaxExpressId(getCompleteEntityIndex(model.dataStore));
|
|
197
|
-
}
|
|
198
|
-
const firstModel = this.models[0];
|
|
199
|
-
const firstModelOffset = modelOffsets.get(firstModel.id);
|
|
200
|
-
const firstModelInfraMap = this.findInfrastructureEntities(firstModel.dataStore);
|
|
201
|
-
const firstProjectIds = this.findEntitiesByType(firstModel.dataStore, 'IFCPROJECT');
|
|
202
|
-
const spatialLookup = this.buildSpatialLookup(firstModel.dataStore);
|
|
203
184
|
let isFirstModel = true;
|
|
204
185
|
let entitiesProcessed = 0;
|
|
186
|
+
let federatedModelCount = 0;
|
|
205
187
|
const YIELD_INTERVAL = 2000;
|
|
206
188
|
if (onProgress)
|
|
207
189
|
onProgress({ phase: 'preparing', percent: 0, entitiesProcessed: 0, entitiesTotal: totalEntities });
|
|
208
190
|
for (const model of this.models) {
|
|
209
|
-
const offset = modelOffsets.get(model.id);
|
|
191
|
+
const offset = setup.modelOffsets.get(model.id);
|
|
210
192
|
const source = model.dataStore.source;
|
|
211
193
|
if (!source || source.length === 0)
|
|
212
194
|
continue;
|
|
@@ -220,61 +202,23 @@ export class MergedExporter {
|
|
|
220
202
|
});
|
|
221
203
|
}
|
|
222
204
|
const completeIndex = getCompleteEntityIndex(model.dataStore);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
byType: model.dataStore.entityIndex.byType,
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
const sharedRemap = new Map();
|
|
235
|
-
const skipEntityIds = new Set();
|
|
236
|
-
if (!isFirstModel) {
|
|
237
|
-
const projectIds = this.findEntitiesByType(model.dataStore, 'IFCPROJECT');
|
|
238
|
-
if (firstProjectIds.length > 0) {
|
|
239
|
-
for (const pid of projectIds) {
|
|
240
|
-
sharedRemap.set(pid, firstProjectIds[0] + firstModelOffset);
|
|
241
|
-
skipEntityIds.add(pid);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
const modelInfra = this.findInfrastructureEntities(model.dataStore);
|
|
245
|
-
for (const [type, firstIds] of firstModelInfraMap) {
|
|
246
|
-
const thisIds = modelInfra.get(type);
|
|
247
|
-
if (thisIds && firstIds.length > 0 && thisIds.length > 0) {
|
|
248
|
-
sharedRemap.set(thisIds[0], firstIds[0] + firstModelOffset);
|
|
249
|
-
skipEntityIds.add(thisIds[0]);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
this.unifySpatialEntities(model.dataStore, spatialLookup, firstModelOffset, sharedRemap, skipEntityIds);
|
|
253
|
-
this.skipRedundantRelAggregates(model.dataStore, sharedRemap, skipEntityIds);
|
|
254
|
-
}
|
|
205
|
+
const includedEntityIds = this.computeIncludedEntityIds(model, options, completeIndex, source);
|
|
206
|
+
const modelScale = this.resolveUnitScale(model);
|
|
207
|
+
const compatible = isFirstModel || setup.assumeShared
|
|
208
|
+
|| this.unitsCompatible(modelScale, setup.primaryScale);
|
|
209
|
+
if (!isFirstModel && !compatible)
|
|
210
|
+
federatedModelCount++;
|
|
211
|
+
const plan = this.planModel(model, completeIndex, isFirstModel, compatible, setup, guidToFinalId);
|
|
212
|
+
const sourceSchema = model.dataStore.schemaVersion || 'IFC4';
|
|
255
213
|
let entityCount = 0;
|
|
256
214
|
for (const [expressId, entityRef] of completeIndex) {
|
|
257
215
|
if (includedEntityIds !== null && !includedEntityIds.has(expressId))
|
|
258
216
|
continue;
|
|
259
|
-
if (skipEntityIds.has(expressId))
|
|
217
|
+
if (plan.skipEntityIds.has(expressId))
|
|
260
218
|
continue;
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
finalText = entityText;
|
|
265
|
-
}
|
|
266
|
-
else {
|
|
267
|
-
finalText = this.remapEntityText(entityText, offset, sharedRemap);
|
|
268
|
-
}
|
|
269
|
-
const sourceSchema = model.dataStore.schemaVersion || 'IFC4';
|
|
270
|
-
if (needsConversion(sourceSchema, schema)) {
|
|
271
|
-
const converted = convertStepLine(finalText, sourceSchema, schema);
|
|
272
|
-
if (converted !== null)
|
|
273
|
-
allEntityLines.push(converted);
|
|
274
|
-
}
|
|
275
|
-
else {
|
|
276
|
-
allEntityLines.push(finalText);
|
|
277
|
-
}
|
|
219
|
+
const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId, modelScale);
|
|
220
|
+
if (line !== null)
|
|
221
|
+
allEntityLines.push(line);
|
|
278
222
|
entityCount++;
|
|
279
223
|
entitiesProcessed++;
|
|
280
224
|
// Yield to event loop every YIELD_INTERVAL entities
|
|
@@ -304,13 +248,311 @@ export class MergedExporter {
|
|
|
304
248
|
}
|
|
305
249
|
return {
|
|
306
250
|
content,
|
|
307
|
-
stats:
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
251
|
+
stats: this.buildStats(allEntityLines.length, content.byteLength, federatedModelCount),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Assemble the result stats, including any federation conformance warnings.
|
|
256
|
+
*/
|
|
257
|
+
buildStats(totalEntityCount, fileSize, federatedModelCount) {
|
|
258
|
+
const warnings = [];
|
|
259
|
+
if (federatedModelCount > 0) {
|
|
260
|
+
warnings.push(`${federatedModelCount} model(s) had a length unit differing from the first model and were ` +
|
|
261
|
+
`federated as separate IfcProject roots to keep their geometry correctly scaled. The output ` +
|
|
262
|
+
`therefore contains ${federatedModelCount + 1} IfcProject instances, which intentionally relaxes ` +
|
|
263
|
+
`the IfcSingleProjectInstance rule (SIZEOF(IfcProject) <= 1). Some single-project viewers may ` +
|
|
264
|
+
`only show the first project. Pass unitReconciliation:'assume-shared' to force one project when ` +
|
|
265
|
+
`units are already normalised.`);
|
|
266
|
+
}
|
|
267
|
+
return { modelCount: this.models.length, totalEntityCount, fileSize, federatedModelCount, warnings };
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Build the ifc-lite provenance header. Merged files have no single source
|
|
271
|
+
* header to round-trip, so we deliberately emit our own rather than picking
|
|
272
|
+
* one model's FILE_DESCRIPTION arbitrarily.
|
|
273
|
+
*/
|
|
274
|
+
buildHeader(options, schema) {
|
|
275
|
+
return generateHeader({
|
|
276
|
+
schema,
|
|
277
|
+
description: options.description || `Merged export of ${this.models.length} models from ifc-lite`,
|
|
278
|
+
author: options.author || '',
|
|
279
|
+
organization: options.organization || '',
|
|
280
|
+
application: options.application || 'ifc-lite',
|
|
281
|
+
filename: options.filename || 'merged.ifc',
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Compute the model-independent state shared by export()/exportAsync():
|
|
286
|
+
* per-model id offsets and the primary model's project/infra/spatial/unit info.
|
|
287
|
+
*/
|
|
288
|
+
buildMergeSetup(options) {
|
|
289
|
+
// Determine ID offsets. Span the COMPLETE entity set (incl. deferred
|
|
290
|
+
// property atoms) so the next model's offset clears every id this model
|
|
291
|
+
// will emit — otherwise a deferred atom at a high id collides.
|
|
292
|
+
let nextAvailableId = 1;
|
|
293
|
+
const modelOffsets = new Map();
|
|
294
|
+
for (const model of this.models) {
|
|
295
|
+
modelOffsets.set(model.id, nextAvailableId - 1); // start at nextAvailableId
|
|
296
|
+
nextAvailableId += getMaxExpressId(getCompleteEntityIndex(model.dataStore));
|
|
297
|
+
}
|
|
298
|
+
const firstModel = this.models[0];
|
|
299
|
+
return {
|
|
300
|
+
modelOffsets,
|
|
301
|
+
firstModelOffset: modelOffsets.get(firstModel.id),
|
|
302
|
+
firstModelInfraMap: this.findInfrastructureEntities(firstModel.dataStore),
|
|
303
|
+
firstProjectIds: this.findEntitiesByType(firstModel.dataStore, 'IFCPROJECT'),
|
|
304
|
+
spatialLookup: this.buildSpatialLookup(firstModel.dataStore),
|
|
305
|
+
primaryScale: this.resolveUnitScale(firstModel),
|
|
306
|
+
assumeShared: options.unitReconciliation === 'assume-shared',
|
|
312
307
|
};
|
|
313
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Resolve a model's length unit scale (raw IFC length → metres). Prefers an
|
|
311
|
+
* explicit `lengthUnitScale` on the input, else the value the parser stamped
|
|
312
|
+
* on the data store, else metres.
|
|
313
|
+
*/
|
|
314
|
+
resolveUnitScale(model) {
|
|
315
|
+
const explicit = model.lengthUnitScale;
|
|
316
|
+
if (typeof explicit === 'number' && explicit > 0)
|
|
317
|
+
return explicit;
|
|
318
|
+
const fromStore = model.dataStore.lengthUnitScale;
|
|
319
|
+
if (typeof fromStore === 'number' && fromStore > 0)
|
|
320
|
+
return fromStore;
|
|
321
|
+
return 1.0;
|
|
322
|
+
}
|
|
323
|
+
/** True when two length unit scales are equal within relative tolerance. */
|
|
324
|
+
unitsCompatible(a, b) {
|
|
325
|
+
if (a === b)
|
|
326
|
+
return true;
|
|
327
|
+
const max = Math.max(Math.abs(a), Math.abs(b));
|
|
328
|
+
if (max === 0)
|
|
329
|
+
return true;
|
|
330
|
+
return Math.abs(a - b) <= max * UNIT_SCALE_TOLERANCE;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Resolve the set of express ids to include for a model under visibility
|
|
334
|
+
* filtering, or `null` when no filtering is requested (include everything).
|
|
335
|
+
*/
|
|
336
|
+
computeIncludedEntityIds(model, options, completeIndex, source) {
|
|
337
|
+
if (!options.visibleOnly)
|
|
338
|
+
return null;
|
|
339
|
+
const hiddenIds = options.hiddenEntityIdsByModel?.get(model.id) ?? new Set();
|
|
340
|
+
const isolatedIds = options.isolatedEntityIdsByModel?.get(model.id) ?? null;
|
|
341
|
+
const { roots, hiddenProductIds } = getVisibleEntityIds(model.dataStore, hiddenIds, isolatedIds);
|
|
342
|
+
const included = collectReferencedEntityIds(roots, source, completeIndex, hiddenProductIds);
|
|
343
|
+
// Second pass: collect style entities that reference included geometry.
|
|
344
|
+
collectStyleEntities(included, source, {
|
|
345
|
+
byId: completeIndex,
|
|
346
|
+
byType: model.dataStore.entityIndex.byType,
|
|
347
|
+
});
|
|
348
|
+
return included;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Plan how a model's entities are remapped, skipped, or re-stamped, given
|
|
352
|
+
* whether it shares the primary model's length unit (`compatible`).
|
|
353
|
+
*
|
|
354
|
+
* Compatible (or `assume-shared`) models are unified into the primary project:
|
|
355
|
+
* their IfcProject, shared infrastructure, and matching spatial structure are
|
|
356
|
+
* deduplicated, and a rooted entity repeating an already-emitted GlobalId is
|
|
357
|
+
* unified to that one instance.
|
|
358
|
+
*
|
|
359
|
+
* Incompatible (federated) models keep their own project, units, contexts and
|
|
360
|
+
* spatial structure so their coordinates stay correctly scaled; a rooted
|
|
361
|
+
* entity whose GlobalId collides with one already emitted is given a fresh
|
|
362
|
+
* deterministic GlobalId, since the two cannot be the same instance across
|
|
363
|
+
* different unit spaces.
|
|
364
|
+
*/
|
|
365
|
+
planModel(model, completeIndex, isFirstModel, compatible, setup, guidToFinalId) {
|
|
366
|
+
const source = model.dataStore.source;
|
|
367
|
+
const sharedRemap = new Map();
|
|
368
|
+
const skipEntityIds = new Set();
|
|
369
|
+
const guidRewrite = new Map();
|
|
370
|
+
// One cheap pass to read each rooted entity's GlobalId (first attribute).
|
|
371
|
+
const localGuids = new Map();
|
|
372
|
+
for (const [id, ref] of completeIndex) {
|
|
373
|
+
const guid = this.extractGlobalIdFast(ref, source);
|
|
374
|
+
if (guid !== null)
|
|
375
|
+
localGuids.set(id, guid);
|
|
376
|
+
}
|
|
377
|
+
if (!isFirstModel && compatible) {
|
|
378
|
+
// Remap this model's IfcProject references → first model's IfcProject.
|
|
379
|
+
const projectIds = this.findEntitiesByType(model.dataStore, 'IFCPROJECT');
|
|
380
|
+
if (setup.firstProjectIds.length > 0) {
|
|
381
|
+
for (const pid of projectIds) {
|
|
382
|
+
sharedRemap.set(pid, setup.firstProjectIds[0] + setup.firstModelOffset);
|
|
383
|
+
skipEntityIds.add(pid);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// Remap and skip duplicate infrastructure (units, contexts).
|
|
387
|
+
const modelInfra = this.findInfrastructureEntities(model.dataStore);
|
|
388
|
+
for (const [type, firstIds] of setup.firstModelInfraMap) {
|
|
389
|
+
const thisIds = modelInfra.get(type);
|
|
390
|
+
if (thisIds && firstIds.length > 0 && thisIds.length > 0) {
|
|
391
|
+
sharedRemap.set(thisIds[0], firstIds[0] + setup.firstModelOffset);
|
|
392
|
+
skipEntityIds.add(thisIds[0]);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// Unify spatial hierarchy: match Site, Building, Storey to first model.
|
|
396
|
+
this.unifySpatialEntities(model.dataStore, setup.spatialLookup, setup.firstModelOffset, sharedRemap, skipEntityIds);
|
|
397
|
+
// Skip IfcRelAggregates that become fully redundant after unification.
|
|
398
|
+
this.skipRedundantRelAggregates(model.dataStore, sharedRemap, skipEntityIds);
|
|
399
|
+
}
|
|
400
|
+
if (!isFirstModel) {
|
|
401
|
+
// GlobalId reconciliation against everything emitted by earlier models.
|
|
402
|
+
const pendingMinted = new Set();
|
|
403
|
+
for (const [id, guid] of localGuids) {
|
|
404
|
+
if (skipEntityIds.has(id))
|
|
405
|
+
continue; // already unified/deduped above
|
|
406
|
+
const prior = guidToFinalId.get(guid);
|
|
407
|
+
if (prior === undefined)
|
|
408
|
+
continue; // first occurrence — kept as-is
|
|
409
|
+
// Unify (drop + remap refs to the one instance) ONLY when this is a
|
|
410
|
+
// physical/spatial root AND both this model and the emitter share the
|
|
411
|
+
// primary unit. Two conditions force "keep + re-stamp" instead:
|
|
412
|
+
// - Objectified relationships (IfcRel*): same GlobalId does not imply
|
|
413
|
+
// the same membership (e.g. a storey-containment listing different
|
|
414
|
+
// elements per discipline), so dropping one would orphan elements.
|
|
415
|
+
// - The colliding instance was emitted in a different unit space
|
|
416
|
+
// (a federated model), so unifying would reinterpret coordinates —
|
|
417
|
+
// the very mis-scale this fix prevents, reached transitively.
|
|
418
|
+
const type = (completeIndex.get(id)?.type ?? '').toUpperCase();
|
|
419
|
+
const emitterIsPrimaryUnit = this.unitsCompatible(prior.scale, setup.primaryScale);
|
|
420
|
+
const canUnify = compatible && emitterIsPrimaryUnit && !isRelationshipType(type);
|
|
421
|
+
if (canUnify) {
|
|
422
|
+
sharedRemap.set(id, prior.finalId);
|
|
423
|
+
skipEntityIds.add(id);
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
guidRewrite.set(id, this.mintUniqueGuid(guid, model.id, guidToFinalId, pendingMinted));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { sharedRemap, skipEntityIds, guidRewrite, localGuids };
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Render one source entity into its final STEP line: apply id offset + shared
|
|
434
|
+
* remaps, re-stamp a federated GlobalId if needed, apply schema conversion,
|
|
435
|
+
* and register the emitted GlobalId so later models can reconcile against it.
|
|
436
|
+
* Returns `null` when schema conversion drops the entity.
|
|
437
|
+
*/
|
|
438
|
+
renderEntity(localId, entityRef, source, offset, plan, sourceSchema, targetSchema, guidToFinalId, modelScale) {
|
|
439
|
+
const entityText = safeUtf8Decode(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
|
|
440
|
+
// Remap ids. Fast path: the first model (offset 0, no remaps) is byte-identical.
|
|
441
|
+
let finalText;
|
|
442
|
+
if (offset === 0 && plan.sharedRemap.size === 0) {
|
|
443
|
+
finalText = entityText;
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
finalText = this.remapEntityText(entityText, offset, plan.sharedRemap);
|
|
447
|
+
}
|
|
448
|
+
// Re-stamp the GlobalId for a federated entity whose id collides.
|
|
449
|
+
const mintedGuid = plan.guidRewrite.get(localId);
|
|
450
|
+
if (mintedGuid !== undefined) {
|
|
451
|
+
finalText = this.replaceGlobalId(finalText, mintedGuid);
|
|
452
|
+
}
|
|
453
|
+
if (needsConversion(sourceSchema, targetSchema)) {
|
|
454
|
+
const converted = convertStepLine(finalText, sourceSchema, targetSchema);
|
|
455
|
+
if (converted === null)
|
|
456
|
+
return null;
|
|
457
|
+
finalText = converted;
|
|
458
|
+
}
|
|
459
|
+
// Record the emitted GlobalId → final express id + unit scale, for rooted
|
|
460
|
+
// entities only. Read it from the FINAL line, not the source: schema
|
|
461
|
+
// conversion can replace an unsupported rooted type with an IFCPROXY that
|
|
462
|
+
// carries a freshly-minted GlobalId, so the source guid would be stale.
|
|
463
|
+
// Emitted entities are not sharedRemap keys, so their final id is
|
|
464
|
+
// localId + offset.
|
|
465
|
+
if (plan.localGuids.has(localId)) {
|
|
466
|
+
const emittedGuid = this.readLeadingGuid(finalText)
|
|
467
|
+
?? mintedGuid ?? plan.localGuids.get(localId);
|
|
468
|
+
if (emittedGuid !== undefined) {
|
|
469
|
+
guidToFinalId.set(emittedGuid, { finalId: localId + offset, scale: modelScale });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return finalText;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Read the GlobalId (first quoted attribute) from an already-rendered STEP
|
|
476
|
+
* line. Used to register the id that was actually emitted, after any id
|
|
477
|
+
* remap, GlobalId re-stamp, or schema conversion. Returns null if the first
|
|
478
|
+
* quoted token is not a 22-char GlobalId.
|
|
479
|
+
*/
|
|
480
|
+
readLeadingGuid(entityText) {
|
|
481
|
+
const open = entityText.indexOf('(');
|
|
482
|
+
if (open === -1)
|
|
483
|
+
return null;
|
|
484
|
+
const q1 = entityText.indexOf("'", open + 1);
|
|
485
|
+
if (q1 === -1)
|
|
486
|
+
return null;
|
|
487
|
+
const q2 = entityText.indexOf("'", q1 + 1);
|
|
488
|
+
if (q2 === -1)
|
|
489
|
+
return null;
|
|
490
|
+
const raw = entityText.slice(q1 + 1, q2);
|
|
491
|
+
return GLOBAL_ID_RE.test(raw) ? raw : null;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Mint a fresh, deterministic, collision-free GlobalId for an entity whose id
|
|
495
|
+
* collides. Seeded from the original GlobalId and the model's stable id so the
|
|
496
|
+
* output is reproducible and does not churn when an unrelated earlier model
|
|
497
|
+
* changes size; checked against both already-emitted ids and the ids minted
|
|
498
|
+
* so far for this model.
|
|
499
|
+
*/
|
|
500
|
+
mintUniqueGuid(original, modelId, guidToFinalId, pendingMinted) {
|
|
501
|
+
let candidate = deterministicGlobalId(`${original}#${modelId}`);
|
|
502
|
+
let n = 0;
|
|
503
|
+
while (guidToFinalId.has(candidate) || pendingMinted.has(candidate)) {
|
|
504
|
+
candidate = deterministicGlobalId(`${original}#${modelId}#${n++}`);
|
|
505
|
+
}
|
|
506
|
+
pendingMinted.add(candidate);
|
|
507
|
+
return candidate;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Read an entity's GlobalId (first attribute) by decoding only its head.
|
|
511
|
+
* Returns the 22-char id for a rooted entity, or `null` for any entity whose
|
|
512
|
+
* first attribute is not a GlobalId (geometry, lists, property atoms, …).
|
|
513
|
+
*/
|
|
514
|
+
extractGlobalIdFast(ref, source) {
|
|
515
|
+
// Non-rooted resource entities (property/quantity/material/style/actor …)
|
|
516
|
+
// lead with a Name string that can itself be 22 charset chars; never treat
|
|
517
|
+
// those as a GlobalId or reconciliation would drop/rename them.
|
|
518
|
+
if (NON_ROOTED_STRING_TYPES.has((ref.type ?? '').toUpperCase()))
|
|
519
|
+
return null;
|
|
520
|
+
// 128 bytes comfortably spans `#<id>=<LONGEST_TYPE_NAME>('<22-char id>'`,
|
|
521
|
+
// so the GlobalId is always fully inside the window.
|
|
522
|
+
const end = Math.min(ref.byteOffset + 128, ref.byteOffset + ref.byteLength);
|
|
523
|
+
const head = safeUtf8Decode(source, ref.byteOffset, end);
|
|
524
|
+
const open = head.indexOf('(');
|
|
525
|
+
if (open === -1)
|
|
526
|
+
return null;
|
|
527
|
+
let i = open + 1;
|
|
528
|
+
while (i < head.length && (head[i] === ' ' || head[i] === '\t' || head[i] === '\n' || head[i] === '\r'))
|
|
529
|
+
i++;
|
|
530
|
+
if (head[i] !== "'")
|
|
531
|
+
return null;
|
|
532
|
+
// A GlobalId never contains a quote (charset excludes it), so the next
|
|
533
|
+
// quote closes it.
|
|
534
|
+
const close = head.indexOf("'", i + 1);
|
|
535
|
+
if (close === -1)
|
|
536
|
+
return null;
|
|
537
|
+
const raw = head.slice(i + 1, close);
|
|
538
|
+
return GLOBAL_ID_RE.test(raw) ? raw : null;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Replace an entity's GlobalId (first quoted attribute) with `newGuid`.
|
|
542
|
+
* `newGuid` is a 22-char IFC id (no quote in its charset), so this is safe.
|
|
543
|
+
*/
|
|
544
|
+
replaceGlobalId(entityText, newGuid) {
|
|
545
|
+
const open = entityText.indexOf('(');
|
|
546
|
+
if (open === -1)
|
|
547
|
+
return entityText;
|
|
548
|
+
const q1 = entityText.indexOf("'", open + 1);
|
|
549
|
+
if (q1 === -1)
|
|
550
|
+
return entityText;
|
|
551
|
+
const q2 = entityText.indexOf("'", q1 + 1);
|
|
552
|
+
if (q2 === -1)
|
|
553
|
+
return entityText;
|
|
554
|
+
return entityText.slice(0, q1 + 1) + newGuid + entityText.slice(q2);
|
|
555
|
+
}
|
|
314
556
|
/**
|
|
315
557
|
* Remap all #ID references in a STEP entity line.
|
|
316
558
|
* Applies offset to all IDs, then overrides with specific remappings.
|