step.motion.path.passes.spline

Spline-conversion pass — translate a linear/turn sequence into a SplinePath.

Unlike merge / corner-cut (pure node-list transforms), this pass replaces the entire path with a single ABSOLUTE spline-driven step. It is therefore a “terminal” transform — typically the last entry in a pipeline that opts in to spline mode.

The pass densely samples the SAME centripetal Catmull-Rom curve used by the relative SplinePath / C++ SplineMotion (see catmull_rom_spline.hpp) and emits ONE GotoWaypoints side-action that follows those dense samples CLOSED-LOOP on the localization particle filter — so drift is corrected along the whole curve. The relative SplineMotion / smooth_path(spline=True) path (which uses build_spline_step() directly) is UNAFFECTED.

Classes

SplinifyPass

Terminal pass that collapses each motion RUN into one continuous spline.

Functions

sample_centripetal_catmull_rom(→ list[tuple[float, float]])

Densely sample a centripetal Catmull-Rom curve through control_points_m.

segments_to_spline_waypoints(→ list[tuple[float, float]])

Compute (forward_cm, left_cm) control waypoints from a segment sequence.

build_spline_step(→ step.motion.spline_path.SplinePath)

Build a SplinePath from a fully-splinifiable node list.

Module Contents

step.motion.path.passes.spline.sample_centripetal_catmull_rom(control_points_m: list[tuple[float, float]], spacing_m: float = _SAMPLE_SPACING_M) list[tuple[float, float]]

Densely sample a centripetal Catmull-Rom curve through control_points_m.

Matches CatmullRomSpline in catmull_rom_spline.hpp exactly:

  • Centripetal parameterisation (alpha=0.5): knot spacing is the square root of the chord length between consecutive control points.

  • Virtual endpoints via REFLECTION (not duplication) so the curve passes through every control point: P[-1] = 2*P[0] - P[1] and P[N] = 2*P[N-1] - P[N-2].

  • Barry-Goldman segment evaluation.

Returns a flat list of (x_m, y_m) points, including the first and last control points, with consecutive points roughly spacing_m apart so the full path from the first to the last control point is covered.

Parameters:
  • control_points_m – At least 2 control points (x_m, y_m).

  • spacing_m – Target arc-length spacing between samples (metres).

Returns:

Dense [(x_m, y_m), ...] samples along the curve.

step.motion.path.passes.spline.segments_to_spline_waypoints(segments: list[step.motion.path.ir.Segment]) list[tuple[float, float]]

Compute (forward_cm, left_cm) control waypoints from a segment sequence.

Walks the SAME body-frame pose integration as to_absolute (+forward is the robot’s initial heading, +left is 90° CCW, heading CCW-positive):

  • linear advances along its axis and emits the endpoint.

  • diagonal holds heading and advances by its known body-frame displacement (forward_m, left_m) rotated into world, emitting the endpoint.

  • arc is SAMPLED into several intermediate control points (≈ one every 18° of sweep, min 2) so the Catmull-Rom traces the arc’s curvature; the running pose advances to the arc’s exact endpoint/heading.

  • turn only updates the running heading (no waypoint) — the corner is rounded by the Catmull-Rom through the neighbouring waypoints.

The robot’s start position (0, 0) is not included — it is the implicit origin for spline().

step.motion.path.passes.spline.build_spline_step(nodes: list[step.motion.path.ir.PathNode | None]) step.motion.spline_path.SplinePath

Build a SplinePath from a fully-splinifiable node list.

Strict, single-spline builder used by smooth_path(spline=True): raises ValueError for any node that cannot be represented as a waypoint — deferred placeholders, side actions, condition-based segments, follow_line / spline segments, or segments with unknown endpoints. Arcs and diagonals ARE accepted — they are integrated into control waypoints the Catmull-Rom traces (arcs are sampled along their curvature). A minimum of 2 control waypoints (after sampling) is required so the spline has at least 2 explicit control points.

The optimize().splinify() pass does NOT use this — it splits at barriers instead (see SplinifyPass).

class step.motion.path.passes.spline.SplinifyPass(absolute: bool = False)

Terminal pass that collapses each motion RUN into one continuous spline.

Rather than demanding the whole path be a single uninterrupted curve, the pass SPLITS at barriers — exactly like merge / cut_corners / to_absolute already do — so a path that mixes motion with side effects still splinifies cleanly instead of raising:

  • A blocking (inline) side action, a deferred (None) placeholder, a condition-based / unknown-endpoint segment, a follow_line / spline segment, or a turn_to_heading BREAKS the run: it is passed through unchanged and the motion on either side becomes a separate spline. (A blocking side action must stop the robot anyway, so the velocity break at that one point is unavoidable — the step still fires at the same place.)

  • A background side action does NOT break the run: the spline flows across it (velocity stays continuous) and the step is lifted to the run’s start, launched fire-and-forget so it overlaps the motion.

Each maximal run of splinifiable segments (linear / diagonal / arc / plain turn) with ≥ 2 control waypoints becomes one spline; a run too short to splinify (a lone linear, or turns only) is kept as its raw segments.

Two render modes, chosen by the builder from whether to_absolute() is on:

  • relative (default) → Segment(kind="spline") per run: the continuous, odometry-driven C++ SplineMotion follows the centripetal Catmull-Rom.

  • absoluteSideAction(SplineFollow) per run: a continuous pure-pursuit follower rides the SAME curve closed-loop on the localization particle filter, correcting drift along the whole curve. Holonomic — heading held, independent of the path tangent.

Terminal: it replaces every motion run wholesale, so nothing may run after it.

name = 'splinify'
requires
terminal = True
absolute = False
run(nodes: list[step.motion.path.ir.PathNode | None]) list[step.motion.path.ir.PathNode | None]