# -*- coding: utf-8 -*- """ $Id$ Copyright 2010 Lars Kruse This file is part of PyCAM. PyCAM is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. PyCAM is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with PyCAM. If not, see . """ from pycam.Geometry.Point import Point, Vector from pycam.Geometry.Line import Line from pycam.Geometry.utils import epsilon from pycam.Geometry.Polygon import PolygonSorter import pycam.Utils.log import pycam.Geometry import math _log = pycam.Utils.log.get_logger() GRID_DIRECTION_X = 0 GRID_DIRECTION_Y = 1 GRID_DIRECTION_XY = 2 MILLING_STYLE_IGNORE = 0 MILLING_STYLE_CONVENTIONAL = 1 MILLING_STYLE_CLIMB = 2 START_X = 0x1 START_Y = 0x2 START_Z = 0x4 SPIRAL_DIRECTION_IN = 0 SPIRAL_DIRECTION_OUT = 1 POCKETING_TYPE_NONE = 0 POCKETING_TYPE_HOLES = 1 POCKETING_TYPE_MATERIAL = 2 def isiterable(obj): try: iter(obj) return True except TypeError: return False def floatrange(start, end, inc=None, steps=None, reverse=False): if reverse: start, end = end, start # 'inc' will be adjusted below anyway if abs(start - end) < epsilon: yield start elif inc is None and steps is None: raise ValueError("floatrange: either 'inc' or 'steps' must be provided") elif (not steps is None) and (steps < 2): raise ValueError("floatrange: 'steps' must be greater than 1") else: # the input is fine # reverse increment, if it does not suit start/end if steps is None: if ((end - start) > 0) != (inc > 0): inc = -inc steps = int(math.ceil(float(end - start) / inc) + 1) inc = float(end - start) / (steps - 1) for index in range(steps): yield start + inc * index def get_fixed_grid_line(start, end, line_pos, z, step_width=None, grid_direction=GRID_DIRECTION_X): if step_width is None: # useful for PushCutter operations steps = (start, end) elif isiterable(step_width): steps = step_width else: steps = floatrange(start, end, inc=step_width) if grid_direction == GRID_DIRECTION_X: get_point = lambda pos: Point(pos, line_pos, z) else: get_point = lambda pos: Point(line_pos, pos, z) for pos in steps: yield get_point(pos) def get_fixed_grid_layer(minx, maxx, miny, maxy, z, line_distance, step_width=None, grid_direction=GRID_DIRECTION_X, milling_style=MILLING_STYLE_IGNORE, start_position=0): if grid_direction == GRID_DIRECTION_XY: raise ValueError("'get_one_layer_fixed_grid' does not accept XY " \ + "direction") # zigzag is only available if the milling zigzag = (milling_style == MILLING_STYLE_IGNORE) # If we happen to start at a position that collides with the milling style, # then we need to move to the closest other corner. Here we decide, which # would be the best alternative. def get_alternative_start_position(start): if (maxx - minx) <= (maxy - miny): # toggle the X position bit return start ^ START_X else: # toggle the Y position bit return start ^ START_Y if grid_direction == GRID_DIRECTION_X: primary_dir = START_X secondary_dir = START_Y else: primary_dir = START_Y secondary_dir = START_X # Determine the starting direction (assuming we begin at the lower x/y # coordinates. if milling_style == MILLING_STYLE_IGNORE: # just move forward - milling style is not important pass elif (milling_style == MILLING_STYLE_CLIMB) == \ (grid_direction == GRID_DIRECTION_X): if bool(start_position & START_X) == bool(start_position & START_Y): # we can't start from here - choose an alternative start_position = get_alternative_start_position(start_position) elif (milling_style == MILLING_STYLE_CONVENTIONAL) == \ (grid_direction == GRID_DIRECTION_X): if bool(start_position & START_X) != bool(start_position & START_Y): # we can't start from here - choose an alternative start_position = get_alternative_start_position(start_position) else: raise ValueError("Invalid milling style given: %s" % str(milling_style)) # sort out the coordinates (primary/secondary) if grid_direction == GRID_DIRECTION_X: start, end = minx, maxx line_start, line_end = miny, maxy else: start, end = miny, maxy line_start, line_end = minx, maxx # switch start/end if we move from high to low if start_position & primary_dir: start, end = end, start if start_position & secondary_dir: line_start, line_end = line_end, line_start # calculate the line positions if isiterable(line_distance): lines = line_distance else: lines = floatrange(line_start, line_end, inc=line_distance) # at the end of the layer we will be on the other side of the 2nd direction end_position = start_position ^ secondary_dir # the final position will probably be on the other side (primary) if not zigzag: end_position ^= primary_dir # calculate each line def get_lines(start, end, end_position): result = [] for line_pos in lines: result.append(get_fixed_grid_line(start, end, line_pos, z, step_width=step_width, grid_direction=grid_direction)) if zigzag: start, end = end, start end_position ^= primary_dir return result, end_position return get_lines(start, end, end_position) def get_fixed_grid((low, high), layer_distance, line_distance=None, step_width=None, grid_direction=GRID_DIRECTION_X, milling_style=MILLING_STYLE_IGNORE, start_position=START_Z): """ Calculate the grid positions for toolpath moves """ if isiterable(layer_distance): layers = layer_distance elif layer_distance is None: # useful for DropCutter layers = [low[2]] else: layers = floatrange(low[2], high[2], inc=layer_distance, reverse=bool(start_position & START_Z)) def get_layers_with_direction(layers): for layer in layers: # this will produce a nice xy-grid, as well as simple x and y grids if grid_direction != GRID_DIRECTION_Y: yield (layer, GRID_DIRECTION_X) if grid_direction != GRID_DIRECTION_X: yield (layer, GRID_DIRECTION_Y) for z, direction in get_layers_with_direction(layers): result, start_position = get_fixed_grid_layer(low[0], high[0], low[1], high[1], z, line_distance, step_width=step_width, grid_direction=direction, milling_style=milling_style, start_position=start_position) yield result def _get_position(minx, maxx, miny, maxy, z, position): if position & START_X > 0: x = minx else: x = maxx if position & START_Y > 0: y = miny else: y = maxy return Point(x, y, z) def get_spiral_layer_lines(minx, maxx, miny, maxy, z, line_distance_x, line_distance_y, grid_direction, start_position, current_location): xor_map = {GRID_DIRECTION_X: START_X, GRID_DIRECTION_Y: START_Y} end_position = start_position ^ xor_map[grid_direction] end_location = _get_position(minx, maxx, miny, maxy, z, end_position) lines = [(current_location, end_location)] if grid_direction == GRID_DIRECTION_X: next_grid_direction = GRID_DIRECTION_Y if start_position & START_Y > 0: miny += line_distance_y else: maxy -= line_distance_y else: next_grid_direction = GRID_DIRECTION_X if start_position & START_X > 0: minx += line_distance_x else: maxx -= line_distance_x if (minx - epsilon <= maxx ) and (miny - epsilon <= maxy): # recursively compute the next lines lines.extend(get_spiral_layer_lines(minx, maxx, miny, maxy, z, line_distance_x, line_distance_y, next_grid_direction, end_position, end_location)) return lines def get_spiral_layer(minx, maxx, miny, maxy, z, line_distance, step_width, grid_direction, start_position, rounded_corners, reverse): current_location = _get_position(minx, maxx, miny, maxy, z, start_position) if line_distance > 0: line_steps_x = math.ceil((float(maxx - minx) / line_distance)) line_steps_y = math.ceil((float(maxy - miny) / line_distance)) line_distance_x = (maxx - minx) / line_steps_x line_distance_y = (maxy - miny) / line_steps_y lines = get_spiral_layer_lines(minx, maxx, miny, maxy, z, line_distance_x, line_distance_y, grid_direction, start_position, current_location) if reverse: lines.reverse() # turn the lines into steps if rounded_corners: rounded_lines = [] previous = None for index, (start, end) in enumerate(lines): radius = 0.5 * min(line_distance_x, line_distance_y) edge_vector = end.sub(start) # TODO: ellipse would be better than arc offset = edge_vector.normalized().mul(radius) if previous: start = start.add(offset) center = previous.add(offset) up_vector = previous.sub(center).cross(start.sub(center)).normalized() north = center.add(Vector(1.0, 0.0, 0.0)) angle_start = pycam.Geometry.get_angle_pi(north, center, previous, up_vector, pi_factor=True) * 180.0 angle_end = pycam.Geometry.get_angle_pi(north, center, start, up_vector, pi_factor=True) * 180.0 # TODO: remove these exceptions based on up_vector.z (get_points_of_arc does not respect the plane, yet) if up_vector.z < 0: angle_start, angle_end = -angle_end, -angle_start arc_points = pycam.Geometry.get_points_of_arc(center, radius, angle_start, angle_end) if up_vector.z < 0: arc_points.reverse() for arc_index in range(len(arc_points) - 1): p1_coord = arc_points[arc_index] p2_coord = arc_points[arc_index + 1] p1 = Point(p1_coord[0], p1_coord[1], z) p2 = Point(p2_coord[0], p2_coord[1], z) rounded_lines.append((p1, p2)) if index != len(lines) - 1: end = end.sub(offset) previous = end rounded_lines.append((start, end)) lines = rounded_lines for start, end in lines: points = [] if step_width is None: points.append(start) points.append(end) else: line = Line(start, end) if isiterable(step_width): steps = step_width else: steps = floatrange(0.0, line.len, inc=step_width) for step in steps: next_point = line.p1.add(line.dir.mul(step)) points.append(next_point) if reverse: points.reverse() yield points def get_spiral((low, high), layer_distance, line_distance=None, step_width=None, milling_style=MILLING_STYLE_IGNORE, spiral_direction=SPIRAL_DIRECTION_IN, rounded_corners=False, start_position=(START_X | START_Y | START_Z)): """ Calculate the grid positions for toolpath moves """ if isiterable(layer_distance): layers = layer_distance elif layer_distance is None: # useful for DropCutter layers = [low[2]] else: layers = floatrange(low[2], high[2], inc=layer_distance, reverse=bool(start_position & START_Z)) if (milling_style == MILLING_STYLE_CLIMB) == \ (start_position & START_X > 0): start_direction = GRID_DIRECTION_X else: start_direction = GRID_DIRECTION_Y reverse = (spiral_direction == SPIRAL_DIRECTION_OUT) for z in layers: yield get_spiral_layer(low[0], high[0], low[1], high[1], z, line_distance, step_width=step_width, grid_direction=start_direction, start_position=start_position, rounded_corners=rounded_corners, reverse=reverse) def get_lines_layer(lines, z, last_z=None, step_width=None, milling_style=MILLING_STYLE_CONVENTIONAL): get_proj_point = lambda proj_point: Point(proj_point.x, proj_point.y, z) projected_lines = [] for line in lines: if (not last_z is None) and (last_z < line.minz): # the line was processed before continue elif line.minz < z < line.maxz: # Split the line at the point at z level and do the calculation # for both point pairs. factor = (z - line.p1.z) / (line.p2.z - line.p1.z) plane_point = line.p1.add(line.vector.mul(factor)) if line.p1.z < z: p1 = get_proj_point(line.p1) p2 = line.p2 else: p1 = line.p1 p2 = get_proj_point(line.p2) projected_lines.append(Line(p1, plane_point)) yield Line(plane_point, p2) elif line.minz < last_z < line.maxz: plane = Plane(Point(0, 0, last_z), Vector(0, 0, 1)) cp = plane.intersect_point(line.dir, line.p1)[0] # we can be sure that there is an intersection if line.p1.z > last_z: p1, p2 = cp, line.p2 else: p1, p2 = line.p1, cp projected_lines.append(Line(p1, p2)) else: if line.maxz <= z: # the line is completely below z projected_lines.append(Line(get_proj_point(line.p1), get_proj_point(line.p2))) elif line.minz >= z: projected_lines.append(line) else: log.warn("Unexpected condition 'get_lines_layer': " + \ "%s / %s / %s / %s" % (line.p1, line.p2, z, last_z)) # process all projected lines for line in projected_lines: points = [] if step_width is None: points.append(line.p1) points.append(line.p2) else: if isiterable(step_width): steps = step_width else: steps = floatrange(0.0, line.len, inc=step_width) for step in steps: next_point = line.p1.add(line.dir.mul(step)) points.append(next_point) yield points def _get_sorted_polygons(models, callback=None): # Sort the polygons according to their directions (first inside, then # outside. This reduces the problem of break-away pieces. inner_polys = [] outer_polys = [] for model in models: for poly in model.get_polygons(): if poly.get_area() <= 0: inner_polys.append(poly) else: outer_polys.append(poly) inner_sorter = PolygonSorter(inner_polys, callback=callback) outer_sorter = PolygonSorter(outer_polys, callback=callback) return inner_sorter.get_polygons() + outer_sorter.get_polygons() def get_lines_grid(models, (low, high), layer_distance, line_distance=None, step_width=None, milling_style=MILLING_STYLE_CONVENTIONAL, start_position=START_Z, pocketing_type=POCKETING_TYPE_NONE, callback=None): # the lower limit is never below the model polygons = _get_sorted_polygons(models, callback=callback) if polygons: low_limit_lines = min([polygon.minz for polygon in polygons]) low[2] = max(low[2], low_limit_lines) # calculate pockets if pocketing_type != POCKETING_TYPE_NONE: if not callback is None: callback(text="Generating pocketing polygons ...") polygons = get_pocketing_polygons(polygons, line_distance, pocketing_type, callback=callback) # extract lines in correct order from all polygons lines = [] for polygon in polygons: if callback: callback() if polygon.is_closed and \ (milling_style == MILLING_STYLE_CONVENTIONAL): polygon = polygon.copy() polygon.reverse() for line in polygon.get_lines(): lines.append(line) if isiterable(layer_distance): layers = layer_distance elif layer_distance is None: # only one layer layers = [low[2]] else: layers = floatrange(low[2], high[2], inc=layer_distance, reverse=bool(start_position & START_Z)) # turn the generator into a list - otherwise the slicing fails layers = list(layers) last_z = None if layers: # the upper layers are used for PushCutter operations for z in layers[:-1]: if callback: callback() yield get_lines_layer(lines, z, last_z=last_z, step_width=None, milling_style=milling_style) last_z = z # the last layer is used for a DropCutter operation if callback: callback() yield get_lines_layer(lines, layers[-1], last_z=last_z, step_width=step_width, milling_style=milling_style) def get_pocketing_polygons(polygons, offset, pocketing_type, callback=None): pocketing_limit = 1000 base_polygons = [] other_polygons = [] if pocketing_type == POCKETING_TYPE_HOLES: # go inwards offset *= -1 for poly in polygons: if poly.is_closed and poly.is_outer(): base_polygons.append(poly) else: other_polygons.append(poly) elif pocketing_type == POCKETING_TYPE_MATERIAL: for poly in polygons: if poly.is_closed and not poly.is_outer(): base_polygons.append(poly) else: other_polygons.append(poly) else: _log.warning("Invalid pocketing type given: %d" % str(pocketing_type)) return polygons # For now we use only the polygons that do not surround any other # polygons. Sorry - the pocketing is currently very simple ... base_filtered_polygons = [] for candidate in base_polygons: if callback and callback(): # we were interrupted return polygons for other in other_polygons: if candidate.is_polygon_inside(other): break else: base_filtered_polygons.append(candidate) # start the pocketing for all remaining polygons pocket_polygons = [] for base_polygon in base_filtered_polygons: pocket_polygons.append(base_polygon) current_queue = [base_polygon] next_queue = [] pocket_depth = 0 while current_queue and (pocket_depth < pocketing_limit): if callback and callback(): return polygons for poly in current_queue: result = poly.get_offset_polygons(offset) pocket_polygons.extend(result) next_queue.extend(result) pocket_depth += 1 current_queue = next_queue next_queue = [] return pocket_polygons