@es3d/es3d 1.0.0 → 1.0.2

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 (80) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +95 -86
  3. package/docs/SPEC.md +426 -417
  4. package/package.json +1 -1
  5. package/scripts/es3d-cli.mjs +390 -377
  6. package/src/cache/geometry-store.js +392 -392
  7. package/src/cache/mesh-bundle.js +123 -123
  8. package/src/cache/sha256.js +73 -73
  9. package/src/format/ast.js +238 -238
  10. package/src/format/colors.js +93 -93
  11. package/src/format/compact-parser.js +171 -171
  12. package/src/format/compact-tokenizer.js +165 -165
  13. package/src/format/errors.js +247 -247
  14. package/src/format/json.js +55 -55
  15. package/src/format/limits.js +14 -14
  16. package/src/format/parse.js +132 -92
  17. package/src/format/schema.js +617 -617
  18. package/src/format/serializer.js +333 -333
  19. package/src/format/validator.js +330 -330
  20. package/src/format/value-error.js +12 -12
  21. package/src/format/value-format.js +314 -314
  22. package/src/format/values.js +459 -459
  23. package/src/format/xml-parser.js +136 -136
  24. package/src/format/xml-tokenizer.js +237 -237
  25. package/src/geometry/contour.js +86 -86
  26. package/src/geometry/extrude.js +175 -175
  27. package/src/geometry/gear.js +85 -85
  28. package/src/geometry/geometry-cache.js +159 -159
  29. package/src/geometry/lod-error.js +178 -178
  30. package/src/geometry/mesh.js +458 -458
  31. package/src/geometry/primitives.js +299 -299
  32. package/src/geometry/registry.js +645 -645
  33. package/src/geometry/revolve.js +136 -136
  34. package/src/geometry/sweep.js +441 -441
  35. package/src/index.js +47 -47
  36. package/src/io/compression.js +67 -67
  37. package/src/io/convert.js +236 -236
  38. package/src/io/download.js +13 -13
  39. package/src/io/external-scene.js +127 -127
  40. package/src/io/file-loader.js +28 -28
  41. package/src/io/gltf-pack.js +1461 -0
  42. package/src/io/gltf.js +630 -630
  43. package/src/io/mesh-import.js +104 -104
  44. package/src/io/obj.js +352 -352
  45. package/src/io/step-brep.js +439 -439
  46. package/src/io/step-exact.js +1149 -1149
  47. package/src/io/step.js +1418 -1418
  48. package/src/io/stl.js +193 -193
  49. package/src/io/svg-import.js +787 -787
  50. package/src/material/materials.js +154 -154
  51. package/src/math/mat4.js +475 -475
  52. package/src/math/random.js +30 -30
  53. package/src/math/trig.js +65 -65
  54. package/src/math/vec3.js +81 -81
  55. package/src/optimize/optimizer.js +604 -579
  56. package/src/path/path-flatten.js +322 -322
  57. package/src/path/path-parser.js +332 -332
  58. package/src/path/shapes.js +129 -129
  59. package/src/path/triangulate.js +517 -517
  60. package/src/pipeline.js +74 -74
  61. package/src/render/camera.js +168 -168
  62. package/src/render/frame-builder.js +362 -362
  63. package/src/render/geometry-pool.js +149 -149
  64. package/src/render/lighting.js +149 -149
  65. package/src/render/orbit-controls.js +237 -237
  66. package/src/render/renderer.js +258 -249
  67. package/src/render/scroll-timeline.js +104 -104
  68. package/src/render/shaders/glsl.js +285 -285
  69. package/src/render/shaders/wgsl.js +252 -252
  70. package/src/render/stage.js +365 -352
  71. package/src/render/texture-cache.js +40 -40
  72. package/src/render/textures.js +223 -223
  73. package/src/render/webgl2/webgl2-backend.js +586 -532
  74. package/src/render/webgpu/webgpu-backend.js +482 -480
  75. package/src/scene/flatten.js +754 -754
  76. package/src/scene/picking.js +161 -161
  77. package/src/scene/scene-graph.js +320 -320
  78. package/src/transform/transform.js +211 -211
  79. package/src/wasm/kernels-binary.js +3 -3
  80. package/src/wasm/kernels.js +385 -385
package/src/pipeline.js CHANGED
@@ -1,74 +1,74 @@
1
- // The ES3D processing pipeline used by the application, the benchmark, the CLI
2
- // and the tests:
3
- //
4
- // text/binary --parse--> AST --validate--> typed AST --build--> scene graph
5
- // --flatten--> RenderScene (instance table) --lazy--> meshes
6
- //
7
- // Each stage is timed so the benchmark and the statistics panel can report
8
- // where time is spent.
9
-
10
- import { parse } from './format/parse.js';
11
- import { Diagnostic } from './format/errors.js';
12
- import { buildSceneGraph } from './scene/scene-graph.js';
13
- import { flattenScene } from './scene/flatten.js';
14
- import { GeometryCache } from './geometry/geometry-cache.js';
15
-
16
- export const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
17
-
18
- /** Convert an element-attached diagnostic into a located Diagnostic. */
19
- export function elementDiagnostic(doc, d) {
20
- const el = d.element;
21
- const diag = new Diagnostic({
22
- severity: d.severity,
23
- phase: d.phase || 'geometry',
24
- code: d.code,
25
- message: el ? `<${el.name}${el.values && el.values.id ? ` id="${el.values.id}"` : ''}>: ${d.message}` : d.message,
26
- offset: el && el.nameStart >= 0 ? el.nameStart : -1,
27
- length: el && el.nameStart >= 0 ? el.nameEnd - el.nameStart : 0,
28
- uid: el ? el.uid : 0,
29
- });
30
- diag.locate(doc && doc.lineIndex);
31
- return diag;
32
- }
33
-
34
- /**
35
- * Parse, validate, build and flatten a document.
36
- * @param {string|ArrayBuffer|Uint8Array} input
37
- * @param {{cache?: GeometryCache, time?: number, progress?: number, maxInstances?: number}} [opts]
38
- */
39
- export function compile(input, opts = {}) {
40
- const t0 = now();
41
- const doc = parse(input);
42
- const t1 = now();
43
- const timings = { parse: t1 - t0, build: 0, flatten: 0 };
44
- if (!doc.ok) {
45
- return { ok: false, doc, graph: null, scene: null, cache: opts.cache || null, diagnostics: doc.diagnostics, timings };
46
- }
47
- const cache = opts.cache || new GeometryCache();
48
- const graph = buildSceneGraph(doc, cache, opts);
49
- const t2 = now();
50
- const scene = flattenScene(graph, { time: opts.time || 0, progress: opts.progress || 0 });
51
- const t3 = now();
52
- timings.build = t2 - t1;
53
- timings.flatten = t3 - t2;
54
- const extra = graph.diagnostics.map((d) => elementDiagnostic(doc, d));
55
- const diagnostics = [...doc.diagnostics, ...extra];
56
- const ok = !diagnostics.some((d) => d.severity === 'error');
57
- return { ok, doc, graph, scene, cache, diagnostics, timings };
58
- }
59
-
60
- /** Generate every mesh needed for LOD 0 of a compiled scene (benchmarks, exports). */
61
- export function generateGeometry(result, lod = 0) {
62
- const t0 = now();
63
- const { scene, cache } = result;
64
- let triangles = 0;
65
- let vertices = 0;
66
- for (const entry of scene.geometries) {
67
- const m = cache.mesh(entry, lod);
68
- vertices += m.vertexCount;
69
- triangles += m.kind === 'triangles' ? m.primitiveCount : 0;
70
- }
71
- return { ms: now() - t0, uniqueVertices: vertices, uniqueTriangles: triangles };
72
- }
73
-
74
- export { GeometryCache };
1
+ // The ES3D processing pipeline used by the application, the benchmark, the CLI
2
+ // and the tests:
3
+ //
4
+ // text/binary --parse--> AST --validate--> typed AST --build--> scene graph
5
+ // --flatten--> RenderScene (instance table) --lazy--> meshes
6
+ //
7
+ // Each stage is timed so the benchmark and the statistics panel can report
8
+ // where time is spent.
9
+
10
+ import { parse } from './format/parse.js';
11
+ import { Diagnostic } from './format/errors.js';
12
+ import { buildSceneGraph } from './scene/scene-graph.js';
13
+ import { flattenScene } from './scene/flatten.js';
14
+ import { GeometryCache } from './geometry/geometry-cache.js';
15
+
16
+ export const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
17
+
18
+ /** Convert an element-attached diagnostic into a located Diagnostic. */
19
+ export function elementDiagnostic(doc, d) {
20
+ const el = d.element;
21
+ const diag = new Diagnostic({
22
+ severity: d.severity,
23
+ phase: d.phase || 'geometry',
24
+ code: d.code,
25
+ message: el ? `<${el.name}${el.values && el.values.id ? ` id="${el.values.id}"` : ''}>: ${d.message}` : d.message,
26
+ offset: el && el.nameStart >= 0 ? el.nameStart : -1,
27
+ length: el && el.nameStart >= 0 ? el.nameEnd - el.nameStart : 0,
28
+ uid: el ? el.uid : 0,
29
+ });
30
+ diag.locate(doc && doc.lineIndex);
31
+ return diag;
32
+ }
33
+
34
+ /**
35
+ * Parse, validate, build and flatten a document.
36
+ * @param {string|ArrayBuffer|Uint8Array} input
37
+ * @param {{cache?: GeometryCache, time?: number, progress?: number, maxInstances?: number}} [opts]
38
+ */
39
+ export function compile(input, opts = {}) {
40
+ const t0 = now();
41
+ const doc = parse(input);
42
+ const t1 = now();
43
+ const timings = { parse: t1 - t0, build: 0, flatten: 0 };
44
+ if (!doc.ok) {
45
+ return { ok: false, doc, graph: null, scene: null, cache: opts.cache || null, diagnostics: doc.diagnostics, timings };
46
+ }
47
+ const cache = opts.cache || new GeometryCache();
48
+ const graph = buildSceneGraph(doc, cache, opts);
49
+ const t2 = now();
50
+ const scene = flattenScene(graph, { time: opts.time || 0, progress: opts.progress || 0 });
51
+ const t3 = now();
52
+ timings.build = t2 - t1;
53
+ timings.flatten = t3 - t2;
54
+ const extra = graph.diagnostics.map((d) => elementDiagnostic(doc, d));
55
+ const diagnostics = [...doc.diagnostics, ...extra];
56
+ const ok = !diagnostics.some((d) => d.severity === 'error');
57
+ return { ok, doc, graph, scene, cache, diagnostics, timings };
58
+ }
59
+
60
+ /** Generate every mesh needed for LOD 0 of a compiled scene (benchmarks, exports). */
61
+ export function generateGeometry(result, lod = 0) {
62
+ const t0 = now();
63
+ const { scene, cache } = result;
64
+ let triangles = 0;
65
+ let vertices = 0;
66
+ for (const entry of scene.geometries) {
67
+ const m = cache.mesh(entry, lod);
68
+ vertices += m.vertexCount;
69
+ triangles += m.kind === 'triangles' ? m.primitiveCount : 0;
70
+ }
71
+ return { ms: now() - t0, uniqueVertices: vertices, uniqueTriangles: triangles };
72
+ }
73
+
74
+ export { GeometryCache };
@@ -1,168 +1,168 @@
1
- // Camera model shared by both GPU backends.
2
-
3
- import * as mat4 from '../math/mat4.js';
4
-
5
- export class Camera {
6
- constructor() {
7
- this.type = 'perspective';
8
- this.position = [6, 4, 8];
9
- this.target = [0, 0, 0];
10
- this.up = [0, 1, 0];
11
- this.fov = 45; // vertical, degrees
12
- this.orthoHeight = 10;
13
- this.near = null; // null = automatic from scene bounds
14
- this.far = null;
15
- // fov (or orthoHeight) is the vertical extent at this reference aspect ratio;
16
- // fit says how other aspect ratios are derived from it (see verticalFov)
17
- this.refAspect = 1;
18
- this.fit = 'contain';
19
- this.view = mat4.create();
20
- this.proj = mat4.create();
21
- this.viewProj = mat4.create();
22
- this.invViewProj = mat4.create();
23
- this.currentNear = 0.1;
24
- this.currentFar = 1000;
25
- }
26
-
27
- /** Configure from a camera description produced by the flattener. */
28
- fromSpec(c) {
29
- this.type = c.type || 'perspective';
30
- this.position = c.position.slice();
31
- this.target = c.target.slice();
32
- this.up = c.up.slice();
33
- this.fov = c.fov || 45;
34
- this.orthoHeight = c.height || 10;
35
- this.near = c.near || null;
36
- this.far = c.far || null;
37
- this.refAspect = c.aspect || 1;
38
- this.fit = c.fit || 'contain';
39
- return this;
40
- }
41
-
42
- clone() {
43
- const c = new Camera();
44
- c.type = this.type;
45
- c.position = this.position.slice();
46
- c.target = this.target.slice();
47
- c.up = this.up.slice();
48
- c.fov = this.fov;
49
- c.orthoHeight = this.orthoHeight;
50
- c.near = this.near;
51
- c.far = this.far;
52
- c.refAspect = this.refAspect;
53
- c.fit = this.fit;
54
- return c;
55
- }
56
-
57
- /**
58
- * Factor that turns the reference vertical extent into the one for a viewport
59
- * aspect ratio. The reference view is refAspect wide and 1 high:
60
- * contain shows all of it (like SVG's "meet"), cover fills the viewport with
61
- * it ("slice"), width keeps its width and height keeps its height.
62
- */
63
- extentScale(aspect) {
64
- const A = this.refAspect > 0 ? this.refAspect : 1;
65
- const keepWidth = A / aspect; // vertical extent that keeps the reference width
66
- switch (this.fit) {
67
- case 'cover':
68
- return Math.min(1, keepWidth);
69
- case 'width':
70
- return keepWidth;
71
- case 'height':
72
- return 1;
73
- default:
74
- return Math.max(1, keepWidth);
75
- }
76
- }
77
-
78
- get distance() {
79
- return Math.hypot(this.position[0] - this.target[0], this.position[1] - this.target[1], this.position[2] - this.target[2]);
80
- }
81
-
82
- /** Frame an axis aligned bounding box. */
83
- frame(bounds, dir = [0.62, 0.5, 0.75]) {
84
- const c = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2];
85
- const r = Math.max(1e-3, Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2);
86
- const l = Math.hypot(...dir);
87
- const d = this.type === 'orthographic' ? r * 3 : (r / Math.sin(((this.fov / 2) * Math.PI) / 180)) * 1.05;
88
- this.target = c;
89
- this.position = [c[0] + (dir[0] / l) * d, c[1] + (dir[1] / l) * d, c[2] + (dir[2] / l) * d];
90
- this.up = [0, 1, 0];
91
- this.orthoHeight = r * 2.2;
92
- // the distance above assumes fov applies to the shorter viewport side
93
- this.refAspect = 1;
94
- this.fit = 'contain';
95
- return this;
96
- }
97
-
98
- /**
99
- * Update view / projection matrices.
100
- * @param {number} aspect
101
- * @param {boolean} zeroToOne WebGPU depth range
102
- * @param {{min:number[],max:number[]}} [bounds] scene bounds for automatic near/far
103
- */
104
- update(aspect, zeroToOne, bounds) {
105
- mat4.lookAt(this.view, this.position, this.target, this.up);
106
- let near = this.near;
107
- let far = this.far;
108
- if (near === null || far === null) {
109
- let autoNear = 0.05;
110
- let autoFar = 2000;
111
- if (bounds) {
112
- const c = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2];
113
- const R = Math.max(1e-3, Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2);
114
- const d = Math.hypot(this.position[0] - c[0], this.position[1] - c[1], this.position[2] - c[2]);
115
- autoFar = (d + R) * 1.5 + 1e-3;
116
- // outside the bounds the near plane can sit just before the scene;
117
- // inside it is a small fraction of the scene size
118
- autoNear = Math.max((d - R) * 0.5, R * 0.002, autoFar / 50000, 1e-4);
119
- }
120
- if (near === null) near = autoNear;
121
- if (far === null) far = Math.max(autoFar, near * 10);
122
- }
123
- this.currentNear = near;
124
- this.currentFar = far;
125
- // Like SVG's preserveAspectRatio, the framed view (fov or the orthographic
126
- // height at refAspect) is fitted to the viewport, so what the author framed
127
- // stays framed on every screen shape. The default (contain, aspect 1)
128
- // applies fov to the shorter side.
129
- if (this.type === 'orthographic') {
130
- const h = (this.orthoHeight * this.extentScale(aspect)) / 2;
131
- const w = h * aspect;
132
- mat4.ortho(this.proj, -w, w, -h, h, this.near === null ? -far : near, far, zeroToOne);
133
- } else {
134
- mat4.perspective(this.proj, this.verticalFov(aspect), aspect, near, far, zeroToOne);
135
- }
136
- this.aspect = aspect;
137
- mat4.multiply(this.viewProj, this.proj, this.view);
138
- mat4.invert(this.invViewProj, this.viewProj);
139
- }
140
-
141
- /** Vertical field of view in radians for a viewport aspect ratio. */
142
- verticalFov(aspect) {
143
- const f = (this.fov * Math.PI) / 180;
144
- const s = this.extentScale(aspect);
145
- return s === 1 ? f : 2 * Math.atan(Math.tan(f / 2) * s);
146
- }
147
-
148
- /** World-space ray through a pixel (x, y in CSS pixels from the top-left). */
149
- ray(x, y, width, height, zeroToOne = false) {
150
- const nx = (x / width) * 2 - 1;
151
- const ny = 1 - (y / height) * 2;
152
- const z0 = zeroToOne ? 0 : -1;
153
- const a = [0, 0, 0];
154
- const b = [0, 0, 0];
155
- mat4.transformPoint(a, this.invViewProj, nx, ny, z0);
156
- mat4.transformPoint(b, this.invViewProj, nx, ny, 1);
157
- const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
158
- const l = Math.hypot(d[0], d[1], d[2]) || 1;
159
- return { origin: a, dir: [d[0] / l, d[1] / l, d[2] / l] };
160
- }
161
-
162
- /** Pixels per world unit at a distance (used for level-of-detail selection). */
163
- pixelsPerUnit(distance, viewportHeight) {
164
- const aspect = this.aspect || 1;
165
- if (this.type === 'orthographic') return viewportHeight / (this.orthoHeight * this.extentScale(aspect));
166
- return viewportHeight / (2 * Math.max(distance, 1e-6) * Math.tan(this.verticalFov(aspect) / 2));
167
- }
168
- }
1
+ // Camera model shared by both GPU backends.
2
+
3
+ import * as mat4 from '../math/mat4.js';
4
+
5
+ export class Camera {
6
+ constructor() {
7
+ this.type = 'perspective';
8
+ this.position = [6, 4, 8];
9
+ this.target = [0, 0, 0];
10
+ this.up = [0, 1, 0];
11
+ this.fov = 45; // vertical, degrees
12
+ this.orthoHeight = 10;
13
+ this.near = null; // null = automatic from scene bounds
14
+ this.far = null;
15
+ // fov (or orthoHeight) is the vertical extent at this reference aspect ratio;
16
+ // fit says how other aspect ratios are derived from it (see verticalFov)
17
+ this.refAspect = 1;
18
+ this.fit = 'contain';
19
+ this.view = mat4.create();
20
+ this.proj = mat4.create();
21
+ this.viewProj = mat4.create();
22
+ this.invViewProj = mat4.create();
23
+ this.currentNear = 0.1;
24
+ this.currentFar = 1000;
25
+ }
26
+
27
+ /** Configure from a camera description produced by the flattener. */
28
+ fromSpec(c) {
29
+ this.type = c.type || 'perspective';
30
+ this.position = c.position.slice();
31
+ this.target = c.target.slice();
32
+ this.up = c.up.slice();
33
+ this.fov = c.fov || 45;
34
+ this.orthoHeight = c.height || 10;
35
+ this.near = c.near || null;
36
+ this.far = c.far || null;
37
+ this.refAspect = c.aspect || 1;
38
+ this.fit = c.fit || 'contain';
39
+ return this;
40
+ }
41
+
42
+ clone() {
43
+ const c = new Camera();
44
+ c.type = this.type;
45
+ c.position = this.position.slice();
46
+ c.target = this.target.slice();
47
+ c.up = this.up.slice();
48
+ c.fov = this.fov;
49
+ c.orthoHeight = this.orthoHeight;
50
+ c.near = this.near;
51
+ c.far = this.far;
52
+ c.refAspect = this.refAspect;
53
+ c.fit = this.fit;
54
+ return c;
55
+ }
56
+
57
+ /**
58
+ * Factor that turns the reference vertical extent into the one for a viewport
59
+ * aspect ratio. The reference view is refAspect wide and 1 high:
60
+ * contain shows all of it (like SVG's "meet"), cover fills the viewport with
61
+ * it ("slice"), width keeps its width and height keeps its height.
62
+ */
63
+ extentScale(aspect) {
64
+ const A = this.refAspect > 0 ? this.refAspect : 1;
65
+ const keepWidth = A / aspect; // vertical extent that keeps the reference width
66
+ switch (this.fit) {
67
+ case 'cover':
68
+ return Math.min(1, keepWidth);
69
+ case 'width':
70
+ return keepWidth;
71
+ case 'height':
72
+ return 1;
73
+ default:
74
+ return Math.max(1, keepWidth);
75
+ }
76
+ }
77
+
78
+ get distance() {
79
+ return Math.hypot(this.position[0] - this.target[0], this.position[1] - this.target[1], this.position[2] - this.target[2]);
80
+ }
81
+
82
+ /** Frame an axis aligned bounding box. */
83
+ frame(bounds, dir = [0.62, 0.5, 0.75]) {
84
+ const c = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2];
85
+ const r = Math.max(1e-3, Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2);
86
+ const l = Math.hypot(...dir);
87
+ const d = this.type === 'orthographic' ? r * 3 : (r / Math.sin(((this.fov / 2) * Math.PI) / 180)) * 1.05;
88
+ this.target = c;
89
+ this.position = [c[0] + (dir[0] / l) * d, c[1] + (dir[1] / l) * d, c[2] + (dir[2] / l) * d];
90
+ this.up = [0, 1, 0];
91
+ this.orthoHeight = r * 2.2;
92
+ // the distance above assumes fov applies to the shorter viewport side
93
+ this.refAspect = 1;
94
+ this.fit = 'contain';
95
+ return this;
96
+ }
97
+
98
+ /**
99
+ * Update view / projection matrices.
100
+ * @param {number} aspect
101
+ * @param {boolean} zeroToOne WebGPU depth range
102
+ * @param {{min:number[],max:number[]}} [bounds] scene bounds for automatic near/far
103
+ */
104
+ update(aspect, zeroToOne, bounds) {
105
+ mat4.lookAt(this.view, this.position, this.target, this.up);
106
+ let near = this.near;
107
+ let far = this.far;
108
+ if (near === null || far === null) {
109
+ let autoNear = 0.05;
110
+ let autoFar = 2000;
111
+ if (bounds) {
112
+ const c = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2];
113
+ const R = Math.max(1e-3, Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2);
114
+ const d = Math.hypot(this.position[0] - c[0], this.position[1] - c[1], this.position[2] - c[2]);
115
+ autoFar = (d + R) * 1.5 + 1e-3;
116
+ // outside the bounds the near plane can sit just before the scene;
117
+ // inside it is a small fraction of the scene size
118
+ autoNear = Math.max((d - R) * 0.5, R * 0.002, autoFar / 50000, 1e-4);
119
+ }
120
+ if (near === null) near = autoNear;
121
+ if (far === null) far = Math.max(autoFar, near * 10);
122
+ }
123
+ this.currentNear = near;
124
+ this.currentFar = far;
125
+ // Like SVG's preserveAspectRatio, the framed view (fov or the orthographic
126
+ // height at refAspect) is fitted to the viewport, so what the author framed
127
+ // stays framed on every screen shape. The default (contain, aspect 1)
128
+ // applies fov to the shorter side.
129
+ if (this.type === 'orthographic') {
130
+ const h = (this.orthoHeight * this.extentScale(aspect)) / 2;
131
+ const w = h * aspect;
132
+ mat4.ortho(this.proj, -w, w, -h, h, this.near === null ? -far : near, far, zeroToOne);
133
+ } else {
134
+ mat4.perspective(this.proj, this.verticalFov(aspect), aspect, near, far, zeroToOne);
135
+ }
136
+ this.aspect = aspect;
137
+ mat4.multiply(this.viewProj, this.proj, this.view);
138
+ mat4.invert(this.invViewProj, this.viewProj);
139
+ }
140
+
141
+ /** Vertical field of view in radians for a viewport aspect ratio. */
142
+ verticalFov(aspect) {
143
+ const f = (this.fov * Math.PI) / 180;
144
+ const s = this.extentScale(aspect);
145
+ return s === 1 ? f : 2 * Math.atan(Math.tan(f / 2) * s);
146
+ }
147
+
148
+ /** World-space ray through a pixel (x, y in CSS pixels from the top-left). */
149
+ ray(x, y, width, height, zeroToOne = false) {
150
+ const nx = (x / width) * 2 - 1;
151
+ const ny = 1 - (y / height) * 2;
152
+ const z0 = zeroToOne ? 0 : -1;
153
+ const a = [0, 0, 0];
154
+ const b = [0, 0, 0];
155
+ mat4.transformPoint(a, this.invViewProj, nx, ny, z0);
156
+ mat4.transformPoint(b, this.invViewProj, nx, ny, 1);
157
+ const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
158
+ const l = Math.hypot(d[0], d[1], d[2]) || 1;
159
+ return { origin: a, dir: [d[0] / l, d[1] / l, d[2] / l] };
160
+ }
161
+
162
+ /** Pixels per world unit at a distance (used for level-of-detail selection). */
163
+ pixelsPerUnit(distance, viewportHeight) {
164
+ const aspect = this.aspect || 1;
165
+ if (this.type === 'orthographic') return viewportHeight / (this.orthoHeight * this.extentScale(aspect));
166
+ return viewportHeight / (2 * Math.max(distance, 1e-6) * Math.tan(this.verticalFov(aspect) / 2));
167
+ }
168
+ }