_images/onCodeberg.png

Source Code#

./src/axonometry/__init__.py#
 1# SPDX-FileCopyrightText: 2022-2025 Julien Rippinger
 2#
 3# SPDX-License-Identifier: GPL-3.0-or-later
 4
 5"""A toolbox to script and generate axonometric drawing operations.
 6
 7To enable a maximum amount of thinkering, the following API documentation
 8covers all public objects of the codebase. For scripting,
 9the :py:class:`Axonometry`, :py:class:`Point` and :py:class:`Line`
10classes and their corresponding methods are sufficient.
11"""
12
13from __future__ import annotations
14
15from .axonometry import Axonometry
16from .config import config_manager
17from .drawing import Drawing, mesh_from_obj_file
18from .line import Line
19from .plane import Plane, ReferencePlane
20from .point import Point
21from .surface import Surface
22from .trihedron import Trihedron, is_valid_angle_pair
23
24__all__ = [
25    "Axonometry",
26    "Drawing",
27    "Line",
28    "Plane",
29    "Point",
30    "ReferencePlane",
31    "Surface",
32    "Trihedron",
33    "config_manager",
34    "is_valid_angle_pair",
35    "mesh_from_obj_file",
36]
./src/axonometry/drawing.py#
  1# SPDX-FileCopyrightText: 2022-2025 Julien Rippinger
  2#
  3# SPDX-License-Identifier: GPL-3.0-or-later
  4
  5from __future__ import annotations
  6
  7import logging
  8from typing import TYPE_CHECKING, Literal
  9
 10from compas.datastructures import Mesh
 11from compas.files import OBJ
 12from compas.geometry import Geometry as CGeometry
 13from compas.geometry import Line as CLine
 14from compas.geometry import Point as CPoint
 15from compas.geometry import Polyline as CPolyline
 16from compas.geometry import Scale
 17from shapely import LineString
 18from vpype import Document, LineCollection, circle, read_svg
 19
 20from .config import config_manager
 21
 22if TYPE_CHECKING:
 23    import compas
 24    from PIL.Image import Image
 25
 26    from .axonometry import Axonometry
 27    from .line import Line
 28    from .point import Point
 29
 30
 31logging.basicConfig(level=logging.INFO)
 32logger = logging.getLogger(__name__)
 33
 34
 35class Drawing:
 36    """Records all drawing and projection operations to be rendered.
 37
 38    A wrapper class for :py:class:`vpype.Document` and :py:class:`compas.scene.Scene`.
 39    Instantiated in :py:attr:`Axonometry.drawing`.
 40    Methods mostly called with the ``add_*()`` methods at the :py:class:`Plane` level.
 41
 42    .. attention::
 43
 44        On user-level, geometries are added to :py:class:`Plane` objects, ex. with
 45        :py:meth:`Plane.draw_line`. Because the necessary updates of the plane and geometry
 46        metadata (i.e. :py:attr:`Plane.objects`, :py:attr:`Line.projections`, etc.) is handled
 47        by these upstream functions.
 48    """
 49
 50    def __init__(
 51        self,
 52        page_size: Literal["A1"] | tuple[float, float] = "A1",
 53        orientation: str = "portrait",
 54    ) -> None:
 55        self.dimensions = (
 56            (
 57                config_manager.config["sizes"][page_size][orientation][0]
 58                * config_manager.config["css_pixel"],
 59                config_manager.config["sizes"][page_size][orientation][1]
 60                * config_manager.config["css_pixel"],
 61            )
 62            if isinstance(page_size, str)
 63            else page_size
 64        )
 65        self.document: Document = Document(
 66            page_size=self.dimensions,
 67        )
 68        self.frames = None
 69
 70    def __repr__(self) -> str:
 71        """Identify drawing."""
 72        return "Drawing"  # + hex(id(self)) ?
 73
 74    # ==========================================================================
 75    # Methods
 76    # ==========================================================================
 77
 78    def record_frames(self):
 79        from vpype_viewer import render_image
 80
 81        self.frames: list[Image] = [render_image(self.document, size=(1920, 1080))]
 82
 83    def add(self, item: Point | Line, layer_id: int | None = None) -> None:
 84        """Add a :py:class:`Point` or :py:class:`Line` to embedded :py:class:`vpype.Document`.
 85
 86        Pass :py:attr:`Point.data` and :py:attr:`Line.data` to
 87        :py:meth:`add_compas_geometry`.
 88
 89        :param layer_id: Define the layer number for the added geometry, usually
 90          handled upstream.
 91        """
 92        compas_data = [item.data]  # it's the compas data which is being drawn
 93        logger.debug(f"[{item.key.upper()}] {item} added to {self}.")
 94        self.add_compas_geometry(compas_data, layer_id=layer_id)
 95
 96    def add_axonometry(
 97        self,
 98        axonometry: Axonometry,
 99        position: tuple[float, float] | None = None,
100    ) -> None:
101        """Combine several instances of :py:class:`Axonometry`.
102
103        .. caution::
104
105            Not fully implemented yet.
106
107        >>> from axonometry import Axonometry, Drawing
108        >>> drawing = Drawing()
109        >>> axo1 = Axonometry(47.5, 15)
110        >>> axo2 = Axonometry(30, 30)
111        >>> drawing.add_axonometry(axo1)
112        >>> drawing.add_axonometry(axo2)
113
114        """
115        if position:
116            axonometry.drawing.document.translate()  # TODO compute translate from new position
117        self.document.extend(axonometry.drawing.document)
118
119    def add_compas_geometry(
120        self,
121        compas_data: list[
122            compas.geometry.Line | compas.geometry.Point | compas.geometry.Polyline
123        ],
124        layer_id: int | None = None,
125    ) -> None:
126        """Add a compas :py:class:`~compas.geometry.Point` or :py:class:`~compas.geometry.Line` collection to embedded :py:class:`vpype.Document`.
127
128        Converts a list of :py:class:`compas.geometry.Point` and
129        :py:class:`compas.geometry.Line` into a :py:class:`vpype.LineCollection`.
130        Then added to the :py:class:`vpype.Document`.
131
132        :param compas_data: Add :py:class:`compas.geometry.Point` and
133          :py:class:`compas.geometry.Line` objects directly, or receive the attributes
134          :py:attr:`Point.data` and :py:attr:`Line.data` when calling :py:meth:`Drawing.add`.
135        :param layer_id: Define the layer number for the added geometry, usually
136          handled upstream.
137        """
138        # no traces ?
139        logger.debug(f"[{self}] Add compas data objects to drawing: {compas_data}")
140        # for item in compas_data:
141        #     self.viewer.scene.add(item)
142        geometry = convert_compas_to_vpype_lines(compas_data)
143        if geometry:
144            geometry.translate(self.dimensions[0] / 2, self.dimensions[1] / 2)
145            self.document.add(geometry, layer_id=layer_id)
146            if self.frames:
147                from vpype_viewer import render_image
148
149                self.frames.append(render_image(self.document, size=(1920, 1080)))
150
151
152# ==========================================================================
153# Utilities
154# ==========================================================================
155
156
157def convert_compas_to_vpype_lines(
158    compas_geometries: list[CGeometry],
159) -> LineCollection:
160    """Convert a list of compas geometries to a vpype :py:class:`vpype.LineCollection`."""
161    vpype_lines = LineCollection()
162    for compas_geometry in compas_geometries:
163        shapely_line = _convert_compas_to_shapely(compas_geometry)
164        vpype_lines.append(shapely_line)
165    return vpype_lines
166
167
168def _convert_compas_to_shapely(compas_geometry: CGeometry) -> LineString:
169    """Convert a compas geometry object to a shapely LineString."""
170    if isinstance(compas_geometry, CLine):
171        return LineString(
172            [
173                (
174                    compas_geometry.start.x * config_manager.config["css_pixel"],
175                    compas_geometry.start.y * config_manager.config["css_pixel"],
176                ),
177                (
178                    compas_geometry.end.x * config_manager.config["css_pixel"],
179                    compas_geometry.end.y * config_manager.config["css_pixel"],
180                ),
181            ],
182        )
183    if isinstance(compas_geometry, CPolyline):
184        return LineString(
185            [
186                (
187                    point.x * config_manager.config["css_pixel"],
188                    point.y * config_manager.config["css_pixel"],
189                )
190                for point in compas_geometry
191            ],
192        )
193    if isinstance(compas_geometry, CPoint):
194        if config_manager.config["point_radius"]:
195            return circle(
196                compas_geometry.x * config_manager.config["css_pixel"],
197                compas_geometry.y * config_manager.config["css_pixel"],
198                config_manager.config["point_radius"],
199            )
200        return None
201    raise ValueError(f"Unsupported Compas geometry type: {compas_geometry}")
202
203
204def _convert_svg_vpype_doc(svg_file: str) -> Document:
205    """Create a vpype Document from a list of Compas geometries."""
206    coll = read_svg(svg_file, 0.01)[0].as_mls()
207    points = []
208    for line in coll.geoms:
209        for coord in line.coords:
210            points.append(coord)
211
212    compas_geometries = [CPolyline(points)]
213    layers = convert_compas_to_vpype_lines(compas_geometries)
214    document = Document()
215    for layer in layers:
216        document.add(layer, layer_id=1)  # Assuming all lines are on the same layer
217    return document
218
219
220def mesh_from_obj_file(
221    filepath: str,
222    scale_factor: float = 50.0,
223) -> Mesh:
224    """Import a 3D model as OBJ file.
225
226    The returned mesh can be manipulated with the COMPAS tools before beeing
227    added to an axonometry with the function :py:func:`~.import_mesh`.
228
229    :param filepath: File location path of the model saved as OBJ file.
230    :param scale_factor: Change size of imported object.
231    :returns: COMPAS Mesh object
232    """
233    obj = OBJ(filepath)
234    obj.read()
235    # mesh = Mesh.from_vertices_and_faces(obj.vertices, obj.faces)
236    mesh = Mesh.from_obj(filepath)
237    mesh.transform(Scale.from_factors(3 * [scale_factor]))
238
239    return mesh
./src/axonometry/line.py#
  1# SPDX-FileCopyrightText: 2022-2025 Julien Rippinger
  2#
  3# SPDX-License-Identifier: GPL-3.0-or-later
  4
  5from __future__ import annotations
  6
  7import logging
  8import random
  9from typing import TYPE_CHECKING, Literal
 10
 11from compas.geometry import Line as CLine
 12from compas.geometry import Point as CPoint
 13from compas.geometry import intersection_line_line_xy
 14
 15from .config import config_manager
 16from .point import Point, is_coplanar, pair_projections_points
 17
 18if TYPE_CHECKING:
 19    import compas
 20
 21    from .axonometry import Axonometry
 22    from .plane import ReferencePlane
 23
 24logging.basicConfig(level=logging.INFO)
 25logger = logging.getLogger(__name__)
 26
 27
 28class Line:
 29    """The primary drawing element.
 30
 31    A line connects two :class:`.Point` objects.
 32
 33    :param start: The first point.
 34    :param end: The second point.
 35    :param data: Optionally set the data on creation, usually handled by
 36      :py:meth:`~Plane.draw_line`.
 37    :param plane: Optionally set the plane membership on creation, usually set by parent.
 38    """
 39
 40    @property
 41    def __data__(self) -> dict:
 42        return {"start": self.start.data.__data__, "end": self.end.data.__data__}
 43
 44    def __init__(
 45        self,
 46        start: Point,
 47        end: Point,
 48        data: compas.geometry.Line | None = None,
 49        plane: ReferencePlane | Axonometry | None = None,
 50    ) -> None:
 51        assert is_coplanar([start, end]), "Points are not in the same plane."
 52        # assert start.key != "xyz" and end.key != "xyz" and (start != end), (
 53        #     "Points are equal, that's not a line."
 54        # )
 55        self.plane: ReferencePlane | Axonometry = plane
 56        """The :py:class:`Plane` of which the line is in; set by parent."""
 57        self.projections: dict[Literal["xy", "yz", "zx", "xyz"], Line | list[Line]] = {
 58            "xy": None,
 59            "yz": None,
 60            "zx": None,
 61            "xyz": [],
 62        }  #: Collection of the lines' projections; updated automatically.
 63        self.start: Point = start  #: The first point of the line.
 64        self.end: Point = end  #: The second point of the line.
 65        self.key: Literal["xy", "yz", "zx", "xyz"] = start.key
 66        """The lines' coordinate system, i.e. plane membership."""
 67        self.data: compas.geometry.Line | None = data  #: View plane coordinate system line.
 68
 69    def __repr__(self) -> str:
 70        """Made of points."""
 71        return f"Line({self.start}, {self.end})"
 72
 73    def __iter__(self):
 74        """Necessary for compas.geometry functions."""
 75        return iter([self.start.data, self.end.data])
 76
 77    def __eq__(self, other: Line) -> bool:
 78        """Lines are equal when their points are equal."""
 79        if (not isinstance(other, type(self))) or (self is None or other is None):
 80            # if the other item of comparison is not also of the Point class
 81            return False
 82        return (self.start == other.start and self.end == other.end) or (
 83            self.start == other.end and self.end == other.start
 84        )
 85
 86    # ==========================================================================
 87    # Constructors
 88    # ==========================================================================
 89
 90    @staticmethod
 91    def from_xy(start: tuple[float], end: tuple[float]) -> Line:
 92        """Create a new Line instance using the given start and end coordinates, interpreted as 2D (x-y) coordinates.
 93
 94        :param start: A tuple containing two float values representing the starting point of the line.
 95        :param end: A tuple containing two float values representing the ending point of the line.
 96
 97        :returns: A new Line instance with the specified start and end points.
 98        """
 99        return Line(Point.from_xy(*start), Point.from_xy(*end))
100
101    @staticmethod
102    def from_yz(start: tuple[float], end: tuple[float]) -> Line:
103        """Create a new Line instance using the given start and end coordinates, interpreted as 2D (x-y) coordinates.
104
105        :param start: A tuple containing two float values representing the starting point of the line.
106        :param end: A tuple containing two float values representing the ending point of the line.
107
108        :returns: A new Line instance with the specified start and end points.
109        """
110        return Line(Point.from_yz(*start), Point.from_yz(*end))
111
112    @staticmethod
113    def from_zx(start: tuple[float], end: tuple[float]) -> Line:
114        """Create a new Line instance using the given start and end coordinates, interpreted as 2D (x-y) coordinates.
115
116        :param start: A tuple containing two float values representing the starting point of the line.
117        :param end: A tuple containing two float values representing the ending point of the line.
118
119        :returns: A new Line instance with the specified start and end points.
120        """
121        return Line(Point.from_zx(*start), Point.from_zx(*end))
122
123    @staticmethod
124    def from_xyz(start: tuple[float], end: tuple[float]) -> Line:
125        """Create a new Line instance using the given start and end coordinates, interpreted as 3D (x-y-z) coordinates.
126
127        :param start: A tuple containing three float values representing the starting point of the line.
128        :param end: A tuple containing three float values representing the ending point of the line.
129
130        :returns: A new Line instance with the specified start and end points.
131        """
132        return Line(Point.from_xyz(*start), Point.from_xyz(*end))
133
134    @staticmethod
135    def random_line(key: Literal["xy", "yz", "zx", "xyz"] = "xyz") -> Line:
136        """Make a random line object, perpendicular to one of the coordinate planes."""
137        if key == "xy":
138            start = Point.random_point("xy")
139            end = random.choice(  # noqa: S311
140                [
141                    Point.from_xy(start.x + random.randint(20, 70), start.y),  # noqa: S311
142                    Point.from_xy(start.x, start.y + random.randint(20, 70)),  # noqa: S311
143                ],
144            )
145        elif key == "yz":
146            start = Point.random_point("yz")
147            end = random.choice(  # noqa: S311
148                [
149                    Point.from_yz(start.y, start.z + random.randint(20, 70)),  # noqa: S311
150                    Point.from_yz(start.y + random.randint(20, 70), start.z),  # noqa: S311
151                ],
152            )
153        elif key == "zx":
154            start = Point.random_point("zx")
155            end = random.choice(  # noqa: S311
156                [
157                    Point.from_zx(start.z + random.randint(20, 70), start.x),  # noqa: S311
158                    Point.from_zx(start.z, start.x + random.randint(20, 70)),  # noqa: S311
159                ],
160            )
161        elif key == "xyz":
162            start = Point.random_point()
163            end = random.choice(  # noqa: S311
164                [
165                    Point.from_xyz(start.x, start.y, start.z + random.randint(20, 70)),  # noqa: S311
166                    Point.from_xyz(start.x + random.randint(20, 70), start.y, start.z),  # noqa: S311
167                    Point.from_xyz(start.x, start.y + random.randint(20, 70), start.z),  # noqa: S311
168                ],
169            )
170        return Line(start, end)
171
172    # ==========================================================================
173    # Methods
174    # ==========================================================================
175
176    def intersections_with_line(self, line: Line) -> CPoint:
177        """Compute the intersection with another line, assuming they lie on the same plane.
178
179        :param line: The other line.
180        """
181        point_data = CPoint(*intersection_line_line_xy(self.data, line))
182        raise point_data
183
184    # ==========================================================================
185    # Projection
186    # ==========================================================================
187
188    def project(
189        self,
190        distance: float | None = None,
191        start_distance: float | None = None,
192        end_distance: float | None = None,
193        ref_plane_key: Literal["xy", "yz", "zx"] | None = None,
194    ) -> Line:
195        """Project crurrent line on another plane.
196
197        The projection can be in both directions between one of the three
198        :term:`reference planes <Reference plane>` and the
199        :term:`axonometric picture plane <Axonometric picture plane>`.
200
201        >>> from axonometry import Axonometry, Point, Line
202        >>> my_axo = Axonometry(10,15)
203
204        >>> xy_line = my_axo["xy"].draw_line(Line(Point(x=-18, y=6), Point(x=26, y=12)))
205        >>> xy_line.project(distance=75)
206        Line(Point(x=-18, y=6, z=75), Point(x=26, y=12, z=75))  # z added by projection
207
208        >>> xyz_line = my_axo.draw_line(Line(Point(x=6, y=9, z=44), Point(x=45, y=-5, z=9)))
209        >>> xyz_line.project(ref_plane_key="yz")
210        Line(Point(y=9, z=44), Point(y=-5, z=9))
211
212        :param distance: The missing third coordinate in order to project the line on the
213          axonometric picture plane. This applies when the point to project is contained
214          in a reference plane.
215        :param start_distance: Specify a distinct ``distance`` value for the
216          :py:attr:`Line.start` projection.
217        :param end_distance: Specify a distinct ``distance`` value for the
218          :py:attr:`Line.end` projection.
219        :param ref_plane_key: Specify the reference plane for the auxilary projection.
220        :return: A new line.
221        """
222        # determine projection origin plane
223        assert self.plane, "To project, the line needs to be part of a plane."
224        if self.plane.key == "xyz":
225            new_line = self._project_on_reference_plane(ref_plane_key)
226        else:
227            new_line = self._project_on_axonometry_plane(
228                distance,
229                start_distance=start_distance,
230                end_distance=end_distance,
231                ref_plane_key=ref_plane_key,
232            )
233
234        return new_line
235
236    def _project_on_axonometry_plane(
237        self,
238        distance: float,
239        start_distance: float | None = None,
240        end_distance: float | None = None,
241        ref_plane_key: Literal["xy", "yz", "zx"] | None = None,
242    ) -> Line:
243        """Project line on the axonometric picture plane.
244
245        :param distance: The third coordinate value relative to the current reference plane.
246        :param start_distance: Fine-tuning specific distance for beginning of line.
247        :param end_distance: Fine-tuning specific distance for end of line.
248        """
249        # TODO: check if line already exists.
250
251        start_distance = start_distance if start_distance else distance
252        end_distance = end_distance if end_distance else distance
253
254        if self.plane.key == "xy":
255            new_line = Line(
256                Point(x=self.start.x, y=self.start.y, z=start_distance),
257                Point(x=self.end.x, y=self.end.y, z=end_distance),
258            )  # data will be updated later
259
260            if ref_plane_key is None:
261                # Make sure not to use perpendicular plane as auxilary plane
262                if self.start.x == self.end.x:
263                    ref_plane_key = "yz"
264                elif self.start.y == self.end.y:
265                    ref_plane_key = "zx"
266                else:
267                    ref_plane_key = random.choice(["yz", "zx"])  # noqa: S311
268            else:
269                assert ref_plane_key in ["yz", "zx"], f"Wrong {ref_plane_key=}"
270
271            # Build auxilary line in chosen reference plane
272            if ref_plane_key == "yz":
273                auxilary_line = self.plane.axo[ref_plane_key].draw_line(
274                    Line(
275                        Point(y=self.start.y, z=start_distance),
276                        Point(y=self.end.y, z=end_distance),
277                    ),
278                )
279            elif ref_plane_key == "zx":
280                auxilary_line = self.plane.axo[ref_plane_key].draw_line(
281                    Line(
282                        Point(x=self.start.x, z=start_distance),
283                        Point(x=self.end.x, z=end_distance),
284                    ),
285                )
286
287        elif self.plane.key == "yz":
288            new_line = Line(
289                Point(x=start_distance, y=self.start.y, z=self.start.z),
290                Point(x=end_distance, y=self.end.y, z=self.end.z),
291            )  # data will be update
292            if ref_plane_key is None:
293                # Make sure not to use perpendicular plane as auxilary plane
294                if self.start.z == self.end.z:
295                    ref_plane_key = "xy"
296                elif self.start.y == self.end.y:
297                    ref_plane_key = "zx"
298                else:
299                    # Default to XY because dominant in architecture conventions
300                    ref_plane_key = "xy"  # random.choice(["zx", "xy"])
301            else:
302                assert ref_plane_key in ["xy", "zx"], f"Wrong {ref_plane_key=}"
303
304            # Build auxilary line in chosen reference plane
305            if ref_plane_key == "zx":
306                auxilary_line = self.plane.axo[ref_plane_key].draw_line(
307                    Line(
308                        Point(z=self.start.z, x=start_distance),
309                        Point(z=self.end.z, x=end_distance),
310                    ),
311                )
312            elif ref_plane_key == "xy":
313                auxilary_line = self.plane.axo[ref_plane_key].draw_line(
314                    Line(
315                        Point(y=self.start.y, x=start_distance),
316                        Point(y=self.end.y, x=end_distance),
317                    ),
318                )
319
320        elif self.plane.key == "zx":
321            new_line = Line(
322                Point(x=self.start.x, y=start_distance, z=self.start.z),
323                Point(x=self.end.x, y=end_distance, z=self.end.z),
324            )  # data will be update
325            if ref_plane_key is None:
326                # Make sure not to use perpendicular plane as auxilary plane
327                if self.start.z == self.end.z:
328                    ref_plane_key = "xy"
329                elif self.start.x == self.end.x:
330                    ref_plane_key = "yz"
331                else:
332                    # Default to XY because dominant in architecture conventions
333                    ref_plane_key = "xy"  # random.choice(["xy", "yz"])
334            else:
335                assert ref_plane_key in ["xy", "yz"], f"Wrong {ref_plane_key=}"
336
337            # Build auxilary line in chosen reference plane
338            if ref_plane_key == "xy":
339                auxilary_line = self.plane.axo[ref_plane_key].draw_line(
340                    Line(
341                        Point(x=self.start.x, y=start_distance),
342                        Point(x=self.end.x, y=end_distance),
343                    ),
344                )
345            elif ref_plane_key == "yz":
346                auxilary_line = self.plane.axo[ref_plane_key].draw_line(
347                    Line(
348                        Point(z=self.start.z, y=start_distance),
349                        Point(z=self.end.z, y=end_distance),
350                    ),
351                )
352
353        axo_line_start_data = intersection_line_line_xy(
354            CLine.from_point_and_vector(self.start.data, self.plane.projection_vector),
355            CLine.from_point_and_vector(
356                auxilary_line.start.data,
357                self.plane.axo[ref_plane_key].projection_vector,
358            ),
359        )
360
361        axo_line_end_data = intersection_line_line_xy(
362            CLine.from_point_and_vector(self.end.data, self.plane.projection_vector),
363            CLine.from_point_and_vector(
364                auxilary_line.end.data,
365                self.plane.axo[ref_plane_key].projection_vector,
366            ),
367        )
368
369        new_line.data = CLine(CPoint(*axo_line_start_data), CPoint(*axo_line_end_data))
370        new_line.start.data = new_line.data[0]
371        new_line.end.data = new_line.data[1]
372
373        self.plane.drawing.add_compas_geometry(
374            [
375                CLine(self.start.data, axo_line_start_data),
376                CLine(auxilary_line.start.data, axo_line_start_data),
377                CLine(self.end.data, axo_line_end_data),
378                CLine(auxilary_line.end.data, axo_line_end_data),
379            ],
380            layer_id=config_manager.config["layers"]["projection_traces"]["id"],
381        )
382
383        self.plane.axo.draw_line(new_line)
384        pair_projections_lines(new_line, auxilary_line)
385        pair_projections_lines(new_line, self)
386
387        return new_line
388
389    def _project_on_reference_plane(
390        self,
391        ref_plane_key: Literal["xy", "yz", "zx"],
392    ) -> Line:
393        """Project the line on the selected reference plane.
394
395        Knowing that the current line is member of the axo plane.
396        """
397        if self == self.projections[ref_plane_key]:
398            # projection of line already exists, nothing to do
399            logger.debug("Line already exists.")
400            new_line = self.projections[ref_plane_key]
401        else:
402            if ref_plane_key == "xy":
403                new_line = self.plane[ref_plane_key].draw_line(
404                    Line(
405                        Point(x=self.start.x, y=self.start.y),
406                        Point(x=self.end.x, y=self.end.y),
407                    ),
408                )
409            elif ref_plane_key == "yz":
410                new_line = self.plane[ref_plane_key].draw_line(
411                    Line(
412                        Point(y=self.start.y, z=self.start.z),
413                        Point(y=self.end.y, z=self.end.z),
414                    ),
415                )
416            elif ref_plane_key == "zx":
417                new_line = self.plane[ref_plane_key].draw_line(
418                    Line(
419                        Point(x=self.start.x, z=self.start.z),
420                        Point(x=self.end.x, z=self.end.z),
421                    ),
422                )
423
424            # draw new projection line
425            self.plane.drawing.add_compas_geometry(
426                [
427                    CLine(self.start.data, new_line.start.data),
428                    CLine(self.end.data, new_line.end.data),
429                ],
430                layer_id=config_manager.config["layers"]["projection_traces"]["id"],
431            )
432
433            pair_projections_lines(self, new_line)
434
435        return new_line
436
437    def project_into_surface(self, distance: float, length: float) -> None:
438        """Projection of a surface in axonometric picture plane out of its perpendicular lines."""
439        logger.debug(
440            f"[{self.key.upper()}] Line on plane data: {self.start.data=};{self.end.data=}",
441        )
442        assert self.key != "xyz", (
443            "Only possible to project from reference plane into axonometric picture plane."
444        )
445
446        def _get_projection_planes(line):
447            keys = [key for key, val in line.projections.items() if isinstance(val, Line)]
448            return keys
449
450        new_line_1 = self.start.project_into_line(distance=distance, length=length)
451        aux_planes = _get_projection_planes(new_line_1)
452        new_line_2 = self.end.project_into_line(
453            distance=distance,
454            length=length,
455            ref_plane_keys=aux_planes,
456        )
457        self.plane.axo.draw_line(Line(new_line_1.start, new_line_2.start))
458        self.plane.axo.draw_line(Line(new_line_1.end, new_line_2.end))
459
460    def on_projection_planes(self) -> list[str] | None:
461        """Get the plane keys in which line has a projection."""
462        return [key for key in self.projections if self.projections[key] is not None]
463
464    def not_on_projection_planes(self) -> list[str] | None:
465        """Get the plane keys in which line has no projection."""
466        return [key for key in self.projections if self.projections[key] is None]
467
468
469# ==========================================================================
470# Utilities
471# ==========================================================================
472
473
474def pair_projections_lines(obj1: Line, obj2: Line) -> None:
475    """Include each other in the projections collection."""
476    if obj2.key == "xyz":
477        obj1.projections["xyz"].append(obj2)
478    else:
479        obj1.projections[obj2.key] = obj2
480    if obj1.key == "xyz":
481        obj2.projections["xyz"].append(obj1)
482    else:
483        obj2.projections[obj1.key] = obj1
484
485    pair_projections_points(obj1.start, obj2.start)
486    pair_projections_points(obj1.end, obj2.end)
./src/axonometry/plane.py#
  1# SPDX-FileCopyrightText: 2022-2025 Julien Rippinger
  2#
  3# SPDX-License-Identifier: GPL-3.0-or-later
  4
  5from __future__ import annotations
  6
  7import logging
  8from typing import TYPE_CHECKING, Literal
  9
 10from compas.geometry import Line as CLine
 11from compas.geometry import Point as CPoint
 12from compas.geometry import Polyline as CPolyline
 13from compas.geometry import intersection_line_line_xy
 14from vpype import read_svg
 15
 16from .config import config_manager
 17from .line import Line, pair_projections_lines
 18from .point import Point, pair_projections_points
 19
 20if TYPE_CHECKING:
 21    import compas
 22
 23    from .axonometry import Axonometry
 24    from .drawing import Drawing
 25
 26
 27logging.basicConfig(level=logging.INFO)
 28logger = logging.getLogger(__name__)
 29
 30
 31class Plane:
 32    """Base class for Axonometry and ReferencePlane.
 33
 34    Mangaing the adding of :py:class:`Point` and :py:class:`Line` to the various planes.
 35    """
 36
 37    drawing: Drawing | None = None  #: Class attribute set by :py:class:`.Axonometry`.
 38
 39    def __init__(self) -> None:
 40        self.key: Literal["xy", "yz", "zx", "xyz"] | None = None
 41        self._points: list[Point] = []
 42        self._lines: list[Line] = []
 43
 44    # ==========================================================================
 45    # Properties
 46    # ==========================================================================
 47
 48    @property
 49    def points(self) -> list[Point]:
 50        """All points contained in the current plane."""
 51        return self._points
 52
 53    @points.setter
 54    def points(self, value: list[Point] | Point) -> None:
 55        """Store points which got added to the current plane.
 56
 57        .. note::
 58
 59            Points with same values can coexist in the same plane.
 60
 61        """
 62        if isinstance(value, list):
 63            if not all(isinstance(item, Point) for item in value):
 64                raise ValueError("All elements must be instances of Point")
 65            self._points.extend(value)
 66        elif isinstance(value, Point):
 67            self._points.append(value)
 68        else:
 69            raise TypeError("Points must be a list of Points or a single Point")
 70
 71    @property
 72    def lines(self) -> list[Line]:
 73        """All lines contained in the current plane."""
 74        return self._lines
 75
 76    @lines.setter
 77    def lines(self, value: list[Line] | Line) -> None:
 78        """Store lines which got added to the current plane."""
 79        if isinstance(value, list):
 80            if not all(isinstance(item, Line) for item in value):
 81                raise ValueError("All elements must be instances of Line")
 82            self._lines.extend(value)
 83        elif isinstance(value, Line):
 84            self._lines.append(value)
 85        else:
 86            raise TypeError("Lines must be a list of Lines or a single Line")
 87
 88        # Add the line points to the plane attributes as well
 89        if value.start not in self._points:
 90            self._points.append(value.start)
 91        if value.end not in self._points:
 92            self._points.append(value.end)
 93
 94    @property
 95    def objects(self) -> dict[Literal["points", "lines"], list[Point | Line]]:
 96        """Collection of points and lines in current plane."""
 97        return {"points": self._points, "lines": self._lines}
 98
 99    # ==========================================================================
100    # Methods
101    # ==========================================================================
102
103    def draw_point(
104        self,
105        point: Point,
106        ref_plane_keys: list[Literal["xy", "yz", "zx"]] = ["xy", "yz", "zx"],
107    ) -> Point:
108        """Add a :py:class:`Point` to the current plane.
109
110        .. note::
111
112            The :py:class:`Line` object operations are basically performed on its points, this method is
113            extensively called in :py:meth:`draw_line` and :py:meth:`~Line.project`, with :py:attr:`Line.start` and
114            :py:attr:`Line.end` as parameters.
115        """
116        assert point.key == self.key, (
117            f"Point coordinates must follow containing plane coordinates. Plane:{self.key} & Point:{point.key}"
118        )
119        logger.debug(f"[{self.key.upper()}] Add {point}")
120        # TODO: normally by avoiding the XYZ plane case, the following lines should work ?
121        # if self.key != "xyz" and point in self.objects["points"]:  # point already exists in plane
122        #     index = self.objects["points"].index(point)
123        #     point = self.objects["points"][index]
124        # else:  # make a new point
125        if self.key == "xyz":
126            # Point data could not exist
127            logger.debug(
128                f"[{self.key.upper()}] Adding {point} by auxilary projection.",
129            )
130            # Point data must be computed with the reference plane projection intersections
131            if point.data is None:
132                point.data = self._decompose_xyz_point(point, ref_plane_keys)
133
134        else:
135            if point.matrix_applied:
136                # Reuse the original when repeating operation.
137                point.reset_data()
138            point.data = point.data.transformed(self.matrix)
139            point.matrix_applied = True
140
141        logger.debug(f"[{self.key.upper()}] Add {point}")
142        self.points = point
143        Plane.drawing.add(point)
144        point.plane = self  # add self as parent
145        logger.debug(f"[{self.key.upper()}] Current objects in {self}: {self.objects}.")
146        return point
147
148    def draw_line(
149        self,
150        line: Line,
151        layer_nr: int | None = None,
152        ref_plane_keys: list[Literal["xy", "yz", "zx"]] = ["xy", "yz", "zx"],
153    ) -> Line:
154        """Add a :py:class:`Line` to the current plane.
155
156        By inheritence, two main cases occur, each handled by a set of operations:
157
158        - Adding a line into the axonometric picture plane.
159        - Adding the line into one of the three reference planes.
160
161        :param line: The new line to be added.
162        :param ref_plane_keys: The reference planes on which to construct the auxiliary projections when adding a line on the axonometric picture plane, defaults to all three i.e. ["xy", "yz", "zx"].
163        :return: The newly added line. If an identical line already exists in the planes' objects, the existing line is returned instead of a new object.
164
165        """
166        if line in self.objects["lines"]:  # TODO: check line layer as well
167            # line already exists in plane
168            logger.debug(
169                f"[{self.key.upper()}] Adding {line} by using existing line from {self.objects['lines']}",
170            )
171            index = self.objects["lines"].index(line)
172            line = self.objects["lines"][index]
173
174        else:
175            logger.info(f"[{self.key.upper()}] Add {line}")
176            line.plane = self
177
178            if self.key == "xyz":  # stop recursivity with "and line.data is not None" ?
179                self._draw_line_on_xyz_plane(line, ref_plane_keys)
180                self._add_projected_lines_in_ref_plane(line, layer_nr=layer_nr)
181
182            elif self.key in ["xy", "yz", "zx"]:
183                self._draw_line_on_ref_plane(line)
184                self._add_projected_line_in_axo_plane(line)
185                # TODO: add line to xyz plane without recursivity error.
186
187            self.lines = line
188            if not layer_nr:
189                layer_nr = config_manager.config["layers"]["geometry"]["id"]
190            Plane.drawing.add(line, layer_id=layer_nr)
191
192        return line
193
194    def _draw_line_on_xyz_plane(
195        self,
196        line: Line,
197        ref_plane_keys: list[Literal["xy", "yz", "zx", "xyz"]] | None = None,
198    ) -> None:
199        """Compute line data when added to XYZ plane.
200
201        Add the start and end point to the XYZ plane and use their data to update the
202        current line data.
203
204        First add the lines' start and end point to the XYZ space. Get the data from these
205        points in order to update the line data. Finally add lines where the two XYZ made
206        auxilary projections.
207        """
208        # Randomize reference plane projections at this level in order
209        # to make start and end points project in the same planes.
210        if ref_plane_keys is None:
211            # Make sure not to use perpendicular plane as auxilary plane
212            if line.start.x == line.end.x and line.start.y == line.end.y:
213                ref_plane_keys = ["zx", "yz"]
214            elif line.start.y == line.end.y and line.start.z == line.end.z:
215                ref_plane_keys = ["xy", "zx"]
216            elif line.start.z == line.end.z and line.start.x == line.end.x:
217                ref_plane_keys = ["xy", "yz"]
218            # For all other scenarios
219            else:
220                # Favour XY plane because of architecture customs
221                ref_plane_keys = random_axo_ref_plane_keys(privilege_xy_plane=True)
222
223        line.start = self.draw_point(
224            line.start,
225            ref_plane_keys=ref_plane_keys,
226        )
227        line.end = self.draw_point(
228            line.end,
229            ref_plane_keys=ref_plane_keys,
230        )
231        # Update the line data with the projection
232        line.data = CLine(line.start.data, line.end.data)
233
234    def _draw_line_on_ref_plane(self, line: Line) -> None:
235        """Compute the line data when added to a reference plane."""
236        # Compute the start and end points when added to the reference plane.
237        line.start = self.draw_point(line.start)
238        line.end = self.draw_point(line.end)
239        # Get and update the line data from the new points.
240        line.data = CLine(line.start.data, line.end.data)
241
242    def _add_projected_lines_in_ref_plane(
243        self,
244        line: Line,
245        layer_nr: int | None = None,
246    ) -> None:
247        """Check in which plane two points have both a projection and draw a line if so."""
248        for ref_plane_key in self._common_projections(
249            line.start.projections,
250            line.end.projections,
251        ):
252            auxilary_line = Line(
253                line.start.projections[ref_plane_key],
254                line.end.projections[ref_plane_key],
255                data=CLine(
256                    line.start.projections[ref_plane_key].data,
257                    line.end.projections[ref_plane_key].data,
258                ),
259                plane=self[ref_plane_key],
260            )
261
262            if not layer_nr:
263                layer_nr = config_manager.config["layers"]["geometry"]["id"]
264
265            Plane.drawing.add(
266                auxilary_line,
267                layer_id=layer_nr,
268            )
269            self[ref_plane_key].lines = auxilary_line
270            pair_projections_lines(line, auxilary_line)
271
272    def _add_projected_line_in_axo_plane(self, line: Line) -> None:
273        """Add line in axo projection if points (projection) already exists there.
274
275        Check if line start and end points have a axo projection.
276        Draw a line if projections exists, add it to axo plane, pair lines.
277        Check remaining planes for start end projections of new line.
278        """
279        if (
280            len(line.start.projections["xyz"]) >= 1 and len(line.end.projections["xyz"]) >= 1
281        ):  # TODO: get all points
282            logger.debug(
283                "Line points have axo projections and line ?",
284                line.start.projections["xyz"][0].data,
285                line.end.projections["xyz"][0].data,
286            )
287
288            # Make new line
289            new_axo_line = Line(
290                line.start.projections["xyz"][0],
291                line.end.projections["xyz"][0],
292                data=CLine(
293                    line.start.projections["xyz"][0].data,
294                    line.end.projections["xyz"][0].data,
295                ),
296                plane=self.axo,
297            )
298            Plane.drawing.add(
299                new_axo_line,
300                layer_id=config_manager.config["layers"]["geometry"]["id"],
301            )
302            self.axo.lines = new_axo_line
303            pair_projections_lines(line, new_axo_line)
304
305            # Propagate axo line projections
306            for key in self._common_projections(
307                new_axo_line.start.projections,
308                new_axo_line.end.projections,
309                exclude=[self.key, "xyz"],
310            ):
311                logger.debug(
312                    f"Line points have other ref plane projections ? {new_axo_line.start.projections[key]=}; {new_axo_line.end.projections[key]=}",
313                )
314                new_ref_plane_line = Line(
315                    new_axo_line.start.projections[key],
316                    new_axo_line.end.projections[key],
317                    data=CLine(
318                        new_axo_line.start.projections[key].data,
319                        new_axo_line.end.projections[key].data,
320                    ),
321                    plane=self.axo[key],
322                )
323                Plane.drawing.add(
324                    new_ref_plane_line,
325                    layer_id=config_manager.config["layers"]["geometry"]["id"],
326                )
327                self.axo[key].lines = new_ref_plane_line
328                pair_projections_lines(new_axo_line, new_ref_plane_line)
329
330    def _common_projections(
331        self,
332        dict1,
333        dict2,
334        exclude: list[Literal["xy", "yz", "zx", "xyz"]] = ["xyz"],
335    ):
336        """Find which projected points are on the same reference plane."""
337        for key in dict1:
338            if key in exclude:  # Exclude this specific key from comparison
339                continue
340            if key in dict2 and dict1[key] is not None and dict2[key] is not None:
341                yield key
342
343    def _decompose_xyz_point(
344        self,
345        axo_point: Point,
346        ref_plane_keys: list[Literal["xy", "yz", "zx"]],
347    ) -> CPoint:
348        """Directly added point in XYZ space becomes the intersection of two projected points.
349
350        Basically adding points in two reference planes and intersecting them
351        in the xyz space. The two planes can be provided as a parameter.
352
353        :param: The axonometric point to be found by intersection of two projections.
354        :param ref_plane_keys: The two reference, default to XY and random YZ or ZX.
355        :return: Intersection coordinates from projected points.
356        """
357        logger.debug(f"Decompose {axo_point=}")
358
359        if (
360            ref_plane_keys
361            and len(ref_plane_keys) < 2
362            and (
363                set(ref_plane_keys) != set(("xy", "yz"))
364                or set(ref_plane_keys) != set(("xy", "zx"))
365                or set(ref_plane_keys) != set(("yz", "zx"))
366            )
367        ):
368            raise ValueError(f"{ref_plane_keys} are invalid. A minimum of two.")
369
370        # No keys provided. Defaults to all three keys
371        if set(ref_plane_keys) == set(("xy", "yz", "zx")):
372            p1 = Point(x=axo_point.x, y=axo_point.y)
373            p2 = Point(y=axo_point.y, z=axo_point.z)
374            p3 = Point(z=axo_point.z, x=axo_point.x)
375            plane1 = self.xy
376            plane2 = self.yz
377            plane3 = self.zx
378
379        else:
380            p3 = None  # only two projections
381            if "xy" in ref_plane_keys and "yz" in ref_plane_keys:
382                p1 = Point(x=axo_point.x, y=axo_point.y)
383                p2 = Point(y=axo_point.y, z=axo_point.z)
384                plane1 = self.xy
385                plane2 = self.yz
386
387            if "zx" in ref_plane_keys and "yz" in ref_plane_keys:
388                p1 = Point(y=axo_point.y, z=axo_point.z)
389                p2 = Point(z=axo_point.z, x=axo_point.x)
390                plane1 = self.yz
391                plane2 = self.zx
392
393            if "xy" in ref_plane_keys and "zx" in ref_plane_keys:
394                p1 = Point(z=axo_point.z, x=axo_point.x)
395                p2 = Point(x=axo_point.x, y=axo_point.y)
396                plane1 = self.zx
397                plane2 = self.xy
398
399        logger.debug(f"Two auxilary points computed {p1=}, {p2=}")
400
401        # Draw the points
402        plane1.draw_point(p1)
403        plane2.draw_point(p2)
404        pair_projections_points(axo_point, p1)
405        pair_projections_points(axo_point, p2)
406
407        # add them in respective ReferencePlanes
408        axo_point_data = intersection_line_line_xy(
409            CLine.from_point_and_vector(p1.data, plane1.projection_vector),
410            CLine.from_point_and_vector(p2.data, plane2.projection_vector),
411        )
412        axo_point_data = CPoint(*axo_point_data)
413        logger.debug(f"New {axo_point_data=}")
414        # Add points in reference planes to the
415        # axo point projections collection
416
417        # draw intersection
418        Plane.drawing.add_compas_geometry(
419            [CLine(p1.data, axo_point_data), CLine(p2.data, axo_point_data)],
420            layer_id=config_manager.config["layers"]["projection_traces"]["id"],
421        )
422        if p3:
423            # Repeat drawing operations for third point
424            plane3.draw_point(p3)
425            pair_projections_points(axo_point, p3)
426            Plane.drawing.add_compas_geometry(
427                [CLine(p3.data, axo_point_data)],
428                layer_id=config_manager.config["layers"]["projection_traces"]["id"],
429            )
430        return axo_point_data
431
432
433class ReferencePlane(Plane):
434    """Tilted coordinate plane in which to draw, project into and from.
435
436    :param line_pair: The two lines making up the reference plane axes.
437    :param projection_vector: The projection direction towards the axonometric picture plane;
438      reverse direction than the reference plane translation.
439    """
440
441    axo: Axonometry | None = None  #: Attribute on :py:class:`.Axonometry` construction
442
443    def __init__(
444        self,
445        line_pair: list[compas.geometry.Line],
446        projection_vector: compas.geometry.Vector,
447    ) -> None:
448        super().__init__()  # Call the parent class constructor if necessary
449        self.matrix = None
450        self.axes = line_pair
451        self.projection_vector = projection_vector
452        self.matrix_to_coord_plane = None  # TODO
453
454    def __repr__(self) -> str:
455        """Get axes keys."""
456        return f"Reference Plane {self.key.upper()}"
457
458    # ==========================================================================
459    # Methods
460    # ==========================================================================
461
462    def import_svg_file(self, file: str, scale: float | None = None):
463        """Get an external svg and add it to current reference plane.
464
465        An SVG is treated as a collection of lines.
466        Read the svg. Parse the line coordinates. Add each line to the current plane.
467
468        Import the SVG and convert it to a :py:class:`~shapely.MultiLineString`.
469
470
471        Roughly the code should be as
472        follow::
473
474            for line in collection:
475                self.draw_line(Line(line))  # this will call the matrix
476            doc = self.drawing.convert_svg_vpype_doc(svg_file)
477        """
478
479        def _compute_scale_factor(svg_width: float, svg_height: float) -> float:
480            size = max(svg_width, svg_height)
481            return 300 / size
482
483        # TODO: what curve quantization value ?
484        svg_lines, svg_width, svg_height = read_svg(
485            file,
486            7.5,
487        )
488
489        # Translate figures because axes are flipped later
490        if self.key == "yz":
491            svg_lines.translate(0, -svg_height)
492        if self.key == "zx":
493            svg_lines.translate(-svg_width, -svg_height)
494
495        # Separate pen up paths and lines
496        svg_lines.crop(*svg_lines.bounds())
497        pen_paths = svg_lines.pen_up_trajectories().lines
498        svg_lines = svg_lines.lines
499
500        # Extract coordinate values from numpy arrays
501        svg_points = []
502        for line in svg_lines:
503            for p in line:
504                svg_points.append((p.real, p.imag))
505        pen_path_points = []
506        for line in pen_paths:
507            for p in line:
508                pen_path_points.append((p.real, p.imag))
509
510        if not scale:
511            scale = _compute_scale_factor(svg_width, svg_height)
512
513        pen_paths = (
514            CPolyline(pen_path_points)
515            .scaled(
516                scale,
517            )
518            .lines
519        )
520
521        svg_lines = CPolyline(svg_points).scaled(
522            scale,
523        )
524        for line in svg_lines.lines:
525            if line not in pen_paths:
526                start, end = {  # Axes are flipped to rotate and mirror figures
527                    "xy": (
528                        Point(x=line.start.y, y=line.start.x),
529                        Point(x=line.end.y, y=line.end.x),
530                    ),
531                    "yz": (
532                        Point(z=-line.start.y, y=line.start.x),
533                        Point(z=-line.end.y, y=line.end.x),
534                    ),
535                    "zx": (
536                        Point(x=-line.start.x, z=-line.start.y),
537                        Point(x=-line.end.x, z=-line.end.y),
538                    ),
539                }.get(self.key)
540                # end = Point(x=line.end.x, y=line.end.y)
541                if start == end:
542                    continue
543                self.draw_line(
544                    Line(
545                        start,
546                        end,
547                    ),
548                )
549
550        # Same but without removing path lines:
551        # sp_multiline = read_svg(
552        #     filepath,
553        #     10.0,
554        #     default_width=10.0,
555        #     default_height=10.0,
556        #     simplify=True,
557        #     parallel=True,
558        # )[0].as_mls()
559
560        # svg_points = []
561        # for line in sp_multiline.geoms:
562        #     for coord in line.coords:
563        #         svg_points.append(coord)
564
565        # ply = CPolyline(svg_points).scaled(scale)
566        # for line in ply.lines:
567        #     self.draw_line(
568        #         Line(
569        #             Point(x=line.start.x, y=line.start.y),
570        #             Point(x=line.end.x, y=line.end.y),
571        #         ),
572        #     )
573
574
575# ==========================================================================
576# Utilities
577# ==========================================================================
578
579
580def random_axo_ref_plane_keys(
581    *,
582    force_plane: str | None = None,
583    privilege_xy_plane: bool = True,
584) -> list[Literal["xy", "yz", "zx"]]:
585    """Compute XY and second random key."""
586    from random import choice, sample
587
588    all_planes = ["xy", "yz", "zx"]
589    random_planes = []
590    if force_plane:
591        random_planes.append(force_plane)
592        all_planes.remove(force_plane)
593        random_planes.append(choice(all_planes))  # noqa: S311
594    elif privilege_xy_plane:
595        random_planes = ["xy", choice(["yz", "zx"])]  # noqa: S311
596    else:
597        random_planes = list(sample(all_planes, 2))
598
599    return random_planes
./src/axonometry/point.py#
  1# SPDX-FileCopyrightText: 2022-2025 Julien Rippinger
  2#
  3# SPDX-License-Identifier: GPL-3.0-or-later
  4
  5from __future__ import annotations
  6
  7import logging
  8import random
  9from typing import TYPE_CHECKING, Literal
 10
 11from compas.geometry import Line as CLine
 12from compas.geometry import Point as CPoint
 13from compas.geometry import intersection_line_line_xy
 14
 15from .config import config_manager
 16
 17if TYPE_CHECKING:
 18    import compas
 19
 20    from .axonometry import Axonometry
 21    from .plane import ReferencePlane
 22
 23logging.basicConfig(level=logging.INFO)
 24logger = logging.getLogger(__name__)
 25
 26
 27class Point:
 28    """The atoms of geometry.
 29
 30    The coordinates have to be passed on explicitly::
 31
 32        Point(x=10, y=20)
 33        Point(z=15, y=20)
 34        Point(x=10, z=15)
 35        Point(x=10, y=20, z=15)
 36
 37    :param kwargs: A minimum of two coordanate values.
 38    :raises ValueError: A point is described by a minimum of two coordinates.
 39
 40    """
 41
 42    @property
 43    def __data__(self) -> list:
 44        """Give access to data in order to be used with compas methods.
 45
 46        .. warning::
 47
 48            The data is very different from the user defined values.
 49            They are all compas.geometry objects with only (X,Y)
 50            coordinates, all in the same plane (i.e. your piece of
 51            paper).
 52
 53        """
 54        return list(self.data.__data__)
 55
 56    def __init__(self, **kwargs: float) -> None:
 57        self.plane: ReferencePlane | Axonometry = None
 58        """The :py:class:`Plane` of which the line is in; set by parent."""
 59        self.matrix_applied: bool = False  # Flag set when point added to reference plane
 60        self.projections: dict[Literal["xy", "yz", "zx", "xyz"], Point | list[Point]] = {
 61            "xy": None,
 62            "yz": None,
 63            "zx": None,
 64            "xyz": [],
 65        }  #: Collection of the lines' projections; updated automatically.
 66        self.x = None  #: X value in world coordinate space
 67        self.y = None  #: Y value in world coordinate space
 68        self.z = None  #: Z value in world coordinate space
 69        self._set_coordinates(**kwargs)
 70        self.key: Literal["xy", "yz", "zx", "xyz"] = self._set_key()
 71        """The points' coordinate system, i.e. plane membership."""
 72        # Data is the point location on the paper
 73        self.data: compas.geometry.Point | None = None  #: Position of point in view plane coordinate system.
 74        if self.key in ["xy", "yz", "zx"]:
 75            self.reset_data()
 76
 77    def __repr__(self) -> str:
 78        """Get the user set coordinate values."""
 79        if self.key == "xy":
 80            repr_str = f"Point(x={self.x:.2f}, y={self.y:.2f})"
 81        elif self.key == "yz":
 82            repr_str = f"Point(y={self.y:.2f}, z={self.z:.2f})"
 83        elif self.key == "zx":
 84            repr_str = f"Point(x={self.x:.2f}, z={self.z:.2f})"
 85        else:
 86            repr_str = f"Point(x={self.x:.2f}, y={self.y:.2f}, z={self.z:.2f})"
 87
 88        return repr_str
 89
 90    def __eq__(self, other: Point) -> bool:
 91        """Projected points are considered as equal."""
 92        if (not isinstance(other, type(self))) or (self is None or other is None):
 93            # if the other item of comparison is not also of the Point class
 94            return False
 95        if self.key == other.key:
 96            return (self.x == other.x) and (self.y == other.y) and (self.z == other.z)
 97        common_key = "".join(set(self.key).intersection(other.key))
 98        if set(common_key) == set("xy"):
 99            return (self.x == other.x) and (self.y == other.y)
100        if set(common_key) == set("yz"):
101            return (self.y == other.y) and (self.z == other.z)
102        if set(common_key) == set("zx"):
103            return (self.x == other.x) and (self.z == other.z)
104        return False
105
106    # ==========================================================================
107    # Properties
108    # ==========================================================================
109
110    def _set_coordinates(self, **kwargs: float) -> None:
111        """Set the coordinates from the provided kwargs."""
112        if len(kwargs) == 1:
113            # If only one coordinate is provided, raise an error.
114            raise ValueError("At least two coordinates must be provided.")
115        self.x = kwargs.get("x")  #: X value in world coordinate space
116        self.y = kwargs.get("y")  #: Y value in world coordinate space
117        self.z = kwargs.get("z")  #: Z value in world coordinate space
118
119    def _set_key(self) -> Literal["xy", "yz", "zx", "xyz"]:
120        combined_key = ""
121        if self.x is not None:
122            combined_key += "x"
123        if self.y is not None:
124            combined_key += "y"
125        if self.z is not None:
126            combined_key += "z"
127        # Flip letters in last case
128        return "zx" if combined_key == "xz" else combined_key
129
130    # ==========================================================================
131    # Constructors
132    # ==========================================================================
133
134    @staticmethod
135    def from_xy(x: float, y: float) -> Point:
136        """Create a new Point instance with the given x and y coordinates.
137
138        :param x: The x-coordinate of the point.
139        :param y: The y-coordinate of the point.
140
141        :returns: A new Point instance with the specified x and y coordinates.
142        """
143        return Point(x=x, y=y)
144
145    @staticmethod
146    def from_yz(y: float, z: float) -> Point:
147        """Create a new Point instance with the given y and z coordinates.
148
149        :param y: The y-coordinate of the point.
150        :param z: The z-coordinate of the point.
151
152        :returns: A new Point instance with the specified y and z coordinates.
153        """
154        return Point(y=y, z=z)
155
156    @staticmethod
157    def from_zx(z: float, x: float) -> Point:
158        """Create a new Point instance with the given z and x coordinates.
159
160        :param x: The z-coordinate of the point.
161        :param y: The x-coordinate of the point.
162
163        :returns: A new Point instance with the specified z and x coordinates.
164        """
165        return Point(z=z, x=x)
166
167    @staticmethod
168    def from_xyz(x: float, y: float, z: float) -> Point:
169        """Create a new Point instance with the given x, y, and z coordinates.
170
171        :param x: The x-coordinate of the point.
172        :param y: The y-coordinate of the point.
173        :param z: The z-coordinate of the point.
174
175        :returns: A new Point instance with the specified x, y, and z coordinates.
176        """
177        return Point(x=x, y=y, z=z)
178
179    @staticmethod
180    def random_point(key: Literal["xy", "yz", "zx", "xyz"] = "xyz") -> Point:
181        """Get a random point.
182
183        To follow architecture standards, z is always equal or higher than 0.
184        """
185        return {
186            "xy": Point.from_xy(random.randint(-20, 70), random.randint(-20, 70)),  # noqa: S311
187            "yz": Point.from_yz(random.randint(-20, 70), random.randint(0, 80)),  # noqa: S311
188            "zx": Point.from_zx(random.randint(0, 80), random.randint(-20, 70)),  # noqa: S311
189            "xyz": Point.from_xyz(
190                random.randint(-20, 70),  # noqa: S311
191                random.randint(-20, 70),  # noqa: S311
192                random.randint(0, 80),  # noqa: S311
193            ),
194        }.get(key)
195
196    # ==========================================================================
197    # Methods
198    # ==========================================================================
199
200    def reset_data(self) -> CPoint:
201        """Reset point data.
202
203        Function used to add objects a second time to a reference plane.
204        Data needs a reset because by adding a point to a reference plane, the point.data is
205        transformed with the planes matrix. This process is automated with the
206        help of the :py:attr:`axonometry.Point.matrix_applied` boolean flag attribute.
207        """
208        if self.key == "xy":
209            self.data = CPoint(self.x, self.y)
210        elif self.key == "yz":
211            self.data = CPoint(self.z, self.y)
212        elif self.key == "zx":
213            self.data = CPoint(self.x, self.z)
214        else:
215            self.data: CPoint | None = None
216        return self.data
217
218    # ==========================================================================
219    # Projection
220    # ==========================================================================
221
222    def project(
223        self,
224        distance: float | None = None,
225        ref_plane_key: Literal["xy", "yz", "zx"] | None = None,
226        auxilaray_plane_key: Literal["xy", "yz", "zx"] | None = None,
227    ) -> Point:
228        """Project current point on another plane.
229
230        .. note::
231
232            As modifying a :py:class:`Line` is basically handling its points, this method is
233            extensively called in :py:meth:`draw_line` and :py:meth:`~Line.project`, with the
234            points :py:attr:`Line.start` and :py:attr:`Line.end` as parameters.
235
236        Two scenarios: current point is in a reference plane and is projected onto the
237        axonometric picture plane. Or the current point is in the axonometric picture
238        plane and is beeing projected on a reference plane. Depending, the right paramteres
239        have to be provided.
240
241        :param distance: The missing third coordinate in order to project the point on the
242            axonometric picture plane. This applies when the point to project is contained
243            in a reference plane.
244        :param ref_plane_key: The selected reference plane on which to project the point. This
245            applies when the point to project is on the axonometric picture plane.
246        """
247        # determine projection origin plane
248        if self.plane.key == "xyz":
249            logger.debug(f"{self} is in XYZ and projected on a reference plane")
250            new_point = self._project_on_reference_plane(ref_plane_key)
251        else:
252            logger.debug(
253                f"{self} is in {self.plane} and projected on a reference plane",
254            )
255            new_point = self._project_on_axonometry_plane(distance, auxilaray_plane_key)
256
257        return new_point
258
259    def _project_on_axonometry_plane(
260        self,
261        distance: float,
262        auxilaray_plane_key: Literal["xy", "yz", "zx"] | None = None,
263    ) -> Point:
264        """Projection initiated from a reference plane onto the axonometry plane.
265
266        In order to determine the axonometric point an auxilary point in a second reference
267        plane is created. The projected intersection of the current and the auxilary
268        point are the axonometric point.
269
270        For the auxilary plane choice, the XY plane is privileged when possible
271        because of architecture standards.
272        """
273        assert distance is not None, (
274            "Provide (third coordinate value) in order to project the point into XYZ space."
275        )
276        if self.plane.key == "xy":
277            new_point = Point(x=self.x, y=self.y, z=distance)  # data will be update
278
279            if auxilaray_plane_key and auxilaray_plane_key not in ["yz", "zx"]:
280                # If provided, ensure the auxiliary plane key is valid.
281                raise ValueError(
282                    f"{auxilaray_plane_key} invalid for a projection from {self.plane}",
283                )
284            if not auxilaray_plane_key:
285                # If no auxiliary plane key is provided, randomly choose one from "yz" and "zx"
286                auxilaray_plane_key = random.choice(["yz", "zx"])  # noqa: S311
287
288            if auxilaray_plane_key == "yz":
289                auxilary_point = self.plane.axo[auxilaray_plane_key].draw_point(
290                    Point(y=self.y, z=distance),
291                )
292            elif auxilaray_plane_key == "zx":
293                auxilary_point = self.plane.axo[auxilaray_plane_key].draw_point(
294                    Point(x=self.x, z=distance),
295                )
296
297        elif self.plane.key == "yz":
298            new_point = Point(x=distance, y=self.y, z=self.z)  # data will be update
299
300            if auxilaray_plane_key and auxilaray_plane_key not in ["zx", "xy"]:
301                # If provided, ensure the auxiliary plane key is valid.
302                raise ValueError(
303                    f"{auxilaray_plane_key} invalid for a projection from {self.plane}",
304                )
305            if not auxilaray_plane_key:
306                # If no auxiliary plane key is provided, randomly choose one from "xy" and "zx"
307                auxilaray_plane_key = "xy"  # random.choice(["zx", "xy"])
308
309            if auxilaray_plane_key == "zx":
310                auxilary_point = self.plane.axo[auxilaray_plane_key].draw_point(
311                    Point(z=self.z, x=distance),
312                )
313            elif auxilaray_plane_key == "xy":
314                auxilary_point = self.plane.axo[auxilaray_plane_key].draw_point(
315                    Point(y=self.y, x=distance),
316                )
317
318        elif self.plane.key == "zx":
319            new_point = Point(x=self.x, y=distance, z=self.z)  # data will be update
320
321            if auxilaray_plane_key and auxilaray_plane_key not in ["xy", "yz"]:
322                raise ValueError(
323                    f"{auxilaray_plane_key} invalid for a projection from {self.plane}",
324                )
325            if not auxilaray_plane_key:
326                auxilaray_plane_key = "xy"  # random.choice(["xy", "yz"])
327
328            if auxilaray_plane_key == "xy":
329                auxilary_point = self.plane.axo[auxilaray_plane_key].draw_point(
330                    Point(x=self.x, y=distance),
331                )
332            elif auxilaray_plane_key == "yz":
333                auxilary_point = self.plane.axo[auxilaray_plane_key].draw_point(
334                    Point(z=self.z, y=distance),
335                )
336
337        axo_point_data = intersection_line_line_xy(
338            CLine.from_point_and_vector(self.data, self.plane.projection_vector),
339            CLine.from_point_and_vector(
340                auxilary_point.data,
341                self.plane.axo[auxilaray_plane_key].projection_vector,
342            ),
343        )
344
345        new_point.data = CPoint(*axo_point_data)
346        # draw intersection
347        self.plane.drawing.add_compas_geometry(
348            [
349                CLine(self.data, axo_point_data),
350                CLine(auxilary_point.data, axo_point_data),
351            ],
352            layer_id=config_manager.config["layers"]["projection_traces"]["id"],
353        )
354
355        self.plane.axo.draw_point(new_point)
356
357        # Update point projections collection
358        pair_projections_points(new_point, auxilary_point)
359        pair_projections_points(new_point, self)
360
361        return new_point
362
363    def _project_on_reference_plane(self, ref_plane_key: Literal["xy", "yz", "zx"]) -> Point:
364        if self == self.projections[ref_plane_key]:
365            # projection of point already exists, nothing to do
366            logger.debug(
367                f"{self=} is already projected in {ref_plane_key.upper()}: {self.projections[ref_plane_key]=}",
368            )
369            new_point = self.projections[ref_plane_key]
370
371        else:
372            if ref_plane_key == "xy":
373                # Point was maybe already projected when added to the XYZ axo space
374                new_point = self.plane[ref_plane_key].draw_point(
375                    Point(x=self.x, y=self.y),
376                )
377            elif ref_plane_key == "yz":
378                # Point was maybe already projected when added to the XYZ axo space
379                new_point = self.plane[ref_plane_key].draw_point(
380                    Point(y=self.y, z=self.z),
381                )
382            elif ref_plane_key == "zx":
383                # Point was maybe already projected when added to the XYZ axo space
384                new_point = self.plane[ref_plane_key].draw_point(
385                    Point(x=self.x, z=self.z),
386                )
387
388            # draw new projection line
389            self.plane.drawing.add_compas_geometry(
390                [CLine(self.data, new_point.data)],
391                layer_id=config_manager.config["layers"]["projection_traces"]["id"],
392            )
393            pair_projections_points(self, new_point)
394
395        return new_point
396
397    def project_into_line(
398        self,
399        distance: float,
400        length: float,
401        ref_plane_keys: list[Literal["xy", "yz", "zx"]] | None = None,
402    ):
403        """Projection of a line in axonometric picture plane out of its perpendicular point.
404
405        Instead of building a point, one adds a line in axo space.
406
407        >>> p_xy.project_into_line(distance=80, length=50)
408        INFO:axonometry.plane:[XYZ] Add Line(Point(x=24, y=38, z=105.0), Point(x=24, y=38, z=55.0))
409        >>> p_yz.project_into_line(distance=80, length=50)
410        INFO:axonometry.plane:[XYZ] Add Line(Point(x=105.0, y=29, z=51), Point(x=55.0, y=29, z=51))
411        >>> p_zx.project_into_line(distance=80, length=50)
412        INFO:axonometry.plane:[XYZ] Add Line(Point(x=17, y=105.0, z=26), Point(x=17, y=55.0, z=26))
413
414        :param distance: The position of the middle of the new line.
415        :param length: The length of the new line.
416
417        :return: A new :py:class:`Line` on the axonometric picture plane.
418        """
419        from .line import Line  # local import because of cicularity
420
421        # Make new line points
422        if self.key == "xy":
423            far = Point(x=self.x, y=self.y, z=distance + length / 2)
424            close = Point(x=self.x, y=self.y, z=distance - length / 2)
425        elif self.key == "yz":
426            far = Point(y=self.y, z=self.z, x=distance + length / 2)
427            close = Point(y=self.y, z=self.z, x=distance - length / 2)
428        elif self.key == "zx":
429            far = Point(z=self.z, x=self.x, y=distance + length / 2)
430            close = Point(z=self.z, x=self.x, y=distance - length / 2)
431
432        # Add line to axo plane
433        new_line = Line(close, far)
434        new_line = self.plane.axo.draw_line(new_line, ref_plane_keys=ref_plane_keys)
435        # Get updated point data
436        close = new_line.start
437        far = new_line.end
438        # Draw projection trace
439        self.plane.drawing.add_compas_geometry(
440            [CLine(self.data, close.data)],
441            layer_id=config_manager.config["layers"]["projection_traces"]["id"],
442        )
443        # Pair projections
444        pair_projections_points(self, close)
445        pair_projections_points(self, far)
446        new_line.projections[self.key] = self
447
448        return new_line
449
450    def on_projection_planes(self) -> list[str] | None:
451        """Get the plane keys in which point has a projection."""
452        return [key for key in self.projections if self.projections[key] is not None]
453
454    def not_on_projection_planes(self) -> list[str] | None:
455        """Get the plane keys in which point has no projection."""
456        return [key for key in self.projections if self.projections[key] is None]
457
458
459# ==========================================================================
460# Predicates
461# ==========================================================================
462
463
464def is_coplanar(points: list[Point]) -> bool:
465    """Check if a series of points are in the same plane."""
466    keys = [point.key for point in points]
467    return len(set(keys)) == 1
468
469
470# ==========================================================================
471# Utilities
472# ==========================================================================
473
474
475def pair_projections_points(obj1: Point, obj2: Point) -> None:
476    """Include each other in the projections collection."""
477    if obj2.key == "xyz":
478        obj1.projections["xyz"].append(obj2)
479    else:
480        obj1.projections[obj2.key] = obj2
481    if obj1.key == "xyz":
482        obj2.projections["xyz"].append(obj1)
483    else:
484        obj2.projections[obj1.key] = obj1
./src/axonometry/surface.py#
  1# SPDX-FileCopyrightText: 2022-2025 Julien Rippinger
  2#
  3# SPDX-License-Identifier: GPL-3.0-or-later
  4
  5from __future__ import annotations
  6
  7import logging
  8from typing import TYPE_CHECKING
  9
 10if TYPE_CHECKING:
 11    from .line import Line
 12    from .point import Point
 13
 14logging.basicConfig(level=logging.INFO)
 15logger = logging.getLogger(__name__)
 16
 17
 18class Surface:
 19    """A collection class to apply various graphical operations on a set of lines.
 20
 21    :raise AssertionError: If the geometries are not all in the same plane.
 22    """
 23
 24    def __init__(self, lines: list[Line] | list[Point]) -> None:
 25        self.plane = lines
 26        self.key = lines
 27        self.lines = lines
 28
 29    # ==========================================================================
 30    # Properties
 31    # ==========================================================================
 32
 33    @property
 34    def is_closed(self):
 35        """Number of unique points equal number of lines."""
 36        unique_points = {point for line in self._lines for point in (line.start, line.end)}
 37        return len(unique_points) == len(self._lines)
 38
 39    @property
 40    def plane(self):
 41        return self._plane
 42
 43    @plane.setter
 44    def plane(self, lines: list[Line]) -> None:
 45        """Set the plane attribute for the surface.
 46
 47        :param lines: List of lines.
 48        """
 49        planes = {obj.plane for obj in lines}
 50        assert len(planes) == 1, "Lines not in same plane."
 51        self._plane = (
 52            planes.pop()
 53        )  # Assuming the set contains only one element after the assertion passes
 54
 55    @property
 56    def key(self):
 57        return self._key
 58
 59    @key.setter
 60    def key(self, lines):
 61        keys = {obj.key for obj in lines}
 62        assert len(keys) == 1, "Lines not in same plane."
 63        self._key = keys.pop()
 64
 65    # ==========================================================================
 66    # Constructors
 67    # ==========================================================================
 68
 69    @staticmethod
 70    def closed(geometries: list[Line] | list[Point]) -> Surface:
 71        """Get surface collection of a computed continous line.
 72
 73        Find missing lines from a sparse collection of lines or points. Compute new lines by
 74        checking nearest points of existing lines. Make a surface object from that new line
 75        collection.
 76
 77        :param geometries: A sparse collection of :py:class:`.Point` or :py:class:`.Line`.
 78        """
 79        raise NotImplementedError
 80
 81    @staticmethod
 82    def from_points(points: list[Point]) -> Surface:
 83        """Make surface from convex hull of a list of points.
 84
 85        :raises NotImplementedError: WIP.
 86        """
 87        # from compas.geoemtry import convex_hull_xy
 88        # verts = convex_hull_xy(points)
 89        raise NotImplementedError
 90
 91    # ==========================================================================
 92    # Methods
 93    # ==========================================================================
 94
 95    def bounding_box(self):
 96        """Draw the axis-aligned minimum bounding box of a list of points in the plane.
 97
 98        :raises NotImplementedError: WIP.
 99        """
100        # from compas.geometry import bounding_box_xy
101        # unique_points = {point for line in self._lines for point in (line.start, line.end)}
102        # corners = bounding_box_xy(list(unique_points))
103        raise NotImplementedError
./src/axonometry/trihedron.py#
  1# SPDX-FileCopyrightText: 2022-2025 Julien Rippinger
  2#
  3# SPDX-License-Identifier: GPL-3.0-or-later
  4
  5from __future__ import annotations
  6
  7import logging
  8import math
  9from typing import Literal
 10
 11from compas.geometry import Frame as CFrame
 12from compas.geometry import Line as CLine
 13from compas.geometry import Point as CPoint
 14from compas.geometry import Transformation as CTransformation
 15from compas.geometry import Translation as CTranslation
 16from compas.geometry import Vector as CVector
 17
 18from .plane import ReferencePlane
 19
 20logging.basicConfig(level=logging.INFO)
 21logger = logging.getLogger(__name__)
 22
 23
 24class Trihedron:
 25    """The orthogonal projection of a trihedron.
 26
 27    It is basically a collection of marks defining the axonometric space.
 28    From the trihedron one can extract the tilted main reference planes.
 29    This class is creating the ReferencePlanes and holds geometric operations
 30    called in the parent Axonometry object.
 31
 32    :param angles: Standard axonometric notation; left/right angles from 'horizon' for
 33      x/y coordinate axes.
 34    :param position: Center of the trihedron; (0,0) by default.
 35    :param size: The length of coordinate plane axes; 100 by default.
 36    :param ref_planes_distance: Reference plane translation distance from the trihedron
 37      center; 100 by default.
 38    """
 39
 40    def __init__(
 41        self,
 42        angles: tuple[float, float],
 43        position: tuple[float, float] = (0, 0),
 44        size: float = 100.0,
 45        ref_planes_distance: float = 100.0,
 46    ) -> None:
 47        # Check if input is valid
 48        assert is_valid_angle_pair(angles), "Angle can not be 0. Approximate zero with .1"
 49        # Initialize object
 50        logger.debug(
 51            f"[{self}] Trihedron {position=}, {size=}, {ref_planes_distance=}",
 52        )
 53        self.axo = None  # set by parent
 54        self.axo_angles: tuple[float, float] = (
 55            math.radians(angles[0]),
 56            math.radians(angles[1]),
 57        )
 58        self.position: tuple[float, float] = position
 59        self.size: float = size
 60        self.axes: dict[Literal["x", "y", "z"], CLine] = self._setup_axes()
 61        self.reference_planes = self._setup_reference_planes(ref_planes_distance)
 62
 63    def __repr__(self) -> str:
 64        """Trihedron."""
 65        return "Trihedron"
 66
 67    # ==========================================================================
 68    # Methods
 69    # ==========================================================================
 70
 71    def _setup_axes(self) -> dict[Literal["x", "y", "z"], CLine]:
 72        # parameters
 73        length = self.size
 74        alpha, beta = self.axo_angles
 75        # Calculate angles
 76        p0 = CPoint(*self.position)
 77        # Find second point by rotation and lenght
 78        p_x = self._rotated_point(p0, math.pi - alpha, length)
 79        p_y = self._rotated_point(p0, beta, length)
 80        # Always vertical
 81        p_z = CPoint(p0.x, p0.y - length)
 82        # Make axis lines
 83        axis_x, axis_y, axis_z = (
 84            CLine(p0, p_x),
 85            CLine(p0, p_y),
 86            CLine(p0, p_z),
 87        )
 88
 89        return {"z": axis_z, "x": axis_x, "y": axis_y}
 90
 91    def _setup_reference_planes(
 92        self,
 93        ref_planes_distance: float,
 94    ) -> dict[Literal["xy", "yz", "zx"], ReferencePlane]:
 95        axes_matrix_pairs = self._tilt_coordinate_planes(ref_planes_distance)
 96
 97        ref_planes = {
 98            "xy": ReferencePlane(axes_matrix_pairs[0][0], self.axes["z"].direction),
 99            "yz": ReferencePlane(axes_matrix_pairs[1][0], self.axes["x"].direction),
100            "zx": ReferencePlane(axes_matrix_pairs[2][0], self.axes["y"].direction),
101        }
102
103        ref_planes["xy"].matrix = axes_matrix_pairs[0][1]
104        ref_planes["yz"].matrix = axes_matrix_pairs[1][1]
105        ref_planes["zx"].matrix = axes_matrix_pairs[2][1]
106
107        for key, plane in ref_planes.items():
108            plane.key = key
109
110        return ref_planes
111
112    def _tilt_coordinate_planes(
113        self,
114        ref_planes_distance: float = 0.0,
115    ) -> list[tuple[list[CLine], CTransformation]]:
116        """Tilt the three reference planes (XY, ZY, and ZX).
117
118        The function takes into account the distance to the reference planes and returns their
119        geometric representations along with transformation matrices. N.B.: The code is not
120        optimized for speed or repetitivity to make these operations as explicit as possibly.
121
122        :param ref_planes_distance: The distance from the origin to each of the reference
123          planes, defaults to .0.
124
125        :return: A list containing tuples, where each tuple consists of a list of lines
126          representing the axes of the tilted plane and a transformation matrix that can be
127          applied to add geometric elements to the reference planes.
128        """
129        # Get the angles necessary to make the ReferencePlanes
130        tilted_angle_pairs = self._get_tilted_angles()
131        p0 = CPoint(*self.position)
132
133        # XY
134        angle_pair_xy = tilted_angle_pairs[0]
135        # Translation by given distance and opposite axis
136        TZ = CTranslation.from_vector(  # noqa: N806
137            self.axes["z"].direction.inverted() * ref_planes_distance,
138        )
139        # Make axes lines
140        p1 = self._rotated_point(p0, math.pi / 2 - angle_pair_xy[0], self.size)
141        p2 = self._rotated_point(p0, math.pi / 2 + angle_pair_xy[1], self.size)
142        XOY = [CLine(p0, p1).transformed(TZ), CLine(p0, p2).transformed(TZ)]  # noqa: N806
143        # Compute Matrix
144        vector_x = CVector.from_start_end(p0, p2)
145        vector_y = CVector.from_start_end(p0, p1)
146        F = CFrame(  # noqa: N806
147            point=p0.transformed(TZ),
148            xaxis=vector_x.transformed(TZ),
149            yaxis=vector_y.transformed(TZ),
150        )
151        MZ = CTransformation.from_frame(F)  # noqa: N806
152
153        # ZY
154        angle_pair_zy = tilted_angle_pairs[1]
155        # Translation by given distance and opposite axis
156        TX = CTranslation.from_vector(  # noqa: N806
157            self.axes["x"].direction.inverted() * ref_planes_distance,
158        )
159        # Make axes lines
160        p1 = self._rotated_point(
161            p0,
162            (math.pi * 2 - self.axo_angles[0]) - angle_pair_zy[0],
163            self.size,
164        )
165        p2 = self._rotated_point(
166            p0,
167            (math.pi * 2 - self.axo_angles[0]) + angle_pair_zy[1],
168            self.size,
169        )
170        YOZ = [CLine(p0, p1).transformed(TX), CLine(p0, p2).transformed(TX)]  # noqa: N806
171        # Compute Matrix
172        axis_y = CVector.from_start_end(p0, p1)
173        axis_z = CVector.from_start_end(p0, p2)
174        F = CFrame(  # noqa: N806
175            point=p0.transformed(TX),
176            xaxis=axis_y.transformed(TX),
177            yaxis=axis_z.transformed(TX),
178        )
179        MX = CTransformation.from_frame(F)  # noqa: N806
180
181        # ZX
182        angle_pair_zx = tilted_angle_pairs[2]
183        # Translation by given distance and opposite axis
184        TY = CTranslation.from_vector(  # noqa: N806
185            self.axes["y"].direction.inverted() * ref_planes_distance,
186        )
187        # Make axes lines
188        p1 = self._rotated_point(
189            p0,
190            (math.pi + self.axo_angles[1]) - angle_pair_zx[0],
191            self.size,
192        )
193        p2 = self._rotated_point(
194            p0,
195            (math.pi + self.axo_angles[1]) + angle_pair_zx[1],
196            self.size,
197        )
198        ZOX = [CLine(p0, p1).transformed(TY), CLine(p0, p2).transformed(TY)]  # noqa: N806
199        # Compute Matrix
200        axis_z = CVector.from_start_end(p0, p2)
201        axis_x = CVector.from_start_end(p0, p1)
202        F = CFrame(  # noqa: N806
203            point=p0.transformed(TY),
204            xaxis=axis_x.transformed(TY),
205            yaxis=axis_z.transformed(TY),
206        )
207        # get frame matrix
208        MY = CTransformation.from_frame(F)  # noqa: N806
209
210        return [(XOY, MZ), (YOZ, MX), (ZOX, MY)]
211
212    def _axis_angles(self) -> list[float]:
213        """To be used to translate the reference planes."""
214        zero = CVector.Xaxis()
215        return [math.degrees(axis.direction.angle(zero)) for axis in self.axes.values()]
216
217    def _get_tilted_angles(self) -> list[tuple[float]]:
218        """Order by coordinate plane is XY, ZY, ZX."""
219        a_z, a_x, a_y = (
220            math.pi - (self.axo_angles[0] + self.axo_angles[1]),
221            math.pi / 2 + self.axo_angles[1],
222            math.pi / 2 + self.axo_angles[0],
223        )
224        assert math.isclose(
225            a_z + a_x + a_y,
226            math.pi * 2,
227        ), (
228            f"Something went wrong with the Axonometry angles: a_z = {int(math.degrees(a_z))}° / a_x = {int(math.degrees(a_x))}° / a_y = {int(math.degrees(a_y))}°"
229        )
230        logger.debug(
231            f"[Trihedron] Compute tilt for axo system: a_z = {int(math.degrees(a_z))}° / a_x = {int(math.degrees(a_x))}° / a_y = {int(math.degrees(a_y))}°",
232        )
233        # progress counter-clockwise
234        angle_pairs = [
235            (a_y - math.pi / 2, a_x - math.pi / 2),
236            (a_z - math.pi / 2, a_y - math.pi / 2),
237            (a_x - math.pi / 2, a_z - math.pi / 2),
238        ]
239        # Get angles for each coordinate plane
240        return [self._tilt(angle_pair) for angle_pair in angle_pairs]
241
242    def _tilt(self, angles: tuple[float, float]) -> tuple[float, float]:
243        """Coordinate plane tilt."""
244        alpha = math.pi / 2 - angles[0]
245        beta = math.pi / 2 - angles[1]
246
247        OP = 1  # noqa: N806
248        XP = abs(OP / math.tan(alpha))  # noqa: N806
249        YP = abs(OP / math.tan(beta))  # noqa: N806
250
251        h = math.sqrt(XP * YP)
252
253        gamma = math.atan(XP / h)
254        delta = math.atan(YP / h)
255
256        assert math.isclose(gamma + delta, math.pi / 2)
257        return (gamma, delta)
258
259    def _rotated_point(self, p0: CPoint, angle: float, length: float) -> CPoint:
260        # Calculate the new point after rotation
261        x = p0.x + length * math.cos(angle)
262        y = p0.y + length * math.sin(angle)
263        return CPoint(x, y)
264
265
266# ==========================================================================
267# Utilities
268# ==========================================================================
269
270
271def random_valid_angles() -> tuple:
272    """Compute an angle pair which can produce a valid axonometric drawing.
273
274    The notation follows standard hand-drawn axonometry conventions expressed as a tuple of
275    the two angles between the X and Y from the "axonoemtric horizon".
276
277    TODO: allow a zero angle value.
278
279    """
280    import random
281
282    alpha = random.choice(list(range(91)))  # noqa: S311
283    beta = random.choice(list(range(91)))  # noqa: S311
284    while not is_valid_angle_pair((alpha, beta)):
285        alpha = random.choice(list(range(91)))  # noqa: S311
286        beta = random.choice(list(range(91)))  # noqa: S311
287
288    return (alpha, beta)
289
290
291# ==========================================================================
292# Predicates
293# ==========================================================================
294
295
296def is_valid_angle_pair(angles: tuple) -> bool:
297    """Test if an angle pair are valid axonometry angles.
298
299    Check if angles satisfy the following conditions::
300
301        not (180 - (alpha + beta) >= 90 and
302        not (alpha == 0 and beta == 0) and
303        not (alpha == 90 and beta == 0) and
304        not (alpha == 0 and beta == 90)
305
306    .. hint::
307
308        Currently the angle value 0 is not supported.
309        But one can use a float vlue of .1 to approximate zero.
310    """
311    right_angle = 90
312    return (
313        180 - (angles[0] + angles[1]) >= right_angle
314        and not (angles[0] == 0 and angles[1] == 0)
315        and not (angles[0] == right_angle and angles[1] == 0)
316        and not (angles[0] == 0 and angles[1] == right_angle)
317    )
./src/axonometry/surface.py#
  1# SPDX-FileCopyrightText: 2022-2025 Julien Rippinger
  2#
  3# SPDX-License-Identifier: GPL-3.0-or-later
  4
  5from __future__ import annotations
  6
  7import logging
  8from typing import TYPE_CHECKING
  9
 10if TYPE_CHECKING:
 11    from .line import Line
 12    from .point import Point
 13
 14logging.basicConfig(level=logging.INFO)
 15logger = logging.getLogger(__name__)
 16
 17
 18class Surface:
 19    """A collection class to apply various graphical operations on a set of lines.
 20
 21    :raise AssertionError: If the geometries are not all in the same plane.
 22    """
 23
 24    def __init__(self, lines: list[Line] | list[Point]) -> None:
 25        self.plane = lines
 26        self.key = lines
 27        self.lines = lines
 28
 29    # ==========================================================================
 30    # Properties
 31    # ==========================================================================
 32
 33    @property
 34    def is_closed(self):
 35        """Number of unique points equal number of lines."""
 36        unique_points = {point for line in self._lines for point in (line.start, line.end)}
 37        return len(unique_points) == len(self._lines)
 38
 39    @property
 40    def plane(self):
 41        return self._plane
 42
 43    @plane.setter
 44    def plane(self, lines: list[Line]) -> None:
 45        """Set the plane attribute for the surface.
 46
 47        :param lines: List of lines.
 48        """
 49        planes = {obj.plane for obj in lines}
 50        assert len(planes) == 1, "Lines not in same plane."
 51        self._plane = (
 52            planes.pop()
 53        )  # Assuming the set contains only one element after the assertion passes
 54
 55    @property
 56    def key(self):
 57        return self._key
 58
 59    @key.setter
 60    def key(self, lines):
 61        keys = {obj.key for obj in lines}
 62        assert len(keys) == 1, "Lines not in same plane."
 63        self._key = keys.pop()
 64
 65    # ==========================================================================
 66    # Constructors
 67    # ==========================================================================
 68
 69    @staticmethod
 70    def closed(geometries: list[Line] | list[Point]) -> Surface:
 71        """Get surface collection of a computed continous line.
 72
 73        Find missing lines from a sparse collection of lines or points. Compute new lines by
 74        checking nearest points of existing lines. Make a surface object from that new line
 75        collection.
 76
 77        :param geometries: A sparse collection of :py:class:`.Point` or :py:class:`.Line`.
 78        """
 79        raise NotImplementedError
 80
 81    @staticmethod
 82    def from_points(points: list[Point]) -> Surface:
 83        """Make surface from convex hull of a list of points.
 84
 85        :raises NotImplementedError: WIP.
 86        """
 87        # from compas.geoemtry import convex_hull_xy
 88        # verts = convex_hull_xy(points)
 89        raise NotImplementedError
 90
 91    # ==========================================================================
 92    # Methods
 93    # ==========================================================================
 94
 95    def bounding_box(self):
 96        """Draw the axis-aligned minimum bounding box of a list of points in the plane.
 97
 98        :raises NotImplementedError: WIP.
 99        """
100        # from compas.geometry import bounding_box_xy
101        # unique_points = {point for line in self._lines for point in (line.start, line.end)}
102        # corners = bounding_box_xy(list(unique_points))
103        raise NotImplementedError
./src/axonometry/config.py#
  1# SPDX-FileCopyrightText: 2022-2025 Julien Rippinger
  2# SPDX-FileCopyrightText: 2019-2022 Antoine Beyeler & Contributors
  3#
  4# SPDX-License-Identifier: GPL-3.0-or-later
  5
  6from __future__ import annotations
  7
  8import logging
  9import os
 10import pathlib
 11from typing import TYPE_CHECKING, Any
 12
 13import tomli
 14
 15if TYPE_CHECKING:
 16    from collections.abc import Mapping
 17
 18logging.basicConfig(level=logging.INFO)
 19logger = logging.getLogger(__name__)
 20
 21
 22class ConfigManager:
 23    r"""Helper class to load axonometry's TOML configuration file.
 24
 25    This class is typically used via its singleton instance ``config``::
 26
 27        >>> from axonometry import config_manager
 28        >>> config_manager.config["layers"]["axo_system"]["id"]
 29        1
 30
 31    By default, built-in configuration packaged with axonometry are loaded at startup.
 32    If a file exists at path ``~/.axonometry.toml``, it will be loaded as well.
 33    Additionaly files may be loaded using the :func:`load_config_file` method.
 34
 35    The file holds default values to be accessed based on certain inputs::
 36
 37        >>> from axonometry import Axonometry
 38        >>> my_axo = Axonometry(15, 30, paper_size="A3")
 39        INFO:axonometry.axonometry:[XYZ] 15°/30°
 40        >>> my_axo.drawing.dimensions
 41        (2245.0393701054, 3178.5826772031)  # sizes in css pixel.
 42
 43    The configuration follows the line weights implement the DIN A norm, i.e. a
 44    :math:`\sqrt{2} ≈ 1.4` relationship of sizes. As such the lineweights
 45    in mm are:
 46
 47    +------+------+------+------+------+------+------+------+------+------+
 48    | 0.10 | 0.13 | 0.18 | 0.25 | 0.35 | 0.50 | 0.70 | 1.00 | 1.40 | 2.00 |
 49    +------+------+------+------+------+------+------+------+------+------+
 50
 51    This relationship allows linewidths to be scaled coherently based on paper sizes.
 52    For example, a 2 mm line width on an A0 page becomes a 1.4 mm line width on an A1 page.
 53
 54    Further reading: `Why A4? The Mathematical Beauty of Paper Size <https://web.archive.org/web/20230814124712/https://scilogs.spektrum.de/hlf/why-a4-the-mathematical-beauty-of-paper-size/>`__.
 55
 56    .. literalinclude:: ../../../src/axonometry/axo_config.toml
 57      :caption: Default configuration values in ``axo_config.toml``
 58      :language: toml
 59      :lines: 5-
 60
 61    """
 62
 63    def __init__(self) -> None:
 64        self._config: dict = {}
 65
 66    # ==========================================================================
 67    # Methods
 68    # ==========================================================================
 69
 70    def load_config_file(self, path: str) -> None:
 71        """Load a config file and add its content to the configuration database.
 72
 73        :param path: path of the config file. The configuration file must be in TOML format.
 74        """
 75
 76        def _update(d: dict, u: Mapping) -> dict:
 77            """Overwrite list member, UNLESS they are list of table, in which case they must extend the list."""
 78            for k, v in u.items():
 79                if isinstance(v, dict):
 80                    d[k] = _update(d.get(k, {}), v)
 81                elif isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
 82                    if k in d:
 83                        d[k].extend(v)
 84                    else:
 85                        d[k] = v
 86                else:
 87                    d[k] = v
 88            return d
 89
 90        logger.info(f"loading config file at {path}")
 91        with open(path, "rb") as fp:
 92            self._config = _update(self._config, tomli.load(fp))
 93
 94    @property
 95    def config(self) -> dict[str, Any]:
 96        """Access default configuration by key."""
 97        return self._config
 98
 99
100# ==========================================================================
101# Utilities
102# ==========================================================================
103
104config_manager = ConfigManager()
105
106
107def _init() -> None:
108    pathlib.Path("output/").mkdir(parents=True, exist_ok=True)
109    config_manager.load_config_file(str(pathlib.Path(__file__).parent / "axo_config.toml"))
110    path = os.path.expanduser("~/.aconometry.toml")
111    if os.path.exists(path):
112        config_manager.load_config_file(str(path))
113
114
115_init()