diff --git a/src/Mod/Path/PathScripts/PathChamfer.py b/src/Mod/Path/PathScripts/PathChamfer.py index 8ada7b1587..ab84fbb975 100644 --- a/src/Mod/Path/PathScripts/PathChamfer.py +++ b/src/Mod/Path/PathScripts/PathChamfer.py @@ -33,7 +33,7 @@ import math from PySide import QtCore -if False: +if True: PathLog.setLevel(PathLog.Level.DEBUG, PathLog.thisModule()) PathLog.trackModule(PathLog.thisModule()) else: @@ -44,13 +44,40 @@ def translate(context, text, disambig=None): return QtCore.QCoreApplication.translate(context, text, disambig) def orientWire(w, forward=True): - face = Part.Face(w) - cw = 0 < face.Surface.Axis.z - if forward != cw: - PathLog.track('orientWire - needs flipping') - return PathGeom.flipWire(w) - PathLog.track('orientWire - ok') - return w + '''orientWire(w, forward=True) ... orients given wire in a specific direction. + If forward = True (the default) the wire is oriented clockwise, looking down the negative Z axis. + If forward = False the wire is oriented counter clockwise. + If forward = None the orientation is determined by the order in which the edges appear in the wire.''' + # first, we must ensure all edges are oriented the same way + # one would thing this is the way it should be, but it turns out it isn't + # on top of that, when creating a face the axis of the face seems to depend + # the axis of any included arcs, and not in the order of the edges + e0 = w.Edges[0] + # well, even the very first edge could be misoriented, so let's try and connect it to the second + if 1 < len(w.Edges): + last = e0.valueAt(e0.LastParameter) + e1 = w.Edges[1] + if not PathGeom.pointsCoincide(last, e1.valueAt(e1.FirstParameter)) and not PathGeom.pointsCoincide(last, e1.valueAt(e1.LastParameter)): + e0 = PathGeom.flipEdge(e0) + + edges = [e0] + last = e0.valueAt(e0.LastParameter) + for e in w.Edges[1:]: + edge = e if PathGeom.pointsCoincide(last, e.valueAt(e.FirstParameter)) else PathGeom.flipEdge(e) + edges.append(edge) + last = edge.valueAt(edge.LastParameter) + wire = Part.Wire(edges) + if forward is not None: + # now that we have a wire where all edges are oriented in the same way which + # also matches their order - we can create a face and get it's axis to determine + # the orientation of the wire - which is all we need here + face = Part.Face(wire) + cw = 0 < face.Surface.Axis.z + if forward != cw: + PathLog.track('orientWire - needs flipping') + return PathGeom.flipWire(wire) + PathLog.track('orientWire - ok') + return wire def isCircleAt(edge, center): if Circel == type(edge.Curve) or ArcOfCircle == type(edge.Curve): @@ -58,21 +85,55 @@ def isCircleAt(edge, center): return False def offsetWire(wire, base, offset, forward): + '''offsetWire(wire, base, offset, forward) ... offsets the wire away from base and orients the wire accordingly. + The function tries to avoid most of the pitfalls of Part.makeOffset2D which is possible because all offsetting + happens in the XY plane. + ''' PathLog.track('offsetWire') + if 1 == len(wire.Edges): + edge = wire.Edges[0] + curve = edge.Curve + if Part.Circle == type(curve) and wire.isClosed(): + # it's a full circle and there are some problems with that, see + # http://www.freecadweb.org/wiki/Part%20Offset2D + # it's easy to construct them manually though + z = -1 if forward else 1 + edge = Part.makeCircle(curve.Radius + offset, curve.Center, FreeCAD.Vector(0, 0, z)) + if base.isInside(edge.Vertexes[0].Point, offset/2, True): + if offset > curve.Radius or PathGeom.isRoughly(offset, curve.Radius): + # offsetting a hole by its own radius (or more) makes the hole vanish + return None + edge = Part.makeCircle(curve.Radius - offset, curve.Center, FreeCAD.Vector(0, 0, -z)) + w = Part.Wire([edge]) + return w + if Part.Line == type(curve) or Part.LineSegment == type(curve): + # offsetting a single edge doesn't work because there is an infinite + # possible planes into which the edge could be offset + # luckily, the plane here must be the XY-plane ... + n = (edge.Vertexes[1].Point - edge.Vertexes[0].Point).cross(FreeCAD.Vector(0, 0, 1)) + o = n.normalize() * offset + edge.translate(o) + if base.isInside(edge.valueAt((edge.FirstParameter + edge.LastParameter)/2), offset/2, True): + edge.translate(-2 * o) + w = Part.Wire([edge]) + return orientWire(w, forward) + # if we get to this point the assumption is that makeOffset2D can deal with the edge + pass + w = wire.makeOffset2D(offset) - if 1 == len(w.Edges): - e = w.Edges[0] - e.Placement = FreeCAD.Placement() - w = Part.Wire(e) - if wire.isClosed(): if not base.isInside(w.Edges[0].Vertexes[0].Point, offset/2, True): PathLog.track('closed - outside') return orientWire(w, forward) PathLog.track('closed - inside') - w = wire.makeOffset2D(-offset) + try: + w = wire.makeOffset2D(-offset) + except: + # most likely offsetting didn't work because the wire is a hole + # and the offset is too big - making the hole vanish + return None return orientWire(w, forward) # An edge is considered to be inside of shape if the mid point is inside @@ -192,7 +253,9 @@ class ObjectChamfer(PathEngraveBase.ObjectOp): for w in self.adjustWirePlacement(obj, base, basewires): self.adjusted_basewires.append(w) - wires.append(offsetWire(w, base.Shape, offset, True)) + wire = offsetWire(w, base.Shape, offset, True) + if wire: + wires.append(wire) self.wires = wires self.buildpathocc(obj, wires, [depth], True) diff --git a/src/Mod/Path/PathTests/PathTestUtils.py b/src/Mod/Path/PathTests/PathTestUtils.py index 9a2b65005b..8ea63aa58e 100644 --- a/src/Mod/Path/PathTests/PathTestUtils.py +++ b/src/Mod/Path/PathTests/PathTestUtils.py @@ -33,9 +33,9 @@ from FreeCAD import Vector class PathTestBase(unittest.TestCase): """Base test class with some additional asserts.""" - def assertRoughly(self, f1, f2): + def assertRoughly(self, f1, f2, error=0.00001): """Verify that two float values are approximately the same.""" - self.assertTrue(math.fabs(f1 - f2) < 0.00001, "%f != %f" % (f1, f2)) + self.assertTrue(math.fabs(f1 - f2) < error, "%f != %f" % (f1, f2)) def assertCoincide(self, pt1, pt2): """Verify that two points coincide - roughly speaking.""" @@ -53,8 +53,8 @@ class PathTestBase(unittest.TestCase): """Verify that edge is a line from pt1 to pt2.""" # Depending on the setting of LineOld .... self.assertTrue(type(edge.Curve) is Part.Line or type(edge.Curve) is Part.LineSegment) - self.assertCoincide(edge.valueAt(edge.FirstParameter), pt1) - self.assertCoincide(edge.valueAt(edge.LastParameter), pt2) + self.assertCoincide(pt1, edge.valueAt(edge.FirstParameter)) + self.assertCoincide(pt2, edge.valueAt(edge.LastParameter)) def assertLines(self, edgs, tail, points): """Verify that the edges match the polygon resulting from points.""" diff --git a/src/Mod/Path/PathTests/TestPathChamfer.py b/src/Mod/Path/PathTests/TestPathChamfer.py index 2e12d4339a..e56f405cbb 100644 --- a/src/Mod/Path/PathTests/TestPathChamfer.py +++ b/src/Mod/Path/PathTests/TestPathChamfer.py @@ -29,14 +29,29 @@ import PathScripts.PathChamfer as PathChamfer import PathScripts.PathGeom as PathGeom import PathScripts.PathLog as PathLog import PathTests.PathTestUtils as PathTestUtils +import math from FreeCAD import Vector PathLog.setLevel(PathLog.Level.INFO, PathLog.thisModule()) #PathLog.trackModule(PathLog.thisModule()) -def getWire(obj): - return obj.Tool.Tip.Profile[0].Shape.Wires[0] +def getWire(obj, nr=0): + return obj.Tip.Profile[0].Shape.Wires[nr] + +def getWireInside(obj): + w1 = getWire(obj, 0) + w2 = getWire(obj, 1) + if w2.BoundBox.isInside(w1.BoundBox): + return w1 + return w2 + +def getWireOutside(obj): + w1 = getWire(obj, 0) + w2 = getWire(obj, 1) + if w2.BoundBox.isInside(w1.BoundBox): + return w2 + return w1 def getPositiveShape(obj): return obj.Tool.Shape @@ -44,23 +59,212 @@ def getPositiveShape(obj): def getNegativeShape(obj): return obj.Shape +doc = None +triangle = None +shape = None + +def makeWire(pts): + edges = [] + first = pts[0] + last = pts[0] + for p in pts[1:]: + edges.append(Part.Edge(Part.LineSegment(last, p))) + last = p + edges.append(Part.Edge(Part.LineSegment(last, first))) + return Part.Wire(edges) + + class TestPathChamfer(PathTestUtils.PathTestBase): - def setUp(self): - self.doc = FreeCAD.open(FreeCAD.getHomePath() + 'Mod/Path/PathTests/test_chamfer.fcstd') - self.circle = self.doc.getObjectsByLabel('circle-cut')[0] - self.square = self.doc.getObjectsByLabel('square-cut')[0] - self.triangle = self.doc.getObjectsByLabel('triangle-cut')[0] - self.shape = self.doc.getObjectsByLabel('shape-cut')[0] + @classmethod + def setUpClass(cls): + global doc + doc = FreeCAD.openDocument(FreeCAD.getHomePath() + 'Mod/Path/PathTests/test_chamfer.fcstd') - def tearDown(self): + @classmethod + def tearDownClass(cls): FreeCAD.closeDocument("test_chamfer") - def test01(self): - '''Check offsetting a cylinder.''' - obj = self.circle + def test00(self): + '''Check that face orientation has anything to do with the wire orientation.''' + pa = Vector(1, 1, 0) + pb = Vector(1, 5, 0) + pc = Vector(5, 5, 0) + pd = Vector(5, 1, 0) - wire = PathChamfer.offsetWire(getWire(obj), getPositiveShape(obj), 3, True) + w = makeWire([pa, pb, pc, pd]) + f = Part.Face(w) + self.assertCoincide(Vector(0, 0, -1), f.Surface.Axis) + + w = makeWire([pa, pd, pc, pb]) + f = Part.Face(w) + self.assertCoincide(Vector(0, 0, +1), f.Surface.Axis) + + def test01(self): + '''Check offsetting a circular hole.''' + obj = doc.getObjectsByLabel('offset-circle')[0] + + small = getWireInside(obj) + self.assertRoughly(10, small.Edges[0].Curve.Radius) + + wire = PathChamfer.offsetWire(small, obj.Shape, 3, True) + self.assertIsNotNone(wire) + self.assertEqual(1, len(wire.Edges)) + self.assertRoughly(7, wire.Edges[0].Curve.Radius) + self.assertCoincide(Vector(0, 0, 1), wire.Edges[0].Curve.Axis) + + wire = PathChamfer.offsetWire(small, obj.Shape, 9.9, True) + self.assertIsNotNone(wire) + self.assertEqual(1, len(wire.Edges)) + self.assertRoughly(0.1, wire.Edges[0].Curve.Radius) + self.assertCoincide(Vector(0, 0, 1), wire.Edges[0].Curve.Axis) + + def test02(self): + '''Check offsetting a circular hole by the radius or more makes the hole vanish.''' + obj = doc.getObjectsByLabel('offset-circle')[0] + + small = getWireInside(obj) + self.assertRoughly(10, small.Edges[0].Curve.Radius) + wire = PathChamfer.offsetWire(small, obj.Shape, 10, True) + self.assertIsNone(wire) + + wire = PathChamfer.offsetWire(small, obj.Shape, 15, True) + self.assertIsNone(wire) + + def test03(self): + '''Check offsetting a cylinder succeeds.''' + obj = doc.getObjectsByLabel('offset-circle')[0] + + big = getWireOutside(obj) + self.assertRoughly(20, big.Edges[0].Curve.Radius) + + wire = PathChamfer.offsetWire(big, obj.Shape, 10, True) + self.assertIsNotNone(wire) + self.assertEqual(1, len(wire.Edges)) + self.assertRoughly(30, wire.Edges[0].Curve.Radius) + self.assertCoincide(Vector(0, 0, -1), wire.Edges[0].Curve.Axis) + + wire = PathChamfer.offsetWire(big, obj.Shape, 20, True) + self.assertIsNotNone(wire) + self.assertEqual(1, len(wire.Edges)) + self.assertRoughly(40, wire.Edges[0].Curve.Radius) + self.assertCoincide(Vector(0, 0, -1), wire.Edges[0].Curve.Axis) + + def test04(self): + '''Check offsetting a hole with Placement.''' + obj = doc.getObjectsByLabel('offset-placement')[0] + + wires = [w for w in obj.Shape.Wires if 1 == len(w.Edges) and PathGeom.isRoughly(0, w.Edges[0].Vertexes[0].Point.z)] + self.assertEqual(2, len(wires)) + w = wires[1] if wires[0].BoundBox.isInside(wires[1].BoundBox) else wires[0] + + self.assertRoughly(10, w.Edges[0].Curve.Radius) + # make sure there is a placement and I didn't mess up the model + self.assertFalse(PathGeom.pointsCoincide(Vector(), w.Edges[0].Placement.Base)) + + wire = PathChamfer.offsetWire(w, obj.Shape, 2, True) + self.assertIsNotNone(wire) + self.assertEqual(1, len(wire.Edges)) + self.assertRoughly(8, wire.Edges[0].Curve.Radius) + self.assertCoincide(Vector(0, 0, 0), wire.Edges[0].Curve.Center) + self.assertCoincide(Vector(0, 0, 1), wire.Edges[0].Curve.Axis) + + def test05(self): + '''Check offsetting a cylinder with Placement.''' + obj = doc.getObjectsByLabel('offset-placement')[0] + + wires = [w for w in obj.Shape.Wires if 1 == len(w.Edges) and PathGeom.isRoughly(0, w.Edges[0].Vertexes[0].Point.z)] + self.assertEqual(2, len(wires)) + w = wires[0] if wires[0].BoundBox.isInside(wires[1].BoundBox) else wires[1] + + self.assertRoughly(20, w.Edges[0].Curve.Radius) + # make sure there is a placement and I didn't mess up the model + self.assertFalse(PathGeom.pointsCoincide(Vector(), w.Edges[0].Placement.Base)) + + wire = PathChamfer.offsetWire(w, obj.Shape, 2, True) + self.assertIsNotNone(wire) + self.assertEqual(1, len(wire.Edges)) + self.assertRoughly(22, wire.Edges[0].Curve.Radius) + self.assertCoincide(Vector(0, 0, 0), wire.Edges[0].Curve.Center) + self.assertCoincide(Vector(0, 0, -1), wire.Edges[0].Curve.Axis) + + def test10(self): + '''Check offsetting hole wire succeeds.''' + obj = doc.getObjectsByLabel('offset-edge')[0] + + small = getWireInside(obj) + # sanity check + y = 10 + x = 10 * math.cos(math.pi/6) + self.assertLines(small.Edges, False, [Vector(0, y, 0), Vector(-x, -y/2, 0), Vector(x, -y/2, 0), Vector(0, y, 0)]) + + wire = PathChamfer.offsetWire(small, obj.Shape, 3, True) + self.assertIsNotNone(wire) + self.assertEqual(3, len(wire.Edges)) + self.assertTrue(wire.isClosed()) + y = 4 # offset works in both directions + x = 4 * math.cos(math.pi/6) + self.assertLines(wire.Edges, False, [Vector(0, 4, 0), Vector(-x, -2, 0), Vector(x, -2, 0), Vector(0, 4, 0)]) + f = Part.Face(wire) + self.assertCoincide(Vector(0, 0, 1), f.Surface.Axis) + + def test11(self): + '''Check offsetting hole wire for more than it's size makes hole vanish.''' + obj = doc.getObjectsByLabel('offset-edge')[0] + + small = getWireInside(obj) + # sanity check + y = 10 + x = 10 * math.cos(math.pi/6) + self.assertLines(small.Edges, False, [Vector(0, y, 0), Vector(-x, -y/2, 0), Vector(x, -y/2, 0), Vector(0, y, 0)]) + wire = PathChamfer.offsetWire(small, obj.Shape, 5, True) + self.assertIsNone(wire) + + def test12(self): + '''Check offsetting a body wire succeeds.''' + obj = doc.getObjectsByLabel('offset-edge')[0] + + big = getWireOutside(obj) + # sanity check + y = 20 + x = 20 * math.cos(math.pi/6) + self.assertLines(big.Edges, False, [Vector(0, y, 0), Vector(-x, -y/2, 0), Vector(x, -y/2, 0), Vector(0, y, 0)]) + + wire = PathChamfer.offsetWire(big, obj.Shape, 5, True) + self.assertIsNotNone(wire) + self.assertEqual(6, len(wire.Edges)) + lastAngle = None + refAngle = math.pi / 3 + for e in wire.Edges: + if Part.Circle == type(e.Curve): + self.assertRoughly(5, e.Curve.Radius) + self.assertCoincide(Vector(0, 0, -1), e.Curve.Axis) + else: + self.assertRoughly(34.641, e.Length, 0.001) + begin = e.Vertexes[0].Point + end = e.Vertexes[1].Point + v = end - begin + angle = PathGeom.getAngle(v) + if PathGeom.isRoughly(0, angle) or PathGeom.isRoughly(math.pi, math.fabs(angle)): + if lastAngle: + self.assertRoughly(-refAngle, lastAngle) + elif PathGeom.isRoughly(+refAngle, angle): + if lastAngle: + self.assertRoughly(math.pi, math.fabs(lastAngle)) + elif PathGeom.isRoughly(-refAngle, angle): + if lastAngle: + self.assertRoughly(+refAngle, lastAngle) + else: + self.assertIsNone("%s: angle=%s" % (type(e.Curve), angle)) + lastAngle = angle + f = Part.Face(wire) + self.assertCoincide(Vector(0, 0, -1), f.Surface.Axis) + + def test21(self): + '''Check offsetting a cylinder.''' + obj = doc.getObjectsByLabel('circle-cut')[0] + + wire = PathChamfer.offsetWire(getWire(obj.Tool), getPositiveShape(obj), 3, True) self.assertEqual(1, len(wire.Edges)) edge = wire.Edges[0] self.assertCoincide(Vector(), edge.Curve.Center) @@ -68,7 +272,7 @@ class TestPathChamfer(PathTestUtils.PathTestBase): self.assertRoughly(33, edge.Curve.Radius) # the other way around everything's the same except the axis is negative - wire = PathChamfer.offsetWire(getWire(obj), getPositiveShape(obj), 3, False) + wire = PathChamfer.offsetWire(getWire(obj.Tool), getPositiveShape(obj), 3, False) self.assertEqual(1, len(wire.Edges)) edge = wire.Edges[0] self.assertCoincide(Vector(), edge.Curve.Center) @@ -76,11 +280,11 @@ class TestPathChamfer(PathTestUtils.PathTestBase): self.assertRoughly(33, edge.Curve.Radius) - def test02(self): + def test22(self): '''Check offsetting a box.''' - obj = self.square + obj = doc.getObjectsByLabel('square-cut')[0] - wire = PathChamfer.offsetWire(getWire(obj), getPositiveShape(obj), 3, True) + wire = PathChamfer.offsetWire(getWire(obj.Tool), getPositiveShape(obj), 3, True) self.assertEqual(8, len(wire.Edges)) self.assertEqual(4, len([e for e in wire.Edges if Part.Line == type(e.Curve)])) self.assertEqual(4, len([e for e in wire.Edges if Part.Circle == type(e.Curve)])) @@ -92,22 +296,20 @@ class TestPathChamfer(PathTestUtils.PathTestBase): self.assertEqual(60, e.Length) if Part.Circle == type(e.Curve): self.assertRoughly(3, e.Curve.Radius) - # As it turns out the arcs are oriented the wrong way - self.assertCoincide(Vector(0, 0, +1), e.Curve.Axis) - - - wire = PathChamfer.offsetWire(getWire(obj), getPositiveShape(obj), 3, False) - self.assertEqual(8, len(wire.Edges)) - self.assertEqual(4, len([e for e in wire.Edges if Part.Line == type(e.Curve)])) - self.assertEqual(4, len([e for e in wire.Edges if Part.Circle == type(e.Curve)])) - for e in wire.Edges: - if Part.Line == type(e.Curve): - if PathGeom.isRoughly(e.Vertexes[0].Point.x, e.Vertexes[1].Point.x): - self.assertEqual(40, e.Length) - if PathGeom.isRoughly(e.Vertexes[0].Point.y, e.Vertexes[1].Point.y): - self.assertEqual(60, e.Length) - if Part.Circle == type(e.Curve): - self.assertRoughly(3, e.Curve.Radius) - # As it turns out the arcs are oriented the wrong way self.assertCoincide(Vector(0, 0, -1), e.Curve.Axis) + # change offset orientation + wire = PathChamfer.offsetWire(getWire(obj.Tool), getPositiveShape(obj), 3, False) + self.assertEqual(8, len(wire.Edges)) + self.assertEqual(4, len([e for e in wire.Edges if Part.Line == type(e.Curve)])) + self.assertEqual(4, len([e for e in wire.Edges if Part.Circle == type(e.Curve)])) + for e in wire.Edges: + if Part.Line == type(e.Curve): + if PathGeom.isRoughly(e.Vertexes[0].Point.x, e.Vertexes[1].Point.x): + self.assertEqual(40, e.Length) + if PathGeom.isRoughly(e.Vertexes[0].Point.y, e.Vertexes[1].Point.y): + self.assertEqual(60, e.Length) + if Part.Circle == type(e.Curve): + self.assertRoughly(3, e.Curve.Radius) + self.assertCoincide(Vector(0, 0, +1), e.Curve.Axis) + diff --git a/src/Mod/Path/PathTests/test_chamfer.fcstd b/src/Mod/Path/PathTests/test_chamfer.fcstd index 2d09689fa8..1713002a54 100644 Binary files a/src/Mod/Path/PathTests/test_chamfer.fcstd and b/src/Mod/Path/PathTests/test_chamfer.fcstd differ