# -*- coding: utf-8 -*- # *************************************************************************** # * Copyright (c) 2019 sliptonic * # * * # * This program is free software; you can redistribute it and/or modify * # * it under the terms of the GNU Lesser General Public License (LGPL) * # * as published by the Free Software Foundation; either version 2 of * # * the License, or (at your option) any later version. * # * for detail see the LICENCE text file. * # * * # * This program 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 Library General Public License for more details. * # * * # * You should have received a copy of the GNU Library General Public * # * License along with this program; if not, write to the Free Software * # * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 * # * USA * # * * # *************************************************************************** from PySide.QtCore import QT_TRANSLATE_NOOP import FreeCAD import Part import Path import math # lazily loaded modules from lazy_loader.lazy_loader import LazyLoader PathUtils = LazyLoader("PathScripts.PathUtils", globals(), "PathScripts.PathUtils") __title__ = "CAM Features Extensions" __author__ = "sliptonic (Brad Collette)" __url__ = "https://www.freecad.org" __doc__ = "Class and implementation of face extensions features." 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()) translate = FreeCAD.Qt.translate def endPoints(edgeOrWire): """endPoints(edgeOrWire) ... return the first and last point of the wire or the edge, assuming the argument is not a closed wire.""" if Part.Wire == type(edgeOrWire): # edges = edgeOrWire.Edges pts = [e.valueAt(e.FirstParameter) for e in edgeOrWire.Edges] pts.extend([e.valueAt(e.LastParameter) for e in edgeOrWire.Edges]) unique = [] for p in pts: cnt = len([p2 for p2 in pts if Path.Geom.pointsCoincide(p, p2)]) if 1 == cnt: unique.append(p) return unique pfirst = edgeOrWire.valueAt(edgeOrWire.FirstParameter) plast = edgeOrWire.valueAt(edgeOrWire.LastParameter) if Path.Geom.pointsCoincide(pfirst, plast): return None return [pfirst, plast] def includesPoint(p, pts): """includesPoint(p, pts) ... answer True if the collection of pts includes the point p""" for pt in pts: if Path.Geom.pointsCoincide(p, pt): return True return False def selectOffsetWire(feature, wires): """selectOffsetWire(feature, wires) ... returns the Wire in wires which is does not intersect with feature""" closest = None for w in wires: dist = feature.distToShape(w)[0] if closest is None or dist > closest[0]: closest = (dist, w) if closest is not None: return closest[1] return None def extendWire(feature, wire, length): """extendWire(wire, length) ... return a closed Wire which extends wire by length""" Path.Log.track(length) if not length or length == 0: return None try: off2D = wire.makeOffset2D(length) except FreeCAD.Base.FreeCADError as ee: Path.Log.debug(ee) return None endPts = endPoints(wire) # Assumes wire is NOT closed if endPts: edges = [ e for e in off2D.Edges if Part.Circle != type(e.Curve) or not includesPoint(e.Curve.Center, endPts) ] wires = [Part.Wire(e) for e in Part.sortEdges(edges)] offset = selectOffsetWire(feature, wires) ePts = endPoints(offset) if ePts and len(ePts) > 1: l0 = (ePts[0] - endPts[0]).Length l1 = (ePts[1] - endPts[0]).Length edges = wire.Edges if l0 < l1: edges.append(Part.Edge(Part.LineSegment(endPts[0], ePts[0]))) edges.extend(offset.Edges) edges.append(Part.Edge(Part.LineSegment(endPts[1], ePts[1]))) else: edges.append(Part.Edge(Part.LineSegment(endPts[1], ePts[0]))) edges.extend(offset.Edges) edges.append(Part.Edge(Part.LineSegment(endPts[0], ePts[1]))) return Part.Wire(edges) return None def createExtension(obj, extObj, extFeature, extSub): return Extension( obj, extObj, extFeature, extSub, obj.ExtensionLengthDefault, Extension.DirectionNormal, ) def readObjExtensionFeature(obj): """readObjExtensionFeature(obj)... Return three item string tuples (base name, feature, subfeature) extracted from obj.ExtensionFeature """ extensions = [] for extObj, features in obj.ExtensionFeature: for sub in features: extFeature, extSub = sub.split(":") extensions.append((extObj.Name, extFeature, extSub)) return extensions def getExtensions(obj): Path.Log.debug("getExtenstions()") extensions = [] i = 0 for extObj, features in obj.ExtensionFeature: for sub in features: extFeature, extSub = sub.split(":") extensions.append(createExtension(obj, extObj, extFeature, extSub)) i = i + 1 return extensions def setExtensions(obj, extensions): Path.Log.track(obj.Label, len(extensions)) obj.ExtensionFeature = [(ext.obj, ext.getSubLink()) for ext in extensions] def getStandardAngle(x, y): """getStandardAngle(x, y)... Return standard degree angle given x and y values of vector.""" angle = math.degrees(math.atan2(y, x)) if angle < 0.0: return angle + 360.0 return angle def arcAdjustmentAngle(arc1, arc2): """arcAdjustmentAngle(arc1, arc2)... Return adjustment angle to apply to arc2 in order to align it with arc1. Arcs must have same center point.""" center = arc1.Curve.Center cntr2 = arc2.Curve.Center # Verify centers of arcs are same if center.sub(cntr2).Length > 0.0000001: return None # Calculate midpoint of arc1, and standard angle from center to that midpoint midPntArc1 = arc1.valueAt( arc1.FirstParameter + (arc1.LastParameter - arc1.FirstParameter) / 2.0 ) midPntVect1 = midPntArc1.sub(center) ang1 = getStandardAngle(midPntVect1.x, midPntVect1.y) # Calculate midpoint of arc2, and standard angle from center to that midpoint midPntArc2 = arc2.valueAt( arc2.FirstParameter + (arc2.LastParameter - arc2.FirstParameter) / 2.0 ) midPntVect2 = midPntArc2.sub(center) ang2 = getStandardAngle(midPntVect2.x, midPntVect2.y) # Return adjustment angle to apply to arc2 in order to align with arc1 return ang1 - ang2 class Extension(object): DirectionNormal = 0 DirectionX = 1 DirectionY = 2 def __init__(self, op, obj, feature, sub, length, direction): Path.Log.debug( "Extension(%s, %s, %s, %.2f, %s" % (obj.Label, feature, sub, length, direction) ) self.op = op self.obj = obj self.feature = feature self.sub = sub self.length = length self.direction = direction self.extFaces = None self.isDebug = True if Path.Log.getLevel(Path.Log.thisModule()) == 4 else False self.avoid = False if sub.startswith("Avoid_"): self.avoid = True self.wire = None def getSubLink(self): return "%s:%s" % (self.feature, self.sub) def _extendEdge(self, feature, e0, direction): Path.Log.track(feature, e0, direction) if isinstance(e0.Curve, Part.Line) or isinstance(e0.Curve, Part.LineSegment): e2 = e0.copy() off = self.length.Value * direction e2.translate(off) e2 = Path.Geom.flipEdge(e2) e1 = Part.Edge( Part.LineSegment(e0.valueAt(e0.LastParameter), e2.valueAt(e2.FirstParameter)) ) e3 = Part.Edge( Part.LineSegment(e2.valueAt(e2.LastParameter), e0.valueAt(e0.FirstParameter)) ) wire = Part.Wire([e0, e1, e2, e3]) self.wire = wire return wire return extendWire(feature, Part.Wire([e0]), self.length.Value) def _getEdgeNumbers(self): if "Wire" in self.sub: numbers = [nr for nr in self.sub[5:-1].split(",")] else: numbers = [self.sub[4:]] Path.Log.debug("_getEdgeNumbers() -> %s" % numbers) return numbers def _getEdgeNames(self): return ["Edge%s" % nr for nr in self._getEdgeNumbers()] def _getEdges(self): return [self.obj.Shape.getElement(sub) for sub in self._getEdgeNames()] def _getDirectedNormal(self, p0, normal): poffPlus = p0 + 0.01 * normal poffMinus = p0 - 0.01 * normal if not self.obj.Shape.isInside(poffPlus, 0.005, True): return normal if not self.obj.Shape.isInside(poffMinus, 0.005, True): return normal.negative() return None def _getDirection(self, wire): e0 = wire.Edges[0] midparam = e0.FirstParameter + 0.5 * (e0.LastParameter - e0.FirstParameter) tangent = e0.tangentAt(midparam) Path.Log.track("tangent", tangent, self.feature, self.sub) normal = tangent.cross(FreeCAD.Vector(0, 0, 1)) if Path.Geom.pointsCoincide(normal, FreeCAD.Vector(0, 0, 0)): return None return self._getDirectedNormal(e0.valueAt(midparam), normal.normalize()) def getExtensionFaces(self, extensionWire): """getExtensionFace(extensionWire)... A public helper method to retrieve the requested extension as a face, rather than a wire because some extensions require a face shape for definition that allows for two wires for boundary definition. """ if self.extFaces: return self.extFaces return [Part.Face(extensionWire)] def getWire(self): """getWire()... Public method to retrieve the extension area, pertaining to the feature and sub element provided at class instantiation, as a closed wire. If no closed wire is possible, a `None` value is returned.""" return self._getRegularWire() def _getRegularWire(self): """_getRegularWire()... Private method to retrieve the extension area, pertaining to the feature and sub element provided at class instantiation, as a closed wire. If no closed wire is possible, a `None` value is returned.""" Path.Log.track() length = self.length.Value if Path.Geom.isRoughly(0, length) or not self.sub: Path.Log.debug("no extension, length=%.2f, sub=%s" % (length, self.sub)) return None feature = self.obj.Shape.getElement(self.feature) edges = self._getEdges() sub = Part.Wire(Part.sortEdges(edges)[0]) if 1 == len(edges): Path.Log.debug("Extending single edge wire") edge = edges[0] if Part.Circle == type(edge.Curve): Path.Log.debug("is Part.Circle") circle = edge.Curve # for a circle we have to figure out if it's a hole or a cylinder p0 = edge.valueAt(edge.FirstParameter) normal = (edge.Curve.Center - p0).normalize() direction = self._getDirectedNormal(p0, normal) if direction is None: return None if Path.Geom.pointsCoincide(normal, direction): r = circle.Radius - length else: r = circle.Radius + length # assuming the offset produces a valid circle - go for it if r > 0: Path.Log.debug("radius > 0 - extend outward") e3 = Part.makeCircle( r, circle.Center, circle.Axis, edge.FirstParameter * 180 / math.pi, edge.LastParameter * 180 / math.pi, ) # Determine if rotational alignment is necessary for new arc rotationAdjustment = arcAdjustmentAngle(edge, e3) if not Path.Geom.isRoughly(rotationAdjustment, 0.0): e3.rotate( edge.Curve.Center, FreeCAD.Vector(0.0, 0.0, 1.0), rotationAdjustment, ) if endPoints(edge): Path.Log.debug("Make section of donut") # need to construct the arc slice e0 = Part.makeLine( edge.valueAt(edge.FirstParameter), e3.valueAt(e3.FirstParameter), ) e2 = Part.makeLine( edge.valueAt(edge.LastParameter), e3.valueAt(e3.LastParameter), ) wire = Part.Wire([e0, edge, e2, e3]) # Determine if calculated extension collides with model (wrong direction) face = Part.Face(wire) if face.common(feature).Area < face.Area * 0.10: return wire # Calculated extension is correct else: return None # Extension collides with model extWire = Part.Wire([e3]) self.extFaces = [self._makeCircularExtFace(edge, extWire)] return extWire Path.Log.debug("radius < 0 - extend inward") # the extension is bigger than the hole - so let's just cover the whole hole if endPoints(edge): # if the resulting arc is smaller than the radius, create a pie slice Path.Log.track() center = circle.Center e0 = Part.makeLine(center, edge.valueAt(edge.FirstParameter)) e2 = Part.makeLine(edge.valueAt(edge.LastParameter), center) return Part.Wire([e0, edge, e2]) Path.Log.track() return Part.Wire([edge]) else: Path.Log.debug("else is NOT Part.Circle") Path.Log.track(self.feature, self.sub, type(edge.Curve), endPoints(edge)) direction = self._getDirection(sub) if direction is None: return None return self._extendEdge(feature, edges[0], direction) elif sub.isClosed(): Path.Log.debug("Extending multi-edge closed wire") subFace = Part.Face(sub) featFace = Part.Face(feature.Wires[0]) isOutside = True if not Path.Geom.isRoughly(featFace.Area, subFace.Area): length = -1.0 * length isOutside = False try: off2D = sub.makeOffset2D(length) except FreeCAD.Base.FreeCADError as ee: Path.Log.debug(ee) return None if isOutside: self.extFaces = [Part.Face(off2D).cut(featFace)] else: self.extFaces = [subFace.cut(Part.Face(off2D))] return off2D Path.Log.debug("Extending multi-edge open wire") extendedWire = extendWire(feature, sub, length) if extendedWire is None: return extendedWire # Trim wire face using model extFace = Part.Face(extendedWire) trimmedWire = extFace.cut(self.obj.Shape).Wires[0] return trimmedWire.copy() def _makeCircularExtFace(self, edge, extWire): """_makeCircularExtensionFace(edge, extWire)... Create proper circular extension face shape. Incoming edge is expected to be a circle. """ # Add original outer wire to cut faces if necessary edgeFace = Part.Face(Part.Wire([edge])) edgeFace.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - edgeFace.BoundBox.ZMin)) extWireFace = Part.Face(extWire) extWireFace.translate(FreeCAD.Vector(0.0, 0.0, 0.0 - extWireFace.BoundBox.ZMin)) if extWireFace.Area >= edgeFace.Area: extensionFace = extWireFace.cut(edgeFace) else: extensionFace = edgeFace.cut(extWireFace) extensionFace.translate(FreeCAD.Vector(0.0, 0.0, edge.BoundBox.ZMin)) return extensionFace # Eclass def initialize_properties(obj): """initialize_properties(obj)... Adds feature properties to object argument""" if not hasattr(obj, "ExtensionLengthDefault"): obj.addProperty( "App::PropertyDistance", "ExtensionLengthDefault", "Extension", QT_TRANSLATE_NOOP("App::Property", "Default length of extensions."), ) if not hasattr(obj, "ExtensionFeature"): obj.addProperty( "App::PropertyLinkSubListGlobal", "ExtensionFeature", "Extension", QT_TRANSLATE_NOOP("App::Property", "List of features to extend."), ) if not hasattr(obj, "ExtensionCorners"): obj.addProperty( "App::PropertyBool", "ExtensionCorners", "Extension", QT_TRANSLATE_NOOP( "App::Property", "When enabled connected extension edges are combined to wires.", ), ) obj.ExtensionCorners = True obj.setEditorMode("ExtensionFeature", 2) def set_default_property_values(obj, job): """set_default_property_values(obj, job) ... set default values for feature properties""" obj.ExtensionCorners = True obj.setExpression("ExtensionLengthDefault", "OpToolDiameter / 2.0") def SetupProperties(): """SetupProperties()... Returns list of feature property names""" setup = ["ExtensionLengthDefault", "ExtensionFeature", "ExtensionCorners"] return setup # Extend outline face generation function def getExtendOutlineFace(base_shape, face, extension, remHoles=False, offset_tolerance=1e-4): """getExtendOutlineFace(obj, base_shape, face, extension, remHoles) ... Creates an extended face for the pocket, taking into consideration lateral collision with the greater base shape. Arguments are: parent base shape of face, target face, extension magnitude, remove holes boolean, offset tolerance = 1e-4 default The default value of 1e-4 for offset tolerance is the same default value at getOffsetArea() function definition. Return is an all access face extending the specified extension value from the source face. """ # Make offset face per user-specified extension distance so as to allow full clearing of face where possible. offset_face = PathUtils.getOffsetArea( face, extension, removeHoles=remHoles, plane=face, tolerance=offset_tolerance ) if not offset_face: Path.Log.error("Failed to offset a selected face.") return None # Apply collision detection by limiting extended face using base shape depth = 0.2 offset_ext = offset_face.extrude(FreeCAD.Vector(0.0, 0.0, depth)) face_del = offset_face.extrude(FreeCAD.Vector(0.0, 0.0, -1.0 * depth)) clear = base_shape.cut(face_del) available = offset_ext.cut(clear) available.removeSplitter() # Debug # Part.show(available) # FreeCAD.ActiveDocument.ActiveObject.Label = "available" # Identify bottom face of available volume zmin = available.BoundBox.ZMax bottom_faces = list() for f in available.Faces: bbx = f.BoundBox zNorm = abs(f.normalAt(0.0, 0.0).z) if ( Path.Geom.isRoughly(zNorm, 1.0) and Path.Geom.isRoughly(bbx.ZMax - bbx.ZMin, 0.0) and Path.Geom.isRoughly(bbx.ZMin, face.BoundBox.ZMin) ): if bbx.ZMin < zmin: bottom_faces.append(f) if bottom_faces: extended = None for bf in bottom_faces: # Drop travel face to same height as source face diff = face.BoundBox.ZMax - bf.BoundBox.ZMax bf.translate(FreeCAD.Vector(0.0, 0.0, diff)) cmn = bf.common(face) if hasattr(cmn, "Area") and cmn.Area > 0.0: extended = bf return extended Path.Log.error("No bottom face for extend outline.") return None # Waterline extension face generation function def getWaterlineFace(base_shape, face): """getWaterlineFace(base_shape, face) ... Creates a waterline extension face for the target face, taking into consideration the greater base shape. Arguments are: parent base shape and target face. Return is a waterline face at height of the target face. """ faceHeight = face.BoundBox.ZMin # Get envelope of model to height of face, then fuse with model and refine the shape baseBB = base_shape.BoundBox depthparams = PathUtils.depth_params( clearance_height=faceHeight, safe_height=faceHeight, start_depth=faceHeight, step_down=math.floor(faceHeight - baseBB.ZMin + 2.0), z_finish_step=0.0, final_depth=baseBB.ZMin, user_depths=None, ) env = PathUtils.getEnvelope(partshape=base_shape, subshape=None, depthparams=depthparams) # Get top face(s) of envelope at face height rawList = list() for f in env.Faces: if Path.Geom.isRoughly(f.BoundBox.ZMin, faceHeight): rawList.append(f) # make compound and extrude downward rawComp = Part.makeCompound(rawList) rawCompExtNeg = rawComp.extrude(FreeCAD.Vector(0.0, 0.0, baseBB.ZMin - faceHeight - 1.0)) # Cut off bottom of base shape at face height topSolid = base_shape.cut(rawCompExtNeg) # Get intersection with base shape # The commented version returns waterlines that only intersects horizontal faces at same height as target face # cmn = base_shape.common(rawComp) # waterlineShape = cmn.cut(topSolid) # return waterlineShape # This version returns more of a true waterline flowing from target face waterlineShape = rawComp.cut(topSolid) faces = list() for f in waterlineShape.Faces: cmn = face.common(f) if hasattr(cmn, "Area") and cmn.Area > 0.0: faces.append(f) if faces: return Part.makeCompound(faces) return None