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.
@@ -0,0 +1,359 @@
1
+ // The gallery's layout core (Engine/include/gallery.h), in the engine's module: the folder tree, the
2
+ // folder in view, and its display list, the same the Mac paints. Engine/browser/web_gallery.cpp
3
+ // carries it across; this reads the items from the module's memory.
4
+
5
+ export const Kinds = Object.freeze({ Rect: 0, Text: 1, Picture: 2, Line: 3 });
6
+ export const Flags = Object.freeze({ Dashed: 1, Hatched: 2, Sticker: 4, Fading: 8, Fixed: 16, Sheet: 32, Stale: 64, Lost: 128,
7
+ Ruler: 256, Turn: 512, Detail: 1024 });
8
+ export const Targets = Object.freeze({ None: 0, Folder: 1, File: 2, More: 3, Peek: 4, Reconnect: 5, Page: 6 });
9
+ export const Actions = Object.freeze({ None: 0, Handled: 1, EnterFolder: 2, OpenFile: 3, Peek: 4, Leave: 5,
10
+ Reconnect: 6, Turn: 7 });
11
+ export const Keys = Object.freeze({ Left: 0, Right: 1, Up: 2, Down: 3, Enter: 4, Space: 5, Escape: 6, FolderUp: 7 });
12
+ export const Views = Object.freeze({ Plates: 0, Sheet: 1, Ladders: 2, Atlas: 3, Catalogue: 4 });
13
+ export const ViewNames = Object.freeze(["Plates", "Sheet", "Ladders", "Atlas", "Catalogue"]);
14
+ export const Scales = Object.freeze({ Fit: 0, Folder: 1, True: 2 });
15
+ export const Sorts = Object.freeze({ Name: 0, Size: 1 });
16
+ export const Transitions = Object.freeze({ None: 0, Switch: 1, Into: 2, Out: 3 });
17
+ export const TrackKinds = Object.freeze({ Fly: 0, FadeIn: 1 });
18
+
19
+ const kItemSize = 116; // GeoGalleryItem: 29 four-byte fields
20
+ const kNone = 0xffffffff;
21
+
22
+ /** A colour from the core: straight sRGB, red in the low byte, as CSS. */
23
+ export function cssColour(rgba) {
24
+ if (!rgba) return "transparent";
25
+ const r = rgba & 0xff, g = (rgba >>> 8) & 0xff, b = (rgba >>> 16) & 0xff, a = (rgba >>> 24) / 255;
26
+ return a >= 1 ? `rgb(${r} ${g} ${b})` : `rgb(${r} ${g} ${b} / ${Math.round(a * 1000) / 1000})`;
27
+ }
28
+
29
+ export class GalleryCore {
30
+ #m;
31
+ #g;
32
+ #scratch;
33
+ #items = null;
34
+ #text = "";
35
+ #textBytes = null;
36
+
37
+ constructor(module) {
38
+ this.#m = module;
39
+ if (module._gv_gallery_item_size() !== kItemSize) throw new Error("The gallery's items are not the size the page reads.");
40
+ this.#g = module._gv_gallery_create();
41
+ this.#scratch = module._malloc(256);
42
+ }
43
+
44
+ destroy() {
45
+ this.#m._gv_gallery_destroy(this.#g);
46
+ this.#m._free(this.#scratch);
47
+ }
48
+
49
+ #string(text, use) {
50
+ const m = this.#m;
51
+ const size = m.lengthBytesUTF8(text) + 1;
52
+ const pointer = m._malloc(size);
53
+ m.stringToUTF8(text, pointer, size);
54
+ try {
55
+ return use(pointer);
56
+ } finally {
57
+ m._free(pointer);
58
+ }
59
+ }
60
+
61
+ /** Replaces the tree with a walk's entries: [{ path, folder, bytes, modified }]. */
62
+ setEntries(title, entries) {
63
+ const m = this.#m;
64
+ const n = entries.length;
65
+ const paths = entries.map((e) => e.path).join("\n");
66
+ const folders = m._malloc(Math.max(1, n));
67
+ const bytes = m._malloc(Math.max(8, n * 8));
68
+ const modified = m._malloc(Math.max(8, n * 8));
69
+ // Each call may grow the heap, which detaches views made before it: views after.
70
+ this.#string(paths, (p) => this.#string(title, (t) => {
71
+ m.HEAPU8.set(entries.map((e) => (e.folder ? 1 : 0)), folders);
72
+ new Float64Array(m.HEAPU8.buffer, bytes, n).set(entries.map((e) => e.bytes ?? 0));
73
+ new Float64Array(m.HEAPU8.buffer, modified, n).set(entries.map((e) => e.modified ?? 0));
74
+ m._gv_gallery_set_entries(this.#g, t, p, folders, bytes, modified, n);
75
+ }));
76
+ m._free(folders);
77
+ m._free(bytes);
78
+ m._free(modified);
79
+ this.#items = null;
80
+ }
81
+
82
+ get fileCount() { return this.#m._gv_gallery_file_count(this.#g); }
83
+ path(file) { return this.#m.UTF8ToString(this.#m._gv_gallery_file_path(this.#g, file)); }
84
+ fileAt(path) {
85
+ const file = this.#string(path, (p) => this.#m._gv_gallery_file_at(this.#g, p)) >>> 0;
86
+ return file === 0xffffffff ? null : file;
87
+ }
88
+
89
+ /** Facts as the library measured them: { size: [x, y, z], hasBox, estimated, parts, product, triangles }. */
90
+ setFacts(file, f) {
91
+ this.#string(f.product ?? "", (p) =>
92
+ this.#m._gv_gallery_set_facts(this.#g, file, f.size[0], f.size[1], f.size[2], f.hasBox ? 1 : 0,
93
+ f.estimated ? 1 : 0, f.parts ?? 0, p, f.triangles ?? 0));
94
+ }
95
+
96
+ /** A picture's note: { width, height, mmPerPixel, part: [x, y, w, h], ink: [x, y, w, h] }. */
97
+ setPicture(file, p) {
98
+ const m = this.#m;
99
+ const floats = new Float32Array(m.HEAPU8.buffer, this.#scratch, 8);
100
+ floats.set([...p.part, ...p.ink]);
101
+ m._gv_gallery_set_picture(this.#g, file, p.width, p.height, p.mmPerPixel, this.#scratch, this.#scratch + 16);
102
+ }
103
+
104
+ setFailure(file, message) {
105
+ this.#string(message || "This file could not be read.", (p) => this.#m._gv_gallery_set_failure(this.#g, file, p));
106
+ }
107
+
108
+ /** The view as a poster, printed with `date` in its foot. */
109
+ setPoster(poster, date = "") {
110
+ this.#string(date, (d) => this.#m._gv_gallery_set_poster(this.#g, poster ? 1 : 0, d));
111
+ }
112
+ get poster() { return this.#m._gv_gallery_poster(this.#g) !== 0; }
113
+
114
+ /** The last layout's lanes, which scroll sideways: [{ id, rect: [x, y, w, h], contentWidth }]. */
115
+ get lanes() {
116
+ const m = this.#m;
117
+ const n = m._gv_gallery_lanes(this.#g, 0, 0);
118
+ if (n === 0) return [];
119
+ const out = m._malloc(n * 24);
120
+ m._gv_gallery_lanes(this.#g, out, n);
121
+ const v = new Float32Array(m.HEAPU8.buffer, out, n * 6).slice();
122
+ m._free(out);
123
+ const lanes = [];
124
+ for (let i = 0; i < n; i++) {
125
+ lanes.push({ id: v[i * 6], rect: [v[i * 6 + 1], v[i * 6 + 2], v[i * 6 + 3], v[i * 6 + 4]], contentWidth: v[i * 6 + 5] });
126
+ }
127
+ return lanes;
128
+ }
129
+ setLaneScroll(lane, x) { this.#m._gv_gallery_set_lane_scroll(this.#g, lane, x); }
130
+ /** The selection's lane and the scroll that shows it, or null if it shows or is in none. */
131
+ revealLane() {
132
+ if (!this.#m._gv_gallery_reveal_lane(this.#g, this.#scratch)) return null;
133
+ const v = new Float32Array(this.#m.HEAPU8.buffer, this.#scratch, 2);
134
+ return { lane: v[0], x: v[1] };
135
+ }
136
+ /** Atlas's camera: { k, x, y, fit, level }, view points = map units × k + (x, y). */
137
+ get atlasCamera() {
138
+ this.#m._gv_gallery_atlas_camera(this.#g, this.#scratch);
139
+ const v = new Float32Array(this.#m.HEAPU8.buffer, this.#scratch, 4);
140
+ const [k, x, y, fit] = v;
141
+ return { k, x, y, fit, level: k < fit * 1.55 ? 0 : k < fit * 3.3 ? 1 : 2 };
142
+ }
143
+ /** Zooms the map by `factor` about a point of the view; pans it; or goes to a level (0–2). */
144
+ atlasZoom(factor, x, y) { this.#m._gv_gallery_atlas_zoom(this.#g, factor, x, y); }
145
+ atlasPan(dx, dy) { this.#m._gv_gallery_atlas_pan(this.#g, dx, dy); }
146
+ atlasLevel(level) { this.#m._gv_gallery_atlas_level(this.#g, level); }
147
+
148
+ /** Catalogue: { spread, spreads, index }: the spread in view, how many, and the index's first. */
149
+ get catalogue() {
150
+ this.#m._gv_gallery_catalogue(this.#g, this.#scratch);
151
+ const v = new Uint32Array(this.#m.HEAPU8.buffer, this.#scratch, 3);
152
+ return { spread: v[0], spreads: v[1], index: v[2] };
153
+ }
154
+ setCatalogueSpread(spread) { this.#m._gv_gallery_set_catalogue_spread(this.#g, spread); }
155
+ catalogueIndex() { this.#m._gv_gallery_catalogue_index(this.#g); }
156
+
157
+ /** Ladders' scale: each strip its own, or one shared. */
158
+ setLadders(shared) { this.#m._gv_gallery_set_ladders(this.#g, shared ? 1 : 0); }
159
+
160
+ /** The folder can no longer be read: what was last seen, faded, with Reconnect. */
161
+ setLost(lost) { this.#m._gv_gallery_set_lost(this.#g, lost ? 1 : 0); }
162
+
163
+ /** A file being read, 0 to 1, or negative once it is not. */
164
+ setReading(file, fraction) { this.#m._gv_gallery_set_reading(this.#g, file, fraction); }
165
+ /** A file changed on disk: a Δ until seen, its picture faded until a new one comes. */
166
+ setChanged(file, changed) { this.#m._gv_gallery_set_changed(this.#g, file, changed ? 1 : 0); }
167
+ setStyle(style) { this.#m._gv_gallery_set_style(this.#g, style); }
168
+ setViewport(width, height) { this.#m._gv_gallery_set_viewport(this.#g, width, height); }
169
+ setScroll(top) { this.#m._gv_gallery_set_scroll(this.#g, top); }
170
+ setFolder(path) { return this.#string(path, (p) => this.#m._gv_gallery_set_folder(this.#g, p)) !== 0; }
171
+ get folder() { return this.#m.UTF8ToString(this.#m._gv_gallery_folder(this.#g)); }
172
+ get title() { return this.#m.UTF8ToString(this.#m._gv_gallery_folder_title(this.#g)); }
173
+ get subtitle() { return this.#m.UTF8ToString(this.#m._gv_gallery_folder_subtitle(this.#g)); }
174
+ get contentHeight() { return this.#m._gv_gallery_content_height(this.#g); }
175
+ get picturePixels() { return this.#m._gv_gallery_picture_pixels(this.#g); }
176
+
177
+ /** Lays out if anything changed; returns whether it did. */
178
+ layout() {
179
+ const changed = this.#m._gv_gallery_layout(this.#g) !== 0;
180
+ if (changed) this.#items = null;
181
+ return changed;
182
+ }
183
+
184
+ /** The display list, decoded once a layout. */
185
+ get items() {
186
+ if (this.#items) return this.#items;
187
+ const m = this.#m;
188
+ const n = m._gv_gallery_item_count(this.#g);
189
+ const base = m._gv_gallery_items(this.#g);
190
+ const view = new DataView(m.HEAPU8.buffer, base, n * kItemSize);
191
+ const text = m._gv_gallery_text(this.#g);
192
+ const items = new Array(n);
193
+ let end = 0;
194
+ for (let i = 0; i < n; i++) {
195
+ const o = i * kItemSize;
196
+ const u = (k) => view.getUint32(o + k * 4, true);
197
+ const f = (k) => view.getFloat32(o + k * 4, true);
198
+ items[i] = {
199
+ kind: u(0), flags: u(1), x: f(2), y: f(3), width: f(4), height: f(5),
200
+ fill: u(6), stroke: u(7), shadow: u(8), lineWidth: f(9), radius: f(10),
201
+ text: u(11), textLength: u(12), fontSize: f(13), tracking: f(14), weight: u(15), align: u(16),
202
+ baseline: f(17), file: u(18), targetKind: u(19), target: u(20), key: u(21),
203
+ group: u(22), line: f(23), column: u(24), columnLine: f(25),
204
+ stickyTop: f(26), stickyUntil: f(27), lane: u(28),
205
+ };
206
+ end = Math.max(end, items[i].text + items[i].textLength);
207
+ }
208
+ this.#textBytes = m.HEAPU8.slice(text, text + end);
209
+ this.#items = items;
210
+ return items;
211
+ }
212
+
213
+ /** An item's text. */
214
+ text(item) {
215
+ return new TextDecoder().decode(this.#textBytes.subarray(item.text, item.text + item.textLength));
216
+ }
217
+
218
+ hit(x, y) {
219
+ const m = this.#m;
220
+ m._gv_gallery_hit(this.#g, x, y, this.#scratch);
221
+ const out = new Uint32Array(m.HEAPU8.buffer, this.#scratch, 3);
222
+ return { kind: out[0], target: out[1], key: out[2] };
223
+ }
224
+
225
+ hover(kind, target) { return this.#m._gv_gallery_hover(this.#g, kind, target) !== 0; }
226
+
227
+ #action(call) {
228
+ call(this.#scratch);
229
+ const out = new Uint32Array(this.#m.HEAPU8.buffer, this.#scratch, 2);
230
+ return { kind: out[0], target: out[1] };
231
+ }
232
+
233
+ click(kind, target, twice) {
234
+ return this.#action((out) => this.#m._gv_gallery_click(this.#g, kind, target, twice ? 1 : 0, out));
235
+ }
236
+
237
+ key(key) {
238
+ return this.#action((out) => this.#m._gv_gallery_key(this.#g, key, out));
239
+ }
240
+
241
+ // ---- Views, search, Peek and motion ----
242
+
243
+ /** The views that may be shown, a bit each (1 << Views.Plates ...). */
244
+ setViews(mask) { this.#m._gv_gallery_set_views(this.#g, mask); }
245
+ get views() { return this.#m._gv_gallery_views(this.#g) >>> 0; }
246
+ /** Shows a view if it is enabled; the folder in view remembers it. */
247
+ setView(view) { return this.#m._gv_gallery_set_view(this.#g, view) !== 0; }
248
+ get view() { return this.#m._gv_gallery_view(this.#g); }
249
+ /** Sheet's scale (Scales), cell size in points and order (Sorts). */
250
+ setSheet(scale, cell, sort) { this.#m._gv_gallery_set_sheet(this.#g, scale, cell, sort); }
251
+
252
+ setSearch(query) { this.#string(query, (p) => this.#m._gv_gallery_set_search(this.#g, p)); }
253
+ get search() { return this.#m.UTF8ToString(this.#m._gv_gallery_search(this.#g)); }
254
+ /** How many files the search found, or null with no search. */
255
+ get matches() {
256
+ const n = this.#m._gv_gallery_matches(this.#g) >>> 0;
257
+ return n === kNone ? null : n;
258
+ }
259
+
260
+ peek(file) { this.#m._gv_gallery_peek(this.#g, file ?? kNone); }
261
+ get peeking() {
262
+ const file = this.#m._gv_gallery_peeking(this.#g) >>> 0;
263
+ return file === kNone ? null : file;
264
+ }
265
+ /** Whether the file in Peek can turn: its turntable's frames have come. */
266
+ setPeekTurnable(turnable) { this.#m._gv_gallery_set_peek_turnable(this.#g, turnable); }
267
+ get peekTurnable() { return Boolean(this.#m._gv_gallery_peek_turnable(this.#g)); }
268
+
269
+ /** The selected file, or null. */
270
+ get selected() {
271
+ const file = this.#m._gv_gallery_selected(this.#g) >>> 0;
272
+ return file === kNone ? null : file;
273
+ }
274
+
275
+ /** Where to scroll so the selection shows, or null if it shows already. */
276
+ get reveal() {
277
+ const top = this.#m._gv_gallery_reveal(this.#g);
278
+ return top < 0 ? null : top;
279
+ }
280
+
281
+ /** What the last layout plays against the one before: { kind, rect: [x, y, w, h], duration, fadeOut }. */
282
+ get transition() {
283
+ const m = this.#m;
284
+ m._gv_gallery_transition(this.#g, this.#scratch);
285
+ const v = new Float32Array(m.HEAPU8.buffer, this.#scratch, 7);
286
+ return { kind: v[0], rect: [v[1], v[2], v[3], v[4]], duration: v[5], fadeOut: v[6] };
287
+ }
288
+
289
+ /** The last layout's tracks: [{ item, kind, from: [x, y, w, h], delay, duration }]. */
290
+ get tracks() {
291
+ const m = this.#m;
292
+ const n = m._gv_gallery_tracks(this.#g, 0, 0);
293
+ if (n === 0) return [];
294
+ const out = m._malloc(n * 32);
295
+ m._gv_gallery_tracks(this.#g, out, n);
296
+ const v = new Float32Array(m.HEAPU8.buffer, out, n * 8).slice();
297
+ m._free(out);
298
+ const tracks = new Array(n);
299
+ for (let i = 0; i < n; i++) {
300
+ const o = i * 8;
301
+ tracks[i] = { item: v[o], kind: v[o + 1], from: [v[o + 2], v[o + 3], v[o + 4], v[o + 5]],
302
+ delay: v[o + 6], duration: v[o + 7] };
303
+ }
304
+ return tracks;
305
+ }
306
+
307
+ /** The files drawn large in view, nearest the middle first: those whose detail pictures are wanted. */
308
+ details() {
309
+ const m = this.#m;
310
+ const out = m._malloc(32 * 4);
311
+ const n = Math.min(32, m._gv_gallery_details(this.#g, out, 32));
312
+ const files = [...new Uint32Array(m.HEAPU8.buffer, out, n)];
313
+ m._free(out);
314
+ return files;
315
+ }
316
+
317
+ /** The files to draw, in the library's bands: [{ file, band, order, folderColor }]. */
318
+ wanted() {
319
+ const m = this.#m;
320
+ const n = m._gv_gallery_wanted(this.#g, 0, 0);
321
+ const out = m._malloc(Math.max(32, n * 32));
322
+ m._gv_gallery_wanted(this.#g, out, n);
323
+ const values = new Float64Array(m.HEAPU8.buffer, out, n * 4).slice();
324
+ m._free(out);
325
+ const wants = new Array(n);
326
+ for (let i = 0; i < n; i++) {
327
+ wants[i] = { file: values[i * 4], band: values[i * 4 + 1], order: values[i * 4 + 2], folderColor: values[i * 4 + 3] };
328
+ }
329
+ return wants;
330
+ }
331
+
332
+ /** The alignment groups painted more than `tolerance` apart: { over: [{ group, column, spread, items }], checked }. */
333
+ check(lines, columns, tolerance = 0.5) {
334
+ const m = this.#m;
335
+ const n = lines.length;
336
+ const l = m._malloc(Math.max(4, n * 4)), c = m._malloc(Math.max(4, n * 4));
337
+ const out = m._malloc(64 * 32), checked = m._malloc(4);
338
+ new Float32Array(m.HEAPU8.buffer, l, n).set(lines);
339
+ new Float32Array(m.HEAPU8.buffer, c, n).set(columns);
340
+ const over = m._gv_gallery_check(this.#g, l, c, tolerance, out, 64, checked);
341
+ const values = new Float64Array(m.HEAPU8.buffer, out, Math.min(over, 64) * 4).slice();
342
+ const count = new Uint32Array(m.HEAPU8.buffer, checked, 1)[0];
343
+ for (const p of [l, c, out, checked]) m._free(p);
344
+ const groups = [];
345
+ for (let i = 0; i < values.length / 4; i++) {
346
+ groups.push({ group: values[i * 4], column: values[i * 4 + 1] === 1, spread: values[i * 4 + 2], items: values[i * 4 + 3] });
347
+ }
348
+ return { over: groups, checked: count };
349
+ }
350
+
351
+ /** A style's sheet: { top, bottom, vignette, grid, gridAlpha, gridPitch, dark }. */
352
+ sheet(style) {
353
+ const m = this.#m;
354
+ m._gv_style_sheet(style, this.#scratch);
355
+ const v = new Float32Array(m.HEAPU8.buffer, this.#scratch, 13);
356
+ return { top: [v[0], v[1], v[2]], bottom: [v[3], v[4], v[5]], vignette: v[6], grid: [v[7], v[8], v[9]],
357
+ gridAlpha: v[10], gridPitch: v[11], dark: v[12] === 1 };
358
+ }
359
+ }
@@ -0,0 +1,262 @@
1
+ // The gallery's library in the browser: the files a gallery wants, in bands, become facts and
2
+ // pictures, from what this browser kept where it can, else by importing and drawing, as the Mac's
3
+ // library::Service does (Engine/library/service.h).
4
+ //
5
+ // A file is looked up by its key (kept.js): its facts and its picture in the current drawing spec.
6
+ // Missing either, its kept import is drawn; missing that too, it is imported first, in the gallery's
7
+ // lane of the page's importers (importers.js), so a file a person opens never waits behind it.
8
+ // Pictures are drawn in the picture worker (thumbs.js), which keeps them and the facts.
9
+
10
+ import { keptImport, keptPicture, keyOf, pictureKeyOf } from "./kept.js";
11
+
12
+ const kBands = ["open", "visible", "near", "prefetch"];
13
+
14
+ // A phone stops a page well before it would on a computer, and its gallery imports alongside its
15
+ // pictures and page: a file larger than this is not imported there. It shows as a file that could
16
+ // not be drawn, saying why, and opens on a computer. A STEP file's import takes many times its
17
+ // size (a 16 MB file 390-700 MB on an iPhone 16 Pro), an STL file's about 115 bytes a triangle.
18
+ export const kPhoneStepBytes = 12 * 1024 ** 2;
19
+ export const kPhoneStlBytes = 16 * 1024 ** 2;
20
+
21
+ function tooLargeForPhone(bytes) {
22
+ return new Error(`Too large to draw on a phone (${Math.round(bytes / 1024 ** 2)} MB). Open it on a computer.`);
23
+ }
24
+
25
+ export class GalleryLibrary {
26
+ #importers;
27
+ #thumbnailer;
28
+ #version;
29
+ #on;
30
+ #worker = null;
31
+ #folders = null; // the store's folders, which read a connected folder's files
32
+ #lost = false;
33
+
34
+ /** The folder can no longer be read: show only what was kept. */
35
+ setLost(lost) {
36
+ this.#lost = lost;
37
+ if (!lost) {
38
+ this.#done.clear();
39
+ this.#failed.clear();
40
+ }
41
+ }
42
+
43
+ // What was kept of a file, without reading it: its key from the last listing's size and time.
44
+ async #kept(w) {
45
+ const root = this.#root, spec = this.#spec;
46
+ if (!root || !spec) return;
47
+ const key = await keyOf({ source: root.name, path: w.path, size: w.bytes, modified: w.modified,
48
+ version: this.#version });
49
+ const picture = await pictureKeyOf({ ...spec, folderColor: w.folderColor, version: this.#version });
50
+ const kept = await keptPicture(key, picture);
51
+ if (kept?.facts) this.#on.facts?.(w.path, kept.facts);
52
+ if (kept?.png && kept.note) this.#on.picture?.(w.path, kept.png, kept.note);
53
+ }
54
+ #root = null; // { id, name }
55
+ #spec = null;
56
+ #done = new Set(); // paths with their facts and picture in the current spec
57
+ #failed = new Set();
58
+ #phone;
59
+
60
+ /**
61
+ * `on` takes facts(path, facts), picture(path, png, note) and failed(path, message), as each is
62
+ * known, and reading(path, fraction) while a file is imported. `version` is the cache files'
63
+ * version, which keys hold.
64
+ */
65
+ // Whether the walk's sizes and times find a file in the caches, so the file itself is fetched
66
+ // only to import it: the shell's are its own, and fetching a file there reads all of it.
67
+ #walkStats;
68
+
69
+ constructor({ importers, thumbnailer, version, phone = false, walkStats = false }, on) {
70
+ this.#walkStats = walkStats;
71
+ this.#importers = importers;
72
+ this.#thumbnailer = thumbnailer;
73
+ this.#version = version;
74
+ this.#phone = phone;
75
+ this.#on = on;
76
+ }
77
+
78
+ /** The connected folder the gallery shows, and the store's folders that read it. */
79
+ setRoot(folders, root) {
80
+ this.#folders = folders;
81
+ this.#root = root;
82
+ this.#done.clear();
83
+ this.#failed.clear();
84
+ }
85
+
86
+ /** How pictures are drawn: { style, colorMode, pixels, pixelsPerPoint, lineScale }. */
87
+ setSpec(spec) {
88
+ this.#spec = spec;
89
+ this.#done.clear();
90
+ }
91
+
92
+ /**
93
+ * Replaces what is wanted: [{ path, band (1 in view .. 3 elsewhere), order, folderColor, bytes,
94
+ * modified }]. While the folder cannot be read, only what was kept is shown, found by the size and
95
+ * time the last listing gave.
96
+ */
97
+ want(wants) {
98
+ const jobs = [];
99
+ for (const w of wants) {
100
+ if (this.#done.has(w.path) || this.#failed.has(w.path)) continue;
101
+ jobs.push({ id: w.path, band: kBands[w.band] ?? "prefetch", order: w.order, bytes: w.bytes ?? 0,
102
+ run: (signal) => (this.#lost ? this.#kept(w) : this.#draw(w.path, w.folderColor, signal, w)) });
103
+ }
104
+ this.#importers.want(jobs, {
105
+ onDone: (path) => this.#done.add(path),
106
+ onFailed: (path, error) => {
107
+ this.#failed.add(path);
108
+ this.#on.failed?.(path, String(error?.message ?? error));
109
+ },
110
+ });
111
+ }
112
+
113
+ // Where a file is in the caches: its key, from the size and time its walk gave where they are
114
+ // trusted, else from the file itself, fetched. { key, bytes, file }, the file null until needed.
115
+ async #locate(path, stat) {
116
+ const root = this.#root;
117
+ let file = null, bytes = stat?.bytes ?? 0, modified = stat?.modified ?? 0;
118
+ if (!this.#walkStats || !(bytes > 0 && modified > 0)) {
119
+ file = await this.#folders.file(root.id, path);
120
+ if (!file) throw new Error("This file could not be read.");
121
+ bytes = file.size;
122
+ modified = file.lastModified;
123
+ // Its size and time, kept with the listing, find its picture again if the folder cannot be read.
124
+ this.#on.measured?.(path, bytes, modified);
125
+ }
126
+ const key = await keyOf({ source: root.name, path, size: bytes, modified, version: this.#version });
127
+ return { key, bytes, file };
128
+ }
129
+
130
+ // The file itself, to import it: fetched now if its walk found it.
131
+ async #fileOf(path, located) {
132
+ located.file ??= await this.#folders.file(this.#root.id, path);
133
+ if (!located.file) throw new Error("This file could not be read.");
134
+ return located.file;
135
+ }
136
+
137
+ #tooLarge(path, bytes) {
138
+ return this.#phone && bytes > (/\.stl$/i.test(path) ? kPhoneStlBytes : kPhoneStepBytes);
139
+ }
140
+
141
+ async #draw(path, folderColor, signal, stat) {
142
+ const root = this.#root, spec = this.#spec;
143
+ if (!root || !spec) return;
144
+ const located = await this.#locate(path, stat);
145
+ const { key } = located;
146
+ const picture = await pictureKeyOf({ ...spec, folderColor, version: this.#version });
147
+ // Kept before: its facts at once, and its picture if drawn in this spec.
148
+ const kept = await keptPicture(key, picture);
149
+ if (kept?.facts) this.#on.facts?.(path, kept.facts);
150
+ if (kept?.png && kept.note) {
151
+ this.#on.picture?.(path, kept.png, kept.note);
152
+ return;
153
+ }
154
+ if (signal.aborted) throw new Error("cancelled");
155
+ // Its import kept before, or imported now.
156
+ let blob = (await keptImport(key, this.#version))?.blob;
157
+ if (!blob && this.#tooLarge(path, located.bytes)) throw tooLargeForPhone(located.bytes);
158
+ if (!blob) {
159
+ this.#on.reading?.(path, 0);
160
+ const file = await this.#fileOf(path, located);
161
+ blob = await this.#import(file, key, signal, (fraction) => this.#on.reading?.(path, fraction));
162
+ }
163
+ const drawn = await this.#thumbnailer.draw(blob, spec, -1, { folderColor, keep: { key, picture } });
164
+ drawn.bitmap?.close();
165
+ if (drawn.facts) this.#on.facts?.(path, drawn.facts);
166
+ this.#on.picture?.(path, drawn.png, { width: drawn.width, height: drawn.height, mmPerPixel: drawn.worldPerPixel,
167
+ part: drawn.partRect, ink: drawn.inkRect, firstColor: drawn.firstColor });
168
+ }
169
+
170
+ /**
171
+ * A file's detail picture, for where it is drawn large: the same picture at twice the pixels and
172
+ * twice the pixels a point, kept like the gallery's and read back if kept. The file is imported
173
+ * first if its import was not kept. Resolves to { png, note }, or null when it cannot be drawn.
174
+ */
175
+ async detail(path, folderColor, signal, stat) {
176
+ const root = this.#root, spec = this.#spec;
177
+ if (!root || !spec) return null;
178
+ const detail = { ...spec, pixels: spec.pixels * 2, pixelsPerPoint: spec.pixelsPerPoint * 2, supersample: 1 };
179
+ const located = await this.#locate(path, stat).catch(() => null);
180
+ if (!located) return null;
181
+ const { key } = located;
182
+ const picture = await pictureKeyOf({ ...detail, folderColor, version: this.#version });
183
+ const kept = await keptPicture(key, picture);
184
+ if (kept?.png && kept.note) return { png: kept.png, note: kept.note };
185
+ if (signal.aborted) return null;
186
+ let blob = (await keptImport(key, this.#version))?.blob;
187
+ if (!blob && this.#tooLarge(path, located.bytes)) return null;
188
+ if (!blob) blob = await this.#import(await this.#fileOf(path, located), key, signal);
189
+ if (signal.aborted) return null;
190
+ const drawn = await this.#thumbnailer.draw(blob, detail, -1, { folderColor, keep: { key, picture } });
191
+ drawn.bitmap?.close();
192
+ return { png: drawn.png, note: { width: drawn.width, height: drawn.height, mmPerPixel: drawn.worldPerPixel,
193
+ part: drawn.partRect, ink: drawn.inkRect } };
194
+ }
195
+
196
+ /**
197
+ * Peek's turntable: `frames` pictures of the file at `path`, `pixels` square, turned evenly about
198
+ * the upright at one scale (its box's diagonal, from `size` in millimetres, fits whichever way it
199
+ * faces), each handed to `onFrame(frame, bitmap, note)` as it is drawn. The file is imported first
200
+ * if its import was not kept. Stops when `signal` aborts.
201
+ */
202
+ async turn(path, folderColor, frames, pixels, size, onFrame, signal, stat) {
203
+ const root = this.#root, spec = this.#spec;
204
+ if (!root || !spec) return false;
205
+ const located = await this.#locate(path, stat).catch(() => null);
206
+ if (!located) return false;
207
+ const { key } = located;
208
+ let blob = (await keptImport(key, this.#version))?.blob;
209
+ if (!blob && this.#tooLarge(path, located.bytes)) return false; // too large here
210
+ if (!blob) {
211
+ if (signal.aborted) return false;
212
+ blob = await this.#import(await this.#fileOf(path, located), key, signal);
213
+ }
214
+ const worldPerPixel = size ? Math.hypot(...size) * 1.1 / pixels : 0;
215
+ for (let frame = 0; frame < frames; frame++) {
216
+ if (signal.aborted) return false;
217
+ // Drawn as a detail is: lines as thick for its size as a picture's, and not supersampled.
218
+ const drawn = await this.#thumbnailer.draw(blob, { ...spec, pixels, turn: (360 * frame) / frames, worldPerPixel,
219
+ pixelsPerPoint: spec.pixelsPerPoint * pixels / spec.pixels,
220
+ supersample: 1 }, -1, { folderColor });
221
+ onFrame(frame, drawn.bitmap, { width: drawn.width, height: drawn.height, ink: drawn.inkRect });
222
+ }
223
+ return !signal.aborted;
224
+ }
225
+
226
+ // Imports in the gallery's own worker; a file in a connected folder keeps no source copy.
227
+ async #import(file, key, signal, progress) {
228
+ this.#worker ??= new Worker(new URL("./import-worker.js", import.meta.url), { type: "module" });
229
+ const worker = this.#worker;
230
+ const stl = /\.stl$/i.test(file.name);
231
+ const result = await new Promise((resolve) => {
232
+ worker.onmessage = ({ data }) => {
233
+ if (data.type === "progress" && data.total > 0) progress?.(data.done / data.total);
234
+ if (data.type === "done" || data.type === "failed") resolve(data);
235
+ };
236
+ worker.onerror = (event) => resolve({ type: "failed", message: event.message ?? "The importer failed." });
237
+ signal.addEventListener("abort", () => worker.postMessage({ type: "cancel" }), { once: true });
238
+ worker.postMessage({ type: "import", file, kind: stl ? "stl" : "step", name: file.name, key, keepSource: false,
239
+ workers: this.#phone ? 1 : 2, threads: false, progressive: false });
240
+ });
241
+ // A phone lets its importer go after each file: its heap only grows, and the page's memory is
242
+ // what the phone watches.
243
+ if (this.#phone) {
244
+ worker.terminate();
245
+ this.#worker = null;
246
+ }
247
+ if (result.type !== "done") throw new Error(result.message ?? "This file could not be read.");
248
+ const kept = await keptImport(key, this.#version);
249
+ if (!kept) throw new Error("The import was not kept.");
250
+ return kept.blob;
251
+ }
252
+ }
253
+
254
+ /** Facts as kept (scene::Facts' JSON) to the layout's: sides in millimetres, parts and name. */
255
+ export function layoutFacts(facts) {
256
+ const hasBox = Array.isArray(facts.min) && Array.isArray(facts.max);
257
+ return {
258
+ size: hasBox ? facts.max.map((v, i) => v - facts.min[i]) : [0, 0, 0],
259
+ hasBox, estimated: Boolean(facts.estimated), parts: facts.parts ?? 0, product: facts.name ?? "",
260
+ triangles: facts.triangles ?? 0,
261
+ };
262
+ }