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,651 @@
1
+ """Interactive Ollama chat client for controlling Blender via blenderwright."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import json
6
+ import re
7
+ import sys
8
+ from typing import Any
9
+
10
+ try:
11
+ import ollama
12
+ from ollama import Client as OllamaClient
13
+ except ImportError:
14
+ ollama = None
15
+ OllamaClient = None
16
+
17
+ from blenderwright.connection import BlenderConnectionError
18
+ from blenderwright.tool_registry import get_ollama_tools
19
+
20
+ # Supported image formats for the !image REPL command
21
+ SUPPORTED_IMAGE_FORMATS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
22
+
23
+ # Default models
24
+ # The 175 tool schemas cost roughly 31,000 tokens on every request, measured
25
+ # against qwen3.5 via prompt_eval_count. A 32,768 window therefore leaves under
26
+ # 1,700 tokens for up to MAX_TOOL_ROUNDS rounds of calls and their results, so
27
+ # work overflowed mid-task and Ollama silently dropped messages. Give the
28
+ # conversation real room; models in use here support far larger windows.
29
+ DEFAULT_NUM_CTX = 65536
30
+
31
+ DEFAULT_CHAT_MODEL = "qwen2.5-coder:14b"
32
+ DEFAULT_VISION_MODEL = "llava-llama3:latest"
33
+
34
+ # Max tool-call loop iterations to prevent infinite retries
35
+ MAX_TOOL_ROUNDS = 25
36
+
37
+ # Base system prompt: tool list is appended dynamically in initialize()
38
+ SYSTEM_PROMPT_BASE = """You are an expert Blender 3D artist and technical director. You control \
39
+ Blender through tool calls.
40
+
41
+ IMPORTANT: You must ONLY use tools from the list below. Do NOT invent tool names.
42
+
43
+ Guidelines:
44
+ - Always get_scene_info first to understand the current state before making changes.
45
+ - Use get_viewport_screenshot to visually check your work after significant changes.
46
+ - To delete an object, use delete_object with the "name" parameter.
47
+ - To move/rotate/scale objects, use set_location, set_rotation, set_scale.
48
+ - To create objects, use create_object with "type" (CUBE, SPHERE, etc).
49
+ - Apply professional 3D practices: proper topology, edge flow, material setup.
50
+ - Name objects descriptively. Keep scenes organized with collections.
51
+ - When the user describes something to create, break it into logical steps.
52
+ - Explain what you're doing briefly, then execute with tool calls.
53
+
54
+ Choose the primitive that matches the object's silhouette, not its category:
55
+ - Round in cross-section (rocket, fuselage, pipe, tank, barrel, column, limb, \
56
+ tree trunk): CYLINDER. Taper with CONE. This is the most commonly missed one; \
57
+ "mechanical" does not mean "box".
58
+ - Boxy in cross-section (crate, building, table, panel, brick): CUBE.
59
+ - Rounded or blobby (head, boulder, pod, dome): UV_SPHERE.
60
+ - Dish or nozzle shapes (engine bell, funnel, horn): CONE, often scaled and \
61
+ flipped.
62
+ Ask yourself what the object looks like sliced through the middle, then pick \
63
+ the primitive with that cross-section.
64
+
65
+ Every object is solid and needs all three axes sized. X, Y and Z:
66
+ - Before creating anything, state its real width, depth and height.
67
+ - A part left at its default depth produces a flat cardboard cutout of the \
68
+ object rather than the object. This is the single most common failure. If two \
69
+ of the three dimensions are large and one is still the default, it is wrong.
70
+ - Cylinders are round: their X and Y should usually match each other, with Z \
71
+ as the length.
72
+
73
+ Parts must actually meet. An assembly is not a pile:
74
+ - Stack parts so their surfaces touch or slightly overlap. Compute positions \
75
+ from the sizes you chose: a 40m stage sitting on top of a 30m stage is centred \
76
+ 20m above that stage's centre, not at the same location.
77
+ - Two parts at the same location are inside each other, not stacked.
78
+ - Engines, fins and greebles attach to the surface of the body, not floating \
79
+ beside it. If you name something "left", create a matching "right".
80
+
81
+ Modeling strategy: what actually works with available tools:
82
+ - Organic/anatomical (bodies, creatures, faces): build from multiple positioned \
83
+ primitives (UV_SPHERE for rounded forms, CYLINDER for shafts). Scale and \
84
+ position each part, then join_objects to merge. Add Subdivision modifier \
85
+ (levels 2-3) and set_smooth_shading for smooth results. Do NOT use sculpt mode: \
86
+ no stroke tools are available.
87
+ - Hard-surface (mechanical, weapons, props): start from the primitive whose \
88
+ cross-section matches, then refine with add_loop_cut and extrude_faces. Use \
89
+ bevel_edges for chamfers. Mirror modifier for symmetric objects.
90
+ - Layered organic forms: overlap multiple UV_SPHEREs at different scales and \
91
+ positions to approximate organic volume, then join. Subdivision smooths the \
92
+ joins.
93
+ - DO NOT use boolean_operation for organic shapes: unreliable without \
94
+ perfectly clean manifold meshes. Prefer join_objects + smooth shading instead.
95
+ - DO NOT enter sculpt mode: there are no brush stroke tools. It is a dead end.
96
+
97
+ Always plan before acting:
98
+ 1. State your approach: which primitive for each part and why that \
99
+ cross-section, the width/depth/height of each, and where each sits relative to \
100
+ the others.
101
+ 2. Then execute step by step with tool calls.
102
+ 3. After major changes, use get_viewport_screenshot to verify visually. Check \
103
+ that nothing is flat, nothing floats, and nothing is buried inside another part.
104
+ 4. Finish the whole request. If asked for several things, a scene, effects, a \
105
+ check, do all of them, and say which you could not do rather than stopping.
106
+ """
107
+
108
+
109
+ def parse_image_command(user_input: str) -> tuple[str, str] | None:
110
+ """Parse a !image command from user input.
111
+
112
+ Args:
113
+ user_input: Raw user input string.
114
+
115
+ Returns:
116
+ Tuple of (image_path, message) or None if not an image command.
117
+ """
118
+ if not user_input.startswith("!image "):
119
+ return None
120
+ remainder = user_input[len("!image "):].strip()
121
+ if not remainder:
122
+ return None
123
+ parts = remainder.split(None, 1)
124
+ image_path = parts[0]
125
+ message = parts[1] if len(parts) > 1 else ""
126
+ return (image_path, message)
127
+
128
+
129
+ def load_image_as_base64(image_path: str) -> str:
130
+ """Load an image file and return its base64-encoded string.
131
+
132
+ Args:
133
+ image_path: Absolute or relative path to the image file.
134
+
135
+ Returns:
136
+ Base64-encoded string of the image bytes.
137
+
138
+ Raises:
139
+ ValueError: If the file extension is not in SUPPORTED_IMAGE_FORMATS.
140
+ FileNotFoundError: If the file does not exist.
141
+ OSError: If the file cannot be read.
142
+ """
143
+ import pathlib
144
+ ext = pathlib.Path(image_path).suffix.lower()
145
+ if ext not in SUPPORTED_IMAGE_FORMATS:
146
+ supported = ", ".join(sorted(SUPPORTED_IMAGE_FORMATS))
147
+ raise ValueError(f"Unsupported image format '{ext}'. Supported: {supported}")
148
+ with open(image_path, "rb") as f:
149
+ return base64.b64encode(f.read()).decode("utf-8")
150
+
151
+
152
+ class BlenderChatSession:
153
+ """Interactive chat session connecting Ollama to Blender."""
154
+
155
+ def __init__(
156
+ self,
157
+ chat_model: str = DEFAULT_CHAT_MODEL,
158
+ vision_model: str = DEFAULT_VISION_MODEL,
159
+ host: str = "127.0.0.1",
160
+ port: int = 9876,
161
+ ollama_host: str | None = None,
162
+ think: bool = False,
163
+ num_ctx: int = DEFAULT_NUM_CTX,
164
+ ):
165
+ self.chat_model = chat_model
166
+ self.vision_model = vision_model
167
+ self.host = host
168
+ self.port = port
169
+ self.think = think
170
+ self.num_ctx = num_ctx
171
+ if OllamaClient is None:
172
+ raise RuntimeError(
173
+ "The ollama package is required for the chat client. "
174
+ "Install it with: uv pip install -e '.[chat]'"
175
+ )
176
+ self.ollama_client = OllamaClient(host=ollama_host) if ollama_host else OllamaClient()
177
+ self.messages: list[dict[str, Any]] = []
178
+ self.tools: list[dict[str, Any]] = []
179
+ self._tool_names: set[str] = set()
180
+ self._loop: asyncio.AbstractEventLoop | None = None
181
+
182
+ def initialize(self) -> None:
183
+ """Connect to Blender and load tool definitions."""
184
+ # Import here to avoid circular imports: server module registers all tools
185
+ from blenderwright.server import mcp
186
+
187
+ # Configure and verify Blender connection
188
+ from blenderwright.connection import BlenderConnection
189
+ from blenderwright import server as srv
190
+ srv._connection = BlenderConnection(host=self.host, port=self.port)
191
+ srv._connection.connect()
192
+
193
+ self.tools = get_ollama_tools(mcp)
194
+ self._tool_names = {t["function"]["name"] for t in self.tools}
195
+
196
+ # Build system prompt with tool listing grouped by module
197
+ tool_list = _build_tool_list(self.tools)
198
+ system_prompt = SYSTEM_PROMPT_BASE + "\nAvailable tools:\n" + tool_list
199
+
200
+ # Roughly four characters per token. Exact enough to show whether the
201
+ # tools have eaten the window before the conversation starts.
202
+ prompt_tokens = (len(system_prompt) + len(json.dumps(self.tools))) // 4
203
+ headroom = self.num_ctx - prompt_tokens
204
+ print(f"Tools: {len(self.tools)} | prompt ~{prompt_tokens:,} tokens "
205
+ f"of {self.num_ctx:,} | ~{headroom:,} left for the conversation")
206
+ if headroom < prompt_tokens // 2:
207
+ print(" Warning: little room left for tool results. "
208
+ "Raise --num-ctx if replies stop early.")
209
+ self.messages = [{"role": "system", "content": system_prompt}]
210
+
211
+ def execute_tool(self, name: str, arguments: dict[str, Any]) -> str:
212
+ """Execute a blenderwright tool through the MCP tool layer.
213
+
214
+ Routes through FastMCP's call_tool so we get proper parameter
215
+ validation and correct parameter-to-command mapping.
216
+
217
+ Args:
218
+ name: The tool name (as registered with @mcp.tool()).
219
+ arguments: The tool parameters.
220
+
221
+ Returns:
222
+ JSON string of the result.
223
+ """
224
+ from blenderwright.server import mcp
225
+
226
+ if self._loop is None:
227
+ self._loop = asyncio.new_event_loop()
228
+
229
+ try:
230
+ result = self._loop.run_until_complete(mcp.call_tool(name, arguments))
231
+ # call_tool returns list[TextContent]: extract the text
232
+ if result and hasattr(result[0], "text"):
233
+ return result[0].text
234
+ return json.dumps(result, default=str)
235
+ except Exception as e:
236
+ return json.dumps({"status": "error", "result": str(e)})
237
+
238
+ def analyze_screenshot(self, image_base64: str, context: str = "") -> str:
239
+ """Send a viewport screenshot to the vision model for analysis.
240
+
241
+ Args:
242
+ image_base64: Base64-encoded PNG image data.
243
+ context: Optional context about what to look for.
244
+
245
+ Returns:
246
+ Vision model's analysis of the image.
247
+ """
248
+ prompt = "Analyze this Blender viewport screenshot. "
249
+ if context:
250
+ prompt += context
251
+ else:
252
+ prompt += (
253
+ "Describe what you see: objects, materials, lighting, composition. "
254
+ "Note any issues with topology, scale, or visual quality."
255
+ )
256
+
257
+ response = self.ollama_client.chat(
258
+ model=self.vision_model,
259
+ messages=[{
260
+ "role": "user",
261
+ "content": prompt,
262
+ "images": [image_base64],
263
+ }],
264
+ )
265
+ return response.message.content
266
+
267
+ def chat(self, user_message: str, images: list[str] | None = None) -> str:
268
+ """Send a message and process the full tool-calling loop.
269
+
270
+ Args:
271
+ user_message: The user's message.
272
+ images: Optional list of base64-encoded image strings to include.
273
+
274
+ Returns:
275
+ The final assistant response text.
276
+ """
277
+ if self.think:
278
+ user_message = f"/think\n{user_message}"
279
+ user_msg: dict[str, Any] = {"role": "user", "content": user_message}
280
+ if images:
281
+ user_msg["images"] = images
282
+ self.messages.append(user_msg)
283
+
284
+ chat_kwargs: dict[str, Any] = {
285
+ "model": self.chat_model,
286
+ "messages": self.messages,
287
+ "tools": self.tools,
288
+ "options": {"num_ctx": self.num_ctx},
289
+ }
290
+
291
+ for _round in range(MAX_TOOL_ROUNDS):
292
+ response = self.ollama_client.chat(**chat_kwargs)
293
+
294
+ # Check for native tool calls first
295
+ tool_calls = response.message.tool_calls or []
296
+
297
+ # Fallback: parse tool calls from text if model outputs them as text
298
+ if not tool_calls and response.message.content:
299
+ parsed = _parse_text_tool_calls(response.message.content, self._tool_names)
300
+ if parsed:
301
+ tool_calls = parsed
302
+ # Strip tool-call markup from the message content before appending
303
+ clean_content = _strip_tool_markup(response.message.content)
304
+ self.messages.append({"role": "assistant", "content": clean_content})
305
+ else:
306
+ self.messages.append(response.message)
307
+ return response.message.content
308
+ elif not tool_calls:
309
+ self.messages.append(response.message)
310
+ return response.message.content or ""
311
+ else:
312
+ self.messages.append(response.message)
313
+
314
+ for tool_call in tool_calls:
315
+ if hasattr(tool_call, "function"):
316
+ tool_name = tool_call.function.name
317
+ tool_args = tool_call.function.arguments
318
+ else:
319
+ tool_name = tool_call["name"]
320
+ tool_args = tool_call.get("arguments", {})
321
+
322
+ # Check if tool exists: if not, give model corrective feedback
323
+ if tool_name not in self._tool_names:
324
+ similar = _find_similar_tools(tool_name, self._tool_names)
325
+ hint = f"Tool '{tool_name}' does not exist."
326
+ if similar:
327
+ hint += f" Similar tools: {', '.join(similar)}."
328
+ hint += " Use get_scene_info to see available objects first."
329
+ print(f" [!] {hint}")
330
+ self.messages.append({
331
+ "role": "tool",
332
+ "content": hint,
333
+ })
334
+ continue
335
+
336
+ print(f" -> {tool_name}({_format_args(tool_args)})")
337
+
338
+ result = self.execute_tool(tool_name, tool_args)
339
+
340
+ # If tool errored, still feed it back so the model can recover
341
+ if result.startswith('{"status": "error"'):
342
+ print(f" [!] Error: {result}")
343
+
344
+ # If this was a screenshot, auto-analyze with vision model
345
+ vision_note = ""
346
+ if tool_name in ("get_viewport_screenshot", "fast_viewport_capture"):
347
+ try:
348
+ result_data = json.loads(result)
349
+ if isinstance(result_data, dict) and "image" in result_data:
350
+ print(" -> Analyzing screenshot with vision model...")
351
+ analysis = self.analyze_screenshot(result_data["image"])
352
+ vision_note = f"\n\n[Vision Analysis]: {analysis}"
353
+ except (json.JSONDecodeError, KeyError):
354
+ pass
355
+
356
+ if vision_note:
357
+ try:
358
+ result_obj = json.loads(result)
359
+ result_obj["vision_analysis"] = analysis
360
+ tool_content = json.dumps(result_obj)
361
+ except (json.JSONDecodeError, TypeError):
362
+ tool_content = result + vision_note
363
+ else:
364
+ tool_content = result
365
+ self.messages.append({
366
+ "role": "tool",
367
+ "content": tool_content,
368
+ })
369
+
370
+ return "(Reached maximum tool-calling rounds. Please try a simpler request.)"
371
+
372
+ def _handle_image_command(self, image_path: str, message: str) -> str:
373
+ """Handle a !image command by routing through the vision model first.
374
+
375
+ Sends the image to the vision model for analysis, then passes the
376
+ resulting text description to the chat model. This avoids sending
377
+ raw image bytes to non-vision chat models (which causes HTTP 500).
378
+
379
+ Args:
380
+ image_path: Path to the image file (already validated).
381
+ message: User's instruction about what to do with the image.
382
+
383
+ Returns:
384
+ The final assistant response text.
385
+ """
386
+ image_data = load_image_as_base64(image_path)
387
+ context = message if message else "Describe this image in detail for use as a 3D modeling reference."
388
+ print(" -> Analyzing image with vision model...")
389
+ description = self.analyze_screenshot(image_data, context=context)
390
+ combined = f"[Reference image analysis]: {description}"
391
+ if message:
392
+ combined += f"\n\nUser request: {message}"
393
+ return self.chat(combined)
394
+
395
+ def close(self) -> None:
396
+ """Disconnect from Blender."""
397
+ from blenderwright import server as srv
398
+ if srv._connection:
399
+ srv._connection.disconnect()
400
+ if self._loop is not None:
401
+ self._loop.close()
402
+ self._loop = None
403
+
404
+
405
+ def _parse_text_tool_calls(text: str, known_tools: set[str]) -> list[dict[str, Any]]:
406
+ """Parse tool calls from model text output when native tool-calling isn't used.
407
+
408
+ Handles formats like:
409
+ - <function=name><parameter=key>value</parameter></function>
410
+ - {"name": "tool", "arguments": {...}}
411
+
412
+ Args:
413
+ text: The model's text output.
414
+ known_tools: Set of valid tool names.
415
+
416
+ Returns:
417
+ List of parsed tool call dicts, or empty list if none found.
418
+ """
419
+ calls = []
420
+
421
+ # Pattern 1: XML-style <function=name>...</function>
422
+ func_pattern = re.compile(
423
+ r'<function=(\w+)>(.*?)</function>', re.DOTALL
424
+ )
425
+ param_pattern = re.compile(
426
+ r'<parameter=(\w+)>\s*(.*?)\s*</parameter>', re.DOTALL
427
+ )
428
+
429
+ for match in func_pattern.finditer(text):
430
+ func_name = match.group(1)
431
+ body = match.group(2)
432
+ args = {}
433
+ for param_match in param_pattern.finditer(body):
434
+ key = param_match.group(1)
435
+ value = param_match.group(2).strip()
436
+ try:
437
+ args[key] = json.loads(value)
438
+ except (json.JSONDecodeError, ValueError):
439
+ args[key] = value
440
+ # Accept all parsed tool calls: validation happens in the chat loop
441
+ calls.append({"name": func_name, "arguments": args})
442
+
443
+ if calls:
444
+ return calls
445
+
446
+ # Pattern 2: JSON object with name/arguments: find by scanning for opening brace
447
+ for i, ch in enumerate(text):
448
+ if ch != '{':
449
+ continue
450
+ depth = 0
451
+ for j in range(i, len(text)):
452
+ if text[j] == '{':
453
+ depth += 1
454
+ elif text[j] == '}':
455
+ depth -= 1
456
+ if depth == 0:
457
+ candidate = text[i:j + 1]
458
+ try:
459
+ obj = json.loads(candidate)
460
+ if isinstance(obj, dict) and "name" in obj and "arguments" in obj:
461
+ calls.append({
462
+ "name": obj["name"],
463
+ "arguments": obj.get("arguments", {}),
464
+ })
465
+ except (json.JSONDecodeError, AttributeError):
466
+ pass
467
+ break
468
+
469
+ return calls
470
+
471
+
472
+ def _build_tool_list(tools: list[dict[str, Any]]) -> str:
473
+ """Build a compact tool listing for the system prompt.
474
+
475
+ Args:
476
+ tools: List of Ollama tool definitions.
477
+
478
+ Returns:
479
+ Formatted string with tool names and brief descriptions.
480
+ """
481
+ lines = []
482
+ for tool in sorted(tools, key=lambda t: t["function"]["name"]):
483
+ name = tool["function"]["name"]
484
+ desc = tool["function"].get("description", "")
485
+ # Take just the first sentence for brevity
486
+ short_desc = desc.split(".")[0].strip() if desc else ""
487
+ lines.append(f"- {name}: {short_desc}")
488
+ return "\n".join(lines)
489
+
490
+
491
+ def _find_similar_tools(name: str, known_tools: set[str], max_results: int = 5) -> list[str]:
492
+ """Find tools with similar names for corrective suggestions.
493
+
494
+ Args:
495
+ name: The unknown tool name.
496
+ known_tools: Set of valid tool names.
497
+ max_results: Maximum suggestions to return.
498
+
499
+ Returns:
500
+ List of similar tool names, most relevant first.
501
+ """
502
+ name_words = set(name.lower().split("_"))
503
+ scored = []
504
+ for tool in known_tools:
505
+ tool_words = set(tool.lower().split("_"))
506
+ overlap = len(name_words & tool_words)
507
+ if overlap > 0:
508
+ scored.append((overlap, tool))
509
+ scored.sort(key=lambda x: (-x[0], x[1]))
510
+ return [s[1] for s in scored[:max_results]]
511
+
512
+
513
+ def _strip_tool_markup(text: str) -> str:
514
+ """Remove tool-call markup from text, keeping any natural language."""
515
+ # Remove XML-style function calls
516
+ cleaned = re.sub(r'<function=\w+>.*?</function>', '', text, flags=re.DOTALL)
517
+ # Remove opening <tool_call> tags
518
+ cleaned = re.sub(r'<tool_call>', '', cleaned)
519
+ # Remove closing </tool_call> tags
520
+ cleaned = re.sub(r'</tool_call>', '', cleaned)
521
+ return cleaned.strip() or ""
522
+
523
+
524
+ def _format_args(args: dict[str, Any]) -> str:
525
+ """Format tool arguments for display, truncating long values."""
526
+ parts = []
527
+ for k, v in args.items():
528
+ s = repr(v)
529
+ if len(s) > 50:
530
+ s = s[:47] + "..."
531
+ parts.append(f"{k}={s}")
532
+ return ", ".join(parts)
533
+
534
+
535
+ def main():
536
+ """Run the interactive Ollama chat client for Blender."""
537
+ if ollama is None:
538
+ print("Error: 'ollama' package is not installed.")
539
+ print("Install it with: uv pip install ollama")
540
+ sys.exit(1)
541
+
542
+ import argparse
543
+
544
+ parser = argparse.ArgumentParser(
545
+ description="Interactive Ollama chat client for Blender",
546
+ prog="blenderwright-chat",
547
+ )
548
+ parser.add_argument(
549
+ "--model", "-m",
550
+ default=DEFAULT_CHAT_MODEL,
551
+ help=f"Ollama chat model (default: {DEFAULT_CHAT_MODEL})",
552
+ )
553
+ parser.add_argument(
554
+ "--vision-model", "-v",
555
+ default=DEFAULT_VISION_MODEL,
556
+ help=f"Ollama vision model for screenshots (default: {DEFAULT_VISION_MODEL})",
557
+ )
558
+ parser.add_argument(
559
+ "--host",
560
+ default="127.0.0.1",
561
+ help="Blender addon host (default: 127.0.0.1)",
562
+ )
563
+ parser.add_argument(
564
+ "--port", "-p",
565
+ type=int,
566
+ default=9876,
567
+ help="Blender addon port (default: 9876)",
568
+ )
569
+ parser.add_argument(
570
+ "--ollama-host",
571
+ default=None,
572
+ help="Ollama API host URL (default: http://localhost:11434)",
573
+ )
574
+ parser.add_argument(
575
+ "--num-ctx",
576
+ type=int,
577
+ default=DEFAULT_NUM_CTX,
578
+ help=f"Ollama context window (default: {DEFAULT_NUM_CTX}); the tool "
579
+ f"schemas alone cost ~31k tokens",
580
+ )
581
+ parser.add_argument(
582
+ "--think",
583
+ action="store_true",
584
+ default=False,
585
+ help="Enable thinking mode (chain-of-thought reasoning before tool calls)",
586
+ )
587
+ args = parser.parse_args()
588
+
589
+ session = BlenderChatSession(
590
+ chat_model=args.model,
591
+ vision_model=args.vision_model,
592
+ host=args.host,
593
+ port=args.port,
594
+ ollama_host=args.ollama_host,
595
+ think=args.think,
596
+ num_ctx=args.num_ctx,
597
+ )
598
+
599
+ ollama_display = args.ollama_host or "localhost:11434"
600
+ think_display = " | think: on" if args.think else ""
601
+ print(f"blenderwright chat | model: {args.model} | vision: {args.vision_model}{think_display}")
602
+ print(f"Ollama: {ollama_display}")
603
+ print(f"Connecting to Blender at {args.host}:{args.port}...")
604
+
605
+ try:
606
+ session.initialize()
607
+ print("Connected. Type 'quit' to exit.\n")
608
+ except BlenderConnectionError as e:
609
+ print(f"Error: {e}")
610
+ sys.exit(1)
611
+
612
+ try:
613
+ while True:
614
+ try:
615
+ user_input = input("You: ").strip()
616
+ except EOFError:
617
+ break
618
+
619
+ if not user_input:
620
+ continue
621
+ if user_input.lower() in ("quit", "exit", "q"):
622
+ break
623
+
624
+ parsed_img = parse_image_command(user_input)
625
+ if parsed_img is not None:
626
+ image_path, message = parsed_img
627
+ try:
628
+ response = session._handle_image_command(image_path, message)
629
+ print(f"\nAssistant: {response}\n")
630
+ except FileNotFoundError:
631
+ print(f"Error: Image file not found: {image_path}")
632
+ except ValueError as e:
633
+ print(f"Error: {e}")
634
+ except OSError as e:
635
+ print(f"Error reading image: {e}")
636
+ continue
637
+
638
+ try:
639
+ response = session.chat(user_input)
640
+ print(f"\nAssistant: {response}\n")
641
+ except Exception as e:
642
+ print(f"\nError: {e}\n")
643
+ except KeyboardInterrupt:
644
+ print("\n")
645
+ finally:
646
+ session.close()
647
+ print("Disconnected from Blender.")
648
+
649
+
650
+ if __name__ == "__main__":
651
+ main()
@@ -0,0 +1 @@
1
+ """MCP prompt templates for common Blender workflows."""