abstract-3d 4.0.0-beta.0 → 4.0.0-beta.4

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 (44) hide show
  1. package/README.md +2 -2
  2. package/lib/renderers/react/react.d.ts.map +1 -1
  3. package/lib/renderers/react/react.js +4 -3
  4. package/lib/renderers/react/react.js.map +1 -1
  5. package/lib/renderers/three/appearance/appearance.d.ts.map +1 -1
  6. package/lib/renderers/three/appearance/appearance.js +4 -1
  7. package/lib/renderers/three/appearance/appearance.js.map +1 -1
  8. package/lib/renderers/three/build/build.js +7 -3
  9. package/lib/renderers/three/build/build.js.map +1 -1
  10. package/lib/renderers/three/build/materials.d.ts +7 -3
  11. package/lib/renderers/three/build/materials.d.ts.map +1 -1
  12. package/lib/renderers/three/build/materials.js +10 -5
  13. package/lib/renderers/three/build/materials.js.map +1 -1
  14. package/lib/renderers/three/quality/quality.d.ts +5 -24
  15. package/lib/renderers/three/quality/quality.d.ts.map +1 -1
  16. package/lib/renderers/three/quality/quality.js +8 -44
  17. package/lib/renderers/three/quality/quality.js.map +1 -1
  18. package/lib/renderers/three/shadow/shadow.d.ts +15 -0
  19. package/lib/renderers/three/shadow/shadow.d.ts.map +1 -0
  20. package/lib/renderers/three/shadow/shadow.js +77 -0
  21. package/lib/renderers/three/shadow/shadow.js.map +1 -0
  22. package/lib/renderers/three/viewer/options.d.ts +7 -3
  23. package/lib/renderers/three/viewer/options.d.ts.map +1 -1
  24. package/lib/renderers/three/viewer/options.js +2 -0
  25. package/lib/renderers/three/viewer/options.js.map +1 -1
  26. package/lib/renderers/three/viewer/viewer.d.ts +3 -2
  27. package/lib/renderers/three/viewer/viewer.d.ts.map +1 -1
  28. package/lib/renderers/three/viewer/viewer.js +33 -25
  29. package/lib/renderers/three/viewer/viewer.js.map +1 -1
  30. package/package.json +1 -3
  31. package/src/renderers/ifc/ifc.test.ts +7 -5
  32. package/src/renderers/react/react.tsx +5 -4
  33. package/src/renderers/three/appearance/appearance.test.ts +14 -4
  34. package/src/renderers/three/appearance/appearance.ts +4 -1
  35. package/src/renderers/three/build/build.test.ts +18 -10
  36. package/src/renderers/three/build/build.ts +6 -3
  37. package/src/renderers/three/build/materials.ts +14 -8
  38. package/src/renderers/three/compile/compile.test.ts +32 -19
  39. package/src/renderers/three/quality/quality.test.ts +11 -4
  40. package/src/renderers/three/quality/quality.ts +7 -66
  41. package/src/renderers/three/shadow/shadow.ts +111 -0
  42. package/src/renderers/three/viewer/options.ts +9 -3
  43. package/src/renderers/three/viewer/viewer.ts +35 -28
  44. package/src/custom_typings/n8ao.d.ts +0 -11
@@ -1,43 +1,56 @@
1
1
  import { Matrix4, Vector3 } from "three";
2
- import { describe, expect, it } from "vitest";
2
+ import { assert, describe, expect, it } from "vitest";
3
3
  import type { Group, Scene } from "../../../abstract-3d.js";
4
4
  import { alignedDimension, box, group, line, sphere, text, vec3, vec3Zero } from "../../../abstract-3d.js";
5
+ import type { CompiledScene, Item } from "./compile.js";
5
6
  import { compileScene, getItemMatrix } from "./compile.js";
6
7
 
7
8
  const red = { normal: "rgb(255, 0, 0)" };
8
9
  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)
10
+ const apply = (m: Matrix4 | undefined, x: number, y: number, z: number): ReadonlyArray<number> => {
11
+ assert(m);
12
+ return new Vector3(x, y, z)
11
13
  .applyMatrix4(m)
12
14
  .toArray()
13
15
  .map((v) => Math.round(v * 1000) / 1000 + 0);
16
+ };
17
+ const getItem = (compiled: CompiledScene, index: number): Item => {
18
+ const item = compiled.items.at(index);
19
+ assert(item);
20
+ return item;
21
+ };
22
+ // Destructured, as the build's and lint's compiler options disagree on reading an index signature by dot or bracket
23
+ const groupId = (g: Group): string => {
24
+ const { id = "" }: Record<string, string | undefined> = g.data ?? {};
25
+ return id;
26
+ };
14
27
 
15
28
  describe("compileScene", () => {
16
29
  it("flattens nested groups into items with world matrices", () => {
17
30
  const inner = group([sphere(1, red)], vec3(0, 10, 0));
18
31
  const compiled = compileScene(scene([group([box(vec3(1, 1, 1), red)], vec3(100, 0, 0), vec3Zero, [inner])]));
19
32
  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]);
33
+ expect(apply(compiled.items.at(0)?.matrix, 0, 0, 0)).toEqual([100, 0, 0]);
34
+ expect(apply(compiled.items.at(1)?.matrix, 0, 0, 0)).toEqual([100, 10, 0]);
22
35
  });
23
36
 
24
37
  it("applies group rotation before the group's position", () => {
25
38
  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]);
39
+ expect(apply(compiled.items.at(0)?.matrix, 1, 0, 0)).toEqual([5, 1, 0]);
27
40
  });
28
41
 
29
42
  it("rotates the scene by its deprecated rotation, then moves it by minus its deprecated center", () => {
30
43
  const g = group([box(vec3(1, 1, 1), red)], vec3(10, 0, 0));
31
44
  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]);
45
+ expect(apply(centered.items.at(0)?.matrix, 0, 0, 0)).toEqual([0, 0, 0]);
33
46
  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]);
47
+ expect(apply(rotated.items.at(0)?.matrix, 1, 0, 0)).toEqual([-1, 0, 0]);
35
48
  });
36
49
 
37
50
  it("gives every item of a top-level group that group's target, and the data of the group it's in", () => {
38
51
  const inner = group([sphere(1, red)], vec3Zero, vec3Zero, [], { part: "motor" });
39
52
  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 ?? "");
53
+ const compiled = compileScene(scene([fan, group([box(vec3(1, 1, 1), red)], vec3Zero, vec3Zero, [], { id: "coil" })]), groupId);
41
54
  expect(compiled.targets).toEqual([
42
55
  { kind: "group", id: "fan", root: 0, rootData: { id: "fan" } },
43
56
  { kind: "group", id: "coil", root: 1, rootData: { id: "coil" } },
@@ -70,9 +83,9 @@ describe("compileScene", () => {
70
83
  );
71
84
  const compiled = compileScene(scene([group([], vec3(10, 0, 0), vec3Zero, [fan])]));
72
85
  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);
86
+ expect(compiled.animations.at(0)?.speed).toEqual(vec3(0, 0, Math.PI));
87
+ expect(compiled.items.at(0)?.animation).toBe(0);
88
+ const world = (seconds: number): ReadonlyArray<number> => apply(getItemMatrix(compiled, getItem(compiled, 0), seconds, new Matrix4()), 1, 0, 0);
76
89
  expect(world(0)).toEqual([11, 5, 0]);
77
90
  expect(world(0.5)).toEqual([10, 6, 0]);
78
91
  });
@@ -82,8 +95,8 @@ describe("compileScene", () => {
82
95
  const inner = group([box(vec3(1, 1, 1), red)], vec3(1, 0, 0), vec3Zero, [], {}, spin);
83
96
  const compiled = compileScene(scene([group([], vec3Zero, vec3Zero, [inner], {}, spin)]));
84
97
  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]);
98
+ expect(apply(getItemMatrix(compiled, getItem(compiled, 0), 0.5, new Matrix4()), 0, 0, 0)).toEqual([0, 1, 0]);
99
+ expect(apply(getItemMatrix(compiled, getItem(compiled, 0), 0.5, new Matrix4()), 1, 0, 0)).toEqual([-1, 1, 0]);
87
100
  });
88
101
 
89
102
  it("converts aligned dimensions to dimension items with a target per dimension", () => {
@@ -92,22 +105,22 @@ describe("compileScene", () => {
92
105
  const items = compiled.items.filter((i) => i.style === "dimension");
93
106
  expect(items.length).toBeGreaterThan(0);
94
107
  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" });
108
+ expect(compiled.targets[items.at(0)?.target ?? -1]).toEqual({ kind: "dimension", id: "fan" });
96
109
  });
97
110
 
98
111
  it("adds relation shapes, with an outline around their boxes", () => {
99
112
  const relation = { id: "r1", pos: vec3Zero, groups: [group([box(vec3(10, 10, 10), red), sphere(1, red)], vec3(5, 0, 0))] };
100
113
  const compiled = compileScene(scene([], { relations: [relation] }));
101
114
  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;
115
+ expect(compiled.targets[getItem(compiled, 0).target]).toEqual({ kind: "relation", relation });
116
+ const outline = getItem(compiled, 1).mesh.geometry;
104
117
  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]);
118
+ expect(apply(compiled.items.at(0)?.matrix, 0, 0, 0)).toEqual([5, 0, 0]);
106
119
  });
107
120
 
108
121
  it("keeps the world matrix of each top-level group for its tooltips", () => {
109
122
  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]);
123
+ expect(apply(compiled.roots.at(0), 0, 0, 0)).toEqual([2, 0, 0]);
111
124
  expect(apply(compiled.matrix, 0, 0, 0)).toEqual([-1, 0, 0]);
112
125
  });
113
126
  });
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { Quality, TierGovernor } from "./quality.js";
3
- import { recordFrame } from "./quality.js";
3
+ import { getPixelRatio, recordFrame } from "./quality.js";
4
4
 
5
- const governor = (quality: Quality): TierGovernor => ({ quality, tier: quality === "auto" ? "medium" : quality, sum: 0, count: 0 });
5
+ const governor = (quality: Quality): TierGovernor => ({ quality, tier: quality === "auto" ? "high" : quality, sum: 0, count: 0 });
6
6
  const record = (g: TierGovernor, intervals: ReadonlyArray<number>): ReadonlyArray<boolean> => intervals.map((ms) => recordFrame(g, ms));
7
7
 
8
8
  describe("recordFrame", () => {
@@ -19,12 +19,19 @@ describe("recordFrame", () => {
19
19
  expect(g.tier).toBe("low");
20
20
  });
21
21
 
22
- it("keeps auto at medium at 60 frames per second, ignoring the pauses between moves", () => {
22
+ it("keeps auto at high at 60 frames per second, ignoring the pauses between moves", () => {
23
23
  const g = governor("auto");
24
24
  record(
25
25
  g,
26
26
  Array.from({ length: 100 }, (_, i) => (i % 10 === 0 ? 2000 : 16.7))
27
27
  );
28
- expect(g.tier).toBe("medium");
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);
29
36
  });
30
37
  });
@@ -1,35 +1,20 @@
1
- import { N8AOPass } from "n8ao";
2
- import type { OrthographicCamera, PerspectiveCamera, Scene, WebGLRenderer } from "three";
3
- import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
4
- import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
5
-
6
1
  /**
7
- * Quality tiers that spend the GPU where it shows: the camera is still most of the time, and a still frame can afford ambient occlusion that a moving
8
- * one can't on a weak GPU. Low renders directly; medium adds ambient occlusion at rest, faded in; high has it while moving too. Effects only at rest
9
- * is how progressive model viewers such as Sketchfab's stay fast; the AO is N8AO, configured as the r3f renderer's
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
10
5
  */
11
6
 
12
7
  export type Tier = "low" | "medium" | "high";
13
8
  export type Quality = Tier | "auto";
14
9
 
15
- /** `auto` starts at medium, and drops to low for good once moving frames average slower than 40 per second */
10
+ /** `auto` starts at high, and drops to low for good once moving frames average slower than 40 per second */
16
11
  export type TierGovernor = { readonly quality: Quality; tier: Tier; sum: number; count: number };
17
12
 
18
- export type RenderPath = {
19
- readonly tier: Tier;
20
- /** With ambient occlusion, faded in by its intensity over the first frames at rest */
21
- readonly composer: EffectComposer | undefined;
22
- readonly ao: { readonly configuration: { intensity: number } } | undefined;
23
- readonly fade: { frame: number };
24
- };
25
-
26
- const pixelRatioCaps: Record<Tier, number> = { low: 1, medium: 1.5, high: 2 };
13
+ const pixelRatioCaps: Record<Tier, number> = { low: 1, medium: 1.5, high: Infinity };
27
14
  const windowSize = 30;
28
15
  const slowFrameMs = 25;
29
16
  /** Longer intervals are pauses between moves, not slow frames */
30
17
  const pauseMs = 250;
31
- const fadeFrames = 6;
32
- const intensity = 2;
33
18
 
34
19
  /** Records the time between two frames while the camera moves; true when that dropped the tier */
35
20
  export function recordFrame(governor: TierGovernor, intervalMs: number): boolean {
@@ -46,50 +31,6 @@ export function recordFrame(governor: TierGovernor, intervalMs: number): boolean
46
31
  return slow;
47
32
  }
48
33
 
49
- /** Also sets the renderer's pixel ratio, capped by the tier */
50
- export function createRenderPath(renderer: WebGLRenderer, scene: Scene, camera: PerspectiveCamera | OrthographicCamera, tier: Tier): RenderPath {
51
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, pixelRatioCaps[tier]));
52
- if (tier === "low") {
53
- return { tier, composer: undefined, ao: undefined, fade: { frame: 0 } };
54
- }
55
- const composer = new EffectComposer(renderer);
56
- const ao = new N8AOPass(scene, camera);
57
- Object.assign(ao.configuration, {
58
- aoRadius: 40,
59
- distanceFalloff: 0.2,
60
- screenSpaceRadius: true,
61
- halfRes: true,
62
- denoiseSamples: 4,
63
- gammaCorrection: false,
64
- });
65
- // Multisampled, as SMAA, which N8AO suggests, would lose the antialiased alpha at edges over the transparent background
66
- ao.beautyRenderTarget.samples = 4;
67
- composer.addPass(ao);
68
- // Tone mapping and sRGB, which rendering to the screen otherwise does
69
- composer.addPass(new OutputPass());
70
- return { tier, composer, ao, fade: { frame: 0 } };
71
- }
72
-
73
- /** Renders a frame; true while the frame at rest needs more frames to fade its ambient occlusion in */
74
- export function renderPath(
75
- path: RenderPath,
76
- renderer: WebGLRenderer,
77
- scene: Scene,
78
- camera: PerspectiveCamera | OrthographicCamera,
79
- moving: boolean
80
- ): boolean {
81
- if (!path.composer || !path.ao || (moving && path.tier === "medium")) {
82
- path.fade.frame = 0;
83
- renderer.render(scene, camera);
84
- return false;
85
- }
86
- path.fade.frame = path.tier === "high" ? fadeFrames : Math.min(fadeFrames, path.fade.frame + 1);
87
- path.ao.configuration.intensity = (intensity * path.fade.frame) / fadeFrames;
88
- path.composer.render();
89
- return path.fade.frame < fadeFrames;
90
- }
91
-
92
- export function disposeRenderPath(path: RenderPath): void {
93
- path.composer?.passes.forEach((p) => p.dispose());
94
- path.composer?.dispose();
34
+ export function getPixelRatio(tier: Tier, devicePixelRatio: number): number {
35
+ return Math.min(devicePixelRatio, pixelRatioCaps[tier]);
95
36
  }
@@ -0,0 +1,111 @@
1
+ import {
2
+ Box3,
3
+ type Scene,
4
+ Mesh,
5
+ MeshBasicMaterial,
6
+ MeshDepthMaterial,
7
+ OrthographicCamera,
8
+ PlaneGeometry,
9
+ ShaderMaterial,
10
+ UniformsUtils,
11
+ Vector3,
12
+ type WebGLRenderer,
13
+ WebGLRenderTarget,
14
+ } from "three";
15
+ import { FullScreenQuad } from "three/addons/postprocessing/Pass.js";
16
+ import { HorizontalBlurShader } from "three/addons/shaders/HorizontalBlurShader.js";
17
+ import { VerticalBlurShader } from "three/addons/shaders/VerticalBlurShader.js";
18
+ import type { BuiltScene } from "../build/build.js";
19
+
20
+ /**
21
+ * 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
22
+ * 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
23
+ * ContactShadows with `frames={1}`
24
+ */
25
+
26
+ export type GroundShadow = { readonly mesh: Mesh<PlaneGeometry, MeshBasicMaterial>; readonly target: WebGLRenderTarget };
27
+
28
+ /** The layer the solids are drawn on for the shadow, so text, lines, images and dimensions don't cast one */
29
+ const shadowLayer = 1;
30
+ /** Of the texture's longer side */
31
+ const resolution = 512;
32
+ /** The blur's reach, of the solids' height */
33
+ const softness = 0.15;
34
+ const opacity = 0.8;
35
+
36
+ /** Undefined for a scene without solids; renders with `renderer`, and leaves its render target as it was */
37
+ export function createGroundShadow(renderer: WebGLRenderer, scene: Scene, built: BuiltScene): GroundShadow | undefined {
38
+ const bounds = new Box3();
39
+ built.parts.forEach((part) => {
40
+ if (part.kind === "solid" && part.object.parent !== built.dimensions && part.object.boundingBox) {
41
+ part.object.layers.enable(shadowLayer);
42
+ bounds.union(part.object.boundingBox);
43
+ }
44
+ });
45
+ if (bounds.isEmpty()) {
46
+ return undefined;
47
+ }
48
+ const size = bounds.getSize(new Vector3());
49
+ // The plane reaches past the solids by twice the blur's reach on every side
50
+ const reach = softness * size.y;
51
+ const [width, depth] = [size.x + reach * 4, size.z + reach * 4];
52
+ const center = new Vector3((bounds.min.x + bounds.max.x) / 2, bounds.min.y, (bounds.min.z + bounds.max.z) / 2);
53
+ const [texelsX, texelsZ] =
54
+ width > depth ? [resolution, Math.ceil((resolution * depth) / width)] : [Math.ceil((resolution * width) / depth), resolution];
55
+ const [target, blurred] = [new WebGLRenderTarget(texelsX, texelsZ), new WebGLRenderTarget(texelsX, texelsZ)];
56
+
57
+ // Looks up from the ground, seeing as far as the solids are high
58
+ const camera = new OrthographicCamera(-width / 2, width / 2, depth / 2, -depth / 2, 0, size.y);
59
+ camera.position.copy(center);
60
+ camera.rotation.x = Math.PI / 2;
61
+ camera.layers.set(shadowLayer);
62
+ camera.updateMatrixWorld();
63
+ // Depth tested, so at each point the surface closest to the ground darkens it, not the last one drawn
64
+ const darkness = new MeshDepthMaterial();
65
+ darkness.onBeforeCompile = (shader) => {
66
+ shader.fragmentShader = shader.fragmentShader.replace(
67
+ "gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );",
68
+ "gl_FragColor = vec4( vec3( 0.0 ), 1.0 - fragCoordZ );"
69
+ );
70
+ };
71
+ const previous = renderer.getRenderTarget();
72
+ Object.assign(scene, { overrideMaterial: darkness });
73
+ renderer.setRenderTarget(target);
74
+ renderer.render(scene, camera);
75
+ Object.assign(scene, { overrideMaterial: null });
76
+
77
+ const horizontal = new ShaderMaterial({ ...HorizontalBlurShader, uniforms: UniformsUtils.clone(HorizontalBlurShader.uniforms) });
78
+ const vertical = new ShaderMaterial({ ...VerticalBlurShader, uniforms: UniformsUtils.clone(VerticalBlurShader.uniforms) });
79
+ const quad = new FullScreenQuad(horizontal);
80
+ // The blur shaders reach 4 steps; three passes of shrinking steps blend into one smooth falloff
81
+ const step = (reach * texelsX) / width / 4;
82
+ [step, step / 2, step / 4].forEach((amount) => {
83
+ Object.assign(horizontal.uniforms, { tDiffuse: { value: target.texture }, h: { value: amount / texelsX } });
84
+ quad.material = horizontal;
85
+ renderer.setRenderTarget(blurred);
86
+ quad.render(renderer);
87
+ Object.assign(vertical.uniforms, { tDiffuse: { value: blurred.texture }, v: { value: amount / texelsZ } });
88
+ quad.material = vertical;
89
+ renderer.setRenderTarget(target);
90
+ quad.render(renderer);
91
+ });
92
+ renderer.setRenderTarget(previous);
93
+ [blurred, darkness, horizontal, vertical, quad].forEach((d) => d.dispose());
94
+
95
+ // The plane faces down as the camera looked up, so its texture lines up, and is mirrored to face up
96
+ const mesh = new Mesh(
97
+ new PlaneGeometry(width, depth).rotateX(Math.PI / 2),
98
+ new MeshBasicMaterial({ map: target.texture, transparent: true, depthWrite: false, opacity })
99
+ );
100
+ mesh.position.copy(center);
101
+ mesh.scale.y = -1;
102
+ scene.add(mesh);
103
+ return { mesh, target };
104
+ }
105
+
106
+ export function disposeGroundShadow(shadow: GroundShadow): void {
107
+ shadow.mesh.removeFromParent();
108
+ shadow.mesh.geometry.dispose();
109
+ shadow.mesh.material.dispose();
110
+ shadow.target.dispose();
111
+ }
@@ -34,7 +34,7 @@ export type ViewerOptions<Content> = {
34
34
  */
35
35
  readonly activeRelations?: ReadonlyArray<string>;
36
36
  readonly showDimensions?: boolean;
37
- /** View, buffer zones and padding are read when the scene is fitted: first, and for a new camera or canvas size */
37
+ /** View, buffer zones and padding are read when the scene is fitted: first, and for a new camera or canvas aspect */
38
38
  readonly camera?: CameraOptions;
39
39
  readonly view?: View;
40
40
  readonly bufferZones?: BufferZones;
@@ -46,7 +46,9 @@ export type ViewerOptions<Content> = {
46
46
  /** Lights faces by which way they turn and gives metal reflections: `studio` by default, `room` for three's RoomEnvironment, or null for none */
47
47
  readonly environment?: Texture | "studio" | "room" | null;
48
48
  readonly environmentIntensity?: number;
49
- /** `low` by default, without ambient occlusion */
49
+ /** A soft shadow on the ground under the scene, rendered once per build; on by default */
50
+ readonly groundShadow?: boolean;
51
+ /** The resolution: `low` 1 and `medium` up to 1.5 pixels per CSS pixel, `high`, the default, the screen's; `auto` high until moving gets slow */
50
52
  readonly quality?: Quality;
51
53
  readonly useAlphaTest?: boolean;
52
54
  readonly materialStateImages?: Record<string, string>;
@@ -61,10 +63,12 @@ export type ViewerOptions<Content> = {
61
63
  readonly onHoverGroup?: (id: string | undefined, rootData: Data, data: Data, event: SceneEvent) => void;
62
64
  readonly onContextMenuGroup?: (id: string, rootData: Data, data: Data, left: number, top: number, event: SceneEvent) => void;
63
65
  readonly onClickRelation?: (relation: Relation, event: SceneEvent) => void;
66
+ /** A click, not a drag, on nothing clickable, e.g. the background; r3f's Canvas event of the same name */
67
+ readonly onPointerMissed?: (event: MouseEvent) => void;
64
68
  };
65
69
 
66
70
  /** What an update has to redo, in the order it's redone */
67
- export type Change = "compile" | "build" | "camera" | "controls" | "gizmo" | "lights" | "quality" | "paint" | "dimensions" | "overlays";
71
+ export type Change = "compile" | "build" | "shadow" | "camera" | "controls" | "gizmo" | "lights" | "quality" | "paint" | "dimensions" | "overlays";
68
72
 
69
73
  /** What passing each option redoes; callbacks and what's read later need nothing */
70
74
  export const effects: { readonly [K in keyof ViewerOptions<unknown>]-?: ReadonlyArray<Change> } = {
@@ -86,6 +90,7 @@ export const effects: { readonly [K in keyof ViewerOptions<unknown>]-?: Readonly
86
90
  lights: ["lights"],
87
91
  environment: ["lights"],
88
92
  environmentIntensity: ["lights"],
93
+ groundShadow: ["shadow"],
89
94
  quality: ["quality"],
90
95
  popovers: ["overlays"],
91
96
  getTooltips: ["overlays"],
@@ -95,4 +100,5 @@ export const effects: { readonly [K in keyof ViewerOptions<unknown>]-?: Readonly
95
100
  onHoverGroup: [],
96
101
  onContextMenuGroup: [],
97
102
  onClickRelation: [],
103
+ onPointerMissed: [],
98
104
  };
@@ -32,8 +32,10 @@ import { configureControls, createControls, setPose } from "../camera/controls.j
32
32
  import { createGizmo } from "../camera/gizmo.js";
33
33
  import type { CompiledScene } from "../compile/compile.js";
34
34
  import { compileScene } from "../compile/compile.js";
35
- import type { RenderPath, TierGovernor } from "../quality/quality.js";
36
- import { createRenderPath, disposeRenderPath, recordFrame, renderPath } from "../quality/quality.js";
35
+ import type { TierGovernor } from "../quality/quality.js";
36
+ import { getPixelRatio, recordFrame } from "../quality/quality.js";
37
+ import type { GroundShadow } from "../shadow/shadow.js";
38
+ import { createGroundShadow, disposeGroundShadow } from "../shadow/shadow.js";
37
39
  import type { GlyphAtlas } from "../text/text.js";
38
40
  import { createGlyphAtlas } from "../text/text.js";
39
41
  import type { Change, ViewerOptions } from "./options.js";
@@ -77,7 +79,7 @@ export type Viewer<Content> = {
77
79
  compiled?: CompiledScene;
78
80
  built?: BuiltScene;
79
81
  gizmo?: ViewportGizmo;
80
- path?: RenderPath;
82
+ shadow?: GroundShadow;
81
83
  governor?: TierGovernor;
82
84
  readonly environments: Partial<Record<"studio" | "room", Texture>>;
83
85
  /** The scene is built and its shaders compiled */
@@ -164,12 +166,12 @@ export function disposeViewer<Content>(v: Viewer<Content>): void {
164
166
  v.resizeObserver.disconnect();
165
167
  v.controls.orbit.dispose();
166
168
  v.gizmo?.dispose();
167
- if (v.path) {
168
- disposeRenderPath(v.path);
169
- }
170
169
  if (v.built) {
171
170
  disposeScene(v.built);
172
171
  }
172
+ if (v.shadow) {
173
+ disposeGroundShadow(v.shadow);
174
+ }
173
175
  Object.values(v.environments).forEach((e) => e.dispose());
174
176
  v.geometries.forEach((g) => g.dispose());
175
177
  v.atlas.texture.dispose();
@@ -191,16 +193,22 @@ function apply<Content>(v: Viewer<Content>, changes: ReadonlySet<Change>): void
191
193
  v.structure = { activeComponents: options.activeComponents, relationsActive: options.activeRelations !== undefined };
192
194
  rebuild(v);
193
195
  }
196
+ if (changes.has("shadow") || changes.has("build")) {
197
+ if (v.shadow) {
198
+ disposeGroundShadow(v.shadow);
199
+ }
200
+ v.shadow = v.built && options.groundShadow !== false ? createGroundShadow(v.renderer, v.scene, v.built) : undefined;
201
+ }
194
202
  if (changes.has("camera")) {
195
203
  const { near = 0.1, far = 2000, ...rest } = options.camera ?? { type: "Perspective" };
196
204
  v.camera = v.cameras[rest.type];
197
- Object.assign(v.camera, { near, far, ...("fov" in rest && rest.fov !== undefined ? { fov: rest.fov } : {}) });
205
+ Object.assign(v.camera, { near, far, ...(rest.type === "Perspective" ? { fov: rest.fov ?? 45 } : {}) });
198
206
  v.controls.orbit.object = v.camera;
199
207
  }
200
208
  if (changes.has("controls")) {
201
209
  configureControls(v.controls, options.controls ?? {});
202
210
  }
203
- // As the r3f renderer, the scene is fitted at first, and for a new camera or canvas size
211
+ // As the r3f renderer, the scene is fitted at first, and for a new camera or canvas aspect
204
212
  if ((changes.has("build") && !v.fitted) || changes.has("camera")) {
205
213
  fitPose(v);
206
214
  }
@@ -211,11 +219,9 @@ function apply<Content>(v: Viewer<Content>, changes: ReadonlySet<Change>): void
211
219
  updateLights(v);
212
220
  }
213
221
  if (changes.has("quality")) {
214
- const quality = options.quality ?? "low";
215
- v.governor = { quality, tier: quality === "auto" ? "medium" : quality, sum: 0, count: 0 };
216
- }
217
- if (changes.has("quality") || changes.has("camera")) {
218
- updateRenderPath(v);
222
+ const quality = options.quality ?? "high";
223
+ v.governor = { quality, tier: quality === "auto" ? "high" : quality, sum: 0, count: 0 };
224
+ v.renderer.setPixelRatio(getPixelRatio(v.governor.tier, window.devicePixelRatio));
219
225
  }
220
226
  if ((changes.has("paint") || changes.has("build")) && v.built) {
221
227
  const start = performance.now();
@@ -284,7 +290,7 @@ function renderFrame<Content>(v: Viewer<Content>, now: number): void {
284
290
  renderer.info.reset();
285
291
  const moving = v.controls.orbit.update(Math.min(interval / 1000, 0.1)) || !!gizmo?.animating;
286
292
  if (moving && loop.wasMoving && v.governor && recordFrame(v.governor, interval)) {
287
- updateRenderPath(v);
293
+ renderer.setPixelRatio(getPixelRatio(v.governor.tier, window.devicePixelRatio));
288
294
  }
289
295
  loop.wasMoving = moving;
290
296
  const animated = !!built && built.animatedItems.length > 0;
@@ -296,33 +302,38 @@ function renderFrame<Content>(v: Viewer<Content>, now: number): void {
296
302
  if (built) {
297
303
  cullScene(built, camera.position);
298
304
  }
299
- const fading = v.loaded && v.path ? renderPath(v.path, renderer, v.scene, camera, moving || animated) : false;
305
+ if (v.loaded) {
306
+ renderer.render(v.scene, camera);
307
+ }
300
308
  gizmo?.render();
301
309
  v.overlays.renderer.render(v.overlays.scene, camera);
302
310
  Object.assign(v.stats, { frames: v.stats.frames + 1, frameMs: performance.now() - start, drawCalls: renderer.info.render.calls });
303
- if (moving || animated || fading) {
311
+ if (moving || animated) {
304
312
  invalidate(v);
305
313
  } else {
306
314
  loop.last = 0;
307
315
  }
308
316
  }
309
317
 
318
+ /** As the r3f renderer, a resize refits only when the canvas' aspect changes */
310
319
  function onResize<Content>(v: Viewer<Content>, rect: DOMRectReadOnly): void {
320
+ const aspectChanged = rect.width / rect.height !== v.size.width / v.size.height;
311
321
  Object.assign(v.size, { width: rect.width, height: rect.height });
312
322
  v.renderer.setSize(rect.width, rect.height, false);
313
- v.path?.composer?.setSize(rect.width, rect.height);
314
323
  v.overlays.renderer.setSize(rect.width, rect.height);
315
324
  v.gizmo?.update();
316
- fitPose(v);
325
+ if (aspectChanged) {
326
+ fitPose(v);
327
+ }
317
328
  invalidate(v);
318
329
  }
319
330
 
320
- /** Fits the projection to the scene, and moves the camera to look at it from the view */
331
+ /** Fits the projection to the scene, and moves the camera to look from the view; as the r3f renderer, the target stays where it was panned to */
321
332
  function fitPose<Content>(v: Viewer<Content>): void {
322
333
  const placement = fitScene(v);
323
334
  if (placement) {
324
335
  const { x, y, z } = placement.direction;
325
- setPose(v.controls, new Vector3(x, y, z).multiplyScalar(placement.distance), new Vector3());
336
+ setPose(v.controls, new Vector3(x, y, z).multiplyScalar(placement.distance), v.controls.orbit.target);
326
337
  v.fitted = true;
327
338
  }
328
339
  }
@@ -369,13 +380,16 @@ function hoverAtPointer<Content>(v: Viewer<Content>): void {
369
380
 
370
381
  function onClick<Content>(v: Viewer<Content>, event: MouseEvent): void {
371
382
  const { down } = v.pointer;
372
- const hit = down && Math.hypot(event.clientX - down.x, event.clientY - down.y) <= clickTolerance ? pick(v, event) : undefined;
383
+ const clicked = !!down && Math.hypot(event.clientX - down.x, event.clientY - down.y) <= clickTolerance;
384
+ const hit = clicked ? pick(v, event) : undefined;
373
385
  const target = hit && v.compiled!.targets[hit.target];
374
386
  const args = hit && getGroupArgs(v, hit);
375
387
  if (hit && target?.kind === "relation") {
376
388
  v.options.onClickRelation?.(target.relation, { nativeEvent: event, point: hit.point });
377
389
  } else if (hit && args) {
378
390
  v.options.onClickGroup?.(...args, { nativeEvent: event, point: hit.point });
391
+ } else if (clicked) {
392
+ v.options.onPointerMissed?.(event);
379
393
  }
380
394
  }
381
395
 
@@ -529,12 +543,5 @@ function updateOverlays<Content>(v: Viewer<Content>): void {
529
543
  setOverlays(v.overlays, overlays, options.onOverlays);
530
544
  }
531
545
 
532
- function updateRenderPath<Content>(v: Viewer<Content>): void {
533
- if (v.path) {
534
- disposeRenderPath(v.path);
535
- }
536
- v.path = createRenderPath(v.renderer, v.scene, v.camera, v.governor?.tier ?? "low");
537
- }
538
-
539
546
  const raycaster = new Raycaster();
540
547
  const ndc = new Vector2();
@@ -1,11 +0,0 @@
1
- declare module "n8ao" {
2
- import type { Camera, Scene, WebGLRenderTarget } from "three";
3
- import type { Pass } from "three/addons/postprocessing/Pass.js";
4
-
5
- type AOPass = Pass & {
6
- readonly configuration: Record<string, unknown> & { intensity: number };
7
- readonly beautyRenderTarget: WebGLRenderTarget;
8
- };
9
-
10
- export const N8AOPass: new (scene: Scene, camera: Camera, width?: number, height?: number) => AOPass;
11
- }