fluidcad 0.0.34 → 0.0.36

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 (176) hide show
  1. package/README.md +69 -0
  2. package/bin/commands/login.js +148 -0
  3. package/bin/commands/mcp.js +3 -2
  4. package/bin/commands/pack.js +49 -0
  5. package/bin/commands/publish.js +231 -0
  6. package/bin/fluidcad.js +6 -0
  7. package/bin/lib/api-client.js +48 -0
  8. package/bin/lib/browser.js +16 -0
  9. package/bin/lib/config.js +39 -0
  10. package/bin/lib/model-config.js +61 -0
  11. package/bin/lib/prompt.js +97 -0
  12. package/bin/lib/workspace.js +57 -0
  13. package/lib/dist/common/shape-factory.d.ts +2 -1
  14. package/lib/dist/common/shape-factory.js +4 -0
  15. package/lib/dist/common/transformable-primitive.d.ts +6 -5
  16. package/lib/dist/common/transformable-primitive.js +8 -7
  17. package/lib/dist/common/vertex.js +0 -1
  18. package/lib/dist/core/2d/aline.d.ts +4 -3
  19. package/lib/dist/core/2d/aline.js +3 -2
  20. package/lib/dist/core/2d/arc.d.ts +3 -2
  21. package/lib/dist/core/2d/arc.js +4 -3
  22. package/lib/dist/core/2d/bezier.d.ts +8 -6
  23. package/lib/dist/core/2d/circle.d.ts +4 -3
  24. package/lib/dist/core/2d/circle.js +3 -2
  25. package/lib/dist/core/2d/ellipse.d.ts +5 -4
  26. package/lib/dist/core/2d/ellipse.js +5 -4
  27. package/lib/dist/core/2d/hline.d.ts +4 -3
  28. package/lib/dist/core/2d/hline.js +5 -3
  29. package/lib/dist/core/2d/line.js +1 -0
  30. package/lib/dist/core/2d/offset.d.ts +3 -2
  31. package/lib/dist/core/2d/offset.js +6 -5
  32. package/lib/dist/core/2d/polygon.d.ts +5 -4
  33. package/lib/dist/core/2d/polygon.js +10 -9
  34. package/lib/dist/core/2d/rect.d.ts +4 -3
  35. package/lib/dist/core/2d/rect.js +10 -9
  36. package/lib/dist/core/2d/slot.d.ts +14 -6
  37. package/lib/dist/core/2d/slot.js +19 -8
  38. package/lib/dist/core/2d/vline.d.ts +4 -3
  39. package/lib/dist/core/2d/vline.js +5 -3
  40. package/lib/dist/core/chamfer.d.ts +5 -4
  41. package/lib/dist/core/chamfer.js +7 -6
  42. package/lib/dist/core/color.d.ts +3 -2
  43. package/lib/dist/core/color.js +2 -1
  44. package/lib/dist/core/cut.d.ts +4 -3
  45. package/lib/dist/core/cut.js +5 -4
  46. package/lib/dist/core/cylinder.d.ts +2 -1
  47. package/lib/dist/core/cylinder.js +2 -1
  48. package/lib/dist/core/draft.d.ts +3 -2
  49. package/lib/dist/core/draft.js +3 -2
  50. package/lib/dist/core/extrude.d.ts +4 -3
  51. package/lib/dist/core/extrude.js +5 -4
  52. package/lib/dist/core/fillet.d.ts +5 -4
  53. package/lib/dist/core/fillet.js +6 -5
  54. package/lib/dist/core/index.d.ts +1 -0
  55. package/lib/dist/core/index.js +1 -0
  56. package/lib/dist/core/interfaces.d.ts +25 -24
  57. package/lib/dist/core/param.d.ts +74 -0
  58. package/lib/dist/core/param.js +147 -0
  59. package/lib/dist/core/repeat.d.ts +2 -1
  60. package/lib/dist/core/repeat.js +10 -8
  61. package/lib/dist/core/revolve.d.ts +2 -1
  62. package/lib/dist/core/revolve.js +3 -2
  63. package/lib/dist/core/rib.d.ts +3 -2
  64. package/lib/dist/core/rib.js +6 -2
  65. package/lib/dist/core/rotate.d.ts +5 -4
  66. package/lib/dist/core/rotate.js +4 -3
  67. package/lib/dist/core/shell.d.ts +3 -2
  68. package/lib/dist/core/shell.js +3 -2
  69. package/lib/dist/core/sphere.d.ts +3 -2
  70. package/lib/dist/core/sphere.js +2 -1
  71. package/lib/dist/core/translate.d.ts +7 -6
  72. package/lib/dist/core/translate.js +6 -5
  73. package/lib/dist/features/2d/arc.js +5 -5
  74. package/lib/dist/features/2d/bezier.js +16 -16
  75. package/lib/dist/features/2d/circle.js +4 -0
  76. package/lib/dist/features/2d/ellipse.js +4 -0
  77. package/lib/dist/features/2d/hline.d.ts +3 -0
  78. package/lib/dist/features/2d/hline.js +9 -2
  79. package/lib/dist/features/2d/line.d.ts +3 -0
  80. package/lib/dist/features/2d/line.js +11 -3
  81. package/lib/dist/features/2d/sketch.js +5 -1
  82. package/lib/dist/features/2d/slot.d.ts +5 -0
  83. package/lib/dist/features/2d/slot.js +52 -7
  84. package/lib/dist/features/2d/tarc-to-point-tangent.js +3 -0
  85. package/lib/dist/features/2d/tarc-to-point.js +3 -0
  86. package/lib/dist/features/2d/tarc-with-tangent.js +3 -0
  87. package/lib/dist/features/2d/tarc.js +3 -0
  88. package/lib/dist/features/2d/vline.d.ts +3 -0
  89. package/lib/dist/features/2d/vline.js +9 -2
  90. package/lib/dist/features/copy-circular.d.ts +4 -3
  91. package/lib/dist/features/copy-circular.js +16 -9
  92. package/lib/dist/features/copy-circular2d.js +16 -9
  93. package/lib/dist/features/copy-linear.d.ts +4 -3
  94. package/lib/dist/features/copy-linear.js +18 -12
  95. package/lib/dist/features/copy-linear2d.js +18 -12
  96. package/lib/dist/features/extrude-base.d.ts +4 -3
  97. package/lib/dist/features/extrude-base.js +10 -3
  98. package/lib/dist/features/mirror-shape2d.js +2 -2
  99. package/lib/dist/features/repeat-base.d.ts +13 -0
  100. package/lib/dist/features/repeat-base.js +21 -0
  101. package/lib/dist/features/repeat-circular.d.ts +6 -5
  102. package/lib/dist/features/repeat-circular.js +3 -6
  103. package/lib/dist/features/repeat-linear.d.ts +7 -7
  104. package/lib/dist/features/repeat-linear.js +3 -6
  105. package/lib/dist/index.d.ts +5 -0
  106. package/lib/dist/index.js +8 -1
  107. package/lib/dist/io/file-import.d.ts +7 -0
  108. package/lib/dist/io/file-import.js +30 -10
  109. package/lib/dist/math/lazy-matrix.d.ts +5 -0
  110. package/lib/dist/math/lazy-matrix.js +78 -10
  111. package/lib/dist/oc/boolean-ops.d.ts +2 -2
  112. package/lib/dist/param-registry.d.ts +34 -0
  113. package/lib/dist/param-registry.js +60 -0
  114. package/lib/dist/rendering/mesh-builder.js +2 -1
  115. package/lib/dist/tests/features/copy-circular.test.js +1 -1
  116. package/lib/dist/tests/features/copy-linear.test.js +10 -10
  117. package/lib/dist/tests/features/repeat-user-repro-cache.test.d.ts +1 -0
  118. package/lib/dist/tests/features/repeat-user-repro-cache.test.js +97 -0
  119. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  120. package/llm-docs/api/bezier.md +10 -11
  121. package/llm-docs/api/index.json +1 -1
  122. package/llm-docs/api/types/arc-points.md +2 -2
  123. package/llm-docs/api/types/cut.md +10 -10
  124. package/llm-docs/api/types/extrude.md +10 -10
  125. package/llm-docs/api/types/loft.md +6 -6
  126. package/llm-docs/api/types/revolve.md +6 -6
  127. package/llm-docs/api/types/rib.md +2 -2
  128. package/llm-docs/api/types/slot.md +2 -2
  129. package/llm-docs/api/types/sweep.md +10 -10
  130. package/llm-docs/api/types/transformable.md +14 -14
  131. package/llm-docs/index.json +12 -12
  132. package/mcp/dist/client.d.ts +1 -0
  133. package/mcp/dist/client.js +8 -1
  134. package/mcp/dist/server.js +14 -1
  135. package/mcp/dist/tools/engine.d.ts +16 -0
  136. package/mcp/dist/tools/engine.js +45 -0
  137. package/package.json +9 -3
  138. package/server/dist/api.d.ts +37 -0
  139. package/server/dist/api.js +44 -0
  140. package/server/dist/code-editor.d.ts +64 -0
  141. package/server/dist/code-editor.js +520 -2
  142. package/server/dist/fluidcad-server.d.ts +87 -1
  143. package/server/dist/fluidcad-server.js +254 -88
  144. package/server/dist/host/blocked-imports.d.ts +8 -0
  145. package/server/dist/host/blocked-imports.js +30 -0
  146. package/server/dist/{vite-manager.d.ts → host/local-scene-host.d.ts} +3 -1
  147. package/server/dist/{vite-manager.js → host/local-scene-host.js} +6 -26
  148. package/server/dist/host/scene-host.d.ts +19 -0
  149. package/server/dist/host/scene-host.js +1 -0
  150. package/server/dist/index.js +24 -117
  151. package/server/dist/model-package/capture-params.d.ts +19 -0
  152. package/server/dist/model-package/capture-params.js +42 -0
  153. package/server/dist/model-package/pack.d.ts +23 -0
  154. package/server/dist/model-package/pack.js +230 -0
  155. package/server/dist/model-package/types.d.ts +79 -0
  156. package/server/dist/model-package/types.js +17 -0
  157. package/server/dist/routes/hit-test.d.ts +3 -0
  158. package/server/dist/routes/hit-test.js +17 -0
  159. package/server/dist/routes/pack.d.ts +10 -0
  160. package/server/dist/routes/pack.js +47 -0
  161. package/server/dist/routes/params.d.ts +3 -0
  162. package/server/dist/routes/params.js +75 -0
  163. package/server/dist/routes/sketch-edits.d.ts +3 -0
  164. package/server/dist/routes/sketch-edits.js +542 -0
  165. package/server/dist/routes/timeline.d.ts +3 -0
  166. package/server/dist/routes/timeline.js +49 -0
  167. package/server/dist/server-core.d.ts +53 -0
  168. package/server/dist/server-core.js +147 -0
  169. package/server/dist/ws-protocol.d.ts +101 -2
  170. package/ui/dist/assets/index-CDJmUpFI.css +2 -0
  171. package/ui/dist/assets/index-MRqwG9Vh.js +5417 -0
  172. package/ui/dist/index.html +2 -2
  173. package/server/dist/routes/actions.d.ts +0 -3
  174. package/server/dist/routes/actions.js +0 -309
  175. package/ui/dist/assets/index-BdqrMDRu.js +0 -4946
  176. package/ui/dist/assets/index-DR7c2Qk9.css +0 -2
@@ -1,12 +1,14 @@
1
- import crypto from 'crypto';
2
1
  import fs from 'fs';
3
2
  import http from 'http';
4
3
  import path from 'path';
5
4
  import express from 'express';
6
- import { WebSocketServer, WebSocket } from 'ws';
7
5
  import { FluidCadServer } from "./fluidcad-server.js";
6
+ import { createServerCore } from "./server-core.js";
8
7
  import { createPropertiesRouter } from "./routes/properties.js";
9
- import { createActionsRouter } from "./routes/actions.js";
8
+ import { createParamsRouter } from "./routes/params.js";
9
+ import { createHitTestRouter } from "./routes/hit-test.js";
10
+ import { createTimelineRouter } from "./routes/timeline.js";
11
+ import { createSketchEditsRouter } from "./routes/sketch-edits.js";
10
12
  import { createExportRouter } from "./routes/export.js";
11
13
  import { createScreenshotRouter } from "./routes/screenshot.js";
12
14
  import { createPreferencesRouter } from "./routes/preferences.js";
@@ -15,6 +17,7 @@ import { createSceneRouter } from "./routes/scene.js";
15
17
  import { createEditorRouter, DirtyBufferState } from "./routes/editor.js";
16
18
  import { createRenderRouter } from "./routes/render.js";
17
19
  import { createLintRouter } from "./routes/lint.js";
20
+ import { createPackRouter } from "./routes/pack.js";
18
21
  import { normalizePath } from "./normalize-path.js";
19
22
  import { writeInstanceFile, deleteInstanceFile } from "./instance-file.js";
20
23
  import { addInstance, removeInstance } from "./global-registry.js";
@@ -53,20 +56,32 @@ const fluidCadServer = new FluidCadServer();
53
56
  const dirtyBufferState = new DirtyBufferState();
54
57
  const app = express();
55
58
  app.use(express.json({ limit: '50mb' }));
59
+ // ---------------------------------------------------------------------------
60
+ // HTTP + WebSocket server (set up early so routes can reference its helpers)
61
+ // ---------------------------------------------------------------------------
62
+ const httpServer = http.createServer(app);
63
+ const core = createServerCore(httpServer);
64
+ const broadcastToUI = core.broadcastToUI;
65
+ const requestScreenshot = core.requestScreenshot;
66
+ const getLastCameraState = core.getLastCameraState;
56
67
  app.use('/api', createHealthRouter({
57
68
  version: PACKAGE_VERSION,
58
69
  workspacePath: WORKSPACE_PATH,
59
70
  startedAt: STARTED_AT,
60
71
  }));
61
72
  app.use('/api', createPropertiesRouter(fluidCadServer));
62
- app.use('/api', createActionsRouter(fluidCadServer, sendToExtension, broadcastToUI, WORKSPACE_PATH));
73
+ app.use('/api', createParamsRouter(fluidCadServer, sendToExtension, broadcastToUI));
74
+ app.use('/api', createHitTestRouter(fluidCadServer));
75
+ app.use('/api', createTimelineRouter(fluidCadServer, sendToExtension, broadcastToUI));
76
+ app.use('/api', createSketchEditsRouter(fluidCadServer, sendToExtension, WORKSPACE_PATH));
63
77
  app.use('/api', createExportRouter(fluidCadServer, WORKSPACE_PATH));
64
78
  app.use('/api', createScreenshotRouter(requestScreenshot));
65
79
  app.use('/api', createPreferencesRouter());
66
- app.use('/api', createSceneRouter(fluidCadServer, () => lastCameraState));
80
+ app.use('/api', createSceneRouter(fluidCadServer, getLastCameraState));
67
81
  app.use('/api', createEditorRouter(dirtyBufferState));
68
82
  app.use('/api', createRenderRouter((fileName, code) => runLiveRender(fileName, code)));
69
83
  app.use('/api', createLintRouter());
84
+ app.use('/api', createPackRouter(fluidCadServer, WORKSPACE_PATH, PACKAGE_VERSION, getLastCameraState));
70
85
  // Static files — serve UI build, with SPA fallback
71
86
  app.use(express.static(UI_DIST, {
72
87
  setHeaders(res, filePath) {
@@ -80,121 +95,12 @@ app.get('*splat', (_req, res) => {
80
95
  res.sendFile(path.join(UI_DIST, 'index.html'));
81
96
  });
82
97
  // ---------------------------------------------------------------------------
83
- // HTTP + WebSocket server
84
- // ---------------------------------------------------------------------------
85
- const httpServer = http.createServer(app);
86
- const wss = new WebSocketServer({ server: httpServer });
87
- const uiClients = new Set();
88
- let lastSceneMessage = null;
89
- let initCompleteMessage = null;
90
- let lastCameraState = null;
91
- function broadcastToUI(msg) {
92
- const data = JSON.stringify(msg);
93
- if (msg.type === 'scene-rendered') {
94
- lastSceneMessage = data;
95
- }
96
- if (msg.type === 'init-complete') {
97
- initCompleteMessage = data;
98
- }
99
- for (const client of uiClients) {
100
- if (client.readyState === WebSocket.OPEN) {
101
- client.send(data);
102
- }
103
- }
104
- }
105
- // ---------------------------------------------------------------------------
106
- // Screenshot request/response coordination
107
- // ---------------------------------------------------------------------------
108
- const SCREENSHOT_TIMEOUT_MS = 10_000;
109
- const pendingScreenshots = new Map();
110
- function requestScreenshot(options) {
111
- return new Promise((resolve, reject) => {
112
- if (uiClients.size === 0) {
113
- reject(new Error('No UI client connected.'));
114
- return;
115
- }
116
- const requestId = crypto.randomUUID();
117
- const timeout = setTimeout(() => {
118
- pendingScreenshots.delete(requestId);
119
- reject(new Error('Screenshot request timed out.'));
120
- }, SCREENSHOT_TIMEOUT_MS);
121
- pendingScreenshots.set(requestId, {
122
- resolve(data) {
123
- clearTimeout(timeout);
124
- pendingScreenshots.delete(requestId);
125
- resolve(data);
126
- },
127
- reject(err) {
128
- clearTimeout(timeout);
129
- pendingScreenshots.delete(requestId);
130
- reject(err);
131
- },
132
- });
133
- broadcastToUI({ type: 'take-screenshot', requestId, options });
134
- });
135
- }
136
- function handleUIMessage(raw) {
137
- let msg;
138
- try {
139
- msg = JSON.parse(raw);
140
- }
141
- catch {
142
- return;
143
- }
144
- if (msg.type === 'screenshot-result' && msg.requestId) {
145
- const pending = pendingScreenshots.get(msg.requestId);
146
- if (!pending) {
147
- return;
148
- }
149
- if (msg.success && msg.data) {
150
- pending.resolve(Buffer.from(msg.data, 'base64'));
151
- }
152
- else {
153
- pending.reject(new Error(msg.error || 'Screenshot failed.'));
154
- }
155
- return;
156
- }
157
- if (msg.type === 'camera-state') {
158
- // Trust UI structure — we own both ends. Just shape-check arrays.
159
- if (Array.isArray(msg.position) && msg.position.length === 3 &&
160
- Array.isArray(msg.target) && msg.target.length === 3 &&
161
- Array.isArray(msg.up) && msg.up.length === 3) {
162
- lastCameraState = {
163
- type: 'camera-state',
164
- position: msg.position,
165
- target: msg.target,
166
- up: msg.up,
167
- projection: msg.projection === 'perspective' ? 'perspective' : 'orthographic',
168
- };
169
- }
170
- }
171
- }
172
- // ---------------------------------------------------------------------------
173
- // WebSocket connections
174
- // ---------------------------------------------------------------------------
175
- wss.on('connection', (ws) => {
176
- uiClients.add(ws);
177
- // Replay init-complete and last scene to newly connected UI client
178
- if (initCompleteMessage) {
179
- ws.send(initCompleteMessage);
180
- }
181
- if (lastSceneMessage) {
182
- ws.send(lastSceneMessage);
183
- }
184
- ws.on('message', (data) => {
185
- handleUIMessage(String(data));
186
- });
187
- ws.on('close', () => {
188
- uiClients.delete(ws);
189
- });
190
- });
191
- // ---------------------------------------------------------------------------
192
98
  // IPC message handling — extension host → server
193
99
  // ---------------------------------------------------------------------------
194
100
  let currentFile = null;
195
101
  let renderVersion = 0;
196
102
  const lastSceneByFile = new Map();
197
- function emitSuccess(version, absPath, result, rollbackStop, breakpointHit) {
103
+ function emitSuccess(version, absPath, result, rollbackStop, breakpointHit, params) {
198
104
  lastSceneByFile.set(absPath, { result, rollbackStop });
199
105
  fluidCadServer.setCompileError(null);
200
106
  sendToExtension({
@@ -209,6 +115,7 @@ function emitSuccess(version, absPath, result, rollbackStop, breakpointHit) {
209
115
  absPath,
210
116
  rollbackStop,
211
117
  breakpointHit,
118
+ params,
212
119
  });
213
120
  broadcastToUI({ type: 'render-version', version, state: 'end', absPath });
214
121
  }
@@ -277,7 +184,7 @@ async function runLiveRender(fileName, code) {
277
184
  if (!data) {
278
185
  return { state: 'no-scene-manager', version: myVersion, durationMs: Date.now() - startedAt };
279
186
  }
280
- emitSuccess(myVersion, data.absPath, data.result, data.rollbackStop, data.breakpointHit);
187
+ emitSuccess(myVersion, data.absPath, data.result, data.rollbackStop, data.breakpointHit, data.params);
281
188
  return {
282
189
  state: 'rendered',
283
190
  version: myVersion,
@@ -312,7 +219,7 @@ async function handleExtensionMessage(msg) {
312
219
  return;
313
220
  }
314
221
  if (data) {
315
- emitSuccess(myVersion, data.absPath, data.result, data.rollbackStop, data.breakpointHit);
222
+ emitSuccess(myVersion, data.absPath, data.result, data.rollbackStop, data.breakpointHit, data.params);
316
223
  }
317
224
  }
318
225
  catch (err) {
@@ -0,0 +1,19 @@
1
+ import type { ParamDefinition } from '../../../lib/dist/index.js';
2
+ /**
3
+ * Render a model once, headlessly, and return its full parameter schema.
4
+ *
5
+ * Param *definitions* (type, default, current value, constraints) only exist
6
+ * after the engine runs the model — the packer/bundler never executes user
7
+ * code, so a static manifest can carry override values at most. `fluidcad
8
+ * publish` calls this to capture the real schema and embeds it in the manifest
9
+ * (`paramDefinitions`), so the hub can build param forms without a live worker.
10
+ *
11
+ * The render doubles as a build gate: a compile/runtime error in the model
12
+ * propagates out of here, failing the publish before any draft is created.
13
+ *
14
+ * Side-effect-free at import time — it constructs its own `FluidCadServer`
15
+ * (which boots OC wasm + a Vite SSR pipeline) and tears the Vite server down
16
+ * before returning so a one-shot CLI process can exit. Import this (or use
17
+ * `fluidcad/server/api`), NOT `fluidcad/server`, which boots the desktop binary.
18
+ */
19
+ export declare function captureParamDefinitions(entryPath: string, workspacePath: string): Promise<ParamDefinition[]>;
@@ -0,0 +1,42 @@
1
+ import { LocalSceneHost } from "../host/local-scene-host.js";
2
+ import { FluidCadServer } from "../fluidcad-server.js";
3
+ /**
4
+ * Render a model once, headlessly, and return its full parameter schema.
5
+ *
6
+ * Param *definitions* (type, default, current value, constraints) only exist
7
+ * after the engine runs the model — the packer/bundler never executes user
8
+ * code, so a static manifest can carry override values at most. `fluidcad
9
+ * publish` calls this to capture the real schema and embeds it in the manifest
10
+ * (`paramDefinitions`), so the hub can build param forms without a live worker.
11
+ *
12
+ * The render doubles as a build gate: a compile/runtime error in the model
13
+ * propagates out of here, failing the publish before any draft is created.
14
+ *
15
+ * Side-effect-free at import time — it constructs its own `FluidCadServer`
16
+ * (which boots OC wasm + a Vite SSR pipeline) and tears the Vite server down
17
+ * before returning so a one-shot CLI process can exit. Import this (or use
18
+ * `fluidcad/server/api`), NOT `fluidcad/server`, which boots the desktop binary.
19
+ */
20
+ export async function captureParamDefinitions(entryPath, workspacePath) {
21
+ const host = new LocalSceneHost();
22
+ const server = new FluidCadServer(host);
23
+ try {
24
+ await server.init(workspacePath);
25
+ const rendered = await server.processFile(entryPath);
26
+ if (!rendered) {
27
+ throw new Error('The engine did not initialize — is there an init.js at the workspace root? ' +
28
+ 'Run `fluidcad init` to scaffold one.');
29
+ }
30
+ return rendered.params ?? [];
31
+ }
32
+ finally {
33
+ // LocalSceneHost.init() starts a Vite dev server that keeps the event loop
34
+ // alive; close it so the CLI can exit after a single render.
35
+ try {
36
+ await host.server?.close();
37
+ }
38
+ catch {
39
+ /* best-effort teardown */
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,23 @@
1
+ import type { ParamDefinition } from '../../../lib/dist/index.js';
2
+ import { type ModelPackageCamera, type ModelPackageManifest, type ParamValue } from './types.ts';
3
+ export interface PackInputs {
4
+ entryPath: string;
5
+ workspacePath: string;
6
+ fluidcadVersion: string;
7
+ name?: string;
8
+ description?: string;
9
+ paramOverrides?: Record<string, ParamValue>;
10
+ /**
11
+ * Full param schema to embed in the manifest. `fluidcad publish` renders the
12
+ * model once to capture this (see `capture-params.ts`); `fluidcad pack` omits
13
+ * it. Kept as an input (rather than rendering inside `packModel`) so packing
14
+ * stays a pure, engine-free file producer.
15
+ */
16
+ paramDefinitions?: ParamDefinition[];
17
+ camera?: ModelPackageCamera;
18
+ }
19
+ export interface PackResult {
20
+ manifest: ModelPackageManifest;
21
+ zip: Buffer;
22
+ }
23
+ export declare function packModel(inputs: PackInputs): Promise<PackResult>;
@@ -0,0 +1,230 @@
1
+ import { build } from 'esbuild';
2
+ import { readFile, readdir, stat } from 'fs/promises';
3
+ import { existsSync } from 'fs';
4
+ import { basename, extname, join, relative } from 'path';
5
+ import JSZip from 'jszip';
6
+ import ignoreFactory from 'ignore';
7
+ import { normalizePath } from "../normalize-path.js";
8
+ import { getBlockedNodeModule } from "../host/blocked-imports.js";
9
+ import { ASSETS_PREFIX, BUNDLE_FILENAME, FILES_PREFIX, MANIFEST_FILENAME, } from "./types.js";
10
+ /**
11
+ * Reject Node.js builtins that are off-limits in `.fluid.js` code. Same
12
+ * defence the LocalSceneHost applies at SSR transform time; here it runs
13
+ * at pack time so the produced bundle is verified before it ships.
14
+ */
15
+ function blockNodeBuiltinsPlugin() {
16
+ return {
17
+ name: 'block-node-builtins',
18
+ setup(b) {
19
+ b.onResolve({ filter: /.*/ }, (args) => {
20
+ const blocked = getBlockedNodeModule(args.path);
21
+ if (!blocked)
22
+ return null;
23
+ return {
24
+ errors: [
25
+ {
26
+ text: `Module "${args.path}" is not allowed in FluidCAD scripts. ` +
27
+ `Access to Node.js "${blocked}" module is restricted for security.`,
28
+ },
29
+ ],
30
+ };
31
+ });
32
+ },
33
+ };
34
+ }
35
+ /**
36
+ * Bundle the model into a single ES module via a virtual wrapper. When
37
+ * `init.js` exists it runs FIRST (its side effects set up the engine) and
38
+ * its `default` export is forwarded as the bundle's `default` export so
39
+ * the hub-side loader has a handle on the SceneManager. When there's no
40
+ * init.js, the entry is bundled directly.
41
+ *
42
+ * The bundle is self-contained — every transitively-imported workspace file
43
+ * is inlined (npm deps too). The original file text the hub displays comes
44
+ * from the `files/` tree, not from this bundle.
45
+ */
46
+ async function bundleModel(entryAbs, initAbs, workspaceAbs) {
47
+ const entryRel = './' + normalizePath(relative(workspaceAbs, entryAbs));
48
+ const initRel = initAbs ? './' + normalizePath(relative(workspaceAbs, initAbs)) : null;
49
+ const wrapperSource = initRel
50
+ ? `import sceneManager from ${JSON.stringify(initRel)};\n` +
51
+ `import ${JSON.stringify(entryRel)};\n` +
52
+ `export default sceneManager;\n`
53
+ : `export * from ${JSON.stringify(entryRel)};\n`;
54
+ const result = await build({
55
+ stdin: {
56
+ contents: wrapperSource,
57
+ resolveDir: workspaceAbs,
58
+ sourcefile: '__fluidpkg_entry__.js',
59
+ loader: 'js',
60
+ },
61
+ format: 'esm',
62
+ bundle: true,
63
+ write: false,
64
+ platform: 'node',
65
+ external: ['fluidcad', 'fluidcad/*'],
66
+ plugins: [blockNodeBuiltinsPlugin()],
67
+ logLevel: 'silent',
68
+ });
69
+ if (result.errors.length) {
70
+ throw new Error(result.errors.map((e) => e.text).join('\n'));
71
+ }
72
+ if (!result.outputFiles || result.outputFiles.length === 0) {
73
+ throw new Error(`esbuild produced no output for ${entryAbs}`);
74
+ }
75
+ return result.outputFiles[0].text;
76
+ }
77
+ async function collectImportAssetPaths(workspacePath) {
78
+ // STEP imports are stored as cached `.brep` (+ `.colors.json` sidecar) under
79
+ // `imports/` — the engine reads those at render time, not the original
80
+ // `.step` files. Walk the whole workspace so any `.brep`/`.colors.json` is
81
+ // captured; also include any `.step`/`.stp` originals the user kept around
82
+ // (for display in the hub's file viewer; the engine ignores them).
83
+ const out = [];
84
+ async function walk(dir) {
85
+ let entries;
86
+ try {
87
+ entries = await readdir(dir);
88
+ }
89
+ catch {
90
+ return;
91
+ }
92
+ for (const entry of entries) {
93
+ if (entry === 'node_modules' || entry === '.git' || entry === 'dist')
94
+ continue;
95
+ const full = join(dir, entry);
96
+ let st;
97
+ try {
98
+ st = await stat(full);
99
+ }
100
+ catch {
101
+ continue;
102
+ }
103
+ if (st.isDirectory()) {
104
+ await walk(full);
105
+ }
106
+ else if (st.isFile()) {
107
+ const lower = entry.toLowerCase();
108
+ const ext = extname(lower);
109
+ const isColors = lower.endsWith('.colors.json');
110
+ if (ext === '.step' || ext === '.stp' || ext === '.brep' || isColors) {
111
+ out.push(normalizePath(relative(workspacePath, full)));
112
+ }
113
+ }
114
+ }
115
+ }
116
+ await walk(workspacePath);
117
+ return out.sort();
118
+ }
119
+ // Enforced on top of any `.gitignore`: dependency trees, prior pack outputs
120
+ // (the latter would otherwise recurse into the next pack), and `fluidcad.json`
121
+ // (the local hub binding — model id + name — which the hub already owns and
122
+ // should never ship as model source). `node_modules` is also pruned during the
123
+ // walk for speed. Hidden dot-entries are excluded by the walk directly (see
124
+ // below), so VCS metadata (`.git`) and secrets (`.env`) need no pattern here.
125
+ const ALWAYS_EXCLUDE = ['node_modules', '*.fluidpkg', 'fluidcad.json'];
126
+ // `ignore` ships a CJS `module.exports = factory`, but its bundled types use
127
+ // `export default`, which loses the call signature under `module: nodenext`.
128
+ // Pin the factory's real signature; the runtime value is the callable factory.
129
+ const ignore = ignoreFactory;
130
+ /**
131
+ * Pack v2 file selection: every non-ignored file in the workspace, so the hub
132
+ * ships the whole project (README, package.json, configs, sources) — not just
133
+ * the entry's transitive imports.
134
+ *
135
+ * A root `.gitignore` is honored via the mature `ignore` package (same matcher
136
+ * eslint/prettier use). Hidden dot-entries (names starting with `.`) are ALWAYS
137
+ * excluded — `.git`, `.env`, and tool/editor state like `.claude`/`.vscode` are
138
+ * never model content and may hold secrets — regardless of whether they're
139
+ * gitignored. `node_modules` is pruned too; `ALWAYS_EXCLUDE` (prior `.fluidpkg`
140
+ * outputs) is enforced on top of any `.gitignore`. We walk and filter per-file
141
+ * rather than pruning ignored directories so negation rules (`!keep/this`) work.
142
+ */
143
+ async function collectWorkspaceFiles(workspaceAbs) {
144
+ const gitignorePath = join(workspaceAbs, '.gitignore');
145
+ const hasGitignore = existsSync(gitignorePath);
146
+ const ig = ignore().add(ALWAYS_EXCLUDE);
147
+ if (hasGitignore) {
148
+ ig.add(await readFile(gitignorePath, 'utf8'));
149
+ }
150
+ const out = [];
151
+ async function walk(dir) {
152
+ let entries;
153
+ try {
154
+ entries = await readdir(dir, { withFileTypes: true });
155
+ }
156
+ catch {
157
+ return;
158
+ }
159
+ for (const entry of entries) {
160
+ const name = entry.name;
161
+ // Skip dependency trees and ALL hidden dot-entries (VCS metadata, secrets,
162
+ // editor/tool state) — never packaged, gitignore or not.
163
+ if (name === 'node_modules' || name.startsWith('.'))
164
+ continue;
165
+ const full = join(dir, name);
166
+ const rel = normalizePath(relative(workspaceAbs, full));
167
+ if (entry.isDirectory()) {
168
+ await walk(full);
169
+ }
170
+ else if (entry.isFile()) {
171
+ if (ig.ignores(rel))
172
+ continue;
173
+ out.push(rel);
174
+ }
175
+ }
176
+ }
177
+ await walk(workspaceAbs);
178
+ return out.sort();
179
+ }
180
+ export async function packModel(inputs) {
181
+ const entryAbs = normalizePath(inputs.entryPath);
182
+ const workspaceAbs = normalizePath(inputs.workspacePath);
183
+ const initPath = join(workspaceAbs, 'init.js');
184
+ const initAbs = existsSync(initPath) ? normalizePath(initPath) : null;
185
+ const bundle = await bundleModel(entryAbs, initAbs, workspaceAbs);
186
+ const assetPaths = await collectImportAssetPaths(workspaceAbs);
187
+ // The full human tree, minus anything already shipped under assets/ (so large
188
+ // brep/STEP bytes aren't duplicated). assets + files together = the package.
189
+ const assetSet = new Set(assetPaths);
190
+ const filePaths = (await collectWorkspaceFiles(workspaceAbs)).filter((p) => !assetSet.has(p));
191
+ const entryRelative = normalizePath(relative(workspaceAbs, entryAbs));
192
+ const defaultName = basename(entryAbs).replace(/\.fluid\.js$/i, '');
193
+ const manifest = {
194
+ schemaVersion: 2,
195
+ name: inputs.name ?? defaultName,
196
+ fluidcadVersion: inputs.fluidcadVersion,
197
+ createdAt: new Date().toISOString(),
198
+ entry: entryRelative,
199
+ hasInit: !!initAbs,
200
+ assets: assetPaths,
201
+ files: filePaths,
202
+ };
203
+ if (inputs.description)
204
+ manifest.description = inputs.description;
205
+ if (inputs.paramOverrides && Object.keys(inputs.paramOverrides).length > 0) {
206
+ manifest.params = inputs.paramOverrides;
207
+ }
208
+ if (inputs.paramDefinitions && inputs.paramDefinitions.length > 0) {
209
+ manifest.paramDefinitions = inputs.paramDefinitions;
210
+ }
211
+ if (inputs.camera)
212
+ manifest.camera = inputs.camera;
213
+ const zip = new JSZip();
214
+ zip.file(MANIFEST_FILENAME, JSON.stringify(manifest, null, 2));
215
+ zip.file(BUNDLE_FILENAME, bundle);
216
+ for (const relPath of assetPaths) {
217
+ const bytes = await readFile(join(workspaceAbs, relPath));
218
+ zip.file(ASSETS_PREFIX + relPath, bytes);
219
+ }
220
+ for (const relPath of filePaths) {
221
+ const bytes = await readFile(join(workspaceAbs, relPath));
222
+ zip.file(FILES_PREFIX + relPath, bytes);
223
+ }
224
+ const buffer = await zip.generateAsync({
225
+ type: 'nodebuffer',
226
+ compression: 'DEFLATE',
227
+ compressionOptions: { level: 6 },
228
+ });
229
+ return { manifest, zip: buffer };
230
+ }
@@ -0,0 +1,79 @@
1
+ import type { ParamDefinition } from '../../../lib/dist/index.js';
2
+ export type ParamValue = string | number | boolean | (string | number)[];
3
+ export interface ModelPackageCamera {
4
+ position: [number, number, number];
5
+ target: [number, number, number];
6
+ up: [number, number, number];
7
+ projection: 'orthographic' | 'perspective';
8
+ }
9
+ /**
10
+ * Contents of `manifest.json` inside a `.fluidpkg` archive. The bundle, the
11
+ * optional init.js, and any STEP assets live alongside this manifest as
12
+ * separate entries in the zip — we never base64-embed binaries in JSON.
13
+ *
14
+ * The HubSceneHost reads this manifest first to decide what else to load from
15
+ * the archive (presence of `init.js`, which asset paths to map, the file tree).
16
+ *
17
+ * `schemaVersion: 2` retired the `src/` source tree: the single self-contained
18
+ * `bundle.js` is what the engine executes, and the `files` tree (below) is the
19
+ * full human-readable project the hub displays — so `sources`/`src/` (the old
20
+ * transitive-import subset) is gone.
21
+ */
22
+ export interface ModelPackageManifest {
23
+ schemaVersion: 2;
24
+ name: string;
25
+ description?: string;
26
+ fluidcadVersion: string;
27
+ createdAt: string;
28
+ entry: string;
29
+ /**
30
+ * True when the workspace had an `init.js`. Its code is bundled at the top of
31
+ * `bundle.js` so the engine pipeline is set up before the entry runs, and the
32
+ * bundle's `default` export is init's default (the SceneManager). The original
33
+ * `init.js` text is still in the `files` tree for display.
34
+ */
35
+ hasInit: boolean;
36
+ assets: string[];
37
+ /**
38
+ * Workspace-relative paths of every non-ignored file in the workspace
39
+ * (Pack v2), shipped verbatim under `files/<path>`. The full human tree —
40
+ * README, package.json, configs, the `.fluid.js` sources — that the hub's
41
+ * file viewer lists and serves, separate from the self-contained `bundle.js`
42
+ * the engine executes. Paths already shipped under `assets/` (engine
43
+ * brep/STEP) are NOT repeated here; `files` + `assets` is the whole package.
44
+ *
45
+ * Selection respects a root `.gitignore` (via the `ignore` package) and
46
+ * always excludes `node_modules`, prior `*.fluidpkg` outputs, `fluidcad.json`
47
+ * (the local hub binding), and every hidden dot-entry (`.git`, `.env`,
48
+ * `.claude`, `.vscode`, … — never model content, may hold secrets), whether or
49
+ * not they're gitignored.
50
+ */
51
+ files: string[];
52
+ params?: Record<string, ParamValue>;
53
+ /**
54
+ * Full parameter schema captured by rendering the model once at pack time
55
+ * (type/default/current value/constraints per `param()` call). Unlike
56
+ * `params` (override VALUES only), this is the complete definition set the
57
+ * hub stores and renders forms from. Populated by `fluidcad publish` (which
58
+ * boots the engine to render); plain `fluidcad pack` leaves it undefined.
59
+ */
60
+ paramDefinitions?: ParamDefinition[];
61
+ camera?: ModelPackageCamera;
62
+ }
63
+ /**
64
+ * Standard layout inside a `.fluidpkg` zip:
65
+ * manifest.json — ModelPackageManifest as JSON
66
+ * bundle.js — esbuild ES module output: init.js code first (if
67
+ * hasInit), then the entry; bundle's default export
68
+ * is init's default (SceneManager) when present
69
+ * assets/<path> — raw bytes of imported STEP files, paths preserved
70
+ * relative to the workspace root
71
+ * files/<path> — every non-ignored workspace file (Pack v2), verbatim;
72
+ * the full human tree the hub viewer lists and serves.
73
+ * Excludes anything already under assets/ to avoid
74
+ * duplicate bytes.
75
+ */
76
+ export declare const MANIFEST_FILENAME = "manifest.json";
77
+ export declare const BUNDLE_FILENAME = "bundle.js";
78
+ export declare const ASSETS_PREFIX = "assets/";
79
+ export declare const FILES_PREFIX = "files/";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Standard layout inside a `.fluidpkg` zip:
3
+ * manifest.json — ModelPackageManifest as JSON
4
+ * bundle.js — esbuild ES module output: init.js code first (if
5
+ * hasInit), then the entry; bundle's default export
6
+ * is init's default (SceneManager) when present
7
+ * assets/<path> — raw bytes of imported STEP files, paths preserved
8
+ * relative to the workspace root
9
+ * files/<path> — every non-ignored workspace file (Pack v2), verbatim;
10
+ * the full human tree the hub viewer lists and serves.
11
+ * Excludes anything already under assets/ to avoid
12
+ * duplicate bytes.
13
+ */
14
+ export const MANIFEST_FILENAME = 'manifest.json';
15
+ export const BUNDLE_FILENAME = 'bundle.js';
16
+ export const ASSETS_PREFIX = 'assets/';
17
+ export const FILES_PREFIX = 'files/';
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import type { FluidCadServer } from '../fluidcad-server.ts';
3
+ export declare function createHitTestRouter(fluidCadServer: FluidCadServer): Router;
@@ -0,0 +1,17 @@
1
+ import { Router } from 'express';
2
+ export function createHitTestRouter(fluidCadServer) {
3
+ const router = Router();
4
+ router.post('/hit-test', (req, res) => {
5
+ const { shapeId, rayOrigin, rayDir, edgeThreshold } = req.body;
6
+ if (typeof shapeId !== 'string' ||
7
+ !Array.isArray(rayOrigin) || rayOrigin.length !== 3 ||
8
+ !Array.isArray(rayDir) || rayDir.length !== 3 ||
9
+ typeof edgeThreshold !== 'number') {
10
+ res.status(400).json({ error: 'Invalid request body' });
11
+ return;
12
+ }
13
+ const result = fluidCadServer.hitTest(shapeId, rayOrigin, rayDir, edgeThreshold);
14
+ res.json(result);
15
+ });
16
+ return router;
17
+ }
@@ -0,0 +1,10 @@
1
+ import { Router } from 'express';
2
+ import type { FluidCadServer } from '../fluidcad-server.ts';
3
+ import type { CameraStateMessage } from '../ws-protocol.ts';
4
+ /**
5
+ * `POST /api/pack` — produce a `.fluidpkg` (zip) archive of the currently
6
+ * rendered file. Pulls live param overrides and the last-known camera state
7
+ * from the running server so the archive matches what the user is seeing.
8
+ * Returns the binary archive directly (application/zip).
9
+ */
10
+ export declare function createPackRouter(fluidCadServer: FluidCadServer, workspacePath: string, fluidcadVersion: string, getLastCameraState: () => CameraStateMessage | null): Router;