# Copyright (c) 2023, Manfred Moitzi # License: MIT License from __future__ import annotations from typing import Sequence, NamedTuple, Any, Iterator from typing_extensions import Self import abc import copy import enum import math from .deps import ( Vec2, Path, colors, Matrix44, BoundingBox2d, ) from .properties import Properties, Pen from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d # Page coordinates are always plot units: # 1 plot unit (plu) = 0.025mm # 40 plu = 1mm # 1016 plu = 1 inch # 3.39 plu = 1 dot @300 dpi # positive x-axis is horizontal from left to right # positive y-axis is vertical from bottom to top class Backend(abc.ABC): """Abstract base class for implementing a low level output backends.""" @abc.abstractmethod def draw_polyline(self, properties: Properties, points: Sequence[Vec2]) -> None: """Draws a polyline from a sequence `points`. The input coordinates are page coordinates in plot units. The `points` sequence can contain 0 or more points! Args: properties: display :class:`Properties` for the polyline points: sequence of :class:`ezdxf.math.Vec2` instances """ ... @abc.abstractmethod def draw_paths( self, properties: Properties, paths: Sequence[Path], filled: bool ) -> None: """Draws filled or outline paths from the sequence of `paths`. The input coordinates are page coordinates in plot units. The `paths` sequence can contain 0 or more single :class:`~ezdxf.path.Path` instances. Draws outline paths if Properties.FillType is NONE and filled paths otherwise. Args: properties: display :class:`Properties` for the filled polygon paths: sequence of single :class:`ezdxf.path.Path` instances filled: draw filled paths if ``True`` otherwise outline paths """ ... class RecordType(enum.Enum): POLYLINE = enum.auto() FILLED_PATHS = enum.auto() OUTLINE_PATHS = enum.auto() class DataRecord(NamedTuple): type: RecordType property_hash: int data: Any class Recorder(Backend): """The :class:`Recorder` class records the output of the :class:`Plotter` class. All input coordinates are page coordinates: - 1 plot unit (plu) = 0.025mm - 40 plu = 1 mm - 1016 plu = 1 inch """ def __init__(self) -> None: self._records: list[DataRecord] = [] self._properties: dict[int, Properties] = {} self._pens: Sequence[Pen] = [] def player(self) -> Player: """Returns a :class:`Player` instance with the original recordings. Make a copy of this player to protect the original recordings from being modified:: safe_player = recorder.player().copy() """ return Player(self._records, self._properties) def draw_polyline(self, properties: Properties, points: Sequence[Vec2]) -> None: self.store(RecordType.POLYLINE, properties, NumpyPoints2d(points)) def draw_paths( self, properties: Properties, paths: Sequence[Path], filled: bool ) -> None: data = tuple(map(NumpyPath2d, paths)) record_type = RecordType.FILLED_PATHS if filled else RecordType.OUTLINE_PATHS self.store(record_type, properties, data) def store(self, record_type: RecordType, properties: Properties, args) -> None: prop_hash = properties.hash() if prop_hash not in self._properties: self._properties[prop_hash] = properties.copy() self._records.append(DataRecord(record_type, prop_hash, args)) if len(self._pens) != len(properties.pen_table): self._pens = list(properties.pen_table.values()) class Player: """This class replays the recordings of the :class:`Recorder` class on another backend. The class can modify the recorded output. """ def __init__(self, records: list[DataRecord], properties: dict[int, Properties]): self._records: list[DataRecord] = records self._properties: dict[int, Properties] = properties self._bbox = BoundingBox2d() def __copy__(self) -> Self: """Returns a new :class:`Player` instance with a copy of recordings.""" records = copy.deepcopy(self._records) player = self.__class__(records, self._properties) player._bbox = self._bbox.copy() return player copy = __copy__ def recordings(self) -> Iterator[tuple[RecordType, Properties, Any]]: """Yields all recordings as `(RecordType, Properties, Data)` tuples. The content of the `Data` field is determined by the enum :class:`RecordType`: - :attr:`RecordType.POLYLINE` returns a :class:`NumpyPoints2d` instance - :attr:`RecordType.FILLED_POLYGON` returns a tuple of :class:`NumpyPath2d` instances """ props = self._properties for record in self._records: yield record.type, props[record.property_hash], record.data def bbox(self) -> BoundingBox2d: """Returns the bounding box of all recorded polylines and polygons as :class:`~ezdxf.math.BoundingBox2d`. """ if not self._bbox.has_data: self.update_bbox() return self._bbox def update_bbox(self) -> None: points: list[Vec2] = [] for record in self._records: if record.type == RecordType.POLYLINE: points.extend(record.data.extents()) else: for path in record.data: points.extend(path.extents()) self._bbox = BoundingBox2d(points) def replay(self, backend: Backend) -> None: """Replay the recording on another backend.""" current_props = Properties() props = self._properties for record in self._records: current_props = props.get(record.property_hash, current_props) if record.type == RecordType.POLYLINE: backend.draw_polyline(current_props, record.data.vertices()) else: paths = [p.to_path2d() for p in record.data] backend.draw_paths( current_props, paths, filled=record.type == RecordType.FILLED_PATHS ) def transform(self, m: Matrix44) -> None: """Transforms the recordings by a transformation matrix `m` of type :class:`~ezdxf.math.Matrix44`. """ for record in self._records: if record.type == RecordType.POLYLINE: record.data.transform_inplace(m) else: for path in record.data: path.transform_inplace(m) if self._bbox.has_data: # fast, but maybe inaccurate update self._bbox = BoundingBox2d(m.fast_2d_transform(self._bbox.rect_vertices())) def sort_filled_paths(self) -> None: """Sort filled paths by descending luminance (from light to dark). This also changes the plot order in the way that all filled paths are plotted before polylines and outline paths. """ fillings = [] outlines = [] current = Properties() props = self._properties for record in self._records: if record.type == RecordType.FILLED_PATHS: current = props.get(record.property_hash, current) key = colors.luminance(current.resolve_fill_color()) fillings.append((key, record)) else: outlines.append(record) fillings.sort(key=lambda r: r[0], reverse=True) records = [sort_rec[1] for sort_rec in fillings] records.extend(outlines) self._records = records def placement_matrix( bbox: BoundingBox2d, sx: float = 1.0, sy: float = 1.0, rotation: float = 0.0 ) -> Matrix44: """Returns a matrix to place the bbox in the first quadrant of the coordinate system (+x, +y). """ if abs(sx) < 1e-9: sx = 1.0 if abs(sy) < 1e-9: sy = 1.0 m = Matrix44.scale(sx, sy, 1.0) if rotation: m @= Matrix44.z_rotate(math.radians(rotation)) corners = m.fast_2d_transform(bbox.rect_vertices()) tx, ty = BoundingBox2d(corners).extmin return m @ Matrix44.translate(-tx, -ty, 0)