geometryview 0.1.1 → 0.3.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/README.md +3 -1
- package/package.json +1 -1
- package/src/mcp.mjs +1 -1
- package/src/native.mjs +147 -15
- package/src/page.mjs +6 -1
- package/src/tools.mjs +135 -5
- package/web/build/geometryview-thumbs-web.mjs +2 -0
- package/web/build/geometryview-thumbs-web.wasm +0 -0
- package/web/build/geometryview-web.mjs +1 -1
- package/web/build/geometryview-web.wasm +0 -0
- package/web/chrome.js +7 -3
- package/web/engine.js +9 -0
- package/web/gallery-core.js +359 -0
- package/web/gallery-library.js +262 -0
- package/web/gallery-toolbar.js +148 -0
- package/web/gallery-view.js +1018 -0
- package/web/gallery.css +94 -0
- package/web/gallery.js +563 -0
- package/web/icons.js +6 -0
- package/web/import-queue.js +168 -0
- package/web/import-worker.js +63 -9
- package/web/importers.js +140 -0
- package/web/index.html +1 -0
- package/web/kept.js +263 -0
- package/web/library-store.js +116 -3
- package/web/library.js +25 -1
- package/web/occt/geometryview-step-web.wasm +0 -0
- package/web/occt-threads/geometryview-step-web.wasm +0 -0
- package/web/pdf.js +54 -0
- package/web/structure.js +2 -1
- package/web/thumbs-worker.js +86 -0
- package/web/thumbs.js +97 -0
- package/web/viewer.css +4 -0
- package/web/viewer.js +746 -27
package/web/viewer.js
CHANGED
|
@@ -12,6 +12,12 @@ import { LibraryCard, cachedImport, forgetKept, keptFile, keptSource, libraryEnt
|
|
|
12
12
|
setCacheVersion } from "./library.js";
|
|
13
13
|
import { keyOf } from "./library-rows.js";
|
|
14
14
|
import { LibraryStore } from "./library-store.js";
|
|
15
|
+
import { Importers } from "./importers.js";
|
|
16
|
+
import { Gallery } from "./gallery.js";
|
|
17
|
+
import { GalleryToolbar } from "./gallery-toolbar.js";
|
|
18
|
+
import { kContentInsets as kGalleryInsets } from "./gallery-view.js";
|
|
19
|
+
import { evict as evictImports, forget as forgetKeptKey, forgetNamed, keptImport, keptSource as keptSourceOf,
|
|
20
|
+
keyOf as keptKeyOf, persistOnce } from "./kept.js";
|
|
15
21
|
import { DropZone, HoldHint, HoverChip, Look, LookCard, Masthead, SectionCard, SelectionCard,
|
|
16
22
|
ShortcutsSheet, StatusCard, Styles, Toolbar } from "./chrome.js";
|
|
17
23
|
import { el } from "./ui.js";
|
|
@@ -40,7 +46,8 @@ async function isolate() {
|
|
|
40
46
|
}
|
|
41
47
|
await isolate();
|
|
42
48
|
const canvas = document.getElementById("viewport");
|
|
43
|
-
|
|
49
|
+
// A phone, or a check asking to be one (?phone=1).
|
|
50
|
+
const phone = params.get("phone") === "1" || /iPhone|iPad|Android/.test(navigator.userAgent) ||
|
|
44
51
|
(navigator.maxTouchPoints > 1 && /Macintosh/.test(navigator.userAgent));
|
|
45
52
|
document.body.classList.toggle("phone", phone);
|
|
46
53
|
|
|
@@ -104,11 +111,17 @@ const progress = new Map();
|
|
|
104
111
|
let hovered = null; // { document, node, face }
|
|
105
112
|
// The tree and library card: always open on a desktop, a sheet opened when wanted on a phone.
|
|
106
113
|
let panelOpen = !phone;
|
|
114
|
+
// The gallery, made when a folder's is first shown (gallery.js).
|
|
115
|
+
let gallery = null;
|
|
116
|
+
let galleryShown = false;
|
|
117
|
+
let openedFromGallery = false;
|
|
107
118
|
// What the chrome covers of the view, in CSS pixels, as framing keeps clear of it.
|
|
108
119
|
let insets = { left: 0, top: 0, right: 0, bottom: 0 };
|
|
109
120
|
let pointer = null; // { x, y } in CSS pixels, while over the view
|
|
110
121
|
|
|
111
122
|
const masthead = new Masthead();
|
|
123
|
+
// In the gallery, × leaves it, as Esc does at the top; in the viewer it closes the files.
|
|
124
|
+
masthead.onClose = () => (galleryShown ? leaveGallery() : closeFiles());
|
|
112
125
|
const structure = new StructureCard(engine, { onSwitch: (show) => showLibrary(show) });
|
|
113
126
|
const library = new LibraryCard(libraryStore, {
|
|
114
127
|
open: (row, add) => openFromFolder(row, add),
|
|
@@ -117,6 +130,8 @@ const library = new LibraryCard(libraryStore, {
|
|
|
117
130
|
connect: () => connectFolder(),
|
|
118
131
|
switch: (show) => showLibrary(show),
|
|
119
132
|
forget: (key) => forgetRecent(key),
|
|
133
|
+
showFolder: (root, path) => showGallery(root, path),
|
|
134
|
+
search: (query) => gallery?.setSearch(query),
|
|
120
135
|
});
|
|
121
136
|
const selection = new SelectionCard((action) => engine.perform(action));
|
|
122
137
|
const lookCard = new LookCard(look, (next) => applyLook(next));
|
|
@@ -141,6 +156,22 @@ const toolbar = new Toolbar({
|
|
|
141
156
|
showLibrary(names.length === 0);
|
|
142
157
|
},
|
|
143
158
|
});
|
|
159
|
+
const galleryToolbar = new GalleryToolbar({
|
|
160
|
+
view: (view) => gallery?.setView(view),
|
|
161
|
+
sheet: (change) => gallery?.setSheet(change),
|
|
162
|
+
ladders: (change) => gallery?.setLadders(change),
|
|
163
|
+
atlas: (change) => gallery?.atlas(change),
|
|
164
|
+
catalogue: (change) => gallery?.catalogue(change),
|
|
165
|
+
look: () => toggleCard(lookCard),
|
|
166
|
+
poster: () => setGalleryPoster(!gallery?.poster),
|
|
167
|
+
save: () => saveGalleryPoster(),
|
|
168
|
+
savePDF: () => saveGalleryPDF(),
|
|
169
|
+
// On a phone the Library is a sheet raised from the toolbar.
|
|
170
|
+
library: () => {
|
|
171
|
+
panelOpen = !panelOpen;
|
|
172
|
+
showLibrary(true);
|
|
173
|
+
},
|
|
174
|
+
});
|
|
144
175
|
const holdHint = new HoldHint();
|
|
145
176
|
const hoverChip = new HoverChip();
|
|
146
177
|
const dropZone = new DropZone();
|
|
@@ -152,6 +183,7 @@ picker.addEventListener("change", () => {
|
|
|
152
183
|
});
|
|
153
184
|
document.body.append(masthead.element, structure.element, library.element, selection.element,
|
|
154
185
|
lookCard.element, sectionCard.element, toolbar.element, toolbar.viewsMenu, toolbar.moreMenu,
|
|
186
|
+
galleryToolbar.element, galleryToolbar.posterBar,
|
|
155
187
|
holdHint.element,
|
|
156
188
|
hoverChip.element, dropZone.element, shortcuts.element, picker);
|
|
157
189
|
|
|
@@ -167,6 +199,22 @@ new ViewportInput(engine, canvas, {
|
|
|
167
199
|
showHover();
|
|
168
200
|
},
|
|
169
201
|
});
|
|
202
|
+
// While the gallery shows, its keys are its own; in the viewer, Esc goes back to the gallery the
|
|
203
|
+
// files came from once no card is open.
|
|
204
|
+
addEventListener("keydown", (e) => {
|
|
205
|
+
if (galleryShown && e.key === "Escape" && dismissCard()) {
|
|
206
|
+
// A card open over the gallery closes first.
|
|
207
|
+
e.preventDefault();
|
|
208
|
+
e.stopImmediatePropagation();
|
|
209
|
+
} else if (galleryShown && gallery?.key(e)) {
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
e.stopImmediatePropagation();
|
|
212
|
+
} else if (!galleryShown && openedFromGallery && e.key === "Escape" && !dismissCard()) {
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
e.stopImmediatePropagation();
|
|
215
|
+
enterGallery();
|
|
216
|
+
}
|
|
217
|
+
}, { capture: true });
|
|
170
218
|
// ⌘O opens files, ⇧⌘O adds them to what is shown, ⌥⌘O connects a folder: the Mac's keys.
|
|
171
219
|
addEventListener("keydown", (e) => {
|
|
172
220
|
if ((e.metaKey || e.ctrlKey) && e.code === "KeyO") {
|
|
@@ -259,6 +307,11 @@ function refreshSelection() {
|
|
|
259
307
|
}
|
|
260
308
|
|
|
261
309
|
function refreshMasthead() {
|
|
310
|
+
if (galleryShown) {
|
|
311
|
+
masthead.show(gallery.title, gallery.subtitle);
|
|
312
|
+
masthead.setProgress(null);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
262
315
|
if (names.length === 0) {
|
|
263
316
|
masthead.hide();
|
|
264
317
|
return;
|
|
@@ -304,9 +357,8 @@ function run(name) {
|
|
|
304
357
|
toolbar.projection.selected = perspective ? 1 : 0;
|
|
305
358
|
return engine.perform(Actions.ToggleProjection);
|
|
306
359
|
case "clear":
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
else engine.perform(Actions.ClearSelection);
|
|
360
|
+
// Esc closes what is open first, and only then clears the selection.
|
|
361
|
+
if (!dismissCard()) engine.perform(Actions.ClearSelection);
|
|
310
362
|
return undefined;
|
|
311
363
|
case "isolate": return engine.perform(Actions.Isolate);
|
|
312
364
|
case "showAll": return engine.perform(Actions.ShowAll);
|
|
@@ -353,6 +405,15 @@ function closeCards() {
|
|
|
353
405
|
sectionCard.element.hidden = true;
|
|
354
406
|
}
|
|
355
407
|
|
|
408
|
+
// Closes whatever card, menu or sheet is open: the shortcuts sheet, the views and More menus, the
|
|
409
|
+
// look and section cards. Returns whether any was.
|
|
410
|
+
function dismissCard() {
|
|
411
|
+
const open = [shortcuts.element, toolbar.viewsMenu, toolbar.moreMenu, lookCard.element,
|
|
412
|
+
sectionCard.element].filter((element) => !element.hidden);
|
|
413
|
+
for (const element of open) element.hidden = true;
|
|
414
|
+
return open.length > 0;
|
|
415
|
+
}
|
|
416
|
+
|
|
356
417
|
// Shows `next`, and remembers it unless it is the look the page started with: a look saved only
|
|
357
418
|
// once the person changes it keeps following the shared default until then.
|
|
358
419
|
function applyLook(next, { remember = true } = {}) {
|
|
@@ -362,6 +423,126 @@ function applyLook(next, { remember = true } = {}) {
|
|
|
362
423
|
const dark = Styles[look.style].onDark;
|
|
363
424
|
masthead.setOnDark(dark);
|
|
364
425
|
document.body.classList.toggle("on-dark", dark);
|
|
426
|
+
gallery?.setStyle(look.engineLook.style);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---- The gallery --------------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
// A connected folder's gallery covers the view, the Library beside it; the 3D view draws nothing
|
|
432
|
+
// meanwhile. A file opened from it opens in the viewer, and Esc there comes back.
|
|
433
|
+
function galleryFor() {
|
|
434
|
+
if (gallery) return gallery;
|
|
435
|
+
// Plates and Sheet ship first; ?views=all shows the views still being built.
|
|
436
|
+
// All five views; Plates and Sheet alone with views=two.
|
|
437
|
+
const views = params.get("views") === "two" ? 0b11 : 0b11111;
|
|
438
|
+
gallery = new Gallery({ engine, store: libraryStore, importers, phone, probes: params.get("test") === "gallery", views }, {
|
|
439
|
+
open: (root, path) => openFromGallery(root, path),
|
|
440
|
+
leave: () => leaveGallery(),
|
|
441
|
+
folderChanged: () => refreshMasthead(),
|
|
442
|
+
changed: () => galleryToolbar.show(gallery.state),
|
|
443
|
+
leavePoster: (toggle = false) => setGalleryPoster(toggle ? !gallery.poster : false),
|
|
444
|
+
// Permission asked again from the lost card's Reconnect, in its click.
|
|
445
|
+
reconnect: async (root) => {
|
|
446
|
+
if (!root || !(await libraryStore.folders.reconnect(root.id))) return;
|
|
447
|
+
await library.refresh();
|
|
448
|
+
await gallery.reconnected();
|
|
449
|
+
},
|
|
450
|
+
search: (query, found) => library.showSearch(galleryShown, query, found),
|
|
451
|
+
});
|
|
452
|
+
canvas.after(gallery.view.element);
|
|
453
|
+
// A press in the gallery closes the cards, as one in the view does.
|
|
454
|
+
gallery.view.element.addEventListener("pointerdown", () => closeCards());
|
|
455
|
+
gallery.setStyle(look.engineLook.style);
|
|
456
|
+
return gallery;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function showGallery(root, path) {
|
|
460
|
+
const g = galleryFor();
|
|
461
|
+
if (phone && panelOpen) {
|
|
462
|
+
// Chosen in the Library's sheet: the sheet goes down over the gallery.
|
|
463
|
+
panelOpen = false;
|
|
464
|
+
showLibrary(true);
|
|
465
|
+
}
|
|
466
|
+
enterGallery();
|
|
467
|
+
await g.show(root, path);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function enterGallery() {
|
|
471
|
+
galleryShown = true;
|
|
472
|
+
gallery.view.element.hidden = false;
|
|
473
|
+
engine.setCovered(true);
|
|
474
|
+
status.hide();
|
|
475
|
+
toolbar.element.hidden = true;
|
|
476
|
+
galleryToolbar.element.hidden = false;
|
|
477
|
+
galleryToolbar.show(gallery.state);
|
|
478
|
+
library.showSearch(true, gallery.core.search, gallery.core.matches);
|
|
479
|
+
selection.element.hidden = true;
|
|
480
|
+
if (!phone) {
|
|
481
|
+
panelOpen = true;
|
|
482
|
+
showLibrary(true);
|
|
483
|
+
}
|
|
484
|
+
gallery.view.reload();
|
|
485
|
+
refreshMasthead();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function leaveGallery() {
|
|
489
|
+
dismissCard(); // no card left open over the viewer
|
|
490
|
+
if (gallery.poster) setGalleryPoster(false);
|
|
491
|
+
galleryShown = false;
|
|
492
|
+
gallery.view.element.hidden = true;
|
|
493
|
+
engine.setCovered(false);
|
|
494
|
+
toolbar.element.hidden = false;
|
|
495
|
+
galleryToolbar.element.hidden = true;
|
|
496
|
+
library.showSearch(false);
|
|
497
|
+
if (names.length === 0) showEmpty();
|
|
498
|
+
refreshMasthead();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Show it off: the view as a poster, the full window less 30 each side, the cards put away, with
|
|
502
|
+
// Save PNG and Done in the top margin.
|
|
503
|
+
function setGalleryPoster(poster) {
|
|
504
|
+
if (!gallery || !galleryShown || phone) return;
|
|
505
|
+
masthead.element.hidden = poster;
|
|
506
|
+
library.element.hidden = poster;
|
|
507
|
+
galleryToolbar.element.hidden = poster;
|
|
508
|
+
galleryToolbar.posterBar.hidden = !poster;
|
|
509
|
+
if (poster) closeCards();
|
|
510
|
+
gallery.view.setInsets(poster ? { top: 30, left: 30, bottom: 30, right: 30 } : kGalleryInsets);
|
|
511
|
+
gallery.setPoster(poster);
|
|
512
|
+
if (!poster) {
|
|
513
|
+
showLibrary(true);
|
|
514
|
+
refreshMasthead();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function saveGalleryPoster() {
|
|
519
|
+
const png = await gallery?.view.posterPNG();
|
|
520
|
+
if (!png) return;
|
|
521
|
+
const link = el("a", { href: URL.createObjectURL(png), download: `${gallery.title} poster.png` });
|
|
522
|
+
link.click();
|
|
523
|
+
setTimeout(() => URL.revokeObjectURL(link.href), 10_000);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function saveGalleryPDF() {
|
|
527
|
+
if (!gallery) return;
|
|
528
|
+
// A book can take a while: the button counts its pages meanwhile, and takes no second press.
|
|
529
|
+
let pdf;
|
|
530
|
+
try {
|
|
531
|
+
pdf = await gallery.posterPDF((page, pages) => galleryToolbar.savingPDF(`Page ${page} of ${pages}\u2026`));
|
|
532
|
+
} finally {
|
|
533
|
+
galleryToolbar.savingPDF(null);
|
|
534
|
+
}
|
|
535
|
+
if (!pdf) return;
|
|
536
|
+
const what = gallery.core.view === 4 ? "catalogue" : "poster";
|
|
537
|
+
const link = el("a", { href: URL.createObjectURL(pdf), download: `${gallery.title} ${what}.pdf` });
|
|
538
|
+
link.click();
|
|
539
|
+
setTimeout(() => URL.revokeObjectURL(link.href), 10_000);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function openFromGallery(root, path) {
|
|
543
|
+
leaveGallery();
|
|
544
|
+
openedFromGallery = true;
|
|
545
|
+
await openFromFolder({ root: root.id, path, name: path.split("/").pop() }, false);
|
|
365
546
|
}
|
|
366
547
|
|
|
367
548
|
// ---- Layout -----------------------------------------------------------------------------------
|
|
@@ -435,6 +616,7 @@ async function connectFolder() {
|
|
|
435
616
|
}
|
|
436
617
|
|
|
437
618
|
async function showConnected(ids) {
|
|
619
|
+
persistOnce(); // what is kept of a connected folder's files should outlast storage pressure
|
|
438
620
|
await library.refresh();
|
|
439
621
|
for (const id of ids) library.expand(id);
|
|
440
622
|
if (!phone) panelOpen = true;
|
|
@@ -472,6 +654,7 @@ async function rememberOpened(name) {
|
|
|
472
654
|
if (!name) return;
|
|
473
655
|
const source = sources.get(name) ?? {};
|
|
474
656
|
const entry = { key: name, name, where: source.where ?? "Kept in this browser", openedAt: Date.now() };
|
|
657
|
+
if (keys.has(name)) entry.cacheKey = keys.get(name);
|
|
475
658
|
for (const field of ["handle", "root", "path"]) if (source[field]) entry[field] = source[field];
|
|
476
659
|
try {
|
|
477
660
|
const dropped = await libraryStore.remember(entry);
|
|
@@ -484,8 +667,12 @@ async function rememberOpened(name) {
|
|
|
484
667
|
}
|
|
485
668
|
|
|
486
669
|
async function forgetRecent(key) {
|
|
670
|
+
const entry = (await libraryStore.recent()).find((r) => r.key === key);
|
|
487
671
|
await libraryStore.forget(key);
|
|
488
|
-
if (!names.includes(key))
|
|
672
|
+
if (!names.includes(key)) {
|
|
673
|
+
await forgetKept(key);
|
|
674
|
+
if (entry?.cacheKey) await forgetKeptKey(entry.cacheKey);
|
|
675
|
+
}
|
|
489
676
|
}
|
|
490
677
|
|
|
491
678
|
// The Library's keys of what is shown: names, and paths in connected folders.
|
|
@@ -505,6 +692,20 @@ function forget() {
|
|
|
505
692
|
structure.reset();
|
|
506
693
|
selection.element.hidden = true;
|
|
507
694
|
library.setOpen([]);
|
|
695
|
+
// The next file starts without a cut, as the engine does: the cuts go off, keeping where they
|
|
696
|
+
// were placed.
|
|
697
|
+
sectionCard.cuts.forEach((cut) => { cut.enabled = false; });
|
|
698
|
+
sectionCard.show();
|
|
699
|
+
sectionCard.element.hidden = true;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Closes everything open: back to the Library and the invitation to open something.
|
|
703
|
+
function closeFiles() {
|
|
704
|
+
forget();
|
|
705
|
+
closeCards();
|
|
706
|
+
engine.close();
|
|
707
|
+
refreshMasthead();
|
|
708
|
+
showEmpty();
|
|
508
709
|
}
|
|
509
710
|
|
|
510
711
|
function openCache(source, name, add) {
|
|
@@ -528,12 +729,35 @@ async function openFiles(files, adding) {
|
|
|
528
729
|
// ---- Importing --------------------------------------------------------------------------------
|
|
529
730
|
|
|
530
731
|
// 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.
|
|
732
|
+
// get two OCCT workers, as P2 measured best; desktops one per core, less two for the page. What is
|
|
733
|
+
// opened goes through the importers' live lane: one at a time, in the order asked, and a file being
|
|
734
|
+
// opened cancels a refinement running; the gallery's imports have a lane of their own.
|
|
532
735
|
let importer = null;
|
|
533
|
-
|
|
736
|
+
const importers = new Importers({ shared: phone });
|
|
737
|
+
let importing = Promise.resolve(); // the latest file asked for, for the scripted checks
|
|
738
|
+
let nextJob = 1;
|
|
534
739
|
const stepSources = new Map(); // STEP files imported this visit, by name, for refinement
|
|
740
|
+
const keys = new Map(); // names of files imported this visit, to their kept imports' keys
|
|
535
741
|
const importWorkers = phone ? 2 : Math.max(2, Math.min(12, (navigator.hardwareConcurrency ?? 4) - 2));
|
|
536
742
|
|
|
743
|
+
// Where a file came from, for its kept import's key: a connected folder's name and its path there,
|
|
744
|
+
// or "file" and its name for one opened on its own.
|
|
745
|
+
function originOf(file) {
|
|
746
|
+
const source = sources.get(file.name);
|
|
747
|
+
return source?.root ? { source: library.folderName(source.root) || source.root, path: source.path }
|
|
748
|
+
: { source: "file", path: file.name };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function keyFor(file) {
|
|
752
|
+
return keptKeyOf({ ...originOf(file), size: file.size, modified: file.lastModified, version: engine.cacheVersion });
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Keeps what is kept within its budget, sparing what is open, after an import adds to it.
|
|
756
|
+
function evictKept() {
|
|
757
|
+
const open = new Set(names.map((name) => keys.get(name)).filter(Boolean));
|
|
758
|
+
evictImports(open).catch(() => {});
|
|
759
|
+
}
|
|
760
|
+
|
|
537
761
|
// What importing an STL file takes at its peak. A binary file, read in pieces, is never held
|
|
538
762
|
// whole, and the importer's heap stays under 300 MB and 115 bytes a triangle: 0.46 GB for 2
|
|
539
763
|
// million, 0.87 GB for 5 and 1.27-1.37 GB for 10. Any other is read whole, and is reckoned as it
|
|
@@ -549,8 +773,11 @@ const kPhoneImportBudget = 1.2 * 1024 ** 3;
|
|
|
549
773
|
|
|
550
774
|
function importFile(file, add) {
|
|
551
775
|
if (!/\.stl$/i.test(file.name)) stepSources.set(file.name, file);
|
|
552
|
-
importing =
|
|
553
|
-
const
|
|
776
|
+
importing = importers.live(`open-${nextJob++}`, async (signal) => {
|
|
777
|
+
const key = await keyFor(file);
|
|
778
|
+
keys.set(file.name, key);
|
|
779
|
+
// Kept before by key, or by name before keys were kept.
|
|
780
|
+
const cached = (await keptImport(key, engine.cacheVersion))?.blob ?? await cachedImport(file);
|
|
554
781
|
if (cached) {
|
|
555
782
|
openCache(cached, file.name, add);
|
|
556
783
|
return { type: "cached" };
|
|
@@ -594,8 +821,12 @@ function importFile(file, add) {
|
|
|
594
821
|
}
|
|
595
822
|
};
|
|
596
823
|
worker.onerror = (event) => resolve({ type: "failed", message: event.message ?? "The importer failed." });
|
|
597
|
-
|
|
598
|
-
|
|
824
|
+
signal.addEventListener("abort", () => worker.postMessage({ type: "cancel" }));
|
|
825
|
+
// A file in a connected folder can be read from there again, so its source is not kept.
|
|
826
|
+
const fromFolder = Boolean(sources.get(file.name)?.root);
|
|
827
|
+
worker.postMessage({ type: "import", file, kind: stl ? "stl" : "step", name: file.name, key,
|
|
828
|
+
keepSource: !stl && !fromFolder, workers: importWorkers, threads: stl,
|
|
829
|
+
progressive: !stl });
|
|
599
830
|
});
|
|
600
831
|
if (stl) worker.terminate();
|
|
601
832
|
lastImport = result;
|
|
@@ -605,7 +836,8 @@ function importFile(file, add) {
|
|
|
605
836
|
return result;
|
|
606
837
|
}
|
|
607
838
|
if (token !== null) engine.finishImport(token);
|
|
608
|
-
else openCache(await
|
|
839
|
+
else openCache((await keptImport(key))?.blob, file.name, add);
|
|
840
|
+
evictKept();
|
|
609
841
|
return result;
|
|
610
842
|
});
|
|
611
843
|
return importing;
|
|
@@ -615,22 +847,40 @@ function importFile(file, add) {
|
|
|
615
847
|
|
|
616
848
|
// The engine asks for finer geometry where the settled view is close, as on the Mac: the STEP
|
|
617
849
|
// 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,
|
|
619
|
-
// Refinements wait their turn behind
|
|
850
|
+
// document. The file is the one imported this visit, the one in its connected folder, or the one
|
|
851
|
+
// the library kept beside its cache. Refinements wait their turn behind files being opened, in the
|
|
852
|
+
// same worker and its warm OCCT pool, and one running is cancelled for a file being opened.
|
|
620
853
|
let refinement = null;
|
|
621
854
|
|
|
855
|
+
// A STEP file's source again, for refining: from this visit, its folder, or what was kept.
|
|
856
|
+
async function sourceFor(name) {
|
|
857
|
+
const visit = stepSources.get(name);
|
|
858
|
+
if (visit) return visit;
|
|
859
|
+
const where = sources.get(name);
|
|
860
|
+
if (where?.root && library.readyFolders.has(where.root)) {
|
|
861
|
+
const file = await libraryStore.folders.file(where.root, where.path).catch(() => null);
|
|
862
|
+
if (file) return file;
|
|
863
|
+
}
|
|
864
|
+
const key = keys.get(name);
|
|
865
|
+
return (key ? await keptSourceOf(key) : null) ?? await keptSource(name);
|
|
866
|
+
}
|
|
867
|
+
|
|
622
868
|
function refineDocument(token, name, level, definitions) {
|
|
623
|
-
const job = { cancelled: false };
|
|
869
|
+
const job = { cancelled: false, id: `refine-${nextJob++}` };
|
|
624
870
|
refinement = job;
|
|
625
871
|
const asked = performance.now();
|
|
626
|
-
|
|
872
|
+
importers.live(job.id, async (signal) => {
|
|
627
873
|
let ok = false;
|
|
628
874
|
const started = performance.now();
|
|
629
875
|
let note = "";
|
|
630
876
|
let made = null, segments = 0, refused = 0;
|
|
877
|
+
signal.addEventListener("abort", () => {
|
|
878
|
+
job.cancelled = true;
|
|
879
|
+
importer?.postMessage({ type: "cancel" });
|
|
880
|
+
});
|
|
631
881
|
try {
|
|
632
|
-
const file = job.cancelled ? null :
|
|
633
|
-
if (!file) return;
|
|
882
|
+
const file = job.cancelled ? null : await sourceFor(name);
|
|
883
|
+
if (!file || job.cancelled) return;
|
|
634
884
|
importer ??= new Worker(new URL("./import-worker.js", import.meta.url), { type: "module" });
|
|
635
885
|
const result = await new Promise((resolve) => {
|
|
636
886
|
importer.onmessage = ({ data }) => {
|
|
@@ -651,7 +901,7 @@ function refineDocument(token, name, level, definitions) {
|
|
|
651
901
|
coarserFaces: result.coarserFaces, crashedWorkers: result.crashedWorkers };
|
|
652
902
|
}
|
|
653
903
|
// 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}`);
|
|
904
|
+
if (!ok && !job.cancelled) console.warn(`Refining ${name} to level ${level} failed: ${result.message}`);
|
|
655
905
|
} finally {
|
|
656
906
|
refinements.push({ name, level, definitions: definitions.length, waitedMs: Math.round(started - asked),
|
|
657
907
|
ms: Math.round(performance.now() - started), ok, cancelled: job.cancelled, note,
|
|
@@ -659,21 +909,26 @@ function refineDocument(token, name, level, definitions) {
|
|
|
659
909
|
if (!job.cancelled) engine.finishRefinement(token, ok);
|
|
660
910
|
if (refinement === job) refinement = null;
|
|
661
911
|
}
|
|
662
|
-
});
|
|
912
|
+
}, { band: "visible" });
|
|
663
913
|
}
|
|
664
914
|
|
|
665
915
|
// Each refinement job, as the scripted check reports them.
|
|
666
916
|
const refinements = [];
|
|
667
917
|
|
|
668
918
|
function cancelRefinement() {
|
|
669
|
-
if (refinement)
|
|
919
|
+
if (!refinement) return;
|
|
920
|
+
refinement.cancelled = true;
|
|
921
|
+
importers.cancel(refinement.id);
|
|
670
922
|
}
|
|
671
923
|
|
|
672
924
|
let lastImport = null;
|
|
673
925
|
let importStarted = 0;
|
|
674
926
|
|
|
675
|
-
// Removes
|
|
676
|
-
|
|
927
|
+
// Removes what was kept of a file by its name, so the next open imports again.
|
|
928
|
+
async function forgetImport(name) {
|
|
929
|
+
await forgetKept(name);
|
|
930
|
+
await forgetNamed(name);
|
|
931
|
+
}
|
|
677
932
|
|
|
678
933
|
// Dropping files: holding ⌘ adds them to what is shown.
|
|
679
934
|
let dragDepth = 0;
|
|
@@ -782,6 +1037,9 @@ if (params.has("token")) {
|
|
|
782
1037
|
if (params.get("test") === "import") runImportTest().catch((error) => post({ error: error.message }));
|
|
783
1038
|
else if (params.get("test") === "library") runLibraryTest().catch((error) => post({ error: error.message }));
|
|
784
1039
|
else if (params.get("test") === "refine") runRefineTest().catch((error) => post({ error: error.message }));
|
|
1040
|
+
else if (params.get("test") === "thumbs") runThumbsTest().catch((error) => post({ error: error.message }));
|
|
1041
|
+
else if (params.get("test") === "worker") runWorkerTest().catch((error) => post({ error: error.message }));
|
|
1042
|
+
else if (params.get("test") === "gallery") runGalleryTest().catch((error) => post({ error: error.message, stack: error.stack }));
|
|
785
1043
|
else if (params.has("test")) runTest().catch((error) => post({ error: error.message }));
|
|
786
1044
|
|
|
787
1045
|
// ---- The launcher's bridge ---------------------------------------------------------------------
|
|
@@ -943,8 +1201,12 @@ function bridgeHandlers() {
|
|
|
943
1201
|
|
|
944
1202
|
function testLabel() {
|
|
945
1203
|
// 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") ?? "
|
|
947
|
-
|
|
1204
|
+
const name = (model ?? params.get("file") ?? (params.get("test") === "gallery" ? params.get("folder") : null) ?? "none")
|
|
1205
|
+
.replaceAll("/", "-");
|
|
1206
|
+
// The gallery's checks are reported with its measurements.
|
|
1207
|
+
const phase = ["thumbs", "worker"].includes(params.get("test")) ? "g1"
|
|
1208
|
+
: params.get("test") === "gallery" ? "g9" : "p4";
|
|
1209
|
+
return `${params.get("label") ?? "unlabelled"}-${phase}-${params.get("test")}-${name}`;
|
|
948
1210
|
}
|
|
949
1211
|
|
|
950
1212
|
async function post(report) {
|
|
@@ -994,7 +1256,7 @@ async function runImportTest() {
|
|
|
994
1256
|
// is left, so the first open shows whether it lasted.
|
|
995
1257
|
async function runLibraryTest() {
|
|
996
1258
|
const name = params.get("file") ?? "pack.step";
|
|
997
|
-
if (!params.has("keep")) await
|
|
1259
|
+
if (!params.has("keep")) await forgetImport(name);
|
|
998
1260
|
const keptBefore = (await libraryEntries()).map((e) => e.name);
|
|
999
1261
|
let id;
|
|
1000
1262
|
if (libraryStore.kind === "shell") {
|
|
@@ -1017,6 +1279,9 @@ async function runLibraryTest() {
|
|
|
1017
1279
|
}
|
|
1018
1280
|
await showConnected([id]);
|
|
1019
1281
|
const listed = (await libraryStore.folders.list(id, "")).map((e) => e.name).sort();
|
|
1282
|
+
// A gallery's walk: its folders and model files, by path.
|
|
1283
|
+
const walked = [];
|
|
1284
|
+
for await (const entry of libraryStore.folders.walk(id)) walked.push(`${entry.kind}:${entry.path}`);
|
|
1020
1285
|
|
|
1021
1286
|
lastImport = null;
|
|
1022
1287
|
const opened = performance.now();
|
|
@@ -1051,13 +1316,467 @@ async function runLibraryTest() {
|
|
|
1051
1316
|
const opfs = await navigator.storage.getDirectory();
|
|
1052
1317
|
await opfs.removeEntry("library-test", { recursive: true }).catch(() => {});
|
|
1053
1318
|
}
|
|
1054
|
-
await post({ backend: libraryStore.kind, origin: location.origin, keptBefore, listed, firstMs, imported,
|
|
1319
|
+
await post({ backend: libraryStore.kind, origin: location.origin, keptBefore, listed, walked, firstMs, imported,
|
|
1055
1320
|
stayedOnLibrary, stillOnLibrary: !library.element.hidden || phone,
|
|
1056
1321
|
recentFirst: recent[0]?.key,
|
|
1057
1322
|
recentWhere: recent[0]?.where, reopenMs, reopenedFromCache,
|
|
1058
1323
|
idleFrames: idle.frames - settled.frames, looping: idle.looping, rows });
|
|
1059
1324
|
}
|
|
1060
1325
|
|
|
1326
|
+
// Draws fixtures' pictures in the gallery's picture worker, one after another as the gallery
|
|
1327
|
+
// will, and reports how long each took, the most memory the worker held and how often it was
|
|
1328
|
+
// replaced; the first eight go on a sheet, kept as a PNG.
|
|
1329
|
+
async function runThumbsTest() {
|
|
1330
|
+
const { Thumbnailer } = await import("./thumbs.js");
|
|
1331
|
+
const files = (params.get("files") ?? "gallery-drive,gallery-motor").split(",");
|
|
1332
|
+
const count = Number(params.get("count") ?? 40);
|
|
1333
|
+
const thumbnailer = new Thumbnailer({ recycleMB: Number(params.get("recycle") ?? 256) });
|
|
1334
|
+
const blobs = await Promise.all(files.map(async (f) => (await fetch(`${fixtures}${f}.geoblob`)).blob()));
|
|
1335
|
+
const spec = { pixels: Number(params.get("size") ?? 256), style: Number(params.get("style") ?? 0),
|
|
1336
|
+
colorMode: 1, pixelsPerPoint: 2, lineScale: 0.5 };
|
|
1337
|
+
const times = [];
|
|
1338
|
+
let firstMs = 0, maxHeapMB = 0, firstPicture = null;
|
|
1339
|
+
const kept = [];
|
|
1340
|
+
for (let i = 0; i < count; i++) {
|
|
1341
|
+
const started = performance.now();
|
|
1342
|
+
const picture = await thumbnailer.draw(blobs[i % blobs.length], spec, i % 4);
|
|
1343
|
+
const ms = performance.now() - started;
|
|
1344
|
+
if (i === 0) firstMs = ms;
|
|
1345
|
+
else times.push(ms);
|
|
1346
|
+
maxHeapMB = Math.max(maxHeapMB, thumbnailer.heapMB);
|
|
1347
|
+
firstPicture ??= { width: picture.width, partRect: picture.partRect, inkRect: picture.inkRect,
|
|
1348
|
+
worldPerPixel: picture.worldPerPixel, pngBytes: picture.png.size };
|
|
1349
|
+
if (kept.length < 8) kept.push(picture.bitmap);
|
|
1350
|
+
else picture.bitmap.close();
|
|
1351
|
+
}
|
|
1352
|
+
thumbnailer.close();
|
|
1353
|
+
const sheet = new OffscreenCanvas(spec.pixels * kept.length, spec.pixels);
|
|
1354
|
+
const context = sheet.getContext("2d");
|
|
1355
|
+
context.fillStyle = "#f2ead8";
|
|
1356
|
+
context.fillRect(0, 0, sheet.width, sheet.height);
|
|
1357
|
+
kept.forEach((bitmap, i) => context.drawImage(bitmap, i * spec.pixels, 0));
|
|
1358
|
+
const png = await sheet.convertToBlob({ type: "image/png" });
|
|
1359
|
+
await fetch(`/png?label=${encodeURIComponent(testLabel())}`, { method: "POST", body: png }).catch(() => {});
|
|
1360
|
+
times.sort((a, b) => a - b);
|
|
1361
|
+
const at = (q) => Math.round(times[Math.min(times.length - 1, Math.floor(q * times.length))] * 100) / 100;
|
|
1362
|
+
await post({ files, count, size: spec.pixels, style: spec.style, firstMs: Math.round(firstMs),
|
|
1363
|
+
p50Ms: at(0.5), p90Ms: at(0.9), maxHeapMB, recycled: thumbnailer.recycled, firstPicture });
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// The import worker's gallery messages: a STEP file's structure alone, as one model segment; and
|
|
1367
|
+
// an import cancelled soon after it starts, which stops with its slices out.
|
|
1368
|
+
async function runWorkerTest() {
|
|
1369
|
+
const name = params.get("file") ?? "pack.step";
|
|
1370
|
+
const file = new File([await (await fetch(`${fixtures}step/${name}`)).blob()], name);
|
|
1371
|
+
const worker = new Worker(new URL("./import-worker.js", import.meta.url), { type: "module" });
|
|
1372
|
+
const ask = (message, onSegment = () => {}) => new Promise((resolve) => {
|
|
1373
|
+
worker.onmessage = ({ data }) => {
|
|
1374
|
+
if (data.type === "segment") onSegment(data.bytes);
|
|
1375
|
+
else if (data.type !== "progress") resolve(data);
|
|
1376
|
+
};
|
|
1377
|
+
worker.postMessage(message);
|
|
1378
|
+
});
|
|
1379
|
+
let started = performance.now();
|
|
1380
|
+
const segments = [];
|
|
1381
|
+
const structure = await ask({ type: "structure", file }, (bytes) => segments.push(bytes.length));
|
|
1382
|
+
const structureMs = Math.round(performance.now() - started);
|
|
1383
|
+
|
|
1384
|
+
started = performance.now();
|
|
1385
|
+
let progressed = false;
|
|
1386
|
+
const cancelled = new Promise((resolve) => {
|
|
1387
|
+
worker.onmessage = ({ data }) => {
|
|
1388
|
+
if (data.type === "progress" && data.done > 0 && !progressed) {
|
|
1389
|
+
progressed = true;
|
|
1390
|
+
worker.postMessage({ type: "cancel" });
|
|
1391
|
+
} else if (data.type === "done" || data.type === "failed") {
|
|
1392
|
+
resolve(data);
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
});
|
|
1396
|
+
worker.postMessage({ type: "import", file, kind: "step", name, key: "worker-test", keepSource: false,
|
|
1397
|
+
workers: importWorkers, threads: false, progressive: false });
|
|
1398
|
+
const outcome = await cancelled;
|
|
1399
|
+
const cancelMs = Math.round(performance.now() - started);
|
|
1400
|
+
worker.terminate();
|
|
1401
|
+
await forgetKeptKey("worker-test");
|
|
1402
|
+
await post({ file: name, structure: structure.type, structureMs, structureSegments: segments,
|
|
1403
|
+
cancelOutcome: outcome.type, cancelled: outcome.cancelled ?? false, cancelMs,
|
|
1404
|
+
message: outcome.message ?? "" });
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// A served folder's gallery (?folder=test-models), measured as the Mac's --gallery-check measures
|
|
1408
|
+
// its own: in Illustration, Blueprint and Studio, the folder and its first subfolders, every
|
|
1409
|
+
// alignment group as painted, and how far anything was painted from where the core laid it. Then
|
|
1410
|
+
// the view is scrolled through its tallest folder while long tasks are counted, and the 3D view's
|
|
1411
|
+
// frames while the gallery covers it.
|
|
1412
|
+
async function runGalleryTest() {
|
|
1413
|
+
const dir = params.get("folder") ?? "test-models";
|
|
1414
|
+
const name = dir.split("/").pop();
|
|
1415
|
+
let id, listing;
|
|
1416
|
+
if (libraryStore.kind === "shell") {
|
|
1417
|
+
// On the iPhone: the folder the shell connected from its own storage, walked as the gallery
|
|
1418
|
+
// walks it.
|
|
1419
|
+
id = (await libraryStore.folders.folders()).find((f) => f.name === name)?.id;
|
|
1420
|
+
if (!id) throw new Error(`The shell connected no folder named ${name}.`);
|
|
1421
|
+
listing = [];
|
|
1422
|
+
for await (const entry of libraryStore.folders.walk(id)) listing.push({ path: entry.path, folder: entry.kind === "folder" });
|
|
1423
|
+
} else {
|
|
1424
|
+
listing = await (await fetch(`/list?dir=${encodeURIComponent(dir)}`)).json();
|
|
1425
|
+
if (!libraryStore.folders.adoptRemote) throw new Error("Run with backend=visit.");
|
|
1426
|
+
id = libraryStore.folders.adoptRemote(name, listing, `/viewer/${dir}/`);
|
|
1427
|
+
}
|
|
1428
|
+
await library.refresh();
|
|
1429
|
+
const root = { id, name };
|
|
1430
|
+
const g = galleryFor();
|
|
1431
|
+
await showGallery(root, "");
|
|
1432
|
+
const settle = async () => {
|
|
1433
|
+
const started = performance.now();
|
|
1434
|
+
while (g.waitingInView > 0 && performance.now() - started < Number(params.get("wait") ?? 240000)) await sleep(250);
|
|
1435
|
+
await sleep(400);
|
|
1436
|
+
g.view.repaint();
|
|
1437
|
+
await new Promise(requestAnimationFrame);
|
|
1438
|
+
return Math.round(performance.now() - started) / 1000;
|
|
1439
|
+
};
|
|
1440
|
+
const subfolders = [...new Set(listing.filter((e) => e.folder && !e.path.includes("/")).map((e) => e.path))]
|
|
1441
|
+
.filter((p) => listing.some((e) => !e.folder && e.path.startsWith(`${p}/`))).sort().slice(0, 3);
|
|
1442
|
+
const runs = [];
|
|
1443
|
+
let worst = 0, over = 0, deviation = 0;
|
|
1444
|
+
// Every alignment group as painted, and how far each item was painted from where the core laid it.
|
|
1445
|
+
const measure = () => {
|
|
1446
|
+
const { lines, columns } = g.view.measured();
|
|
1447
|
+
const result = g.core.check(lines, columns, 0.5);
|
|
1448
|
+
const items = g.core.items;
|
|
1449
|
+
let off = 0, worstItem = null;
|
|
1450
|
+
for (let i = 0; i < items.length; i++) {
|
|
1451
|
+
const it = items[i];
|
|
1452
|
+
const d = Math.max(Number.isNaN(lines[i]) ? 0 : Math.abs(lines[i] - it.line),
|
|
1453
|
+
it.column && !Number.isNaN(columns[i]) ? Math.abs(columns[i] - it.columnLine) : 0);
|
|
1454
|
+
if (d > off) {
|
|
1455
|
+
off = d;
|
|
1456
|
+
// The item furthest off, to look into: what it is, where laid and where painted.
|
|
1457
|
+
worstItem = { kind: it.kind, flags: it.flags, x: it.x, y: it.y, width: it.width, height: it.height,
|
|
1458
|
+
line: it.line, painted: lines[i], column: it.columnLine, paintedColumn: columns[i],
|
|
1459
|
+
lineWidth: it.lineWidth, fontSize: it.fontSize, group: it.group };
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
deviation = Math.max(deviation, off);
|
|
1463
|
+
worst = Math.max(worst, ...result.over.map((o) => o.spread), 0);
|
|
1464
|
+
over += result.over.length;
|
|
1465
|
+
// The toolbar fits its room: folded as it must be, never cut off.
|
|
1466
|
+
const bar = galleryToolbar.element;
|
|
1467
|
+
return { groupsChecked: result.checked, over: result.over, painted: lines.filter((v) => !Number.isNaN(v)).length,
|
|
1468
|
+
maxDeviation: Math.round(off * 1000) / 1000, ...(off > 0.1 ? { worstItem } : {}), toolbarCut: Math.max(0, bar.scrollWidth - bar.clientWidth),
|
|
1469
|
+
toolbarFold: bar.classList.contains("tighter") ? 2 : bar.classList.contains("tight") ? 1 : 0 };
|
|
1470
|
+
};
|
|
1471
|
+
// checkViews (a list, "4" or "0,4") runs only those, for looking into one.
|
|
1472
|
+
const views = params.has("checkViews") ? params.get("checkViews").split(",").map(Number)
|
|
1473
|
+
: params.get("views") === "all" ? [0, 1, 2, 3, 4] : [0, 1];
|
|
1474
|
+
// scrollOnly skips the styles' runs, for timing a scroll alone; styles=0 runs Illustration only,
|
|
1475
|
+
// to keep a phone's run short.
|
|
1476
|
+
const styles = params.has("styles") ? params.get("styles").split(",").map(Number) : [0, 3, 5];
|
|
1477
|
+
for (const style of params.has("scrollOnly") ? [] : styles) {
|
|
1478
|
+
const next = Object.assign(Object.create(Object.getPrototypeOf(look)), look);
|
|
1479
|
+
next.choose(style);
|
|
1480
|
+
applyLook(next, { remember: false });
|
|
1481
|
+
for (const view of views) {
|
|
1482
|
+
g.setView(view);
|
|
1483
|
+
for (const path of ["", ...subfolders]) {
|
|
1484
|
+
await g.show(root, path);
|
|
1485
|
+
g.setView(view);
|
|
1486
|
+
await sleep(600); // past any move
|
|
1487
|
+
const waited = await settle();
|
|
1488
|
+
if (params.has("shots") && path === "") {
|
|
1489
|
+
// Whoever runs the browser takes a screenshot while the page waits.
|
|
1490
|
+
await fetch(`/shot?name=${encodeURIComponent(`${params.get("label") ?? "web"}-gallery-${dir.replaceAll("/", "-")}-${["plates", "sheet", "ladders", "atlas", "catalogue"][view]}-${style}`)}`);
|
|
1491
|
+
await sleep(1500);
|
|
1492
|
+
}
|
|
1493
|
+
runs.push({ style, view, folder: path, waited, ...measure() });
|
|
1494
|
+
if (view === 4) {
|
|
1495
|
+
// Catalogue's other spreads: the contents, a plate and its table, the index.
|
|
1496
|
+
const { spreads } = g.core.catalogue;
|
|
1497
|
+
for (const spread of [...new Set([1, 2, 3, Math.floor(spreads / 2), spreads - 1])].filter((s) => s > 0 && s < spreads)) {
|
|
1498
|
+
g.catalogue({ spread });
|
|
1499
|
+
await sleep(200);
|
|
1500
|
+
await settle();
|
|
1501
|
+
if (params.has("shots") && path === "") {
|
|
1502
|
+
await fetch(`/shot?name=${encodeURIComponent(`${params.get("label") ?? "web"}-gallery-${dir.replaceAll("/", "-")}-catalogue-${style}-s${spread}`)}`);
|
|
1503
|
+
await sleep(1500);
|
|
1504
|
+
}
|
|
1505
|
+
runs.push({ style, view, spread, folder: path, waited: 0, ...measure() });
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
if (view === 3) {
|
|
1509
|
+
// Atlas closer in: the districts, then every part named.
|
|
1510
|
+
for (const level of [1, 2]) {
|
|
1511
|
+
g.atlas({ level });
|
|
1512
|
+
await sleep(300);
|
|
1513
|
+
await settle();
|
|
1514
|
+
runs.push({ style, view, level, folder: path, waited: 0, ...measure() });
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
if (params.has("lost")) {
|
|
1521
|
+
// The folder drawn, then unreadable, as when the browser's permission lapses: shown again from
|
|
1522
|
+
// what was kept, faded, with Reconnect; then readable again.
|
|
1523
|
+
g.setView(0);
|
|
1524
|
+
await g.show(root, "");
|
|
1525
|
+
await settle();
|
|
1526
|
+
await sleep(2500); // past the listing being kept
|
|
1527
|
+
const folders = libraryStore.folders;
|
|
1528
|
+
const { walk, file } = folders;
|
|
1529
|
+
const denied = () => { throw new DOMException("The folder cannot be read.", "NotAllowedError"); };
|
|
1530
|
+
folders.walk = async function* () { denied(); };
|
|
1531
|
+
folders.file = async () => denied();
|
|
1532
|
+
g.root = null;
|
|
1533
|
+
await g.show(root, "");
|
|
1534
|
+
await sleep(3000);
|
|
1535
|
+
g.view.repaint();
|
|
1536
|
+
await new Promise(requestAnimationFrame);
|
|
1537
|
+
const items = g.core.items;
|
|
1538
|
+
const pictures = items.filter((i) => i.kind === 2 && !(i.flags & 16));
|
|
1539
|
+
const shown = [...g.view.element.querySelectorAll(".gallery-content img.g-picture:not([hidden])")]
|
|
1540
|
+
.filter((img) => img.style.visibility !== "hidden" && img.src).length;
|
|
1541
|
+
const lost = { items: items.length, faded: items.filter((i) => i.flags & 128).length,
|
|
1542
|
+
card: items.filter((i) => i.flags & 16).length, pictures: pictures.length, picturesShown: shown,
|
|
1543
|
+
...measure() };
|
|
1544
|
+
if (params.has("shots")) {
|
|
1545
|
+
await fetch(`/shot?name=${encodeURIComponent(`${params.get("label") ?? "web"}-gallery-lost`)}`);
|
|
1546
|
+
await sleep(1500);
|
|
1547
|
+
}
|
|
1548
|
+
folders.walk = walk;
|
|
1549
|
+
folders.file = file;
|
|
1550
|
+
await g.reconnected();
|
|
1551
|
+
await settle();
|
|
1552
|
+
const back = { faded: g.core.items.filter((i) => i.flags & 128).length, items: g.core.items.length };
|
|
1553
|
+
await post({ folder: dir, lost, back });
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
if (params.has("poster")) {
|
|
1557
|
+
// The folder as a poster in each view: aligned as painted, and drawn as PNG.
|
|
1558
|
+
const posters = [];
|
|
1559
|
+
const names = ["plates", "sheet", "ladders", "atlas", "catalogue"];
|
|
1560
|
+
for (const view of params.get("views") === "all" ? [0, 1, 2, 3, 4] : [0, 1]) {
|
|
1561
|
+
await g.show(root, "");
|
|
1562
|
+
g.setView(view);
|
|
1563
|
+
setGalleryPoster(true);
|
|
1564
|
+
await sleep(600);
|
|
1565
|
+
await settle();
|
|
1566
|
+
const run = { view, ...measure(), contentHeight: Math.round(g.core.contentHeight) };
|
|
1567
|
+
const png = await g.view.posterPNG();
|
|
1568
|
+
run.pngBytes = png?.size ?? 0;
|
|
1569
|
+
// Its PDF too: a page, or in Catalogue the book, a spread a page.
|
|
1570
|
+
const pdfStart = performance.now();
|
|
1571
|
+
let counted = 0, label = "";
|
|
1572
|
+
const pdf = await g.posterPDF((page, pages) => {
|
|
1573
|
+
counted++;
|
|
1574
|
+
galleryToolbar.savingPDF(`Page ${page} of ${pages}\u2026`); // as the button counts them
|
|
1575
|
+
label = galleryToolbar.posterBar.textContent;
|
|
1576
|
+
});
|
|
1577
|
+
galleryToolbar.savingPDF(null);
|
|
1578
|
+
run.pdfPagesCounted = counted;
|
|
1579
|
+
run.pdfLabel = label;
|
|
1580
|
+
run.pdfBytes = pdf.size;
|
|
1581
|
+
run.pdfMs = Math.round(performance.now() - pdfStart);
|
|
1582
|
+
await fetch(`/png?type=pdf&label=${encodeURIComponent(`${params.get("label") ?? "web"}-poster-${names[view]}`)}`,
|
|
1583
|
+
{ method: "POST", body: pdf });
|
|
1584
|
+
if (png) await fetch(`/png?label=${encodeURIComponent(`${params.get("label") ?? "web"}-poster-${names[view]}`)}`, { method: "POST", body: png });
|
|
1585
|
+
if (params.has("shots")) {
|
|
1586
|
+
await fetch(`/shot?name=${encodeURIComponent(`${params.get("label") ?? "web"}-poster-screen-${view}`)}`);
|
|
1587
|
+
await sleep(1500);
|
|
1588
|
+
}
|
|
1589
|
+
setGalleryPoster(false);
|
|
1590
|
+
posters.push(run);
|
|
1591
|
+
}
|
|
1592
|
+
await post({ folder: dir, posters, groupsOver: over, maxDeviation: Math.round(deviation * 1000) / 1000 });
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
const g3 = params.has("scrollOnly") ? null : await galleryMotionChecks(g, root, subfolders, measure, settle);
|
|
1596
|
+
// Scrolling the tallest folder: long tasks over 50 ms while it moves.
|
|
1597
|
+
const tall = params.get("scroll") ?? subfolders.at(-1) ?? "";
|
|
1598
|
+
await g.show(root, tall);
|
|
1599
|
+
await settle();
|
|
1600
|
+
if (params.has("expand")) {
|
|
1601
|
+
// Every size band shown whole: "show all" on each, then their pictures.
|
|
1602
|
+
for (let band = 0; band < 12; band++) g.view.act(g.core.click(3, band, false));
|
|
1603
|
+
await settle();
|
|
1604
|
+
}
|
|
1605
|
+
const longTasks = [];
|
|
1606
|
+
const observer = new PerformanceObserver((list) => {
|
|
1607
|
+
for (const entry of list.getEntries()) longTasks.push(Math.round(entry.duration));
|
|
1608
|
+
});
|
|
1609
|
+
try {
|
|
1610
|
+
observer.observe({ type: "longtask", buffered: false });
|
|
1611
|
+
} catch {
|
|
1612
|
+
// No long tasks reported here.
|
|
1613
|
+
}
|
|
1614
|
+
const clip = g.view.element.querySelector(".gallery-clip");
|
|
1615
|
+
const height = clip.scrollHeight - clip.clientHeight;
|
|
1616
|
+
const scrollStarted = performance.now();
|
|
1617
|
+
for (let i = 1; i <= 120; i++) {
|
|
1618
|
+
clip.scrollTop = (height * i) / 120;
|
|
1619
|
+
await new Promise(requestAnimationFrame);
|
|
1620
|
+
}
|
|
1621
|
+
const scrollMs = Math.round(performance.now() - scrollStarted);
|
|
1622
|
+
await sleep(200);
|
|
1623
|
+
observer.disconnect();
|
|
1624
|
+
// No 3D frames while the gallery covers the view.
|
|
1625
|
+
const before = engine.stats().frames;
|
|
1626
|
+
await sleep(2000);
|
|
1627
|
+
const framesWhileCovered = engine.stats().frames - before;
|
|
1628
|
+
// Detail pictures: Catalogue's plate figure, drawn large.
|
|
1629
|
+
const details = { galleryBefore: galleryShown };
|
|
1630
|
+
if (views.includes(4)) {
|
|
1631
|
+
await g.show(root, "");
|
|
1632
|
+
g.setView(4);
|
|
1633
|
+
g.catalogue({ spread: 2 });
|
|
1634
|
+
await settle();
|
|
1635
|
+
const detailStart = performance.now();
|
|
1636
|
+
const wanted = g.core.details().length;
|
|
1637
|
+
while (g.view.detailCount < wanted && performance.now() - detailStart < 60_000) await sleep(200);
|
|
1638
|
+
Object.assign(details, { catalogueWanted: wanted, catalogueShown: g.view.detailCount,
|
|
1639
|
+
catalogueSeconds: Math.round(performance.now() - detailStart) / 1000 });
|
|
1640
|
+
if (params.has("shots")) {
|
|
1641
|
+
await fetch(`/shot?name=${encodeURIComponent(`${params.get("label") ?? "web"}-gallery-detail-catalogue`)}`);
|
|
1642
|
+
await sleep(1500);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
// What the person found trying the gallery: Esc with the Look card open closes the card first,
|
|
1646
|
+
// and × leaves the gallery.
|
|
1647
|
+
const galleryBefore = galleryShown;
|
|
1648
|
+
toggleCard(lookCard);
|
|
1649
|
+
const lookOpened = !lookCard.element.hidden;
|
|
1650
|
+
// Pressed as a key is, at the page's focus: the window's capturing listener sees it first.
|
|
1651
|
+
document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true }));
|
|
1652
|
+
const escape = { galleryBefore, lookOpened, lookOpenAfter: !lookCard.element.hidden, galleryAfter: galleryShown };
|
|
1653
|
+
masthead.onClose();
|
|
1654
|
+
const close = { galleryAfter: galleryShown, viewHidden: g.view.element.hidden,
|
|
1655
|
+
emptyShown: !status.element.hidden };
|
|
1656
|
+
await post({ folder: dir, files: listing.filter((e) => !e.folder).length, runs, groupsOver: over,
|
|
1657
|
+
window: { escape, close, details },
|
|
1658
|
+
worstSpread: worst, maxDeviation: Math.round(deviation * 1000) / 1000,
|
|
1659
|
+
scroll: { folder: tall, height: Math.round(height), frames: 120, ms: scrollMs, longTasks,
|
|
1660
|
+
items: g.core.items.length,
|
|
1661
|
+
longest: Math.max(0, ...longTasks) },
|
|
1662
|
+
framesWhileCovered, ...(g3 ? { g3 } : {}),
|
|
1663
|
+
// Nothing scrolls sideways, the page or the gallery, at any width.
|
|
1664
|
+
overflowX: { page: document.scrollingElement.scrollWidth - innerWidth,
|
|
1665
|
+
gallery: clip.scrollWidth - clip.clientWidth, width: innerWidth } });
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// G3's checks in the page: a heading of Sheet's stays at the top as its block scrolls under it; a
|
|
1669
|
+
// search fades what it did not find; Peek is painted over the view and aligned; and each move plays
|
|
1670
|
+
// for as long as the spec says, the selection kept.
|
|
1671
|
+
async function galleryMotionChecks(g, root, subfolders, measure, settle) {
|
|
1672
|
+
const clip = g.view.element.querySelector(".gallery-clip");
|
|
1673
|
+
const out = {};
|
|
1674
|
+
// Sticky headings, in Sheet at the top, scrolled a third of the way.
|
|
1675
|
+
await g.show(root, "");
|
|
1676
|
+
g.setView(1);
|
|
1677
|
+
await sleep(600);
|
|
1678
|
+
await settle();
|
|
1679
|
+
clip.scrollTop = Math.round((clip.scrollHeight - clip.clientHeight) / 3);
|
|
1680
|
+
await new Promise(requestAnimationFrame);
|
|
1681
|
+
await new Promise(requestAnimationFrame);
|
|
1682
|
+
const top = clip.getBoundingClientRect().top;
|
|
1683
|
+
const items = g.core.items;
|
|
1684
|
+
let stuck = null;
|
|
1685
|
+
for (const [i, box] of g.view.paintedBoxes()) {
|
|
1686
|
+
const item = items[i];
|
|
1687
|
+
if (item.kind !== 0 || !(item.stickyUntil > 0)) continue;
|
|
1688
|
+
const dy = clip.scrollTop - item.stickyTop;
|
|
1689
|
+
if (dy > 0 && dy < item.stickyUntil) {
|
|
1690
|
+
// On screen, the band's top sits at the view's top.
|
|
1691
|
+
const node = [...clip.querySelectorAll(".g-rect")].find((n) => !n.hidden && Number(n.style.zIndex) === items.length + i);
|
|
1692
|
+
stuck = { stickyTop: item.stickyTop, scroll: clip.scrollTop,
|
|
1693
|
+
fromViewTop: node ? Math.round((node.getBoundingClientRect().top - top) * 1000) / 1000 : null,
|
|
1694
|
+
layoutTop: Math.round(box.y * 1000) / 1000 };
|
|
1695
|
+
break;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
out.sticky = { ...stuck, aligned: measure() };
|
|
1699
|
+
clip.scrollTop = 0;
|
|
1700
|
+
// A search: what it did not find painted at 22%.
|
|
1701
|
+
const probe = g.core.path(g.core.wanted()[0]?.file ?? 0).split("/").pop().slice(0, 3);
|
|
1702
|
+
g.setSearch(probe);
|
|
1703
|
+
await new Promise(requestAnimationFrame);
|
|
1704
|
+
const faded = [...g.view.element.querySelectorAll(".gallery-content > :not([hidden])")].filter((n) => n.style.opacity === "0.22").length;
|
|
1705
|
+
out.search = { query: probe, found: g.core.matches, fadedNodes: faded, aligned: measure() };
|
|
1706
|
+
g.setSearch("");
|
|
1707
|
+
// Peek over the first file of the first subfolder, in Sheet, where → selects a file. A folder
|
|
1708
|
+
// shows the view it last showed, so the view is chosen once in it.
|
|
1709
|
+
await g.show(root, subfolders[0] ?? "");
|
|
1710
|
+
g.setView(1);
|
|
1711
|
+
await sleep(600);
|
|
1712
|
+
await settle();
|
|
1713
|
+
g.view.key(1); // → selects the first file
|
|
1714
|
+
g.view.key(5); // Space
|
|
1715
|
+
await new Promise(requestAnimationFrame);
|
|
1716
|
+
const fixed = g.view.element.querySelectorAll(".gallery-fixed > :not([hidden])").length;
|
|
1717
|
+
out.peek = { file: g.core.peeking === null ? null : g.core.path(g.core.peeking), fixedNodes: fixed, aligned: measure() };
|
|
1718
|
+
if (params.has("shots")) {
|
|
1719
|
+
await fetch(`/shot?name=${encodeURIComponent(`${params.get("label") ?? "web"}-gallery-peek`)}`);
|
|
1720
|
+
await sleep(1500);
|
|
1721
|
+
}
|
|
1722
|
+
// Peek's turntable: its frames come, then → turns the part a frame at a time.
|
|
1723
|
+
const turnStart = performance.now();
|
|
1724
|
+
while (!g.core.peekTurnable && performance.now() - turnStart < 60_000) await sleep(100);
|
|
1725
|
+
await new Promise(requestAnimationFrame);
|
|
1726
|
+
const turn = { seconds: Math.round(performance.now() - turnStart) / 1000, turnable: g.core.peekTurnable,
|
|
1727
|
+
aligned: measure() };
|
|
1728
|
+
if (params.has("shots")) {
|
|
1729
|
+
await fetch(`/shot?name=${encodeURIComponent(`${params.get("label") ?? "web"}-gallery-peek-turn-0`)}`);
|
|
1730
|
+
await sleep(1500);
|
|
1731
|
+
}
|
|
1732
|
+
for (let i = 0; i < 6; i++) g.view.key(1); // a quarter turn, at 24 frames
|
|
1733
|
+
turn.frameAfterSix = g.view.turnFrameShown;
|
|
1734
|
+
turn.peekingStill = g.core.peeking === null ? null : g.core.path(g.core.peeking);
|
|
1735
|
+
if (params.has("shots")) {
|
|
1736
|
+
await fetch(`/shot?name=${encodeURIComponent(`${params.get("label") ?? "web"}-gallery-peek-turn-6`)}`);
|
|
1737
|
+
await sleep(1500);
|
|
1738
|
+
}
|
|
1739
|
+
out.peekTurn = turn;
|
|
1740
|
+
g.view.key(6); // Esc closes Peek
|
|
1741
|
+
// A switch from Plates to Sheet, which shows more: its transition and tracks, and the animations
|
|
1742
|
+
// playing. Parts in both fly; those new to Sheet fade in.
|
|
1743
|
+
g.setView(0);
|
|
1744
|
+
await sleep(700);
|
|
1745
|
+
const selected = g.core.selected;
|
|
1746
|
+
g.setView(1);
|
|
1747
|
+
const t = g.core.transition, tracks = g.core.tracks;
|
|
1748
|
+
const playing = document.getAnimations().map((a) => a.effect?.getTiming?.()).filter(Boolean);
|
|
1749
|
+
out.switch = {
|
|
1750
|
+
transition: { kind: t.kind, duration: t.duration, fadeOut: t.fadeOut },
|
|
1751
|
+
fly: tracks.filter((k) => k.kind === 0).length, fadeIn: tracks.filter((k) => k.kind === 1).length,
|
|
1752
|
+
flyTiming: [...new Set(tracks.filter((k) => k.kind === 0).map((k) => `${k.delay}+${k.duration}`))],
|
|
1753
|
+
fadeTiming: [...new Set(tracks.filter((k) => k.kind === 1).map((k) => `${Math.round(k.delay)}+${Math.round(k.duration)}`))],
|
|
1754
|
+
animations: playing.length,
|
|
1755
|
+
animationTimings: [...new Set(playing.map((p) => `${Math.round(p.delay)}+${Math.round(p.duration)}`))],
|
|
1756
|
+
selectionKept: g.core.selected === selected && selected !== null,
|
|
1757
|
+
};
|
|
1758
|
+
await sleep(700);
|
|
1759
|
+
// Into a folder from its plate, and back out.
|
|
1760
|
+
await g.show(root, "");
|
|
1761
|
+
g.setView(0);
|
|
1762
|
+
await sleep(600);
|
|
1763
|
+
await settle();
|
|
1764
|
+
const plate = g.core.items.find((i) => i.targetKind === 1);
|
|
1765
|
+
if (plate) {
|
|
1766
|
+
g.view.act(g.core.click(1, plate.target, false));
|
|
1767
|
+
const into = g.core.transition;
|
|
1768
|
+
const zoom = document.getAnimations().map((a) => a.effect?.getTiming?.()).filter(Boolean);
|
|
1769
|
+
await sleep(600);
|
|
1770
|
+
g.view.key(6); // Esc: up again
|
|
1771
|
+
const back = g.core.transition;
|
|
1772
|
+
out.zoom = { into: { kind: into.kind, duration: into.duration, rect: into.rect.map(Math.round) },
|
|
1773
|
+
out: { kind: back.kind, duration: back.duration, rect: back.rect.map(Math.round) },
|
|
1774
|
+
animations: zoom.length, animationTimings: [...new Set(zoom.map((p) => `${Math.round(p.delay)}+${Math.round(p.duration)}`))] };
|
|
1775
|
+
await sleep(600);
|
|
1776
|
+
}
|
|
1777
|
+
return out;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1061
1780
|
// Waits for an imported file's scene, zooms in on the middle of the view, and reports how many
|
|
1062
1781
|
// triangles refinement brought, and how long after the view settled they were all in.
|
|
1063
1782
|
async function runRefineTest() {
|