@bldrs-ai/conway 1.516.1463 → 1.519.1471

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.
@@ -30,7 +30,7 @@ var import_node_process = require("node:process");
30
30
  var readline = __toESM(require("node:readline"), 1);
31
31
 
32
32
  // compiled/src/version/version.js
33
- var versionString = "Conway v1.516.1463";
33
+ var versionString = "Conway v1.519.1471";
34
34
 
35
35
  // compiled/dependencies/conway-geom/interface/conway_geometry.js
36
36
  var wasmType = "";
@@ -14965,7 +14965,7 @@ ${t5.join("\n")}` : "";
14965
14965
  var import_process = require("process");
14966
14966
 
14967
14967
  // compiled/src/version/version.js
14968
- var versionString = "Conway v1.516.1463";
14968
+ var versionString = "Conway v1.519.1471";
14969
14969
 
14970
14970
  // compiled/dependencies/conway-geom/interface/conway_geometry.js
14971
14971
  function pThreadsAllowed() {
@@ -15943,7 +15943,7 @@ var ParsingBuffer = class {
15943
15943
  };
15944
15944
 
15945
15945
  // compiled/src/version/version.js
15946
- var versionString = "Conway v1.516.1463";
15946
+ var versionString = "Conway v1.519.1471";
15947
15947
 
15948
15948
  // compiled/dependencies/conway-geom/interface/conway_geometry.js
15949
15949
  function pThreadsAllowed() {
@@ -944,7 +944,7 @@ var EntityTypesIfcCount = 909;
944
944
  var entity_types_ifc_gen_default = EntityTypesIfc;
945
945
 
946
946
  // compiled/src/version/version.js
947
- var versionString = "Conway v1.516.1463";
947
+ var versionString = "Conway v1.519.1471";
948
948
 
949
949
  // compiled/dependencies/conway-geom/interface/conway_geometry.js
950
950
  var wasmType = "";
@@ -1 +1 @@
1
- {"version":3,"file":"spatial_imposter.d.ts","sourceRoot":"","sources":["../../../../src/compat/web-ifc/spatial_imposter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,YAAY,MAAM,0BAA0B,CAAA;AAgBxD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAA;AAKpE,gEAAgE;AAChE,eAAO,MAAM,sBAAsB;;;;;CAA+B,CAAA;AAqBlE,+DAA+D;AAC/D,MAAM,WAAW,KAAK;IACpB,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;CAC9B;AA0BD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAE,IAAI,EAAE,KAAK,GAAI,MAAM,EAAE,CAerD;AAqCD;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAE,QAAQ,EAAE,MAAM,GAAI,MAAM,CAGlE;AAGD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACjC,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GAAI,OAAO,CAW5B;AAGD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAC3B,CAAC,EAAE,KAAK,EACR,CAAC,EAAE,KAAK,EACR,GAAG,GAAE,MAAqB,EAC1B,OAAO,GAAE,MAAmB,GAAI,OAAO,CAiB1C;AAGD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAE,GAAG,KAAK,EAAE,CAAE,KAAK,GAAG,SAAS,CAAE,EAAE,GAAI,KAAK,GAAG,SAAS,CA4BhF;AAmKD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH,wBAAsB,6BAA6B,CAC/C,KAAK,EAAE,YAAY,EACnB,MAAM,EAAE,CAAE,IAAI,EAAE,kBAAkB,KAAM,IAAI,EAC5C,kBAAkB,CAAC,EAAE,SAAS,CAAE,MAAM,CAAE,EACxC,mBAAmB,GAAE,MAAU,GAAI,OAAO,CAAE,MAAM,CAAE,CAwdvD"}
1
+ {"version":3,"file":"spatial_imposter.d.ts","sourceRoot":"","sources":["../../../../src/compat/web-ifc/spatial_imposter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,YAAY,MAAM,0BAA0B,CAAA;AAgBxD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAA;AAKpE,gEAAgE;AAChE,eAAO,MAAM,sBAAsB;;;;;CAA+B,CAAA;AAqBlE,+DAA+D;AAC/D,MAAM,WAAW,KAAK;IACpB,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,GAAG,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;CAC9B;AA0BD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAE,IAAI,EAAE,KAAK,GAAI,MAAM,EAAE,CAerD;AA6DD;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAE,QAAQ,EAAE,MAAM,GAAI,MAAM,CAGlE;AAGD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACjC,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GAAI,OAAO,CAW5B;AAGD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAC3B,CAAC,EAAE,KAAK,EACR,CAAC,EAAE,KAAK,EACR,GAAG,GAAE,MAAqB,EAC1B,OAAO,GAAE,MAAmB,GAAI,OAAO,CAiB1C;AAGD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAE,GAAG,KAAK,EAAE,CAAE,KAAK,GAAG,SAAS,CAAE,EAAE,GAAI,KAAK,GAAG,SAAS,CA4BhF;AAoWD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH,wBAAsB,6BAA6B,CAC/C,KAAK,EAAE,YAAY,EACnB,MAAM,EAAE,CAAE,IAAI,EAAE,kBAAkB,KAAM,IAAI,EAC5C,kBAAkB,CAAC,EAAE,SAAS,CAAE,MAAM,CAAE,EACxC,mBAAmB,GAAE,MAAU,GAAI,OAAO,CAAE,MAAM,CAAE,CAkevD"}
@@ -83,7 +83,33 @@ const PRODUCT_PLACEMENT = [5, 5, 3];
83
83
  const LOCAL_PLACEMENT_REL_TO = [0, 0, 1];
84
84
  const LOCAL_PLACEMENT_RELATIVE = [1, 0, 1];
85
85
  const PLACEMENT_LOCATION = [0, 0, 2];
86
+ const PLACEMENT_AXIS = [1, 1, 3];
87
+ const PLACEMENT_REF_DIRECTION = [2, 1, 3];
86
88
  const STOREY_ELEVATION = [9, 9, 6];
89
+ const IDENTITY_PLACEMENT = [
90
+ 1,
91
+ 0,
92
+ 0,
93
+ 0,
94
+ 0,
95
+ 1,
96
+ 0,
97
+ 0,
98
+ 0,
99
+ 0,
100
+ 1,
101
+ 0,
102
+ 0,
103
+ 0,
104
+ 0,
105
+ 1,
106
+ ];
107
+ /** Column-major indices of a 4x4's translation component. */
108
+ const TRANSLATION = [12, 13, 14];
109
+ /** Column-major index of the local +Z axis' world Z component. */
110
+ const LOCAL_Z_WORLD_Z = 10;
111
+ /** Cross-product magnitude below which two unit axes are parallel. */
112
+ const MIN_AXIS_CROSS = 1e-9;
87
113
  const SPACE_TYPES = new Set([
88
114
  EntityTypesIfc.IFCSPACE,
89
115
  EntityTypesIfc.IFCSPATIALZONE,
@@ -192,15 +218,188 @@ async function ensure_(model, localID) {
192
218
  await model.ensureResidentByLocalID(localID);
193
219
  }
194
220
  /**
195
- * World translation of an IfcLocalPlacement chain (rotation ignored —
196
- * enough for the axis-aligned storey plates this first pass draws).
221
+ * @param v A vector.
222
+ * @return {[number, number, number] | undefined} `v` scaled to unit
223
+ * length, or undefined when it has none (zero / non-finite input).
224
+ */
225
+ function normalize3_(v) {
226
+ const length = Math.hypot(v[0], v[1], v[2]);
227
+ return Number.isFinite(length) && length > 0 ?
228
+ [v[0] / length, v[1] / length, v[2] / length] : void 0;
229
+ }
230
+ /**
231
+ * @param a Left vector.
232
+ * @param b Right vector.
233
+ * @return {[number, number, number]} `a` x `b`.
234
+ */
235
+ function cross3_(a, b) {
236
+ return [
237
+ a[1] * b[2] - a[2] * b[1],
238
+ a[2] * b[0] - a[0] * b[2],
239
+ a[0] * b[1] - a[1] * b[0],
240
+ ];
241
+ }
242
+ /**
243
+ * The `DirectionRatios` of an IfcDirection referenced from `field` of
244
+ * `entity`, or undefined when the field is absent.
245
+ *
246
+ * @param model The model.
247
+ * @param entity The referring entity.
248
+ * @param field The (index, min, max) field triple.
249
+ * @return {Promise<[number, number, number] | undefined>} The ratios.
250
+ */
251
+ async function directionRatios_(model, entity, field) {
252
+ const directionID = entity.extractReferenceLocalID(field[0], field[1], field[2], true);
253
+ if (directionID === null) {
254
+ return;
255
+ }
256
+ await ensure_(model, directionID);
257
+ const direction = model.getElementByLocalID(directionID);
258
+ const ratios = direction?.DirectionRatios;
259
+ if (ratios === void 0 || ratios.length < 2) {
260
+ return;
261
+ }
262
+ return [ratios[0], ratios[1], ratios[2] ?? 0];
263
+ }
264
+ /**
265
+ * The local +X reference to use when a placement states an `Axis` but no
266
+ * `RefDirection`: world +X, unless that is (nearly) parallel to the
267
+ * stated axis, in which case world +Y.
268
+ *
269
+ * IFC's own `IfcFirstProjAxis` makes exactly this substitution, and
270
+ * without it a lie-down placement — `Axis = (1,0,0)`, RefDirection
271
+ * omitted, which is how a wall-mounted or rotated-into-plan element is
272
+ * commonly written — has its Axis cross the defaulted (1,0,0) to zero
273
+ * and falls all the way back to the world frame, discarding a perfectly
274
+ * well-formed rotation. conway-geom divides by that zero cross product
275
+ * and returns NaN, so matching the C++ settles nothing here; keeping the
276
+ * stated Axis is strictly better than dropping it.
277
+ *
278
+ * @param zAxis The normalized local +Z.
279
+ * @return {[number, number, number]} A reference not parallel to it.
280
+ */
281
+ function defaultRefDirection_(zAxis) {
282
+ // |cross(zAxis, worldX)| — world +X stays the default (which is what
283
+ // the IFC default says, and what the overwhelmingly common
284
+ // zAxis = (0,0,1) needs) until it degenerates.
285
+ return Math.hypot(zAxis[1], zAxis[2]) > MIN_AXIS_CROSS ?
286
+ [1, 0, 0] : [0, 1, 0];
287
+ }
288
+ /**
289
+ * The 4x4 an IfcAxis2Placement denotes: Location as the translation,
290
+ * and — on the 3D form only — Axis as local +Z and RefDirection as local
291
+ * +X, both defaulting to the world axes when absent per the IFC
292
+ * defaults.
293
+ *
294
+ * The 2D form has to be handled, not assumed away.
295
+ * `IfcLocalPlacement.RelativePlacement` is
296
+ * `IfcAxis2Placement2D | IfcAxis2Placement3D`, and the two disagree on
297
+ * every field past Location: offset 1 is `Axis` on the 3D entity but
298
+ * `RefDirection` on the 2D one, and offset 2 does not exist there at
299
+ * all, so reading it throws "too few fields in record". That throw would
300
+ * escape to {@link placementTransform_}'s catch, which memoizes
301
+ * `undefined` for the placement — and a memoized undefined is not merely
302
+ * a lost sample: every descendant placement then takes the
303
+ * no-parent branch and is composed in LOCAL coordinates, silently
304
+ * reparenting a whole subtree to the origin. So the direction fields are
305
+ * read only once the type is confirmed 3D, which also restores the
306
+ * pre-#517 behaviour for 2D links (translation only).
307
+ *
308
+ * Deliberately the same construction as conway-geom's
309
+ * `GetAxis2Placement3D` (ConwayGeometryProcessor.cpp) — normalize the
310
+ * two references, `y = normalize(z x x)`, then re-derive
311
+ * `x = normalize(y x z)` so a non-perpendicular RefDirection is
312
+ * projected rather than skewing the frame. It has to match: the plates
313
+ * are previewing meshes the durable pipeline places with that function,
314
+ * and a second convention here (Gram-Schmidt on x, say) would rotate
315
+ * them apart on any file whose RefDirection is off-perpendicular.
316
+ *
317
+ * One deviation, on degenerate input: where the C++ divides by a zero
318
+ * cross product and yields NaN, this falls back to the identity axes. A
319
+ * NaN transform reaching Share is an invisible or scene-destroying box,
320
+ * and the imposter path's standing rule is that a preview must never
321
+ * break open.
322
+ *
323
+ * @param model The model.
324
+ * @param axisLocalID The IfcAxis2Placement3D's local ID.
325
+ * @return {Promise<Mat4>} The placement, identity if unreadable.
326
+ */
327
+ async function axisPlacementMatrix_(model, axisLocalID) {
328
+ await ensure_(model, axisLocalID);
329
+ const placement = model.getElementByLocalID(axisLocalID);
330
+ if (placement === void 0) {
331
+ return IDENTITY_PLACEMENT.slice();
332
+ }
333
+ let translation = [0, 0, 0];
334
+ const locationID = placement.extractReferenceLocalID(PLACEMENT_LOCATION[0], PLACEMENT_LOCATION[1], PLACEMENT_LOCATION[2], false);
335
+ if (locationID !== null) {
336
+ await ensure_(model, locationID);
337
+ const point = model.getElementByLocalID(locationID);
338
+ const coords = point?.Coordinates;
339
+ if (coords !== void 0 && coords.length >= 2) {
340
+ translation = [coords[0], coords[1], coords[2] ?? 0];
341
+ }
342
+ }
343
+ const is3D = model.typeIDOf(axisLocalID) === EntityTypesIfc.IFCAXIS2PLACEMENT3D;
344
+ const axisRatios = is3D ?
345
+ await directionRatios_(model, placement, PLACEMENT_AXIS) : void 0;
346
+ const refRatios = is3D ?
347
+ await directionRatios_(model, placement, PLACEMENT_REF_DIRECTION) : void 0;
348
+ let zAxis = (axisRatios !== void 0 ? normalize3_(axisRatios) : void 0) ?? [0, 0, 1];
349
+ let refAxis = (refRatios !== void 0 ? normalize3_(refRatios) : void 0) ??
350
+ defaultRefDirection_(zAxis);
351
+ let yAxis = normalize3_(cross3_(zAxis, refAxis));
352
+ if (yAxis === void 0) {
353
+ // Axis and RefDirection are parallel (or garbage): nothing here
354
+ // determines a frame, so keep the translation and drop back to world
355
+ // axes. Only reachable with an explicit RefDirection now — an Axis
356
+ // on its own gets a perpendicular default instead of being thrown
357
+ // away with it (see defaultRefDirection_).
358
+ zAxis = [0, 0, 1];
359
+ refAxis = [1, 0, 0];
360
+ yAxis = [0, 1, 0];
361
+ }
362
+ const xAxis = normalize3_(cross3_(yAxis, zAxis)) ?? refAxis;
363
+ return [
364
+ xAxis[0],
365
+ xAxis[1],
366
+ xAxis[2],
367
+ 0,
368
+ yAxis[0],
369
+ yAxis[1],
370
+ yAxis[2],
371
+ 0,
372
+ zAxis[0],
373
+ zAxis[1],
374
+ zAxis[2],
375
+ 0,
376
+ translation[0],
377
+ translation[1],
378
+ translation[2],
379
+ 1,
380
+ ];
381
+ }
382
+ /**
383
+ * The composed world transform of an IfcLocalPlacement chain: every
384
+ * link's IfcAxis2Placement3D built out in full and multiplied
385
+ * `parent * local`, which is exactly conway-geom's `GetLocalPlacement`.
386
+ *
387
+ * Rotation used to be dropped here — the chain summed Location
388
+ * translations and nothing else (conway#514's shortcut). That is correct
389
+ * only while every ancestor placement is axis-aligned, and Revit
390
+ * routinely rotates the site or building placement to true north, so on
391
+ * a real export every sampled origin landed unrotated in raw model space
392
+ * while the durable meshes beside it did not: the plate cloud read as a
393
+ * rotated ghost of the building (conway#517).
197
394
  *
198
395
  * @param model The model.
199
396
  * @param placementLocalID IfcObjectPlacement local ID.
200
- * @param cache Memo.
201
- * @return {Promise<[number, number, number] | undefined>} World origin.
397
+ * @param cache Memo of composed transforms, keyed by placement local ID.
398
+ * A placement re-entered while it is being composed (a cyclic RelativeTo)
399
+ * reads back undefined and stops the recursion.
400
+ * @return {Promise<Mat4 | undefined>} The world transform.
202
401
  */
203
- async function placementOrigin_(model, placementLocalID, cache) {
402
+ async function placementTransform_(model, placementLocalID, cache) {
204
403
  if (cache.has(placementLocalID)) {
205
404
  return cache.get(placementLocalID);
206
405
  }
@@ -216,37 +415,16 @@ async function placementOrigin_(model, placementLocalID, cache) {
216
415
  return;
217
416
  }
218
417
  const relativeID = entity.extractReferenceLocalID(LOCAL_PLACEMENT_RELATIVE[0], LOCAL_PLACEMENT_RELATIVE[1], LOCAL_PLACEMENT_RELATIVE[2], false);
219
- let local = [0, 0, 0];
220
- if (relativeID !== null) {
221
- await ensure_(model, relativeID);
222
- const relative = model.getElementByLocalID(relativeID);
223
- if (relative !== void 0) {
224
- const locationID = relative.extractReferenceLocalID(PLACEMENT_LOCATION[0], PLACEMENT_LOCATION[1], PLACEMENT_LOCATION[2], false);
225
- if (locationID !== null) {
226
- await ensure_(model, locationID);
227
- const point = model.getElementByLocalID(locationID);
228
- const coords = point?.Coordinates;
229
- if (coords !== void 0 && coords.length >= 2) {
230
- local = [coords[0], coords[1], coords[2] ?? 0];
231
- }
232
- }
233
- }
234
- }
418
+ const local = relativeID !== null ?
419
+ await axisPlacementMatrix_(model, relativeID) :
420
+ IDENTITY_PLACEMENT.slice();
235
421
  const parentID = entity.extractReferenceLocalID(LOCAL_PLACEMENT_REL_TO[0], LOCAL_PLACEMENT_REL_TO[1], LOCAL_PLACEMENT_REL_TO[2], true);
236
422
  if (parentID === null) {
237
423
  cache.set(placementLocalID, local);
238
424
  return local;
239
425
  }
240
- const parent = await placementOrigin_(model, parentID, cache);
241
- if (parent === void 0) {
242
- cache.set(placementLocalID, local);
243
- return local;
244
- }
245
- const world = [
246
- parent[0] + local[0],
247
- parent[1] + local[1],
248
- parent[2] + local[2],
249
- ];
426
+ const parent = await placementTransform_(model, parentID, cache);
427
+ const world = parent === void 0 ? local : mat4MultiplyF64(parent, local);
250
428
  cache.set(placementLocalID, world);
251
429
  return world;
252
430
  }
@@ -254,7 +432,19 @@ async function placementOrigin_(model, placementLocalID, cache) {
254
432
  return;
255
433
  }
256
434
  }
257
- async function productOrigin_(model, localID, cache) {
435
+ /**
436
+ * @param transform A composed world transform.
437
+ * @return {[number, number, number]} Its translation column, i.e. where
438
+ * the placement's own origin lands in world space.
439
+ */
440
+ function originOf_(transform) {
441
+ return [
442
+ transform[TRANSLATION[0]],
443
+ transform[TRANSLATION[1]],
444
+ transform[TRANSLATION[2]],
445
+ ];
446
+ }
447
+ async function productTransform_(model, localID, cache) {
258
448
  await ensure_(model, localID);
259
449
  const entity = model.getElementByLocalID(localID);
260
450
  if (entity === void 0) {
@@ -264,7 +454,7 @@ async function productOrigin_(model, localID, cache) {
264
454
  if (placementID === null) {
265
455
  return;
266
456
  }
267
- return placementOrigin_(model, placementID, cache);
457
+ return placementTransform_(model, placementID, cache);
268
458
  }
269
459
  /**
270
460
  * Walk the IFC spatial tree and emit AABB imposters for Share.
@@ -463,13 +653,16 @@ export async function emitSpatialStructureImposters(model, onMesh, coordinationM
463
653
  queue.push(childID);
464
654
  }
465
655
  }
466
- const originCache = new Map();
656
+ const placementCache = new Map();
467
657
  for (const node of nodes.values()) {
468
658
  try {
469
659
  // IfcProject is an IfcContext, not an IfcProduct — field 5 is
470
660
  // not ObjectPlacement.
471
661
  if (node.typeID !== EntityTypesIfc.IFCPROJECT) {
472
- node.origin = await productOrigin_(model, node.localID, originCache);
662
+ node.placement =
663
+ await productTransform_(model, node.localID, placementCache);
664
+ node.origin = node.placement !== void 0 ?
665
+ originOf_(node.placement) : void 0;
473
666
  }
474
667
  if (STOREY_TYPES.has(node.typeID)) {
475
668
  await ensure_(model, node.localID);
@@ -492,9 +685,9 @@ export async function emitSpatialStructureImposters(model, onMesh, coordinationM
492
685
  const origins = [];
493
686
  for (let i = 0; i < products.length && origins.length < PRODUCT_SAMPLE; i += stride) {
494
687
  try {
495
- const origin = await productOrigin_(model, products[i], originCache);
496
- if (origin !== void 0) {
497
- origins.push(origin);
688
+ const transform = await productTransform_(model, products[i], placementCache);
689
+ if (transform !== void 0) {
690
+ origins.push(originOf_(transform));
498
691
  }
499
692
  }
500
693
  catch {
@@ -564,7 +757,7 @@ export async function emitSpatialStructureImposters(model, onMesh, coordinationM
564
757
  // own placement Z; the two only part on files that leave every
565
758
  // storey placement on the datum.
566
759
  const storeyZ0 = STOREY_TYPES.has(node.typeID) && node.elevation !== void 0 ?
567
- storeyDatumZ_(localID, nodes, parentOf) + node.elevation : void 0;
760
+ storeyFloorZ_(localID, node.elevation, nodes, parentOf) : void 0;
568
761
  if (node.origin !== void 0) {
569
762
  // The placement point contributes this node's XY footprint. For
570
763
  // a storey with an Elevation its Z is the DATUM, not its
@@ -597,7 +790,12 @@ export async function emitSpatialStructureImposters(model, onMesh, coordinationM
597
790
  // elevation), but a file that parks every storey placement on
598
791
  // the building datum and carries the height only in Elevation
599
792
  // would then collapse all of its plates onto one Z. Storey
600
- // HEIGHTS stay on elevation deltas — datum-invariant either way.
793
+ // HEIGHTS stay on elevation deltas — datum-invariant either way,
794
+ // though on a datum tilted out of plumb they over-thicken the
795
+ // plate by 1/cos(tilt) (the floor Z is tilt-correct; see
796
+ // storeyFloorZ_). Left alone: the sample-derived height fallback
797
+ // is already a world-Z span, so one scale cannot serve both, and
798
+ // a tilted building has no horizontal storeys to plate anyway.
601
799
  const z0 = storeyZ0 ?? node.origin?.[2] ?? box.min[2];
602
800
  const z1 = height !== void 0 ? z0 + height : box.max[2];
603
801
  box = {
@@ -659,33 +857,44 @@ export async function emitSpatialStructureImposters(model, onMesh, coordinationM
659
857
  return emitted;
660
858
  }
661
859
  /**
662
- * World Z of the datum an IfcBuildingStorey's `Elevation` is measured
663
- * from the nearest ancestor with a resolved placement, i.e. the
664
- * IfcBuilding in a well-formed file.
860
+ * World Z of an IfcBuildingStorey's floor: `Elevation` measured up the
861
+ * datum's own +Z from the datum's world origin, where the datum is the
862
+ * nearest ancestor with a resolved placement (the IfcBuilding in a
863
+ * well-formed file).
864
+ *
865
+ * Elevation is a length along the datum's Z, not along world Z, so the
866
+ * datum's whole transform is needed and not just its height — on a
867
+ * building tilted out of plumb the two differ by `cos(tilt)`. Only the
868
+ * Z component is taken, because that is all the banding uses: the plate
869
+ * keeps the XY footprint its placement and contained samples give it.
870
+ * A tilted building's storeys are not horizontal anyway, so the
871
+ * axis-aligned plate is an approximation there by construction — this
872
+ * just stops it being an approximation on the *height* as well.
665
873
  *
666
- * Zero when nothing up the chain resolved one, which reads Elevation as
667
- * world-relative. That is the pre-fix behaviour and the only remaining
668
- * fallback: a storey whose ancestors have no usable placement gives us
669
- * nothing better to anchor on.
874
+ * Falls back to a world-relative Elevation (datum at zero, +Z up) when
875
+ * nothing up the chain resolved a placement: the pre-fix behaviour, and
876
+ * the only thing left to anchor on.
670
877
  *
671
878
  * @param localID The storey.
879
+ * @param elevation The storey's `Elevation`, in source units.
672
880
  * @param nodes All walked nodes.
673
881
  * @param parentOf Child -> parent.
674
- * @return {number} The datum's world Z.
882
+ * @return {number} The floor's world Z.
675
883
  */
676
- function storeyDatumZ_(localID, nodes, parentOf) {
884
+ function storeyFloorZ_(localID, elevation, nodes, parentOf) {
677
885
  let id = parentOf.get(localID);
678
886
  // Bounded rather than while-truthy: parentOf is built by a BFS and so
679
887
  // should be acyclic, but this module treats the aggregate graph as
680
888
  // untrusted everywhere else too (see `computing` in aabbOf).
681
889
  for (let hops = 0; id !== void 0 && hops <= nodes.size; ++hops) {
682
- const originZ = nodes.get(id)?.origin?.[2];
683
- if (originZ !== void 0) {
684
- return originZ;
890
+ const placement = nodes.get(id)?.placement;
891
+ if (placement !== void 0) {
892
+ return placement[TRANSLATION[2]] +
893
+ elevation * placement[LOCAL_Z_WORLD_Z];
685
894
  }
686
895
  id = parentOf.get(id);
687
896
  }
688
- return 0;
897
+ return elevation;
689
898
  }
690
899
  /**
691
900
  * The durable coordination frame, derived the way the preview channels
@@ -699,9 +908,9 @@ function storeyDatumZ_(localID, nodes, parentOf) {
699
908
  * Best-effort by construction, and the failure has two shapes. Beyond
700
909
  * LARGE_COORDINATE_BUDGET_M both sides snap to COORDINATION_SNAP_M and
701
910
  * agree as long as the spatial root and the first geometry share a grid
702
- * cell. Worse: the anchor comes from `placementOrigin_`, which sums
703
- * IfcLocalPlacement translations and never looks at geometry — so a file
704
- * that keeps identity placements and bakes absolute coordinates into the
911
+ * cell. Worse: the anchor comes from `placementTransform_`, which reads
912
+ * IfcLocalPlacement chains and never looks at geometry — so a file that
913
+ * keeps identity placements and bakes absolute coordinates into the
705
914
  * vertices (civil/GIS exports do this) has a root box near zero and gets
706
915
  * no recentre at all, while the durable walk anchors tens of km out and
707
916
  * does. That is a full site-offset, not a one-cell disagreement. Whenever