# 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
import numpy as np
from shapely.geometry import MultiPolygon, Polygon
from pybosl2.caps import CapSpec, CapType
from pybosl2.enums import RoundingMethod
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.shapes2d import Bosl2Shape2D
from pybosl2.shapes3d import Bosl2Solid
__all__ = ["Region"]
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
[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.
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.
"""
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)])
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
[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 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.
"""
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,
) -> "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.
Returns:
A new :class:`Region` with every path offset by the given parameters.
"""
return Region([p.offset(radius=radius, delta=delta, chamfer=chamfer) for p in self.paths])
[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,
) -> "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.
Returns:
A new :class:`Region` with rounded corners on every path.
"""
return Region(
[
p.round_corners(
radius=radius, # type: ignore[arg-type]
method=method,
cut=cut,
joint=joint,
width=width,
curvature=curvature,
closed=closed,
)
for p in self.paths
]
)
[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) -> np.ndarray:
"""Return the bounding box over every path in the region.
Returns:
A numpy array ``[[min_x, min_y], [max_x, max_y]]``.
"""
assert self.paths, "empty Region has no bounds"
all_pts = np.vstack([p.array for p in self.paths])
return np.array([all_pts.min(axis=0), all_pts.max(axis=0)])
[docs]
def geometry(self) -> "Bosl2Shape2D":
"""2-D geometry: the outline with the holes subtracted.
Returns:
A :class:`~pybosl2.shapes2d.Bosl2Shape2D`, so the result chains straight into the 2-D
operators and the extruders.
"""
shape = self.outline.polygon()
for hole in self.holes:
shape = shape - hole.polygon()
return shape
[docs]
def fill(self) -> "Bosl2Shape2D":
"""Return this region as 2-D geometry with its holes filled in.
Equivalent to just the outline (OpenSCAD ``fill()``).
Returns:
A :class:`~pybosl2.shapes2d.Bosl2Shape2D`.
"""
return self.geometry().fill()
[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,
) -> "Solid":
"""Extrude this region along +Z into a 3-D solid with holes included.
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.
fa: Smoothness override for the minimum angle.
fs: Smoothness override for the minimum segment length.
Returns:
A :class:`~pybosl2.shapes3d.Bosl2Solid` (CSG) or
:class:`~pybosl2._sdf.shapes3d.PyShape` (SDF).
"""
from pybosl2._backend import current_backend, get_backend
from pybosl2.exceptions import UnsupportedByBackendError
if current_backend() != "csg" and self.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
return get_backend().linear_extrude(
list(self.paths),
height,
given_arguments(
{
"center": center,
"twist": twist,
"scale": scale,
"slices": slices,
"fn": fn,
"fa": fa,
"fs": fs,
}
),
)
[docs]
def rotate_extrude(
self,
angle: float = 360.0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> "Bosl2Solid":
"""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.
fa: Minimum angle for polygon segments.
fs: Minimum size for polygon segments.
Returns:
A :class:`~pybosl2.shapes3d.Bosl2Solid` (csg backend only -- the SDF backend has no
revolve).
"""
return self.geometry().rotate_extrude(angle, fn=fn, fa=fa, fs=fs)
[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
]
if not polygons:
return Region([])
return Region(polygons)
[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.
"""
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.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("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.
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
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.
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
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.
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
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.
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
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]})"