Source code for pybosl2.parts.wiring

# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause

# LibFile: pybosl2/parts/wiring.py
#    Pure-Python port of BOSL2's wiring.scad: rendering for routed bundles of wires.
#    :class:`WireBundle` sweeps a hexagonally-packed bundle of round wires along a path whose
#    corners are rounded, colouring each wire from a 17-entry table.
#    :func:`hex_offsets` exposes the optimal hex-packing centre points it uses.
#
# FileSummary: Routed bundles of wires.
# DocCategory: Parts library
# FileGroup: BOSL2

"""Routed bundles of wires."""

from __future__ import annotations

import math

from pybosl2.path3d import Path3D
from pybosl2.shapes3d import Bosl2Solid

__all__ = ["WireBundle", "hex_offsets"]

# The 17 base wire colours, in the same order as BOSL2 wiring.scad.
_WIRE_COLORS = [
    [0.2, 0.2, 0.2],
    [1.0, 0.2, 0.2],
    [0.0, 0.8, 0.0],
    [1.0, 1.0, 0.2],
    [0.3, 0.3, 1.0],
    [1.0, 1.0, 1.0],
    [0.7, 0.5, 0.0],
    [0.5, 0.5, 0.5],
    [0.2, 0.9, 0.9],
    [0.8, 0.0, 0.8],
    [0.0, 0.6, 0.6],
    [1.0, 0.7, 0.7],
    [1.0, 0.5, 1.0],
    [0.5, 0.6, 0.0],
    [1.0, 0.7, 0.0],
    [0.7, 1.0, 0.5],
    [0.6, 0.6, 1.0],
]


def _segs(r: float) -> int:
    """OpenSCAD segs(r) with the default $fa=12, $fs=2."""
    return max(5, math.ceil(min(360 / 12, 2 * math.pi * r / 2)))


def _hex_offset_ring(d: float, lev: int) -> list[list[float]]:
    """Return a hexagonal ring of packing centres spaced *d* apart (BOSL2 _hex_offset_ring()).

    ``lev=0`` is the single centre point; ``lev>=1`` is a hexagon of ``6*lev`` points.
    """
    if lev == 0:
        return [[0.0, 0.0]]
    r = lev * d  # hexagon circumradius; side length == r
    corners = [(r * math.cos(math.radians(60 * k)), r * math.sin(math.radians(60 * k))) for k in range(6)]
    pts: list[list[float]] = []
    for k in range(6):  # subdivide each edge into lev segments
        x0, y0 = corners[k]
        x1, y1 = corners[(k + 1) % 6]
        for s in range(lev):
            t = s / lev
            pts.append([x0 + (x1 - x0) * t, y0 + (y1 - y0) * t])
    pts.reverse()
    return pts


def _hex_offsets(n: int, d: float) -> list[list[float]]:
    """Centres for the optimal hex packing of at least *n* circles of spacing *d* (BOSL2 _hex_offsets()).

    Fills out the final ring, so the result may hold more than *n* points.
    """
    arr: list[list[float]] = []
    lev = 0
    while len(arr) < n:
        arr += _hex_offset_ring(d, lev)
        lev += 1
    return arr


[docs] def hex_offsets(sides: int, diameter: float) -> list[list[float]]: """Return the centre points for the optimal hexagonal packing of at least *sides* circles. Circles are spaced *diameter* apart. Args: sides: Minimum number of circles to pack. diameter: Centre-to-centre spacing between circles. Returns: A list of ``[x, y]`` centre offsets. """ return _hex_offsets(sides, diameter)
[docs] class WireBundle: """A bundle of round wires routed along a path with rounded corners. The wires are hex-packed in the bundle cross-section and each is coloured from the 17-entry table (re-used, offset by *wirenum*, if there are more than 17). *wirediam* is each wire's diameter; *corner_steps* sets how finely the rounded corners are faceted. Examples: A 13-wire bundle routed around three corners: .. pythonscad-example:: from pybosl2.parts.wiring import WireBundle WireBundle([[50, 0, -50], [50, 50, -50], [0, 50, -50], [0, 0, -50], [0, 0, 0]], wires=13, rounding=10).show() """ def __init__( self, path: list[list[float]], wires: int, wirediam: float = 2, rounding: float = 10, wirenum: int = 0, corner_steps: int = 15, ) -> None: """Create a wire bundle routed along *path*. Args: path: A list of 3-D points defining the bundle route. wires: Number of wires in the bundle. wirediam: Diameter of each wire in mm. rounding: Radius for rounding path corners. wirenum: Starting index into the colour table for offset colouring. corner_steps: Number of facets per rounded corner. Returns: None. Raises: ValueError: If *wires* is less than 1. """ if wires < 1: raise ValueError("wire_bundle() needs at least one wire.") sides = max(_segs(wirediam / 2), 8) offsets = _hex_offsets(wires, wirediam) rounded_path = Path3D(path, closed=False).round_corners(radius=rounding, fn=(corner_steps + 1) * 4) radius = wirediam / 2 profile = [ [radius * math.cos(2 * math.pi * k / sides), radius * math.sin(2 * math.pi * k / sides)] for k in range(sides) ] bundle: Bosl2Solid | None = None for i in range(wires): ox, oy = offsets[i] prof = [[x + ox, y + oy] for x, y in profile] wire = Bosl2Solid(rounded_path.path_sweep(prof).polyhedron()) # type: ignore[attr-defined] wire = wire.color(_WIRE_COLORS[(i + wirenum) % len(_WIRE_COLORS)]) bundle = wire if bundle is None else (bundle | wire) assert bundle is not None self._solid: Bosl2Solid = Bosl2Solid(bundle.shape, size=None) self._wires: int = wires self._wirediam: float = wirediam @property def wires(self) -> int: """Number of wires in the bundle.""" return self._wires @property def wirediam(self) -> float: """Wire diameter in mm.""" return self._wirediam
[docs] def shape(self) -> Bosl2Solid: """Return the wire bundle geometry.""" return self._solid
[docs] def show(self) -> None: """Display the wire bundle in the viewer.""" self._solid.show()