Skip to content

kumiki.csg.pathcsg

Flat import path

kumiki/__init__.py re-exports everything on this page via from kumiki import *, so every name below is also available directly as kumiki.PathSegment -- you do not need to import from the submodule path shown in the heading above.

kumiki.csg.pathcsg

Path-based CSG: FancyPath, a closed 2D boundary of straight and arc segments, and PathExtrusion, which extrudes one along its local Z.

Unlike ConvexPolygonExtrusion the boundary may be concave. Curved sides carry no plane, so they are never measurable.

Path module-attribute

Path = FancyPath

PathExtrusionFeatureKey module-attribute

PathExtrusionFeatureKey = Union[ExtrusionCap, FlatSide, CurvedSide]

PathSegment dataclass

Bases: ABC

One segment of a closed FancyPath, in the path's local 2D plane.

start abstractmethod property

start: V2

end abstractmethod property

end: V2

is_planar abstractmethod

is_planar() -> bool

Return whether extruding this segment gives a flat side.

Source code in kumiki/csg/pathcsg.py
@abstractmethod
def is_planar(self) -> bool:
    """Return whether extruding this segment gives a flat side."""

ray_crossings abstractmethod

ray_crossings(y: Numeric, inclusive: bool = False, eps: Optional[Numeric] = None) -> List[Numeric]

Return the x values where this segment crosses height y.

inclusive=False counts each crossing at a shared vertex once (for parity tests); inclusive=True includes the segment's own endpoints (for convex decomposition).

Source code in kumiki/csg/pathcsg.py
@abstractmethod
def ray_crossings(self, y: Numeric, inclusive: bool = False, eps: Optional[Numeric] = None) -> List[Numeric]:
    """Return the x values where this segment crosses height y.

    `inclusive=False` counts each crossing at a shared vertex once (for parity tests);
    `inclusive=True` includes the segment's own endpoints (for convex decomposition).
    """

v_extrema abstractmethod

v_extrema() -> List[Numeric]

Return the y values strictly between the endpoints where x(y) turns (e.g. an arc's pole).

Source code in kumiki/csg/pathcsg.py
@abstractmethod
def v_extrema(self) -> List[Numeric]:
    """Return the y values strictly between the endpoints where x(y) turns (e.g. an arc's pole)."""

closest_point abstractmethod

closest_point(point: V2, eps: Optional[Numeric] = None) -> V2

Return the nearest point on this segment to point.

Source code in kumiki/csg/pathcsg.py
@abstractmethod
def closest_point(self, point: V2, eps: Optional[Numeric] = None) -> V2:
    """Return the nearest point on this segment to `point`."""

outward_local_normal abstractmethod

outward_local_normal(point: V2, eps: Optional[Numeric] = None) -> V2

Return the 2D outward normal at point on this segment, assuming a CCW path.

Source code in kumiki/csg/pathcsg.py
@abstractmethod
def outward_local_normal(self, point: V2, eps: Optional[Numeric] = None) -> V2:
    """Return the 2D outward normal at `point` on this segment, assuming a CCW path."""

bounds abstractmethod

bounds() -> Tuple[V2, V2]

Return this segment's (min_corner, max_corner).

Source code in kumiki/csg/pathcsg.py
@abstractmethod
def bounds(self) -> Tuple[V2, V2]:
    """Return this segment's (min_corner, max_corner)."""

signed_area_contribution abstractmethod

signed_area_contribution() -> Numeric

Return this segment's term in FancyPath.signed_area.

Source code in kumiki/csg/pathcsg.py
@abstractmethod
def signed_area_contribution(self) -> Numeric:
    """Return this segment's term in FancyPath.signed_area."""

tessellate abstractmethod

tessellate(tolerance: float) -> List[V2]

Return points approximating this segment for meshing, excluding start and including end.

Source code in kumiki/csg/pathcsg.py
@abstractmethod
def tessellate(self, tolerance: float) -> List[V2]:
    """Return points approximating this segment for meshing, excluding `start` and including `end`."""

tessellate_with_extra_breaks abstractmethod

tessellate_with_extra_breaks(other_breaks: List[Numeric], tolerance: float) -> List[V2]

Like tessellate(), plus a vertex at each y in other_breaks within this segment's y range.

Keeps the wall mesh's vertices matching the cap pieces' seams.

Source code in kumiki/csg/pathcsg.py
@abstractmethod
def tessellate_with_extra_breaks(self, other_breaks: List[Numeric], tolerance: float) -> List[V2]:
    """Like tessellate(), plus a vertex at each y in `other_breaks` within this segment's y range.

    Keeps the wall mesh's vertices matching the cap pieces' seams.
    """

sample_interior abstractmethod

sample_interior(point_lo: V2, point_hi: V2, tolerance: float) -> List[V2]

Return mesh points strictly between point_lo and point_hi on this segment, in that order.

Uses the same sample grid as tessellate() so cap and wall meshes share vertices. Empty for a straight segment.

Source code in kumiki/csg/pathcsg.py
@abstractmethod
def sample_interior(self, point_lo: V2, point_hi: V2, tolerance: float) -> List[V2]:
    """Return mesh points strictly between `point_lo` and `point_hi` on this segment, in that order.

    Uses the same sample grid as tessellate() so cap and wall meshes share vertices. Empty for a
    straight segment.
    """

reverse abstractmethod

reverse() -> PathSegment

Return this segment traversed from end to start.

Source code in kumiki/csg/pathcsg.py
@abstractmethod
def reverse(self) -> 'PathSegment':
    """Return this segment traversed from `end` to `start`."""

StraightSegment dataclass

Bases: PathSegment

A straight segment of a path, between two 2D points.

line_start instance-attribute

line_start: V2

line_end instance-attribute

line_end: V2

start property

start: V2

end property

end: V2

is_planar

is_planar() -> bool
Source code in kumiki/csg/pathcsg.py
def is_planar(self) -> bool:
    return True

ray_crossings

ray_crossings(y: Numeric, inclusive: bool = False, eps: Optional[Numeric] = None) -> List[Numeric]
Source code in kumiki/csg/pathcsg.py
def ray_crossings(self, y: Numeric, inclusive: bool = False, eps: Optional[Numeric] = None) -> List[Numeric]:
    y0, y1 = self.line_start[1], self.line_end[1]
    if safe_zero_test(y1 - y0, eps=eps):
        return []  # horizontal segment: no crossings, doesn't bound any band

    if inclusive:
        lo, hi = (y0, y1) if safe_compare(y0, y1, Comparison.LE, eps=eps) else (y1, y0)
        hit = safe_compare(lo, y, Comparison.LE, eps=eps) and safe_compare(y, hi, Comparison.LE, eps=eps)
    else:
        hit = (
            (safe_compare(y0, y, Comparison.LE, eps=eps) and safe_compare(y, y1, Comparison.LT, eps=eps))
            or (safe_compare(y1, y, Comparison.LE, eps=eps) and safe_compare(y, y0, Comparison.LT, eps=eps))
        )
    if not hit:
        return []

    t = (y - y0) / (y1 - y0)
    x = self.line_start[0] + t * (self.line_end[0] - self.line_start[0])
    return [x]

v_extrema

v_extrema() -> List[Numeric]
Source code in kumiki/csg/pathcsg.py
def v_extrema(self) -> List[Numeric]:
    return []

closest_point

closest_point(point: V2, eps: Optional[Numeric] = None) -> V2
Source code in kumiki/csg/pathcsg.py
def closest_point(self, point: V2, eps: Optional[Numeric] = None) -> V2:
    edge = self.line_end - self.line_start
    edge_len_sq = edge[0] ** 2 + edge[1] ** 2
    # Degeneracy is a property of the path, not of how close the caller
    # clicked, so this takes no query tolerance.
    if safe_zero_test_sq(edge_len_sq):
        return self.line_start

    to_point = point - self.line_start
    t = (to_point[0] * edge[0] + to_point[1] * edge[1]) / edge_len_sq
    if safe_compare(t, 0, Comparison.LT, eps=eps):
        t = scalar(0)
    elif safe_compare(t, 1, Comparison.GT, eps=eps):
        t = scalar(1)
    return self.line_start + edge * t

outward_local_normal

outward_local_normal(point: V2, eps: Optional[Numeric] = None) -> V2
Source code in kumiki/csg/pathcsg.py
def outward_local_normal(self, point: V2, eps: Optional[Numeric] = None) -> V2:
    edge = self.line_end - self.line_start
    return safe_normalize_vector(_right_perpendicular(edge))

bounds

bounds() -> Tuple[V2, V2]
Source code in kumiki/csg/pathcsg.py
def bounds(self) -> Tuple[V2, V2]:
    x0, y0 = self.line_start[0], self.line_start[1]
    x1, y1 = self.line_end[0], self.line_end[1]
    min_x = x0 if safe_compare(x0, x1, Comparison.LE) else x1
    max_x = x1 if safe_compare(x0, x1, Comparison.LE) else x0
    min_y = y0 if safe_compare(y0, y1, Comparison.LE) else y1
    max_y = y1 if safe_compare(y0, y1, Comparison.LE) else y0
    return create_v2(min_x, min_y), create_v2(max_x, max_y)

signed_area_contribution

signed_area_contribution() -> Numeric
Source code in kumiki/csg/pathcsg.py
def signed_area_contribution(self) -> Numeric:
    x0, y0 = self.line_start[0], self.line_start[1]
    x1, y1 = self.line_end[0], self.line_end[1]
    return (x0 * y1 - x1 * y0) / scalar(2)

tessellate

tessellate(tolerance: float) -> List[V2]
Source code in kumiki/csg/pathcsg.py
def tessellate(self, tolerance: float) -> List[V2]:
    return [self.line_end]

tessellate_with_extra_breaks

tessellate_with_extra_breaks(other_breaks: List[Numeric], tolerance: float) -> List[V2]
Source code in kumiki/csg/pathcsg.py
def tessellate_with_extra_breaks(self, other_breaks: List[Numeric], tolerance: float) -> List[V2]:
    y0, y1 = self.line_start[1], self.line_end[1]
    if safe_zero_test(y1 - y0):
        return [self.line_end]  # horizontal: no y ever falls strictly inside its range
    lo, hi = (y0, y1) if safe_compare(y0, y1, Comparison.LE) else (y1, y0)

    inserts = []  # (t, point), t in (0, 1)
    for y in other_breaks:
        if safe_compare(y, lo, Comparison.GT) and safe_compare(y, hi, Comparison.LT):
            t = (y - y0) / (y1 - y0)
            x = self.line_start[0] + t * (self.line_end[0] - self.line_start[0])
            inserts.append((t, create_v2(x, y)))
    inserts.sort(key=lambda item: giraffe_evalf(item[0]))
    return [pt for _, pt in inserts] + [self.line_end]

sample_interior

sample_interior(point_lo: V2, point_hi: V2, tolerance: float) -> List[V2]
Source code in kumiki/csg/pathcsg.py
def sample_interior(self, point_lo: V2, point_hi: V2, tolerance: float) -> List[V2]:
    return []

reverse

reverse() -> StraightSegment
Source code in kumiki/csg/pathcsg.py
def reverse(self) -> 'StraightSegment':
    return StraightSegment(self.line_end, self.line_start)

ArcSegment dataclass

Bases: PathSegment

A circular arc from start_angle, sweeping sweep_angle: CCW if positive, CW if negative.

abs(sweep_angle) is in (0, 2*pi]. The sign picks which of the two arcs between the endpoints.

center instance-attribute

center: V2

radius instance-attribute

radius: Numeric

start_angle instance-attribute

start_angle: Numeric

sweep_angle instance-attribute

sweep_angle: Numeric

end_angle property

end_angle: Numeric

start property

start: V2

end property

end: V2

is_planar

is_planar() -> bool
Source code in kumiki/csg/pathcsg.py
def is_planar(self) -> bool:
    return False

ray_crossings

ray_crossings(y: Numeric, inclusive: bool = False, eps: Optional[Numeric] = None) -> List[Numeric]
Source code in kumiki/csg/pathcsg.py
def ray_crossings(self, y: Numeric, inclusive: bool = False, eps: Optional[Numeric] = None) -> List[Numeric]:
    results: List[Numeric] = []
    for angle_lo, angle_hi, cos_sign in self._monotonic_subranges(eps=eps):
        y_lo = self.center[1] + self.radius * sin(angle_lo)
        y_hi = self.center[1] + self.radius * sin(angle_hi)
        if inclusive:
            lo, hi = (y_lo, y_hi) if safe_compare(y_lo, y_hi, Comparison.LE, eps=eps) else (y_hi, y_lo)
            hit = safe_compare(lo, y, Comparison.LE, eps=eps) and safe_compare(y, hi, Comparison.LE, eps=eps)
        else:
            hit = (
                (safe_compare(y_lo, y, Comparison.LE, eps=eps) and safe_compare(y, y_hi, Comparison.LT, eps=eps))
                or (safe_compare(y_hi, y, Comparison.LE, eps=eps) and safe_compare(y, y_lo, Comparison.LT, eps=eps))
            )
        if not hit:
            continue
        sin_theta = (y - self.center[1]) / self.radius
        # The hit test above accepts y within the comparison tolerance of
        # this subrange's endpoints, so at the extremes sin_theta can land
        # a hair outside [-1, 1] -- far enough outside to blow past sqrt's
        # own small-negative guard once the tolerance is widened for
        # picking. The gate has already established y lies on this
        # subrange, so clamp instead of failing on the noise.
        sin_theta = min(scalar(1), max(scalar(-1), sin_theta))
        cos_theta = cos_sign * sym_sqrt(scalar(1) - sin_theta ** 2)
        results.append(self.center[0] + self.radius * cos_theta)
    return results

v_extrema

v_extrema() -> List[Numeric]
Source code in kumiki/csg/pathcsg.py
def v_extrema(self) -> List[Numeric]:
    extrema = []
    for angle_lo, angle_hi, _ in self._monotonic_subranges()[:-1]:
        # angle_hi of each piece but the last is an interior pole
        extrema.append(self.center[1] + self.radius * sin(angle_hi))
    return extrema

closest_point

closest_point(point: V2, eps: Optional[Numeric] = None) -> V2
Source code in kumiki/csg/pathcsg.py
def closest_point(self, point: V2, eps: Optional[Numeric] = None) -> V2:
    angle = self._angle_for_point(point)
    lo, hi = self._angle_extent_float()
    if lo - 1e-9 <= angle <= hi + 1e-9:
        return self._point_at_angle(angle)
    # off the arc's own span -- nearer endpoint wins
    start_pt, end_pt = self.start, self.end
    d_start = (point[0] - start_pt[0]) ** 2 + (point[1] - start_pt[1]) ** 2
    d_end = (point[0] - end_pt[0]) ** 2 + (point[1] - end_pt[1]) ** 2
    return start_pt if safe_compare(d_start, d_end, Comparison.LE, eps=eps) else end_pt

outward_local_normal

outward_local_normal(point: V2, eps: Optional[Numeric] = None) -> V2
Source code in kumiki/csg/pathcsg.py
def outward_local_normal(self, point: V2, eps: Optional[Numeric] = None) -> V2:
    # For CCW (positive sweep) travel, outward is +radial (away from
    # center) -- e.g. the rim of a convex bulge. For CW (negative sweep,
    # a concave bite), it's -radial (toward center, into the void the
    # bite carves out). See the class docstring: this sign is exactly
    # what makes a concave arc possible at all for a fixed start/end.
    radial = point - self.center
    if safe_compare(self.sweep_angle, 0, Comparison.LT, eps=eps):
        radial = -radial
    return safe_normalize_vector(radial)

bounds

bounds() -> Tuple[V2, V2]
Source code in kumiki/csg/pathcsg.py
def bounds(self) -> Tuple[V2, V2]:
    xs = [self.start[0], self.end[0]]
    ys = [self.start[1], self.end[1]]
    angle_lo = self.start_angle if safe_compare(self.start_angle, self.end_angle, Comparison.LE) else self.end_angle
    angle_hi = self.end_angle if safe_compare(self.start_angle, self.end_angle, Comparison.LE) else self.start_angle
    for cardinal in (scalar(0), pi / 2, pi, scalar(3) * pi / 2):
        angle = cardinal
        while safe_compare(angle, angle_lo, Comparison.LT):
            angle += 2 * pi
        if safe_compare(angle, angle_lo, Comparison.GE) and \
           safe_compare(angle, angle_hi, Comparison.LE):
            xs.append(self.center[0] + self.radius * cos(angle))
            ys.append(self.center[1] + self.radius * sin(angle))
    min_x = xs[0]; max_x = xs[0]; min_y = ys[0]; max_y = ys[0]
    for x in xs[1:]:
        if safe_compare(x, min_x, Comparison.LT): min_x = x
        if safe_compare(x, max_x, Comparison.GT): max_x = x
    for y in ys[1:]:
        if safe_compare(y, min_y, Comparison.LT): min_y = y
        if safe_compare(y, max_y, Comparison.GT): max_y = y
    return create_v2(min_x, min_y), create_v2(max_x, max_y)

signed_area_contribution

signed_area_contribution() -> Numeric
Source code in kumiki/csg/pathcsg.py
def signed_area_contribution(self) -> Numeric:
    start_pt, end_pt = self.start, self.end
    chord_term = (start_pt[0] * end_pt[1] - end_pt[0] * start_pt[1]) / scalar(2)
    # Standard chord+bulge area identity: adding a CCW arc of sweep angle
    # `t` in place of its own chord adds a signed r^2*(t - sin(t))/2 term
    # -- valid for any t in (0, 2*pi], reflex sweeps included.
    arc_term = (self.radius ** 2) * (self.sweep_angle - sin(self.sweep_angle)) / scalar(2)
    return chord_term + arc_term

tessellate

tessellate(tolerance: float) -> List[V2]
Source code in kumiki/csg/pathcsg.py
def tessellate(self, tolerance: float) -> List[V2]:
    points = [self._point_at_angle(a) for a in self._angle_grid(tolerance)]
    points.append(self.end)
    return points

tessellate_with_extra_breaks

tessellate_with_extra_breaks(other_breaks: List[Numeric], tolerance: float) -> List[V2]
Source code in kumiki/csg/pathcsg.py
def tessellate_with_extra_breaks(self, other_breaks: List[Numeric], tolerance: float) -> List[V2]:
    angles = list(self._angle_grid(tolerance))
    for y in other_breaks:
        for x in self.ray_crossings(y, inclusive=True):
            angle = self._angle_for_point(create_v2(x, y))
            lo, hi = self._angle_extent_float()
            if lo + 1e-9 < angle < hi - 1e-9:  # strictly interior -- not one of this segment's own endpoints
                angles.append(angle)
    clockwise = safe_compare(self.sweep_angle, 0, Comparison.LT)
    angles.sort(reverse=bool(clockwise))
    deduped: List[float] = []
    for a in angles:
        if not deduped or abs(a - deduped[-1]) > 1e-9:
            deduped.append(a)
    points = [self._point_at_angle(a) for a in deduped]
    points.append(self.end)
    return points

sample_interior

sample_interior(point_lo: V2, point_hi: V2, tolerance: float) -> List[V2]
Source code in kumiki/csg/pathcsg.py
def sample_interior(self, point_lo: V2, point_hi: V2, tolerance: float) -> List[V2]:
    angle_lo = self._angle_for_point(point_lo)
    angle_hi = self._angle_for_point(point_hi)
    reverse = angle_lo > angle_hi
    angle_a, angle_b = (angle_hi, angle_lo) if reverse else (angle_lo, angle_hi)
    grid = self._angle_grid(tolerance)
    # _angle_grid's own order runs start_angle -> end_angle (ascending for
    # a CCW/positive sweep, descending for CW/negative) -- sort ascending
    # first so the reverse-if-needed below reliably lands on point_lo ->
    # point_hi order regardless of which direction the grid itself runs.
    interior = sorted(a for a in grid if angle_a + 1e-9 < a < angle_b - 1e-9)
    if reverse:
        interior.reverse()
    return [self._point_at_angle(a) for a in interior]

reverse

reverse() -> ArcSegment
Source code in kumiki/csg/pathcsg.py
def reverse(self) -> 'ArcSegment':
    # Same circle, same points, traversed the other way: starting where
    # this one ended, sweeping by the same magnitude in the opposite
    # (CCW<->CW) direction.
    return ArcSegment(center=self.center, radius=self.radius,
                       start_angle=self.end_angle, sweep_angle=-self.sweep_angle)

FancyPath dataclass

A closed loop of segments, each ending where the next starts.

Not validated on construction; call is_valid().

segments instance-attribute

segments: List[PathSegment]

is_valid

is_valid() -> bool

Return whether the loop has a segment, closes, and winds CCW.

Self-intersection isn't checked; a self-intersecting path is undefined behaviour.

Source code in kumiki/csg/pathcsg.py
def is_valid(self) -> bool:
    """Return whether the loop has a segment, closes, and winds CCW.

    Self-intersection isn't checked; a self-intersecting path is undefined behaviour.
    """
    if not self.segments:
        return False
    n = len(self.segments)
    for i in range(n):
        a = self.segments[i]
        b = self.segments[(i + 1) % n]
        end_pt, start_pt = a.end, b.start
        if not (safe_zero_test(end_pt[0] - start_pt[0]) and safe_zero_test(end_pt[1] - start_pt[1])):
            return False
    return safe_compare(self.signed_area(), 0, Comparison.GT)

signed_area

signed_area() -> Numeric
Source code in kumiki/csg/pathcsg.py
def signed_area(self) -> Numeric:
    total = scalar(0)
    for seg in self.segments:
        total += seg.signed_area_contribution()
    return total

reversed

reversed() -> FancyPath

Return this loop traversed the other way (flips CW and CCW).

Source code in kumiki/csg/pathcsg.py
def reversed(self) -> 'FancyPath':
    """Return this loop traversed the other way (flips CW and CCW)."""
    return FancyPath([seg.reverse() for seg in reversed(self.segments)])

bounds

bounds() -> Tuple[V2, V2]
Source code in kumiki/csg/pathcsg.py
def bounds(self) -> Tuple[V2, V2]:
    mins, maxs = self.segments[0].bounds()
    min_x, min_y = mins[0], mins[1]
    max_x, max_y = maxs[0], maxs[1]
    for seg in self.segments[1:]:
        lo, hi = seg.bounds()
        if safe_compare(lo[0], min_x, Comparison.LT): min_x = lo[0]
        if safe_compare(lo[1], min_y, Comparison.LT): min_y = lo[1]
        if safe_compare(hi[0], max_x, Comparison.GT): max_x = hi[0]
        if safe_compare(hi[1], max_y, Comparison.GT): max_y = hi[1]
    return create_v2(min_x, min_y), create_v2(max_x, max_y)

locate_boundary_segment

locate_boundary_segment(point: V2, eps: Optional[Numeric] = None) -> Optional[Tuple[int, PathSegment]]

Return the first segment point lies on, or None if it isn't on the boundary.

Source code in kumiki/csg/pathcsg.py
def locate_boundary_segment(self, point: V2, eps: Optional[Numeric] = None) -> Optional[Tuple[int, PathSegment]]:
    """Return the first segment `point` lies on, or None if it isn't on the boundary."""
    for index, seg in enumerate(self.segments):
        closest = seg.closest_point(point, eps=eps)
        if safe_zero_test_sq((closest[0] - point[0]) ** 2 + (closest[1] - point[1]) ** 2, eps):
            return index, seg
    return None

is_point_on_boundary_2d

is_point_on_boundary_2d(point: V2, eps: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/pathcsg.py
def is_point_on_boundary_2d(self, point: V2, eps: Optional[Numeric] = None) -> bool:
    return self.locate_boundary_segment(point, eps=eps) is not None

contains_point_2d

contains_point_2d(point: V2, eps: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/pathcsg.py
def contains_point_2d(self, point: V2, eps: Optional[Numeric] = None) -> bool:
    if self.is_point_on_boundary_2d(point, eps=eps):
        return True
    x, y = point[0], point[1]
    count = 0
    for seg in self.segments:
        for cross_x in seg.ray_crossings(y, eps=eps):
            if safe_compare(cross_x, x, Comparison.GT, eps=eps):
                count += 1
    return count % 2 == 1

tessellate

tessellate(tolerance: float) -> Profile

Return the path as a point list, for an outline. Use tessellate_for_mesh for a PathExtrusion's wall.

Source code in kumiki/csg/pathcsg.py
def tessellate(self, tolerance: float) -> Profile:
    """Return the path as a point list, for an outline. Use tessellate_for_mesh for a PathExtrusion's wall."""
    points: List[V2] = []
    for seg in self.segments:
        points.extend(seg.tessellate(tolerance))
    return points

tessellate_for_mesh

tessellate_for_mesh(tolerance: float) -> Profile

Return the path as a point list with a vertex at every seam decompose_path_into_convex_pieces makes.

Source code in kumiki/csg/pathcsg.py
def tessellate_for_mesh(self, tolerance: float) -> Profile:
    """Return the path as a point list with a vertex at every seam decompose_path_into_convex_pieces makes."""
    breakpoints = _path_breakpoints(self)
    points: List[V2] = []
    for seg in self.segments:
        # Passing every breakpoint (including this segment's own start/end)
        # is fine: tessellate_with_extra_breaks only ever inserts ones that
        # land strictly inside its own y-range, so its own endpoints are a
        # no-op here regardless.
        points.extend(seg.tessellate_with_extra_breaks(breakpoints, tolerance))
    return points

FlatSide dataclass

Key of the side extruded from a straight segment.

index instance-attribute

index: int

CurvedSide dataclass

Key of the side extruded from an arc. Has no plane, so is never measurable.

index instance-attribute

index: int

SimplePathExtrusionFeature dataclass

Bases: _PathExtrusionFeature, FaceFeature

A flat side (FlatSide key) or an end cap of a PathExtrusion.

__post_init__

__post_init__()
Source code in kumiki/csg/pathcsg.py
def __post_init__(self):
    if isinstance(self.key, CurvedSide):
        raise ValueError(f"{self.name!r}: {self.key!r} is curved; use SimplePathExtrusionCurvedFeature")

SimplePathExtrusionCurvedFeature dataclass

Bases: _PathExtrusionFeature, CurvedFaceFeature

A curved side of a PathExtrusion. Has no plane.

__post_init__

__post_init__()
Source code in kumiki/csg/pathcsg.py
def __post_init__(self):
    if not isinstance(self.key, CurvedSide):
        raise ValueError(f"{self.name!r}: {self.key!r} is not a curved side; use SimplePathExtrusionFeature")

locate_simple_unbounded

locate_simple_unbounded(owner: CutCSG) -> None
Source code in kumiki/csg/pathcsg.py
def locate_simple_unbounded(self, owner: 'CutCSG') -> None:
    return None

PathExtrusion dataclass

Bases: HasFeatures, CutCSG

Extrusion of a closed FancyPath (may be concave) along local Z.

The path is in the local XY plane at transform; it extends -z by start_distance and +z by end_distance, as ConvexPolygonExtrusion.

path instance-attribute

path: FancyPath

transform class-attribute instance-attribute

transform: Transform = field(default_factory=Transform.identity)

start_distance class-attribute instance-attribute

start_distance: Optional[Numeric] = None

end_distance class-attribute instance-attribute

end_distance: Optional[Numeric] = None

__repr__

__repr__() -> str
Source code in kumiki/csg/pathcsg.py
def __repr__(self) -> str:
    return (f"PathExtrusion({len(self.path.segments)} segments, "
            f"transform={self.transform}, start={self.start_distance}, end={self.end_distance})")

side

side(index: int) -> PathExtrusionFeatureKey

Return the key for side index: FlatSide or CurvedSide as the segment is.

Source code in kumiki/csg/pathcsg.py
def side(self, index: int) -> PathExtrusionFeatureKey:
    """Return the key for side `index`: FlatSide or CurvedSide as the segment is."""
    segment = self.path.segments[index]
    return FlatSide(index) if segment.is_planar() else CurvedSide(index)

feature

feature(name: str, key: PathExtrusionFeatureKey, **rest) -> _PathExtrusionFeature

Return a feature of this extrusion; pass extrusion.side(n) for a side.

Source code in kumiki/csg/pathcsg.py
def feature(self, name: str, key: PathExtrusionFeatureKey,
            **rest) -> '_PathExtrusionFeature':
    """Return a feature of this extrusion; pass `extrusion.side(n)` for a side."""
    if isinstance(key, CurvedSide):
        return SimplePathExtrusionCurvedFeature(name, key=key, **rest)
    return SimplePathExtrusionFeature(name, key=key, **rest)

carriers

carriers() -> Dict[FeatureKey, Carrier]
Source code in kumiki/csg/pathcsg.py
def carriers(self) -> Dict[FeatureKey, Carrier]:
    return {}

contains_point

contains_point(point: V3, eps: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/pathcsg.py
def contains_point(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    x, y, z = self._local_coords(point)
    if self.start_distance is not None and safe_compare(z, self.start_distance, Comparison.LT, eps=eps):
        return False
    if self.end_distance is not None and safe_compare(z, self.end_distance, Comparison.GT, eps=eps):
        return False
    return self.path.contains_point_2d(create_v2(x, y), eps=eps)

is_point_on_boundary

is_point_on_boundary(point: V3, eps: Optional[Numeric] = None) -> bool
Source code in kumiki/csg/pathcsg.py
def is_point_on_boundary(self, point: V3, eps: Optional[Numeric] = None) -> bool:
    if not self.contains_point(point, eps=eps):
        return False
    x, y, z = self._local_coords(point)
    if self.start_distance is not None and safe_zero_test(z - self.start_distance, eps=eps):
        return True
    if self.end_distance is not None and safe_zero_test(z - self.end_distance, eps=eps):
        return True
    return self.path.is_point_on_boundary_2d(create_v2(x, y), eps=eps)

get_outward_normal

get_outward_normal(point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]
Source code in kumiki/csg/pathcsg.py
def get_outward_normal(self, point: V3, eps: Optional[Numeric] = None) -> Optional[Direction3D]:
    x, y, z = self._local_coords(point)
    if self.end_distance is not None and safe_equality_test(z, self.end_distance, eps=eps):
        return safe_transform_vector(self.transform.orientation.matrix, Matrix([scalar(0), scalar(0), scalar(1)]))
    if self.start_distance is not None and safe_equality_test(z, self.start_distance, eps=eps):
        return safe_transform_vector(self.transform.orientation.matrix, Matrix([scalar(0), scalar(0), scalar(-1)]))

    located = self.path.locate_boundary_segment(create_v2(x, y), eps=eps)
    if located is None:
        return None
    _, seg = located
    n2 = seg.outward_local_normal(create_v2(x, y), eps=eps)
    local_normal = Matrix([n2[0], n2[1], scalar(0)])
    return safe_transform_vector(self.transform.orientation.matrix, local_normal)

get_aabb

get_aabb() -> AxisAlignedBoundingBox
Source code in kumiki/csg/pathcsg.py
def get_aabb(self) -> AxisAlignedBoundingBox:
    if self.start_distance is None or self.end_distance is None:
        warnings.warn(
            "get_aabb() called on an infinite PathExtrusion — result is unbounded",
            UserWarning,
            stacklevel=2,
        )
        return AxisAlignedBoundingBox(None, None, None, None, None, None)

    mins, maxs = self.path.bounds()
    corners_global = [
        self.transform.local_to_global(Matrix([x, y, z]))
        for x in (mins[0], maxs[0])
        for y in (mins[1], maxs[1])
        for z in (self.start_distance, self.end_distance)
    ]
    xs = [p[0] for p in corners_global]
    ys = [p[1] for p in corners_global]
    zs = [p[2] for p in corners_global]

    def _min(*vals):
        result = vals[0]
        for v in vals[1:]:
            if safe_compare(v, result, Comparison.LT):
                result = v
        return result

    def _max(*vals):
        result = vals[0]
        for v in vals[1:]:
            if safe_compare(v, result, Comparison.GT):
                result = v
        return result

    return AxisAlignedBoundingBox(_min(*xs), _min(*ys), _min(*zs), _max(*xs), _max(*ys), _max(*zs))

get_convex_hull

get_convex_hull() -> ConvexHull

Return the convex hull of the path's segment start points, extruded.

KNOWN BUG: an arc bulging outward lies outside this hull, so the bound is too small.

Source code in kumiki/csg/pathcsg.py
def get_convex_hull(self) -> 'ConvexHull':
    """Return the convex hull of the path's segment start points, extruded.

    KNOWN BUG: an arc bulging outward lies outside this hull, so the bound is too small.
    """
    from .edges_on_csg import _hull_or_unknown, _extruded_hull_half_spaces
    points = [seg.start for seg in self.path.segments]
    return _hull_or_unknown(_extruded_hull_half_spaces(
        [(float(point[0, 0]), float(point[1, 0])) for point in points],
        self.transform, self.start_distance, self.end_distance))

plane_sides

plane_sides(frame: PlaneFrame, seed: PolygonRegions) -> Sides

Return the union of its convex pieces' plane_sides.

Source code in kumiki/csg/pathcsg.py
def plane_sides(self, frame: 'PlaneFrame', seed: 'PolygonRegions') -> 'Sides':
    """Return the union of its convex pieces' plane_sides."""
    from .cross_sections import _union
    front: 'PolygonRegions' = []
    back: 'PolygonRegions' = []
    for piece in decompose_path_into_convex_pieces(self.path, tolerance=1e-4):
        convex = ConvexPolygonExtrusion(points=piece, transform=self.transform,
                                        start_distance=self.start_distance, end_distance=self.end_distance)
        piece_front, piece_back = convex.plane_sides(frame, seed)
        front, back = _union(front, piece_front), _union(back, piece_back)
    return front, back

decompose_path_into_convex_pieces

decompose_path_into_convex_pieces(path: FancyPath, tolerance: float) -> List[Profile]

Split a FancyPath into convex polygons (point lists), sweeping horizontal bands over its segments.

Band corners are exact; curved band edges are sampled at tolerance.

Source code in kumiki/csg/pathcsg.py
def decompose_path_into_convex_pieces(path: FancyPath, tolerance: float) -> List[Profile]:
    """Split a FancyPath into convex polygons (point lists), sweeping horizontal bands over its segments.

    Band corners are exact; curved band edges are sampled at `tolerance`.
    """
    deduped = _path_breakpoints(path)

    # Full crossing set at each breakpoint LEVEL (every segment, not just
    # ones "mid-band active" in some particular band) -- needed because a
    # pole can sit exactly on a band boundary and only affect the band on
    # ONE side of it (the arc is only mid-band active above its own pole,
    # say), yet the piece on the OTHER side still shares that exact boundary
    # line and must place a vertex at the same spot, or the two pieces
    # subdivide their shared edge differently -- a T-junction between two
    # cap pieces, not a wall/cap one this time, but the same failure mode.
    level_crossings = {v: sorted(set(
        x for seg in path.segments for x in seg.ray_crossings(v, inclusive=True)
    ), key=giraffe_evalf) for v in deduped}

    pieces: List[Profile] = []
    for i in range(len(deduped) - 1):
        v_lo, v_hi = deduped[i], deduped[i + 1]
        v_mid = (v_lo + v_hi) / scalar(2)

        crossings = []  # (u_mid, u_lo, u_hi, segment)
        for seg in path.segments:
            # A single segment can contribute more than one crossing to the
            # same band -- e.g. one ArcSegment spanning an entire semicircle
            # crosses a horizontal line through its interior twice (once on
            # each side), even though the band-splitting above already
            # guarantees no *individual* monotonic branch needs further
            # splitting. Each of a segment's monotonic branches spans the
            # full band whenever it's active in it, so ray_crossings returns
            # its hits in the same (angle-ascending) branch order regardless
            # of which of v_lo/v_mid/v_hi it's asked about -- sort each by x
            # and zip by position to pair them correctly.
            mid_hits = sorted(seg.ray_crossings(v_mid), key=giraffe_evalf)
            if not mid_hits:
                continue
            lo_hits = sorted(seg.ray_crossings(v_lo, inclusive=True), key=giraffe_evalf)
            hi_hits = sorted(seg.ray_crossings(v_hi, inclusive=True), key=giraffe_evalf)
            if len(lo_hits) != len(mid_hits) or len(hi_hits) != len(mid_hits):
                raise ValueError(
                    "FancyPath is not simple: a segment's crossing count changed within a "
                    "single decomposition band (band boundaries should prevent this)"
                )
            for k in range(len(mid_hits)):
                crossings.append((mid_hits[k], lo_hits[k], hi_hits[k], seg))
        crossings.sort(key=lambda c: giraffe_evalf(c[0]))

        if len(crossings) % 2 != 0:
            raise ValueError("FancyPath is not simple: odd number of boundary crossings in a v-band")

        for j in range(0, len(crossings) - 1, 2):
            _, u_left_lo, u_left_hi, seg_left = crossings[j]
            _, u_right_lo, u_right_hi, seg_right = crossings[j + 1]
            # NOTE: unlike the straight-only decompose_simple_polygon_into_convex_pieces,
            # there is no "both ends coincide -> zero-area, skip" shortcut here:
            # a piece pinched to a point at BOTH v_lo and v_hi can still have
            # real width strictly between them once a curved edge is involved
            # (e.g. a half-disk -- straight diameter on one side, arc on the
            # other -- is pinched at both its top and bottom vertices, but is
            # not remotely zero-area). A genuinely zero-width piece (left and
            # right edges coincide throughout) just produces zero-area fan
            # triangles below, which is harmless, unlike incorrectly dropping
            # real geometry would be.

            bl = create_v2(u_left_lo, v_lo)
            br = create_v2(u_right_lo, v_lo)
            tr = create_v2(u_right_hi, v_hi)
            tl = create_v2(u_left_hi, v_hi)
            # A band that starts or ends exactly at a pole (e.g. the top/bottom
            # of a circular arc) has its left/right corner pair coincide at a
            # single point there -- emitting both would add a zero-area
            # triangle at that pole, which breaks manifoldness (a self-loop
            # edge, plus an over-shared edge next to it) rather than just
            # being a harmless sliver.
            lo_degenerate = safe_zero_test(u_right_lo - u_left_lo)
            hi_degenerate = safe_zero_test(u_right_hi - u_left_hi)

            # Seam points other segments' poles land at this exact level,
            # strictly inside this piece's bl-br / tr-tl span -- see
            # level_crossings above. Ascending order matches bl->br;
            # reversed matches tr->tl (descending, since tr's x > tl's x).
            extra_lo = [x for x in level_crossings[v_lo]
                        if safe_compare(x, u_left_lo, Comparison.GT) and safe_compare(x, u_right_lo, Comparison.LT)]
            extra_hi = [x for x in level_crossings[v_hi]
                        if safe_compare(x, u_left_hi, Comparison.GT) and safe_compare(x, u_right_hi, Comparison.LT)]

            piece: Profile = [bl] if lo_degenerate else [bl] + [create_v2(x, v_lo) for x in extra_lo] + [br]
            piece.extend(seg_right.sample_interior(br, tr, tolerance))
            piece.append(tr)
            if not hi_degenerate:
                piece.extend(create_v2(x, v_hi) for x in reversed(extra_hi))
                piece.append(tl)
            piece.extend(seg_left.sample_interior(tl, bl, tolerance))
            pieces.append(piece)

    return pieces

is_cap

is_cap(key: PathExtrusionFeatureKey) -> bool

Return whether a key names an end cap rather than a side.

Source code in kumiki/csg/pathcsg.py
def is_cap(key: PathExtrusionFeatureKey) -> bool:
    """Return whether a key names an end cap rather than a side."""
    return isinstance(key, ExtrusionCap)

side_index

side_index(key: PathExtrusionFeatureKey) -> int

Return the segment index a side key names. Raises for a cap.

Source code in kumiki/csg/pathcsg.py
def side_index(key: PathExtrusionFeatureKey) -> int:
    """Return the segment index a side key names. Raises for a cap."""
    if isinstance(key, (FlatSide, CurvedSide)):
        return key.index
    raise ValueError(f"{key!r} names a cap, not a side, so it indexes no segment")