scad-gltf 0.3.3 → 0.5.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.
package/README.md CHANGED
@@ -16,6 +16,7 @@ The C++ source code for this custom OpenSCAD version is included directly in thi
16
16
  - **Extended PBR Material Support:** Native extensions to the OpenSCAD `color()` module supporting `metalness`, `roughness`, `transmission` (glass), `thickness`, `ior`, `attenuationColor`, `attenuationDistance`, `clearcoat`, `sheen`, `emissive`, `specular`, and `iridescence`, plus a `$asa` special variable for auto smooth shading.
17
17
  - **Hierarchical Node Animation:** Define articulated, rigid-body hierarchies and keyframe animations using `armature()` and `bone()` modules. Exports proper glTF node transform tracks (rigid parenting rather than vertex-weighted skinning—ideal for robots, mechanical parts, vehicles, and articulated components).
18
18
  - **Texture Baking:** Automatically generate UVs and bake high-poly details (colors, normals, ORM) onto low-poly meshes using the new `bake()` module.
19
+ - **Scene Lighting:** Spawn precise light sources within your hierarchy (point, directional, spot) using the `light()` module, exporting directly to glTF.
19
20
  - **Web Editor & Real-time Viewer (Scadify):** In-browser IDE with live WebAssembly compilation, GPU path tracing, animation timeline scrubbing, video/image export, URL sharing, and `.scad` / `.hdr` drag-and-drop.
20
21
  - **LLM Friendly:** Includes a built-in modular prompt generator (`prompt.js` and Web UI) to help AI models (like Gemini, Claude, or ChatGPT) write compatible OpenSCAD scripts utilizing the new features.
21
22
  - **Local API Server & Editor:** Bundled `scad-gltf serve` CLI utility to manage local `.scad` files remotely via REST API with automatic `include`/`use` dependency resolution.
@@ -24,6 +25,7 @@ The C++ source code for this custom OpenSCAD version is included directly in thi
24
25
  - **AI Studio Extension:** Chrome extension to natively preview, prompt, take chat snapshots, open in Scadify, and locally save AI-generated 3D models directly inside Google AI Studio.
25
26
  - **Godot 4, Rust Bevy & Web Integration:** Native Godot 4 importer addon and Rust Bevy compile-time `build.rs` integration.
26
27
  - **Automated AI Generation:** Pass an OpenAI-compatible base URL (and optional API key) to the `scad-gltf gen` CLI tool to automatically spawn a local MCP server, query the LLM via standard endpoints, allow it to visually iterate, and output a ready-to-run game project or raw 3D asset script.
28
+ - **Project Runner:** Bundled `scad-gltf play` CLI utility to safely unpack, build, and launch generated game projects in isolated temporary environments.
27
29
 
28
30
  ---
29
31
 
@@ -251,7 +253,7 @@ When asking an LLM (like Gemini) to generate OpenSCAD code, the extension automa
251
253
 
252
254
  ---
253
255
 
254
- ## 🤖 Model Context Protocol (MCP) Server
256
+ ## 🤖 Model Context Protocol Server (`scad-gltf mcp`)
255
257
 
256
258
  This package includes a native [MCP Server](https://modelcontextprotocol.io/) (`scad-gltf mcp`) designed to give AI assistants **visual feedback** during the 3D modeling process.
257
259
 
@@ -259,7 +261,7 @@ Instead of generating code blindly, the AI can compile its script, render the 3D
259
261
 
260
262
  **Exposed MCP Tools:**
261
263
 
262
- - `get_scad_prompt`: Injects the custom OpenSCAD syntax rules (PBR, animations, baking) into the AI's context.
264
+ - `get_project_prompt`: Generates the specialized system prompt and user input request for creating full procedural 3D projects (Godot, Web, Rust, or pure SCAD).
263
265
  - `render_scad_model`: Compiles the generated `.scad` code to GLB and returns base64 images from requested camera angles (front, back, left, right, top, bottom, isometric) and specific animation keyframes.
264
266
  - `compile_rust_project` / `test_godot_project`: Tests the generated game projects for compilation and runtime errors.
265
267
 
@@ -282,27 +284,16 @@ If you installed the package globally, you can configure your MCP client to use
282
284
 
283
285
  ---
284
286
 
285
- ## 🎮 Godot Engine Integration
286
-
287
- This repository includes an official **Godot 4.x Importer Addon** located in the [`/godot`](./godot) directory.
288
-
289
- The addon allows you to drag-and-drop `.scad` files directly into your Godot project. It uses this WASM compiler under the hood to transform scripts into 3D scenes automatically.
290
-
291
- - **Features:** Supports PBR Materials and Hierarchical Node Animations inside the Godot Editor.
292
- - **License:** The Godot Addon is licensed under **MIT**.
293
- - **Setup:** Simply copy the `addons/scad_importer` folder to your project and enable it in Project Settings.
294
-
295
- ---
296
-
297
- ### Automated AI Generation (OpenAI-Compatible API)
287
+ ## Automated AI Generation (`scad-gltf gen`)
298
288
 
299
289
  By default, the `scad-gltf gen` utility copies a heavily engineered system prompt to your clipboard to paste into an LLM. However, if you provide a **Base URL** (and optional **API Key**), the CLI will fully automate this process. It will automatically spin up the `scad-gltf mcp` server in the background, connect it to your LLM, and allow the AI to _visually evaluate and fix_ its code in real-time before saving the final `generate_project.js` script to your disk.
300
290
 
301
291
  **Supported Generation Types:**
302
292
 
303
- - `gen`: Generate a single raw `.scad` asset file.
293
+ - `scad`: Generate a single raw `.scad` asset file.
304
294
  - `godot`: Generate a fully packaged Godot 4 game project containing generated assets and GDScript logic.
305
295
  - `bevy`: Generate a Rust Bevy game project.
296
+ - `wgpu`: Generate a Rust wgpu graphics project.
306
297
  - `web`: Generate a Vite-based Web3D app (Three.js/Babylon).
307
298
 
308
299
  **Option 1: Using OpenAI (GPT-4o)**
@@ -340,7 +331,20 @@ You can also pass credentials inline as a JSON string argument instead of modify
340
331
  scad-gltf gen godot "A futuristic tank game" '{"openaiBaseUrl": "http://127.0.0.1:8080/v1", "openaiModel": "llama-3"}'
341
332
  ```
342
333
 
343
- _Once the automated process completes for full game projects, simply run the generated Node.js script to assemble your complete Godot, Bevy, or Vite project structure with all assets and code!_
334
+ **Providing Existing SCAD Files (`scadFiles`)**
335
+ You can inject your own local `.scad` files into the AI's context using the `scadFiles` JSON option. The AI will embed them into the generated project or use them as a foundation.
336
+
337
+ ```bash
338
+ scad-gltf gen godot "A game using the provided player model" '{"scadFiles": ["./player.scad"]}'
339
+ ```
340
+
341
+ _Once the automated process completes for full game projects, you can test it instantly using the `play` command:_
342
+
343
+ ```bash
344
+ scad-gltf play generate_godot_project.js
345
+ ```
346
+
347
+ _Alternatively, to extract the final project permanently into your current directory, just run the script with Node:_
344
348
 
345
349
  ```bash
346
350
  node generate_godot_project.js
@@ -348,6 +352,18 @@ node generate_godot_project.js
348
352
 
349
353
  ---
350
354
 
355
+ ## Testing Generated Projects (`scad-gltf play`)
356
+
357
+ The `scad-gltf play` utility is designed to quickly test the single-file Node.js scripts produced by the `gen` command. Instead of cluttering your working directory, it unpacks the project into a temporary folder, automatically installs dependencies or builds the project (Godot, Rust Bevy, or Vite), and launches it. Once you close the application, the temporary folder is automatically cleaned up.
358
+
359
+ **Usage:**
360
+
361
+ ```bash
362
+ scad-gltf play <path_to_generated_script.js>
363
+ ```
364
+
365
+ ---
366
+
351
367
  ## Extended OpenSCAD Syntax
352
368
 
353
369
  This custom fork introduces new syntax not found in standard OpenSCAD.
@@ -440,6 +456,17 @@ bake(uvs=true) {
440
456
  }
441
457
  ```
442
458
 
459
+ ### 4. Scene Lighting
460
+
461
+ Add glTF lights directly into your model hierarchy:
462
+
463
+ ```openscad
464
+ // A spot light pointing down at an angle
465
+ translate([0, 0, 50]) rotate([0, 30, 0])
466
+ light(type="spot", color=[1, 0.8, 0.8], intensity=5.0, innerConeAngle=15.0, outerConeAngle=30.0)
467
+ color("gray") cylinder(h=2, r=1, center=true); // A visual representation of the light fixture
468
+ ```
469
+
443
470
  ---
444
471
 
445
472
  ## AI Integration (`prompt.js`)
@@ -463,6 +490,7 @@ const promptContext = generatePrompt(description, {
463
490
  iridescence: true, // Thin-film interference
464
491
  autoSmoothAngle: true, // $asa rules
465
492
  animation: true, // Armature & bone syntax
493
+ lights: false, // Light rules
466
494
  bakeColors: false, // Texture baking flags
467
495
  bakeNormals: false,
468
496
  bakeOrm: false,
@@ -478,7 +506,7 @@ console.log(promptContext);
478
506
 
479
507
  Once connected, an AI assistant can use the server to execute the following loop:
480
508
 
481
- 1. **Retrieve Syntax Rules:** The assistant calls the `get_scad_prompt` tool to get the extended syntax rules for PBR materials, hierarchical node animations, and texture baking.
509
+ 1. **Retrieve Syntax Rules:** The assistant calls the `get_project_prompt` tool to get the extended syntax rules and project structure guidelines.
482
510
  2. **Generate Code:** The assistant writes the `.scad` script based on your design request.
483
511
  3. **Compile & Visually Inspect:** The assistant calls the `render_scad_model` tool to inspect rendered multi-angle frames and keyframes, fixing any errors before final export.
484
512
 
package/bin/gen.js CHANGED
@@ -14,7 +14,7 @@ async function main() {
14
14
  `Usage: scad-gltf gen <${VALID_TYPES.join("|")}> "<description>" [options_json]`,
15
15
  );
16
16
  console.error(
17
- `Example: scad-gltf gen godot "A 3D physics simulation" '{"openaiModel": "gpt-4o"}'`,
17
+ `Example: scad-gltf gen godot "A 3D physics simulation" '{"openaiModel": "gpt-4o", "scadFiles": ["./player.scad"]}'`,
18
18
  );
19
19
  process.exit(1);
20
20
  }
package/bin/mcp.js CHANGED
@@ -13,7 +13,9 @@ import {
13
13
  import puppeteer from "puppeteer";
14
14
  import { convertScadToGltf } from "../src/convert.js";
15
15
  import { generatePrompt } from "../src/prompt.js";
16
+ import { getProjectPrompts } from "../src/project-prompts.js";
16
17
  import { runGodotAsync } from "../src/godot-utils.js";
18
+ import { generateScadPreviewUrl } from "../src/cli-utils.js";
17
19
 
18
20
  const __filename = fileURLToPath(import.meta.url);
19
21
  const __dirname = path.dirname(__filename);
@@ -55,15 +57,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
55
57
  return {
56
58
  tools: [
57
59
  {
58
- name: "get_scad_prompt",
60
+ name: "get_project_prompt",
59
61
  description:
60
- "Generates the specialized instruction prompt containing the required syntax rules for PBR, Animations, and Texture Baking in this custom OpenSCAD environment.",
62
+ "Generates the specialized system prompt and user input request for creating full procedural 3D projects (Godot, Web, Rust, or pure SCAD). This provides the syntax rules and structure required to generate the project.",
61
63
  inputSchema: {
62
64
  type: "object",
63
65
  properties: {
66
+ project_type: {
67
+ type: "string",
68
+ enum: ["scad", "bevy", "wgpu", "web", "godot"],
69
+ description:
70
+ "The target framework or output type for the project.",
71
+ },
64
72
  description: {
65
73
  type: "string",
66
- description: "The description of the object you want to design.",
74
+ description:
75
+ "The description of the project or object you want to design.",
67
76
  },
68
77
  options: {
69
78
  type: "object",
@@ -78,17 +87,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
78
87
  transmission: {
79
88
  type: "boolean",
80
89
  description:
81
- "Include rules for transparent/glass volumes: transmission, thickness, ior, attenuationColor, and attenuationDistance. (Default: true)",
90
+ "Include rules for transparent/glass volumes. (Default: false)",
82
91
  },
83
92
  clearcoat: {
84
93
  type: "boolean",
85
94
  description:
86
- "Include rules for clearcoat and clearcoatRoughness. (Default: true)",
95
+ "Include rules for clearcoat and clearcoatRoughness. (Default: false)",
87
96
  },
88
97
  sheen: {
89
98
  type: "boolean",
90
99
  description:
91
- "Include rules for cloth/velvet sheen: sheen, sheenColor, and sheenRoughness. (Default: true)",
100
+ "Include rules for cloth/velvet sheen. (Default: false)",
92
101
  },
93
102
  emissive: {
94
103
  type: "boolean",
@@ -98,12 +107,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
98
107
  specular: {
99
108
  type: "boolean",
100
109
  description:
101
- "Include rules for specular reflections: specularColor and specularIntensity. (Default: true)",
110
+ "Include rules for specular reflections. (Default: true)",
102
111
  },
103
112
  iridescence: {
104
113
  type: "boolean",
105
114
  description:
106
- "Include rules for thin-film interference: iridescence and iridescenceIOR. (Default: true)",
115
+ "Include rules for thin-film interference. (Default: false)",
107
116
  },
108
117
  autoSmoothAngle: {
109
118
  type: "boolean",
@@ -140,21 +149,27 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
140
149
  description:
141
150
  "Include rules for the lazy-union compiler optimization. (Default: false)",
142
151
  },
143
- modelName: {
152
+ lights: {
144
153
  type: "boolean",
145
154
  description:
146
- "Include output instructions requiring the '/* Model Name: ... */' header comment. (Default: true)",
155
+ "Include rules for adding scene lights. (Default: false)",
156
+ },
157
+ scadFiles: {
158
+ type: "array",
159
+ items: { type: "string" },
160
+ description:
161
+ "Array of file paths to existing .scad files to embed as context.",
147
162
  },
148
163
  },
149
164
  },
150
165
  },
151
- required: ["description"],
166
+ required: ["project_type", "description"],
152
167
  },
153
168
  },
154
169
  {
155
170
  name: "render_scad_model",
156
171
  description:
157
- "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.",
172
+ "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. The tool response will also include a GitHub-hosted Live Editor Preview URL—you MUST share this URL with the user so they can view the model interactively.",
158
173
  inputSchema: {
159
174
  type: "object",
160
175
  properties: {
@@ -257,23 +272,54 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
257
272
  const { name, arguments: args } = request.params;
258
273
 
259
274
  // ------------------------------------------
260
- // TOOL 1: get_scad_prompt
275
+ // TOOL 1: get_project_prompt
261
276
  // ------------------------------------------
262
- if (name === "get_scad_prompt") {
277
+ if (name === "get_project_prompt") {
263
278
  try {
264
- const promptText = generatePrompt(args.description, args.options || {});
279
+ const pType = args.project_type;
280
+ const desc = args.description;
281
+ const opts = {
282
+ transmission: false,
283
+ clearcoat: false,
284
+ sheen: false,
285
+ iridescence: false,
286
+ ...(args.options || {}),
287
+ };
288
+
289
+ if (!["scad", "bevy", "wgpu", "web", "godot"].includes(pType)) {
290
+ throw new Error(`Invalid project type: ${pType}`);
291
+ }
292
+
293
+ if (pType !== "scad") {
294
+ opts.modelName = false;
295
+ }
296
+
297
+ const promptRules = generatePrompt(
298
+ pType === "scad" ? desc : "the 3D assets for the project",
299
+ opts,
300
+ );
301
+
302
+ const prompts = getProjectPrompts(pType);
303
+ const systemPrompt = prompts.buildSystemPrompt(promptRules, opts);
304
+ const userRequest = prompts.buildInputRequest
305
+ ? prompts.buildInputRequest(desc, promptRules)
306
+ : promptRules;
307
+
265
308
  return {
266
309
  content: [
267
310
  {
268
311
  type: "text",
269
- text: promptText,
312
+ text: `=== SYSTEM PROMPT ===\n${systemPrompt}\n\n=== USER REQUEST ===\n${userRequest}`,
270
313
  },
271
314
  ],
272
315
  };
273
316
  } catch (error) {
274
317
  return {
275
318
  content: [
276
- { type: "text", text: `Error generating prompt: ${error.message}` },
319
+ {
320
+ type: "text",
321
+ text: `Error generating project prompt: ${error.message}`,
322
+ },
277
323
  ],
278
324
  isError: true,
279
325
  };
@@ -295,7 +341,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
295
341
  const animIndex = args.animation_index || 0;
296
342
  const returnImages = args.return_images !== false;
297
343
 
298
- // 1. Convert SCAD to GLB ArrayBuffer
344
+ // 1. Generate the public GitHub Scadify URL
345
+ const previewUrl =
346
+ generateScadPreviewUrl(
347
+ scadCode,
348
+ "https://iliagrigorevdev.github.io/scad-gltf",
349
+ ) || "Failed to generate URL";
350
+
351
+ // 2. Convert SCAD to GLB ArrayBuffer
299
352
  const glbDataArray = await convertScadToGltf(scadCode, {
300
353
  wasmUrl: `file://${wasmPath}`,
301
354
  });
@@ -305,7 +358,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
305
358
  content: [
306
359
  {
307
360
  type: "text",
308
- text: `Successfully compiled SCAD without syntax errors. (Visual rendering skipped because return_images is false).`,
361
+ text: `Successfully compiled SCAD without syntax errors. (Visual rendering skipped because return_images is false).\n\nLive Editor Preview URL: ${previewUrl}`,
309
362
  },
310
363
  ],
311
364
  };
@@ -330,8 +383,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
330
383
  <script type="importmap">
331
384
  {
332
385
  "imports": {
333
- "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
334
- "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
386
+ "three": "https://unpkg.com/three@0.186.1/build/three.module.js",
387
+ "three/addons/": "https://unpkg.com/three@0.186.1/examples/jsm/"
335
388
  }
336
389
  }
337
390
  </script>
@@ -416,6 +469,58 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
416
469
  const gltf = await loader.loadAsync(dataUrl);
417
470
  scene.add(gltf.scene);
418
471
 
472
+ // Detect scene lights to match editor behavior
473
+ let sceneHasLights = false;
474
+ let maxDir = 0;
475
+ let maxPt = 0;
476
+
477
+ gltf.scene.traverse((child) => {
478
+ if (child.isMesh) {
479
+ child.frustumCulled = false;
480
+ }
481
+ if (child.isLight) {
482
+ sceneHasLights = true;
483
+ child.castShadow = true;
484
+ if (child.isDirectionalLight) {
485
+ maxDir = Math.max(maxDir, child.intensity);
486
+ } else {
487
+ maxPt = Math.max(maxPt, child.intensity);
488
+ }
489
+ }
490
+ });
491
+
492
+ if (sceneHasLights) {
493
+ renderer.shadowMap.enabled = true;
494
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
495
+
496
+ gltf.scene.traverse((child) => {
497
+ if (child.isMesh) {
498
+ child.castShadow = true;
499
+ child.receiveShadow = true;
500
+ }
501
+ });
502
+
503
+ let optimalExposure = 1.0;
504
+ const expDir = maxDir > 0 ? 3.0 / maxDir : 1.0;
505
+ const expPt = maxPt > 0 ? 3000.0 / maxPt : 1.0;
506
+
507
+ if (maxDir > 0 && maxPt > 0)
508
+ optimalExposure = Math.min(expDir, expPt);
509
+ else if (maxDir > 0) optimalExposure = expDir;
510
+ else if (maxPt > 0) optimalExposure = expPt;
511
+
512
+ optimalExposure = Math.max(0.00001, Math.min(optimalExposure, 5.0));
513
+
514
+ renderer.toneMappingExposure = optimalExposure;
515
+ scene.environmentIntensity = 0.0;
516
+
517
+ ambientLight.visible = false;
518
+ hemiLight.visible = false;
519
+ keyLight.visible = false;
520
+ fillLight.visible = false;
521
+ rimLight.visible = false;
522
+ }
523
+
419
524
  // --- Apply Animation State if requested ---
420
525
  let appliedAnim = false;
421
526
  if (gltf.animations && gltf.animations.length > 0) {
@@ -477,6 +582,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
477
582
  vertices.push(center.x, center.y, center.z);
478
583
  }
479
584
 
585
+ // Dynamically scale shadow biases and camera bounds based on actual model size
586
+ const shadowCamSize = initialDim * 1.5;
587
+ const shadowScale = initialDim / 10.0;
588
+
589
+ if (sceneHasLights) {
590
+ gltf.scene.traverse((child) => {
591
+ if (child.isLight && child.shadow) {
592
+ child.shadow.bias = -0.0005 * shadowScale;
593
+ child.shadow.normalBias = 0.02 * shadowScale;
594
+
595
+ if (child.isDirectionalLight) {
596
+ child.shadow.camera.left = -shadowCamSize;
597
+ child.shadow.camera.right = shadowCamSize;
598
+ child.shadow.camera.top = shadowCamSize;
599
+ child.shadow.camera.bottom = -shadowCamSize;
600
+ child.shadow.camera.near = Math.max(0.01, initialDim * 0.01);
601
+ child.shadow.camera.far = shadowCamSize * 4;
602
+ child.shadow.camera.updateProjectionMatrix();
603
+ child.shadow.mapSize.width = 2048;
604
+ child.shadow.mapSize.height = 2048;
605
+ } else if (child.isPointLight || child.isSpotLight) {
606
+ child.shadow.camera.near = Math.max(0.01, initialDim * 0.01);
607
+ child.shadow.camera.far = shadowCamSize * 4;
608
+ child.shadow.camera.updateProjectionMatrix();
609
+ }
610
+ }
611
+ });
612
+ }
613
+
480
614
  const fovY = (camera.fov * Math.PI) / 180;
481
615
  const tanY = Math.tan(fovY / 2);
482
616
  const tanX = tanY * camera.aspect;
@@ -611,7 +745,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
611
745
  const content = [
612
746
  {
613
747
  type: "text",
614
- text: `Successfully compiled SCAD and rendered ${snapshots.length} camera angle(s)${appliedAnimStr}. Please analyze these visual results to determine your next adjustments.`,
748
+ text: `Successfully compiled SCAD and rendered ${snapshots.length} camera angle(s)${appliedAnimStr}. Please analyze these visual results to determine your next adjustments.\n\nLive Editor Preview URL: ${previewUrl}`,
615
749
  },
616
750
  ];
617
751
 
@@ -746,16 +880,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
746
880
  const originalProjectGodot = fs.readFileSync(projectGodotPath, "utf8");
747
881
  let playResult;
748
882
 
883
+ // 1.5 Prepare MCP Runner Autoload Script (ALWAYS inject to ensure a clean exit)
884
+ const snapTime = Math.max(0.5, runTime - 0.5);
885
+
886
+ let runnerScript = `extends Node\nfunc _ready():\n\tprint("[MCP] Autoload ready. Waiting ${snapTime} seconds...")\n\tawait get_tree().create_timer(${snapTime}).timeout\n`;
887
+
888
+ // If returning images, inject the screenshot capture logic
749
889
  if (returnImages) {
750
- // 1.5 Prepare Screenshot Autoload Script
751
- const snapTime = Math.max(0.5, runTime - 0.5);
752
-
753
- const screenshotScript = `extends Node
754
- func _ready():
755
- \tprint("[ScreenshotMCP] Autoload ready. Waiting ${snapTime} seconds...")
756
- \tawait get_tree().create_timer(${snapTime}).timeout
757
- \tvar ds = DisplayServer.get_name()
758
- \tprint("[ScreenshotMCP] DisplayServer is: ", ds)
890
+ runnerScript += `\tvar ds = DisplayServer.get_name()
891
+ \tprint("[MCP] DisplayServer is: ", ds)
759
892
  \tif ds != "headless":
760
893
  \t\tawait RenderingServer.frame_post_draw
761
894
  \t\tvar tex = get_viewport().get_texture()
@@ -770,34 +903,37 @@ func _ready():
770
903
  \t\t\t\tvar cropped = img.get_region(Rect2i(x, y, size, size))
771
904
  \t\t\t\tvar path = ProjectSettings.globalize_path("res://screenshot_mcp.png")
772
905
  \t\t\t\tvar err = cropped.save_png(path)
773
- \t\t\t\tprint("[ScreenshotMCP] Save PNG to ", path, " returned error code: ", err)
906
+ \t\t\t\tprint("[MCP] Save PNG to ", path, " returned error code: ", err)
774
907
  \t\t\telse:
775
- \t\t\t\tprint("[ScreenshotMCP] Error: Image is null or empty.")
908
+ \t\t\t\tprint("[MCP] Error: Image is null or empty.")
776
909
  \t\telse:
777
- \t\t\tprint("[ScreenshotMCP] Error: Viewport texture is null.")
778
- \tget_tree().quit()
779
- `;
780
- fs.writeFileSync(
781
- path.join(projectDir, "screenshot_mcp.gd"),
782
- screenshotScript,
910
+ \t\t\tprint("[MCP] Error: Viewport texture is null.")\n`;
911
+ }
912
+
913
+ // Always force Godot to quit gracefully from the inside
914
+ runnerScript += `\tget_tree().quit()\n`;
915
+
916
+ fs.writeFileSync(path.join(projectDir, "mcp_runner.gd"), runnerScript);
917
+
918
+ let tempProjectGodot = originalProjectGodot;
919
+ if (tempProjectGodot.includes("[autoload]")) {
920
+ tempProjectGodot = tempProjectGodot.replace(
921
+ "[autoload]",
922
+ '[autoload]\nMCPRunner="*res://mcp_runner.gd"',
783
923
  );
924
+ } else {
925
+ tempProjectGodot += `\n[autoload]\nMCPRunner="*res://mcp_runner.gd"\n`;
926
+ }
927
+ fs.writeFileSync(projectGodotPath, tempProjectGodot, "utf8");
784
928
 
785
- let tempProjectGodot = originalProjectGodot;
786
- if (tempProjectGodot.includes("[autoload]")) {
787
- tempProjectGodot = tempProjectGodot.replace(
788
- "[autoload]",
789
- '[autoload]\nScreenshotMCP="*res://screenshot_mcp.gd"',
790
- );
791
- } else {
792
- tempProjectGodot += `\n[autoload]\nScreenshotMCP="*res://screenshot_mcp.gd"\n`;
793
- }
794
- fs.writeFileSync(projectGodotPath, tempProjectGodot, "utf8");
929
+ // 2. Run Project Step
930
+ // Give the Node wrapper a +5000ms buffer so the internal Godot timer always finishes first
931
+ const safeNodeTimeout = runTime * 1000 + 5000;
795
932
 
796
- // 2. Run Project Step
797
- // Always try windowed first to get the screenshot.
798
- // If the environment lacks a display server, we'll catch the error and fallback.
933
+ if (returnImages) {
934
+ // Windowed first to get the screenshot
799
935
  let playArgs = [
800
- "--windowed", // Use windowed instead of headless to ensure rendering works for screenshot
936
+ "--windowed",
801
937
  "--resolution",
802
938
  "512x512",
803
939
  "--audio-driver",
@@ -806,13 +942,7 @@ func _ready():
806
942
  projectDir,
807
943
  ];
808
944
 
809
- playResult = await runGodotAsync(
810
- playArgs,
811
- projectDir,
812
- // Give Godot extra buffer time to boot, take the shot, and quit gracefully
813
- runTime * 1000 + 5000,
814
- );
815
-
945
+ playResult = await runGodotAsync(playArgs, projectDir, safeNodeTimeout);
816
946
  let windowedOutput = playResult.output;
817
947
 
818
948
  // Fallback if windowed mode fails due to display server issues
@@ -824,30 +954,29 @@ func _ready():
824
954
  playResult = await runGodotAsync(
825
955
  ["--headless", "--audio-driver", "Dummy", "--path", projectDir],
826
956
  projectDir,
827
- runTime * 1000,
957
+ safeNodeTimeout,
828
958
  );
829
- // Explicitly inject the display error into the final output so we aren't flying blind!
830
959
  playResult.output =
831
960
  "--- WINDOWED LAUNCH FAILED (Display Error) ---\n" +
832
961
  windowedOutput.trim() +
833
962
  "\n\n--- FALLBACK HEADLESS LAUNCH ---\n" +
834
963
  playResult.output;
835
964
  }
836
-
837
- // 3. Cleanup Autoloads
838
- fs.writeFileSync(projectGodotPath, originalProjectGodot, "utf8");
839
- if (fs.existsSync(path.join(projectDir, "screenshot_mcp.gd"))) {
840
- fs.unlinkSync(path.join(projectDir, "screenshot_mcp.gd"));
841
- }
842
965
  } else {
843
966
  // Text-only mode: Run headless immediately
844
967
  playResult = await runGodotAsync(
845
968
  ["--headless", "--audio-driver", "Dummy", "--path", projectDir],
846
969
  projectDir,
847
- runTime * 1000,
970
+ safeNodeTimeout,
848
971
  );
849
972
  }
850
973
 
974
+ // 3. Cleanup Autoloads
975
+ fs.writeFileSync(projectGodotPath, originalProjectGodot, "utf8");
976
+ if (fs.existsSync(path.join(projectDir, "mcp_runner.gd"))) {
977
+ fs.unlinkSync(path.join(projectDir, "mcp_runner.gd"));
978
+ }
979
+
851
980
  // 4. Read Screenshot
852
981
  let screenshotBase64 = null;
853
982
  if (returnImages) {
@@ -1 +1 @@
1
- *{box-sizing:border-box}body{color:#ddd;background-color:#222;margin:0;font-family:system-ui,sans-serif;overflow:hidden}#app-container{flex-direction:row;width:100vw;height:100vh;display:flex}@media (width<=800px),(orientation:portrait){#app-container{flex-direction:column}}#left-panel{background:#1e1e1e;border-right:1px solid #444;flex-direction:column;flex:1;gap:15px;min-width:0;min-height:50vh;padding:15px;display:flex;overflow-y:auto}@media (width<=800px),(orientation:portrait){#left-panel{border-bottom:1px solid #444;border-right:none}}#right-panel{background:#111;flex:1;min-width:0;min-height:50vh;position:relative}.panel-section{flex-direction:column;gap:8px;display:flex}.flex-grow{flex-grow:1}h2{color:#fff;text-transform:uppercase;letter-spacing:.5px;margin:0;font-size:14px}.help-text{color:#aaa;margin:0 0 5px;font-size:11px}textarea{color:#d4d4d4;resize:vertical;background:#2a2a2a;border:1px solid #444;border-radius:4px;outline:none;width:100%;padding:10px;font-family:monospace;font-size:12px}textarea:focus{border-color:#666}input[type=text],input[type=number],select{color:#d4d4d4;background:#2a2a2a;border:1px solid #444;border-radius:4px;outline:none;align-items:center;height:34px;padding:0 8px;font-size:13px;display:flex}input[type=text]:focus,input[type=number]:focus,select:focus{border-color:#666}#editor{white-space:pre;flex-grow:1;min-height:150px;overflow:auto}button{color:#fff;cursor:pointer;background:#444;border:1px solid #555;border-radius:4px;justify-content:center;align-items:center;height:34px;padding:0 12px;font-size:13px;font-weight:500;transition:background .2s;display:flex}button:hover{background:#555}button.primary{background:#06c;border-color:#05a}button.primary:hover{background:#07e}button.danger{background:#c00;border-color:#a00}button.danger:hover{background:#e00}.controls-row{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.toggles{-webkit-user-select:none;user-select:none;font-size:13px}.scad-prompt-toggles{-webkit-user-select:none;user-select:none;flex-wrap:wrap;gap:8px 12px;margin-bottom:8px;font-size:13px;display:flex}.scad-prompt-toggles label{cursor:pointer;align-items:center;gap:4px;display:flex}.scad-prompt-group{background:#2a2a2a;border:1px solid #444;border-radius:4px;flex-direction:column;gap:8px;margin-bottom:8px;padding:8px;display:flex}.scad-prompt-group-header{color:#eee;border-bottom:1px solid #444;justify-content:space-between;align-items:center;padding-bottom:6px;font-size:13px;font-weight:700;display:flex}.scad-prompt-group-header label{cursor:pointer;align-items:center;gap:4px;font-size:12px;font-weight:400;display:flex}.scad-prompt-toggles.nested{margin-bottom:0}.toggles label{cursor:pointer;align-items:center;gap:4px;display:flex}.toggles label input[type=number]{color:#d4d4d4;background:#2a2a2a;border:1px solid #444;border-radius:4px;outline:none;width:45px;height:24px;padding:2px 4px;font-family:monospace;font-size:12px}input[type=range]{-webkit-appearance:none;background:0 0;flex:1;width:100%;margin:8px 0}input[type=range]:focus{outline:none}input[type=range]::-webkit-slider-runnable-track{cursor:pointer;background:#444;border-radius:3px;width:100%;height:6px}input[type=range]::-webkit-slider-thumb{cursor:pointer;-webkit-appearance:none;background:#06c;border-radius:50%;width:16px;height:16px;margin-top:-5px}#backend-url{flex:1;min-width:0}#backend-ui{flex-direction:column;gap:10px;margin-top:10px;display:none}#backend-ui.active{display:flex}#backend-select{flex:1;min-width:0}#backend-single-save-btn,#backend-delete-btn{flex-shrink:0}#viewer{width:100%;height:100%;overflow:hidden}#viewer canvas{display:block}#viewer-controls{z-index:10;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background:#0009;border-radius:6px;align-items:center;gap:12px;padding:6px 12px;display:flex;position:absolute;top:15px;right:15px}#viewer-controls label{color:#fff;cursor:pointer;-webkit-user-select:none;user-select:none;align-items:center;gap:4px;margin:0;font-size:13px;display:flex}#fullscreen-btn,#screenshot-btn{color:#fff;cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;height:auto;font-size:16px;display:flex}#fullscreen-btn{padding:0 4px}#screenshot-btn{padding:0 4px 5px}#fullscreen-btn:hover,#screenshot-btn:hover{color:#0af;background:0 0}#drag-overlay{color:#fff;z-index:9999;letter-spacing:1px;background:#0066ccd9;justify-content:center;align-items:center;width:100vw;height:100vh;font-size:2rem;font-weight:700;display:none;position:fixed;top:0;left:0}#drag-overlay.active{display:flex}.footer-section{align-items:center;margin-top:auto;padding-top:5px}.source-link{color:#888;font-size:12px;text-decoration:none;transition:color .2s}.source-link:hover{color:#fff}
1
+ *{box-sizing:border-box}body{color:#ddd;background-color:#222;margin:0;font-family:system-ui,sans-serif;overflow:hidden}#app-container{flex-direction:row;width:100vw;height:100vh;display:flex}@media (width<=800px),(orientation:portrait){#app-container{flex-direction:column}}#left-panel{background:#1e1e1e;border-right:1px solid #444;flex-direction:column;flex:1;gap:15px;min-width:0;min-height:50vh;padding:15px;display:flex;overflow-y:auto}@media (width<=800px),(orientation:portrait){#left-panel{border-bottom:1px solid #444;border-right:none}}#right-panel{background:#111;flex:1;min-width:0;min-height:50vh;position:relative}.panel-section{flex-direction:column;gap:8px;display:flex}.flex-grow{flex-grow:1}h2{color:#fff;text-transform:uppercase;letter-spacing:.5px;margin:0;font-size:14px}.help-text{color:#aaa;margin:0 0 5px;font-size:11px}textarea{color:#d4d4d4;resize:vertical;background:#2a2a2a;border:1px solid #444;border-radius:4px;outline:none;width:100%;padding:10px;font-family:monospace;font-size:12px}textarea:focus{border-color:#666}input[type=text],input[type=number],select{color:#d4d4d4;background:#2a2a2a;border:1px solid #444;border-radius:4px;outline:none;align-items:center;height:34px;padding:0 8px;font-size:13px;display:flex}input[type=text]:focus,input[type=number]:focus,select:focus{border-color:#666}#editor{white-space:pre;flex-grow:1;min-height:150px;overflow:auto}button{color:#fff;cursor:pointer;background:#444;border:1px solid #555;border-radius:4px;justify-content:center;align-items:center;height:34px;padding:0 12px;font-size:13px;font-weight:500;transition:background .2s;display:flex}button:hover{background:#555}button.primary{background:#06c;border-color:#05a}button.primary:hover{background:#07e}button.danger{background:#c00;border-color:#a00}button.danger:hover{background:#e00}.controls-row{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.toggles{-webkit-user-select:none;user-select:none;font-size:13px}.scad-prompt-toggles{-webkit-user-select:none;user-select:none;flex-wrap:wrap;gap:8px 12px;margin-bottom:8px;font-size:13px;display:flex}.scad-prompt-toggles label{cursor:pointer;align-items:center;gap:4px;display:flex}.scad-prompt-group{background:#2a2a2a;border:1px solid #444;border-radius:4px;flex-direction:column;gap:8px;margin-bottom:8px;padding:8px;display:flex}.scad-prompt-group-header{color:#eee;border-bottom:1px solid #444;justify-content:space-between;align-items:center;padding-bottom:6px;font-size:13px;font-weight:700;display:flex}.scad-prompt-group-header label{cursor:pointer;align-items:center;gap:4px;font-size:12px;font-weight:400;display:flex}.scad-prompt-toggles.nested{margin-bottom:0}.toggles label{cursor:pointer;align-items:center;gap:4px;display:flex}.toggles label input[type=number]{color:#d4d4d4;background:#2a2a2a;border:1px solid #444;border-radius:4px;outline:none;width:45px;height:24px;padding:2px 4px;font-family:monospace;font-size:12px}input[type=range]{-webkit-appearance:none;background:0 0;flex:1;width:100%;margin:8px 0}input[type=range]:focus{outline:none}input[type=range]::-webkit-slider-runnable-track{cursor:pointer;background:#444;border-radius:3px;width:100%;height:6px}input[type=range]::-webkit-slider-thumb{cursor:pointer;-webkit-appearance:none;background:#06c;border-radius:50%;width:16px;height:16px;margin-top:-5px}#backend-url{flex:1;min-width:0}#backend-ui{flex-direction:column;gap:10px;margin-top:10px;display:none}#backend-ui.active{display:flex}#backend-select{flex:1;min-width:0}#backend-single-save-btn,#backend-delete-btn{flex-shrink:0}#viewer{width:100%;height:100%;overflow:hidden}#viewer canvas{display:block}#viewer-controls{z-index:10;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);background:#0009;border-radius:6px;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:12px;max-width:calc(100% - 30px);padding:6px 12px;display:flex;position:absolute;top:15px;right:15px}#viewer-controls select{color:#fff;cursor:pointer;background:#333;border:1px solid #555;border-radius:4px;outline:none;padding:2px 6px;font-size:13px}#viewer-controls label{color:#fff;cursor:pointer;-webkit-user-select:none;user-select:none;align-items:center;gap:4px;margin:0;font-size:13px;display:flex}#fullscreen-btn,#screenshot-btn{color:#fff;cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;height:auto;font-size:16px;display:flex}#fullscreen-btn{padding:0 4px}#screenshot-btn{padding:0 4px 5px}#fullscreen-btn:hover,#screenshot-btn:hover{color:#0af;background:0 0}#drag-overlay{color:#fff;z-index:9999;letter-spacing:1px;background:#0066ccd9;justify-content:center;align-items:center;width:100vw;height:100vh;font-size:2rem;font-weight:700;display:none;position:fixed;top:0;left:0}#drag-overlay.active{display:flex}.footer-section{align-items:center;margin-top:auto;padding-top:5px}.source-link{color:#888;font-size:12px;text-decoration:none;transition:color .2s}.source-link:hover{color:#fff}