refactor: excel parse

This commit is contained in:
Blizzard
2026-04-16 10:01:11 +08:00
parent 680ecc320f
commit f62f95ec02
7941 changed files with 2899112 additions and 0 deletions
@@ -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