Source code for pybosl2.skin

# 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
# DocCategory: Paths, regions & surfaces

"""Surface generators: sweep, path_sweep, skin, linear_sweep, rotate_sweep, spiral_sweep (BOSL2 skin.scad)."""

#    Pure-Python port of the surface generators from BOSL2's skin.scad, building
#    VNFs (pybosl2/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.
#

from __future__ import annotations

import math
from dataclasses import dataclass, field
from enum import StrEnum
from typing import TYPE_CHECKING, Any, Sequence, cast

if TYPE_CHECKING:
    from pybosl2.path2d import Path2D
    from pybosl2.path3d import Path3D
    from pybosl2.paths import Path
    from pybosl2.shapes3d import Bosl2Solid

import numpy as np

from pybosl2._helpers import translate4, zrot4
from pybosl2.caps import CapsSpec, CapType, has_decorative_caps, norm_caps, vnf_with_decorative_caps
from pybosl2.enums import ResampleMethod, RoundingMethod, SamplingType, SkinMethod, SweepMethod, VNFStyle
from pybosl2.points import Point
from pybosl2.transforms import apply as _apply
from pybosl2.transforms import rot_about_axis, rot_decode, rot_inverse
from pybosl2.vnf import VNF

UP = Point([0.0, 0.0, 1.0])
BACK = Point([0.0, 1.0, 0.0])


[docs] class Sweepable: """Mixin adding sweep methods to Path2D and Path3D."""
[docs] def path_sweep( self, shape: Path2D, method: SweepMethod = SweepMethod.INCREMENTAL, normal: Sequence[float] | Sequence[Sequence[float]] | None = None, closed: bool = False, twist: float = 0.0, twist_by_length: bool = True, scale: Any = (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 = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, transforms: bool = False, ) -> VNF | Bosl2Solid | list[list[list[float]]]: """Sweep *shape* along this path (BOSL2 path_sweep()). *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:: import math import numpy as np from pybosl2 import Path3D 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)] Path3D(helix).path_sweep(square).polyhedron().show() """ return _path_sweep( shape, cast("Path2D | Path3D", self), method=method, normal=normal, closed=closed, twist=twist, twist_by_length=twist_by_length, scale=scale, scale_by_length=scale_by_length, symmetry=symmetry, last_normal=last_normal, tangent=tangent, uniform=uniform, relaxed=relaxed, caps=caps, style=style, transforms=transforms, )
[docs] def path_sweep2d( self, shape: Path2D, closed: bool = False, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> VNF | Bosl2Solid: """Sweep 2-D *shape* along this 2-D path (BOSL2 path_sweep2d()). For each point on the profile, the path is offset by its X coordinate and lifted to Z = Y, producing a stack of profiles that are skinned into the final surface. Closed paths are reversed automatically to maintain the same winding. Examples: A rounded bar swept along a wavy 2-D path: .. pythonscad-example:: import math from pybosl2 import Path2D shape = [[-2, -2], [2, -2], [2, 2], [-2, 2]] path = [[t, 8 * math.sin(t / 12)] for t in range(0, 90, 3)] Path2D(path).path_sweep2d(shape).polyhedron().show() """ return _path_sweep2d(shape, cast("Path2D", self), closed=closed, caps=caps, style=style)
[docs] def linear_sweep( self, height: float | None = None, twist: float = 0.0, scale: Any = 1, shift: Sequence[float] = (0.0, 0.0), slices: int | None = None, center: bool = False, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> VNF | Bosl2Solid: """Extrude this 2-D profile linearly with optional twist/scale/shift (BOSL2 linear_sweep()). The profile is duplicated at *slices* positions along the Z axis; at each level the points are twisted (rotation around Z, degrees) and scaled (uniform scalar or 2-vector), then shifted in XY. The slices are skinned into a VNF. Examples: A twisting, tapering square column: .. pythonscad-example:: from pybosl2 import Path2D square = [[-10, -10], [10, -10], [10, 10], [-10, 10]] Path2D(square).linear_sweep(height=40, twist=120, scale=0.4).polyhedron().show() """ return _linear_sweep( cast("Path2D", self), height=height, twist=twist, scale=scale, shift=shift, slices=slices, center=center, caps=caps, style=style, )
[docs] def rotate_sweep( self, angle: float = 360.0, caps: CapsSpec = CapType.BUTT, _closed: bool | None = None, style: VNFStyle = VNFStyle.MIN_EDGE, start: float = 0.0, ) -> VNF | Bosl2Solid: """Revolve this 2-D profile around the Z axis (BOSL2 rotate_sweep()). The profile is swept through *angle* degrees (default 360) around Z, starting at *start* degrees. When *angle* < 360 the profile is capped at both ends. Examples: Revolving a rounded profile into a spool: .. pythonscad-example:: from pybosl2 import Path2D profile = [[4, -10], [12, -10], [12, -6], [7, -2], [7, 2], [12, 6], [12, 10], [4, 10]] Path2D(profile).rotate_sweep(angle=360).polyhedron().show() """ return _rotate_sweep( cast("Path2D", self), angle=angle, caps=caps, style=style, start=start, )
[docs] def spiral_sweep( self, 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: VNFStyle = VNFStyle.MIN_EDGE, ) -> VNF | Bosl2Solid: """Sweep this 2-D profile along a helix (BOSL2 spiral_sweep()). The profile follows a helical path of *height* and *radius* (or separate start/end radii) over *turns* revolutions. Unlike rotate_sweep, the profile also gains height, producing a coil. Examples: A rectangular-section coil spring: .. pythonscad-example:: from pybosl2 import Path2D section = [[-1.2, -1.2], [1.2, -1.2], [1.2, 1.2], [-1.2, 1.2]] Path2D(section).spiral_sweep(height=40, radius=12, turns=5).polyhedron().show() """ return _spiral_sweep( cast("Path2D", self), height, radius=radius, turns=turns, radius1=radius1, radius2=radius2, diameter=diameter, diameter1=diameter1, diameter2=diameter2, center=center, style=style, )
[docs] def sweep( self, transforms: Sequence[Sequence[Sequence[float]]], closed: bool = False, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> VNF | Bosl2Solid: """Apply each 4x4 transform to this 2-D shape and skin the resulting profiles into a VNF. or Bosl2Solid (BOSL2 sweep()). """ return _sweep( [list(p) for p in cast("Path2D", self)], transforms, closed=closed, caps=caps, style=style, )
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]] | Path | np.ndarray | Sequence[np.ndarray]) -> 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 pybosl2/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]] | Path2D) -> list[Sequence[float]]: """*poly* wound clockwise (reversed if its signed area is positive/CCW).""" from pybosl2.path2d import Path2D return list(poly) if Path2D._polygon_area(poly, signed=True) <= 0 else list(reversed(list(poly))) # type: ignore[arg-type]
# (imported from pybosl2._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: """Return 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 assert zu is not None xu = np.cross(yu, zu) elif yu is None: assert zu is not None assert xu is not None yu = np.cross(zu, xu) elif zu is None: assert xu is not None assert yu is not None zu = np.cross(xu, yu) assert xu is not None assert yu is not None assert zu is not None m = np.eye(4) m[:3, :3] = np.column_stack([xu, yu, zu]) return m
def _sweep( shape: Sequence[Sequence[float]], transforms: Sequence[Sequence[Sequence[float]]], closed: bool = False, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> VNF | Bosl2Solid: """Apply each 4x4 transform to the 2-D *shape* and skin the resulting profiles into a VNF or Bosl2Solid. Decorative cap types (ARROW, DIAMOND, DOT, etc.) produce a :class:`~pybosl2.shapes3d.Bosl2Solid` with the endcap geometry unioned to the swept body. Basic caps (NONE, BUTT, ROUND, SPHERE) are handled inline by :class:`VNF.vertex_array`. 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: BUTT); supports decorative cap types 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." cap_specs = 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)] if has_decorative_caps(cap_specs): vnf = VNF.vertex_array(points, col_wrap=True, style=style) vnf = vnf if vnf.volume() >= 0 else vnf.reverse() center1 = list(np.mean(points[0], axis=0)) center2 = list(np.mean(points[-1], axis=0)) radius = float(max(np.linalg.norm(np.asarray(p[:2]) - np.asarray(center1[:2])) for p in points[0])) outdir1 = [center1[i] - center2[i] for i in range(3)] outdir2 = [center2[i] - center1[i] for i in range(3)] return vnf_with_decorative_caps(vnf, cap_specs, closed, [center1, center2], [outdir1, outdir2], radius) return VNF.vertex_array( points[:-1] if closed else points, caps=cap_specs, col_wrap=True, row_wrap=closed, style=style, ) def _path_sweep( shape: Sequence[Sequence[float]] | Path2D, path: Sequence[Sequence[float]] | Path2D | Path3D, method: SweepMethod = SweepMethod.INCREMENTAL, normal: Sequence[float] | Sequence[Sequence[float]] | None = None, closed: bool = False, twist: float = 0.0, twist_by_length: bool = True, scale: Any = (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 = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, transforms: bool = False, ) -> VNF | Bosl2Solid | list[list[list[float]]]: """Sweep the 2-D *shape* along the 2-D/3-D *path* (internal implementation). Public API: use :meth:`Sweepable.path_sweep` instead of calling this directly. """ from pybosl2.path3d import Path3D patharr = np.asarray(path3d(path), dtype=float) npts = len(patharr) assert npts >= 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(Path3D(patharr).tangents(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, (npts, 1)) else: normal_single = np.asarray( (BACK if (method == SweepMethod.INCREMENTAL and abs(tangents[0][2]) > 1 / math.sqrt(2)) else UP), dtype=float, ) normals = np.tile(normal_single, (npts, 1)) if twist_by_length: tpathfrac = np.asarray(Path3D(patharr).length_fractions(closed=closed), dtype=float) else: tpathfrac = np.array([i / (npts - (0 if closed else 1)) for i in range(npts + 1)]) if scale_by_length: spathfrac = np.asarray(Path3D(patharr).length_fractions(closed=closed), dtype=float) else: spathfrac = np.array([i / (npts - (0 if closed else 1)) for i in range(npts + 1)]) # Resolve the per-cross-section scale [sx, sy]. if isinstance(scale, (int, float)) or (np.ndim(scale) == 1 and len(scale) == 2): s = [float(scale), float(scale)] if isinstance(scale, (int, float)) else [float(scale[0]), float(scale[1])] if not scale_by_length: scalevals = [ [float(v) for v in ((1 - i / (npts - 1)) * np.array([1.0, 1.0]) + (i / (npts - 1)) * np.array(s))] for i in range(npts) ] else: scalevals = [ [float(v) for v in ((1 - f) * np.array([1.0, 1.0]) + f * np.array(s))] for f in spathfrac[:npts] ] else: scalevals = [[float(x), float(x)] if isinstance(x, (int, float)) else [float(x[0]), float(x[1])] for x in scale] 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 = npts + (1 if closed else 0) if method == SweepMethod.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) % npts] - patharr[i % npts] c1 = float(v1 @ v1) refl_r = radius - 2 * (v1 @ radius) / c1 * v1 refl_t = tangents[i % npts] - 2 * (v1 @ tangents[i % npts]) / c1 * v1 v2 = tangents[(i + 1) % npts] - refl_t c2 = float(v2 @ v2) radius = refl_r - (2 / c2) * (v2 @ refl_r) * v2 cur = frame_map(y=radius, z=tangents[(i + 1) % npts]) 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(npts) ] if closed: unscaled.append( translate4(patharr[0]) @ rotations[0] @ zrot4(-correction_twist + correction_twist % (360 / symmetry) - twist) ) elif method == SweepMethod.MANUAL: unscaled = [] for i in range(nprofiles): ni, ti = normals[i % npts], tangents[i % npts] if relaxed: ynormal, znormal = ni, ti - (ni @ ti) * ni else: ynormal, znormal = ni - (ni @ ti) * ti, ti unscaled.append( translate4(patharr[i % npts]) @ frame_map(y=ynormal, z=znormal) @ zrot4(-twist * tpathfrac[i]) ) elif method == SweepMethod.NATURAL: pathnormal = np.asarray(Path3D(patharr).normals(tangents=tangents, closed=closed), dtype=float) # type: ignore[arg-type,type-var] unscaled = [ translate4(patharr[i % npts]) @ frame_map(x=pathnormal[i % npts], z=tangents[i % npts]) @ 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=closed, 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 result: list[list[float]] = np.roll(p, -best_k, axis=0).tolist() return result
[docs] def slice_profiles( profiles: Sequence[Sequence[Sequence[float]]], slices: int, closed: bool = False ) -> list[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
def _skin( profiles: Sequence[Sequence[Sequence[float]]], slices: int, refine: float = 1.0, method: SkinMethod = SkinMethod.DIRECT, sampling: SamplingType | None = None, caps: CapsSpec = CapType.BUTT, closed: bool = False, style: VNFStyle = VNFStyle.MIN_EDGE, z: Sequence[float] | None = None, ) -> VNF | Bosl2Solid: """Blend a stack of 2-D/3-D profiles into a skinned surface (internal implementation). Public API: use :meth:`VNF.from_skin` instead of calling this directly. """ 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) cap_specs = 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 isinstance(m, SkinMethod), f"skin(): only the 'direct' and 'reindex' methods are ported (got {m!r})." sampling = sampling if sampling is not None else SamplingType.LENGTH dim = len(profiles[0][0]) if dim == 2: assert z is not None, "skin(): 2-D profiles need a matching-length z list." assert 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 pybosl2.path3d import Path3D maxlen = max(refine_list[i] * len(profiles[i]) for i in range(sides)) resampled = [Path3D(profiles[i]).subdivide_path(points=int(maxlen), closed=True) for i in range(sides)] fixedprof = [resampled[0]] for i in range(1, sides): if method[i - 1] == SkinMethod.DIRECT: fixedprof.append(resampled[i]) else: fixedprof.append(_reindex_polygon(fixedprof[i - 1], resampled[i])) # type: ignore[arg-type] sliced = slice_profiles(fixedprof, slices, closed) # type: ignore[arg-type] grid = sliced if not closed else sliced + [sliced[0]] if has_decorative_caps(cap_specs): vnf = VNF.vertex_array(grid, col_wrap=True, style=style) vnf = vnf if vnf.volume() >= 0 else vnf.reverse() grid_arr = np.asarray(grid, dtype=float) center1 = list(grid_arr[0].mean(axis=0)) center2 = list(grid_arr[-1].mean(axis=0)) radius = float(max(np.linalg.norm(p[:2] - np.asarray(center1[:2])) for p in grid_arr[0])) outdir1 = [center1[i] - center2[i] for i in range(3)] outdir2 = [center2[i] - center1[i] for i in range(3)] return vnf_with_decorative_caps(vnf, cap_specs, closed, [center1, center2], [outdir1, outdir2], radius) vnf = VNF.vertex_array( grid[:-1] if closed else grid, caps=cap_specs, col_wrap=True, row_wrap=closed, style=style, ) return vnf if vnf.volume() >= 0 else vnf.reverse() # --------------------------------------------------------------------------------------------- # linear_sweep() / rotate_sweep() / spiral_sweep() # --------------------------------------------------------------------------------------------- def _linear_sweep( region: Sequence[Sequence[float]] | Path2D, height: float | None = None, twist: float = 0.0, scale: float = 1, shift: Sequence[float] = (0.0, 0.0), slices: int | None = None, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.DEFAULT, center: bool | None = None, ) -> VNF | Bosl2Solid: """Extrude a 2-D outline to *height* with optional twist / scale / shift (internal implementation). Public API: use :meth:`Sweepable.linear_sweep` instead of calling this directly. """ 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])] cap_specs = 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]) @ _scale4([1 + (sc[0] - 1) * u, 1 + (sc[1] - 1) * u, 1]) @ zrot4(-twist * u) ) verts.append(np.asarray(_apply(m, base), dtype=float)) if has_decorative_caps(cap_specs): vnf = VNF.vertex_array(verts, col_wrap=True, style=style) vnf = vnf if vnf.volume() >= 0 else vnf.reverse() center1 = list(verts[0].mean(axis=0).tolist()) center2 = list(verts[-1].mean(axis=0).tolist()) radius = float(max(np.linalg.norm(p[:2] - np.asarray(center1[:2])) for p in verts[0])) outdir1 = [0.0, 0.0, -1.0] outdir2 = [0.0, 0.0, 1.0] return vnf_with_decorative_caps(vnf, cap_specs, False, [center1, center2], [outdir1, outdir2], radius) vnf = VNF.vertex_array( verts, caps=cap_specs, col_wrap=True, style=style, ) return vnf if vnf.volume() >= 0 else vnf.reverse() def _rotate_sweep( shape: Sequence[Sequence[float]] | Path2D, angle: float = 360.0, caps: CapsSpec = CapType.BUTT, _closed: bool | None = None, style: VNFStyle = VNFStyle.MIN_EDGE, start: float = 0.0, ) -> VNF | Bosl2Solid: """Revolve a 2-D *shape* around the Z axis (internal implementation). Public API: use :meth:`Sweepable.rotate_sweep` instead of calling this directly. """ assert 0 < angle <= 360, "rotate_sweep(): angle must be in (0, 360]." cap_specs = norm_caps(caps) prof = [[p[0], p[1]] for p in shape] full = angle >= 360 if any(s.cap_type != CapType.NONE for s in cap_specs) 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] cap_list: CapsSpec = [CapType.NONE, CapType.NONE] if full else cap_specs result = _sweep( prof, transforms, closed=full, caps=cap_list, style=style, ) if isinstance(result, VNF): return result if result.volume() >= 0 else result.reverse() return result def _spiral_sweep( poly: Sequence[Sequence[float]] | Path2D, 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: VNFStyle = VNFStyle.MIN_EDGE, ) -> VNF | Bosl2Solid: """Sweep a 2-D cross-section *poly* along a helix (internal implementation). Public API: use :meth:`Sweepable.spiral_sweep` instead of calling this directly. """ assert height > 0, "spiral_sweep(): need positive height and nonzero turns." assert 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) ) result = _sweep(poly, transforms, closed=False, caps=[CapType.BUTT, CapType.BUTT], style=style) if isinstance(result, VNF): return result if result.volume() >= 0 else result.reverse() return result
[docs] def subdivide_and_slice( profiles: Sequence[Sequence[Sequence[float]]], slices: int, numpoints: int | str | None = None, method: ResampleMethod = ResampleMethod.LENGTH, # noqa: ARG001 closed: bool = False, ) -> list[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 pybosl2.path2d import Path2D from pybosl2.path3d import Path3D def _wrap(prof: Sequence[Sequence[float]]) -> Path2D | Path3D: return Path3D(prof) if prof and len(prof[0]) == 3 else Path2D(prof) 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]) assert isinstance(numpoints, int), "numpoints must be int after resolution" numpoints = round(numpoints) assert numpoints >= maxsize, "subdivide_and_slice(): numpoints is smaller than the largest profile." fixed = [_wrap(p).subdivide_path(points=numpoints, closed=True) for p in profiles] return slice_profiles(fixed, slices, closed) # type: ignore[arg-type]
# --------------------------------------------------------------------------------------------- # os_circle() / offset_sweep() -- profile-based offset extrusion with rim roundovers # (BOSL2 rounding.scad: os_circle, offset_sweep) # ---------------------------------------------------------------------------------------------
[docs] class OSType(StrEnum): """Offset sweep profile type.""" CIRCLE = "circle" SMOOTH = "smooth" TEARDROP = "teardrop" CHAMFER = "chamfer" FLAT = "flat" PROFILE = "profile"
@dataclass class OSProfile: """Descriptor for an offset-sweep rim treatment profile (BOSL2 ``os_profile()``). Holds the parameters that define how one rim of an extruded shape is treated by :func:`offset_sweep` — roundover, flare, teardrop, chamfer, flat, or a custom point profile. """ type: OSType radius: float = 0.0 height: float = 0.0 extra: float = 0.0 cut: float = 0.0 curvature: float = 0.5 radius_sign: float = 1.0 max_angle: float = 45.0 width: float = 0.0 points: list[list[float]] = field(default_factory=list[list[float]]) def get(self, key: str, default: object = None) -> object: """Return the value for key or a default.""" if key == "type": return self.type.value mapping = { "r": "radius", "h": "height", "k": "curvature", "r_sign": "radius_sign", } attr = mapping.get(key, key) if hasattr(self, attr): return getattr(self, attr) return default def __getitem__(self, key: str) -> object: """Return the item for key.""" if key == "type": return self.type.value mapping = { "r": "radius", "h": "height", "k": "curvature", "r_sign": "radius_sign", } attr = mapping.get(key, key) if hasattr(self, attr): return getattr(self, attr) raise KeyError(key) def __contains__(self, key: str) -> bool: """Return whether key is in this object.""" mapping = { "r": "radius", "h": "height", "k": "curvature", "r_sign": "radius_sign", } attr = mapping.get(key, key) return hasattr(self, attr)
[docs] def os_circle( radius: float | None = None, height: float | None = None, extra: float = 0.0, ) -> OSProfile: """Circular roundover/flare profile for :func:`offset_sweep` (BOSL2 ``os_circle()``). Describes the treatment applied to one rim of the extruded shape: * ``radius > 0`` — inward roundover: the rim is eased in (material is *removed* from the corner, yielding a convex fillet). * ``radius < 0`` — outward flare: extra material is added outside the wall at the rim (a concave cove). * ``radius == 0`` — square / no treatment (same as passing ``None`` to :func:`offset_sweep`). Args: radius: Roundover radius (positive = roundover, negative = flare). height: Height of the rim treatment; defaults to ``abs(radius)``. Should be less than half the extrusion height. extra: Extra extension beyond the nominal arc (useful to close tiny gaps from floating-point rounding; default 0). Returns: A descriptor ``OSProfile`` consumed by :func:`offset_sweep`. """ r_val = radius h_val = height assert r_val is not None, "os_circle(): radius is required." h_res = float(h_val) if h_val is not None else abs(float(r_val)) return OSProfile(type=OSType.CIRCLE, radius=float(r_val), height=h_res, extra=float(extra))
[docs] def os_smooth( cut: float | None = None, radius: float | None = None, curvature: float = 0.5, extra: float = 0.0, ) -> OSProfile: """Continuous curvature (Bézier) profile for :func:`offset_sweep` (BOSL2 ``os_smooth()``). Uses a 4th-order Bézier curve to ease the transition between flat and curved edges, avoiding sudden changes in curvature. Args: cut: Depth of the roundover/flare. radius: Alternative to ``cut`` (aliases it). curvature: Smoothness/curvature match parameter between 0 and 1 (default 0.5). extra: Extra extension beyond the nominal curve (default 0). Returns: A descriptor ``OSProfile`` consumed by :func:`offset_sweep`. """ r_val = radius k_val = curvature val = float(cut) if cut is not None else (float(r_val) if r_val is not None else 1.0) sign = 1.0 if val >= 0 else -1.0 return OSProfile(type=OSType.SMOOTH, cut=abs(val), curvature=float(k_val), radius_sign=sign, extra=float(extra))
[docs] def os_teardrop( radius: float | None = None, height: float | None = None, cut: float | None = None, max_angle: float = 45.0, extra: float = 0.0, ) -> OSProfile: """Teardrop profile for :func:`offset_sweep` to avoid overhangs in 3D printing (BOSL2 ``os_teardrop()``). Transitions from a 1/8th circle into a straight line at ``max_angle`` degrees relative to the vertical wall, allowing support-free printing. Args: radius: Radius of the circular portion. height: Total height of the treatment (defaults to ``abs(radius)``). cut: Alternative to ``radius`` (aliases it). max_angle: Curvature transition angle relative to the wall (default 45.0). extra: Extra extension beyond the nominal curve (default 0). Returns: A descriptor ``OSProfile`` consumed by :func:`offset_sweep`. """ r_arg = radius h_arg = height r_val = float(r_arg) if r_arg is not None else (float(cut) if cut is not None else 1.0) h_val = float(h_arg) if h_arg is not None else abs(r_val) return OSProfile(type=OSType.TEARDROP, radius=r_val, height=h_val, max_angle=float(max_angle), extra=float(extra))
[docs] def os_chamfer( width: float | None = None, height: float | None = None, angle: float | None = None, cut: float | None = None, extra: float = 0.0, ) -> OSProfile: """Chamfer/bevel profile for :func:`offset_sweep` (BOSL2 ``os_chamfer()``). Creates a flat bevel transition. Args: width: Horizontal width of the chamfer. height: Vertical height of the chamfer (defaults to ``width``). angle: Bevel angle in degrees. If given, overrides ``width``. cut: Bevel size (aliases both ``width`` and ``height``). extra: Extra extension beyond the nominal bevel (default 0). Returns: A descriptor ``OSProfile`` consumed by :func:`offset_sweep`. """ if cut is not None: w = float(cut) h = float(cut) else: w = float(width) if width is not None else 1.0 h = float(height) if height is not None else w if angle is not None: w = h * math.tan(math.radians(float(angle))) return OSProfile(type=OSType.CHAMFER, width=w, height=h, extra=float(extra))
[docs] def os_flat() -> OSProfile: """Flat end cap profile descriptor representing no treatment (BOSL2 ``os_flat()``).""" return OSProfile(type=OSType.FLAT, radius=0.0, height=0.0)
[docs] def os_profile(profile: Sequence[Sequence[float]], extra: float = 0.0) -> OSProfile: """Return a custom offset sweep profile descriptor (BOSL2 ``os_profile()``). Accepts a list of 2D points `[[x, y], ...]` defining the profile: - `x` is the inward radial offset (meaning `delta = -x`). - `y` is the height `z`. Args: profile: Sequence of ``[x, y]`` points. The first must be ``[0, 0]``. extra: Extra extension (default 0). Returns: A descriptor ``OSProfile`` consumed by :func:`offset_sweep`. """ pts = [[float(p[0]), float(p[1])] for p in profile] assert pts, "os_profile(): First point of the profile must be [0, 0]." assert pts[0] == [0.0, 0.0], "os_profile(): First point of the profile must be [0, 0]." return OSProfile(type=OSType.PROFILE, points=pts, extra=float(extra))
def _offset_sweep( path: Sequence[Sequence[float]], height: float, bottom: object = None, top: object = None, steps: int = 16, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> VNF | Bosl2Solid: """Extrude a 2-D outline to *height* with optional edge treatments on each rim (BOSL2 ``offset_sweep()``). Stacks a sequence of radially-offset outlines along the Z axis. The transition from the vertical wall to the flat cap is determined by the *bottom* and *top* profiles. Supported profiles (offset specifiers): - ``os_circle()``: Circular roundover/flare. - ``os_smooth()``: Bezier-based continuous curvature G2 smoothing. - ``os_teardrop()``: Teardrop profile for support-free 3D printing. - ``os_chamfer()``: Straight bevel. - ``os_flat()``: Flat cap. - ``os_profile()``: User-defined custom 2D profile. Args: path: The 2-D polygon to extrude (a closed path or point list). height: Total extrusion height (Z from 0 to height). bottom: Bottom-rim treatment descriptor (or ``None`` for square). top: Top-rim treatment descriptor (or ``None`` for square). steps: Number of slices/steps per rim treatment (default 16). caps: Cap the flat top and bottom (default True); bool or [bool, bool]. style: ``vnf_vertex_array`` quad-subdivision style. Returns: A :class:`~pybosl2.vnf.VNF`. """ from pybosl2.path2d import Path2D as _Path assert height > 0, "offset_sweep(): height must be positive." cap_specs = norm_caps(caps) base = [[float(p[0]), float(p[1])] for p in path] def _to_desc(j: object) -> "OSProfile | dict[str, object] | None": if j is None: return None if isinstance(j, (dict, OSProfile)): return j return os_circle(cast("float", j)) bottom_desc = _to_desc(bottom) top_desc = _to_desc(top) # --------------------------------------------------------------------------- # Build (delta, z) pairs for each level of the stack. # --------------------------------------------------------------------------- def _arc_column(desc: Any, n: int) -> tuple[list[float], list[float]]: """Return (deltas, zs) for one rim, length n+1.""" if desc is None: return ([0.0], [0.0]) t = desc.get("type", "circle") if t == OSType.FLAT or (t == OSType.CIRCLE and desc["r"] == 0.0): return ([0.0], [0.0]) if t == OSType.CIRCLE: r = desc["r"] h = abs(desc["h"]) ar = abs(r) sign = -1.0 if r > 0 else 1.0 angles = [math.pi / 2 * i / n for i in range(n + 1)] deltas = [sign * ar * (1.0 - math.cos(a)) for a in angles] zs = [h * math.sin(a) for a in angles] return (deltas, zs) elif t == OSType.TEARDROP: r = desc["r"] h = abs(desc["h"]) max_angle = desc.get("max_angle", 45.0) ar = abs(r) sign = -1.0 if r > 0 else 1.0 max_a_rad = math.radians(max_angle) z_trans = ar * math.sin(max_a_rad) delta_trans = ar * (1.0 - math.cos(max_a_rad)) if h <= z_trans: limit_a = math.asin(h / ar) if ar > 0 else 0.0 angles = [limit_a * i / n for i in range(n + 1)] deltas = [sign * ar * (1.0 - math.cos(a)) for a in angles] zs = [h * math.sin(a) / math.sin(limit_a) if limit_a > 0 else 0.0 for a in angles] return (deltas, zs) else: n_circ = n // 2 n_line = n - n_circ deltas = [] zs = [] for i in range(n_circ): a = max_a_rad * i / n_circ deltas.append(sign * ar * (1.0 - math.cos(a))) zs.append(ar * math.sin(a)) for i in range(n_line + 1): curr_z = z_trans + (h - z_trans) * i / n_line curr_delta = delta_trans + (curr_z - z_trans) * math.tan(max_a_rad) deltas.append(sign * curr_delta) zs.append(curr_z) return (deltas, zs) elif t == OSType.SMOOTH: cut = abs(desc["cut"]) k = desc["k"] sign = -1.0 if desc["r_sign"] > 0 else 1.0 deltas = [] zs = [] for i in range(n + 1): u = i / n xu = 4.0 * k * cut * (u**3) * (1.0 - u) + cut * (u**4) yu = cut * ((1.0 - u) ** 4) + 4.0 * k * cut * u * ((1.0 - u) ** 3) deltas.append(sign * xu) zs.append(cut - yu) return (deltas, zs) elif t == OSType.CHAMFER: w = desc["width"] h = abs(desc["height"]) deltas = [-w * i / n for i in range(n + 1)] zs = [h * i / n for i in range(n + 1)] return (deltas, zs) elif t == OSType.PROFILE: pts = desc["points"] pts_arr = np.asarray(pts, dtype=float) zs_in = pts_arr[:, 1] xs_in = pts_arr[:, 0] z_min, z_max = zs_in[0], zs_in[-1] zs = [z_min + (z_max - z_min) * i / n for i in range(n + 1)] deltas = [-float(np.interp(z, zs_in, xs_in)) for z in zs] return (deltas, zs) return ([0.0], [0.0]) bot_deltas, bot_zs = _arc_column(bottom_desc, steps) top_deltas, top_zs = _arc_column(top_desc, steps) h_bot = bot_zs[-1] h_top = top_zs[-1] assert h_bot + h_top <= height, ( "offset_sweep(): the sum of the bottom and top rim heights exceeds the extrusion height." ) # Assemble (delta, z) pairs for the complete column, bottom → top. column: list[tuple[float, float]] = [] # Bottom rim. for d, z in zip(bot_deltas, bot_zs, strict=False): column.append((d, z)) # Middle straight wall (if there is room between the two rims). mid_z_bot = column[-1][1] mid_z_top = height - h_top if mid_z_top > mid_z_bot + 1e-9: column.append((column[-1][0], mid_z_top)) # Top rim (arc in reverse: from height-h_top up to height). for d, z in zip(reversed(top_deltas), reversed(top_zs), strict=False): column.append((d, height - z)) # De-duplicate consecutive identical entries. deduped: list[tuple[float, float]] = [column[0]] for pair in column[1:]: prev = deduped[-1] if abs(pair[0] - prev[0]) > 1e-12 or abs(pair[1] - prev[1]) > 1e-12: deduped.append(pair) # Build one 3-D ring per (delta, z) level. profiles_3d: list[list[list[float]]] = [] for delta, z in deduped: if abs(delta) < 1e-12: ring: list[list[float]] = [[p[0], p[1], z] for p in base] else: off = list(_Path(base).offset(delta=delta)) ring = [[float(p[0]), float(p[1]), z] for p in off] if ring: profiles_3d.append(ring) if len(profiles_3d) < 2: return VNF([], []) # Normalise all rings to the same vertex count. maxn = max(len(r) for r in profiles_3d) from pybosl2.path3d import Path3D as _Path3D norm = [_Path3D(row).subdivide_path(points=maxn, closed=True) for row in profiles_3d] if has_decorative_caps(cap_specs): vnf = VNF.vertex_array(norm, col_wrap=True, style=style) vnf = vnf if vnf.volume() >= 0 else vnf.reverse() norm_arr = np.asarray(norm, dtype=float) center1 = list(norm_arr[0].mean(axis=0)) center2 = list(norm_arr[-1].mean(axis=0)) radius = float(max(np.linalg.norm(p[:2] - np.asarray(center1[:2])) for p in norm_arr[0])) outdir1 = [center1[i] - center2[i] for i in range(3)] outdir2 = [center2[i] - center1[i] for i in range(3)] return vnf_with_decorative_caps(vnf, cap_specs, False, [center1, center2], [outdir1, outdir2], radius) vnf = VNF.vertex_array( norm, caps=cap_specs, col_wrap=True, style=style, ) return vnf if vnf.volume() >= 0 else vnf.reverse() def _convex_offset_extrude( path: Sequence[Sequence[float]], height: float, bottom: object = None, top: object = None, steps: int = 16, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> VNF | Bosl2Solid: """Offset sweep/extrusion of a 2-D shape (BOSL2 convex_offset_extrude()). An alias for :func:`_offset_sweep` to match BOSL2's geometry-oriented name. """ return _offset_sweep(path, height=height, bottom=bottom, top=top, steps=steps, caps=caps, style=style) def _rounded_prism( bottom: Sequence[Sequence[float]], top: Sequence[Sequence[float]] | None = None, height: float | None = None, joint_top: float | dict[str, object] | None = None, joint_bottom: float | dict[str, object] | None = None, joint_sides: float | list[float] | None = None, curvature_sides: float | list[float] | None = None, steps: int = 16, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, joint_bot: float | dict[str, object] | None = None, k_sides: float | list[float] | None = None, ) -> VNF | Bosl2Solid: """Loft/extrusion between two polygons with top, bottom, and side rounding (BOSL2 rounded_prism()). Args: bottom: The bottom polygon path (2-D point sequence). top: The top polygon path (defaults to *bottom*). height: Prism height. joint_top: Rounding radius or specifier for the top rim. joint_bottom: Rounding radius or specifier for the bottom rim. joint_sides: Rounding radius or specifier for the vertical side corners. curvature_sides: Continuous curvature parameter for side corners. steps: Arc slices for top/bottom rim treatments. caps: Cap bottom/top. style: Subdivision style. joint_bot: Alias for *joint_bottom*, provided for BOSL2 compatibility. k_sides: Alias for *curvature_sides*, provided for BOSL2 compatibility. Returns: A :class:`~pybosl2.vnf.VNF`. """ from pybosl2.path2d import Path2D as _Path # joint_bot / k_sides are BOSL2's names for joint_bottom / curvature_sides joint_bot = joint_bottom if joint_bottom is not None else joint_bot k_sides = curvature_sides if curvature_sides is not None else k_sides # Coerce/normalize top and height if top is None: top = bottom bot_z = [float(p[2]) if len(p) > 2 else 0.0 for p in bottom] top_z = [float(p[2]) if len(p) > 2 else 0.0 for p in top] z_diff = abs(max(top_z) - min(bot_z)) h_val = float(height) if height is not None else (z_diff if z_diff > 1e-9 else 1.0) b_2d = [[float(p[0]), float(p[1])] for p in bottom] t_2d = [[float(p[0]), float(p[1])] for p in top] # Pre-round the side corners if requested if joint_sides is not None: from pybosl2.rounding import _round_corners as _rc if k_sides is not None: # a curvature turns the side rounding into a smooth join b_rounded = _rc(b_2d, method=RoundingMethod.SMOOTH, joint=joint_sides, curvature=k_sides) t_rounded = _rc(t_2d, method=RoundingMethod.SMOOTH, joint=joint_sides, curvature=k_sides) else: b_rounded = _rc(b_2d, method=RoundingMethod.CIRCLE, radius=joint_sides) t_rounded = _rc(t_2d, method=RoundingMethod.CIRCLE, radius=joint_sides) else: b_rounded = b_2d t_rounded = t_2d # Convert rim treatments to dict descriptors def _to_desc(j: object) -> "OSProfile | dict[str, object] | None": if j is None: return None if isinstance(j, (dict, OSProfile)): return j return os_circle(cast("float", j)) desc_top = _to_desc(joint_top) desc_bot = _to_desc(joint_bot) # Re-use the (delta, z) levels calculation from offset_sweep def _arc_column(desc: Any, n: int) -> tuple[list[float], list[float]]: if desc is None: return ([0.0], [0.0]) t = desc.get("type", "circle") if t == OSType.FLAT or (t == OSType.CIRCLE and desc["r"] == 0.0): return ([0.0], [0.0]) if t == OSType.CIRCLE: r = desc["r"] h = abs(desc["h"]) ar: float = abs(r) sign = -1.0 if r > 0 else 1.0 angles = [math.pi / 2 * i / n for i in range(n + 1)] deltas = [sign * ar * (1.0 - math.cos(a)) for a in angles] zs = [h * math.sin(a) for a in angles] return (deltas, zs) elif t == OSType.TEARDROP: r = desc["r"] h = abs(desc["h"]) max_angle = desc.get("max_angle", 45.0) ar = abs(r) sign = -1.0 if r > 0 else 1.0 max_a_rad = math.radians(max_angle) z_trans = ar * math.sin(max_a_rad) delta_trans = ar * (1.0 - math.cos(max_a_rad)) if h <= z_trans: limit_a = math.asin(h / ar) if ar > 0 else 0.0 angles = [limit_a * i / n for i in range(n + 1)] deltas = [sign * ar * (1.0 - math.cos(a)) for a in angles] zs = [h * math.sin(a) / math.sin(limit_a) if limit_a > 0 else 0.0 for a in angles] return (deltas, zs) else: n_circ = n // 2 n_line = n - n_circ deltas = [] zs = [] for i in range(n_circ): a = max_a_rad * i / n_circ deltas.append(sign * ar * (1.0 - math.cos(a))) zs.append(ar * math.sin(a)) for i in range(n_line + 1): curr_z = z_trans + (h - z_trans) * i / n_line curr_delta = delta_trans + (curr_z - z_trans) * math.tan(max_a_rad) deltas.append(sign * curr_delta) zs.append(curr_z) return (deltas, zs) elif t == OSType.SMOOTH: cut = abs(desc["cut"]) k = desc["k"] sign = -1.0 if desc["r_sign"] > 0 else 1.0 deltas = [] zs = [] for i in range(n + 1): u = i / n xu = 4.0 * k * cut * (u**3) * (1.0 - u) + cut * (u**4) yu = cut * ((1.0 - u) ** 4) + 4.0 * k * cut * u * ((1.0 - u) ** 3) deltas.append(sign * xu) zs.append(cut - yu) return (deltas, zs) elif t == OSType.CHAMFER: w = desc["width"] h = abs(desc["height"]) deltas = [-w * i / n for i in range(n + 1)] zs = [h * i / n for i in range(n + 1)] return (deltas, zs) elif t == OSType.PROFILE: pts = desc["points"] pts_arr = np.asarray(pts, dtype=float) zs_in = pts_arr[:, 1] xs_in = pts_arr[:, 0] z_min, z_max = zs_in[0], zs_in[-1] zs = [z_min + (z_max - z_min) * i / n for i in range(n + 1)] deltas = [-float(np.interp(z, zs_in, xs_in)) for z in zs] return (deltas, zs) return ([0.0], [0.0]) bot_deltas, bot_zs = _arc_column(desc_bot, steps) top_deltas, top_zs = _arc_column(desc_top, steps) h_bot = bot_zs[-1] h_top = top_zs[-1] assert h_bot + h_top <= h_val, ( "rounded_prism(): the sum of the bottom and top rim heights exceeds the prism height." ) column: list[tuple[float, float]] = [] # Bottom rim. for d, z in zip(bot_deltas, bot_zs, strict=False): column.append((d, z)) # Middle straight wall. mid_z_bot = column[-1][1] mid_z_top = h_val - h_top if mid_z_top > mid_z_bot + 1e-9: column.append((column[-1][0], mid_z_top)) # Top rim. for d, z in zip(reversed(top_deltas), reversed(top_zs), strict=False): column.append((d, h_val - z)) # De-duplicate consecutive identical entries. deduped: list[tuple[float, float]] = [column[0]] for pair in column[1:]: prev = deduped[-1] if abs(pair[0] - prev[0]) > 1e-12 or abs(pair[1] - prev[1]) > 1e-12: deduped.append(pair) # Build one 3-D ring per level profiles_3d = [] b_arr = np.asarray(b_rounded, dtype=float) t_arr = np.asarray(t_rounded, dtype=float) assert len(b_arr) == len(t_arr), "rounded_prism(): bottom and top polygons must have the same number of vertices." for delta, z in deduped: frac = z / h_val base_z = (1.0 - frac) * b_arr + frac * t_arr if abs(delta) < 1e-12: ring = [[p[0], p[1], z] for p in base_z] else: off = list(_Path(base_z.tolist()).offset(delta=delta)) ring = [[float(p[0]), float(p[1]), z] for p in off] if ring: profiles_3d.append(ring) if len(profiles_3d) < 2: return VNF([], []) # Normalise rings maxn = max(len(r) for r in profiles_3d) from pybosl2.path3d import Path3D as _Path3D norm = [_Path3D(row).subdivide_path(points=maxn, closed=True) for row in profiles_3d] cap_specs = norm_caps(caps) if has_decorative_caps(cap_specs): vnf = VNF.vertex_array(norm, col_wrap=True, style=style) vnf = vnf if vnf.volume() >= 0 else vnf.reverse() norm_arr = np.asarray(norm, dtype=float) center1 = list(norm_arr[0].mean(axis=0)) center2 = list(norm_arr[-1].mean(axis=0)) radius = float(max(np.linalg.norm(p[:2] - np.asarray(center1[:2])) for p in norm_arr[0])) outdir1 = [center1[i] - center2[i] for i in range(3)] outdir2 = [center2[i] - center1[i] for i in range(3)] return vnf_with_decorative_caps(vnf, cap_specs, False, [center1, center2], [outdir1, outdir2], radius) vnf = VNF.vertex_array( norm, caps=cap_specs, col_wrap=True, style=style, ) return vnf if vnf.volume() >= 0 else vnf.reverse() def _join_prism( polygon: Sequence[Sequence[float]], height: float, fillet: float = 0.0, steps: int = 16, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> VNF | Bosl2Solid: """Join an arbitrary prism to a base plane with a filleted transition (BOSL2 join_prism()). Uses :func:`_offset_sweep` with an outward bottom flare (os_circle(radius=-fillet)) to create the rounded fillet joint. """ bottom_desc = os_circle(radius=-fillet) if fillet > 0 else None return _offset_sweep(polygon, height=height, bottom=bottom_desc, steps=steps, caps=caps, style=style) def _prism_connector( profile: Sequence[Sequence[float]], length: float, fillet: float = 0.0, fillet1: float | None = None, fillet2: float | None = None, steps: int = 16, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> VNF | Bosl2Solid: """Construct a filleted prism connecting two objects (BOSL2 prism_connector()). Uses :func:`_offset_sweep` with outward flares at both ends (os_circle(radius=-fillet)) to create the filleted joints. """ f1 = fillet1 if fillet1 is not None else fillet f2 = fillet2 if fillet2 is not None else fillet bot_desc = os_circle(radius=-f1) if f1 > 0 else None top_desc = os_circle(radius=-f2) if f2 > 0 else None return _offset_sweep(profile, height=length, bottom=bot_desc, top=top_desc, steps=steps, caps=caps, style=style) def _attach_prism( profile: Sequence[Sequence[float]], length: float, fillet: float = 0.0, rounding: float = 0.0, steps: int = 16, caps: CapsSpec = CapType.BUTT, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> VNF | Bosl2Solid: """Attach a filleted prism with optional rounded end (BOSL2 attach_prism()). Uses :func:`_offset_sweep` with a bottom flare (os_circle(radius=-fillet)) and top roundover (os_circle(radius=rounding)) to create the filleted joints. """ bot_desc = os_circle(radius=-fillet) if fillet > 0 else None top_desc = os_circle(radius=rounding) if rounding > 0 else None return _offset_sweep(profile, height=length, bottom=bot_desc, top=top_desc, steps=steps, caps=caps, style=style) def _bent_cutout_mask( radius: float, thickness: float, path: Sequence[Sequence[float]], style: VNFStyle = VNFStyle.MIN_EDGE, ) -> VNF: """Create a mask to generate a round-edged cutout in a cylindrical shell (BOSL2 bent_cutout_mask()). Wraps a 2-D path around a cylinder of *radius* and extrudes it radially by *thickness*. Args: radius: Radius of the cylinder to wrap around. thickness: Radial thickness of the mask. path: 2-D path/polygon defining the cutout profile. style: Subdivision style. """ pts = [list(map(float, p)) for p in path] if not pts: return VNF([], []) # Ensure closed loop if len(pts) > 1 and np.allclose(pts[0], pts[-1], atol=1e-9): pts.pop() inner_ring = [] outer_ring = [] r_in = radius - thickness / 2.0 r_out = radius + thickness / 2.0 for x, y in pts: theta = x / radius c = math.cos(theta) s = math.sin(theta) inner_ring.append([r_in * c, r_in * s, y]) outer_ring.append([r_out * c, r_out * s, y]) vnf = VNF.vertex_array([inner_ring, outer_ring], caps=CapType.BUTT, col_wrap=True, style=style) return vnf if vnf.volume() >= 0 else vnf.reverse() # --------------------------------------------------------------------------------------------- # path_sweep2d() -- sweep a 2-D shape along a 2-D path (creases allowed) # --------------------------------------------------------------------------------------------- def _path_sweep2d( shape: Sequence[Sequence[float]] | Path2D, path: Sequence[Sequence[float]] | Path2D, closed: bool = False, caps: CapsSpec = CapType.BUTT, quality: int = 1, style: VNFStyle = VNFStyle.MIN_EDGE, ) -> VNF | Bosl2Solid: """Sweep a 2-D *shape* along a 2-D *path* (internal implementation). Public API: use :meth:`Sweepable.path_sweep2d` instead of calling this directly. """ from pybosl2.path2d import Path2D _ = quality shp: Path2D = shape if isinstance(shape, Path2D) else Path2D(shape) p: Path2D = path if isinstance(path, Path2D) else Path2D(path) cap_specs = norm_caps(caps, closed=closed) profile = shp if not shp.is_clockwise() else shp.reverse() # ccw_polygon flip = -1.0 if (closed and p.is_clockwise()) else 1.0 pth = p if flip > 0 else p.reverse() # 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], same_length=True) 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]] if has_decorative_caps(cap_specs): vnf = VNF.vertex_array(grid, col_wrap=True, style=style) vnf = vnf if vnf.volume() >= 0 else vnf.reverse() grid_arr = np.asarray(grid, dtype=float) center1 = list(grid_arr[0].mean(axis=0)) center2 = list(grid_arr[-1].mean(axis=0)) radius = float(max(np.linalg.norm(p[:2] - np.asarray(center1[:2])) for p in grid_arr[0])) outdir1 = [center1[i] - center2[i] for i in range(3)] outdir2 = [center2[i] - center1[i] for i in range(3)] return vnf_with_decorative_caps(vnf, cap_specs, closed, [center1, center2], [outdir1, outdir2], radius) vnf = VNF.vertex_array( grid, caps=cap_specs, 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[float]: """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]], num_copies: int | Sequence[int], twist: float | Sequence[float] | None = None, scale: float | Sequence[float] | None = None, smoothlen: int = 1, long: bool = False, turns: float = 0, closed: bool = False, method: ResampleMethod = ResampleMethod.LENGTH, ) -> list[list[list[float]]]: """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 num_copies: 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, "rot_resample(): smoothlen must be a positive odd integer." assert smoothlen % 2 == 1, "rot_resample(): smoothlen must be a positive odd integer." assert isinstance(method, ResampleMethod) m = len(rotlist_extra) tcount = m + (0 if closed else -1) if method == ResampleMethod.LENGTH: assert isinstance(num_copies, int), "rot_resample(): num_copies must be an integer for method='length'." count = (num_copies + 1) if closed else num_copies else: count = int(tcount * num_copies + 1) if isinstance(num_copies, (int, float)) else int(sum(num_copies) + 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 == ResampleMethod.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 == ResampleMethod.COUNT: nlist = [int(num_copies)] * tcount if isinstance(num_copies, (int, float)) else [int(x) for x in num_copies] samples = [[k / N for k in range(N)] for N in nlist] 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) ]