@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.
Files changed (192) hide show
  1. package/README.md +116 -0
  2. package/config/mcp/antigravity.json +22 -0
  3. package/config/mcp/claude.json +22 -0
  4. package/config/mcp/codex.toml +25 -0
  5. package/docs/adr/ADR-001-KICAD-OPENSCAD-MCP-STRATEGY.md +334 -0
  6. package/docs/review_documentation.md +121 -0
  7. package/installer.js +358 -0
  8. package/mcp_servers/kicad-mcp-server/.env.example +22 -0
  9. package/mcp_servers/kicad-mcp-server/.github/workflows/ci.yml +36 -0
  10. package/mcp_servers/kicad-mcp-server/CLAUDE.md +487 -0
  11. package/mcp_servers/kicad-mcp-server/README.md +316 -0
  12. package/mcp_servers/kicad-mcp-server/docs/DEVICE_TREE.md +416 -0
  13. package/mcp_servers/kicad-mcp-server/docs/INSTALLATION.md +332 -0
  14. package/mcp_servers/kicad-mcp-server/docs/PIN_ANALYSIS.md +332 -0
  15. package/mcp_servers/kicad-mcp-server/docs/README.md +240 -0
  16. package/mcp_servers/kicad-mcp-server/docs/TESTING.md +613 -0
  17. package/mcp_servers/kicad-mcp-server/docs/VALIDATION.md +268 -0
  18. package/mcp_servers/kicad-mcp-server/pyproject.toml +96 -0
  19. package/mcp_servers/kicad-mcp-server/requirements-dev.txt +16 -0
  20. package/mcp_servers/kicad-mcp-server/requirements-test.txt +24 -0
  21. package/mcp_servers/kicad-mcp-server/requirements.txt +13 -0
  22. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__init__.py +3 -0
  23. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__main__.py +17 -0
  24. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/config.py +46 -0
  25. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/__init__.py +1 -0
  26. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/types.py +87 -0
  27. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/__init__.py +1 -0
  28. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/netlist_parser.py +234 -0
  29. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser.py +375 -0
  30. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser_kicad.py +327 -0
  31. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/schematic_parser.py +902 -0
  32. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/server.py +71 -0
  33. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/__init__.py +1 -0
  34. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/arduino/connectivity_test.cpp.j2 +189 -0
  35. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/atmega.dts.j2 +77 -0
  36. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/esp32.dts.j2 +77 -0
  37. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/nrf52.dts.j2 +77 -0
  38. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/stm32f4.dts.j2 +89 -0
  39. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/esp_idf/test_suite.c.j2 +340 -0
  40. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/pytest/test_connectivity.py.j2 +147 -0
  41. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/st_hal/hal_test.c.j2 +313 -0
  42. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_gpio_test.py.j2 +99 -0
  43. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_i2c_test.py.j2 +117 -0
  44. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_pinmux_test.py.j2 +43 -0
  45. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_spi_test.py.j2 +94 -0
  46. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_gpio_test.c.j2 +113 -0
  47. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_i2c_test.c.j2 +101 -0
  48. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_spi_test.c.j2 +94 -0
  49. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/unittest/test_schematic.py.j2 +172 -0
  50. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/__init__.py +36 -0
  51. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/device_tree.py +1187 -0
  52. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/hierarchical_analysis.py +211 -0
  53. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/netlist.py +320 -0
  54. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/parts_registry.py +142 -0
  55. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb.py +955 -0
  56. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb_layout.py +308 -0
  57. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pin_analysis.py +765 -0
  58. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/project.py +196 -0
  59. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic.py +319 -0
  60. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_editor.py +674 -0
  61. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_search.py +158 -0
  62. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/validation.py +866 -0
  63. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/visualization.py +225 -0
  64. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/__init__.py +1 -0
  65. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/file_handlers.py +65 -0
  66. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_cli.py +103 -0
  67. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_version.py +103 -0
  68. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/parts_registry.py +197 -0
  69. package/mcp_servers/kicad-mcp-server/tests/__init__.py +1 -0
  70. package/mcp_servers/kicad-mcp-server/tests/examples/ESP32S3_TEST.md +219 -0
  71. package/mcp_servers/kicad-mcp-server/tests/fixtures/README.md +65 -0
  72. package/mcp_servers/kicad-mcp-server/tests/fixtures/__init__.py +1 -0
  73. package/mcp_servers/kicad-mcp-server/tests/fixtures/example_pcb.kicad_pcb +177 -0
  74. package/mcp_servers/kicad-mcp-server/tests/fixtures/example_schematic.kicad_sch +145 -0
  75. package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/child.kicad_sch +24 -0
  76. package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/root.kicad_sch +38 -0
  77. package/mcp_servers/kicad-mcp-server/tests/test_tools/__init__.py +1 -0
  78. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_hierarchical_labels.py +195 -0
  79. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_kicad_cli.py +116 -0
  80. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_netlist_cache_path.py +29 -0
  81. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic.py +189 -0
  82. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic_hierarchy.py +69 -0
  83. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_visualization.py +136 -0
  84. package/mcp_servers/kicad-mcp-server/uv.lock +2873 -0
  85. package/mcp_servers/openscad-mcp-server/.dockerignore +9 -0
  86. package/mcp_servers/openscad-mcp-server/.github/workflows/test.yml +40 -0
  87. package/mcp_servers/openscad-mcp-server/Dockerfile +29 -0
  88. package/mcp_servers/openscad-mcp-server/LICENSE +21 -0
  89. package/mcp_servers/openscad-mcp-server/README.md +154 -0
  90. package/mcp_servers/openscad-mcp-server/docs/audit.md +56 -0
  91. package/mcp_servers/openscad-mcp-server/docs/docker.md +66 -0
  92. package/mcp_servers/openscad-mcp-server/docs/issue-followup.md +19 -0
  93. package/mcp_servers/openscad-mcp-server/docs/jetson.md +17 -0
  94. package/mcp_servers/openscad-mcp-server/glama.json +4 -0
  95. package/mcp_servers/openscad-mcp-server/legacy/README.md +15 -0
  96. package/mcp_servers/openscad-mcp-server/legacy/README.original.md +294 -0
  97. package/mcp_servers/openscad-mcp-server/legacy/implementation_plan.md +100 -0
  98. package/mcp_servers/openscad-mcp-server/legacy/old/download_sam2_checkpoint.py +115 -0
  99. package/mcp_servers/openscad-mcp-server/legacy/old/src/ai/sam_segmentation.py +209 -0
  100. package/mcp_servers/openscad-mcp-server/legacy/old/src/models/threestudio_generator.py +231 -0
  101. package/mcp_servers/openscad-mcp-server/legacy/old/src/workflow/image_to_model_pipeline.py +260 -0
  102. package/mcp_servers/openscad-mcp-server/legacy/old/test_sam2_segmentation.py +96 -0
  103. package/mcp_servers/openscad-mcp-server/legacy/requirements.txt +57 -0
  104. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/README.md +39 -0
  105. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/ai-driven-code-generation.md +122 -0
  106. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/export-formats.md +76 -0
  107. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/ai/ai_service.py.md +51 -0
  108. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/main.py.md +63 -0
  109. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/models/code_generator.py.md +63 -0
  110. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/nlp/parameter_extractor.py.md +63 -0
  111. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/ai/natural-language-processing.md +78 -0
  112. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/nlp/parameter-extraction.md +173 -0
  113. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/export-formats.md +91 -0
  114. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/openscad-basics.md +66 -0
  115. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/primitive-testing.md +79 -0
  116. package/mcp_servers/openscad-mcp-server/legacy/src/__init__.py +0 -0
  117. package/mcp_servers/openscad-mcp-server/legacy/src/ai/ai_service.py +257 -0
  118. package/mcp_servers/openscad-mcp-server/legacy/src/ai/gemini_api.py +161 -0
  119. package/mcp_servers/openscad-mcp-server/legacy/src/ai/venice_api.py +203 -0
  120. package/mcp_servers/openscad-mcp-server/legacy/src/config.py +121 -0
  121. package/mcp_servers/openscad-mcp-server/legacy/src/main.py +1456 -0
  122. package/mcp_servers/openscad-mcp-server/legacy/src/main.py.new +404 -0
  123. package/mcp_servers/openscad-mcp-server/legacy/src/main_remote.py +401 -0
  124. package/mcp_servers/openscad-mcp-server/legacy/src/models/__init__.py +0 -0
  125. package/mcp_servers/openscad-mcp-server/legacy/src/models/code_generator.py +321 -0
  126. package/mcp_servers/openscad-mcp-server/legacy/src/models/cuda_mvs.py +209 -0
  127. package/mcp_servers/openscad-mcp-server/legacy/src/models/scad_templates/basic_shapes.scad +144 -0
  128. package/mcp_servers/openscad-mcp-server/legacy/src/nlp/__init__.py +0 -0
  129. package/mcp_servers/openscad-mcp-server/legacy/src/nlp/parameter_extractor.py +388 -0
  130. package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/__init__.py +0 -0
  131. package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/wrapper.py +418 -0
  132. package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/__init__.py +1 -0
  133. package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/printer_discovery.py +471 -0
  134. package/mcp_servers/openscad-mcp-server/legacy/src/remote/connection_manager.py +537 -0
  135. package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_client.py +435 -0
  136. package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_server.py +787 -0
  137. package/mcp_servers/openscad-mcp-server/legacy/src/remote/error_handling.py +415 -0
  138. package/mcp_servers/openscad-mcp-server/legacy/src/testing/__init__.py +0 -0
  139. package/mcp_servers/openscad-mcp-server/legacy/src/testing/primitive_tester.py +203 -0
  140. package/mcp_servers/openscad-mcp-server/legacy/src/testing/test_primitives.py +98 -0
  141. package/mcp_servers/openscad-mcp-server/legacy/src/utils/__init__.py +1 -0
  142. package/mcp_servers/openscad-mcp-server/legacy/src/utils/cad_exporter.py +241 -0
  143. package/mcp_servers/openscad-mcp-server/legacy/src/utils/format_validator.py +206 -0
  144. package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_exporter.py +140 -0
  145. package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_repair.py +91 -0
  146. package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_validator.py +123 -0
  147. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/__init__.py +0 -0
  148. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/headless_renderer.py +52 -0
  149. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/renderer.py +177 -0
  150. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/web_interface.py +639 -0
  151. package/mcp_servers/openscad-mcp-server/legacy/src/workflow/image_approval.py +148 -0
  152. package/mcp_servers/openscad-mcp-server/legacy/src/workflow/multi_view_to_model_pipeline.py +338 -0
  153. package/mcp_servers/openscad-mcp-server/legacy/test_complete_workflow.py +374 -0
  154. package/mcp_servers/openscad-mcp-server/legacy/test_cuda_mvs.py +191 -0
  155. package/mcp_servers/openscad-mcp-server/legacy/test_gemini_api.py +168 -0
  156. package/mcp_servers/openscad-mcp-server/legacy/test_image_approval.py +192 -0
  157. package/mcp_servers/openscad-mcp-server/legacy/test_image_approval_workflow.py +251 -0
  158. package/mcp_servers/openscad-mcp-server/legacy/test_image_to_model_pipeline.py +145 -0
  159. package/mcp_servers/openscad-mcp-server/legacy/test_model_selection.py +41 -0
  160. package/mcp_servers/openscad-mcp-server/legacy/test_multi_view_pipeline.py +290 -0
  161. package/mcp_servers/openscad-mcp-server/legacy/test_primitives.sh +13 -0
  162. package/mcp_servers/openscad-mcp-server/legacy/test_rabbit_direct.py +71 -0
  163. package/mcp_servers/openscad-mcp-server/legacy/test_remote_cuda_mvs.py +283 -0
  164. package/mcp_servers/openscad-mcp-server/legacy/test_venice_example.py +69 -0
  165. package/mcp_servers/openscad-mcp-server/pyproject.toml +33 -0
  166. package/mcp_servers/openscad-mcp-server/requirements.txt +2 -0
  167. package/mcp_servers/openscad-mcp-server/scad/simple_cube.scad +2 -0
  168. package/mcp_servers/openscad-mcp-server/scripts/test_docker.py +220 -0
  169. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__init__.py +3 -0
  170. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__main__.py +3 -0
  171. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/engine.py +94 -0
  172. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/geometry.py +242 -0
  173. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/server.py +245 -0
  174. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/service.py +190 -0
  175. package/mcp_servers/openscad-mcp-server/tests/conftest.py +17 -0
  176. package/mcp_servers/openscad-mcp-server/tests/test_engine.py +37 -0
  177. package/mcp_servers/openscad-mcp-server/tests/test_geometry.py +106 -0
  178. package/mcp_servers/openscad-mcp-server/tests/test_integration.py +172 -0
  179. package/mcp_servers/openscad-mcp-server/tests/test_transports.py +314 -0
  180. package/mcp_servers/openscad-mcp-server/uv.lock +1123 -0
  181. package/package.json +44 -0
  182. package/scripts/install_mcps.sh +86 -0
  183. package/scripts/openscad_wrapper.sh +62 -0
  184. package/scripts/run_kicad_mcp.sh +25 -0
  185. package/scripts/run_openscad_mcp.sh +41 -0
  186. package/scripts/test_mcp_connection.py +626 -0
  187. package/scripts/test_mcp_connection.sh +168 -0
  188. package/skills/code-first-hardware-design/SKILL.md +260 -0
  189. package/skills/pcb-constraint-definition/SKILL.md +236 -0
  190. package/skills/pcb-layout-routing-automation/SKILL.md +153 -0
  191. package/skills/pcb-validation-dfm-signoff/SKILL.md +194 -0
  192. 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()