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.
- package/README.md +1 -1
- package/lib/renderers/ifc/ifc-encoding.d.ts +21 -0
- package/lib/renderers/ifc/ifc-encoding.d.ts.map +1 -0
- package/lib/renderers/ifc/ifc-encoding.js +78 -0
- package/lib/renderers/ifc/ifc-encoding.js.map +1 -0
- package/lib/renderers/ifc/ifc-geometry.d.ts +14 -0
- package/lib/renderers/ifc/ifc-geometry.d.ts.map +1 -0
- package/lib/renderers/ifc/ifc-geometry.js +114 -0
- package/lib/renderers/ifc/ifc-geometry.js.map +1 -0
- package/lib/renderers/ifc/ifc.d.ts +26 -0
- package/lib/renderers/ifc/ifc.d.ts.map +1 -0
- package/lib/renderers/ifc/ifc.js +111 -0
- package/lib/renderers/ifc/ifc.js.map +1 -0
- package/lib/renderers/ifc/index.d.ts +2 -0
- package/lib/renderers/ifc/index.d.ts.map +1 -0
- package/lib/renderers/ifc/index.js +2 -0
- package/lib/renderers/ifc/index.js.map +1 -0
- package/lib/renderers/index.d.ts +1 -0
- package/lib/renderers/index.d.ts.map +1 -1
- package/lib/renderers/index.js +1 -0
- package/lib/renderers/index.js.map +1 -1
- package/lib/renderers/react/react-mesh.d.ts.map +1 -1
- package/lib/renderers/react/react-mesh.js +10 -75
- package/lib/renderers/react/react-mesh.js.map +1 -1
- package/lib/renderers/shared/three-geometry.d.ts +17 -0
- package/lib/renderers/shared/three-geometry.d.ts.map +1 -0
- package/lib/renderers/shared/three-geometry.js +73 -0
- package/lib/renderers/shared/three-geometry.js.map +1 -0
- package/package.json +8 -3
- package/src/renderers/ifc/ifc-encoding.ts +91 -0
- package/src/renderers/ifc/ifc-geometry.ts +149 -0
- package/src/renderers/ifc/ifc.test.ts +110 -0
- package/src/renderers/ifc/ifc.ts +167 -0
- package/src/renderers/ifc/index.ts +1 -0
- package/src/renderers/index.ts +1 -0
- package/src/renderers/react/react-mesh.tsx +10 -103
- 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";
|
package/src/renderers/index.ts
CHANGED
|
@@ -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 {
|
|
27
|
-
import
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|