fluidcad 0.0.5 → 0.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluidcad",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Parametric CAD modeling library using javascript",
5
5
  "author": "Marwan Aouida <contact@marwan.dev>",
6
6
  "homepage": "https://fluidcad.io",
@@ -39,7 +39,7 @@
39
39
  "./server": "./server/dist/index.js"
40
40
  },
41
41
  "scripts": {
42
- "clean": "rm -rf lib/dist server/dist ui/dist",
42
+ "clean": "rm -rf lib/dist server/dist ui/dist lib/tsconfig.tsbuildinfo server/tsconfig.tsbuildinfo",
43
43
  "build:lib": "tsc -p lib/tsconfig.json",
44
44
  "build:server": "tsc -p server/tsconfig.json",
45
45
  "build:ui": "vite build --config ui/vite.config.ts",
@@ -0,0 +1,32 @@
1
+ export type SceneRenderedData = {
2
+ absPath: string;
3
+ result: any[];
4
+ rollbackStop: number;
5
+ };
6
+ export declare class FluidCadServer {
7
+ private viteManager;
8
+ private sceneManager;
9
+ private previousScenes;
10
+ private renderingCache;
11
+ private currentFileName;
12
+ init(workspacePath: string): Promise<void>;
13
+ processFile(filePath: string, ignoreCache?: boolean): Promise<SceneRenderedData | null>;
14
+ updateLiveCode(fileName: string, code: string): Promise<SceneRenderedData | null>;
15
+ rollbackFromUI(index: number): Promise<SceneRenderedData | null>;
16
+ rollback(fileName: string, index: number): Promise<SceneRenderedData | null>;
17
+ importFile(workspacePath: string, fileName: string, data: string): Promise<void>;
18
+ getShapeProperties(shapeId: string): any;
19
+ getFaceProperties(shapeId: string, faceIndex: number): any;
20
+ getEdgeProperties(shapeId: string, edgeIndex: number): any;
21
+ exportShapes(shapeIds: string[], options: {
22
+ format: 'step' | 'stl';
23
+ includeColors?: boolean;
24
+ resolution?: string;
25
+ customLinearDeflection?: number;
26
+ customAngularDeflectionDeg?: number;
27
+ }): {
28
+ data: string | Uint8Array;
29
+ fileName: string;
30
+ } | null;
31
+ hitTest(shapeId: string, rayOrigin: [number, number, number], rayDir: [number, number, number], edgeThreshold: number): any;
32
+ }
@@ -0,0 +1,150 @@
1
+ import { join } from 'path';
2
+ import { existsSync } from 'fs';
3
+ import { ViteManager } from "./vite-manager.js";
4
+ export class FluidCadServer {
5
+ viteManager = new ViteManager();
6
+ sceneManager;
7
+ previousScenes = new Map();
8
+ renderingCache = new Map();
9
+ currentFileName = '';
10
+ async init(workspacePath) {
11
+ await this.viteManager.init(workspacePath);
12
+ const initFilePath = join(workspacePath, 'init.js');
13
+ if (existsSync(initFilePath)) {
14
+ const { default: _sceneManager } = await this.viteManager.loadModule(initFilePath);
15
+ this.sceneManager = await _sceneManager;
16
+ }
17
+ }
18
+ async processFile(filePath, ignoreCache = false) {
19
+ if (!this.sceneManager) {
20
+ return null;
21
+ }
22
+ const normalizedFileName = filePath.replace('virtual:live-render:', '');
23
+ this.currentFileName = normalizedFileName;
24
+ if (!ignoreCache) {
25
+ const fromCache = this.renderingCache.get(normalizedFileName);
26
+ if (fromCache) {
27
+ return {
28
+ absPath: normalizedFileName,
29
+ result: fromCache,
30
+ rollbackStop: fromCache.length - 1,
31
+ };
32
+ }
33
+ }
34
+ try {
35
+ let scene = this.sceneManager.startScene();
36
+ this.sceneManager.setCurrentFile(normalizedFileName);
37
+ this.viteManager.invalidateModule();
38
+ await this.viteManager.loadModule(filePath);
39
+ if (this.previousScenes.has(normalizedFileName)) {
40
+ const previousScene = this.previousScenes.get(normalizedFileName);
41
+ scene = this.sceneManager.compare(previousScene, scene);
42
+ }
43
+ this.previousScenes.set(normalizedFileName, scene);
44
+ this.sceneManager.renderScene(scene);
45
+ const result = scene.getRenderedObjects();
46
+ for (const obj of result) {
47
+ if (obj.sourceLocation) {
48
+ obj.sourceLocation.filePath = obj.sourceLocation.filePath.replace('virtual:live-render:', '');
49
+ }
50
+ }
51
+ if (!filePath.startsWith('virtual:live-render')) {
52
+ this.renderingCache.set(normalizedFileName, result);
53
+ }
54
+ return {
55
+ absPath: normalizedFileName,
56
+ result,
57
+ rollbackStop: result.length - 1,
58
+ };
59
+ }
60
+ catch (error) {
61
+ this.viteManager.invalidateModule();
62
+ console.log('Error processing file:', error);
63
+ throw error;
64
+ }
65
+ }
66
+ async updateLiveCode(fileName, code) {
67
+ const id = `virtual:live-render:${fileName}`;
68
+ this.viteManager.setBuffer(id, code);
69
+ this.renderingCache.delete(fileName);
70
+ return this.processFile(id, true);
71
+ }
72
+ async rollbackFromUI(index) {
73
+ return this.rollback(this.currentFileName, index);
74
+ }
75
+ async rollback(fileName, index) {
76
+ if (!this.sceneManager) {
77
+ return null;
78
+ }
79
+ const scene = this.previousScenes.get(fileName);
80
+ if (!scene) {
81
+ return null;
82
+ }
83
+ const totalObjects = scene.getAllSceneObjects().length;
84
+ const rollbackIndex = index >= totalObjects - 1 ? totalObjects - 1 : index;
85
+ this.sceneManager.rollbackScene(scene, rollbackIndex);
86
+ const result = scene.getRenderedObjects();
87
+ return {
88
+ absPath: fileName,
89
+ result,
90
+ rollbackStop: index,
91
+ };
92
+ }
93
+ async importFile(workspacePath, fileName, data) {
94
+ if (!this.sceneManager) {
95
+ throw new Error('SceneManager not initialized');
96
+ }
97
+ const binaryData = Buffer.from(data, 'base64');
98
+ await this.sceneManager.importFile(workspacePath, fileName, binaryData);
99
+ }
100
+ getShapeProperties(shapeId) {
101
+ if (!this.sceneManager) {
102
+ return null;
103
+ }
104
+ const scene = this.previousScenes.get(this.currentFileName);
105
+ if (!scene) {
106
+ return null;
107
+ }
108
+ return this.sceneManager.getShapeProperties(scene, shapeId);
109
+ }
110
+ getFaceProperties(shapeId, faceIndex) {
111
+ if (!this.sceneManager) {
112
+ return null;
113
+ }
114
+ const scene = this.previousScenes.get(this.currentFileName);
115
+ if (!scene) {
116
+ return null;
117
+ }
118
+ return this.sceneManager.getFaceProperties(scene, shapeId, faceIndex);
119
+ }
120
+ getEdgeProperties(shapeId, edgeIndex) {
121
+ if (!this.sceneManager) {
122
+ return null;
123
+ }
124
+ const scene = this.previousScenes.get(this.currentFileName);
125
+ if (!scene) {
126
+ return null;
127
+ }
128
+ return this.sceneManager.getEdgeProperties(scene, shapeId, edgeIndex);
129
+ }
130
+ exportShapes(shapeIds, options) {
131
+ if (!this.sceneManager) {
132
+ return null;
133
+ }
134
+ const scene = this.previousScenes.get(this.currentFileName);
135
+ if (!scene) {
136
+ return null;
137
+ }
138
+ return this.sceneManager.exportShapes(scene, shapeIds, options);
139
+ }
140
+ hitTest(shapeId, rayOrigin, rayDir, edgeThreshold) {
141
+ if (!this.sceneManager) {
142
+ return null;
143
+ }
144
+ const scene = this.previousScenes.get(this.currentFileName);
145
+ if (!scene) {
146
+ return null;
147
+ }
148
+ return this.sceneManager.hitTest(scene, shapeId, rayOrigin, rayDir, edgeThreshold);
149
+ }
150
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,290 @@
1
+ import crypto from 'crypto';
2
+ import http from 'http';
3
+ import path from 'path';
4
+ import express from 'express';
5
+ import { WebSocketServer, WebSocket } from 'ws';
6
+ import { FluidCadServer } from "./fluidcad-server.js";
7
+ import { createPropertiesRouter } from "./routes/properties.js";
8
+ import { createActionsRouter } from "./routes/actions.js";
9
+ import { createExportRouter } from "./routes/export.js";
10
+ import { createScreenshotRouter } from "./routes/screenshot.js";
11
+ const PORT = parseInt(process.env.FLUIDCAD_SERVER_PORT || '3100', 10);
12
+ const WORKSPACE_PATH = process.env.FLUIDCAD_WORKSPACE_PATH || '';
13
+ const UI_DIST = path.resolve(import.meta.dirname, '../../ui/dist');
14
+ // ---------------------------------------------------------------------------
15
+ // IPC helpers — communication with extension host process
16
+ // ---------------------------------------------------------------------------
17
+ function sendToExtension(msg) {
18
+ if (process.send) {
19
+ process.send(msg);
20
+ }
21
+ }
22
+ // ---------------------------------------------------------------------------
23
+ // Express app
24
+ // ---------------------------------------------------------------------------
25
+ const fluidCadServer = new FluidCadServer();
26
+ const app = express();
27
+ app.use(express.json({ limit: '50mb' }));
28
+ app.use('/api', createPropertiesRouter(fluidCadServer));
29
+ app.use('/api', createActionsRouter(fluidCadServer, sendToExtension, broadcastToUI, WORKSPACE_PATH));
30
+ app.use('/api', createExportRouter(fluidCadServer));
31
+ app.use('/api', createScreenshotRouter(requestScreenshot));
32
+ // Static files — serve UI build, with SPA fallback
33
+ app.use(express.static(UI_DIST, {
34
+ setHeaders(res, filePath) {
35
+ if (path.extname(filePath) === '.html') {
36
+ res.setHeader('Cache-Control', 'no-cache');
37
+ }
38
+ },
39
+ }));
40
+ app.get('*splat', (_req, res) => {
41
+ res.setHeader('Cache-Control', 'no-cache');
42
+ res.sendFile(path.join(UI_DIST, 'index.html'));
43
+ });
44
+ // ---------------------------------------------------------------------------
45
+ // HTTP + WebSocket server
46
+ // ---------------------------------------------------------------------------
47
+ const httpServer = http.createServer(app);
48
+ const wss = new WebSocketServer({ server: httpServer });
49
+ const uiClients = new Set();
50
+ let lastSceneMessage = null;
51
+ let initCompleteMessage = null;
52
+ function broadcastToUI(msg) {
53
+ const data = JSON.stringify(msg);
54
+ if (msg.type === 'scene-rendered') {
55
+ lastSceneMessage = data;
56
+ }
57
+ if (msg.type === 'init-complete') {
58
+ initCompleteMessage = data;
59
+ }
60
+ for (const client of uiClients) {
61
+ if (client.readyState === WebSocket.OPEN) {
62
+ client.send(data);
63
+ }
64
+ }
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // Screenshot request/response coordination
68
+ // ---------------------------------------------------------------------------
69
+ const SCREENSHOT_TIMEOUT_MS = 10_000;
70
+ const pendingScreenshots = new Map();
71
+ function requestScreenshot(options) {
72
+ return new Promise((resolve, reject) => {
73
+ if (uiClients.size === 0) {
74
+ reject(new Error('No UI client connected.'));
75
+ return;
76
+ }
77
+ const requestId = crypto.randomUUID();
78
+ const timeout = setTimeout(() => {
79
+ pendingScreenshots.delete(requestId);
80
+ reject(new Error('Screenshot request timed out.'));
81
+ }, SCREENSHOT_TIMEOUT_MS);
82
+ pendingScreenshots.set(requestId, {
83
+ resolve(data) {
84
+ clearTimeout(timeout);
85
+ pendingScreenshots.delete(requestId);
86
+ resolve(data);
87
+ },
88
+ reject(err) {
89
+ clearTimeout(timeout);
90
+ pendingScreenshots.delete(requestId);
91
+ reject(err);
92
+ },
93
+ });
94
+ broadcastToUI({ type: 'take-screenshot', requestId, options });
95
+ });
96
+ }
97
+ function handleUIMessage(raw) {
98
+ let msg;
99
+ try {
100
+ msg = JSON.parse(raw);
101
+ }
102
+ catch {
103
+ return;
104
+ }
105
+ if (msg.type === 'screenshot-result' && msg.requestId) {
106
+ const pending = pendingScreenshots.get(msg.requestId);
107
+ if (!pending) {
108
+ return;
109
+ }
110
+ if (msg.success && msg.data) {
111
+ pending.resolve(Buffer.from(msg.data, 'base64'));
112
+ }
113
+ else {
114
+ pending.reject(new Error(msg.error || 'Screenshot failed.'));
115
+ }
116
+ }
117
+ }
118
+ // ---------------------------------------------------------------------------
119
+ // WebSocket connections
120
+ // ---------------------------------------------------------------------------
121
+ wss.on('connection', (ws) => {
122
+ uiClients.add(ws);
123
+ // Replay init-complete and last scene to newly connected UI client
124
+ if (initCompleteMessage) {
125
+ ws.send(initCompleteMessage);
126
+ }
127
+ if (lastSceneMessage) {
128
+ ws.send(lastSceneMessage);
129
+ }
130
+ ws.on('message', (data) => {
131
+ handleUIMessage(String(data));
132
+ });
133
+ ws.on('close', () => {
134
+ uiClients.delete(ws);
135
+ });
136
+ });
137
+ // ---------------------------------------------------------------------------
138
+ // IPC message handling — extension host → server
139
+ // ---------------------------------------------------------------------------
140
+ let currentFile = null;
141
+ let renderVersion = 0;
142
+ async function handleExtensionMessage(msg) {
143
+ try {
144
+ switch (msg.type) {
145
+ case 'process-file': {
146
+ const myVersion = ++renderVersion;
147
+ broadcastToUI({ type: 'processing-file' });
148
+ currentFile = msg.filePath;
149
+ const data = await fluidCadServer.processFile(msg.filePath);
150
+ if (myVersion !== renderVersion) {
151
+ return;
152
+ }
153
+ if (data) {
154
+ sendToExtension({
155
+ type: 'scene-rendered',
156
+ absPath: data.absPath,
157
+ result: data.result,
158
+ rollbackStop: data.rollbackStop,
159
+ });
160
+ broadcastToUI({
161
+ type: 'scene-rendered',
162
+ result: data.result,
163
+ absPath: data.absPath,
164
+ });
165
+ }
166
+ break;
167
+ }
168
+ case 'live-update': {
169
+ const myVersion = ++renderVersion;
170
+ if (msg.fileName !== currentFile) {
171
+ broadcastToUI({ type: 'processing-file' });
172
+ currentFile = msg.fileName;
173
+ }
174
+ const data = await fluidCadServer.updateLiveCode(msg.fileName, msg.code);
175
+ if (myVersion !== renderVersion) {
176
+ return;
177
+ }
178
+ if (data) {
179
+ sendToExtension({
180
+ type: 'scene-rendered',
181
+ absPath: data.absPath,
182
+ result: data.result,
183
+ rollbackStop: data.rollbackStop,
184
+ });
185
+ broadcastToUI({
186
+ type: 'scene-rendered',
187
+ result: data.result,
188
+ absPath: data.absPath,
189
+ });
190
+ }
191
+ break;
192
+ }
193
+ case 'rollback': {
194
+ const myVersion = ++renderVersion;
195
+ const data = await fluidCadServer.rollback(msg.fileName, msg.index);
196
+ if (myVersion !== renderVersion) {
197
+ return;
198
+ }
199
+ if (data) {
200
+ sendToExtension({
201
+ type: 'scene-rendered',
202
+ absPath: data.absPath,
203
+ result: data.result,
204
+ rollbackStop: data.rollbackStop,
205
+ });
206
+ broadcastToUI({
207
+ type: 'scene-rendered',
208
+ result: data.result,
209
+ absPath: data.absPath,
210
+ rollbackStop: data.rollbackStop,
211
+ });
212
+ }
213
+ break;
214
+ }
215
+ case 'import-file': {
216
+ try {
217
+ await fluidCadServer.importFile(msg.workspacePath, msg.fileName, msg.data);
218
+ sendToExtension({ type: 'import-complete', success: true });
219
+ }
220
+ catch (err) {
221
+ sendToExtension({ type: 'error', message: err.stack || err.message || String(err) });
222
+ }
223
+ break;
224
+ }
225
+ case 'highlight-shape': {
226
+ broadcastToUI({ type: 'highlight-shape', shapeId: msg.shapeId });
227
+ break;
228
+ }
229
+ case 'clear-highlight': {
230
+ broadcastToUI({ type: 'clear-highlight' });
231
+ break;
232
+ }
233
+ case 'show-shape-properties': {
234
+ broadcastToUI({ type: 'show-shape-properties', shapeId: msg.shapeId });
235
+ break;
236
+ }
237
+ case 'export-scene': {
238
+ try {
239
+ const result = fluidCadServer.exportShapes(msg.shapeIds, msg.options);
240
+ if (result) {
241
+ const data = typeof result.data === 'string'
242
+ ? Buffer.from(result.data, 'utf-8').toString('base64')
243
+ : Buffer.from(result.data).toString('base64');
244
+ sendToExtension({
245
+ type: 'export-complete',
246
+ success: true,
247
+ data,
248
+ fileName: result.fileName,
249
+ });
250
+ }
251
+ else {
252
+ sendToExtension({ type: 'export-complete', success: false, error: 'No active scene to export.' });
253
+ }
254
+ }
255
+ catch (err) {
256
+ sendToExtension({ type: 'export-complete', success: false, error: err.message || String(err) });
257
+ }
258
+ break;
259
+ }
260
+ }
261
+ }
262
+ catch (err) {
263
+ sendToExtension({
264
+ type: 'error',
265
+ message: err.stack || err.message || String(err),
266
+ });
267
+ }
268
+ }
269
+ // Listen for IPC messages from extension host
270
+ process.on('message', (msg) => {
271
+ handleExtensionMessage(msg);
272
+ });
273
+ // ---------------------------------------------------------------------------
274
+ // Start
275
+ // ---------------------------------------------------------------------------
276
+ httpServer.listen(PORT, () => {
277
+ const url = `http://localhost:${PORT}`;
278
+ console.log(`FluidCAD server listening on ${url}`);
279
+ // Signal ready immediately so extension can show the webview
280
+ sendToExtension({ type: 'ready', port: PORT, url });
281
+ // Initialize FluidCAD server in the background
282
+ fluidCadServer.init(WORKSPACE_PATH).then(() => {
283
+ sendToExtension({ type: 'init-complete', success: true });
284
+ broadcastToUI({ type: 'init-complete', success: true });
285
+ }).catch((err) => {
286
+ const error = err.stack || err.message || String(err);
287
+ sendToExtension({ type: 'init-complete', success: false, error });
288
+ broadcastToUI({ type: 'init-complete', success: false, error });
289
+ });
290
+ });
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import type { FluidCadServer } from '../fluidcad-server.ts';
3
+ export declare function createActionsRouter(fluidCadServer: FluidCadServer, sendToExtension: (msg: any) => void, broadcastToUI: (msg: any) => void, workspacePath: string): Router;
@@ -0,0 +1,100 @@
1
+ import { Router } from 'express';
2
+ export function createActionsRouter(fluidCadServer, sendToExtension, broadcastToUI, workspacePath) {
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
+ router.post('/insert-point', (req, res) => {
17
+ const { point, sourceLocation } = req.body;
18
+ if (!Array.isArray(point) || point.length !== 2 ||
19
+ !sourceLocation || typeof sourceLocation.line !== 'number' || typeof sourceLocation.column !== 'number') {
20
+ res.status(400).json({ error: 'Invalid request body' });
21
+ return;
22
+ }
23
+ sendToExtension({
24
+ type: 'insert-point',
25
+ point: point,
26
+ sourceLocation,
27
+ });
28
+ res.json({ success: true });
29
+ });
30
+ router.post('/remove-point', (req, res) => {
31
+ const { point, sourceLocation } = req.body;
32
+ if (!Array.isArray(point) || point.length !== 2 ||
33
+ !sourceLocation || typeof sourceLocation.line !== 'number' || typeof sourceLocation.column !== 'number') {
34
+ res.status(400).json({ error: 'Invalid request body' });
35
+ return;
36
+ }
37
+ sendToExtension({
38
+ type: 'remove-point',
39
+ point: point,
40
+ sourceLocation,
41
+ });
42
+ res.json({ success: true });
43
+ });
44
+ router.post('/rollback', async (req, res) => {
45
+ const { index } = req.body;
46
+ if (typeof index !== 'number' || index < 0) {
47
+ res.status(400).json({ error: 'Invalid index' });
48
+ return;
49
+ }
50
+ const data = await fluidCadServer.rollbackFromUI(index);
51
+ if (!data) {
52
+ res.status(404).json({ error: 'No active scene' });
53
+ return;
54
+ }
55
+ sendToExtension({
56
+ type: 'scene-rendered',
57
+ absPath: data.absPath,
58
+ result: data.result,
59
+ rollbackStop: data.rollbackStop,
60
+ });
61
+ broadcastToUI({
62
+ type: 'scene-rendered',
63
+ result: data.result,
64
+ absPath: data.absPath,
65
+ rollbackStop: data.rollbackStop,
66
+ });
67
+ res.json({ success: true });
68
+ });
69
+ router.post('/set-pick-points', (req, res) => {
70
+ const { points, sourceLocation } = req.body;
71
+ if (!Array.isArray(points) ||
72
+ !sourceLocation || typeof sourceLocation.line !== 'number' || typeof sourceLocation.column !== 'number') {
73
+ res.status(400).json({ error: 'Invalid request body' });
74
+ return;
75
+ }
76
+ sendToExtension({
77
+ type: 'set-pick-points',
78
+ points: points,
79
+ sourceLocation,
80
+ });
81
+ res.json({ success: true });
82
+ });
83
+ router.post('/import-file', async (req, res) => {
84
+ const { fileName, data } = req.body;
85
+ if (typeof fileName !== 'string' || typeof data !== 'string') {
86
+ res.status(400).json({ error: 'Invalid request body' });
87
+ return;
88
+ }
89
+ try {
90
+ await fluidCadServer.importFile(workspacePath, fileName, data);
91
+ }
92
+ catch (err) {
93
+ res.status(500).json({ error: err.message || String(err) });
94
+ return;
95
+ }
96
+ const loadName = fileName.replace(/\.(step|stp)$/i, '');
97
+ res.json({ success: true, fileName: loadName });
98
+ });
99
+ return router;
100
+ }
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import type { FluidCadServer } from '../fluidcad-server.ts';
3
+ export declare function createExportRouter(fluidCadServer: FluidCadServer): Router;
@@ -0,0 +1,55 @@
1
+ import { Router } from 'express';
2
+ export function createExportRouter(fluidCadServer) {
3
+ const router = Router();
4
+ router.post('/export', (req, res) => {
5
+ const { format, shapeIds, includeColors, resolution, customAngularDeflectionDeg, customLinearDeflection } = req.body;
6
+ if (format !== 'step' && format !== 'stl') {
7
+ res.status(400).json({ error: 'Invalid format. Must be "step" or "stl".' });
8
+ return;
9
+ }
10
+ if (!Array.isArray(shapeIds) || shapeIds.length === 0) {
11
+ res.status(400).json({ error: 'shapeIds must be a non-empty array.' });
12
+ return;
13
+ }
14
+ if (format === 'stl') {
15
+ const validResolutions = ['coarse', 'medium', 'fine', 'custom'];
16
+ if (resolution && !validResolutions.includes(resolution)) {
17
+ res.status(400).json({ error: 'Invalid resolution.' });
18
+ return;
19
+ }
20
+ if (resolution === 'custom') {
21
+ if (typeof customLinearDeflection !== 'number' || typeof customAngularDeflectionDeg !== 'number') {
22
+ res.status(400).json({ error: 'Custom resolution requires customLinearDeflection and customAngularDeflectionDeg.' });
23
+ return;
24
+ }
25
+ }
26
+ }
27
+ try {
28
+ const result = fluidCadServer.exportShapes(shapeIds, {
29
+ format,
30
+ includeColors,
31
+ resolution: resolution || 'medium',
32
+ customLinearDeflection,
33
+ customAngularDeflectionDeg,
34
+ });
35
+ if (!result) {
36
+ res.status(404).json({ error: 'No active scene to export.' });
37
+ return;
38
+ }
39
+ const ext = format === 'step' ? '.step' : '.stl';
40
+ const mimeType = format === 'step' ? 'application/step' : 'application/sla';
41
+ res.setHeader('Content-Type', mimeType);
42
+ res.setHeader('Content-Disposition', `attachment; filename="export${ext}"`);
43
+ if (typeof result.data === 'string') {
44
+ res.send(Buffer.from(result.data, 'utf-8'));
45
+ }
46
+ else {
47
+ res.send(Buffer.from(result.data));
48
+ }
49
+ }
50
+ catch (err) {
51
+ res.status(500).json({ error: err.message || String(err) });
52
+ }
53
+ });
54
+ return router;
55
+ }
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import type { FluidCadServer } from '../fluidcad-server.ts';
3
+ export declare function createPropertiesRouter(fluidCadServer: FluidCadServer): Router;