Source code for bosl2.distributors

# 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/distributors.py
#    Pure-Python port of BOSL2's distributors.scad: the "copiers" that duplicate a shape into a
#    line/grid/ring/arc/sphere/path pattern, plus the reflected-copy helpers. Each copier is a
#    module-level function that returns a list of 4x4 transformation matrices (BOSL2's function
#    form without a ``p=`` argument), and a matching method on the :class:`Distributable` mixin
#    that applies those matrices to the object.
#
#    The mixin is inherited by :class:`~bosl2.shapes3d.Bosl2Solid`, :class:`~bosl2.paths.Path`,
#    and :class:`~bosl2.paths.Path3D`, each of which implements ``_distribute(mats)`` to say what
#    "a list of copies" means for it:
#      * Bosl2Solid  -> the UNION of the transformed geometry copies (a new Bosl2Solid).
#      * Path / Path3D -> a plain ``list`` of transformed path copies (BOSL2's function form).
#        A 2-D Path only supports the in-plane copiers; one that would lift it out of the XY plane
#        raises, directing you to Path3D.
#
#    Only matrix math and bosl2.transforms/constants are imported at load time (so paths.py can
#    pull in the mixin during its own import without a cycle); Path/Region/point-in-polygon are
#    imported lazily inside the few functions that need them.
#
# FileSummary: Distributors: line/grid/ring/arc/sphere/path copiers and reflected copies.
# FileGroup: BOSL2

from __future__ import annotations

import math
from abc import ABC, abstractmethod
from collections.abc import Sequence

import numpy as np

from bosl2._helpers import is_num, rot_from_to4, translate4
from bosl2.constants import BACK, RIGHT, UP
from bosl2.transforms import axis_angle_matrix

__all__ = [
    "move_copies",
    "xcopies",
    "ycopies",
    "zcopies",
    "line_copies",
    "grid_copies",
    "rot_copies",
    "xrot_copies",
    "yrot_copies",
    "zrot_copies",
    "arc_copies",
    "sphere_copies",
    "path_copies",
    "mirror_copy",
    "xflip_copy",
    "yflip_copy",
    "zflip_copy",
    "distribute",
    "xdistribute",
    "ydistribute",
    "zdistribute",
    "Distributable",
]


# ---------------------------------------------------------------------------
# Section: matrix helpers
# ---------------------------------------------------------------------------


def _scalar_vec3(v, fill: float = 0.0) -> np.ndarray:
    """BOSL2 scalar_vec3(): a scalar becomes [v, fill, fill]; a vector is padded to length 3."""
    if is_num(v):
        return np.array([float(v), float(fill), float(fill)])
    arr = list(v)
    return np.array([float(arr[i]) if i < len(arr) else float(fill) for i in range(3)])


def _unit3(v) -> np.ndarray:
    a = _scalar_vec3(v, 0.0) if is_num(v) else np.asarray(v, dtype=float)
    if a.shape[0] == 2:
        a = np.array([a[0], a[1], 0.0])
    sides = float(np.linalg.norm(a))
    return a / sides if sides else a


def _rot4(a: float, v=None, reverse: bool = False) -> np.ndarray:
    """4x4 rotation of *a* degrees about axis *v* (default +Z), through the origin."""
    angle = -a if reverse else a
    m = np.eye(4)
    m[:3, :3] = axis_angle_matrix(angle, UP if v is None else v)
    return m


def _mirror4(nv) -> np.ndarray:
    sides = _unit3(nv)
    m = np.eye(4)
    m[:3, :3] = np.eye(3) - 2 * np.outer(sides, sides)
    return m


# (imported from bosl2._helpers as rot_from_to4)


def _frame_map4(x=None, z=None) -> np.ndarray:
    """A rotation whose local X and Z axes point along *x* and *z* (BOSL2 frame_map(x=, z=))."""
    xv, zv = _unit3(x), _unit3(z)
    yv = _unit3(np.cross(zv, xv))
    m = np.eye(4)
    m[:3, 0], m[:3, 1], m[:3, 2] = xv, yv, zv
    return m


def _spherical_to_xyz(radius: float, theta: float, phi: float) -> np.ndarray:
    th, ph = math.radians(theta), math.radians(phi)
    return np.array(
        [
            radius * math.sin(ph) * math.cos(th),
            radius * math.sin(ph) * math.sin(th),
            radius * math.cos(ph),
        ]
    )


def _radius(radius=None, diameter=None, radius1=None, diameter1=None, dflt=None):
    """BOSL2 get_radius() priority: radius1 > diameter1/2 > radius > diameter/2 > dflt."""
    if radius1 is not None:
        return radius1
    if diameter1 is not None:
        return diameter1 / 2
    if radius is not None:
        return radius
    if diameter is not None:
        return diameter / 2
    return dflt


def _apply4(m: np.ndarray, pts3: np.ndarray) -> np.ndarray:
    """Apply a 4x4 matrix to an (N, 3) point array, returning an (N, 3) array."""
    pts = np.asarray(pts3, dtype=float)
    homo = np.hstack([pts, np.ones((len(pts), 1))])
    out = (m @ homo.T).T
    w = out[:, 3:4]
    return out[:, :3] / np.where(w == 0, 1.0, w)


# ---------------------------------------------------------------------------
# Section: copier matrix generators (BOSL2 function form, returning matrices)
# ---------------------------------------------------------------------------


[docs] def move_copies(a=([0, 0, 0],)) -> list[np.ndarray]: """One translation matrix per offset in *a* (BOSL2 move_copies()).""" return [translate4(pos) for pos in a]
[docs] def line_copies(spacing=None, sides=None, length: float | None = None, p1=None, p2=None) -> list[np.ndarray]: """Translation matrices evenly spread along a line (BOSL2 line_copies()).""" if length is not None: ll = _scalar_vec3(length, 0.0) elif spacing is not None and sides is not None: ll = (sides - 1) * _scalar_vec3(spacing, 0.0) elif p1 is not None and p2 is not None: ll = _scalar_vec3(np.asarray(p2, dtype=float) - np.asarray(p1, dtype=float), 0.0) else: ll = None if sides is not None: cnt = int(sides) elif spacing is not None and ll is not None: cnt = int(math.floor(np.linalg.norm(ll) / np.linalg.norm(_scalar_vec3(spacing, 0.0)) + 1.000001)) else: cnt = 2 if cnt <= 1: spc = np.zeros(3) elif spacing is None and ll is not None: spc = ll / (cnt - 1) elif is_num(spacing) and ll is not None: spc = ll / (cnt - 1) else: spc = _scalar_vec3(spacing, 0.0) spos = _scalar_vec3(p1, 0.0) if p1 is not None else -(cnt - 1) / 2 * spc return [translate4(i * spc + spos) for i in range(cnt)]
def _axis_copies(direction, spacing, sides, length, sp) -> list[np.ndarray]: dirv = np.asarray(direction, dtype=float) sp_pt = (sp * dirv) if is_num(sp) else (np.asarray(sp, dtype=float) if sp is not None else None) if isinstance(spacing, (list, tuple, np.ndarray)): # explicit positions along the axis base = sp_pt if sp_pt is not None else np.zeros(3) return [translate4(base + float(s) * dirv) for s in spacing] lv = (length * dirv) if length is not None else None spv = (spacing * dirv) if spacing is not None else None return line_copies(spacing=spv, sides=sides, length=lv, p1=sp_pt)
[docs] def xcopies(spacing=None, sides=None, length: float | None = None, sp=None) -> list[np.ndarray]: """Copies spread along the X axis (BOSL2 xcopies()).""" return _axis_copies(RIGHT, spacing, sides, length, sp)
[docs] def ycopies(spacing=None, sides=None, length: float | None = None, sp=None) -> list[np.ndarray]: """Copies spread along the Y axis (BOSL2 ycopies()).""" return _axis_copies(BACK, spacing, sides, length, sp)
[docs] def zcopies(spacing=None, sides=None, length: float | None = None, sp=None) -> list[np.ndarray]: """Copies spread along the Z axis (BOSL2 zcopies()).""" return _axis_copies(UP, spacing, sides, length, sp)
[docs] def grid_copies( spacing=None, sides=None, size=None, stagger: bool = False, inside=None, nonzero: bool | None = None, axes="xy", ) -> list[np.ndarray]: """Copies laid out in a square or staggered (hex) grid (BOSL2 grid_copies()).""" assert stagger in (False, True, "alt"), "grid_copies(): stagger must be False, True or 'alt'." assert len(axes) == 2 and axes[0] in "xyz" and axes[1] in "xyz" and axes[0] != axes[1], ( "grid_copies(): invalid axes." ) ai = {"x": 0, "y": 1, "z": 2} def permax(pt): out = [0.0, 0.0, 0.0] out[ai[axes[0]]] = pt[0] out[ai[axes[1]]] = pt[1] return np.array(out) bounds = None if inside is not None: arr = np.asarray(inside, dtype=float) bounds = [arr.min(axis=0), arr.max(axis=0)] if size is not None: size = [float(size), float(size)] if is_num(size) else [float(size[0]), float(size[1])] elif bounds is not None: size = [2 * max(abs(bounds[0][i]), abs(bounds[1][i])) for i in range(2)] if is_num(spacing): from bosl2.transforms import polar_to_xy spacing = polar_to_xy(spacing, 60) if stagger is not False else [spacing, spacing] elif isinstance(spacing, (list, tuple, np.ndarray)): spacing = [float(spacing[0]), float(spacing[1])] elif size is not None: if is_num(sides): spacing = [size[0] / (sides - 1), size[1] / (sides - 1)] elif isinstance(sides, (list, tuple, np.ndarray)): spacing = [size[0] / (sides[0] - 1), size[1] / (sides[1] - 1)] else: div = [1, 1] if stagger is False else [2, 2] spacing = [size[0] / div[0], size[1] / div[1]] if is_num(sides): sides = [int(sides), int(sides)] elif isinstance(sides, (list, tuple, np.ndarray)): sides = [int(sides[0]), int(sides[1])] elif size is not None and spacing is not None: sides = [ int(math.floor(size[0] / spacing[0])) + 1, int(math.floor(size[1] / spacing[1])) + 1, ] else: sides = [2, 2] spacing = np.asarray(spacing, dtype=float) offset = spacing * (np.asarray(sides) - 1) / 2 def keep(pos): if inside is None: return True from bosl2.paths import Path return Path._point_in_polygon(pos, inside, nonzero=bool(nonzero)) >= 0 mats = [] if stagger is False: for row in range(sides[1]): for col in range(sides[0]): pos = np.array([col, row]) * spacing - offset if keep(pos): mats.append(translate4(permax(pos))) else: staggermod = 1 if stagger == "alt" else 0 cols1 = math.ceil(sides[0] / 2) cols2 = sides[0] - cols1 for row in range(sides[1]): rowcols = cols1 if (row % 2) == staggermod else cols2 for col in range(rowcols): rowdx = spacing[0] if (row % 2) != staggermod else 0.0 pos = np.array([2 * col, row]) * spacing + np.array([rowdx, 0.0]) - offset if keep(pos): mats.append(translate4(permax(pos))) return mats
[docs] def rot_copies( rots=None, v=None, center: "bool | Sequence[float]" = (0, 0, 0), sides=None, sa: float = 0, offset=0, delta: "Sequence[float]" = (0, 0, 0), subrot: bool = True, ) -> list[np.ndarray]: """Rotated copies about an axis, optionally offset into a ring (BOSL2 rot_copies()).""" assert subrot or np.linalg.norm(_scalar_vec3(delta, 0.0)) > 0, ( "rot_copies(): subrot can only be False when delta is nonzero." ) sang = sa + offset if sides is not None: angs = [] if sides <= 0 else [i / sides * 360 + sang for i in range(sides)] elif rots: angs = [float(a) for a in rots] else: angs = [] cen, deltav = _scalar_vec3(center, 0.0), _scalar_vec3(delta, 0.0) mats = [] for angle in angs: m = ( translate4(cen)
[docs] @ _rot4(angle, v) @ translate4(deltav) @ _rot4(0 if subrot else angle, v, reverse=True) @ translate4(-cen) ) mats.append(m) return mats
def xrot_copies( rots=None, center: "bool | Sequence[float]" = (0, 0, 0), sides=None, sa: float = 0, radius: float | None = None, diameter: float | None = None, subrot: bool = True, ) -> list[np.ndarray]: """ Rotated copies around the X axis, optionally into a ring of radius *radius* (BOSL2 xrot_copies()). """ rr = _radius(radius=radius, diameter=diameter, dflt=0) return rot_copies( rots=rots, v=RIGHT, center=center, sides=sides, sa=sa, delta=[0, rr, 0], subrot=subrot, )
[docs] def yrot_copies( rots=None, center: "bool | Sequence[float]" = (0, 0, 0), sides=None, sa: float = 0, radius: float | None = None, diameter: float | None = None, subrot: bool = True, ) -> list[np.ndarray]: """ Rotated copies around the Y axis, optionally into a ring of radius *radius* (BOSL2 yrot_copies()). """ rr = _radius(radius=radius, diameter=diameter, dflt=0) return rot_copies( rots=rots, v=BACK, center=center, sides=sides, sa=sa, delta=[-rr, 0, 0], subrot=subrot, )
[docs] def zrot_copies( rots=None, center: "bool | Sequence[float]" = (0, 0, 0), sides=None, sa: float = 0, radius: float | None = None, diameter: float | None = None, subrot: bool = True, ) -> list[np.ndarray]: """ Rotated copies around the Z axis, optionally into a ring of radius *radius* (BOSL2 zrot_copies()). """ rr = _radius(radius=radius, diameter=diameter, dflt=0) return rot_copies( rots=rots, v=UP, center=center, sides=sides, sa=sa, delta=[rr, 0, 0], subrot=subrot, )
[docs] def arc_copies( sides=6, radius: float | None = None, radius_x: float | None = None, radius_y: float | None = None, diameter: float | None = None, diameter_x: float | None = None, diameter_y: float | None = None, sa: float = 0, ea: float = 360, rot=True, ) -> list[np.ndarray]: """Copies spread along an (elliptical) arc in the XY plane (BOSL2 arc_copies()).""" rxv = _radius(radius1=radius_x, radius=radius, diameter1=diameter_x, diameter=diameter, dflt=1) ryv = _radius(radius1=radius_y, radius=radius, diameter1=diameter_y, diameter=diameter, dflt=1) sa, ea = sa % 360, ea % 360 extra_n = 1 if abs(ea - sa) < 0.01 else 0 delt = ((360.0 if ea <= sa else 0) + ea - sa) / (sides - 1 + extra_n) mats = [] for i in range(sides): angle = sa + i * delt pos = [ rxv * math.cos(math.radians(angle)), ryv * math.sin(math.radians(angle)), 0, ] ang2 = ( math.degrees( math.atan2( ryv * math.sin(math.radians(angle)), rxv * math.cos(math.radians(angle)), ) ) if rot else 0 ) mats.append(translate4(pos) @ _rot4(ang2)) return mats
[docs] def sphere_copies( sides=100, radius: float | None = None, diameter: float | None = None, cone_ang=90, scale=(1, 1, 1), perp: bool = True, ) -> list[np.ndarray]: """Copies spread over a sphere/ellipsoid by the golden-spiral method (BOSL2 sphere_copies()).""" rr = _radius(radius=radius, diameter=diameter, dflt=50) cnt = math.ceil(sides / (cone_ang / 180)) scalev = _scalar_vec3(scale, 1.0) mats = [] for x in range(sides): theta = (180 * (1 + math.sqrt(5)) * (x + 0.5)) % 360 phi = math.degrees(math.acos(1 - 2 * (x + 0.5) / cnt)) xyz = _spherical_to_xyz(rr, theta, phi) pos = xyz * scalev m = translate4(pos) @ (rot_from_to4(UP, xyz) if perp else np.eye(4)) mats.append(m) return mats
[docs] def path_copies( path, sides=None, spacing=None, sp=None, dist: "Sequence[float] | None" = None, rotate_children: bool = True, closed: bool | None = None, ) -> list[np.ndarray]: """Copies placed along *path*, oriented to it (BOSL2 path_copies()).""" from bosl2.paths import Path pts = [list(map(float, p)) for p in path] closed = bool(getattr(path, "closed", False)) if closed is None else closed length = Path._path_length(pts, closed=closed) if dist is not None: distances = sorted(float(x) for x in dist) elif sp is not None: if sides is not None and spacing is not None: distances = [sp + i * spacing for i in range(sides)] elif sides is not None: distances = list(np.linspace(sp, length, sides)) else: distances = list(np.arange(sp, length, spacing)) elif sides is not None and spacing is None: distances = list(np.linspace(0, length, sides, endpoint=not closed)) else: cnt = sides if sides is not None else int(math.floor(length / spacing)) + (0 if closed else 1) ptlist = [i * spacing for i in range(cnt)] center = sum(ptlist) / len(ptlist) if closed: distances = sorted((e - center) % length for e in ptlist) else: distances = [e + length / 2 - center for e in ptlist] assert min(distances) >= -1e-9 and max(distances) <= length + 1e-9, "path_copies(): copies don't fit on the path." distances = [min(max(dst, 0.0), length) for dst in distances] cutlist = Path._path_cut_points(pts, distances, closed=closed, direction=True) planar = len(pts[0]) == 2 mats = [] for point, _ind, tangent, normal in cutlist: base = translate4(point) if not rotate_children: rotm = np.eye(4) elif planar: rotm = rot_from_to4([0, 1, 0], _scalar_vec3(normal, 0.0)) else: rotm = _frame_map4(x=tangent, z=normal) mats.append(base @ rotm) return mats
[docs] def mirror_copy(v=(0, 0, 1), offset=0, center: bool | list[float] | None = None) -> list[np.ndarray]: """The original plus a mirrored copy across the plane with normal *v* (BOSL2 mirror_copy()).""" nv = _unit3(v) cen = ( _scalar_vec3(center, 0.0) if center is not None and not is_num(center) else (center * nv if is_num(center) else np.zeros(3)) ) off = nv * offset return [ translate4(off), translate4(np.asarray(cen)) @ _mirror4(nv) @ translate4(-np.asarray(cen)) @ translate4(off), ]
[docs] def xflip_copy(offset=0, x=0) -> list[np.ndarray]: """The original plus a copy mirrored across the X=*x* plane (BOSL2 xflip_copy()).""" return mirror_copy(v=[1, 0, 0], offset=offset, center=[x, 0, 0])
[docs] def yflip_copy(offset=0, y=0) -> list[np.ndarray]: """The original plus a copy mirrored across the Y=*y* plane (BOSL2 yflip_copy()).""" return mirror_copy(v=[0, 1, 0], offset=offset, center=[0, y, 0])
[docs] def zflip_copy(offset=0, z=0) -> list[np.ndarray]: """The original plus a copy mirrored across the Z=*z* plane (BOSL2 zflip_copy()).""" return mirror_copy(v=[0, 0, 1], offset=offset, center=[0, 0, z])
# --------------------------------------------------------------------------- # Section: Distributable mixin # ---------------------------------------------------------------------------
[docs] class Distributable(ABC): """Mixin adding the distributors.scad copiers as methods. Inherited by :class:`~bosl2.shapes3d.Bosl2Solid`, :class:`~bosl2.paths.Path`, and :class:`~bosl2.paths.Path3D`. Each copier builds a list of transformation matrices and hands them to ``_distribute``, which every host class implements: a Bosl2Solid unions the geometry copies into a new solid; a Path / Path3D returns a plain ``list`` of the copied paths. """ @abstractmethod def _distribute(self, mats): # pragma: no cover - overridden by every host class raise NotImplementedError("Distributable subclasses must implement _distribute().")
[docs] def move_copies(self, a=([0, 0, 0],)): """Copy to each offset in *a*.""" return self._distribute(move_copies(a))
[docs] def xcopies(self, spacing=None, sides=None, length: float | None = None, sp=None): """Copies spread along the X axis.""" return self._distribute(xcopies(spacing, sides, length, sp))
[docs] def ycopies(self, spacing=None, sides=None, length: float | None = None, sp=None): """Copies spread along the Y axis.""" return self._distribute(ycopies(spacing, sides, length, sp))
[docs] def zcopies(self, spacing=None, sides=None, length: float | None = None, sp=None): """Copies spread along the Z axis.""" return self._distribute(zcopies(spacing, sides, length, sp))
[docs] def line_copies(self, spacing=None, sides=None, length: float | None = None, p1=None, p2=None): """Copies spread along a line.""" return self._distribute(line_copies(spacing, sides, length, p1, p2))
[docs] def grid_copies( self, spacing=None, sides=None, size=None, stagger: bool = False, inside=None, nonzero: bool | None = None, axes="xy", ): """Copies in a square or staggered (hex) grid.""" return self._distribute(grid_copies(spacing, sides, size, stagger, inside, nonzero, axes))
[docs] def rot_copies( self, rots=None, v=None, center: "bool | Sequence[float]" = (0, 0, 0), sides=None, sa: float = 0, offset=0, delta: "Sequence[float]" = (0, 0, 0), subrot: bool = True, ): """Rotated copies about an axis (optionally into a ring via *delta*).""" return self._distribute(rot_copies(rots, v, center, sides, sa, offset, delta, subrot))
[docs] def xrot_copies( self, rots=None, center: "bool | Sequence[float]" = (0, 0, 0), sides=None, sa: float = 0, radius: float | None = None, diameter: float | None = None, subrot: bool = True, ): """Rotated copies around the X axis.""" return self._distribute(xrot_copies(rots, center, sides, sa, radius, diameter, subrot))
[docs] def yrot_copies( self, rots=None, center: "bool | Sequence[float]" = (0, 0, 0), sides=None, sa: float = 0, radius: float | None = None, diameter: float | None = None, subrot: bool = True, ): """Rotated copies around the Y axis.""" return self._distribute(yrot_copies(rots, center, sides, sa, radius, diameter, subrot))
[docs] def zrot_copies( self, rots=None, center: "bool | Sequence[float]" = (0, 0, 0), sides=None, sa: float = 0, radius: float | None = None, diameter: float | None = None, subrot: bool = True, ): """Rotated copies around the Z axis.""" return self._distribute(zrot_copies(rots, center, sides, sa, radius, diameter, subrot))
[docs] def arc_copies( self, sides=6, radius: float | None = None, radius_x: float | None = None, radius_y: float | None = None, diameter: float | None = None, diameter_x: float | None = None, diameter_y: float | None = None, sa: float = 0, ea: float = 360, rot=True, ): """Copies spread along an (elliptical) arc in the XY plane.""" return self._distribute( arc_copies( sides, radius, radius_x, radius_y, diameter, diameter_x, diameter_y, sa, ea, rot, ) )
[docs] def sphere_copies( self, sides=100, radius: float | None = None, diameter: float | None = None, cone_ang=90, scale=(1, 1, 1), perp: bool = True, ): """Copies spread over a sphere/ellipsoid surface.""" return self._distribute(sphere_copies(sides, radius, diameter, cone_ang, scale, perp))
[docs] def path_copies( self, path, sides=None, spacing=None, sp=None, dist: "Sequence[float] | None" = None, rotate_children: bool = True, closed: bool | None = None, ): """Copies placed along *path*, oriented to it.""" return self._distribute(path_copies(path, sides, spacing, sp, dist, rotate_children, closed))
[docs] def mirror_copy(self, v=(0, 0, 1), offset=0, center: bool | None = None): """This object plus a copy mirrored across the plane with normal *v*.""" return self._distribute(mirror_copy(v, offset, center))
[docs] def xflip_copy(self, offset=0, x=0): """This object plus a copy mirrored across the X=*x* plane.""" return self._distribute(xflip_copy(offset, x))
[docs] def yflip_copy(self, offset=0, y=0): """This object plus a copy mirrored across the Y=*y* plane.""" return self._distribute(yflip_copy(offset, y))
[docs] def zflip_copy(self, offset=0, z=0): """This object plus a copy mirrored across the Z=*z* plane.""" return self._distribute(zflip_copy(offset, z))
# --------------------------------------------------------------------------- # Section: distributing a list of distinct children # ---------------------------------------------------------------------------
[docs] def distribute(children, spacing=None, sizes=None, dir=RIGHT, length: float | None = None): """Space a LIST of distinct solids out along *dir* so they don't overlap (BOSL2 distribute()). Unlike the copiers (which duplicate one shape), this lays out several different children in order. *sizes* gives each child's extent along *dir*; if omitted it is read from each child's bounding box. Give *spacing* (gap between children) or *length* (total length to fill). Returns the union of the positioned children. """ children = list(children) dirv = _unit3(dir) cnt = len(children) assert cnt >= 1, "distribute(): needs at least one child." if sizes is None: extents = [ float(abs(np.asarray(c.bounds()[1], dtype=float) @ dirv - np.asarray(c.bounds()[0], dtype=float) @ dirv)) for c in children ] else: extents = [float(s) for s in sizes] gaps = [0.0] if cnt < 2 else [extents[i] / 2 + extents[i + 1] / 2 for i in range(cnt - 1)] spc = ( ((length - sum(gaps)) / (cnt - 1)) if (length is not None and cnt > 1) else (spacing if spacing is not None else 10) ) gaps2 = [g + spc for g in gaps] positions = np.cumsum([0.0] + gaps2) start = -sum(gaps2) / 2 * dirv placed = [c.translate((start + positions[i] * dirv).tolist()) for i, c in enumerate(children)] out = placed[0] for c in placed[1:]: out = out | c return out
[docs] def xdistribute(children, spacing=None, sizes=None, length: float | None = None): """Distribute distinct children along the X axis (BOSL2 xdistribute()).""" return distribute(children, spacing=spacing, sizes=sizes, dir=RIGHT, length=length)
[docs] def ydistribute(children, spacing=None, sizes=None, length: float | None = None): """Distribute distinct children along the Y axis (BOSL2 ydistribute()).""" return distribute(children, spacing=spacing, sizes=sizes, dir=BACK, length=length)
[docs] def zdistribute(children, spacing=None, sizes=None, length: float | None = None): """Distribute distinct children along the Z axis (BOSL2 zdistribute()).""" return distribute(children, spacing=spacing, sizes=sizes, dir=UP, length=length)