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
package/web/chrome.js ADDED
@@ -0,0 +1,541 @@
1
+ // The cards and overlays around the view, after the Mac app's Chrome folder: the masthead, the
2
+ // toolbar, the look, section and selection cards, the status card, the hold hint, the hover chip,
3
+ // the drop zone and the shortcuts sheet.
4
+
5
+ import { Actions, Tools } from "./engine.js";
6
+ import { iconNode, icons } from "./icons.js";
7
+ import { foldPlan, ruleShows } from "./toolbar-fold.js";
8
+ import { el, formatNumber, keyCap, pill, segmented, slider } from "./ui.js";
9
+
10
+ // ---- The look ---------------------------------------------------------------------------------
11
+
12
+ // As engine.h numbers them. onDark: the sheet is dark, so the chrome drawn on it turns light.
13
+ const row = (title, usesColour, castsShadow, grain, wobble, onDark) =>
14
+ ({ title, usesColour, castsShadow, grain, wobble, onDark });
15
+ export const Styles = [
16
+ row("Illustration", true, true, true, true, false),
17
+ row("Metal", true, true, false, false, false),
18
+ row("Clay", true, true, false, false, false),
19
+ row("Blueprint", false, false, true, false, true),
20
+ row("Technical", false, false, false, false, false),
21
+ row("Studio", true, true, false, false, true),
22
+ ];
23
+ const Colours = [
24
+ { title: "Material", summary: "Colours follow the material each name suggests." },
25
+ { title: "Parts", summary: "Each part gets a colour of its own." },
26
+ { title: "Mono", summary: "Every part in one neutral colour." },
27
+ { title: "File", summary: "The colours stored in the file." },
28
+ ];
29
+ const Ghosts = [{ title: "Shell", value: 1 }, { title: "Lines", value: 2 }, { title: "Both", value: 3 }];
30
+ const kWobbleSteps = [0, 1, 3, 5];
31
+
32
+ // How the model is drawn, as chosen; remembered in this browser between visits. Until the person
33
+ // chooses, it is the engine's default, which every app shares.
34
+ export class Look {
35
+ style = 0; colour = 1; ghosts = 1; shadow = true; grain = true; wobble = 0;
36
+
37
+ // `defaults` is the engine's default look, as Engine.defaultLook gives it.
38
+ static saved(defaults) {
39
+ const look = new Look();
40
+ if (defaults) {
41
+ Object.assign(look, { style: defaults.style, colour: defaults.colorMode, ghosts: defaults.ghostStyle,
42
+ shadow: defaults.castShadow, grain: defaults.grain > 0, wobble: defaults.wobble });
43
+ }
44
+ try {
45
+ Object.assign(look, JSON.parse(localStorage.getItem("look") ?? "{}"));
46
+ } catch {
47
+ // A private window, or nothing saved yet.
48
+ }
49
+ return look;
50
+ }
51
+
52
+ save() {
53
+ try {
54
+ localStorage.setItem("look", JSON.stringify(this));
55
+ } catch {
56
+ // Not remembered, which costs nothing now.
57
+ }
58
+ }
59
+
60
+ // Choosing a style also chooses its usual shadow.
61
+ choose(style) {
62
+ this.style = style;
63
+ this.shadow = Styles[style].castsShadow;
64
+ }
65
+
66
+ get engineLook() {
67
+ return { style: this.style, colorMode: this.colour, ghostStyle: this.ghosts, castShadow: this.shadow,
68
+ grain: this.grain ? 1 : 0, wobble: this.wobble };
69
+ }
70
+ }
71
+
72
+ export class LookCard {
73
+ look;
74
+ #rows = {};
75
+ #note;
76
+ #controls;
77
+
78
+ constructor(look, onChange) {
79
+ this.look = look;
80
+ const edit = (change) => {
81
+ const next = Object.assign(new Look(), this.look);
82
+ change(next);
83
+ this.show(next);
84
+ onChange(next);
85
+ };
86
+ this.#controls = {
87
+ style: segmented(Styles.map((s) => s.title), { help: "Drawing style", onChange: (i) => edit((l) => l.choose(i)) }),
88
+ colour: segmented(Colours.map((c) => c.title), { help: "What decides each part's colour",
89
+ onChange: (i) => edit((l) => { l.colour = i; }) }),
90
+ ghosts: segmented(Ghosts.map((g) => g.title), { help: "How ghosted parts look",
91
+ onChange: (i) => edit((l) => { l.ghosts = Ghosts[i].value; }) }),
92
+ shadow: segmented(["On", "Off"], { help: "Cast shadow", onChange: (i) => edit((l) => { l.shadow = i === 0; }) }),
93
+ grain: segmented(["On", "Off"], { help: "Paper grain", onChange: (i) => edit((l) => { l.grain = i === 0; }) }),
94
+ wobble: segmented(["Off", "Light", "More", "Most"], { help: "Hand-drawn wobble",
95
+ onChange: (i) => edit((l) => { l.wobble = kWobbleSteps[i]; }) }),
96
+ };
97
+ this.#note = el("p.note");
98
+ const row = (label, control) => {
99
+ const text = el("span.label", { text: label });
100
+ this.#rows[label] = { text, control };
101
+ return el("div.row", {}, [text, control.element]);
102
+ };
103
+ this.element = el("section.card.popover.look", { hidden: true, "aria-label": "Look" }, [
104
+ el("h2", { text: "LOOK" }),
105
+ row("Style", this.#controls.style),
106
+ row("Colour", this.#controls.colour),
107
+ this.#note,
108
+ row("Ghosts", this.#controls.ghosts),
109
+ row("Shadow", this.#controls.shadow),
110
+ row("Grain", this.#controls.grain),
111
+ row("Hand-drawn", this.#controls.wobble),
112
+ ]);
113
+ this.show(look);
114
+ }
115
+
116
+ show(look) {
117
+ this.look = look;
118
+ const c = this.#controls;
119
+ c.style.selected = look.style;
120
+ c.colour.selected = look.colour;
121
+ c.ghosts.selected = Math.max(0, Ghosts.findIndex((g) => g.value === look.ghosts));
122
+ c.shadow.selected = look.shadow ? 0 : 1;
123
+ c.grain.selected = look.grain ? 0 : 1;
124
+ c.wobble.selected = Math.max(0, kWobbleSteps.indexOf(look.wobble));
125
+ // What the style does not use is dimmed rather than hidden, so nothing moves under the pointer.
126
+ const style = Styles[look.style];
127
+ this.#enable("Colour", style.usesColour);
128
+ this.#enable("Grain", style.grain);
129
+ this.#enable("Hand-drawn", style.wobble);
130
+ this.#note.textContent = style.usesColour ? Colours[look.colour].summary
131
+ : `${style.title} draws every part alike.`;
132
+ }
133
+
134
+ #enable(label, enabled) {
135
+ this.#rows[label].text.classList.toggle("disabled", !enabled);
136
+ this.#rows[label].control.enabled = enabled;
137
+ }
138
+ }
139
+
140
+ // ---- Section ----------------------------------------------------------------------------------
141
+
142
+ export class SectionCard {
143
+ cuts = [0, 1, 2].map(() => ({ enabled: false, position: 0.5, flip: false }));
144
+ #rows = [];
145
+
146
+ constructor(onChange) {
147
+ const edit = (axis, change) => {
148
+ change(this.cuts[axis]);
149
+ this.show();
150
+ onChange(this.cuts);
151
+ };
152
+ const rows = ["X", "Y", "Z"].map((name, axis) => {
153
+ const toggle = segmented(["Off", "Cut"], { help: `Cut across ${name}`,
154
+ onChange: (i) => edit(axis, (c) => { c.enabled = i === 1; }) });
155
+ const position = slider({ help: `Where to cut across ${name}`,
156
+ onChange: (v) => edit(axis, (c) => { c.position = v; }) });
157
+ const side = segmented(["+", "−"], { help: "Which side to remove",
158
+ onChange: (i) => edit(axis, (c) => { c.flip = i === 1; }) });
159
+ this.#rows.push({ toggle, position, side });
160
+ return el("div.row", {}, [el("span.axis", { text: name }), toggle.element, position.element, side.element]);
161
+ });
162
+ this.element = el("section.card.popover.section", { hidden: true, "aria-label": "Section" },
163
+ [el("h2", { text: "SECTION" }), ...rows]);
164
+ this.show();
165
+ }
166
+
167
+ get isCutting() { return this.cuts.some((c) => c.enabled); }
168
+
169
+ show() {
170
+ this.cuts.forEach((cut, axis) => {
171
+ const row = this.#rows[axis];
172
+ row.toggle.selected = cut.enabled ? 1 : 0;
173
+ row.position.value = cut.position;
174
+ row.position.enabled = cut.enabled;
175
+ row.side.selected = cut.flip ? 1 : 0;
176
+ });
177
+ }
178
+ }
179
+
180
+ // ---- Selection --------------------------------------------------------------------------------
181
+
182
+ export class SelectionCard {
183
+ #kind = el("div.kind");
184
+ #name = el("div.name");
185
+ #path = el("div.path");
186
+ #faces = el("div.number");
187
+ #bodies = el("div.number");
188
+
189
+ constructor(onAction) {
190
+ const stat = (number, unit) => el("div.stat", {}, [number, el("div.unit", { text: unit })]);
191
+ const action = (title, help, a) => pill({ title, help, onPress: () => onAction(a) });
192
+ this.element = el("section.card.selection", { hidden: true, "aria-label": "Selection" }, [
193
+ el("div.head", {}, [this.#kind, this.#name, this.#path]),
194
+ el("hr"),
195
+ el("div.stats", {}, [stat(this.#faces, "faces"), stat(this.#bodies, "bodies")]),
196
+ el("div.actions", {}, [
197
+ action("Hide", "Hide the selection", Actions.HideSelection),
198
+ action("Ghost", "Ghost the selection", Actions.GhostSelection),
199
+ action("Isolate", "Hide everything else", Actions.Isolate),
200
+ ]),
201
+ action("Fit to selection", "Frame the selection", Actions.FitSelection),
202
+ ]);
203
+ }
204
+
205
+ show(summary, tree) {
206
+ if (summary.occurrences === 0) {
207
+ this.element.hidden = true;
208
+ return;
209
+ }
210
+ const mode = ["FACE", "BODY", "PART"][Math.min(summary.mode, 2)];
211
+ this.#kind.textContent = summary.occurrences > 1 ? `${mode} · ${formatNumber(summary.occurrences)} BODIES` : mode;
212
+ if (summary.node >= 0 && tree.documents[summary.document]?.model) {
213
+ this.#name.textContent = tree.name(summary.document, summary.node);
214
+ this.#path.textContent = tree.path(summary.document, summary.node);
215
+ } else {
216
+ this.#name.textContent = "Selection";
217
+ this.#path.textContent = "";
218
+ }
219
+ this.#faces.textContent = formatNumber(summary.faces);
220
+ this.#bodies.textContent = formatNumber(summary.occurrences);
221
+ this.element.hidden = false;
222
+ }
223
+ }
224
+
225
+ // ---- The toolbar ------------------------------------------------------------------------------
226
+
227
+ export class Toolbar {
228
+ #needSelection = [];
229
+ #needModel = [];
230
+ #tools = []; // { key, element, group }, in order
231
+ #rules = []; // the rule after each group but the last
232
+ #folded = new Set();
233
+ #handlers;
234
+ #more;
235
+
236
+ constructor({ onAction, onMode, onProjection, onShortcuts, onLook, onSection, onOpen, onConnect, canConnect,
237
+ onParts }) {
238
+ this.pick = segmented(["Face", "Body", "Part"], {
239
+ help: "What a click selects (1, 2, 3)",
240
+ tips: ["Clicks select single faces (1)", "Clicks select whole bodies (2)", "Clicks select whole parts (3)"],
241
+ onChange: onMode,
242
+ });
243
+ this.projection = segmented(["Ortho", "Persp"], { help: "Projection (P)", onChange: (i) => onProjection(i === 1) });
244
+ const act = (a) => () => onAction(a);
245
+ const hide = pill({ icon: icons.eyeSlash, help: "Hide the selection (hold H and click)", onPress: act(Actions.HideSelection) });
246
+ const ghost = pill({ icon: icons.ghost, help: "Ghost the selection (hold G and click)", onPress: act(Actions.GhostSelection) });
247
+ const isolate = pill({ title: "Isolate", help: "Hide everything else (I)", onPress: act(Actions.Isolate) });
248
+ const showAll = pill({ icon: icons.eye, help: "Show everything (⇧H)", onPress: act(Actions.ShowAll) });
249
+ const undo = pill({ icon: icons.undo, help: "Undo (⌘Z)", onPress: act(Actions.Undo) });
250
+ const redo = pill({ icon: icons.redo, help: "Redo (⇧⌘Z)", onPress: act(Actions.Redo) });
251
+ const seeThrough = pill({ icon: icons.seeThrough, help: "See through the housing (T)", onPress: act(Actions.ToggleSeeThrough) });
252
+ const cut = pill({ icon: icons.scissors, help: "Section (S)", onPress: onSection });
253
+ const look = pill({ icon: icons.palette, help: "Style and colour", onPress: onLook });
254
+ const views = pill({ icon: icons.cube, help: "Standard views", onPress: () => this.#toggleViews(views) });
255
+ const fit = pill({ icon: icons.fit, help: "Fit to view (F)", onPress: act(Actions.Fit) });
256
+ // The two ways in, as on the first page and in the Library.
257
+ const open = pill({ icon: icons.plus, help: "Open files (⌘O)", onPress: onOpen });
258
+ const connect = pill({ icon: icons.folderPlus, help: "Connect a folder (⌥⌘O)", onPress: onConnect });
259
+ connect.hidden = !canConnect;
260
+ // On a phone the tree is a sheet over the view, opened from here.
261
+ const parts = pill({ icon: icons.parts, help: "Parts and library", onPress: onParts });
262
+ parts.classList.add("phone-only");
263
+ const shortcuts = pill({ title: "?", style: "ghostly", help: "Shortcuts (?)", onPress: onShortcuts });
264
+ // A phone has the selection card's own buttons for these, and no keyboard.
265
+ for (const button of [hide, ghost, isolate, shortcuts]) button.classList.add("desktop-only");
266
+ this.#needSelection = [hide, ghost, isolate];
267
+ this.#needModel = [showAll, fit, views, undo, redo, seeThrough];
268
+ this.viewsMenu = el("div.menu.views", { hidden: true, role: "menu" }, [
269
+ ["Front", Actions.ViewFront], ["Back", Actions.ViewBack], ["Left", Actions.ViewLeft],
270
+ ["Right", Actions.ViewRight], ["Top", Actions.ViewTop], ["Bottom", Actions.ViewBottom],
271
+ ["Isometric", Actions.ViewIso],
272
+ ].map(([title, a]) => el("button", { type: "button", role: "menuitem", text: title,
273
+ onclick: () => { this.viewsMenu.hidden = true; onAction(a); } })));
274
+ this.#handlers = { onAction, onProjection, onShortcuts, onLook, onSection, onOpen, onConnect };
275
+ this.#more = pill({ icon: icons.more, help: "More tools", onPress: () => this.#toggleMore() });
276
+ this.#more.classList.add("folded");
277
+ this.#more.dataset.tool = "more";
278
+ this.moreMenu = el("div.menu.more", { hidden: true, role: "menu" });
279
+ const groups = [
280
+ [["parts", parts], ["open", open], ["connect", connect]],
281
+ [["pick", this.pick.element]],
282
+ [["hide", hide], ["ghost", ghost], ["isolate", isolate], ["showAll", showAll]],
283
+ [["undo", undo], ["redo", redo]],
284
+ [["seeThrough", seeThrough], ["section", cut], ["look", look], ["projection", this.projection.element],
285
+ ["views", views], ["fit", fit], ["shortcuts", shortcuts]],
286
+ ];
287
+ const children = [];
288
+ groups.forEach((tools, group) => {
289
+ if (group > 0) {
290
+ const rule = el("span.rule");
291
+ this.#rules.push(rule);
292
+ children.push(rule);
293
+ }
294
+ for (const [key, element] of tools) {
295
+ element.dataset.tool = key;
296
+ this.#tools.push({ key, element, group });
297
+ children.push(element);
298
+ }
299
+ });
300
+ children.push(this.#more);
301
+ this.element = el("nav.card.toolbar", { "aria-label": "Tools" }, children);
302
+ this.update({ hasSelection: false, hasModel: false });
303
+ }
304
+
305
+ // Folds the least used tools into More until the toolbar fits `available` pixels, by the rule in
306
+ // toolbar-fold.js. Measures each tool as shown, so it runs only when the space changes.
307
+ fit(available) {
308
+ for (const tool of this.#tools) tool.element.classList.remove("folded");
309
+ const items = this.#tools.map(({ key, element, group }) => ({ key, group, width: element.offsetWidth }));
310
+ this.#folded = foldPlan(items, available);
311
+ for (const tool of this.#tools) tool.element.classList.toggle("folded", this.#folded.has(tool.key));
312
+ this.#rules.forEach((rule, group) => rule.classList.toggle("folded", !ruleShows(items, this.#folded, group)));
313
+ this.#more.classList.toggle("folded", this.#folded.size === 0);
314
+ if (this.#folded.size === 0) this.moreMenu.hidden = true;
315
+ }
316
+
317
+ // The folded tools, as rows in toolbar order: the views under a heading of their own.
318
+ #toggleMore() {
319
+ const menu = this.moreMenu;
320
+ if (!menu.hidden) {
321
+ menu.hidden = true;
322
+ return;
323
+ }
324
+ const h = this.#handlers;
325
+ const tool = (key) => this.#tools.find((t) => t.key === key).element;
326
+ const row = (icon, title, press, { disabled = false, checked = false } = {}) => {
327
+ const button = el("button", { type: "button", role: "menuitem", disabled },
328
+ [el("span.icon", {}, icon ? [iconNode(icon, 15)] : []), el("span", { text: title }),
329
+ el("span.check", {}, checked ? [iconNode("check", 13)] : [])]);
330
+ button.addEventListener("click", () => { menu.hidden = true; press(); });
331
+ return button;
332
+ };
333
+ const action = (key, icon, title, a) => row(icon, title, () => h.onAction(a), { disabled: tool(key).disabled });
334
+ const rows = {
335
+ open: () => [row("plus", "Open files", h.onOpen)],
336
+ connect: () => (tool("connect").hidden ? [] : [row("folderPlus", "Connect a folder", h.onConnect)]),
337
+ hide: () => [action("hide", "eyeSlash", "Hide the selection", Actions.HideSelection)],
338
+ ghost: () => [action("ghost", "ghost", "Ghost the selection", Actions.GhostSelection)],
339
+ isolate: () => [action("isolate", "scope", "Isolate", Actions.Isolate)],
340
+ showAll: () => [action("showAll", "eye", "Show everything", Actions.ShowAll)],
341
+ undo: () => [action("undo", "undo", "Undo", Actions.Undo)],
342
+ redo: () => [action("redo", "redo", "Redo", Actions.Redo)],
343
+ seeThrough: () => [action("seeThrough", "seeThrough", "See through", Actions.ToggleSeeThrough)],
344
+ section: () => [row("scissors", "Section", h.onSection)],
345
+ look: () => [row("palette", "Look", h.onLook)],
346
+ projection: () => ["Orthographic", "Perspective"].map((title, i) => row(null, title, () => {
347
+ this.projection.selected = i;
348
+ h.onProjection(i === 1);
349
+ }, { checked: this.projection.selected === i })),
350
+ views: () => [el("div.heading", { text: "Views" }),
351
+ ...[["Front", Actions.ViewFront], ["Back", Actions.ViewBack], ["Left", Actions.ViewLeft],
352
+ ["Right", Actions.ViewRight], ["Top", Actions.ViewTop], ["Bottom", Actions.ViewBottom],
353
+ ["Isometric", Actions.ViewIso]]
354
+ .map(([title, a]) => row(null, title, () => h.onAction(a), { disabled: tool("views").disabled }))],
355
+ fit: () => [action("fit", "fit", "Fit to view", Actions.Fit)],
356
+ shortcuts: () => [row(null, "Shortcuts", h.onShortcuts)],
357
+ };
358
+ const items = this.#tools.filter((t) => this.#folded.has(t.key)).flatMap((t) => rows[t.key]?.() ?? []);
359
+ menu.replaceChildren(...items);
360
+ menu.hidden = false;
361
+ this.#place(menu, this.#more);
362
+ }
363
+
364
+ // A menu above its button, kept 8 pixels inside the window.
365
+ #place(menu, button) {
366
+ const rect = button.getBoundingClientRect();
367
+ menu.style.left = `${Math.max(8, Math.min(rect.left, innerWidth - menu.offsetWidth - 8))}px`;
368
+ menu.style.top = `${rect.top - menu.offsetHeight - 10}px`;
369
+ }
370
+
371
+ // The standard views, in a menu above their button, outside the toolbar so nothing clips it.
372
+ #toggleViews(button) {
373
+ const menu = this.viewsMenu;
374
+ menu.hidden = !menu.hidden;
375
+ if (menu.hidden) return;
376
+ this.#place(menu, button);
377
+ }
378
+
379
+ update({ hasSelection, hasModel }) {
380
+ for (const b of this.#needSelection) b.disabled = !hasSelection;
381
+ for (const b of this.#needModel) b.disabled = !hasModel;
382
+ }
383
+ }
384
+
385
+ // ---- The masthead -----------------------------------------------------------------------------
386
+
387
+ export class Masthead {
388
+ #title = el("div.title");
389
+ #subtitle = el("div.subtitle");
390
+ #bar = el("div.progress", { hidden: true }, [el("div.fill")]);
391
+ element = el("header.masthead", { hidden: true }, [this.#title, this.#subtitle, this.#bar]);
392
+
393
+ show(title, subtitle) {
394
+ this.#title.textContent = title;
395
+ this.#subtitle.textContent = subtitle;
396
+ this.element.hidden = false;
397
+ }
398
+
399
+ hide() { this.element.hidden = true; }
400
+
401
+ setProgress(fraction) {
402
+ this.#bar.hidden = fraction === null;
403
+ if (fraction !== null) this.#bar.firstChild.style.transform = `translateX(${(fraction - 1) * 100}%)`;
404
+ }
405
+
406
+ setOnDark(dark) { this.element.classList.toggle("on-dark", dark); }
407
+ }
408
+
409
+ // ---- The status card --------------------------------------------------------------------------
410
+
411
+ // A size as people read it: 57 KB, 8.1 MB, 25 MB.
412
+ function formatBytes(bytes) {
413
+ const [value, unit] = bytes >= 1e6 ? [bytes / 1e6, "MB"] : [Math.max(1, bytes / 1e3), "KB"];
414
+ return `${value < 10 ? value.toFixed(1).replace(/\.0$/, "") : Math.round(value)} ${unit}`;
415
+ }
416
+
417
+ export class StatusCard {
418
+ #heading = el("h2");
419
+ #body = el("p");
420
+ #buttons;
421
+ #samples = el("div.samples", { hidden: true });
422
+ #offering = false;
423
+ #invitation;
424
+ element;
425
+
426
+ // The two ways in: opening files and, where this browser can reach folders, connecting one.
427
+ constructor({ onOpen, onConnect, canConnect, phone }) {
428
+ const open = pill({ icon: icons.plus, title: "Open files", help: "Open STEP, STL or cache files (⌘O)", onPress: onOpen });
429
+ const connect = pill({ icon: icons.folderPlus, title: "Connect a folder", help: "Connect a folder (⌥⌘O)",
430
+ onPress: onConnect });
431
+ connect.hidden = !canConnect;
432
+ this.#buttons = el("div.buttons", {}, [open, connect]);
433
+ this.#invitation = phone ? (canConnect ? "Open STEP or STL files, or connect a folder." : "Open STEP or STL files.")
434
+ : (canConnect ? "Drop STEP or STL files to open them, or a folder to connect it."
435
+ : "Drop STEP or STL files here to open them.");
436
+ this.element = el("section.card.status", { hidden: true, role: "status" },
437
+ [this.#heading, this.#body, this.#buttons, this.#samples]);
438
+ }
439
+
440
+ // Models to try, offered beside the two ways in: [{ name, size, about }], and what picking one does.
441
+ setSamples(samples, onPick) {
442
+ this.#samples.replaceChildren(el("p.heading", { text: "Or try a sample" }), ...samples.map((sample) =>
443
+ el("button.sample", { type: "button", title: sample.about, onclick: () => onPick(sample) },
444
+ [el("span.name", { text: sample.name }), el("span.size", { text: formatBytes(sample.size) })])));
445
+ this.#samples.dataset.count = samples.length;
446
+ this.#samples.hidden = !this.#offering || samples.length === 0;
447
+ }
448
+
449
+ showInvitation() {
450
+ this.#show("Nothing open yet", this.#invitation, true);
451
+ }
452
+ showLoading(name) { this.#show(`Opening ${name}`, "This only takes a moment."); }
453
+ showDownloading(name, done, total) {
454
+ this.#show(`Downloading ${name}`, `${formatBytes(done)} of ${formatBytes(total)}.`);
455
+ }
456
+ showImporting(name, done, total) {
457
+ this.#show(`Importing ${name}`, total > 0 ? `${done} of ${total} pieces meshed.`
458
+ : "Reading its structure…");
459
+ }
460
+ showFailure(message) { this.#show("That did not open", message, true); }
461
+ hide() { this.element.hidden = true; }
462
+
463
+ #show(heading, body, offerOpen = false) {
464
+ this.#heading.textContent = heading;
465
+ this.#body.textContent = body;
466
+ this.#buttons.hidden = !offerOpen;
467
+ this.#offering = offerOpen;
468
+ this.#samples.hidden = !offerOpen || !Number(this.#samples.dataset.count);
469
+ this.element.hidden = false;
470
+ }
471
+ }
472
+
473
+ // ---- Small overlays ---------------------------------------------------------------------------
474
+
475
+ export class HoldHint {
476
+ element = el("div.chip.hint", { hidden: true, "aria-live": "polite" });
477
+
478
+ show(tool) {
479
+ const hints = {
480
+ [Tools.Hide]: ["H", "Click to hide. Click again to peel the next layer."],
481
+ [Tools.Ghost]: ["G", "Click to ghost. Click a ghost to bring it back."],
482
+ };
483
+ const hint = hints[tool];
484
+ this.element.hidden = !hint;
485
+ this.element.classList.toggle("hiding", tool === Tools.Hide);
486
+ if (hint) this.element.replaceChildren(keyCap(hint[0]), el("span", { text: hint[1] }));
487
+ }
488
+ }
489
+
490
+ // A label naming what is under the pointer, beside it.
491
+ export class HoverChip {
492
+ element = el("div.chip.hover", { hidden: true });
493
+
494
+ show(name, face, x, y) {
495
+ if (name === null || x === null) {
496
+ this.element.hidden = true;
497
+ return;
498
+ }
499
+ this.element.replaceChildren(el("span.name", { text: name }), el("span.face", { text: `face ${face + 1}` }));
500
+ this.element.hidden = false;
501
+ const width = this.element.offsetWidth;
502
+ this.element.style.left = `${Math.min(x + 16, innerWidth - width - 8)}px`;
503
+ this.element.style.top = `${Math.max(y + 14, 8)}px`;
504
+ }
505
+ }
506
+
507
+ export class DropZone {
508
+ element = el("div.dropzone", { hidden: true }, [
509
+ el("div.words", { text: "Drop files to open them, or a folder to connect it. Hold ⌘ to add to what is shown." }),
510
+ ]);
511
+ }
512
+
513
+ export class ShortcutsSheet {
514
+ element;
515
+
516
+ constructor() {
517
+ const entries = [
518
+ [["Click"], "Select. Shift or ⌘ adds"],
519
+ [["1", "2", "3"], "Clicks select faces, bodies or parts"],
520
+ [["H", "Click"], "Hide what you click"],
521
+ [["G", "Click"], "Ghost, or bring back a ghost"],
522
+ [["⇧H"], "Show everything"],
523
+ [["⇧G"], "Unghost everything"],
524
+ [["I"], "Isolate the selection"],
525
+ [["⌘", "Z"], "Undo, with ⇧ to redo"],
526
+ [["F"], "Fit to view"],
527
+ [["P"], "Orthographic or perspective"],
528
+ [["T"], "See through the housing"],
529
+ [["S"], "Section across X"],
530
+ [["Esc"], "Clear the selection"],
531
+ [["⌘O"], "Open a file"],
532
+ [["?"], "This sheet"],
533
+ ];
534
+ const grid = el("div.grid", {}, entries.flatMap(([keys, meaning]) => [
535
+ el("div.keys", {}, keys.map(keyCap)), el("div", { text: meaning }),
536
+ ]));
537
+ this.element = el("div.sheet", { hidden: true, onclick: () => { this.element.hidden = true; } }, [
538
+ el("section.card", { onclick: (e) => e.stopPropagation() }, [el("h2", { text: "Shortcuts" }), grid]),
539
+ ]);
540
+ }
541
+ }