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/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
+ }