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.
- package/README.md +37 -2
- package/bin/geometryview.mjs +65 -0
- package/package.json +42 -4
- package/src/bridge.mjs +77 -0
- package/src/mcp.mjs +73 -0
- package/src/native.mjs +176 -0
- package/src/page.mjs +147 -0
- package/src/tools.mjs +265 -0
- package/src/web.mjs +173 -0
- package/src/websocket.mjs +104 -0
- package/web/LICENSE +21 -0
- package/web/NOTICES.md +49 -0
- package/web/bridge.js +29 -0
- package/web/build/geometryview-web.mjs +2 -0
- package/web/build/geometryview-web.wasm +0 -0
- package/web/chrome.js +541 -0
- package/web/engine.js +453 -0
- package/web/fonts/IBMPlexMono-Medium.ttf +0 -0
- package/web/fonts/IBMPlexMono-Regular.ttf +0 -0
- package/web/fonts/IBMPlexMono-SemiBold.ttf +0 -0
- package/web/fonts/LICENSE.txt +93 -0
- package/web/icons/icon-180.png +0 -0
- package/web/icons/icon-192.png +0 -0
- package/web/icons/icon-32.png +0 -0
- package/web/icons/icon-512.png +0 -0
- package/web/icons.js +50 -0
- package/web/import-worker.js +329 -0
- package/web/index.html +17 -0
- package/web/input.js +255 -0
- package/web/isolation-worker.js +21 -0
- package/web/library-rows.js +120 -0
- package/web/library-store.js +368 -0
- package/web/library.js +306 -0
- package/web/manifest.webmanifest +14 -0
- package/web/occt/geometryview-occt-web.mjs +2 -0
- package/web/occt/geometryview-occt-web.wasm +0 -0
- package/web/occt/geometryview-step-web.mjs +2 -0
- package/web/occt/geometryview-step-web.wasm +0 -0
- package/web/occt-threads/geometryview-step-web.mjs +2 -0
- package/web/occt-threads/geometryview-step-web.wasm +0 -0
- package/web/occt-worker.js +45 -0
- package/web/structure.js +435 -0
- package/web/toolbar-fold.js +49 -0
- package/web/ui.js +134 -0
- package/web/viewer.css +558 -0
- package/web/viewer.js +1163 -0
package/src/tools.mjs
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// The tools an AI app can call: open a model, read its parts, picture it, and show it to the user.
|
|
2
|
+
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { importModel, nativeAvailable, openInViewer, readStats, readTree, render,
|
|
5
|
+
viewerApplication } from "./native.mjs";
|
|
6
|
+
import { linkToPage, openInPage, pageRender, pageTree, showInPage, viewerServer } from "./page.mjs";
|
|
7
|
+
|
|
8
|
+
export const instructions =
|
|
9
|
+
"GeometryView views 3D CAD models (STEP .step/.stp and STL .stl files) on this computer. Use it " +
|
|
10
|
+
"whenever the user wants to see, inspect or picture a CAD model, part or assembly, rather " +
|
|
11
|
+
"than opening a web viewer. Call open_model with the file's absolute path first. " +
|
|
12
|
+
"get_structure lists the assemblies and parts; render_view returns a picture you can look " +
|
|
13
|
+
"at, in any view and drawing style (illustration, metal, clay, blueprint, technical, " +
|
|
14
|
+
"studio). To study a part, pass names from get_structure as isolate, and use section to cut " +
|
|
15
|
+
"through the model. open_in_viewer shows the model to the user, live: in the GeometryView app, " +
|
|
16
|
+
"in their browser, or, when they want to see it inside this chat app, as a link to open in " +
|
|
17
|
+
"the app's own browser (where: link). Where this computer has no native engine, the viewer " +
|
|
18
|
+
"opens in the user's browser and the pictures come from it, so the user sees what you look at.";
|
|
19
|
+
|
|
20
|
+
const STYLES = ["illustration", "metal", "clay", "blueprint", "technical", "studio"];
|
|
21
|
+
const COLOURS = ["material", "parts", "mono", "file"];
|
|
22
|
+
// Where the camera looks from, as azimuth and elevation in degrees, with Z up.
|
|
23
|
+
const VIEWS = {
|
|
24
|
+
iso: [35, 25], front: [0, 0], back: [180, 0], left: [-90, 0], right: [90, 0],
|
|
25
|
+
top: [0, 89.9], bottom: [0, -89.9],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const path = { type: "string", description: "Absolute path to a .step, .stp or .stl file." };
|
|
29
|
+
|
|
30
|
+
// ---- The tree as an outline ---------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
function annotate(nodes) {
|
|
33
|
+
const children = nodes.map(() => []);
|
|
34
|
+
const parts = nodes.map((node) => (node.kind === "part" ? 1 : 0));
|
|
35
|
+
nodes.forEach((node, index) => node.parent >= 0 && children[node.parent].push(index));
|
|
36
|
+
for (let index = nodes.length - 1; index >= 0; --index) {
|
|
37
|
+
if (nodes[index].parent >= 0) {
|
|
38
|
+
parts[nodes[index].parent] += parts[index];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { children, parts };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function describe(node, index, parts) {
|
|
45
|
+
const name = node.name || `(unnamed ${node.kind})`;
|
|
46
|
+
const bodies = node.bodies[1] - node.bodies[0];
|
|
47
|
+
if (node.kind === "assembly") {
|
|
48
|
+
return `${name} — assembly, ${parts[index]} part${parts[index] === 1 ? "" : "s"}`;
|
|
49
|
+
}
|
|
50
|
+
return bodies > 1 ? `${name} — part, ${bodies} bodies` : `${name} — part`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// An indented outline of the tree. Bodies are counted on their parts rather than listed. With a
|
|
54
|
+
/// filter, only nodes whose names contain it, and their ancestors, are shown.
|
|
55
|
+
function outline(nodes, { maxDepth = Infinity, maxLines = 400, filter = "" } = {}) {
|
|
56
|
+
const { children, parts } = annotate(nodes);
|
|
57
|
+
const wanted = new Uint8Array(nodes.length);
|
|
58
|
+
const needle = filter.toLowerCase();
|
|
59
|
+
for (let index = 0; index < nodes.length; ++index) {
|
|
60
|
+
if (!needle || nodes[index].name.toLowerCase().includes(needle)) {
|
|
61
|
+
for (let at = index; at >= 0 && !wanted[at]; at = nodes[at].parent) {
|
|
62
|
+
wanted[at] = 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const lines = [];
|
|
67
|
+
let omitted = 0;
|
|
68
|
+
const walk = (index, depth) => {
|
|
69
|
+
if (!wanted[index] || nodes[index].kind === "body") {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (lines.length >= maxLines || depth > maxDepth) {
|
|
73
|
+
omitted += 1;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
lines.push(`${" ".repeat(depth)}${describe(nodes[index], index, parts)}`);
|
|
77
|
+
children[index].forEach((child) => walk(child, depth + 1));
|
|
78
|
+
};
|
|
79
|
+
nodes.forEach((node, index) => node.parent < 0 && walk(index, 0));
|
|
80
|
+
if (omitted > 0) {
|
|
81
|
+
lines.push(`… ${omitted} more not shown; narrow with filter or raise max_lines.`);
|
|
82
|
+
}
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function counts(nodes) {
|
|
87
|
+
const kinds = { assembly: 0, part: 0, body: 0 };
|
|
88
|
+
nodes.forEach((node) => (kinds[node.kind] += 1));
|
|
89
|
+
const bodies = Math.max(0, ...nodes.filter((n) => n.parent < 0).map((n) => n.bodies[1]));
|
|
90
|
+
return { assemblies: kinds.assembly, parts: kinds.part, bodies };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const round = (value) => +value.toPrecision(4);
|
|
94
|
+
|
|
95
|
+
// ---- Tools -------------------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export const tools = [
|
|
98
|
+
{
|
|
99
|
+
name: "open_model",
|
|
100
|
+
description: "Opens a 3D CAD model, a STEP (.step, .stp) or STL (.stl) file on this " +
|
|
101
|
+
"computer, and summarises it: size, counts and top-level assemblies and parts. " +
|
|
102
|
+
"Call this before the other GeometryView tools.",
|
|
103
|
+
inputSchema: { type: "object", properties: { path }, required: ["path"] },
|
|
104
|
+
async call({ path: file }) {
|
|
105
|
+
const started = performance.now();
|
|
106
|
+
let nodes, stats, how;
|
|
107
|
+
if (nativeAvailable()) {
|
|
108
|
+
const { blob, fromCache } = await importModel(file);
|
|
109
|
+
[nodes, stats] = await Promise.all([readTree(blob), readStats(blob)]);
|
|
110
|
+
how = fromCache ? "from cache" : "imported";
|
|
111
|
+
} else {
|
|
112
|
+
stats = await openInPage(file);
|
|
113
|
+
nodes = await pageTree(file);
|
|
114
|
+
how = "in the browser's viewer";
|
|
115
|
+
}
|
|
116
|
+
const { assemblies, parts, bodies } = counts(nodes);
|
|
117
|
+
const size = stats.max.map((high, axis) => round(high - stats.min[axis]));
|
|
118
|
+
const seconds = ((performance.now() - started) / 1000).toFixed(2);
|
|
119
|
+
return [{
|
|
120
|
+
type: "text",
|
|
121
|
+
text: [
|
|
122
|
+
`Opened ${basename(file)} (${how}, ${seconds} s).`,
|
|
123
|
+
`${assemblies} assemblies, ${parts} parts, ${bodies} bodies; ` +
|
|
124
|
+
`${stats.faces} faces, ${stats.triangles} triangles.`,
|
|
125
|
+
`Bounding box ${size.join(" × ")} (model units), from [${stats.min.map(round)}] to ` +
|
|
126
|
+
`[${stats.max.map(round)}].`,
|
|
127
|
+
"",
|
|
128
|
+
outline(nodes, { maxDepth: 2, maxLines: 60 }),
|
|
129
|
+
].join("\n"),
|
|
130
|
+
}];
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "get_structure",
|
|
135
|
+
description: "Lists a CAD model's assemblies and parts as an indented outline, with " +
|
|
136
|
+
"counts, to find the names of parts to show alone.",
|
|
137
|
+
inputSchema: {
|
|
138
|
+
type: "object",
|
|
139
|
+
properties: {
|
|
140
|
+
path,
|
|
141
|
+
filter: { type: "string", description: "Only parts whose names contain this." },
|
|
142
|
+
max_lines: { type: "integer", minimum: 1, maximum: 5000, default: 400 },
|
|
143
|
+
},
|
|
144
|
+
required: ["path"],
|
|
145
|
+
},
|
|
146
|
+
async call({ path: file, filter = "", max_lines: maxLines = 400 }) {
|
|
147
|
+
const nodes = nativeAvailable() ? await readTree((await importModel(file)).blob)
|
|
148
|
+
: await pageTree(file);
|
|
149
|
+
const text = outline(nodes, { filter, maxLines });
|
|
150
|
+
return [{ type: "text", text: text || `No part's name contains "${filter}".` }];
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "render_view",
|
|
155
|
+
description: "Renders a picture of a 3D CAD model (STEP or STL) for you to look at: a " +
|
|
156
|
+
"standard view or any angle, a drawing style (illustration, metal, clay, " +
|
|
157
|
+
"blueprint, technical, or studio: polished metal on a dark backdrop), named parts " +
|
|
158
|
+
"shown alone, and section cuts through it.",
|
|
159
|
+
inputSchema: {
|
|
160
|
+
type: "object",
|
|
161
|
+
properties: {
|
|
162
|
+
path,
|
|
163
|
+
view: { type: "string", enum: Object.keys(VIEWS), default: "iso" },
|
|
164
|
+
azimuth: { type: "number", description: "Degrees around Z, overriding view." },
|
|
165
|
+
elevation: { type: "number", description: "Degrees above the XY plane, overriding view." },
|
|
166
|
+
style: { type: "string", enum: STYLES, default: "illustration" },
|
|
167
|
+
colour: { type: "string", enum: COLOURS, default: "parts" },
|
|
168
|
+
isolate: { type: "array", items: { type: "string" },
|
|
169
|
+
description: "Show only parts whose names contain one of these." },
|
|
170
|
+
section: {
|
|
171
|
+
type: "array",
|
|
172
|
+
description: "Planes cutting through the model.",
|
|
173
|
+
items: {
|
|
174
|
+
type: "object",
|
|
175
|
+
properties: {
|
|
176
|
+
axis: { type: "string", enum: ["x", "y", "z"] },
|
|
177
|
+
at: { type: "number", minimum: 0, maximum: 1,
|
|
178
|
+
description: "Where along the axis, from 0 to 1 across the model." },
|
|
179
|
+
flip: { type: "boolean", description: "Keep the other side.", default: false },
|
|
180
|
+
},
|
|
181
|
+
required: ["axis", "at"],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
width: { type: "integer", minimum: 64, maximum: 4096, default: 1200 },
|
|
185
|
+
height: { type: "integer", minimum: 64, maximum: 4096, default: 800 },
|
|
186
|
+
perspective: { type: "boolean", default: false },
|
|
187
|
+
},
|
|
188
|
+
required: ["path"],
|
|
189
|
+
},
|
|
190
|
+
async call(args) {
|
|
191
|
+
const source = args.path;
|
|
192
|
+
const [azimuth, elevation] = VIEWS[args.view ?? "iso"] ?? VIEWS.iso;
|
|
193
|
+
const view = {
|
|
194
|
+
width: args.width ?? 1200,
|
|
195
|
+
height: args.height ?? 800,
|
|
196
|
+
azimuth: args.azimuth ?? azimuth,
|
|
197
|
+
elevation: args.elevation ?? elevation,
|
|
198
|
+
perspective: args.perspective ?? false,
|
|
199
|
+
style: Math.max(0, STYLES.indexOf(args.style ?? "illustration")),
|
|
200
|
+
colour: Math.max(0, COLOURS.indexOf(args.colour ?? "parts")),
|
|
201
|
+
section: (args.section ?? [])
|
|
202
|
+
.map(({ axis, at, flip }) => `${axis}${flip ? "-" : ""}${at}`)
|
|
203
|
+
.join(","),
|
|
204
|
+
isolate: args.isolate ?? [],
|
|
205
|
+
overlay: true,
|
|
206
|
+
};
|
|
207
|
+
const png = nativeAvailable()
|
|
208
|
+
? await render((await importModel(args.path)).blob, view)
|
|
209
|
+
: await pageRender(args.path, { ...view, section: args.section ?? [] });
|
|
210
|
+
const shown = view.isolate.length ? ` showing only ${view.isolate.join(", ")}` : "";
|
|
211
|
+
return [
|
|
212
|
+
{ type: "image", data: png.toString("base64"), mimeType: "image/png" },
|
|
213
|
+
{ type: "text", text: `${basename(source)}${shown}, ${args.style ?? "illustration"} ` +
|
|
214
|
+
`style, from azimuth ${view.azimuth}°, elevation ${view.elevation}°.` },
|
|
215
|
+
];
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: "open_in_viewer",
|
|
220
|
+
description: "Shows a STEP or STL model to the user, live, where they can turn it, select " +
|
|
221
|
+
"parts and cut sections: in the GeometryView app on a Mac that has it (where: " +
|
|
222
|
+
"app), in their web browser (where: browser), or as a link for this chat app to " +
|
|
223
|
+
"open in its own browser (where: link; open the returned address there, and the " +
|
|
224
|
+
"model appears once the page loads). It can show only some parts, in a chosen " +
|
|
225
|
+
"style and colours.",
|
|
226
|
+
inputSchema: {
|
|
227
|
+
type: "object",
|
|
228
|
+
properties: {
|
|
229
|
+
path,
|
|
230
|
+
where: { type: "string", enum: ["app", "browser", "link"],
|
|
231
|
+
description: "Where to show it. By default the app where it is installed, else " +
|
|
232
|
+
"the browser." },
|
|
233
|
+
isolate: { type: "array", items: { type: "string" },
|
|
234
|
+
description: "Show only parts whose names contain one of these." },
|
|
235
|
+
style: { type: "string", enum: STYLES },
|
|
236
|
+
colour: { type: "string", enum: COLOURS },
|
|
237
|
+
},
|
|
238
|
+
required: ["path"],
|
|
239
|
+
},
|
|
240
|
+
async call({ path: file, where, isolate = [], style, colour }) {
|
|
241
|
+
const application = process.platform === "darwin" ? viewerApplication() : null;
|
|
242
|
+
where ??= application ? "app" : "browser";
|
|
243
|
+
const name = basename(file);
|
|
244
|
+
const shown = (isolate.length ? `, showing only ${isolate.join(", ")}` : "") +
|
|
245
|
+
(style ? `, in ${style} style` : "") + (colour ? `, coloured by ${colour}` : "");
|
|
246
|
+
if (where === "app") {
|
|
247
|
+
if (!application) throw new Error("The GeometryView app is not installed here: use where browser or link.");
|
|
248
|
+
await openInViewer(file, { isolate, style, colour });
|
|
249
|
+
return [{ type: "text", text: `Opened ${name} in GeometryView${shown}.` }];
|
|
250
|
+
}
|
|
251
|
+
const show = { isolate };
|
|
252
|
+
if (style) show.style = STYLES.indexOf(style);
|
|
253
|
+
if (colour) show.colour = COLOURS.indexOf(colour);
|
|
254
|
+
if (where === "link") {
|
|
255
|
+
const { url, shown: now } = await linkToPage(file, show);
|
|
256
|
+
return [{ type: "text", text: now
|
|
257
|
+
? `Showing ${name}${shown} in the viewer page already open at ${url}.`
|
|
258
|
+
: `Open ${url} in this app's browser to see ${name}${shown}; it appears once the page loads.` }];
|
|
259
|
+
}
|
|
260
|
+
await showInPage(file, show);
|
|
261
|
+
const { address } = await viewerServer();
|
|
262
|
+
return [{ type: "text", text: `Opened ${name}${shown} in the viewer in the browser (${address}).` }];
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
];
|
package/src/web.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// The viewer, served to this machine only: the page and its engine with cross-origin isolation, the
|
|
2
|
+
// model files the AI app asked to show, and the bridge to the page.
|
|
3
|
+
//
|
|
4
|
+
// Every request must name this server by its loopback address, which shuts out DNS rebinding;
|
|
5
|
+
// model files and the bridge need the token this run made, and the bridge the page's own origin,
|
|
6
|
+
// so another site in the same browser can neither read a file nor drive the viewer.
|
|
7
|
+
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
10
|
+
import { stat } from "node:fs/promises";
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
import { basename, dirname, extname, join, resolve, sep } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { Bridge } from "./bridge.mjs";
|
|
15
|
+
import { acceptWebSocket } from "./websocket.mjs";
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
const kTypes = {
|
|
20
|
+
".html": "text/html; charset=utf-8", ".js": "text/javascript", ".mjs": "text/javascript",
|
|
21
|
+
".css": "text/css", ".wasm": "application/wasm", ".json": "application/json",
|
|
22
|
+
".ttf": "font/ttf", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon",
|
|
23
|
+
".webmanifest": "application/manifest+json",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/// The port the viewer prefers: always the same, so the page's origin, and with it the library
|
|
27
|
+
/// and look the browser keeps for it, are the same from one run to the next.
|
|
28
|
+
export const kDefaultPort = 47700;
|
|
29
|
+
|
|
30
|
+
/// What the identity route answers, so a second launcher can tell this one holds the port.
|
|
31
|
+
const kIdentity = JSON.stringify({ name: "geometryview" });
|
|
32
|
+
|
|
33
|
+
/// Whether a GeometryView viewer already answers on `port`.
|
|
34
|
+
export async function viewerAt(port) {
|
|
35
|
+
try {
|
|
36
|
+
const answer = await fetch(`http://127.0.0.1:${port}/geometryview.json`,
|
|
37
|
+
{ signal: AbortSignal.timeout(1000) });
|
|
38
|
+
return answer.ok && (await answer.json()).name === "geometryview";
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Where the viewer's files are, as URL prefixes and folders: GEOMETRYVIEW_WEB_DIR; else, in the
|
|
45
|
+
/// repository, its sources and builds, so an edit shows at once; else the copy packed beside this
|
|
46
|
+
/// package.
|
|
47
|
+
export function viewerMounts() {
|
|
48
|
+
const root = join(here, "../..");
|
|
49
|
+
const packed = [process.env.GEOMETRYVIEW_WEB_DIR,
|
|
50
|
+
existsSync(join(root, "Web/viewer/index.html")) ? null : join(here, "../web")];
|
|
51
|
+
for (const directory of packed.filter(Boolean)) {
|
|
52
|
+
if (existsSync(join(directory, "index.html"))) return [["/", directory]];
|
|
53
|
+
}
|
|
54
|
+
return [
|
|
55
|
+
["/build/", join(root, "build/wasm-webgpu/Engine")],
|
|
56
|
+
["/fonts/", join(root, "ThirdParty/ibm-plex-mono")],
|
|
57
|
+
["/occt/", join(root, "build/wasm-release/Import")],
|
|
58
|
+
["/occt-threads/", join(root, "build/wasm-threads/Import")],
|
|
59
|
+
["/", join(root, "Web/viewer")],
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Starts the server on 127.0.0.1. Returns { url, port, token, bridge, grant(path), close() }.
|
|
64
|
+
export async function startViewer({ port = 0 } = {}) {
|
|
65
|
+
const token = randomBytes(16).toString("hex");
|
|
66
|
+
const mounts = viewerMounts();
|
|
67
|
+
const grants = new Map(); // id to an absolute path the AI app asked to show
|
|
68
|
+
const bridge = new Bridge();
|
|
69
|
+
const headers = {
|
|
70
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
|
71
|
+
"Cross-Origin-Embedder-Policy": "require-corp",
|
|
72
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
73
|
+
"X-Content-Type-Options": "nosniff",
|
|
74
|
+
"Cache-Control": "no-cache",
|
|
75
|
+
};
|
|
76
|
+
let address = "";
|
|
77
|
+
// Bodies go chunked, with their size apart. Safari, like any WebKit, holds back a body whose
|
|
78
|
+
// length it knows and hands it to the page some 80 ms at a time, so a cache file meant to stream
|
|
79
|
+
// into GPU buffers arrived in pieces of up to its whole size, late. A HEAD answer has no body to
|
|
80
|
+
// hold back.
|
|
81
|
+
const sized = (request, size) =>
|
|
82
|
+
request.method === "HEAD" ? { "Content-Length": size } : { "GeometryView-Size": size };
|
|
83
|
+
const ours = (request) => {
|
|
84
|
+
const host = request.headers.host ?? "";
|
|
85
|
+
return host === `127.0.0.1:${server.address().port}` || host === `localhost:${server.address().port}`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const server = createServer(async (request, response) => {
|
|
89
|
+
if (!ours(request)) {
|
|
90
|
+
response.writeHead(421).end();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const url = new URL(request.url, address);
|
|
94
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
95
|
+
response.writeHead(405, headers).end();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (url.pathname === "/geometryview.json") {
|
|
99
|
+
response.writeHead(200, { ...headers, "Content-Type": "application/json" }).end(kIdentity);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// A model file the AI app asked to show.
|
|
103
|
+
if (url.pathname.startsWith("/file/")) {
|
|
104
|
+
const path = grants.get(url.pathname.slice("/file/".length));
|
|
105
|
+
if (url.searchParams.get("token") !== token || !path) {
|
|
106
|
+
response.writeHead(403, headers).end();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const info = await stat(path);
|
|
111
|
+
response.writeHead(200, { ...headers, ...sized(request, info.size),
|
|
112
|
+
"Content-Type": "application/octet-stream",
|
|
113
|
+
"Last-Modified": info.mtime.toUTCString() });
|
|
114
|
+
if (request.method === "HEAD") response.end();
|
|
115
|
+
else createReadStream(path).pipe(response);
|
|
116
|
+
} catch {
|
|
117
|
+
response.writeHead(404, headers).end();
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const path = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
122
|
+
const [prefix, directory] = mounts.find(([p]) => path.startsWith(p));
|
|
123
|
+
const file = resolve(directory, decodeURIComponent(path.slice(prefix.length)));
|
|
124
|
+
if (!file.startsWith(resolve(directory) + sep)) {
|
|
125
|
+
response.writeHead(400, headers).end();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const info = await stat(file);
|
|
130
|
+
if (!info.isFile()) throw new Error("not a file");
|
|
131
|
+
response.writeHead(200, { ...headers, ...sized(request, info.size),
|
|
132
|
+
"Content-Type": kTypes[extname(file)] ?? "application/octet-stream" });
|
|
133
|
+
if (request.method === "HEAD") response.end();
|
|
134
|
+
else createReadStream(file).pipe(response);
|
|
135
|
+
} catch {
|
|
136
|
+
response.writeHead(404, headers).end();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
server.on("upgrade", (request, socket) => {
|
|
141
|
+
const url = new URL(request.url, address || "http://127.0.0.1");
|
|
142
|
+
if (!ours(request) || url.pathname !== "/bridge" || url.searchParams.get("token") !== token ||
|
|
143
|
+
request.headers.origin !== address) {
|
|
144
|
+
socket.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const connection = acceptWebSocket(request, socket);
|
|
148
|
+
if (connection) bridge.attach(connection);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await new Promise((done, fail) => {
|
|
152
|
+
server.once("error", fail);
|
|
153
|
+
server.listen(port, "127.0.0.1", done);
|
|
154
|
+
});
|
|
155
|
+
address = `http://127.0.0.1:${server.address().port}`;
|
|
156
|
+
return {
|
|
157
|
+
url: `${address}/?token=${token}`,
|
|
158
|
+
address,
|
|
159
|
+
port: server.address().port,
|
|
160
|
+
token,
|
|
161
|
+
bridge,
|
|
162
|
+
/// Lets the page fetch `path`, and returns where: a URL on this server.
|
|
163
|
+
grant(path) {
|
|
164
|
+
const id = `${randomBytes(8).toString("hex")}/${encodeURIComponent(basename(path))}`;
|
|
165
|
+
grants.set(id, resolve(path));
|
|
166
|
+
return `/file/${id}?token=${token}`;
|
|
167
|
+
},
|
|
168
|
+
close() {
|
|
169
|
+
bridge.close();
|
|
170
|
+
return new Promise((done) => server.close(done));
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The server side of WebSocket (RFC 6455), for the bridge between the launcher and the viewer's
|
|
2
|
+
// page: the handshake, text frames in both directions, ping and close. No dependencies; frames from
|
|
3
|
+
// the page are masked, as the protocol requires, and frames to it are not.
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
const kGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
8
|
+
const kMaxMessage = 64 << 20; // a rendered picture, base64, with room to spare
|
|
9
|
+
|
|
10
|
+
/// Completes an upgrade request and returns the connection: { send(text), close(), onmessage,
|
|
11
|
+
/// onclose }. Answers 400 and returns null if the request is not a WebSocket handshake.
|
|
12
|
+
export function acceptWebSocket(request, socket) {
|
|
13
|
+
const key = request.headers["sec-websocket-key"];
|
|
14
|
+
if (request.headers.upgrade?.toLowerCase() !== "websocket" || !key) {
|
|
15
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const accept = createHash("sha1").update(key + kGuid).digest("base64");
|
|
19
|
+
socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
|
|
20
|
+
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
|
21
|
+
socket.setNoDelay(true);
|
|
22
|
+
|
|
23
|
+
const connection = { onmessage: null, onclose: null, open: true };
|
|
24
|
+
let buffered = Buffer.alloc(0);
|
|
25
|
+
let fragments = [];
|
|
26
|
+
|
|
27
|
+
const frame = (opcode, payload) => {
|
|
28
|
+
const length = payload.length;
|
|
29
|
+
const header = length < 126 ? Buffer.from([0x80 | opcode, length])
|
|
30
|
+
: length < 65536 ? Buffer.from([0x80 | opcode, 126, length >> 8, length & 255])
|
|
31
|
+
: (() => {
|
|
32
|
+
const h = Buffer.alloc(10);
|
|
33
|
+
h[0] = 0x80 | opcode;
|
|
34
|
+
h[1] = 127;
|
|
35
|
+
h.writeBigUInt64BE(BigInt(length), 2);
|
|
36
|
+
return h;
|
|
37
|
+
})();
|
|
38
|
+
socket.write(Buffer.concat([header, payload]));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const finish = () => {
|
|
42
|
+
if (!connection.open) return;
|
|
43
|
+
connection.open = false;
|
|
44
|
+
connection.onclose?.();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
connection.send = (text) => {
|
|
48
|
+
if (connection.open) frame(0x1, Buffer.from(text, "utf8"));
|
|
49
|
+
};
|
|
50
|
+
connection.close = () => {
|
|
51
|
+
if (connection.open) frame(0x8, Buffer.alloc(0));
|
|
52
|
+
socket.end();
|
|
53
|
+
finish();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
socket.on("data", (chunk) => {
|
|
57
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
58
|
+
for (;;) {
|
|
59
|
+
if (buffered.length < 2) return;
|
|
60
|
+
const fin = (buffered[0] & 0x80) !== 0;
|
|
61
|
+
const opcode = buffered[0] & 0x0f;
|
|
62
|
+
const masked = (buffered[1] & 0x80) !== 0;
|
|
63
|
+
let length = buffered[1] & 0x7f;
|
|
64
|
+
let at = 2;
|
|
65
|
+
if (length === 126) {
|
|
66
|
+
if (buffered.length < 4) return;
|
|
67
|
+
length = buffered.readUInt16BE(2);
|
|
68
|
+
at = 4;
|
|
69
|
+
} else if (length === 127) {
|
|
70
|
+
if (buffered.length < 10) return;
|
|
71
|
+
length = Number(buffered.readBigUInt64BE(2));
|
|
72
|
+
at = 10;
|
|
73
|
+
}
|
|
74
|
+
if (!masked || length > kMaxMessage) {
|
|
75
|
+
connection.close(); // a client must mask; and nothing this large is expected
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (buffered.length < at + 4 + length) return;
|
|
79
|
+
const mask = buffered.subarray(at, at + 4);
|
|
80
|
+
const payload = Buffer.from(buffered.subarray(at + 4, at + 4 + length));
|
|
81
|
+
for (let i = 0; i < payload.length; ++i) payload[i] ^= mask[i & 3];
|
|
82
|
+
buffered = buffered.subarray(at + 4 + length);
|
|
83
|
+
if (opcode === 0x8) {
|
|
84
|
+
connection.close();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (opcode === 0x9) {
|
|
88
|
+
frame(0xA, payload);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (opcode === 0x1 || opcode === 0x0) {
|
|
92
|
+
fragments.push(payload);
|
|
93
|
+
if (fin) {
|
|
94
|
+
const text = Buffer.concat(fragments).toString("utf8");
|
|
95
|
+
fragments = [];
|
|
96
|
+
connection.onmessage?.(text);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
socket.on("close", finish);
|
|
102
|
+
socket.on("error", finish);
|
|
103
|
+
return connection;
|
|
104
|
+
}
|
package/web/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Atharva Aalok
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/web/NOTICES.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
GeometryView is MIT licensed (see [LICENSE](LICENSE)). It builds on the work below, each under its
|
|
4
|
+
own license. Versions are pinned in [ThirdParty/manifest.toml](ThirdParty/manifest.toml).
|
|
5
|
+
|
|
6
|
+
## Shipped in the app, the browser viewer and the launcher
|
|
7
|
+
|
|
8
|
+
| Project | License | How it is used |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| [Open CASCADE Technology](https://github.com/Open-Cascade-SAS/OCCT) (OCCT) 8.0.1 | LGPL-2.1 with the OCCT exception 1.0 | Reads and meshes STEP files: in the Mac app's import helper and in the browser viewer's importers. It is not changed. `ThirdParty/build_occt.sh` and `ThirdParty/build_occt_wasm.sh` rebuild it from its source, so you can relink GeometryView with your own build of it. |
|
|
11
|
+
| [meshoptimizer](https://github.com/zeux/meshoptimizer) 1.3 | MIT | Meshlets and their bounds. Vendored, with its [license](ThirdParty/meshoptimizer/LICENSE.md). |
|
|
12
|
+
| [xxHash](https://github.com/Cyan4973/xxHash) 0.8.3 | BSD-2-Clause | Content hashes, to tell files and repeated shapes apart. Vendored, with its [license](ThirdParty/xxhash/LICENSE). |
|
|
13
|
+
| [IBM Plex Mono](https://github.com/IBM/plex) 2.5.0 | SIL Open Font License 1.1 | The interface's typeface. Vendored, with its [license](ThirdParty/ibm-plex-mono/LICENSE.txt). |
|
|
14
|
+
| [Dawn](https://dawn.googlesource.com/dawn)'s emdawnwebgpu | BSD-3-Clause | The WebGPU bindings compiled into the browser viewer's engine. |
|
|
15
|
+
| [Emscripten](https://emscripten.org) runtime and libraries | MIT / University of Illinois NCSA; libc++ under Apache-2.0 with LLVM exceptions; musl under MIT | Compiled into the browser viewer's WebAssembly. |
|
|
16
|
+
|
|
17
|
+
The figure palette and the see-through look follow [Colorfigures](https://april-zhu.com/colorfigures)
|
|
18
|
+
by April Zhu, under the MIT license:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
Copyright (c) 2026 April Zhu (Colorfigures: colour, hand-drawn style, 3D props, template, examples)
|
|
22
|
+
Copyright (c) 2026 April Zhu (iso-glow, on which Colorfigures is based)
|
|
23
|
+
Copyright (c) 2026 Tolga Cohce (iso-figure, on which iso-glow is based)
|
|
24
|
+
|
|
25
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
26
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
27
|
+
in the Software without restriction, including without limitation the rights
|
|
28
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
29
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
30
|
+
furnished to do so, subject to the following conditions:
|
|
31
|
+
|
|
32
|
+
The above copyright notice and this permission notice shall be included in all
|
|
33
|
+
copies or substantial portions of the Software.
|
|
34
|
+
|
|
35
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
36
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
37
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
38
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
39
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
40
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
41
|
+
SOFTWARE.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Used to build and test only
|
|
45
|
+
|
|
46
|
+
| Project | License |
|
|
47
|
+
|---|---|
|
|
48
|
+
| [doctest](https://github.com/doctest/doctest) 2.4.12 | MIT; vendored with its [license](ThirdParty/doctest/LICENSE.txt) |
|
|
49
|
+
| [Dawn](https://dawn.googlesource.com/dawn) (native) | BSD-3-Clause; the WebGPU test harness on the Mac |
|
package/web/bridge.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The page's side of the launcher's bridge: when the launcher opened this page, it passes a token,
|
|
2
|
+
// and the page takes requests from the AI app's tools over a WebSocket to the launcher, and
|
|
3
|
+
// answers them. See Launcher/src/bridge.mjs.
|
|
4
|
+
|
|
5
|
+
export function connectBridge(token, handlers) {
|
|
6
|
+
const connect = () => {
|
|
7
|
+
const socket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}` +
|
|
8
|
+
`/bridge?token=${encodeURIComponent(token)}`);
|
|
9
|
+
socket.onmessage = async (event) => {
|
|
10
|
+
let message;
|
|
11
|
+
try {
|
|
12
|
+
message = JSON.parse(event.data);
|
|
13
|
+
} catch {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const { id, method, params } = message;
|
|
17
|
+
try {
|
|
18
|
+
const handler = handlers[method];
|
|
19
|
+
if (!handler) throw new Error(`The viewer does not know ${method}.`);
|
|
20
|
+
socket.send(JSON.stringify({ id, result: await handler(params ?? {}) }));
|
|
21
|
+
} catch (error) {
|
|
22
|
+
socket.send(JSON.stringify({ id, error: String(error?.message ?? error) }));
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
// The launcher may restart: keep trying, quietly.
|
|
26
|
+
socket.onclose = () => setTimeout(connect, 2000);
|
|
27
|
+
};
|
|
28
|
+
connect();
|
|
29
|
+
}
|