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

from typing import TYPE_CHECKING, cast

if TYPE_CHECKING:
    from pybosl2._backend import Solid

import math

from pybosl2._backend import csg_part
from pybosl2._helpers import frag_count as _segs
from pybosl2.color import Color
from pybosl2.exceptions import Bosl2ValueError
from pybosl2.parts._buildable import Buildable
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 = [
    Color([0.2, 0.2, 0.2]),
    Color([1.0, 0.2, 0.2]),
    Color([0.0, 0.8, 0.0]),
    Color([1.0, 1.0, 0.2]),
    Color([0.3, 0.3, 1.0]),
    Color([1.0, 1.0, 1.0]),
    Color([0.7, 0.5, 0.0]),
    Color([0.5, 0.5, 0.5]),
    Color([0.2, 0.9, 0.9]),
    Color([0.8, 0.0, 0.8]),
    Color([0.0, 0.6, 0.6]),
    Color([1.0, 0.7, 0.7]),
    Color([1.0, 0.5, 1.0]),
    Color([0.5, 0.6, 0.0]),
    Color([1.0, 0.7, 0.0]),
    Color([0.7, 1.0, 0.5]),
    Color([0.6, 0.6, 1.0]),
]


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

    ``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*.

    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(Buildable): """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 Bosl2ValueError("wire_bundle() needs at least one wire.") self._wires: int = wires self._wirediam: float = wirediam # The spec above is all a caller needs to *measure* this part; the geometry # below is deferred to `shape` (SPEC C-14, PLAN O-2). self._args = ( path, wires, wirediam, rounding, wirenum, corner_steps, ) self._solid: "Bosl2Solid | None" = None def _build(self) -> "Bosl2Solid": """Build the geometry. Called once, on the first access to `shape`.""" ( path, wires, wirediam, rounding, wirenum, corner_steps, ) = self._args 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: "Solid | None" = None for i in range(wires): ox, oy = offsets[i] prof = [[x + ox, y + oy] for x, y in profile] wire = rounded_path.path_sweep(prof) wire = wire.color(_WIRE_COLORS[(i + wirenum) % len(_WIRE_COLORS)]) bundle = wire if bundle is None else (bundle | wire) assert bundle is not None return Bosl2Solid(cast("Bosl2Solid", bundle).shape, size=None) @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 @property @csg_part( "sweeps each wire along the route with path_sweep(), and a swept tube that follows bends " "is a non-convex mesh with no distance-field form" ) def shape(self) -> Bosl2Solid: """Return the wire bundle geometry.""" if self._solid is None: self._solid = self._build() return self._solid