Source code for bosl2.partitions

# 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: bosl2/partitions.py
#    Pure-Python port of BOSL2's partitions.scad: cut an object with a plane (half_of and the six
#    axis half-cuts), and partition a large object into two interlocking pieces for printing
#    (partition_path / partition_mask / partition_cut_mask / the partition() split).
#
#    The cut operators live on :class:`~bosl2.shapes3d.Bosl2Solid` via the :class:`Partitionable`
#    mixin: a half-cut intersects the solid with a half-space mask, auto-sized from the object's
#    native bounding box (so the BOSL2 ``s=`` mask-size argument is optional here). partition()
#    returns the two interlocking pieces. The 2-D cut-path generators (:func:`partition_path` and
#    friends) return :class:`~bosl2.paths.Path` objects; the mask builders return Bosl2Solids.
#
#    Only matrix/path math and bosl2.transforms/constants are imported at load time; native
#    primitives, shapes2d.arc, and Bosl2Solid are imported lazily inside the functions that need
#    them, so shapes3d.py can pull in the mixin during its own import without a cycle.
#
# FileSummary: Planar half-cuts and interlocking partitions (jigsaw/dovetail/... joints).
# FileGroup: BOSL2

from __future__ import annotations

import math
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from bosl2.paths import Path
    from bosl2.shapes3d import Bosl2Solid

import numpy as np

from bosl2._helpers import is_num, zrot4
from bosl2.constants import BACK, DOWN, FRONT, LEFT, RIGHT, UP
from bosl2.geometry import pointlist_bounds
from bosl2.transforms import axis_angle_matrix, rot_about_axis, rot_from_to
from bosl2.vectors import unit

__all__ = [
    "partition_path",
    "partition_mask",
    "partition_cut_mask",
    "Partitionable",
]


# ---------------------------------------------------------------------------
# Section: 2-D path helpers
# ---------------------------------------------------------------------------


# (imported from bosl2._helpers as is_num)


def _yscale(s, path):
    return [[float(p[0]), float(p[1]) * s] for p in path]


def _scale2(sx, sy, path):
    return [[float(p[0]) * sx, float(p[1]) * sy] for p in path]


def _left(x, path):
    return [[float(p[0]) - x, float(p[1])] for p in path]


def _right(x, path):
    return [[float(p[0]) + x, float(p[1])] for p in path]


def _xflip(x, path):
    return [[2 * x - float(p[0]), float(p[1])] for p in path]


def _skew(axy_deg, path):
    t = math.tan(math.radians(axy_deg))
    return [[float(p[0]) + float(p[1]) * t, float(p[1])] for p in path]


def _lerp(a, b, u):
    return a + (b - a) * u


def _dedup(path):
    from bosl2.paths import Path

    return [list(p) for p in Path._deduplicate(path, closed=False)]


def _merge_collinear(path):
    # BOSL2's path_merge_collinear() drops exact-duplicate points before merging collinear runs;
    # the toolkit kernel does not, so dedup first (a bare duplicate otherwise collapses a corner).
    from bosl2.paths import Path

    return [list(p) for p in Path._path_merge_collinear(_dedup(path), closed=False)]


# ---------------------------------------------------------------------------
# Section: named cut sub-paths
# ---------------------------------------------------------------------------


def _partition_subpath(cptype, fn=None, fa=None, fs=None):
    """The simple named cut sub-paths used by the mask builders (BOSL2 _partition_subpath())."""
    from bosl2.shapes2d import arc

    if cptype == "flat":
        return [[0, 0], [1, 0]]
    if cptype == "sawtooth":
        return [[0, 0], [0.5, 1], [1, 0]]
    if cptype == "sinewave":
        return [[a / 360, math.sin(math.radians(a)) / 2] for a in range(0, 361, 5)]
    if cptype == "comb":
        dx = 0.5 * math.sin(math.radians(2))
        return [
            [0, 0],
            [dx, 0.5],
            [0.5 - dx, 0.5],
            [0.5 + dx, -0.5],
            [1 - dx, -0.5],
            [1, 0],
        ]
    if cptype == "finger":
        dx = 0.5 * math.sin(math.radians(20))
        return [
            [0, 0],
            [dx, 0.5],
            [0.5 - dx, 0.5],
            [0.5 + dx, -0.5],
            [1 - dx, -0.5],
            [1, 0],
        ]
    if cptype == "dovetail":
        return [[0, -0.5], [0.3, -0.5], [0.2, 0.5], [0.8, 0.5], [0.7, -0.5], [1, -0.5]]
    if cptype == "hammerhead":
        return [
            [0, -0.5],
            [0.35, -0.5],
            [0.35, 0],
            [0.15, 0],
            [0.15, 0.5],
            [0.85, 0.5],
            [0.85, 0],
            [0.65, 0],
            [0.65, -0.5],
            [1, -0.5],
        ]
    if cptype == "jigsaw":
        return (
            list(
                arc(
                    radius=5 / 16,
                    center=[0, -3 / 16],
                    start=270,
                    angle=125,
                    fn=fn,
                    fa=fa,
                    fs=fs,
                )
            )
            + list(
                arc(
                    radius=5 / 16,
                    center=[1 / 2, 3 / 16],
                    start=215,
                    angle=-250,
                    fn=fn,
                    fa=fa,
                    fs=fs,
                )
            )
            + list(
                arc(
                    radius=5 / 16,
                    center=[1, -3 / 16],
                    start=145,
                    angle=125,
                    fn=fn,
                    fa=fa,
                    fs=fs,
                )
            )
        )
    raise AssertionError(f"Unsupported cutpath type: {cptype!r}")


def _partition_cutpath(length, h, cutsize, cutpath, gap, cutpath_centered, fn=None, fa=None, fs=None):
    """One row of the named cut sub-path, repeated to span *length* (BOSL2 _partition_cutpath())."""
    cs = list(cutsize) if isinstance(cutsize, (list, tuple, np.ndarray)) else [cutsize * 2, cutsize]
    sub: list[list[float]] = (
        [list(p) for p in cutpath]
        if isinstance(cutpath, (list, tuple, np.ndarray))
        else _partition_subpath(cutpath, fn, fa, fs)
    )
    reps_raw = 1 + math.floor((length - cs[0]) / (cs[0] + gap))
    reps = reps_raw - 1 if (reps_raw % 2 == 0 and cutpath_centered) else reps_raw
    reps = max(1, reps)
    cplen = reps * cs[0] + max(0, reps - 1) * gap
    pts = [[-length / 2, sub[0][1] * cs[1]]]
    for i in range(reps):
        for pt in sub:
            pts.append([pt[0] * cs[0] + i * (cs[0] + gap) - cplen / 2, pt[1] * cs[1]])
    pts.append([cplen / 2, sub[-1][1] * cs[1]])
    return _dedup(pts)


# ---------------------------------------------------------------------------
# Section: partition_path segment engine
# ---------------------------------------------------------------------------


def _ptn_sect(
    cptype,
    length: float = 25,
    width: float = 25,
    invert=False,
    fn=None,
    fa=None,
    fs=None,
):
    """One section of a partition_path, with the full BOSL2 modifier grammar (BOSL2 _ptn_sect())."""
    from bosl2.shapes2d import _frag_count, arc

    if is_num(cptype):
        assert cptype > 0, "flat section length must be positive."
        return [[0, 0], [float(cptype), 0]]
    if invert:
        return _yscale(-1, _ptn_sect(cptype, length, width, fn=fn, fa=fa, fs=fs))

    if isinstance(cptype, str) and " " in cptype:
        pos = cptype.rfind(" ")
        opt = cptype[pos + 1 :]
        base = cptype[:pos]
        if opt == "yflip":
            return _yscale(-1, _ptn_sect(base, length, width, fn=fn, fa=fa, fs=fs))
        if opt == "xflip":
            sect = _ptn_sect(base, length, width, fn=fn, fa=fa, fs=fs)
            b = pointlist_bounds(sect)
            xpos = (b[1][0] + b[0][0]) / 2
            return _xflip(xpos, sect)[::-1]
        if opt in ("addflip", "wave"):
            sect1 = _ptn_sect(base, length, width, fn=fn, fa=fa, fs=fs)
            sect2 = _ptn_sect(base + " yflip xflip", length, width, fn=fn, fa=fa, fs=fs)
            b1, b2 = pointlist_bounds(sect1), pointlist_bounds(sect2)
            osect1 = _scale2(0.5, 0.5, _left(b1[0][0], sect1))
            osect2 = _right(osect1[-1][0], _scale2(0.5, 0.5, _left(b2[0][0], sect2)))
            return _merge_collinear(osect1 + osect2)
        if opt and opt[0].isdigit() and opt.endswith("x") and opt[:-1].isdigit():  # "3x": repeat
            reps = int(opt[:-1])
            assert reps > 0, "repetition count must be positive."
            sect = _ptn_sect(base, length, width, fn=fn, fa=fa, fs=fs)
            w = sect[-1][0]
            out = []
            for i in range(reps):
                out += _right(i * w, sect)
            return _merge_collinear(out)
        if opt and opt[0].isdigit() and "x" in opt:  # "30x20": resize
            parts = opt.split("x")
            assert len(parts) == 2, "size modifier must be LENGTHxWIDTH, e.g. '30x25'."
            return _ptn_sect(base, float(parts[0]), float(parts[1]), fn=fn, fa=fa, fs=fs)
        if opt.startswith("skew:"):
            angle = float(opt[5:])
            assert -45 <= angle <= 45, "skew angle must be between -45 and 45."
            return _skew(angle, _ptn_sect(base, length, width, fn=fn, fa=fa, fs=fs))
        if opt.startswith("pinch:"):
            val_str = opt[6:]
            is_deg = val_str.endswith("deg")
            is_pct = val_str.endswith("%")
            num_str = val_str[:-3] if is_deg else val_str[:-1] if is_pct else val_str
            val = float(num_str)
            raw = _ptn_sect(base, length, width, fn=fn, fa=fa, fs=fs)
            xs = [p[0] for p in raw]
            minx, maxx = min(xs), max(xs)
            w_half, midx = (maxx - minx) / 2, (minx + maxx) / 2
            maxy = max(abs(p[1]) for p in raw)
            dx = maxy * math.tan(math.radians(val)) / w_half if (is_deg and maxy and w_half) else 0
            pcnt = (1 - dx) * 100 if is_deg else val
            if maxy == 0:
                return raw
            return [[(p[0] - midx) * _lerp(1, pcnt / 100, abs(p[1]) / maxy) + midx, p[1]] for p in raw]
        if base == "flat" and opt and opt[0].isdigit() and "x" not in opt and ":" not in opt:
            return [[0, 0], [float(opt), 0]]
        raise AssertionError(f"Bad section option: {opt!r}")

    if cptype == "sinewave":
        return _ptn_sect("halfsine addflip", length, width, fn=fn, fa=fa, fs=fs)
    steps = _frag_count(length / 2, fn, fa, fs)
    if cptype == "flat":
        path: list[list[float]] = [[0, 0], [1, 0]]
    elif cptype == "sawtooth":
        path = [[0, 0], [0, 1], [1, 0]]
    elif cptype == "square":
        path = [[0, 0], [0, 1], [1, 1], [1, 0]]
    elif cptype == "triangle":
        path = [[0, 0], [0.5, 1], [1, 0]]
    elif cptype == "halfsine":
        path = [[a / 180, math.sin(math.radians(a))] for a in np.arange(0, 180.0001, 360 / steps)]
    elif cptype == "semicircle":
        path = _yscale(
            2,
            list(
                arc(
                    count=math.ceil(steps / 2),
                    radius=1 / 2,
                    center=[1 / 2, 0],
                    start=180,
                    angle=-180,
                )
            ),
        )
    elif cptype == "comb":
        dx = math.tan(math.radians(2)) * width / length
        assert dx <= 0.5, "width-to-length ratio too large for comb form."
        path = [[0, 0], [dx, 1], [1 - dx, 1], [1, 0]]
    elif cptype == "finger":
        dx = math.tan(math.radians(20)) * width / length
        assert dx <= 0.5, "width-to-length ratio too large for finger form."
        path = [[0, 0], [dx, 1], [1 - dx, 1], [1, 0]]
    elif cptype == "dovetail":
        dx = math.tan(math.radians(9)) * width / length / 2
        assert dx < 0.25, "width-to-length ratio too large for dovetail form."
        path = [
            [0, 0],
            [0.25 + dx, 0],
            [0.25 - dx, 1],
            [0.75 + dx, 1],
            [0.75 - dx, 0],
            [1, 0],
        ]
    elif cptype == "hammerhead":
        path = [
            [0, 0],
            [0.35, 0],
            [0.35, 0.5],
            [0.15, 0.5],
            [0.15, 1],
            [0.85, 1],
            [0.85, 0.5],
            [0.65, 0.5],
            [0.65, 0],
            [1, 0],
        ]
    elif cptype == "jigsaw":
        path = (
            list(
                arc(
                    count=math.ceil(steps / 4),
                    radius=5 / 16,
                    center=[0, 5 / 16],
                    start=270,
                    angle=125,
                    fn=fn,
                    fa=fa,
                    fs=fs,
                )
            )
            + list(
                arc(
                    count=math.ceil(steps / 2),
                    radius=5 / 16,
                    center=[1 / 2, 11 / 16],
                    start=215,
                    angle=-250,
                    fn=fn,
                    fa=fa,
                    fs=fs,
                )
            )
            + list(
                arc(
                    count=math.ceil(steps / 4),
                    radius=5 / 16,
                    center=[1, 5 / 16],
                    start=145,
                    angle=125,
                    fn=fn,
                    fa=fa,
                    fs=fs,
                )
            )
        )
    elif isinstance(cptype, (list, tuple, np.ndarray)):
        path = [list(p) for p in cptype]
    else:
        raise AssertionError(f"Unsupported partition section type: {cptype!r}")
    return _scale2(length, width, path)


[docs] def partition_path( pathdesc, repeat: int = 1, y=None, altpath=None, seglen: float = 25, segwidth: float = 25, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> "Path": """Build a 2-D interlocking cut path from a list of segment descriptors (BOSL2 partition_path()). Each item of *pathdesc* is a numeric length (a flat section), a 2-D path (used as-is), or a named section pattern -- ``"flat"``, ``"sawtooth"``, ``"square"``, ``"triangle"``, ``"halfsine"``, ``"semicircle"``, ``"sinewave"``, ``"comb"``, ``"finger"``, ``"dovetail"``, ``"hammerhead"``, ``"jigsaw"`` -- optionally suffixed with space-separated modifiers (``"3x"`` repeat, ``"30x20"`` resize, ``"xflip"``/``"yflip"``/``"addflip"``/``"wave"``, ``"skew:15"``, ``"pinch:33"`` / ``"pinch:20deg"``). Modifiers apply left to right. Args: pathdesc: list of segment descriptors repeat: repeat the whole *pathdesc* this many times (default 1) y: if given, close the path at this Y (for a polygon); its sign orients the result altpath: optional base path the pattern is redirected along seglen: default length for named sections (default 25) segwidth: default width for named sections (default 25) Returns: A :class:`~bosl2.paths.Path` (closed when *y* is given). Examples: A wall profile mixing jigsaw and hammerhead joints, stroked into a divider: .. pythonscad-example:: wall = partition_path([40, "jigsaw", 10, "jigsaw yflip", 40], fn=24) wall.stroke(width=3).linear_extrude(height=30).show() """ from bosl2.paths import Path paths = [] for _n in range(repeat): for pd in pathdesc: if isinstance(pd, (list, tuple, np.ndarray)): paths.append([[float(a), float(b)] for a, b in pd]) elif is_num(pd): paths.append(_ptn_sect(pd, fn=fn, fa=fa, fs=fs)) elif isinstance(pd, str): paths.append(_ptn_sect(pd, seglen, segwidth, fn=fn, fa=fa, fs=fs)) else: raise AssertionError(f"Path descriptor {pd!r} is invalid.") min_xs = [min(p[0] for p in path) for path in paths] max_xs = [max(p[0] for p in path) for path in paths] min_y = min(p[1] for path in paths for p in path) max_y = max(p[1] for path in paths for p in path) widths = [max_xs[i] - min_xs[i] for i in range(len(paths))] allpos = list(np.cumsum([0.0] + widths)) totlen = allpos[-1] fullpath = [] for i, path in enumerate(paths): fullpath += _left(totlen / 2 - allpos[i], path) cleanpath = _merge_collinear(_dedup(fullpath)) redirpath = cleanpath if altpath is None else _ptn_path_redirect(altpath, cleanpath) if y is None: return Path(redirpath, closed=False) assert y < min_y or y > max_y, "partition_path(): closing y would make the path self-crossing." closedpath = [[redirpath[-1][0], y], [redirpath[0][0], y]] + redirpath outpath = closedpath if y < 0 else closedpath[::-1] return Path(outpath, closed=True)
def _ptn_path_redirect(major_path, minor_path, center=True): """Re-lay *minor_path* (a partition pattern) along *major_path* (BOSL2 _ptn_path_redirect()).""" from bosl2.paths import Path major2 = _merge_collinear(major_path) minor2 = [list(p) for p in Path._resample_path(minor_path, spacing=1, closed=False)] major_len = Path._path_length(major2, closed=False) minor_len = abs(minor_path[-1][0] - minor_path[0][0]) extend_by = max(0, -(major_len - minor_len)) e1 = extend_by * (0.5 if center else 0) e2 = extend_by * (0.5 if center else 1) vec1 = unit(np.asarray(major2[0]) - np.asarray(major_path[1]), [-1.0, 0.0]) vec2 = unit(np.asarray(major2[-1]) - np.asarray(major_path[-2]), [1.0, 0.0]) major3 = ( [list(np.asarray(major2[0]) + vec1 * e1)] + [list(p) for p in major2[1:-1]] + [list(np.asarray(major2[-1]) + vec2 * e2)] ) major_len2 = Path._path_length(major3, closed=False) xoff = (major_len2 - minor_len) / 2 if center else 0 minor3 = _left(minor2[0][0] - xoff, minor2) out = [] for pt in minor3: pinfo = Path._path_cut_points(major3, max(0.0, pt[0]), closed=False, direction=True) base = np.asarray(pinfo[0]) tangent = unit(np.asarray(pinfo[3]), [0.0, 1.0]) out.append(list(base + tangent * pt[1])) return _merge_collinear(_dedup(out)) # --------------------------------------------------------------------------- # Section: mask geometry # --------------------------------------------------------------------------- def _partition_mask_shape( length, w, h, cutsize, cutpath, gap, cutpath_centered, inverse, slop, fn=None, fa=None, fs=None, ): """Native geometry for a partition mask (removes half, leaving an interlocking edge).""" from pythonscad import polygon as _polygon from pythonscad import square as _square cs = list(cutsize) if isinstance(cutsize, (list, tuple, np.ndarray)) else [cutsize * 2, cutsize] path = _partition_cutpath(length, h, cs, cutpath, gap, cutpath_centered, fn, fa, fs) ww = w * (-1 if inverse else 1) fullpath = list(path) + [[path[-1][0], ww], [path[0][0], ww]] poly = _polygon([[float(x), float(y)] for x, y in fullpath]) if slop: poly = poly.offset(delta=-slop) poly = poly & _square([length, w * 2], center=True) return poly.linear_extrude(height=h, center=True)
[docs] def partition_mask( length: float = 100, w=100, height: float = 100, cutsize=10, cutpath="jigsaw", gap: float = 0, cutpath_centered: bool = True, inverse: bool = False, slop: float = 0.0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """A mask to remove half of an object, leaving an interlocking edge (BOSL2 partition_mask()). Intersect it with (or subtract it from) a solid to keep the half within *w* of the cut plane. Pair a plain mask with an ``inverse=True`` one to split a part into two mating pieces. Args: length: length of the cut axis w: width of the kept part, back from the cut plane height: height of the part cutsize: cut-pattern width (scalar, or ``[length, width]``) cutpath: named cut pattern or an explicit 2-D path gap: empty gaps between pattern iterations cutpath_centered: keep the pattern centered (default True) inverse: build the mating (inverted) mask slop: shrink the mask by this much for a printer-fit clearance """ from bosl2.shapes3d import Bosl2Solid return Bosl2Solid( _partition_mask_shape( length, w, height, cutsize, cutpath, gap, cutpath_centered, inverse, slop, fn, fa, fs, ) )
[docs] def partition_cut_mask( length: float = 100, height: float = 100, cutsize=10, cutpath="jigsaw", gap: float = 0, cutpath_centered: bool = True, slop: float = 0.1, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Bosl2Solid: """A thin mask to cut an object into two mating pieces (BOSL2 partition_cut_mask()). Subtract it from a solid to split it along the cut path with a *slop*-wide kerf. """ from bosl2.drawing import stroke as _stroke from bosl2.shapes3d import Bosl2Solid cs = list(cutsize) if isinstance(cutsize, (list, tuple, np.ndarray)) else [cutsize * 2, cutsize] path = _partition_cutpath(length, height, cs, cutpath, gap, cutpath_centered, fn, fa, fs) ribbon = _stroke(path, width=max(0.1, slop * 2)) return Bosl2Solid(ribbon.linear_extrude(height=height, center=True))
# --------------------------------------------------------------------------- # Section: Partitionable mixin # --------------------------------------------------------------------------- def _as_vec3(v): a = np.asarray(v, dtype=float) if a.shape[0] == 2: a = np.array([a[0], a[1], 0.0]) return a
[docs] class Partitionable(ABC): """Mixin adding the partitions.scad planar cuts and the partition() split as methods. Inherited by :class:`~bosl2.shapes3d.Bosl2Solid`. A half-cut intersects the solid with a half-space mask whose size defaults to the object's own bounding box (so BOSL2's ``s=`` argument is optional). ``cut_path=`` follows a 2-D :func:`partition_path` to make an interlocking cut face instead of a flat plane. """ @abstractmethod def _wrap(self, new_shape): # pragma: no cover - provided by the host class (Bosl2Solid) """Re-wrap a native shape as the host solid type.""" raise NotImplementedError def _half_mask(self, v, cpv, s, cut_path, cut_angle, offset): from pythonscad import polygon as _polygon v3 = _as_vec3(v) vu = unit(v3) if cut_path is None: ppath = [[-s / 2, 0.0], [s / 2, 0.0]] else: ppath = [[float(a), float(b)] for a, b in cut_path] if ppath[0][0] > ppath[-1][0]: ppath = ppath[::-1] poly_pts = ( [[min(-s / 2, ppath[0][0]), s]] + [[min(-s / 2, ppath[0][0]), ppath[0][1]]] + ppath + [[max(s / 2, ppath[-1][0]), ppath[-1][1]]] + [[max(s / 2, ppath[-1][0]), s]] ) poly = _polygon([[float(x), float(y)] for x, y in poly_pts]) if offset: poly = poly.offset(radius=offset) mask = poly.linear_extrude(height=s, center=True) if bool(np.allclose(vu, UP)): xyv = np.asarray(FRONT, dtype=float) elif bool(np.allclose(vu, DOWN)): xyv = np.asarray(BACK, dtype=float) else: xyv = np.array([v3[0], v3[1], 0.0]) angle = math.degrees(math.atan2(xyv[1], xyv[0])) - 90 m = rot_about_axis(cut_angle, v3) @ _rot4(rot_from_to(xyv, v3)) @ zrot4(angle) mask = mask.multmatrix(m.tolist()) if not np.allclose(cpv, 0): mask = mask.translate([float(c) for c in cpv]) return mask
[docs] def half_of( self, v=UP, center: bool | list[float] | None = None, s=None, cut_path=None, cut_angle: float = 0, offset=0, ): """Keep the half of this solid on the side the normal *v* points to (BOSL2 half_of()). *center* is a point on the cut plane, or a scalar distance to shift the plane along *v*. *s* (the mask size) defaults to twice the object's bounding-box reach, so it rarely needs setting. *cut_path* follows a 2-D :func:`partition_path` for an interlocking cut face; *cut_angle* spins that face about *v*; *offset* grows the mask. """ v3 = _as_vec3(v) if center is None: cpv = np.zeros(3) elif is_num(center): cpv = float(center) * unit(v3) # type: ignore[arg-type] else: cpv = _as_vec3(center) if s is None: center_pt, size = self.bounds() # type: ignore[attr-defined] reach = float(np.linalg.norm(size)) + float(np.linalg.norm(cpv - np.asarray(center_pt))) s = 2.2 * reach + 2.0 return self._wrap(self.shape & self._half_mask(v3, cpv, s, cut_path, cut_angle, offset)) # type: ignore[attr-defined]
[docs] def left_half(self, x=0, s=None, cut_path=None, cut_angle: float = 0, offset=0): """Keep the left (-X) half, cut at ``X=x`` (BOSL2 left_half()).""" return self.half_of( LEFT, center=[x, 0, 0], s=s, cut_path=cut_path, cut_angle=cut_angle, offset=offset, )
[docs] def right_half(self, x=0, s=None, cut_path=None, cut_angle: float = 0, offset=0): """Keep the right (+X) half, cut at ``X=x`` (BOSL2 right_half()).""" return self.half_of( RIGHT, center=[x, 0, 0], s=s, cut_path=cut_path, cut_angle=cut_angle, offset=offset, )
[docs] def front_half(self, y=0, s=None, cut_path=None, cut_angle: float = 0, offset=0): """Keep the front (-Y) half, cut at ``Y=y`` (BOSL2 front_half()).""" return self.half_of( FRONT, center=[0, y, 0], s=s, cut_path=cut_path, cut_angle=cut_angle, offset=offset, )
[docs] def back_half(self, y=0, s=None, cut_path=None, cut_angle: float = 0, offset=0): """Keep the back (+Y) half, cut at ``Y=y`` (BOSL2 back_half()).""" return self.half_of( BACK, center=[0, y, 0], s=s, cut_path=cut_path, cut_angle=cut_angle, offset=offset, )
[docs] def bottom_half(self, z=0, s=None, cut_path=None, cut_angle: float = 0, offset=0): """Keep the bottom (-Z) half, cut at ``Z=z`` (BOSL2 bottom_half()).""" return self.half_of( DOWN, center=[0, 0, z], s=s, cut_path=cut_path, cut_angle=cut_angle, offset=offset, )
[docs] def top_half(self, z=0, s=None, cut_path=None, cut_angle: float = 0, offset=0): """Keep the top (+Z) half, cut at ``Z=z`` (BOSL2 top_half()).""" return self.half_of( UP, center=[0, 0, z], s=s, cut_path=cut_path, cut_angle=cut_angle, offset=offset, )
[docs] def partition( self, spread: float = 10, cutsize=10, cutpath="jigsaw", gap: float = 0, cutpath_centered: bool = True, spin=0, slop: float = 0.0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ): """Cut this solid into two interlocking pieces, spread apart (BOSL2 partition()). Returns ``[back_piece, front_piece]`` -- the two halves with matched joining edges, moved *spread* apart along the (spun) Y axis so they print separately and snap back together. The joint follows *cutpath* (``"jigsaw"``, ``"dovetail"``, ``"hammerhead"``, ...); *spin* rotates the cut direction; *slop* leaves a printer-fit clearance. """ center_pt, size = self.bounds() # type: ignore[attr-defined] cs = list(cutsize) if isinstance(cutsize, (list, tuple, np.ndarray)) else [cutsize * 2, cutsize] sp = math.radians(spin) c, sn = math.cos(sp), math.sin(sp) rsx = abs(size[0] * c - size[1] * sn) rsy = abs(size[0] * sn + size[1] * c) rsz = abs(size[2]) vec = np.array([-sn, c, 0.0]) * (spread / 2) pieces = [] for idx, inverse in ((0, False), (1, True)): mask = _partition_mask_shape( rsx, rsy, rsz, cs, cutpath, gap, cutpath_centered, inverse, slop, fn, fa, fs, ) mask = mask.rotate([0, 0, spin]).translate([float(c2) for c2 in center_pt]) move = vec if idx == 0 else -vec pieces.append(self._wrap(self.shape & mask).translate([float(m) for m in move])) # type: ignore[attr-defined] return pieces
# (imported from bosl2._helpers as zrot4) def _rot4(angle_axis): angle, axis = angle_axis m = np.eye(4) m[:3, :3] = axis_angle_matrix(angle, axis) return m