Source code for pybosl2.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: pybosl2/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:`~pybosl2.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:`~pybosl2.paths.Path2D` objects; the mask builders return Bosl2Solids.
#
#    Only matrix/path math and pybosl2.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).
# DocCategory: Foundational
# FileGroup: BOSL2

"""Planar half-cuts and interlocking partitions (jigsaw/dovetail/... joints)."""

from __future__ import annotations

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

if TYPE_CHECKING:
    from typing import Self

    from pybosl2.path2d import Path2D
    from pybosl2.shapes3d import Bosl2Solid

import numpy as np

from pybosl2._helpers import is_num, zrot4
from pybosl2.constants import BACK, DOWN, FRONT, LEFT, RIGHT, UP
from pybosl2.enums import PartitionCutType
from pybosl2.transforms import axis_angle_matrix, rot_about_axis, rot_from_to
from pybosl2.vectors import unit

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


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


# (imported from pybosl2._helpers as is_num)


def _yscale(s: float, path: Sequence[Sequence[float]] | Path2D) -> Path2D:
    from pybosl2.path2d import Path2D

    return Path2D([[float(p[0]), float(p[1]) * s] for p in path])


def _scale2(sx: float, sy: float, path: Sequence[Sequence[float]] | Path2D) -> Path2D:
    from pybosl2.path2d import Path2D

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


def _left(x: float, path: Sequence[Sequence[float]] | Path2D) -> Path2D:
    from pybosl2.path2d import Path2D

    return Path2D([[float(p[0]) - x, float(p[1])] for p in path])


def _right(x: float, path: Sequence[Sequence[float]] | Path2D) -> Path2D:
    from pybosl2.path2d import Path2D

    return Path2D([[float(p[0]) + x, float(p[1])] for p in path])


def _xflip(x: float, path: Sequence[Sequence[float]] | Path2D) -> Path2D:
    from pybosl2.path2d import Path2D

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


def _skew(axy_deg: float, path: Sequence[Sequence[float]] | Path2D) -> Path2D:
    from pybosl2.path2d import Path2D

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


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


def _dedup(path: Sequence[Sequence[float]] | Path2D) -> Path2D:
    from pybosl2.path2d import Path2D

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


def _merge_collinear(path: Sequence[Sequence[float]] | Path2D) -> Path2D:
    # BOSL2's 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 pybosl2.path2d import Path2D

    return Path2D([list(p) for p in _dedup(path).merge_collinear(closed=False)])


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


def _partition_subpath(
    cptype: PartitionCutType,
    fn: int | None = None,
    fa: float | None = None,
    fs: float | None = None,
) -> Path2D:
    """Return the simple named cut sub-paths used by the mask builders (BOSL2 _partition_subpath())."""
    from pybosl2.path2d import Path2D
    from pybosl2.shapes2d import arc

    if cptype == PartitionCutType.FLAT:
        return Path2D([[0, 0], [1, 0]])
    if cptype == PartitionCutType.SAWTOOTH:
        return Path2D([[0, 0], [0.5, 1], [1, 0]])
    if cptype == PartitionCutType.SINEWAVE:
        return Path2D([[a / 360, math.sin(math.radians(a)) / 2] for a in range(0, 361, 5)])
    if cptype == PartitionCutType.COMB:
        dx = 0.5 * math.sin(math.radians(2))
        return Path2D(
            [
                [0, 0],
                [dx, 0.5],
                [0.5 - dx, 0.5],
                [0.5 + dx, -0.5],
                [1 - dx, -0.5],
                [1, 0],
            ]
        )
    if cptype == PartitionCutType.FINGER:
        dx = 0.5 * math.sin(math.radians(20))
        return Path2D(
            [
                [0, 0],
                [dx, 0.5],
                [0.5 - dx, 0.5],
                [0.5 + dx, -0.5],
                [1 - dx, -0.5],
                [1, 0],
            ]
        )
    if cptype == PartitionCutType.DOVETAIL:
        return Path2D([[0, -0.5], [0.3, -0.5], [0.2, 0.5], [0.8, 0.5], [0.7, -0.5], [1, -0.5]])
    if cptype == PartitionCutType.HAMMERHEAD:
        return Path2D(
            [
                [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 == PartitionCutType.JIGSAW:
        return Path2D(
            list(  # type: ignore[arg-type]
                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: float,
    h: float,
    cutsize: float | Sequence[float],
    cutpath: str | Sequence[Sequence[float]],
    gap: float,
    cutpath_centered: bool,
    fn: int | None = None,
    fa: float | None = None,
    fs: float | None = None,
) -> Path2D:
    """One row of the named cut sub-path, repeated to span *length* (BOSL2 _partition_cutpath())."""
    _ = h
    cs: list[float] = list(cutsize) if isinstance(cutsize, (list, tuple, np.ndarray)) else [cutsize * 2, cutsize]  # type: ignore[operator, list-item]
    sub: list[list[float]] | Path2D
    if isinstance(cutpath, str):
        sub = _partition_subpath(PartitionCutType(cutpath), fn, fa, fs)
    else:
        sub = [list(p) for p in cutpath]
    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: PartitionCutType | float | str,
    length: float = 25,
    width: float = 25,
    invert: bool = False,
    fn: int | None = None,
    fa: float | None = None,
    fs: float | None = None,
) -> Path2D:
    """One section of a partition_path, with the full BOSL2 modifier grammar (BOSL2 _ptn_sect())."""
    from pybosl2._helpers import frag_count as _frag_count
    from pybosl2.path2d import Path2D
    from pybosl2.shapes2d import arc

    if is_num(cptype):
        assert cptype > 0, "flat section length must be positive."  # type: ignore[operator]
        return Path2D([[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 = sect.bounds()
            xpos = (b.max_x + b.min_x) / 2
            return Path2D(_xflip(xpos, sect)[::-1])  # type: ignore[arg-type]
        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 = sect1.bounds(), sect2.bounds()
            osect1 = _scale2(0.5, 0.5, _left(b1.min_x, sect1))
            osect2 = _right(osect1[-1][0], _scale2(0.5, 0.5, _left(b2.min_x, sect2)))
            return _merge_collinear(list(osect1) + list(osect2))  # type: ignore[arg-type]
        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: list[Any] = []
            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 Path2D([[(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 Path2D([[0, 0], [float(opt), 0]])
        raise AssertionError(f"Bad section option: {opt!r}")

    if cptype == PartitionCutType.SINEWAVE:
        return _ptn_sect("halfsine addflip", length, width, fn=fn, fa=fa, fs=fs)
    steps = _frag_count(length / 2, fn, fa, fs)
    if cptype == PartitionCutType.FLAT:
        path: list[list[float]] | Path2D = [[0, 0], [1, 0]]
    elif cptype == PartitionCutType.SAWTOOTH:
        path = [[0, 0], [0, 1], [1, 0]]
    elif cptype == PartitionCutType.SQUARE:
        path = [[0, 0], [0, 1], [1, 1], [1, 0]]
    elif cptype == PartitionCutType.TRIANGLE:
        path = [[0, 0], [0.5, 1], [1, 0]]
    elif cptype == PartitionCutType.HALFSINE:
        path = [[a / 180, math.sin(math.radians(a))] for a in np.arange(0, 180.0001, 360 / steps)]
    elif cptype == PartitionCutType.SEMICIRCLE:
        raw_arc = arc(
            count=math.ceil(steps / 2),
            radius=1 / 2,
            center=[1 / 2, 0],
            start=180,
            angle=-180,
        )
        path = _yscale(2, list(raw_arc))  # type: ignore[arg-type]
    elif cptype == PartitionCutType.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 == PartitionCutType.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 == PartitionCutType.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 == PartitionCutType.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 == PartitionCutType.JIGSAW:
        path = (
            list(  # type: ignore[assignment]
                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: Sequence[Any], repeat: int = 1, y: float | None = None, altpath: Path2D | None = None, seglen: float = 25, segwidth: float = 25, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Path2D: """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) fn: number of fragments for circle resolution. fa: minimum fragment angle for circle resolution. fs: minimum fragment size for circle resolution. Returns: A :class:`~pybosl2.paths.Path2D` (closed when *y* is given). Examples: A wall profile mixing jigsaw and hammerhead joints, stroked into a divider: .. pythonscad-example:: from pybosl2.partitions import partition_path wall = partition_path([40, "jigsaw", 10, "jigsaw yflip", 40], fn=24) wall.stroke(width=3).linear_extrude(height=30).show() """ from pybosl2.path2d import Path2D paths: list[Any] = [] 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"Path2D 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: list[Any] = [] 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 Path2D(list(redirpath), closed=False) # type: ignore[arg-type] assert y < min_y or y > max_y, "partition_path(): closing y would make the path self-crossing." closedpath: list[Any] = [[redirpath[-1][0], y], [redirpath[0][0], y]] + list(redirpath) outpath = closedpath if y < 0 else closedpath[::-1] return Path2D(outpath, closed=True)
def _ptn_path_redirect(major_path: Path2D, minor_path: Path2D, center: bool = True) -> Path2D: """Re-lay *minor_path* (a partition pattern) along *major_path* (BOSL2 _ptn_path_redirect()).""" from pybosl2.path2d import Path2D major2 = _merge_collinear(major_path) minor2 = [list(p) for p in minor_path.resample_path(spacing=1, closed=False)] major_len = major2.perimeter() 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]] # type: ignore[arg-type] + [list(np.asarray(major2[-1]) + vec2 * e2)] ) major_len2 = Path2D(major3).perimeter() xoff = (major_len2 - minor_len) / 2 if center else 0 minor3 = _left(minor2[0][0] - xoff, minor2) out = [] for pt in minor3: pinfo = Path2D(major3).cut_points(max(0.0, pt[0]), closed=False, direction=True)[0] base = np.asarray(pinfo.point) tangent = unit(np.asarray(pinfo.normal), [0.0, 1.0]) out.append(list(base + tangent * pt[1])) return _merge_collinear(_dedup(out)) # --------------------------------------------------------------------------- # Section: mask geometry # --------------------------------------------------------------------------- def _partition_mask_shape( length: float, w: float, h: float, cutsize: float | Sequence[float], cutpath: str | Sequence[Sequence[float]] | Path2D, gap: float, cutpath_centered: bool, inverse: bool, slop: float, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> Any: """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] # type: ignore[operator] path = _partition_cutpath(length, h, cs, cutpath, gap, cutpath_centered, fn, fa, fs) # type: ignore[arg-type] 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: float = 100, height: float = 100, cutsize: float | Sequence[float] = 10, cutpath: str | Path2D = "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: """Return 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 fn: number of fragments for circle resolution. fa: minimum fragment angle for circle resolution. fs: minimum fragment size for circle resolution. Examples: A jigsaw-pattern mask for a 100×100×10 mm part: .. pythonscad-example:: from pybosl2.solid import cuboid from pybosl2.partitions import partition_mask (cuboid([100, 100, 10]) & partition_mask(w=50, height=10, cutpath="jigsaw", slop=0.15)).show() """ from pybosl2.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: float | Sequence[float] = 10, cutpath: str | Path2D = "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: """Return 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. Examples: Split a 100×100×10 mm plate with a jigsaw cut: .. pythonscad-example:: from pybosl2.partitions import partition_cut_mask mask = partition_cut_mask(height=10, cutsize=8, slop=0.3) mask.show() """ from pybosl2.shapes3d import Bosl2Solid cs = list(cutsize) if isinstance(cutsize, (list, tuple, np.ndarray)) else [cutsize * 2, cutsize] # type: ignore[operator] path = _partition_cutpath(length, height, cs, cutpath, gap, cutpath_centered, fn, fa, fs) # type: ignore[arg-type] ribbon = path.stroke(width=max(0.1, slop * 2)) return Bosl2Solid(ribbon.linear_extrude(height=height, center=True))
# --------------------------------------------------------------------------- # Section: Partitionable mixin # --------------------------------------------------------------------------- def _as_vec3(v: Any) -> np.ndarray: 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:`~pybosl2.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: Any) -> Self: # pragma: no cover """Re-wrap a native shape as the host solid type.""" raise NotImplementedError def _half_mask( self, v: Any, cpv: Any, s: float, cut_path: Sequence[Sequence[float]] | Path2D | None, cut_angle: float, offset: float, ) -> Any: 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.vector)): xyv = np.asarray(FRONT.vector, dtype=float) elif bool(np.allclose(vu, DOWN.vector)): xyv = np.asarray(BACK.vector, 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: Any = UP, center: bool | list[float] | None = None, s: float | None = None, cut_path: Path2D | None = None, cut_angle: float = 0, offset: float = 0, ) -> Self: """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. Examples: Cut a cube in half along a jigsaw pattern: .. pythonscad-example:: from pybosl2.solid import cuboid from pybosl2.partitions import partition_path from pybosl2 import UP path = partition_path(["finger", 20, "finger"], seglen=25) cuboid([60, 60, 20]).half_of(v=UP, cut_path=path).show() """ v3 = _as_vec3(v) if center is None: cpv = np.zeros(3) elif isinstance(center, (int, float, np.integer, np.floating)) and not isinstance(center, bool): cpv = float(center) * unit(v3) 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: float = 0, s: float | None = None, cut_path: Path2D | None = None, cut_angle: float = 0, offset: float = 0, ) -> Self: """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: float = 0, s: float | None = None, cut_path: Path2D | None = None, cut_angle: float = 0, offset: float = 0, ) -> Self: """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: float = 0, s: float | None = None, cut_path: Path2D | None = None, cut_angle: float = 0, offset: float = 0, ) -> Self: """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: float = 0, s: float | None = None, cut_path: Path2D | None = None, cut_angle: float = 0, offset: float = 0, ) -> Self: """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: float = 0, s: float | None = None, cut_path: Path2D | None = None, cut_angle: float = 0, offset: float = 0, ) -> Self: """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: float = 0, s: float | None = None, cut_path: Path2D | None = None, cut_angle: float = 0, offset: float = 0, ) -> Self: """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: float | Sequence[float] = 10, cutpath: str | Path2D = "jigsaw", gap: float = 0, cutpath_centered: bool = True, spin: float = 0, slop: float = 0.0, fn: int | None = None, fa: float | None = None, fs: float | None = None, ) -> list[Self]: """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. Examples: Split a block into two dovetailed halves: .. pythonscad-example:: from pybosl2.solid import cuboid halves = cuboid([60, 60, 20]).partition(spread=15, cutpath="dovetail", slop=0.15) halves[0].show() """ center_pt, size = self.bounds() # type: ignore[attr-defined] cs: list[float] = list(cutsize) if isinstance(cutsize, (list, tuple, np.ndarray)) else [cutsize * 2, cutsize] # type: ignore[operator, list-item] 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 pybosl2._helpers as zrot4) def _rot4(angle_axis: Sequence[Any]) -> np.ndarray: angle: float = angle_axis[0] axis: Any = angle_axis[1] m = np.eye(4) m[:3, :3] = axis_angle_matrix(angle, axis) return m