# 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/skin.py
# Pure-Python port of the surface generators from BOSL2's skin.scad, building
# VNFs (bosl2/vnf.py) that render via polyhedron(). No osuse()/BOSL2 runtime
# dependency.
#
# Ported (function forms):
# * sweep(shape, transforms) -- skin a shape through a list of 4x4 transforms
# * path_sweep(shape, path) -- sweep along a 2-D/3-D path. Frame methods
# "incremental" (rotation-minimizing frame via the double-reflection
# algorithm), "manual" (caller normals) and "natural" (the curve's own
# normal); twist, per-point/interpolated scale, open & closed paths,
# flat caps on/off, user tangents, and the transforms=True mode.
# * skin(profiles, slices) -- blend a stack of profiles (methods "direct"
# and "reindex")
# * linear_sweep(region, h) -- extrude an outline with twist/scale/shift
# * rotate_sweep(shape, angle) -- revolve a profile around Z
# * spiral_sweep(poly, h, r) -- sweep a cross-section along a helix
# * path_sweep2d(shape, path) -- sweep a 2-D shape along a 2-D path
# * rot_resample(rotlist, n) -- resample a transform list along its screw motion
# * subdivide_and_slice() / slice_profiles() -- the skin() profile helpers
#
# NOT ported (they depend on machinery this pure-Python port does not
# implement, and nothing in the toolkit needs them): the texture engine
# (texture()/tex_* options), the attachment/anchor system (anchors,
# sweep_attach()), rounded/chamfered "fancy" end caps, region shapes with
# holes (use a native linear_extrude/CSG), the skin() "distance"/
# "fast_distance"/"tangent" vertex-matching methods (and associate_vertices()),
# and spiral_sweep()'s lead-in tapers.
#
# FileSummary: Skin/sweep/revolve 2-D profiles into VNF surfaces (BOSL2 skin.scad).
# FileGroup: BOSL2
from __future__ import annotations
import math
from typing import Sequence, Union
import numpy as np
from bosl2._helpers import translate4, zrot4
from bosl2.constants import Vec3
from bosl2.transforms import apply as _apply
from bosl2.transforms import rot_about_axis, rot_decode, rot_inverse
from bosl2.vnf import VNF
UP = Vec3([0.0, 0.0, 1.0])
BACK = Vec3([0.0, 1.0, 0.0])
def _u(v: Sequence[float]) -> np.ndarray:
a = np.asarray(v, dtype=float)
sides = float(np.linalg.norm(a))
return a / sides if sides else a
def _u_nd(v: np.ndarray) -> np.ndarray:
sides = float(np.linalg.norm(v))
return v / sides if sides else v
[docs]
def path3d(path: Sequence[Sequence[float]]) -> list[list[float]]:
"""Pad a 2-D (or 3-D) point list to 3-D with z=0.
The coordinates are converted to plain Python floats, not left as whatever the input held: a
numpy row in would otherwise leak ``np.float64`` scalars out of an annotation that promises
``float``, and those raise SystemError/TypeError at the native FFI boundary (see the note in
bosl2/paths.py).
"""
return [[float(p[0]), float(p[1]), float(p[2]) if len(p) > 2 else 0.0] for p in path]
[docs]
def clockwise_polygon(poly: Sequence[Sequence[float]]) -> list[Sequence[float]]:
"""*poly* wound clockwise (reversed if its signed area is positive/CCW)."""
from bosl2.paths import Path
return list(poly) if Path._polygon_area(poly, signed=True) <= 0 else list(reversed(list(poly)))
# (imported from bosl2._helpers as translate4, zrot4)
def _scale4(s: Sequence[float]) -> np.ndarray:
m = np.eye(4)
m[0, 0], m[1, 1] = s[0], s[1]
if len(s) > 2:
m[2, 2] = s[2]
return m
def _xrot4(a: float) -> np.ndarray:
radius = math.radians(a)
c, s = math.cos(radius), math.sin(radius)
m = np.eye(4)
m[1, 1], m[1, 2], m[2, 1], m[2, 2] = c, -s, s, c
return m
def _segs(radius: float) -> int:
"""
OpenSCAD's default $fa=12/$fs=2 facet count for a circle of radius *radius* (BOSL2 segs()).
"""
return max(5, math.ceil(min(360.0 / 12.0, (2 * math.pi * abs(radius)) / 2.0)))
[docs]
def frame_map(
x: Sequence[float] | None = None,
y: Sequence[float] | None = None,
z: Sequence[float] | None = None,
) -> np.ndarray:
"""The 4x4 rotation whose columns are the given orthonormal axes (BOSL2 frame_map()).
Give any two of x/y/z (as 3-vectors); the third is filled in by the cross product.
"""
xu = _u(x) if x is not None else None
yu = _u(y) if y is not None else None
zu = _u(z) if z is not None else None
if xu is None:
assert yu is not None and zu is not None
xu = np.cross(yu, zu)
elif yu is None:
assert zu is not None and xu is not None
yu = np.cross(zu, xu)
elif zu is None:
assert xu is not None and yu is not None
zu = np.cross(xu, yu)
assert xu is not None and yu is not None and zu is not None
m = np.eye(4)
m[:3, :3] = np.column_stack([xu, yu, zu])
return m
#: A BOSL2 ``caps=`` argument: one bool for both ends, a ``[cap1, cap2]`` pair for each end
#: separately, or None to take the call's own default. Every sweep/skin entry point accepts all
#: three spellings, exactly as BOSL2 does -- see :func:`_norm_caps`.
CapsSpec = Union[bool, Sequence[bool], None]
def _norm_caps(caps: CapsSpec, closed: bool = False, default: bool = True) -> list[bool]:
"""Normalize a :data:`CapsSpec` to a plain ``[cap1, cap2]`` bool pair.
A single bool (or numpy bool) caps both ends alike, a 2-sequence caps each end separately,
and None falls back to *default*. A *closed* sweep loops back on itself and so has no ends to
cap -- it is always uncapped, whatever was asked for.
"""
if closed:
return [False, False]
if caps is None:
return [default, default]
if isinstance(caps, (list, tuple, np.ndarray)):
return [bool(caps[0]), bool(caps[1])]
return [bool(caps), bool(caps)]
[docs]
def sweep(
shape: Sequence[Sequence[float]],
transforms: Sequence[Sequence[Sequence[float]]],
closed: bool = False,
caps: CapsSpec = None,
style: str = "min_edge",
) -> VNF:
"""Apply each 4x4 transform to the 2-D *shape* and skin the resulting profiles into a VNF.
Args:
shape: a 2-D polygon (list of [x, y] points)
transforms: list of 4x4 matrices, one per cross section along the path
closed: the sweep loops back on itself (no caps)
caps: cap the open ends (default: True/True open, none closed); bool or [bool, bool]
style: vnf_vertex_array quad-subdivision style
"""
shape3 = np.asarray(path3d(shape), dtype=float)
assert len(shape3) >= 3, "shape must be a path of at least 3 points."
flatcaps = _norm_caps(caps, closed=closed)
ntrans = len(transforms)
assert ntrans >= 2, "transforms must be length 2 or more."
hi = ntrans - (0 if closed else 1)
points = [np.asarray(_apply(transforms[i % ntrans], shape3), dtype=float) for i in range(hi + 1)]
return VNF.vertex_array(points, cap1=flatcaps[0], cap2=flatcaps[1], col_wrap=True, style=style)
[docs]
def path_sweep(
shape: Sequence[Sequence[float]],
path: Sequence[Sequence[float]],
method: str = "incremental",
normal: Sequence[float] | Sequence[Sequence[float]] | None = None,
closed: bool = False,
twist: float = 0.0,
twist_by_length: bool = True,
scale: tuple[float, float] = (1.0, 1.0),
scale_by_length: bool = True,
symmetry: int = 1,
last_normal: Sequence[float] | None = None,
tangent: Sequence[Sequence[float]] | None = None,
uniform: bool = True,
relaxed: bool = False,
caps: CapsSpec = None,
style: str = "min_edge",
transforms: bool = False,
):
"""Sweep the 2-D *shape* along the 2-D/3-D *path*, returning a VNF (or the transform list).
*method* orients the cross section: "incremental" (rotation-minimizing frame), "manual"
(using *normal* as a per-point normal list), or "natural" (the path's own normal). *twist*
(degrees) and *scale* (scalar, 2-vector, per-point vector, or Nx2) are interpolated along the
path. See BOSL2 path_sweep() for the full semantics.
Examples:
Sweeping a small square profile along a helical path into a solid:
.. pythonscad-example::
square = [[-3, -3], [3, -3], [3, 3], [-3, 3]]
helix = [[10 * math.cos(t), 10 * math.sin(t), t * 3] for t in np.linspace(0, 3 * math.pi, 40)]
path_sweep(square, helix).polyhedron().show()
"""
from bosl2.paths import Path # local: keep the import graph acyclic
caps = _norm_caps(caps, closed=closed) # a closed loop has no ends to cap
patharr = np.asarray(path3d(path), dtype=float)
L = len(patharr)
assert L >= 2, "path must have at least 2 points."
if tangent is not None:
tangents = np.array([_u(t) for t in path3d(tangent)])
else:
tangents = np.asarray(Path._path_tangents(patharr, closed=closed, uniform=uniform), dtype=float)
# Resolve the initial/per-point normal.
if normal is not None:
narr = np.asarray(normal, dtype=float)
if narr.ndim == 2:
normals = np.array([_u(n) for n in narr])
normal_single = normals[0]
else:
normal_single = _u_nd(narr)
normals = np.tile(normal_single, (L, 1))
else:
normal_single = np.asarray(
(BACK if (method == "incremental" and abs(tangents[0][2]) > 1 / math.sqrt(2)) else UP),
dtype=float,
)
normals = np.tile(normal_single, (L, 1))
if twist_by_length:
tpathfrac = np.asarray(Path._path_length_fractions(patharr, closed), dtype=float)
else:
tpathfrac = np.array([i / (L - (0 if closed else 1)) for i in range(L + 1)])
if scale_by_length:
spathfrac = np.asarray(Path._path_length_fractions(patharr, closed), dtype=float)
else:
spathfrac = np.array([i / (L - (0 if closed else 1)) for i in range(L + 1)])
# Resolve the per-cross-section scale [sx, sy].
if np.isscalar(scale) or (np.ndim(scale) == 1 and len(scale) == 2):
s = [float(scale), float(scale)] if np.isscalar(scale) else [float(scale[0]), float(scale[1])] # type: ignore[arg-type]
if not scale_by_length:
scalevals = [
[float(v) for v in ((1 - i / (L - 1)) * np.array([1.0, 1.0]) + (i / (L - 1)) * np.array(s))]
for i in range(L)
]
else:
scalevals = [[float(v) for v in ((1 - f) * np.array([1.0, 1.0]) + f * np.array(s))] for f in spathfrac[:L]] # type: ignore[arg-type]
else:
scalevals = [[float(x), float(x)] if np.isscalar(x) else [float(x[0]), float(x[1])] for x in scale] # type: ignore[arg-type]
scale_list = [_scale4([sv[0], sv[1], 1.0]) for sv in scalevals]
if closed:
scale_list.append(_scale4([scalevals[0][0], scalevals[0][1], 1.0]))
nprofiles = L + (1 if closed else 0)
if method == "incremental":
t0 = tangents[0]
radius = normal_single - (normal_single @ t0) * t0
cur = frame_map(y=radius, z=t0)
rotations = []
for i in range(nprofiles):
rotations.append(cur)
if i < nprofiles - 1:
v1 = patharr[(i + 1) % L] - patharr[i % L]
c1 = float(v1 @ v1)
rL = radius - 2 * (v1 @ radius) / c1 * v1
tL = tangents[i % L] - 2 * (v1 @ tangents[i % L]) / c1 * v1
v2 = tangents[(i + 1) % L] - tL
c2 = float(v2 @ v2)
radius = rL - (2 / c2) * (v2 @ rL) * v2
cur = frame_map(y=radius, z=tangents[(i + 1) % L])
if closed:
reference = rotations[0]
elif last_normal is None:
reference = rotations[-1]
else:
lt = tangents[-1]
ln = np.asarray(last_normal, dtype=float)
reference = frame_map(y=ln - (ln @ lt) * lt, z=lt)
mismatch = rotations[-1][:3, :3].T @ reference[:3, :3]
correction_twist = math.degrees(math.atan2(mismatch[1][0], mismatch[0][0]))
twistfix = correction_twist % (360 / symmetry)
unscaled = [translate4(patharr[i]) @ rotations[i] @ zrot4((twistfix - twist) * tpathfrac[i]) for i in range(L)]
if closed:
unscaled.append(
translate4(patharr[0])
@ rotations[0]
@ zrot4(-correction_twist + correction_twist % (360 / symmetry) - twist)
)
elif method == "manual":
unscaled = []
for i in range(nprofiles):
ni, ti = normals[i % L], tangents[i % L]
if relaxed:
ynormal, znormal = ni, ti - (ni @ ti) * ni
else:
ynormal, znormal = ni - (ni @ ti) * ti, ti
unscaled.append(translate4(patharr[i % L]) @ frame_map(y=ynormal, z=znormal) @ zrot4(-twist * tpathfrac[i]))
elif method == "natural":
pathnormal = np.asarray(Path._path_normals(patharr, tangents, closed), dtype=float)
unscaled = [
translate4(patharr[i % L])
@ frame_map(x=pathnormal[i % L], z=tangents[i % L])
@ zrot4(-twist * tpathfrac[i])
for i in range(nprofiles)
]
else:
raise AssertionError(f"Unknown method {method!r} (use incremental/manual/natural).")
transform_list = [unscaled[i] @ scale_list[i] for i in range(len(unscaled))]
if transforms:
return transform_list
shp = clockwise_polygon(shape)
return sweep(shp, transform_list, closed=False, caps=caps, style=style)
# ---------------------------------------------------------------------------------------------
# skin() -- blend a stack of profiles into a surface
# ---------------------------------------------------------------------------------------------
def _reindex_polygon(reference: Sequence[Sequence[float]], poly: Sequence[Sequence[float]]) -> list[list[float]]:
"""Circularly rotate *poly*'s vertices to best line up with *reference* (BOSL2 reindex_polygon).
Both must be equal-length point lists. Picks the rotation minimizing the summed vertex
distance. Winding is not adjusted here (the profiles skin() feeds in are already 3-D).
"""
ref = np.asarray(reference, dtype=float)
p = np.asarray(poly, dtype=float)
sides = len(ref)
best_k, best_val = 0, None
for k in range(sides):
val = float(np.sum(np.linalg.norm(ref - np.roll(p, -k, axis=0), axis=1)))
if best_val is None or val < best_val:
best_val, best_k = val, k
return np.roll(p, -best_k, axis=0).tolist()
[docs]
def slice_profiles(profiles: Sequence[Sequence[float]], slices: int, closed: bool = False) -> list[list[float]]:
"""Interpolate *slices* extra profiles between each consecutive pair (BOSL2 slice_profiles()).
*slices* is a count (or a per-segment list). The profiles must all be equal-length point
lists; the interpolation is vertex-by-vertex."""
sides = len(profiles)
nseg = sides - (0 if closed else 1)
count = list(slices) if isinstance(slices, (list, tuple, np.ndarray)) else [slices] * nseg
out = []
for i in range(nseg):
a = np.asarray(profiles[i], dtype=float)
b = np.asarray(profiles[(i + 1) % sides], dtype=float)
steps = int(count[i]) + 1
for k in range(steps): # lerpn(..., endpoint=False)
out.append((a + (b - a) * (k / steps)).tolist())
if not closed:
out.append([list(p) for p in profiles[-1]])
return out
[docs]
def skin(
profiles: Sequence[Sequence[Sequence[float]]],
slices: int,
refine: float = 1.0,
method: str = "direct",
sampling: str | None = None,
caps: CapsSpec = None,
closed: bool = False,
style: str = "min_edge",
z: Sequence[float] | None = None,
) -> VNF:
"""Blend a stack of 2-D/3-D profiles into a skinned surface, returning a VNF (BOSL2 skin()).
Consecutive profiles are connected vertex-to-vertex; *slices* extra interpolated profiles are
inserted between each pair to smooth the transition. Profiles of differing point counts are
resampled up to the largest (via :meth:`Path._subdivide_path`).
Args:
profiles: list of >= 2 closed profiles (each a list of points). If 2-D, give matching *z*.
slices: number of interpolated profiles inserted between each pair (int or per-gap list)
refine: subdivide every profile by this factor before skinning (default 1)
method: "direct" (connect vertex i to vertex i) or "reindex" (rotate each profile to
best-align with the previous). The "distance"/"tangent" vertex-matching methods
are not ported.
sampling: "length" or "segment" resampling (default "length")
caps: cap the ends (default: True for open, False for closed); bool or [bool, bool]
closed: the stack loops back to the first profile (default False)
style: vnf_vertex_array quad-subdivision style
z: per-profile Z heights, required when the profiles are 2-D
Examples:
Skinning a round profile up to a square one (a lofted transition):
.. pythonscad-example::
circle = [[6 * math.cos(t), 6 * math.sin(t)] for t in np.linspace(0, 2 * math.pi, 24, endpoint=False)]
square = [[-8, -8], [8, -8], [8, 8], [-8, 8]]
skin([circle, square], slices=20, method="reindex", z=[0, 25]).polyhedron().show()
"""
profiles = [list(p) for p in profiles]
sides = len(profiles)
assert sides > 1, "skin() needs at least two profiles."
profcount = sides - (0 if closed else 1)
fullcaps = _norm_caps(caps, closed=closed)
refine_list = list(refine) if isinstance(refine, (list, tuple)) else [refine] * sides
method_list = list(method) if isinstance(method, (list, tuple)) else [method] * profcount
for m in method_list:
assert m in (
"direct",
"reindex",
), f"skin(): only the 'direct' and 'reindex' methods are ported (got {m!r})."
sampling = sampling if sampling is not None else "length"
dim = len(profiles[0][0])
if dim == 2:
assert z is not None and len(z) == sides, "skin(): 2-D profiles need a matching-length z list."
profiles = [[[pt[0], pt[1], z[i]] for pt in profiles[i]] for i in range(sides)]
from bosl2.paths import Path # local: keep the import graph acyclic
maxlen = max(refine_list[i] * len(profiles[i]) for i in range(sides))
resampled = [Path._subdivide_path(profiles[i], sides=maxlen, closed=True, method=sampling) for i in range(sides)]
fixedprof = [resampled[0]]
for i in range(1, sides):
if method[i - 1] == "direct":
fixedprof.append(resampled[i])
else:
fixedprof.append(_reindex_polygon(fixedprof[i - 1], resampled[i]))
sliced = slice_profiles(fixedprof, slices, closed)
grid = sliced if not closed else sliced + [sliced[0]]
vnf = VNF.vertex_array(grid, cap1=fullcaps[0], cap2=fullcaps[1], col_wrap=True, style=style)
return vnf if vnf.volume() >= 0 else vnf.reverse()
# ---------------------------------------------------------------------------------------------
# linear_sweep() / rotate_sweep() / spiral_sweep()
# ---------------------------------------------------------------------------------------------
[docs]
def linear_sweep(
region: Sequence[Sequence[float]],
height: float | None = None,
twist: float = 0.0,
scale=1,
shift=(0.0, 0.0),
slices: int | None = None,
caps: CapsSpec = None,
style: str = "default",
center: bool | None = None,
) -> VNF:
"""Extrude a 2-D outline to *height* with optional twist / scale / shift (BOSL2 linear_sweep()).
A single closed outline (a Path or point list) is supported -- for a region with holes use a
native ``linear_extrude`` instead. The bottom sits on Z=0 unless *center* is True.
Args:
region: the 2-D outline to extrude (a closed path)
height: extrusion height (aliases: *height*; default 1)
twist: total twist over the height, in degrees (default 0)
scale: scale of the top relative to the bottom (scalar or [x, y]; default 1)
shift: [x, y] offset of the top relative to the bottom (default [0, 0])
slices: number of intermediate layers (default: enough for ~5 deg of twist each)
caps: cap the ends (default True); bool or [bool, bool]
center: center the extrusion on Z (default False -> base on Z=0)
style: vnf_vertex_array quad-subdivision style
Examples:
A twisting, tapering square column:
.. pythonscad-example::
square = [[-10, -10], [10, -10], [10, 10], [-10, 10]]
linear_sweep(square, height=40, twist=120, scale=0.4).polyhedron().show()
"""
hh = float(height if height is not None else (height if height is not None else 1))
path = [[p[0], p[1]] for p in region]
if slices is None:
slices = max(1, math.ceil(abs(twist) / 5))
sc = [float(scale), float(scale)] if isinstance(scale, (int, float)) else [float(scale[0]), float(scale[1])]
sh = [float(shift[0]), float(shift[1])]
fullcaps = _norm_caps(caps)
z0 = -hh / 2 if center else 0.0
base = np.asarray(path3d(path), dtype=float)
verts = []
for i in range(slices + 1):
u = i / slices
m = (
translate4([sh[0] * u, sh[1] * u, z0 + hh * u])
[docs]
@ _scale4([1 + (sc[0] - 1) * u, 1 + (sc[1] - 1) * u, 1])
@ zrot4(-twist * u)
)
verts.append(np.asarray(_apply(m, base), dtype=float))
vnf = VNF.vertex_array(verts, cap1=fullcaps[0], cap2=fullcaps[1], col_wrap=True, style=style)
return vnf if vnf.volume() >= 0 else vnf.reverse()
def rotate_sweep(
shape: Sequence[Sequence[float]],
angle: float = 360.0,
caps: CapsSpec = None,
closed: bool | None = None,
style: str = "min_edge",
start: float = 0.0,
) -> VNF:
"""Revolve a 2-D *shape* (in the X+ half-plane, x=radius, y=height) around the Z axis (BOSL2 rotate_sweep()).
A closed *shape* profile makes a solid of revolution; an open path with *caps* is first closed
to the axis. A full 360-degree revolution loops seamlessly; a partial angle end-caps the sweep.
Args:
shape: the 2-D profile to revolve (x >= 0)
angle: revolution angle in degrees, 0 < angle <= 360 (default 360)
caps: end-cap a partial revolution / close an open profile to the axis (default: angle < 360)
closed: legacy inverse of *caps* (give one or the other)
style: vnf_vertex_array quad-subdivision style
start: starting angle in degrees (default 0)
Examples:
Revolving a rounded profile into a spool:
.. pythonscad-example::
profile = [[4, -10], [12, -10], [12, -6], [7, -2], [7, 2], [12, 6], [12, 10], [4, 10]]
rotate_sweep(profile, 360).polyhedron().show()
"""
assert 0 < angle <= 360, "rotate_sweep(): angle must be in (0, 360]."
# Default: cap a partial revolution / an explicitly-open profile, but never a full one.
capv = _norm_caps(caps, default=(not closed) if closed is not None else (angle < 360))
prof = [[p[0], p[1]] for p in shape]
full = angle >= 360
if any(capv) and not full:
prof = [[0.0, prof[0][1]]] + prof + [[0.0, prof[-1][1]]]
xmax = max(p[0] for p in prof)
steps = math.ceil(_segs(xmax) * angle / 360) + (0 if full else 1)
steps = max(steps, 3)
if full:
angs = [start + 360.0 * i / steps for i in range(steps)]
else:
angs = [start + angle * i / (steps - 1) for i in range(steps)]
transforms = [zrot4(a) @ _xrot4(90) for a in angs]
vnf = sweep(
prof,
transforms,
closed=full,
caps=[(not full) and capv[0], (not full) and capv[1]],
style=style,
)
return vnf if vnf.volume() >= 0 else vnf.reverse()
[docs]
def spiral_sweep(
poly: Sequence[Sequence[float]],
height: float,
radius: float | None = None,
turns: float = 1.0,
radius1: float | None = None,
radius2: float | None = None,
diameter: float | None = None,
diameter1: float | None = None,
diameter2: float | None = None,
center: bool = True,
style: str = "min_edge",
) -> VNF:
"""Sweep a 2-D cross-section *poly* along a helix (BOSL2 spiral_sweep(), without lead-in tapers).
*poly*'s X is the radial offset from the helix radius and its Y is the vertical offset, so a
small wire cross-section becomes a spring/thread. The lead-in taper options are not ported.
Args:
poly: the 2-D wire cross-section (closed path)
height: total height of the spiral
radius/diameter: helix radius/diameter (or per-end radius1/radius2 / diameter1/diameter2 for a conical spiral)
turns: number of turns (default 1)
center: center the spiral on Z (default True)
style: vnf_vertex_array quad-subdivision style
Examples:
A rectangular-section coil spring:
.. pythonscad-example::
section = [[-1.2, -1.2], [1.2, -1.2], [1.2, 1.2], [-1.2, 1.2]]
spiral_sweep(section, height=40, radius=12, turns=5).polyhedron().show()
"""
assert height > 0 and turns != 0, "spiral_sweep(): need positive height and nonzero turns."
rr1 = (
radius1
if radius1 is not None
else (
radius
if radius is not None
else (diameter1 / 2 if diameter1 is not None else (diameter / 2 if diameter is not None else 1))
)
)
rr2 = (
radius2
if radius2 is not None
else (
radius
if radius is not None
else (diameter2 / 2 if diameter2 is not None else (diameter / 2 if diameter is not None else 1))
)
)
poly = [[p[0], p[1]] for p in poly]
nturns = abs(turns)
sides = _segs(max(rr1, rr2))
ang_step = 360.0 / sides
total = 360.0 * nturns
steps = math.ceil(total / ang_step)
angs = [total * i / steps for i in range(steps + 1)]
z0 = -height / 2 if center else 0.0
transforms = []
for a in angs:
frac = a / total
rad = rr1 + (rr2 - rr1) * frac
z = z0 + height * frac
transforms.append(
translate4([0, 0, z]) @ zrot4(a * math.copysign(1, turns)) @ translate4([rad, 0, 0]) @ _xrot4(90)
)
vnf = sweep(poly, transforms, closed=False, caps=(True, True), style=style)
return vnf if vnf.volume() >= 0 else vnf.reverse()
[docs]
def subdivide_and_slice(
profiles: Sequence[Sequence[float]],
slices: int,
numpoints=None,
method: str = "length",
closed: bool = False,
) -> list[list[float]]:
"""Resample every profile up to *numpoints* then interpolate *slices* between them (BOSL2 subdivide_and_slice()).
*numpoints* defaults to the largest profile's length; "lcm" uses the least common multiple of
the profile lengths. Returns the stacked list of (equal-length) profiles."""
from bosl2.paths import Path
maxsize = max(len(p) for p in profiles)
if numpoints is None:
numpoints = maxsize
elif numpoints == "lcm":
from functools import reduce
numpoints = reduce(lambda a, b: a * b // math.gcd(a, b), [len(p) for p in profiles])
numpoints = round(numpoints)
assert numpoints >= maxsize, "subdivide_and_slice(): numpoints is smaller than the largest profile."
fixed = [Path._subdivide_path(p, sides=numpoints, closed=True, method=method) for p in profiles]
return slice_profiles(fixed, slices, closed)
# ---------------------------------------------------------------------------------------------
# path_sweep2d() -- sweep a 2-D shape along a 2-D path (creases allowed)
# ---------------------------------------------------------------------------------------------
[docs]
def path_sweep2d(
shape: Sequence[Sequence[float]],
path: Sequence[Sequence[float]],
closed: bool = False,
caps: CapsSpec = None,
quality: int = 1,
style: str = "min_edge",
) -> VNF:
"""Sweep a 2-D *shape* along a 2-D *path*, mapping the shape's Y to Z (BOSL2 path_sweep2d()).
Both *shape* and *path* are 2-D :class:`~bosl2.paths.Path` objects (coerced from point lists).
Each shape point offsets the path by its X and lifts it to its Y, so a shape with a wide X
range becomes a wall of varying width along the path. Unlike :func:`path_sweep`, moderate local
concavity is handled by the offset (mitre joins); an offset large enough to collapse a feature
of the path will still fold, so keep the shape's X extent below the path's tightest radius.
Args:
shape: the 2-D cross-section (a closed path); its X is the offset from the path, its Y the height
path: the 2-D path to sweep along
closed: the path is a closed loop (default False)
caps: cap the open ends (default: True for open, False for closed)
quality: accepted for signature parity (unused -- the mitre offset needs no quality knob)
style: vnf_vertex_array quad-subdivision style
Examples:
A rounded bar swept along a wavy 2-D path:
.. pythonscad-example::
shape = [[-2, -2], [2, -2], [2, 2], [-2, 2]]
path = [[t, 8 * math.sin(t / 12)] for t in range(0, 90, 3)]
path_sweep2d(shape, path).polyhedron().show()
"""
from bosl2.paths import Path
shape = Path(shape)
path = Path(path)
fullcaps = _norm_caps(caps, closed=closed)
profile = shape if not shape.is_clockwise() else shape.reversed_path() # ccw_polygon
flip = -1.0 if (closed and path.is_clockwise()) else 1.0
pth = path if flip > 0 else path.reversed_path()
# For each profile point, offset the path by -flip*x and lift the result to z=y.
per_point = []
for pt in profile:
off = pth.offset(delta=-flip * pt[0])
assert len(off) == len(pth), (
"path_sweep2d(): the offset dropped points (the shape is too wide for the path here); "
"reduce the shape's X extent."
)
per_point.append([[float(p[0]), float(p[1]), float(pt[1])] for p in off])
# transpose: one grid row per path position, each a full cross-section
grid = [[per_point[j][i] for j in range(len(profile))] for i in range(len(pth))]
if closed:
grid = grid + [grid[0]]
vnf = VNF.vertex_array(grid, cap1=fullcaps[0], cap2=fullcaps[1], col_wrap=True, style=style)
return vnf if vnf.volume() >= 0 else vnf.reverse()
# ---------------------------------------------------------------------------------------------
# rot_resample() -- resample a list of transforms to uniform screw-motion spacing
# ---------------------------------------------------------------------------------------------
def _closest_angle_array(alpha: float, beta: Sequence[float]) -> list[float]:
"""Congruent angle to *beta* nearest *alpha* (within +/-180 degrees); *beta* may be a list."""
return [_closest_angle(alpha, b) for b in beta]
def _closest_angle(alpha: float, beta: float) -> float:
"""Congruent angle to *beta* nearest *alpha* (within +/-180 degrees); *beta* may be a list."""
if beta - alpha > 180:
return beta - math.ceil((beta - alpha - 180) / 360) * 360
if beta - alpha < -180:
return beta + math.ceil((alpha - beta - 180) / 360) * 360
return beta
def _smooth(data: Sequence[float], length: int, closed: bool = False, angle: bool = False) -> list:
"""Moving-average smooth of *data* over a window of *length* (BOSL2 _smooth()).
With *angle*, values are unwrapped to the nearest congruent angle before averaging so the mean
does not jump across the +/-180 boundary. Ends are padded with the edge value (open case).
"""
halfwidth = length // 2
sides = len(data)
out = []
if closed:
for i in range(sides):
window = [data[(i + k) % sides] for k in range(-halfwidth, halfwidth + 1)]
if angle:
window = _closest_angle_array(data[i], window)
out.append(sum(window) / len(window))
else:
for i in range(sides):
lo, hi = max(i - halfwidth, 0), min(i + halfwidth, sides - 1)
window = list(data[lo : hi + 1])
pad = data[0] if (i - halfwidth) < 0 else data[-1]
out.append((sum(window) + pad * (length - len(window))) / length)
return out
[docs]
def rot_resample(
rotlist: Sequence[Sequence[float]],
sides: int,
twist=None,
scale=None,
smoothlen: int = 1,
long=False,
turns: float = 0,
closed: bool = False,
method: str = "length",
) -> list:
"""Resample a list of 4x4 transforms to uniform screw-motion spacing (BOSL2 rot_resample()).
Interpolates between successive transforms along their screw motion (via :func:`rot_decode`),
optionally adding *twist* and *scale* (smoothed over *smoothlen*). Handy for regularizing the
transform list from ``path_sweep(..., transforms=True)`` before handing it to :func:`sweep`.
Args:
rotlist: list of 4x4 transform matrices
sides: number of output samples (method="length") or samples per gap (method="count")
twist: extra twist in degrees (scalar or per-gap list)
scale: extra scale (scalar or per-gap list, multiplied cumulatively)
smoothlen: odd window length for smoothing the twist/scale (default 1 = none)
long: take the >180-degree rotation at a gap (scalar or per-gap list)
turns: extra full turns to add at a gap (scalar or per-gap list)
closed: the transform list forms a loop (default False)
method: "length" (uniform screw-distance) or "count" (fixed samples per gap)
"""
rotlist_extra = [np.asarray(t, dtype=float) for t in rotlist]
assert smoothlen > 0 and smoothlen % 2 == 1, "rot_resample(): smoothlen must be a positive odd integer."
assert method in ("length", "count")
m = len(rotlist_extra)
tcount = m + (0 if closed else -1)
if method == "length":
count = (sides + 1) if closed else sides
else:
count = (sum(sides) if isinstance(sides, (list, tuple)) else tcount * sides) + 1
long_l = list(long) if isinstance(long, (list, tuple)) else [long] * tcount
turns_l = list(turns) if isinstance(turns, (list, tuple)) else [turns] * tcount
steps = [rot_inverse(rotlist_extra[i]) @ rotlist_extra[(i + 1) % m] for i in range(tcount)]
parms = []
for i in range(tcount):
tp = rot_decode(steps[i], long_l[i])
parms.append(
[
tp[0] + turns_l[i] * 360,
np.asarray(tp[1], dtype=float),
np.asarray(tp[2], dtype=float),
np.asarray(tp[3], dtype=float),
]
)
radius = [float(np.linalg.norm(p[2])) for p in parms]
length = [
math.hypot(
float(np.linalg.norm(parms[i][3])),
parms[i][0] / 360 * 2 * math.pi * radius[i],
)
for i in range(tcount)
]
if method == "length":
assert all(x > 0 for x in length), "rot_resample(): a repeated/origin rotation makes method='length' undefined."
cumlen = [0.0]
for x in length:
cumlen.append(cumlen[-1] + x)
totlen = cumlen[-1]
stepsize = totlen / (count - 1) if count > 1 else totlen
if method == "count":
nlist = list(sides) if isinstance(sides, (list, tuple)) else [sides] * tcount
samples = [[k / N for k in range(N)] for N in nlist] # lerpn(0,1,N,endpoint=False)
else:
samples = []
for i in range(tcount):
remainder = cumlen[i] % stepsize
offset = 0.0 if remainder == 0 else stepsize - remainder
n = math.ceil((length[i] - offset) / stepsize)
samples.append([(offset + k * stepsize) / length[i] for k in range(n)])
twist_v = 0 if twist is None else twist
scale_v = 1 if scale is None else scale
lastsample = samples[-1][-1] if samples[-1] else 1.0
needlast = abs(lastsample - 1.0) > 1e-9
if isinstance(twist_v, (int, float)):
sampletwist: list[float] = list(np.linspace(0, twist_v, count))
else:
cumtwist = [0.0]
for t in twist_v:
cumtwist.append(cumtwist[-1] + t)
sampletwist = [cumtwist[i] + (cumtwist[i + 1] - cumtwist[i]) * u for i in range(tcount) for u in samples[i]]
if needlast:
sampletwist.append(cumtwist[-1])
if isinstance(scale_v, (int, float)):
samplescale: list[float] = [1 + (scale_v - 1) * u for u in np.linspace(0, 1, count)]
else:
cumscale = [1.0]
for s in scale_v:
cumscale.append(cumscale[-1] * s)
samplescale = [cumscale[i] + (cumscale[i + 1] - cumscale[i]) * u for i in range(tcount) for u in samples[i]]
if needlast:
samplescale.append(cumscale[-1])
smoothtwist = _smooth(
sampletwist[:-1] if closed else sampletwist,
smoothlen,
closed=closed,
angle=True,
)
smoothscale = _smooth(samplescale, smoothlen, closed=closed)
interpolated = []
for i in range(tcount):
for u in samples[i]:
mv = np.eye(4)
mv[:3, 3] = u * parms[i][3]
interpolated.append(rotlist[i] @ mv @ rot_about_axis(u * parms[i][0], parms[i][1], parms[i][2]))
if needlast:
interpolated.append(rotlist[-1])
end = len(interpolated) - (1 if closed else 0)
return [
interpolated[i] @ zrot4(smoothtwist[i]) @ _scale4([smoothscale[i], smoothscale[i], 1.0]) for i in range(end)
]