# 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 typing import TYPE_CHECKING
from pybosl2._backend import backend_only
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 pybosl2.exceptions import Bosl2ValueError
from .base import Bosl2Shape2D, Shape2DLike, _finish
if TYPE_CHECKING:
from collections.abc import Sequence
from openscad import PyOpenSCAD
# Defined once in base.py: five identical copies is the same duplication C-21 is about, and
# only base.py imported the names its own copy referenced.
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 osimport as _oosimport
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")
_oosimport = native("osimport")
_opolygon = native("polygon")
_osquare = native("square")
_otext = native("text")
[docs]
@backend_only("csg")
def osimport(
file: str,
convexity: int | None = None,
layer: str | None = None,
dpi: float | None = None,
center: bool | None = None,
id: str | None = None, # noqa: A002
) -> Bosl2Shape2D:
"""Import a 2-D drawing (SVG or DXF) as a :class:`Bosl2Shape2D` (OpenSCAD ``import()``).
The wrapped counterpart of the bare native ``osimport()``, so an imported outline joins the
fluent API instead of being a raw handle a caller has to keep unwrapping -- it can be
offset, filled, hulled, coloured and extruded like any other 2-D shape.
Relative paths resolve against the PROCESS working directory, not the calling module, so
pass an absolute path if the asset lives beside your source.
Use :func:`pybosl2.shapes3d.osimport` for 3-D meshes (STL/OFF/3MF).
Args:
file: Path to the drawing to import.
convexity: Convexity hint for preview rendering.
layer: For DXF, the layer to import.
dpi: For SVG, dots per inch used to convert lengths.
center: Center the imported drawing on the origin.
id: For SVG, the id of the single element to import.
Returns:
A :class:`Bosl2Shape2D` wrapping the imported outline.
Examples:
Import a 2-D drawing, resize it and extrude::
from pybosl2 import shapes2d as s2
s2.osimport("drawing.svg").resize([40, 40, 0]).linear_extrude(height=2).show()
"""
kwargs: dict[str, object] = {}
for value, name in ((convexity, "convexity"), (layer, "layer"), (dpi, "dpi"), (center, "center"), (id, "id")):
if value is not None:
kwargs[name] = value
return Bosl2Shape2D(_oosimport(file, **kwargs))
[docs]
@backend_only("csg")
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]
@backend_only("csg")
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
if not items:
raise Bosl2ValueError("hull(): needs at least one shape to hull.")
return Bosl2Shape2D(_ohull(*[_as_native_2d(c) for c in items]))
# ---------------------------------------------------------------------------
# Section: Rounding 2D shapes
# ---------------------------------------------------------------------------
[docs]
@backend_only("csg")
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. Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back
out to fa/fs.
fa: arc smoothness overrides. Omitted, the ambient ``use_defaults(fa=...)`` value applies.
fs: arc smoothness overrides. Omitted, the ambient ``use_defaults(fs=...)`` value applies.
"""
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)
if children is None:
raise Bosl2ValueError("round2d(): needs the shape(s) to round -- pass 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]
@backend_only("csg")
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. Omitted, the ambient ``use_defaults(fn=...)`` value applies; ``fn=0`` opts back
out to fa/fs.
fa: arc smoothness overrides. Omitted, the ambient ``use_defaults(fa=...)`` value applies.
fs: arc smoothness overrides. Omitted, the ambient ``use_defaults(fs=...)`` value applies.
"""
if thickness is None:
raise Bosl2ValueError("shell2d(): needs a wall thickness -- pass thickness=.")
if children is None:
raise Bosl2ValueError("shell2d(): needs the shape(s) to shell -- pass 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]
@backend_only("csg")
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]
@backend_only("csg", neutral="pybosl2.flat.text")
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: "Anchor | Sequence[float] | None" = None,
spin: float = 0,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Bosl2Shape2D:
"""Create 2-D text (halign/valign supported natively).
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 "baseline")
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: where the finished text's bounding box lands, in the anchor language
(SPEC C-10). ``None`` leaves it where *halign*/*valign* put it, which is the
typographic placement and the usual answer for text
spin: Z-axis rotation in degrees (default 0)
fn: number of fragments for circle resolution. Omitted, the ambient ``use_defaults(fn=...)`` value applies;
``fn=0`` opts back out to fa/fs.
fa: minimum fragment angle for circle resolution. Omitted, the ambient ``use_defaults(fa=...)`` value applies.
fs: minimum fragment size for circle resolution. Omitted, the ambient ``use_defaults(fs=...)`` value applies.
"""
h = halign if halign is not None else "center"
v = valign if valign is not None else "baseline"
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,
)
placed = _finish(shape, [0.0, 0.0], spin)
# The typographic alignment has already positioned the text; an anchor, if given, then places
# its finished box the way it places every other 2-D shape (SPEC C-10, PLAN O-6b).
return placed.reanchor(anchor) if anchor is not None else placed