ocp-viewer-core 1.0.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/package.json +22 -0
- package/src/animation.js +87 -0
- package/src/apply.js +328 -0
- package/src/index.js +65 -0
- package/src/logo.js +279 -0
- package/src/notify.js +112 -0
- package/src/options.js +272 -0
- package/src/page.js +476 -0
- package/src/render.js +299 -0
- package/src/states.js +120 -0
package/src/render.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drawing a model, and deciding where the camera ends up.
|
|
3
|
+
*
|
|
4
|
+
* This is the part that was hard to get right, and it is the part every client
|
|
5
|
+
* has to agree on: what `reset_camera` means, and above all what *keeping* the
|
|
6
|
+
* camera means when the model underneath it has changed. ocp_vscode worked this
|
|
7
|
+
* out over a long time; cad-viewer-widget has its own version; build123d Studio
|
|
8
|
+
* has none at all, which is why a second `show()` there throws the view away.
|
|
9
|
+
* One answer, so a script that looks right in one viewer looks right in all of
|
|
10
|
+
* them.
|
|
11
|
+
*
|
|
12
|
+
* The three modes, since the names understate the difference:
|
|
13
|
+
*
|
|
14
|
+
* keep the camera direction survives, the distance is recomputed from the
|
|
15
|
+
* new bounding box, and the zoom is corrected by how much that
|
|
16
|
+
* distance moved. Not "leave the camera alone" - a model ten times
|
|
17
|
+
* larger, left alone, is off screen.
|
|
18
|
+
* center the direction survives and the target moves to the new centre.
|
|
19
|
+
* reset and the preset views: the stored state is discarded.
|
|
20
|
+
*
|
|
21
|
+
* Nothing here touches the DOM, a transport or a host. The two things only a
|
|
22
|
+
* host knows - the size of the surface it draws on, and how to send a status
|
|
23
|
+
* message - arrive as callbacks.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
Copyright 2026 Bernhard Walter
|
|
28
|
+
|
|
29
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
30
|
+
you may not use this file except in compliance with the License.
|
|
31
|
+
You may obtain a copy of the License at
|
|
32
|
+
|
|
33
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
34
|
+
|
|
35
|
+
Unless required by applicable law or agreed to in writing, software
|
|
36
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
37
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
38
|
+
See the License for the specific language governing permissions and
|
|
39
|
+
limitations under the License.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { VIEWS } from "./apply.js";
|
|
43
|
+
import { buildRenderOptions, buildViewerOptions, preset } from "./options.js";
|
|
44
|
+
|
|
45
|
+
// The carry-over pairs: the option name the renderer takes, and the name the
|
|
46
|
+
// same value comes back under in a notification. Sliders and normals are
|
|
47
|
+
// deliberately absent - three-cad-viewer decides for itself whether to keep or
|
|
48
|
+
// reset those, from whether the bounding box changed, and an explicit value
|
|
49
|
+
// from the caller already arrives through buildViewerOptions.
|
|
50
|
+
const CARRY_OVER = [
|
|
51
|
+
["clipIntersection", "clip_intersection"],
|
|
52
|
+
["clipPlaneHelpers", "clip_planes"],
|
|
53
|
+
["clipObjectColors", "clip_object_colors"],
|
|
54
|
+
["zebraCount", "zebra_count"],
|
|
55
|
+
["zebraOpacity", "zebra_opacity"],
|
|
56
|
+
["zebraDirection", "zebra_direction"],
|
|
57
|
+
["zebraColorScheme", "zebra_color_scheme"],
|
|
58
|
+
["zebraMappingMode", "zebra_mapping_mode"]
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
function length(v) {
|
|
62
|
+
return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalize(v) {
|
|
66
|
+
const n = length(v);
|
|
67
|
+
return [v[0] / n, v[1] / n, v[2] / n];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The centre and the radius of a model's bounding box. */
|
|
71
|
+
function boundingSphere(bb) {
|
|
72
|
+
const center = [
|
|
73
|
+
(bb.xmax + bb.xmin) / 2,
|
|
74
|
+
(bb.ymax + bb.ymin) / 2,
|
|
75
|
+
(bb.zmax + bb.zmin) / 2
|
|
76
|
+
];
|
|
77
|
+
const radius = Math.max(
|
|
78
|
+
Math.sqrt(
|
|
79
|
+
Math.pow(bb.xmax - bb.xmin, 2) +
|
|
80
|
+
Math.pow(bb.ymax - bb.ymin, 2) +
|
|
81
|
+
Math.pow(bb.zmax - bb.zmin, 2)
|
|
82
|
+
),
|
|
83
|
+
length(center)
|
|
84
|
+
);
|
|
85
|
+
return { center, radius };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create the renderer for one viewer.
|
|
90
|
+
*
|
|
91
|
+
* State is held here rather than by the host because it is this policy's:
|
|
92
|
+
* the camera distance of the previous render, which the zoom correction needs,
|
|
93
|
+
* and the previous bounding radius.
|
|
94
|
+
*
|
|
95
|
+
* @param viewer a three-cad-viewer instance
|
|
96
|
+
* @param status the picture `createNotifier` keeps. Read for the stored
|
|
97
|
+
* camera and the carried-over settings, and written with
|
|
98
|
+
* what the renderer settled on - a render is a change the
|
|
99
|
+
* viewer does not notify about
|
|
100
|
+
* @param overrides optional {render, viewer, theme} - only what this host
|
|
101
|
+
* genuinely differs on. The defaults themselves come from
|
|
102
|
+
* the core, so a host that overrides nothing behaves like
|
|
103
|
+
* every other one
|
|
104
|
+
* @param resize () => void, called when the config carries a tree width.
|
|
105
|
+
* Only the host can resolve the other two dimensions
|
|
106
|
+
* @param sendStatus (snapshot) => void, the host's status message
|
|
107
|
+
* @param debug optional (label, value) => void
|
|
108
|
+
*/
|
|
109
|
+
export function createRenderer({ viewer, status, overrides, resize, sendStatus, debug }) {
|
|
110
|
+
const hostOverrides = overrides || {};
|
|
111
|
+
let cameraDistance = null;
|
|
112
|
+
let lastRadius = null;
|
|
113
|
+
|
|
114
|
+
function log(label, value) {
|
|
115
|
+
if (debug) {
|
|
116
|
+
debug(label, value);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function render(meshData, config) {
|
|
121
|
+
const shapes = meshData.shapes || meshData;
|
|
122
|
+
const renderOptions = buildRenderOptions(config, hostOverrides.render);
|
|
123
|
+
const viewerOptions = buildViewerOptions(config, hostOverrides.viewer);
|
|
124
|
+
|
|
125
|
+
if (!config.theme) {
|
|
126
|
+
config.theme = hostOverrides.theme;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const resetCamera = preset(config, "resetCamera", "keep");
|
|
130
|
+
log("renderOptions", renderOptions);
|
|
131
|
+
log("viewerOptions", viewerOptions);
|
|
132
|
+
log("resetCamera", resetCamera);
|
|
133
|
+
|
|
134
|
+
const { center, radius } = boundingSphere(shapes["bb"]);
|
|
135
|
+
lastRadius = radius;
|
|
136
|
+
|
|
137
|
+
// "keep" and "center" are the two modes that carry the previous camera
|
|
138
|
+
// over; a preset view and "reset" start from the config alone.
|
|
139
|
+
const useStoredState = resetCamera === "keep" || resetCamera === "center";
|
|
140
|
+
const newZoom = config.zoom !== undefined;
|
|
141
|
+
|
|
142
|
+
if (!useStoredState) {
|
|
143
|
+
viewerOptions.zoom = config.zoom !== undefined ? config.zoom : 1.0;
|
|
144
|
+
if (config.position !== undefined) {
|
|
145
|
+
viewerOptions.position = config.position;
|
|
146
|
+
}
|
|
147
|
+
if (config.quaternion !== undefined) {
|
|
148
|
+
viewerOptions.quaternion = config.quaternion;
|
|
149
|
+
}
|
|
150
|
+
if (config.target !== undefined) {
|
|
151
|
+
viewerOptions.target = config.target;
|
|
152
|
+
}
|
|
153
|
+
cameraDistance = null;
|
|
154
|
+
} else {
|
|
155
|
+
if (config.position) {
|
|
156
|
+
viewerOptions.position = config.position;
|
|
157
|
+
} else if (status.position) {
|
|
158
|
+
let p = [0, 0, 0];
|
|
159
|
+
if (resetCamera === "keep") {
|
|
160
|
+
// The direction from target to camera is what survives. The
|
|
161
|
+
// distance is taken from the new model's radius, so the
|
|
162
|
+
// model stays the same size on screen however much it grew.
|
|
163
|
+
const distance = 2.5 * radius;
|
|
164
|
+
for (let i = 0; i < 3; i++) {
|
|
165
|
+
p[i] = status.position[i] - status.target[i];
|
|
166
|
+
}
|
|
167
|
+
p = normalize(p);
|
|
168
|
+
for (let i = 0; i < 3; i++) {
|
|
169
|
+
p[i] = p[i] * distance + status.target[i];
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
// center: same direction, aimed at the new centre.
|
|
173
|
+
for (let i = 0; i < 3; i++) {
|
|
174
|
+
p[i] = status.position[i] - status.target[i] + center[i];
|
|
175
|
+
}
|
|
176
|
+
status.target = center;
|
|
177
|
+
}
|
|
178
|
+
viewerOptions.position = p;
|
|
179
|
+
}
|
|
180
|
+
status.position = viewerOptions.position;
|
|
181
|
+
|
|
182
|
+
if (config.quaternion) {
|
|
183
|
+
viewerOptions.quaternion = config.quaternion;
|
|
184
|
+
} else if (status.quaternion) {
|
|
185
|
+
viewerOptions.quaternion = status.quaternion;
|
|
186
|
+
}
|
|
187
|
+
status.quaternion = viewerOptions.quaternion;
|
|
188
|
+
|
|
189
|
+
if (config.target) {
|
|
190
|
+
viewerOptions.target = config.target;
|
|
191
|
+
} else if (status.target) {
|
|
192
|
+
viewerOptions.target = status.target;
|
|
193
|
+
}
|
|
194
|
+
status.target = viewerOptions.target;
|
|
195
|
+
|
|
196
|
+
if (config.zoom) {
|
|
197
|
+
viewerOptions.zoom = config.zoom;
|
|
198
|
+
} else if (status.zoom) {
|
|
199
|
+
viewerOptions.zoom = status.zoom;
|
|
200
|
+
}
|
|
201
|
+
status.zoom = viewerOptions.zoom;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
log("position", status.position);
|
|
205
|
+
log("quaternion", status.quaternion);
|
|
206
|
+
log("target", status.target);
|
|
207
|
+
log("zoom", status.zoom);
|
|
208
|
+
|
|
209
|
+
for (const [optionKey, statusKey] of CARRY_OVER) {
|
|
210
|
+
viewerOptions[optionKey] =
|
|
211
|
+
config[optionKey] !== undefined ? config[optionKey] : status[statusKey];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (config.tab) {
|
|
215
|
+
// Through render rather than a setActiveTab afterwards, so the
|
|
216
|
+
// scene is built in the target tab instead of being painted in CAD
|
|
217
|
+
// mode first and switched.
|
|
218
|
+
viewerOptions.tab = config.tab;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// The envelope when it is the instanced format, the tree otherwise.
|
|
222
|
+
//
|
|
223
|
+
// A host that received base64 hands over `{instances, shapes}` and the
|
|
224
|
+
// renderer decodes and resolves it itself. A host that decoded its own
|
|
225
|
+
// buffers - build123d Studio reads raw arrays off a binary frame and
|
|
226
|
+
// builds typed-array views onto it with no copy - cannot go that way:
|
|
227
|
+
// `decodeBuffer` base64-decodes unconditionally. It resolves the refs
|
|
228
|
+
// itself, with the renderer's own `resolveInstances`, and hands over a
|
|
229
|
+
// plain tree.
|
|
230
|
+
//
|
|
231
|
+
// Passing the envelope regardless is what broke that host: with no
|
|
232
|
+
// `instances` beside them, the shapes went in as an object with no
|
|
233
|
+
// `parts`, and the walk died on `id.replaceAll` of undefined - after
|
|
234
|
+
// clear() had already taken the previous model off the screen.
|
|
235
|
+
const instanced = Array.isArray(meshData.instances);
|
|
236
|
+
viewer.render(instanced ? meshData : shapes, renderOptions, viewerOptions);
|
|
237
|
+
|
|
238
|
+
if (config.treeWidth && resize) {
|
|
239
|
+
resize();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!newZoom && resetCamera === "keep" && cameraDistance != null) {
|
|
243
|
+
// The camera moved to suit the new model's size, so the zoom is
|
|
244
|
+
// corrected by the same ratio - without this, "keep" keeps the
|
|
245
|
+
// number and loses the framing.
|
|
246
|
+
viewer.setCameraZoom(
|
|
247
|
+
((status.zoom == null ? 1.0 : status.zoom) *
|
|
248
|
+
viewer.camera.camera_distance) /
|
|
249
|
+
cameraDistance
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (VIEWS.includes(resetCamera)) {
|
|
254
|
+
viewer.setView(resetCamera);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Read back what the renderer settled on. A render is a change it does
|
|
258
|
+
// not notify about, so without this the next "keep" carries over the
|
|
259
|
+
// camera from two models ago.
|
|
260
|
+
status.position = viewer.getCameraPosition();
|
|
261
|
+
status.quaternion = viewer.getCameraQuaternion();
|
|
262
|
+
status.target = viewer.controls.getTarget().toArray();
|
|
263
|
+
status.zoom = viewer.getCameraZoom();
|
|
264
|
+
cameraDistance = viewer.camera.camera_distance;
|
|
265
|
+
|
|
266
|
+
status.clip_planes = viewer.getClipPlaneHelpers();
|
|
267
|
+
status.clip_object_colors = viewer.getObjectColorCaps();
|
|
268
|
+
status.clip_intersection = viewer.getClipIntersection();
|
|
269
|
+
status.zebra_count = viewer.getZebraCount();
|
|
270
|
+
status.zebra_opacity = viewer.getZebraOpacity();
|
|
271
|
+
status.zebra_direction = viewer.getZebraDirection();
|
|
272
|
+
status.zebra_color_scheme = viewer.getZebraColorScheme();
|
|
273
|
+
status.zebra_mapping_mode = viewer.getZebraMappingMode();
|
|
274
|
+
|
|
275
|
+
if (sendStatus) {
|
|
276
|
+
sendStatus({ ...status });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (config.explode) {
|
|
280
|
+
viewer.setExplode(true);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Applied here rather than through the option path, the same way explode
|
|
284
|
+
// is: it is a tool activation, not an option the renderer holds.
|
|
285
|
+
if (["distance", "properties", "select"].includes(config.analysisTool)) {
|
|
286
|
+
viewer.display.setTool(config.analysisTool, true);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return viewerOptions;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
render,
|
|
294
|
+
/** The previous model's bounding radius, for a host that wants it. */
|
|
295
|
+
get lastRadius() {
|
|
296
|
+
return lastRadius;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
package/src/states.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree visibility state across a re-`show()`.
|
|
3
|
+
*
|
|
4
|
+
* When a model is shown again, the user's visibility choices should survive
|
|
5
|
+
* for the objects that are still there. Without this, every `show()` resets the
|
|
6
|
+
* tree.
|
|
7
|
+
*
|
|
8
|
+
* The two halves are pure and take no viewer: what the new model contains, and
|
|
9
|
+
* which of the old choices are worth re-applying. Only the orchestrator at the
|
|
10
|
+
* bottom touches a viewer.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
Copyright 2026 Bernhard Walter
|
|
15
|
+
|
|
16
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
17
|
+
you may not use this file except in compliance with the License.
|
|
18
|
+
You may obtain a copy of the License at
|
|
19
|
+
|
|
20
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
21
|
+
|
|
22
|
+
Unless required by applicable law or agreed to in writing, software
|
|
23
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
24
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
25
|
+
See the License for the specific language governing permissions and
|
|
26
|
+
limitations under the License.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The visibility state of every leaf in a tessellated model, keyed by id.
|
|
31
|
+
*
|
|
32
|
+
* A node with `parts` is a group and carries no state of its own; a node
|
|
33
|
+
* without is a leaf. The key is the node's `id`, which is the leading-slash
|
|
34
|
+
* path - the same form `viewer.treeview.getStates()` returns, which is what
|
|
35
|
+
* makes the two comparable. The renderer assumes those two addressings agree
|
|
36
|
+
* (`node.id === parent.id + "/" + node.name`) and validates it nowhere, so a
|
|
37
|
+
* mismatch here shows up as states that silently fail to restore.
|
|
38
|
+
*/
|
|
39
|
+
export function collectStates(shapes) {
|
|
40
|
+
const states = {};
|
|
41
|
+
function walk(node) {
|
|
42
|
+
if (node == null) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (node.parts != null) {
|
|
46
|
+
for (const part of node.parts) {
|
|
47
|
+
walk(part);
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
states[node.id] = node.state;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
walk(shapes);
|
|
54
|
+
return states;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Which of the previous states should be re-applied to the new model.
|
|
59
|
+
*
|
|
60
|
+
* A key qualifies when it still exists in the new model and its state actually
|
|
61
|
+
* differs, so an unchanged tree produces an empty result and no repaint. The
|
|
62
|
+
* state is the `[faces, edges]` pair, compared element by element - the arrays
|
|
63
|
+
* are distinct objects on both sides, so identity would say "different" every
|
|
64
|
+
* time and restore the entire tree on every show.
|
|
65
|
+
*/
|
|
66
|
+
export function statesToRestore(oldStates, newStates) {
|
|
67
|
+
const restore = {};
|
|
68
|
+
if (oldStates == null || newStates == null) {
|
|
69
|
+
return restore;
|
|
70
|
+
}
|
|
71
|
+
for (const key of Object.keys(oldStates)) {
|
|
72
|
+
const before = oldStates[key];
|
|
73
|
+
const after = newStates[key];
|
|
74
|
+
if (after == null || before == null) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (before[0] !== after[0] || before[1] !== after[1]) {
|
|
78
|
+
restore[key] = before;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return restore;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read the states a viewer currently holds, safely on a viewer that has not
|
|
86
|
+
* rendered yet.
|
|
87
|
+
*
|
|
88
|
+
* Note that `getStates()` hands back the tree's own live arrays rather than
|
|
89
|
+
* copies, so the result must be treated as a snapshot to read and never
|
|
90
|
+
* mutated - the next render would be writing through it.
|
|
91
|
+
*/
|
|
92
|
+
export function currentStates(viewer) {
|
|
93
|
+
if (viewer == null || viewer.treeview == null) {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
return viewer.treeview.getStates();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Apply tree state after a model has been rendered.
|
|
101
|
+
*
|
|
102
|
+
* Explicit states win: a caller who passed `states=` is describing what they
|
|
103
|
+
* want to see, and it outranks what the user last clicked. Otherwise the prior
|
|
104
|
+
* choices are restored for whatever survived into the new model.
|
|
105
|
+
*
|
|
106
|
+
* Either way it is one batched `setStates`. A per-key `setState` loop is a
|
|
107
|
+
* repaint per key, and on a large model - turning every edge off, say - that is
|
|
108
|
+
* the whole scene re-rendered once per object, which freezes the host.
|
|
109
|
+
*/
|
|
110
|
+
export function restoreStates(viewer, shapes, oldStates, explicitStates) {
|
|
111
|
+
if (explicitStates != null) {
|
|
112
|
+
viewer.setStates(explicitStates);
|
|
113
|
+
return explicitStates;
|
|
114
|
+
}
|
|
115
|
+
const restore = statesToRestore(oldStates, collectStates(shapes));
|
|
116
|
+
if (Object.keys(restore).length > 0) {
|
|
117
|
+
viewer.setStates(restore);
|
|
118
|
+
}
|
|
119
|
+
return restore;
|
|
120
|
+
}
|