abstract-3d 2.12.1 → 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 (61) 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-geometry.d.ts +4 -0
  9. package/lib/renderers/ifc/ifc-geometry.d.ts.map +1 -1
  10. package/lib/renderers/ifc/ifc-geometry.js +8 -0
  11. package/lib/renderers/ifc/ifc-geometry.js.map +1 -1
  12. package/lib/renderers/ifc/ifc.d.ts +36 -23
  13. package/lib/renderers/ifc/ifc.d.ts.map +1 -1
  14. package/lib/renderers/ifc/ifc.js +164 -67
  15. package/lib/renderers/ifc/ifc.js.map +1 -1
  16. package/lib/renderers/react/index.d.ts +0 -1
  17. package/lib/renderers/react/index.d.ts.map +1 -1
  18. package/lib/renderers/react/index.js.map +1 -1
  19. package/lib/renderers/react/react-dimension.d.ts +2 -2
  20. package/lib/renderers/react/react-dimension.d.ts.map +1 -1
  21. package/lib/renderers/react/react-dimension.js +2 -2
  22. package/lib/renderers/react/react-dimension.js.map +1 -1
  23. package/lib/renderers/react/react-group.d.ts +3 -2
  24. package/lib/renderers/react/react-group.d.ts.map +1 -1
  25. package/lib/renderers/react/react-group.js +3 -3
  26. package/lib/renderers/react/react-group.js.map +1 -1
  27. package/lib/renderers/react/react-material.d.ts +2 -2
  28. package/lib/renderers/react/react-material.d.ts.map +1 -1
  29. package/lib/renderers/react/react-material.js +3 -3
  30. package/lib/renderers/react/react-material.js.map +1 -1
  31. package/lib/renderers/react/react-relation.d.ts +10 -0
  32. package/lib/renderers/react/react-relation.d.ts.map +1 -0
  33. package/lib/renderers/react/react-relation.js +31 -0
  34. package/lib/renderers/react/react-relation.js.map +1 -0
  35. package/lib/renderers/react/react-scene.d.ts +4 -7
  36. package/lib/renderers/react/react-scene.d.ts.map +1 -1
  37. package/lib/renderers/react/react-scene.js +4 -4
  38. package/lib/renderers/react/react-scene.js.map +1 -1
  39. package/lib/renderers/react/react.d.ts +8 -7
  40. package/lib/renderers/react/react.d.ts.map +1 -1
  41. package/lib/renderers/react/react.js +2 -2
  42. package/lib/renderers/react/react.js.map +1 -1
  43. package/package.json +2 -2
  44. package/src/abstract-3d.ts +33 -6
  45. package/src/renderers/ifc/ifc-encoding.ts +31 -7
  46. package/src/renderers/ifc/ifc-geometry.ts +12 -0
  47. package/src/renderers/ifc/ifc.test.ts +285 -101
  48. package/src/renderers/ifc/ifc.ts +313 -98
  49. package/src/renderers/react/index.ts +0 -1
  50. package/src/renderers/react/react-dimension.tsx +3 -3
  51. package/src/renderers/react/react-group.test.ts +2 -2
  52. package/src/renderers/react/react-group.tsx +5 -4
  53. package/src/renderers/react/react-material.tsx +4 -4
  54. package/src/renderers/react/react-relation.tsx +86 -0
  55. package/src/renderers/react/react-scene.tsx +9 -21
  56. package/src/renderers/react/react.tsx +11 -14
  57. package/lib/renderers/react/react-hotspot.d.ts +0 -27
  58. package/lib/renderers/react/react-hotspot.d.ts.map +0 -1
  59. package/lib/renderers/react/react-hotspot.js +0 -37
  60. package/lib/renderers/react/react-hotspot.js.map +0 -1
  61. package/src/renderers/react/react-hotspot.tsx +0 -112
@@ -1,45 +1,59 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import type { Scene } from "../../abstract-3d.js";
3
- import { box, 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
- import { triangulate } from "./ifc-geometry.js";
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 */
13
13
  const normalize = (ifc: string): string => ifc.replaceAll(/'[0-9A-Za-z_$]{22}'/g, "'guid'");
14
14
 
15
- /** The layer of every face set of the element named `name`, in order */
15
+ const entities = (ifc: string): ReadonlyMap<string, string> => new Map(Array.from(ifc.matchAll(/^#(\d+)=(.*);$/gm), (match) => [match[1], match[2]]));
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
+
20
+ /** The layer of every face set and polyline of the element named `name`, in order */
16
21
  function elementLayers(ifc: string, name: string): ReadonlyArray<string | undefined> {
17
- const entities = new Map(Array.from(ifc.matchAll(/^#(\d+)=(.*);$/gm), (match) => [match[1], match[2]]));
22
+ const byId = entities(ifc);
18
23
  const layers = new Map(
19
24
  Array.from(ifc.matchAll(/[=]IFCPRESENTATIONLAYERASSIGNMENT\('([^']*)',\$,\(([^)]*)\),\$\);/g)).flatMap(([, layer, items]) =>
20
25
  ids(items).map((item) => [item, layer] as const)
21
26
  )
22
27
  );
23
- // The element's owner history, placement and shape, the shape's representation and its context and items
24
- const [, , shape] = ids(Array.from(entities.values()).find((e) => e.includes(`,'${name}',$,$,#`))!);
25
- const [, ...items] = ids(entities.get(ids(entities.get(shape)!)[0])!);
28
+ // The element's owner history, placement and shape, the shape's representations and their context and items
29
+ const [, , shape] = ids(byId.get(named(ifc, name))!);
30
+ const items = ids(byId.get(shape)!).flatMap((representation) => ids(byId.get(representation)!).slice(1));
26
31
  return items.map((item) => layers.get(item));
27
32
  }
28
33
 
29
- /** An air handler component with a panel and a fan with a motor, the motor's meshes two groups without data down */
30
- const component = (id: string, key: string): Scene["groups"][number] =>
31
- 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(
32
45
  [box(vec3(1, 1, 1), red)],
33
46
  vec3Zero,
34
47
  vec3Zero,
35
48
  [
36
- 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" },
37
50
  group([sphere(1, red)], vec3Zero, vec3Zero, [group([], vec3Zero, vec3Zero, [group([cylinder(vec3Zero, 1, 2, red)])])], { type: "fan_wall" }),
38
51
  ],
39
- { id, key }
40
- );
41
-
42
- const layered = (layer: string, groups: Scene["groups"]): Scene["groups"][number] => group([], vec3Zero, vec3Zero, groups, { layer });
52
+ { key: "FAN" }
53
+ ),
54
+ id,
55
+ layer,
56
+ });
43
57
 
44
58
  describe("ifc encoding", () => {
45
59
  it("writes reals with a decimal point", () => {
@@ -60,6 +74,14 @@ describe("ifc encoding", () => {
60
74
  expect(guid(ifcFile(), uuid)).toBe(first);
61
75
  expect(guid(m, uuid)).not.toBe(first);
62
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
+ });
63
85
  });
64
86
 
65
87
  describe("ifc triangulation", () => {
@@ -76,23 +98,32 @@ describe("ifc triangulation", () => {
76
98
  expect(triangulate(cylinder(vec3Zero, 1, 2, red, vec3Zero, false, [], 0, Math.PI).geometry)!.triangles.length).toBeGreaterThan(0);
77
99
  expect(triangulate(line(vec3Zero, vec3(1, 1, 1), 1, red).geometry)).toBeUndefined();
78
100
  });
101
+
102
+ it("converts the end points of lines like the triangle points and nothing else", () => {
103
+ expect(polyline(line(vec3(1, 2, 3), vec3(4, 5, 6), 1, red).geometry, vec3(0, 10, 0))).toEqual(["(1.,-3.,12.)", "(4.,-6.,15.)"]);
104
+ expect(polyline(culledLine(vec3Zero, vec3(1, 0, 0), vec3(0, 0, 1), 1, red).geometry)).toEqual(["(0.,0.,0.)", "(1.,0.,0.)"]);
105
+ expect(polyline(box(vec3(1, 1, 1), red).geometry)).toBeUndefined();
106
+ });
79
107
  });
80
108
 
81
109
  describe("ifc render", () => {
82
- 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", () => {
83
111
  const ifc = render(
84
112
  scene([
85
- group(
86
- [box(vec3(1, 1, 1), red)],
87
- vec3Zero,
88
- vec3Zero,
89
- [
90
- group([sphere(1, red)], vec3Zero, vec3Zero, [], { name: "Fan" }),
91
- group([box(vec3(2, 2, 2), red)], vec3(0, 100, 0)),
92
- group([line(vec3Zero, vec3(1, 0, 0), 1, red)], vec3Zero, vec3Zero, [], { name: "Lines only" }),
93
- ],
94
- { id: "588c9525-0f1b-4b0b-a3e1-ba10f8a922ff" }
95
- ),
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
+ },
96
127
  group([box(vec3(1, 1, 1), red)]),
97
128
  group([]),
98
129
  ])
@@ -100,8 +131,8 @@ describe("ifc render", () => {
100
131
  expect(ifc).toMatch(/^ISO-10303-21;/);
101
132
  expect(ifc).toContain("FILE_SCHEMA(('IFC4'));");
102
133
  expect(ifc.trimEnd()).toMatch(/END-ISO-10303-21;$/);
103
- // The line-only and the empty group are left out, the group without data is merged into its parent
104
- expect(count(ifc, "IFCBUILDINGELEMENTPROXY")).toBe(3);
134
+ // The empty group is left out, the group without id or name is merged into its parent
135
+ expect(count(ifc, "IFCBUILDINGELEMENTPROXY")).toBe(4);
105
136
  expect(count(ifc, "IFCTRIANGULATEDFACESET")).toBe(4);
106
137
  expect(count(ifc, "IFCRELAGGREGATES")).toBe(4);
107
138
  expect(count(ifc, "IFCRELCONTAINEDINSPATIALSTRUCTURE")).toBe(1);
@@ -112,10 +143,13 @@ describe("ifc render", () => {
112
143
  expect(ifc).toContain("IFCCOLOURRGB($,1.,0.,0.)");
113
144
  expect(ifc).toMatch(/[=]IFCSURFACESTYLERENDERING\(#\d+,0\.5,\$,\$,\$,\$,\$,\$,\.NOTDEFINED\.\);/);
114
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")}',`);
115
148
  expect(ifc).toContain(",'588c9525-0f1b-4b0b-a3e1-ba10f8a922ff',$,$,#");
116
149
  expect(ifc).toContain(",'588c9525-0f1b-4b0b-a3e1-ba10f8a922ff',.NOTDEFINED.)");
150
+ expect(ifc).not.toContain("not read");
117
151
  expect(ifc).toContain(",'Fan',$,$,#");
118
- // 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
119
153
  expect(ifc).toContain(",'Element 2',$,$,#");
120
154
  });
121
155
 
@@ -129,30 +163,85 @@ describe("ifc render", () => {
129
163
  expect(count(ifc, "IFCSURFACESTYLE")).toBe(2);
130
164
  });
131
165
 
132
- it("wraps the scene in one root element", () => {
166
+ it("writes a line as a polyline in a Curve3D representation next to the face sets, styled with its colour and on its group's layer", () => {
167
+ const green = { normal: "rgb(0, 255, 0)" };
133
168
  const ifc = render(
134
169
  scene([
135
- group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [], { name: "Filter" }),
136
- group([box(vec3(1, 1, 1), red)], vec3(0, 0, 10), vec3Zero, [], { name: "Fan" }),
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))]),
172
+ layer: "A",
173
+ },
174
+ ])
175
+ );
176
+ const byId = entities(ifc);
177
+ expect(count(ifc, "IFCPOLYLINE")).toBe(1);
178
+ const [, polylineId, start, end] = /^#(\d+)=IFCPOLYLINE\(\(#(\d+),#(\d+)\)\);$/m.exec(ifc)!;
179
+ // The line keeps its group's offset, Y-up 100 becomes Z
180
+ expect([byId.get(start), byId.get(end)]).toEqual(["IFCCARTESIANPOINT((1.,-3.,102.))", "IFCCARTESIANPOINT((4.,-6.,105.))"]);
181
+ const [tessellation, curve] = ids(Array.from(byId.values()).find((e) => e.startsWith("IFCPRODUCTDEFINITIONSHAPE("))!);
182
+ expect(byId.get(tessellation)).toMatch(/^IFCSHAPEREPRESENTATION\(#\d+,'Body','Tessellation',\(#\d+\)\)$/);
183
+ expect(byId.get(curve)).toMatch(new RegExp(`^IFCSHAPEREPRESENTATION\\(#\\d+,'Body','Curve3D',\\(#${polylineId}\\)\\)$`));
184
+ const [, style] = ids(Array.from(byId.values()).find((e) => e.startsWith(`IFCSTYLEDITEM(#${polylineId},`))!);
185
+ const [colour] = ids(byId.get(style)!);
186
+ expect(byId.get(style)).toBe(`IFCCURVESTYLE($,$,$,#${colour},.T.)`);
187
+ expect(byId.get(colour)).toBe("IFCCOLOURRGB($,0.,1.,0.)");
188
+ expect(elementLayers(ifc, "Element 1")).toEqual(["A", "A"]);
189
+ });
190
+
191
+ it("writes an element with only lines, styling each colour once", () => {
192
+ const green = { normal: "rgb(0, 255, 0)" };
193
+ const ifc = render(
194
+ scene([
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" },
196
+ ])
197
+ );
198
+ expect(count(ifc, "IFCBUILDINGELEMENTPROXY")).toBe(1);
199
+ expect(ifc).toContain(",'Lines only',$,$,#");
200
+ expect(count(ifc, "IFCPOLYLINE")).toBe(2);
201
+ expect(count(ifc, "IFCCURVESTYLE")).toBe(1);
202
+ expect(count(ifc, "IFCTRIANGULATEDFACESET")).toBe(0);
203
+ expect(count(ifc, "IFCSHAPEREPRESENTATION")).toBe(1);
204
+ expect(ifc).toMatch(/[=]IFCSHAPEREPRESENTATION\(#\d+,'Body','Curve3D',\(#\d+,#\d+\)\);/);
205
+ });
206
+
207
+ it("writes each element as the class entity gives it, an IfcBuildingElementProxy otherwise", () => {
208
+ const ifc = render(
209
+ scene([
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
+ },
137
218
  ]),
138
- { 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
+ }
139
229
  );
140
230
  expect(ifc).toMatch(/[=]IFCUNITARYEQUIPMENT\('[^']{22}',#\d+,'AHU',\$,\$,#\d+,\$,\$,\.AIRHANDLER\.\);/);
141
- expect(count(ifc, "IFCBUILDINGELEMENTPROXY")).toBe(2);
142
- // 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
143
235
  expect(ifc).toMatch(/[=]IFCRELCONTAINEDINSPATIALSTRUCTURE\('[^']{22}',#\d+,\$,\$,\(#\d+\),#\d+\);/);
144
236
  expect(count(ifc, "IFCRELAGGREGATES")).toBe(4);
145
237
  });
146
238
 
147
239
  it("writes the owner history on every rooted entity and the author and application in the header", () => {
148
- const ifc = render(
149
- scene([group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [group([sphere(1, red)], vec3Zero, vec3Zero, [], { name: "Fan" })])]),
150
- {
151
- author: "Ivan",
152
- organization: "Acme",
153
- application: { name: "airCalc ACOM", version: "10.1" },
154
- }
155
- );
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
+ });
156
245
  expect(ifc).toContain("/* name */ 'Abstract 3D.ifc',");
157
246
  expect(ifc).toContain("/* author */ ('Ivan'),\n/* organization */ ('Acme'),");
158
247
  expect(ifc).toContain("/* originating_system */ 'airCalc ACOM',");
@@ -180,12 +269,12 @@ describe("ifc render", () => {
180
269
  expect(count(ifc, "IFCORGANIZATION")).toBe(1);
181
270
  });
182
271
 
183
- 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", () => {
184
273
  const ifc = render(
185
274
  scene([
186
275
  group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [
187
- group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [group([sphere(1, red)], vec3Zero, vec3Zero, [], { id: "fan" })], { type: "panel" }),
188
- 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" },
189
278
  ]),
190
279
  ])
191
280
  );
@@ -196,73 +285,168 @@ describe("ifc render", () => {
196
285
  expect(ifc).toMatch(/[=]IFCRELAGGREGATES\('[^']{22}',#\d+,\$,\$,#\d+,\(#\d+,#\d+\)\);/);
197
286
  });
198
287
 
199
- 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", () => {
200
- const paths: Array<ReadonlyArray<Record<string, string>>> = [];
201
- const ifc = render(scene([component("a1", "FAN"), component("a2", "FAN"), component("a3", "DUCTCONNECTORS")]), {
202
- root: { entity: "IfcUnitaryEquipment", predefinedType: "AIRHANDLER", name: "AHU" },
203
- layer: (path) => {
204
- paths.push(path);
205
- const key = path.filter((d) => "key" in d).at(-1)?.key;
206
- return path.at(-1)?.type === "panel" ? "acomSECTIONS" : key ? `acom${key}` : undefined;
207
- },
208
- });
209
- // Box, panel, fan and the motor two groups without data further down
210
- expect(elementLayers(ifc, "a1")).toEqual(["acomFAN", "acomSECTIONS", "acomFAN", "acomFAN"]);
211
- 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"]);
212
293
  // One layer per name across the elements
213
294
  expect(count(ifc, "IFCPRESENTATIONLAYERASSIGNMENT")).toBe(3);
214
- expect(ifc).toMatch(/[=]IFCPRESENTATIONLAYERASSIGNMENT\('acomSECTIONS',\$,\(#\d+,#\d+,#\d+\),\$\);/);
215
- expect(ifc).toMatch(/[=]IFCPRESENTATIONLAYERASSIGNMENT\('acomFAN',\$,\((#\d+,){5}#\d+\),\$\);/);
216
- expect(paths).toContainEqual([{ name: "AHU" }, { id: "a1", key: "FAN" }, { type: "panel" }]);
217
- // The root, 3 components and their panel and fan, groups without data are skipped
218
- expect(paths).toHaveLength(10);
219
- });
295
+ expect(ifc).toMatch(/[=]IFCPRESENTATIONLAYERASSIGNMENT\('SECTIONS',\$,\(#\d+,#\d+,#\d+\),\$\);/);
296
+ expect(ifc).toMatch(/[=]IFCPRESENTATIONLAYERASSIGNMENT\('FAN',\$,\((#\d+,){5}#\d+\),\$\);/);
220
297
 
221
- it("inherits the layer through groups without data and groups the layer returns undefined or an empty name for", () => {
222
- const ifc = render(
298
+ const nested = render(
223
299
  scene([
224
- group(
225
- [box(vec3(1, 1, 1), red)],
226
- vec3Zero,
227
- vec3Zero,
228
- [
229
- group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [], { type: "panel" }),
230
- layered("", [group([box(vec3(1, 1, 1), red)])]),
231
- layered("B", [group([], vec3Zero, vec3Zero, [group([box(vec3(1, 1, 1), red)])])]),
232
- layered("B", [group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [], { name: "Inner", type: "fan" })]),
233
- ],
234
- { layer: "A" }
235
- ),
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
+ },
236
306
  group([box(vec3(1, 1, 1), red)]),
237
- ]),
238
- { layer: (path) => path.at(-1)?.layer }
307
+ ])
239
308
  );
240
- expect(elementLayers(ifc, "Element 1")).toEqual(["A", "A", "A", "B"]);
241
- expect(elementLayers(ifc, "Inner")).toEqual(["B"]);
242
- // Meshes without a layer above them get none
243
- expect(elementLayers(ifc, "Element 2")).toEqual([undefined]);
244
- 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);
245
313
  });
246
314
 
247
315
  it("only adds the layers to the output", () => {
248
316
  vi.useFakeTimers({ now: new Date("2026-10-12T10:00:00Z") });
249
- const s = scene([
250
- group([box(vec3(1, 1, 1), red)], vec3(1, 2, 3), vec3(0.1, 0, 0), [
251
- group([sphere(1, red)], vec3Zero, vec3Zero, [group([cylinder(vec3Zero, 1, 2, red)])], { name: "Fan" }),
252
- ]),
253
- ]);
254
- const plain = normalize(render(s));
255
- const undefinedLayers = normalize(render(s, { layer: () => undefined }));
256
- 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")));
257
321
  vi.useRealTimers();
258
322
  expect(count(plain, "IFCPRESENTATIONLAYERASSIGNMENT")).toBe(0);
259
- expect(undefinedLayers).toBe(plain);
260
- // Only the fan's meshes are in a group with data
261
- 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.))");
262
432
  });
263
433
 
264
434
  it("only references entities that exist", () => {
265
- const ifc = render(scene([group([box(vec3(1, 1, 1), red)], vec3(1, 2, 3), vec3(0.1, 0.2, 0.3), [group([sphere(1, red)])])]));
435
+ const ifc = render(
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
+ )
449
+ );
266
450
  const ids = new Set(Array.from(ifc.matchAll(/^#(\d+)=/gm), (match) => match[1]));
267
451
  const referenced = Array.from(ifc.matchAll(/[(,]#(\d+)/g), (match) => match[1]);
268
452
  expect(referenced.filter((id) => !ids.has(id))).toEqual([]);