CAM: Adaptive: Make machined regions respect stock and model in 3D, not just the 2D projections of the stock and selected machining bounding box

This commit is contained in:
Dan Taylor
2025-02-22 10:43:30 -06:00
parent 4e132ec936
commit 54116898da
2 changed files with 613 additions and 229 deletions

View File

@@ -396,10 +396,109 @@ class TestPathAdaptive(PathTestBase):
break
self.assertTrue(isInBox, "No paths originating within the inner hole.")
def test08(self):
"""test08() Tests stock awareness- avoids cutting into the model regardless
of bounding box selected."""
# Instantiate a Adaptive operation and set Base Geometry
adaptive = PathAdaptive.Create("Adaptive")
adaptive.Base = [(self.doc.Fusion, ["Face3", "Face10"])] # (base, subs_list)
adaptive.Label = "test08+"
adaptive.Comment = "test08() Verify path generated on adjacent, combined Face3 and Face10. The Z heights are different. Result should be the combination at Z=10 (faces from (0,0) to (40,25), minus tool radius), and only the lower face at Z=5: (15,0) to (40,25)."
# Set additional operation properties
setDepthsAndHeights(adaptive, 15, 0)
adaptive.FinishingProfile = False
adaptive.HelixAngle = 75.0
adaptive.HelixDiameterLimit.Value = 1.0
adaptive.LiftDistance.Value = 1.0
adaptive.StepOver = 75
adaptive.UseOutline = False
adaptive.setExpression("StepDown", None)
adaptive.StepDown.Value = (
5.0 # Have to set expression to None before numerical value assignment
)
_addViewProvider(adaptive)
self.doc.recompute()
# Check:
# - Bounding box at Z=10 stays within Face3 and Face10- so -X for Face3,
# +X and +/-Y for Face10
# - bounding box at Z=5 stays within Face10
# - No toolpaths at Z=0
paths = [c for c in adaptive.Path.Commands if c.Name in ["G0", "G00", "G1", "G01"]]
toolr = adaptive.OpToolDiameter.Value / 2
tol = adaptive.Tolerance
# Make clean up math below- combine tool radius and tolerance into a
# single field that can be added/subtracted to/from bounding boxes
moffset = toolr - tol
zDict = {10: None, 5: None, 0: None}
getPathBoundaries(paths, zDict)
# NOTE: Face3 is at Z=10, Face10 is at Z=5
bbf3 = self.doc.Fusion.Shape.getElement("Face3").BoundBox
bbf10 = self.doc.Fusion.Shape.getElement("Face10").BoundBox
okAt10 = (
zDict[10] is not None
and zDict[10]["min"][0] >= bbf3.XMin + moffset
and zDict[10]["min"][1] >= bbf10.YMin + moffset
and zDict[10]["max"][0] <= bbf10.XMax - moffset
and zDict[10]["max"][1] <= bbf10.YMax - moffset
)
okAt5 = (
zDict[5] is not None
and zDict[5]["min"][0] >= bbf10.XMin + moffset
and zDict[5]["min"][1] >= bbf10.YMin + moffset
and zDict[5]["max"][0] < bbf10.XMax - moffset
and zDict[5]["max"][1] < bbf10.YMax - moffset
)
okAt0 = not zDict[0]
self.assertTrue(okAt10 and okAt5 and okAt0, "Path boundaries outside of expected regions")
# Eclass
def getPathBoundaries(paths, zDict):
"""getPathBoundaries(paths, zDict): Takes the list of paths and dictionary
of Z depths of interest, and finds the bounding box of the paths at each
depth.
NOTE: You'd think that using Path.BoundBox would give us what we want,
but... no, for whatever reason it appears to always extend to (0,0,0)
"""
last = FreeCAD.Vector(0.0, 0.0, 0.0)
# First make sure each element has X, Y, and Z coordinates
for p in paths:
params = p.Parameters
last.x = p.X if "X" in params else last.x
last.y = p.Y if "Y" in params else last.y
last.z = p.Z if "Z" in params else last.z
p.X = last.x
p.Y = last.y
p.Z = last.z
for z in zDict:
zpaths = [k for k in paths if k.Z == z]
if not zpaths:
zDict[z] = None
continue
xmin = min([k.X for k in zpaths])
xmax = max([k.X for k in zpaths])
ymin = min([k.Y for k in zpaths])
ymax = max([k.Y for k in zpaths])
zDict[z] = {"min": (xmin, ymin), "max": (xmax, ymax)}
def setDepthsAndHeights(op, strDep=20.0, finDep=0.0):
"""setDepthsAndHeights(op, strDep=20.0, finDep=0.0)... Sets default depths and heights for `op` passed to it"""
@@ -421,43 +520,29 @@ def getGcodeMoves(cmdList, includeRapids=True, includeLines=True, includeArcs=Tr
"""getGcodeMoves(cmdList, includeRapids=True, includeLines=True, includeArcs=True)...
Accepts command dict and returns point string coordinate.
"""
# NOTE: Can NOT just check "if p.get("X")" or similar- that chokes when X is
# zero. That becomes especially obvious when Z=0, and moves end up on the
# wrong depth
gcode_list = list()
last = FreeCAD.Vector(0.0, 0.0, 0.0)
for c in cmdList:
p = c.Parameters
name = c.Name
if includeRapids and name in ["G0", "G00"]:
if (includeRapids and name in ["G0", "G00"]) or (includeLines and name in ["G1", "G01"]):
gcode = name
x = last.x
y = last.y
z = last.z
if p.get("X"):
if "X" in p:
x = round(p["X"], 2)
gcode += " X" + str(x)
if p.get("Y"):
gcode += " X" + str(x)
if "Y" in p:
y = round(p["Y"], 2)
gcode += " Y" + str(y)
if p.get("Z"):
gcode += " Y" + str(y)
if "Z" in p:
z = round(p["Z"], 2)
gcode += " Z" + str(z)
last.x = x
last.y = y
last.z = z
gcode_list.append(gcode)
elif includeLines and name in ["G1", "G01"]:
gcode = name
x = last.x
y = last.y
z = last.z
if p.get("X"):
x = round(p["X"], 2)
gcode += " X" + str(x)
if p.get("Y"):
y = round(p["Y"], 2)
gcode += " Y" + str(y)
if p.get("Z"):
z = round(p["Z"], 2)
gcode += " Z" + str(z)
gcode += " Z" + str(z)
last.x = x
last.y = y
last.z = z
@@ -470,23 +555,23 @@ def getGcodeMoves(cmdList, includeRapids=True, includeLines=True, includeArcs=Tr
i = 0.0
j = 0.0
k = 0.0
if p.get("I"):
if "I" in p:
i = round(p["I"], 2)
gcode += " I" + str(i)
if p.get("J"):
if "J" in p:
j = round(p["J"], 2)
gcode += " J" + str(j)
if p.get("K"):
if "K" in p:
k = round(p["K"], 2)
gcode += " K" + str(k)
if p.get("X"):
if "X" in p:
x = round(p["X"], 2)
gcode += " X" + str(x)
if p.get("Y"):
if "Y" in p:
y = round(p["Y"], 2)
gcode += " Y" + str(y)
if p.get("Z"):
if "Z" in p:
z = round(p["Z"], 2)
gcode += " Z" + str(z)
@@ -501,7 +586,7 @@ def pathOriginatesInBox(cmd, minPoint, maxPoint):
p = cmd.Parameters
name = cmd.Name
if name in ["G0", "G00", "G1", "G01"]:
if p.get("X") and p.get("Y"):
if "X" in p and "Y" in p:
x = p.get("X")
y = p.get("Y")
if x > minPoint.x and y > minPoint.y and x < maxPoint.x and y < maxPoint.y:

View File

@@ -72,6 +72,9 @@ sceneGraph = None
scenePathNodes = [] # for scene cleanup afterwards
topZ = 10
# Constants to avoid magic numbers in the code
_ADAPTIVE_MIN_STEPDOWN = 0.1
def sceneDrawPath(path, color=(0, 0, 1)):
coPoint = coin.SoCoordinate3()
@@ -117,7 +120,7 @@ def CalcHelixConePoint(height, cur_z, radius, angle):
def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
if len(adaptiveResults) == 0 or len(adaptiveResults[0]["AdaptivePaths"]) == 0:
if not adaptiveResults or not adaptiveResults[0]["AdaptivePaths"]:
return
# minLiftDistance = op.tool.Diameter
@@ -125,74 +128,55 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
for region in adaptiveResults:
p1 = region["HelixCenterPoint"]
p2 = region["StartPoint"]
r = math.sqrt((p1[0] - p2[0]) * (p1[0] - p2[0]) + (p1[1] - p2[1]) * (p1[1] - p2[1]))
if r > helixRadius:
helixRadius = r
helixRadius = max(math.dist(p1[:2], p2[:2]), helixRadius)
stepDown = obj.StepDown.Value
passStartDepth = obj.StartDepth.Value
if stepDown < 0.1:
stepDown = 0.1
stepDown = max(obj.StepDown.Value, _ADAPTIVE_MIN_STEPDOWN)
length = 2 * math.pi * helixRadius
if float(obj.HelixAngle) < 1:
obj.HelixAngle = 1
if float(obj.HelixAngle) > 89:
obj.HelixAngle = 89
obj.HelixAngle = min(89, max(obj.HelixAngle.Value, 1))
obj.HelixConeAngle = max(obj.HelixConeAngle, 0)
if float(obj.HelixConeAngle) < 0:
obj.HelixConeAngle = 0
helixAngleRad = math.pi * float(obj.HelixAngle) / 180.0
helixAngleRad = math.radians(obj.HelixAngle)
depthPerOneCircle = length * math.tan(helixAngleRad)
# print("Helix circle depth: {}".format(depthPerOneCircle))
stepUp = obj.LiftDistance.Value
if stepUp < 0:
stepUp = 0
stepUp = max(obj.LiftDistance.Value, 0)
finish_step = obj.FinishDepth.Value if hasattr(obj, "FinishDepth") else 0.0
if finish_step > stepDown:
finish_step = stepDown
# TODO: finishStep is of limited utility with how regions are now broken
# up based on the model geometry- the "finish" step gets applied to each
# region separately, which results in excessive "finish" steps being taken
# where they really need not be. Leaving stock in Z generally makes more
# sense, but both technically have their uses, so leaving this here as
# option. Implementing flat area detection would make better use of both.
finishStep = min(obj.FinishDepth.Value, stepDown) if hasattr(obj, "FinishDepth") else 0.0
depth_params = PathUtils.depth_params(
clearance_height=obj.ClearanceHeight.Value,
safe_height=obj.SafeHeight.Value,
start_depth=obj.StartDepth.Value,
step_down=stepDown,
z_finish_step=finish_step,
final_depth=obj.FinalDepth.Value,
user_depths=None,
)
# Track Z position to determine when changing height is necessary prior to a move
lz = obj.StartDepth.Value
# ml: this is dangerous because it'll hide all unused variables hence forward
# however, I don't know what lx and ly signify so I'll leave them for now
# lx = adaptiveResults[0]["HelixCenterPoint"][0]
# ly = adaptiveResults[0]["HelixCenterPoint"][1]
lz = passStartDepth
step = 0
for region in adaptiveResults:
passStartDepth = region["TopDepth"]
for passEndDepth in depth_params.data:
step = step + 1
depthParams = PathUtils.depth_params(
clearance_height=obj.ClearanceHeight.Value,
safe_height=obj.SafeHeight.Value,
start_depth=region["TopDepth"],
step_down=stepDown,
z_finish_step=finishStep,
final_depth=region["BottomDepth"],
user_depths=None,
)
for region in adaptiveResults:
for passEndDepth in depthParams.data:
startAngle = math.atan2(
region["StartPoint"][1] - region["HelixCenterPoint"][1],
region["StartPoint"][0] - region["HelixCenterPoint"][0],
)
# lx = region["HelixCenterPoint"][0]
# ly = region["HelixCenterPoint"][1]
passDepth = passStartDepth - passEndDepth
p1 = region["HelixCenterPoint"]
p2 = region["StartPoint"]
helixRadius = math.sqrt(
(p1[0] - p2[0]) * (p1[0] - p2[0]) + (p1[1] - p2[1]) * (p1[1] - p2[1])
)
helixRadius = math.dist(p1[:2], p2[:2])
# Helix ramp
if helixRadius > 0.01:
@@ -256,8 +240,6 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
op.commandlist.append(
Path.Command("G1", {"X": x, "Y": y, "Z": z, "F": op.vertFeed})
)
# lx = x
# ly = y
fi = fi + math.pi / 16
# one more circle at target depth to make sure center is cleared
@@ -269,13 +251,11 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
op.commandlist.append(
Path.Command("G1", {"X": x, "Y": y, "Z": z, "F": op.horizFeed})
)
# lx = x
# ly = y
fi = fi + math.pi / 16
else:
# Cone
_HelixAngle = 360 - (float(obj.HelixAngle) * 4)
_HelixAngle = 360 - (obj.HelixAngle.Value * 4)
if obj.HelixConeAngle > 6:
obj.HelixConeAngle = 6
@@ -510,8 +490,6 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
},
)
)
# lx = x
# ly = y
else: # no helix entry
# rapid move to clearance height
@@ -551,8 +529,6 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
x = pt[0]
y = pt[1]
# dist = math.sqrt((x-lx)*(x-lx) + (y-ly)*(y-ly))
if motionType == area.AdaptiveMotionType.Cutting:
z = passEndDepth
if z != lz:
@@ -576,13 +552,6 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
op.commandlist.append(Path.Command("G0", {"X": x, "Y": y}))
# elif motionType == area.AdaptiveMotionType.LinkClearAtPrevPass:
# if lx!=x or ly!=y:
# op.commandlist.append(Path.Command("G0", { "X": lx, "Y":ly, "Z":passStartDepth+stepUp}))
# op.commandlist.append(Path.Command("G0", { "X": x, "Y":y, "Z":passStartDepth+stepUp}))
# lx = x
# ly = y
lz = z
# return to safe height in this Z pass
@@ -592,14 +561,14 @@ def GenerateGCode(op, obj, adaptiveResults, helixDiameter):
lz = z
passStartDepth = passEndDepth
passStartDepth = passEndDepth
# return to safe height in this Z pass
z = obj.ClearanceHeight.Value
if z != lz:
op.commandlist.append(Path.Command("G0", {"Z": z}))
# return to safe height in this Z pass
z = obj.ClearanceHeight.Value
if z != lz:
op.commandlist.append(Path.Command("G0", {"Z": z}))
lz = z
lz = z
z = obj.ClearanceHeight.Value
if z != lz:
@@ -634,70 +603,100 @@ def Execute(op, obj):
topZ = op.stock.Shape.BoundBox.ZMax
obj.Stopped = False
obj.StopProcessing = False
if obj.Tolerance < 0.001:
obj.Tolerance = 0.001
obj.Tolerance = max(0.001, obj.Tolerance)
# Get list of working edges for adaptive algorithm
pathArray = op.pathArray
if not pathArray:
msg = translate(
"CAM",
"Adaptive operation couldn't determine the boundary wire. Did you select base geometry?",
# NOTE: Reminder that stock is formatted differently than inside/outside!
stockPaths = {d: convertTo2d(op.stockPathArray[d]) for d in op.stockPathArray}
outsideOpType = area.AdaptiveOperationType.ClearingOutside
insideOpType = area.AdaptiveOperationType.ClearingInside
# List every REGION separately- we can then calculate a toolpath based
# on the region. One or more stepdowns may use that same toolpath by
# keeping a reference to the region without requiring we calculate the
# toolpath once per step down OR forcing all stepdowns of a region into
# a single list.
regionOps = list()
outsidePathArray2dDepthTuples = list()
insidePathArray2dDepthTuples = list()
# NOTE: Make sure the depth lists are sorted for use in order-by-depth
# and order-by-region algorithms below
# NOTE: Pretty sure sorting is already guaranteed by how these are
# created, but best to not assume that
for rdict in op.outsidePathArray:
regionOps.append(
{
"opType": outsideOpType,
"path2d": convertTo2d(rdict["edges"]),
# FIXME: Kinda gross- just use this to match up with the
# appropriate stockpaths entry...
"startdepth": rdict["depths"][0],
}
)
outsidePathArray2dDepthTuples.append(
(sorted(rdict["depths"], reverse=True), regionOps[-1])
)
for rdict in op.insidePathArray:
regionOps.append(
{
"opType": insideOpType,
"path2d": convertTo2d(rdict["edges"]),
# FIXME: Kinda gross- just use this to match up with the
# appropriate stockpaths entry...
"startdepth": rdict["depths"][0],
}
)
insidePathArray2dDepthTuples.append(
(sorted(rdict["depths"], reverse=True), regionOps[-1])
)
FreeCAD.Console.PrintUserWarning(msg)
return
path2d = convertTo2d(pathArray)
# Use the 2D outline of the stock as the stock
# FIXME: This does not account for holes in the middle of stock!
outer_wire = TechDraw.findShapeOutline(op.stock.Shape, 1, FreeCAD.Vector(0, 0, 1))
stockPaths = [[discretize(outer_wire)]]
stockPath2d = convertTo2d(stockPaths)
# opType = area.AdaptiveOperationType.ClearingInside # Commented out per LGTM suggestion
if obj.OperationType == "Clearing":
if obj.Side == "Outside":
opType = area.AdaptiveOperationType.ClearingOutside
else:
opType = area.AdaptiveOperationType.ClearingInside
else: # profiling
if obj.Side == "Outside":
opType = area.AdaptiveOperationType.ProfilingOutside
else:
opType = area.AdaptiveOperationType.ProfilingInside
keepToolDownRatio = 3.0
if hasattr(obj, "KeepToolDownRatio"):
keepToolDownRatio = float(obj.KeepToolDownRatio)
keepToolDownRatio = obj.KeepToolDownRatio.Value
# put here all properties that influence calculation of adaptive base paths,
inputStateObject = {
"tool": float(op.tool.Diameter),
"tolerance": float(obj.Tolerance),
"geometry": path2d,
"stockGeometry": stockPath2d,
"stepover": float(obj.StepOver),
"effectiveHelixDiameter": float(helixDiameter),
"operationType": obj.OperationType,
"side": obj.Side,
# These fields are used to determine if toolpaths should be recalculated
outsideInputStateObject = {
"tool": op.tool.Diameter.Value,
"tolerance": obj.Tolerance,
"geometry": [k["path2d"] for k in regionOps if k["opType"] == outsideOpType],
"stockGeometry": stockPaths,
"stepover": obj.StepOver,
"effectiveHelixDiameter": helixDiameter,
"operationType": "Clearing",
"side": "Outside",
"forceInsideOut": obj.ForceInsideOut,
"finishingProfile": obj.FinishingProfile,
"keepToolDownRatio": keepToolDownRatio,
"stockToLeave": float(obj.StockToLeave),
"stockToLeave": obj.StockToLeave.Value,
}
insideInputStateObject = {
"tool": op.tool.Diameter.Value,
"tolerance": obj.Tolerance,
"geometry": [k["path2d"] for k in regionOps if k["opType"] == insideOpType],
"stockGeometry": stockPaths,
"stepover": obj.StepOver,
"effectiveHelixDiameter": helixDiameter,
"operationType": "Clearing",
"side": "Inside",
"forceInsideOut": obj.ForceInsideOut,
"finishingProfile": obj.FinishingProfile,
"keepToolDownRatio": keepToolDownRatio,
"stockToLeave": obj.StockToLeave.Value,
}
inputStateObject = [outsideInputStateObject, insideInputStateObject]
inputStateChanged = False
adaptiveResults = None
if obj.AdaptiveOutputState is not None and obj.AdaptiveOutputState != "":
# If we have a valid... path? Something. Generated, make that
# tentatively the output
if obj.AdaptiveOutputState:
adaptiveResults = obj.AdaptiveOutputState
# If ANYTHING in our input-cutting parameters, cutting regions,
# etc.- changes, force recalculating
if json.dumps(obj.AdaptiveInputState) != json.dumps(inputStateObject):
inputStateChanged = True
adaptiveResults = None
@@ -721,31 +720,67 @@ def Execute(op, obj):
start = time.time()
if inputStateChanged or adaptiveResults is None:
a2d = area.Adaptive2d()
a2d.stepOverFactor = 0.01 * obj.StepOver
a2d.toolDiameter = float(op.tool.Diameter)
a2d.helixRampDiameter = helixDiameter
a2d.keepToolDownDistRatio = keepToolDownRatio
a2d.stockToLeave = float(obj.StockToLeave)
a2d.tolerance = float(obj.Tolerance)
a2d.forceInsideOut = obj.ForceInsideOut
a2d.finishingProfile = obj.FinishingProfile
a2d.opType = opType
# NOTE: Seem to need to create a new a2d for each area when we're
# stepping down depths like this. If we don't, it will keep history
# from the last region we did.
# EXECUTE
results = a2d.Execute(stockPath2d, path2d, progressFn)
# TODO: QThread/QRunnable trigger Python's global interpretor lock
# (GIL). To calculate toolpaths in parallel, making a C++ shim that
# takes in the array of regions/stock paths and parallelizes in
# C++-land is probably the way to do it.
# Create a toolpath for each region to avoid re-calculating for
# identical stepdowns
for rdict in regionOps:
path2d = rdict["path2d"]
opType = rdict["opType"]
a2d = area.Adaptive2d()
a2d.stepOverFactor = 0.01 * obj.StepOver
a2d.toolDiameter = op.tool.Diameter.Value
a2d.helixRampDiameter = helixDiameter
a2d.keepToolDownDistRatio = keepToolDownRatio
a2d.stockToLeave = obj.StockToLeave.Value
a2d.tolerance = obj.Tolerance
a2d.forceInsideOut = obj.ForceInsideOut
a2d.finishingProfile = obj.FinishingProfile
a2d.opType = opType
rdict["toolpaths"] = a2d.Execute(
stockPaths[rdict["startdepth"]], path2d, progressFn
)
# Create list of regions to cut, in an order they can be cut in
cutlist = list()
# Region IDs that have been cut already
cutids = list()
# Create sorted list of unique depths
# NOTE: reverse because we cut top-down!
depths = list()
# NOTE: alltuples is sorted by depth already
alltuples = outsidePathArray2dDepthTuples + insidePathArray2dDepthTuples
for t in alltuples:
depths += [d for d in t[0]]
depths = sorted(list(set(depths)), reverse=True)
for d in depths:
cutlist += [([d], o[1]) for o in outsidePathArray2dDepthTuples if d in o[0]]
cutlist += [([d], i[1]) for i in insidePathArray2dDepthTuples if d in i[0]]
# need to convert results to python object to be JSON serializable
adaptiveResults = []
for result in results:
adaptiveResults.append(
{
"HelixCenterPoint": result.HelixCenterPoint,
"StartPoint": result.StartPoint,
"AdaptivePaths": result.AdaptivePaths,
"ReturnMotionType": result.ReturnMotionType,
}
)
stepdown = max(obj.StepDown.Value, _ADAPTIVE_MIN_STEPDOWN)
adaptiveResults = list()
for depths, region in cutlist:
for result in region["toolpaths"]:
adaptiveResults.append(
{
"HelixCenterPoint": result.HelixCenterPoint,
"StartPoint": result.StartPoint,
"AdaptivePaths": result.AdaptivePaths,
"ReturnMotionType": result.ReturnMotionType,
"TopDepth": depths[0] + stepdown,
"BottomDepth": depths[-1],
}
)
# GENERATE
GenerateGCode(op, obj, adaptiveResults, helixDiameter)
@@ -765,71 +800,328 @@ def Execute(op, obj):
sceneClean()
def _get_working_edges(op, obj):
"""_get_working_edges(op, obj)...
Compile all working edges from the Base Geometry selection (obj.Base)
for the current operation.
Additional modifications to selected region(face), such as extensions,
should be placed within this function.
def projectFacesToXY(faces, minEdgeLength=1e-10):
"""projectFacesToXY(faces, minEdgeLength)
Calculates the projection of the provided list of faces onto the XY plane.
The returned value is a single shape that may contain multiple faces if
there were disjoint projections. Each individual face will be clean, without
triangulated geometry, etc., and will be at Z=0 on the XY plane
minEdgeLength is provided to (eg) filter out the tips of cones that are
internally represented as arbitrarily-small circular faces- using those for
additional operations causes problems.
"""
all_regions = list()
edge_list = list()
avoidFeatures = list()
rawEdges = list()
projdir = FreeCAD.Vector(0, 0, 1)
outfaces = []
for f in faces:
# Vertical cones and spheres will still have a projection on the XY
# plane. Cylinders and flat faces will not.
if Path.Geom.isVertical(f) and type(f.Surface) not in [Part.Cone, Part.Sphere]:
continue
# NOTE: Wires/edges get clipped if we have an "exact fit" bounding box
projface = Path.Geom.makeBoundBoxFace(f.BoundBox, offset=1, zHeight=0)
# NOTE: Cylinders, cones, and spheres are messy:
# - Internal representation of non-truncted cones and spheres includes
# the "tip" with a ~0-area closed edge. This is different than the
# "isNull() note" at the top in magnitude
# - Projecting edges doesn't naively work due to the way seams are handled
# - There may be holes at either end that may or may not line up- any
# overlap is a hole in the projection
if type(f.Surface) in [Part.Cone, Part.Cylinder, Part.Sphere]:
# This gets most of the face outline, but since cylinder/cone faces
# are hollow, if the ends overlap in the projection there may be a
# hole we need to remove from the solid projection
oface = Part.makeFace(TechDraw.findShapeOutline(f, 1, projdir))
# "endfacewires" is JUST the end faces of a cylinder/cone, used to
# determine if there's a hole we can see through the shape that
# should NOT be solid in the projection
endfacewires = DraftGeomUtils.findWires(
[e for e in f.Edges if not e.isSeam(f) and e.Length > minEdgeLength]
)
# Need to verify that there actually is a projection before taking
# a wire from the list, else this could nicely be one line.
projwires = []
for w in endfacewires:
pp = projface.makeParallelProjection(w, projdir).Wires
if pp:
projwires.append(pp[0])
if len(projwires) > 1:
faces = [Part.makeFace(x) for x in projwires]
overlap = faces[0].common(faces[1:])
outfaces.append(oface.cut(overlap))
else:
outfaces.append(oface)
# For other cases, projecting the wires to a plane should suffice
else:
facewires = list()
for w in f.Wires:
if w.isClosed():
projwire = projface.makeParallelProjection(w, projdir).Wires[0]
if projwire.isClosed():
facewires.append(projwire)
if facewires:
outfaces.append(Part.makeFace(facewires))
if outfaces:
fusion = outfaces[0].fuse(outfaces[1:])
# removeSplitter fixes occasional concatenate issues for some face orders
return DraftGeomUtils.concatenate(fusion.removeSplitter())
else:
return Part.Shape()
def _getSolidProjection(shp, z):
"""_getSolidProjection(shp, z)
Calculates a shape obtained by slicing shp at the height z, then projecting
the solids above that height onto a region of proj_face, and creating a
simplified face
"""
bb = shp.BoundBox
# Find all faces above the machining depth. This is used to mask future
# interior cuts, and the outer wire is used as the external wire
bbCutTop = Part.makeBox(
bb.XLength,
bb.YLength,
max(bb.ZLength, bb.ZLength - z),
FreeCAD.Vector(bb.XMin, bb.YMin, z),
)
aboveSolids = shp.common(bbCutTop).Solids
faces = list()
for s in aboveSolids:
faces += s.Faces
return projectFacesToXY(faces)
def _workingEdgeHelperManual(op, obj, depths):
# Final calculated regions- list of dicts with entries:
# "region" - actual shape
# "depths" - list of depths this region applies to
insideRegions = list()
outsideRegions = list()
# User selections, with extensions
selectedRegions = list()
selectedEdges = list()
# Get extensions and identify faces to avoid
extensions = FeatureExtensions.getExtensions(obj)
for e in extensions:
if e.avoid:
avoidFeatures.append(e.feature)
avoidFeatures = [e for e in extensions if e.avoid]
# Get faces selected by user
for base, subs in obj.Base:
for sub in subs:
if sub.startswith("Face"):
if sub not in avoidFeatures:
if obj.UseOutline:
face = base.Shape.getElement(sub)
# get outline with wire_A method used in PocketShape, but it does not play nicely later
# wire_A = TechDraw.findShapeOutline(face, 1, FreeCAD.Vector(0.0, 0.0, 1.0))
wire_B = face.OuterWire
shape = Part.Face(wire_B)
else:
shape = base.Shape.getElement(sub)
all_regions.append(shape)
elif sub.startswith("Edge"):
# Save edges for later processing
rawEdges.append(base.Shape.getElement(sub))
# Efor
# Process selected edges
if rawEdges:
edgeWires = DraftGeomUtils.findWires(rawEdges)
if edgeWires:
for w in edgeWires:
for e in w.Edges:
edge_list.append([discretize(e)])
# Apply regular Extensions
op.exts = []
# Similarly, expand selected regions with extensions
for ext in extensions:
if not ext.avoid:
wire = ext.getWire()
if wire:
for f in ext.getExtensionFaces(wire):
op.exts.append(f)
all_regions.append(f)
if wire := ext.getWire():
selectedRegions += [f for f in ext.getExtensionFaces(wire)]
# Second face-combining method attempted
horizontal = Path.Geom.combineHorizontalFaces(all_regions)
if horizontal:
obj.removalshape = Part.makeCompound(horizontal)
for f in horizontal:
for w in f.Wires:
for e in w.Edges:
edge_list.append([discretize(e)])
for base, subs in obj.Base:
for sub in subs:
element = base.Shape.getElement(sub)
if sub.startswith("Face") and sub not in avoidFeatures:
shape = Part.Face(element.OuterWire) if obj.UseOutline else element
selectedRegions.append(shape)
# Omit vertical edges, since they project to nothing in the XY plane
# and cause processing failures later if included
elif sub.startswith("Edge") and not Path.Geom.isVertical(element):
selectedEdges.append(element)
return edge_list
# Multiple input solids can be selected- make a single part out of them,
# will process each solid separately as appropriate
shps = op.model[0].Shape.fuse([k.Shape for k in op.model[1:]])
# Make a face to project onto
# NOTE: Use 0 as the height, since that's what TechDraw.findShapeOutline
# uses, which we use to find the machining boundary, and the actual depth
# is tracked separately.
# NOTE: Project to the PART bounding box- with some padding- not the stock,
# since the stock may be smaller than the part
projface = Path.Geom.makeBoundBoxFace(shps.BoundBox, offset=1, zHeight=0)
projdir = FreeCAD.Vector(0, 0, 1)
# When looking for selected edges, project to a single plane first, THEN try
# to find wires. Take all the resulting wires and make faces in one shot to
# make bullseye-style cutouts where selected wires nest.
edgefaces = list()
if selectedEdges:
pp = [projface.makeParallelProjection(e, projdir).Wires[0] for e in selectedEdges]
ppe = list()
for w in pp:
ppe += w.Edges
edgeWires = DraftGeomUtils.findWires(ppe)
edgefaces = Part.makeFace(edgeWires).Faces
selectedRefined = projectFacesToXY(selectedRegions + edgefaces)
# If the user selected only faces that don't have an XY projection AND no
# edges, give a useful error
if not selectedRefined.Wires:
Path.Log.warning("Selected faces/wires have no projection on the XY plane")
return insideRegions, outsideRegions
lastdepth = obj.StartDepth.Value
for depth in depths:
# If our depth is above the top of the stock, there's nothing to machine
if depth >= op.stock.Shape.BoundBox.ZMax:
lastdepth = depth
continue
aboveRefined = _getSolidProjection(shps, depth)
# Create appropriate tuples and add to list, processing inside/outside
# as requested by operation
if obj.Side == "Outside":
# Outside is based on the outer wire of the faces of aboveRefined
# Insides are based on the remaining "below" regions, masked by the
# "above"- if something is above an area, we can't machine it in 2.5D
# Outside: Take the outer wire of the above faces, added to selected
# edges and regions
# NOTE: Exactly one entry per depth (not necessarily one depth entry per
# stepdown, however), which is a LIST of the wires we're staying outside
# NOTE: Do this FIRST- if any inside regions share enough of an edge
# with an outside region for a tool to get through, we want to skip them
# for the current stepdown
# NOTE: This check naively seems unnecessary, but it's possible the
# user selected a vertical face as the only face to stay outside of,
# and we're above the model, causing keepOutFaces to be empty
if keepOutFaces := [
Part.makeFace(TechDraw.findShapeOutline(f, 1, projdir))
for f in aboveRefined.Faces + selectedRefined.Faces
]:
finalMerge = keepOutFaces[0].fuse(keepOutFaces[1:])
else:
finalMerge = selectedRefined
# Without removeSplitter(), concatenate will sometimes fail when
# trying to merge faces that are (eg) connected A-B and B-C,
# seemingly when trying to merge A-C
regions = DraftGeomUtils.concatenate(finalMerge.removeSplitter())
# If this region exists in our list, it has to be the last entry, due to
# proceeding in order and having only one per depth. If it's already
# there, replace with the new, deeper depth, else add new
# NOTE: Do NOT need a check for whether outsideRegions[-1]["region"]
# is valid since we have a user-specified region regardless of depth
# NOTE: See "isNull() note" at top of file
if (
outsideRegions
and regions.Wires
and not regions.cut(outsideRegions[-1]["region"]).Wires
):
outsideRegions[-1]["depths"].append(depth)
else:
outsideRegions.append({"region": regions, "depths": [depth]})
# Inside
# For every area selected by the user, project to a plane
# NOTE: See "isNull() note" at top of file
else:
if aboveRefined.Wires:
finalCut = selectedRefined.cut(aboveRefined)
else:
finalCut = selectedRefined
# Split up into individual faces if any are disjoint, then update
# insideRegions- either by adding a new entry OR by updating the depth
# of an existing entry
for f in finalCut.Faces:
addNew = True
# Brute-force search all existing regions to see if any are the same
newtop = lastdepth
for rdict in insideRegions:
# FIXME: Smarter way to do this than a full cut operation?
if not rdict["region"].cut(f).Wires:
rdict["depths"].append(depth)
addNew = False
break
if addNew:
insideRegions.append({"region": f, "depths": [depth]})
# Update the last depth step
lastdepth = depth
# end for depth
return insideRegions, outsideRegions
def _getWorkingEdges(op, obj):
"""_getWorkingEdges(op, obj)...
Compile all working edges from the Base Geometry selection (obj.Base)
for the current operation (or the entire model if no selections).
Additional modifications to selected region(face), such as extensions,
should be placed within this function.
This version will return two lists- one for outside (keepout) edges and one
for inside ("machine inside") edges. Each list will be a dict with "region"
and "depths" entries- the former being discretized geometry of the region,
the latter being a list of every depth the geometry is machined on
"""
# Find depth steps, throwing out all depths above anywhere we might cut
# NOTE: Finish stepdown = 0 here- it's actually applied when gcode is
# generated; doing so here would cause it to be applied twice.
depthParams = PathUtils.depth_params(
clearance_height=obj.ClearanceHeight.Value,
safe_height=obj.SafeHeight.Value,
start_depth=obj.StartDepth.Value,
step_down=max(obj.StepDown.Value, _ADAPTIVE_MIN_STEPDOWN),
z_finish_step=0.0,
final_depth=obj.FinalDepth.Value,
user_depths=None,
)
depths = [d for d in depthParams.data if d < op.stock.Shape.BoundBox.ZMax]
# Get the stock outline at each stepdown. Used to calculate toolpaths and
# for calcuating cut regions in some instances
# NOTE: Stock is handled DIFFERENTLY than inside and outside regions!
# Combining different depths just adds code to look up the correct outline
# when computing inside/outside regions, for no real benefit.
stockProjectionDict = {d: _getSolidProjection(op.stock.Shape, d) for d in depths}
# If user specified edges, calculate the machining regions based on that
# input. Otherwise, process entire model
# Output are lists of dicts with "region" and "depths" entries. Depths are
# a list of Z depths that the region applies to
# Inside regions are a single face; outside regions consist of ALL geometry
# to be avoided at those depths.
insideRegions, outsideRegions = _workingEdgeHelperManual(op, obj, depths)
# Create discretized regions
def _createDiscretizedRegions(regionDicts):
discretizedRegions = list()
for rdict in regionDicts:
discretizedRegions.append(
{
"edges": [[discretize(w)] for w in rdict["region"].Wires],
"depths": rdict["depths"],
}
)
return discretizedRegions
insideDiscretized = _createDiscretizedRegions(insideRegions)
outsideDiscretized = _createDiscretizedRegions(outsideRegions)
# NOTE: REMINDER: This is notably different from machining regions- just
# a dict with depth: region entries, single depth for easy lookup
stockDiscretized = {}
for d in stockProjectionDict:
discretizedEdges = list()
for a in stockProjectionDict[d].Faces:
for w in a.Wires:
discretizedEdges.append([discretize(w)])
stockDiscretized[d] = discretizedEdges
# Return found inside and outside regions/depths. Up to the caller to decide
# which ones it cares about.
# NOTE: REMINDER: Stock is notably different from machining regions- just
# a dict with depth: region entries, single depth for easy lookup
return insideDiscretized, outsideDiscretized, stockDiscretized
class PathAdaptive(PathOp.ObjectOp):
@@ -1108,7 +1400,14 @@ class PathAdaptive(PathOp.ObjectOp):
See documentation of execute() for a list of base functionality provided.
Should be overwritten by subclasses."""
self.pathArray = _get_working_edges(self, obj)
# Contains both geometry to machine and the applicable depths
# NOTE: Reminder that stock is formatted differently than inside/outside!
inside, outside, stock = _getWorkingEdges(self, obj)
self.insidePathArray = inside
self.outsidePathArray = outside
self.stockPathArray = stock
Execute(self, obj)
def opOnDocumentRestored(self, obj):