brepjs-bim 0.24.0 → 0.24.1

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 CHANGED
@@ -95,9 +95,16 @@ otherwise supplied. Existing non-semantic Families archetypes continue to use th
95
95
  registry beneath Bridge Parts. Bridge, Bridge Part, and Earthworks Fill require IFC4X3; `fromIfc`
96
96
  reconstructs their civil spatial hierarchy, direct containment, and typed Earthworks inventory.
97
97
 
98
- The existing typed routes adapt semantic envelope dimensions from the reference Families into
99
- their parametric BIM specs. They do not promise exact preservation of compound or voided source
100
- bodies; exact authored-body preservation in this profile is specific to Earthworks Fill.
98
+ Civil-semantic wall and railing routes require `bodyEvaluator` (or the
99
+ `proxyEvaluator` fallback) so the adapter can verify the authored Product Body. Missing the
100
+ evaluator is `FAMILIES_PRODUCT_BODY_EVALUATOR_REQUIRED` (element path and mapped category); the
101
+ adapter does not silently keep a parametric envelope. Conventional archetype walls and railings
102
+ stay specification-authoritative and do not need an evaluator. When the evaluator is present, the
103
+ adapter applies registered wall openings to the parametric candidate, then compares that candidate
104
+ with the evaluated source in Product-local coordinates. Coincident bodies retain editable
105
+ parametric IFC; compound, voided, or otherwise different bodies retain their typed Wall or Railing
106
+ classification and export every authoritative item as tessellation. The evaluator's source handles
107
+ remain borrowed. Other typed civil routes continue to use their semantic envelope dimensions.
101
108
 
102
109
  This is deliberately not a claim of complete IFC infrastructure coverage or unchanged parity with
103
110
  the full scratch prototype. Member and Sign remain outside the profile: without `proxyEvaluator`
@@ -172,6 +179,8 @@ warnings travel inside the payload rather than throwing.
172
179
  Each `add*` call parses and validates its spec and stores a typed `BimElement` keyed by a `LocalId`.
173
180
  Parametric physical elements build an analytical brepjs solid; civil spatial elements are body-less,
174
181
  and arbitrary-body products such as Earthworks Fill take ownership of a validated authored solid.
182
+ Families-projected civil walls and railings retain evaluated authored solids when their Bodies do
183
+ not coincide with the post-opening parametric candidate.
175
184
  The IFC writer walks the model, applies placement, and emits schema-correct IFC entities; the
176
185
  importer is the inverse. No kernel/WASM changes are required.
177
186
 
@@ -536,7 +536,7 @@ var propertyKeyTypes = /* @__PURE__*/ new Set([
536
536
  function escapeRegex(str) {
537
537
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
538
538
  }
539
- function clone$2(inst, def, params) {
539
+ function clone$3(inst, def, params) {
540
540
  const cl = new inst._zod.constr(def ?? inst._zod.def);
541
541
  if (!def || params?.parent) cl._zod.parent = inst;
542
542
  return cl;
@@ -572,7 +572,7 @@ function pick(schema, mask) {
572
572
  const currDef = schema._zod.def;
573
573
  const checks = currDef.checks;
574
574
  if (checks && checks.length > 0) throw new Error(".pick() cannot be used on object schemas containing refinements");
575
- return clone$2(schema, mergeDefs(schema._zod.def, {
575
+ return clone$3(schema, mergeDefs(schema._zod.def, {
576
576
  get shape() {
577
577
  const newShape = {};
578
578
  for (const key in mask) {
@@ -590,7 +590,7 @@ function omit(schema, mask) {
590
590
  const currDef = schema._zod.def;
591
591
  const checks = currDef.checks;
592
592
  if (checks && checks.length > 0) throw new Error(".omit() cannot be used on object schemas containing refinements");
593
- return clone$2(schema, mergeDefs(schema._zod.def, {
593
+ return clone$3(schema, mergeDefs(schema._zod.def, {
594
594
  get shape() {
595
595
  const newShape = { ...schema._zod.def.shape };
596
596
  for (const key in mask) {
@@ -611,7 +611,7 @@ function extend(schema, shape) {
611
611
  const existingShape = schema._zod.def.shape;
612
612
  for (const key in shape) if (Object.getOwnPropertyDescriptor(existingShape, key) !== void 0) throw new Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead.");
613
613
  }
614
- return clone$2(schema, mergeDefs(schema._zod.def, { get shape() {
614
+ return clone$3(schema, mergeDefs(schema._zod.def, { get shape() {
615
615
  const _shape = {
616
616
  ...schema._zod.def.shape,
617
617
  ...shape
@@ -622,7 +622,7 @@ function extend(schema, shape) {
622
622
  }
623
623
  function safeExtend(schema, shape) {
624
624
  if (!isPlainObject(shape)) throw new Error("Invalid input to safeExtend: expected a plain object");
625
- return clone$2(schema, mergeDefs(schema._zod.def, { get shape() {
625
+ return clone$3(schema, mergeDefs(schema._zod.def, { get shape() {
626
626
  const _shape = {
627
627
  ...schema._zod.def.shape,
628
628
  ...shape
@@ -633,7 +633,7 @@ function safeExtend(schema, shape) {
633
633
  }
634
634
  function merge(a, b) {
635
635
  if (a._zod.def.checks?.length) throw new Error(".merge() cannot be used on object schemas containing refinements. Use .safeExtend() instead.");
636
- return clone$2(a, mergeDefs(a._zod.def, {
636
+ return clone$3(a, mergeDefs(a._zod.def, {
637
637
  get shape() {
638
638
  const _shape = {
639
639
  ...a._zod.def.shape,
@@ -651,7 +651,7 @@ function merge(a, b) {
651
651
  function partial(Class, schema, mask) {
652
652
  const checks = schema._zod.def.checks;
653
653
  if (checks && checks.length > 0) throw new Error(".partial() cannot be used on object schemas containing refinements");
654
- return clone$2(schema, mergeDefs(schema._zod.def, {
654
+ return clone$3(schema, mergeDefs(schema._zod.def, {
655
655
  get shape() {
656
656
  const oldShape = schema._zod.def.shape;
657
657
  const shape = { ...oldShape };
@@ -674,7 +674,7 @@ function partial(Class, schema, mask) {
674
674
  }));
675
675
  }
676
676
  function required(Class, schema, mask) {
677
- return clone$2(schema, mergeDefs(schema._zod.def, { get shape() {
677
+ return clone$3(schema, mergeDefs(schema._zod.def, { get shape() {
678
678
  const oldShape = schema._zod.def.shape;
679
679
  const shape = { ...oldShape };
680
680
  if (mask) for (const key in mask) {
@@ -4043,7 +4043,7 @@ var ZodType = /*@__PURE__*/ $constructor("ZodType", (inst, def) => {
4043
4043
  return this.check(...chks);
4044
4044
  },
4045
4045
  clone(def, params) {
4046
- return clone$2(this, def, params);
4046
+ return clone$3(this, def, params);
4047
4047
  },
4048
4048
  brand() {
4049
4049
  return this;
@@ -7561,19 +7561,19 @@ function rotationOp(matrix) {
7561
7561
  }
7562
7562
  //#endregion
7563
7563
  //#region src/elementFns/placedGeometry.ts
7564
- var testHooks$3 = null;
7564
+ var testHooks$5 = null;
7565
7565
  function place(solid, frame) {
7566
7566
  let placed = null;
7567
7567
  try {
7568
7568
  placed = locateShapeInFrame(solid, frame);
7569
- testHooks$3?.afterPlaced?.(placed);
7569
+ testHooks$5?.afterPlaced?.(placed);
7570
7570
  return (0, brepjs.ok)(placed);
7571
7571
  } catch (cause) {
7572
7572
  placed?.[Symbol.dispose]();
7573
7573
  return (0, brepjs.err)(geometryError("PLACED_GEOMETRY_FAILED", "Element placement threw while transforming geometry", cause));
7574
7574
  }
7575
7575
  }
7576
- function disposeAll(solids) {
7576
+ function disposeAll$1(solids) {
7577
7577
  for (const s of solids) s[Symbol.dispose]();
7578
7578
  }
7579
7579
  function placeWithinParent(solid, localFrame, parentFrame) {
@@ -7615,7 +7615,7 @@ function placedSolids(el, options = {}) {
7615
7615
  for (const solid of bodySolids(el.geometry)) {
7616
7616
  const placed = placeWithinParent(solid, el.spec, parentFrame);
7617
7617
  if (!placed.ok) {
7618
- disposeAll(out);
7618
+ disposeAll$1(out);
7619
7619
  return placed;
7620
7620
  }
7621
7621
  out.push(placed.value);
@@ -7640,12 +7640,12 @@ function placedSolids(el, options = {}) {
7640
7640
  var _usingCtx3 = _usingCtx();
7641
7641
  const built = stairFlightToSolid(flight);
7642
7642
  if (!built.ok) {
7643
- disposeAll(out);
7643
+ disposeAll$1(out);
7644
7644
  return (0, brepjs.err)(built.error);
7645
7645
  }
7646
7646
  const placed = placeWithinParent(_usingCtx3.u(built.value.solid), flight, parentFrame);
7647
7647
  if (!placed.ok) {
7648
- disposeAll(out);
7648
+ disposeAll$1(out);
7649
7649
  return placed;
7650
7650
  }
7651
7651
  out.push(placed.value);
@@ -7662,12 +7662,12 @@ function placedSolids(el, options = {}) {
7662
7662
  var _usingCtx4 = _usingCtx();
7663
7663
  const built = rampFlightToSolid(flight);
7664
7664
  if (!built.ok) {
7665
- disposeAll(out);
7665
+ disposeAll$1(out);
7666
7666
  return (0, brepjs.err)(built.error);
7667
7667
  }
7668
7668
  const placed = placeWithinParent(_usingCtx4.u(built.value.solid), flight, parentFrame);
7669
7669
  if (!placed.ok) {
7670
- disposeAll(out);
7670
+ disposeAll$1(out);
7671
7671
  return placed;
7672
7672
  }
7673
7673
  out.push(placed.value);
@@ -7718,12 +7718,12 @@ function placedSolids(el, options = {}) {
7718
7718
  ]
7719
7719
  });
7720
7720
  if (!componentLocal.ok) {
7721
- disposeAll(out);
7721
+ disposeAll$1(out);
7722
7722
  return componentLocal;
7723
7723
  }
7724
7724
  const placed = placeWithinParent(_usingCtx5.u(componentLocal.value), el.spec, parentFrame);
7725
7725
  if (!placed.ok) {
7726
- disposeAll(out);
7726
+ disposeAll$1(out);
7727
7727
  return placed;
7728
7728
  }
7729
7729
  out.push(placed.value);
@@ -7828,7 +7828,7 @@ async function initIfcApi(api) {
7828
7828
  }
7829
7829
  //#endregion
7830
7830
  //#region src/ifc-writer/ifcWriter.ts
7831
- var testHooks$2 = null;
7831
+ var testHooks$4 = null;
7832
7832
  /** Default MVD ViewDefinition declared in the STEP FILE_DESCRIPTION header. */
7833
7833
  var DEFAULT_MVD_VIEW_DEFINITION = "ReferenceView_v1.2";
7834
7834
  var VIEW_DEFINITION_RE = /ViewDefinition \[[^\]]*\]/;
@@ -7890,7 +7890,7 @@ var IfcWriter = class IfcWriter {
7890
7890
  }
7891
7891
  writeLine(entity) {
7892
7892
  this.#api.WriteLine(this.#modelId, entity);
7893
- testHooks$2?.afterWriteLine?.();
7893
+ testHooks$4?.afterWriteLine?.();
7894
7894
  return entity.expressID;
7895
7895
  }
7896
7896
  ref(id) {
@@ -7903,7 +7903,7 @@ var IfcWriter = class IfcWriter {
7903
7903
  if (this.#closed) return;
7904
7904
  this.#closed = true;
7905
7905
  this.#api.CloseModel(this.#modelId);
7906
- testHooks$2?.afterClose?.();
7906
+ testHooks$4?.afterClose?.();
7907
7907
  }
7908
7908
  [Symbol.dispose]() {
7909
7909
  this.close();
@@ -14439,7 +14439,7 @@ function buildElementContainmentMap(reader) {
14439
14439
  //#endregion
14440
14440
  //#region src/import/geometryRead.ts
14441
14441
  var NONE = { kind: "NONE" };
14442
- var testHooks$1 = null;
14442
+ var testHooks$3 = null;
14443
14443
  /**
14444
14444
  * Reconstructs the `Body` (SweptSolid) representation of a product into a brepjs
14445
14445
  * solid, falling back to tessellated mesh import when no parametric path exists.
@@ -14683,7 +14683,7 @@ function reconstructTessellatedMesh(mesh, scaleToMm, itemExpressId, diagnostics)
14683
14683
  }
14684
14684
  if (solid !== null) {
14685
14685
  try {
14686
- testHooks$1?.afterItemSolid?.(itemExpressId, solid);
14686
+ testHooks$3?.afterItemSolid?.(itemExpressId, solid);
14687
14687
  } catch (cause) {
14688
14688
  solid[Symbol.dispose]();
14689
14689
  throw cause;
@@ -15644,7 +15644,7 @@ function readOwnerHistory(reader, ownerHistoryExpressId) {
15644
15644
  }
15645
15645
  //#endregion
15646
15646
  //#region src/import/fromIfc.ts
15647
- var testHooks = null;
15647
+ var testHooks$2 = null;
15648
15648
  /**
15649
15649
  * Each enumerated physical-element IFC type and the {@link ImportedElementCategory}
15650
15650
  * it maps to. Curtain-wall sub-components (IfcPlate/IfcMember) and stair/ramp
@@ -15708,7 +15708,7 @@ async function fromIfc(bytes, options = {}) {
15708
15708
  if (element === null) continue;
15709
15709
  elements.push(element);
15710
15710
  byExpressId.set(element.expressId, element);
15711
- testHooks?.afterElement?.(element, elements.length);
15711
+ testHooks$2?.afterElement?.(element, elements.length);
15712
15712
  }
15713
15713
  const report = appendIssues(emptyReport(), diagnostics);
15714
15714
  const applicationName = readApplicationName(reader);
@@ -15754,7 +15754,7 @@ function readElement(reader, expressId, category, scale, containment, typeEnums,
15754
15754
  bounds: null,
15755
15755
  volumeMm3: null
15756
15756
  } : reconstructGeometry(reader, expressId, scale, voidedBy, diagnostics);
15757
- testHooks?.afterGeometry?.(expressId, geometry);
15757
+ testHooks$2?.afterGeometry?.(expressId, geometry);
15758
15758
  const psets = readPsets(reader, expressId).map(toImportedPset);
15759
15759
  const material = readMaterial(reader, expressId, scale);
15760
15760
  const classification = readClassification(reader, expressId);
@@ -28895,13 +28895,151 @@ function isPureTranslation(f, eps = 1e-9) {
28895
28895
  return Math.abs((f[0] ?? 1) - 1) < eps && Math.abs((f[5] ?? 1) - 1) < eps && Math.abs((f[10] ?? 1) - 1) < eps && Math.abs(f[1] ?? 0) < eps && Math.abs(f[2] ?? 0) < eps && Math.abs(f[4] ?? 0) < eps && Math.abs(f[6] ?? 0) < eps && Math.abs(f[8] ?? 0) < eps && Math.abs(f[9] ?? 0) < eps;
28896
28896
  }
28897
28897
  //#endregion
28898
+ //#region src/familiesProductBody.ts
28899
+ var RELATIVE_VOLUME_TOLERANCE = 1e-6;
28900
+ var testHooks$1 = null;
28901
+ /**
28902
+ * Evaluates a civil Product Body, clones and localizes every borrowed source,
28903
+ * then proves whether the registered-opening parametric Body is coincident.
28904
+ * An EXACT result is caller-owned until takeExactProductBody() succeeds.
28905
+ */
28906
+ function selectCivilProductBody(input) {
28907
+ const evaluated = evaluateBody(input);
28908
+ if (!evaluated.ok) return evaluated;
28909
+ const sources = evaluated.value;
28910
+ if (sources.length === 0) return (0, brepjs.err)(productBodyError(input, "FAMILIES_PRODUCT_BODY_EMPTY", `evaluated to no solid Body items`));
28911
+ const localized = [];
28912
+ const inverse = decomposeFrame(frameInverse(input.productWorldFrame));
28913
+ for (const [itemIndex, source] of sources.entries()) {
28914
+ const copied = (0, brepjs.clone)(source);
28915
+ if (!copied.ok) {
28916
+ disposeAll(localized);
28917
+ return (0, brepjs.err)(productBodyError(input, "FAMILIES_PRODUCT_BODY_COPY_FAILED", `Body item ${itemIndex} could not be copied`, copied.error, itemIndex));
28918
+ }
28919
+ try {
28920
+ testHooks$1?.afterCopy?.(itemIndex, copied.value, source);
28921
+ } catch (cause) {
28922
+ copied.value[Symbol.dispose]();
28923
+ disposeAll(localized);
28924
+ return (0, brepjs.err)(productBodyError(input, "FAMILIES_PRODUCT_BODY_COPY_FAILED", `Body item ${itemIndex} could not be copied`, cause, itemIndex));
28925
+ }
28926
+ let valid;
28927
+ try {
28928
+ valid = (0, brepjs.validSolid)(copied.value);
28929
+ } catch (cause) {
28930
+ copied.value[Symbol.dispose]();
28931
+ disposeAll(localized);
28932
+ return (0, brepjs.err)(productBodyError(input, "FAMILIES_PRODUCT_BODY_INVALID", `Body item ${itemIndex} could not be validated as a solid`, cause, itemIndex));
28933
+ }
28934
+ if (!valid.ok) {
28935
+ copied.value[Symbol.dispose]();
28936
+ disposeAll(localized);
28937
+ return (0, brepjs.err)(productBodyError(input, "FAMILIES_PRODUCT_BODY_INVALID", `Body item ${itemIndex} is not a valid solid: ${valid.error}`, void 0, itemIndex));
28938
+ }
28939
+ let local = null;
28940
+ try {
28941
+ testHooks$1?.beforeLocalize?.(itemIndex, valid.value);
28942
+ local = locateShapeInFrame(valid.value, inverse);
28943
+ testHooks$1?.afterLocalized?.(itemIndex, local);
28944
+ localized.push(local);
28945
+ } catch (cause) {
28946
+ local?.[Symbol.dispose]();
28947
+ valid.value[Symbol.dispose]();
28948
+ disposeAll(localized);
28949
+ return (0, brepjs.err)(productBodyError(input, "FAMILIES_PRODUCT_BODY_LOCALIZE_FAILED", `Body item ${itemIndex} could not be moved into the Product-local frame`, cause, itemIndex));
28950
+ }
28951
+ valid.value[Symbol.dispose]();
28952
+ }
28953
+ const exactBody = {
28954
+ kind: "EXACT",
28955
+ solids: asNonEmpty(localized)
28956
+ };
28957
+ try {
28958
+ testHooks$1?.beforeCoincidence?.(exactBody, input.parametricBody);
28959
+ } catch (cause) {
28960
+ disposeAll(exactBody.solids);
28961
+ return (0, brepjs.err)(productBodyError(input, "FAMILIES_PRODUCT_BODY_COMPARISON_FAILED", `authored and parametric Bodies could not be compared`, cause));
28962
+ }
28963
+ if (!bodiesCoincident(exactBody, input.parametricBody)) return (0, brepjs.ok)({
28964
+ kind: "EXACT",
28965
+ body: exactBody
28966
+ });
28967
+ disposeAll(exactBody.solids);
28968
+ return (0, brepjs.ok)({ kind: "PARAMETRIC" });
28969
+ }
28970
+ function evaluateBody(input) {
28971
+ try {
28972
+ const evaluated = input.evaluator.evaluate(input.element.geometry);
28973
+ if (!evaluated.ok) return (0, brepjs.err)(productBodyError(input, "FAMILIES_PRODUCT_BODY_EVALUATION_FAILED", `authored Body evaluation failed: ${evaluated.error.message}`, evaluated.error));
28974
+ return (0, brepjs.ok)((0, brepjs.isSolid)(evaluated.value) ? [evaluated.value] : (0, brepjs.getSolids)(evaluated.value));
28975
+ } catch (cause) {
28976
+ return (0, brepjs.err)(productBodyError(input, "FAMILIES_PRODUCT_BODY_EVALUATION_FAILED", `authored Body evaluation threw`, cause));
28977
+ }
28978
+ }
28979
+ function bodiesCoincident(exact, parametric) {
28980
+ const exactVolume = measureBody(exact);
28981
+ const parametricVolume = measureBody(parametric);
28982
+ if (exactVolume === null || parametricVolume === null || !volumesClose(exactVolume, parametricVolume)) return false;
28983
+ let union = null;
28984
+ try {
28985
+ const fused = (0, brepjs.fuseAll)([...bodySolids(exact), ...bodySolids(parametric)], {
28986
+ optimisation: "sameFace",
28987
+ simplify: true,
28988
+ strategy: "pairwise",
28989
+ trackEvolution: false
28990
+ });
28991
+ if (!fused.ok) return false;
28992
+ union = fused.value;
28993
+ const measured = (0, brepjs.measureVolume)(union);
28994
+ return measured.ok && volumesClose(measured.value, exactVolume) && volumesClose(measured.value, parametricVolume);
28995
+ } catch {
28996
+ return false;
28997
+ } finally {
28998
+ union?.[Symbol.dispose]();
28999
+ }
29000
+ }
29001
+ function measureBody(body) {
29002
+ let total = 0;
29003
+ try {
29004
+ for (const solid of bodySolids(body)) {
29005
+ const measured = (0, brepjs.measureVolume)(solid);
29006
+ if (!measured.ok || !Number.isFinite(measured.value) || measured.value <= 0) return null;
29007
+ total += measured.value;
29008
+ }
29009
+ return total;
29010
+ } catch {
29011
+ return null;
29012
+ }
29013
+ }
29014
+ function volumesClose(a, b) {
29015
+ return Math.abs(a - b) <= RELATIVE_VOLUME_TOLERANCE * Math.max(Math.abs(a), Math.abs(b), Number.EPSILON);
29016
+ }
29017
+ function asNonEmpty(solids) {
29018
+ const first = solids[0];
29019
+ if (first === void 0) throw new Error("Expected a non-empty exact Product Body");
29020
+ return [first, ...solids.slice(1)];
29021
+ }
29022
+ function disposeAll(solids) {
29023
+ for (const solid of solids) solid[Symbol.dispose]();
29024
+ }
29025
+ function productBodyError(input, code, detail, cause, itemIndex) {
29026
+ return {
29027
+ ...specError(code, `familiesToBim: '${input.element.keyPath}' (${input.category}) ${detail}`, cause),
29028
+ metadata: {
29029
+ keyPath: input.element.keyPath,
29030
+ category: input.category,
29031
+ ...itemIndex !== void 0 ? { itemIndex } : {}
29032
+ }
29033
+ };
29034
+ }
29035
+ //#endregion
28898
29036
  //#region src/familiesAdapter.ts
28899
29037
  /**
28900
29038
  * brepjs-families -> BimModel adapter. Consumes a resolved element tree and
28901
- * feeds each element's PRE-DESUGARED props into the parametric specs the
28902
- * spec path stays authoritative for IFC (IfcExtrudedAreaSolid + placement),
28903
- * while the IR path serves the viewport and dedup. GlobalIds derive from
28904
- * families key paths (stable under reordering), not insertion order.
29039
+ * feeds each element's PRE-DESUGARED props into parametric specs. Civil wall
29040
+ * and railing Products additionally compare the evaluated authored Body with
29041
+ * that spec Body and retain an exact Body when they diverge. GlobalIds derive
29042
+ * from families key paths (stable under reordering), not insertion order.
28905
29043
  *
28906
29044
  * Scope: building Storey containers; civil Site/Bridge/recursive Bridge Part
28907
29045
  * structure and Earthworks Fill bodies; Wall/Slab/Column/Beam/Roof/Stair,
@@ -28913,6 +29051,7 @@ function isPureTranslation(f, eps = 1e-9) {
28913
29051
  * voids are rejected: they cut only the IR/viewport geometry, and exporting
28914
29052
  * the uncut spec body would silently diverge from what the user sees.
28915
29053
  */
29054
+ var testHooks = null;
28916
29055
  var SPEC_DEFAULTS = {
28917
29056
  origin: [
28918
29057
  0,
@@ -29093,6 +29232,12 @@ function civilProductArchetype(el) {
29093
29232
  if (definition === void 0 || !definition.roles.includes(el.semantics.role)) return void 0;
29094
29233
  return definition.archetype;
29095
29234
  }
29235
+ function civilProductBodyCategory(el, archetype) {
29236
+ if (el.semantics?.kind !== "product") return null;
29237
+ if (archetype === "wall") return "WALL";
29238
+ if (archetype === "railing") return "RAILING";
29239
+ return null;
29240
+ }
29096
29241
  function semanticDimension(el, ...names) {
29097
29242
  if (el.semantics?.kind !== "product") return void 0;
29098
29243
  for (const name of names) {
@@ -29798,33 +29943,55 @@ function addEarthworksFillElement(model, el, evaluator, spatialFrame) {
29798
29943
  if (!added.ok) localized.value[Symbol.dispose]();
29799
29944
  return added;
29800
29945
  }
29946
+ function installCivilProductBody(model, el, localId, category, evaluator, productWorldFrame) {
29947
+ const target = model.getElement(localId);
29948
+ if (target === null || target.category !== category || target.geometry.kind !== "PARAMETRIC") return (0, brepjs.err)(specError("FAMILIES_PRODUCT_BODY_TARGET_INVALID", `familiesToBim: '${el.keyPath}' could not find its parametric ${category} Body after projection`));
29949
+ const selected = selectCivilProductBody({
29950
+ element: el,
29951
+ category,
29952
+ evaluator,
29953
+ productWorldFrame,
29954
+ parametricBody: target.geometry
29955
+ });
29956
+ if (!selected.ok) return selected;
29957
+ if (selected.value.kind === "PARAMETRIC") return (0, brepjs.ok)(void 0);
29958
+ const takeover = model.takeExactProductBody(localId, selected.value.body);
29959
+ if (!takeover.ok) disposeProductBody(selected.value.body);
29960
+ return takeover;
29961
+ }
29801
29962
  /**
29802
29963
  * Project a resolved families tree into an eager BimModel. The caller owns
29803
29964
  * the returned model (`using`); families stays domain-neutral — this adapter
29804
29965
  * is where families types meet the IFC vocabulary.
29805
29966
  */
29806
29967
  function familiesToBim(root, options) {
29968
+ const model = new BimModel();
29969
+ let transferred = false;
29970
+ try {
29971
+ const projected = projectFamiliesToBim(root, options, model);
29972
+ transferred = projected.ok;
29973
+ return projected;
29974
+ } catch (cause) {
29975
+ return (0, brepjs.err)(specError("FAMILIES_PROJECTION_FAILED", `familiesToBim: unexpected projection failure at '${root.keyPath}'`, cause));
29976
+ } finally {
29977
+ if (!transferred) model[Symbol.dispose]();
29978
+ }
29979
+ }
29980
+ function projectFamiliesToBim(root, options, model) {
29807
29981
  const usesAuthoredCivilHierarchy = hasCivilSpatialIntent(root);
29808
29982
  if (usesAuthoredCivilHierarchy) {
29809
29983
  const keyed = requireKeyed(root);
29810
29984
  if (!keyed.ok) return keyed;
29811
29985
  }
29812
- const model = new BimModel();
29813
29986
  const initResult = model.init(options.project, usesAuthoredCivilHierarchy ? { stableKey: civilSpatialKind(root) !== void 0 ? `${root.keyPath}#project` : root.keyPath } : void 0);
29814
29987
  if (!initResult.ok) return initResult;
29815
29988
  const projectId = initResult.value;
29816
29989
  let buildingId = null;
29817
29990
  if (!usesAuthoredCivilHierarchy) {
29818
29991
  const siteResult = model.addSite({ name: options.siteName ?? "Site" });
29819
- if (!siteResult.ok) {
29820
- model[Symbol.dispose]();
29821
- return siteResult;
29822
- }
29992
+ if (!siteResult.ok) return siteResult;
29823
29993
  const buildingResult = model.addBuilding({ name: options.buildingName ?? "Building" });
29824
- if (!buildingResult.ok) {
29825
- model[Symbol.dispose]();
29826
- return buildingResult;
29827
- }
29994
+ if (!buildingResult.ok) return buildingResult;
29828
29995
  buildingId = buildingResult.value;
29829
29996
  model.aggregate(projectId, siteResult.value);
29830
29997
  model.aggregate(siteResult.value, buildingId);
@@ -29854,8 +30021,7 @@ function familiesToBim(root, options) {
29854
30021
  if (el.geometry.kind !== "Empty") return (0, brepjs.err)(specError("FAMILIES_UNSUPPORTED_CIVIL_SEMANTICS", `familiesToBim: civil spatial element '${el.keyPath}' carries its own geometry — Site/Bridge/Bridge Part export no body, so author it as a child Product (e.g. Earthworks Fill)`));
29855
30022
  const nodeFrame = civilNodeFrame(el, cumulativeFrameHere);
29856
30023
  const rotatedFrame = rotatedHere || hasRotatedAxes(el);
29857
- const input = rotatedFrame ? civilSpatialFrameInput(el, nodeFrame, state.spatialFrame) : civilSpatialInput(el, subtractTranslation(cumulativeTranslationHere, state.projectedSpatialTranslation));
29858
- const added = addCivilSpatialOccurrence(model, el, civilKind, input);
30024
+ const added = addCivilSpatialOccurrence(model, el, civilKind, rotatedFrame ? civilSpatialFrameInput(el, nodeFrame, state.spatialFrame) : civilSpatialInput(el, subtractTranslation(cumulativeTranslationHere, state.projectedSpatialTranslation)));
29859
30025
  if (!added.ok) return added;
29860
30026
  model.aggregate(state.spatialStructureId, added.value);
29861
30027
  idByKeyPath.set(el.keyPath, added.value);
@@ -29891,6 +30057,15 @@ function familiesToBim(root, options) {
29891
30057
  } else if (route !== void 0) {
29892
30058
  const keyed = requireKeyed(el);
29893
30059
  if (!keyed.ok) return keyed;
30060
+ const productBodyCategory = civilProductBodyCategory(el, effectiveArchetype);
30061
+ const productBodyEvaluator = productBodyCategory === null ? void 0 : options.bodyEvaluator ?? options.proxyEvaluator;
30062
+ if (productBodyCategory !== null && productBodyEvaluator === void 0) return (0, brepjs.err)({
30063
+ ...specError("FAMILIES_PRODUCT_BODY_EVALUATOR_REQUIRED", `familiesToBim: civil Product '${el.keyPath}' mapped to ${productBodyCategory} needs bodyEvaluator or proxyEvaluator to verify its authoritative Body`),
30064
+ metadata: {
30065
+ keyPath: el.keyPath,
30066
+ category: productBodyCategory
30067
+ }
30068
+ });
29894
30069
  const voids = el.props["voids"];
29895
30070
  if (Array.isArray(voids)) {
29896
30071
  const openings = el.children.filter((c) => c.type === "Opening").length;
@@ -29910,6 +30085,11 @@ function familiesToBim(root, options) {
29910
30085
  const opened = addOpenings(model, el, added.value, nextSpatialStructureId, idByKeyPath);
29911
30086
  if (!opened.ok) return opened;
29912
30087
  }
30088
+ if (productBodyCategory !== null && productBodyEvaluator !== void 0) {
30089
+ const installed = installCivilProductBody(model, el, added.value, productBodyCategory, productBodyEvaluator, elementBodyFrame(el, cumulativeFrameHere, occurrenceTransformDepthHere));
30090
+ if (!installed.ok) return installed;
30091
+ testHooks?.afterCivilProductBody?.(model, added.value, el);
30092
+ }
29913
30093
  } 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`));
29914
30094
  else if (el.type !== "Group" && el.geometry.kind !== "Empty") {
29915
30095
  if (options.proxyEvaluator === void 0) return (0, brepjs.err)(specError("FAMILIES_UNSUPPORTED_TYPE", `familiesToBim: no supported spec mapping for element type '${el.type}' at '${el.keyPath}' (archetype: ${el.archetype ?? "none"}) — declare a recognized archetype or civil Product category/role, add a spec route, or pass proxyEvaluator to export it as an IfcBuildingElementProxy`));
@@ -29953,10 +30133,7 @@ function familiesToBim(root, options) {
29953
30133
  cumulativeFrame: IDENTITY_FRAME,
29954
30134
  spatialFrame: IDENTITY_FRAME
29955
30135
  });
29956
- if (!walked.ok) {
29957
- model[Symbol.dispose]();
29958
- return walked;
29959
- }
30136
+ if (!walked.ok) return walked;
29960
30137
  return (0, brepjs.ok)({
29961
30138
  model,
29962
30139
  idByKeyPath,
@@ -7538,19 +7538,19 @@ function rotationOp(matrix) {
7538
7538
  }
7539
7539
  //#endregion
7540
7540
  //#region src/elementFns/placedGeometry.ts
7541
- var testHooks$3 = null;
7541
+ var testHooks$5 = null;
7542
7542
  function place(solid, frame) {
7543
7543
  let placed = null;
7544
7544
  try {
7545
7545
  placed = locateShapeInFrame(solid, frame);
7546
- testHooks$3?.afterPlaced?.(placed);
7546
+ testHooks$5?.afterPlaced?.(placed);
7547
7547
  return ok(placed);
7548
7548
  } catch (cause) {
7549
7549
  placed?.[Symbol.dispose]();
7550
7550
  return err(geometryError("PLACED_GEOMETRY_FAILED", "Element placement threw while transforming geometry", cause));
7551
7551
  }
7552
7552
  }
7553
- function disposeAll(solids) {
7553
+ function disposeAll$1(solids) {
7554
7554
  for (const s of solids) s[Symbol.dispose]();
7555
7555
  }
7556
7556
  function placeWithinParent(solid, localFrame, parentFrame) {
@@ -7592,7 +7592,7 @@ function placedSolids(el, options = {}) {
7592
7592
  for (const solid of bodySolids(el.geometry)) {
7593
7593
  const placed = placeWithinParent(solid, el.spec, parentFrame);
7594
7594
  if (!placed.ok) {
7595
- disposeAll(out);
7595
+ disposeAll$1(out);
7596
7596
  return placed;
7597
7597
  }
7598
7598
  out.push(placed.value);
@@ -7617,12 +7617,12 @@ function placedSolids(el, options = {}) {
7617
7617
  var _usingCtx3 = _usingCtx();
7618
7618
  const built = stairFlightToSolid(flight);
7619
7619
  if (!built.ok) {
7620
- disposeAll(out);
7620
+ disposeAll$1(out);
7621
7621
  return err(built.error);
7622
7622
  }
7623
7623
  const placed = placeWithinParent(_usingCtx3.u(built.value.solid), flight, parentFrame);
7624
7624
  if (!placed.ok) {
7625
- disposeAll(out);
7625
+ disposeAll$1(out);
7626
7626
  return placed;
7627
7627
  }
7628
7628
  out.push(placed.value);
@@ -7639,12 +7639,12 @@ function placedSolids(el, options = {}) {
7639
7639
  var _usingCtx4 = _usingCtx();
7640
7640
  const built = rampFlightToSolid(flight);
7641
7641
  if (!built.ok) {
7642
- disposeAll(out);
7642
+ disposeAll$1(out);
7643
7643
  return err(built.error);
7644
7644
  }
7645
7645
  const placed = placeWithinParent(_usingCtx4.u(built.value.solid), flight, parentFrame);
7646
7646
  if (!placed.ok) {
7647
- disposeAll(out);
7647
+ disposeAll$1(out);
7648
7648
  return placed;
7649
7649
  }
7650
7650
  out.push(placed.value);
@@ -7695,12 +7695,12 @@ function placedSolids(el, options = {}) {
7695
7695
  ]
7696
7696
  });
7697
7697
  if (!componentLocal.ok) {
7698
- disposeAll(out);
7698
+ disposeAll$1(out);
7699
7699
  return componentLocal;
7700
7700
  }
7701
7701
  const placed = placeWithinParent(_usingCtx5.u(componentLocal.value), el.spec, parentFrame);
7702
7702
  if (!placed.ok) {
7703
- disposeAll(out);
7703
+ disposeAll$1(out);
7704
7704
  return placed;
7705
7705
  }
7706
7706
  out.push(placed.value);
@@ -7805,7 +7805,7 @@ async function initIfcApi(api) {
7805
7805
  }
7806
7806
  //#endregion
7807
7807
  //#region src/ifc-writer/ifcWriter.ts
7808
- var testHooks$2 = null;
7808
+ var testHooks$4 = null;
7809
7809
  /** Default MVD ViewDefinition declared in the STEP FILE_DESCRIPTION header. */
7810
7810
  var DEFAULT_MVD_VIEW_DEFINITION = "ReferenceView_v1.2";
7811
7811
  var VIEW_DEFINITION_RE = /ViewDefinition \[[^\]]*\]/;
@@ -7867,7 +7867,7 @@ var IfcWriter = class IfcWriter {
7867
7867
  }
7868
7868
  writeLine(entity) {
7869
7869
  this.#api.WriteLine(this.#modelId, entity);
7870
- testHooks$2?.afterWriteLine?.();
7870
+ testHooks$4?.afterWriteLine?.();
7871
7871
  return entity.expressID;
7872
7872
  }
7873
7873
  ref(id) {
@@ -7880,7 +7880,7 @@ var IfcWriter = class IfcWriter {
7880
7880
  if (this.#closed) return;
7881
7881
  this.#closed = true;
7882
7882
  this.#api.CloseModel(this.#modelId);
7883
- testHooks$2?.afterClose?.();
7883
+ testHooks$4?.afterClose?.();
7884
7884
  }
7885
7885
  [Symbol.dispose]() {
7886
7886
  this.close();
@@ -14416,7 +14416,7 @@ function buildElementContainmentMap(reader) {
14416
14416
  //#endregion
14417
14417
  //#region src/import/geometryRead.ts
14418
14418
  var NONE = { kind: "NONE" };
14419
- var testHooks$1 = null;
14419
+ var testHooks$3 = null;
14420
14420
  /**
14421
14421
  * Reconstructs the `Body` (SweptSolid) representation of a product into a brepjs
14422
14422
  * solid, falling back to tessellated mesh import when no parametric path exists.
@@ -14660,7 +14660,7 @@ function reconstructTessellatedMesh(mesh, scaleToMm, itemExpressId, diagnostics)
14660
14660
  }
14661
14661
  if (solid !== null) {
14662
14662
  try {
14663
- testHooks$1?.afterItemSolid?.(itemExpressId, solid);
14663
+ testHooks$3?.afterItemSolid?.(itemExpressId, solid);
14664
14664
  } catch (cause) {
14665
14665
  solid[Symbol.dispose]();
14666
14666
  throw cause;
@@ -15621,7 +15621,7 @@ function readOwnerHistory(reader, ownerHistoryExpressId) {
15621
15621
  }
15622
15622
  //#endregion
15623
15623
  //#region src/import/fromIfc.ts
15624
- var testHooks = null;
15624
+ var testHooks$2 = null;
15625
15625
  /**
15626
15626
  * Each enumerated physical-element IFC type and the {@link ImportedElementCategory}
15627
15627
  * it maps to. Curtain-wall sub-components (IfcPlate/IfcMember) and stair/ramp
@@ -15685,7 +15685,7 @@ async function fromIfc(bytes, options = {}) {
15685
15685
  if (element === null) continue;
15686
15686
  elements.push(element);
15687
15687
  byExpressId.set(element.expressId, element);
15688
- testHooks?.afterElement?.(element, elements.length);
15688
+ testHooks$2?.afterElement?.(element, elements.length);
15689
15689
  }
15690
15690
  const report = appendIssues(emptyReport(), diagnostics);
15691
15691
  const applicationName = readApplicationName(reader);
@@ -15731,7 +15731,7 @@ function readElement(reader, expressId, category, scale, containment, typeEnums,
15731
15731
  bounds: null,
15732
15732
  volumeMm3: null
15733
15733
  } : reconstructGeometry(reader, expressId, scale, voidedBy, diagnostics);
15734
- testHooks?.afterGeometry?.(expressId, geometry);
15734
+ testHooks$2?.afterGeometry?.(expressId, geometry);
15735
15735
  const psets = readPsets(reader, expressId).map(toImportedPset);
15736
15736
  const material = readMaterial(reader, expressId, scale);
15737
15737
  const classification = readClassification(reader, expressId);
@@ -28872,13 +28872,151 @@ function isPureTranslation(f, eps = 1e-9) {
28872
28872
  return Math.abs((f[0] ?? 1) - 1) < eps && Math.abs((f[5] ?? 1) - 1) < eps && Math.abs((f[10] ?? 1) - 1) < eps && Math.abs(f[1] ?? 0) < eps && Math.abs(f[2] ?? 0) < eps && Math.abs(f[4] ?? 0) < eps && Math.abs(f[6] ?? 0) < eps && Math.abs(f[8] ?? 0) < eps && Math.abs(f[9] ?? 0) < eps;
28873
28873
  }
28874
28874
  //#endregion
28875
+ //#region src/familiesProductBody.ts
28876
+ var RELATIVE_VOLUME_TOLERANCE = 1e-6;
28877
+ var testHooks$1 = null;
28878
+ /**
28879
+ * Evaluates a civil Product Body, clones and localizes every borrowed source,
28880
+ * then proves whether the registered-opening parametric Body is coincident.
28881
+ * An EXACT result is caller-owned until takeExactProductBody() succeeds.
28882
+ */
28883
+ function selectCivilProductBody(input) {
28884
+ const evaluated = evaluateBody(input);
28885
+ if (!evaluated.ok) return evaluated;
28886
+ const sources = evaluated.value;
28887
+ if (sources.length === 0) return err(productBodyError(input, "FAMILIES_PRODUCT_BODY_EMPTY", `evaluated to no solid Body items`));
28888
+ const localized = [];
28889
+ const inverse = decomposeFrame(frameInverse(input.productWorldFrame));
28890
+ for (const [itemIndex, source] of sources.entries()) {
28891
+ const copied = clone(source);
28892
+ if (!copied.ok) {
28893
+ disposeAll(localized);
28894
+ return err(productBodyError(input, "FAMILIES_PRODUCT_BODY_COPY_FAILED", `Body item ${itemIndex} could not be copied`, copied.error, itemIndex));
28895
+ }
28896
+ try {
28897
+ testHooks$1?.afterCopy?.(itemIndex, copied.value, source);
28898
+ } catch (cause) {
28899
+ copied.value[Symbol.dispose]();
28900
+ disposeAll(localized);
28901
+ return err(productBodyError(input, "FAMILIES_PRODUCT_BODY_COPY_FAILED", `Body item ${itemIndex} could not be copied`, cause, itemIndex));
28902
+ }
28903
+ let valid;
28904
+ try {
28905
+ valid = validSolid(copied.value);
28906
+ } catch (cause) {
28907
+ copied.value[Symbol.dispose]();
28908
+ disposeAll(localized);
28909
+ return err(productBodyError(input, "FAMILIES_PRODUCT_BODY_INVALID", `Body item ${itemIndex} could not be validated as a solid`, cause, itemIndex));
28910
+ }
28911
+ if (!valid.ok) {
28912
+ copied.value[Symbol.dispose]();
28913
+ disposeAll(localized);
28914
+ return err(productBodyError(input, "FAMILIES_PRODUCT_BODY_INVALID", `Body item ${itemIndex} is not a valid solid: ${valid.error}`, void 0, itemIndex));
28915
+ }
28916
+ let local = null;
28917
+ try {
28918
+ testHooks$1?.beforeLocalize?.(itemIndex, valid.value);
28919
+ local = locateShapeInFrame(valid.value, inverse);
28920
+ testHooks$1?.afterLocalized?.(itemIndex, local);
28921
+ localized.push(local);
28922
+ } catch (cause) {
28923
+ local?.[Symbol.dispose]();
28924
+ valid.value[Symbol.dispose]();
28925
+ disposeAll(localized);
28926
+ return err(productBodyError(input, "FAMILIES_PRODUCT_BODY_LOCALIZE_FAILED", `Body item ${itemIndex} could not be moved into the Product-local frame`, cause, itemIndex));
28927
+ }
28928
+ valid.value[Symbol.dispose]();
28929
+ }
28930
+ const exactBody = {
28931
+ kind: "EXACT",
28932
+ solids: asNonEmpty(localized)
28933
+ };
28934
+ try {
28935
+ testHooks$1?.beforeCoincidence?.(exactBody, input.parametricBody);
28936
+ } catch (cause) {
28937
+ disposeAll(exactBody.solids);
28938
+ return err(productBodyError(input, "FAMILIES_PRODUCT_BODY_COMPARISON_FAILED", `authored and parametric Bodies could not be compared`, cause));
28939
+ }
28940
+ if (!bodiesCoincident(exactBody, input.parametricBody)) return ok({
28941
+ kind: "EXACT",
28942
+ body: exactBody
28943
+ });
28944
+ disposeAll(exactBody.solids);
28945
+ return ok({ kind: "PARAMETRIC" });
28946
+ }
28947
+ function evaluateBody(input) {
28948
+ try {
28949
+ const evaluated = input.evaluator.evaluate(input.element.geometry);
28950
+ if (!evaluated.ok) return err(productBodyError(input, "FAMILIES_PRODUCT_BODY_EVALUATION_FAILED", `authored Body evaluation failed: ${evaluated.error.message}`, evaluated.error));
28951
+ return ok(isSolid(evaluated.value) ? [evaluated.value] : getSolids(evaluated.value));
28952
+ } catch (cause) {
28953
+ return err(productBodyError(input, "FAMILIES_PRODUCT_BODY_EVALUATION_FAILED", `authored Body evaluation threw`, cause));
28954
+ }
28955
+ }
28956
+ function bodiesCoincident(exact, parametric) {
28957
+ const exactVolume = measureBody(exact);
28958
+ const parametricVolume = measureBody(parametric);
28959
+ if (exactVolume === null || parametricVolume === null || !volumesClose(exactVolume, parametricVolume)) return false;
28960
+ let union = null;
28961
+ try {
28962
+ const fused = fuseAll([...bodySolids(exact), ...bodySolids(parametric)], {
28963
+ optimisation: "sameFace",
28964
+ simplify: true,
28965
+ strategy: "pairwise",
28966
+ trackEvolution: false
28967
+ });
28968
+ if (!fused.ok) return false;
28969
+ union = fused.value;
28970
+ const measured = measureVolume(union);
28971
+ return measured.ok && volumesClose(measured.value, exactVolume) && volumesClose(measured.value, parametricVolume);
28972
+ } catch {
28973
+ return false;
28974
+ } finally {
28975
+ union?.[Symbol.dispose]();
28976
+ }
28977
+ }
28978
+ function measureBody(body) {
28979
+ let total = 0;
28980
+ try {
28981
+ for (const solid of bodySolids(body)) {
28982
+ const measured = measureVolume(solid);
28983
+ if (!measured.ok || !Number.isFinite(measured.value) || measured.value <= 0) return null;
28984
+ total += measured.value;
28985
+ }
28986
+ return total;
28987
+ } catch {
28988
+ return null;
28989
+ }
28990
+ }
28991
+ function volumesClose(a, b) {
28992
+ return Math.abs(a - b) <= RELATIVE_VOLUME_TOLERANCE * Math.max(Math.abs(a), Math.abs(b), Number.EPSILON);
28993
+ }
28994
+ function asNonEmpty(solids) {
28995
+ const first = solids[0];
28996
+ if (first === void 0) throw new Error("Expected a non-empty exact Product Body");
28997
+ return [first, ...solids.slice(1)];
28998
+ }
28999
+ function disposeAll(solids) {
29000
+ for (const solid of solids) solid[Symbol.dispose]();
29001
+ }
29002
+ function productBodyError(input, code, detail, cause, itemIndex) {
29003
+ return {
29004
+ ...specError(code, `familiesToBim: '${input.element.keyPath}' (${input.category}) ${detail}`, cause),
29005
+ metadata: {
29006
+ keyPath: input.element.keyPath,
29007
+ category: input.category,
29008
+ ...itemIndex !== void 0 ? { itemIndex } : {}
29009
+ }
29010
+ };
29011
+ }
29012
+ //#endregion
28875
29013
  //#region src/familiesAdapter.ts
28876
29014
  /**
28877
29015
  * brepjs-families -> BimModel adapter. Consumes a resolved element tree and
28878
- * feeds each element's PRE-DESUGARED props into the parametric specs the
28879
- * spec path stays authoritative for IFC (IfcExtrudedAreaSolid + placement),
28880
- * while the IR path serves the viewport and dedup. GlobalIds derive from
28881
- * families key paths (stable under reordering), not insertion order.
29016
+ * feeds each element's PRE-DESUGARED props into parametric specs. Civil wall
29017
+ * and railing Products additionally compare the evaluated authored Body with
29018
+ * that spec Body and retain an exact Body when they diverge. GlobalIds derive
29019
+ * from families key paths (stable under reordering), not insertion order.
28882
29020
  *
28883
29021
  * Scope: building Storey containers; civil Site/Bridge/recursive Bridge Part
28884
29022
  * structure and Earthworks Fill bodies; Wall/Slab/Column/Beam/Roof/Stair,
@@ -28890,6 +29028,7 @@ function isPureTranslation(f, eps = 1e-9) {
28890
29028
  * voids are rejected: they cut only the IR/viewport geometry, and exporting
28891
29029
  * the uncut spec body would silently diverge from what the user sees.
28892
29030
  */
29031
+ var testHooks = null;
28893
29032
  var SPEC_DEFAULTS = {
28894
29033
  origin: [
28895
29034
  0,
@@ -29070,6 +29209,12 @@ function civilProductArchetype(el) {
29070
29209
  if (definition === void 0 || !definition.roles.includes(el.semantics.role)) return void 0;
29071
29210
  return definition.archetype;
29072
29211
  }
29212
+ function civilProductBodyCategory(el, archetype) {
29213
+ if (el.semantics?.kind !== "product") return null;
29214
+ if (archetype === "wall") return "WALL";
29215
+ if (archetype === "railing") return "RAILING";
29216
+ return null;
29217
+ }
29073
29218
  function semanticDimension(el, ...names) {
29074
29219
  if (el.semantics?.kind !== "product") return void 0;
29075
29220
  for (const name of names) {
@@ -29775,33 +29920,55 @@ function addEarthworksFillElement(model, el, evaluator, spatialFrame) {
29775
29920
  if (!added.ok) localized.value[Symbol.dispose]();
29776
29921
  return added;
29777
29922
  }
29923
+ function installCivilProductBody(model, el, localId, category, evaluator, productWorldFrame) {
29924
+ const target = model.getElement(localId);
29925
+ if (target === null || target.category !== category || target.geometry.kind !== "PARAMETRIC") return err(specError("FAMILIES_PRODUCT_BODY_TARGET_INVALID", `familiesToBim: '${el.keyPath}' could not find its parametric ${category} Body after projection`));
29926
+ const selected = selectCivilProductBody({
29927
+ element: el,
29928
+ category,
29929
+ evaluator,
29930
+ productWorldFrame,
29931
+ parametricBody: target.geometry
29932
+ });
29933
+ if (!selected.ok) return selected;
29934
+ if (selected.value.kind === "PARAMETRIC") return ok(void 0);
29935
+ const takeover = model.takeExactProductBody(localId, selected.value.body);
29936
+ if (!takeover.ok) disposeProductBody(selected.value.body);
29937
+ return takeover;
29938
+ }
29778
29939
  /**
29779
29940
  * Project a resolved families tree into an eager BimModel. The caller owns
29780
29941
  * the returned model (`using`); families stays domain-neutral — this adapter
29781
29942
  * is where families types meet the IFC vocabulary.
29782
29943
  */
29783
29944
  function familiesToBim(root, options) {
29945
+ const model = new BimModel();
29946
+ let transferred = false;
29947
+ try {
29948
+ const projected = projectFamiliesToBim(root, options, model);
29949
+ transferred = projected.ok;
29950
+ return projected;
29951
+ } catch (cause) {
29952
+ return err(specError("FAMILIES_PROJECTION_FAILED", `familiesToBim: unexpected projection failure at '${root.keyPath}'`, cause));
29953
+ } finally {
29954
+ if (!transferred) model[Symbol.dispose]();
29955
+ }
29956
+ }
29957
+ function projectFamiliesToBim(root, options, model) {
29784
29958
  const usesAuthoredCivilHierarchy = hasCivilSpatialIntent(root);
29785
29959
  if (usesAuthoredCivilHierarchy) {
29786
29960
  const keyed = requireKeyed(root);
29787
29961
  if (!keyed.ok) return keyed;
29788
29962
  }
29789
- const model = new BimModel();
29790
29963
  const initResult = model.init(options.project, usesAuthoredCivilHierarchy ? { stableKey: civilSpatialKind(root) !== void 0 ? `${root.keyPath}#project` : root.keyPath } : void 0);
29791
29964
  if (!initResult.ok) return initResult;
29792
29965
  const projectId = initResult.value;
29793
29966
  let buildingId = null;
29794
29967
  if (!usesAuthoredCivilHierarchy) {
29795
29968
  const siteResult = model.addSite({ name: options.siteName ?? "Site" });
29796
- if (!siteResult.ok) {
29797
- model[Symbol.dispose]();
29798
- return siteResult;
29799
- }
29969
+ if (!siteResult.ok) return siteResult;
29800
29970
  const buildingResult = model.addBuilding({ name: options.buildingName ?? "Building" });
29801
- if (!buildingResult.ok) {
29802
- model[Symbol.dispose]();
29803
- return buildingResult;
29804
- }
29971
+ if (!buildingResult.ok) return buildingResult;
29805
29972
  buildingId = buildingResult.value;
29806
29973
  model.aggregate(projectId, siteResult.value);
29807
29974
  model.aggregate(siteResult.value, buildingId);
@@ -29831,8 +29998,7 @@ function familiesToBim(root, options) {
29831
29998
  if (el.geometry.kind !== "Empty") return err(specError("FAMILIES_UNSUPPORTED_CIVIL_SEMANTICS", `familiesToBim: civil spatial element '${el.keyPath}' carries its own geometry — Site/Bridge/Bridge Part export no body, so author it as a child Product (e.g. Earthworks Fill)`));
29832
29999
  const nodeFrame = civilNodeFrame(el, cumulativeFrameHere);
29833
30000
  const rotatedFrame = rotatedHere || hasRotatedAxes(el);
29834
- const input = rotatedFrame ? civilSpatialFrameInput(el, nodeFrame, state.spatialFrame) : civilSpatialInput(el, subtractTranslation(cumulativeTranslationHere, state.projectedSpatialTranslation));
29835
- const added = addCivilSpatialOccurrence(model, el, civilKind, input);
30001
+ const added = addCivilSpatialOccurrence(model, el, civilKind, rotatedFrame ? civilSpatialFrameInput(el, nodeFrame, state.spatialFrame) : civilSpatialInput(el, subtractTranslation(cumulativeTranslationHere, state.projectedSpatialTranslation)));
29836
30002
  if (!added.ok) return added;
29837
30003
  model.aggregate(state.spatialStructureId, added.value);
29838
30004
  idByKeyPath.set(el.keyPath, added.value);
@@ -29868,6 +30034,15 @@ function familiesToBim(root, options) {
29868
30034
  } else if (route !== void 0) {
29869
30035
  const keyed = requireKeyed(el);
29870
30036
  if (!keyed.ok) return keyed;
30037
+ const productBodyCategory = civilProductBodyCategory(el, effectiveArchetype);
30038
+ const productBodyEvaluator = productBodyCategory === null ? void 0 : options.bodyEvaluator ?? options.proxyEvaluator;
30039
+ if (productBodyCategory !== null && productBodyEvaluator === void 0) return err({
30040
+ ...specError("FAMILIES_PRODUCT_BODY_EVALUATOR_REQUIRED", `familiesToBim: civil Product '${el.keyPath}' mapped to ${productBodyCategory} needs bodyEvaluator or proxyEvaluator to verify its authoritative Body`),
30041
+ metadata: {
30042
+ keyPath: el.keyPath,
30043
+ category: productBodyCategory
30044
+ }
30045
+ });
29871
30046
  const voids = el.props["voids"];
29872
30047
  if (Array.isArray(voids)) {
29873
30048
  const openings = el.children.filter((c) => c.type === "Opening").length;
@@ -29887,6 +30062,11 @@ function familiesToBim(root, options) {
29887
30062
  const opened = addOpenings(model, el, added.value, nextSpatialStructureId, idByKeyPath);
29888
30063
  if (!opened.ok) return opened;
29889
30064
  }
30065
+ if (productBodyCategory !== null && productBodyEvaluator !== void 0) {
30066
+ const installed = installCivilProductBody(model, el, added.value, productBodyCategory, productBodyEvaluator, elementBodyFrame(el, cumulativeFrameHere, occurrenceTransformDepthHere));
30067
+ if (!installed.ok) return installed;
30068
+ testHooks?.afterCivilProductBody?.(model, added.value, el);
30069
+ }
29890
30070
  } 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`));
29891
30071
  else if (el.type !== "Group" && el.geometry.kind !== "Empty") {
29892
30072
  if (options.proxyEvaluator === void 0) return err(specError("FAMILIES_UNSUPPORTED_TYPE", `familiesToBim: no supported spec mapping for element type '${el.type}' at '${el.keyPath}' (archetype: ${el.archetype ?? "none"}) — declare a recognized archetype or civil Product category/role, add a spec route, or pass proxyEvaluator to export it as an IfcBuildingElementProxy`));
@@ -29930,10 +30110,7 @@ function familiesToBim(root, options) {
29930
30110
  cumulativeFrame: IDENTITY_FRAME,
29931
30111
  spatialFrame: IDENTITY_FRAME
29932
30112
  });
29933
- if (!walked.ok) {
29934
- model[Symbol.dispose]();
29935
- return walked;
29936
- }
30113
+ if (!walked.ok) return walked;
29937
30114
  return ok({
29938
30115
  model,
29939
30116
  idByKeyPath,
@@ -9,9 +9,16 @@ export interface FamiliesToBimOptions {
9
9
  readonly siteName?: string | undefined;
10
10
  readonly buildingName?: string | undefined;
11
11
  /**
12
- * Materializes exact evaluated Product Bodies for supported typed routes
13
- * such as Earthworks Fill. Supplying this option does not opt unsupported
14
- * products into the proxy fallback.
12
+ * Materializes exact evaluated Product Bodies for supported typed routes.
13
+ * Earthworks Fill always retains that Body. Civil-semantic walls and railings
14
+ * require this evaluator (or `proxyEvaluator`) before projection: absence
15
+ * returns FAMILIES_PRODUCT_BODY_EVALUATOR_REQUIRED with the element path and
16
+ * mapped category, and does not fall back to a parametric envelope.
17
+ * Conventional archetype walls and railings stay specification-authoritative
18
+ * and do not require an evaluator. When present, civil walls and railings
19
+ * compare the evaluated Body with their post-opening parametric Body and
20
+ * retain the authored Body when they differ. Supplying this option does not
21
+ * opt unsupported products into the proxy fallback.
15
22
  */
16
23
  readonly bodyEvaluator?: csg.Evaluator | undefined;
17
24
  /**
@@ -44,6 +51,11 @@ export interface FamiliesBimResult {
44
51
  */
45
52
  readonly proxied: readonly ProxiedElement[];
46
53
  }
54
+ export interface FamiliesAdapterTestHooks {
55
+ readonly afterCivilProductBody?: ((model: BimModel, localId: LocalId, element: ResolvedElement) => void) | undefined;
56
+ }
57
+ /** Package-internal deterministic failure seam for projection ownership tests. */
58
+ export declare function setFamiliesAdapterTestHooksForTesting(hooks: FamiliesAdapterTestHooks | null): void;
47
59
  /**
48
60
  * Project a resolved families tree into an eager BimModel. The caller owns
49
61
  * the returned model (`using`); families stays domain-neutral — this adapter
@@ -0,0 +1,40 @@
1
+ import { Result, Solid, ValidSolid, csg } from 'brepjs';
2
+ import { ResolvedElement } from 'brepjs-families';
3
+ import { BimError } from './errors/bimError.js';
4
+ import { ProductBody } from './types/productBody.js';
5
+ import { Frame } from './placementFrame.js';
6
+ type ExactProductBody = Extract<ProductBody, {
7
+ readonly kind: 'EXACT';
8
+ }>;
9
+ type ParametricProductBody = Extract<ProductBody, {
10
+ readonly kind: 'PARAMETRIC';
11
+ }>;
12
+ export type CivilProductBodySelection = {
13
+ readonly kind: 'PARAMETRIC';
14
+ } | {
15
+ readonly kind: 'EXACT';
16
+ readonly body: ExactProductBody;
17
+ };
18
+ export interface CivilProductBodyInput {
19
+ readonly element: ResolvedElement;
20
+ readonly category: 'WALL' | 'RAILING';
21
+ readonly evaluator: csg.Evaluator;
22
+ readonly productWorldFrame: Frame;
23
+ /** Borrowed model-owned comparison candidate after registered openings. */
24
+ readonly parametricBody: ParametricProductBody;
25
+ }
26
+ export interface FamiliesProductBodyTestHooks {
27
+ readonly afterCopy?: ((itemIndex: number, solid: Solid, source: Solid) => void) | undefined;
28
+ readonly beforeLocalize?: ((itemIndex: number, solid: ValidSolid) => void) | undefined;
29
+ readonly afterLocalized?: ((itemIndex: number, solid: ValidSolid) => void) | undefined;
30
+ readonly beforeCoincidence?: ((exact: ExactProductBody, parametric: ParametricProductBody) => void) | undefined;
31
+ }
32
+ /** Package-internal deterministic failure seams for exact ownership tests. */
33
+ export declare function setFamiliesProductBodyTestHooksForTesting(hooks: FamiliesProductBodyTestHooks | null): void;
34
+ /**
35
+ * Evaluates a civil Product Body, clones and localizes every borrowed source,
36
+ * then proves whether the registered-opening parametric Body is coincident.
37
+ * An EXACT result is caller-owned until takeExactProductBody() succeeds.
38
+ */
39
+ export declare function selectCivilProductBody(input: CivilProductBodyInput): Result<CivilProductBodySelection, BimError>;
40
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brepjs-bim",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "BIM layer for brepjs — IFC4-aligned parametric building elements",
5
5
  "keywords": [
6
6
  "bim",