# 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]})"