CadQuery / cadquery

A python parametric CAD scripting framework based on OCCT
https://cadquery.readthedocs.io
Other
3.21k stars 292 forks source link

cutting a groove #619

Open mbway opened 3 years ago

mbway commented 3 years ago

What would be the best way to go about cutting a groove into an arbitrary solid? (to create an annular snap-fit joint)

My first thought was to create a copy of the solid, slice it in half along the desired groove location by intersecting it with a large box which contains one half of the solid and selecting the edge of the bottom face of the intersection. Now I have a wire that perfectly follows the contour of the solid. What I would like to do now is to sweep a sphere along this path and cut away from the original solid with it.

I've looked through all the API documentation and examined all the examples but I can't find anything that is similar to what I'm trying to do. Creating a sphere and then calling sweep simply gives list index out of range. I know this probably means it's looking for 2d shapes on the stack and not finding any, but this error message is not particularly useful as it's too generic.

I tried to sweep a circle rather than a sphere the result takes a very long time to compute and is not correct. I think that right now that the swept shape doesn't stay tangent/parallel to the curve being swept through and I can't find any way to achieve this. I think it's possible to create a donut shape in cadquery by revolving a circle but I'm not looking for a circular donut shape.

image

import cadquery as cq
print(cq.__version__)  # 2.1
path = cq.Workplane('XY').ellipse(5, 7)
tube = cq.Workplane('YZ').circle(0.5).sweep(path)
show_object(path, options={'alpha': 0.5})
show_object(tube, options={'alpha': 0.5})

for now I think another approach could be to intersect a thin box and the solid then create a shell of the intersection and cut that away from the original solid but I don't like this approach as much.

mbway commented 3 years ago

a more complex hypothetical example:

import cadquery as cq

obj = (cq.Workplane('XY')
    .circle(10)
    .workplane(20).rect(5, 7)
    .loft()
)
show_object(obj, options={'alpha': 0.5})

selector = (cq.Workplane('XY')
    .box(15, 20, 20, centered=(False, False, False))
    .rotate((0, 0, 0), (0, 1, 0), -70)
    .translate((10, -10, 10))
)
show_object(selector, options={'alpha': 0.9})

path = obj.intersect(selector).faces('<Z').edges()
tube = cq.Workplane('XZ').circle(0.5).sweep(path)
show_object(path, options={'alpha': 0.5})
show_object(tube, options={'alpha': 0.5})

image

marcus7070 commented 3 years ago

So I've gotten a bit closer, but I've got an error I don't understand in the final sweep, perhaps because the path has some sharp corners?

import cadquery as cq

obj = (cq.Workplane('XY')
    .circle(10)
    .workplane(20).rect(5, 7)
    .loft()
)
show_object(obj, "obj", options={'alpha': 0.5})

selector = (cq.Workplane('XY')
    .box(15, 20, 20, centered=(False, False, False))
    .rotate((0, 0, 0), (0, 1, 0), -70)
    .translate((10, -10, 10))
)
# show_object(selector, options={'alpha': 0.9})

path = obj.intersect(selector).faces('<Z').edges() #.toPending().wire()
show_object(path, "path", options={'alpha': 0.5})

plane = cq.Plane(
    path.val().startPoint(),
    cq.Vector(0, 0, 1),
    path.val().tangentAt(0),
)
tube = cq.Workplane(plane).circle(0.5).sweep(path)
show_object(tube, "tube", options={'alpha': 0.5})

screenshot2021-02-06-065818

Uncommenting that toPending().wire() results in Standard_RangeError:NCollection_Array1::Create.

marcus7070 commented 3 years ago

That error goes away if you loft between two circles.

import cadquery as cq

obj = (cq.Workplane('XY')
    .circle(10)
    .workplane(20).circle(5) #.rect(5, 7)
    .loft()
)
show_object(obj, "obj", options={'alpha': 0.5})

selector = (cq.Workplane('XY')
    .box(15, 20, 20, centered=(False, False, False))
    .rotate((0, 0, 0), (0, 1, 0), -70)
    .translate((10, -10, 10))
)
# show_object(selector, options={'alpha': 0.9})

path = obj.intersect(selector).faces('<Z').edges().toPending()
show_object(path, "path", options={'alpha': 0.5})

plane = cq.Plane(
    path.val().startPoint(),
    cq.Vector(0, 0, 1),
    path.val().tangentAt(0),
)
tube = cq.Workplane(plane).circle(0.5).sweep(path, clean=False)
show_object(tube, "tube", options={'alpha': 0.5})

screenshot2021-02-06-071949

Still not sure if it's a kernel error or if I'm not sweeping a multiple edge wire correctly.

marcus7070 commented 3 years ago

I had another go, still got the same error. Here is as close as I could get:

import cadquery as cq

obj = (cq.Workplane('XY')
    .circle(10)
    .workplane(20).rect(5, 7)
    .loft()
)

selector = (cq.Workplane('XY')
    .box(15, 20, 20, centered=(False, False, False))
    .rotate((0, 0, 0), (0, 1, 0), -70)
    .translate((10, -10, 10))
)
# show_object(selector, options={'alpha': 0.9})

path = obj.intersect(selector).faces('<Z').edges()
show_object(path, "path", options={'alpha': 0.5})
paths = path.vals()
# check that these edges are in order and all join to the next edge
path_check_list = paths.copy()
path_check_list.append(path_check_list[0])
for p0, p1 in zip(path_check_list[:-1], path_check_list[1:]):
    assert p0.endPoint() == p1.startPoint()

# despite the wire looking reasonable, the sweep still errors
# so do each cut individually

for p in paths:
    plane_z_dir = p.tangentAt(0)
    plane_x_dir = plane_z_dir.cross(cq.Vector(0, 1, 0))
    assert plane_z_dir.dot(plane_x_dir) < 1e-4  # ie. orthogonal

    plane = cq.Plane(
        p.positionAt(0),
        plane_x_dir,
        plane_z_dir,
    )
    tube = cq.Workplane(plane).circle(0.5).sweep(cq.Workplane(p).toPending())
    # show_object(tube)
    obj = obj.cut(tube)

show_object(obj, "obj", options={'alpha': 0.5})

screenshot2021-02-08-120147

adam-urbanczyk commented 3 years ago

This looks fine I think:

import cadquery as cq

obj = (cq.Workplane('XY')
    .circle(10)
    .workplane(20).rect(5, 7)
    .loft()
)
show_object(obj, "obj", options={'alpha': 0.5})

selector = (cq.Workplane('XY')
    .box(15, 20, 20, centered=(False, False, False))
    .rotate((0, 0, 0), (0, 1, 0), -70)
    .translate((10, -10, 10))
)
# show_object(selector, options={'alpha': 0.9})

path = obj.intersect(selector).faces('<Z').wires()
show_object(path, "path", options={'alpha': 0.5})

plane = cq.Plane(
    path.val().startPoint(),
    cq.Vector(0, 0, 1),
    path.val().tangentAt(0),
)

tube = cq.Workplane(plane).circle(0.5).sweep(path,transition='round')
show_object(tube, "tube", options={'alpha': 0.5})

result = obj.cut(tube)
show_object(result, "result")

Though I had issues with bigger radii.

adam-urbanczyk commented 3 years ago

Shorter version of the code, note the usage of section. The final cut is not working properly - I tag it for now as a kernel issue. Maybe it'll be solved in OCCT 7.5.

import cadquery as cq

obj = (cq.Workplane('XY')
    .circle(10)
    .workplane(20).rect(5, 7)
    .loft()
)

path = obj.transformed((10,0,0),(0,0,-10)).section().wires()

param=0.0
plane = cq.Plane(
    path.val().positionAt(param),
    cq.Vector(0, 0, 1),
    path.val().tangentAt(param),
)

tube = cq.Workplane(plane).circle(1).sweep(path,transition='round')

result = obj.cut(tube)
show_object(result, "result")
mbway commented 3 years ago

Thank you both for helping me with this. It's hard to know what is 'best practice' and techniques from only looking at the small number of examples and the API reference. It might be easier coming from interactive CAD software where cadquery simply performs operations you are already used to but I have no experience there either.

I didn't realize that when sweeping the workplane origin should be at the start point of the path. I expected that it would translate to where it needed to be. I also thought that the shape wouldn't stay tangent to the path because it didn't during my testing of a simple case(I was probably doing something wrong). Am I correct in thinking that the expected behavior is that the angle between the workplane and the tangent at the start of the path is maintained throughout the sweep?

I have experimented some more and found that for the code in the last comment, the cut sometimes does work if the plane origin is translated slight in y or z.

also I found that translating the swept shape has a different effect to positioning it at the origin and moving the workplane origin. The docs don't go into these sorts of details as far as I can see.

mbway commented 3 years ago

this might be a separate issue but I think section() isn't working on lofts (I tried for rects, circles and ellipses): when the sphere line is uncommented it prints 1 (section succeeded) but when calling section on the loft it prints 0

import cadquery as cq

obj = (cq.Workplane('XY')
    .rect(5, 7)
    .workplane(20)
    .rect(5, 7)
    .loft()
)
#obj = cq.Workplane('XY').sphere(10)
show_object(obj, options={'alpha': 0.6})

path = obj.section(2).edges()
print(len(path.objects))
show_object(path)
marcus7070 commented 3 years ago

I think your workplane origin is not where you think it is. I'll comment your code below:

import cadquery as cq

obj = (cq.Workplane('XY')
# workplane origin is at (0, 0, 0)
    .rect(5, 7)
    .workplane(20)
# workplane orign now at (0, 0, 20)
    .rect(5, 7)
    .loft()
)
#obj = cq.Workplane('XY').sphere(10)
show_object(obj, options={'alpha': 0.6})

path = (
    obj
    .section(2)
# take a section through (0, 0, 20 + 2), which is above the object
    .edges()
)
print(len(path.objects))
show_object(path)

.section() or a negative offset works in your code.

mbway commented 3 years ago

ok, that was stupid. I see what I did wrong. Is there a way to reset the origin of the workplane while keeping the current objects in place without keeping track of the current height and doing .workplane(offset=-height)? I tried doing .workplane(origin=(0, 0, 0)).section(2) but that seems to put the origin in the middle of the loft for some reason.

marcus7070 commented 3 years ago

ok, that was stupid. I see what I did wrong.

Don't worry about it. I can only spot that mistake so quickly because I've made it myself so many times.

Is there a way to reset the origin of the workplane

Once #464 gets done, I expect you'll be able to do something like:

path = obj.workplane('XY').section(2).edges()

In the meantime I would suggest referring back to a tag, like so:

import cadquery as cq

obj = (cq.Workplane('XY')
# workplane origin is at (0, 0, 0)
    .tag('base')
    .rect(5, 7)
    .workplane(20)
# workplane orign now at (0, 0, 20)
    .rect(5, 7)
    .loft()
)
#obj = cq.Workplane('XY').sphere(10)
show_object(obj, options={'alpha': 0.6})

path = (
    obj
    .workplaneFromTagged('base')
# workplane is now the same as when base was tagged, (0, 0, 0)
    .section(2)
# take a section through (0, 0, 0 + 2), which is in the object
    .edges()
)
print(len(path.objects))
show_object(path)

If you need even more flexibility in where you place the workplane, you could create a cq.Plane like we do in the previous comments.

marcus7070 commented 3 years ago

The final cut is not working properly - I tag it for now as a kernel issue. Maybe it'll be solved in OCCT 7.5.

@adam-urbanczyk, that wire starts and ends at a sharp corner. So the start and end of the swept solid self-intersect. Even though .isValid() returns True, I think that shape isn't correct and that's where the problem is. edit: removed a link to fuzzy cuts and intersect issue, that's got nothing to do with this

mbway commented 3 years ago

I tried calling clean() before cutting but that didn't help. As a work around, assuming that self-intersection was the issue I created a very thin box at the start/end point of the path and cut away at the tube before using it to cut and that did work (but obviously leaves a sliver un-cut).

I tried intersecting a shape around the seam then unioning that shape back in in the hopes that the geometry would be cleaned up in the process, but I can't even intersect the tube with anything around the seam because I get:

    tube.intersect(cutter)
  File "/home/matthew/anaconda3/envs/cadquery/lib/python3.8/site-packages/cadquery/cq.py", line 3029, in intersect
    newS = newS.clean()
  File "/home/matthew/anaconda3/envs/cadquery/lib/python3.8/site-packages/cadquery/occ_impl/shapes.py", line 347, in clean
    upgrader.Build()
OCP.Standard.Standard_NullObject: BRep_Tool:: TopoDS_Vertex hasn't gp_Pnt

here cutter is a box encompassing the seam and tube is a sweep in the shape of an ellipse with a single seam.

Are there any workarounds I can use to get cutting working properly without waiting for a new release?

marcus7070 commented 3 years ago

My first thoughts were to try splitting that path wire along a continuous edge (eg. where the wire crosses the x-axis) and rotate the order ofthe edges so the path wire starts and ends at that point. That way the tangent at the start and end of the wire is identical and the shape won't intersect.

marcus7070 commented 3 years ago

Oh, check out the way this person unions a sphere at the corners. Perhaps that might be a quick fix? https://stackoverflow.com/a/48491990

marcus7070 commented 3 years ago

Sorry, it appears I was wrong about the path wire starting and ending at a sharp corner. I was looking closely at the tube solid and could see a good corner:

screenshot2021-02-12-103308

and a bad corner:

screenshot2021-02-12-103338

and jumped to a conclusion.

The path wire already starts and ends on a continuous edge, so that's not the problem. Here I highlight the start point and also assert that the tangents are equal between the start and end point, so there should be no self intersection:

import cadquery as cq

obj = (cq.Workplane('XY')
    .circle(10)
    .workplane(20).rect(5, 7)
    .loft()
)

path = obj.transformed((10,0,0),(0,0,-10)).section().wires()

param=0.0
plane = cq.Plane(
    path.val().positionAt(param),
    cq.Vector(0, 0, 1),
    path.val().tangentAt(param),
)

show_object(cq.Workplane(
    origin=path.val().positionAt(param)).sphere(1),
    'path start',
    options={'color': 'red'}
)
assert path.val().tangentAt(0) == path.val().tangentAt(1)

tube = cq.Workplane(plane).circle(1).sweep(path,transition='round')

result = obj.cut(tube)
show_object(result, "result")

screenshot2021-02-12-104039

It does look like a kernel error.


However, cutting with a sphere and individual edges is working pretty well, I hope this is a suitable workaround for you:

import cadquery as cq

groove_radius = 1

obj = (cq.Workplane('XY')
    .circle(10)
    .workplane(20).rect(5, 7)
    .loft()
)

selector = (cq.Workplane('XY')
    .box(15, 20, 20, centered=(False, False, False))
    .rotate((0, 0, 0), (0, 1, 0), -70)
    .translate((10, -10, 10))
)

path = obj.intersect(selector).faces('<Z').edges()
show_object(path, "path", options={'alpha': 0.5})
paths = path.vals()

for p in paths:
    plane_z_dir = p.tangentAt(0)
    plane_x_dir = plane_z_dir.cross(cq.Vector(0, 1, 0))
    assert plane_z_dir.dot(plane_x_dir) < 1e-4  # ie. orthogonal

    plane = cq.Plane(
        p.positionAt(0),
        plane_x_dir,
        plane_z_dir,
    )
    tube = cq.Workplane(plane).circle(groove_radius).sweep(cq.Workplane(p).toPending())
    sphere = cq.Workplane('XY', origin = p.endPoint()).sphere(groove_radius)
    tube = tube.union(sphere)
    obj = obj.cut(tube)

show_object(obj, "obj", options={'alpha': 0.5})

screenshot2021-02-12-104339

mbway commented 3 years ago

unfortunately I had to make the sphere slightly larger than the tube for it to work (and the same additional radius would stop working if I changed the loft slightly) so still really buggy, but it's good enough. Thanks again for your help

adam-urbanczyk commented 3 years ago

NB: I opened #627 - it should to make the original approach of @marcus7070 work as intended.