# 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/flat.py
# FileSummary: Statically typed 2D shape constructors and backend-neutral flat shape facade.
# DocCategory: Foundational
# FileGroup: BOSL2
"""Statically typed 2D shape constructors and backend-neutral flat shape facade."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Protocol, Self, cast, runtime_checkable
from pybosl2._backend import Shape, current_backend
from pybosl2._edges_lang import resolve_anchor
from pybosl2.constants import CENTER
from pybosl2.defaults import resolve_res as _resolve_res
from pybosl2.exceptions import UnsupportedByBackendError
from pybosl2.groups import EdgeTreatment, Placement, resolve_edge_treatment, resolve_placement_2d
if TYPE_CHECKING:
from collections.abc import Sequence
from pybosl2._backend import Solid
from pybosl2._edges_lang import Anchor
from pybosl2.bounds import Bounds2D
from pybosl2.path2d import Path2D
__all__ = [
"Flat",
"circle",
"ellipse",
"regular_ngon",
"star",
"trapezoid",
"polygon",
"rect",
"square",
"text",
]
[docs]
@runtime_checkable
class Flat(Shape, Protocol):
"""A 2-D shape: :class:`~pybosl2._backend.Shape` plus what only two dimensions can do.
Everything shared with solids — the ``backend`` tag, the boolean operators, the transforms,
``bounds()`` and ``show()`` — is declared once on ``Shape`` (SPEC C-15, C-18). What is left
here is the way up into three dimensions (SPEC C-17).
"""
[docs]
def bounds(self) -> Bounds2D:
"""Return the 2-D axis-aligned bounding box (SPEC S-2b)."""
...
[docs]
def rotate(self, a: float | Sequence[float]) -> Flat:
"""Rotate this shape *a* degrees about Z.
Args:
a: The rotation angle in degrees.
"""
...
[docs]
def linear_extrude(self, height: float, **kwargs: Any) -> Solid:
"""Extrude this 2-D shape into a 3-D solid.
Args:
height: Height of the extrusion.
**kwargs: Options the active backend's extruder declares.
"""
...
# The rest of the way up into three dimensions (SPEC C-17), and the operations only an outline
# has. Declared because the objects have them and C-20 says the contract is the whole object;
# `Any` where the two backends spell an option differently (PLAN T-6c).
[docs]
def rotate_extrude(self, *args: Any, **kwargs: Any) -> Solid:
"""Revolve this 2-D shape about the Z axis into a solid."""
...
[docs]
def offset(self, *args: Any, **kwargs: Any) -> Self:
"""Return this outline grown or shrunk by a distance."""
...
[docs]
def hull(self, *others: Any, **kwargs: Any) -> Self:
"""Return the convex hull of this shape and any others."""
...
[docs]
def fill(self) -> Self:
"""Return this outline with its interior holes closed.
Declared because both 2-D implementations have it (SPEC C-20). Note that `fill` is listed
in ``CSG_ONLY_FEATURES`` while the SDF implementation meshes the field, crosses to CSG and
comes back — a refusal that never fires (SPEC §12.2, PLAN B-P4).
"""
...
# In-plane transforms. Both dimensions can honour a flip and a Z-rotation, so C-22 would put
# these on `Shape` -- they stay here only until the 3-D side grows the same spellings, which is
# the C-21 synonym work (SPEC §12.2).
[docs]
def xflip(self, *args: Any, **kwargs: Any) -> Self:
"""Mirror this shape across the YZ plane."""
...
[docs]
def yflip(self, *args: Any, **kwargs: Any) -> Self:
"""Mirror this shape across the XZ plane."""
...
[docs]
def circle(
radius: float | None = None,
diameter: float | None = None,
*,
points: "Path2D | None" = None,
corner: Sequence[Sequence[float]] | None = None,
anchor: Anchor | Sequence[float] = CENTER,
spin: float = 0,
placement: Placement | None = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
res: int | None = None,
) -> Flat:
"""Return a circle on the active backend.
Creates a 2D circle with the specified parameters.
Args:
radius: Radius of the circle.
diameter: Diameter of the circle.
points: Three 2-D points the circle should pass through.
corner: Three 2-D points defining a path the circle should be tangent to.
anchor: Anchor point.
spin: Z-axis rotation in degrees after anchor.
placement: Anchor and spin as one reusable value (SPEC G-1). A placement that also sets
orient raises here: the plane has no third axis to turn a face towards (SPEC E-5).
fn: Arc smoothness overrides (CSG backend only). Omitted, the ambient ``use_defaults(fn=...)`` value applies;
``fn=0`` opts back out to fa/fs.
fa: Arc smoothness overrides (CSG backend only). Omitted, the ambient ``use_defaults(fa=...)`` value applies.
fs: Arc smoothness overrides (CSG backend only). Omitted, the ambient ``use_defaults(fs=...)`` value applies.
res: SDF backend's resolution (SDF backend only). Omitted, the ambient ``use_defaults(res=...)`` value
applies.
Returns:
A 2-D flat shape representing a circle.
Examples:
.. pythonscad-example::
from pybosl2.flat import circle
circle(radius=15).linear_extrude(height=5).show()
"""
anchor, spin = resolve_placement_2d(placement, anchor, spin, "circle")
if current_backend() == "sdf":
from pybosl2.sdf.shapes2d import circle2d
return cast("Flat", circle2d(radius=radius, diameter=diameter, res=_resolve_res(res) or 10))
from pybosl2.shapes2d.circle import circle as csg_circle
return cast(
"Flat",
csg_circle(
radius=radius,
diameter=diameter,
points=points,
corner=corner,
anchor=anchor,
spin=spin,
fn=fn,
fa=fa,
fs=fs,
),
)
[docs]
def square(
size: float | Sequence[float] = 1,
*,
center: bool | None = None,
rounding: float | Sequence[float] = 0,
chamfer: float | Sequence[float] = 0,
treatment: EdgeTreatment | None = None,
anchor: Anchor | Sequence[float] = CENTER,
spin: float | None = None,
placement: Placement | None = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
res: int | None = None,
) -> Flat:
"""Return a square on the active backend.
Creates a 2D square with the specified size and corner treatments.
Args:
size: Size of the square (scalar or 2-element sequence).
center: Whether to center the shape (CSG only).
rounding: Corner rounding radius.
chamfer: Corner chamfer distance.
treatment: A rounding or a chamfer as one value (SPEC G-1). An edge is rounded or chamfered,
never both, so this makes the pair unrepresentable rather than checked; giving it beside
rounding= or chamfer= raises (SPEC G-3).
anchor: Anchor point.
spin: Z-axis rotation in degrees after anchor.
placement: Anchor and spin as one reusable value (SPEC G-1). A placement that also sets
orient raises here: the plane has no third axis to turn a face towards (SPEC E-5).
fn: Arc smoothness overrides (CSG backend only). Omitted, the ambient ``use_defaults(fn=...)`` value applies;
``fn=0`` opts back out to fa/fs.
fa: Arc smoothness overrides (CSG backend only). Omitted, the ambient ``use_defaults(fa=...)`` value applies.
fs: Arc smoothness overrides (CSG backend only). Omitted, the ambient ``use_defaults(fs=...)`` value applies.
res: SDF backend's resolution (SDF backend only). Omitted, the ambient ``use_defaults(res=...)`` value
applies.
Returns:
A 2-D flat shape representing a square.
Examples:
.. pythonscad-example::
from pybosl2.flat import square
square(size=20, rounding=2).linear_extrude(height=5).show()
"""
rounding, chamfer = resolve_edge_treatment(treatment, rounding, chamfer, "square")
anchor, spin = resolve_placement_2d(placement, anchor, spin, "square")
if current_backend() == "sdf":
from pybosl2.sdf.shapes2d import square2d
try:
resolved_anchor = resolve_anchor(cast("Any", anchor)).vector_2d.tolist()
except Exception:
resolved_anchor = list(anchor)
return cast("Flat", square2d(size=size, anchor=resolved_anchor, res=_resolve_res(res) or 10))
from pybosl2.shapes2d.square import square as csg_square
return cast(
"Flat",
csg_square(
size=size,
center=center,
rounding=rounding,
chamfer=chamfer,
anchor=anchor,
spin=spin,
fn=fn,
fa=fa,
fs=fs,
),
)
[docs]
def rect(
size: float | Sequence[float] = 1,
*,
rounding: float | Sequence[float] = 0,
chamfer: float | Sequence[float] = 0,
treatment: EdgeTreatment | None = None,
anchor: Anchor | Sequence[float] = CENTER,
spin: float = 0,
placement: Placement | None = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
res: int | None = None,
) -> Flat:
"""Return a rectangle on the active backend.
Creates a 2D rectangle with the specified dimensions and corner treatments.
Args:
size: Size of the rectangle (scalar or 2-element sequence).
rounding: Corner rounding radius.
chamfer: Corner chamfer distance.
treatment: A rounding or a chamfer as one value (SPEC G-1). An edge is rounded or chamfered,
never both, so this makes the pair unrepresentable rather than checked; giving it beside
rounding= or chamfer= raises (SPEC G-3).
anchor: Anchor point.
spin: Z-axis rotation in degrees after anchor.
placement: Anchor and spin as one reusable value (SPEC G-1). A placement that also sets
orient raises here: the plane has no third axis to turn a face towards (SPEC E-5).
fn: Arc smoothness overrides (CSG backend only). Omitted, the ambient ``use_defaults(fn=...)`` value applies;
``fn=0`` opts back out to fa/fs.
fa: Arc smoothness overrides (CSG backend only). Omitted, the ambient ``use_defaults(fa=...)`` value applies.
fs: Arc smoothness overrides (CSG backend only). Omitted, the ambient ``use_defaults(fs=...)`` value applies.
res: SDF backend's resolution (SDF backend only). Omitted, the ambient ``use_defaults(res=...)`` value
applies.
Returns:
A 2-D flat shape representing a rectangle.
Examples:
.. pythonscad-example::
from pybosl2.flat import rect
rect(size=[30, 20], rounding=3).linear_extrude(height=5).show()
"""
rounding, chamfer = resolve_edge_treatment(treatment, rounding, chamfer, "rect")
anchor, spin = resolve_placement_2d(placement, anchor, spin, "rect")
if current_backend() == "sdf":
from pybosl2.sdf.shapes2d import rect2d
try:
resolved_anchor = resolve_anchor(cast("Any", anchor)).vector_2d.tolist()
except Exception:
resolved_anchor = list(anchor)
return cast(
"Flat",
rect2d(
size=size,
rounding=rounding,
chamfer=chamfer,
anchor=resolved_anchor,
res=_resolve_res(res) or 10,
),
)
from pybosl2.shapes2d.square import rect as csg_rect
return cast(
"Flat",
csg_rect(
size=size,
rounding=rounding,
chamfer=chamfer,
anchor=anchor,
spin=spin,
fn=fn,
fa=fa,
fs=fs,
),
)
[docs]
def polygon(
points: "Path2D",
*,
anchor: Anchor | Sequence[float] = CENTER,
spin: float = 0,
placement: Placement | None = None,
res: int | None = None,
) -> Flat:
"""Return a polygon on the active backend.
Creates a 2D polygon from list of points.
Args:
points: The outline, as a :class:`~pybosl2.path2d.Path2D` (SPEC C-7a).
anchor: Anchor point.
spin: Z-axis rotation in degrees after anchor.
placement: Anchor and spin as one reusable value (SPEC G-1). A placement that also sets
orient raises here: the plane has no third axis to turn a face towards (SPEC E-5).
res: SDF backend's resolution (SDF backend only). Omitted, the ambient ``use_defaults(res=...)`` value
applies.
Returns:
A 2-D flat shape representing a polygon.
Examples:
.. pythonscad-example::
from pybosl2 import Path2D
from pybosl2.flat import polygon
polygon(points=Path2D([[0, 0], [10, 0], [5, 10]])).linear_extrude(height=5).show()
"""
anchor, spin = resolve_placement_2d(placement, anchor, spin, "polygon")
from pybosl2.path2d import Path2D
from pybosl2.paths import require_path
points = cast("Path2D", require_path(points, "points", "polygon", Path2D))
if current_backend() == "sdf":
from pybosl2.sdf.shapes2d import polygon2d
return cast("Flat", polygon2d(paths=points, res=_resolve_res(res) or 10))
from pybosl2.shapes2d.square import polygon as csg_polygon
return cast(
"Flat",
csg_polygon(
path=points,
anchor=anchor,
spin=spin,
),
)
[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: Anchor | Sequence[float] | None = None,
spin: float = 0,
placement: Placement | None = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
) -> Flat:
"""Return a text shape on the active backend.
Creates a 2D shape representing the given text.
Args:
text: The string content.
size: Text height.
font: Font family name.
halign: Horizontal alignment.
valign: Vertical alignment: "top", "center", "baseline" or "bottom" (default "baseline").
spacing: Character spacing factor.
direction: Text direction.
language: Language code.
script: Script code.
anchor: Where the finished text's 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 after anchor.
placement: Anchor and spin as one reusable value (SPEC G-1). A placement that also sets
orient raises here: the plane has no third axis to turn a face towards (SPEC E-5).
fn: Arc smoothness overrides (CSG backend only). Omitted, the ambient ``use_defaults(fn=...)`` value applies;
``fn=0`` opts back out to fa/fs.
fa: Arc smoothness overrides (CSG backend only). Omitted, the ambient ``use_defaults(fa=...)`` value applies.
fs: Arc smoothness overrides (CSG backend only). Omitted, the ambient ``use_defaults(fs=...)`` value applies.
Returns:
A 2-D flat shape representing text.
Examples:
.. pythonscad-example::
from pybosl2.flat import text
text(text="BOSL2", size=10).linear_extrude(height=3).show()
"""
anchor, spin = resolve_placement_2d(placement, anchor, spin, "text")
if current_backend() == "sdf":
raise UnsupportedByBackendError(
"text",
"sdf",
hint="the sdf backend has no 2-D text support; build on the default (csg) backend.",
)
from pybosl2.shapes2d.ops import text as csg_text
return cast(
"Flat",
csg_text(
text=text,
size=size,
font=font,
halign=halign,
valign=valign,
spacing=spacing,
direction=direction,
language=language,
script=script,
anchor=anchor,
spin=spin,
fn=fn,
fa=fa,
fs=fs,
),
)
[docs]
def ellipse(
radius: float | Sequence[float] | None = None,
diameter: float | Sequence[float] | None = None,
*,
realign: bool = False,
anchor: Anchor | Sequence[float] = CENTER,
spin: float = 0,
placement: Placement | None = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
res: int | None = None,
) -> Flat:
"""Return an ellipse on the active backend.
Args:
radius: Radius, or a per-axis pair.
diameter: Diameter, or a per-axis pair.
realign: Rotate by half a segment so a flat faces +X (CSG backend only).
anchor: Anchor point.
spin: Z-axis rotation in degrees after anchor (CSG backend only).
placement: Anchor and spin as one reusable value (SPEC G-1). A placement that also sets
orient raises here: the plane has no third axis to turn a face towards (SPEC E-5).
fn: Arc smoothness override (CSG backend only). Omitted, the ambient ``use_defaults(fn=...)`` value applies;
``fn=0`` opts back out to fa/fs.
fa: Arc smoothness override (CSG backend only). Omitted, the ambient ``use_defaults(fa=...)`` value applies.
fs: Arc smoothness override (CSG backend only). Omitted, the ambient ``use_defaults(fs=...)`` value applies.
res: Sampling resolution (SDF backend only). Omitted, the ambient ``use_defaults(res=...)`` value applies.
Returns:
A 2-D shape on whichever backend is active.
Examples:
.. pythonscad-example::
from pybosl2.flat import ellipse
ellipse(radius=[20, 10]).linear_extrude(height=4).show()
"""
anchor, spin = resolve_placement_2d(placement, anchor, spin, "ellipse")
if current_backend() == "sdf":
from pybosl2.sdf.shapes2d import ellipse2d
return cast("Flat", ellipse2d(radius=radius, diameter=diameter, res=_resolve_res(res) or 10))
from pybosl2.shapes2d import ellipse as csg_ellipse
return cast(
"Flat",
csg_ellipse(radius=radius, diameter=diameter, realign=realign, anchor=anchor, spin=spin, fn=fn, fa=fa, fs=fs),
)
[docs]
def star(
tips: int = 5,
radius: float | None = None,
inner_radius: float | None = None,
*,
diameter: float | None = None,
inner_diameter: float | None = None,
step: int | None = None,
realign: bool = False,
anchor: Anchor | Sequence[float] = CENTER,
spin: float = 0,
placement: Placement | None = None,
res: int | None = None,
) -> Flat:
"""Return a star on the active backend.
Args:
tips: Number of points.
radius: Outer radius (to the tips).
inner_radius: Inner radius (to the valleys).
diameter: Outer diameter, instead of *radius*.
inner_diameter: Inner diameter, instead of *inner_radius*.
step: Skip-count star form, instead of an inner radius (CSG backend only).
realign: Rotate by half a point (CSG backend only).
anchor: Anchor point.
spin: Z-axis rotation in degrees after anchor (CSG backend only).
placement: Anchor and spin as one reusable value (SPEC G-1). A placement that also sets
orient raises here: the plane has no third axis to turn a face towards (SPEC E-5).
res: Sampling resolution (SDF backend only). Omitted, the ambient ``use_defaults(res=...)`` value applies.
Returns:
A 2-D shape on whichever backend is active.
Examples:
.. pythonscad-example::
from pybosl2.flat import star
star(tips=6, radius=20, inner_radius=9).linear_extrude(height=4).show()
"""
anchor, spin = resolve_placement_2d(placement, anchor, spin, "star")
if current_backend() == "sdf":
from pybosl2.sdf.shapes2d import star2d
return cast(
"Flat",
star2d(
num_sides=tips,
radius=radius,
inner_radius=inner_radius,
diameter=diameter,
inner_diameter=inner_diameter,
res=_resolve_res(res) or 10,
),
)
from pybosl2.shapes2d import star as csg_star
return cast(
"Flat",
csg_star(
tips=tips,
radius=radius,
inner_radius=inner_radius,
diameter=diameter,
inner_diameter=inner_diameter,
step=step,
realign=realign,
anchor=anchor,
spin=spin,
),
)
[docs]
def regular_ngon(
sides: int = 6,
radius: float | None = None,
*,
diameter: float | None = None,
outer_radius: float | None = None,
outer_diameter: float | None = None,
rounding: float = 0,
realign: bool = False,
anchor: Anchor | Sequence[float] = CENTER,
spin: float = 0,
placement: Placement | None = None,
fn: int | None = None,
fa: float | None = None,
fs: float | None = None,
res: int | None = None,
) -> Flat:
"""Return a regular polygon on the active backend.
Args:
sides: Number of sides.
radius: Radius to a vertex.
diameter: Diameter to a vertex, instead of *radius*.
outer_radius: Radius of the circumscribed circle.
outer_diameter: Diameter of the circumscribed circle.
rounding: Corner rounding radius (CSG backend only).
realign: Rotate by half a side (CSG backend only).
anchor: Anchor point.
spin: Z-axis rotation in degrees after anchor (CSG backend only).
placement: Anchor and spin as one reusable value (SPEC G-1). A placement that also sets
orient raises here: the plane has no third axis to turn a face towards (SPEC E-5).
fn: Fragment count 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.
res: Sampling resolution (SDF backend only). Omitted, the ambient ``use_defaults(res=...)`` value applies.
Returns:
A 2-D shape on whichever backend is active.
Raises:
UnsupportedByBackendError: If *rounding* is asked for on the SDF backend, which has no
rounded-corner ngon.
Examples:
.. pythonscad-example::
from pybosl2.flat import regular_ngon
regular_ngon(sides=7, radius=15).linear_extrude(height=4).show()
"""
anchor, spin = resolve_placement_2d(placement, anchor, spin, "regular_ngon")
if current_backend() == "sdf":
if rounding:
raise UnsupportedByBackendError(
"regular_ngon(rounding=)",
"sdf",
hint="the sdf ngon has no corner rounding; build it on the csg backend, or round "
"the field with .round() afterwards.",
)
from pybosl2.sdf.shapes2d import regular_ngon2d
return cast(
"Flat",
regular_ngon2d(
num_sides=sides,
radius=radius,
diameter=diameter,
outer_radius=outer_radius,
outer_diameter=outer_diameter,
res=_resolve_res(res) or 10,
),
)
from pybosl2.shapes2d import regular_ngon as csg_ngon
return cast(
"Flat",
csg_ngon(
sides=sides,
radius=radius,
diameter=diameter,
outer_radius=outer_radius,
outer_diameter=outer_diameter,
rounding=rounding,
realign=realign,
anchor=anchor,
spin=spin,
fn=fn,
fa=fa,
fs=fs,
),
)
[docs]
def trapezoid(
height: float | None = None,
width1: float | None = None,
width2: float | None = None,
*,
angle: float | None = None,
shift: float = 0,
anchor: Anchor | Sequence[float] = CENTER,
spin: float = 0,
placement: Placement | None = None,
res: int | None = None,
) -> Flat:
"""Return a trapezoid on the active backend.
Give exactly three of *height*, *width1*, *width2* and *angle*.
Args:
height: Height of the trapezoid.
width1: Width of the bottom edge.
width2: Width of the top edge.
angle: Base angle in degrees.
shift: Shift of the top edge along X.
anchor: Anchor point.
spin: Z-axis rotation in degrees after anchor (CSG backend only).
placement: Anchor and spin as one reusable value (SPEC G-1). A placement that also sets
orient raises here: the plane has no third axis to turn a face towards (SPEC E-5).
res: Sampling resolution (SDF backend only). Omitted, the ambient ``use_defaults(res=...)`` value applies.
Returns:
A 2-D shape on whichever backend is active.
Raises:
ValueError: If other than three of height/width1/width2/angle are given.
Examples:
.. pythonscad-example::
from pybosl2.flat import trapezoid
trapezoid(height=10, width1=20, width2=12).linear_extrude(height=4).show()
"""
anchor, spin = resolve_placement_2d(placement, anchor, spin, "trapezoid")
if current_backend() == "sdf":
from pybosl2.sdf.shapes2d import trapezoid2d
return cast(
"Flat",
trapezoid2d(
height=height, width1=width1, width2=width2, angle=angle, shift=shift, res=_resolve_res(res) or 10
),
)
from pybosl2.shapes2d import trapezoid as csg_trapezoid
return cast(
"Flat",
csg_trapezoid(height=height, width1=width1, width2=width2, angle=angle, shift=shift, anchor=anchor, spin=spin),
)