"""
build123d Common
name: build_common.py
by: Gumyr
date: July 12th 2022
desc:
This is a Python code defining a class hierarchy for building CAD
models. The code defines an abstract base class Builder with three
concrete subclasses BuildLine, BuildPart, and BuildSketch in separate
modules.
The Builder class has several methods for adding and retrieving
geometric shapes such as vertices, edges, faces, and solids. It also
has a method _add_to_pending for adding shapes to a pending list that
will be integrated into the final model later. The class has a
_get_context method for retrieving the current Builder instance and a
validate_inputs method for validating input shapes.
The code also defines a validate_inputs function that takes a Builder
instance and validates the input shapes.
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.
"""
from __future__ import annotations
import contextvars
import inspect
import logging
import sys
import warnings
import functools
from abc import ABC, abstractmethod
from itertools import product
from math import sqrt, cos, pi
from typing import Any, cast, overload, Type, TypeVar
from collections.abc import Callable, Iterable
from typing_extensions import Self
from build123d.build_enums import Align, Mode, Select, Unit
from build123d.geometry import (
Axis,
Location,
Plane,
Vector,
VectorLike,
to_align_offset,
)
from build123d.topology import (
Compound,
Curve,
Edge,
Face,
Joint,
Part,
Shape,
ShapeList,
Sketch,
Solid,
Vertex,
Wire,
tuplify,
new_edges,
)
# pylint: disable=too-many-lines
# Create a build123d logger to distinguish these logs from application logs.
# If the user doesn't configure logging, all build123d logs will be discarded.
logging.getLogger("build123d").addHandler(logging.NullHandler())
logger = logging.getLogger("build123d")
# The recommended user log configuration is as follows:
# logging.basicConfig(
# filename="myapp.log",
# level=logging.INFO,
# format="%(name)s-%(levelname)s %(asctime)s - [%(filename)s:%(lineno)s - \
# %(funcName)20s() ] - %(message)s",
# )
# Where using %(name)s in the log format will distinguish between user and build123d library logs
#
# CONSTANTS
#
# LENGTH CONSTANTS
MC = 0.001
MM = 1
CM = 10 * MM
M = 1000 * MM
IN = 25.4 * MM
FT = 12 * IN
THOU = IN / 1000
# UNIT CONVERSIONS
UNITS_PER_METER = {
Unit.IN: M / IN,
Unit.FT: M / FT,
Unit.MC: M / MC,
Unit.MM: M / MM,
Unit.CM: M / CM,
Unit.M: 1,
}
# MASS CONSTANTS
G = 1
KG = 1000 * G
LB = 453.59237 * G
def _is_point(obj):
"""Identify points as tuples of numbers"""
return isinstance(obj, tuple) and all(
isinstance(item, (int, float)) for item in obj
)
T = TypeVar("T", Any, list[Any])
def flatten_sequence(*obj: T) -> ShapeList[Any]:
"""Convert a sequence of object potentially containing iterables into a flat list"""
flat_list: ShapeList[Any] = ShapeList()
for item in obj:
# Note: an Iterable can't be used here as it will match with Vector & Vertex
# and break them into a list of floats.
if isinstance(item, (list, tuple, filter, set)) and not _is_point(item):
flat_list.extend(flatten_sequence(*item))
else:
flat_list.append(item)
return flat_list
operations_apply_to = {
"add": ["BuildPart", "BuildSketch", "BuildLine"],
"bounding_box": ["BuildPart", "BuildSketch", "BuildLine"],
"chamfer": ["BuildPart", "BuildSketch", "BuildLine"],
"extrude": ["BuildPart"],
"fillet": ["BuildPart", "BuildSketch", "BuildLine"],
"full_round": ["BuildSketch"],
"loft": ["BuildPart"],
"make_brake_formed": ["BuildPart"],
"make_face": ["BuildSketch"],
"make_hull": ["BuildSketch"],
"mirror": ["BuildPart", "BuildSketch", "BuildLine"],
"offset": ["BuildPart", "BuildSketch", "BuildLine"],
"project": ["BuildPart", "BuildSketch", "BuildLine"],
"project_workplane": ["BuildPart"],
"revolve": ["BuildPart"],
"scale": ["BuildPart", "BuildSketch", "BuildLine"],
"section": ["BuildPart"],
"split": ["BuildPart", "BuildSketch", "BuildLine"],
"sweep": ["BuildPart", "BuildSketch"],
"thicken": ["BuildPart"],
}
B = TypeVar("B", bound="Builder")
"""Builder type hint"""
class Builder(ABC):
"""Builder
Base class for the build123d Builders.
Args:
workplanes: sequence of Union[Face, Plane, Location]: set plane(s) to work on
mode (Mode, optional): combination mode. Defaults to Mode.ADD.
Attributes:
mode (Mode): builder's combination mode
workplanes (list[Plane]): active workplanes
builder_parent (Builder): build to pass objects to on exit
"""
# pylint: disable=too-many-instance-attributes
# Context variable used to by Objects and Operations to link to current builder instance
_current: contextvars.ContextVar[Builder] = contextvars.ContextVar(
"Builder._current"
)
# Abstract class variables
_tag = "Builder"
_obj_name = "None"
# _shape: Shape # The type of the shape the builder creates
# _sub_class: Curve | Sketch | Part # The class of the shape the builder creates
def __init__(
self,
*workplanes: Face | Plane | Location,
mode: Mode = Mode.ADD,
):
self.mode = mode
planes = WorkplaneList._convert_to_planes(workplanes)
self.workplanes = planes if planes else [Plane.XY]
self._reset_tok = None
current_frame = inspect.currentframe()
assert current_frame is not None
assert current_frame.f_back is not None
self._python_frame = current_frame.f_back.f_back
self.parent_frame = None
self.builder_parent = None
self.lasts: dict = {Vertex: [], Edge: [], Face: [], Solid: []}
self.workplanes_context = None
self.exit_workplanes: list[Plane] = []
self.obj_before: Shape | None = None
self.to_combine: list[Shape] = []
@property
@abstractmethod
def _obj(self) -> Shape:
"""Object to pass to parent"""
raise NotImplementedError # pragma: no cover
@_obj.setter
@abstractmethod
def _obj(self, value: Part) -> None:
raise NotImplementedError # pragma: no cover
@property
def max_dimension(self) -> float:
"""Maximum size of object in all directions"""
return self._obj.bounding_box().diagonal if self._obj else 0.0
@property
def new_edges(self) -> ShapeList[Edge]:
"""Edges that changed during last operation"""
before_list = [] if self.obj_before is None else [self.obj_before]
return new_edges(*(before_list + self.to_combine), combined=self._obj)
def __enter__(self):
"""Upon entering record the parent and a token to restore contextvars"""
# Only set parents from the same scope. Note inspect.currentframe() is supported
# by CPython in Linux, Window & MacOS but may not be supported in other python
# implementations. Support outside of these OS's is outside the scope of this
# project.
same_scope = (
Builder._get_context()._python_frame == inspect.currentframe().f_back
if Builder._get_context()
else False
)
if same_scope:
self.builder_parent = Builder._get_context()
else:
self.builder_parent = None
self._reset_tok = self._current.set(self)
logger.info(
"Entering %s with mode=%s which is in %s scope as parent",
type(self).__name__,
self.mode,
"same" if same_scope else "different",
)
# If there are no workplanes, create a default XY plane
if self.workplanes:
self.workplanes_context = WorkplaneList(*self.workplanes).__enter__()
else:
self.workplanes_context = WorkplaneList(Plane.XY).__enter__()
return self
def _exit_extras(self):
"""Any builder specific exit actions"""
def __exit__(self, exception_type, exception_value, traceback):
"""Upon exiting restore context and send object to parent"""
self._current.reset(self._reset_tok)
self._exit_extras() # custom builder exit code
if self.builder_parent is not None and self.mode != Mode.PRIVATE:
logger.debug(
"Transferring object(s) to %s", type(self.builder_parent).__name__
)
if self._obj is None and not sys.exc_info()[1]:
warnings.warn(
f"{self._obj_name} is None - {self._tag} didn't create anything",
stacklevel=2,
)
self.builder_parent._add_to_context(self._obj, mode=self.mode)
self.exit_workplanes = WorkplaneList._get_context().workplanes
# Now that the object has been transferred, it's save to remove any (non-default)
# workplanes that were created then exit
if self.workplanes:
self.workplanes_context.__exit__(None, None, None)
logger.info("Exiting %s", type(self).__name__)
@abstractmethod
def _add_to_pending(self, *objects: Edge | Face, face_plane: Plane | None = None):
"""Integrate a sequence of objects into existing builder object"""
return NotImplementedError # pragma: no cover
@classmethod
def _get_context(
cls: Type[B],
caller: Builder | Shape | Joint | str | None = None,
log: bool = True,
) -> B | None:
"""Return the instance of the current builder"""
result = cls._current.get(None)
context_name = "None" if result is None else type(result).__name__
if log:
if isinstance(caller, (Part, Sketch, Curve, Wire)):
caller_name = caller.__class__.__name__
elif isinstance(caller, str):
caller_name = caller
else:
caller_name = "None"
logger.info("%s context requested by %s", context_name, caller_name)
return cast(B, result)
def _add_to_context(
self,
*objects: Edge | Wire | Face | Solid | Compound,
faces_to_pending: bool = True,
clean: bool = True,
mode: Mode = Mode.ADD,
):
"""Add objects to Builder instance
Core method to interface with Builder instance. Input sequence of objects is
parsed into lists of edges, faces, and solids. Edges and faces are added to pending
lists. Solids are combined with current part.
Each operation generates a list of vertices, edges, faces, and solids that have
changed during this operation. These lists are only guaranteed to be valid up until
the next operation as subsequent operations can eliminate these objects.
Args:
objects (Union[Edge, Wire, Face, Solid, Compound]): sequence of objects to add
faces_to_pending (bool, optional): add faces to pending_faces. Default to True.
clean (bool, optional): Remove extraneous internal structure. Defaults to True.
mode (Mode, optional): combination mode. Defaults to Mode.ADD.
Raises:
ValueError: Invalid input
ValueError: Nothing to intersect with
ValueError: Nothing to intersect with
"""
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
self.obj_before = self._obj
self.to_combine = list(objects)
if mode != Mode.PRIVATE and len(objects) > 0:
# Typed dictionary: keys are classes, values are lists of instances of those classes
typed: dict[
Type[Edge | Wire | Face | Solid | Compound],
list[Edge | Wire | Face | Solid | Compound],
] = {cls: [] for cls in [Edge, Wire, Face, Solid, Compound]}
for cls in [Edge, Wire, Face, Solid, Compound]:
typed[cls] = [obj for obj in objects if isinstance(obj, cls)]
# Check for invalid inputs
num_stored = sum(len(t) for t in typed.values())
# Generate an exception if not processing exceptions
if len(objects) != num_stored and not sys.exc_info()[1]:
unsupported = set(objects) - {v for l in typed.values() for v in l}
if unsupported != {None}:
raise ValueError(f"{self._tag} doesn't accept {unsupported}")
# Extract base objects from Compounds
compound: Compound
for compound in typed[Compound]:
for obj_types in [Edge, Wire, Face, Solid]:
typed[obj_types].extend(compound.get_type(obj_types))
# Align sketch planar faces with Plane.XY
if self._tag == "BuildSketch":
aligned = []
new_face: Face
for new_face in typed[Face]:
if not new_face.is_coplanar(Plane.XY):
# Try to keep the x direction, if not allow it to be assigned automatically
try:
plane = Plane(
origin=(0, 0, 0),
x_dir=(1, 0, 0),
z_dir=new_face.normal_at(),
)
except Exception:
plane = Plane(origin=(0, 0, 0), z_dir=new_face.normal_at())
new_face = plane.to_local_coords(new_face)
new_face.move(Location((0, 0, -new_face.center().Z)))
if new_face.normal_at().Z > 0: # Flip the face if up-side-down
aligned.append(new_face)
else:
aligned.append(-new_face)
typed[Face] = aligned
# Convert wires to edges
new_wire: Wire
for new_wire in typed[Wire]:
typed[Edge].extend(new_wire.edges())
# Allow faces to be combined with solids for section operations
if not faces_to_pending:
typed[Solid].extend(typed[Face])
typed[Face] = []
# Store the objects pre integration
pre = {}
for cls in [Vertex, Edge, Face, Solid]:
pre[cls] = set() if self._obj is None else set(self._shapes(cls))
if typed[self._shape]:
logger.debug(
"Attempting to integrate %d object(s) into part with Mode=%s",
len(typed[self._shape]),
mode,
)
combined: Shape | list[Shape] | None
if mode == Mode.ADD:
if self._obj is None:
if len(typed[self._shape]) == 1:
combined = typed[self._shape][0]
else:
combined = (
typed[self._shape].pop().fuse(*typed[self._shape])
)
else:
combined = self._obj.fuse(*typed[self._shape])
elif mode == Mode.SUBTRACT:
if self._obj is None:
raise RuntimeError("Nothing to subtract from")
combined = self._obj.cut(*typed[self._shape])
elif mode == Mode.INTERSECT:
if self._obj is None:
raise RuntimeError("Nothing to intersect with")
combined = self._obj.intersect(*typed[self._shape])
elif mode == Mode.REPLACE:
combined = self._sub_class(list(typed[self._shape]))
if combined is None: # empty intersection result
self._obj = self._sub_class()
elif isinstance(
combined, list
): # If the boolean operation created a list, convert back
self._obj = self._sub_class(combined)
else:
self._obj = combined
# If the boolean operation created a list, convert back
# self._obj = (
# self._sub_class(combined)
# if isinstance(combined, list)
# else combined
# )
if self._obj is not None and clean:
self._obj = self._obj.clean()
logger.info(
"Completed integrating %d object(s) into part with Mode=%s",
len(typed[self._shape]),
mode,
)
# Determine the last object
# Note that when determining the Select.LAST values for the core shape type of a builder
# the answer is just the categorized inputs to this method. I.e.
# Buildline.edges(Select.LAST) just returns the typed[Edge] values as that's what
# just was added - no need for the set math.
for cls in [Vertex, Edge, Face, Solid]:
post = set() if self._obj is None else set(self._shapes(cls))
self.lasts[cls] = (
ShapeList(typed[cls])
if self._shape == cls
else ShapeList(post - pre[cls])
)
# Cast to appropriate base types (Curve, Sketch or Part)
# _sub_class is an abstract class variable assigned in the sub classes
# pylint: disable=not-callable
if self._obj is not None:
if isinstance(self._obj, Compound):
self._obj = self._sub_class(self._obj.wrapped)
else:
self._obj = self._sub_class(Compound(self._shapes()).wrapped)
# Add to pending
if self._tag == "BuildPart":
self._add_to_pending(*typed[Edge])
for plane in WorkplaneList._get_context().workplanes:
global_faces = [plane.from_local_coords(f) for f in typed[Face]]
self._add_to_pending(*global_faces, face_plane=plane)
elif self._tag == "BuildSketch":
self._add_to_pending(*typed[Edge])
# Known pylint issue with Enums
# pylint: disable=no-member
[docs]
def vertices(self, select: Select = Select.ALL) -> ShapeList[Vertex]:
"""Return Vertices
Return either all or the vertices created during the last operation.
Args:
select (Select, optional): Vertex selector. Defaults to Select.ALL.
Returns:
ShapeList[Vertex]: Vertices extracted
"""
vertex_list: list[Vertex] = []
if select == Select.ALL:
for obj_edge in self._obj.edges():
vertex_list.extend(obj_edge.vertices())
elif select == Select.LAST:
vertex_list = self.lasts[Vertex]
elif select == Select.NEW:
raise ValueError("Select.NEW only valid for edges")
else:
raise ValueError(
f"Invalid input, must be one of Select.{Select._member_names_}"
)
return ShapeList(set(vertex_list))
def vertex(self, select: Select = Select.ALL) -> Vertex:
"""Return Vertex
Return a vertex.
Args:
select (Select, optional): Vertex selector. Defaults to Select.ALL.
Returns:
Vertex: Vertex extracted
"""
all_vertices = self.vertices(select)
vertex_count = len(all_vertices)
if vertex_count != 1:
warnings.warn(
f"Found {vertex_count} vertices, returning first",
stacklevel=2,
)
return all_vertices[0]
[docs]
def edges(self, select: Select = Select.ALL) -> ShapeList[Edge]:
"""Return Edges
Return either all or the edges created during the last operation.
Args:
select (Select, optional): Edge selector. Defaults to Select.ALL.
Returns:
ShapeList[Edge]: Edges extracted
"""
if select == Select.ALL:
edge_list = self._obj.edges()
elif select == Select.LAST:
edge_list = self.lasts[Edge]
elif select == Select.NEW:
edge_list = self.new_edges
else:
raise ValueError(
f"Invalid input, must be one of Select.{Select._member_names_}"
)
return ShapeList(edge_list)
def edge(self, select: Select = Select.ALL) -> Edge:
"""Return Edge
Return an edge.
Args:
select (Select, optional): Edge selector. Defaults to Select.ALL.
Returns:
Edge: Edge extracted
"""
all_edges = self.edges(select)
edge_count = len(all_edges)
if edge_count != 1:
warnings.warn(
f"Found {edge_count} edges, returning first",
stacklevel=2,
)
return all_edges[0]
[docs]
def wires(self, select: Select = Select.ALL) -> ShapeList[Wire]:
"""Return Wires
Return either all or the wires created during the last operation.
Args:
select (Select, optional): Wire selector. Defaults to Select.ALL.
Returns:
ShapeList[Wire]: Wires extracted
"""
if select == Select.ALL:
wire_list = self._obj.wires()
elif select == Select.LAST:
wire_list = Wire.combine(self.lasts[Edge])
elif select == Select.NEW:
raise ValueError("Select.NEW only valid for edges")
else:
raise ValueError(
f"Invalid input, must be one of Select.{Select._member_names_}"
)
return ShapeList(wire_list)
def wire(self, select: Select = Select.ALL) -> Wire:
"""Return Wire
Return a wire.
Args:
select (Select, optional): Wire selector. Defaults to Select.ALL.
Returns:
Wire: Wire extracted
"""
all_wires = self.wires(select)
wire_count = len(all_wires)
if wire_count != 1:
warnings.warn(
f"Found {wire_count} wires, returning first",
stacklevel=2,
)
return all_wires[0]
[docs]
def faces(self, select: Select = Select.ALL) -> ShapeList[Face]:
"""Return Faces
Return either all or the faces created during the last operation.
Args:
select (Select, optional): Face selector. Defaults to Select.ALL.
Returns:
ShapeList[Face]: Faces extracted
"""
if select == Select.ALL:
face_list = self._obj.faces()
elif select == Select.LAST:
face_list = self.lasts[Face]
elif select == Select.NEW:
raise ValueError("Select.NEW only valid for edges")
else:
raise ValueError(
f"Invalid input, must be one of Select.{Select._member_names_}"
)
return ShapeList(face_list)
def face(self, select: Select = Select.ALL) -> Face:
"""Return Face
Return a face.
Args:
select (Select, optional): Face selector. Defaults to Select.ALL.
Returns:
Face: Face extracted
"""
all_faces = self.faces(select)
face_count = len(all_faces)
if face_count != 1:
warnings.warn(
f"Found {face_count} faces, returning first",
stacklevel=2,
)
return all_faces[0]
[docs]
def solids(self, select: Select = Select.ALL) -> ShapeList[Solid]:
"""Return Solids
Return either all or the solids created during the last operation.
Args:
select (Select, optional): Solid selector. Defaults to Select.ALL.
Returns:
ShapeList[Solid]: Solids extracted
"""
if select == Select.ALL:
solid_list = self._obj.solids()
elif select == Select.LAST:
solid_list = self.lasts[Solid]
elif select == Select.NEW:
raise ValueError("Select.NEW only valid for edges")
else:
raise ValueError(
f"Invalid input, must be one of Select.{Select._member_names_}"
)
return ShapeList(solid_list)
def solid(self, select: Select = Select.ALL) -> Solid:
"""Return Solid
Return a solid.
Args:
select (Select, optional): Solid selector. Defaults to Select.ALL.
Returns:
Solid: Solid extracted
"""
all_solids = self.solids(select)
solid_count = len(all_solids)
if solid_count != 1:
warnings.warn(
f"Found {solid_count} solids, returning first",
stacklevel=2,
)
return all_solids[0]
def _shapes(
self,
obj_type: Type[Vertex] | Type[Edge] | Type[Face] | Type[Solid] | None = None,
) -> ShapeList:
"""Extract Shapes"""
obj_type = self._shape if obj_type is None else obj_type
if obj_type == Vertex:
result = self._obj.vertices()
elif obj_type == Edge:
result = self._obj.edges()
elif obj_type == Face:
result = self._obj.faces()
elif obj_type == Solid:
result = self._obj.solids()
else:
result = None
return result
def validate_inputs(
self, validating_class, objects: Shape | Iterable[Shape] | None = None
):
"""Validate that objects/operations and parameters apply"""
if not objects:
objects = []
elif not isinstance(objects, Iterable):
objects = [objects]
if (
isinstance(validating_class, (Part, Sketch, Curve, Wire))
and self._tag not in validating_class._applies_to
):
raise RuntimeError(
f"{self.__class__.__name__} doesn't have a "
f"{validating_class.__class__.__name__} object or operation "
f"({validating_class.__class__.__name__} applies to {validating_class._applies_to})"
)
if (
isinstance(validating_class, str)
and self.__class__.__name__ not in operations_apply_to[validating_class]
):
raise RuntimeError(
f"({validating_class} doesn't apply to {operations_apply_to[validating_class]})"
)
# Check for valid object inputs
for obj in objects:
operation = (
validating_class
if isinstance(validating_class, str)
else validating_class.__class__.__name__
)
if obj is None:
pass
elif not isinstance(obj, Shape):
raise RuntimeError(
f"{operation} doesn't accept {type(obj).__name__},"
f" did you intend <keyword>={obj}?"
)
def _invalid_combine(self):
"""Raise an error for invalid boolean combine operations"""
raise RuntimeError(
f"{self.__class__.__name__} is a builder of Shapes and can't be "
f"combined. The object being constructed is accessible via the "
f"'{self._obj_name}' attribute."
)
def __add__(self, _other) -> Self:
"""Invalid add"""
return self._invalid_combine()
def __sub__(self, _other) -> Self:
"""Invalid sub"""
return self._invalid_combine()
def __and__(self, _other) -> Self:
"""Invalid and"""
return self._invalid_combine()
def __getattr__(self, name):
"""The user is likely trying to reference the builder's object"""
raise AttributeError(
f"'{self.__class__.__name__}' has no attribute '{name}'. "
f"Did you intend '<{self.__class__.__name__}>.{self._obj_name}.{name}'?"
)
def validate_inputs(
context: Builder | None, validating_class, objects: Iterable[Shape] | None = None
):
"""A function to wrap the method when used outside of a Builder context"""
if context is None:
pass
else:
context.validate_inputs(validating_class, objects)
class LocationList:
"""Location Context
A stateful context of active locations. At least one must be active
at all time. Note that local locations are stored and global locations
are returned as a property of the local locations and the currently
active workplanes.
Args:
locations (list[Location]): list of locations to add to the context
"""
# Context variable used to link to LocationList instance
_current: contextvars.ContextVar[LocationList] = contextvars.ContextVar(
"ContextList._current"
)
@property
def locations(self) -> list[Location]:
"""Current local locations globalized with current workplanes"""
context = WorkplaneList._get_context()
workplanes = context.workplanes if context else [Plane.XY]
global_locations = [
plane.location * local_location
for plane in workplanes
for local_location in self.local_locations
]
return global_locations
def __init__(self, locations: list[Location]):
self._reset_tok = None
self.local_locations = locations
self.location_index = 0
self.plane_index = 0
self.iter_loc = None
def __enter__(self):
"""Upon entering create a token to restore contextvars"""
self._reset_tok = self._current.set(self)
logger.info(
"%s is pushing %d points: %s",
type(self).__name__,
len(self.local_locations),
self.local_locations,
)
return self
def __exit__(self, exception_type, exception_value, traceback):
"""Upon exiting restore context"""
self._current.reset(self._reset_tok)
logger.info(
"%s is popping %d points", type(self).__name__, len(self.local_locations)
)
def __iter__(self):
"""Initialize to beginning"""
self.location_index = 0
self.iter_loc = self.locations
return self
def __next__(self):
"""While not through all the locations, return the next one"""
if self.location_index >= len(self.iter_loc):
raise StopIteration
result = self.iter_loc[self.location_index]
self.location_index += 1
return result
def __mul__(self, shape: Shape) -> list[Shape]:
"""Vectorized application of locations to a shape"""
if not isinstance(shape, Shape):
raise ValueError("Location list can only be multiplied with shapes")
return [loc * shape for loc in self.locations]
@classmethod
def _get_context(cls):
"""Return the instance of the current LocationList"""
return cls._current.get(None)
[docs]
class HexLocations(LocationList):
"""Location Context: Hex Array
Creates a context of hexagon array of locations for Part or Sketch. When creating
hex locations for an array of circles, set `radius` to the radius of the circle
plus one half the spacing between the circles.
Args:
radius (float): distance from origin to vertices (major), or
optionally from the origin to side (minor or apothem)
with major_radius = False
x_count (int): number of points ( > 0 )
y_count (int): number of points ( > 0 )
major_radius (bool): If True the radius is the major radius, else the
radius is the minor radius (also known as inscribed radius).
Defaults to False.
align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object.
Defaults to (Align.CENTER, Align.CENTER).
Attributes:
radius (float): distance from origin to vertices (major), or
optionally from the origin to side (minor or apothem)
with major_radius = False
apothem (float): radius of the inscribed circle, also known as minor radius
x_count (int): number of points ( > 0 )
y_count (int): number of points ( > 0 )
major_radius (bool): If True the radius is the major radius, else the
radius is the minor radius (also known as inscribed radius).
align (Union[Align, tuple[Align, Align]]): align min, center, or max of object.
diagonal (float): major radius
local_locations (list{Location}): locations relative to workplane
Raises:
ValueError: Spacing and count must be > 0
"""
def __init__(
self,
radius: float,
x_count: int,
y_count: int,
major_radius: bool = False,
align: Align | tuple[Align, Align] = (Align.CENTER, Align.CENTER),
):
# pylint: disable=too-many-locals
if major_radius:
diagonal = 2 * radius
apothem = radius * cos(pi / 6)
else:
diagonal = 4 * radius / sqrt(3)
apothem = radius
x_spacing = 3 * diagonal / 4
y_spacing = diagonal * sqrt(3) / 2
if x_spacing <= 0 or y_spacing <= 0 or x_count < 1 or y_count < 1:
raise ValueError("Spacing and count must be > 0 ")
self.radius = radius
self.apothem = apothem
self.diagonal = diagonal
self.x_count = x_count
self.y_count = y_count
self.major_radius = major_radius
self.align = tuplify(align, 2)
# Generate the raw coordinates relative to bottom left point
points = ShapeList[Vector]()
for x_val in range(0, x_count, 2):
for y_val in range(y_count):
points.append(
Vector(x_spacing * x_val, y_spacing * y_val + y_spacing / 2)
)
for x_val in range(1, x_count, 2):
for y_val in range(y_count):
points.append(Vector(x_spacing * x_val, y_spacing * y_val + y_spacing))
# Determine the minimum point and size of the array
sorted_points = [points.sort_by(Axis.X), points.sort_by(Axis.Y)]
# pylint doesn't recognize that a ShapeList of Vector is valid
# pylint: disable=no-member
size = [
sorted_points[0][-1].X - sorted_points[0][0].X,
sorted_points[1][-1].Y - sorted_points[1][0].Y,
]
min_corner = Vector(sorted_points[0][0].X, sorted_points[1][0].Y)
# Calculate the amount to offset the array to align it
align_offset = to_align_offset((0, 0), size, align)
# Align the points
points = ShapeList(
[point + Vector(*align_offset) - min_corner for point in points]
)
# Convert to locations and store the reference plane
local_locations = [Location(point) for point in points]
self.local_locations = Locations._move_to_existing(
local_locations
) #: values independent of workplanes
super().__init__(self.local_locations)
[docs]
class PolarLocations(LocationList):
"""Location Context: Polar Array
Creates a context of polar array of locations for Part or Sketch
Args:
radius (float): array radius
count (int): Number of points to push
start_angle (float, optional): angle to first point from +ve X axis. Defaults to 0.0.
angular_range (float, optional): magnitude of array from start angle. Defaults to 360.0.
rotate (bool, optional): Align locations with arc tangents. Defaults to True.
endpoint (bool, optional): If True, `start_angle` + `angular_range` is the last sample.
Otherwise, it is not included. Defaults to False.
Attributes:
local_locations (list{Location}): locations relative to workplane
Raises:
ValueError: Count must be greater than or equal to 1
"""
def __init__(
self,
radius: float,
count: int,
start_angle: float = 0.0,
angular_range: float = 360.0,
rotate: bool = True,
endpoint: bool = False,
):
if count < 1:
raise ValueError(f"At least 1 elements required, requested {count}")
if count == 1:
angle_step = 0.0
else:
angle_step = angular_range / (count - int(endpoint))
# Note: rotate==False==0 so the location orientation doesn't change
local_locations = []
for i in range(count):
local_locations.append(
Location(
Vector(radius, 0).rotate(Axis.Z, start_angle + angle_step * i),
Vector(0, 0, 1),
rotate * (angle_step * i + start_angle),
)
)
self.local_locations = Locations._move_to_existing(
local_locations
) #: values independent of workplanes
super().__init__(self.local_locations)
[docs]
class Locations(LocationList):
"""Location Context: Push Points
Creates a context of locations for Part or Sketch
Args:
pts (Union[VectorLike, Vertex, Location, Face, Plane, Axis] or iterable of same):
sequence of points to push
Attributes:
local_locations (list{Location}): locations relative to workplane
"""
def __init__(
self,
*pts: (
VectorLike
| Vertex
| Location
| Face
| Plane
| Axis
| Iterable[VectorLike | Vertex | Location | Face | Plane | Axis]
),
):
local_locations = []
for point in flatten_sequence(*pts):
if isinstance(point, Location):
local_locations.append(point)
elif isinstance(point, Vector):
local_locations.append(Location(point))
elif isinstance(point, Vertex):
local_locations.append(Location(Vector(point.to_tuple())))
elif isinstance(point, tuple):
local_locations.append(Location(Vector(point)))
elif isinstance(point, Plane):
local_locations.append(Location(point))
elif isinstance(point, Axis):
local_locations.append(point.location)
elif isinstance(point, Face):
local_locations.append(Location(Plane(point)))
else:
raise ValueError(f"Locations doesn't accept type {type(point)}")
self.local_locations = Locations._move_to_existing(
local_locations
) #: values independent of workplanes
super().__init__(self.local_locations)
@staticmethod
def _move_to_existing(local_locations: list[Location]) -> list[Location]:
"""_move_to_existing
Move as a group the local locations to any existing locations Note that existing
polar locations may be rotated so this rotates the group not the individuals.
Args:
local_locations (list[Location]): location group to move to existing locations
Returns:
list[Location]: group of locations moved to existing locations as a group
"""
location_group = []
if LocationList._get_context() is not None:
for group_center in LocationList._get_context().local_locations:
location_group.extend([group_center * l for l in local_locations])
else:
location_group = local_locations
return location_group
[docs]
class GridLocations(LocationList):
"""Location Context: Rectangular Array
Creates a context of rectangular array of locations for Part or Sketch
Args:
x_spacing (float): horizontal spacing
y_spacing (float): vertical spacing
x_count (int): number of horizontal points
y_count (int): number of vertical points
align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object.
Defaults to (Align.CENTER, Align.CENTER).
Attributes:
x_spacing (float): horizontal spacing
y_spacing (float): vertical spacing
x_count (int): number of horizontal points
y_count (int): number of vertical points
align (Union[Align, tuple[Align, Align]]): align min, center, or max of object.
local_locations (list{Location}): locations relative to workplane
Raises:
ValueError: Either x or y count must be greater than or equal to one.
"""
# pylint: disable=too-many-instance-attributes
def __init__(
self,
x_spacing: float,
y_spacing: float,
x_count: int,
y_count: int,
align: Align | tuple[Align, Align] = (Align.CENTER, Align.CENTER),
):
if x_count < 1 or y_count < 1:
raise ValueError(
f"At least 1 elements required, requested {x_count}, {y_count}"
)
self.x_spacing = x_spacing
self.y_spacing = y_spacing
self.x_count = x_count
self.y_count = y_count
self.align = tuplify(align, 2)
size = [x_spacing * (x_count - 1), y_spacing * (y_count - 1)]
self.size = Vector(*size) #: size of the grid
align_offset = to_align_offset((0, 0), size, align)
self.min = align_offset #: bottom left corner
self.max = self.min + self.size #: top right corner
# Create the list of local locations
local_locations = [
Location(
align_offset
+ Vector(
i * x_spacing,
j * y_spacing,
)
)
for i, j in product(range(x_count), range(y_count))
]
self.local_locations = Locations._move_to_existing(
local_locations
) #: values independent of workplanes
self.planes: list[Plane] = []
super().__init__(self.local_locations)
class WorkplaneList:
"""Workplane Context
A stateful context of active workplanes. At least one must be active
at all time.
Args:
workplanes (sequence of Union[Face, Plane, Location]): objects to become planes
Attributes:
workplanes (list[Plane]): list of workplanes
"""
# Context variable used to link to WorkplaneList instance
_current: contextvars.ContextVar[WorkplaneList] = contextvars.ContextVar(
"WorkplaneList._current"
)
def __init__(self, *workplanes: Face | Plane | Location):
self._reset_tok = None
self.workplanes = WorkplaneList._convert_to_planes(workplanes)
self.locations_context = None
self.plane_index = 0
@staticmethod
def _convert_to_planes(objs: Iterable[Face | Plane | Location]) -> list[Plane]:
"""Translate objects to planes"""
objs = flatten_sequence(*objs)
planes = []
for obj in objs:
if isinstance(obj, Plane):
planes.append(obj)
elif isinstance(obj, (Location, Face)):
planes.append(Plane(obj))
else:
raise ValueError(f"WorkplaneList does not accept {type(obj)}")
return planes
def __enter__(self):
"""Upon entering create a token to restore contextvars"""
self._reset_tok = self._current.set(self)
logger.info(
"%s is pushing %d workplanes: %s",
type(self).__name__,
len(self.workplanes),
self.workplanes,
)
self.locations_context = LocationList([Location(Vector())]).__enter__()
return self
def __exit__(self, exception_type, exception_value, traceback):
"""Upon exiting restore context"""
self._current.reset(self._reset_tok)
self.locations_context.__exit__(None, None, None)
logger.info(
"%s is popping %d workplanes", type(self).__name__, len(self.workplanes)
)
def __iter__(self):
"""Initialize to beginning"""
self.plane_index = 0
return self
def __next__(self):
"""While not through all the workplanes, return the next one"""
if self.plane_index >= len(self.workplanes):
raise StopIteration
result = self.workplanes[self.plane_index]
self.plane_index += 1
return result
@classmethod
def _get_context(cls):
"""Return the instance of the current ContextList"""
return cls._current.get(None)
@overload
@classmethod
def localize(cls, points: VectorLike) -> Vector: ... # type: ignore[overload-overlap]
@overload
@classmethod
def localize(cls, *points: VectorLike) -> list[Vector]: ...
@classmethod # type: ignore[misc]
def localize(cls, *points: VectorLike):
"""Localize a sequence of points to the active workplane
(only used by BuildLine where there is only one active workplane)
The return value is conditional:
- 1 point -> Vector
- >1 points -> list[Vector]
"""
if WorkplaneList._get_context() is None:
points_per_workplane = [Vector(p) for p in points]
else:
points_per_workplane = []
workplane = WorkplaneList._get_context().workplanes[0]
localized_pts = [
(
cast(Vector, workplane.from_local_coords(Vector(pt)))
if isinstance(pt, tuple)
else Vector(pt)
)
for pt in points
]
if len(localized_pts) == 1:
points_per_workplane.append(localized_pts[0])
else:
points_per_workplane.extend(localized_pts)
if len(points_per_workplane) == 1:
return points_per_workplane[0]
return points_per_workplane
# Type variable representing the return type of the wrapped function
T2 = TypeVar("T2")
def __gen_context_component_getter(
func: Callable[[Builder, Select], T2]
) -> Callable[[Select], T2]:
"""
Wraps a Builder method to automatically provide the Builder context.
This function creates a wrapper around the provided Builder method (`func`) that
automatically retrieves the current Builder context and passes it as the first
argument to the method. This allows the method to be called without explicitly
providing the Builder context.
Args:
func (Callable[[Builder, Select], T2]): The Builder method to be wrapped.
- The method must take a `Builder` instance as its first argument and
a `Select` instance as its second argument.
Returns:
Callable[[Select], T2]: A callable that takes only a `Select` argument and
internally retrieves the Builder context to call the original method.
Raises:
RuntimeError: If no Builder context is available when the returned function
is called.
"""
@functools.wraps(func)
def getter(select: Select = Select.ALL) -> T2:
# Retrieve the current Builder context based on the method name
context = Builder._get_context(func.__name__)
if not context:
raise RuntimeError(
f"{func.__name__}() requires a Builder context to be in scope"
)
# Call the original method with the retrieved context and provided select
return func(context, select)
return getter
# The following functions are used to get the shapes from the builder in context
vertices = __gen_context_component_getter(Builder.vertices)
edges = __gen_context_component_getter(Builder.edges)
wires = __gen_context_component_getter(Builder.wires)
faces = __gen_context_component_getter(Builder.faces)
solids = __gen_context_component_getter(Builder.solids)
vertex = __gen_context_component_getter(Builder.vertex)
edge = __gen_context_component_getter(Builder.edge)
wire = __gen_context_component_getter(Builder.wire)
face = __gen_context_component_getter(Builder.face)
solid = __gen_context_component_getter(Builder.solid)
#
# To avoid import loops, Vector add & sub are monkey-patched
def _vector_add_sub_wrapper(original_op: Callable[[Vector, VectorLike], Vector]):
def wrapper(self: Vector, vec: VectorLike):
if isinstance(vec, tuple):
try:
# Relative adds must take into consideration planes with non-zero origins
origin = WorkplaneList._get_context().workplanes[0].origin
vec = WorkplaneList.localize(vec) - origin # type: ignore[union-attr]
except AttributeError:
# raised from `WorkplaneList._get_context().workplanes[0]` when context is `None`
pass
return original_op(self, vec)
return wrapper
logger.debug("monkey-patching `Vector.add` and `Vector.sub`")
Vector.add = _vector_add_sub_wrapper(Vector.add) # type: ignore
Vector.sub = _vector_add_sub_wrapper(Vector.sub) # type: ignore