@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
package/src/theme.js ADDED
@@ -0,0 +1,131 @@
1
+ // The viewer's theme from the --kc-viewer-* tokens of the page around it: a host themes the
2
+ // viewer as it themes the panels, and changes it while it shows (VS Code's theme switch). A
3
+ // token left unset, or set to no colour the page reads, takes the viewer's own colour, LIGHT or
4
+ // DARK as the page's colour scheme is.
5
+
6
+ import { DARK, LIGHT } from './viewer/index.js';
7
+
8
+ /** @import { Theme } from './viewer/types.js' */
9
+
10
+ /** Each colour of a viewer's theme, by the token that sets it. */
11
+ const TOKENS = /** @type {const} */ ({
12
+ background: '--kc-viewer-background',
13
+ grid: '--kc-viewer-grid',
14
+ plate: '--kc-viewer-plate',
15
+ edges: '--kc-viewer-edges',
16
+ wire: '--kc-viewer-wire',
17
+ selection: '--kc-viewer-selection',
18
+ cap: '--kc-viewer-cap',
19
+ ghost: '--kc-viewer-ghost',
20
+ highlight: '--kc-viewer-highlight',
21
+ });
22
+ /** A hex colour with an alpha (#rgba, #rrggbbaa), which three.js can't read: its colour first. */
23
+ const HEX_WITH_ALPHA = /^(#[\da-f]{3})[\da-f]$|^(#[\da-f]{6})[\da-f]{2}$/i;
24
+ /** An rgba() colour: three.js reads it, warning of the alpha it leaves out. */
25
+ const RGBA = /^rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)$/;
26
+ /** Two fills a colour the browser can't read leaves as they were: it reads as neither. */
27
+ const UNREAD = /** @type {const} */ (['#000000', '#ffffff']);
28
+ /** The attributes of the page's root and body a host themes it by (VS Code: a class, a style). */
29
+ const THEMED_BY = ['class', 'style'];
30
+
31
+ /** colorReader's, once made (null where the page has no 2D context). @type {ColorReader | null | undefined} */
32
+ let reader;
33
+
34
+ /**
35
+ * What of a 2D context reads a colour: its fill, and the pixel it draws.
36
+ * @typedef {Pick<CanvasRenderingContext2D, 'fillStyle' | 'clearRect' | 'fillRect' | 'getImageData'>} ColorReader
37
+ */
38
+
39
+ /**
40
+ * The theme the tokens set on `element` (inherited from the page) give.
41
+ * @param {Element} element
42
+ * @returns {Theme}
43
+ */
44
+ export function themeFromTokens(element) {
45
+ const style = getComputedStyle(element);
46
+ const dark = globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
47
+ const fallback = dark ? DARK : LIGHT;
48
+ /** @type {Theme} */
49
+ const theme = { ...fallback };
50
+ const context = colorReader();
51
+ for (const [key, token] of /** @type {[keyof Theme, string][]} */ (Object.entries(TOKENS))) {
52
+ theme[key] = viewerColor(style.getPropertyValue(token).trim(), context) || fallback[key];
53
+ }
54
+ return theme;
55
+ }
56
+
57
+ /**
58
+ * `color` as the viewer can draw it: #rrggbb, the sRGB of a pixel `context` draws in it, so any
59
+ * CSS colour the browser reads (three.js reads hex, rgb() and a name alone: a color-mix() or an
60
+ * oklch() it drew black, and a context's fill keeps those as they are), and opaque, a theme's
61
+ * colours being so; empty for one the browser doesn't read. Without a context (jsdom), the
62
+ * colour as given, opaque.
63
+ * @param {string} color
64
+ * @param {ColorReader | null} context
65
+ * @returns {string}
66
+ */
67
+ export function viewerColor(color, context) {
68
+ if (!color || !context) return opaque(color);
69
+ const fills = UNREAD.map((unread) => {
70
+ context.fillStyle = unread;
71
+ context.fillStyle = color;
72
+ return String(context.fillStyle);
73
+ });
74
+ if (fills[0] !== fills[1]) return '';
75
+ context.clearRect(0, 0, 1, 1);
76
+ context.fillRect(0, 0, 1, 1);
77
+ const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
78
+ return `#${[r, g, b].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`;
79
+ }
80
+
81
+ /**
82
+ * What reads a CSS colour as the page does: a 2D context, made once; null where the page has
83
+ * none.
84
+ * @returns {ColorReader | null}
85
+ */
86
+ function colorReader() {
87
+ if (reader === undefined) {
88
+ reader = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
89
+ }
90
+ return reader;
91
+ }
92
+
93
+ /**
94
+ * Gives `changed` the theme of `element`'s tokens now, and again whenever it changes: with the
95
+ * page's colour scheme, or a class or style of its root or body.
96
+ * @param {Element} element
97
+ * @param {(theme: Theme) => void} changed
98
+ * @returns {() => void} stops watching
99
+ */
100
+ export function watchTokens(element, changed) {
101
+ let last = '';
102
+ const update = () => {
103
+ const theme = themeFromTokens(element);
104
+ const key = JSON.stringify(theme);
105
+ if (key === last) return;
106
+ last = key;
107
+ changed(theme);
108
+ };
109
+ const observer = new MutationObserver(update);
110
+ for (const node of [document.documentElement, document.body]) {
111
+ observer.observe(node, { attributes: true, attributeFilter: THEMED_BY });
112
+ }
113
+ const scheme = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
114
+ scheme?.addEventListener('change', update);
115
+ update();
116
+ return () => {
117
+ observer.disconnect();
118
+ scheme?.removeEventListener('change', update);
119
+ };
120
+ }
121
+
122
+ /**
123
+ * `color` without the alpha of a hex or an rgba() colour that has one: a theme's colours are
124
+ * opaque.
125
+ * @param {string} color
126
+ */
127
+ function opaque(color) {
128
+ return color
129
+ .replace(HEX_WITH_ALPHA, (_, short, long) => short ?? long)
130
+ .replace(RGBA, (_, r, g, b) => `rgb(${r}, ${g}, ${b})`);
131
+ }
package/src/tokens.css ADDED
@@ -0,0 +1,70 @@
1
+ /*
2
+ The --kc-* tokens the panels are styled by, light and dark as the page's colour scheme is: the
3
+ KupCAD brand, orangered on brand black or white, gainsboro for the surfaces. A host sets its
4
+ own on top (vscode.css maps VS Code's). --kc-viewer-* (background, grid, plate, edges, wire,
5
+ selection, cap, ghost, highlight) theme the viewer; left unset, it uses its own colours, the
6
+ brand's too. A control of a host's own takes the panels' look by the class .kc-control
7
+ (controls.css); a range input by .kc-range, a checkbox by .kc-check.
8
+
9
+ The accent is a fill where there is one of a kind (the primary button, a slider, a checked
10
+ box); a toggle while on, or what is chosen in a list, is --kc-accent-subtle edged in it, as
11
+ several are on at once. The text on the fill is --kc-accent-fg, the brand black: white on the
12
+ orange is 3.0:1, too faint for text. Orange as text on the background (a link, a revert) is --kc-accent-text, a darker
13
+ orange in light, as the brand orange on white is 3.0:1 too. tokens.test.js holds each pair
14
+ to 4.5:1 for text and 3:1 for a control's edge or a focus ring.
15
+ */
16
+ @import './controls.css';
17
+
18
+ :root {
19
+ --kc-font: system-ui, -apple-system, 'Segoe UI', sans-serif;
20
+ --kc-font-size: 13px;
21
+ --kc-bg: #ffffff;
22
+ --kc-fg: #1a1b1c;
23
+ --kc-muted: #5a5d65;
24
+ /* A divider between panes and rows */
25
+ --kc-border: #d5dae0;
26
+ /* The edge of a field, a select or a checkbox, which tells it from the background */
27
+ --kc-control-border: #8a9099;
28
+ --kc-input-bg: #ffffff;
29
+ /* A row or a button under the pointer */
30
+ --kc-hover: rgba(26, 27, 28, 0.06);
31
+ --kc-accent: #ff5f14;
32
+ --kc-accent-fg: #1a1b1c;
33
+ --kc-accent-hover: #f0540c;
34
+ --kc-accent-text: #c5480d;
35
+ /* What is chosen in a list (the tree's open script), its text --kc-fg */
36
+ --kc-accent-subtle: #ffe9de;
37
+ --kc-focus: #c5480d;
38
+ /* A slider's track beyond its thumb */
39
+ --kc-track: #d5dae0;
40
+ --kc-error: #c8102e;
41
+ --kc-warning: #8a5a00;
42
+ /* A section's header: a group of parameters' */
43
+ --kc-section-bg: #e3e7eb;
44
+ --kc-section-fg: #1a1b1c;
45
+ --kc-radius: 4px;
46
+ --kc-gap: 8px;
47
+ }
48
+
49
+ @media (prefers-color-scheme: dark) {
50
+ :root {
51
+ --kc-bg: #1a1b1c;
52
+ --kc-fg: #e3e7eb;
53
+ --kc-muted: #9a9da3;
54
+ --kc-border: #333436;
55
+ --kc-control-border: #73767c;
56
+ --kc-input-bg: #282829;
57
+ --kc-hover: rgba(227, 231, 235, 0.07);
58
+ --kc-accent: #ff5f14;
59
+ --kc-accent-fg: #1a1b1c;
60
+ --kc-accent-hover: #ff7a3d;
61
+ --kc-accent-text: #ff6a24;
62
+ --kc-accent-subtle: #43271b;
63
+ --kc-focus: #ff6a24;
64
+ --kc-track: #3a3b3e;
65
+ --kc-error: #ff6b81;
66
+ --kc-warning: #e3b341;
67
+ --kc-section-bg: #282829;
68
+ --kc-section-fg: #e3e7eb;
69
+ }
70
+ }
@@ -0,0 +1,13 @@
1
+ // A panel's values as plain data, for a message or a worker: a state's proxy can't be cloned.
2
+
3
+ /** @import { ParamValues } from '@kupcad/protocol/types' */
4
+
5
+ /**
6
+ * `values`, a state's, as plain data.
7
+ * @param {ParamValues} values
8
+ * @returns {ParamValues}
9
+ */
10
+ export function plainValues(values) {
11
+ // Snapshot's type of the recursive ParamValue is too deep for TypeScript to work out
12
+ return /** @type {ParamValues} */ ($state.snapshot(/** @type {unknown} */ (values)));
13
+ }
@@ -0,0 +1,81 @@
1
+ // How the viewer looks, as the person chose it, kept in a host's settings under the view.* keys:
2
+ // the render mode, the edges and the axes shown, the floor (environment) and the projection.
3
+ // ViewerPane reads each in a $derived and writes what the person chooses; a host's settings page
4
+ // shows and writes the same keys, and the pane follows it. A kept value that isn't one reads as
5
+ // the default.
6
+
7
+ import { RENDER_MODES } from './viewer/index.js';
8
+
9
+ /** @import { Settings } from './settings.js' */
10
+ /** @import { Environment, Projection, RenderMode } from './viewer/types.js' */
11
+
12
+ /**
13
+ * The view as chosen.
14
+ * @typedef {object} ViewChoices
15
+ * @property {RenderMode} renderMode
16
+ * @property {boolean} edges
17
+ * @property {boolean} axes
18
+ * @property {Environment} environment the floor: a grid, the build plate, or none
19
+ * @property {Projection} projection
20
+ */
21
+
22
+ /** The settings' keys, by choice. @type {Readonly<Record<keyof ViewChoices, string>>} */
23
+ export const VIEW_KEYS = Object.freeze({
24
+ renderMode: 'view.renderMode',
25
+ edges: 'view.edges',
26
+ axes: 'view.axes',
27
+ environment: 'view.environment',
28
+ projection: 'view.projection',
29
+ });
30
+
31
+ /** The floors the viewer stands a model on, in the order a settings page offers them. */
32
+ export const ENVIRONMENTS = /** @type {const} */ (['grid', 'plate', 'none']);
33
+
34
+ /** The projections, in the order a settings page offers them. */
35
+ export const PROJECTIONS = /** @type {const} */ (['perspective', 'orthographic']);
36
+
37
+ /** What the view is before anyone chose. @type {Readonly<ViewChoices>} */
38
+ export const VIEW_DEFAULTS = Object.freeze({
39
+ renderMode: 'solid',
40
+ edges: true,
41
+ axes: true,
42
+ environment: 'grid',
43
+ projection: 'perspective',
44
+ });
45
+
46
+ /** The values each choice takes. @type {Record<keyof ViewChoices, readonly unknown[]>} */
47
+ const VALID = {
48
+ renderMode: RENDER_MODES,
49
+ edges: [true, false],
50
+ axes: [true, false],
51
+ environment: ENVIRONMENTS,
52
+ projection: PROJECTIONS,
53
+ };
54
+
55
+ /**
56
+ * The view's `name` as `settings` keep it, else its default. Read in a reaction, it reads that
57
+ * key alone.
58
+ * @template {keyof ViewChoices} K
59
+ * @param {Settings} settings
60
+ * @param {K} name
61
+ * @returns {ViewChoices[K]}
62
+ */
63
+ export function viewChoice(settings, name) {
64
+ const kept = settings.get(VIEW_KEYS[name]);
65
+ return /** @type {ViewChoices[K]} */ (VALID[name].includes(kept) ? kept : VIEW_DEFAULTS[name]);
66
+ }
67
+
68
+ /**
69
+ * The whole view as `settings` keep it, each choice else its default.
70
+ * @param {Settings} settings
71
+ * @returns {ViewChoices}
72
+ */
73
+ export function viewChoices(settings) {
74
+ return {
75
+ renderMode: viewChoice(settings, 'renderMode'),
76
+ edges: viewChoice(settings, 'edges'),
77
+ axes: viewChoice(settings, 'axes'),
78
+ environment: viewChoice(settings, 'environment'),
79
+ projection: viewChoice(settings, 'projection'),
80
+ };
81
+ }
@@ -0,0 +1,90 @@
1
+ // How a model's shapes look: their render mode (and the theme's colour of their edges in it), the
2
+ // selection, and the theme's colours of a ghost and a highlight, set on their materials. Ghosts
3
+ // keep their own look.
4
+
5
+ import { Color } from 'three';
6
+
7
+ /** @import { LineBasicMaterial, MeshBasicMaterial, MeshStandardMaterial } from 'three' */
8
+ /** @import { RenderMode, SceneModel, Theme } from './types.js' */
9
+
10
+ /** How see-through a solid is in x-ray. */
11
+ export const XRAY_OPACITY = 0.25;
12
+ /** The selected part's colour moves this far from its own toward the selection's. */
13
+ export const SELECTION_TINT = 0.45;
14
+
15
+ /**
16
+ * Draws the model's shapes as `mode` says: as they are, see-through, or not at all but for their
17
+ * edges (the mesh stays, for picking, and its edges with it); their edges with them when
18
+ * `edges`, and always in a wireframe, which is nothing else. A highlight is seen through what
19
+ * hides it only as they are: in x-ray everything is, and a wireframe has no faces. The edges take
20
+ * `theme`'s colour for where they are drawn (edgeColorOf).
21
+ * @param {SceneModel | null} model
22
+ * @param {RenderMode} mode
23
+ * @param {boolean} edges
24
+ * @param {Theme} theme
25
+ */
26
+ export function applyRenderMode(model, mode, edges, theme) {
27
+ if (!model) return;
28
+ model.edgeMaterial.color.set(edgeColorOf(model.kind, mode, theme));
29
+ for (const shape of model.shapes) {
30
+ if (shape.edges) shape.edges.visible = edges || mode === 'wireframe';
31
+ if (shape.seenThrough) shape.seenThrough.visible = mode === 'solid';
32
+ if (shape.ghost) continue;
33
+ const material = /** @type {MeshStandardMaterial} */ (shape.mesh.material);
34
+ const xray = mode === 'xray';
35
+ material.visible = mode !== 'wireframe';
36
+ material.transparent = xray || shape.opacity < 1;
37
+ material.opacity = xray ? Math.min(shape.opacity, XRAY_OPACITY) : shape.opacity;
38
+ material.depthWrite = !xray;
39
+ material.needsUpdate = true;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * The colour of a model's edges: over its faces, `theme`'s `edges`; on the background, where a
45
+ * wireframe's are and a drawing's outlines, its `wire` (a dark theme's edges, near black, were
46
+ * lost on its background there).
47
+ * @param {SceneModel['kind']} kind
48
+ * @param {RenderMode} mode
49
+ * @param {Theme} theme
50
+ */
51
+ export function edgeColorOf(kind, mode, theme) {
52
+ return kind === '2d' || mode === 'wireframe' ? theme.wire : theme.edges;
53
+ }
54
+
55
+ /**
56
+ * Tints the copies of `part` toward `selection`, and draws their edges with `selectionEdges`;
57
+ * the other shapes as they are.
58
+ * @param {SceneModel | null} model
59
+ * @param {string | null} part
60
+ * @param {string} selection a CSS colour
61
+ * @param {LineBasicMaterial} selectionEdges
62
+ */
63
+ export function applySelection(model, part, selection, selectionEdges) {
64
+ if (!model) return;
65
+ const tint = new Color(selection);
66
+ for (const shape of model.shapes) {
67
+ const material = /** @type {MeshStandardMaterial} */ (shape.mesh.material);
68
+ const chosen = part !== null && shape.part === part;
69
+ material.color.copy(shape.color);
70
+ if (chosen) material.color.lerp(tint, SELECTION_TINT);
71
+ if (shape.edges) shape.edges.material = chosen ? selectionEdges : model.edgeMaterial;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Gives the model the colours of `theme` it takes besides its edges' (applyRenderMode's): a
77
+ * ghost's and a highlight's (the selection, applied after, tints from them).
78
+ * @param {SceneModel | null} model
79
+ * @param {Theme} theme
80
+ */
81
+ export function applyTheme(model, theme) {
82
+ if (!model) return;
83
+ for (const shape of model.shapes) {
84
+ if (shape.ghost) shape.color.set(theme.ghost);
85
+ if (shape.seenThrough) {
86
+ shape.color.set(theme.highlight);
87
+ /** @type {MeshBasicMaterial} */ (shape.seenThrough.material).color.set(theme.highlight);
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,100 @@
1
+ // Where the camera looks from, and how far it stands to frame a model: plain math over the
2
+ // script's coordinates (millimetres, Z up).
3
+
4
+ import { Sphere, Vector3 } from 'three';
5
+
6
+ /** @import { Box3 } from 'three' */
7
+ /** @import { View } from './types.js' */
8
+
9
+ /**
10
+ * A top or bottom view looks almost straight down the Z axis: exactly along it, Z up has no
11
+ * meaning on the screen, so the view leans this much toward -Y and the screen's up is +Y.
12
+ */
13
+ const STRAIGHT_LEAN = 1e-4;
14
+ /** A turn stops this short of looking straight down or up (radians): there, Z up means nothing. */
15
+ const POLE_MARGIN = 1e-3;
16
+
17
+ /** What one step zooms in by (`I`, and the most one turn of the wheel does), and out (`O`). */
18
+ export const ZOOM_STEP = 1.25;
19
+
20
+ /**
21
+ * From the model's centre toward the camera, for each view (unit length).
22
+ * @type {Record<View, Vector3>}
23
+ */
24
+ export const VIEWS = {
25
+ iso: new Vector3(1, -1, 1).normalize(),
26
+ top: new Vector3(0, -STRAIGHT_LEAN, 1).normalize(),
27
+ bottom: new Vector3(0, -STRAIGHT_LEAN, -1).normalize(),
28
+ front: new Vector3(0, -1, 0),
29
+ back: new Vector3(0, 1, 0),
30
+ right: new Vector3(1, 0, 0),
31
+ left: new Vector3(-1, 0, 0),
32
+ };
33
+
34
+ /**
35
+ * The tangent of half a vertical field of view of `fovDeg`: how far up the view reaches, per
36
+ * unit of distance from a perspective camera.
37
+ * @param {number} fovDeg
38
+ */
39
+ export const halfFovTangent = (fovDeg) => Math.tan((fovDeg * Math.PI) / 360);
40
+
41
+ /**
42
+ * The sphere a camera must see to show all of `box`: one around a point when the box is
43
+ * empty (no model yet).
44
+ * @param {Box3} box
45
+ * @returns {Sphere}
46
+ */
47
+ export function boundsOf(box) {
48
+ if (box.isEmpty()) return new Sphere(new Vector3(), 50);
49
+ return box.getBoundingSphere(new Sphere());
50
+ }
51
+
52
+ /**
53
+ * How far from the sphere's centre a perspective camera of `fovDeg` (vertical) and `aspect`
54
+ * stands to see all of it, with `margin` to spare.
55
+ * @param {Sphere} sphere
56
+ * @param {number} fovDeg
57
+ * @param {number} aspect width / height
58
+ * @param {number} [margin] a factor over the sphere's radius
59
+ * @returns {number}
60
+ */
61
+ export function perspectiveDistance(sphere, fovDeg, aspect, margin = 1.15) {
62
+ const vertical = (fovDeg * Math.PI) / 360;
63
+ const horizontal = Math.atan(halfFovTangent(fovDeg) * aspect);
64
+ const half = Math.min(vertical, horizontal);
65
+ return (sphere.radius * margin) / Math.sin(half);
66
+ }
67
+
68
+ /**
69
+ * The half-height an orthographic camera shows to see all of the sphere at `aspect`, with
70
+ * `margin` to spare.
71
+ * @param {Sphere} sphere
72
+ * @param {number} aspect
73
+ * @param {number} [margin]
74
+ * @returns {number}
75
+ */
76
+ export function orthographicHalfHeight(sphere, aspect, margin = 1.15) {
77
+ const radius = sphere.radius * margin;
78
+ return aspect >= 1 ? radius : radius / aspect;
79
+ }
80
+
81
+ /**
82
+ * Where a camera standing `offset` from what it looks at stands once turned `across` radians
83
+ * about the Z axis (anticlockwise, seen from the top) and `up` radians toward the top: as far
84
+ * off, short of looking straight down or up.
85
+ * @param {Vector3} offset
86
+ * @param {number} across
87
+ * @param {number} up
88
+ * @returns {Vector3}
89
+ */
90
+ export function orbited(offset, across, up) {
91
+ const radius = offset.length();
92
+ const fromTop = Math.acos(Math.min(1, Math.max(-1, offset.z / radius)));
93
+ const tilted = Math.min(Math.PI - POLE_MARGIN, Math.max(POLE_MARGIN, fromTop - up));
94
+ const around = Math.atan2(offset.y, offset.x) + across;
95
+ return new Vector3(
96
+ Math.sin(tilted) * Math.cos(around),
97
+ Math.sin(tilted) * Math.sin(around),
98
+ Math.cos(tilted),
99
+ ).multiplyScalar(radius);
100
+ }
@@ -0,0 +1,30 @@
1
+ // Freeing the GPU's copies of what the viewer drew: three.js keeps a geometry's buffers and a
2
+ // material's program until they are disposed, however long ago their objects left the scene.
3
+
4
+ import { LineSegments, Mesh } from 'three';
5
+
6
+ /** @import { Object3D } from 'three' */
7
+
8
+ /**
9
+ * Frees the geometries of the meshes and lines under `root`.
10
+ * @param {Object3D} root
11
+ */
12
+ export function disposeGeometries(root) {
13
+ root.traverse((object) => {
14
+ if (object instanceof Mesh || object instanceof LineSegments) object.geometry.dispose();
15
+ });
16
+ }
17
+
18
+ /**
19
+ * Frees the geometries and the materials of the meshes and lines under `root`, which owns them
20
+ * all.
21
+ * @param {Object3D} root
22
+ */
23
+ export function disposeObject(root) {
24
+ disposeGeometries(root);
25
+ root.traverse((object) => {
26
+ if (object instanceof Mesh || object instanceof LineSegments) {
27
+ for (const material of [object.material].flat()) material.dispose();
28
+ }
29
+ });
30
+ }
@@ -0,0 +1,145 @@
1
+ // What a model stands on, on the XY plane at z = 0: a grid sized to the model, a build plate,
2
+ // or nothing; and the axes through the origin, where the coordinates are zero.
3
+
4
+ import {
5
+ BufferGeometry,
6
+ Float32BufferAttribute,
7
+ Group,
8
+ LineBasicMaterial,
9
+ LineSegments,
10
+ Mesh,
11
+ MeshBasicMaterial,
12
+ PlaneGeometry,
13
+ } from 'three';
14
+
15
+ /** @import { Box3 } from 'three' */
16
+ /** @import { Environment, Plate, Theme } from './types.js' */
17
+
18
+ /** A grid has this many cells along its side. */
19
+ const GRID_CELLS = 20;
20
+ /** A plate's grid has a line every this many millimetres. */
21
+ const PLATE_STEP_MM = 10;
22
+ /** The plate's surface sits this far below its lines, so they aren't lost in it. */
23
+ const PLATE_BELOW_MM = 0.01;
24
+ /** A grid's side, with no model to size it to. */
25
+ const DEFAULT_GRID_MM = 100;
26
+ /** The axes are drawn this far above the floor, so its lines through the origin don't hide them. */
27
+ const AXES_LIFT_MM = 0.02;
28
+ /** The Z axis rises this much of the floor's side. */
29
+ const Z_AXIS_SHARE = 0.25;
30
+ /** The axes' colours, as CAD and slicers draw them: X red, Y green, Z blue. */
31
+ const AXIS_COLORS = /** @type {const} */ (['#e5484d', '#30a46c', '#3e63dd']);
32
+
33
+ /**
34
+ * What `kind` stands a model of `box` on, in the theme's colours.
35
+ * @param {Environment} kind
36
+ * @param {Box3} box the model's bounds, empty with no model
37
+ * @param {Plate} plate
38
+ * @param {Theme} theme
39
+ * @returns {Group}
40
+ */
41
+ export function environmentOf(kind, box, plate, theme) {
42
+ const group = new Group();
43
+ if (kind === 'grid') {
44
+ const side = gridSide(box);
45
+ group.add(lines(side, side, side / GRID_CELLS, theme.grid));
46
+ } else if (kind === 'plate') {
47
+ const surface = new Mesh(
48
+ new PlaneGeometry(plate.width, plate.depth),
49
+ new MeshBasicMaterial({ color: theme.plate }),
50
+ );
51
+ surface.position.z = -PLATE_BELOW_MM;
52
+ group.add(surface, lines(plate.width, plate.depth, PLATE_STEP_MM, theme.grid));
53
+ }
54
+ return group;
55
+ }
56
+
57
+ /**
58
+ * The axes through the origin, where the coordinates are zero: X and Y across a floor
59
+ * `side` long, Z up from the origin.
60
+ * @param {Environment} kind
61
+ * @param {Box3} box the model's bounds
62
+ * @param {Plate} plate
63
+ * @returns {Group}
64
+ */
65
+ export function axesOf(kind, box, plate) {
66
+ const side = kind === 'plate' ? Math.max(plate.width, plate.depth) : gridSide(box);
67
+ const half = side / 2;
68
+ const ends = [
69
+ [-half, 0, AXES_LIFT_MM, half, 0, AXES_LIFT_MM],
70
+ [0, -half, AXES_LIFT_MM, 0, half, AXES_LIFT_MM],
71
+ [0, 0, AXES_LIFT_MM, 0, 0, side * Z_AXIS_SHARE],
72
+ ];
73
+ const group = new Group();
74
+ for (const [i, positions] of ends.entries()) {
75
+ const geometry = new BufferGeometry().setAttribute(
76
+ 'position',
77
+ new Float32BufferAttribute(positions, 3),
78
+ );
79
+ group.add(new LineSegments(geometry, new LineBasicMaterial({ color: AXIS_COLORS[i] })));
80
+ }
81
+ return group;
82
+ }
83
+
84
+ /**
85
+ * A grid's side for a model of `box`: a round length (1, 2 or 5 times a power of ten, in mm)
86
+ * past twice its extent across XY and from the origin.
87
+ * @param {Box3} box
88
+ * @returns {number}
89
+ */
90
+ export function gridSide(box) {
91
+ if (box.isEmpty()) return DEFAULT_GRID_MM;
92
+ const reach = Math.max(
93
+ Math.abs(box.min.x),
94
+ Math.abs(box.max.x),
95
+ Math.abs(box.min.y),
96
+ Math.abs(box.max.y),
97
+ );
98
+ const wanted = Math.max(reach * 2, 1);
99
+ const power = 10 ** Math.floor(Math.log10(wanted));
100
+ const step = [1, 2, 5, 10].find((factor) => factor * power >= wanted) ?? 10;
101
+ return step * power;
102
+ }
103
+
104
+ /**
105
+ * Lines every `step` from the origin across a `width` × `depth` rectangle centred on it, and
106
+ * along its edges. They ran from one edge, so a plate whose side isn't a whole number of steps
107
+ * had none through the origin and one past its far edge.
108
+ * @param {number} width
109
+ * @param {number} depth
110
+ * @param {number} step
111
+ * @param {string} color
112
+ * @returns {LineSegments}
113
+ */
114
+ function lines(width, depth, step, color) {
115
+ /** @type {number[]} */
116
+ const positions = [];
117
+ const halfW = width / 2;
118
+ const halfD = depth / 2;
119
+ for (const x of stops(halfW, step)) positions.push(x, -halfD, 0, x, halfD, 0);
120
+ for (const y of stops(halfD, step)) positions.push(-halfW, y, 0, halfW, y, 0);
121
+ const geometry = new BufferGeometry().setAttribute(
122
+ 'position',
123
+ new Float32BufferAttribute(positions, 3),
124
+ );
125
+ return new LineSegments(geometry, new LineBasicMaterial({ color }));
126
+ }
127
+
128
+ /**
129
+ * Where lines across ±`half` go: every whole `step` from 0 inside it (each a multiple, not a
130
+ * sum, so they don't drift), and ±`half`.
131
+ * @param {number} half
132
+ * @param {number} step
133
+ * @returns {number[]}
134
+ */
135
+ export function stops(half, step) {
136
+ // A multiple this near an edge is the edge
137
+ const near = step * 1e-6;
138
+ /** @type {number[]} */
139
+ const inside = [];
140
+ for (let i = Math.ceil(-half / step); i * step <= half; i++) {
141
+ const at = i * step;
142
+ if (Math.abs(Math.abs(at) - half) > near) inside.push(at);
143
+ }
144
+ return [-half, ...inside, half];
145
+ }