geometryview 0.1.2 → 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/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/engine.js +4 -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/thumbs-worker.js +86 -0
- package/web/thumbs.js +97 -0
- package/web/viewer.js +721 -25
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,12 +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();
|
|
112
|
-
|
|
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());
|
|
113
125
|
const structure = new StructureCard(engine, { onSwitch: (show) => showLibrary(show) });
|
|
114
126
|
const library = new LibraryCard(libraryStore, {
|
|
115
127
|
open: (row, add) => openFromFolder(row, add),
|
|
@@ -118,6 +130,8 @@ const library = new LibraryCard(libraryStore, {
|
|
|
118
130
|
connect: () => connectFolder(),
|
|
119
131
|
switch: (show) => showLibrary(show),
|
|
120
132
|
forget: (key) => forgetRecent(key),
|
|
133
|
+
showFolder: (root, path) => showGallery(root, path),
|
|
134
|
+
search: (query) => gallery?.setSearch(query),
|
|
121
135
|
});
|
|
122
136
|
const selection = new SelectionCard((action) => engine.perform(action));
|
|
123
137
|
const lookCard = new LookCard(look, (next) => applyLook(next));
|
|
@@ -142,6 +156,22 @@ const toolbar = new Toolbar({
|
|
|
142
156
|
showLibrary(names.length === 0);
|
|
143
157
|
},
|
|
144
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
|
+
});
|
|
145
175
|
const holdHint = new HoldHint();
|
|
146
176
|
const hoverChip = new HoverChip();
|
|
147
177
|
const dropZone = new DropZone();
|
|
@@ -153,6 +183,7 @@ picker.addEventListener("change", () => {
|
|
|
153
183
|
});
|
|
154
184
|
document.body.append(masthead.element, structure.element, library.element, selection.element,
|
|
155
185
|
lookCard.element, sectionCard.element, toolbar.element, toolbar.viewsMenu, toolbar.moreMenu,
|
|
186
|
+
galleryToolbar.element, galleryToolbar.posterBar,
|
|
156
187
|
holdHint.element,
|
|
157
188
|
hoverChip.element, dropZone.element, shortcuts.element, picker);
|
|
158
189
|
|
|
@@ -168,6 +199,22 @@ new ViewportInput(engine, canvas, {
|
|
|
168
199
|
showHover();
|
|
169
200
|
},
|
|
170
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 });
|
|
171
218
|
// ⌘O opens files, ⇧⌘O adds them to what is shown, ⌥⌘O connects a folder: the Mac's keys.
|
|
172
219
|
addEventListener("keydown", (e) => {
|
|
173
220
|
if ((e.metaKey || e.ctrlKey) && e.code === "KeyO") {
|
|
@@ -260,6 +307,11 @@ function refreshSelection() {
|
|
|
260
307
|
}
|
|
261
308
|
|
|
262
309
|
function refreshMasthead() {
|
|
310
|
+
if (galleryShown) {
|
|
311
|
+
masthead.show(gallery.title, gallery.subtitle);
|
|
312
|
+
masthead.setProgress(null);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
263
315
|
if (names.length === 0) {
|
|
264
316
|
masthead.hide();
|
|
265
317
|
return;
|
|
@@ -371,6 +423,126 @@ function applyLook(next, { remember = true } = {}) {
|
|
|
371
423
|
const dark = Styles[look.style].onDark;
|
|
372
424
|
masthead.setOnDark(dark);
|
|
373
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);
|
|
374
546
|
}
|
|
375
547
|
|
|
376
548
|
// ---- Layout -----------------------------------------------------------------------------------
|
|
@@ -444,6 +616,7 @@ async function connectFolder() {
|
|
|
444
616
|
}
|
|
445
617
|
|
|
446
618
|
async function showConnected(ids) {
|
|
619
|
+
persistOnce(); // what is kept of a connected folder's files should outlast storage pressure
|
|
447
620
|
await library.refresh();
|
|
448
621
|
for (const id of ids) library.expand(id);
|
|
449
622
|
if (!phone) panelOpen = true;
|
|
@@ -481,6 +654,7 @@ async function rememberOpened(name) {
|
|
|
481
654
|
if (!name) return;
|
|
482
655
|
const source = sources.get(name) ?? {};
|
|
483
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);
|
|
484
658
|
for (const field of ["handle", "root", "path"]) if (source[field]) entry[field] = source[field];
|
|
485
659
|
try {
|
|
486
660
|
const dropped = await libraryStore.remember(entry);
|
|
@@ -493,8 +667,12 @@ async function rememberOpened(name) {
|
|
|
493
667
|
}
|
|
494
668
|
|
|
495
669
|
async function forgetRecent(key) {
|
|
670
|
+
const entry = (await libraryStore.recent()).find((r) => r.key === key);
|
|
496
671
|
await libraryStore.forget(key);
|
|
497
|
-
if (!names.includes(key))
|
|
672
|
+
if (!names.includes(key)) {
|
|
673
|
+
await forgetKept(key);
|
|
674
|
+
if (entry?.cacheKey) await forgetKeptKey(entry.cacheKey);
|
|
675
|
+
}
|
|
498
676
|
}
|
|
499
677
|
|
|
500
678
|
// The Library's keys of what is shown: names, and paths in connected folders.
|
|
@@ -551,12 +729,35 @@ async function openFiles(files, adding) {
|
|
|
551
729
|
// ---- Importing --------------------------------------------------------------------------------
|
|
552
730
|
|
|
553
731
|
// The importer runs in a worker, with its own pool of OCCT workers, one file at a time. Phones
|
|
554
|
-
// 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.
|
|
555
735
|
let importer = null;
|
|
556
|
-
|
|
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;
|
|
557
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
|
|
558
741
|
const importWorkers = phone ? 2 : Math.max(2, Math.min(12, (navigator.hardwareConcurrency ?? 4) - 2));
|
|
559
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
|
+
|
|
560
761
|
// What importing an STL file takes at its peak. A binary file, read in pieces, is never held
|
|
561
762
|
// whole, and the importer's heap stays under 300 MB and 115 bytes a triangle: 0.46 GB for 2
|
|
562
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
|
|
@@ -572,8 +773,11 @@ const kPhoneImportBudget = 1.2 * 1024 ** 3;
|
|
|
572
773
|
|
|
573
774
|
function importFile(file, add) {
|
|
574
775
|
if (!/\.stl$/i.test(file.name)) stepSources.set(file.name, file);
|
|
575
|
-
importing =
|
|
576
|
-
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);
|
|
577
781
|
if (cached) {
|
|
578
782
|
openCache(cached, file.name, add);
|
|
579
783
|
return { type: "cached" };
|
|
@@ -617,8 +821,12 @@ function importFile(file, add) {
|
|
|
617
821
|
}
|
|
618
822
|
};
|
|
619
823
|
worker.onerror = (event) => resolve({ type: "failed", message: event.message ?? "The importer failed." });
|
|
620
|
-
|
|
621
|
-
|
|
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 });
|
|
622
830
|
});
|
|
623
831
|
if (stl) worker.terminate();
|
|
624
832
|
lastImport = result;
|
|
@@ -628,7 +836,8 @@ function importFile(file, add) {
|
|
|
628
836
|
return result;
|
|
629
837
|
}
|
|
630
838
|
if (token !== null) engine.finishImport(token);
|
|
631
|
-
else openCache(await
|
|
839
|
+
else openCache((await keptImport(key))?.blob, file.name, add);
|
|
840
|
+
evictKept();
|
|
632
841
|
return result;
|
|
633
842
|
});
|
|
634
843
|
return importing;
|
|
@@ -638,22 +847,40 @@ function importFile(file, add) {
|
|
|
638
847
|
|
|
639
848
|
// The engine asks for finer geometry where the settled view is close, as on the Mac: the STEP
|
|
640
849
|
// file's definitions it names are imported again at a finer level, and their segments join the
|
|
641
|
-
// document. The file is the one imported this visit,
|
|
642
|
-
// 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.
|
|
643
853
|
let refinement = null;
|
|
644
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
|
+
|
|
645
868
|
function refineDocument(token, name, level, definitions) {
|
|
646
|
-
const job = { cancelled: false };
|
|
869
|
+
const job = { cancelled: false, id: `refine-${nextJob++}` };
|
|
647
870
|
refinement = job;
|
|
648
871
|
const asked = performance.now();
|
|
649
|
-
|
|
872
|
+
importers.live(job.id, async (signal) => {
|
|
650
873
|
let ok = false;
|
|
651
874
|
const started = performance.now();
|
|
652
875
|
let note = "";
|
|
653
876
|
let made = null, segments = 0, refused = 0;
|
|
877
|
+
signal.addEventListener("abort", () => {
|
|
878
|
+
job.cancelled = true;
|
|
879
|
+
importer?.postMessage({ type: "cancel" });
|
|
880
|
+
});
|
|
654
881
|
try {
|
|
655
|
-
const file = job.cancelled ? null :
|
|
656
|
-
if (!file) return;
|
|
882
|
+
const file = job.cancelled ? null : await sourceFor(name);
|
|
883
|
+
if (!file || job.cancelled) return;
|
|
657
884
|
importer ??= new Worker(new URL("./import-worker.js", import.meta.url), { type: "module" });
|
|
658
885
|
const result = await new Promise((resolve) => {
|
|
659
886
|
importer.onmessage = ({ data }) => {
|
|
@@ -674,7 +901,7 @@ function refineDocument(token, name, level, definitions) {
|
|
|
674
901
|
coarserFaces: result.coarserFaces, crashedWorkers: result.crashedWorkers };
|
|
675
902
|
}
|
|
676
903
|
// The view keeps the geometry it has, so a failure is only worth a note for diagnosing.
|
|
677
|
-
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}`);
|
|
678
905
|
} finally {
|
|
679
906
|
refinements.push({ name, level, definitions: definitions.length, waitedMs: Math.round(started - asked),
|
|
680
907
|
ms: Math.round(performance.now() - started), ok, cancelled: job.cancelled, note,
|
|
@@ -682,21 +909,26 @@ function refineDocument(token, name, level, definitions) {
|
|
|
682
909
|
if (!job.cancelled) engine.finishRefinement(token, ok);
|
|
683
910
|
if (refinement === job) refinement = null;
|
|
684
911
|
}
|
|
685
|
-
});
|
|
912
|
+
}, { band: "visible" });
|
|
686
913
|
}
|
|
687
914
|
|
|
688
915
|
// Each refinement job, as the scripted check reports them.
|
|
689
916
|
const refinements = [];
|
|
690
917
|
|
|
691
918
|
function cancelRefinement() {
|
|
692
|
-
if (refinement)
|
|
919
|
+
if (!refinement) return;
|
|
920
|
+
refinement.cancelled = true;
|
|
921
|
+
importers.cancel(refinement.id);
|
|
693
922
|
}
|
|
694
923
|
|
|
695
924
|
let lastImport = null;
|
|
696
925
|
let importStarted = 0;
|
|
697
926
|
|
|
698
|
-
// Removes
|
|
699
|
-
|
|
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
|
+
}
|
|
700
932
|
|
|
701
933
|
// Dropping files: holding ⌘ adds them to what is shown.
|
|
702
934
|
let dragDepth = 0;
|
|
@@ -805,6 +1037,9 @@ if (params.has("token")) {
|
|
|
805
1037
|
if (params.get("test") === "import") runImportTest().catch((error) => post({ error: error.message }));
|
|
806
1038
|
else if (params.get("test") === "library") runLibraryTest().catch((error) => post({ error: error.message }));
|
|
807
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 }));
|
|
808
1043
|
else if (params.has("test")) runTest().catch((error) => post({ error: error.message }));
|
|
809
1044
|
|
|
810
1045
|
// ---- The launcher's bridge ---------------------------------------------------------------------
|
|
@@ -966,8 +1201,12 @@ function bridgeHandlers() {
|
|
|
966
1201
|
|
|
967
1202
|
function testLabel() {
|
|
968
1203
|
// A report's name is a file's, so an imported fixture's folder joins it with a dash.
|
|
969
|
-
const name = (model ?? params.get("file") ?? "
|
|
970
|
-
|
|
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}`;
|
|
971
1210
|
}
|
|
972
1211
|
|
|
973
1212
|
async function post(report) {
|
|
@@ -1017,7 +1256,7 @@ async function runImportTest() {
|
|
|
1017
1256
|
// is left, so the first open shows whether it lasted.
|
|
1018
1257
|
async function runLibraryTest() {
|
|
1019
1258
|
const name = params.get("file") ?? "pack.step";
|
|
1020
|
-
if (!params.has("keep")) await
|
|
1259
|
+
if (!params.has("keep")) await forgetImport(name);
|
|
1021
1260
|
const keptBefore = (await libraryEntries()).map((e) => e.name);
|
|
1022
1261
|
let id;
|
|
1023
1262
|
if (libraryStore.kind === "shell") {
|
|
@@ -1040,6 +1279,9 @@ async function runLibraryTest() {
|
|
|
1040
1279
|
}
|
|
1041
1280
|
await showConnected([id]);
|
|
1042
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}`);
|
|
1043
1285
|
|
|
1044
1286
|
lastImport = null;
|
|
1045
1287
|
const opened = performance.now();
|
|
@@ -1074,13 +1316,467 @@ async function runLibraryTest() {
|
|
|
1074
1316
|
const opfs = await navigator.storage.getDirectory();
|
|
1075
1317
|
await opfs.removeEntry("library-test", { recursive: true }).catch(() => {});
|
|
1076
1318
|
}
|
|
1077
|
-
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,
|
|
1078
1320
|
stayedOnLibrary, stillOnLibrary: !library.element.hidden || phone,
|
|
1079
1321
|
recentFirst: recent[0]?.key,
|
|
1080
1322
|
recentWhere: recent[0]?.where, reopenMs, reopenedFromCache,
|
|
1081
1323
|
idleFrames: idle.frames - settled.frames, looping: idle.looping, rows });
|
|
1082
1324
|
}
|
|
1083
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
|
+
|
|
1084
1780
|
// Waits for an imported file's scene, zooms in on the middle of the view, and reports how many
|
|
1085
1781
|
// triangles refinement brought, and how long after the view settled they were all in.
|
|
1086
1782
|
async function runRefineTest() {
|