@kupcad/ui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The shapes of @kupcad/ui/viewer's API. The model it shows is @kupcad/protocol's.
|
|
2
|
+
|
|
3
|
+
import type { Camera, Color, Group, LineBasicMaterial, LineSegments, Mesh, Object3D } from 'three';
|
|
4
|
+
|
|
5
|
+
/** The viewer's colours, any CSS colour each. */
|
|
6
|
+
export interface Theme {
|
|
7
|
+
background: string;
|
|
8
|
+
grid: string;
|
|
9
|
+
/** The build plate's surface. */
|
|
10
|
+
plate: string;
|
|
11
|
+
/** A solid's feature edges, drawn over its faces. */
|
|
12
|
+
edges: string;
|
|
13
|
+
/** Lines drawn on the background: a wireframe's edges and a drawing's outlines. */
|
|
14
|
+
wire: string;
|
|
15
|
+
/** The selected part's tint and edges. */
|
|
16
|
+
selection: string;
|
|
17
|
+
/** Where a section cuts a solid. */
|
|
18
|
+
cap: string;
|
|
19
|
+
ghost: string;
|
|
20
|
+
/** A solid the script highlights (`.highlight`): seen through whatever hides it too. */
|
|
21
|
+
highlight: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type View = 'iso' | 'top' | 'bottom' | 'front' | 'back' | 'right' | 'left';
|
|
25
|
+
export type Projection = 'perspective' | 'orthographic';
|
|
26
|
+
/** Solids as surfaces with their edges; see-through; their edges alone. */
|
|
27
|
+
export type RenderMode = 'solid' | 'xray' | 'wireframe';
|
|
28
|
+
/** What the model stands on: a grid sized to it, a build plate, or nothing. */
|
|
29
|
+
export type Environment = 'grid' | 'plate' | 'none';
|
|
30
|
+
export type Axis = 'x' | 'y' | 'z';
|
|
31
|
+
|
|
32
|
+
/** A plane across `axis` at `offset` (mm): what is past it is cut away, the far side when `flip`. */
|
|
33
|
+
/**
|
|
34
|
+
* Where the camera stands, as plain numbers (`cameraState()`, `restoreCamera()`): what it looks
|
|
35
|
+
* at, the way from there to the camera, how far, and how high the view is at what it looks at,
|
|
36
|
+
* which either projection sees the same; and its named view, if it is in one.
|
|
37
|
+
*/
|
|
38
|
+
export interface CameraState {
|
|
39
|
+
target: [number, number, number];
|
|
40
|
+
direction: [number, number, number];
|
|
41
|
+
distance: number;
|
|
42
|
+
halfHeight: number;
|
|
43
|
+
view: View | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Section {
|
|
47
|
+
axis: Axis;
|
|
48
|
+
offset: number;
|
|
49
|
+
flip?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A box in the script's coordinates, in mm. */
|
|
53
|
+
export interface Bounds {
|
|
54
|
+
min: [number, number, number];
|
|
55
|
+
max: [number, number, number];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A build plate's size, in mm. */
|
|
59
|
+
export interface Plate {
|
|
60
|
+
width: number;
|
|
61
|
+
depth: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What the viewer draws with: a WebGLRenderer, or a stand-in in tests. */
|
|
65
|
+
export interface RendererLike {
|
|
66
|
+
localClippingEnabled: boolean;
|
|
67
|
+
setPixelRatio(ratio: number): void;
|
|
68
|
+
setSize(width: number, height: number, updateStyle?: boolean): void;
|
|
69
|
+
render(scene: Object3D, camera: Camera): void;
|
|
70
|
+
dispose(): void;
|
|
71
|
+
/** Lets the canvas's WebGL context go at once. */
|
|
72
|
+
forceContextLoss(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ViewerOptions {
|
|
76
|
+
theme?: Theme;
|
|
77
|
+
environment?: Environment;
|
|
78
|
+
/** The build plate's size; by default 256 × 256 mm. */
|
|
79
|
+
plate?: Plate;
|
|
80
|
+
/** Where three.js's Draco decoder files are, for a model built with `--draco`. */
|
|
81
|
+
dracoDecoderPath?: string;
|
|
82
|
+
/** Makes the renderer for the canvas; by default a WebGLRenderer. */
|
|
83
|
+
renderer?: (canvas: HTMLCanvasElement) => RendererLike;
|
|
84
|
+
/**
|
|
85
|
+
* How long the camera takes to move to a view, in ms; 0 moves it at once, as a page that asks
|
|
86
|
+
* for reduced motion does. By default 250.
|
|
87
|
+
*/
|
|
88
|
+
motionMs?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A shape the viewer shows: a solid's copy of a part, a ghost, or a drawing's filled shape. */
|
|
92
|
+
export interface Shape {
|
|
93
|
+
mesh: Mesh;
|
|
94
|
+
/** Its feature edges or outline; none for a ghost. */
|
|
95
|
+
edges: LineSegments | null;
|
|
96
|
+
/** The part it is a copy of (a drawing's shape: its name); null for a ghost or a nameless one. */
|
|
97
|
+
part: string | null;
|
|
98
|
+
ghost: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* A highlighted solid's copy, drawn see-through where something hides the solid; null for
|
|
101
|
+
* any other shape.
|
|
102
|
+
*/
|
|
103
|
+
seenThrough: Mesh | null;
|
|
104
|
+
/** Its own colour and opacity, which the render mode and the selection change from. */
|
|
105
|
+
color: Color;
|
|
106
|
+
opacity: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A face of a solid, as its feature edges bound it: flat or curved, its area (mm²), and a flat
|
|
111
|
+
* one's way (a unit vector) and the axis it faces along, if one ('+Z (top)').
|
|
112
|
+
*/
|
|
113
|
+
export interface Face {
|
|
114
|
+
flat: boolean;
|
|
115
|
+
area: number;
|
|
116
|
+
normal: [number, number, number] | null;
|
|
117
|
+
facing: string | null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What is under a point of the viewer: the part (null for a nameless one) and, on a solid, the face. */
|
|
121
|
+
export interface Hover {
|
|
122
|
+
part: string | null;
|
|
123
|
+
face: Face | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A 'hover' event's detail: what the pointer is over, and where (CSS pixels); null for nothing. */
|
|
127
|
+
export type HoverDetail = (Hover & { x: number; y: number }) | null;
|
|
128
|
+
|
|
129
|
+
/** A model made into a scene, in the script's coordinates. */
|
|
130
|
+
export interface SceneModel {
|
|
131
|
+
kind: '3d' | '2d';
|
|
132
|
+
root: Group;
|
|
133
|
+
shapes: Shape[];
|
|
134
|
+
/** The edges' material, shared by every shape but the selected ones. */
|
|
135
|
+
edgeMaterial: LineBasicMaterial;
|
|
136
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// The view cube: a small cube in a corner, turned as the viewer's camera is, whose faces, edges and
|
|
2
|
+
// corners (26 parts, each a way to look from) take the camera there when clicked; the one under
|
|
3
|
+
// the pointer lights up. Its faces are labelled as the views are named (TOP, FRONT, RIGHT, ...),
|
|
4
|
+
// each reading upright from its own view. It draws on a canvas of its own, over the viewer's.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
AmbientLight,
|
|
8
|
+
BoxGeometry,
|
|
9
|
+
CanvasTexture,
|
|
10
|
+
Color,
|
|
11
|
+
DirectionalLight,
|
|
12
|
+
EdgesGeometry,
|
|
13
|
+
Group,
|
|
14
|
+
LineBasicMaterial,
|
|
15
|
+
LineSegments,
|
|
16
|
+
Matrix4,
|
|
17
|
+
Mesh,
|
|
18
|
+
MeshBasicMaterial,
|
|
19
|
+
MeshLambertMaterial,
|
|
20
|
+
OrthographicCamera,
|
|
21
|
+
PlaneGeometry,
|
|
22
|
+
Raycaster,
|
|
23
|
+
Scene,
|
|
24
|
+
SRGBColorSpace,
|
|
25
|
+
Vector2,
|
|
26
|
+
Vector3,
|
|
27
|
+
WebGLRenderer,
|
|
28
|
+
} from 'three';
|
|
29
|
+
import { disposeObject } from './dispose.js';
|
|
30
|
+
import { LIGHT } from './theme.js';
|
|
31
|
+
|
|
32
|
+
/** @import { Quaternion } from 'three' */
|
|
33
|
+
/** @import { RendererLike, Theme } from './types.js' */
|
|
34
|
+
/** @import { Viewer } from './viewer.js' */
|
|
35
|
+
|
|
36
|
+
/** How thick an edge's or a corner's part is, of the cube's side (1). */
|
|
37
|
+
const BAND = 0.22;
|
|
38
|
+
/** The cube's camera stands this far off, and shows this much around it. */
|
|
39
|
+
const CAMERA_DISTANCE = 3;
|
|
40
|
+
const HALF_VIEW = 0.95;
|
|
41
|
+
/** A label sits this far out of its face, so it is drawn over it, across this much of it. */
|
|
42
|
+
const LABEL_LIFT = 0.002;
|
|
43
|
+
const LABEL_SIZE = 0.9;
|
|
44
|
+
const LABEL_PX = 128;
|
|
45
|
+
/** A pointer that moves less than this between down and up clicked. */
|
|
46
|
+
const CLICK_SLOP_PX = 4;
|
|
47
|
+
/**
|
|
48
|
+
* Each face's label, the way it faces and its text's up (Z up, as the scene is): the screen's up
|
|
49
|
+
* in its view (from the top, leaning toward the front, +Y; from the bottom, -Y).
|
|
50
|
+
*/
|
|
51
|
+
const LABELS = /** @type {const} */ ([
|
|
52
|
+
{ text: 'TOP', normal: [0, 0, 1], up: [0, 1, 0] },
|
|
53
|
+
{ text: 'BOTTOM', normal: [0, 0, -1], up: [0, -1, 0] },
|
|
54
|
+
{ text: 'FRONT', normal: [0, -1, 0], up: [0, 0, 1] },
|
|
55
|
+
{ text: 'BACK', normal: [0, 1, 0], up: [0, 0, 1] },
|
|
56
|
+
{ text: 'RIGHT', normal: [1, 0, 0], up: [0, 0, 1] },
|
|
57
|
+
{ text: 'LEFT', normal: [-1, 0, 0], up: [0, 0, 1] },
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @typedef {object} ViewCubeOptions
|
|
62
|
+
* @property {Theme} [theme]
|
|
63
|
+
* @property {(canvas: HTMLCanvasElement) => RendererLike} [renderer] by default a WebGLRenderer
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {HTMLCanvasElement} canvas
|
|
68
|
+
* @returns {RendererLike}
|
|
69
|
+
*/
|
|
70
|
+
const webGLRenderer = (canvas) => new WebGLRenderer({ canvas, antialias: true, alpha: true });
|
|
71
|
+
|
|
72
|
+
export class ViewCube {
|
|
73
|
+
/** @type {HTMLCanvasElement} */
|
|
74
|
+
#canvas;
|
|
75
|
+
/** @type {Viewer} */
|
|
76
|
+
#viewer;
|
|
77
|
+
/** @type {RendererLike} */
|
|
78
|
+
#renderer;
|
|
79
|
+
#scene = new Scene();
|
|
80
|
+
#camera = new OrthographicCamera(-HALF_VIEW, HALF_VIEW, HALF_VIEW, -HALF_VIEW, 0.1, 10);
|
|
81
|
+
#cube = new Group();
|
|
82
|
+
/** Its parts, each with the way it looks from. @type {Mesh[]} */
|
|
83
|
+
#parts = [];
|
|
84
|
+
#outline = new LineBasicMaterial();
|
|
85
|
+
#raycaster = new Raycaster();
|
|
86
|
+
/** @type {Mesh | null} */
|
|
87
|
+
#hovered = null;
|
|
88
|
+
/** @type {Theme} */
|
|
89
|
+
#theme;
|
|
90
|
+
/** @type {{ x: number, y: number } | null} */
|
|
91
|
+
#down = null;
|
|
92
|
+
#listeners = new AbortController();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {HTMLCanvasElement} canvas
|
|
96
|
+
* @param {Viewer} viewer the camera it follows and moves
|
|
97
|
+
* @param {ViewCubeOptions} [options]
|
|
98
|
+
*/
|
|
99
|
+
constructor(canvas, viewer, options = {}) {
|
|
100
|
+
this.#canvas = canvas;
|
|
101
|
+
this.#viewer = viewer;
|
|
102
|
+
this.#theme = options.theme ?? LIGHT;
|
|
103
|
+
this.#renderer = (options.renderer ?? webGLRenderer)(canvas);
|
|
104
|
+
this.#renderer.setPixelRatio(globalThis.devicePixelRatio ?? 1);
|
|
105
|
+
this.#camera.up.set(0, 0, 1);
|
|
106
|
+
const headlight = new DirectionalLight(0xffffff, 1.2);
|
|
107
|
+
this.#camera.add(headlight);
|
|
108
|
+
this.#scene.add(this.#cube, this.#camera, new AmbientLight(0xffffff, 1.6));
|
|
109
|
+
this.#build();
|
|
110
|
+
const { signal } = this.#listeners;
|
|
111
|
+
viewer.addEventListener('camera', () => this.#render(), { signal });
|
|
112
|
+
canvas.addEventListener('pointermove', (event) => this.#hover(event), { signal });
|
|
113
|
+
canvas.addEventListener('pointerleave', () => this.#light(null), { signal });
|
|
114
|
+
canvas.addEventListener('pointerdown', (event) => this.#pointerDown(event), { signal });
|
|
115
|
+
canvas.addEventListener('pointerup', (event) => this.#pointerUp(event), { signal });
|
|
116
|
+
this.setTheme(this.#theme);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The part under a point of the canvas (CSS pixels from its top left): the way it looks from,
|
|
121
|
+
* or null.
|
|
122
|
+
* @param {number} x
|
|
123
|
+
* @param {number} y
|
|
124
|
+
* @returns {[number, number, number] | null}
|
|
125
|
+
*/
|
|
126
|
+
directionAt(x, y) {
|
|
127
|
+
const part = this.#partAt(x, y);
|
|
128
|
+
return part ? /** @type {[number, number, number]} */ (part.userData.direction) : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {Theme} theme */
|
|
132
|
+
setTheme(theme) {
|
|
133
|
+
this.#theme = theme;
|
|
134
|
+
const face = new Color(theme.plate);
|
|
135
|
+
const ink = new Color(face.getHSL({ h: 0, s: 0, l: 0 }).l > 0.5 ? '#1f2328' : '#e6e7e9');
|
|
136
|
+
this.#outline.color.copy(ink);
|
|
137
|
+
this.#paintLabels(ink);
|
|
138
|
+
this.#light(this.#hovered);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Fits the drawing to the canvas's size, in CSS pixels. */
|
|
142
|
+
resize(width = this.#canvas.clientWidth, height = this.#canvas.clientHeight) {
|
|
143
|
+
this.#renderer.setSize(Math.max(1, width), Math.max(1, height), false);
|
|
144
|
+
this.#render();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
dispose() {
|
|
148
|
+
this.#listeners.abort();
|
|
149
|
+
this.#removeLabels();
|
|
150
|
+
disposeObject(this.#cube);
|
|
151
|
+
this.#renderer.dispose();
|
|
152
|
+
// Its WebGL context let go now, as the viewer's is
|
|
153
|
+
this.#renderer.forceContextLoss();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Makes the 26 parts, the outline and the labels. */
|
|
157
|
+
#build() {
|
|
158
|
+
for (const x of [-1, 0, 1]) {
|
|
159
|
+
for (const y of [-1, 0, 1]) {
|
|
160
|
+
for (const z of [-1, 0, 1]) {
|
|
161
|
+
if (x === 0 && y === 0 && z === 0) continue;
|
|
162
|
+
const part = new Mesh(
|
|
163
|
+
new BoxGeometry(partExtent(x), partExtent(y), partExtent(z)),
|
|
164
|
+
new MeshLambertMaterial(),
|
|
165
|
+
);
|
|
166
|
+
part.position.set(partOffset(x), partOffset(y), partOffset(z));
|
|
167
|
+
part.userData.direction = [x, y, z];
|
|
168
|
+
this.#parts.push(part);
|
|
169
|
+
this.#cube.add(part);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
this.#cube.add(new LineSegments(new EdgesGeometry(new BoxGeometry(1, 1, 1)), this.#outline));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Labels each face in `ink`, upright as its view sees it: none where the page has no 2D
|
|
178
|
+
* context to draw the text with (jsdom).
|
|
179
|
+
* @param {Color} ink
|
|
180
|
+
*/
|
|
181
|
+
#paintLabels(ink) {
|
|
182
|
+
this.#removeLabels();
|
|
183
|
+
for (const { text, normal, up } of LABELS) {
|
|
184
|
+
const texture = labelTexture(text, ink);
|
|
185
|
+
if (!texture) return;
|
|
186
|
+
const plane = new Mesh(
|
|
187
|
+
new PlaneGeometry(LABEL_SIZE, LABEL_SIZE),
|
|
188
|
+
new MeshBasicMaterial({ map: texture, transparent: true }),
|
|
189
|
+
);
|
|
190
|
+
const n = new Vector3(...normal);
|
|
191
|
+
const u = new Vector3(...up);
|
|
192
|
+
plane.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(u.clone().cross(n), u, n));
|
|
193
|
+
plane.position.copy(n).multiplyScalar(0.5 + LABEL_LIFT);
|
|
194
|
+
plane.userData.label = true;
|
|
195
|
+
// The part under it is what a pointer finds
|
|
196
|
+
plane.raycast = () => {};
|
|
197
|
+
this.#cube.add(plane);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Takes the labels away, with their textures. */
|
|
202
|
+
#removeLabels() {
|
|
203
|
+
for (const label of this.#cube.children.filter((child) => child.userData.label)) {
|
|
204
|
+
label.removeFromParent();
|
|
205
|
+
/** @type {MeshBasicMaterial} */ (/** @type {Mesh} */ (label).material).map?.dispose();
|
|
206
|
+
disposeObject(label);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Lights up `part` (none: null) in the selection's colour, the rest as faces are.
|
|
212
|
+
* @param {Mesh | null} part
|
|
213
|
+
*/
|
|
214
|
+
#light(part) {
|
|
215
|
+
this.#hovered = part;
|
|
216
|
+
const face = new Color(this.#theme.plate);
|
|
217
|
+
const lit = new Color(this.#theme.selection);
|
|
218
|
+
for (const each of this.#parts) {
|
|
219
|
+
/** @type {MeshLambertMaterial} */ (each.material).color.copy(each === part ? lit : face);
|
|
220
|
+
}
|
|
221
|
+
this.#canvas.style.cursor = part ? 'pointer' : '';
|
|
222
|
+
this.#render();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** @param {PointerEvent} event */
|
|
226
|
+
#hover(event) {
|
|
227
|
+
const part = this.#partAt(event.offsetX, event.offsetY);
|
|
228
|
+
if (part !== this.#hovered) this.#light(part);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** @param {PointerEvent} event */
|
|
232
|
+
#pointerDown(event) {
|
|
233
|
+
this.#down =
|
|
234
|
+
event.isPrimary && event.button === 0 ? { x: event.offsetX, y: event.offsetY } : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** @param {PointerEvent} event */
|
|
238
|
+
#pointerUp(event) {
|
|
239
|
+
const down = this.#down;
|
|
240
|
+
this.#down = null;
|
|
241
|
+
if (!down || Math.hypot(event.offsetX - down.x, event.offsetY - down.y) > CLICK_SLOP_PX) return;
|
|
242
|
+
const direction = this.directionAt(event.offsetX, event.offsetY);
|
|
243
|
+
if (direction) this.#viewer.lookFrom(direction);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {number} x
|
|
248
|
+
* @param {number} y
|
|
249
|
+
* @returns {Mesh | null}
|
|
250
|
+
*/
|
|
251
|
+
#partAt(x, y) {
|
|
252
|
+
const width = this.#canvas.clientWidth;
|
|
253
|
+
const height = this.#canvas.clientHeight;
|
|
254
|
+
if (width === 0 || height === 0) return null;
|
|
255
|
+
this.#follow(this.#viewer.orientation);
|
|
256
|
+
this.#camera.updateMatrixWorld();
|
|
257
|
+
this.#raycaster.setFromCamera(
|
|
258
|
+
new Vector2((x / width) * 2 - 1, 1 - (y / height) * 2),
|
|
259
|
+
this.#camera,
|
|
260
|
+
);
|
|
261
|
+
const [hit] = this.#raycaster.intersectObjects(this.#parts, false);
|
|
262
|
+
return hit ? /** @type {Mesh} */ (hit.object) : null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Turns the cube's camera as the viewer's is: it looks at the cube from where the viewer's
|
|
267
|
+
* camera looks at the model.
|
|
268
|
+
* @param {Quaternion} orientation
|
|
269
|
+
*/
|
|
270
|
+
#follow(orientation) {
|
|
271
|
+
this.#camera.quaternion.copy(orientation);
|
|
272
|
+
this.#camera.position.set(0, 0, CAMERA_DISTANCE).applyQuaternion(orientation);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#render() {
|
|
276
|
+
this.#follow(this.#viewer.orientation);
|
|
277
|
+
this.#renderer.render(this.#scene, this.#camera);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* How long a part is along an axis its way (`d`, -1, 0 or 1) leans along: a band at the cube's
|
|
283
|
+
* side, else the middle between the bands.
|
|
284
|
+
* @param {number} d
|
|
285
|
+
*/
|
|
286
|
+
const partExtent = (d) => (d === 0 ? 1 - 2 * BAND : BAND);
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Where a part's middle is along an axis its way (`d`) leans along.
|
|
290
|
+
* @param {number} d
|
|
291
|
+
*/
|
|
292
|
+
const partOffset = (d) => d * (0.5 - BAND / 2);
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A face's label: `text` in `ink` on a clear square, or null where the page has no 2D context.
|
|
296
|
+
* @param {string} text
|
|
297
|
+
* @param {Color} ink
|
|
298
|
+
* @returns {CanvasTexture | null}
|
|
299
|
+
*/
|
|
300
|
+
function labelTexture(text, ink) {
|
|
301
|
+
const canvas = document.createElement('canvas');
|
|
302
|
+
canvas.width = LABEL_PX;
|
|
303
|
+
canvas.height = LABEL_PX;
|
|
304
|
+
const context = canvas.getContext('2d');
|
|
305
|
+
if (!context) return null;
|
|
306
|
+
context.fillStyle = `#${ink.getHexString()}`;
|
|
307
|
+
context.font = `700 ${Math.round(LABEL_PX / 4.2)}px system-ui, sans-serif`;
|
|
308
|
+
context.textAlign = 'center';
|
|
309
|
+
context.textBaseline = 'middle';
|
|
310
|
+
context.fillText(text, LABEL_PX / 2, LABEL_PX / 2, LABEL_PX * 0.92);
|
|
311
|
+
const texture = new CanvasTexture(canvas);
|
|
312
|
+
texture.colorSpace = SRGBColorSpace;
|
|
313
|
+
return texture;
|
|
314
|
+
}
|