planarcurve 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.
@@ -0,0 +1,52 @@
1
+ """Planar curve and trajectory mathematics."""
2
+
3
+ from .trajectory import (
4
+ HeadingRateChange,
5
+ HeadingRateProfile,
6
+ Pose2D,
7
+ Trajectory2D,
8
+ TrajectorySample,
9
+ curvature,
10
+ evaluate_heading,
11
+ evaluate_heading_rate,
12
+ evaluate_trajectory,
13
+ evaluate_trajectory_at,
14
+ integrated_heading_rate,
15
+ sample_trajectory_by_arclength,
16
+ trajectory_end_pose,
17
+ trajectory_to_cubic_path,
18
+ trajectory_total_turn,
19
+ )
20
+ from .transforms import Affine2D, Similarity2D
21
+ from .types import CubicBezier, CubicPath, CubicSegment, Point2D, QuadraticBezier
22
+
23
+ try:
24
+ from ._version import __version__
25
+ except ImportError: # source tree before setuptools_scm generated _version.py
26
+ __version__ = "0+unknown"
27
+
28
+ __all__ = [
29
+ "Affine2D",
30
+ "CubicBezier",
31
+ "CubicPath",
32
+ "QuadraticBezier",
33
+ "CubicSegment",
34
+ "HeadingRateChange",
35
+ "HeadingRateProfile",
36
+ "Point2D",
37
+ "Pose2D",
38
+ "Similarity2D",
39
+ "Trajectory2D",
40
+ "TrajectorySample",
41
+ "__version__",
42
+ "curvature",
43
+ "evaluate_heading",
44
+ "evaluate_heading_rate",
45
+ "evaluate_trajectory",
46
+ "evaluate_trajectory_at",
47
+ "integrated_heading_rate",
48
+ "sample_trajectory_by_arclength",
49
+ "trajectory_end_pose",
50
+ "trajectory_to_cubic_path",
51
+ "trajectory_total_turn",
52
+ ]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = 'g96d8146d2'
planarcurve/bezier.py ADDED
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+ from .types import CubicBezier, QuadraticBezier
6
+
7
+
8
+ def _parameters(t: np.ndarray) -> np.ndarray:
9
+ values = np.asarray(t, dtype=float)
10
+ if values.ndim != 1 or not np.isfinite(values).all():
11
+ raise ValueError("parameters must be a finite one-dimensional array")
12
+ if np.any((values < 0.0) | (values > 1.0)):
13
+ raise ValueError("parameters must lie in [0, 1]")
14
+ return values
15
+
16
+
17
+ def evaluate_quadratic(p0, p1, p2, t: np.ndarray) -> np.ndarray:
18
+ u = _parameters(t)[:, None]
19
+ one = 1.0 - u
20
+ return one**2 * np.asarray(p0) + 2.0 * one * u * np.asarray(p1) + u**2 * np.asarray(p2)
21
+
22
+
23
+ def evaluate_quadratic_bezier(curve: QuadraticBezier, t: np.ndarray) -> np.ndarray:
24
+ return evaluate_quadratic(curve.p0, curve.p1, curve.p2, t)
25
+
26
+
27
+ def quadratic_derivative(curve: QuadraticBezier, t: np.ndarray) -> np.ndarray:
28
+ u = _parameters(t)[:, None]
29
+ return 2.0 * (
30
+ (1.0 - u) * (np.asarray(curve.p1) - np.asarray(curve.p0))
31
+ + u * (np.asarray(curve.p2) - np.asarray(curve.p1))
32
+ )
33
+
34
+
35
+ def evaluate_cubic(curve: CubicBezier, t: np.ndarray) -> np.ndarray:
36
+ u = _parameters(t)[:, None]
37
+ one = 1.0 - u
38
+ return (
39
+ one**3 * np.asarray(curve.p0)
40
+ + 3.0 * one**2 * u * np.asarray(curve.p1)
41
+ + 3.0 * one * u**2 * np.asarray(curve.p2)
42
+ + u**3 * np.asarray(curve.p3)
43
+ )
44
+
45
+
46
+ def cubic_derivative(curve: CubicBezier, t: np.ndarray) -> np.ndarray:
47
+ u = _parameters(t)[:, None]
48
+ one = 1.0 - u
49
+ return 3.0 * (
50
+ one**2 * (np.asarray(curve.p1) - np.asarray(curve.p0))
51
+ + 2.0 * one * u * (np.asarray(curve.p2) - np.asarray(curve.p1))
52
+ + u**2 * (np.asarray(curve.p3) - np.asarray(curve.p2))
53
+ )
54
+
55
+
56
+ def cubic_second_derivative(curve: CubicBezier, t: np.ndarray) -> np.ndarray:
57
+ u = _parameters(t)[:, None]
58
+ return 6.0 * (
59
+ (1.0 - u) * (np.asarray(curve.p2) - 2.0 * np.asarray(curve.p1) + np.asarray(curve.p0))
60
+ + u * (np.asarray(curve.p3) - 2.0 * np.asarray(curve.p2) + np.asarray(curve.p1))
61
+ )
62
+
63
+
64
+ def cubic_tangent(curve: CubicBezier, t: np.ndarray, *, unit: bool = True) -> np.ndarray:
65
+ derivative = cubic_derivative(curve, t)
66
+ if not unit:
67
+ return derivative
68
+ norms = np.linalg.norm(derivative, axis=1)
69
+ return np.divide(
70
+ derivative,
71
+ norms[:, None],
72
+ out=np.zeros_like(derivative),
73
+ where=norms[:, None] > 1e-12,
74
+ )
75
+
76
+
77
+ def _signed_curvature(first: np.ndarray, second: np.ndarray) -> np.ndarray:
78
+ cross = first[:, 0] * second[:, 1] - first[:, 1] * second[:, 0]
79
+ speed = np.linalg.norm(first, axis=1)
80
+ return np.divide(cross, speed**3, out=np.zeros_like(cross), where=speed > 1e-12)
81
+
82
+
83
+ def cubic_curvature(curve: CubicBezier, t: np.ndarray) -> np.ndarray:
84
+ return _signed_curvature(cubic_derivative(curve, t), cubic_second_derivative(curve, t))
85
+
86
+
87
+ def quadratic_tangent(
88
+ curve: QuadraticBezier, t: np.ndarray, *, unit: bool = True
89
+ ) -> np.ndarray:
90
+ derivative = quadratic_derivative(curve, t)
91
+ if not unit:
92
+ return derivative
93
+ norms = np.linalg.norm(derivative, axis=1)
94
+ return np.divide(
95
+ derivative,
96
+ norms[:, None],
97
+ out=np.zeros_like(derivative),
98
+ where=norms[:, None] > 1e-12,
99
+ )
100
+
101
+
102
+ def quadratic_curvature(curve: QuadraticBezier, t: np.ndarray) -> np.ndarray:
103
+ derivative = quadratic_derivative(curve, t)
104
+ second = np.broadcast_to(
105
+ 2.0 * (np.asarray(curve.p2) - 2.0 * np.asarray(curve.p1) + np.asarray(curve.p0)),
106
+ derivative.shape,
107
+ )
108
+ return _signed_curvature(derivative, second)
109
+
110
+
111
+ def split_cubic(curve: CubicBezier, t: float = 0.5) -> tuple[CubicBezier, CubicBezier]:
112
+ parameter = _parameters(np.array([t]))[0]
113
+ p0, p1, p2, p3 = (
114
+ np.asarray(point, dtype=float)
115
+ for point in (curve.p0, curve.p1, curve.p2, curve.p3)
116
+ )
117
+ p01 = (1 - parameter) * p0 + parameter * p1
118
+ p12 = (1 - parameter) * p1 + parameter * p2
119
+ p23 = (1 - parameter) * p2 + parameter * p3
120
+ p012 = (1 - parameter) * p01 + parameter * p12
121
+ p123 = (1 - parameter) * p12 + parameter * p23
122
+ middle = (1 - parameter) * p012 + parameter * p123
123
+ return (
124
+ CubicBezier(tuple(p0), tuple(p01), tuple(p012), tuple(middle)),
125
+ CubicBezier(tuple(middle), tuple(p123), tuple(p23), tuple(p3)),
126
+ )
127
+
128
+
129
+ def _point_line_distance(point: np.ndarray, start: np.ndarray, end: np.ndarray) -> float:
130
+ direction = end - start
131
+ norm = np.linalg.norm(direction)
132
+ if norm <= 1e-12:
133
+ return float(np.linalg.norm(point - start))
134
+ offset = point - start
135
+ cross = direction[0] * offset[1] - direction[1] * offset[0]
136
+ return float(abs(cross) / norm)
137
+
138
+
139
+ def flatten_cubic(
140
+ curve: CubicBezier, *, tolerance: float = 1e-3, max_depth: int = 16
141
+ ) -> np.ndarray:
142
+ if not np.isfinite(tolerance) or tolerance <= 0.0:
143
+ raise ValueError("tolerance must be positive and finite")
144
+ if isinstance(max_depth, bool) or max_depth < 0:
145
+ raise ValueError("max_depth must be nonnegative")
146
+ points = [np.asarray(curve.p0, dtype=float)]
147
+
148
+ def flatten(piece: CubicBezier, depth: int) -> None:
149
+ p0, p1, p2, p3 = (
150
+ np.asarray(point, dtype=float) for point in (piece.p0, piece.p1, piece.p2, piece.p3)
151
+ )
152
+ flatness = max(_point_line_distance(p1, p0, p3), _point_line_distance(p2, p0, p3))
153
+ if flatness <= tolerance:
154
+ points.append(p3)
155
+ return
156
+ if depth == 0:
157
+ raise ValueError("cubic flattening did not meet tolerance")
158
+ left, right = split_cubic(piece)
159
+ flatten(left, depth - 1)
160
+ flatten(right, depth - 1)
161
+
162
+ flatten(curve, max_depth)
163
+ return np.asarray(points)
164
+
165
+
166
+ def cubic_length(curve: CubicBezier, *, tolerance: float = 1e-6, max_depth: int = 16) -> float:
167
+ if not np.isfinite(tolerance) or tolerance <= 0.0:
168
+ raise ValueError("tolerance must be positive and finite")
169
+ if isinstance(max_depth, bool) or max_depth < 0:
170
+ raise ValueError("max_depth must be nonnegative")
171
+
172
+ def estimate(piece: CubicBezier, local_tolerance: float, depth: int) -> float:
173
+ p0, p1, p2, p3 = (np.asarray(point) for point in (piece.p0, piece.p1, piece.p2, piece.p3))
174
+ chord = float(np.linalg.norm(p3 - p0))
175
+ polygon = float(
176
+ np.linalg.norm(p1 - p0) + np.linalg.norm(p2 - p1) + np.linalg.norm(p3 - p2)
177
+ )
178
+ if polygon - chord <= local_tolerance:
179
+ return (polygon + chord) / 2.0
180
+ if depth == 0:
181
+ raise ValueError("cubic length approximation did not meet tolerance")
182
+ left, right = split_cubic(piece)
183
+ return estimate(left, local_tolerance / 2.0, depth - 1) + estimate(
184
+ right, local_tolerance / 2.0, depth - 1
185
+ )
186
+
187
+ return estimate(curve, tolerance, max_depth)
188
+
189
+
190
+ def _quadratic_as_cubic(curve: QuadraticBezier) -> CubicBezier:
191
+ p0, p1, p2 = (np.asarray(point, dtype=float) for point in (curve.p0, curve.p1, curve.p2))
192
+ return CubicBezier(
193
+ tuple(p0),
194
+ tuple(p0 + (2.0 / 3.0) * (p1 - p0)),
195
+ tuple(p2 + (2.0 / 3.0) * (p1 - p2)),
196
+ tuple(p2),
197
+ )
198
+
199
+
200
+ def flatten_quadratic(
201
+ curve: QuadraticBezier, *, tolerance: float = 1e-3, max_depth: int = 16
202
+ ) -> np.ndarray:
203
+ return flatten_cubic(
204
+ _quadratic_as_cubic(curve), tolerance=tolerance, max_depth=max_depth
205
+ )
206
+
207
+
208
+ def quadratic_length(
209
+ curve: QuadraticBezier, *, tolerance: float = 1e-6, max_depth: int = 16
210
+ ) -> float:
211
+ return cubic_length(_quadratic_as_cubic(curve), tolerance=tolerance, max_depth=max_depth)
@@ -0,0 +1,82 @@
1
+ """Compatibility imports for the former curvature-module API.
2
+
3
+ New code should import the neutral trajectory API from :mod:`planarcurve.trajectory`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+
10
+ from .trajectory import (
11
+ HeadingRateChange,
12
+ HeadingRateProfile,
13
+ Pose2D,
14
+ Trajectory2D,
15
+ TrajectorySample,
16
+ evaluate_heading,
17
+ evaluate_heading_rate,
18
+ evaluate_trajectory,
19
+ evaluate_trajectory_at,
20
+ integrated_heading_rate,
21
+ sample_trajectory_by_arclength,
22
+ trajectory_end_pose,
23
+ trajectory_to_cubic_path,
24
+ trajectory_total_turn,
25
+ )
26
+
27
+ __all__ = [
28
+ "CurvatureChange",
29
+ "CurvatureProfile",
30
+ "CurvatureSample",
31
+ "CurvatureStrokeGeometry",
32
+ "CurvatureStrokeSample",
33
+ "CurvatureTrajectory",
34
+ "Pose2D",
35
+ "StrokePose",
36
+ "evaluate_curvature_stroke",
37
+ "evaluate_heading",
38
+ "evaluate_heading_rate",
39
+ "evaluate_stroke_at",
40
+ "integrated_heading_rate",
41
+ "sample_by_arclength",
42
+ "sample_curvature_stroke",
43
+ "to_cubic_beziers",
44
+ "trajectory_end_pose",
45
+ "trajectory_total_turn",
46
+ ]
47
+
48
+
49
+ CurvatureChange = HeadingRateChange
50
+ CurvatureProfile = HeadingRateProfile
51
+ CurvatureTrajectory = Trajectory2D
52
+ CurvatureStrokeGeometry = Trajectory2D
53
+ CurvatureSample = TrajectorySample
54
+ CurvatureStrokeSample = TrajectorySample
55
+ StrokePose = Pose2D
56
+ evaluate_curvature_stroke = evaluate_trajectory
57
+ evaluate_stroke_at = evaluate_trajectory_at
58
+
59
+
60
+ def curvature_to_cubic_segments(
61
+ trajectory: Trajectory2D, *, max_error: float = 1e-4
62
+ ):
63
+ return list(trajectory_to_cubic_path(trajectory, max_error=max_error).segments)
64
+
65
+
66
+ def sample_curvature_stroke(
67
+ trajectory: Trajectory2D, *, count: int = 256, samples: int = 64
68
+ ) -> np.ndarray:
69
+ return sample_trajectory_by_arclength(
70
+ trajectory, samples, integration_count=count
71
+ )
72
+
73
+
74
+ def sample_by_arclength(
75
+ trajectory: Trajectory2D, count: int, *, integration_count: int = 256
76
+ ) -> np.ndarray:
77
+ return sample_trajectory_by_arclength(
78
+ trajectory, count, integration_count=integration_count
79
+ )
80
+
81
+
82
+ to_cubic_beziers = trajectory_to_cubic_path
@@ -0,0 +1,3 @@
1
+ from .policy import SplineEndpointPolicy
2
+
3
+ __all__ = ["SplineEndpointPolicy"]
@@ -0,0 +1,110 @@
1
+ """Generic parameterizations for ordered positions, intervals, and positives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+
8
+ def ordered_positions_from_logits(
9
+ logits: np.ndarray,
10
+ *,
11
+ min_spacing: float = 0.0,
12
+ start: float = 0.0,
13
+ end: float = 1.0,
14
+ ) -> np.ndarray:
15
+ """Decode interval logits into strictly ordered positions in ``(start, end)``."""
16
+ values = np.asarray(logits, dtype=float)
17
+ if values.ndim != 1 or not np.isfinite(values).all():
18
+ raise ValueError("logits must be a finite 1-D array")
19
+ _validate_interval(start, end)
20
+ _validate_spacing(min_spacing)
21
+ interval_count = values.size
22
+ position_count = max(interval_count - 1, 0)
23
+ if position_count == 0:
24
+ return np.empty(0, dtype=float)
25
+ span = end - start
26
+ reserved = max(position_count - 1, 0) * min_spacing
27
+ if reserved >= span:
28
+ raise ValueError("interval too small for the requested positions and spacing")
29
+ weights = np.exp(np.maximum(values - values.max(), -27.0))
30
+ fractions = weights / weights.sum()
31
+ gaps = fractions * (span - reserved)
32
+ gaps[1:-1] += min_spacing
33
+ return start + np.cumsum(gaps[:-1])
34
+
35
+
36
+ def ordered_positions_to_logits(
37
+ positions: np.ndarray,
38
+ *,
39
+ min_spacing: float = 0.0,
40
+ start: float = 0.0,
41
+ end: float = 1.0,
42
+ ) -> np.ndarray:
43
+ """Encode ordered positions as interval logits, up to a constant offset."""
44
+ values = np.asarray(positions, dtype=float)
45
+ if values.ndim != 1 or not np.isfinite(values).all():
46
+ raise ValueError("positions must be a finite 1-D array")
47
+ _validate_interval(start, end)
48
+ _validate_spacing(min_spacing)
49
+ if values.size == 0:
50
+ return np.empty(0, dtype=float)
51
+ if np.any(np.diff(values) <= 0.0) or values[0] <= start or values[-1] >= end:
52
+ raise ValueError("positions must be strictly ordered inside the interval")
53
+ gaps = np.diff(np.concatenate(([start], values, [end])))
54
+ if np.any(gaps[1:-1] < min_spacing - 1e-12):
55
+ raise ValueError(f"positions must respect minimum spacing {min_spacing}")
56
+ available = (end - start) - max(values.size - 1, 0) * min_spacing
57
+ fractions = gaps.copy()
58
+ fractions[1:-1] -= min_spacing
59
+ fractions = np.clip(fractions / available, 1e-12, None)
60
+ return np.log(fractions)
61
+
62
+
63
+ def decode_interval(param: float, *, low: float, high: float) -> float:
64
+ """Map an unconstrained scalar into the open interval ``(low, high)``."""
65
+ _validate_interval(low, high)
66
+ value = float(param)
67
+ if not np.isfinite(value):
68
+ raise ValueError("parameter must be finite")
69
+ if value >= 0.0:
70
+ logistic = 1.0 / (1.0 + np.exp(-value))
71
+ else:
72
+ exponential = np.exp(value)
73
+ logistic = exponential / (1.0 + exponential)
74
+ return float(low + (high - low) * logistic)
75
+
76
+
77
+ def encode_interval(value: float, *, low: float, high: float) -> float:
78
+ """Map an interval value back to its unconstrained logistic parameter."""
79
+ _validate_interval(low, high)
80
+ numeric = float(value)
81
+ if not np.isfinite(numeric) or not low <= numeric <= high:
82
+ raise ValueError("value must be finite and inside the interval")
83
+ fraction = np.clip((numeric - low) / (high - low), 1e-12, 1.0 - 1e-12)
84
+ return float(np.log(fraction / (1.0 - fraction)))
85
+
86
+
87
+ def decode_positive(param: float) -> float:
88
+ """Decode a real value as a positive scalar."""
89
+ value = float(param)
90
+ if not np.isfinite(value):
91
+ raise ValueError("parameter must be finite")
92
+ return float(np.exp(np.clip(value, -50.0, 50.0)))
93
+
94
+
95
+ def encode_positive(value: float) -> float:
96
+ """Encode a positive scalar as its logarithm."""
97
+ numeric = float(value)
98
+ if not np.isfinite(numeric) or numeric <= 0.0:
99
+ raise ValueError("value must be positive and finite")
100
+ return float(np.log(numeric))
101
+
102
+
103
+ def _validate_interval(low: float, high: float) -> None:
104
+ if not np.isfinite([low, high]).all() or not low < high:
105
+ raise ValueError("interval bounds must be finite and increasing")
106
+
107
+
108
+ def _validate_spacing(min_spacing: float) -> None:
109
+ if not np.isfinite(min_spacing) or min_spacing < 0.0:
110
+ raise ValueError("min_spacing must be finite and nonnegative")
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class SplineEndpointPolicy:
9
+ """Endpoint constraints used by spline fitting, not by spline geometry."""
10
+
11
+ start: Literal["locked", "free", "anchored"] = "locked"
12
+ end: Literal["locked", "free", "anchored"] = "locked"
13
+
14
+ def __post_init__(self) -> None:
15
+ allowed = {"locked", "free", "anchored"}
16
+ if self.start not in allowed or self.end not in allowed:
17
+ raise ValueError("endpoint policy must be locked, free, or anchored")