diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index e3876a1..1df34f7 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -1754,6 +1754,71 @@ class Face(Mixin2D, Shape[TopoDS_Face]): f"{type(planar_shape)}" ) + def wrap_faces( + self, + faces: Iterable[Face], + path: Wire | Edge, + start: float = 0.0, + ) -> ShapeList[Face]: + """wrap_faces + + Wrap a sequence of 2D faces onto a 3D surface, aligned along a guiding path. + + This method places multiple planar `Face` objects (defined in the XY plane) onto a + curved 3D surface (`self`), following a given path (Wire or Edge) that lies on or + closely follows the surface. Each face is spaced along the path according to its + original horizontal (X-axis) position, preserving the relative layout of the input + faces. + + The wrapping process attempts to maintain the shape and size of each face while + minimizing distortion. Each face is repositioned to the origin, then individually + wrapped onto the surface starting at a specific point along the path. The face's + new orientation is defined using the path's tangent direction and the surface normal + at that point. + + This is particularly useful for placing a series of features—such as embossed logos, + engraved labels, or patterned tiles—onto a freeform or cylindrical surface, aligned + along a reference edge or curve. + + Args: + faces (Iterable[Face]): An iterable of 2D planar faces to be wrapped. + path (Wire | Edge): A curve on the target surface that defines the alignment + direction. The X-position of each face is mapped to a relative position + along this path. + start (float, optional): The relative starting point on the path (between 0.0 + and 1.0) where the first face should be placed. Defaults to 0.0. + + Returns: + ShapeList[Face]: A list of wrapped face objects, aligned and conformed to the + surface. + """ + path_length = path.length + + face_list = list(faces) + first_face_min_x = face_list[0].bounding_box().min.X + + # Position each face at the origin and wrap onto surface + wrapped_faces: ShapeList[Face] = ShapeList() + for face in face_list: + bbox = face.bounding_box() + face_center_x = (bbox.min.X + bbox.max.X) / 2 + delta_x = face_center_x - first_face_min_x + relative_position_on_wire = start + delta_x / path_length + path_position = path.position_at(relative_position_on_wire) + surface_location = Location( + Plane( + path_position, + x_dir=path.tangent_at(relative_position_on_wire), + z_dir=self.normal_at(path_position), + ) + ) + assert isinstance(face.position, Vector) + face.position -= (delta_x, 0, 0) # Shift back to origin + wrapped_face = Face.wrap(self, face, surface_location) + wrapped_faces.append(wrapped_face) + + return wrapped_faces + def _uv_bounds(self) -> tuple[float, float, float, float]: """Return the u min, u max, v min, v max values""" return BRepTools.UVBounds_s(self.wrapped) diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index 0bde6c6..5106228 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -50,6 +50,7 @@ from build123d.objects_sketch import ( Polygon, Rectangle, RegularPolygon, + Text, Triangle, ) from build123d.operations_generic import fillet, offset @@ -888,6 +889,22 @@ class TestFace(unittest.TestCase): with self.assertRaises(RuntimeError): surface.wrap(star, target) + def test_wrap_faces(self): + sphere = Solid.make_sphere(50, angle1=-90).face() + surface = sphere.face() + path: Edge = ( + sphere.cut( + Solid.make_cylinder(80, 100, Plane.YZ).locate(Location((-50, 0, -70))) + ) + .edges() + .sort_by(Axis.Z)[0] + .reversed() + ) + text = Text(txt="ei", font_size=15, align=(Align.MIN, Align.CENTER)) + wrapped_faces = surface.wrap_faces(text.faces(), path, 0.2) + self.assertEqual(len(wrapped_faces), 3) + self.assertTrue(all(not f.is_planar_face for f in wrapped_faces)) + def test_revolve(self): l1 = Edge.make_line((3, 0), (3, 2)) revolved = Face.revolve(l1, 360, Axis.Y)