# 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
# LibFile: bosl2/regions.py
# Path 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
# (`Path(pts).offset(radius=-2).round_corners(radius=1).polygon()`) instead of threading raw
# point lists through free functions.
#
# FileSummary: Object API for 2-D paths and regions.
# FileGroup: Bosl2
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any
import numpy as np
from bosl2.paths import (
Path,
Path3D,
) # Path/Path3D live in paths.py; re-exported here for compatibility
from bosl2.shapes3d import text3d
if TYPE_CHECKING: # for the annotations only -- importing shapes2d here would be circular
from bosl2._backend import Solid # noqa: F401
from bosl2.shapes2d import Bosl2Shape2D, Shape2DLike # noqa: F401
from bosl2.shapes3d import Bosl2Solid # noqa: F401
__all__ = ["Path", "Path3D", "Region"]
[docs]
class Region(list):
"""A 2-D region: a list of :class:`Path` outlines, holes included.
BOSL2 represents a shape-with-holes as a list of paths (outline first, then the holes), and
that is what this is -- so, like :class:`Path`, it subclasses ``list`` and stays a drop-in
for the raw region data the toolkit already passes to ``region()``/``union()``.
Args:
paths: the outlines; each is coerced to a :class:`Path`. A single flat point list is
accepted and treated as one outline.
Examples:
A rectangular plate with a rectangular hole (outline + one hole), extruded into a solid:
.. pythonscad-example::
region = Region.with_holes(
[[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: Sequence = ()) -> None:
items = list(paths)
if items and not isinstance(items[0], (list, tuple, np.ndarray)):
raise TypeError(f"Region needs paths, got {type(items[0]).__name__}")
# a bare point list ([[x, y], ...]) is one outline, not a list of paths
if items and np.asarray(items[0], dtype=float).ndim == 1:
items = [items]
super().__init__([p if isinstance(p, Path) else Path(p) for p in items])
[docs]
@classmethod
def with_holes(cls, outline: Sequence, *holes: Sequence) -> "Region":
"""A region from an outline plus hole outlines.
This is what a concentric ``DifferenceWithOffset`` produces: outline + inner hole, no
clipping involved.
"""
return cls([outline, *holes])
@property
def outline(self) -> Path:
"""The outer path."""
assert len(self), "empty Region has no outline"
return self[0]
@property
def holes(self) -> list[Path]:
return list(self[1:])
[docs]
def offset(
self,
radius: float | None = None,
delta: float | None = None,
chamfer: bool = False,
) -> "Region":
"""Offset every path in the region."""
return Region([p.offset(radius=radius, delta=delta, chamfer=chamfer) for p in self])
[docs]
def round_corners(self, radius: float | list[float] | None = None, **kwargs: Any) -> "Region":
return Region([p.round_corners(radius=radius, **kwargs) for p in self])
[docs]
def translate(self, v: Sequence[float]) -> "Region":
return Region([p.translate(v) for p in self])
[docs]
def bounds(self) -> np.ndarray:
"""[[min_x, min_y], [max_x, max_y]] over every path."""
assert len(self), "empty Region has no bounds"
all_pts = np.vstack([p.array for p in self])
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:`~bosl2.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":
"""This region as 2-D geometry with its holes filled in -- i.e. just the outline
(OpenSCAD ``fill()``).
Returns:
A :class:`~bosl2.shapes2d.Bosl2Shape2D`.
"""
return self.geometry().fill()
[docs]
def hull(self, *others: "Shape2DLike") -> "Bosl2Shape2D":
"""The 2-D convex hull of this region, optionally together with *others* (more regions,
paths, 2-D shapes or point lists) -- OpenSCAD ``hull()``.
Returns:
A :class:`~bosl2.shapes2d.Bosl2Shape2D`.
"""
return self.geometry().hull(*others)
[docs]
def linear_extrude(self, height: float, **kwargs: Any) -> "Solid":
"""Extrude this region *height* along +Z into a 3-D solid (holes included), **on whichever
backend is active** -- a :class:`~bosl2.shapes3d.Bosl2Solid` under the default CSG backend,
a :class:`~bosl2._sdf.shapes3d.PyShape` under ``use_backend("sdf")``. See
:meth:`bosl2.paths.Path.linear_extrude` for the 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:`~bosl2.exceptions.UnsupportedByBackend` there.
"""
from bosl2._backend import current_backend, get_backend
from bosl2.exceptions import UnsupportedByBackend
if current_backend() != "csg" and self.holes:
raise UnsupportedByBackend(
"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.",
)
return get_backend().linear_extrude(list(self), height, **kwargs)
[docs]
def rotate_extrude(self, angle: float = 360.0, **kwargs: Any) -> "Bosl2Solid":
"""Revolve this region about the Y axis into a 3-D solid; see
:meth:`~bosl2.shapes2d.Bosl2Shape2D.rotate_extrude`.
Returns:
A :class:`~bosl2.shapes3d.Bosl2Solid` (csg backend only -- the SDF backend has no
revolve).
"""
return self.geometry().rotate_extrude(angle, **kwargs)
[docs]
def debug_region(self, size: float = 1, vertices: bool = True):
"""A debug view of this region: 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:`~bosl2.paths.Path.debug_polygon`.
Returns:
A :class:`~bosl2.shapes3d.Bosl2Solid`.
"""
import operator
from functools import reduce
from bosl2.paths import Path
paths = [p if isinstance(p, Path) else Path(p) for p in self]
if len(paths) <= 1:
return (paths[0] if paths else Path(self)).debug_polygon(size=size, vertices=vertices)
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])
[docs]
def stroke(self, width: float = 1, **kwargs: Any):
"""
Draw every path in this region as a closed solid line (see
:func:`bosl2.drawing.stroke`).
"""
from bosl2.drawing import stroke as _stroke
return _stroke(self, width=width, **kwargs)
[docs]
def dashed_stroke(self, dashpat: Sequence[float] = (3, 3), **kwargs: Any) -> "list[Path | Path3D]": # type: ignore[override]
"""
Break every path in this region into dash sub-paths (see
:func:`bosl2.drawing.dashed_stroke`).
"""
from bosl2.drawing import dashed_stroke as _dashed
return _dashed(self, dashpat=dashpat, **kwargs) # type: ignore[return-value]
def __repr__(self) -> str:
return f"Region({len(self)} paths: {[len(p) for p in self]})"