@hybridlabor-api/bdb-hardware-pcb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -0
- package/config/mcp/antigravity.json +22 -0
- package/config/mcp/claude.json +22 -0
- package/config/mcp/codex.toml +25 -0
- package/docs/adr/ADR-001-KICAD-OPENSCAD-MCP-STRATEGY.md +334 -0
- package/docs/review_documentation.md +121 -0
- package/installer.js +358 -0
- package/mcp_servers/kicad-mcp-server/.env.example +22 -0
- package/mcp_servers/kicad-mcp-server/.github/workflows/ci.yml +36 -0
- package/mcp_servers/kicad-mcp-server/CLAUDE.md +487 -0
- package/mcp_servers/kicad-mcp-server/README.md +316 -0
- package/mcp_servers/kicad-mcp-server/docs/DEVICE_TREE.md +416 -0
- package/mcp_servers/kicad-mcp-server/docs/INSTALLATION.md +332 -0
- package/mcp_servers/kicad-mcp-server/docs/PIN_ANALYSIS.md +332 -0
- package/mcp_servers/kicad-mcp-server/docs/README.md +240 -0
- package/mcp_servers/kicad-mcp-server/docs/TESTING.md +613 -0
- package/mcp_servers/kicad-mcp-server/docs/VALIDATION.md +268 -0
- package/mcp_servers/kicad-mcp-server/pyproject.toml +96 -0
- package/mcp_servers/kicad-mcp-server/requirements-dev.txt +16 -0
- package/mcp_servers/kicad-mcp-server/requirements-test.txt +24 -0
- package/mcp_servers/kicad-mcp-server/requirements.txt +13 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__init__.py +3 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__main__.py +17 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/config.py +46 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/types.py +87 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/netlist_parser.py +234 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser.py +375 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser_kicad.py +327 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/schematic_parser.py +902 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/server.py +71 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/arduino/connectivity_test.cpp.j2 +189 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/atmega.dts.j2 +77 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/esp32.dts.j2 +77 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/nrf52.dts.j2 +77 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/stm32f4.dts.j2 +89 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/esp_idf/test_suite.c.j2 +340 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/pytest/test_connectivity.py.j2 +147 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/st_hal/hal_test.c.j2 +313 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_gpio_test.py.j2 +99 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_i2c_test.py.j2 +117 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_pinmux_test.py.j2 +43 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_spi_test.py.j2 +94 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_gpio_test.c.j2 +113 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_i2c_test.c.j2 +101 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_spi_test.c.j2 +94 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/unittest/test_schematic.py.j2 +172 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/__init__.py +36 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/device_tree.py +1187 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/hierarchical_analysis.py +211 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/netlist.py +320 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/parts_registry.py +142 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb.py +955 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb_layout.py +308 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pin_analysis.py +765 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/project.py +196 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic.py +319 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_editor.py +674 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_search.py +158 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/validation.py +866 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/visualization.py +225 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/file_handlers.py +65 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_cli.py +103 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_version.py +103 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/parts_registry.py +197 -0
- package/mcp_servers/kicad-mcp-server/tests/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/tests/examples/ESP32S3_TEST.md +219 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/README.md +65 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/example_pcb.kicad_pcb +177 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/example_schematic.kicad_sch +145 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/child.kicad_sch +24 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/root.kicad_sch +38 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_hierarchical_labels.py +195 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_kicad_cli.py +116 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_netlist_cache_path.py +29 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic.py +189 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic_hierarchy.py +69 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_visualization.py +136 -0
- package/mcp_servers/kicad-mcp-server/uv.lock +2873 -0
- package/mcp_servers/openscad-mcp-server/.dockerignore +9 -0
- package/mcp_servers/openscad-mcp-server/.github/workflows/test.yml +40 -0
- package/mcp_servers/openscad-mcp-server/Dockerfile +29 -0
- package/mcp_servers/openscad-mcp-server/LICENSE +21 -0
- package/mcp_servers/openscad-mcp-server/README.md +154 -0
- package/mcp_servers/openscad-mcp-server/docs/audit.md +56 -0
- package/mcp_servers/openscad-mcp-server/docs/docker.md +66 -0
- package/mcp_servers/openscad-mcp-server/docs/issue-followup.md +19 -0
- package/mcp_servers/openscad-mcp-server/docs/jetson.md +17 -0
- package/mcp_servers/openscad-mcp-server/glama.json +4 -0
- package/mcp_servers/openscad-mcp-server/legacy/README.md +15 -0
- package/mcp_servers/openscad-mcp-server/legacy/README.original.md +294 -0
- package/mcp_servers/openscad-mcp-server/legacy/implementation_plan.md +100 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/download_sam2_checkpoint.py +115 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/src/ai/sam_segmentation.py +209 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/src/models/threestudio_generator.py +231 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/src/workflow/image_to_model_pipeline.py +260 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/test_sam2_segmentation.py +96 -0
- package/mcp_servers/openscad-mcp-server/legacy/requirements.txt +57 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/README.md +39 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/ai-driven-code-generation.md +122 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/export-formats.md +76 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/ai/ai_service.py.md +51 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/main.py.md +63 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/models/code_generator.py.md +63 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/nlp/parameter_extractor.py.md +63 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/ai/natural-language-processing.md +78 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/nlp/parameter-extraction.md +173 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/export-formats.md +91 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/openscad-basics.md +66 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/primitive-testing.md +79 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/ai/ai_service.py +257 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/ai/gemini_api.py +161 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/ai/venice_api.py +203 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/config.py +121 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/main.py +1456 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/main.py.new +404 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/main_remote.py +401 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/code_generator.py +321 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/cuda_mvs.py +209 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/scad_templates/basic_shapes.scad +144 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/nlp/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/nlp/parameter_extractor.py +388 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/wrapper.py +418 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/__init__.py +1 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/printer_discovery.py +471 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/connection_manager.py +537 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_client.py +435 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_server.py +787 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/error_handling.py +415 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/testing/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/testing/primitive_tester.py +203 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/testing/test_primitives.py +98 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/__init__.py +1 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/cad_exporter.py +241 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/format_validator.py +206 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_exporter.py +140 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_repair.py +91 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_validator.py +123 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/headless_renderer.py +52 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/renderer.py +177 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/web_interface.py +639 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/workflow/image_approval.py +148 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/workflow/multi_view_to_model_pipeline.py +338 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_complete_workflow.py +374 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_cuda_mvs.py +191 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_gemini_api.py +168 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_image_approval.py +192 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_image_approval_workflow.py +251 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_image_to_model_pipeline.py +145 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_model_selection.py +41 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_multi_view_pipeline.py +290 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_primitives.sh +13 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_rabbit_direct.py +71 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_remote_cuda_mvs.py +283 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_venice_example.py +69 -0
- package/mcp_servers/openscad-mcp-server/pyproject.toml +33 -0
- package/mcp_servers/openscad-mcp-server/requirements.txt +2 -0
- package/mcp_servers/openscad-mcp-server/scad/simple_cube.scad +2 -0
- package/mcp_servers/openscad-mcp-server/scripts/test_docker.py +220 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__init__.py +3 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__main__.py +3 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/engine.py +94 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/geometry.py +242 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/server.py +245 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/service.py +190 -0
- package/mcp_servers/openscad-mcp-server/tests/conftest.py +17 -0
- package/mcp_servers/openscad-mcp-server/tests/test_engine.py +37 -0
- package/mcp_servers/openscad-mcp-server/tests/test_geometry.py +106 -0
- package/mcp_servers/openscad-mcp-server/tests/test_integration.py +172 -0
- package/mcp_servers/openscad-mcp-server/tests/test_transports.py +314 -0
- package/mcp_servers/openscad-mcp-server/uv.lock +1123 -0
- package/package.json +44 -0
- package/scripts/install_mcps.sh +86 -0
- package/scripts/openscad_wrapper.sh +62 -0
- package/scripts/run_kicad_mcp.sh +25 -0
- package/scripts/run_openscad_mcp.sh +41 -0
- package/scripts/test_mcp_connection.py +626 -0
- package/scripts/test_mcp_connection.sh +168 -0
- package/skills/code-first-hardware-design/SKILL.md +260 -0
- package/skills/pcb-constraint-definition/SKILL.md +236 -0
- package/skills/pcb-layout-routing-automation/SKILL.md +153 -0
- package/skills/pcb-validation-dfm-signoff/SKILL.md +194 -0
- package/skills/schematic-datasheet-analysis/SKILL.md +202 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""OpenSCAD subprocess execution with verified, atomically replaced outputs."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
VIEWS = {
|
|
11
|
+
"perspective": "0,0,0,55,0,25,100",
|
|
12
|
+
"front": "0,0,0,90,0,0,100",
|
|
13
|
+
"top": "0,0,0,0,0,0,100",
|
|
14
|
+
"right": "0,0,0,90,0,90,100",
|
|
15
|
+
}
|
|
16
|
+
FORMATS = ("scad", "stl", "csg", "3mf")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OpenSCADError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OpenSCAD:
|
|
24
|
+
def __init__(self, executable: str | None = None, timeout: float = 120):
|
|
25
|
+
requested = executable or os.environ.get("OPENSCAD_EXECUTABLE")
|
|
26
|
+
self.executable = requested or shutil.which("openscad")
|
|
27
|
+
if not self.executable:
|
|
28
|
+
for candidate in (
|
|
29
|
+
"/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD",
|
|
30
|
+
"/Applications/OpenSCAD-2021.01.app/Contents/MacOS/OpenSCAD",
|
|
31
|
+
):
|
|
32
|
+
if Path(candidate).is_file():
|
|
33
|
+
self.executable = candidate
|
|
34
|
+
break
|
|
35
|
+
if not self.executable:
|
|
36
|
+
raise OpenSCADError(
|
|
37
|
+
"OpenSCAD not found. Install it or set OPENSCAD_EXECUTABLE to its executable."
|
|
38
|
+
)
|
|
39
|
+
self.timeout = timeout
|
|
40
|
+
|
|
41
|
+
def _run(self, args: list[str]) -> subprocess.CompletedProcess:
|
|
42
|
+
try:
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
[self.executable, *args],
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
timeout=self.timeout,
|
|
48
|
+
)
|
|
49
|
+
except subprocess.TimeoutExpired as exc:
|
|
50
|
+
raise OpenSCADError(
|
|
51
|
+
f"OpenSCAD exceeded the {self.timeout:g}s timeout"
|
|
52
|
+
) from exc
|
|
53
|
+
except OSError as exc:
|
|
54
|
+
raise OpenSCADError(f"Cannot launch OpenSCAD: {exc}") from exc
|
|
55
|
+
diagnostics = result.stderr + result.stdout
|
|
56
|
+
# Some OpenSCAD versions report errors (including failed assertions) with exit code zero.
|
|
57
|
+
if result.returncode or re.search(r"(?:ERROR|WARNING):", diagnostics):
|
|
58
|
+
raise OpenSCADError(f"OpenSCAD failed: {diagnostics[-6000:].strip()}")
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
def version(self) -> str:
|
|
62
|
+
result = self._run(["--version"])
|
|
63
|
+
return (result.stdout + result.stderr).strip()
|
|
64
|
+
|
|
65
|
+
def export(self, source: Path, destination: Path, view: str | None = None) -> Path:
|
|
66
|
+
suffix = destination.suffix.lower()
|
|
67
|
+
if view is not None and view not in VIEWS:
|
|
68
|
+
raise ValueError(f"Unknown view: {view}")
|
|
69
|
+
if suffix not in {".stl", ".csg", ".3mf", ".png"}:
|
|
70
|
+
raise ValueError(f"Unsupported output extension: {suffix}")
|
|
71
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
with tempfile.TemporaryDirectory(
|
|
73
|
+
prefix=".render-", dir=destination.parent
|
|
74
|
+
) as temporary:
|
|
75
|
+
output = Path(temporary) / destination.name
|
|
76
|
+
args = ["-o", str(output)]
|
|
77
|
+
if suffix == ".png":
|
|
78
|
+
args += [
|
|
79
|
+
"--render",
|
|
80
|
+
"--autocenter",
|
|
81
|
+
"--viewall",
|
|
82
|
+
"--imgsize=800,600",
|
|
83
|
+
f"--camera={VIEWS[view or 'perspective']}",
|
|
84
|
+
]
|
|
85
|
+
args.append(str(source.resolve()))
|
|
86
|
+
self._run(args)
|
|
87
|
+
if not output.is_file() or output.stat().st_size == 0:
|
|
88
|
+
raise OpenSCADError("OpenSCAD produced no geometry/output")
|
|
89
|
+
if suffix == ".png" and not output.read_bytes().startswith(
|
|
90
|
+
b"\x89PNG\r\n\x1a\n"
|
|
91
|
+
):
|
|
92
|
+
raise OpenSCADError("OpenSCAD did not produce a PNG image")
|
|
93
|
+
os.replace(output, destination)
|
|
94
|
+
return destination
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Small, explicit parametric templates; no inferred geometry or AI fallback."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
"cube": {"width": 10, "depth": 10, "height": 10, "center": False},
|
|
9
|
+
"sphere": {"radius": 10, "segments": 48},
|
|
10
|
+
"cylinder": {"radius": 10, "height": 20, "center": False, "segments": 48},
|
|
11
|
+
"box": {"width": 30, "depth": 20, "height": 15, "thickness": 2},
|
|
12
|
+
"rounded_box": {
|
|
13
|
+
"width": 30,
|
|
14
|
+
"depth": 20,
|
|
15
|
+
"height": 15,
|
|
16
|
+
"radius": 3,
|
|
17
|
+
"segments": 32,
|
|
18
|
+
},
|
|
19
|
+
"tube": {
|
|
20
|
+
"outer_radius": 10,
|
|
21
|
+
"inner_radius": 8,
|
|
22
|
+
"height": 20,
|
|
23
|
+
"center": False,
|
|
24
|
+
"segments": 48,
|
|
25
|
+
},
|
|
26
|
+
"cone": {
|
|
27
|
+
"bottom_radius": 10,
|
|
28
|
+
"top_radius": 0,
|
|
29
|
+
"height": 20,
|
|
30
|
+
"center": False,
|
|
31
|
+
"segments": 48,
|
|
32
|
+
},
|
|
33
|
+
"torus": {"major_radius": 15, "minor_radius": 5, "segments": 48},
|
|
34
|
+
"prism": {"width": 20, "depth": 20, "height": 10},
|
|
35
|
+
"hexagonal_prism": {"radius": 10, "height": 20, "center": False},
|
|
36
|
+
"text": {"text": "OpenSCAD", "size": 10, "height": 3},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
BODIES = {
|
|
40
|
+
"cube": "cube([width, depth, height], center=center);",
|
|
41
|
+
"sphere": "sphere(r=radius, $fn=segments);",
|
|
42
|
+
"cylinder": "cylinder(h=height, r=radius, center=center, $fn=segments);",
|
|
43
|
+
"box": """difference() {
|
|
44
|
+
cube([width, depth, height]);
|
|
45
|
+
translate([thickness, thickness, thickness])
|
|
46
|
+
cube([width-2*thickness, depth-2*thickness, height]);
|
|
47
|
+
}""",
|
|
48
|
+
"rounded_box": """hull() {
|
|
49
|
+
for (x=[radius, width-radius], y=[radius, depth-radius], z=[radius, height-radius])
|
|
50
|
+
translate([x,y,z]) sphere(r=radius, $fn=segments);
|
|
51
|
+
}""",
|
|
52
|
+
"tube": """difference() {
|
|
53
|
+
cylinder(h=height, r=outer_radius, center=center, $fn=segments);
|
|
54
|
+
translate([0,0,center ? 0 : -0.01])
|
|
55
|
+
cylinder(h=height+0.02, r=inner_radius, center=center, $fn=segments);
|
|
56
|
+
}""",
|
|
57
|
+
"cone": "cylinder(h=height, r1=bottom_radius, r2=top_radius, center=center, $fn=segments);",
|
|
58
|
+
"torus": """rotate_extrude($fn=segments)
|
|
59
|
+
translate([major_radius,0,0]) circle(r=minor_radius, $fn=segments);""",
|
|
60
|
+
"prism": "linear_extrude(height=height) polygon([[0,0],[width,0],[0,depth]]);",
|
|
61
|
+
"hexagonal_prism": "cylinder(h=height, r=radius, center=center, $fn=6);",
|
|
62
|
+
"text": 'linear_extrude(height=height) text(text, size=size, halign="center", valign="center");',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parameters_for(shape: str, values: dict | None = None) -> dict:
|
|
67
|
+
if shape not in DEFAULTS:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Unsupported model type {shape!r}. Supported: {', '.join(DEFAULTS)}"
|
|
70
|
+
)
|
|
71
|
+
values = values or {}
|
|
72
|
+
unknown = values.keys() - DEFAULTS[shape].keys()
|
|
73
|
+
if unknown:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Unsupported parameters for {shape}: {', '.join(sorted(unknown))}"
|
|
76
|
+
)
|
|
77
|
+
p = DEFAULTS[shape] | values
|
|
78
|
+
for key, value in p.items():
|
|
79
|
+
if key == "center":
|
|
80
|
+
if type(value) is not bool:
|
|
81
|
+
raise ValueError("center must be a boolean")
|
|
82
|
+
elif key == "text":
|
|
83
|
+
if not isinstance(value, str) or not value.strip() or len(value) > 200:
|
|
84
|
+
raise ValueError("text must contain 1–200 characters")
|
|
85
|
+
elif key == "segments":
|
|
86
|
+
if type(value) is not int or not 12 <= value <= 256:
|
|
87
|
+
raise ValueError("segments must be an integer between 12 and 256")
|
|
88
|
+
elif (
|
|
89
|
+
type(value) not in (int, float)
|
|
90
|
+
or value < 0
|
|
91
|
+
or value > 10000
|
|
92
|
+
or not math.isfinite(value)
|
|
93
|
+
or (value == 0 and key != "top_radius")
|
|
94
|
+
):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"{key} must be a finite positive number, at most 10000 mm"
|
|
97
|
+
)
|
|
98
|
+
if shape == "box" and (
|
|
99
|
+
2 * p["thickness"] >= min(p["width"], p["depth"])
|
|
100
|
+
or p["thickness"] >= p["height"]
|
|
101
|
+
):
|
|
102
|
+
raise ValueError("thickness leaves no interior in the box")
|
|
103
|
+
if shape == "rounded_box" and 2 * p["radius"] > min(
|
|
104
|
+
p["width"], p["depth"], p["height"]
|
|
105
|
+
):
|
|
106
|
+
raise ValueError("radius exceeds half the smallest box dimension")
|
|
107
|
+
if shape == "tube" and p["inner_radius"] >= p["outer_radius"]:
|
|
108
|
+
raise ValueError("inner_radius must be smaller than outer_radius")
|
|
109
|
+
if shape == "torus" and p["minor_radius"] >= p["major_radius"]:
|
|
110
|
+
raise ValueError("minor_radius must be smaller than major_radius")
|
|
111
|
+
return p
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def generate_source(shape: str, values: dict | None = None) -> tuple[str, dict]:
|
|
115
|
+
p = parameters_for(shape, values)
|
|
116
|
+
source = "// Dimensions in millimeters. Edit the parameters below.\n"
|
|
117
|
+
source += "\n".join(
|
|
118
|
+
f"{key} = {json.dumps(value, ensure_ascii=False)};" for key, value in p.items()
|
|
119
|
+
)
|
|
120
|
+
return source + "\n\n" + BODIES[shape] + "\n", p
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Restricted description syntax is intentionally separate from arbitrary SCAD.
|
|
124
|
+
SHAPES = [
|
|
125
|
+
("rounded_box", r"rounded[ _]box"),
|
|
126
|
+
("box", r"hollow box|container|tray|box"),
|
|
127
|
+
("hexagonal_prism", r"hexagonal[ _]prism"),
|
|
128
|
+
("tube", r"tube|pipe"),
|
|
129
|
+
("cube", r"cube|cuboid|block"),
|
|
130
|
+
("sphere", r"sphere|ball"),
|
|
131
|
+
("cylinder", r"cylinder|rod"),
|
|
132
|
+
("cone", r"cone"),
|
|
133
|
+
("torus", r"torus|donut"),
|
|
134
|
+
("prism", r"triangular prism|prism|wedge"),
|
|
135
|
+
]
|
|
136
|
+
UNITS = {
|
|
137
|
+
None: 1,
|
|
138
|
+
"mm": 1,
|
|
139
|
+
"cm": 10,
|
|
140
|
+
"m": 1000,
|
|
141
|
+
"in": 25.4,
|
|
142
|
+
"inch": 25.4,
|
|
143
|
+
"inches": 25.4,
|
|
144
|
+
}
|
|
145
|
+
NUMBER = r"(-?(?:\d+(?:\.\d*)?|\.\d+))\s*(mm|cm|m|inches|inch|in)?\b"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_description(
|
|
149
|
+
description: str, shape: str | None = None, existing: dict | None = None
|
|
150
|
+
) -> tuple[str, dict]:
|
|
151
|
+
if not description.strip() or len(description) > 2000:
|
|
152
|
+
raise ValueError("description must contain 1–2000 characters")
|
|
153
|
+
if re.search(r"\b(ft|feet|foot)\b", description, re.I):
|
|
154
|
+
raise ValueError("Use mm, cm, m, or inches")
|
|
155
|
+
if re.search(
|
|
156
|
+
r"\b(hole|holes|cutout|subtract|union|difference|intersection)\b",
|
|
157
|
+
description,
|
|
158
|
+
re.I,
|
|
159
|
+
):
|
|
160
|
+
raise ValueError("Use create_model_from_scad for composite geometry")
|
|
161
|
+
if shape is None:
|
|
162
|
+
for candidate, pattern in SHAPES:
|
|
163
|
+
match = re.search(rf"\b(?:{pattern})\b", description, re.I)
|
|
164
|
+
if match:
|
|
165
|
+
remainder = description[: match.start()] + description[match.end() :]
|
|
166
|
+
if any(
|
|
167
|
+
re.search(rf"\b(?:{other})\b", remainder, re.I)
|
|
168
|
+
for _, other in SHAPES
|
|
169
|
+
):
|
|
170
|
+
raise ValueError("Use create_model_from_scad for multiple shapes")
|
|
171
|
+
shape = candidate
|
|
172
|
+
break
|
|
173
|
+
else:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
"No supported primitive found. Use explicit model_type/parameters or create_model_from_scad for complex models."
|
|
176
|
+
)
|
|
177
|
+
if shape not in DEFAULTS:
|
|
178
|
+
raise ValueError("This model cannot be edited with a primitive description")
|
|
179
|
+
values = dict(existing or {})
|
|
180
|
+
found = False
|
|
181
|
+
consumed_numbers = set()
|
|
182
|
+
for key in DEFAULTS[shape]:
|
|
183
|
+
if key in ("text", "center"):
|
|
184
|
+
continue
|
|
185
|
+
label = key.replace("_", "[ _]")
|
|
186
|
+
aliases = {
|
|
187
|
+
"width": "width|wide",
|
|
188
|
+
"height": "height|high|tall",
|
|
189
|
+
"depth": "depth|deep|long",
|
|
190
|
+
"thickness": "thickness|thick",
|
|
191
|
+
}.get(key, label)
|
|
192
|
+
patterns = [
|
|
193
|
+
rf"\b(?:{label})\s*(?:=|:|of|to)?\s*{NUMBER}",
|
|
194
|
+
rf"{NUMBER}\s*(?:{aliases})\b",
|
|
195
|
+
]
|
|
196
|
+
matches = [
|
|
197
|
+
m for pattern in patterns for m in re.finditer(pattern, description, re.I)
|
|
198
|
+
]
|
|
199
|
+
if matches:
|
|
200
|
+
match = max(matches, key=lambda m: m.start())
|
|
201
|
+
if key == "segments" and match.group(2):
|
|
202
|
+
raise ValueError("segments is a count, not a length")
|
|
203
|
+
value = (
|
|
204
|
+
float(match.group(1))
|
|
205
|
+
* UNITS[match.group(2).lower() if match.group(2) else None]
|
|
206
|
+
)
|
|
207
|
+
values[key] = (
|
|
208
|
+
int(value) if key == "segments" and value.is_integer() else value
|
|
209
|
+
)
|
|
210
|
+
consumed_numbers.update(m.span(1) for m in matches)
|
|
211
|
+
found = True
|
|
212
|
+
if "radius" in DEFAULTS[shape]:
|
|
213
|
+
diameter = re.search(
|
|
214
|
+
rf"\bdiameter\s*(?:=|:|of|to)?\s*{NUMBER}", description, re.I
|
|
215
|
+
)
|
|
216
|
+
if diameter:
|
|
217
|
+
values["radius"] = (
|
|
218
|
+
float(diameter[1])
|
|
219
|
+
* UNITS[diameter[2].lower() if diameter[2] else None]
|
|
220
|
+
/ 2
|
|
221
|
+
)
|
|
222
|
+
consumed_numbers.add(diameter.span(1))
|
|
223
|
+
found = True
|
|
224
|
+
if "center" in DEFAULTS[shape] and re.search(
|
|
225
|
+
r"\bcent(er|re)(ed)?\b", description, re.I
|
|
226
|
+
):
|
|
227
|
+
values["center"] = not bool(
|
|
228
|
+
re.search(r"\b(?:not|un)[ -]?cent(er|re)(ed)?\b", description, re.I)
|
|
229
|
+
)
|
|
230
|
+
found = True
|
|
231
|
+
if existing is not None and not found:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
"No parameter changes recognized. Use e.g. 'height 30 mm' or explicit parameters."
|
|
234
|
+
)
|
|
235
|
+
number_spans = {
|
|
236
|
+
m.span() for m in re.finditer(r"-?(?:\d+(?:\.\d*)?|\.\d+)", description)
|
|
237
|
+
}
|
|
238
|
+
if number_spans - consumed_numbers:
|
|
239
|
+
raise ValueError(
|
|
240
|
+
"Dimensions were not understood. Name each parameter, e.g. 'width 30 mm height 20 mm'."
|
|
241
|
+
)
|
|
242
|
+
return shape, parameters_for(shape, values)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Official MCP transports plus local preview/download HTTP routes."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI, Request
|
|
13
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
14
|
+
from mcp.server.fastmcp import FastMCP, Image
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, validate_call
|
|
16
|
+
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .engine import OpenSCAD, OpenSCADError
|
|
20
|
+
from .geometry import DEFAULTS
|
|
21
|
+
from .service import ModelConflict, ModelNotFound, ModelService
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_server(service: ModelService) -> tuple[FastMCP, FastAPI]:
|
|
25
|
+
mcp = FastMCP(
|
|
26
|
+
"OpenSCAD",
|
|
27
|
+
stateless_http=True,
|
|
28
|
+
json_response=True,
|
|
29
|
+
instructions="Create parametric primitives with explicit dimensions in mm, or supply OpenSCAD source for complex models. Read the returned parameters. Only SCAD preserves editable source. This local server executes trusted SCAD with the current user's file access.",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
async def create_3d_model(
|
|
33
|
+
description: str = "",
|
|
34
|
+
model_type: str | None = None,
|
|
35
|
+
parameters: dict | None = None,
|
|
36
|
+
) -> dict:
|
|
37
|
+
"""Create a primitive, STL and four PNG views. Prefer model_type and parameters; descriptions only recognize named primitive dimensions. Use get_capabilities for defaults."""
|
|
38
|
+
return await asyncio.to_thread(
|
|
39
|
+
service.create, description, model_type, parameters
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
async def create_model_from_scad(scad_code: str, description: str = "") -> dict:
|
|
43
|
+
"""Compile trusted, self-contained 3D OpenSCAD source into STL and four PNG previews. No image reconstruction or implicit AI generation."""
|
|
44
|
+
return await asyncio.to_thread(service.from_scad, scad_code, description)
|
|
45
|
+
|
|
46
|
+
async def modify_3d_model(
|
|
47
|
+
model_id: str,
|
|
48
|
+
modifications: str = "",
|
|
49
|
+
parameters: dict | None = None,
|
|
50
|
+
scad_code: str | None = None,
|
|
51
|
+
expected_revision: str | None = None,
|
|
52
|
+
) -> dict:
|
|
53
|
+
"""Edit primitive dimensions or replace source using scad_code. Pass expected_revision from get_model/get_model_source to reject stale edits. Failed edits leave the previous revision intact."""
|
|
54
|
+
return await asyncio.to_thread(
|
|
55
|
+
service.modify,
|
|
56
|
+
model_id,
|
|
57
|
+
modifications,
|
|
58
|
+
parameters,
|
|
59
|
+
scad_code,
|
|
60
|
+
expected_revision,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def export_model(model_id: str, format: str = "stl") -> dict:
|
|
64
|
+
"""Export scad (editable source), stl/3mf (meshes), or csg (evaluated geometry tree)."""
|
|
65
|
+
return await asyncio.to_thread(service.export, model_id, format)
|
|
66
|
+
|
|
67
|
+
async def get_model(model_id: str) -> dict:
|
|
68
|
+
"""Read a persisted model's parameters and local artifact paths."""
|
|
69
|
+
return await asyncio.to_thread(service.get, model_id)
|
|
70
|
+
|
|
71
|
+
async def get_model_source(model_id: str) -> dict:
|
|
72
|
+
"""Read the saved UTF-8 SCAD source and revision over MCP, including inside Docker. Edit scad_code and pass its revision as expected_revision to modify_3d_model."""
|
|
73
|
+
return await asyncio.to_thread(service.source, model_id)
|
|
74
|
+
|
|
75
|
+
async def get_model_preview(model_id: str, view: str = "perspective") -> Image:
|
|
76
|
+
"""Return an actual PNG image to the MCP client. Views: perspective, front, top, right."""
|
|
77
|
+
path = await asyncio.to_thread(service.preview, model_id, view)
|
|
78
|
+
return Image(path=str(path))
|
|
79
|
+
|
|
80
|
+
async def get_capabilities() -> dict:
|
|
81
|
+
"""Report OpenSCAD version and supported primitives with defaults (all dimensions in mm)."""
|
|
82
|
+
version = await asyncio.to_thread(service.engine.version)
|
|
83
|
+
return {
|
|
84
|
+
"version": __version__,
|
|
85
|
+
"openscad": version,
|
|
86
|
+
"primitives": DEFAULTS,
|
|
87
|
+
"transports": ["stdio", "streamable-http"],
|
|
88
|
+
"mcp_path": "/mcp",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
functions = [
|
|
92
|
+
create_3d_model,
|
|
93
|
+
create_model_from_scad,
|
|
94
|
+
modify_3d_model,
|
|
95
|
+
export_model,
|
|
96
|
+
get_model,
|
|
97
|
+
get_model_source,
|
|
98
|
+
get_capabilities,
|
|
99
|
+
]
|
|
100
|
+
rest_tools = {}
|
|
101
|
+
for function in functions:
|
|
102
|
+
mcp.add_tool(function)
|
|
103
|
+
rest_tools[function.__name__] = validate_call(
|
|
104
|
+
config=ConfigDict(extra="forbid")
|
|
105
|
+
)(function)
|
|
106
|
+
mcp.add_tool(get_model_preview)
|
|
107
|
+
mcp_app = mcp.streamable_http_app()
|
|
108
|
+
|
|
109
|
+
@asynccontextmanager
|
|
110
|
+
async def lifespan(app):
|
|
111
|
+
async with mcp.session_manager.run():
|
|
112
|
+
yield
|
|
113
|
+
|
|
114
|
+
app = FastAPI(title="OpenSCAD MCP Server", version=__version__, lifespan=lifespan)
|
|
115
|
+
app.add_middleware(
|
|
116
|
+
TrustedHostMiddleware, allowed_hosts=["localhost", "127.0.0.1", "[::1]"]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@app.middleware("http")
|
|
120
|
+
async def local_origin(request: Request, call_next):
|
|
121
|
+
origin = request.headers.get("origin")
|
|
122
|
+
if origin:
|
|
123
|
+
from urllib.parse import urlsplit
|
|
124
|
+
|
|
125
|
+
parsed = urlsplit(origin)
|
|
126
|
+
if parsed.scheme not in ("http", "https") or parsed.hostname not in (
|
|
127
|
+
"localhost",
|
|
128
|
+
"127.0.0.1",
|
|
129
|
+
"::1",
|
|
130
|
+
):
|
|
131
|
+
return JSONResponse(
|
|
132
|
+
{"detail": "Only local browser origins are allowed"},
|
|
133
|
+
status_code=403,
|
|
134
|
+
)
|
|
135
|
+
response = await call_next(request)
|
|
136
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
137
|
+
return response
|
|
138
|
+
|
|
139
|
+
@app.exception_handler(ModelNotFound)
|
|
140
|
+
async def missing(request, exc):
|
|
141
|
+
return JSONResponse({"detail": str(exc)}, status_code=404)
|
|
142
|
+
|
|
143
|
+
@app.exception_handler(ModelConflict)
|
|
144
|
+
async def conflict(request, exc):
|
|
145
|
+
return JSONResponse(
|
|
146
|
+
{"detail": str(exc), "current_revision": exc.current_revision},
|
|
147
|
+
status_code=409,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@app.exception_handler(ValueError)
|
|
151
|
+
async def invalid(request, exc):
|
|
152
|
+
return JSONResponse({"detail": str(exc)}, status_code=422)
|
|
153
|
+
|
|
154
|
+
@app.exception_handler(OpenSCADError)
|
|
155
|
+
async def render_failed(request, exc):
|
|
156
|
+
return JSONResponse({"detail": str(exc)}, status_code=422)
|
|
157
|
+
|
|
158
|
+
@app.get("/")
|
|
159
|
+
async def root():
|
|
160
|
+
return {
|
|
161
|
+
"name": "OpenSCAD MCP Server",
|
|
162
|
+
"version": __version__,
|
|
163
|
+
"mcp_endpoint": "/mcp",
|
|
164
|
+
"tools": list(rest_tools) + ["get_model_preview"],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@app.get("/health")
|
|
168
|
+
async def health():
|
|
169
|
+
return await get_capabilities()
|
|
170
|
+
|
|
171
|
+
class ToolCall(BaseModel):
|
|
172
|
+
model_config = ConfigDict(extra="forbid")
|
|
173
|
+
tool_name: str
|
|
174
|
+
tool_params: dict = Field(default_factory=dict)
|
|
175
|
+
|
|
176
|
+
@app.post("/tool_call")
|
|
177
|
+
async def tool_call(body: ToolCall):
|
|
178
|
+
if body.tool_name not in rest_tools:
|
|
179
|
+
return JSONResponse({"detail": "Unknown tool"}, status_code=404)
|
|
180
|
+
try:
|
|
181
|
+
return await rest_tools[body.tool_name](**body.tool_params)
|
|
182
|
+
except ValidationError as exc:
|
|
183
|
+
return JSONResponse({"detail": str(exc)}, status_code=422)
|
|
184
|
+
|
|
185
|
+
@app.get("/preview/{view}/{model_id}")
|
|
186
|
+
async def preview(view: str, model_id: str):
|
|
187
|
+
path = await asyncio.to_thread(service.preview, model_id, view)
|
|
188
|
+
return FileResponse(
|
|
189
|
+
path, media_type="image/png", headers={"Cache-Control": "no-store"}
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
@app.get("/download/{model_id}")
|
|
193
|
+
async def download(model_id: str, format: str = "stl"):
|
|
194
|
+
data = await asyncio.to_thread(service.export, model_id, format)
|
|
195
|
+
return FileResponse(data["model_file"], filename=f"{model_id}.{format}")
|
|
196
|
+
|
|
197
|
+
@app.get("/ui/preview/{model_id}", response_class=HTMLResponse)
|
|
198
|
+
async def preview_page(model_id: str):
|
|
199
|
+
data = await asyncio.to_thread(service.get, model_id)
|
|
200
|
+
views = "".join(
|
|
201
|
+
f'<figure><img width="400" src="{url}" alt="{view}"><figcaption>{view}</figcaption></figure>'
|
|
202
|
+
for view, url in data["previews"].items()
|
|
203
|
+
)
|
|
204
|
+
links = " · ".join(
|
|
205
|
+
f'<a href="/download/{model_id}?format={fmt}">{fmt.upper()}</a>'
|
|
206
|
+
for fmt in data["supported_formats"]
|
|
207
|
+
)
|
|
208
|
+
return f'<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>OpenSCAD model</title><body><h1>{html.escape(data["description"] or data["model_type"])}</h1><pre>{html.escape(json.dumps(data["parameters"], indent=2))}</pre><nav>{links}</nav><main style="display:flex;flex-wrap:wrap">{views}</main></body></html>'
|
|
209
|
+
|
|
210
|
+
app.mount("/", mcp_app)
|
|
211
|
+
return mcp, app
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main():
|
|
215
|
+
parser = argparse.ArgumentParser(description="Local OpenSCAD MCP server")
|
|
216
|
+
parser.add_argument("--transport", choices=["stdio", "http"], default="stdio")
|
|
217
|
+
parser.add_argument("--port", type=int, default=8000)
|
|
218
|
+
parser.add_argument(
|
|
219
|
+
"--output-dir",
|
|
220
|
+
default=os.environ.get(
|
|
221
|
+
"OPENSCAD_OUTPUT_DIR", str(Path.home() / ".local/share/openscad-mcp/models")
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
parser.add_argument("--openscad", default=os.environ.get("OPENSCAD_EXECUTABLE"))
|
|
225
|
+
parser.add_argument("--timeout", type=float, default=120)
|
|
226
|
+
args = parser.parse_args()
|
|
227
|
+
if args.timeout <= 0:
|
|
228
|
+
parser.error("--timeout must be positive")
|
|
229
|
+
logging.basicConfig(level=logging.INFO)
|
|
230
|
+
try:
|
|
231
|
+
engine = OpenSCAD(args.openscad, args.timeout)
|
|
232
|
+
engine.version()
|
|
233
|
+
mcp, app = create_server(ModelService(args.output_dir, engine))
|
|
234
|
+
except OpenSCADError as exc:
|
|
235
|
+
parser.exit(1, f"{exc}\n")
|
|
236
|
+
if args.transport == "stdio":
|
|
237
|
+
mcp.run(transport="stdio")
|
|
238
|
+
else:
|
|
239
|
+
import uvicorn
|
|
240
|
+
|
|
241
|
+
uvicorn.run(app, host="127.0.0.1", port=args.port)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
main()
|