# 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/parts/walls.py
# Pure-Python port of BOSL2's walls.scad: FDM-optimised wall shapes that use less material and
# print without support. :class:`SparseWall` is an X-braced open wall (and
# :class:`~SparseCuboid` its solid-box variant); :class:`~CorrugatedWall` a sinusoidal
# corrugated panel; :class:`~ThinningWall` / :class:`~ThinningTriangle` walls whose
# middle thins away while the edges stay thick; :class:`~NarrowingStrut` the home-plate strut
# those triangles are built from.
#
# The honeycomb hex_panel() is not ported.
#
# FileSummary: FDM-optimised walls: sparse, corrugated, thinning and struts.
# DocCategory: Parts library
# FileGroup: BOSL2
"""FDM-optimised walls: sparse, corrugated, thinning and struts."""
from __future__ import annotations
import math
from enum import StrEnum
from typing import Any
import numpy as np
from pybosl2._native import native
from pybosl2.shapes3d import Bosl2Solid, cuboid
from pybosl2.vnf import VNF
_opolygon = native("polygon")
__all__ = [
"CorrugatedWall",
"NarrowingStrut",
"SparseCuboid",
"SparseWall",
"SparseAxis",
"ThinningTriangle",
"ThinningWall",
]
class SparseAxis(StrEnum):
"""Axis for :class:`SparseCuboid` internal bracing."""
X = "X"
Y = "Y"
Z = "Z"
def _rect(x0: float, x1: float, y0: float, y1: float) -> Any:
"""Return a native 2D axis-aligned rectangle from two opposite corners."""
return _opolygon([[x0, y0], [x1, y0], [x1, y1], [x0, y1]])
def _circle_2tangents(r: float, p1: list[float], p2: list[float], p3: list[float]) -> list[float]:
"""Centre of the circle of radius *r* tangent to segments p2->p1 and p2->p3 (BOSL2.
circle_2tangents()[0]); the corner is at *p2*. Points are 3-vectors (the y component is 0 here).
"""
p1a, p2a, p3a = (np.asarray(p, dtype=float) for p in (p1, p2, p3))
v1 = (p1a - p2a) / np.linalg.norm(p1a - p2a)
v2 = (p3a - p2a) / np.linalg.norm(p3a - p2a)
bis = v1 + v2
bis = bis / np.linalg.norm(bis)
half = math.acos(float(np.clip(np.dot(v1, v2), -1.0, 1.0))) / 2
return (p2a + bis * (r / math.sin(half))).tolist() # type: ignore[no-any-return]
def _segs(r: float) -> int:
"""OpenSCAD segs(r) with the default $fa=12, $fs=2."""
return max(5, math.ceil(min(360 / 12, 2 * math.pi * r / 2)))
def _sparse_wall2d(h: float, length: float, maxang: float, strut: float, max_bridge: float) -> Any:
"""Return the 2D cross-braced pattern, in the (X=h, Y=length) plane (BOSL2 sparse_wall2d())."""
zoff = h / 2 - strut / 2
yoff = length / 2 - strut / 2
maxa = math.radians(maxang)
maxhyp = 1.5 * (max_bridge + strut) / 2 / math.sin(maxa)
maxz = 2 * maxhyp * math.cos(maxa)
zreps = math.ceil(2 * zoff / maxz)
zstep = 2 * zoff / zreps
hyp = zstep / 2 / math.cos(maxa)
maxy = min(2 * hyp * math.sin(maxa), max_bridge + strut)
yreps = math.ceil(2 * yoff / maxy)
ystep = 2 * yoff / yreps
angle = math.atan(ystep / zstep)
parts = [
_rect(-h / 2, -h / 2 + strut, -length / 2, length / 2),
_rect(h / 2 - strut, h / 2, -length / 2, length / 2),
_rect(-h / 2, h / 2, -length / 2, -length / 2 + strut),
_rect(-h / 2, h / 2, length / 2 - strut, length / 2),
]
wx = (h - strut) / zreps
wy = strut / math.cos(angle)
for iy in range(yreps):
vpos = (iy - (yreps - 1) / 2) * ystep
for jx in range(zreps):
upos = (jx - (zreps - 1) / 2) * zstep
for syx in (math.tan(-angle), math.tan(angle)):
corners = [
(-wx / 2, -wy / 2),
(wx / 2, -wy / 2),
(wx / 2, wy / 2),
(-wx / 2, wy / 2),
]
poly = [[upos + cx, vpos + cy + syx * cx] for cx, cy in corners]
parts.append(_opolygon(poly))
region = parts[0]
for p in parts[1:]:
region = region | p
return region
[docs]
class NarrowingStrut:
"""A strut like an extruded baseball home plate: a rectangle topped by a narrowing triangle (BOSL2.
narrowing_strut()).
The triangular top converges at *angle* so the strut can brace an overhang without needing
support. *w* is the width (thickness), *length* the length, *wall* the height of the rectangular
base. It sits on the ``z = 0`` plane with the apex pointing up.
Examples:
.. pythonscad-example::
from pybosl2.parts.walls import NarrowingStrut
NarrowingStrut(w=10, length=100, wall=5, angle=30).show()
"""
def __init__(self, w: float = 10, length: float = 100, wall: float = 5, angle: float = 30) -> None:
"""Create a narrowing strut.
Args:
w: Width (thickness) of the strut in mm.
length: Length of the strut in mm.
wall: Height of the rectangular base in mm.
angle: Narrowing angle in degrees.
Returns:
None.
"""
self._width = w
self._length = length
self._wall_val = wall
self._angle = angle
height = wall + w / 2 / math.tan(math.radians(angle))
profile = [[-w / 2, 0], [w / 2, 0], [w / 2, wall], [0, height], [-w / 2, wall]]
shape = _opolygon(profile).linear_extrude(height=length, center=True).rotate([90, 0, 0])
self._solid: Bosl2Solid = Bosl2Solid(shape, size=[w, length, height])
@property
def width(self) -> float:
"""Width (thickness) of the strut in mm."""
return self._width
@property
def length(self) -> float:
"""Length of the strut in mm."""
return self._length
@property
def wall(self) -> float:
"""Height of the rectangular base in mm."""
return self._wall_val
@property
def angle(self) -> float:
"""Narrowing angle in degrees."""
return self._angle
[docs]
def shape(self) -> Bosl2Solid:
"""Return the strut geometry."""
return self._solid
[docs]
def show(self) -> None:
"""Display the strut in the viewer."""
self._solid.show()
[docs]
class SparseWall:
"""An open, X-cross-braced rectangular wall that saves material.
and prints support-free (BOSL2 sparse_wall()).
A solid border of width *strut* frames a lattice of diagonal braces, each kept under *maxang*
from vertical (so it needs no support) and spaced so no bridge exceeds *max_bridge*. The wall
is *thick* in X, *length* long in Y and *height* tall in Z.
Examples:
.. pythonscad-example::
from pybosl2.parts.walls import SparseWall
SparseWall(height=50, length=100, thick=4).show()
"""
def __init__(
self,
height: float = 50,
length: float = 100,
thick: float = 4,
maxang: float = 30,
strut: float = 5,
max_bridge: float = 20,
) -> None:
"""Create a sparse X-braced wall.
Args:
height: Wall height in mm (Z axis).
length: Wall length in mm (Y axis).
thick: Wall thickness in mm (X axis).
maxang: Maximum angle from vertical for the diagonal braces.
strut: Width of the solid border in mm.
max_bridge: Maximum unsupported bridge length in mm.
Returns:
None.
"""
self._height = height
self._length = length
self._thick = thick
region = _sparse_wall2d(height, length, maxang, strut, max_bridge)
shape = region.linear_extrude(height=thick, center=True).rotate([0, 90, 0])
self._solid: Bosl2Solid = Bosl2Solid(shape, size=[thick, length, height])
@property
def height(self) -> float:
"""Wall height in mm (Z axis)."""
return self._height
@property
def length(self) -> float:
"""Wall length in mm (Y axis)."""
return self._length
@property
def thick(self) -> float:
"""Wall thickness in mm (X axis)."""
return self._thick
[docs]
def shape(self) -> Bosl2Solid:
"""Return the wall geometry."""
return self._solid
[docs]
def show(self) -> None:
"""Display the wall in the viewer."""
self._solid.show()
[docs]
class SparseCuboid:
"""A solid cuboid whose interior is X-cross-braced along *dir* ("X", "Y" or "Z") (BOSL2 sparse_cuboid()).
A drop-in for :func:`~pybosl2.shapes3d.cuboid` when the part would benefit from the sparse
lattice; *dir* is the axis the diagonal braces (and the through-gaps) run along.
Examples:
.. pythonscad-example::
from pybosl2.parts.walls import SparseCuboid, SparseAxis
SparseCuboid(size=[50, 40, 10], dir=SparseAxis.Y, strut=3).show()
"""
def __init__(
self,
size: float | list[float],
dir: SparseAxis = SparseAxis.Y, # noqa: A002
strut: float = 5,
maxang: float = 30,
max_bridge: float = 20,
) -> None:
"""Create a sparse-braced cuboid.
Args:
size: Outer dimensions, either a single float for a cube or ``[X, Y, Z]``.
dir: Axis along which the diagonal braces run.
strut: Width of the solid border in mm.
maxang: Maximum angle from vertical for the diagonal braces.
max_bridge: Maximum unsupported bridge length in mm.
Returns:
None.
"""
self._size = list(size) if isinstance(size, (list, tuple)) else [size, size, size]
sx, sy, sz = (float(v) for v in self._size)
if dir == SparseAxis.X:
braced = SparseWall(sz, sy, sx, maxang, strut, max_bridge).shape()
elif dir == SparseAxis.Y:
braced = SparseWall(sz, sx, sy, maxang, strut, max_bridge).shape().rotate([0, 0, 90])
elif dir == SparseAxis.Z:
braced = SparseWall(sx, sy, sz, maxang, strut, max_bridge).shape().rotate([0, 90, 0])
else:
raise ValueError("sparse_cuboid(): dir must be a SparseAxis value.")
self._solid: Bosl2Solid = Bosl2Solid((braced & cuboid([sx, sy, sz])).shape, size=[sx, sy, sz])
@property
def size(self) -> list[float]:
"""Outer dimensions ``[X, Y, Z]`` in mm."""
return self._size
[docs]
def shape(self) -> Bosl2Solid:
"""Return the cuboid geometry."""
return self._solid
[docs]
def show(self) -> None:
"""Display the cuboid in the viewer."""
self._solid.show()
[docs]
class CorrugatedWall:
"""A corrugated wall: a solid border framing a sinusoidal sheet.
of thickness *wall* (BOSL2 corrugated_wall()).
The corrugation waves back and forth across the *thick* thickness as it runs along the length,
which stiffens a thin wall. *strut* is the width of the solid top/bottom/end border.
Examples:
.. pythonscad-example::
from pybosl2.parts.walls import CorrugatedWall
CorrugatedWall(height=50, length=100, thick=5).show()
"""
def __init__(
self,
height: float = 50,
length: float = 100,
thick: float = 5,
strut: float = 5,
wall: float = 2,
) -> None:
"""Create a corrugated wall.
Args:
height: Wall height in mm (Z axis).
length: Wall length in mm (Y axis).
thick: Overall wall thickness in mm (X axis).
strut: Width of the solid top/bottom/end border in mm.
wall: Thickness of the corrugated sheet in mm.
Returns:
None.
"""
self._height = height
self._length = length
self._thick = thick
amplitude = (thick - wall) / 2
period = min(15, thick * 2)
steps = ((_segs(thick / 2) + 3) // 4) * 4
step = period / steps
il = length - 2 * strut + 2 * step
ys = [-il / 2 + i * step for i in range(int(il / step) + 1)]
pts = [[amplitude * math.sin(math.radians(y / period * 360)) - wall / 2, y] for y in ys]
pts += [[amplitude * math.sin(math.radians(y / period * 360)) + wall / 2, y] for y in reversed(ys)]
sheet = _opolygon(pts).linear_extrude(height=height - 2 * strut + 0.1, center=True)
frame = cuboid([thick, length, height]) - cuboid([thick + 0.5, length - 2 * strut, height - 2 * strut])
self._solid: Bosl2Solid = Bosl2Solid((Bosl2Solid(sheet) | frame).shape, size=[thick, length, height])
@property
def height(self) -> float:
"""Wall height in mm (Z axis)."""
return self._height
@property
def length(self) -> float:
"""Wall length in mm (Y axis)."""
return self._length
@property
def thick(self) -> float:
"""Wall thickness in mm (X axis)."""
return self._thick
[docs]
def shape(self) -> Bosl2Solid:
"""Return the wall geometry."""
return self._solid
[docs]
def show(self) -> None:
"""Display the wall in the viewer."""
self._solid.show()
[docs]
class ThinningWall:
"""A rectangular wall that thins to *wall* in the middle while.
the edges stay *thick* (BOSL2 thinning_wall()).
Angled shoulders (kept under *angle*) join the thick border to the thin centre so nothing
overhangs. *length* may be a single length or ``(bottom, top)`` for a trapezoidal wall. The diagonal
``braces`` option of the original is not ported.
Examples:
.. pythonscad-example::
from pybosl2.parts.walls import ThinningWall
ThinningWall(height=50, length=80, thick=4).show()
"""
def __init__(
self,
height: float = 50,
length: float = 100,
thick: float = 5,
angle: float = 30,
strut: float | None = None,
wall: float | None = None,
) -> None:
"""Create a wall that thins in the middle while edges stay thick.
Args:
height: Wall height in mm (Z axis).
length: Wall length in mm, or ``(bottom, top)`` for a trapezoid.
thick: Edge thickness in mm (X axis).
angle: Maximum overhang angle in degrees.
strut: Width of the thick border; auto-calculated if None.
wall: Centre wall thickness; auto-calculated if None.
Returns:
None.
"""
self._height = height
self._length = length
self._thick = thick
l1 = length[0] if isinstance(length, (list, tuple)) else length
l2 = length[1] if isinstance(length, (list, tuple)) else length
strut_val = strut if strut is not None else min(height, l1, l2, thick) / 2
wall_val = wall if wall is not None else thick / 2
bevel_h = strut_val + (thick - wall_val) / 2 / math.tan(math.radians(angle))
cp1 = _circle_2tangents(strut_val, [0, 0, height / 2], [l2 / 2, 0, height / 2], [l1 / 2, 0, -height / 2])
cp2 = _circle_2tangents(
bevel_h,
[0, 0, height / 2],
[l2 / 2, 0, height / 2],
[l1 / 2, 0, -height / 2],
)
cp3 = _circle_2tangents(
bevel_h,
[0, 0, -height / 2],
[l1 / 2, 0, -height / 2],
[l2 / 2, 0, height / 2],
)
cp4 = _circle_2tangents(
strut_val,
[0, 0, -height / 2],
[l1 / 2, 0, -height / 2],
[l2 / 2, 0, height / 2],
)
z1, z2, z3 = height / 2, cp1[2], cp2[2]
x1, x2, x3, x4, x5, x6 = l2 / 2, cp1[0], cp2[0], l1 / 2, cp4[0], cp3[0]
y1, y2 = thick / 2, wall_val / 2
pts = [
[-x4, -y1, -z1],
[x4, -y1, -z1],
[x1, -y1, z1],
[-x1, -y1, z1],
[-x5, -y1, -z2],
[x5, -y1, -z2],
[x2, -y1, z2],
[-x2, -y1, z2],
[-x6, -y2, -z3],
[x6, -y2, -z3],
[x3, -y2, z3],
[-x3, -y2, z3],
[-x4, y1, -z1],
[x4, y1, -z1],
[x1, y1, z1],
[-x1, y1, z1],
[-x5, y1, -z2],
[x5, y1, -z2],
[x2, y1, z2],
[-x2, y1, z2],
[-x6, y2, -z3],
[x6, y2, -z3],
[x3, y2, z3],
[-x3, y2, z3],
]
faces = [
[4, 5, 1],
[5, 6, 2],
[6, 7, 3],
[7, 4, 0],
[4, 1, 0],
[5, 2, 1],
[6, 3, 2],
[7, 0, 3],
[8, 9, 5],
[9, 10, 6],
[10, 11, 7],
[11, 8, 4],
[8, 5, 4],
[9, 6, 5],
[10, 7, 6],
[11, 4, 7],
[11, 10, 9],
[20, 21, 22],
[11, 9, 8],
[20, 22, 23],
[16, 17, 21],
[17, 18, 22],
[18, 19, 23],
[19, 16, 20],
[16, 21, 20],
[17, 22, 21],
[18, 23, 22],
[19, 20, 23],
[12, 13, 17],
[13, 14, 18],
[14, 15, 19],
[15, 12, 16],
[12, 17, 16],
[13, 18, 17],
[14, 19, 18],
[15, 16, 19],
[0, 1, 13],
[1, 2, 14],
[2, 3, 15],
[3, 0, 12],
[0, 13, 12],
[1, 14, 13],
[2, 15, 14],
[3, 12, 15],
]
pts = [[-y, x, z] for x, y, z in pts]
shape = VNF(pts, faces).polyhedron()
self._solid: Bosl2Solid = Bosl2Solid(shape, size=[thick, l1, height])
@property
def height(self) -> float:
"""Wall height in mm (Z axis)."""
return self._height
@property
def length(self) -> float:
"""Wall length in mm."""
return self._length
@property
def thick(self) -> float:
"""Wall thickness in mm (X axis)."""
return self._thick
[docs]
def shape(self) -> Bosl2Solid:
"""Return the wall geometry."""
return self._solid
[docs]
def show(self) -> None:
"""Display the wall in the viewer."""
self._solid.show()
[docs]
class ThinningTriangle:
"""A right-triangular wall with thick edges thinning to *wall* in the middle (BOSL2 thinning_triangle()).
The hypotenuse rises from the front-bottom to the back-top. *diagonly* keeps only the
hypotenuse edge thick; *center* centres the shape (otherwise it rests on ``z = 0`` at the
front). Built from :class:`NarrowingStrut` braces.
Examples:
.. pythonscad-example::
from pybosl2.parts.walls import ThinningTriangle
ThinningTriangle(height=50, length=80, thick=4, center=True).show()
"""
def __init__(
self,
height: float = 50,
length: float = 100,
thick: float = 5,
angle: float = 30,
strut: float = 5,
wall: float = 3,
diagonly: bool = False,
center: bool | None = None,
) -> None:
"""Create a right-triangular wall with thick edges thinning in the middle.
Args:
height: Wall height in mm (Z axis).
length: Wall length in mm (Y axis).
thick: Edge thickness in mm (X axis).
angle: Maximum overhang angle in degrees for the struts.
strut: Width of the thick border in mm.
wall: Centre wall thickness in mm.
diagonly: If True, keep only the hypotenuse edge thick.
center: If True, centre the shape; if False, rest on z=0 at the front.
Returns:
None.
"""
self._height = height
self._length = length
self._thick = thick
dang = math.degrees(math.atan(height / length))
dlen = height / math.sin(math.radians(dang))
parts = []
if not diagonly:
ns1 = NarrowingStrut(w=thick, length=length, wall=strut, angle=angle).shape()
parts.append(ns1.down(height / 2))
ns2 = NarrowingStrut(w=thick, length=height - 0.1, wall=strut, angle=angle).shape()
parts.append(ns2.rotate([-90, 0, 0]).forward(length / 2))
hyp = (
NarrowingStrut(w=thick, length=dlen * 1.2, wall=strut, angle=angle)
.shape()
.rotate([0, 180, 0])
.rotate([-dang, 0, 0])
)
parts.append(cuboid([thick, length, height]) & hyp)
parts.append(cuboid([wall, length - 0.1, height - 0.1]))
body = parts[0]
for p in parts[1:]:
body = body | p
cutter = cuboid([thick + 0.1, length * 2, height]).up(height / 2).rotate([-dang, 0, 0])
body = body - cutter
if center is False:
body = body.up(height / 2).back(length / 2)
self._solid: Bosl2Solid = Bosl2Solid(body.shape, size=[thick, length, height])
@property
def height(self) -> float:
"""Wall height in mm (Z axis)."""
return self._height
@property
def length(self) -> float:
"""Wall length in mm (Y axis)."""
return self._length
@property
def thick(self) -> float:
"""Wall thickness in mm (X axis)."""
return self._thick
[docs]
def shape(self) -> Bosl2Solid:
"""Return the triangle geometry."""
return self._solid
[docs]
def show(self) -> None:
"""Display the triangle in the viewer."""
self._solid.show()