| | |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | """PathUtils -common functions used in PathScripts for filtering, sorting, and generating gcode toolpath data""" |
| |
|
| | import FreeCAD |
| | from FreeCAD import Vector |
| | from PySide import QtCore |
| | import Path |
| | import Path.Main.Job as PathJob |
| | import math |
| | from numpy import linspace |
| | import tsp_solver |
| |
|
| | |
| | from lazy_loader.lazy_loader import LazyLoader |
| |
|
| | DraftGeomUtils = LazyLoader("DraftGeomUtils", globals(), "DraftGeomUtils") |
| | Part = LazyLoader("Part", globals(), "Part") |
| | TechDraw = LazyLoader("TechDraw", globals(), "TechDraw") |
| |
|
| | translate = FreeCAD.Qt.translate |
| |
|
| |
|
| | if False: |
| | Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule()) |
| | Path.Log.trackModule(Path.Log.thisModule()) |
| | else: |
| | Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule()) |
| |
|
| |
|
| | UserInput = None |
| |
|
| |
|
| | class PathNoTCExistsException(Exception): |
| | """PathNoECExistsException is raised when no TC exists at all, or when all |
| | existing TCs are rejected by a given op. |
| | This is typically an error because avery op requires a TC.""" |
| |
|
| | def __init__(self): |
| | super().__init__("No Tool Controllers exist") |
| |
|
| |
|
| | def waiting_effects(function): |
| | def new_function(*args, **kwargs): |
| | if not FreeCAD.GuiUp: |
| | return function(*args, **kwargs) |
| | from PySide import QtGui |
| |
|
| | QtGui.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) |
| | res = None |
| | try: |
| | res = function(*args, **kwargs) |
| | |
| | |
| | |
| | |
| | finally: |
| | QtGui.QApplication.restoreOverrideCursor() |
| | return res |
| |
|
| | return new_function |
| |
|
| |
|
| | |
| | def fmt(val): |
| | return format(val, ".4f") |
| |
|
| |
|
| | def segments(poly): |
| | """A sequence of (x,y) numeric coordinates pairs""" |
| | return zip(poly, poly[1:] + [poly[0]]) |
| |
|
| |
|
| | def loopdetect(obj, edge1, edge2): |
| | """Returns a loop of edges that includes the two edges. |
| | Useful for detecting boundaries of negative space features ie 'holes' |
| | If a unique loop is not found, returns None |
| | """ |
| |
|
| | Path.Log.track() |
| | candidates = [] |
| | for wire in obj.Shape.Wires: |
| | for e in wire.Edges: |
| | if e.hashCode() == edge1.hashCode(): |
| | candidates.append((wire.hashCode(), wire)) |
| | if e.hashCode() == edge2.hashCode(): |
| | candidates.append((wire.hashCode(), wire)) |
| | loop = set([x for x in candidates if candidates.count(x) > 1]) |
| | if len(loop) != 1: |
| | return None |
| | loopwire = next(x for x in loop)[1] |
| | return loopwire.Edges |
| |
|
| |
|
| | def wiredetect(obj, edgeName): |
| | """Returns all edges from wire which includes the edge.""" |
| | edge = obj.Shape.getElement(edgeName) |
| | for wire in obj.Shape.Wires: |
| | for e in wire.Edges: |
| | if e.hashCode() == edge.hashCode(): |
| | return wire.Edges |
| |
|
| | return None |
| |
|
| |
|
| | def horizontalEdgeLoop(obj, edge, verbose=False): |
| | """Returns a loop of edges in the horizontal plane that includes one edge""" |
| |
|
| | isHorizontal = Path.Geom.isHorizontal |
| | isRoughly = Path.Geom.isRoughly |
| |
|
| | if not isHorizontal(edge) and verbose: |
| | |
| | return None |
| |
|
| | |
| | ehash = edge.hashCode() |
| | wires = [w for w in obj.Shape.Wires if any(e.hashCode() == ehash for e in w.Edges)] |
| | loops = [ |
| | w for w in wires if all(isHorizontal(e) for e in w.Edges) and isHorizontal(Part.Face(w)) |
| | ] |
| | if len(loops) > 0: |
| | return loops[0].Edges |
| |
|
| | |
| |
|
| | |
| | candidates = [ |
| | e |
| | for e in obj.Shape.Edges |
| | if isHorizontal(e) and isRoughly(e.BoundBox.ZMin, edge.BoundBox.ZMin) |
| | ] |
| |
|
| | |
| | |
| | for cluster in Part.getSortedClusters(candidates): |
| | wire = Part.Wire(cluster) |
| | if wire.isClosed() and any(e.hashCode() == ehash for e in cluster): |
| | |
| | return cluster |
| |
|
| | return None |
| |
|
| |
|
| | def tangentEdgeLoop(obj, edge): |
| | """Returns a tangent loop of edges""" |
| |
|
| | isCoincide = Path.Geom.pointsCoincide |
| |
|
| | loop = [edge] |
| | hashes = [edge.hashCode()] |
| | startPoint = edge.Vertexes[0].Point |
| | lastEdge = edge |
| | lastIndex = -1 |
| | repeatCount = 0 |
| | while repeatCount < len(obj.Shape.Edges): |
| | repeatCount += 1 |
| |
|
| | lastPoint = lastEdge.Vertexes[lastIndex].Point |
| | lastTangent = lastEdge.tangentAt(lastEdge.ParameterRange[lastIndex]) |
| |
|
| | if isCoincide(lastEdge.Vertexes[lastIndex].Point, startPoint): |
| | |
| | break |
| |
|
| | for e in obj.Shape.Edges: |
| | if e.hashCode() in hashes: |
| | |
| | continue |
| |
|
| | if isCoincide(lastPoint, e.Vertexes[0].Point): |
| | index = 0 |
| | elif isCoincide(lastPoint, e.Vertexes[-1].Point): |
| | index = -1 |
| | else: |
| | continue |
| |
|
| | tangent = e.tangentAt(e.ParameterRange[index]) |
| | if isCoincide(tangent, lastTangent, 0.05): |
| | |
| | loop.append(e) |
| | hashes.append(e.hashCode()) |
| | lastEdge = e |
| | lastIndex = -1 if index == 0 else 0 |
| | break |
| |
|
| | else: |
| | |
| | break |
| |
|
| | if len(loop) > 1: |
| | |
| | return loop |
| |
|
| | return None |
| |
|
| |
|
| | def horizontalFaceLoop(obj, face, faceList=None): |
| | """horizontalFaceLoop(obj, face, faceList=None) ... returns a list of face names which form the walls of a vertical hole face is a part of. |
| | All face names listed in faceList must be part of the hole for the solution to be returned.""" |
| |
|
| | isVertical = Path.Geom.isVertical |
| | isRoughly = Path.Geom.isRoughly |
| |
|
| | if not all(isVertical(obj.Shape.getElement(f)) for f in faceList): |
| | |
| | Path.Log.warning( |
| | translate( |
| | "CAM", |
| | "Selected faces should be vertical", |
| | ) |
| | ) |
| | return None |
| |
|
| | cluster = [horizontalEdgeLoop(obj, e) for e in face.Edges] |
| |
|
| | |
| | clusterSorted = sorted( |
| | [edges for edges in cluster if edges], |
| | key=lambda edges: Part.Face(Part.Wire(Part.sortEdges(edges)[0])).Area, |
| | ) |
| |
|
| | for edges in clusterSorted: |
| | hashes = [e.hashCode() for e in edges] |
| |
|
| | |
| | faces = [ |
| | "Face%d" % (i + 1) |
| | for i, f in enumerate(obj.Shape.Faces) |
| | if any(e.hashCode() in hashes for e in f.Edges) and isVertical(f) |
| | ] |
| |
|
| | if faceList and not all(f in faces for f in faceList): |
| | |
| | continue |
| |
|
| | |
| | |
| | comp = Part.makeCompound([obj.Shape.getElement(f) for f in faces]) |
| |
|
| | outline = TechDraw.findShapeOutline(comp, 1, Vector(0, 0, 1)) |
| |
|
| | |
| | |
| | uniqueEdges = [] |
| | for edge in outline.Edges: |
| | if any(Path.Geom.edgesMatch(edge, e) for e in uniqueEdges): |
| | continue |
| | uniqueEdges.append(edge) |
| |
|
| | w = Part.Wire(uniqueEdges) |
| |
|
| | |
| | |
| | bb1 = comp.BoundBox |
| | bb2 = w.BoundBox |
| | prec = 1 |
| | if ( |
| | w.isClosed() |
| | and isRoughly(bb1.XMin, bb2.XMin, prec) |
| | and isRoughly(bb1.XMax, bb2.XMax, prec) |
| | and isRoughly(bb1.YMin, bb2.YMin, prec) |
| | and isRoughly(bb1.YMax, bb2.YMax, prec) |
| | ): |
| | return faces |
| | return None |
| |
|
| |
|
| | def filterArcs(arcEdge): |
| | """filterArcs(Edge) -used to split an arc that is over 180 degrees. Returns list""" |
| | Path.Log.track() |
| | splitlist = [] |
| | if isinstance(arcEdge.Curve, Part.Circle): |
| | angle = abs(arcEdge.LastParameter - arcEdge.FirstParameter) |
| | goodarc = angle <= math.pi |
| |
|
| | if goodarc: |
| | splitlist.append(arcEdge) |
| | else: |
| | arcstpt = arcEdge.valueAt(arcEdge.FirstParameter) |
| | arcmid = arcEdge.valueAt( |
| | (arcEdge.LastParameter - arcEdge.FirstParameter) * 0.5 + arcEdge.FirstParameter |
| | ) |
| | arcquad1 = arcEdge.valueAt( |
| | (arcEdge.LastParameter - arcEdge.FirstParameter) * 0.25 + arcEdge.FirstParameter |
| | ) |
| | arcquad2 = arcEdge.valueAt( |
| | (arcEdge.LastParameter - arcEdge.FirstParameter) * 0.75 + arcEdge.FirstParameter |
| | ) |
| | arcendpt = arcEdge.valueAt(arcEdge.LastParameter) |
| | |
| | arcseg1 = Part.ArcOfCircle(arcstpt, arcquad1, arcmid) |
| | arcseg2 = Part.ArcOfCircle(arcmid, arcquad2, arcendpt) |
| |
|
| | eseg1 = arcseg1.toShape() |
| | eseg2 = arcseg2.toShape() |
| | splitlist.append(eseg1) |
| | splitlist.append(eseg2) |
| |
|
| | elif isinstance(arcEdge.Curve, Part.LineSegment): |
| | pass |
| | return splitlist |
| |
|
| |
|
| | def makeWorkplane(shape): |
| | """ |
| | Creates a workplane circle at the ZMin level. |
| | """ |
| | Path.Log.track() |
| | loc = Vector(shape.BoundBox.Center.x, shape.BoundBox.Center.y, shape.BoundBox.ZMin) |
| | c = Part.makeCircle(10, loc) |
| | return c |
| |
|
| |
|
| | def getEnvelope(partshape, subshape=None, depthparams=None): |
| | """ |
| | getEnvelope(partshape, stockheight=None) |
| | returns a shape corresponding to the partshape silhouette extruded to height. |
| | if stockheight is given, the returned shape is extruded to that height otherwise the returned shape |
| | is the height of the original shape boundbox |
| | partshape = solid object |
| | stockheight = float - Absolute Z height of the top of material before cutting. |
| | """ |
| | Path.Log.track(partshape, subshape, depthparams) |
| |
|
| | zShift = 0 |
| | if subshape is not None: |
| | if isinstance(subshape, Part.Face): |
| | Path.Log.debug("processing a face") |
| | sec = Part.makeCompound([subshape]) |
| | else: |
| | area = Path.Area(Fill=2, Coplanar=0).add(subshape) |
| | area.setPlane(makeWorkplane(partshape)) |
| | Path.Log.debug("About to section with params: {}".format(area.getParams())) |
| | sec = area.makeSections(heights=[0.0], project=True)[0].getShape() |
| |
|
| | Path.Log.debug( |
| | "partshapeZmin: {}, subshapeZMin: {}, zShift: {}".format( |
| | partshape.BoundBox.ZMin, subshape.BoundBox.ZMin, zShift |
| | ) |
| | ) |
| |
|
| | else: |
| | area = Path.Area(Fill=2, Coplanar=0).add(partshape) |
| | area.setPlane(makeWorkplane(partshape)) |
| | sec = area.makeSections(heights=[0.0], project=True)[0].getShape() |
| |
|
| | |
| | |
| | if depthparams is not None: |
| | eLength = depthparams.safe_height - depthparams.final_depth |
| | zShift = depthparams.final_depth - sec.BoundBox.ZMin |
| | Path.Log.debug( |
| | "boundbox zMIN: {} elength: {} zShift {}".format( |
| | partshape.BoundBox.ZMin, eLength, zShift |
| | ) |
| | ) |
| | else: |
| | eLength = partshape.BoundBox.ZLength - sec.BoundBox.ZMin |
| |
|
| | |
| | newPlace = FreeCAD.Placement(Vector(0, 0, zShift), sec.Placement.Rotation) |
| | sec.Placement = newPlace |
| |
|
| | |
| | envelopeshape = sec.extrude(Vector(0, 0, eLength)) |
| | if Path.Log.getLevel(Path.Log.thisModule()) == Path.Log.Level.DEBUG: |
| | removalshape = FreeCAD.ActiveDocument.addObject("Part::Feature", "Envelope") |
| | removalshape.Shape = envelopeshape |
| | return envelopeshape |
| |
|
| |
|
| | |
| | def getOffsetArea( |
| | fcShape, |
| | offset, |
| | removeHoles=False, |
| | |
| | plane=Part.makeCircle(10), |
| | tolerance=1e-4, |
| | ): |
| | """Make an offset area of a shape, projected onto a plane. |
| | Positive offsets expand the area, negative offsets shrink it. |
| | Inspired by _buildPathArea() from Path.Op.Area.py module. Adjustments made |
| | based on notes by @sliptonic at this webpage: |
| | https://github.com/sliptonic/FreeCAD/wiki/PathArea-notes.""" |
| | Path.Log.debug("getOffsetArea()") |
| |
|
| | areaParams = {} |
| | areaParams["Offset"] = offset |
| | areaParams["Fill"] = 1 |
| | areaParams["Outline"] = removeHoles |
| | areaParams["Coplanar"] = 0 |
| | areaParams["SectionCount"] = 1 |
| | areaParams["Reorient"] = True |
| | areaParams["OpenMode"] = 0 |
| | areaParams["MaxArcPoints"] = 400 |
| | areaParams["Project"] = True |
| | areaParams["FitArcs"] = False |
| | areaParams["Deflection"] = tolerance |
| | areaParams["Accuracy"] = tolerance |
| | areaParams["Tolerance"] = 1e-5 |
| | areaParams["Simplify"] = True |
| | areaParams["CleanDistance"] = tolerance / 5 |
| |
|
| | area = Path.Area() |
| | |
| | area.setPlane(makeWorkplane(plane)) |
| | area.add(fcShape) |
| | area.setParams(**areaParams) |
| |
|
| | offsetShape = area.getShape() |
| | if not offsetShape.Faces: |
| | return False |
| | return offsetShape |
| |
|
| |
|
| | def reverseEdge(e): |
| | if DraftGeomUtils.geomType(e) == "Circle": |
| | arcstpt = e.valueAt(e.FirstParameter) |
| | arcmid = e.valueAt((e.LastParameter - e.FirstParameter) * 0.5 + e.FirstParameter) |
| | arcendpt = e.valueAt(e.LastParameter) |
| | arcofCirc = Part.ArcOfCircle(arcendpt, arcmid, arcstpt) |
| | newedge = arcofCirc.toShape() |
| | elif DraftGeomUtils.geomType(e) == "LineSegment" or DraftGeomUtils.geomType(e) == "Line": |
| | stpt = e.valueAt(e.FirstParameter) |
| | endpt = e.valueAt(e.LastParameter) |
| | newedge = Part.makeLine(endpt, stpt) |
| |
|
| | return newedge |
| |
|
| |
|
| | def getToolControllers(obj, proxy=None): |
| | """returns all the tool controllers""" |
| | if proxy is None: |
| | proxy = obj.Proxy |
| | try: |
| | job = findParentJob(obj) |
| | except Exception: |
| | job = None |
| |
|
| | Path.Log.debug("op={} ({})".format(obj.Label, type(obj))) |
| | if job: |
| | return [tc for tc in job.Tools.Group if proxy.isToolSupported(obj, tc.Tool)] |
| | return [] |
| |
|
| |
|
| | def getToolShapeName(tool): |
| | if hasattr(tool, "ShapeName"): |
| | return tool.ShapeName.lower() |
| | if hasattr(tool, "ShapeType"): |
| | return tool.ShapeType.lower() |
| | return "" |
| |
|
| |
|
| | def findToolController(obj, proxy, name=None): |
| | """returns a tool controller with a given name. |
| | If no name is specified, returns the first controller. |
| | if no controller is found, returns None""" |
| |
|
| | Path.Log.track("name: {}".format(name)) |
| | c = None |
| | if UserInput: |
| | c = UserInput.selectedToolController() |
| | if c is not None: |
| | return c |
| |
|
| | controllers = getToolControllers(obj, proxy) |
| |
|
| | if len(controllers) == 0: |
| | raise PathNoTCExistsException() |
| |
|
| | |
| | if len(controllers) == 1: |
| | if name is None or name == controllers[0].Label: |
| | tc = controllers[0] |
| | else: |
| | tc = None |
| | elif name is not None: |
| | tc = [i for i in controllers if i.Label == name][0] |
| | elif UserInput: |
| | tc = UserInput.chooseToolController(controllers) |
| | return tc |
| |
|
| |
|
| | def findParentJob(obj): |
| | """retrieves a parent job object for an operation or other Path object""" |
| | Path.Log.track() |
| | if hasattr(obj, "Proxy") and isinstance(obj.Proxy, PathJob.ObjectJob): |
| | return obj |
| |
|
| | for i in obj.InList: |
| | if hasattr(i, "Proxy") and isinstance(i.Proxy, PathJob.ObjectJob): |
| | return i |
| | if ( |
| | i.TypeId == "Path::FeaturePython" |
| | or i.TypeId == "Path::FeatureCompoundPython" |
| | or i.TypeId == "App::DocumentObjectGroup" |
| | ): |
| | grandParent = findParentJob(i) |
| | if grandParent is not None: |
| | return grandParent |
| | return None |
| |
|
| |
|
| | def GetJobs(jobname=None): |
| | """returns all jobs in the current document. If name is given, returns that job""" |
| | if jobname: |
| | return [job for job in PathJob.Instances() if job.Name == jobname] |
| | return PathJob.Instances() |
| |
|
| |
|
| | def addToJob(obj, jobname=None): |
| | """adds a path object to a job |
| | obj = obj |
| | jobname = None""" |
| | Path.Log.track(jobname) |
| |
|
| | job = None |
| | if jobname is not None: |
| | jobs = GetJobs(jobname) |
| | if len(jobs) == 1: |
| | job = jobs[0] |
| | else: |
| | Path.Log.error(translate("Path", "Didn't find job {}".format(jobname))) |
| | return None |
| | else: |
| | jobs = GetJobs() |
| | if len(jobs) == 0 and UserInput: |
| | job = UserInput.createJob() |
| | elif len(jobs) == 1: |
| | job = jobs[0] |
| | elif UserInput: |
| | job = UserInput.chooseJob(jobs) |
| |
|
| | if obj and job: |
| | job.Proxy.addOperation(obj) |
| | return job |
| |
|
| |
|
| | def sort_locations(locations, keys, attractors=None): |
| | """sort holes by the nearest neighbor method |
| | keys: two-element list of keys for X and Y coordinates. for example ['x','y'] |
| | originally written by m0n5t3r for PathHelix |
| | """ |
| | from queue import PriorityQueue |
| | from collections import defaultdict |
| |
|
| | if attractors is None: |
| | attractors = [] |
| |
|
| | attractors = attractors or [keys[0]] |
| |
|
| | def sqdist(a, b): |
| | """square Euclidean distance""" |
| | d = 0 |
| | for k in keys: |
| | d += (a[k] - b[k]) ** 2 |
| |
|
| | return d |
| |
|
| | def weight(location): |
| | w = 0 |
| |
|
| | for k in attractors: |
| | w += abs(location[k]) |
| |
|
| | return w |
| |
|
| | def find_closest(location_list, location, dist): |
| | q = PriorityQueue() |
| |
|
| | for i, j in enumerate(location_list): |
| | |
| | q.put((dist(j, location) + weight(j), i, j)) |
| |
|
| | prio, i, result = q.get() |
| |
|
| | return result |
| |
|
| | out = [] |
| | zero = defaultdict(lambda: 0) |
| |
|
| | out.append(find_closest(locations, zero, sqdist)) |
| | locations.remove(out[-1]) |
| |
|
| | while locations: |
| | closest = find_closest(locations, out[-1], sqdist) |
| | out.append(closest) |
| | locations.remove(closest) |
| |
|
| | return out |
| |
|
| |
|
| | def sort_locations_tsp(locations, keys, attractors=None, startPoint=None, endPoint=None): |
| | """ |
| | Python wrapper for the C++ TSP solver. Takes a list of dicts (locations), |
| | a list of keys (e.g. ['x', 'y']), and optional parameters. |
| | |
| | Parameters: |
| | - locations: List of dictionaries with point coordinates |
| | - keys: List of keys to use for coordinates (e.g. ['x', 'y']) |
| | - attractors: Optional parameter (not used, kept for compatibility) |
| | - startPoint: Optional starting point [x, y] |
| | - endPoint: Optional ending point [x, y] |
| | |
| | Returns the sorted list of locations in TSP order. |
| | If startPoint is None, the path is optimized to start near the first point in the original list, |
| | but may not start exactly at that point. |
| | """ |
| | |
| | points = [(loc[keys[0]], loc[keys[1]]) for loc in locations] |
| | order = tsp_solver.solve(points=points, startPoint=startPoint, endPoint=endPoint) |
| |
|
| | |
| | return [locations[i] for i in order] |
| |
|
| |
|
| | def sort_tunnels_tsp(tunnels, allowFlipping=False, routeStartPoint=None, routeEndPoint=None): |
| | """ |
| | Python wrapper for the C++ TSP tunnel solver. Takes a list of dicts (tunnels), |
| | a list of keys for start/end coordinates, and optional parameters. |
| | |
| | Parameters: |
| | - tunnels: List of dictionaries with tunnel data. Each tunnel dictionary should contain: |
| | - startX: X-coordinate of the tunnel start point |
| | - startY: Y-coordinate of the tunnel start point |
| | - endX: X-coordinate of the tunnel end point |
| | - endY: Y-coordinate of the tunnel end point |
| | - isOpen: Boolean indicating if the tunnel is open (optional, defaults to True) |
| | - allowFlipping: Whether tunnels can be reversed (entry becomes exit) |
| | - routeStartPoint: Optional starting point [x, y] for the entire route |
| | - routeEndPoint: Optional ending point [x, y] for the entire route |
| | |
| | Returns the sorted list of tunnels in TSP order. Each returned tunnel dictionary |
| | will include the original keys plus: |
| | - flipped: Boolean indicating if the tunnel was reversed during optimization |
| | - index: Original index of the tunnel in the input list |
| | """ |
| | |
| | return tsp_solver.solveTunnels( |
| | tunnels=tunnels, |
| | allowFlipping=allowFlipping, |
| | routeStartPoint=routeStartPoint, |
| | routeEndPoint=routeEndPoint, |
| | ) |
| |
|
| |
|
| | def guessDepths(objshape, subs=None): |
| | """ |
| | takes an object shape and optional list of subobjects and returns a depth_params |
| | object with suggested height/depth values. |
| | |
| | objshape = Part::Shape. |
| | subs = list of subobjects from objshape |
| | """ |
| |
|
| | bb = objshape.BoundBox |
| | clearance = bb.ZMax + 5.0 |
| | safe = bb.ZMax |
| | start = bb.ZMax |
| | final = bb.ZMin |
| |
|
| | if subs is not None: |
| | subobj = Part.makeCompound(subs) |
| | fbb = subobj.BoundBox |
| | start = fbb.ZMax |
| |
|
| | if fbb.ZMax == fbb.ZMin and fbb.ZMax == bb.ZMax: |
| | final = fbb.ZMin |
| | elif fbb.ZMax > fbb.ZMin and fbb.ZMax == bb.ZMax: |
| | final = fbb.ZMin |
| | elif fbb.ZMax > fbb.ZMin and fbb.ZMin > bb.ZMin: |
| | final = fbb.ZMin |
| | elif fbb.ZMax == fbb.ZMin and fbb.ZMax > bb.ZMin: |
| | final = fbb.ZMin |
| |
|
| | return depth_params(clearance, safe, start, 1.0, 0.0, final, user_depths=None, equalstep=False) |
| |
|
| |
|
| | def drillTipLength(tool): |
| | """returns the length of the drillbit tip.""" |
| |
|
| | if not hasattr(tool, "TipAngle"): |
| | Path.Log.error(translate("Path", "Selected tool is not a drill")) |
| | return 0.0 |
| |
|
| | angle = tool.TipAngle |
| |
|
| | if angle <= 0 or angle >= 180: |
| | Path.Log.error( |
| | translate("Path", "Invalid Cutting Edge Angle %.2f, must be >0° and <=180°") % angle |
| | ) |
| | return 0.0 |
| |
|
| | theta = math.radians(angle) |
| | length = (float(tool.Diameter) / 2) / math.tan(theta / 2) |
| |
|
| | if length < 0: |
| | Path.Log.error( |
| | translate("Path", "Cutting Edge Angle (%.2f) results in negative tool tip length") |
| | % angle |
| | ) |
| | return 0.0 |
| |
|
| | return length |
| |
|
| |
|
| | class depth_params(object): |
| | """calculates the intermediate depth values for various operations given the starting, ending, and stepdown parameters |
| | (self, clearance_height, safe_height, start_depth, step_down, z_finish_depth, final_depth, [user_depths=None], equalstep=False) |
| | |
| | Note: if user_depths are supplied, only user_depths will be used. |
| | |
| | clearance_height: Height to clear all obstacles |
| | safe_height: Height to clear raw stock material |
| | start_depth: Top of Model |
| | step_down: Distance to step down between passes (always positive) |
| | z_finish_step: Maximum amount of material to remove on the final pass |
| | final_depth: Lowest point of the cutting operation |
| | user_depths: List of specified depths |
| | equalstep: Boolean. If True, steps down except Z_finish_depth will be balanced. |
| | """ |
| |
|
| | def __init__( |
| | self, |
| | clearance_height, |
| | safe_height, |
| | start_depth, |
| | step_down, |
| | z_finish_step, |
| | final_depth, |
| | user_depths=None, |
| | equalstep=False, |
| | ): |
| | """self, clearance_height, safe_height, start_depth, step_down, z_finish_depth, final_depth, [user_depths=None], equalstep=False""" |
| |
|
| | self.__clearance_height = clearance_height |
| | self.__safe_height = safe_height |
| | self.__start_depth = start_depth |
| | self.__step_down = math.fabs(step_down) |
| | self.__z_finish_step = math.fabs(z_finish_step) |
| | self.__final_depth = final_depth |
| | self.__user_depths = user_depths |
| | self.data = self.__get_depths(equalstep=equalstep) |
| | self.index = 0 |
| |
|
| | if self.__z_finish_step > self.__step_down: |
| | raise ValueError("z_finish_step must be less than step_down") |
| |
|
| | def __iter__(self): |
| | self.index = 0 |
| | return self |
| |
|
| | def __next__(self): |
| | if self.index == len(self.data): |
| | raise StopIteration |
| | self.index = self.index + 1 |
| | return self.data[self.index - 1] |
| |
|
| | def next(self): |
| | return self.__next__() |
| |
|
| | @property |
| | def clearance_height(self): |
| | """ |
| | Height of all vises, clamps, and other obstructions. Rapid moves at clearance height |
| | are always assumed to be safe from collision. |
| | """ |
| | return self.__clearance_height |
| |
|
| | @property |
| | def safe_height(self): |
| | """ |
| | Height of top of raw stock material. Rapid moves above safe height are |
| | assumed to be safe within an operation. May not be safe between |
| | operations or tool changes. |
| | All moves below safe height except retraction should be at feed rate. |
| | """ |
| | return self.__safe_height |
| |
|
| | @property |
| | def start_depth(self): |
| | """ |
| | Start Depth is the top of the model. |
| | """ |
| | return self.__start_depth |
| |
|
| | @property |
| | def step_down(self): |
| | """ |
| | Maximum step down value between passes. Step-Down may be less than |
| | this value, especially if equalstep is True. |
| | """ |
| | return self.__step_down |
| |
|
| | @property |
| | def z_finish_depth(self): |
| | """ |
| | The amount of material to remove on the finish pass. If given, the |
| | final pass will remove exactly this amount. |
| | """ |
| | return self.__z_finish_step |
| |
|
| | @property |
| | def final_depth(self): |
| | """ |
| | The height of the cutter during the last pass or finish pass if |
| | z_finish_pass is given. |
| | """ |
| | return self.__final_depth |
| |
|
| | @property |
| | def user_depths(self): |
| | """ |
| | Returns a list of the user_specified depths. If user_depths were given |
| | in __init__, these depths override all calculation and only these are |
| | used. |
| | """ |
| | return self.__user_depths |
| |
|
| | def __get_depths(self, equalstep=False): |
| | """returns a list of depths to be used in order from first to last. |
| | equalstep=True: all steps down before the finish pass will be equalized.""" |
| |
|
| | if self.user_depths is not None: |
| | return self.__user_depths |
| |
|
| | total_depth = self.__start_depth - self.__final_depth |
| |
|
| | if total_depth < 0: |
| | return [] |
| |
|
| | depths = [self.__final_depth] |
| |
|
| | |
| | if self.__z_finish_step > 0: |
| | if self.__z_finish_step < total_depth: |
| | depths.append(self.__z_finish_step + self.__final_depth) |
| | else: |
| | return depths |
| |
|
| | if equalstep: |
| | depths += self.__equal_steps(self.__start_depth, depths[-1], self.__step_down)[1:] |
| | else: |
| | depths += self.__fixed_steps(self.__start_depth, depths[-1], self.__step_down)[1:] |
| |
|
| | depths.reverse() |
| |
|
| | if len(depths) < 2: |
| | return depths |
| |
|
| | return self.__filter_roughly_equal_depths(depths) |
| |
|
| | def __filter_roughly_equal_depths(self, depths): |
| | """Depths arrive sorted from largest to smallest, positive to negative. |
| | Return unique list of depths, using Path.Geom.isRoughly() method to determine |
| | if the two values are equal. Only one of two consecutive equals are removed. |
| | |
| | The assumption is that there are not enough consecutively roughly-equal depths |
| | to be removed, so as to eliminate an effective step-down depth with the removal |
| | of repetitive roughly-equal values.""" |
| |
|
| | depthcopy = sorted(depths) |
| | keep = [depthcopy[0]] |
| | for depth in depthcopy[1:]: |
| | if not Path.Geom.isRoughly(depth, keep[-1]): |
| | keep.append(depth) |
| | keep.reverse() |
| | return keep |
| |
|
| | def __equal_steps(self, start, stop, max_size): |
| | """returns a list of depths beginning with the bottom (included), ending |
| | with the top (not included). |
| | all steps are of equal size, which is as big as possible but not bigger |
| | than max_size.""" |
| |
|
| | steps_needed = math.ceil((start - stop) / max_size) |
| | depths = list(linspace(stop, start, steps_needed, endpoint=False)) |
| |
|
| | return depths |
| |
|
| | def __fixed_steps(self, start, stop, size): |
| | """returns a list of depths beginning with the bottom (included), ending |
| | with the top (not included). |
| | all steps are of size 'size' except the one at the bottom which can be |
| | smaller.""" |
| |
|
| | fullsteps = int((start - stop) / size) |
| | last_step = start - (fullsteps * size) |
| | depths = list(linspace(last_step, start, fullsteps, endpoint=False)) |
| |
|
| | if last_step == stop: |
| | return depths |
| | else: |
| | return [stop] + depths |
| |
|
| |
|
| | def simplify3dLine(line, tolerance=1e-4): |
| | """Simplify a line defined by a list of App.Vectors, while keeping the |
| | maximum deviation from the original line within the defined tolerance. |
| | Implementation of |
| | https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm""" |
| | stack = [(0, len(line) - 1)] |
| | results = [] |
| |
|
| | def processRange(start, end): |
| | """Internal worker. Process a range of Vector indices within the |
| | line.""" |
| | if end - start < 2: |
| | results.extend(line[start:end]) |
| | return |
| | |
| | maxIndex, maxDistance = 0, 0.0 |
| | startPoint, endPoint = (line[start], line[end]) |
| | for i in range(start + 1, end): |
| | v = line[i] |
| | distance = v.distanceToLineSegment(startPoint, endPoint).Length |
| | if distance > maxDistance: |
| | maxDistance = distance |
| | maxIndex = i |
| | if maxDistance > tolerance: |
| | |
| | stack.append((maxIndex, end)) |
| | stack.append((start, maxIndex)) |
| | else: |
| | results.append(line[start]) |
| |
|
| | while len(stack): |
| | processRange(*stack.pop()) |
| | |
| | |
| | results.append(line[-1]) |
| | return results |
| |
|
| |
|
| | def RtoIJ(startpoint, command): |
| | """ |
| | This function takes a startpoint and an arc command in radius mode and |
| | returns an arc command in IJ mode. Useful for preprocessor scripts |
| | """ |
| | if "R" not in command.Parameters: |
| | raise ValueError("No R parameter in command") |
| | if command.Name not in ["G2", "G02", "G03", "G3"]: |
| | raise ValueError("Not an arc command") |
| |
|
| | endpoint = command.Placement.Base |
| | radius = command.Parameters["R"] |
| |
|
| | |
| | |
| | chord = endpoint.sub(startpoint) |
| |
|
| | |
| | perp = chord.cross(Vector(0, 0, 1)) |
| |
|
| | |
| | plength = math.sqrt(radius**2 - (chord.Length / 2) ** 2) |
| | perp.normalize() |
| | perp.scale(plength, plength, plength) |
| |
|
| | |
| | relativecenter = chord.scale(0.5, 0.5, 0.5).add(perp) |
| |
|
| | |
| | params = {c: command.Parameters[c] for c in "XYZF" if c in command.Parameters} |
| | params["I"] = relativecenter.x |
| | params["J"] = relativecenter.y |
| |
|
| | newcommand = Path.Command(command.Name) |
| | newcommand.Parameters = params |
| |
|
| | return newcommand |
| |
|
| |
|
| | def getPathWithPlacement(pathobj): |
| | """ |
| | Applies the rotation, and then position of the obj's Placement |
| | to the obj's path |
| | """ |
| |
|
| | if pathobj.Path is None: |
| | return pathobj.Path |
| |
|
| | |
| | |
| | if not hasattr(pathobj, "Placement") or pathobj.Placement.isIdentity(): |
| | return pathobj.Path |
| |
|
| | return applyPlacementToPath(pathobj.Placement, pathobj.Path) |
| |
|
| |
|
| | def applyPlacementToPath(placement, path): |
| | """ |
| | Applies the rotation, and then position of the placement to path |
| | """ |
| |
|
| | CmdMoveRapid = ["G0", "G00"] |
| | CmdMoveStraight = ["G1", "G01"] |
| | CmdMoveCW = ["G2", "G02"] |
| | CmdMoveCCW = ["G3", "G03"] |
| | CmdDrill = ["G73", "G81", "G82", "G83"] |
| | CmdMoveArc = CmdMoveCW + CmdMoveCCW |
| | CmdMove = CmdMoveStraight + CmdMoveArc |
| |
|
| | commands = [] |
| | currX = 0 |
| | currY = 0 |
| | currZ = 0 |
| |
|
| | |
| | cmd = Path.Command("G0 A0 B0 C0") |
| | t = cmd.transform(placement) |
| | tparams = t.Parameters |
| | transA0 = tparams.get("A", 0) |
| | transB0 = tparams.get("B", 0) |
| | transC0 = tparams.get("C", 0) |
| |
|
| | for cmd in path.Commands: |
| | if (cmd.Name in CmdMoveRapid) or (cmd.Name in CmdMove) or (cmd.Name in CmdDrill): |
| | params = cmd.Parameters |
| | currX = x = params.get("X", currX) |
| | currY = y = params.get("Y", currY) |
| | currZ = z = params.get("Z", currZ) |
| |
|
| | x, y, z = placement.Rotation.multVec(FreeCAD.Vector(x, y, z)) |
| |
|
| | if x != currX: |
| | params.update({"X": x}) |
| | if y != currY: |
| | params.update({"Y": y}) |
| | if z != currZ: |
| | params.update({"Z": z}) |
| |
|
| | |
| | if cmd.Name in CmdMoveArc: |
| | currI = i = params.get("I", 0) |
| | currJ = j = params.get("J", 0) |
| |
|
| | i, j, k = placement.Rotation.multVec(FreeCAD.Vector(i, j, 0)) |
| |
|
| | if currI != i: |
| | params.update({"I": i}) |
| | if currJ != j: |
| | params.update({"J": j}) |
| |
|
| | cmd.Parameters = params |
| |
|
| | |
| | params = cmd.Parameters |
| | aVal = params.get("A", None) |
| | bVal = params.get("B", None) |
| | cVal = params.get("C", None) |
| |
|
| | t = cmd.transform(placement) |
| |
|
| | |
| | tparams = t.Parameters |
| | if aVal is not None: |
| | tparams.update({"A": transA0 + aVal}) |
| | if bVal is not None: |
| | tparams.update({"B": transB0 + bVal}) |
| | if cVal is not None: |
| | tparams.update({"C": transC0 + cVal}) |
| | if aVal is not None or bVal is not None or cVal is not None: |
| | t.Parameters = tparams |
| |
|
| | commands.append(t) |
| | newPath = Path.Path(commands) |
| |
|
| | return newPath |
| |
|