Skip to content

strategies

toolpath_engine.strategies.base.ToolpathStrategy

Bases: ABC

Base class for toolpath generation strategies.

Subclass this to create custom strategies. The strategy receives geometry and parameters, and returns a ToolpathCollection.

Source code in utde_v0.1.0/toolpath_engine/strategies/base.py
class ToolpathStrategy(ABC):
    """
    Base class for toolpath generation strategies.

    Subclass this to create custom strategies. The strategy receives
    geometry and parameters, and returns a ToolpathCollection.
    """

    def __init__(self, name: str = ""):
        self.name = name
        self.params: Dict[str, Any] = {}

    def configure(self, **kwargs):
        """Set strategy parameters."""
        self.params.update(kwargs)
        return self  # allow chaining

    @abstractmethod
    def generate(self, **kwargs) -> ToolpathCollection:
        """
        Generate toolpaths from geometry.

        Returns a ToolpathCollection with typed, oriented points.
        """
        pass

    def __repr__(self):
        return f"{self.__class__.__name__}('{self.name}', params={self.params})"

configure(**kwargs)

Set strategy parameters.

Source code in utde_v0.1.0/toolpath_engine/strategies/base.py
def configure(self, **kwargs):
    """Set strategy parameters."""
    self.params.update(kwargs)
    return self  # allow chaining

generate(**kwargs) abstractmethod

Generate toolpaths from geometry.

Returns a ToolpathCollection with typed, oriented points.

Source code in utde_v0.1.0/toolpath_engine/strategies/base.py
@abstractmethod
def generate(self, **kwargs) -> ToolpathCollection:
    """
    Generate toolpaths from geometry.

    Returns a ToolpathCollection with typed, oriented points.
    """
    pass

toolpath_engine.strategies.follow_curve.FollowCurveStrategy

Bases: ToolpathStrategy

Generate a toolpath by following one or more curves.

Parameters:

Name Type Description Default
feed_rate

Feed rate in mm/min (default 1000)

required
spacing

Resample spacing in mm (default None = use original points)

required
path_type

Classification string (default "cut")

required
Source code in utde_v0.1.0/toolpath_engine/strategies/follow_curve.py
class FollowCurveStrategy(ToolpathStrategy):
    """
    Generate a toolpath by following one or more curves.

    Parameters:
        feed_rate: Feed rate in mm/min (default 1000)
        spacing: Resample spacing in mm (default None = use original points)
        path_type: Classification string (default "cut")
    """

    def __init__(self):
        super().__init__("follow_curve")

    def generate(
        self,
        curve: Optional[Curve] = None,
        curves: Optional[list] = None,
        feed_rate: float = 1000.0,
        spacing: Optional[float] = None,
        path_type: str = "cut",
        source: str = "follow_curve",
        **kwargs,
    ) -> ToolpathCollection:
        collection = ToolpathCollection(name="follow_curve")

        curve_list = curves or ([curve] if curve else [])

        for crv in curve_list:
            if spacing:
                crv = crv.resample(spacing)

            points = []
            for i, pt in enumerate(crv.points):
                tp = ToolpathPoint(
                    position=pt,
                    orientation=Orientation.z_down(),
                    feed_rate=feed_rate,
                    path_type=path_type,
                    source=source,
                    curve_ref=crv.name,
                )
                points.append(tp)

            toolpath = Toolpath(points, name=f"follow_{crv.name}")
            collection.add(toolpath)

        return collection

toolpath_engine.strategies.raster_fill.RasterFillStrategy

Bases: ToolpathStrategy

Generate raster (zigzag) toolpaths over a surface, clipped to the actual surface boundary.

Parameters:

Name Type Description Default
spacing

Distance between raster lines (mm).

required
angle

Raster angle in degrees (0 = along U direction).

required
feed_rate

Feed rate (mm/min).

required
step_size

Point spacing along each raster pass (mm).

required
overshoot

Extra extension past bounds when no boundary is set (mm).

required
zigzag

Alternate pass direction on each row (default True).

required
path_type

Toolpath type label.

required
normal_offset

Lift each point off the surface in the normal direction (mm).

required
edge_inset

Shrink the clipping boundary inward from the surface edges (mm).

required
Source code in utde_v0.1.0/toolpath_engine/strategies/raster_fill.py
class RasterFillStrategy(ToolpathStrategy):
    """
    Generate raster (zigzag) toolpaths over a surface, clipped to the actual
    surface boundary.

    Parameters:
        spacing:       Distance between raster lines (mm).
        angle:         Raster angle in degrees (0 = along U direction).
        feed_rate:     Feed rate (mm/min).
        step_size:     Point spacing along each raster pass (mm).
        overshoot:     Extra extension past bounds when no boundary is set (mm).
        zigzag:        Alternate pass direction on each row (default True).
        path_type:     Toolpath type label.
        normal_offset: Lift each point off the surface in the normal direction (mm).
        edge_inset:    Shrink the clipping boundary inward from the surface edges (mm).
    """

    def __init__(self):
        super().__init__("raster_fill")

    def generate(
        self,
        surface: Optional[Surface] = None,
        spacing: float = 5.0,
        angle: float = 0.0,
        feed_rate: float = 1000.0,
        step_size: float = 1.0,
        overshoot: float = 0.0,
        zigzag: bool = True,
        path_type: str = "infill",
        normal_offset: float = 0.0,
        edge_inset: float = 0.0,
        **kwargs,
    ) -> ToolpathCollection:
        if surface is None:
            surface = Surface.plane()

        collection = ToolpathCollection(name="raster_fill")
        u_min, u_max, v_min, v_max = surface.bounds
        angle_rad = math.radians(angle)
        cos_a = math.cos(angle_rad)
        sin_a = math.sin(angle_rad)

        u_center = (u_min + u_max) / 2.0
        v_center = (v_min + v_max) / 2.0
        half_diag = math.sqrt((u_max - u_min) ** 2 + (v_max - v_min) ** 2) / 2.0

        # ── Build the 2D UV clipping polygon ──────────────────────────────────
        clip_poly: Optional[List[Tuple[float, float]]] = None

        if surface.boundary_loop and surface.surface_type == "plane":
            uv_pts = _project_boundary_to_uv(surface.boundary_loop, surface)
            if uv_pts and len(uv_pts) >= 3:
                uv_pts = _ensure_ccw(uv_pts)
                if edge_inset > 0:
                    uv_pts = _inset_polygon_2d(uv_pts, edge_inset)
                if len(uv_pts) >= 3:
                    clip_poly = uv_pts

        if clip_poly is None:
            # Fallback: rectangular surface bounds, expanded by overshoot
            o = overshoot
            clip_poly = _ensure_ccw([
                (u_min - o, v_min - o),
                (u_max + o, v_min - o),
                (u_max + o, v_max + o),
                (u_min - o, v_max + o),
            ])

        # ── Sweep the raster lines across the full UV extent ──────────────────
        # Use the half-diagonal from the surface centre as sweep radius so that
        # rotated raster lines fully cover the surface before clipping.
        sweep = half_diag + max(overshoot, 0)
        v_sweep_min = v_center - sweep
        v_sweep_max = v_center + sweep
        u_line_min  = u_center - sweep
        u_line_max  = u_center + sweep

        num_passes = max(1, int((v_sweep_max - v_sweep_min) / spacing))

        for pass_idx in range(num_passes + 1):
            v = v_sweep_min + pass_idx * spacing

            # Endpoints of this raster line in UV space (rotated by angle)
            uv_start = (u_line_min * cos_a - v * sin_a,
                        u_line_min * sin_a + v * cos_a)
            uv_end   = (u_line_max * cos_a - v * sin_a,
                        u_line_max * sin_a + v * cos_a)

            segments = _clip_segment_to_polygon(uv_start, uv_end, clip_poly)

            # Zigzag: reverse segment order and direction on odd passes
            if zigzag and pass_idx % 2 == 1:
                segments = [(e, s) for s, e in reversed(segments)]

            for seg_s, seg_e in segments:
                seg_len = math.sqrt(
                    (seg_e[0] - seg_s[0]) ** 2 + (seg_e[1] - seg_s[1]) ** 2
                )
                num_steps = max(2, int(seg_len / step_size))

                points = []
                for step in range(num_steps + 1):
                    t  = step / num_steps
                    ru = seg_s[0] + t * (seg_e[0] - seg_s[0])
                    rv = seg_s[1] + t * (seg_e[1] - seg_s[1])

                    pos    = surface.evaluate(ru, rv)
                    normal = surface.normal_at(ru, rv)

                    if normal_offset != 0.0:
                        pos = pos + normal * normal_offset

                    points.append(ToolpathPoint(
                        position=pos,
                        orientation=Orientation.from_vector(normal),
                        feed_rate=feed_rate,
                        path_type=path_type,
                        source="raster_fill",
                        surface_ref=surface.name,
                    ))

                if points:
                    collection.add(
                        Toolpath(points, name=f"raster_pass_{pass_idx}"),
                        layer=0,
                    )

        return collection

toolpath_engine.strategies.contour_parallel.ContourParallelStrategy

Bases: ToolpathStrategy

Generate contour-parallel (offset) toolpaths from a boundary curve.

Parameters:

Name Type Description Default
stepover

Distance between offset passes (mm)

required
num_passes

Number of offset passes (or auto from stepover)

required
feed_rate

Feed rate mm/min

required
direction

"inward" or "outward"

required
Source code in utde_v0.1.0/toolpath_engine/strategies/contour_parallel.py
class ContourParallelStrategy(ToolpathStrategy):
    """
    Generate contour-parallel (offset) toolpaths from a boundary curve.

    Parameters:
        stepover: Distance between offset passes (mm)
        num_passes: Number of offset passes (or auto from stepover)
        feed_rate: Feed rate mm/min
        direction: "inward" or "outward"
    """

    def __init__(self):
        super().__init__("contour_parallel")

    def _offset_curve_2d(self, curve: Curve, distance: float) -> Curve:
        """
        Offset a curve by a distance (positive = outward, negative = inward).
        Simple 2D offset using point normals — works for planar curves.
        """
        if len(curve.points) < 3:
            return curve

        new_points = []
        n = len(curve.points)

        for i in range(n):
            # Compute local normal (perpendicular to tangent, in XY plane)
            p = curve.points[i]
            tangent = curve.tangent_at(i)
            # 2D normal: rotate tangent 90° in XY
            normal = Vector3(-tangent.y, tangent.x, 0).normalized()

            # Offset point
            new_p = p + normal * distance
            new_points.append(new_p)

        return Curve(new_points, name=f"{curve.name}_offset_{distance:.1f}", closed=curve.closed)

    def generate(
        self,
        boundary: Optional[Curve] = None,
        stepover: float = 5.0,
        num_passes: int = 5,
        feed_rate: float = 1000.0,
        direction: str = "inward",
        path_type: str = "contour",
        **kwargs,
    ) -> ToolpathCollection:
        if boundary is None:
            # Default: a square boundary
            boundary = Curve.from_points(
                [(50, 50, 0), (-50, 50, 0), (-50, -50, 0), (50, -50, 0), (50, 50, 0)],
                name="default_boundary",
                closed=True,
            )

        collection = ToolpathCollection(name="contour_parallel")
        sign = -1.0 if direction == "inward" else 1.0

        for pass_idx in range(num_passes):
            offset_dist = sign * stepover * (pass_idx + 1)
            offset_curve = self._offset_curve_2d(boundary, offset_dist)

            points = []
            for pt in offset_curve.points:
                tp = ToolpathPoint(
                    position=pt,
                    orientation=Orientation.z_down(),
                    feed_rate=feed_rate,
                    path_type=path_type,
                    source="contour_parallel",
                    curve_ref=boundary.name,
                )
                points.append(tp)

            toolpath = Toolpath(points, name=f"contour_pass_{pass_idx}")
            collection.add(toolpath)

        return collection