# 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: pybosl2/shapes2d/ops.py
# FileSummary: Boolean operations, offsets, text and cross helpers.
# DocCategory: Foundational
# FileGroup: BOSL2
"""Boolean operations, offsets, text and cross helpers."""
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, Union
import numpy as np
from pybosl2._edges_lang import Anchor
# Import base class and helper functions from shapes2d.base
from pybosl2._helpers import (
anchor_offset_box as _anchor_offset_box,
)
from pybosl2._helpers import (
as_native_2d as _as_native_2d,
)
from pybosl2._helpers import (
is_child_2d as _is_child_2d,
)
from pybosl2._native import native
from pybosl2.constants import CENTER
from .base import Bosl2Shape2D, _finish
if TYPE_CHECKING:
from openscad import PyOpenSCAD
from pybosl2.path2d import Path2D
Shape2DLike = Union["Bosl2Shape2D", "PyOpenSCAD", "Path2D", Sequence[Sequence[float]], np.ndarray]
if TYPE_CHECKING: # real stub-typed imports for the checker (identical to pre-lazy)
from pythonscad import circle as _ocircle
from pythonscad import fill as _ofill
from pythonscad import hull as _ohull
from pythonscad import polygon as _opolygon
from pythonscad import square as _osquare
from pythonscad import text as _otext
else:
_ocircle = native("circle")
_ofill = native("fill")
_ohull = native("hull")
_opolygon = native("polygon")
_osquare = native("square")
_otext = native("text")
[docs]
def fill(children: "Shape2DLike") -> Bosl2Shape2D:
"""*children* with every hole filled in -- only the outermost outline survives.
(OpenSCAD ``fill()``, the module form of :meth:`Bosl2Shape2D.fill`).
Args:
children: the 2-D shape to fill (a ``Bosl2Shape2D``, a native shape, a
:class:`~pybosl2.paths.Path2D` / :class:`~pybosl2.regions.Region`, or a point list)
"""
return Bosl2Shape2D(_ofill(_as_native_2d(children)))
[docs]
def hull(*children: "Shape2DLike | Sequence[Shape2DLike]") -> Bosl2Shape2D:
"""Return the 2-D convex hull of *children* (OpenSCAD ``hull()``, the module form of.
:meth:`Bosl2Shape2D.hull`).
Args:
children: the 2-D shapes to hull -- any mix of ``Bosl2Shape2D``, native shapes,
:class:`~pybosl2.paths.Path2D` / :class:`~pybosl2.regions.Region`, or point lists.
A single list/tuple *of* shapes is also accepted.
"""
items = list(children)
if len(items) == 1 and not _is_child_2d(items[0]):
items = list(items[0]) # type: ignore[arg-type] # a single list *of* shapes
assert items, "hull(): needs at least one child."
return Bosl2Shape2D(_ohull(*[_as_native_2d(c) for c in items]))
# ---------------------------------------------------------------------------
# Section: Rounding 2D shapes
# ---------------------------------------------------------------------------
[docs]
def round2d(
radius: float | None = None,
outer_radius: float | None = None,
inner_radius: float | None = None,
children: "Bosl2Shape2D | PyOpenSCAD | None" = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""Round the concave and/or convex corners of arbitrary 2-D children, via chained .offset() calls.
Giving `radius` rounds all corners; `inner_radius` alone rounds only concave corners;
`outer_radius` alone rounds only convex corners; giving both rounds each to a different
radius.
Note: BOSL2's outer-radius parameter is named `or`, exposed here as `outer_radius`.
Args:
radius: radius to round all concave and convex corners to
outer_radius: radius to round only convex (outside) corners to (BOSL2 `or`)
inner_radius: radius to round only concave (inside) corners to
children: the 2-D solid(s) to round
fn: arc smoothness overrides
fa: arc smoothness overrides
fs: arc smoothness overrides
"""
orad = outer_radius if outer_radius is not None else (radius if radius is not None else 0)
irad = inner_radius if inner_radius is not None else (radius if radius is not None else 0)
assert orad >= 0, f"outer_radius must be >= 0, got {orad}"
assert irad >= 0, f"inner_radius must be >= 0, got {irad}"
assert children is not None, "round2d(): must give children"
shape = Bosl2Shape2D(_as_native_2d(children))
shape = shape.offset(delta=irad, chamfer=True)
shape = shape.offset(delta=-(irad + orad))
return shape.offset(radius=orad, fn=fn, fa=fa, fs=fs)
[docs]
def shell2d(
thickness: float | Sequence[float] | None = None,
outer_radius: float | Sequence[float] = 0,
inner_radius: float | Sequence[float] = 0,
children: "Bosl2Shape2D | PyOpenSCAD | None" = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""Create a hollow shell from 2-D children, with optional rounding.
Note: BOSL2's outer-radius parameter is named `or`, exposed here as `outer_radius`.
Args:
thickness: shell thickness; positive expands outward, negative shrinks inward,
or a 2-element list to do both
outer_radius: rounding radius for outside corners of the shell (BOSL2 `or`); a
[CONVEX,CONCAVE] pair rounds those corner types separately (default 0)
inner_radius: rounding radius for inside corners of the shell; a [CONVEX,CONCAVE]
pair rounds those corner types separately (default 0)
children: the 2-D solid(s) to shell
fn: arc smoothness overrides
fa: arc smoothness overrides
fs: arc smoothness overrides
"""
assert thickness is not None, "shell2d(): must give thickness"
assert children is not None, "shell2d(): must give children"
if isinstance(thickness, (int, float)):
th = [float(thickness), 0.0] if thickness < 0 else [0.0, float(thickness)]
else:
tl = [float(v) for v in thickness]
th = [tl[1], tl[0]] if tl[0] > tl[1] else tl
orad = (
[float(outer_radius), float(outer_radius)]
if isinstance(outer_radius, (int, float))
else [float(v) for v in outer_radius]
)
irad = (
[float(inner_radius), float(inner_radius)]
if isinstance(inner_radius, (int, float))
else [float(v) for v in inner_radius]
)
base = Bosl2Shape2D(_as_native_2d(children))
outer_shape = round2d(
outer_radius=orad[0],
inner_radius=orad[1],
children=base.offset(delta=th[1], fn=fn, fa=fa, fs=fs),
fn=fn,
fa=fa,
fs=fs,
)
inner_shape = round2d(
outer_radius=irad[1],
inner_radius=irad[0],
children=base.offset(delta=th[0], fn=fn, fa=fa, fs=fs),
fn=fn,
fa=fa,
fs=fs,
)
return outer_shape - inner_shape
# -- cross / plus shape --------------------------------------------------------
[docs]
def cross(
size: float | Sequence[float] = [10, 10],
arm_width: float | Sequence[float] | None = None,
center: bool | None = None,
anchor: Anchor | Sequence[float] = CENTER,
spin: float = 0,
) -> Bosl2Shape2D:
"""Return a 2-D cross (plus) shape: two perpendicular centred rectangles.
Args:
size: overall size, a scalar square or ``[width, length]`` (default ``[10, 10]``).
arm_width: width of each arm; a scalar or ``[horizontal, vertical]`` pair.
When *None* (default) the arms are one-third of the overall size.
center: centre alignment (default True).
anchor: anchor point (default CENTER).
spin: Z-axis rotation in degrees after anchor (default 0).
Returns:
A :class:`Bosl2Shape2D` wrapping the cross polygon.
Examples:
.. pythonscad-example::
from pybosl2 import shapes2d as s2
s2.cross(size=30).linear_extrude(height=5).show()
"""
sz = [float(size)] * 2 if isinstance(size, (int, float)) else [float(size[0]), float(size[1])]
if arm_width is None:
aw: list[float] = [sz[0] / 3, sz[1] / 3]
elif isinstance(arm_width, (int, float)):
aw = [float(arm_width), float(arm_width)]
else:
aw = [float(arm_width[0]), float(arm_width[1])]
hw_x, hw_y = sz[0] / 2, sz[1] / 2
htx, hty = aw[0] / 2, aw[1] / 2
use_anchor = anchor
if center is not None:
use_anchor = CENTER if center else Anchor.LEFT + Anchor.FRONT
ha_pts = [[-hw_x, -hty], [hw_x, -hty], [hw_x, hty], [-hw_x, hty]]
va_pts = [[-htx, -hw_y], [htx, -hw_y], [htx, hw_y], [-htx, hw_y]]
ha_shape = Bosl2Shape2D(_opolygon(ha_pts))
va_shape = Bosl2Shape2D(_opolygon(va_pts))
shape = ha_shape | va_shape
offset = _anchor_offset_box(sz, use_anchor)
return _finish(shape, offset, spin, size=sz, anchor=use_anchor)
# ---------------------------------------------------------------------------
# Section: Text
# ---------------------------------------------------------------------------
[docs]
def text(
text: str,
size: float = 10,
font: str = "Liberation Sans",
halign: str | None = None,
valign: str | None = None,
spacing: float = 1.0,
direction: str = "ltr",
language: str = "en",
script: str = "latin",
anchor: str = "baseline",
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""2-D text, built directly with the builtin text() (which already supports halign/valign).
Args:
text: text to create
size: font size (default 10)
font: font to use (default "Liberation Sans")
halign: horizontal alignment: "left", "center", "right" (default "center")
valign: vertical alignment: "top", "center", "baseline", "bottom" (default: `anchor`)
spacing: relative spacing multiplier between characters (default 1.0)
direction: text direction: "ltr", "rtl", "ttb", "btt" (default "ltr")
language: language the text is in (default "en")
script: script the text is in (default "latin")
anchor: vertical alignment fallback used when valign isn't given (default "baseline")
spin: Z-axis rotation in degrees (default 0)
fn: number of fragments for circle resolution.
fa: minimum fragment angle for circle resolution.
fs: minimum fragment size for circle resolution.
"""
h = halign if halign is not None else "center"
v = valign if valign is not None else anchor
shape = _otext(
text,
size=size,
font=font,
halign=h,
valign=v,
spacing=spacing,
direction=direction,
language=language,
script=script,
fn=fn,
fa=fa,
fs=fs,
)
return _finish(shape, [0.0, 0.0], spin)