geometryview 0.0.0-stage → 0.1.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.
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 +176 -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/README.md CHANGED
@@ -1,3 +1,38 @@
1
- # Temporary Holding Version
1
+ # GeometryView
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **See inside your CAD models, fast.** Open STEP and STL files in your browser, and let your AI apps
4
+ open, inspect and picture them.
5
+
6
+ <img src="https://raw.githubusercontent.com/atharvaaalok/geometryview/main/Docs/media/styles.jpg" alt="A drive cut open, in six drawing styles" width="600">
7
+
8
+ ## View a model
9
+
10
+ ```sh
11
+ npx geometryview part.step # opens it in your browser
12
+ npx geometryview # the viewer, with your folders and recent files
13
+ ```
14
+
15
+ The viewer is served from your computer only (127.0.0.1), and your files never leave it. It needs a
16
+ browser with WebGPU: Chrome or Edge, or Safari 26 or later. Or use the hosted copy, with sample
17
+ models: [atharvaaalok.github.io/geometryview](https://atharvaaalok.github.io/geometryview/).
18
+
19
+ ## Use it from your AI app
20
+
21
+ `geometryview --mcp` is a tool server (MCP) with four tools:
22
+
23
+ - `open_model` opens a file and sums it up.
24
+ - `get_structure` lists its assemblies and parts.
25
+ - `render_view` returns a picture: any view, any of six styles, sections, parts alone.
26
+ - `open_in_viewer` shows it to you, live.
27
+
28
+ ```sh
29
+ claude mcp add geometryview -- npx -y geometryview --mcp # Claude Code
30
+ codex mcp add geometryview -- npx -y geometryview --mcp # Codex
31
+ ```
32
+
33
+ Other apps take the same command, `npx -y geometryview --mcp`, in their MCP settings. Claude
34
+ Desktop can also install it in one click from the `.mcpb` in the
35
+ [latest release](https://github.com/atharvaaalok/geometryview/releases/latest).
36
+
37
+ More, including the Mac app and the iPhone: [github.com/atharvaaalok/geometryview](https://github.com/atharvaaalok/geometryview).
38
+ MIT licensed.
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ // geometryview [file] serves the viewer on this machine and opens it in the browser, with the
3
+ // file if one is given
4
+ // geometryview --no-open [file]
5
+ // serves it and prints its address, to open in any browser, such as an
6
+ // AI app's own; the file opens in the first page to load it
7
+ // geometryview --mcp runs the tool server that AI apps start (Claude, Codex, ChatGPT)
8
+ //
9
+ // The viewer is at http://127.0.0.1:47700, so the browser keeps its library from one run to the
10
+ // next. If GeometryView is already running there, the browser is opened at it. --port N, or
11
+ // GEOMETRYVIEW_PORT, chooses another port.
12
+
13
+ import { serve } from "../src/mcp.mjs";
14
+ import { linkToPage, openBrowser, openInPage, preferredPort, viewerServer } from "../src/page.mjs";
15
+ import { instructions, tools } from "../src/tools.mjs";
16
+ import { viewerAt } from "../src/web.mjs";
17
+
18
+ const args = process.argv.slice(2);
19
+ const at = args.indexOf("--port");
20
+ if (at >= 0) {
21
+ process.env.GEOMETRYVIEW_PORT = args[at + 1];
22
+ args.splice(at, 2);
23
+ }
24
+
25
+ if (args.includes("--mcp")) {
26
+ serve(tools, instructions);
27
+ } else if (args.includes("--help") || args.includes("-h")) {
28
+ process.stdout.write("usage: geometryview [--port N] [--no-open] [file] | --mcp\n");
29
+ } else {
30
+ const file = args.find((arg) => !arg.startsWith("-"));
31
+ const launch = !args.includes("--no-open") && !args.includes("--serve");
32
+ const port = preferredPort();
33
+ if (port !== 0 && (await viewerAt(port))) {
34
+ const address = `http://127.0.0.1:${port}/`;
35
+ process.stdout.write(`GeometryView is already running at ${address}\n`);
36
+ if (file) {
37
+ process.stdout.write("Open the file there, by dragging it onto the page or with ⌘O, or stop " +
38
+ "that viewer and run this again.\n");
39
+ }
40
+ if (launch) await openBrowser(address);
41
+ } else if (!launch) {
42
+ const viewer = await viewerServer();
43
+ process.stdout.write(`GeometryView is at ${viewer.url}\nOpen that address in a browser. ` +
44
+ "Press Ctrl-C to stop.\n");
45
+ if (file) {
46
+ try {
47
+ await linkToPage(file);
48
+ } catch (error) {
49
+ process.stderr.write(`${error.message}\n`);
50
+ }
51
+ }
52
+ } else {
53
+ const viewer = await viewerServer();
54
+ process.stdout.write(`GeometryView is at ${viewer.address}/\nPress Ctrl-C to stop.\n`);
55
+ if (file) {
56
+ try {
57
+ await openInPage(file);
58
+ } catch (error) {
59
+ process.stderr.write(`${error.message}\n`);
60
+ }
61
+ } else {
62
+ await openBrowser(viewer.url);
63
+ }
64
+ }
65
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "geometryview",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "View STEP and STL models in your browser, and let AI apps open, inspect and picture them (MCP).",
5
+ "keywords": [
6
+ "step",
7
+ "stl",
8
+ "cad",
9
+ "3d",
10
+ "viewer",
11
+ "webgpu",
12
+ "wasm",
13
+ "opencascade",
14
+ "mcp",
15
+ "model-context-protocol"
16
+ ],
17
+ "homepage": "https://github.com/atharvaaalok/geometryview#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/atharvaaalok/geometryview/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/atharvaaalok/geometryview.git",
24
+ "directory": "Launcher"
25
+ },
26
+ "license": "MIT",
27
+ "author": "Atharva Aalok",
28
+ "type": "module",
29
+ "bin": {
30
+ "geometryview": "bin/geometryview.mjs"
31
+ },
32
+ "files": [
33
+ "bin",
34
+ "src",
35
+ "web"
36
+ ],
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "scripts": {
41
+ "test": "node --test test/*.test.mjs",
42
+ "prepack": "node scripts/web.mjs"
43
+ }
44
+ }
package/src/bridge.mjs ADDED
@@ -0,0 +1,77 @@
1
+ // The bridge between the launcher and the viewer's pages: requests from the AI app's tools to the
2
+ // most recently connected page, and the page's answers. Messages are JSON:
3
+ // launcher to page: { id, method, params }
4
+ // page to launcher: { id, result } or { id, error }
5
+
6
+ export class Bridge {
7
+ #pages = [];
8
+ #pending = new Map();
9
+ #next = 1;
10
+ #waiting = [];
11
+
12
+ /// Takes a page's connection, from acceptWebSocket.
13
+ attach(connection) {
14
+ this.#pages.push(connection);
15
+ connection.onmessage = (text) => {
16
+ let message;
17
+ try {
18
+ message = JSON.parse(text);
19
+ } catch {
20
+ return;
21
+ }
22
+ const pending = this.#pending.get(message.id);
23
+ if (!pending) return;
24
+ this.#pending.delete(message.id);
25
+ clearTimeout(pending.timer);
26
+ if (message.error !== undefined) pending.reject(new Error(String(message.error)));
27
+ else pending.resolve(message.result);
28
+ };
29
+ connection.onclose = () => {
30
+ this.#pages = this.#pages.filter((page) => page !== connection);
31
+ for (const [id, pending] of this.#pending) {
32
+ if (pending.page === connection) {
33
+ this.#pending.delete(id);
34
+ clearTimeout(pending.timer);
35
+ pending.reject(new Error("The viewer's page was closed."));
36
+ }
37
+ }
38
+ };
39
+ for (const resolve of this.#waiting.splice(0)) resolve(connection);
40
+ }
41
+
42
+ get connected() { return this.#pages.length > 0; }
43
+
44
+ /// The newest page, waiting up to `timeoutMs` for one to connect.
45
+ page(timeoutMs) {
46
+ if (this.connected) return Promise.resolve(this.#pages.at(-1));
47
+ return new Promise((resolve, reject) => {
48
+ const timer = setTimeout(() => {
49
+ this.#waiting = this.#waiting.filter((w) => w !== done);
50
+ reject(new Error("The viewer did not open in a browser in time."));
51
+ }, timeoutMs);
52
+ const done = (page) => {
53
+ clearTimeout(timer);
54
+ resolve(page);
55
+ };
56
+ this.#waiting.push(done);
57
+ });
58
+ }
59
+
60
+ /// Asks the newest page to do `method`, and returns its result.
61
+ async request(method, params = {}, { timeoutMs = 120_000, waitMs = 30_000 } = {}) {
62
+ const page = await this.page(waitMs);
63
+ const id = this.#next++;
64
+ return new Promise((resolve, reject) => {
65
+ const timer = setTimeout(() => {
66
+ this.#pending.delete(id);
67
+ reject(new Error(`The viewer did not answer ${method} in time.`));
68
+ }, timeoutMs);
69
+ this.#pending.set(id, { resolve, reject, timer, page });
70
+ page.send(JSON.stringify({ id, method, params }));
71
+ });
72
+ }
73
+
74
+ close() {
75
+ for (const page of this.#pages) page.close();
76
+ }
77
+ }
package/src/mcp.mjs ADDED
@@ -0,0 +1,73 @@
1
+ // The Model Context Protocol over standard input and output: newline-delimited JSON-RPC 2.0.
2
+ // Only what a tool server needs: initialize, ping, tools/list and tools/call. Standard output
3
+ // carries the protocol alone, so anything else goes to standard error.
4
+
5
+ import { createInterface } from "node:readline";
6
+
7
+ const SERVER = { name: "geometryview", version: "0.1.0" };
8
+ const LATEST_PROTOCOL = "2025-06-18";
9
+
10
+ /// Serves `tools` until standard input closes. Each tool is { name, description, inputSchema,
11
+ /// call(args) -> Promise<content[]> }; a thrown error becomes a tool error the model can read.
12
+ export function serve(tools, instructions) {
13
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
14
+ const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
15
+ const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
16
+
17
+ const handlers = {
18
+ initialize: (params) => ({
19
+ // Answer in the client's version: every version so far shares what this server uses.
20
+ protocolVersion: params?.protocolVersion ?? LATEST_PROTOCOL,
21
+ capabilities: { tools: {} },
22
+ serverInfo: SERVER,
23
+ instructions,
24
+ }),
25
+ ping: () => ({}),
26
+ "tools/list": () => ({
27
+ tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
28
+ }),
29
+ "tools/call": async (params) => {
30
+ const tool = byName.get(params?.name);
31
+ if (!tool) {
32
+ return { content: [{ type: "text", text: `There is no tool named ${params?.name}.` }],
33
+ isError: true };
34
+ }
35
+ try {
36
+ return { content: await tool.call(params.arguments ?? {}) };
37
+ } catch (error) {
38
+ return { content: [{ type: "text", text: error.message ?? String(error) }], isError: true };
39
+ }
40
+ },
41
+ };
42
+
43
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
44
+ lines.on("line", async (line) => {
45
+ if (!line.trim()) {
46
+ return;
47
+ }
48
+ let message;
49
+ try {
50
+ message = JSON.parse(line);
51
+ } catch {
52
+ fail(null, -32700, "Parse error");
53
+ return;
54
+ }
55
+ const handler = handlers[message.method];
56
+ if (message.id === undefined) {
57
+ return; // a notification, such as notifications/initialized
58
+ }
59
+ if (!handler) {
60
+ fail(message.id, -32601, `Method not found: ${message.method}`);
61
+ return;
62
+ }
63
+ try {
64
+ reply(message.id, await handler(message.params));
65
+ } catch (error) {
66
+ fail(message.id, -32603, error.message ?? String(error));
67
+ }
68
+ });
69
+ }
70
+
71
+ function send(message) {
72
+ process.stdout.write(JSON.stringify(message) + "\n");
73
+ }
package/src/native.mjs ADDED
@@ -0,0 +1,176 @@
1
+ // The native engine on a Mac, driven through gcli: import a STEP or STL file into a cache file,
2
+ // read its assembly tree and statistics, and render it to a PNG. The universal WebAssembly engine
3
+ // will take this module's place on other machines.
4
+
5
+ import { execFile } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync } from "node:fs";
8
+ import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
9
+ import { homedir, tmpdir } from "node:os";
10
+ import { basename, dirname, join, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { promisify } from "node:util";
13
+
14
+ const run = promisify(execFile);
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+
17
+ /// Where gcli, the OCCT worker and the shader library are: GEOMETRYVIEW_NATIVE_DIR if set, else the
18
+ /// copy bundled beside this package, else the repository's release build.
19
+ function nativeDirectory() {
20
+ const candidates = [
21
+ process.env.GEOMETRYVIEW_NATIVE_DIR,
22
+ join(here, "../native"),
23
+ join(here, "../../build/release"),
24
+ ].filter(Boolean);
25
+ for (const directory of candidates) {
26
+ if (existsSync(join(directory, "gcli")) || existsSync(join(directory, "Tools/gcli"))) {
27
+ return directory;
28
+ }
29
+ }
30
+ throw new Error("The native engine was not found. Build it with `cmake --build --preset " +
31
+ "release`, or set GEOMETRYVIEW_NATIVE_DIR.");
32
+ }
33
+
34
+ /// Whether this machine has the native engine (a Mac with gcli built or bundled), unless
35
+ /// GEOMETRYVIEW_ENGINE=page asks for the browser's.
36
+ export function nativeAvailable() {
37
+ if (process.env.GEOMETRYVIEW_ENGINE === "page") return false;
38
+ try {
39
+ nativeDirectory();
40
+ return process.platform === "darwin";
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ function binaries() {
47
+ const directory = nativeDirectory();
48
+ // A bundled copy is flat; a build directory keeps each target in its own folder.
49
+ const flat = existsSync(join(directory, "gcli"));
50
+ return {
51
+ gcli: join(directory, flat ? "gcli" : "Tools/gcli"),
52
+ worker: join(directory, flat ? "geometryview-occt-worker" : "Import/geometryview-occt-worker"),
53
+ shaders: join(directory, flat ? "default.metallib" : "shaders/default.metallib"),
54
+ };
55
+ }
56
+
57
+ const cacheDirectory = join(homedir(), "Library/Caches/geometryview");
58
+
59
+ /// Imports `path` unless an import of the same file, unchanged, is cached. Returns the cache file.
60
+ // The cache file version gcli writes and reads, from Import/format/blob.h.
61
+ export const kCacheVersion = 5;
62
+
63
+ // Whether a cache file is in the current format: 8 bytes of magic, then a little-endian u32.
64
+ async function isCurrent(path) {
65
+ const file = await open(path, "r");
66
+ try {
67
+ const header = Buffer.alloc(12);
68
+ const { bytesRead } = await file.read(header, 0, 12, 0);
69
+ return bytesRead === 12 && header.readUInt32LE(8) === kCacheVersion;
70
+ } finally {
71
+ await file.close();
72
+ }
73
+ }
74
+
75
+ export async function importModel(path) {
76
+ const source = resolve(path);
77
+ const info = await stat(source).catch(() => {
78
+ throw new Error(`There is no file at ${source}.`);
79
+ });
80
+ if (!info.isFile()) {
81
+ throw new Error(`${source} is not a file.`);
82
+ }
83
+ if (!/\.(step|stp|stl)$/i.test(source)) {
84
+ throw new Error("Only STEP (.step, .stp) and STL (.stl) files can be opened.");
85
+ }
86
+ const key = createHash("sha256")
87
+ .update(`${source}\0${info.size}\0${info.mtimeMs}`)
88
+ .digest("hex")
89
+ .slice(0, 32);
90
+ const blob = join(cacheDirectory, `${key}.geoblob`);
91
+ if (existsSync(blob)) {
92
+ // One in an older format is made again: gcli reads only its own.
93
+ if (await isCurrent(blob)) return { blob, source, fromCache: true };
94
+ await rm(blob, { force: true });
95
+ }
96
+ await mkdir(cacheDirectory, { recursive: true });
97
+ const { gcli, worker } = binaries();
98
+ const partial = `${blob}.${process.pid}.partial`;
99
+ try {
100
+ await run(gcli, ["import", source, partial, worker], { maxBuffer: 1 << 20 });
101
+ } catch (error) {
102
+ await rm(partial, { force: true });
103
+ throw new Error(`Could not import ${basename(source)}: ${(error.stderr || error.message).trim()}`);
104
+ }
105
+ await rename(partial, blob);
106
+ return { blob, source, fromCache: false };
107
+ }
108
+
109
+ /// The assembly tree: one node per entry, depth-first, with parent, kind, name and body range.
110
+ export async function readTree(blob) {
111
+ const { gcli } = binaries();
112
+ const { stdout } = await run(gcli, ["tree", blob], { maxBuffer: 1 << 30 });
113
+ return JSON.parse(stdout);
114
+ }
115
+
116
+ /// Faces, triangles, area, signed volume and the bounding box.
117
+ export async function readStats(blob) {
118
+ const { gcli } = binaries();
119
+ const { stdout } = await run(gcli, ["stats", blob], { maxBuffer: 1 << 20 });
120
+ return JSON.parse(stdout);
121
+ }
122
+
123
+ /// Renders the cache file and returns the PNG's bytes.
124
+ export async function render(blob, view) {
125
+ const { gcli, shaders } = binaries();
126
+ const png = join(tmpdir(), `geometry-view-${process.pid}-${Date.now()}.png`);
127
+ const args = [
128
+ "render", shaders, blob, png,
129
+ String(view.width), String(view.height),
130
+ String(view.azimuth), String(view.elevation),
131
+ view.perspective ? "perspective" : "ortho",
132
+ String(view.style), String(view.colour),
133
+ view.section ?? "",
134
+ view.overlay ? "overlay" : "plain",
135
+ (view.isolate ?? []).join(","),
136
+ ];
137
+ try {
138
+ await run(gcli, args, { maxBuffer: 1 << 20 });
139
+ return await readFile(png);
140
+ } catch (error) {
141
+ throw new Error((error.stderr || error.message).trim());
142
+ } finally {
143
+ await rm(png, { force: true });
144
+ }
145
+ }
146
+
147
+ /// The native GeometryView app, if it is installed or built.
148
+ export function viewerApplication() {
149
+ const candidates = [
150
+ process.env.GEOMETRYVIEW_APP,
151
+ "/Applications/GeometryView.app",
152
+ join(homedir(), "Applications/GeometryView.app"),
153
+ join(here, "../../build/release/App/GeometryView.app"),
154
+ ].filter(Boolean);
155
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
156
+ }
157
+
158
+ /// Opens `path` in the app, then, if asked, shows some parts alone, in a style and colours: a
159
+ /// geometryview://show request sent after the file, which the app applies once the model is in.
160
+ /// The file itself goes the usual way, which grants the app's sandbox access to it.
161
+ export async function openInViewer(path, { isolate = [], style, colour } = {}) {
162
+ const application = viewerApplication();
163
+ if (!application) {
164
+ throw new Error("The GeometryView app was not found on this Mac.");
165
+ }
166
+ const file = resolve(path);
167
+ await run("open", ["-a", application, file]);
168
+ if (isolate.length > 0 || style || colour) {
169
+ const query = new URLSearchParams({ file });
170
+ for (const name of isolate) query.append("isolate", name);
171
+ if (style) query.set("style", style);
172
+ if (colour) query.set("colour", colour);
173
+ await run("open", ["-a", application, `geometryview://show?${query}`]);
174
+ }
175
+ return application;
176
+ }
package/src/page.mjs ADDED
@@ -0,0 +1,147 @@
1
+ // The viewer in the person's browser, as an engine for the AI app's tools: on machines without the
2
+ // native engine, a model is opened, described and pictured by the page, which the person sees too.
3
+
4
+ import { execFile, spawn } from "node:child_process";
5
+ import { existsSync } from "node:fs";
6
+ import { stat } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { basename, join, resolve } from "node:path";
9
+ import { kDefaultPort, startViewer } from "./web.mjs";
10
+
11
+ let viewer = null;
12
+ let shownFor = null; // the path the page has open, as the launcher asked
13
+
14
+ /// The port asked for: GEOMETRYVIEW_PORT, or the usual one.
15
+ export function preferredPort() {
16
+ return Number(process.env.GEOMETRYVIEW_PORT ?? kDefaultPort);
17
+ }
18
+
19
+ /// The viewer's server, started once per launcher, on the preferred port. If something else holds
20
+ /// it, such as another launcher, on any free port: a page there has a library of its own.
21
+ export async function viewerServer() {
22
+ if (!viewer) {
23
+ const port = preferredPort();
24
+ try {
25
+ viewer = await startViewer({ port });
26
+ } catch (error) {
27
+ if (error.code !== "EADDRINUSE" || port === 0) throw error;
28
+ viewer = await startViewer({ port: 0 });
29
+ process.stderr.write(`Port ${port} is in use, so the viewer is at ${viewer.address}, where ` +
30
+ "the browser keeps a library of its own.\n");
31
+ }
32
+ }
33
+ return viewer;
34
+ }
35
+
36
+ // Chrome on Linux gives a page WebGPU only with Vulkan on, which is off by default on NVIDIA
37
+ // GPUs at least, and flags cannot reach a Chrome already running. So on Linux the viewer opens in a
38
+ // Chrome window of its own, with its own profile, and Vulkan on.
39
+ const linuxChromes = ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium",
40
+ "/usr/bin/chromium-browser", "/snap/bin/chromium"];
41
+
42
+ /// The arguments that open `url` in the viewer's own Chrome window on Linux, or null without Chrome.
43
+ export function linuxViewerWindow(url, home = homedir(), exists = existsSync) {
44
+ const chrome = process.env.GEOMETRYVIEW_CHROME ?? linuxChromes.find((path) => exists(path));
45
+ if (!chrome) return null;
46
+ const state = process.env.XDG_STATE_HOME ?? join(home, ".local/state");
47
+ return [chrome, [`--user-data-dir=${join(state, "geometryview/chrome")}`, "--enable-features=Vulkan",
48
+ "--no-first-run", "--no-default-browser-check", `--app=${url}`]];
49
+ }
50
+
51
+ /// Opens `url` in the person's default browser, or on Linux the viewer's own Chrome window.
52
+ export function openBrowser(url) {
53
+ const window = process.platform === "linux" ? linuxViewerWindow(url) : null;
54
+ if (window) {
55
+ // It outlives the launcher's request: the person keeps the window.
56
+ spawn(window[0], window[1], { detached: true, stdio: "ignore" }).unref();
57
+ return Promise.resolve();
58
+ }
59
+ const [command, args] =
60
+ process.platform === "darwin" ? ["open", [url]]
61
+ : process.platform === "win32" ? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]]
62
+ : ["xdg-open", [url]];
63
+ return new Promise((done) => execFile(command, args, () => done()));
64
+ }
65
+
66
+ /// The page. If none is connected, the browser is opened at it, unless `launch` is false: then
67
+ /// the first page to connect from the viewer's address, opened anywhere, such as an AI app's own
68
+ /// browser pane.
69
+ async function page({ launch = true } = {}) {
70
+ const server = await viewerServer();
71
+ if (!server.bridge.connected) {
72
+ shownFor = null;
73
+ if (launch) await openBrowser(server.url);
74
+ }
75
+ return server;
76
+ }
77
+
78
+ /// The model file at `path`, checked: its full path and modification time.
79
+ async function modelFile(path) {
80
+ const source = resolve(path);
81
+ const info = await stat(source).catch(() => {
82
+ throw new Error(`There is no file at ${source}.`);
83
+ });
84
+ if (!info.isFile()) throw new Error(`${source} is not a file.`);
85
+ if (!/\.(step|stp|stl|geoblob)$/i.test(source)) {
86
+ throw new Error("Only STEP (.step, .stp) and STL (.stl) files can be opened.");
87
+ }
88
+ return { source, info };
89
+ }
90
+
91
+ /// Opens `path` in the page, unless it is open there already. Returns the scene's description.
92
+ /// `waitMs` is how long to wait for a page to connect.
93
+ export async function openInPage(path, { launch = true, waitMs = 30_000 } = {}) {
94
+ const { source, info } = await modelFile(path);
95
+ const server = await page({ launch });
96
+ if (shownFor === source && server.bridge.connected) {
97
+ return server.bridge.request("describe", {}, { waitMs });
98
+ }
99
+ const description = await server.bridge.request("open", {
100
+ url: server.grant(source), name: basename(source), lastModified: info.mtimeMs,
101
+ }, { timeoutMs: 600_000, waitMs });
102
+ shownFor = source;
103
+ return description;
104
+ }
105
+
106
+ /// Opens `path` in the page and shows it as asked: `isolate`, names of parts to show alone, and a
107
+ /// `style` and `colour`, as the look card numbers them. Returns the scene's description, with
108
+ /// `isolated`, how many parts and assemblies are shown alone, when some were asked for.
109
+ export async function showInPage(path, show = {}, options = {}) {
110
+ const description = await openInPage(path, options);
111
+ if (show.isolate?.length > 0 || show.style !== undefined || show.colour !== undefined) {
112
+ return (await viewerServer()).bridge.request("show", show, { waitMs: options.waitMs });
113
+ }
114
+ return description;
115
+ }
116
+
117
+ // How long a link waits for its page: the person may open it a while after it is offered.
118
+ const kLinkWaitMs = 30 * 60_000;
119
+
120
+ /// The viewer's address for an AI app to open in its own browser, with `path` shown in it as
121
+ /// asked: at once in a page already connected, or else in the first page to connect. Opens no
122
+ /// browser itself.
123
+ export async function linkToPage(path, show = {}) {
124
+ await modelFile(path);
125
+ const server = await viewerServer();
126
+ const connected = server.bridge.connected;
127
+ const showing = showInPage(path, show, { launch: false, waitMs: kLinkWaitMs });
128
+ if (connected) {
129
+ await showing;
130
+ } else {
131
+ showing.catch((error) => process.stderr.write(`${error.message}\n`));
132
+ }
133
+ return { url: server.url, shown: connected };
134
+ }
135
+
136
+ /// The open model's tree, as gcli tree gives it.
137
+ export async function pageTree(path) {
138
+ await openInPage(path);
139
+ return (await viewerServer()).bridge.request("structure", {});
140
+ }
141
+
142
+ /// A picture of the open model, as PNG bytes.
143
+ export async function pageRender(path, view) {
144
+ await openInPage(path);
145
+ const answer = await (await viewerServer()).bridge.request("render", view);
146
+ return Buffer.from(answer.png, "base64");
147
+ }