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 +46 -18
- package/bin/gen.js +1 -1
- package/bin/mcp.js +198 -69
- package/editor/dist/assets/{index-B1KAb97r.css → index-D8In1wnN.css} +1 -1
- package/editor/dist/assets/{index-CgVih1C4.js → index-b4iPmEjR.js} +204 -92
- package/editor/dist/assets/{openscad-CdBCY4mx.wasm → openscad-DJJQxKJM.wasm} +0 -0
- package/editor/dist/assets/preview-Cx3FHqKG.css +1 -0
- package/editor/dist/assets/preview-DfaOpP2w.js +1 -0
- package/editor/dist/assets/{prompt-ui-B9QCNLfJ.js → prompt-ui-zw7U8lsl.js} +5 -3
- package/editor/dist/assets/shared-CDFEFgsW.js +4315 -0
- package/editor/dist/content.js +1 -1
- package/editor/dist/index.html +11 -6
- package/editor/dist/preview.html +20 -15
- package/editor/dist/sw.js +1 -1
- package/godot/addons/scad_importer/plugin.cfg +0 -1
- package/godot/addons/scad_importer/scad_importer.gd +3 -153
- package/godot/addons/scad_importer/scad_plugin.gd +0 -3
- package/package.json +4 -4
- package/src/cli-utils.js +406 -376
- package/src/ext/openscad.js +1 -1
- package/src/ext/openscad.wasm +0 -0
- package/src/project-prompts.js +111 -87
- package/src/prompt.js +21 -0
- package/editor/dist/assets/OutputPass-B6MrFnGE.js +0 -4317
- package/editor/dist/assets/preview-A6M04CFK.css +0 -1
- package/editor/dist/assets/preview-BbUpKLF4.js +0 -1
- package/godot/README.md +0 -64
- package/godot/addons/scad_importer/LICENSE +0 -21
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 (
|
|
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
|
-
- `
|
|
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
|
-
##
|
|
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
|
-
- `
|
|
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
|
-
|
|
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 `
|
|
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: "
|
|
60
|
+
name: "get_project_prompt",
|
|
59
61
|
description:
|
|
60
|
-
"Generates the specialized
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
152
|
+
lights: {
|
|
144
153
|
type: "boolean",
|
|
145
154
|
description:
|
|
146
|
-
"Include
|
|
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
|
|
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:
|
|
275
|
+
// TOOL 1: get_project_prompt
|
|
261
276
|
// ------------------------------------------
|
|
262
|
-
if (name === "
|
|
277
|
+
if (name === "get_project_prompt") {
|
|
263
278
|
try {
|
|
264
|
-
const
|
|
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:
|
|
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
|
-
{
|
|
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.
|
|
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.
|
|
334
|
-
"three/addons/": "https://unpkg.com/three@0.
|
|
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
|
-
|
|
751
|
-
|
|
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("[
|
|
906
|
+
\t\t\t\tprint("[MCP] Save PNG to ", path, " returned error code: ", err)
|
|
774
907
|
\t\t\telse:
|
|
775
|
-
\t\t\t\tprint("[
|
|
908
|
+
\t\t\t\tprint("[MCP] Error: Image is null or empty.")
|
|
776
909
|
\t\telse:
|
|
777
|
-
\t\t\tprint("[
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
797
|
-
//
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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}
|