@es3d/es3d 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +86 -86
  3. package/docs/SPEC.md +417 -417
  4. package/package.json +1 -1
  5. package/scripts/es3d-cli.mjs +377 -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 +92 -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.js +630 -630
  42. package/src/io/mesh-import.js +104 -104
  43. package/src/io/obj.js +352 -352
  44. package/src/io/step-brep.js +439 -439
  45. package/src/io/step-exact.js +1149 -1149
  46. package/src/io/step.js +1418 -1418
  47. package/src/io/stl.js +193 -193
  48. package/src/io/svg-import.js +787 -787
  49. package/src/material/materials.js +154 -154
  50. package/src/math/mat4.js +475 -475
  51. package/src/math/random.js +30 -30
  52. package/src/math/trig.js +65 -65
  53. package/src/math/vec3.js +81 -81
  54. package/src/optimize/optimizer.js +579 -579
  55. package/src/path/path-flatten.js +322 -322
  56. package/src/path/path-parser.js +332 -332
  57. package/src/path/shapes.js +129 -129
  58. package/src/path/triangulate.js +517 -517
  59. package/src/pipeline.js +74 -74
  60. package/src/render/camera.js +168 -168
  61. package/src/render/frame-builder.js +362 -362
  62. package/src/render/geometry-pool.js +149 -149
  63. package/src/render/lighting.js +149 -149
  64. package/src/render/orbit-controls.js +237 -237
  65. package/src/render/renderer.js +258 -249
  66. package/src/render/scroll-timeline.js +104 -104
  67. package/src/render/shaders/glsl.js +285 -285
  68. package/src/render/shaders/wgsl.js +252 -252
  69. package/src/render/stage.js +364 -352
  70. package/src/render/texture-cache.js +40 -40
  71. package/src/render/textures.js +223 -223
  72. package/src/render/webgl2/webgl2-backend.js +586 -532
  73. package/src/render/webgpu/webgpu-backend.js +482 -480
  74. package/src/scene/flatten.js +754 -754
  75. package/src/scene/picking.js +161 -161
  76. package/src/scene/scene-graph.js +320 -320
  77. package/src/transform/transform.js +211 -211
  78. package/src/wasm/kernels-binary.js +3 -3
  79. 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
+ }