# 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/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:`~pybosl2.shapes3d.Bosl2Solid`, :class:`~pybosl2.paths.Path2D`,
# and :class:`~pybosl2.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).
# * Path2D / Path3D -> a plain ``list`` of transformed path copies (BOSL2's function form).
# A 2-D Path2D 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 pybosl2.transforms/constants are imported at load time (so paths.py can
# pull in the mixin during its own import without a cycle); Path2D/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.
# DocCategory: Foundational
# FileGroup: BOSL2
"""Return Distributors: line/grid/ring/arc/sphere/path copiers and reflected copies."""
from __future__ import annotations
import math
from abc import ABC, abstractmethod
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, TypeVar
import numpy as np
from pybosl2._helpers import is_num, rot_from_to4, translate4
from pybosl2.constants import BACK, RIGHT, UP
from pybosl2.enums import StaggerMode
from pybosl2.points import Point
from pybosl2.transforms import axis_angle_matrix
if TYPE_CHECKING:
from collections.abc import Sequence
from pybosl2._edges_lang import Anchor
from pybosl2._shape import BaseShape as BaseShape
_CopyType = TypeVar("_CopyType", bound="Distributable")
__all__ = [
"xdistribute",
"ydistribute",
"zdistribute",
"Distributable",
"DistributableMatrix",
]
# ---------------------------------------------------------------------------
# Section: matrix helpers
# ---------------------------------------------------------------------------
# (imported from pybosl2._helpers as rot_from_to4)
def _vec3(v: Any, fill: float = 0.0) -> np.ndarray:
if is_num(v):
return np.array([float(v), float(fill), float(fill)])
arr = np.asarray(v, dtype=float)
if arr.shape[0] == 1:
return np.array([float(arr[0]), float(fill), float(fill)])
out = np.zeros(3)
n = min(arr.shape[0], 3)
out[:n] = arr[:n]
out[n:] = float(fill)
return out
# ---------------------------------------------------------------------------
# Section: copier matrix generators (BOSL2 function form, returning matrices)
# ---------------------------------------------------------------------------
def line_copies(
spacing: float | np.ndarray | None = None,
length: float | np.ndarray | None = None,
p1: Point | None = None,
p2: Point | None = None,
num_copies: int | None = None,
) -> list[np.ndarray]:
"""Return translation matrices evenly spread along a line."""
if length is not None:
ll = _vec3(length, 0.0)
elif spacing is not None and num_copies is not None:
ll = (num_copies - 1) * _vec3(spacing, 0.0)
elif p1 is not None and p2 is not None:
ll = np.asarray(p2, dtype=float) - np.asarray(p1, dtype=float)
else:
ll = None
if num_copies is not None:
cnt = int(num_copies)
elif spacing is not None and ll is not None:
cnt = int(math.floor(np.linalg.norm(ll) / np.linalg.norm(_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 or is_num(spacing) and ll is not None:
spc = ll / (cnt - 1)
else:
spc = _vec3(spacing, 0.0)
spos = _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: Point,
spacing: float | Sequence[float] | np.ndarray | None,
length: float | None,
start_pos: float | Point | None,
num_copies: int | None = None,
) -> list[np.ndarray]:
dirv = np.asarray(direction, dtype=float)
sp_pt: Point | None = None
if is_num(start_pos):
sp_pt = Point(
float(start_pos * dirv[0]),
float(start_pos * dirv[1]),
float(start_pos * dirv[2]),
)
elif start_pos is not None:
arr = np.asarray(start_pos, dtype=float)
sp_pt = Point(float(arr[0]), float(arr[1]), float(arr[2]) if arr.shape[0] > 2 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, num_copies=num_copies, length=lv, p1=sp_pt)
def xcopies(
spacing: float | None = None,
length: float | None = None,
start_pos: float | Point | None = None,
num_copies: int | None = None,
) -> list[np.ndarray]:
"""Return copies spread along the X axis."""
return _axis_copies(
RIGHT.vector,
spacing,
length,
start_pos,
num_copies=num_copies,
)
def ycopies(
spacing: float | None = None,
length: float | None = None,
start_pos: float | Point | None = None,
num_copies: int | None = None,
) -> list[np.ndarray]:
"""Return copies spread along the Y axis."""
return _axis_copies(BACK.vector, spacing, num_copies=num_copies, length=length, start_pos=start_pos)
def zcopies(
spacing: float | None = None,
length: float | None = None,
start_pos: float | Point | None = None,
num_copies: int | None = None,
) -> list[np.ndarray]:
"""Return copies spread along the Z axis."""
return _axis_copies(UP.vector, spacing, num_copies=num_copies, length=length, start_pos=start_pos)
def grid_copies(
spacing: float | Sequence[float] | np.ndarray | None = None,
size: float | Sequence[float] | np.ndarray | None = None,
stagger: bool | StaggerMode = False,
inside: Sequence[Sequence[float]] | np.ndarray | None = None,
nonzero: bool | None = None,
axes: str = "xy",
num_copies: int | Sequence[int] | np.ndarray | None = None,
) -> list[np.ndarray]:
"""Return copies laid out in a square or staggered (hex) grid."""
assert stagger in (
False,
True,
StaggerMode.ALT,
), "grid_copies(): stagger must be False, True or 'alt'."
assert len(axes) == 2, "grid_copies(): invalid axes."
assert axes[0] in "xyz", "grid_copies(): invalid axes."
assert axes[1] in "xyz", "grid_copies(): invalid axes."
assert axes[0] != axes[1], "grid_copies(): invalid axes."
ai: dict[str, int] = {"x": 0, "y": 1, "z": 2}
def permax(pt: Sequence[float]) -> np.ndarray:
out: list[float] = [0.0, 0.0, 0.0]
out[ai[axes[0]]] = pt[0]
out[ai[axes[1]]] = pt[1]
return np.array(out)
bounds: tuple[np.ndarray, np.ndarray] | None = 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])] # type: ignore[arg-type,index]
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 pybosl2.transforms import polar_to_xy
spacing = polar_to_xy(spacing, 60) if stagger is not False else [spacing, spacing] # type: ignore[arg-type,list-item]
elif isinstance(spacing, (list, tuple, np.ndarray)):
spacing = [float(spacing[0]), float(spacing[1])]
elif size is not None:
if is_num(num_copies):
spacing = [size[0] / (num_copies - 1), size[1] / (num_copies - 1)] # type: ignore[operator,list-item]
elif isinstance(num_copies, (list, tuple, np.ndarray)):
spacing = [size[0] / (num_copies[0] - 1), size[1] / (num_copies[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(num_copies):
num_copies = [int(num_copies), int(num_copies)] # type: ignore[arg-type]
elif isinstance(num_copies, (list, tuple, np.ndarray)):
num_copies = [int(num_copies[0]), int(num_copies[1])]
elif size is not None and spacing is not None:
num_copies = [
int(math.floor(size[0] / spacing[0])) + 1, # type: ignore[index]
int(math.floor(size[1] / spacing[1])) + 1, # type: ignore[index]
]
else:
num_copies = [2, 2]
spacing = np.asarray(spacing, dtype=float)
offset = spacing * (np.asarray(num_copies) - 1) / 2
def keep(pos: np.ndarray) -> bool:
if inside is None:
return True
from pybosl2.path2d import Path2D
from pybosl2.points import Point
return (
Path2D._point_in_polygon(
Point(float(pos[0]), float(pos[1])),
Path2D(inside) if not isinstance(inside, Path2D) else inside,
nonzero=bool(nonzero),
)
>= 0
)
mats: list[np.ndarray] = []
if stagger is False:
for row in range(num_copies[1]):
for col in range(num_copies[0]):
pos = np.array([col, row]) * spacing - offset
if keep(pos):
mats.append(translate4(permax(pos)))
else:
staggermod: int = 1 if stagger == StaggerMode.ALT else 0
cols1 = math.ceil(num_copies[0] / 2)
cols2 = num_copies[0] - cols1
for row in range(num_copies[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
def rot_copies(
rots: Sequence[float] | None = None,
v: Point | None = None,
center: bool | Sequence[float] = (0, 0, 0),
sa: float = 0,
offset: float = 0,
delta: Sequence[float] = (0, 0, 0),
subrot: bool = True,
num_copies: int | None = None,
) -> list[np.ndarray]:
"""Return rotated copies about an axis, optionally offset into a ring."""
assert subrot or np.linalg.norm(_vec3(delta, 0.0)) > 0, (
"rot_copies(): subrot can only be False when delta is nonzero."
)
sang = sa + offset
if num_copies is not None:
angs = [] if num_copies <= 0 else [i / num_copies * 360 + sang for i in range(num_copies)]
elif rots:
angs = [float(a) for a in rots]
else:
angs = []
cen = _vec3(center, 0.0)
deltav = _vec3(delta, 0.0)
mats = []
for angle in angs:
rot_m = np.eye(4)
rot_m[:3, :3] = axis_angle_matrix(angle, UP.vector if v is None else v)
rot_rev = np.eye(4)
rev_ang = 0 if subrot else -angle
rot_rev[:3, :3] = axis_angle_matrix(rev_ang, UP.vector if v is None else v)
m = translate4(cen) @ rot_m @ translate4(deltav) @ rot_rev @ translate4(-cen)
mats.append(m)
return mats
def xrot_copies(
rots: Sequence[float] | None = None,
center: bool | Sequence[float] = (0, 0, 0),
sa: float = 0,
radius: float | None = None,
diameter: float | None = None,
subrot: bool = True,
num_copies: int | None = None,
) -> list[np.ndarray]:
"""Return rotated copies around the X axis, optionally into a ring of radius *radius*."""
rr = radius if radius is not None else (diameter / 2 if diameter is not None else 0)
return rot_copies(
rots=rots,
v=RIGHT.vector,
center=center,
num_copies=num_copies,
sa=sa,
delta=[0, rr, 0],
subrot=subrot,
)
def yrot_copies(
rots: Sequence[float] | None = None,
center: bool | Sequence[float] = (0, 0, 0),
sa: float = 0,
radius: float | None = None,
diameter: float | None = None,
subrot: bool = True,
num_copies: int | None = None,
) -> list[np.ndarray]:
"""Return rotated copies around the Y axis, optionally into a ring of radius *radius*."""
rr = radius if radius is not None else (diameter / 2 if diameter is not None else 0)
return rot_copies(
rots=rots,
v=BACK.vector,
center=center,
num_copies=num_copies,
sa=sa,
delta=[-rr, 0, 0],
subrot=subrot,
)
def zrot_copies(
rots: Sequence[float] | None = None,
center: bool | Sequence[float] = (0, 0, 0),
sa: float = 0,
radius: float | None = None,
diameter: float | None = None,
subrot: bool = True,
num_copies: int | None = None,
) -> list[np.ndarray]:
"""Return rotated copies around the Z axis, optionally into a ring of radius *radius*."""
rr: float = radius if radius is not None else (diameter / 2 if diameter is not None else 0)
return rot_copies(
rots=rots,
v=UP.vector,
center=center,
sa=sa,
delta=[rr, 0, 0],
subrot=subrot,
num_copies=num_copies,
)
def arc_copies(
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: bool = True,
num_copies: int = 6,
) -> list[np.ndarray]:
"""Return copies spread along an (elliptical) arc in the XY plane."""
rxv = (
radius_x
if radius_x is not None
else (
diameter_x / 2
if diameter_x is not None
else (radius if radius is not None else (diameter / 2 if diameter is not None else 1))
)
)
ryv = (
radius_y
if radius_y is not None
else (
diameter_y / 2
if diameter_y is not None
else (radius if radius is not None else (diameter / 2 if diameter is not None else 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) / (num_copies - 1 + extra_n)
mats = []
for i in range(num_copies):
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
)
rot_mat = np.eye(4)
rot_mat[:3, :3] = axis_angle_matrix(ang2, UP.vector)
mats.append(translate4(pos) @ rot_mat)
return mats
def sphere_copies(
num_copies: int = 100,
radius: float | None = None,
diameter: float | None = None,
cone_ang: float = 90,
scale: Sequence[float] = (1, 1, 1),
perp: bool = True,
) -> list[np.ndarray]:
"""Return copies spread over a sphere/ellipsoid by the golden-spiral method."""
rr = radius if radius is not None else (diameter / 2 if diameter is not None else 50)
cnt = math.ceil(num_copies / (cone_ang / 180))
scalev = _vec3(scale, 1.0)
mats = []
for x in range(num_copies):
theta = (180 * (1 + math.sqrt(5)) * (x + 0.5)) % 360
phi = math.degrees(math.acos(1 - 2 * (x + 0.5) / cnt))
th, ph = math.radians(theta), math.radians(phi)
xyz = np.array(
[
rr * math.sin(ph) * math.cos(th),
rr * math.sin(ph) * math.sin(th),
rr * math.cos(ph),
]
)
pos = xyz * scalev
m = translate4(pos) @ (rot_from_to4(UP, xyz) if perp else np.eye(4))
mats.append(m)
return mats
def path_copies(
path: Sequence[Sequence[float]],
spacing: float | None = None,
start_pos: float | None = None,
dist: Sequence[float] | None = None,
rotate_children: bool = True,
closed: bool | None = None,
num_copies: int | None = None,
) -> list[np.ndarray]:
"""Return copies placed along *path*, oriented to it."""
from pybosl2.path2d import Path2D
from pybosl2.path3d import Path3D
pts = [list(map(float, p)) for p in path]
closed = bool(getattr(path, "closed", False)) if closed is None else closed
dim = len(pts[0]) if pts else 2
length = (Path3D(pts) if dim == 3 else Path2D(pts)).perimeter()
if dist is not None:
distances = sorted(float(x) for x in dist)
elif start_pos is not None:
if num_copies is not None and spacing is not None:
distances = [start_pos + i * spacing for i in range(num_copies)]
elif num_copies is not None:
distances = list(np.linspace(start_pos, length, num_copies))
else:
distances = list(np.arange(start_pos, length, spacing))
elif num_copies is not None and spacing is None:
distances = list(np.linspace(0, length, num_copies, endpoint=not closed))
else:
assert spacing is not None
cnt = num_copies if num_copies 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, "path_copies(): copies don't fit on the path."
assert 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 = (Path3D(pts) if dim == 3 else Path2D(pts)).cut_points(distances, closed=closed, direction=True)
planar = len(pts[0]) == 2
mats = []
for cp in cutlist:
base = translate4(cp.point)
if not rotate_children:
rotm = np.eye(4)
elif planar:
rotm = rot_from_to4([0, 1, 0], _vec3(cp.normal, 0.0))
else:
xv = np.asarray(cp.direction, dtype=float)
n = float(np.linalg.norm(xv))
xv = xv / n if n else xv
zv = np.asarray(cp.normal, dtype=float)
n = float(np.linalg.norm(zv))
zv = zv / n if n else zv
yv = np.cross(zv, xv)
n = float(np.linalg.norm(yv))
yv = yv / n if n else yv
rotm = np.eye(4)
rotm[:3, 0], rotm[:3, 1], rotm[:3, 2] = xv, yv, zv
mats.append(base @ rotm)
return mats
def mirror_copy(
v: Sequence[float] = (0, 0, 1),
offset: float = 0,
center: bool | list[float] | None = None,
) -> list[np.ndarray]:
"""Return the original plus a mirrored copy across the plane with normal *v*."""
nv = np.asarray(v, dtype=float)
nv_norm = float(np.linalg.norm(nv))
nv = nv / nv_norm if nv_norm else nv
cen = (
_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)) # type: ignore[operator]
)
off = nv * offset
mirror_m = np.eye(4)
mirror_m[:3, :3] = np.eye(3) - 2 * np.outer(nv, nv)
return [
translate4(off),
translate4(np.asarray(cen)) @ mirror_m @ translate4(-np.asarray(cen)) @ translate4(off),
]
def xflip_copy(offset: float = 0, x: float = 0) -> list[np.ndarray]:
"""Return the original plus a copy mirrored across the X=*x* plane."""
return mirror_copy(v=[1, 0, 0], offset=offset, center=[x, 0, 0])
def yflip_copy(offset: float = 0, y: float = 0) -> list[np.ndarray]:
"""Return the original plus a copy mirrored across the Y=*y* plane."""
return mirror_copy(v=[0, 1, 0], offset=offset, center=[0, y, 0])
def zflip_copy(offset: float = 0, z: float = 0) -> list[np.ndarray]:
"""Return the original plus a copy mirrored across the Z=*z* plane."""
return mirror_copy(v=[0, 0, 1], offset=offset, center=[0, 0, z])
# ---------------------------------------------------------------------------
# Section: Distributable mixin
# ---------------------------------------------------------------------------
[docs]
class DistributableMatrix:
"""Return Matrix-generating copiers -- each returns ``list[np.ndarray]`` (4x4 matrices)."""
line_copies = staticmethod(line_copies) # -> list[np.ndarray]
xcopies = staticmethod(xcopies) # -> list[np.ndarray]
ycopies = staticmethod(ycopies) # -> list[np.ndarray]
zcopies = staticmethod(zcopies) # -> list[np.ndarray]
grid_copies = staticmethod(grid_copies) # -> list[np.ndarray]
rot_copies = staticmethod(rot_copies) # -> list[np.ndarray]
xrot_copies = staticmethod(xrot_copies) # -> list[np.ndarray]
yrot_copies = staticmethod(yrot_copies) # -> list[np.ndarray]
zrot_copies = staticmethod(zrot_copies) # -> list[np.ndarray]
arc_copies = staticmethod(arc_copies) # -> list[np.ndarray]
sphere_copies = staticmethod(sphere_copies) # -> list[np.ndarray]
path_copies = staticmethod(path_copies) # -> list[np.ndarray]
mirror_copy = staticmethod(mirror_copy) # -> list[np.ndarray]
xflip_copy = staticmethod(xflip_copy) # -> list[np.ndarray]
yflip_copy = staticmethod(yflip_copy) # -> list[np.ndarray]
zflip_copy = staticmethod(zflip_copy) # -> list[np.ndarray]
[docs]
class Distributable(ABC):
"""Return Mixin adding the distributors.scad copiers as methods.
Inherited by :class:`~pybosl2.shapes3d.Bosl2Solid`, :class:`~pybosl2.paths.Path2D`, and
:class:`~pybosl2.paths.Path3D`. Each copier returns a ``list`` of positioned copies;
callers union, hull, or combine them as needed.
"""
@abstractmethod
def _distribute(self, mats: list[np.ndarray]) -> list[_CopyType]: # pragma: no cover
raise NotImplementedError("Distributable subclasses must implement _distribute().")
# -- instance methods ------------------------------------------------------
[docs]
def move_and_copy(self, vectors: list[Point] | None = None) -> list[_CopyType]:
"""Copy to each offset in *vectors* (BOSL2 move_copies).
Args:
vectors: A list of :class:`~pybosl2.points.Point` offsets, or ``None``
for a single copy at the origin.
Returns:
The union (or list for paths) of copies at each offset.
"""
offsets = vectors if vectors is not None else [Point(0, 0, 0)]
return self._distribute([translate4(pos) for pos in offsets])
[docs]
def line_copies(
self,
spacing: float | None = None,
length: float | None = None,
p1: Point | None = None,
p2: Point | None = None,
num_copies: int | None = None,
) -> list[_CopyType]:
"""Return copies spread along a line."""
return self._distribute(line_copies(spacing, length, p1, p2, num_copies=num_copies))
[docs]
def xcopies(
self,
spacing: float | None = None,
length: float | None = None,
start_pos: float | Point | None = None,
num_copies: int | None = None,
) -> list[_CopyType]:
"""Return copies spread along the X axis."""
return self._distribute(_axis_copies(RIGHT.vector, spacing, length, start_pos, num_copies=num_copies))
[docs]
def ycopies(
self,
spacing: float | None = None,
length: float | None = None,
start_pos: float | Point | None = None,
num_copies: int | None = None,
) -> list[_CopyType]:
"""Return copies spread along the Y axis."""
return self._distribute(_axis_copies(BACK.vector, spacing, length, start_pos, num_copies=num_copies))
[docs]
def zcopies(
self,
spacing: float | None = None,
length: float | None = None,
start_pos: float | Point | None = None,
num_copies: int | None = None,
) -> list[_CopyType]:
"""Return copies spread along the Z axis."""
return self._distribute(_axis_copies(UP.vector, spacing, length, start_pos, num_copies=num_copies))
[docs]
def grid_copies(
self,
spacing: float | Sequence[float] | np.ndarray | None = None,
size: float | Sequence[float] | np.ndarray | None = None,
stagger: bool | StaggerMode = False,
inside: Sequence[Sequence[float]] | np.ndarray | None = None,
nonzero: bool | None = None,
axes: str = "xy",
num_copies: int | Sequence[int] | np.ndarray | None = None,
) -> list[_CopyType]:
"""Return copies in a square or staggered (hex) grid."""
return self._distribute(grid_copies(spacing, size, stagger, inside, nonzero, axes, num_copies))
[docs]
def rot_copies(
self,
rots: Sequence[float] | None = None,
v: Point | None = None,
center: bool | Sequence[float] = (0, 0, 0),
sa: float = 0,
offset: float = 0,
delta: Sequence[float] = (0, 0, 0),
subrot: bool = True,
num_copies: int | None = None,
) -> list[_CopyType]:
"""Rotated copies about an axis (optionally into a ring via *delta*)."""
return self._distribute(rot_copies(rots, v, center, sa, offset, delta, subrot, num_copies=num_copies))
[docs]
def xrot_copies(
self,
rots: Sequence[float] | None = None,
center: bool | Sequence[float] = (0, 0, 0),
sa: float = 0,
radius: float | None = None,
diameter: float | None = None,
subrot: bool = True,
num_copies: int | None = None,
) -> list[_CopyType]:
"""Rotated copies around the X axis."""
return self._distribute(xrot_copies(rots, center, sa, radius, diameter, subrot, num_copies=num_copies))
[docs]
def yrot_copies(
self,
rots: Sequence[float] | None = None,
center: bool | Sequence[float] = (0, 0, 0),
sa: float = 0,
radius: float | None = None,
diameter: float | None = None,
subrot: bool = True,
num_copies: int | None = None,
) -> list[_CopyType]:
"""Rotated copies around the Y axis."""
return self._distribute(yrot_copies(rots, center, sa, radius, diameter, subrot, num_copies=num_copies))
[docs]
def zrot_copies(
self,
rots: Sequence[float] | None = None,
center: bool | Sequence[float] = (0, 0, 0),
sa: float = 0,
radius: float | None = None,
diameter: float | None = None,
subrot: bool = True,
num_copies: int | None = None,
) -> list[_CopyType]:
"""Rotated copies around the Z axis."""
return self._distribute(zrot_copies(rots, center, sa, radius, diameter, subrot, num_copies=num_copies))
[docs]
def arc_copies(
self,
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: bool = True,
num_copies: int = 6,
) -> list[_CopyType]:
"""Return copies spread along an (elliptical) arc in the XY plane."""
return self._distribute(
arc_copies(
radius,
radius_x,
radius_y,
diameter,
diameter_x,
diameter_y,
sa,
ea,
rot,
num_copies=num_copies,
)
)
[docs]
def sphere_copies(
self,
num_copies: int = 100,
radius: float | None = None,
diameter: float | None = None,
cone_ang: float = 90,
scale: Sequence[float] = (1, 1, 1),
perp: bool = True,
) -> list[_CopyType]:
"""Return copies spread over a sphere/ellipsoid surface."""
return self._distribute(sphere_copies(num_copies, radius, diameter, cone_ang, scale, perp))
[docs]
def path_copies(
self,
path: Sequence[Sequence[float]],
spacing: float | None = None,
start_pos: float | None = None,
dist: Sequence[float] | None = None,
rotate_children: bool = True,
closed: bool | None = None,
num_copies: int | None = None,
) -> list[_CopyType]:
"""Return copies placed along *path*, oriented to it."""
return self._distribute(
path_copies(
path,
spacing,
start_pos,
dist,
rotate_children,
closed,
num_copies=num_copies,
)
)
[docs]
def mirror_copy(
self,
v: Sequence[float] = (0, 0, 1),
offset: float = 0,
center: bool | list[float] | None = None,
) -> list[_CopyType]:
"""Return 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: float = 0, x: float = 0) -> list[_CopyType]:
"""Return This object plus a copy mirrored across the X=*x* plane."""
return self._distribute(xflip_copy(offset, x))
[docs]
def yflip_copy(self, offset: float = 0, y: float = 0) -> list[_CopyType]:
"""Return This object plus a copy mirrored across the Y=*y* plane."""
return self._distribute(yflip_copy(offset, y))
[docs]
def zflip_copy(self, offset: float = 0, z: float = 0) -> list[_CopyType]:
"""Return 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]
@staticmethod
def distribute(
children: list[BaseShape],
spacing: float | None = None,
sizes: list[float] | None = None,
dir: Anchor | Point = RIGHT, # noqa: A002
length: float | None = None,
) -> BaseShape:
"""Space a list of distinct objects along *dir* so they don't overlap.
Unlike the copiers, this lays out several different children.
*sizes* gives each child's extent along *dir*; auto-computed from bounding boxes if omitted.
Args:
children: Objects with ``translate()``, ``bounds()``, and CSG operators.
spacing: Gap between adjacent children.
sizes: Per-child extent along *dir*.
dir: Direction vector (default +X).
length: Total length to fill.
Returns:
The union of all positioned children.
"""
children = list(children)
dir_arr = _vec3(dir, 0.0) if is_num(dir) else np.asarray(dir, dtype=float)
dir_norm = float(np.linalg.norm(dir_arr))
dirv = dir_arr / dir_norm if dir_norm else dir_arr
cnt = len(children)
assert cnt >= 1, "distribute(): needs at least one child."
if sizes is None:
extents = [
abs(float(np.dot(np.asarray(c.bounds()[1]), dirv) - np.dot(np.asarray(c.bounds()[0]), 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
# -- module-level convenience wrappers for the static distribute ---------------
# (the full implementation is on Distributable, above)
[docs]
def xdistribute(
children: list[BaseShape],
spacing: float | None = None,
sizes: list[float] | None = None,
length: float | None = None,
) -> BaseShape:
"""Distribute distinct children along the X axis.
Examples:
.. pythonscad-example::
from pybosl2.solid import cuboid
from pybosl2.distributors import xdistribute
xdistribute(spacing=15, children=[cuboid([5, 5, 20]) for _ in range(5)]).show()
"""
return Distributable.distribute(children, spacing=spacing, sizes=sizes, dir=RIGHT, length=length)
[docs]
def ydistribute(
children: list[BaseShape],
spacing: float | None = None,
sizes: list[float] | None = None,
length: float | None = None,
) -> BaseShape:
"""Distribute distinct children along the Y axis."""
return Distributable.distribute(children, spacing=spacing, sizes=sizes, dir=BACK, length=length)
[docs]
def zdistribute(
children: list[BaseShape],
spacing: float | None = None,
sizes: list[float] | None = None,
length: float | None = None,
) -> BaseShape:
"""Distribute distinct children along the Z axis."""
return Distributable.distribute(children, spacing=spacing, sizes=sizes, dir=UP, length=length)