@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
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// A model the core made (@kupcad/protocol's Model) as a scene. A GLB's Solids are put back in
|
|
2
|
+
// the script's coordinates: the core writes them in metres and Y up under one root node, whose
|
|
3
|
+
// transform this undoes, so the scene is in millimetres and Z up. Each mesh is known by the part
|
|
4
|
+
// its node is a copy of (the frame's `parts[].nodes`), and gets its feature edges; a ghost is seen
|
|
5
|
+
// through, and a highlighted solid drawn in the highlight's colour, seen through what hides it
|
|
6
|
+
// too. A drawing's SVG becomes its filled shapes and outlines on the XY plane.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
DoubleSide,
|
|
10
|
+
EdgesGeometry,
|
|
11
|
+
Float32BufferAttribute,
|
|
12
|
+
BufferGeometry,
|
|
13
|
+
GreaterDepth,
|
|
14
|
+
Group,
|
|
15
|
+
LineBasicMaterial,
|
|
16
|
+
LineSegments,
|
|
17
|
+
Matrix4,
|
|
18
|
+
Mesh,
|
|
19
|
+
MeshBasicMaterial,
|
|
20
|
+
ShapeGeometry,
|
|
21
|
+
} from 'three';
|
|
22
|
+
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js';
|
|
23
|
+
import { disposeGeometries } from './dispose.js';
|
|
24
|
+
|
|
25
|
+
/** @import { Material, Object3D } from 'three' */
|
|
26
|
+
/** @import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js' */
|
|
27
|
+
/** @import { Model, Part } from '@kupcad/protocol/types' */
|
|
28
|
+
/** @import { SceneModel, Shape, Theme } from './types.js' */
|
|
29
|
+
|
|
30
|
+
/** Where a solid's faces meet at more than this many degrees, an edge is drawn: its features. */
|
|
31
|
+
export const EDGE_ANGLE_DEG = 20;
|
|
32
|
+
/** A ghost is seen through. */
|
|
33
|
+
export const GHOST_OPACITY = 0.2;
|
|
34
|
+
/** How much of a highlighted solid shows where something hides it. */
|
|
35
|
+
export const SEEN_THROUGH_OPACITY = 0.4;
|
|
36
|
+
/**
|
|
37
|
+
* A shape's faces are drawn this far behind its edges (in depth, as a factor of their slope
|
|
38
|
+
* and in its smallest steps), so the edges, at the same depth, aren't lost in the faces.
|
|
39
|
+
*/
|
|
40
|
+
const FACES_BEHIND_EDGES = { polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 };
|
|
41
|
+
/** The extension a GLB built with `--draco` uses. */
|
|
42
|
+
const DRACO_EXTENSION = 'KHR_draco_mesh_compression';
|
|
43
|
+
/** A GLB's header (magic, version, length), then its JSON chunk's length and type. */
|
|
44
|
+
const GLB_JSON_START = 20;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The scene of `model`; null for a model of nothing.
|
|
48
|
+
* @param {Model} model
|
|
49
|
+
* @param {GLTFLoader} loader
|
|
50
|
+
* @param {Theme} theme
|
|
51
|
+
* @returns {Promise<SceneModel | null>}
|
|
52
|
+
*/
|
|
53
|
+
export async function sceneOf(model, loader, theme) {
|
|
54
|
+
if (model.kind === '3d') return solids(model.glb, model.parts, loader, theme);
|
|
55
|
+
if (model.kind === '2d') return drawing(model.svg, theme);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether a GLB's meshes are Draco-compressed (its JSON lists the extension as used).
|
|
61
|
+
* @param {Uint8Array} glb
|
|
62
|
+
*/
|
|
63
|
+
export function isDracoCompressed(glb) {
|
|
64
|
+
const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength);
|
|
65
|
+
const jsonLength = view.getUint32(12, true);
|
|
66
|
+
const json = new TextDecoder().decode(glb.subarray(GLB_JSON_START, GLB_JSON_START + jsonLength));
|
|
67
|
+
return (
|
|
68
|
+
/** @type {{ extensionsUsed?: string[] }} */ (JSON.parse(json)).extensionsUsed?.includes(
|
|
69
|
+
DRACO_EXTENSION,
|
|
70
|
+
) ?? false
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {Uint8Array} glb
|
|
76
|
+
* @param {Part[]} parts
|
|
77
|
+
* @param {GLTFLoader} loader
|
|
78
|
+
* @param {Theme} theme
|
|
79
|
+
* @returns {Promise<SceneModel>}
|
|
80
|
+
*/
|
|
81
|
+
async function solids(glb, parts, loader, theme) {
|
|
82
|
+
// The loader takes a whole ArrayBuffer: the frame's GLB is a view on the frame
|
|
83
|
+
const gltf = await loader.parseAsync(/** @type {ArrayBuffer} */ (glb.slice().buffer), '');
|
|
84
|
+
const scene = gltf.scene;
|
|
85
|
+
if (scene.children.length === 1) {
|
|
86
|
+
const root = scene.children[0];
|
|
87
|
+
root.updateMatrix();
|
|
88
|
+
scene.applyMatrix4(new Matrix4().copy(root.matrix).invert());
|
|
89
|
+
}
|
|
90
|
+
const partOf = new Map(parts.flatMap((part) => part.nodes.map((node) => [node, part.name])));
|
|
91
|
+
// The loader sanitizes the objects' names ("Lid v2.0" is "Lid_v20"): the GLB's are the parts'
|
|
92
|
+
const nodes = /** @type {{ name?: string }[]} */ (gltf.parser.json.nodes ?? []);
|
|
93
|
+
/** @param {Object3D} object */
|
|
94
|
+
const nodeName = (object) => {
|
|
95
|
+
const index = gltf.parser.associations.get(object)?.nodes;
|
|
96
|
+
return index === undefined ? undefined : nodes[index]?.name;
|
|
97
|
+
};
|
|
98
|
+
const edgeMaterial = new LineBasicMaterial({ color: theme.edges });
|
|
99
|
+
/** @type {Shape[]} */
|
|
100
|
+
const shapes = [];
|
|
101
|
+
scene.traverse((object) => {
|
|
102
|
+
if (object instanceof Mesh) {
|
|
103
|
+
shapes.push(solidShape(object, partOf, nodeName, edgeMaterial, theme));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
const root = new Group();
|
|
107
|
+
root.add(scene);
|
|
108
|
+
return { kind: '3d', root, shapes, edgeMaterial };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A solid's mesh as a shape: its own material (so tinting one part tints no other), its part,
|
|
113
|
+
* and its edges; a ghost seen through, in the theme's colour; a highlighted one (the core tags
|
|
114
|
+
* its material) in the highlight's, seen through what hides it.
|
|
115
|
+
* @param {Mesh} mesh
|
|
116
|
+
* @param {Map<string, string>} partOf
|
|
117
|
+
* @param {(object: Object3D) => string | undefined} nodeName
|
|
118
|
+
* @param {LineBasicMaterial} edgeMaterial
|
|
119
|
+
* @param {Theme} theme
|
|
120
|
+
* @returns {Shape}
|
|
121
|
+
*/
|
|
122
|
+
function solidShape(mesh, partOf, nodeName, edgeMaterial, theme) {
|
|
123
|
+
const material = /** @type {import('three').MeshStandardMaterial} */ (
|
|
124
|
+
/** @type {Material} */ (mesh.material).clone()
|
|
125
|
+
);
|
|
126
|
+
mesh.material = material;
|
|
127
|
+
const role = material.userData.kupcad_role;
|
|
128
|
+
const ghost = role === 'ghost';
|
|
129
|
+
if (ghost) {
|
|
130
|
+
material.color.set(theme.ghost);
|
|
131
|
+
material.transparent = true;
|
|
132
|
+
material.opacity = GHOST_OPACITY;
|
|
133
|
+
material.depthWrite = false;
|
|
134
|
+
return {
|
|
135
|
+
mesh,
|
|
136
|
+
edges: null,
|
|
137
|
+
part: null,
|
|
138
|
+
ghost,
|
|
139
|
+
seenThrough: null,
|
|
140
|
+
color: material.color.clone(),
|
|
141
|
+
opacity: GHOST_OPACITY,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const highlighted = role === 'highlight';
|
|
145
|
+
if (highlighted) material.color.set(theme.highlight);
|
|
146
|
+
Object.assign(material, FACES_BEHIND_EDGES);
|
|
147
|
+
const edges = new LineSegments(new EdgesGeometry(mesh.geometry, EDGE_ANGLE_DEG), edgeMaterial);
|
|
148
|
+
mesh.add(edges);
|
|
149
|
+
return {
|
|
150
|
+
mesh,
|
|
151
|
+
edges,
|
|
152
|
+
part: partAbove(mesh, partOf, nodeName),
|
|
153
|
+
ghost,
|
|
154
|
+
seenThrough: highlighted ? seenThroughOf(mesh, theme.highlight) : null,
|
|
155
|
+
color: material.color.clone(),
|
|
156
|
+
opacity: material.opacity,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A copy of a highlighted solid's mesh, under it, drawn where something hides the solid (its
|
|
162
|
+
* depth behind what is drawn there; never over the solid's own faces, which lie behind it by
|
|
163
|
+
* their offset), see-through in `color`: the highlight seen through whatever is in front.
|
|
164
|
+
* @param {Mesh} mesh
|
|
165
|
+
* @param {string} color
|
|
166
|
+
* @returns {Mesh}
|
|
167
|
+
*/
|
|
168
|
+
function seenThroughOf(mesh, color) {
|
|
169
|
+
const copy = new Mesh(
|
|
170
|
+
mesh.geometry,
|
|
171
|
+
new MeshBasicMaterial({
|
|
172
|
+
color,
|
|
173
|
+
transparent: true,
|
|
174
|
+
opacity: SEEN_THROUGH_OPACITY,
|
|
175
|
+
depthFunc: GreaterDepth,
|
|
176
|
+
depthWrite: false,
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
mesh.add(copy);
|
|
180
|
+
return copy;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The part of the nearest node above `object` (itself first) that is a copy of one.
|
|
185
|
+
* @param {Object3D} object
|
|
186
|
+
* @param {Map<string, string>} partOf
|
|
187
|
+
* @param {(object: Object3D) => string | undefined} nodeName the GLB's name of an object's node
|
|
188
|
+
* @returns {string | null}
|
|
189
|
+
*/
|
|
190
|
+
function partAbove(object, partOf, nodeName) {
|
|
191
|
+
for (let at = /** @type {Object3D | null} */ (object); at; at = at.parent) {
|
|
192
|
+
const name = nodeName(at);
|
|
193
|
+
const part = name === undefined ? undefined : partOf.get(name);
|
|
194
|
+
if (part !== undefined) return part;
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A drawing (export_svg's) on the XY plane: each filled path a shape named by its id, with its
|
|
201
|
+
* outline; an open path its outline alone. The core negated every y, SVG's running down the
|
|
202
|
+
* page; the group turns it back.
|
|
203
|
+
* @param {string} svg
|
|
204
|
+
* @param {Theme} theme
|
|
205
|
+
* @returns {SceneModel}
|
|
206
|
+
*/
|
|
207
|
+
function drawing(svg, theme) {
|
|
208
|
+
const root = new Group();
|
|
209
|
+
root.scale.y = -1;
|
|
210
|
+
// Outlines on the background
|
|
211
|
+
const edgeMaterial = new LineBasicMaterial({ color: theme.wire });
|
|
212
|
+
/** @type {Shape[]} */
|
|
213
|
+
const shapes = [];
|
|
214
|
+
for (const path of new SVGLoader().parse(svg).paths) {
|
|
215
|
+
/** @type {{ fill?: string, fillOpacity?: number }} */
|
|
216
|
+
const style = path.userData?.style ?? {};
|
|
217
|
+
const filled = style.fill !== undefined && style.fill !== 'none';
|
|
218
|
+
const edges = new LineSegments(outlineOf(path, filled), edgeMaterial);
|
|
219
|
+
if (!filled) {
|
|
220
|
+
root.add(edges);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const opacity = style.fillOpacity ?? 1;
|
|
224
|
+
const material = new MeshBasicMaterial({
|
|
225
|
+
color: path.color,
|
|
226
|
+
side: DoubleSide,
|
|
227
|
+
transparent: opacity < 1,
|
|
228
|
+
opacity,
|
|
229
|
+
...FACES_BEHIND_EDGES,
|
|
230
|
+
});
|
|
231
|
+
const mesh = new Mesh(new ShapeGeometry(path.toShapes()), material);
|
|
232
|
+
mesh.add(edges);
|
|
233
|
+
root.add(mesh);
|
|
234
|
+
const id = /** @type {Element | undefined} */ (path.userData?.node)?.getAttribute('id');
|
|
235
|
+
shapes.push({
|
|
236
|
+
mesh,
|
|
237
|
+
edges,
|
|
238
|
+
part: id || null,
|
|
239
|
+
ghost: false,
|
|
240
|
+
seenThrough: null,
|
|
241
|
+
color: material.color.clone(),
|
|
242
|
+
opacity,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return { kind: '2d', root, shapes, edgeMaterial };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A path's outline as line segments, each of its subpaths closed when the path is filled.
|
|
250
|
+
* @param {import('three').ShapePath} path
|
|
251
|
+
* @param {boolean} closed
|
|
252
|
+
* @returns {BufferGeometry}
|
|
253
|
+
*/
|
|
254
|
+
function outlineOf(path, closed) {
|
|
255
|
+
/** @type {number[]} */
|
|
256
|
+
const positions = [];
|
|
257
|
+
for (const subPath of path.subPaths) {
|
|
258
|
+
const points = subPath.getPoints();
|
|
259
|
+
const count = closed ? points.length : points.length - 1;
|
|
260
|
+
for (let i = 0; i < count; i++) {
|
|
261
|
+
const a = points[i];
|
|
262
|
+
const b = points[(i + 1) % points.length];
|
|
263
|
+
positions.push(a.x, a.y, 0, b.x, b.y, 0);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return new BufferGeometry().setAttribute('position', new Float32BufferAttribute(positions, 3));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The materials the model's scene owns: each shape's, a highlight's copy's, and its edges'. A
|
|
271
|
+
* selected shape's edges are drawn with the viewer's material, which isn't the model's to free.
|
|
272
|
+
* @param {SceneModel} model
|
|
273
|
+
* @returns {Set<Material>}
|
|
274
|
+
*/
|
|
275
|
+
export function ownMaterials(model) {
|
|
276
|
+
return new Set([
|
|
277
|
+
model.edgeMaterial,
|
|
278
|
+
...model.shapes.flatMap((shape) =>
|
|
279
|
+
[shape.mesh, shape.seenThrough].flatMap((mesh) =>
|
|
280
|
+
mesh ? [/** @type {Material} */ (mesh.material)] : [],
|
|
281
|
+
),
|
|
282
|
+
),
|
|
283
|
+
]);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Frees the GPU's copies of a scene's geometries and materials.
|
|
288
|
+
* @param {SceneModel} model
|
|
289
|
+
*/
|
|
290
|
+
export function disposeScene(model) {
|
|
291
|
+
disposeGeometries(model.root);
|
|
292
|
+
for (const material of ownMaterials(model)) material.dispose();
|
|
293
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The camera's moves: where it stands (a pose) and how it gets from one pose to the next, eased
|
|
2
|
+
// over a moment. Its direction from what it looks at turns on the way, so a move from the top to
|
|
3
|
+
// the bottom goes round the model, not through it; its distance, what it looks at and how much
|
|
4
|
+
// an orthographic camera shows ease along. Plain math over three.js's vectors.
|
|
5
|
+
|
|
6
|
+
import { Quaternion, Vector3 } from 'three';
|
|
7
|
+
|
|
8
|
+
/** How long a move takes, by default. */
|
|
9
|
+
export const MOTION_MS = 250;
|
|
10
|
+
/** Two directions this close (the cosine of their angle) are one: a named view's, say. */
|
|
11
|
+
const SAME_DIRECTION = 1 - 1e-6;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where a camera stands: what it looks at, from which way (a unit vector from the target toward
|
|
15
|
+
* the camera) and how far, and the half-height an orthographic camera shows (at zoom 1).
|
|
16
|
+
* @typedef {object} Pose
|
|
17
|
+
* @property {Vector3} target
|
|
18
|
+
* @property {Vector3} direction
|
|
19
|
+
* @property {number} distance
|
|
20
|
+
* @property {number} halfHeight
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether two unit directions are one.
|
|
25
|
+
* @param {Vector3} a
|
|
26
|
+
* @param {Vector3} b
|
|
27
|
+
*/
|
|
28
|
+
export const sameDirection = (a, b) => a.dot(b) >= SAME_DIRECTION;
|
|
29
|
+
|
|
30
|
+
/** A move from one pose to another, eased in and out. */
|
|
31
|
+
export class CameraMove {
|
|
32
|
+
/** @type {Pose} */
|
|
33
|
+
#from;
|
|
34
|
+
/** @type {Pose} */
|
|
35
|
+
#to;
|
|
36
|
+
/** The turn from the first direction to the last. */
|
|
37
|
+
#turn;
|
|
38
|
+
#start;
|
|
39
|
+
#ms;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {Pose} from
|
|
43
|
+
* @param {Pose} to
|
|
44
|
+
* @param {number} start when it starts, in ms (`performance.now()`'s)
|
|
45
|
+
* @param {number} ms how long it takes
|
|
46
|
+
*/
|
|
47
|
+
constructor(from, to, start, ms) {
|
|
48
|
+
this.#from = from;
|
|
49
|
+
this.#to = to;
|
|
50
|
+
this.#turn = new Quaternion().setFromUnitVectors(from.direction, to.direction);
|
|
51
|
+
this.#start = start;
|
|
52
|
+
this.#ms = ms;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Where it ends. */
|
|
56
|
+
get to() {
|
|
57
|
+
return this.#to;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The pose at `now`, and whether the move is over.
|
|
62
|
+
* @param {number} now
|
|
63
|
+
* @returns {{ pose: Pose, done: boolean }}
|
|
64
|
+
*/
|
|
65
|
+
at(now) {
|
|
66
|
+
const t = Math.min(1, Math.max(0, (now - this.#start) / this.#ms));
|
|
67
|
+
if (t === 1) return { pose: this.#to, done: true };
|
|
68
|
+
const k = easeInOut(t);
|
|
69
|
+
const from = this.#from;
|
|
70
|
+
const to = this.#to;
|
|
71
|
+
const turn = new Quaternion().slerp(this.#turn, k);
|
|
72
|
+
return {
|
|
73
|
+
pose: {
|
|
74
|
+
target: from.target.clone().lerp(to.target, k),
|
|
75
|
+
direction: from.direction.clone().applyQuaternion(turn).normalize(),
|
|
76
|
+
distance: from.distance + (to.distance - from.distance) * k,
|
|
77
|
+
halfHeight: from.halfHeight + (to.halfHeight - from.halfHeight) * k,
|
|
78
|
+
},
|
|
79
|
+
done: false,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Cubic ease in and out: slow at both ends.
|
|
86
|
+
* @param {number} t from 0 to 1
|
|
87
|
+
*/
|
|
88
|
+
export function easeInOut(t) {
|
|
89
|
+
return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The direction `[x, y, z]` as a unit vector.
|
|
94
|
+
* @param {readonly [number, number, number]} direction
|
|
95
|
+
*/
|
|
96
|
+
export const unit = (direction) => new Vector3(...direction).normalize();
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// A section through the model: a plane that every shape's materials clip at, and the faces it
|
|
2
|
+
// cuts capped where the plane runs inside a solid, by three.js's stencil technique
|
|
3
|
+
// (webgl_clipping_stencil): each solid's back faces count up in the stencil buffer and its front
|
|
4
|
+
// faces down, both clipped, so where the count isn't 0 the plane is inside a solid, and a plane
|
|
5
|
+
// drawn there in the cap's colour closes the cut.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
AlwaysStencilFunc,
|
|
9
|
+
BackSide,
|
|
10
|
+
DecrementWrapStencilOp,
|
|
11
|
+
DoubleSide,
|
|
12
|
+
FrontSide,
|
|
13
|
+
Group,
|
|
14
|
+
IncrementWrapStencilOp,
|
|
15
|
+
Mesh,
|
|
16
|
+
MeshBasicMaterial,
|
|
17
|
+
MeshStandardMaterial,
|
|
18
|
+
NotEqualStencilFunc,
|
|
19
|
+
Plane,
|
|
20
|
+
PlaneGeometry,
|
|
21
|
+
ReplaceStencilOp,
|
|
22
|
+
Vector3,
|
|
23
|
+
} from 'three';
|
|
24
|
+
import { ownMaterials } from './model.js';
|
|
25
|
+
|
|
26
|
+
/** @import { Material, Sphere } from 'three' */
|
|
27
|
+
/** @import { Section, SceneModel, Theme } from './types.js' */
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The order things are drawn in: the stencil counts, the cap where they say the plane is inside
|
|
31
|
+
* a solid, then the model (always last, the others drawn only while a section is on).
|
|
32
|
+
*/
|
|
33
|
+
export const RENDER_ORDER = { stencil: 1, cap: 1.1, model: 2 };
|
|
34
|
+
/** The axis each section is across, as the direction it cuts away. */
|
|
35
|
+
const AXES = { x: new Vector3(1, 0, 0), y: new Vector3(0, 1, 0), z: new Vector3(0, 0, 1) };
|
|
36
|
+
/** The cap plane reaches this far past the model's bounding sphere's radius. */
|
|
37
|
+
const CAP_REACH = 4;
|
|
38
|
+
const PLANE_NORMAL = new Vector3(0, 0, 1);
|
|
39
|
+
|
|
40
|
+
export class SectionView {
|
|
41
|
+
/** The plane every material clips at, while a section is on. */
|
|
42
|
+
plane = new Plane();
|
|
43
|
+
/** Whether the model is cut: a section is on, and the model is a solid's (a drawing isn't). */
|
|
44
|
+
clipping = false;
|
|
45
|
+
/** The cap's plane, drawn where the stencil count isn't 0. */
|
|
46
|
+
cap;
|
|
47
|
+
/** The stencil counters added under each solid mesh: taken away with the section. */
|
|
48
|
+
#counters = /** @type {Group[]} */ ([]);
|
|
49
|
+
/** The model they count for: a section moved over it keeps them. @type {SceneModel | null} */
|
|
50
|
+
#counted = null;
|
|
51
|
+
/** The counters' back faces count up, their front faces down; every counter shares these. */
|
|
52
|
+
#counting = [
|
|
53
|
+
counterMaterial(BackSide, IncrementWrapStencilOp, this.plane),
|
|
54
|
+
counterMaterial(FrontSide, DecrementWrapStencilOp, this.plane),
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** @param {Theme} theme */
|
|
58
|
+
constructor(theme) {
|
|
59
|
+
const material = new MeshStandardMaterial({
|
|
60
|
+
color: theme.cap,
|
|
61
|
+
// Seen from the side cut away, against the plane's normal, which points to what is kept
|
|
62
|
+
side: DoubleSide,
|
|
63
|
+
stencilWrite: true,
|
|
64
|
+
stencilRef: 0,
|
|
65
|
+
stencilFunc: NotEqualStencilFunc,
|
|
66
|
+
stencilFail: ReplaceStencilOp,
|
|
67
|
+
stencilZFail: ReplaceStencilOp,
|
|
68
|
+
stencilZPass: ReplaceStencilOp,
|
|
69
|
+
});
|
|
70
|
+
this.cap = new Mesh(new PlaneGeometry(1, 1), material);
|
|
71
|
+
this.cap.renderOrder = RENDER_ORDER.cap;
|
|
72
|
+
this.cap.visible = false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {string} color */
|
|
76
|
+
setCapColor(color) {
|
|
77
|
+
/** @type {MeshStandardMaterial} */ (this.cap.material).color.set(color);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Cuts `model` at `section`, or takes the cut away (null). A section moved over the same
|
|
82
|
+
* model moves the plane alone: its counters stay, and the materials' programs (a material
|
|
83
|
+
* clipping, or not, is a program of its own), where every move of the offset made them again.
|
|
84
|
+
* @param {Section | null} section
|
|
85
|
+
* @param {SceneModel | null} model
|
|
86
|
+
* @param {Sphere} bounds the model's, which the cap must cover
|
|
87
|
+
* @param {{ capped: boolean, also: Material[] }} options whether the cut is closed (not in a
|
|
88
|
+
* wireframe), and materials outside the model that clip with it (the selected shapes'
|
|
89
|
+
* edges)
|
|
90
|
+
*/
|
|
91
|
+
apply(section, model, bounds, { capped, also }) {
|
|
92
|
+
const cut = section && model?.kind === '3d' ? { section, model } : null;
|
|
93
|
+
this.clipping = cut !== null;
|
|
94
|
+
if (cut) {
|
|
95
|
+
// What lies past the offset, along the axis (against it when flipped), is cut away
|
|
96
|
+
this.plane.normal.copy(AXES[cut.section.axis]).multiplyScalar(cut.section.flip ? 1 : -1);
|
|
97
|
+
this.plane.constant = cut.section.flip ? -cut.section.offset : cut.section.offset;
|
|
98
|
+
}
|
|
99
|
+
this.cap.visible = cut !== null && capped;
|
|
100
|
+
for (const material of [...(model ? ownMaterials(model) : []), ...also]) {
|
|
101
|
+
const clips = material.clippingPlanes !== null && material.clippingPlanes.length > 0;
|
|
102
|
+
if (clips === this.clipping) continue;
|
|
103
|
+
material.clippingPlanes = this.clipping ? [this.plane] : null;
|
|
104
|
+
material.needsUpdate = true;
|
|
105
|
+
}
|
|
106
|
+
this.#count(cut && capped ? cut.model : null);
|
|
107
|
+
if (!cut || !capped) return;
|
|
108
|
+
// Centred where the model's centre meets the plane, the cap covers the cut wherever it is
|
|
109
|
+
const size = bounds.radius * CAP_REACH;
|
|
110
|
+
this.cap.scale.set(size, size, 1);
|
|
111
|
+
this.plane.projectPoint(bounds.center, this.cap.position);
|
|
112
|
+
this.cap.quaternion.setFromUnitVectors(PLANE_NORMAL, this.plane.normal);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
dispose() {
|
|
116
|
+
this.cap.geometry.dispose();
|
|
117
|
+
/** @type {Material} */ (this.cap.material).dispose();
|
|
118
|
+
for (const material of this.#counting) material.dispose();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Puts the stencil counters under the solid meshes of `model`, or takes them away (null):
|
|
123
|
+
* the ones there already stay.
|
|
124
|
+
* @param {SceneModel | null} model
|
|
125
|
+
*/
|
|
126
|
+
#count(model) {
|
|
127
|
+
if (model === this.#counted) return;
|
|
128
|
+
for (const counters of this.#counters) counters.removeFromParent();
|
|
129
|
+
this.#counters = [];
|
|
130
|
+
this.#counted = model;
|
|
131
|
+
for (const shape of model?.shapes ?? []) {
|
|
132
|
+
if (shape.ghost) continue;
|
|
133
|
+
const counters = new Group();
|
|
134
|
+
for (const material of this.#counting) {
|
|
135
|
+
const counter = new Mesh(shape.mesh.geometry, material);
|
|
136
|
+
counter.renderOrder = RENDER_ORDER.stencil;
|
|
137
|
+
counters.add(counter);
|
|
138
|
+
}
|
|
139
|
+
shape.mesh.add(counters);
|
|
140
|
+
this.#counters.push(counters);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The material of a stencil counter, an invisible copy of a solid clipped at `plane`: its
|
|
147
|
+
* faces on `side` change the count by `op`.
|
|
148
|
+
* @param {import('three').Side} side
|
|
149
|
+
* @param {import('three').StencilOp} op
|
|
150
|
+
* @param {Plane} plane
|
|
151
|
+
* @returns {MeshBasicMaterial}
|
|
152
|
+
*/
|
|
153
|
+
function counterMaterial(side, op, plane) {
|
|
154
|
+
return new MeshBasicMaterial({
|
|
155
|
+
side,
|
|
156
|
+
clippingPlanes: [plane],
|
|
157
|
+
depthWrite: false,
|
|
158
|
+
depthTest: false,
|
|
159
|
+
colorWrite: false,
|
|
160
|
+
stencilWrite: true,
|
|
161
|
+
stencilFunc: AlwaysStencilFunc,
|
|
162
|
+
stencilFail: op,
|
|
163
|
+
stencilZFail: op,
|
|
164
|
+
stencilZPass: op,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// For a host's tests of the viewer and the components. Imported (a Vitest `setupFiles` entry,
|
|
2
|
+
// say), it gives jsdom what a browser has and they use: the component watches its canvas's size,
|
|
3
|
+
// the camera's controls capture the pointer while it drags, and a screenshot reads the canvas as
|
|
4
|
+
// an image, which jsdom draws none of: here an empty PNG. A canvas has no context, as jsdom's has
|
|
5
|
+
// none, but quietly (jsdom says "not implemented" when asked). A dialog (the shortcuts') opens and
|
|
6
|
+
// closes, which jsdom's element can't. `stubRenderer` draws in place of WebGL.
|
|
7
|
+
|
|
8
|
+
/** @import { Camera, Object3D } from 'three' */
|
|
9
|
+
/** @import { RendererLike } from './types.js' */
|
|
10
|
+
|
|
11
|
+
globalThis.ResizeObserver ??= class {
|
|
12
|
+
observe() {}
|
|
13
|
+
unobserve() {}
|
|
14
|
+
disconnect() {}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
Element.prototype.setPointerCapture ??= () => {};
|
|
18
|
+
Element.prototype.releasePointerCapture ??= () => {};
|
|
19
|
+
Element.prototype.hasPointerCapture ??= () => false;
|
|
20
|
+
|
|
21
|
+
HTMLCanvasElement.prototype.getContext = /** @type {HTMLCanvasElement['getContext']} */ (
|
|
22
|
+
() => null
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// Shown, a dialog is open; closed, it says so, as a browser's does
|
|
26
|
+
HTMLDialogElement.prototype.showModal ??= /** @this {HTMLDialogElement} */ function showModal() {
|
|
27
|
+
this.open = true;
|
|
28
|
+
};
|
|
29
|
+
HTMLDialogElement.prototype.close ??= /** @this {HTMLDialogElement} */ function close() {
|
|
30
|
+
if (!this.open) return;
|
|
31
|
+
this.open = false;
|
|
32
|
+
this.dispatchEvent(new Event('close'));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** @type {HTMLCanvasElement['toBlob']} */
|
|
36
|
+
HTMLCanvasElement.prototype.toBlob = function toBlob(callback, type = 'image/png') {
|
|
37
|
+
callback(new Blob([], { type }));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A stand-in for WebGL, for a Viewer's `options.renderer`: it draws nothing, and keeps what it
|
|
42
|
+
* was asked of the viewer's canvas (the first it is given; another, a view cube's, it draws on
|
|
43
|
+
* unrecorded): how many frames, the scene and the camera of the last, and whether it was
|
|
44
|
+
* disposed; and of every canvas, how many renderers were made and how many let their context
|
|
45
|
+
* go. As a WebGLRenderer does, it brings the scene's and the camera's matrices up to date: the
|
|
46
|
+
* camera's controls pan along them.
|
|
47
|
+
*/
|
|
48
|
+
export function stubRenderer() {
|
|
49
|
+
const drawn = {
|
|
50
|
+
renders: 0,
|
|
51
|
+
/** @type {Object3D | null} */
|
|
52
|
+
scene: null,
|
|
53
|
+
/** @type {Camera | null} */
|
|
54
|
+
camera: null,
|
|
55
|
+
disposed: false,
|
|
56
|
+
made: 0,
|
|
57
|
+
released: 0,
|
|
58
|
+
};
|
|
59
|
+
/** @type {HTMLCanvasElement | null} */
|
|
60
|
+
let viewers = null;
|
|
61
|
+
/**
|
|
62
|
+
* @param {HTMLCanvasElement} canvas
|
|
63
|
+
* @returns {RendererLike}
|
|
64
|
+
*/
|
|
65
|
+
const renderer = (canvas) => {
|
|
66
|
+
viewers ??= canvas;
|
|
67
|
+
const recorded = canvas === viewers;
|
|
68
|
+
drawn.made += 1;
|
|
69
|
+
return {
|
|
70
|
+
localClippingEnabled: false,
|
|
71
|
+
setPixelRatio() {},
|
|
72
|
+
setSize() {},
|
|
73
|
+
render(scene, camera) {
|
|
74
|
+
scene.updateMatrixWorld();
|
|
75
|
+
if (camera.parent === null) camera.updateMatrixWorld();
|
|
76
|
+
if (!recorded) return;
|
|
77
|
+
drawn.renders += 1;
|
|
78
|
+
drawn.scene = scene;
|
|
79
|
+
drawn.camera = camera;
|
|
80
|
+
},
|
|
81
|
+
dispose() {
|
|
82
|
+
if (recorded) drawn.disposed = true;
|
|
83
|
+
},
|
|
84
|
+
forceContextLoss() {
|
|
85
|
+
drawn.released += 1;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
return { renderer, drawn };
|
|
90
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The viewer's colours: what a host maps its own tokens into (the VS Code webview its
|
|
2
|
+
// --vscode-* variables, the playground its page's). Any CSS colour. By default the KupCAD brand:
|
|
3
|
+
// a picked part in its orangered, on the neutrals of @kupcad/ui's tokens; a section's cut
|
|
4
|
+
// faces in dodgerblue, so no other warm colour reads as the selection.
|
|
5
|
+
|
|
6
|
+
/** @import { Theme } from './types.js' */
|
|
7
|
+
|
|
8
|
+
/** @type {Theme} */
|
|
9
|
+
export const LIGHT = {
|
|
10
|
+
background: '#eef1f4',
|
|
11
|
+
grid: '#c9cfd6',
|
|
12
|
+
plate: '#e3e7eb',
|
|
13
|
+
edges: '#1a1b1c',
|
|
14
|
+
wire: '#1a1b1c',
|
|
15
|
+
selection: '#ff5f14',
|
|
16
|
+
cap: '#0099ff',
|
|
17
|
+
ghost: '#7d8794',
|
|
18
|
+
highlight: '#d6409f',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** @type {Theme} */
|
|
22
|
+
export const DARK = {
|
|
23
|
+
background: '#131415',
|
|
24
|
+
grid: '#2e2f32',
|
|
25
|
+
plate: '#1f2022',
|
|
26
|
+
edges: '#0b0b0c',
|
|
27
|
+
wire: '#c9ced4',
|
|
28
|
+
selection: '#ff5f14',
|
|
29
|
+
cap: '#3daeff',
|
|
30
|
+
ghost: '#8a94a1',
|
|
31
|
+
highlight: '#f06cb8',
|
|
32
|
+
};
|