"""
Generic Operations
name: operations_generic.py
by: Gumyr
date: March 21th 2023
desc:
This python module contains operations (functions) that work on combinations
of Curves, Sketches and Parts.
license:
Copyright 2022 Gumyr
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import copy as copy_module
import logging
from math import radians, tan
from typing import cast, TypeAlias
from collections.abc import Iterable
from build123d.build_common import (
Builder,
LocationList,
WorkplaneList,
flatten_sequence,
validate_inputs,
)
from build123d.build_enums import Keep, Kind, Mode, Side, Transition, GeomType
from build123d.build_line import BuildLine
from build123d.build_part import BuildPart
from build123d.build_sketch import BuildSketch
from build123d.geometry import (
Axis,
Location,
Matrix,
Plane,
Rotation,
RotationLike,
Vector,
VectorLike,
)
from build123d.objects_part import BasePartObject
from build123d.objects_sketch import BaseSketchObject
from build123d.objects_curve import BaseLineObject
from build123d.topology import (
Compound,
Curve,
Edge,
Face,
GroupBy,
Part,
Shape,
ShapeList,
Shell,
Sketch,
Solid,
Vertex,
Wire,
isclose_b,
)
logging.getLogger("build123d").addHandler(logging.NullHandler())
logger = logging.getLogger("build123d")
AddType: TypeAlias = Edge | Wire | Face | Solid | Compound | Builder
"""Type of objects which can be added to a builder"""
[docs]
def add(
objects: AddType | Iterable[AddType],
rotation: float | RotationLike | None = None,
clean: bool = True,
mode: Mode = Mode.ADD,
) -> Compound:
"""Generic Object: Add Object to Part or Sketch
Add an object to a builder.
BuildPart:
Edges and Wires are added to pending_edges. Compounds of Face are added to
pending_faces. Solids or Compounds of Solid are combined into the part.
BuildSketch:
Edges and Wires are added to pending_edges. Compounds of Face are added to sketch.
BuildLine:
Edges and Wires are added to line.
Args:
objects (Edge | Wire | Face | Solid | Compound or Iterable of): objects to add
rotation (float | RotationLike, optional): rotation angle for sketch,
rotation about each axis for part. Defaults to None.
clean (bool, optional): Remove extraneous internal structure. Defaults to True.
mode (Mode, optional): combine mode. Defaults to Mode.ADD.
"""
context: Builder | None = Builder._get_context(None)
if context is None:
raise RuntimeError("Add must have an active builder context")
if isinstance(objects, Iterable) and not isinstance(objects, Compound):
object_list = list(objects)
else:
object_list = [objects]
object_iter = [
(
obj.unwrap(fully=False)
if isinstance(obj, Compound)
else obj._obj if isinstance(obj, Builder) else obj
)
for obj in object_list
]
validate_inputs(context, "add", object_iter)
if isinstance(context, BuildPart):
if rotation is None:
rotation = Rotation(0, 0, 0)
elif isinstance(rotation, tuple):
rotation = Rotation(*rotation)
else:
raise ValueError("Invalid rotation value")
object_iter = [obj.moved(rotation) for obj in object_iter]
new_edges = [obj for obj in object_iter if isinstance(obj, Edge)]
new_wires = [obj for obj in object_iter if isinstance(obj, Wire)]
new_faces = [obj for obj in object_iter if isinstance(obj, Face)]
new_solids = [obj for obj in object_iter if isinstance(obj, Solid)]
for compound in filter(lambda o: isinstance(o, Compound), object_iter):
new_edges.extend(compound.get_type(Edge))
new_wires.extend(compound.get_type(Wire))
new_faces.extend(compound.get_type(Face))
new_solids.extend(compound.get_type(Solid))
for new_wire in new_wires:
new_edges.extend(new_wire.edges())
# Add the pending Edges in one group
if not LocationList._get_context():
raise RuntimeError("There is no active Locations context")
located_edges = [
edge.moved(location)
for edge in new_edges
for location in LocationList._get_context().locations
]
context._add_to_pending(*located_edges)
new_objects = located_edges
# Add to pending Faces batched by workplane
for workplane in WorkplaneList._get_context().workplanes:
faces_per_workplane = []
for location in LocationList._get_context().locations:
for face in new_faces:
faces_per_workplane.append(face.moved(location))
context._add_to_pending(*faces_per_workplane, face_plane=workplane)
new_objects.extend(faces_per_workplane)
# Add to context Solids
located_solids = [
solid.moved(location)
for solid in new_solids
for location in LocationList._get_context().locations
]
context._add_to_context(*located_solids, clean=clean, mode=mode)
new_objects.extend(located_solids)
elif isinstance(context, BuildSketch):
rotation_angle = rotation if isinstance(rotation, (int, float)) else 0.0
new_objects = []
for obj in object_iter:
new_objects.extend(
[
obj.rotate(Axis.Z, rotation_angle).moved(location)
for location in LocationList._get_context().local_locations
]
)
context._add_to_context(*new_objects, mode=mode)
elif isinstance(context, BuildLine):
rotation_angle = rotation if isinstance(rotation, (int, float)) else 0.0
new_objects = []
for obj in object_iter:
new_objects.extend(
[
obj.rotate(Axis.Z, rotation_angle).moved(location)
for location in LocationList._get_context().local_locations
]
)
context._add_to_context(*new_objects, mode=mode)
else:
raise RuntimeError(f"Builder {context.__class__.__name__} is unsupported")
return Compound(new_objects)
[docs]
def bounding_box(
objects: Shape | Iterable[Shape] | None = None,
mode: Mode = Mode.PRIVATE,
) -> Sketch | Part:
"""Generic Operation: Add Bounding Box
Applies to: BuildSketch and BuildPart
Add the 2D or 3D bounding boxes of the object sequence
Args:
objects (Shape or Iterable of): objects to create bbox for
mode (Mode, optional): combination mode. Defaults to Mode.ADD.
"""
context: Builder | None = Builder._get_context("bounding_box")
if objects is None:
if context is None or context is not None and context._obj is None:
raise ValueError("objects must be provided")
object_list = [context._obj]
else:
object_list = flatten_sequence(objects)
validate_inputs(context, "bounding_box", object_list)
if all([obj._dim == 2 for obj in object_list]):
new_faces = []
for obj in object_list:
if isinstance(obj, Vertex):
continue
bbox = obj.bounding_box()
vertices = [
(bbox.min.X, bbox.min.Y),
(bbox.min.X, bbox.max.Y),
(bbox.max.X, bbox.max.Y),
(bbox.max.X, bbox.min.Y),
(bbox.min.X, bbox.min.Y),
]
new_faces.append(Face(Wire.make_polygon([Vector(v) for v in vertices])))
if context is not None:
context._add_to_context(*new_faces, mode=mode)
return Sketch(Compound(new_faces).wrapped)
new_objects = []
for obj in object_list:
if isinstance(obj, Vertex):
continue
bbox = obj.bounding_box()
new_objects.append(
Solid.make_box(
bbox.size.X,
bbox.size.Y,
bbox.size.Z,
Plane((bbox.min.X, bbox.min.Y, bbox.min.Z)),
)
)
if context is not None:
context._add_to_context(*new_objects, mode=mode)
return Part(Compound(new_objects).wrapped)
ChamferFilletType: TypeAlias = Edge | Vertex
"""Type of objects which can be chamfered or filleted"""
[docs]
def chamfer(
objects: ChamferFilletType | Iterable[ChamferFilletType],
length: float,
length2: float | None = None,
angle: float | None = None,
reference: Edge | Face | None = None,
) -> Sketch | Part:
"""Generic Operation: chamfer
Applies to 2 and 3 dimensional objects.
Chamfer the given sequence of edges or vertices.
Args:
objects (Edge | Vertex or Iterable of): edges or vertices to chamfer
length (float): chamfer size
length2 (float, optional): asymmetric chamfer size. Defaults to None.
angle (float, optional): chamfer angle in degrees. Defaults to None.
reference (Edge | Face): identifies the side where length is measured. Edge(s) must
be part of the face. Vertex/Vertices must be part of edge
Raises:
ValueError: no objects provided
ValueError: objects must be Edges
ValueError: objects must be Vertices
ValueError: Only one of length2 or angle should be provided
ValueError: reference can only be used in conjunction with length2 or angle
"""
context: Builder | None = Builder._get_context("chamfer")
if length2 and angle:
raise ValueError("Only one of length2 or angle should be provided")
if angle:
length2 = length * tan(radians(angle))
if reference and not (length2 or angle):
raise ValueError(
"reference can only be used in conjunction with length2 or angle"
)
length2 = length if length2 is None else length2
if (objects is None and context is None) or (
objects is None and context is not None and context._obj is None
):
raise ValueError("No objects provided")
object_list = flatten_sequence(objects)
validate_inputs(context, "chamfer", object_list)
if context is not None:
target = context._obj
else:
target = object_list[0].topo_parent
if target is None:
raise ValueError("Nothing to chamfer")
if target._dim == 3:
# Convert BasePartObject into Part so casting into Part during construction works
target = Part(target.wrapped) if isinstance(target, BasePartObject) else target
if not all([isinstance(obj, Edge) for obj in object_list]):
raise ValueError("3D chamfer operation takes only Edges")
new_part = target.chamfer(length, length2, list(object_list), reference)
if context is not None:
context._add_to_context(new_part, mode=Mode.REPLACE)
return Part(Compound([new_part]).wrapped)
if target._dim == 2:
# Convert BaseSketchObject into Sketch so casting into Sketch during construction works
target = (
Sketch(target.wrapped) if isinstance(target, BaseSketchObject) else target
)
if not all([isinstance(obj, Vertex) for obj in object_list]):
raise ValueError("2D chamfer operation takes only Vertices")
new_faces = []
for face in target.faces():
vertices_in_face = [v for v in face.vertices() if v in object_list]
if vertices_in_face:
new_faces.append(
face.chamfer_2d(length, length2, vertices_in_face, reference)
)
else:
new_faces.append(face)
new_sketch = Sketch(Compound(new_faces).wrapped)
if context is not None:
context._add_to_context(new_sketch, mode=Mode.REPLACE)
return new_sketch
if target._dim == 1:
target = (
Wire(target.wrapped)
if isinstance(target, BaseLineObject)
else target.wires()[0]
)
if not all([isinstance(obj, Vertex) for obj in object_list]):
raise ValueError("1D fillet operation takes only Vertices")
# Remove any end vertices as these can't be filleted
if not target.is_closed:
object_list = ShapeList(
filter(
lambda v: not (
isclose_b(
(Vector(*v.to_tuple()) - target.position_at(0)).length,
0.0,
)
or isclose_b(
(Vector(*v.to_tuple()) - target.position_at(1)).length,
0.0,
)
),
object_list,
)
)
new_wire = target.chamfer_2d(length, length2, object_list, reference)
if context is not None:
context._add_to_context(new_wire, mode=Mode.REPLACE)
return new_wire
raise ValueError("Invalid object dimension")
[docs]
def fillet(
objects: ChamferFilletType | Iterable[ChamferFilletType],
radius: float,
) -> Sketch | Part | Curve:
"""Generic Operation: fillet
Applies to 2 and 3 dimensional objects.
Fillet the given sequence of edges or vertices. Note that vertices on
either end of an open line will be automatically skipped.
Args:
objects (Edge | Vertex or Iterable of): edges or vertices to fillet
radius (float): fillet size - must be less than 1/2 local width
Raises:
ValueError: no objects provided
ValueError: objects must be Edges
ValueError: objects must be Vertices
ValueError: nothing to fillet
"""
context: Builder | None = Builder._get_context("fillet")
if (objects is None and context is None) or (
objects is None and context is not None and context._obj is None
):
raise ValueError("No objects provided")
object_list = flatten_sequence(objects)
validate_inputs(context, "fillet", object_list)
if context is not None:
target = context._obj
else:
target = object_list[0].topo_parent
if target is None:
raise ValueError("Nothing to fillet")
if target._dim == 3:
# Convert BasePartObject in Part so casting into Part during construction works
target = Part(target.wrapped) if isinstance(target, BasePartObject) else target
if not all([isinstance(obj, Edge) for obj in object_list]):
raise ValueError("3D fillet operation takes only Edges")
new_part = target.fillet(radius, list(object_list))
if context is not None:
context._add_to_context(new_part, mode=Mode.REPLACE)
return Part(Compound([new_part]).wrapped)
if target._dim == 2:
# Convert BaseSketchObject into Sketch so casting into Sketch during construction works
target = (
Sketch(target.wrapped) if isinstance(target, BaseSketchObject) else target
)
if not all([isinstance(obj, Vertex) for obj in object_list]):
raise ValueError("2D fillet operation takes only Vertices")
new_faces = []
for face in target.faces():
vertices_in_face = [v for v in face.vertices() if v in list(object_list)]
if vertices_in_face:
new_faces.append(face.fillet_2d(radius, vertices_in_face))
else:
new_faces.append(face)
new_sketch = Sketch(Compound(new_faces).wrapped)
if context is not None:
context._add_to_context(new_sketch, mode=Mode.REPLACE)
return new_sketch
if target._dim == 1:
target = (
Wire(target.wrapped)
if isinstance(target, BaseLineObject)
else target.wires()[0]
)
if not all([isinstance(obj, Vertex) for obj in object_list]):
raise ValueError("1D fillet operation takes only Vertices")
# Remove any end vertices as these can't be filleted
if not target.is_closed:
object_list = ShapeList(
filter(
lambda v: not (
isclose_b(
(Vector(*v.to_tuple()) - target.position_at(0)).length,
0.0,
)
or isclose_b(
(Vector(*v.to_tuple()) - target.position_at(1)).length,
0.0,
)
),
object_list,
)
)
new_wire = target.fillet_2d(radius, object_list)
if context is not None:
context._add_to_context(new_wire, mode=Mode.REPLACE)
return new_wire
raise ValueError("Invalid object dimension")
MirrorType: TypeAlias = Edge | Wire | Face | Compound | Curve | Sketch | Part
"""Type of objects which can be mirrored"""
[docs]
def mirror(
objects: MirrorType | Iterable[MirrorType] | None = None,
about: Plane = Plane.XZ,
mode: Mode = Mode.ADD,
) -> Curve | Sketch | Part | Compound:
"""Generic Operation: mirror
Applies to 1, 2, and 3 dimensional objects.
Mirror a sequence of objects over the given plane.
Args:
objects (Edge | Face | Compound or Iterable of): objects to mirror
about (Plane, optional): reference plane. Defaults to "XZ".
mode (Mode, optional): combination mode. Defaults to Mode.ADD.
Raises:
ValueError: missing objects
"""
context: Builder | None = Builder._get_context("mirror")
if isinstance(objects, Iterable) and not isinstance(objects, Compound):
object_list = list(objects)
else:
object_list = [objects]
if objects is None:
if context is None or context is not None and context._obj is None:
raise ValueError("objects must be provided")
object_list = [context._obj]
else:
object_list = flatten_sequence(objects)
validate_inputs(context, "mirror", object_list)
mirrored = [copy_module.deepcopy(o).mirror(about) for o in object_list]
if context is not None:
context._add_to_context(*mirrored, mode=mode)
mirrored_compound = Compound(mirrored)
if all([obj._dim == 3 for obj in object_list]):
return Part(mirrored_compound.wrapped)
if all([obj._dim == 2 for obj in object_list]):
return Sketch(mirrored_compound.wrapped)
if all([obj._dim == 1 for obj in object_list]):
return Curve(mirrored_compound.wrapped)
return mirrored_compound
OffsetType: TypeAlias = Edge | Face | Solid | Compound
"""Type of objects which can be offset"""
[docs]
def offset(
objects: OffsetType | Iterable[OffsetType] | None = None,
amount: float = 0,
openings: Face | list[Face] | None = None,
kind: Kind = Kind.ARC,
side: Side = Side.BOTH,
closed: bool = True,
min_edge_length: float | None = None,
mode: Mode = Mode.REPLACE,
) -> Curve | Sketch | Part | Compound:
"""Generic Operation: offset
Applies to 1, 2, and 3 dimensional objects.
Offset the given sequence of Edges, Faces, Compound of Faces, or Solids.
The kind parameter controls the shape of the transitions. For Solid
objects, the openings parameter allows selected faces to be open, like
a hollow box with no lid.
Args:
objects (Edge | Face | Solid | Compound or Iterable of): objects to offset
amount (float): positive values external, negative internal
openings (list[Face], optional), sequence of faces to open in part.
Defaults to None.
kind (Kind, optional): transition shape. Defaults to Kind.ARC.
side (Side, optional): side to place offset. Defaults to Side.BOTH.
closed (bool, optional): if Side!=BOTH, close the LEFT or RIGHT
offset. Defaults to True.
min_edge_length (float, optional): repair degenerate edges generated by offset
by eliminating edges of minimum length in offset wire. Defaults to None.
mode (Mode, optional): combination mode. Defaults to Mode.REPLACE.
Raises:
ValueError: missing objects
ValueError: Invalid object type
"""
context: Builder | None = Builder._get_context("offset")
if objects is None:
if context is None or context is not None and context._obj is None:
raise ValueError("objects must be provided")
object_list = [context._obj]
else:
object_list = flatten_sequence(objects)
validate_inputs(context, "offset", object_list)
edges: list[Edge] = []
faces: list[Face] = []
solids: list[Solid] = []
for obj in object_list:
if isinstance(obj, Compound):
edges.extend(obj.get_type(Edge))
faces.extend(obj.get_type(Face))
solids.extend(obj.get_type(Solid))
elif isinstance(obj, Solid):
solids.append(obj)
elif isinstance(obj, Face):
faces.append(obj)
elif isinstance(obj, Edge):
edges.append(obj)
elif isinstance(obj, Wire):
edges.extend(obj.edges())
else:
raise TypeError(f"Unsupported type {type(obj)} for {obj}")
new_faces = []
for face in faces:
outer_wire = face.outer_wire().offset_2d(amount, kind=kind)
if min_edge_length is not None:
outer_wire = outer_wire.fix_degenerate_edges(min_edge_length)
inner_wires = []
for inner_wire in face.inner_wires():
try:
offset_wire = inner_wire.offset_2d(-amount, kind=kind)
if min_edge_length is not None:
inner_wires.append(
offset_wire.fix_degenerate_edges(min_edge_length)
)
else:
inner_wires.append(offset_wire)
except Exception:
pass
# inner wires may go beyond the outer wire so subtract faces
new_face = Face(outer_wire)
if (new_face.normal_at() - face.normal_at()).length > 0.001:
new_face = -new_face
if inner_wires:
inner_faces = [Face(w) for w in inner_wires]
subtraction = new_face.cut(*inner_faces)
if isinstance(subtraction, Compound):
new_faces.append(new_face.unwrap(fully=True))
elif isinstance(subtraction, ShapeList):
new_faces.extend(subtraction)
else:
new_faces.append(subtraction)
else:
new_faces.append(new_face)
if edges:
if len(edges) == 1 and edges[0].geom_type == GeomType.LINE:
new_wires = [
Wire(
[
Edge.make_line(edges[0] @ 0.0, edges[0] @ 0.5),
Edge.make_line(edges[0] @ 0.5, edges[0] @ 1.0),
]
).offset_2d(amount, kind=kind, side=side, closed=closed)
]
else:
new_wires = [
Wire(edges).offset_2d(amount, kind=kind, side=side, closed=closed)
]
if min_edge_length is not None:
new_wires = [w.fix_degenerate_edges(min_edge_length) for w in new_wires]
else:
new_wires = []
if isinstance(openings, Face):
openings = [openings]
new_solids = []
for solid in solids:
if openings:
openings_in_this_solid = [o for o in openings if o in solid.faces()]
else:
openings_in_this_solid = []
new_solids.append(
solid.offset_3d(openings_in_this_solid, amount, kind=kind).fix()
)
new_objects = new_wires + new_faces + new_solids
if context is not None:
context._add_to_context(*new_objects, mode=mode)
offset_compound = Compound(new_objects)
if all([obj._dim == 3 for obj in object_list]):
return Part(offset_compound.wrapped)
if all([obj._dim == 2 for obj in object_list]):
return Sketch(offset_compound.wrapped)
if all([obj._dim == 1 for obj in object_list]):
return Curve(offset_compound.wrapped)
return offset_compound
ProjectType: TypeAlias = Edge | Face | Wire | Vector | Vertex
"""Type of objects which can be projected"""
[docs]
def project(
objects: ProjectType | Iterable[ProjectType] | None = None,
workplane: Plane | None = None,
target: Solid | Compound | Part | None = None,
mode: Mode = Mode.ADD,
) -> Curve | Sketch | Compound | ShapeList[Vector]:
"""Generic Operation: project
Applies to 0, 1, and 2 dimensional objects.
Project the given objects or points onto a BuildLine or BuildSketch workplane in
the direction of the normal of that workplane. When projecting onto a
sketch a Face(s) are generated while Edges are generated for BuildLine.
Will only use the first if BuildSketch has multiple active workplanes.
In algebra mode a workplane must be provided and the output is either
a Face, Curve, Sketch, Compound, or ShapeList[Vector].
Note that only if mode is not Mode.PRIVATE only Faces can be projected into
BuildSketch and Edge/Wires into BuildLine.
Args:
objects (Edge | Face | Wire | VectorLike | Vertex or Iterable of):
objects or points to project
workplane (Plane, optional): screen workplane
mode (Mode, optional): combination mode. Defaults to Mode.ADD.
Raises:
ValueError: project doesn't accept group_by
ValueError: Either a workplane must be provided or a builder must be active
ValueError: Points and faces can only be projected in PRIVATE mode
ValueError: Edges, wires and points can only be projected in PRIVATE mode
RuntimeError: BuildPart doesn't have a project operation
"""
context: Builder | None = Builder._get_context("project")
if isinstance(objects, GroupBy):
raise ValueError("project doesn't accept group_by, did you miss [n]?")
if not objects and context is None:
raise ValueError("No object to project")
if not objects and context is not None and isinstance(context, BuildPart):
object_list = context.pending_edges + context.pending_faces
context.pending_edges = []
context.pending_faces = []
if len(context.pending_face_planes) > 0:
workplane = context.pending_face_planes[0]
context.pending_face_planes = []
else:
workplane = context.exit_workplanes[0]
else:
object_list = flatten_sequence(objects)
# The size of the object determines the size of the target projection screen
# as the screen is normal to the direction of parallel projection
shape_list = [
Vertex(*o.to_tuple()) if isinstance(o, Vector) else o for o in object_list
]
object_size = Compound(children=shape_list).bounding_box(optimal=False).diagonal
vct_vrt_list = [o for o in object_list if isinstance(o, (Vector, Vertex))]
point_list = [Vector(pnt) for pnt in vct_vrt_list]
face_list = [o for o in object_list if isinstance(o, Face)]
line_list = [o for o in object_list if isinstance(o, (Edge, Wire))]
if workplane is None:
if context is None:
raise ValueError(
"Either a workplane must be provided or a builder must be active"
)
if isinstance(context, BuildLine):
workplane = context.workplanes[0]
if mode != Mode.PRIVATE and (face_list or point_list):
raise ValueError(
"Points and faces can only be projected in PRIVATE mode"
)
elif isinstance(context, BuildSketch):
workplane = context.workplanes[0]
if mode != Mode.PRIVATE and (line_list or point_list):
raise ValueError(
"Edges, wires and points can only be projected in PRIVATE mode"
)
working_plane = cast(Plane, workplane)
# BuildLine and BuildSketch are from target to workplane while BuildPart is
# from workplane to target so the projection direction needs to be flipped
projection_flip = 1
if context is not None and isinstance(context, BuildPart):
if mode != Mode.PRIVATE and point_list:
raise ValueError("Points can only be projected in PRIVATE mode")
if target is None:
target = context.part
projection_flip = -1
else:
target = Face.make_rect(3 * object_size, 3 * object_size, plane=working_plane)
if target is None:
raise ValueError("A target object could not be determined")
validate_inputs(context, "project")
projected_shapes = []
obj: Shape
for obj in face_list + line_list:
obj_to_screen = (target.center() - obj.center()).normalized()
if working_plane.from_local_coords(obj_to_screen).Z < 0:
projection_direction = -working_plane.z_dir * projection_flip
else:
projection_direction = working_plane.z_dir * projection_flip
projection = obj.project_to_shape(target, projection_direction)
if projection:
if isinstance(context, BuildSketch):
projected_shapes.extend(
[working_plane.to_local_coords(p) for p in projection]
)
elif isinstance(context, BuildLine):
projected_shapes.extend(projection)
else: # BuildPart
projected_shapes.append(projection[0])
projected_points: ShapeList[Vector] = ShapeList()
for pnt in point_list:
pnt_to_target = (working_plane.origin - pnt).normalized()
if working_plane.from_local_coords(pnt_to_target).Z < 0:
projection_axis = -Axis(pnt, working_plane.z_dir * projection_flip)
else:
projection_axis = Axis(pnt, working_plane.z_dir * projection_flip)
intersection = working_plane.intersect(projection_axis)
if isinstance(intersection, Axis):
raise RuntimeError("working_plane and projection_axis are parallel")
if intersection is not None:
projected_points.append(working_plane.to_local_coords(intersection))
if context is not None:
context._add_to_context(*projected_shapes, mode=mode)
if projected_points:
result = projected_points
else:
result = Compound(projected_shapes)
if all([obj._dim == 2 for obj in object_list]):
result = Sketch(result.wrapped)
elif all([obj._dim == 1 for obj in object_list]):
result = Curve(result.wrapped)
return result
[docs]
def scale(
objects: Shape | Iterable[Shape] | None = None,
by: float | tuple[float, float, float] = 1,
mode: Mode = Mode.REPLACE,
) -> Curve | Sketch | Part | Compound:
"""Generic Operation: scale
Applies to 1, 2, and 3 dimensional objects.
Scale a sequence of objects. Note that when scaling non-uniformly across
the three axes, the type of the underlying object may change to bspline from
line, circle, etc.
Args:
objects (Edge | Face | Compound | Solid or Iterable of): objects to scale
by (float | tuple[float, float, float]): scale factor
mode (Mode, optional): combination mode. Defaults to Mode.REPLACE.
Raises:
ValueError: missing objects
"""
context: Builder | None = Builder._get_context("scale")
if objects is None:
if context is None or context is not None and context._obj is None:
raise ValueError("objects must be provided")
object_list = [context._obj]
else:
object_list = flatten_sequence(objects)
validate_inputs(context, "scale", object_list)
if isinstance(by, (int, float)):
factor = float(by)
elif (
isinstance(by, (tuple))
and len(by) == 3
and all(isinstance(s, (int, float)) for s in by)
):
by_vector = Vector(by)
scale_matrix = Matrix(
[
[by_vector.X, 0.0, 0.0, 0.0],
[0.0, by_vector.Y, 0.0, 0.0],
[0.0, 0.0, by_vector.Z, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
)
else:
raise ValueError("by must be a float or a three tuple of float")
new_objects = []
for obj in object_list:
if obj is None:
continue
current_location = obj.location
assert current_location is not None
obj_at_origin = obj.located(Location(Vector()))
if isinstance(by, (int, float)):
new_object = obj_at_origin.scale(factor).locate(current_location)
else:
new_object = obj_at_origin.transform_geometry(scale_matrix).locate(
current_location
)
new_objects.append(new_object)
if context is not None:
context._add_to_context(*new_objects, mode=mode)
scale_compound = Compound(new_objects)
if all([obj._dim == 3 for obj in object_list]):
scale_compound = Part(scale_compound.wrapped)
elif all([obj._dim == 2 for obj in object_list]):
scale_compound = Sketch(scale_compound.wrapped)
elif all([obj._dim == 1 for obj in object_list]):
scale_compound = Curve(scale_compound.wrapped)
return scale_compound.unwrap(fully=False)
SplitType: TypeAlias = Edge | Wire | Face | Solid
"""Type of objects which can be split"""
[docs]
def split(
objects: SplitType | Iterable[SplitType] | None = None,
bisect_by: Plane | Face | Shell = Plane.XZ,
keep: Keep = Keep.TOP,
mode: Mode = Mode.REPLACE,
):
"""Generic Operation: split
Applies to 1, 2, and 3 dimensional objects.
Bisect object with plane and keep either top, bottom or both.
Args:
objects (Edge | Wire | Face | Solid or Iterable of), objects to split
bisect_by (Plane | Face, optional): plane to segment part.
Defaults to Plane.XZ.
keep (Keep, optional): selector for which segment to keep. Defaults to Keep.TOP.
mode (Mode, optional): combination mode. Defaults to Mode.REPLACE.
Raises:
ValueError: missing objects
"""
context: Builder | None = Builder._get_context("split")
if objects is None:
if context is None or context is not None and context._obj is None:
raise ValueError("objects must be provided")
object_list = [context._obj]
else:
object_list = flatten_sequence(objects)
validate_inputs(context, "split", object_list)
new_objects: list[SplitType] = []
for obj in object_list:
bottom = None
if keep == Keep.BOTH:
top, bottom = obj.split(bisect_by, keep)
else:
top = obj.split(bisect_by, keep)
for subpart in [top, bottom]:
if isinstance(subpart, Iterable):
new_objects.extend(subpart)
elif subpart is not None:
new_objects.append(subpart)
if context is not None:
context._add_to_context(*new_objects, mode=mode)
split_compound = Compound(new_objects)
if all([obj._dim == 3 for obj in object_list]):
return Part(split_compound.wrapped)
if all([obj._dim == 2 for obj in object_list]):
return Sketch(split_compound.wrapped)
if all([obj._dim == 1 for obj in object_list]):
return Curve(split_compound.wrapped)
return split_compound
SweepType: TypeAlias = Compound | Edge | Wire | Face | Solid
"""Type of objects which can be swept"""
[docs]
def sweep(
sections: SweepType | Iterable[SweepType] | None = None,
path: Curve | Edge | Wire | Iterable[Edge] | None = None,
multisection: bool = False,
is_frenet: bool = False,
transition: Transition = Transition.TRANSFORMED,
normal: VectorLike | None = None,
binormal: Edge | Wire | None = None,
clean: bool = True,
mode: Mode = Mode.ADD,
) -> Part | Sketch:
"""Generic Operation: sweep
Sweep pending 1D or 2D objects along path.
Args:
sections (Compound | Edge | Wire | Face | Solid): cross sections to sweep into object
path (Curve | Edge | Wire, optional): path to follow.
Defaults to context pending_edges.
multisection (bool, optional): sweep multiple on path. Defaults to False.
is_frenet (bool, optional): use frenet algorithm. Defaults to False.
transition (Transition, optional): discontinuity handling option.
Defaults to Transition.TRANSFORMED.
normal (VectorLike, optional): fixed normal. Defaults to None.
binormal (Edge | Wire, optional): guide rotation along path. Defaults to None.
clean (bool, optional): Remove extraneous internal structure. Defaults to True.
mode (Mode, optional): combination. Defaults to Mode.ADD.
"""
context: Builder | None = Builder._get_context("sweep")
section_list = (
[*sections] if isinstance(sections, (list, tuple, filter)) else [sections]
)
section_list = [sec for sec in section_list if sec is not None]
validate_inputs(context, "sweep", section_list)
if path is None:
if (
context is None
or not isinstance(context, (BuildPart, BuildSketch))
or not context.pending_edges
):
raise ValueError("path must be provided")
path_wire = Wire(context.pending_edges)
context.pending_edges = []
else:
if isinstance(path, Iterable):
try:
path_wire = Wire(path)
except ValueError as err:
raise ValueError("Unable to build path from edges") from err
else:
path_wire = Wire(path.edges()) if not isinstance(path, Wire) else path
if not section_list:
if (
context is not None
and isinstance(context, BuildPart)
and context.pending_faces
):
section_list = context.pending_faces
context.pending_faces = []
context.pending_face_planes = []
else:
raise ValueError("No sections provided")
edge_list: list[Edge] = []
face_list: list[Face] = []
for sec in section_list:
if isinstance(sec, (Curve, Wire, Edge)):
edge_list.extend(sec.edges())
else:
face_list.extend(sec.faces())
# sweep to create solids
new_solids = []
binormal_mode: Wire | Vector | None
if face_list:
if binormal is None and normal is not None:
binormal_mode = Vector(normal)
elif isinstance(binormal, Edge):
binormal_mode = Wire([binormal])
else:
binormal_mode = binormal
if multisection:
sections = [face.outer_wire() for face in face_list]
new_solids = [
Solid.sweep_multi(sections, path_wire, True, is_frenet, binormal_mode)
]
else:
new_solids = [
Solid.sweep(
section=face,
path=path_wire,
make_solid=True,
is_frenet=is_frenet,
mode=binormal_mode,
transition=transition,
)
for face in face_list
]
# sweep to create faces
new_faces: list[Face] = []
if edge_list:
for sec in section_list:
swept = Shell.sweep(sec, path_wire, transition)
new_faces.extend(swept.faces())
if context is not None:
context._add_to_context(*(new_solids + new_faces), clean=clean, mode=mode)
elif clean:
new_solids = [solid.clean() for solid in new_solids]
new_faces = [face.clean() for face in new_faces]
if new_solids:
return Part(Compound(new_solids).wrapped)
return Sketch(Compound(new_faces).wrapped)