gumyr / build123d

A python CAD programming library
Apache License 2.0
386 stars 72 forks source link

projecting to Plane does not work #563

Open Windfisch opened 4 months ago

Windfisch commented 4 months ago

Hi,

Calling project on the individual faces of a Box creates wrong results or crashes.

Reproducer

from build123d import *
from ocp_vscode import *

NUM=0
box = Location((0,0,10)) * Box(10,10,10)
face = box.faces()[NUM]
foo = project(face, workplane = Plane.XY)
show(box, foo, face)

Actual results

for NUM=0: bug0

for NUM=1: bug1

for NUM=2: bug2

for NUM = 3, 4 or 5, or for foo = project(box.faces(), workplane=Plane.XY):

(cadquery) flo@beastie ~/kruschkram/b3d-workspace $ /home/flo/bin/cadquery/bin/python /home/flo/kruschkram/b3d-workspace/bugs/project.py
Traceback (most recent call last):
  File "/home/flo/kruschkram/b3d-workspace/bugs/project.py", line 9, in <module>
    foo = project(face, workplane = Plane.XY)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/flo/bin/cadquery/lib/python3.11/site-packages/build123d/operations_generic.py", line 777, in project
    projection = obj.project_to_shape(target, projection_direction)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/flo/bin/cadquery/lib/python3.11/site-packages/build123d/topology.py", line 5982, in project_to_shape
    sewed_face_list = Face.sew_faces(intersected_faces)
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/flo/bin/cadquery/lib/python3.11/site-packages/build123d/topology.py", line 5578, in sew_faces
    sewed_shape = downcast(shell_builder.SewedShape())
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/flo/bin/cadquery/lib/python3.11/site-packages/build123d/topology.py", line 8054, in downcast
    f_downcast: Any = downcast_LUT[shapetype(obj)]
                                   ^^^^^^^^^^^^^^
  File "/home/flo/bin/cadquery/lib/python3.11/site-packages/build123d/topology.py", line 8104, in shapetype
    raise ValueError("Null TopoDS_Shape object")
ValueError: Null TopoDS_Shape object

Expected results

for NUM == 0, 1, 2, i.e. for all "sides" of the box that are parallel to the Z axis, I would expect a Line to be projected, but not a Quad three times the cube's size.

(Note that this is how project() works internally: It creates a quad three times the input's bounding box size, and projects everything onto that quad. Apparently it fails to do so, though)

for those sides that are the top or bottom of the cube, i would expect something similar to show(Rect(10,10)).

For the whole box.faces(), I would expect the same.

Windfisch commented 4 months ago

I've got another similar bug:

from build123d import *
from ocp_vscode import *

ZPOS=5
sphere = Location((0,0,ZPOS)) * Sphere(10)
sphere.color = "#88ff8888"
foo = project(sphere.faces(), workplane = Plane.XY)
show(foo, sphere)

yields

bugsphere_5

Changing ZPOS = 11 yields

Traceback (most recent call last):
  File "/home/flo/kruschkram/b3d-workspace/bugs/project.py", line 8, in <module>
    foo = project(sphere.faces(), workplane = Plane.XY)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/flo/bin/cadquery/lib/python3.11/site-packages/build123d/operations_generic.py", line 777, in project
    projection = obj.project_to_shape(target, projection_direction)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/flo/bin/cadquery/lib/python3.11/site-packages/build123d/topology.py", line 5982, in project_to_shape
    sewed_face_list = Face.sew_faces(intersected_faces)
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/flo/bin/cadquery/lib/python3.11/site-packages/build123d/topology.py", line 5578, in sew_faces
    sewed_shape = downcast(shell_builder.SewedShape())
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/flo/bin/cadquery/lib/python3.11/site-packages/build123d/topology.py", line 8054, in downcast
    f_downcast: Any = downcast_LUT[shapetype(obj)]
                                   ^^^^^^^^^^^^^^
  File "/home/flo/bin/cadquery/lib/python3.11/site-packages/build123d/topology.py", line 8104, in shapetype
    raise ValueError("Null TopoDS_Shape object")
ValueError: Null TopoDS_Shape object

Expected result:

The result of show(sphere, Circle(10)):

expected

gumyr commented 4 months ago

I'm quite confident that there is a bug in project lines 774 & 792 where the to_local_coords should be from_local_coords but haven't fully confirmed the change yet. The direction of projection needs to be calculated correctly which is the problem.

Getting perpendicular faces to return an Edge is going to be difficult to implement I expect - at least until the work on intersections is complete (OCCT doesn't handle intersections consistently - there are many difficult procedures that are need to cover all the possibilities).

@Windfisch You're welcome to look into the problem(s) further - any help would be appreciated.

Windfisch commented 4 months ago

Just looking into this.

It appears to me that project_to_shape works by extruding the face-to-be-projected, and intersecting this extrusion with the target face.

This has two issues:

  1. I find it surprising and inconvenient that this extrusion is only made into one direction, not bidirectionally. From a math perspective, a projection onto the XY plane should just strip away the Z coordinate of everything. It should not matter if a face-to-be-projected is in front of or behind the target (w.r.t to the direction). There should no be change in result if the direction is multiplied by -1.
  2. If the extruded face is an object with zero volume (e.g. when the extrusion direction is parallel with the planar face-to-be-extruded), then this intersection seems to return a wrong result. (zerovolume.intersect(target) returns just target, instead of an empty solid)

As for 1., I would like to fix this by always extruding bidirectionally. What do you think about this?

As for 2, i'm investigating what's going on there...

gumyr commented 4 months ago

I don't agree with 1) but there is no "correct" answer here. There could easily be part geometries where projecting in both directions would result in Faces on multiple sections of a part where only one is desired. This may require a potentially difficult selection process for the user as well as potentially exposing more OCCT problems. In addition, project with a center (which can only be in one direction) would be different than project with a direction which would be in two directions.

2) is a real problem. Unfortunately, intersect is quite complex as there are many different types of OCCT intersect classes/methods and the correct one needs to be used depending on the types of objects involved. I've started the Intersect Everything issue (https://github.com/gumyr/build123d/issues/328) but haven't completed the work. I don't doubt there are build123d issues here too, it's difficult to design and test for all circumstances. Any help is appreciated.

Windfisch commented 4 months ago

for 1), I agree that there is no "correct" answer; however, what build123d currently does is hard to predict and highly unintuitive: It's not like we're projecting to the first hit of the target face. Since we're extruding the face-to-be-projected by the diagonal of the bounding box that contains the face and the target, this could or could not hit the target face once, twice or more (with a wide but curved target face, for example).

If we had a clean "extrudes up to the first hit of the target face, but not more", I would agree, but IIUC we don't. Or am I wrong here?

for 2): it seems that the intersection operation (BRepAlgoApi_Common) is an operation that modifies its first argument. Or does not, in case an error occurred, which is likely the case here. And in that case, nothing is changed, leaving the old face in place.

The most obvious solution to me appears to check the HasErrors method... But it does not exist in the python bindings. @gumyr can you help me here?

Also, is there a way to return a valid but empty object? Or would that mess up subsequent operations too much?

Windfisch commented 4 months ago

a-ha! the missing HasError method is pulled in via using SomeProtectedBase::method from a protected base class, and is not defined as a bool normalMethod() { ... }. Maybe this confuses pybind?

But uh, that's definitely outside of my python-fu.

Edit: oh maybe this could help: https://github.com/CadQuery/OCP/blob/4b98a5dc79fa900f7429975708f6a8c2e41cecd1/ocp.toml#L596-L606

@gumyr at this point, i'd like to have a quick sanity check from your side :D

gumyr commented 4 months ago

If we had a clean "extrudes up to the first hit of the target face, but not more", I would agree, but IIUC we don't. Or am I wrong here? build123d does have an extrude_until but it is complex and slow and has similar limitations to the above.

Although using HasErrors would be convenient getting Adam to add it to the OCP API is likely to be difficult based on past experience - you're welcome to try though. Checking for empty objects is possible which would avoid this.

Which respect to the direction of projection, when using the project operation this is determined for the user. Consider the following example:

with BuildPart() as test1:
    Box(10, 10, 1)
    with BuildSketch(Plane.XY.offset(2)) as front:
        Text("F", font_size=8)
    p_f = project()
    thicken(amount=-0.1, mode=Mode.SUBTRACT)
    with BuildSketch(Plane.XY.offset(-2)) as back:
        Text("B", font_size=8)
    p_b = project()
    thicken(amount=-0.1, mode=Mode.SUBTRACT)

image image

However, as previously mentioned, there is a bug to be fixed.

gumyr commented 4 months ago

There are several issues here. The above commit fixes the problem when the source and target object were within 1 of each other.

The projection of sphere problem is a different problem and related to how OCCT's BRepPrimAPI_MakePrism works (or doesn't) for closed faces. When the sphere's face is extruded, a cylinder with domed ends is expected, but that is not what is generated - a shifted sphere + the original is the result. Thus the intersection fails in the gap. A new strategy is required for closed faces.

face_extruded = Solid.extrude((Pos(0, 0, 11) * Sphere(10)).face(), Vector(0, 0, -25))

image

gumyr commented 4 months ago

Here is a proposal for how to do projection of non-planar Faces or Solids better - the complexity is likely why OCCT has limited related functionality:

  1. visible, hidden = shape.project_to_viewport(viewport_origin=s.center() + projection_direction) to create a list of visible Edges on Plane.XY.
  2. These Edges share no common Vertex but do intersect:
      for e1, e2 in itertools.combinations(visible, 2):
          print(topo_explore_common_vertex(e1, e2))
          print(e1.intersect(e2).vertices())
  3. As the intersections may occur in the middle of an Edge, trim these Edges by the intersections to create a new list of Edges with a Vertex at every potential contact point.
  4. Create a Python set of all Edge Vertices (i.e. unique).
  5. Edit the Edges to replace potentially duplicate Vertices with ones from the Vertex set.
  6. Build Wires from all possible combinations of Edges by grouping Edges into ones that share a common Vertex and are closed. (edges_to_wires doesn't do this well enough). Edges that are not part of a closed Wire are discarded.
  7. Find the outer wires and their associated inner wires.
  8. Creating Faces from the outer_wire, inner_wires. Fuse them together.
  9. Reposition the fused Faces back to the original Location of the Shape to project.

These new Faces are then extruded and intersected with the target Shape as done currently. Extrude Until.NEXT could be used to avoid internal Faces however one has to account for the extrusion protruding past the boundary of the target object.

A potential alternative solution:

  1. Create the visible Edges as above.
  2. Position them where the original object was.
  3. Project each Edge onto the target Shape.
  4. Project points onto the target Shape to sample the internal structure of the target at that location.
  5. Rebuild a new non-planar shape from the projected Edges and internal points on the surface.

This would be an approximation of the target surface and could cause issues when combined with the target (close but not close enough?)

gumyr commented 3 months ago

I've made progress on a re-implementation of project_to_shape (see https://github.com/gumyr/build123d/blob/dev/src/build123d/topology.py#L6057) that will hopefully avoid the issues with the current algorithm but I've found there are a whole set of new problems. Happily the OCCT BRepFeat_SplitShape implements the functionality of steps 4 & 5 from the second list above which is great - which enables the isolation of projected faces as shown here: image

However, handling of self projection is more problematic. Although I've been able to project the visible edges of the target object onto the plane of the object to project, when these edges are projected back onto the target the result is a segmentation fault. This is required to handle the hole in the above part which is not appropriately clipped.

I'm now experimenting with dividing a face into "front" and "back" depending on the direction of the normal to aid in this self clipping problem.