Merge branch 'dev' into algebra

This commit is contained in:
Roger Maitland 2023-03-31 11:55:15 -04:00
commit 94063d3f60
8 changed files with 1152 additions and 62 deletions

View file

@ -100,8 +100,8 @@ prompt users for valid options without having to refer to documentation.
Selectors replaced by Lists
===========================
String based selectors have been replaced with standard python filters and
sorting which opens up the fully functionality of python list functionality.
To aid the user, common operations have been optimized as shown here along with
sorting which opens up the full functionality of python lists. To aid the
user, common operations have been optimized as shown here along with
a fully custom selection:
.. code-block:: python

View file

@ -960,7 +960,7 @@ progressively modify the size of each square.
36. Extrude Until
---------------------------------------------------
Sometimes you will want to extrude until a given face that can be not planar or
Sometimes you will want to extrude until a given face that could be non planar or
where you might not know easily the distance you have to extrude to. In such
cases you can use :class:`~operations_part.extrude` :class:`~build_enums.Until`
with ``Until.NEXT`` or ``Until.LAST``.

54
examples/shamrock.py Normal file
View file

@ -0,0 +1,54 @@
from build123d import *
class Shamrock(BaseSketchObject):
"""Sketch Object: Shamrock
Adds a four leaf clover
Args:
height (float): y axis dimension
rotation (float, optional): angle in degrees. Defaults to 0.
align (tuple[Align, Align], optional): alignment. Defaults to (Align.CENTER, Align.CENTER).
mode (Mode, optional): combination mode. Defaults to Mode.ADD.
"""
def __init__(
self,
height: float,
rotation: float = 0,
align: tuple[Align, Align] = (Align.CENTER, Align.CENTER),
mode: Mode = Mode.ADD,
):
with BuildSketch() as shamrock:
with BuildLine():
b0 = Bezier((240, 310), (112, 325), (162, 438), (252, 470))
b1 = Bezier(b0 @ 1, (136, 431), (73, 589), (179, 643))
b2 = Bezier(b1 @ 1, (151, 747), (293, 770), (360, 679))
b3 = Bezier(b2 @ 1, (358, 736), (366, 789), (392, 840))
l0 = Line(b3 @ 1, (407, 834))
b4 = Bezier(l0 @ 1, (366, 781), (374, 670), (374, 670))
b5 = Bezier(b4 @ 1, (373, 794), (506, 789), (528, 727))
b6 = Bezier(b5 @ 1, (636, 733), (638, 578), (507, 541))
b7 = Bezier(b6 @ 1, (628, 559), (651, 380), (575, 365))
b8 = Bezier(b7 @ 1, (592, 269), (420, 268), (417, 361))
b9 = Bezier(b8 @ 1, (410, 253), (262, 222), b0 @ 0)
Mirror(about=Plane.XZ, mode=Mode.REPLACE)
MakeFace()
Scale(by=height / shamrock.sketch.bounding_box().size.Y)
super().__init__(
obj=shamrock.sketch.translate(
-shamrock.sketch.center(CenterOf.BOUNDING_BOX)
),
rotation=rotation,
align=align,
mode=mode,
)
if __name__ == "__main__" or "show_object" in locals():
with BuildSketch() as shamrock_example:
Shamrock(10)
if "show_object" in locals():
show_object(shamrock_example.sketch)

View file

@ -34,7 +34,7 @@ import warnings
from abc import ABC, abstractmethod
from itertools import product
from math import sqrt
from typing import Iterable, Union, List
from typing import Iterable, Union
from typing_extensions import Self
from build123d.build_enums import Align, Mode, Select

906
src/build123d/exporters.py Normal file
View file

@ -0,0 +1,906 @@
# pylint has trouble with the OCP imports
# pylint: disable=no-name-in-module, import-error
from build123d import *
from build123d import Shape
from OCP.GeomConvert import GeomConvert_BSplineCurveToBezierCurve #type: ignore
from OCP.GeomConvert import GeomConvert #type: ignore
from OCP.Geom import Geom_BSplineCurve, Geom_BezierCurve #type: ignore
from OCP.gp import gp_XYZ, gp_Pnt, gp_Vec, gp_Dir, gp_Ax2 #type: ignore
from OCP.BRepLib import BRepLib #type: ignore
from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape #type: ignore
from OCP.HLRAlgo import HLRAlgo_Projector #type: ignore
from typing import Callable, List, Union, Tuple, Dict, Optional
from typing_extensions import Self
import svgpathtools as PT
import xml.etree.ElementTree as ET
from enum import Enum, auto
import ezdxf
from ezdxf import zoom
from ezdxf.math import Vec2
from ezdxf.colors import aci2rgb
from ezdxf.tools.standards import linetypes as ezdxf_linetypes
import math
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
class Drawing(object):
def __init__(
self,
shape: Shape,
*,
look_at: VectorLike = None,
look_from: VectorLike = (1, -1, 1),
look_up: VectorLike = (0, 0, 1),
with_hidden: bool = True,
focus: Union[float, None] = None,
):
hlr = HLRBRep_Algo()
hlr.Add(shape.wrapped)
projection_origin = Vector(look_at) if look_at else shape.center()
projection_dir = Vector(look_from).normalized()
projection_x = Vector(look_up).normalized().cross(projection_dir)
coordinate_system = gp_Ax2(
projection_origin.to_pnt(),
projection_dir.to_dir(),
projection_x.to_dir()
)
if focus is not None:
projector = HLRAlgo_Projector(coordinate_system, focus)
else:
projector = HLRAlgo_Projector(coordinate_system)
hlr.Projector(projector)
hlr.Update()
hlr.Hide()
hlr_shapes = HLRBRep_HLRToShape(hlr)
visible = []
visible_sharp_edges = hlr_shapes.VCompound()
if not visible_sharp_edges.IsNull():
visible.append(visible_sharp_edges)
visible_smooth_edges = hlr_shapes.Rg1LineVCompound()
if not visible_smooth_edges.IsNull():
visible.append(visible_smooth_edges)
visible_contour_edges = hlr_shapes.OutLineVCompound()
if not visible_contour_edges.IsNull():
visible.append(visible_contour_edges)
hidden = []
if with_hidden:
hidden_sharp_edges = hlr_shapes.HCompound()
if not hidden_sharp_edges.IsNull():
hidden.append(hidden_sharp_edges)
hidden_contour_edges = hlr_shapes.OutLineHCompound()
if not hidden_contour_edges.IsNull():
hidden.append(hidden_contour_edges)
# magic number from CQ
# TODO: figure out the proper source of this value.
tolerance = 1e-6
# Fix the underlying geometry - otherwise we will get segfaults
for el in visible:
BRepLib.BuildCurves3d_s(el, tolerance)
for el in hidden:
BRepLib.BuildCurves3d_s(el, tolerance)
# Convert and store the results.
self.visible_lines = Compound.make_compound(map(Shape, visible))
self.hidden_lines = Compound.make_compound(map(Shape, hidden))
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
class AutoNameEnum(Enum):
def _generate_next_value_(name, start, count, last_values):
return name
class LineType(AutoNameEnum):
CONTINUOUS = auto()
CENTERX2 = auto()
CENTER2 = auto()
DASHED = auto()
DASHEDX2 = auto()
DASHED2 = auto()
PHANTOM = auto()
PHANTOMX2 = auto()
PHANTOM2 = auto()
DASHDOT = auto()
DASHDOTX2 = auto()
DASHDOT2 = auto()
DOT = auto()
DOTX2 = auto()
DOT2 = auto()
DIVIDE = auto()
DIVIDEX2 = auto()
DIVIDE2 = auto()
ISO_DASH = 'ACAD_ISO02W100' # __ __ __ __ __ __ __ __ __ __ __ __ __
ISO_DASH_SPACE = 'ACAD_ISO03W100' # __ __ __ __ __ __
ISO_LONG_DASH_DOT = 'ACAD_ISO04W100' # ____ . ____ . ____ . ____ . _
ISO_LONG_DASH_DOUBLE_DOT = 'ACAD_ISO05W100' # ____ .. ____ .. ____ .
ISO_LONG_DASH_TRIPLE_DOT = 'ACAD_ISO06W100' # ____ ... ____ ... ____
ISO_DOT = 'ACAD_ISO07W100' # . . . . . . . . . . . . . . . . . . . .
ISO_LONG_DASH_SHORT_DASH = 'ACAD_ISO08W100' # ____ __ ____ __ ____ _
ISO_LONG_DASH_DOUBLE_SHORT_DASH = 'ACAD_ISO09W100' # ____ __ __ ____
ISO_DASH_DOT = 'ACAD_ISO10W100' # __ . __ . __ . __ . __ . __ . __ .
ISO_DOUBLE_DASH_DOT = 'ACAD_ISO11W100' # __ __ . __ __ . __ __ . __ _
ISO_DASH_DOUBLE_DOT = 'ACAD_ISO12W100' # __ . . __ . . __ . . __ . .
ISO_DOUBLE_DASH_DOUBLE_DOT = 'ACAD_ISO13W100' # __ __ . . __ __ . . _
ISO_DASH_TRIPLE_DOT = 'ACAD_ISO14W100' # __ . . . __ . . . __ . . . _
ISO_DOUBLE_DASH_TRIPLE_DOT = 'ACAD_ISO15W100' # __ __ . . . __ __ . .
class ColorIndex(Enum):
RED = 1
YELLOW = 2
GREEN = 3
CYAN = 4
BLUE = 5
MAGENTA = 6
BLACK = 7
GRAY = 8
LIGHT_GRAY = 9
def lin_pattern(*args):
"""Convert an ISO line pattern from the values found in a standard
AutoCAD .lin file to the values expected by ezdxf. Specifically,
prepend the sum of the absolute values of the lengths, and divide
by 2.54 to convert the units from mm to 1/10in."""
abs_args = [abs(l) for l in args]
result = [(l / 2.54) for l in [sum(abs_args), *args]]
return result
# Scale factor to convert various units to meters.
UNITS_PER_METER = {
Unit.INCH: 100/2.54,
Unit.FOOT: 100/(12*2.54),
Unit.MICRO: 1_000_000,
Unit.MILLIMETER: 1000,
Unit.CENTIMETER: 100,
Unit.METER: 1,
}
def unit_conversion_scale(from_unit: Unit, to_unit: Unit) -> float:
result = UNITS_PER_METER[to_unit] / UNITS_PER_METER[from_unit]
return result
# ---------------------------------------------------------------------------
#
# ---------------------------------------------------------------------------
class Export2D(object):
"""Base class for 2D exporters (DXF, SVG)."""
# When specifying a parametric interval [u1, u2] on a spline,
# OCCT considers two parameters to be equal if
# abs(u1-u2) < tolerance, and generally raises an exception in
# this case.
PARAMETRIC_TOLERANCE = 1e-9
DEFAULT_COLOR_INDEX = ColorIndex.BLACK
DEFAULT_LINE_WEIGHT = 0.09
DEFAULT_LINE_TYPE = LineType.CONTINUOUS
# Pull default (ANSI) linetypes out of ezdxf for more convenient
# lookup and add some ISO linetypes.
LINETYPE_DEFS = {
name: (desc, pattern)
for name, desc, pattern in ezdxf_linetypes()
} | {
LineType.ISO_DASH.value: (
"ISO dash __ __ __ __ __ __ __ __ __ __ __ __ __",
lin_pattern(12,-3)
),
LineType.ISO_DASH_SPACE.value: (
"ISO dash space __ __ __ __ __ __",
lin_pattern(12,-18)
),
LineType.ISO_LONG_DASH_DOT.value: (
"ISO long-dash dot ____ . ____ . ____ . ____ . _",
lin_pattern(24,-3,0,-3)
),
LineType.ISO_LONG_DASH_DOUBLE_DOT.value: (
"ISO long-dash double-dot ____ .. ____ .. ____ . ",
lin_pattern(24,-3,0,-3,0,-3)
),
LineType.ISO_LONG_DASH_TRIPLE_DOT.value: (
"ISO long-dash triple-dot ____ ... ____ ... ____",
lin_pattern(24,-3,0,-3,0,-3,0,-3)
),
LineType.ISO_DOT.value: (
"ISO dot . . . . . . . . . . . . . . . . . . . . ",
lin_pattern(0,-3)
),
LineType.ISO_LONG_DASH_SHORT_DASH.value: (
"ISO long-dash short-dash ____ __ ____ __ ____ _",
lin_pattern(24,-3,6,-3)
),
LineType.ISO_LONG_DASH_DOUBLE_SHORT_DASH.value: (
"ISO long-dash double-short-dash ____ __ __ ____",
lin_pattern(24,-3,6,-3,6,-3)
),
LineType.ISO_DASH_DOT.value: (
"ISO dash dot __ . __ . __ . __ . __ . __ . __ . ",
lin_pattern(12,-3,0,-3)
),
LineType.ISO_DOUBLE_DASH_DOT.value: (
"ISO double-dash dot __ __ . __ __ . __ __ . __ _",
lin_pattern(12,-3,12,-3,0,-3)
),
LineType.ISO_DASH_DOUBLE_DOT.value: (
"ISO dash double-dot __ . . __ . . __ . . __ . . ",
lin_pattern(12,-3,0,-3,0,-3)
),
LineType.ISO_DOUBLE_DASH_DOUBLE_DOT.value: (
"ISO double-dash double-dot __ __ . . __ __ . . _",
lin_pattern(12,-3,12,-3,0,-3,0,-3)
),
LineType.ISO_DASH_TRIPLE_DOT.value: (
"ISO dash triple-dot __ . . . __ . . . __ . . . _",
lin_pattern(12,-3,0,-3,0,-3,0,-3)
),
LineType.ISO_DOUBLE_DASH_TRIPLE_DOT.value: (
"ISO double-dash triple-dot __ __ . . . __ __ . .",
lin_pattern(12,-3,12,-3,0,-3,0,-3,0,-3)
),
}
# Scale factor to convert from linetype units (1/10 inch).
LTYPE_SCALE = {
Unit.INCH: 0.1,
Unit.FOOT: 0.1/12,
Unit.MILLIMETER: 2.54,
Unit.CENTIMETER: 0.254,
Unit.METER: 0.00254,
}
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
class ExportDXF(Export2D):
UNITS_LOOKUP = {
Unit.MICRO : 13,
Unit.MILLIMETER: ezdxf.units.MM,
Unit.CENTIMETER: ezdxf.units.CM,
Unit.METER : ezdxf.units.M,
Unit.INCH : ezdxf.units.IN,
Unit.FOOT : ezdxf.units.FT,
}
METRIC_UNITS = {
Unit.MILLIMETER,
Unit.CENTIMETER,
Unit.METER,
}
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def __init__(
self,
version: str = ezdxf.DXF2013,
unit: Unit = Unit.MILLIMETER,
color: Optional[ColorIndex] = None,
line_weight: Optional[float] = None,
line_type: Optional[LineType] = None,
):
if unit not in self.UNITS_LOOKUP:
raise ValueError(f"unit `{unit.name}` not supported.")
if unit in ExportDXF.METRIC_UNITS:
self._linetype_scale = Export2D.LTYPE_SCALE[Unit.MILLIMETER]
else:
self._linetype_scale = 1
self._document = ezdxf.new(
dxfversion = version,
units = self.UNITS_LOOKUP[unit],
setup = False,
)
self._modelspace = self._document.modelspace()
default_layer = self._document.layers.get('0')
if color is not None:
default_layer.color = color.value
if line_weight is not None:
default_layer.dxf.lineweight = round(line_weight * 100)
if line_type is not None:
default_layer.dxf.linetype = self._linetype(line_type)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def add_layer(
self,
name: str,
*,
color: Optional[ColorIndex] = None,
line_weight: Optional[float] = None,
line_type: Optional[LineType] = None,
) -> Self:
"""Create a layer definition
Refer to :ref:`ezdxf layers <ezdxf-stable:layer_concept>` and
:doc:`ezdxf layer tutorial <ezdxf-stable:tutorials/layers>`.
:param name: layer definition name
:param color: color index.
:param linetype: ezdxf :doc:`line type <ezdxf-stable:concepts/linetypes>`
"""
kwargs = {}
if line_type is not None:
linetype = self._linetype(line_type)
kwargs['linetype'] = linetype
if color is not None:
kwargs['color'] = color.value
if line_weight is not None:
kwargs['lineweight'] = round(line_weight * 100)
self._document.layers.add(name, **kwargs)
return self
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _linetype(self, line_type: LineType) -> str:
"""Ensure that the specified LineType has been defined in the document,
and return its string name."""
linetype = line_type.value
if linetype not in self._document.linetypes:
# The linetype is not in the doc yet.
# Add it from our available definitions.
if linetype in Export2D.LINETYPE_DEFS:
desc, pattern = Export2D.LINETYPE_DEFS.get(linetype)
self._document.linetypes.add(
name=linetype,
pattern=[self._linetype_scale * v for v in pattern],
description=desc,
)
else:
raise ValueError("Unknown linetype `{linetype}`.")
return linetype
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def add_shape(self, shape: Shape, layer: str = "") -> Self:
self._non_planar_point_count = 0
attributes = {}
if layer:
attributes["layer"] = layer
for edge in shape.edges():
self._convert_edge(edge, attributes)
if self._non_planar_point_count > 0:
print(f"WARNING, exporting non-planar shape to 2D format.")
print(" This is probably not what you want.")
print(f" {self._non_planar_point_count} points found outside the XY plane.")
return self
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def write(self, file_name: str):
# Reset the main CAD viewport of the model space to the
# extents of its entities.
# TODO: Expose viewport control to the user.
# Do the same for ExportSVG.
zoom.extents(self._modelspace)
self._document.saveas(file_name)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _convert_point(self, pt: Union[gp_XYZ, gp_Pnt, gp_Vec, Vector]) -> Vec2:
"""Create a Vec2 from a gp_Pnt or Vector.
This method also checks for points z != 0."""
if isinstance(pt, (gp_XYZ, gp_Pnt, gp_Vec)):
(x, y, z) = (pt.X(), pt.Y(), pt.Z())
elif isinstance(pt, Vector):
(x, y, z) = pt.to_tuple()
else:
raise TypeError(f"Expected `gp_Pnt`, `gp_XYZ`, `gp_Vec`, or `Vector`. Got `{type(pt).__name__}`.")
if abs(z) > 1e-6:
self._non_planar_point_count += 1
return Vec2(x, y)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _convert_line(self, edge: Edge, attribs: dict):
self._modelspace.add_line(
self._convert_point(edge.start_point()),
self._convert_point(edge.end_point()),
attribs,
)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _convert_circle(self, edge: Edge, attribs: dict):
geom = edge._geom_adaptor()
circle = geom.Circle()
center = self._convert_point(circle.Location())
radius = circle.Radius()
if edge.is_closed():
self._modelspace.add_circle(center, radius, attribs)
else:
x_axis = circle.XAxis().Direction()
z_axis = circle.Axis().Direction()
phi = x_axis.AngleWithRef(gp_Dir(1, 0, 0), z_axis)
u1 = geom.FirstParameter()
u2 = geom.LastParameter()
if z_axis.Z() > 0:
angle1 = math.degrees(phi + u1)
angle2 = math.degrees(phi + u2)
ccw = True
else:
angle1 = math.degrees(phi - u1)
angle2 = math.degrees(phi - u2)
ccw = False
self._modelspace.add_arc(center, radius, angle1, angle2, ccw, attribs)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _convert_ellipse(self, edge: Edge, attribs: dict):
geom = edge._geom_adaptor()
ellipse = geom.Ellipse()
minor_radius = ellipse.MinorRadius()
major_radius = ellipse.MajorRadius()
center = ellipse.Location()
major_axis = major_radius * gp_Vec(ellipse.XAxis().Direction())
main_dir = ellipse.Axis().Direction()
if main_dir.Z() > 0:
start = geom.FirstParameter()
end = geom.LastParameter()
else:
start = -geom.LastParameter()
end = -geom.FirstParameter()
self._modelspace.add_ellipse(
self._convert_point(center),
self._convert_point(major_axis),
minor_radius / major_radius,
start,
end,
attribs,
)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _convert_bspline(self, edge: Edge, attribs):
# This reduces the B-Spline to degree 3, generally adding
# poles and knots to approximate the original.
# This also will convert basically any edge into a B-Spline.
edge = edge.to_splines()
# This pulls the underlying Geom_BSplineCurve out of the Edge.
# The adaptor also supplies a parameter range for the curve.
adaptor = edge._geom_adaptor()
curve = adaptor.Curve().Curve()
u1 = adaptor.FirstParameter()
u2 = adaptor.LastParameter()
# Extract the relevant segment of the curve.
spline = GeomConvert.SplitBSplineCurve_s(
curve, u1, u2,
Export2D.PARAMETRIC_TOLERANCE,
)
# need to apply the transform on the geometry level
t = edge.location.wrapped.Transformation()
spline.Transform(t)
order = spline.Degree() + 1
knots = list(spline.KnotSequence())
poles = [
self._convert_point(p)
for p in spline.Poles()
]
weights = (
[spline.Weight(i) for i in range(1, spline.NbPoles() + 1)]
if spline.IsRational()
else None
)
if spline.IsPeriodic():
pad = spline.NbKnots() - spline.LastUKnotIndex()
poles += poles[:pad]
dxf_spline = ezdxf.math.BSpline(poles, order, knots, weights)
self._modelspace.add_spline(dxfattribs=attribs).apply_construction_tool(dxf_spline)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _convert_other(self, edge: Edge, attribs: dict):
self._convert_bspline(edge, attribs)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
_CONVERTER_LOOKUP = {
GeomType.LINE.name: _convert_line,
GeomType.CIRCLE.name: _convert_circle,
GeomType.ELLIPSE.name: _convert_ellipse,
GeomType.BSPLINE.name: _convert_bspline,
}
def _convert_edge(self, edge: Edge, attribs: dict):
geom_type = edge.geom_type()
if False and geom_type not in self._CONVERTER_LOOKUP:
article = "an" if geom_type[0] in "AEIOU" else "a"
print(f"Hey neat, {article} {geom_type}!")
convert = self._CONVERTER_LOOKUP.get(geom_type, ExportDXF._convert_other)
convert(self, edge, attribs)
# ---------------------------------------------------------------------------
#
# ---------------------------------------------------------------------------
class ExportSVG(Export2D):
"""SVG file export functionality."""
Converter = Callable[[Edge], ET.Element]
# These are the units which are available in the Unit enum *and*
# are valid units in SVG.
_UNIT_STRING = {
Unit.MILLIMETER: 'mm',
Unit.CENTIMETER: 'cm',
Unit.INCH : 'in',
}
class Layer(object):
def __init__(
self,
name: str,
color: ColorIndex,
line_weight: float,
line_type: LineType,
):
self.name = name
self.color = color
self.line_weight = line_weight
self.line_type = line_type
self.elements: List[ET.Element] = []
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def __init__(
self,
unit: Unit = Unit.MILLIMETER,
scale: float = 1,
margin: float = 0,
fit_to_stroke: bool = True,
precision: int = 6,
color: ColorIndex = Export2D.DEFAULT_COLOR_INDEX,
line_weight: float = Export2D.DEFAULT_LINE_WEIGHT, # in millimeters
line_type: LineType = Export2D.DEFAULT_LINE_TYPE,
):
if unit not in ExportSVG._UNIT_STRING:
raise ValueError("Invalid unit. Supported units are %s." %
', '.join(ExportSVG._UNIT_STRING.values()))
self.unit = unit
self.scale = scale
self.margin = margin
self.fit_to_stroke = fit_to_stroke
self.precision = precision
self._non_planar_point_count = 0
self._layers: Dict[str, ExportSVG.Layer] = {}
self._bounds: BoundingBox = None
# Add the default layer.
self.add_layer(
"",
color = color,
line_weight = line_weight,
line_type = line_type
)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def add_layer(
self,
name: str,
*,
color: ColorIndex = Export2D.DEFAULT_COLOR_INDEX,
line_weight: float = Export2D.DEFAULT_LINE_WEIGHT, # in millimeters
line_type: LineType = Export2D.DEFAULT_LINE_TYPE,
) -> Self:
if name in self._layers:
raise ValueError(f"Duplicate layer name '{name}'.")
if line_type.value not in Export2D.LINETYPE_DEFS:
raise ValueError(f"Unknow linetype `{line_type.value}`.")
layer = ExportSVG.Layer(
name = name,
color = color,
line_weight = line_weight,
line_type = line_type,
)
self._layers[name] = layer
return self
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def add_shape(self, shape: Shape, layer: str = ''):
self._non_planar_point_count = 0
if layer not in self._layers:
raise ValueError(f"Undefined layer: {layer}.")
layer = self._layers[layer]
bb = shape.bounding_box()
self._bounds = self._bounds.add(bb) if self._bounds else bb
elements = [
self._convert_edge(edge)
for edge in shape.edges()
]
layer.elements.extend(elements)
if self._non_planar_point_count > 0:
print(f"WARNING, exporting non-planar shape to 2D format.")
print(" This is probably not what you want.")
print(f" {self._non_planar_point_count} points found outside the XY plane.")
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def path_point(self, pt: Union[gp_Pnt, Vector]) -> complex:
"""Create a complex point from a gp_Pnt or Vector.
We are using complex because that is what svgpathtools wants.
This method also checks for points z != 0."""
if isinstance(pt, gp_Pnt):
xyz = pt.X(), pt.Y(), pt.Z()
elif isinstance(pt, Vector):
xyz = pt.X, pt.Y, pt.Z
else:
raise TypeError(f"Expected `gp_Pnt` or `Vector`. Got `{type(pt).__name__}`.")
x, y, z = tuple(round(v, self.precision) for v in xyz)
if z != 0:
self._non_planar_point_count += 1
return complex(x, y)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _convert_line(self, edge: Edge) -> ET.Element:
p0 = self.path_point(edge @ 0)
p1 = self.path_point(edge @ 1)
result = ET.Element('line', {
'x1': str(p0.real), 'y1': str(p0.imag),
'x2': str(p1.real), 'y2': str(p1.imag)
})
return result
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _convert_circle(self, edge: Edge) -> ET.Element:
geom = edge._geom_adaptor()
circle = geom.Circle()
radius = circle.Radius()
center = circle.Location()
if edge.is_closed():
c = self.path_point(center)
result = ET.Element('circle', {
'cx': str(c.real), 'cy': str(c.imag),
'r': str(radius)
})
else:
x_axis = circle.XAxis().Direction()
z_axis = circle.Axis().Direction()
phi = x_axis.AngleWithRef(gp_Dir(1, 0, 0), z_axis)
if z_axis.Z() > 0:
u1 = geom.FirstParameter()
u2 = geom.LastParameter()
sweep = True
else:
u1 = -geom.LastParameter()
u2 = -geom.FirstParameter()
sweep = False
du = u2 - u1
large_arc = (du < -math.pi) or (du > math.pi)
start = self.path_point(edge @ 0)
end = self.path_point(edge @ 1)
radius = complex(radius, radius)
rotation = math.degrees(phi)
arc = PT.Arc(start, radius, rotation, large_arc, sweep, end)
path = PT.Path(arc)
result = ET.Element('path', {'d': path.d()})
return result
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _convert_ellipse(self, edge: Edge) -> ET.Element:
geom = edge._geom_adaptor()
ellipse = geom.Ellipse()
minor_radius = ellipse.MinorRadius()
major_radius = ellipse.MajorRadius()
x_axis = ellipse.XAxis().Direction()
z_axis = ellipse.Axis().Direction()
if z_axis.Z() > 0:
u1 = geom.FirstParameter()
u2 = geom.LastParameter()
sweep = True
else:
u1 = -geom.LastParameter()
u2 = -geom.FirstParameter()
sweep = False
du = u2 - u1
large_arc = (du < -math.pi) or (du > math.pi)
start = self.path_point(edge @ 0)
end = self.path_point(edge @ 1)
radius = complex(major_radius, minor_radius)
rotation = math.degrees(x_axis.AngleWithRef(gp_Dir(1, 0, 0), z_axis))
if edge.is_closed():
midway = self.path_point(edge @ 0.5)
arcs = [
PT.Arc(start, radius, rotation, False, sweep, midway),
PT.Arc(midway, radius, rotation, False, sweep, end),
]
else:
arcs = [
PT.Arc(start, radius, rotation, large_arc, sweep, end)
]
path = PT.Path(*arcs)
result = ET.Element('path', {'d': path.d()})
return result
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _convert_bspline(self, edge: Edge) -> ET.Element:
# This reduces the B-Spline to degree 3, generally adding
# poles and knots to approximate the original.
# This also will convert basically any edge into a B-Spline.
edge = edge.to_splines()
# This pulls the underlying Geom_BSplineCurve out of the Edge.
# The adaptor also supplies a parameter range for the curve.
adaptor = edge._geom_adaptor()
spline = adaptor.Curve().Curve()
u1 = adaptor.FirstParameter()
u2 = adaptor.LastParameter()
# Apply the shape location to the geometry.
t = edge.location.wrapped.Transformation()
spline.Transform(t)
# describe_bspline(spline)
# Convert the B-Spline to Bezier curves.
# From the OCCT 7.6.0 documentation:
# > Note: ParametricTolerance is not used.
converter = GeomConvert_BSplineCurveToBezierCurve(
spline, u1, u2, Export2D.PARAMETRIC_TOLERANCE
)
def make_segment(bezier: Geom_BezierCurve) -> Union[PT.Line, PT.QuadraticBezier, PT.CubicBezier]:
p = [self.path_point(p) for p in bezier.Poles()]
if len(p) == 2:
result = PT.Line(start=p[0], end=p[1])
elif len(p) == 3:
result = PT.QuadraticBezier(start=p[0], control=p[1], end=p[2])
elif len(p) == 4:
result = PT.CubicBezier(start=p[0], control1=p[1], control2=p[2], end=p[3])
else:
raise ValueError(f"Surprising Bézier of degree {bezier.Degree()}!")
return result
segments = [
make_segment(converter.Arc(i))
for i in range(1, converter.NbArcs() + 1)
]
path = PT.Path(*segments)
result = ET.Element('path', {'d': path.d()})
return result
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _convert_other(self, edge: Edge) -> ET.Element:
# _convert_bspline can actually handle basically anything
# because it calls Edge.to_splines() first thing.
return self._convert_bspline(edge)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
_CONVERTER_LOOKUP = {
GeomType.LINE.name: _convert_line,
GeomType.CIRCLE.name: _convert_circle,
GeomType.ELLIPSE.name: _convert_ellipse,
GeomType.BSPLINE.name: _convert_bspline,
}
def _convert_edge(self, edge: Edge) -> ET.Element:
geom_type = edge.geom_type()
if False and geom_type not in self._CONVERTER_LOOKUP:
article = "an" if geom_type[0] in "AEIOU" else "a"
print(f"Hey neat, {article} {geom_type}!")
convert = self._CONVERTER_LOOKUP.get(geom_type, ExportSVG._convert_other)
result = convert(self, edge)
return result
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _group_for_layer(self, layer: Layer, attribs: Dict = {}) -> ET.Element:
(r, g, b) = (
(0, 0, 0) if layer.color == ColorIndex.BLACK
else aci2rgb(layer.color.value)
)
lwscale = unit_conversion_scale(Unit.MILLIMETER, self.unit)
stroke_width = layer.line_weight * lwscale
result = ET.Element('g', attribs | {
'stroke' : f"rgb({r},{g},{b})",
'stroke-width': f"{stroke_width}",
'fill' : "none",
})
if layer.name:
result.set('id', layer.name)
if layer.line_type is not LineType.CONTINUOUS:
ltname = layer.line_type.value
_, pattern = Export2D.LINETYPE_DEFS[ltname]
ltscale = ExportSVG.LTYPE_SCALE[self.unit] * layer.line_weight
dash_array = [
f"{round(ltscale * abs(e), self.precision)}"
for e in pattern[1:]
]
result.set('stroke-dasharray', ' '.join(dash_array))
for element in layer.elements:
result.append(element)
return result
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def write(self, path: str):
bb = self._bounds
margin = self.margin
if self.fit_to_stroke:
max_line_weight = max(
l.line_weight
for l in self._layers.values()
)
margin += max_line_weight / 2
view_left = round(+bb.min.X - margin, self.precision)
view_top = round(-bb.max.Y - margin, self.precision)
view_width = round(bb.size.X + 2 * margin, self.precision)
view_height = round(bb.size.Y + 2 * margin, self.precision)
view_box = [str(f) for f in [view_left, view_top, view_width, view_height]]
doc_width = round(view_width * self.scale, self.precision)
doc_height = round(view_height * self.scale, self.precision)
doc_unit = self._UNIT_STRING.get(self.unit, '')
svg = ET.Element('svg', {
'width' : f"{doc_width}{doc_unit}",
'height' : f"{doc_height}{doc_unit}",
'viewBox': " ".join(view_box),
'version': "1.1",
'xmlns' : "http://www.w3.org/2000/svg",
})
container_group = ET.Element('g', {
'transform' : f"scale(1,-1)",
'stroke-linecap': "round",
})
svg.append(container_group)
for _, layer in self._layers.items():
layer_group = self._group_for_layer(layer)
container_group.append(layer_group)
xml = ET.ElementTree(svg)
ET.indent(xml, ' ')
xml.write(
path,
encoding='utf-8',
xml_declaration=True,
default_namespace=False
)

View file

@ -446,10 +446,14 @@ class Vector:
return line * (self.dot(line) / (line_length * line_length))
def distance_to_plane(self):
"""Minimum distance between vector and plane"""
raise NotImplementedError("Have not needed this yet, but OCCT supports it!")
def distance_to_plane(self, plane: Plane) -> float:
"""Minimum unsigned distance between vector and plane"""
return plane.wrapped.Distance(self.to_pnt())
def signed_distance_from_plane(self, plane: Plane) -> float:
"""Signed distance from plane to point vector."""
return (self - plane.origin).dot(plane.z_dir)
def project_to_plane(self, plane: Plane) -> Vector:
"""Vector is projected onto the plane provided as input.
@ -1550,7 +1554,7 @@ class Plane:
"""Return a plane from a OCCT gp_pln"""
@overload
def __init__(self, face: "Face"): # pragma: no cover
def __init__(self, face: "Face", x_dir: Optional[VectorLike] = None ): # pragma: no cover
"""Return a plane extending the face.
Note: for non planar face this will return the underlying work plane"""
@ -1569,42 +1573,76 @@ class Plane:
def __init__(self, *args, **kwargs):
"""Create a plane from either an OCCT gp_pln or coordinates"""
if args:
if isinstance(args[0], gp_Pln):
self.wrapped = args[0]
# Check for Face by using the OCCT class to avoid circular imports of the Face class
elif hasattr(args[0], "wrapped") and isinstance(
args[0].wrapped,
TopoDS_Face,
):
properties = GProp_GProps()
BRepGProp.SurfaceProperties_s(args[0].wrapped, properties)
self._origin = Vector(properties.CentreOfMass())
self.x_dir = Vector(
BRep_Tool.Surface_s(args[0].wrapped).Position().XDirection()
)
self.z_dir = Plane.get_topods_face_normal(args[0].wrapped)
elif isinstance(args[0], Location):
topo_face = BRepBuilderAPI_MakeFace(
Plane.XY.wrapped, -1.0, 1.0, -1.0, 1.0
).Face()
topo_face.Move(args[0].wrapped)
self._origin = args[0].position
self.x_dir = Vector(
BRep_Tool.Surface_s(topo_face).Position().XDirection()
)
self.z_dir = Plane.get_topods_face_normal(topo_face)
else:
self._origin = Vector(args[0])
self.x_dir = Vector(args[1]) if len(args) >= 2 else None
self.z_dir = Vector(args[2]) if len(args) == 3 else Vector(0, 0, 1)
if kwargs:
if "gp_pln" in kwargs:
self.wrapped = kwargs.get("gp_pln")
self._origin = Vector(kwargs.get("origin", (0, 0, 0)))
self.x_dir = kwargs.get("x_dir")
self.x_dir = Vector(self.x_dir) if self.x_dir else None
self.z_dir = Vector(kwargs.get("z_dir", (0, 0, 1)))
def optarg(kwargs, name, args, index, default):
if name in kwargs:
return kwargs[name]
if len(args) > index:
return args[index]
return default
arg_plane = None
arg_face = None
arg_location = None
arg_origin = None
arg_x_dir = None
arg_z_dir = (0, 0, 1)
arg0 = args[0] if args else None
type_error_message = "Expected gp_Pln, Face, Location, or VectorLike"
if "gp_pln" in kwargs:
arg_plane = kwargs["gp_pln"]
elif isinstance(arg0, gp_Pln):
arg_plane = arg0
elif "face" in kwargs:
arg_face = kwargs["face"]
arg_x_dir = kwargs.get("x_dir", None)
# Check for Face by using the OCCT class to avoid circular imports of the Face class
elif hasattr(arg0, "wrapped") and isinstance(arg0.wrapped, TopoDS_Face):
arg_face = arg0
arg_x_dir = optarg(kwargs, "x_dir", args, 1, arg_x_dir)
elif "location" in kwargs:
arg_location = kwargs["location"]
elif isinstance(arg0, Location):
arg_location = arg0
elif "origin" in kwargs:
arg_origin = kwargs["origin"]
arg_x_dir = kwargs.get("x_dir", arg_x_dir)
arg_z_dir = kwargs.get("z_dir", arg_z_dir)
else:
try:
arg_origin = Vector(arg0)
except TypeError:
raise TypeError(type_error_message)
arg_x_dir = optarg(kwargs, "x_dir", args, 1, arg_x_dir)
arg_z_dir = optarg(kwargs, "z_dir", args, 2, arg_z_dir)
if arg_plane:
self.wrapped = arg_plane
elif arg_face:
properties = GProp_GProps()
BRepGProp.SurfaceProperties_s(arg_face.wrapped, properties)
self._origin = Vector(properties.CentreOfMass())
self.x_dir = Vector(arg_x_dir) if arg_x_dir else Vector(
BRep_Tool.Surface_s(arg_face.wrapped).Position().XDirection()
)
self.z_dir = Plane.get_topods_face_normal(arg_face.wrapped)
elif arg_location:
topo_face = BRepBuilderAPI_MakeFace(
Plane.XY.wrapped, -1.0, 1.0, -1.0, 1.0
).Face()
topo_face.Move(arg_location.wrapped)
self._origin = arg_location.position
self.x_dir = Vector(
BRep_Tool.Surface_s(topo_face).Position().XDirection()
)
self.z_dir = Plane.get_topods_face_normal(topo_face)
elif arg_origin:
self._origin = Vector(arg_origin)
self.x_dir = Vector(arg_x_dir) if arg_x_dir else None
self.z_dir = Vector(arg_z_dir)
if hasattr(self, "wrapped"):
self._origin = Vector(self.wrapped.Location())
self.x_dir = Vector(self.wrapped.XAxis().Direction())

View file

@ -2906,10 +2906,12 @@ class ShapeList(list[T]):
ShapeList: Sorted shapes
"""
other = other if isinstance(other, Shape) else Vertex(other)
distances = {other.distance_to(obj): obj for obj in self}
return ShapeList(
distances[key] for key in sorted(distances.keys(), reverse=reverse)
distances = sorted(
[(other.distance_to(obj), obj) for obj in self],
key=lambda obj: obj[0],
reverse=reverse,
)
return ShapeList([obj[1] for obj in distances])
def __gt__(self, sort_by: Union[Axis, SortBy] = Axis.Z):
"""Sort operator"""

View file

@ -1769,19 +1769,50 @@ class TestPlane(DirectApiTestCase):
self.assertVectorAlmostEquals(plane.z_dir, z_dir, 5)
def test_plane_init(self):
# from origin
o = (0, 0, 0)
x = (1, 0, 0)
y = (0, 1, 0)
z = (0, 0, 1)
planes = [
Plane(o),
Plane(o, x),
Plane(o, x, z),
Plane(o, x, z_dir=z),
Plane(o, x_dir=x, z_dir=z),
Plane(o, x_dir=x),
Plane(o, z_dir=z),
Plane(origin=o, x_dir=x, z_dir=z),
Plane(origin=o, x_dir=x),
Plane(origin=o, z_dir=z),
]
for p in planes:
self.assertVectorAlmostEquals(p.origin, o, 6)
self.assertVectorAlmostEquals(p.x_dir, x, 6)
self.assertVectorAlmostEquals(p.y_dir, y, 6)
self.assertVectorAlmostEquals(p.z_dir, z, 6)
with self.assertRaises(TypeError):
Plane()
with self.assertRaises(TypeError):
Plane(o, z_dir=1)
# rotated location around z
loc = Location((0, 0, 0), (0, 0, 45))
p = Plane(loc)
self.assertVectorAlmostEquals(p.origin, (0, 0, 0), 6)
self.assertVectorAlmostEquals(
p.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
)
self.assertVectorAlmostEquals(
p.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
)
self.assertVectorAlmostEquals(p.z_dir, (0, 0, 1), 6)
self.assertVectorAlmostEquals(loc.position, p.to_location().position, 6)
self.assertVectorAlmostEquals(loc.orientation, p.to_location().orientation, 6)
p_from_loc = Plane(loc)
p_from_named_loc = Plane(location=loc)
for p in [p_from_loc, p_from_named_loc]:
self.assertVectorAlmostEquals(p.origin, (0, 0, 0), 6)
self.assertVectorAlmostEquals(
p.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
)
self.assertVectorAlmostEquals(
p.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
)
self.assertVectorAlmostEquals(p.z_dir, (0, 0, 1), 6)
self.assertVectorAlmostEquals(loc.position, p.to_location().position, 6)
self.assertVectorAlmostEquals(
loc.orientation, p.to_location().orientation, 6
)
# rotated location around x and origin <> (0,0,0)
loc = Location((0, 2, -1), (45, 0, 0))
@ -1800,9 +1831,10 @@ class TestPlane(DirectApiTestCase):
# from a face
f = Face.make_rect(1, 2).located(Location((1, 2, 3), (45, 0, 45)))
p_from_face = Plane(f)
p_from_named_face = Plane(face=f)
plane_from_gp_pln = Plane(gp_pln=p_from_face.wrapped)
p_deep_copy = copy.deepcopy(p_from_face)
for p in [p_from_face, plane_from_gp_pln, p_deep_copy]:
for p in [p_from_face, p_from_named_face, plane_from_gp_pln, p_deep_copy]:
self.assertVectorAlmostEquals(p.origin, (1, 2, 3), 6)
self.assertVectorAlmostEquals(p.x_dir, (math.sqrt(2) / 2, 0.5, 0.5), 6)
self.assertVectorAlmostEquals(p.y_dir, (-math.sqrt(2) / 2, 0.5, 0.5), 6)
@ -1816,6 +1848,21 @@ class TestPlane(DirectApiTestCase):
f.location.orientation, p.to_location().orientation, 6
)
# from a face with x_dir
f = Face.make_rect(1, 2)
x = (1, 1)
y = (-1, 1)
planes = [
Plane(f, x),
Plane(f, x_dir=x),
Plane(face=f, x_dir=x),
]
for p in planes:
self.assertVectorAlmostEquals(p.origin, (0, 0, 0), 6)
self.assertVectorAlmostEquals(p.x_dir, Vector(x).normalized(), 6)
self.assertVectorAlmostEquals(p.y_dir, Vector(y).normalized(), 6)
self.assertVectorAlmostEquals(p.z_dir, (0, 0, 1), 6)
with self.assertRaises(TypeError):
Plane(Edge.make_line((0, 0), (0, 1)))
@ -2278,6 +2325,31 @@ class TestShapeList(DirectApiTestCase):
with self.assertRaises(ValueError):
boxes.solids().group_by("AREA")
def test_distance(self):
with BuildPart() as box:
Box(1, 2, 3)
obj = (-0.2, 0.1, 0.5)
edges = box.edges().sort_by_distance(obj)
distances = [Vertex(*obj).distance_to(edge) for edge in edges]
self.assertTrue(
all([distances[i] >= distances[i - 1] for i in range(1, len(edges))])
)
def test_distance_reverse(self):
with BuildPart() as box:
Box(1, 2, 3)
obj = (-0.2, 0.1, 0.5)
edges = box.edges().sort_by_distance(obj, reverse=True)
distances = [Vertex(*obj).distance_to(edge) for edge in edges]
self.assertTrue(
all([distances[i] <= distances[i - 1] for i in range(1, len(edges))])
)
def test_distance_equal(self):
with BuildPart() as box:
Box(1, 1, 1)
self.assertEqual(len(box.edges().sort_by_distance((0, 0, 0))), 12)
class TestShell(DirectApiTestCase):
def test_shell_init(self):
@ -2489,6 +2561,26 @@ class TestVector(DirectApiTestCase):
self.assertEqual(a, b)
self.assertEqual(a, c)
def test_vector_distance(self):
"""
Test line distance from plane.
"""
v = Vector(1, 2, 3)
self.assertAlmostEqual(1, v.signed_distance_from_plane(Plane.YZ))
self.assertAlmostEqual(2, v.signed_distance_from_plane(Plane.ZX))
self.assertAlmostEqual(3, v.signed_distance_from_plane(Plane.XY))
self.assertAlmostEqual(-1, v.signed_distance_from_plane(Plane.ZY))
self.assertAlmostEqual(-2, v.signed_distance_from_plane(Plane.XZ))
self.assertAlmostEqual(-3, v.signed_distance_from_plane(Plane.YX))
self.assertAlmostEqual(1, v.distance_to_plane(Plane.YZ))
self.assertAlmostEqual(2, v.distance_to_plane(Plane.ZX))
self.assertAlmostEqual(3, v.distance_to_plane(Plane.XY))
self.assertAlmostEqual(1, v.distance_to_plane(Plane.ZY))
self.assertAlmostEqual(2, v.distance_to_plane(Plane.XZ))
self.assertAlmostEqual(3, v.distance_to_plane(Plane.YX))
def test_vector_project(self):
"""
Test line projection and plane projection methods of Vector
@ -2520,9 +2612,7 @@ class TestVector(DirectApiTestCase):
)
def test_vector_not_implemented(self):
v = Vector(1, 2, 3)
with self.assertRaises(NotImplementedError):
v.distance_to_plane()
pass
def test_vector_special_methods(self):
v = Vector(1, 2, 3)