refactor: excel parse
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
#
|
||||
# Ezdxf user: DO NOT IMPORT FROM ezdxf.path.* MODULES!
|
||||
# import all Path() related classes and functions from ezdxf.path
|
||||
#
|
||||
# from ezdxf.path import Path, make_path,
|
||||
#
|
||||
from .commands import *
|
||||
from .path import *
|
||||
from .converter import *
|
||||
from .tools import *
|
||||
from .nesting import *
|
||||
from .shapes import *
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) 2021-2022, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
from typing import NamedTuple, Union
|
||||
|
||||
from ezdxf.math import Vec2, Vec3
|
||||
|
||||
__all__ = [
|
||||
"Command",
|
||||
"AnyCurve",
|
||||
"PathElement",
|
||||
"LineTo",
|
||||
"Curve3To",
|
||||
"Curve4To",
|
||||
"MoveTo",
|
||||
]
|
||||
|
||||
|
||||
@enum.unique
|
||||
class Command(enum.IntEnum):
|
||||
LINE_TO = 1 # (LINE_TO, end vertex)
|
||||
CURVE3_TO = 2 # (CURVE3_TO, end vertex, ctrl) quadratic bezier
|
||||
CURVE4_TO = 3 # (CURVE4_TO, end vertex, ctrl1, ctrl2) cubic bezier
|
||||
MOVE_TO = 4 # (MOVE_TO, end vertex), creates a gap and starts a sub-path
|
||||
|
||||
|
||||
class LineTo(NamedTuple):
|
||||
end: Vec2|Vec3
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return Command.LINE_TO
|
||||
|
||||
|
||||
class MoveTo(NamedTuple):
|
||||
end: Vec2|Vec3
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return Command.MOVE_TO
|
||||
|
||||
|
||||
class Curve3To(NamedTuple):
|
||||
end: Vec2|Vec3
|
||||
ctrl: Vec2|Vec3
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return Command.CURVE3_TO
|
||||
|
||||
|
||||
class Curve4To(NamedTuple):
|
||||
end: Vec2|Vec3
|
||||
ctrl1: Vec2|Vec3
|
||||
ctrl2: Vec2|Vec3
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return Command.CURVE4_TO
|
||||
|
||||
|
||||
AnyCurve = (Command.CURVE3_TO, Command.CURVE4_TO)
|
||||
PathElement = Union[LineTo, Curve3To, Curve4To, MoveTo]
|
||||
@@ -0,0 +1,960 @@
|
||||
# Copyright (c) 2020-2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
List,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Union,
|
||||
Optional,
|
||||
Callable,
|
||||
Type,
|
||||
TypeVar,
|
||||
)
|
||||
from typing_extensions import TypeAlias
|
||||
from functools import singledispatch, partial
|
||||
import logging
|
||||
|
||||
from ezdxf.math import (
|
||||
ABS_TOL,
|
||||
Vec2,
|
||||
Vec3,
|
||||
NULLVEC,
|
||||
Z_AXIS,
|
||||
OCS,
|
||||
Bezier3P,
|
||||
Bezier4P,
|
||||
ConstructionEllipse,
|
||||
BSpline,
|
||||
have_bezier_curves_g1_continuity,
|
||||
fit_points_to_cad_cv,
|
||||
UVec,
|
||||
Matrix44,
|
||||
)
|
||||
from ezdxf.lldxf import const
|
||||
from ezdxf.entities import (
|
||||
LWPolyline,
|
||||
Polyline,
|
||||
Hatch,
|
||||
Line,
|
||||
Spline,
|
||||
Ellipse,
|
||||
Arc,
|
||||
Circle,
|
||||
Solid,
|
||||
Trace,
|
||||
Face3d,
|
||||
Viewport,
|
||||
Image,
|
||||
Helix,
|
||||
Wipeout,
|
||||
MPolygon,
|
||||
BoundaryPaths,
|
||||
AbstractBoundaryPath,
|
||||
PolylinePath,
|
||||
EdgePath,
|
||||
LineEdge,
|
||||
ArcEdge,
|
||||
EllipseEdge,
|
||||
SplineEdge,
|
||||
)
|
||||
from ezdxf.entities.polygon import DXFPolygon
|
||||
from .path import Path
|
||||
from .commands import Command
|
||||
from . import tools
|
||||
from .nesting import group_paths
|
||||
|
||||
__all__ = [
|
||||
"make_path",
|
||||
"to_lines",
|
||||
"to_polylines3d",
|
||||
"to_lwpolylines",
|
||||
"to_polylines2d",
|
||||
"to_hatches",
|
||||
"to_mpolygons",
|
||||
"to_bsplines_and_vertices",
|
||||
"to_splines_and_polylines",
|
||||
"from_hatch",
|
||||
"from_hatch_ocs",
|
||||
"from_hatch_boundary_path",
|
||||
"from_hatch_edge_path",
|
||||
"from_hatch_polyline_path",
|
||||
"from_vertices",
|
||||
]
|
||||
|
||||
MAX_DISTANCE = 0.01
|
||||
MIN_SEGMENTS = 4
|
||||
G1_TOL = 1e-4
|
||||
TPolygon = TypeVar("TPolygon", Hatch, MPolygon)
|
||||
BoundaryFactory = Callable[[BoundaryPaths, Path, int], None]
|
||||
logger = logging.getLogger("ezdxf")
|
||||
|
||||
|
||||
@singledispatch
|
||||
def make_path(entity, segments: int = 1, level: int = 4) -> Path:
|
||||
"""Factory function to create a single :class:`Path` object from a DXF
|
||||
entity.
|
||||
|
||||
Args:
|
||||
entity: DXF entity
|
||||
segments: minimal count of cubic Bézier-curves for elliptical arcs
|
||||
level: subdivide level for SPLINE approximation
|
||||
|
||||
Raises:
|
||||
TypeError: for unsupported DXF types
|
||||
|
||||
"""
|
||||
# Complete documentation is path.rst, because Sphinx auto-function
|
||||
# renders for each overloaded function a signature, which is ugly
|
||||
# and wrong signatures for multiple overloaded function
|
||||
# e.g. 3 equal signatures for type Solid.
|
||||
raise TypeError(f"unsupported DXF type: {entity.dxftype()}")
|
||||
|
||||
|
||||
@make_path.register(LWPolyline)
|
||||
def _from_lwpolyline(lwpolyline: LWPolyline, **kwargs) -> Path:
|
||||
path = Path()
|
||||
tools.add_2d_polyline(
|
||||
path,
|
||||
lwpolyline.get_points("xyb"),
|
||||
close=lwpolyline.closed,
|
||||
ocs=lwpolyline.ocs(),
|
||||
elevation=lwpolyline.dxf.elevation,
|
||||
segments=kwargs.get("segments", 1),
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
@make_path.register(Polyline)
|
||||
def _from_polyline(polyline: Polyline, **kwargs) -> Path:
|
||||
if polyline.is_polygon_mesh or polyline.is_poly_face_mesh:
|
||||
raise TypeError("Unsupported DXF type PolyMesh or PolyFaceMesh")
|
||||
|
||||
path = Path()
|
||||
if len(polyline.vertices) == 0:
|
||||
return path
|
||||
|
||||
if polyline.is_3d_polyline:
|
||||
return from_vertices(polyline.points(), polyline.is_closed)
|
||||
|
||||
points = [vertex.format("xyb") for vertex in polyline.vertices]
|
||||
ocs = polyline.ocs()
|
||||
if polyline.dxf.hasattr("elevation"):
|
||||
elevation = Vec3(polyline.dxf.elevation).z
|
||||
else:
|
||||
# the elevation attribute is mandatory, but if it's missing
|
||||
# take the elevation value of the first vertex.
|
||||
elevation = Vec3(polyline.vertices[0].dxf.location).z
|
||||
tools.add_2d_polyline(
|
||||
path,
|
||||
points,
|
||||
close=polyline.is_closed,
|
||||
ocs=ocs,
|
||||
elevation=elevation,
|
||||
segments=kwargs.get("segments", 1),
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
@make_path.register(Helix)
|
||||
@make_path.register(Spline)
|
||||
def _from_spline(spline: Spline, **kwargs) -> Path:
|
||||
level = kwargs.get("level", 4)
|
||||
path = Path()
|
||||
tools.add_spline(path, spline.construction_tool(), level=level, reset=True)
|
||||
return path
|
||||
|
||||
|
||||
@make_path.register(Ellipse)
|
||||
def _from_ellipse(ellipse: Ellipse, **kwargs) -> Path:
|
||||
segments = kwargs.get("segments", 1)
|
||||
path = Path()
|
||||
tools.add_ellipse(path, ellipse.construction_tool(), segments=segments, reset=True)
|
||||
return path
|
||||
|
||||
|
||||
@make_path.register(Line)
|
||||
def _from_line(line: Line, **kwargs) -> Path:
|
||||
path = Path(line.dxf.start)
|
||||
path.line_to(line.dxf.end)
|
||||
return path
|
||||
|
||||
|
||||
@make_path.register(Arc)
|
||||
def _from_arc(arc: Arc, **kwargs) -> Path:
|
||||
segments = kwargs.get("segments", 1)
|
||||
path = Path()
|
||||
radius = abs(arc.dxf.radius)
|
||||
if radius > 1e-12:
|
||||
ellipse = ConstructionEllipse.from_arc(
|
||||
center=arc.dxf.center,
|
||||
radius=radius,
|
||||
extrusion=arc.dxf.extrusion,
|
||||
start_angle=arc.dxf.start_angle,
|
||||
end_angle=arc.dxf.end_angle,
|
||||
)
|
||||
tools.add_ellipse(path, ellipse, segments=segments, reset=True)
|
||||
return path
|
||||
|
||||
|
||||
@make_path.register(Circle)
|
||||
def _from_circle(circle: Circle, **kwargs) -> Path:
|
||||
segments = kwargs.get("segments", 1)
|
||||
path = Path()
|
||||
radius = abs(circle.dxf.radius)
|
||||
if radius > 1e-12:
|
||||
ellipse = ConstructionEllipse.from_arc(
|
||||
center=circle.dxf.center,
|
||||
radius=radius,
|
||||
extrusion=circle.dxf.extrusion,
|
||||
)
|
||||
tools.add_ellipse(path, ellipse, segments=segments, reset=True)
|
||||
return path
|
||||
|
||||
|
||||
@make_path.register(Face3d)
|
||||
@make_path.register(Trace)
|
||||
@make_path.register(Solid)
|
||||
def _from_quadrilateral(solid: "Solid", **kwargs) -> Path:
|
||||
vertices = solid.wcs_vertices()
|
||||
return from_vertices(vertices, close=True)
|
||||
|
||||
|
||||
@make_path.register(Viewport)
|
||||
def _from_viewport(vp: "Viewport", **kwargs) -> Path:
|
||||
if vp.has_extended_clipping_path:
|
||||
handle = vp.dxf.clipping_boundary_handle
|
||||
if handle != "0" and vp.doc: # exist
|
||||
db = vp.doc.entitydb
|
||||
if db: # exist
|
||||
# Many DXF entities can define a clipping path:
|
||||
clipping_entity = vp.doc.entitydb.get(handle)
|
||||
if clipping_entity: # exist
|
||||
return make_path(clipping_entity, **kwargs)
|
||||
# Return bounding box:
|
||||
return from_vertices(vp.clipping_rect_corners(), close=True)
|
||||
|
||||
|
||||
@make_path.register(Wipeout)
|
||||
@make_path.register(Image)
|
||||
def _from_image(image: "Image", **kwargs) -> Path:
|
||||
return from_vertices(image.boundary_path_wcs(), close=True)
|
||||
|
||||
|
||||
@make_path.register(MPolygon)
|
||||
@make_path.register(Hatch)
|
||||
def _from_hatch(hatch: Hatch, **kwargs) -> Path:
|
||||
ocs = hatch.ocs()
|
||||
elevation = hatch.dxf.elevation.z
|
||||
offset = NULLVEC
|
||||
if isinstance(hatch, MPolygon):
|
||||
offset = hatch.dxf.get("offset_vector", NULLVEC)
|
||||
try:
|
||||
paths = [
|
||||
from_hatch_boundary_path(boundary, ocs, elevation, offset=offset)
|
||||
for boundary in hatch.paths
|
||||
]
|
||||
except const.DXFStructureError:
|
||||
# TODO: fix problems beforehand in audit process? see issue #1081
|
||||
logger.warning(f"invalid data in {str(hatch)}")
|
||||
return Path()
|
||||
# looses the boundary path state:
|
||||
return tools.to_multi_path(paths)
|
||||
|
||||
|
||||
def from_hatch(hatch: DXFPolygon, offset: Vec3 = NULLVEC) -> Iterator[Path]:
|
||||
"""Yield all HATCH/MPOLYGON boundary paths as separated :class:`Path` objects in WCS
|
||||
coordinates.
|
||||
"""
|
||||
ocs = hatch.ocs()
|
||||
elevation = hatch.dxf.elevation.z
|
||||
for boundary in hatch.paths:
|
||||
p = from_hatch_boundary_path(boundary, ocs, elevation=elevation, offset=offset)
|
||||
if p.has_sub_paths:
|
||||
yield from p.sub_paths()
|
||||
else:
|
||||
yield p
|
||||
|
||||
|
||||
def from_hatch_ocs(hatch: DXFPolygon, offset: Vec3 = NULLVEC) -> Iterator[Path]:
|
||||
"""Yield all HATCH/MPOLYGON boundary paths as separated :class:`Path` objects in OCS
|
||||
coordinates. Elevation and offset is applied to all vertices.
|
||||
|
||||
.. versionadded:: 1.1
|
||||
|
||||
"""
|
||||
elevation = hatch.dxf.elevation.z
|
||||
for boundary in hatch.paths:
|
||||
p = from_hatch_boundary_path(boundary, elevation=elevation, offset=offset)
|
||||
if p.has_sub_paths:
|
||||
yield from p.sub_paths()
|
||||
else:
|
||||
yield p
|
||||
|
||||
|
||||
def from_hatch_boundary_path(
|
||||
boundary: AbstractBoundaryPath,
|
||||
ocs: Optional[OCS] = None,
|
||||
elevation: float = 0,
|
||||
offset: Vec3 = NULLVEC, # ocs offset!
|
||||
) -> Path:
|
||||
"""Returns a :class:`Path` object from a :class:`~ezdxf.entities.Hatch`
|
||||
polyline- or edge path.
|
||||
"""
|
||||
if isinstance(boundary, EdgePath):
|
||||
p = from_hatch_edge_path(boundary, ocs, elevation)
|
||||
elif isinstance(boundary, PolylinePath):
|
||||
p = from_hatch_polyline_path(boundary, ocs, elevation)
|
||||
else:
|
||||
raise TypeError(type(boundary))
|
||||
|
||||
if offset and ocs is not None: # only for MPOLYGON
|
||||
# assume offset is in OCS
|
||||
offset = ocs.to_wcs(offset.replace(z=elevation))
|
||||
p = p.transform(Matrix44.translate(offset.x, offset.y, offset.z))
|
||||
|
||||
# attach path type information
|
||||
p.user_data = const.BoundaryPathState.from_flags(boundary.path_type_flags)
|
||||
return p
|
||||
|
||||
|
||||
def from_hatch_polyline_path(
|
||||
polyline: PolylinePath, ocs: Optional[OCS] = None, elevation: float = 0
|
||||
) -> Path:
|
||||
"""Returns a :class:`Path` object from a :class:`~ezdxf.entities.Hatch`
|
||||
polyline path.
|
||||
"""
|
||||
path = Path()
|
||||
tools.add_2d_polyline(
|
||||
path,
|
||||
polyline.vertices, # list[(x, y, bulge)]
|
||||
close=polyline.is_closed,
|
||||
ocs=ocs or OCS(),
|
||||
elevation=elevation,
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def from_hatch_edge_path(
|
||||
edges: EdgePath,
|
||||
ocs: Optional[OCS] = None,
|
||||
elevation: float = 0,
|
||||
) -> Path:
|
||||
"""Returns a :class:`Path` object from a :class:`~ezdxf.entities.Hatch`
|
||||
edge path.
|
||||
|
||||
"""
|
||||
|
||||
def line(edge: LineEdge):
|
||||
start = wcs(edge.start)
|
||||
end = wcs(edge.end)
|
||||
segment = Path(start)
|
||||
segment.line_to(end)
|
||||
return segment
|
||||
|
||||
def arc(edge: ArcEdge):
|
||||
x, y, *_ = edge.center
|
||||
# from_arc() requires OCS data:
|
||||
# Note: clockwise oriented arcs are converted to counter
|
||||
# clockwise arcs at the loading stage!
|
||||
# See: ezdxf.entities.boundary_paths.ArcEdge.load_tags()
|
||||
ellipse = ConstructionEllipse.from_arc(
|
||||
center=(x, y, elevation),
|
||||
radius=edge.radius,
|
||||
extrusion=extrusion,
|
||||
start_angle=edge.start_angle,
|
||||
end_angle=edge.end_angle,
|
||||
)
|
||||
segment = Path()
|
||||
tools.add_ellipse(segment, ellipse, reset=True)
|
||||
return segment
|
||||
|
||||
def ellipse(edge: EllipseEdge):
|
||||
ocs_ellipse = edge.construction_tool()
|
||||
# ConstructionEllipse has WCS representation:
|
||||
# Note: clockwise oriented ellipses are converted to counter
|
||||
# clockwise ellipses at the loading stage!
|
||||
# See: ezdxf.entities.boundary_paths.EllipseEdge.load_tags()
|
||||
ellipse = ConstructionEllipse(
|
||||
center=wcs(ocs_ellipse.center.replace(z=float(elevation))),
|
||||
major_axis=wcs_tangent(ocs_ellipse.major_axis),
|
||||
ratio=ocs_ellipse.ratio,
|
||||
extrusion=extrusion,
|
||||
start_param=ocs_ellipse.start_param,
|
||||
end_param=ocs_ellipse.end_param,
|
||||
)
|
||||
segment = Path()
|
||||
tools.add_ellipse(segment, ellipse, reset=True)
|
||||
return segment
|
||||
|
||||
def spline(edge: SplineEdge):
|
||||
control_points = [wcs(p) for p in edge.control_points]
|
||||
if len(control_points) == 0:
|
||||
fit_points = [wcs(p) for p in edge.fit_points]
|
||||
if len(fit_points):
|
||||
bspline = from_fit_points(edge, fit_points)
|
||||
else:
|
||||
# No control points and no fit points:
|
||||
# DXF structure error
|
||||
return
|
||||
else:
|
||||
bspline = from_control_points(edge, control_points)
|
||||
segment = Path()
|
||||
tools.add_spline(segment, bspline, reset=True)
|
||||
return segment
|
||||
|
||||
def from_fit_points(edge: SplineEdge, fit_points):
|
||||
tangents = None
|
||||
if edge.start_tangent and edge.end_tangent:
|
||||
tangents = (
|
||||
wcs_tangent(edge.start_tangent),
|
||||
wcs_tangent(edge.end_tangent),
|
||||
)
|
||||
return fit_points_to_cad_cv( # only a degree of 3 is supported
|
||||
fit_points,
|
||||
tangents=tangents,
|
||||
)
|
||||
|
||||
def from_control_points(edge: SplineEdge, control_points):
|
||||
return BSpline(
|
||||
control_points=control_points,
|
||||
order=edge.degree + 1,
|
||||
knots=edge.knot_values,
|
||||
weights=edge.weights if edge.weights else None,
|
||||
)
|
||||
|
||||
def wcs(vertex: UVec) -> Vec3:
|
||||
return _wcs(Vec3(vertex[0], vertex[1], elevation))
|
||||
|
||||
def wcs_tangent(vertex: UVec) -> Vec3:
|
||||
return _wcs(Vec3(vertex[0], vertex[1], 0))
|
||||
|
||||
def _wcs(vec3: Vec3) -> Vec3:
|
||||
if ocs and ocs.transform:
|
||||
return ocs.to_wcs(vec3)
|
||||
else:
|
||||
return vec3
|
||||
|
||||
extrusion = ocs.uz if ocs else Z_AXIS
|
||||
path = Path()
|
||||
loop: Optional[Path] = None
|
||||
for edge in edges:
|
||||
next_segment: Optional[Path] = None
|
||||
if isinstance(edge, LineEdge):
|
||||
next_segment = line(edge)
|
||||
elif isinstance(edge, ArcEdge):
|
||||
if abs(edge.radius) > ABS_TOL:
|
||||
next_segment = arc(edge)
|
||||
elif isinstance(edge, EllipseEdge):
|
||||
if not Vec2(edge.major_axis).is_null:
|
||||
next_segment = ellipse(edge)
|
||||
elif isinstance(edge, SplineEdge):
|
||||
next_segment = spline(edge)
|
||||
else:
|
||||
raise TypeError(type(edge))
|
||||
|
||||
if next_segment is None:
|
||||
continue
|
||||
|
||||
if loop is None:
|
||||
loop = next_segment
|
||||
continue
|
||||
|
||||
if loop.end.isclose(next_segment.start):
|
||||
# end of current loop connects to the start of the next segment
|
||||
loop.append_path(next_segment)
|
||||
elif loop.end.isclose(next_segment.end):
|
||||
# end of current loop connects to the end of the next segment
|
||||
loop.append_path(next_segment.reversed())
|
||||
elif loop.start.isclose(next_segment.end):
|
||||
# start of the current loop connects to the end of the next segment
|
||||
next_segment.append_path(loop)
|
||||
loop = next_segment
|
||||
elif loop.start.isclose(next_segment.start):
|
||||
# start of the current loop connects to the start of the next segment
|
||||
loop = loop.reversed()
|
||||
loop.append_path(next_segment)
|
||||
else: # gap between current loop and next segment
|
||||
if loop.is_closed: # start a new loop
|
||||
path.extend_multi_path(loop)
|
||||
loop = next_segment # start a new loop
|
||||
# behavior changed in version v0.18 based on issue #706:
|
||||
else: # close the gap by a straight line and append the segment
|
||||
loop.append_path(next_segment)
|
||||
|
||||
if loop is not None:
|
||||
loop.close()
|
||||
path.extend_multi_path(loop)
|
||||
return path # multi path
|
||||
|
||||
|
||||
def from_vertices(vertices: Iterable[UVec], close=False) -> Path:
|
||||
"""Returns a :class:`Path` object from the given `vertices`."""
|
||||
_vertices = Vec3.list(vertices)
|
||||
if len(_vertices) < 2:
|
||||
return Path()
|
||||
path = Path(start=_vertices[0])
|
||||
for vertex in _vertices[1:]:
|
||||
if not path.end.isclose(vertex):
|
||||
path.line_to(vertex)
|
||||
if close:
|
||||
path.close()
|
||||
return path
|
||||
|
||||
|
||||
def to_lwpolylines(
|
||||
paths: Iterable[Path],
|
||||
*,
|
||||
distance: float = MAX_DISTANCE,
|
||||
segments: int = MIN_SEGMENTS,
|
||||
extrusion: UVec = Z_AXIS,
|
||||
dxfattribs=None,
|
||||
) -> Iterator[LWPolyline]:
|
||||
"""Convert the given `paths` into :class:`~ezdxf.entities.LWPolyline`
|
||||
entities.
|
||||
The `extrusion` vector is applied to all paths, all vertices are projected
|
||||
onto the plane normal to this extrusion vector. The default extrusion vector
|
||||
is the WCS z-axis. The plane elevation is the distance from the WCS origin
|
||||
to the start point of the first path.
|
||||
|
||||
Args:
|
||||
paths: iterable of :class:`Path` objects
|
||||
distance: maximum distance, see :meth:`Path.flattening`
|
||||
segments: minimum segment count per Bézier curve
|
||||
extrusion: extrusion vector for all paths
|
||||
dxfattribs: additional DXF attribs
|
||||
|
||||
Returns:
|
||||
iterable of :class:`~ezdxf.entities.LWPolyline` objects
|
||||
|
||||
"""
|
||||
if isinstance(paths, Path):
|
||||
paths = [paths]
|
||||
else:
|
||||
paths = list(paths)
|
||||
if len(paths) == 0:
|
||||
return
|
||||
extrusion = Vec3(extrusion)
|
||||
reference_point = Vec3(paths[0].start)
|
||||
dxfattribs = dict(dxfattribs or {})
|
||||
if not Z_AXIS.isclose(extrusion):
|
||||
ocs, elevation = _get_ocs(extrusion, reference_point)
|
||||
paths = tools.transform_paths_to_ocs(paths, ocs)
|
||||
dxfattribs["elevation"] = elevation
|
||||
dxfattribs["extrusion"] = extrusion
|
||||
elif reference_point.z != 0:
|
||||
dxfattribs["elevation"] = reference_point.z
|
||||
|
||||
for path in tools.single_paths(paths):
|
||||
if len(path) > 0:
|
||||
p = LWPolyline.new(dxfattribs=dxfattribs)
|
||||
p.append_points(path.flattening(distance, segments), format="xy")
|
||||
yield p
|
||||
|
||||
|
||||
def _get_ocs(extrusion: Vec3, reference_point: Vec3) -> tuple[OCS, float]:
|
||||
ocs = OCS(extrusion)
|
||||
elevation = ocs.from_wcs(reference_point).z
|
||||
return ocs, elevation
|
||||
|
||||
|
||||
def to_polylines2d(
|
||||
paths: Iterable[Path],
|
||||
*,
|
||||
distance: float = MAX_DISTANCE,
|
||||
segments: int = MIN_SEGMENTS,
|
||||
extrusion: UVec = Z_AXIS,
|
||||
dxfattribs=None,
|
||||
) -> Iterator[Polyline]:
|
||||
"""Convert the given `paths` into 2D :class:`~ezdxf.entities.Polyline`
|
||||
entities.
|
||||
The `extrusion` vector is applied to all paths, all vertices are projected
|
||||
onto the plane normal to this extrusion vector. The default extrusion vector
|
||||
is the WCS z-axis. The plane elevation is the distance from the WCS origin
|
||||
to the start point of the first path.
|
||||
|
||||
Args:
|
||||
paths: iterable of :class:`Path` objects
|
||||
distance: maximum distance, see :meth:`Path.flattening`
|
||||
segments: minimum segment count per Bézier curve
|
||||
extrusion: extrusion vector for all paths
|
||||
dxfattribs: additional DXF attribs
|
||||
|
||||
Returns:
|
||||
iterable of 2D :class:`~ezdxf.entities.Polyline` objects
|
||||
|
||||
"""
|
||||
if isinstance(paths, Path):
|
||||
paths = [paths]
|
||||
else:
|
||||
paths = list(paths)
|
||||
if len(paths) == 0:
|
||||
return
|
||||
extrusion = Vec3(extrusion)
|
||||
reference_point = Vec3(paths[0].start)
|
||||
dxfattribs = dict(dxfattribs or {})
|
||||
if not Z_AXIS.isclose(extrusion):
|
||||
ocs, elevation = _get_ocs(extrusion, reference_point)
|
||||
paths = tools.transform_paths_to_ocs(paths, ocs)
|
||||
dxfattribs["elevation"] = Vec3(0, 0, elevation)
|
||||
dxfattribs["extrusion"] = extrusion
|
||||
elif reference_point.z != 0:
|
||||
dxfattribs["elevation"] = Vec3(0, 0, reference_point.z)
|
||||
|
||||
for path in tools.single_paths(paths):
|
||||
if len(path) > 0:
|
||||
p = Polyline.new(dxfattribs=dxfattribs)
|
||||
p.append_vertices(path.flattening(distance, segments))
|
||||
p.new_seqend()
|
||||
yield p
|
||||
|
||||
|
||||
def to_hatches(
|
||||
paths: Iterable[Path],
|
||||
*,
|
||||
edge_path: bool = True,
|
||||
distance: float = MAX_DISTANCE,
|
||||
segments: int = MIN_SEGMENTS,
|
||||
g1_tol: float = G1_TOL,
|
||||
extrusion: UVec = Z_AXIS,
|
||||
dxfattribs=None,
|
||||
) -> Iterator[Hatch]:
|
||||
"""Convert the given `paths` into :class:`~ezdxf.entities.Hatch` entities.
|
||||
Uses LWPOLYLINE paths for boundaries without curves and edge paths, build
|
||||
of LINE and SPLINE edges, as boundary paths for boundaries including curves.
|
||||
The `extrusion` vector is applied to all paths, all vertices are projected
|
||||
onto the plane normal to this extrusion vector. The default extrusion vector
|
||||
is the WCS z-axis. The plane elevation is the distance from the WCS origin
|
||||
to the start point of the first path.
|
||||
|
||||
Args:
|
||||
paths: iterable of :class:`Path` objects
|
||||
edge_path: ``True`` for edge paths build of LINE and SPLINE edges,
|
||||
``False`` for only LWPOLYLINE paths as boundary paths
|
||||
distance: maximum distance, see :meth:`Path.flattening`
|
||||
segments: minimum segment count per Bézier curve to flatten LWPOLYLINE paths
|
||||
g1_tol: tolerance for G1 continuity check to separate SPLINE edges
|
||||
extrusion: extrusion vector to all paths
|
||||
dxfattribs: additional DXF attribs
|
||||
|
||||
Returns:
|
||||
iterable of :class:`~ezdxf.entities.Hatch` objects
|
||||
|
||||
"""
|
||||
boundary_factory: BoundaryFactory
|
||||
if edge_path:
|
||||
# noinspection PyTypeChecker
|
||||
boundary_factory = partial(
|
||||
build_edge_path, distance=distance, segments=segments, g1_tol=g1_tol
|
||||
)
|
||||
else:
|
||||
# noinspection PyTypeChecker
|
||||
boundary_factory = partial(
|
||||
build_poly_path, distance=distance, segments=segments
|
||||
)
|
||||
|
||||
yield from _polygon_converter(Hatch, paths, boundary_factory, extrusion, dxfattribs)
|
||||
|
||||
|
||||
def to_mpolygons(
|
||||
paths: Iterable[Path],
|
||||
*,
|
||||
distance: float = MAX_DISTANCE,
|
||||
segments: int = MIN_SEGMENTS,
|
||||
extrusion: UVec = Z_AXIS,
|
||||
dxfattribs=None,
|
||||
) -> Iterator[MPolygon]:
|
||||
"""Convert the given `paths` into :class:`~ezdxf.entities.MPolygon` entities.
|
||||
In contrast to HATCH, MPOLYGON supports only polyline boundary paths.
|
||||
All curves will be approximated.
|
||||
|
||||
The `extrusion` vector is applied to all paths, all vertices are projected
|
||||
onto the plane normal to this extrusion vector. The default extrusion vector
|
||||
is the WCS z-axis. The plane elevation is the distance from the WCS origin
|
||||
to the start point of the first path.
|
||||
|
||||
Args:
|
||||
paths: iterable of :class:`Path` objects
|
||||
distance: maximum distance, see :meth:`Path.flattening`
|
||||
segments: minimum segment count per Bézier curve to flatten LWPOLYLINE paths
|
||||
extrusion: extrusion vector to all paths
|
||||
dxfattribs: additional DXF attribs
|
||||
|
||||
Returns:
|
||||
iterable of :class:`~ezdxf.entities.MPolygon` objects
|
||||
|
||||
"""
|
||||
# noinspection PyTypeChecker
|
||||
boundary_factory: BoundaryFactory = partial(
|
||||
build_poly_path, distance=distance, segments=segments
|
||||
)
|
||||
dxfattribs = dict(dxfattribs or {})
|
||||
dxfattribs.setdefault("fill_color", const.BYLAYER)
|
||||
|
||||
yield from _polygon_converter(
|
||||
MPolygon, paths, boundary_factory, extrusion, dxfattribs
|
||||
)
|
||||
|
||||
|
||||
def build_edge_path(
|
||||
boundaries: BoundaryPaths,
|
||||
path: Path,
|
||||
flags: int,
|
||||
distance: float,
|
||||
segments: int,
|
||||
g1_tol: float,
|
||||
):
|
||||
if path.has_curves: # Edge path with LINE and SPLINE edges
|
||||
edge_path = boundaries.add_edge_path(flags)
|
||||
for edge in to_bsplines_and_vertices(path, g1_tol=g1_tol):
|
||||
if isinstance(edge, BSpline):
|
||||
edge_path.add_spline(
|
||||
control_points=edge.control_points,
|
||||
degree=edge.degree,
|
||||
knot_values=edge.knots(),
|
||||
)
|
||||
else: # add LINE edges
|
||||
prev = edge[0]
|
||||
for p in edge[1:]:
|
||||
edge_path.add_line(prev, p)
|
||||
prev = p
|
||||
else: # Polyline boundary path
|
||||
boundaries.add_polyline_path(
|
||||
Vec2.generate(path.flattening(distance, segments)), flags=flags
|
||||
)
|
||||
|
||||
|
||||
def build_poly_path(
|
||||
boundaries: BoundaryPaths,
|
||||
path: Path,
|
||||
flags: int,
|
||||
distance: float,
|
||||
segments: int,
|
||||
):
|
||||
boundaries.add_polyline_path(
|
||||
# Vec2 removes the z-axis, which would be interpreted as bulge value!
|
||||
Vec2.generate(path.flattening(distance, segments)),
|
||||
flags=flags,
|
||||
)
|
||||
|
||||
|
||||
def _polygon_converter(
|
||||
cls: Type[TPolygon],
|
||||
paths: Iterable[Path],
|
||||
add_boundary: BoundaryFactory,
|
||||
extrusion: UVec = Z_AXIS,
|
||||
dxfattribs=None,
|
||||
) -> Iterator[TPolygon]:
|
||||
if isinstance(paths, Path):
|
||||
paths = [paths]
|
||||
else:
|
||||
paths = list(paths)
|
||||
if len(paths) == 0:
|
||||
return
|
||||
|
||||
extrusion = Vec3(extrusion)
|
||||
reference_point = paths[0].start
|
||||
_dxfattribs: dict = dict(dxfattribs or {})
|
||||
if not Z_AXIS.isclose(extrusion):
|
||||
ocs, elevation = _get_ocs(extrusion, reference_point)
|
||||
paths = tools.transform_paths_to_ocs(paths, ocs)
|
||||
_dxfattribs["elevation"] = Vec3(0, 0, elevation)
|
||||
_dxfattribs["extrusion"] = extrusion
|
||||
elif reference_point.z != 0:
|
||||
_dxfattribs["elevation"] = Vec3(0, 0, reference_point.z)
|
||||
_dxfattribs.setdefault("solid_fill", 1)
|
||||
_dxfattribs.setdefault("pattern_name", "SOLID")
|
||||
_dxfattribs.setdefault("color", const.BYLAYER)
|
||||
|
||||
for group in group_paths(tools.single_paths(paths)):
|
||||
if len(group) == 0:
|
||||
continue
|
||||
polygon = cls.new(dxfattribs=_dxfattribs)
|
||||
boundaries = polygon.paths
|
||||
external = group[0]
|
||||
external.close()
|
||||
add_boundary(boundaries, external, 1)
|
||||
for hole in group[1:]:
|
||||
hole.close()
|
||||
add_boundary(boundaries, hole, 0)
|
||||
yield polygon
|
||||
|
||||
|
||||
def to_polylines3d(
|
||||
paths: Iterable[Path],
|
||||
*,
|
||||
distance: float = MAX_DISTANCE,
|
||||
segments: int = MIN_SEGMENTS,
|
||||
dxfattribs=None,
|
||||
) -> Iterator[Polyline]:
|
||||
"""Convert the given `paths` into 3D :class:`~ezdxf.entities.Polyline`
|
||||
entities.
|
||||
|
||||
Args:
|
||||
paths: iterable of :class:`Path` objects
|
||||
distance: maximum distance, see :meth:`Path.flattening`
|
||||
segments: minimum segment count per Bézier curve
|
||||
dxfattribs: additional DXF attribs
|
||||
|
||||
Returns:
|
||||
iterable of 3D :class:`~ezdxf.entities.Polyline` objects
|
||||
|
||||
"""
|
||||
if isinstance(paths, Path):
|
||||
paths = [paths]
|
||||
|
||||
dxfattribs = dict(dxfattribs or {})
|
||||
dxfattribs["flags"] = const.POLYLINE_3D_POLYLINE
|
||||
for path in tools.single_paths(paths):
|
||||
if len(path) > 0:
|
||||
p = Polyline.new(dxfattribs=dxfattribs)
|
||||
p.append_vertices(path.flattening(distance, segments))
|
||||
p.new_seqend()
|
||||
yield p
|
||||
|
||||
|
||||
def to_lines(
|
||||
paths: Iterable[Path],
|
||||
*,
|
||||
distance: float = MAX_DISTANCE,
|
||||
segments: int = MIN_SEGMENTS,
|
||||
dxfattribs=None,
|
||||
) -> Iterator[Line]:
|
||||
"""Convert the given `paths` into :class:`~ezdxf.entities.Line` entities.
|
||||
|
||||
Args:
|
||||
paths: iterable of :class:`Path` objects
|
||||
distance: maximum distance, see :meth:`Path.flattening`
|
||||
segments: minimum segment count per Bézier curve
|
||||
dxfattribs: additional DXF attribs
|
||||
|
||||
Returns:
|
||||
iterable of :class:`~ezdxf.entities.Line` objects
|
||||
|
||||
"""
|
||||
if isinstance(paths, Path):
|
||||
paths = [paths]
|
||||
dxfattribs = dict(dxfattribs or {})
|
||||
prev_vertex = None
|
||||
for path in tools.single_paths(paths):
|
||||
if len(path) == 0:
|
||||
continue
|
||||
for vertex in path.flattening(distance, segments):
|
||||
if prev_vertex is None:
|
||||
prev_vertex = vertex
|
||||
continue
|
||||
dxfattribs["start"] = prev_vertex
|
||||
dxfattribs["end"] = vertex
|
||||
yield Line.new(dxfattribs=dxfattribs)
|
||||
prev_vertex = vertex
|
||||
prev_vertex = None
|
||||
|
||||
|
||||
PathParts: TypeAlias = Union[BSpline, List[Vec3]]
|
||||
|
||||
|
||||
def to_bsplines_and_vertices(path: Path, g1_tol: float = G1_TOL) -> Iterator[PathParts]:
|
||||
"""Convert a :class:`Path` object into multiple cubic B-splines and
|
||||
polylines as lists of vertices. Breaks adjacent Bèzier without G1
|
||||
continuity into separated B-splines.
|
||||
|
||||
Args:
|
||||
path: :class:`Path` objects
|
||||
g1_tol: tolerance for G1 continuity check
|
||||
|
||||
Returns:
|
||||
:class:`~ezdxf.math.BSpline` and lists of :class:`~ezdxf.math.Vec3`
|
||||
|
||||
"""
|
||||
from ezdxf.math import bezier_to_bspline
|
||||
|
||||
def to_vertices():
|
||||
points = [polyline[0][0]]
|
||||
for line in polyline:
|
||||
points.append(line[1])
|
||||
return points
|
||||
|
||||
def to_bspline():
|
||||
b1 = bezier[0]
|
||||
_g1_continuity_curves = [b1]
|
||||
for b2 in bezier[1:]:
|
||||
if have_bezier_curves_g1_continuity(b1, b2, g1_tol):
|
||||
_g1_continuity_curves.append(b2)
|
||||
else:
|
||||
yield bezier_to_bspline(_g1_continuity_curves)
|
||||
_g1_continuity_curves = [b2]
|
||||
b1 = b2
|
||||
|
||||
if _g1_continuity_curves:
|
||||
yield bezier_to_bspline(_g1_continuity_curves)
|
||||
|
||||
curves = []
|
||||
for path in tools.single_paths([path]):
|
||||
prev = path.start
|
||||
for cmd in path:
|
||||
if cmd.type == Command.CURVE3_TO:
|
||||
curve = Bezier3P([prev, cmd.ctrl, cmd.end]) # type: ignore
|
||||
elif cmd.type == Command.CURVE4_TO:
|
||||
curve = Bezier4P([prev, cmd.ctrl1, cmd.ctrl2, cmd.end]) # type: ignore
|
||||
elif cmd.type == Command.LINE_TO:
|
||||
curve = (prev, cmd.end)
|
||||
else:
|
||||
raise ValueError
|
||||
curves.append(curve)
|
||||
prev = cmd.end
|
||||
|
||||
bezier: list = []
|
||||
polyline: list = []
|
||||
for curve in curves:
|
||||
if isinstance(curve, tuple):
|
||||
if bezier:
|
||||
yield from to_bspline()
|
||||
bezier.clear()
|
||||
polyline.append(curve)
|
||||
else:
|
||||
if polyline:
|
||||
yield to_vertices()
|
||||
polyline.clear()
|
||||
bezier.append(curve)
|
||||
|
||||
if bezier:
|
||||
yield from to_bspline()
|
||||
if polyline:
|
||||
yield to_vertices()
|
||||
|
||||
|
||||
def to_splines_and_polylines(
|
||||
paths: Iterable[Path],
|
||||
*,
|
||||
g1_tol: float = G1_TOL,
|
||||
dxfattribs=None,
|
||||
) -> Iterator[Union[Spline, Polyline]]:
|
||||
"""Convert the given `paths` into :class:`~ezdxf.entities.Spline` and 3D
|
||||
:class:`~ezdxf.entities.Polyline` entities.
|
||||
|
||||
Args:
|
||||
paths: iterable of :class:`Path` objects
|
||||
g1_tol: tolerance for G1 continuity check
|
||||
dxfattribs: additional DXF attribs
|
||||
|
||||
Returns:
|
||||
iterable of :class:`~ezdxf.entities.Line` objects
|
||||
|
||||
"""
|
||||
if isinstance(paths, Path):
|
||||
paths = [paths]
|
||||
dxfattribs = dict(dxfattribs or {})
|
||||
|
||||
for path in tools.single_paths(paths):
|
||||
for data in to_bsplines_and_vertices(path, g1_tol):
|
||||
if isinstance(data, BSpline):
|
||||
spline = Spline.new(dxfattribs=dxfattribs)
|
||||
spline.apply_construction_tool(data)
|
||||
yield spline
|
||||
else:
|
||||
attribs = dict(dxfattribs)
|
||||
attribs["flags"] = const.POLYLINE_3D_POLYLINE
|
||||
polyline = Polyline.new(dxfattribs=dxfattribs)
|
||||
polyline.append_vertices(data)
|
||||
polyline.new_seqend()
|
||||
yield polyline
|
||||
@@ -0,0 +1,185 @@
|
||||
# Copyright (c) 2020-2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
"""
|
||||
This module provides "nested Polygon" detection for multiple paths.
|
||||
|
||||
Terminology
|
||||
-----------
|
||||
|
||||
exterior
|
||||
creates a filled area, has counter-clockwise (ccw) winding
|
||||
exterior := Path
|
||||
|
||||
hole
|
||||
creates an unfilled area, has clockwise winding (cw),
|
||||
hole := Polygon
|
||||
|
||||
polygon
|
||||
list of nested paths:
|
||||
polygon without a hole: [path]
|
||||
polygon with 1 hole: [path, [path]]
|
||||
polygon with 2 separated holes: [path, [path], [path]]
|
||||
polygon with 2 nested holes: [path, [path, [path]]]
|
||||
|
||||
polygon := [exterior, *hole]
|
||||
|
||||
The result is a list of polygons:
|
||||
|
||||
1 polygon returns: [[ext-path]]
|
||||
2 separated polygons returns: [[ext-path], [ext-path, [hole-path]]]
|
||||
|
||||
A hole is just another polygon, some render backends may require a distinct winding
|
||||
order for nested paths like: ccw-cw-ccw-cw...
|
||||
|
||||
[Exterior-ccw,
|
||||
[Hole-Exterior-cw,
|
||||
[Sub-Hole-ccw],
|
||||
[Sub-Hole-ccw],
|
||||
],
|
||||
[Hole-Exterior-cw],
|
||||
[Hole-Exterior-cw],
|
||||
]
|
||||
|
||||
The implementation has to do some expensive tests, like check if a path is
|
||||
inside of another path or if paths do overlap. A goal is to reduce this costs
|
||||
by using proxy objects:
|
||||
|
||||
Bounding Box Proxy
|
||||
------------------
|
||||
|
||||
This implementation uses the bounding box of the path as proxy object, this is very fast
|
||||
but not accurate, but can handle most of the real world scenarios, in the assumption
|
||||
that most HATCHES are created from non-overlapping boundary paths.
|
||||
Overlap detection and resolving is not possible.
|
||||
|
||||
The input paths have to implement the SupportsBoundingBox protocol, which requires
|
||||
only a method bbox() that returns a BoundingBox2d instance for the path.
|
||||
|
||||
Sort by Area
|
||||
------------
|
||||
|
||||
It is not possible for a path to contain another path with a larger area.
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Tuple,
|
||||
Optional,
|
||||
List,
|
||||
Iterable,
|
||||
Sequence,
|
||||
Iterator,
|
||||
TypeVar,
|
||||
)
|
||||
from typing_extensions import TypeAlias
|
||||
from collections import namedtuple
|
||||
from ezdxf.math import AbstractBoundingBox
|
||||
from ezdxf.protocols import SupportsBoundingBox
|
||||
|
||||
|
||||
__all__ = [
|
||||
"make_polygon_structure",
|
||||
"winding_deconstruction",
|
||||
"group_paths",
|
||||
"flatten_polygons",
|
||||
]
|
||||
|
||||
|
||||
T = TypeVar("T", bound=SupportsBoundingBox)
|
||||
|
||||
Polygon: TypeAlias = Tuple[T, Optional[List["Polygon"]]]
|
||||
BoxStruct = namedtuple("BoxStruct", "bbox, path")
|
||||
|
||||
|
||||
def make_polygon_structure(paths: Iterable[T]) -> list[Polygon]:
|
||||
"""Returns a recursive polygon structure from iterable `paths`, uses 2D
|
||||
bounding boxes as fast detection objects.
|
||||
|
||||
"""
|
||||
|
||||
# Implements fast bounding box construction and fast inside check.
|
||||
def area(item: BoxStruct) -> float:
|
||||
size = item.bbox.size
|
||||
return size.x * size.y
|
||||
|
||||
def separate(
|
||||
exterior: AbstractBoundingBox, candidates: list[BoxStruct]
|
||||
) -> tuple[list[BoxStruct], list[BoxStruct]]:
|
||||
holes: list[BoxStruct] = []
|
||||
outside: list[BoxStruct] = []
|
||||
for candidate in candidates:
|
||||
# Fast inside check:
|
||||
(holes if exterior.inside(candidate.bbox.center) else outside).append(
|
||||
candidate
|
||||
)
|
||||
return holes, outside
|
||||
|
||||
def polygon_structure(outside: list[BoxStruct]) -> list[list]:
|
||||
polygons = []
|
||||
while outside:
|
||||
exterior = outside.pop() # path with the largest area
|
||||
# Get holes inside of exterior and returns the remaining paths
|
||||
# outside of exterior:
|
||||
holes, outside = separate(exterior.bbox, outside)
|
||||
if holes:
|
||||
# build nested hole structure:
|
||||
# the largest hole could contain the smaller holes,
|
||||
# and so on ...
|
||||
holes = polygon_structure(holes) # type: ignore
|
||||
polygons.append([exterior, *holes])
|
||||
return polygons
|
||||
|
||||
def as_nested_paths(polygons) -> list:
|
||||
return [
|
||||
polygon.path if isinstance(polygon, BoxStruct) else as_nested_paths(polygon)
|
||||
for polygon in polygons
|
||||
]
|
||||
|
||||
boxed_paths = []
|
||||
for path in paths:
|
||||
bbox = path.bbox()
|
||||
if bbox.has_data:
|
||||
boxed_paths.append(BoxStruct(bbox, path))
|
||||
boxed_paths.sort(key=area)
|
||||
return as_nested_paths(polygon_structure(boxed_paths))
|
||||
|
||||
|
||||
def winding_deconstruction(
|
||||
polygons: list[Polygon],
|
||||
) -> tuple[list[T], list[T]]:
|
||||
"""Flatten the nested polygon structure in a tuple of two lists,
|
||||
the first list contains the paths which should be counter-clockwise oriented
|
||||
and the second list contains the paths which should be clockwise oriented.
|
||||
|
||||
The paths are not converted to this orientation.
|
||||
|
||||
"""
|
||||
|
||||
def deconstruct(polygons_, level):
|
||||
for polygon in polygons_:
|
||||
if isinstance(polygon, Sequence):
|
||||
deconstruct(polygon, level + 1)
|
||||
else:
|
||||
# level 0 is the list of polygons
|
||||
# level 1 = ccw, 2 = cw, 3 = ccw, 4 = cw, ...
|
||||
(ccw_paths if (level % 2) else cw_paths).append(polygon)
|
||||
|
||||
cw_paths: list[T] = []
|
||||
ccw_paths: list[T] = []
|
||||
deconstruct(polygons, 0)
|
||||
return ccw_paths, cw_paths
|
||||
|
||||
|
||||
def flatten_polygons(polygons: Polygon) -> Iterator[T]:
|
||||
"""Yield a flat representation of the given nested polygons."""
|
||||
for polygon in polygons:
|
||||
if isinstance(polygon, Sequence):
|
||||
yield from flatten_polygons(polygon) # type: ignore
|
||||
else:
|
||||
yield polygon # type: ignore # T
|
||||
|
||||
|
||||
def group_paths(paths: Iterable[T]) -> list[list[T]]:
|
||||
"""Group separated paths and their inner holes as flat lists."""
|
||||
polygons = make_polygon_structure(paths)
|
||||
return [list(flatten_polygons(polygon)) for polygon in polygons]
|
||||
@@ -0,0 +1,507 @@
|
||||
# Copyright (c) 2020-2023, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Optional,
|
||||
Iterator,
|
||||
Iterable,
|
||||
Any,
|
||||
Callable,
|
||||
)
|
||||
from typing_extensions import Self
|
||||
from ezdxf.math import (
|
||||
Vec3,
|
||||
NULLVEC,
|
||||
OCS,
|
||||
Bezier3P,
|
||||
Bezier4P,
|
||||
Matrix44,
|
||||
has_clockwise_orientation,
|
||||
UVec,
|
||||
BoundingBox,
|
||||
)
|
||||
|
||||
from .commands import (
|
||||
Command,
|
||||
LineTo,
|
||||
MoveTo,
|
||||
Curve3To,
|
||||
Curve4To,
|
||||
PathElement,
|
||||
)
|
||||
|
||||
__all__ = ["Path"]
|
||||
|
||||
MAX_DISTANCE = 0.01
|
||||
MIN_SEGMENTS = 4
|
||||
G1_TOL = 1e-4
|
||||
_slots = ("_vertices", "_start_index", "_commands", "_has_sub_paths", "_user_data")
|
||||
|
||||
|
||||
class Path:
|
||||
__slots__ = _slots
|
||||
|
||||
def __init__(self, start: UVec = NULLVEC):
|
||||
# stores all command vertices in a contiguous list:
|
||||
self._vertices: list[Vec3] = [Vec3(start)]
|
||||
# start index of each command
|
||||
self._start_index: list[int] = []
|
||||
self._commands: list[Command] = []
|
||||
self._has_sub_paths = False
|
||||
self._user_data: Any = None # should be immutable data!
|
||||
|
||||
@classmethod
|
||||
def from_vertices_and_commands(
|
||||
cls, vertices: list[Vec3], command_codes: list[Command], user_data: Any = None
|
||||
) -> Self:
|
||||
"""Create path instances from a list of vertices and a list of commands."""
|
||||
# Used for fast conversion from NumpyPath2d to Path.
|
||||
# This is "hacky" but also 8x faster than the correct way using only public
|
||||
# methods and properties.
|
||||
new_path = cls()
|
||||
if len(vertices) == 0:
|
||||
return new_path
|
||||
new_path._vertices = vertices
|
||||
new_path._commands = command_codes
|
||||
new_path._start_index = make_vertex_index(command_codes)
|
||||
new_path._has_sub_paths = any(cmd == Command.MOVE_TO for cmd in command_codes)
|
||||
new_path._user_data = user_data
|
||||
return new_path
|
||||
|
||||
def transform(self, m: Matrix44) -> Self:
|
||||
"""Returns a new transformed path.
|
||||
|
||||
Args:
|
||||
m: transformation matrix of type :class:`~ezdxf.math.Matrix44`
|
||||
|
||||
"""
|
||||
new_path = self.clone()
|
||||
new_path._vertices = list(m.transform_vertices(self._vertices))
|
||||
return new_path
|
||||
|
||||
def bbox(self) -> BoundingBox:
|
||||
"""Returns the bounding box of all control vertices as
|
||||
:class:`~ezdxf.math.BoundingBox` instance.
|
||||
"""
|
||||
return BoundingBox(self.control_vertices())
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Returns count of path elements."""
|
||||
return len(self._commands)
|
||||
|
||||
def __getitem__(self, item) -> PathElement:
|
||||
"""Returns the path element at given index, slicing is not supported."""
|
||||
if isinstance(item, slice):
|
||||
raise TypeError("slicing not supported")
|
||||
cmd = self._commands[item]
|
||||
index = self._start_index[item]
|
||||
vertices = self._vertices
|
||||
if cmd == Command.MOVE_TO:
|
||||
return MoveTo(vertices[index])
|
||||
if cmd == Command.LINE_TO:
|
||||
return LineTo(vertices[index])
|
||||
if cmd == Command.CURVE3_TO: # end, ctrl
|
||||
return Curve3To(vertices[index + 1], vertices[index])
|
||||
if cmd == Command.CURVE4_TO:
|
||||
return Curve4To( # end, ctrl1, ctrl2
|
||||
vertices[index + 2],
|
||||
vertices[index],
|
||||
vertices[index + 1],
|
||||
)
|
||||
raise ValueError(f"Invalid command: {cmd}")
|
||||
|
||||
def __iter__(self) -> Iterator[PathElement]:
|
||||
return (self[i] for i in range(len(self._commands)))
|
||||
|
||||
def commands(self) -> list[PathElement]:
|
||||
"""Returns all path elements as list."""
|
||||
return list(self.__iter__())
|
||||
|
||||
def __copy__(self) -> Self:
|
||||
"""Returns a new copy of :class:`Path` with shared immutable data."""
|
||||
copy = self.__class__()
|
||||
# vertices itself are immutable - no copying required
|
||||
copy._vertices = self._vertices.copy()
|
||||
self._copy_properties(copy)
|
||||
return copy
|
||||
|
||||
def _copy_properties(self, clone: Path) -> None:
|
||||
assert len(self._vertices) == len(clone._vertices)
|
||||
clone._commands = self._commands.copy()
|
||||
clone._start_index = self._start_index.copy()
|
||||
clone._has_sub_paths = self._has_sub_paths
|
||||
# copy by reference: user data should be immutable data!
|
||||
clone._user_data = self._user_data
|
||||
|
||||
clone = __copy__
|
||||
|
||||
@property
|
||||
def user_data(self) -> Any:
|
||||
"""Attach arbitrary user data to a :class:`Path` object.
|
||||
The user data is copied by reference, no deep copy is applied
|
||||
therefore a mutable state is shared between copies.
|
||||
"""
|
||||
return self._user_data
|
||||
|
||||
@user_data.setter
|
||||
def user_data(self, data: Any):
|
||||
self._user_data = data
|
||||
|
||||
@property
|
||||
def start(self) -> Vec3:
|
||||
""":class:`Path` start point, resetting the start point of an empty
|
||||
path is possible.
|
||||
"""
|
||||
return self._vertices[0]
|
||||
|
||||
@start.setter
|
||||
def start(self, location: UVec) -> None:
|
||||
if self._commands:
|
||||
raise ValueError("Requires an empty path.")
|
||||
else:
|
||||
self._vertices[0] = Vec3(location)
|
||||
|
||||
@property
|
||||
def end(self) -> Vec3:
|
||||
""":class:`Path` end point."""
|
||||
return self._vertices[-1]
|
||||
|
||||
def control_vertices(self) -> list[Vec3]:
|
||||
"""Yields all path control vertices in consecutive order."""
|
||||
if self._commands:
|
||||
return list(self._vertices)
|
||||
return []
|
||||
|
||||
def command_codes(self) -> list[int]:
|
||||
"""Internal API."""
|
||||
return list(self._commands)
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
"""Returns ``True`` if the start point is close to the end point."""
|
||||
vertices = self._vertices
|
||||
if len(vertices) > 1:
|
||||
return vertices[0].isclose(vertices[-1])
|
||||
return False
|
||||
|
||||
@property
|
||||
def has_lines(self) -> bool:
|
||||
"""Returns ``True`` if the path has any line segments."""
|
||||
return Command.LINE_TO in self._commands
|
||||
|
||||
@property
|
||||
def has_curves(self) -> bool:
|
||||
"""Returns ``True`` if the path has any curve segments."""
|
||||
return (
|
||||
Command.CURVE4_TO in self._commands or Command.CURVE3_TO in self._commands
|
||||
)
|
||||
|
||||
@property
|
||||
def has_sub_paths(self) -> bool:
|
||||
"""Returns ``True`` if the path is a :term:`Multi-Path` object that
|
||||
contains multiple sub-paths.
|
||||
|
||||
"""
|
||||
return self._has_sub_paths
|
||||
|
||||
def has_clockwise_orientation(self) -> bool:
|
||||
"""Returns ``True`` if 2D path has clockwise orientation, ignores
|
||||
z-axis of all control vertices.
|
||||
|
||||
Raises:
|
||||
TypeError: can't detect orientation of a :term:`Multi-Path` object
|
||||
|
||||
"""
|
||||
if self.has_sub_paths:
|
||||
raise TypeError("can't detect orientation of a multi-path object")
|
||||
return has_clockwise_orientation(self._vertices)
|
||||
|
||||
def append_path_element(self, cmd: PathElement) -> None:
|
||||
"""Append a single path element."""
|
||||
t = cmd.type
|
||||
if t == Command.LINE_TO:
|
||||
self.line_to(cmd.end)
|
||||
elif t == Command.MOVE_TO:
|
||||
self.move_to(cmd.end)
|
||||
elif t == Command.CURVE3_TO:
|
||||
self.curve3_to(cmd.end, cmd.ctrl) # type: ignore
|
||||
elif t == Command.CURVE4_TO:
|
||||
self.curve4_to(cmd.end, cmd.ctrl1, cmd.ctrl2) # type: ignore
|
||||
else:
|
||||
raise ValueError(f"Invalid command: {t}")
|
||||
|
||||
def line_to(self, location: UVec) -> None:
|
||||
"""Add a line from actual path end point to `location`."""
|
||||
self._commands.append(Command.LINE_TO)
|
||||
self._start_index.append(len(self._vertices))
|
||||
self._vertices.append(Vec3(location))
|
||||
|
||||
def move_to(self, location: UVec) -> None:
|
||||
"""Start a new sub-path at `location`. This creates a gap between the
|
||||
current end-point and the start-point of the new sub-path. This converts
|
||||
the instance into a :term:`Multi-Path` object.
|
||||
|
||||
If the :meth:`move_to` command is the first command, the start point of
|
||||
the path will be reset to `location`.
|
||||
|
||||
"""
|
||||
commands = self._commands
|
||||
if not commands:
|
||||
self._vertices[0] = Vec3(location)
|
||||
return
|
||||
self._has_sub_paths = True
|
||||
if commands[-1] == Command.MOVE_TO:
|
||||
# replace last move to command
|
||||
commands.pop()
|
||||
self._vertices.pop()
|
||||
self._start_index.pop()
|
||||
commands.append(Command.MOVE_TO)
|
||||
self._start_index.append(len(self._vertices))
|
||||
self._vertices.append(Vec3(location))
|
||||
|
||||
def curve3_to(self, location: UVec, ctrl: UVec) -> None:
|
||||
"""Add a quadratic Bèzier-curve from actual path end point to
|
||||
`location`, `ctrl` is the control point for the quadratic Bèzier-curve.
|
||||
"""
|
||||
self._commands.append(Command.CURVE3_TO)
|
||||
self._start_index.append(len(self._vertices))
|
||||
self._vertices.extend((Vec3(ctrl), Vec3(location)))
|
||||
|
||||
def curve4_to(self, location: UVec, ctrl1: UVec, ctrl2: UVec) -> None:
|
||||
"""Add a cubic Bèzier-curve from actual path end point to `location`,
|
||||
`ctrl1` and `ctrl2` are the control points for the cubic Bèzier-curve.
|
||||
"""
|
||||
self._commands.append(Command.CURVE4_TO)
|
||||
self._start_index.append(len(self._vertices))
|
||||
self._vertices.extend((Vec3(ctrl1), Vec3(ctrl2), Vec3(location)))
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close path by adding a line segment from the end point to the start
|
||||
point.
|
||||
"""
|
||||
if not self.is_closed:
|
||||
self.line_to(self.start)
|
||||
|
||||
def close_sub_path(self) -> None:
|
||||
"""Close last sub-path by adding a line segment from the end point to
|
||||
the start point of the last sub-path. Behaves like :meth:`close` for
|
||||
:term:`Single-Path` instances.
|
||||
"""
|
||||
if self.has_sub_paths:
|
||||
start_point = self._start_of_last_sub_path()
|
||||
assert (
|
||||
start_point is not None
|
||||
), "internal error: required MOVE_TO command not found"
|
||||
if not self.end.isclose(start_point):
|
||||
self.line_to(start_point)
|
||||
else:
|
||||
self.close()
|
||||
|
||||
def _start_of_last_sub_path(self) -> Optional[Vec3]:
|
||||
move_to = Command.MOVE_TO
|
||||
commands = self._commands
|
||||
index = len(commands) - 1
|
||||
# The first command at index 0 is never MOVE_TO!
|
||||
while index > 0:
|
||||
if commands[index] == move_to:
|
||||
return self._vertices[self._start_index[index]]
|
||||
index -= 1
|
||||
return None
|
||||
|
||||
def reversed(self) -> Self:
|
||||
"""Returns a new :class:`Path` with reversed commands and control
|
||||
vertices.
|
||||
|
||||
"""
|
||||
path = self.clone()
|
||||
if not path._commands:
|
||||
return path
|
||||
if path._commands[-1] == Command.MOVE_TO:
|
||||
# The last move_to will become the first move_to.
|
||||
# A move_to as first command just moves the start point and can be
|
||||
# removed!
|
||||
# There are never two consecutive MOVE_TO commands in a Path!
|
||||
path._commands.pop()
|
||||
path._vertices.pop()
|
||||
path._start_index.pop()
|
||||
path._has_sub_paths = any( # is still a multi-path?
|
||||
cmd == Command.MOVE_TO for cmd in path._commands
|
||||
)
|
||||
path._commands.reverse()
|
||||
path._vertices.reverse()
|
||||
path._start_index = make_vertex_index(path._commands)
|
||||
return path
|
||||
|
||||
def clockwise(self) -> Self:
|
||||
"""Returns new :class:`Path` in clockwise orientation.
|
||||
|
||||
Raises:
|
||||
TypeError: can't detect orientation of a :term:`Multi-Path` object
|
||||
|
||||
"""
|
||||
if self.has_clockwise_orientation():
|
||||
return self.clone()
|
||||
else:
|
||||
return self.reversed()
|
||||
|
||||
def counter_clockwise(self) -> Self:
|
||||
"""Returns new :class:`Path` in counter-clockwise orientation.
|
||||
|
||||
Raises:
|
||||
TypeError: can't detect orientation of a :term:`Multi-Path` object
|
||||
|
||||
"""
|
||||
|
||||
if self.has_clockwise_orientation():
|
||||
return self.reversed()
|
||||
else:
|
||||
return self.clone()
|
||||
|
||||
def approximate(self, segments: int = 20) -> Iterator[Vec3]:
|
||||
"""Approximate path by vertices, `segments` is the count of
|
||||
approximation segments for each Bézier curve.
|
||||
|
||||
Does not yield any vertices for empty paths, where only a start point
|
||||
is present!
|
||||
|
||||
Approximation of :term:`Multi-Path` objects is possible, but gaps are
|
||||
indistinguishable from line segments.
|
||||
|
||||
"""
|
||||
|
||||
def curve3(p0: Vec3, p1: Vec3, p2: Vec3) -> Iterator[Vec3]:
|
||||
return iter(Bezier3P((p0, p1, p2)).approximate(segments))
|
||||
|
||||
def curve4(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3) -> Iterator[Vec3]:
|
||||
return iter(Bezier4P((p0, p1, p2, p3)).approximate(segments))
|
||||
|
||||
return self._approximate(curve3, curve4)
|
||||
|
||||
def flattening(self, distance: float, segments: int = 4) -> Iterator[Vec3]:
|
||||
"""Approximate path by vertices and use adaptive recursive flattening
|
||||
to approximate Bèzier curves. The argument `segments` is the
|
||||
minimum count of approximation segments for each curve, if the distance
|
||||
from the center of the approximation segment to the curve is bigger than
|
||||
`distance` the segment will be subdivided.
|
||||
|
||||
Does not yield any vertices for empty paths, where only a start point
|
||||
is present!
|
||||
|
||||
Flattening of :term:`Multi-Path` objects is possible, but gaps are
|
||||
indistinguishable from line segments.
|
||||
|
||||
Args:
|
||||
distance: maximum distance from the center of the curve to the
|
||||
center of the line segment between two approximation points to
|
||||
determine if a segment should be subdivided.
|
||||
segments: minimum segment count per Bézier curve
|
||||
|
||||
"""
|
||||
|
||||
def curve3(p0: Vec3, p1: Vec3, p2: Vec3) -> Iterator[Vec3]:
|
||||
if distance == 0.0:
|
||||
raise ValueError(f"invalid max distance: 0.0")
|
||||
return iter(Bezier3P((p0, p1, p2)).flattening(distance, segments))
|
||||
|
||||
def curve4(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3) -> Iterator[Vec3]:
|
||||
if distance == 0.0:
|
||||
raise ValueError(f"invalid max distance: 0.0")
|
||||
return iter(Bezier4P((p0, p1, p2, p3)).flattening(distance, segments))
|
||||
|
||||
return self._approximate(curve3, curve4)
|
||||
|
||||
def _approximate(self, curve3: Callable, curve4: Callable) -> Iterator[Vec3]:
|
||||
if not self._commands:
|
||||
return
|
||||
|
||||
start = self._vertices[0]
|
||||
yield start
|
||||
|
||||
vertices = self._vertices
|
||||
for si, cmd in zip(self._start_index, self._commands):
|
||||
if cmd == Command.LINE_TO or cmd == Command.MOVE_TO:
|
||||
end_location = vertices[si]
|
||||
yield end_location
|
||||
elif cmd == Command.CURVE3_TO:
|
||||
ctrl, end_location = vertices[si : si + 2]
|
||||
pts = curve3(start, ctrl, end_location)
|
||||
next(pts) # skip first vertex
|
||||
yield from pts
|
||||
elif cmd == Command.CURVE4_TO:
|
||||
ctrl1, ctrl2, end_location = vertices[si : si + 3]
|
||||
pts = curve4(start, ctrl1, ctrl2, end_location)
|
||||
next(pts) # skip first vertex
|
||||
yield from pts
|
||||
else:
|
||||
raise ValueError(f"Invalid command: {cmd}")
|
||||
start = end_location
|
||||
|
||||
def to_wcs(self, ocs: OCS, elevation: float) -> None:
|
||||
"""Transform path from given `ocs` to WCS coordinates inplace."""
|
||||
self._vertices = list(
|
||||
ocs.to_wcs(v.replace(z=float(elevation))) for v in self._vertices
|
||||
)
|
||||
|
||||
def sub_paths(self) -> Iterator[Self]:
|
||||
"""Yield all sub-paths as :term:`Single-Path` objects.
|
||||
|
||||
It's safe to call :meth:`sub_paths` on any path-type:
|
||||
:term:`Single-Path`, :term:`Multi-Path` and :term:`Empty-Path`.
|
||||
|
||||
"""
|
||||
path = self.__class__(start=self.start)
|
||||
path._user_data = self._user_data
|
||||
move_to = Command.MOVE_TO
|
||||
for cmd in self.commands():
|
||||
if cmd.type == move_to:
|
||||
yield path
|
||||
path = self.__class__(start=cmd.end)
|
||||
path._user_data = self._user_data
|
||||
else:
|
||||
path.append_path_element(cmd)
|
||||
yield path
|
||||
|
||||
def extend_multi_path(self, path: Path) -> None:
|
||||
"""Extend the path by another path. The source path is automatically a
|
||||
:term:`Multi-Path` object, even if the previous end point matches the
|
||||
start point of the appended path. Ignores paths without any commands
|
||||
(empty paths).
|
||||
|
||||
"""
|
||||
if len(path):
|
||||
self.move_to(path.start)
|
||||
for cmd in path.commands():
|
||||
self.append_path_element(cmd)
|
||||
|
||||
def append_path(self, path: Path) -> None:
|
||||
"""Append another path to this path. Adds a :code:`self.line_to(path.start)`
|
||||
if the end of this path != the start of appended path.
|
||||
|
||||
"""
|
||||
if len(path) == 0:
|
||||
return # do not append an empty path
|
||||
if self._commands:
|
||||
if not self.end.isclose(path.start):
|
||||
self.line_to(path.start)
|
||||
else:
|
||||
self.start = path.start
|
||||
for cmd in path.commands():
|
||||
self.append_path_element(cmd)
|
||||
|
||||
|
||||
CMD_SIZE = {
|
||||
Command.MOVE_TO: 1,
|
||||
Command.LINE_TO: 1,
|
||||
Command.CURVE3_TO: 2,
|
||||
Command.CURVE4_TO: 3,
|
||||
}
|
||||
|
||||
|
||||
def make_vertex_index(command_codes: Iterable[Command]) -> list[int]:
|
||||
cmd_size = CMD_SIZE
|
||||
start: int = 1
|
||||
start_index: list[int] = []
|
||||
for code in command_codes:
|
||||
start_index.append(start)
|
||||
start += cmd_size[code]
|
||||
return start_index
|
||||
@@ -0,0 +1,306 @@
|
||||
# Copyright (c) 2021-2024, Manfred Moitzi
|
||||
# License: MIT License
|
||||
from __future__ import annotations
|
||||
import math
|
||||
from ezdxf.math import (
|
||||
cubic_bezier_arc_parameters,
|
||||
Matrix44,
|
||||
UVec,
|
||||
basic_transformation,
|
||||
Vec3,
|
||||
)
|
||||
from ezdxf.render import forms
|
||||
from .path import Path
|
||||
from . import converter
|
||||
|
||||
__all__ = [
|
||||
"unit_circle",
|
||||
"elliptic_transformation",
|
||||
"rect",
|
||||
"ngon",
|
||||
"wedge",
|
||||
"star",
|
||||
"gear",
|
||||
"helix",
|
||||
]
|
||||
|
||||
|
||||
def unit_circle(
|
||||
start_angle: float = 0,
|
||||
end_angle: float = math.tau,
|
||||
segments: int = 1,
|
||||
transform: Matrix44 | None = None,
|
||||
) -> Path:
|
||||
"""Returns a unit circle as a :class:`Path` object, with the center at
|
||||
(0, 0, 0) and the radius of 1 drawing unit.
|
||||
|
||||
The arc spans from the start- to the end angle in counter-clockwise
|
||||
orientation. The end angle has to be greater than the start angle and the
|
||||
angle span has to be greater than 0.
|
||||
|
||||
Args:
|
||||
start_angle: start angle in radians
|
||||
end_angle: end angle in radians (end_angle > start_angle!)
|
||||
segments: count of Bèzier-curve segments, default is one segment for
|
||||
each arc quarter (π/2)
|
||||
transform: transformation Matrix applied to the unit circle
|
||||
|
||||
"""
|
||||
path = Path()
|
||||
start_flag = True
|
||||
for start, ctrl1, ctrl2, end in cubic_bezier_arc_parameters(
|
||||
start_angle, end_angle, segments
|
||||
):
|
||||
if start_flag:
|
||||
path.start = start
|
||||
start_flag = False
|
||||
path.curve4_to(end, ctrl1, ctrl2)
|
||||
if transform is None:
|
||||
return path
|
||||
else:
|
||||
return path.transform(transform)
|
||||
|
||||
|
||||
def wedge(
|
||||
start_angle: float,
|
||||
end_angle: float,
|
||||
segments: int = 1,
|
||||
transform: Matrix44 | None = None,
|
||||
) -> Path:
|
||||
"""Returns a wedge as a :class:`Path` object, with the center at
|
||||
(0, 0, 0) and the radius of 1 drawing unit.
|
||||
|
||||
The arc spans from the start- to the end angle in counter-clockwise
|
||||
orientation. The end angle has to be greater than the start angle and the
|
||||
angle span has to be greater than 0.
|
||||
|
||||
Args:
|
||||
start_angle: start angle in radians
|
||||
end_angle: end angle in radians (end_angle > start_angle!)
|
||||
segments: count of Bèzier-curve segments, default is one segment for
|
||||
each arc quarter (π/2)
|
||||
transform: transformation Matrix applied to the wedge
|
||||
|
||||
"""
|
||||
path = Path()
|
||||
start_flag = True
|
||||
for start, ctrl1, ctrl2, end in cubic_bezier_arc_parameters(
|
||||
start_angle, end_angle, segments
|
||||
):
|
||||
if start_flag:
|
||||
path.line_to(start)
|
||||
start_flag = False
|
||||
path.curve4_to(end, ctrl1, ctrl2)
|
||||
path.line_to((0, 0, 0))
|
||||
if transform is None:
|
||||
return path
|
||||
else:
|
||||
return path.transform(transform)
|
||||
|
||||
|
||||
def elliptic_transformation(
|
||||
center: UVec = (0, 0, 0),
|
||||
radius: float = 1,
|
||||
ratio: float = 1,
|
||||
rotation: float = 0,
|
||||
) -> Matrix44:
|
||||
"""Returns the transformation matrix to transform a unit circle into
|
||||
an arbitrary circular- or elliptic arc.
|
||||
|
||||
Example how to create an ellipse with a major axis length of 3, a minor
|
||||
axis length 1.5 and rotated about 90°::
|
||||
|
||||
m = elliptic_transformation(radius=3, ratio=0.5, rotation=math.pi / 2)
|
||||
ellipse = shapes.unit_circle(transform=m)
|
||||
|
||||
Args:
|
||||
center: curve center in WCS
|
||||
radius: radius of the major axis in drawing units
|
||||
ratio: ratio of minor axis to major axis
|
||||
rotation: rotation angle about the z-axis in radians
|
||||
|
||||
"""
|
||||
if radius < 1e-6:
|
||||
raise ValueError(f"invalid radius: {radius}")
|
||||
if ratio < 1e-6:
|
||||
raise ValueError(f"invalid ratio: {ratio}")
|
||||
scale_x = radius
|
||||
scale_y = radius * ratio
|
||||
return basic_transformation(center, (scale_x, scale_y, 1), rotation)
|
||||
|
||||
|
||||
def rect(
|
||||
width: float = 1, height: float = 1, transform: Matrix44 | None = None
|
||||
) -> Path:
|
||||
"""Returns a closed rectangle as a :class:`Path` object, with the center at
|
||||
(0, 0, 0) and the given `width` and `height` in drawing units.
|
||||
|
||||
Args:
|
||||
width: width of the rectangle in drawing units, width > 0
|
||||
height: height of the rectangle in drawing units, height > 0
|
||||
transform: transformation Matrix applied to the rectangle
|
||||
|
||||
"""
|
||||
if width < 1e-9:
|
||||
raise ValueError(f"invalid width: {width}")
|
||||
if height < 1e-9:
|
||||
raise ValueError(f"invalid height: {height}")
|
||||
|
||||
w2 = float(width) / 2.0
|
||||
h2 = float(height) / 2.0
|
||||
path = converter.from_vertices(
|
||||
[(w2, h2), (-w2, h2), (-w2, -h2), (w2, -h2)], close=True
|
||||
)
|
||||
if transform is None:
|
||||
return path
|
||||
else:
|
||||
return path.transform(transform)
|
||||
|
||||
|
||||
def ngon(
|
||||
count: int,
|
||||
length: float | None = None,
|
||||
radius: float = 1.0,
|
||||
transform: Matrix44 | None = None,
|
||||
) -> Path:
|
||||
"""Returns a `regular polygon <https://en.wikipedia.org/wiki/Regular_polygon>`_
|
||||
a :class:`Path` object, with the center at (0, 0, 0).
|
||||
The polygon size is determined by the edge `length` or the circum `radius`
|
||||
argument. If both are given `length` has higher priority. Default size is
|
||||
a `radius` of 1. The ngon starts with the first vertex is on the x-axis!
|
||||
The base geometry is created by function :func:`ezdxf.render.forms.ngon`.
|
||||
|
||||
Args:
|
||||
count: count of polygon corners >= 3
|
||||
length: length of polygon side
|
||||
radius: circum radius, default is 1
|
||||
transform: transformation Matrix applied to the ngon
|
||||
|
||||
"""
|
||||
vertices = forms.ngon(count, length=length, radius=radius)
|
||||
if transform is not None:
|
||||
vertices = transform.transform_vertices(vertices)
|
||||
return converter.from_vertices(vertices, close=True)
|
||||
|
||||
|
||||
def star(count: int, r1: float, r2: float, transform: Matrix44 | None = None) -> Path:
|
||||
"""Returns a `star shape <https://en.wikipedia.org/wiki/Star_polygon>`_ as
|
||||
a :class:`Path` object, with the center at (0, 0, 0).
|
||||
|
||||
Argument `count` defines the count of star spikes, `r1` defines the radius
|
||||
of the "outer" vertices and `r2` defines the radius of the "inner" vertices,
|
||||
but this does not mean that `r1` has to be greater than `r2`.
|
||||
The star shape starts with the first vertex is on the x-axis!
|
||||
The base geometry is created by function :func:`ezdxf.render.forms.star`.
|
||||
|
||||
Args:
|
||||
count: spike count >= 3
|
||||
r1: radius 1
|
||||
r2: radius 2
|
||||
transform: transformation Matrix applied to the star
|
||||
|
||||
"""
|
||||
vertices = forms.star(count, r1=r1, r2=r2)
|
||||
if transform is not None:
|
||||
vertices = transform.transform_vertices(vertices)
|
||||
return converter.from_vertices(vertices, close=True)
|
||||
|
||||
|
||||
def gear(
|
||||
count: int,
|
||||
top_width: float,
|
||||
bottom_width: float,
|
||||
height: float,
|
||||
outside_radius: float,
|
||||
transform: Matrix44 | None = None,
|
||||
) -> Path:
|
||||
"""
|
||||
Returns a `gear <https://en.wikipedia.org/wiki/Gear>`_ (cogwheel) shape as
|
||||
a :class:`Path` object, with the center at (0, 0, 0).
|
||||
The base geometry is created by function :func:`ezdxf.render.forms.gear`.
|
||||
|
||||
.. warning::
|
||||
|
||||
This function does not create correct gears for mechanical engineering!
|
||||
|
||||
Args:
|
||||
count: teeth count >= 3
|
||||
top_width: teeth width at outside radius
|
||||
bottom_width: teeth width at base radius
|
||||
height: teeth height; base radius = outside radius - height
|
||||
outside_radius: outside radius
|
||||
transform: transformation Matrix applied to the gear shape
|
||||
|
||||
"""
|
||||
vertices = forms.gear(count, top_width, bottom_width, height, outside_radius)
|
||||
if transform is not None:
|
||||
vertices = transform.transform_vertices(vertices)
|
||||
return converter.from_vertices(vertices, close=True)
|
||||
|
||||
|
||||
def helix(
|
||||
radius: float,
|
||||
pitch: float,
|
||||
turns: float,
|
||||
ccw=True,
|
||||
segments: int = 4,
|
||||
) -> Path:
|
||||
"""
|
||||
Returns a `helix <https://en.wikipedia.org/wiki/Helix>`_ as
|
||||
a :class:`Path` object.
|
||||
The center of the helix is always (0, 0, 0), a positive `pitch` value
|
||||
creates a helix along the +z-axis, a negative value along the -z-axis.
|
||||
|
||||
Args:
|
||||
radius: helix radius
|
||||
pitch: the height of one complete helix turn
|
||||
turns: count of turns
|
||||
ccw: creates a counter-clockwise turning (right-handed) helix if ``True``
|
||||
segments: cubic Bezier segments per turn
|
||||
|
||||
"""
|
||||
|
||||
# Source of algorithm: https://www.arc.id.au/HelixDrawing.html
|
||||
def bezier_ctrl_points(b, angle, segments):
|
||||
zz = 0.0
|
||||
z_step = angle / segments * p
|
||||
z_step_2 = z_step * 0.5
|
||||
for _, v1, v2, v3 in cubic_bezier_arc_parameters(0, angle, segments):
|
||||
yield (
|
||||
Vec3(v1.x * rx, v1.y * ry, zz + z_step_2 - b),
|
||||
Vec3(v2.x * rx, v2.y * ry, zz + z_step_2 + b),
|
||||
Vec3(v3.x * rx, v3.y * ry, zz + z_step),
|
||||
)
|
||||
zz += z_step
|
||||
|
||||
def param_b(alpha: float) -> float:
|
||||
cos_a = math.cos(alpha)
|
||||
b_1 = (1.0 - cos_a) * (3.0 - cos_a) * alpha * p
|
||||
b_2 = math.sin(alpha) * (4.0 - cos_a) * math.tan(alpha)
|
||||
return b_1 / b_2
|
||||
|
||||
rx = radius
|
||||
ry = radius
|
||||
if not ccw:
|
||||
ry = -ry
|
||||
path = Path(start=(radius, 0, 0))
|
||||
|
||||
p = pitch / math.tau
|
||||
b = param_b(math.pi / segments)
|
||||
full_turns = int(math.floor(turns))
|
||||
if full_turns > 0:
|
||||
curve_params = list(bezier_ctrl_points(b, math.tau, segments))
|
||||
for _ in range(full_turns):
|
||||
z = Vec3(0, 0, path.end.z)
|
||||
for v1, v2, v3 in curve_params:
|
||||
path.curve4_to(z + v3, z + v1, z + v2)
|
||||
|
||||
reminder = turns - full_turns
|
||||
if reminder > 1e-6:
|
||||
segments = math.ceil(reminder * 4)
|
||||
b = param_b(reminder * math.pi / segments)
|
||||
z = Vec3(0, 0, path.end.z)
|
||||
for v1, v2, v3 in bezier_ctrl_points(b, math.tau * reminder, segments):
|
||||
path.curve4_to(z + v3, z + v1, z + v2)
|
||||
|
||||
return path
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user