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/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
- const phone = /iPhone|iPad|Android/.test(navigator.userAgent) ||
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
- masthead.onClose = () => closeFiles();
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)) await forgetKept(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
- let importing = Promise.resolve();
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 = importing.then(async () => {
576
- const cached = await cachedImport(file);
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
- worker.postMessage({ type: "import", file, kind: stl ? "stl" : "step", name: file.name,
621
- workers: importWorkers, threads: stl, progressive: !stl });
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 keptFile(file.name), file.name, add);
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, or the one the library kept beside its cache.
642
- // Refinements wait their turn behind imports, in the same worker and its warm OCCT pool.
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
- importing = importing.then(async () => {
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 : stepSources.get(name) ?? await keptSource(name);
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) refinement.cancelled = true;
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 a kept import, so the next open imports again.
699
- const forgetImport = (name) => forgetKept(name);
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") ?? "none").replaceAll("/", "-");
970
- return `${params.get("label") ?? "unlabelled"}-p4-${params.get("test")}-${name}`;
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 forgetKept(name);
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() {