abstract-3d 2.10.6 → 2.11.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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/lib/renderers/ifc/ifc-encoding.d.ts +21 -0
  3. package/lib/renderers/ifc/ifc-encoding.d.ts.map +1 -0
  4. package/lib/renderers/ifc/ifc-encoding.js +78 -0
  5. package/lib/renderers/ifc/ifc-encoding.js.map +1 -0
  6. package/lib/renderers/ifc/ifc-geometry.d.ts +14 -0
  7. package/lib/renderers/ifc/ifc-geometry.d.ts.map +1 -0
  8. package/lib/renderers/ifc/ifc-geometry.js +114 -0
  9. package/lib/renderers/ifc/ifc-geometry.js.map +1 -0
  10. package/lib/renderers/ifc/ifc.d.ts +26 -0
  11. package/lib/renderers/ifc/ifc.d.ts.map +1 -0
  12. package/lib/renderers/ifc/ifc.js +111 -0
  13. package/lib/renderers/ifc/ifc.js.map +1 -0
  14. package/lib/renderers/ifc/index.d.ts +2 -0
  15. package/lib/renderers/ifc/index.d.ts.map +1 -0
  16. package/lib/renderers/ifc/index.js +2 -0
  17. package/lib/renderers/ifc/index.js.map +1 -0
  18. package/lib/renderers/index.d.ts +1 -0
  19. package/lib/renderers/index.d.ts.map +1 -1
  20. package/lib/renderers/index.js +1 -0
  21. package/lib/renderers/index.js.map +1 -1
  22. package/lib/renderers/react/react-mesh.d.ts.map +1 -1
  23. package/lib/renderers/react/react-mesh.js +10 -75
  24. package/lib/renderers/react/react-mesh.js.map +1 -1
  25. package/lib/renderers/shared/three-geometry.d.ts +17 -0
  26. package/lib/renderers/shared/three-geometry.d.ts.map +1 -0
  27. package/lib/renderers/shared/three-geometry.js +73 -0
  28. package/lib/renderers/shared/three-geometry.js.map +1 -0
  29. package/package.json +8 -3
  30. package/src/renderers/ifc/ifc-encoding.ts +91 -0
  31. package/src/renderers/ifc/ifc-geometry.ts +149 -0
  32. package/src/renderers/ifc/ifc.test.ts +110 -0
  33. package/src/renderers/ifc/ifc.ts +167 -0
  34. package/src/renderers/ifc/index.ts +1 -0
  35. package/src/renderers/index.ts +1 -0
  36. package/src/renderers/react/react-mesh.tsx +10 -103
  37. package/src/renderers/shared/three-geometry.ts +87 -0
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } 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";
4
+ import { guid, ifcFile, real, str } from "./ifc-encoding.js";
5
+ import { triangulate } from "./ifc-geometry.js";
6
+ import { render } from "./ifc.js";
7
+
8
+ const red = { normal: "rgb(255, 0, 0)", opacity: 0.5 };
9
+ const scene = (groups: Scene["groups"]): Scene => ({ size_deprecated: vec3Zero, groups });
10
+ const count = (ifc: string, entity: string): number => ifc.split(`=${entity}(`).length - 1;
11
+
12
+ describe("ifc encoding", () => {
13
+ it("writes reals with a decimal point", () => {
14
+ expect([real(0), real(-0), real(1), real(-2.5), real(1e-9), real(1 / 3)]).toEqual(["0.", "0.", "1.", "-2.5", "0.", "0.333333"]);
15
+ });
16
+
17
+ it("escapes strings", () => {
18
+ expect(str("it's a\\b")).toBe("'it''s a\\\\b'");
19
+ expect(str("Fläkt")).toBe("'Fl\\X2\\00E4\\X0\\kt'");
20
+ expect(str(undefined)).toBe("$");
21
+ });
22
+
23
+ it("derives the guid from a uuid and stays unique within a file", () => {
24
+ const m = ifcFile();
25
+ const uuid = "588c9525-0f1b-4b0b-a3e1-ba10f8a922ff";
26
+ const first = guid(m, uuid);
27
+ expect(first).toMatch(/^[0-9A-Za-z_$]{22}$/);
28
+ expect(guid(ifcFile(), uuid)).toBe(first);
29
+ expect(guid(m, uuid)).not.toBe(first);
30
+ });
31
+ });
32
+
33
+ describe("ifc triangulation", () => {
34
+ it("triangulates a box into 12 triangles over 8 shared points, converted to Z-up", () => {
35
+ const t = triangulate(box(vec3(2, 4, 6), red, vec3(0, 10, 0)).geometry)!;
36
+ expect(t.triangles).toHaveLength(12);
37
+ expect(t.points).toHaveLength(8);
38
+ // Y-up 10 + half height 2 becomes Z
39
+ expect(t.points).toContain("(1.,3.,12.)");
40
+ });
41
+
42
+ it("triangulates holes and skips lines", () => {
43
+ expect(triangulate(box(vec3(10, 10, 1), red, vec3Zero, vec3Zero, [roundHole(vec2(0, 0), 2)]).geometry)!.triangles.length).toBeGreaterThan(12);
44
+ expect(triangulate(cylinder(vec3Zero, 1, 2, red, vec3Zero, false, [], 0, Math.PI).geometry)!.triangles.length).toBeGreaterThan(0);
45
+ expect(triangulate(line(vec3Zero, vec3(1, 1, 1), 1, red).geometry)).toBeUndefined();
46
+ });
47
+ });
48
+
49
+ describe("ifc render", () => {
50
+ it("writes an element per top-level group and group with data, merging groups without data into their parent", () => {
51
+ const ifc = render(
52
+ scene([
53
+ group(
54
+ [box(vec3(1, 1, 1), red)],
55
+ vec3Zero,
56
+ vec3Zero,
57
+ [
58
+ group([sphere(1, red)], vec3Zero, vec3Zero, [], { name: "Fan" }),
59
+ group([box(vec3(2, 2, 2), red)], vec3(0, 100, 0)),
60
+ group([line(vec3Zero, vec3(1, 0, 0), 1, red)], vec3Zero, vec3Zero, [], { name: "Lines only" }),
61
+ ],
62
+ { id: "588c9525-0f1b-4b0b-a3e1-ba10f8a922ff" }
63
+ ),
64
+ group([box(vec3(1, 1, 1), red)]),
65
+ group([]),
66
+ ])
67
+ );
68
+ expect(ifc).toMatch(/^ISO-10303-21;/);
69
+ expect(ifc).toContain("FILE_SCHEMA(('IFC4'));");
70
+ expect(ifc.trimEnd()).toMatch(/END-ISO-10303-21;$/);
71
+ // The line-only and the empty group are left out, the group without data is merged into its parent
72
+ expect(count(ifc, "IFCBUILDINGELEMENTPROXY")).toBe(3);
73
+ expect(count(ifc, "IFCTRIANGULATEDFACESET")).toBe(4);
74
+ expect(count(ifc, "IFCRELAGGREGATES")).toBe(4);
75
+ expect(count(ifc, "IFCRELCONTAINEDINSPATIALSTRUCTURE")).toBe(1);
76
+ // The merged box keeps its group's offset, Y-up 100 + half height becomes Z
77
+ expect(ifc).toContain("(1.,-1.,101.)");
78
+ // Same material is styled once
79
+ expect(count(ifc, "IFCSURFACESTYLE")).toBe(1);
80
+ expect(ifc).toContain("IFCCOLOURRGB($,1.,0.,0.)");
81
+ expect(ifc).toContain("IFCSURFACESTYLESHADING(#");
82
+ expect(ifc).toContain(",'588c9525-0f1b-4b0b-a3e1-ba10f8a922ff',$,$,#");
83
+ expect(ifc).toContain(",'588c9525-0f1b-4b0b-a3e1-ba10f8a922ff',.NOTDEFINED.)");
84
+ expect(ifc).toContain(",'Fan',$,$,#");
85
+ // IFC requires a name, top-level groups without data are named after their position
86
+ expect(ifc).toContain(",'Element 2',$,$,#");
87
+ });
88
+
89
+ it("wraps the scene in one root element", () => {
90
+ const ifc = render(
91
+ scene([
92
+ group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [], { name: "Filter" }),
93
+ group([box(vec3(1, 1, 1), red)], vec3(0, 0, 10), vec3Zero, [], { name: "Fan" }),
94
+ ]),
95
+ { root: { entity: "IfcUnitaryEquipment", predefinedType: "AIRHANDLER", name: "AHU" } }
96
+ );
97
+ expect(ifc).toMatch(/[=]IFCUNITARYEQUIPMENT\('[^']{22}',\$,'AHU',\$,\$,#\d+,\$,\$,\.AIRHANDLER\.\);/);
98
+ expect(count(ifc, "IFCBUILDINGELEMENTPROXY")).toBe(2);
99
+ // Only the root is in the storey, the groups are aggregated into it
100
+ expect(ifc).toMatch(/[=]IFCRELCONTAINEDINSPATIALSTRUCTURE\('[^']{22}',\$,\$,\$,\(#\d+\),#\d+\);/);
101
+ expect(count(ifc, "IFCRELAGGREGATES")).toBe(4);
102
+ });
103
+
104
+ it("only references entities that exist", () => {
105
+ 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)])])]));
106
+ const ids = new Set(Array.from(ifc.matchAll(/^#(\d+)=/gm), (match) => match[1]));
107
+ const referenced = Array.from(ifc.matchAll(/[(,]#(\d+)/g), (match) => match[1]);
108
+ expect(referenced.filter((id) => !ids.has(id))).toEqual([]);
109
+ });
110
+ });
@@ -0,0 +1,167 @@
1
+ import { Color, SRGBColorSpace } from "three";
2
+ import type { Scene, Group, Material, Vec3 } from "../../abstract-3d.js";
3
+ import { vec3Zero, vec3PosX, vec3PosY, vec3Rot, vec3TransRot, vec3RotCombine } from "../../abstract-3d.js";
4
+ import type { MutableIfc } from "./ifc-encoding.js";
5
+ import { add, guid, ifcFile, point, real, ref, refs, serialize, str } from "./ifc-encoding.js";
6
+ import type { Triangulation } from "./ifc-geometry.js";
7
+ import { toIfc, triangulate } from "./ifc-geometry.js";
8
+
9
+ /** Element classes with the same attributes as IfcBuildingElementProxy (GlobalId, ..., Tag, PredefinedType) */
10
+ export type IfcElementEntity = "IfcBuildingElementProxy" | "IfcUnitaryEquipment";
11
+
12
+ export type IfcElementClass = {
13
+ readonly entity: IfcElementEntity;
14
+ /** The entity's PredefinedType, e.g. "AIRHANDLER" for IfcUnitaryEquipment, defaults to "NOTDEFINED" */
15
+ readonly predefinedType?: string;
16
+ };
17
+
18
+ export type IfcOptions = {
19
+ /** Name of the IFC project, defaults to "Abstract 3D" */
20
+ readonly name?: string;
21
+ /**
22
+ * Wraps the whole scene in one element, e.g. `{ entity: "IfcUnitaryEquipment", predefinedType: "AIRHANDLER", name: "AHU" }`, otherwise the
23
+ * top-level groups are placed directly in the storey
24
+ */
25
+ readonly root?: IfcElementClass & { readonly name: string };
26
+ };
27
+
28
+ type NodeMesh = { readonly material: Material; readonly triangulation: Triangulation };
29
+
30
+ /** A group that becomes an element, groups without any triangulated geometry in them are left out */
31
+ type ElementNode = {
32
+ readonly group: Group;
33
+ readonly elementClass: IfcElementClass;
34
+ readonly pos: Vec3;
35
+ readonly rot: Vec3;
36
+ readonly meshes: ReadonlyArray<NodeMesh>;
37
+ readonly children: ReadonlyArray<ElementNode>;
38
+ };
39
+
40
+ const proxyClass: IfcElementClass = { entity: "IfcBuildingElementProxy" };
41
+
42
+ type IfcContext = { readonly body: number; readonly styles: Map<string, number> };
43
+
44
+ /**
45
+ * IFC4 (Reference View) with every mesh as an IfcTriangulatedFaceSet, in millimetres. Every top-level group and every group with `data` becomes an
46
+ * IfcBuildingElementProxy placed relative to its parent element and aggregated into it. Groups without `data` are only transforms, so their meshes
47
+ * are merged into the parent element
48
+ */
49
+ export function render(scene: Scene, options: IfcOptions = {}): string {
50
+ const name = options.name ?? "Abstract 3D";
51
+ const m = ifcFile();
52
+
53
+ const world = axisPlacement(m, vec3Zero, vec3Zero);
54
+ const context = add(m, `IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,${ref(world)},$)`);
55
+ const body = add(m, `IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,${ref(context)},$,.MODEL_VIEW.,$)`);
56
+ const units = [
57
+ add(m, "IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.)"),
58
+ add(m, "IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.)"),
59
+ add(m, "IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.)"),
60
+ add(m, "IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.)"),
61
+ ];
62
+ const project = add(m, `IFCPROJECT(${str(guid(m))},$,${str(name)},$,$,$,$,(${ref(context)}),${ref(add(m, `IFCUNITASSIGNMENT(${refs(units)})`))})`);
63
+
64
+ const sitePlacement = add(m, `IFCLOCALPLACEMENT($,${ref(world)})`);
65
+ const site = add(m, `IFCSITE(${str(guid(m))},$,'Site',$,$,${ref(sitePlacement)},$,$,.ELEMENT.,$,$,$,$,$)`);
66
+ const buildingPlacement = add(m, `IFCLOCALPLACEMENT(${ref(sitePlacement)},${ref(world)})`);
67
+ const building = add(m, `IFCBUILDING(${str(guid(m))},$,'Building',$,$,${ref(buildingPlacement)},$,$,.ELEMENT.,$,$,$)`);
68
+ const storeyPlacement = add(m, `IFCLOCALPLACEMENT(${ref(buildingPlacement)},${ref(world)})`);
69
+ const storey = add(m, `IFCBUILDINGSTOREY(${str(guid(m))},$,'Storey',$,$,${ref(storeyPlacement)},$,$,.ELEMENT.,0.)`);
70
+ aggregate(m, project, [site]);
71
+ aggregate(m, site, [building]);
72
+ aggregate(m, building, [storey]);
73
+
74
+ const center = scene.center_deprecated ?? vec3Zero;
75
+ const sceneRot = scene.rotation_deprecated ?? vec3Zero;
76
+ const c: IfcContext = { body, styles: new Map() };
77
+ const nodes = options.root
78
+ ? [elementNode({ pos: center, rot: sceneRot, groups: scene.groups, data: { name: options.root.name } }, center, sceneRot, options.root)]
79
+ : scene.groups.map((g) => elementNode(g, vec3TransRot(g.pos, center, sceneRot), vec3RotCombine(sceneRot, g.rot ?? vec3Zero)));
80
+ const elements = nodes.flatMap((node) => node ?? []).map((node, i) => element(m, node, `${i + 1}`, storeyPlacement, c));
81
+ if (elements.length > 0) {
82
+ add(m, `IFCRELCONTAINEDINSPATIALSTRUCTURE(${str(guid(m))},$,$,$,${refs(elements)},${ref(storey)})`);
83
+ }
84
+
85
+ const date = new Date().toISOString().split(".")[0] ?? "1970-01-01T00:00:00";
86
+ return serialize(m, `${name}.ifc`, date);
87
+ }
88
+
89
+ function elementNode(group: Group, pos: Vec3, rot: Vec3, elementClass = proxyClass): ElementNode | undefined {
90
+ const { meshes, children } = elementContent(group, vec3Zero, vec3Zero);
91
+ return meshes.length > 0 || children.length > 0 ? { group, elementClass, pos, rot, meshes, children } : undefined;
92
+ }
93
+
94
+ /** Meshes of the group and its descendants without data, relative to the element the group is at pos/rot in, and the descendants with data as elements */
95
+ function elementContent(group: Group, pos: Vec3, rot: Vec3): Pick<ElementNode, "meshes" | "children"> {
96
+ const meshes = (group.meshes ?? []).flatMap((mesh) => {
97
+ const triangulation = triangulate(mesh.geometry, pos, rot);
98
+ return triangulation ? [{ material: mesh.material, triangulation }] : [];
99
+ });
100
+ const nested = (group.groups ?? []).map((child) => {
101
+ const childPos = vec3TransRot(child.pos, pos, rot);
102
+ const childRot = vec3RotCombine(rot, child.rot ?? vec3Zero);
103
+ return Object.keys(child.data ?? {}).length > 0
104
+ ? { meshes: [], children: [elementNode(child, childPos, childRot)].flatMap((node) => node ?? []) }
105
+ : elementContent(child, childPos, childRot);
106
+ });
107
+ return { meshes: [...meshes, ...nested.flatMap((n) => n.meshes)], children: nested.flatMap((n) => n.children) };
108
+ }
109
+
110
+ /** `path` is the element's position in the tree (e.g. "2.1"), named after it when the group has no `data.name` or `data.id` since IFC requires a name */
111
+ function element(m: MutableIfc, node: ElementNode, path: string, parentPlacement: number, c: IfcContext): number {
112
+ const placement = add(m, `IFCLOCALPLACEMENT(${ref(parentPlacement)},${ref(axisPlacement(m, node.pos, node.rot))})`);
113
+ const items = node.meshes.map(({ material, triangulation }) => faceSet(m, triangulation, material, c));
114
+ const shape =
115
+ items.length > 0
116
+ ? ref(add(m, `IFCPRODUCTDEFINITIONSHAPE($,$,(${ref(add(m, `IFCSHAPEREPRESENTATION(${ref(c.body)},'Body','Tessellation',${refs(items)})`))}))`))
117
+ : "$";
118
+ const id = node.group.data?.["id"];
119
+ const name = node.group.data?.["name"] ?? id ?? `Element ${path}`;
120
+ const { entity, predefinedType = "NOTDEFINED" } = node.elementClass;
121
+ const ifcElement = add(
122
+ m,
123
+ `${entity.toUpperCase()}(${str(guid(m, id))},$,${str(name)},$,$,${ref(placement)},${shape},${str(id)},.${predefinedType}.)`
124
+ );
125
+ aggregate(
126
+ m,
127
+ ifcElement,
128
+ node.children.map((child, i) => element(m, child, `${path}.${i + 1}`, placement, c))
129
+ );
130
+ return ifcElement;
131
+ }
132
+
133
+ function faceSet(m: MutableIfc, { points, triangles }: Triangulation, material: Material, c: IfcContext): number {
134
+ const pointList = add(m, `IFCCARTESIANPOINTLIST3D((${points.join(",")}))`);
135
+ const faces = add(m, `IFCTRIANGULATEDFACESET(${ref(pointList)},$,.F.,(${triangles.map((t) => `(${t.join(",")})`).join(",")}),$)`);
136
+ add(m, `IFCSTYLEDITEM(${ref(faces)},(${ref(surfaceStyle(m, material, c))}),$)`);
137
+ return faces;
138
+ }
139
+
140
+ function surfaceStyle(m: MutableIfc, material: Material, c: IfcContext): number {
141
+ const opacity = material.opacity ?? 1;
142
+ const key = `${material.normal}_${opacity}`;
143
+ const cached = c.styles.get(key);
144
+ if (cached !== undefined) {
145
+ return cached;
146
+ }
147
+ const { r, g, b } = new Color(material.normal).getRGB({ r: 0, g: 0, b: 0 }, SRGBColorSpace);
148
+ const colour = add(m, `IFCCOLOURRGB($,${real(r)},${real(g)},${real(b)})`);
149
+ const shading = add(m, `IFCSURFACESTYLESHADING(${ref(colour)},${real(1 - opacity)})`);
150
+ const style = add(m, `IFCSURFACESTYLE(${str(material.normal)},.BOTH.,(${ref(shading)}))`);
151
+ c.styles.set(key, style);
152
+ return style;
153
+ }
154
+
155
+ /** The group's local axes, converted to IFC's Z-up where three.js' up (Y) becomes the placement's Axis (Z) */
156
+ function axisPlacement(m: MutableIfc, pos: Vec3, rot: Vec3): number {
157
+ const location = add(m, `IFCCARTESIANPOINT(${point(toIfc(pos))})`);
158
+ const axis = add(m, `IFCDIRECTION(${point(toIfc(vec3Rot(vec3PosY, vec3Zero, rot)))})`);
159
+ const refDirection = add(m, `IFCDIRECTION(${point(toIfc(vec3Rot(vec3PosX, vec3Zero, rot)))})`);
160
+ return add(m, `IFCAXIS2PLACEMENT3D(${ref(location)},${ref(axis)},${ref(refDirection)})`);
161
+ }
162
+
163
+ function aggregate(m: MutableIfc, parent: number, children: ReadonlyArray<number>): void {
164
+ if (children.length > 0) {
165
+ add(m, `IFCRELAGGREGATES(${str(guid(m))},$,$,$,${ref(parent)},${refs(children)})`);
166
+ }
167
+ }
@@ -0,0 +1 @@
1
+ export * from "./ifc.js";
@@ -3,3 +3,4 @@ export * as Svg from "./svg/index.js";
3
3
  export * as Stl from "./stl/index.js";
4
4
  export * as React3Js from "./react/index.js";
5
5
  export * as Step from "./step/index.js";
6
+ export * as Ifc from "./ifc/index.js";
@@ -8,7 +8,6 @@ import {
8
8
  CatmullRomCurve3,
9
9
  Color,
10
10
  ConeGeometry,
11
- Curve,
12
11
  CylinderGeometry,
13
12
  Euler,
14
13
  ExtrudeGeometry,
@@ -23,20 +22,9 @@ import {
23
22
  Vector2,
24
23
  Vector3,
25
24
  } from "three";
26
- import { exhaustiveCheck } from "ts-exhaustive-check";
27
- import type {
28
- Mesh,
29
- Box,
30
- Plane,
31
- Shape as Shape_1,
32
- Cylinder,
33
- Polygon as A3dPolygon,
34
- Tube as A3dTube,
35
- CircleCurve as A3dCircleCurve,
36
- Hole,
37
- Vec3,
38
- } from "../../abstract-3d.js";
39
- import { vec2Scale, vec2Sub, vec2Add, isZero, vec3, equals } from "../../abstract-3d.js";
25
+ import type { Mesh, Box, Plane, Shape as Shape_1, Cylinder, Polygon as A3dPolygon, Tube as A3dTube } from "../../abstract-3d.js";
26
+ import { vec2Scale, isZero } from "../../abstract-3d.js";
27
+ import { addHoles, CircleCurve, CYLINDER_SEGMENTS, cylinderCutPositions, nonZeroHoles } from "../shared/three-geometry.js";
40
28
  import { planeGeometry } from "./react-image-material.js";
41
29
 
42
30
  extend({
@@ -56,7 +44,6 @@ extend({
56
44
  CylinderGeometry,
57
45
  });
58
46
 
59
- const CYLINDER_SEGMENTS = 40;
60
47
  const boxGeometry = new BoxGeometry();
61
48
  const cylinderGeometries = new Map<string, BufferGeometry>();
62
49
  const coneGeometry = new ConeGeometry(1, 1, 16, 1);
@@ -80,8 +67,7 @@ export function ReactMesh({ mesh, children }: { readonly mesh: Mesh; readonly ch
80
67
  switch (mesh.geometry.type) {
81
68
  case "Box": {
82
69
  const { pos, size, rot, holes } = mesh.geometry;
83
- const filteredHoles = holes?.filter((h) => !holeIsZero(h));
84
- return !filteredHoles || filteredHoles.length === 0 ? (
70
+ return nonZeroHoles(holes).length === 0 ? (
85
71
  <mesh
86
72
  geometry={boxGeometry}
87
73
  scale={[size.x, size.y, size.z]}
@@ -117,8 +103,7 @@ export function ReactMesh({ mesh, children }: { readonly mesh: Mesh; readonly ch
117
103
  const { pos, radius, radiusEnd = radius, rot, length, holes, open, angleStart = 0, angleLength = Math.PI * 2 } = mesh.geometry;
118
104
  const isWhole = isZero(Math.PI * 2 - angleLength);
119
105
  // Holes are extruded along the length, which can't taper
120
- const filteredHoles = radiusEnd === radius ? holes?.filter((h) => !holeIsZero(h)) : undefined;
121
- if (isWhole && filteredHoles && filteredHoles.length > 0) {
106
+ if (isWhole && radiusEnd === radius && nonZeroHoles(holes).length > 0) {
122
107
  return <ExcrudeCylinder cyl={mesh.geometry}>{children}</ExcrudeCylinder>;
123
108
  }
124
109
  // Unit geometry scaled by the largest radius, so only the radius ratio needs its own geometry
@@ -162,8 +147,7 @@ export function ReactMesh({ mesh, children }: { readonly mesh: Mesh; readonly ch
162
147
  );
163
148
  case "Plane": {
164
149
  const { pos, size, rot, holes } = mesh.geometry;
165
- const filteredHoles = holes?.filter((h) => !holeIsZero(h));
166
- return !filteredHoles || filteredHoles.length === 0 ? (
150
+ return nonZeroHoles(holes).length === 0 ? (
167
151
  <mesh
168
152
  geometry={planeGeometry}
169
153
  scale={[size.x, size.y, 1]}
@@ -273,7 +257,7 @@ function ExcrudeBoxPlane({
273
257
  const excrudeGeometry = React.useMemo(() => {
274
258
  const shape = new Shape();
275
259
  shape.moveTo(-half.x, -half.y).lineTo(-half.x, half.y).lineTo(half.x, half.y).lineTo(half.x, -half.y).closePath();
276
- holes(geo.holes, shape);
260
+ addHoles(geo.holes, shape);
277
261
  return new ExtrudeGeometry(shape, { depth: sizeZ, bevelEnabled: false });
278
262
  }, [geo, half.x, half.y, sizeZ]);
279
263
 
@@ -299,7 +283,7 @@ function ExcrudeShape({ s, children }: { readonly s: Shape_1; readonly children?
299
283
  shape.lineTo(p.x, p.y);
300
284
  }
301
285
  shape.closePath();
302
- holes(s.holes, shape);
286
+ addHoles(s.holes, shape);
303
287
  return new ExtrudeGeometry(shape, { depth: s.thickness, bevelEnabled: false });
304
288
  }, [s]);
305
289
 
@@ -330,30 +314,11 @@ function getCylinderGeometry(top: number, bottom: number, open: boolean, angleSt
330
314
  );
331
315
  }
332
316
 
333
- const rimPoint = (radius: number, y: number, angle: number): Vec3 => vec3(radius * Math.sin(angle), y, radius * Math.cos(angle));
334
-
335
317
  /** The two walls closing an angled cylinder, from the axis out to the rim at angleStart and angleEnd */
336
318
  function getCylinderCutGeometry(top: number, bottom: number, angleStart: number, angleLength: number): BufferGeometry {
337
319
  return cachedCylinderGeometry(`cut_${top}_${bottom}_${angleStart}_${angleLength}`, () => {
338
- const [c0, c1] = [vec3(0, -0.5, 0), vec3(0, 0.5, 0)];
339
- const angleEnd = angleStart + angleLength;
340
- const [b0, t0] = [rimPoint(bottom, -0.5, angleStart), rimPoint(top, 0.5, angleStart)];
341
- const [b1, t1] = [rimPoint(bottom, -0.5, angleEnd), rimPoint(top, 0.5, angleEnd)];
342
- // Unshared vertices so each wall gets its own flat normal, wound opposite so both face outwards
343
- const triangles = [
344
- [c0, b0, t0],
345
- [c0, t0, c1],
346
- [c0, c1, t1],
347
- [c0, t1, b1],
348
- ];
349
320
  const g = new BufferGeometry();
350
- g.setAttribute(
351
- "position",
352
- new Float32BufferAttribute(
353
- triangles.flat().flatMap((v) => [v.x, v.y, v.z]),
354
- 3
355
- )
356
- );
321
+ g.setAttribute("position", new Float32BufferAttribute(cylinderCutPositions(top, bottom, 1, angleStart, angleLength), 3));
357
322
  g.computeVertexNormals();
358
323
  return g;
359
324
  });
@@ -363,7 +328,7 @@ function ExcrudeCylinder({ cyl, children }: { readonly cyl: Cylinder; readonly c
363
328
  const excrudeGeometry = React.useMemo(() => {
364
329
  const shape = new Shape();
365
330
  shape.moveTo(0, cyl.radius).absellipse(0, 0, cyl.radius, cyl.radius, 0, Math.PI * 2, true);
366
- holes(cyl.holes, shape);
331
+ addHoles(cyl.holes, shape);
367
332
  return new ExtrudeGeometry(shape, { depth: cyl.length, bevelEnabled: false });
368
333
  }, [cyl]);
369
334
 
@@ -377,29 +342,6 @@ function ExcrudeCylinder({ cyl, children }: { readonly cyl: Cylinder; readonly c
377
342
  );
378
343
  }
379
344
 
380
- function holes(holes: ReadonlyArray<Hole> | undefined, shape: Shape): void {
381
- holes
382
- ?.filter((h) => !holeIsZero(h))
383
- .forEach((h) => {
384
- switch (h.type) {
385
- case "RoundHole":
386
- shape.holes.push(new Path().absarc(h.pos.x, h.pos.y, h.radius, 0, Math.PI * 2, true));
387
- break;
388
- case "SquareHole": {
389
- const path = new Path();
390
- const halfHole = vec2Scale(h.size, 0.5);
391
- const min = vec2Sub(h.pos, halfHole);
392
- const max = vec2Add(h.pos, halfHole);
393
- path.moveTo(min.x, min.y).lineTo(min.x, max.y).lineTo(max.x, max.y).lineTo(max.x, min.y).closePath();
394
- shape.holes.push(path);
395
- break;
396
- }
397
- default:
398
- exhaustiveCheck(h);
399
- }
400
- });
401
- }
402
-
403
345
  function Polygon({ polygon, children }: { readonly polygon: A3dPolygon; readonly children?: React.JSX.Element }): React.JSX.Element {
404
346
  const vertices = React.useMemo(() => {
405
347
  if (polygon.points.length === 4) {
@@ -481,41 +423,6 @@ function Tube({ tube, children }: { readonly tube: A3dTube; readonly children?:
481
423
  );
482
424
  }
483
425
 
484
- function holeIsZero(hole: Hole): boolean {
485
- switch (hole.type) {
486
- case "RoundHole": {
487
- return equals(hole.radius, 0.0);
488
- }
489
- case "SquareHole": {
490
- return equals(hole.size.x, 0.0) || equals(hole.size.y, 0.0);
491
- }
492
- default:
493
- return false;
494
- }
495
- }
496
-
497
- // oxlint-disable-next-line functional/no-classes -- extends three.js Curve, which requires a class
498
- class CircleCurve extends Curve<Vector3> {
499
- radius: number;
500
- angleLength: number;
501
- startAngle: number;
502
-
503
- constructor(circleCurve: A3dCircleCurve) {
504
- super();
505
- this.radius = circleCurve.radius;
506
- this.startAngle = circleCurve.angleStart;
507
- this.angleLength = circleCurve.angleLength;
508
- }
509
-
510
- override getPoint(t: number, optionalTarget = new Vector3()): Vector3 {
511
- return optionalTarget.set(
512
- -this.radius * Math.sin(this.startAngle + this.angleLength * t),
513
- -this.radius * Math.cos(this.startAngle + this.angleLength * t),
514
- 0
515
- );
516
- }
517
- }
518
-
519
426
  const culledLineVertexShader = `
520
427
  uniform vec3 lineStart;
521
428
  uniform vec3 lineEnd;
@@ -0,0 +1,87 @@
1
+ import { Curve, Path, type Shape, Vector3 } from "three";
2
+ import { exhaustiveCheck } from "ts-exhaustive-check";
3
+ import type { CircleCurve as A3dCircleCurve, Hole, Vec3 } from "../../abstract-3d.js";
4
+ import { vec2Scale, vec2Sub, vec2Add, vec3, equals } from "../../abstract-3d.js";
5
+
6
+ /** Three.js geometry helpers shared by the react renderer and the triangulating exporters */
7
+
8
+ export const CYLINDER_SEGMENTS = 40;
9
+
10
+ export function holeIsZero(hole: Hole): boolean {
11
+ switch (hole.type) {
12
+ case "RoundHole": {
13
+ return equals(hole.radius, 0.0);
14
+ }
15
+ case "SquareHole": {
16
+ return equals(hole.size.x, 0.0) || equals(hole.size.y, 0.0);
17
+ }
18
+ default:
19
+ return false;
20
+ }
21
+ }
22
+
23
+ export function nonZeroHoles(holes: ReadonlyArray<Hole> | undefined): ReadonlyArray<Hole> {
24
+ return holes?.filter((h) => !holeIsZero(h)) ?? [];
25
+ }
26
+
27
+ export function addHoles(holes: ReadonlyArray<Hole> | undefined, shape: Shape): void {
28
+ nonZeroHoles(holes).forEach((h) => {
29
+ switch (h.type) {
30
+ case "RoundHole":
31
+ shape.holes.push(new Path().absarc(h.pos.x, h.pos.y, h.radius, 0, Math.PI * 2, true));
32
+ break;
33
+ case "SquareHole": {
34
+ const path = new Path();
35
+ const halfHole = vec2Scale(h.size, 0.5);
36
+ const min = vec2Sub(h.pos, halfHole);
37
+ const max = vec2Add(h.pos, halfHole);
38
+ path.moveTo(min.x, min.y).lineTo(min.x, max.y).lineTo(max.x, max.y).lineTo(max.x, min.y).closePath();
39
+ shape.holes.push(path);
40
+ break;
41
+ }
42
+ default:
43
+ exhaustiveCheck(h);
44
+ }
45
+ });
46
+ }
47
+
48
+ const rimPoint = (radius: number, y: number, angle: number): Vec3 => vec3(radius * Math.sin(angle), y, radius * Math.cos(angle));
49
+
50
+ /** Flat xyz positions of the two walls closing an angled cylinder, from the axis out to the rim at angleStart and angleEnd */
51
+ export function cylinderCutPositions(top: number, bottom: number, length: number, angleStart: number, angleLength: number): ReadonlyArray<number> {
52
+ const half = length / 2;
53
+ const [c0, c1] = [vec3(0, -half, 0), vec3(0, half, 0)];
54
+ const angleEnd = angleStart + angleLength;
55
+ const [b0, t0] = [rimPoint(bottom, -half, angleStart), rimPoint(top, half, angleStart)];
56
+ const [b1, t1] = [rimPoint(bottom, -half, angleEnd), rimPoint(top, half, angleEnd)];
57
+ // Unshared vertices so each wall gets its own flat normal, wound opposite so both face outwards
58
+ const triangles = [
59
+ [c0, b0, t0],
60
+ [c0, t0, c1],
61
+ [c0, c1, t1],
62
+ [c0, t1, b1],
63
+ ];
64
+ return triangles.flat().flatMap((v) => [v.x, v.y, v.z]);
65
+ }
66
+
67
+ // oxlint-disable-next-line functional/no-classes -- extends three.js Curve, which requires a class
68
+ export class CircleCurve extends Curve<Vector3> {
69
+ radius: number;
70
+ angleLength: number;
71
+ startAngle: number;
72
+
73
+ constructor(circleCurve: A3dCircleCurve) {
74
+ super();
75
+ this.radius = circleCurve.radius;
76
+ this.startAngle = circleCurve.angleStart;
77
+ this.angleLength = circleCurve.angleLength;
78
+ }
79
+
80
+ override getPoint(t: number, optionalTarget = new Vector3()): Vector3 {
81
+ return optionalTarget.set(
82
+ -this.radius * Math.sin(this.startAngle + this.angleLength * t),
83
+ -this.radius * Math.cos(this.startAngle + this.angleLength * t),
84
+ 0
85
+ );
86
+ }
87
+ }