@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.
- package/LICENSE +661 -0
- package/README.md +281 -2
- package/package.json +65 -3
- package/src/ConsolePanel.svelte +139 -0
- package/src/ExportMenu.svelte +36 -0
- package/src/ParamField.svelte +390 -0
- package/src/ParamsPanel.svelte +216 -0
- package/src/PreviewPanel.svelte +372 -0
- package/src/ShortcutsDialog.svelte +170 -0
- package/src/Splitter.svelte +148 -0
- package/src/Viewer.svelte +175 -0
- package/src/ViewerPane.svelte +745 -0
- package/src/base64.js +16 -0
- package/src/console.js +78 -0
- package/src/controls.css +183 -0
- package/src/index.js +40 -0
- package/src/measures.js +52 -0
- package/src/params.js +348 -0
- package/src/range.js +19 -0
- package/src/recent.js +44 -0
- package/src/run-status.js +78 -0
- package/src/settings.js +82 -0
- package/src/shortcuts.js +59 -0
- package/src/theme.js +131 -0
- package/src/tokens.css +70 -0
- package/src/values.svelte.js +13 -0
- package/src/view-settings.js +81 -0
- package/src/viewer/appearance.js +90 -0
- package/src/viewer/camera.js +100 -0
- package/src/viewer/dispose.js +30 -0
- package/src/viewer/environment.js +145 -0
- package/src/viewer/faces.js +311 -0
- package/src/viewer/hover-mark.js +125 -0
- package/src/viewer/index.js +7 -0
- package/src/viewer/model.js +293 -0
- package/src/viewer/motion.js +96 -0
- package/src/viewer/section.js +166 -0
- package/src/viewer/testing.js +90 -0
- package/src/viewer/theme.js +32 -0
- package/src/viewer/types.d.ts +136 -0
- package/src/viewer/view-cube.js +314 -0
- package/src/viewer/viewer.js +1027 -0
- package/src/viewer/wheel.js +55 -0
- package/src/viewport-socket.js +64 -0
- 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
|
+
}
|