Skip to content

Commit

Permalink
Adding full_round operation
Browse files Browse the repository at this point in the history
  • Loading branch information
gumyr committed Feb 14, 2024
1 parent 594e5f8 commit 745bb7d
Show file tree
Hide file tree
Showing 9 changed files with 348 additions and 4 deletions.
2 changes: 2 additions & 0 deletions docs/cheat_sheet.rst
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Cheat Sheet
| :func:`~operations_generic.add`
| :func:`~operations_generic.chamfer`
| :func:`~operations_generic.fillet`
| :func:`~operations_sketch.full_round`
| :func:`~operations_sketch.make_face`
| :func:`~operations_sketch.make_hull`
| :func:`~operations_generic.mirror`
Expand All @@ -98,6 +99,7 @@ Cheat Sheet
| :func:`~operations_part.extrude`
| :func:`~operations_generic.fillet`
| :func:`~operations_part.loft`
| :func:`~operations_part.make_brake_formed`
| :func:`~operations_generic.mirror`
| :func:`~operations_generic.offset`
| :func:`~operations_part.revolve`
Expand Down
2 changes: 1 addition & 1 deletion docs/key_concepts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ Selectors
Using Locations & Rotating Objects
==================================

build123d stores points (to be specific ``Location``(s)) internally to be used as
build123d stores points (to be specific ``Location`` (s)) internally to be used as
positions for the placement of new objects. By default, a single location
will be created at the origin of the given workplane such that:

Expand Down
3 changes: 3 additions & 0 deletions docs/operations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ BuildPart and Algebra Part.
+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+
| :func:`~operations_generic.fillet` | Radius Vertex or Edge | | ||| :ref:`9 <ex 9>` |
+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+
| :func:`~operations_sketch.full_round` | Round-off Face along given Edge | | || | |
+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+
| :func:`~operations_part.loft` | Create 3D Shape from sections | | | || :ref:`24 <ex 24>` |
+----------------------------------------------+------------------------------------+----+----+----+----+------------------------+
| :func:`~operations_part.make_brake_formed` | Create sheet metal parts | | | || |
Expand Down Expand Up @@ -104,6 +106,7 @@ Reference
.. autofunction:: operations_generic.chamfer
.. autofunction:: operations_part.extrude
.. autofunction:: operations_generic.fillet
.. autofunction:: operations_sketch.full_round
.. autofunction:: operations_part.loft
.. autofunction:: operations_part.make_brake_formed
.. autofunction:: operations_sketch.make_face
Expand Down
5 changes: 5 additions & 0 deletions src/build123d/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""build123d import definitions"""

from build123d.build_common import *
from build123d.build_enums import *
from build123d.build_line import *
Expand Down Expand Up @@ -185,6 +186,7 @@
"chamfer",
"extrude",
"fillet",
"full_round",
"loft",
"make_brake_formed",
"make_face",
Expand All @@ -201,4 +203,7 @@
"sweep",
"thicken",
"trace",
# Topology Exploration
"topo_explore_connected_edges",
"topo_explore_common_vertex",
]
2 changes: 2 additions & 0 deletions src/build123d/build_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
limitations under the License.
"""

from __future__ import annotations

import contextvars
Expand Down Expand Up @@ -135,6 +136,7 @@ def flatten_sequence(*obj: T) -> list[Any]:
"chamfer": ["BuildPart", "BuildSketch", "BuildLine"],
"extrude": ["BuildPart"],
"fillet": ["BuildPart", "BuildSketch", "BuildLine"],
"full_round": ["BuildSketch"],
"loft": ["BuildPart"],
"make_brake_formed": ["BuildPart"],
"make_face": ["BuildSketch"],
Expand Down
160 changes: 158 additions & 2 deletions src/build123d/operations_sketch.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,168 @@
limitations under the License.
"""

from __future__ import annotations
from typing import Iterable, Union
from build123d.build_enums import Mode
from build123d.topology import Compound, Curve, Edge, Face, ShapeList, Wire, Sketch
from build123d.build_enums import Mode, SortBy
from build123d.topology import (
Compound,
Curve,
Edge,
Face,
ShapeList,
Wire,
Sketch,
topo_explore_connected_edges,
topo_explore_common_vertex,
TOLERANCE,
)
from build123d.geometry import Vector
from build123d.build_common import flatten_sequence, validate_inputs
from build123d.build_sketch import BuildSketch
from build123d.objects_curve import ThreePointArc
from scipy.spatial import Voronoi


def full_round(
edge: Edge,
invert: bool = False,
voronoi_point_count: int = 100,
mode: Mode = Mode.REPLACE,
):
"""Sketch Operation: full_round
Given an edge from a Face/Sketch, modify the face by replacing the given edge with the
arc of the Voronoi largest empty circle that will fit within the Face. This
"rounds off" the end of the object.
Args:
edge (Edge): target Edge to remove
invert (bool, optional): make the arc concave instead of convex. Defaults to False.
voronoi_point_count (int, optional): number of points along each edge
used to create the voronoi vertices as potential locations for the
center of the largest empty circle. Defaults to 100.
mode (Mode, optional): combination mode. Defaults to Mode.REPLACE.
Raises:
ValueError: Invalid geometry
"""
context: BuildSketch = BuildSketch._get_context("full_round")

if not isinstance(edge, Edge):
raise ValueError("A single Edge must be provided")
validate_inputs(context, "full_round", edge)

#
# Generate a set of evenly spaced points along the given edge and the
# edges connected to it and use them to generate the Voronoi vertices
# as possible locations for the center of the largest empty circle
# Note: full_round could be enhanced to handle the case of a face composed
# of two edges.
connected_edges = topo_explore_connected_edges(edge, edge.topo_parent)
if len(connected_edges) != 2:
raise ValueError("Invalid geometry - 3 or more edges required")

edge_group = [edge] + connected_edges
voronoi_edge_points = [
v
for e in edge_group
for v in e.positions(
[i / voronoi_point_count for i in range(voronoi_point_count + 1)]
)
]
numpy_style_pnts = [[p.X, p.Y] for p in voronoi_edge_points]
voronoi_vertices = [Vector(*v) for v in Voronoi(numpy_style_pnts).vertices]

#
# Refine the largest empty circle center estimate by averaging the best
# three candidates. The minimum distance between the edges and this
# center is the circle radius.
best_three = [(float("inf"), None), (float("inf"), None), (float("inf"), None)]

for i, v in enumerate(voronoi_vertices):
distances = [edge_group[i].distance_to(v) for i in range(3)]
avg_distance = sum(distances) / 3
differences = max(abs(dist - avg_distance) for dist in distances)

# Check if this delta is among the three smallest and update best_three if so
# Compare with the largest delta in the best three
if differences < best_three[-1][0]:
# Replace the last element with the new one
best_three[-1] = (differences, i)
# Sort the list to keep the smallest deltas first
best_three.sort(key=lambda x: x[0])

# Extract the indices of the best three and average them
best_indices = [x[1] for x in best_three]
voronoi_circle_center = sum(voronoi_vertices[i] for i in best_indices) / 3

# Determine where the connected edges intersect with the largest empty circle
connected_edges_end_points = [
e.distance_to_with_closest_points(voronoi_circle_center)[1]
for e in connected_edges
]
middle_edge_arc_point = edge.distance_to_with_closest_points(voronoi_circle_center)[
1
]
if invert:
middle_edge_arc_point = voronoi_circle_center * 2 - middle_edge_arc_point
connected_edges_end_params = [
e.param_at_point(connected_edges_end_points[i])
for i, e in enumerate(connected_edges)
]
for param in connected_edges_end_params:
if not (0.0 < param < 1.0):
raise ValueError("Invalid geometry to create the end arc")

common_vertex_points = [
Vector(topo_explore_common_vertex(edge, e)) for e in connected_edges
]
common_vertex_params = [
e.param_at_point(common_vertex_points[i]) for i, e in enumerate(connected_edges)
]

# Trim the connected edges to end at the closest points to the circle center
trimmed_connected_edges = [
e.trim(*sorted([1.0 - common_vertex_params[i], connected_edges_end_params[i]]))
for i, e in enumerate(connected_edges)
]
# Record the position of the newly trimmed connected edges to build the arc
# accurately
trimmed_end_points = []
for i in range(2):
if (
trimmed_connected_edges[i].position_at(0)
- connected_edges[i].position_at(0)
).length < TOLERANCE:
trimmed_end_points.append(trimmed_connected_edges[i].position_at(1))
else:
trimmed_end_points.append(trimmed_connected_edges[i].position_at(0))

# Generate the new circular edge
new_arc = Edge.make_three_point_arc(
trimmed_end_points[0],
middle_edge_arc_point,
trimmed_end_points[1],
)

# Recover other edges
other_edges = edge.topo_parent.edges() - topo_explore_connected_edges(edge) - [edge]

# Rebuild the face
# Note that the longest wire must be the perimeter and others holes
face_wires = Wire.combine(
trimmed_connected_edges + [new_arc] + other_edges
).sort_by(SortBy.LENGTH, reverse=True)
pending_face = Face(face_wires[0], face_wires[1:])

if context is not None:
context._add_to_context(pending_face, mode=mode)
context.pending_edges = ShapeList()

# return Sketch(Compound([pending_face]).wrapped)
return Sketch([pending_face])


def make_face(
Expand Down
53 changes: 52 additions & 1 deletion src/build123d/topology.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@
TopoDS_Shell,
TopoDS_Solid,
TopoDS_Vertex,
TopoDS_Edge,
TopoDS_Wire,
)
from OCP.TopTools import (
Expand Down Expand Up @@ -2791,7 +2792,9 @@ def mesh(self, tolerance: float, angular_tolerance: float = 0.1):
"""

if not BRepTools.Triangulation_s(self.wrapped, tolerance):
BRepMesh_IncrementalMesh(self.wrapped, tolerance, True, angular_tolerance, True)
BRepMesh_IncrementalMesh(
self.wrapped, tolerance, True, angular_tolerance, True
)

def tessellate(
self, tolerance: float, angular_tolerance: float = 0.1
Expand Down Expand Up @@ -8216,6 +8219,54 @@ def new_edges(*objects: Shape, combined: Shape) -> ShapeList[Edge]:
return ShapeList(edges)


def topo_explore_connected_edges(edge: Edge, parent: Shape = None) -> ShapeList[Edge]:
"""Given an edge extracted from a Shape, return the edges connected to it"""

parent = parent if parent is not None else edge.topo_parent
given_topods_edge = edge.wrapped
connected_edges = set()

# Find all the TopoDS_Edges for this Shape
topods_edges = ShapeList([e.wrapped for e in parent.edges()])

for topods_edge in topods_edges:
# # Don't match with the given edge
if given_topods_edge.IsSame(topods_edge):
continue
# If the edge shares a vertex with the given edge they are connected
if topo_explore_common_vertex(given_topods_edge, topods_edge) is not None:
connected_edges.add(topods_edge)

return ShapeList([Edge(e) for e in connected_edges])


def topo_explore_common_vertex(
edge1: Union[Edge, TopoDS_Edge], edge2: Union[Edge, TopoDS_Edge]
) -> Union[Vertex, None]:
"""Given two edges, find the common vertex"""
topods_edge1 = edge1.wrapped if isinstance(edge1, Edge) else edge1
topods_edge2 = edge2.wrapped if isinstance(edge2, Edge) else edge2

# Explore vertices of the first edge
vert_exp = TopExp_Explorer(topods_edge1, ta.TopAbs_VERTEX)
while vert_exp.More():
vertex1 = vert_exp.Current()

# Explore vertices of the second edge
explorer2 = TopExp_Explorer(topods_edge2, ta.TopAbs_VERTEX)
while explorer2.More():
vertex2 = explorer2.Current()

# Check if the vertices are the same
if vertex1.IsSame(vertex2):
return Vertex(downcast(vertex1)) # Common vertex found

explorer2.Next()
vert_exp.Next()

return None # No common vertex found


class SkipClean:
"""Skip clean context for use in operator driven code where clean=False wouldn't work"""

Expand Down
23 changes: 23 additions & 0 deletions tests/test_build_sketch.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
limitations under the License.
"""

import unittest
from math import pi, sqrt
from build123d import *
Expand Down Expand Up @@ -466,6 +467,28 @@ def test_trace(self):
test = trace(line, 4).clean()
self.assertEqual(len(test.faces()), 1)

def test_full_round(self):
with BuildSketch() as test:
trap = Trapezoid(0.5, 1, 90 - 8)
full_round(test.edges().sort_by(Axis.Y)[-1])
self.assertLess(test.face().area, trap.face().area)

with self.assertRaises(ValueError):
full_round(test.edges().sort_by(Axis.Y))

with self.assertRaises(ValueError):
full_round(trap.edges().sort_by(Axis.X)[-1])

l1 = Edge.make_spline([(-1, 0), (1, 0)], tangents=((0, -8), (0, 8)), scale=True)
l2 = Edge.make_line(l1 @ 0, l1 @ 1)
face = Face(Wire([l1, l2]))
with self.assertRaises(ValueError):
full_round(face.edges()[0])

positive = full_round(trap.edges().sort_by(SortBy.LENGTH)[0])
negative = full_round(trap.edges().sort_by(SortBy.LENGTH)[0], invert=True)
self.assertLess(negative.area, positive.area)


if __name__ == "__main__":
unittest.main(failfast=True)
Loading

0 comments on commit 745bb7d

Please sign in to comment.