crgutils 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.
crgutils/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """crgutils — Python library for reading and evaluating OpenCRG road surface files.
2
+
3
+ Quick start
4
+ -----------
5
+ >>> import crgutils
6
+ >>> ds = crgutils.read("road.crg") # load + validate + apply modifiers
7
+ >>> cp = ds.create_contact_point()
8
+ >>> z = cp.eval_uv_to_z(10.0, 0.0) # elevation at (u=10, v=0)
9
+ >>> x, y = cp.eval_uv_to_xy(10.0, 0.5) # road → world coords
10
+ >>> u, v = cp.eval_xy_to_uv(x, y) # world → road coords
11
+ """
12
+
13
+ from ._convenience import read
14
+ from ._dataset import CRGDataset
15
+ from ._contact_point import ContactPoint
16
+ from ._types import BorderMode, RefLineContinue, CurvMode, GridNaNMode, EvalOptions
17
+
18
+ # Lower-level building blocks — available for advanced use but not part of the
19
+ # primary public API.
20
+ from ._loader import load # noqa: F401
21
+ from ._check import check # noqa: F401
22
+ from ._modifiers import apply_modifiers # noqa: F401
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ __all__ = [
27
+ # High-level interface
28
+ "read",
29
+ "CRGDataset",
30
+ "ContactPoint",
31
+ # Types / enums
32
+ "BorderMode",
33
+ "RefLineContinue",
34
+ "CurvMode",
35
+ "GridNaNMode",
36
+ "EvalOptions",
37
+ "__version__",
38
+ ]
crgutils/_check.py ADDED
@@ -0,0 +1,102 @@
1
+ """Validate a CRGDataset for consistency and accuracy.
2
+
3
+ Port of ``crgCheck()`` / ``crgCheckOpts()`` / ``crgCheckData()`` from
4
+ crgLoader.c / crgMgr.c.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import warnings
11
+
12
+ import numpy as np
13
+
14
+ from ._dataset import CRGDataset
15
+
16
+
17
+ def check(dataset: CRGDataset, *, raise_on_error: bool = False) -> bool:
18
+ """Validate *dataset* for internal consistency.
19
+
20
+ Parameters
21
+ ----------
22
+ dataset:
23
+ The dataset to validate.
24
+ raise_on_error:
25
+ If True, raise a ``ValueError`` instead of returning False.
26
+
27
+ Returns
28
+ -------
29
+ bool
30
+ ``True`` if all checks pass.
31
+ """
32
+ errors: list[str] = []
33
+ warnings_: list[str] = []
34
+
35
+ # --- basic shape checks ---
36
+ if dataset.n_u < 2:
37
+ errors.append(f"u axis has fewer than 2 points ({dataset.n_u})")
38
+ if dataset.n_v < 1:
39
+ errors.append(f"v axis has no points ({dataset.n_v})")
40
+ if dataset.z.shape != (dataset.n_v, dataset.n_u):
41
+ errors.append(
42
+ f"z shape {dataset.z.shape} does not match (n_v={dataset.n_v}, n_u={dataset.n_u})"
43
+ )
44
+
45
+ # --- u increment consistency ---
46
+ if dataset.n_u >= 2:
47
+ computed_inc = (dataset.u_max - dataset.u_min) / (dataset.n_u - 1)
48
+ if abs(computed_inc - dataset.u_inc) > 1e-6 * max(1.0, abs(dataset.u_inc)):
49
+ warnings_.append(
50
+ f"u_inc mismatch: stored={dataset.u_inc:.6f}, computed={computed_inc:.6f}"
51
+ )
52
+
53
+ # --- v monotonicity ---
54
+ if dataset.n_v >= 2:
55
+ diffs = np.diff(dataset.v.astype(float))
56
+ if not np.all(diffs > 0):
57
+ errors.append("v array is not strictly increasing")
58
+
59
+ # --- phi continuity (warn on large jumps) ---
60
+ if dataset.n_u >= 2:
61
+ phi_unwrapped = np.unwrap(dataset.phi.astype(float))
62
+ dphi = np.abs(np.diff(phi_unwrapped))
63
+ max_dphi = float(dphi.max()) if len(dphi) else 0.0
64
+ if max_dphi > math.pi / 2:
65
+ warnings_.append(
66
+ f"Large heading jump detected: max |Δphi| = {math.degrees(max_dphi):.1f}°"
67
+ )
68
+
69
+ # --- NaN fraction in z grid ---
70
+ nan_count = int(np.isnan(dataset.z.astype(float)).sum())
71
+ total = dataset.z.size
72
+ if nan_count > 0:
73
+ frac = nan_count / total
74
+ if frac > 0.5:
75
+ errors.append(f"More than 50% of z values are NaN ({nan_count}/{total})")
76
+ elif frac > 0.1:
77
+ warnings_.append(f"{nan_count}/{total} z values are NaN ({100*frac:.1f}%)")
78
+
79
+ # --- reference line x/y length vs u length ---
80
+ if dataset.n_u >= 2:
81
+ dx = np.diff(dataset.x.astype(float))
82
+ dy = np.diff(dataset.y.astype(float))
83
+ seg_lengths = np.sqrt(dx ** 2 + dy ** 2)
84
+ total_xy = float(seg_lengths.sum())
85
+ total_u = dataset.u_max - dataset.u_min
86
+ if total_u > 0 and abs(total_xy - total_u) / total_u > 0.05:
87
+ warnings_.append(
88
+ f"Reference line arc length {total_xy:.3f} m differs from "
89
+ f"u range {total_u:.3f} m by more than 5%"
90
+ )
91
+
92
+ for w in warnings_:
93
+ warnings.warn(f"CRGDataset check: {w}", UserWarning, stacklevel=2)
94
+
95
+ if errors:
96
+ msg = "CRGDataset validation failed:\n" + "\n".join(f" - {e}" for e in errors)
97
+ if raise_on_error:
98
+ raise ValueError(msg)
99
+ warnings.warn(msg, UserWarning, stacklevel=2)
100
+ return False
101
+
102
+ return True
@@ -0,0 +1,167 @@
1
+ """ContactPoint: stateful evaluation context for a CRGDataset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from ._dataset import CRGDataset
11
+ from ._types import EvalOptions
12
+ from ._eval import (
13
+ _eval_uv_to_z,
14
+ _eval_uv_to_xy,
15
+ _xy_to_uv,
16
+ _eval_uv_to_pk,
17
+ )
18
+
19
+
20
+ class ContactPoint:
21
+ """Evaluation context for a :class:`CRGDataset`.
22
+
23
+ A ``ContactPoint`` binds a dataset to a set of :class:`EvalOptions` and
24
+ maintains a small history deque that accelerates successive nearby queries
25
+ (warm start for the xy→uv search).
26
+
27
+ Multiple ``ContactPoint`` instances on the same dataset are safe to use
28
+ independently (each has its own history and options).
29
+
30
+ Parameters
31
+ ----------
32
+ dataset:
33
+ The CRG dataset to evaluate against.
34
+ **options:
35
+ Keyword arguments that override :class:`EvalOptions` defaults.
36
+ Keys must be valid :class:`EvalOptions` field names.
37
+
38
+ Examples
39
+ --------
40
+ >>> import crgutils
41
+ >>> ds = crgutils.load("road.crg")
42
+ >>> cp = crgutils.ContactPoint(ds)
43
+ >>> z = cp.eval_uv_to_z(5.0, 0.0)
44
+ >>> x, y = cp.eval_uv_to_xy(5.0, 0.5)
45
+ >>> u, v = cp.eval_xy_to_uv(x, y)
46
+ """
47
+
48
+ def __init__(self, dataset: CRGDataset, **options: Any) -> None:
49
+ self._ds = dataset
50
+ # Merge dataset-level default options with caller overrides
51
+ merged = {**dataset.default_options, **options}
52
+ self._options = EvalOptions(**merged)
53
+ self._history: deque[tuple[float, float, int]] = deque(
54
+ maxlen=self._options.history_size
55
+ )
56
+
57
+ # Pre-seed history if ref_line_search_u is set
58
+ if self._options.ref_line_search_u is not None:
59
+ su = self._options.ref_line_search_u
60
+ idx = int((su - dataset.u_min) / dataset.u_inc)
61
+ idx = max(1, min(idx, dataset.n_u - 1))
62
+ x_seed, y_seed = _eval_uv_to_xy(dataset, self._options, su, 0.0)
63
+ self._history.appendleft((x_seed, y_seed, idx))
64
+
65
+ # ------------------------------------------------------------------
66
+ # Factory
67
+ # ------------------------------------------------------------------
68
+
69
+ def with_options(self, **overrides: Any) -> "ContactPoint":
70
+ """Return a new ``ContactPoint`` with option overrides applied.
71
+
72
+ The new instance shares the same dataset but gets a fresh history.
73
+ """
74
+ from dataclasses import asdict
75
+ current = asdict(self._options)
76
+ current.update(overrides)
77
+ return ContactPoint(self._ds, **current)
78
+
79
+ # ------------------------------------------------------------------
80
+ # Properties
81
+ # ------------------------------------------------------------------
82
+
83
+ @property
84
+ def options(self) -> EvalOptions:
85
+ return self._options
86
+
87
+ @property
88
+ def dataset(self) -> CRGDataset:
89
+ return self._ds
90
+
91
+ # ------------------------------------------------------------------
92
+ # Primary evaluation methods
93
+ # ------------------------------------------------------------------
94
+
95
+ def eval_uv_to_z(self, u: float, v: float) -> float:
96
+ """Bilinear elevation at road coordinate (u, v) [m]."""
97
+ return _eval_uv_to_z(self._ds, self._options, u, v)
98
+
99
+ def eval_xy_to_z(self, x: float, y: float) -> float:
100
+ """Elevation at Cartesian position (x, y) [m]."""
101
+ u, v = _xy_to_uv(self._ds, self._options, x, y, self._history)
102
+ return _eval_uv_to_z(self._ds, self._options, u, v)
103
+
104
+ def eval_uv_to_xy(self, u: float, v: float) -> tuple[float, float]:
105
+ """Convert road (u, v) to Cartesian (x, y) [m]."""
106
+ return _eval_uv_to_xy(self._ds, self._options, u, v)
107
+
108
+ def eval_xy_to_uv(self, x: float, y: float) -> tuple[float, float]:
109
+ """Convert Cartesian (x, y) to road (u, v) [m]."""
110
+ return _xy_to_uv(self._ds, self._options, x, y, self._history)
111
+
112
+ def eval_uv_to_pk(self, u: float, v: float) -> tuple[float, float]:
113
+ """Heading [rad] and curvature [1/m] at road (u, v)."""
114
+ return _eval_uv_to_pk(self._ds, self._options, u, v)
115
+
116
+ def eval_xy_to_pk(self, x: float, y: float) -> tuple[float, float]:
117
+ """Heading [rad] and curvature [1/m] at Cartesian (x, y)."""
118
+ u, v = _xy_to_uv(self._ds, self._options, x, y, self._history)
119
+ return _eval_uv_to_pk(self._ds, self._options, u, v)
120
+
121
+ # ------------------------------------------------------------------
122
+ # Vectorised convenience methods
123
+ # ------------------------------------------------------------------
124
+
125
+ def eval_uv_to_z_grid(
126
+ self,
127
+ u: "np.ndarray",
128
+ v: "np.ndarray",
129
+ ) -> "np.ndarray":
130
+ """Vectorised elevation evaluation over arrays of (u, v) pairs.
131
+
132
+ Parameters
133
+ ----------
134
+ u, v:
135
+ 1-D arrays of matching length or broadcastable shapes.
136
+
137
+ Returns
138
+ -------
139
+ np.ndarray
140
+ Elevation values, same shape as broadcast(u, v).
141
+ """
142
+ u_arr = np.asarray(u, dtype=float).ravel()
143
+ v_arr = np.asarray(v, dtype=float).ravel()
144
+ z_arr = np.empty(len(u_arr), dtype=float)
145
+ for i in range(len(u_arr)):
146
+ z_arr[i] = _eval_uv_to_z(self._ds, self._options, float(u_arr[i]), float(v_arr[i]))
147
+ return z_arr
148
+
149
+ def eval_xy_to_z_grid(
150
+ self,
151
+ x: "np.ndarray",
152
+ y: "np.ndarray",
153
+ ) -> "np.ndarray":
154
+ """Vectorised elevation evaluation over arrays of (x, y) pairs."""
155
+ x_arr = np.asarray(x, dtype=float).ravel()
156
+ y_arr = np.asarray(y, dtype=float).ravel()
157
+ z_arr = np.empty(len(x_arr), dtype=float)
158
+ for i in range(len(x_arr)):
159
+ z_arr[i] = self.eval_xy_to_z(float(x_arr[i]), float(y_arr[i]))
160
+ return z_arr
161
+
162
+ def __repr__(self) -> str:
163
+ return (
164
+ f"ContactPoint(dataset={self._ds.source_file!r}, "
165
+ f"border_mode_u={self._options.border_mode_u.name}, "
166
+ f"border_mode_v={self._options.border_mode_v.name})"
167
+ )
@@ -0,0 +1,96 @@
1
+ """Convenience functions that combine multiple crgutils steps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ._dataset import CRGDataset
8
+ from ._loader import load
9
+ from ._check import check
10
+ from ._modifiers import apply_modifiers
11
+ from ._types import GridNaNMode
12
+
13
+
14
+ def read(
15
+ path: str | Path,
16
+ *,
17
+ raise_on_error: bool = True,
18
+ # ---- scale modifiers ----
19
+ scale_z: float | None = None,
20
+ scale_slope: float | None = None,
21
+ scale_bank: float | None = None,
22
+ scale_length: float | None = None,
23
+ scale_width: float | None = None,
24
+ scale_curvature: float | None = None,
25
+ # ---- NaN handling ----
26
+ grid_nan_mode: int | str | GridNaNMode | None = None,
27
+ grid_nan_offset: float | None = None,
28
+ # ---- repositioning by reference point ----
29
+ ref_point_u: float | None = None,
30
+ ref_point_u_frac: float | None = None,
31
+ ref_point_u_offset: float | None = None,
32
+ ref_point_v: float | None = None,
33
+ ref_point_v_frac: float | None = None,
34
+ ref_point_v_offset: float | None = None,
35
+ ref_point_x: float | None = None,
36
+ ref_point_y: float | None = None,
37
+ ref_point_z: float | None = None,
38
+ ref_point_phi: float | None = None,
39
+ # ---- reference line offsets / rotation ----
40
+ ref_line_offset_x: float | None = None,
41
+ ref_line_offset_y: float | None = None,
42
+ ref_line_offset_z: float | None = None,
43
+ ref_line_offset_phi: float | None = None,
44
+ ref_line_rot_center_x: float | None = None,
45
+ ref_line_rot_center_y: float | None = None,
46
+ ) -> CRGDataset | None:
47
+ """Load a .crg file, validate it, and apply modifiers in one call.
48
+
49
+ Equivalent to::
50
+
51
+ ds = crgutils.load(path)
52
+ crgutils.check(ds, raise_on_error=True)
53
+ crgutils.apply_modifiers(ds, **ds.pending_modifiers)
54
+ ds.pending_modifiers.clear()
55
+
56
+ File-embedded ``pending_modifiers`` are always applied first. Any modifier
57
+ kwargs passed here override same-named keys from the file's embedded
58
+ modifiers.
59
+
60
+ Parameters
61
+ ----------
62
+ path:
63
+ Path to a ``.crg`` file.
64
+ raise_on_error:
65
+ If ``True`` (default), raise :exc:`ValueError` on validation failure.
66
+ If ``False``, return ``None`` instead.
67
+ scale_z, scale_slope, ... :
68
+ Modifier overrides — see :func:`~crgutils.apply_modifiers`.
69
+
70
+ Returns
71
+ -------
72
+ CRGDataset | None
73
+ The loaded, validated, modified dataset; or ``None`` if
74
+ *raise_on_error* is ``False`` and validation fails.
75
+ """
76
+ ds = load(path)
77
+ if not check(ds, raise_on_error=raise_on_error):
78
+ return None
79
+
80
+ # Build dict of explicitly-supplied caller overrides (skip None sentinels)
81
+ _locs = locals()
82
+ _modifier_keys = [
83
+ "scale_z", "scale_slope", "scale_bank", "scale_length",
84
+ "scale_width", "scale_curvature", "grid_nan_mode", "grid_nan_offset",
85
+ "ref_point_u", "ref_point_u_frac", "ref_point_u_offset",
86
+ "ref_point_v", "ref_point_v_frac", "ref_point_v_offset",
87
+ "ref_point_x", "ref_point_y", "ref_point_z", "ref_point_phi",
88
+ "ref_line_offset_x", "ref_line_offset_y", "ref_line_offset_z",
89
+ "ref_line_offset_phi", "ref_line_rot_center_x", "ref_line_rot_center_y",
90
+ ]
91
+ caller_overrides = {k: _locs[k] for k in _modifier_keys if _locs[k] is not None}
92
+
93
+ merged = {**ds.pending_modifiers, **caller_overrides}
94
+ apply_modifiers(ds, **merged)
95
+ ds.pending_modifiers.clear()
96
+ return ds
crgutils/_dataset.py ADDED
@@ -0,0 +1,192 @@
1
+ """CRGDataset: the core data container for a loaded .crg file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ if TYPE_CHECKING:
9
+ from ._contact_point import ContactPoint
10
+ from ._types import BorderMode, CurvMode, RefLineContinue
11
+
12
+ import numpy as np
13
+
14
+
15
+ @dataclass
16
+ class CRGDataset:
17
+ """Holds all arrays and metadata parsed from a single .crg file.
18
+
19
+ Coordinate system
20
+ -----------------
21
+ u — arc-length along the reference line [m]
22
+ v — lateral offset from the reference line [m]
23
+ x, y — Cartesian world coordinates of the reference line [m]
24
+ phi — heading angle of the reference line [rad]
25
+ z — elevation grid, shape (n_v, n_u) [m], float32
26
+
27
+ All arrays are stored in increasing u / v order.
28
+ """
29
+
30
+ # --- Reference line (all float64, shape (n_u,)) ---
31
+ u: np.ndarray # u coordinates
32
+ x: np.ndarray # Cartesian x of ref line (derived from phi if absent)
33
+ y: np.ndarray # Cartesian y of ref line
34
+ phi: np.ndarray # heading angles [rad]
35
+
36
+ # --- Cross-section grid ---
37
+ v: np.ndarray # v positions, shape (n_v,), float64
38
+ z: np.ndarray # elevation grid, shape (n_v, n_u), float32
39
+ z_mean: np.ndarray # per-v-channel mean subtracted during normalisation, (n_v,)
40
+
41
+ # --- Optional reference line channels (shape (n_u,) or scalar) ---
42
+ ref_z: np.ndarray | None # ref line elevation [m]
43
+ bank: np.ndarray | None # banking [1/m] (tan of bank angle)
44
+ slope: np.ndarray | None # slope [1]
45
+
46
+ # --- Grid spacing / topology ---
47
+ u_inc: float
48
+ v_inc: float = 0.0 # 0 if v is non-equally spaced
49
+ u_is_closed: bool = False
50
+ u_close_min: float = 0.0
51
+ u_close_max: float = 0.0
52
+ v_equally_spaced: bool = True
53
+ has_bank: bool = False
54
+
55
+ # --- Precomputed trigonometry for extrapolation ---
56
+ phi_first_sin: float = 0.0
57
+ phi_first_cos: float = 1.0
58
+ phi_last_sin: float = 0.0
59
+ phi_last_cos: float = 1.0
60
+
61
+ # --- Options / modifiers embedded in the file header ---
62
+ default_options: dict[str, Any] = field(default_factory=dict)
63
+ pending_modifiers: dict[str, Any] = field(default_factory=dict)
64
+
65
+ # --- Source info (informational only) ---
66
+ source_file: str = ""
67
+ comment: str = ""
68
+
69
+ # ------------------------------------------------------------------
70
+ # Properties
71
+ # ------------------------------------------------------------------
72
+
73
+ @property
74
+ def u_min(self) -> float:
75
+ return float(self.u[0])
76
+
77
+ @property
78
+ def u_max(self) -> float:
79
+ return float(self.u[-1])
80
+
81
+ @property
82
+ def v_min(self) -> float:
83
+ return float(self.v[0])
84
+
85
+ @property
86
+ def v_max(self) -> float:
87
+ return float(self.v[-1])
88
+
89
+ @property
90
+ def u_range(self) -> tuple[float, float]:
91
+ return (self.u_min, self.u_max)
92
+
93
+ @property
94
+ def v_range(self) -> tuple[float, float]:
95
+ return (self.v_min, self.v_max)
96
+
97
+ @property
98
+ def shape(self) -> tuple[int, int]:
99
+ """(n_v, n_u)"""
100
+ return self.z.shape
101
+
102
+ @property
103
+ def n_u(self) -> int:
104
+ return len(self.u)
105
+
106
+ @property
107
+ def n_v(self) -> int:
108
+ return len(self.v)
109
+
110
+ # ------------------------------------------------------------------
111
+ # Human-readable summaries
112
+ # ------------------------------------------------------------------
113
+
114
+ def info(self) -> str:
115
+ lines = [
116
+ f"CRGDataset: {self.source_file}",
117
+ f" u range : [{self.u_min:.4f}, {self.u_max:.4f}] m (n={self.n_u}, inc={self.u_inc:.4f})",
118
+ f" v range : [{self.v_min:.4f}, {self.v_max:.4f}] m (n={self.n_v}"
119
+ + (f", inc={self.v_inc:.4f})" if self.v_equally_spaced else ", irregular)"),
120
+ f" z shape : {self.z.shape} (float32)",
121
+ f" has_bank: {self.has_bank}",
122
+ f" closed : {self.u_is_closed}",
123
+ ]
124
+ return "\n".join(lines)
125
+
126
+ def channel_info(self) -> str:
127
+ n_channels = 1 + self.n_v # U virtual + v channels
128
+ if self.phi is not None and len(self.phi):
129
+ n_channels += 1
130
+ lines = [f"Channel count: {n_channels}"]
131
+ lines.append(f" U : u coordinate, virtual, [{self.u_min:.4f}, {self.u_max:.4f}] m")
132
+ if self.phi is not None and len(self.phi):
133
+ lines.append(f" phi: heading angle, [{float(self.phi.min()):.4f}, {float(self.phi.max()):.4f}] rad")
134
+ for i, vpos in enumerate(self.v):
135
+ zch = self.z[i]
136
+ lines.append(
137
+ f" D{i+1} : v={vpos:+.4f} m, z in [{float(np.nanmin(zch)+self.z_mean[i]):.4f}, "
138
+ f"{float(np.nanmax(zch)+self.z_mean[i]):.4f}] m"
139
+ )
140
+ return "\n".join(lines)
141
+
142
+ def road_info(self) -> str:
143
+ lines = [
144
+ "Road information:",
145
+ f" reference line : u=[{self.u_min:.4f}, {self.u_max:.4f}] m",
146
+ f" x range : [{float(self.x.min()):.4f}, {float(self.x.max()):.4f}] m",
147
+ f" y range : [{float(self.y.min()):.4f}, {float(self.y.max()):.4f}] m",
148
+ f" phi range : [{float(self.phi.min()):.4f}, {float(self.phi.max()):.4f}] rad",
149
+ f" v range : [{self.v_min:.4f}, {self.v_max:.4f}] m",
150
+ ]
151
+ z_all = self.z.astype(np.float64) + self.z_mean[:, np.newaxis]
152
+ lines.append(
153
+ f" z range : [{float(np.nanmin(z_all)):.4f}, {float(np.nanmax(z_all)):.4f}] m"
154
+ )
155
+ return "\n".join(lines)
156
+
157
+ def create_contact_point(
158
+ self,
159
+ *,
160
+ border_mode_u: BorderMode | None = None,
161
+ border_mode_v: BorderMode | None = None,
162
+ border_offset_u: float | None = None,
163
+ border_offset_v: float | None = None,
164
+ smooth_u_begin: float | None = None,
165
+ smooth_u_end: float | None = None,
166
+ ref_line_continue: RefLineContinue | None = None,
167
+ curv_mode: CurvMode | None = None,
168
+ ref_line_search_u: float | None = None,
169
+ ref_line_close: float | None = None,
170
+ ref_line_far: float | None = None,
171
+ history_size: int | None = None,
172
+ ) -> ContactPoint:
173
+ """Create a :class:`ContactPoint` evaluation context for this dataset.
174
+
175
+ All parameters correspond to fields of :class:`~crgutils.EvalOptions`.
176
+ Omitted parameters use the dataset's ``default_options`` or the
177
+ :class:`~crgutils.EvalOptions` defaults.
178
+
179
+ Returns
180
+ -------
181
+ ContactPoint
182
+ A new evaluation context bound to this dataset.
183
+ """
184
+ from ._contact_point import ContactPoint # lazy import — avoids circular dep
185
+ _locs = locals()
186
+ _option_keys = [
187
+ "border_mode_u", "border_mode_v", "border_offset_u", "border_offset_v",
188
+ "smooth_u_begin", "smooth_u_end", "ref_line_continue", "curv_mode",
189
+ "ref_line_search_u", "ref_line_close", "ref_line_far", "history_size",
190
+ ]
191
+ options = {k: _locs[k] for k in _option_keys if _locs[k] is not None}
192
+ return ContactPoint(self, **options)