Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/disassemble.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

641 lines
21 KiB
Python

# Copyright (c) 2021-2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterable, Optional, cast, TYPE_CHECKING
import abc
import math
from ezdxf.entities import DXFEntity, Insert, get_font_name
from ezdxf.lldxf import const
from ezdxf.enums import TextEntityAlignment
from ezdxf.math import Vec3, UCS, Z_AXIS, X_AXIS, BoundingBox
from ezdxf.path import Path, make_path, from_vertices, precise_bbox
from ezdxf.render import MeshBuilder, MeshVertexMerger, TraceBuilder
from ezdxf.protocols import SupportsVirtualEntities, virtual_entities
from ezdxf.tools.text import (
TextLine,
unified_alignment,
plain_text,
text_wrap,
estimate_mtext_content_extents,
)
from ezdxf.fonts import fonts
if TYPE_CHECKING:
from ezdxf.entities import LWPolyline, Polyline, MText, Text
__all__ = [
"make_primitive",
"recursive_decompose",
"to_primitives",
"to_vertices",
"to_control_vertices",
"to_paths",
"to_meshes",
]
class Primitive(abc.ABC):
"""It is not efficient to create the Path() or MeshBuilder() representation
by default. For some entities it's just not needed (LINE, POINT) and for
others the builtin flattening() method is more efficient or accurate than
using a Path() proxy object. (ARC, CIRCLE, ELLIPSE, SPLINE).
The `max_flattening_distance` defines the max distance between the
approximation line and the original curve. Use argument
`max_flattening_distance` to override the default value, or set the value
by direct attribute access.
"""
max_flattening_distance: float = 0.01
def __init__(
self, entity: DXFEntity, max_flattening_distance: Optional[float] = None
):
self.entity: DXFEntity = entity
# Path representation for linear entities:
self._path: Optional[Path] = None
# MeshBuilder representation for mesh based entities:
# PolygonMesh, PolyFaceMesh, Mesh
self._mesh: Optional[MeshBuilder] = None
if max_flattening_distance is not None:
self.max_flattening_distance = max_flattening_distance
@property
def is_empty(self) -> bool:
"""Returns `True` if represents an empty primitive which do not
yield any vertices.
"""
if self._mesh:
return len(self._mesh.vertices) == 0
return self.path is None # on demand calculations!
@property
def path(self) -> Optional[Path]:
""":class:`~ezdxf.path.Path` representation or ``None``,
idiom to check if is a path representation (could be empty)::
if primitive.path is not None:
process(primitive.path)
"""
return None
@property
def mesh(self) -> Optional[MeshBuilder]:
""":class:`~ezdxf.render.mesh.MeshBuilder` representation or ``None``,
idiom to check if is a mesh representation (could be empty)::
if primitive.mesh is not None:
process(primitive.mesh)
"""
return None
@abc.abstractmethod
def vertices(self) -> Iterable[Vec3]:
"""Yields all vertices of the path/mesh representation as
:class:`~ezdxf.math.Vec3` objects.
"""
pass
def bbox(self, fast=False) -> BoundingBox:
"""Returns the :class:`~ezdxf.math.BoundingBox` of the path/mesh
representation. Returns the precise bounding box for the path
representation if `fast` is ``False``, otherwise the bounding box for
Bézier curves is based on their control points.
"""
if self.mesh:
return BoundingBox(self.vertices())
path = self.path
if path:
if fast:
return BoundingBox(path.control_vertices())
return precise_bbox(path)
return BoundingBox()
class EmptyPrimitive(Primitive):
@property
def is_empty(self) -> bool:
return True
def vertices(self) -> Iterable[Vec3]:
return []
class ConvertedPrimitive(Primitive):
"""Base class for all DXF entities which store the path/mesh representation
at instantiation.
"""
def __init__(self, entity: DXFEntity):
super().__init__(entity)
self._convert_entity()
@abc.abstractmethod
def _convert_entity(self):
"""This method creates the path/mesh representation."""
pass
@property
def path(self) -> Optional[Path]:
return self._path
@property
def mesh(self) -> Optional[MeshBuilder]:
return self._mesh
def vertices(self) -> Iterable[Vec3]:
if self.path:
yield from self._path.flattening(self.max_flattening_distance) # type: ignore
elif self.mesh:
yield from self._mesh.vertices # type: ignore
class CurvePrimitive(Primitive):
@property
def path(self) -> Optional[Path]:
"""Create path representation on demand."""
if self._path is None:
self._path = make_path(self.entity)
return self._path
def vertices(self) -> Iterable[Vec3]:
# Not faster but more precise, because cubic bezier curves do not
# perfectly represent elliptic arcs (CIRCLE, ARC, ELLIPSE).
# SPLINE: cubic bezier curves do not perfectly represent splines with
# degree != 3.
yield from self.entity.flattening(self.max_flattening_distance) # type: ignore
class LinePrimitive(Primitive):
# TODO: apply thickness if not 0
@property
def path(self) -> Optional[Path]:
"""Create path representation on demand."""
if self._path is None:
self._path = make_path(self.entity)
return self._path
def vertices(self) -> Iterable[Vec3]:
e = self.entity
yield e.dxf.start
yield e.dxf.end
def bbox(self, fast=False) -> BoundingBox:
e = self.entity
return BoundingBox((e.dxf.start, e.dxf.end))
class LwPolylinePrimitive(ConvertedPrimitive):
# TODO: apply thickness if not 0
def _convert_entity(self) -> None:
e: LWPolyline = cast("LWPolyline", self.entity)
if e.has_width: # use a mesh representation:
# TraceBuilder operates in OCS!
ocs = e.ocs()
elevation = e.dxf.elevation
tb = TraceBuilder.from_polyline(e)
mb = MeshVertexMerger() # merges coincident vertices
for face in tb.faces_wcs(ocs, elevation):
mb.add_face(face)
self._mesh = MeshBuilder.from_builder(mb)
else: # use a path representation to support bulges!
self._path = make_path(e)
class PointPrimitive(Primitive):
@property
def path(self) -> Optional[Path]:
"""Create path representation on demand.
:class:`Path` can not represent a point, a :class:`Path` with only a
start point yields not vertices!
"""
if self._path is None:
self._path = Path(self.entity.dxf.location)
return self._path
def vertices(self) -> Iterable[Vec3]:
yield self.entity.dxf.location
def bbox(self, fast=False) -> BoundingBox:
return BoundingBox((self.entity.dxf.location,))
class MeshPrimitive(ConvertedPrimitive):
def _convert_entity(self):
self._mesh = MeshBuilder.from_mesh(self.entity)
class QuadrilateralPrimitive(ConvertedPrimitive):
# TODO: apply thickness if not 0
def _convert_entity(self):
self._path = make_path(self.entity)
class PolylinePrimitive(ConvertedPrimitive):
# TODO: apply thickness if not 0
def _convert_entity(self) -> None:
e: Polyline = cast("Polyline", self.entity)
if e.is_2d_polyline and e.has_width:
# TraceBuilder operates in OCS!
ocs = e.ocs()
elevation = e.dxf.elevation.z
tb = TraceBuilder.from_polyline(e)
mb = MeshVertexMerger() # merges coincident vertices
for face in tb.faces_wcs(ocs, elevation):
mb.add_face(face)
self._mesh = MeshBuilder.from_builder(mb)
elif e.is_2d_polyline or e.is_3d_polyline:
self._path = make_path(e)
else:
m = MeshVertexMerger.from_polyface(e) # type: ignore
self._mesh = MeshBuilder.from_builder(m)
class HatchPrimitive(ConvertedPrimitive):
def _convert_entity(self):
self._path = make_path(self.entity)
DESCENDER_FACTOR = 0.333 # from TXT SHX font - just guessing
X_HEIGHT_FACTOR = 0.666 # from TXT SHX font - just guessing
class TextLinePrimitive(ConvertedPrimitive):
def _convert_entity(self) -> None:
"""Calculates the rough border path for a single line text.
Calculation is based on a monospaced font and therefore the border
path is just an educated guess.
Vertical text generation and oblique angle is ignored.
"""
def text_rotation():
if fit_or_aligned and not p1.isclose(p2):
return (p2 - p1).angle
else:
return math.radians(text.dxf.rotation)
def location():
if fit_or_aligned:
return p1.lerp(p2, factor=0.5)
return p1
text = cast("Text", self.entity)
if text.dxftype() == "ATTDEF":
# ATTDEF outside of a BLOCK renders the tag rather than the value
content = text.dxf.tag
else:
content = text.dxf.text
content = plain_text(content)
if len(content) == 0:
# empty path - does not render any vertices!
self._path = Path()
return
font = fonts.make_font(
get_font_name(text), text.dxf.height, text.dxf.width
)
text_line = TextLine(content, font)
alignment, p1, p2 = text.get_placement()
if p2 is None:
p2 = p1
fit_or_aligned = (
alignment == TextEntityAlignment.FIT
or alignment == TextEntityAlignment.ALIGNED
)
if text.dxf.halign > 2: # ALIGNED=3, MIDDLE=4, FIT=5
text_line.stretch(alignment, p1, p2)
halign, valign = unified_alignment(text)
mirror_x = -1 if text.is_backward else 1
mirror_y = -1 if text.is_upside_down else 1
oblique: float = math.radians(text.dxf.oblique)
corner_vertices = text_line.corner_vertices(
location(),
halign,
valign,
angle=text_rotation(),
scale=(mirror_x, mirror_y),
oblique=oblique,
)
ocs = text.ocs()
self._path = from_vertices(
ocs.points_to_wcs(corner_vertices),
close=True,
)
class MTextPrimitive(ConvertedPrimitive):
def _convert_entity(self) -> None:
"""Calculates the rough border path for a MTEXT entity.
Calculation is based on a mono-spaced font and therefore the border
path is just an educated guess.
Most special features of MTEXT is not supported.
"""
def get_content() -> list[str]:
text = mtext.plain_text(split=False)
return text_wrap(text, box_width, font.text_width) # type: ignore
def get_max_str() -> str:
return max(content, key=lambda s: len(s))
def get_rect_width() -> float:
if box_width:
return box_width
s = get_max_str()
if len(s) == 0:
s = " "
return font.text_width(s)
def get_rect_height() -> float:
line_height = font.measurements.total_height
cap_height = font.measurements.cap_height
# Line spacing factor: Percentage of default (3-on-5) line
# spacing to be applied.
# thx to mbway: multiple of cap_height between the baseline of the
# previous line and the baseline of the next line
# 3-on-5 line spacing = 5/3 = 1.67
line_spacing = cap_height * mtext.dxf.line_spacing_factor * 1.67
spacing = line_spacing - line_height
line_count = len(content)
return line_height * line_count + spacing * (line_count - 1)
def get_ucs() -> UCS:
"""Create local coordinate system:
origin = insertion point
z-axis = extrusion vector
x-axis = text_direction or text rotation, text rotation requires
extrusion vector == (0, 0, 1) or treatment like an OCS?
"""
origin = mtext.dxf.insert
z_axis = mtext.dxf.extrusion # default is Z_AXIS
x_axis = X_AXIS
if mtext.dxf.hasattr("text_direction"):
x_axis = mtext.dxf.text_direction
elif mtext.dxf.hasattr("rotation"):
# TODO: what if extrusion vector is not (0, 0, 1)
x_axis = Vec3.from_deg_angle(mtext.dxf.rotation)
z_axis = Z_AXIS
return UCS(origin=origin, ux=x_axis, uz=z_axis)
def get_shift_factors():
halign, valign = unified_alignment(mtext)
shift_x = 0
shift_y = 0
if halign == const.CENTER:
shift_x = -0.5
elif halign == const.RIGHT:
shift_x = -1.0
if valign == const.MIDDLE:
shift_y = 0.5
elif valign == const.BOTTOM:
shift_y = 1.0
return shift_x, shift_y
def get_corner_vertices() -> Iterable[Vec3]:
"""Create corner vertices in the local working plan, where
the insertion point is the origin.
"""
if columns:
rect_width = columns.total_width
rect_height = columns.total_height
if rect_width == 0.0 or rect_height == 0.0:
# Reliable sources like AutoCAD and BricsCAD do write
# correct total_width and total_height values!
w, h = _estimate_column_extents(mtext)
if rect_width == 0.0:
rect_width = w
if rect_height == 0.0:
rect_height = h
else:
rect_width = mtext.dxf.get("rect_width", get_rect_width())
rect_height = mtext.dxf.get("rect_height", get_rect_height())
# TOP LEFT alignment:
vertices = [
Vec3(0, 0),
Vec3(rect_width, 0),
Vec3(rect_width, -rect_height),
Vec3(0, -rect_height),
]
sx, sy = get_shift_factors()
shift = Vec3(sx * rect_width, sy * rect_height)
return (v + shift for v in vertices)
mtext: MText = cast("MText", self.entity)
columns = mtext.columns
if columns is None:
box_width = mtext.dxf.get("width", 0)
font = fonts.make_font(
get_font_name(mtext), mtext.dxf.char_height, 1.0
)
content: list[str] = get_content()
if len(content) == 0:
# empty path - does not render any vertices!
self._path = Path()
return
ucs = get_ucs()
corner_vertices = get_corner_vertices()
self._path = from_vertices(
ucs.points_to_wcs(corner_vertices),
close=True,
)
def _estimate_column_extents(mtext: MText):
columns = mtext.columns
assert columns is not None
_content = mtext.text
if columns.count > 1:
_columns_content = _content.split("\\N")
if len(_columns_content) > 1:
_content = max(_columns_content, key=lambda t: len(t))
return estimate_mtext_content_extents(
content=_content,
font=fonts.MonospaceFont(mtext.dxf.char_height, 1.0),
column_width=columns.width,
line_spacing_factor=mtext.dxf.get_default("line_spacing_factor"),
)
class ImagePrimitive(ConvertedPrimitive):
def _convert_entity(self):
self._path = make_path(self.entity)
class ViewportPrimitive(ConvertedPrimitive):
def _convert_entity(self):
vp = self.entity
if vp.dxf.status == 0: # Viewport is off
return # empty primitive
self._path = make_path(vp)
# SHAPE is not supported, could not create any SHAPE entities in BricsCAD
_PRIMITIVE_CLASSES = {
"3DFACE": QuadrilateralPrimitive,
"ARC": CurvePrimitive,
# TODO: ATTRIB and ATTDEF could contain embedded MTEXT,
# but this is not supported yet!
"ATTRIB": TextLinePrimitive,
"ATTDEF": TextLinePrimitive,
"CIRCLE": CurvePrimitive,
"ELLIPSE": CurvePrimitive,
"HATCH": HatchPrimitive, # multi-path object
"MPOLYGON": HatchPrimitive, # multi-path object
"HELIX": CurvePrimitive,
"IMAGE": ImagePrimitive,
"LINE": LinePrimitive,
"LWPOLYLINE": LwPolylinePrimitive,
"MESH": MeshPrimitive,
"MTEXT": MTextPrimitive,
"POINT": PointPrimitive,
"POLYLINE": PolylinePrimitive,
"SPLINE": CurvePrimitive,
"SOLID": QuadrilateralPrimitive,
"TEXT": TextLinePrimitive,
"TRACE": QuadrilateralPrimitive,
"VIEWPORT": ViewportPrimitive,
"WIPEOUT": ImagePrimitive,
}
def make_primitive(
entity: DXFEntity, max_flattening_distance=None
) -> Primitive:
"""Factory to create path/mesh primitives. The `max_flattening_distance`
defines the max distance between the approximation line and the original
curve. Use `max_flattening_distance` to override the default value.
Returns an **empty primitive** for unsupported entities. The `empty` state
of a primitive can be checked by the property :attr:`is_empty`.
The :attr:`path` and the :attr:`mesh` attributes of an empty primitive
are ``None`` and the :meth:`vertices` method yields no vertices.
"""
cls = _PRIMITIVE_CLASSES.get(entity.dxftype(), EmptyPrimitive)
primitive = cls(entity)
if max_flattening_distance:
primitive.max_flattening_distance = max_flattening_distance
return primitive
def recursive_decompose(entities: Iterable[DXFEntity]) -> Iterable[DXFEntity]:
"""Recursive decomposition of the given DXF entity collection into a flat
stream of DXF entities. All block references (INSERT) and entities which provide
a :meth:`virtual_entities` method will be disassembled into simple DXF
sub-entities, therefore the returned entity stream does not contain any
INSERT entity.
Point entities will **not** be disassembled into DXF sub-entities,
as defined by the current point style $PDMODE.
These entity types include sub-entities and will be decomposed into
simple DXF entities:
- INSERT
- DIMENSION
- LEADER
- MLEADER
- MLINE
Decomposition of XREF, UNDERLAY and ACAD_TABLE entities is not supported.
This function does not apply the clipping path created by the XCLIP command.
The function returns all entities and ignores the clipping path polygon and no
entity is clipped.
"""
for entity in entities:
if isinstance(entity, Insert):
# TODO: emit internal XCLIP marker entity?
if entity.mcount > 1:
yield from recursive_decompose(entity.multi_insert())
else:
yield from entity.attribs
yield from recursive_decompose(virtual_entities(entity))
# has a required __virtual_entities__() to be rendered?
elif isinstance(entity, SupportsVirtualEntities):
# could contain block references:
yield from recursive_decompose(virtual_entities(entity))
else:
yield entity
def to_primitives(
entities: Iterable[DXFEntity],
max_flattening_distance: Optional[float] = None,
) -> Iterable[Primitive]:
"""Yields all DXF entities as path or mesh primitives. Yields
unsupported entities as empty primitives, see :func:`make_primitive`.
Args:
entities: iterable of DXF entities
max_flattening_distance: override the default value
"""
for e in entities:
yield make_primitive(e, max_flattening_distance)
def to_vertices(primitives: Iterable[Primitive]) -> Iterable[Vec3]:
"""Yields all vertices from the given `primitives`. Paths will be flattened
to create the associated vertices. See also :func:`to_control_vertices` to
collect only the control vertices from the paths without flattening.
"""
for p in primitives:
yield from p.vertices()
def to_paths(primitives: Iterable[Primitive]) -> Iterable[Path]:
"""Yields all :class:`~ezdxf.path.Path` objects from the given
`primitives`. Ignores primitives without a defined path.
"""
for prim in primitives:
if prim.path is not None: # lazy evaluation!
yield prim.path
def to_meshes(primitives: Iterable[Primitive]) -> Iterable[MeshBuilder]:
"""Yields all :class:`~ezdxf.render.MeshBuilder` objects from the given
`primitives`. Ignores primitives without a defined mesh.
"""
for prim in primitives:
if prim.mesh is not None:
yield prim.mesh
def to_control_vertices(primitives: Iterable[Primitive]) -> Iterable[Vec3]:
"""Yields all path control vertices and all mesh vertices from the given
`primitives`. Like :func:`to_vertices`, but without flattening.
"""
for prim in primitives:
# POINT has only a start point and yields from vertices()!
if prim.path:
yield from prim.path.control_vertices()
else:
yield from prim.vertices()