neuralcad / core /validator.py
CallMeDaniel's picture
refactor: eliminate all Serializer classes, use Pydantic model_dump() directly
2330e12
"""
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