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 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)