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.
- blenderwright/__init__.py +3 -0
- blenderwright/__main__.py +5 -0
- blenderwright/aliases.py +86 -0
- blenderwright/connection.py +179 -0
- blenderwright/enum_hints.py +118 -0
- blenderwright/ollama_chat.py +651 -0
- blenderwright/prompts/__init__.py +1 -0
- blenderwright/prompts/workflows.py +330 -0
- blenderwright/resources/__init__.py +1 -0
- blenderwright/resources/scene_info.py +36 -0
- blenderwright/server.py +76 -0
- blenderwright/strict.py +34 -0
- blenderwright/tool_registry.py +156 -0
- blenderwright/tools/__init__.py +1 -0
- blenderwright/tools/animation.py +245 -0
- blenderwright/tools/armature.py +279 -0
- blenderwright/tools/booltool.py +116 -0
- blenderwright/tools/camera.py +222 -0
- blenderwright/tools/code_exec.py +31 -0
- blenderwright/tools/collections.py +123 -0
- blenderwright/tools/curves.py +339 -0
- blenderwright/tools/file_ops.py +153 -0
- blenderwright/tools/geometry_nodes.py +187 -0
- blenderwright/tools/gpencil.py +180 -0
- blenderwright/tools/lighting.py +239 -0
- blenderwright/tools/materials.py +1035 -0
- blenderwright/tools/mesh_editing.py +573 -0
- blenderwright/tools/mesh_quality.py +113 -0
- blenderwright/tools/modeling.py +455 -0
- blenderwright/tools/objects.py +508 -0
- blenderwright/tools/physics.py +318 -0
- blenderwright/tools/print3d.py +121 -0
- blenderwright/tools/rendering.py +205 -0
- blenderwright/tools/scene.py +204 -0
- blenderwright/tools/screenshot.py +52 -0
- blenderwright/tools/sculpting.py +234 -0
- blenderwright/tools/selection.py +238 -0
- blenderwright/tools/sweep.py +174 -0
- blenderwright/tools/transforms.py +185 -0
- blenderwright/tools/uv.py +133 -0
- blenderwright/tools/viewport.py +99 -0
- blenderwright/validators.py +217 -0
- blenderwright-2.0.0.dist-info/METADATA +386 -0
- blenderwright-2.0.0.dist-info/RECORD +47 -0
- blenderwright-2.0.0.dist-info/WHEEL +4 -0
- blenderwright-2.0.0.dist-info/entry_points.txt +3 -0
- blenderwright-2.0.0.dist-info/licenses/LICENSE +21 -0
blenderwright/aliases.py
ADDED
|
@@ -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
|