Source code for reasitic.spiral_helpers

"""Spiral geometry / cell-sizing helper functions.

Mirrors a cluster of small spiral-parameterisation helpers from the
binary that the OptSq / OptPoly inner loops invoke per iteration:

* :func:`spiral_max_n`            ↔ ``spiral_FindMaxN`` (decomp ``0x08072a80``)
* :func:`spiral_radius_for_n`     ↔ ``spiral_radius_for_N`` (``0x0806c608``)
* :func:`spiral_turn_position`    ↔ ``spiral_turn_position_recursive`` (``0x080943ac``)
* :func:`wire_position_periodic_fold` ↔ ``wire_position_periodic_fold`` (``0x08094370``)
* :func:`segment_pair_distance_metric` ↔ ``segment_pair_distance_metric`` (``0x08094a5c``)

The spiral-type encoding is the binary's case-table:

* ``0``  / ``3`` / ``5`` / ``0x10`` / ``0x12`` — square / symmetric-square
                                                  / rectangular variants
* ``1``  / ``0x11`` / ``0x14``                  — polygon spiral, symmetric
                                                  polygon, etc.

For the canonical Python-side calls a handful of named-string
aliases (``"square"``, ``"polygon"``, ...) are accepted so callers
don't need to remember the magic codes.
"""

from __future__ import annotations

import math

# Spiral-type code tables (mirrors decomp case statements)
_SQUARE_LIKE = (0, 3, 5, 0x10, 0x12)
_POLYGON_LIKE = (1, 0x11, 0x14)

_NAME_TO_CODE = {
    "square": 0,
    "rect": 0,
    "rectangle": 0,
    "symsq": 3,
    "symmetric_square": 3,
    "spiral": 1,
    "polygon": 1,
    "sympoly": 0x11,
    "symmetric_polygon": 0x11,
}


def _resolve_type(spiral_type: int | str) -> int:
    if isinstance(spiral_type, int):
        return spiral_type
    code = _NAME_TO_CODE.get(spiral_type.lower())
    if code is None:
        raise ValueError(
            f"unknown spiral type {spiral_type!r}; "
            f"choose from {sorted(_NAME_TO_CODE)}"
        )
    return code


[docs] def spiral_max_n( *, outer_dim_um: float, width_um: float, spacing_um: float, spiral_type: int | str = "square", sides: int = 4, ) -> float: """Maximum integer turn count that fits the spiral footprint. Mirrors ``spiral_FindMaxN`` (decomp ``0x08072a80``): * Square (type 0): ``N = round(L / (1 + 2(W + S)))`` with a Q1-quartic refinement for fractional turns. * Polygon (type 1): ``N = (L − W) · cos(π/sides) / (S + W) − 2``. Args: outer_dim_um: Outer-edge length / radius in μm. width_um: Trace width in μm. spacing_um: Edge-to-edge spacing in μm. spiral_type: ``"square"`` or ``"polygon"`` (or the binary's integer code). sides: Polygon side count for polygon spirals. Returns: The maximum (possibly fractional) ``N``. Returns ``-1.0`` if the spiral type is unrecognised, matching the binary's error-path return value. """ code = _resolve_type(spiral_type) L = float(outer_dim_um) W = float(width_um) S = float(spacing_um) if code == 0: # Square: linear formula with a quartile refinement pitch = W + S x = L / (1.0 + 2.0 * pitch) n_int = round(x) # Q1 refinement: round(x*4) - 4*round(x), then divide by 4 q1 = round(x * 4.0) - 4.0 * n_int return float(n_int + q1 * 0.25 - 0.25) if code == 1: if sides <= 0: raise ValueError("sides must be positive for polygon spirals") cos_factor = math.cos(math.pi / float(sides)) return float(((L - W) * cos_factor) / (S + W) - 2.0) return -1.0
[docs] def spiral_radius_for_n( *, outer_dim_um: float, width_um: float, spacing_um: float, sides: int = 4, spiral_type: int | str = "square", ) -> float: """Inverse of :func:`spiral_max_n` — given the parameters, return the corner-rounded radius. Mirrors ``spiral_radius_for_N`` (decomp ``0x0806c608``). Square-like cases use ``r = 0.5 · (L + W) / (W + S)``; polygon- like cases use ``r = L · cos(π/sides) / (W + S)``. The result is then quantised on a 1/sides grid: ``r = round(r) + round((r − round(r)) · sides) / sides``, which is the binary's quirky mid-fractional-turn snap. For the ``0x10 / 0x11`` symmetric-rect variants the result is fully rounded to an integer at the end (no fractional turns allowed). """ code = _resolve_type(spiral_type) L = float(outer_dim_um) W = float(width_um) S = float(spacing_um) if code in _SQUARE_LIKE: r = 0.5 * (L + W) / (W + S) elif code in _POLYGON_LIKE: if sides <= 0: raise ValueError("sides must be positive for polygon spirals") r = L * math.cos(math.pi / float(sides)) / (W + S) else: return 1e15 # Mid-fractional-turn snap on a 1/sides grid base = round(r) frac = round((r - base) * sides) / float(sides) if sides > 0 else 0.0 r = base + frac if code - 0x10 < 2: # 0x10, 0x11 — symmetric-rect / symmetric-poly return float(round(r)) return float(r)
[docs] def spiral_turn_position( *, i: int, outer_dim_um: float, width_um: float, spacing_um: float, fold_size: int, ) -> float: """Position of the ``i``-th wire turn, with reflection across the fold. Mirrors ``spiral_turn_position_recursive`` (decomp ``0x080943ac``): * If ``fold_size < i``, recurse with the reflected index ``(2·fold_size − i) + 1`` and negate. * Otherwise return ``0.5 · (outer_dim − W) − (W + S) · (i − 1)``. Used by the symmetric-spiral builders to place each turn at the right offset. """ if fold_size < i: inner = spiral_turn_position( i=(fold_size * 2 - i) + 1, outer_dim_um=outer_dim_um, width_um=width_um, spacing_um=spacing_um, fold_size=fold_size, ) return -inner return 0.5 * (outer_dim_um - width_um) - (width_um + spacing_um) * (i - 1)
[docs] def wire_position_periodic_fold( *, i: int, outer_dim_um: float, width_um: float, spacing_um: float, fold_size: int, ) -> float: """1-D position-folding helper for the wire-discretisation pass. Mirrors ``wire_position_periodic_fold`` (decomp ``0x08094370``). Reflects ``i`` across the fold centre until it lands in ``[0, fold_size]``, then returns ``(outer − W) − (W + S) · 2 · (i − 1)``. """ while fold_size < i: i = (fold_size * 2 - i) + 1 return (outer_dim_um - width_um) - (width_um + spacing_um) * 2.0 * (i - 1)
[docs] def segment_pair_distance_metric( seg: object, ) -> int: """Cheap integer distance metric used to sort segment pairs. Mirrors ``segment_pair_distance_metric`` (decomp ``0x08094a5c``): .. code-block:: text metric = ((seg[0x10] − seg[8]) // 1000) + ((seg[0xc] − seg[4]) · 1000) The decomp reads four ``int`` fields from a 32-byte segment record. The Python equivalent picks the same fields off any object that exposes integer-coercible ``a.x``, ``a.y``, ``b.x``, ``b.y`` attributes (e.g. our :class:`reasitic.geometry.Segment`). """ a_x = int(seg.a.x) # type: ignore[attr-defined] a_y = int(seg.a.y) # type: ignore[attr-defined] b_x = int(seg.b.x) # type: ignore[attr-defined] b_y = int(seg.b.y) # type: ignore[attr-defined] return (b_x - a_x) // 1000 + (b_y - a_y) * 1000