from collections import defaultdict
from dataclasses import dataclass
from typing import List, Tuple, Dict
import numpy as np
from OCP.BRepFeat import BRepFeat
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from cadquery import cq
from cq_cam.commands.base_command import Unit
from cq_cam.commands.command import Rapid, Cut, Plunge
from cq_cam.job import Job
from cq_cam.operations.base_operation import FaceBaseOperation, OperationError
from cq_cam.operations.mixin_operation import PlaneValidationMixin, ObjectsValidationMixin
from cq_cam.operations.strategy import ZigZagStrategy, Strategy
from cq_cam.utils.utils import WireClipper, flatten_list, flatten_wire_to_closed_2d
from cq_cam.visualize import visualize_task
[docs]@dataclass(kw_only=True)
class Pocket(PlaneValidationMixin, ObjectsValidationMixin, FaceBaseOperation):
""" 2.5D Pocket operation
All faces involved must be planes and parallel.
"""
tool_diameter: float = 3.175
strategy: Strategy = ZigZagStrategy
""" Diameter of the tool that will be used to perform the operation.
"""
# TODO rotation angle for zigzag
@property
def _tool_diameter(self) -> float:
return self.tool_diameter
def __post_init__(self):
# Give each face an ID
super().__post_init__()
faces = [(i, face.copy()) for i, face in enumerate(self._faces)]
features, coplanar_faces, depth_info = self._discover_pocket_features(faces)
groups = self._group_faces_by_features(features, coplanar_faces)
for group_faces in groups.values():
boundaries = self._boundaries_by_group(group_faces, coplanar_faces, depth_info)
for boundary, start_depth, end_depth in boundaries:
self.process_boundary(boundary, start_depth, end_depth)
@staticmethod
def _group_faces_by_features(features: List[cq.Face], faces: List[Tuple[int, cq.Face]]):
feat = BRepFeat()
remaining = faces[:]
groups = defaultdict(lambda: [])
for feature in features:
for i, face in remaining[:]:
# IsInside_s works regardless the faces are co-planar
if feat.IsInside_s(face.wrapped, feature.wrapped):
groups[feature].append((i, face))
remaining.remove((i, face))
assert not remaining
return groups
def _discover_pocket_features(self, faces: List[Tuple[int, cq.Face]]):
""" Given a list of faces, creates fused feature faces and returns depth information """
# Move everything on the same plane
coplanar_faces = []
job_zdir = self.job.workplane.plane.zDir
job_height = job_zdir.dot(self.job.workplane.plane.origin)
depth_info = {}
for i, face in faces:
face_height = job_zdir.dot(face.Center())
face_depth = face_height - job_height
depth_info[i] = face_depth
# Move face to same level with job plane
translated_face = face.translate(job_zdir.multiply(-face_depth))
coplanar_faces.append((i, translated_face))
features = self._combine_coplanar_faces([f[1] for f in coplanar_faces])
return features, coplanar_faces, depth_info
@staticmethod
def _combine_coplanar_faces(faces: List[cq.Face]) -> List[cq.Face]:
""" Given a list of (coplanar) faces, fuse them together to form
bigger faces """
# This works also as a sanity check as it will
# raise if the faces are not coplanar
wp = cq.Workplane().add(faces).combine()
features = []
explorer = TopExp_Explorer(wp.objects[0].wrapped, TopAbs_FACE)
while explorer.More():
face = explorer.Current()
features.append(cq.Face(face))
explorer.Next()
return features
def _boundaries_by_group(self,
group_faces: List[Tuple[int, cq.Face]],
coplanar_faces: List[Tuple[int, cq.Face]],
depth_info: Dict[int, float]):
face_depths = list(set(depth_info.values()))
face_depths.sort()
face_depths.reverse()
current_depth = 0
boundaries = []
group_i = [i for i, _ in group_faces]
for face_depth in face_depths:
depth_i = [i for i, depth in depth_info.items() if depth <= face_depth and i in group_i]
features = self._combine_coplanar_faces([f for i, f in coplanar_faces if i in depth_i and i in group_i])
assert len(features) == 1
boundary = features[0]
boundaries.append((boundary, current_depth, face_depth))
current_depth = face_depth
return boundaries
def _generate_depths(self, start_depth: float, end_depth: float):
if self.stepdown:
depths = list(np.arange(start_depth - self.stepdown, end_depth, -self.stepdown))
if depths[-1] != end_depth:
depths.append(end_depth)
return depths
else:
return [end_depth]
def _apply_avoid(self, outer_subject_wires, inner_subject_wires, avoid_objs, outer_offset, inner_offset):
avoid_clip = WireClipper()
for o in avoid_objs:
if isinstance(o, cq.Face):
# Use reverse offsets because avoid is like an anti-pocket
outer_avoid_wires = o.outerWire().offset2D(inner_offset)
inner_avoid_wires = flatten_list([wire.offset2D(outer_offset) for wire in o.innerWires()])
elif isinstance(o, cq.Wire):
# TODO check this
outer_avoid_wires = o.offset2D(inner_offset)
inner_avoid_wires = []
else:
raise OperationError('Avoid can only be a wire or a face')
for wire in outer_avoid_wires + inner_avoid_wires:
avoid_clip.add_clip_wire(wire)
for outer_subject in outer_subject_wires:
avoid_clip.add_subject_wire(outer_subject)
outer_boundaries = [list(boundary) for boundary in avoid_clip.execute_difference()]
avoid_clip.reset()
for inner_subject in inner_subject_wires:
avoid_clip.add_subject_wire(inner_subject)
inner_boundaries = [list(boundary) for boundary in avoid_clip.execute_difference()]
return outer_boundaries, inner_boundaries
def process_boundary(self, face: cq.Face, start_depth: float, end_depth: float):
# TODO break this function down into easily testable sections
# Perform validations
self.validate_face_plane(face)
face_workplane = cq.Workplane(obj=face)
self.validate_plane(self.job, face_workplane)
# Prepare profile paths
job_plane = self.job.workplane.plane
tool_radius = self._tool_diameter / 2
outer_wire_offset = tool_radius * self.outer_boundary_offset[0] + self.outer_boundary_offset[1]
inner_wire_offset = tool_radius * self.inner_boundary_offset[0] + self.inner_boundary_offset[1]
# These are the profile paths. They are done very last as a finishing pass
outer_profiles = face.outerWire().offset2D(outer_wire_offset)
inner_profiles = flatten_list([wire.offset2D(inner_wire_offset) for wire in face.innerWires()])
# Prepare primary clearing regions
if self.boundary_final_pass_stepover is None:
self.boundary_final_pass_stepover = self.stepover
final_pass_offset = tool_radius * self.boundary_final_pass_stepover
# Generate the primary clearing regions with stepover from the above profiles
outer_boundaries = flatten_list([wire.offset2D(-final_pass_offset) for wire in outer_profiles])
inner_boundaries = flatten_list([wire.offset2D(final_pass_offset) for wire in inner_profiles])
# TODO apply "avoid" here using wire clipper? or in the strategy?
# Note: also apply avoid to the actual profiles
if self.avoid:
objs = self._o_objects(self.avoid)
outer_profiles, inner_profiles = self._apply_avoid(
outer_profiles,
inner_profiles,
objs,
outer_wire_offset,
inner_wire_offset
)
outer_boundaries, inner_boundaries = self._apply_avoid(
outer_boundaries,
inner_boundaries,
objs,
outer_wire_offset - final_pass_offset,
inner_wire_offset + final_pass_offset
)
cut_sequences = self.strategy.process(self, outer_boundaries, inner_boundaries)
if self.avoid:
cut_sequences += outer_profiles
cut_sequences += inner_profiles
else:
outer_polygons = tuple(flatten_wire_to_closed_2d(outer_profile) for outer_profile in outer_profiles)
inner_polygons = tuple(flatten_wire_to_closed_2d(inner_profile) for inner_profile in inner_profiles)
cut_sequences += outer_polygons
cut_sequences += inner_polygons
for i, depth in enumerate(self._generate_depths(start_depth, end_depth)):
for cut_sequence in cut_sequences:
cut_start = cut_sequence[0]
self.commands.append(Rapid(x=None, y=None, z=self.clearance_height))
self.commands.append(Rapid(x=cut_start[0], y=cut_start[1], z=None))
self.commands.append(Rapid(x=None, y=None, z=self.top_height)) # TODO plunge or rapid?
self.commands.append(Plunge(z=depth))
for cut in cut_sequence[1:]:
self.commands.append(Cut(x=cut[0], y=cut[1], z=None))
def pick_other_scanline_end(scanline, scanpoint):
if scanline[0] == scanpoint:
return scanline[1]
return scanline[0]
def demo():
job_plane = cq.Workplane().box(15, 15, 10).faces('>Z').workplane()
obj = (
job_plane
.rect(7.5, 7.5)
.cutBlind(-4)
.faces('>Z[1]')
.rect(2, 2)
.extrude(2)
.faces('>Z').workplane()
.moveTo(-5.75, 0)
.rect(4, 2)
.cutBlind(-6)
)
op_plane = obj.faces('>Z[1] or >Z[2]')
# test = obj.faces('>Z[-3] or >Z[-2] or >Z[-4]')
# obj = op_plane.workplane().rect(2, 2).extrude(4)
job = Job(job_plane, 300, 100, Unit.METRIC, 5)
op = Pocket(job=job, wp=op_plane, clearance_height=2, stepdown=None, tool_diameter=0.5)
toolpath = visualize_task(job, op)
print(op.to_gcode())
show_object(obj)
# show_object(cq.Workplane().box(15, 15, 10).faces('>Z'), 'job')
# show_object(op_plane, 'op')
# show_object(op_plane)
show_object(toolpath, 'g')
# for w in op._wires:
# show_object(w)
# show_object(test, 'test')
def demo2():
from cq_cam.job import Job
from cq_cam.operations.pocket import Pocket
from cq_cam.commands.base_command import Unit
from cq_cam.visualize import visualize_task
result = cq.Workplane("front").box(20.0, 20.0, 2).faces('>Z').workplane().rect(15, 15).cutBlind(-1)
result = result.moveTo(0, -10).rect(5, 5).cutBlind(-1)
# show_object(result.faces('<Z[1]'))
job_plane = result.faces('>Z').workplane()
job = Job(job_plane, 300, 100, Unit.METRIC, 5)
op = Pocket(job=job, tool_diameter=1, clearance_height=5, top_height=0, o=result.faces('<Z[1]'),
outer_boundary_offset=1, avoid=result.faces('>Z'))
toolpath = visualize_task(job, op, as_edges=False)
# result.objects += toolpath
show_object(result)
show_object(toolpath)
show_object(result.faces('>Z'), 'avoid', {'color': 'red'})
if 'show_object' in locals() or __name__ == '__main__':
demo2()