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/page.js ADDED
@@ -0,0 +1,476 @@
1
+ /**
2
+ * The viewer page, held once for every host that shows one.
3
+ *
4
+ * A host's HTML supplies only what is genuinely its own: where it loaded these
5
+ * modules from, where its settings come from, and how to send a message. Even
6
+ * measuring the surface is here - a host that owns a whole browsing context
7
+ * gets the window measured for it, and one that shows the viewer in a pane
8
+ * passes its own container and its own size.
9
+ *
10
+ * This is the one module in the package that touches the DOM, and it does so
11
+ * because it *is* the page. Everything else here takes a viewer and plain data
12
+ * and could run headless; that distinction is worth keeping, so new DOM work
13
+ * belongs in this file or in a host, not spread through the others.
14
+ */
15
+
16
+ /*
17
+ Copyright 2026 Bernhard Walter
18
+
19
+ Licensed under the Apache License, Version 2.0 (the "License");
20
+ you may not use this file except in compliance with the License.
21
+ You may obtain a copy of the License at
22
+
23
+ http://www.apache.org/licenses/LICENSE-2.0
24
+
25
+ Unless required by applicable law or agreed to in writing, software
26
+ distributed under the License is distributed on an "AS IS" BASIS,
27
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
28
+ See the License for the specific language governing permissions and
29
+ limitations under the License.
30
+ */
31
+
32
+ import { animate } from "./animation.js";
33
+ import { applyConfig } from "./apply.js";
34
+ import { logo } from "./logo.js";
35
+ import { createNotifier } from "./notify.js";
36
+ import { buildDisplayOptions, preset } from "./options.js";
37
+ import { createRenderer } from "./render.js";
38
+ import { currentStates, restoreStates } from "./states.js";
39
+
40
+ const MIN_WIDTH = 450;
41
+
42
+ /**
43
+ * Build the page.
44
+ *
45
+ * @param Viewer, Display, Timer three-cad-viewer's three, passed in because
46
+ * only the host knows where it loaded them from
47
+ * @param send (command, message) => void - the host's channel to Python
48
+ * @param overrides {display, viewer} - the settings this host differs on
49
+ * @param theme the host's resolved theme, for the observer below
50
+ * @param container the element to draw into, or its id. Default "cad_viewer"
51
+ * @param getSize () => {width, height} of the surface. Default the window
52
+ * @param listen attach the window's `message` and `resize` listeners.
53
+ * Default true; a host that has neither passes false and
54
+ * drives `handleMessage` and `resize` itself
55
+ * @returns `{ showSplash, setTheme, handleMessage, resize }` - the two page
56
+ * hosts deliver messages as a `message` event, the extension posting
57
+ * into the webview and the standalone's socket shim posting what came
58
+ * off the wire, and both let this file listen for them. A host whose
59
+ * models arrive some other way calls `handleMessage` with the same
60
+ * objects, so every host runs the one dispatch and no host can end up
61
+ * with a message branch, and so a feature, that another lacks.
62
+ */
63
+ export function createPage({
64
+ Viewer,
65
+ Display,
66
+ Timer,
67
+ send,
68
+ overrides,
69
+ theme,
70
+ container,
71
+ getSize,
72
+ listen
73
+ }) {
74
+ var viewer = null;
75
+ var display = null;
76
+ var _shapes = null;
77
+ var _meshData = null;
78
+ var _config = null;
79
+ var _camera_distance = null;
80
+ var viewerOptions = {};
81
+ var last_bb_radius = null;
82
+
83
+ // The viewer's own state, as the renderer reports it: one object the
84
+ // notifier keeps, under the names the renderer notifies with - camelCase
85
+ // for the camera, which is sent straight to checkChanges, and snake_case
86
+ // for everything that goes through its notification map. render() writes
87
+ // into it too, after
88
+ // reading the camera back off the viewer.
89
+ //
90
+ // It is also what goes on the wire. The extension answers a status
91
+ // request with the last message the webview sent, replacing rather
92
+ // than merging, so a delta would lose every earlier value - and
93
+ // combined_config merges the viewer's state into the next show. The
94
+ // notifier's delta plus this snapshot is what preserves both.
95
+ var notifier = null;
96
+ var renderer = null;
97
+ var status = {};
98
+
99
+ function debugLog(tag, obj) {
100
+ console.log(tag, obj);
101
+ var msg = tag + (obj ? " " + JSON.stringify(obj) : "");
102
+ send("log", msg);
103
+ }
104
+
105
+ function nc(change) {
106
+ return notifier == null ? null : notifier.notify(change);
107
+ }
108
+
109
+ function makeRenderer() {
110
+ renderer = createRenderer({
111
+ viewer: viewer,
112
+ status: status,
113
+ overrides: { viewer: overrides.viewer, theme: overrides.display.theme },
114
+ // A tree width is a resize, and only this host knows the
115
+ // other two dimensions.
116
+ resize: () => {
117
+ const displayOptions = getDisplayOptions(_config.theme);
118
+ viewer.resizeCadView(
119
+ displayOptions.cadWidth,
120
+ displayOptions.treeWidth,
121
+ displayOptions.height,
122
+ displayOptions.glass
123
+ );
124
+ },
125
+ sendStatus: (snapshot) => send("status", snapshot),
126
+ debug: _config != null && _config.debug ? debugLog : null
127
+ });
128
+ }
129
+
130
+ function makeNotifier() {
131
+ notifier = createNotifier({
132
+ viewer: viewer,
133
+ // The delta is what changed; the snapshot is what the
134
+ // extension will answer the next status request with.
135
+ // Event keys - a selection, a pick, the active tool - ride
136
+ // on the delta only, so a selection made minutes ago is not
137
+ // replayed into an unrelated update.
138
+ send: (delta) => send("status", { ...status, ...delta }),
139
+ debug: _config != null && _config.debug ? debugLog : null
140
+ });
141
+ status = notifier.status;
142
+ }
143
+
144
+ function normalizeWidth(width, glass, tools) {
145
+ const treeWidth =
146
+ glass || !tools
147
+ ? 0
148
+ : preset(_config, "treeWidth", overrides.display.treeWidth);
149
+ return Math.max(MIN_WIDTH - treeWidth, width - treeWidth - 20);
150
+ }
151
+
152
+ /**
153
+ * The chrome above the canvas, measured rather than assumed.
154
+ *
155
+ * This was the constant 65, which is an approximation of the canvas's own
156
+ * top offset and wrong in both directions: it over-reserves in a pane,
157
+ * showing as a bottom margin visibly larger than the left and top ones,
158
+ * and measuring the toolbar alone instead under-reserves - the toolbar's
159
+ * offset and the gap below it are part of the same distance, and without
160
+ * them the canvas overflows and clips the status line, which is pinned to
161
+ * `bottom: 4px` inside it.
162
+ *
163
+ * Taken from the container so that it means the same thing to a host that
164
+ * owns the window and to one that draws into a pane.
165
+ */
166
+ const ASSUMED_CHROME = 65;
167
+
168
+ function getContainer() {
169
+ if (container == null) {
170
+ return document.getElementById("cad_viewer");
171
+ }
172
+ return typeof container === "string"
173
+ ? document.getElementById(container)
174
+ : container;
175
+ }
176
+
177
+ function reservedHeight() {
178
+ const element = getContainer();
179
+ if (element == null) {
180
+ return ASSUMED_CHROME;
181
+ }
182
+ const canvas = element.querySelector(
183
+ ".tcv_cad_view_glass, .tcv_cad_view"
184
+ );
185
+ if (canvas != null) {
186
+ const offset =
187
+ canvas.getBoundingClientRect().top -
188
+ element.getBoundingClientRect().top;
189
+ if (offset > 0) {
190
+ return Math.round(offset);
191
+ }
192
+ }
193
+ // Before the canvas exists, approximate from the toolbar if it is there.
194
+ const toolbar = element.querySelector(".tcv_cad_toolbar");
195
+ if (toolbar != null && toolbar.offsetHeight > 0) {
196
+ return toolbar.offsetHeight + 10;
197
+ }
198
+ return ASSUMED_CHROME;
199
+ }
200
+
201
+ function normalizeHeight(height) {
202
+ return height - reservedHeight();
203
+ }
204
+
205
+ function measureSize() {
206
+ if (getSize != null) {
207
+ return getSize();
208
+ }
209
+ return {
210
+ width: window.innerWidth,
211
+ height: window.innerHeight
212
+ };
213
+ }
214
+
215
+ function getGeometry() {
216
+ const size = measureSize();
217
+ const glass = preset(_config, "glass", overrides.display.glass);
218
+ const tools = preset(_config, "tools", overrides.display.tools);
219
+ return {
220
+ cadWidth: normalizeWidth(size.width, glass, tools),
221
+ height: normalizeHeight(size.height),
222
+ treeWidth: preset(
223
+ _config,
224
+ "treeWidth",
225
+ overrides.display.treeWidth
226
+ )
227
+ };
228
+ }
229
+
230
+ function getDisplayOptions(theme) {
231
+ const options = buildDisplayOptions(
232
+ _config,
233
+ overrides.display,
234
+ getGeometry()
235
+ );
236
+ // An explicit theme argument still wins: showViewer is called
237
+ // with config.theme, and the observer below with the host's.
238
+ if (theme) {
239
+ options.theme = theme;
240
+ }
241
+ return options;
242
+ }
243
+
244
+ function showViewer(meshData, config) {
245
+ debugLog("showViewer called");
246
+ _meshData = meshData;
247
+ _shapes = meshData.shapes || meshData;
248
+ _config = config;
249
+ const displayOptions = getDisplayOptions(config.theme);
250
+ if (display == null) {
251
+ const element = getContainer();
252
+ element.innerHTML = "";
253
+ display = new Display(element, displayOptions);
254
+ }
255
+ if (_config == null) {
256
+ debugLog("OCP CAD Viewer: config is null");
257
+ _config = {};
258
+ }
259
+ if (_config.debug) {
260
+ debugLog("_config", _config);
261
+ debugLog("displayOptions", displayOptions);
262
+ }
263
+ // Reuse the viewer across shows: clear() tears down the scene and
264
+ // resets activeTab to "tree" (which triggers leaveStudioMode if the
265
+ // previous show was in Studio mode), but keeps the WebGL context,
266
+ // ViewerState (preferences), and StudioManager (incl. PMREM env
267
+ // cache) alive — saving a context teardown and HDR re-fetch per show.
268
+ if (viewer != null) {
269
+ viewer.clear();
270
+ } else {
271
+ viewer = new Viewer(display, displayOptions, nc, null);
272
+ // After the viewer, because both read it. `nc` is the
273
+ // trampoline that bridges the notifier to the constructor.
274
+ makeNotifier();
275
+ makeRenderer();
276
+ }
277
+
278
+ if (_shapes) renderer.render(_meshData, _config);
279
+
280
+ // Three display options are re-applied on every show, for one reason:
281
+ // the Display is built once, so everything it was constructed with is
282
+ // dropped by every show after the first. The theme is the one that was
283
+ // missing - `show(theme=...)` and a changed workspace setting both
284
+ // computed it into displayOptions and then threw it away, because by
285
+ // then `display` was no longer null.
286
+ viewer.setTheme(displayOptions.theme);
287
+ viewer.glassMode(displayOptions.glass);
288
+ viewer.showTools(displayOptions.tools);
289
+
290
+ debugLog("showViewer finished");
291
+
292
+ return viewer;
293
+
294
+ // viewer.trimUI(["axes", "axes0", "grid", "ortho", "more", "help"])
295
+ }
296
+
297
+ /**
298
+ * The surface changed size.
299
+ *
300
+ * A window resize for the two page hosts, and a splitter drag for a host
301
+ * that draws into a pane - the same work either way, which is why it is a
302
+ * function rather than a listener body.
303
+ */
304
+ function resize() {
305
+ if (viewer != null) {
306
+ const displayOptions = getDisplayOptions(_config.theme);
307
+ viewer.resizeCadView(
308
+ displayOptions.cadWidth,
309
+ displayOptions.treeWidth,
310
+ displayOptions.height,
311
+ displayOptions.glass
312
+ );
313
+ viewer.gridHelper.clearCache();
314
+ viewer.gridHelper.update(viewer.getCameraZoom(), true);
315
+ viewer.update(true, true);
316
+ }
317
+ }
318
+
319
+ function handleMessage(message) {
320
+ var data = message;
321
+
322
+ if (data.type === "logo") {
323
+ // The splash lives in the core, so a host asks for it by name and
324
+ // sends only what it alone knows: its theme, its tree width and
325
+ // its modifier keys.
326
+ const splash = logo();
327
+ data = {
328
+ ...splash,
329
+ config: { ...splash.config, ...(data.config || {}) }
330
+ };
331
+ }
332
+
333
+ if (data.type === "data" && data?.data?.shapes?.parts?.length > 0) {
334
+ const timer = new Timer("webView", data.config.timeit);
335
+
336
+ const oldStates = currentStates(viewer);
337
+
338
+ let meshData = data.data;
339
+ let config = data.config;
340
+
341
+ if (config._splash) {
342
+ const displayOptions = getDisplayOptions(config.theme);
343
+ config.zoom = Math.min(
344
+ 1.0,
345
+ displayOptions.cadWidth / displayOptions.height
346
+ );
347
+ // debugLog("logo zoom =", config.zoom);
348
+ }
349
+
350
+ showViewer(meshData, config);
351
+
352
+ // Explicit states win, otherwise the user's prior visibility
353
+ // choices are restored for whatever survived into the new
354
+ // model - in one batched setStates either way, because a
355
+ // per-key loop is a repaint per key and freezes the host.
356
+ restoreStates(viewer, meshData.shapes, oldStates, config.states);
357
+ timer.split("states updated");
358
+
359
+ timer.stop();
360
+ } else if (data.type === "screenshot") {
361
+ var promise = viewer.getImage(data.filename);
362
+ promise.then((result) => {
363
+ send("screenshot", {
364
+ filename: result.task,
365
+ data: result.dataUrl
366
+ });
367
+ });
368
+ } else if (data.type === "set_relative_time") {
369
+ if (viewer) {
370
+ viewer.setRelativeTime(data.value);
371
+ }
372
+ } else if (data.type === "backend_response") {
373
+ viewer.handleBackendResponse(data);
374
+ } else if (data.type === "clear") {
375
+ // Python's `show_clear()`, and `show_all()` when it finds nothing
376
+ // drawable. clear() is a no-op on a viewer that shows nothing, and
377
+ // the stored model goes with the scene - a cleared model must not
378
+ // be resurrectable by anything that re-renders `_meshData`.
379
+ if (viewer != null) {
380
+ viewer.clear();
381
+ _meshData = null;
382
+ _shapes = null;
383
+ }
384
+ // No `init` branch, which was one of two of the same kind and the
385
+ // worse: it called `init(data.paths, data.settings)`, a name this
386
+ // module never defines. Only the extension sends an `init` message,
387
+ // and `viewer.html`'s own listener answers it - by *calling*
388
+ // `createPage`, so this listener does not exist yet when the first one
389
+ // arrives. A second one would have reached here and thrown a
390
+ // ReferenceError. Deleting it also removes the one branch that could
391
+ // not work from a host driving `handleMessage` directly.
392
+ //
393
+ // No `show` branch: nothing in any host sends it, and it was a
394
+ // landmine rather than merely dead - `showViewer()` with
395
+ // no arguments evaluates `getDisplayOptions(config.theme)` against an
396
+ // undefined config and throws before reaching its own null guard, and
397
+ // it assigns `_meshData = undefined` on the way, destroying the stored
398
+ // model so that even a corrected call could not re-show. If a host
399
+ // ever needs to re-render what it already has, that is `showViewer(
400
+ // _meshData, _config)` guarded on `_meshData` being present.
401
+ } else if (data.type === "ui") {
402
+ if (_config["_splash"]) {
403
+ return;
404
+ }
405
+ if (data.config.debug) {
406
+ debugLog("data.config", data.config);
407
+ }
408
+ // One dispatch for every key, shared with every other host, so
409
+ // that a setter cannot exist in one client and not another.
410
+ applyConfig(viewer, data.config, {
411
+ // Only the host knows the other two dimensions, so a
412
+ // viewport key is a resize rather than a setter.
413
+ resize: (key, value) => {
414
+ const displayOptions = getDisplayOptions(data.config.theme);
415
+ const glass =
416
+ data.config.glass !== undefined
417
+ ? data.config.glass
418
+ : displayOptions.glass;
419
+ viewer.resizeCadView(
420
+ key === "cadWidth" ? value : displayOptions.cadWidth,
421
+ key === "treeWidth" ? value : displayOptions.treeWidth,
422
+ key === "height" ? value : displayOptions.height,
423
+ glass
424
+ );
425
+ },
426
+ onUnknown: (key) => {
427
+ debugLog(`ui: no setter for '${key}'`);
428
+ }
429
+ });
430
+ } else if (data.type === "animation") {
431
+ // Explode goes off first: both transform the same objects,
432
+ // and animating an already-displaced model is wrong.
433
+ animate(viewer, data.data, data.config.speed, (action) => {
434
+ console.error(`Unknown animation action: ${action}`);
435
+ });
436
+ }
437
+ }
438
+
439
+ // The window is where two of the three hosts' messages and resizes arrive,
440
+ // and it is not where the third's do: a pane host has no window message of
441
+ // its own and resizes when a splitter moves, so it passes `listen: false`
442
+ // and calls `handleMessage` and `resize` itself. The dispatch above is the
443
+ // same one either way, which is the point - a message branch is a feature,
444
+ // and a host that wrote its own dispatch would have its own feature set.
445
+ if (listen !== false) {
446
+ window.addEventListener("resize", () => resize(), true);
447
+ window.addEventListener("message", (event) => {
448
+ handleMessage(
449
+ typeof event.data === "string" || event.data instanceof String
450
+ ? JSON.parse(event.data)
451
+ : event.data
452
+ );
453
+ });
454
+ }
455
+
456
+ return {
457
+ /** Draw the splash, with whatever the host knows that the core cannot. */
458
+ showSplash(config) {
459
+ const splash = logo();
460
+ showViewer(splash.data, { ...splash.config, ...config });
461
+ },
462
+
463
+ /** The host's theme changed under it - a VS Code colour theme, say. */
464
+ setTheme(next) {
465
+ if (viewer != null) {
466
+ viewer.setTheme(next);
467
+ }
468
+ },
469
+
470
+ /** One message, already parsed. What the window listener above calls. */
471
+ handleMessage,
472
+
473
+ /** The surface changed size. What the resize listener above calls. */
474
+ resize
475
+ };
476
+ }