mirror of
https://github.com/gumyr/build123d.git
synced 2025-12-05 18:20:46 -08:00
1180 lines
41 KiB
Python
1180 lines
41 KiB
Python
"""
|
|
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
|
|
from abc import ABC, abstractmethod
|
|
from itertools import product
|
|
from math import sqrt
|
|
from typing import Callable, Iterable, Union
|
|
from typing_extensions import Self
|
|
|
|
from build123d.build_enums import Align, Mode, Select
|
|
from build123d.geometry import Axis, Location, Plane, Vector, VectorLike
|
|
from build123d.topology import (
|
|
Compound,
|
|
Curve,
|
|
Edge,
|
|
Face,
|
|
Part,
|
|
Shape,
|
|
ShapeList,
|
|
Sketch,
|
|
Solid,
|
|
Vertex,
|
|
Wire,
|
|
tuplify,
|
|
new_edges,
|
|
)
|
|
|
|
# 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
|
|
#
|
|
MM = 1
|
|
CM = 10 * MM
|
|
M = 1000 * MM
|
|
IN = 25.4 * MM
|
|
FT = 12 * IN
|
|
THOU = IN / 1000
|
|
|
|
|
|
operations_apply_to = {
|
|
"add": ["BuildPart", "BuildSketch", "BuildLine"],
|
|
"bounding_box": ["BuildPart", "BuildSketch", "BuildLine"],
|
|
"chamfer": ["BuildPart", "BuildSketch"],
|
|
"extrude": ["BuildPart"],
|
|
"fillet": ["BuildPart", "BuildSketch", "BuildLine"],
|
|
"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"],
|
|
}
|
|
|
|
|
|
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
|
|
|
|
"""
|
|
|
|
# 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 = None
|
|
_sub_class = None
|
|
|
|
@property
|
|
@abstractmethod
|
|
def _obj(self) -> Shape:
|
|
"""Object to pass to parent"""
|
|
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):
|
|
return new_edges(*([self.obj_before] + self.to_combine), combined=self._obj)
|
|
|
|
def __init__(
|
|
self,
|
|
*workplanes: Union[Face, Plane, Location],
|
|
mode: Mode = Mode.ADD,
|
|
):
|
|
self.mode = mode
|
|
self.workplanes = WorkplaneList._convert_to_planes(workplanes)
|
|
self._reset_tok = None
|
|
self._python_frame = inspect.currentframe().f_back.f_back
|
|
self.builder_parent = None
|
|
self.lasts: dict = {Vertex: [], Edge: [], Face: [], Solid: []}
|
|
self.workplanes_context = None
|
|
self.exit_workplanes = None
|
|
self.obj_before: Shape = None
|
|
self.to_combine: list[Shape] = None
|
|
|
|
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.workplanes = self.workplanes if self.workplanes else [Plane.XY]
|
|
|
|
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"""
|
|
pass
|
|
|
|
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]:
|
|
raise RuntimeError(
|
|
f"{self._obj_name} is None - {self._tag} didn't create anything"
|
|
)
|
|
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: Union[Edge, Face], face_plane: Plane = None):
|
|
"""Integrate a sequence of objects into existing builder object"""
|
|
return NotImplementedError # pragma: no cover
|
|
|
|
@classmethod
|
|
def _get_context(cls, caller: Union[Builder, str] = None, log: bool = True) -> Self:
|
|
"""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 result
|
|
|
|
def _add_to_context(
|
|
self,
|
|
*objects: Union[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
|
|
"""
|
|
self.obj_before = self._obj
|
|
self.to_combine = list(objects)
|
|
if mode != Mode.PRIVATE and len(objects) > 0:
|
|
# Categorize the input objects by type
|
|
typed = {}
|
|
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) - set(v for l in typed.values() for v in l)
|
|
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 = []
|
|
face: Face
|
|
for face in typed[Face]:
|
|
if not 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=face.normal_at(),
|
|
)
|
|
except:
|
|
plane = Plane(origin=(0, 0, 0), z_dir=face.normal_at())
|
|
|
|
face: Face = plane.to_local_coords(face)
|
|
face.move(Location((0, 0, -face.center().Z)))
|
|
if face.normal_at().Z > 0: # Flip the face if up-side-down
|
|
aligned.append(face)
|
|
else:
|
|
aligned.append(-face)
|
|
typed[Face] = aligned
|
|
|
|
# Convert wires to edges
|
|
wire: Wire
|
|
for wire in typed[Wire]:
|
|
typed[Edge].extend(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,
|
|
)
|
|
|
|
if mode == Mode.ADD:
|
|
if self._obj is None:
|
|
if len(typed[self._shape]) == 1:
|
|
self._obj = typed[self._shape][0]
|
|
else:
|
|
self._obj = (
|
|
typed[self._shape].pop().fuse(*typed[self._shape])
|
|
)
|
|
else:
|
|
self._obj = self._obj.fuse(*typed[self._shape])
|
|
elif mode == Mode.SUBTRACT:
|
|
if self._obj is None:
|
|
raise RuntimeError("Nothing to subtract from")
|
|
self._obj = self._obj.cut(*typed[self._shape])
|
|
elif mode == Mode.INTERSECT:
|
|
if self._obj is None:
|
|
raise RuntimeError("Nothing to intersect with")
|
|
self._obj = self._obj.intersect(*typed[self._shape])
|
|
elif mode == Mode.REPLACE:
|
|
self._obj = Compound.make_compound(list(typed[self._shape]))
|
|
|
|
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.make_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(face) for face 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
|
|
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 edge in self._obj.edges():
|
|
vertex_list.extend(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
|
|
"""
|
|
vertices = self.vertices(select)
|
|
vertex_count = len(vertices)
|
|
if vertex_count != 1:
|
|
warnings.warn(f"Found {vertex_count} vertices, returning first")
|
|
return vertices[0]
|
|
|
|
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
|
|
"""
|
|
edges = self.edges(select)
|
|
edge_count = len(edges)
|
|
if edge_count != 1:
|
|
warnings.warn(f"Found {edge_count} edges, returning first")
|
|
return edges[0]
|
|
|
|
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
|
|
"""
|
|
wires = self.wires(select)
|
|
wire_count = len(wires)
|
|
if wire_count != 1:
|
|
warnings.warn(f"Found {wire_count} wires, returning first")
|
|
return wires[0]
|
|
|
|
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
|
|
"""
|
|
faces = self.faces(select)
|
|
face_count = len(faces)
|
|
if face_count != 1:
|
|
warnings.warn(f"Found {face_count} faces, returning first")
|
|
return faces[0]
|
|
|
|
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
|
|
"""
|
|
solids = self.solids(select)
|
|
solid_count = len(solids)
|
|
if solid_count != 1:
|
|
warnings.warn(f"Found {solid_count} solids, returning first")
|
|
return solids[0]
|
|
|
|
def _shapes(self, obj_type: Union[Vertex, Edge, Face, Solid] = 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: Union[Shape, Iterable[Shape]] = 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})"
|
|
)
|
|
elif (
|
|
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 validate_inputs(
|
|
context: Builder, validating_class, objects: Iterable[Shape] = 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)
|
|
|
|
|
|
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 `apothem` to the radius of the circle
|
|
plus one half the spacing between the circles.
|
|
|
|
Args:
|
|
apothem (float): radius of the inscribed circle
|
|
xCount (int): number of points ( > 0 )
|
|
yCount (int): number of points ( > 0 )
|
|
align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object.
|
|
Defaults to (Align.CENTER, Align.CENTER).
|
|
|
|
Atributes:
|
|
apothem (float): radius of the inscribed circle
|
|
xCount (int): number of points ( > 0 )
|
|
yCount (int): number of points ( > 0 )
|
|
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,
|
|
apothem: float,
|
|
x_count: int,
|
|
y_count: int,
|
|
align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER),
|
|
):
|
|
diagonal = 4 * apothem / sqrt(3)
|
|
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.apothem = apothem
|
|
self.diagonal = diagonal
|
|
self.x_count = x_count
|
|
self.y_count = y_count
|
|
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 = []
|
|
for i in range(2):
|
|
if align[i] == Align.MIN:
|
|
align_offset.append(0)
|
|
elif align[i] == Align.CENTER:
|
|
align_offset.append(-size[i] / 2)
|
|
elif align[i] == Align.MAX:
|
|
align_offset.append(-size[i])
|
|
|
|
# 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)
|
|
|
|
super().__init__(self.local_locations)
|
|
|
|
|
|
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.
|
|
|
|
Atributes:
|
|
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,
|
|
):
|
|
if count < 1:
|
|
raise ValueError(f"At least 1 elements required, requested {count}")
|
|
|
|
angle_step = angular_range / count
|
|
|
|
# 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)
|
|
|
|
super().__init__(self.local_locations)
|
|
|
|
|
|
class Locations(LocationList):
|
|
"""Location Context: Push Points
|
|
|
|
Creates a context of locations for Part or Sketch
|
|
|
|
Args:
|
|
pts (Union[VectorLike, Vertex, Location]): sequence of points to push
|
|
|
|
Atributes:
|
|
local_locations (list{Location}): locations relative to workplane
|
|
|
|
"""
|
|
|
|
def __init__(self, *pts: Union[VectorLike, Vertex, Location, Face, Plane, Axis]):
|
|
local_locations = []
|
|
for point in 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)
|
|
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():
|
|
local_vertex_compound = Compound.make_compound(
|
|
[Face.make_rect(1, 1).locate(l) for l in local_locations]
|
|
)
|
|
for group_center in LocationList._get_context().local_locations:
|
|
location_group.extend(
|
|
[
|
|
v.location
|
|
for v in local_vertex_compound.moved(group_center).faces()
|
|
]
|
|
)
|
|
else:
|
|
location_group = local_locations
|
|
return location_group
|
|
|
|
|
|
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.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
x_spacing: float,
|
|
y_spacing: float,
|
|
x_count: int,
|
|
y_count: int,
|
|
align: Union[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)]
|
|
align_offset = []
|
|
for i in range(2):
|
|
if align[i] == Align.MIN:
|
|
align_offset.append(0.0)
|
|
elif align[i] == Align.CENTER:
|
|
align_offset.append(-size[i] / 2)
|
|
elif align[i] == Align.MAX:
|
|
align_offset.append(-size[i])
|
|
|
|
# Create the list of local locations
|
|
local_locations = []
|
|
for i, j in product(range(x_count), range(y_count)):
|
|
local_locations.append(
|
|
Location(
|
|
Vector(
|
|
i * x_spacing + align_offset[0],
|
|
j * y_spacing + align_offset[1],
|
|
)
|
|
)
|
|
)
|
|
|
|
self.local_locations = Locations._move_to_existing(local_locations)
|
|
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: Union[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[Union[Face, Plane, Location]]) -> list[Plane]:
|
|
"""Translate objects to planes"""
|
|
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)
|
|
|
|
@classmethod
|
|
def localize(cls, *points: VectorLike) -> Union[list[Vector], Vector]:
|
|
"""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 = [
|
|
workplane.from_local_coords(pt) if isinstance(pt, tuple) else 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:
|
|
result = points_per_workplane[0]
|
|
else:
|
|
result = points_per_workplane
|
|
return result
|
|
|
|
|
|
#
|
|
# 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`
|
|
# TODO make a specific `NoContextError` and raise that from `_get_context()` ?
|
|
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)
|
|
Vector.sub = _vector_add_sub_wrapper(Vector.sub)
|