geodetic 0.4.0 → 0.5.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f85da37953a9e974422502d62fbf82279fcf4f0ff71594ada2bb150e07cc75d1
4
- data.tar.gz: a986282c09df583d9de453034f36e1a15c0d29cc016f586f09239f4f642d6fae
3
+ metadata.gz: 495ba0272532d5591ecbb37869857675af00014d6863849c79c6fbef35e590c3
4
+ data.tar.gz: dcb68a4385f7a888fbfb37b421d2f82c66aae077bda8557ce2a7b60c57e38b35
5
5
  SHA512:
6
- metadata.gz: 2d72a9b0e9e1a0688f25659dbff3d35e8b1c001c72468d2d2bd5c64ffe9fd2f16315dbbe4ec429c545115268e7486db81b26c4f6c9d625de3a8089b82cbcf8df
7
- data.tar.gz: 031eb9604c27d9683c74bf3483b913a5ddb065ed78bd5375f23297f39b923f5a6212498c236982a310afe25fa5ca300df0dfaaa60d8632ad02950c75c2662e15
6
+ metadata.gz: 8f276d8924aad177efc94aee0037f1fbd4e1cb655668ed54ff5ba0e471c16dd91687966577505057e31a144ea545adf3d8843535ca0705fca69fc5e5bf9a3637
7
+ data.tar.gz: f2d8102549b1502099d149453f5b18d8143b5799e3d3f5c2c4f947c86b7e2de1355e74916ae3b0d7376652fdb547ebd48488bff2eba18d60211da4ff0ff030a3
data/CHANGELOG.md CHANGED
@@ -11,6 +11,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
  ## [Unreleased]
12
12
 
13
13
 
14
+ ## [0.5.0] - 2026-03-10
15
+
16
+ ### Added
17
+
18
+ - **`Geodetic::Vector` class** — geodetic displacement pairing a Distance (magnitude) with a Bearing (direction)
19
+ - **Construction**: `Vector.new(distance:, bearing:)` with automatic coercion from numeric values
20
+ - **Components**: `north`, `east` — decomposed meters; `magnitude` — distance in meters
21
+ - **Factory methods**: `Vector.from_components(north:, east:)`, `Vector.from_segment(segment)`
22
+ - **Vincenty direct**: `destination_from(origin)` solves the direct geodetic problem on the WGS84 ellipsoid
23
+ - **Arithmetic**: `+`, `-` (component-wise), `*`, `/` (scalar), `-@` (unary minus); `Numeric * Vector` via coerce
24
+ - **Products**: `dot(other)`, `cross(other)`, `angle_between(other)`
25
+ - **Properties**: `zero?`, `normalize`, `reverse`/`inverse`
26
+ - **Comparable**: ordered by distance (magnitude)
27
+ - Near-zero results (< 1e-9 m) snap to clean zero vector
28
+ - **Geodetic arithmetic with `+` operator** — build geometry from coordinates, vectors, and distances:
29
+ - `Coordinate + Coordinate` → Segment
30
+ - `Coordinate + Coordinate + Coordinate` → Path (via Segment + Coordinate → Path)
31
+ - `Coordinate + Segment` → Path
32
+ - `Segment + Coordinate` → Path
33
+ - `Segment + Segment` → Path
34
+ - `Coordinate + Distance` → Circle
35
+ - `Distance + Coordinate` → Circle (commutative)
36
+ - `Coordinate + Vector` → Segment (Vincenty direct)
37
+ - `Vector + Coordinate` → Segment (reverse start to coordinate)
38
+ - `Segment + Vector` → Path (extend from endpoint)
39
+ - `Vector + Segment` → Path (prepend via reverse)
40
+ - `Path + Vector` → Path (extend from last point)
41
+ - **Translation with `*` operator and `translate` method** — uniform displacement across all geometric types:
42
+ - `Coordinate * Vector` → Coordinate (translated point)
43
+ - `Segment * Vector` → Segment (translated endpoints)
44
+ - `Path * Vector` → Path (translated waypoints)
45
+ - `Circle * Vector` → Circle (translated centroid, preserved radius)
46
+ - `Polygon * Vector` → Polygon (translated vertices)
47
+ - **`Segment#to_vector`** — extract a Vector from a Segment's length and bearing
48
+ - **`Path#to_corridor(width:)`** — convert a path into a Polygon corridor of a given width; uses mean bearing at interior waypoints to avoid self-intersection; accepts meters or a Distance object
49
+ - **Geodetic arithmetic example** (`examples/08_geodetic_arithmetic.rb`) — 11-section demo covering all arithmetic operators, Vector class, translation, corridors, and composed operations
50
+ - Documentation: `docs/reference/vector.md` (Vector reference), `docs/reference/arithmetic.md` (Geodetic Arithmetic reference)
51
+
52
+ ### Changed
53
+
54
+ - Updated README with Vector, Geodetic Arithmetic, and Corridors sections; added to key features list
55
+ - Updated `examples/README.md` with example 08 description
56
+
14
57
  ## [0.4.0] - 2026-03-10
15
58
 
16
59
  ### Added
data/README.md CHANGED
@@ -22,6 +22,8 @@
22
22
  - <strong>Segments</strong> - Directed two-point line segments with projection, intersection, and interpolation<br>
23
23
  - <strong>Paths</strong> - Directed coordinate sequences with navigation, interpolation, closest approach, intersection, and area conversion<br>
24
24
  - <strong>Features</strong> - Named geometry wrapper with metadata and delegated distance/bearing<br>
25
+ - <strong>Vectors</strong> - Geodetic displacement (distance + bearing) with full arithmetic and Vincenty direct<br>
26
+ - <strong>Geodetic Arithmetic</strong> - Compose geometry with operators: P1 + P2 → Segment, + P3 → Path, + Distance → Circle, * Vector → translate<br>
25
27
  - <strong>Validated Setters</strong> - Type coercion and range validation on all coordinate attributes<br>
26
28
  - <strong>Serialization</strong> - to_s(precision), to_a, from_string, from_array, DMS format<br>
27
29
  - <strong>Multiple Datums</strong> - WGS84, Clarke 1866, GRS 1980, Airy 1830, and more<br>
@@ -649,6 +651,78 @@ park.distance_to(liberty).to_km # => "12.47 km"
649
651
 
650
652
  All three attributes (`label`, `geometry`, `metadata`) are mutable.
651
653
 
654
+ ### Vectors
655
+
656
+ `Vector` pairs a `Distance` (magnitude) with a `Bearing` (direction) to represent a geodetic displacement. It solves the Vincenty direct problem to compute destination points.
657
+
658
+ ```ruby
659
+ v = Geodetic::Vector.new(distance: 10_000, bearing: 90.0)
660
+ v = Geodetic::Vector.new(distance: Distance.km(10), bearing: Bearing.new(90))
661
+
662
+ v.north # => north component in meters
663
+ v.east # => east component in meters
664
+ v.magnitude # => distance in meters
665
+ v.reverse # => same distance, opposite bearing
666
+ v.normalize # => unit vector (1 meter)
667
+ ```
668
+
669
+ **Vector arithmetic:**
670
+
671
+ ```ruby
672
+ v1 + v2 # => Vector (component-wise addition)
673
+ v1 - v2 # => Vector (component-wise subtraction)
674
+ v * 3 # => Vector (scale distance)
675
+ v / 2 # => Vector (scale distance)
676
+ -v # => Vector (reverse bearing)
677
+ v.dot(v2) # => Float (dot product)
678
+ v.cross(v2) # => Float (2D cross product)
679
+ ```
680
+
681
+ **Factory methods:**
682
+
683
+ ```ruby
684
+ Vector.from_components(north: 1000, east: 500)
685
+ Vector.from_segment(segment)
686
+ segment.to_vector
687
+ ```
688
+
689
+ ### Geodetic Arithmetic
690
+
691
+ Operators build geometry from coordinates, vectors, and distances:
692
+
693
+ ```ruby
694
+ # Building geometry with +
695
+ p1 + p2 # => Segment
696
+ p1 + p2 + p3 # => Path
697
+ p1 + segment # => Path
698
+ segment + p3 # => Path
699
+ segment + segment # => Path
700
+ p1 + distance # => Circle
701
+ p1 + vector # => Segment (to destination)
702
+ segment + vector # => Path (extend from endpoint)
703
+ vector + segment # => Path (prepend via reverse)
704
+ path + vector # => Path (extend from last point)
705
+ vector + coordinate # => Segment
706
+ distance + coordinate # => Circle
707
+
708
+ # Translation with * or .translate
709
+ p1 * vector # => Coordinate (translated point)
710
+ segment * vector # => Segment (translated endpoints)
711
+ path * vector # => Path (translated waypoints)
712
+ circle * vector # => Circle (translated centroid)
713
+ polygon * vector # => Polygon (translated vertices)
714
+ ```
715
+
716
+ ### Corridors
717
+
718
+ Convert a path into a polygon corridor of a given width:
719
+
720
+ ```ruby
721
+ route = seattle + portland + sf
722
+ corridor = route.to_corridor(width: 1000) # 1km wide polygon
723
+ corridor = route.to_corridor(width: Distance.km(1))
724
+ ```
725
+
652
726
  ### Web Mercator Tile Coordinates
653
727
 
654
728
  ```ruby
@@ -679,6 +753,8 @@ The [`examples/`](examples/) directory contains runnable demo scripts showing pr
679
753
  | [`04_bearing_calculations.rb`](examples/04_bearing_calculations.rb) | Bearing class, compass directions, elevation angles, and chain bearings |
680
754
  | [`05_map_rendering/`](examples/05_map_rendering/) | Render landmarks on a raster map with Feature objects, polygon areas, bearing arrows, and icons using [libgd-gis](https://rubygems.org/gems/libgd-gis) |
681
755
  | [`06_path_operations.rb`](examples/06_path_operations.rb) | Path class: construction, navigation, mutation, path arithmetic, closest approach, containment, Enumerable, equality, subpaths, split, interpolation, bounding boxes, polygon conversion, intersection, path-to-path/area closest points, and Feature integration |
756
+ | [`07_segments_and_shapes.rb`](examples/07_segments_and_shapes.rb) | Segment and polygon subclasses: Triangle, Rectangle, Pentagon, Hexagon, Octagon with containment, edges, and bounding boxes |
757
+ | [`08_geodetic_arithmetic.rb`](examples/08_geodetic_arithmetic.rb) | Geodetic arithmetic: building geometry with + (Segments, Paths, Circles), Vector class (Vincenty direct, components, arithmetic, dot/cross products), translation with * (Coordinates, Segments, Paths, Circles, Polygons), and corridors |
682
758
 
683
759
  Run any example with:
684
760
 
@@ -0,0 +1,368 @@
1
+ # Geodetic Arithmetic Reference
2
+
3
+ Geodetic provides operator overloading that lets you compose geometric objects naturally. The `+` operator builds geometry from parts, while `*` (and its alias `translate`) applies a vector displacement to shift objects.
4
+
5
+ ---
6
+
7
+ ## Design Principles
8
+
9
+ 1. **Type determines result** — the types of the operands, not their values, determine the return type. `Coordinate + Coordinate` always returns a Segment, never conditionally a different type.
10
+
11
+ 2. **Left operand is the anchor** — in asymmetric operations, the left operand provides the reference point. `P1 + V` creates a segment starting at P1; `V + P1` creates a segment ending at P1.
12
+
13
+ 3. **Consistent translation** — `*` always means "translate by this vector," returning the same type as the receiver. A translated Segment is still a Segment; a translated Path is still a Path.
14
+
15
+ ---
16
+
17
+ ## The + Operator: Building Geometry
18
+
19
+ The `+` operator composes smaller geometric objects into larger ones. The result type depends on the combination of operand types.
20
+
21
+ ### Coordinate + Coordinate → Segment
22
+
23
+ Two points define a directed line segment.
24
+
25
+ ```ruby
26
+ seattle = Geodetic::Coordinate::LLA.new(lat: 47.62, lng: -122.35, alt: 0)
27
+ portland = Geodetic::Coordinate::LLA.new(lat: 45.52, lng: -122.68, alt: 0)
28
+
29
+ seg = seattle + portland # => Geodetic::Segment
30
+ seg.start_point # => seattle
31
+ seg.end_point # => portland
32
+ seg.length # => Distance (~235 km)
33
+ seg.bearing # => Bearing (~188°)
34
+ ```
35
+
36
+ Works across any coordinate system — the Segment converts both points to LLA internally:
37
+
38
+ ```ruby
39
+ utm = portland.to_utm
40
+ seg = seattle + utm # => Segment (seattle → portland via LLA)
41
+ ```
42
+
43
+ Order matters: `seattle + portland` is a different segment than `portland + seattle`.
44
+
45
+ ### Coordinate + Coordinate + Coordinate → Path
46
+
47
+ Chaining builds a path. The first `+` produces a Segment; the second `+` extends it into a Path.
48
+
49
+ ```ruby
50
+ path = seattle + portland + sf # => Geodetic::Path (3 points)
51
+ path.size # => 3
52
+ path.first # => seattle
53
+ path.last # => sf
54
+ ```
55
+
56
+ Further chaining continues to extend the Path:
57
+
58
+ ```ruby
59
+ path = seattle + portland + sf + la + nyc # => Path (5 points)
60
+ ```
61
+
62
+ ### Coordinate + Segment → Path
63
+
64
+ A point plus a segment produces a three-point path: the point, then the segment's endpoints.
65
+
66
+ ```ruby
67
+ seg = portland + sf
68
+ path = seattle + seg # => Path: seattle → portland → sf
69
+ path.size # => 3
70
+ ```
71
+
72
+ ### Segment + Coordinate → Path
73
+
74
+ Extending a segment with a point:
75
+
76
+ ```ruby
77
+ seg = seattle + portland
78
+ path = seg + sf # => Path: seattle → portland → sf
79
+ ```
80
+
81
+ ### Segment + Segment → Path
82
+
83
+ Two segments concatenate into a four-point path:
84
+
85
+ ```ruby
86
+ seg1 = seattle + portland
87
+ seg2 = sf + la
88
+ path = seg1 + seg2 # => Path: seattle → portland → sf → la
89
+ ```
90
+
91
+ ### Coordinate + Distance → Circle
92
+
93
+ A point plus a distance defines a circle.
94
+
95
+ ```ruby
96
+ radius = Geodetic::Distance.km(5)
97
+ circle = seattle + radius
98
+ # => Areas::Circle centered at seattle, 5000m radius
99
+ ```
100
+
101
+ ### Distance + Coordinate → Circle
102
+
103
+ Commutative — same result:
104
+
105
+ ```ruby
106
+ circle = Geodetic::Distance.km(5) + seattle
107
+ # => Areas::Circle centered at seattle, 5000m radius
108
+ ```
109
+
110
+ ### Coordinate + Vector → Segment
111
+
112
+ A point plus a vector solves the Vincenty direct problem, producing a segment from the origin to the destination.
113
+
114
+ ```ruby
115
+ v = Geodetic::Vector.new(distance: 100_000, bearing: 45.0)
116
+ seg = seattle + v
117
+ # => Segment from seattle to a point 100km northeast
118
+ seg.length_meters # => ~100000.0
119
+ seg.bearing # => ~45°
120
+ ```
121
+
122
+ This is different from translation (`*`): `+` gives you the journey (a Segment), while `*` gives you just the destination (a Coordinate).
123
+
124
+ ### Vector + Coordinate → Segment
125
+
126
+ The vector reversed determines the start point; the coordinate is the endpoint.
127
+
128
+ ```ruby
129
+ v = Geodetic::Vector.new(distance: 10_000, bearing: 90.0)
130
+ seg = v + seattle
131
+ # => Segment from (10km west of seattle) to seattle
132
+ ```
133
+
134
+ ### Segment + Vector → Path
135
+
136
+ Extends the segment from its endpoint in the vector's direction.
137
+
138
+ ```ruby
139
+ seg = seattle + portland
140
+ v = Geodetic::Vector.new(distance: 50_000, bearing: 180.0)
141
+ path = seg + v
142
+ # => Path: seattle → portland → (50km south of portland)
143
+ ```
144
+
145
+ ### Vector + Segment → Path
146
+
147
+ Prepends a new start point. The vector is reversed from the segment's start to find it.
148
+
149
+ ```ruby
150
+ v = Geodetic::Vector.new(distance: 50_000, bearing: 90.0)
151
+ seg = seattle + portland
152
+ path = v + seg
153
+ # => Path: (50km west of seattle) → seattle → portland
154
+ ```
155
+
156
+ ### Path + Vector → Path
157
+
158
+ Extends the path from its last point in the vector's direction.
159
+
160
+ ```ruby
161
+ path = seattle + portland + sf
162
+ v = Geodetic::Vector.new(distance: 100_000, bearing: 180.0)
163
+ path2 = path + v
164
+ # => Path: seattle → portland → sf → (100km south of sf)
165
+ ```
166
+
167
+ ### Path + Coordinate → Path
168
+
169
+ Appends a waypoint (already existed before arithmetic was added):
170
+
171
+ ```ruby
172
+ path = seattle + portland
173
+ path2 = path + sf # => Path: seattle → portland → sf
174
+ ```
175
+
176
+ ### Path + Path → Path
177
+
178
+ Concatenates two paths (already existed):
179
+
180
+ ```ruby
181
+ west_coast = seattle + portland + sf
182
+ east_coast = nyc + dc
183
+ cross_country = west_coast + east_coast
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Complete + Operator Table
189
+
190
+ | Left | + Right | Result | Description |
191
+ |------|---------|--------|-------------|
192
+ | Coordinate | Coordinate | Segment | Two-point directed segment |
193
+ | Coordinate | Vector | Segment | Origin to Vincenty destination |
194
+ | Coordinate | Distance | Circle | Point + radius |
195
+ | Coordinate | Segment | Path | Point then segment endpoints |
196
+ | Segment | Coordinate | Path | Extend with waypoint |
197
+ | Segment | Segment | Path | Concatenate segments |
198
+ | Segment | Vector | Path | Extend from endpoint |
199
+ | Vector | Coordinate | Segment | Reverse start to coordinate |
200
+ | Vector | Segment | Path | Prepend via reverse |
201
+ | Vector | Vector | Vector | Component-wise addition |
202
+ | Distance | Coordinate | Circle | Radius + center |
203
+ | Path | Coordinate | Path | Append waypoint |
204
+ | Path | Path | Path | Concatenate |
205
+ | Path | Segment | Path | Append segment points |
206
+ | Path | Vector | Path | Extend from last point |
207
+
208
+ ---
209
+
210
+ ## The * Operator: Translation
211
+
212
+ The `*` operator translates (shifts) a geometric object by a vector displacement. Every point in the object is moved by the same vector. The result is always the same type as the receiver.
213
+
214
+ The named method `translate` is an alias for `*`.
215
+
216
+ ### Coordinate * Vector → Coordinate
217
+
218
+ Returns the destination point — the pure result of moving the point.
219
+
220
+ ```ruby
221
+ v = Geodetic::Vector.new(distance: 10_000, bearing: 0.0)
222
+ p2 = seattle * v # => LLA (10km north of seattle)
223
+ p2 = seattle.translate(v) # => same
224
+ ```
225
+
226
+ Compare with `+`: `seattle + v` returns a **Segment** (the journey); `seattle * v` returns a **Coordinate** (just the destination).
227
+
228
+ ### Segment * Vector → Segment
229
+
230
+ Both endpoints are translated by the same vector. Length and bearing are preserved.
231
+
232
+ ```ruby
233
+ seg = Geodetic::Segment.new(seattle, portland)
234
+ v = Geodetic::Vector.new(distance: 100_000, bearing: 90.0)
235
+
236
+ shifted = seg * v
237
+ shifted = seg.translate(v)
238
+
239
+ shifted.length_meters # => same as original
240
+ shifted.start_point # => 100km east of seattle
241
+ shifted.end_point # => 100km east of portland
242
+ ```
243
+
244
+ ### Path * Vector → Path
245
+
246
+ All waypoints are translated. The shape and distances between points are preserved.
247
+
248
+ ```ruby
249
+ route = seattle + portland + sf
250
+ v = Geodetic::Vector.new(distance: 50_000, bearing: 0.0)
251
+
252
+ shifted = route * v # => Path shifted 50km north
253
+ shifted = route.translate(v) # => same
254
+ shifted.size # => 3
255
+ ```
256
+
257
+ ### Circle * Vector → Circle
258
+
259
+ The centroid is translated. The radius is preserved.
260
+
261
+ ```ruby
262
+ circle = Geodetic::Areas::Circle.new(centroid: seattle, radius: 5000)
263
+ v = Geodetic::Vector.new(distance: 10_000, bearing: 180.0)
264
+
265
+ shifted = circle * v # => Circle 10km south, same 5km radius
266
+ shifted = circle.translate(v) # => same
267
+ shifted.radius # => 5000.0
268
+ ```
269
+
270
+ ### Polygon * Vector → Polygon
271
+
272
+ All boundary vertices are translated. The shape is preserved.
273
+
274
+ ```ruby
275
+ a = LLA.new(lat: 40.0, lng: -74.0, alt: 0)
276
+ b = LLA.new(lat: 40.0, lng: -73.0, alt: 0)
277
+ c = LLA.new(lat: 41.0, lng: -73.5, alt: 0)
278
+ poly = Geodetic::Areas::Polygon.new(boundary: [a, b, c])
279
+
280
+ v = Geodetic::Vector.new(distance: 100_000, bearing: 0.0)
281
+ shifted = poly * v # => Polygon shifted 100km north
282
+ shifted = poly.translate(v) # => same
283
+ ```
284
+
285
+ ---
286
+
287
+ ## Complete * Operator Table
288
+
289
+ | Object | * Vector | Result | Effect |
290
+ |--------|----------|--------|--------|
291
+ | Coordinate | Vector | Coordinate | Translate point |
292
+ | Segment | Vector | Segment | Translate both endpoints |
293
+ | Path | Vector | Path | Translate all waypoints |
294
+ | Circle | Vector | Circle | Translate centroid, preserve radius |
295
+ | Polygon | Vector | Polygon | Translate all vertices |
296
+
297
+ The `*` operator only accepts a `Vector` on the right side. Any other type raises `ArgumentError`.
298
+
299
+ ---
300
+
301
+ ## Corridors
302
+
303
+ `Path#to_corridor(width:)` converts a path into a polygon by offsetting each waypoint perpendicular to the path bearing on both sides.
304
+
305
+ ```ruby
306
+ route = seattle + portland + sf
307
+ corridor = route.to_corridor(width: 1000) # 1km wide
308
+ corridor = route.to_corridor(width: Distance.km(1)) # also accepts Distance
309
+ # => Areas::Polygon with 2*N boundary vertices
310
+ ```
311
+
312
+ At interior waypoints, the perpendicular direction uses the mean bearing of the two adjacent segments to avoid self-intersection at bends.
313
+
314
+ Requires at least 2 coordinates. The `width:` parameter accepts meters (Numeric) or a `Distance` object.
315
+
316
+ ---
317
+
318
+ ## Combining + and *
319
+
320
+ The operators compose naturally:
321
+
322
+ ```ruby
323
+ # Build a route, then shift it
324
+ v = Geodetic::Vector.new(distance: 50_000, bearing: 90.0)
325
+ route = (seattle + portland + sf) * v # shifted 50km east
326
+
327
+ # Build a circle, then translate it
328
+ circle = (seattle + Distance.km(5)) * v
329
+
330
+ # Chain: point + vector gives segment, then extend
331
+ seg = seattle + Geodetic::Vector.new(distance: 100_000, bearing: 45.0)
332
+ path = seg + portland # 3-point path
333
+
334
+ # Translate a corridor
335
+ corridor = route.to_corridor(width: 500)
336
+ shifted_corridor = corridor * v
337
+ ```
338
+
339
+ ---
340
+
341
+ ## Key Distinctions
342
+
343
+ ### + vs * with Vector
344
+
345
+ This is the most important distinction to understand:
346
+
347
+ | Expression | Result | Meaning |
348
+ |-----------|--------|---------|
349
+ | `P + V` | Segment | The **journey** — where you started and where you arrived |
350
+ | `P * V` | Coordinate | The **destination** — just where you end up |
351
+
352
+ `P + V` gives you a Segment because building geometry is the purpose of `+`. The segment records both the origin and the destination.
353
+
354
+ `P * V` gives you a Coordinate because translation is the purpose of `*`. You're moving the point, not creating a composite object.
355
+
356
+ ### Commutativity
357
+
358
+ Most `+` operations are **not** commutative — order determines the structure:
359
+
360
+ - `P1 + P2` starts at P1; `P2 + P1` starts at P2
361
+ - `P + V` creates a segment starting at P; `V + P` creates a segment ending at P
362
+
363
+ The exceptions are:
364
+
365
+ - `P + Distance` and `Distance + P` both produce the same Circle
366
+ - `V1 + V2` and `V2 + V1` produce the same Vector (component addition is commutative)
367
+
368
+ Translation (`*`) is always `object * vector` — the vector must be on the right.