geometryview 0.1.2 → 0.4.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 CHANGED
@@ -1,9 +1,9 @@
1
1
  # GeometryView
2
2
 
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.
3
+ **See inside your CAD models, and a whole folder of them at once.** STEP and STL, in your browser,
4
+ on a Mac, on your phone, and from your AI apps.
5
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">
6
+ <img src="https://raw.githubusercontent.com/atharvaaalok/geometryview/v0.4.0/Docs/media/fig1-light.webp" width="680" alt="A turbofan cut along its axis, six stages numbered, in a drafting frame">
7
7
 
8
8
  ## View a model
9
9
 
@@ -13,26 +13,35 @@ npx geometryview # the viewer, with your folders and recent files
13
13
  ```
14
14
 
15
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/).
16
+ browser with WebGPU: Chrome or Edge, or Safari 26 or later; on Linux it opens a Chrome window of its
17
+ own, with Vulkan on. Or use the hosted copy:
18
+ [atharvaaalok.github.io/geometryview](https://atharvaaalok.github.io/geometryview/).
18
19
 
19
- ## Use it from your AI app
20
+ Connect a folder and every part in it is drawn to scale, five ways: Plates, Sheet, Ladders, Atlas and
21
+ Catalogue.
20
22
 
21
- `geometryview --mcp` is a tool server (MCP) with four tools:
23
+ <img src="https://raw.githubusercontent.com/atharvaaalok/geometryview/v0.4.0/Docs/media/plates-light.webp" width="680" alt="A folder of 124 parts as a poster, fifteen families each at its own scale">
22
24
 
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.
25
+ ## Use it from your AI app
27
26
 
28
27
  ```sh
29
28
  claude mcp add geometryview -- npx -y geometryview --mcp # Claude Code
30
29
  codex mcp add geometryview -- npx -y geometryview --mcp # Codex
31
30
  ```
32
31
 
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
32
+ Other apps take the same command, `npx -y geometryview --mcp`, in their MCP settings. Claude Desktop
33
+ installs it in one click from the `.mcpb` in the
35
34
  [latest release](https://github.com/atharvaaalok/geometryview/releases/latest).
36
35
 
37
- More, including the Mac app and the iPhone: [github.com/atharvaaalok/geometryview](https://github.com/atharvaaalok/geometryview).
38
- MIT licensed.
36
+ - `open_model` opens a file and sums it up.
37
+ - `get_structure` lists its assemblies and parts.
38
+ - `render_view` returns a picture: any view, any of six styles, sections, parts alone.
39
+ - `open_in_viewer` shows it to you, live.
40
+ - `view_folder` pictures a whole folder on one contact sheet. It needs the native engine: the Claude
41
+ Desktop extension for Mac, or a build from source.
42
+
43
+ Your files stay on your computer. The pictures, part names and structure the tools return go to
44
+ your AI app, and from it to its provider.
45
+
46
+ More, including the Mac app and the iPhone:
47
+ [github.com/atharvaaalok/geometryview](https://github.com/atharvaaalok/geometryview). MIT licensed.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "geometryview",
3
- "version": "0.1.2",
4
- "description": "View STEP and STL models in your browser, and let AI apps open, inspect and picture them (MCP).",
3
+ "version": "0.4.0",
4
+ "description": "See inside STEP and STL models, and whole folders of them, in your browser; and let AI apps open, inspect and picture them (MCP).",
5
5
  "keywords": [
6
6
  "step",
7
7
  "stl",
@@ -14,7 +14,7 @@
14
14
  "mcp",
15
15
  "model-context-protocol"
16
16
  ],
17
- "homepage": "https://github.com/atharvaaalok/geometryview#readme",
17
+ "homepage": "https://atharvaaalok.github.io/geometryview/",
18
18
  "bugs": {
19
19
  "url": "https://github.com/atharvaaalok/geometryview/issues"
20
20
  },
@@ -39,6 +39,7 @@
39
39
  },
40
40
  "scripts": {
41
41
  "test": "node --test test/*.test.mjs",
42
- "prepack": "node scripts/web.mjs"
42
+ "prepack": "node scripts/web.mjs",
43
+ "prepublishOnly": "node scripts/readme.mjs --check"
43
44
  }
44
45
  }
package/src/mcp.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { createInterface } from "node:readline";
6
6
 
7
- const SERVER = { name: "geometryview", version: "0.1.2" };
7
+ const SERVER = { name: "geometryview", version: "0.4.0" };
8
8
  const LATEST_PROTOCOL = "2025-06-18";
9
9
 
10
10
  /// Serves `tools` until standard input closes. Each tool is { name, description, inputSchema,
package/src/native.mjs CHANGED
@@ -2,11 +2,11 @@
2
2
  // read its assembly tree and statistics, and render it to a PNG. The universal WebAssembly engine
3
3
  // will take this module's place on other machines.
4
4
 
5
- import { execFile } from "node:child_process";
5
+ import { execFile, spawn } from "node:child_process";
6
6
  import { createHash } from "node:crypto";
7
7
  import { existsSync } from "node:fs";
8
- import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
9
- import { homedir, release, tmpdir } from "node:os";
8
+ import { mkdir, open, readdir, readFile, rename, rm, stat, utimes } from "node:fs/promises";
9
+ import { cpus, homedir, release, tmpdir } from "node:os";
10
10
  import { basename, dirname, join, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { promisify } from "node:util";
@@ -63,6 +63,66 @@ function binaries() {
63
63
 
64
64
  const cacheDirectory = join(homedir(), "Library/Caches/geometryview");
65
65
 
66
+ // Imports and renders at once: a third of the cores, at most four, so a folder's worth of files
67
+ // leaves the machine usable (ADR 0016).
68
+ const slots = Math.max(1, Math.min(4, Math.floor(cpus().length / 3)));
69
+ let busy = 0;
70
+ const queued = [];
71
+
72
+ /// Runs `work` once a slot is free.
73
+ export async function withSlot(work) {
74
+ if (busy >= slots) await new Promise((resolve) => queued.push(resolve));
75
+ busy += 1;
76
+ try {
77
+ return await work();
78
+ } finally {
79
+ busy -= 1;
80
+ queued.shift()?.();
81
+ }
82
+ }
83
+
84
+ // Imports under way, by cache file, so the same file asked for twice is imported once.
85
+ const importing = new Map();
86
+
87
+ // The cache's budget: GEOMETRYVIEW_CACHE_BYTES, else 4 GB. Past it, the files used longest ago go,
88
+ // down to 80% of it, in a sweep run 30 s after the last import; a cache file's time is its last use.
89
+ // Partial files left by an import that died go once they are an hour old.
90
+ const kCacheBudget = Number(process.env.GEOMETRYVIEW_CACHE_BYTES) || 4 * 1024 ** 3;
91
+ let sweepTimer = null;
92
+
93
+ function scheduleSweep() {
94
+ clearTimeout(sweepTimer);
95
+ sweepTimer = setTimeout(() => sweepCache().catch(() => {}), 30_000);
96
+ sweepTimer.unref?.();
97
+ }
98
+
99
+ /// Keeps the cache within `budget` bytes, sparing files used in the last minute. Returns the bytes
100
+ /// it holds after.
101
+ export async function sweepCache(directory = cacheDirectory, budget = kCacheBudget, now = Date.now()) {
102
+ const names = await readdir(directory).catch(() => []);
103
+ const files = [];
104
+ for (const name of names) {
105
+ const path = join(directory, name);
106
+ const info = await stat(path).catch(() => null);
107
+ if (!info?.isFile()) continue;
108
+ if (name.endsWith(".partial")) {
109
+ if (now - info.mtimeMs > 3600_000) await rm(path, { force: true });
110
+ continue;
111
+ }
112
+ if (name.endsWith(".geoblob")) files.push({ path, bytes: info.size, used: info.mtimeMs });
113
+ }
114
+ let total = files.reduce((sum, file) => sum + file.bytes, 0);
115
+ if (total <= budget) return total;
116
+ files.sort((a, b) => a.used - b.used);
117
+ for (const file of files) {
118
+ if (total <= budget * 0.8) break;
119
+ if (now - file.used < 60_000) continue;
120
+ await rm(file.path, { force: true });
121
+ total -= file.bytes;
122
+ }
123
+ return total;
124
+ }
125
+
66
126
  /// Imports `path` unless an import of the same file, unchanged, is cached. Returns the cache file.
67
127
  // The cache file version gcli writes and reads, from Import/format/blob.h.
68
128
  export const kCacheVersion = 5;
@@ -96,23 +156,95 @@ export async function importModel(path) {
96
156
  .slice(0, 32);
97
157
  const blob = join(cacheDirectory, `${key}.geoblob`);
98
158
  if (existsSync(blob)) {
99
- // One in an older format is made again: gcli reads only its own.
100
- if (await isCurrent(blob)) return { blob, source, fromCache: true };
159
+ // One in an older format is made again: gcli reads only its own. Its time marks its use.
160
+ if (await isCurrent(blob)) {
161
+ const now = new Date();
162
+ await utimes(blob, now, now).catch(() => {});
163
+ return { blob, source, fromCache: true };
164
+ }
101
165
  await rm(blob, { force: true });
102
166
  }
103
- await mkdir(cacheDirectory, { recursive: true });
104
- const { gcli, worker } = binaries();
105
- const partial = `${blob}.${process.pid}.partial`;
106
- try {
107
- await run(gcli, ["import", source, partial, worker], { maxBuffer: 1 << 20 });
108
- } catch (error) {
109
- await rm(partial, { force: true });
110
- throw new Error(`Could not import ${basename(source)}: ${(error.stderr || error.message).trim()}`);
167
+ if (!importing.has(blob)) {
168
+ const work = withSlot(async () => {
169
+ await mkdir(cacheDirectory, { recursive: true });
170
+ const { gcli, worker } = binaries();
171
+ const partial = `${blob}.${process.pid}.partial`;
172
+ try {
173
+ await run(gcli, ["import", source, partial, worker], { maxBuffer: 1 << 20 });
174
+ } catch (error) {
175
+ await rm(partial, { force: true });
176
+ throw new Error(`Could not import ${basename(source)}: ${(error.stderr || error.message).trim()}`);
177
+ }
178
+ await rename(partial, blob);
179
+ scheduleSweep();
180
+ }).finally(() => importing.delete(blob));
181
+ importing.set(blob, work);
111
182
  }
112
- await rename(partial, blob);
183
+ await importing.get(blob);
113
184
  return { blob, source, fromCache: false };
114
185
  }
115
186
 
187
+ /// The STEP and STL files in `folder`, and with `recursive` in its folders too, hidden ones left
188
+ /// out: at most 20,000, 32 folders deep.
189
+ export async function listModels(folder, { recursive = false } = {}) {
190
+ const root = resolve(folder);
191
+ const info = await stat(root).catch(() => {
192
+ throw new Error(`There is no folder at ${root}.`);
193
+ });
194
+ if (!info.isDirectory()) throw new Error(`${root} is not a folder.`);
195
+ const found = [];
196
+ const walk = async (directory, depth) => {
197
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
198
+ for (const entry of entries) {
199
+ if (found.length >= 20000 || entry.name.startsWith(".")) continue;
200
+ const path = join(directory, entry.name);
201
+ if (entry.isDirectory()) {
202
+ if (recursive && depth < 32) await walk(path, depth + 1);
203
+ } else if (entry.isFile() && /\.(step|stp|stl)$/i.test(entry.name)) {
204
+ found.push(path);
205
+ }
206
+ }
207
+ };
208
+ await walk(root, 0);
209
+ return found;
210
+ }
211
+
212
+ /// A cache file's facts: its box in millimetres, parts, bodies, faces, triangles and the name
213
+ /// inside it (scene::Facts).
214
+ export async function readFacts(blob) {
215
+ const { gcli } = binaries();
216
+ const { stdout } = await run(gcli, ["facts", blob], { maxBuffer: 1 << 20 });
217
+ return JSON.parse(stdout);
218
+ }
219
+
220
+ /// Draws cache files on one contact sheet, as the gallery draws them: `tiles` are { blob, label,
221
+ /// sub }. Returns the PNG's bytes and, for each tile, its cell and any error.
222
+ export async function contactSheet(tiles, { cell = 192, columns, style = "illustration", colour = "parts",
223
+ sameScale = false, title } = {}) {
224
+ const { gcli, shaders } = binaries();
225
+ const png = join(tmpdir(), `geometry-view-sheet-${process.pid}-${Date.now()}.png`);
226
+ const args = ["sheet", shaders, png, "--cell", String(cell), "--style", String(style),
227
+ "--colour", String(colour)];
228
+ if (columns) args.push("--columns", String(columns));
229
+ if (sameScale) args.push("--same-scale");
230
+ if (title) args.push("--title", title);
231
+ try {
232
+ const stdout = await withSlot(() => new Promise((resolve, reject) => {
233
+ const child = spawn(gcli, args, { stdio: ["pipe", "pipe", "pipe"] });
234
+ let out = "", err = "";
235
+ child.stdout.on("data", (chunk) => { out += chunk; });
236
+ child.stderr.on("data", (chunk) => { err += chunk; });
237
+ child.on("error", reject);
238
+ child.on("close", (code) => (code === 0 ? resolve(out) : reject(new Error(err.trim() || `gcli sheet failed (${code})`))));
239
+ child.stdin.end(tiles.map((tile) => JSON.stringify(tile)).join("\n") + "\n");
240
+ }));
241
+ const placed = stdout.split("\n").filter(Boolean).map((line) => JSON.parse(line));
242
+ return { png: await readFile(png), tiles: placed };
243
+ } finally {
244
+ await rm(png, { force: true });
245
+ }
246
+ }
247
+
116
248
  /// The assembly tree: one node per entry, depth-first, with parent, kind, name and body range.
117
249
  export async function readTree(blob) {
118
250
  const { gcli } = binaries();
@@ -142,7 +274,7 @@ export async function render(blob, view) {
142
274
  (view.isolate ?? []).join(","),
143
275
  ];
144
276
  try {
145
- await run(gcli, args, { maxBuffer: 1 << 20 });
277
+ await withSlot(() => run(gcli, args, { maxBuffer: 1 << 20 }));
146
278
  return await readFile(png);
147
279
  } catch (error) {
148
280
  throw new Error((error.stderr || error.message).trim());
package/src/tools.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // The tools an AI app can call: open a model, read its parts, picture it, and show it to the user.
2
2
 
3
- import { basename } from "node:path";
4
- import { importModel, nativeAvailable, openInViewer, readStats, readTree, render,
5
- viewerApplication } from "./native.mjs";
3
+ import { basename, relative, resolve } from "node:path";
4
+ import { contactSheet, importModel, listModels, nativeAvailable, openInViewer, readFacts, readStats,
5
+ readTree, render, viewerApplication } from "./native.mjs";
6
6
  import { linkToPage, openInPage, pageRender, pageTree, showInPage, viewerServer } from "./page.mjs";
7
7
 
8
8
  export const instructions =
@@ -14,8 +14,10 @@ export const instructions =
14
14
  "studio). To study a part, pass names from get_structure as isolate, and use section to cut " +
15
15
  "through the model. open_in_viewer shows the model to the user, live: in the GeometryView app, " +
16
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.";
17
+ "the app's own browser (where: link). view_folder pictures a whole folder of models on one " +
18
+ "contact sheet with a numbered index, to survey a library or find a part by eye. Where this " +
19
+ "computer has no native engine, the viewer opens in the user's browser and the pictures come " +
20
+ "from it, so the user sees what you look at.";
19
21
 
20
22
  const STYLES = ["illustration", "metal", "clay", "blueprint", "technical", "studio"];
21
23
  const COLOURS = ["material", "parts", "mono", "file"];
@@ -27,6 +29,35 @@ const VIEWS = {
27
29
 
28
30
  const path = { type: "string", description: "Absolute path to a .step, .stp or .stl file." };
29
31
 
32
+ // How long view_folder waits for imports before drawing what it has; the rest keep importing, so
33
+ // asking again shows them.
34
+ const kFolderDeadline = 90_000;
35
+
36
+ const naturalOrder = new Intl.Collator("en", { numeric: true, sensitivity: "base" }).compare;
37
+
38
+ /// A box's sides in millimetres, as the gallery writes them: 40 × 30 × 26.
39
+ function sides(facts) {
40
+ if (!Array.isArray(facts?.min) || !Array.isArray(facts?.max)) return "";
41
+ return facts.max.map((high, axis) => {
42
+ const value = high - facts.min[axis];
43
+ return value >= 100 ? String(Math.round(value)) : String(Number(value.toPrecision(3)));
44
+ }).join(" × ");
45
+ }
46
+
47
+ function largestSide(facts) {
48
+ if (!Array.isArray(facts?.min) || !Array.isArray(facts?.max)) return Infinity;
49
+ return Math.max(...facts.max.map((high, axis) => high - facts.min[axis]));
50
+ }
51
+
52
+ /// Waits for `work`, or rejects with `kLate` once `deadline` passes; the work carries on.
53
+ const kLate = Symbol("late");
54
+ function byDeadline(work, deadline) {
55
+ work.catch(() => {});
56
+ return Promise.race([work, new Promise((_, reject) => {
57
+ setTimeout(() => reject(kLate), Math.max(0, deadline - Date.now())).unref?.();
58
+ })]);
59
+ }
60
+
30
61
  // ---- The tree as an outline ---------------------------------------------------------------------
31
62
 
32
63
  function annotate(nodes) {
@@ -215,6 +246,105 @@ export const tools = [
215
246
  ];
216
247
  },
217
248
  },
249
+ {
250
+ name: "view_folder",
251
+ description: "Pictures a folder of 3D CAD models (STEP and STL files) on one contact sheet, " +
252
+ "each part drawn over its name and number, for you to look at, with a numbered " +
253
+ "index of the files: their paths, sizes in millimetres, parts and triangles. Use it " +
254
+ "to survey a library, find a part by eye, or compare sizes (same_scale). Files are " +
255
+ "taken in name order, max_files of them; those still importing after 90 seconds " +
256
+ "are listed, and asking again shows them.",
257
+ inputSchema: {
258
+ type: "object",
259
+ properties: {
260
+ path: { type: "string", description: "Absolute path to a folder." },
261
+ recursive: { type: "boolean", default: false, description: "Include the folders inside." },
262
+ max_files: { type: "integer", minimum: 1, maximum: 144, default: 36 },
263
+ filter: { type: "string", description: "Only files whose path inside the folder contains this." },
264
+ sort: { type: "string", enum: ["name", "size"], default: "name",
265
+ description: "Order on the sheet: by path, or smallest to largest measured size." },
266
+ style: { type: "string", enum: STYLES, default: "illustration" },
267
+ colour: { type: "string", enum: COLOURS, default: "parts" },
268
+ same_scale: { type: "boolean", default: false,
269
+ description: "Draw every part at one scale, so sizes compare." },
270
+ cell: { type: "integer", minimum: 96, maximum: 384, default: 192,
271
+ description: "Each picture's size in points." },
272
+ columns: { type: "integer", minimum: 1, maximum: 12 },
273
+ },
274
+ required: ["path"],
275
+ },
276
+ async call(args) {
277
+ if (!nativeAvailable()) {
278
+ throw new Error("view_folder needs GeometryView's native engine, on a Mac with Apple silicon " +
279
+ "and macOS 26 or later. Use render_view to picture files one by one.");
280
+ }
281
+ const folder = resolve(args.path);
282
+ const max = Math.min(144, Math.max(1, args.max_files ?? 36));
283
+ const filter = (args.filter ?? "").toLowerCase();
284
+ const found = (await listModels(folder, { recursive: args.recursive ?? false }))
285
+ .map((file) => ({ file, path: relative(folder, file) }))
286
+ .filter(({ path: inside }) => !filter || inside.toLowerCase().includes(filter))
287
+ .sort((a, b) => naturalOrder(a.path, b.path));
288
+ const where = `${folder}${args.recursive ? " and its folders" : ""}`;
289
+ if (found.length === 0) {
290
+ return [{ type: "text", text: `No STEP or STL files${filter ? ` matching "${args.filter}"` : ""} in ${where}.` }];
291
+ }
292
+ const chosen = found.slice(0, max);
293
+ // Imported a few at a time, by the deadline.
294
+ const deadline = Date.now() + kFolderDeadline;
295
+ const done = await Promise.all(chosen.map(async (entry) => {
296
+ try {
297
+ const read = (async () => {
298
+ const { blob } = await importModel(entry.file);
299
+ return { blob, facts: await readFacts(blob) };
300
+ })();
301
+ return { ...entry, ...(await byDeadline(read, deadline)) };
302
+ } catch (error) {
303
+ return error === kLate ? { ...entry, late: true } : { ...entry, error: String(error.message ?? error) };
304
+ }
305
+ }));
306
+ const drawn = done.filter((entry) => entry.blob);
307
+ if (args.sort === "size") drawn.sort((a, b) => largestSide(a.facts) - largestSide(b.facts));
308
+ const lines = [];
309
+ let png = null;
310
+ if (drawn.length > 0) {
311
+ const tiles = drawn.map((entry, index) => ({
312
+ blob: entry.blob,
313
+ label: basename(entry.path).replace(/\.(step|stp|stl)$/i, ""),
314
+ sub: [`#${index + 1}`, sides(entry.facts)].filter(Boolean).join(" · "),
315
+ }));
316
+ const sheet = await contactSheet(tiles, {
317
+ cell: args.cell ?? 192, columns: args.columns, style: args.style ?? "illustration",
318
+ colour: args.colour ?? "parts", sameScale: args.same_scale ?? false, title: basename(folder),
319
+ });
320
+ png = sheet.png;
321
+ drawn.forEach((entry, index) => {
322
+ const f = entry.facts ?? {};
323
+ const failed = sheet.tiles[index]?.error;
324
+ const parts = f.parts ? `${f.parts} part${f.parts === 1 ? "" : "s"}` : null;
325
+ const size = sides(f) ? `${sides(f)} mm` : null;
326
+ const triangles = f.triangles ? `${f.triangles.toLocaleString("en-US")} triangles` : null;
327
+ const name = f.name && f.name !== basename(entry.path).replace(/\.[^.]+$/, "") ? `"${f.name}"` : null;
328
+ lines.push(`${index + 1}. ${entry.path}: ${[size, parts, triangles, name, failed && `not drawn: ${failed}`]
329
+ .filter(Boolean).join(", ")}`);
330
+ });
331
+ }
332
+ const late = done.filter((entry) => entry.late).map((entry) => entry.path);
333
+ const failed = done.filter((entry) => entry.error);
334
+ const header = `${drawn.length} of ${found.length} STEP and STL files in ${where}` +
335
+ (filter ? ` matching "${args.filter}"` : "") +
336
+ `, ${args.style ?? "illustration"} style, ${args.sort === "size" ? "smallest to largest" : "by name"}` +
337
+ (args.same_scale ? ", at one scale" : "") + ". Numbered in reading order, as on the sheet.";
338
+ const text = [header, "", ...lines];
339
+ if (found.length > chosen.length) text.push("", `${found.length - chosen.length} more not shown: raise max_files (up to 144) or use filter.`);
340
+ if (late.length) text.push("", `Still importing, not drawn yet (ask again to see them): ${late.join(", ")}.`);
341
+ if (failed.length) text.push("", "Could not be read:", ...failed.map((entry) => `- ${entry.path}: ${entry.error}`));
342
+ const content = [];
343
+ if (png) content.push({ type: "image", data: png.toString("base64"), mimeType: "image/png" });
344
+ content.push({ type: "text", text: text.join("\n") });
345
+ return content;
346
+ },
347
+ },
218
348
  {
219
349
  name: "open_in_viewer",
220
350
  description: "Shows a STEP or STL model to the user, live, where they can turn it, select " +
package/web/NOTICES.md CHANGED
@@ -3,6 +3,9 @@
3
3
  GeometryView is MIT licensed (see [LICENSE](LICENSE)). It builds on the work below, each under its
4
4
  own license. Versions are pinned in [ThirdParty/manifest.toml](ThirdParty/manifest.toml).
5
5
 
6
+ The showcase models ([`Tools/showcase`](Tools/showcase), and the folder and sample files made from
7
+ them) and the README's pictures are GeometryView's own, under the same MIT license.
8
+
6
9
  ## Shipped in the app, the browser viewer and the launcher
7
10
 
8
11
  | Project | License | How it is used |
@@ -47,3 +50,4 @@ SOFTWARE.
47
50
  |---|---|
48
51
  | [doctest](https://github.com/doctest/doctest) 2.4.12 | MIT; vendored with its [license](ThirdParty/doctest/LICENSE.txt) |
49
52
  | [Dawn](https://dawn.googlesource.com/dawn) (native) | BSD-3-Clause; the WebGPU test harness on the Mac |
53
+ | [build123d](https://github.com/gumyr/build123d) 0.13 and [OCP](https://github.com/CadQuery/OCP) 8.0.1 | Apache-2.0; they write the showcase models' STEP files |