Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 104 additions & 15 deletions cadquery/occ_impl/shapes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5496,6 +5496,19 @@ def _compound_or_shape(s: TopoDS_Shape | Sequence[TopoDS_Shape]) -> Shape:
return rv


def _compound(s: TopoDS_Shape | Sequence[TopoDS_Shape]) -> Compound:
"""
Convert a list of TopoDS_Shape to a Compound.
"""

if isinstance(s, TopoDS_Shape):
rv = Compound.makeCompound([_normalize(Shape.cast(s))])
else:
rv = Compound.makeCompound([_normalize(Shape.cast(el)) for el in s])

return rv


def _shape(s: TopoDS_Shape, _: type[T]) -> T:
"""
Cast a TopoDS_Shape to a Shape of the specfied type.
Expand Down Expand Up @@ -5805,6 +5818,54 @@ def _combine_ops(op: Op, *ops: Op) -> Op:
return op


def _apply_reshape(op: Op, ctx: ShapeBuild_ReShape) -> Op:
"""
Apply (if applicable) additional ReShape history to an exisitn Op. Used by solid.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: "exisitn" -> "existing"

"""

hist = ctx.History()

if hist.HasModified():

# update modified
for key, val in op._modified.items():

processed = []

for subshape in val:
modified = hist.Modified(subshape.wrapped)

if modified:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like an empty OCP list is True so this line should be changed to if not modified.IsEmpty(): or reverse the branches and check if modified.IsEmpty().

processed.extend([Shape.cast(el) for el in modified])
else:
processed.append(subshape)

op._modified[key] = compound(processed)

# update images
for key, val in op._images.items():

mod = hist.Modified(val.wrapped)
if not mod.IsEmpty():
op._images[key] = Shape.cast(mod.First())

return op


def _polish_images(op: Op, s: Shape) -> Op:
"""
Workaround for Reshape context not tracking (face, face) relations when fixing solids.
"""

face_dict = {f: f for f in s.Faces()}

for k, v in op._images.items():
if isinstance(k, Face):
op._images[k] = compound([face_dict[f] for f in v.Faces()])

return op


class History:
"""
Operation history.
Expand Down Expand Up @@ -5940,7 +6001,7 @@ def _update_history(

if has_modifidied:
try:
mod = _compound_or_shape(list(builder.Modified(wrapped)))
mod = _compound(list(builder.Modified(wrapped)))
if mod:
if el in op._modified:
op._modified[el] |= mod
Expand Down Expand Up @@ -5983,12 +6044,12 @@ def _remap_history_values(history: History | None, aux: History,) -> None:

# handle last shape
last_op._last_shape = compound(
[last_aux._modified.get(el, el) for el in last_op._last_shape]
[_normalize(last_aux._modified.get(el, el)) for el in last_op._last_shape]
)

# handle first shape
last_op._first_shape = compound(
[last_aux._modified.get(el, el) for el in last_op._first_shape]
[_normalize(last_aux._modified.get(el, el)) for el in last_op._first_shape]
)


Expand Down Expand Up @@ -6365,25 +6426,49 @@ def solid(
Build solid from faces or shells.
"""

ctx = ShapeBuild_ReShape()
builder = ShapeFix_Solid()
builder.SetContext(ctx)

# get both Shells and Faces
s = [s1, *sn]
shells_faces = [f for el in s for f in _get(el, (Shell, Face))]
# try to collect shells
shells = [el.wrapped for el in shells_faces if isinstance(el, Shell)]

# if no shells are present, use faces to construct them
shells = [el.wrapped for el in shells_faces if isinstance(el, Shell)]
if not shells:
faces = [el for el in shells_faces if isinstance(el, Face)]
shells = [
tcast(
TopoDS_Shell, shell(*faces, tol=tol, history=history, name=name).wrapped
rvs = [
builder.SolidFromShell(
TopoDS.Shell(shell(*faces, tol=tol, history=history, name=name).wrapped)
)
]

rvs = [builder.SolidFromShell(sh) for sh in shells]
if history:
# NB: reusing the history from shell()
_apply_reshape(history.ops[-1], ctx)

rv = tcast(Compound | Solid, _compound_or_shape(rvs))

return tcast(Compound | Solid, _compound_or_shape(rvs))
# otherwise construct solids with provided shells
else:
rvs = [builder.SolidFromShell(sh) for sh in shells]
rv = tcast(Compound | Solid, _compound_or_shape(rvs))

if history:
# update history - this is likely a noop for this branch
_update_history(history, name, shells_faces, ctx.History())
# update images by hand
op = history.ops[-1]
for el in op._tracked:
if isSubshape(el, rv):
op._images[el] = el

if history:
_polish_images(history.ops[-1], rv)

return rv


@multidispatch
Expand All @@ -6401,8 +6486,6 @@ def solid(
builder = BRepBuilderAPI_MakeSolid()
builder.Add(_get_one(shell(*s, tol=tol, history=history, name=name), Shell).wrapped)

n_inner = 0

if inner:
for sh in _get(shell(*inner, tol=tol, history=history), Shell):
builder.Add(sh.wrapped)
Expand All @@ -6414,12 +6497,18 @@ def solid(
sf.SetContext(ctx)
sf.Perform()

rv = _shape(sf.Solid(), Solid)

# combine histories of all shell operations if needed
if history and inner:
inner_op = history.pop()
_combine_ops(history.ops[-1], inner_op)
if history:
if inner:
inner_op = history.pop()
_combine_ops(history.ops[-1], inner_op)

_apply_reshape(history.ops[-1], ctx)
_polish_images(history.ops[-1], rv)

return _shape(sf.Solid(), Solid)
return rv


@multimethod
Expand Down
88 changes: 87 additions & 1 deletion tests/test_free_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
_combine_hist_dict,
enclose,
split,
_compound,
)

from OCP.BOPAlgo import BOPAlgo_CheckStatus
Expand Down Expand Up @@ -187,6 +188,15 @@ def test__shape_to_faces_shells():
_shape_to_faces_shells(vertex(0, 0, 0).wrapped)


def test__compound(box_shape):

res1 = _compound(box_shape.wrapped)
assert isinstance(res1, Compound)

res2 = _compound([box_shape.wrapped])
assert isinstance(res2, Compound)


# %% constructors


Expand Down Expand Up @@ -1447,7 +1457,7 @@ def test_history_offset():
assert sides.edges().size() == 4


def test_comibine_hist_dict():
def test_combine_hist_dict():

f = plane(1, 1)
v = vertex(0, 0, 0)
Expand Down Expand Up @@ -1562,6 +1572,82 @@ def _check(op, edges):
_check(op, edges)


@fixture
def nested_spheres() -> tuple[Solid, Solid]:

return sphere(0.5), sphere(0.25)


@fixture
def tiny_edge_box_faces() -> list[Face]:
"""
Faces of a box with one face manipulated to contain a tiny edge.
"""

faces = box(1, 1, 1).Faces()

edges = faces[0].outerWire().Edges()
edge = edges[0]

start, end = edge.startPoint(), edge.endPoint()
sliver_end = start + (end - start) * 1e-6

faces[0] = face(
wire(segment(start, sliver_end), segment(sliver_end, end), *edges[1:])
)

return faces


def test_solid_history(nested_spheres, tiny_edge_box_faces):

# helper to check if images are subshapes and have correct orientation
def check_faces_helper(faces, op, s):

for f in faces:
assert isSubshape(op.images(f), s)
assert any(f_res.isEqual(op.images(f)) for f_res in s.Faces())

h = History()

sphere_outer, sphere_inner = nested_spheres

# regular case
face_outer = sphere_outer.face()
face_inner = sphere_inner.face()

s1 = solid([face_outer], inner=[face_inner], history=h)

check_faces_helper((face_outer, face_inner), h[-1], s1)

# manipulated orientation case
face_outer = sphere_outer.face().reverse()
face_inner = sphere_inner.face()

s2 = solid([face_outer], inner=[face_inner], history=h)

check_faces_helper((face_outer, face_inner), h[-1], s2)

# another manipulated orientation case
s3 = solid(face_outer, history=h)

check_faces_helper((face_outer,), h[-1], s3)

# manipulated edges case
s4 = solid(*tiny_edge_box_faces, history=h)

check_faces_helper(tiny_edge_box_faces, h[-1], s4)

# manipulated edges case
s5 = solid(tiny_edge_box_faces, history=h)

check_faces_helper(tiny_edge_box_faces, h[-1], s5)

# solid from shells
s6 = solid(sphere_outer.shell(), history=h)
check_faces_helper(sphere_outer.Faces(), h[-1], s6)


def test_hlr():

s1 = box(1, 1, 1)
Expand Down