geometryview 0.0.0-stage → 0.1.1

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.
Files changed (46) hide show
  1. package/README.md +37 -2
  2. package/bin/geometryview.mjs +65 -0
  3. package/package.json +42 -4
  4. package/src/bridge.mjs +77 -0
  5. package/src/mcp.mjs +73 -0
  6. package/src/native.mjs +183 -0
  7. package/src/page.mjs +147 -0
  8. package/src/tools.mjs +265 -0
  9. package/src/web.mjs +173 -0
  10. package/src/websocket.mjs +104 -0
  11. package/web/LICENSE +21 -0
  12. package/web/NOTICES.md +49 -0
  13. package/web/bridge.js +29 -0
  14. package/web/build/geometryview-web.mjs +2 -0
  15. package/web/build/geometryview-web.wasm +0 -0
  16. package/web/chrome.js +541 -0
  17. package/web/engine.js +453 -0
  18. package/web/fonts/IBMPlexMono-Medium.ttf +0 -0
  19. package/web/fonts/IBMPlexMono-Regular.ttf +0 -0
  20. package/web/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  21. package/web/fonts/LICENSE.txt +93 -0
  22. package/web/icons/icon-180.png +0 -0
  23. package/web/icons/icon-192.png +0 -0
  24. package/web/icons/icon-32.png +0 -0
  25. package/web/icons/icon-512.png +0 -0
  26. package/web/icons.js +50 -0
  27. package/web/import-worker.js +329 -0
  28. package/web/index.html +17 -0
  29. package/web/input.js +255 -0
  30. package/web/isolation-worker.js +21 -0
  31. package/web/library-rows.js +120 -0
  32. package/web/library-store.js +368 -0
  33. package/web/library.js +306 -0
  34. package/web/manifest.webmanifest +14 -0
  35. package/web/occt/geometryview-occt-web.mjs +2 -0
  36. package/web/occt/geometryview-occt-web.wasm +0 -0
  37. package/web/occt/geometryview-step-web.mjs +2 -0
  38. package/web/occt/geometryview-step-web.wasm +0 -0
  39. package/web/occt-threads/geometryview-step-web.mjs +2 -0
  40. package/web/occt-threads/geometryview-step-web.wasm +0 -0
  41. package/web/occt-worker.js +45 -0
  42. package/web/structure.js +435 -0
  43. package/web/toolbar-fold.js +49 -0
  44. package/web/ui.js +134 -0
  45. package/web/viewer.css +558 -0
  46. package/web/viewer.js +1163 -0
@@ -0,0 +1,329 @@
1
+ // Imports STEP and STL files off the page's thread, into cache files kept in the library.
2
+ //
3
+ // Each import gets a fresh supervisor (the portable StepImport as WebAssembly), whose memory goes
4
+ // with it, since WebAssembly memory never shrinks. STEP slices go to a warm pool of OCCT workers
5
+ // that outlives imports: a worker that traps, runs out of memory, takes too long or grows past
6
+ // kRecycleMB is replaced, as in P2. The finished cache file is written to the origin-private file
7
+ // system, where the page opens it, streamed, like any other.
8
+ //
9
+ // An STL file needs no OCCT. Where the page is cross-origin isolated its packer runs on threads,
10
+ // in a worker of its own that the page ends afterwards, since a threaded module's memory is kept
11
+ // by its threads until they end.
12
+ //
13
+ // A STEP file's source is kept beside its cache, so its definitions can be imported again, finer,
14
+ // when the view comes close: a refinement, whose segments the page adds to the open document.
15
+ //
16
+ // Messages in: { type: "import", file, kind: "step" | "stl", name, workers, threads, progressive }
17
+ // { 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 }
21
+
22
+ import createStepImporter from "./occt/geometryview-step-web.mjs";
23
+
24
+ // Some browsers refuse a worker that starts another worker by URL. Claude Desktop's built-in
25
+ // browser is one: the nested worker fails at once, with no message and no request sent. A module
26
+ // worker made from a blob whose one line imports the script starts there too. The script is
27
+ // fetched exactly as before, and inside it import.meta.url is still its own.
28
+ //
29
+ // The OCCT pool starts its workers by URL, and by blob once one fails before its first message,
30
+ // which costs nothing where URLs work. The threaded packer starts its own threads, which can't be
31
+ // retried, so every other worker made here starts by blob: for those threads that measured the
32
+ // same as by URL. Pages on the iPhone shell's geo:// scheme keep plain URLs, which a blob can't
33
+ // import from.
34
+ const kWorkerByUrl = self.Worker;
35
+ const kBlobsWork = location.protocol === "http:" || location.protocol === "https:";
36
+ const blobWrappers = new Map();
37
+
38
+ function blobWrapper(href) {
39
+ let url = blobWrappers.get(href);
40
+ if (url === undefined) {
41
+ url = URL.createObjectURL(new Blob([`import ${JSON.stringify(href)};\n`], { type: "text/javascript" }));
42
+ blobWrappers.set(href, url); // kept: workers start from it again
43
+ }
44
+ return url;
45
+ }
46
+
47
+ function startWorker(url, options, byBlob) {
48
+ const href = new URL(url, import.meta.url).href;
49
+ return byBlob ? new kWorkerByUrl(blobWrapper(href), { ...options, type: "module" })
50
+ : new kWorkerByUrl(href, options);
51
+ }
52
+
53
+ if (kBlobsWork) {
54
+ self.Worker = class extends kWorkerByUrl {
55
+ constructor(url, options = {}) {
56
+ super(blobWrapper(new URL(url, import.meta.url).href), { ...options, type: "module" });
57
+ }
58
+ };
59
+ }
60
+
61
+ async function supervisor(threads) {
62
+ if (!threads) return createStepImporter();
63
+ const { default: create } = await import("./occt-threads/geometryview-step-web.mjs");
64
+ return create();
65
+ }
66
+
67
+ const kRecycleMB = 256;
68
+ const kOutOfMemory = 2; // worker::ReplyStatus::OutOfMemory
69
+ const kTimeoutMs = 120_000;
70
+
71
+ let occtModule = null;
72
+ const pool = [];
73
+
74
+ let poolByBlob = false;
75
+
76
+ function spawn() {
77
+ return new Promise((resolve, reject) => {
78
+ const byBlob = poolByBlob;
79
+ const worker = startWorker("./occt-worker.js", { type: "module" }, byBlob);
80
+ worker.onmessage = ({ data }) => {
81
+ if (data.ready) {
82
+ resolve({ worker, heapMB: 0, job: null, timer: 0 });
83
+ } else {
84
+ worker.terminate();
85
+ reject(new Error(`An OCCT worker failed to start: ${data.error}`));
86
+ }
87
+ };
88
+ worker.onerror = (event) => {
89
+ worker.terminate();
90
+ if (!byBlob && kBlobsWork) {
91
+ poolByBlob = true; // this browser won't start a worker from a worker by URL
92
+ spawn().then(resolve, reject);
93
+ } else {
94
+ reject(new Error(event.message ?? "An OCCT worker failed to start."));
95
+ }
96
+ };
97
+ worker.postMessage({ module: occtModule });
98
+ });
99
+ }
100
+
101
+ async function warm(size) {
102
+ occtModule ??= await WebAssembly.compileStreaming(fetch(new URL("./occt/geometryview-occt-web.wasm", import.meta.url)));
103
+ // All at once: each instantiates OCCT on its own thread.
104
+ const missing = size - pool.length;
105
+ if (missing > 0) pool.push(...await Promise.all(Array.from({ length: missing }, spawn)));
106
+ }
107
+
108
+ // Posts each segment the importer has written since the last call, so the page shows the model
109
+ // while it imports.
110
+ function postSegments(step) {
111
+ const place = step._malloc(16);
112
+ while (step._step_take_segment(place)) {
113
+ const [offset, size] = new Float64Array(step.HEAPF64.buffer, place, 2);
114
+ const pointer = step._step_read(offset, size);
115
+ if (pointer === 0) break;
116
+ const bytes = step.HEAPU8.slice(pointer, pointer + size);
117
+ self.postMessage({ type: "segment", bytes }, [bytes.buffer]);
118
+ }
119
+ step._free(place);
120
+ }
121
+
122
+ // Hands the supervisor's slices to the pool until every one is answered.
123
+ function mesh(step, slots, progressive) {
124
+ const total = step._step_jobs();
125
+ let replies = 0;
126
+ const replacing = new Set();
127
+ return new Promise((resolve, reject) => {
128
+ const idle = [...slots];
129
+ let busy = 0;
130
+ let closed = false;
131
+
132
+ const pump = () => {
133
+ while (!closed && idle.length > 0) {
134
+ const id = step._step_next();
135
+ if (id === -2) {
136
+ if (busy === 0) {
137
+ closed = true;
138
+ Promise.all(replacing).then(() => resolve(replies));
139
+ }
140
+ return;
141
+ }
142
+ if (id === -1) {
143
+ if (busy === 0) reject(new Error("The import stalled with slices unanswered."));
144
+ return;
145
+ }
146
+ const text = step.HEAPU8.slice(step._step_text(), step._step_text() + step._step_text_size());
147
+ const [linear, angular, minSize] = new Float64Array(step.HEAPF64.buffer, step._step_request(), 3);
148
+ const slot = idle.pop();
149
+ slot.job = id;
150
+ busy += 1;
151
+ slot.worker.onmessage = (event) => answered(slot, event.data);
152
+ slot.worker.onerror = (event) => answered(slot, { crashed: true, error: event.message });
153
+ slot.timer = setTimeout(() => answered(slot, { crashed: true, error: "timed out" }), kTimeoutMs);
154
+ slot.worker.postMessage({ id, text: text.buffer, linear, angular, minSize }, [text.buffer]);
155
+ }
156
+ };
157
+
158
+ // Its memory may be corrupt, or full and never to shrink: a fresh worker takes its place.
159
+ const replace = (slot) => {
160
+ slot.worker.terminate();
161
+ const replaced = spawn().then((fresh) => Object.assign(slot, fresh));
162
+ replacing.add(replaced);
163
+ return replaced.finally(() => replacing.delete(replaced));
164
+ };
165
+
166
+ const answered = async (slot, answer) => {
167
+ if (slot.job === null) return;
168
+ clearTimeout(slot.timer);
169
+ const id = slot.job;
170
+ slot.job = null;
171
+ busy -= 1;
172
+ try {
173
+ if (answer.crashed) {
174
+ step._step_crash(id);
175
+ pump();
176
+ await replace(slot);
177
+ } else {
178
+ // Each call may grow the heap, which detaches views made before it: read them after.
179
+ const payload = new Uint8Array(answer.payload);
180
+ const pointer = step._step_payload(payload.length);
181
+ step.HEAPU8.set(payload, pointer);
182
+ step._step_reply(id, answer.status, answer.faces, answer.unmapped, answer.unmeshed,
183
+ answer.read, answer.transfer, answer.mesh);
184
+ if (progressive) postSegments(step);
185
+ replies += 1;
186
+ slot.heapMB = answer.heapMB;
187
+ // A worker whose allocation failed is full, and its memory never shrinks.
188
+ if (slot.heapMB > kRecycleMB || answer.status === kOutOfMemory) await replace(slot);
189
+ if (replies % 8 === 0) self.postMessage({ type: "progress", done: replies, total });
190
+ }
191
+ idle.push(slot);
192
+ pump();
193
+ } catch (error) {
194
+ reject(error);
195
+ }
196
+ };
197
+ pump();
198
+ });
199
+ }
200
+
201
+ async function library() {
202
+ const root = await navigator.storage.getDirectory();
203
+ return root.getDirectoryHandle("library", { create: true });
204
+ }
205
+
206
+ async function openForWriting(folder, fileName) {
207
+ const access = await (await folder.getFileHandle(fileName, { create: true })).createSyncAccessHandle();
208
+ access.truncate(0);
209
+ return access;
210
+ }
211
+
212
+ // Writes the cache file to the library, with a note of the file it came from.
213
+ async function keep(name, bytes, note) {
214
+ const folder = await library();
215
+ const write = async (fileName, data) => {
216
+ const access = await openForWriting(folder, fileName);
217
+ for (let at = 0; at < data.length;) at += access.write(data.subarray(at), { at });
218
+ access.flush();
219
+ access.close();
220
+ };
221
+ await write(`${name}.geoblob`, bytes);
222
+ await write(`${name}.json`, new TextEncoder().encode(JSON.stringify(note)));
223
+ }
224
+
225
+ // Keeps a STEP file beside its cache, read again in pieces rather than held through the import,
226
+ // once the import is done, since nothing waits for it. The library uses it only when its size
227
+ // matches the note's, so a copy cut short is never read.
228
+ async function keepSource(name, file) {
229
+ const access = await openForWriting(await library(), `${name}.source`);
230
+ for (let at = 0; at < file.size; at += kSourcePiece) {
231
+ const piece = new Uint8Array(await file.slice(at, at + kSourcePiece).arrayBuffer());
232
+ for (let done = 0; done < piece.length;) done += access.write(piece.subarray(done), { at: at + done });
233
+ }
234
+ access.flush();
235
+ access.close();
236
+ }
237
+ const kSourcePiece = 8 << 20;
238
+
239
+ // Reads `file` straight into the importer's input, a piece at a time, so it is never held whole a
240
+ // second time on its way in: _step_open makes room for all of it, and nothing calls into the
241
+ // importer while it fills, so the heap cannot grow and move under the copy.
242
+ async function readInto(step, file) {
243
+ const pointer = step._step_open(file.size);
244
+ let at = pointer;
245
+ const reader = file.stream().getReader();
246
+ for (;;) {
247
+ const { done, value } = await reader.read();
248
+ if (done) break;
249
+ step.HEAPU8.set(value, at);
250
+ at += value.length;
251
+ }
252
+ if (at - pointer !== file.size) throw new Error(`${file.name} changed while it was read.`);
253
+ }
254
+
255
+ // Imports some of a STEP file's definitions again at a finer level, posting each segment as it is
256
+ // written; they hold geometry only, for the page to add to the document already open.
257
+ async function refine(data) {
258
+ try {
259
+ const step = await supervisor(false);
260
+ await readInto(step, data.file);
261
+ // Each call may grow the heap, which detaches views made before it: read them after.
262
+ const room = step._step_refine(data.level, data.definitions.length);
263
+ new Uint32Array(step.HEAPU8.buffer, room, data.definitions.length).set(data.definitions);
264
+ if (step._step_begin() !== 0) throw new Error(step.UTF8ToString(step._step_error()));
265
+ await warm(data.workers);
266
+ await mesh(step, pool, true);
267
+ if (step._step_finish() !== 0) throw new Error(step.UTF8ToString(step._step_error()));
268
+ postSegments(step);
269
+ // What it made, for the page's scripted check: a definition can mesh to nothing at all.
270
+ const stats = new Float64Array(step.HEAPF64.buffer, step._step_stats(), 17);
271
+ self.postMessage({ type: "done", triangles: stats[11], faces: stats[10], failedFaces: stats[12],
272
+ crashedWorkers: stats[14], coarserFaces: stats[16] });
273
+ } catch (error) {
274
+ self.postMessage({ type: "failed", message: String(error.message ?? error) });
275
+ }
276
+ }
277
+
278
+ // Hands an STL file to the importer a piece at a time. False if it must be read whole instead,
279
+ // which the importer says on seeing the first piece.
280
+ async function readPieces(step, file) {
281
+ const room = step._stl_open(file.size);
282
+ const piece = step._stl_piece_size();
283
+ for (let at = 0; at < file.size; at += piece) {
284
+ const bytes = new Uint8Array(await file.slice(at, Math.min(file.size, at + piece)).arrayBuffer());
285
+ step.HEAPU8.set(bytes, room);
286
+ if (step._stl_take(bytes.length) === 0) return false;
287
+ }
288
+ return file.size > 0;
289
+ }
290
+
291
+ self.onmessage = async ({ data }) => {
292
+ if (data.type === "refine") {
293
+ await refine(data);
294
+ return;
295
+ }
296
+ if (data.type !== "import") return;
297
+ const started = performance.now();
298
+ try {
299
+ const step = await supervisor(data.threads && self.crossOriginIsolated);
300
+ // A binary STL file is read in pieces, so it is never held whole; anything else, whole.
301
+ const inPieces = data.kind === "stl" && await readPieces(step, data.file);
302
+ if (!inPieces) await readInto(step, data.file);
303
+ let slices = 0;
304
+ if (data.kind === "stl") {
305
+ const made = inPieces ? step._mesh_import_pieces() : step._mesh_import();
306
+ if (made !== 0) throw new Error(step.UTF8ToString(step._step_error()));
307
+ } else {
308
+ if (step._step_begin() !== 0) throw new Error(step.UTF8ToString(step._step_error()));
309
+ self.postMessage({ type: "progress", done: 0, total: step._step_jobs() });
310
+ if (data.progressive) postSegments(step); // the model, written by step_begin
311
+ await warm(data.workers);
312
+ slices = await mesh(step, pool, data.progressive);
313
+ if (step._step_finish() !== 0) throw new Error(step.UTF8ToString(step._step_error()));
314
+ if (data.progressive) postSegments(step);
315
+ }
316
+ const blob = step.HEAPU8.subarray(step._step_blob(), step._step_blob() + step._step_blob_size());
317
+ const stats = new Float64Array(step.HEAPF64.buffer, step._step_stats(), 17);
318
+ const result = { triangles: stats[11], faces: stats[10], failedFaces: stats[12],
319
+ coarserFaces: stats[16], slices,
320
+ importMs: Math.round(performance.now() - started), cacheBytes: blob.length,
321
+ 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 });
324
+ self.postMessage({ type: "done", ...result });
325
+ if (data.kind === "step") await keepSource(data.name, data.file).catch(() => {});
326
+ } catch (error) {
327
+ self.postMessage({ type: "failed", message: String(error.message ?? error) });
328
+ }
329
+ };
package/web/index.html ADDED
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
6
+ <title>GeometryView</title>
7
+ <link rel="manifest" href="manifest.webmanifest">
8
+ <link rel="icon" href="icons/icon-32.png" sizes="32x32" type="image/png">
9
+ <link rel="apple-touch-icon" href="icons/icon-180.png">
10
+ <link rel="stylesheet" href="viewer.css">
11
+ </head>
12
+ <body>
13
+ <canvas id="viewport"></canvas>
14
+ <div id="status" class="status" hidden></div>
15
+ <script type="module" src="viewer.js"></script>
16
+ </body>
17
+ </html>
package/web/input.js ADDED
@@ -0,0 +1,255 @@
1
+ // Pointer, wheel, touch and key input on the viewport, turned into the engine's events as the Mac
2
+ // viewport turns AppKit's: drawable pixels from the top left, the same buttons and modifiers, the
3
+ // wheel's lines and pixels scaled as the Mac scales them, and pinches as magnification.
4
+
5
+ import { Buttons, Modifiers, Pointer, Tools } from "./engine.js";
6
+
7
+ // What each key does, as the Mac's default key map has it. `hold` keys pick a tool while down.
8
+ const kKeys = {
9
+ h: { hold: Tools.Hide }, g: { hold: Tools.Ghost },
10
+ f: { command: "fit" }, p: { command: "projection" }, Escape: { command: "clear" },
11
+ i: { command: "isolate" }, H: { command: "showAll" }, G: { command: "unghostAll" },
12
+ 1: { command: "faces" }, 2: { command: "bodies" }, 3: { command: "parts" },
13
+ s: { command: "section" }, t: { command: "seeThrough" }, "?": { command: "shortcuts" },
14
+ };
15
+
16
+ export class ViewportInput {
17
+ #engine;
18
+ #canvas;
19
+ #onCommand;
20
+ #onTool;
21
+ #onPointer;
22
+ #pressed = new Set(); // buttons this view sent a press for
23
+ #touches = new Map(); // pointer id to its last position, for touch gestures
24
+ #pinch = null;
25
+ #space = false;
26
+ #heldKey = null;
27
+
28
+ // `onCommand(name)` for keys that are not tools, `onTool(tool)` when a held tool changes, and
29
+ // `onPointer(x, y)` with the pointer's CSS position in the viewport, or null when it leaves.
30
+ constructor(engine, canvas, { onCommand, onTool, onPointer }) {
31
+ this.#engine = engine;
32
+ this.#canvas = canvas;
33
+ this.#onCommand = onCommand;
34
+ this.#onTool = onTool;
35
+ this.#onPointer = onPointer;
36
+ canvas.addEventListener("pointerdown", (e) => this.#down(e));
37
+ canvas.addEventListener("pointermove", (e) => this.#move(e));
38
+ canvas.addEventListener("pointerup", (e) => this.#up(e));
39
+ canvas.addEventListener("pointercancel", (e) => this.#cancel(e));
40
+ canvas.addEventListener("pointerleave", (e) => this.#leave(e));
41
+ canvas.addEventListener("wheel", (e) => this.#wheel(e), { passive: false });
42
+ canvas.addEventListener("contextmenu", (e) => e.preventDefault());
43
+ // Safari's trackpad pinch.
44
+ canvas.addEventListener("gesturestart", (e) => { e.preventDefault(); this.#gestureScale = 1; });
45
+ canvas.addEventListener("gesturechange", (e) => this.#gesture(e));
46
+ addEventListener("keydown", (e) => this.#keyDown(e));
47
+ addEventListener("keyup", (e) => this.#keyUp(e));
48
+ addEventListener("blur", () => this.releaseHeldTool());
49
+ }
50
+
51
+ #gestureScale = 1;
52
+
53
+ #point(e) {
54
+ const rect = this.#canvas.getBoundingClientRect();
55
+ const scale = this.#engine.scale;
56
+ return { x: (e.clientX - rect.left) * scale, y: (e.clientY - rect.top) * scale,
57
+ cssX: e.clientX - rect.left, cssY: e.clientY - rect.top };
58
+ }
59
+
60
+ #modifiers(e) {
61
+ return (e.shiftKey ? Modifiers.Shift : 0) | (e.ctrlKey ? Modifiers.Control : 0) |
62
+ (e.altKey ? Modifiers.Option : 0) | (e.metaKey ? Modifiers.Command : 0) |
63
+ (this.#space ? Modifiers.Space : 0);
64
+ }
65
+
66
+ #button(e) {
67
+ return e.button === 0 ? Buttons.Left : e.button === 1 ? Buttons.Middle
68
+ : e.button === 2 ? Buttons.Right : Buttons.None;
69
+ }
70
+
71
+ #post(kind, button, e, delta = 0) {
72
+ const { x, y } = this.#point(e);
73
+ this.#engine.pointer(kind, button, x, y, delta, this.#modifiers(e), e.timeStamp / 1000);
74
+ }
75
+
76
+ #down(e) {
77
+ if (e.pointerType === "touch") {
78
+ this.#touchDown(e);
79
+ return;
80
+ }
81
+ const button = this.#button(e);
82
+ if (button === Buttons.None) return;
83
+ this.#canvas.setPointerCapture(e.pointerId);
84
+ this.#pressed.add(button);
85
+ this.#post(Pointer.Down, button, e);
86
+ }
87
+
88
+ #move(e) {
89
+ if (e.pointerType === "touch") {
90
+ this.#touchMove(e);
91
+ return;
92
+ }
93
+ const { cssX, cssY } = this.#point(e);
94
+ this.#onPointer?.(cssX, cssY);
95
+ // Coalesced moves since the last frame, so a fast drag keeps its path.
96
+ const events = e.getCoalescedEvents?.() ?? [e];
97
+ for (const each of events.length ? events : [e]) this.#post(Pointer.Move, Buttons.None, each);
98
+ }
99
+
100
+ #up(e) {
101
+ if (e.pointerType === "touch") {
102
+ this.#touchUp(e);
103
+ return;
104
+ }
105
+ const button = this.#button(e);
106
+ if (!this.#pressed.delete(button)) return;
107
+ this.#post(Pointer.Up, button, e);
108
+ }
109
+
110
+ #cancel(e) {
111
+ if (e.pointerType === "touch") {
112
+ this.#touchUp(e, true);
113
+ return;
114
+ }
115
+ if (this.#pressed.size === 0) return;
116
+ this.#pressed.clear();
117
+ this.#post(Pointer.Cancel, Buttons.None, e);
118
+ }
119
+
120
+ #leave(e) {
121
+ if (e.pointerType === "touch" || this.#pressed.size > 0) return;
122
+ this.#onPointer?.(null);
123
+ this.#post(Pointer.Leave, Buttons.None, e);
124
+ }
125
+
126
+ #wheel(e) {
127
+ e.preventDefault();
128
+ if (e.ctrlKey) {
129
+ // A trackpad pinch arrives as a wheel with Control held, in Chrome and Firefox.
130
+ this.#post(Pointer.Magnify, Buttons.None, e, -e.deltaY * 4);
131
+ return;
132
+ }
133
+ // As the Mac scales its scroll deltas: precise pixels by 2, whole lines by 30. The browser's
134
+ // count down where the Mac's count up, and a mouse wheel's notch arrives as about 100 pixels,
135
+ // which is held to two lines' worth.
136
+ const delta = e.deltaMode === 0 ? Math.max(-60, Math.min(60, -e.deltaY * 2))
137
+ : -e.deltaY * 30;
138
+ this.#post(Pointer.Scroll, Buttons.None, e, delta);
139
+ }
140
+
141
+ #gesture(e) {
142
+ e.preventDefault();
143
+ const change = e.scale / this.#gestureScale - 1;
144
+ this.#gestureScale = e.scale;
145
+ this.#post(Pointer.Magnify, Buttons.None, e, change * 400);
146
+ }
147
+
148
+ // ---- Touch: one finger rotates, two pan and pinch -----------------------------------------
149
+
150
+ #touchDown(e) {
151
+ this.#canvas.setPointerCapture(e.pointerId);
152
+ this.#touches.set(e.pointerId, this.#point(e));
153
+ if (this.#touches.size === 1) {
154
+ this.#pressed.add(Buttons.Left);
155
+ this.#post(Pointer.Down, Buttons.Left, e);
156
+ } else if (this.#touches.size === 2) {
157
+ // The rotation stops where it was, and a pan starts from between the fingers.
158
+ this.#pressed.delete(Buttons.Left);
159
+ this.#post(Pointer.Up, Buttons.Left, e);
160
+ const centre = this.#centre();
161
+ this.#pinch = { distance: this.#spread(), centre };
162
+ this.#engine.pointer(Pointer.Down, Buttons.Middle, centre.x, centre.y, 0, 0, e.timeStamp / 1000);
163
+ this.#pressed.add(Buttons.Middle);
164
+ }
165
+ }
166
+
167
+ #touchMove(e) {
168
+ if (!this.#touches.has(e.pointerId)) return;
169
+ this.#touches.set(e.pointerId, this.#point(e));
170
+ if (this.#touches.size === 1) {
171
+ this.#post(Pointer.Move, Buttons.None, e);
172
+ } else if (this.#touches.size === 2 && this.#pinch) {
173
+ const centre = this.#centre();
174
+ const spread = this.#spread();
175
+ const time = e.timeStamp / 1000;
176
+ this.#engine.pointer(Pointer.Move, Buttons.None, centre.x, centre.y, 0, 0, time);
177
+ if (this.#pinch.distance > 0) {
178
+ const change = spread / this.#pinch.distance - 1;
179
+ this.#engine.pointer(Pointer.Magnify, Buttons.None, centre.x, centre.y, change * 400, 0, time);
180
+ }
181
+ this.#pinch = { distance: spread, centre };
182
+ }
183
+ }
184
+
185
+ #touchUp(e, cancelled = false) {
186
+ if (!this.#touches.has(e.pointerId)) return;
187
+ const time = e.timeStamp / 1000;
188
+ if (this.#touches.size === 2 && this.#pinch) {
189
+ const { x, y } = this.#pinch.centre;
190
+ this.#engine.pointer(Pointer.Up, Buttons.Middle, x, y, 0, 0, time);
191
+ this.#pressed.delete(Buttons.Middle);
192
+ this.#pinch = null;
193
+ } else if (this.#touches.size === 1 && this.#pressed.delete(Buttons.Left)) {
194
+ this.#post(cancelled ? Pointer.Cancel : Pointer.Up, Buttons.Left, e);
195
+ }
196
+ this.#touches.delete(e.pointerId);
197
+ if (this.#touches.size === 0) {
198
+ this.#engine.pointer(Pointer.Leave, Buttons.None, 0, 0, 0, 0, time);
199
+ }
200
+ }
201
+
202
+ #centre() {
203
+ const [a, b] = [...this.#touches.values()];
204
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
205
+ }
206
+
207
+ #spread() {
208
+ const [a, b] = [...this.#touches.values()];
209
+ return Math.hypot(a.x - b.x, a.y - b.y);
210
+ }
211
+
212
+ // ---- Keys --------------------------------------------------------------------------------
213
+
214
+ #keyDown(e) {
215
+ if (e.target instanceof HTMLInputElement) return;
216
+ if (e.code === "Space") {
217
+ this.#space = true;
218
+ e.preventDefault();
219
+ return;
220
+ }
221
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
222
+ e.preventDefault();
223
+ this.#onCommand?.(e.shiftKey ? "redo" : "undo");
224
+ return;
225
+ }
226
+ if (e.repeat || e.metaKey || e.ctrlKey || e.altKey) return;
227
+ const binding = kKeys[e.key];
228
+ if (!binding) return;
229
+ e.preventDefault();
230
+ if (binding.hold !== undefined) {
231
+ this.#heldKey = e.code;
232
+ this.#engine.setTool(binding.hold);
233
+ this.#onTool?.(binding.hold);
234
+ } else {
235
+ this.#onCommand?.(binding.command);
236
+ }
237
+ }
238
+
239
+ #keyUp(e) {
240
+ if (e.code === "Space") {
241
+ this.#space = false;
242
+ } else if (e.code === this.#heldKey) {
243
+ this.releaseHeldTool();
244
+ }
245
+ }
246
+
247
+ // Drops a held tool, as when the page loses focus while its key is down.
248
+ releaseHeldTool() {
249
+ this.#space = false;
250
+ if (this.#heldKey === null) return;
251
+ this.#heldKey = null;
252
+ this.#engine.setTool(Tools.Select);
253
+ this.#onTool?.(Tools.Select);
254
+ }
255
+ }
@@ -0,0 +1,21 @@
1
+ // Makes a page served without the headers threads need, as GitHub Pages serves it, cross-origin
2
+ // isolated: the page registers this worker, which adds COOP, COEP and CORP to each response from
3
+ // the page's own origin. Served with the headers already, by the launcher, the page never
4
+ // registers it. Everything the page loads is its own, so require-corp blocks nothing.
5
+
6
+ self.addEventListener("install", () => self.skipWaiting());
7
+ self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
8
+
9
+ self.addEventListener("fetch", (event) => {
10
+ const request = event.request;
11
+ if (new URL(request.url).origin !== self.location.origin) return;
12
+ if (request.cache === "only-if-cached" && request.mode !== "same-origin") return;
13
+ event.respondWith(fetch(request).then((response) => {
14
+ if (response.status === 0 || response.type === "opaqueredirect") return response;
15
+ const headers = new Headers(response.headers);
16
+ headers.set("Cross-Origin-Opener-Policy", "same-origin");
17
+ headers.set("Cross-Origin-Embedder-Policy", "require-corp");
18
+ headers.set("Cross-Origin-Resource-Policy", "same-origin");
19
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
20
+ }));
21
+ });