geometryview 0.1.1 → 0.3.0

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