fluidcad 0.0.34 → 0.0.35

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 (174) hide show
  1. package/README.md +69 -0
  2. package/bin/commands/login.js +120 -0
  3. package/bin/commands/pack.js +49 -0
  4. package/bin/commands/publish.js +136 -0
  5. package/bin/fluidcad.js +6 -0
  6. package/bin/lib/api-client.js +40 -0
  7. package/bin/lib/browser.js +16 -0
  8. package/bin/lib/config.js +39 -0
  9. package/bin/lib/model-config.js +38 -0
  10. package/bin/lib/workspace.js +57 -0
  11. package/lib/dist/common/shape-factory.d.ts +2 -1
  12. package/lib/dist/common/shape-factory.js +4 -0
  13. package/lib/dist/common/transformable-primitive.d.ts +6 -5
  14. package/lib/dist/common/transformable-primitive.js +8 -7
  15. package/lib/dist/common/vertex.js +0 -1
  16. package/lib/dist/core/2d/aline.d.ts +4 -3
  17. package/lib/dist/core/2d/aline.js +3 -2
  18. package/lib/dist/core/2d/arc.d.ts +3 -2
  19. package/lib/dist/core/2d/arc.js +4 -3
  20. package/lib/dist/core/2d/bezier.d.ts +8 -6
  21. package/lib/dist/core/2d/circle.d.ts +4 -3
  22. package/lib/dist/core/2d/circle.js +3 -2
  23. package/lib/dist/core/2d/ellipse.d.ts +5 -4
  24. package/lib/dist/core/2d/ellipse.js +5 -4
  25. package/lib/dist/core/2d/hline.d.ts +4 -3
  26. package/lib/dist/core/2d/hline.js +5 -3
  27. package/lib/dist/core/2d/line.js +1 -0
  28. package/lib/dist/core/2d/offset.d.ts +3 -2
  29. package/lib/dist/core/2d/offset.js +6 -5
  30. package/lib/dist/core/2d/polygon.d.ts +5 -4
  31. package/lib/dist/core/2d/polygon.js +10 -9
  32. package/lib/dist/core/2d/rect.d.ts +4 -3
  33. package/lib/dist/core/2d/rect.js +10 -9
  34. package/lib/dist/core/2d/slot.d.ts +14 -6
  35. package/lib/dist/core/2d/slot.js +19 -8
  36. package/lib/dist/core/2d/vline.d.ts +4 -3
  37. package/lib/dist/core/2d/vline.js +5 -3
  38. package/lib/dist/core/chamfer.d.ts +5 -4
  39. package/lib/dist/core/chamfer.js +7 -6
  40. package/lib/dist/core/color.d.ts +3 -2
  41. package/lib/dist/core/color.js +2 -1
  42. package/lib/dist/core/cut.d.ts +4 -3
  43. package/lib/dist/core/cut.js +5 -4
  44. package/lib/dist/core/cylinder.d.ts +2 -1
  45. package/lib/dist/core/cylinder.js +2 -1
  46. package/lib/dist/core/draft.d.ts +3 -2
  47. package/lib/dist/core/draft.js +3 -2
  48. package/lib/dist/core/extrude.d.ts +4 -3
  49. package/lib/dist/core/extrude.js +5 -4
  50. package/lib/dist/core/fillet.d.ts +5 -4
  51. package/lib/dist/core/fillet.js +6 -5
  52. package/lib/dist/core/index.d.ts +1 -0
  53. package/lib/dist/core/index.js +1 -0
  54. package/lib/dist/core/interfaces.d.ts +25 -24
  55. package/lib/dist/core/param.d.ts +74 -0
  56. package/lib/dist/core/param.js +147 -0
  57. package/lib/dist/core/repeat.d.ts +2 -1
  58. package/lib/dist/core/repeat.js +10 -8
  59. package/lib/dist/core/revolve.d.ts +2 -1
  60. package/lib/dist/core/revolve.js +3 -2
  61. package/lib/dist/core/rib.d.ts +3 -2
  62. package/lib/dist/core/rib.js +6 -2
  63. package/lib/dist/core/rotate.d.ts +5 -4
  64. package/lib/dist/core/rotate.js +4 -3
  65. package/lib/dist/core/shell.d.ts +3 -2
  66. package/lib/dist/core/shell.js +3 -2
  67. package/lib/dist/core/sphere.d.ts +3 -2
  68. package/lib/dist/core/sphere.js +2 -1
  69. package/lib/dist/core/translate.d.ts +7 -6
  70. package/lib/dist/core/translate.js +6 -5
  71. package/lib/dist/features/2d/arc.js +5 -5
  72. package/lib/dist/features/2d/bezier.js +16 -16
  73. package/lib/dist/features/2d/circle.js +4 -0
  74. package/lib/dist/features/2d/ellipse.js +4 -0
  75. package/lib/dist/features/2d/hline.d.ts +3 -0
  76. package/lib/dist/features/2d/hline.js +9 -2
  77. package/lib/dist/features/2d/line.d.ts +3 -0
  78. package/lib/dist/features/2d/line.js +11 -3
  79. package/lib/dist/features/2d/sketch.js +5 -1
  80. package/lib/dist/features/2d/slot.d.ts +5 -0
  81. package/lib/dist/features/2d/slot.js +52 -7
  82. package/lib/dist/features/2d/tarc-to-point-tangent.js +3 -0
  83. package/lib/dist/features/2d/tarc-to-point.js +3 -0
  84. package/lib/dist/features/2d/tarc-with-tangent.js +3 -0
  85. package/lib/dist/features/2d/tarc.js +3 -0
  86. package/lib/dist/features/2d/vline.d.ts +3 -0
  87. package/lib/dist/features/2d/vline.js +9 -2
  88. package/lib/dist/features/copy-circular.d.ts +4 -3
  89. package/lib/dist/features/copy-circular.js +16 -9
  90. package/lib/dist/features/copy-circular2d.js +16 -9
  91. package/lib/dist/features/copy-linear.d.ts +4 -3
  92. package/lib/dist/features/copy-linear.js +18 -12
  93. package/lib/dist/features/copy-linear2d.js +18 -12
  94. package/lib/dist/features/extrude-base.d.ts +4 -3
  95. package/lib/dist/features/extrude-base.js +10 -3
  96. package/lib/dist/features/mirror-shape2d.js +2 -2
  97. package/lib/dist/features/repeat-base.d.ts +13 -0
  98. package/lib/dist/features/repeat-base.js +21 -0
  99. package/lib/dist/features/repeat-circular.d.ts +6 -5
  100. package/lib/dist/features/repeat-circular.js +3 -6
  101. package/lib/dist/features/repeat-linear.d.ts +7 -7
  102. package/lib/dist/features/repeat-linear.js +3 -6
  103. package/lib/dist/index.d.ts +5 -0
  104. package/lib/dist/index.js +8 -1
  105. package/lib/dist/io/file-import.d.ts +7 -0
  106. package/lib/dist/io/file-import.js +30 -10
  107. package/lib/dist/math/lazy-matrix.d.ts +5 -0
  108. package/lib/dist/math/lazy-matrix.js +78 -10
  109. package/lib/dist/oc/boolean-ops.d.ts +2 -2
  110. package/lib/dist/param-registry.d.ts +34 -0
  111. package/lib/dist/param-registry.js +60 -0
  112. package/lib/dist/rendering/mesh-builder.js +2 -1
  113. package/lib/dist/tests/features/copy-circular.test.js +1 -1
  114. package/lib/dist/tests/features/copy-linear.test.js +10 -10
  115. package/lib/dist/tests/features/repeat-user-repro-cache.test.d.ts +1 -0
  116. package/lib/dist/tests/features/repeat-user-repro-cache.test.js +97 -0
  117. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  118. package/llm-docs/api/bezier.md +10 -11
  119. package/llm-docs/api/index.json +1 -1
  120. package/llm-docs/api/types/arc-points.md +2 -2
  121. package/llm-docs/api/types/cut.md +10 -10
  122. package/llm-docs/api/types/extrude.md +10 -10
  123. package/llm-docs/api/types/loft.md +6 -6
  124. package/llm-docs/api/types/revolve.md +6 -6
  125. package/llm-docs/api/types/rib.md +2 -2
  126. package/llm-docs/api/types/slot.md +2 -2
  127. package/llm-docs/api/types/sweep.md +10 -10
  128. package/llm-docs/api/types/transformable.md +14 -14
  129. package/llm-docs/index.json +12 -12
  130. package/mcp/dist/client.d.ts +1 -0
  131. package/mcp/dist/client.js +8 -1
  132. package/mcp/dist/server.js +14 -1
  133. package/mcp/dist/tools/engine.d.ts +16 -0
  134. package/mcp/dist/tools/engine.js +45 -0
  135. package/package.json +9 -3
  136. package/server/dist/api.d.ts +37 -0
  137. package/server/dist/api.js +44 -0
  138. package/server/dist/code-editor.d.ts +64 -0
  139. package/server/dist/code-editor.js +520 -2
  140. package/server/dist/fluidcad-server.d.ts +68 -1
  141. package/server/dist/fluidcad-server.js +224 -88
  142. package/server/dist/host/blocked-imports.d.ts +8 -0
  143. package/server/dist/host/blocked-imports.js +30 -0
  144. package/server/dist/{vite-manager.d.ts → host/local-scene-host.d.ts} +3 -1
  145. package/server/dist/{vite-manager.js → host/local-scene-host.js} +6 -26
  146. package/server/dist/host/scene-host.d.ts +19 -0
  147. package/server/dist/host/scene-host.js +1 -0
  148. package/server/dist/index.js +24 -117
  149. package/server/dist/model-package/capture-params.d.ts +19 -0
  150. package/server/dist/model-package/capture-params.js +42 -0
  151. package/server/dist/model-package/pack.d.ts +23 -0
  152. package/server/dist/model-package/pack.js +229 -0
  153. package/server/dist/model-package/types.d.ts +78 -0
  154. package/server/dist/model-package/types.js +17 -0
  155. package/server/dist/routes/hit-test.d.ts +3 -0
  156. package/server/dist/routes/hit-test.js +17 -0
  157. package/server/dist/routes/pack.d.ts +10 -0
  158. package/server/dist/routes/pack.js +47 -0
  159. package/server/dist/routes/params.d.ts +3 -0
  160. package/server/dist/routes/params.js +75 -0
  161. package/server/dist/routes/sketch-edits.d.ts +3 -0
  162. package/server/dist/routes/sketch-edits.js +542 -0
  163. package/server/dist/routes/timeline.d.ts +3 -0
  164. package/server/dist/routes/timeline.js +49 -0
  165. package/server/dist/server-core.d.ts +53 -0
  166. package/server/dist/server-core.js +147 -0
  167. package/server/dist/ws-protocol.d.ts +101 -2
  168. package/ui/dist/assets/index-CDJmUpFI.css +2 -0
  169. package/ui/dist/assets/index-MRqwG9Vh.js +5417 -0
  170. package/ui/dist/index.html +2 -2
  171. package/server/dist/routes/actions.d.ts +0 -3
  172. package/server/dist/routes/actions.js +0 -309
  173. package/ui/dist/assets/index-BdqrMDRu.js +0 -4946
  174. package/ui/dist/assets/index-DR7c2Qk9.css +0 -2
package/README.md CHANGED
@@ -115,6 +115,11 @@ Import and export STEP files with full color support. Bring in existing CAD mode
115
115
 
116
116
  FluidCAD ships official extensions for **VS Code** and **Neovim**, but works with any editor -- just point the CLI at your project.
117
117
 
118
+ ### LLM / AI Agent Integration (MCP)
119
+
120
+ FluidCAD ships an [MCP](https://modelcontextprotocol.io) server so AI agents (Claude Code, Claude Desktop, Cursor, opencode, etc.) can drive a running workspace -- take screenshots, inspect geometry, edit `.fluid.js` files, and look up the API by symbol. See [Set Up the MCP Server](#3-optional-set-up-the-mcp-server) below.
121
+
122
+
118
123
  ---
119
124
 
120
125
  ## Tutorials
@@ -234,6 +239,70 @@ This starts a local server and opens a 3D viewport in your browser. Edit your `.
234
239
 
235
240
  </details>
236
241
 
242
+ ### 3. (Optional) Set Up the MCP Server
243
+
244
+ FluidCAD bundles an [MCP](https://modelcontextprotocol.io) server so LLM agents can drive your workspace -- screenshots, geometry inspection, source edits, API lookup. It's included in the `fluidcad` package; no separate install needed.
245
+
246
+ Wire it into your MCP client:
247
+
248
+ <details>
249
+ <summary><strong>Claude Code</strong></summary>
250
+
251
+ Register at user scope so it's available in every project:
252
+
253
+ ```bash
254
+ claude mcp add --scope user FluidCAD -- npx -y fluidcad mcp
255
+ ```
256
+
257
+ </details>
258
+
259
+ <details>
260
+ <summary><strong>Claude Desktop / Cursor</strong></summary>
261
+
262
+ Add to `claude_desktop_config.json` or `~/.cursor/mcp.json`:
263
+
264
+ ```json
265
+ {
266
+ "mcpServers": {
267
+ "FluidCAD": {
268
+ "command": "npx",
269
+ "args": ["-y", "fluidcad", "mcp"]
270
+ }
271
+ }
272
+ }
273
+ ```
274
+
275
+ </details>
276
+
277
+ <details>
278
+ <summary><strong>opencode</strong></summary>
279
+
280
+ Run `opencode mcp add` and answer the prompts, or add to `~/.config/opencode/opencode.json`:
281
+
282
+ ```json
283
+ {
284
+ "$schema": "https://opencode.ai/config.json",
285
+ "mcp": {
286
+ "FluidCAD": {
287
+ "type": "local",
288
+ "command": ["npx", "-y", "fluidcad", "mcp"],
289
+ "enabled": true
290
+ }
291
+ }
292
+ }
293
+ ```
294
+
295
+ </details>
296
+
297
+ Then install the companion skill so agents follow the FluidCAD workflow:
298
+
299
+ ```bash
300
+ npx skills add Fluid-CAD/FluidCAD
301
+ ```
302
+
303
+ See the [MCP README](mcp/README.md) for the full tool surface, transport details, and local-testing guide.
304
+
305
+
237
306
  ---
238
307
 
239
308
  ## License
@@ -0,0 +1,120 @@
1
+ import http from 'http';
2
+ import { randomBytes, createHash } from 'crypto';
3
+ import { getHubUrl, writeCredentials } from '../lib/config.js';
4
+ import { HubClient } from '../lib/api-client.js';
5
+ import { openBrowser } from '../lib/browser.js';
6
+
7
+ const CLIENT_ID = 'fluidcad-cli';
8
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
9
+
10
+ function base64url(buf) {
11
+ return buf.toString('base64url');
12
+ }
13
+
14
+ /**
15
+ * Loopback + PKCE login (RFC 8252): spin up a one-shot 127.0.0.1 server, send
16
+ * the browser to the hub's /cli/authorize, receive the code on /callback,
17
+ * exchange it (with the PKCE verifier) for a token, and save it.
18
+ */
19
+ function runLogin(opts) {
20
+ return new Promise((resolve, reject) => {
21
+ const hubUrl = getHubUrl(opts.hub);
22
+ const verifier = base64url(randomBytes(32));
23
+ const challenge = base64url(createHash('sha256').update(verifier).digest());
24
+ const state = base64url(randomBytes(16));
25
+ let redirectUri;
26
+ let settled = false;
27
+
28
+ const finish = (fn, arg) => {
29
+ if (settled) {
30
+ return;
31
+ }
32
+ settled = true;
33
+ clearTimeout(timer);
34
+ server.close();
35
+ fn(arg);
36
+ };
37
+
38
+ const server = http.createServer((req, res) => {
39
+ const url = new URL(req.url, 'http://127.0.0.1');
40
+ if (url.pathname !== '/callback') {
41
+ res.writeHead(404);
42
+ res.end();
43
+ return;
44
+ }
45
+ res.writeHead(200, { 'content-type': 'text/html' });
46
+ res.end(
47
+ '<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;text-align:center;padding-top:3rem">' +
48
+ '<h2>FluidCAD CLI</h2><p>You can close this tab and return to your terminal.</p>',
49
+ );
50
+ const error = url.searchParams.get('error');
51
+ const code = url.searchParams.get('code');
52
+ const returnedState = url.searchParams.get('state');
53
+
54
+ (async () => {
55
+ if (error) {
56
+ throw new Error(`authorization ${error}`);
57
+ }
58
+ if (returnedState !== state) {
59
+ throw new Error('state mismatch — aborting (possible CSRF)');
60
+ }
61
+ if (!code) {
62
+ throw new Error('no authorization code in callback');
63
+ }
64
+ const { status, body } = await new HubClient(hubUrl).postJson('/api/cli/token', {
65
+ client_id: CLIENT_ID,
66
+ code,
67
+ code_verifier: verifier,
68
+ redirect_uri: redirectUri,
69
+ });
70
+ if (status !== 200 || !body.access_token) {
71
+ throw new Error(`token exchange failed: ${body.error_description || body.error || `HTTP ${status}`}`);
72
+ }
73
+ writeCredentials({ token: body.access_token, email: body.user?.email ?? null, hubUrl });
74
+ return body.user?.email ?? null;
75
+ })().then(
76
+ (email) => finish(resolve, email),
77
+ (err) => finish(reject, err),
78
+ );
79
+ });
80
+
81
+ const timer = setTimeout(
82
+ () => finish(reject, new Error('login timed out — no response within 5 minutes')),
83
+ LOGIN_TIMEOUT_MS,
84
+ );
85
+
86
+ server.on('error', (err) => finish(reject, err));
87
+ server.listen(0, '127.0.0.1', () => {
88
+ const { port } = server.address();
89
+ redirectUri = `http://127.0.0.1:${port}/callback`;
90
+ const authorizeUrl =
91
+ `${hubUrl}/cli/authorize?` +
92
+ new URLSearchParams({
93
+ response_type: 'code',
94
+ client_id: CLIENT_ID,
95
+ redirect_uri: redirectUri,
96
+ code_challenge: challenge,
97
+ code_challenge_method: 'S256',
98
+ state,
99
+ });
100
+ console.log('Opening your browser to authorize the FluidCAD CLI…');
101
+ console.log(`\n ${authorizeUrl}\n`);
102
+ openBrowser(authorizeUrl);
103
+ });
104
+ });
105
+ }
106
+
107
+ export function registerLoginCommand(program) {
108
+ program
109
+ .command('login')
110
+ .description('Authenticate this machine with the FluidCAD hub')
111
+ .option('--hub <url>', 'Hub base URL (default: $FLUIDCAD_HUB_URL or https://hub.fluidcad.io)')
112
+ .action((opts) => {
113
+ runLogin(opts)
114
+ .then((email) => console.log(`Logged in as ${email ?? 'your account'}`))
115
+ .catch((err) => {
116
+ console.error(err?.message ?? err);
117
+ process.exit(1);
118
+ });
119
+ });
120
+ }
@@ -0,0 +1,49 @@
1
+ import { writeFileSync } from 'fs';
2
+ import { resolve, basename } from 'path';
3
+ import { findEntry, readPackageVersion } from '../lib/workspace.js';
4
+
5
+ async function runPack(opts) {
6
+ // The packer lives in server/ because esbuild + jszip belong at that layer;
7
+ // import it lazily so the rest of the CLI doesn't pay the cost.
8
+ const { packModel } = await import('../../server/dist/model-package/pack.js');
9
+
10
+ const workspace = resolve(opts.workspace ?? process.cwd());
11
+ const entry = findEntry(workspace, opts.entry);
12
+ const fluidcadVersion = readPackageVersion();
13
+
14
+ const { manifest, zip } = await packModel({
15
+ entryPath: entry,
16
+ workspacePath: workspace,
17
+ fluidcadVersion,
18
+ name: opts.name,
19
+ description: opts.description,
20
+ });
21
+
22
+ const outPath = opts.out
23
+ ? resolve(opts.out)
24
+ : resolve(workspace, basename(entry).replace(/\.fluid\.js$/i, '') + '.fluidpkg');
25
+ writeFileSync(outPath, zip);
26
+ const fileCount = manifest.files?.length ?? 0;
27
+ const assetCount = manifest.assets.length;
28
+ console.log(
29
+ `Wrote ${outPath} (${zip.length} bytes, ${fileCount} file${fileCount === 1 ? '' : 's'}, ` +
30
+ `${assetCount} asset${assetCount === 1 ? '' : 's'})`,
31
+ );
32
+ }
33
+
34
+ export function registerPackCommand(program) {
35
+ program
36
+ .command('pack')
37
+ .description('Package a .fluid.js model into a shareable .fluidpkg archive')
38
+ .option('-w, --workspace <path>', 'Workspace directory (defaults to cwd)')
39
+ .option('-e, --entry <file>', 'Entry .fluid.js file (auto-detected if only one exists)')
40
+ .option('-o, --out <path>', 'Output path (defaults to <entry-basename>.fluidpkg in the workspace)')
41
+ .option('-n, --name <name>', 'Package name (defaults to the entry file basename)')
42
+ .option('-d, --description <text>', 'Optional human description')
43
+ .action((opts) => {
44
+ runPack(opts).catch((err) => {
45
+ console.error(err?.message ?? err);
46
+ process.exit(1);
47
+ });
48
+ });
49
+ }
@@ -0,0 +1,136 @@
1
+ import { resolve } from 'path';
2
+ import { getHubUrl, readCredentials } from '../lib/config.js';
3
+ import { HubClient } from '../lib/api-client.js';
4
+ import { findEntry, readPackageVersion, readWorkspacePackage } from '../lib/workspace.js';
5
+ import { readModelId, writeModelId } from '../lib/model-config.js';
6
+ import { openBrowser } from '../lib/browser.js';
7
+
8
+ async function runPublish(opts) {
9
+ const creds = readCredentials();
10
+ if (!creds) {
11
+ throw new Error('Not logged in. Run `fluidcad login` first.');
12
+ }
13
+ const hubUrl = getHubUrl(opts.hub || creds.hubUrl);
14
+
15
+ const workspace = resolve(opts.workspace ?? process.cwd());
16
+ const entry = findEntry(workspace, opts.entry);
17
+
18
+ // Prefills from the model's own package.json + the stable identity from
19
+ // fluidcad.json (null on the first publish → the hub mints one).
20
+ const pkg = readWorkspacePackage(workspace);
21
+ const name = opts.name ?? pkg.name;
22
+ const description = opts.description ?? pkg.description;
23
+ const modelId = readModelId(workspace);
24
+
25
+ // Render once to capture the full param schema for the manifest. This also
26
+ // acts as a build gate — a compile/runtime error fails the publish here,
27
+ // before anything is uploaded.
28
+ //
29
+ // Dynamic import is deliberate (and necessary): this pulls in the whole
30
+ // engine — Vite + OC wasm, ~40MB / ~110ms. bin/fluidcad.js eagerly loads
31
+ // every command module at startup, so a top-level import here would make
32
+ // `init`, `serve`, `login`, `--help` etc. pay that cost too. Loading it only
33
+ // when `publish` actually runs is the justified exception to "no inline
34
+ // imports".
35
+ console.log('Building model…');
36
+ const { captureParamDefinitions } = await import(
37
+ '../../server/dist/model-package/capture-params.js'
38
+ );
39
+ let paramDefinitions;
40
+ try {
41
+ paramDefinitions = await captureParamDefinitions(entry, workspace);
42
+ } catch (err) {
43
+ throw new Error(`Model failed to build — fix the error and retry:\n${err?.message ?? err}`);
44
+ }
45
+
46
+ // Pack the whole workspace (Pack v2), embedding the captured params. Lazy
47
+ // import for the same reason (keeps esbuild/jszip off the startup path of
48
+ // non-publish commands), consistent with how `pack` loads it.
49
+ const { packModel } = await import('../../server/dist/model-package/pack.js');
50
+ const { manifest, zip } = await packModel({
51
+ entryPath: entry,
52
+ workspacePath: workspace,
53
+ fluidcadVersion: readPackageVersion(),
54
+ name,
55
+ description,
56
+ paramDefinitions,
57
+ });
58
+
59
+ // Show exactly what's going up so stray files/secrets get caught before they
60
+ // leave the machine (the .gitignore guard plus an always-exclude list).
61
+ const files = manifest.files ?? [];
62
+ console.log(`\nUploading ${files.length} file${files.length === 1 ? '' : 's'} (${(zip.length / 1024).toFixed(1)} KB):`);
63
+ for (const f of files) {
64
+ console.log(` ${f}`);
65
+ }
66
+
67
+ const form = new FormData();
68
+ form.append('fluidpkg', new Blob([zip], { type: 'application/zip' }), 'model.fluidpkg');
69
+ if (modelId) {
70
+ form.append('modelId', modelId);
71
+ }
72
+ if (name) {
73
+ form.append('name', name);
74
+ }
75
+ if (opts.visibility) {
76
+ form.append('visibility', opts.visibility);
77
+ }
78
+
79
+ const { status, body } = await new HubClient(hubUrl, creds.token).postForm('/api/publish', form);
80
+ if (status === 401) {
81
+ throw new Error('Your session has expired. Run `fluidcad login` again.');
82
+ }
83
+ if (status === 403) {
84
+ throw new Error(body.error || 'That model belongs to another account.');
85
+ }
86
+ if (status === 422) {
87
+ throw new Error(body.error || 'That FluidCAD version is not hosted yet.');
88
+ }
89
+ if (status !== 200 && status !== 201) {
90
+ throw new Error(body.error || `Publish failed (HTTP ${status})`);
91
+ }
92
+
93
+ // First publish for this workspace → persist the hub-minted id so the next
94
+ // publish lands as a new version of the same model.
95
+ if (!modelId && body.modelId) {
96
+ writeModelId(workspace, body.modelId);
97
+ }
98
+
99
+ console.log('');
100
+ if (body.isNewVersion) {
101
+ console.log(`A new version (v${body.version}) will be published — add details in your browser.`);
102
+ } else {
103
+ console.log('Created model — finish setup in your browser.');
104
+ }
105
+
106
+ if (body.formUrl) {
107
+ const formUrl = body.formUrl.startsWith('http') ? body.formUrl : hubUrl + body.formUrl;
108
+ console.log(`\n ${formUrl}\n`);
109
+ await openBrowser(formUrl);
110
+ } else if (body.shareUrl) {
111
+ // Fallback for a pre-v2 hub that deployed synchronously and has no form.
112
+ console.log(`\n ${body.shareUrl}\n`);
113
+ }
114
+ }
115
+
116
+ export function registerPublishCommand(program) {
117
+ program
118
+ .command('publish')
119
+ .description('Pack the current model and publish it to the FluidCAD hub')
120
+ .option('-w, --workspace <path>', 'Workspace directory (defaults to cwd)')
121
+ .option('-e, --entry <file>', 'Entry .fluid.js file (auto-detected if only one exists)')
122
+ .option('-n, --name <name>', 'Model name (defaults to the package name)')
123
+ .option('-d, --description <text>', 'Optional human description')
124
+ .option('--visibility <visibility>', 'public | unlisted | private (default: unlisted)')
125
+ .option('--hub <url>', 'Hub base URL (default: the hub you logged into)')
126
+ .action((opts) => {
127
+ runPublish(opts)
128
+ // The render spins up engine + Vite handles; exit explicitly so a
129
+ // successful publish doesn't hang waiting for the loop to drain.
130
+ .then(() => process.exit(0))
131
+ .catch((err) => {
132
+ console.error(err?.message ?? err);
133
+ process.exit(1);
134
+ });
135
+ });
136
+ }
package/bin/fluidcad.js CHANGED
@@ -7,6 +7,9 @@ import { fileURLToPath } from 'url';
7
7
  import { registerInitCommand } from './commands/init.js';
8
8
  import { registerServeCommand } from './commands/serve.js';
9
9
  import { registerMcpCommand } from './commands/mcp.js';
10
+ import { registerPackCommand } from './commands/pack.js';
11
+ import { registerLoginCommand } from './commands/login.js';
12
+ import { registerPublishCommand } from './commands/publish.js';
10
13
 
11
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
15
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
@@ -19,5 +22,8 @@ const program = new Command()
19
22
  registerInitCommand(program);
20
23
  registerServeCommand(program);
21
24
  registerMcpCommand(program);
25
+ registerPackCommand(program);
26
+ registerLoginCommand(program);
27
+ registerPublishCommand(program);
22
28
 
23
29
  program.parseAsync(process.argv);
@@ -0,0 +1,40 @@
1
+ import { getHubUrl } from './config.js';
2
+
3
+ /**
4
+ * Tiny client for the hub API. The CLI commands are pure API clients — open a
5
+ * browser, store a token, POST a package — so this just wraps `fetch` with the
6
+ * bearer header and a uniform `{ status, body }` result.
7
+ */
8
+ export class HubClient {
9
+ constructor(hubUrl, token) {
10
+ this.base = getHubUrl(hubUrl);
11
+ this.token = token;
12
+ }
13
+
14
+ #authHeaders(extra = {}) {
15
+ return { ...(this.token ? { authorization: `Bearer ${this.token}` } : {}), ...extra };
16
+ }
17
+
18
+ async #result(res) {
19
+ const body = await res.json().catch(() => ({}));
20
+ return { status: res.status, body };
21
+ }
22
+
23
+ async postJson(path, body) {
24
+ const res = await fetch(this.base + path, {
25
+ method: 'POST',
26
+ headers: this.#authHeaders({ 'content-type': 'application/json' }),
27
+ body: JSON.stringify(body),
28
+ });
29
+ return this.#result(res);
30
+ }
31
+
32
+ async postForm(path, form) {
33
+ const res = await fetch(this.base + path, {
34
+ method: 'POST',
35
+ headers: this.#authHeaders(),
36
+ body: form,
37
+ });
38
+ return this.#result(res);
39
+ }
40
+ }
@@ -0,0 +1,16 @@
1
+ import open from 'open';
2
+
3
+ /**
4
+ * Best-effort browser open, shared by `login` and `publish`. The caller always
5
+ * prints the URL too, so a failure here (headless box, SSH, no DISPLAY) is
6
+ * non-fatal — the user can open it manually.
7
+ */
8
+ export async function openBrowser(url) {
9
+ try {
10
+ const child = await open(url);
11
+ // Detach so a one-shot CLI process isn't held open by the browser handle.
12
+ child.unref?.();
13
+ } catch {
14
+ /* ignore — the URL is printed regardless */
15
+ }
16
+ }
@@ -0,0 +1,39 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from 'fs';
4
+
5
+ // Where the CLI keeps its hub credentials. XDG-ish; one file, mode 0600.
6
+ const CONFIG_DIR = join(homedir(), '.config', 'fluidcad');
7
+ export const CREDENTIALS_PATH = join(CONFIG_DIR, 'credentials.json');
8
+
9
+ const DEFAULT_HUB_URL = 'https://hub.fluidcad.io';
10
+
11
+ /** Resolve the hub base URL: explicit override → $FLUIDCAD_HUB_URL → default. */
12
+ export function getHubUrl(override) {
13
+ const url = override || process.env.FLUIDCAD_HUB_URL || DEFAULT_HUB_URL;
14
+ return url.replace(/\/+$/, '');
15
+ }
16
+
17
+ /** Read saved credentials, or null if not logged in / unreadable. */
18
+ export function readCredentials() {
19
+ try {
20
+ const creds = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'));
21
+ return creds && creds.token ? creds : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /** Persist credentials at mode 0600 (re-chmod in case the file pre-existed). */
28
+ export function writeCredentials(creds) {
29
+ mkdirSync(CONFIG_DIR, { recursive: true });
30
+ writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
31
+ chmodSync(CREDENTIALS_PATH, 0o600);
32
+ }
33
+
34
+ /** Forget saved credentials (used by `logout`). */
35
+ export function clearCredentials() {
36
+ if (existsSync(CREDENTIALS_PATH)) {
37
+ writeFileSync(CREDENTIALS_PATH, '{}\n', { mode: 0o600 });
38
+ }
39
+ }
@@ -0,0 +1,38 @@
1
+ import { readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ // `fluidcad.json` at the workspace root holds the stable, hub-minted model
5
+ // identity (and room to grow: default visibility, name, …). It's meant to be
6
+ // committed — like `fly.toml`, it ties a workspace to the model it publishes
7
+ // to, so re-publishing creates a new VERSION rather than an unrelated model.
8
+ const FILENAME = 'fluidcad.json';
9
+
10
+ export function modelConfigPath(workspace) {
11
+ return join(workspace, FILENAME);
12
+ }
13
+
14
+ /** Read `fluidcad.json`, or an empty object if missing/unreadable. */
15
+ export function readModelConfig(workspace) {
16
+ try {
17
+ const cfg = JSON.parse(readFileSync(modelConfigPath(workspace), 'utf8'));
18
+ return cfg && typeof cfg === 'object' ? cfg : {};
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ /** The persisted hub model id for this workspace, or null on first publish. */
25
+ export function readModelId(workspace) {
26
+ const cfg = readModelConfig(workspace);
27
+ return typeof cfg.modelId === 'string' && cfg.modelId ? cfg.modelId : null;
28
+ }
29
+
30
+ /**
31
+ * Persist the hub-minted model id, preserving any other fields already in
32
+ * `fluidcad.json`. Called after the first publish writes back the new id.
33
+ */
34
+ export function writeModelId(workspace, modelId) {
35
+ const cfg = readModelConfig(workspace);
36
+ cfg.modelId = modelId;
37
+ writeFileSync(modelConfigPath(workspace), JSON.stringify(cfg, null, 2) + '\n');
38
+ }
@@ -0,0 +1,57 @@
1
+ import { readFileSync, existsSync, readdirSync } from 'fs';
2
+ import { dirname, join, resolve } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+
7
+ // Shared by `pack` and `publish`: both resolve the entry `.fluid.js` the same
8
+ // way and stamp the same fluidcad version onto the package.
9
+
10
+ /** The installed fluidcad version (from the package's own package.json). */
11
+ export function readPackageVersion() {
12
+ try {
13
+ const pkgPath = resolve(__dirname, '..', '..', 'package.json');
14
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? '0.0.0';
15
+ } catch {
16
+ return '0.0.0';
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Read the MODEL workspace's own `package.json` for publish prefills: name and
22
+ * description (→ short description). Distinct from `readPackageVersion`, which
23
+ * reads the fluidcad engine's version. Missing/unreadable fields come back
24
+ * undefined. (The published version number is hub-assigned, not read here.)
25
+ */
26
+ export function readWorkspacePackage(workspace) {
27
+ try {
28
+ const pkg = JSON.parse(readFileSync(join(workspace, 'package.json'), 'utf8'));
29
+ return {
30
+ name: typeof pkg.name === 'string' ? pkg.name : undefined,
31
+ description: typeof pkg.description === 'string' ? pkg.description : undefined,
32
+ };
33
+ } catch {
34
+ return {};
35
+ }
36
+ }
37
+
38
+ /** Resolve the entry `.fluid.js`: the override if given, else the sole one. */
39
+ export function findEntry(workspace, override) {
40
+ if (override) {
41
+ const abs = resolve(workspace, override);
42
+ if (!existsSync(abs)) {
43
+ throw new Error(`Entry file not found: ${abs}`);
44
+ }
45
+ return abs;
46
+ }
47
+ const candidates = readdirSync(workspace).filter((f) => f.endsWith('.fluid.js'));
48
+ if (candidates.length === 0) {
49
+ throw new Error('No .fluid.js files found in the workspace. Pass --entry to specify one.');
50
+ }
51
+ if (candidates.length > 1) {
52
+ throw new Error(
53
+ `Multiple .fluid.js files found: ${candidates.join(', ')}. Pass --entry to choose one.`,
54
+ );
55
+ }
56
+ return resolve(workspace, candidates[0]);
57
+ }
@@ -3,6 +3,7 @@ import { Solid } from "./solid.js";
3
3
  import { Wire } from "./wire.js";
4
4
  import { Face } from "./face.js";
5
5
  import { Edge } from "./edge.js";
6
+ import { Vertex } from "./vertex.js";
6
7
  export declare class ShapeFactory {
7
- static fromShape(shape: TopoDS_Shape): Edge | Wire | Solid | Face;
8
+ static fromShape(shape: TopoDS_Shape): Vertex | Edge | Wire | Solid | Face;
8
9
  }
@@ -3,6 +3,7 @@ import { Solid } from "./solid.js";
3
3
  import { Wire } from "./wire.js";
4
4
  import { Face } from "./face.js";
5
5
  import { Edge } from "./edge.js";
6
+ import { Vertex } from "./vertex.js";
6
7
  export class ShapeFactory {
7
8
  static fromShape(shape) {
8
9
  if (Explorer.isSolid(shape)) {
@@ -17,6 +18,9 @@ export class ShapeFactory {
17
18
  if (Explorer.isEdge(shape)) {
18
19
  return Edge.fromTopoDSEdge(Explorer.toEdge(shape));
19
20
  }
21
+ if (Explorer.isVertex(shape)) {
22
+ return Vertex.fromTopoDSVertex(Explorer.toVertex(shape));
23
+ }
20
24
  if (Explorer.isCompound(shape) || Explorer.isCompoundSolid(shape)) {
21
25
  const solids = Explorer.findShapes(shape, Explorer.getOcShapeType("solid"));
22
26
  if (solids.length === 1) {
@@ -3,14 +3,15 @@ import { Matrix4 } from "../math/matrix4.js";
3
3
  import type { AxisLike } from "../math/axis.js";
4
4
  import type { PlaneLike } from "../math/plane.js";
5
5
  import type { PointLike } from "../math/point.js";
6
+ import { type NumberParam } from "../core/param.js";
6
7
  export declare abstract class TransformablePrimitive extends SceneObject {
7
8
  transform(matrix: Matrix4): this;
8
- translate(x: number): this;
9
- translate(x: number, y: number): this;
10
- translate(x: number, y: number, z: number): this;
9
+ translate(x: NumberParam): this;
10
+ translate(x: NumberParam, y: NumberParam): this;
11
+ translate(x: NumberParam, y: NumberParam, z: NumberParam): this;
11
12
  translate(p: PointLike): this;
12
- rotate(angle: number): this;
13
- rotate(axis: AxisLike, angle: number): this;
13
+ rotate(angle: NumberParam): this;
14
+ rotate(axis: AxisLike, angle: NumberParam): this;
14
15
  mirror(plane: PlaneLike): this;
15
16
  mirror(axis: AxisLike): this;
16
17
  }