abstract-3d 3.1.1 → 4.0.0-beta.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 (229) hide show
  1. package/README.md +21 -1
  2. package/lib/renderers/dxf/dxf-encoding/dxf-build.d.ts.map +1 -1
  3. package/lib/renderers/dxf/dxf-encoding/dxf-build.js +1 -2
  4. package/lib/renderers/dxf/dxf-encoding/dxf-build.js.map +1 -1
  5. package/lib/renderers/ifc/ifc.test.d.ts +2 -0
  6. package/lib/renderers/ifc/ifc.test.d.ts.map +1 -0
  7. package/lib/renderers/ifc/ifc.test.js +380 -0
  8. package/lib/renderers/ifc/ifc.test.js.map +1 -0
  9. package/lib/renderers/index.d.ts +1 -0
  10. package/lib/renderers/index.d.ts.map +1 -1
  11. package/lib/renderers/index.js +1 -0
  12. package/lib/renderers/index.js.map +1 -1
  13. package/lib/renderers/react/index.d.ts +2 -5
  14. package/lib/renderers/react/index.d.ts.map +1 -1
  15. package/lib/renderers/react/index.js +1 -2
  16. package/lib/renderers/react/index.js.map +1 -1
  17. package/lib/renderers/react/react.d.ts +16 -42
  18. package/lib/renderers/react/react.d.ts.map +1 -1
  19. package/lib/renderers/react/react.js +49 -32
  20. package/lib/renderers/react/react.js.map +1 -1
  21. package/lib/renderers/three/appearance/appearance.d.ts +41 -0
  22. package/lib/renderers/three/appearance/appearance.d.ts.map +1 -0
  23. package/lib/renderers/three/appearance/appearance.js +89 -0
  24. package/lib/renderers/three/appearance/appearance.js.map +1 -0
  25. package/lib/renderers/three/appearance/appearance.test.d.ts +2 -0
  26. package/lib/renderers/three/appearance/appearance.test.d.ts.map +1 -0
  27. package/lib/renderers/three/appearance/appearance.test.js +97 -0
  28. package/lib/renderers/three/appearance/appearance.test.js.map +1 -0
  29. package/lib/renderers/three/appearance.d.ts +41 -0
  30. package/lib/renderers/three/appearance.d.ts.map +1 -0
  31. package/lib/renderers/three/appearance.js +100 -0
  32. package/lib/renderers/three/appearance.js.map +1 -0
  33. package/lib/renderers/three/build/build.d.ts +51 -0
  34. package/lib/renderers/three/build/build.d.ts.map +1 -0
  35. package/lib/renderers/three/build/build.js +174 -0
  36. package/lib/renderers/three/build/build.js.map +1 -0
  37. package/lib/renderers/three/build/build.test.d.ts +2 -0
  38. package/lib/renderers/three/build/build.test.d.ts.map +1 -0
  39. package/lib/renderers/three/build/build.test.js +120 -0
  40. package/lib/renderers/three/build/build.test.js.map +1 -0
  41. package/lib/renderers/three/build/materials.d.ts +12 -0
  42. package/lib/renderers/three/build/materials.d.ts.map +1 -0
  43. package/lib/renderers/three/build/materials.js +55 -0
  44. package/lib/renderers/three/build/materials.js.map +1 -0
  45. package/lib/renderers/three/build/parts.d.ts +65 -0
  46. package/lib/renderers/three/build/parts.d.ts.map +1 -0
  47. package/lib/renderers/three/build/parts.js +203 -0
  48. package/lib/renderers/three/build/parts.js.map +1 -0
  49. package/lib/renderers/three/build.d.ts +50 -0
  50. package/lib/renderers/three/build.d.ts.map +1 -0
  51. package/lib/renderers/three/build.js +210 -0
  52. package/lib/renderers/three/build.js.map +1 -0
  53. package/lib/renderers/three/camera/camera.d.ts +40 -0
  54. package/lib/renderers/three/camera/camera.d.ts.map +1 -0
  55. package/lib/renderers/three/camera/camera.js +51 -0
  56. package/lib/renderers/three/camera/camera.js.map +1 -0
  57. package/lib/renderers/three/camera/camera.test.d.ts +2 -0
  58. package/lib/renderers/three/camera/camera.test.d.ts.map +1 -0
  59. package/lib/renderers/three/camera/camera.test.js +46 -0
  60. package/lib/renderers/three/camera/camera.test.js.map +1 -0
  61. package/lib/renderers/three/camera/controls.d.ts +24 -0
  62. package/lib/renderers/three/camera/controls.d.ts.map +1 -0
  63. package/lib/renderers/three/camera/controls.js +39 -0
  64. package/lib/renderers/three/camera/controls.js.map +1 -0
  65. package/lib/renderers/three/camera/gizmo.d.ts +44 -0
  66. package/lib/renderers/three/camera/gizmo.d.ts.map +1 -0
  67. package/lib/renderers/three/camera/gizmo.js +42 -0
  68. package/lib/renderers/three/camera/gizmo.js.map +1 -0
  69. package/lib/renderers/three/camera.d.ts +63 -0
  70. package/lib/renderers/three/camera.d.ts.map +1 -0
  71. package/lib/renderers/three/camera.js +85 -0
  72. package/lib/renderers/three/camera.js.map +1 -0
  73. package/lib/renderers/three/compile/compile.d.ts +59 -0
  74. package/lib/renderers/three/compile/compile.d.ts.map +1 -0
  75. package/lib/renderers/three/compile/compile.js +88 -0
  76. package/lib/renderers/three/compile/compile.js.map +1 -0
  77. package/lib/renderers/three/compile/compile.test.d.ts +2 -0
  78. package/lib/renderers/three/compile/compile.test.d.ts.map +1 -0
  79. package/lib/renderers/three/compile/compile.test.js +93 -0
  80. package/lib/renderers/three/compile/compile.test.js.map +1 -0
  81. package/lib/renderers/three/compile.d.ts +58 -0
  82. package/lib/renderers/three/compile.d.ts.map +1 -0
  83. package/lib/renderers/three/compile.js +89 -0
  84. package/lib/renderers/three/compile.js.map +1 -0
  85. package/lib/renderers/three/controls.d.ts +27 -0
  86. package/lib/renderers/three/controls.d.ts.map +1 -0
  87. package/lib/renderers/three/controls.js +115 -0
  88. package/lib/renderers/three/controls.js.map +1 -0
  89. package/lib/renderers/three/geometry/geometry.d.ts +21 -0
  90. package/lib/renderers/three/geometry/geometry.d.ts.map +1 -0
  91. package/lib/renderers/three/geometry/geometry.js +132 -0
  92. package/lib/renderers/three/geometry/geometry.js.map +1 -0
  93. package/lib/renderers/three/geometry/geometry.test.d.ts +2 -0
  94. package/lib/renderers/three/geometry/geometry.test.d.ts.map +1 -0
  95. package/lib/renderers/three/geometry/geometry.test.js +80 -0
  96. package/lib/renderers/three/geometry/geometry.test.js.map +1 -0
  97. package/lib/renderers/three/geometry.d.ts +20 -0
  98. package/lib/renderers/three/geometry.d.ts.map +1 -0
  99. package/lib/renderers/three/geometry.js +164 -0
  100. package/lib/renderers/three/geometry.js.map +1 -0
  101. package/lib/renderers/three/gizmo.d.ts +49 -0
  102. package/lib/renderers/three/gizmo.d.ts.map +1 -0
  103. package/lib/renderers/three/gizmo.js +172 -0
  104. package/lib/renderers/three/gizmo.js.map +1 -0
  105. package/lib/renderers/three/index.d.ts +16 -0
  106. package/lib/renderers/three/index.d.ts.map +1 -0
  107. package/lib/renderers/three/index.js +6 -0
  108. package/lib/renderers/three/index.js.map +1 -0
  109. package/lib/renderers/three/input.d.ts +25 -0
  110. package/lib/renderers/three/input.d.ts.map +1 -0
  111. package/lib/renderers/three/input.js +71 -0
  112. package/lib/renderers/three/input.js.map +1 -0
  113. package/lib/renderers/three/lines.d.ts +16 -0
  114. package/lib/renderers/three/lines.d.ts.map +1 -0
  115. package/lib/renderers/three/lines.js +63 -0
  116. package/lib/renderers/three/lines.js.map +1 -0
  117. package/lib/renderers/three/materials.d.ts +15 -0
  118. package/lib/renderers/three/materials.d.ts.map +1 -0
  119. package/lib/renderers/three/materials.js +70 -0
  120. package/lib/renderers/three/materials.js.map +1 -0
  121. package/lib/renderers/three/options.d.ts +68 -0
  122. package/lib/renderers/three/options.d.ts.map +1 -0
  123. package/lib/renderers/three/options.js +64 -0
  124. package/lib/renderers/three/options.js.map +1 -0
  125. package/lib/renderers/three/overlays.d.ts +25 -0
  126. package/lib/renderers/three/overlays.d.ts.map +1 -0
  127. package/lib/renderers/three/overlays.js +57 -0
  128. package/lib/renderers/three/overlays.js.map +1 -0
  129. package/lib/renderers/three/quality/quality.d.ts +37 -0
  130. package/lib/renderers/three/quality/quality.d.ts.map +1 -0
  131. package/lib/renderers/three/quality/quality.js +64 -0
  132. package/lib/renderers/three/quality/quality.js.map +1 -0
  133. package/lib/renderers/three/quality/quality.test.d.ts +2 -0
  134. package/lib/renderers/three/quality/quality.test.d.ts.map +1 -0
  135. package/lib/renderers/three/quality/quality.test.js +23 -0
  136. package/lib/renderers/three/quality/quality.test.js.map +1 -0
  137. package/lib/renderers/three/quality.d.ts +24 -0
  138. package/lib/renderers/three/quality.d.ts.map +1 -0
  139. package/lib/renderers/three/quality.js +67 -0
  140. package/lib/renderers/three/quality.js.map +1 -0
  141. package/lib/renderers/three/text/text.d.ts +55 -0
  142. package/lib/renderers/three/text/text.d.ts.map +1 -0
  143. package/lib/renderers/three/text/text.js +90 -0
  144. package/lib/renderers/three/text/text.js.map +1 -0
  145. package/lib/renderers/three/text/text.test.d.ts +2 -0
  146. package/lib/renderers/three/text/text.test.d.ts.map +1 -0
  147. package/lib/renderers/three/text/text.test.js +70 -0
  148. package/lib/renderers/three/text/text.test.js.map +1 -0
  149. package/lib/renderers/three/text.d.ts +53 -0
  150. package/lib/renderers/three/text.d.ts.map +1 -0
  151. package/lib/renderers/three/text.js +151 -0
  152. package/lib/renderers/three/text.js.map +1 -0
  153. package/lib/renderers/three/viewer/options.d.ts +71 -0
  154. package/lib/renderers/three/viewer/options.d.ts.map +1 -0
  155. package/lib/renderers/three/viewer/options.js +31 -0
  156. package/lib/renderers/three/viewer/options.js.map +1 -0
  157. package/lib/renderers/three/viewer/options.test.d.ts +2 -0
  158. package/lib/renderers/three/viewer/options.test.d.ts.map +1 -0
  159. package/lib/renderers/three/viewer/options.test.js +38 -0
  160. package/lib/renderers/three/viewer/options.test.js.map +1 -0
  161. package/lib/renderers/three/viewer/overlays.d.ts +31 -0
  162. package/lib/renderers/three/viewer/overlays.d.ts.map +1 -0
  163. package/lib/renderers/three/viewer/overlays.js +43 -0
  164. package/lib/renderers/three/viewer/overlays.js.map +1 -0
  165. package/lib/renderers/three/viewer/viewer.d.ts +90 -0
  166. package/lib/renderers/three/viewer/viewer.d.ts.map +1 -0
  167. package/lib/renderers/three/viewer/viewer.js +432 -0
  168. package/lib/renderers/three/viewer/viewer.js.map +1 -0
  169. package/lib/renderers/three/viewer.d.ts +38 -0
  170. package/lib/renderers/three/viewer.d.ts.map +1 -0
  171. package/lib/renderers/three/viewer.js +435 -0
  172. package/lib/renderers/three/viewer.js.map +1 -0
  173. package/package.json +26 -9
  174. package/src/abstract-3d.ts +1 -1
  175. package/src/custom_typings/n8ao.d.ts +11 -0
  176. package/src/renderers/dxf/dxf-encoding/dxf-build.ts +1 -2
  177. package/src/renderers/index.ts +1 -0
  178. package/src/renderers/react/index.ts +2 -5
  179. package/src/renderers/react/react.tsx +96 -181
  180. package/src/renderers/three/appearance/appearance.test.ts +116 -0
  181. package/src/renderers/three/appearance/appearance.ts +132 -0
  182. package/src/renderers/three/build/build.test.ts +141 -0
  183. package/src/renderers/three/build/build.ts +270 -0
  184. package/src/renderers/three/build/materials.ts +85 -0
  185. package/src/renderers/three/build/parts.ts +277 -0
  186. package/src/renderers/three/camera/camera.test.ts +53 -0
  187. package/src/renderers/three/camera/camera.ts +82 -0
  188. package/src/renderers/three/camera/controls.ts +65 -0
  189. package/src/renderers/three/camera/gizmo.ts +93 -0
  190. package/src/renderers/three/compile/compile.test.ts +113 -0
  191. package/src/renderers/three/compile/compile.ts +151 -0
  192. package/src/renderers/three/geometry/geometry.test.ts +95 -0
  193. package/src/renderers/three/geometry/geometry.ts +179 -0
  194. package/src/renderers/three/index.ts +15 -0
  195. package/src/renderers/three/quality/quality.test.ts +30 -0
  196. package/src/renderers/three/quality/quality.ts +95 -0
  197. package/src/renderers/three/text/text.test.ts +79 -0
  198. package/src/renderers/three/text/text.ts +128 -0
  199. package/src/renderers/three/viewer/options.ts +98 -0
  200. package/src/renderers/three/viewer/overlays.ts +71 -0
  201. package/src/renderers/three/viewer/viewer.ts +540 -0
  202. package/lib/renderers/dxf/color.d.ts +0 -7
  203. package/lib/renderers/dxf/color.d.ts.map +0 -1
  204. package/lib/renderers/dxf/color.js +0 -311
  205. package/lib/renderers/dxf/color.js.map +0 -1
  206. package/lib/renderers/dxf/dxf-encoding.d.ts +0 -17
  207. package/lib/renderers/dxf/dxf-encoding.d.ts.map +0 -1
  208. package/lib/renderers/dxf/dxf-encoding.js +0 -18467
  209. package/lib/renderers/dxf/dxf-encoding.js.map +0 -1
  210. package/lib/renderers/react/react-hotspot.d.ts +0 -27
  211. package/lib/renderers/react/react-hotspot.d.ts.map +0 -1
  212. package/lib/renderers/react/react-hotspot.js +0 -37
  213. package/lib/renderers/react/react-hotspot.js.map +0 -1
  214. package/lib/renderers/shared.d.ts +0 -30
  215. package/lib/renderers/shared.d.ts.map +0 -1
  216. package/lib/renderers/shared.js +0 -164
  217. package/lib/renderers/shared.js.map +0 -1
  218. package/src/custom_typings/react-three-fiber.d.ts +0 -9
  219. package/src/custom_typings/react-three-postprocessing.d.ts +0 -1
  220. package/src/renderers/react/react-camera.tsx +0 -391
  221. package/src/renderers/react/react-dimension.tsx +0 -122
  222. package/src/renderers/react/react-group.test.ts +0 -56
  223. package/src/renderers/react/react-group.tsx +0 -202
  224. package/src/renderers/react/react-image-material.tsx +0 -139
  225. package/src/renderers/react/react-material.tsx +0 -91
  226. package/src/renderers/react/react-mesh.tsx +0 -512
  227. package/src/renderers/react/react-relation.tsx +0 -86
  228. package/src/renderers/react/react-scene.tsx +0 -111
  229. package/src/renderers/react/react-types.ts +0 -9
@@ -0,0 +1,113 @@
1
+ import { Matrix4, Vector3 } from "three";
2
+ import { describe, expect, it } from "vitest";
3
+ import type { Group, Scene } from "../../../abstract-3d.js";
4
+ import { alignedDimension, box, group, line, sphere, text, vec3, vec3Zero } from "../../../abstract-3d.js";
5
+ import { compileScene, getItemMatrix } from "./compile.js";
6
+
7
+ const red = { normal: "rgb(255, 0, 0)" };
8
+ const scene = (groups: ReadonlyArray<Group>, rest: Partial<Scene> = {}): Scene => ({ size_deprecated: vec3(1, 1, 1), groups, ...rest });
9
+ const apply = (m: Matrix4, x: number, y: number, z: number): ReadonlyArray<number> =>
10
+ new Vector3(x, y, z)
11
+ .applyMatrix4(m)
12
+ .toArray()
13
+ .map((v) => Math.round(v * 1000) / 1000 + 0);
14
+
15
+ describe("compileScene", () => {
16
+ it("flattens nested groups into items with world matrices", () => {
17
+ const inner = group([sphere(1, red)], vec3(0, 10, 0));
18
+ const compiled = compileScene(scene([group([box(vec3(1, 1, 1), red)], vec3(100, 0, 0), vec3Zero, [inner])]));
19
+ expect(compiled.items.map((i) => i.role)).toEqual(["solid", "solid"]);
20
+ expect(apply(compiled.items[0].matrix, 0, 0, 0)).toEqual([100, 0, 0]);
21
+ expect(apply(compiled.items[1].matrix, 0, 0, 0)).toEqual([100, 10, 0]);
22
+ });
23
+
24
+ it("applies group rotation before the group's position", () => {
25
+ const compiled = compileScene(scene([group([box(vec3(1, 1, 1), red)], vec3(5, 0, 0), vec3(0, 0, Math.PI / 2))]));
26
+ expect(apply(compiled.items[0].matrix, 1, 0, 0)).toEqual([5, 1, 0]);
27
+ });
28
+
29
+ it("rotates the scene by its deprecated rotation, then moves it by minus its deprecated center", () => {
30
+ const g = group([box(vec3(1, 1, 1), red)], vec3(10, 0, 0));
31
+ const centered = compileScene(scene([g], { center_deprecated: vec3(10, 0, 0) }));
32
+ expect(apply(centered.items[0].matrix, 0, 0, 0)).toEqual([0, 0, 0]);
33
+ const rotated = compileScene(scene([g], { center_deprecated: vec3(-10, 0, 0), rotation_deprecated: vec3(0, Math.PI, 0) }));
34
+ expect(apply(rotated.items[0].matrix, 1, 0, 0)).toEqual([-1, 0, 0]);
35
+ });
36
+
37
+ it("gives every item of a top-level group that group's target, and the data of the group it's in", () => {
38
+ const inner = group([sphere(1, red)], vec3Zero, vec3Zero, [], { part: "motor" });
39
+ const fan = group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [inner], { id: "fan" });
40
+ const compiled = compileScene(scene([fan, group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [], { id: "coil" })]), (g) => g.data?.id ?? "");
41
+ expect(compiled.targets).toEqual([
42
+ { kind: "group", id: "fan", root: 0, rootData: { id: "fan" } },
43
+ { kind: "group", id: "coil", root: 1, rootData: { id: "coil" } },
44
+ ]);
45
+ expect(compiled.items.map((i) => [i.target, i.data])).toEqual([
46
+ [0, { id: "fan" }],
47
+ [0, { part: "motor" }],
48
+ [1, { id: "coil" }],
49
+ ]);
50
+ });
51
+
52
+ it("gives groups an empty id without createGroupId", () => {
53
+ const compiled = compileScene(scene([group([box(vec3(1, 1, 1), red)])]));
54
+ expect(compiled.targets[0]).toMatchObject({ kind: "group", id: "" });
55
+ });
56
+
57
+ it("assigns roles by geometry type", () => {
58
+ const g = group([box(vec3(1, 1, 1), red), text(vec3Zero, "A", 10, red), line(vec3Zero, vec3(1, 0, 0), 1, red)]);
59
+ expect(compileScene(scene([g])).items.map((i) => i.role)).toEqual(["solid", "text", "line"]);
60
+ });
61
+
62
+ it("makes items of animated groups relative to an animation that rotates the group", () => {
63
+ const fan = group(
64
+ [box(vec3(1, 1, 1), red)],
65
+ vec3(0, 5, 0),
66
+ vec3Zero,
67
+ [],
68
+ {},
69
+ { transform: { rot: vec3(0, 0, Math.PI), trans: vec3Zero }, duration: 1000 }
70
+ );
71
+ const compiled = compileScene(scene([group([], vec3(10, 0, 0), vec3Zero, [fan])]));
72
+ expect(compiled.animations).toHaveLength(1);
73
+ expect(compiled.animations[0].speed).toEqual(vec3(0, 0, Math.PI));
74
+ expect(compiled.items[0].animation).toBe(0);
75
+ const world = (seconds: number): ReadonlyArray<number> => apply(getItemMatrix(compiled, compiled.items[0], seconds, new Matrix4()), 1, 0, 0);
76
+ expect(world(0)).toEqual([11, 5, 0]);
77
+ expect(world(0.5)).toEqual([10, 6, 0]);
78
+ });
79
+
80
+ it("chains nested animations", () => {
81
+ const spin = { transform: { rot: vec3(0, 0, Math.PI), trans: vec3Zero }, duration: 1000 };
82
+ const inner = group([box(vec3(1, 1, 1), red)], vec3(1, 0, 0), vec3Zero, [], {}, spin);
83
+ const compiled = compileScene(scene([group([], vec3Zero, vec3Zero, [inner], {}, spin)]));
84
+ expect(compiled.animations.map((a) => a.parent)).toEqual([undefined, 0]);
85
+ expect(apply(getItemMatrix(compiled, compiled.items[0], 0.5, new Matrix4()), 0, 0, 0)).toEqual([0, 1, 0]);
86
+ expect(apply(getItemMatrix(compiled, compiled.items[0], 0.5, new Matrix4()), 1, 0, 0)).toEqual([-1, 1, 0]);
87
+ });
88
+
89
+ it("converts aligned dimensions to dimension items with a target per dimension", () => {
90
+ const dimension = alignedDimension(vec3Zero, vec3(1000, 0, 0), vec3(0, -100, 0), "1000", ["front"], undefined, "fan");
91
+ const compiled = compileScene(scene([], { dimensions_deprecated: { dimensions: [dimension], material: red } }));
92
+ const items = compiled.items.filter((i) => i.style === "dimension");
93
+ expect(items.length).toBeGreaterThan(0);
94
+ expect(new Set(items.map((i) => i.role))).toEqual(new Set(["line", "text", "solid"]));
95
+ expect(compiled.targets[items[0].target]).toEqual({ kind: "dimension", id: "fan" });
96
+ });
97
+
98
+ it("adds relation shapes, with an outline around their boxes", () => {
99
+ const relation = { id: "r1", pos: vec3Zero, groups: [group([box(vec3(10, 10, 10), red), sphere(1, red)], vec3(5, 0, 0))] };
100
+ const compiled = compileScene(scene([], { relations: [relation] }));
101
+ expect(compiled.items.map((i) => i.style)).toEqual(["relation", "outline", "relation"]);
102
+ expect(compiled.targets[compiled.items[0].target]).toEqual({ kind: "relation", relation });
103
+ const outline = compiled.items[1].mesh.geometry;
104
+ expect(outline.type === "Box" && outline.size).toEqual(vec3(10.125, 10.125, 10.125));
105
+ expect(apply(compiled.items[0].matrix, 0, 0, 0)).toEqual([5, 0, 0]);
106
+ });
107
+
108
+ it("keeps the world matrix of each top-level group for its tooltips", () => {
109
+ const compiled = compileScene(scene([group([], vec3(3, 0, 0))], { center_deprecated: vec3(1, 0, 0) }));
110
+ expect(apply(compiled.roots[0], 0, 0, 0)).toEqual([2, 0, 0]);
111
+ expect(apply(compiled.matrix, 0, 0, 0)).toEqual([-1, 0, 0]);
112
+ });
113
+ });
@@ -0,0 +1,151 @@
1
+ import { Euler, Matrix4, Quaternion, Vector3 } from "three";
2
+ import type { Group, Mesh, Relation, Scene, Vec3 } from "../../../abstract-3d.js";
3
+ import { dimensionConvertToTypeMesh, vec3, vec3Scale, vec3Zero } from "../../../abstract-3d.js";
4
+
5
+ /**
6
+ * Scene → flat items with world matrices, with no GPU objects, so everything here is testable in Node; groups transform as the r3f renderer's
7
+ * react-group.tsx
8
+ */
9
+
10
+ /** How the item's geometry is drawn */
11
+ export type Role = "solid" | "text" | "line" | "image";
12
+ /** What the item belongs to, which decides its material and whether it's shown */
13
+ export type Style = "group" | "dimension" | "relation" | "outline";
14
+
15
+ /** What a pointer event on an item is about, and what hover and selection apply to */
16
+ export type Target =
17
+ | { readonly kind: "group"; readonly id: string; readonly root: number; readonly rootData: Record<string, string> | undefined }
18
+ | { readonly kind: "dimension"; readonly id: string | undefined }
19
+ | { readonly kind: "relation"; readonly relation: Relation };
20
+
21
+ export type Item = {
22
+ readonly mesh: Mesh;
23
+ readonly role: Role;
24
+ readonly style: Style;
25
+ /** World matrix, or for an animated item its matrix relative to its animation's group */
26
+ readonly matrix: Matrix4;
27
+ readonly target: number;
28
+ /** The data of the group the mesh is in */
29
+ readonly data: Record<string, string> | undefined;
30
+ readonly animation: number | undefined;
31
+ };
32
+
33
+ export type Animation = {
34
+ readonly parent: number | undefined;
35
+ /** The matrix of the group's parent, in world space or relative to the parent animation's group */
36
+ readonly base: Matrix4;
37
+ readonly pos: Vec3;
38
+ readonly rot: Vec3;
39
+ /** Radians per second around x, y and z */
40
+ readonly speed: Vec3;
41
+ };
42
+
43
+ export type CompiledScene = {
44
+ readonly items: ReadonlyArray<Item>;
45
+ readonly targets: ReadonlyArray<Target>;
46
+ readonly animations: ReadonlyArray<Animation>;
47
+ /** World matrix of the scene's root, from its deprecated center and rotation */
48
+ readonly matrix: Matrix4;
49
+ /** World matrix of each top-level group */
50
+ readonly roots: ReadonlyArray<Matrix4>;
51
+ };
52
+
53
+ type Output = { readonly items: Array<Item>; readonly animations: Array<Animation> };
54
+ type ItemBase = Pick<Item, "style" | "target" | "data" | "animation">;
55
+
56
+ const outlineScale = 1.0125;
57
+
58
+ /** Targets are the top-level groups, then the dimensions, then the relations */
59
+ export function compileScene(scene: Scene, createGroupId?: (g: Group) => string): CompiledScene {
60
+ const out: Output = { items: [], animations: [] };
61
+ const center = scene.center_deprecated ?? vec3Zero;
62
+ const matrix = new Matrix4().makeTranslation(-center.x, -center.y, -center.z).multiply(transform(vec3Zero, scene.rotation_deprecated));
63
+ const dimensions = scene.dimensions_deprecated?.dimensions ?? [];
64
+ const relations = scene.relations ?? [];
65
+ const targets: ReadonlyArray<Target> = [
66
+ ...scene.groups.map((g, root): Target => ({ kind: "group", id: createGroupId?.(g) ?? "", root, rootData: g.data })),
67
+ ...dimensions.map((d): Target => ({ kind: "dimension", id: d.id })),
68
+ ...relations.map((relation): Target => ({ kind: "relation", relation })),
69
+ ];
70
+ scene.groups.forEach((g, i) => addGroup(out, g, matrix, undefined, "group", i));
71
+ dimensions.forEach((dimension, i) => {
72
+ const mesh = dimensionConvertToTypeMesh(dimension, scene.rotation_deprecated ?? vec3Zero, scene.dimensions_deprecated?.material);
73
+ const base = { style: "dimension", target: scene.groups.length + i, data: undefined, animation: undefined } as const;
74
+ addMeshes(out, mesh.meshes, matrix.clone().multiply(transform(mesh.pos, mesh.rot)), base);
75
+ });
76
+ relations.forEach((relation, i) =>
77
+ relation.groups?.forEach((g) => addGroup(out, g, matrix, undefined, "relation", scene.groups.length + dimensions.length + i))
78
+ );
79
+ return { ...out, targets, matrix, roots: scene.groups.map((g) => matrix.clone().multiply(transform(g.pos, g.rot))) };
80
+ }
81
+
82
+ function addGroup(out: Output, g: Group, parent: Matrix4, parentAnimation: number | undefined, style: "group" | "relation", target: number): void {
83
+ const animation = g.animation ? out.animations.push(createAnimation(g, parent, parentAnimation)) - 1 : parentAnimation;
84
+ // An animated group's items are relative to it, so its animation can move them
85
+ const matrix = g.animation ? new Matrix4() : parent.clone().multiply(transform(g.pos, g.rot));
86
+ addMeshes(out, g.meshes ?? [], matrix, { style, target, data: g.data, animation });
87
+ g.groups?.forEach((child) => addGroup(out, child, matrix, animation, style, target));
88
+ }
89
+
90
+ function addMeshes(out: Output, meshes: ReadonlyArray<Mesh>, matrix: Matrix4, base: ItemBase): void {
91
+ meshes.forEach((mesh) => {
92
+ out.items.push({ ...base, mesh, role: getRole(mesh), matrix });
93
+ if (base.style === "relation" && mesh.geometry.type === "Box") {
94
+ const outline = { ...mesh, geometry: { ...mesh.geometry, size: vec3Scale(mesh.geometry.size, outlineScale) } };
95
+ out.items.push({ ...base, style: "outline", mesh: outline, role: "solid", matrix });
96
+ }
97
+ });
98
+ }
99
+
100
+ /** The item's world matrix at `seconds` into its animations, written to `out` */
101
+ export function getItemMatrix(compiled: CompiledScene, item: Item, seconds: number, out: Matrix4): Matrix4 {
102
+ return item.animation === undefined
103
+ ? out.copy(item.matrix)
104
+ : getAnimationMatrix(compiled.animations, item.animation, seconds, out).multiply(item.matrix);
105
+ }
106
+
107
+ function getAnimationMatrix(animations: ReadonlyArray<Animation>, index: number, seconds: number, out: Matrix4): Matrix4 {
108
+ const a = animations[index]!;
109
+ const rot = vec3(a.rot.x + a.speed.x * seconds, a.rot.y + a.speed.y * seconds, a.rot.z + a.speed.z * seconds);
110
+ out.copy(a.base).multiply(transform(a.pos, rot, scratch));
111
+ return a.parent === undefined ? out : out.premultiply(getAnimationMatrix(animations, a.parent, seconds, new Matrix4()));
112
+ }
113
+
114
+ /** The r3f renderer turned an animated group by its rotation per duration in milliseconds */
115
+ function createAnimation(g: Group, parent: Matrix4, parentAnimation: number | undefined): Animation {
116
+ const { rot } = g.animation!.transform;
117
+ const perSecond = 1000 / g.animation!.duration;
118
+ return {
119
+ parent: parentAnimation,
120
+ base: parent.clone(),
121
+ pos: g.pos,
122
+ rot: g.rot ?? vec3Zero,
123
+ speed: vec3(rot.x * perSecond, rot.y * perSecond, rot.z * perSecond),
124
+ };
125
+ }
126
+
127
+ function getRole(mesh: Mesh): Role {
128
+ switch (mesh.geometry.type) {
129
+ case "Text":
130
+ return "text";
131
+ case "Line":
132
+ case "CulledLine":
133
+ return "line";
134
+ case "Image":
135
+ return "image";
136
+ default:
137
+ return "solid";
138
+ }
139
+ }
140
+
141
+ const scratch = new Matrix4();
142
+ const euler = new Euler();
143
+ const quaternion = new Quaternion();
144
+ const position = new Vector3();
145
+ const scale = new Vector3();
146
+
147
+ /** Translation, then XYZ Euler rotation, then scale, as on a three.js Object3D */
148
+ export function transform(pos: Vec3, rot: Vec3 | undefined, out = new Matrix4(), size?: Vec3): Matrix4 {
149
+ quaternion.setFromEuler(euler.set(rot?.x ?? 0, rot?.y ?? 0, rot?.z ?? 0));
150
+ return out.compose(position.set(pos.x, pos.y, pos.z), quaternion, scale.set(size?.x ?? 1, size?.y ?? 1, size?.z ?? 1));
151
+ }
@@ -0,0 +1,95 @@
1
+ import { type BufferGeometry, Vector3 } from "three";
2
+ import { describe, expect, it } from "vitest";
3
+ import type { Mesh } from "../../../abstract-3d.js";
4
+ import { box, circleCurve, cone, cylinder, plane, polygon, roundHole, shape, sphere, tube, vec2, vec3, vec3Zero } from "../../../abstract-3d.js";
5
+ import { createSolidGeometry, getSolidShape, keepGeometries } from "./geometry.js";
6
+
7
+ const m = { normal: "rgb(0,0,0)" };
8
+ const local = (mesh: Mesh, x: number, y: number, z: number): ReadonlyArray<number> =>
9
+ new Vector3(x, y, z)
10
+ .applyMatrix4(getSolidShape(mesh.geometry)!.local)
11
+ .toArray()
12
+ .map((v) => Math.round(v * 1000) / 1000 + 0);
13
+ const key = (mesh: Mesh): string => getSolidShape(mesh.geometry)!.key;
14
+ const bounds = (mesh: Mesh): ReadonlyArray<number> => {
15
+ const geometry = createSolidGeometry(mesh.geometry);
16
+ geometry.computeBoundingBox();
17
+ return [...geometry.boundingBox!.min.toArray(), ...geometry.boundingBox!.max.toArray()].map((v) => Math.round(v * 1000) / 1000 + 0);
18
+ };
19
+
20
+ describe("getSolidShape", () => {
21
+ it("shares unit geometries between primitives of any size, scaled by the local matrix", () => {
22
+ expect(key(box(vec3(1, 2, 3), m))).toBe(key(box(vec3(4, 5, 6), m)));
23
+ expect(local(box(vec3(2, 4, 6), m, vec3(10, 0, 0)), 0.5, 0.5, 0.5)).toEqual([11, 2, 3]);
24
+ expect(local(plane(vec3Zero, vec2(2, 4), m), 0.5, 0.5, 0)).toEqual([1, 2, 0]);
25
+ expect(local(sphere(3, m, vec3(1, 0, 0)), 1, 0, 0)).toEqual([4, 0, 0]);
26
+ expect(local(cone(vec3Zero, 2, 10, m), 1, 0.5, 0)).toEqual([2, 5, 0]);
27
+ });
28
+
29
+ it("scales cylinders by their largest radius, with a geometry per radius ratio", () => {
30
+ expect(key(cylinder(vec3Zero, 1, 2, m))).toBe(key(cylinder(vec3Zero, 5, 7, m)));
31
+ expect(key(cylinder(vec3Zero, 1, 2, m))).not.toBe(key(cylinder(vec3Zero, 1, 2, m, vec3Zero, false, [], 0, Math.PI * 2, 2)));
32
+ expect(local(cylinder(vec3Zero, 1, 10, m, vec3Zero, false, [], 0, Math.PI * 2, 4), 1, 0.5, 0)).toEqual([4, 5, 0]);
33
+ });
34
+
35
+ it("shares geometries between identical shapes", () => {
36
+ const triangle = polygon([vec3(0, 0, 0), vec3(1, 0, 0), vec3(1, 1, 0)], m);
37
+ expect(key(triangle)).toBe(key(polygon([vec3(0, 0, 0), vec3(1, 0, 0), vec3(1, 1, 0)], m)));
38
+ });
39
+
40
+ it("ignores zero holes", () => {
41
+ expect(key(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [roundHole(vec2(0, 0), 0)]))).toBe(key(box(vec3(2, 2, 2), m)));
42
+ });
43
+
44
+ it("extrudes primitives with holes into their own geometry, centered", () => {
45
+ const holed = box(vec3(10, 10, 2), m, vec3(1, 0, 0), vec3Zero, [roundHole(vec2(0, 0), 2)]);
46
+ expect(key(holed)).not.toBe(key(box(vec3(10, 10, 2), m)));
47
+ expect(local(holed, 0, 0, 0)).toEqual([1, 0, 0]);
48
+ expect(bounds(holed)).toEqual([-5, -5, -1, 5, 5, 1]);
49
+ expect(bounds(cylinder(vec3Zero, 2, 10, m, vec3Zero, false, [roundHole(vec2(0, 0), 1)]))).toEqual([-2, -5, -2, 2, 5, 2]);
50
+ });
51
+ });
52
+
53
+ describe("createSolidGeometry", () => {
54
+ it("closes partial cylinders with walls", () => {
55
+ const whole = createSolidGeometry(cylinder(vec3Zero, 1, 1, m).geometry);
56
+ const half = createSolidGeometry(cylinder(vec3Zero, 1, 1, m, vec3Zero, false, [], 0, Math.PI).geometry);
57
+ expect(half.getAttribute("position").count).toBeGreaterThan(whole.getAttribute("position").count / 2);
58
+ });
59
+
60
+ it("draws 4-point polygons as a quad and other polygons as a triangle list", () => {
61
+ expect(createSolidGeometry(polygon([vec3(0, 0, 0), vec3(1, 0, 0), vec3(1, 1, 0), vec3(0, 1, 0)], m).geometry).index!.count).toBe(6);
62
+ expect(createSolidGeometry(polygon([vec3(0, 0, 0), vec3(1, 0, 0), vec3(1, 1, 0)], m).geometry).index!.count).toBe(3);
63
+ });
64
+
65
+ it("gives every geometry an index, normals and uvs, so they can share a batch", () => {
66
+ const meshes = [
67
+ box(vec3(1, 1, 1), m),
68
+ box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [roundHole(vec2(0, 0), 0.1)]),
69
+ polygon([vec3(0, 0, 0), vec3(1, 0, 0), vec3(1, 1, 0)], m),
70
+ cylinder(vec3Zero, 1, 1, m, vec3Zero, false, [], 0, Math.PI),
71
+ shape([vec2(0, 0), vec2(1, 0), vec2(0, 1)], vec3Zero, m, 1),
72
+ tube(circleCurve(10, 0, Math.PI), 1, m),
73
+ ];
74
+ meshes.forEach((mesh) => {
75
+ const geometry = createSolidGeometry(mesh.geometry);
76
+ expect(geometry.index!.count).toBeGreaterThan(0);
77
+ expect(Object.keys(geometry.attributes).sort()).toEqual(["normal", "position", "uv"]);
78
+ });
79
+ });
80
+ });
81
+
82
+ describe("keepGeometries", () => {
83
+ it("disposes the geometries a build no longer uses", () => {
84
+ const [kept, unused] = [createSolidGeometry(box(vec3(1, 1, 1), m).geometry), createSolidGeometry(sphere(1, m).geometry)];
85
+ const cache = new Map<string, BufferGeometry>([
86
+ ["Box", kept],
87
+ ["Sphere", unused],
88
+ ]);
89
+ const disposed: Array<string> = [];
90
+ unused.addEventListener("dispose", () => disposed.push("Sphere"));
91
+ keepGeometries(cache, new Set(["Box"]));
92
+ expect(disposed).toEqual(["Sphere"]);
93
+ expect([...cache.keys()]).toEqual(["Box"]);
94
+ });
95
+ });
@@ -0,0 +1,179 @@
1
+ import {
2
+ BoxGeometry,
3
+ BufferGeometry,
4
+ CatmullRomCurve3,
5
+ ConeGeometry,
6
+ CylinderGeometry,
7
+ ExtrudeGeometry,
8
+ Float32BufferAttribute,
9
+ type Matrix4,
10
+ PlaneGeometry,
11
+ Shape,
12
+ SphereGeometry,
13
+ TubeGeometry,
14
+ Vector2,
15
+ Vector3,
16
+ } from "three";
17
+ import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";
18
+ import type { Hole, Mesh } from "../../../abstract-3d.js";
19
+ import { isZero, vec3, vec3Zero } from "../../../abstract-3d.js";
20
+ import { addHoles, CircleCurve, CYLINDER_SEGMENTS, cylinderCutPositions, holeIsZero } from "../../shared/three-geometry.js";
21
+ import { transform } from "../compile/compile.js";
22
+
23
+ /**
24
+ * Solid geometries by key, so identical shapes share one geometry; primitives are unit geometries scaled by their local matrix. The shapes, holes and
25
+ * radius-ratio cylinders are the r3f renderer's react-mesh.tsx
26
+ */
27
+
28
+ export type SolidShape = {
29
+ readonly key: string;
30
+ /** Position, rotation and, for unit geometries, scale, relative to the item's matrix */
31
+ readonly local: Matrix4;
32
+ };
33
+
34
+ /**
35
+ * The shape of a solid geometry, undefined for text and lines; images are unit planes. Shapes with their own geometry are keyed by JSON of what
36
+ * identifies them: cheap next to the geometry creation that sharing skips (measured: unique keys built systemair3 2–4 times slower)
37
+ */
38
+ export function getSolidShape(g: Mesh["geometry"]): SolidShape | undefined {
39
+ switch (g.type) {
40
+ case "Box":
41
+ case "Plane": {
42
+ const size = g.type === "Box" ? g.size : vec3(g.size.x, g.size.y, 1);
43
+ return g.holes?.some((h) => !holeIsZero(h))
44
+ ? { key: JSON.stringify([g.type, g.size, g.holes]), local: transform(g.pos, g.rot) }
45
+ : { key: g.type, local: transform(g.pos, g.rot, undefined, size) };
46
+ }
47
+ case "Image":
48
+ return { key: "Plane", local: transform(g.pos, g.rot, undefined, vec3(g.size.x, g.size.y, 1)) };
49
+ case "Sphere":
50
+ return { key: g.type, local: transform(g.pos, vec3Zero, undefined, vec3(g.radius, g.radius, g.radius)) };
51
+ case "Cone":
52
+ return { key: g.type, local: transform(g.pos, g.rot, undefined, vec3(g.radius, g.length, g.radius)) };
53
+ case "Cylinder": {
54
+ const { radius, radiusEnd = radius, open = false, angleStart = 0, angleLength = Math.PI * 2 } = g;
55
+ if (radiusEnd === radius && isZero(Math.PI * 2 - angleLength) && g.holes?.some((h) => !holeIsZero(h))) {
56
+ return { key: JSON.stringify([g.type, radius, g.length, g.holes]), local: transform(g.pos, g.rot) };
57
+ }
58
+ // Unit geometry scaled by the largest radius, so only the radius ratio needs its own geometry
59
+ const max = Math.max(radius, radiusEnd) || 1;
60
+ return {
61
+ key: `${g.type} ${radiusEnd / max} ${radius / max} ${open} ${angleStart} ${angleLength}`,
62
+ local: transform(g.pos, g.rot, undefined, vec3(max, g.length, max)),
63
+ };
64
+ }
65
+ case "Shape":
66
+ return { key: JSON.stringify([g.type, g.points, g.thickness, g.holes]), local: transform(g.pos, g.rot) };
67
+ case "Polygon":
68
+ return { key: JSON.stringify([g.type, g.points]), local: transform(g.pos, g.rot) };
69
+ case "Tube":
70
+ return { key: JSON.stringify([g.type, g.curve, g.radius]), local: transform(g.pos, g.rot) };
71
+ default:
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ /** The geometry of a solid shape, indexed, with exactly position, normal and uv attributes and no groups, as a BatchedMesh needs */
77
+ export function createSolidGeometry(g: Mesh["geometry"]): BufferGeometry {
78
+ const geometry = createGeometry(g);
79
+ const count = geometry.getAttribute("position").count;
80
+ if (!geometry.index) {
81
+ geometry.setIndex(Array.from({ length: count }, (_, i) => i));
82
+ }
83
+ if (!geometry.hasAttribute("normal")) {
84
+ geometry.computeVertexNormals();
85
+ }
86
+ if (!geometry.hasAttribute("uv")) {
87
+ geometry.setAttribute("uv", new Float32BufferAttribute(new Float32Array(count * 2), 2));
88
+ }
89
+ Object.keys(geometry.attributes).forEach((name) => {
90
+ if (name !== "position" && name !== "normal" && name !== "uv") {
91
+ geometry.deleteAttribute(name);
92
+ }
93
+ });
94
+ geometry.clearGroups();
95
+ return geometry;
96
+ }
97
+
98
+ function createGeometry(g: Mesh["geometry"]): BufferGeometry {
99
+ switch (g.type) {
100
+ case "Box":
101
+ case "Plane": {
102
+ if (!g.holes?.some((h) => !holeIsZero(h))) {
103
+ return g.type === "Box" ? new BoxGeometry() : new PlaneGeometry();
104
+ }
105
+ const [x, y] = [g.size.x / 2, g.size.y / 2];
106
+ return extrude(
107
+ new Shape([new Vector2(-x, -y), new Vector2(-x, y), new Vector2(x, y), new Vector2(x, -y)]),
108
+ g.holes,
109
+ g.type === "Box" ? g.size.z : 0
110
+ );
111
+ }
112
+ case "Image":
113
+ return new PlaneGeometry();
114
+ case "Sphere":
115
+ return new SphereGeometry(1, 12, 12);
116
+ case "Cone":
117
+ return new ConeGeometry(1, 1, 16, 1);
118
+ case "Cylinder": {
119
+ const { radius, radiusEnd = radius, open = false, angleStart = 0, angleLength = Math.PI * 2 } = g;
120
+ const whole = isZero(Math.PI * 2 - angleLength);
121
+ if (radiusEnd === radius && whole && g.holes?.some((h) => !holeIsZero(h))) {
122
+ return extrude(new Shape().moveTo(0, radius).absellipse(0, 0, radius, radius, 0, Math.PI * 2, true), g.holes, g.length).rotateX(Math.PI / 2);
123
+ }
124
+ const max = Math.max(radius, radiusEnd) || 1;
125
+ const [top, bottom] = [radiusEnd / max, radius / max];
126
+ const mantle = new CylinderGeometry(top, bottom, 1, CYLINDER_SEGMENTS, 1, open && whole, angleStart, angleLength);
127
+ if (whole) {
128
+ return mantle;
129
+ }
130
+ // The walls closing the cut, merged with the mantle, both unindexed
131
+ const positions = cylinderCutPositions(top, bottom, 1, angleStart, angleLength);
132
+ const walls = new BufferGeometry().setAttribute("position", new Float32BufferAttribute(positions, 3));
133
+ walls.setAttribute("uv", new Float32BufferAttribute(new Float32Array((positions.length / 3) * 2), 2));
134
+ walls.computeVertexNormals();
135
+ return mergeGeometries([mantle.toNonIndexed(), walls]);
136
+ }
137
+ case "Shape":
138
+ return extrude(new Shape(g.points.map((p) => new Vector2(p.x, p.y))), g.holes, g.thickness);
139
+ case "Polygon": {
140
+ // A quad for 4 points, else a list of triangles, as the points are given
141
+ const geometry = new BufferGeometry().setAttribute(
142
+ "position",
143
+ new Float32BufferAttribute(
144
+ g.points.flatMap((p) => [p.x, p.y, p.z]),
145
+ 3
146
+ )
147
+ );
148
+ geometry.setIndex(g.points.length === 4 ? [0, 1, 2, 2, 3, 0] : Array.from({ length: Math.floor(g.points.length / 3) * 3 }, (_, i) => i));
149
+ geometry.computeVertexNormals();
150
+ return geometry;
151
+ }
152
+ case "Tube":
153
+ return new TubeGeometry(
154
+ g.curve.type === "SplineCurve" ? new CatmullRomCurve3(g.curve.points.map((p) => new Vector3(p.x, p.y, p.z))) : new CircleCurve(g.curve),
155
+ 9,
156
+ g.radius,
157
+ 9,
158
+ false
159
+ );
160
+ default:
161
+ return new BufferGeometry();
162
+ }
163
+ }
164
+
165
+ /** Extruded along z and centered on it */
166
+ function extrude(shape: Shape, holes: ReadonlyArray<Hole> | undefined, depth: number): BufferGeometry {
167
+ addHoles(holes, shape);
168
+ return new ExtrudeGeometry(shape, { depth, bevelEnabled: false }).translate(0, 0, -depth / 2);
169
+ }
170
+
171
+ /** Disposes the cached geometries whose keys aren't in `keys` */
172
+ export function keepGeometries(cache: Map<string, BufferGeometry>, keys: ReadonlySet<string>): void {
173
+ cache.forEach((geometry, key) => {
174
+ if (!keys.has(key)) {
175
+ geometry.dispose();
176
+ cache.delete(key);
177
+ }
178
+ });
179
+ }
@@ -0,0 +1,15 @@
1
+ export { createViewer, updateViewer, disposeViewer, invalidate } from "./viewer/viewer.js";
2
+ export type { Viewer } from "./viewer/viewer.js";
3
+ export type { ViewerOptions, Popover, SceneEvent } from "./viewer/options.js";
4
+ export type { MountedOverlay, MountOverlays } from "./viewer/overlays.js";
5
+ export { compileScene } from "./compile/compile.js";
6
+ export type { CompiledScene } from "./compile/compile.js";
7
+ export { buildScene, disposeScene } from "./build/build.js";
8
+ export type { BuildOptions, BuiltScene } from "./build/build.js";
9
+ export { createGlyphAtlas } from "./text/text.js";
10
+ export { ERROR_IMG_KEY } from "./appearance/appearance.js";
11
+ export type { MaterialState } from "./appearance/appearance.js";
12
+ export type { CameraOptions, BufferZones } from "./camera/camera.js";
13
+ export type { ControlsOptions } from "./camera/controls.js";
14
+ export type { ControlsHelper, ViewcubeOptions, AxesOptions, GizmoAlignment } from "./camera/gizmo.js";
15
+ export type { Quality, Tier } from "./quality/quality.js";
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Quality, TierGovernor } from "./quality.js";
3
+ import { recordFrame } from "./quality.js";
4
+
5
+ const governor = (quality: Quality): TierGovernor => ({ quality, tier: quality === "auto" ? "medium" : quality, sum: 0, count: 0 });
6
+ const record = (g: TierGovernor, intervals: ReadonlyArray<number>): ReadonlyArray<boolean> => intervals.map((ms) => recordFrame(g, ms));
7
+
8
+ describe("recordFrame", () => {
9
+ it("keeps a fixed quality", () => {
10
+ const g = governor("high");
11
+ record(g, Array<number>(100).fill(100));
12
+ expect(g.tier).toBe("high");
13
+ });
14
+
15
+ it("drops auto to low for good when moving frames are slow", () => {
16
+ const g = governor("auto");
17
+ expect(record(g, Array<number>(30).fill(40)).filter(Boolean)).toHaveLength(1);
18
+ record(g, Array<number>(100).fill(5));
19
+ expect(g.tier).toBe("low");
20
+ });
21
+
22
+ it("keeps auto at medium at 60 frames per second, ignoring the pauses between moves", () => {
23
+ const g = governor("auto");
24
+ record(
25
+ g,
26
+ Array.from({ length: 100 }, (_, i) => (i % 10 === 0 ? 2000 : 16.7))
27
+ );
28
+ expect(g.tier).toBe("medium");
29
+ });
30
+ });