brepjs-bim 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,13 @@ and serializes the result to a valid **IFC-SPF** file — with a matching import
14
14
  Pipeline: **author spec → `BimModel` (typed element + brepjs geometry) → spatial structure +
15
15
  property sets + classification → export IFC / COBie, validate, round-trip.**
16
16
 
17
+ The declarative entry point is
18
+ [`brepjs-families`](https://www.npmjs.com/package/brepjs-families): author the building as a tree of
19
+ prop-driven components, then `familiesToBim(tree, { project })` projects it onto a `BimModel` with
20
+ stable GlobalIds derived from component key paths. Hand-authoring specs against `BimModel` remains
21
+ the low-level path (and covers elements the declarative route doesn't yet). See the
22
+ [Declarative Models guide](https://brepjs.dev/families/overview).
23
+
17
24
  ## Scope
18
25
 
19
26
  Parametric authoring of the common IFC4 building elements plus the data layers that make a model
@@ -27624,7 +27624,8 @@ function assignNum(target, key, value) {
27624
27624
  * fill-role void (Door/Window family) maps onto addDoor/addWindow, which cut
27625
27625
  * the wall and wire IfcRelVoidsElement + IfcRelFillsElement; the opening and
27626
27626
  * filler GlobalIds derive from the synthesized key paths. Anonymous (non-fill)
27627
- * voids stay IR-only and never reach the spec path.
27627
+ * voids are rejected: they cut only the IR/viewport geometry, and exporting
27628
+ * the uncut spec body would silently diverge from what the user sees.
27628
27629
  */
27629
27630
  var SPEC_DEFAULTS = {
27630
27631
  origin: [
@@ -27734,6 +27735,16 @@ function peelTranslates(node) {
27734
27735
  moved
27735
27736
  };
27736
27737
  }
27738
+ /** True when the element's transform PROP carries a rotation. The spec path
27739
+ * folds only translations into IfcLocalPlacement (walls orient via `axisX`),
27740
+ * so a tRotate placement would export un-rotated while the viewport shows it
27741
+ * rotated — reject instead of diverging. Rotations a family render bakes
27742
+ * into its own body geometry (e.g. a circular beam oriented along axisX) are
27743
+ * fine: the spec rebuilds that body parametrically from props. */
27744
+ function hasRotateOp(el) {
27745
+ const ops = el.props["transform"];
27746
+ return Array.isArray(ops) && ops.some((op) => typeof op === "object" && op !== null && op.op === "rotate");
27747
+ }
27737
27748
  /** Fold the resolved geometry's outer translate chain into the spec placement
27738
27749
  * origin, so IfcLocalPlacement matches the IR world frame no matter where the
27739
27750
  * transform came from (family-internal or prop-level). */
@@ -27765,18 +27776,43 @@ function specInput(el) {
27765
27776
  ...collectSpecProps(el)
27766
27777
  };
27767
27778
  }
27779
+ /** `Pset_<Type>Common` fields the specs model first-class: mapped onto their
27780
+ * spec names so the writer emits them in the element's own common pset. */
27781
+ var COMMON_PSET_FIELDS = {
27782
+ IsExternal: "isExternal",
27783
+ FireRating: "fireRating",
27784
+ AcousticRating: "acousticRating",
27785
+ ThermalTransmittance: "thermalTransmittance",
27786
+ LoadBearing: "loadBearing",
27787
+ Status: "status"
27788
+ };
27768
27789
  function collectSpecProps(el) {
27769
- const psets = el.attributes["psets"];
27770
27790
  const out = {};
27791
+ const material = el.attributes["material"];
27792
+ if (typeof material === "string" && el.props["materialName"] === void 0) out["materialName"] = material;
27793
+ const psets = el.attributes["psets"];
27771
27794
  if (psets && typeof psets === "object") {
27772
- const common = psets["Pset_WallCommon"];
27773
- if (common && typeof common === "object") {
27774
- const c = common;
27775
- if (typeof c["IsExternal"] === "boolean") out["isExternal"] = c["IsExternal"];
27776
- if (typeof c["FireRating"] === "string") out["fireRating"] = c["FireRating"];
27795
+ const custom = {};
27796
+ const ownCommonPset = `Pset_${el.type}Common`;
27797
+ for (const [psetName, fields] of Object.entries(psets)) {
27798
+ if (!fields || typeof fields !== "object") continue;
27799
+ const record = fields;
27800
+ if (psetName === ownCommonPset) {
27801
+ for (const [field, specKey] of Object.entries(COMMON_PSET_FIELDS)) if (record[field] !== void 0) out[specKey] = record[field];
27802
+ } else {
27803
+ const values = {};
27804
+ for (const [field, value] of Object.entries(record)) if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") values[field] = value;
27805
+ if (Object.keys(values).length > 0) custom[psetName] = values;
27806
+ }
27807
+ }
27808
+ if (Object.keys(custom).length > 0) {
27809
+ const declared = el.props["customProperties"];
27810
+ out["customProperties"] = declared && typeof declared === "object" ? {
27811
+ ...custom,
27812
+ ...declared
27813
+ } : custom;
27777
27814
  }
27778
27815
  }
27779
- delete out["psets"];
27780
27816
  return out;
27781
27817
  }
27782
27818
  function addFill(model, fill, input, identity) {
@@ -27817,6 +27853,7 @@ function addOpenings(model, host, wallId, containerId, idByKeyPath) {
27817
27853
  const added = addFill(model, fill, {
27818
27854
  materialName: SPEC_DEFAULTS.materialName,
27819
27855
  ...stripGeometryProps(fill.props),
27856
+ ...collectSpecProps(fill),
27820
27857
  wallLocalId: wallId,
27821
27858
  offsetAlongWall: delta[0] * axisX[0] + delta[1] * axisX[1] + delta[2] * axisX[2],
27822
27859
  offsetFromFloor: delta[2]
@@ -27863,7 +27900,8 @@ function familiesToBim(root, options) {
27863
27900
  if (project !== null) model.aggregate(project.localId, siteResult.value);
27864
27901
  model.aggregate(siteResult.value, buildingId);
27865
27902
  const idByKeyPath = /* @__PURE__ */ new Map();
27866
- const walk = (el, storeyId) => {
27903
+ const walk = (el, storeyId, rotated) => {
27904
+ const rotatedHere = rotated || hasRotateOp(el);
27867
27905
  let containerId = storeyId;
27868
27906
  const route = specRoute(el.type);
27869
27907
  if (el.type === "Storey") {
@@ -27880,6 +27918,12 @@ function familiesToBim(root, options) {
27880
27918
  } else if (route !== void 0) {
27881
27919
  const keyed = requireKeyed(el);
27882
27920
  if (!keyed.ok) return keyed;
27921
+ if (rotatedHere) return (0, brepjs.err)(specError("FAMILIES_UNSUPPORTED_TRANSFORM", `familiesToBim: '${el.keyPath}' carries a rotated placement — the spec path folds only translations into IfcLocalPlacement; orient walls via axisX instead of tRotate`));
27922
+ const voids = el.props["voids"];
27923
+ if (Array.isArray(voids)) {
27924
+ const openings = el.children.filter((c) => c.type === "Opening").length;
27925
+ if (voids.length > openings) return (0, brepjs.err)(specError("FAMILIES_ANONYMOUS_VOID", `familiesToBim: '${el.keyPath}' has ${voids.length - openings} anonymous void(s) the IFC body cannot carry — use a fill-role family (Door/Window) for each void`));
27926
+ }
27883
27927
  const parsed = route.parse(("input" in route ? route.input : specInput)(el));
27884
27928
  if (!parsed.ok) return parsed;
27885
27929
  const added = route.add(model, parsed.value, el.keyPath);
@@ -27895,12 +27939,12 @@ function familiesToBim(root, options) {
27895
27939
  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}'`));
27896
27940
  for (const child of el.children) {
27897
27941
  if (el.type === "Wall" && child.type === "Opening") continue;
27898
- const r = walk(child, containerId);
27942
+ const r = walk(child, containerId, rotatedHere);
27899
27943
  if (!r.ok) return r;
27900
27944
  }
27901
27945
  return (0, brepjs.ok)(void 0);
27902
27946
  };
27903
- const walked = walk(root, null);
27947
+ const walked = walk(root, null, false);
27904
27948
  if (!walked.ok) {
27905
27949
  model[Symbol.dispose]();
27906
27950
  return walked;
@@ -27601,7 +27601,8 @@ function assignNum(target, key, value) {
27601
27601
  * fill-role void (Door/Window family) maps onto addDoor/addWindow, which cut
27602
27602
  * the wall and wire IfcRelVoidsElement + IfcRelFillsElement; the opening and
27603
27603
  * filler GlobalIds derive from the synthesized key paths. Anonymous (non-fill)
27604
- * voids stay IR-only and never reach the spec path.
27604
+ * voids are rejected: they cut only the IR/viewport geometry, and exporting
27605
+ * the uncut spec body would silently diverge from what the user sees.
27605
27606
  */
27606
27607
  var SPEC_DEFAULTS = {
27607
27608
  origin: [
@@ -27711,6 +27712,16 @@ function peelTranslates(node) {
27711
27712
  moved
27712
27713
  };
27713
27714
  }
27715
+ /** True when the element's transform PROP carries a rotation. The spec path
27716
+ * folds only translations into IfcLocalPlacement (walls orient via `axisX`),
27717
+ * so a tRotate placement would export un-rotated while the viewport shows it
27718
+ * rotated — reject instead of diverging. Rotations a family render bakes
27719
+ * into its own body geometry (e.g. a circular beam oriented along axisX) are
27720
+ * fine: the spec rebuilds that body parametrically from props. */
27721
+ function hasRotateOp(el) {
27722
+ const ops = el.props["transform"];
27723
+ return Array.isArray(ops) && ops.some((op) => typeof op === "object" && op !== null && op.op === "rotate");
27724
+ }
27714
27725
  /** Fold the resolved geometry's outer translate chain into the spec placement
27715
27726
  * origin, so IfcLocalPlacement matches the IR world frame no matter where the
27716
27727
  * transform came from (family-internal or prop-level). */
@@ -27742,18 +27753,43 @@ function specInput(el) {
27742
27753
  ...collectSpecProps(el)
27743
27754
  };
27744
27755
  }
27756
+ /** `Pset_<Type>Common` fields the specs model first-class: mapped onto their
27757
+ * spec names so the writer emits them in the element's own common pset. */
27758
+ var COMMON_PSET_FIELDS = {
27759
+ IsExternal: "isExternal",
27760
+ FireRating: "fireRating",
27761
+ AcousticRating: "acousticRating",
27762
+ ThermalTransmittance: "thermalTransmittance",
27763
+ LoadBearing: "loadBearing",
27764
+ Status: "status"
27765
+ };
27745
27766
  function collectSpecProps(el) {
27746
- const psets = el.attributes["psets"];
27747
27767
  const out = {};
27768
+ const material = el.attributes["material"];
27769
+ if (typeof material === "string" && el.props["materialName"] === void 0) out["materialName"] = material;
27770
+ const psets = el.attributes["psets"];
27748
27771
  if (psets && typeof psets === "object") {
27749
- const common = psets["Pset_WallCommon"];
27750
- if (common && typeof common === "object") {
27751
- const c = common;
27752
- if (typeof c["IsExternal"] === "boolean") out["isExternal"] = c["IsExternal"];
27753
- if (typeof c["FireRating"] === "string") out["fireRating"] = c["FireRating"];
27772
+ const custom = {};
27773
+ const ownCommonPset = `Pset_${el.type}Common`;
27774
+ for (const [psetName, fields] of Object.entries(psets)) {
27775
+ if (!fields || typeof fields !== "object") continue;
27776
+ const record = fields;
27777
+ if (psetName === ownCommonPset) {
27778
+ for (const [field, specKey] of Object.entries(COMMON_PSET_FIELDS)) if (record[field] !== void 0) out[specKey] = record[field];
27779
+ } else {
27780
+ const values = {};
27781
+ for (const [field, value] of Object.entries(record)) if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") values[field] = value;
27782
+ if (Object.keys(values).length > 0) custom[psetName] = values;
27783
+ }
27784
+ }
27785
+ if (Object.keys(custom).length > 0) {
27786
+ const declared = el.props["customProperties"];
27787
+ out["customProperties"] = declared && typeof declared === "object" ? {
27788
+ ...custom,
27789
+ ...declared
27790
+ } : custom;
27754
27791
  }
27755
27792
  }
27756
- delete out["psets"];
27757
27793
  return out;
27758
27794
  }
27759
27795
  function addFill(model, fill, input, identity) {
@@ -27794,6 +27830,7 @@ function addOpenings(model, host, wallId, containerId, idByKeyPath) {
27794
27830
  const added = addFill(model, fill, {
27795
27831
  materialName: SPEC_DEFAULTS.materialName,
27796
27832
  ...stripGeometryProps(fill.props),
27833
+ ...collectSpecProps(fill),
27797
27834
  wallLocalId: wallId,
27798
27835
  offsetAlongWall: delta[0] * axisX[0] + delta[1] * axisX[1] + delta[2] * axisX[2],
27799
27836
  offsetFromFloor: delta[2]
@@ -27840,7 +27877,8 @@ function familiesToBim(root, options) {
27840
27877
  if (project !== null) model.aggregate(project.localId, siteResult.value);
27841
27878
  model.aggregate(siteResult.value, buildingId);
27842
27879
  const idByKeyPath = /* @__PURE__ */ new Map();
27843
- const walk = (el, storeyId) => {
27880
+ const walk = (el, storeyId, rotated) => {
27881
+ const rotatedHere = rotated || hasRotateOp(el);
27844
27882
  let containerId = storeyId;
27845
27883
  const route = specRoute(el.type);
27846
27884
  if (el.type === "Storey") {
@@ -27857,6 +27895,12 @@ function familiesToBim(root, options) {
27857
27895
  } else if (route !== void 0) {
27858
27896
  const keyed = requireKeyed(el);
27859
27897
  if (!keyed.ok) return keyed;
27898
+ if (rotatedHere) return err(specError("FAMILIES_UNSUPPORTED_TRANSFORM", `familiesToBim: '${el.keyPath}' carries a rotated placement — the spec path folds only translations into IfcLocalPlacement; orient walls via axisX instead of tRotate`));
27899
+ const voids = el.props["voids"];
27900
+ if (Array.isArray(voids)) {
27901
+ const openings = el.children.filter((c) => c.type === "Opening").length;
27902
+ if (voids.length > openings) return err(specError("FAMILIES_ANONYMOUS_VOID", `familiesToBim: '${el.keyPath}' has ${voids.length - openings} anonymous void(s) the IFC body cannot carry — use a fill-role family (Door/Window) for each void`));
27903
+ }
27860
27904
  const parsed = route.parse(("input" in route ? route.input : specInput)(el));
27861
27905
  if (!parsed.ok) return parsed;
27862
27906
  const added = route.add(model, parsed.value, el.keyPath);
@@ -27872,12 +27916,12 @@ function familiesToBim(root, options) {
27872
27916
  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}'`));
27873
27917
  for (const child of el.children) {
27874
27918
  if (el.type === "Wall" && child.type === "Opening") continue;
27875
- const r = walk(child, containerId);
27919
+ const r = walk(child, containerId, rotatedHere);
27876
27920
  if (!r.ok) return r;
27877
27921
  }
27878
27922
  return ok(void 0);
27879
27923
  };
27880
- const walked = walk(root, null);
27924
+ const walked = walk(root, null, false);
27881
27925
  if (!walked.ok) {
27882
27926
  model[Symbol.dispose]();
27883
27927
  return walked;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brepjs-bim",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "BIM layer for brepjs — IFC4-aligned parametric building elements",
5
5
  "keywords": [
6
6
  "bim",