@es3d/es3d 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +86 -3
  3. package/docs/REFERENCE.md +581 -0
  4. package/docs/SPEC.md +417 -0
  5. package/package.json +50 -4
  6. package/scripts/es3d-cli.mjs +377 -0
  7. package/src/cache/geometry-store.js +392 -0
  8. package/src/cache/mesh-bundle.js +123 -0
  9. package/src/cache/sha256.js +73 -0
  10. package/src/format/ast.js +238 -0
  11. package/src/format/colors.js +93 -0
  12. package/src/format/compact-parser.js +171 -0
  13. package/src/format/compact-tokenizer.js +165 -0
  14. package/src/format/errors.js +247 -0
  15. package/src/format/json.js +55 -0
  16. package/src/format/limits.js +14 -0
  17. package/src/format/parse.js +92 -0
  18. package/src/format/schema.js +617 -0
  19. package/src/format/serializer.js +333 -0
  20. package/src/format/validator.js +330 -0
  21. package/src/format/value-error.js +12 -0
  22. package/src/format/value-format.js +314 -0
  23. package/src/format/values.js +459 -0
  24. package/src/format/xml-parser.js +136 -0
  25. package/src/format/xml-tokenizer.js +237 -0
  26. package/src/geometry/contour.js +86 -0
  27. package/src/geometry/extrude.js +175 -0
  28. package/src/geometry/gear.js +85 -0
  29. package/src/geometry/geometry-cache.js +159 -0
  30. package/src/geometry/lod-error.js +178 -0
  31. package/src/geometry/mesh.js +458 -0
  32. package/src/geometry/primitives.js +299 -0
  33. package/src/geometry/registry.js +645 -0
  34. package/src/geometry/revolve.js +136 -0
  35. package/src/geometry/sweep.js +441 -0
  36. package/src/index.js +47 -0
  37. package/src/io/compression.js +67 -0
  38. package/src/io/convert.js +236 -0
  39. package/src/io/download.js +13 -0
  40. package/src/io/external-scene.js +127 -0
  41. package/src/io/file-loader.js +28 -0
  42. package/src/io/gltf.js +630 -0
  43. package/src/io/mesh-import.js +104 -0
  44. package/src/io/obj.js +352 -0
  45. package/src/io/step-brep.js +439 -0
  46. package/src/io/step-exact.js +1149 -0
  47. package/src/io/step.js +1418 -0
  48. package/src/io/stl.js +193 -0
  49. package/src/io/svg-import.js +787 -0
  50. package/src/material/materials.js +154 -0
  51. package/src/math/mat4.js +475 -0
  52. package/src/math/random.js +30 -0
  53. package/src/math/trig.js +65 -0
  54. package/src/math/vec3.js +81 -0
  55. package/src/optimize/optimizer.js +579 -0
  56. package/src/path/path-flatten.js +322 -0
  57. package/src/path/path-parser.js +332 -0
  58. package/src/path/shapes.js +129 -0
  59. package/src/path/triangulate.js +517 -0
  60. package/src/pipeline.js +74 -0
  61. package/src/render/camera.js +168 -0
  62. package/src/render/frame-builder.js +362 -0
  63. package/src/render/geometry-pool.js +149 -0
  64. package/src/render/lighting.js +149 -0
  65. package/src/render/orbit-controls.js +237 -0
  66. package/src/render/renderer.js +249 -0
  67. package/src/render/scroll-timeline.js +104 -0
  68. package/src/render/shaders/glsl.js +285 -0
  69. package/src/render/shaders/wgsl.js +252 -0
  70. package/src/render/stage.js +352 -0
  71. package/src/render/texture-cache.js +40 -0
  72. package/src/render/textures.js +223 -0
  73. package/src/render/webgl2/webgl2-backend.js +532 -0
  74. package/src/render/webgpu/webgpu-backend.js +480 -0
  75. package/src/scene/animation.js +171 -0
  76. package/src/scene/camera-constraints.js +117 -0
  77. package/src/scene/flatten.js +754 -0
  78. package/src/scene/picking.js +161 -0
  79. package/src/scene/scene-graph.js +320 -0
  80. package/src/transform/transform.js +211 -0
  81. package/src/wasm/kernels-binary.js +3 -0
  82. package/src/wasm/kernels.js +385 -0
@@ -0,0 +1,149 @@
1
+ // Shared vertex / index buffers.
2
+ //
3
+ // All meshes (every geometry, every level of detail, wireframe edge lists and
4
+ // normal-visualisation lines) are packed into ONE interleaved vertex array and
5
+ // ONE 32-bit index array. Indices are stored pre-offset (absolute), so a draw
6
+ // is just (firstIndex, indexCount). No per-draw buffer binding, and no base
7
+ // vertex. That keeps the WebGL2 and WebGPU paths the same.
8
+ //
9
+ // The pool is CPU-side; backends upload the ranges appended since their last
10
+ // sync (or everything after a reallocation).
11
+
12
+ export const VERTEX_FLOATS = 8; // position xyz, normal xyz, uv
13
+ export const VERTEX_BYTES = VERTEX_FLOATS * 4;
14
+
15
+ export class GeometryPool {
16
+ constructor(vertexCapacity = 1 << 16, indexCapacity = 1 << 17) {
17
+ this.vertices = new Float32Array(vertexCapacity * VERTEX_FLOATS);
18
+ this.indices = new Uint32Array(indexCapacity);
19
+ this.vertexCount = 0;
20
+ this.indexCount = 0;
21
+ this.ranges = new Map(); // key object -> {firstIndex, indexCount}
22
+ this.edgeRanges = new Map();
23
+ this.normalRanges = new Map();
24
+ // dirty tracking for incremental uploads
25
+ this.generation = 0; // bumps on reallocation
26
+ this.uploadedVertices = 0;
27
+ this.uploadedIndices = 0;
28
+ }
29
+
30
+ reset() {
31
+ this.vertexCount = 0;
32
+ this.indexCount = 0;
33
+ this.ranges.clear();
34
+ this.edgeRanges.clear();
35
+ this.normalRanges.clear();
36
+ this.generation++;
37
+ this.uploadedVertices = 0;
38
+ this.uploadedIndices = 0;
39
+ }
40
+
41
+ get byteSize() {
42
+ return this.vertexCount * VERTEX_BYTES + this.indexCount * 4;
43
+ }
44
+
45
+ ensure(extraV, extraI) {
46
+ let grew = false;
47
+ if ((this.vertexCount + extraV) * VERTEX_FLOATS > this.vertices.length) {
48
+ let cap = this.vertices.length / VERTEX_FLOATS;
49
+ while (cap < this.vertexCount + extraV) cap *= 2;
50
+ const v = new Float32Array(cap * VERTEX_FLOATS);
51
+ v.set(this.vertices.subarray(0, this.vertexCount * VERTEX_FLOATS));
52
+ this.vertices = v;
53
+ grew = true;
54
+ }
55
+ if (this.indexCount + extraI > this.indices.length) {
56
+ let cap = this.indices.length;
57
+ while (cap < this.indexCount + extraI) cap *= 2;
58
+ const i = new Uint32Array(cap);
59
+ i.set(this.indices.subarray(0, this.indexCount));
60
+ this.indices = i;
61
+ grew = true;
62
+ }
63
+ if (grew) {
64
+ this.generation++;
65
+ this.uploadedVertices = 0;
66
+ this.uploadedIndices = 0;
67
+ }
68
+ }
69
+
70
+ appendVertices(mesh) {
71
+ const n = mesh.vertexCount;
72
+ const base = this.vertexCount;
73
+ const v = this.vertices;
74
+ const p = mesh.positions, nr = mesh.normals, t = mesh.uvs;
75
+ let o = base * VERTEX_FLOATS;
76
+ for (let i = 0; i < n; i++) {
77
+ v[o] = p[i * 3];
78
+ v[o + 1] = p[i * 3 + 1];
79
+ v[o + 2] = p[i * 3 + 2];
80
+ v[o + 3] = nr[i * 3];
81
+ v[o + 4] = nr[i * 3 + 1];
82
+ v[o + 5] = nr[i * 3 + 2];
83
+ v[o + 6] = t[i * 2];
84
+ v[o + 7] = t[i * 2 + 1];
85
+ o += VERTEX_FLOATS;
86
+ }
87
+ this.vertexCount += n;
88
+ return base;
89
+ }
90
+
91
+ appendIndices(src, base) {
92
+ const first = this.indexCount;
93
+ const dst = this.indices;
94
+ for (let i = 0; i < src.length; i++) dst[first + i] = src[i] + base;
95
+ this.indexCount += src.length;
96
+ return first;
97
+ }
98
+
99
+ /** Range of a mesh's primitives (triangles or lines). */
100
+ meshRange(mesh) {
101
+ let r = this.ranges.get(mesh);
102
+ if (r) return r;
103
+ this.ensure(mesh.vertexCount, mesh.indices.length);
104
+ const base = this.appendVertices(mesh);
105
+ const first = this.appendIndices(mesh.indices, base);
106
+ r = { firstIndex: first, indexCount: mesh.indices.length, baseVertex: base, kind: mesh.kind };
107
+ this.ranges.set(mesh, r);
108
+ return r;
109
+ }
110
+
111
+ /** Line range with the unique edges of a mesh (wireframe). */
112
+ edgeRange(mesh) {
113
+ let r = this.edgeRanges.get(mesh);
114
+ if (r) return r;
115
+ const base = this.meshRange(mesh).baseVertex;
116
+ const edges = mesh.edges();
117
+ this.ensure(0, edges.length);
118
+ const first = this.appendIndices(edges, base);
119
+ r = { firstIndex: first, indexCount: edges.length, kind: 'lines' };
120
+ this.edgeRanges.set(mesh, r);
121
+ return r;
122
+ }
123
+
124
+ /** Line range visualising vertex normals (uv.x = 0 at the base, 1 at the tip). */
125
+ normalRange(mesh) {
126
+ let r = this.normalRanges.get(mesh);
127
+ if (r) return r;
128
+ const lines = mesh.normalLines();
129
+ r = this.meshRange(lines);
130
+ this.normalRanges.set(mesh, r);
131
+ return r;
132
+ }
133
+
134
+ /** Pending upload ranges since the last sync. */
135
+ pending() {
136
+ return {
137
+ full: this.uploadedVertices === 0 && this.uploadedIndices === 0,
138
+ vertexStart: this.uploadedVertices,
139
+ vertexEnd: this.vertexCount,
140
+ indexStart: this.uploadedIndices,
141
+ indexEnd: this.indexCount,
142
+ };
143
+ }
144
+
145
+ markUploaded() {
146
+ this.uploadedVertices = this.vertexCount;
147
+ this.uploadedIndices = this.indexCount;
148
+ }
149
+ }
@@ -0,0 +1,149 @@
1
+ // Packing of per-frame uniforms (camera, environment and lights) into the
2
+ // layout shared by the GLSL uniform block and the WGSL uniform struct:
3
+ //
4
+ // mat4 viewProj; mat4 view; vec4 cameraPos(time); vec4 sky(envIntensity);
5
+ // vec4 ground(exposure); vec4 params(lightCount, normalLength, lineBias, viewportH);
6
+ // vec4 lightPos[16] xyz = position, w = type (0 dir, 1 point, 2 spot, 3 ambient, 4 hemisphere)
7
+ // vec4 lightDir[16] xyz = direction, w = range
8
+ // vec4 lightColor[16] rgb = color * intensity, w = cos(outer cone)
9
+ // vec4 lightExtra[16] x = cos(inner cone), yzw = hemisphere ground color
10
+
11
+ import { colorToLinear } from '../format/colors.js';
12
+
13
+ export const MAX_LIGHTS = 16;
14
+ export const FRAME_FLOATS = 48 + MAX_LIGHTS * 16;
15
+ export const FRAME_BYTES = FRAME_FLOATS * 4;
16
+
17
+ const TYPE_CODE = { directional: 0, point: 1, spot: 2, ambient: 3, hemisphere: 4 };
18
+
19
+ /** Lights used when a document defines none: a key and a fill light. */
20
+ // Light intensity convention: a white directional light of intensity 1 lights a
21
+ // white diffuse surface it faces head-on to full brightness (radiance * PI in the shader).
22
+ export const DEFAULT_LIGHTS = [
23
+ { type: 'directional', color: [1.1, 1.08, 1.04], direction: norm([-0.45, -0.85, -0.35]), position: [0, 0, 0], range: 0, outer: 0, inner: 0, ground: [0, 0, 0] },
24
+ { type: 'directional', color: [0.22, 0.25, 0.32], direction: norm([0.6, -0.2, 0.75]), position: [0, 0, 0], range: 0, outer: 0, inner: 0, ground: [0, 0, 0] },
25
+ ];
26
+
27
+ function norm(v) {
28
+ const l = Math.hypot(v[0], v[1], v[2]) || 1;
29
+ return [v[0] / l, v[1] / l, v[2] / l];
30
+ }
31
+
32
+ /**
33
+ * @returns {{data: Float32Array, lightCount: number, truncated: number}}
34
+ */
35
+ export function packFrameUniforms(out, { viewProj, view, cameraPos, time, environment, lights, normalLength = 0.1, lineBias = 0.0015, viewportHeight = 1 }) {
36
+ out.fill(0);
37
+ out.set(viewProj, 0);
38
+ out.set(view, 16);
39
+ out[32] = cameraPos[0];
40
+ out[33] = cameraPos[1];
41
+ out[34] = cameraPos[2];
42
+ out[35] = time || 0;
43
+ const sky = colorToLinear(environment.sky);
44
+ const ground = colorToLinear(environment.ground);
45
+ out[36] = sky[0];
46
+ out[37] = sky[1];
47
+ out[38] = sky[2];
48
+ out[39] = environment.intensity;
49
+ out[40] = ground[0];
50
+ out[41] = ground[1];
51
+ out[42] = ground[2];
52
+ out[43] = environment.exposure;
53
+ const list = lights && lights.length ? lights : DEFAULT_LIGHTS;
54
+ const count = Math.min(list.length, MAX_LIGHTS);
55
+ out[44] = count;
56
+ out[45] = normalLength;
57
+ out[46] = lineBias;
58
+ out[47] = viewportHeight;
59
+ for (let i = 0; i < count; i++) {
60
+ const l = list[i];
61
+ const p = 48 + i * 4;
62
+ const d = 48 + MAX_LIGHTS * 4 + i * 4;
63
+ const c = 48 + MAX_LIGHTS * 8 + i * 4;
64
+ const e = 48 + MAX_LIGHTS * 12 + i * 4;
65
+ out[p] = l.position[0];
66
+ out[p + 1] = l.position[1];
67
+ out[p + 2] = l.position[2];
68
+ out[p + 3] = TYPE_CODE[l.type] ?? 0;
69
+ out[d] = l.direction[0];
70
+ out[d + 1] = l.direction[1];
71
+ out[d + 2] = l.direction[2];
72
+ out[d + 3] = l.range || 0;
73
+ out[c] = l.color[0];
74
+ out[c + 1] = l.color[1];
75
+ out[c + 2] = l.color[2];
76
+ out[c + 3] = l.outer || 0;
77
+ // smoothstep needs edge0 < edge1: keep the inner cone strictly inside the outer one
78
+ out[e] = Math.max(l.inner || 0, (l.outer || 0) + 1e-4);
79
+ out[e + 1] = l.ground ? l.ground[0] : 0;
80
+ out[e + 2] = l.ground ? l.ground[1] : 0;
81
+ out[e + 3] = l.ground ? l.ground[2] : 0;
82
+ }
83
+ return { data: out, lightCount: count, truncated: Math.max(0, list.length - MAX_LIGHTS), usedDefaults: !(lights && lights.length) };
84
+ }
85
+
86
+ /**
87
+ * The light types of packed frame uniforms, in order, as a short key ("0,0",
88
+ * "0,1,2"): the light setup that mesh shaders are specialised for.
89
+ */
90
+ export function lightTypes(frameData) {
91
+ const n = Math.round(frameData[44]);
92
+ let key = '';
93
+ for (let i = 0; i < n; i++) key += (i ? ',' : '') + Math.round(frameData[48 + i * 4 + 3]);
94
+ return key;
95
+ }
96
+
97
+ /** Material uniform block: baseColor, emissive(normalScale), params(metallic, roughness, flags), uvScale, overlay color. */
98
+ export const MATERIAL_FLOATS = 20;
99
+
100
+ export const MAT_FLAG_MAP = 1;
101
+ export const MAT_FLAG_NORMAL_MAP = 2;
102
+ export const MAT_FLAG_DOUBLE_SIDED = 4;
103
+ export const MAT_FLAG_UNLIT = 8;
104
+ export const MAT_FLAG_OVERLAY = 16;
105
+ export const MAT_FLAG_MR_MAP = 32;
106
+ export const MAT_FLAG_OCCLUSION = 64;
107
+ export const MAT_FLAG_EMISSIVE_MAP = 128;
108
+ export const MAT_FLAG_ALPHA_MASK = 256;
109
+
110
+ export function packMaterial(out, m, { hasMap = false, hasNormalMap = false, hasMrMap = false, hasOcclusionMap = false, hasEmissiveMap = false, doubleSided = false } = {}) {
111
+ out.fill(0);
112
+ out[0] = m.baseColor[0];
113
+ out[1] = m.baseColor[1];
114
+ out[2] = m.baseColor[2];
115
+ out[3] = m.alphaMode === 'opaque' ? 1 : m.opacity;
116
+ out[4] = m.emissive[0];
117
+ out[5] = m.emissive[1];
118
+ out[6] = m.emissive[2];
119
+ out[7] = m.normalScale;
120
+ out[8] = m.metallic;
121
+ out[9] = m.roughness;
122
+ out[10] =
123
+ (hasMap ? MAT_FLAG_MAP : 0) |
124
+ (hasNormalMap ? MAT_FLAG_NORMAL_MAP : 0) |
125
+ (doubleSided ? MAT_FLAG_DOUBLE_SIDED : 0) |
126
+ (m.unlit ? MAT_FLAG_UNLIT : 0) |
127
+ (hasMrMap ? MAT_FLAG_MR_MAP : 0) |
128
+ (hasOcclusionMap ? MAT_FLAG_OCCLUSION : 0) |
129
+ (hasEmissiveMap ? MAT_FLAG_EMISSIVE_MAP : 0) |
130
+ (m.alphaMode === 'mask' ? MAT_FLAG_ALPHA_MASK : 0);
131
+ out[11] = m.alphaCutoff ?? 0.5;
132
+ out[12] = m.uvScale[0];
133
+ out[13] = m.uvScale[1];
134
+ out[14] = m.occlusionStrength ?? 1;
135
+ return out;
136
+ }
137
+
138
+ /** Uniforms for debug line overlays (sRGB color, drawn unlit). */
139
+ export function packOverlay(out, rgba) {
140
+ out.fill(0);
141
+ out[16] = rgba[0];
142
+ out[17] = rgba[1];
143
+ out[18] = rgba[2];
144
+ out[19] = rgba[3];
145
+ out[10] = MAT_FLAG_OVERLAY;
146
+ out[12] = 1;
147
+ out[13] = 1;
148
+ return out;
149
+ }
@@ -0,0 +1,237 @@
1
+ // Mouse / touch camera controls: orbit (left drag), pan (right or middle drag,
2
+ // or shift + left drag), zoom (wheel or pinch). Works for perspective and
3
+ // orthographic cameras. A document's <controls> element (scene.controls) can
4
+ // limit them and make the camera collide with the scene: see configure().
5
+
6
+ import { constrainCamera } from '../scene/camera-constraints.js';
7
+
8
+ export class OrbitControls {
9
+ /**
10
+ * @param {HTMLElement} element
11
+ * @param {import('./camera.js').Camera} camera
12
+ */
13
+ constructor(element, camera) {
14
+ this.el = element;
15
+ this.camera = camera;
16
+ this.enabled = true;
17
+ this.enableZoom = true;
18
+ this.enablePan = true;
19
+ this.limits = null; // scene.controls: distance, angle and target limits, collision
20
+ this.world = null; // () => ({scene, meshOf, isVisible}) for collision
21
+ this.rotateSpeed = 0.006;
22
+ this.zoomSpeed = 0.0015;
23
+ this.onChange = null;
24
+ this.onClick = null; // (x, y, event) for picking
25
+ this.onDoubleClick = null;
26
+ this.pointers = new Map();
27
+ this.mode = null;
28
+ this.moved = 0;
29
+ this.last = null;
30
+ this.pinch = null;
31
+
32
+ this._down = (e) => this.pointerDown(e);
33
+ this._move = (e) => this.pointerMove(e);
34
+ this._up = (e) => this.pointerUp(e);
35
+ this._wheel = (e) => this.wheel(e);
36
+ this._ctx = (e) => e.preventDefault();
37
+ this._dbl = (e) => {
38
+ if (this.onDoubleClick) {
39
+ const r = this.el.getBoundingClientRect();
40
+ this.onDoubleClick(e.clientX - r.left, e.clientY - r.top, e);
41
+ }
42
+ };
43
+ element.addEventListener('pointerdown', this._down);
44
+ element.addEventListener('pointermove', this._move);
45
+ element.addEventListener('pointerup', this._up);
46
+ element.addEventListener('pointercancel', this._up);
47
+ element.addEventListener('wheel', this._wheel, { passive: false });
48
+ element.addEventListener('contextmenu', this._ctx);
49
+ element.addEventListener('dblclick', this._dbl);
50
+ element.style.touchAction = 'none';
51
+ }
52
+
53
+ dispose() {
54
+ const el = this.el;
55
+ el.removeEventListener('pointerdown', this._down);
56
+ el.removeEventListener('pointermove', this._move);
57
+ el.removeEventListener('pointerup', this._up);
58
+ el.removeEventListener('pointercancel', this._up);
59
+ el.removeEventListener('wheel', this._wheel);
60
+ el.removeEventListener('contextmenu', this._ctx);
61
+ el.removeEventListener('dblclick', this._dbl);
62
+ }
63
+
64
+ /**
65
+ * Apply a document's <controls> (scene.controls, or null for free orbiting).
66
+ * @param {object|null} spec
67
+ * @param {{world?: () => object, ignoreMode?: boolean}} [opts] world supplies the
68
+ * scene for collision; ignoreMode keeps dragging on (an editor previewing limits).
69
+ */
70
+ configure(spec, opts = {}) {
71
+ this.limits = spec;
72
+ if (opts.world) this.world = opts.world;
73
+ const mode = spec ? spec.mode : 'orbit';
74
+ this.enabled = opts.ignoreMode || mode === 'orbit';
75
+ this.enableZoom = !spec || spec.zoom !== false;
76
+ this.enablePan = !spec || spec.pan !== false;
77
+ // a canvas that does not orbit must let touch scrolling through to the page
78
+ this.el.style.touchAction = this.enabled ? 'none' : 'auto';
79
+ }
80
+
81
+ changed() {
82
+ if (this.onChange) this.onChange();
83
+ }
84
+
85
+ /** Camera state before a manipulation, for constrain(). */
86
+ snapshot() {
87
+ return { position: this.camera.position.slice(), target: this.camera.target.slice() };
88
+ }
89
+
90
+ /** Enforce limits and collision after a manipulation that started at `prev`. */
91
+ constrain(prev) {
92
+ if (!this.limits) return;
93
+ const world = this.limits.collide && this.world ? this.world() : null;
94
+ constrainCamera(prev, this.camera, this.limits, world);
95
+ }
96
+
97
+ pointerDown(e) {
98
+ if (!this.enabled) return;
99
+ this.el.setPointerCapture(e.pointerId);
100
+ this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
101
+ this.moved = 0;
102
+ if (this.pointers.size === 2) {
103
+ const [a, b] = [...this.pointers.values()];
104
+ this.pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2 };
105
+ this.mode = 'pinch';
106
+ return;
107
+ }
108
+ if (e.button === 2 || e.button === 1 || e.shiftKey || e.ctrlKey || e.metaKey) this.mode = 'pan';
109
+ else this.mode = 'orbit';
110
+ this.last = { x: e.clientX, y: e.clientY };
111
+ }
112
+
113
+ pointerMove(e) {
114
+ if (!this.enabled || !this.pointers.has(e.pointerId)) return;
115
+ this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
116
+ const prev = this.snapshot();
117
+ if (this.mode === 'pinch' && this.pointers.size >= 2) {
118
+ const [a, b] = [...this.pointers.values()];
119
+ const d = Math.hypot(a.x - b.x, a.y - b.y);
120
+ const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2;
121
+ if (this.pinch.d > 0 && this.enableZoom) this.zoom(this.pinch.d / Math.max(d, 1));
122
+ if (this.enablePan) this.pan(cx - this.pinch.cx, cy - this.pinch.cy);
123
+ this.pinch = { d, cx, cy };
124
+ this.moved += 10;
125
+ this.constrain(prev);
126
+ this.changed();
127
+ return;
128
+ }
129
+ if (!this.last) return;
130
+ const dx = e.clientX - this.last.x;
131
+ const dy = e.clientY - this.last.y;
132
+ this.last = { x: e.clientX, y: e.clientY };
133
+ this.moved += Math.abs(dx) + Math.abs(dy);
134
+ if (this.mode === 'orbit') this.orbit(dx, dy);
135
+ else if (this.mode === 'pan' && this.enablePan) this.pan(dx, dy);
136
+ this.constrain(prev);
137
+ this.changed();
138
+ }
139
+
140
+ pointerUp(e) {
141
+ if (!this.pointers.has(e.pointerId)) return;
142
+ this.pointers.delete(e.pointerId);
143
+ try {
144
+ this.el.releasePointerCapture(e.pointerId);
145
+ } catch {
146
+ // pointer capture may already be released
147
+ }
148
+ if (this.pointers.size === 0) {
149
+ if (this.moved < 4 && this.onClick && e.button === 0) {
150
+ const r = this.el.getBoundingClientRect();
151
+ this.onClick(e.clientX - r.left, e.clientY - r.top, e);
152
+ }
153
+ this.mode = null;
154
+ this.last = null;
155
+ this.pinch = null;
156
+ } else if (this.pointers.size === 1) {
157
+ const p = [...this.pointers.values()][0];
158
+ this.mode = 'orbit';
159
+ this.last = { x: p.x, y: p.y };
160
+ this.pinch = null;
161
+ }
162
+ }
163
+
164
+ wheel(e) {
165
+ // without zoom the wheel scrolls the page
166
+ if (!this.enabled || !this.enableZoom) return;
167
+ e.preventDefault();
168
+ const delta = e.deltaMode === 1 ? e.deltaY * 33 : e.deltaMode === 2 ? e.deltaY * 400 : e.deltaY;
169
+ const prev = this.snapshot();
170
+ this.zoom(Math.exp(delta * this.zoomSpeed));
171
+ this.constrain(prev);
172
+ this.changed();
173
+ }
174
+
175
+ orbit(dx, dy) {
176
+ const c = this.camera;
177
+ const off = [c.position[0] - c.target[0], c.position[1] - c.target[1], c.position[2] - c.target[2]];
178
+ const r = Math.hypot(off[0], off[1], off[2]) || 1e-6;
179
+ let theta = Math.atan2(off[0], off[2]);
180
+ let phi = Math.acos(Math.max(-1, Math.min(1, off[1] / r)));
181
+ theta -= dx * this.rotateSpeed;
182
+ phi -= dy * this.rotateSpeed;
183
+ phi = Math.max(0.001, Math.min(Math.PI - 0.001, phi));
184
+ c.position = [
185
+ c.target[0] + r * Math.sin(phi) * Math.sin(theta),
186
+ c.target[1] + r * Math.cos(phi),
187
+ c.target[2] + r * Math.sin(phi) * Math.cos(theta),
188
+ ];
189
+ c.up = [0, 1, 0];
190
+ }
191
+
192
+ pan(dx, dy) {
193
+ const c = this.camera;
194
+ const h = this.el.clientHeight || 1;
195
+ const dist = c.distance;
196
+ const unitsPerPixel = c.type === 'orthographic' ? c.orthoHeight / h : (2 * dist * Math.tan(((c.fov / 2) * Math.PI) / 180)) / h;
197
+ // camera basis
198
+ const f = [c.target[0] - c.position[0], c.target[1] - c.position[1], c.target[2] - c.position[2]];
199
+ const fl = Math.hypot(...f) || 1;
200
+ f[0] /= fl; f[1] /= fl; f[2] /= fl;
201
+ let right = [f[1] * c.up[2] - f[2] * c.up[1], f[2] * c.up[0] - f[0] * c.up[2], f[0] * c.up[1] - f[1] * c.up[0]];
202
+ const rl = Math.hypot(...right) || 1;
203
+ right = right.map((v) => v / rl);
204
+ const up = [right[1] * f[2] - right[2] * f[1], right[2] * f[0] - right[0] * f[2], right[0] * f[1] - right[1] * f[0]];
205
+ const mx = -dx * unitsPerPixel, my = dy * unitsPerPixel;
206
+ for (let k = 0; k < 3; k++) {
207
+ const d = right[k] * mx + up[k] * my;
208
+ c.position[k] += d;
209
+ c.target[k] += d;
210
+ }
211
+ }
212
+
213
+ zoom(factor) {
214
+ const c = this.camera;
215
+ if (c.type === 'orthographic') {
216
+ c.orthoHeight = Math.max(1e-4, c.orthoHeight * factor);
217
+ return;
218
+ }
219
+ const off = [c.position[0] - c.target[0], c.position[1] - c.target[1], c.position[2] - c.target[2]];
220
+ const r = Math.hypot(...off);
221
+ const nr = Math.max(1e-4, r * factor);
222
+ for (let k = 0; k < 3; k++) c.position[k] = c.target[k] + (off[k] / (r || 1)) * nr;
223
+ }
224
+
225
+ /** Move the orbit centre to a point, keeping the viewing direction. */
226
+ focus(point) {
227
+ const c = this.camera;
228
+ const prev = this.snapshot();
229
+ const d = [point[0] - c.target[0], point[1] - c.target[1], point[2] - c.target[2]];
230
+ for (let k = 0; k < 3; k++) {
231
+ c.target[k] += d[k];
232
+ c.position[k] += d[k];
233
+ }
234
+ this.constrain(prev);
235
+ this.changed();
236
+ }
237
+ }