nadata 0.1.0__py3-none-any.whl
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.
- nadata/__init__.py +15 -0
- nadata/api.py +894 -0
- nadata/cli.py +445 -0
- nadata/console.py +486 -0
- nadata/core/__init__.py +0 -0
- nadata/core/database/__init__.py +38 -0
- nadata/core/database/builtins.py +87 -0
- nadata/core/database/database.py +958 -0
- nadata/core/database/dumper.py +285 -0
- nadata/core/database/entity_id.py +142 -0
- nadata/core/database/instance.py +706 -0
- nadata/core/database/network_index.py +1142 -0
- nadata/core/database/page_manager.py +175 -0
- nadata/core/database/persistence/__init__.py +28 -0
- nadata/core/database/persistence/binary_cells.py +1141 -0
- nadata/core/database/persistence/compaction.py +97 -0
- nadata/core/database/persistence/dumper.py +798 -0
- nadata/core/database/persistence/lazy_instances.py +1537 -0
- nadata/core/database/persistence/loader.py +424 -0
- nadata/core/database/persistence/record_store.py +228 -0
- nadata/core/database/persistence/serialization.py +605 -0
- nadata/core/database/query_engine.py +222 -0
- nadata/core/database/queryset.py +508 -0
- nadata/core/database/reconciliation.py +287 -0
- nadata/core/database/slotted_page.py +190 -0
- nadata/core/database/spatial_index.py +1080 -0
- nadata/core/expressions/__init__.py +44 -0
- nadata/core/expressions/autograd.py +2909 -0
- nadata/core/expressions/builtins.py +4673 -0
- nadata/core/expressions/expression.py +2658 -0
- nadata/core/expressions/io_builtins.py +972 -0
- nadata/core/expressions/scope.py +336 -0
- nadata/core/expressions/statement.py +953 -0
- nadata/core/struct/__init__.py +14 -0
- nadata/core/struct/struct.py +218 -0
- nadata/core/struct/struct_attribute.py +100 -0
- nadata/core/struct/struct_method.py +324 -0
- nadata/core/types/__init__.py +33 -0
- nadata/core/types/bits/__init__.py +0 -0
- nadata/core/types/bits/bits.py +686 -0
- nadata/core/types/home_types/__init__.py +19 -0
- nadata/core/types/home_types/color.py +102 -0
- nadata/core/types/home_types/coordinate.py +1008 -0
- nadata/core/types/home_types/datetime.py +362 -0
- nadata/core/types/home_types/geogrid.py +92 -0
- nadata/core/types/home_types/geometry.py +1223 -0
- nadata/core/types/home_types/graph.py +0 -0
- nadata/core/types/home_types/planet.py +359 -0
- nadata/core/types/home_types/sea_level.py +98 -0
- nadata/core/types/home_types/vec.py +0 -0
- nadata/core/types/types.py +1345 -0
- nadata/core/values/__init__.py +14 -0
- nadata/core/values/values.py +1568 -0
- nadata/parser/__init__.py +0 -0
- nadata/parser/expressions/__init__.py +6 -0
- nadata/parser/expressions/dependencies.py +318 -0
- nadata/parser/expressions/expression_parser.py +993 -0
- nadata/parser/expressions/splitter.py +98 -0
- nadata/parser/expressions/statement_parser.py +1425 -0
- nadata/parser/expressions/tokenizer.py +402 -0
- nadata/parser/expressions/type_parser.py +187 -0
- nadata/parser/nadata_loader/__init__.py +8 -0
- nadata/parser/nadata_loader/loader.py +951 -0
- nadata/parser/nastruct_parser/__init__.py +0 -0
- nadata/parser/nastruct_parser/declaration.py +105 -0
- nadata/parser/nastruct_parser/imports.py +164 -0
- nadata/parser/nastruct_parser/nastruct_parser.py +209 -0
- nadata/parser/nastruct_parser/parse_attributes.py +149 -0
- nadata/parser/nastruct_parser/parse_callables.py +436 -0
- nadata/parser/nastruct_parser/parse_expression.py +59 -0
- nadata/parser/nastruct_parser/parse_structs.py +206 -0
- nadata/parser/nastruct_parser/parser_state.py +84 -0
- nadata/parser/nastruct_parser/resolver.py +1412 -0
- nadata/parser/nastruct_parser/struct_body_state.py +50 -0
- nadata/parser/nastruct_parser/tmp_structs.py +156 -0
- nadata/parser/utils/__init__.py +20 -0
- nadata/parser/utils/brackets.py +252 -0
- nadata/parser/utils/comments_remove.py +149 -0
- nadata/parser/utils/identifiers.py +121 -0
- nadata/session.py +868 -0
- nadata-0.1.0.dist-info/METADATA +191 -0
- nadata-0.1.0.dist-info/RECORD +86 -0
- nadata-0.1.0.dist-info/WHEEL +5 -0
- nadata-0.1.0.dist-info/entry_points.txt +2 -0
- nadata-0.1.0.dist-info/licenses/LICENSE +21 -0
- nadata-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Geometry primitives for Nadata.
|
|
3
|
+
|
|
4
|
+
Provides the Polyline and Polygon value types over Coordinates, with
|
|
5
|
+
the measurement and predicate layer of the map plan (Phase B):
|
|
6
|
+
|
|
7
|
+
- Polyline: geodesic length, point lookup by distance or fraction,
|
|
8
|
+
distance to the path.
|
|
9
|
+
- Polygon: spherical-excess area, geodesic perimeter, vertex centroid,
|
|
10
|
+
antimeridian-safe point-in-polygon (planar even-odd test on an
|
|
11
|
+
unwrapped equirectangular projection), distance to the boundary, and
|
|
12
|
+
the inradius / circumradius fast-path radii used by the Phase C
|
|
13
|
+
spatial index.
|
|
14
|
+
|
|
15
|
+
Sphere math is closed-form; spheroid math uses Vincenty per edge and
|
|
16
|
+
sphere formulas on authalic / quadratic-mean radii (documented
|
|
17
|
+
approximations). Shape constructor sugar (circle, ellipse, regular
|
|
18
|
+
polygon) produces Polygons.
|
|
19
|
+
|
|
20
|
+
Free functions in this module receive raw payloads from the evaluator
|
|
21
|
+
(home_types module convention); method-style members on the types are
|
|
22
|
+
the primary API and delegate to the same logic.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# Import Modules
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
import math
|
|
30
|
+
|
|
31
|
+
from nadata.core.types.home_types.coordinate import (
|
|
32
|
+
Coordinate,
|
|
33
|
+
_from_unit_vector,
|
|
34
|
+
_to_unit_vector,
|
|
35
|
+
normalize_lon,
|
|
36
|
+
wrap_lon_delta,
|
|
37
|
+
)
|
|
38
|
+
from nadata.core.types.home_types.planet import Planet, get_current_planet
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_coordinate_list(name: str, vertices: Any) -> list[Coordinate]:
|
|
43
|
+
"""
|
|
44
|
+
Validates a vertex list argument.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
name (str): Argument name for error messages.
|
|
48
|
+
vertices (Any): Candidate vertex list.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
list[Coordinate]: The validated vertex list.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
TypeError: If the argument is not a list of Coordinates.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
if isinstance(vertices, tuple):
|
|
58
|
+
vertices = list(vertices)
|
|
59
|
+
|
|
60
|
+
if not isinstance(vertices, list):
|
|
61
|
+
raise TypeError(
|
|
62
|
+
f"{name} must be a list of Coordinate, got '{type(vertices).__name__}'"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
for index, vertex in enumerate(vertices):
|
|
66
|
+
if not isinstance(vertex, Coordinate):
|
|
67
|
+
raise TypeError(
|
|
68
|
+
f"{name}[{index}] must be a Coordinate, "
|
|
69
|
+
f"got '{type(vertex).__name__}'"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return vertices
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _unit_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
|
|
76
|
+
"""
|
|
77
|
+
Computes the cross product of two unit vectors.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
a (tuple[float, float, float]): First unit vector.
|
|
81
|
+
b (tuple[float, float, float]): Second unit vector.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
tuple[float, float, float]: The cross product.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
a[1] * b[2] - a[2] * b[1],
|
|
89
|
+
a[2] * b[0] - a[0] * b[2],
|
|
90
|
+
a[0] * b[1] - a[1] * b[0],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _unit_dot(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
|
|
95
|
+
"""
|
|
96
|
+
Computes the dot product of two vectors.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
a (tuple[float, float, float]): First vector.
|
|
100
|
+
b (tuple[float, float, float]): Second vector.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
float: The dot product.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _central_angle(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
|
|
110
|
+
"""
|
|
111
|
+
Computes the angle between two unit vectors.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
a (tuple[float, float, float]): First unit vector.
|
|
115
|
+
b (tuple[float, float, float]): Second unit vector.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
float: Angle in radians, in [0, pi].
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
cross: tuple[float, float, float] = _unit_cross(a=a, b=b)
|
|
122
|
+
|
|
123
|
+
return math.atan2(
|
|
124
|
+
math.sqrt(cross[0] ** 2 + cross[1] ** 2 + cross[2] ** 2),
|
|
125
|
+
_unit_dot(a=a, b=b),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _resolve_planet(planet: Optional[Planet]) -> Planet:
|
|
130
|
+
"""
|
|
131
|
+
Resolves the effective planet for a computation.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
planet (Optional[Planet]): Explicit override, or None to use
|
|
135
|
+
the session current planet.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Planet: The planet to compute against.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
return planet if planet is not None else get_current_planet()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _unwrapped_lons(vertices: list[Coordinate]) -> list[float]:
|
|
145
|
+
"""
|
|
146
|
+
Unwraps vertex longitudes into a contiguous sequence (consecutive
|
|
147
|
+
deltas within 180 degrees), so polygons crossing the antimeridian
|
|
148
|
+
keep their true shape.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
vertices (list[Coordinate]): The polygon or polyline vertices.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
list[float]: Contiguous longitudes in degrees.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
if not vertices:
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
lons: list[float] = [vertices[0].lon]
|
|
161
|
+
|
|
162
|
+
for vertex in vertices[1:]:
|
|
163
|
+
lons.append(lons[-1] + wrap_lon_delta(lon_delta_deg=vertex.lon - lons[-1]))
|
|
164
|
+
|
|
165
|
+
return lons
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _edge_angle(a: Coordinate, b: Coordinate) -> float:
|
|
169
|
+
"""
|
|
170
|
+
Computes the angular separation of two coordinates on the unit
|
|
171
|
+
sphere (their geodetic directions treated as unit vectors).
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
a (Coordinate): First coordinate.
|
|
175
|
+
b (Coordinate): Second coordinate.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
float: Angle in radians.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
return _central_angle(
|
|
182
|
+
a=_to_unit_vector(lat_deg=a.lat, lon_deg=a.lon),
|
|
183
|
+
b=_to_unit_vector(lat_deg=b.lat, lon_deg=b.lon),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _edge_length(a: Coordinate, b: Coordinate, planet: Planet) -> float:
|
|
188
|
+
"""
|
|
189
|
+
Computes the geodesic length of one edge.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
a (Coordinate): First endpoint.
|
|
193
|
+
b (Coordinate): Second endpoint.
|
|
194
|
+
planet (Planet): Effective planet.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
float: Edge length in meters.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
if planet.is_sphere:
|
|
201
|
+
return _edge_angle(a=a, b=b) * planet.equatorial_radius
|
|
202
|
+
|
|
203
|
+
return a.distance(other=b, planet=planet)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _segment_distance(
|
|
207
|
+
point: Coordinate,
|
|
208
|
+
a: Coordinate,
|
|
209
|
+
b: Coordinate,
|
|
210
|
+
) -> float:
|
|
211
|
+
"""
|
|
212
|
+
Computes the angular distance from a point to the minor great-
|
|
213
|
+
circle arc between two coordinates, on the unit sphere.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
point (Coordinate): The query point.
|
|
217
|
+
a (Coordinate): Segment start.
|
|
218
|
+
b (Coordinate): Segment end.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
float: Angular distance in radians.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
p: tuple[float, float, float] = _to_unit_vector(lat_deg=point.lat, lon_deg=point.lon)
|
|
225
|
+
u: tuple[float, float, float] = _to_unit_vector(lat_deg=a.lat, lon_deg=a.lon)
|
|
226
|
+
v: tuple[float, float, float] = _to_unit_vector(lat_deg=b.lat, lon_deg=b.lon)
|
|
227
|
+
|
|
228
|
+
# Fall back to endpoints for degenerate or antipodal segments
|
|
229
|
+
angle_ab: float = _central_angle(a=u, b=v)
|
|
230
|
+
|
|
231
|
+
if angle_ab < 1e-15 or abs(angle_ab - math.pi) < 1e-9:
|
|
232
|
+
return min(
|
|
233
|
+
_central_angle(a=p, b=u),
|
|
234
|
+
_central_angle(a=p, b=v),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
normal: tuple[float, float, float] = _unit_cross(a=u, b=v)
|
|
238
|
+
normal_norm: float = math.sqrt(_unit_dot(a=normal, b=normal))
|
|
239
|
+
normal = (normal[0] / normal_norm, normal[1] / normal_norm, normal[2] / normal_norm)
|
|
240
|
+
|
|
241
|
+
# Project the point onto the great-circle plane
|
|
242
|
+
dot_pn: float = _unit_dot(a=p, b=normal)
|
|
243
|
+
projected: tuple[float, float, float] = (
|
|
244
|
+
p[0] - dot_pn * normal[0],
|
|
245
|
+
p[1] - dot_pn * normal[1],
|
|
246
|
+
p[2] - dot_pn * normal[2],
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
projected_norm: float = math.sqrt(_unit_dot(a=projected, b=projected))
|
|
250
|
+
|
|
251
|
+
if projected_norm < 1e-15:
|
|
252
|
+
# The point sits on a pole of the great circle: nearest endpoint
|
|
253
|
+
return min(
|
|
254
|
+
_central_angle(a=p, b=u),
|
|
255
|
+
_central_angle(a=p, b=v),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
projected = (
|
|
259
|
+
projected[0] / projected_norm,
|
|
260
|
+
projected[1] / projected_norm,
|
|
261
|
+
projected[2] / projected_norm,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Inside the minor arc when closer to both endpoints than they are
|
|
265
|
+
# to each other
|
|
266
|
+
angle_ap: float = _central_angle(a=u, b=projected)
|
|
267
|
+
angle_bp: float = _central_angle(a=v, b=projected)
|
|
268
|
+
|
|
269
|
+
if angle_ap <= angle_ab + 1e-12 and angle_bp <= angle_ab + 1e-12:
|
|
270
|
+
return _central_angle(a=p, b=projected)
|
|
271
|
+
|
|
272
|
+
return min(
|
|
273
|
+
_central_angle(a=p, b=u),
|
|
274
|
+
_central_angle(a=p, b=v),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _segment_distance_meters(
|
|
279
|
+
point: Coordinate,
|
|
280
|
+
a: Coordinate,
|
|
281
|
+
b: Coordinate,
|
|
282
|
+
planet: Planet,
|
|
283
|
+
) -> float:
|
|
284
|
+
"""
|
|
285
|
+
Computes the distance from a point to a boundary segment.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
point (Coordinate): The query point.
|
|
289
|
+
a (Coordinate): Segment start.
|
|
290
|
+
b (Coordinate): Segment end.
|
|
291
|
+
planet (Planet): Effective planet.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
float: Distance in meters (spheroid: sphere approximation on
|
|
295
|
+
the quadratic mean radius).
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
radius: float = (
|
|
299
|
+
planet.equatorial_radius
|
|
300
|
+
if planet.is_sphere
|
|
301
|
+
else planet.quadratic_mean_radius
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return _segment_distance(point=point, a=a, b=b) * radius
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _point_in_ring(lon: float, lat: float, lons: list[float], lats: list[float]) -> bool:
|
|
308
|
+
"""
|
|
309
|
+
Planar even-odd point-in-ring test on the equirectangular plane.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
lon (float): Query longitude (already translated).
|
|
313
|
+
lat (float): Query latitude.
|
|
314
|
+
lons (list[float]): Ring longitudes (contiguous unwrap).
|
|
315
|
+
lats (list[float]): Ring latitudes.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
bool: True when the point is inside the ring.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
inside: bool = False
|
|
322
|
+
count: int = len(lons)
|
|
323
|
+
j: int = count - 1
|
|
324
|
+
|
|
325
|
+
for i in range(count):
|
|
326
|
+
yi: float = lats[i]
|
|
327
|
+
yj: float = lats[j]
|
|
328
|
+
|
|
329
|
+
if (yi > lat) != (yj > lat):
|
|
330
|
+
x_intersect: float = (
|
|
331
|
+
(lons[j] - lons[i]) * (lat - yi) / (yj - yi) + lons[i]
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if lon < x_intersect:
|
|
335
|
+
inside = not inside
|
|
336
|
+
|
|
337
|
+
j = i
|
|
338
|
+
|
|
339
|
+
return inside
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class Polyline:
|
|
344
|
+
"""
|
|
345
|
+
A path over the planet: an ordered sequence of Coordinates.
|
|
346
|
+
|
|
347
|
+
Attributes:
|
|
348
|
+
vertices (list[Coordinate]): The path vertices.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
def __init__(self, vertices: Any) -> None:
|
|
352
|
+
"""
|
|
353
|
+
Initializes the Polyline instance.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
vertices (Any): The path vertices (list of Coordinate).
|
|
357
|
+
|
|
358
|
+
Raises:
|
|
359
|
+
TypeError: If vertices is not a list of Coordinates.
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
self.vertices: list[Coordinate] = _require_coordinate_list(
|
|
363
|
+
name="Polyline vertices", vertices=vertices
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def _planet(self, planet: Optional[Planet]) -> Planet:
|
|
367
|
+
"""
|
|
368
|
+
Resolves the effective planet.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
planet (Optional[Planet]): Explicit override.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Planet: The planet to compute against.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
return _resolve_planet(planet=planet)
|
|
378
|
+
|
|
379
|
+
def num_vertices(self) -> int:
|
|
380
|
+
"""
|
|
381
|
+
Returns the vertex count.
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
int: Number of vertices.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
return len(self.vertices)
|
|
388
|
+
|
|
389
|
+
def vertex(self, index: int) -> Coordinate:
|
|
390
|
+
"""
|
|
391
|
+
Returns the vertex at an index (negative indices count from
|
|
392
|
+
the end).
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
index (int): Vertex index.
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
Coordinate: The vertex.
|
|
399
|
+
|
|
400
|
+
Raises:
|
|
401
|
+
IndexError: If the index is out of range.
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
if not isinstance(index, int) or isinstance(index, bool):
|
|
405
|
+
raise TypeError(f"Vertex index must be an integer, got '{type(index).__name__}'")
|
|
406
|
+
|
|
407
|
+
return self.vertices[index]
|
|
408
|
+
|
|
409
|
+
def length(self, planet: Optional[Planet] = None) -> float:
|
|
410
|
+
"""
|
|
411
|
+
Computes the geodesic length along the path.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
planet (Optional[Planet], optional): Explicit planet
|
|
415
|
+
override. Defaults to None.
|
|
416
|
+
center (Optional[Coordinate], optional): Explicit anchor
|
|
417
|
+
override. Defaults to the centroid.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
float: Length in meters.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
resolved: Planet = self._planet(planet=planet)
|
|
424
|
+
|
|
425
|
+
total: float = 0.0
|
|
426
|
+
|
|
427
|
+
for i in range(len(self.vertices) - 1):
|
|
428
|
+
total += _edge_length(
|
|
429
|
+
a=self.vertices[i],
|
|
430
|
+
b=self.vertices[i + 1],
|
|
431
|
+
planet=resolved,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
return total
|
|
435
|
+
|
|
436
|
+
def point_at_distance(
|
|
437
|
+
self,
|
|
438
|
+
distance_m: float,
|
|
439
|
+
planet: Optional[Planet] = None,
|
|
440
|
+
) -> Coordinate:
|
|
441
|
+
"""
|
|
442
|
+
Returns the point at a along-path distance (walking the
|
|
443
|
+
geodesic edges).
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
distance_m (float): Distance along the path in meters.
|
|
447
|
+
planet (Optional[Planet], optional): Explicit planet
|
|
448
|
+
override. Defaults to None.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
Coordinate: The point at the distance.
|
|
452
|
+
|
|
453
|
+
Raises:
|
|
454
|
+
ValueError: If the distance is negative or beyond the path
|
|
455
|
+
length, or the path is empty.
|
|
456
|
+
"""
|
|
457
|
+
|
|
458
|
+
resolved: Planet = self._planet(planet=planet)
|
|
459
|
+
|
|
460
|
+
if not self.vertices:
|
|
461
|
+
raise ValueError("Cannot locate a point on an empty Polyline")
|
|
462
|
+
|
|
463
|
+
if not isinstance(distance_m, (int, float)) or isinstance(distance_m, bool):
|
|
464
|
+
raise TypeError(
|
|
465
|
+
f"Distance must be a number, got '{type(distance_m).__name__}'"
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
remaining: float = float(distance_m)
|
|
469
|
+
|
|
470
|
+
if remaining < 0.0:
|
|
471
|
+
raise ValueError(f"Distance must be non-negative, got {remaining}")
|
|
472
|
+
|
|
473
|
+
if remaining > self.length(planet=planet):
|
|
474
|
+
raise ValueError(
|
|
475
|
+
f"Distance {distance_m} exceeds the path length "
|
|
476
|
+
f"{self.length(planet=planet)}"
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
last_index: int = len(self.vertices) - 2
|
|
480
|
+
|
|
481
|
+
for i in range(len(self.vertices) - 1):
|
|
482
|
+
edge: float = _edge_length(
|
|
483
|
+
a=self.vertices[i], b=self.vertices[i + 1], planet=resolved
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
if remaining <= edge or i == last_index:
|
|
487
|
+
# Clamp absorbs float drift at the path end
|
|
488
|
+
fraction: float = 0.0 if edge == 0.0 else min(
|
|
489
|
+
1.0, remaining / edge
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
return self.vertices[i].interpolate(
|
|
493
|
+
other=self.vertices[i + 1], t=fraction, planet=planet
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
remaining -= edge
|
|
497
|
+
|
|
498
|
+
raise ValueError("Cannot locate a point on an empty Polyline")
|
|
499
|
+
|
|
500
|
+
def point_at(self, t: float, planet: Optional[Planet] = None) -> Coordinate:
|
|
501
|
+
"""
|
|
502
|
+
Returns the point at a fraction of the total path length.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
t (float): Fraction in [0, 1].
|
|
506
|
+
planet (Optional[Planet], optional): Explicit planet
|
|
507
|
+
override. Defaults to None.
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
Coordinate: The point at the fraction.
|
|
511
|
+
|
|
512
|
+
Raises:
|
|
513
|
+
ValueError: If t is outside [0, 1] or the path is empty.
|
|
514
|
+
"""
|
|
515
|
+
|
|
516
|
+
if not isinstance(t, (int, float)) or isinstance(t, bool):
|
|
517
|
+
raise TypeError(f"t must be a number, got '{type(t).__name__}'")
|
|
518
|
+
|
|
519
|
+
if not 0.0 <= float(t) <= 1.0:
|
|
520
|
+
raise ValueError(f"t must be in [0, 1], got {t}")
|
|
521
|
+
|
|
522
|
+
return self.point_at_distance(
|
|
523
|
+
distance_m=float(t) * self.length(planet=planet), planet=planet
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
def distance_to(self, point: Coordinate, planet: Optional[Planet] = None) -> float:
|
|
527
|
+
"""
|
|
528
|
+
Computes the distance from a coordinate to the nearest point
|
|
529
|
+
of the path.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
point (Coordinate): The query coordinate.
|
|
533
|
+
planet (Optional[Planet], optional): Explicit planet
|
|
534
|
+
override. Defaults to None.
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
float: Distance in meters.
|
|
538
|
+
|
|
539
|
+
Raises:
|
|
540
|
+
ValueError: If the path is empty.
|
|
541
|
+
"""
|
|
542
|
+
|
|
543
|
+
resolved: Planet = self._planet(planet=planet)
|
|
544
|
+
|
|
545
|
+
if not self.vertices:
|
|
546
|
+
raise ValueError("Cannot measure distance to an empty Polyline")
|
|
547
|
+
|
|
548
|
+
best: Optional[float] = None
|
|
549
|
+
|
|
550
|
+
for i in range(len(self.vertices) - 1):
|
|
551
|
+
candidate: float = _segment_distance_meters(
|
|
552
|
+
point=point,
|
|
553
|
+
a=self.vertices[i],
|
|
554
|
+
b=self.vertices[i + 1],
|
|
555
|
+
planet=resolved,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
if best is None or candidate < best:
|
|
559
|
+
best = candidate
|
|
560
|
+
|
|
561
|
+
# A single-vertex path measures the distance to the vertex
|
|
562
|
+
if best is None:
|
|
563
|
+
best = _segment_distance_meters(
|
|
564
|
+
point=point,
|
|
565
|
+
a=self.vertices[0],
|
|
566
|
+
b=self.vertices[0],
|
|
567
|
+
planet=resolved,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
return best
|
|
571
|
+
|
|
572
|
+
def __eq__(self, other: Any) -> bool:
|
|
573
|
+
"""
|
|
574
|
+
Checks equality with another Polyline.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
other (Any): Object to compare with.
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
bool: True when the vertex lists match.
|
|
581
|
+
"""
|
|
582
|
+
|
|
583
|
+
if not isinstance(other, Polyline):
|
|
584
|
+
return False
|
|
585
|
+
|
|
586
|
+
return self.vertices == other.vertices
|
|
587
|
+
|
|
588
|
+
def __repr__(self) -> str:
|
|
589
|
+
"""
|
|
590
|
+
Returns string representation of the Polyline.
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
str: Polyline constructor string.
|
|
594
|
+
"""
|
|
595
|
+
|
|
596
|
+
return f"Polyline({self.vertices!r})"
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
class Polygon:
|
|
600
|
+
"""
|
|
601
|
+
A closed ring over the planet: an ordered sequence of Coordinates
|
|
602
|
+
(the closing edge is implicit). A duplicate closing vertex is
|
|
603
|
+
stripped on construction.
|
|
604
|
+
|
|
605
|
+
Attributes:
|
|
606
|
+
vertices (list[Coordinate]): The ring vertices.
|
|
607
|
+
"""
|
|
608
|
+
|
|
609
|
+
def __init__(self, vertices: Any) -> None:
|
|
610
|
+
"""
|
|
611
|
+
Initializes the Polygon instance.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
vertices (Any): The ring vertices (list of Coordinate).
|
|
615
|
+
|
|
616
|
+
Raises:
|
|
617
|
+
TypeError: If vertices is not a list of Coordinates.
|
|
618
|
+
ValueError: If the vertex count is 1 or 2.
|
|
619
|
+
"""
|
|
620
|
+
|
|
621
|
+
ring: list[Coordinate] = _require_coordinate_list(
|
|
622
|
+
name="Polygon vertices", vertices=vertices
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
# Strip an explicitly closed ring
|
|
626
|
+
if len(ring) >= 2 and ring[0] == ring[-1]:
|
|
627
|
+
ring = ring[:-1]
|
|
628
|
+
|
|
629
|
+
if 0 < len(ring) < 3:
|
|
630
|
+
raise ValueError(
|
|
631
|
+
f"A polygon needs 0 (empty) or at least 3 vertices, got {len(ring)}"
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
self.vertices: list[Coordinate] = ring
|
|
635
|
+
|
|
636
|
+
def _planet(self, planet: Optional[Planet]) -> Planet:
|
|
637
|
+
"""
|
|
638
|
+
Resolves the effective planet.
|
|
639
|
+
|
|
640
|
+
Args:
|
|
641
|
+
planet (Optional[Planet]): Explicit override.
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
Planet: The planet to compute against.
|
|
645
|
+
"""
|
|
646
|
+
|
|
647
|
+
return _resolve_planet(planet=planet)
|
|
648
|
+
|
|
649
|
+
def num_vertices(self) -> int:
|
|
650
|
+
"""
|
|
651
|
+
Returns the vertex count.
|
|
652
|
+
|
|
653
|
+
Returns:
|
|
654
|
+
int: Number of vertices.
|
|
655
|
+
"""
|
|
656
|
+
|
|
657
|
+
return len(self.vertices)
|
|
658
|
+
|
|
659
|
+
def vertex(self, index: int) -> Coordinate:
|
|
660
|
+
"""
|
|
661
|
+
Returns the vertex at an index (negative indices count from
|
|
662
|
+
the end).
|
|
663
|
+
|
|
664
|
+
Args:
|
|
665
|
+
index (int): Vertex index.
|
|
666
|
+
|
|
667
|
+
Returns:
|
|
668
|
+
Coordinate: The vertex.
|
|
669
|
+
|
|
670
|
+
Raises:
|
|
671
|
+
IndexError: If the index is out of range.
|
|
672
|
+
"""
|
|
673
|
+
|
|
674
|
+
if not isinstance(index, int) or isinstance(index, bool):
|
|
675
|
+
raise TypeError(f"Vertex index must be an integer, got '{type(index).__name__}'")
|
|
676
|
+
|
|
677
|
+
return self.vertices[index]
|
|
678
|
+
|
|
679
|
+
def area(self, planet: Optional[Planet] = None) -> float:
|
|
680
|
+
"""
|
|
681
|
+
Computes the enclosed area.
|
|
682
|
+
|
|
683
|
+
Sphere: exact spherical excess (Chamberlain & Duquette).
|
|
684
|
+
Spheroid: the same formula on the authalic (equal-area)
|
|
685
|
+
radius -- an approximation (see the map plan, open points).
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
planet (Optional[Planet], optional): Explicit planet
|
|
689
|
+
override. Defaults to None.
|
|
690
|
+
|
|
691
|
+
Returns:
|
|
692
|
+
float: Area in square meters (0 for the empty polygon).
|
|
693
|
+
"""
|
|
694
|
+
|
|
695
|
+
resolved: Planet = self._planet(planet=planet)
|
|
696
|
+
|
|
697
|
+
if len(self.vertices) < 3:
|
|
698
|
+
return 0.0
|
|
699
|
+
|
|
700
|
+
lons: list[float] = _unwrapped_lons(vertices=self.vertices)
|
|
701
|
+
|
|
702
|
+
total: float = 0.0
|
|
703
|
+
|
|
704
|
+
for i in range(len(self.vertices)):
|
|
705
|
+
j: int = (i + 1) % len(self.vertices)
|
|
706
|
+
|
|
707
|
+
lat_i: float = math.radians(self.vertices[i].lat)
|
|
708
|
+
lat_j: float = math.radians(self.vertices[j].lat)
|
|
709
|
+
|
|
710
|
+
total += math.radians(lons[j] - lons[i]) * (
|
|
711
|
+
2.0 + math.sin(lat_i) + math.sin(lat_j)
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
return abs(total) * resolved.authalic_radius**2 / 2.0
|
|
715
|
+
|
|
716
|
+
def perimeter(self, planet: Optional[Planet] = None) -> float:
|
|
717
|
+
"""
|
|
718
|
+
Computes the geodesic length of the closed boundary.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
planet (Optional[Planet], optional): Explicit planet
|
|
722
|
+
override. Defaults to None.
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
float: Perimeter in meters.
|
|
726
|
+
"""
|
|
727
|
+
|
|
728
|
+
resolved: Planet = self._planet(planet=planet)
|
|
729
|
+
|
|
730
|
+
if len(self.vertices) < 3:
|
|
731
|
+
return 0.0
|
|
732
|
+
|
|
733
|
+
total: float = 0.0
|
|
734
|
+
|
|
735
|
+
for i in range(len(self.vertices)):
|
|
736
|
+
total += _edge_length(
|
|
737
|
+
a=self.vertices[i],
|
|
738
|
+
b=self.vertices[(i + 1) % len(self.vertices)],
|
|
739
|
+
planet=resolved,
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
return total
|
|
743
|
+
|
|
744
|
+
def centroid(self, planet: Optional[Planet] = None) -> Coordinate:
|
|
745
|
+
"""
|
|
746
|
+
Returns the vertex centroid: the normalized mean of the vertex
|
|
747
|
+
direction vectors, at surface radius (a fast-path anchor, not
|
|
748
|
+
an area centroid).
|
|
749
|
+
|
|
750
|
+
Args:
|
|
751
|
+
planet (Optional[Planet], optional): Explicit planet
|
|
752
|
+
override. Defaults to None.
|
|
753
|
+
|
|
754
|
+
Returns:
|
|
755
|
+
Coordinate: The centroid coordinate.
|
|
756
|
+
|
|
757
|
+
Raises:
|
|
758
|
+
ValueError: If the polygon is empty or the vertices cancel
|
|
759
|
+
out (e.g. antipodal halves).
|
|
760
|
+
"""
|
|
761
|
+
|
|
762
|
+
resolved: Planet = self._planet(planet=planet)
|
|
763
|
+
|
|
764
|
+
if not self.vertices:
|
|
765
|
+
raise ValueError("Cannot compute the centroid of an empty Polygon")
|
|
766
|
+
|
|
767
|
+
x: float = 0.0
|
|
768
|
+
y: float = 0.0
|
|
769
|
+
z: float = 0.0
|
|
770
|
+
|
|
771
|
+
for vertex in self.vertices:
|
|
772
|
+
vx, vy, vz = _to_unit_vector(lat_deg=vertex.lat, lon_deg=vertex.lon)
|
|
773
|
+
|
|
774
|
+
x += vx
|
|
775
|
+
y += vy
|
|
776
|
+
z += vz
|
|
777
|
+
|
|
778
|
+
norm: float = math.sqrt(x * x + y * y + z * z)
|
|
779
|
+
|
|
780
|
+
if norm < 1e-15:
|
|
781
|
+
raise ValueError("Polygon vertices cancel out; centroid is undefined")
|
|
782
|
+
|
|
783
|
+
lat: float
|
|
784
|
+
lon: float
|
|
785
|
+
lat, lon = _from_unit_vector(vector=(x / norm, y / norm, z / norm))
|
|
786
|
+
|
|
787
|
+
return Coordinate(
|
|
788
|
+
lat=lat, lon=lon, radius=resolved.surface_radius(lat_deg=lat)
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
def contains(self, point: Coordinate, planet: Optional[Planet] = None) -> bool:
|
|
792
|
+
"""
|
|
793
|
+
Tests whether a coordinate lies inside the ring.
|
|
794
|
+
|
|
795
|
+
Uses a planar even-odd test on an unwrapped equirectangular
|
|
796
|
+
projection, probing the point at longitudes shifted by -360,
|
|
797
|
+
0, and +360 so antimeridian-crossing rings test correctly.
|
|
798
|
+
Region-sized polygons are exact for practical purposes; polar
|
|
799
|
+
cap shapes are an approximation (map plan, open points).
|
|
800
|
+
|
|
801
|
+
Args:
|
|
802
|
+
point (Coordinate): The query coordinate.
|
|
803
|
+
planet (Optional[Planet], optional): Unused; accepted for
|
|
804
|
+
API symmetry. Defaults to None.
|
|
805
|
+
|
|
806
|
+
Returns:
|
|
807
|
+
bool: True when the point is inside (boundary is inside).
|
|
808
|
+
|
|
809
|
+
Raises:
|
|
810
|
+
ValueError: If the polygon is empty.
|
|
811
|
+
"""
|
|
812
|
+
|
|
813
|
+
if not self.vertices:
|
|
814
|
+
raise ValueError("An empty Polygon contains nothing")
|
|
815
|
+
|
|
816
|
+
lons: list[float] = _unwrapped_lons(vertices=self.vertices)
|
|
817
|
+
lats: list[float] = [vertex.lat for vertex in self.vertices]
|
|
818
|
+
|
|
819
|
+
for shift in (-360.0, 0.0, 360.0):
|
|
820
|
+
if _point_in_ring(
|
|
821
|
+
lon=point.lon + shift, lat=point.lat, lons=lons, lats=lats
|
|
822
|
+
):
|
|
823
|
+
return True
|
|
824
|
+
|
|
825
|
+
return False
|
|
826
|
+
|
|
827
|
+
def distance_to(self, point: Coordinate, planet: Optional[Planet] = None) -> float:
|
|
828
|
+
"""
|
|
829
|
+
Computes the distance from a coordinate to the polygon (0 when
|
|
830
|
+
the point is inside).
|
|
831
|
+
|
|
832
|
+
Args:
|
|
833
|
+
point (Coordinate): The query coordinate.
|
|
834
|
+
planet (Optional[Planet], optional): Explicit planet
|
|
835
|
+
override. Defaults to None.
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
float: Distance in meters.
|
|
839
|
+
|
|
840
|
+
Raises:
|
|
841
|
+
ValueError: If the polygon is empty.
|
|
842
|
+
"""
|
|
843
|
+
|
|
844
|
+
resolved: Planet = self._planet(planet=planet)
|
|
845
|
+
|
|
846
|
+
if not self.vertices:
|
|
847
|
+
raise ValueError("Cannot measure distance to an empty Polygon")
|
|
848
|
+
|
|
849
|
+
if self.contains(point=point):
|
|
850
|
+
return 0.0
|
|
851
|
+
|
|
852
|
+
best: Optional[float] = None
|
|
853
|
+
count: int = len(self.vertices)
|
|
854
|
+
|
|
855
|
+
for i in range(count):
|
|
856
|
+
candidate: float = _segment_distance_meters(
|
|
857
|
+
point=point,
|
|
858
|
+
a=self.vertices[i],
|
|
859
|
+
b=self.vertices[(i + 1) % count],
|
|
860
|
+
planet=resolved,
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
if best is None or candidate < best:
|
|
864
|
+
best = candidate
|
|
865
|
+
|
|
866
|
+
assert best is not None
|
|
867
|
+
|
|
868
|
+
return best
|
|
869
|
+
|
|
870
|
+
def circumradius(
|
|
871
|
+
self,
|
|
872
|
+
planet: Optional[Planet] = None,
|
|
873
|
+
center: Optional[Coordinate] = None,
|
|
874
|
+
) -> float:
|
|
875
|
+
"""
|
|
876
|
+
Returns the geodesic distance from the centroid (or an
|
|
877
|
+
explicit anchor) to the farthest vertex. Points farther than
|
|
878
|
+
this from the centroid are definitely outside (Phase C fast
|
|
879
|
+
path).
|
|
880
|
+
|
|
881
|
+
Args:
|
|
882
|
+
planet (Optional[Planet], optional): Explicit planet
|
|
883
|
+
override. Defaults to None.
|
|
884
|
+
center (Optional[Coordinate], optional): Explicit anchor
|
|
885
|
+
override. Defaults to the centroid.
|
|
886
|
+
|
|
887
|
+
Returns:
|
|
888
|
+
float: Circumradius in meters.
|
|
889
|
+
|
|
890
|
+
Raises:
|
|
891
|
+
ValueError: If the polygon is empty.
|
|
892
|
+
"""
|
|
893
|
+
|
|
894
|
+
resolved: Planet = self._planet(planet=planet)
|
|
895
|
+
|
|
896
|
+
if not self.vertices:
|
|
897
|
+
raise ValueError("An empty Polygon has no circumradius")
|
|
898
|
+
|
|
899
|
+
anchor: Coordinate = (
|
|
900
|
+
center if center is not None else self.centroid(planet=planet)
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
return max(
|
|
904
|
+
anchor.distance(other=vertex, planet=resolved)
|
|
905
|
+
for vertex in self.vertices
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
def inradius(
|
|
909
|
+
self,
|
|
910
|
+
planet: Optional[Planet] = None,
|
|
911
|
+
center: Optional[Coordinate] = None,
|
|
912
|
+
) -> float:
|
|
913
|
+
"""
|
|
914
|
+
Returns the distance from the centroid to the nearest boundary
|
|
915
|
+
point. Points closer than this to the centroid are definitely
|
|
916
|
+
inside (Phase C fast path). 0 when the centroid falls outside
|
|
917
|
+
the ring (pathological shapes).
|
|
918
|
+
|
|
919
|
+
Args:
|
|
920
|
+
planet (Optional[Planet], optional): Explicit planet
|
|
921
|
+
override. Defaults to None.
|
|
922
|
+
|
|
923
|
+
Returns:
|
|
924
|
+
float: Inradius in meters.
|
|
925
|
+
|
|
926
|
+
Raises:
|
|
927
|
+
ValueError: If the polygon is empty.
|
|
928
|
+
"""
|
|
929
|
+
|
|
930
|
+
resolved: Planet = self._planet(planet=planet)
|
|
931
|
+
|
|
932
|
+
if not self.vertices:
|
|
933
|
+
raise ValueError("An empty Polygon has no inradius")
|
|
934
|
+
|
|
935
|
+
anchor: Coordinate = (
|
|
936
|
+
center if center is not None else self.centroid(planet=planet)
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
if not self.contains(point=anchor):
|
|
940
|
+
return 0.0
|
|
941
|
+
|
|
942
|
+
best: Optional[float] = None
|
|
943
|
+
count: int = len(self.vertices)
|
|
944
|
+
|
|
945
|
+
for i in range(count):
|
|
946
|
+
candidate: float = _segment_distance_meters(
|
|
947
|
+
point=anchor,
|
|
948
|
+
a=self.vertices[i],
|
|
949
|
+
b=self.vertices[(i + 1) % count],
|
|
950
|
+
planet=resolved,
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
if best is None or candidate < best:
|
|
954
|
+
best = candidate
|
|
955
|
+
|
|
956
|
+
assert best is not None
|
|
957
|
+
|
|
958
|
+
return best
|
|
959
|
+
|
|
960
|
+
def __eq__(self, other: Any) -> bool:
|
|
961
|
+
"""
|
|
962
|
+
Checks equality with another Polygon.
|
|
963
|
+
|
|
964
|
+
Args:
|
|
965
|
+
other (Any): Object to compare with.
|
|
966
|
+
|
|
967
|
+
Returns:
|
|
968
|
+
bool: True when the vertex lists match.
|
|
969
|
+
"""
|
|
970
|
+
|
|
971
|
+
if not isinstance(other, Polygon):
|
|
972
|
+
return False
|
|
973
|
+
|
|
974
|
+
return self.vertices == other.vertices
|
|
975
|
+
|
|
976
|
+
def __repr__(self) -> str:
|
|
977
|
+
"""
|
|
978
|
+
Returns string representation of the Polygon.
|
|
979
|
+
|
|
980
|
+
Returns:
|
|
981
|
+
str: Polygon constructor string.
|
|
982
|
+
"""
|
|
983
|
+
|
|
984
|
+
return f"Polygon({self.vertices!r})"
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
# Shape constructor sugar
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def circle(
|
|
991
|
+
center: Coordinate,
|
|
992
|
+
radius_m: float,
|
|
993
|
+
segments: int = 64,
|
|
994
|
+
planet: Optional[Planet] = None,
|
|
995
|
+
) -> Polygon:
|
|
996
|
+
"""
|
|
997
|
+
Builds a geodesic circle: all vertices at a surface distance from
|
|
998
|
+
the center.
|
|
999
|
+
|
|
1000
|
+
Args:
|
|
1001
|
+
center (Coordinate): The circle center.
|
|
1002
|
+
radius_m (float): Radius in meters.
|
|
1003
|
+
segments (int, optional): Vertex count. Defaults to 64.
|
|
1004
|
+
planet (Optional[Planet], optional): Explicit planet override.
|
|
1005
|
+
|
|
1006
|
+
Returns:
|
|
1007
|
+
Polygon: The circle polygon.
|
|
1008
|
+
|
|
1009
|
+
Raises:
|
|
1010
|
+
TypeError: If segments is not an integer.
|
|
1011
|
+
ValueError: If radius is not positive or segments < 3.
|
|
1012
|
+
"""
|
|
1013
|
+
|
|
1014
|
+
if isinstance(segments, bool) or not isinstance(segments, int):
|
|
1015
|
+
raise TypeError(f"Segments must be an integer, got '{type(segments).__name__}'")
|
|
1016
|
+
|
|
1017
|
+
if segments < 3:
|
|
1018
|
+
raise ValueError(f"A circle needs at least 3 segments, got {segments}")
|
|
1019
|
+
|
|
1020
|
+
vertices: list[Coordinate] = [
|
|
1021
|
+
center.destination(
|
|
1022
|
+
bearing_deg=360.0 * i / segments,
|
|
1023
|
+
distance_m=radius_m,
|
|
1024
|
+
planet=planet,
|
|
1025
|
+
)
|
|
1026
|
+
for i in range(segments)
|
|
1027
|
+
]
|
|
1028
|
+
|
|
1029
|
+
return Polygon(vertices=vertices)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def ellipse(
|
|
1033
|
+
center: Coordinate,
|
|
1034
|
+
radius_a_m: float,
|
|
1035
|
+
radius_b_m: float,
|
|
1036
|
+
bearing_deg: float = 0.0,
|
|
1037
|
+
segments: int = 64,
|
|
1038
|
+
planet: Optional[Planet] = None,
|
|
1039
|
+
) -> Polygon:
|
|
1040
|
+
"""
|
|
1041
|
+
Builds a parametric ellipse: the vertex at angle t sits at polar
|
|
1042
|
+
radius r(t) = (ra * rb) / sqrt((rb cos t)^2 + (ra sin t)^2) from
|
|
1043
|
+
the center, oriented along the bearing.
|
|
1044
|
+
|
|
1045
|
+
Args:
|
|
1046
|
+
center (Coordinate): The ellipse center.
|
|
1047
|
+
radius_a_m (float): Semi-axis along the bearing, in meters.
|
|
1048
|
+
radius_b_m (float): Semi-axis perpendicular, in meters.
|
|
1049
|
+
bearing_deg (float, optional): Orientation of the a axis.
|
|
1050
|
+
Defaults to 0.0.
|
|
1051
|
+
segments (int, optional): Vertex count. Defaults to 64.
|
|
1052
|
+
planet (Optional[Planet], optional): Explicit planet override.
|
|
1053
|
+
|
|
1054
|
+
Returns:
|
|
1055
|
+
Polygon: The ellipse polygon.
|
|
1056
|
+
|
|
1057
|
+
Raises:
|
|
1058
|
+
TypeError: If segments is not an integer.
|
|
1059
|
+
ValueError: If a radius is not positive or segments < 3.
|
|
1060
|
+
"""
|
|
1061
|
+
|
|
1062
|
+
if isinstance(segments, bool) or not isinstance(segments, int):
|
|
1063
|
+
raise TypeError(f"Segments must be an integer, got '{type(segments).__name__}'")
|
|
1064
|
+
|
|
1065
|
+
if segments < 3:
|
|
1066
|
+
raise ValueError(f"An ellipse needs at least 3 segments, got {segments}")
|
|
1067
|
+
|
|
1068
|
+
if radius_a_m <= 0.0 or radius_b_m <= 0.0:
|
|
1069
|
+
raise ValueError(
|
|
1070
|
+
f"Semi-axes must be positive, got {radius_a_m} and {radius_b_m}"
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
vertices: list[Coordinate] = []
|
|
1074
|
+
|
|
1075
|
+
for i in range(segments):
|
|
1076
|
+
theta: float = 2.0 * math.pi * i / segments
|
|
1077
|
+
cos_t: float = math.cos(theta)
|
|
1078
|
+
sin_t: float = math.sin(theta)
|
|
1079
|
+
|
|
1080
|
+
polar_radius: float = (radius_a_m * radius_b_m) / math.sqrt(
|
|
1081
|
+
(radius_b_m * cos_t) ** 2 + (radius_a_m * sin_t) ** 2
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
vertices.append(
|
|
1085
|
+
center.destination(
|
|
1086
|
+
bearing_deg=bearing_deg + math.degrees(theta),
|
|
1087
|
+
distance_m=polar_radius,
|
|
1088
|
+
planet=planet,
|
|
1089
|
+
)
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
return Polygon(vertices=vertices)
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def regular_polygon(
|
|
1096
|
+
center: Coordinate,
|
|
1097
|
+
radius_m: float,
|
|
1098
|
+
sides: int,
|
|
1099
|
+
rotation_deg: float = 0.0,
|
|
1100
|
+
planet: Optional[Planet] = None,
|
|
1101
|
+
) -> Polygon:
|
|
1102
|
+
"""
|
|
1103
|
+
Builds a regular polygon: sides vertices on a geodesic circle,
|
|
1104
|
+
rotated by an initial bearing.
|
|
1105
|
+
|
|
1106
|
+
Args:
|
|
1107
|
+
center (Coordinate): The polygon center.
|
|
1108
|
+
radius_m (float): Circumradius in meters.
|
|
1109
|
+
sides (int): Vertex count (at least 3).
|
|
1110
|
+
rotation_deg (float, optional): Bearing of the first vertex.
|
|
1111
|
+
Defaults to 0.0.
|
|
1112
|
+
planet (Optional[Planet], optional): Explicit planet override.
|
|
1113
|
+
|
|
1114
|
+
Returns:
|
|
1115
|
+
Polygon: The regular polygon.
|
|
1116
|
+
|
|
1117
|
+
Raises:
|
|
1118
|
+
TypeError: If sides is not an integer.
|
|
1119
|
+
ValueError: If sides < 3 or radius is not positive.
|
|
1120
|
+
"""
|
|
1121
|
+
|
|
1122
|
+
if isinstance(sides, bool) or not isinstance(sides, int):
|
|
1123
|
+
raise TypeError(f"Sides must be an integer, got '{type(sides).__name__}'")
|
|
1124
|
+
|
|
1125
|
+
if sides < 3:
|
|
1126
|
+
raise ValueError(f"A regular polygon needs at least 3 sides, got {sides}")
|
|
1127
|
+
|
|
1128
|
+
vertices: list[Coordinate] = [
|
|
1129
|
+
center.destination(
|
|
1130
|
+
bearing_deg=rotation_deg + 360.0 * i / sides,
|
|
1131
|
+
distance_m=radius_m,
|
|
1132
|
+
planet=planet,
|
|
1133
|
+
)
|
|
1134
|
+
for i in range(sides)
|
|
1135
|
+
]
|
|
1136
|
+
|
|
1137
|
+
return Polygon(vertices=vertices)
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
# Free-function twins of the member API
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def area(polygon: Polygon, planet: Optional[Planet] = None) -> float:
|
|
1144
|
+
"""
|
|
1145
|
+
Enclosed area of a polygon, in square meters.
|
|
1146
|
+
|
|
1147
|
+
Args:
|
|
1148
|
+
polygon (Polygon): The polygon.
|
|
1149
|
+
planet (Optional[Planet], optional): Explicit planet override.
|
|
1150
|
+
|
|
1151
|
+
Returns:
|
|
1152
|
+
float: Area in square meters.
|
|
1153
|
+
"""
|
|
1154
|
+
|
|
1155
|
+
return polygon.area(planet=planet)
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def perimeter(polygon: Polygon, planet: Optional[Planet] = None) -> float:
|
|
1159
|
+
"""
|
|
1160
|
+
Geodesic boundary length of a polygon, in meters.
|
|
1161
|
+
|
|
1162
|
+
Args:
|
|
1163
|
+
polygon (Polygon): The polygon.
|
|
1164
|
+
planet (Optional[Planet], optional): Explicit planet override.
|
|
1165
|
+
|
|
1166
|
+
Returns:
|
|
1167
|
+
float: Perimeter in meters.
|
|
1168
|
+
"""
|
|
1169
|
+
|
|
1170
|
+
return polygon.perimeter(planet=planet)
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def contains(
|
|
1174
|
+
polygon: Polygon,
|
|
1175
|
+
point: Coordinate,
|
|
1176
|
+
planet: Optional[Planet] = None,
|
|
1177
|
+
) -> bool:
|
|
1178
|
+
"""
|
|
1179
|
+
Tests whether a coordinate lies inside a polygon.
|
|
1180
|
+
|
|
1181
|
+
Args:
|
|
1182
|
+
polygon (Polygon): The polygon.
|
|
1183
|
+
point (Coordinate): The query coordinate.
|
|
1184
|
+
planet (Optional[Planet], optional): Unused; API symmetry.
|
|
1185
|
+
|
|
1186
|
+
Returns:
|
|
1187
|
+
bool: True when the point is inside.
|
|
1188
|
+
"""
|
|
1189
|
+
|
|
1190
|
+
return polygon.contains(point=point, planet=planet)
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def polyline_length(
|
|
1194
|
+
polyline: Polyline,
|
|
1195
|
+
planet: Optional[Planet] = None,
|
|
1196
|
+
) -> float:
|
|
1197
|
+
"""
|
|
1198
|
+
Geodesic length of a polyline, in meters.
|
|
1199
|
+
|
|
1200
|
+
Args:
|
|
1201
|
+
polyline (Polyline): The polyline.
|
|
1202
|
+
planet (Optional[Planet], optional): Explicit planet override.
|
|
1203
|
+
|
|
1204
|
+
Returns:
|
|
1205
|
+
float: Length in meters.
|
|
1206
|
+
"""
|
|
1207
|
+
|
|
1208
|
+
return polyline.length(planet=planet)
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def normalize_ring(vertices: Any) -> "Polygon":
|
|
1212
|
+
"""
|
|
1213
|
+
Builds a Polygon from a vertex ring, stripping any explicit
|
|
1214
|
+
closing vertex.
|
|
1215
|
+
|
|
1216
|
+
Args:
|
|
1217
|
+
vertices (Any): The ring vertices (list of Coordinate).
|
|
1218
|
+
|
|
1219
|
+
Returns:
|
|
1220
|
+
Polygon: The normalized polygon.
|
|
1221
|
+
"""
|
|
1222
|
+
|
|
1223
|
+
return Polygon(vertices=vertices)
|