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()