Source code for pybosl2.shapes2d.square

# 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/square.py
# FileSummary: Rectangles, squares, polygons, ngons and trapezoid shapes.
# DocCategory: Foundational
# FileGroup: BOSL2

"""Rectangles, squares, polygons, ngons and trapezoid shapes."""

from __future__ import annotations

import math
from collections.abc import Sequence
from typing import TYPE_CHECKING, Union

import numpy as np

# Import base class and helper functions from shapes2d.base
from pybosl2._helpers import (
    anchor_offset_box as _anchor_offset_box,
)
from pybosl2._helpers import (
    anchor_offset_hull as _anchor_offset_hull,
)
from pybosl2._helpers import (
    arc_points as _arc_points,
)
from pybosl2._helpers import (
    circle_pts as _circle_pts,
)
from pybosl2._helpers import (
    frag_count as _frag_count,
)
from pybosl2._helpers import (
    pick_radius as _pick_radius,
)
from pybosl2._helpers import (
    polar_to_xy as _polar_to_xy,
)
from pybosl2._helpers import (
    rect_path as _rect_path,
)
from pybosl2._helpers import (
    rotate2d as _rotate2d,
)
from pybosl2._native import native
from pybosl2.constants import CENTER

from .base import (
    Bosl2Shape2D,
    _adjacent_angle_to_hypotenuse,
    _adjacent_angle_to_opposite,
    _finish,
    _opposite_angle_to_adjacent,
    _v_theta,
)

if TYPE_CHECKING:
    from openscad import PyOpenSCAD

    from pybosl2._edges_lang import Anchor
    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")


# ---------------------------------------------------------------------------
# Section: 2D Primitives
# ---------------------------------------------------------------------------


[docs] def square( size: float | Sequence[float] = 1, center: bool | None = None, rounding: float | Sequence[float] = 0, chamfer: float | Sequence[float] = 0, anchor: Anchor | Sequence[float] = CENTER, spin: float | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Shape2D: """Return a rectangle, built via polygon() with BOSL2-style anchor/spin support. Args: size: size of the square; a scalar uses the same size for X and Y center: if given, overrides anchor (True -> CENTER, False -> FRONT+LEFT) rounding: corner rounding radius, or per-corner list [X+Y+,X-Y+,X-Y-,X+Y-] (default 0) chamfer: corner chamfer size, or per-corner list [X+Y+,X-Y+,X-Y-,X+Y-] (default 0) anchor: anchor point (default CENTER) spin: Z-axis rotation in degrees after anchor (default 0) fn: arc smoothness overrides for rounded corners fa: arc smoothness overrides for rounded corners fs: arc smoothness overrides for rounded corners Examples: .. pythonscad-example:: from pybosl2 import shapes2d as s2 s2.square(20).linear_extrude(height=5).show() """ assert not (rounding and chamfer), "Cannot set both rounding and chamfer at the same time." sz = [float(size), float(size)] if isinstance(size, (int, float)) else [float(v) for v in size] use_anchor = anchor if center is not None: use_anchor = CENTER if center else [-1, -1, 0] if rounding != 0 or chamfer != 0: path = _rect_path(sz, rounding=rounding, chamfer=chamfer, fn=fn, fa=fa, fs=fs) shape = _opolygon(path) offset = _anchor_offset_hull(path, use_anchor) return _finish(shape, offset, spin or 0, size=sz, anchor=use_anchor) shape = _osquare(sz, center=True) offset = _anchor_offset_box(sz, use_anchor) return _finish(shape, offset, spin or 0, size=sz, anchor=use_anchor)
[docs] def rect( size: float | Sequence[float] = 1, rounding: float | Sequence[float] = 0, atype: str = "box", chamfer: float | Sequence[float] = 0, anchor: Anchor | Sequence[float] = CENTER, spin: float = 0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Shape2D: """Return a rectangle with optional rounded or chamfered corners. Note: negative rounding/chamfer (BOSL2's "external roundover spikes") is not supported here. Args: size: size of the rectangle; a scalar uses the same size for X and Y rounding: corner rounding radius, or per-corner list [X+Y+,X-Y+,X-Y-,X+Y-] (default 0) atype: anchor type, "box" (bounding box) or "perim" (rounded/chamfered perimeter) (default "box") chamfer: corner chamfer size, or per-corner list [X+Y+,X-Y+,X-Y-,X+Y-] (default 0) anchor: anchor point (default CENTER) spin: Z-axis rotation in degrees after anchor (default 0) fn: arc smoothness overrides for rounded corners fa: arc smoothness overrides for rounded corners fs: arc smoothness overrides for rounded corners """ rl = [float(rounding)] * 4 if isinstance(rounding, (int, float)) else [float(v) for v in rounding] cl = [float(chamfer)] * 4 if isinstance(chamfer, (int, float)) else [float(v) for v in chamfer] msg = "Cannot set both rounding and chamfer on the same corner." assert not any(a and b for a, b in zip(rl, cl, strict=False)), msg sz = [float(size), float(size)] if isinstance(size, (int, float)) else list(size) path = _rect_path(sz, rounding=rounding, chamfer=chamfer, fn=fn, fa=fa, fs=fs) shape = _opolygon(path) complex_shape = (rounding != 0 if isinstance(rounding, (int, float)) else any(rounding)) or ( chamfer != 0 if isinstance(chamfer, (int, float)) else any(chamfer) ) if complex_shape and atype == "perim": offset = _anchor_offset_hull(path, anchor) return _finish(shape, offset, spin, size=sz, anchor=anchor) offset = _anchor_offset_box(sz, anchor) return _finish(shape, offset, spin, size=sz, anchor=anchor)
[docs] def rect_path( size: float | Sequence[float] = 1, rounding: float | Sequence[float] = 0, chamfer: float | Sequence[float] = 0, anchor: Anchor | Sequence[float] = CENTER, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> list[list[float]]: """Return the *points* of a (optionally rounded/chamfered) rectangle -- BOSL2's ``rect()`` in its. function form, as opposed to :func:`rect` which returns 2-D geometry (a :class:`Bosl2Shape2D`). Use this when the rectangle is an input to further path math (e.g. a profile fed to :func:`base_bgtk.PolygonPrism`), not something to draw. Usage:: rect_path([20, 4], rounding=[-3, -3, 0, 0], anchor=TOP + LEFT) Args: size: [x, y] size (or a single number for a square) rounding: corner radius; a single value or per-corner list. Negative = concave. chamfer: corner chamfer; a single value or per-corner list anchor: BOSL2 anchor the path is translated onto (default CENTER) fn: number of fragments for circle resolution. fa: minimum fragment angle for circle resolution. fs: minimum fragment size for circle resolution. Note: For small radii this can emit one more point per corner than the real BOSL2 does (BOSL2 rounds the corner-arc segment count, this rounds up); the arc geometry is identical, only the sampling differs. """ sz = [float(size), float(size)] if isinstance(size, (int, float)) else [float(v) for v in size] path = _rect_path(sz, rounding=rounding, chamfer=chamfer, fn=fn, fa=fa, fs=fs) offset = _anchor_offset_box(sz, anchor) return [[float(p[0]) + offset[0], float(p[1]) + offset[1]] for p in path]
[docs] def polygon( path: Path2D, anchor: Anchor | Sequence[float] = CENTER, spin: float = 0, ) -> Bosl2Shape2D: """Return a polygon, built with the builtin polygon(), with anchor/spin support. Args: path: polygon path anchor: anchor point (default CENTER) spin: Z-axis rotation in degrees after anchor (default 0) """ return _finish(_opolygon(path), anchor, spin)
# --------------------------------------------------------------------------- # Section: Polygons # --------------------------------------------------------------------------- def _regular_ngon_path( sides: int, radius: float, rounding: float = 0, chamfer: float = 0, realign: bool = False, align_tip: Sequence[float] | None = None, align_side: Sequence[float] | None = None, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> list[list[float]]: if not rounding and not chamfer: path = _circle_pts(radius, sides) else: assert not (rounding and chamfer), "Cannot set both rounding and chamfer at the same time on an n-gon." assert rounding >= 0, f"rounding must be >= 0 for regular n-gons, got {rounding}" assert chamfer >= 0, f"chamfer must be >= 0 for regular n-gons, got {chamfer}" half_angle = math.radians((180 - 360.0 / sides) / 2) inset: float = chamfer / math.sin(half_angle) if chamfer else rounding / math.sin(half_angle) assert inset < radius, ( f"{'chamfer' if chamfer else 'rounding'} value {chamfer or rounding} is too large " f"for a {sides}-gon of radius {radius}" ) steps = max(1, int(_frag_count(radius, fn, fa, fs) // sides)) path2: list[list[float]] = [] for i in range(sides): a = 360 - i * 360.0 / sides p = _polar_to_xy(radius - inset, a) if chamfer: half_angle = math.radians(180.0 / sides) chamf_len = chamfer / math.sin(half_angle) * math.cos(half_angle) c1 = [ p[0] + chamf_len * math.cos(math.radians(a - 90)), p[1] + chamf_len * math.sin(math.radians(a - 90)), ] c2 = [ p[0] + chamf_len * math.cos(math.radians(a + 90)), p[1] + chamf_len * math.sin(math.radians(a + 90)), ] path2.append(c1) path2.append(c2) else: path2.extend(_arc_points(steps, rounding, a + 180.0 / sides, -360.0 / sides, p)) maxx_idx = max(range(len(path2)), key=lambda k: path2[k][0]) path = path2[maxx_idx:] + path2[:maxx_idx] extra_rot = 0.0 if align_tip is not None: extra_rot += math.degrees(math.atan2(align_tip[1], align_tip[0])) elif align_side is not None: extra_rot += math.degrees(math.atan2(align_side[1], align_side[0])) + 180.0 / sides if realign: extra_rot -= 180.0 / sides if extra_rot: path = [_rotate2d(p, extra_rot) for p in path] return path
[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, inner_radius: float | None = None, inner_diameter: float | None = None, side: float | None = None, rounding: float = 0, chamfer: float = 0, realign: bool = False, align_tip: Sequence[float] | None = None, align_side: Sequence[float] | None = None, anchor: Anchor | Sequence[float] = CENTER, spin: float = 0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Shape2D: """Return a regular N-gon (equilateral, equiangular polygon), built directly with polygon(). Note: BOSL2's outer-radius parameter is named `or`, which collides with the Python keyword `or`; it is exposed here as `outer_radius` instead. Args: sides: number of sides (default 6) radius: outside radius, at the points outer_radius: outside radius, at the points (BOSL2 ``or``) diameter: outside diameter, at the points outer_diameter: outside diameter, at the points inner_radius: inside radius, at the center of the sides inner_diameter: inside diameter, at the center of the sides side: length of each side rounding: rounding radius for the tips of the polygon (default 0) chamfer: chamfer size for the tips of the polygon (default 0) realign: put the midpoint of the last edge (instead of vertex 0) on the X+ axis (default False) align_tip: rotate so the first vertex points in this 2-D direction (applied before spin) align_side: rotate so the normal of side 0 points in this 2-D direction (applied before spin) anchor: anchor point (default CENTER) spin: Z-axis rotation in degrees after anchor (default 0) fn: arc smoothness overrides for rounded tips fa: arc smoothness overrides for rounded tips fs: arc smoothness overrides for rounded tips Examples: .. pythonscad-example:: from pybosl2 import shapes2d as s2 s2.regular_ngon(sides=6, radius=15).linear_extrude(height=5).show() """ assert not (rounding and chamfer), "Cannot set both rounding and chamfer at the same time." assert sides >= 3 sc = 1 / math.cos(math.radians(180.0 / sides)) ir_s = inner_radius * sc if inner_radius is not None else None id_s = inner_diameter * sc if inner_diameter is not None else None side_s = side / 2 / math.sin(math.radians(180.0 / sides)) if side is not None else None dflt_val: float = side_s if side_s is not None else 0.0 rad = _pick_radius( radius1=ir_s, diameter1=id_s, radius2=outer_radius, diameter2=outer_diameter, radius=radius, diameter=diameter, dflt=dflt_val, ) if rad is None: raise ValueError( "regular_ngon(): need to specify one of radius, diameter, outer_radius, outer_diameter, inner_radius, inner_diameter, side." # noqa: E501 ) path = _regular_ngon_path( sides, rad, rounding=rounding, chamfer=chamfer, realign=realign, align_tip=align_tip, align_side=align_side, fn=fn, fa=fa, fs=fs, ) shape = _opolygon(path) offset = _anchor_offset_hull(path, anchor) return _finish(shape, offset, spin)
[docs] def pentagon( radius: float | None = None, diameter: float | None = None, outer_radius: float | None = None, outer_diameter: float | None = None, inner_radius: float | None = None, inner_diameter: float | None = None, side: float | None = None, rounding: float = 0, chamfer: float = 0, realign: bool = False, align_tip: Sequence[float] | None = None, align_side: Sequence[float] | None = None, anchor: Anchor | Sequence[float] = CENTER, spin: float = 0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Shape2D: """Return a regular pentagon. See regular_ngon() for argument details.""" return regular_ngon( sides=5, radius=radius, diameter=diameter, outer_radius=outer_radius, outer_diameter=outer_diameter, inner_radius=inner_radius, inner_diameter=inner_diameter, side=side, rounding=rounding, chamfer=chamfer, realign=realign, align_tip=align_tip, align_side=align_side, anchor=anchor, spin=spin, fn=fn, fa=fa, fs=fs, )
[docs] def hexagon( radius: float | None = None, diameter: float | None = None, outer_radius: float | None = None, outer_diameter: float | None = None, inner_radius: float | None = None, inner_diameter: float | None = None, side: float | None = None, rounding: float = 0, chamfer: float = 0, realign: bool = False, align_tip: Sequence[float] | None = None, align_side: Sequence[float] | None = None, anchor: Anchor | Sequence[float] = CENTER, spin: float = 0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Shape2D: """Return a regular hexagon. See regular_ngon() for argument details.""" return regular_ngon( sides=6, radius=radius, diameter=diameter, outer_radius=outer_radius, outer_diameter=outer_diameter, inner_radius=inner_radius, inner_diameter=inner_diameter, side=side, rounding=rounding, chamfer=chamfer, realign=realign, align_tip=align_tip, align_side=align_side, anchor=anchor, spin=spin, fn=fn, fa=fa, fs=fs, )
[docs] def octagon( radius: float | None = None, diameter: float | None = None, outer_radius: float | None = None, outer_diameter: float | None = None, inner_radius: float | None = None, inner_diameter: float | None = None, side: float | None = None, rounding: float = 0, chamfer: float = 0, realign: bool = False, align_tip: Sequence[float] | None = None, align_side: Sequence[float] | None = None, anchor: Anchor | Sequence[float] = CENTER, spin: float = 0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Shape2D: """Return a regular octagon. See regular_ngon() for argument details.""" return regular_ngon( sides=8, radius=radius, diameter=diameter, outer_radius=outer_radius, outer_diameter=outer_diameter, inner_radius=inner_radius, inner_diameter=inner_diameter, side=side, rounding=rounding, chamfer=chamfer, realign=realign, align_tip=align_tip, align_side=align_side, anchor=anchor, spin=spin, fn=fn, fa=fa, fs=fs, )
[docs] def right_triangle( size: Sequence[float] = [1, 1], center: bool | None = None, rounding: float = 0, chamfer: float = 0, anchor: Anchor | Sequence[float] | None = None, spin: float = 0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Shape2D: """Return a right triangle, built directly with polygon(). Args: size: [width, length] of the right triangle center: True forces anchor=CENTER, False forces anchor=[-1,-1] (default: use anchor=) rounding: corner rounding radius (default 0) chamfer: corner chamfer size (default 0) anchor: anchor point (default: [-1,-1], the right-angle corner) spin: Z-axis rotation in degrees after anchor (default 0) fn: arc smoothness overrides for rounded corners fa: arc smoothness overrides for rounded corners fs: arc smoothness overrides for rounded corners Examples: .. pythonscad-example:: from pybosl2 import shapes2d as s2 s2.right_triangle(size=[30, 20]).linear_extrude(height=5).show() """ assert not (rounding and chamfer), "Cannot set both rounding and chamfer at the same time." assert rounding >= 0, f"rounding must be >= 0 for right_triangle, got {rounding}" assert chamfer >= 0, f"chamfer must be >= 0 for right_triangle, got {chamfer}" sz: Sequence[float] = [float(size), float(size)] if isinstance(size, (int, float)) else size if anchor is not None: use_anchor = anchor elif center: use_anchor = CENTER else: use_anchor = [-1, -1, 0] shape = _opolygon([[sz[0] / 2, -sz[1] / 2], [-sz[0] / 2, -sz[1] / 2], [-sz[0] / 2, sz[1] / 2]]) bshape = Bosl2Shape2D(shape) if chamfer: bshape = bshape.offset(delta=chamfer, chamfer=True).offset(delta=-chamfer) if rounding: bshape = bshape.offset(radius=rounding, fn=fn, fa=fa, fs=fs) offset = _anchor_offset_box(sz, use_anchor) return _finish(bshape.__scad__(), offset, spin, size=sz, anchor=use_anchor)
def _trapezoid_path( height: float, width1: float, width2: float, shift: float, chamfer: float | Sequence[float], rounding: float | Sequence[float], flip: bool, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> list[list[float]]: chamfs: list[float] = list(chamfer) if isinstance(chamfer, (list, tuple)) else [chamfer] * 4 # type: ignore[list-item] rounds: list[float] = list(rounding) if isinstance(rounding, (list, tuple)) else [rounding] * 4 # type: ignore[list-item] srads: list[float] = [rounds[i] if rounds[i] else chamfs[i] for i in range(4)] rads = [abs(s) for s in srads] base = [ [width2 / 2 + shift, height / 2], [-width2 / 2 + shift, height / 2], [-width1 / 2, -height / 2], [width1 / 2, -height / 2], ] angle1 = _v_theta([base[0][0] - base[3][0], base[0][1] - base[3][1]]) - 90 angle2 = _v_theta([base[1][0] - base[2][0], base[1][1] - base[2][1]]) - 90 angles = [angle1, angle2, angle2, angle1] qdirs = [[1, 1], [-1, 1], [-1, -1], [1, -1]] angle_pairs = [ { "pos": (angles[0], 90), "flip": (angles[0], -90), "neg": (180 + angles[0], 90), }, { "pos": (90, 180 + angles[1]), "flip": (270, 180 + angles[1]), "neg": (90, angles[1]), }, { "pos": (180 + angles[2], 270), "flip": (180 + angles[2], 90), "neg": (angles[2], -90), }, { "pos": (-90, angles[3]), "flip": (90, angles[3]), "neg": (270, 180 + angles[3]), }, ] cpath = [] for i in range(4): if rads[i] == 0: cpath.append(base[i]) continue hyp = _adjacent_angle_to_hypotenuse(rads[i], angles[i]) xoff = _adjacent_angle_to_opposite(rads[i], angles[i]) sign_a = -1 if (srads[i] < 0 and flip) else 1 a = [xoff * qdirs[i][1] * sign_a, -rads[i] * qdirs[i][1] * sign_a] sign_b = 1 if (srads[i] < 0 and not flip) else -1 b = [a[0] + hyp * qdirs[i][0] * sign_b, a[1]] center = [base[i][0] + b[0], base[i][1] + b[1]] if srads[i] > 0: a0, a1 = angle_pairs[i]["pos"] # type: ignore[index] elif flip: a0, a1 = angle_pairs[i]["flip"] # type: ignore[index] else: a0, a1 = angle_pairs[i]["neg"] # type: ignore[index] point_count = max(3, math.ceil(_frag_count(rads[i], fn, fa, fs) * abs(a1 - a0) / 360)) if rounds[i] else 2 cpath.extend(_arc_points(point_count, rads[i], a0, a1 - a0, center)) return list(reversed(cpath))
[docs] def trapezoid( height: float | None = None, width1: float | None = None, width2: float | None = None, angle: float | None = None, shift: float = 0, chamfer: float | Sequence[float] = 0, rounding: float | Sequence[float] = 0, flip: bool = False, anchor: Anchor | Sequence[float] = CENTER, spin: float = 0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Shape2D: """Return a trapezoid with parallel front and back sides, built directly with polygon(). Args: height: Y-axis height of the trapezoid width1: X-axis width of the front end width2: X-axis width of the back end angle: if given in place of height/width1/width2, the missing value is derived from this angle shift: X-axis shift of the back of the trapezoid (default 0) rounding: corner rounding radius, or per-corner list [X+Y+,X-Y+,X-Y-,X+Y-] (default 0) chamfer: corner chamfer length, or per-corner list [X+Y+,X-Y+,X-Y-,X+Y-] (default 0) flip: point negative roundings/chamfers forward/back instead of left/right (default False) anchor: anchor point (default CENTER) spin: Z-axis rotation in degrees after anchor (default 0) fn: arc smoothness overrides for rounded corners fa: arc smoothness overrides for rounded corners fs: arc smoothness overrides for rounded corners Examples: .. pythonscad-example:: from pybosl2 import shapes2d as s2 s2.trapezoid(height=20, width1=30, width2=15).linear_extrude(height=5).show() """ defined = sum(x is not None for x in (height, width1, width2, angle)) assert defined == 3, "Must give exactly 3 of the arguments height, width1, width2, and angle." if height is None: assert width1 is not None assert width2 is not None assert angle is not None height = _opposite_angle_to_adjacent(abs(width2 - width1) / 2, abs(angle)) if width1 is None: assert width2 is not None assert angle is not None width1 = width2 + 2 * (_adjacent_angle_to_opposite(height, angle) + shift) if width2 is None: assert width1 is not None assert angle is not None width2 = width1 - 2 * (_adjacent_angle_to_opposite(height, angle) + shift) assert width1 >= 0, "Degenerate trapezoid geometry." assert width2 >= 0, "Degenerate trapezoid geometry." assert height > 0, "Degenerate trapezoid geometry." assert width1 + width2 > 0, "Degenerate trapezoid geometry." path = _trapezoid_path(height, width1, width2, shift, chamfer, rounding, flip, fn, fa, fs) shape = _opolygon(path) offset = _anchor_offset_hull(path, anchor) return _finish(shape, offset, spin)