geodetic 0.3.2 → 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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -2
  3. data/README.md +121 -8
  4. data/docs/coordinate-systems/gars.md +2 -2
  5. data/docs/coordinate-systems/georef.md +2 -2
  6. data/docs/coordinate-systems/gh.md +2 -2
  7. data/docs/coordinate-systems/gh36.md +2 -2
  8. data/docs/coordinate-systems/h3.md +2 -2
  9. data/docs/coordinate-systems/ham.md +2 -2
  10. data/docs/coordinate-systems/olc.md +2 -2
  11. data/docs/index.md +4 -2
  12. data/docs/reference/areas.md +140 -14
  13. data/docs/reference/arithmetic.md +368 -0
  14. data/docs/reference/feature.md +2 -2
  15. data/docs/reference/path.md +3 -3
  16. data/docs/reference/segment.md +181 -0
  17. data/docs/reference/vector.md +256 -0
  18. data/examples/02_all_coordinate_systems.rb +6 -6
  19. data/examples/06_path_operations.rb +2 -4
  20. data/examples/07_segments_and_shapes.rb +258 -0
  21. data/examples/08_geodetic_arithmetic.rb +393 -0
  22. data/examples/README.md +35 -1
  23. data/lib/geodetic/areas/bounding_box.rb +56 -0
  24. data/lib/geodetic/areas/circle.rb +8 -0
  25. data/lib/geodetic/areas/hexagon.rb +11 -0
  26. data/lib/geodetic/areas/octagon.rb +11 -0
  27. data/lib/geodetic/areas/pentagon.rb +11 -0
  28. data/lib/geodetic/areas/polygon.rb +64 -14
  29. data/lib/geodetic/areas/rectangle.rb +85 -35
  30. data/lib/geodetic/areas/regular_polygon.rb +59 -0
  31. data/lib/geodetic/areas/triangle.rb +180 -0
  32. data/lib/geodetic/areas.rb +6 -0
  33. data/lib/geodetic/coordinate/gh36.rb +1 -1
  34. data/lib/geodetic/coordinate/h3.rb +1 -1
  35. data/lib/geodetic/coordinate/spatial_hash.rb +2 -2
  36. data/lib/geodetic/coordinate.rb +26 -1
  37. data/lib/geodetic/distance.rb +5 -1
  38. data/lib/geodetic/path.rb +85 -153
  39. data/lib/geodetic/segment.rb +193 -0
  40. data/lib/geodetic/vector.rb +242 -0
  41. data/lib/geodetic/version.rb +1 -1
  42. data/lib/geodetic.rb +2 -0
  43. data/mkdocs.yml +1 -0
  44. metadata +14 -1
@@ -0,0 +1,256 @@
1
+ # Vector Reference
2
+
3
+ `Geodetic::Vector` represents a geodetic displacement — a combination of distance (magnitude) and bearing (direction). It is the fundamental type for expressing "how far and which way" between two points on Earth.
4
+
5
+ A Vector solves the **Vincenty direct problem**: given an origin point, a bearing, and a distance, compute the destination point on the WGS84 ellipsoid.
6
+
7
+ ---
8
+
9
+ ## Constructor
10
+
11
+ ```ruby
12
+ # From numeric values (meters and degrees)
13
+ v = Geodetic::Vector.new(distance: 10_000, bearing: 90.0)
14
+
15
+ # From Distance and Bearing objects
16
+ v = Geodetic::Vector.new(
17
+ distance: Geodetic::Distance.km(10),
18
+ bearing: Geodetic::Bearing.new(90.0)
19
+ )
20
+ ```
21
+
22
+ Numeric arguments are automatically coerced: `distance` wraps into `Distance.new(value)` and `bearing` wraps into `Bearing.new(value)`.
23
+
24
+ ---
25
+
26
+ ## Attributes
27
+
28
+ | Attribute | Type | Description |
29
+ |------------|----------|-------------|
30
+ | `distance` | Distance | The magnitude of the vector |
31
+ | `bearing` | Bearing | The direction of the vector (0-360 degrees from north) |
32
+
33
+ ---
34
+
35
+ ## Components
36
+
37
+ A Vector decomposes into north/east components in meters, treating bearing as an angle measured clockwise from north.
38
+
39
+ | Method | Returns | Description |
40
+ |--------|---------|-------------|
41
+ | `north` | Float | North component: `distance * cos(bearing)` |
42
+ | `east` | Float | East component: `distance * sin(bearing)` |
43
+
44
+ ```ruby
45
+ v = Geodetic::Vector.new(distance: 1000, bearing: 45.0)
46
+ v.north # => 707.107 (meters north)
47
+ v.east # => 707.107 (meters east)
48
+ ```
49
+
50
+ A due-north vector has a full north component and zero east component. A due-east vector has zero north and full east.
51
+
52
+ ---
53
+
54
+ ## Factory Methods
55
+
56
+ ### `Vector.from_components(north:, east:)`
57
+
58
+ Reconstruct a Vector from north/east components in meters.
59
+
60
+ ```ruby
61
+ v = Geodetic::Vector.from_components(north: 1000, east: 0)
62
+ v.bearing.degrees # => 0.0 (due north)
63
+ v.distance.meters # => 1000.0
64
+ ```
65
+
66
+ Near-zero magnitudes (< 1e-9 meters) snap to a clean zero vector to avoid floating-point artifacts.
67
+
68
+ ### `Vector.from_segment(segment)`
69
+
70
+ Create a Vector from an existing Segment's length and bearing.
71
+
72
+ ```ruby
73
+ seg = Geodetic::Segment.new(seattle, portland)
74
+ v = Geodetic::Vector.from_segment(seg)
75
+ v.distance.meters # => same as seg.length_meters
76
+ v.bearing.degrees # => same as seg.bearing.degrees
77
+ ```
78
+
79
+ Also available as `segment.to_vector`.
80
+
81
+ ---
82
+
83
+ ## Vincenty Direct
84
+
85
+ ### `destination_from(origin)`
86
+
87
+ Given an origin coordinate, compute the destination point by traveling the vector's distance along the vector's bearing. Uses the full Vincenty direct formula on the WGS84 ellipsoid.
88
+
89
+ ```ruby
90
+ v = Geodetic::Vector.new(distance: 100_000, bearing: 45.0)
91
+ dest = v.destination_from(seattle) # => LLA 100km northeast of Seattle
92
+ ```
93
+
94
+ Accepts any coordinate type — non-LLA inputs are converted automatically. A zero-distance vector returns the origin unchanged.
95
+
96
+ **Round-trip accuracy:**
97
+
98
+ ```ruby
99
+ v = Geodetic::Vector.new(distance: 50_000, bearing: 135.0)
100
+ dest = v.destination_from(origin)
101
+ origin.distance_to(dest).meters # => 50000.0 (within ~1 meter)
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Arithmetic
107
+
108
+ Vector arithmetic uses north/east component decomposition. The result is always a new Vector.
109
+
110
+ ### Vector + Vector
111
+
112
+ Component-wise addition. Combines two displacements into a single resultant vector.
113
+
114
+ ```ruby
115
+ north = Geodetic::Vector.new(distance: 1000, bearing: 0.0)
116
+ east = Geodetic::Vector.new(distance: 1000, bearing: 90.0)
117
+ result = north + east
118
+ result.bearing.degrees # => 45.0
119
+ result.distance.meters # => 1414.21
120
+ ```
121
+
122
+ ### Vector - Vector
123
+
124
+ Component-wise subtraction.
125
+
126
+ ```ruby
127
+ v1 = Geodetic::Vector.new(distance: 1000, bearing: 45.0)
128
+ result = v1 - v1
129
+ result.zero? # => true
130
+ ```
131
+
132
+ ### Vector * Scalar
133
+
134
+ Scales the distance. Negative scalars reverse the bearing.
135
+
136
+ ```ruby
137
+ v = Geodetic::Vector.new(distance: 1000, bearing: 45.0)
138
+ (v * 3).distance.meters # => 3000.0, bearing 45
139
+ (v * -1).bearing.degrees # => 225.0 (reversed)
140
+ ```
141
+
142
+ `Scalar * Vector` also works via `coerce`:
143
+
144
+ ```ruby
145
+ 2 * v # => Vector with double the distance
146
+ ```
147
+
148
+ ### Vector / Scalar
149
+
150
+ Scales the distance down. Raises `ZeroDivisionError` for zero. Negative divisors reverse the bearing.
151
+
152
+ ```ruby
153
+ v = Geodetic::Vector.new(distance: 3000, bearing: 90.0)
154
+ (v / 3).distance.meters # => 1000.0
155
+ ```
156
+
157
+ ### Unary Minus
158
+
159
+ Same distance, reversed bearing.
160
+
161
+ ```ruby
162
+ v = Geodetic::Vector.new(distance: 1000, bearing: 45.0)
163
+ (-v).bearing.degrees # => 225.0
164
+ ```
165
+
166
+ ### Identity: V + (-V)
167
+
168
+ Opposite vectors cancel to zero:
169
+
170
+ ```ruby
171
+ v = Geodetic::Vector.new(distance: 1000, bearing: 45.0)
172
+ result = v + (-v)
173
+ result.zero? # => true
174
+ ```
175
+
176
+ ---
177
+
178
+ ## Products
179
+
180
+ ### `dot(other)`
181
+
182
+ Scalar dot product using north/east components. Returns a Float.
183
+
184
+ ```ruby
185
+ north = Geodetic::Vector.new(distance: 100, bearing: 0.0)
186
+ east = Geodetic::Vector.new(distance: 100, bearing: 90.0)
187
+
188
+ north.dot(north) # => 10000.0 (parallel)
189
+ north.dot(east) # => 0.0 (perpendicular)
190
+ ```
191
+
192
+ ### `cross(other)`
193
+
194
+ 2D cross product (scalar). Returns a Float. Positive means `other` is to the left of `self`.
195
+
196
+ ```ruby
197
+ north.cross(east) # => 10000.0
198
+ east.cross(north) # => -10000.0
199
+ ```
200
+
201
+ ### `angle_between(other)`
202
+
203
+ Returns a Bearing representing the angular difference.
204
+
205
+ ```ruby
206
+ north = Geodetic::Vector.new(distance: 100, bearing: 0.0)
207
+ east = Geodetic::Vector.new(distance: 100, bearing: 90.0)
208
+ north.angle_between(east).degrees # => 90.0
209
+ ```
210
+
211
+ ---
212
+
213
+ ## Properties
214
+
215
+ | Method | Returns | Description |
216
+ |-------------|----------|-------------|
217
+ | `magnitude` | Float | Distance in meters |
218
+ | `zero?` | Boolean | True if distance is zero |
219
+ | `normalize` | Vector | Unit vector (1 meter) in the same bearing |
220
+ | `reverse` | Vector | Same distance, opposite bearing (alias for `-self`) |
221
+ | `inverse` | Vector | Alias for `reverse` |
222
+
223
+ ```ruby
224
+ v = Geodetic::Vector.new(distance: 5000, bearing: 135.0)
225
+ v.magnitude # => 5000.0
226
+ v.normalize.magnitude # => 1.0
227
+ v.reverse.bearing.degrees # => 315.0
228
+ ```
229
+
230
+ ---
231
+
232
+ ## Comparison
233
+
234
+ Vectors include `Comparable`, ordered by distance (magnitude). Two vectors are equal (`==`) only if both distance and bearing match.
235
+
236
+ ```ruby
237
+ short = Geodetic::Vector.new(distance: 100, bearing: 0.0)
238
+ long = Geodetic::Vector.new(distance: 200, bearing: 0.0)
239
+ short < long # => true
240
+
241
+ # Same magnitude, different bearing
242
+ a = Geodetic::Vector.new(distance: 100, bearing: 0.0)
243
+ b = Geodetic::Vector.new(distance: 100, bearing: 90.0)
244
+ (a <=> b) == 0 # => true (same magnitude)
245
+ a == b # => false (different bearing)
246
+ ```
247
+
248
+ ---
249
+
250
+ ## Display
251
+
252
+ ```ruby
253
+ v = Geodetic::Vector.new(distance: 1000, bearing: 90.0)
254
+ v.to_s # => "Vector(1000.00 m, 90.0000°)"
255
+ v.inspect # => "#<Geodetic::Vector distance=#<Geodetic::Distance ...> bearing=#<Geodetic::Bearing ...>>"
256
+ ```
@@ -253,13 +253,13 @@ def demo_coordinate_systems
253
253
  puts " Lat/Lng error is from MGRS 1-meter grid precision truncation."
254
254
  puts
255
255
 
256
- # ========== Rectangle Area ==========
257
- puts "RECTANGLE AREA"
256
+ # ========== BoundingBox Area ==========
257
+ puts "BOUNDING BOX AREA"
258
258
  puts "-" * 50
259
259
 
260
260
  nw = LLA.new(lat: 47.65, lng: -122.40)
261
261
  se = LLA.new(lat: 47.60, lng: -122.30)
262
- rect = Areas::Rectangle.new(nw: nw, se: se)
262
+ rect = Areas::BoundingBox.new(nw: nw, se: se)
263
263
  puts " NW: (#{rect.nw.lat}, #{rect.nw.lng})"
264
264
  puts " SE: (#{rect.se.lat}, #{rect.se.lng})"
265
265
  puts " NE: (#{rect.ne.lat}, #{rect.ne.lng})"
@@ -268,10 +268,10 @@ def demo_coordinate_systems
268
268
  puts " Space Needle inside? #{rect.includes?(lla_coord)}"
269
269
  puts " London inside? #{rect.includes?(london_lla)}"
270
270
 
271
- # Rectangle from non-LLA coordinates
271
+ # BoundingBox from non-LLA coordinates
272
272
  nw_wm = WebMerc.from_lla(nw)
273
273
  se_wm = WebMerc.from_lla(se)
274
- rect_wm = Areas::Rectangle.new(nw: nw_wm, se: se_wm)
274
+ rect_wm = Areas::BoundingBox.new(nw: nw_wm, se: se_wm)
275
275
  puts " From WebMercator: NW=(#{rect_wm.nw.lat.round(4)}, #{rect_wm.nw.lng.round(4)})"
276
276
  puts
277
277
 
@@ -292,7 +292,7 @@ def demo_coordinate_systems
292
292
  puts "Geohash-36 (GH36)"
293
293
  puts "Geoid Height Support"
294
294
  puts
295
- puts "Areas: Circle, Polygon, Rectangle"
295
+ puts "Areas: Circle, Polygon, BoundingBox"
296
296
  puts
297
297
  puts "All coordinate systems support complete bidirectional conversions!"
298
298
  puts "Total coordinate systems implemented: 13"
@@ -59,10 +59,8 @@ puts
59
59
 
60
60
  puts "--- 3. Segment Analysis ---"
61
61
 
62
- route.segments.each_with_index do |(a, b), i|
63
- dist = a.distance_to(b)
64
- bearing = a.bearing_to(b)
65
- puts " Segment #{i + 1}: #{dist.to_km} #{bearing.to_compass(points: 8)} (#{bearing.to_s(1)})"
62
+ route.segments.each_with_index do |seg, i|
63
+ puts " Segment #{i + 1}: #{seg.length.to_km} #{seg.bearing.to_compass(points: 8)} (#{seg.bearing.to_s(1)})"
66
64
  end
67
65
 
68
66
  puts <<~TOTAL
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Demonstration of Segment and polygon subclasses (Triangle, Rectangle,
4
+ # Pentagon, Hexagon, Octagon). Shows construction, properties, predicates,
5
+ # containment testing, segment access, bounding boxes, and Feature integration.
6
+
7
+ require_relative "../lib/geodetic"
8
+
9
+ include Geodetic
10
+ LLA = Coordinate::LLA
11
+
12
+ # ── Notable locations around the National Mall, Washington DC ─────
13
+
14
+ lincoln = LLA.new(lat: 38.8893, lng: -77.0502, alt: 0)
15
+ washington = LLA.new(lat: 38.8895, lng: -77.0353, alt: 0)
16
+ capitol = LLA.new(lat: 38.8899, lng: -77.0091, alt: 0)
17
+ white_house = LLA.new(lat: 38.8977, lng: -77.0365, alt: 0)
18
+ jefferson = LLA.new(lat: 38.8814, lng: -77.0365, alt: 0)
19
+ pentagon = LLA.new(lat: 38.8719, lng: -77.0563, alt: 0)
20
+
21
+ puts "=== Segments and Shapes Demo ==="
22
+ puts
23
+
24
+ # ── 1. Segment Basics ────────────────────────────────────────────
25
+
26
+ puts "--- 1. Segment ---"
27
+ puts
28
+
29
+ seg = Segment.new(lincoln, washington)
30
+
31
+ puts <<~SEG
32
+ Lincoln Memorial -> Washington Monument
33
+ Length: #{seg.length.to_s} (#{seg.length_meters.round(1)} m)
34
+ Distance: #{seg.distance.to_s} (alias for length)
35
+ Bearing: #{seg.bearing.to_s} (#{seg.bearing.to_compass})
36
+ Midpoint: #{seg.midpoint.to_s(4)}
37
+ Centroid: #{seg.centroid.to_s(4)} (alias for midpoint)
38
+ SEG
39
+ puts
40
+
41
+ # ── 2. Segment Operations ───────────────────────────────────────
42
+
43
+ puts "--- 2. Segment Operations ---"
44
+ puts
45
+
46
+ quarter = seg.interpolate(0.25)
47
+ puts " Quarter-way point: #{quarter.to_s(4)}"
48
+ puts " Reverse bearing: #{seg.reverse.bearing.to_s}"
49
+ puts
50
+
51
+ foot, dist_m = seg.project(white_house)
52
+ puts " Project White House onto Lincoln-Washington segment:"
53
+ puts " Foot: #{foot.to_s(4)}"
54
+ puts " Distance: #{dist_m.round(1)} m from segment"
55
+ puts
56
+
57
+ puts " White House is a vertex? #{seg.includes?(white_house)}"
58
+ puts " Lincoln is a vertex? #{seg.includes?(lincoln)}"
59
+ puts " Midpoint on segment? #{seg.contains?(seg.midpoint)}"
60
+ puts " White House on segment? #{seg.contains?(white_house)}"
61
+ puts
62
+
63
+ # ── 3. Segment Intersection ─────────────────────────────────────
64
+
65
+ puts "--- 3. Segment Intersection ---"
66
+ puts
67
+
68
+ mall_ns = Segment.new(white_house, jefferson)
69
+ mall_ew = Segment.new(lincoln, capitol)
70
+
71
+ puts " N-S segment: White House -> Jefferson Memorial"
72
+ puts " E-W segment: Lincoln Memorial -> Capitol"
73
+ puts " Intersects? #{mall_ew.intersects?(mall_ns)}"
74
+ puts
75
+
76
+ parallel = Segment.new(
77
+ LLA.new(lat: 38.892, lng: -77.050, alt: 0),
78
+ LLA.new(lat: 38.892, lng: -77.010, alt: 0)
79
+ )
80
+ puts " Parallel segment (north of Mall):"
81
+ puts " Intersects E-W? #{mall_ew.intersects?(parallel)}"
82
+ puts
83
+
84
+ # ── 4. Triangle ──────────────────────────────────────────────────
85
+
86
+ puts "--- 4. Triangle ---"
87
+ puts
88
+
89
+ puts " Isosceles (center + width + height):"
90
+ tri = Areas::Triangle.new(center: washington, width: 400, height: 600, bearing: 0)
91
+ puts " Sides: #{tri.sides}"
92
+ puts " Width: #{tri.width} m"
93
+ puts " Height: #{tri.height} m"
94
+ puts " Bearing: #{tri.bearing}\u00B0"
95
+ puts " Equilateral? #{tri.equilateral?}"
96
+ puts " Isosceles? #{tri.isosceles?}"
97
+ puts " Scalene? #{tri.scalene?}"
98
+ puts " Side lengths: #{tri.side_lengths.map { |l| "#{l.round(1)} m" }.join(', ')}"
99
+ puts " Center inside? #{tri.includes?(washington)}"
100
+ puts
101
+
102
+ puts " Equilateral by side (500m):"
103
+ eq_tri = Areas::Triangle.new(center: washington, side: 500, bearing: 30)
104
+ puts " Equilateral? #{eq_tri.equilateral?}"
105
+ puts " Side lengths: #{eq_tri.side_lengths.map { |l| "#{l.round(1)} m" }.join(', ')}"
106
+ puts
107
+
108
+ puts " Equilateral by radius (300m):"
109
+ r_tri = Areas::Triangle.new(center: washington, radius: 300, bearing: 0)
110
+ puts " Equilateral? #{r_tri.equilateral?}"
111
+ puts " Width: #{r_tri.width.round(1)} m"
112
+ puts " Height: #{r_tri.height.round(1)} m"
113
+ puts
114
+
115
+ puts " Arbitrary 3 vertices:"
116
+ arb_tri = Areas::Triangle.new(vertices: [lincoln, washington, white_house])
117
+ puts " Scalene? #{arb_tri.scalene?}"
118
+ puts " Side lengths: #{arb_tri.side_lengths.map { |l| "#{l.round(1)} m" }.join(', ')}"
119
+ puts " Washington inside? #{arb_tri.includes?(washington)}"
120
+ puts
121
+
122
+ # ── 5. Rectangle ────────────────────────────────────────────────
123
+
124
+ puts "--- 5. Rectangle ---"
125
+ puts
126
+
127
+ puts " From a Segment (Lincoln -> Washington) + width:"
128
+ centerline = Segment.new(lincoln, washington)
129
+ rect = Areas::Rectangle.new(segment: centerline, width: 200)
130
+
131
+ puts " Width: #{rect.width} m"
132
+ puts " Height: #{rect.height.round(1)} m"
133
+ puts " Bearing: #{rect.bearing.round(1)}\u00B0"
134
+ puts " Center: #{rect.center.to_s(4)}"
135
+ puts " Square? #{rect.square?}"
136
+ puts " Sides: #{rect.sides}"
137
+ puts " Corners: #{rect.corners.size}"
138
+ puts " Centerline start: #{rect.centerline.start_point.to_s(4)}"
139
+ puts " Centerline end: #{rect.centerline.end_point.to_s(4)}"
140
+ puts
141
+
142
+ puts " From an array of two points + width:"
143
+ rect2 = Areas::Rectangle.new(segment: [white_house, jefferson], width: 300)
144
+ puts " Width: #{rect2.width} m"
145
+ puts " Height: #{rect2.height.round(1)} m"
146
+ puts " Bearing: #{rect2.bearing.round(1)}\u00B0"
147
+ puts " Square? #{rect2.square?}"
148
+ puts
149
+
150
+ puts " Square rectangle (width = segment distance):"
151
+ short_seg = Segment.new(lincoln, seg.interpolate(0.1))
152
+ sq = Areas::Rectangle.new(segment: short_seg, width: short_seg.distance)
153
+ puts " Width: #{sq.width.round(1)} m"
154
+ puts " Height: #{sq.height.round(1)} m"
155
+ puts " Square? #{sq.square?}"
156
+ puts
157
+
158
+ # ── 6. Pentagon, Hexagon, Octagon ────────────────────────────────
159
+
160
+ puts "--- 6. Regular Polygons ---"
161
+ puts
162
+
163
+ [
164
+ ["Pentagon", Areas::Pentagon.new(center: washington, radius: 500, bearing: 0)],
165
+ ["Hexagon", Areas::Hexagon.new(center: washington, radius: 500, bearing: 0)],
166
+ ["Octagon", Areas::Octagon.new(center: washington, radius: 500, bearing: 0)]
167
+ ].each do |name, shape|
168
+ puts " #{name}:"
169
+ puts " Sides: #{shape.sides}"
170
+ puts " Radius: #{shape.radius} m"
171
+ puts " Vertices: #{shape.boundary.size - 1} (boundary has #{shape.boundary.size} points, closed)"
172
+ puts " Centroid: #{shape.centroid.to_s(4)}"
173
+ puts " Center inside? #{shape.includes?(washington)}"
174
+ puts " Pentagon inside? #{shape.includes?(pentagon)}"
175
+ puts
176
+ end
177
+
178
+ # ── 7. Polygon Segments ─────────────────────────────────────────
179
+
180
+ puts "--- 7. Polygon Segments ---"
181
+ puts
182
+
183
+ puts " Rectangle segments (edges/border):"
184
+ rect.segments.each_with_index do |s, i|
185
+ puts " Edge #{i + 1}: #{s.length_meters.round(1)} m, bearing #{s.bearing.to_s}"
186
+ end
187
+ puts " Connected? #{rect.segments.each_cons(2).all? { |a, b| a.end_point == b.start_point }}"
188
+ puts
189
+
190
+ puts " Triangle segments:"
191
+ tri.segments.each_with_index do |s, i|
192
+ puts " Side #{i + 1}: #{s.length_meters.round(1)} m"
193
+ end
194
+ puts
195
+
196
+ # ── 8. Bounding Boxes ───────────────────────────────────────────
197
+
198
+ puts "--- 8. Bounding Boxes ---"
199
+ puts
200
+
201
+ [
202
+ ["Triangle", tri],
203
+ ["Rectangle", rect]
204
+ ].each do |name, shape|
205
+ bbox = shape.to_bounding_box
206
+ puts " #{name}:"
207
+ puts " NW: #{bbox.nw.to_s(4)}"
208
+ puts " SE: #{bbox.se.to_s(4)}"
209
+
210
+ shape_center = shape.respond_to?(:center) ? shape.center : shape.centroid
211
+ puts " Center inside bbox? #{bbox.includes?(shape_center)}"
212
+ puts
213
+ end
214
+
215
+ # ── 9. Containment ──────────────────────────────────────────────
216
+
217
+ puts "--- 9. Containment Testing ---"
218
+ puts
219
+
220
+ hex = Areas::Hexagon.new(center: washington, radius: 500)
221
+
222
+ test_points = {
223
+ "Washington Monument" => washington,
224
+ "Lincoln Memorial" => lincoln,
225
+ "White House" => white_house,
226
+ "Pentagon" => pentagon
227
+ }
228
+
229
+ puts " Hexagon (500m radius around Washington Monument):"
230
+ test_points.each do |name, point|
231
+ dist = washington.distance_to(point)
232
+ puts " #{name.ljust(22)} inside? #{hex.includes?(point).to_s.ljust(5)} (#{dist.to_s} away)"
233
+ end
234
+ puts
235
+
236
+ # ── 10. Feature Integration ─────────────────────────────────────
237
+
238
+ puts "--- 10. Feature Integration ---"
239
+ puts
240
+
241
+ seg_feature = Feature.new(label: "National Mall Axis", geometry: centerline)
242
+ tri_feature = Feature.new(label: "Warning Zone", geometry: tri, metadata: { type: "restricted" })
243
+ rect_feature = Feature.new(label: "Search Area", geometry: rect, metadata: { priority: "high" })
244
+
245
+ puts " Segment feature: #{seg_feature}"
246
+ puts " Triangle feature: #{tri_feature}"
247
+ puts " Rectangle feature: #{rect_feature}"
248
+ puts
249
+
250
+ puts " Distance from each feature to the Pentagon:"
251
+ [seg_feature, tri_feature, rect_feature].each do |f|
252
+ dist = f.distance_to(pentagon)
253
+ bearing = f.bearing_to(pentagon)
254
+ puts " #{f.label.ljust(20)} #{dist.to_s.rjust(12)} bearing #{bearing.to_s} (#{bearing.to_compass})"
255
+ end
256
+ puts
257
+
258
+ puts "=== Done ==="