tesserax 0.2.1__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {tesserax-0.2.1 → tesserax-0.5.1}/PKG-INFO +3 -2
  2. {tesserax-0.2.1 → tesserax-0.5.1}/README.md +2 -1
  3. {tesserax-0.2.1 → tesserax-0.5.1}/docs/_quarto.yml +1 -1
  4. {tesserax-0.2.1 → tesserax-0.5.1}/docs/core.qmd +89 -1
  5. {tesserax-0.2.1 → tesserax-0.5.1}/docs/gallery.qmd +6 -4
  6. {tesserax-0.2.1 → tesserax-0.5.1}/docs/index.qmd +2 -1
  7. {tesserax-0.2.1 → tesserax-0.5.1}/makefile +1 -1
  8. {tesserax-0.2.1 → tesserax-0.5.1}/pyproject.toml +4 -2
  9. tesserax-0.5.1/src/tesserax/__init__.py +16 -0
  10. {tesserax-0.2.1 → tesserax-0.5.1}/src/tesserax/base.py +199 -4
  11. {tesserax-0.2.1 → tesserax-0.5.1}/src/tesserax/canvas.py +35 -4
  12. {tesserax-0.2.1 → tesserax-0.5.1}/src/tesserax/core.py +17 -0
  13. tesserax-0.5.1/src/tesserax/layout.py +355 -0
  14. tesserax-0.5.1/src/tesserax/py.typed +0 -0
  15. {tesserax-0.2.1 → tesserax-0.5.1}/uv.lock +115 -1
  16. tesserax-0.2.1/src/tesserax/__init__.py +0 -5
  17. tesserax-0.2.1/src/tesserax/force.py +0 -98
  18. tesserax-0.2.1/src/tesserax/layout.py +0 -107
  19. {tesserax-0.2.1 → tesserax-0.5.1}/.github/workflows/release.yaml +0 -0
  20. {tesserax-0.2.1 → tesserax-0.5.1}/.github/workflows/tests.yaml +0 -0
  21. {tesserax-0.2.1 → tesserax-0.5.1}/.gitignore +0 -0
  22. {tesserax-0.2.1 → tesserax-0.5.1}/.python-version +0 -0
  23. {tesserax-0.2.1 → tesserax-0.5.1}/.vscode/settings.json +0 -0
  24. {tesserax-0.2.1 → tesserax-0.5.1}/AGENT.md +0 -0
  25. {tesserax-0.2.1 → tesserax-0.5.1}/LICENSE +0 -0
  26. {tesserax-0.2.1 → tesserax-0.5.1}/docs/.gitignore +0 -0
  27. {tesserax-0.2.1 → tesserax-0.5.1}/docs/styles.css +0 -0
  28. {tesserax-0.2.1 → tesserax-0.5.1}/examples/basic.py +0 -0
  29. /tesserax-0.2.1/src/tesserax/py.typed → /tesserax-0.5.1/src/tesserax/align.py +0 -0
  30. {tesserax-0.2.1 → tesserax-0.5.1}/tests/test_geometry.py +0 -0
  31. {tesserax-0.2.1 → tesserax-0.5.1}/tests/test_layout.py +0 -0
  32. {tesserax-0.2.1 → tesserax-0.5.1}/tests/test_shapes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tesserax
3
- Version: 0.2.1
3
+ Version: 0.5.1
4
4
  Summary: A pure-Python library for rendering professional CS graphics.
5
5
  Author-email: Alejandro Piad <apiad@apiad.net>
6
6
  License-File: LICENSE
@@ -80,7 +80,8 @@ Layouts are a unique feature of Tesserax to automate the positioning of child el
80
80
 
81
81
  * **Row**: Aligns shapes horizontally. Baselines can be set to `start`, `middle`, or `end`.
82
82
  * **Column**: Aligns shapes vertically with `start`, `middle`, or `end` alignment.
83
- * **ForceLayout**: Typically used to draw graphs.
83
+ * **HierarchicalLayout**: Useful for drawing trees, DAGs, automata, etc.
84
+ * **ForceLayout**: Typically used to draw arbitrary graphs with a force-directed algorithm.
84
85
 
85
86
  ### Transforms
86
87
 
@@ -71,7 +71,8 @@ Layouts are a unique feature of Tesserax to automate the positioning of child el
71
71
 
72
72
  * **Row**: Aligns shapes horizontally. Baselines can be set to `start`, `middle`, or `end`.
73
73
  * **Column**: Aligns shapes vertically with `start`, `middle`, or `end` alignment.
74
- * **ForceLayout**: Typically used to draw graphs.
74
+ * **HierarchicalLayout**: Useful for drawing trees, DAGs, automata, etc.
75
+ * **ForceLayout**: Typically used to draw arbitrary graphs with a force-directed algorithm.
75
76
 
76
77
  ### Transforms
77
78
 
@@ -9,7 +9,7 @@ website:
9
9
  - href: index.qmd
10
10
  text: Home
11
11
  - href: core.qmd
12
- text: Core Concepts
12
+ text: User Guide
13
13
  - href: gallery.qmd
14
14
  text: Gallery
15
15
 
@@ -1,4 +1,4 @@
1
- This "Core Concepts" guide will walk you through building a scene from scratch, demonstrating how **Tesserax** handles shapes, positioning, and composition.
1
+ This guide will walk you through building a scene from scratch, demonstrating how **Tesserax** handles shapes, positioning, and composition.
2
2
 
3
3
  ## The Canvas and the First Shape
4
4
 
@@ -21,6 +21,20 @@ canvas.fit(padding=10).display()
21
21
 
22
22
  ```
23
23
 
24
+ ## Drawing Text
25
+
26
+ Text behaves exactly like any other shape in Tesserax.
27
+
28
+ ```{python}
29
+ from tesserax import Canvas, Text
30
+
31
+ with Canvas() as canvas:
32
+ Rect(150, 40, fill="lightblue")
33
+ Text("Hello World", size=24, font="serif").translated(0, 22)
34
+
35
+ canvas.align("horizontal").fit(10).display()
36
+ ```
37
+
24
38
  ## Adding and Transforming Shapes
25
39
 
26
40
  While you can add shapes and manually set their coordinates, Tesserax provides a fluent API for transformations. Here, we add a `Circle` and use `translated()` to move it into position.
@@ -141,3 +155,77 @@ When you call `shape.anchor(name)`, Tesserax performs the following behind the s
141
155
  This means you never have to manually calculate `sin()` or `cos()` to find where a rotated object's edge is located—you just ask for the anchor.
142
156
 
143
157
  For explicit anchoring, you can use `Shape.resolve(p: Point)` to map a point in local space to the global space, this way you can, e.g., get the point at 2/3rds of the way inside a rectangle and map it to global space.
158
+
159
+ ## Drawing Paths and Lines
160
+
161
+ While shapes like `Rect` and `Circle` cover many use cases, sometimes you need to draw arbitrary lines, custom shapes, or connectors. Tesserax offers two ways to do this: the low-level `Path` for precise control and the high-level `Polyline` for rapid sequences.
162
+
163
+ ### The Low-Level `Path` Object
164
+
165
+ The `Path` class roughly corresponds to the SVG `<path>` element. It operates like a pen: you move it to a location, then draw lines or curves to subsequent points.
166
+
167
+ This is ideal for creating custom glyphs or specific geometric curves.
168
+
169
+ ```{python}
170
+ from tesserax import Canvas, Path
171
+
172
+ with Canvas() as canvas:
173
+ # 1. A simple custom shape (a triangle)
174
+ p = Path()
175
+ p.move_to(0, 0).line_to(50, 50).line_to(0, 50).close()
176
+ p.translated(20, 20)
177
+
178
+ # 2. A curved path using Bezier curves
179
+ curve = Path()
180
+ curve.move_to(100, 20)
181
+ # Cubic Bezier: 2 control points, 1 end point
182
+ curve.cubic_to(
183
+ 150, 20, # Control Point 1
184
+ 150, 80, # Control Point 2
185
+ 200, 80 # End Point
186
+ )
187
+
188
+ # Quadratic Bezier: 1 control point, 1 end point
189
+ curve.quadratic_to(
190
+ 250, 80, # Control Point
191
+ 300, 20 # End Point
192
+ )
193
+
194
+ canvas.fit(padding=10).display()
195
+ ```
196
+
197
+ ### The High-Level `Polyline`
198
+
199
+ For many diagrams, you simply want to connect a sequence of points. The `Polyline` shape automates this.
200
+
201
+ Its most powerful feature is the `smoothness` parameter. Instead of manually calculating Bezier control points to round a corner, you can simply tell `Polyline` to blend the corners for you.
202
+
203
+ * `smoothness=0`: Sharp corners (standard polygon).
204
+ * `smoothness=0.2`: Subtle rounded corners (like a modern UI box).
205
+ * `smoothness=1`: Maximum rounding (spline-like).
206
+
207
+ ```{python}
208
+ from tesserax import Canvas, Polyline, Point
209
+
210
+ # Helper to generate a zigzag pattern
211
+ points = [
212
+ Point(0, 50), Point(50, 0), Point(100, 50),
213
+ Point(150, 0), Point(200, 50)
214
+ ]
215
+
216
+ with Canvas() as canvas:
217
+ # 1. Sharp Polyline (Default)
218
+ Polyline(points, smoothness=0).translated(0, 0)
219
+
220
+ # 2. Slightly Rounded (20% smoothing)
221
+ # Note how it preserves most of the straight line but rounds the tip.
222
+ Polyline(points, smoothness=0.2, stroke="blue").translated(0, 60)
223
+
224
+ # 3. Fully Smooth (100% smoothing)
225
+ # This creates a flowing wave-like appearance.
226
+ Polyline(points, smoothness=1.0, stroke="red").translated(0, 120)
227
+
228
+ canvas.fit(10).display()
229
+ ```
230
+
231
+ This makes `Polyline` an excellent tool for drawing graph edges, wiring diagrams, or "hand-drawn" style annotations where sharp vertices look unnatural.
@@ -58,7 +58,7 @@ This example uses a force layout to draw a simple graph that represents an autom
58
58
  ```{python}
59
59
  import math
60
60
  from tesserax import Canvas, Circle, Arrow
61
- from tesserax.force import ForceLayout
61
+ from tesserax.layout import HierarchicalLayout
62
62
  from tesserax.core import Point
63
63
 
64
64
  def get_boundary_point(center: Point, target: Point, radius: float) -> Point:
@@ -76,10 +76,10 @@ def get_boundary_point(center: Point, target: Point, radius: float) -> Point:
76
76
 
77
77
  with Canvas() as canvas:
78
78
  states: list[Shape] = []
79
- radius = 25
79
+ radius = 20
80
80
 
81
81
  # 1. Define the Graph Structure
82
- with ForceLayout(k=75, iterations=300) as graph:
82
+ with HierarchicalLayout(orientation="horizontal") as graph:
83
83
  # Create 5 states
84
84
  for i in range(4):
85
85
  states.append(Circle(r=radius))
@@ -87,11 +87,13 @@ with Canvas() as canvas:
87
87
  # Connect them (Topology)
88
88
  # q0 -> q1 -> q2
89
89
  graph.connect(states[0], states[1])
90
- graph.connect(states[1], states[2])
90
+ graph.connect(states[0], states[2])
91
91
  # q2 -> q0 (cycle)
92
92
  graph.connect(states[2], states[0])
93
93
  # q2 -> q3 -> q4
94
94
  graph.connect(states[2], states[3])
95
+ # Set the root
96
+ graph.root(states[0])
95
97
 
96
98
  # 2. Draw Transitions (Visuals)
97
99
  # We define edges manually to ensure directionality (ForceLayout is undirected)
@@ -69,7 +69,8 @@ Layouts are a unique feature of Tesserax to automate the positioning of child el
69
69
 
70
70
  * **Row**: Aligns shapes horizontally. Baselines can be set to `start`, `middle`, or `end`.
71
71
  * **Column**: Aligns shapes vertically with `start`, `middle`, or `end` alignment.
72
- * **ForceLayout**: Typically used to draw graphs.
72
+ * **HierarchicalLayout**: Useful for drawing trees, DAGs, automata, etc.
73
+ * **ForceLayout**: Typically used to draw arbitrary graphs with a force-directed algorithm.
73
74
 
74
75
  ### Transforms
75
76
 
@@ -54,7 +54,7 @@ release:
54
54
  @echo Remove backup files
55
55
  @rm pyproject.toml.bak src/tesserax/__init__.py.bak
56
56
 
57
- @uv sync
57
+ @uv sync --all-groups
58
58
 
59
59
  @echo "Committing version bump..."
60
60
  @git add pyproject.toml src/tesserax/__init__.py uv.lock
@@ -1,13 +1,12 @@
1
1
  [project]
2
2
  name = "tesserax"
3
- version = "0.2.1"
3
+ version = "0.5.1"
4
4
  description = "A pure-Python library for rendering professional CS graphics."
5
5
  readme = "README.md"
6
6
  authors = [
7
7
  { name = "Alejandro Piad", email = "apiad@apiad.net" }
8
8
  ]
9
9
  requires-python = ">=3.12"
10
- dependencies = []
11
10
 
12
11
  [build-system]
13
12
  requires = ["hatchling"]
@@ -22,3 +21,6 @@ dev = [
22
21
  "pytest-coverage>=0.0",
23
22
  "ruff>=0.14.14",
24
23
  ]
24
+ export = [
25
+ "cairosvg>=2.8.2",
26
+ ]
@@ -0,0 +1,16 @@
1
+ from .canvas import Canvas
2
+ from .core import Shape, Bounds, Point
3
+ from .base import (
4
+ Rect,
5
+ Square,
6
+ Circle,
7
+ Ellipse,
8
+ Line,
9
+ Arrow,
10
+ Group,
11
+ Path,
12
+ Polyline,
13
+ Text,
14
+ )
15
+
16
+ __version__ = "0.5.1"
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
- from typing import Callable, Self
3
- from .core import Point, Shape, Bounds
2
+ from typing import Callable, Literal, Self
3
+ from .core import Anchor, Point, Shape, Bounds
4
4
 
5
5
 
6
6
  class Rect(Shape):
@@ -164,6 +164,36 @@ class Group(Shape):
164
164
  def __exit__(self, exc_type, exc_val, exc_tb):
165
165
  self.add(*self.stack.pop())
166
166
 
167
+ def align(
168
+ self,
169
+ axis: Literal["horizontal", "vertical", "both"] = "both",
170
+ anchor: Anchor = "center",
171
+ ) -> Self:
172
+ """
173
+ Aligns all children in the group relative to the anchor of the first child.
174
+
175
+ The alignment is performed in the group's local coordinate system by
176
+ adjusting the translation (tx, ty) of each shape.
177
+ """
178
+ if not self.shapes:
179
+ return self
180
+
181
+ # The first shape acts as the reference datum for the alignment
182
+ first = self.shapes[0]
183
+ ref_p = first.transform.map(first.local().anchor(anchor))
184
+
185
+ for shape in self.shapes[1:]:
186
+ # Calculate the child's anchor point in the group's coordinate system
187
+ curr_p = shape.transform.map(shape.local().anchor(anchor))
188
+
189
+ if axis in ("horizontal", "both"):
190
+ shape.transform.tx += ref_p.x - curr_p.x
191
+
192
+ if axis in ("vertical", "both"):
193
+ shape.transform.ty += ref_p.y - curr_p.y
194
+
195
+ return self
196
+
167
197
 
168
198
  class Path(Shape):
169
199
  """
@@ -172,8 +202,13 @@ class Path(Shape):
172
202
  layout bounding box calculations.
173
203
  """
174
204
 
175
- def __init__(self) -> None:
205
+ def __init__(self, stroke: str = "black", width: float = 1) -> None:
176
206
  super().__init__()
207
+ self.stroke = stroke
208
+ self.width = width
209
+ self._reset()
210
+
211
+ def _reset(self):
177
212
  self._commands: list[str] = []
178
213
  self._cursor: tuple[float, float] = (0.0, 0.0)
179
214
 
@@ -237,6 +272,15 @@ class Path(Shape):
237
272
  self._update_cursor(end_x, end_y)
238
273
  return self
239
274
 
275
+ def quadratic_to(self, cx: float, cy: float, ex: float, ey: float) -> Self:
276
+ """
277
+ Draws a quadratic Bezier curve to (ex, ey) with control point (cx, cy).
278
+ """
279
+ self._commands.append(f"Q {cx} {cy}, {ex} {ey}")
280
+ self._expand_bounds(cx, cy) # Approximate bounds including control point
281
+ self._update_cursor(ex, ey)
282
+ return self
283
+
240
284
  def close(self) -> Self:
241
285
  """Closes the path by drawing a line back to the start."""
242
286
  self._commands.append("Z")
@@ -261,4 +305,155 @@ class Path(Shape):
261
305
  # You might want to offset commands by self.x/self.y if
262
306
  # this shape is moved by a Layout.
263
307
  d_attr = " ".join(self._commands)
264
- return f'<path d="{d_attr}" fill="none" stroke="black" stroke-width="2" />'
308
+ return f'<path d="{d_attr}" fill="none" stroke="{self.stroke}" stroke-width="{self.width}" />'
309
+
310
+
311
+ class Polyline(Path):
312
+ """
313
+ A sequence of connected lines with optional corner rounding.
314
+
315
+ Args:
316
+ points: List of vertices.
317
+ smoothness: 0.0 (sharp) to 1.0 (fully rounded/spline-like).
318
+ closed: If True, connects the last point back to the first.
319
+ """
320
+
321
+ def __init__(
322
+ self,
323
+ points: list[Point],
324
+ smoothness: float = 0.0,
325
+ closed: bool = False,
326
+ stroke: str = "black",
327
+ width: float = 1.0,
328
+ ) -> None:
329
+ super().__init__(stroke=stroke, width=width)
330
+
331
+ self.points = points or []
332
+ self.smoothness = smoothness
333
+ self.closed = closed
334
+ self._build()
335
+
336
+ def add(self, p: Point) -> Self:
337
+ self.points.append(p)
338
+ return self
339
+
340
+ def _build(self):
341
+ self._reset()
342
+
343
+ if not self.points:
344
+ return
345
+
346
+ # Clamp smoothness to 0-1 range
347
+ s = max(0.0, min(1.0, self.smoothness))
348
+
349
+ # Determine effective loop of points
350
+ # If closed, we wrap around; if not, we handle start/end differently
351
+ verts = self.points + ([self.points[0], self.points[1]] if self.closed else [])
352
+
353
+ # 1. Move to the geometric start
354
+ # If smoothing is on and not self.closed, we start exactly at P0
355
+ # If closed, we start at the midpoint of the last segment (handled by loop)
356
+ self.move_to(verts[0].x, verts[0].y)
357
+
358
+ # We iterate through triplets: (Prev, Curr, Next)
359
+ # But for an open polyline, we only round the *internal* corners.
360
+
361
+ if len(verts) < 3:
362
+ # Fallback for simple line
363
+ for p in verts[1:]:
364
+ self.line_to(p.x, p.y)
365
+
366
+ return
367
+
368
+ # Logic for Open Polyline
369
+ # P0 -> ... -> Pn
370
+ # We start at P0.
371
+ # For every corner P_i, we draw a line to "Start of Curve", then curve to "End of Curve".
372
+
373
+ # Start
374
+ curr_p = verts[0]
375
+ self.move_to(curr_p.x, curr_p.y)
376
+
377
+ for i in range(1, len(verts) - 1):
378
+ prev_p = verts[i - 1]
379
+ curr_p = verts[i]
380
+ next_p = verts[i + 1]
381
+
382
+ # Vectors
383
+ vec_in = curr_p - prev_p
384
+ vec_out = next_p - curr_p
385
+
386
+ len_in = vec_in.magnitude()
387
+ len_out = vec_out.magnitude()
388
+
389
+ # Corner Radius determination
390
+ # We can't exceed 50% of the shortest leg, or curves will overlap
391
+ max_r = min(len_in, len_out) / 2.0
392
+ radius = max_r * s
393
+
394
+ # Calculate geometric points
395
+ # "Start of Curve" is back along the incoming vector
396
+ p_start = curr_p - vec_in.normalize() * radius
397
+
398
+ # "End of Curve" is forward along the outgoing vector
399
+ p_end = curr_p + vec_out.normalize() * radius
400
+
401
+ # Draw
402
+ self.line_to(p_start.x, p_start.y)
403
+ self.quadratic_to(curr_p.x, curr_p.y, p_end.x, p_end.y)
404
+
405
+ # Finish at the last point
406
+ last = verts[-1]
407
+ self.line_to(last.x, last.y)
408
+
409
+ if self.closed:
410
+ self.close()
411
+
412
+ def _render(self) -> str:
413
+ self._build()
414
+ return super()._render()
415
+
416
+
417
+ class Text(Shape):
418
+ """
419
+ A text primitive with heuristic-based bounding box calculation.
420
+ """
421
+
422
+ def __init__(
423
+ self,
424
+ content: str,
425
+ size: float = 12,
426
+ font: str = "sans-serif",
427
+ fill: str = "black",
428
+ anchor: Literal["start", "middle", "end"] = "middle",
429
+ ) -> None:
430
+ super().__init__()
431
+ self.content = content
432
+ self.size = size
433
+ self.font = font
434
+ self.fill = fill
435
+ self._anchor = anchor
436
+
437
+ def local(self) -> Bounds:
438
+ # Heuristic: average character width is ~60% of font size
439
+ width = len(self.content) * self.size * 0.6
440
+ height = self.size
441
+
442
+ match self._anchor:
443
+ case "start":
444
+ return Bounds(0, -height + 2, width, height)
445
+ case "middle":
446
+ return Bounds(-width / 2, -height + 2, width, height)
447
+ case "end":
448
+ return Bounds(-width, -height + 2, width, height)
449
+ case _:
450
+ raise ValueError(f"Invalid anchor: {self._anchor}")
451
+
452
+ def _render(self) -> str:
453
+ # dominant-baseline="middle" or "alphabetic" helps vertical alignment
454
+ # but "central" is often more predictable for layout centers.
455
+ return (
456
+ f'<text x="0" y="0" font-family="{self.font}" font-size="{self.size}" '
457
+ f'fill="{self.fill}" text-anchor="{self._anchor}" dominant-baseline="middle">'
458
+ f"{self.content}</text>"
459
+ )
@@ -69,10 +69,41 @@ class Canvas(Group):
69
69
  "</svg>"
70
70
  )
71
71
 
72
- def save(self, path: str | Path) -> None:
73
- """Writes the canvas content to an SVG file."""
74
- with open(path, "w", encoding="utf-8") as f:
75
- f.write(self._build_svg())
72
+ def save(self, path: str | Path, dpi: int = 300) -> None:
73
+ """
74
+ Exports the canvas to a raster or vector format with a transparent background.
75
+
76
+ Supported formats: .png, .pdf, .svg, .ps.
77
+ Requires the 'export' extra (cairosvg).
78
+ """
79
+
80
+ svg_data = self._build_svg().encode("utf-8")
81
+ target = str(path)
82
+ extension = Path(path).suffix.lower()
83
+
84
+ if extension == ".svg":
85
+ with open(path, "wb") as fp:
86
+ fp.write(svg_data)
87
+
88
+ return
89
+
90
+ try:
91
+ import cairosvg
92
+ except ImportError:
93
+ raise ImportError(
94
+ "Export requires 'cairosvg'. Install with: pip install tesserax[export]"
95
+ )
96
+
97
+ match extension:
98
+ case ".png":
99
+ # CairoSVG handles transparency by default if the SVG has no background rect
100
+ cairosvg.svg2png(bytestring=svg_data, write_to=target, dpi=dpi)
101
+ case ".pdf":
102
+ cairosvg.svg2pdf(bytestring=svg_data, write_to=target, dpi=dpi)
103
+ case ".ps":
104
+ cairosvg.svg2ps(bytestring=svg_data, write_to=target, dpi=dpi)
105
+ case _:
106
+ raise ValueError(f"Unsupported export format: {extension}")
76
107
 
77
108
  def __str__(self) -> str:
78
109
  return self._build_svg()
@@ -39,6 +39,23 @@ class Point:
39
39
  def __sub__(self, other: Point) -> Point:
40
40
  return Point(self.x - other.x, self.y - other.y)
41
41
 
42
+ def magnitude(self) -> float:
43
+ return math.sqrt(self.x**2 + self.y**2)
44
+
45
+ def normalize(self) -> Point:
46
+ m = self.magnitude()
47
+
48
+ if m == 0:
49
+ return Point(0, 0)
50
+
51
+ return Point(self.x / m, self.y / m)
52
+
53
+ def __mul__(self, scalar: float) -> Point:
54
+ return Point(self.x * scalar, self.y * scalar)
55
+
56
+ def __truediv__(self, scalar: float) -> Point:
57
+ return Point(self.x / scalar, self.y / scalar)
58
+
42
59
  def dx(self, dx: float) -> Point:
43
60
  return self + Point(dx, 0)
44
61