scad-gltf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +489 -0
  2. package/bin/scad-convert.js +221 -0
  3. package/bin/scad-godot.js +247 -0
  4. package/bin/scad-mcp.js +508 -0
  5. package/bin/scad-serve.js +225 -0
  6. package/bin/scad-web.js +206 -0
  7. package/editor/dist/aristea_wreck_puresky_2k.hdr +0 -0
  8. package/editor/dist/assets/OutputPass-Bvl6NigM.js +4317 -0
  9. package/editor/dist/assets/index-V9cEH4KX.js +4318 -0
  10. package/editor/dist/assets/index-t9MYrExo.css +1 -0
  11. package/editor/dist/assets/openscad-CdBCY4mx.wasm +0 -0
  12. package/editor/dist/assets/preview-C1zc24MJ.js +1 -0
  13. package/editor/dist/assets/preview-fQfL_FJ-.css +1 -0
  14. package/editor/dist/assets/prompt-ui-8EFRz0ju.js +126 -0
  15. package/editor/dist/content-loader.js +4 -0
  16. package/editor/dist/content.css +255 -0
  17. package/editor/dist/content.js +24 -0
  18. package/editor/dist/icon.png +0 -0
  19. package/editor/dist/index.html +187 -0
  20. package/editor/dist/manifest.json +23 -0
  21. package/editor/dist/manifest.webmanifest +1 -0
  22. package/editor/dist/preview.html +27 -0
  23. package/editor/dist/registerSW.js +1 -0
  24. package/editor/dist/sw.js +1 -0
  25. package/editor/dist/workbox-9c191d2f.js +1 -0
  26. package/godot/README.md +64 -0
  27. package/godot/addons/scad_importer/plugin.cfg +7 -0
  28. package/godot/addons/scad_importer/scad_importer.gd +197 -0
  29. package/godot/addons/scad_importer/scad_plugin.gd +12 -0
  30. package/godot/examples/README.md +82 -0
  31. package/godot/examples/fruit_fusion_3d.js +1574 -0
  32. package/godot/examples/package.json +5 -0
  33. package/package.json +63 -0
  34. package/src/convert.js +94 -0
  35. package/src/ext/openscad.js +14 -0
  36. package/src/ext/openscad.wasm +0 -0
  37. package/src/prompt.js +229 -0
@@ -0,0 +1,508 @@
1
+ #!/usr/bin/env node
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import {
8
+ CallToolRequestSchema,
9
+ ListToolsRequestSchema,
10
+ } from "@modelcontextprotocol/sdk/types.js";
11
+ import puppeteer from "puppeteer";
12
+ import { convertScadToGltf } from "../src/convert.js";
13
+ import { generatePrompt } from "../src/prompt.js";
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = path.dirname(__filename);
17
+ const wasmPath = path.resolve(__dirname, "../src/ext/openscad.wasm");
18
+
19
+ // Polyfill fetch so the WASM loader works natively in Node.js
20
+ const originalFetch = global.fetch;
21
+ global.fetch = async (url, options) => {
22
+ const urlStr = url.toString();
23
+ if (urlStr.startsWith("file://") || urlStr.endsWith(".wasm")) {
24
+ const normalizedPath = urlStr.startsWith("file://")
25
+ ? fileURLToPath(urlStr)
26
+ : urlStr;
27
+
28
+ const buffer = fs.readFileSync(normalizedPath);
29
+ return new Response(buffer, {
30
+ status: 200,
31
+ headers: { "Content-Type": "application/wasm" },
32
+ });
33
+ }
34
+ return originalFetch ? originalFetch(url, options) : undefined;
35
+ };
36
+
37
+ // Initialize MCP Server
38
+ const server = new Server(
39
+ {
40
+ name: "scad-mcp-server",
41
+ version: "1.2.0",
42
+ },
43
+ {
44
+ capabilities: {
45
+ tools: {},
46
+ },
47
+ },
48
+ );
49
+
50
+ // Define MCP Tools
51
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
52
+ return {
53
+ tools: [
54
+ {
55
+ name: "get_scad_prompt",
56
+ description:
57
+ "Generates the specialized instruction prompt containing the required syntax rules for PBR, Animations, and Texture Baking in this custom OpenSCAD environment.",
58
+ inputSchema: {
59
+ type: "object",
60
+ properties: {
61
+ description: {
62
+ type: "string",
63
+ description: "The description of the object you want to design.",
64
+ },
65
+ },
66
+ required: ["description"],
67
+ },
68
+ },
69
+ {
70
+ name: "render_scad_model",
71
+ description:
72
+ "Converts OpenSCAD code to GLTF and uses a 3D renderer to capture images from requested camera angles. Analyze these returned images to verify your design, including specific frames of your animations.",
73
+ inputSchema: {
74
+ type: "object",
75
+ properties: {
76
+ scad_code: {
77
+ type: "string",
78
+ description: "The raw OpenSCAD code to convert and render.",
79
+ },
80
+ camera_angles: {
81
+ type: "array",
82
+ items: {
83
+ type: "string",
84
+ enum: [
85
+ "front",
86
+ "back",
87
+ "left",
88
+ "right",
89
+ "top",
90
+ "bottom",
91
+ "isometric",
92
+ ],
93
+ },
94
+ description:
95
+ "Array of camera angles to render. Defaults to ['front', 'top', 'isometric']. Use this to inspect specific sides of your model.",
96
+ },
97
+ animation_time: {
98
+ type: "number",
99
+ description:
100
+ "The time in seconds to evaluate the animation at (e.g. 1.5). Default is 0.0. Useful for verifying moving parts.",
101
+ },
102
+ animation_index: {
103
+ type: "number",
104
+ description:
105
+ "The index of the animation track to play if multiple exist. Default is 0.",
106
+ },
107
+ },
108
+ required: ["scad_code"],
109
+ },
110
+ },
111
+ ],
112
+ };
113
+ });
114
+
115
+ // Tool Handlers
116
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
117
+ const { name, arguments: args } = request.params;
118
+
119
+ // ------------------------------------------
120
+ // TOOL 1: get_scad_prompt
121
+ // ------------------------------------------
122
+ if (name === "get_scad_prompt") {
123
+ try {
124
+ const promptText = generatePrompt(args.description, {});
125
+ return {
126
+ content: [
127
+ {
128
+ type: "text",
129
+ text: promptText,
130
+ },
131
+ ],
132
+ };
133
+ } catch (error) {
134
+ return {
135
+ content: [
136
+ { type: "text", text: `Error generating prompt: ${error.message}` },
137
+ ],
138
+ isError: true,
139
+ };
140
+ }
141
+ }
142
+
143
+ // ------------------------------------------
144
+ // TOOL 2: render_scad_model
145
+ // ------------------------------------------
146
+ if (name === "render_scad_model") {
147
+ let browser;
148
+ try {
149
+ const scadCode = args.scad_code;
150
+ const requestedAngles =
151
+ args.camera_angles && args.camera_angles.length > 0
152
+ ? args.camera_angles
153
+ : ["front", "top", "isometric"];
154
+ const animTime = args.animation_time || 0.0;
155
+ const animIndex = args.animation_index || 0;
156
+
157
+ // 1. Convert SCAD to GLB ArrayBuffer
158
+ const glbDataArray = await convertScadToGltf(scadCode, {
159
+ wasmUrl: `file://${wasmPath}`,
160
+ });
161
+
162
+ // Convert Uint8Array to base64
163
+ const glbBase64 = Buffer.from(glbDataArray).toString("base64");
164
+
165
+ // 2. Launch Puppeteer Headless to Render using Three.js
166
+ browser = await puppeteer.launch({
167
+ headless: true,
168
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
169
+ });
170
+ const page = await browser.newPage();
171
+
172
+ // Provide an importmap so Puppeteer can load three.js modules cleanly
173
+ const html = `
174
+ <!DOCTYPE html>
175
+ <html>
176
+ <head>
177
+ <style>body{margin:0; overflow:hidden;}</style>
178
+ <script type="importmap">
179
+ {
180
+ "imports": {
181
+ "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
182
+ "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
183
+ }
184
+ }
185
+ </script>
186
+ </head>
187
+ <body></body>
188
+ </html>
189
+ `;
190
+ await page.goto(`data:text/html,${encodeURIComponent(html)}`);
191
+
192
+ // 3. Inject rendering logic and return base64 snapshots
193
+ const snapshots = await page.evaluate(
194
+ async (base64Glb, angles, targetAnimTime, targetAnimIndex) => {
195
+ const THREE = await import("three");
196
+ const { GLTFLoader } =
197
+ await import("three/addons/loaders/GLTFLoader.js");
198
+ const { RoomEnvironment } =
199
+ await import("three/addons/environments/RoomEnvironment.js");
200
+
201
+ const width = 512;
202
+ const height = 512;
203
+ const renderer = new THREE.WebGLRenderer({
204
+ antialias: true,
205
+ alpha: false,
206
+ preserveDrawingBuffer: true,
207
+ });
208
+ renderer.setSize(width, height);
209
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
210
+ document.body.appendChild(renderer.domElement);
211
+
212
+ const scene = new THREE.Scene();
213
+ scene.background = new THREE.Color(0x222222);
214
+
215
+ const pmremGenerator = new THREE.PMREMGenerator(renderer);
216
+ scene.environment = pmremGenerator.fromScene(
217
+ new RoomEnvironment(),
218
+ 0.04,
219
+ ).texture;
220
+
221
+ // Soft global ambient light
222
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.3);
223
+ scene.add(ambientLight);
224
+
225
+ // Hemisphere light for natural sky/ground illumination
226
+ const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.4);
227
+ hemiLight.position.set(0, 20, 0);
228
+ scene.add(hemiLight);
229
+
230
+ const camera = new THREE.PerspectiveCamera(
231
+ 45,
232
+ width / height,
233
+ 0.1,
234
+ 1000,
235
+ );
236
+ scene.add(camera);
237
+
238
+ // 3-Point Lighting Rig attached to camera for consistent illumination from all angles
239
+
240
+ // Key Light (Main illumination from top-right)
241
+ const keyLight = new THREE.DirectionalLight(0xffffff, 1.2);
242
+ keyLight.position.set(10, 10, 5);
243
+ camera.add(keyLight);
244
+ camera.add(keyLight.target);
245
+ keyLight.target.position.set(0, 0, -5);
246
+
247
+ // Fill Light (Softer light from bottom-left to reduce shadows)
248
+ const fillLight = new THREE.DirectionalLight(0xd0e0ff, 0.6);
249
+ fillLight.position.set(-10, -2, 5);
250
+ camera.add(fillLight);
251
+ camera.add(fillLight.target);
252
+ fillLight.target.position.set(0, 0, -5);
253
+
254
+ // Rim Light (Highlight edges from behind the object)
255
+ const rimLight = new THREE.DirectionalLight(0xffeedd, 0.8);
256
+ rimLight.position.set(0, 10, -15);
257
+ camera.add(rimLight);
258
+ camera.add(rimLight.target);
259
+ rimLight.target.position.set(0, 0, -5);
260
+
261
+ // Load the model from base64
262
+ const loader = new GLTFLoader();
263
+ const dataUrl = "data:application/octet-stream;base64," + base64Glb;
264
+ const gltf = await loader.loadAsync(dataUrl);
265
+ scene.add(gltf.scene);
266
+
267
+ // --- Apply Animation State if requested ---
268
+ let appliedAnim = false;
269
+ if (gltf.animations && gltf.animations.length > 0) {
270
+ const mixer = new THREE.AnimationMixer(gltf.scene);
271
+ // Clamp index to available animations
272
+ const clipIndex = Math.min(
273
+ Math.max(targetAnimIndex, 0),
274
+ gltf.animations.length - 1,
275
+ );
276
+ const clip = gltf.animations[clipIndex];
277
+
278
+ if (clip) {
279
+ const action = mixer.clipAction(clip);
280
+ action.play();
281
+ // Advance the mixer exactly to the requested time.
282
+ // If targetAnimTime > duration, modulo it so it loops naturally like a real animation.
283
+ const duration = clip.duration;
284
+ const finalTime = duration > 0 ? targetAnimTime % duration : 0;
285
+
286
+ mixer.setTime(finalTime);
287
+ appliedAnim = true;
288
+ }
289
+ }
290
+
291
+ // Wait for matrices to update properly after animation applied
292
+ scene.updateMatrixWorld(true);
293
+
294
+ // Calculate bounding box to find initial anchor center and scale
295
+ const box = new THREE.Box3().setFromObject(gltf.scene);
296
+ if (box.isEmpty()) {
297
+ box.setFromCenterAndSize(
298
+ new THREE.Vector3(0, 0, 0),
299
+ new THREE.Vector3(1, 1, 1),
300
+ );
301
+ }
302
+ const center = box.getCenter(new THREE.Vector3());
303
+ const size = box.getSize(new THREE.Vector3());
304
+ const initialDim = Math.max(size.x, size.y, size.z) || 10;
305
+
306
+ // Extract all world-space vertices
307
+ const vertices = [];
308
+ const v3 = new THREE.Vector3();
309
+ gltf.scene.traverse((child) => {
310
+ if (
311
+ child.isMesh &&
312
+ child.geometry &&
313
+ child.geometry.attributes.position
314
+ ) {
315
+ const pos = child.geometry.attributes.position;
316
+ const matrix = child.matrixWorld;
317
+ for (let i = 0; i < pos.count; i++) {
318
+ v3.fromBufferAttribute(pos, i).applyMatrix4(matrix);
319
+ vertices.push(v3.x, v3.y, v3.z);
320
+ }
321
+ }
322
+ });
323
+
324
+ if (vertices.length === 0) {
325
+ vertices.push(center.x, center.y, center.z);
326
+ }
327
+
328
+ const fovY = (camera.fov * Math.PI) / 180;
329
+ const tanY = Math.tan(fovY / 2);
330
+ const tanX = tanY * camera.aspect;
331
+
332
+ const results = [];
333
+ for (const angle of angles) {
334
+ const dir = new THREE.Vector3();
335
+ switch (angle.toLowerCase()) {
336
+ case "front":
337
+ dir.set(0, 0, 1);
338
+ break;
339
+ case "back":
340
+ dir.set(0, 0, -1);
341
+ break;
342
+ case "left":
343
+ dir.set(-1, 0, 0);
344
+ break;
345
+ case "right":
346
+ dir.set(1, 0, 0);
347
+ break;
348
+ case "top":
349
+ dir.set(0, 1, 0);
350
+ break;
351
+ case "bottom":
352
+ dir.set(0, -1, 0);
353
+ break;
354
+ case "isometric":
355
+ default:
356
+ dir.set(1, 1, 1).normalize();
357
+ break;
358
+ }
359
+
360
+ let viewCenter = center.clone();
361
+ let distance = initialDim * 2.5;
362
+
363
+ // Iterate to converge perspective NDC screen bounds to exact center and margin
364
+ for (let iter = 0; iter < 5; iter++) {
365
+ camera.near = Math.max(0.01, initialDim * 0.01);
366
+ camera.far = Math.max(1000, distance * 10);
367
+ camera.updateProjectionMatrix();
368
+
369
+ camera.position
370
+ .copy(viewCenter)
371
+ .add(dir.clone().multiplyScalar(distance));
372
+ camera.lookAt(viewCenter);
373
+ camera.updateMatrixWorld();
374
+ camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
375
+
376
+ const pvMatrix = new THREE.Matrix4().multiplyMatrices(
377
+ camera.projectionMatrix,
378
+ camera.matrixWorldInverse,
379
+ );
380
+ const pme = pvMatrix.elements;
381
+
382
+ let minNdcX = Infinity,
383
+ maxNdcX = -Infinity;
384
+ let minNdcY = Infinity,
385
+ maxNdcY = -Infinity;
386
+
387
+ for (let i = 0; i < vertices.length; i += 3) {
388
+ const x = vertices[i];
389
+ const y = vertices[i + 1];
390
+ const z = vertices[i + 2];
391
+
392
+ const nx = x * pme[0] + y * pme[4] + z * pme[8] + pme[12];
393
+ const ny = x * pme[1] + y * pme[5] + z * pme[9] + pme[13];
394
+ const nw = x * pme[3] + y * pme[7] + z * pme[11] + pme[15];
395
+
396
+ if (nw > 0) {
397
+ const ndcX = nx / nw;
398
+ const ndcY = ny / nw;
399
+ if (ndcX < minNdcX) minNdcX = ndcX;
400
+ if (ndcX > maxNdcX) maxNdcX = ndcX;
401
+ if (ndcY < minNdcY) minNdcY = ndcY;
402
+ if (ndcY > maxNdcY) maxNdcY = ndcY;
403
+ }
404
+ }
405
+
406
+ if (minNdcX === Infinity) break;
407
+
408
+ const midNdcX = (minNdcX + maxNdcX) / 2;
409
+ const midNdcY = (minNdcY + maxNdcY) / 2;
410
+ const spanX = (maxNdcX - minNdcX) / 2;
411
+ const spanY = (maxNdcY - minNdcY) / 2;
412
+
413
+ const worldShift = new THREE.Vector3(
414
+ midNdcX * distance * tanX,
415
+ midNdcY * distance * tanY,
416
+ 0,
417
+ ).applyQuaternion(camera.quaternion);
418
+
419
+ viewCenter.add(worldShift);
420
+ const maxSpan = Math.max(spanX, spanY);
421
+ distance = Math.max(distance * maxSpan * 1.08, camera.near + 0.1);
422
+ }
423
+
424
+ camera.near = Math.max(0.01, initialDim * 0.01);
425
+ camera.far = Math.max(1000, distance * 10);
426
+ camera.updateProjectionMatrix();
427
+
428
+ // Final render with converged camera setup
429
+ camera.position
430
+ .copy(viewCenter)
431
+ .add(dir.clone().multiplyScalar(distance));
432
+ camera.lookAt(viewCenter);
433
+ camera.updateMatrixWorld();
434
+
435
+ renderer.render(scene, camera);
436
+
437
+ const b64 = renderer.domElement
438
+ .toDataURL("image/png")
439
+ .split(",")[1];
440
+ results.push({ name: angle, data: b64, appliedAnim });
441
+ }
442
+
443
+ return results;
444
+ },
445
+ glbBase64,
446
+ requestedAngles,
447
+ animTime,
448
+ animIndex,
449
+ );
450
+
451
+ // 4. Close browser
452
+ await browser.close();
453
+
454
+ // 5. Construct the MCP Response
455
+ const appliedAnimStr = snapshots[0].appliedAnim
456
+ ? ` at animation time ${animTime}s (Track ${animIndex})`
457
+ : ` (Static Model)`;
458
+
459
+ const content = [
460
+ {
461
+ type: "text",
462
+ text: `Successfully compiled SCAD and rendered ${snapshots.length} camera angle(s)${appliedAnimStr}. Please analyze these visual results to determine your next adjustments.`,
463
+ },
464
+ ];
465
+
466
+ for (const snap of snapshots) {
467
+ const angleName =
468
+ snap.name.charAt(0).toUpperCase() + snap.name.slice(1);
469
+ content.push({
470
+ type: "text",
471
+ text: `${angleName} View:`,
472
+ });
473
+ content.push({
474
+ type: "image",
475
+ data: snap.data,
476
+ mimeType: "image/png",
477
+ });
478
+ }
479
+
480
+ return { content };
481
+ } catch (error) {
482
+ if (browser) await browser.close();
483
+ return {
484
+ content: [
485
+ {
486
+ type: "text",
487
+ text: `Failed to compile or render OpenSCAD model. Error: ${error.message}\nIf this is a syntax error, review your OpenSCAD code and try again.`,
488
+ },
489
+ ],
490
+ isError: true,
491
+ };
492
+ }
493
+ }
494
+
495
+ throw new Error(`Tool not found: ${name}`);
496
+ });
497
+
498
+ // Run Server
499
+ async function main() {
500
+ const transport = new StdioServerTransport();
501
+ await server.connect(transport);
502
+ console.error("🚀 SCAD MCP Server running on stdio");
503
+ }
504
+
505
+ main().catch((error) => {
506
+ console.error("Server error:", error);
507
+ process.exit(1);
508
+ });