Source code for pybosl2.regions

# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause
# DocCategory: Paths, regions & surfaces

"""Object API for 2-D paths and regions.

Path2D and Region: object wrappers over the 2-D point maths in paths.py/rounding.py/
transforms.py, so a polygon can be built once and then chained
(`Path2D(pts).offset(radius=-2).round_corners(radius=1).polygon()`) instead of threading raw
point lists through free functions.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, cast

import numpy as np
from shapely.geometry import MultiPolygon, Polygon

from pybosl2.bounds import Bounds2D
from pybosl2.caps import CapSpec, CapType
from pybosl2.enums import RoundingMethod
from pybosl2.exceptions import Bosl2ValueError
from pybosl2.path2d import Path2D
from pybosl2.shapes3d import text3d

if TYPE_CHECKING:  # for the annotations only -- importing shapes2d here would be circular
    from collections.abc import Iterator, Sequence

    from pybosl2._backend import Solid
    from pybosl2.color import Color
    from pybosl2.flat import Flat

__all__ = ["Region"]


def _polygon_parts(geom: Any) -> list[Polygon]:
    """Every non-empty ``Polygon`` in *geom*, whatever shapely handed back.

    Repairing a ring with ``buffer(0)`` can return a ``Polygon``, a ``MultiPolygon`` (a
    figure-eight outline is genuinely two polygons) or an empty ``GeometryCollection`` --
    callers that then reach for ``.exterior`` break on the last two.
    """
    if geom.is_empty:
        return []
    if isinstance(geom, Polygon):
        return [geom] if geom.area > 0 else []
    parts: list[Polygon] = []
    for part in getattr(geom, "geoms", ()):
        parts.extend(_polygon_parts(part))
    return parts


def _flatten_shapely_to_paths(geom: MultiPolygon) -> list[Path2D]:
    """Extract all paths from a ``Polygon`` or ``MultiPolygon``.

    Every polygon (exterior and any holes) in the geometry is flattened into
    the result list.  For a ``MultiPolygon``, all component polygons are
    included.

    Returns:
        A flat list of :class:`Path2D` objects.

    """
    if geom.is_empty:
        return []
    polys: list[Polygon] = list(geom.geoms) if isinstance(geom, MultiPolygon) else [geom]
    paths: list[Path2D] = []
    for poly in polys:
        if not isinstance(poly, Polygon):
            continue
        # A region's outlines are rings, so they come back closed whatever built them.
        paths.append(Path2D(np.asarray(poly.exterior.coords)[:-1], closed=True))
        for interior in poly.interiors:
            paths.append(Path2D(np.asarray(interior.coords)[:-1], closed=True))
    return paths


def inward_probe(poly: Any) -> Any:
    """Return a point strictly inside *poly* and on no other polygon's boundary.

    The midpoint of the first edge, nudged inward. WHICH side is inward depends on the winding, so
    both are tried. Assuming one (this used to take `-dy, +dx` and nothing else) puts the probe
    OUTSIDE every clockwise ring -- 97 of the 153 rings in Wikipedia's Flag_of_Portugal -- and a
    probe outside its own polygon fails every containment test, so every nesting depth derived from
    it is wrong: the flag's green field came out a top-level sibling of the red one instead of
    sitting on it.

    Args:
        poly: The polygon to find an interior point of. It must carry no holes -- the probe is only
            guaranteed inside the shell, which is what nesting is computed from.

    Returns:
        A point inside *poly*.

    """
    from shapely.geometry import Point as _Point

    coords = poly.exterior.coords
    x0, y0 = float(coords[0][0]), float(coords[0][1])
    x1, y1 = float(coords[1][0]), float(coords[1][1])
    mx, my = (x0 + x1) / 2, (y0 + y1) / 2
    dx, dy = x1 - x0, y1 - y0
    for sx, sy in ((-dy, dx), (dy, -dx)):
        probe = _Point(mx + sx * 1e-6, my + sy * 1e-6)
        if poly.contains(probe):
            return probe
    # Degenerate first edge (a spike, or a duplicated point): fall back to a point shapely
    # guarantees is inside.
    return poly.representative_point()


def nesting_depths(polys: "Sequence[Any]", probes: "Sequence[Any]") -> list[int]:
    """Return how many other polygons contain each one -- the even-odd nesting depth.

    Even depth is a shell, odd depth is a hole in the shell that encloses it. This is the rule SVG
    and OpenSCAD's multi-path ``polygon()`` use.

    Args:
        polys: The polygons, each a bare shell.
        probes: An interior point per polygon, from :func:`inward_probe`.

    Returns:
        One depth per polygon, in the same order.

    """
    return [sum(1 for j, other in enumerate(polys) if i != j and other.contains(probes[i])) for i in range(len(polys))]


[docs] class Region: """A 2-D region backed by :mod:`shapely` (not OpenSCAD/PythonSCAD). Stores :class:`~shapely.geometry.MultiPolygon` internally and derives :class:`Path2D` outlines only when requested. All Boolean operations (union, intersection, difference, symmetric difference) use shapely directly. Operator overloads (``|``, ``&``, ``-``, ``^``) are provided. Create a region from a single outline (no holes):: Region([[0, 0], [80, 0], [80, 60], [0, 60]]) Create a region with holes (outline first, then hole paths):: Region([ [[0, 0], [80, 0], [80, 60], [0, 60]], # outer outline [[20, 20], [60, 20], [60, 40], [20, 40]], # hole 1 ]) Or use the shorthand :meth:`with_holes` for a more readable call. For native-geometry output (e.g. extrusion), call :meth:`geometry()` which converts paths to :class:`~pybosl2.shapes2d.Bosl2Shape2D`. Args: paths: The outlines; each is coerced to a :class:`Path2D`. A single flat point list is treated as one outline. A ``shapely.Polygon`` or ``shapely.MultiPolygon`` is also accepted. Examples: A rectangular plate with a rectangular hole (outline + one hole), extruded into a solid: .. pythonscad-example:: from pybosl2 import Region region = Region([ [[0, 0], [80, 0], [80, 60], [0, 60]], [[20, 20], [60, 20], [60, 40], [20, 40]], ]) region.geometry().linear_extrude(height=5).show() """ def __init__(self, paths: Any = ()) -> None: """Create a region from path outlines or a shapely geometry. When *paths* is a list of :class:`~pybosl2.path2d.Path2D` objects that each carry a colour (via :meth:`~pybosl2.path2d.Path2D.color`), the colours are read from the paths rather than passed separately. Args: paths: The outlines; each is coerced to a :class:`Path2D`. A single flat point list is treated as one outline. A ``shapely.Polygon`` or ``shapely.MultiPolygon`` is also accepted. """ self._color: "Color | None" = None self._polygon_colors: list["Color | None"] = [] if isinstance(paths, (Polygon, MultiPolygon)): if paths.is_empty: self._polygon = MultiPolygon() elif isinstance(paths, MultiPolygon): self._polygon = paths else: self._polygon = MultiPolygon([paths]) return items = list(paths) if items and not isinstance(items[0], (list, tuple, np.ndarray, Path2D)): raise TypeError(f"Region needs paths, got {type(items[0]).__name__}") if items and np.asarray(items[0], dtype=float).ndim == 1: items = [items] if not items: self._polygon = MultiPolygon() return paths_list = [p if isinstance(p, Path2D) else Path2D(p, closed=True) for p in items] outer = paths_list[0]._points holes = [h._points for h in paths_list[1:]] self._polygon = MultiPolygon([Polygon(outer, holes)])
[docs] def simplify(self, tolerance: float) -> "Region": """Return a copy with each polygon simplified, keeping its colour. Douglas-Peucker, topology-preserving, applied per polygon so the per-piece colours survive -- ``shapely``'s own ``simplify`` on the whole geometry would return bare polygons and drop them. Worth doing on traced or imported artwork, where the point count reflects the drawing tool rather than anything the output can show: Wikipedia's Flag of Portugal carries 33150 points, and a tolerance of 0.25 (0.02mm once that flag is 60mm wide) takes it to 11315 with the area unchanged to five figures. Args: tolerance: maximum deviation, in the region's own units. Returns: A new :class:`Region`; *self* is unchanged. """ if not (tolerance > 0): raise Bosl2ValueError(f"tolerance must be > 0, got {tolerance}") polys = list(self._polygon.geoms) if isinstance(self._polygon, MultiPolygon) else [self._polygon] colours = self._polygon_colors or [self._color] * len(polys) pieces: list[tuple[Any, Any]] = [] for poly, colour in zip(polys, colours, strict=False): reduced = poly.simplify(tolerance, preserve_topology=True) if not reduced.is_empty: pieces.append((colour, reduced)) simplified = Region._from_colored_pieces(pieces) if simplified._color is None: simplified._color = self._color return simplified
[docs] def color(self, c: "Color") -> "Region": """Return a copy of this region with the given :class:`Color`. Args: c: The colour to apply. """ copy = self.copy() copy._color = c return copy
[docs] def color_all(self, c: "Color") -> "Region": """Return a simplified copy with every polygon set to the same colour. All polygons are unioned together (same-colour overlaps merge into one shape) and the per-polygon colour list is collapsed to a single entry. The result has :attr:`_polygon_colors` cleared since every piece is the same colour, and :attr:`_color` set to *c*. Args: c: The colour to apply to every polygon. Must not be ``None``. Returns: A new :class:`Region` where every polygon has colour *c* and overlapping polygons are merged into non-overlapping pieces. """ from shapely.ops import unary_union as _unary_union new = self.copy() new._color = c polys = list(new._polygon.geoms) if isinstance(new._polygon, MultiPolygon) else [new._polygon] if polys: merged = _unary_union(polys) new._polygon = MultiPolygon([merged]) if isinstance(merged, Polygon) else merged new._polygon_colors = [] return new
[docs] def copy(self) -> "Region": """Return a shallow copy of this region.""" c = Region.__new__(Region) c._polygon = self._polygon c._color = self._color c._polygon_colors = list(self._polygon_colors) return c
def __len__(self) -> int: """Return the number of paths in the region.""" return len(self.paths) def __getitem__(self, index: int | slice) -> Path2D | list[Path2D]: """Access a path by index or slice.""" return self.paths[index] def __iter__(self) -> Iterator[Path2D]: """Iterate over the paths.""" return iter(self.paths) @property def paths(self) -> list[Path2D]: """The list of :class:`Path2D` objects derived from the geometry. Extracted on-demand from the underlying shapely :class:`~shapely.geometry.Polygon` or :class:`~shapely.geometry.MultiPolygon`. Returns: A list of :class:`Path2D` objects. """ return _flatten_shapely_to_paths(self._polygon) @property def geom(self) -> MultiPolygon: """The underlying shapely geometry.""" return self._polygon @classmethod def _from_colored_pieces(cls, pieces: "list[tuple[Any, Any]]") -> "Region": """Build a Region from already-resolved ``(colour, shapely geometry)`` pairs. For callers that have done their own compositing and just need the pieces carried into a Region with their colours -- :func:`~pybosl2.svg.region_from_svg` resolves SVG paint order itself, because that is per-element and :meth:`even_odd` is not. The pieces are expected to be disjoint; nothing here re-clips them. """ all_polys: list[Polygon] = [] colors: list["Color | None"] = [] for color, geom in pieces: for part in _polygon_parts(geom): all_polys.append(part) colors.append(color) if not all_polys: return cls() region = cls(MultiPolygon(all_polys) if len(all_polys) > 1 else all_polys[0]) region._polygon_colors = colors region._color = next((c for c in colors if c is not None), None) return region
[docs] def to_shapely(self) -> MultiPolygon: """Return the shapely geometry for this region. Equivalent to the :attr:`geom` property; provided for explicit usage. Returns: A ``shapely.Polygon`` or ``shapely.MultiPolygon``. """ return self.geom
[docs] @classmethod def from_svg( cls, file: str, fn: int | None = None, fa: float | None = None, fs: float = 2.0, flip_y: bool = True, color: str | None = None, strokes: str = "polygon", clip_to_viewbox: bool = True, ) -> "Region": """Load an SVG drawing as a Region of outlines (see :func:`pybosl2.svg.region_from_svg`). Real path data rather than the renderer's opaque imported handle, so the drawing can be measured, offset, rounded or tessellated like anything else -- and needs no renderer. Args: file: Path to the SVG. fn: Minimum fragment count per curved segment (``>= 3`` → absolute point count). Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs. fa: Minimum angle in degrees (accepted for API parity). Omitted, the ambient ``use_defaults(fa=...)`` value applies. fs: Minimum fragment size in SVG user units (default ``2.0``). Omitted, the ambient ``use_defaults(fs=...)`` value applies. flip_y: Negate Y so the drawing is not mirrored (SVG's Y axis points down). color: When set, overrides every shape's fill colour with this hex string. Pass ``None`` (the default) to use the SVG's own colours. strokes: ``"polygon"`` (default) converts stroked paths to filled polygons. ``"ignore"`` skips shapes that have only a stroke and no fill. clip_to_viewbox: When True (the default), clip the drawing to the SVG's ``viewBox``. Pass False for a drawing whose content already sits inside its viewBox, which is the usual case for a CAD-style silhouette, to skip the work. Returns: A :class:`Region` of the drawing, nested by the even-odd rule. """ from pybosl2.svg import region_from_svg return region_from_svg( file, fn=fn, fa=fa, fs=fs, flip_y=flip_y, color=color, strokes=strokes, clip_to_viewbox=clip_to_viewbox, )
[docs] @classmethod def even_odd(cls, paths: "Sequence[Path2D]") -> "Region": """Create a region from outlines nested by the EVEN-ODD rule. Colours are read from each :class:`~pybosl2.path2d.Path2D` object (via :meth:`~pybosl2.path2d.Path2D.color`). Same-colour solids are unioned together; different-colour solids are kept as separate non-overlapping polygons inside the region -- the first colour in order wins any overlap so :meth:`geometry` and :meth:`linear_extrude` render each in its own colour. Uncoloured paths are transparent: they do not participate in overlap resolution. The default constructor is outer-plus-holes: outline 0 bounds the region and every other outline is a hole in it. Even-odd instead decides each outline by how many others CONTAIN it, which is the rule SVG and OpenSCAD's multi-path ``polygon()`` use. Args: paths: The outlines, each a :class:`~pybosl2.path2d.Path2D` (SPEC C-7a). Coloured ``Path2D`` objects carry their colour through to the result. Returns: A :class:`Region` whose solid area is the even-odd interpretation of *paths*. The :attr:`_polygon_colors` list tracks which polygon has which colour. Examples: Two disjoint squares, each with a hole -- four outlines, two solids:: Region.even_odd([outer_a, hole_a, outer_b, hole_b]) Overlapping red and blue squares -- first colour wins the overlap:: from pybosl2 import Path2D, Region from pybosl2.color import Color red = Path2D([[0,0],[30,0],[30,20],[0,20]]).color(Color("#ff0000")) blue = Path2D([[20,0],[50,0],[50,20],[20,20]]).color(Color("#0000ff")) Region.even_odd([red, blue]).geometry().linear_extrude(height=3).show() """ from shapely.geometry import Polygon as _Polygon from shapely.ops import unary_union as _unary_union from pybosl2.paths import require_paths rings = [cast("Path2D", p) for p in require_paths(paths, "paths", "even_odd", Path2D)] # Repair each ring BEFORE it is used for nesting or unioning. Real drawings are full # of self-intersecting outlines -- 20 of the 148 rings in Wikipedia's Flag_of_Portugal # are -- and an invalid ring poisons everything downstream: its holes cannot be matched # to a shell and shapely aborts the whole union with # "TopologyException: unable to assign free hole to a shell". # Repairing afterwards (which is all `piece.buffer(0)` below used to do) is too late. # buffer(0) legitimately splits a figure-eight into two polygons, so a repaired ring # can yield several -- each becomes a ring in its own right, keeping its colour. polys: list[Polygon] = [] ring_colors: list["Color | None"] = [] for ring in rings: if len(ring) < 3: continue geom = _Polygon(ring._points) if not geom.is_valid: geom = geom.buffer(0) for part in _polygon_parts(geom): polys.append(part) ring_colors.append(ring._color if ring._color is not None else None) if not polys: return cls() probes = [inward_probe(poly) for poly in polys] depths = nesting_depths(polys, probes) color_groups: dict["Color | None", list[_Polygon]] = {} group_order: list["Color | None"] = [] for i, poly in enumerate(polys): c = ring_colors[i] holes = [ o.exterior.coords for j, o in enumerate(polys) if j != i and depths[j] == depths[i] + 1 and poly.contains(probes[j]) ] piece = _Polygon(poly.exterior.coords, holes) if not piece.is_valid: piece = piece.buffer(0) if piece.is_empty: continue if depths[i] % 2 and c is None: # Odd depth with no fill is just a hole in the ring enclosing it, which # that ring has already cut. Nothing to add. continue # An odd-depth ring WITH a fill is a solid filling its own hole -- it joins its # colour group like any other piece, keeping its own holes so the rings nested # inside IT are not covered over. It used to be held back and appended at the # end, skipping the subtraction below and built from its bare exterior: that is # why a colour could overlap ITSELF (8077mm^2 of the Portuguese flag's yellow) # and why the arms overlapped the fields they sit on. if c not in color_groups: group_order.append(c) color_groups.setdefault(c, []).append(piece) if not color_groups: return cls() all_polys: list[Polygon] = [] polygon_colors: list["Color | None"] = [] previous_union: Polygon | MultiPolygon | None = None for color in group_order: pieces = color_groups[color] merged = _unary_union(pieces) # Uncoloured paths are transparent: they do not subtract from or # get subtracted by coloured paths. They just live alongside them. if color is not None: if previous_union is not None and merged.intersects(previous_union): merged = merged.difference(previous_union) if merged.is_empty: continue previous_union = merged if previous_union is None else previous_union.union(merged) for p in _polygon_parts(merged): all_polys.append(p) polygon_colors.append(color) if not all_polys: return cls() result_geom = MultiPolygon(all_polys) if len(all_polys) > 1 else all_polys[0] region = cls(result_geom) region._polygon_colors = polygon_colors region._color = next((c for c in polygon_colors if c is not None), None) return region
[docs] @classmethod def with_holes( cls, outline: Path2D, *holes: Path2D, ) -> "Region": r"""Create a region from an outline plus hole outlines. Convenience constructor. Equivalent to ``Region([outline, \\*holes])``. See :meth:`__init__` for the full constructor. Args: outline: The outer outline :class:`Path2D`. holes: Zero or more hole outline :class:`Path2D` objects. Returns: A :class:`Region` with the outline as the first path and holes as subsequent paths. """ from pybosl2.paths import require_path, require_paths outline = cast("Path2D", require_path(outline, "outline", "with_holes", Path2D)) require_paths(list(holes), "holes", "with_holes", Path2D) return cls([outline, *holes])
@property def outline(self) -> Path2D: """The outer path. Returns: The first :class:`Path2D` in the region, which is the outer outline. """ return self.paths[0] if self.paths else Path2D([], closed=True) @property def holes(self) -> list[Path2D]: """The hole paths. Returns: All :class:`Path2D` objects after the first, which are the interior holes. """ return self.paths[1:]
[docs] def offset( self, radius: float | None = None, delta: float | None = None, chamfer: bool = False, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> "Region": """Offset every path in the region. Args: radius: The corner-rounding radius for the offset. delta: The absolute offset distance. chamfer: Whether to chamfer corners instead of rounding them. fn: Fixed number of fragments for the rounded corners; ambient default when omitted. Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs. fa: Minimum fragment angle for the rounded corners. Omitted, the ambient ``use_defaults(fa=...)`` value applies. fs: Minimum fragment size for the rounded corners. Omitted, the ambient ``use_defaults(fs=...)`` value applies. Returns: A new :class:`Region` with every path offset by the given parameters. """ result = Region( [p.offset(radius=radius, delta=delta, chamfer=chamfer, fn=fn, fa=fa, fs=fs) for p in self.paths] ) if self._color is not None: result._color = self._color return result
[docs] def round_corners( self, radius: float | list[float] | None = None, method: RoundingMethod = RoundingMethod.CIRCLE, cut: float | None = None, joint: float | None = None, width: float | None = None, curvature: float | None = None, closed: bool | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> "Region": """Round the corners of every path in the region. Args: radius: The rounding radius. A single float applies to all corners; a list applies per-corner radii. method: The rounding method (``"circle"``, ``"smooth"``, etc.). cut: Cut depth for chamfers. joint: Joint distance for rounding. width: Width for rounding. curvature: Curvature value for rounding. closed: Override whether paths are treated as closed. fn: Fixed number of fragments per full circle; ambient default when omitted. Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs. fa: Minimum fragment angle in degrees. Omitted, the ambient ``use_defaults(fa=...)`` value applies. fs: Minimum fragment size in millimetres. Omitted, the ambient ``use_defaults(fs=...)`` value applies. Returns: A new :class:`Region` with rounded corners on every path. """ result = Region( [ p.round_corners( radius=radius, # type: ignore[arg-type] method=method, cut=cut, joint=joint, width=width, curvature=curvature, closed=closed, fn=fn, fa=fa, fs=fs, ) for p in self.paths ] ) if self._color is not None: result._color = self._color return result
[docs] def translate(self, v: Sequence[float]) -> "Region": """Translate every path in the region by the given vector. Args: v: A 2-D or 3-D translation vector. Returns: A new :class:`Region` with every path translated. """ return Region([p.translate(v) for p in self.paths])
[docs] def bounds(self) -> Bounds2D: """Return the axis-aligned bounding box over every path in the region (SPEC S-2b). Returns: The :class:`~pybosl2.bounds.Bounds2D` box -- the same type every other ``bounds()`` in the library answers, rather than the bare NumPy ``[[min], [max]]`` array this used to hand back. Raises: Bosl2ValueError: If the region holds no paths. Examples: .. pythonscad-example:: from pybosl2 import Path2D, Region region = Region([Path2D([[0, 0], [30, 0], [30, 20], [0, 20]], closed=True)]) print(region.bounds().size) # (30.0, 20.0) region.geometry().linear_extrude(height=4).show() """ if not (self.paths): raise Bosl2ValueError("empty Region has no bounds") all_pts = np.vstack([p.array for p in self.paths]) return Bounds2D.from_min_max(all_pts.min(axis=0).tolist(), all_pts.max(axis=0).tolist())
[docs] def geometry(self) -> "Flat": """2-D geometry: every polygon in the region, each with its holes subtracted. When :attr:`_polygon_colors` is populated each polygon is coloured individually before being unioned into the final shape. Otherwise the region's single :attr:`_color` is applied to every piece. Returns: A :class:`~pybosl2.shapes2d.Bosl2Shape2D`, so the result chains straight into the 2-D operators and the extruders. """ polys = list(self._polygon.geoms) if isinstance(self._polygon, MultiPolygon) else [self._polygon] shape = None for i, poly in enumerate(polys): if poly.is_empty: continue piece = Path2D(list(poly.exterior.coords)[:-1], closed=True).polygon() for interior in poly.interiors: piece = piece - Path2D(list(interior.coords)[:-1], closed=True).polygon() poly_color = ( self._polygon_colors[i] if i < len(self._polygon_colors) and self._polygon_colors[i] is not None else self._color ) if poly_color is not None and hasattr(piece, "color"): piece = piece.color(poly_color) shape = piece if shape is None else (shape | piece) if shape is None: from pybosl2.flat import polygon as _facade_polygon # local: flat imports this module shape = _facade_polygon(Path2D([[0.0, 0.0], [0.0, 0.0], [0.0, 0.0]])) return shape
[docs] def fill(self) -> "Flat": """Return this region as 2-D geometry with its holes filled in. Equivalent to just the outline (OpenSCAD ``fill()``). Returns: A :class:`~pybosl2.flat.Flat` with its holes closed. """ result = self.geometry().fill() if self._color is not None and hasattr(result, "color"): result = result.color(self._color) return result
def _split_polygons(self) -> "list[Region]": """Return sub-Regions, one per distinct colour group, with overlaps resolved. Same-colour polygons are unioned together; different-colour polygons are kept as separate Regions. The first colour wins any overlap so that no two sub-Regions share geometry. Used internally by :meth:`linear_extrude` so that each colour group can be extruded and coloured independently. Returns: A list of :class:`Region` objects, one per colour group found in :attr:`_polygon_colors`. When :attr:`_polygon_colors` is empty the region's single :attr:`_color` is used for every piece. """ from shapely.ops import unary_union as _unary_union from pybosl2.color import Color as _Color # noqa: TC001 polys = list(self._polygon.geoms) if isinstance(self._polygon, MultiPolygon) else [self._polygon] color_groups: dict[_Color | None, list[Polygon]] = {} for i, poly in enumerate(polys): if poly.is_empty: continue c = self._polygon_colors[i] if i < len(self._polygon_colors) else None if c is None: c = self._color color_groups.setdefault(c, []).append(poly) pieces: list[Region] = [] for color, group_polys in color_groups.items(): merged = _unary_union(group_polys) if isinstance(merged, Polygon): if not merged.is_empty: r = Region(merged) if color is not None: r._color = color pieces.append(r) else: for p in merged.geoms: if not p.is_empty: r = Region(p) if color is not None: r._color = color pieces.append(r) return pieces if pieces else [self]
[docs] @classmethod def hull(cls, *others: Region | Path2D) -> "Region": """Return the 2-D convex hull of all the given regions and paths. Uses shapely :func:`~shapely.convex_hull` on the union of all input geometries. Accepts a flat list or multiple arguments:: Region.convex_hull(rect, circle) Region.convex_hull([rect, circle]) Args: others: The regions or closed paths to hull together. Returns: A :class:`Region` representing the convex hull. """ from shapely.ops import unary_union from pybosl2.path2d import Path2D as _Path items: list[Region | Path2D] = ( list(others[0]) if len(others) == 1 and isinstance(others[0], (list, tuple)) else list(others) ) geoms: list[Polygon] = [] for item in items: if isinstance(item, _Path): item = Region([item]) if not isinstance(item, Region): raise TypeError(f"convex_hull() expects Region or Path2D, got {type(item).__name__}") geoms.extend(item.geom.geoms) if not geoms: return Region() result = unary_union(geoms).convex_hull r = Region(_flatten_shapely_to_paths(result)) r._polygon = MultiPolygon([result]) if isinstance(result, Polygon) else result return r
[docs] def linear_extrude( self, height: float, center: bool = False, twist: float = 0.0, scale: float = 1.0, slices: int | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, color_heights: "dict[str | Color, float] | None" = None, ) -> "Solid": """Extrude this region along +Z into a 3-D solid with holes included. When the region contains multiple colour groups (via :attr:`_polygon_colors`), each group is extruded separately and the results are unioned -- so different-colour parts of an SVG drawing become different-colour solids without manual splitting. The result depends on the active backend: a :class:`~pybosl2.shapes3d.Bosl2Solid` under the default CSG backend, or a :class:`~pybosl2.sdf.shapes3d.PyShape` under ``use_backend("sdf")``. See :meth:`pybosl2.paths.Path2D.linear_extrude` for per-backend options. The SDF backend's prism is the union of the outlines' fields, so it can only express a region of DISJOINT islands; a region with holes raises :class:`~pybosl2.exceptions.UnsupportedByBackendError` there. Args: height: The extrusion height along +Z. center: Extrude symmetrically along Z if True (default False). twist: Twist angle in degrees over the full height (default 0). scale: Scale factor for the top cross-section (default 1.0). slices: Number of intermediate layers for twist/scale (auto if None). fn: Smoothness override for the angular resolution. Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs. fa: Smoothness override for the minimum angle. Omitted, the ambient ``use_defaults(fa=...)`` value applies. fs: Smoothness override for the minimum segment length. Omitted, the ambient ``use_defaults(fs=...)`` value applies. color_heights: Optional dict mapping colour names (``"#ff0000"``, ``"red"``, or :class:`~pybosl2.color.Color` objects) to a specific extrusion height. Coloured pieces that match a key are extruded by that height; pieces whose colour is not in the mapping use the default *height*. Returns: A :class:`~pybosl2.shapes3d.Bosl2Solid` (CSG) or :class:`~pybosl2.sdf.shapes3d.PyShape` (SDF). Example: .. code-block:: python red = Path2D.square(20).color("red") blue = Path2D.square(20).color("blue") region = Region.even_odd([red, blue.translate([25, 0])]) solid = region.linear_extrude( height=5, color_heights={"red": 10, "blue": 3}, ) solid.show() # The red square is 10 mm tall, the blue square is 3 mm tall. """ from functools import reduce from pybosl2._backend import current_backend, get_backend from pybosl2.exceptions import UnsupportedByBackendError pieces = self._split_polygons() # Normalize colour-keyed heights so string keys work alongside Color keys. height_map: dict[Color, float] = {} if color_heights: from pybosl2.color import Color as _Color for col, h in color_heights.items(): height_map[_Color(col) if not isinstance(col, _Color) else col] = h def _height_for(region: Region) -> float: if region._color is not None and height_map: for c, h in height_map.items(): if c == region._color: return h return height if len(pieces) == 1: # Single polygon: use the backend's native outline+holes path if current_backend() != "csg" and pieces[0].holes: raise UnsupportedByBackendError( "linear_extrude (region with holes)", current_backend(), hint="the sdf prism unions its outlines' fields, so it cannot cut holes. " "Extrude the outline and subtract the holes' own extrusions, or build " "it on the csg backend.", ) from pybosl2._backend import given_arguments piece_height = _height_for(pieces[0]) result = get_backend().linear_extrude( list(pieces[0].paths), piece_height, given_arguments( { "center": center, "twist": twist, "scale": scale, "slices": slices, "fn": fn, "fa": fa, "fs": fs, } ), ) if pieces[0]._color is not None and hasattr(result, "color"): result = result.color(pieces[0]._color) return result extruded = [ p.linear_extrude( _height_for(p), center=center, twist=twist, scale=scale, slices=slices, fn=fn, fa=fa, fs=fs, ) for p in pieces ] return reduce(lambda a, b: a | b, extruded)
[docs] def rotate_extrude( self, angle: float = 360.0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> "Solid": """Revolve this region about the Y axis into a 3-D solid. Args: angle: The rotation angle in degrees. fn: Number of polygon segments for curved geometry. Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back out to fa/fs. fa: Minimum angle for polygon segments. Omitted, the ambient ``use_defaults(fa=...)`` value applies. fs: Minimum size for polygon segments. Omitted, the ambient ``use_defaults(fs=...)`` value applies. Returns: A :class:`~pybosl2._backend.Solid` from the active backend (SPEC A-10). Revolving is CSG-only, so the SDF backend refuses rather than approximating (SPEC B-4). """ result = self.geometry().rotate_extrude(angle, fn=fn, fa=fa, fs=fs) if self._color is not None and hasattr(result, "color"): result = result.color(self._color) return result
[docs] def stroke( self, width: float = 1, closed: bool | None = None, # noqa: ARG002 endcap1: CapType | CapSpec = CapType.ROUND, endcap2: CapType | CapSpec = CapType.ROUND, joints: CapType | CapSpec = CapType.ROUND, ) -> Region: """Stroke every path in the region (closed) and return the union as a Region. Args: width: Stroke width. closed: Accepted for signature parity; a region's paths are always stroked closed. endcap1: Cap style for the start of each path. endcap2: Cap style for the end of each path. joints: Cap style where segments meet. Returns: A :class:`Region` of the stroked outlines. """ from pybosl2._stroke2d import stroke_2d polygons = [ stroke_2d(p, width=width, closed=True, endcap1=endcap1, endcap2=endcap2, joints=joints) for p in self.paths ] result = Region([]) if not polygons else Region(polygons) if self._color is not None: result._color = self._color return result
[docs] def dashed_stroke( self, dashpat: Any = None, closed: bool | None = None, # noqa: ARG002 fit: bool = True, mindash: float = 0.5, ) -> Region: """Break every path in the region into dashed polygon outlines. Returns a :class:`Region` of all dash polygons. Args: dashpat: The dash pattern, as alternating on and off lengths. closed: Join the last point back to the first. fit: Stretch the pattern so a whole number of dashes fits the path. mindash: Shortest dash to keep; anything shorter is dropped. """ from pybosl2._stroke2d import dashed_stroke_2d results: list[Region] = [ dashed_stroke_2d(p, dashpat=dashpat, closed=True, fit=fit, mindash=mindash) for p in self.paths ] if not results: return Region([]) combined = results[0] for r in results[1:]: combined = combined | r return combined
[docs] def debug_region(self, size: float = 1, vertices: bool = True) -> Any: """Visualize this region with vertex labels for debugging. Produces the filled region as a thin flat solid with every path's vertices labelled in red -- path ``a`` gets labels ``a0, a1, ...``, path ``b`` ``b0, b1, ...`` (BOSL2 ``debug_region()``). A single-path region defers to :meth:`~pybosl2.paths.Path2D.debug_polygon`. Args: size: Text size for vertex labels. vertices: If False, omit vertex labels and return only the filled region. Returns: A :class:`~pybosl2.shapes3d.Bosl2Solid`. """ import operator from functools import reduce from pybosl2.color import Color from pybosl2.path2d import Path2D as _Path paths = [p if isinstance(p, _Path) else _Path(p) for p in self.paths] if len(paths) <= 1: return (paths[0] if paths else _Path(self.paths)).debug_polygon(size=size, vertices=vertices) # type: ignore[arg-type] solid = self.geometry().linear_extrude(height=0.01, center=True) if not vertices: return solid labels = [ text3d( f"{chr(97 + j)}{i}", size=size, height=0.02, halign="center", valign="center", ) .translate([float(x), float(y), 0.01]) .color(Color("red")) for j, path in enumerate(paths) for i, (x, y) in enumerate(path) ] return reduce(operator.or_, [solid, *labels])
# ----------------------------------------------------------------------------------- # 2-D boolean set operations # -----------------------------------------------------------------------------------
[docs] def intersection(self, other: Region | Path2D) -> "Region": """Return the 2-D intersection of this region with other (the area they share). Uses shapely for exact polygon coordinates. The result inherits *self*'s colour. Args: other: the region to intersect with. Returns: A :class:`Region` with the intersection area. Examples: Two overlapping squares share a rectangular strip: .. pythonscad-example:: from pybosl2 import Region a = Region([[0, 0], [40, 0], [40, 30], [0, 30]]) b = Region([[20, 0], [60, 0], [60, 30], [20, 30]]) a.intersection(b).geometry().linear_extrude(height=3).show() """ if isinstance(other, Path2D): other = Region([other]) result = self.geom.intersection(other.geom) if result.is_empty: return Region([]) r = Region(_flatten_shapely_to_paths(result)) r._polygon = result r._color = self._color return r
[docs] def union(self, other: Region | Path2D) -> "Region": """Return the 2-D union of this region and other (all area covered by either). Uses shapely for exact polygon coordinates. When *self* and *other* carry different colours, the union inherits *self*'s colour -- the first path in the set wins the overlap. Args: other: the region to union with. Returns: A :class:`Region` with the combined area. Examples: Two adjacent squares merge into an L-shape: .. pythonscad-example:: from pybosl2 import Region a = Region([[0, 0], [30, 0], [30, 30], [0, 30]]) b = Region([[20, 0], [50, 0], [50, 30], [20, 30]]) a.union(b).geometry().linear_extrude(height=3).show() """ if isinstance(other, Path2D): other = Region([other]) result = self.geom.union(other.geom) if result.is_empty: return Region([]) r = Region(_flatten_shapely_to_paths(result)) r._polygon = result r._color = self._color return r
[docs] def difference(self, other: Region | Path2D) -> "Region": """Return the 2-D difference: self with the area of other subtracted. Uses shapely for exact polygon coordinates. The result inherits *self*'s colour. Args: other: the region to subtract. Returns: A :class:`Region` with the subtracted area. Examples: Punch a rectangular notch out of a square: .. pythonscad-example:: from pybosl2 import Region plate = Region([[0, 0], [60, 0], [60, 40], [0, 40]]) notch = Region([[20, 10], [40, 10], [40, 30], [20, 30]]) plate.difference(notch).geometry().linear_extrude(height=4).show() """ if isinstance(other, Path2D): other = Region([other]) result = self.geom.difference(other.geom) if result.is_empty: return Region([]) r = Region(_flatten_shapely_to_paths(result)) r._polygon = result r._color = self._color return r
[docs] def symmetric_difference(self, other: Region | Path2D) -> "Region": """Return the 2-D symmetric difference (XOR): area in either region but not both. Uses shapely for exact polygon coordinates. The result inherits *self*'s colour. Args: other: the region to xor with. Returns: A :class:`Region` with the symmetric difference area. """ if isinstance(other, Path2D): other = Region([other]) result = self.geom.symmetric_difference(other.geom) if result.is_empty: return Region([]) r = Region(_flatten_shapely_to_paths(result)) r._polygon = result r._color = self._color return r
# Operator overloads for convenience (mirror Bosl2Shape2D's &/|/- operators). def __and__(self, other: Region | Path2D) -> "Region": """Return ``self & other``, equivalent to ``self.intersection(other)``.""" return self.intersection(other) def __or__(self, other: Region | Path2D) -> "Region": """Return ``self | other``, equivalent to ``self.union(other)``.""" return self.union(other) def __sub__(self, other: Region | Path2D) -> "Region": """Return ``self - other``, equivalent to ``self.difference(other)``.""" return self.difference(other) def __xor__(self, other: Region | Path2D) -> "Region": """Return ``self ^ other``, equivalent to ``self.symmetric_difference(other)``.""" return self.symmetric_difference(other) def __repr__(self) -> str: """Return a string representation of the region.""" return f"Region({len(self.paths)} paths: {[len(p) for p in self.paths]})"