Source code for gcoordinator.infill_generator

"""
This module provides functions for generating infill paths for 3D printing.


Infill patterns fall into two categories with different generation strategies:

  Category 1 – Implicit surface (TPMS):
    gyroid_infill, schwartz_p_infill
    Defined by a 3D equation f(X, Y, Z) = 0. The layer cross-section is obtained
    by extracting iso-contour lines of f sampled on the print-plane grid.

  Category 2 – Line pattern:
    line_infill, grid_infill, triangle_infill
    Defined as families of parallel straight lines. Each line is clipped against
    the boundary polygon using a scanline algorithm, guaranteeing that every
    returned Path is a true straight segment.
"""

import numpy as np
from contourpy import contour_generator
from gcoordinator.path_generator import Path, PathList


# ─────────────────────────────────────────────────────────────────────────────
# Shared helper
# ─────────────────────────────────────────────────────────────────────────────

def _points_in_polygon(polygon_xy: np.ndarray, points: np.ndarray) -> np.ndarray:
    """Vectorized even-odd ray-casting point-in-polygon test."""
    px, py = points[:, 0], points[:, 1]
    vx, vy = polygon_xy[:, 0], polygon_xy[:, 1]
    vx_next = np.roll(vx, -1)
    vy_next = np.roll(vy, -1)

    py_col      = py[:, np.newaxis]
    px_col      = px[:, np.newaxis]
    vy_row      = vy[np.newaxis, :]
    vy_next_row = vy_next[np.newaxis, :]
    vx_row      = vx[np.newaxis, :]
    vx_next_row = vx_next[np.newaxis, :]

    cond = (vy_row > py_col) != (vy_next_row > py_col)
    with np.errstate(divide='ignore', invalid='ignore'):
        x_intersect = vx_row + (py_col - vy_row) * (vx_next_row - vx_row) / (vy_next_row - vy_row)
    crosses = cond & (px_col < x_intersect)
    return np.count_nonzero(crosses, axis=1) % 2 == 1


# ─────────────────────────────────────────────────────────────────────────────
# Abstract base
# ─────────────────────────────────────────────────────────────────────────────

class _InfillGenerator:
    def __call__(self, path) -> PathList:
        raise NotImplementedError

    @staticmethod
    def _to_path_list(path) -> PathList:
        if isinstance(path, Path):
            return PathList([path])
        if isinstance(path, PathList):
            return path
        raise TypeError("path must be a Path or PathList object")


# ─────────────────────────────────────────────────────────────────────────────
# Category 1: Implicit surface – iso-contour extraction
# ─────────────────────────────────────────────────────────────────────────────

class _ImplicitSurfaceInfillGenerator(_InfillGenerator):
    """
    Generates infill by sampling a scalar field f(X, Y, z_height) on a 2-D grid
    and extracting iso-contour lines at level 0.

    Appropriate for TPMS-like equations whose zero-contour on the print plane
    forms closed or open curves without self-intersections at grid crossings.

    Subclasses must implement _equation().
    """

    def __call__(self, path) -> PathList:
        path_list = self._to_path_list(path)
        x, y, X, Y, z_height = self._build_grid(path_list)
        equation = self._equation(X, Y, z_height)
        mask = self._build_mask(path_list, X, Y)
        return self._contour_to_paths(x, y, equation * mask, z_height)

    def _equation(self, X: np.ndarray, Y: np.ndarray, z_height: float) -> np.ndarray:
        raise NotImplementedError

    def _resolution(self, min_x: float, max_x: float, min_y: float, max_y: float):
        """Grid resolution: default adaptive 0.4 mm step."""
        return int((max_x - min_x) / 0.4), int((max_y - min_y) / 0.4)

    def _build_grid(self, path_list: PathList):
        all_x = np.concatenate([p.x for p in path_list.paths if len(p.x) > 0])
        all_y = np.concatenate([p.y for p in path_list.paths if len(p.y) > 0])
        min_x, max_x = all_x.min(), all_x.max()
        min_y, max_y = all_y.min(), all_y.max()
        res_x, res_y = self._resolution(min_x, max_x, min_y, max_y)
        z_height = path_list.paths[0].center[2]
        x = np.linspace(min_x, max_x, res_x)
        y = np.linspace(min_y, max_y, res_y)
        X, Y = np.meshgrid(x, y)
        return x, y, X, Y, z_height

    @staticmethod
    def _build_mask(path_list: PathList, X: np.ndarray, Y: np.ndarray) -> np.ndarray:
        points = np.column_stack((X.ravel(), Y.ravel()))
        result = None
        for p in path_list.paths:
            polygon = np.column_stack([p.x, p.y])
            inside = _points_in_polygon(polygon, points).reshape(X.shape).astype(float)
            inside[inside == 1] = -1  # inside  → -1
            inside[inside == 0] =  1  # outside →  1
            result = inside if result is None else result * inside
        result[result == 1] = np.nan
        return result

    @staticmethod
    def _contour_to_paths(x, y, z, z_height: float) -> PathList:
        gen = contour_generator(x=x, y=y, z=z)
        paths = []
        for vertices in gen.lines(0.0):
            x_c = vertices[:, 0]
            y_c = vertices[:, 1]
            z_c = np.full_like(x_c, z_height)
            paths.append(Path(x_c, y_c, z_c))
        return PathList(paths)


# ─────────────────────────────────────────────────────────────────────────────
# Category 2: Line pattern – scanline clipping
# ─────────────────────────────────────────────────────────────────────────────

class _LinePatternInfillGenerator(_InfillGenerator):
    """
    Generates infill as one or more families of parallel straight lines,
    each clipped against the boundary polygon.

    Algorithm for each family (angle θ, spacing d):
      1. Rotate all polygon vertices by -θ  →  lines become horizontal.
      2. Generate scanlines at y' = k·d (in rotated frame) within the bbox.
      3. Clip each scanline against the rotated polygon (even-odd rule).
      4. Rotate the segment endpoints back by +θ  →  original frame.

    Every returned Path is guaranteed to be a straight 2-point segment.

    Subclasses must implement _line_families().
    """

    def _line_families(self) -> list[tuple[float, float]]:
        """Return [(angle_rad, spacing), ...] for each family of parallel lines."""
        raise NotImplementedError

    def __call__(self, path) -> PathList:
        path_list = self._to_path_list(path)
        polygons = [np.column_stack([p.x, p.y]) for p in path_list.paths
                    if len(p.x) > 0]
        z_height = float(path_list.paths[0].center[2])

        infill_paths = []
        for angle, spacing in self._line_families():
            cos_a, sin_a = np.cos(angle), np.sin(angle)

            # Rotate each boundary polygon by -angle
            rot_polygons = []
            for poly in polygons:
                rx =  poly[:, 0] * cos_a + poly[:, 1] * sin_a
                ry = -poly[:, 0] * sin_a + poly[:, 1] * cos_a
                rot_polygons.append(np.column_stack([rx, ry]))

            all_ry = np.concatenate([p[:, 1] for p in rot_polygons])
            all_rx = np.concatenate([p[:, 0] for p in rot_polygons])
            min_ry, max_ry = float(all_ry.min()), float(all_ry.max())
            min_rx, max_rx = float(all_rx.min()), float(all_rx.max())

            k0 = int(np.ceil(min_ry / spacing))
            k1 = int(np.floor(max_ry / spacing))
            for k in range(k0, k1 + 1):
                ry_val = k * spacing
                for rx0, rx1 in self._clip_scanline(ry_val, rot_polygons, min_rx, max_rx):
                    # Rotate endpoints back by +angle
                    x0 = rx0 * cos_a - ry_val * sin_a
                    y0 = rx0 * sin_a + ry_val * cos_a
                    x1 = rx1 * cos_a - ry_val * sin_a
                    y1 = rx1 * sin_a + ry_val * cos_a
                    infill_paths.append(Path(
                        np.array([x0, x1]),
                        np.array([y0, y1]),
                        np.array([z_height, z_height]),
                    ))

        return PathList(infill_paths)

    @staticmethod
    def _clip_scanline(y_val: float, polygons: list, lo_x: float, hi_x: float):
        """
        Find (x0, x1) segment pairs where the horizontal line y = y_val is inside
        the boundary (even-odd rule across all polygon edges).
        """
        crossings = []
        for poly in polygons:
            vy     = poly[:, 1]
            vx     = poly[:, 0]
            vy_next = np.roll(vy, -1)
            vx_next = np.roll(vx, -1)
            mask = (vy > y_val) != (vy_next > y_val)
            if mask.any():
                t  = (y_val - vy[mask]) / (vy_next[mask] - vy[mask])
                xi = vx[mask] + t * (vx_next[mask] - vx[mask])
                crossings.extend(xi.tolist())
        crossings.sort()
        segments = []
        for i in range(0, len(crossings) - 1, 2):
            x0 = max(crossings[i],     lo_x)
            x1 = min(crossings[i + 1], hi_x)
            if x0 < x1:
                segments.append((x0, x1))
        return segments


# ─────────────────────────────────────────────────────────────────────────────
# Concrete generators – Category 1 (implicit surface)
# ─────────────────────────────────────────────────────────────────────────────

class _GyroidInfillGenerator(_ImplicitSurfaceInfillGenerator):
    """
    Gyroid TPMS: sin(X'p)cos(Y'p) + sin(Y'p)cos(Zp) + sin(Zp)cos(X'p) = value
    X', Y' are the meshgrid coordinates rotated 45°. Pattern varies with z.
    """
    def __init__(self, infill_distance: float, value: float):
        self.infill_distance = infill_distance
        self.value = value

    def _equation(self, X, Y, z_height):
        theta = np.pi / 4
        p     = np.pi * np.cos(theta) * np.sqrt(2) / self.infill_distance
        rot_x =  X * np.cos(theta) + Y * np.sin(theta)
        rot_y = -X * np.sin(theta) + Y * np.cos(theta)
        return (
            np.sin(rot_x * p) * np.cos(rot_y * p)
            + np.sin(rot_y * p) * np.cos(z_height * p)
            + np.sin(z_height * p) * np.cos(rot_x * p)
            - self.value
        )


class _SchwartzPInfillGenerator(_ImplicitSurfaceInfillGenerator):
    """
    Schwartz P TPMS: cos(Xp) + cos(Yp) + cos(Zp) = value. Pattern varies with z.
    """
    def __init__(self, infill_distance: float, value: float):
        self.infill_distance = infill_distance
        self.value = value

    def _equation(self, X, Y, z_height):
        p = 2 * np.pi / self.infill_distance
        return np.cos(X * p) + np.cos(Y * p) + np.cos(z_height * p) - self.value


class _CustomImplicitInfillGenerator(_ImplicitSurfaceInfillGenerator):
    """
    User-defined implicit surface infill.

    The scalar field is provided as a callable ``equation_fn(X, Y, z_height)``
    that receives the meshgrid arrays and the layer height, and returns a
    2-D ndarray of the same shape.  The iso-contour at level 0 becomes the
    infill pattern.
    """
    def __init__(self, equation_fn, resolution: float):
        self._equation_fn = equation_fn
        self._res_step    = resolution   # grid step in mm

    def _resolution(self, min_x, max_x, min_y, max_y):
        return (
            max(2, int((max_x - min_x) / self._res_step)),
            max(2, int((max_y - min_y) / self._res_step)),
        )

    def _equation(self, X, Y, z_height):
        return self._equation_fn(X, Y, z_height)


# ─────────────────────────────────────────────────────────────────────────────
# Concrete generators – Category 2 (line pattern)
# ─────────────────────────────────────────────────────────────────────────────

class _LineInfillGenerator(_LinePatternInfillGenerator):
    """Single family of parallel lines at a given angle."""
    def __init__(self, infill_distance: float, angle: float):
        self.infill_distance = infill_distance
        self.angle = angle

    def _line_families(self):
        return [(self.angle, self.infill_distance)]


class _GridInfillGenerator(_LinePatternInfillGenerator):
    """Two families of axis-aligned lines (0° and 90°)."""
    def __init__(self, infill_distance: float):
        self.infill_distance = infill_distance

    def _line_families(self):
        d = self.infill_distance
        return [(0.0, d), (np.pi / 2, d)]


class _TriangleInfillGenerator(_LinePatternInfillGenerator):
    """Three families of lines at 0°, 60°, 120° forming an equilateral triangular grid."""
    def __init__(self, infill_distance: float):
        self.infill_distance = infill_distance

    def _line_families(self):
        d = self.infill_distance
        return [(0.0, d), (np.pi / 3, d), (2 * np.pi / 3, d)]


# ─────────────────────────────────────────────────────────────────────────────
# Public API – unified class (gc.Infill.xxx)
# ─────────────────────────────────────────────────────────────────────────────

[docs] class Infill: """ Namespace class for infill pattern generation, mirroring the Transform API. Usage:: import gcoordinator as gc import numpy as np infill = gc.Infill.gyroid(wall, infill_distance=2) infill = gc.Infill.schwartz_p(wall, infill_distance=2) infill = gc.Infill.line(wall, infill_distance=2, angle=np.pi/4) infill = gc.Infill.grid(wall, infill_distance=2) infill = gc.Infill.triangle(wall, infill_distance=2) # Custom implicit surface: def my_equation(X, Y, z_height): p = 2 * np.pi / 3.0 return np.sin(X * p) + np.cos(Y * p) * np.sin(z_height * p) infill = gc.Infill.custom_implicit(wall, my_equation) """
[docs] @staticmethod def gyroid(path, infill_distance=1, value=0) -> PathList: """ Gyroid (TPMS) infill. Pattern varies with layer height. Args: path (Path or PathList): Boundary of the infill region. infill_distance (float): Spacing between gyroid surfaces. value (float): Iso-level offset; 0 gives the mid-surface. Returns: PathList: Generated infill paths. """ return _GyroidInfillGenerator(infill_distance, value)(path)
[docs] @staticmethod def schwartz_p(path, infill_distance=1, value=0) -> PathList: """ Schwartz P (TPMS) infill. Pattern varies with layer height. Args: path (Path or PathList): Boundary of the infill region. infill_distance (float): Spacing between surfaces. value (float): Iso-level offset; 0 gives the mid-surface. Returns: PathList: Generated infill paths. """ return _SchwartzPInfillGenerator(infill_distance, value)(path)
[docs] @staticmethod def line(path, infill_distance=1, angle=np.pi/4) -> PathList: """ Parallel-line infill at a given angle. Args: path (Path or PathList): Boundary of the infill region. infill_distance (float): Perpendicular spacing between lines. angle (float): Angle of the lines from the X-axis in radians. Returns: PathList: Generated infill paths (each Path is a straight segment). """ return _LineInfillGenerator(infill_distance, angle)(path)
[docs] @staticmethod def grid(path, infill_distance=1) -> PathList: """ Rectilinear grid infill (lines in X and Y). Args: path (Path or PathList): Boundary of the infill region. infill_distance (float): Spacing between grid lines. Returns: PathList: Generated infill paths (each Path is a straight segment). """ return _GridInfillGenerator(infill_distance)(path)
[docs] @staticmethod def triangle(path, infill_distance=1) -> PathList: """ Triangular grid infill (lines at 0°, 60°, 120°). Args: path (Path or PathList): Boundary of the infill region. infill_distance (float): Perpendicular spacing within each line family. Returns: PathList: Generated infill paths (each Path is a straight segment). """ return _TriangleInfillGenerator(infill_distance)(path)
[docs] @staticmethod def custom_implicit(path, equation_fn, resolution=0.4) -> PathList: """ Infill defined by a user-supplied implicit surface equation. The zero iso-contour of ``equation_fn`` on the print plane becomes the infill pattern, clipped to the boundary. Args: path (Path or PathList): Boundary of the infill region. equation_fn (callable): A function with signature ``(X: np.ndarray, Y: np.ndarray, z_height: float) -> np.ndarray`` that returns the scalar field evaluated on the meshgrid. The iso-contour at level 0 is used as the infill. resolution (float): Grid sampling step in mm. Smaller values give finer contours but take longer. Default: 0.4. Returns: PathList: Generated infill paths. Example:: import numpy as np import gcoordinator as gc def lidinoid(X, Y, z): p = 2 * np.pi / 3.0 return ( np.sin(2*X*p) * np.cos(Y*p) * np.sin(z*p) + np.sin(2*Y*p) * np.cos(z*p) * np.sin(X*p) + np.sin(2*z*p) * np.cos(X*p) * np.sin(Y*p) - 0.3 ) infill = gc.Infill.custom_implicit(wall, lidinoid, resolution=0.3) """ return _CustomImplicitInfillGenerator(equation_fn, resolution)(path)
# ───────────────────────────────────────────────────────────────────────────── # Backward-compatible module-level aliases # ─────────────────────────────────────────────────────────────────────────────
[docs] def gyroid_infill(path, infill_distance=1, value=0) -> PathList: """Backward-compatible alias for Infill.gyroid().""" return Infill.gyroid(path, infill_distance, value)
[docs] def line_infill(path, infill_distance=1, angle=np.pi/4) -> PathList: """Backward-compatible alias for Infill.line().""" return Infill.line(path, infill_distance, angle)