brepjs-bim 0.4.0 → 0.6.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.
@@ -6350,6 +6350,14 @@ var BimModel = class {
6350
6350
  if (key !== void 0 && this.#usedStableKeys.has(key)) return (0, brepjs.err)(specError("DUPLICATE_STABLE_KEY", `BimModel: duplicate stableKey '${key}'`));
6351
6351
  return (0, brepjs.ok)(void 0);
6352
6352
  }
6353
+ #checkOpeningKeys(options) {
6354
+ const filler = this.#checkStableKey(options);
6355
+ if (!filler.ok) return filler;
6356
+ const opening = this.#checkStableKey({ stableKey: options?.openingStableKey });
6357
+ if (!opening.ok) return opening;
6358
+ if (options?.stableKey !== void 0 && options.stableKey === options.openingStableKey) return (0, brepjs.err)(specError("DUPLICATE_STABLE_KEY", `BimModel: stableKey and openingStableKey are both '${options.stableKey}'`));
6359
+ return (0, brepjs.ok)(void 0);
6360
+ }
6353
6361
  addWall(spec, options) {
6354
6362
  const keyCheck = this.#checkStableKey(options);
6355
6363
  if (!keyCheck.ok) return keyCheck;
@@ -6641,7 +6649,9 @@ var BimModel = class {
6641
6649
  });
6642
6650
  return (0, brepjs.ok)(id);
6643
6651
  }
6644
- addDoor(spec) {
6652
+ addDoor(spec, options) {
6653
+ const keyCheck = this.#checkOpeningKeys(options);
6654
+ if (!keyCheck.ok) return keyCheck;
6645
6655
  const wall = this.#elements.get(spec.wallLocalId);
6646
6656
  if (wall === void 0 || wall.category !== "WALL") return (0, brepjs.err)(specError("DOOR_WALL_NOT_FOUND", `No wall found for localId ${spec.wallLocalId}`));
6647
6657
  if (spec.offsetAlongWall + spec.width > wall.spec.length) return (0, brepjs.err)(specError("DOOR_EXCEEDS_WALL_BOUNDS", "Door (offsetAlongWall + width) exceeds wall length"));
@@ -6656,13 +6666,13 @@ var BimModel = class {
6656
6666
  const cutResult = this.#cutWallGeometry(wall, openingSpec);
6657
6667
  if (!cutResult.ok) return (0, brepjs.err)(cutResult.error);
6658
6668
  this.#replaceWallGeometry(wall, cutResult.value);
6659
- const openingId = this.#makeElement("OPENING", openingSpec, null);
6669
+ const openingId = this.#makeElement("OPENING", openingSpec, null, options?.openingStableKey);
6660
6670
  this.#makeRel({
6661
6671
  kind: "VOIDS_WALL",
6662
6672
  wallLocalId: spec.wallLocalId,
6663
6673
  openingLocalId: openingId
6664
6674
  });
6665
- const doorId = this.#makeElement("DOOR", spec, null);
6675
+ const doorId = this.#makeElement("DOOR", spec, null, options?.stableKey);
6666
6676
  this.#makeRel({
6667
6677
  kind: "FILLS_OPENING",
6668
6678
  openingLocalId: openingId,
@@ -6675,7 +6685,9 @@ var BimModel = class {
6675
6685
  });
6676
6686
  return (0, brepjs.ok)(doorId);
6677
6687
  }
6678
- addWindow(spec) {
6688
+ addWindow(spec, options) {
6689
+ const keyCheck = this.#checkOpeningKeys(options);
6690
+ if (!keyCheck.ok) return keyCheck;
6679
6691
  const wall = this.#elements.get(spec.wallLocalId);
6680
6692
  if (wall === void 0 || wall.category !== "WALL") return (0, brepjs.err)(specError("WINDOW_WALL_NOT_FOUND", `No wall found for localId ${spec.wallLocalId}`));
6681
6693
  if (spec.offsetAlongWall + spec.width > wall.spec.length) return (0, brepjs.err)(specError("WINDOW_EXCEEDS_WALL_BOUNDS", "Window (offsetAlongWall + width) exceeds wall length"));
@@ -6690,13 +6702,13 @@ var BimModel = class {
6690
6702
  const cutResult = this.#cutWallGeometry(wall, openingSpec);
6691
6703
  if (!cutResult.ok) return (0, brepjs.err)(cutResult.error);
6692
6704
  this.#replaceWallGeometry(wall, cutResult.value);
6693
- const openingId = this.#makeElement("OPENING", openingSpec, null);
6705
+ const openingId = this.#makeElement("OPENING", openingSpec, null, options?.openingStableKey);
6694
6706
  this.#makeRel({
6695
6707
  kind: "VOIDS_WALL",
6696
6708
  wallLocalId: spec.wallLocalId,
6697
6709
  openingLocalId: openingId
6698
6710
  });
6699
- const windowId = this.#makeElement("WINDOW", spec, null);
6711
+ const windowId = this.#makeElement("WINDOW", spec, null, options?.stableKey);
6700
6712
  this.#makeRel({
6701
6713
  kind: "FILLS_OPENING",
6702
6714
  openingLocalId: openingId,
@@ -6709,7 +6721,9 @@ var BimModel = class {
6709
6721
  });
6710
6722
  return (0, brepjs.ok)(windowId);
6711
6723
  }
6712
- addSlabOpening(input) {
6724
+ addSlabOpening(input, options) {
6725
+ const keyCheck = this.#checkStableKey(options);
6726
+ if (!keyCheck.ok) return keyCheck;
6713
6727
  const slab = this.#elements.get(input.slabLocalId);
6714
6728
  if (slab === void 0 || slab.category !== "SLAB") return (0, brepjs.err)(specError("SLAB_OPENING_SLAB_NOT_FOUND", `No slab found for localId ${input.slabLocalId}`));
6715
6729
  if (input.offsetX + input.sizeX > slab.spec.length) return (0, brepjs.err)(specError("SLAB_OPENING_EXCEEDS_SLAB_BOUNDS", "Opening (offsetX + sizeX) exceeds slab length"));
@@ -6739,7 +6753,7 @@ var BimModel = class {
6739
6753
  const cutResult = this.#cutSlabGeometry(slab, openingSpec);
6740
6754
  if (!cutResult.ok) return (0, brepjs.err)(cutResult.error);
6741
6755
  this.#replaceSlabGeometry(slab, cutResult.value);
6742
- const openingId = this.#makeElement("OPENING", openingSpec, null);
6756
+ const openingId = this.#makeElement("OPENING", openingSpec, null, options?.stableKey);
6743
6757
  this.#makeRel({
6744
6758
  kind: "VOIDS_SLAB",
6745
6759
  slabLocalId: input.slabLocalId,
@@ -16479,8 +16493,11 @@ function assignNum(target, key, value) {
16479
16493
  * while the IR path serves the viewport and dedup. GlobalIds derive from
16480
16494
  * families key paths (stable under reordering), not insertion order.
16481
16495
  *
16482
- * v1 scope: Storey containers and Wall/Slab elements. Openings, fills, and
16483
- * relationship wiring (IfcRelVoidsElement) follow with the opening writer.
16496
+ * Scope: Storey containers, Wall/Slab elements, and wall openings — a
16497
+ * fill-role void (Door/Window family) maps onto addDoor/addWindow, which cut
16498
+ * the wall and wire IfcRelVoidsElement + IfcRelFillsElement; the opening and
16499
+ * filler GlobalIds derive from the synthesized key paths. Anonymous (non-fill)
16500
+ * voids stay IR-only and never reach the spec path.
16484
16501
  */
16485
16502
  var SPEC_DEFAULTS = {
16486
16503
  origin: [
@@ -16506,36 +16523,58 @@ var GEOMETRY_PROPS = /* @__PURE__ */ new Set([
16506
16523
  "transform",
16507
16524
  "psets"
16508
16525
  ]);
16509
- /** Fold the resolved geometry's OUTER literal translate chain into the spec
16510
- * placement origin, so IfcLocalPlacement matches the IR world frame no
16511
- * matter where the transform came from (family-internal or prop-level).
16512
- * Parameter-driven translations cannot fold and keep the default origin. */
16513
- function composedOrigin(el) {
16514
- const out = [...el.props["origin"] ?? [
16526
+ /** Total of the resolved geometry's OUTER literal translate chain. The
16527
+ * transform vocabulary is translate-only, so frame differences are exact
16528
+ * subtractions. Parameter-driven translations stop the peel. */
16529
+ function peelTranslates(node) {
16530
+ const total = [
16515
16531
  0,
16516
16532
  0,
16517
16533
  0
16518
- ]];
16534
+ ];
16519
16535
  let moved = false;
16520
- let node = el.geometry;
16521
- while (node.kind === "Translate") {
16522
- const v = node.vector;
16536
+ let cur = node;
16537
+ while (cur.kind === "Translate") {
16538
+ const v = cur.vector;
16523
16539
  if (v.kind !== "Vec3Lit") break;
16524
- out[0] += v.value[0];
16525
- out[1] += v.value[1];
16526
- out[2] += v.value[2];
16540
+ total[0] += v.value[0];
16541
+ total[1] += v.value[1];
16542
+ total[2] += v.value[2];
16527
16543
  moved = true;
16528
- node = node.target;
16544
+ cur = cur.target;
16529
16545
  }
16530
- return moved || el.props["origin"] !== void 0 ? out : void 0;
16546
+ return {
16547
+ total,
16548
+ moved
16549
+ };
16550
+ }
16551
+ /** Fold the resolved geometry's outer translate chain into the spec placement
16552
+ * origin, so IfcLocalPlacement matches the IR world frame no matter where the
16553
+ * transform came from (family-internal or prop-level). */
16554
+ function composedOrigin(el) {
16555
+ const base = el.props["origin"] ?? [
16556
+ 0,
16557
+ 0,
16558
+ 0
16559
+ ];
16560
+ const { total, moved } = peelTranslates(el.geometry);
16561
+ if (!moved && el.props["origin"] === void 0) return void 0;
16562
+ return [
16563
+ base[0] + total[0],
16564
+ base[1] + total[1],
16565
+ base[2] + total[2]
16566
+ ];
16567
+ }
16568
+ function stripGeometryProps(props) {
16569
+ const out = {};
16570
+ for (const [k, v] of Object.entries(props)) if (!GEOMETRY_PROPS.has(k)) out[k] = v;
16571
+ return out;
16531
16572
  }
16532
16573
  function specInput(el) {
16533
- const fromProps = {};
16534
- for (const [k, v] of Object.entries(el.props)) if (!GEOMETRY_PROPS.has(k)) fromProps[k] = v;
16535
16574
  const origin = composedOrigin(el);
16536
16575
  return {
16537
16576
  ...SPEC_DEFAULTS,
16538
- ...fromProps,
16577
+ ...stripGeometryProps(el.props),
16539
16578
  ...origin ? { origin } : {},
16540
16579
  ...collectSpecProps(el)
16541
16580
  };
@@ -16554,6 +16593,56 @@ function collectSpecProps(el) {
16554
16593
  delete out["psets"];
16555
16594
  return out;
16556
16595
  }
16596
+ function addFill(model, fill, input, identity) {
16597
+ if (fill.type === "Door") {
16598
+ const parsed = parseDoorSpec(input);
16599
+ if (!parsed.ok) return parsed;
16600
+ return model.addDoor(parsed.value, identity);
16601
+ }
16602
+ if (fill.type === "Window") {
16603
+ const parsed = parseWindowSpec(input);
16604
+ if (!parsed.ok) return parsed;
16605
+ return model.addWindow(parsed.value, identity);
16606
+ }
16607
+ return (0, brepjs.err)(specError("FAMILIES_UNSUPPORTED_FILL", `familiesToBim: no fill mapping for element type '${fill.type}' at '${fill.keyPath}'`));
16608
+ }
16609
+ /** Map a wall's synthesized Opening children onto addDoor/addWindow. The
16610
+ * wall-relative offsets come from the void geometry's frame minus the host's:
16611
+ * exact because both carry the same outer host transform. */
16612
+ function addOpenings(model, host, wallId, containerId, idByKeyPath) {
16613
+ const hostT = peelTranslates(host.geometry).total;
16614
+ for (const opening of host.children) {
16615
+ if (opening.type !== "Opening") continue;
16616
+ const fill = opening.children[0];
16617
+ if (fill === void 0) return (0, brepjs.err)(specError("FAMILIES_OPENING_NO_FILL", `familiesToBim: opening '${opening.keyPath}' has no fill element`));
16618
+ const keyed = requireKeyed(opening);
16619
+ if (!keyed.ok) return keyed;
16620
+ const fillT = peelTranslates(opening.geometry).total;
16621
+ const added = addFill(model, fill, {
16622
+ materialName: SPEC_DEFAULTS.materialName,
16623
+ ...stripGeometryProps(fill.props),
16624
+ wallLocalId: wallId,
16625
+ offsetAlongWall: fillT[0] - hostT[0],
16626
+ offsetFromFloor: fillT[2] - hostT[2]
16627
+ }, {
16628
+ stableKey: fill.keyPath,
16629
+ openingStableKey: opening.keyPath
16630
+ });
16631
+ if (!added.ok) return added;
16632
+ model.placeIn(added.value, containerId);
16633
+ idByKeyPath.set(fill.keyPath, added.value);
16634
+ const fillsRel = model.getAllRelationships().find((r) => r.kind === "FILLS_OPENING" && r.fillerLocalId === added.value);
16635
+ if (fillsRel !== void 0) idByKeyPath.set(opening.keyPath, fillsRel.openingLocalId);
16636
+ }
16637
+ return (0, brepjs.ok)(void 0);
16638
+ }
16639
+ /** Every element that mints an IFC identity needs an explicit key: an
16640
+ * index-fallback path is order-dependent, which would silently break the
16641
+ * reorder-stable GlobalId contract. */
16642
+ function requireKeyed(el) {
16643
+ if (el.keyed) return (0, brepjs.ok)(void 0);
16644
+ return (0, brepjs.err)(specError("FAMILIES_UNKEYED_ELEMENT", `familiesToBim: '${el.keyPath}' has no explicit key — IFC identity needs order-independent key paths (add a key to the element)`));
16645
+ }
16557
16646
  /**
16558
16647
  * Project a resolved families tree into an eager BimModel. The caller owns
16559
16648
  * the returned model (`using`); families stays domain-neutral — this adapter
@@ -16570,6 +16659,8 @@ function familiesToBim(root, options) {
16570
16659
  const walk = (el, storeyId) => {
16571
16660
  let containerId = storeyId;
16572
16661
  if (el.type === "Storey") {
16662
+ const keyed = requireKeyed(el);
16663
+ if (!keyed.ok) return keyed;
16573
16664
  const id = model.addStorey({
16574
16665
  name: el.attributes["name"] ?? el.keyPath,
16575
16666
  elevation: el.props["elevation"] ?? 0
@@ -16578,6 +16669,8 @@ function familiesToBim(root, options) {
16578
16669
  idByKeyPath.set(el.keyPath, id);
16579
16670
  containerId = id;
16580
16671
  } else if (el.type === "Wall" || el.type === "Slab") {
16672
+ const keyed = requireKeyed(el);
16673
+ if (!keyed.ok) return keyed;
16581
16674
  const parsed = el.type === "Wall" ? parseWallSpec(specInput(el)) : parseSlabSpec(specInput(el));
16582
16675
  if (!parsed.ok) return parsed;
16583
16676
  const added = el.type === "Wall" ? model.addWall(parsed.value, { stableKey: el.keyPath }) : model.addSlab(parsed.value, { stableKey: el.keyPath });
@@ -16585,8 +16678,14 @@ function familiesToBim(root, options) {
16585
16678
  idByKeyPath.set(el.keyPath, added.value);
16586
16679
  if (containerId === null) return (0, brepjs.err)(specError("FAMILIES_NO_STOREY", `familiesToBim: '${el.keyPath}' has no Storey ancestor — IFC elements need spatial containment`));
16587
16680
  model.placeIn(added.value, containerId);
16588
- } else if (el.type !== "Opening" && el.type !== "Group" && el.geometry.kind !== "Empty") return (0, brepjs.err)(specError("FAMILIES_UNSUPPORTED_TYPE", `familiesToBim: no spec mapping for element type '${el.type}' at '${el.keyPath}'`));
16681
+ if (el.type === "Wall") {
16682
+ const opened = addOpenings(model, el, added.value, containerId, idByKeyPath);
16683
+ if (!opened.ok) return opened;
16684
+ }
16685
+ } else if (el.type === "Opening") return (0, brepjs.err)(specError("FAMILIES_OPENING_OUTSIDE_WALL", `familiesToBim: opening '${el.keyPath}' is not hosted by a Wall — only wall openings are mapped`));
16686
+ else if (el.type !== "Group" && el.geometry.kind !== "Empty") return (0, brepjs.err)(specError("FAMILIES_UNSUPPORTED_TYPE", `familiesToBim: no spec mapping for element type '${el.type}' at '${el.keyPath}'`));
16589
16687
  for (const child of el.children) {
16688
+ if (el.type === "Wall" && child.type === "Opening") continue;
16590
16689
  const r = walk(child, containerId);
16591
16690
  if (!r.ok) return r;
16592
16691
  }
@@ -6327,6 +6327,14 @@ var BimModel = class {
6327
6327
  if (key !== void 0 && this.#usedStableKeys.has(key)) return err(specError("DUPLICATE_STABLE_KEY", `BimModel: duplicate stableKey '${key}'`));
6328
6328
  return ok(void 0);
6329
6329
  }
6330
+ #checkOpeningKeys(options) {
6331
+ const filler = this.#checkStableKey(options);
6332
+ if (!filler.ok) return filler;
6333
+ const opening = this.#checkStableKey({ stableKey: options?.openingStableKey });
6334
+ if (!opening.ok) return opening;
6335
+ if (options?.stableKey !== void 0 && options.stableKey === options.openingStableKey) return err(specError("DUPLICATE_STABLE_KEY", `BimModel: stableKey and openingStableKey are both '${options.stableKey}'`));
6336
+ return ok(void 0);
6337
+ }
6330
6338
  addWall(spec, options) {
6331
6339
  const keyCheck = this.#checkStableKey(options);
6332
6340
  if (!keyCheck.ok) return keyCheck;
@@ -6618,7 +6626,9 @@ var BimModel = class {
6618
6626
  });
6619
6627
  return ok(id);
6620
6628
  }
6621
- addDoor(spec) {
6629
+ addDoor(spec, options) {
6630
+ const keyCheck = this.#checkOpeningKeys(options);
6631
+ if (!keyCheck.ok) return keyCheck;
6622
6632
  const wall = this.#elements.get(spec.wallLocalId);
6623
6633
  if (wall === void 0 || wall.category !== "WALL") return err(specError("DOOR_WALL_NOT_FOUND", `No wall found for localId ${spec.wallLocalId}`));
6624
6634
  if (spec.offsetAlongWall + spec.width > wall.spec.length) return err(specError("DOOR_EXCEEDS_WALL_BOUNDS", "Door (offsetAlongWall + width) exceeds wall length"));
@@ -6633,13 +6643,13 @@ var BimModel = class {
6633
6643
  const cutResult = this.#cutWallGeometry(wall, openingSpec);
6634
6644
  if (!cutResult.ok) return err(cutResult.error);
6635
6645
  this.#replaceWallGeometry(wall, cutResult.value);
6636
- const openingId = this.#makeElement("OPENING", openingSpec, null);
6646
+ const openingId = this.#makeElement("OPENING", openingSpec, null, options?.openingStableKey);
6637
6647
  this.#makeRel({
6638
6648
  kind: "VOIDS_WALL",
6639
6649
  wallLocalId: spec.wallLocalId,
6640
6650
  openingLocalId: openingId
6641
6651
  });
6642
- const doorId = this.#makeElement("DOOR", spec, null);
6652
+ const doorId = this.#makeElement("DOOR", spec, null, options?.stableKey);
6643
6653
  this.#makeRel({
6644
6654
  kind: "FILLS_OPENING",
6645
6655
  openingLocalId: openingId,
@@ -6652,7 +6662,9 @@ var BimModel = class {
6652
6662
  });
6653
6663
  return ok(doorId);
6654
6664
  }
6655
- addWindow(spec) {
6665
+ addWindow(spec, options) {
6666
+ const keyCheck = this.#checkOpeningKeys(options);
6667
+ if (!keyCheck.ok) return keyCheck;
6656
6668
  const wall = this.#elements.get(spec.wallLocalId);
6657
6669
  if (wall === void 0 || wall.category !== "WALL") return err(specError("WINDOW_WALL_NOT_FOUND", `No wall found for localId ${spec.wallLocalId}`));
6658
6670
  if (spec.offsetAlongWall + spec.width > wall.spec.length) return err(specError("WINDOW_EXCEEDS_WALL_BOUNDS", "Window (offsetAlongWall + width) exceeds wall length"));
@@ -6667,13 +6679,13 @@ var BimModel = class {
6667
6679
  const cutResult = this.#cutWallGeometry(wall, openingSpec);
6668
6680
  if (!cutResult.ok) return err(cutResult.error);
6669
6681
  this.#replaceWallGeometry(wall, cutResult.value);
6670
- const openingId = this.#makeElement("OPENING", openingSpec, null);
6682
+ const openingId = this.#makeElement("OPENING", openingSpec, null, options?.openingStableKey);
6671
6683
  this.#makeRel({
6672
6684
  kind: "VOIDS_WALL",
6673
6685
  wallLocalId: spec.wallLocalId,
6674
6686
  openingLocalId: openingId
6675
6687
  });
6676
- const windowId = this.#makeElement("WINDOW", spec, null);
6688
+ const windowId = this.#makeElement("WINDOW", spec, null, options?.stableKey);
6677
6689
  this.#makeRel({
6678
6690
  kind: "FILLS_OPENING",
6679
6691
  openingLocalId: openingId,
@@ -6686,7 +6698,9 @@ var BimModel = class {
6686
6698
  });
6687
6699
  return ok(windowId);
6688
6700
  }
6689
- addSlabOpening(input) {
6701
+ addSlabOpening(input, options) {
6702
+ const keyCheck = this.#checkStableKey(options);
6703
+ if (!keyCheck.ok) return keyCheck;
6690
6704
  const slab = this.#elements.get(input.slabLocalId);
6691
6705
  if (slab === void 0 || slab.category !== "SLAB") return err(specError("SLAB_OPENING_SLAB_NOT_FOUND", `No slab found for localId ${input.slabLocalId}`));
6692
6706
  if (input.offsetX + input.sizeX > slab.spec.length) return err(specError("SLAB_OPENING_EXCEEDS_SLAB_BOUNDS", "Opening (offsetX + sizeX) exceeds slab length"));
@@ -6716,7 +6730,7 @@ var BimModel = class {
6716
6730
  const cutResult = this.#cutSlabGeometry(slab, openingSpec);
6717
6731
  if (!cutResult.ok) return err(cutResult.error);
6718
6732
  this.#replaceSlabGeometry(slab, cutResult.value);
6719
- const openingId = this.#makeElement("OPENING", openingSpec, null);
6733
+ const openingId = this.#makeElement("OPENING", openingSpec, null, options?.stableKey);
6720
6734
  this.#makeRel({
6721
6735
  kind: "VOIDS_SLAB",
6722
6736
  slabLocalId: input.slabLocalId,
@@ -16456,8 +16470,11 @@ function assignNum(target, key, value) {
16456
16470
  * while the IR path serves the viewport and dedup. GlobalIds derive from
16457
16471
  * families key paths (stable under reordering), not insertion order.
16458
16472
  *
16459
- * v1 scope: Storey containers and Wall/Slab elements. Openings, fills, and
16460
- * relationship wiring (IfcRelVoidsElement) follow with the opening writer.
16473
+ * Scope: Storey containers, Wall/Slab elements, and wall openings — a
16474
+ * fill-role void (Door/Window family) maps onto addDoor/addWindow, which cut
16475
+ * the wall and wire IfcRelVoidsElement + IfcRelFillsElement; the opening and
16476
+ * filler GlobalIds derive from the synthesized key paths. Anonymous (non-fill)
16477
+ * voids stay IR-only and never reach the spec path.
16461
16478
  */
16462
16479
  var SPEC_DEFAULTS = {
16463
16480
  origin: [
@@ -16483,36 +16500,58 @@ var GEOMETRY_PROPS = /* @__PURE__ */ new Set([
16483
16500
  "transform",
16484
16501
  "psets"
16485
16502
  ]);
16486
- /** Fold the resolved geometry's OUTER literal translate chain into the spec
16487
- * placement origin, so IfcLocalPlacement matches the IR world frame no
16488
- * matter where the transform came from (family-internal or prop-level).
16489
- * Parameter-driven translations cannot fold and keep the default origin. */
16490
- function composedOrigin(el) {
16491
- const out = [...el.props["origin"] ?? [
16503
+ /** Total of the resolved geometry's OUTER literal translate chain. The
16504
+ * transform vocabulary is translate-only, so frame differences are exact
16505
+ * subtractions. Parameter-driven translations stop the peel. */
16506
+ function peelTranslates(node) {
16507
+ const total = [
16492
16508
  0,
16493
16509
  0,
16494
16510
  0
16495
- ]];
16511
+ ];
16496
16512
  let moved = false;
16497
- let node = el.geometry;
16498
- while (node.kind === "Translate") {
16499
- const v = node.vector;
16513
+ let cur = node;
16514
+ while (cur.kind === "Translate") {
16515
+ const v = cur.vector;
16500
16516
  if (v.kind !== "Vec3Lit") break;
16501
- out[0] += v.value[0];
16502
- out[1] += v.value[1];
16503
- out[2] += v.value[2];
16517
+ total[0] += v.value[0];
16518
+ total[1] += v.value[1];
16519
+ total[2] += v.value[2];
16504
16520
  moved = true;
16505
- node = node.target;
16521
+ cur = cur.target;
16506
16522
  }
16507
- return moved || el.props["origin"] !== void 0 ? out : void 0;
16523
+ return {
16524
+ total,
16525
+ moved
16526
+ };
16527
+ }
16528
+ /** Fold the resolved geometry's outer translate chain into the spec placement
16529
+ * origin, so IfcLocalPlacement matches the IR world frame no matter where the
16530
+ * transform came from (family-internal or prop-level). */
16531
+ function composedOrigin(el) {
16532
+ const base = el.props["origin"] ?? [
16533
+ 0,
16534
+ 0,
16535
+ 0
16536
+ ];
16537
+ const { total, moved } = peelTranslates(el.geometry);
16538
+ if (!moved && el.props["origin"] === void 0) return void 0;
16539
+ return [
16540
+ base[0] + total[0],
16541
+ base[1] + total[1],
16542
+ base[2] + total[2]
16543
+ ];
16544
+ }
16545
+ function stripGeometryProps(props) {
16546
+ const out = {};
16547
+ for (const [k, v] of Object.entries(props)) if (!GEOMETRY_PROPS.has(k)) out[k] = v;
16548
+ return out;
16508
16549
  }
16509
16550
  function specInput(el) {
16510
- const fromProps = {};
16511
- for (const [k, v] of Object.entries(el.props)) if (!GEOMETRY_PROPS.has(k)) fromProps[k] = v;
16512
16551
  const origin = composedOrigin(el);
16513
16552
  return {
16514
16553
  ...SPEC_DEFAULTS,
16515
- ...fromProps,
16554
+ ...stripGeometryProps(el.props),
16516
16555
  ...origin ? { origin } : {},
16517
16556
  ...collectSpecProps(el)
16518
16557
  };
@@ -16531,6 +16570,56 @@ function collectSpecProps(el) {
16531
16570
  delete out["psets"];
16532
16571
  return out;
16533
16572
  }
16573
+ function addFill(model, fill, input, identity) {
16574
+ if (fill.type === "Door") {
16575
+ const parsed = parseDoorSpec(input);
16576
+ if (!parsed.ok) return parsed;
16577
+ return model.addDoor(parsed.value, identity);
16578
+ }
16579
+ if (fill.type === "Window") {
16580
+ const parsed = parseWindowSpec(input);
16581
+ if (!parsed.ok) return parsed;
16582
+ return model.addWindow(parsed.value, identity);
16583
+ }
16584
+ return err(specError("FAMILIES_UNSUPPORTED_FILL", `familiesToBim: no fill mapping for element type '${fill.type}' at '${fill.keyPath}'`));
16585
+ }
16586
+ /** Map a wall's synthesized Opening children onto addDoor/addWindow. The
16587
+ * wall-relative offsets come from the void geometry's frame minus the host's:
16588
+ * exact because both carry the same outer host transform. */
16589
+ function addOpenings(model, host, wallId, containerId, idByKeyPath) {
16590
+ const hostT = peelTranslates(host.geometry).total;
16591
+ for (const opening of host.children) {
16592
+ if (opening.type !== "Opening") continue;
16593
+ const fill = opening.children[0];
16594
+ if (fill === void 0) return err(specError("FAMILIES_OPENING_NO_FILL", `familiesToBim: opening '${opening.keyPath}' has no fill element`));
16595
+ const keyed = requireKeyed(opening);
16596
+ if (!keyed.ok) return keyed;
16597
+ const fillT = peelTranslates(opening.geometry).total;
16598
+ const added = addFill(model, fill, {
16599
+ materialName: SPEC_DEFAULTS.materialName,
16600
+ ...stripGeometryProps(fill.props),
16601
+ wallLocalId: wallId,
16602
+ offsetAlongWall: fillT[0] - hostT[0],
16603
+ offsetFromFloor: fillT[2] - hostT[2]
16604
+ }, {
16605
+ stableKey: fill.keyPath,
16606
+ openingStableKey: opening.keyPath
16607
+ });
16608
+ if (!added.ok) return added;
16609
+ model.placeIn(added.value, containerId);
16610
+ idByKeyPath.set(fill.keyPath, added.value);
16611
+ const fillsRel = model.getAllRelationships().find((r) => r.kind === "FILLS_OPENING" && r.fillerLocalId === added.value);
16612
+ if (fillsRel !== void 0) idByKeyPath.set(opening.keyPath, fillsRel.openingLocalId);
16613
+ }
16614
+ return ok(void 0);
16615
+ }
16616
+ /** Every element that mints an IFC identity needs an explicit key: an
16617
+ * index-fallback path is order-dependent, which would silently break the
16618
+ * reorder-stable GlobalId contract. */
16619
+ function requireKeyed(el) {
16620
+ if (el.keyed) return ok(void 0);
16621
+ return err(specError("FAMILIES_UNKEYED_ELEMENT", `familiesToBim: '${el.keyPath}' has no explicit key — IFC identity needs order-independent key paths (add a key to the element)`));
16622
+ }
16534
16623
  /**
16535
16624
  * Project a resolved families tree into an eager BimModel. The caller owns
16536
16625
  * the returned model (`using`); families stays domain-neutral — this adapter
@@ -16547,6 +16636,8 @@ function familiesToBim(root, options) {
16547
16636
  const walk = (el, storeyId) => {
16548
16637
  let containerId = storeyId;
16549
16638
  if (el.type === "Storey") {
16639
+ const keyed = requireKeyed(el);
16640
+ if (!keyed.ok) return keyed;
16550
16641
  const id = model.addStorey({
16551
16642
  name: el.attributes["name"] ?? el.keyPath,
16552
16643
  elevation: el.props["elevation"] ?? 0
@@ -16555,6 +16646,8 @@ function familiesToBim(root, options) {
16555
16646
  idByKeyPath.set(el.keyPath, id);
16556
16647
  containerId = id;
16557
16648
  } else if (el.type === "Wall" || el.type === "Slab") {
16649
+ const keyed = requireKeyed(el);
16650
+ if (!keyed.ok) return keyed;
16558
16651
  const parsed = el.type === "Wall" ? parseWallSpec(specInput(el)) : parseSlabSpec(specInput(el));
16559
16652
  if (!parsed.ok) return parsed;
16560
16653
  const added = el.type === "Wall" ? model.addWall(parsed.value, { stableKey: el.keyPath }) : model.addSlab(parsed.value, { stableKey: el.keyPath });
@@ -16562,8 +16655,14 @@ function familiesToBim(root, options) {
16562
16655
  idByKeyPath.set(el.keyPath, added.value);
16563
16656
  if (containerId === null) return err(specError("FAMILIES_NO_STOREY", `familiesToBim: '${el.keyPath}' has no Storey ancestor — IFC elements need spatial containment`));
16564
16657
  model.placeIn(added.value, containerId);
16565
- } else if (el.type !== "Opening" && el.type !== "Group" && el.geometry.kind !== "Empty") return err(specError("FAMILIES_UNSUPPORTED_TYPE", `familiesToBim: no spec mapping for element type '${el.type}' at '${el.keyPath}'`));
16658
+ if (el.type === "Wall") {
16659
+ const opened = addOpenings(model, el, added.value, containerId, idByKeyPath);
16660
+ if (!opened.ok) return opened;
16661
+ }
16662
+ } else if (el.type === "Opening") return err(specError("FAMILIES_OPENING_OUTSIDE_WALL", `familiesToBim: opening '${el.keyPath}' is not hosted by a Wall — only wall openings are mapped`));
16663
+ else if (el.type !== "Group" && el.geometry.kind !== "Empty") return err(specError("FAMILIES_UNSUPPORTED_TYPE", `familiesToBim: no spec mapping for element type '${el.type}' at '${el.keyPath}'`));
16566
16664
  for (const child of el.children) {
16665
+ if (el.type === "Wall" && child.type === "Opening") continue;
16567
16666
  const r = walk(child, containerId);
16568
16667
  if (!r.ok) return r;
16569
16668
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { BimModel } from './model/bimModel.js';
1
+ export { BimModel, type ElementIdentityOptions, type OpeningIdentityOptions, } from './model/bimModel.js';
2
2
  export type { BimTreeNode, BimTreeSummary } from './model/treeSummary.js';
3
3
  export { placedSolids } from './elementFns/placedGeometry.js';
4
4
  export { toIfc, toIfcValidated } from './serialize/toIfc.js';
@@ -28,6 +28,11 @@ import { ProjectSpec, SiteSpec, BuildingSpec, StoreySpec } from '../specs/spatia
28
28
  export interface ElementIdentityOptions {
29
29
  readonly stableKey?: string | undefined;
30
30
  }
31
+ /** Identity options for adders that create TWO elements: `stableKey` names
32
+ * the filler (door/window), `openingStableKey` the synthesized opening. */
33
+ export interface OpeningIdentityOptions extends ElementIdentityOptions {
34
+ readonly openingStableKey?: string | undefined;
35
+ }
31
36
  export declare class BimModel {
32
37
  #private;
33
38
  init(spec: ProjectSpec): Result<LocalId, BimError>;
@@ -126,9 +131,9 @@ export declare class BimModel {
126
131
  * {@link ProxySpec.solid}).
127
132
  */
128
133
  addProxy(spec: ProxySpec): Result<LocalId, BimError>;
129
- addDoor(spec: DoorSpec): Result<LocalId, BimError>;
130
- addWindow(spec: WindowSpec): Result<LocalId, BimError>;
131
- addSlabOpening(input: SlabOpeningInput): Result<LocalId, BimError>;
134
+ addDoor(spec: DoorSpec, options?: OpeningIdentityOptions): Result<LocalId, BimError>;
135
+ addWindow(spec: WindowSpec, options?: OpeningIdentityOptions): Result<LocalId, BimError>;
136
+ addSlabOpening(input: SlabOpeningInput, options?: ElementIdentityOptions): Result<LocalId, BimError>;
132
137
  getDoors(): BimElement<'DOOR'>[];
133
138
  getWindows(): BimElement<'WINDOW'>[];
134
139
  aggregate(parentId: LocalId, childId: LocalId): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brepjs-bim",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "BIM layer for brepjs — IFC4-aligned parametric building elements",
5
5
  "keywords": [
6
6
  "bim",