@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,190 @@
1
+ """Persistent models with immutable revisions and commit-on-success edits."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import tempfile
8
+ import threading
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+ from .engine import FORMATS, VIEWS, OpenSCAD
13
+ from .geometry import generate_source, parse_description
14
+
15
+
16
+ class ModelNotFound(ValueError):
17
+ pass
18
+
19
+
20
+ class ModelConflict(ValueError):
21
+ def __init__(self, current_revision: str):
22
+ self.current_revision = current_revision
23
+ super().__init__(
24
+ "Model changed since it was read. Read the current source/parameters before retrying the edit."
25
+ )
26
+
27
+
28
+ class ModelService:
29
+ def __init__(self, output_dir: str | Path, engine: OpenSCAD | None = None):
30
+ self.root = Path(output_dir).expanduser().resolve()
31
+ self.root.mkdir(parents=True, exist_ok=True)
32
+ self.engine = engine or OpenSCAD()
33
+ # One process owns this store. Serialize renders/edits so updates cannot race.
34
+ self.lock = threading.RLock()
35
+
36
+ def _directory(self, model_id: str) -> Path:
37
+ if not re.fullmatch(r"[0-9a-f]{32}", model_id):
38
+ raise ModelNotFound("Invalid model ID")
39
+ return self.root / model_id
40
+
41
+ def _load(self, model_id: str) -> dict:
42
+ try:
43
+ return json.loads((self._directory(model_id) / "model.json").read_text())
44
+ except FileNotFoundError as exc:
45
+ raise ModelNotFound(f"Model {model_id} not found") from exc
46
+
47
+ def _save(self, model_id: str, metadata: dict):
48
+ directory = self._directory(model_id)
49
+ with tempfile.NamedTemporaryFile(
50
+ mode="w", dir=directory, delete=False, suffix=".json"
51
+ ) as stream:
52
+ temporary = Path(stream.name)
53
+ json.dump(metadata, stream, indent=2)
54
+ try:
55
+ os.replace(temporary, directory / "model.json")
56
+ finally:
57
+ temporary.unlink(missing_ok=True)
58
+
59
+ def _build(
60
+ self, model_id: str, source: str, shape: str, parameters: dict, description: str
61
+ ) -> dict:
62
+ directory = self._directory(model_id)
63
+ directory.mkdir(exist_ok=True)
64
+ revision = uuid.uuid4().hex
65
+ target = directory / revision
66
+ target.mkdir()
67
+ try:
68
+ scad = target / "model.scad"
69
+ scad.write_text(source, encoding="utf-8")
70
+ self.engine.export(scad, target / "model.stl")
71
+ for view in VIEWS:
72
+ self.engine.export(scad, target / f"{view}.png", view)
73
+ data = {
74
+ "model_id": model_id,
75
+ "revision": revision,
76
+ "model_type": shape,
77
+ "parameters": parameters,
78
+ "description": description,
79
+ }
80
+ self._save(model_id, data)
81
+ except Exception:
82
+ shutil.rmtree(target)
83
+ if not (directory / "model.json").exists():
84
+ directory.rmdir()
85
+ raise
86
+ return self._public(data)
87
+
88
+ def _public(self, data: dict) -> dict:
89
+ model_id = data["model_id"]
90
+ directory = self._directory(model_id) / data["revision"]
91
+ return data | {
92
+ "scad_file": str(directory / "model.scad"),
93
+ "model_file": str(directory / "model.stl"),
94
+ "preview_url": f"/ui/preview/{model_id}",
95
+ "previews": {view: f"/preview/{view}/{model_id}" for view in VIEWS},
96
+ "preview_files": {view: str(directory / f"{view}.png") for view in VIEWS},
97
+ "download_url": f"/download/{model_id}?format=stl",
98
+ "supported_formats": list(FORMATS),
99
+ }
100
+
101
+ def create(
102
+ self,
103
+ description: str = "",
104
+ model_type: str | None = None,
105
+ parameters: dict | None = None,
106
+ ) -> dict:
107
+ if model_type is None:
108
+ model_type, extracted = parse_description(description)
109
+ parameters = extracted | (parameters or {})
110
+ source, parameters = generate_source(model_type, parameters)
111
+ with self.lock:
112
+ return self._build(
113
+ uuid.uuid4().hex, source, model_type, parameters, description
114
+ )
115
+
116
+ def from_scad(self, source: str, description: str = "") -> dict:
117
+ if not source.strip() or len(source.encode()) > 100_000:
118
+ raise ValueError("SCAD source must contain 1–100000 bytes")
119
+ with self.lock:
120
+ return self._build(uuid.uuid4().hex, source, "custom", {}, description)
121
+
122
+ def modify(
123
+ self,
124
+ model_id: str,
125
+ modifications: str = "",
126
+ parameters: dict | None = None,
127
+ scad_code: str | None = None,
128
+ expected_revision: str | None = None,
129
+ ) -> dict:
130
+ with self.lock:
131
+ data = self._load(model_id)
132
+ if expected_revision is not None and expected_revision != data["revision"]:
133
+ raise ModelConflict(data["revision"])
134
+ if scad_code is not None:
135
+ if modifications or parameters:
136
+ raise ValueError("Supply scad_code alone when replacing source")
137
+ if not scad_code.strip() or len(scad_code.encode()) > 100_000:
138
+ raise ValueError("SCAD source must contain 1–100000 bytes")
139
+ return self._build(
140
+ model_id, scad_code, "custom", {}, data["description"]
141
+ )
142
+ if data["model_type"] == "custom":
143
+ raise ValueError("Supply scad_code to replace a custom model's source")
144
+ if not modifications and not parameters:
145
+ raise ValueError("Supply modifications, parameters, or scad_code")
146
+ values = data["parameters"]
147
+ if modifications:
148
+ _, values = parse_description(modifications, data["model_type"], values)
149
+ source, values = generate_source(
150
+ data["model_type"], values | (parameters or {})
151
+ )
152
+ return self._build(
153
+ model_id, source, data["model_type"], values, data["description"]
154
+ )
155
+
156
+ def get(self, model_id: str) -> dict:
157
+ return self._public(self._load(model_id))
158
+
159
+ def source(self, model_id: str) -> dict:
160
+ data = self._load(model_id)
161
+ path = self._directory(model_id) / data["revision"] / "model.scad"
162
+ return {
163
+ "model_id": model_id,
164
+ "revision": data["revision"],
165
+ "model_type": data["model_type"],
166
+ "parameters": data["parameters"],
167
+ "scad_code": path.read_text(encoding="utf-8"),
168
+ }
169
+
170
+ def export(self, model_id: str, format: str = "stl") -> dict:
171
+ if format not in FORMATS:
172
+ raise ValueError(f"Unsupported format. Choose from {', '.join(FORMATS)}")
173
+ with self.lock:
174
+ data = self._load(model_id)
175
+ directory = self._directory(model_id) / data["revision"]
176
+ output = directory / f"model.{format}"
177
+ if not output.exists():
178
+ self.engine.export(directory / "model.scad", output)
179
+ return {
180
+ "model_id": model_id,
181
+ "format": format,
182
+ "model_file": str(output),
183
+ "download_url": f"/download/{model_id}?format={format}",
184
+ }
185
+
186
+ def preview(self, model_id: str, view: str) -> Path:
187
+ if view not in VIEWS:
188
+ raise ModelNotFound("Unknown preview view")
189
+ data = self._load(model_id)
190
+ return self._directory(model_id) / data["revision"] / f"{view}.png"
@@ -0,0 +1,17 @@
1
+ import pytest
2
+
3
+ from openscad_mcp.engine import OpenSCAD
4
+ from openscad_mcp.service import ModelService
5
+
6
+
7
+ @pytest.fixture
8
+ def engine():
9
+ # Missing OpenSCAD is a failure: a green suite must include actual rendering.
10
+ result = OpenSCAD(timeout=120)
11
+ result.version()
12
+ return result
13
+
14
+
15
+ @pytest.fixture
16
+ def service(tmp_path, engine):
17
+ return ModelService(tmp_path / "models", engine)
@@ -0,0 +1,37 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from openscad_mcp.engine import OpenSCAD, OpenSCADError
7
+
8
+
9
+ def test_missing_executable():
10
+ with pytest.raises(OpenSCADError, match="Cannot launch"):
11
+ OpenSCAD("/no/such/openscad").version()
12
+
13
+
14
+ @pytest.mark.parametrize("mode", ["timeout", "empty", "warning", "error", "nonzero"])
15
+ def test_failures_do_not_replace_existing_outputs(tmp_path, monkeypatch, mode):
16
+ destination = tmp_path / "model.stl"
17
+ destination.write_bytes(b"previous valid revision")
18
+
19
+ def run(command, **kwargs):
20
+ if mode == "timeout":
21
+ raise subprocess.TimeoutExpired(command, 1)
22
+ output = Path(command[command.index("-o") + 1])
23
+ if mode != "empty":
24
+ output.write_bytes(b"bad output")
25
+ message = {
26
+ "warning": "WARNING: Ignoring unknown module",
27
+ "error": "ERROR: Assertion failed",
28
+ }.get(mode, "")
29
+ return subprocess.CompletedProcess(
30
+ command, 1 if mode == "nonzero" else 0, "", message
31
+ )
32
+
33
+ monkeypatch.setattr(subprocess, "run", run)
34
+ with pytest.raises(OpenSCADError):
35
+ OpenSCAD("openscad").export(tmp_path / "source.scad", destination)
36
+ assert destination.read_bytes() == b"previous valid revision"
37
+ assert not list(tmp_path.glob(".render-*"))
@@ -0,0 +1,106 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from openscad_mcp.geometry import generate_source, parameters_for, parse_description
6
+
7
+
8
+ @pytest.mark.parametrize(
9
+ ("description", "shape", "expected"),
10
+ [
11
+ (
12
+ "hollow box width 30 mm depth 20 mm height 15 mm thickness 2 mm",
13
+ "box",
14
+ {"width": 30, "thickness": 2},
15
+ ),
16
+ ("rounded box width 40 mm", "rounded_box", {"width": 40}),
17
+ (
18
+ "tube outer_radius 12 inner_radius 6 height 30",
19
+ "tube",
20
+ {"outer_radius": 12, "inner_radius": 6},
21
+ ),
22
+ (
23
+ "cube 2 cm wide 1 inch high depth 5 mm",
24
+ "cube",
25
+ {"width": 20, "height": 25.4, "depth": 5},
26
+ ),
27
+ ("sphere diameter 30 mm", "sphere", {"radius": 15}),
28
+ ("cube centered width 12", "cube", {"center": True, "width": 12}),
29
+ ("cube not centered width 12", "cube", {"center": False}),
30
+ ],
31
+ )
32
+ def test_descriptions(description, shape, expected):
33
+ actual_shape, parameters = parse_description(description)
34
+ assert actual_shape == shape
35
+ assert expected.items() <= parameters.items()
36
+
37
+
38
+ @pytest.mark.parametrize(
39
+ "description",
40
+ [
41
+ "a rabbit",
42
+ "cube 20x30x40",
43
+ "cube width -3 mm",
44
+ "cube height 0",
45
+ "cube width 3 feet",
46
+ ],
47
+ )
48
+ def test_reject_unrecognized_or_invalid_descriptions(description):
49
+ with pytest.raises(ValueError):
50
+ parse_description(description)
51
+
52
+
53
+ @pytest.mark.parametrize(
54
+ ("shape", "values"),
55
+ [
56
+ ("cube", {"width": float("nan")}),
57
+ ("cube", {"width": float("inf")}),
58
+ ("cube", {"width": True}),
59
+ ("cube", {"center": "false"}),
60
+ ("cube", {"radius": 3}),
61
+ ("tube", {"inner_radius": 12}),
62
+ ("box", {"thickness": 11}),
63
+ ("rounded_box", {"radius": 20}),
64
+ ("sphere", {"segments": 2}),
65
+ ("torus", {"minor_radius": 20}),
66
+ ("custom", {}),
67
+ ],
68
+ )
69
+ def test_reject_invalid_parameters(shape, values):
70
+ with pytest.raises(ValueError):
71
+ parameters_for(shape, values)
72
+
73
+
74
+ def test_edit_preserves_unspecified_dimensions():
75
+ existing = parameters_for("cube", {"width": 23, "center": True})
76
+ _, result = parse_description("height 32 mm", "cube", existing)
77
+ assert result == existing | {"height": 32}
78
+ assert existing["height"] == 10
79
+ with pytest.raises(ValueError, match="No parameter changes"):
80
+ parse_description("make it prettier", "cube", existing)
81
+
82
+
83
+ def test_source_is_self_contained_and_strings_are_escaped():
84
+ value = 'say "hello";\n cube(999); //'
85
+ source, _ = generate_source("text", {"text": value})
86
+ assert f"text = {json.dumps(value)};" in source
87
+ assert "include <" not in source
88
+
89
+
90
+ @pytest.mark.parametrize(
91
+ "description",
92
+ [
93
+ "cube width 20 radius 3",
94
+ "cube with a hole",
95
+ "cube and sphere",
96
+ "sphere segments 24 mm",
97
+ ],
98
+ )
99
+ def test_does_not_ignore_unsupported_dimensions_or_operations(description):
100
+ with pytest.raises(ValueError):
101
+ parse_description(description)
102
+
103
+
104
+ def test_huge_number_is_validation_error():
105
+ with pytest.raises(ValueError):
106
+ parameters_for("cube", {"width": 10**400})
@@ -0,0 +1,172 @@
1
+ import hashlib
2
+ import math
3
+ import shutil
4
+ import zipfile
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ import trimesh
9
+ from PIL import Image
10
+
11
+ from openscad_mcp.engine import OpenSCADError
12
+ from openscad_mcp.geometry import DEFAULTS, generate_source
13
+ from openscad_mcp.service import ModelNotFound, ModelService
14
+
15
+ pytestmark = pytest.mark.integration
16
+
17
+
18
+ @pytest.mark.parametrize("shape", list(DEFAULTS))
19
+ def test_every_primitive_is_real_closed_geometry(tmp_path, engine, shape):
20
+ source, _ = generate_source(shape)
21
+ scad = tmp_path / f"{shape}.scad"
22
+ scad.write_text(source)
23
+ stl = engine.export(scad, tmp_path / f"{shape}.stl")
24
+ mesh = trimesh.load_mesh(stl)
25
+ assert len(mesh.faces) >= 4
26
+ assert mesh.is_watertight
27
+ assert mesh.is_winding_consistent
28
+ assert mesh.volume > 0
29
+ if shape == "cube":
30
+ assert mesh.extents.tolist() == pytest.approx([10, 10, 10])
31
+ assert mesh.volume == pytest.approx(1000)
32
+ if shape == "box":
33
+ assert mesh.volume == pytest.approx(30 * 20 * 15 - 26 * 16 * 13)
34
+ if shape == "tube":
35
+ assert mesh.volume == pytest.approx(math.pi * (100 - 64) * 20, rel=0.01)
36
+ if shape == "prism":
37
+ assert mesh.volume == pytest.approx(2000)
38
+
39
+
40
+ def test_full_model_lifecycle(service, tmp_path):
41
+ model = service.create(
42
+ "hollow box width 30 mm depth 20 mm height 15 mm thickness 2 mm"
43
+ )
44
+ model_id = model["model_id"]
45
+ hashes = []
46
+ for path in model["preview_files"].values():
47
+ with Image.open(path) as image:
48
+ assert image.size == (800, 600)
49
+ assert image.convert("RGB").getextrema() != ((0, 0),) * 3
50
+ assert len(image.getcolors(800 * 600)) >= 2
51
+ # A filled silhouette must occupy a meaningful part of the image.
52
+ colors = image.convert("RGB").getcolors(800 * 600)
53
+ assert max(count for count, _ in colors) < 800 * 600 * 0.99
54
+ hashes.append(hashlib.sha256(Path(path).read_bytes()).hexdigest())
55
+ assert len(set(hashes)) == 4
56
+ for format in ("scad", "stl", "csg", "3mf"):
57
+ exported = Path(service.export(model_id, format)["model_file"])
58
+ assert exported.stat().st_size > 100
59
+ if format == "3mf":
60
+ with zipfile.ZipFile(exported) as archive:
61
+ assert any(name.endswith(".model") for name in archive.namelist())
62
+ if format == "scad":
63
+ # The download works outside the source checkout, without template includes.
64
+ portable = tmp_path / "elsewhere" / "portable.scad"
65
+ portable.parent.mkdir()
66
+ shutil.copyfile(exported, portable)
67
+ service.engine.export(portable, portable.with_suffix(".stl"))
68
+ changed = service.modify(model_id, "height 25 mm")
69
+ assert changed["parameters"]["height"] == 25
70
+ assert changed["parameters"]["thickness"] == 2
71
+ assert trimesh.load_mesh(changed["model_file"]).extents.tolist() == pytest.approx(
72
+ [30, 20, 25]
73
+ )
74
+ reloaded = ModelService(service.root, service.engine).get(model_id)
75
+ assert reloaded == changed
76
+ with pytest.raises(OpenSCADError):
77
+ service.modify(model_id, scad_code="this is invalid SCAD !!!")
78
+ assert service.get(model_id) == changed
79
+ assert (
80
+ len(list((service.root / model_id).iterdir())) == 3
81
+ ) # two revisions and manifest
82
+
83
+
84
+ @pytest.mark.parametrize(
85
+ "source",
86
+ [
87
+ "nonsense !!!",
88
+ "",
89
+ "// no geometry",
90
+ "unknown_module();",
91
+ 'assert(false, "broken"); cube(2);',
92
+ ],
93
+ )
94
+ def test_invalid_source_never_creates_success(service, source):
95
+ with pytest.raises((ValueError, OpenSCADError)):
96
+ service.from_scad(source)
97
+ assert not list(service.root.iterdir())
98
+
99
+
100
+ def test_custom_geometry(service):
101
+ model = service.from_scad(
102
+ "difference() { cube([40,30,8]); translate([20,15,-1]) cylinder(h=10,r=5,$fn=48); }"
103
+ )
104
+ mesh = trimesh.load_mesh(model["model_file"])
105
+ assert mesh.is_watertight
106
+ assert mesh.volume == pytest.approx(40 * 30 * 8 - math.pi * 25 * 8, rel=0.002)
107
+ with pytest.raises(ValueError, match="scad_code"):
108
+ service.modify(model["model_id"], parameters={"width": 2})
109
+
110
+
111
+ @pytest.mark.parametrize("model_id", ["../../etc/passwd", "bad", "a" * 32])
112
+ def test_unknown_ids(service, model_id):
113
+ with pytest.raises(ModelNotFound):
114
+ service.get(model_id)
115
+ with pytest.raises(ModelNotFound):
116
+ service.source(model_id)
117
+
118
+
119
+ def test_source_round_trip_and_stale_edits(service):
120
+ from openscad_mcp.service import ModelConflict
121
+
122
+ original = "// café — dimensions\nsize = 4;\ncube(size);\n"
123
+ model = service.from_scad(original)
124
+ source = service.source(model["model_id"])
125
+ assert source["scad_code"] == original
126
+ assert source["revision"] == model["revision"]
127
+ edited = service.modify(
128
+ model["model_id"],
129
+ scad_code=original.replace("size = 4", "size = 6"),
130
+ expected_revision=source["revision"],
131
+ )
132
+ assert trimesh.load_mesh(edited["model_file"]).volume == pytest.approx(216)
133
+ with pytest.raises(ModelConflict) as caught:
134
+ service.modify(
135
+ model["model_id"],
136
+ scad_code="cube(10);",
137
+ expected_revision=source["revision"],
138
+ )
139
+ assert caught.value.current_revision == edited["revision"]
140
+ assert service.source(model["model_id"])["scad_code"] == original.replace(
141
+ "size = 4", "size = 6"
142
+ )
143
+ # Exactly two successful revisions; rejection created no files.
144
+ assert len(list((service.root / model["model_id"]).iterdir())) == 3
145
+
146
+
147
+ def test_concurrent_edits_cannot_both_commit_same_revision(service):
148
+ from concurrent.futures import ThreadPoolExecutor
149
+
150
+ from openscad_mcp.service import ModelConflict
151
+
152
+ model = service.create(model_type="cube")
153
+
154
+ def edit(height):
155
+ try:
156
+ return service.modify(
157
+ model["model_id"],
158
+ parameters={"height": height},
159
+ expected_revision=model["revision"],
160
+ )
161
+ except ModelConflict:
162
+ return None
163
+
164
+ with ThreadPoolExecutor(max_workers=2) as pool:
165
+ results = list(pool.map(edit, [12, 15]))
166
+ winners = [result for result in results if result is not None]
167
+ assert len(winners) == 1
168
+ assert service.get(model["model_id"]) == winners[0]
169
+ assert (
170
+ service.source(model["model_id"])["parameters"]["height"]
171
+ == winners[0]["parameters"]["height"]
172
+ )