Spaces:
Sleeping
Sleeping
| """ | |
| 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" | |
| def error_count(self) -> int: | |
| return sum(1 for i in self.issues if i.severity == "error") | |
| 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 | |