@ifc-lite/export 2.2.0 → 2.4.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/dist/glb.d.ts +10 -0
- package/dist/glb.d.ts.map +1 -1
- package/dist/glb.js +22 -0
- package/dist/glb.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/merged-exporter.d.ts +117 -16
- package/dist/merged-exporter.d.ts.map +1 -1
- package/dist/merged-exporter.js +272 -65
- package/dist/merged-exporter.js.map +1 -1
- package/dist/schema-converter.d.ts +3 -1
- package/dist/schema-converter.d.ts.map +1 -1
- package/dist/schema-converter.js +112 -3
- package/dist/schema-converter.js.map +1 -1
- package/dist/unit-normalize.d.ts +62 -0
- package/dist/unit-normalize.d.ts.map +1 -0
- package/dist/unit-normalize.js +346 -0
- package/dist/unit-normalize.js.map +1 -0
- package/package.json +6 -6
package/dist/merged-exporter.js
CHANGED
|
@@ -9,6 +9,7 @@ import { convertStepLine, needsConversion } from './schema-converter.js';
|
|
|
9
9
|
import { assembleStepBytes } from './step-serialization.js';
|
|
10
10
|
import { getCompleteEntityIndex, getMaxExpressId } from './entity-iteration.js';
|
|
11
11
|
import { StepExporter } from './step-exporter.js';
|
|
12
|
+
import { rescaleEntityLengths, computeNormalizeFactor } from './unit-normalize.js';
|
|
12
13
|
/** Entity types forming shared infrastructure (deduplicated across models). */
|
|
13
14
|
const SHARED_INFRASTRUCTURE_TYPES = new Set([
|
|
14
15
|
'IFCUNITASSIGNMENT',
|
|
@@ -71,6 +72,14 @@ function isRelationshipType(typeUpper) {
|
|
|
71
72
|
}
|
|
72
73
|
/** Relative tolerance for comparing two length unit scale factors. */
|
|
73
74
|
const UNIT_SCALE_TOLERANCE = 1e-6;
|
|
75
|
+
/** SI prefix multipliers, for resolving prefixed area/volume units (rarely used). */
|
|
76
|
+
const SI_PREFIX_MULTIPLIERS = {
|
|
77
|
+
ATTO: 1e-18, FEMTO: 1e-15, PICO: 1e-12, NANO: 1e-9, MICRO: 1e-6, MILLI: 1e-3,
|
|
78
|
+
CENTI: 1e-2, DECI: 1e-1, DECA: 1e1, HECTO: 1e2, KILO: 1e3, MEGA: 1e6,
|
|
79
|
+
GIGA: 1e9, TERA: 1e12, PETA: 1e15, EXA: 1e18,
|
|
80
|
+
};
|
|
81
|
+
/** Source schemas the IFC4 length registry does not fully cover (see #1475 review). */
|
|
82
|
+
const NORMALIZE_UNCOVERED_SCHEMAS = new Set(['IFC4X3', 'IFC5']);
|
|
74
83
|
/**
|
|
75
84
|
* True when a mutation view carries pending edits the exporter would bake.
|
|
76
85
|
*
|
|
@@ -93,6 +102,19 @@ function viewHasMutations(view) {
|
|
|
93
102
|
const created = typeof view.getNewEntities === 'function' ? view.getNewEntities() : [];
|
|
94
103
|
return created.length > 0;
|
|
95
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Drop models with no usable source (nothing to emit): a cache-restored or
|
|
107
|
+
* metadata-only store can reach the merge with an empty `.source`. The emit loop
|
|
108
|
+
* already skips them, but the primary model (`models[0]`) and the unit/offset
|
|
109
|
+
* setup must be computed over the SAME set — otherwise an empty model at index 0
|
|
110
|
+
* would poison the primary unit/scale that later models normalize against. When
|
|
111
|
+
* every model is empty the original list is kept so a valid (empty) file is still
|
|
112
|
+
* produced rather than throwing.
|
|
113
|
+
*/
|
|
114
|
+
function withUsableSource(models) {
|
|
115
|
+
const usable = models.filter(m => m.dataStore.source && m.dataStore.source.length > 0);
|
|
116
|
+
return usable.length > 0 ? usable : models;
|
|
117
|
+
}
|
|
96
118
|
/**
|
|
97
119
|
* Merges multiple IFC models into a single STEP file.
|
|
98
120
|
*
|
|
@@ -116,14 +138,15 @@ function viewHasMutations(view) {
|
|
|
116
138
|
* fresh deterministic GlobalId so the file has no duplicate-GlobalId errors
|
|
117
139
|
* and no relationship membership is lost.
|
|
118
140
|
*
|
|
119
|
-
* Conformance trade-off: when federation triggers
|
|
120
|
-
* one IfcProject, which intentionally relaxes the
|
|
121
|
-
* EXPRESS rule (SIZEOF(IfcProject) <= 1). This
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* `
|
|
125
|
-
*
|
|
126
|
-
*
|
|
141
|
+
* Conformance trade-off: when federation triggers (under the default `'auto'`),
|
|
142
|
+
* the file contains more than one IfcProject, which intentionally relaxes the
|
|
143
|
+
* IfcSingleProjectInstance EXPRESS rule (SIZEOF(IfcProject) <= 1). This preserves
|
|
144
|
+
* both units without rewriting coordinates, and is strictly better than a silent
|
|
145
|
+
* mis-scale. `MergeExportResult.stats.warnings` flags it. To instead get one
|
|
146
|
+
* ordinary single-unit IfcProject, pass `unitReconciliation: 'normalize'` — it
|
|
147
|
+
* rescales every length-valued datum of the differing-unit models into the first
|
|
148
|
+
* model's unit (see {@link ./unit-normalize.ts}). Use `'assume-shared'` only when
|
|
149
|
+
* the caller has already normalised units.
|
|
127
150
|
*
|
|
128
151
|
* Limitation: federation only unifies a model against the *first* model's unit
|
|
129
152
|
* group. Two non-first models that share a unit different from the first are
|
|
@@ -147,7 +170,7 @@ export class MergedExporter {
|
|
|
147
170
|
throw new Error('MergedExporter.export() cannot apply pending edits — baking needs the async parser. ' +
|
|
148
171
|
'Use exportAsync() for merged export with mutations.');
|
|
149
172
|
}
|
|
150
|
-
const models = this.models;
|
|
173
|
+
const models = withUsableSource(this.models);
|
|
151
174
|
const setup = this.buildMergeSetup(options, models);
|
|
152
175
|
const allEntityLines = [];
|
|
153
176
|
// Tracks every GlobalId already emitted → its final express id + unit scale,
|
|
@@ -156,6 +179,8 @@ export class MergedExporter {
|
|
|
156
179
|
const guidToFinalId = new Map();
|
|
157
180
|
let isFirstModel = true;
|
|
158
181
|
let federatedModelCount = 0;
|
|
182
|
+
let normalizedModelCount = 0;
|
|
183
|
+
const normalizeWarnings = new Set();
|
|
159
184
|
for (const model of models) {
|
|
160
185
|
const offset = setup.modelOffsets.get(model.id);
|
|
161
186
|
const source = model.dataStore.source;
|
|
@@ -165,19 +190,21 @@ export class MergedExporter {
|
|
|
165
190
|
// walk and the emit loop both reach every entity the source defines.
|
|
166
191
|
const completeIndex = getCompleteEntityIndex(model.dataStore);
|
|
167
192
|
const includedEntityIds = this.computeIncludedEntityIds(model, options, completeIndex, source);
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|| this.unitsCompatible(modelScale, setup.primaryScale);
|
|
171
|
-
if (!isFirstModel && !compatible)
|
|
193
|
+
const mode = this.resolveModelMode(model, isFirstModel, setup);
|
|
194
|
+
if (!isFirstModel && !mode.compatible)
|
|
172
195
|
federatedModelCount++;
|
|
173
|
-
|
|
196
|
+
if (mode.normalized) {
|
|
197
|
+
normalizedModelCount++;
|
|
198
|
+
this.collectNormalizeCaveats(model, normalizeWarnings);
|
|
199
|
+
}
|
|
200
|
+
const plan = this.planModel(model, completeIndex, isFirstModel, mode.compatible, mode.lengthFactor, setup, guidToFinalId);
|
|
174
201
|
const sourceSchema = model.dataStore.schemaVersion || 'IFC4';
|
|
175
202
|
for (const [expressId, entityRef] of completeIndex) {
|
|
176
203
|
if (includedEntityIds !== null && !includedEntityIds.has(expressId))
|
|
177
204
|
continue;
|
|
178
205
|
if (plan.skipEntityIds.has(expressId))
|
|
179
206
|
continue;
|
|
180
|
-
const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId,
|
|
207
|
+
const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId, mode);
|
|
181
208
|
if (line !== null)
|
|
182
209
|
allEntityLines.push(line);
|
|
183
210
|
}
|
|
@@ -189,7 +216,7 @@ export class MergedExporter {
|
|
|
189
216
|
const content = assembleStepBytes(header, allEntityLines);
|
|
190
217
|
return {
|
|
191
218
|
content,
|
|
192
|
-
stats: this.buildStats(allEntityLines.length, content.byteLength, federatedModelCount),
|
|
219
|
+
stats: this.buildStats(allEntityLines.length, content.byteLength, federatedModelCount, normalizedModelCount, normalizeWarnings),
|
|
193
220
|
};
|
|
194
221
|
}
|
|
195
222
|
/**
|
|
@@ -206,7 +233,7 @@ export class MergedExporter {
|
|
|
206
233
|
// Bake each model's pending edits into its source bytes before merging, so
|
|
207
234
|
// federated export round-trips mutations like single-model export. Models
|
|
208
235
|
// without edits pass through unchanged (no export/parse cost).
|
|
209
|
-
const models = await this.bakeMutatedModels();
|
|
236
|
+
const models = withUsableSource(await this.bakeMutatedModels());
|
|
210
237
|
const setup = this.buildMergeSetup(options, models);
|
|
211
238
|
const allEntityLines = [];
|
|
212
239
|
const guidToFinalId = new Map();
|
|
@@ -218,6 +245,8 @@ export class MergedExporter {
|
|
|
218
245
|
let isFirstModel = true;
|
|
219
246
|
let entitiesProcessed = 0;
|
|
220
247
|
let federatedModelCount = 0;
|
|
248
|
+
let normalizedModelCount = 0;
|
|
249
|
+
const normalizeWarnings = new Set();
|
|
221
250
|
const YIELD_INTERVAL = 2000;
|
|
222
251
|
if (onProgress)
|
|
223
252
|
onProgress({ phase: 'preparing', percent: 0, entitiesProcessed: 0, entitiesTotal: totalEntities });
|
|
@@ -237,12 +266,14 @@ export class MergedExporter {
|
|
|
237
266
|
}
|
|
238
267
|
const completeIndex = getCompleteEntityIndex(model.dataStore);
|
|
239
268
|
const includedEntityIds = this.computeIncludedEntityIds(model, options, completeIndex, source);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|| this.unitsCompatible(modelScale, setup.primaryScale);
|
|
243
|
-
if (!isFirstModel && !compatible)
|
|
269
|
+
const mode = this.resolveModelMode(model, isFirstModel, setup);
|
|
270
|
+
if (!isFirstModel && !mode.compatible)
|
|
244
271
|
federatedModelCount++;
|
|
245
|
-
|
|
272
|
+
if (mode.normalized) {
|
|
273
|
+
normalizedModelCount++;
|
|
274
|
+
this.collectNormalizeCaveats(model, normalizeWarnings);
|
|
275
|
+
}
|
|
276
|
+
const plan = this.planModel(model, completeIndex, isFirstModel, mode.compatible, mode.lengthFactor, setup, guidToFinalId);
|
|
246
277
|
const sourceSchema = model.dataStore.schemaVersion || 'IFC4';
|
|
247
278
|
let entityCount = 0;
|
|
248
279
|
for (const [expressId, entityRef] of completeIndex) {
|
|
@@ -250,7 +281,7 @@ export class MergedExporter {
|
|
|
250
281
|
continue;
|
|
251
282
|
if (plan.skipEntityIds.has(expressId))
|
|
252
283
|
continue;
|
|
253
|
-
const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId,
|
|
284
|
+
const line = this.renderEntity(expressId, entityRef, source, offset, plan, sourceSchema, schema, guidToFinalId, mode);
|
|
254
285
|
if (line !== null)
|
|
255
286
|
allEntityLines.push(line);
|
|
256
287
|
entityCount++;
|
|
@@ -282,7 +313,7 @@ export class MergedExporter {
|
|
|
282
313
|
}
|
|
283
314
|
return {
|
|
284
315
|
content,
|
|
285
|
-
stats: this.buildStats(allEntityLines.length, content.byteLength, federatedModelCount),
|
|
316
|
+
stats: this.buildStats(allEntityLines.length, content.byteLength, federatedModelCount, normalizedModelCount, normalizeWarnings),
|
|
286
317
|
};
|
|
287
318
|
}
|
|
288
319
|
/**
|
|
@@ -331,17 +362,37 @@ export class MergedExporter {
|
|
|
331
362
|
/**
|
|
332
363
|
* Assemble the result stats, including any federation conformance warnings.
|
|
333
364
|
*/
|
|
334
|
-
buildStats(totalEntityCount, fileSize, federatedModelCount) {
|
|
365
|
+
buildStats(totalEntityCount, fileSize, federatedModelCount, normalizedModelCount, normalizeWarnings) {
|
|
335
366
|
const warnings = [];
|
|
336
367
|
if (federatedModelCount > 0) {
|
|
337
368
|
warnings.push(`${federatedModelCount} model(s) had a length unit differing from the first model and were ` +
|
|
338
369
|
`federated as separate IfcProject roots to keep their geometry correctly scaled. The output ` +
|
|
339
370
|
`therefore contains ${federatedModelCount + 1} IfcProject instances, which intentionally relaxes ` +
|
|
340
371
|
`the IfcSingleProjectInstance rule (SIZEOF(IfcProject) <= 1). Some single-project viewers may ` +
|
|
341
|
-
`only show the first project. Pass unitReconciliation:'
|
|
342
|
-
`units are already normalised.`);
|
|
372
|
+
`only show the first project. Pass unitReconciliation:'normalize' to rescale them into one ` +
|
|
373
|
+
`single-unit project, or 'assume-shared' when units are already normalised.`);
|
|
374
|
+
}
|
|
375
|
+
warnings.push(...normalizeWarnings);
|
|
376
|
+
return { modelCount: this.models.length, totalEntityCount, fileSize, federatedModelCount, normalizedModelCount, warnings };
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Record advisories for a model being normalized. The rescaler derives its
|
|
380
|
+
* length-attribute map from the IFC4 schema registry, so it may not cover
|
|
381
|
+
* length attributes introduced by newer schemas (IFC4X3 alignment / linear
|
|
382
|
+
* referencing), and it deliberately leaves georeferencing untouched.
|
|
383
|
+
*/
|
|
384
|
+
collectNormalizeCaveats(model, warnings) {
|
|
385
|
+
const schema = (model.dataStore.schemaVersion ?? '').toUpperCase();
|
|
386
|
+
if (NORMALIZE_UNCOVERED_SCHEMAS.has(schema)) {
|
|
387
|
+
warnings.add(`Model "${model.name}" (${schema}) was normalized using the IFC4 length-attribute schema; ` +
|
|
388
|
+
`length values on ${schema}-specific entities (e.g. alignment / linear-referencing segment ` +
|
|
389
|
+
`lengths and radii) may not have been rescaled. Verify infrastructure geometry.`);
|
|
390
|
+
}
|
|
391
|
+
if (this.findEntitiesByType(model.dataStore, 'IFCMAPCONVERSION').length > 0) {
|
|
392
|
+
warnings.add(`Model "${model.name}" carries georeferencing (IfcMapConversion), which normalize leaves ` +
|
|
393
|
+
`untouched. If the model was georeferenced in its own unit, review the merged coordinate ` +
|
|
394
|
+
`operation.`);
|
|
343
395
|
}
|
|
344
|
-
return { modelCount: this.models.length, totalEntityCount, fileSize, federatedModelCount, warnings };
|
|
345
396
|
}
|
|
346
397
|
/**
|
|
347
398
|
* Build the ifc-lite provenance header. Merged files have no single source
|
|
@@ -374,16 +425,126 @@ export class MergedExporter {
|
|
|
374
425
|
nextAvailableId += getMaxExpressId(getCompleteEntityIndex(model.dataStore));
|
|
375
426
|
}
|
|
376
427
|
const firstModel = models[0];
|
|
428
|
+
const primaryScale = this.resolveUnitScale(firstModel);
|
|
377
429
|
return {
|
|
378
430
|
modelOffsets,
|
|
379
431
|
firstModelOffset: modelOffsets.get(firstModel.id),
|
|
380
432
|
firstModelInfraMap: this.findInfrastructureEntities(firstModel.dataStore),
|
|
381
433
|
firstProjectIds: this.findEntitiesByType(firstModel.dataStore, 'IFCPROJECT'),
|
|
382
434
|
spatialLookup: this.buildSpatialLookup(firstModel.dataStore),
|
|
383
|
-
primaryScale
|
|
435
|
+
primaryScale,
|
|
436
|
+
primaryAreaScale: this.resolveDerivedUnitScale(firstModel.dataStore, 'AREAUNIT', primaryScale, 2),
|
|
437
|
+
primaryVolumeScale: this.resolveDerivedUnitScale(firstModel.dataStore, 'VOLUMEUNIT', primaryScale, 3),
|
|
384
438
|
assumeShared: options.unitReconciliation === 'assume-shared',
|
|
439
|
+
normalize: options.unitReconciliation === 'normalize',
|
|
440
|
+
mergeSites: options.mergeSites,
|
|
441
|
+
mergeBuildings: options.mergeBuildings,
|
|
442
|
+
mergeStoreys: options.mergeStoreys,
|
|
385
443
|
};
|
|
386
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* Decide how one model folds into the merge from its length unit and the
|
|
447
|
+
* reconciliation mode.
|
|
448
|
+
*
|
|
449
|
+
* - The primary model, `assume-shared`, and any model that already shares the
|
|
450
|
+
* primary unit are unified with no rescale.
|
|
451
|
+
* - Under `normalize`, a differing-unit model is unified *and* rescaled: every
|
|
452
|
+
* length-valued datum is multiplied by `primaryScale`-relative factor so its
|
|
453
|
+
* geometry stays correct under the single shared unit.
|
|
454
|
+
* - Otherwise (`auto`) a differing-unit model is federated (kept as its own
|
|
455
|
+
* project + units), leaving its raw coordinates untouched.
|
|
456
|
+
*
|
|
457
|
+
* Area/volume reconciliation is gated on the length unit differing: a model that
|
|
458
|
+
* shares the primary's length unit is treated as fully compatible (factors 1).
|
|
459
|
+
* A model that pairs a matching length unit with a *divergent* area/volume unit
|
|
460
|
+
* (a non-conformant combination no mainstream exporter emits) is not rescaled.
|
|
461
|
+
*/
|
|
462
|
+
resolveModelMode(model, isFirstModel, setup) {
|
|
463
|
+
const modelScale = this.resolveUnitScale(model);
|
|
464
|
+
if (isFirstModel || setup.assumeShared || this.unitsCompatible(modelScale, setup.primaryScale)) {
|
|
465
|
+
return { compatible: true, lengthFactor: 1, areaFactor: 1, volumeFactor: 1, effectiveScale: modelScale, normalized: false };
|
|
466
|
+
}
|
|
467
|
+
if (setup.normalize) {
|
|
468
|
+
// Each dimension is converted by the ratio of its own declared unit, not by
|
|
469
|
+
// powers of the length factor — IFC declares area/volume units independently
|
|
470
|
+
// (Revit: millimetre lengths but square-/cubic-metre areas/volumes).
|
|
471
|
+
const modelArea = this.resolveDerivedUnitScale(model.dataStore, 'AREAUNIT', modelScale, 2);
|
|
472
|
+
const modelVolume = this.resolveDerivedUnitScale(model.dataStore, 'VOLUMEUNIT', modelScale, 3);
|
|
473
|
+
return {
|
|
474
|
+
compatible: true,
|
|
475
|
+
lengthFactor: computeNormalizeFactor(modelScale, setup.primaryScale),
|
|
476
|
+
areaFactor: computeNormalizeFactor(modelArea, setup.primaryAreaScale),
|
|
477
|
+
volumeFactor: computeNormalizeFactor(modelVolume, setup.primaryVolumeScale),
|
|
478
|
+
effectiveScale: setup.primaryScale,
|
|
479
|
+
normalized: true,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
// auto: federate the differing-unit model, coordinates untouched.
|
|
483
|
+
return { compatible: false, lengthFactor: 1, areaFactor: 1, volumeFactor: 1, effectiveScale: modelScale, normalized: false };
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Resolve a model's declared AREAUNIT / VOLUMEUNIT scale (SI m² / m³ per unit)
|
|
487
|
+
* by walking IfcProject → IfcUnitAssignment. Falls back to the length-derived
|
|
488
|
+
* unit (`lengthScale ** power`) when the model declares no explicit area/volume
|
|
489
|
+
* unit — the IFC default. A prefixed SI area/volume unit (rare) applies the
|
|
490
|
+
* prefix once (buildingSMART / IfcOpenShell convention).
|
|
491
|
+
*/
|
|
492
|
+
resolveDerivedUnitScale(dataStore, wantType, lengthScale, power) {
|
|
493
|
+
const fallback = Math.pow(lengthScale, power);
|
|
494
|
+
const projectIds = this.findEntitiesByType(dataStore, 'IFCPROJECT');
|
|
495
|
+
if (projectIds.length === 0)
|
|
496
|
+
return fallback;
|
|
497
|
+
// IfcProject.UnitsInContext = attr 8 → IfcUnitAssignment.
|
|
498
|
+
const unitsAttr = this.extractStepAttribute(projectIds[0], dataStore, 8);
|
|
499
|
+
const assignMatch = unitsAttr?.match(/^#(\d+)$/);
|
|
500
|
+
if (!assignMatch)
|
|
501
|
+
return fallback;
|
|
502
|
+
// IfcUnitAssignment.Units = attr 0 (a list of unit refs).
|
|
503
|
+
const listAttr = this.extractStepAttribute(parseInt(assignMatch[1], 10), dataStore, 0);
|
|
504
|
+
if (!listAttr)
|
|
505
|
+
return fallback;
|
|
506
|
+
for (const m of listAttr.matchAll(/#(\d+)/g)) {
|
|
507
|
+
const uid = parseInt(m[1], 10);
|
|
508
|
+
const uref = dataStore.entityIndex.byId.get(uid);
|
|
509
|
+
const utype = (uref?.type ?? '').toUpperCase();
|
|
510
|
+
if (utype === 'IFCSIUNIT') {
|
|
511
|
+
// IfcSIUnit: [1] UnitType, [2] Prefix, [3] Name.
|
|
512
|
+
if (this.normalizeEnum(this.extractStepAttribute(uid, dataStore, 1)) !== wantType)
|
|
513
|
+
continue;
|
|
514
|
+
const prefixRaw = this.extractStepAttribute(uid, dataStore, 2);
|
|
515
|
+
if (!prefixRaw || prefixRaw === '$' || prefixRaw === '*')
|
|
516
|
+
return 1.0; // square/cubic metre
|
|
517
|
+
const mult = SI_PREFIX_MULTIPLIERS[this.normalizeEnum(prefixRaw)];
|
|
518
|
+
return mult !== undefined ? mult : 1.0;
|
|
519
|
+
}
|
|
520
|
+
if (utype === 'IFCCONVERSIONBASEDUNIT') {
|
|
521
|
+
// IfcConversionBasedUnit: [1] UnitType, [2] Name, [3] ConversionFactor.
|
|
522
|
+
if (this.normalizeEnum(this.extractStepAttribute(uid, dataStore, 1)) !== wantType)
|
|
523
|
+
continue;
|
|
524
|
+
const convMatch = this.extractStepAttribute(uid, dataStore, 3)?.match(/^#(\d+)$/);
|
|
525
|
+
if (convMatch) {
|
|
526
|
+
// IfcMeasureWithUnit.ValueComponent = attr 0 (e.g. IFCAREAMEASURE(0.0929)).
|
|
527
|
+
const num = this.parseMeasureNumber(this.extractStepAttribute(parseInt(convMatch[1], 10), dataStore, 0));
|
|
528
|
+
if (num !== undefined && num > 0)
|
|
529
|
+
return num;
|
|
530
|
+
}
|
|
531
|
+
return fallback;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return fallback;
|
|
535
|
+
}
|
|
536
|
+
/** Uppercase an enum token, stripping the STEP `.ENUM.` dots. `''` for nullish. */
|
|
537
|
+
normalizeEnum(raw) {
|
|
538
|
+
return (raw ?? '').replace(/\./g, '').trim().toUpperCase();
|
|
539
|
+
}
|
|
540
|
+
/** Extract the number from a bare real or a typed measure token (`IFCAREAMEASURE(0.09)`). */
|
|
541
|
+
parseMeasureNumber(raw) {
|
|
542
|
+
if (!raw)
|
|
543
|
+
return undefined;
|
|
544
|
+
const typed = raw.match(/\(([^)]*)\)\s*$/);
|
|
545
|
+
const n = Number((typed ? typed[1] : raw).trim());
|
|
546
|
+
return Number.isFinite(n) ? n : undefined;
|
|
547
|
+
}
|
|
387
548
|
/**
|
|
388
549
|
* Resolve a model's length unit scale (raw IFC length → metres). Prefers an
|
|
389
550
|
* explicit `lengthUnitScale` on the input, else the value the parser stamped
|
|
@@ -429,10 +590,10 @@ export class MergedExporter {
|
|
|
429
590
|
* Plan how a model's entities are remapped, skipped, or re-stamped, given
|
|
430
591
|
* whether it shares the primary model's length unit (`compatible`).
|
|
431
592
|
*
|
|
432
|
-
* Compatible (
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
* unified to that one instance.
|
|
593
|
+
* Compatible models (same unit, `assume-shared`, or `normalize`d into the
|
|
594
|
+
* primary unit) are unified into the primary project: their IfcProject, shared
|
|
595
|
+
* infrastructure, and matching spatial structure are deduplicated, and a rooted
|
|
596
|
+
* entity repeating an already-emitted GlobalId is unified to that one instance.
|
|
436
597
|
*
|
|
437
598
|
* Incompatible (federated) models keep their own project, units, contexts and
|
|
438
599
|
* spatial structure so their coordinates stay correctly scaled; a rooted
|
|
@@ -440,7 +601,7 @@ export class MergedExporter {
|
|
|
440
601
|
* deterministic GlobalId, since the two cannot be the same instance across
|
|
441
602
|
* different unit spaces.
|
|
442
603
|
*/
|
|
443
|
-
planModel(model, completeIndex, isFirstModel, compatible, setup, guidToFinalId) {
|
|
604
|
+
planModel(model, completeIndex, isFirstModel, compatible, lengthFactor, setup, guidToFinalId) {
|
|
444
605
|
const source = model.dataStore.source;
|
|
445
606
|
const sharedRemap = new Map();
|
|
446
607
|
const skipEntityIds = new Set();
|
|
@@ -471,7 +632,9 @@ export class MergedExporter {
|
|
|
471
632
|
}
|
|
472
633
|
}
|
|
473
634
|
// Unify spatial hierarchy: match Site, Building, Storey to first model.
|
|
474
|
-
this
|
|
635
|
+
// Under normalize, this model's raw elevations are in its own unit, so the
|
|
636
|
+
// elevation match is done in the primary unit (rawElevation * lengthFactor).
|
|
637
|
+
this.unifySpatialEntities(model.dataStore, setup.spatialLookup, setup.firstModelOffset, lengthFactor, sharedRemap, skipEntityIds, setup);
|
|
475
638
|
// Skip IfcRelAggregates that become fully redundant after unification.
|
|
476
639
|
this.skipRedundantRelAggregates(model.dataStore, sharedRemap, skipEntityIds);
|
|
477
640
|
}
|
|
@@ -513,7 +676,7 @@ export class MergedExporter {
|
|
|
513
676
|
* and register the emitted GlobalId so later models can reconcile against it.
|
|
514
677
|
* Returns `null` when schema conversion drops the entity.
|
|
515
678
|
*/
|
|
516
|
-
renderEntity(localId, entityRef, source, offset, plan, sourceSchema, targetSchema, guidToFinalId,
|
|
679
|
+
renderEntity(localId, entityRef, source, offset, plan, sourceSchema, targetSchema, guidToFinalId, mode) {
|
|
517
680
|
const entityText = safeUtf8Decode(source, entityRef.byteOffset, entityRef.byteOffset + entityRef.byteLength);
|
|
518
681
|
// Remap ids. Fast path: the first model (offset 0, no remaps) is byte-identical.
|
|
519
682
|
let finalText;
|
|
@@ -528,6 +691,13 @@ export class MergedExporter {
|
|
|
528
691
|
if (mintedGuid !== undefined) {
|
|
529
692
|
finalText = this.replaceGlobalId(finalText, mintedGuid);
|
|
530
693
|
}
|
|
694
|
+
// Normalize units: rescale every length/area/volume-valued datum into the
|
|
695
|
+
// primary unit. Done on the SOURCE-schema text (entityRef.type), before any
|
|
696
|
+
// schema conversion, so the schema-derived attribute indices line up. Touches
|
|
697
|
+
// only numeric literals, so id remaps and the GlobalId re-stamp above are safe.
|
|
698
|
+
if (mode.lengthFactor !== 1 || mode.areaFactor !== 1 || mode.volumeFactor !== 1) {
|
|
699
|
+
finalText = rescaleEntityLengths(finalText, entityRef.type.toUpperCase(), mode.lengthFactor, mode.areaFactor, mode.volumeFactor);
|
|
700
|
+
}
|
|
531
701
|
if (needsConversion(sourceSchema, targetSchema)) {
|
|
532
702
|
const converted = convertStepLine(finalText, sourceSchema, targetSchema);
|
|
533
703
|
if (converted === null)
|
|
@@ -544,7 +714,7 @@ export class MergedExporter {
|
|
|
544
714
|
const emittedGuid = this.readLeadingGuid(finalText)
|
|
545
715
|
?? mintedGuid ?? plan.localGuids.get(localId);
|
|
546
716
|
if (emittedGuid !== undefined) {
|
|
547
|
-
guidToFinalId.set(emittedGuid, { finalId: localId + offset, scale:
|
|
717
|
+
guidToFinalId.set(emittedGuid, { finalId: localId + offset, scale: mode.effectiveScale });
|
|
548
718
|
}
|
|
549
719
|
}
|
|
550
720
|
return finalText;
|
|
@@ -746,58 +916,62 @@ export class MergedExporter {
|
|
|
746
916
|
* to the first model's equivalents. Matched entities are remapped and
|
|
747
917
|
* their duplicate entity is skipped from output.
|
|
748
918
|
*
|
|
749
|
-
* Matching strategy
|
|
750
|
-
*
|
|
751
|
-
*
|
|
919
|
+
* Matching strategy per container type is driven by
|
|
920
|
+
* {@link MergeExportOptions.mergeSites} / `mergeBuildings` / `mergeStoreys`
|
|
921
|
+
* (all optional; omitted keeps the pre-existing combined heuristic):
|
|
922
|
+
* - Sites/Buildings: `'single'` (ignore name, unify iff exactly one in each
|
|
923
|
+
* model), `'by-name'` (name only, no fallback), or omitted — name first,
|
|
924
|
+
* else single-instance fallback.
|
|
925
|
+
* - Storeys: `'by-name'`, `'by-elevation'` (tolerance ±0.5 model units), or
|
|
926
|
+
* `'by-name-then-elevation'` (default, also the omitted behavior).
|
|
752
927
|
*/
|
|
753
|
-
unifySpatialEntities(dataStore, lookup, firstModelOffset, sharedRemap, skipEntityIds) {
|
|
754
|
-
// Unify IfcSite
|
|
928
|
+
unifySpatialEntities(dataStore, lookup, firstModelOffset, elevationFactor, sharedRemap, skipEntityIds, mergeModes) {
|
|
929
|
+
// Unify IfcSite. matchedFirstSites guards against two of this model's
|
|
930
|
+
// sites (e.g. duplicate/identically-named) both matching the same
|
|
931
|
+
// first-model target — only the first claims it, the second is kept as
|
|
932
|
+
// its own root instead of silently losing its spatial sub-tree.
|
|
755
933
|
const sites = this.findEntitiesByType(dataStore, 'IFCSITE');
|
|
934
|
+
const matchedFirstSites = new Set();
|
|
756
935
|
for (const id of sites) {
|
|
757
|
-
const
|
|
758
|
-
let match;
|
|
759
|
-
if (name)
|
|
760
|
-
match = lookup.sitesByName.get(name.toLowerCase());
|
|
761
|
-
// If single site in both models, unify regardless of name
|
|
762
|
-
if (match === undefined && sites.length === 1 && lookup.siteIds.length === 1) {
|
|
763
|
-
match = lookup.siteIds[0];
|
|
764
|
-
}
|
|
936
|
+
const match = this.matchRootContainer(id, dataStore, mergeModes.mergeSites, sites.length, lookup.sitesByName, lookup.siteIds, matchedFirstSites);
|
|
765
937
|
if (match !== undefined) {
|
|
938
|
+
matchedFirstSites.add(match);
|
|
766
939
|
sharedRemap.set(id, match + firstModelOffset);
|
|
767
940
|
skipEntityIds.add(id);
|
|
768
941
|
}
|
|
769
942
|
}
|
|
770
|
-
// Unify IfcBuilding
|
|
943
|
+
// Unify IfcBuilding — same already-matched guard as sites.
|
|
771
944
|
const buildings = this.findEntitiesByType(dataStore, 'IFCBUILDING');
|
|
945
|
+
const matchedFirstBuildings = new Set();
|
|
772
946
|
for (const id of buildings) {
|
|
773
|
-
const
|
|
774
|
-
let match;
|
|
775
|
-
if (name)
|
|
776
|
-
match = lookup.buildingsByName.get(name.toLowerCase());
|
|
777
|
-
if (match === undefined && buildings.length === 1 && lookup.buildingIds.length === 1) {
|
|
778
|
-
match = lookup.buildingIds[0];
|
|
779
|
-
}
|
|
947
|
+
const match = this.matchRootContainer(id, dataStore, mergeModes.mergeBuildings, buildings.length, lookup.buildingsByName, lookup.buildingIds, matchedFirstBuildings);
|
|
780
948
|
if (match !== undefined) {
|
|
949
|
+
matchedFirstBuildings.add(match);
|
|
781
950
|
sharedRemap.set(id, match + firstModelOffset);
|
|
782
951
|
skipEntityIds.add(id);
|
|
783
952
|
}
|
|
784
953
|
}
|
|
785
|
-
// Unify IfcBuildingStorey — name
|
|
954
|
+
// Unify IfcBuildingStorey — mode-driven name/elevation matching
|
|
955
|
+
const storeyMode = mergeModes.mergeStoreys ?? 'by-name-then-elevation';
|
|
786
956
|
const matchedFirstStoreys = new Set();
|
|
787
957
|
for (const id of this.findEntitiesByType(dataStore, 'IFCBUILDINGSTOREY')) {
|
|
788
958
|
const name = this.extractEntityName(id, dataStore);
|
|
789
959
|
let match;
|
|
790
|
-
//
|
|
791
|
-
if (name) {
|
|
960
|
+
// Name match (skipped entirely under 'by-elevation')
|
|
961
|
+
if (storeyMode !== 'by-elevation' && name) {
|
|
792
962
|
const candidate = lookup.storeysByName.get(name.toLowerCase());
|
|
793
963
|
if (candidate !== undefined && !matchedFirstStoreys.has(candidate)) {
|
|
794
964
|
match = candidate;
|
|
795
965
|
}
|
|
796
966
|
}
|
|
797
|
-
//
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
967
|
+
// Elevation match (skipped entirely under 'by-name'). The candidate's raw
|
|
968
|
+
// elevation is in this model's unit; scale it into the primary unit so the
|
|
969
|
+
// comparison (and the ±0.5 m tolerance) is unit-consistent under normalize
|
|
970
|
+
// (factor 1 otherwise).
|
|
971
|
+
if (match === undefined && storeyMode !== 'by-name') {
|
|
972
|
+
const rawElevation = this.extractStoreyElevation(id, dataStore);
|
|
973
|
+
if (rawElevation !== undefined) {
|
|
974
|
+
const elevation = rawElevation * elevationFactor;
|
|
801
975
|
for (const entry of lookup.storeysByElevation) {
|
|
802
976
|
if (matchedFirstStoreys.has(entry.expressId))
|
|
803
977
|
continue;
|
|
@@ -816,6 +990,39 @@ export class MergedExporter {
|
|
|
816
990
|
}
|
|
817
991
|
}
|
|
818
992
|
}
|
|
993
|
+
/**
|
|
994
|
+
* Match one IfcSite/IfcBuilding instance against the first model's
|
|
995
|
+
* equivalents, per {@link mergeMode}:
|
|
996
|
+
* - `'single'`: ignore name — unify iff both models contribute exactly one.
|
|
997
|
+
* - `'by-name'`: name match only, no single-instance fallback.
|
|
998
|
+
* - omitted: name match, else single-instance fallback (pre-existing heuristic).
|
|
999
|
+
*
|
|
1000
|
+
* `matchedFirst` excludes first-model targets already claimed by an earlier
|
|
1001
|
+
* entity in this same model's loop — without it, two of this model's sites
|
|
1002
|
+
* (or buildings) sharing a name/being the sole instance would both resolve
|
|
1003
|
+
* to the same target, and the second would be dropped (skipped + remapped)
|
|
1004
|
+
* rather than kept as its own root.
|
|
1005
|
+
*/
|
|
1006
|
+
matchRootContainer(id, dataStore, mergeMode, countInThisModel, firstModelByName, firstModelIds, matchedFirst) {
|
|
1007
|
+
const bySingle = () => {
|
|
1008
|
+
if (countInThisModel !== 1 || firstModelIds.length !== 1)
|
|
1009
|
+
return undefined;
|
|
1010
|
+
const candidate = firstModelIds[0];
|
|
1011
|
+
return matchedFirst.has(candidate) ? undefined : candidate;
|
|
1012
|
+
};
|
|
1013
|
+
const byName = () => {
|
|
1014
|
+
const name = this.extractEntityName(id, dataStore);
|
|
1015
|
+
if (!name)
|
|
1016
|
+
return undefined;
|
|
1017
|
+
const candidate = firstModelByName.get(name.toLowerCase());
|
|
1018
|
+
return candidate !== undefined && !matchedFirst.has(candidate) ? candidate : undefined;
|
|
1019
|
+
};
|
|
1020
|
+
if (mergeMode === 'single')
|
|
1021
|
+
return bySingle();
|
|
1022
|
+
if (mergeMode === 'by-name')
|
|
1023
|
+
return byName();
|
|
1024
|
+
return byName() ?? bySingle();
|
|
1025
|
+
}
|
|
819
1026
|
/**
|
|
820
1027
|
* Skip IfcRelAggregates that become fully redundant after spatial unification.
|
|
821
1028
|
*
|