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 +3 -0
- ldraw_mcp/blender_script.py +137 -0
- ldraw_mcp/render.py +127 -0
- ldraw_mcp/server.py +101 -0
- ldraw_mcp/setup_cli.py +239 -0
- ldraw_mcp-0.1.0.dist-info/METADATA +141 -0
- ldraw_mcp-0.1.0.dist-info/RECORD +10 -0
- ldraw_mcp-0.1.0.dist-info/WHEEL +4 -0
- ldraw_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- ldraw_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
ldraw_mcp/__init__.py
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/ldraw-mcp/)
|
|
32
|
+
[](https://pypi.org/project/ldraw-mcp/)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
[](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,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.
|