@kupcad/ui 0.0.0-stage → 0.1.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 (45) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +281 -2
  3. package/package.json +65 -3
  4. package/src/ConsolePanel.svelte +139 -0
  5. package/src/ExportMenu.svelte +36 -0
  6. package/src/ParamField.svelte +390 -0
  7. package/src/ParamsPanel.svelte +216 -0
  8. package/src/PreviewPanel.svelte +372 -0
  9. package/src/ShortcutsDialog.svelte +170 -0
  10. package/src/Splitter.svelte +148 -0
  11. package/src/Viewer.svelte +175 -0
  12. package/src/ViewerPane.svelte +745 -0
  13. package/src/base64.js +16 -0
  14. package/src/console.js +78 -0
  15. package/src/controls.css +183 -0
  16. package/src/index.js +40 -0
  17. package/src/measures.js +52 -0
  18. package/src/params.js +348 -0
  19. package/src/range.js +19 -0
  20. package/src/recent.js +44 -0
  21. package/src/run-status.js +78 -0
  22. package/src/settings.js +82 -0
  23. package/src/shortcuts.js +59 -0
  24. package/src/theme.js +131 -0
  25. package/src/tokens.css +70 -0
  26. package/src/values.svelte.js +13 -0
  27. package/src/view-settings.js +81 -0
  28. package/src/viewer/appearance.js +90 -0
  29. package/src/viewer/camera.js +100 -0
  30. package/src/viewer/dispose.js +30 -0
  31. package/src/viewer/environment.js +145 -0
  32. package/src/viewer/faces.js +311 -0
  33. package/src/viewer/hover-mark.js +125 -0
  34. package/src/viewer/index.js +7 -0
  35. package/src/viewer/model.js +293 -0
  36. package/src/viewer/motion.js +96 -0
  37. package/src/viewer/section.js +166 -0
  38. package/src/viewer/testing.js +90 -0
  39. package/src/viewer/theme.js +32 -0
  40. package/src/viewer/types.d.ts +136 -0
  41. package/src/viewer/view-cube.js +314 -0
  42. package/src/viewer/viewer.js +1027 -0
  43. package/src/viewer/wheel.js +55 -0
  44. package/src/viewport-socket.js +64 -0
  45. package/src/vscode.css +49 -0
@@ -0,0 +1,1027 @@
1
+ // The viewer: a three.js scene of a model the core made, in the script's own coordinates
2
+ // (millimetres, Z up), for any host; Viewer.svelte wraps it for Svelte. Its state is properties
3
+ // (render mode, camera, section, selection, environment) that the host's toolbar sets, and it
4
+ // renders when one changes or the camera moves, not every frame. A click on a part selects it
5
+ // and says so (a 'select' event, `detail.part` its name, or null), as does a model without the
6
+ // selected part. The camera moves to a view over a moment (motion.js); turned by hand, it is in
7
+ // no named view, which a 'view' event says. Its keys and its wheel (wheel.js) are Orca Slicer's;
8
+ // the section the wheel moves, a 'section' event says. What is under the pointer (the part, the
9
+ // face: faces.js) a 'hover' event says, a frame at most, and the viewer marks: the copy and its
10
+ // face glow in the selection's colour (hover-mark.js).
11
+
12
+ import {
13
+ AmbientLight,
14
+ Box3,
15
+ Color,
16
+ DirectionalLight,
17
+ Group,
18
+ LineBasicMaterial,
19
+ MOUSE,
20
+ OrthographicCamera,
21
+ PerspectiveCamera,
22
+ Quaternion,
23
+ Raycaster,
24
+ Scene,
25
+ Sphere,
26
+ Vector2,
27
+ Vector3,
28
+ WebGLRenderer,
29
+ } from 'three';
30
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
31
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
32
+ import { applyRenderMode, applySelection, applyTheme } from './appearance.js';
33
+ import {
34
+ boundsOf,
35
+ halfFovTangent,
36
+ orbited,
37
+ orthographicHalfHeight,
38
+ perspectiveDistance,
39
+ VIEWS,
40
+ ZOOM_STEP,
41
+ } from './camera.js';
42
+ import { disposeObject } from './dispose.js';
43
+ import { axesOf, environmentOf } from './environment.js';
44
+ import { faceAt } from './faces.js';
45
+ import { HoverMark } from './hover-mark.js';
46
+ import { disposeScene, isDracoCompressed, sceneOf } from './model.js';
47
+ import { CameraMove, MOTION_MS, sameDirection, unit } from './motion.js';
48
+ import { RENDER_ORDER, SectionView } from './section.js';
49
+ import { LIGHT } from './theme.js';
50
+ import { wheelGesture } from './wheel.js';
51
+
52
+ /** @import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js' */
53
+ /** @import { Pose } from './motion.js' */
54
+
55
+ /** @import { Model } from '@kupcad/protocol/types' */
56
+ /** @import { Bounds, CameraState, Environment, Hover, Plate, Projection, RendererLike, RenderMode, SceneModel, Section, Shape, Theme, View, ViewerOptions } from './types.js' */
57
+
58
+ /** The perspective camera's vertical field of view, in degrees. */
59
+ const FOV_DEG = 35;
60
+ const DEFAULT_PLATE = { width: 256, depth: 256 };
61
+ /** A pointer that moves less than this between down and up clicked, rather than dragged. */
62
+ const CLICK_SLOP_PX = 4;
63
+ /** The perspective camera's near plane, as a factor of the model's radius. */
64
+ const NEAR_FACTOR = 0.01;
65
+ /** The orthographic camera stands this many radii from the model's centre. */
66
+ const ORTHO_STANDOFF = 4;
67
+ /** The lights: an even fill, and one from the camera. */
68
+ const AMBIENT_INTENSITY = 0.9;
69
+ const HEADLIGHT_INTENSITY = 2.2;
70
+ /** The views by key, Orca Slicer's (Ctrl or Cmd with a digit, or the digit alone): 0 its default. */
71
+ const VIEW_KEYS = /** @type {Record<string, View>} */ ({
72
+ 0: 'iso',
73
+ 1: 'top',
74
+ 2: 'bottom',
75
+ 3: 'front',
76
+ 4: 'back',
77
+ 5: 'left',
78
+ 6: 'right',
79
+ });
80
+ /** Scrolling this many CSS pixels with Option moves the section across the whole model. */
81
+ const SECTION_SWEEP_PX = 2000;
82
+
83
+ /** The render modes a toolbar offers, in order. */
84
+ export const RENDER_MODES = /** @type {const} */ (['solid', 'xray', 'wireframe']);
85
+ /** The views the camera turns to, by name. */
86
+ const VIEW_NAMES = /** @type {View[]} */ (Object.keys(VIEWS));
87
+
88
+ /** A model the viewer can't show, saying why. */
89
+ export class ViewerError extends Error {
90
+ /** @param {string} message */
91
+ constructor(message) {
92
+ super(message);
93
+ this.name = 'ViewerError';
94
+ }
95
+ }
96
+
97
+ /**
98
+ * @param {HTMLCanvasElement} canvas
99
+ * @returns {RendererLike}
100
+ */
101
+ const webGLRenderer = (canvas) => new WebGLRenderer({ canvas, antialias: true, stencil: true });
102
+
103
+ export class Viewer extends EventTarget {
104
+ /** @type {HTMLCanvasElement} */
105
+ #canvas;
106
+ /** @type {ViewerOptions} */
107
+ #options;
108
+ /** @type {RendererLike} */
109
+ #renderer;
110
+ #scene = new Scene();
111
+ /** The model's scene goes in here; what it stands on, beside it. */
112
+ #content = new Group();
113
+ #environment = new Group();
114
+ #perspective = new PerspectiveCamera(FOV_DEG, 1, 1, 1000);
115
+ #orthographic = new OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
116
+ /** @type {PerspectiveCamera | OrthographicCamera} */
117
+ #camera = this.#perspective;
118
+ /** The orthographic camera's half-height at zoom 1. */
119
+ #halfHeight = 1;
120
+ /** @type {OrbitControls} */
121
+ #controls;
122
+ #headlight = new DirectionalLight(0xffffff, HEADLIGHT_INTENSITY);
123
+ /** @type {SectionView} */
124
+ #sectionView;
125
+ #raycaster = new Raycaster();
126
+ /** The selected part's edges, in the selection's colour. */
127
+ #selectionEdges = new LineBasicMaterial();
128
+ /** What the pointer is over, marked. */
129
+ #hoverMark = new HoverMark(LIGHT.selection);
130
+ /** @type {SceneModel | null} */
131
+ #model = null;
132
+ /** The kind of the last model shown, which a model of nothing (one that failed) leaves. @type {SceneModel['kind'] | null} */
133
+ #shownKind = null;
134
+ /** The camera a solid was last seen with, which a drawing (from the top, orthographic) leaves. @type {{ projection: Projection, view: View | null, direction: Vector3 }} */
135
+ #solidCamera = { projection: 'perspective', view: 'iso', direction: VIEWS.iso.clone() };
136
+ /** Around the model and what it stands on: what the camera's depth takes in. */
137
+ #sceneSphere = new Sphere();
138
+ /** @type {Theme} */
139
+ #theme;
140
+ /** @type {Environment} */
141
+ #environmentKind;
142
+ /** @type {Plate} */
143
+ #plate;
144
+ /** @type {RenderMode} */
145
+ #renderMode = 'solid';
146
+ /** The shapes' edges are drawn (a wireframe's always are). */
147
+ #edges = true;
148
+ /** The axes through the origin are drawn. */
149
+ #axes = true;
150
+ /** @type {Projection} */
151
+ #projection = 'perspective';
152
+ /** The named view the camera looks from, or null for none (turned by hand). @type {View | null} */
153
+ #view = 'iso';
154
+ /** The camera's move under way, if one. @type {CameraMove | null} */
155
+ #move = null;
156
+ /** How the camera was turned when last drawn: a change is a 'camera' event. */
157
+ #orientation = new Quaternion();
158
+ /** @type {Section | null} */
159
+ #section = null;
160
+ /** @type {string | null} */
161
+ #selected = null;
162
+ /** @type {Promise<GLTFLoader> | null} */
163
+ #loader = null;
164
+ /** The GLB loader's Draco decoder, once made: it keeps its workers until disposed. @type {DRACOLoader | null} */
165
+ #draco = null;
166
+ /** Counts the models asked for: one that loads after a later one is dropped. */
167
+ #loads = 0;
168
+ #frame = 0;
169
+ #width = 1;
170
+ #height = 1;
171
+ /** Where the pointer went down, to tell a click from a drag. @type {{ x: number, y: number } | null} */
172
+ #down = null;
173
+ /** Where the pointer is, to say what is under it on the next frame. @type {{ x: number, y: number } | null} */
174
+ #pointer = null;
175
+ #hoverFrame = 0;
176
+ /** Something under the pointer was said: leaving it is said too. */
177
+ #hovering = false;
178
+ #disposed = false;
179
+ /** @type {AbortController} */
180
+ #listeners = new AbortController();
181
+
182
+ /**
183
+ * @param {HTMLCanvasElement} canvas
184
+ * @param {ViewerOptions} [options]
185
+ */
186
+ constructor(canvas, options = {}) {
187
+ super();
188
+ this.#canvas = canvas;
189
+ this.#options = options;
190
+ this.#theme = options.theme ?? LIGHT;
191
+ this.#environmentKind = options.environment ?? 'grid';
192
+ this.#plate = options.plate ?? DEFAULT_PLATE;
193
+ this.#renderer = (options.renderer ?? webGLRenderer)(canvas);
194
+ this.#renderer.localClippingEnabled = true;
195
+ this.#renderer.setPixelRatio(globalThis.devicePixelRatio ?? 1);
196
+ for (const camera of [this.#perspective, this.#orthographic]) camera.up.set(0, 0, 1);
197
+ const { signal } = this.#listeners;
198
+ // Before the controls' own, which it keeps the wheel from
199
+ canvas.addEventListener('wheel', (event) => this.#wheel(event), { signal, passive: false });
200
+ this.#controls = new OrbitControls(this.#camera, canvas);
201
+ // Orca Slicer's: the middle button pans, as the right one does
202
+ this.#controls.mouseButtons.MIDDLE = MOUSE.PAN;
203
+ // Taken by hand: a move under way stops where it is
204
+ this.#controls.addEventListener('start', () => (this.#move = null));
205
+ this.#controls.addEventListener('change', () => {
206
+ this.#fitDepth();
207
+ this.#noteTurned();
208
+ this.#invalidate();
209
+ });
210
+ this.#sectionView = new SectionView(this.#theme);
211
+ this.#scene.add(
212
+ this.#content,
213
+ this.#environment,
214
+ this.#sectionView.cap,
215
+ new AmbientLight(0xffffff, AMBIENT_INTENSITY),
216
+ this.#headlight,
217
+ this.#headlight.target,
218
+ );
219
+ canvas.addEventListener('pointerdown', (event) => this.#pointerDown(event), { signal });
220
+ canvas.addEventListener('pointerup', (event) => this.#pointerUp(event), { signal });
221
+ canvas.addEventListener('keydown', (event) => this.#keyDown(event), { signal });
222
+ canvas.addEventListener('pointermove', (event) => this.#pointerMove(event), { signal });
223
+ canvas.addEventListener('pointerleave', () => this.#pointerMove(null), { signal });
224
+ // Its keys work once it has the focus, which a click gives it
225
+ if (canvas.tabIndex < 0) canvas.tabIndex = 0;
226
+ this.setTheme(this.#theme);
227
+ this.resize();
228
+ this.#frameFrom(VIEWS.iso, false);
229
+ }
230
+
231
+ get renderMode() {
232
+ return this.#renderMode;
233
+ }
234
+ get edges() {
235
+ return this.#edges;
236
+ }
237
+ get axes() {
238
+ return this.#axes;
239
+ }
240
+ get projection() {
241
+ return this.#projection;
242
+ }
243
+ /** The named view the camera looks from, or null when it was turned by hand. */
244
+ get view() {
245
+ return this.#view;
246
+ }
247
+
248
+ /** How the camera is turned (Z up): what a view cube follows, as 'camera' events say. */
249
+ get orientation() {
250
+ return this.#camera.quaternion.clone();
251
+ }
252
+ get section() {
253
+ return this.#section;
254
+ }
255
+ get selected() {
256
+ return this.#selected;
257
+ }
258
+ get environment() {
259
+ return this.#environmentKind;
260
+ }
261
+
262
+ /** The kind of the model shown: '3d' (its solids), '2d' (a drawing), or null for none. */
263
+ get kind() {
264
+ return this.#model?.kind ?? null;
265
+ }
266
+
267
+ /**
268
+ * The model's bounds, in the script's coordinates (mm), or null with no model shown: what a
269
+ * section's offset ranges over.
270
+ * @returns {Bounds | null}
271
+ */
272
+ get bounds() {
273
+ const box = this.#modelBox();
274
+ if (box.isEmpty()) return null;
275
+ return {
276
+ min: /** @type {Bounds['min']} */ (box.min.toArray()),
277
+ max: /** @type {Bounds['max']} */ (box.max.toArray()),
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Shows `model` in place of the last one (null, or a model of kind 'none', shows nothing).
283
+ * The camera stays where it is, unless the model is the first, or of another kind than the
284
+ * last one shown: then it frames it, a drawing from the top, orthographic, and a solid after
285
+ * a drawing as the last solid was seen. A selection whose part is gone is dropped, with a
286
+ * 'select' event.
287
+ * @param {Model | null} model
288
+ * @returns {Promise<boolean>} whether it is shown: not when a later model was asked for
289
+ * before it was ready, or the viewer was disposed
290
+ * @throws {ViewerError} for a Draco-compressed model without `dracoDecoderPath`
291
+ */
292
+ async setModel(model) {
293
+ const load = ++this.#loads;
294
+ const built = model ? await this.#build(model) : null;
295
+ if (load !== this.#loads || this.#disposed) {
296
+ if (built) disposeScene(built);
297
+ return false;
298
+ }
299
+ const before = this.#model;
300
+ // Its marks go with it: the next pointer move marks the new one
301
+ this.#markHover(null);
302
+ if (before) {
303
+ before.root.removeFromParent();
304
+ disposeScene(before);
305
+ }
306
+ this.#model = built;
307
+ if (built) {
308
+ for (const shape of built.shapes) shape.mesh.renderOrder = RENDER_ORDER.model;
309
+ this.#content.add(built.root);
310
+ }
311
+ const dropped =
312
+ this.#selected !== null && !built?.shapes.some((shape) => shape.part === this.#selected);
313
+ if (dropped) this.#selected = null;
314
+ this.#applyRenderMode();
315
+ this.#applySelection();
316
+ this.#rebuildEnvironment();
317
+ if (built && built.kind !== this.#shownKind) {
318
+ if (built.kind === '2d') {
319
+ if (this.#shownKind === '3d') {
320
+ this.#solidCamera = {
321
+ projection: this.#projection,
322
+ view: this.#view,
323
+ direction: this.#direction(),
324
+ };
325
+ }
326
+ this.#useProjection('orthographic');
327
+ this.#setView('top');
328
+ this.#frameFrom(VIEWS.top, false);
329
+ } else if (this.#shownKind === '2d') {
330
+ this.#useProjection(this.#solidCamera.projection);
331
+ this.#setView(this.#solidCamera.view);
332
+ this.#frameFrom(this.#solidCamera.direction, false);
333
+ } else {
334
+ this.#frameFrom(this.#direction(), false);
335
+ }
336
+ }
337
+ if (built) this.#shownKind = built.kind;
338
+ this.#applySection();
339
+ this.#invalidate();
340
+ if (dropped) this.#said(null);
341
+ return true;
342
+ }
343
+
344
+ /** @param {RenderMode} mode */
345
+ setRenderMode(mode) {
346
+ this.#renderMode = mode;
347
+ this.#applyRenderMode();
348
+ this.#applySection();
349
+ this.#invalidate();
350
+ }
351
+
352
+ /**
353
+ * Draws the axes through the origin (X red, Y green, Z blue), where the coordinates are zero,
354
+ * or not.
355
+ * @param {boolean} visible
356
+ */
357
+ setAxes(visible) {
358
+ this.#axes = visible;
359
+ this.#rebuildEnvironment();
360
+ this.#invalidate();
361
+ }
362
+
363
+ /**
364
+ * Draws the shapes' edges, or not (a wireframe's are drawn whatever this says).
365
+ * @param {boolean} visible
366
+ */
367
+ setEdges(visible) {
368
+ this.#edges = visible;
369
+ this.#applyRenderMode();
370
+ this.#invalidate();
371
+ }
372
+
373
+ /**
374
+ * Looks with `projection`, and from `view`, the camera moving there and framing the model.
375
+ * @param {{ projection?: Projection, view?: View }} camera
376
+ */
377
+ setCamera({ projection, view }) {
378
+ if (projection) this.#useProjection(projection);
379
+ if (view) {
380
+ this.#setView(view);
381
+ this.#frameFrom(VIEWS[view], true);
382
+ }
383
+ this.#invalidate();
384
+ }
385
+
386
+ /**
387
+ * Looks from `direction` (from the model toward the camera, Z up: `[1, -1, 1]` is the iso
388
+ * view, `[0, 0, 1]` the top), the camera moving there and framing the model: a view cube's
389
+ * face, edge or corner. A named view's direction is that view.
390
+ * @param {readonly [number, number, number]} direction
391
+ */
392
+ lookFrom(direction) {
393
+ const turned = unit(direction);
394
+ const view = VIEW_NAMES.find((name) => sameDirection(VIEWS[name], turned)) ?? null;
395
+ this.#setView(view);
396
+ this.#frameFrom(view ? VIEWS[view] : turned, true);
397
+ }
398
+
399
+ /**
400
+ * Zooms in by `factor` (out, below 1) toward what the camera looks at.
401
+ * @param {number} factor
402
+ */
403
+ zoom(factor) {
404
+ this.#settleMove();
405
+ if (this.#camera === this.#orthographic) {
406
+ this.#orthographic.zoom *= factor;
407
+ } else {
408
+ const target = this.#controls.target;
409
+ this.#perspective.position.sub(target).divideScalar(factor).add(target);
410
+ }
411
+ this.#fitDepth();
412
+ this.#controls.update();
413
+ this.#invalidate();
414
+ }
415
+
416
+ /** @param {Section | null} section */
417
+ setSection(section) {
418
+ this.#section = section ? { ...section } : null;
419
+ this.#applySection();
420
+ this.#invalidate();
421
+ }
422
+
423
+ /**
424
+ * Tints the copies of `part`, or none (null).
425
+ * @param {string | null} part
426
+ */
427
+ select(part) {
428
+ this.#selected = part;
429
+ this.#applySelection();
430
+ this.#invalidate();
431
+ }
432
+
433
+ /**
434
+ * @param {Environment} environment
435
+ * @param {Plate} [plate] the build plate's size, from now on
436
+ */
437
+ setEnvironment(environment, plate) {
438
+ this.#environmentKind = environment;
439
+ if (plate) this.#plate = plate;
440
+ this.#rebuildEnvironment();
441
+ this.#invalidate();
442
+ }
443
+
444
+ /** @param {Theme} theme */
445
+ setTheme(theme) {
446
+ this.#theme = theme;
447
+ this.#scene.background = new Color(theme.background);
448
+ this.#selectionEdges.color.set(theme.selection);
449
+ this.#hoverMark.setColor(theme.selection);
450
+ this.#sectionView.setCapColor(theme.cap);
451
+ applyTheme(this.#model, theme);
452
+ this.#applyRenderMode();
453
+ this.#applySelection();
454
+ this.#rebuildEnvironment();
455
+ this.#invalidate();
456
+ }
457
+
458
+ /**
459
+ * Frames the whole model from where the camera looks now (its angle kept), moving there; at
460
+ * once where `instant`.
461
+ * @param {{ instant?: boolean }} [options]
462
+ */
463
+ fit({ instant = false } = {}) {
464
+ this.#frameFrom(this.#direction(), !instant);
465
+ }
466
+
467
+ /**
468
+ * Where the camera stands, a move under way ended where it was going: for `restoreCamera`, as
469
+ * a host that shows another script keeps this one's camera.
470
+ * @returns {CameraState}
471
+ */
472
+ cameraState() {
473
+ this.#settleMove();
474
+ const pose = this.#pose();
475
+ const halfHeight =
476
+ this.#camera === this.#orthographic
477
+ ? pose.halfHeight
478
+ : pose.distance * halfFovTangent(FOV_DEG);
479
+ return {
480
+ target: /** @type {[number, number, number]} */ (pose.target.toArray()),
481
+ direction: /** @type {[number, number, number]} */ (pose.direction.toArray()),
482
+ distance: pose.distance,
483
+ halfHeight,
484
+ view: this.#view,
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Stands the camera where `state` says, at once, through the camera it looks with now: as
490
+ * much seen at what it looks at, whichever it was taken with. Its named view is the state's
491
+ * where it still looks from it.
492
+ * @param {CameraState} state
493
+ */
494
+ restoreCamera(state) {
495
+ this.#move = null;
496
+ const direction = new Vector3(...state.direction).normalize();
497
+ const distance =
498
+ this.#camera === this.#perspective
499
+ ? state.halfHeight / halfFovTangent(FOV_DEG)
500
+ : state.distance;
501
+ this.#standAt(
502
+ { target: new Vector3(...state.target), direction, distance, halfHeight: state.halfHeight },
503
+ true,
504
+ );
505
+ const view = state.view && sameDirection(VIEWS[state.view], direction) ? state.view : null;
506
+ this.#setView(view);
507
+ this.#invalidate();
508
+ }
509
+
510
+ /**
511
+ * Fits the drawing to the canvas's size (by default the size it is laid out at), in CSS
512
+ * pixels.
513
+ * @param {number} [width]
514
+ * @param {number} [height]
515
+ */
516
+ resize(width = this.#canvas.clientWidth, height = this.#canvas.clientHeight) {
517
+ this.#width = Math.max(1, width);
518
+ this.#height = Math.max(1, height);
519
+ this.#renderer.setSize(this.#width, this.#height, false);
520
+ this.#updateProjection();
521
+ this.#invalidate();
522
+ }
523
+
524
+ /**
525
+ * The part under a point of the canvas (CSS pixels from its top left), or null: what a
526
+ * section cut away isn't there.
527
+ * @param {number} x
528
+ * @param {number} y
529
+ * @returns {string | null}
530
+ */
531
+ partAt(x, y) {
532
+ return this.#hitAt(x, y)?.shape.part ?? null;
533
+ }
534
+
535
+ /**
536
+ * What is under a point of the canvas (CSS pixels from its top left): the part (null for a
537
+ * nameless one) and, on a solid, the face; null over nothing, a ghost, or what a section cut
538
+ * away.
539
+ * @param {number} x
540
+ * @param {number} y
541
+ * @returns {Hover | null}
542
+ */
543
+ hoverAt(x, y) {
544
+ return this.#hoverOf(this.#hitAt(x, y));
545
+ }
546
+
547
+ /**
548
+ * What `hit` is: its part and, on a solid, its face; null for none.
549
+ * @param {{ shape: Shape, triangle: number | null } | null} hit
550
+ * @returns {Hover | null}
551
+ */
552
+ #hoverOf(hit) {
553
+ if (!hit) return null;
554
+ const face =
555
+ this.#model?.kind === '3d' && hit.triangle !== null
556
+ ? faceAt(hit.shape.mesh, hit.triangle)
557
+ : null;
558
+ return { part: hit.shape.part, face };
559
+ }
560
+
561
+ /**
562
+ * The canvas as it shows now, as an image.
563
+ * @param {string} [type]
564
+ * @returns {Promise<Blob>}
565
+ */
566
+ screenshot(type = 'image/png') {
567
+ // Read before the frame is handed on, while the drawing buffer still holds it
568
+ this.#render();
569
+ return new Promise((resolve, reject) => {
570
+ this.#canvas.toBlob((blob) => {
571
+ if (blob) resolve(blob);
572
+ else reject(new ViewerError('the canvas gave no image'));
573
+ }, type);
574
+ });
575
+ }
576
+
577
+ dispose() {
578
+ if (this.#disposed) return;
579
+ this.#disposed = true;
580
+ if (this.#frame) cancelAnimationFrame(this.#frame);
581
+ if (this.#hoverFrame) cancelAnimationFrame(this.#hoverFrame);
582
+ this.#listeners.abort();
583
+ this.#controls.dispose();
584
+ this.#hoverMark.dispose();
585
+ if (this.#model) disposeScene(this.#model);
586
+ disposeObject(this.#environment);
587
+ this.#draco?.dispose();
588
+ this.#sectionView.dispose();
589
+ this.#selectionEdges.dispose();
590
+ this.#renderer.dispose();
591
+ // Its WebGL context let go now: a page has few, and gets one back only when it is collected
592
+ this.#renderer.forceContextLoss();
593
+ }
594
+
595
+ /**
596
+ * @param {Model} model
597
+ * @returns {Promise<SceneModel | null>}
598
+ */
599
+ async #build(model) {
600
+ if (model.kind === '3d' && isDracoCompressed(model.glb) && !this.#options.dracoDecoderPath) {
601
+ throw new ViewerError(
602
+ "the model is Draco-compressed: give the viewer the decoder's files (dracoDecoderPath)",
603
+ );
604
+ }
605
+ return sceneOf(model, await this.#gltfLoader(), this.#theme);
606
+ }
607
+
608
+ /**
609
+ * The GLB loader, made once; with the Draco decoder when the host gave its files, which it
610
+ * loads only when a model needs it.
611
+ * @returns {Promise<GLTFLoader>}
612
+ */
613
+ #gltfLoader() {
614
+ this.#loader ??= (async () => {
615
+ const loader = new GLTFLoader();
616
+ const decoderPath = this.#options.dracoDecoderPath;
617
+ if (decoderPath) {
618
+ const { DRACOLoader } = await import('three/addons/loaders/DRACOLoader.js');
619
+ // Disposed while its module loaded: a decoder made now would keep its workers for good
620
+ if (!this.#disposed) {
621
+ this.#draco = new DRACOLoader().setDecoderPath(decoderPath);
622
+ loader.setDRACOLoader(this.#draco);
623
+ }
624
+ }
625
+ return loader;
626
+ })();
627
+ return this.#loader;
628
+ }
629
+
630
+ #applyRenderMode() {
631
+ applyRenderMode(this.#model, this.#renderMode, this.#edges, this.#theme);
632
+ }
633
+
634
+ #applySelection() {
635
+ applySelection(this.#model, this.#selected, this.#theme.selection, this.#selectionEdges);
636
+ }
637
+
638
+ #applySection() {
639
+ this.#sectionView.apply(this.#section, this.#model, boundsOf(this.#modelBox()), {
640
+ capped: this.#renderMode !== 'wireframe',
641
+ also: [this.#selectionEdges, ...this.#hoverMark.materials],
642
+ });
643
+ }
644
+
645
+ #rebuildEnvironment() {
646
+ disposeObject(this.#environment);
647
+ this.#environment.clear();
648
+ const box = this.#modelBox();
649
+ this.#environment.add(environmentOf(this.#environmentKind, box, this.#plate, this.#theme));
650
+ if (this.#axes) this.#environment.add(axesOf(this.#environmentKind, box, this.#plate));
651
+ this.#sceneSphere = boundsOf(box.union(new Box3().setFromObject(this.#environment)));
652
+ this.#fitDepth();
653
+ }
654
+
655
+ /**
656
+ * The camera's near and far planes, so the model and what it stands on are drawn whole from
657
+ * where it looks: an orthographic camera's near plane may lie behind it, a perspective
658
+ * one's stays where `fit` put it. They were the model's alone, so a small model's plate was
659
+ * cut off past the far plane, and behind an orthographic camera.
660
+ */
661
+ #fitDepth() {
662
+ const target = this.#controls.target;
663
+ const reach = target.distanceTo(this.#sceneSphere.center) + this.#sceneSphere.radius;
664
+ const distance = this.#camera.position.distanceTo(target);
665
+ if (this.#camera === this.#orthographic) this.#orthographic.near = distance - reach;
666
+ this.#camera.far = distance + reach;
667
+ this.#camera.updateProjectionMatrix();
668
+ }
669
+
670
+ /** @returns {Box3} */
671
+ #modelBox() {
672
+ return this.#model ? new Box3().setFromObject(this.#model.root) : new Box3();
673
+ }
674
+
675
+ /**
676
+ * The way the camera looks from, from what it looks at: the way it moves to, while it moves.
677
+ * @returns {Vector3}
678
+ */
679
+ #direction() {
680
+ if (this.#move) return this.#move.to.direction.clone();
681
+ return this.#camera.position.clone().sub(this.#controls.target).normalize();
682
+ }
683
+
684
+ /**
685
+ * Where the camera stands now.
686
+ * @returns {Pose}
687
+ */
688
+ #pose() {
689
+ const target = this.#controls.target.clone();
690
+ const offset = this.#camera.position.clone().sub(target);
691
+ return {
692
+ target,
693
+ direction: offset.clone().normalize(),
694
+ distance: offset.length(),
695
+ halfHeight: this.#halfHeight / this.#orthographic.zoom,
696
+ };
697
+ }
698
+
699
+ /**
700
+ * Where the camera stands to show the whole model from `direction`.
701
+ * @param {Vector3} direction
702
+ * @returns {Pose}
703
+ */
704
+ #framing(direction) {
705
+ const sphere = boundsOf(this.#modelBox());
706
+ const aspect = this.#width / this.#height;
707
+ const perspective = this.#camera === this.#perspective;
708
+ if (perspective) this.#perspective.near = Math.max(sphere.radius * NEAR_FACTOR, 0.01);
709
+ return {
710
+ target: sphere.center.clone(),
711
+ direction: direction.clone(),
712
+ distance: perspective
713
+ ? perspectiveDistance(sphere, FOV_DEG, aspect)
714
+ : sphere.radius * ORTHO_STANDOFF,
715
+ halfHeight: orthographicHalfHeight(sphere, aspect),
716
+ };
717
+ }
718
+
719
+ /**
720
+ * Frames the model from `direction`: over a move when `moving` (unless the page asks for
721
+ * reduced motion), at once otherwise.
722
+ * @param {Vector3} direction
723
+ * @param {boolean} moving
724
+ */
725
+ #frameFrom(direction, moving) {
726
+ const to = this.#framing(direction);
727
+ const ms = moving ? this.#motionMs() : 0;
728
+ this.#move = ms > 0 ? new CameraMove(this.#pose(), to, performance.now(), ms) : null;
729
+ if (!this.#move) this.#standAt(to, true);
730
+ this.#invalidate();
731
+ }
732
+
733
+ /** How long a move takes: none when the page asks for reduced motion. */
734
+ #motionMs() {
735
+ const reduced = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
736
+ return reduced ? 0 : (this.#options.motionMs ?? MOTION_MS);
737
+ }
738
+
739
+ /**
740
+ * Stands the camera at `pose`; `settled` when it stays there: the controls take it up.
741
+ * @param {Pose} pose
742
+ * @param {boolean} settled
743
+ */
744
+ #standAt(pose, settled) {
745
+ this.#controls.target.copy(pose.target);
746
+ this.#camera.position.copy(pose.target).addScaledVector(pose.direction, pose.distance);
747
+ this.#halfHeight = pose.halfHeight;
748
+ this.#orthographic.zoom = 1;
749
+ this.#camera.lookAt(pose.target);
750
+ this.#fitDepth();
751
+ this.#updateProjection();
752
+ if (settled) this.#controls.update();
753
+ }
754
+
755
+ /** Ends a move under way where it was going. */
756
+ #settleMove() {
757
+ const move = this.#move;
758
+ if (!move) return;
759
+ this.#move = null;
760
+ this.#standAt(move.to, true);
761
+ }
762
+
763
+ /**
764
+ * The view is `view` from now on, which a 'view' event says when it changed.
765
+ * @param {View | null} view
766
+ */
767
+ #setView(view) {
768
+ if (view === this.#view) return;
769
+ this.#view = view;
770
+ this.dispatchEvent(new CustomEvent('view', { detail: { view } }));
771
+ }
772
+
773
+ /** Turned by hand away from its named view, the camera is in none. */
774
+ #noteTurned() {
775
+ if (this.#move || this.#view === null) return;
776
+ if (!sameDirection(this.#direction(), VIEWS[this.#view])) this.#setView(null);
777
+ }
778
+
779
+ /**
780
+ * Looks through the other camera, from where this one looks and seeing as much.
781
+ * @param {Projection} projection
782
+ */
783
+ #useProjection(projection) {
784
+ if (projection === this.#projection) return;
785
+ this.#settleMove();
786
+ const target = this.#controls.target;
787
+ const from = this.#camera;
788
+ const direction = from.position.clone().sub(target).normalize();
789
+ const halfTan = halfFovTangent(FOV_DEG);
790
+ if (projection === 'orthographic') {
791
+ const distance = from.position.distanceTo(target);
792
+ this.#halfHeight = distance * halfTan;
793
+ this.#orthographic.zoom = 1;
794
+ this.#orthographic.position.copy(target).addScaledVector(direction, distance);
795
+ this.#camera = this.#orthographic;
796
+ } else {
797
+ const distance = this.#halfHeight / this.#orthographic.zoom / halfTan;
798
+ this.#perspective.position.copy(target).addScaledVector(direction, distance);
799
+ this.#camera = this.#perspective;
800
+ }
801
+ this.#projection = projection;
802
+ this.#camera.lookAt(target);
803
+ this.#controls.object = this.#camera;
804
+ this.#fitDepth();
805
+ this.#updateProjection();
806
+ this.#controls.update();
807
+ }
808
+
809
+ #updateProjection() {
810
+ const aspect = this.#width / this.#height;
811
+ this.#perspective.aspect = aspect;
812
+ this.#perspective.updateProjectionMatrix();
813
+ Object.assign(this.#orthographic, {
814
+ top: this.#halfHeight,
815
+ bottom: -this.#halfHeight,
816
+ left: -this.#halfHeight * aspect,
817
+ right: this.#halfHeight * aspect,
818
+ });
819
+ this.#orthographic.updateProjectionMatrix();
820
+ }
821
+
822
+ /** Draws on the next frame, once however many things changed before it. */
823
+ #invalidate() {
824
+ if (this.#frame || this.#disposed) return;
825
+ this.#frame = requestAnimationFrame(() => {
826
+ this.#frame = 0;
827
+ this.#render();
828
+ });
829
+ }
830
+
831
+ #render() {
832
+ if (this.#move) {
833
+ const { pose, done } = this.#move.at(performance.now());
834
+ if (done) this.#move = null;
835
+ this.#standAt(pose, done);
836
+ if (!done) this.#invalidate();
837
+ }
838
+ this.#headlight.position.copy(this.#camera.position);
839
+ this.#headlight.target.position.copy(this.#controls.target);
840
+ this.#renderer.render(this.#scene, this.#camera);
841
+ if (!this.#orientation.equals(this.#camera.quaternion)) {
842
+ this.#orientation.copy(this.#camera.quaternion);
843
+ this.dispatchEvent(new CustomEvent('camera'));
844
+ }
845
+ }
846
+
847
+ /**
848
+ * A click selects with the main button of the first pointer: the others pan and zoom.
849
+ * @param {PointerEvent} event
850
+ */
851
+ #pointerDown(event) {
852
+ this.#down =
853
+ event.isPrimary && event.button === 0 ? { x: event.offsetX, y: event.offsetY } : null;
854
+ }
855
+
856
+ /**
857
+ * The first shape the ray through a point of the canvas meets, and the triangle it meets:
858
+ * not a ghost (seen through), nor where a section cut it away.
859
+ * @param {number} x
860
+ * @param {number} y
861
+ * @returns {{ shape: Shape, triangle: number | null } | null}
862
+ */
863
+ #hitAt(x, y) {
864
+ if (!this.#model) return null;
865
+ this.#scene.updateMatrixWorld();
866
+ this.#camera.updateMatrixWorld();
867
+ const pointer = new Vector2((x / this.#width) * 2 - 1, 1 - (y / this.#height) * 2);
868
+ this.#raycaster.setFromCamera(pointer, this.#camera);
869
+ /** @type {Map<import('three').Object3D, Shape>} */
870
+ const solid = new Map();
871
+ for (const shape of this.#model.shapes) {
872
+ if (!shape.ghost) solid.set(shape.mesh, shape);
873
+ }
874
+ for (const hit of this.#raycaster.intersectObjects([...solid.keys()], false)) {
875
+ if (this.#sectionView.clipping && this.#sectionView.plane.distanceToPoint(hit.point) < 0) {
876
+ continue;
877
+ }
878
+ const shape = /** @type {Shape} */ (solid.get(hit.object));
879
+ return { shape, triangle: hit.faceIndex ?? null };
880
+ }
881
+ return null;
882
+ }
883
+
884
+ /**
885
+ * The pointer moved over the canvas (or left it: null): what is under it is said on the next
886
+ * frame, once however often it moved, and nothing while a button drags the camera.
887
+ * @param {PointerEvent | null} event
888
+ */
889
+ #pointerMove(event) {
890
+ this.#pointer = event && event.buttons === 0 ? { x: event.offsetX, y: event.offsetY } : null;
891
+ if (this.#hoverFrame || this.#disposed) return;
892
+ this.#hoverFrame = requestAnimationFrame(() => {
893
+ this.#hoverFrame = 0;
894
+ this.#sayHover();
895
+ });
896
+ }
897
+
898
+ /**
899
+ * Marks what is under the pointer, and says it ('hover', its detail `hoverAt`'s with where), or
900
+ * nothing.
901
+ */
902
+ #sayHover() {
903
+ const at = this.#pointer;
904
+ const hit = at ? this.#hitAt(at.x, at.y) : null;
905
+ this.#markHover(hit);
906
+ const under = this.#hoverOf(hit);
907
+ if (!under && !this.#hovering) return;
908
+ this.#hovering = under !== null;
909
+ const detail = under && at ? { ...under, x: at.x, y: at.y } : null;
910
+ this.dispatchEvent(new CustomEvent('hover', { detail }));
911
+ }
912
+
913
+ /**
914
+ * Marks what `hit` is (none: null): its copy, and on a solid its face; drawn again when the
915
+ * mark moved.
916
+ * @param {{ shape: Shape, triangle: number | null } | null} hit
917
+ */
918
+ #markHover(hit) {
919
+ const triangle = this.#model?.kind === '3d' ? (hit?.triangle ?? null) : null;
920
+ if (this.#hoverMark.mark(hit?.shape.mesh ?? null, triangle)) this.#invalidate();
921
+ }
922
+
923
+ /**
924
+ * Orca Slicer's keys: a digit (with Ctrl or Cmd, or alone) looks from a view, `F` fits the
925
+ * model, `I` and `O` zoom in and out. Another key is left to the page.
926
+ * @param {KeyboardEvent} event
927
+ */
928
+ #keyDown(event) {
929
+ if (event.altKey || event.shiftKey) return;
930
+ const view = Object.hasOwn(VIEW_KEYS, event.key) ? VIEW_KEYS[event.key] : null;
931
+ const key = event.ctrlKey || event.metaKey ? null : event.key.toLowerCase();
932
+ if (view) this.setCamera({ view });
933
+ else if (key === 'f') this.fit();
934
+ else if (key === 'i') this.zoom(ZOOM_STEP);
935
+ else if (key === 'o') this.zoom(1 / ZOOM_STEP);
936
+ else return;
937
+ event.preventDefault();
938
+ }
939
+
940
+ /**
941
+ * A turn of the wheel does what wheel.js says, the controls never: they would zoom only with
942
+ * the wheel alone, and not while a button drags.
943
+ * @param {WheelEvent} event
944
+ */
945
+ #wheel(event) {
946
+ event.preventDefault();
947
+ event.stopImmediatePropagation();
948
+ const gesture = wheelGesture(event, this.#height);
949
+ if (!gesture) return;
950
+ if (gesture.kind === 'zoom') this.zoom(gesture.factor);
951
+ else if (gesture.kind === 'pan') this.#pan(gesture.x, gesture.y);
952
+ else if (gesture.kind === 'orbit') this.#orbit(gesture.x, gesture.y);
953
+ else this.#moveSection(gesture.y);
954
+ }
955
+
956
+ /**
957
+ * Moves the camera across, as far as the model then moves the other way on the canvas, `x`
958
+ * and `y` CSS pixels (where the camera looks): as a page's content scrolls.
959
+ * @param {number} x
960
+ * @param {number} y
961
+ */
962
+ #pan(x, y) {
963
+ this.#settleMove();
964
+ const target = this.#controls.target;
965
+ const seen =
966
+ this.#camera === this.#orthographic
967
+ ? (2 * this.#halfHeight) / this.#orthographic.zoom
968
+ : 2 * this.#camera.position.distanceTo(target) * halfFovTangent(FOV_DEG);
969
+ const perPx = seen / this.#height;
970
+ const move = new Vector3(x * perPx, -y * perPx, 0).applyQuaternion(this.#camera.quaternion);
971
+ this.#camera.position.add(move);
972
+ target.add(move);
973
+ this.#controls.update();
974
+ }
975
+
976
+ /**
977
+ * Turns the camera about what it looks at as a drag of the left button by `-x` and `-y` CSS
978
+ * pixels does (the canvas's height of them, a whole turn): the model turns the way a page's
979
+ * content scrolls.
980
+ * @param {number} x
981
+ * @param {number} y
982
+ */
983
+ #orbit(x, y) {
984
+ this.#settleMove();
985
+ const target = this.#controls.target;
986
+ const turn = (2 * Math.PI) / this.#height;
987
+ const offset = orbited(this.#camera.position.clone().sub(target), x * turn, -y * turn);
988
+ this.#camera.position.copy(target).add(offset);
989
+ this.#controls.update();
990
+ }
991
+
992
+ /**
993
+ * Moves a solid's section down `y` CSS pixels' worth (up, below 0), within the model: a model
994
+ * without one is cut across Z from its top, as Orca's section view is. A 'section' event says
995
+ * where it is now.
996
+ * @param {number} y
997
+ */
998
+ #moveSection(y) {
999
+ if (this.#model?.kind !== '3d') return;
1000
+ const box = this.#modelBox();
1001
+ const section = this.#section ?? { axis: 'z', offset: box.max.z };
1002
+ const min = box.min[section.axis];
1003
+ const max = box.max[section.axis];
1004
+ const moved = section.offset - (y / SECTION_SWEEP_PX) * (max - min);
1005
+ const next = { ...section, offset: Math.min(max, Math.max(min, moved)) };
1006
+ this.setSection(next);
1007
+ this.dispatchEvent(new CustomEvent('section', { detail: { section: next } }));
1008
+ }
1009
+
1010
+ /** @param {PointerEvent} event */
1011
+ #pointerUp(event) {
1012
+ const down = this.#down;
1013
+ this.#down = null;
1014
+ if (!down || Math.hypot(event.offsetX - down.x, event.offsetY - down.y) > CLICK_SLOP_PX) return;
1015
+ const part = this.partAt(event.offsetX, event.offsetY);
1016
+ this.select(part);
1017
+ this.#said(part);
1018
+ }
1019
+
1020
+ /**
1021
+ * Says the selection is now `part`.
1022
+ * @param {string | null} part
1023
+ */
1024
+ #said(part) {
1025
+ this.dispatchEvent(new CustomEvent('select', { detail: { part } }));
1026
+ }
1027
+ }