1245 lines
51 KiB
Python
1245 lines
51 KiB
Python
"""
|
|
Primitive-offer route selection for `Pather`.
|
|
|
|
`RoutingPlanner` is the stateless boundary between `Pather` routing calls and
|
|
Tool primitive offers. `Pather` passes copied `RoutePortContext` snapshots here;
|
|
the planner returns `PreparedRouteResult` records that describe pending
|
|
mutations without applying them to the live Pattern.
|
|
|
|
Public routing modes and bounds are normalized by `bounds.py` before the solver
|
|
sees them. This module plans one route intent at a time: it queries Tool offers,
|
|
enumerates bounded primitive compositions, inserts ptype adapters, solves
|
|
primitive parameters, ranks candidates, and commits only the selected offers
|
|
into `RenderStep` payloads.
|
|
|
|
All search is performed in Tool-local route coordinates. The active input port
|
|
is at the origin, travel is along +x, and positive jog is to the left. After a
|
|
candidate is selected, committed steps are transformed back into layout-space
|
|
using the copied starting port.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
# ruff: noqa: ANN401,PLR0912,PLR0913,PLR0915,TC001,TC002,TC003
|
|
|
|
from collections.abc import Iterable, Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from itertools import combinations
|
|
from math import cos, isclose as math_isclose, sin
|
|
from typing import Any, Literal
|
|
|
|
import numpy
|
|
from numpy import pi
|
|
from numpy.typing import ArrayLike
|
|
|
|
from ...error import BuildError, PortError
|
|
from ...ports import Port
|
|
from ...utils import PTypeMatch, SupportsBool, ptype_match, ptypes_compatible
|
|
from ..tools import (
|
|
BendOffer,
|
|
PrimitiveKind,
|
|
PrimitiveOffer,
|
|
RenderStep,
|
|
SOffer,
|
|
StraightOffer,
|
|
Tool,
|
|
)
|
|
from ..utils import ell
|
|
from . import bounds as planner_bounds
|
|
from .interface import (
|
|
PreparedRouteAction,
|
|
PreparedRouteResult,
|
|
RoutePlanningError,
|
|
RoutePortContext,
|
|
)
|
|
|
|
|
|
def is_close(a: float, b: float) -> bool:
|
|
"""Compare route-solver scalars with the planner tolerance."""
|
|
return math_isclose(float(a), float(b), rel_tol=1e-5, abs_tol=1e-8)
|
|
|
|
|
|
def clean_parameter(value: float) -> float:
|
|
"""Snap tiny solver noise in primitive parameters before domain checks."""
|
|
rounded = round(float(value))
|
|
if abs(float(value) - rounded) <= 1e-8:
|
|
return float(rounded)
|
|
if abs(float(value)) <= 1e-10:
|
|
return 0.0
|
|
return float(value)
|
|
|
|
|
|
def minimum_parameter(offer: PrimitiveOffer, route_name: str) -> float:
|
|
"""Return an offer's deterministic minimum legal parameter."""
|
|
lower, upper = offer.parameter_domain
|
|
if lower != upper and lower >= upper:
|
|
raise BuildError(f'{route_name} primitive has an invalid parameter domain {offer.parameter_domain}')
|
|
if not numpy.isfinite(lower):
|
|
raise BuildError(f'{route_name} primitive has no finite minimum parameter')
|
|
return offer.canonicalize_parameter(lower)
|
|
|
|
|
|
def minimum_nonzero_parameters(offer: PrimitiveOffer) -> tuple[float, ...]:
|
|
"""Return deterministic nonzero endpoint parameters near an offer domain edge."""
|
|
lower, upper = offer.parameter_domain
|
|
if lower == upper:
|
|
try:
|
|
value = offer.canonicalize_parameter(lower)
|
|
except BuildError:
|
|
return ()
|
|
return () if is_close(value, 0) else (value,)
|
|
|
|
candidates: list[float] = []
|
|
if lower > 0 and numpy.isfinite(lower):
|
|
candidates.append(float(lower))
|
|
if upper < 0 and numpy.isfinite(upper):
|
|
candidates.append(float(numpy.nextafter(upper, -numpy.inf)))
|
|
|
|
selected: list[float] = []
|
|
for value in candidates:
|
|
try:
|
|
parameter = offer.canonicalize_parameter(value)
|
|
except BuildError:
|
|
continue
|
|
if not is_close(parameter, 0) and not any(is_close(parameter, prev) for prev in selected):
|
|
selected.append(parameter)
|
|
return tuple(selected)
|
|
|
|
|
|
def adapter_s_parameter(offer: PrimitiveOffer, residual_jog: float) -> float:
|
|
"""Choose a deterministic S-adapter parameter, preferring residual-jog direction."""
|
|
candidates = minimum_nonzero_parameters(offer)
|
|
if not candidates:
|
|
raise BuildError('S adapter has no finite deterministic parameter')
|
|
|
|
residual_sign = numpy.sign(residual_jog)
|
|
|
|
def key(item: tuple[int, float]) -> tuple[float, int, int]:
|
|
index, value = item
|
|
sign = numpy.sign(value)
|
|
sign_rank = 0 if is_close(residual_jog, 0) or sign == residual_sign else 1
|
|
return round(abs(value), 9), sign_rank, index
|
|
|
|
return min(enumerate(candidates), key=key)[1]
|
|
|
|
|
|
def is_adapter_offer(offer: PrimitiveOffer) -> bool:
|
|
"""Return true for straight/S offers that intentionally change concrete ptype."""
|
|
return (
|
|
isinstance(offer, StraightOffer | SOffer)
|
|
and ptype_match(offer.in_ptype, offer.in_ptype) is PTypeMatch.EXACT
|
|
and ptype_match(offer.out_ptype, offer.out_ptype) is PTypeMatch.EXACT
|
|
and ptype_match(offer.in_ptype, offer.out_ptype) is PTypeMatch.MISMATCH
|
|
)
|
|
|
|
|
|
def raise_if_fatal(err: Exception) -> None:
|
|
"""Propagate fatal planning errors while allowing normal candidate rejection."""
|
|
if getattr(err, 'fatal', False):
|
|
raise err
|
|
|
|
|
|
def solve_small_lstsq(
|
|
matrix: Sequence[Sequence[float]],
|
|
residual: Sequence[float],
|
|
) -> tuple[float, ...] | None:
|
|
"""
|
|
Solve tiny least-squares systems without always paying NumPy setup cost.
|
|
|
|
Route parameter solving only uses one or two constraints and one or two
|
|
adjustable primitive parameters. Closed forms keep common cases simple;
|
|
NumPy remains the fallback for degenerate or future larger systems.
|
|
"""
|
|
rows = len(matrix)
|
|
cols = len(matrix[0]) if rows else 0
|
|
if rows == 1 and cols == 1:
|
|
a = matrix[0][0]
|
|
return None if a == 0 else (residual[0] / a,)
|
|
if rows == 1 and cols == 2:
|
|
a, b = matrix[0]
|
|
denom = a * a + b * b
|
|
return None if denom == 0 else (residual[0] * a / denom, residual[0] * b / denom)
|
|
if rows == 2 and cols == 1:
|
|
a = matrix[0][0]
|
|
b = matrix[1][0]
|
|
denom = a * a + b * b
|
|
return None if denom == 0 else ((a * residual[0] + b * residual[1]) / denom,)
|
|
if rows == 2 and cols == 2:
|
|
a, b = matrix[0]
|
|
c, d = matrix[1]
|
|
determinant = a * d - b * c
|
|
if determinant != 0:
|
|
return (
|
|
(d * residual[0] - b * residual[1]) / determinant,
|
|
(-c * residual[0] + a * residual[1]) / determinant,
|
|
)
|
|
matrix_array = numpy.array(matrix)
|
|
deltas, _residuals, _rank, _singular = numpy.linalg.lstsq(matrix_array, numpy.array(residual), rcond=None)
|
|
return tuple(float(delta) for delta in deltas)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SelectedPrimitive:
|
|
"""
|
|
One evaluated primitive offer in a candidate route.
|
|
|
|
`out_port` is still in route-local coordinates. `role` distinguishes
|
|
primitives that satisfy the requested route shape from ptype adapters that
|
|
the grammar may insert around those primitives.
|
|
"""
|
|
offer: PrimitiveOffer
|
|
"""Offer selected for this primitive step."""
|
|
parameter: float
|
|
"""Canonicalized offer parameter used for endpoint, cost, and commit."""
|
|
out_port: Port
|
|
"""Route-local endpoint produced by the selected offer."""
|
|
cost: float
|
|
"""Finite additive planning cost reported by the offer."""
|
|
role: Literal['main', 'adapter'] = 'main'
|
|
"""Whether this step satisfies route geometry or adapts ptype."""
|
|
route_kind: PrimitiveKind | None = None
|
|
"""Primitive kind used when querying the Tool for this step."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Candidate:
|
|
"""A fully solved primitive sequence with its composed local endpoint."""
|
|
steps: tuple[SelectedPrimitive, ...]
|
|
"""Ordered primitive sequence selected by the grammar."""
|
|
end_port: Port
|
|
"""Composed route-local endpoint."""
|
|
cost: float
|
|
"""Sum of primitive costs."""
|
|
order: int
|
|
"""Deterministic discovery order used as the final tie-breaker."""
|
|
public_length: float
|
|
"""Length reported back to bundle planning for omitted-length anchors."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RouteRequest:
|
|
"""
|
|
Normalized solver input for one route leg.
|
|
|
|
Public Pather calls are converted into this smaller shape before grammar
|
|
enumeration. `length`, `jog`, and `out_ptype` become endpoint constraints;
|
|
`route_kwargs` are forwarded to Tool primitive-offer generation.
|
|
"""
|
|
family: PrimitiveKind
|
|
"""High-level route family being solved."""
|
|
tool: Tool
|
|
"""Tool queried for primitive offers."""
|
|
in_ptype: str | None
|
|
"""Input ptype at the start of the route."""
|
|
route_kwargs: Mapping[str, Any]
|
|
"""Tool kwargs forwarded to primitive-offer generation."""
|
|
length: float | None = None
|
|
"""Requested local x displacement, when constrained."""
|
|
jog: float | None = None
|
|
"""Requested local y displacement, when constrained."""
|
|
ccw: SupportsBool | None = None
|
|
"""Requested bend direction for single-bend routes."""
|
|
out_ptype: str | None = None
|
|
"""Requested final endpoint ptype."""
|
|
constrain_jog: bool = False
|
|
"""Whether bend-family trace_into routes must also match `jog`."""
|
|
max_bends: int | None = None
|
|
"""Optional override for grammar bend budget."""
|
|
|
|
@property
|
|
def route_name(self) -> str:
|
|
if self.family in ('straight', 'bend'):
|
|
return 'trace'
|
|
if self.family == 's':
|
|
return 'S-bend'
|
|
return 'U-turn'
|
|
|
|
@property
|
|
def out_rotation(self) -> float:
|
|
if self.family == 'straight':
|
|
return pi
|
|
if self.family == 'bend':
|
|
return -pi / 2 if bool(self.ccw) else pi / 2
|
|
if self.family == 's':
|
|
return pi
|
|
return 0.0
|
|
|
|
@property
|
|
def bend_budget(self) -> int:
|
|
if self.max_bends is not None:
|
|
return self.max_bends
|
|
if self.family == 'straight':
|
|
return 0
|
|
if self.family == 'bend':
|
|
return 1
|
|
return 2
|
|
|
|
|
|
class Solver:
|
|
"""
|
|
Bounded grammar solver for composed primitive routes.
|
|
|
|
The grammar is `A? (N A? (B|S|U) A?)* N A?`, where `A` is a ptype adapter,
|
|
`N` is a normal straight-like primitive, and the middle term is either a
|
|
bend primitive, a Tool-provided S/U primitive, or a composed S/U route made
|
|
from bend primitives. Parameter solving happens after a sequence is
|
|
enumerated so fixed and adjustable offers share the same path.
|
|
"""
|
|
|
|
def __init__(self, request: RouteRequest) -> None:
|
|
self.request = request
|
|
self.eval_cache: dict[tuple[int, float, str | None, str | None, str, str, PrimitiveKind | None], SelectedPrimitive] = {}
|
|
self.offer_cache: dict[
|
|
tuple[PrimitiveKind, str | None, str | None, tuple[tuple[str, Any], ...]],
|
|
tuple[PrimitiveOffer, ...],
|
|
] = {}
|
|
self.order = 0
|
|
|
|
def solve(self) -> Candidate:
|
|
"""
|
|
Enumerate, finalize, deduplicate, and rank legal candidates.
|
|
|
|
Non-fatal candidate errors are accumulated so the failure message can
|
|
preserve useful Tool feedback. Fatal offer-contract errors stop the
|
|
solve immediately.
|
|
"""
|
|
def endpoint_key(port: Port) -> tuple[float, float, float | None, str | None]:
|
|
return (
|
|
round(float(port.x), 9),
|
|
round(float(port.y), 9),
|
|
None if port.rotation is None else round(float(port.rotation), 9),
|
|
port.ptype,
|
|
)
|
|
|
|
def candidate_key(candidate: Candidate) -> tuple[Any, ...]:
|
|
def offer_key(offer: PrimitiveOffer) -> tuple[Any, ...]:
|
|
return (
|
|
type(offer).__qualname__,
|
|
offer.in_ptype,
|
|
offer.out_ptype,
|
|
round(float(offer.priority_bias), 9),
|
|
tuple(round(float(value), 9) for value in offer.parameter_domain),
|
|
getattr(offer, 'ccw', None),
|
|
id(offer.endpoint_planner),
|
|
id(offer.commit_planner),
|
|
)
|
|
|
|
return (
|
|
endpoint_key(candidate.end_port),
|
|
tuple((
|
|
offer_key(step.offer),
|
|
step.role,
|
|
step.route_kind,
|
|
round(float(step.parameter), 9),
|
|
endpoint_key(step.out_port),
|
|
) for step in candidate.steps),
|
|
)
|
|
|
|
candidates: list[Candidate] = []
|
|
errors: list[Exception] = []
|
|
seen: set[tuple[Any, ...]] = set()
|
|
for steps in self.enumerate_grammar():
|
|
if not steps:
|
|
continue
|
|
if any(first.role == 'adapter' and second.role == 'adapter' for first, second in zip(steps, steps[1:], strict=False)):
|
|
continue
|
|
try:
|
|
candidate = self.finalize(steps)
|
|
except (BuildError, NotImplementedError, PortError) as err:
|
|
raise_if_fatal(err)
|
|
errors.append(err)
|
|
continue
|
|
key = candidate_key(candidate)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
candidates.append(candidate)
|
|
|
|
if not candidates:
|
|
for err in errors:
|
|
if getattr(err, 'fatal', False):
|
|
raise err
|
|
if errors:
|
|
last_error = errors[-1]
|
|
if self.request.route_name in str(last_error):
|
|
raise last_error
|
|
raise BuildError(f'{self.request.route_name} route is unsupported: {last_error}') from last_error
|
|
raise BuildError(f'No legal primitive offer for {self.request.route_name}')
|
|
|
|
return min(
|
|
candidates,
|
|
key=lambda candidate: (
|
|
round(float(candidate.cost), 9),
|
|
sum(step.role == 'adapter' for step in candidate.steps),
|
|
len(candidate.steps),
|
|
candidate.order,
|
|
),
|
|
)
|
|
|
|
def primitive_offers(
|
|
self,
|
|
kind: PrimitiveKind,
|
|
in_ptype: str | None,
|
|
*,
|
|
out_ptype: str | None = None,
|
|
extra: Mapping[str, Any] | None = None,
|
|
) -> tuple[PrimitiveOffer, ...]:
|
|
"""Query the active Tool with route kwargs and per-query overrides."""
|
|
kwargs = dict(self.request.route_kwargs)
|
|
kwargs.pop('out_ptype', None)
|
|
if extra:
|
|
kwargs.update(extra)
|
|
try:
|
|
cache_key = (kind, in_ptype, out_ptype, tuple(sorted(kwargs.items())))
|
|
hash(cache_key)
|
|
except TypeError:
|
|
return self.request.tool.primitive_offers(kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs)
|
|
|
|
cached = self.offer_cache.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
offers = self.request.tool.primitive_offers(kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs)
|
|
self.offer_cache[cache_key] = offers
|
|
return offers
|
|
|
|
def evaluate(
|
|
self,
|
|
offer: PrimitiveOffer,
|
|
parameter: float,
|
|
in_ptype: str | None,
|
|
*,
|
|
out_ptype: str | None,
|
|
role: Literal['main', 'adapter'],
|
|
route_kind: PrimitiveKind | None,
|
|
route_name: str | None = None,
|
|
) -> SelectedPrimitive:
|
|
"""
|
|
Canonicalize and validate one offer evaluation.
|
|
|
|
This is the single point where the solver checks ptype compatibility,
|
|
endpoint declarations, selected endpoint ptype, finite cost, and
|
|
zero-jog S rejection.
|
|
"""
|
|
route_name = self.request.route_name if route_name is None else route_name
|
|
selected = offer.canonicalize_parameter(clean_parameter(parameter))
|
|
key = (id(offer), round(float(selected), 12), in_ptype, out_ptype, role, route_name, route_kind)
|
|
cached = self.eval_cache.get(key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
if not ptypes_compatible(in_ptype, offer.in_ptype):
|
|
raise BuildError('primitive input ptype is incompatible')
|
|
if isinstance(offer, SOffer) and is_close(selected, 0):
|
|
raise BuildError('zero-jog S primitive candidates are not allowed')
|
|
out_port = offer.endpoint_at(selected)
|
|
if not ptypes_compatible(out_port.ptype, offer.out_ptype):
|
|
raise RoutePlanningError(
|
|
f'{route_name} primitive endpoint ptype does not match declared offer out_ptype',
|
|
fatal=True,
|
|
)
|
|
if out_ptype is not None and not ptypes_compatible(out_port.ptype, out_ptype):
|
|
raise RoutePlanningError(
|
|
'Requested out_ptype does not match primitive endpoint ptype',
|
|
fatal=True,
|
|
)
|
|
cost = float(offer.cost_at(selected))
|
|
if not numpy.isfinite(cost):
|
|
raise BuildError(f'{route_name} primitive returned non-finite cost')
|
|
if cost < 0:
|
|
raise BuildError(f'{route_name} primitive returned negative cost')
|
|
primitive = SelectedPrimitive(
|
|
offer,
|
|
selected,
|
|
out_port,
|
|
cost,
|
|
role=role,
|
|
route_kind=route_kind,
|
|
)
|
|
self.eval_cache[key] = primitive
|
|
return primitive
|
|
|
|
def compose_endpoint(self, steps: Sequence[SelectedPrimitive]) -> Port:
|
|
"""
|
|
Compose local primitive endpoints into one local route endpoint.
|
|
|
|
Primitive output rotations follow Masque's port convention: the port
|
|
points back into the primitive, so each step advances orientation by
|
|
the primitive output rotation plus pi.
|
|
"""
|
|
x = 0.0
|
|
y = 0.0
|
|
angle = 0.0
|
|
ptype: str | None = None
|
|
for step in steps:
|
|
out_port = step.out_port
|
|
if out_port.rotation is None:
|
|
raise BuildError('Primitive endpoints must have rotation')
|
|
angle_cos = cos(angle)
|
|
angle_sin = sin(angle)
|
|
x += angle_cos * float(out_port.x) - angle_sin * float(out_port.y)
|
|
y += angle_sin * float(out_port.x) + angle_cos * float(out_port.y)
|
|
angle += out_port.rotation + pi
|
|
ptype = out_port.ptype
|
|
return Port((x, y), rotation=angle - pi, ptype=ptype)
|
|
|
|
def current_ptype(self, steps: Sequence[SelectedPrimitive]) -> str | None:
|
|
return self.request.in_ptype if not steps else self.compose_endpoint(steps).ptype
|
|
|
|
def adapter_options(
|
|
self,
|
|
steps: Sequence[SelectedPrimitive],
|
|
*,
|
|
residual_jog: float,
|
|
) -> tuple[tuple[SelectedPrimitive, ...], ...]:
|
|
"""Return no-adapter plus single straight/S ptype adapter options."""
|
|
current_ptype = self.current_ptype(steps)
|
|
options: list[tuple[SelectedPrimitive, ...]] = [()]
|
|
for kind in ('straight', 's'):
|
|
try:
|
|
offers = self.primitive_offers(kind, current_ptype, out_ptype=None)
|
|
except NotImplementedError:
|
|
continue
|
|
for offer in offers:
|
|
if not is_adapter_offer(offer):
|
|
continue
|
|
try:
|
|
parameter = (
|
|
minimum_parameter(offer, 'straight adapter')
|
|
if kind == 'straight'
|
|
else adapter_s_parameter(offer, residual_jog)
|
|
)
|
|
selected = self.evaluate(
|
|
offer,
|
|
parameter,
|
|
current_ptype,
|
|
out_ptype=None,
|
|
role='adapter',
|
|
route_kind=kind,
|
|
route_name=f'{kind} adapter',
|
|
)
|
|
except BuildError as err:
|
|
raise_if_fatal(err)
|
|
continue
|
|
except NotImplementedError:
|
|
continue
|
|
options.append((selected,))
|
|
return tuple(options)
|
|
|
|
def straight_options(
|
|
self,
|
|
steps: Sequence[SelectedPrimitive],
|
|
) -> tuple[tuple[SelectedPrimitive, ...], ...]:
|
|
"""Return no-straight plus minimum-parameter non-adapter straight options."""
|
|
current_ptype = self.current_ptype(steps)
|
|
options: list[tuple[SelectedPrimitive, ...]] = [()]
|
|
try:
|
|
offers = self.primitive_offers('straight', current_ptype, out_ptype=None)
|
|
except NotImplementedError:
|
|
return tuple(options)
|
|
for offer in offers:
|
|
if is_adapter_offer(offer):
|
|
continue
|
|
try:
|
|
parameter = minimum_parameter(offer, 'trace')
|
|
selected = self.evaluate(
|
|
offer,
|
|
parameter,
|
|
current_ptype,
|
|
out_ptype=None,
|
|
role='main',
|
|
route_kind='straight',
|
|
route_name='trace',
|
|
)
|
|
except BuildError as err:
|
|
raise_if_fatal(err)
|
|
continue
|
|
except NotImplementedError:
|
|
continue
|
|
options.append((selected,))
|
|
return tuple(options)
|
|
|
|
def bend_options(
|
|
self,
|
|
steps: Sequence[SelectedPrimitive],
|
|
ccw: SupportsBool,
|
|
) -> tuple[tuple[SelectedPrimitive, ...], ...]:
|
|
"""Return legal fixed-direction bend options for the current ptype."""
|
|
current_ptype = self.current_ptype(steps)
|
|
options: list[tuple[SelectedPrimitive, ...]] = []
|
|
try:
|
|
offers = self.primitive_offers('bend', current_ptype, out_ptype=None, extra={'ccw': ccw})
|
|
except NotImplementedError:
|
|
return ()
|
|
for offer in offers:
|
|
if not isinstance(offer, BendOffer):
|
|
continue
|
|
if bool(offer.ccw) != bool(ccw):
|
|
continue
|
|
try:
|
|
parameter = minimum_parameter(offer, 'trace')
|
|
selected = self.evaluate(
|
|
offer,
|
|
parameter,
|
|
current_ptype,
|
|
out_ptype=None,
|
|
role='main',
|
|
route_kind='bend',
|
|
route_name='trace',
|
|
)
|
|
except BuildError as err:
|
|
raise_if_fatal(err)
|
|
continue
|
|
except NotImplementedError:
|
|
continue
|
|
options.append((selected,))
|
|
return tuple(options)
|
|
|
|
def su_primitive_options(
|
|
self,
|
|
steps: Sequence[SelectedPrimitive],
|
|
kind: Literal['s', 'u'],
|
|
jog: float | None = None,
|
|
) -> tuple[tuple[SelectedPrimitive, ...], ...]:
|
|
"""Return Tool-provided S/U primitive options for candidate jogs."""
|
|
current_ptype = self.current_ptype(steps)
|
|
options: list[tuple[SelectedPrimitive, ...]] = []
|
|
try:
|
|
offers = self.primitive_offers(kind, current_ptype, out_ptype=None)
|
|
except NotImplementedError:
|
|
return ()
|
|
route_name = 'S-bend' if kind == 's' else 'U-turn'
|
|
for offer in offers:
|
|
if kind == 's' and not isinstance(offer, SOffer):
|
|
continue
|
|
for parameter in self.su_parameters(offer, kind, jog):
|
|
try:
|
|
selected = self.evaluate(
|
|
offer,
|
|
parameter,
|
|
current_ptype,
|
|
out_ptype=None,
|
|
role='main',
|
|
route_kind=kind,
|
|
route_name=route_name,
|
|
)
|
|
except BuildError as err:
|
|
raise_if_fatal(err)
|
|
continue
|
|
except NotImplementedError:
|
|
continue
|
|
options.append((selected,))
|
|
return tuple(options)
|
|
|
|
def su_parameters(
|
|
self,
|
|
offer: PrimitiveOffer,
|
|
kind: Literal['s', 'u'],
|
|
requested_jog: float | None,
|
|
) -> tuple[float, ...]:
|
|
"""Build deterministic jog-parameter probes for Tool-provided S/U offers."""
|
|
candidates: list[float] = []
|
|
if requested_jog is not None and (kind == 'u' or not is_close(requested_jog, 0)):
|
|
candidates.append(float(requested_jog))
|
|
if kind == 'u':
|
|
lower, _upper = offer.parameter_domain
|
|
if numpy.isfinite(lower):
|
|
candidates.append(float(lower))
|
|
else:
|
|
candidates.append(0.0)
|
|
candidates.extend(minimum_nonzero_parameters(offer))
|
|
|
|
selected: list[float] = []
|
|
for candidate in candidates:
|
|
try:
|
|
parameter = offer.canonicalize_parameter(candidate)
|
|
except BuildError:
|
|
continue
|
|
if kind == 's' and is_close(parameter, 0):
|
|
continue
|
|
if not any(is_close(parameter, existing) for existing in selected):
|
|
selected.append(parameter)
|
|
return tuple(selected)
|
|
|
|
def turn_options(
|
|
self,
|
|
steps: Sequence[SelectedPrimitive],
|
|
remaining_bends: int,
|
|
) -> tuple[tuple[tuple[SelectedPrimitive, ...], int], ...]:
|
|
"""Return bend-family options paired with their consumed bend budget."""
|
|
options: list[tuple[tuple[SelectedPrimitive, ...], int]] = []
|
|
if remaining_bends >= 1:
|
|
for ccw in (False, True):
|
|
options.extend((turn, 1) for turn in self.bend_options(steps, ccw))
|
|
if remaining_bends >= 2:
|
|
jog = self.request.jog
|
|
options.extend((turn, 2) for turn in self.su_primitive_options(steps, 's', jog))
|
|
options.extend((turn, 2) for turn in self.su_primitive_options(steps, 'u', jog))
|
|
return tuple(options)
|
|
|
|
def enumerate_grammar(self) -> Iterable[tuple[SelectedPrimitive, ...]]:
|
|
"""Yield raw primitive sequences allowed by the bounded route grammar."""
|
|
residual_jog = 0.0 if self.request.jog is None else float(self.request.jog)
|
|
base: tuple[SelectedPrimitive, ...] = ()
|
|
for prefix_adapter in self.adapter_options(base, residual_jog=residual_jog):
|
|
prefix = (*base, *prefix_adapter)
|
|
yield from self.enumerate_segments(prefix, self.request.bend_budget, residual_jog=residual_jog)
|
|
|
|
def enumerate_segments(
|
|
self,
|
|
steps: tuple[SelectedPrimitive, ...],
|
|
remaining_bends: int,
|
|
*,
|
|
residual_jog: float,
|
|
) -> Iterable[tuple[SelectedPrimitive, ...]]:
|
|
"""Recursively enumerate normal/adapter/turn blocks within the bend budget."""
|
|
for normal in self.straight_options(steps):
|
|
after_normal = (*steps, *normal)
|
|
suffix_options = (
|
|
self.adapter_options(after_normal, residual_jog=0)
|
|
if self.request.out_ptype is not None
|
|
else ((),)
|
|
)
|
|
for suffix in suffix_options:
|
|
yield (*after_normal, *suffix)
|
|
|
|
if remaining_bends <= 0:
|
|
continue
|
|
for core_adapter in self.adapter_options(after_normal, residual_jog=residual_jog):
|
|
before_core = (*after_normal, *core_adapter)
|
|
for turn, bend_count in self.turn_options(before_core, remaining_bends):
|
|
after_turn = (*before_core, *turn)
|
|
for post_adapter in self.adapter_options(after_turn, residual_jog=residual_jog):
|
|
yield from self.enumerate_segments(
|
|
(*after_turn, *post_adapter),
|
|
remaining_bends - bend_count,
|
|
residual_jog=residual_jog,
|
|
)
|
|
|
|
def adjustable_indices(self, steps: Sequence[SelectedPrimitive]) -> tuple[int, ...]:
|
|
"""Return non-adapter primitive indices whose parameter can still move."""
|
|
adjustable: list[int] = []
|
|
for index, step in enumerate(steps):
|
|
if step.role == 'adapter':
|
|
continue
|
|
parameter = step.parameter
|
|
lower, upper = step.offer.parameter_domain
|
|
probes = [
|
|
parameter + max(1e-6, abs(parameter) * 1e-6),
|
|
parameter - max(1e-6, abs(parameter) * 1e-6),
|
|
]
|
|
if numpy.isfinite(upper):
|
|
probes.append(numpy.nextafter(upper, -numpy.inf))
|
|
if numpy.isfinite(lower):
|
|
probes.append(lower)
|
|
for probe in probes:
|
|
try:
|
|
step.offer.canonicalize_parameter(probe)
|
|
except BuildError:
|
|
continue
|
|
if abs(float(probe) - float(parameter)) > 1e-12:
|
|
adjustable.append(index)
|
|
break
|
|
return tuple(adjustable)
|
|
|
|
def reevaluate(self, steps: Sequence[SelectedPrimitive], parameters: Sequence[float]) -> tuple[SelectedPrimitive, ...]:
|
|
"""Re-evaluate a primitive sequence with new parameters and flowing ptypes."""
|
|
selected: list[SelectedPrimitive] = []
|
|
current_ptype = self.request.in_ptype
|
|
for step, parameter in zip(steps, parameters, strict=True):
|
|
selected_step = self.evaluate(
|
|
step.offer,
|
|
parameter,
|
|
current_ptype,
|
|
out_ptype=None,
|
|
role=step.role,
|
|
route_kind=step.route_kind,
|
|
)
|
|
selected.append(selected_step)
|
|
current_ptype = selected_step.out_port.ptype
|
|
return tuple(selected)
|
|
|
|
def solve_parameters(
|
|
self,
|
|
steps: Sequence[SelectedPrimitive],
|
|
solve_indices: Sequence[int],
|
|
constraints: Sequence[tuple[Literal['x', 'y'], float]],
|
|
) -> tuple[tuple[SelectedPrimitive, ...], Port] | None:
|
|
"""
|
|
Adjust selected primitive parameters to satisfy endpoint constraints.
|
|
|
|
The solver estimates each adjustable parameter's local linear effect by
|
|
probing the composed endpoint, solves the tiny least-squares system,
|
|
then re-evaluates with canonicalized parameters.
|
|
"""
|
|
parameters = [step.parameter for step in steps]
|
|
for _iteration in range(3):
|
|
selected_steps = self.reevaluate(steps, parameters)
|
|
base_end = self.compose_endpoint(selected_steps)
|
|
if not solve_indices:
|
|
return selected_steps, base_end
|
|
|
|
matrix = [[0.0 for _index in solve_indices] for _constraint in constraints]
|
|
for column, solve_index in enumerate(solve_indices):
|
|
step = steps[solve_index]
|
|
parameter = parameters[solve_index]
|
|
probe = parameter + max(1e-6, abs(parameter) * 1e-6)
|
|
try:
|
|
probe = step.offer.canonicalize_parameter(probe)
|
|
except BuildError:
|
|
probe = numpy.nextafter(parameter, -numpy.inf)
|
|
try:
|
|
probe = step.offer.canonicalize_parameter(probe)
|
|
except BuildError:
|
|
return None
|
|
if abs(float(probe) - float(parameter)) <= 1e-12:
|
|
return None
|
|
probe_parameters = list(parameters)
|
|
probe_parameters[solve_index] = probe
|
|
probe_steps = self.reevaluate(steps, probe_parameters)
|
|
probe_end = self.compose_endpoint(probe_steps)
|
|
for row, (axis, _target) in enumerate(constraints):
|
|
matrix[row][column] = (float(getattr(probe_end, axis)) - float(getattr(base_end, axis))) / (probe - parameter)
|
|
|
|
residual = [target - float(getattr(base_end, axis)) for axis, target in constraints]
|
|
if all(abs(value) <= 1e-9 for value in residual):
|
|
return selected_steps, base_end
|
|
deltas = solve_small_lstsq(matrix, residual)
|
|
if deltas is None:
|
|
return None
|
|
changed = False
|
|
for solve_index, delta in zip(solve_indices, deltas, strict=True):
|
|
parameter = steps[solve_index].offer.canonicalize_parameter(
|
|
clean_parameter(parameters[solve_index] + float(delta)),
|
|
)
|
|
changed = changed or abs(parameter - parameters[solve_index]) > 1e-12
|
|
parameters[solve_index] = parameter
|
|
if not changed:
|
|
return selected_steps, base_end
|
|
selected_steps = self.reevaluate(steps, parameters)
|
|
return selected_steps, self.compose_endpoint(selected_steps)
|
|
|
|
def endpoint_matches(
|
|
self,
|
|
end_port: Port,
|
|
constraints: Sequence[tuple[Literal['x', 'y'], float]],
|
|
) -> bool:
|
|
"""Return true when a composed endpoint satisfies requested position, rotation, and ptype."""
|
|
for axis, target in constraints:
|
|
if not is_close(getattr(end_port, axis), target):
|
|
return False
|
|
if end_port.rotation is None:
|
|
return False
|
|
rotation_delta = (float(end_port.rotation) - self.request.out_rotation) % (2 * pi)
|
|
if not (is_close(rotation_delta, 0) or is_close(rotation_delta, 2 * pi)):
|
|
return False
|
|
return self.request.out_ptype is None or ptypes_compatible(end_port.ptype, self.request.out_ptype)
|
|
|
|
def finalize(self, steps: Sequence[SelectedPrimitive]) -> Candidate:
|
|
"""
|
|
Try all small solve sets for one raw sequence and return the first match.
|
|
|
|
Solve-set order is deterministic and becomes part of the candidate
|
|
ordering only after cost and structural tie-breakers.
|
|
"""
|
|
constraints: list[tuple[Literal['x', 'y'], float]] = []
|
|
if self.request.length is not None:
|
|
constraints.append(('x', float(self.request.length)))
|
|
if self.request.family == 'straight':
|
|
constraints.append(('y', 0.0))
|
|
elif self.request.family in ('s', 'u') or self.request.constrain_jog:
|
|
if self.request.jog is None:
|
|
raise BuildError(f'{self.request.route_name} route requires a jog constraint')
|
|
constraints.append(('y', float(self.request.jog)))
|
|
route_constraints = tuple(constraints)
|
|
adjustable = self.adjustable_indices(steps)
|
|
solve_sets: list[tuple[int, ...]] = [()]
|
|
max_solve = min(len(route_constraints), len(adjustable))
|
|
for solve_size in range(1, max_solve + 1):
|
|
solve_sets.extend(combinations(adjustable, solve_size))
|
|
|
|
for solve_indices in solve_sets:
|
|
solved = self.solve_parameters(steps, solve_indices, route_constraints)
|
|
if solved is None:
|
|
continue
|
|
selected_steps, end_port = solved
|
|
if not self.endpoint_matches(end_port, route_constraints):
|
|
continue
|
|
order = self.order
|
|
self.order += 1
|
|
public_length = float(end_port.x) if self.request.length is None else float(self.request.length)
|
|
return Candidate(
|
|
tuple(selected_steps),
|
|
end_port,
|
|
sum(step.cost for step in selected_steps),
|
|
order,
|
|
public_length,
|
|
)
|
|
raise BuildError(f'{self.request.route_name} composed primitive route is unsupported')
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RouteLeg:
|
|
"""
|
|
One solved route leg tied to the Pather port it will update.
|
|
|
|
`start_port` is the copied route start used for all layout transforms.
|
|
`tool` is stored with the leg so prepared render steps cannot be stamped
|
|
with a mismatched Tool after bundle ordering.
|
|
"""
|
|
portspec: str
|
|
"""Pather port name this leg will update."""
|
|
start_port: Port
|
|
"""Copied layout-space route start."""
|
|
tool: Tool
|
|
"""Tool used for all render steps in this leg."""
|
|
candidate: Candidate
|
|
"""Solved local primitive candidate."""
|
|
plug_into: str | None = None
|
|
"""Optional destination port to consume after applying the final endpoint."""
|
|
|
|
|
|
class RoutingPlanner:
|
|
"""
|
|
Pather-facing stateless route-selection facade.
|
|
|
|
Public Pather methods call this class with copied port contexts. Returned
|
|
`PreparedRouteResult`s contain only committed render steps, final ports,
|
|
plug targets, and renames needed for Pather state mutation.
|
|
"""
|
|
|
|
TRACE_INTO_MAX_BENDS: int = 4
|
|
|
|
def plan_leg(
|
|
self,
|
|
family: PrimitiveKind,
|
|
context: RoutePortContext,
|
|
*,
|
|
length: float | None = None,
|
|
jog: float | None = None,
|
|
ccw: SupportsBool | None = None,
|
|
plug_into: str | None = None,
|
|
constrain_jog: bool = False,
|
|
max_bends: int | None = None,
|
|
**kwargs: Any,
|
|
) -> RouteLeg:
|
|
"""Solve one route leg and attach it to its source Pather context."""
|
|
request = RouteRequest(
|
|
family=family,
|
|
tool=context.tool,
|
|
in_ptype=context.port.ptype,
|
|
route_kwargs=kwargs,
|
|
length=length,
|
|
jog=jog,
|
|
ccw=ccw,
|
|
out_ptype=kwargs.get('out_ptype'),
|
|
constrain_jog=constrain_jog,
|
|
max_bends=max_bends,
|
|
)
|
|
try:
|
|
candidate = Solver(request).solve()
|
|
except BuildError as err:
|
|
if family == 'u' and length is None and not getattr(err, 'fatal', False):
|
|
raise BuildError('No legal primitive offer for omitted-length U-turn') from err
|
|
raise
|
|
return RouteLeg(
|
|
portspec=context.portspec,
|
|
start_port=context.port.copy(),
|
|
tool=context.tool,
|
|
candidate=candidate,
|
|
plug_into=plug_into,
|
|
)
|
|
|
|
def prepared_route_action_from_leg(
|
|
self,
|
|
leg: RouteLeg,
|
|
) -> PreparedRouteAction:
|
|
"""
|
|
Convert a solved leg into committed render steps and a final live port.
|
|
|
|
Offer commits happen here, after route selection succeeds. Each
|
|
primitive endpoint is transformed from route-local coordinates using
|
|
the previous step's layout-space output port.
|
|
"""
|
|
current = leg.start_port.copy()
|
|
render_steps: list[RenderStep] = []
|
|
for selected in leg.candidate.steps:
|
|
port_rot = current.rotation
|
|
if port_rot is None:
|
|
raise PortError('Ports must have rotation')
|
|
out_port = selected.out_port.copy()
|
|
out_port.rotate_around((0, 0), pi + port_rot)
|
|
out_port.translate(current.offset)
|
|
render_steps.append(RenderStep(
|
|
selected.offer.opcode,
|
|
leg.tool,
|
|
current.copy(),
|
|
out_port.copy(),
|
|
selected.offer.commit(selected.parameter),
|
|
))
|
|
current = out_port
|
|
if not render_steps:
|
|
raise BuildError('Route leg has no primitive steps')
|
|
return PreparedRouteAction(
|
|
portspec=leg.portspec,
|
|
render_steps=tuple(render_steps),
|
|
final_port=current.copy(),
|
|
plug_into=leg.plug_into,
|
|
)
|
|
|
|
def prepared_result_from_legs(
|
|
self,
|
|
legs: Sequence[RouteLeg],
|
|
*,
|
|
renames: tuple[tuple[str, str], ...] = (),
|
|
) -> PreparedRouteResult:
|
|
"""Build a prepared result from solved legs plus deferred port renames."""
|
|
return PreparedRouteResult(
|
|
actions=tuple(self.prepared_route_action_from_leg(leg) for leg in legs),
|
|
renames=renames,
|
|
)
|
|
|
|
def plan_trace_route(
|
|
self,
|
|
contexts: Sequence[RoutePortContext],
|
|
ccw: SupportsBool | None,
|
|
length: float | None = None,
|
|
*,
|
|
spacing: float | ArrayLike | None = None,
|
|
**bounds: Any,
|
|
) -> PreparedRouteResult:
|
|
"""Plan straight or single-bend traces, including `each` and bundle-bound modes."""
|
|
route_bounds = dict(bounds)
|
|
portspec = tuple(context.portspec for context in contexts)
|
|
planner_bounds.validate_trace_args(portspec, length=length, spacing=spacing, bounds=route_bounds)
|
|
family: Literal['straight', 'bend'] = 'straight' if ccw is None else 'bend'
|
|
if length is not None:
|
|
leg = self.plan_leg(family, contexts[0], length=length, ccw=ccw, **route_bounds)
|
|
return self.prepared_result_from_legs((leg,))
|
|
|
|
if route_bounds.get('each') is not None:
|
|
each = route_bounds.pop('each')
|
|
return PreparedRouteResult(tuple(
|
|
self.prepared_route_action_from_leg(
|
|
self.plan_leg(family, context, length=each, ccw=ccw, **route_bounds),
|
|
)
|
|
for context in contexts
|
|
))
|
|
|
|
bundle_bounds = planner_bounds.present_bundle_bounds(route_bounds)
|
|
if not bundle_bounds:
|
|
leg = self.plan_leg(family, contexts[0], length=None, ccw=ccw, **route_bounds)
|
|
return self.prepared_result_from_legs((leg,))
|
|
|
|
bound_type = bundle_bounds[0]
|
|
bound_value = route_bounds.pop(bound_type)
|
|
set_rotation = route_bounds.pop('set_rotation', None)
|
|
extensions = ell(
|
|
{context.portspec: context.port for context in contexts},
|
|
ccw,
|
|
spacing=spacing,
|
|
bound=bound_value,
|
|
bound_type=bound_type,
|
|
set_rotation=set_rotation,
|
|
)
|
|
actions = []
|
|
for port_name, route_length in extensions.items():
|
|
context = next(context for context in contexts if context.portspec == port_name)
|
|
leg = self.plan_leg(family, context, length=route_length, ccw=ccw, **route_bounds)
|
|
actions.append(self.prepared_route_action_from_leg(leg))
|
|
return PreparedRouteResult(tuple(actions))
|
|
|
|
def plan_trace_to_route(
|
|
self,
|
|
contexts: Sequence[RoutePortContext],
|
|
ccw: SupportsBool | None,
|
|
*,
|
|
spacing: float | ArrayLike | None = None,
|
|
**bounds: Any,
|
|
) -> PreparedRouteResult:
|
|
"""Plan `trace_to()` by resolving positional targets or delegating to `trace()` modes."""
|
|
route_bounds = dict(bounds)
|
|
if len(contexts) == 1:
|
|
resolved = planner_bounds.resolved_position_bound(contexts[0].port, route_bounds, allow_length=False)
|
|
else:
|
|
resolved = None
|
|
if any(route_bounds.get(key) is not None for key in planner_bounds.POSITION_KEYS):
|
|
raise BuildError('Position bounds only allowed with a single port')
|
|
if resolved is None:
|
|
return self.plan_trace_route(contexts, ccw, spacing=spacing, **route_bounds)
|
|
|
|
planner_bounds.validate_trace_to_positional_args(spacing=spacing, bounds=route_bounds)
|
|
_key, _value, length = resolved
|
|
other_bounds = {
|
|
key: value
|
|
for key, value in route_bounds.items()
|
|
if key not in planner_bounds.POSITION_KEYS and key != 'length'
|
|
}
|
|
family: Literal['straight', 'bend'] = 'straight' if ccw is None else 'bend'
|
|
leg = self.plan_leg(family, contexts[0], length=length, ccw=ccw, **other_bounds)
|
|
return self.prepared_result_from_legs((leg,))
|
|
|
|
def plan_jog_route(
|
|
self,
|
|
contexts: Sequence[RoutePortContext],
|
|
offset: float,
|
|
length: float | None = None,
|
|
*,
|
|
spacing: float | ArrayLike | None = None,
|
|
**bounds: Any,
|
|
) -> PreparedRouteResult:
|
|
"""Plan S-bend routes for single ports or spaced bundles."""
|
|
if numpy.isclose(offset, 0):
|
|
return self.plan_trace_to_route(contexts, None, length=length, spacing=spacing, **bounds)
|
|
route_bounds = dict(bounds)
|
|
portspec = tuple(context.portspec for context in contexts)
|
|
planner_bounds.validate_jog_args(portspec, length=length, spacing=spacing, bounds=route_bounds)
|
|
other_bounds = dict(route_bounds)
|
|
if length is None and len(contexts) == 1:
|
|
resolved = planner_bounds.resolved_position_bound(contexts[0].port, route_bounds, allow_length=True)
|
|
if resolved is not None:
|
|
_key, _value, length = resolved
|
|
other_bounds = {key: value for key, value in route_bounds.items() if key not in planner_bounds.POSITION_KEYS}
|
|
if len(contexts) > 1:
|
|
return PreparedRouteResult(tuple(
|
|
self.prepared_route_action_from_leg(leg)
|
|
for leg in self.plan_su_bundle_routes('s', contexts, offset, length, spacing, **other_bounds)
|
|
))
|
|
leg = self.plan_leg('s', contexts[0], length=length, jog=offset, **other_bounds)
|
|
return self.prepared_result_from_legs((leg,))
|
|
|
|
def plan_uturn_route(
|
|
self,
|
|
contexts: Sequence[RoutePortContext],
|
|
offset: float,
|
|
length: float | None = None,
|
|
*,
|
|
spacing: float | ArrayLike | None = None,
|
|
**bounds: Any,
|
|
) -> PreparedRouteResult:
|
|
"""Plan U-turn routes for single ports or spaced bundles."""
|
|
route_bounds = dict(bounds)
|
|
portspec = tuple(context.portspec for context in contexts)
|
|
planner_bounds.validate_uturn_args(portspec, spacing=spacing, bounds=route_bounds)
|
|
if len(contexts) > 1:
|
|
return PreparedRouteResult(tuple(
|
|
self.prepared_route_action_from_leg(leg)
|
|
for leg in self.plan_su_bundle_routes('u', contexts, offset, length, spacing, **route_bounds)
|
|
))
|
|
leg = self.plan_leg('u', contexts[0], length=length, jog=offset, **route_bounds)
|
|
return self.prepared_result_from_legs((leg,))
|
|
|
|
def plan_su_bundle_routes(
|
|
self,
|
|
kind: Literal['s', 'u'],
|
|
contexts: Sequence[RoutePortContext],
|
|
offset: float,
|
|
length: float | None,
|
|
spacing: float | ArrayLike | None,
|
|
**kwargs: Any,
|
|
) -> tuple[RouteLeg, ...]:
|
|
"""
|
|
Solve the anchor S/U route and derive exact routes for the rest of a bundle.
|
|
|
|
The anchor may determine the public length when omitted. Once known,
|
|
`su_bundle_specs()` normalizes every other port into an exact length
|
|
and offset so all legs can be planned independently.
|
|
"""
|
|
if len(contexts) == 1:
|
|
return (self.plan_leg(kind, contexts[0], length=length, jog=offset, **kwargs),)
|
|
route_name = 'jog' if kind == 's' else 'uturn'
|
|
if kind == 'u' and is_close(offset, 0):
|
|
raise BuildError('multi-port uturn() requires nonzero offset to determine bundle ordering')
|
|
contexts_by_name = {context.portspec: context for context in contexts}
|
|
initial_specs = planner_bounds.su_bundle_specs(contexts, offset, 0, spacing, route_name=route_name)
|
|
anchor_portspec, _anchor_length, _anchor_offset = initial_specs[0]
|
|
anchor = self.plan_leg(kind, contexts_by_name[anchor_portspec], length=length, jog=offset, **kwargs)
|
|
base_length = anchor.candidate.public_length
|
|
specs = planner_bounds.su_bundle_specs(contexts, offset, base_length, spacing, route_name=route_name)
|
|
first_portspec, _first_length, _first_offset = specs[0]
|
|
routes_by_name = {first_portspec: anchor}
|
|
for spec_portspec, spec_length, spec_offset in specs[1:]:
|
|
if kind == 's' and is_close(spec_offset, 0):
|
|
routes_by_name[spec_portspec] = self.plan_leg('straight', contexts_by_name[spec_portspec], length=spec_length, **kwargs)
|
|
else:
|
|
routes_by_name[spec_portspec] = self.plan_leg(
|
|
kind,
|
|
contexts_by_name[spec_portspec],
|
|
length=spec_length,
|
|
jog=spec_offset,
|
|
**kwargs,
|
|
)
|
|
return tuple(routes_by_name[spec_portspec] for spec_portspec, _length, _offset in specs)
|
|
|
|
def plan_trace_into(
|
|
self,
|
|
context_src: RoutePortContext,
|
|
portspec_dst: str,
|
|
port_dst: Port,
|
|
*,
|
|
out_ptype: str | None,
|
|
plug_destination: bool,
|
|
thru: str | None,
|
|
**kwargs: Any,
|
|
) -> PreparedRouteResult:
|
|
"""Plan a bounded route from one source port into a destination port."""
|
|
reserved = {
|
|
'portspec', 'ccw', 'length', 'offset', 'plug_into', 'spacing', 'each', 'set_rotation',
|
|
*planner_bounds.POSITION_KEYS,
|
|
*planner_bounds.BUNDLE_BOUND_KEYS,
|
|
}
|
|
collisions = sorted(set(kwargs) & reserved)
|
|
if collisions:
|
|
raise BuildError(f'trace_into() kwargs cannot override route arguments: {", ".join(collisions)}')
|
|
if out_ptype is None:
|
|
out_ptype = port_dst.ptype
|
|
if context_src.port.rotation is None or port_dst.rotation is None:
|
|
raise PortError('Ports must have rotation')
|
|
desired = port_dst.copy()
|
|
desired.rotation = port_dst.rotation - pi
|
|
desired.ptype = out_ptype
|
|
family, length, jog, ccw = self.trace_into_spec(context_src.port, desired)
|
|
leg = self.plan_leg(
|
|
family,
|
|
context_src,
|
|
length=length,
|
|
jog=jog,
|
|
ccw=ccw,
|
|
plug_into=portspec_dst if plug_destination else None,
|
|
constrain_jog=family == 'bend',
|
|
max_bends=self.TRACE_INTO_MAX_BENDS,
|
|
**(dict(kwargs) | {'out_ptype': out_ptype}),
|
|
)
|
|
renames = ((thru, context_src.portspec),) if thru is not None else ()
|
|
return self.prepared_result_from_legs(
|
|
(leg,),
|
|
renames=renames,
|
|
)
|
|
|
|
def trace_into_spec(
|
|
self,
|
|
start_port: Port,
|
|
end_port: Port,
|
|
) -> tuple[PrimitiveKind, float, float, SupportsBool | None]:
|
|
"""Convert source/destination geometry into a primitive route family and constraints."""
|
|
def quarter_turn(rotation: float) -> int:
|
|
normalized = rotation % (2 * pi)
|
|
if is_close(normalized, 2 * pi):
|
|
normalized = 0.0
|
|
quarter = int(round(normalized / (pi / 2))) % 4
|
|
if not is_close(normalized, (quarter * pi / 2) % (2 * pi)):
|
|
raise BuildError('trace_into() only supports Manhattan port rotations')
|
|
return quarter
|
|
|
|
travel_jog, _angle = start_port.measure_travel(end_port)
|
|
travel, jog = travel_jog
|
|
length = -float(travel)
|
|
offset = -float(jog)
|
|
if start_port.rotation is None or end_port.rotation is None:
|
|
raise PortError('Ports must have rotation')
|
|
relative_quarter = (quarter_turn(end_port.rotation) - quarter_turn(start_port.rotation)) % 4
|
|
if relative_quarter == 0:
|
|
return ('straight', length, 0.0, None) if is_close(offset, 0) else ('s', length, offset, None)
|
|
if relative_quarter == 1:
|
|
return 'bend', length, offset, True
|
|
if relative_quarter == 2:
|
|
return 'u', length, offset, None
|
|
return 'bend', length, offset, False
|