scad-gltf 0.4.0 → 0.5.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 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.
@@ -260,7 +261,7 @@ Instead of generating code blindly, the AI can compile its script, render the 3D
260
261
 
261
262
  **Exposed MCP Tools:**
262
263
 
263
- - `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).
264
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.
265
266
  - `compile_rust_project` / `test_godot_project`: Tests the generated game projects for compilation and runtime errors.
266
267
 
@@ -283,18 +284,6 @@ If you installed the package globally, you can configure your MCP client to use
283
284
 
284
285
  ---
285
286
 
286
- ## 🎮 Godot Engine Integration
287
-
288
- This repository includes an official **Godot 4.x Importer Addon** located in the [`/godot`](./godot) directory.
289
-
290
- 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.
291
-
292
- - **Features:** Supports PBR Materials and Hierarchical Node Animations inside the Godot Editor.
293
- - **License:** The Godot Addon is licensed under **MIT**.
294
- - **Setup:** Simply copy the `addons/scad_importer` folder to your project and enable it in Project Settings.
295
-
296
- ---
297
-
298
287
  ## Automated AI Generation (`scad-gltf gen`)
299
288
 
300
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.
@@ -467,6 +456,17 @@ bake(uvs=true) {
467
456
  }
468
457
  ```
469
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
+
470
470
  ---
471
471
 
472
472
  ## AI Integration (`prompt.js`)
@@ -490,6 +490,7 @@ const promptContext = generatePrompt(description, {
490
490
  iridescence: true, // Thin-film interference
491
491
  autoSmoothAngle: true, // $asa rules
492
492
  animation: true, // Armature & bone syntax
493
+ lights: false, // Light rules
493
494
  bakeColors: false, // Texture baking flags
494
495
  bakeNormals: false,
495
496
  bakeOrm: false,
@@ -505,7 +506,7 @@ console.log(promptContext);
505
506
 
506
507
  Once connected, an AI assistant can use the server to execute the following loop:
507
508
 
508
- 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.
509
510
  2. **Generate Code:** The assistant writes the `.scad` script based on your design request.
510
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.
511
512
 
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) {