geometryview 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/README.md +37 -2
- package/bin/geometryview.mjs +65 -0
- package/package.json +42 -4
- package/src/bridge.mjs +77 -0
- package/src/mcp.mjs +73 -0
- package/src/native.mjs +183 -0
- package/src/page.mjs +147 -0
- package/src/tools.mjs +265 -0
- package/src/web.mjs +173 -0
- package/src/websocket.mjs +104 -0
- package/web/LICENSE +21 -0
- package/web/NOTICES.md +49 -0
- package/web/bridge.js +29 -0
- package/web/build/geometryview-web.mjs +2 -0
- package/web/build/geometryview-web.wasm +0 -0
- package/web/chrome.js +541 -0
- package/web/engine.js +453 -0
- package/web/fonts/IBMPlexMono-Medium.ttf +0 -0
- package/web/fonts/IBMPlexMono-Regular.ttf +0 -0
- package/web/fonts/IBMPlexMono-SemiBold.ttf +0 -0
- package/web/fonts/LICENSE.txt +93 -0
- package/web/icons/icon-180.png +0 -0
- package/web/icons/icon-192.png +0 -0
- package/web/icons/icon-32.png +0 -0
- package/web/icons/icon-512.png +0 -0
- package/web/icons.js +50 -0
- package/web/import-worker.js +329 -0
- package/web/index.html +17 -0
- package/web/input.js +255 -0
- package/web/isolation-worker.js +21 -0
- package/web/library-rows.js +120 -0
- package/web/library-store.js +368 -0
- package/web/library.js +306 -0
- package/web/manifest.webmanifest +14 -0
- package/web/occt/geometryview-occt-web.mjs +2 -0
- package/web/occt/geometryview-occt-web.wasm +0 -0
- package/web/occt/geometryview-step-web.mjs +2 -0
- package/web/occt/geometryview-step-web.wasm +0 -0
- package/web/occt-threads/geometryview-step-web.mjs +2 -0
- package/web/occt-threads/geometryview-step-web.wasm +0 -0
- package/web/occt-worker.js +45 -0
- package/web/structure.js +435 -0
- package/web/toolbar-fold.js +49 -0
- package/web/ui.js +134 -0
- package/web/viewer.css +558 -0
- package/web/viewer.js +1163 -0
package/web/viewer.js
ADDED
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
// The universal viewer's page: the engine on a full-window canvas, and the chrome around it, laid
|
|
2
|
+
// out as the Mac app lays out its window.
|
|
3
|
+
//
|
|
4
|
+
// viewer/?model=<fixture> opens <fixtures>/<fixture>.geoblob (fixtures= moves the folder)
|
|
5
|
+
// viewer/?sample=<id> opens a sample from samples/index.json, where the copy has them
|
|
6
|
+
// viewer/?test=idle&label=... runs a scripted check and posts its report (see runTest)
|
|
7
|
+
|
|
8
|
+
import { Actions, Engine, Events, NodeKinds, Tools } from "./engine.js";
|
|
9
|
+
import { ViewportInput } from "./input.js";
|
|
10
|
+
import { StructureCard } from "./structure.js";
|
|
11
|
+
import { LibraryCard, cachedImport, forgetKept, keptFile, keptSource, libraryEntries,
|
|
12
|
+
setCacheVersion } from "./library.js";
|
|
13
|
+
import { keyOf } from "./library-rows.js";
|
|
14
|
+
import { LibraryStore } from "./library-store.js";
|
|
15
|
+
import { DropZone, HoldHint, HoverChip, Look, LookCard, Masthead, SectionCard, SelectionCard,
|
|
16
|
+
ShortcutsSheet, StatusCard, Styles, Toolbar } from "./chrome.js";
|
|
17
|
+
import { el } from "./ui.js";
|
|
18
|
+
|
|
19
|
+
const params = new URLSearchParams(location.search);
|
|
20
|
+
// Errors, kept for the scripted checks' reports.
|
|
21
|
+
const errors = [];
|
|
22
|
+
addEventListener("error", (e) => errors.push(`${e.message} at ${e.filename}:${e.lineno}`));
|
|
23
|
+
addEventListener("unhandledrejection", (e) => errors.push(String(e.reason?.stack ?? e.reason)));
|
|
24
|
+
// Served without the headers threads need, as GitHub Pages serves the page, a service worker of
|
|
25
|
+
// the page's own adds them. The page reloads once, to be served through it, before starting.
|
|
26
|
+
async function isolate() {
|
|
27
|
+
if (self.crossOriginIsolated || !self.isSecureContext || !("serviceWorker" in navigator)) return;
|
|
28
|
+
if (params.has("token") || window.webkit?.messageHandlers?.shell) return;
|
|
29
|
+
try {
|
|
30
|
+
await navigator.serviceWorker.register("isolation-worker.js");
|
|
31
|
+
// Served through it already, but not isolated: this browser can't be, so go on without threads.
|
|
32
|
+
if (navigator.serviceWorker.controller || sessionStorage.getItem("isolation-reload")) return;
|
|
33
|
+
sessionStorage.setItem("isolation-reload", "1");
|
|
34
|
+
await navigator.serviceWorker.ready;
|
|
35
|
+
} catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
location.reload();
|
|
39
|
+
await new Promise(() => {});
|
|
40
|
+
}
|
|
41
|
+
await isolate();
|
|
42
|
+
const canvas = document.getElementById("viewport");
|
|
43
|
+
const phone = /iPhone|iPad|Android/.test(navigator.userAgent) ||
|
|
44
|
+
(navigator.maxTouchPoints > 1 && /Macintosh/.test(navigator.userAgent));
|
|
45
|
+
document.body.classList.toggle("phone", phone);
|
|
46
|
+
|
|
47
|
+
// The Mac window's measurements, in CSS pixels.
|
|
48
|
+
const kEdge = 18, kCardWidth = 300, kCardTop = 72, kCardBottom = 96;
|
|
49
|
+
|
|
50
|
+
const listeners = new Set();
|
|
51
|
+
const waitFor = (kind) => new Promise((resolve) => {
|
|
52
|
+
const listener = (event) => {
|
|
53
|
+
if (event.kind === kind || event.kind === Events.Failed) {
|
|
54
|
+
listeners.delete(listener);
|
|
55
|
+
resolve(event);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
listeners.add(listener);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ---- The engine and the chrome ----------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
// Where the Library's folders and recent files come from.
|
|
64
|
+
const libraryStore = new LibraryStore({ backend: params.get("backend") ?? undefined });
|
|
65
|
+
const status = new StatusCard({ onOpen: () => chooseFile(false), onConnect: () => connectFolder(),
|
|
66
|
+
canConnect: libraryStore.canConnect, phone });
|
|
67
|
+
document.body.append(status.element);
|
|
68
|
+
// The engine may speak while it starts, before the chrome below exists to listen.
|
|
69
|
+
let chromeReady = false;
|
|
70
|
+
|
|
71
|
+
let engine;
|
|
72
|
+
try {
|
|
73
|
+
engine = await Engine.start(canvas, {
|
|
74
|
+
event: handle,
|
|
75
|
+
refine: (token, name, level, definitions) => refineDocument(token, name, level, definitions),
|
|
76
|
+
cancelRefinement: () => cancelRefinement(),
|
|
77
|
+
}, { phone });
|
|
78
|
+
} catch (error) {
|
|
79
|
+
status.showFailure(`This browser cannot run the viewer: ${error.message}${webgpuAdvice()}`);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// What to do where a browser offers no WebGPU and the way to it is known.
|
|
84
|
+
function webgpuAdvice() {
|
|
85
|
+
const agent = navigator.userAgent;
|
|
86
|
+
// Chrome on Linux has no adapter without Vulkan, which is off by default on NVIDIA GPUs at least.
|
|
87
|
+
if (/Linux/.test(agent) && !/Android/.test(agent) && /Chrome\//.test(agent)) {
|
|
88
|
+
return " Chrome on Linux needs Vulkan for WebGPU: turn on chrome://flags/#enable-vulkan, then " +
|
|
89
|
+
"restart Chrome.";
|
|
90
|
+
}
|
|
91
|
+
return navigator.gpu ? "" : " Open it in a current Chrome, Edge or Safari.";
|
|
92
|
+
}
|
|
93
|
+
window.geometryView = engine; // for the console and the shell's checks
|
|
94
|
+
// ?plain leaves out the view cube and scale bar, for pictures.
|
|
95
|
+
if (params.has("plain")) engine.setOverlayShown(false);
|
|
96
|
+
setCacheVersion(engine.cacheVersion);
|
|
97
|
+
// From navigation to an engine ready to draw: the page, the module, the GPU and the overlay's text.
|
|
98
|
+
const startupMs = Math.round(performance.now());
|
|
99
|
+
|
|
100
|
+
let look = Look.saved(engine.defaultLook());
|
|
101
|
+
let perspective = false;
|
|
102
|
+
let names = [];
|
|
103
|
+
const progress = new Map();
|
|
104
|
+
let hovered = null; // { document, node, face }
|
|
105
|
+
// The tree and library card: always open on a desktop, a sheet opened when wanted on a phone.
|
|
106
|
+
let panelOpen = !phone;
|
|
107
|
+
// What the chrome covers of the view, in CSS pixels, as framing keeps clear of it.
|
|
108
|
+
let insets = { left: 0, top: 0, right: 0, bottom: 0 };
|
|
109
|
+
let pointer = null; // { x, y } in CSS pixels, while over the view
|
|
110
|
+
|
|
111
|
+
const masthead = new Masthead();
|
|
112
|
+
const structure = new StructureCard(engine, { onSwitch: (show) => showLibrary(show) });
|
|
113
|
+
const library = new LibraryCard(libraryStore, {
|
|
114
|
+
open: (row, add) => openFromFolder(row, add),
|
|
115
|
+
reopen: (entry, add) => reopenRecent(entry, add),
|
|
116
|
+
choose: () => chooseFile(false),
|
|
117
|
+
connect: () => connectFolder(),
|
|
118
|
+
switch: (show) => showLibrary(show),
|
|
119
|
+
forget: (key) => forgetRecent(key),
|
|
120
|
+
});
|
|
121
|
+
const selection = new SelectionCard((action) => engine.perform(action));
|
|
122
|
+
const lookCard = new LookCard(look, (next) => applyLook(next));
|
|
123
|
+
const sectionCard = new SectionCard((cuts) => engine.setSection(cuts));
|
|
124
|
+
const toolbar = new Toolbar({
|
|
125
|
+
onAction: (action) => engine.perform(action),
|
|
126
|
+
onMode: (mode) => { engine.setSelectionMode(mode); refreshSelection(); },
|
|
127
|
+
onProjection: (persp) => {
|
|
128
|
+
if (persp !== perspective) {
|
|
129
|
+
perspective = persp;
|
|
130
|
+
engine.perform(Actions.ToggleProjection);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
onShortcuts: () => { shortcuts.element.hidden = false; },
|
|
134
|
+
onLook: () => toggleCard(lookCard),
|
|
135
|
+
onSection: () => toggleCard(sectionCard),
|
|
136
|
+
onOpen: () => chooseFile(false),
|
|
137
|
+
onConnect: () => connectFolder(),
|
|
138
|
+
canConnect: libraryStore.canConnect,
|
|
139
|
+
onParts: () => {
|
|
140
|
+
panelOpen = !panelOpen;
|
|
141
|
+
showLibrary(names.length === 0);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
const holdHint = new HoldHint();
|
|
145
|
+
const hoverChip = new HoverChip();
|
|
146
|
+
const dropZone = new DropZone();
|
|
147
|
+
const shortcuts = new ShortcutsSheet();
|
|
148
|
+
const picker = el("input", { type: "file", accept: ".step,.stp,.stl,.geoblob", multiple: true, hidden: true });
|
|
149
|
+
picker.addEventListener("change", () => {
|
|
150
|
+
openFiles([...picker.files], picker.dataset.adding === "1");
|
|
151
|
+
picker.value = "";
|
|
152
|
+
});
|
|
153
|
+
document.body.append(masthead.element, structure.element, library.element, selection.element,
|
|
154
|
+
lookCard.element, sectionCard.element, toolbar.element, toolbar.viewsMenu, toolbar.moreMenu,
|
|
155
|
+
holdHint.element,
|
|
156
|
+
hoverChip.element, dropZone.element, shortcuts.element, picker);
|
|
157
|
+
|
|
158
|
+
new ResizeObserver(() => engine.resize()).observe(canvas);
|
|
159
|
+
new ViewportInput(engine, canvas, {
|
|
160
|
+
onCommand: run,
|
|
161
|
+
onTool: (tool) => {
|
|
162
|
+
holdHint.show(tool);
|
|
163
|
+
canvas.style.cursor = tool === Tools.Select ? "" : "crosshair";
|
|
164
|
+
},
|
|
165
|
+
onPointer: (x, y) => {
|
|
166
|
+
pointer = x === null ? null : { x, y };
|
|
167
|
+
showHover();
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
// ⌘O opens files, ⇧⌘O adds them to what is shown, ⌥⌘O connects a folder: the Mac's keys.
|
|
171
|
+
addEventListener("keydown", (e) => {
|
|
172
|
+
if ((e.metaKey || e.ctrlKey) && e.code === "KeyO") {
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
if (e.altKey) connectFolder();
|
|
175
|
+
else chooseFile(e.shiftKey);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
// A press in the view closes the look and section cards and the views menu, as clicking away
|
|
179
|
+
// does on the Mac.
|
|
180
|
+
canvas.addEventListener("pointerdown", () => {
|
|
181
|
+
closeCards();
|
|
182
|
+
toolbar.viewsMenu.hidden = true;
|
|
183
|
+
toolbar.moreMenu.hidden = true;
|
|
184
|
+
});
|
|
185
|
+
// The toolbar folds what doesn't fit into More: worked out when the window's width changes, and
|
|
186
|
+
// once the fonts its labels are measured in are in. Its room is what viewer.css gives it.
|
|
187
|
+
const fitToolbar = () => toolbar.fit(phone || innerWidth <= 900 ? innerWidth - 16 : innerWidth - 336);
|
|
188
|
+
addEventListener("resize", fitToolbar);
|
|
189
|
+
document.fonts.ready.then(fitToolbar);
|
|
190
|
+
fitToolbar();
|
|
191
|
+
chromeReady = true;
|
|
192
|
+
applyLook(look, { remember: false });
|
|
193
|
+
showEmpty();
|
|
194
|
+
|
|
195
|
+
// ---- Engine events ----------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
function handle(kind, document, node, done, total, message) {
|
|
198
|
+
if (!chromeReady) return;
|
|
199
|
+
const event = { kind, document, node, done, total, message };
|
|
200
|
+
switch (kind) {
|
|
201
|
+
case Events.Loading:
|
|
202
|
+
names[document] = message;
|
|
203
|
+
progress.set(document, 0);
|
|
204
|
+
structure.addDocument(message);
|
|
205
|
+
// The left card stays as the person left it: they may be browsing the Library file by file.
|
|
206
|
+
// The structure card is made ready for when they switch to it.
|
|
207
|
+
structure.showNothing(false);
|
|
208
|
+
status.showLoading(message);
|
|
209
|
+
refreshMasthead();
|
|
210
|
+
updateInsets();
|
|
211
|
+
break;
|
|
212
|
+
case Events.Progress:
|
|
213
|
+
// Only while the document is loading: progress arriving after the scene is ready would
|
|
214
|
+
// leave the bar full.
|
|
215
|
+
if (!progress.has(document)) break;
|
|
216
|
+
progress.set(document, total > 0 ? done / total : 0);
|
|
217
|
+
refreshMasthead();
|
|
218
|
+
break;
|
|
219
|
+
case Events.ModelReady:
|
|
220
|
+
structure.modelReady(document);
|
|
221
|
+
status.hide();
|
|
222
|
+
refreshMasthead();
|
|
223
|
+
toolbar.update({ hasSelection: false, hasModel: true });
|
|
224
|
+
rememberOpened(names[document]);
|
|
225
|
+
break;
|
|
226
|
+
case Events.SceneReady:
|
|
227
|
+
progress.clear();
|
|
228
|
+
status.hide();
|
|
229
|
+
refreshMasthead();
|
|
230
|
+
break;
|
|
231
|
+
case Events.Failed:
|
|
232
|
+
progress.clear();
|
|
233
|
+
refreshMasthead();
|
|
234
|
+
status.showFailure(message);
|
|
235
|
+
break;
|
|
236
|
+
case Events.SelectionChanged:
|
|
237
|
+
structure.reveal(document, node);
|
|
238
|
+
refreshSelection();
|
|
239
|
+
break;
|
|
240
|
+
case Events.HoverChanged:
|
|
241
|
+
hovered = node === 0xFFFFFFFF ? null : { document, node, face: done };
|
|
242
|
+
showHover();
|
|
243
|
+
structure.highlight(document, node);
|
|
244
|
+
break;
|
|
245
|
+
case Events.LooksChanged:
|
|
246
|
+
structure.looksChanged();
|
|
247
|
+
break;
|
|
248
|
+
case Events.VisibilityChanged:
|
|
249
|
+
structure.visibilityChanged(done, total);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
for (const listener of [...listeners]) listener(event);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function refreshSelection() {
|
|
256
|
+
const summary = engine.summary();
|
|
257
|
+
selection.show(summary, structure.tree);
|
|
258
|
+
toolbar.update({ hasSelection: summary.occurrences > 0, hasModel: names.length > 0 });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function refreshMasthead() {
|
|
262
|
+
if (names.length === 0) {
|
|
263
|
+
masthead.hide();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const title = names.length === 1 ? names[0] : `${names.length} files`;
|
|
267
|
+
let parts = 0, bodies = 0;
|
|
268
|
+
for (const d of structure.tree.documents) {
|
|
269
|
+
if (!d.model) continue;
|
|
270
|
+
for (const n of d.model) if (n.kind === NodeKinds.Part) parts += 1;
|
|
271
|
+
bodies += d.model.reduce((most, n) => Math.max(most, n.occurrenceEnd), 0);
|
|
272
|
+
}
|
|
273
|
+
let detail = names.length === 1 ? "" : names.join(", ");
|
|
274
|
+
if (bodies > 0) {
|
|
275
|
+
const count = (n, one, many) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
|
|
276
|
+
const counts = `${count(parts, "part", "parts")} · ${count(bodies, "body", "bodies")}`;
|
|
277
|
+
detail = detail ? `${counts} · ${detail}` : counts;
|
|
278
|
+
} else {
|
|
279
|
+
detail = "Reading…";
|
|
280
|
+
}
|
|
281
|
+
masthead.show(title, detail);
|
|
282
|
+
const fractions = [...progress.values()];
|
|
283
|
+
masthead.setProgress(fractions.length ? fractions.reduce((a, b) => a + b, 0) / fractions.length : null);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function showHover() {
|
|
287
|
+
const tool = canvas.style.cursor === "crosshair";
|
|
288
|
+
if (!hovered || !pointer) {
|
|
289
|
+
hoverChip.show(null);
|
|
290
|
+
if (!tool) canvas.style.cursor = "";
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
hoverChip.show(structure.tree.name(hovered.document, hovered.node), hovered.face, pointer.x, pointer.y);
|
|
294
|
+
if (!tool) canvas.style.cursor = "pointer";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---- Commands ---------------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
function run(name) {
|
|
300
|
+
switch (name) {
|
|
301
|
+
case "fit": return engine.perform(Actions.Fit);
|
|
302
|
+
case "projection":
|
|
303
|
+
perspective = !perspective;
|
|
304
|
+
toolbar.projection.selected = perspective ? 1 : 0;
|
|
305
|
+
return engine.perform(Actions.ToggleProjection);
|
|
306
|
+
case "clear":
|
|
307
|
+
if (!shortcuts.element.hidden) shortcuts.element.hidden = true;
|
|
308
|
+
else if (!lookCard.element.hidden || !sectionCard.element.hidden) closeCards();
|
|
309
|
+
else engine.perform(Actions.ClearSelection);
|
|
310
|
+
return undefined;
|
|
311
|
+
case "isolate": return engine.perform(Actions.Isolate);
|
|
312
|
+
case "showAll": return engine.perform(Actions.ShowAll);
|
|
313
|
+
case "unghostAll": return engine.perform(Actions.UnghostAll);
|
|
314
|
+
case "faces": case "bodies": case "parts": {
|
|
315
|
+
const mode = ["faces", "bodies", "parts"].indexOf(name);
|
|
316
|
+
toolbar.pick.selected = mode;
|
|
317
|
+
engine.setSelectionMode(mode);
|
|
318
|
+
return refreshSelection();
|
|
319
|
+
}
|
|
320
|
+
case "section": return toggleSection();
|
|
321
|
+
case "seeThrough": return engine.perform(Actions.ToggleSeeThrough);
|
|
322
|
+
case "shortcuts":
|
|
323
|
+
shortcuts.element.hidden = !shortcuts.element.hidden;
|
|
324
|
+
return undefined;
|
|
325
|
+
case "undo": return engine.perform(Actions.Undo);
|
|
326
|
+
case "redo": return engine.perform(Actions.Redo);
|
|
327
|
+
default: return undefined;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// S cuts across X at the middle, or, with a cut open, removes every cut.
|
|
332
|
+
function toggleSection() {
|
|
333
|
+
if (sectionCard.isCutting) {
|
|
334
|
+
sectionCard.cuts.forEach((c) => { c.enabled = false; });
|
|
335
|
+
sectionCard.element.hidden = true;
|
|
336
|
+
} else {
|
|
337
|
+
sectionCard.cuts[0].enabled = true;
|
|
338
|
+
sectionCard.element.hidden = false;
|
|
339
|
+
lookCard.element.hidden = true;
|
|
340
|
+
}
|
|
341
|
+
sectionCard.show();
|
|
342
|
+
engine.setSection(sectionCard.cuts);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function toggleCard(card) {
|
|
346
|
+
const other = card === lookCard ? sectionCard : lookCard;
|
|
347
|
+
card.element.hidden = !card.element.hidden;
|
|
348
|
+
other.element.hidden = true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function closeCards() {
|
|
352
|
+
lookCard.element.hidden = true;
|
|
353
|
+
sectionCard.element.hidden = true;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Shows `next`, and remembers it unless it is the look the page started with: a look saved only
|
|
357
|
+
// once the person changes it keeps following the shared default until then.
|
|
358
|
+
function applyLook(next, { remember = true } = {}) {
|
|
359
|
+
look = next;
|
|
360
|
+
if (remember) look.save();
|
|
361
|
+
engine.setLook(look.engineLook);
|
|
362
|
+
const dark = Styles[look.style].onDark;
|
|
363
|
+
masthead.setOnDark(dark);
|
|
364
|
+
document.body.classList.toggle("on-dark", dark);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---- Layout -----------------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
// The tree card covers the left, the masthead the top and the toolbar the bottom: framing keeps
|
|
370
|
+
// the model in the rest.
|
|
371
|
+
function updateInsets() {
|
|
372
|
+
const covered = !phone && (!structure.element.hidden || !library.element.hidden);
|
|
373
|
+
insets = { left: covered ? kEdge + kCardWidth : 0, top: kCardTop - 16, right: 0, bottom: kCardBottom - 16 };
|
|
374
|
+
engine.setInsets(insets.left, insets.top, insets.right, insets.bottom);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Nothing open: the invitation, and on a desktop the Library beside it.
|
|
378
|
+
function showEmpty() {
|
|
379
|
+
status.showInvitation();
|
|
380
|
+
structure.element.hidden = true;
|
|
381
|
+
library.element.hidden = true;
|
|
382
|
+
libraryEntries().then((kept) => library.refresh(kept)).then(() => {
|
|
383
|
+
if (names.length === 0 && panelOpen) showLibrary(true);
|
|
384
|
+
});
|
|
385
|
+
updateInsets();
|
|
386
|
+
toolbar.update({ hasSelection: false, hasModel: false });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// The Library card, or the structure card; the latter says so when nothing is open.
|
|
390
|
+
function showLibrary(show) {
|
|
391
|
+
library.element.hidden = !show || !panelOpen;
|
|
392
|
+
structure.element.hidden = show || !panelOpen;
|
|
393
|
+
structure.showNothing(names.filter(Boolean).length === 0);
|
|
394
|
+
library.switcher.selected = show ? 0 : 1;
|
|
395
|
+
structure.switcher.selected = show ? 0 : 1;
|
|
396
|
+
if (show) library.refresh();
|
|
397
|
+
updateInsets();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---- Files ------------------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
// Where each file shown came from, by name, for Recent: { handle, root, path, where }.
|
|
403
|
+
const sources = new Map();
|
|
404
|
+
|
|
405
|
+
// Opens files from the browser's own picker, which gives handles Recent can read again, or else
|
|
406
|
+
// from the page's file input.
|
|
407
|
+
async function chooseFile(adding) {
|
|
408
|
+
let picked;
|
|
409
|
+
try {
|
|
410
|
+
picked = await libraryStore.folders.pickFiles();
|
|
411
|
+
} catch (error) {
|
|
412
|
+
status.showFailure(error.message);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (picked === undefined) {
|
|
416
|
+
picker.dataset.adding = adding ? "1" : "0";
|
|
417
|
+
picker.click();
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (!picked) return;
|
|
421
|
+
for (const { file, handle } of picked) sources.set(file.name, { handle, where: file.name });
|
|
422
|
+
await openFiles(picked.map((p) => p.file), adding);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Asks for a folder and shows it in the Library, open.
|
|
426
|
+
async function connectFolder() {
|
|
427
|
+
if (!libraryStore.canConnect) return;
|
|
428
|
+
try {
|
|
429
|
+
const id = await libraryStore.folders.connect();
|
|
430
|
+
if (id === null || id === undefined) return;
|
|
431
|
+
await showConnected([id]);
|
|
432
|
+
} catch (error) {
|
|
433
|
+
status.showFailure(`That folder could not be connected. ${error.message}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function showConnected(ids) {
|
|
438
|
+
await library.refresh();
|
|
439
|
+
for (const id of ids) library.expand(id);
|
|
440
|
+
if (!phone) panelOpen = true;
|
|
441
|
+
showLibrary(true);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// A file from a connected folder.
|
|
445
|
+
async function openFromFolder(row, add) {
|
|
446
|
+
try {
|
|
447
|
+
const file = await libraryStore.folders.file(row.root, row.path);
|
|
448
|
+
const handle = await libraryStore.folders.fileHandle?.(row.root, row.path).catch(() => null);
|
|
449
|
+
sources.set(file.name, { handle, root: row.root, path: row.path,
|
|
450
|
+
where: `${library.folderName(row.root)}/${row.path}` });
|
|
451
|
+
await openFiles([file], add);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
status.showFailure(`${row.name} could not be read. ${error.message}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// A recent file: from where it was, or as it was kept here.
|
|
458
|
+
async function reopenRecent(entry, add) {
|
|
459
|
+
const got = await libraryStore.reopen(entry, library.readyFolders).catch(() => null);
|
|
460
|
+
if (!got) {
|
|
461
|
+
status.showFailure(`${entry.name} is no longer where it was.`);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
sources.set(entry.name, { handle: entry.handle, root: entry.root, path: entry.path, where: entry.where });
|
|
465
|
+
if (got.file) await openFiles([got.file], add);
|
|
466
|
+
else if (got.cache) openCache(got.cache, entry.name, add);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Puts what opened at the top of Recent. Kept imports of files that fall off are deleted, unless
|
|
470
|
+
// they are open, so what this browser keeps stays as Recent names.
|
|
471
|
+
async function rememberOpened(name) {
|
|
472
|
+
if (!name) return;
|
|
473
|
+
const source = sources.get(name) ?? {};
|
|
474
|
+
const entry = { key: name, name, where: source.where ?? "Kept in this browser", openedAt: Date.now() };
|
|
475
|
+
for (const field of ["handle", "root", "path"]) if (source[field]) entry[field] = source[field];
|
|
476
|
+
try {
|
|
477
|
+
const dropped = await libraryStore.remember(entry);
|
|
478
|
+
for (const old of dropped) if (!names.includes(old.key)) await forgetKept(old.key);
|
|
479
|
+
} catch {
|
|
480
|
+
// Not remembered: the file is open all the same.
|
|
481
|
+
}
|
|
482
|
+
library.setOpen(openKeys());
|
|
483
|
+
await library.refresh(); // shows it in Recent at once, as the Mac does
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function forgetRecent(key) {
|
|
487
|
+
await libraryStore.forget(key);
|
|
488
|
+
if (!names.includes(key)) await forgetKept(key);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// The Library's keys of what is shown: names, and paths in connected folders.
|
|
492
|
+
function openKeys() {
|
|
493
|
+
const keys = [];
|
|
494
|
+
for (const name of names.filter(Boolean)) {
|
|
495
|
+
keys.push(name);
|
|
496
|
+
const source = sources.get(name);
|
|
497
|
+
if (source?.root) keys.push(keyOf(source.root, source.path));
|
|
498
|
+
}
|
|
499
|
+
return keys;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function forget() {
|
|
503
|
+
names = [];
|
|
504
|
+
progress.clear();
|
|
505
|
+
structure.reset();
|
|
506
|
+
selection.element.hidden = true;
|
|
507
|
+
library.setOpen([]);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function openCache(source, name, add) {
|
|
511
|
+
if (!add) forget();
|
|
512
|
+
engine.open(source, name, { add });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function openFiles(files, adding) {
|
|
516
|
+
for (const [index, file] of files.entries()) {
|
|
517
|
+
const add = adding || index > 0;
|
|
518
|
+
if (/\.geoblob$/i.test(file.name)) {
|
|
519
|
+
openCache(file, file.name, add);
|
|
520
|
+
} else if (/\.(step|stp|stl)$/i.test(file.name)) {
|
|
521
|
+
await importFile(file, add);
|
|
522
|
+
} else {
|
|
523
|
+
status.showFailure(`${file.name} is not a STEP, STL or cache file.`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---- Importing --------------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
// The importer runs in a worker, with its own pool of OCCT workers, one file at a time. Phones
|
|
531
|
+
// get two OCCT workers, as P2 measured best; desktops one per core, less two for the page.
|
|
532
|
+
let importer = null;
|
|
533
|
+
let importing = Promise.resolve();
|
|
534
|
+
const stepSources = new Map(); // STEP files imported this visit, by name, for refinement
|
|
535
|
+
const importWorkers = phone ? 2 : Math.max(2, Math.min(12, (navigator.hardwareConcurrency ?? 4) - 2));
|
|
536
|
+
|
|
537
|
+
// What importing an STL file takes at its peak. A binary file, read in pieces, is never held
|
|
538
|
+
// whole, and the importer's heap stays under 300 MB and 115 bytes a triangle: 0.46 GB for 2
|
|
539
|
+
// million, 0.87 GB for 5 and 1.27-1.37 GB for 10. Any other is read whole, and is reckoned as it
|
|
540
|
+
// was measured before: 250 bytes a triangle, with the file. A phone stops a page well before 2 GB,
|
|
541
|
+
// so there a file that would pass about 1.2 GB is refused, with a way forward, rather than lost
|
|
542
|
+
// midway.
|
|
543
|
+
function stlImportBytes(file) {
|
|
544
|
+
const triangles = (file.size - 84) / 50;
|
|
545
|
+
if (Number.isInteger(triangles) && triangles > 0) return 300 * 1024 ** 2 + triangles * 115;
|
|
546
|
+
return file.size + Math.max(0, triangles) * 250;
|
|
547
|
+
}
|
|
548
|
+
const kPhoneImportBudget = 1.2 * 1024 ** 3;
|
|
549
|
+
|
|
550
|
+
function importFile(file, add) {
|
|
551
|
+
if (!/\.stl$/i.test(file.name)) stepSources.set(file.name, file);
|
|
552
|
+
importing = importing.then(async () => {
|
|
553
|
+
const cached = await cachedImport(file);
|
|
554
|
+
if (cached) {
|
|
555
|
+
openCache(cached, file.name, add);
|
|
556
|
+
return { type: "cached" };
|
|
557
|
+
}
|
|
558
|
+
if (phone && /\.stl$/i.test(file.name) && stlImportBytes(file) > kPhoneImportBudget) {
|
|
559
|
+
const message = `${file.name} is too large to import on a phone. Import it on a computer, ` +
|
|
560
|
+
"then open its cache file here.";
|
|
561
|
+
status.showFailure(message);
|
|
562
|
+
return { type: "failed", message };
|
|
563
|
+
}
|
|
564
|
+
// STEP files go to the long-lived importer, whose OCCT pool stays warm; an STL file to one
|
|
565
|
+
// of its own, ended afterwards, which frees its threads and memory.
|
|
566
|
+
const stl = /\.stl$/i.test(file.name);
|
|
567
|
+
const worker = stl ? new Worker(new URL("./import-worker.js", import.meta.url), { type: "module" })
|
|
568
|
+
: (importer ??= new Worker(new URL("./import-worker.js", import.meta.url), { type: "module" }));
|
|
569
|
+
// A STEP file's document opens at once and fills as its segments are written, as on the Mac;
|
|
570
|
+
// an STL file's single segment comes at the end, so it opens from the library when done.
|
|
571
|
+
let token = null, document = -1, shown = false;
|
|
572
|
+
if (!stl) {
|
|
573
|
+
if (!add) forget();
|
|
574
|
+
token = await engine.openImport(file.name, { add });
|
|
575
|
+
document = names.length - 1;
|
|
576
|
+
}
|
|
577
|
+
status.showImporting(file.name, 0, 0);
|
|
578
|
+
const result = await new Promise((resolve) => {
|
|
579
|
+
worker.onmessage = ({ data }) => {
|
|
580
|
+
if (data.type === "segment") {
|
|
581
|
+
if (token !== null && engine.importSegment(token, data.bytes) && !shown) {
|
|
582
|
+
shown = true; // something to look at: the progress moves to the masthead
|
|
583
|
+
status.hide();
|
|
584
|
+
}
|
|
585
|
+
} else if (data.type === "progress") {
|
|
586
|
+
if (shown) {
|
|
587
|
+
if (progress.has(document)) progress.set(document, data.total > 0 ? data.done / data.total : 0);
|
|
588
|
+
refreshMasthead();
|
|
589
|
+
} else {
|
|
590
|
+
status.showImporting(file.name, data.done, data.total);
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
resolve(data);
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
worker.onerror = (event) => resolve({ type: "failed", message: event.message ?? "The importer failed." });
|
|
597
|
+
worker.postMessage({ type: "import", file, kind: stl ? "stl" : "step", name: file.name,
|
|
598
|
+
workers: importWorkers, threads: stl, progressive: !stl });
|
|
599
|
+
});
|
|
600
|
+
if (stl) worker.terminate();
|
|
601
|
+
lastImport = result;
|
|
602
|
+
if (result.type !== "done") {
|
|
603
|
+
if (token !== null) engine.finishImport(token, result.message);
|
|
604
|
+
else status.showFailure(result.message);
|
|
605
|
+
return result;
|
|
606
|
+
}
|
|
607
|
+
if (token !== null) engine.finishImport(token);
|
|
608
|
+
else openCache(await keptFile(file.name), file.name, add);
|
|
609
|
+
return result;
|
|
610
|
+
});
|
|
611
|
+
return importing;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ---- Refinement --------------------------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
// The engine asks for finer geometry where the settled view is close, as on the Mac: the STEP
|
|
617
|
+
// file's definitions it names are imported again at a finer level, and their segments join the
|
|
618
|
+
// document. The file is the one imported this visit, or the one the library kept beside its cache.
|
|
619
|
+
// Refinements wait their turn behind imports, in the same worker and its warm OCCT pool.
|
|
620
|
+
let refinement = null;
|
|
621
|
+
|
|
622
|
+
function refineDocument(token, name, level, definitions) {
|
|
623
|
+
const job = { cancelled: false };
|
|
624
|
+
refinement = job;
|
|
625
|
+
const asked = performance.now();
|
|
626
|
+
importing = importing.then(async () => {
|
|
627
|
+
let ok = false;
|
|
628
|
+
const started = performance.now();
|
|
629
|
+
let note = "";
|
|
630
|
+
let made = null, segments = 0, refused = 0;
|
|
631
|
+
try {
|
|
632
|
+
const file = job.cancelled ? null : stepSources.get(name) ?? await keptSource(name);
|
|
633
|
+
if (!file) return;
|
|
634
|
+
importer ??= new Worker(new URL("./import-worker.js", import.meta.url), { type: "module" });
|
|
635
|
+
const result = await new Promise((resolve) => {
|
|
636
|
+
importer.onmessage = ({ data }) => {
|
|
637
|
+
if (data.type === "segment") {
|
|
638
|
+
segments += 1;
|
|
639
|
+
if (!job.cancelled && !engine.refineSegment(token, data.bytes)) refused += 1;
|
|
640
|
+
} else if (data.type !== "progress") {
|
|
641
|
+
resolve(data);
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
importer.onerror = (event) => resolve({ type: "failed", message: event.message });
|
|
645
|
+
importer.postMessage({ type: "refine", file, level, definitions, workers: importWorkers });
|
|
646
|
+
});
|
|
647
|
+
ok = result.type === "done";
|
|
648
|
+
note = result.message ?? "";
|
|
649
|
+
if (ok) {
|
|
650
|
+
made = { triangles: result.triangles, failedFaces: result.failedFaces,
|
|
651
|
+
coarserFaces: result.coarserFaces, crashedWorkers: result.crashedWorkers };
|
|
652
|
+
}
|
|
653
|
+
// The view keeps the geometry it has, so a failure is only worth a note for diagnosing.
|
|
654
|
+
if (!ok) console.warn(`Refining ${name} to level ${level} failed: ${result.message}`);
|
|
655
|
+
} finally {
|
|
656
|
+
refinements.push({ name, level, definitions: definitions.length, waitedMs: Math.round(started - asked),
|
|
657
|
+
ms: Math.round(performance.now() - started), ok, cancelled: job.cancelled, note,
|
|
658
|
+
made, segments, refused });
|
|
659
|
+
if (!job.cancelled) engine.finishRefinement(token, ok);
|
|
660
|
+
if (refinement === job) refinement = null;
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Each refinement job, as the scripted check reports them.
|
|
666
|
+
const refinements = [];
|
|
667
|
+
|
|
668
|
+
function cancelRefinement() {
|
|
669
|
+
if (refinement) refinement.cancelled = true;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
let lastImport = null;
|
|
673
|
+
let importStarted = 0;
|
|
674
|
+
|
|
675
|
+
// Removes a kept import, so the next open imports again.
|
|
676
|
+
const forgetImport = (name) => forgetKept(name);
|
|
677
|
+
|
|
678
|
+
// Dropping files: holding ⌘ adds them to what is shown.
|
|
679
|
+
let dragDepth = 0;
|
|
680
|
+
addEventListener("dragenter", (e) => {
|
|
681
|
+
if (![...e.dataTransfer.types].includes("Files")) return;
|
|
682
|
+
e.preventDefault();
|
|
683
|
+
dragDepth += 1;
|
|
684
|
+
dropZone.element.hidden = false;
|
|
685
|
+
});
|
|
686
|
+
addEventListener("dragover", (e) => e.preventDefault());
|
|
687
|
+
addEventListener("dragleave", () => {
|
|
688
|
+
dragDepth = Math.max(0, dragDepth - 1);
|
|
689
|
+
if (dragDepth === 0) dropZone.element.hidden = true;
|
|
690
|
+
});
|
|
691
|
+
// Files open; folders connect. The drop's items are read at once, as browsers require.
|
|
692
|
+
addEventListener("drop", async (e) => {
|
|
693
|
+
e.preventDefault();
|
|
694
|
+
dragDepth = 0;
|
|
695
|
+
dropZone.element.hidden = true;
|
|
696
|
+
const add = e.metaKey || e.ctrlKey;
|
|
697
|
+
const { files, connected } = await libraryStore.adopt(e.dataTransfer);
|
|
698
|
+
if (connected.length > 0) await showConnected(connected);
|
|
699
|
+
for (const { file, handle } of files) if (handle) sources.set(file.name, { handle, where: file.name });
|
|
700
|
+
if (files.length > 0) await openFiles(files.map((f) => f.file), add);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
// The fixtures are beside the page when served from the build, and in the spike's folder in the
|
|
704
|
+
// iOS shell, which passes fixtures=/spike/fixtures/.
|
|
705
|
+
const model = params.get("model") ?? params.get("import");
|
|
706
|
+
const fixtures = params.get("fixtures") ?? "fixtures/";
|
|
707
|
+
if (params.has("import")) {
|
|
708
|
+
// A fixture imported as a dropped file would be: import=step/ple120.step.
|
|
709
|
+
const path = params.get("import");
|
|
710
|
+
const response = await fetch(`${fixtures}${path}`);
|
|
711
|
+
const file = new File([await response.blob()], path.split("/").pop(), { lastModified: 1 });
|
|
712
|
+
if (params.has("fresh")) await forgetImport(file.name);
|
|
713
|
+
importStarted = performance.now();
|
|
714
|
+
importFile(file, false);
|
|
715
|
+
} else if (model) {
|
|
716
|
+
openCache(`${fixtures}${model}.geoblob`, `${model}.geoblob`, false);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ---- Samples ---------------------------------------------------------------------------------
|
|
720
|
+
|
|
721
|
+
// A hosted copy carries models to try, listed in samples/index.json beside the page. Served by the
|
|
722
|
+
// launcher or the iOS shell there are none, and nothing is offered.
|
|
723
|
+
const samples = fetch("samples/index.json")
|
|
724
|
+
.then((response) => (response.ok ? response.json() : { samples: [] }))
|
|
725
|
+
.then((index) => index.samples ?? [], () => []);
|
|
726
|
+
samples.then((list) => status.setSamples(list, (sample) => openSample(sample)));
|
|
727
|
+
|
|
728
|
+
// Downloads a sample, saying how far it has got, then opens it as a file the person chose.
|
|
729
|
+
async function openSample(sample) {
|
|
730
|
+
try {
|
|
731
|
+
const response = await fetch(`samples/${sample.file}`);
|
|
732
|
+
if (!response.ok) throw new Error(`the server answered ${response.status}`);
|
|
733
|
+
// The index's size, since a compressed response's length is not the file's.
|
|
734
|
+
const total = sample.size || Number(response.headers.get("Content-Length"));
|
|
735
|
+
const chunks = [];
|
|
736
|
+
let done = 0, shown = 0;
|
|
737
|
+
for (const reader = response.body.getReader(); ;) {
|
|
738
|
+
const { value, done: finished } = await reader.read();
|
|
739
|
+
if (finished) break;
|
|
740
|
+
chunks.push(value);
|
|
741
|
+
done += value.byteLength;
|
|
742
|
+
if (performance.now() - shown > 100) {
|
|
743
|
+
status.showDownloading(sample.file, done, total);
|
|
744
|
+
shown = performance.now();
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
await importFile(new File(chunks, sample.file, { lastModified: 1 }), false);
|
|
748
|
+
} catch (error) {
|
|
749
|
+
status.showFailure(`${sample.name} could not be downloaded: ${error.message}.`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (params.has("sample")) {
|
|
754
|
+
const sample = (await samples).find((each) => each.id === params.get("sample"));
|
|
755
|
+
if (sample) openSample(sample);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// In the iOS shell: documents the person opens from Files or the share sheet arrive here.
|
|
759
|
+
window.geometryViewShell = {
|
|
760
|
+
async open(url, name, lastModified) {
|
|
761
|
+
if (/\.geoblob$/i.test(name)) {
|
|
762
|
+
openCache(url, name, false);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
status.showLoading(name);
|
|
766
|
+
const response = await fetch(url);
|
|
767
|
+
if (!response.ok) {
|
|
768
|
+
status.showFailure(`${name} could not be read.`);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
await importFile(new File([await response.blob()], name, { lastModified }), false);
|
|
772
|
+
},
|
|
773
|
+
};
|
|
774
|
+
window.webkit?.messageHandlers?.shell?.postMessage({ ready: true });
|
|
775
|
+
|
|
776
|
+
// Opened by the launcher: take the AI app's requests.
|
|
777
|
+
if (params.has("token")) {
|
|
778
|
+
const { connectBridge } = await import("./bridge.js");
|
|
779
|
+
connectBridge(params.get("token"), bridgeHandlers());
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (params.get("test") === "import") runImportTest().catch((error) => post({ error: error.message }));
|
|
783
|
+
else if (params.get("test") === "library") runLibraryTest().catch((error) => post({ error: error.message }));
|
|
784
|
+
else if (params.get("test") === "refine") runRefineTest().catch((error) => post({ error: error.message }));
|
|
785
|
+
else if (params.has("test")) runTest().catch((error) => post({ error: error.message }));
|
|
786
|
+
|
|
787
|
+
// ---- The launcher's bridge ---------------------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
// What the AI app's tools ask of the page, when the launcher opened it. Each answers with plain
|
|
790
|
+
// data; a picture comes back as a PNG in base64.
|
|
791
|
+
function bridgeHandlers() {
|
|
792
|
+
const kinds = ["assembly", "part", "body"];
|
|
793
|
+
let isolatedByRequest = false;
|
|
794
|
+
|
|
795
|
+
const describe = () => {
|
|
796
|
+
const info = engine.sceneInfo();
|
|
797
|
+
let assemblies = 0, parts = 0, bodies = 0;
|
|
798
|
+
for (const d of structure.tree.documents) {
|
|
799
|
+
if (!d.model) continue;
|
|
800
|
+
for (const n of d.model) {
|
|
801
|
+
if (n.kind === NodeKinds.Assembly) assemblies += 1;
|
|
802
|
+
else if (n.kind === NodeKinds.Part) parts += 1;
|
|
803
|
+
}
|
|
804
|
+
bodies += d.model.reduce((most, n) => Math.max(most, n.occurrenceEnd), 0);
|
|
805
|
+
}
|
|
806
|
+
return { names: names.filter(Boolean), assemblies, parts, bodies, faces: info.faces,
|
|
807
|
+
triangles: info.triangles, min: info.min, max: info.max };
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
// A style and colours, as the look card numbers them; either may be left as it is.
|
|
811
|
+
const restyle = (style, colour) => {
|
|
812
|
+
if (style === undefined && colour === undefined) return;
|
|
813
|
+
const next = Object.assign(new Look(), look);
|
|
814
|
+
if (style !== undefined) next.choose(style);
|
|
815
|
+
if (colour !== undefined) next.colour = colour;
|
|
816
|
+
look = next;
|
|
817
|
+
lookCard.show(next);
|
|
818
|
+
engine.setLook(next.engineLook);
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
// Shows alone the parts and assemblies whose names contain one of `isolate`, framed, after
|
|
822
|
+
// undoing what an earlier request isolated. Returns how many it found. Only the outermost of
|
|
823
|
+
// nested matches is selected: "rotor" finds the rotor assembly and its rotor_core, and selecting
|
|
824
|
+
// the core again, inside the selected assembly, would take it out.
|
|
825
|
+
const showAlone = (isolate) => {
|
|
826
|
+
if (isolate.length > 0 || isolatedByRequest) engine.perform(Actions.ShowAll);
|
|
827
|
+
isolatedByRequest = false;
|
|
828
|
+
if (isolate.length === 0) return 0;
|
|
829
|
+
const wanted = isolate.map((n) => n.toLowerCase());
|
|
830
|
+
engine.perform(Actions.ClearSelection);
|
|
831
|
+
let found = 0;
|
|
832
|
+
structure.tree.documents.forEach((d, document) => {
|
|
833
|
+
let insideMatch = 0; // nodes before this one are inside the last match
|
|
834
|
+
d.model?.forEach((n, node) => {
|
|
835
|
+
if (node < insideMatch || n.kind === NodeKinds.Body) return;
|
|
836
|
+
if (wanted.some((w) => n.name.toLowerCase().includes(w))) {
|
|
837
|
+
engine.selectNode(document, node, true);
|
|
838
|
+
found += 1;
|
|
839
|
+
insideMatch = n.end;
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
if (found === 0) throw new Error(`No part's name contains ${isolate.join(" or ")}.`);
|
|
844
|
+
engine.perform(Actions.Isolate);
|
|
845
|
+
engine.perform(Actions.ClearSelection);
|
|
846
|
+
isolatedByRequest = true;
|
|
847
|
+
return found;
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
const toBase64 = (bytes) => {
|
|
851
|
+
let text = "";
|
|
852
|
+
for (let at = 0; at < bytes.length; at += 0x8000) text += String.fromCharCode(...bytes.subarray(at, at + 0x8000));
|
|
853
|
+
return btoa(text);
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
return {
|
|
857
|
+
async open({ url, name, add = false, lastModified }) {
|
|
858
|
+
const ready = waitFor(Events.SceneReady);
|
|
859
|
+
if (/\.geoblob$/i.test(name)) {
|
|
860
|
+
openCache(url, name, add);
|
|
861
|
+
} else {
|
|
862
|
+
const response = await fetch(url);
|
|
863
|
+
if (!response.ok) throw new Error(`The launcher could not serve ${name}.`);
|
|
864
|
+
const file = new File([await response.blob()], name, { lastModified: lastModified ?? Date.now() });
|
|
865
|
+
const result = await importFile(file, add);
|
|
866
|
+
if (result.type === "failed") throw new Error(result.message);
|
|
867
|
+
}
|
|
868
|
+
const event = await ready;
|
|
869
|
+
if (event.kind === Events.Failed) throw new Error(event.message);
|
|
870
|
+
isolatedByRequest = false;
|
|
871
|
+
return describe();
|
|
872
|
+
},
|
|
873
|
+
|
|
874
|
+
async describe() {
|
|
875
|
+
if (names.length === 0) throw new Error("Nothing is open in the viewer.");
|
|
876
|
+
return describe();
|
|
877
|
+
},
|
|
878
|
+
|
|
879
|
+
async structure({ document = 0 }) {
|
|
880
|
+
const model = engine.model(document);
|
|
881
|
+
if (!model) throw new Error("Nothing is open in the viewer.");
|
|
882
|
+
return model.map((n) => ({ parent: n.parent, kind: kinds[n.kind] ?? "body", name: n.name,
|
|
883
|
+
bodies: [n.firstOccurrence, n.occurrenceEnd] }));
|
|
884
|
+
},
|
|
885
|
+
|
|
886
|
+
// A picture as the AI app asked: a direction, a style and colours, parts shown alone, and
|
|
887
|
+
// cuts. The viewer shows it too, so the person sees what the AI looked at.
|
|
888
|
+
// Some parts shown alone, and a style and colours, as the AI app asked: the view stays the
|
|
889
|
+
// person's to turn.
|
|
890
|
+
async show({ style, colour, isolate = [] }) {
|
|
891
|
+
if (names.length === 0) throw new Error("Nothing is open in the viewer.");
|
|
892
|
+
restyle(style, colour);
|
|
893
|
+
return { ...describe(), isolated: showAlone(isolate) };
|
|
894
|
+
},
|
|
895
|
+
|
|
896
|
+
async render({ azimuth = 35, elevation = 25, perspective: persp = false, style, colour,
|
|
897
|
+
isolate = [], section = [], width = 1200, height = 800 }) {
|
|
898
|
+
if (names.length === 0) throw new Error("Nothing is open in the viewer.");
|
|
899
|
+
restyle(style, colour);
|
|
900
|
+
|
|
901
|
+
sectionCard.cuts.forEach((cut) => { cut.enabled = false; });
|
|
902
|
+
for (const cut of section) {
|
|
903
|
+
const axis = "xyz".indexOf(cut.axis);
|
|
904
|
+
if (axis >= 0) Object.assign(sectionCard.cuts[axis], { enabled: true, position: cut.at, flip: Boolean(cut.flip) });
|
|
905
|
+
}
|
|
906
|
+
sectionCard.show();
|
|
907
|
+
engine.setSection(sectionCard.cuts);
|
|
908
|
+
|
|
909
|
+
showAlone(isolate);
|
|
910
|
+
|
|
911
|
+
perspective = persp;
|
|
912
|
+
toolbar.projection.selected = persp ? 1 : 0;
|
|
913
|
+
engine.setView(azimuth, elevation, persp);
|
|
914
|
+
const image = await engine.snapshot();
|
|
915
|
+
if (!image) {
|
|
916
|
+
const reason = engine.lastError();
|
|
917
|
+
throw new Error(`The viewer could not draw a picture.${reason ? ` ${reason}` : ""}`);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// The part of the view the cards leave clear, fitted into the size asked for.
|
|
921
|
+
const scale = engine.scale;
|
|
922
|
+
const x = Math.round(insets.left * scale), y = Math.round(insets.top * scale);
|
|
923
|
+
const w = image.width - x - Math.round(insets.right * scale);
|
|
924
|
+
const h = image.height - y - Math.round(insets.bottom * scale);
|
|
925
|
+
const whole = new OffscreenCanvas(image.width, image.height);
|
|
926
|
+
whole.getContext("2d").putImageData(
|
|
927
|
+
new ImageData(new Uint8ClampedArray(image.pixels.buffer), image.width, image.height), 0, 0);
|
|
928
|
+
const picture = new OffscreenCanvas(width, height);
|
|
929
|
+
const context = picture.getContext("2d");
|
|
930
|
+
// Letterboxed with the picture's own background, from its corner.
|
|
931
|
+
const corner = (y * image.width + x) * 4;
|
|
932
|
+
context.fillStyle = `rgb(${image.pixels[corner]}, ${image.pixels[corner + 1]}, ${image.pixels[corner + 2]})`;
|
|
933
|
+
context.fillRect(0, 0, width, height);
|
|
934
|
+
const fit = Math.min(width / w, height / h);
|
|
935
|
+
context.drawImage(whole, x, y, w, h, (width - w * fit) / 2, (height - h * fit) / 2, w * fit, h * fit);
|
|
936
|
+
const png = new Uint8Array(await (await picture.convertToBlob({ type: "image/png" })).arrayBuffer());
|
|
937
|
+
return { png: toBase64(png), width, height };
|
|
938
|
+
},
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ---- Scripted checks --------------------------------------------------------------------------
|
|
943
|
+
|
|
944
|
+
function testLabel() {
|
|
945
|
+
// A report's name is a file's, so an imported fixture's folder joins it with a dash.
|
|
946
|
+
const name = (model ?? params.get("file") ?? "none").replaceAll("/", "-");
|
|
947
|
+
return `${params.get("label") ?? "unlabelled"}-p4-${params.get("test")}-${name}`;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async function post(report) {
|
|
951
|
+
const body = JSON.stringify({ label: testLabel(), userAgent: navigator.userAgent, devicePixelRatio,
|
|
952
|
+
errors, ...report });
|
|
953
|
+
const shell = window.webkit?.messageHandlers?.census;
|
|
954
|
+
if (shell) shell.postMessage(body);
|
|
955
|
+
else await fetch("/report", { method: "POST", body }).catch(() => {});
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
959
|
+
|
|
960
|
+
// The view, as a PNG the server keeps beside the fixtures.
|
|
961
|
+
async function postSnapshot() {
|
|
962
|
+
const image = await engine.snapshot();
|
|
963
|
+
if (!image || window.webkit?.messageHandlers?.census) return;
|
|
964
|
+
const picture = new OffscreenCanvas(image.width, image.height);
|
|
965
|
+
picture.getContext("2d").putImageData(
|
|
966
|
+
new ImageData(new Uint8ClampedArray(image.pixels.buffer), image.width, image.height), 0, 0);
|
|
967
|
+
const png = await picture.convertToBlob({ type: "image/png" });
|
|
968
|
+
await fetch(`/png?label=${encodeURIComponent(testLabel())}`, { method: "POST", body: png }).catch(() => {});
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Imports a fixture, opens what it made, and reports how long each took and what was made.
|
|
972
|
+
async function runImportTest() {
|
|
973
|
+
// An import the page refuses, or that fails in its worker, never reaches the engine.
|
|
974
|
+
const refused = importing.then((result) => result?.type === "failed" ? result : new Promise(() => {}));
|
|
975
|
+
const ready = await Promise.race([waitFor(Events.SceneReady), refused]);
|
|
976
|
+
if (ready.type === "failed") {
|
|
977
|
+
await post({ failed: ready.message, workers: importWorkers });
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (ready.kind === Events.Failed) throw new Error(ready.message);
|
|
981
|
+
const totalMs = performance.now() - importStarted;
|
|
982
|
+
await sleep(1500);
|
|
983
|
+
const stats = engine.stats();
|
|
984
|
+
if (!params.has("nosnapshot")) await postSnapshot();
|
|
985
|
+
await post({ totalMs: Math.round(totalMs), import: lastImport, cached: lastImport === null,
|
|
986
|
+
workers: importWorkers, triangles: stats.triangles, bufferMB: stats.bufferMB,
|
|
987
|
+
heapMB: stats.heapMB });
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Connects a folder, made in the origin-private file system as a person's would be on a disk (in
|
|
991
|
+
// the iPhone shell, the shell connects one in its own storage), opens a file from it, then the
|
|
992
|
+
// same file from Recent, which should come from the kept import, and counts the frames drawn while
|
|
993
|
+
// nothing happens with the Library showing. Reports each step. With keep, what an earlier run kept
|
|
994
|
+
// is left, so the first open shows whether it lasted.
|
|
995
|
+
async function runLibraryTest() {
|
|
996
|
+
const name = params.get("file") ?? "pack.step";
|
|
997
|
+
if (!params.has("keep")) await forgetKept(name);
|
|
998
|
+
const keptBefore = (await libraryEntries()).map((e) => e.name);
|
|
999
|
+
let id;
|
|
1000
|
+
if (libraryStore.kind === "shell") {
|
|
1001
|
+
id = (await libraryStore.folders.folders()).find((f) => f.name === "library-test")?.id;
|
|
1002
|
+
if (!id) throw new Error("The shell connected no test folder.");
|
|
1003
|
+
} else {
|
|
1004
|
+
const bytes = await (await fetch(`${fixtures}step/${name}`)).blob();
|
|
1005
|
+
const opfs = await navigator.storage.getDirectory();
|
|
1006
|
+
await opfs.removeEntry("library-test", { recursive: true }).catch(() => {});
|
|
1007
|
+
const folder = await opfs.getDirectoryHandle("library-test", { create: true });
|
|
1008
|
+
const inner = await folder.getDirectoryHandle("sub", { create: true });
|
|
1009
|
+
const writable = await (await inner.getFileHandle(name, { create: true })).createWritable();
|
|
1010
|
+
await writable.write(bytes);
|
|
1011
|
+
await writable.close();
|
|
1012
|
+
await (await (await folder.getFileHandle("notes.txt", { create: true })).createWritable()).close();
|
|
1013
|
+
id = libraryStore.kind === "visit"
|
|
1014
|
+
? libraryStore.folders.adoptFiles("library-test", new Map([
|
|
1015
|
+
[`sub/${name}`, new File([bytes], name, { lastModified: 1 })], ["notes.txt", new File([], "notes.txt")]]))
|
|
1016
|
+
: await libraryStore.folders.adoptHandle(folder);
|
|
1017
|
+
}
|
|
1018
|
+
await showConnected([id]);
|
|
1019
|
+
const listed = (await libraryStore.folders.list(id, "")).map((e) => e.name).sort();
|
|
1020
|
+
|
|
1021
|
+
lastImport = null;
|
|
1022
|
+
const opened = performance.now();
|
|
1023
|
+
let ready = waitFor(Events.SceneReady);
|
|
1024
|
+
await openFromFolder({ root: id, path: `sub/${name}`, name }, false);
|
|
1025
|
+
await ready;
|
|
1026
|
+
const firstMs = Math.round(performance.now() - opened);
|
|
1027
|
+
const imported = lastImport?.type === "done";
|
|
1028
|
+
// Opening a file leaves the Library showing: the card switches only when the person switches it.
|
|
1029
|
+
const stayedOnLibrary = !library.element.hidden || phone;
|
|
1030
|
+
await sleep(300);
|
|
1031
|
+
const recent = await libraryStore.recent();
|
|
1032
|
+
|
|
1033
|
+
lastImport = null;
|
|
1034
|
+
const again = performance.now();
|
|
1035
|
+
ready = waitFor(Events.SceneReady);
|
|
1036
|
+
await reopenRecent(recent[0], false);
|
|
1037
|
+
await ready;
|
|
1038
|
+
const reopenMs = Math.round(performance.now() - again);
|
|
1039
|
+
const reopenedFromCache = lastImport === null;
|
|
1040
|
+
|
|
1041
|
+
showLibrary(true);
|
|
1042
|
+
await sleep(1500);
|
|
1043
|
+
const settled = engine.stats();
|
|
1044
|
+
await sleep(3000);
|
|
1045
|
+
const idle = engine.stats();
|
|
1046
|
+
const rows = [...library.element.querySelectorAll(".row")].map((r) => r.textContent.trim());
|
|
1047
|
+
|
|
1048
|
+
if (!params.has("keep")) {
|
|
1049
|
+
await libraryStore.folders.disconnect(id);
|
|
1050
|
+
await forgetRecent(name);
|
|
1051
|
+
const opfs = await navigator.storage.getDirectory();
|
|
1052
|
+
await opfs.removeEntry("library-test", { recursive: true }).catch(() => {});
|
|
1053
|
+
}
|
|
1054
|
+
await post({ backend: libraryStore.kind, origin: location.origin, keptBefore, listed, firstMs, imported,
|
|
1055
|
+
stayedOnLibrary, stillOnLibrary: !library.element.hidden || phone,
|
|
1056
|
+
recentFirst: recent[0]?.key,
|
|
1057
|
+
recentWhere: recent[0]?.where, reopenMs, reopenedFromCache,
|
|
1058
|
+
idleFrames: idle.frames - settled.frames, looping: idle.looping, rows });
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Waits for an imported file's scene, zooms in on the middle of the view, and reports how many
|
|
1062
|
+
// triangles refinement brought, and how long after the view settled they were all in.
|
|
1063
|
+
async function runRefineTest() {
|
|
1064
|
+
const ready = await waitFor(Events.SceneReady);
|
|
1065
|
+
if (ready.kind === Events.Failed) throw new Error(ready.message);
|
|
1066
|
+
await sleep(1500);
|
|
1067
|
+
const before = engine.stats().triangles;
|
|
1068
|
+
const { Pointer, Buttons } = await import("./engine.js");
|
|
1069
|
+
for (let i = 0; i < Number(params.get("zoom") ?? 6); ++i) {
|
|
1070
|
+
engine.pointer(Pointer.Scroll, Buttons.None, canvas.width / 2, canvas.height / 2, 120);
|
|
1071
|
+
await new Promise(requestAnimationFrame);
|
|
1072
|
+
}
|
|
1073
|
+
// Refinement starts once the view has been still a moment, and may run several jobs, finest
|
|
1074
|
+
// first; a job's segments can all come at its end, and count once a frame has drawn them. Done
|
|
1075
|
+
// after 3 s with no job running and the count steady.
|
|
1076
|
+
const zoomed = performance.now();
|
|
1077
|
+
let triangles = before, changed = zoomed, quietFrom = zoomed + 3000;
|
|
1078
|
+
const patience = Number(params.get("wait") ?? 120000);
|
|
1079
|
+
while (performance.now() - zoomed < patience) {
|
|
1080
|
+
await sleep(100);
|
|
1081
|
+
const now = engine.stats().triangles;
|
|
1082
|
+
if (now !== triangles) {
|
|
1083
|
+
triangles = now;
|
|
1084
|
+
changed = performance.now();
|
|
1085
|
+
}
|
|
1086
|
+
if (refinement) quietFrom = performance.now();
|
|
1087
|
+
if (performance.now() - Math.max(changed, quietFrom) >= 3000) break;
|
|
1088
|
+
}
|
|
1089
|
+
await post({ before, after: triangles, refinedMs: triangles === before ? null : Math.round(changed - zoomed),
|
|
1090
|
+
workers: importWorkers, refinements });
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// The page's calls into the engine while a test watches, each named with its time in ms since
|
|
1094
|
+
// the watch began: whatever wakes a view that should be still, short of the engine's own clock.
|
|
1095
|
+
function watchEngineCalls() {
|
|
1096
|
+
const calls = [];
|
|
1097
|
+
let since = null;
|
|
1098
|
+
const prototype = Object.getPrototypeOf(engine);
|
|
1099
|
+
for (const name of Object.getOwnPropertyNames(prototype)) {
|
|
1100
|
+
const original = Object.getOwnPropertyDescriptor(prototype, name).value;
|
|
1101
|
+
if (name === "constructor" || name === "stats" || typeof original !== "function") continue;
|
|
1102
|
+
engine[name] = function (...args) {
|
|
1103
|
+
if (since !== null && calls.length < 20) calls.push(`${name}@${Math.round(performance.now() - since)}`);
|
|
1104
|
+
return original.apply(this, args);
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
return {
|
|
1108
|
+
start() { calls.length = 0; since = performance.now(); },
|
|
1109
|
+
stop() { since = null; return calls.slice(); },
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Waits for the scene, then measures: frames submitted while nothing happens, frames while the
|
|
1114
|
+
// pointer drags the view, hover picks, and the memory held.
|
|
1115
|
+
async function runTest() {
|
|
1116
|
+
const opened = performance.now();
|
|
1117
|
+
const ready = await waitFor(Events.SceneReady);
|
|
1118
|
+
if (ready.kind === Events.Failed) throw new Error(ready.message);
|
|
1119
|
+
const loadMs = performance.now() - opened;
|
|
1120
|
+
const watch = watchEngineCalls();
|
|
1121
|
+
await sleep(1500);
|
|
1122
|
+
const settled = engine.stats();
|
|
1123
|
+
watch.start();
|
|
1124
|
+
await sleep(3000);
|
|
1125
|
+
const idle = engine.stats();
|
|
1126
|
+
const idleCalls = watch.stop();
|
|
1127
|
+
// A drag across the middle, one move per frame for 2 s.
|
|
1128
|
+
const { width, height } = canvas;
|
|
1129
|
+
const { Pointer, Buttons } = await import("./engine.js");
|
|
1130
|
+
engine.pointer(Pointer.Down, Buttons.Left, width / 2, height / 2);
|
|
1131
|
+
const dragStart = engine.stats();
|
|
1132
|
+
const started = performance.now();
|
|
1133
|
+
for (let i = 1; i <= 120; ++i) {
|
|
1134
|
+
engine.pointer(Pointer.Move, Buttons.None, width / 2 + i * 4, height / 2 + i);
|
|
1135
|
+
await new Promise(requestAnimationFrame);
|
|
1136
|
+
}
|
|
1137
|
+
engine.pointer(Pointer.Up, Buttons.Left, width / 2 + 480, height / 2 + 120);
|
|
1138
|
+
const dragMs = performance.now() - started;
|
|
1139
|
+
const dragged = engine.stats();
|
|
1140
|
+
// Hover over the middle, where the model is.
|
|
1141
|
+
engine.pointer(Pointer.Move, Buttons.None, width / 2, height / 2);
|
|
1142
|
+
watch.start();
|
|
1143
|
+
await sleep(1000);
|
|
1144
|
+
const hover = engine.stats();
|
|
1145
|
+
await sleep(2000);
|
|
1146
|
+
const after = engine.stats();
|
|
1147
|
+
const afterCalls = watch.stop();
|
|
1148
|
+
if (!params.has("nosnapshot")) await postSnapshot();
|
|
1149
|
+
await post({
|
|
1150
|
+
width, height, startupMs, loadMs: Math.round(loadMs),
|
|
1151
|
+
idleFrames: idle.frames - settled.frames, idleSettledFrames: idle.settledFrames - settled.settledFrames,
|
|
1152
|
+
looping: idle.looping, loopCause: idle.loopCause, idleCalls,
|
|
1153
|
+
dragFrames: dragged.frames - dragStart.frames, dragMs: Math.round(dragMs),
|
|
1154
|
+
gpuMs: dragged.gpuMs, hovered: hover.hovered,
|
|
1155
|
+
settledAfterDrag: after.settledFrames - dragged.settledFrames,
|
|
1156
|
+
loopingAfter: after.looping, loopCauseAfter: after.loopCause, afterCalls,
|
|
1157
|
+
bufferMB: after.bufferMB, targetMB: after.targetMB, heapMB: after.heapMB,
|
|
1158
|
+
triangles: after.triangles, read: engine.lastRead,
|
|
1159
|
+
// The toolbar's tools that show, by name: what folds into More on this screen.
|
|
1160
|
+
tools: [...toolbar.element.querySelectorAll("[data-tool]")].filter((e) => e.getClientRects().length > 0)
|
|
1161
|
+
.map((e) => e.dataset.tool),
|
|
1162
|
+
});
|
|
1163
|
+
}
|