abstract-3d 3.1.2 → 4.0.0-beta.10

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 (236) hide show
  1. package/README.md +53 -1
  2. package/lib/abstract-3d.d.ts +27 -1
  3. package/lib/abstract-3d.d.ts.map +1 -1
  4. package/lib/abstract-3d.js +26 -2
  5. package/lib/abstract-3d.js.map +1 -1
  6. package/lib/renderers/dxf/dxf-encoding/dxf-build.d.ts.map +1 -1
  7. package/lib/renderers/dxf/dxf-encoding/dxf-build.js +1 -2
  8. package/lib/renderers/dxf/dxf-encoding/dxf-build.js.map +1 -1
  9. package/lib/renderers/ifc/ifc.test.d.ts +2 -0
  10. package/lib/renderers/ifc/ifc.test.d.ts.map +1 -0
  11. package/lib/renderers/ifc/ifc.test.js +380 -0
  12. package/lib/renderers/ifc/ifc.test.js.map +1 -0
  13. package/lib/renderers/index.d.ts +1 -0
  14. package/lib/renderers/index.d.ts.map +1 -1
  15. package/lib/renderers/index.js +1 -0
  16. package/lib/renderers/index.js.map +1 -1
  17. package/lib/renderers/react/index.d.ts +2 -5
  18. package/lib/renderers/react/index.d.ts.map +1 -1
  19. package/lib/renderers/react/index.js +1 -2
  20. package/lib/renderers/react/index.js.map +1 -1
  21. package/lib/renderers/react/react.d.ts +16 -42
  22. package/lib/renderers/react/react.d.ts.map +1 -1
  23. package/lib/renderers/react/react.js +56 -32
  24. package/lib/renderers/react/react.js.map +1 -1
  25. package/lib/renderers/three/appearance/appearance.d.ts +46 -0
  26. package/lib/renderers/three/appearance/appearance.d.ts.map +1 -0
  27. package/lib/renderers/three/appearance/appearance.js +102 -0
  28. package/lib/renderers/three/appearance/appearance.js.map +1 -0
  29. package/lib/renderers/three/appearance/appearance.test.d.ts +2 -0
  30. package/lib/renderers/three/appearance/appearance.test.d.ts.map +1 -0
  31. package/lib/renderers/three/appearance/appearance.test.js +97 -0
  32. package/lib/renderers/three/appearance/appearance.test.js.map +1 -0
  33. package/lib/renderers/three/appearance.d.ts +41 -0
  34. package/lib/renderers/three/appearance.d.ts.map +1 -0
  35. package/lib/renderers/three/appearance.js +100 -0
  36. package/lib/renderers/three/appearance.js.map +1 -0
  37. package/lib/renderers/three/build/build.d.ts +53 -0
  38. package/lib/renderers/three/build/build.d.ts.map +1 -0
  39. package/lib/renderers/three/build/build.js +181 -0
  40. package/lib/renderers/three/build/build.js.map +1 -0
  41. package/lib/renderers/three/build/build.test.d.ts +2 -0
  42. package/lib/renderers/three/build/build.test.d.ts.map +1 -0
  43. package/lib/renderers/three/build/build.test.js +120 -0
  44. package/lib/renderers/three/build/build.test.js.map +1 -0
  45. package/lib/renderers/three/build/materials.d.ts +20 -0
  46. package/lib/renderers/three/build/materials.d.ts.map +1 -0
  47. package/lib/renderers/three/build/materials.js +168 -0
  48. package/lib/renderers/three/build/materials.js.map +1 -0
  49. package/lib/renderers/three/build/parts.d.ts +65 -0
  50. package/lib/renderers/three/build/parts.d.ts.map +1 -0
  51. package/lib/renderers/three/build/parts.js +203 -0
  52. package/lib/renderers/three/build/parts.js.map +1 -0
  53. package/lib/renderers/three/build.d.ts +50 -0
  54. package/lib/renderers/three/build.d.ts.map +1 -0
  55. package/lib/renderers/three/build.js +210 -0
  56. package/lib/renderers/three/build.js.map +1 -0
  57. package/lib/renderers/three/camera/camera.d.ts +40 -0
  58. package/lib/renderers/three/camera/camera.d.ts.map +1 -0
  59. package/lib/renderers/three/camera/camera.js +51 -0
  60. package/lib/renderers/three/camera/camera.js.map +1 -0
  61. package/lib/renderers/three/camera/camera.test.d.ts +2 -0
  62. package/lib/renderers/three/camera/camera.test.d.ts.map +1 -0
  63. package/lib/renderers/three/camera/camera.test.js +46 -0
  64. package/lib/renderers/three/camera/camera.test.js.map +1 -0
  65. package/lib/renderers/three/camera/controls.d.ts +24 -0
  66. package/lib/renderers/three/camera/controls.d.ts.map +1 -0
  67. package/lib/renderers/three/camera/controls.js +39 -0
  68. package/lib/renderers/three/camera/controls.js.map +1 -0
  69. package/lib/renderers/three/camera/gizmo.d.ts +44 -0
  70. package/lib/renderers/three/camera/gizmo.d.ts.map +1 -0
  71. package/lib/renderers/three/camera/gizmo.js +43 -0
  72. package/lib/renderers/three/camera/gizmo.js.map +1 -0
  73. package/lib/renderers/three/camera.d.ts +63 -0
  74. package/lib/renderers/three/camera.d.ts.map +1 -0
  75. package/lib/renderers/three/camera.js +85 -0
  76. package/lib/renderers/three/camera.js.map +1 -0
  77. package/lib/renderers/three/compile/compile.d.ts +59 -0
  78. package/lib/renderers/three/compile/compile.d.ts.map +1 -0
  79. package/lib/renderers/three/compile/compile.js +88 -0
  80. package/lib/renderers/three/compile/compile.js.map +1 -0
  81. package/lib/renderers/three/compile/compile.test.d.ts +2 -0
  82. package/lib/renderers/three/compile/compile.test.d.ts.map +1 -0
  83. package/lib/renderers/three/compile/compile.test.js +93 -0
  84. package/lib/renderers/three/compile/compile.test.js.map +1 -0
  85. package/lib/renderers/three/compile.d.ts +58 -0
  86. package/lib/renderers/three/compile.d.ts.map +1 -0
  87. package/lib/renderers/three/compile.js +89 -0
  88. package/lib/renderers/three/compile.js.map +1 -0
  89. package/lib/renderers/three/controls.d.ts +27 -0
  90. package/lib/renderers/three/controls.d.ts.map +1 -0
  91. package/lib/renderers/three/controls.js +115 -0
  92. package/lib/renderers/three/controls.js.map +1 -0
  93. package/lib/renderers/three/environment/studio.d.ts +9 -0
  94. package/lib/renderers/three/environment/studio.d.ts.map +1 -0
  95. package/lib/renderers/three/environment/studio.js +15 -0
  96. package/lib/renderers/three/environment/studio.js.map +1 -0
  97. package/lib/renderers/three/geometry/geometry.d.ts +21 -0
  98. package/lib/renderers/three/geometry/geometry.d.ts.map +1 -0
  99. package/lib/renderers/three/geometry/geometry.js +141 -0
  100. package/lib/renderers/three/geometry/geometry.js.map +1 -0
  101. package/lib/renderers/three/geometry/geometry.test.d.ts +2 -0
  102. package/lib/renderers/three/geometry/geometry.test.d.ts.map +1 -0
  103. package/lib/renderers/three/geometry/geometry.test.js +80 -0
  104. package/lib/renderers/three/geometry/geometry.test.js.map +1 -0
  105. package/lib/renderers/three/geometry.d.ts +20 -0
  106. package/lib/renderers/three/geometry.d.ts.map +1 -0
  107. package/lib/renderers/three/geometry.js +164 -0
  108. package/lib/renderers/three/geometry.js.map +1 -0
  109. package/lib/renderers/three/gizmo.d.ts +49 -0
  110. package/lib/renderers/three/gizmo.d.ts.map +1 -0
  111. package/lib/renderers/three/gizmo.js +172 -0
  112. package/lib/renderers/three/gizmo.js.map +1 -0
  113. package/lib/renderers/three/index.d.ts +16 -0
  114. package/lib/renderers/three/index.d.ts.map +1 -0
  115. package/lib/renderers/three/index.js +6 -0
  116. package/lib/renderers/three/index.js.map +1 -0
  117. package/lib/renderers/three/input.d.ts +25 -0
  118. package/lib/renderers/three/input.d.ts.map +1 -0
  119. package/lib/renderers/three/input.js +71 -0
  120. package/lib/renderers/three/input.js.map +1 -0
  121. package/lib/renderers/three/lines.d.ts +16 -0
  122. package/lib/renderers/three/lines.d.ts.map +1 -0
  123. package/lib/renderers/three/lines.js +63 -0
  124. package/lib/renderers/three/lines.js.map +1 -0
  125. package/lib/renderers/three/materials.d.ts +15 -0
  126. package/lib/renderers/three/materials.d.ts.map +1 -0
  127. package/lib/renderers/three/materials.js +70 -0
  128. package/lib/renderers/three/materials.js.map +1 -0
  129. package/lib/renderers/three/options.d.ts +68 -0
  130. package/lib/renderers/three/options.d.ts.map +1 -0
  131. package/lib/renderers/three/options.js +64 -0
  132. package/lib/renderers/three/options.js.map +1 -0
  133. package/lib/renderers/three/overlays.d.ts +25 -0
  134. package/lib/renderers/three/overlays.d.ts.map +1 -0
  135. package/lib/renderers/three/overlays.js +57 -0
  136. package/lib/renderers/three/overlays.js.map +1 -0
  137. package/lib/renderers/three/quality/quality.d.ts +18 -0
  138. package/lib/renderers/three/quality/quality.d.ts.map +1 -0
  139. package/lib/renderers/three/quality/quality.js +28 -0
  140. package/lib/renderers/three/quality/quality.js.map +1 -0
  141. package/lib/renderers/three/quality/quality.test.d.ts +2 -0
  142. package/lib/renderers/three/quality/quality.test.d.ts.map +1 -0
  143. package/lib/renderers/three/quality/quality.test.js +23 -0
  144. package/lib/renderers/three/quality/quality.test.js.map +1 -0
  145. package/lib/renderers/three/quality.d.ts +24 -0
  146. package/lib/renderers/three/quality.d.ts.map +1 -0
  147. package/lib/renderers/three/quality.js +67 -0
  148. package/lib/renderers/three/quality.js.map +1 -0
  149. package/lib/renderers/three/shadow/key-light.d.ts +6 -0
  150. package/lib/renderers/three/shadow/key-light.d.ts.map +1 -0
  151. package/lib/renderers/three/shadow/key-light.js +39 -0
  152. package/lib/renderers/three/shadow/key-light.js.map +1 -0
  153. package/lib/renderers/three/shadow/shadow.d.ts +21 -0
  154. package/lib/renderers/three/shadow/shadow.d.ts.map +1 -0
  155. package/lib/renderers/three/shadow/shadow.js +120 -0
  156. package/lib/renderers/three/shadow/shadow.js.map +1 -0
  157. package/lib/renderers/three/text/text.d.ts +55 -0
  158. package/lib/renderers/three/text/text.d.ts.map +1 -0
  159. package/lib/renderers/three/text/text.js +90 -0
  160. package/lib/renderers/three/text/text.js.map +1 -0
  161. package/lib/renderers/three/text/text.test.d.ts +2 -0
  162. package/lib/renderers/three/text/text.test.d.ts.map +1 -0
  163. package/lib/renderers/three/text/text.test.js +70 -0
  164. package/lib/renderers/three/text/text.test.js.map +1 -0
  165. package/lib/renderers/three/text.d.ts +53 -0
  166. package/lib/renderers/three/text.d.ts.map +1 -0
  167. package/lib/renderers/three/text.js +151 -0
  168. package/lib/renderers/three/text.js.map +1 -0
  169. package/lib/renderers/three/viewer/options.d.ts +82 -0
  170. package/lib/renderers/three/viewer/options.d.ts.map +1 -0
  171. package/lib/renderers/three/viewer/options.js +34 -0
  172. package/lib/renderers/three/viewer/options.js.map +1 -0
  173. package/lib/renderers/three/viewer/options.test.d.ts +2 -0
  174. package/lib/renderers/three/viewer/options.test.d.ts.map +1 -0
  175. package/lib/renderers/three/viewer/options.test.js +38 -0
  176. package/lib/renderers/three/viewer/options.test.js.map +1 -0
  177. package/lib/renderers/three/viewer/overlays.d.ts +31 -0
  178. package/lib/renderers/three/viewer/overlays.d.ts.map +1 -0
  179. package/lib/renderers/three/viewer/overlays.js +43 -0
  180. package/lib/renderers/three/viewer/overlays.js.map +1 -0
  181. package/lib/renderers/three/viewer/viewer.d.ts +102 -0
  182. package/lib/renderers/three/viewer/viewer.d.ts.map +1 -0
  183. package/lib/renderers/three/viewer/viewer.js +488 -0
  184. package/lib/renderers/three/viewer/viewer.js.map +1 -0
  185. package/lib/renderers/three/viewer.d.ts +38 -0
  186. package/lib/renderers/three/viewer.d.ts.map +1 -0
  187. package/lib/renderers/three/viewer.js +435 -0
  188. package/lib/renderers/three/viewer.js.map +1 -0
  189. package/package.json +34 -16
  190. package/src/abstract-3d.ts +60 -2
  191. package/src/renderers/dxf/dxf-encoding/dxf-build.ts +1 -2
  192. package/src/renderers/ifc/ifc.test.ts +7 -5
  193. package/src/renderers/index.ts +1 -0
  194. package/src/renderers/react/index.ts +2 -5
  195. package/src/renderers/react/react.tsx +107 -181
  196. package/src/renderers/three/appearance/appearance.test.ts +153 -0
  197. package/src/renderers/three/appearance/appearance.ts +150 -0
  198. package/src/renderers/three/build/build.test.ts +149 -0
  199. package/src/renderers/three/build/build.ts +278 -0
  200. package/src/renderers/three/build/materials.ts +222 -0
  201. package/src/renderers/three/build/parts.ts +277 -0
  202. package/src/renderers/three/camera/camera.test.ts +53 -0
  203. package/src/renderers/three/camera/camera.ts +82 -0
  204. package/src/renderers/three/camera/controls.ts +65 -0
  205. package/src/renderers/three/camera/gizmo.ts +94 -0
  206. package/src/renderers/three/compile/compile.test.ts +126 -0
  207. package/src/renderers/three/compile/compile.ts +151 -0
  208. package/src/renderers/three/environment/studio.ts +18 -0
  209. package/src/renderers/three/geometry/geometry.test.ts +121 -0
  210. package/src/renderers/three/geometry/geometry.ts +189 -0
  211. package/src/renderers/three/index.ts +15 -0
  212. package/src/renderers/three/quality/quality.test.ts +37 -0
  213. package/src/renderers/three/quality/quality.ts +36 -0
  214. package/src/renderers/three/shadow/key-light.ts +43 -0
  215. package/src/renderers/three/shadow/shadow.ts +170 -0
  216. package/src/renderers/three/text/text.test.ts +79 -0
  217. package/src/renderers/three/text/text.ts +128 -0
  218. package/src/renderers/three/viewer/options.ts +112 -0
  219. package/src/renderers/three/viewer/overlays.ts +71 -0
  220. package/src/renderers/three/viewer/viewer.ts +596 -0
  221. package/lib/renderers/react/react-hotspot.d.ts +0 -27
  222. package/lib/renderers/react/react-hotspot.d.ts.map +0 -1
  223. package/lib/renderers/react/react-hotspot.js +0 -37
  224. package/lib/renderers/react/react-hotspot.js.map +0 -1
  225. package/src/custom_typings/react-three-fiber.d.ts +0 -9
  226. package/src/custom_typings/react-three-postprocessing.d.ts +0 -1
  227. package/src/renderers/react/react-camera.tsx +0 -391
  228. package/src/renderers/react/react-dimension.tsx +0 -122
  229. package/src/renderers/react/react-group.test.ts +0 -56
  230. package/src/renderers/react/react-group.tsx +0 -202
  231. package/src/renderers/react/react-image-material.tsx +0 -139
  232. package/src/renderers/react/react-material.tsx +0 -91
  233. package/src/renderers/react/react-mesh.tsx +0 -512
  234. package/src/renderers/react/react-relation.tsx +0 -86
  235. package/src/renderers/react/react-scene.tsx +0 -111
  236. package/src/renderers/react/react-types.ts +0 -9
@@ -0,0 +1,121 @@
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
+ /** The radius and joined sides' bits, the same for every vertex */
21
+ const bevel = (mesh: Mesh): ReadonlyArray<number> => {
22
+ const attribute = createSolidGeometry(mesh.geometry).getAttribute("bevel");
23
+ const pairs = new Set(Array.from({ length: attribute.count }, (_, i) => `${attribute.getX(i)} ${attribute.getY(i)}`));
24
+ return [...pairs].flatMap((pair) => pair.split(" ").map(Number));
25
+ };
26
+
27
+ describe("getSolidShape", () => {
28
+ it("shares unit geometries between primitives of any size, scaled by the local matrix", () => {
29
+ expect(key(box(vec3(1, 2, 3), m))).toBe(key(box(vec3(4, 5, 6), m)));
30
+ expect(local(box(vec3(2, 4, 6), m, vec3(10, 0, 0)), 0.5, 0.5, 0.5)).toEqual([11, 2, 3]);
31
+ expect(local(plane(vec3Zero, vec2(2, 4), m), 0.5, 0.5, 0)).toEqual([1, 2, 0]);
32
+ expect(local(sphere(3, m, vec3(1, 0, 0)), 1, 0, 0)).toEqual([4, 0, 0]);
33
+ expect(local(cone(vec3Zero, 2, 10, m), 1, 0.5, 0)).toEqual([2, 5, 0]);
34
+ });
35
+
36
+ it("scales cylinders by their largest radius, with a geometry per radius ratio", () => {
37
+ expect(key(cylinder(vec3Zero, 1, 2, m))).toBe(key(cylinder(vec3Zero, 5, 7, m)));
38
+ expect(key(cylinder(vec3Zero, 1, 2, m))).not.toBe(key(cylinder(vec3Zero, 1, 2, m, vec3Zero, false, [], 0, Math.PI * 2, 2)));
39
+ expect(local(cylinder(vec3Zero, 1, 10, m, vec3Zero, false, [], 0, Math.PI * 2, 4), 1, 0.5, 0)).toEqual([4, 5, 0]);
40
+ });
41
+
42
+ it("shares geometries between identical shapes", () => {
43
+ const triangle = polygon([vec3(0, 0, 0), vec3(1, 0, 0), vec3(1, 1, 0)], m);
44
+ expect(key(triangle)).toBe(key(polygon([vec3(0, 0, 0), vec3(1, 0, 0), vec3(1, 1, 0)], m)));
45
+ });
46
+
47
+ it("ignores zero holes", () => {
48
+ expect(key(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [roundHole(vec2(0, 0), 0)]))).toBe(key(box(vec3(2, 2, 2), m)));
49
+ });
50
+
51
+ it("extrudes primitives with holes into their own geometry, centered", () => {
52
+ const holed = box(vec3(10, 10, 2), m, vec3(1, 0, 0), vec3Zero, [roundHole(vec2(0, 0), 2)]);
53
+ expect(key(holed)).not.toBe(key(box(vec3(10, 10, 2), m)));
54
+ expect(local(holed, 0, 0, 0)).toEqual([1, 0, 0]);
55
+ expect(bounds(holed)).toEqual([-5, -5, -1, 5, 5, 1]);
56
+ expect(bounds(cylinder(vec3Zero, 2, 10, m, vec3Zero, false, [roundHole(vec2(0, 0), 1)]))).toEqual([-2, -5, -2, 2, 5, 2]);
57
+ });
58
+ });
59
+
60
+ describe("createSolidGeometry", () => {
61
+ it("closes partial cylinders with walls", () => {
62
+ const whole = createSolidGeometry(cylinder(vec3Zero, 1, 1, m).geometry);
63
+ const half = createSolidGeometry(cylinder(vec3Zero, 1, 1, m, vec3Zero, false, [], 0, Math.PI).geometry);
64
+ expect(half.getAttribute("position").count).toBeGreaterThan(whole.getAttribute("position").count / 2);
65
+ });
66
+
67
+ it("draws 4-point polygons as a quad and other polygons as a triangle list", () => {
68
+ 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);
69
+ expect(createSolidGeometry(polygon([vec3(0, 0, 0), vec3(1, 0, 0), vec3(1, 1, 0)], m).geometry).index!.count).toBe(3);
70
+ });
71
+
72
+ it("gives every geometry an index, normals and uvs, so they can share a batch", () => {
73
+ const meshes = [
74
+ box(vec3(1, 1, 1), m),
75
+ box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [roundHole(vec2(0, 0), 0.1)]),
76
+ polygon([vec3(0, 0, 0), vec3(1, 0, 0), vec3(1, 1, 0)], m),
77
+ cylinder(vec3Zero, 1, 1, m, vec3Zero, false, [], 0, Math.PI),
78
+ shape([vec2(0, 0), vec2(1, 0), vec2(0, 1)], vec3Zero, m, 1),
79
+ tube(circleCurve(10, 0, Math.PI), 1, m),
80
+ ];
81
+ meshes.forEach((mesh) => {
82
+ const geometry = createSolidGeometry(mesh.geometry);
83
+ expect(geometry.index!.count).toBeGreaterThan(0);
84
+ expect(Object.keys(geometry.attributes).sort()).toEqual(["bevel", "normal", "position", "uv"]);
85
+ });
86
+ });
87
+
88
+ it("gives boxes with a radius it as the radius their edges are shaded rounded by, with a geometry per radius", () => {
89
+ expect(bevel(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [], 4))).toEqual([4, 2 ** 12 - 1]);
90
+ expect(key(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [], 4))).toBe(key(box(vec3(9, 9, 9), m, vec3Zero, vec3Zero, [], 4)));
91
+ expect(key(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [], 4))).not.toBe(key(box(vec3(1, 1, 1), m)));
92
+ });
93
+
94
+ it("rounds all edges of boxes with a radius, or only their rounded edges, a bit each in the order of boxEdges, with a geometry per edges", () => {
95
+ expect(bevel(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [], 4, ["BottomBack", "FrontRight", "TopRight"]))).toEqual([4, 1 + 2 ** 7 + 2 ** 11]);
96
+ expect(bevel(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [], 4, []))).toEqual([4, 0]);
97
+ expect(key(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [], 4, ["TopFront"]))).not.toBe(key(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [], 4)));
98
+ });
99
+
100
+ it("keeps boxes without a radius, with holes, and other shapes sharp", () => {
101
+ expect(bevel(box(vec3(1, 1, 1), m))).toEqual([0, 0]);
102
+ expect(bevel(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [], undefined, ["TopFront"]))).toEqual([0, 0]);
103
+ expect(bevel(box(vec3(1, 1, 1), m, vec3Zero, vec3Zero, [roundHole(vec2(0, 0), 0.1)], 4))).toEqual([0, 0]);
104
+ expect(bevel(sphere(1, m))).toEqual([0, 0]);
105
+ });
106
+ });
107
+
108
+ describe("keepGeometries", () => {
109
+ it("disposes the geometries a build no longer uses", () => {
110
+ const [kept, unused] = [createSolidGeometry(box(vec3(1, 1, 1), m).geometry), createSolidGeometry(sphere(1, m).geometry)];
111
+ const cache = new Map<string, BufferGeometry>([
112
+ ["Box", kept],
113
+ ["Sphere", unused],
114
+ ]);
115
+ const disposed: Array<string> = [];
116
+ unused.addEventListener("dispose", () => disposed.push("Sphere"));
117
+ keepGeometries(cache, new Set(["Box"]));
118
+ expect(disposed).toEqual(["Sphere"]);
119
+ expect([...cache.keys()]).toEqual(["Box"]);
120
+ });
121
+ });
@@ -0,0 +1,189 @@
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 { boxEdges, 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
+ : {
46
+ key: g.type === "Box" && g.radius ? `Box ${g.radius} ${g.roundedEdges?.join() ?? ""}` : g.type,
47
+ local: transform(g.pos, g.rot, undefined, size),
48
+ };
49
+ }
50
+ case "Image":
51
+ return { key: "Plane", local: transform(g.pos, g.rot, undefined, vec3(g.size.x, g.size.y, 1)) };
52
+ case "Sphere":
53
+ return { key: g.type, local: transform(g.pos, vec3Zero, undefined, vec3(g.radius, g.radius, g.radius)) };
54
+ case "Cone":
55
+ return { key: g.type, local: transform(g.pos, g.rot, undefined, vec3(g.radius, g.length, g.radius)) };
56
+ case "Cylinder": {
57
+ const { radius, radiusEnd = radius, open = false, angleStart = 0, angleLength = Math.PI * 2 } = g;
58
+ if (radiusEnd === radius && isZero(Math.PI * 2 - angleLength) && g.holes?.some((h) => !holeIsZero(h))) {
59
+ return { key: JSON.stringify([g.type, radius, g.length, g.holes]), local: transform(g.pos, g.rot) };
60
+ }
61
+ // Unit geometry scaled by the largest radius, so only the radius ratio needs its own geometry
62
+ const max = Math.max(radius, radiusEnd) || 1;
63
+ return {
64
+ key: `${g.type} ${radiusEnd / max} ${radius / max} ${open} ${angleStart} ${angleLength}`,
65
+ local: transform(g.pos, g.rot, undefined, vec3(max, g.length, max)),
66
+ };
67
+ }
68
+ case "Shape":
69
+ return { key: JSON.stringify([g.type, g.points, g.thickness, g.holes]), local: transform(g.pos, g.rot) };
70
+ case "Polygon":
71
+ return { key: JSON.stringify([g.type, g.points]), local: transform(g.pos, g.rot) };
72
+ case "Tube":
73
+ return { key: JSON.stringify([g.type, g.curve, g.radius]), local: transform(g.pos, g.rot) };
74
+ default:
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ /** The geometry of a solid shape, indexed, with exactly position, normal and uv attributes and no groups, as a BatchedMesh needs */
80
+ export function createSolidGeometry(g: Mesh["geometry"]): BufferGeometry {
81
+ const geometry = createGeometry(g);
82
+ const count = geometry.getAttribute("position").count;
83
+ if (!geometry.index) {
84
+ geometry.setIndex(Array.from({ length: count }, (_, i) => i));
85
+ }
86
+ if (!geometry.hasAttribute("normal")) {
87
+ geometry.computeVertexNormals();
88
+ }
89
+ if (!geometry.hasAttribute("uv")) {
90
+ geometry.setAttribute("uv", new Float32BufferAttribute(new Float32Array(count * 2), 2));
91
+ }
92
+ // The radius a unit box's edges are shaded as rounded by, 0 for sharp, and a bit per rounded edge, in the order of `boxEdges`; every geometry has
93
+ // it, as a batch's geometries share their attributes
94
+ const bevel =
95
+ geometry instanceof BoxGeometry && g.type === "Box" && g.radius
96
+ ? [g.radius, boxEdges.reduce((edges, edge, i) => ((g.roundedEdges?.includes(edge) ?? true) ? edges + 2 ** i : edges), 0)]
97
+ : [0, 0];
98
+ geometry.setAttribute("bevel", new Float32BufferAttribute(Array.from({ length: count }, () => bevel).flat(), 2));
99
+ Object.keys(geometry.attributes).forEach((name) => {
100
+ if (name !== "position" && name !== "normal" && name !== "uv" && name !== "bevel") {
101
+ geometry.deleteAttribute(name);
102
+ }
103
+ });
104
+ geometry.clearGroups();
105
+ return geometry;
106
+ }
107
+
108
+ function createGeometry(g: Mesh["geometry"]): BufferGeometry {
109
+ switch (g.type) {
110
+ case "Box":
111
+ case "Plane": {
112
+ if (!g.holes?.some((h) => !holeIsZero(h))) {
113
+ return g.type === "Box" ? new BoxGeometry() : new PlaneGeometry();
114
+ }
115
+ const [x, y] = [g.size.x / 2, g.size.y / 2];
116
+ return extrude(
117
+ new Shape([new Vector2(-x, -y), new Vector2(-x, y), new Vector2(x, y), new Vector2(x, -y)]),
118
+ g.holes,
119
+ g.type === "Box" ? g.size.z : 0
120
+ );
121
+ }
122
+ case "Image":
123
+ return new PlaneGeometry();
124
+ case "Sphere":
125
+ return new SphereGeometry(1, 12, 12);
126
+ case "Cone":
127
+ return new ConeGeometry(1, 1, 16, 1);
128
+ case "Cylinder": {
129
+ const { radius, radiusEnd = radius, open = false, angleStart = 0, angleLength = Math.PI * 2 } = g;
130
+ const whole = isZero(Math.PI * 2 - angleLength);
131
+ if (radiusEnd === radius && whole && g.holes?.some((h) => !holeIsZero(h))) {
132
+ 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);
133
+ }
134
+ const max = Math.max(radius, radiusEnd) || 1;
135
+ const [top, bottom] = [radiusEnd / max, radius / max];
136
+ const mantle = new CylinderGeometry(top, bottom, 1, CYLINDER_SEGMENTS, 1, open && whole, angleStart, angleLength);
137
+ if (whole) {
138
+ return mantle;
139
+ }
140
+ // The walls closing the cut, merged with the mantle, both unindexed
141
+ const positions = cylinderCutPositions(top, bottom, 1, angleStart, angleLength);
142
+ const walls = new BufferGeometry().setAttribute("position", new Float32BufferAttribute(positions, 3));
143
+ walls.setAttribute("uv", new Float32BufferAttribute(new Float32Array((positions.length / 3) * 2), 2));
144
+ walls.computeVertexNormals();
145
+ return mergeGeometries([mantle.toNonIndexed(), walls]);
146
+ }
147
+ case "Shape":
148
+ return extrude(new Shape(g.points.map((p) => new Vector2(p.x, p.y))), g.holes, g.thickness);
149
+ case "Polygon": {
150
+ // A quad for 4 points, else a list of triangles, as the points are given
151
+ const geometry = new BufferGeometry().setAttribute(
152
+ "position",
153
+ new Float32BufferAttribute(
154
+ g.points.flatMap((p) => [p.x, p.y, p.z]),
155
+ 3
156
+ )
157
+ );
158
+ geometry.setIndex(g.points.length === 4 ? [0, 1, 2, 2, 3, 0] : Array.from({ length: Math.floor(g.points.length / 3) * 3 }, (_, i) => i));
159
+ geometry.computeVertexNormals();
160
+ return geometry;
161
+ }
162
+ case "Tube":
163
+ return new TubeGeometry(
164
+ g.curve.type === "SplineCurve" ? new CatmullRomCurve3(g.curve.points.map((p) => new Vector3(p.x, p.y, p.z))) : new CircleCurve(g.curve),
165
+ 9,
166
+ g.radius,
167
+ 9,
168
+ false
169
+ );
170
+ default:
171
+ return new BufferGeometry();
172
+ }
173
+ }
174
+
175
+ /** Extruded along z and centered on it */
176
+ function extrude(shape: Shape, holes: ReadonlyArray<Hole> | undefined, depth: number): BufferGeometry {
177
+ addHoles(holes, shape);
178
+ return new ExtrudeGeometry(shape, { depth, bevelEnabled: false }).translate(0, 0, -depth / 2);
179
+ }
180
+
181
+ /** Disposes the cached geometries whose keys aren't in `keys` */
182
+ export function keepGeometries(cache: Map<string, BufferGeometry>, keys: ReadonlySet<string>): void {
183
+ cache.forEach((geometry, key) => {
184
+ if (!keys.has(key)) {
185
+ geometry.dispose();
186
+ cache.delete(key);
187
+ }
188
+ });
189
+ }
@@ -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,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Quality, TierGovernor } from "./quality.js";
3
+ import { getPixelRatio, recordFrame } from "./quality.js";
4
+
5
+ const governor = (quality: Quality): TierGovernor => ({ quality, tier: quality === "auto" ? "high" : 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 high 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("high");
29
+ });
30
+ });
31
+
32
+ describe("getPixelRatio", () => {
33
+ it("caps the screen's pixel ratio by the tier, and high is the screen's own, as the r3f renderer's", () => {
34
+ expect([getPixelRatio("low", 3), getPixelRatio("medium", 3), getPixelRatio("high", 3)]).toEqual([1, 1.5, 3]);
35
+ expect(getPixelRatio("high", 1)).toBe(1);
36
+ });
37
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Quality tiers by resolution, the cheapest lever on a weak GPU: low renders a pixel per CSS pixel, medium up to 1.5, and high, the default, the
3
+ * screen's own pixel ratio as the r3f renderer. `auto` starts high and drops to low for good once moving frames are slow, as model viewers lower
4
+ * their resolution under load
5
+ */
6
+
7
+ export type Tier = "low" | "medium" | "high";
8
+ export type Quality = Tier | "auto";
9
+
10
+ /** `auto` starts at high, and drops to low for good once moving frames average slower than 40 per second */
11
+ export type TierGovernor = { readonly quality: Quality; tier: Tier; sum: number; count: number };
12
+
13
+ const pixelRatioCaps: Record<Tier, number> = { low: 1, medium: 1.5, high: Infinity };
14
+ const windowSize = 30;
15
+ const slowFrameMs = 25;
16
+ /** Longer intervals are pauses between moves, not slow frames */
17
+ const pauseMs = 250;
18
+
19
+ /** Records the time between two frames while the camera moves; true when that dropped the tier */
20
+ export function recordFrame(governor: TierGovernor, intervalMs: number): boolean {
21
+ if (governor.quality !== "auto" || governor.tier === "low" || intervalMs > pauseMs) {
22
+ return false;
23
+ }
24
+ governor.sum += intervalMs;
25
+ governor.count += 1;
26
+ if (governor.count < windowSize) {
27
+ return false;
28
+ }
29
+ const slow = governor.sum / governor.count > slowFrameMs;
30
+ Object.assign(governor, { sum: 0, count: 0 }, slow ? { tier: "low" } : {});
31
+ return slow;
32
+ }
33
+
34
+ export function getPixelRatio(tier: Tier, devicePixelRatio: number): number {
35
+ return Math.min(devicePixelRatio, pixelRatioCaps[tier]);
36
+ }
@@ -0,0 +1,43 @@
1
+ import { DirectionalLight, Group, Sphere, Vector3 } from "three";
2
+ import type { BuiltScene } from "../build/build.js";
3
+ import { getShadowCasters } from "./shadow.js";
4
+
5
+ /**
6
+ * A key light from the upper left front, on top of the studio's soft light, that casts the solids' shadows on themselves, e.g. under a roof or behind
7
+ * a profile. Its shadow map is rendered once per build, as the scene doesn't move, so a frame only samples it; the casters are the ground shadow's
8
+ */
9
+
10
+ const direction = new Vector3(-1, 2, 1.2).normalize();
11
+ const intensity = 1.6;
12
+ const mapSize = 2048;
13
+ /** Of the key light kept out of its shadows, so faces under an overhang, e.g. a recessed base frame, aren't black */
14
+ const shadowIntensity = 0.6;
15
+
16
+ /** The light and its target, to add to the scene, with or without shadows; undefined for a scene without solids */
17
+ export function createKeyLight(built: BuiltScene, shadows: boolean): Group | undefined {
18
+ const { bounds } = getShadowCasters(built);
19
+ if (bounds.isEmpty()) {
20
+ return undefined;
21
+ }
22
+ const { center, radius } = bounds.getBoundingSphere(new Sphere());
23
+ const light = new DirectionalLight(0xffffff, intensity);
24
+ light.position.copy(center).addScaledVector(direction, radius * 2);
25
+ light.target.position.copy(center);
26
+ light.castShadow = shadows;
27
+ // The shadow camera just encloses the scene, so the map's texels are as small as they can be
28
+ Object.assign(light.shadow.camera, { left: -radius, right: radius, top: radius, bottom: -radius, near: radius, far: radius * 3 });
29
+ light.shadow.camera.updateProjectionMatrix();
30
+ light.shadow.mapSize.set(mapSize, mapSize);
31
+ // Offsets along the normal by three texels, against shadow acne on the lit faces
32
+ Object.assign(light.shadow, { bias: -0.0005, normalBias: (radius * 6) / mapSize, radius: 3, intensity: shadowIntensity });
33
+ return new Group().add(light, light.target);
34
+ }
35
+
36
+ export function disposeKeyLight(key: Group): void {
37
+ key.removeFromParent();
38
+ key.children.forEach((child) => {
39
+ if (child instanceof DirectionalLight) {
40
+ child.shadow.dispose();
41
+ }
42
+ });
43
+ }
@@ -0,0 +1,170 @@
1
+ import {
2
+ type BatchedMesh,
3
+ Box3,
4
+ CanvasTexture,
5
+ HalfFloatType,
6
+ type Scene,
7
+ Mesh,
8
+ MeshBasicMaterial,
9
+ MeshDepthMaterial,
10
+ OrthographicCamera,
11
+ PlaneGeometry,
12
+ ShaderMaterial,
13
+ UniformsUtils,
14
+ Vector3,
15
+ type WebGLRenderer,
16
+ WebGLRenderTarget,
17
+ } from "three";
18
+ import { FullScreenQuad } from "three/addons/postprocessing/Pass.js";
19
+ import { HorizontalBlurShader } from "three/addons/shaders/HorizontalBlurShader.js";
20
+ import { VerticalBlurShader } from "three/addons/shaders/VerticalBlurShader.js";
21
+ import type { BuiltScene } from "../build/build.js";
22
+
23
+ /**
24
+ * A soft shadow on the ground the scene stands on, rendered once per build: the solids seen from below by their depth, darker the closer to the
25
+ * ground, blurred into a texture on a plane, so a frame costs one textured quad. three.js's webgl_shadow_contact example, rendered once as drei's
26
+ * ContactShadows with `frames={1}`
27
+ */
28
+
29
+ export type GroundShadow = {
30
+ readonly mesh: Mesh<PlaneGeometry, MeshBasicMaterial>;
31
+ readonly floor: Mesh<PlaneGeometry, MeshBasicMaterial>;
32
+ readonly target: WebGLRenderTarget;
33
+ };
34
+
35
+ /** The layer the solids are drawn on for the shadow, so text, lines, images and dimensions don't cast one */
36
+ const shadowLayer = 1;
37
+ /** Of the texture's longer side */
38
+ const resolution = 512;
39
+ /** The blur's reach, of the solids' height */
40
+ const softness = 0.22;
41
+ const opacity = 1;
42
+ /** A pool of light on the floor the shadow lies in, so it shows on a dark background, which a shadow alone can't darken; on white it's invisible */
43
+ const floorLight = 0.06;
44
+ /** The pool's reach beyond the solids, of their height */
45
+ const floorReach = 1;
46
+
47
+ /** The batches of solids that cast shadows, with their bounds: opaque ones whose material doesn't turn it off, not text, lines, images or dimensions */
48
+ export function getShadowCasters(built: BuiltScene): { readonly casters: ReadonlyArray<BatchedMesh>; readonly bounds: Box3 } {
49
+ const casters: Array<BatchedMesh> = [];
50
+ const bounds = new Box3();
51
+ built.parts.forEach((part) => {
52
+ if (part.kind === "solid" && part.object.castShadow && part.object.parent !== built.dimensions && part.object.boundingBox) {
53
+ casters.push(part.object);
54
+ bounds.union(part.object.boundingBox);
55
+ }
56
+ });
57
+ return { casters, bounds };
58
+ }
59
+
60
+ /** Undefined for a scene without solids; renders with `renderer`, and leaves its render target as it was */
61
+ export function createGroundShadow(renderer: WebGLRenderer, scene: Scene, built: BuiltScene): GroundShadow | undefined {
62
+ const { casters, bounds } = getShadowCasters(built);
63
+ if (bounds.isEmpty()) {
64
+ return undefined;
65
+ }
66
+ casters.forEach((caster) => caster.layers.enable(shadowLayer));
67
+ const size = bounds.getSize(new Vector3());
68
+ // The plane reaches past the solids by twice the blur's reach on every side
69
+ const reach = softness * size.y;
70
+ const [width, depth] = [size.x + reach * 4, size.z + reach * 4];
71
+ const center = new Vector3((bounds.min.x + bounds.max.x) / 2, bounds.min.y, (bounds.min.z + bounds.max.z) / 2);
72
+ const [texelsX, texelsZ] =
73
+ width > depth ? [resolution, Math.ceil((resolution * depth) / width)] : [Math.ceil((resolution * width) / depth), resolution];
74
+ // Half float, so the blur's soft falloff isn't rounded into steps
75
+ const halfFloat = { type: HalfFloatType };
76
+ const [target, blurred] = [new WebGLRenderTarget(texelsX, texelsZ, halfFloat), new WebGLRenderTarget(texelsX, texelsZ, halfFloat)];
77
+
78
+ // Looks up from the ground, seeing as far as the solids are high
79
+ const camera = new OrthographicCamera(-width / 2, width / 2, depth / 2, -depth / 2, 0, size.y);
80
+ camera.position.copy(center);
81
+ camera.rotation.x = Math.PI / 2;
82
+ camera.layers.set(shadowLayer);
83
+ camera.updateMatrixWorld();
84
+ // Depth tested, so at each point the surface closest to the ground darkens it, not the last one drawn
85
+ const darkness = new MeshDepthMaterial();
86
+ darkness.onBeforeCompile = (shader) => {
87
+ shader.fragmentShader = shader.fragmentShader.replace(
88
+ "gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );",
89
+ "gl_FragColor = vec4( vec3( 0.0 ), sqrt( 1.0 - fragCoordZ ) );"
90
+ );
91
+ };
92
+ const previous = renderer.getRenderTarget();
93
+ Object.assign(scene, { overrideMaterial: darkness });
94
+ renderer.setRenderTarget(target);
95
+ renderer.render(scene, camera);
96
+ Object.assign(scene, { overrideMaterial: null });
97
+
98
+ const horizontal = new ShaderMaterial({ ...HorizontalBlurShader, uniforms: UniformsUtils.clone(HorizontalBlurShader.uniforms) });
99
+ const vertical = new ShaderMaterial({ ...VerticalBlurShader, uniforms: UniformsUtils.clone(VerticalBlurShader.uniforms) });
100
+ const quad = new FullScreenQuad(horizontal);
101
+ // The blur shaders reach 4 steps; three passes of shrinking steps blend into one smooth falloff
102
+ const step = (reach * texelsX) / width / 4;
103
+ [step, step / 2, step / 4].forEach((amount) => {
104
+ Object.assign(horizontal.uniforms, { tDiffuse: { value: target.texture }, h: { value: amount / texelsX } });
105
+ quad.material = horizontal;
106
+ renderer.setRenderTarget(blurred);
107
+ quad.render(renderer);
108
+ Object.assign(vertical.uniforms, { tDiffuse: { value: blurred.texture }, v: { value: amount / texelsZ } });
109
+ quad.material = vertical;
110
+ renderer.setRenderTarget(target);
111
+ quad.render(renderer);
112
+ });
113
+ renderer.setRenderTarget(previous);
114
+ [blurred, darkness, horizontal, vertical, quad].forEach((d) => d.dispose());
115
+
116
+ // The plane faces down as the camera looked up, so its texture lines up, and is mirrored to face up
117
+ const mesh = new Mesh(
118
+ new PlaneGeometry(width, depth).rotateX(Math.PI / 2),
119
+ ditherAlpha(new MeshBasicMaterial({ map: target.texture, transparent: true, depthWrite: false, opacity }), 2)
120
+ );
121
+ mesh.position.copy(center);
122
+ mesh.scale.y = -1;
123
+ // The floor's pool of light first, then the shadow in it, both before other transparent objects
124
+ const floor = new Mesh(
125
+ new PlaneGeometry(size.x + size.y * floorReach * 2, size.z + size.y * floorReach * 2).rotateX(-Math.PI / 2),
126
+ ditherAlpha(new MeshBasicMaterial({ alphaMap: createPool(), transparent: true, depthWrite: false, opacity: floorLight }), 1)
127
+ );
128
+ floor.position.copy(center);
129
+ Object.assign(floor, { renderOrder: -2 });
130
+ Object.assign(mesh, { renderOrder: -1 });
131
+ scene.add(floor, mesh);
132
+ return { mesh, floor, target };
133
+ }
134
+
135
+ /** A radial falloff from the center to the edges, as a spotlight's pool on the floor */
136
+ function createPool(): CanvasTexture {
137
+ const canvas = Object.assign(document.createElement("canvas"), { width: 128, height: 128 });
138
+ const ctx = canvas.getContext("2d")!;
139
+ const gradient = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
140
+ gradient.addColorStop(0, "#fff");
141
+ gradient.addColorStop(0.3, "#999");
142
+ gradient.addColorStop(0.7, "#222");
143
+ gradient.addColorStop(1, "#000");
144
+ Object.assign(ctx, { fillStyle: gradient }).fillRect(0, 0, 128, 128);
145
+ return new CanvasTexture(canvas);
146
+ }
147
+
148
+ export function disposeGroundShadow(shadow: GroundShadow): void {
149
+ [shadow.mesh, shadow.floor].forEach((mesh) => {
150
+ mesh.removeFromParent();
151
+ mesh.geometry.dispose();
152
+ mesh.material.alphaMap?.dispose();
153
+ mesh.material.dispose();
154
+ });
155
+ shadow.target.dispose();
156
+ }
157
+
158
+ /**
159
+ * Jitters the alpha by about `levels` of the screen's 256 per pixel, so a faint gradient blended over the background doesn't show the 8-bit steps of
160
+ * the blend; three's `dithering` jitters the color, which these blends' steps don't come from
161
+ */
162
+ function ditherAlpha(material: MeshBasicMaterial, levels: number): MeshBasicMaterial {
163
+ material.onBeforeCompile = (shader) => {
164
+ shader.fragmentShader = shader.fragmentShader.replace(
165
+ "#include <dithering_fragment>",
166
+ `gl_FragColor.a = clamp( gl_FragColor.a + ( rand( gl_FragCoord.xy ) - 0.5 ) * ${(levels / 255).toFixed(5)}, 0.0, 1.0 );`
167
+ );
168
+ };
169
+ return material;
170
+ }