abstract-3d 2.12.2 → 3.0.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.
Files changed (56) hide show
  1. package/lib/abstract-3d.d.ts +32 -5
  2. package/lib/abstract-3d.d.ts.map +1 -1
  3. package/lib/abstract-3d.js.map +1 -1
  4. package/lib/renderers/ifc/ifc-encoding.d.ts +5 -2
  5. package/lib/renderers/ifc/ifc-encoding.d.ts.map +1 -1
  6. package/lib/renderers/ifc/ifc-encoding.js +30 -7
  7. package/lib/renderers/ifc/ifc-encoding.js.map +1 -1
  8. package/lib/renderers/ifc/ifc.d.ts +35 -22
  9. package/lib/renderers/ifc/ifc.d.ts.map +1 -1
  10. package/lib/renderers/ifc/ifc.js +124 -49
  11. package/lib/renderers/ifc/ifc.js.map +1 -1
  12. package/lib/renderers/react/index.d.ts +0 -1
  13. package/lib/renderers/react/index.d.ts.map +1 -1
  14. package/lib/renderers/react/index.js.map +1 -1
  15. package/lib/renderers/react/react-dimension.d.ts +2 -2
  16. package/lib/renderers/react/react-dimension.d.ts.map +1 -1
  17. package/lib/renderers/react/react-dimension.js +2 -2
  18. package/lib/renderers/react/react-dimension.js.map +1 -1
  19. package/lib/renderers/react/react-group.d.ts +3 -2
  20. package/lib/renderers/react/react-group.d.ts.map +1 -1
  21. package/lib/renderers/react/react-group.js +3 -3
  22. package/lib/renderers/react/react-group.js.map +1 -1
  23. package/lib/renderers/react/react-material.d.ts +2 -2
  24. package/lib/renderers/react/react-material.d.ts.map +1 -1
  25. package/lib/renderers/react/react-material.js +3 -3
  26. package/lib/renderers/react/react-material.js.map +1 -1
  27. package/lib/renderers/react/react-relation.d.ts +10 -0
  28. package/lib/renderers/react/react-relation.d.ts.map +1 -0
  29. package/lib/renderers/react/react-relation.js +31 -0
  30. package/lib/renderers/react/react-relation.js.map +1 -0
  31. package/lib/renderers/react/react-scene.d.ts +4 -7
  32. package/lib/renderers/react/react-scene.d.ts.map +1 -1
  33. package/lib/renderers/react/react-scene.js +4 -4
  34. package/lib/renderers/react/react-scene.js.map +1 -1
  35. package/lib/renderers/react/react.d.ts +8 -7
  36. package/lib/renderers/react/react.d.ts.map +1 -1
  37. package/lib/renderers/react/react.js +2 -2
  38. package/lib/renderers/react/react.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/abstract-3d.ts +33 -6
  41. package/src/renderers/ifc/ifc-encoding.ts +31 -7
  42. package/src/renderers/ifc/ifc.test.ts +233 -102
  43. package/src/renderers/ifc/ifc.ts +263 -80
  44. package/src/renderers/react/index.ts +0 -1
  45. package/src/renderers/react/react-dimension.tsx +3 -3
  46. package/src/renderers/react/react-group.test.ts +2 -2
  47. package/src/renderers/react/react-group.tsx +5 -4
  48. package/src/renderers/react/react-material.tsx +4 -4
  49. package/src/renderers/react/react-relation.tsx +86 -0
  50. package/src/renderers/react/react-scene.tsx +9 -21
  51. package/src/renderers/react/react.tsx +11 -14
  52. package/lib/renderers/react/react-hotspot.d.ts +0 -27
  53. package/lib/renderers/react/react-hotspot.d.ts.map +0 -1
  54. package/lib/renderers/react/react-hotspot.js +0 -37
  55. package/lib/renderers/react/react-hotspot.js.map +0 -1
  56. package/src/renderers/react/react-hotspot.tsx +0 -112
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import type { Scene } from "../../abstract-3d.js";
3
- import { box, culledLine, cylinder, group, line, roundHole, sphere, vec2, vec3, vec3Zero } from "../../abstract-3d.js";
2
+ import type { Group, Scene } from "../../abstract-3d.js";
3
+ import { box, culledLine, cylinder, group, line, plane, roundHole, sphere, vec2, vec3, vec3Zero } from "../../abstract-3d.js";
4
4
  import { guid, ifcFile, real, str } from "./ifc-encoding.js";
5
5
  import { polyline, triangulate } from "./ifc-geometry.js";
6
6
  import { render } from "./ifc.js";
7
7
 
8
8
  const red = { normal: "rgb(255, 0, 0)", opacity: 0.5 };
9
- const scene = (groups: Scene["groups"]): Scene => ({ size_deprecated: vec3Zero, groups });
9
+ const scene = (groups: Scene["groups"], relations?: Scene["relations"]): Scene => ({ size_deprecated: vec3Zero, groups, relations });
10
10
  const count = (ifc: string, entity: string): number => ifc.split(`=${entity}(`).length - 1;
11
11
  const ids = (s: string): ReadonlyArray<string> => Array.from(s.matchAll(/#(\d+)/g), (match) => match[1]);
12
12
  /** Random GlobalIds replaced, to compare files */
@@ -14,6 +14,9 @@ const normalize = (ifc: string): string => ifc.replaceAll(/'[0-9A-Za-z_$]{22}'/g
14
14
 
15
15
  const entities = (ifc: string): ReadonlyMap<string, string> => new Map(Array.from(ifc.matchAll(/^#(\d+)=(.*);$/gm), (match) => [match[1], match[2]]));
16
16
 
17
+ /** The #id of the element or port named `name` */
18
+ const named = (ifc: string, name: string): string => new RegExp(`^#(\\d+)=IFC\\w+\\('[^']{22}',#\\d+,'${name}',`, "m").exec(ifc)![1];
19
+
17
20
  /** The layer of every face set and polyline of the element named `name`, in order */
18
21
  function elementLayers(ifc: string, name: string): ReadonlyArray<string | undefined> {
19
22
  const byId = entities(ifc);
@@ -23,25 +26,34 @@ function elementLayers(ifc: string, name: string): ReadonlyArray<string | undefi
23
26
  )
24
27
  );
25
28
  // The element's owner history, placement and shape, the shape's representations and their context and items
26
- const [, , shape] = ids(Array.from(byId.values()).find((e) => e.includes(`,'${name}',$,$,#`))!);
29
+ const [, , shape] = ids(byId.get(named(ifc, name))!);
27
30
  const items = ids(byId.get(shape)!).flatMap((representation) => ids(byId.get(representation)!).slice(1));
28
31
  return items.map((item) => layers.get(item));
29
32
  }
30
33
 
31
- /** An air handler component with a panel and a fan with a motor, the motor's meshes two groups without data down */
32
- const component = (id: string, key: string): Scene["groups"][number] =>
33
- group(
34
+ /** The location, axis and ref direction of the placement of the element or port named `name`, in IFC's Z-up */
35
+ function placementOf(ifc: string, name: string): ReadonlyArray<string> {
36
+ const byId = entities(ifc);
37
+ const [, placement] = ids(byId.get(named(ifc, name))!);
38
+ const [, axes] = ids(byId.get(placement)!);
39
+ return ids(byId.get(axes)!).map((id) => byId.get(id)!);
40
+ }
41
+
42
+ /** An air handler component with a panel and a fan with a motor, the motor's meshes two groups without id or name down */
43
+ const component = (id: string, layer: string): Group => ({
44
+ ...group(
34
45
  [box(vec3(1, 1, 1), red)],
35
46
  vec3Zero,
36
47
  vec3Zero,
37
48
  [
38
- group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [], { type: "panel" }),
49
+ { ...group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [], { type: "panel" }), layer: "SECTIONS" },
39
50
  group([sphere(1, red)], vec3Zero, vec3Zero, [group([], vec3Zero, vec3Zero, [group([cylinder(vec3Zero, 1, 2, red)])])], { type: "fan_wall" }),
40
51
  ],
41
- { id, key }
42
- );
43
-
44
- const layered = (layer: string, groups: Scene["groups"]): Scene["groups"][number] => group([], vec3Zero, vec3Zero, groups, { layer });
52
+ { key: "FAN" }
53
+ ),
54
+ id,
55
+ layer,
56
+ });
45
57
 
46
58
  describe("ifc encoding", () => {
47
59
  it("writes reals with a decimal point", () => {
@@ -62,6 +74,14 @@ describe("ifc encoding", () => {
62
74
  expect(guid(ifcFile(), uuid)).toBe(first);
63
75
  expect(guid(m, uuid)).not.toBe(first);
64
76
  });
77
+
78
+ it("derives the guid from a hash of any other id", () => {
79
+ const fromId = guid(ifcFile(), "588c9525-0f1b-4b0b-a3e1-ba10f8a922ff_supply");
80
+ expect(fromId).toMatch(/^[0-9A-Za-z_$]{22}$/);
81
+ expect(guid(ifcFile(), "588c9525-0f1b-4b0b-a3e1-ba10f8a922ff_supply")).toBe(fromId);
82
+ expect(guid(ifcFile(), "588c9525-0f1b-4b0b-a3e1-ba10f8a922ff_extract")).not.toBe(fromId);
83
+ expect(guid(ifcFile(), "")).toMatch(/^[0-9A-Za-z_$]{22}$/);
84
+ });
65
85
  });
66
86
 
67
87
  describe("ifc triangulation", () => {
@@ -87,20 +107,23 @@ describe("ifc triangulation", () => {
87
107
  });
88
108
 
89
109
  describe("ifc render", () => {
90
- it("writes an element per top-level group and group with data, merging groups without data into their parent", () => {
110
+ it("writes an element per top-level group and group with an id or name, merging the other groups into their parent", () => {
91
111
  const ifc = render(
92
112
  scene([
93
- group(
94
- [box(vec3(1, 1, 1), red)],
95
- vec3Zero,
96
- vec3Zero,
97
- [
98
- group([sphere(1, red)], vec3Zero, vec3Zero, [], { name: "Fan" }),
99
- group([box(vec3(2, 2, 2), red)], vec3(0, 100, 0)),
100
- group([line(vec3Zero, vec3(1, 0, 0), 1, red)], vec3Zero, vec3Zero, [], { name: "Lines only" }),
101
- ],
102
- { id: "588c9525-0f1b-4b0b-a3e1-ba10f8a922ff" }
103
- ),
113
+ {
114
+ ...group(
115
+ [box(vec3(1, 1, 1), red)],
116
+ vec3Zero,
117
+ vec3Zero,
118
+ [
119
+ { ...group([sphere(1, red)]), name: "Fan" },
120
+ group([box(vec3(2, 2, 2), red)], vec3(0, 100, 0)),
121
+ { ...group([line(vec3Zero, vec3(1, 0, 0), 1, red)]), name: "Lines only" },
122
+ ],
123
+ { id: "not read" }
124
+ ),
125
+ id: "588c9525-0f1b-4b0b-a3e1-ba10f8a922ff",
126
+ },
104
127
  group([box(vec3(1, 1, 1), red)]),
105
128
  group([]),
106
129
  ])
@@ -108,7 +131,7 @@ describe("ifc render", () => {
108
131
  expect(ifc).toMatch(/^ISO-10303-21;/);
109
132
  expect(ifc).toContain("FILE_SCHEMA(('IFC4'));");
110
133
  expect(ifc.trimEnd()).toMatch(/END-ISO-10303-21;$/);
111
- // The empty group is left out, the group without data is merged into its parent
134
+ // The empty group is left out, the group without id or name is merged into its parent
112
135
  expect(count(ifc, "IFCBUILDINGELEMENTPROXY")).toBe(4);
113
136
  expect(count(ifc, "IFCTRIANGULATEDFACESET")).toBe(4);
114
137
  expect(count(ifc, "IFCRELAGGREGATES")).toBe(4);
@@ -120,10 +143,13 @@ describe("ifc render", () => {
120
143
  expect(ifc).toContain("IFCCOLOURRGB($,1.,0.,0.)");
121
144
  expect(ifc).toMatch(/[=]IFCSURFACESTYLERENDERING\(#\d+,0\.5,\$,\$,\$,\$,\$,\$,\.NOTDEFINED\.\);/);
122
145
  expect(ifc).not.toContain("IFCSURFACESTYLESHADING(");
146
+ // The GlobalId comes from the uuid, the name and tag are the id when there's no name
147
+ expect(ifc).toContain(`IFCBUILDINGELEMENTPROXY('${guid(ifcFile(), "588c9525-0f1b-4b0b-a3e1-ba10f8a922ff")}',`);
123
148
  expect(ifc).toContain(",'588c9525-0f1b-4b0b-a3e1-ba10f8a922ff',$,$,#");
124
149
  expect(ifc).toContain(",'588c9525-0f1b-4b0b-a3e1-ba10f8a922ff',.NOTDEFINED.)");
150
+ expect(ifc).not.toContain("not read");
125
151
  expect(ifc).toContain(",'Fan',$,$,#");
126
- // IFC requires a name, top-level groups without data are named after their position
152
+ // IFC requires a name, top-level groups without one are named after their position
127
153
  expect(ifc).toContain(",'Element 2',$,$,#");
128
154
  });
129
155
 
@@ -141,11 +167,11 @@ describe("ifc render", () => {
141
167
  const green = { normal: "rgb(0, 255, 0)" };
142
168
  const ifc = render(
143
169
  scene([
144
- group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [group([line(vec3(1, 2, 3), vec3(4, 5, 6), 2, green)], vec3(0, 100, 0))], {
170
+ {
171
+ ...group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [group([line(vec3(1, 2, 3), vec3(4, 5, 6), 2, green)], vec3(0, 100, 0))]),
145
172
  layer: "A",
146
- }),
147
- ]),
148
- { layer: (path) => path.at(-1)?.layer }
173
+ },
174
+ ])
149
175
  );
150
176
  const byId = entities(ifc);
151
177
  expect(count(ifc, "IFCPOLYLINE")).toBe(1);
@@ -166,9 +192,7 @@ describe("ifc render", () => {
166
192
  const green = { normal: "rgb(0, 255, 0)" };
167
193
  const ifc = render(
168
194
  scene([
169
- group([line(vec3Zero, vec3(1, 0, 0), 1, green), culledLine(vec3Zero, vec3(0, 1, 0), vec3(0, 0, 1), 1, green)], vec3Zero, vec3Zero, [], {
170
- name: "Lines only",
171
- }),
195
+ { ...group([line(vec3Zero, vec3(1, 0, 0), 1, green), culledLine(vec3Zero, vec3(0, 1, 0), vec3(0, 0, 1), 1, green)]), name: "Lines only" },
172
196
  ])
173
197
  );
174
198
  expect(count(ifc, "IFCBUILDINGELEMENTPROXY")).toBe(1);
@@ -180,30 +204,44 @@ describe("ifc render", () => {
180
204
  expect(ifc).toMatch(/[=]IFCSHAPEREPRESENTATION\(#\d+,'Body','Curve3D',\(#\d+,#\d+\)\);/);
181
205
  });
182
206
 
183
- it("wraps the scene in one root element", () => {
207
+ it("writes each element as the class entity gives it, an IfcBuildingElementProxy otherwise", () => {
184
208
  const ifc = render(
185
209
  scene([
186
- group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [], { name: "Filter" }),
187
- group([box(vec3(1, 1, 1), red)], vec3(0, 0, 10), vec3Zero, [], { name: "Fan" }),
210
+ {
211
+ ...group([], vec3Zero, vec3Zero, [
212
+ { ...group([box(vec3(1, 1, 1), red)]), name: "Filter" },
213
+ { ...group([box(vec3(1, 1, 1), red)], vec3(0, 0, 10)), name: "Fan", description: "Supply" },
214
+ { ...group([box(vec3(1, 1, 1), red)], vec3(0, 0, 20)), name: "Inspection" },
215
+ ]),
216
+ name: "AHU",
217
+ },
188
218
  ]),
189
- { root: { entity: "IfcUnitaryEquipment", predefinedType: "AIRHANDLER", name: "AHU" } }
219
+ {
220
+ entity: (g) =>
221
+ g.name === "AHU"
222
+ ? { entity: "IfcUnitaryEquipment", predefinedType: "AIRHANDLER" }
223
+ : g.name === "Fan"
224
+ ? { entity: "IfcFan", predefinedType: "CENTRIFUGALBACKWARDINCLINEDCURVED" }
225
+ : g.name === "Filter"
226
+ ? { entity: "IfcFilter" }
227
+ : undefined,
228
+ }
190
229
  );
191
230
  expect(ifc).toMatch(/[=]IFCUNITARYEQUIPMENT\('[^']{22}',#\d+,'AHU',\$,\$,#\d+,\$,\$,\.AIRHANDLER\.\);/);
192
- expect(count(ifc, "IFCBUILDINGELEMENTPROXY")).toBe(2);
193
- // Only the root is in the storey, the groups are aggregated into it
231
+ expect(ifc).toMatch(/[=]IFCFAN\('[^']{22}',#\d+,'Fan','Supply',\$,#\d+,#\d+,\$,\.CENTRIFUGALBACKWARDINCLINEDCURVED\.\);/);
232
+ expect(ifc).toMatch(/[=]IFCFILTER\('[^']{22}',#\d+,'Filter',\$,\$,#\d+,#\d+,\$,\.NOTDEFINED\.\);/);
233
+ expect(ifc).toMatch(/[=]IFCBUILDINGELEMENTPROXY\('[^']{22}',#\d+,'Inspection',/);
234
+ // Only the AHU is in the storey, the groups are aggregated into it
194
235
  expect(ifc).toMatch(/[=]IFCRELCONTAINEDINSPATIALSTRUCTURE\('[^']{22}',#\d+,\$,\$,\(#\d+\),#\d+\);/);
195
236
  expect(count(ifc, "IFCRELAGGREGATES")).toBe(4);
196
237
  });
197
238
 
198
239
  it("writes the owner history on every rooted entity and the author and application in the header", () => {
199
- const ifc = render(
200
- scene([group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [group([sphere(1, red)], vec3Zero, vec3Zero, [], { name: "Fan" })])]),
201
- {
202
- author: "Ivan",
203
- organization: "Acme",
204
- application: { name: "airCalc ACOM", version: "10.1" },
205
- }
206
- );
240
+ const ifc = render(scene([group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [{ ...group([sphere(1, red)]), name: "Fan" }])]), {
241
+ author: "Ivan",
242
+ organization: "Acme",
243
+ application: { name: "airCalc ACOM", version: "10.1" },
244
+ });
207
245
  expect(ifc).toContain("/* name */ 'Abstract 3D.ifc',");
208
246
  expect(ifc).toContain("/* author */ ('Ivan'),\n/* organization */ ('Acme'),");
209
247
  expect(ifc).toContain("/* originating_system */ 'airCalc ACOM',");
@@ -231,12 +269,12 @@ describe("ifc render", () => {
231
269
  expect(count(ifc, "IFCORGANIZATION")).toBe(1);
232
270
  });
233
271
 
234
- it("makes elements only of groups with data.id or data.name, merging groups with other data into their parent", () => {
272
+ it("makes elements only of groups with an id or name, merging groups with only data into their parent", () => {
235
273
  const ifc = render(
236
274
  scene([
237
275
  group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [
238
- group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [group([sphere(1, red)], vec3Zero, vec3Zero, [], { id: "fan" })], { type: "panel" }),
239
- group([sphere(1, red)], vec3Zero, vec3Zero, [], { name: "Motor", type: "motor" }),
276
+ group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [{ ...group([sphere(1, red)]), id: "fan" }], { type: "panel", id: "data" }),
277
+ { ...group([sphere(1, red)], vec3Zero, vec3Zero, [], { type: "motor" }), name: "Motor" },
240
278
  ]),
241
279
  ])
242
280
  );
@@ -247,74 +285,167 @@ describe("ifc render", () => {
247
285
  expect(ifc).toMatch(/[=]IFCRELAGGREGATES\('[^']{22}',#\d+,\$,\$,#\d+,\(#\d+,#\d+\)\);/);
248
286
  });
249
287
 
250
- it("puts the meshes on the layer of the nearest group above them with one, from the data path of the root and the groups with data", () => {
251
- const paths: Array<ReadonlyArray<Record<string, string>>> = [];
252
- const ifc = render(scene([component("a1", "FAN"), component("a2", "FAN"), component("a3", "DUCTCONNECTORS")]), {
253
- root: { entity: "IfcUnitaryEquipment", predefinedType: "AIRHANDLER", name: "AHU" },
254
- layer: (path) => {
255
- paths.push(path);
256
- const key = path.filter((d) => "key" in d).at(-1)?.key;
257
- return path.at(-1)?.type === "panel" ? "acomSECTIONS" : key ? `acom${key}` : undefined;
258
- },
259
- });
260
- // Box, panel, fan and the motor two groups without data further down
261
- expect(elementLayers(ifc, "a1")).toEqual(["acomFAN", "acomSECTIONS", "acomFAN", "acomFAN"]);
262
- expect(elementLayers(ifc, "a3")).toEqual(["acomDUCTCONNECTORS", "acomSECTIONS", "acomDUCTCONNECTORS", "acomDUCTCONNECTORS"]);
288
+ it("puts the meshes on the layer of the nearest group above them with one, through elements and groups without one", () => {
289
+ const ifc = render(scene([component("a1", "FAN"), component("a2", "FAN"), component("a3", "DUCTCONNECTORS")]));
290
+ // Box, panel, fan and the motor two groups without a layer further down
291
+ expect(elementLayers(ifc, "a1")).toEqual(["FAN", "SECTIONS", "FAN", "FAN"]);
292
+ expect(elementLayers(ifc, "a3")).toEqual(["DUCTCONNECTORS", "SECTIONS", "DUCTCONNECTORS", "DUCTCONNECTORS"]);
263
293
  // One layer per name across the elements
264
294
  expect(count(ifc, "IFCPRESENTATIONLAYERASSIGNMENT")).toBe(3);
265
- expect(ifc).toMatch(/[=]IFCPRESENTATIONLAYERASSIGNMENT\('acomSECTIONS',\$,\(#\d+,#\d+,#\d+\),\$\);/);
266
- expect(ifc).toMatch(/[=]IFCPRESENTATIONLAYERASSIGNMENT\('acomFAN',\$,\((#\d+,){5}#\d+\),\$\);/);
267
- expect(paths).toContainEqual([{ name: "AHU" }, { id: "a1", key: "FAN" }, { type: "panel" }]);
268
- // The root, 3 components and their panel and fan, groups without data are skipped
269
- expect(paths).toHaveLength(10);
270
- });
295
+ expect(ifc).toMatch(/[=]IFCPRESENTATIONLAYERASSIGNMENT\('SECTIONS',\$,\(#\d+,#\d+,#\d+\),\$\);/);
296
+ expect(ifc).toMatch(/[=]IFCPRESENTATIONLAYERASSIGNMENT\('FAN',\$,\((#\d+,){5}#\d+\),\$\);/);
271
297
 
272
- it("inherits the layer through groups without data and groups the layer returns undefined or an empty name for", () => {
273
- const ifc = render(
298
+ const nested = render(
274
299
  scene([
275
- group(
276
- [box(vec3(1, 1, 1), red)],
277
- vec3Zero,
278
- vec3Zero,
279
- [
280
- group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [], { type: "panel" }),
281
- layered("", [group([box(vec3(1, 1, 1), red)])]),
282
- layered("B", [group([], vec3Zero, vec3Zero, [group([box(vec3(1, 1, 1), red)])])]),
283
- layered("B", [group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [], { name: "Inner", type: "fan" })]),
284
- ],
285
- { layer: "A" }
286
- ),
300
+ {
301
+ ...group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [
302
+ { ...group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [], {}), name: "Inner" },
303
+ ]),
304
+ layer: "A",
305
+ },
287
306
  group([box(vec3(1, 1, 1), red)]),
288
- ]),
289
- { layer: (path) => path.at(-1)?.layer }
307
+ ])
290
308
  );
291
- expect(elementLayers(ifc, "Element 1")).toEqual(["A", "A", "A", "B"]);
292
- expect(elementLayers(ifc, "Inner")).toEqual(["B"]);
293
- // Meshes without a layer above them get none
294
- expect(elementLayers(ifc, "Element 2")).toEqual([undefined]);
295
- expect(count(ifc, "IFCPRESENTATIONLAYERASSIGNMENT")).toBe(2);
309
+ // Elements inherit the layer too, meshes without a layer above them get none
310
+ expect(elementLayers(nested, "Inner")).toEqual(["A"]);
311
+ expect(elementLayers(nested, "Element 2")).toEqual([undefined]);
312
+ expect(count(nested, "IFCPRESENTATIONLAYERASSIGNMENT")).toBe(1);
296
313
  });
297
314
 
298
315
  it("only adds the layers to the output", () => {
299
316
  vi.useFakeTimers({ now: new Date("2026-10-12T10:00:00Z") });
300
- const s = scene([
301
- group([box(vec3(1, 1, 1), red)], vec3(1, 2, 3), vec3(0.1, 0, 0), [
302
- group([sphere(1, red)], vec3Zero, vec3Zero, [group([cylinder(vec3Zero, 1, 2, red)])], { name: "Fan" }),
303
- ]),
304
- ]);
305
- const plain = normalize(render(s));
306
- const undefinedLayers = normalize(render(s, { layer: () => undefined }));
307
- const withLayers = normalize(render(s, { layer: () => "A" }));
317
+ const fan = { ...group([sphere(1, red)], vec3Zero, vec3Zero, [group([cylinder(vec3Zero, 1, 2, red)])]), name: "Fan" };
318
+ const s = (layer?: string): Scene => scene([group([box(vec3(1, 1, 1), red)], vec3(1, 2, 3), vec3(0.1, 0, 0), [{ ...fan, layer }])]);
319
+ const plain = normalize(render(s()));
320
+ const withLayer = normalize(render(s("A")));
308
321
  vi.useRealTimers();
309
322
  expect(count(plain, "IFCPRESENTATIONLAYERASSIGNMENT")).toBe(0);
310
- expect(undefinedLayers).toBe(plain);
311
- // Only the fan's meshes are in a group with data
312
- expect(withLayers.replace(/^#\d+=IFCPRESENTATIONLAYERASSIGNMENT\('A',\$,\(#\d+,#\d+\),\$\);\n/m, "")).toBe(plain);
323
+ expect(withLayer.replace(/^#\d+=IFCPRESENTATIONLAYERASSIGNMENT\('A',\$,\(#\d+,#\d+\),\$\);\n/m, "")).toBe(plain);
324
+ });
325
+
326
+ it("writes a relation between two elements as a source port and a sink port nested in them and connected", () => {
327
+ const ifc = render(
328
+ scene(
329
+ [
330
+ { ...group([box(vec3(1, 1, 1), red)]), id: "filter" },
331
+ { ...group([box(vec3(1, 1, 1), red)], vec3(100, 0, 0)), id: "fan" },
332
+ ],
333
+ [{ id: "r1", from: "filter", to: "fan", pos: vec3(50, 0, 0), name: "Filter to fan" }]
334
+ ),
335
+ { port: () => ({ predefinedType: "DUCT", systemType: "VENTILATION" }) }
336
+ );
337
+ const ports = Array.from(
338
+ ifc.matchAll(/^#(\d+)=IFCDISTRIBUTIONPORT\('[^']{22}',#\d+,'Filter to fan',\$,\$,#\d+,\$,\.(\w+)\.,\.DUCT\.,\.VENTILATION\.\);$/gm)
339
+ );
340
+ expect(ports.map(([, , flow]) => flow)).toEqual(["SOURCE", "SINK"]);
341
+ const [source, sink] = ports.map(([, id]) => id);
342
+ expect(ifc).toMatch(new RegExp(`=IFCRELCONNECTSPORTS\\('[^']{22}',#\\d+,\\$,\\$,#${source},#${sink},\\$\\);`));
343
+ expect(ifc).toMatch(new RegExp(`=IFCRELNESTS\\('[^']{22}',#\\d+,\\$,\\$,#${named(ifc, "filter")},\\(#${source}\\)\\);`));
344
+ expect(ifc).toMatch(new RegExp(`=IFCRELNESTS\\('[^']{22}',#\\d+,\\$,\\$,#${named(ifc, "fan")},\\(#${sink}\\)\\);`));
345
+ // Each port is placed relative to its element, the fan's at -50 along x
346
+ const byId = entities(ifc);
347
+ const [, sinkPlacement] = ids(byId.get(sink)!);
348
+ expect(ids(byId.get(sinkPlacement)!)[0]).toBe(ids(byId.get(named(ifc, "fan"))!)[1]);
349
+ expect(ifc).toContain("IFCCARTESIANPOINT((-50.,0.,0.))");
350
+ // Ports are only nested, not contained in the storey
351
+ expect(ifc).toMatch(/[=]IFCRELCONTAINEDINSPATIALSTRUCTURE\('[^']{22}',#\d+,\$,\$,\(#\d+,#\d+\),#\d+\);/);
352
+ });
353
+
354
+ it("writes sibling groups with the same id as one element, its parts in their places, with ports from relations to either part", () => {
355
+ const ifc = render(
356
+ scene(
357
+ [
358
+ { ...group([box(vec3(1, 1, 1), red)], vec3(0, 0, 0)), id: "exchanger", name: "PTD", description: "Plate", layer: "SUPPLY" },
359
+ { ...group([box(vec3(1, 1, 1), red)], vec3(0, 100, 0)), id: "exchanger", name: "PTD", description: "Plate", layer: "EXHAUST" },
360
+ { ...group([box(vec3(1, 1, 1), red)], vec3(100, 100, 0)), id: "filter" },
361
+ ],
362
+ [{ id: "r1", from: "filter", to: "exchanger", pos: vec3(50, 100, 0) }]
363
+ )
364
+ );
365
+
366
+ expect(count(ifc, "IFCBUILDINGELEMENTPROXY")).toBe(2);
367
+ expect(ifc).toMatch(/[=]IFCBUILDINGELEMENTPROXY\('[^']{22}',#\d+,'PTD','Plate',/);
368
+ expect(elementLayers(ifc, "PTD")).toEqual(["SUPPLY", "EXHAUST"]);
369
+ expect(placementOf(ifc, "PTD")[0]).toBe("IFCCARTESIANPOINT((0.,0.,0.))");
370
+ expect(count(ifc, "IFCRELCONNECTSPORTS")).toBe(1);
371
+ });
372
+
373
+ it("writes a relation with one element as an open port with the relation's groups as its shape, and leaves out relations without elements", () => {
374
+ const terminal = { ...group([plane(vec3Zero, vec2(10, 10), red)], vec3(50, 0, 0)), id: "terminal", layer: "DUCTCONNECTORS" };
375
+ const ifc = render(
376
+ scene(
377
+ [{ ...group([box(vec3(1, 1, 1), red)]), id: "fan" }],
378
+ [
379
+ { id: "sup", from: "fan", to: "terminal", pos: vec3(50, 0, 0), name: "SUP", groups: [terminal] },
380
+ { id: "oda", to: "fan", pos: vec3(-50, 0, 0), name: "ODA" },
381
+ { id: "unknown", from: "nothing", to: "missing", pos: vec3Zero, name: "Unknown" },
382
+ ]
383
+ )
384
+ );
385
+ expect(count(ifc, "IFCDISTRIBUTIONPORT")).toBe(2);
386
+ expect(ifc).toMatch(/[=]IFCDISTRIBUTIONPORT\('[^']{22}',#\d+,'SUP',\$,\$,#\d+,#\d+,\.SOURCE\.,\.NOTDEFINED\.,\$\);/);
387
+ expect(ifc).toMatch(/[=]IFCDISTRIBUTIONPORT\('[^']{22}',#\d+,'ODA',\$,\$,#\d+,\$,\.SINK\.,\.NOTDEFINED\.,\$\);/);
388
+ expect(count(ifc, "IFCRELCONNECTSPORTS")).toBe(0);
389
+ // Both ports in one nesting
390
+ expect(count(ifc, "IFCRELNESTS")).toBe(1);
391
+ // The shape is relative to the port, the terminal's group isn't an element
392
+ expect(elementLayers(ifc, "SUP")).toEqual(["DUCTCONNECTORS"]);
393
+ expect(ifc).toContain("(5.,0.,5.)");
394
+ expect(ifc).not.toContain("'terminal'");
395
+ expect(ifc).not.toContain("'Unknown'");
396
+ });
397
+
398
+ it("places a port in its element's rotated axes, and the relation's shape on the source's port only", () => {
399
+ const ifc = render(
400
+ scene(
401
+ [
402
+ { ...group([box(vec3(1, 1, 1), red)], vec3Zero, vec3(0, Math.PI / 2, 0)), id: "a" },
403
+ { ...group([box(vec3(1, 1, 1), red)], vec3(0, 0, -20)), id: "b" },
404
+ ],
405
+ [
406
+ {
407
+ id: "r",
408
+ from: "a",
409
+ to: "b",
410
+ name: "A to B",
411
+ pos: vec3(0, 0, -10),
412
+ rot: vec3(0, Math.PI / 2, 0),
413
+ groups: [group([plane(vec3Zero, vec2(1, 1), red)], vec3(0, 0, -10), vec3(0, Math.PI / 2, 0))],
414
+ },
415
+ ]
416
+ )
417
+ );
418
+ const [source, sink] = Array.from(ifc.matchAll(/^#(\d+)=IFCDISTRIBUTIONPORT\(.*'A to B',\$,\$,#\d+,([#$\d]+),/gm), ([, , shape]) => shape);
419
+ expect(source).toMatch(/^#\d+$/);
420
+ expect(sink).toBe("$");
421
+ // The element is turned 90° around Y-up so its x points along -z, where the relation is, and the relation turned the same way
422
+ const byId = entities(ifc);
423
+ const [sourcePort] = Array.from(ifc.matchAll(/^#(\d+)=IFCDISTRIBUTIONPORT\(/gm), (match) => match[1]);
424
+ const [, placement] = ids(byId.get(sourcePort)!);
425
+ const [, axes] = ids(byId.get(placement)!);
426
+ expect(ids(byId.get(axes)!).map((id) => byId.get(id))).toEqual([
427
+ "IFCCARTESIANPOINT((10.,0.,0.))",
428
+ "IFCDIRECTION((0.,0.,1.))",
429
+ "IFCDIRECTION((1.,0.,0.))",
430
+ ]);
431
+ expect(placementOf(ifc, "b")[0]).toBe("IFCCARTESIANPOINT((0.,20.,0.))");
313
432
  });
314
433
 
315
434
  it("only references entities that exist", () => {
316
435
  const ifc = render(
317
- scene([group([box(vec3(1, 1, 1), red), line(vec3Zero, vec3(1, 1, 1), 1, red)], vec3(1, 2, 3), vec3(0.1, 0.2, 0.3), [group([sphere(1, red)])])])
436
+ scene(
437
+ [
438
+ {
439
+ ...group([box(vec3(1, 1, 1), red), line(vec3Zero, vec3(1, 1, 1), 1, red)], vec3(1, 2, 3), vec3(0.1, 0.2, 0.3), [group([sphere(1, red)])]),
440
+ id: "a",
441
+ },
442
+ { ...group([box(vec3(1, 1, 1), red)]), id: "b" },
443
+ ],
444
+ [
445
+ { id: "r", from: "a", to: "b", pos: vec3Zero, groups: [group([plane(vec3Zero, vec2(1, 1), red)], vec3(1, 2, 3), vec3(0.1, 0.2, 0.3))] },
446
+ { id: "o", from: "b", pos: vec3Zero },
447
+ ]
448
+ )
318
449
  );
319
450
  const ids = new Set(Array.from(ifc.matchAll(/^#(\d+)=/gm), (match) => match[1]));
320
451
  const referenced = Array.from(ifc.matchAll(/[(,]#(\d+)/g), (match) => match[1]);