Build Sheets

Source

fill.py

"""Scanline (tatami) fill plus edge running-stitch for letter polygons."""

import math

from shapely.geometry import LineString, MultiPolygon, Polygon

from .params import StitchParams

Stitch = tuple[float, float, bool]  # (x_mm, y_mm, jump)


def stitch_letters(letters: list[MultiPolygon],
                   params: StitchParams | None = None) -> list[Stitch]:
    p = params or StitchParams()
    out: list[Stitch] = []
    for geom in letters:
        for poly in geom.geoms:
            stitch_polygon(poly, out, p)
    return out


def stitch_polygon(poly: Polygon, out: list[Stitch], p: StitchParams) -> None:
    """Tatami fill + running-stitch outline for one polygon."""
    _fill(poly, out, p)
    _outline(poly, out, p)


def _fill(poly: Polygon, out: list[Stitch], p: StitchParams) -> None:
    safe = poly.buffer(0.05)  # tolerance for boundary-hugging travel checks
    for i, segs in enumerate(_rows(poly, p)):
        if i % 2:  # serpentine: alternate rows run right-to-left
            segs = [(e, s) for s, e in reversed(segs)]
        for start, end in segs:
            travel_to(safe, out, start, p)
            _run(out, start, end, p)


def _rows(poly: Polygon, p: StitchParams) -> list[list[tuple]]:
    minx, miny, maxx, maxy = poly.bounds
    rows = []
    y = miny + p.row_spacing_mm / 2
    while y < maxy:
        cut = poly.intersection(LineString([(minx - 1, y), (maxx + 1, y)]))
        segs = []
        for g in getattr(cut, "geoms", [cut]):
            if isinstance(g, LineString) and g.length >= p.min_row_segment_mm:
                x1, x2 = g.coords[0][0], g.coords[-1][0]
                segs.append(((min(x1, x2), y), (max(x1, x2), y)))
        if segs:
            rows.append(sorted(segs))
        y += p.row_spacing_mm
    return rows


def travel_to(safe: Polygon, out: list[Stitch], point: tuple,
              p: StitchParams) -> None:
    if out:
        lx, ly, _ = out[-1]
        if (lx, ly) == point:
            return
        hop = LineString([(lx, ly), point])
        if hop.length <= p.connect_max_mm and safe.covers(hop):
            out.append((point[0], point[1], False))  # short in-shape travel: sew it
            return
    out.append((point[0], point[1], True))  # otherwise jump


def _run(out: list[Stitch], start: tuple, end: tuple, p: StitchParams) -> None:
    n = max(1, math.ceil(math.dist(start, end) / p.max_stitch_mm))
    for k in range(1, n + 1):
        t = k / n
        out.append((start[0] + (end[0] - start[0]) * t,
                    start[1] + (end[1] - start[1]) * t, False))


def _outline(poly: Polygon, out: list[Stitch], p: StitchParams) -> None:
    safe = poly.buffer(0.05)
    for ring in [poly.exterior, *poly.interiors]:
        n = max(3, math.ceil(ring.length / p.outline_stitch_mm))
        pts = [ring.interpolate(ring.length * k / n) for k in range(n + 1)]
        travel_to(safe, out, (pts[0].x, pts[0].y), p)
        out.extend((pt.x, pt.y, False) for pt in pts[1:])