blenderwright 2.0.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.
Files changed (47) hide show
  1. blenderwright/__init__.py +3 -0
  2. blenderwright/__main__.py +5 -0
  3. blenderwright/aliases.py +86 -0
  4. blenderwright/connection.py +179 -0
  5. blenderwright/enum_hints.py +118 -0
  6. blenderwright/ollama_chat.py +651 -0
  7. blenderwright/prompts/__init__.py +1 -0
  8. blenderwright/prompts/workflows.py +330 -0
  9. blenderwright/resources/__init__.py +1 -0
  10. blenderwright/resources/scene_info.py +36 -0
  11. blenderwright/server.py +76 -0
  12. blenderwright/strict.py +34 -0
  13. blenderwright/tool_registry.py +156 -0
  14. blenderwright/tools/__init__.py +1 -0
  15. blenderwright/tools/animation.py +245 -0
  16. blenderwright/tools/armature.py +279 -0
  17. blenderwright/tools/booltool.py +116 -0
  18. blenderwright/tools/camera.py +222 -0
  19. blenderwright/tools/code_exec.py +31 -0
  20. blenderwright/tools/collections.py +123 -0
  21. blenderwright/tools/curves.py +339 -0
  22. blenderwright/tools/file_ops.py +153 -0
  23. blenderwright/tools/geometry_nodes.py +187 -0
  24. blenderwright/tools/gpencil.py +180 -0
  25. blenderwright/tools/lighting.py +239 -0
  26. blenderwright/tools/materials.py +1035 -0
  27. blenderwright/tools/mesh_editing.py +573 -0
  28. blenderwright/tools/mesh_quality.py +113 -0
  29. blenderwright/tools/modeling.py +455 -0
  30. blenderwright/tools/objects.py +508 -0
  31. blenderwright/tools/physics.py +318 -0
  32. blenderwright/tools/print3d.py +121 -0
  33. blenderwright/tools/rendering.py +205 -0
  34. blenderwright/tools/scene.py +204 -0
  35. blenderwright/tools/screenshot.py +52 -0
  36. blenderwright/tools/sculpting.py +234 -0
  37. blenderwright/tools/selection.py +238 -0
  38. blenderwright/tools/sweep.py +174 -0
  39. blenderwright/tools/transforms.py +185 -0
  40. blenderwright/tools/uv.py +133 -0
  41. blenderwright/tools/viewport.py +99 -0
  42. blenderwright/validators.py +217 -0
  43. blenderwright-2.0.0.dist-info/METADATA +386 -0
  44. blenderwright-2.0.0.dist-info/RECORD +47 -0
  45. blenderwright-2.0.0.dist-info/WHEEL +4 -0
  46. blenderwright-2.0.0.dist-info/entry_points.txt +3 -0
  47. blenderwright-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """blenderwright: MCP Server for Blender."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as python -m blenderwright."""
2
+
3
+ from blenderwright.server import main
4
+
5
+ main()
@@ -0,0 +1,86 @@
1
+ """Keep a renamed parameter's old spelling working.
2
+
3
+ The same concept was spelled two ways. 75 tools took `object_name` for "which
4
+ object"; 15 took a bare `name`, as did three tools referring to a collection
5
+ or a scene. A caller reading one tool's schema could not predict the next, and
6
+ strict.py turns a wrong guess into an error rather than a silent no-op, so
7
+ each mismatch cost a round trip out of a fixed budget.
8
+
9
+ Those 18 were renamed to the typed spelling the rest of the codebase already
10
+ used -- `object_name`, `collection_name`, `scene_name`, alongside the existing
11
+ `material_name`, `curve_name`, `node_name`. Renaming alone would break
12
+ anything already calling with `name`, so the old spelling stays valid as a
13
+ pydantic validation alias: the published schema advertises the new name only,
14
+ and both are accepted.
15
+
16
+ Nothing downstream changed. The function still receives the value under its
17
+ own parameter name, and the wire payload sent to the addon still uses its
18
+ original keys, so no handler needed editing.
19
+
20
+ `name` is deliberately NOT aliased on the create_* tools. There it means the
21
+ thing being made, which is a different concept, and accepting `object_name`
22
+ for it would be a lie.
23
+ """
24
+
25
+ from typing import Any
26
+
27
+ from pydantic import AliasChoices
28
+
29
+ # Tool -> the parameter that replaced its bare `name`. Held as data so the
30
+ # tests can assert against the same list rather than a second copy of it.
31
+ RENAMED_FROM_NAME: dict[str, str] = {
32
+ # transforms
33
+ "set_location": "object_name",
34
+ "set_rotation": "object_name",
35
+ "set_scale": "object_name",
36
+ "apply_transforms": "object_name",
37
+ "set_origin": "object_name",
38
+ "snap_to_grid": "object_name",
39
+ # objects
40
+ "delete_object": "object_name",
41
+ "duplicate_object": "object_name",
42
+ "get_object_info": "object_name",
43
+ "set_object_visibility": "object_name",
44
+ # lighting
45
+ "set_light_property": "object_name",
46
+ "delete_light": "object_name",
47
+ "set_shadow_settings": "object_name",
48
+ # camera
49
+ "set_camera_property": "object_name",
50
+ "set_active_camera": "object_name",
51
+ # collections and scenes: not objects, so typed to what they refer to
52
+ "set_collection_visibility": "collection_name",
53
+ "delete_collection": "collection_name",
54
+ "delete_scene": "scene_name",
55
+ }
56
+
57
+ LEGACY_SPELLING = "name"
58
+
59
+
60
+ def attach_legacy_aliases(server: Any) -> int:
61
+ """Accept each renamed parameter's previous spelling.
62
+
63
+ Args:
64
+ server: A FastMCP instance whose registered tools should also accept
65
+ the older parameter name.
66
+
67
+ Returns:
68
+ The number of tools given an alias. Tools already aliased are not
69
+ counted, so calling this twice reports zero the second time.
70
+ """
71
+ count = 0
72
+ for tool_name, canonical in sorted(RENAMED_FROM_NAME.items()):
73
+ tool = server._tool_manager._tools.get(tool_name)
74
+ if tool is None:
75
+ continue
76
+ model = tool.fn_metadata.arg_model
77
+ field = model.model_fields.get(canonical)
78
+ if field is None or field.validation_alias is not None:
79
+ continue
80
+ # Canonical first: pydantic publishes the first choice in the JSON
81
+ # Schema, so the schema shows one name and only one.
82
+ field.validation_alias = AliasChoices(canonical, LEGACY_SPELLING)
83
+ model.model_config["populate_by_name"] = True
84
+ model.model_rebuild(force=True)
85
+ count += 1
86
+ return count
@@ -0,0 +1,179 @@
1
+ """TCP socket client for communicating with the Blender addon."""
2
+
3
+ import json
4
+ import logging
5
+ import socket
6
+ import struct
7
+ import threading
8
+ import time
9
+ from typing import Any
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class BlenderConnectionError(Exception):
15
+ """Raised when connection to Blender fails."""
16
+ pass
17
+
18
+
19
+ class BlenderConnection:
20
+ """TCP socket client that connects to the Blender addon's socket server.
21
+
22
+ Protocol: Each message is a 4-byte big-endian length prefix followed by
23
+ a UTF-8 JSON payload.
24
+ """
25
+
26
+ DEFAULT_HOST = "127.0.0.1"
27
+ DEFAULT_PORT = 9876
28
+ DEFAULT_TIMEOUT = 30.0
29
+ BUSY_RETRY_DELAY = 2.0 # seconds between retries when Blender is rendering
30
+ BUSY_MAX_RETRIES = 150 # ~5 minutes of waiting at 2s intervals
31
+
32
+ def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, timeout: float = DEFAULT_TIMEOUT):
33
+ self._host = host
34
+ self._port = port
35
+ self._timeout = timeout
36
+ self._socket: socket.socket | None = None
37
+ self._lock = threading.Lock()
38
+
39
+ @property
40
+ def is_connected(self) -> bool:
41
+ return self._socket is not None
42
+
43
+ def connect(self) -> None:
44
+ """Connect to the Blender addon socket server."""
45
+ if self._socket is not None:
46
+ return
47
+ try:
48
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
49
+ sock.settimeout(self._timeout)
50
+ sock.connect((self._host, self._port))
51
+ self._socket = sock
52
+ except (ConnectionRefusedError, OSError) as e:
53
+ raise BlenderConnectionError(
54
+ f"Cannot connect to Blender at {self._host}:{self._port}. "
55
+ "Ensure Blender is running with the blenderwright addon enabled."
56
+ ) from e
57
+
58
+ def disconnect(self) -> None:
59
+ """Disconnect from Blender."""
60
+ if self._socket is not None:
61
+ try:
62
+ self._socket.close()
63
+ except OSError:
64
+ pass
65
+ self._socket = None
66
+
67
+ def _send_raw(self, data: bytes) -> None:
68
+ """Send length-prefixed data."""
69
+ if self._socket is None:
70
+ raise BlenderConnectionError("Not connected to Blender")
71
+ header = struct.pack(">I", len(data))
72
+ self._socket.sendall(header + data)
73
+
74
+ def _recv_raw(self) -> bytes:
75
+ """Receive length-prefixed data."""
76
+ if self._socket is None:
77
+ raise BlenderConnectionError("Not connected to Blender")
78
+ header = self._recv_exactly(4)
79
+ length = struct.unpack(">I", header)[0]
80
+ if length > 100 * 1024 * 1024: # 100MB sanity limit
81
+ raise BlenderConnectionError(f"Response too large: {length} bytes")
82
+ return self._recv_exactly(length)
83
+
84
+ def _recv_exactly(self, n: int) -> bytes:
85
+ """Receive exactly n bytes."""
86
+ if self._socket is None:
87
+ raise BlenderConnectionError("Not connected to Blender")
88
+ chunks = []
89
+ remaining = n
90
+ while remaining > 0:
91
+ chunk = self._socket.recv(min(remaining, 65536))
92
+ if not chunk:
93
+ raise BlenderConnectionError("Connection closed by Blender")
94
+ chunks.append(chunk)
95
+ remaining -= len(chunk)
96
+ return b"".join(chunks)
97
+
98
+ def send_command(self, command: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
99
+ """Send a command to Blender and return the response.
100
+
101
+ Args:
102
+ command: The command name (must be in the addon's allowlist).
103
+ params: Optional parameters for the command.
104
+
105
+ Returns:
106
+ Response dict with 'status' ('ok' or 'error') and 'result'.
107
+
108
+ Raises:
109
+ BlenderConnectionError: If not connected or communication fails.
110
+ """
111
+ with self._lock:
112
+ message = {"command": command}
113
+ if params:
114
+ message["params"] = params
115
+
116
+ # Try up to 2 times: reconnect on stale connection
117
+ last_error = None
118
+ for attempt in range(2):
119
+ if self._socket is None:
120
+ self.connect()
121
+
122
+ try:
123
+ payload = json.dumps(message).encode("utf-8")
124
+ self._send_raw(payload)
125
+ response_data = self._recv_raw()
126
+ response = json.loads(response_data.decode("utf-8"))
127
+ except (OSError, json.JSONDecodeError, BlenderConnectionError) as e:
128
+ last_error = e # noqa: F841
129
+ self.disconnect()
130
+ if attempt == 0:
131
+ continue # retry with fresh connection
132
+ raise BlenderConnectionError(
133
+ f"Connection to Blender lost: {e}"
134
+ ) from e
135
+ else:
136
+ break
137
+
138
+ if not isinstance(response, dict):
139
+ raise BlenderConnectionError("Invalid response format from Blender")
140
+
141
+ # If Blender is rendering, wait and retry automatically
142
+ if response.get("status") == "busy":
143
+ for retry in range(self.BUSY_MAX_RETRIES):
144
+ logger.info(
145
+ "Blender is rendering, waiting %.1fs before retry %d/%d for '%s'",
146
+ self.BUSY_RETRY_DELAY, retry + 1, self.BUSY_MAX_RETRIES, command,
147
+ )
148
+ time.sleep(self.BUSY_RETRY_DELAY)
149
+
150
+ try:
151
+ payload = json.dumps(message).encode("utf-8")
152
+ self._send_raw(payload)
153
+ response_data = self._recv_raw()
154
+ response = json.loads(response_data.decode("utf-8"))
155
+ except (OSError, json.JSONDecodeError, BlenderConnectionError):
156
+ self.disconnect()
157
+ self.connect()
158
+ continue
159
+
160
+ if not isinstance(response, dict):
161
+ raise BlenderConnectionError("Invalid response format from Blender")
162
+
163
+ if response.get("status") != "busy":
164
+ return response
165
+
166
+ raise BlenderConnectionError(
167
+ f"Blender was busy rendering for too long. "
168
+ f"Command '{command}' was not processed after "
169
+ f"{self.BUSY_MAX_RETRIES * self.BUSY_RETRY_DELAY:.0f}s of waiting."
170
+ )
171
+
172
+ return response
173
+
174
+ def __enter__(self):
175
+ self.connect()
176
+ return self
177
+
178
+ def __exit__(self, *args):
179
+ self.disconnect()
@@ -0,0 +1,118 @@
1
+ """Publish each tool's accepted values as a JSON Schema enum.
2
+
3
+ 27 parameters were enforced against an ALLOWED_* set and documented with
4
+ nothing, or with two examples and an "e.g.". A model reading the schema had no
5
+ way to know what was acceptable, so it guessed, and every wrong guess cost a
6
+ round trip out of a fixed budget.
7
+
8
+ Rather than hand-write 27 lists into docstrings, where they would drift from
9
+ the constants exactly as the tool count drifted from the code, the pairing is
10
+ read out of the source: `validate_enum(param, ALLOWED_THING)` says that
11
+ `param` accepts `ALLOWED_THING`. The constant remains the single definition.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+ import importlib
18
+ import inspect
19
+ import re
20
+ from typing import Any
21
+
22
+ # validate_enum(value, ALLOWED_X, ...) inside a tool function body.
23
+ _ENUM_CALL = re.compile(r"validate_enum\(\s*(\w+)\s*,\s*(ALLOWED_\w+)")
24
+
25
+
26
+ def _enum_pairs(module: Any) -> dict[str, dict[str, list[str]]]:
27
+ """Map tool name -> parameter -> accepted values, for one tool module.
28
+
29
+ Args:
30
+ module: An imported module from blenderwright.tools.
31
+
32
+ Returns:
33
+ Nested mapping of function name to parameter to sorted values. A
34
+ parameter checked against more than one set is omitted, because the
35
+ choice depends on another argument and a single enum would be a lie.
36
+ """
37
+ try:
38
+ source = inspect.getsource(module)
39
+ except (OSError, TypeError):
40
+ return {}
41
+
42
+ found: dict[str, dict[str, set[str]]] = {}
43
+ tree = ast.parse(source)
44
+ for node in ast.walk(tree):
45
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
46
+ continue
47
+ body = ast.get_source_segment(source, node) or ""
48
+ for param, const_name in _ENUM_CALL.findall(body):
49
+ const = getattr(module, const_name, None)
50
+ if not isinstance(const, (set, frozenset, list, tuple)):
51
+ continue
52
+ if not all(isinstance(v, str) for v in const):
53
+ continue
54
+ found.setdefault(node.name, {}).setdefault(param, set())
55
+ found[node.name][param] |= {"", *const} - {""}
56
+
57
+ result: dict[str, dict[str, list[str]]] = {}
58
+ for func, params in found.items():
59
+ for param, values in params.items():
60
+ body = None
61
+ # A parameter validated against two different sets (the value of
62
+ # set_curve_property depends on which property) cannot be one enum.
63
+ if sum(1 for p, c in _ENUM_CALL.findall(
64
+ ast.get_source_segment(source, next(
65
+ n for n in ast.walk(tree)
66
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
67
+ and n.name == func)) or "") if p == param) > 1:
68
+ continue
69
+ result.setdefault(func, {})[param] = sorted(values)
70
+ del body
71
+ return result
72
+
73
+
74
+ def attach_enum_hints(server: Any) -> int:
75
+ """Attach accepted values to every tool parameter that has a fixed set.
76
+
77
+ Args:
78
+ server: A FastMCP instance with tools already registered.
79
+
80
+ Returns:
81
+ The number of parameters given an enum.
82
+ """
83
+ modules: dict[str, dict[str, dict[str, list[str]]]] = {}
84
+ attached = 0
85
+
86
+ for tool in server._tool_manager._tools.values():
87
+ module_name = getattr(tool.fn, "__module__", "")
88
+ if not module_name.startswith("blenderwright.tools."):
89
+ continue
90
+ if module_name not in modules:
91
+ try:
92
+ modules[module_name] = _enum_pairs(importlib.import_module(module_name))
93
+ except Exception:
94
+ modules[module_name] = {}
95
+
96
+ params = modules[module_name].get(tool.fn.__name__, {})
97
+ if not params:
98
+ continue
99
+
100
+ model = tool.fn_metadata.arg_model
101
+ changed = False
102
+ for param, values in params.items():
103
+ field = model.model_fields.get(param)
104
+ if field is None or (field.json_schema_extra or {}).get("enum"):
105
+ continue
106
+ extra = dict(field.json_schema_extra or {})
107
+ extra["enum"] = values
108
+ field.json_schema_extra = extra
109
+ changed = True
110
+ attached += 1
111
+ if changed:
112
+ model.model_rebuild(force=True)
113
+ # FastMCP caches the JSON schema on the tool at registration, and
114
+ # that cached copy is what list_tools() serves. Rebuilding the
115
+ # model alone would leave clients reading the old one.
116
+ tool.parameters = model.model_json_schema()
117
+
118
+ return attached