openscad-viewer 2026.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/index.js +430 -0
- package/package.json +32 -0
- package/public/index.html +325 -0
- package/samples/example.scad +23 -0
- package/samples/example.stl +114 -0
- package/spec/openscad-viewer.md +155 -0
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# openscad-viewer
|
|
2
|
+
Simple openscad model viewer with camera controls.
|
|
3
|
+
|
|
4
|
+
## Features
|
|
5
|
+
* Browser UI
|
|
6
|
+
* Render the model (put the file-name in the title bar)
|
|
7
|
+
* Camera controls: zoom, pan, rotate
|
|
8
|
+
* MCP tools
|
|
9
|
+
* `open` opens a model file to the viewer
|
|
10
|
+
* `view` get a rendered image at a particular angle and distance
|
|
11
|
+
* Interaction
|
|
12
|
+
* when a `view` request is made the UI updates; i.e. the user "sees" what the agent is looking at
|
|
13
|
+
* when the model file changes the UI updates automatically
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
* Run via `npx openscad-viewer`
|
|
17
|
+
* Nodejs + express + react
|
package/index.js
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const { WebSocketServer } = require('ws');
|
|
6
|
+
const chokidar = require('chokidar');
|
|
7
|
+
const { execFile } = require('child_process');
|
|
8
|
+
const { promisify } = require('util');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const fsp = require('fs/promises');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
const PORT = 8439;
|
|
16
|
+
|
|
17
|
+
// Redirect all logging to stderr (stdout is reserved for MCP stdio transport)
|
|
18
|
+
const log = (...args) => process.stderr.write(`[openscad-viewer] ${args.join(' ')}\n`);
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// State
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
let currentFile = null;
|
|
24
|
+
let currentStlPath = null;
|
|
25
|
+
let currentStlBuffer = null;
|
|
26
|
+
let lastBoundingBox = null;
|
|
27
|
+
let watcher = null;
|
|
28
|
+
let debounceTimer = null;
|
|
29
|
+
|
|
30
|
+
const wsClients = new Set();
|
|
31
|
+
const pendingRequests = new Map();
|
|
32
|
+
let requestIdCounter = 0;
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// OpenSCAD helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function checkOpenSCAD() {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
execFile('openscad', ['--version'], (err) => resolve(!err));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function compileScad(scadPath) {
|
|
45
|
+
const tmpStl = path.join(os.tmpdir(), `openscad-viewer-${Date.now()}.stl`);
|
|
46
|
+
try {
|
|
47
|
+
await execFileAsync('openscad', ['-o', tmpStl, scadPath], { timeout: 60000 });
|
|
48
|
+
return { stlPath: tmpStl, error: null };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return { stlPath: null, error: err.stderr || err.message };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// STL bounding-box parser (binary + ASCII)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
function parseStlBoundingBox(buffer) {
|
|
59
|
+
const min = [Infinity, Infinity, Infinity];
|
|
60
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
61
|
+
|
|
62
|
+
// Try binary STL
|
|
63
|
+
if (buffer.length >= 84) {
|
|
64
|
+
const numTriangles = buffer.readUInt32LE(80);
|
|
65
|
+
const expectedSize = 84 + numTriangles * 50;
|
|
66
|
+
|
|
67
|
+
if (numTriangles > 0 && buffer.length >= expectedSize) {
|
|
68
|
+
for (let i = 0; i < numTriangles; i++) {
|
|
69
|
+
const base = 84 + i * 50 + 12; // skip normal vector
|
|
70
|
+
for (let v = 0; v < 3; v++) {
|
|
71
|
+
const off = base + v * 12;
|
|
72
|
+
const x = buffer.readFloatLE(off);
|
|
73
|
+
const y = buffer.readFloatLE(off + 4);
|
|
74
|
+
const z = buffer.readFloatLE(off + 8);
|
|
75
|
+
min[0] = Math.min(min[0], x); max[0] = Math.max(max[0], x);
|
|
76
|
+
min[1] = Math.min(min[1], y); max[1] = Math.max(max[1], y);
|
|
77
|
+
min[2] = Math.min(min[2], z); max[2] = Math.max(max[2], z);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (min[0] !== Infinity) return { min, max };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ASCII fallback
|
|
85
|
+
const text = buffer.toString('utf8');
|
|
86
|
+
const re = /vertex\s+([-\d.eE+]+)\s+([-\d.eE+]+)\s+([-\d.eE+]+)/g;
|
|
87
|
+
let m;
|
|
88
|
+
while ((m = re.exec(text)) !== null) {
|
|
89
|
+
const x = parseFloat(m[1]), y = parseFloat(m[2]), z = parseFloat(m[3]);
|
|
90
|
+
min[0] = Math.min(min[0], x); max[0] = Math.max(max[0], x);
|
|
91
|
+
min[1] = Math.min(min[1], y); max[1] = Math.max(max[1], y);
|
|
92
|
+
min[2] = Math.min(min[2], z); max[2] = Math.max(max[2], z);
|
|
93
|
+
}
|
|
94
|
+
return { min, max };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Dependency parsing for .scad files
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
function parseScadDependencies(scadPath) {
|
|
102
|
+
try {
|
|
103
|
+
const content = fs.readFileSync(scadPath, 'utf8');
|
|
104
|
+
const deps = [];
|
|
105
|
+
const re = /(?:include|use)\s*<([^>]+)>/g;
|
|
106
|
+
let m;
|
|
107
|
+
while ((m = re.exec(content)) !== null) {
|
|
108
|
+
const dep = path.resolve(path.dirname(scadPath), m[1]);
|
|
109
|
+
if (fs.existsSync(dep)) deps.push(dep);
|
|
110
|
+
}
|
|
111
|
+
return deps;
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// WebSocket helpers
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
function broadcast(msg) {
|
|
122
|
+
const data = JSON.stringify(msg);
|
|
123
|
+
for (const ws of wsClients) {
|
|
124
|
+
if (ws.readyState === 1) ws.send(data);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sendAndWait(msg, timeoutMs = 10000) {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const requestId = ++requestIdCounter;
|
|
131
|
+
msg.requestId = requestId;
|
|
132
|
+
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
pendingRequests.delete(requestId);
|
|
135
|
+
reject(new Error('Timeout waiting for browser response'));
|
|
136
|
+
}, timeoutMs);
|
|
137
|
+
|
|
138
|
+
pendingRequests.set(requestId, {
|
|
139
|
+
resolve(data) { clearTimeout(timer); pendingRequests.delete(requestId); resolve(data); },
|
|
140
|
+
reject(err) { clearTimeout(timer); pendingRequests.delete(requestId); reject(err); },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const data = JSON.stringify(msg);
|
|
144
|
+
for (const ws of wsClients) {
|
|
145
|
+
if (ws.readyState === 1) { ws.send(data); return; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
pendingRequests.delete(requestId);
|
|
150
|
+
reject(new Error('No browser connected'));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// File watcher
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
function setupWatcher(filePath) {
|
|
159
|
+
if (watcher) watcher.close();
|
|
160
|
+
|
|
161
|
+
const files = [filePath];
|
|
162
|
+
if (path.extname(filePath).toLowerCase() === '.scad') {
|
|
163
|
+
files.push(...parseScadDependencies(filePath));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
watcher = chokidar.watch(files, { ignoreInitial: true });
|
|
167
|
+
watcher.on('change', () => {
|
|
168
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
169
|
+
debounceTimer = setTimeout(() => handleFileChange().catch(err => {
|
|
170
|
+
log(`Watch handler error: ${err.message}`);
|
|
171
|
+
}), 300);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function handleFileChange() {
|
|
176
|
+
log(`File changed: ${currentFile}`);
|
|
177
|
+
const ext = path.extname(currentFile).toLowerCase();
|
|
178
|
+
|
|
179
|
+
if (ext === '.scad') {
|
|
180
|
+
const result = await compileScad(currentFile);
|
|
181
|
+
|
|
182
|
+
// Re-setup watcher (dependencies may have changed, even on errors)
|
|
183
|
+
setupWatcher(currentFile);
|
|
184
|
+
|
|
185
|
+
if (result.error) {
|
|
186
|
+
log(`Compilation error: ${result.error}`);
|
|
187
|
+
broadcast({ type: 'error', message: result.error });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
currentStlPath = result.stlPath;
|
|
191
|
+
currentStlBuffer = await fsp.readFile(result.stlPath);
|
|
192
|
+
lastBoundingBox = parseStlBoundingBox(currentStlBuffer);
|
|
193
|
+
} else {
|
|
194
|
+
currentStlBuffer = await fsp.readFile(currentFile);
|
|
195
|
+
lastBoundingBox = parseStlBoundingBox(currentStlBuffer);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
broadcast({ type: 'model-updated' });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Open a file (used by both CLI startup and MCP `open` tool)
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
async function openFile(filePath) {
|
|
206
|
+
const absPath = path.resolve(filePath);
|
|
207
|
+
|
|
208
|
+
if (!fs.existsSync(absPath)) {
|
|
209
|
+
throw new Error(`File not found: ${absPath}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
213
|
+
if (ext !== '.scad' && ext !== '.stl') {
|
|
214
|
+
throw new Error(`Unsupported file type: ${ext}. Only .scad and .stl are supported.`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (ext === '.scad') {
|
|
218
|
+
if (!(await checkOpenSCAD())) {
|
|
219
|
+
throw new Error('OpenSCAD is not installed or not found on $PATH');
|
|
220
|
+
}
|
|
221
|
+
const result = await compileScad(absPath);
|
|
222
|
+
if (result.error) {
|
|
223
|
+
throw new Error(`OpenSCAD compilation failed:\n${result.error}`);
|
|
224
|
+
}
|
|
225
|
+
currentStlPath = result.stlPath;
|
|
226
|
+
currentStlBuffer = await fsp.readFile(result.stlPath);
|
|
227
|
+
} else {
|
|
228
|
+
currentStlPath = absPath;
|
|
229
|
+
currentStlBuffer = await fsp.readFile(absPath);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
currentFile = absPath;
|
|
233
|
+
lastBoundingBox = parseStlBoundingBox(currentStlBuffer);
|
|
234
|
+
setupWatcher(absPath);
|
|
235
|
+
|
|
236
|
+
broadcast({ type: 'model-updated' });
|
|
237
|
+
broadcast({ type: 'file-info', filename: path.basename(absPath) });
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
success: true,
|
|
241
|
+
file: absPath,
|
|
242
|
+
fileSize: fs.statSync(absPath).size,
|
|
243
|
+
boundingBox: lastBoundingBox,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Open default browser (cross-platform, no extra dependency)
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
function openBrowser(url) {
|
|
252
|
+
const { exec } = require('child_process');
|
|
253
|
+
switch (process.platform) {
|
|
254
|
+
case 'darwin': exec(`open "${url}"`); break;
|
|
255
|
+
case 'win32': exec(`start "" "${url}"`); break;
|
|
256
|
+
default: exec(`xdg-open "${url}"`); break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Main
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
async function main() {
|
|
265
|
+
const args = process.argv.slice(2);
|
|
266
|
+
|
|
267
|
+
if (args.length === 0) {
|
|
268
|
+
process.stderr.write('Usage: openscad-viewer <file.scad|file.stl>\n');
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const filePath = args[0];
|
|
273
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
274
|
+
|
|
275
|
+
if (ext === '.scad' && !(await checkOpenSCAD())) {
|
|
276
|
+
process.stderr.write(
|
|
277
|
+
'Error: OpenSCAD is not installed or not found on $PATH.\n' +
|
|
278
|
+
'Install OpenSCAD to view .scad files, or provide a .stl file instead.\n'
|
|
279
|
+
);
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ----- Express ----------------------------------------------------------
|
|
284
|
+
const app = express();
|
|
285
|
+
const server = http.createServer(app);
|
|
286
|
+
|
|
287
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
288
|
+
|
|
289
|
+
app.get('/model.stl', (_req, res) => {
|
|
290
|
+
if (!currentStlBuffer) return res.status(404).send('No model loaded');
|
|
291
|
+
res.set({ 'Content-Type': 'application/octet-stream', 'Cache-Control': 'no-store' });
|
|
292
|
+
res.send(currentStlBuffer);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ----- WebSocket --------------------------------------------------------
|
|
296
|
+
const wss = new WebSocketServer({ server });
|
|
297
|
+
|
|
298
|
+
wss.on('connection', (ws) => {
|
|
299
|
+
wsClients.add(ws);
|
|
300
|
+
log('Browser connected');
|
|
301
|
+
|
|
302
|
+
if (currentFile) {
|
|
303
|
+
ws.send(JSON.stringify({ type: 'file-info', filename: path.basename(currentFile) }));
|
|
304
|
+
ws.send(JSON.stringify({ type: 'model-updated' }));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
ws.on('message', (raw) => {
|
|
308
|
+
try {
|
|
309
|
+
const msg = JSON.parse(raw);
|
|
310
|
+
if (msg.requestId && pendingRequests.has(msg.requestId)) {
|
|
311
|
+
pendingRequests.get(msg.requestId).resolve(msg);
|
|
312
|
+
}
|
|
313
|
+
} catch (e) {
|
|
314
|
+
log('Bad message from browser:', e.message);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
ws.on('close', () => { wsClients.delete(ws); log('Browser disconnected'); });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ----- Start HTTP server ------------------------------------------------
|
|
322
|
+
try {
|
|
323
|
+
await new Promise((resolve, reject) => {
|
|
324
|
+
server.on('error', (err) => {
|
|
325
|
+
reject(err.code === 'EADDRINUSE'
|
|
326
|
+
? new Error(`Port ${PORT} is already in use`)
|
|
327
|
+
: err);
|
|
328
|
+
});
|
|
329
|
+
server.listen(PORT, '127.0.0.1', resolve);
|
|
330
|
+
});
|
|
331
|
+
} catch (err) {
|
|
332
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
log(`Server running at http://localhost:${PORT}`);
|
|
337
|
+
|
|
338
|
+
// ----- Open initial file ------------------------------------------------
|
|
339
|
+
try {
|
|
340
|
+
await openFile(filePath);
|
|
341
|
+
log(`Opened: ${currentFile}`);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
344
|
+
process.exit(1);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
openBrowser(`http://localhost:${PORT}`);
|
|
348
|
+
|
|
349
|
+
// ----- MCP server (stdio) ----------------------------------------------
|
|
350
|
+
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
|
|
351
|
+
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
|
352
|
+
const { z } = await import('zod');
|
|
353
|
+
|
|
354
|
+
const mcp = new McpServer({ name: 'openscad-viewer', version: '1.0.0' });
|
|
355
|
+
|
|
356
|
+
mcp.tool(
|
|
357
|
+
'open',
|
|
358
|
+
'Opens a .scad or .stl file in the viewer',
|
|
359
|
+
{ file: z.string().describe('Absolute or relative path to a .scad or .stl file') },
|
|
360
|
+
async ({ file }) => {
|
|
361
|
+
try {
|
|
362
|
+
const result = await openFile(file);
|
|
363
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
364
|
+
} catch (err) {
|
|
365
|
+
return {
|
|
366
|
+
content: [{ type: 'text', text: JSON.stringify({ success: false, error: err.message }) }],
|
|
367
|
+
isError: true,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
mcp.tool(
|
|
374
|
+
'view',
|
|
375
|
+
'Renders the current model at a specified camera angle and returns a screenshot image path',
|
|
376
|
+
{
|
|
377
|
+
azimuth: z.number().min(0).max(360).optional().describe('Horizontal angle in degrees (0-360). Default: 45'),
|
|
378
|
+
elevation: z.number().min(-90).max(90).optional().describe('Vertical angle in degrees (-90 to 90). Default: 30'),
|
|
379
|
+
distance: z.number().optional().describe('Distance from model center. Auto-calculated if omitted.'),
|
|
380
|
+
},
|
|
381
|
+
async ({ azimuth, elevation, distance }) => {
|
|
382
|
+
if (!currentFile) {
|
|
383
|
+
return {
|
|
384
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'No model currently loaded' }) }],
|
|
385
|
+
isError: true,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
azimuth = azimuth ?? 45;
|
|
390
|
+
elevation = elevation ?? 30;
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
const response = await sendAndWait({
|
|
394
|
+
type: 'set-camera',
|
|
395
|
+
azimuth,
|
|
396
|
+
elevation,
|
|
397
|
+
distance: distance ?? null,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const base64 = response.dataUrl.replace(/^data:image\/png;base64,/, '');
|
|
401
|
+
const tmpPath = path.join(os.tmpdir(), `openscad-viewer-capture-${Date.now()}.png`);
|
|
402
|
+
await fsp.writeFile(tmpPath, base64, 'base64');
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
content: [{
|
|
406
|
+
type: 'text',
|
|
407
|
+
text: JSON.stringify({
|
|
408
|
+
imagePath: tmpPath,
|
|
409
|
+
camera: { azimuth, elevation, distance: response.distance || distance },
|
|
410
|
+
}, null, 2),
|
|
411
|
+
}],
|
|
412
|
+
};
|
|
413
|
+
} catch (err) {
|
|
414
|
+
return {
|
|
415
|
+
content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
|
|
416
|
+
isError: true,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const transport = new StdioServerTransport();
|
|
423
|
+
await mcp.connect(transport);
|
|
424
|
+
log('MCP server ready on stdio');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
main().catch((err) => {
|
|
428
|
+
process.stderr.write(`Fatal error: ${err.message}\n`);
|
|
429
|
+
process.exit(1);
|
|
430
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openscad-viewer",
|
|
3
|
+
"version": "2026.2.1",
|
|
4
|
+
"description": "Simple openscad model viewer with camera controls.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"openscad-viewer": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node index.js",
|
|
11
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/fingerskier/openscad-viewer.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [],
|
|
18
|
+
"author": "",
|
|
19
|
+
"license": "ISC",
|
|
20
|
+
"type": "commonjs",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/fingerskier/openscad-viewer/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/fingerskier/openscad-viewer#readme",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
27
|
+
"chokidar": "^3.6.0",
|
|
28
|
+
"express": "^4.21.0",
|
|
29
|
+
"ws": "^8.18.0",
|
|
30
|
+
"zod": "^3.24.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>OpenSCAD Viewer</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
body { overflow: hidden; background: #1a1a2e; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
|
10
|
+
#root { width: 100vw; height: 100vh; position: relative; }
|
|
11
|
+
#canvas-container { width: 100%; height: 100%; }
|
|
12
|
+
canvas { display: block; }
|
|
13
|
+
|
|
14
|
+
.error-overlay {
|
|
15
|
+
position: fixed;
|
|
16
|
+
bottom: 20px;
|
|
17
|
+
left: 20px;
|
|
18
|
+
right: 20px;
|
|
19
|
+
max-height: 200px;
|
|
20
|
+
overflow-y: auto;
|
|
21
|
+
background: rgba(185, 28, 28, 0.95);
|
|
22
|
+
color: #fef2f2;
|
|
23
|
+
padding: 14px 40px 14px 16px;
|
|
24
|
+
border-radius: 8px;
|
|
25
|
+
font-family: 'Courier New', Courier, monospace;
|
|
26
|
+
font-size: 13px;
|
|
27
|
+
line-height: 1.5;
|
|
28
|
+
white-space: pre-wrap;
|
|
29
|
+
word-break: break-word;
|
|
30
|
+
z-index: 100;
|
|
31
|
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.error-overlay button {
|
|
35
|
+
position: absolute;
|
|
36
|
+
top: 8px;
|
|
37
|
+
right: 10px;
|
|
38
|
+
background: none;
|
|
39
|
+
border: none;
|
|
40
|
+
color: #fef2f2;
|
|
41
|
+
font-size: 20px;
|
|
42
|
+
cursor: pointer;
|
|
43
|
+
opacity: 0.7;
|
|
44
|
+
line-height: 1;
|
|
45
|
+
}
|
|
46
|
+
.error-overlay button:hover { opacity: 1; }
|
|
47
|
+
|
|
48
|
+
.loading {
|
|
49
|
+
position: fixed;
|
|
50
|
+
top: 50%;
|
|
51
|
+
left: 50%;
|
|
52
|
+
transform: translate(-50%, -50%);
|
|
53
|
+
color: #94a3b8;
|
|
54
|
+
font-size: 16px;
|
|
55
|
+
}
|
|
56
|
+
</style>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<div id="root"></div>
|
|
60
|
+
|
|
61
|
+
<!-- React 18 (UMD) -->
|
|
62
|
+
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
63
|
+
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
64
|
+
|
|
65
|
+
<!-- Three.js import map -->
|
|
66
|
+
<script type="importmap">
|
|
67
|
+
{
|
|
68
|
+
"imports": {
|
|
69
|
+
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
|
70
|
+
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<script type="module">
|
|
76
|
+
import * as THREE from 'three';
|
|
77
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
78
|
+
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
|
79
|
+
|
|
80
|
+
const { createElement: h, useState, useEffect, useRef, useCallback } = React;
|
|
81
|
+
|
|
82
|
+
// -----------------------------------------------------------------------
|
|
83
|
+
// Three.js scene (module-level, shared with React via refs/callbacks)
|
|
84
|
+
// -----------------------------------------------------------------------
|
|
85
|
+
let renderer, scene, camera, controls, gridHelper;
|
|
86
|
+
let currentMesh = null;
|
|
87
|
+
let modelCenter = new THREE.Vector3();
|
|
88
|
+
let modelSize = 1;
|
|
89
|
+
const stlLoader = new STLLoader();
|
|
90
|
+
|
|
91
|
+
function initScene(container) {
|
|
92
|
+
renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
|
93
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
94
|
+
renderer.setPixelRatio(window.devicePixelRatio);
|
|
95
|
+
renderer.setClearColor(0x1a1a2e);
|
|
96
|
+
container.appendChild(renderer.domElement);
|
|
97
|
+
|
|
98
|
+
scene = new THREE.Scene();
|
|
99
|
+
|
|
100
|
+
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100000);
|
|
101
|
+
|
|
102
|
+
// Lights
|
|
103
|
+
scene.add(new THREE.AmbientLight(0x404040, 2));
|
|
104
|
+
const light1 = new THREE.DirectionalLight(0xffffff, 1.5);
|
|
105
|
+
light1.position.set(1, 1, 1);
|
|
106
|
+
scene.add(light1);
|
|
107
|
+
const light2 = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
108
|
+
light2.position.set(-1, -0.5, -1);
|
|
109
|
+
scene.add(light2);
|
|
110
|
+
|
|
111
|
+
// Controls
|
|
112
|
+
controls = new OrbitControls(camera, renderer.domElement);
|
|
113
|
+
controls.enableDamping = true;
|
|
114
|
+
controls.dampingFactor = 0.05;
|
|
115
|
+
|
|
116
|
+
// Grid
|
|
117
|
+
gridHelper = new THREE.GridHelper(100, 20, 0x444466, 0x333355);
|
|
118
|
+
scene.add(gridHelper);
|
|
119
|
+
|
|
120
|
+
// Render loop
|
|
121
|
+
(function animate() {
|
|
122
|
+
requestAnimationFrame(animate);
|
|
123
|
+
controls.update();
|
|
124
|
+
renderer.render(scene, camera);
|
|
125
|
+
})();
|
|
126
|
+
|
|
127
|
+
// Resize handler
|
|
128
|
+
window.addEventListener('resize', () => {
|
|
129
|
+
camera.aspect = window.innerWidth / window.innerHeight;
|
|
130
|
+
camera.updateProjectionMatrix();
|
|
131
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function loadModel(onDone) {
|
|
136
|
+
fetch('/model.stl', { cache: 'no-store' })
|
|
137
|
+
.then((r) => { if (!r.ok) throw new Error('No model'); return r.arrayBuffer(); })
|
|
138
|
+
.then((buf) => {
|
|
139
|
+
const geometry = stlLoader.parse(buf);
|
|
140
|
+
geometry.computeBoundingBox();
|
|
141
|
+
geometry.computeVertexNormals();
|
|
142
|
+
|
|
143
|
+
if (currentMesh) {
|
|
144
|
+
scene.remove(currentMesh);
|
|
145
|
+
currentMesh.geometry.dispose();
|
|
146
|
+
currentMesh.material.dispose();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const material = new THREE.MeshPhongMaterial({
|
|
150
|
+
color: 0x4a90d9,
|
|
151
|
+
specular: 0x222222,
|
|
152
|
+
shininess: 40,
|
|
153
|
+
});
|
|
154
|
+
currentMesh = new THREE.Mesh(geometry, material);
|
|
155
|
+
scene.add(currentMesh);
|
|
156
|
+
|
|
157
|
+
// Bounding box metrics
|
|
158
|
+
const bbox = geometry.boundingBox;
|
|
159
|
+
modelCenter = new THREE.Vector3();
|
|
160
|
+
bbox.getCenter(modelCenter);
|
|
161
|
+
const size = new THREE.Vector3();
|
|
162
|
+
bbox.getSize(size);
|
|
163
|
+
modelSize = Math.max(size.x, size.y, size.z);
|
|
164
|
+
|
|
165
|
+
// Scale grid to model
|
|
166
|
+
const gridScale = Math.max(1, modelSize / 20);
|
|
167
|
+
gridHelper.scale.setScalar(gridScale);
|
|
168
|
+
|
|
169
|
+
// Default isometric camera: azimuth 45°, elevation 30°
|
|
170
|
+
setCameraSpherical(45, 30, modelSize * 2.5, false);
|
|
171
|
+
|
|
172
|
+
if (onDone) onDone();
|
|
173
|
+
})
|
|
174
|
+
.catch((err) => console.error('Model load failed:', err));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function setCameraSpherical(azimuthDeg, elevationDeg, dist, animate = true) {
|
|
178
|
+
const distance = dist || modelSize * 2.5;
|
|
179
|
+
const az = (azimuthDeg * Math.PI) / 180;
|
|
180
|
+
const el = (elevationDeg * Math.PI) / 180;
|
|
181
|
+
|
|
182
|
+
const target = new THREE.Vector3(
|
|
183
|
+
modelCenter.x + distance * Math.cos(el) * Math.sin(az),
|
|
184
|
+
modelCenter.y + distance * Math.sin(el),
|
|
185
|
+
modelCenter.z + distance * Math.cos(el) * Math.cos(az),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
if (!animate) {
|
|
189
|
+
camera.position.copy(target);
|
|
190
|
+
camera.lookAt(modelCenter);
|
|
191
|
+
controls.target.copy(modelCenter);
|
|
192
|
+
controls.update();
|
|
193
|
+
return Promise.resolve(distance);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Smooth animation
|
|
197
|
+
return new Promise((resolve) => {
|
|
198
|
+
const start = camera.position.clone();
|
|
199
|
+
const t0 = performance.now();
|
|
200
|
+
const dur = 600;
|
|
201
|
+
|
|
202
|
+
function step(now) {
|
|
203
|
+
const p = Math.min((now - t0) / dur, 1);
|
|
204
|
+
const ease = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
|
|
205
|
+
camera.position.lerpVectors(start, target, ease);
|
|
206
|
+
camera.lookAt(modelCenter);
|
|
207
|
+
controls.target.copy(modelCenter);
|
|
208
|
+
controls.update();
|
|
209
|
+
if (p < 1) {
|
|
210
|
+
requestAnimationFrame(step);
|
|
211
|
+
} else {
|
|
212
|
+
camera.position.copy(target);
|
|
213
|
+
camera.lookAt(modelCenter);
|
|
214
|
+
controls.update();
|
|
215
|
+
// Extra frame to ensure render is up to date
|
|
216
|
+
requestAnimationFrame(() => {
|
|
217
|
+
renderer.render(scene, camera);
|
|
218
|
+
resolve(distance);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
requestAnimationFrame(step);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function captureScreenshot() {
|
|
227
|
+
renderer.render(scene, camera);
|
|
228
|
+
return renderer.domElement.toDataURL('image/png');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// -----------------------------------------------------------------------
|
|
232
|
+
// React App
|
|
233
|
+
// -----------------------------------------------------------------------
|
|
234
|
+
function App() {
|
|
235
|
+
const containerRef = useRef(null);
|
|
236
|
+
const wsRef = useRef(null);
|
|
237
|
+
const [error, setError] = useState(null);
|
|
238
|
+
const [loading, setLoading] = useState(true);
|
|
239
|
+
|
|
240
|
+
// Initialise Three.js scene once
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
if (containerRef.current && !renderer) {
|
|
243
|
+
initScene(containerRef.current);
|
|
244
|
+
}
|
|
245
|
+
}, []);
|
|
246
|
+
|
|
247
|
+
// WebSocket connection
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
let ws;
|
|
250
|
+
let reconnectTimer;
|
|
251
|
+
|
|
252
|
+
function connect() {
|
|
253
|
+
ws = new WebSocket(`ws://${window.location.host}`);
|
|
254
|
+
wsRef.current = ws;
|
|
255
|
+
|
|
256
|
+
ws.onmessage = async (event) => {
|
|
257
|
+
const msg = JSON.parse(event.data);
|
|
258
|
+
|
|
259
|
+
switch (msg.type) {
|
|
260
|
+
case 'model-updated':
|
|
261
|
+
loadModel(() => {
|
|
262
|
+
setLoading(false);
|
|
263
|
+
setError(null);
|
|
264
|
+
});
|
|
265
|
+
break;
|
|
266
|
+
|
|
267
|
+
case 'file-info':
|
|
268
|
+
document.title = msg.filename + ' \u2014 OpenSCAD Viewer';
|
|
269
|
+
break;
|
|
270
|
+
|
|
271
|
+
case 'error':
|
|
272
|
+
setError(msg.message);
|
|
273
|
+
break;
|
|
274
|
+
|
|
275
|
+
case 'set-camera': {
|
|
276
|
+
const dist = await setCameraSpherical(
|
|
277
|
+
msg.azimuth,
|
|
278
|
+
msg.elevation,
|
|
279
|
+
msg.distance,
|
|
280
|
+
true,
|
|
281
|
+
);
|
|
282
|
+
const dataUrl = captureScreenshot();
|
|
283
|
+
ws.send(JSON.stringify({
|
|
284
|
+
requestId: msg.requestId,
|
|
285
|
+
type: 'screenshot',
|
|
286
|
+
dataUrl,
|
|
287
|
+
distance: dist,
|
|
288
|
+
}));
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
ws.onclose = () => {
|
|
295
|
+
reconnectTimer = setTimeout(connect, 2000);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
ws.onerror = () => ws.close();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
connect();
|
|
302
|
+
|
|
303
|
+
return () => {
|
|
304
|
+
clearTimeout(reconnectTimer);
|
|
305
|
+
if (ws) ws.close();
|
|
306
|
+
};
|
|
307
|
+
}, []);
|
|
308
|
+
|
|
309
|
+
return h('div', { style: { width: '100vw', height: '100vh', position: 'relative' } },
|
|
310
|
+
h('div', { ref: containerRef, id: 'canvas-container' }),
|
|
311
|
+
|
|
312
|
+
loading && h('div', { className: 'loading' }, 'Loading model\u2026'),
|
|
313
|
+
|
|
314
|
+
error && h('div', { className: 'error-overlay' },
|
|
315
|
+
h('button', { onClick: () => setError(null) }, '\u00D7'),
|
|
316
|
+
error,
|
|
317
|
+
),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Mount
|
|
322
|
+
ReactDOM.createRoot(document.getElementById('root')).render(h(App));
|
|
323
|
+
</script>
|
|
324
|
+
</body>
|
|
325
|
+
</html>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Example OpenSCAD file — a rounded cube with cylindrical holes
|
|
2
|
+
// Run: npx openscad-viewer samples/example.scad
|
|
3
|
+
|
|
4
|
+
$fn = 32;
|
|
5
|
+
|
|
6
|
+
difference() {
|
|
7
|
+
// Rounded base block
|
|
8
|
+
minkowski() {
|
|
9
|
+
cube([20, 20, 10], center = true);
|
|
10
|
+
sphere(r = 2);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Hole through the top
|
|
14
|
+
cylinder(h = 20, r = 4, center = true);
|
|
15
|
+
|
|
16
|
+
// Hole through the front
|
|
17
|
+
rotate([90, 0, 0])
|
|
18
|
+
cylinder(h = 30, r = 3, center = true);
|
|
19
|
+
|
|
20
|
+
// Hole through the side
|
|
21
|
+
rotate([0, 90, 0])
|
|
22
|
+
cylinder(h = 30, r = 3, center = true);
|
|
23
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
solid example
|
|
2
|
+
facet normal 0 0 -1
|
|
3
|
+
outer loop
|
|
4
|
+
vertex 0 0 0
|
|
5
|
+
vertex 10 0 0
|
|
6
|
+
vertex 10 10 0
|
|
7
|
+
endloop
|
|
8
|
+
endfacet
|
|
9
|
+
facet normal 0 0 -1
|
|
10
|
+
outer loop
|
|
11
|
+
vertex 0 0 0
|
|
12
|
+
vertex 10 10 0
|
|
13
|
+
vertex 0 10 0
|
|
14
|
+
endloop
|
|
15
|
+
endfacet
|
|
16
|
+
facet normal 0 0 1
|
|
17
|
+
outer loop
|
|
18
|
+
vertex 0 0 8
|
|
19
|
+
vertex 10 10 8
|
|
20
|
+
vertex 10 0 8
|
|
21
|
+
endloop
|
|
22
|
+
endfacet
|
|
23
|
+
facet normal 0 0 1
|
|
24
|
+
outer loop
|
|
25
|
+
vertex 0 0 8
|
|
26
|
+
vertex 0 10 8
|
|
27
|
+
vertex 10 10 8
|
|
28
|
+
endloop
|
|
29
|
+
endfacet
|
|
30
|
+
facet normal 0 -1 0
|
|
31
|
+
outer loop
|
|
32
|
+
vertex 0 0 0
|
|
33
|
+
vertex 10 0 8
|
|
34
|
+
vertex 10 0 0
|
|
35
|
+
endloop
|
|
36
|
+
endfacet
|
|
37
|
+
facet normal 0 -1 0
|
|
38
|
+
outer loop
|
|
39
|
+
vertex 0 0 0
|
|
40
|
+
vertex 0 0 8
|
|
41
|
+
vertex 10 0 8
|
|
42
|
+
endloop
|
|
43
|
+
endfacet
|
|
44
|
+
facet normal 0 1 0
|
|
45
|
+
outer loop
|
|
46
|
+
vertex 0 10 0
|
|
47
|
+
vertex 10 10 0
|
|
48
|
+
vertex 10 10 8
|
|
49
|
+
endloop
|
|
50
|
+
endfacet
|
|
51
|
+
facet normal 0 1 0
|
|
52
|
+
outer loop
|
|
53
|
+
vertex 0 10 0
|
|
54
|
+
vertex 10 10 8
|
|
55
|
+
vertex 0 10 8
|
|
56
|
+
endloop
|
|
57
|
+
endfacet
|
|
58
|
+
facet normal -1 0 0
|
|
59
|
+
outer loop
|
|
60
|
+
vertex 0 0 0
|
|
61
|
+
vertex 0 10 0
|
|
62
|
+
vertex 0 10 8
|
|
63
|
+
endloop
|
|
64
|
+
endfacet
|
|
65
|
+
facet normal -1 0 0
|
|
66
|
+
outer loop
|
|
67
|
+
vertex 0 0 0
|
|
68
|
+
vertex 0 10 8
|
|
69
|
+
vertex 0 0 8
|
|
70
|
+
endloop
|
|
71
|
+
endfacet
|
|
72
|
+
facet normal 1 0 0
|
|
73
|
+
outer loop
|
|
74
|
+
vertex 10 0 0
|
|
75
|
+
vertex 10 0 8
|
|
76
|
+
vertex 10 10 8
|
|
77
|
+
endloop
|
|
78
|
+
endfacet
|
|
79
|
+
facet normal 1 0 0
|
|
80
|
+
outer loop
|
|
81
|
+
vertex 10 0 0
|
|
82
|
+
vertex 10 10 8
|
|
83
|
+
vertex 10 10 0
|
|
84
|
+
endloop
|
|
85
|
+
endfacet
|
|
86
|
+
facet normal 0 -0.5547 0.83205
|
|
87
|
+
outer loop
|
|
88
|
+
vertex 0 0 8
|
|
89
|
+
vertex 5 5 12
|
|
90
|
+
vertex 10 0 8
|
|
91
|
+
endloop
|
|
92
|
+
endfacet
|
|
93
|
+
facet normal 0 0.5547 0.83205
|
|
94
|
+
outer loop
|
|
95
|
+
vertex 10 10 8
|
|
96
|
+
vertex 5 5 12
|
|
97
|
+
vertex 0 10 8
|
|
98
|
+
endloop
|
|
99
|
+
endfacet
|
|
100
|
+
facet normal -0.8575 0 0.5145
|
|
101
|
+
outer loop
|
|
102
|
+
vertex 0 0 8
|
|
103
|
+
vertex 0 10 8
|
|
104
|
+
vertex 5 5 12
|
|
105
|
+
endloop
|
|
106
|
+
endfacet
|
|
107
|
+
facet normal 0.8575 0 0.5145
|
|
108
|
+
outer loop
|
|
109
|
+
vertex 10 0 8
|
|
110
|
+
vertex 5 5 12
|
|
111
|
+
vertex 10 10 8
|
|
112
|
+
endloop
|
|
113
|
+
endfacet
|
|
114
|
+
endsolid example
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# openscad-viewer Specification
|
|
2
|
+
|
|
3
|
+
A browser-based OpenSCAD model viewer with MCP tool integration, enabling AI agents to open and inspect 3D models while the user watches in real-time.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- **OpenSCAD** must be installed locally and available on `$PATH`. The application checks for this at startup and fails with a clear error message if not found. (Only required for `.scad` files — `.stl` files can be viewed without OpenSCAD.)
|
|
8
|
+
- **Node.js**
|
|
9
|
+
|
|
10
|
+
## Rendering Pipeline
|
|
11
|
+
|
|
12
|
+
**For `.scad` files:**
|
|
13
|
+
```
|
|
14
|
+
.scad file → OpenSCAD CLI → .stl (temp) → serve to browser → Three.js renders in 3D
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
1. Server receives a `.scad` file path
|
|
18
|
+
2. Server invokes `openscad -o /tmp/output.stl input.scad` to compile
|
|
19
|
+
3. Server serves the resulting STL to the browser via HTTP/WebSocket
|
|
20
|
+
4. Browser renders the STL using Three.js with orbit camera controls
|
|
21
|
+
|
|
22
|
+
**For `.stl` files:**
|
|
23
|
+
```
|
|
24
|
+
.stl file → serve directly to browser → Three.js renders in 3D
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
1. Server receives a `.stl` file path
|
|
28
|
+
2. Server serves the STL directly to the browser (no compilation)
|
|
29
|
+
3. Browser renders using Three.js — same viewer, same controls
|
|
30
|
+
|
|
31
|
+
## CLI
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
npx openscad-viewer <file.scad|file.stl>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- **File argument is required** — errors if not provided
|
|
38
|
+
- **Fixed port**: `8439` — fails if the port is already in use
|
|
39
|
+
- **Auto-opens** the default browser to `http://localhost:8439`
|
|
40
|
+
- Puts the filename in the browser title bar
|
|
41
|
+
|
|
42
|
+
## Browser UI
|
|
43
|
+
|
|
44
|
+
### 3D Viewport
|
|
45
|
+
|
|
46
|
+
- Three.js renderer displaying the compiled STL model
|
|
47
|
+
- Orbit-style camera controls: rotate, pan, zoom (OrbitControls)
|
|
48
|
+
- Default camera position: isometric view at a reasonable distance from the model bounding box
|
|
49
|
+
|
|
50
|
+
### Error Display
|
|
51
|
+
|
|
52
|
+
- On `.scad` compilation error: **keep the last successfully rendered model visible** and **overlay the OpenSCAD error message** (toast/banner) so the user sees both the last good state and what went wrong
|
|
53
|
+
|
|
54
|
+
### Title Bar
|
|
55
|
+
|
|
56
|
+
- Shows the opened file name
|
|
57
|
+
|
|
58
|
+
## MCP Tools (stdio transport)
|
|
59
|
+
|
|
60
|
+
The viewer process itself is the MCP server, communicating via stdin/stdout.
|
|
61
|
+
|
|
62
|
+
### `open`
|
|
63
|
+
|
|
64
|
+
Opens a `.scad` file in the viewer.
|
|
65
|
+
|
|
66
|
+
**Parameters:**
|
|
67
|
+
|
|
68
|
+
| Name | Type | Required | Description |
|
|
69
|
+
|------|------|----------|-------------|
|
|
70
|
+
| `file` | string | yes | Absolute or relative path to a `.scad` or `.stl` file |
|
|
71
|
+
|
|
72
|
+
**Returns:**
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"success": true,
|
|
77
|
+
"file": "/absolute/path/to/model.scad",
|
|
78
|
+
"fileSize": 2048,
|
|
79
|
+
"boundingBox": { "min": [-10, -10, 0], "max": [10, 10, 20] }
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Errors:**
|
|
84
|
+
|
|
85
|
+
- File not found
|
|
86
|
+
- Unsupported file type (not `.scad` or `.stl`)
|
|
87
|
+
- OpenSCAD compilation failure (include the stderr output) — `.scad` only
|
|
88
|
+
|
|
89
|
+
### `view`
|
|
90
|
+
|
|
91
|
+
Renders the current model at a specified camera angle and returns an image.
|
|
92
|
+
|
|
93
|
+
**Parameters:**
|
|
94
|
+
|
|
95
|
+
| Name | Type | Required | Default | Description |
|
|
96
|
+
|------|------|----------|---------|-------------|
|
|
97
|
+
| `azimuth` | number | no | 45 | Horizontal angle in degrees (0-360) |
|
|
98
|
+
| `elevation` | number | no | 30 | Vertical angle in degrees (-90 to 90) |
|
|
99
|
+
| `distance` | number | no | auto | Distance from model center. Auto-calculated from bounding box if omitted. |
|
|
100
|
+
|
|
101
|
+
**Returns:**
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"imagePath": "/tmp/openscad-viewer-capture-xxxxx.png",
|
|
106
|
+
"camera": { "azimuth": 45, "elevation": 30, "distance": 100 }
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The image is a PNG screenshot of the Three.js viewport at the requested angle.
|
|
111
|
+
|
|
112
|
+
**Side effect:** The browser camera **animates smoothly** to the requested position, so the user sees what the agent is looking at.
|
|
113
|
+
|
|
114
|
+
**Errors:**
|
|
115
|
+
|
|
116
|
+
- No model currently loaded
|
|
117
|
+
|
|
118
|
+
## Real-Time Communication
|
|
119
|
+
|
|
120
|
+
**WebSocket** connection between server and browser for:
|
|
121
|
+
|
|
122
|
+
1. **Model updates**: When the STL is recompiled (due to file change or new `open`), push the new geometry to the browser
|
|
123
|
+
2. **Camera sync**: When an agent calls `view`, push the target camera position to the browser, which animates to it
|
|
124
|
+
3. **Error notifications**: Push compilation errors to the browser for overlay display
|
|
125
|
+
|
|
126
|
+
## File Watching
|
|
127
|
+
|
|
128
|
+
- Watch the opened file for changes
|
|
129
|
+
- **For `.scad` files**, also watch `include`/`use` dependencies — parse for `include <...>` and `use <...>` statements and watch those files too
|
|
130
|
+
- On any watched file change:
|
|
131
|
+
- **`.scad`**: Re-run OpenSCAD CLI compilation. If successful, push new STL to browser. If error, push error message (keep last good model).
|
|
132
|
+
- **`.stl`**: Re-serve the updated file directly to the browser.
|
|
133
|
+
- Debounce rapid changes (300ms) to avoid excessive recompilation
|
|
134
|
+
|
|
135
|
+
## Architecture
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
Node.js + Express + React
|
|
139
|
+
|
|
140
|
+
┌─────────────┐ stdio ┌──────────────────┐ WebSocket ┌─────────────┐
|
|
141
|
+
│ AI Agent │◄──────────►│ Node.js Server │◄─────────────►│ Browser │
|
|
142
|
+
│ (MCP client)│ │ (Express + MCP) │ │ (React + │
|
|
143
|
+
└─────────────┘ │ │ HTTP/GET │ Three.js) │
|
|
144
|
+
│ - file watcher │──────────────►│ │
|
|
145
|
+
│ - OpenSCAD CLI │ (STL files) └─────────────┘
|
|
146
|
+
└──────────────────┘
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Scope Boundaries (Explicitly Out of Scope)
|
|
150
|
+
|
|
151
|
+
- Multiple simultaneous model files (one at a time only)
|
|
152
|
+
- Formats beyond `.scad` and `.stl` (no OBJ, 3MF, etc.)
|
|
153
|
+
- OpenSCAD customizer variable editing
|
|
154
|
+
- Lighting or material controls in the UI
|
|
155
|
+
- Remote/network access (localhost only)
|