""" CNC Manufacturability Validator. Checks a CadQuery solid for common CNC machining issues: - Thin walls - Sharp internal corners (no fillet / too small for tool) - Deep narrow pockets (aspect ratio) - Overall size feasibility - Undercut detection (basic heuristic) """ from typing import Optional import cadquery as cq from pydantic import BaseModel, Field, computed_field class CNCIssue(BaseModel): severity: str # "error", "warning", "info" category: str message: str def __init__(self, severity: str = "", category: str = "", message: str = "", **data): super().__init__(severity=severity, category=category, message=message, **data) class CNCValidationResult(BaseModel): part_name: str = Field(exclude=True) issues: list[CNCIssue] = Field(default_factory=list) machinable: bool = True axis_recommendation: str = "3-axis" @computed_field @property def error_count(self) -> int: return sum(1 for i in self.issues if i.severity == "error") @computed_field @property def warning_count(self) -> int: return sum(1 for i in self.issues if i.severity == "warning") def summary(self) -> str: status = "PASS" if self.machinable else "FAIL" lines = [ f"CNC Validation [{status}] — {self.part_name}", f" Recommended: {self.axis_recommendation} milling", f" Errors: {self.error_count} | Warnings: {self.warning_count}", ] for issue in self.issues: icon = {"error": "✗", "warning": "⚠", "info": "ℹ"}[issue.severity] lines.append(f" {icon} [{issue.category}] {issue.message}") return "\n".join(lines) # --- Configurable thresholds --- DEFAULT_CONFIG = { "min_wall_thickness_mm": 1.5, "min_fillet_radius_mm": 1.0, # Typical smallest endmill radius "max_pocket_depth_ratio": 4.0, # depth / width ratio "max_part_size_mm": 500.0, # Typical CNC work envelope "min_part_size_mm": 1.0, "min_hole_diameter_mm": 1.0, } def _get_validation_config(overrides: dict | None = None) -> "ValidationConfig": """Get validation config, optionally with overrides.""" from config.settings import settings, ValidationConfig if overrides: data = settings.validation.model_dump() data.update(overrides) return ValidationConfig(**data) return settings.validation def validate_for_cnc( workplane: cq.Workplane, part_name: str = "Part", config: Optional[dict] = None, ) -> CNCValidationResult: """ Run manufacturability checks on a CadQuery solid. Returns a CNCValidationResult with issues found. """ cfg = _get_validation_config(config) result = CNCValidationResult(part_name=part_name) shape = workplane.val() bb = shape.BoundingBox() # --- 1. Bounding box / size checks --- dims = sorted([bb.xlen, bb.ylen, bb.zlen]) max_dim = dims[-1] min_dim = dims[0] if max_dim > cfg.max_part_size_mm: result.issues.append( CNCIssue( "error", "Size", f"Part too large: {max_dim:.1f}mm exceeds {cfg.max_part_size_mm}mm work envelope", ) ) result.machinable = False if min_dim < cfg.min_part_size_mm: result.issues.append( CNCIssue( "warning", "Size", f"Very small dimension: {min_dim:.2f}mm — may be difficult to fixture", ) ) # --- 2. Volume sanity check --- volume = shape.Volume() bb_volume = bb.xlen * bb.ylen * bb.zlen if bb_volume > 0: fill_ratio = volume / bb_volume if fill_ratio < 0.05: result.issues.append( CNCIssue( "warning", "Geometry", f"Very low fill ratio ({fill_ratio:.1%}) — complex geometry, high machining time", ) ) result.issues.append( CNCIssue( "info", "Geometry", f"Fill ratio: {fill_ratio:.1%} (volume/bounding box)", ) ) # --- 3. Face and edge complexity --- faces = workplane.faces().vals() edges = workplane.edges().vals() n_faces = len(faces) n_edges = len(edges) from config.settings import settings thresholds = settings.validation.complexity_thresholds five_axis_faces = thresholds.five_axis_faces three_plus_two_faces = thresholds.three_plus_two_faces if n_faces > five_axis_faces: result.issues.append( CNCIssue( "warning", "Complexity", f"{n_faces} faces detected — may require multi-setup or 5-axis", ) ) result.axis_recommendation = "5-axis" elif n_faces > three_plus_two_faces: result.issues.append( CNCIssue( "info", "Complexity", f"{n_faces} faces — consider 4-axis or indexed 5-axis", ) ) result.axis_recommendation = "3+2 axis" # --- 4. Edge length analysis (thin feature proxy) --- edge_lengths = [] for edge in edges: try: edge_lengths.append(edge.Length()) except Exception: pass if edge_lengths: min_edge = min(edge_lengths) if min_edge < cfg.min_wall_thickness_mm: result.issues.append( CNCIssue( "warning", "Thin Feature", f"Shortest edge: {min_edge:.2f}mm — below min wall thickness " f"({cfg.min_wall_thickness_mm}mm)", ) ) # --- 5. Aspect ratio check (deep pocket heuristic) --- # Only flag if the narrowest dimension is small enough to be a pocket/slot if dims[0] > 0 and dims[0] < 20: aspect = dims[2] / dims[0] # tallest / narrowest if aspect > cfg.max_pocket_depth_ratio: result.issues.append( CNCIssue( "warning", "Deep Feature", f"Aspect ratio {aspect:.1f}:1 — may require long-reach tooling or " f"special fixturing", ) ) # --- 6. Surface type analysis --- has_freeform = False planar_count = 0 cylindrical_count = 0 for face in faces: try: geom_type = face.geomType() if geom_type == "PLANE": planar_count += 1 elif geom_type == "CYLINDER": cylindrical_count += 1 elif geom_type in ("BSPLINE", "BEZIER", "OTHER"): has_freeform = True except Exception: pass if has_freeform: result.issues.append( CNCIssue( "warning", "Surface", "Freeform/spline surfaces detected — requires 3D contouring toolpaths", ) ) if result.axis_recommendation == "3-axis": result.axis_recommendation = "3-axis (with 3D finishing)" result.issues.append( CNCIssue( "info", "Surface", f"Faces: {planar_count} planar, {cylindrical_count} cylindrical, " f"{n_faces - planar_count - cylindrical_count} other", ) ) # --- 7. Set final machinable flag --- if result.error_count > 0: result.machinable = False return result