CadQuery / cadquery

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

How to cut a lip on a case seam? #684

Closed fedorkotov closed 3 years ago

fedorkotov commented 3 years ago

What is the proper way of making lips on a case seam like this? image Note that there is a gap between lips of bottom and top half that is necessary to account for 3D printing inaccuracies.

Below I describe how I do it. Maybe there is a better way? I thought about sweeping lip cross section along seam boundary but did not find a way to do that without manually redrawing sweeping path which would be tedious for anything but a simple box with sharp corners. Or maybe someone knows a library that does this automatically?

My method is

  1. Create closed case shell image
  2. Cut it into overlapping halves
    image
  3. Manually create "instrument" bodies to cut half (or slightly more) of overlapping parts to produce a lip image

(see code below)

image

def get_inner_volume(wp):
    return\
      wp\
      .rect(50,50)\
      .extrude(50)\
      .edges()\
      .fillet(10)      

def split_bottom(
      wp,
      cut_zoffset):
    return\
        wp\
        .workplane(cut_zoffset)\
        .split(keepBottom=True)

def split_top(
      wp,
      cut_zoffset):
    return\
        wp\
        .workplane(cut_zoffset)\
        .split(keepTop=True)        

def z_slice(
        wp,
        min_z_offset,
        max_z_offset):

    delta_z = \
        max_z_offset-min_z_offset

    # two calls to split_top
    # because faces(..).workplane()
    # changes Z orientation

    return \
        split_top(
            split_top(
                wp,
                min_z_offset)\
              .faces("<Z"),
            -delta_z)

def filled_shell(wp, thickness):
    return \
        get_inner_volume(wp)\
        .union(
            get_inner_volume(wp)
            .shell(thickness))

def get_case_bottom(wp):
    return\
      split_bottom(
        get_inner_volume(wp)\
        .shell(2),
        1)

def get_case_top(wp):
    return \
    split_top(
        get_inner_volume(wp)\
        .shell(2),
        -1)

def get_lip_cutter_inner(wp):
    return\
        z_slice(
            filled_shell(
                wp,
                2*0.5+2*0.1),
            -1,
            1)

def get_lip_cutter_outer(wp):
    return \
        z_slice(
          filled_shell(
            wp,
            2*0.5-2*0.1)\
          .shell(2),
          -1,
          1)

inner_volume = \
    get_inner_volume(
        cq.Workplane("XY"))

bottom_part = \
    get_case_bottom(
        cq.Workplane("XY")\
          .move(70*1, -70*0.5))

lip_cutter_inner = \
    get_lip_cutter_inner(
       cq.Workplane("XY")\
         .move(70*1, -70*0.5))

top_part = \
    get_case_top(
        cq.Workplane("XY")\
        .move(70*1, 70*0.5))

lip_cutter_outer = \
    get_lip_cutter_outer(
       cq.Workplane("XY")\
         .move(70*1, 70*0.5))

bottom_part_with_lip = \
    get_case_bottom(
        cq.Workplane("XY")\
          .move(70*2, -70*0.5))\
    .cut(
        get_lip_cutter_inner(
           cq.Workplane("XY")\
             .move(70*2, -70*0.5)))

top_part_with_lip = \
    get_case_top(
        cq.Workplane("XY")\
          .move(70*2, 70*0.5))\
    .cut(
        get_lip_cutter_outer(
           cq.Workplane("XY")\
             .move(70*2, 70*0.5)))

cut1 = \
    get_case_bottom(
        cq.Workplane("XY")\
          .move(70*3, 0))\
    .cut(
        get_lip_cutter_inner(
           cq.Workplane("XY")\
             .move(70*3, 0)))\
    .faces(">Z")\
    .workplane(centerOption='CenterOfBoundBox')\
    .transformed(
        rotate=(0.,90.,0))\
    .split(keepBottom=True)

cut2 = \
    get_case_top(
        cq.Workplane("XY")\
          .move(70*3, 0))\
    .cut(
        get_lip_cutter_outer(
           cq.Workplane("XY")\
             .move(70*3, 0)))\
    .faces("<Z")\
    .workplane(centerOption='CenterOfBoundBox')\
    .transformed(
        rotate=(0.,90.,0))\
    .split(keepBottom=True)
jmwright commented 3 years ago

You could try selecting the wires and using offset2d to create the lip.

fedorkotov commented 3 years ago

Thank you @jmwright. I forgot about offset2D.

For this experiment I modified the case shape from my previous example to make it non-convex.

def get_case_bottom(wp):
    return\
      split_bottom(
        get_inner_volume(wp)
        .union(get_inner_volume(wp.move(25,25)))\
        .shell(2),
        0)

image

Cutting lip with ridge on the outer side of case seam by removing material was easy:

result = \ cut_inner_lip( get_case_bottom( cq.Workplane("XY")), 0.9)

![image](https://user-images.githubusercontent.com/1106650/110837862-d38db080-82b2-11eb-846f-99041b81ef55.png)

But what if I want to add the ridge material without removing part of existing wall?
And how do I make a ridge on the inner side of the seam surface?

**For some reason I can not extrude or cut with one of the top face wires. Their 2D offsets can be cut or extruded but the wires themselves can not.** For example I tried to 
- select top outer wire and extrude it in positive Z direction to create a temporary "lid"
- select new top outer wire, create 2D offset and cut it in negative Z direction to remove most of the "lid" so that only outer ridge remains

```python
def add_outer_lip(wp, width):
    return\
    wp\
    .faces(">Z")\
    .wires()\
    .item(0)\
    .toPending()\
    .workplane()\
    .extrude(1)\
    .faces(">Z")\
    .wires()\
    .item(0)\
    .toPending()\
    .workplane()\
    .offset2D(-width)\
    .cutBlind(-1)

result = \
    add_outer_lip(
        get_case_bottom(
            cq.Workplane("XY")),
        0.9)

This code fails at extrude(1) with error Standard_NullObject: BRep_Tool:: TopoDS_Vertex hasn't gp_Pnt See full code of minimal example at the end of this post

For some reason if I replace extrude(1) with twistExtrude(1, 1) it works. image I do not need a skewed lip but this fact may may help someone in debugging.

Same thing with simpler geometry works

result = \
    cq.Workplane("XY")\
    .rect(50,50)\
    .extrude(50)\
    .faces(">Z")\
    .shell(2)\
    .faces(">Z")\
    .wires()\
    .item(1)\
    .toPending()\
    .workplane()\
    .extrude(1)\
    .faces(">Z")\
    .wires()\
    .item(0)\
    .toPending()\
    .workplane()\
    .offset2D(-1)\
    .cutBlind(-1)

image

Full code of minimal failing example def get_inner_volume(wp): return\ wp\ .rect(50,50)\ .extrude(50)\ .edges()\ .fillet(10) def split_bottom( wp, cut_zoffset): return\ wp\ .workplane(cut_zoffset)\ .split(keepBottom=True) def get_case_bottom(wp): return\ split_bottom( get_inner_volume(wp) .union(get_inner_volume(wp.move(25,25)))\ .shell(2), 0) def add_outer_lip(wp, width): return\ wp\ .faces(">Z")\ .wires()\ .item(0)\ .toPending()\ .workplane()\ .extrude(1)\ .faces(">Z")\ .wires()\ .item(0)\ .toPending()\ .workplane()\ .offset2D(-width)\ .cutBlind(-1) result = \ add_outer_lip( get_case_bottom( cq.Workplane("XY")), 0.9)
adam-urbanczyk commented 3 years ago

Can you paste the minimal example with correct formatting?

fedorkotov commented 3 years ago

Sorry. Did not notice that details tag has garbled it. minimal-example.py.txt

adam-urbanczyk commented 3 years ago

Your minimal example works for me and results in the following model: obraz

fedorkotov commented 3 years ago

Hmm. Probably this was fixed recently. I checked again on another machine and got the same error. I recreated conda environment and re-installed cadquery and cq-editor. Now it works.

And creating inner ridge works too with the following code (full source code attached)

def add_inner_lip(wp, width):
    return\
    wp\
    .faces(">Z")\
    .wires()\
    .item(0)\
    .toPending()\
    .workplane()\
    .offset2D(-width)\
    .extrude(1)\
    .faces(">Z[-2]")\
    .wires()\
    .item(2)\
    .toPending()\
    .workplane()\
    .cutBlind(1)

result = \
    add_inner_lip(
        get_case_bottom(
            cq.Workplane("XY")),
        0.9)

image

Using Offset2D is better than my initial approach but I have a new related question. How do I select the innermost or outermost wire of a non-convex face(s) of irregular shape? image Box selection will not do. Now I use .item(..) as you can see in code examples in this and previous posts. But this is not a robust method. There are no guaranties about wire ordering (or are there?).

inner_lip.py.txt

marcus7070 commented 3 years ago

This looks like a pretty cool project @fedorkotov!

I don't have time to fully read and digest your code, so I might be missing a better solution, but for:

How do I select the innermost or outermost wire of a non-convex face(s) of irregular shape?

Note that there is a method Wire.Length that might help you.

w = (
    # make 2 unioned boxes
    cq.Workplane()
    .box(10, 10, 2)
    .union(
        cq.Workplane()
        .box(10, 10, 2, centered=(False, False, True))
    )
    # shell and remove the top face
    .faces(">Z")
    .shell(-0.5, "intersection")
    # select the two wires from the top face
    .faces(">Z")
    .wires()
)
wires_sorted = w.vals()
wires_sorted.sort(key=lambda x: x.Length())
# select just the outer wire and fillet that edge
w = w.newObject([wires_sorted[1]]).fillet(0.4)

screenshot2021-03-12-205227

@fedorkotov's code here is another use case for #646.

fedorkotov commented 3 years ago

Method of ordering by length proposed by @marcus7070 sounds cool and easy to implement but it is based on the following implicit assumption

For two continuous closed 2D curves L_1, L_2 without self-intersections and not intersecting each other ((L_1 is inside L_2) or (L_2 is inside L_1)) and (Length(L_2) < Length(L_1)) ==> (L_2 is inside L_1) (assume any sane definition of "is inside")

Obviously this is not true for all curves. Counterexample is easy to construct. image Here the longer curve is inside the sorter one.

Probably it is true for the curves of interest but it is not obvious (to me) what additional assumptions about L_1 and L_2 are required to prove it.

A better criterion would be area bounded by the curve. Proposition analogous to above for areas instead of lengths is easy to prove. But how do I calculate said areas in cadquery?

jmwright commented 3 years ago

@fedorkotov

The following converts an arbitrary set of wires to a face and gets the area from that. It uses log instead of print because I wrote it in CQ-editor. Note that I'm stepping down to the layer below the fluent API. The other devs might think of a way to do it through the fluent API, I'm not sure.

import cadquery as cq

result = cq.Workplane().rect(10, 10)

face = cq.Face.makeFromWires(result.wires().val())

log(face.Faces()[0].Area()) # logs out 99.99999999999
adam-urbanczyk commented 3 years ago

@fedorkotov check Face.outerWire and Face.innerWires - I think it is exactly what you want.

fedorkotov commented 3 years ago

@jmwright , @adam-urbanczyk thank you for suggestions. I will check them out over the weekend and will share my results

fedorkotov commented 3 years ago

Face.makeFromWires(..) proposed by @jmwright is better in this case than Face.outerWire/Face.innerWires proposed by @adam-urbanczyk because Face.outerWire/Face.innerWires can not select innermost or outermost wire when I have more than one face selected (see inner lip example earlier in this issue)

I wrote a selector that helps select wire with nth area.

class WireWithNthAreaSelector(cq.Selector):
    def __init__(
            self,
            idx):
        self._idx = idx

    def filter(self, objectlist: Sequence[cq.Wire]) -> List[cq.Wire]:
        if len(objectlist) == 0:
            # nothing to filter
            raise ValueError(
              "Can not return the Nth nested wire of an empty list")
        if(len(objectlist) <= self._idx or
           self._idx < -len(objectlist)):
            raise IndexError(
              "Can not return wire with index {} from list with {} wires"\
              .format(self._idx, len(objectlist)))

        wires_with_areas = \
            [(wire, 
                cq.Face.makeFromWires(wire).Area()) 
              for wire in objectlist]

        wires_sorted_by_area = \
            sorted(
              wires_with_areas,
              key=lambda w_data: w_data[1])

        selected_wire, _ = \
            wires_sorted_by_area[self._idx]

        return [selected_wire]

this is how I used it to add inner and outer lips

def add_inner_lip(wp, width):
    return \
        wp\
        .faces(">Z")\
        .wires(WireWithNthAreaSelector(-1))\
        .toPending()\
        .workplane()\
        .offset2D(-width)\
        .extrude(1)\
        .faces(">Z[-2]")\
        .wires(WireWithNthAreaSelector(0))\
        .toPending()\
        .workplane()\
        .cutBlind(1)

def add_outer_lip(wp, width):
    return\
    wp\
    .faces(">Z")\
    .wires(WireWithNthAreaSelector(-1))\
    .toPending()\
    .workplane()\
    .extrude(1)\
    .faces(">Z")\
    .wires()\
    .item(0)\
    .toPending()\
    .workplane()\
    .offset2D(-width)\
    .cutBlind(-1)

Full source code is attached below image

Many thanks to everyone for your help. I think WireWithNthAreaSelector can be useful not only for this particular task but for many other uses. Should I create a pull-request?

adding-case-seam-lip-with-nth-area-selector.py.txt

adam-urbanczyk commented 3 years ago

Great, a PR would be nice! I will probably propose to generalize it into NthAreaSelector, but let's discuss in the PR.

adam-urbanczyk commented 3 years ago

688 is merged, can this be closed @fedorkotov ? If so, maybe consider adding an example to cq-contrib?

fedorkotov commented 3 years ago

Yes it can be closed. I'm working on project now. It is a case for USB-flash like device which will use new selectors. Unfortunately it is not going as fast as I would like because of day job. But I intend to submit it to cq-contrib when it is ready.