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.
@@ -0,0 +1,168 @@
1
+ // The order in which files are imported and drawn: the browser's copy of the Mac's library::Queue
2
+ // (Engine/library/queue.h). Both run the cases in Tests/data/gallery-queue.json, so a gallery
3
+ // fills in the same order everywhere. Pure policy: who runs a job, and how, is the caller's.
4
+ //
5
+ // What is wanted is replaced whole as the view moves: each job has a band (a file being opened, one
6
+ // in view, one near the view, or one prefetched while nothing else waits) and an order within its
7
+ // band. Jobs start best first while slots are free. A job no longer wanted is dropped before it
8
+ // starts; once running it runs on, since its result is kept, unless a job in view needs its slot.
9
+ // Only a file being opened or one in view ever cancels a running job, and only one in a lower band.
10
+
11
+ export const Bands = Object.freeze({ open: 0, visible: 1, near: 2, prefetch: 3 });
12
+
13
+ /** Files larger than this are never prefetched: they are imported only when in view. */
14
+ export const kMaxPrefetchBytes = 300_000_000;
15
+
16
+ const kStale = 4; // a running job no longer wanted ranks below every band
17
+
18
+ export class ImportQueue {
19
+ #slots;
20
+ #prefetch;
21
+ #jobs = new Map(); // id -> { band, order, bytes, position, wanted, running, cancelling }
22
+ #waiting = []; // ids, best first
23
+ #running = 0;
24
+
25
+ constructor({ slots = 1, prefetch = true } = {}) {
26
+ this.#slots = slots;
27
+ this.#prefetch = prefetch;
28
+ }
29
+
30
+ /**
31
+ * Replaces what is wanted: jobs as { id, band, order, bytes }, band a name from Bands. A waiting
32
+ * job not wanted is dropped; a running one runs on, stale. A job is started again only if it is
33
+ * wanted after it finished.
34
+ */
35
+ want(jobs) {
36
+ for (const job of this.#jobs.values()) job.wanted = false;
37
+ jobs.forEach(({ id, band, order = 0, bytes = 0 }, position) => {
38
+ const rank = Bands[band];
39
+ if (rank === undefined) throw new Error(`No band named ${band}`);
40
+ const job = this.#jobs.get(id);
41
+ if (job) {
42
+ Object.assign(job, { band: rank, order, bytes, position, wanted: true });
43
+ } else {
44
+ this.#jobs.set(id, { band: rank, order, bytes, position, wanted: true, running: false, cancelling: false });
45
+ }
46
+ });
47
+ for (const [id, job] of this.#jobs) {
48
+ if (!job.wanted && !job.running) this.#jobs.delete(id);
49
+ }
50
+ this.#sortWaiting();
51
+ }
52
+
53
+ /** Starts the best waiting job, if a slot is free and one may start, and returns its id, or null. */
54
+ next() {
55
+ if (this.#running >= this.#slots) return null;
56
+ for (let i = 0; i < this.#waiting.length; i++) {
57
+ const id = this.#waiting[i];
58
+ const job = this.#jobs.get(id);
59
+ if (job.band === Bands.prefetch) {
60
+ if (!this.#prefetch) break; // prefetched jobs come last
61
+ if (job.bytes > kMaxPrefetchBytes) continue;
62
+ }
63
+ this.#waiting.splice(i, 1);
64
+ job.running = true;
65
+ this.#running++;
66
+ return id;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /** A running job completed, or failed: it is done with. */
72
+ finish(id) {
73
+ const job = this.#jobs.get(id);
74
+ if (!job?.running) return;
75
+ this.#running--;
76
+ this.#jobs.delete(id);
77
+ }
78
+
79
+ /** A running job stopped before completing, as when cancelled: if still wanted it waits again. */
80
+ stopped(id) {
81
+ const job = this.#jobs.get(id);
82
+ if (!job?.running) return;
83
+ this.#running--;
84
+ if (!job.wanted) {
85
+ this.#jobs.delete(id);
86
+ return;
87
+ }
88
+ job.running = false;
89
+ job.cancelling = false;
90
+ this.#sortWaiting();
91
+ }
92
+
93
+ /**
94
+ * Running jobs to cancel now, worst first, so that waiting jobs in view can start. Each is
95
+ * returned once, and keeps its slot until it has stopped.
96
+ */
97
+ toCancel() {
98
+ let available = Math.max(0, this.#slots - this.#running);
99
+ const candidates = [];
100
+ for (const [id, job] of this.#jobs) {
101
+ if (!job.running) continue;
102
+ if (job.cancelling) {
103
+ available++;
104
+ continue;
105
+ }
106
+ candidates.push({ id, rank: job.wanted ? job.band : kStale, order: job.order, position: job.position });
107
+ }
108
+ candidates.sort((a, b) => compare(b, a)); // worst first
109
+ const cancelled = [];
110
+ for (const id of this.#waiting) {
111
+ const band = this.#jobs.get(id).band;
112
+ if (band > Bands.visible) break;
113
+ if (available > 0) {
114
+ available--;
115
+ continue;
116
+ }
117
+ const worse = candidates.findIndex((c) => c.rank > band);
118
+ if (worse < 0) break; // later jobs are in the same band or lower, and can cancel no more
119
+ cancelled.push(candidates[worse].id);
120
+ candidates.splice(worse, 1);
121
+ }
122
+ for (const id of cancelled) this.#jobs.get(id).cancelling = true;
123
+ return cancelled;
124
+ }
125
+
126
+ /** Fewer slots, as memory runs short, start nothing new until running jobs fall below them. */
127
+ setSlots(slots) {
128
+ this.#slots = slots;
129
+ }
130
+ get slots() {
131
+ return this.#slots;
132
+ }
133
+ /** Whether prefetched jobs may start. */
134
+ setPrefetch(allowed) {
135
+ this.#prefetch = allowed;
136
+ }
137
+
138
+ waiting() {
139
+ return [...this.#waiting];
140
+ }
141
+ running() {
142
+ return [...this.#jobs]
143
+ .filter(([, job]) => job.running)
144
+ .sort(([a, x], [b, y]) => compare({ id: a, rank: x.band, ...x }, { id: b, rank: y.band, ...y }))
145
+ .map(([id]) => id);
146
+ }
147
+ get idle() {
148
+ return this.#waiting.length === 0 && this.#running === 0;
149
+ }
150
+ /** A job's band, by name, if the queue holds it. */
151
+ bandOf(id) {
152
+ const job = this.#jobs.get(id);
153
+ return job ? Object.keys(Bands).find((name) => Bands[name] === job.band) : undefined;
154
+ }
155
+
156
+ #sortWaiting() {
157
+ this.#waiting = [...this.#jobs]
158
+ .filter(([, job]) => !job.running)
159
+ .map(([id, job]) => ({ id, rank: job.band, order: job.order, position: job.position }))
160
+ .sort(compare)
161
+ .map((job) => job.id);
162
+ }
163
+ }
164
+
165
+ // Best first: band, then order, then where it was wanted, then id, as the Mac's queue keys jobs.
166
+ function compare(a, b) {
167
+ return a.rank - b.rank || a.order - b.order || a.position - b.position || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
168
+ }
@@ -13,13 +13,25 @@
13
13
  // A STEP file's source is kept beside its cache, so its definitions can be imported again, finer,
14
14
  // when the view comes close: a refinement, whose segments the page adds to the open document.
15
15
  //
16
- // Messages in: { type: "import", file, kind: "step" | "stl", name, workers, threads, progressive }
16
+ // A cache file is kept under its key (kept.js); a file the page can read again from a connected
17
+ // folder keeps no source.
18
+ //
19
+ // Messages in: { type: "import", file, kind: "step" | "stl", name, key, keepSource, workers,
20
+ // threads, progressive }
17
21
  // { type: "refine", file, level, definitions, workers }
18
- // Messages out: { type: "progress", done, total } and, if progressive or refining,
19
- // { type: "segment", bytes } as each is written; then { type: "done", ... } or
20
- // { type: "failed", message }
22
+ // { type: "structure", file }: a STEP file's model alone, as one segment, for an
23
+ // estimated box while it waits to import; nothing is kept
24
+ // { type: "cancel" }: the import or refinement running stops after the slices out
25
+ // Messages out: { type: "progress", done, total } and, if progressive, refining or reading
26
+ // structure, { type: "segment", bytes } as each is written; then
27
+ // { type: "done", ... } or { type: "failed", message, cancelled }
21
28
 
22
29
  import createStepImporter from "./occt/geometryview-step-web.mjs";
30
+ import { keepImport, keepSource as keepSourceByKey } from "./kept.js";
31
+
32
+ // Set by a cancel message, which arrives while an import awaits its slices.
33
+ let cancelled = false;
34
+ const kCancelled = "The import was cancelled.";
23
35
 
24
36
  // Some browsers refuse a worker that starts another worker by URL. Claude Desktop's built-in
25
37
  // browser is one: the nested worker fails at once, with no message and no request sent. A module
@@ -130,6 +142,14 @@ function mesh(step, slots, progressive) {
130
142
  let closed = false;
131
143
 
132
144
  const pump = () => {
145
+ if (cancelled && !closed) {
146
+ // No more slices: once those out are answered, the import stops.
147
+ if (busy === 0) {
148
+ closed = true;
149
+ Promise.all(replacing).then(() => reject(new Error(kCancelled)));
150
+ }
151
+ return;
152
+ }
133
153
  while (!closed && idle.length > 0) {
134
154
  const id = step._step_next();
135
155
  if (id === -2) {
@@ -255,6 +275,7 @@ async function readInto(step, file) {
255
275
  // Imports some of a STEP file's definitions again at a finer level, posting each segment as it is
256
276
  // written; they hold geometry only, for the page to add to the document already open.
257
277
  async function refine(data) {
278
+ cancelled = false;
258
279
  try {
259
280
  const step = await supervisor(false);
260
281
  await readInto(step, data.file);
@@ -271,7 +292,27 @@ async function refine(data) {
271
292
  self.postMessage({ type: "done", triangles: stats[11], faces: stats[10], failedFaces: stats[12],
272
293
  crashedWorkers: stats[14], coarserFaces: stats[16] });
273
294
  } catch (error) {
274
- self.postMessage({ type: "failed", message: String(error.message ?? error) });
295
+ failed(error);
296
+ }
297
+ }
298
+
299
+ function failed(error) {
300
+ const message = String(error.message ?? error);
301
+ self.postMessage({ type: "failed", message, cancelled: message === kCancelled });
302
+ }
303
+
304
+ // Reads a STEP file's structure alone and posts its model, with each body's box estimated, as one
305
+ // segment: what a gallery lays a file out from while it waits to import. Nothing is kept, and no
306
+ // OCCT worker is needed.
307
+ async function structure(data) {
308
+ try {
309
+ const step = await supervisor(false);
310
+ await readInto(step, data.file);
311
+ if (step._step_begin() !== 0) throw new Error(step.UTF8ToString(step._step_error()));
312
+ postSegments(step);
313
+ self.postMessage({ type: "done", structure: true });
314
+ } catch (error) {
315
+ failed(error);
275
316
  }
276
317
  }
277
318
 
@@ -289,11 +330,20 @@ async function readPieces(step, file) {
289
330
  }
290
331
 
291
332
  self.onmessage = async ({ data }) => {
333
+ if (data.type === "cancel") {
334
+ cancelled = true;
335
+ return;
336
+ }
292
337
  if (data.type === "refine") {
293
338
  await refine(data);
294
339
  return;
295
340
  }
341
+ if (data.type === "structure") {
342
+ await structure(data);
343
+ return;
344
+ }
296
345
  if (data.type !== "import") return;
346
+ cancelled = false;
297
347
  const started = performance.now();
298
348
  try {
299
349
  const step = await supervisor(data.threads && self.crossOriginIsolated);
@@ -319,11 +369,15 @@ self.onmessage = async ({ data }) => {
319
369
  coarserFaces: stats[16], slices,
320
370
  importMs: Math.round(performance.now() - started), cacheBytes: blob.length,
321
371
  supervisorMB: Math.round(step.HEAPU8.length / 2 ** 20) };
322
- await keep(data.name, blob, { name: data.name, size: data.file.size,
323
- lastModified: data.file.lastModified, importedAt: Date.now(), ...result });
372
+ const note = { name: data.name, size: data.file.size, lastModified: data.file.lastModified,
373
+ importedAt: Date.now(), ...result };
374
+ if (data.key) await keepImport(data.key, blob, note);
375
+ else await keep(data.name, blob, note);
324
376
  self.postMessage({ type: "done", ...result });
325
- if (data.kind === "step") await keepSource(data.name, data.file).catch(() => {});
377
+ if (data.kind === "step" && data.keepSource !== false) {
378
+ await (data.key ? keepSourceByKey(data.key, data.file) : keepSource(data.name, data.file)).catch(() => {});
379
+ }
326
380
  } catch (error) {
327
- self.postMessage({ type: "failed", message: String(error.message ?? error) });
381
+ failed(error);
328
382
  }
329
383
  };
@@ -0,0 +1,140 @@
1
+ // Runs the page's imports through ImportQueue, in two lanes: what a person opens, and what the
2
+ // gallery wants. What a person opens never waits behind the gallery: on a desktop the gallery has
3
+ // an importer of its own, which starts nothing new while anything is being opened; on a phone, one
4
+ // importer is shared, so the gallery's running job is cancelled for an open and waits again.
5
+ //
6
+ // The live lane's jobs are files being opened ("open"), in the order asked, and refinements of what
7
+ // is open ("visible"): a file being opened cancels a refinement running, which is not tried again;
8
+ // the engine asks anew. Who runs a job, and how, is the caller's: `run(signal)` returns a promise,
9
+ // and stops early, rejecting or not, once `signal` aborts.
10
+
11
+ import { ImportQueue } from "./import-queue.js";
12
+
13
+ export class Importers {
14
+ #live = new ImportQueue({ slots: 1 });
15
+ #liveWants = [];
16
+ #gallery;
17
+ #shared;
18
+ #jobs = new Map(); // id -> { lane, run, resolve, reject, controller }
19
+ #sequence = 0;
20
+
21
+ /** `shared`: one importer for both lanes, as on a phone, where prefetching is off too. */
22
+ constructor({ shared = false, gallerySlots = 1 } = {}) {
23
+ this.#shared = shared;
24
+ this.#gallery = new ImportQueue({ slots: gallerySlots, prefetch: !shared });
25
+ }
26
+
27
+ /** Runs a live job: a file being opened (band "open") or a refinement ("visible"). */
28
+ live(id, run, { band = "open" } = {}) {
29
+ return new Promise((resolve, reject) => {
30
+ this.#jobs.set(id, { lane: "live", run, resolve, reject, controller: null });
31
+ this.#liveWants.push({ id, band, order: this.#sequence++ });
32
+ this.#live.want(this.#liveWants);
33
+ this.#pump();
34
+ });
35
+ }
36
+
37
+ /** Stops a live job: dropped if it waits, aborted if it runs. */
38
+ cancel(id) {
39
+ const job = this.#jobs.get(id);
40
+ if (!job) return;
41
+ if (job.controller) {
42
+ job.controller.abort();
43
+ } else {
44
+ this.#dropLive(id);
45
+ this.#jobs.delete(id);
46
+ job.resolve({ type: "cancelled" });
47
+ this.#pump();
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Replaces what the gallery wants: [{ id, band, order, bytes, run }]. A job not wanted any more
53
+ * is dropped before it starts and runs on once started, unless a job in view needs its slot.
54
+ * Each job's result goes to `onDone(id, result)`; its failure to `onFailed(id, error)`.
55
+ */
56
+ want(jobs, { onDone = () => {}, onFailed = () => {} } = {}) {
57
+ this.#onDone = onDone;
58
+ this.#onFailed = onFailed;
59
+ for (const job of jobs) {
60
+ const known = this.#jobs.get(job.id);
61
+ if (!known) this.#jobs.set(job.id, { lane: "gallery", run: job.run, controller: null });
62
+ else if (known.lane === "gallery") known.run = job.run;
63
+ }
64
+ this.#gallery.want(jobs.map(({ id, band, order, bytes }) => ({ id, band, order, bytes })));
65
+ for (const [id, job] of this.#jobs) {
66
+ if (job.lane === "gallery" && !job.controller && !this.#gallery.bandOf(id)) this.#jobs.delete(id);
67
+ }
68
+ this.#pump();
69
+ }
70
+ #onDone = () => {};
71
+ #onFailed = () => {};
72
+
73
+ setPrefetch(allowed) {
74
+ this.#gallery.setPrefetch(allowed && !this.#shared);
75
+ this.#pump();
76
+ }
77
+
78
+ /** Whether anything is being opened, waiting or running. */
79
+ get opening() {
80
+ return this.#live.waiting().length > 0 || this.#live.running().length > 0;
81
+ }
82
+
83
+ /** Ids running now, by lane, for checks. */
84
+ running() {
85
+ return { live: this.#live.running(), gallery: this.#gallery.running() };
86
+ }
87
+
88
+ #pump() {
89
+ for (const id of this.#live.toCancel()) this.#jobs.get(id)?.controller?.abort();
90
+ for (let id = this.#live.next(); id !== null; id = this.#live.next()) this.#start("live", id);
91
+
92
+ if (this.opening) {
93
+ // One importer for both: the gallery's running job makes way, and waits again.
94
+ if (this.#shared) for (const id of this.#gallery.running()) this.#jobs.get(id)?.controller?.abort();
95
+ return;
96
+ }
97
+ for (const id of this.#gallery.toCancel()) this.#jobs.get(id)?.controller?.abort();
98
+ for (let id = this.#gallery.next(); id !== null; id = this.#gallery.next()) this.#start("gallery", id);
99
+ }
100
+
101
+ #dropLive(id) {
102
+ this.#liveWants = this.#liveWants.filter((want) => want.id !== id);
103
+ this.#live.want(this.#liveWants);
104
+ }
105
+
106
+ #start(lane, id) {
107
+ const job = this.#jobs.get(id);
108
+ const queue = lane === "live" ? this.#live : this.#gallery;
109
+ if (!job) {
110
+ queue.finish(id);
111
+ return;
112
+ }
113
+ const controller = new AbortController();
114
+ job.controller = controller;
115
+ Promise.resolve()
116
+ .then(() => job.run(controller.signal))
117
+ .then((result) => ({ result }), (error) => ({ error }))
118
+ .then(({ result, error }) => {
119
+ job.controller = null;
120
+ const aborted = controller.signal.aborted;
121
+ if (lane === "live") {
122
+ queue.finish(id);
123
+ this.#dropLive(id);
124
+ this.#jobs.delete(id);
125
+ if (aborted) job.resolve({ type: "cancelled" });
126
+ else if (error) job.reject(error);
127
+ else job.resolve(result);
128
+ } else if (aborted) {
129
+ queue.stopped(id); // waits again, if still wanted
130
+ if (!queue.bandOf(id)) this.#jobs.delete(id);
131
+ } else {
132
+ queue.finish(id);
133
+ this.#jobs.delete(id);
134
+ if (error) this.#onFailed(id, error);
135
+ else this.#onDone(id, result);
136
+ }
137
+ this.#pump();
138
+ });
139
+ }
140
+ }
package/web/index.html CHANGED
@@ -8,6 +8,7 @@
8
8
  <link rel="icon" href="icons/icon-32.png" sizes="32x32" type="image/png">
9
9
  <link rel="apple-touch-icon" href="icons/icon-180.png">
10
10
  <link rel="stylesheet" href="viewer.css">
11
+ <link rel="stylesheet" href="gallery.css">
11
12
  </head>
12
13
  <body>
13
14
  <canvas id="viewport"></canvas>