# 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()