Split Cadrille and CAD-Recode nodes from the CADabra monorepo. Multi-modal CAD reconstruction from point clouds, images, or text. Original source: https://github.com/PozzettiAndrea/ComfyUI-CADabra
228 lines
6.4 KiB
Python
228 lines
6.4 KiB
Python
"""
|
|
Safe CadQuery Code Execution
|
|
Runs generated CadQuery code in an isolated subprocess with timeout.
|
|
|
|
This isolation is important because:
|
|
1. Generated code may be invalid and crash
|
|
2. CadQuery can have memory leaks: https://github.com/CadQuery/cadquery/issues/1665
|
|
3. Complex models can take excessive time
|
|
"""
|
|
|
|
import subprocess
|
|
import tempfile
|
|
import sys
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Tuple, Optional, Dict, Any
|
|
|
|
|
|
def execute_cadquery_code(
|
|
code: str,
|
|
timeout: float = 5.0,
|
|
output_dir: Optional[str] = None,
|
|
) -> Tuple[bool, str, Optional[str]]:
|
|
"""
|
|
Safely execute CadQuery code in a subprocess with timeout.
|
|
|
|
The generated code is expected to assign the final result to a variable 'r'.
|
|
For example: r = w0.workplane().box(10, 10, 10)
|
|
|
|
Args:
|
|
code: CadQuery Python code string
|
|
timeout: Maximum execution time in seconds
|
|
output_dir: Directory to save output files (temp dir if None)
|
|
|
|
Returns:
|
|
Tuple of:
|
|
- success: bool, whether execution succeeded
|
|
- result: str, path to STEP file on success, error message on failure
|
|
- stl_path: Optional[str], path to STL file if generated
|
|
"""
|
|
# Use temp dir if no output specified
|
|
use_temp = output_dir is None
|
|
if use_temp:
|
|
temp_dir = tempfile.mkdtemp(prefix='cadrecode_')
|
|
output_dir = temp_dir
|
|
else:
|
|
temp_dir = None
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
step_path = os.path.join(output_dir, 'output.step')
|
|
stl_path = os.path.join(output_dir, 'output.stl')
|
|
script_path = os.path.join(output_dir, 'script.py')
|
|
|
|
# Build the execution script
|
|
# The generated code should define 'r' as the result
|
|
script = f'''
|
|
import sys
|
|
import cadquery as cq
|
|
|
|
try:
|
|
# Execute the generated code
|
|
{_indent_code(code, 4)}
|
|
|
|
# Get the result - expected to be stored in 'r'
|
|
if 'r' not in dir():
|
|
print("Error: Generated code did not define 'r' variable", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
result = r
|
|
if hasattr(result, 'val'):
|
|
compound = result.val()
|
|
else:
|
|
compound = result
|
|
|
|
# Export to STEP
|
|
cq.exporters.export(compound, '{step_path}')
|
|
|
|
# Also export to STL for preview
|
|
try:
|
|
vertices, faces = compound.tessellate(0.001, 0.1)
|
|
import trimesh
|
|
mesh = trimesh.Trimesh([(v.x, v.y, v.z) for v in vertices], faces)
|
|
mesh.export('{stl_path}')
|
|
except Exception as e:
|
|
print(f"STL export failed (non-fatal): {{e}}", file=sys.stderr)
|
|
|
|
print("SUCCESS")
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
print(f"Execution error: {{e}}", file=sys.stderr)
|
|
traceback.print_exc(file=sys.stderr)
|
|
sys.exit(1)
|
|
'''
|
|
|
|
try:
|
|
# Write script
|
|
with open(script_path, 'w') as f:
|
|
f.write(script)
|
|
|
|
# Execute in subprocess
|
|
result = subprocess.run(
|
|
[sys.executable, script_path],
|
|
timeout=timeout,
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=output_dir,
|
|
)
|
|
|
|
if result.returncode == 0 and os.path.exists(step_path):
|
|
stl_result = stl_path if os.path.exists(stl_path) else None
|
|
return True, step_path, stl_result
|
|
else:
|
|
error_msg = result.stderr.strip() if result.stderr else "Unknown error"
|
|
return False, error_msg, None
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return False, f"Execution timed out after {timeout}s", None
|
|
|
|
except Exception as e:
|
|
return False, f"Subprocess error: {str(e)}", None
|
|
|
|
finally:
|
|
# Clean up temp dir if we created one and execution failed
|
|
if use_temp and temp_dir and not os.path.exists(step_path):
|
|
try:
|
|
shutil.rmtree(temp_dir)
|
|
except:
|
|
pass
|
|
|
|
|
|
def execute_cadquery_code_persistent(
|
|
code: str,
|
|
output_path: str,
|
|
timeout: float = 5.0,
|
|
) -> Tuple[bool, str]:
|
|
"""
|
|
Execute CadQuery code and save to a specific output path.
|
|
|
|
Unlike execute_cadquery_code, this keeps the output files permanently.
|
|
|
|
Args:
|
|
code: CadQuery Python code string
|
|
output_path: Path for the output STEP file
|
|
timeout: Maximum execution time in seconds
|
|
|
|
Returns:
|
|
Tuple of (success, error_message or output_path)
|
|
"""
|
|
output_dir = os.path.dirname(output_path)
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
success, result, _ = execute_cadquery_code(code, timeout, output_dir)
|
|
|
|
if success:
|
|
# Move to requested path if different
|
|
temp_step = result
|
|
if temp_step != output_path:
|
|
shutil.move(temp_step, output_path)
|
|
return True, output_path
|
|
else:
|
|
return False, result
|
|
|
|
|
|
def validate_cadquery_code(code: str) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Basic validation of generated CadQuery code.
|
|
|
|
Checks for common issues without executing the code.
|
|
|
|
Args:
|
|
code: CadQuery Python code string
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message if invalid)
|
|
"""
|
|
if not code or not code.strip():
|
|
return False, "Empty code"
|
|
|
|
# Check for required import
|
|
if 'cadquery' not in code and 'cq' not in code:
|
|
return False, "Code does not appear to use CadQuery"
|
|
|
|
# Check for result variable
|
|
if '=' not in code:
|
|
return False, "Code does not contain any assignments"
|
|
|
|
# Try to compile (syntax check only)
|
|
try:
|
|
compile(code, '<string>', 'exec')
|
|
except SyntaxError as e:
|
|
return False, f"Syntax error: {e}"
|
|
|
|
return True, None
|
|
|
|
|
|
def _indent_code(code: str, spaces: int) -> str:
|
|
"""Indent all lines of code by the specified number of spaces."""
|
|
indent = ' ' * spaces
|
|
lines = code.split('\n')
|
|
return '\n'.join(indent + line for line in lines)
|
|
|
|
|
|
def cleanup_temp_files(max_age_hours: int = 24):
|
|
"""
|
|
Clean up old temporary CAD-Recode files.
|
|
|
|
Args:
|
|
max_age_hours: Delete files older than this many hours
|
|
"""
|
|
import time
|
|
|
|
temp_base = tempfile.gettempdir()
|
|
now = time.time()
|
|
max_age_seconds = max_age_hours * 3600
|
|
|
|
for item in os.listdir(temp_base):
|
|
if item.startswith('cadrecode_'):
|
|
path = os.path.join(temp_base, item)
|
|
try:
|
|
if os.path.isdir(path):
|
|
mtime = os.path.getmtime(path)
|
|
if now - mtime > max_age_seconds:
|
|
shutil.rmtree(path)
|
|
except:
|
|
pass
|