This is page 2 of 3. Use http://codebase.md/always-tinkering/rhinomcpserver?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitignore
├── code_architecture.md
├── combined_mcp_server.py
├── diagnose_rhino_connection.py
├── log_manager.py
├── LOGGING.md
├── logs
│ └── json_filter.py
├── mcpLLM.txt
├── NLog.config
├── README.md
├── RHINO_PLUGIN_UPDATE.md
├── RhinoMcpPlugin
│ ├── Models
│ │ └── RhinoObjectProperties.cs
│ ├── Properties
│ │ └── AssemblyInfo.cs
│ ├── RhinoMcpCommand.cs
│ ├── RhinoMcpPlugin.cs
│ ├── RhinoMcpPlugin.csproj
│ ├── RhinoSocketServer.cs
│ ├── RhinoUtilities.cs
│ └── Tools
│ ├── GeometryTools.cs
│ └── SceneTools.cs
├── RhinoMcpPlugin.Tests
│ ├── ImplementationPlan.md
│ ├── Mocks
│ │ └── MockRhinoDoc.cs
│ ├── README.md
│ ├── RhinoMcpPlugin.Tests.csproj
│ ├── test.runsettings
│ ├── Tests
│ │ ├── ColorUtilTests.cs
│ │ ├── RhinoMcpPluginTests.cs
│ │ ├── RhinoSocketServerTests.cs
│ │ └── RhinoUtilitiesTests.cs
│ └── Utils
│ └── ColorParser.cs
├── RhinoPluginFixImplementation.cs
├── RhinoPluginLoggingSpec.md
├── run-combined-server.sh
├── scripts
│ ├── direct-launcher.sh
│ ├── run-combined-server.sh
│ └── run-python-server.sh
└── src
├── daemon_mcp_server.py
├── socket_proxy.py
└── standalone-mcp-server.py
```
# Files
--------------------------------------------------------------------------------
/log_manager.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Log Manager Utility for RhinoMcpServer
4 |
5 | This script helps manage, view, and analyze logs from the MCP Server,
6 | Rhino plugin, Claude AI, and diagnostic tools. It provides a unified
7 | view of logs across all components to aid in debugging.
8 | """
9 |
10 | import os
11 | import sys
12 | import re
13 | import glob
14 | import argparse
15 | from datetime import datetime, timedelta
16 | import json
17 | import subprocess
18 |
19 | # Define the log directory structure
20 | LOG_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
21 | SERVER_LOGS = os.path.join(LOG_ROOT, "server")
22 | PLUGIN_LOGS = os.path.join(LOG_ROOT, "plugin")
23 | CLAUDE_LOGS = os.path.join(LOG_ROOT, "claude")
24 | DIAGNOSTIC_LOGS = os.path.join(LOG_ROOT, "diagnostics")
25 |
26 | # Log entry pattern for parsing - matches standard timestamp format
27 | LOG_PATTERN = re.compile(r'^\[(?P<timestamp>.*?)\] \[(?P<level>.*?)\] \[(?P<component>.*?)\] (?P<message>.*)$')
28 |
29 | class LogEntry:
30 | """Represents a parsed log entry with timestamp and metadata"""
31 |
32 | def __init__(self, timestamp, level, component, message, source_file):
33 | self.timestamp = timestamp
34 | self.level = level.upper()
35 | self.component = component
36 | self.message = message
37 | self.source_file = source_file
38 |
39 | @classmethod
40 | def from_line(cls, line, source_file):
41 | """Parse a log line into a LogEntry object"""
42 | match = LOG_PATTERN.match(line)
43 | if match:
44 | try:
45 | timestamp = datetime.strptime(match.group('timestamp'), '%Y-%m-%d %H:%M:%S,%f')
46 | except ValueError:
47 | try:
48 | timestamp = datetime.strptime(match.group('timestamp'), '%Y-%m-%d %H:%M:%S')
49 | except ValueError:
50 | # If timestamp can't be parsed, use current time
51 | timestamp = datetime.now()
52 |
53 | return cls(
54 | timestamp,
55 | match.group('level'),
56 | match.group('component'),
57 | match.group('message'),
58 | source_file
59 | )
60 | return None
61 |
62 | def __lt__(self, other):
63 | """Support sorting by timestamp"""
64 | return self.timestamp < other.timestamp
65 |
66 | def to_string(self, colors=True, show_source=False):
67 | """Format the log entry for display"""
68 | # Define ANSI color codes
69 | color_map = {
70 | "DEBUG": "\033[36m", # Cyan
71 | "INFO": "\033[32m", # Green
72 | "WARNING": "\033[33m", # Yellow
73 | "ERROR": "\033[31m", # Red
74 | "CRITICAL": "\033[41m\033[97m" # White on red background
75 | }
76 | component_colors = {
77 | "server": "\033[94m", # Blue
78 | "plugin": "\033[95m", # Magenta
79 | "diagnostic": "\033[96m", # Cyan
80 | "claude": "\033[92m" # Green
81 | }
82 | reset = "\033[0m"
83 |
84 | # Format timestamp
85 | timestamp_str = self.timestamp.strftime("%Y-%m-%d %H:%M:%S")
86 |
87 | # Apply colors if enabled
88 | if colors:
89 | level_color = color_map.get(self.level, "")
90 | comp_color = component_colors.get(self.component, "")
91 |
92 | if self.level in ["ERROR", "CRITICAL"]:
93 | # For errors, color the whole line
94 | source_info = f" ({os.path.basename(self.source_file)})" if show_source else ""
95 | return f"{level_color}[{timestamp_str}] [{self.level}] [{self.component}] {self.message}{source_info}{reset}"
96 | else:
97 | # For non-errors, color just the level and component
98 | source_info = f" ({os.path.basename(self.source_file)})" if show_source else ""
99 | return f"[{timestamp_str}] [{level_color}{self.level}{reset}] [{comp_color}{self.component}{reset}] {self.message}{source_info}"
100 | else:
101 | source_info = f" ({os.path.basename(self.source_file)})" if show_source else ""
102 | return f"[{timestamp_str}] [{self.level}] [{self.component}] {self.message}{source_info}"
103 |
104 | def collect_logs(since=None, level_filter=None, component_filter=None):
105 | """Collect and parse logs from all sources
106 |
107 | Args:
108 | since: Datetime object for filtering logs by age
109 | level_filter: List of log levels to include (DEBUG, INFO, etc)
110 | component_filter: List of components to include (server, plugin, etc)
111 |
112 | Returns:
113 | List of LogEntry objects sorted by timestamp
114 | """
115 | all_entries = []
116 |
117 | # Create directories if they don't exist
118 | for directory in [LOG_ROOT, SERVER_LOGS, PLUGIN_LOGS, CLAUDE_LOGS, DIAGNOSTIC_LOGS]:
119 | os.makedirs(directory, exist_ok=True)
120 |
121 | # Gather log files from all directories
122 | log_files = []
123 | log_files.extend(glob.glob(os.path.join(SERVER_LOGS, "*.log")))
124 | log_files.extend(glob.glob(os.path.join(PLUGIN_LOGS, "*.log")))
125 | log_files.extend(glob.glob(os.path.join(DIAGNOSTIC_LOGS, "*.log")))
126 |
127 | # Also check for Claude logs but handle them differently
128 | claude_files = glob.glob(os.path.join(CLAUDE_LOGS, "*.log"))
129 |
130 | # Process standard log files
131 | for log_file in log_files:
132 | try:
133 | with open(log_file, 'r') as f:
134 | for line in f:
135 | line = line.strip()
136 | if not line:
137 | continue
138 |
139 | entry = LogEntry.from_line(line, log_file)
140 | if entry:
141 | # Apply filters
142 | if since and entry.timestamp < since:
143 | continue
144 | if level_filter and entry.level not in level_filter:
145 | continue
146 | if component_filter and entry.component not in component_filter:
147 | continue
148 |
149 | all_entries.append(entry)
150 | except Exception as e:
151 | print(f"Error processing {log_file}: {e}", file=sys.stderr)
152 |
153 | # Handle Claude logs which have a different format
154 | for claude_file in claude_files:
155 | try:
156 | # Get file creation time to use as timestamp
157 | file_time = datetime.fromtimestamp(os.path.getctime(claude_file))
158 |
159 | # Skip if it's before our filter time
160 | if since and file_time < since:
161 | continue
162 |
163 | # Only include preview of Claude logs since they can be large
164 | with open(claude_file, 'r') as f:
165 | content = f.read(500) # Just read first 500 chars
166 | truncated = len(content) < os.path.getsize(claude_file)
167 | message = content + ("..." if truncated else "")
168 |
169 | # Create a synthetic log entry
170 | entry = LogEntry(
171 | file_time,
172 | "INFO",
173 | "claude",
174 | f"Claude interaction: {message}",
175 | claude_file
176 | )
177 |
178 | # Apply filters
179 | if not component_filter or "claude" in component_filter:
180 | all_entries.append(entry)
181 | except Exception as e:
182 | print(f"Error processing Claude log {claude_file}: {e}", file=sys.stderr)
183 |
184 | # Sort all entries by timestamp
185 | all_entries.sort()
186 | return all_entries
187 |
188 | def display_logs(entries, colors=True, show_source=False, max_entries=None):
189 | """Display log entries with optional formatting
190 |
191 | Args:
192 | entries: List of LogEntry objects
193 | colors: Whether to use ANSI colors in output
194 | show_source: Whether to show source filename
195 | max_entries: Maximum number of entries to show (None for all)
196 | """
197 | # Maybe limit entries
198 | if max_entries is not None and len(entries) > max_entries:
199 | skipped = len(entries) - max_entries
200 | entries = entries[-max_entries:]
201 | print(f"... (skipped {skipped} earlier entries) ...\n")
202 |
203 | for entry in entries:
204 | print(entry.to_string(colors=colors, show_source=show_source))
205 |
206 | def extract_error_context(entries, context_lines=5):
207 | """Extract log entries around errors with context
208 |
209 | Args:
210 | entries: List of all LogEntry objects
211 | context_lines: Number of log lines before and after each error
212 |
213 | Returns:
214 | List of error contexts (each being a list of LogEntry objects)
215 | """
216 | error_contexts = []
217 | error_indices = [i for i, entry in enumerate(entries) if entry.level in ["ERROR", "CRITICAL"]]
218 |
219 | for error_idx in error_indices:
220 | # Get context before and after error
221 | start_idx = max(0, error_idx - context_lines)
222 | end_idx = min(len(entries), error_idx + context_lines + 1)
223 |
224 | # Extract the context
225 | context = entries[start_idx:end_idx]
226 | error_contexts.append(context)
227 |
228 | return error_contexts
229 |
230 | def generate_error_report(entries):
231 | """Generate a summary report of errors
232 |
233 | Args:
234 | entries: List of LogEntry objects
235 |
236 | Returns:
237 | String containing the error report
238 | """
239 | error_entries = [e for e in entries if e.level in ["ERROR", "CRITICAL"]]
240 |
241 | if not error_entries:
242 | return "No errors found in logs."
243 |
244 | # Group errors by component
245 | errors_by_component = {}
246 | for entry in error_entries:
247 | if entry.component not in errors_by_component:
248 | errors_by_component[entry.component] = []
249 | errors_by_component[entry.component].append(entry)
250 |
251 | # Generate the report
252 | report = f"Error Report ({len(error_entries)} errors found)\n"
253 | report += "=" * 50 + "\n\n"
254 |
255 | for component, errors in errors_by_component.items():
256 | report += f"{component.upper()} Errors: {len(errors)}\n"
257 | report += "-" * 30 + "\n"
258 |
259 | # Group by error message pattern
260 | error_patterns = {}
261 | for error in errors:
262 | # Simplify message by removing variable parts (numbers, IDs, etc)
263 | simplified = re.sub(r'\b(?:\w+[-_])?[0-9a-f]{8}(?:[-_]\w+)?\b', 'ID', error.message)
264 | simplified = re.sub(r'\d+', 'N', simplified)
265 |
266 | if simplified not in error_patterns:
267 | error_patterns[simplified] = []
268 | error_patterns[simplified].append(error)
269 |
270 | # Report each error pattern
271 | for pattern, pattern_errors in error_patterns.items():
272 | report += f"\n• {pattern} ({len(pattern_errors)} occurrences)\n"
273 | report += f" First seen: {pattern_errors[0].timestamp}\n"
274 | report += f" Last seen: {pattern_errors[-1].timestamp}\n"
275 | report += f" Example: {pattern_errors[-1].message}\n"
276 |
277 | report += "\n" + "=" * 50 + "\n\n"
278 |
279 | return report
280 |
281 | def clear_logs(days_old=None, component=None, confirm=True):
282 | """Clear logs based on specified criteria
283 |
284 | Args:
285 | days_old: Delete logs older than this many days
286 | component: Only delete logs from this component
287 | confirm: Whether to prompt for confirmation
288 | """
289 | # Determine which directories to clean
290 | dirs_to_clean = []
291 | if component == "server" or component is None:
292 | dirs_to_clean.append(SERVER_LOGS)
293 | if component == "plugin" or component is None:
294 | dirs_to_clean.append(PLUGIN_LOGS)
295 | if component == "claude" or component is None:
296 | dirs_to_clean.append(CLAUDE_LOGS)
297 | if component == "diagnostic" or component is None:
298 | dirs_to_clean.append(DIAGNOSTIC_LOGS)
299 |
300 | # Collect files to delete
301 | files_to_delete = []
302 | for directory in dirs_to_clean:
303 | for log_file in glob.glob(os.path.join(directory, "*.log")):
304 | if days_old is not None:
305 | # Check file age
306 | file_time = datetime.fromtimestamp(os.path.getmtime(log_file))
307 | cutoff_time = datetime.now() - timedelta(days=days_old)
308 | if file_time >= cutoff_time:
309 | continue # Skip files newer than cutoff
310 |
311 | files_to_delete.append(log_file)
312 |
313 | # Nothing to delete
314 | if not files_to_delete:
315 | print("No logs found matching the specified criteria.")
316 | return
317 |
318 | # Confirm deletion
319 | if confirm:
320 | print(f"Will delete {len(files_to_delete)} log files:")
321 | for f in files_to_delete[:5]:
322 | print(f" - {os.path.basename(f)}")
323 |
324 | if len(files_to_delete) > 5:
325 | print(f" - ... and {len(files_to_delete) - 5} more")
326 |
327 | confirmation = input("Proceed with deletion? (y/N): ").lower()
328 | if confirmation != 'y':
329 | print("Deletion cancelled.")
330 | return
331 |
332 | # Delete the files
333 | deleted_count = 0
334 | for log_file in files_to_delete:
335 | try:
336 | os.remove(log_file)
337 | deleted_count += 1
338 | except Exception as e:
339 | print(f"Error deleting {log_file}: {e}")
340 |
341 | print(f"Successfully deleted {deleted_count} log files.")
342 |
343 | def monitor_logs(interval=1.0, colors=True, level_filter=None, component_filter=None):
344 | """Monitor logs in real-time like 'tail -f'
345 |
346 | Args:
347 | interval: Polling interval in seconds
348 | colors: Whether to use ANSI colors
349 | level_filter: Optional list of log levels to show
350 | component_filter: Optional list of components to show
351 | """
352 | print(f"Monitoring logs (Ctrl+C to exit)...")
353 | print(f"Filters: levels={level_filter or 'all'}, components={component_filter or 'all'}")
354 |
355 | # Get initial log entries and remember the latest timestamp
356 | entries = collect_logs(level_filter=level_filter, component_filter=component_filter)
357 | last_timestamp = entries[-1].timestamp if entries else datetime.now()
358 |
359 | try:
360 | while True:
361 | # Wait for the specified interval
362 | sys.stdout.flush()
363 | subprocess.call("", shell=True) # Hack to make ANSI colors work in Windows
364 |
365 | # Get new entries since the last check
366 | new_entries = collect_logs(
367 | since=last_timestamp,
368 | level_filter=level_filter,
369 | component_filter=component_filter
370 | )
371 |
372 | # Update timestamp for the next iteration
373 | if new_entries:
374 | last_timestamp = new_entries[-1].timestamp
375 |
376 | # Display new entries
377 | for entry in new_entries:
378 | print(entry.to_string(colors=colors, show_source=True))
379 |
380 | # Sleep before checking again
381 | import time
382 | time.sleep(interval)
383 |
384 | except KeyboardInterrupt:
385 | print("\nStopped monitoring logs.")
386 |
387 | def main():
388 | """Parse arguments and execute requested command"""
389 | parser = argparse.ArgumentParser(description="Manage and view RhinoMcpServer logs")
390 |
391 | subparsers = parser.add_subparsers(dest="command", help="Command to execute")
392 |
393 | # view command
394 | view_parser = subparsers.add_parser("view", help="View logs")
395 | view_parser.add_argument("--since", type=str, help="Show logs since (e.g. '1h', '2d', '30m')")
396 | view_parser.add_argument("--level", type=str, help="Filter by log level (comma-separated: DEBUG,INFO,WARNING,ERROR)")
397 | view_parser.add_argument("--component", type=str, help="Filter by component (comma-separated: server,plugin,claude,diagnostic)")
398 | view_parser.add_argument("--no-color", action="store_true", help="Disable colored output")
399 | view_parser.add_argument("--source", action="store_true", help="Show source log file")
400 | view_parser.add_argument("--max", type=int, help="Maximum number of entries to display")
401 |
402 | # errors command
403 | errors_parser = subparsers.add_parser("errors", help="View errors with context")
404 | errors_parser.add_argument("--since", type=str, help="Show errors since (e.g. '1h', '2d', '30m')")
405 | errors_parser.add_argument("--component", type=str, help="Filter by component")
406 | errors_parser.add_argument("--context", type=int, default=5, help="Number of context lines before/after each error")
407 | errors_parser.add_argument("--no-color", action="store_true", help="Disable colored output")
408 |
409 | # report command
410 | report_parser = subparsers.add_parser("report", help="Generate error report")
411 | report_parser.add_argument("--since", type=str, help="Include errors since (e.g. '1h', '2d', '30m')")
412 | report_parser.add_argument("--output", type=str, help="Output file for the report")
413 |
414 | # clear command
415 | clear_parser = subparsers.add_parser("clear", help="Clear logs")
416 | clear_parser.add_argument("--older-than", type=int, help="Delete logs older than N days")
417 | clear_parser.add_argument("--component", type=str, help="Only clear logs for the specified component")
418 | clear_parser.add_argument("--force", action="store_true", help="Do not ask for confirmation")
419 |
420 | # monitor command
421 | monitor_parser = subparsers.add_parser("monitor", help="Monitor logs in real-time")
422 | monitor_parser.add_argument("--interval", type=float, default=1.0, help="Polling interval in seconds")
423 | monitor_parser.add_argument("--level", type=str, help="Filter by log level (comma-separated)")
424 | monitor_parser.add_argument("--component", type=str, help="Filter by component (comma-separated)")
425 | monitor_parser.add_argument("--no-color", action="store_true", help="Disable colored output")
426 |
427 | # info command
428 | info_parser = subparsers.add_parser("info", help="Show information about logs")
429 |
430 | args = parser.parse_args()
431 |
432 | # Handle time expressions like "1h", "2d", etc.
433 | since_time = None
434 | if hasattr(args, 'since') and args.since:
435 | try:
436 | # Parse time expressions
437 | value = int(args.since[:-1])
438 | unit = args.since[-1].lower()
439 |
440 | if unit == 'h':
441 | since_time = datetime.now() - timedelta(hours=value)
442 | elif unit == 'm':
443 | since_time = datetime.now() - timedelta(minutes=value)
444 | elif unit == 'd':
445 | since_time = datetime.now() - timedelta(days=value)
446 | else:
447 | print(f"Invalid time unit in '{args.since}'. Use 'm' for minutes, 'h' for hours, 'd' for days.")
448 | return 1
449 | except ValueError:
450 | print(f"Invalid time format: '{args.since}'. Use e.g. '1h', '30m', '2d'")
451 | return 1
452 |
453 | # Parse level and component filters
454 | level_filter = None
455 | if hasattr(args, 'level') and args.level:
456 | level_filter = [l.strip().upper() for l in args.level.split(',')]
457 |
458 | component_filter = None
459 | if hasattr(args, 'component') and args.component:
460 | component_filter = [c.strip().lower() for c in args.component.split(',')]
461 |
462 | # Execute the requested command
463 | if args.command == "view":
464 | entries = collect_logs(since=since_time, level_filter=level_filter, component_filter=component_filter)
465 | if not entries:
466 | print("No log entries found matching the criteria.")
467 | return 0
468 |
469 | display_logs(
470 | entries,
471 | colors=not args.no_color,
472 | show_source=args.source,
473 | max_entries=args.max
474 | )
475 |
476 | elif args.command == "errors":
477 | # Collect all entries first
478 | all_entries = collect_logs(since=since_time, component_filter=component_filter)
479 | if not all_entries:
480 | print("No log entries found.")
481 | return 0
482 |
483 | # Extract errors with context
484 | error_contexts = extract_error_context(all_entries, context_lines=args.context)
485 | if not error_contexts:
486 | print("No errors found in the logs.")
487 | return 0
488 |
489 | # Display each error with its context
490 | for i, context in enumerate(error_contexts):
491 | if i > 0:
492 | print("\n" + "-" * 80 + "\n")
493 |
494 | print(f"Error {i+1} of {len(error_contexts)}:")
495 | for entry in context:
496 | print(entry.to_string(colors=not args.no_color, show_source=True))
497 |
498 | elif args.command == "report":
499 | entries = collect_logs(since=since_time)
500 | if not entries:
501 | print("No log entries found.")
502 | return 0
503 |
504 | report = generate_error_report(entries)
505 |
506 | if args.output:
507 | with open(args.output, 'w') as f:
508 | f.write(report)
509 | print(f"Error report saved to {args.output}")
510 | else:
511 | print(report)
512 |
513 | elif args.command == "clear":
514 | clear_logs(
515 | days_old=args.older_than,
516 | component=args.component,
517 | confirm=not args.force
518 | )
519 |
520 | elif args.command == "monitor":
521 | monitor_logs(
522 | interval=args.interval,
523 | colors=not args.no_color,
524 | level_filter=level_filter,
525 | component_filter=component_filter
526 | )
527 |
528 | elif args.command == "info":
529 | # Show information about available logs
530 | print("Log Directory Structure:")
531 | print(f" Root: {LOG_ROOT}")
532 |
533 | # Helper function to summarize logs in a directory
534 | def summarize_dir(dir_path, name):
535 | if not os.path.exists(dir_path):
536 | return f"{name}: Directory not found"
537 |
538 | log_files = glob.glob(os.path.join(dir_path, "*.log"))
539 | if not log_files:
540 | return f"{name}: No log files found"
541 |
542 | newest = max(log_files, key=os.path.getmtime)
543 | oldest = min(log_files, key=os.path.getmtime)
544 | newest_time = datetime.fromtimestamp(os.path.getmtime(newest))
545 | oldest_time = datetime.fromtimestamp(os.path.getmtime(oldest))
546 |
547 | total_size = sum(os.path.getsize(f) for f in log_files)
548 | size_mb = total_size / (1024 * 1024)
549 |
550 | return (f"{name}: {len(log_files)} files, {size_mb:.2f} MB total\n"
551 | f" Newest: {os.path.basename(newest)} ({newest_time})\n"
552 | f" Oldest: {os.path.basename(oldest)} ({oldest_time})")
553 |
554 | print("\nLog Summaries:")
555 | print(summarize_dir(SERVER_LOGS, "Server Logs"))
556 | print(summarize_dir(PLUGIN_LOGS, "Plugin Logs"))
557 | print(summarize_dir(CLAUDE_LOGS, "Claude Logs"))
558 | print(summarize_dir(DIAGNOSTIC_LOGS, "Diagnostic Logs"))
559 |
560 | else:
561 | parser.print_help()
562 |
563 | return 0
564 |
565 | if __name__ == "__main__":
566 | sys.exit(main())
```
--------------------------------------------------------------------------------
/combined_mcp_server.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Combined MCP Server for Rhino
4 | This script implements a direct MCP server using the FastMCP pattern,
5 | following the Model Context Protocol specification.
6 | """
7 |
8 | import json
9 | import os
10 | import sys
11 | import time
12 | import logging
13 | import signal
14 | import threading
15 | import traceback
16 | from datetime import datetime
17 | import re
18 | import uuid
19 | import asyncio
20 | import socket
21 | from typing import Dict, Any, List, Optional, Tuple
22 | from contextlib import asynccontextmanager
23 | from typing import AsyncIterator
24 |
25 | # Import the FastMCP class
26 | from mcp.server.fastmcp import FastMCP, Context
27 |
28 | # Configure logging - improved with structured format and unified location
29 | log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
30 | os.makedirs(log_dir, exist_ok=True)
31 |
32 | # Create log subdirectories
33 | server_log_dir = os.path.join(log_dir, "server")
34 | plugin_log_dir = os.path.join(log_dir, "plugin")
35 | claude_log_dir = os.path.join(log_dir, "claude")
36 |
37 | for directory in [server_log_dir, plugin_log_dir, claude_log_dir]:
38 | os.makedirs(directory, exist_ok=True)
39 |
40 | # Log filenames based on date for easier archiving
41 | today = datetime.now().strftime("%Y-%m-%d")
42 | server_log_file = os.path.join(server_log_dir, f"server_{today}.log")
43 | debug_log_file = os.path.join(server_log_dir, f"debug_{today}.log")
44 |
45 | # Set up the logger with custom format including timestamp, level, component, and message
46 | logging.basicConfig(
47 | level=logging.INFO,
48 | format='[%(asctime)s] [%(levelname)s] [%(component)s] %(message)s',
49 | handlers=[
50 | logging.StreamHandler(sys.stderr),
51 | logging.FileHandler(server_log_file)
52 | ]
53 | )
54 |
55 | # Add a filter to add the component field
56 | class ComponentFilter(logging.Filter):
57 | def __init__(self, component="server"):
58 | super().__init__()
59 | self.component = component
60 |
61 | def filter(self, record):
62 | record.component = self.component
63 | return True
64 |
65 | # Get logger and add the component filter
66 | logger = logging.getLogger()
67 | logger.addFilter(ComponentFilter())
68 |
69 | # Add a debug file handler for detailed debugging
70 | debug_handler = logging.FileHandler(debug_log_file)
71 | debug_handler.setLevel(logging.DEBUG)
72 | debug_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(levelname)s] [%(component)s] %(message)s'))
73 | logger.addHandler(debug_handler)
74 |
75 | # Log basic server startup information
76 | logger.info(f"RhinoMCP server starting in {os.getcwd()}")
77 | logger.info(f"Log files directory: {log_dir}")
78 | logger.info(f"Server log file: {server_log_file}")
79 | logger.info(f"Debug log file: {debug_log_file}")
80 |
81 | # Create a PID file to track this process
82 | pid_file = os.path.join(log_dir, "combined_server.pid")
83 | with open(pid_file, "w") as f:
84 | f.write(str(os.getpid()))
85 | logger.info(f"Server PID: {os.getpid()}")
86 |
87 | # Global Rhino connection
88 | _rhino_connection = None
89 |
90 | class RhinoConnection:
91 | """Class to manage socket connection to Rhino plugin"""
92 |
93 | def __init__(self, host: str = "localhost", port: int = 9876):
94 | self.host = host
95 | self.port = port
96 | self.sock = None
97 | self.request_id = 0
98 |
99 | def connect(self) -> bool:
100 | """Connect to the Rhino plugin socket server"""
101 | if self.sock:
102 | return True
103 |
104 | try:
105 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
106 | self.sock.connect((self.host, self.port))
107 | logger.info(f"Connected to Rhino at {self.host}:{self.port}")
108 | return True
109 | except Exception as e:
110 | logger.error(f"Failed to connect to Rhino: {str(e)}")
111 | logger.debug(f"Connection error details: {traceback.format_exc()}")
112 | self.sock = None
113 | return False
114 |
115 | def disconnect(self):
116 | """Disconnect from the Rhino plugin"""
117 | if self.sock:
118 | try:
119 | self.sock.close()
120 | except Exception as e:
121 | logger.error(f"Error disconnecting from Rhino: {str(e)}")
122 | finally:
123 | self.sock = None
124 |
125 | def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
126 | """Send a command to Rhino and return the response"""
127 | if not self.sock and not self.connect():
128 | raise ConnectionError("Not connected to Rhino")
129 |
130 | # Increment request ID for tracking
131 | self.request_id += 1
132 | current_request_id = self.request_id
133 |
134 | command = {
135 | "id": current_request_id,
136 | "type": command_type,
137 | "params": params or {}
138 | }
139 |
140 | try:
141 | # Log the command being sent
142 | logger.info(f"Request #{current_request_id}: Sending command '{command_type}' to Rhino")
143 | logger.debug(f"Request #{current_request_id} Parameters: {json.dumps(params or {})}")
144 |
145 | # Send the command
146 | command_json = json.dumps(command)
147 | logger.debug(f"Request #{current_request_id} Raw command: {command_json}")
148 | self.sock.sendall(command_json.encode('utf-8'))
149 |
150 | # Set a timeout for receiving
151 | self.sock.settimeout(10.0)
152 |
153 | # Receive the response
154 | buffer_size = 4096
155 | response_data = b""
156 |
157 | while True:
158 | chunk = self.sock.recv(buffer_size)
159 | if not chunk:
160 | break
161 | response_data += chunk
162 |
163 | # Try to parse as JSON to see if we have a complete response
164 | try:
165 | json.loads(response_data.decode('utf-8'))
166 | # If parsing succeeds, we have a complete response
167 | break
168 | except json.JSONDecodeError:
169 | # Not a complete JSON yet, continue receiving
170 | continue
171 |
172 | if not response_data:
173 | logger.error(f"Request #{current_request_id}: No data received from Rhino")
174 | raise ConnectionError(f"Request #{current_request_id}: No data received from Rhino")
175 |
176 | # Log the raw response for debugging
177 | raw_response = response_data.decode('utf-8')
178 | logger.debug(f"Request #{current_request_id} Raw response: {raw_response}")
179 |
180 | response = json.loads(raw_response)
181 |
182 | # Check if the response indicates an error
183 | if "error" in response:
184 | error_msg = response.get("error", "Unknown error from Rhino")
185 | logger.error(f"Request #{current_request_id}: Rhino reported error: {error_msg}")
186 | raise Exception(f"Request #{current_request_id}: Rhino error: {error_msg}")
187 |
188 | if response.get("status") == "error":
189 | error_msg = response.get("message", "Unknown error from Rhino")
190 | logger.error(f"Request #{current_request_id}: Rhino reported error status: {error_msg}")
191 | raise Exception(f"Request #{current_request_id}: Rhino error: {error_msg}")
192 |
193 | # Log success
194 | logger.info(f"Request #{current_request_id}: Command '{command_type}' executed successfully")
195 |
196 | # If we get here, assume success and return the result
197 | if "result" in response:
198 | return response.get("result", {})
199 | else:
200 | # If there's no result field but no error either, return the whole response
201 | return response
202 | except socket.timeout:
203 | logger.error(f"Request #{current_request_id}: Socket timeout while waiting for response from Rhino")
204 | logger.debug(f"Request #{current_request_id}: Timeout after 10 seconds waiting for response to '{command_type}'")
205 | self.sock = None
206 | raise Exception(f"Request #{current_request_id}: Timeout waiting for Rhino response")
207 | except (ConnectionError, BrokenPipeError, ConnectionResetError) as e:
208 | logger.error(f"Request #{current_request_id}: Socket connection error: {str(e)}")
209 | self.sock = None
210 | raise Exception(f"Request #{current_request_id}: Connection to Rhino lost: {str(e)}")
211 | except json.JSONDecodeError as e:
212 | logger.error(f"Request #{current_request_id}: Invalid JSON response: {str(e)}")
213 | if 'response_data' in locals():
214 | logger.error(f"Request #{current_request_id}: Raw response causing JSON error: {response_data[:200]}")
215 | self.sock = None
216 | raise Exception(f"Request #{current_request_id}: Invalid JSON response from Rhino: {str(e)}")
217 | except Exception as e:
218 | logger.error(f"Request #{current_request_id}: Error communicating with Rhino: {str(e)}")
219 | logger.error(f"Request #{current_request_id}: Traceback: {traceback.format_exc()}")
220 | self.sock = None
221 | raise Exception(f"Request #{current_request_id}: Communication error with Rhino: {str(e)}")
222 |
223 | def get_rhino_connection() -> RhinoConnection:
224 | """Get or create a connection to Rhino"""
225 | global _rhino_connection
226 |
227 | # If we have an existing connection, check if it's still valid
228 | if _rhino_connection is not None:
229 | try:
230 | # Don't use ping as it's not implemented in the Rhino plugin
231 | # Instead, try get_scene_info which is more likely to work
232 | _rhino_connection.send_command("get_scene_info", {})
233 | return _rhino_connection
234 | except Exception as e:
235 | # Connection is dead, close it and create a new one
236 | logger.warning(f"Existing connection is no longer valid: {str(e)}")
237 | try:
238 | _rhino_connection.disconnect()
239 | except:
240 | pass
241 | _rhino_connection = None
242 |
243 | # Create a new connection if needed
244 | if _rhino_connection is None:
245 | _rhino_connection = RhinoConnection()
246 | if not _rhino_connection.connect():
247 | logger.error("Failed to connect to Rhino")
248 | _rhino_connection = None
249 | raise Exception("Could not connect to Rhino. Make sure the Rhino plugin is running.")
250 |
251 | # Verify connection with a known working command
252 | try:
253 | # Test the connection with get_scene_info command
254 | result = _rhino_connection.send_command("get_scene_info", {})
255 | logger.info(f"Connection test successful: {result}")
256 | except Exception as e:
257 | logger.error(f"Connection test failed: {str(e)}")
258 | _rhino_connection.disconnect()
259 | _rhino_connection = None
260 | raise Exception(f"Rhino plugin connection test failed: {str(e)}")
261 |
262 | logger.info("Created new connection to Rhino")
263 |
264 | return _rhino_connection
265 |
266 | # Server lifecycle management
267 | @asynccontextmanager
268 | async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
269 | """Manage server startup and shutdown lifecycle"""
270 | try:
271 | logger.info("RhinoMCP server starting up")
272 |
273 | # Try to connect to Rhino on startup to verify it's available
274 | try:
275 | # This will initialize the global connection if needed
276 | rhino = get_rhino_connection()
277 | logger.info("Successfully connected to Rhino on startup")
278 | except Exception as e:
279 | logger.warning(f"Could not connect to Rhino on startup: {str(e)}")
280 | logger.warning("Make sure the Rhino plugin is running before using Rhino resources or tools")
281 |
282 | yield {} # No context resources needed for now
283 | finally:
284 | logger.info("RhinoMCP server shutting down")
285 | # Cleanup code
286 | global _rhino_connection
287 | if _rhino_connection:
288 | logger.info("Disconnecting from Rhino on shutdown")
289 | _rhino_connection.disconnect()
290 | _rhino_connection = None
291 |
292 | if os.path.exists(pid_file):
293 | os.remove(pid_file)
294 | logger.info("Removed PID file")
295 |
296 | # Initialize the FastMCP server
297 | mcp = FastMCP(
298 | "RhinoMcpServer",
299 | description="A Model Context Protocol server for Rhino 3D",
300 | lifespan=server_lifespan
301 | )
302 |
303 | # Tool implementations using FastMCP decorators
304 |
305 | @mcp.tool()
306 | def create_sphere(
307 | ctx: Context,
308 | centerX: float,
309 | centerY: float,
310 | centerZ: float,
311 | radius: float,
312 | color: Optional[str] = None
313 | ) -> str:
314 | """
315 | Creates a sphere with the specified center and radius.
316 |
317 | Parameters:
318 | - centerX: X coordinate of the sphere center
319 | - centerY: Y coordinate of the sphere center
320 | - centerZ: Z coordinate of the sphere center
321 | - radius: Radius of the sphere
322 | - color: Optional color for the sphere (e.g., 'red', 'blue', etc.)
323 |
324 | Returns:
325 | A message indicating the created sphere details
326 | """
327 | tool_id = str(uuid.uuid4())[:8] # Generate a short ID for tracking this tool call
328 | logger.info(f"[{tool_id}] Tool call: create_sphere with center=({centerX},{centerY},{centerZ}), radius={radius}, color={color}")
329 |
330 | try:
331 | # Get the Rhino connection
332 | rhino = get_rhino_connection()
333 |
334 | # Send the command to Rhino
335 | params = {
336 | "centerX": centerX,
337 | "centerY": centerY,
338 | "centerZ": centerZ,
339 | "radius": radius
340 | }
341 |
342 | if color:
343 | params["color"] = color
344 |
345 | result = rhino.send_command("create_sphere", params)
346 |
347 | # Log success
348 | logger.info(f"[{tool_id}] Sphere created successfully")
349 |
350 | # Return the result
351 | return json.dumps(result)
352 | except Exception as e:
353 | logger.error(f"[{tool_id}] Error creating sphere: {str(e)}")
354 | logger.error(f"[{tool_id}] Traceback: {traceback.format_exc()}")
355 | return json.dumps({
356 | "success": False,
357 | "error": f"Error creating sphere: {str(e)}"
358 | })
359 |
360 | @mcp.tool()
361 | def create_box(
362 | ctx: Context,
363 | cornerX: float,
364 | cornerY: float,
365 | cornerZ: float,
366 | width: float,
367 | depth: float,
368 | height: float,
369 | color: Optional[str] = None
370 | ) -> str:
371 | """
372 | Creates a box with the specified dimensions.
373 |
374 | Parameters:
375 | - cornerX: X coordinate of the box corner
376 | - cornerY: Y coordinate of the box corner
377 | - cornerZ: Z coordinate of the box corner
378 | - width: Width of the box (X dimension)
379 | - depth: Depth of the box (Y dimension)
380 | - height: Height of the box (Z dimension)
381 | - color: Optional color for the box (e.g., 'red', 'blue', etc.)
382 |
383 | Returns:
384 | A message indicating the created box details
385 | """
386 | tool_id = str(uuid.uuid4())[:8] # Generate a short ID for tracking this tool call
387 | logger.info(f"[{tool_id}] Tool call: create_box at ({cornerX},{cornerY},{cornerZ}), size={width}x{depth}x{height}, color={color}")
388 |
389 | try:
390 | # Get the Rhino connection
391 | rhino = get_rhino_connection()
392 |
393 | # Send the command to Rhino
394 | params = {
395 | "cornerX": cornerX,
396 | "cornerY": cornerY,
397 | "cornerZ": cornerZ,
398 | "width": width,
399 | "depth": depth,
400 | "height": height
401 | }
402 |
403 | if color:
404 | params["color"] = color
405 |
406 | result = rhino.send_command("create_box", params)
407 |
408 | # Log success
409 | logger.info(f"[{tool_id}] Box created successfully")
410 |
411 | # Return the result
412 | return json.dumps(result)
413 | except Exception as e:
414 | logger.error(f"[{tool_id}] Error creating box: {str(e)}")
415 | logger.error(f"[{tool_id}] Traceback: {traceback.format_exc()}")
416 | return json.dumps({
417 | "success": False,
418 | "error": f"Error creating box: {str(e)}",
419 | "toolId": tool_id # Include the tool ID for error tracking
420 | })
421 |
422 | @mcp.tool()
423 | def create_cylinder(
424 | ctx: Context,
425 | baseX: float,
426 | baseY: float,
427 | baseZ: float,
428 | height: float,
429 | radius: float,
430 | color: Optional[str] = None
431 | ) -> str:
432 | """
433 | Creates a cylinder with the specified base point, height, and radius.
434 |
435 | Parameters:
436 | - baseX: X coordinate of the cylinder base point
437 | - baseY: Y coordinate of the cylinder base point
438 | - baseZ: Z coordinate of the cylinder base point
439 | - height: Height of the cylinder
440 | - radius: Radius of the cylinder
441 | - color: Optional color for the cylinder (e.g., 'red', 'blue', etc.)
442 |
443 | Returns:
444 | A message indicating the created cylinder details
445 | """
446 | tool_id = str(uuid.uuid4())[:8] # Generate a short ID for tracking this tool call
447 | logger.info(f"[{tool_id}] Tool call: create_cylinder at ({baseX},{baseY},{baseZ}), height={height}, radius={radius}, color={color}")
448 |
449 | try:
450 | # Get the Rhino connection
451 | rhino = get_rhino_connection()
452 |
453 | # Send the command to Rhino
454 | params = {
455 | "baseX": baseX,
456 | "baseY": baseY,
457 | "baseZ": baseZ,
458 | "height": height,
459 | "radius": radius
460 | }
461 |
462 | if color:
463 | params["color"] = color
464 |
465 | result = rhino.send_command("create_cylinder", params)
466 |
467 | # Log success
468 | logger.info(f"[{tool_id}] Cylinder created successfully")
469 |
470 | # Return the result
471 | return json.dumps(result)
472 | except Exception as e:
473 | logger.error(f"[{tool_id}] Error creating cylinder: {str(e)}")
474 | logger.error(f"[{tool_id}] Traceback: {traceback.format_exc()}")
475 | return json.dumps({
476 | "success": False,
477 | "error": f"Error creating cylinder: {str(e)}",
478 | "toolId": tool_id # Include the tool ID for error tracking
479 | })
480 |
481 | @mcp.tool()
482 | def get_scene_info(ctx: Context) -> str:
483 | """
484 | Gets information about objects in the current scene.
485 |
486 | Returns:
487 | A JSON string containing scene information
488 | """
489 | tool_id = str(uuid.uuid4())[:8] # Generate a short ID for tracking this tool call
490 | logger.info(f"[{tool_id}] Tool call: get_scene_info")
491 |
492 | try:
493 | # Get the Rhino connection
494 | rhino = get_rhino_connection()
495 |
496 | # Send the command to Rhino
497 | result = rhino.send_command("get_scene_info", {})
498 |
499 | # Log success
500 | logger.info(f"[{tool_id}] Scene info retrieved successfully")
501 |
502 | # Return the result
503 | return json.dumps(result)
504 | except Exception as e:
505 | logger.error(f"[{tool_id}] Error getting scene info: {str(e)}")
506 | logger.error(f"[{tool_id}] Traceback: {traceback.format_exc()}")
507 | return json.dumps({
508 | "success": False,
509 | "error": f"Error getting scene info: {str(e)}",
510 | "toolId": tool_id # Include the tool ID for error tracking
511 | })
512 |
513 | @mcp.tool()
514 | def clear_scene(ctx: Context, currentLayerOnly: bool = False) -> str:
515 | """
516 | Clears all objects from the current scene.
517 |
518 | Parameters:
519 | - currentLayerOnly: If true, only delete objects on the current layer
520 |
521 | Returns:
522 | A message indicating the operation result
523 | """
524 | tool_id = str(uuid.uuid4())[:8] # Generate a short ID for tracking this tool call
525 | layer_info = "current layer only" if currentLayerOnly else "all layers"
526 | logger.info(f"[{tool_id}] Tool call: clear_scene ({layer_info})")
527 |
528 | try:
529 | # Get the Rhino connection
530 | rhino = get_rhino_connection()
531 |
532 | # Send the command to Rhino
533 | params = {
534 | "currentLayerOnly": currentLayerOnly
535 | }
536 |
537 | result = rhino.send_command("clear_scene", params)
538 |
539 | # Log success
540 | logger.info(f"[{tool_id}] Scene cleared successfully ({layer_info})")
541 |
542 | # Return the result
543 | return json.dumps(result)
544 | except Exception as e:
545 | logger.error(f"[{tool_id}] Error clearing scene: {str(e)}")
546 | logger.error(f"[{tool_id}] Traceback: {traceback.format_exc()}")
547 | return json.dumps({
548 | "success": False,
549 | "error": f"Error clearing scene: {str(e)}",
550 | "toolId": tool_id # Include the tool ID for error tracking
551 | })
552 |
553 | @mcp.tool()
554 | def create_layer(ctx: Context, name: str, color: Optional[str] = None) -> str:
555 | """
556 | Creates a new layer in the Rhino document.
557 |
558 | Parameters:
559 | - name: Name of the new layer
560 | - color: Optional color for the layer (e.g., 'red', 'blue', etc.)
561 |
562 | Returns:
563 | A message indicating the operation result
564 | """
565 | tool_id = str(uuid.uuid4())[:8] # Generate a short ID for tracking this tool call
566 | color_info = f" with color {color}" if color else ""
567 | logger.info(f"[{tool_id}] Tool call: create_layer '{name}'{color_info}")
568 |
569 | try:
570 | # Get the Rhino connection
571 | rhino = get_rhino_connection()
572 |
573 | # Send the command to Rhino
574 | params = {
575 | "name": name
576 | }
577 |
578 | if color:
579 | params["color"] = color
580 |
581 | result = rhino.send_command("create_layer", params)
582 |
583 | # Log success
584 | logger.info(f"[{tool_id}] Layer '{name}' created successfully")
585 |
586 | # Return the result
587 | return json.dumps(result)
588 | except Exception as e:
589 | logger.error(f"[{tool_id}] Error creating layer: {str(e)}")
590 | logger.error(f"[{tool_id}] Traceback: {traceback.format_exc()}")
591 | return json.dumps({
592 | "success": False,
593 | "error": f"Error creating layer: {str(e)}",
594 | "toolId": tool_id # Include the tool ID for error tracking
595 | })
596 |
597 | # Record Claude interactions to debug context issues
598 | @mcp.tool()
599 | def log_claude_message(
600 | ctx: Context,
601 | message: str,
602 | type: str = "info"
603 | ) -> str:
604 | """
605 | Log a message from Claude for debugging purposes.
606 |
607 | Parameters:
608 | - message: The message to log
609 | - type: The type of message (info, error, warning, debug)
610 |
611 | Returns:
612 | Success confirmation
613 | """
614 | log_id = str(uuid.uuid4())[:8]
615 |
616 | # Create a timestamp for the filename
617 | timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
618 | claude_log_file = os.path.join(claude_log_dir, f"claude_{timestamp}_{log_id}.log")
619 |
620 | try:
621 | with open(claude_log_file, "w") as f:
622 | f.write(message)
623 |
624 | # Log to server log as well
625 | if type == "error":
626 | logger.error(f"[Claude] [{log_id}] {message[:100]}...")
627 | elif type == "warning":
628 | logger.warning(f"[Claude] [{log_id}] {message[:100]}...")
629 | elif type == "debug":
630 | logger.debug(f"[Claude] [{log_id}] {message[:100]}...")
631 | else:
632 | logger.info(f"[Claude] [{log_id}] {message[:100]}...")
633 |
634 | return json.dumps({
635 | "success": True,
636 | "logId": log_id,
637 | "logFile": claude_log_file
638 | })
639 | except Exception as e:
640 | logger.error(f"Error logging Claude message: {str(e)}")
641 | return json.dumps({
642 | "success": False,
643 | "error": f"Error logging Claude message: {str(e)}"
644 | })
645 |
646 | def main():
647 | """Main function to run the MCP server"""
648 | logger.info("=== RhinoMCP Server Starting ===")
649 | logger.info(f"Process ID: {os.getpid()}")
650 |
651 | try:
652 | # Run the FastMCP server
653 | mcp.run()
654 | except KeyboardInterrupt:
655 | logger.info("Keyboard interrupt received")
656 | except Exception as e:
657 | logger.error(f"Error running server: {str(e)}")
658 | logger.error(f"Traceback: {traceback.format_exc()}")
659 | finally:
660 | logger.info("Server shutting down...")
661 |
662 | # Clean up connection
663 | global _rhino_connection
664 | if _rhino_connection:
665 | _rhino_connection.disconnect()
666 |
667 | if os.path.exists(pid_file):
668 | os.remove(pid_file)
669 | logger.info("Removed PID file")
670 |
671 | return 0
672 |
673 | if __name__ == "__main__":
674 | sys.exit(main())
```
--------------------------------------------------------------------------------
/RhinoPluginFixImplementation.cs:
--------------------------------------------------------------------------------
```csharp
1 | using Rhino;
2 | using Rhino.Commands;
3 | using Rhino.PlugIns;
4 | using System;
5 | using System.Collections.Generic;
6 | using System.Linq;
7 | using System.Net;
8 | using System.Net.Sockets;
9 | using System.Text;
10 | using System.Threading;
11 | using System.Threading.Tasks;
12 | using Newtonsoft.Json;
13 | using Newtonsoft.Json.Linq;
14 | using NLog;
15 |
16 | namespace RhinoMcpPlugin
17 | {
18 | // Main plugin class
19 | public class RhinoMcpPluginCommand : PlugIn
20 | {
21 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
22 | private SocketServer socketServer;
23 | private CommandHandlers commandHandlers;
24 |
25 | public override PlugInLoadTime LoadTime => PlugInLoadTime.AtStartup;
26 |
27 | public RhinoMcpPluginCommand()
28 | {
29 | Instance = this;
30 | }
31 |
32 | public static RhinoMcpPluginCommand Instance { get; private set; }
33 |
34 | protected override LoadReturnCode OnLoad(ref string errorMessage)
35 | {
36 | try
37 | {
38 | Logger.Info("RhinoMcpPlugin loading...");
39 | Logger.Info($"Rhino version: {RhinoApp.Version}");
40 | Logger.Info($"Current directory: {System.IO.Directory.GetCurrentDirectory()}");
41 |
42 | // Check document status
43 | var docCount = RhinoDoc.OpenDocuments().Length;
44 | Logger.Info($"Open document count: {docCount}");
45 | Logger.Info($"Active document: {(RhinoDoc.ActiveDoc != null ? "exists" : "null")}");
46 |
47 | // Create an empty document if none exists
48 | if (RhinoDoc.ActiveDoc == null)
49 | {
50 | Logger.Info("No active document found. Creating a new document...");
51 | RhinoApp.RunScript("_New", false);
52 |
53 | if (RhinoDoc.ActiveDoc == null)
54 | {
55 | Logger.Error("CRITICAL: Failed to create a new document");
56 | errorMessage = "Failed to create a new Rhino document. The plugin requires an active document.";
57 | return LoadReturnCode.FailedToLoad;
58 | }
59 |
60 | Logger.Info($"New document created successfully: {RhinoDoc.ActiveDoc.Name}");
61 | }
62 |
63 | // Initialize command handlers
64 | commandHandlers = new CommandHandlers();
65 |
66 | // Initialize socket server
67 | socketServer = new SocketServer(commandHandlers);
68 | var success = socketServer.Start();
69 | Logger.Info($"Socket server started: {success}");
70 |
71 | // Register document events
72 | RhinoDoc.NewDocument += OnNewDocument;
73 | RhinoDoc.CloseDocument += OnCloseDocument;
74 | RhinoDoc.BeginOpenDocument += OnBeginOpenDocument;
75 | RhinoDoc.EndOpenDocument += OnEndOpenDocument;
76 |
77 | // Check all essential components
78 | var componentStatus = new Dictionary<string, bool> {
79 | { "SocketServer", socketServer != null },
80 | { "CommandHandlers", commandHandlers != null },
81 | { "RhinoDoc", RhinoDoc.ActiveDoc != null }
82 | };
83 |
84 | foreach (var component in componentStatus)
85 | {
86 | Logger.Info($"Component {component.Key}: {(component.Value ? "OK" : "NULL")}");
87 | }
88 |
89 | Logger.Info("RhinoMcpPlugin loaded successfully");
90 | return LoadReturnCode.Success;
91 | }
92 | catch (Exception ex)
93 | {
94 | Logger.Error(ex, $"Error during plugin load: {ex.Message}");
95 | errorMessage = ex.Message;
96 | return LoadReturnCode.FailedToLoad;
97 | }
98 | }
99 |
100 | protected override void OnShutdown()
101 | {
102 | try
103 | {
104 | Logger.Info("RhinoMcpPlugin shutting down...");
105 |
106 | // Unregister document events
107 | RhinoDoc.NewDocument -= OnNewDocument;
108 | RhinoDoc.CloseDocument -= OnCloseDocument;
109 | RhinoDoc.BeginOpenDocument -= OnBeginOpenDocument;
110 | RhinoDoc.EndOpenDocument -= OnEndOpenDocument;
111 |
112 | // Stop socket server
113 | if (socketServer != null)
114 | {
115 | socketServer.Stop();
116 | Logger.Info("Socket server stopped");
117 | }
118 |
119 | Logger.Info("RhinoMcpPlugin shutdown complete");
120 | }
121 | catch (Exception ex)
122 | {
123 | Logger.Error(ex, $"Error during plugin shutdown: {ex.Message}");
124 | }
125 | }
126 |
127 | private void OnNewDocument(object sender, DocumentEventArgs e)
128 | {
129 | Logger.Info($"New document created: {e.Document.Name}");
130 | Logger.Info($"Active document: {(RhinoDoc.ActiveDoc != null ? RhinoDoc.ActiveDoc.Name : "null")}");
131 | }
132 |
133 | private void OnCloseDocument(object sender, DocumentEventArgs e)
134 | {
135 | Logger.Info($"Document closed: {e.Document.Name}");
136 | Logger.Info($"Remaining open documents: {RhinoDoc.OpenDocuments().Length}");
137 |
138 | // If this was the last document, create a new one
139 | if (RhinoDoc.OpenDocuments().Length == 0)
140 | {
141 | Logger.Info("No documents remaining. Creating a new document...");
142 | RhinoApp.RunScript("_New", false);
143 | Logger.Info($"New document created: {(RhinoDoc.ActiveDoc != null ? RhinoDoc.ActiveDoc.Name : "null")}");
144 | }
145 | }
146 |
147 | private void OnBeginOpenDocument(object sender, DocumentOpenEventArgs e)
148 | {
149 | Logger.Info($"Beginning to open document: {e.FileName}");
150 | }
151 |
152 | private void OnEndOpenDocument(object sender, DocumentOpenEventArgs e)
153 | {
154 | Logger.Info($"Finished opening document: {e.Document.Name}");
155 | }
156 | }
157 |
158 | // Socket server class to handle communication
159 | public class SocketServer
160 | {
161 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
162 | private TcpListener server;
163 | private bool isRunning;
164 | private CommandHandlers commandHandlers;
165 | private CancellationTokenSource cancellationTokenSource;
166 |
167 | public bool IsRunning => isRunning;
168 |
169 | public SocketServer(CommandHandlers handlers)
170 | {
171 | commandHandlers = handlers;
172 | cancellationTokenSource = new CancellationTokenSource();
173 | }
174 |
175 | public bool Start(int port = 9876)
176 | {
177 | try
178 | {
179 | Logger.Info($"Socket server initializing on port {port}");
180 | server = new TcpListener(IPAddress.Loopback, port);
181 | server.Start();
182 | isRunning = true;
183 |
184 | // Start accepting clients in a background task
185 | Task.Run(() => AcceptClientsAsync(cancellationTokenSource.Token), cancellationTokenSource.Token);
186 |
187 | Logger.Info($"Socket server started successfully on port {port}");
188 | return true;
189 | }
190 | catch (Exception ex)
191 | {
192 | Logger.Error(ex, $"Failed to start socket server: {ex.Message}");
193 | return false;
194 | }
195 | }
196 |
197 | public void Stop()
198 | {
199 | try
200 | {
201 | Logger.Info("Stopping socket server...");
202 | isRunning = false;
203 | cancellationTokenSource.Cancel();
204 | server?.Stop();
205 | Logger.Info("Socket server stopped");
206 | }
207 | catch (Exception ex)
208 | {
209 | Logger.Error(ex, $"Error stopping socket server: {ex.Message}");
210 | }
211 | }
212 |
213 | private async Task AcceptClientsAsync(CancellationToken cancellationToken)
214 | {
215 | while (isRunning && !cancellationToken.IsCancellationRequested)
216 | {
217 | try
218 | {
219 | var client = await server.AcceptTcpClientAsync();
220 | Logger.Info($"Client connected from {client.Client.RemoteEndPoint}");
221 |
222 | // Handle each client in a separate task
223 | _ = Task.Run(() => HandleClientAsync(client, cancellationToken), cancellationToken);
224 | }
225 | catch (OperationCanceledException)
226 | {
227 | // Normal cancellation, do nothing
228 | Logger.Info("Client acceptance loop cancelled");
229 | break;
230 | }
231 | catch (Exception ex)
232 | {
233 | Logger.Error(ex, $"Error accepting client: {ex.Message}");
234 | // Short delay before trying again
235 | await Task.Delay(1000, cancellationToken);
236 | }
237 | }
238 | }
239 |
240 | private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken)
241 | {
242 | using (client)
243 | {
244 | try
245 | {
246 | using (var stream = client.GetStream())
247 | {
248 | var buffer = new byte[4096];
249 | while (isRunning && !cancellationToken.IsCancellationRequested)
250 | {
251 | // Read command from client
252 | var data = new List<byte>();
253 | int bytesRead;
254 |
255 | do
256 | {
257 | bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken);
258 | if (bytesRead > 0)
259 | {
260 | data.AddRange(buffer.Take(bytesRead));
261 | }
262 | } while (stream.DataAvailable);
263 |
264 | if (bytesRead == 0)
265 | {
266 | // Client disconnected
267 | Logger.Info("Client disconnected");
268 | break;
269 | }
270 |
271 | // Process command and send response
272 | var commandJson = Encoding.UTF8.GetString(data.ToArray());
273 | Logger.Info($"Received command: {commandJson}");
274 |
275 | string responseJson = ProcessCommand(commandJson);
276 | Logger.Debug($"Sending response: {responseJson}");
277 |
278 | var responseBytes = Encoding.UTF8.GetBytes(responseJson);
279 | await stream.WriteAsync(responseBytes, 0, responseBytes.Length, cancellationToken);
280 | }
281 | }
282 | }
283 | catch (OperationCanceledException)
284 | {
285 | // Normal cancellation
286 | Logger.Info("Client handler cancelled");
287 | }
288 | catch (Exception ex)
289 | {
290 | Logger.Error(ex, $"Error handling client: {ex.Message}");
291 | }
292 | finally
293 | {
294 | try
295 | {
296 | client.Close();
297 | }
298 | catch { /* ignore */ }
299 | }
300 | }
301 | }
302 |
303 | private string ProcessCommand(string commandJson)
304 | {
305 | try
306 | {
307 | // Parse command JSON
308 | JObject command = JObject.Parse(commandJson);
309 | string commandType = command["type"]?.ToString().ToLowerInvariant();
310 | JObject parameters = command["params"] as JObject ?? new JObject();
311 |
312 | // Special case for health check
313 | if (commandType == "health_check")
314 | {
315 | return commandHandlers.HandleHealthCheck(parameters);
316 | }
317 |
318 | // Ensure we have an active document before processing commands
319 | if (RhinoDoc.ActiveDoc == null)
320 | {
321 | Logger.Error("CRITICAL: No active document available for command processing");
322 | return JsonConvert.SerializeObject(new
323 | {
324 | error = "No active Rhino document. Please open a document before executing commands."
325 | });
326 | }
327 |
328 | // Process regular commands
329 | switch (commandType)
330 | {
331 | case "ping":
332 | return JsonConvert.SerializeObject(new { result = "pong" });
333 |
334 | case "get_scene_info":
335 | return commandHandlers.HandleGetSceneInfo(parameters);
336 |
337 | case "create_box":
338 | return commandHandlers.HandleCreateBox(parameters);
339 |
340 | case "create_sphere":
341 | return commandHandlers.HandleCreateSphere(parameters);
342 |
343 | case "create_cylinder":
344 | return commandHandlers.HandleCreateCylinder(parameters);
345 |
346 | case "clear_scene":
347 | return commandHandlers.HandleClearScene(parameters);
348 |
349 | case "create_layer":
350 | return commandHandlers.HandleCreateLayer(parameters);
351 |
352 | default:
353 | Logger.Warn($"Unknown command type: {commandType}");
354 | return JsonConvert.SerializeObject(new { error = $"Unknown command type: {commandType}" });
355 | }
356 | }
357 | catch (NullReferenceException ex)
358 | {
359 | Logger.Error(ex, $"NULL REFERENCE processing command: {commandJson}");
360 | Logger.Error($"Stack trace: {ex.StackTrace}");
361 | Logger.Error($"Context: ActiveDoc={RhinoDoc.ActiveDoc != null}");
362 | return JsonConvert.SerializeObject(new { error = $"Error processing command: {ex.Message}" });
363 | }
364 | catch (Exception ex)
365 | {
366 | Logger.Error(ex, $"Error processing command: {ex.Message}");
367 | return JsonConvert.SerializeObject(new { error = $"Error processing command: {ex.Message}" });
368 | }
369 | }
370 | }
371 |
372 | // Class to handle specific commands
373 | public class CommandHandlers
374 | {
375 | private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
376 |
377 | public string HandleHealthCheck(JObject parameters)
378 | {
379 | try
380 | {
381 | Logger.Info("Performing health check...");
382 |
383 | var healthStatus = new Dictionary<string, object> {
384 | { "PluginLoaded", true },
385 | { "RhinoVersion", RhinoApp.Version.ToString() },
386 | { "ActiveDocument", RhinoDoc.ActiveDoc != null },
387 | { "OpenDocumentCount", RhinoDoc.OpenDocuments().Length },
388 | { "SocketServerRunning", true },
389 | { "MemoryUsage", System.GC.GetTotalMemory(false) / 1024 / 1024 + " MB" },
390 | { "SdkVersion", typeof(RhinoApp).Assembly.GetName().Version.ToString() }
391 | };
392 |
393 | Logger.Info($"Health check results: {JsonConvert.SerializeObject(healthStatus)}");
394 | return JsonConvert.SerializeObject(new {
395 | success = true,
396 | result = healthStatus
397 | });
398 | }
399 | catch (Exception ex)
400 | {
401 | Logger.Error(ex, $"Exception during health check: {ex.Message}");
402 | return JsonConvert.SerializeObject(new {
403 | error = $"Health check failed: {ex.Message}"
404 | });
405 | }
406 | }
407 |
408 | public string HandleGetSceneInfo(JObject parameters)
409 | {
410 | Logger.Debug("Processing get_scene_info request");
411 | try
412 | {
413 | var doc = RhinoDoc.ActiveDoc;
414 | if (doc == null)
415 | {
416 | Logger.Error("CRITICAL: RhinoDoc.ActiveDoc is NULL");
417 | return JsonConvert.SerializeObject(new {
418 | error = "No active Rhino document. Please open a document first."
419 | });
420 | }
421 |
422 | Logger.Debug("Accessing document objects...");
423 | var objectCount = doc.Objects.Count;
424 | var layerCount = doc.Layers.Count;
425 |
426 | Logger.Info($"Retrieved scene info: {objectCount} objects, {layerCount} layers");
427 | return JsonConvert.SerializeObject(new {
428 | success = true,
429 | result = new {
430 | objectCount = objectCount,
431 | layerCount = layerCount,
432 | documentName = doc.Name,
433 | activeLayer = doc.Layers.CurrentLayer.Name
434 | }
435 | });
436 | }
437 | catch (NullReferenceException ex)
438 | {
439 | Logger.Error(ex, $"NULL REFERENCE in get_scene_info: {ex.Message}");
440 | Logger.Error($"Stack trace: {ex.StackTrace}");
441 | return JsonConvert.SerializeObject(new {
442 | error = $"Error processing command: {ex.Message}"
443 | });
444 | }
445 | catch (Exception ex)
446 | {
447 | Logger.Error(ex, $"Exception in get_scene_info: {ex.Message}");
448 | return JsonConvert.SerializeObject(new {
449 | error = $"Error getting scene info: {ex.Message}"
450 | });
451 | }
452 | }
453 |
454 | public string HandleCreateBox(JObject parameters)
455 | {
456 | Logger.Debug($"Processing create_box with parameters: {parameters}");
457 |
458 | try
459 | {
460 | // Log parameter extraction
461 | Logger.Debug("Extracting parameters...");
462 | double cornerX = parameters.Value<double>("cornerX");
463 | double cornerY = parameters.Value<double>("cornerY");
464 | double cornerZ = parameters.Value<double>("cornerZ");
465 | double width = parameters.Value<double>("width");
466 | double depth = parameters.Value<double>("depth");
467 | double height = parameters.Value<double>("height");
468 | string color = parameters.Value<string>("color");
469 |
470 | // Log document access
471 | Logger.Debug("Accessing Rhino document...");
472 | var doc = RhinoDoc.ActiveDoc;
473 | if (doc == null)
474 | {
475 | Logger.Error("CRITICAL: RhinoDoc.ActiveDoc is NULL");
476 | return JsonConvert.SerializeObject(new {
477 | error = "No active Rhino document. Please open a document first."
478 | });
479 | }
480 |
481 | // Run on main UI thread
482 | bool success = false;
483 | object result = null;
484 |
485 | // Execute on the main Rhino UI thread
486 | RhinoApp.InvokeOnUiThread(new Action(() =>
487 | {
488 | try
489 | {
490 | // Log geometric operations with safeguards
491 | Logger.Debug("Creating geometry...");
492 | var corner = new Rhino.Geometry.Point3d(cornerX, cornerY, cornerZ);
493 | var box = new Rhino.Geometry.Box(
494 | new Rhino.Geometry.Plane(corner, Rhino.Geometry.Vector3d.ZAxis),
495 | new Rhino.Geometry.Interval(0, width),
496 | new Rhino.Geometry.Interval(0, depth),
497 | new Rhino.Geometry.Interval(0, height)
498 | );
499 |
500 | // Verify box was created
501 | if (box == null || !box.IsValid)
502 | {
503 | Logger.Error($"Box creation failed: {(box == null ? "null box" : "invalid box")}");
504 | result = new { error = "Failed to create valid box geometry" };
505 | return;
506 | }
507 |
508 | // Log document modification
509 | Logger.Debug("Adding to document...");
510 | var id = doc.Objects.AddBox(box);
511 | if (id == Guid.Empty)
512 | {
513 | Logger.Error("Failed to add box to document");
514 | result = new { error = "Failed to add box to document" };
515 | return;
516 | }
517 |
518 | // Apply color if specified
519 | if (!string.IsNullOrEmpty(color))
520 | {
521 | System.Drawing.Color objColor = System.Drawing.Color.FromName(color);
522 | if (objColor.A > 0)
523 | {
524 | var objAttributes = new Rhino.DocObjects.ObjectAttributes();
525 | objAttributes.ColorSource = Rhino.DocObjects.ObjectColorSource.ColorFromObject;
526 | objAttributes.ObjectColor = objColor;
527 | doc.Objects.ModifyAttributes(id, objAttributes, true);
528 | }
529 | }
530 |
531 | // Update views
532 | doc.Views.Redraw();
533 |
534 | // Log successful operation
535 | Logger.Info($"Successfully created box with ID {id}");
536 | result = new { success = true, objectId = id.ToString() };
537 | success = true;
538 | }
539 | catch (Exception ex)
540 | {
541 | Logger.Error(ex, $"Error in UI thread: {ex.Message}");
542 | result = new { error = $"Error in UI thread: {ex.Message}" };
543 | }
544 | }));
545 |
546 | if (success)
547 | {
548 | return JsonConvert.SerializeObject(result);
549 | }
550 | else
551 | {
552 | return JsonConvert.SerializeObject(result ?? new { error = "Unknown error creating box" });
553 | }
554 | }
555 | catch (NullReferenceException ex)
556 | {
557 | Logger.Error(ex, $"NULL REFERENCE in create_box: {ex.Message}");
558 | Logger.Error($"Stack trace: {ex.StackTrace}");
559 | return JsonConvert.SerializeObject(new {
560 | error = $"Error processing command: {ex.Message}"
561 | });
562 | }
563 | catch (Exception ex)
564 | {
565 | Logger.Error(ex, $"Exception in create_box: {ex.Message}");
566 | return JsonConvert.SerializeObject(new {
567 | error = $"Error processing command: {ex.Message}"
568 | });
569 | }
570 | }
571 |
572 | public string HandleCreateSphere(JObject parameters)
573 | {
574 | Logger.Debug($"Processing create_sphere with parameters: {parameters}");
575 |
576 | try
577 | {
578 | // Extract parameters
579 | Logger.Debug("Extracting parameters...");
580 | double centerX = parameters.Value<double>("centerX");
581 | double centerY = parameters.Value<double>("centerY");
582 | double centerZ = parameters.Value<double>("centerZ");
583 | double radius = parameters.Value<double>("radius");
584 | string color = parameters.Value<string>("color");
585 |
586 | // Access document
587 | Logger.Debug("Accessing Rhino document...");
588 | var doc = RhinoDoc.ActiveDoc;
589 | if (doc == null)
590 | {
591 | Logger.Error("CRITICAL: RhinoDoc.ActiveDoc is NULL");
592 | return JsonConvert.SerializeObject(new {
593 | error = "No active Rhino document. Please open a document first."
594 | });
595 | }
596 |
597 | // Execute on UI thread
598 | bool success = false;
599 | object result = null;
600 |
601 | RhinoApp.InvokeOnUiThread(new Action(() =>
602 | {
603 | try
604 | {
605 | // Create geometry
606 | Logger.Debug("Creating sphere geometry...");
607 | var center = new Rhino.Geometry.Point3d(centerX, centerY, centerZ);
608 | var sphere = new Rhino.Geometry.Sphere(center, radius);
609 |
610 | if (!sphere.IsValid)
611 | {
612 | Logger.Error("Invalid sphere geometry");
613 | result = new { error = "Failed to create valid sphere geometry" };
614 | return;
615 | }
616 |
617 | // Add to document
618 | Logger.Debug("Adding sphere to document...");
619 | var id = doc.Objects.AddSphere(sphere);
620 | if (id == Guid.Empty)
621 | {
622 | Logger.Error("Failed to add sphere to document");
623 | result = new { error = "Failed to add sphere to document" };
624 | return;
625 | }
626 |
627 | // Apply color if specified
628 | if (!string.IsNullOrEmpty(color))
629 | {
630 | System.Drawing.Color objColor = System.Drawing.Color.FromName(color);
631 | if (objColor.A > 0)
632 | {
633 | var objAttributes = new Rhino.DocObjects.ObjectAttributes();
634 | objAttributes.ColorSource = Rhino.DocObjects.ObjectColorSource.ColorFromObject;
635 | objAttributes.ObjectColor = objColor;
636 | doc.Objects.ModifyAttributes(id, objAttributes, true);
637 | }
638 | }
639 |
640 | // Update views
641 | doc.Views.Redraw();
642 |
643 | Logger.Info($"Successfully created sphere with ID {id}");
644 | result = new { success = true, objectId = id.ToString() };
645 | success = true;
646 | }
647 | catch (Exception ex)
648 | {
649 | Logger.Error(ex, $"Error in UI thread: {ex.Message}");
650 | result = new { error = $"Error in UI thread: {ex.Message}" };
651 | }
652 | }));
653 |
654 | if (success)
655 | {
656 | return JsonConvert.SerializeObject(result);
657 | }
658 | else
659 | {
660 | return JsonConvert.SerializeObject(result ?? new { error = "Unknown error creating sphere" });
661 | }
662 | }
663 | catch (Exception ex)
664 | {
665 | Logger.Error(ex, $"Exception in create_sphere: {ex.Message}");
666 | return JsonConvert.SerializeObject(new {
667 | error = $"Error creating sphere: {ex.Message}"
668 | });
669 | }
670 | }
671 |
672 | public string HandleCreateCylinder(JObject parameters)
673 | {
674 | Logger.Debug($"Processing create_cylinder with parameters: {parameters}");
675 |
676 | try
677 | {
678 | // Extract parameters
679 | double baseX = parameters.Value<double>("baseX");
680 | double baseY = parameters.Value<double>("baseY");
681 | double baseZ = parameters.Value<double>("baseZ");
682 | double height = parameters.Value<double>("height");
683 | double radius = parameters.Value<double>("radius");
684 | string color = parameters.Value<string>("color");
685 |
686 | // Access document
687 | var doc = RhinoDoc.ActiveDoc;
688 | if (doc == null)
689 | {
690 | Logger.Error("CRITICAL: RhinoDoc.ActiveDoc is NULL");
691 | return JsonConvert.SerializeObject(new {
692 | error = "No active Rhino document. Please open a document first."
693 | });
694 | }
695 |
696 | // Execute on UI thread
697 | bool success = false;
698 | object result = null;
699 |
700 | RhinoApp.InvokeOnUiThread(new Action(() =>
701 | {
702 | try
703 | {
704 | // Create geometry
705 | var basePt = new Rhino.Geometry.Point3d(baseX, baseY, baseZ);
706 | var topPt = new Rhino.Geometry.Point3d(baseX, baseY, baseZ + height);
707 | var cylinder = new Rhino.Geometry.Cylinder(
708 | new Rhino.Geometry.Circle(basePt, radius),
709 | height
710 | );
711 |
712 | if (!cylinder.IsValid)
713 | {
714 | Logger.Error("Invalid cylinder geometry");
715 | result = new { error = "Failed to create valid cylinder geometry" };
716 | return;
717 | }
718 |
719 | // Add to document
720 | var id = doc.Objects.AddCylinder(cylinder);
721 | if (id == Guid.Empty)
722 | {
723 | Logger.Error("Failed to add cylinder to document");
724 | result = new { error = "Failed to add cylinder to document" };
725 | return;
726 | }
727 |
728 | // Apply color if specified
729 | if (!string.IsNullOrEmpty(color))
730 | {
731 | System.Drawing.Color objColor = System.Drawing.Color.FromName(color);
732 | if (objColor.A > 0)
733 | {
734 | var objAttributes = new Rhino.DocObjects.ObjectAttributes();
735 | objAttributes.ColorSource = Rhino.DocObjects.ObjectColorSource.ColorFromObject;
736 | objAttributes.ObjectColor = objColor;
737 | doc.Objects.ModifyAttributes(id, objAttributes, true);
738 | }
739 | }
740 |
741 | // Update views
742 | doc.Views.Redraw();
743 |
744 | Logger.Info($"Successfully created cylinder with ID {id}");
745 | result = new { success = true, objectId = id.ToString() };
746 | success = true;
747 | }
748 | catch (Exception ex)
749 | {
750 | Logger.Error(ex, $"Error in UI thread: {ex.Message}");
751 | result = new { error = $"Error in UI thread: {ex.Message}" };
752 | }
753 | }));
754 |
755 | if (success)
756 | {
757 | return JsonConvert.SerializeObject(result);
758 | }
759 | else
760 | {
761 | return JsonConvert.SerializeObject(result ?? new { error = "Unknown error creating cylinder" });
762 | }
763 | }
764 | catch (Exception ex)
765 | {
766 | Logger.Error(ex, $"Exception in create_cylinder: {ex.Message}");
767 | return JsonConvert.SerializeObject(new {
768 | error = $"Error creating cylinder: {ex.Message}"
769 | });
770 | }
771 | }
772 |
773 | public string HandleClearScene(JObject parameters)
774 | {
775 | Logger.Debug($"Processing clear_scene with parameters: {parameters}");
776 |
777 | try
778 | {
779 | bool currentLayerOnly = parameters.Value<bool>("currentLayerOnly");
780 |
781 | // Access document
782 | var doc = RhinoDoc.ActiveDoc;
783 | if (doc == null)
784 | {
785 | Logger.Error("CRITICAL: RhinoDoc.ActiveDoc is NULL");
786 | return JsonConvert.SerializeObject(new {
787 | error = "No active Rhino document. Please open a document first."
788 | });
789 | }
790 |
791 | // Execute on UI thread
792 | bool success = false;
793 | object result = null;
794 |
795 | RhinoApp.InvokeOnUiThread(new Action(() =>
796 | {
797 | try
798 | {
799 | int deletedCount = 0;
800 |
801 | if (currentLayerOnly)
802 | {
803 | // Get current layer index
804 | int currentLayerIndex = doc.Layers.CurrentLayerIndex;
805 |
806 | // Delete objects on current layer
807 | var objectsToDelete = new List<Guid>();
808 | foreach (var rhObj in doc.Objects)
809 | {
810 | if (rhObj.Attributes.LayerIndex == currentLayerIndex)
811 | {
812 | objectsToDelete.Add(rhObj.Id);
813 | deletedCount++;
814 | }
815 | }
816 |
817 | foreach (var id in objectsToDelete)
818 | {
819 | doc.Objects.Delete(id, true);
820 | }
821 |
822 | Logger.Info($"Deleted {deletedCount} objects from current layer");
823 | }
824 | else
825 | {
826 | // Delete all objects
827 | deletedCount = doc.Objects.Count;
828 | doc.Objects.Clear();
829 | Logger.Info($"Cleared all {deletedCount} objects from document");
830 | }
831 |
832 | // Update views
833 | doc.Views.Redraw();
834 |
835 | result = new {
836 | success = true,
837 | deletedCount = deletedCount,
838 | currentLayerOnly = currentLayerOnly
839 | };
840 | success = true;
841 | }
842 | catch (Exception ex)
843 | {
844 | Logger.Error(ex, $"Error in UI thread: {ex.Message}");
845 | result = new { error = $"Error in UI thread: {ex.Message}" };
846 | }
847 | }));
848 |
849 | if (success)
850 | {
851 | return JsonConvert.SerializeObject(result);
852 | }
853 | else
854 | {
855 | return JsonConvert.SerializeObject(result ?? new { error = "Unknown error clearing scene" });
856 | }
857 | }
858 | catch (Exception ex)
859 | {
860 | Logger.Error(ex, $"Exception in clear_scene: {ex.Message}");
861 | return JsonConvert.SerializeObject(new {
862 | error = $"Error clearing scene: {ex.Message}"
863 | });
864 | }
865 | }
866 |
867 | public string HandleCreateLayer(JObject parameters)
868 | {
869 | Logger.Debug($"Processing create_layer with parameters: {parameters}");
870 |
871 | try
872 | {
873 | string name = parameters.Value<string>("name");
874 | string color = parameters.Value<string>("color");
875 |
876 | if (string.IsNullOrEmpty(name))
877 | {
878 | Logger.Error("Layer name is required");
879 | return JsonConvert.SerializeObject(new {
880 | error = "Layer name is required"
881 | });
882 | }
883 |
884 | // Access document
885 | var doc = RhinoDoc.ActiveDoc;
886 | if (doc == null)
887 | {
888 | Logger.Error("CRITICAL: RhinoDoc.ActiveDoc is NULL");
889 | return JsonConvert.SerializeObject(new {
890 | error = "No active Rhino document. Please open a document first."
891 | });
892 | }
893 |
894 | // Execute on UI thread
895 | bool success = false;
896 | object result = null;
897 |
898 | RhinoApp.InvokeOnUiThread(new Action(() =>
899 | {
900 | try
901 | {
902 | // Check if layer already exists
903 | int existingIndex = doc.Layers.Find(name, true);
904 | if (existingIndex >= 0)
905 | {
906 | Logger.Warning($"Layer '{name}' already exists");
907 | var layer = doc.Layers[existingIndex];
908 | result = new {
909 | success = true,
910 | layerId = layer.Id.ToString(),
911 | layerName = layer.Name,
912 | alreadyExisted = true
913 | };
914 | doc.Layers.SetCurrentLayerIndex(existingIndex, true);
915 | success = true;
916 | return;
917 | }
918 |
919 | // Create new layer
920 | var newLayer = new Rhino.DocObjects.Layer();
921 | newLayer.Name = name;
922 |
923 | // Set color if specified
924 | if (!string.IsNullOrEmpty(color))
925 | {
926 | System.Drawing.Color layerColor = System.Drawing.Color.FromName(color);
927 | if (layerColor.A > 0)
928 | {
929 | newLayer.Color = layerColor;
930 | }
931 | }
932 |
933 | // Add layer to document
934 | int index = doc.Layers.Add(newLayer);
935 | if (index < 0)
936 | {
937 | Logger.Error($"Failed to add layer '{name}' to document");
938 | result = new { error = $"Failed to add layer '{name}' to document" };
939 | return;
940 | }
941 |
942 | // Set as current layer
943 | doc.Layers.SetCurrentLayerIndex(index, true);
944 |
945 | Logger.Info($"Successfully created layer '{name}' with index {index}");
946 | result = new {
947 | success = true,
948 | layerId = newLayer.Id.ToString(),
949 | layerName = newLayer.Name,
950 | layerIndex = index
951 | };
952 | success = true;
953 | }
954 | catch (Exception ex)
955 | {
956 | Logger.Error(ex, $"Error in UI thread: {ex.Message}");
957 | result = new { error = $"Error in UI thread: {ex.Message}" };
958 | }
959 | }));
960 |
961 | if (success)
962 | {
963 | return JsonConvert.SerializeObject(result);
964 | }
965 | else
966 | {
967 | return JsonConvert.SerializeObject(result ?? new { error = $"Unknown error creating layer '{name}'" });
968 | }
969 | }
970 | catch (Exception ex)
971 | {
972 | Logger.Error(ex, $"Exception in create_layer: {ex.Message}");
973 | return JsonConvert.SerializeObject(new {
974 | error = $"Error creating layer: {ex.Message}"
975 | });
976 | }
977 | }
978 | }
979 | }
```