ldraw-mcp 0.1.0__py3-none-any.whl

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.
ldraw_mcp/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ldraw-mcp: render LDraw/LEGO models to images over MCP (headless Blender)."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,137 @@
1
+ """
2
+ Blender-side script: import an LDraw .ldr and render PNG views.
3
+
4
+ Runs INSIDE Blender (never import this from the package):
5
+ blender -b -P blender_script.py -- \
6
+ --ldr model.ldr --out /dir/prefix --azimuths -60,120 [--samples 32]
7
+
8
+ Renders one PNG per azimuth to <prefix>_<i>.png using Cycles CPU (works
9
+ headless everywhere, including WSL2 without a GPU). Camera orbits the
10
+ model bounding box at a fixed elevation.
11
+
12
+ Requires the ImportLDraw addon (io_scene_importldraw) and an LDraw
13
+ parts library (default ~/.ldraw).
14
+ """
15
+
16
+ import argparse
17
+ import math
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ import bpy
22
+
23
+
24
+ def parse_args():
25
+ argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
26
+ p = argparse.ArgumentParser()
27
+ p.add_argument("--ldr", required=True)
28
+ p.add_argument("--out", required=True, help="Output path prefix (no extension)")
29
+ p.add_argument("--azimuths", default="-60,120")
30
+ p.add_argument("--elevation", type=float, default=22.0)
31
+ p.add_argument("--samples", type=int, default=32)
32
+ p.add_argument("--resolution", type=int, default=800)
33
+ p.add_argument("--ldraw-dir", default=str(Path.home() / ".ldraw"))
34
+ return p.parse_args(argv)
35
+
36
+
37
+ def enable_addon():
38
+ import addon_utils
39
+
40
+ for name in ("io_scene_importldraw", "importldraw"):
41
+ try:
42
+ addon_utils.enable(name, default_set=True)
43
+ return
44
+ except Exception:
45
+ continue
46
+ raise RuntimeError("ImportLDraw addon not found in Blender addons")
47
+
48
+
49
+ def scene_bounds():
50
+ lo = [float("inf")] * 3
51
+ hi = [float("-inf")] * 3
52
+ for obj in bpy.context.scene.objects:
53
+ if obj.type != "MESH":
54
+ continue
55
+ for corner in obj.bound_box:
56
+ world = obj.matrix_world @ __import__("mathutils").Vector(corner)
57
+ for i in range(3):
58
+ lo[i] = min(lo[i], world[i])
59
+ hi[i] = max(hi[i], world[i])
60
+ if lo[0] == float("inf"):
61
+ raise RuntimeError("no mesh objects imported — empty or failed import")
62
+ return lo, hi
63
+
64
+
65
+ def main():
66
+ args = parse_args()
67
+ enable_addon()
68
+
69
+ bpy.ops.wm.read_factory_settings(use_empty=True)
70
+ enable_addon() # factory reset drops enabled addons
71
+
72
+ # the addon's look setup assumes a world exists; empty scenes have none
73
+ world = bpy.data.worlds.new("World")
74
+ world.use_nodes = True
75
+ world.node_tree.nodes["Background"].inputs[0].default_value = (1, 1, 1, 1)
76
+ world.node_tree.nodes["Background"].inputs[1].default_value = 1.0
77
+ bpy.context.scene.world = world
78
+
79
+ bpy.ops.import_scene.importldraw(
80
+ filepath=args.ldr,
81
+ ldrawPath=args.ldraw_dir,
82
+ addEnvironment=False, # its ground plane would dominate the bounds
83
+ positionCamera=False,
84
+ importCameras=False,
85
+ positionOnGround=True,
86
+ useLogoStuds=False,
87
+ smoothParts=True,
88
+ addGaps=True,
89
+ bevelEdges=True,
90
+ look="normal",
91
+ )
92
+
93
+ lo, hi = scene_bounds()
94
+ center = [(a + b) / 2 for a, b in zip(lo, hi)]
95
+ span = max(b - a for a, b in zip(lo, hi))
96
+ distance = span * 2.2
97
+
98
+ scene = bpy.context.scene
99
+ scene.render.engine = "CYCLES"
100
+ scene.cycles.samples = args.samples
101
+ scene.cycles.device = "CPU"
102
+ scene.render.resolution_x = args.resolution
103
+ scene.render.resolution_y = args.resolution
104
+ scene.render.film_transparent = False
105
+
106
+ # sun light
107
+ sun = bpy.data.objects.new("Sun", bpy.data.lights.new("Sun", type="SUN"))
108
+ sun.data.energy = 4.0
109
+ sun.rotation_euler = (math.radians(50), 0, math.radians(-40))
110
+ scene.collection.objects.link(sun)
111
+
112
+ cam_data = bpy.data.cameras.new("Cam")
113
+ cam = bpy.data.objects.new("Cam", cam_data)
114
+ scene.collection.objects.link(cam)
115
+ scene.camera = cam
116
+
117
+ elev = math.radians(args.elevation)
118
+ for i, azim_str in enumerate(args.azimuths.split(",")):
119
+ azim = math.radians(float(azim_str))
120
+ cam.location = (
121
+ center[0] + distance * math.cos(elev) * math.cos(azim),
122
+ center[1] + distance * math.cos(elev) * math.sin(azim),
123
+ center[2] + distance * math.sin(elev),
124
+ )
125
+ direction = [c - l for c, l in zip(center, cam.location)]
126
+ import mathutils
127
+
128
+ cam.rotation_euler = (
129
+ mathutils.Vector(direction).to_track_quat("-Z", "Y").to_euler()
130
+ )
131
+ scene.render.filepath = f"{args.out}_{i}.png"
132
+ bpy.ops.render.render(write_still=True)
133
+ print(f"RENDERED {scene.render.filepath}")
134
+
135
+
136
+ if __name__ == "__main__":
137
+ main()
ldraw_mcp/render.py ADDED
@@ -0,0 +1,127 @@
1
+ """
2
+ High-quality LDraw rendering via headless Blender + ImportLDraw.
3
+
4
+ The 'see the actual model' protocol: point at an LDraw model, render it
5
+ with real part geometry (studs, slopes, window glass) in Blender, and
6
+ stitch the views into one PNG.
7
+
8
+ Availability requires: a blender binary, the io_scene_importldraw addon,
9
+ and an LDraw parts library (~/.ldraw). Everything degrades gracefully:
10
+ callers check is_available() or catch LDrawRenderError.
11
+
12
+ Environment variables:
13
+ LDRAW_MCP_BLENDER path to the blender binary (overrides PATH lookup)
14
+ LDRAW_MCP_DISABLE set to "1" to force is_available() to False
15
+ LDRAW_LIBRARY_PATH path to the LDraw parts library
16
+ """
17
+
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import tempfile
22
+ from pathlib import Path
23
+ from typing import Optional, Sequence
24
+
25
+ BLENDER_SCRIPT = Path(__file__).parent / "blender_script.py"
26
+ DEFAULT_AZIMUTHS = (-60.0, 120.0)
27
+
28
+
29
+ class LDrawRenderError(Exception):
30
+ """Blender render failed or is unavailable."""
31
+
32
+
33
+ def find_blender() -> Optional[str]:
34
+ env = os.environ.get("LDRAW_MCP_BLENDER")
35
+ if env and Path(env).exists():
36
+ return env
37
+ found = shutil.which("blender")
38
+ if found:
39
+ return found
40
+ local = Path.home() / ".local" / "bin" / "blender"
41
+ return str(local) if local.exists() else None
42
+
43
+
44
+ def ldraw_library_dir() -> Optional[Path]:
45
+ env = os.environ.get("LDRAW_LIBRARY_PATH")
46
+ candidates = [Path(env)] if env else []
47
+ candidates += [Path.home() / ".ldraw", Path("/usr/share/ldraw")]
48
+ for c in candidates:
49
+ if (c / "parts").is_dir():
50
+ return c
51
+ return None
52
+
53
+
54
+ def is_available() -> bool:
55
+ if os.environ.get("LDRAW_MCP_DISABLE") == "1":
56
+ return False
57
+ return find_blender() is not None and ldraw_library_dir() is not None
58
+
59
+
60
+ def render_ldraw(
61
+ ldr_path: str,
62
+ output_png: str,
63
+ azimuths: Sequence[float] = DEFAULT_AZIMUTHS,
64
+ samples: int = 32,
65
+ resolution: int = 800,
66
+ timeout: int = 600,
67
+ ) -> str:
68
+ """Render an .ldr file to a single side-by-side PNG of `azimuths` views."""
69
+ blender = find_blender()
70
+ library = ldraw_library_dir()
71
+ if blender is None or library is None:
72
+ raise LDrawRenderError(
73
+ "render unavailable (need blender + ImportLDraw addon "
74
+ "+ LDraw library in ~/.ldraw; run `ldraw-mcp-setup`)"
75
+ )
76
+ with tempfile.TemporaryDirectory() as td:
77
+ prefix = str(Path(td) / "view")
78
+ cmd = [
79
+ blender,
80
+ "-b",
81
+ "--factory-startup",
82
+ "-P",
83
+ str(BLENDER_SCRIPT),
84
+ "--",
85
+ "--ldr",
86
+ str(ldr_path),
87
+ "--out",
88
+ prefix,
89
+ "--azimuths=" + ",".join(str(a) for a in azimuths),
90
+ "--samples",
91
+ str(samples),
92
+ "--resolution",
93
+ str(resolution),
94
+ "--ldraw-dir",
95
+ str(library),
96
+ ]
97
+ try:
98
+ proc = subprocess.run(
99
+ cmd, capture_output=True, text=True, timeout=timeout
100
+ )
101
+ except subprocess.TimeoutExpired:
102
+ raise LDrawRenderError(f"blender render timed out after {timeout}s")
103
+ views = [Path(f"{prefix}_{i}.png") for i in range(len(azimuths))]
104
+ missing = [v for v in views if not v.exists()]
105
+ if proc.returncode != 0 or missing:
106
+ tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-15:])
107
+ raise LDrawRenderError(
108
+ f"blender render failed (exit {proc.returncode}, "
109
+ f"missing {len(missing)} view(s)):\n{tail}"
110
+ )
111
+ _stitch(views, output_png)
112
+ return output_png
113
+
114
+
115
+ def _stitch(view_paths, output_png: str) -> None:
116
+ """Combine view PNGs horizontally into one image."""
117
+ from PIL import Image
118
+
119
+ images = [Image.open(p) for p in view_paths]
120
+ height = max(im.height for im in images)
121
+ width = sum(im.width for im in images)
122
+ combined = Image.new("RGB", (width, height), (255, 255, 255))
123
+ x = 0
124
+ for im in images:
125
+ combined.paste(im, (x, (height - im.height) // 2))
126
+ x += im.width
127
+ combined.save(output_png)
ldraw_mcp/server.py ADDED
@@ -0,0 +1,101 @@
1
+ """
2
+ ldraw-mcp server — render LDraw models to images over MCP.
3
+
4
+ Tools:
5
+ render_ldraw_file(path, ...) -> PNG image of the model
6
+ render_ldraw_text(ldr, ...) -> PNG image of inline LDraw content
7
+ check_renderer() -> availability diagnostics
8
+
9
+ Run (stdio):
10
+ ldraw-mcp
11
+ python -m ldraw_mcp.server
12
+
13
+ Register with Claude Code:
14
+ claude mcp add ldraw -- ldraw-mcp
15
+
16
+ Requirements (see README.md):
17
+ - Blender on PATH (or LDRAW_MCP_BLENDER)
18
+ - ImportLDraw addon installed in Blender
19
+ - LDraw parts library at ~/.ldraw (or LDRAW_LIBRARY_PATH)
20
+ Run `ldraw-mcp-setup` to install the library + addon.
21
+ """
22
+
23
+ import tempfile
24
+ from pathlib import Path
25
+
26
+ from mcp.server.fastmcp import FastMCP, Image
27
+
28
+ from . import render as ldraw_render
29
+
30
+ mcp = FastMCP("ldraw")
31
+
32
+
33
+ @mcp.tool()
34
+ def check_renderer() -> str:
35
+ """Report whether the LDraw rendering stack is available and why not."""
36
+ blender = ldraw_render.find_blender()
37
+ library = ldraw_render.ldraw_library_dir()
38
+ lines = [
39
+ f"blender: {blender or 'NOT FOUND (install Blender or set LDRAW_MCP_BLENDER)'}",
40
+ f"ldraw library: {library or 'NOT FOUND (run ldraw-mcp-setup or set LDRAW_LIBRARY_PATH)'}",
41
+ f"available: {ldraw_render.is_available()}",
42
+ ]
43
+ return "\n".join(lines)
44
+
45
+
46
+ def _render(ldr_path: str, azimuths: str, resolution: int, samples: int) -> Image:
47
+ azims = [float(a) for a in azimuths.split(",")]
48
+ with tempfile.TemporaryDirectory() as td:
49
+ out = str(Path(td) / "render.png")
50
+ ldraw_render.render_ldraw(
51
+ ldr_path, out, azimuths=azims, resolution=resolution, samples=samples
52
+ )
53
+ return Image(data=Path(out).read_bytes(), format="png")
54
+
55
+
56
+ @mcp.tool()
57
+ def render_ldraw_file(
58
+ path: str,
59
+ azimuths: str = "-60,120",
60
+ resolution: int = 640,
61
+ samples: int = 24,
62
+ ) -> Image:
63
+ """Render an LDraw model file (.ldr/.mpd/.dat) to a PNG image.
64
+
65
+ Views are rendered at each comma-separated azimuth (degrees) and
66
+ stitched side by side. Higher samples = cleaner but slower.
67
+ """
68
+ p = Path(path).expanduser()
69
+ if not p.exists():
70
+ raise FileNotFoundError(f"no such LDraw file: {p}")
71
+ return _render(str(p), azimuths, resolution, samples)
72
+
73
+
74
+ @mcp.tool()
75
+ def render_ldraw_text(
76
+ ldr: str,
77
+ azimuths: str = "-60,120",
78
+ resolution: int = 640,
79
+ samples: int = 24,
80
+ ) -> Image:
81
+ """Render inline LDraw content (the text of a .ldr file) to a PNG.
82
+
83
+ Useful for quick experiments without writing a file first.
84
+ """
85
+ with tempfile.NamedTemporaryFile(
86
+ "w", suffix=".ldr", delete=False
87
+ ) as f:
88
+ f.write(ldr)
89
+ tmp = f.name
90
+ try:
91
+ return _render(tmp, azimuths, resolution, samples)
92
+ finally:
93
+ Path(tmp).unlink(missing_ok=True)
94
+
95
+
96
+ def main() -> None:
97
+ mcp.run()
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
ldraw_mcp/setup_cli.py ADDED
@@ -0,0 +1,239 @@
1
+ """
2
+ ldraw-mcp-setup — install the LDraw parts library and the ImportLDraw
3
+ Blender addon so the renderer can run.
4
+
5
+ What it does:
6
+ 1. Download the LDraw parts library (complete.zip) into ~/.ldraw if the
7
+ library is not already present.
8
+ 2. Download the latest ImportLDraw addon release from GitHub and install
9
+ it into every detected Blender addons directory under
10
+ ~/.config/blender/<version>/scripts/addons/.
11
+
12
+ Blender itself is a prerequisite and is NOT installed by this script.
13
+ When automatic detection fails, clear manual instructions are printed.
14
+ """
15
+
16
+ import argparse
17
+ import io
18
+ import json
19
+ import os
20
+ import sys
21
+ import urllib.request
22
+ import zipfile
23
+ from pathlib import Path
24
+
25
+ LDRAW_LIBRARY_URL = "https://library.ldraw.org/library/updates/complete.zip"
26
+ IMPORTLDRAW_LATEST_API = (
27
+ "https://api.github.com/repos/TobyLobster/ImportLDraw/releases/latest"
28
+ )
29
+ IMPORTLDRAW_RELEASES_PAGE = (
30
+ "https://github.com/TobyLobster/ImportLDraw/releases/latest"
31
+ )
32
+
33
+
34
+ def _log(msg: str) -> None:
35
+ print(msg, flush=True)
36
+
37
+
38
+ def ldraw_library_present(ldraw_dir: Path) -> bool:
39
+ return (ldraw_dir / "parts").is_dir()
40
+
41
+
42
+ def install_ldraw_library(ldraw_dir: Path, force: bool = False) -> bool:
43
+ """Download + unzip the LDraw parts library. Returns True if installed."""
44
+ if ldraw_library_present(ldraw_dir) and not force:
45
+ _log(f"[ldraw] library already present at {ldraw_dir} — skipping")
46
+ return True
47
+
48
+ ldraw_dir.mkdir(parents=True, exist_ok=True)
49
+ _log(f"[ldraw] downloading {LDRAW_LIBRARY_URL} ...")
50
+ try:
51
+ with urllib.request.urlopen(LDRAW_LIBRARY_URL) as resp:
52
+ data = resp.read()
53
+ except Exception as exc: # noqa: BLE001
54
+ _log(f"[ldraw] download FAILED: {exc}")
55
+ _log(
56
+ "[ldraw] manual step: download complete.zip from\n"
57
+ f" {LDRAW_LIBRARY_URL}\n"
58
+ f" and unzip it so that {ldraw_dir}/parts/ exists."
59
+ )
60
+ return False
61
+
62
+ _log(f"[ldraw] unzipping ({len(data) // (1024 * 1024)} MB) ...")
63
+ # complete.zip contains a top-level 'ldraw/' directory; extract its
64
+ # contents directly into ldraw_dir.
65
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
66
+ for member in zf.namelist():
67
+ parts = member.split("/", 1)
68
+ if parts[0] == "ldraw" and len(parts) == 2 and parts[1]:
69
+ target = ldraw_dir / parts[1]
70
+ if member.endswith("/"):
71
+ target.mkdir(parents=True, exist_ok=True)
72
+ else:
73
+ target.parent.mkdir(parents=True, exist_ok=True)
74
+ with zf.open(member) as src, open(target, "wb") as dst:
75
+ dst.write(src.read())
76
+ else:
77
+ zf.extract(member, ldraw_dir)
78
+
79
+ if ldraw_library_present(ldraw_dir):
80
+ _log(f"[ldraw] installed at {ldraw_dir}")
81
+ return True
82
+ _log(f"[ldraw] extraction finished but {ldraw_dir}/parts/ not found — check layout")
83
+ return False
84
+
85
+
86
+ def detect_blender_addon_dirs() -> list[Path]:
87
+ """Find Blender addons dirs under ~/.config/blender/<version>/scripts/addons."""
88
+ base = Path.home() / ".config" / "blender"
89
+ dirs: list[Path] = []
90
+ if base.is_dir():
91
+ for version_dir in sorted(base.iterdir()):
92
+ if version_dir.is_dir():
93
+ dirs.append(version_dir / "scripts" / "addons")
94
+ return dirs
95
+
96
+
97
+ def _find_addon_asset_url() -> str | None:
98
+ req = urllib.request.Request(
99
+ IMPORTLDRAW_LATEST_API, headers={"Accept": "application/vnd.github+json"}
100
+ )
101
+ with urllib.request.urlopen(req) as resp:
102
+ release = json.load(resp)
103
+ for asset in release.get("assets", []):
104
+ name = asset.get("name", "")
105
+ if name.endswith(".zip"):
106
+ return asset.get("browser_download_url")
107
+ # fall back to the auto-generated source zip
108
+ return release.get("zipball_url")
109
+
110
+
111
+ def _addon_root_in_zip(zf: zipfile.ZipFile) -> str | None:
112
+ """Return the path prefix inside the zip that contains the addon package.
113
+
114
+ The addon package is the directory containing __init__.py that also
115
+ holds loadldraw/ (or is named importldraw/io_scene_importldraw).
116
+ """
117
+ names = zf.namelist()
118
+ # Look for an __init__.py at depth 1 or 2 whose sibling tree looks like
119
+ # the addon.
120
+ inits = [n for n in names if n.endswith("__init__.py")]
121
+ # Prefer the shallowest __init__.py.
122
+ inits.sort(key=lambda n: n.count("/"))
123
+ for init in inits:
124
+ prefix = init[: -len("__init__.py")]
125
+ return prefix
126
+ return None
127
+
128
+
129
+ def install_importldraw_addon(addon_dirs: list[Path], force: bool = False) -> bool:
130
+ """Download the latest ImportLDraw addon and install into each addon dir."""
131
+ if not addon_dirs:
132
+ _log(
133
+ "[addon] no Blender addons directory detected under "
134
+ "~/.config/blender/<version>/scripts/addons/.\n"
135
+ "[addon] manual step: launch Blender once (creates the config dir), "
136
+ "then re-run ldraw-mcp-setup, OR install the addon by hand:\n"
137
+ f" download the latest release zip from {IMPORTLDRAW_RELEASES_PAGE}\n"
138
+ " and use Blender > Edit > Preferences > Add-ons > Install."
139
+ )
140
+ return False
141
+
142
+ target_name = "io_scene_importldraw"
143
+ already = [d for d in addon_dirs if (d / target_name).is_dir()]
144
+ if already and not force:
145
+ _log(
146
+ f"[addon] {target_name} already installed in: "
147
+ + ", ".join(str(d) for d in already)
148
+ + " — skipping (use --force to reinstall)"
149
+ )
150
+ return True
151
+
152
+ _log(f"[addon] querying {IMPORTLDRAW_LATEST_API} ...")
153
+ try:
154
+ asset_url = _find_addon_asset_url()
155
+ except Exception as exc: # noqa: BLE001
156
+ _log(f"[addon] could not query GitHub release: {exc}")
157
+ _log(
158
+ f"[addon] manual step: download the addon zip from {IMPORTLDRAW_RELEASES_PAGE}\n"
159
+ " and install via Blender > Preferences > Add-ons > Install."
160
+ )
161
+ return False
162
+
163
+ if not asset_url:
164
+ _log("[addon] no downloadable zip asset found in the latest release")
165
+ return False
166
+
167
+ _log(f"[addon] downloading {asset_url} ...")
168
+ try:
169
+ req = urllib.request.Request(asset_url, headers={"User-Agent": "ldraw-mcp"})
170
+ with urllib.request.urlopen(req) as resp:
171
+ data = resp.read()
172
+ except Exception as exc: # noqa: BLE001
173
+ _log(f"[addon] download FAILED: {exc}")
174
+ return False
175
+
176
+ ok = True
177
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
178
+ prefix = _addon_root_in_zip(zf)
179
+ if prefix is None:
180
+ _log("[addon] could not locate the addon package inside the zip")
181
+ return False
182
+ members = [
183
+ n for n in zf.namelist() if n.startswith(prefix) and not n.endswith("/")
184
+ ]
185
+ for addon_dir in addon_dirs:
186
+ dest = addon_dir / target_name
187
+ addon_dir.mkdir(parents=True, exist_ok=True)
188
+ _log(f"[addon] installing into {dest}")
189
+ for member in members:
190
+ rel = member[len(prefix):]
191
+ if not rel:
192
+ continue
193
+ target = dest / rel
194
+ target.parent.mkdir(parents=True, exist_ok=True)
195
+ with zf.open(member) as src, open(target, "wb") as out:
196
+ out.write(src.read())
197
+ if not (dest / "__init__.py").exists():
198
+ _log(f"[addon] WARNING: {dest}/__init__.py missing after install")
199
+ ok = False
200
+ if ok:
201
+ _log("[addon] installed. Enable it in Blender > Preferences > Add-ons if needed.")
202
+ return ok
203
+
204
+
205
+ def main() -> None:
206
+ parser = argparse.ArgumentParser(
207
+ prog="ldraw-mcp-setup",
208
+ description="Install the LDraw parts library and ImportLDraw Blender addon.",
209
+ )
210
+ parser.add_argument(
211
+ "--ldraw-dir",
212
+ default=os.environ.get("LDRAW_LIBRARY_PATH") or str(Path.home() / ".ldraw"),
213
+ help="Where to install the LDraw parts library (default: ~/.ldraw)",
214
+ )
215
+ parser.add_argument("--skip-library", action="store_true", help="Don't install the LDraw library")
216
+ parser.add_argument("--skip-addon", action="store_true", help="Don't install the ImportLDraw addon")
217
+ parser.add_argument("--force", action="store_true", help="Reinstall even if present")
218
+ args = parser.parse_args()
219
+
220
+ ldraw_dir = Path(args.ldraw_dir).expanduser()
221
+ ok = True
222
+
223
+ if not args.skip_library:
224
+ ok = install_ldraw_library(ldraw_dir, force=args.force) and ok
225
+
226
+ if not args.skip_addon:
227
+ ok = install_importldraw_addon(detect_blender_addon_dirs(), force=args.force) and ok
228
+
229
+ _log("")
230
+ if ok:
231
+ _log("Setup complete. Verify with: ldraw-mcp (then call check_renderer)")
232
+ _log("Or: python -c \"from ldraw_mcp.render import is_available; print(is_available())\"")
233
+ else:
234
+ _log("Setup finished with warnings — see messages above for manual steps.")
235
+ sys.exit(1)
236
+
237
+
238
+ if __name__ == "__main__":
239
+ main()
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: ldraw-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server that renders LDraw/LEGO models to images with real part geometry via headless Blender + ImportLDraw.
5
+ Project-URL: Homepage, https://github.com/musharna/ldraw-mcp
6
+ Author: Jaret Arnold
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: blender,ldraw,lego,mcp,render
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Multimedia :: Graphics :: 3D Rendering
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: mcp
15
+ Requires-Dist: pillow
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest; extra == 'test'
18
+ Description-Content-Type: text/markdown
19
+
20
+ <div align="center">
21
+
22
+ # ldraw-mcp
23
+
24
+ **Give your MCP client eyes for LEGO® models.**
25
+
26
+ Render LDraw files (`.ldr` / `.mpd` / `.dat`) to images with *real part
27
+ geometry* — studs, slopes, window glass — using headless Blender and the
28
+ ImportLDraw addon. The output looks like a BrickLink Stud.io render, with no
29
+ GUI anywhere in the loop.
30
+
31
+ [![PyPI](https://img.shields.io/pypi/v/ldraw-mcp?color=%230a7bbb&label=PyPI)](https://pypi.org/project/ldraw-mcp/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/ldraw-mcp)](https://pypi.org/project/ldraw-mcp/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
34
+ [![MCP](https://img.shields.io/badge/MCP-server-8A2BE2.svg)](https://modelcontextprotocol.io)
35
+
36
+ <img src="docs/hero.png" alt="Two rendered views of a red LEGO car — real studs, transparent glass, rubber tires, steering wheel" width="100%">
37
+
38
+ <sub>A ~90-line <code>.ldr</code> rendered front-left and rear-right — actual bricks, not a geometric proxy.</sub>
39
+
40
+ </div>
41
+
42
+ ---
43
+
44
+ Point a vision-capable model at a build and it sees the actual bricks:
45
+ crossed rotation matrices, floating plates, sunken windows — the kinds of
46
+ export bugs a geometric proxy render will happily hide.
47
+
48
+ ## Quickstart
49
+
50
+ ```bash
51
+ # 1. install
52
+ pip install ldraw-mcp
53
+
54
+ # 2. install the LDraw parts library + ImportLDraw addon
55
+ ldraw-mcp-setup
56
+
57
+ # 3. register with Claude Code
58
+ claude mcp add ldraw -- ldraw-mcp
59
+ ```
60
+
61
+ Then ask things like *"render output/build.ldr and tell me what looks
62
+ wrong"* — the model sees the render, not just the text.
63
+
64
+ > **Blender is a prerequisite** (see [Requirements](#requirements)); it is
65
+ > not installed by `ldraw-mcp-setup`.
66
+
67
+ ## Tools
68
+
69
+ | tool | what it does |
70
+ |---|---|
71
+ | `render_ldraw_file(path, azimuths="-60,120", resolution=640, samples=24)` | Render a model file to a PNG (multi-view, stitched side by side) |
72
+ | `render_ldraw_text(ldr, azimuths="-60,120", resolution=640, samples=24)` | Render inline LDraw content without writing a file first |
73
+ | `check_renderer()` | Diagnose the Blender / addon / parts-library setup |
74
+
75
+ `azimuths` is a comma-separated list of view angles in degrees; each is
76
+ rendered and the views are stitched horizontally. Elevation is fixed at
77
+ 22°. Higher `samples` = cleaner but slower.
78
+
79
+ ## Requirements
80
+
81
+ - **Blender 4.x** on `PATH`, or point `LDRAW_MCP_BLENDER` at the binary.
82
+ Install it yourself (package manager, blender.org, or a local build);
83
+ `ldraw-mcp-setup` does not install Blender.
84
+ - **ImportLDraw addon** (`io_scene_importldraw`) in Blender's addons dir —
85
+ installed by `ldraw-mcp-setup`.
86
+ - **LDraw parts library** at `~/.ldraw` (or `LDRAW_LIBRARY_PATH`) —
87
+ installed by `ldraw-mcp-setup`.
88
+
89
+ ### Environment variables
90
+
91
+ | var | meaning |
92
+ |---|---|
93
+ | `LDRAW_MCP_BLENDER` | Path to the blender binary (overrides `PATH` lookup) |
94
+ | `LDRAW_MCP_DISABLE` | Set to `1` to force `is_available()` to `False` |
95
+ | `LDRAW_LIBRARY_PATH` | Path to the LDraw parts library (community convention) |
96
+
97
+ ### Manual setup
98
+
99
+ If `ldraw-mcp-setup` can't detect things automatically:
100
+
101
+ - **LDraw library:** download
102
+ [complete.zip](https://library.ldraw.org/library/updates/complete.zip)
103
+ and unzip so that `~/.ldraw/parts/` exists.
104
+ - **ImportLDraw addon:** download the latest release from
105
+ [TobyLobster/ImportLDraw](https://github.com/TobyLobster/ImportLDraw/releases)
106
+ and install it via *Blender > Preferences > Add-ons > Install*, or unzip
107
+ into `~/.config/blender/<version>/scripts/addons/io_scene_importldraw/`.
108
+ (Launch Blender once first so the config directory exists.)
109
+
110
+ ## Troubleshooting
111
+
112
+ - **`check_renderer` says NOT FOUND:** run `ldraw-mcp-setup`, or set the
113
+ relevant env var above.
114
+ - **No GPU / WSL2 / containers:** rendering uses **Cycles on CPU**, which
115
+ works headless everywhere — no GPU or display needed. A ~150-part model
116
+ takes a few seconds at the default 640px / 24 samples.
117
+ - **"no mesh objects imported":** the addon couldn't resolve parts —
118
+ usually a wrong or incomplete LDraw library path. Re-run setup or check
119
+ `LDRAW_LIBRARY_PATH`.
120
+ - **Addon not enabled:** the render script enables it automatically per
121
+ run; if a manual Blender session complains, enable `io_scene_importldraw`
122
+ in Preferences > Add-ons.
123
+
124
+ ## Provenance
125
+
126
+ This renderer was extracted from the **prompt2brick** project, where it
127
+ started life as the vision critic's "see the actual model" path.
128
+ prompt2brick keeps its own vendored copy of the render wrapper and Blender
129
+ script, but **this repo is the canonical source going forward** — fixes and
130
+ improvements to the renderer should land here first and be ported back into
131
+ prompt2brick.
132
+
133
+ ## License
134
+
135
+ MIT — see [LICENSE](LICENSE).
136
+
137
+ ---
138
+
139
+ <sub>LEGO® is a trademark of the LEGO Group, which does not sponsor,
140
+ authorize, or endorse this project. This tool is not affiliated with the
141
+ LEGO Group, BrickLink, or the LDraw.org organization.</sub>
@@ -0,0 +1,10 @@
1
+ ldraw_mcp/__init__.py,sha256=2oxLFbBdb5n_-AJl9vwHL04jn_5xtV6Skl2jj0XLHOU,104
2
+ ldraw_mcp/blender_script.py,sha256=96CsQh5HNQ4HkTCtGU07Hq8upd6gwHBaUCQoZV96oZs,4510
3
+ ldraw_mcp/render.py,sha256=Yp17gqVeluijHqlC9PUQ8D5ZYixuJwGJGPbIo0yh1MA,4108
4
+ ldraw_mcp/server.py,sha256=Id9_t0tBgIn9ENVgKhDbyovzA6Vkejm88rE6Y4Dt3bQ,2829
5
+ ldraw_mcp/setup_cli.py,sha256=D8cE2INJfsKLqbO7xU3LCgI5hYe0LWDKj3fapJG20fw,9042
6
+ ldraw_mcp-0.1.0.dist-info/METADATA,sha256=h_NCudeEVd2Flts7IXhvXEcJop4TVoPJdacAi48jdis,5514
7
+ ldraw_mcp-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ ldraw_mcp-0.1.0.dist-info/entry_points.txt,sha256=dec03rJ3-Hqg0yREC5N1n7-Xjt6Af3sl2BBDVrlidoc,95
9
+ ldraw_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=MWMDpZryeVdiw9xv-WFds8l0UwiwMdANTddbgfMtu_I,1069
10
+ ldraw_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ ldraw-mcp = ldraw_mcp.server:main
3
+ ldraw-mcp-setup = ldraw_mcp.setup_cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jaret Arnold
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.