#!/usr/bin/env python3

# --- MIT License ---
# The MIT License (MIT)

# Copyright © 2026 Carl L. Wuebker and Claude.ai

# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the “Software”), to deal in the Software without
# restriction, including without limitation the rights to use,
# copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom
# the Software is furnished to do so, subject to the following
# conditions:

# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
# --- end MIT License ---

"""
sp2Sch.py -- read a SPICE netlist or model library, lay the chosen .SUBCKT
out as an editable schematic on a Tk canvas, and save/load the result
(Place & Route) as JSON.  Pure stdlib + Tk; KiCad symbols supply the bodies.

FLOW (one Place, _run_placement)
  1. Parse the deck; expand the chosen .SUBCKT and the ones it uses.
  2. Decide roles and orientation BEFORE placement: rails and IO nets,
     driver/receiver pins, P2DL cells (diff pairs, shunt taps, parallel
     banks, bare groups), part rotations, text and T-symbol reservations.
  3. Chain layout per box: the longest signal chain is the spine, the
     rest hang above/below it; Sugiyama/BK orders what the chain does not.
  4. After placement: the self-cross mirror/180 passes, the median move
     pass, whole-sheet compaction, box repack, T rebuild, label pass.
  5. Render only DRAWS what placement committed.

TERMINOLOGY
  CLUSTER  reaches the outside only through .SUBCKT ports or rails.
  SEGMENT  a connectivity component inside a cluster.
  BOX      a packing rectangle (_refine_clusters_into_boxes, _boxes).
  CHAIN    the spine and its subchains inside one box.
  CELL     a P2DL block; a rigid cell never changes shape.

INVARIANTS
  - Every reserved box is an UPPER BOUND of what is drawn: measure the box
    you draw.  Overlap-free is a gate, not an objective.
  - Determinism: sort before any set iteration that picks a position,
    order or direction; -v prints a fingerprint to compare hash seeds.
  - A T-symbol's rotation is its net's role and is never re-aimed.
  - Coordinates: KiCad mm scaled to px; ox_px/oy_px is a part's origin.

HARNESS
  sp2Sch.py -v [deck -s SUBCKT] measures a clean Place: overlaps,
  crossings, lines through parts, grazes, backward pairs and wire length.
"""

import json
import math
import os
import re
import sys
import tkinter as tk
import tkinter.font as tkfont
from collections import Counter, defaultdict, deque
from functools import cmp_to_key
from itertools import combinations
from pathlib import Path
from tkinter import filedialog, messagebox, ttk

# NOTE on portability: every import above is part of the
# Python standard library (Python >= 3.9), so the GUI runs on Linux, Windows
# (10/11) and macOS with a stock Python — no third-party packages required.
# `tkinter` ships with the official python.org installers and most distro
# python packages.  The ONLY non-stdlib dependency, `numpy`, is imported
# LAZILY inside the standalone CLI helper `_ag_spectral_xy` (used only when
# the file is run as a command-line tool, never by the GUI), so a user who
# lacks numpy can still run the schematic viewer.

# ══════════════════════════════════════════════════════════════════════════════
#  §1  KiCad .kicad_sym parser  (identical to kicad_spice_viewer3, rev 3)
# ══════════════════════════════════════════════════════════════════════════════

def _tokenise(text):
    i, n = 0, len(text)
    while i < n:
        c = text[i]
        if c in ' \t\r\n':
            i += 1
        elif c == '(':
            yield '('; i += 1
        elif c == ')':
            yield ')'; i += 1
        elif c == '"':
            j = i + 1; s = []
            while j < n:
                if text[j] == '\\' and j + 1 < n:
                    s.append(text[j + 1]); j += 2
                elif text[j] == '"':
                    j += 1; break
                else:
                    s.append(text[j]); j += 1
            yield ''.join(s); i = j
        else:
            j = i
            while j < n and text[j] not in ' \t\r\n()':
                j += 1
            yield text[i:j]; i = j


def _parse_from(tokens):
    lst = []
    for tok in tokens:
        if tok == ')':
            return lst
        elif tok == '(':
            lst.append(_parse_from(tokens))
        else:
            lst.append(tok)
    return lst


def _parse_sexp(text):
    it = _tokenise(text)
    next(it)          # discard opening '('
    return _parse_from(it)


def _find_all(lst, key):
    return [i for i in lst if isinstance(i, list) and i and i[0] == key]


def _find_one(lst, key):
    r = _find_all(lst, key)
    return r[0] if r else None


def _get_prop(sym_list, name):
    for item in sym_list:
        if isinstance(item, list) and item and item[0] == 'property':
            if len(item) > 1 and item[1] == name:
                return item[2] if len(item) > 2 else ''
    return ''


def _fill_type(item):
    fn = _find_one(item, 'fill')
    if fn:
        t = _find_one(fn, 'type')
        if t:
            return t[1]
    return 'none'


def _stroke_w(item):
    sn = _find_one(item, 'stroke')
    if sn:
        wn = _find_one(sn, 'width')
        if wn:
            return max(0.0, float(wn[1]))
    return 0.0


def _collect_shapes(sexp, out, recurse=True):
    for item in sexp:
        if not isinstance(item, list) or not item:
            continue
        kind = item[0]
        if kind == 'polyline':
            pn = _find_one(item, 'pts')
            if pn:
                pts = [(float(xy[1]), float(xy[2]))
                       for xy in pn
                       if isinstance(xy, list) and xy and xy[0] == 'xy']
                out.append({'kind': 'polyline', 'pts': pts,
                            'fill': _fill_type(item),
                            'stroke_w': _stroke_w(item)})
        elif kind == 'circle':
            cn = _find_one(item, 'center')
            rn = _find_one(item, 'radius')
            if cn and rn:
                out.append({'kind': 'circle',
                            'cx': float(cn[1]), 'cy': float(cn[2]),
                            'r': float(rn[1]),
                            'fill': _fill_type(item),
                            'stroke_w': _stroke_w(item)})
        elif kind == 'arc':
            sn = _find_one(item, 'start')
            mn = _find_one(item, 'mid')
            en = _find_one(item, 'end')
            if sn and mn and en:
                out.append({'kind': 'arc',
                            'start': (float(sn[1]), float(sn[2])),
                            'mid':   (float(mn[1]), float(mn[2])),
                            'end':   (float(en[1]), float(en[2])),
                            'fill': _fill_type(item),
                            'stroke_w': _stroke_w(item)})
        elif kind == 'rectangle':
            sn = _find_one(item, 'start')
            en = _find_one(item, 'end')
            if sn and en:
                out.append({'kind': 'rectangle',
                            'x1': float(sn[1]), 'y1': float(sn[2]),
                            'x2': float(en[1]), 'y2': float(en[2]),
                            'fill': _fill_type(item),
                            'stroke_w': _stroke_w(item)})
        elif kind == 'text':
            txt = item[1] if len(item) > 1 else ''
            an = _find_one(item, 'at')
            if an:
                angle = float(an[3]) if len(an) > 3 else 0.0
                fs = 1.27
                eff = _find_one(item, 'effects')
                if eff:
                    fn = _find_one(eff, 'font')
                    if fn:
                        szn = _find_one(fn, 'size')
                        if szn:
                            fs = float(szn[1])
                out.append({'kind': 'text', 'text': txt,
                            'x': float(an[1]), 'y': float(an[2]),
                            'angle': angle, 'size': fs})
        elif kind == 'pin':
            an = _find_one(item, 'at')
            ln = _find_one(item, 'length')
            if an:
                out.append({'kind': 'pin',
                            'x': float(an[1]), 'y': float(an[2]),
                            'angle': float(an[3]) if len(an) > 3 else 0.0,
                            'length': float(ln[1]) if ln else 2.54})
        elif kind == 'symbol' and recurse:
            _collect_shapes(item, out, recurse=True)


def _parse_sim_pins(sim_pins_str):
    """
    Parse KiCad Sim.Pins string e.g. '1=C 2=B 3=E' into
    {pin_number_str: spice_role_str}.
    """
    result = {}
    for token in sim_pins_str.split():
        if '=' in token:
            num, role = token.split('=', 1)
            result[num.strip()] = role.strip()
    return result


def _collect_pin_geometry(sexp, out):
    """
    Walk sexp and fill out: pin_number_str → (x_mm, y_mm, angle_deg, length_mm).
    (x, y) is the pin anchor on the symbol body (where it meets the component).
    The far tip (where the net wire connects) is:
        tip_x = x + length * cos(radians(angle))
        tip_y = y + length * sin(radians(angle))   (KiCad y-up)
    """
    for item in sexp:
        if not isinstance(item, list) or not item:
            continue
        if item[0] == 'pin':
            an = _find_one(item, 'at')
            ln = _find_one(item, 'length')
            num_node = _find_one(item, 'number')
            if an and num_node:
                num = num_node[1] if len(num_node) > 1 else '?'
                out[num] = (float(an[1]), float(an[2]),
                            float(an[3]) if len(an) > 3 else 0.0,
                            float(ln[1]) if ln else 2.54)
        elif item[0] == 'symbol':
            _collect_pin_geometry(item, out)


def load_kicad_symbols(path):
    """
    Parse a .kicad_sym file.  Returns:
      name → {
        'shapes':   [shape-dict, ...],
        'pins':     {pin_num_str: (x, y, angle_deg, length_mm)},
        'sim_pins': {pin_num_str: spice_role_str},
      }
    """
    text = Path(path).read_text(encoding='utf-8')
    tree = _parse_sexp(text)
    result = {}
    for item in tree:
        if not isinstance(item, list) or not item or item[0] != 'symbol':
            continue
        name = item[1]
        if '_' in name and re.search(r'_\d+_\d+$', name):
            continue
        shapes = []
        for sub in _find_all(item, 'symbol'):
            _collect_shapes(sub, shapes)
        _collect_shapes(item, shapes, recurse=False)
        # Draw filled bodies first: KiCad keeps the body in a different unit
        # from its interior glyphs, which would otherwise be hidden.
        def _is_filled_body(sh):
            if sh.get('fill', 'none') not in ('background', 'outline'):
                return False
            return sh['kind'] in ('circle', 'rectangle', 'polyline', 'arc')
        shapes.sort(key=lambda sh: 0 if _is_filled_body(sh) else 1)
        # IDC and VDC carry a battery-cell glyph
        # (one long plate line at y=+0.254 and three short segments at
        # y=−0.254) that reads as clutter and, on a current source, is
        # simply the wrong symbol.  Strip those interior plate
        # polylines, leaving the circle, the IDC flow-arrow, and the
        # +/− markers.  Identified by their tight |y| ≈ 0.254 extent
        # and small size; the IDC arrow (y up to 2.286) and the body
        # circle are untouched.
        if name in ('IDC', 'VDC'):
            def _is_plate(sh):
                if sh['kind'] != 'polyline':
                    return False
                ys = [p[1] for p in sh['pts']]
                # Plate lines sit on the two horizontal rules at
                # y = ±0.254 and never rise toward the arrow region.
                return all(abs(y) < 0.5 for y in ys)
            shapes = [s for s in shapes if not _is_plate(s)]
        pins = {}
        _collect_pin_geometry(item, pins)
        sim_pins_str = _get_prop(item, 'Sim.Pins')
        sim_pins = _parse_sim_pins(sim_pins_str)
        # Apply override if Sim.Pins was absent
        if not sim_pins and name in SIM_PINS_OVERRIDE:
            sim_pins = SIM_PINS_OVERRIDE[name]
        result[name] = {'shapes': shapes, 'pins': pins, 'sim_pins': sim_pins}
    return result


# ══════════════════════════════════════════════════════════════════════════════
#  §3  SPICE first-letter → KiCad symbol name + net-to-pin mapping
# ══════════════════════════════════════════════════════════════════════════════

# SPICE_NET_ORDER maps the SPICE component letter to the ordered list of
# functional roles that each positional net argument represents.
# These roles must match the Sim.Pins role strings in the .kicad_sym file.
# For symmetric 2-terminal parts (R, C, L) the roles are '+'/'-' by convention.
SPICE_NET_ORDER = {
    'R': ['+', '-'],
    'C': ['+', '-'],
    'L': ['+', '-'],
    'V': ['+', '-'],
    'I': ['+', '-'],
    # Diode SPICE order: anode cathode model  — but Sim.Pins is 1=K 2=A
    # So we label roles as 'A','K' matching the Sim.Pins role strings.
    # The mapping below reverses them: SPICE net1→A, net2→K.
    'D': ['A', 'K'],
    # BJT: collector base emitter [substrate] model
    'Q': ['C', 'B', 'E'],
    # MOSFET / JFET: drain gate source [bulk] model
    'M': ['D', 'G', 'S', 'B'],
    'J': ['D', 'G', 'S'],
    # Controlled sources: output+, output-, control+, control- [, value/gain]
    'E': ['N+', 'N-', 'C+', 'C-'],
    'G': ['N+', 'N-', 'C+', 'C-'],
    # F and H have a controlling-voltage-source name as 3rd token, not a net:
    # F ref N+ N- Vnam gain   → nets are only N+, N-
    # H ref N+ N- Vnam gain   → nets are only N+, N-
    'F': ['N+', 'N-'],
    'H': ['N+', 'N-'],
    # Switch: output+, output-, control+, control-  model
    'S': ['no+', 'no-', 'ctrl+', 'ctrl-'],
    'W': ['no+', 'no-', 'ctrl+', 'ctrl-'],
    # Transmission line: port1+, port1-, port2+, port2-
    'T': ['1+', '1-', '2+', '2-'],
    # Behavioural source (generic, 2 output terminals)
    'B': ['N+', 'N-'],
}


def resolve_pin_nets(comp, sym_entry):
    """
    Given a parsed component dict and its symbol entry (with 'sim_pins'
    and 'pins'), return an ordered list of (pin_num_str, net_name) pairs
    — one per SPICE net.

    Strategy:
      1. Build a role→pin_num reverse map from Sim.Pins.
      2. Look up each SPICE-positional role in SPICE_NET_ORDER[kind].
      3. Pair that role → pin_num → net from comp['nets'].
    Returns [] if mapping cannot be determined.
    """
    kind = comp['kind']
    nets = comp['nets']
    roles = SPICE_NET_ORDER.get(kind, [])
    sim_pins = sym_entry.get('sim_pins', {})   # {pin_num: role}

    # Build reverse: role (lowercased) → pin_num
    role_to_pin = {role.lower(): num for num, role in sim_pins.items()}

    result = []
    for idx, role in enumerate(roles):
        if idx >= len(nets):
            break
        net = nets[idx]
        pin_num = role_to_pin.get(role.lower())
        if pin_num is None:
            # If no Sim.Pins at all, fall back to positional (pin "1", "2", ...)
            pin_num = str(idx + 1)
        result.append((pin_num, net))
    return result


# Sim.Pins overrides for symbols that lack the property in the .kicad_sym file.
# Keyed by symbol name; format identical to parsed Sim.Pins: {pin_num: role}.
SIM_PINS_OVERRIDE = {
    # the standard Device.kicad_sym R/C/L symbols carry NO
    # Sim.Pins property, so supply the 2-pin +/- roles the placer/orienter
    # expect (matches the roles the hand-curated R/C had).
    'R_Small_US': {'1': '+', '2': '-'},
    'C_Small':    {'1': '+', '2': '-'},
    'L_Small':    {'1': '+', '2': '-'},
    'ESOURCE': {'1': 'N+', '2': 'N-', '3': 'C+', '4': 'C-'},
    'GSOURCE': {'1': 'N+', '2': 'N-', '3': 'C+', '4': 'C-'},
    'BSOURCE': {'1': 'N+', '2': 'N-'},
    # 2-pin diamond variants used when an E or G source is in
    # ngspice's behavioral VALUE form (no electrical control inputs).
    'EVALUE':  {'1': 'N+', '2': 'N-'},
    'GVALUE':  {'1': 'N+', '2': 'N-'},
}


SPICE_TO_SYM = {
    # switched to STANDARD KiCad library symbol names.
    # R/C/L come from Device.kicad_sym; the rest from
    # Simulation_SPICE.kicad_sym.  R_Small_US and C_Small are the exact
    # small US zigzag / straight-plate variants previously hand-curated
    # into Sim_SPICE.kicad_sym (verified pin- and shape-identical), and
    # L_Small is the REAL inductor (was faked with the R body before).
    'R': 'R_Small_US',
    'C': 'C_Small',
    'L': 'L_Small',   # inductor: real coil symbol (no longer an R stand-in)
    'V': 'VDC',
    'I': 'IDC',
    'Q': 'NPN',
    'M': 'NMOS',
    'D': 'D',
    'E': 'ESOURCE',
    'F': 'BSOURCE',
    'G': 'GSOURCE',
    'H': 'BSOURCE',
    'S': 'SWITCH',
    'W': 'SWITCH',
    'T': 'TLINE',
    'B': 'BSOURCE',
}

def shorten_ref(ref):
    """
    Shorten SPICE reference designators that duplicate the type letter.
    e.g.  G_G27  → G27,   R_R1   → R1,   X_H1.H_H1 → H1.H1
           I_I_B  → I_B,   I_I_Q  → I_Q
    The pattern is a letter prefix, an underscore, the same letter again,
    then the rest of the name.
    Keeps the ref unchanged when no pattern matches.
    """
    parts = ref.split('.')
    out = []
    for part in parts:
        # Pattern 1: LETTER_LETTERNUMBER+  e.g. G_G27, R_R1, C_C123
        m = re.match(r'^([A-Za-z]+)_([A-Za-z]+)(\d+.*)$', part)
        if m and m.group(1).upper() == m.group(2).upper():
            out.append(m.group(2) + m.group(3))
            continue
        # Pattern 2: LETTER_LETTER_SUFFIX  e.g. I_I_B → I_B, V_V_DC → V_DC
        m2 = re.match(r'^([A-Za-z]+)_([A-Za-z]+)_(.+)$', part)
        if m2 and m2.group(1).upper() == m2.group(2).upper():
            out.append(m2.group(2) + '_' + m2.group(3))
            continue
        out.append(part)
    return '.'.join(out)


# Symbols whose body is a closed box/diamond — value label goes INSIDE
BOXED_SYMS = {'ESOURCE', 'GSOURCE', 'BSOURCE', 'SWITCH', 'TLINE', 'IDC', 'VDC'}
# Sources whose value/equation goes on the LEFT with the ref stacked
# beneath it — see Instance._source_text_layout.  SWITCH/TLINE are
# deliberately absent: they are boxed but not sources, and their labels
# are not an equation the ref belongs under.
_SOURCE_TEXT_SYMS = {'ESOURCE', 'GSOURCE', 'BSOURCE', 'EVALUE', 'GVALUE'}


# ── Net orientation constants ────────────────────────────────────────────────
GND_NETS = frozenset({'0','gnd','vss','vee','agnd','dgnd','sgnd','pgnd',
                      'gnda','gndd','gndpwr'})
VCC_NETS = frozenset({'vcc','vdd','vpp','v+','avcc','dvcc','vccio',
                      'vbat','vpwr','pwr'})
# Tk's Font.measure() gives the advance width; the drawn canvas item
# reports one pixel more from bbox().  Added to every measured text
# extent so the reservation is the upper bound, never the lower.
_TEXT_MEASURE_SLACK = 1
FLIPPABLE_KINDS = frozenset({'R','C','L'})
# ROTATABLE IS NOT THE SAME AS FLIPPABLE.  FLIPPABLE_KINDS
# gates _net_orientation_flip, which SWAPS the two nets across the pins --
# legal only for a symmetric 2-pin part.  A diode or LED must never be
# swapped: anode and cathode are not interchangeable.  But its ORIENTATION
# is free, two ways horizontal and two ways vertical, so the axis rule
# applies to it exactly as it does to a resistor.  Leaving diodes out of
# the axis rule is why LM324.lib's DP came out horizontal while RP, on the
# very same two nets (3 = +power, 4 = ground), came out vertical.
ROTATABLE_2PIN_KINDS = FLIPPABLE_KINDS | frozenset({'D'})
VALUE_MAX_CHARS = 10   # default truncation length for value text on canvas
EVALUE_GVALUE_MAX_PER_LINE = 15  # 2-line wrap width for VALUE sources


# Engineering-suffix normalization: SPICE 'M' is milli and 'MEG' is mega, so
# display m for milli and MEG for mega to avoid the classic mix-up.
_ENG_SUFFIX_RE = re.compile(
    r'(?<![A-Za-z0-9_.])'          # not part of a longer identifier/number
    r'(\d+\.?\d*|\.\d+)'           # group 1: the number
    r'([A-Za-z]+)'                 # group 2: the trailing letters
    r'(?![A-Za-z0-9_])'           # not glued to more alnum (e.g. "1meg2")
)


def _normalize_eng_suffix(value):
    """Canonicalise milli ('m') and mega ('MEG') scale suffixes that
    unambiguously follow a number.  Leaves everything else untouched.
    Safe to run on bare values and on expressions alike."""
    if not value:
        return value

    def _fix(mo):
        num, letters = mo.group(1), mo.group(2)
        low = letters.lower()
        if low == 'meg':
            return num + 'MEG'
        if low == 'm':
            return num + 'm'
        # Any other suffix: leave the letters exactly as written.
        return num + letters

    return _ENG_SUFFIX_RE.sub(_fix, value)


def _wrap_value_square(text, bias=6.0):
    """Wrap a multi-token value into a roughly square (a
    touch wider-than-tall) block instead of one token per line.

    A 2-token value (e.g. a model token + a number, 'R_NOISELESS 2.773E3')
    stays 2 lines; a long POLY expression like FB's twelve tokens lands
    at ~3-4 short lines — about as tall as the source body and far less
    wide than the original single line.  Greedy fill to a target line
    width of ~sqrt(bias * total_chars), never splitting a token.  The
    `bias` (>2) keeps the block a little wider than pixel-square, which
    reads better against a compact symbol body."""
    toks = text.split()
    if len(toks) <= 1:
        return text
    total = sum(len(t) for t in toks)
    longest = max(len(t) for t in toks)
    target = max(longest, int(round((bias * total) ** 0.5)))
    lines, cur = [], ''
    for t in toks:
        if not cur:
            cur = t
        elif len(cur) + 1 + len(t) <= target:
            cur += ' ' + t
        else:
            lines.append(cur)
            cur = t
    if cur:
        lines.append(cur)
    return '\n'.join(lines)


def _wrap_value_nlines(s):
    """Wrap a long value expression into the line count that
    makes the text block roughly SQUARE (smallest footprint next to a
    stacked neighbour).  Breaks only at safe boundaries (space/operator/
    comma/paren/brace) so numbers and identifiers are never split.  Tries
    line counts from 2..8 and returns the wrapping whose widest line is
    smallest while not exceeding the square-ish target; falls back to the
    fewest-lines result if no safe breaks exist."""
    s = s.strip()
    n = len(s)
    if n <= 20:
        return s
    safe = set(' ,)}*/')

    def wrap_to_width(width):
        """Greedy wrap: start a new line at the last safe boundary at or
        before `width` chars into the current line."""
        lines = []
        i = 0
        while i < len(s):
            if len(s) - i <= width:
                lines.append(s[i:].strip())
                break
            # search backward from i+width for a safe break
            cut = -1
            hi = min(i + width, len(s) - 1)
            for j in range(hi, i, -1):
                if s[j - 1] in safe or s[j] in safe:
                    cut = j
                    break
            if cut <= i:                 # no safe break — hard cut forward
                cut = hi
                for j in range(hi, len(s)):
                    if s[j - 1] in safe or s[j] in safe:
                        cut = j
                        break
            lines.append(s[i:cut].strip())
            i = cut
        return [ln for ln in lines if ln]

    # text is ~0.55 char-width per char vs ~1.2 line-height per line; aim
    # for width ≈ sqrt(n * line_h / char_w).  Try a band of line counts and
    # keep the smallest max-width wrapping.
    import math
    target_w = max(8, int(math.sqrt(n * 1.2 / 0.55)))
    best = None
    for width in range(max(8, target_w - 6), target_w + 14, 2):
        lines = wrap_to_width(width)
        if len(lines) < 2 or len(lines) > 8:
            continue
        maxw = max(len(ln) for ln in lines)
        # score: prefer small width, lightly penalise many lines
        score = (maxw, len(lines))
        if best is None or score < best[0]:
            best = (score, lines)
    if best is None:
        # fall back to a single centre split (old 2-line behaviour)
        mid = n // 2
        return s[:mid].rstrip() + '\n' + s[mid:].lstrip()
    return '\n'.join(best[1])


def _split_value_2lines(expr, max_per_line=EVALUE_GVALUE_MAX_PER_LINE,
                        fulltext=False):
    """In : a value expression, a per-line budget and fulltext.  Out: it
    wrapped for display beside an EVALUE / GVALUE diamond.
    fulltext=False keeps each line within max_per_line (15), truncates
    past twice that with an ellipsis, and returns one line when it fits.
    fulltext=True never truncates: a short expression comes back whole
    and the rest goes to _wrap_value_nlines, which picks the line count
    that makes the block roughly SQUARE.
    The 2-line split breaks at a SAFE boundary near the centre (space,
    operator, comma, close paren or brace) so an identifier like V(inn)
    survives, falling back to a hard centre split."""
    s = expr.strip()
    if fulltext:
        if len(s) <= 20:
            return s
        # a long equation wrapped at only 2
        # lines is very WIDE (~len/2 chars) and collides with neighbours
        # (gid 170: G1's full equation ran ~625px wide under R73).  Wrap it
        # into the line count that makes the block roughly SQUARE — text is
        # ~ASPECT× wider per char than tall per line, so the width-balancing
        # target is sqrt(len * line_height / char_width).  Try a few line
        # counts around that and keep the one with the smallest max line
        # width (break only at safe boundaries, never mid-number).
        return _wrap_value_nlines(s)
    else:
        if len(s) > 2 * max_per_line:
            s = s[: 2 * max_per_line - 1] + '\u2026'
        if len(s) <= max_per_line:
            return s

    target = len(s) // 2
    safe = set(' +-*/,)}')
    best = target
    found = False
    for off in range(0, len(s)):
        for pos in (target - off, target + off):
            if pos <= 0 or pos >= len(s):
                continue
            if s[pos - 1] in safe or s[pos] in safe:
                if not fulltext:
                    if pos > max_per_line or (len(s) - pos) > max_per_line:
                        continue
                best = pos
                found = True
                break
        if found:
            break

    line1 = s[:best].rstrip()
    line2 = s[best:].lstrip()
    if not line2:
        return line1
    if not line1:
        return line2
    return f'{line1}\n{line2}'


def sp_pin_x(inst, pnum):
    """In : an instance and a pin number.
    Proc: the pin's absolute canvas x at the instance's current origin.
    Out : float, or None when the pin cannot be located.
    """
    try:
        p = _pin_canvas_pos(inst, pnum)
        return None if p is None else float(p[0])
    except Exception:
        return None


def _net_index_to_pair_index(inst):
    """In : a CompInstance.
    Proc: match each comp['nets'] entry to the _pin_net_pairs entry on
          the same net, greedily left to right, so a duplicated net
          consumes one pair per occurrence.
    Out : {nets_index: pairs_index}; an absent index means no match and
          the caller falls back to identity.
    The bases differ: comp['nets'] is SPICE positional order, which the
    intrinsic role table is written against, while _net_orientation_flip
    reorders _pin_net_pairs for R/C/L so a rail lands on a fixed pin.  A
    role stored on one basis and read on the other is REVERSED."""
    pairs = getattr(inst, '_pin_net_pairs', None) or []
    nets = inst.comp.get('nets') or []
    used = set()
    out = {}
    for j, nn in enumerate(nets):
        nl = str(nn).lower()
        for i, (_pn, pnet) in enumerate(pairs):
            if i in used or str(pnet).lower() != nl:
                continue
            out[j] = i
            used.add(i)
            break
    return out


def _net_orientation_flip(comp, pin_net_pairs):
    """Reorder R/C/L pin_net_pairs so a GND net lands on pin 2 (bottom)
    and a VCC net on pin 1 (top)."""
    if comp['kind'] not in FLIPPABLE_KINDS or len(pin_net_pairs) < 2:
        return pin_net_pairs
    (p1,n1),(p2,n2) = pin_net_pairs[0], pin_net_pairs[1]
    n1l,n2l = n1.lower(), n2.lower()
    if ((n1l in GND_NETS and n2l not in GND_NETS)
            or (n2l in VCC_NETS and n1l not in VCC_NETS)):
        return [(p1,n2),(p2,n1)]
    return pin_net_pairs


# ══════════════════════════════════════════════════════════════════════════════
#  §4  SPICE netlist parser
# ══════════════════════════════════════════════════════════════════════════════

def _join_continuations(raw_lines):
    """Merge continuation lines (starting with '+') into their predecessor."""
    out = []
    for raw in raw_lines:
        line = raw.rstrip('\r\n')
        if line.startswith('+'):
            if out:
                out[-1] = out[-1] + ' ' + line[1:].strip()
            # if no predecessor, treat as standalone (shouldn't happen in valid
            # SPICE)
        else:
            out.append(line)
    return out


def _tokenise_spice_line(line):
    """Split a SPICE line on whitespace, respecting { } balanced expressions."""
    tokens = []
    i, n = 0, len(line)
    depth = 0
    cur = []
    while i < n:
        c = line[i]
        if c in ('{', '('):
            depth += 1; cur.append(c); i += 1
        elif c in ('}', ')'):
            depth -= 1; cur.append(c); i += 1
        elif c in (' ', '\t') and depth == 0:
            if cur:
                tokens.append(''.join(cur)); cur = []
            i += 1
        else:
            cur.append(c); i += 1
    if cur:
        tokens.append(''.join(cur))
    return tokens


class SpiceParser:
    """Parse a SPICE deck into a flat list of component dicts.
    Each dict has:
      ref    reference designator, e.g. 'R_R1'
      kind   first letter uppercased, e.g. 'R', 'C', 'Q', 'X'
      nets   net names, positional, before any PARAMS keyword
      value  value or model string, the last positional token
      sym    the KiCad symbol name to draw
      raw    the original SPICE line
    """

    def __init__(self):
        self.subckts = {}          # name → {'ports': [...], 'lines': [...]}
        self.components = []       # flat list of component dicts
        self._subckt_stack = []    # stack of (name, lines) while inside .SUBCKT
        # .MODEL name → device type (e.g. 'qx' → 'pnp'),
        # used to draw Q/M with the correct polarity regardless of the
        # model NAME (LM324's PNP devices use a model literally named
        # "QX", which the old name heuristic mis-drew as NPN).
        self.models = {}

    # ── public entry point ─────────────────────────────────────────────────

    def parse_file(self, path):
        raw = Path(path).read_text(
            encoding='utf-8', errors='replace').splitlines()
        self._parse_lines(raw)
        self._resolve_device_models()
        return self.components


    def _capture_model(self, line):
        """Record a .MODEL definition's device type, keyed by lower-case
        model name.  '.MODEL QX PNP(IS=...)' → models['qx'] = 'pnp'."""
        toks = _tokenise_spice_line(line)
        if len(toks) >= 3:
            self.models[toks[1].lower()] = toks[2].split('(')[0].lower()

    def _resolve_device_models(self):
        """Fix Q/M symbol polarity from the resolved
        .MODEL type (the model NAME is an unreliable hint).  Only Q/M
        are touched, and only when their model type is known."""
        for c in self.components:
            k = c.get('kind')
            t = self.models.get(str(c.get('value', '')).lower(), '')
            if k == 'Q':
                if t == 'pnp':
                    c['sym'] = 'PNP'
                elif t == 'npn':
                    c['sym'] = 'NPN'
            elif k == 'M':
                if t.startswith('pmos') or t == 'pmos':
                    c['sym'] = 'PMOS'
                elif t.startswith('nmos') or t == 'nmos':
                    c['sym'] = 'NMOS'

    # ── internal ───────────────────────────────────────────────────────────

    def _parse_lines(self, raw_lines):
        lines = _join_continuations(raw_lines)
        for line in lines:
            stripped = line.strip()
            if not stripped or stripped.startswith('*'):
                continue
            uline = stripped.upper()

            if uline.startswith('.MODEL'):
                self._capture_model(stripped)
                # Still accumulate into the enclosing SUBCKT so its body
                # stays intact for expansion; we only also record the type.
                if self._subckt_stack:
                    self.subckts[self._subckt_stack[-1]]['lines'].append(
                        stripped)
                continue

            if uline.startswith('.SUBCKT'):
                tokens = _tokenise_spice_line(stripped)
                # .SUBCKT <name> <port1> <port2> ...  [PARAMS: ...]
                name = tokens[1].upper() if len(tokens) > 1 else '__ANON__'
                # ports: positional tokens after name, stop at PARAMS
                # PARAMS: defaults are captured into 'params' (a dict
                # of param_name.lower() -> default_value_string) so a
                # behavioral source's equation text can later show the
                # actual default — or, at an X-instance call site, the
                # actual override — value instead of the bare
                # parameter name.  See _substitute_value_text and
                # _handle_x, which merges these defaults with the call
                # site's own PARAMS: overrides (override wins).
                ports = []
                param_tokens = []
                in_params = False
                for t in tokens[2:]:
                    if not in_params and (t.upper() == 'PARAMS:'
                                           or t.upper().startswith('PARAMS')):
                        in_params = True
                        continue
                    if in_params:
                        param_tokens.append(t)
                    else:
                        ports.append(t.upper())
                self.subckts[name] = {
                    'ports': ports, 'lines': [],
                    'params': self._parse_params_assignments(param_tokens),
                }
                self._subckt_stack.append(name)
                continue

            if uline.startswith('.ENDS'):
                if self._subckt_stack:
                    self._subckt_stack.pop()
                continue

            # While inside a .SUBCKT definition, accumulate lines
            if self._subckt_stack:
                self.subckts[self._subckt_stack[-1]]['lines'].append(stripped)
                continue

            # Top-level component and dot-command handling
            if stripped.startswith('.'):
                continue          # ignore other dot-commands at top level

            self._handle_component(stripped)

    def _handle_component(self, line):
        tokens = _tokenise_spice_line(line)
        if not tokens:
            return
        ref = tokens[0]
        letter = ref[0].upper()

        if letter == 'X':
            self._handle_x(ref, tokens)
            return

        # Kind-aware splitter.  Replaces the previous
        # _split_nets_value + per-call E/G VALUE-form detection.  The
        # splitter returns a sym override for behavioral E/G sources
        # so we don't have to re-detect the form here.
        nets, value, sym_override = self._parse_net_value(letter, tokens[1:])

        sym = sym_override or SPICE_TO_SYM.get(letter, 'BSOURCE')

        # For Q/M, the model-name "value" picks NPN vs PNP / NMOS vs PMOS.
        # (sym_override is never set for Q or M, so this never clobbers
        # an EVALUE/GVALUE override.)
        if letter == 'Q' and sym_override is None:
            sym = 'PNP' if 'P' in value.upper() else 'NPN'
        elif letter == 'M' and sym_override is None:
            sym = 'PMOS' if value.upper().startswith('P') else 'NMOS'

        comp = {
            'ref': ref, 'kind': letter,
            'nets': nets, 'value': value,
            'sym': sym, 'raw': line,
        }
        # record behavioral-source sense connections on
        # the top-level parse path too (the subckt-expansion path already
        # does this).  E/G sense the nets in V()/I() args; F/H sense the
        # current through named control V-sources (POLY or simple form).
        # These are real but non-wire links, used by the hidden-sense-net
        # detector and the cluster definition (they never bind clusters,
        # which union on drawable pins only).
        if letter in ('E', 'G') and value:
            sensed = self._expr_sensed_nets(value)
            pin_set = {n.upper() for n in nets}
            sn = [s for s in sensed if s.upper() not in pin_set]
            if sn:
                comp['sense_nets'] = sn
        elif letter in ('F', 'H') and value:
            ctrl = self._fh_control_sources(value)
            if ctrl:
                comp['sense_srcs'] = ctrl
        self.components.append(comp)

    def _handle_x(self, ref, tokens):
        """In : an X reference and its tokens.  Out: the subcircuit
        instance expanded into components.
        Two kinds of net are substituted: a formal port of the .SUBCKT
        becomes the actual net passed at this call site, and an internal
        net — neither a formal port nor a global such as GND, VCC or 0 —
        becomes a ref-prefixed unique name, so two instantiations of the
        same subckt cannot collide.  COMP_HYS_BASIC's 'n1' becomes
        'X_LP2951_U1_U2.n1' and 'X_LP2951_U1_U3.n1', which is what SPICE
        itself does at simulation time."""
        # X<ref> <net1> ... <netN> <subckt_name> [PARAMS: ...]
        # The subckt name is the last positional token before PARAMS
        # The call site's own PARAMS: overrides (e.g. 'PARAMS:
        # VHYS=0.05 TD=0.00 TT=2E-9' on this X line) are captured into
        # call_params, separately from the .SUBCKT's own defaults
        # (defn['params'], set above), so a behavioral source's
        # equation can later show the actual resolved value — this
        # instance's override if it gave one, else the .SUBCKT's own
        # default.  See _parse_params_assignments / resolved_params
        # below / _substitute_value_text.
        pos_tokens = []
        param_tokens = []
        in_params = False
        for t in tokens[1:]:
            if not in_params and (t.upper() in ('PARAMS:', 'PARAMS')
                                   or t.upper().startswith('PARAMS:')):
                in_params = True
                continue
            if in_params:
                param_tokens.append(t)
            else:
                pos_tokens.append(t)
        call_params = self._parse_params_assignments(param_tokens)

        if not pos_tokens:
            return

        subckt_name = pos_tokens[-1].upper()
        # Was `[t.upper() for t in pos_tokens[:-1]]`,
        # which force-uppercased every net passed at an X-instance call
        # site regardless of how the person actually typed it, silently
        # destroying original case for any hierarchical net (confirmed
        # as the root cause of a case-preservation gap — dormant in the
        # bundled example libraries only because they happen to already
        # write every net in uppercase).  Matching against defn['ports']
        # stays case-insensitive via substitute()'s own net.upper() key
        # lookup below — only the STORED value needs to keep its real
        # case, not the lookup.
        instance_nets = list(pos_tokens[:-1])

        defn = self.subckts.get(subckt_name)
        if defn is None:
            # Unknown subckt — just record as a black-box component
            self.components.append({
                'ref': ref, 'kind': 'X',
                'nets': instance_nets, 'value': subckt_name,
                'sym': 'BSOURCE', 'raw': ' '.join(tokens),
            })
            return

        # Build net-substitution map: port → instance_net
        port_map = {}
        for port, inst_net in zip(defn['ports'], instance_nets):
            port_map[port] = inst_net

        # Anything else seen inside the subckt body is an INTERNAL net —
        # unless it's a known global like GND/VCC/0/etc., in which case
        # we leave it alone so all instances connect to the same rail.
        # The internal-net prefix is this instance's ref so two
        # instantiations of the same subckt get unique internal names.
        # Net names are case-insensitive in SPICE, so we key on upper().
        globals_lc = (GND_NETS | VCC_NETS)
        internal_prefix = f'{ref}.'

        def substitute(net):
            nu = net.upper()
            if nu in port_map:
                return port_map[nu]
            if net.lower() in globals_lc or nu == '0':
                return net
            # Treat anything else as instance-internal — prefix to make
            # it unique across multiple instantiations.
            return internal_prefix + net

        # Resolved parameter values for THIS instantiation: the
        # .SUBCKT's own PARAMS: defaults, overridden by whatever this
        # X call site itself passed (call_params, above) — standard
        # SPICE override precedence.  Feeds _substitute_value_text
        # below so a behavioral source's displayed equation shows
        # real numbers, not bare parameter names.
        resolved_params = dict(defn.get('params') or {})
        resolved_params.update(call_params)

        # Re-parse the subckt body with substituted nets
        sub_lines = defn['lines']
        joined = _join_continuations(sub_lines)
        for line in joined:
            stripped = line.strip()
            if (not stripped or stripped.startswith('*')
                    or stripped.startswith('.')):
                continue
            sub_tokens = _tokenise_spice_line(stripped)
            if not sub_tokens:
                continue
            sub_letter = sub_tokens[0][0].upper()
            if sub_letter == 'X':
                # Nested X.  We keep this as a black-box component in
                # the flat list (recursive full expansion would explode
                # for deep hierarchies and is rarely what the user
                # wants to look at).  But the nets that get recorded
                # still need port-and-internal substitution so the
                # adjacency graph is correct.
                new_pos = [substitute(t) for t in sub_tokens[1:-1]]
                # The last positional token is the referenced subckt
                # name — don't substitute that.
                if sub_tokens[1:]:
                    new_pos.append(sub_tokens[-1])
                self.components.append({
                    'ref': f'{ref}.{sub_tokens[0]}', 'kind': 'X',
                    'nets': new_pos[:-1] if new_pos else [],
                    'value': new_pos[-1] if new_pos else '',
                    'sym': 'BSOURCE',
                    'raw': stripped,
                })
            else:
                # Kind-aware splitter handles the
                # behavioral E/G detection that previously lived
                # here in two ~12-line blocks.  We still substitute
                # nets AFTER splitting, so the splitter sees the raw
                # token text and isn't confused by ref-prefixed
                # internal nets.
                raw_nets, raw_value, sym_override = self._parse_net_value(
                    sub_letter, sub_tokens[1:])
                # Now substitute (ports → instance, internal → prefix).
                nets = [substitute(n) for n in raw_nets]
                value = raw_value
                sym = sym_override or SPICE_TO_SYM.get(sub_letter,
                                                          'BSOURCE')
                comp = {
                    'ref': f'{ref}.{sub_tokens[0]}',
                    'kind': sub_letter,
                    'nets': nets, 'value': value,
                    'sym': sym,
                    'raw': stripped,
                }
                # For behavioral E/G sources, record
                # the nets SENSED inside the VALUE expression (the
                # arguments of V()/I()) that are NOT among the source's
                # pins.  These are real connections electrically but
                # have no drawable pin after flattening, so a net whose
                # only link to this source is such a sense appears to
                # "go nowhere".  Mapped through the same port/internal
                # substitution as the pins.
                if sub_letter in ('E', 'G') and value:
                    sensed = self._expr_sensed_nets(value)
                    if sensed:
                        pin_set = {n.upper() for n in nets}
                        sense_nets = []
                        for s in sensed:
                            sub = substitute(s)
                            if sub.upper() not in pin_set:
                                sense_nets.append(sub)
                        if sense_nets:
                            comp['sense_nets'] = sense_nets
                # F/H sources sense the CURRENT through named V-sources
                # rather than a net.  Record those control refs as
                # 'sense_srcs' so a current-only link between blocks is
                # visible (LM324's FB senses the clamp's VLP/VLN).  Like
                # sense_nets it is documentation only and binds no cluster.
                if sub_letter in ('F', 'H') and value:
                    ctrl = self._fh_control_sources(value)
                    if ctrl:
                        comp['sense_srcs'] = [substitute(c) for c in ctrl]
                # Rewrite comp['value'] for DISPLAY, substituting port
                # names -> actual net names and parameter names ->
                # resolved values.  Must happen AFTER sense_nets/
                # sense_srcs above, which need the RAW text (literal
                # port/param names) to correctly identify what's being
                # sensed — this only changes what gets shown on
                # screen, never connectivity.
                comp['value'] = self._substitute_value_text(
                    value, substitute, resolved_params)
                self.components.append(comp)

    @staticmethod
    def _fh_control_sources(value):
        """In : an F or H source's value text.  Out: the refs of the
        V-SOURCES whose CURRENT it senses, case preserved; numbers, the
        POLY keyword and coefficients are skipped.
        Two SPICE forms:
          simple   the token stream starts with one source ref, then gain
          POLY(n)  the n tokens after POLY(n) are the control sources
        e.g. ['VB', 'VC', 'VE', 'VLP', 'VLN']."""
        if not value:
            return []
        toks = value.replace('(', ' ( ').replace(')', ' ) ').split()
        # POLY(n) form
        for i, t in enumerate(toks):
            if t.upper() == 'POLY':
                # find the integer count between the parens
                n = None
                for j in range(i + 1, min(i + 4, len(toks))):
                    if toks[j] not in ('(', ')'):
                        try:
                            n = int(toks[j])
                        except ValueError:
                            n = None
                        break
                if not n:
                    return []
                # the n control sources follow the ')'
                rest = []
                seen_close = False
                for t2 in toks[i + 1:]:
                    if t2 == ')':
                        seen_close = True
                        continue
                    if t2 == '(' or not seen_close:
                        continue
                    rest.append(t2)
                return rest[:n]
        # Simple form: first non-numeric token is the control source.
        def _is_num(s):
            try:
                float(s)
                return True
            except ValueError:
                return False
        for t in toks:
            if t in ('(', ')'):
                continue
            if not _is_num(t):
                return [t]
        return []

    @staticmethod
    def _expr_sensed_nets(expr):
        """In : a behavioral VALUE/TABLE/LAPLACE expression.
        Out: the nets it SENSES — the arguments of the V(...) and I(...)
        controlling-quantity functions.  These influence the source but
        are NOT pins of it, so after flattening they have no drawable
        connection to it.
          {LIMIT(GAIN*V(VC+,VC-),INEG,IPOS)}  -> {'VC+', 'VC-'}
          {IF(V(VIN,COM)<V(VC-,COM), ...)}    -> {'VIN','COM','VC-'}
        Only V() and I() reference nets; function names, params and
        numbers are ignored."""
        if not expr:
            return set()
        out = set()
        # Match V(...) or I(...) — capture the parenthesised argument
        # list, then split on commas.  Case-insensitive on the V/I.
        for mfunc in re.finditer(r'(?<![A-Za-z0-9_])[VvIi]\s*\(([^()]*)\)',
                                  expr):
            args = mfunc.group(1)
            for a in args.split(','):
                a = a.strip()
                if a:
                    out.add(a)
        return out

    @staticmethod
    def _substitute_value_text(text, substitute_net, params):
        """Rewrite a component's value/equation for display: net references in
        V()/I() become flattened net names and PARAMS names their resolved
        values.
        """
        if not text:
            return text

        def _net_repl(m):
            letter, args = m.group(1), m.group(2)
            subbed = ','.join(substitute_net(a.strip()) if a.strip() else a
                               for a in args.split(','))
            return f'{letter}({subbed})'

        text = re.sub(r'(?<![A-Za-z0-9_])([VvIi])\s*\(([^()]*)\)',
                       _net_repl, text)
        for pname, pval in sorted((params or {}).items(),
                                   key=lambda kv: -len(kv[0])):
            text = re.sub(r'\b' + re.escape(pname) + r'\b', pval,
                           text, flags=re.IGNORECASE)
        return text

    def expand_subckt(self, name):
        """
        Return a flat component list for the named subckt, as if it were
        a top-level circuit (dummy port nets used verbatim).
        """
        defn = self.subckts.get(name.upper())
        if not defn:
            return []
        # Use port names as nets directly (no substitution needed for display)
        saved = self.components
        self.components = []
        joined = _join_continuations(defn['lines'])
        for line in joined:
            stripped = line.strip()
            if (not stripped or stripped.startswith('*')
                    or stripped.startswith('.')):
                continue
            self._handle_component(stripped)
        result = self.components
        self._resolve_device_models()
        self.components = saved
        return result

    # Keywords that mark the start of an E/G behavioral form.  These
    # appear in token position 3 (0-indexed 2) — i.e., after the 2-net
    # output pair.  Their presence means it is NOT a linear 4-net
    # controlled source.
    _EG_BEHAV_KEYWORDS = frozenset({
        'VALUE', 'TABLE', 'LAPLACE', 'FREQ',
    })

    @staticmethod
    def _is_source_type_token(tok, keyword_set):
        """Return True if `tok` is one of the keywords in `keyword_set`,
        either as a bare keyword (DC, AC, TABLE) or as the prefix of a
        fused form (PULSE(...), TABLE{...}, VALUE={expr}, POLY(3))."""
        if not tok:
            return False
        up = tok.upper()
        if up in keyword_set:
            return True
        # Fused forms: KEYWORD followed by '(' or '{' or '='.
        for kw in keyword_set:
            if up.startswith(kw):
                tail = up[len(kw):]
                if tail and tail[0] in '({=':
                    return True
        return False

    @staticmethod
    def _strip_params_tail(tokens):
        """Drop any tokens from PARAMS: onward.  Returns the leading
        positional-tokens slice."""
        out = []
        for t in tokens:
            tu = t.upper()
            if tu == 'PARAMS:' or tu.startswith('PARAMS:') or tu == 'PARAMS':
                break
            out.append(t)
        return out

    @staticmethod
    def _parse_params_assignments(tokens):
        """In : the tokens FOLLOWING a 'PARAMS:' keyword.
        Out: {param_name.lower(): value_string}.
        Decks mix both spacings freely, sometimes within one PARAMS: list
        — 'vhys = 0.05' as three tokens and 'VHYS=0.05' fused into one —
        so both forms are handled.  Used for a .SUBCKT's PARAMS: defaults
        and for an X-instance's PARAMS: overrides, which _handle_x_
        instance merges (override wins) before _substitute_value_text
        resolves a behavioral equation.  Values keep their literal token
        text, so the equation shows exactly what the deck wrote."""
        params = {}
        i, n = 0, len(tokens)
        while i < n:
            tok = tokens[i]
            if '=' in tok:
                name, _, val = tok.partition('=')
                name = name.strip()
                val = val.strip()
                if name and val:
                    params[name.lower()] = val
                i += 1
                continue
            if i + 2 < n and tokens[i + 1] == '=':
                params[tok.lower()] = tokens[i + 2]
                i += 3
                continue
            i += 1
        return params

    @staticmethod
    def _poly_n(tok):
        """If `tok` is POLY(N) (paren-balanced single token), return
        the integer N.  Otherwise return None.  Used by E/G/F/H POLY
        forms to know how many control net pairs (or Vsource names) to
        consume past the keyword."""
        if not tok:
            return None
        up = tok.upper()
        if not up.startswith('POLY('):
            return None
        # Expect POLY(<int>) with optional whitespace inside.
        inside = tok[5:].rstrip()
        if not inside.endswith(')'):
            return None
        try:
            return int(inside[:-1].strip())
        except ValueError:
            return None

    def _parse_net_value(self, letter, tokens):
        """In : the element letter and the tokens AFTER the reference
        designator (_tokenise_spice_line(line)[1:]).
        Out: (nets, value, sym_override).  sym_override is a KiCad symbol
        name to use instead of the SPICE_TO_SYM default — 'EVALUE' or
        'GVALUE' for a behavioral E/G source, so callers need not
        re-detect the form; None means use the default.
        Kind-aware, unlike a plain "the last positional token is the
        value" split: it knows each element's SPICE syntax."""
        pos = self._strip_params_tail(tokens)
        if not pos:
            return [], '', None

        # Catch-all: returns (nets[:n], ' '.join(nets[n:]), override).
        def split_at(n, sym_override=None):
            n = max(0, min(n, len(pos)))
            return pos[:n], ' '.join(pos[n:]), sym_override

        # ── 2-net kinds with simple value (R, L, C, D, B) ───────────
        if letter in ('R', 'L', 'C', 'D', 'B'):
            # 2 nets, value is the rest.  D's "value" is the model
            # name (plus any area/IC= parameters); R/L/C's "value" is
            # the numeric value or expression; B's is the V={expr} or
            # I={expr} assignment.  All handled identically here.
            return split_at(2)

        # Independent sources (V, I): every form has exactly two nets;
        # everything after them is the value (DC, AC, PULSE, ...).
        if letter in ('V', 'I'):
            nets, value, ov = split_at(2)
            vt = value.strip()
            if vt[:2].upper() == 'DC' and (len(vt) == 2 or vt[2].isspace()):
                value = vt[2:].strip()
            return nets, value, ov

        # ── MOSFETs (M) ─────────────────────────────────────────────
        # Syntax: Mname nd ng ns nb modelname [L=… W=…]
        # Always 4 nets in standard SPICE.  A few decks omit the
        # bulk pin (rare); for those, we accept 3 if there are only
        # exactly 4 tokens total.
        if letter == 'M':
            if len(pos) >= 5:
                return split_at(4)
            if len(pos) == 4:
                # 3 nets + model — likely a non-standard 3-pin form.
                return split_at(3)
            return split_at(max(0, len(pos) - 1))

        # ── JFETs (J) ───────────────────────────────────────────────
        # Syntax: Jname nd ng ns modelname
        if letter == 'J':
            if len(pos) >= 4:
                return split_at(3)
            return split_at(max(0, len(pos) - 1))

        # ── BJTs (Q) ────────────────────────────────────────────────
        # Syntax: Qname nc nb ne [ns] modelname [area]
        # The substrate net is optional.  With 5 positional tokens
        # the layout is unambiguous: 4 nets + model.  With 4 tokens
        # it's 3 nets + model.  With 6 tokens it COULD be 4 nets +
        # model + area, OR 3 nets + model + 2 area-related tokens
        # (rare).  We default to 4-net interpretation when there
        # are ≥5 tokens — that's what most decks do.
        if letter == 'Q':
            if len(pos) >= 5:
                return split_at(4)
            if len(pos) == 4:
                return split_at(3)
            return split_at(max(0, len(pos) - 1))

        # ── Switches (S, W) ─────────────────────────────────────────
        # Syntax: Sname n+ n- nc+ nc- modelname [ON|OFF]    (VCSWitch)
        #         Wname n+ n- vsource modelname [ON|OFF]    (CCSWitch)
        # S has 4 nets; W has 2 nets + a Vsource name (not a net).
        if letter == 'S':
            if len(pos) >= 5:
                return split_at(4)
            return split_at(max(0, len(pos) - 1))
        if letter == 'W':
            # 2 nets, then Vsource name (in value), then model.
            return split_at(2)

        # ── Transmission lines (T, O, U) ────────────────────────────
        # Syntax: Tname n1 n2 n3 n4 Z0=… TD=…
        # 4 nets followed by parameters.
        if letter in ('T', 'O', 'U'):
            if len(pos) >= 5:
                return split_at(4)
            return split_at(max(0, len(pos) - 1))

        # ── Mutual inductance (K) ───────────────────────────────────
        # Syntax: Kname Lname1 Lname2 coupling
        # Not net-bearing at all — the two L-names are inductor
        # references, not nets.  We return them as `nets` for storage
        # consistency but they will simply not appear in the
        # net-to-pins map.  (Existing code already ignored Ks.)
        if letter == 'K':
            return [], ' '.join(pos), None

        # ── VCVS / VCCS (E, G) ──────────────────────────────────────
        # Linear:        Ename n+ n- nc+ nc- gain
        # Behavioural:   Ename n+ n- VALUE  {expr}
        #                Ename n+ n- VALUE = {expr}
        #                Ename n+ n- VALUE={expr}
        #                Ename n+ n- TABLE  {expr} = (...)
        #                Ename n+ n- LAPLACE {expr} = {transfer}
        #                Ename n+ n- FREQ   {expr} = (...)
        #                Ename n+ n- POLY(N) nc1+ nc1- ... ncN+ ncN- c0 c1 ...
        if letter in ('E', 'G'):
            if len(pos) < 3:
                return split_at(max(0, len(pos) - 1))
            tok2 = pos[2]
            # POLY(N) form: 2 output nets + 2N control nets + coeffs.
            pn = self._poly_n(tok2)
            if pn is not None:
                # POLY(N) form layout (E/G only — F/H handled below):
                #   pos = [n+, n-, POLY(N), c1+, c1-, ..., cN+, cN-,
                #           coeff_0, coeff_1, ...]
                # 2 output nets + 2N control nets = 2 + 2*pn net slots.
                # We slice net positions as the first 2 plus the
                # control nets, then push 'POLY(N)' and the coeffs
                # into value.  An explicit POLY prefix in value tells
                # downstream code this is a POLY form.
                if len(pos) >= 3 + 2 * pn:
                    nets = pos[:2] + pos[3:3 + 2 * pn]
                    value = ' '.join([tok2] + pos[3 + 2 * pn:])
                    return nets, value, None
                # Malformed — fall through to behavioral treatment.
            # Behavioural keyword (bare or fused) at position 2.
            if self._is_source_type_token(tok2, self._EG_BEHAV_KEYWORDS):
                # 2 nets; everything else (including the keyword and
                # any '=' / {expr}) is value.  Sym override to the
                # 2-pin diamond.
                sym_override = 'EVALUE' if letter == 'E' else 'GVALUE'
                # Strip the leading keyword (and optional '=') from
                # the value so the existing rendering code shows the
                # bare expression.  This preserves the rev-44 cosmetic
                # behavior for the VALUE form, and extends it to
                # TABLE / LAPLACE / FREQ.
                tail_tokens = pos[2:]
                value = ' '.join(tail_tokens).lstrip()
                # Try to strip a leading "VALUE" / "TABLE" / etc. with
                # optional '=' so the rendered value is the bare {expr}.
                up = value.upper()
                for kw in self._EG_BEHAV_KEYWORDS:
                    if up.startswith(kw):
                        rest = value[len(kw):].lstrip()
                        # Optional '=' separator (and optional spaces).
                        if rest.startswith('='):
                            rest = rest[1:].lstrip()
                        value = rest
                        break
                return pos[:2], value, sym_override
            # Linear 4-net controlled source: n+, n-, nc+, nc-, gain.
            if len(pos) >= 5:
                return split_at(4)
            # 4 tokens total: 4 nets, no gain → treat as 4 nets and
            # no value (degenerate but parseable).
            if len(pos) == 4:
                return split_at(4)
            return split_at(max(0, len(pos) - 1))

        # ── CCVS / CCCS (F, H) ──────────────────────────────────────
        # Linear:      Fname n+ n- Vsource_name gain
        # POLY:        Fname n+ n- POLY(N) Vsrc1 Vsrc2 ... VsrcN c0 c1 ...
        # In both forms there are EXACTLY 2 nets (n+, n-).  Anything
        # past that is Vsource names + coefficients — none of which
        # are nets.  We push the rest into value verbatim.
        if letter in ('F', 'H'):
            return split_at(2)

        # ── Fallback for unknown letters ────────────────────────────
        # Preserve the rev-1 behavior: last positional token is value,
        # everything before it is treated as nets.  This catches
        # exotic / vendor-specific kinds the schema above doesn't
        # cover, without crashing.
        if len(pos) >= 2:
            return pos[:-1], pos[-1], None
        return [], pos[0] if pos else '', None


# ══════════════════════════════════════════════════════════════════════════════
#  §5  Arc geometry helper (no y-flip in kangle)
# ══════════════════════════════════════════════════════════════════════════════

def _arc_3pt_to_canvas(sx, sy, mx, my, ex, ey, scale, ox, oy):
    ax, ay, bx, by, cx, cy = sx, sy, mx, my, ex, ey
    d = 2 * (ax*(by-cy) + bx*(cy-ay) + cx*(ay-by))
    if abs(d) < 1e-9:
        return None
    ux = ((ax**2+ay**2)*(by-cy) + (bx**2+by**2)*(cy-ay)
          + (cx**2+cy**2)*(ay-by)) / d
    uy = ((ax**2+ay**2)*(cx-bx) + (bx**2+by**2)*(ax-cx)
          + (cx**2+cy**2)*(bx-ax)) / d
    r  = math.hypot(ax - ux, ay - uy)

    # KiCad y-up and tkinter y-up (in angle convention) cancel → no flip needed
    def kangle(px, py):
        return math.degrees(math.atan2(py - uy, px - ux))

    a_start = kangle(sx, sy)
    a_mid   = kangle(mx, my)
    a_end   = kangle(ex, ey)

    def arc_extent(a_s, a_e, a_m):
        for direction in (1, -1):
            ext = direction * ((a_e - a_s) * direction % 360)
            if ext == 0:
                ext = direction * 360
            a_test = (a_s + ext / 2) % 360
            a_m2   = a_m % 360
            if (abs(a_test-a_m2) < 5 or abs(a_test-a_m2-360) < 5
                    or abs(a_test-a_m2+360) < 5):
                return ext
        ext_ccw = (a_e - a_s) % 360
        ext_cw  = -((a_s - a_e) % 360)
        return ext_ccw if abs(ext_ccw) <= abs(ext_cw) else ext_cw

    extent = arc_extent(a_start, a_end, a_mid)
    cx0 = ox + (ux - r) * scale
    cy0 = oy - (uy + r) * scale
    cx1 = ox + (ux + r) * scale
    cy1 = oy - (uy - r) * scale
    return (cx0, cy0, cx1, cy1, a_start, extent)


def _arc_bbox(sx, sy, mx, my, ex, ey):
    ax, ay, bx, by, cx, cy = sx, sy, mx, my, ex, ey
    d = 2*(ax*(by-cy)+bx*(cy-ay)+cx*(ay-by))
    if abs(d) < 1e-9:
        return min(sx,ex), min(sy,ey), max(sx,ex), max(sy,ey)
    ux = ((ax**2+ay**2)*(by-cy)+(bx**2+by**2)*(cy-ay)+(cx**2+cy**2)*(ay-by))/d
    uy = ((ax**2+ay**2)*(cx-bx)+(bx**2+by**2)*(ax-cx)+(cx**2+cy**2)*(bx-ax))/d
    r  = math.hypot(ax-ux, ay-uy)

    def ang(px, py):
        return math.degrees(math.atan2(py-uy, px-ux)) % 360

    a_s = ang(sx, sy); a_m = ang(mx, my); a_e = ang(ex, ey)

    def ccw_sweep(a_from, a_to):
        return (a_to - a_from) % 360

    sweep_ccw = ccw_sweep(a_s, a_e)
    in_ccw = ccw_sweep(a_s, a_m) <= sweep_ccw
    a_start, sweep = (a_s, sweep_ccw) if in_ccw else (a_e, ccw_sweep(a_e, a_s))

    candidates = [(sx, sy), (mx, my), (ex, ey)]
    for axis_ang in (0, 90, 180, 270):
        if ccw_sweep(a_start, axis_ang % 360) <= sweep:
            candidates.append((ux + r*math.cos(math.radians(axis_ang)),
                               uy + r*math.sin(math.radians(axis_ang))))
    xs2 = [p[0] for p in candidates]
    ys2 = [p[1] for p in candidates]
    return min(xs2), min(ys2), max(xs2), max(ys2)


def _bbox_of_shapes(shapes):
    xs, ys = [], []
    def add(x, y): xs.append(x); ys.append(y)
    for sh in shapes:
        k = sh['kind']
        if k == 'polyline':
            for x, y in sh['pts']: add(x, y)
        elif k == 'circle':
            add(sh['cx']-sh['r'], sh['cy']-sh['r'])
            add(sh['cx']+sh['r'], sh['cy']+sh['r'])
        elif k == 'arc':
            x0,y0,x1,y1 = _arc_bbox(sh['start'][0],sh['start'][1],
                                     sh['mid'][0],  sh['mid'][1],
                                     sh['end'][0],  sh['end'][1])
            add(x0,y0); add(x1,y1)
        elif k == 'rectangle':
            add(sh['x1'],sh['y1']); add(sh['x2'],sh['y2'])
        elif k == 'text':
            add(sh['x'], sh['y'])
        elif k == 'pin':
            ar = math.radians(sh['angle'])
            add(sh['x'], sh['y'])
            add(sh['x']+sh['length']*math.cos(ar),
                sh['y']+sh['length']*math.sin(ar))
    if not xs: return -5,-5,5,5
    return min(xs), min(ys), max(xs), max(ys)



# ══════════════════════════════════════════════════════════════════════════════
#  §5b  QuadTree for 2-D bounding-box placement
# ══════════════════════════════════════════════════════════════════════════════

class _QTNode:
    """Internal node of a QuadTree over axis-aligned bboxes (x0,y0,x1,y1)."""
    MAX_ITEMS = 8
    MAX_DEPTH = 10

    __slots__ = ('bounds', 'depth', 'items', 'children')

    def __init__(self, bounds, depth=0):
        self.bounds   = bounds          # (x0,y0,x1,y1) of this node's region
        self.depth    = depth
        self.items    = []              # list of (bbox, payload)
        self.children = None            # None → leaf; list of 4 → internal

    def _split(self):
        x0,y0,x1,y1 = self.bounds
        mx,my = (x0+x1)/2, (y0+y1)/2
        d = self.depth + 1
        self.children = [
            _QTNode((x0,y0,mx,my), d),  # NW
            _QTNode((mx,y0,x1,my), d),  # NE
            _QTNode((x0,my,mx,y1), d),  # SW
            _QTNode((mx,my,x1,y1), d),  # SE
        ]
        old = self.items; self.items = []
        for bbox, payload in old:
            self._insert_down(bbox, payload)

    def _quadrants_for(self, bbox):
        bx0,by0,bx1,by1 = bbox
        x0,y0,x1,y1 = self.bounds
        mx,my = (x0+x1)/2, (y0+y1)/2
        quads = []
        for i,(qx0,qy0,qx1,qy1) in enumerate([
            (x0,y0,mx,my),(mx,y0,x1,my),(x0,my,mx,y1),(mx,my,x1,y1)]):
            if bx0<qx1 and bx1>qx0 and by0<qy1 and by1>qy0:
                quads.append(i)
        return quads

    def _insert_down(self, bbox, payload):
        for qi in self._quadrants_for(bbox):
            self.children[qi].insert(bbox, payload)

    def insert(self, bbox, payload):
        if self.children is not None:
            self._insert_down(bbox, payload)
            return
        self.items.append((bbox, payload))
        if (len(self.items) > self.MAX_ITEMS
                and self.depth < self.MAX_DEPTH):
            self._split()

    def query_overlaps(self, bbox, results):
        """Append all payloads whose bbox overlaps the query bbox."""
        bx0,by0,bx1,by1 = bbox
        nx0,ny0,nx1,ny1 = self.bounds
        if bx1<=nx0 or bx0>=nx1 or by1<=ny0 or by0>=ny1:
            return   # query doesn't touch this node
        for (ix0,iy0,ix1,iy1), payload in self.items:
            if bx0<ix1 and bx1>ix0 and by0<iy1 and by1>iy0:
                results.append(payload)
        if self.children:
            for child in self.children:
                child.query_overlaps(bbox, results)

    def remove(self, bbox, payload):
        """Remove a specific (bbox, payload) pair."""
        if self.children is not None:
            for qi in self._quadrants_for(bbox):
                self.children[qi].remove(bbox, payload)
            return
        try:
            self.items.remove((bbox, payload))
        except ValueError:
            pass


class QuadTree:
    """2-D quad tree of axis-aligned boxes: insert, query_overlaps, remove and
    update.  The bounds must cover every box ever inserted.
    """
    def __init__(self, x0, y0, x1, y1):
        self._root = _QTNode((x0, y0, x1, y1))

    def insert(self, bbox, payload):
        self._root.insert(bbox, payload)

    def query_overlaps(self, bbox):
        results = []
        self._root.query_overlaps(bbox, results)
        return results

    def remove(self, bbox, payload):
        self._root.remove(bbox, payload)

    def update(self, old_bbox, new_bbox, payload):
        self.remove(old_bbox, payload)
        self.insert(new_bbox, payload)


def _find_primary_closed_shape(shapes):
    """
    Find the main closed shape (diamond polygon, circle, or rectangle).
    Returns (kind, cx_kicad, cy_kicad, half_w, half_h) or None.
    Priority: largest closed polygon > largest circle > rectangle.
    """
    best_poly = None; best_poly_area = 0
    best_circle = None; best_rect = None
    for s in shapes:
        if s['kind'] == 'pin': continue
        if s['kind'] == 'polyline':
            pts = s['pts']
            if (len(pts) >= 4 and abs(pts[0][0]-pts[-1][0]) < 0.01
                    and abs(pts[0][1]-pts[-1][1]) < 0.01):
                xs=[p[0] for p in pts]; ys=[p[1] for p in pts]
                w=max(xs)-min(xs); h=max(ys)-min(ys); area=w*h
                if area > best_poly_area:
                    best_poly_area=area
                    best_poly=((min(xs)+max(xs))/2,(min(ys)+max(ys))/2,w/2,h/2)
        elif s['kind'] == 'circle':
            if best_circle is None or s['r']>best_circle[2]:
                best_circle=(s['cx'],s['cy'],s['r'],s['r'])
        elif s['kind'] == 'rectangle':
            w=abs(s['x2']-s['x1']); h=abs(s['y2']-s['y1'])
            best_rect=((s['x1']+s['x2'])/2,(s['y1']+s['y2'])/2,w/2,h/2)
    if best_poly:   return ('poly',)   + best_poly
    if best_circle: return ('circle',) + best_circle
    if best_rect:   return ('rect',)   + best_rect
    return None


def _closest_interior_marker_half_h(shapes, closed):
    """In : a symbol's shapes and its closed shape (kind, cx, cy, hw, hh).
    Out: the KiCad-mm distance from that shape's centre to the nearest
    point of any OTHER polyline lying inside its own bbox, else None.
    BOXED_SYM symbols bake polarity marks into the symbol as small
    polylines that int_w/int_h cannot see, so a stacked ref/value pair
    could overlap the '+' inside an E-source diamond.  Callers use this
    to cap how far a stacked pair's outer edge may reach without knowing
    what the marker is; a decoration outside the closed shape's bbox
    belongs to some other part of the symbol and is excluded."""
    if closed is None:
        return None
    _k, ccx, ccy, hw, hh = closed
    best = None
    for s in shapes:
        if s['kind'] != 'polyline':
            continue
        pts = s.get('pts', [])
        if not pts:
            continue
        # Skip the closed shape's own outline (a closed loop of >=4 pts).
        if (len(pts) >= 4 and abs(pts[0][0] - pts[-1][0]) < 0.01
                and abs(pts[0][1] - pts[-1][1]) < 0.01):
            continue
        for (px, py) in pts:
            if not (ccx - hw <= px <= ccx + hw
                    and ccy - hh <= py <= ccy + hh):
                continue
            d = abs(py - ccy)
            if best is None or d < best:
                best = d
    return best


# ── CompInstance ──────────────────────────────────────────────────────────────
# Estimated font metrics {size: (px_per_char, line_height_px)}
_FONT_METRICS = {6:(4.5,9), 8:(5.5,11), 9:(6.0,12), 10:(6.5,13), 12:(7.5,15)}

def _font_size_metrics(size):
    if size in _FONT_METRICS: return _FONT_METRICS[size]
    return size*0.625, size*1.25

def _text_src(item):
    """The item's ORIGINAL, unwrapped text.

    Captured the first time the item is seen (right after build() made
    it, so it is pristine) and returned unchanged thereafter.  Wrapping
    routines must read this rather than item['text'], because they are
    not idempotent — re-wrapping an already-wrapped string inserts a
    blank line per existing break."""
    if item is None:
        return ''
    if 'text0' not in item:
        item['text0'] = item.get('text', '')
    return item['text0']


def _text_bbox_from_anchor(cx, cy, text, anchor, font_size, bold=False):
    """(x0,y0,x1,y1) of text placed at (cx,cy) with given tk anchor.
    Pass bold=True for text drawn bold (refs, interior values)
    so the reserved box matches the wider bold glyphs."""
    w, h = _measure_text(text, font_size, bold)
    a = anchor.lower() if isinstance(anchor,str) else str(anchor)
    if a=='nw':        lx,ty =  0,    0
    elif a=='n':       lx,ty = -w/2,  0
    elif a=='ne':      lx,ty = -w,    0
    elif a=='w':       lx,ty =  0,   -h/2
    elif a in('center','c',''):  lx,ty=-w/2,-h/2
    elif a=='e':       lx,ty = -w,   -h/2
    elif a=='sw':      lx,ty =  0,   -h
    elif a=='s':       lx,ty = -w/2, -h
    elif a=='se':      lx,ty = -w,   -h
    else:              lx,ty = -w/2, -h/2
    return (cx+lx, cy+ty, cx+lx+w, cy+ty+h)

_WRAP_ATOM_RE = re.compile(
    r'[A-Za-z_][A-Za-z0-9_.]*'
    r'|[0-9][0-9.]*(?:[eE][+-]?[0-9]+)?(?:[Mm][Ee][Gg]|[TGKkMmUuNnPpFf])?'
    r'|\S')
_WORD_ATOM_RE = re.compile(
    r'^([A-Za-z_][A-Za-z0-9_.]*'
    r'|[0-9][0-9.]*(?:[eE][+-]?[0-9]+)?(?:[Mm][Ee][Gg]|[TGKkMmUuNnPpFf])?)$')


def _tokenize_for_wrap(text):
    """Split `text` into atoms for word-wrapping:
    identifiers (letters/digits/underscore/dot) and numbers (with
    optional sign/decimal/exponent) stay whole; any other single
    character (operator, punctuation) is its own atom.  Whitespace is a
    break point, consumed rather than returned.  Used to wrap the
    'remaining text' (whichever of ref/value didn't fit inside the body,
    or the combined ref+value string when neither fit) without ever
    splitting a number or identifier mid-token."""
    return _WRAP_ATOM_RE.findall(text)


def _is_word_atom(atom):
    """True for an identifier or number atom (the
    first two _WRAP_ATOM_RE alternatives); False for a single operator/
    punctuation character (the \\S catch-all).  The join/wrap logic uses
    this to decide whether a space is actually NEEDED between two atoms,
    instead of always inserting one."""
    return bool(_WORD_ATOM_RE.match(atom))


def _join_atoms_smart(atoms):
    """In : the atoms of a ref or value string.  Out: them joined with a
    space ONLY between two consecutive WORD atoms.
    An unconditional ' '.join was fine for 'VLIM 1K' but hammered a
    space between every character of a long behavioral equation.  The
    word rule is exactly enough to keep 'VLIM' and '1K' from running
    together and never enough to pull an operator off what it touches.
    Idempotent by construction: re-tokenizing this output and joining
    again gives the same string, checked over every ref and value in all
    four designs."""
    out = []
    prev_word = False
    for a in atoms:
        w = _is_word_atom(a)
        if out and prev_word and w:
            out.append(' ')
        out.append(a)
        prev_word = w
    return ''.join(out)


def _greedy_wrap_lines(atoms, n_lines, font_size, bold=False):
    """In : the atoms, a line count and a font.  Out: up to n_lines line
    strings, fewer when there are not enough atoms to fill them all.
    Distributes atoms greedily, targeting each line at total width over
    n_lines and never splitting an atom.  Joining and width budgeting
    both go through _join_atoms_smart / _is_word_atom, so a space is
    budgeted and inserted only between two consecutive WORD atoms: a run
    of operators and parens packs tight while 'VLIM' and '1K' still get
    the one space they need."""
    if n_lines <= 1 or len(atoms) <= 1:
        return [_join_atoms_smart(atoms)]
    space_w = _measure_text(' ', font_size, bold)[0]
    gaps = sum(1 for i in range(1, len(atoms))
              if _is_word_atom(atoms[i - 1]) and _is_word_atom(atoms[i]))
    total_w = (sum(_measure_text(a, font_size, bold)[0] for a in atoms)
              + space_w * gaps)
    target = total_w / n_lines
    lines = []
    cur = []
    cur_w = 0.0
    for a in atoms:
        aw = _measure_text(a, font_size, bold)[0]
        needs_space = bool(cur) and _is_word_atom(cur[-1]) and _is_word_atom(a)
        prospective = cur_w + (space_w if needs_space else 0) + aw
        if cur and prospective > target and len(lines) < n_lines - 1:
            lines.append(_join_atoms_smart(cur))
            cur = [a]
            cur_w = aw
        else:
            cur_w = prospective if cur else aw
            cur.append(a)
    if cur:
        lines.append(_join_atoms_smart(cur))
    return lines


_DESCENDER_CHARS = set('gjpqy')
_TALL_ASCENDER_CHARS = set('bdfhijklt')
_TIGHT_FONT_CACHE = {}   # (font_size, bold) -> (ascent, linespace), only used
                        # by _tight_text_height; deliberately separate from
                        # _measure_text_real's own closure-local cache
                        # rather than risking a change to that working
                        # code.


def _get_font_metrics(font_size, bold=False):
    """In : a font size and a bold flag.  Out: the (ascent, linespace)
    pair _tight_text_height needs, cached, or None before Tk is ready —
    the caller then falls back to the safe full-metric height.
    The METRICS are cached, not the Font: both are per-font constants,
    but Font.metrics() is a Tcl round-trip every call and
    _tight_text_height asks for two of them on every interior-fit test.
    One OPAx197 Place measured 18,928 round-trips costing ~20 s, about
    half the placement; caching gives two per (font_size, bold)."""
    try:
        import tkinter.font as _tkfont
    except Exception:
        return None
    key = (font_size, bold)
    if key not in _TIGHT_FONT_CACHE:
        try:
            f = _tkfont.Font(family=FONT_FAMILY, size=_font_px(font_size),
                             weight=('bold' if bold else 'normal'))
            _TIGHT_FONT_CACHE[key] = (f.metrics('ascent'),
                                      f.metrics('linespace'))
        except Exception:
            return None
    return _TIGHT_FONT_CACHE[key]


def _font_px(font_size):
    """In : a point size.
    Proc: convert to PIXELS at the reference DPI and return Tk's negative
          size, which means pixels and skips Tk's point-to-pixel scaling.
    Out : the negative pixel size for tkfont.Font(size=...).
    `tk scaling` is set in __init__, but Tk reports it back slightly
    differently per X screen (1.334384 on 1600x1000, 1.332022 on
    1280x1024, neither the 96/72 that was set), so a point-sized label
    measures a pixel wider on one screen and the packed rows shift 1-3
    px.  That was the whole window-size dependence; pixels cannot drift."""
    return -int(round(font_size * _SCHEM_REF_DPI / 72.0))


def _pr_is_placement_free(pr_data):
    """True when a .pr.json has no usable placement: no 'instances' at all (a
    roles-only save), or every instance at the same position.
    """
    insts = (pr_data or {}).get('instances') or {}
    if not insts:
        return True
    if len(insts) < 2:
        return False
    seen = set()
    for v in insts.values():
        try:
            seen.add((round(float(v.get('cx', 0.0)), 3),
                      round(float(v.get('cy', 0.0)), 3)))
        except (TypeError, ValueError):
            return True
        if len(seen) > 1:
            return False
    return True


def _tight_text_height(text, font_size, bold=False):
    """Ink height of the given text, not the font's linespace: SPICE labels
    rarely use ascenders or descenders, so linespace overstates them.
    """
    w, _h = _measure_text(text, font_size, bold)
    m = _get_font_metrics(font_size, bold)
    if m is None:
        return _measure_text(text, font_size, bold)
    ascent, full_line_h = m
    total_h = 0
    for line in text.split('\n'):
        if any((c in _DESCENDER_CHARS or c in _TALL_ASCENDER_CHARS)
               for c in line):
            total_h += full_line_h
        else:
            total_h += max(1, round(ascent * 0.80))
    return w, total_h


def _wrap_ref_designator(text, font_size, bold, target_w):
    """Word-wrap a ref designator at underscores, keeping each underscore
    attached to the part before it.
    """
    if not text or _measure_text(text, font_size, bold)[0] <= target_w:
        return text
    parts = text.split('_')
    if len(parts) <= 1:
        return text
    segments = [p + '_' if i < len(parts) - 1 else p
               for i, p in enumerate(parts)]
    lines = []
    cur = ''
    for seg in segments:
        prospective = cur + seg
        if cur and _measure_text(prospective, font_size, bold)[0] > target_w:
            lines.append(cur)
            cur = seg
        else:
            cur = prospective
    if cur:
        lines.append(cur)
    return '\n'.join(lines) if len(lines) > 1 else text


def _wrap_to_square(text, font_size, bold, body_w, body_h, max_lines=4):
    """Word-wrap `text` (via _tokenize_for_wrap /
    _greedy_wrap_lines) into however many lines (1..max_lines) make the
    label's own block, STACKED BELOW an body_w x body_h footprint, come
    closest to a square overall shape.  Returns text with '\\n' inserted
    at the chosen breaks (or the original text, unchanged, if 1 line is
    already best or there's nothing to break on — a short ref like 'VC'
    correctly comes back unwrapped).  Approximates the stacking geometry
    (vertical, label under body) rather than modelling every possible
    side — a reasonable default matching the existing candidate list's
    own 'below body' entry, which is tried first."""
    atoms = _tokenize_for_wrap(text)
    if len(atoms) <= 1:
        return text
    best_text, best_score = text, None
    for n in range(1, min(len(atoms), max_lines) + 1):
        lines = _greedy_wrap_lines(atoms, n, font_size, bold)
        wrapped = '\n'.join(lines)
        w, h = _measure_text(wrapped, font_size, bold)
        comp_w = max(body_w, w)
        comp_h = body_h + h
        score = abs(comp_w - comp_h)
        if best_score is None or score < best_score:
            best_score, best_text = score, wrapped
    return best_text


def _wrap_to_square_side(text, font_size, bold, body_w, body_h, max_lines=4):
    """In : the text, its font, and the body's width and height.
    Out: the text wrapped for stacking BESIDE the body.
    Same idea as _wrap_to_square, but width ADDS to the body's own width
    (the label extends the row) while height takes the MAX of the two.
    Scoring a side label with _wrap_to_square's vertical formula
    (comp_w=max, comp_h=sum) only asks whether the label beats the body's
    OWN width: R_LP2951_R2's 11-character ref came back as one long line
    beside a 36 px body and reached onto a neighbour's label.  The
    additive-width score picks a narrower, taller block instead."""
    atoms = _tokenize_for_wrap(text)
    if len(atoms) <= 1:
        return text
    best_text, best_score = text, None
    for n in range(1, min(len(atoms), max_lines) + 1):
        lines = _greedy_wrap_lines(atoms, n, font_size, bold)
        wrapped = '\n'.join(lines)
        w, h = _measure_text(wrapped, font_size, bold)
        comp_w = body_w + w
        comp_h = max(body_h, h)
        score = abs(comp_w - comp_h)
        if best_score is None or score < best_score:
            best_score, best_text = score, wrapped
    return best_text



def _try_stacked_interior_fit(val_txt, val_fs, ref_txt, ref_fs,
                              int_w, int_h, max_val_lines=3,
                              max_ref_lines=2, gap=3.0):
    """In : value and ref text with their font sizes, the interior box,
    per-block line caps and a gap between the two blocks.
    Out: (val_text, val_h, ref_text, ref_h) for the first combination
    that fits both the per-line width and the combined height, else None.
    Combinations are tried in ascending TOTAL line count, so the most
    compact fit wins and 1+1 returns immediately; the caps stay small
    because the interior box is tiny.  `gap` is inside the FIT test as
    well as the caller's positioning, so a pair that only fits with zero
    clearance reports as not fitting rather than being drawn touching."""
    val_atoms = _tokenize_for_wrap(val_txt)
    ref_atoms = _tokenize_for_wrap(ref_txt)
    combos = sorted(
        ((nv, nr) for nv in range(1, min(len(val_atoms), max_val_lines) + 1)
         for nr in range(1, min(len(ref_atoms), max_ref_lines) + 1)),
        key=lambda t: (t[0] + t[1], t))
    for nv, nr in combos:
        val_lines = _greedy_wrap_lines(val_atoms, nv, val_fs, False)
        ref_lines = _greedy_wrap_lines(ref_atoms, nr, ref_fs, True)
        val_wrapped = '\n'.join(val_lines)
        ref_wrapped = '\n'.join(ref_lines)
        val_w, val_h = _tight_text_height(val_wrapped, val_fs)
        ref_w, ref_h = _tight_text_height(ref_wrapped, ref_fs, True)
        if (max(val_w, ref_w) <= int_w
                and val_h + gap + ref_h <= int_h):
            return val_wrapped, val_h, ref_wrapped, ref_h
    return None


def _union_bbox(bboxes):
    """Return the bounding union of a list of (x0,y0,x1,y1) tuples."""
    return (min(b[0] for b in bboxes), min(b[1] for b in bboxes),
            max(b[2] for b in bboxes), max(b[3] for b in bboxes))

def _overlaps(a, b):
    return not (a[2]<=b[0] or b[2]<=a[0] or a[3]<=b[1] or b[3]<=a[1])

def _min_separating_shift(lo_fixed, hi_fixed, lo_mov, hi_mov, margin):
    """In : a fixed interval, a movable interval and a margin, all on one
    axis.  Out: the signed shift that clears the movable interval past
    the fixed one, in whichever direction moves less.
    The ONE routine for this, shared by _resolve_cluster_box_overlaps and
    _push_blockers_away.  Shifting by the OVERLAP length is wrong when
    one interval contains the other: [0,10] and [3,7] overlap by 4, yet 4
    either way still leaves 3 units of it — the true shift is 7."""
    push_pos = (hi_fixed - lo_mov) + margin
    push_neg = (lo_fixed - hi_mov) - margin
    return push_pos if abs(push_pos) <= abs(push_neg) else push_neg

def _ccw(a, b, c):
    return (c[1]-a[1])*(b[0]-a[0]) - (b[1]-a[1])*(c[0]-a[0])

def _seg_cross(p1, p2, p3, p4):
    """True if open segments p1-p2 and p3-p4 properly cross.  Promoted to
    module scope so both _self_check and the Sig-Topo-auto
    layout scorer share one definition."""
    d1 = _ccw(p3, p4, p1); d2 = _ccw(p3, p4, p2)
    d3 = _ccw(p1, p2, p3); d4 = _ccw(p1, p2, p4)
    return ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0))

def _seg_cross_point(p1, p2, p3, p4):
    """The point where open segments p1-p2 and p3-p4 properly cross, or
    None when they do not.  _seg_cross answers the same question with a
    sign test and is the cheaper call when only the yes/no is wanted;
    this one is for the overlay, which has to say WHERE."""
    if not _seg_cross(p1, p2, p3, p4):
        return None
    x1, y1 = p1; x2, y2 = p2; x3, y3 = p3; x4, y4 = p4
    den = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
    if den == 0:                       # parallel: _seg_cross said no
        return None
    a = x1 * y2 - y1 * x2
    b = x3 * y4 - y3 * x4
    return ((a * (x3 - x4) - (x1 - x2) * b) / den,
            (a * (y3 - y4) - (y1 - y2) * b) / den)


def _translate_bb(bb, dx, dy):
    return (bb[0]+dx, bb[1]+dy, bb[2]+dx, bb[3]+dy)


_MIN_CLEARANCE = 1.0


def _terminal_pairs(comp, pin_nets=None):
    """In : a component dict, and its (pin, net) pairs when known.
    Proc: A PART IS 2-PIN WHEN IT PRESENTS TWO TERMINALS, whatever its
          symbol has: R, C, L, a diode, a switch, a V or I source, an
          equation-driven or controlled source, and a diode-connected
          transistor, whose base-collector tie leaves two distinct nets.
          A four-terminal source or switch presents TWO pairs — output
          pins and sensing pins — and either may pair with another part.
    Out : the lower-case frozenset({net, net}) pairs; empty for a part
          with none, such as a 3-net transistor.
    One definition, used by the parallel-orientation and the row rule."""
    nets = [str(n).lower() for n in (comp.get('nets') or [])]
    if pin_nets:
        nets = [str(n).lower() for _p, n in pin_nets]
    kind = str(comp.get('kind') or comp.get('ref', '')).upper()[:1]
    out = set()
    distinct = list(dict.fromkeys(nets))
    if len(distinct) == 2:
        out.add(frozenset(distinct))
    if kind in ('E', 'G', 'S', 'W') and len(nets) >= 4:
        for q in (nets[:2], nets[2:4]):
            if len(set(q)) == 2:
                out.add(frozenset(q))
    elif kind in ('F', 'H') and len(nets) >= 2 and len(set(nets[:2])) == 2:
        out.add(frozenset(nets[:2]))
    return out


def _ref_kind(ref):
    """Takes a ref such as 'X_U25.S1' or 'R_R83' and returns the SPICE
    element letter of its last part ('S', 'R'), the device type a
    placement pattern is stated in."""
    tail = str(ref).rsplit('.', 1)[-1]
    tail = tail.split('_', 1)[-1] if '_' in tail and tail[1:2] == '_' \
        else tail
    return tail[:1].upper()


def _boxes_clash_at(a, b, clearance=None):
    """THE box test, module scope.  In: boxes a and b and a clearance.
    Out: True when they are NOT separated by at least that many empty
    pixels on at least one axis.
    SpiceSchem._boxes_clash delegates here so the passes that SEPARATE
    boxes and the checks that GATE on them read one predicate and one
    number.  They did not: separators skipped any pair at `ox <= 0` while
    the gates asked for _MIN_CLEARANCE, so LM324.sub's R44/I3, 0.00 px
    apart in x, was "already apart" to every mover and "clashing" to the
    harness.  clearance defaults to _MIN_CLEARANCE (1 px); pass 0 for the
    strict overlap test where a boundary touch is genuinely allowed."""
    if clearance is None:
        clearance = _MIN_CLEARANCE
    ix = min(a[2], b[2]) - max(a[0], b[0])
    iy = min(a[3], b[3]) - max(a[1], b[1])
    return ix > -clearance and iy > -clearance


def _separate_boxes(items, sep=6.0, max_iter=200, lock=None, axis=None,
                    clearance=None):
    """Order-preserving separation of [x, y, ext] items (ext relative to x, y):
    push overlapping pairs apart along the axis of smaller overlap without
    changing their order.
    """
    n = len(items)
    lock = lock or set()
    if clearance is None:
        clearance = _MIN_CLEARANCE

    def box(i):
        x, y, e = items[i][0], items[i][1], items[i][2]
        return (x + e[0], y + e[1], x + e[2], y + e[3])
    for _it in range(max_iter):
        moved = False
        for a in range(n):
            for b in range(a + 1, n):
                ax0, ay0, ax1, ay1 = box(a)
                bx0, by0, bx1, by1 = box(b)
                ox = min(ax1, bx1) - max(ax0, bx0)
                oy = min(ay1, by1) - max(ay0, by0)
                if ox <= -clearance or oy <= -clearance:
                    continue
                acx = (ax0 + ax1) / 2.0; bcx = (bx0 + bx1) / 2.0
                acy = (ay0 + ay1) / 2.0; bcy = (by0 + by1) / 2.0
                a_lock = a in lock; b_lock = b in lock
                if a_lock and b_lock:
                    continue
                if axis == 'x':
                    use_x = True
                elif axis == 'y':
                    use_x = False
                else:
                    use_x = ox < oy
                if use_x:
                    push = ox + sep
                    sa = 0.0 if a_lock else (1.0 if b_lock else 0.5)
                    sb = 0.0 if b_lock else (1.0 if a_lock else 0.5)
                    if acx <= bcx:
                        items[a][0] -= push * sa; items[b][0] += push * sb
                    else:
                        items[a][0] += push * sa; items[b][0] -= push * sb
                else:
                    push = oy + sep
                    sa = 0.0 if a_lock else (1.0 if b_lock else 0.5)
                    sb = 0.0 if b_lock else (1.0 if a_lock else 0.5)
                    if acy <= bcy:
                        items[a][1] -= push * sa; items[b][1] += push * sb
                    else:
                        items[a][1] += push * sa; items[b][1] -= push * sb
                moved = True
        if not moved:
            break
    return items


def _widen_rigid_lines(items, idxs, axis, sep=6.0, max_iter=50,
                       clearance=None):
    """In : `items` in _separate_boxes's [[x, y, ext], ...] form and the
    indices of a rigid cell's members.  Out: the number of widenings; the
    gaps BETWEEN the cell's lines grow along one axis until no two member
    boxes clash — axis 0 spreads columns, axis 1 spreads rows.
    A line's shared coordinate is the intentional part of a pattern cell
    (a diff pair's devices line up under their own pins), so it is locked
    and only the free gap moves.  Needed because the cell sizes its own
    spacing in the P2DL group phase, before per-pin T's exist; this runs
    at lane-layout time, where rotations, labels and T's are final.
    Shifting a line carries every line beyond it by the same delta."""
    if len(idxs) < 2:
        return 0
    if clearance is None:
        clearance = _MIN_CLEARANCE
    o = 1 - axis                      # the axis we must NOT move
    lines = defaultdict(list)
    for i in idxs:
        lines[round(items[i][axis], 3)].append(i)
    keys = sorted(lines)
    if len(keys) < 2:
        return 0
    moved = 0
    for _ in range(max_iter):
        worst = None
        for a in range(len(keys)):
            for b in range(a + 1, len(keys)):
                for ia in lines[keys[a]]:
                    pa, ea = items[ia], items[ia][2]
                    for ib in lines[keys[b]]:
                        pb, eb = items[ib], items[ib][2]
                        # overlap on the axis we are not moving?
                        if not (pa[o] + ea[o] < pb[o] + eb[o + 2] + clearance
                                and pb[o] + eb[o] < pa[o] + ea[o + 2]
                                + clearance):
                            continue        # already clear, nothing to do
                        need = ((pa[axis] + ea[axis + 2] + sep)
                                - (pb[axis] + eb[axis]))
                        if need > 0 and (worst is None or need > worst[0]):
                            worst = (need, b)
        if worst is None:
            break
        need, b = worst
        for k in keys[b:]:            # carry every later line along
            for i in lines[k]:
                items[i][axis] += need
        moved += 1
    return moved


def _residual_conflicts(items, lock=None, clearance=None):
    """In : `items` in _separate_boxes's [[x, y, ext], ...] form, an
    optional lock set and a clearance.  Out: the indices of items still
    in conflict after the order-preserving push.
    Reports the pairs the push could not clear — both ends locked, or an
    arrangement with no room.  The place-or-error contract turns an item
    left here into a visible ERROR box and a stdout log rather than
    allowing a silent overlap.  Same predicate and default clearance as
    _separate_boxes, so what the push tried to fix and what is reported
    as unfixed cannot disagree."""
    n = len(items)

    def box(i):
        x, y, e = items[i][0], items[i][1], items[i][2]
        return (x + e[0], y + e[1], x + e[2], y + e[3])
    bad = set()
    for a in range(n):
        for b in range(a + 1, n):
            if _boxes_clash_at(box(a), box(b), clearance):
                bad.add(a); bad.add(b)
    return bad


def _seg_intersects_rect(x1, y1, x2, y2, rect):
    """True if the segment (x1,y1)-(x2,y2) intersects the
    axis-aligned rectangle rect=(rx0,ry0,rx1,ry1).  Used to keep a T's
    flight line from crossing its own net-label box.  Liang-Barsky clip."""
    rx0, ry0, rx1, ry1 = rect
    if rx1 < rx0:
        rx0, rx1 = rx1, rx0
    if ry1 < ry0:
        ry0, ry1 = ry1, ry0
    # Trivial: an endpoint inside the rect.
    if (rx0 <= x1 <= rx1 and ry0 <= y1 <= ry1) or \
       (rx0 <= x2 <= rx1 and ry0 <= y2 <= ry1):
        return True
    dx = x2 - x1
    dy = y2 - y1
    p = (-dx, dx, -dy, dy)
    q = (x1 - rx0, rx1 - x1, y1 - ry0, ry1 - y1)
    t0, t1 = 0.0, 1.0
    for pi, qi in zip(p, q):
        if pi == 0:
            if qi < 0:
                return False        # parallel and outside
        else:
            r = qi / pi
            if pi < 0:
                if r > t1:
                    return False
                if r > t0:
                    t0 = r
            else:
                if r < t0:
                    return False
                if r < t1:
                    t1 = r
    return t0 <= t1


class CompInstance:
    """One SPICE component plus all its text labels, ready for 2-D
    placement.
    Every position is an OFFSET from the symbol centre, which is (0, 0):
    moving the instance to canvas pixel (ox_px, oy_px) translates every
    bbox by (ox_px + sym_cx_offset, oy_px + sym_cy_offset).
      sym_body_rel   (x0,y0,x1,y1) symbol graphic bbox about that centre
      text_items     one TextItem per label (nets, value, ref)
      composite_rel  sym_body_rel unioned with every placed text bbox
      ox_px, oy_px   canvas top-left of the composite bbox, set by place
    """

    __slots__ = ('comp','sym_entry','sym_scale','mid_kx','mid_ky',
                 'sym_body_rel','graphic_body_rel','text_items','composite_rel',
                 'ox_px','oy_px','rotation_deg',
                 # Cached (pin_num, net) pairs after orientation
                 # flip — used by clustering and T-terminal code.
                 '_pin_net_pairs',
                 # Tight group id + kind (foundation for the
                 # group-id placement refactor; assigned by
                 # _assign_group_ids, not yet consumed by placement).
                 'group_id',
                 # Outward-side hint for value-text placement,
                 # set by _layout_diff_pair so the load/degeneration resistor
                 # values go away from the cell centre (not over the neighbour).
                 '_value_text_prefer',
                 # T-symbol offsets, in this instance's own frame, frozen
                 # per (rotation, flip) so the reservation handed to
                 # Sugiyama and the T's later drawn are the same numbers.
                 # See _predicted_pin_t.
                 '_t_local_cache')

    def __init__(self, comp, sym_entry, sym_scale, mid_kx, mid_ky):
        self.comp       = comp
        self.sym_entry  = sym_entry
        self.sym_scale  = sym_scale
        self.mid_kx     = mid_kx
        self.mid_ky     = mid_ky
        self.rotation_deg = 0   # applied after placement: 0/90/180/270 CCW
        self.sym_body_rel   = (-1,-1,1,1)
        self.graphic_body_rel = (-1,-1,1,1)  # primary closed shape/lines
        self.text_items    = []   # list of dicts, see _build below
        self.composite_rel = (-1,-1,1,1)
        self.ox_px = 0.0
        self.oy_px = 0.0
        self._pin_net_pairs = []
        self.group_id = None
        # An equation-driven source (has
        # sense_nets/sense_srcs — V()/I() reads with no drawable pin of
        # their own) defaults its value text to the LEFT, same as any
        # other input: the equation reads external nets/sources, so it
        # behaves like an input to this instance even though it isn't
        # a real pin.  Still just a DEFAULT — _layout_diff_pair (or any
        # other caller) can still override it per-instance afterward,
        # same as before.
        self._value_text_prefer = (
            'left' if (comp.get('sense_nets') or comp.get('sense_srcs'))
            else None)

    # ── coordinate helpers ────────────────────────────────────────────────

    def kicad_rel(self, kx, ky):
        """KiCad mm → relative canvas px (origin = sym centre)."""
        return ((kx - self.mid_kx) * self.sym_scale,
                -(ky - self.mid_ky) * self.sym_scale)

    def canvas_xy(self, rx, ry):
        """Relative px → absolute canvas px."""
        return self.ox_px + rx, self.oy_px + ry

    def abs_sym_body(self):
        b = self.sym_body_rel
        return _translate_bb(b, self.ox_px, self.oy_px)

    def abs_composite(self):
        return _translate_bb(self.composite_rel, self.ox_px, self.oy_px)

    # ── build ─────────────────────────────────────────────────────────────

    def build(self, pin_net_pairs, fulltext=False, multi_pin_nets=None):
        """In : pin_net_pairs [(pin_num_str, net_name), ...] in SPICE
        order; fulltext to show the whole value instead of truncating to
        VALUE_MAX_CHARS; multi_pin_nets, the lower-case nets touching two
        or more positional pins.
        Out: none; sets sym_body_rel and rebuilds text_items.
        A pin on a multi-pin net gets NO 'kind=net' item — the label is
        drawn once on the flight line or the T-symbol — while a pin on a
        single-pin net keeps its own label, there being no flight line to
        carry it.  None labels every pin, as on the first render before
        _render has computed the set."""
        shapes = self.sym_entry.get('shapes', [])
        pins   = self.sym_entry.get('pins', {})
        comp   = self.comp
        ss     = self.sym_scale

        # reset text_items at the START of build so re-running
        # build (an earlier revision re-runs it after rotation to regenerate
        # label
        # candidates) REPLACES the items instead of APPENDING duplicates.
        self.text_items = []

        # Apply GND/VCC orientation flip for R, C, L
        pin_net_pairs = _net_orientation_flip(comp, pin_net_pairs)
        # Cache the (post-flip) pin↔net pairs on the instance
        # so cluster/T-terminal code can look up a pin's number by net.
        self._pin_net_pairs = list(pin_net_pairs)

        # Symbol body bbox with 10% pin stubs as obstacle
        no_pin = [s for s in shapes if s['kind'] != 'pin']
        pin10  = [{**s, 'length': s['length']*0.10}
                  for s in shapes if s['kind'] == 'pin']
        body_sh = no_pin + pin10
        if body_sh:
            bb = _bbox_of_shapes(body_sh)
            full_bb = _bbox_of_shapes(shapes) if shapes else bb
            fmkx=(full_bb[0]+full_bb[2])/2; fmky=(full_bb[1]+full_bb[3])/2
            self.sym_body_rel = (
                (bb[0]-fmkx)*ss, -(bb[3]-fmky)*ss,
                (bb[2]-fmkx)*ss, -(bb[1]-fmky)*ss,
            )
        else:
            hw=CELL_W_MM/2*SCALE; hh=CELL_H_MM/2*SCALE
            self.sym_body_rel=(-hw,-hh,hw,hh)

        # Graphic body obstacle for label placement.
        # Priority: use the primary closed shape (circle/diamond/rect) if one
        # exists.
        # This avoids the control-port lines of ESOURCE/GSOURCE inflating the
        # bbox.
        # For components without a closed shape (R, C, L): use the actual drawn
        # polylines only (not text, not pins).
        # The obstacle prevents labels from landing INSIDE the main graphic
        # element.
        full_bb2 = _bbox_of_shapes(shapes) if shapes else (-5,-5,5,5)
        fmkx2=(full_bb2[0]+full_bb2[2])/2; fmky2=(full_bb2[1]+full_bb2[3])/2

        closed_obs = _find_primary_closed_shape(shapes)
        if closed_obs:
            # Use the closed shape's bbox as the body obstacle
            _ck, ccx, ccy, hw, hh = closed_obs
            # Convert KiCad mm to rel canvas px (origin = full bbox centre)
            ocx = (ccx - fmkx2)*ss; ocy = -(ccy - fmky2)*ss
            self.graphic_body_rel = (
                ocx - hw*ss, ocy - hh*ss,
                ocx + hw*ss, ocy + hh*ss,
            )
        else:
            # No closed shape: use only drawn polylines (not text/circles/pins)
            drawn = [s for s in shapes
                     if s['kind'] in ('polyline', 'rectangle', 'arc')]
            if drawn:
                gbb = _bbox_of_shapes(drawn)
                self.graphic_body_rel = (
                    (gbb[0]-fmkx2)*ss, -(gbb[3]-fmky2)*ss,
                    (gbb[2]-fmkx2)*ss, -(gbb[1]-fmky2)*ss,
                )
            else:
                self.graphic_body_rel = self.sym_body_rel

        # Net label candidates, 4 per pin: beside the wire for vertical stubs,
        # above or below it for horizontal stubs.
        NUDGE = 3.0
        for pin_num, net_name in pin_net_pairs:
            if (multi_pin_nets is not None
                    and net_name.lower() in multi_pin_nets):
                continue
            geom = pins.get(str(pin_num))
            if geom is None:
                continue
            ax, ay, angle_deg, alen = geom
            arx, ary = self.kicad_rel(ax, ay)

            a_norm = angle_deg % 360
            is_vertical = (45 <= a_norm < 135) or (225 <= a_norm < 315)
            tW, lh = _measure_text(net_name, 12)

            # The label goes at the pin's outer anchor, which is outside the
            # body, so only the stub and other net labels can block it.

            if is_vertical:
                # Snap label Y to the body edge, not the distant anchor.
                # Top pin (sdy>0, anchor above body): base of text just above
                # body top.
                # Bottom pin (sdy<0, anchor below body): top of text just below
                # body bottom.
                # Use compound anchors (se/ne) so the text corner sits at the
                # snap point.
                # LABEL_GAP: small clearance between text and body bbox edge.
                LABEL_GAP = 3.0   # px
                gbr = self.graphic_body_rel
                ar_local = math.radians(angle_deg)
                sdy_local = -math.sin(ar_local)   # canvas y direction of stub
                if sdy_local > 0:
                    # Stub goes DOWN → anchor is ABOVE body
                    snap_y   = gbr[1] - LABEL_GAP   # base above body top
                    a_left   = 'se'   # right-bottom at (arx-NUDGE, snap_y)
                    a_right  = 'sw'   # left-bottom at (arx+NUDGE, snap_y)
                else:
                    # Stub goes UP → anchor is BELOW body
                    snap_y   = gbr[3] + LABEL_GAP   # top below body bottom
                    a_left   = 'ne'   # right-top corner at (arx-NUDGE, snap_y)
                    a_right  = 'nw'   # left-top corner at (arx+NUDGE, snap_y)
                # Detect sibling pins on the same side (same snap_y level).
                # If this pin's label is too wide to fit between the two stubs
                # without crossing the sibling stub, prefer going OUTSIDE
                # instead.
                sibling_arx = None
                for _pn2, (ax2, _ay2, adeg2, _al2) in pins.items():
                    if _pn2 == str(pin_num): continue
                    a2 = adeg2 % 360
                    is_v2 = (45 <= a2 < 135) or (225 <= a2 < 315)
                    if not is_v2: continue
                    ar2 = math.radians(adeg2); sdy2 = -math.sin(ar2)
                    if abs(sdy2 - sdy_local) > 0.1: continue   # different side
                    arx2 = (ax2 - self.mid_kx) * self.sym_scale
                    if (sibling_arx is None
                            or abs(arx2 - arx) < abs(sibling_arx - arx)):
                        sibling_arx = arx2

                # Left cand0 left edge = arx - NUDGE - tW.
                # It crosses sibling if sibling is between that left edge and
                # this stub.
                would_cross = (sibling_arx is not None and
                               sibling_arx > arx - NUDGE - tW and
                               sibling_arx < arx)

                if would_cross:
                    # Label too wide for inside space → go OUTSIDE (away from
                    # sibling)
                    cands = [
                        (arx + NUDGE,        snap_y, a_right),  # outside
                        (arx - NUDGE,        snap_y, a_left),   # inside
                        (arx + NUDGE + tW,   snap_y, a_left),   # outside +1
                        (arx - NUDGE - tW,   snap_y, a_right),  # inside +1
                        (arx + NUDGE + 2*tW, snap_y, a_left),   # outside +2
                        (arx - NUDGE - 2*tW, snap_y, a_right),  # inside +2
                    ]
                else:
                    # Nearest first; left of the stub is preferred.
                    cands = [
                        (arx - NUDGE,        snap_y, a_left),   # left
                        (arx + NUDGE,        snap_y, a_right),  # right
                        (arx - NUDGE - tW,   snap_y, a_right),  # left +1
                        (arx + NUDGE + tW,   snap_y, a_left),   # right +1
                        (arx - NUDGE - 2*tW, snap_y, a_right),  # left +2
                        (arx + NUDGE + 2*tW, snap_y, a_left),   # right +2
                    ]
            else:
                cands = [
                    (arx,        ary - NUDGE, 's'),   # above anchor (preferred)
                    (arx,        ary + NUDGE, 'n'),   # below anchor
                    (arx - NUDGE,ary,         'e'),   # left of anchor
                    (arx + NUDGE,ary,         'w'),   # right of anchor
                    (arx, ary - NUDGE - lh,   'n'),   # further above
                    (arx, ary + NUDGE + lh,   's'),   # further below
                ]

            self.text_items.append({
                'kind':       'net',
                'text':       net_name,
                'font_size':  12,
                'candidates': cands,
                'placed':     None,
            })

        # Value label
        # Canonicalise milli/MEG suffixes for display
        # (the stored comp['value'] is left byte-faithful to the deck).
        vtext_full = _normalize_eng_suffix(comp['value'])
        # Behavioral-expression sources (EVALUE / GVALUE) get
        # their expression broken into 2 lines and placed to the LEFT
        # of the diamond rather than inside it.
        if comp['sym'] in ('EVALUE', 'GVALUE'):
            vtext_disp = _split_value_2lines(vtext_full,
                                              fulltext=fulltext)
        elif ' ' in vtext_full.strip():
            # multi-token value: wrap to a roughly square
            # block (2 tokens stay 2 lines; long POLY expressions wrap to
            # ~3-4 lines instead of a tall one-per-line ribbon).
            vtext_disp = _wrap_value_square(vtext_full)
        else:
            vtext_disp = (vtext_full
                          if fulltext or len(vtext_full) <= VALUE_MAX_CHARS
                          else vtext_full[:VALUE_MAX_CHARS] + '…')
        self.text_items.append({
            'kind':       'value',
            'text':       vtext_disp,
            'text_full':  vtext_full,
            'font_size':  12,
            'candidates': self._value_candidates(shapes, vtext_disp),
            'placed':     None,
        })
        # REF DESIGNATOR as a first-class text item (user
        # request).  Previously the ref was drawn ad hoc at render time
        # (below the whole composite bbox) and was in NO bounding box, so
        # placement never reserved room for it (a neighbour could overwrite
        # it — the GCM-over-IEE case).  As a text item it is PLACED with the
        # instance and INCLUDED in the bbox.  Candidates sit just below the
        # body (place_texts then stacks it under the value label if one was
        # placed there).  Drawn smaller (font 9) like the old ref label.
        ref_text = shorten_ref(comp['ref'])
        self.text_items.append({
            'kind':       'ref',
            'text':       ref_text,
            'font_size':  9,
            'candidates': self._ref_candidates(shapes),
            'placed':     None,
        })

    def _bjt_interior_candidate(self, shapes, text, font_size=9,
                                font_bold=True):
        """Best interior spot for `text` on a BJT: the line-free region between
        the base bar and the case, on the collector/emitter side.
        """
        _bjt_circle = next((s for s in shapes if s['kind'] == 'circle'),
                           None)
        _spine = None
        for s in shapes:
            if s['kind'] != 'polyline':
                continue
            pts = s.get('pts', [])
            if len(pts) < 2:
                continue
            (x0, y0), (x1, y1) = pts[0], pts[1]
            if abs(x0 - x1) < 0.01 and 3.5 < abs(y0 - y1) < 4.2:
                _spine = x0
                break
        if _bjt_circle is None or _spine is None:
            return None
        ccx, ccy, r = (_bjt_circle['cx'], _bjt_circle['cy'],
                      _bjt_circle['r'])
        ccy_rel = self.kicad_rel(ccx, ccy)[1]
        sign = 1.0 if ccx >= _spine else -1.0
        tw, th = _measure_text(text, font_size, font_bold)
        MARGIN_PX = 1.0
        DIAG_FAR_MM = 2.54

        def avail_h_px(x_local_mm):
            dx_ctr = x_local_mm - ccx
            circ_term = r**2 - dx_ctr**2
            if circ_term < 0:
                return 0.0
            circ_h = math.sqrt(circ_term)
            ax = abs(x_local_mm)
            diag_h = ax if ax <= DIAG_FAR_MM else circ_h
            return 2 * min(circ_h, diag_h) * self.sym_scale

        def fits_at(d_mm):
            x_near = _spine + sign * d_mm
            x_far = _spine + sign * (d_mm + tw / self.sym_scale)
            tight = min(avail_h_px(x_near), avail_h_px(x_far)) - 2 * MARGIN_PX
            return th <= tight

        min_d = None
        d_mm = 0.0
        while d_mm < 2 * r:
            if fits_at(d_mm):
                min_d = d_mm
                break
            d_mm += 0.02
        if min_d is None:
            return None
        max_d = min_d
        d_mm = min_d
        while d_mm < 2 * r:
            if fits_at(d_mm):
                max_d = d_mm
                d_mm += 0.02
            else:
                break
        center_d = (min_d + max_d) / 2.0
        x_center_local = _spine + sign * (
            center_d + tw / (2 * self.sym_scale))
        cx_rel = self.kicad_rel(x_center_local, ccy)[0]
        return (cx_rel, ccy_rel)

    def _ref_candidates(self, shapes):
        """In : the symbol's shapes.  Out: the candidate positions for the
        ref-designator label.
        A SHORT ref goes INSIDE the body when it fits — a source circle
        (VDC/IDC) or a diamond (GSOURCE/BSOURCE) — by the same
        closed-shape fit test the value uses, so VB, IEE and GA sit
        centred in their symbol.  The interior spot is tried FIRST, and a
        too-wide ref falls back to below, above or the sides.
        _value_candidates offers the value its own interior spot, and
        place_texts resolves the two so both cannot claim the centre."""
        body = [s for s in shapes if s['kind'] != 'pin'] or shapes
        bb_b = _bbox_of_shapes(body) if body else (-5, -5, 5, 5)
        bx0, by0, bx1, by1 = bb_b
        bcx_rel, bcy_rel = self.kicad_rel((bx0 + bx1) / 2, (by0 + by1) / 2)
        bhw = (bx1 - bx0) / 2 * self.sym_scale
        bhh = (by1 - by0) / 2 * self.sym_scale
        VPAD = 6.0
        cands = []
        # Interior center candidate — only for closed-body BOXED_SYMS
        # (circle/diamond sources) and only when the ref text fits.
        closed = _find_primary_closed_shape(shapes)
        if closed and self.comp['sym'] in BOXED_SYMS:
            _k, ccx, ccy, hw, hh = closed
            int_w = hw * self.sym_scale * 2 * 0.85
            int_h = hh * self.sym_scale * 2 * 0.85
            if _k in ('circle', 'poly'):
                int_w *= 0.70; int_h *= 0.70
            rw_f, rh_f = _measure_text(shorten_ref(self.comp['ref']), 9)
            if rw_f <= int_w and rh_f <= int_h:
                ccx_rel, ccy_rel = self.kicad_rel(ccx, ccy)
                cands.append((ccx_rel, ccy_rel, 'center', 9, True))
        # BJT interior ref: centered vertically in the empty space between the
        # base bar and the case, on the side away from the base lead wire.
        elif self.comp['sym'] in ('NPN', 'PNP'):
            _bjt_pos = self._bjt_interior_candidate(
                shapes, shorten_ref(self.comp['ref']),
                font_size=9, font_bold=True)
            if _bjt_pos is not None:
                cands.append((_bjt_pos[0], _bjt_pos[1], 'center', 9, True))
        # SOURCES: interior if it fits (costs no width at all),
        # otherwise stacked directly under the equation on the LEFT —
        # never opposite it, which is what made LP2951's U2.E1 as wide
        # as equation + body + ref.  See _source_text_layout.
        _sl = self._source_text_layout()
        if _sl is not None:
            return cands + [(_sl[3], _sl[4], _sl[2], 9, False)]
        # SAME decided side as the value — see
        # _two_pin_text_layout.  Ref and value must not be searched
        # independently, or a vertical resistor ends up with its value
        # on the right and its ref underneath, which is both untidy and
        # a bbox the reservation cannot predict.
        mode = self._two_pin_text_layout()
        if mode == 'right':
            cands.append((bcx_rel + bhw + VPAD, bcy_rel, 'w', 9, False))
            return cands
        if mode == 'above':
            cands.append((bcx_rel, bcy_rel - bhh - VPAD, 's', 9, False))
            return cands
        if mode == 'split':
            # 'ref value' would be wider than the pin span, so the two
            # labels straddle the body: ref above, value below.
            cands.append((bcx_rel, bcy_rel - bhh - VPAD, 's', 9, False))
            return cands
        cands += [
            (bcx_rel, bcy_rel + bhh + VPAD, 'n', 9, False),   # below body
            (bcx_rel, bcy_rel - bhh - VPAD, 's', 9, False),   # above body
            (bcx_rel + bhw + VPAD, bcy_rel, 'w', 9, False),   # right
            (bcx_rel - bhw - VPAD, bcy_rel, 'e', 9, False),   # left
        ]
        return cands

    def _value_candidates(self, shapes, vtext):
        """Return value label candidate positions (preference order)."""
        body  = [s for s in shapes if s['kind'] != 'pin'] or shapes
        bb_b  = _bbox_of_shapes(body) if body else (-5,-5,5,5)
        bx0,by0,bx1,by1 = bb_b
        bcx_rel,bcy_rel = self.kicad_rel((bx0+bx1)/2,(by0+by1)/2)
        bhw = (bx1-bx0)/2*self.sym_scale
        bhh = (by1-by0)/2*self.sym_scale
        # Decide horizontal vs vertical from the full symbol extent including
        # pins; spacing offsets stay body-relative.
        _full_bb = _bbox_of_shapes(shapes) if shapes else bb_b
        _is_vertical = (_full_bb[3] - _full_bb[1]) > (_full_bb[2] - _full_bb[0])
        VPAD = 6.0
        cands = []
        # EVALUE/GVALUE values: the text goes on a side with no pin stub, which
        # depends on the source's rotation.
        _sl = self._source_text_layout()
        if _sl is not None:
            closed = _find_primary_closed_shape(shapes)
            if closed and self.comp['sym'] in BOXED_SYMS:
                _k, ccx, ccy, hw, hh = closed
                int_w = hw * self.sym_scale * 2 * 0.85
                int_h = hh * self.sym_scale * 2 * 0.85
                if _k in ('circle', 'poly'):
                    int_w *= 0.70; int_h *= 0.70
                vw_f, lh_f = _measure_text(vtext, 10)
                if vw_f <= int_w and lh_f <= int_h:
                    ccx_rel, ccy_rel = self.kicad_rel(ccx, ccy)
                    cands.append((ccx_rel, ccy_rel, 'center', 10, True))
            cands.append((_sl[0], _sl[1], _sl[2], 10, False))
            return cands
        closed = _find_primary_closed_shape(shapes)
        if closed and self.comp['sym'] in BOXED_SYMS:
            _k,ccx,ccy,hw,hh = closed
            int_w=hw*self.sym_scale*2*0.85; int_h=hh*self.sym_scale*2*0.85
            if _k in ('circle','poly'): int_w*=0.70; int_h*=0.70
            vw_f,lh_f = _measure_text(vtext, 10)
            if vw_f<=int_w and lh_f<=int_h:
                ccx_rel,ccy_rel=self.kicad_rel(ccx,ccy)
                cands.append((ccx_rel,ccy_rel,'center',10,True))
        # NPN/PNP: offer the SAME diagonal-side
        # interior spot for value that _ref_candidates offers for ref,
        # so _resolve_interior_ref_value can choose whichever ACTUALLY
        # fits (value takes priority per the usual convention, matching
        # every other BOXED_SYM case) rather than value always being
        # forced outside.  Font size 10 bold, matching how an interior
        # value is drawn everywhere else in this file.
        elif self.comp['sym'] in ('NPN', 'PNP'):
            _bjt_pos = self._bjt_interior_candidate(
                shapes, vtext, font_size=10, font_bold=True)
            if _bjt_pos is not None:
                cands.append((_bjt_pos[0], _bjt_pos[1], 'center', 10, True))
        # orientation-aware value placement at the smaller
        # (cap-value) font: a HORIZONTAL part (wide body) puts its value
        # centred above/below so it doesn't add horizontal width; a
        # VERTICAL part (tall body) keeps it to the side, nearest the
        # body.  All four at font 10 to match the capacitor value.
        side = [
            (bcx_rel + bhw + VPAD, bcy_rel, 'w', 10, False),
            (bcx_rel - bhw - VPAD, bcy_rel, 'e', 10, False),
        ]
        # honour a caller-supplied outward-side hint (set
        # by _layout_diff_pair on the load/degeneration resistors) so the
        # value text is tried on the OUTWARD side first and doesn't land
        # over the neighbouring column's body.  'left' → 'e' anchor first
        # (text extends left), 'right' → 'w' first (text extends right).
        prefer = getattr(self, '_value_text_prefer', None)
        if prefer == 'left':
            side = [side[1], side[0]]      # 'e' first
        elif prefer == 'right':
            side = [side[0], side[1]]      # 'w' first (already)
        updown = [
            (bcx_rel, bcy_rel - bhh - VPAD, 's', 10, False),
            (bcx_rel, bcy_rel + bhh + VPAD, 'n', 10, False),
        ]
        # 2-pin text side is decided, not searched: vertical part, ref and value
        # on the right; horizontal part, on top.  Equation sources are handled
        # above.
        mode = self._two_pin_text_layout()
        if mode == 'right':
            cands += [side[0]]           # 'w' anchor: text extends right
            return cands
        if mode == 'above':
            cands += [updown[0]]         # 's' anchor: text sits above
            return cands
        if mode == 'split':
            cands += [updown[1]]         # 'n' anchor: value sits below
            return cands
        cands += (side + updown) if _is_vertical else (updown + side)
        return cands

    def _snapshot_text_items(self):
        """In : self.text_items.  Out: (the LIST object, [(item, text,
        placed, candidates) per item]) — enough to put them back exactly.
        Both halves are needed.  place_texts rewrites item['text'] and
        item['candidates'], so a caller running it just to MEASURE has to
        restore those fields; and _apply_instance_rotation_geometry
        re-runs build(), which replaces text_items with new dicts, so
        restoring fields alone writes into dicts no longer attached.
        Missing the first grew _placement_extent by a text line on every
        call; missing the second left a rejected trial with new,
        unplaced labels."""
        items = getattr(self, 'text_items', None) or []
        return (items,
                [(t, t.get('text'), t.get('placed'), t.get('candidates'))
                 for t in items])

    def _restore_text_items(self, snap):
        """Undo _snapshot_text_items."""
        if not snap:
            return
        items, fields = snap
        self.text_items = items
        for t, text, placed, cands in fields:
            t['text'] = text
            t['placed'] = placed
            t['candidates'] = cands

    def _source_text_layout(self):
        """For an equation-controlled source, return (vx, vy, anchor, rx, ry):
        value label and the ref stacked beyond it, on one decided side.  None
        for anything else.
        """
        if self.comp.get('sym') not in _SOURCE_TEXT_SYMS:
            return None
        shapes = (self.sym_entry or {}).get('shapes', [])
        if not shapes:
            return None
        body = [s for s in shapes if s['kind'] != 'pin'] or shapes
        bx0, by0, bx1, by1 = _bbox_of_shapes(body)
        bcx, bcy = self.kicad_rel((bx0 + bx1) / 2, (by0 + by1) / 2)
        bhw = (bx1 - bx0) / 2 * self.sym_scale
        bhh = (by1 - by0) / 2 * self.sym_scale
        VPAD = 6.0
        items = getattr(self, 'text_items', None) or []
        vitem = next((t for t in items if t['kind'] == 'value'), None)
        vtext = vitem['text'] if vitem else str(self.comp.get('value', ''))
        vtext = vtext or ''
        rtext = shorten_ref(self.comp.get('ref', ''))
        _vw, _vh = _measure_text(vtext, 10)
        _rw, rh = _measure_text(rtext, 9, True)
        try:
            stubs = _instance_stub_boxes(self, relative=True)
        except Exception:
            stubs = []
        # The instance's own net labels, as already-placed boxes when
        # they have been placed and as their first candidate otherwise.
        others = []
        for t in items:
            if t['kind'] != 'net':
                continue
            p = t.get('placed') or (t['candidates'][0] if t['candidates']
                                    else None)
            if not p:
                continue
            others.append(_text_bbox_from_anchor(
                p[0], p[1], t['text'], p[2],
                p[3] if len(p) > 3 else t.get('font_size', 12)))
        # (anchor, value anchor point, unit vector the stack grows along)
        sides = (
            ('e', (bcx - bhw - VPAD, bcy), (0.0, 1.0)),
            ('w', (bcx + bhw + VPAD, bcy), (0.0, 1.0)),
            ('s', (bcx, bcy - bhh - VPAD), (0.0, -1.0)),
            ('n', (bcx, bcy + bhh + VPAD), (0.0, 1.0)),
        )
        pick = None
        for anchor, (vx, vy), _uv in sides:
            vb = _text_bbox_from_anchor(vx, vy, vtext, anchor, 10)
            if not any(_overlaps(vb, s) for s in stubs):
                pick = (anchor, vx, vy, _uv)
                break
        if pick is None:
            anchor, (vx, vy), _uv = sides[0]
            pick = (anchor, vx, vy, _uv)
        anchor, vx, vy, (_ux, uy) = pick
        # Place the ref by SOLVING against the value's real bbox rather
        # than assuming the anchor centres text vertically: 'e'/'w' do,
        # but 's' anchors the bottom and 'n' the top, so a fixed
        # half-height offset stacked the ref straight onto the equation
        # whenever the stub check pushed the pair above the body
        # (OPAX197's X_U4.E1).  A trial box at ry=0 gives the anchor's
        # own offset, whatever it is.
        vb = _text_bbox_from_anchor(vx, vy, vtext, anchor, 10)
        rb0 = _text_bbox_from_anchor(vx, 0.0, rtext, anchor, 9, True)
        GAP = 4.0
        if uy > 0:
            ry = vb[3] + GAP - rb0[1]
        else:
            ry = vb[1] - GAP - rb0[3]
        # Then step along the stack direction until the ref clears the
        # instance's own net labels.
        for _ in range(12):
            rb = _text_bbox_from_anchor(vx, ry, rtext, anchor, 9, True)
            if not any(_overlaps(rb, o) for o in others):
                break
            ry += uy * (rh + 3.0)
        if not vtext.strip():
            ry = vy
        return (vx, vy, anchor, vx, ry)

    def _two_pin_text_layout(self):
        """Out: how this instance's ref and value text is laid out —
          'right'  vertical 2-pin part: both labels RIGHT of the body,
                   between the pins
          'above'  horizontal 2-pin part: both labels ABOVE the body
          'split'  horizontal 2-pin part whose 'ref value' line is wider
                   than its pin span: ref ABOVE, value BELOW
          None     not an ordinary 2-pin part (BJT, FET, subckt,
                   equation source), so the four-sided search applies.
        One routine for the value-candidate builder, the ref placement
        and the bbox reservation, so measure and draw cannot disagree."""
        sym = self.comp.get('sym')
        if sym in ('EVALUE', 'GVALUE'):
            return None                  # equation source: keep the search
        if len(self.comp.get('nets', []) or []) != 2:
            return None
        shapes = (self.sym_entry or {}).get('shapes', [])
        if not shapes:
            return None
        full = _bbox_of_shapes(shapes)
        w = (full[2] - full[0]) * self.sym_scale
        h = (full[3] - full[1]) * self.sym_scale
        if h > w:
            return 'right'
        ref = str(self.comp.get('ref', '') or '')
        val = str(self.comp.get('value', '') or '')
        line = re.sub(r'\s\s+', ' ', f'{ref} {val}').strip()
        tw, _th = _measure_text(line, 10)
        return 'split' if tw > w else 'above'

    def _resolve_interior_ref_value(self):
        """Decide how REF and VALUE are placed for every instance: inside a
        closed body when they fit, else stacked outside on one side.
        """
        ref_it = next((t for t in self.text_items if t['kind'] == 'ref'), None)
        val_it = next((t for t in self.text_items if t['kind'] == 'value'),
                      None)
        if ref_it is None and val_it is None:
            return

        # NPN/PNP: value and ref can both claim the interior spot
        # (_bjt_interior_candidate), so pick one here.
        if self.comp['sym'] in ('NPN', 'PNP'):
            def _bjt_strip_interior(item):
                if item is None:
                    return
                item['candidates'] = [c for c in item['candidates']
                                      if not (len(c) == 5 and c[4])]
            shapes = self.sym_entry.get('shapes', [])
            val_txt_bjt = val_it['text'] if val_it else ''
            ref_txt_bjt = shorten_ref(self.comp['ref']) if ref_it else ''
            val_fits = (val_it is not None and self._bjt_interior_candidate(
                shapes, val_txt_bjt, font_size=10, font_bold=True) is not None)
            ref_fits = (ref_it is not None and self._bjt_interior_candidate(
                shapes, ref_txt_bjt, font_size=9, font_bold=True) is not None)
            if val_fits:
                _bjt_strip_interior(ref_it)
            elif ref_fits:
                _bjt_strip_interior(val_it)
            else:
                _bjt_strip_interior(ref_it)
                _bjt_strip_interior(val_it)
            return

        # No rotation-keyed cache here: text_items are rebuilt every round, so a
        # cache hit returned stale geometry.

        def strip_interior(item):
            if item is None:
                return
            item['candidates'] = [c for c in item['candidates']
                                  if not (len(c) == 5 and c[4])]

        # Wrap from the original text, never from a previous wrap: the wrappers
        # are not idempotent.
        ref_txt = _text_src(ref_it) if ref_it else ''
        val_txt = _text_src(val_it) if val_it else ''
        ref_fs = ref_it['font_size'] if ref_it else 9
        val_fs = 10

        # SOURCES had their layout decided already by _source_text_layout
        # (equation left; ref inside if it fits, else stacked under), so the
        # generic candidates prepended here must not override it.  An
        # equation never fits inside, so its interior candidate is stripped;
        # the ref keeps the one _ref_candidates measured as fitting.
        if self._source_text_layout() is not None:
            strip_interior(val_it)
            return

        # Body half-width/height (the ACTUAL symbol bbox) — needed as the
        # squareness target for whichever text ends up outside.  Same
        # computation _ref_candidates/_value_candidates use, so the VPAD
        # gap and body edges line up with their normal outside candidates.
        body = ([s for s in self.sym_entry.get('shapes', [])
                if s['kind'] != 'pin'] or self.sym_entry.get('shapes', []))
        bb_b = _bbox_of_shapes(body) if body else (-5, -5, 5, 5)
        bx0, by0, bx1, by1 = bb_b
        bcx_rel, bcy_rel = self.kicad_rel((bx0 + bx1) / 2, (by0 + by1) / 2)
        bhw = (bx1 - bx0) / 2 * self.sym_scale
        bhh = (by1 - by0) / 2 * self.sym_scale
        # The vertical/horizontal DECISION uses the FULL symbol extent
        # (pin leads included), not the body-only bb_b: a capacitor's
        # plates read "wide" while the whole symbol is clearly vertical.
        _full_shapes = self.sym_entry.get('shapes', [])
        _full_bb = _bbox_of_shapes(_full_shapes) if _full_shapes else bb_b
        _is_vertical = (_full_bb[3] - _full_bb[1]) > (_full_bb[2] - _full_bb[0])
        VPAD = 6.0

        # Interior candidates only exist for a closed-body BOXED_SYM.
        if self.comp['sym'] in BOXED_SYMS:
            closed = _find_primary_closed_shape(
                self.sym_entry.get('shapes', []))
        else:
            closed = None
        if closed:
            _k, ccx, ccy, hw, hh = closed
            int_w = hw * self.sym_scale * 2 * 0.85
            int_h = hh * self.sym_scale * 2 * 0.85
            if _k in ('circle', 'poly'):
                int_w *= 0.70; int_h *= 0.70
            # Some boxed symbols (ESOURCE/EVALUE diamonds) carry a +/- mark
            # inside, so keep interior text clear of it.
            _marker_half_h = _closest_interior_marker_half_h(
                self.sym_entry.get('shapes', []), closed)
            if _marker_half_h is not None:
                _marker_h_px = 2 * _marker_half_h * self.sym_scale
                int_h = min(int_h, _marker_h_px)

            def fits(text, fs, bold=False):
                if not text:
                    return False
                # Height uses _tight_text_height: linespace pads for ascenders
                # and descenders that SPICE text rarely has.
                w, _h = _measure_text(text, fs, bold)
                _w2, h = _tight_text_height(text, fs, bold)
                return w <= int_w and h <= int_h

            # Case 0 (user) — try to fit BOTH value (on top) and ref
            # (below) inside the body, WRAPPING either/both across
            # multiple lines if needed ("split and wrap f'{value} {ref}'
            # to see if it fits first") — checked before falling back to
            # value-alone or ref-alone.  _try_stacked_interior_fit
            # searches by ascending total line count, so a plain
            # single-line-each fit (the original, simpler Case 0) is
            # still what's returned whenever it already works; wrapping
            # only kicks in when it's actually needed to make room.
            if ref_it and val_it:
                STACK_GAP = 3.0
                fit = _try_stacked_interior_fit(
                    val_txt, val_fs, ref_txt, ref_fs, int_w, int_h,
                    gap=STACK_GAP)
                if fit is not None:
                    val_wrapped, val_h, ref_wrapped, ref_h = fit
                    ccx_rel, ccy_rel = self.kicad_rel(ccx, ccy)
                    # small fixed clearance between
                    # the two stacked lines instead of them touching,
                    # while keeping the PAIR (now val_h+GAP+ref_h tall)
                    # centred on the body as a whole.  Re-derived from
                    # the same centering requirement as the GAP=0 case:
                    # val's centre = ccy - (ref_h+GAP)/2,
                    # ref's centre = ccy + (val_h+GAP)/2 (setting GAP=0
                    # recovers the original formula exactly).
                    val_y = ccy_rel - (ref_h + STACK_GAP) / 2.0
                    ref_y = ccy_rel + (val_h + STACK_GAP) / 2.0
                    val_it['text'] = val_wrapped
                    ref_it['text'] = ref_wrapped
                    val_it['candidates'] = [
                        (ccx_rel, val_y, 'center', val_fs, True)]
                    ref_it['candidates'] = [
                        (ccx_rel, ref_y, 'center', ref_fs, True)]
                    return
            # Case 1: value fits -> value inside, ref outside (wrapped
            # square).
            if val_it and fits(val_txt, val_fs, True):
                strip_interior(ref_it)
                if ref_it:
                    ref_it['text'] = _wrap_to_square(
                        ref_txt, ref_fs, True, bhw * 2, bhh * 2)
                    # Center the interior value and the ref below it as a pair:
                    # shift the value up and pull the ref's below-body candidate
                    # in.
                    ccx_rel, ccy_rel = self.kicad_rel(ccx, ccy)
                    GAP = 3.0
                    ref_y = bcy_rel + bhh + GAP
                    # The value stays centered: an upward shift assumed the ref
                    # would land just below the body, which it often does not.
                    val_y = ccy_rel
                    if val_it:
                        val_it['candidates'] = (
                            [(ccx_rel, val_y, 'center', val_fs, True)]
                            + val_it['candidates'])
                    ref_it['candidates'] = (
                        [(bcx_rel, ref_y, 'n', ref_fs, False)]
                        + ref_it['candidates'])
                return
            # Case 2: value doesn't fit but ref does -> ref inside, value
            # outside (wrapped square).
            if ref_it and fits(ref_txt, ref_fs, True):
                strip_interior(val_it)
                if val_it:
                    val_it['text'] = _wrap_to_square(
                        val_txt, val_fs, False, bhw * 2, bhh * 2)
                return
        # No interior spot: stack ref and value together outside the body, on
        # the SAME side.  A vertical body puts them to the right (as a
        # horizontal body puts them below) so a long ref cannot span both sides.
        if ref_it and val_it:
            strip_interior(ref_it)
            strip_interior(val_it)
            STACK_GAP = 1.5
            if _is_vertical:
                val_wrapped = _wrap_to_square_side(val_txt, val_fs, False,
                                                   bhw * 2, bhh * 2)
                val_w, val_h = _measure_text(val_wrapped, val_fs, False)
                ref_wrapped = _wrap_ref_designator(
                    ref_txt, ref_fs, True, max(val_w, bhw * 2))
                ref_w, ref_h = _measure_text(ref_wrapped, ref_fs, True)
                ref_it['text'] = ref_wrapped
                val_it['text'] = val_wrapped
                side_x = bcx_rel + bhw + VPAD
                val_stack = (side_x, bcy_rel - (ref_h + STACK_GAP) / 2.0,
                            'w', val_fs, False)
                ref_stack = (side_x, bcy_rel + (val_h + STACK_GAP) / 2.0,
                            'w', ref_fs, False)
            else:
                val_wrapped = _wrap_to_square(val_txt, val_fs, False,
                                              bhw * 2, bhh * 2)
                val_w, val_h = _measure_text(val_wrapped, val_fs, False)
                ref_wrapped = _wrap_to_square(
                    ref_txt, ref_fs, True, max(bhw * 2, val_w),
                    bhh * 2 + val_h)
                ref_w, ref_h = _measure_text(ref_wrapped, ref_fs, True)
                ref_it['text'] = ref_wrapped
                val_it['text'] = val_wrapped
                val_stack = (bcx_rel, bcy_rel + bhh + VPAD, 'n', val_fs, False)
                ref_stack = (bcx_rel, bcy_rel + bhh + VPAD + val_h + STACK_GAP,
                            'n', ref_fs, False)
            ref_it['candidates'] = [ref_stack] + ref_it['candidates']
            val_it['candidates'] = [val_stack] + val_it['candidates']
            return
        strip_interior(ref_it)
        strip_interior(val_it)


    def place_texts(self, placed_qt):
        """
        For each text item, try candidates in order, pick the first that
        doesn't overlap anything in placed_qt or local obstacles.
        Updates each text_item['placed'] to
        (rx, ry, anchor, font_size, is_interior).
        Then recomputes composite_rel.
        """
        # Two separate obstacle lists:
        #   stub_placed  — thin pin-stub bboxes. Must not be crossed by any
        #   label.
        #                  No clearance margin (stubs are already narrow).
        #   label_placed — previously-placed text label bboxes. Labels must stay
        #                  at least _INTER_LABEL_GAP pixels clear of each other
        #                  so that adjacent labels don't visually touch.
        stub_placed  = _instance_stub_boxes(self, relative=True)
        label_placed = []
        _INTER_LABEL_GAP = 8.5   # px ≈ one char width at 12pt

        # Coordinate REF and VALUE when both want the body interior; the
        # decision tree below picks one or stacks both.
        self._resolve_interior_ref_value()

        for item in self.text_items:
            candidates = item['candidates']
            # if a value item has an outward-side hint (set
            # by _layout_diff_pair AFTER build() froze the candidates),
            # re-order so the hinted side is tried first.  'left' prefers
            # the 'e' anchor (text extends left), 'right' the 'w' anchor.
            prefer = getattr(self, '_value_text_prefer', None)
            if item['kind'] == 'value' and prefer in ('left', 'right'):
                want = 'e' if prefer == 'left' else 'w'
                candidates = sorted(
                    candidates,
                    key=lambda c: 0 if (len(c) >= 3 and c[2] == want) else 1)
            # vertical hint: a parallel-stack
            # BOTTOM member prefers its value BELOW ('s'), a TOP member ABOVE
            # ('n'), so a tall equation label drops into open space clear of
            # the sibling row / series neighbour (gid 170: G1 below clears R73).
            elif item['kind'] == 'value' and prefer in ('down', 'up'):
                want = 's' if prefer == 'down' else 'n'
                candidates = sorted(
                    candidates,
                    key=lambda c: 0 if (len(c) >= 3 and c[2] == want) else 1)
            chosen = None
            for cand in candidates:
                if len(cand) == 3:
                    rx, ry, anchor = cand
                    fs = item['font_size']
                    is_interior = False
                elif len(cand) == 5:
                    rx, ry, anchor, fs, is_interior = cand
                else:
                    continue
                bb_rel = _text_bbox_from_anchor(
                    rx, ry, item['text'], anchor, fs)
                bb_abs = _translate_bb(bb_rel, self.ox_px, self.oy_px)
                # Check against global placed items AND local items
                global_hits = (placed_qt.query_overlaps(bb_abs)
                               if not is_interior else [])
                # Check a candidate against stubs (exact box, must not cross)
                # and labels (with _INTER_LABEL_GAP margin).
                stub_hit = any(_overlaps(bb_rel, sp) for sp in stub_placed)
                is_pair = {'value', 'ref'}
                label_hit = False
                for lp, lkind in label_placed:
                    gap = (1.0 if {item['kind'], lkind} <= is_pair
                          else _INTER_LABEL_GAP)
                    padded = (bb_rel[0] - gap, bb_rel[1] - gap,
                             bb_rel[2] + gap, bb_rel[3] + gap)
                    if _overlaps(padded, lp):
                        label_hit = True
                        break
                local_hit = stub_hit or label_hit
                if not global_hits and not local_hit:
                    chosen = (rx, ry, anchor, fs, bb_rel, is_interior)
                    break
            if chosen is None:
                # All fixed candidates overlapped something.
                # For net labels on vertical stubs: scan outward horizontally
                # in both directions until a clear spot is found or we give up.
                # This handles wide labels (like CLAW_CLAMP on G12).
                if item['kind'] == 'net' and len(candidates) > 0:
                    cand0 = candidates[0]
                    if len(cand0) == 3:
                        base_rx, base_ry, _base_anchor = cand0
                        fs = item['font_size']; is_interior = False
                    else:
                        base_rx, base_ry, _base_anchor, fs, is_interior = cand0
                    tW_s, _ = _measure_text(item['text'], fs)
                    # Try up to 8 additional shifts of one text-width each,
                    # alternating left and right of the preferred position.
                    # Get the compound anchors from cand0 (left) and cand1
                    # (right)
                    anch_left  = (candidates[0][2]
                                  if len(candidates[0]) == 3
                                  else candidates[0][2])
                    anch_right = (candidates[1][2]
                                  if len(candidates) > 1
                                  and len(candidates[1]) == 3
                                  else 'w')
                    for step in range(1, 9):
                        for sign in (-1, 1):
                            shift = sign * step * (tW_s + 4)
                            rx_try = base_rx + shift
                            ry_try = base_ry
                            anch_try = anch_left if sign < 0 else anch_right
                            bb_rel = _text_bbox_from_anchor(
                                rx_try, ry_try, item['text'], anch_try, fs)
                            bb_abs = _translate_bb(
                                bb_rel, self.ox_px, self.oy_px)
                            g_hits    = placed_qt.query_overlaps(bb_abs)
                            stub_hit2 = any(_overlaps(bb_rel, sp)
                                            for sp in stub_placed)
                            padded2   = (bb_rel[0]-_INTER_LABEL_GAP,
                                         bb_rel[1]-_INTER_LABEL_GAP,
                                         bb_rel[2]+_INTER_LABEL_GAP,
                                         bb_rel[3]+_INTER_LABEL_GAP)
                            l_hit     = stub_hit2 or any(
                                _overlaps(padded2, lp[0])
                                for lp in label_placed)
                            if not g_hits and not l_hit:
                                chosen = (rx_try, ry_try, anch_try, fs,
                                          bb_rel, is_interior)
                                break
                        if chosen is not None:
                            break

                if chosen is None:
                    # Ultimate fallback: use first candidate regardless
                    cand = candidates[0]
                    if len(cand) == 3:
                        rx, ry, anchor = cand
                        fs = item['font_size']
                        is_interior = False
                    else:
                        rx, ry, anchor, fs, is_interior = cand
                    bb_rel = _text_bbox_from_anchor(
                        rx, ry, item['text'], anchor, fs)
                    chosen = (rx, ry, anchor, fs, bb_rel, is_interior)
            rx, ry, anchor, fs, bb_rel, is_interior = chosen
            item['placed'] = (rx, ry, anchor, fs, is_interior)
            if not is_interior:
                # label_placed, not stub_placed.
                label_placed.append((bb_rel, item['kind']))

        # Recompute composite_rel
        self._recompute_composite_rel()

    def _recompute_composite_rel(self):
        """Factored out of place_texts's tail so
        ANY code that moves a label after place_texts has already run
        (e.g. _reresolve_value_texts) can keep composite_rel in sync.
        Found a real bug this way: _reresolve_value_texts reassigns
        item['placed'] to a DIFFERENT candidate to dodge a neighbour, but
        never touched composite_rel — so the BBoxes overlay (and anything
        else reading abs_composite()) could show a STALE box that no
        longer contains the label's actual, post-reresolve position.
        User caught this visually (HLIM's value text partly outside its
        drawn bbox)."""
        all_rel = [self.sym_body_rel]
        for item in self.text_items:
            if item['placed'] is None:
                continue
            rx, ry, anchor, fs, is_interior = item['placed']
            if is_interior:
                continue
            all_rel.append(_text_bbox_from_anchor(
                rx, ry, item['text'], anchor, fs,
                bold=(item['kind'] == 'ref')))
        self.composite_rel = _union_bbox(all_rel)



# ══════════════════════════════════════════════════════════════════════════════
#  §5c  Scanline overlap checker  (IC-design sweep-line algorithm)
# ══════════════════════════════════════════════════════════════════════════════

def scanline_overlaps(bbox_list):
    """Return every overlapping pair among a list of boxes, using a y-sweep with
    an active list (the IC-design scan-line algorithm).
    """
    # Build events: (y_coord, event_type, bbox, label)
    # event_type: 0=OPEN, 1=CLOSE  (OPEN sorts before CLOSE at same y)
    OPEN, CLOSE = 0, 1
    events = []
    for (x0, y0, x1, y1, lbl) in bbox_list:
        events.append((y0, OPEN,  (x0,y0,x1,y1), lbl))
        events.append((y1, CLOSE, (x0,y0,x1,y1), lbl))
    events.sort(key=lambda e: (e[0], e[1]))

    active = []   # list of (bbox, label) currently open
    overlaps = []

    for _y, etype, bb, lbl in events:
        x0,y0,x1,y1 = bb
        if etype == OPEN:
            # Check against all active bboxes
            for (abb, albl) in active:
                ax0,ay0,ax1,ay1 = abb
                # y-overlap: guaranteed (both are open at this y)
                # x-overlap:
                if x0 < ax1 and x1 > ax0:
                    overlaps.append(((lbl, bb), (albl, abb)))
            active.append((bb, lbl))
        else:  # CLOSE
            # Remove this bbox from active set
            active = [(abb, albl) for (abb, albl) in active
                      if not (abb == bb and albl == lbl)]

    return overlaps


# 1/20th of a character width at 12pt — thin enough to not false-fire on
# labels that merely approach a stub, wide enough to catch real overlaps.
_STUB_HALF_WIDTH = 7.5 / 20   # ≈ 0.375 px


def _instance_stub_boxes(inst, relative=False):
    """The per-pin stub hairline bboxes for one
    instance, as plain (x0,y0,x1,y1) tuples (no label).  Factored out of
    instance_bboxes (which additionally labels each one for the -v
    verify harness's reporting) so this same, single computation can be
    reused everywhere a stub obstacle list is needed — place_texts (its
    own pins only, in its own RELATIVE frame — pass relative=True) and
    _reresolve_value_texts (every OTHER instance's pins too, added this
    rev, in ABSOLUTE canvas coordinates — the default) previously each
    had their own inline copy of this same math; keeping one copy avoids
    the two silently drifting apart."""
    ox, oy = (0.0, 0.0) if relative else (inst.ox_px, inst.oy_px)
    ss = inst.sym_scale
    mkx, mky = inst.mid_kx, inst.mid_ky
    boxes = []
    for _pnum, (ax, ay, angle_deg, alen) in inst.sym_entry.get(
            'pins', {}).items():
        ar = math.radians(angle_deg)
        sdx = math.cos(ar)
        sdy = math.sin(ar)
        arx = (ax - mkx) * ss
        ary = -(ay - mky) * ss
        full_px = alen * ss
        p75x = arx + 0.75 * full_px * sdx
        p75y = ary - 0.75 * full_px * sdy
        p100x = arx + 1.0 * full_px * sdx
        p100y = ary - 1.0 * full_px * sdy
        hw = _STUB_HALF_WIDTH
        sw = hw + abs(sdx) * hw
        sh = hw + abs(sdy) * hw
        boxes.append((min(p75x, p100x) - sw + ox, min(p75y, p100y) - sh + oy,
                     max(p75x, p100x) + sw + ox, max(p75y, p100y) + sh + oy))
    return boxes


def instance_bboxes(inst):
    """
    Return all labelled bboxes for a CompInstance in absolute canvas
    coordinates, for use with scanline_overlaps().

    Yields (x0, y0, x1, y1, label_str) tuples:
      • One thin bbox per pin stub (width = 1/20 char ≈ 0.375 px).
        This catches net names that overlap the dashed stub line.
      • One bbox per placed text label.
    Body bbox omitted (body-vs-label handled by place_texts).
    """
    ref  = inst.comp['ref']
    ox   = inst.ox_px
    oy   = inst.oy_px

    # ── Stub bboxes ───────────────────────────────────────────────────────────
    for pnum, sb in zip(inst.sym_entry.get('pins', {}),
                        _instance_stub_boxes(inst)):
        yield (sb[0], sb[1], sb[2], sb[3], f"{ref}:stub:{pnum}")

    # ── Text labels ───────────────────────────────────────────────────────────
    for ti in inst.text_items:
        if ti['placed'] is None:
            continue
        rx, ry, anchor, fs, is_interior = ti['placed']
        if is_interior:
            continue   # interior labels intentionally overlap the body
        bb_rel = _text_bbox_from_anchor(rx, ry, ti['text'], anchor, fs,
                                        bold=(ti['kind'] == 'ref'))
        label = f"{ref}:{ti['kind']}:{ti['text'][:12]}"
        yield (ox+bb_rel[0], oy+bb_rel[1], ox+bb_rel[2], oy+bb_rel[3], label)


# ── Rotation helpers ─────────────────────────────────────────────────────────

def _rotate_kicad_point(x, y, deg):
    """Rotate a KiCad (x,y) point by deg degrees CCW about (0,0)."""
    if deg == 0:   return  x,  y
    if deg == 90:  return -y,  x
    if deg == 180: return -x, -y
    if deg == 270: return  y, -x
    r = math.radians(deg)
    return x*math.cos(r)-y*math.sin(r), x*math.sin(r)+y*math.cos(r)


def _rotated_sym_entry(sym_entry, deg):
    """
    Return a new sym_entry dict with all shape points and pin positions
    rotated by `deg` degrees CCW.  Does not mutate the original.
    """
    if deg == 0:
        return sym_entry

    def rot_pts(pts):
        return [_rotate_kicad_point(x, y, deg) for x, y in pts]

    new_shapes = []
    for s in sym_entry.get('shapes', []):
        ns = dict(s)
        k = s['kind']
        if k == 'polyline':
            ns['pts'] = rot_pts(s['pts'])
        elif k == 'circle':
            rx, ry = _rotate_kicad_point(s['cx'], s['cy'], deg)
            ns = dict(s, cx=rx, cy=ry)
        elif k == 'arc':
            sx, sy = _rotate_kicad_point(*s['start'], deg)
            mx, my = _rotate_kicad_point(*s['mid'],   deg)
            ex, ey = _rotate_kicad_point(*s['end'],   deg)
            ns = dict(s, start=(sx,sy), mid=(mx,my), end=(ex,ey))
        elif k == 'rectangle':
            x1,y1 = _rotate_kicad_point(s['x1'],s['y1'],deg)
            x2,y2 = _rotate_kicad_point(s['x2'],s['y2'],deg)
            ns = dict(s, x1=x1,y1=y1,x2=x2,y2=y2)
        elif k == 'pin':
            rx, ry = _rotate_kicad_point(s['x'],s['y'],deg)
            ns = dict(s, x=rx, y=ry, angle=(s['angle']+deg)%360)
        elif k == 'text':
            rx, ry = _rotate_kicad_point(s['x'],s['y'],deg)
            ns = dict(s, x=rx, y=ry)
        new_shapes.append(ns)

    new_pins = {}
    for pnum, (ax, ay, adeg, alen) in sym_entry.get('pins', {}).items():
        rx, ry = _rotate_kicad_point(ax, ay, deg)
        new_pins[pnum] = (rx, ry, (adeg+deg)%360, alen)

    return dict(sym_entry, shapes=new_shapes, pins=new_pins)


def _mirrored_sym_entry(sym_entry):
    """Mirror a sym_entry about the VERTICAL axis (x -> -x),
    without mutating the original.  Pins keep their names/nets (so net
    connectivity is unchanged); only geometry flips, and a pin's angle
    reflects (0<->180).  Applied after any rotation."""
    def refl(a):
        return (180 - a) % 360
    new_shapes = []
    for s in sym_entry.get('shapes', []):
        k = s['kind']
        if k == 'polyline':
            ns = dict(s, pts=[(-x, y) for (x, y) in s['pts']])
        elif k == 'circle':
            ns = dict(s, cx=-s['cx'], cy=s['cy'])
        elif k == 'arc':
            ns = dict(s, start=(-s['start'][0], s['start'][1]),
                      mid=(-s['mid'][0], s['mid'][1]),
                      end=(-s['end'][0], s['end'][1]))
        elif k == 'rectangle':
            ns = dict(s, x1=-s['x1'], y1=s['y1'], x2=-s['x2'], y2=s['y2'])
        elif k == 'pin':
            ns = dict(s, x=-s['x'], y=s['y'], angle=refl(s['angle']))
        elif k == 'text':
            ns = dict(s, x=-s['x'], y=s['y'])
        else:
            ns = dict(s)
        new_shapes.append(ns)
    new_pins = {}
    for pnum, (ax, ay, adeg, alen) in sym_entry.get('pins', {}).items():
        new_pins[pnum] = (-ax, ay, refl(adeg), alen)
    return dict(sym_entry, shapes=new_shapes, pins=new_pins)


_NET_SORT_FIELD_RE = re.compile(r'\d+|\D+')


def _net_sort_fields(name):
    """Split a net name into alternating runs of digits and non-digits,
    e.g. 'busPin[48]p22inv' -> ['busPin[', '48', ']p', '22', 'inv'].
    Used by _net_name_compare."""
    return _NET_SORT_FIELD_RE.findall(str(name))


def _net_name_compare(a, b):
    """Natural compare for net names: split each
    name into runs of digits (\\d+) and runs of everything else, then
    compare field by field, left to right.  A digit-run vs a digit-run
    compares NUMERICALLY (so '9' < '10'); any other pairing (text vs
    text, or a digit-run vs a non-digit-run) compares as plain strings.
    Shorter field list sorts first if every shared field tied.  Returns
    -1/0/1 for use with functools.cmp_to_key — this can't be a simple
    per-position sort-key tuple because a digit-run at one name's
    position N may face a non-digit-run at the other name's position N,
    and which comparison rule applies depends on BOTH sides at once."""
    fa, fb = _net_sort_fields(a), _net_sort_fields(b)
    for ta, tb in zip(fa, fb):
        if ta == tb:
            continue
        if ta.isdigit() and tb.isdigit():
            va, vb = int(ta), int(tb)
            if va != vb:
                return -1 if va < vb else 1
            continue          # e.g. '007' vs '7' — numerically equal
        return -1 if ta < tb else 1
    if len(fa) != len(fb):
        return -1 if len(fa) < len(fb) else 1
    return 0


def _rect_closest_point(bb, px, py):
    """Point ON (or in) axis-aligned box
    bb=(x0,y0,x1,y1) closest to external point (px,py): clamp px/py into
    the box's x/y ranges.  For a point outside the box this always lands
    exactly on the boundary — on an edge if the point is outside on only
    one axis, or exactly on a CORNER if outside on both (e.g. a point up
    and to the right of the box clamps to the top-right corner) — matching
    the user's own description of the desired behaviour ("either the
    rightmost point of the body or on the diagonal... between the
    rightmost and topmost point")."""
    x0, y0, x1, y1 = bb
    return min(max(px, x0), x1), min(max(py, y0), y1)




def _seg_seg_min_dist(p1, p2, p3, p4):
    """Minimum distance between segments (p1,p2)
    and (p3,p4).  Used to SCORE candidate rotations that already fixed a
    self-crossing (_uncross_final): among several rotations that all
    stop the two flight lines crossing, prefer whichever leaves them
    most clearly apart, not just barely-not-touching.  0 if they
    actually touch/cross (shouldn't happen here — callers only score
    already-non-crossing candidates, but stay correct regardless)."""
    def pt_seg_dist(p, a, b):
        ax, ay = a
        bx, by = b
        px, py = p
        dx, dy = bx - ax, by - ay
        L2 = dx * dx + dy * dy
        if L2 < 1e-12:
            hx, hy = ax, ay
        else:
            t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / L2))
            hx, hy = ax + t * dx, ay + t * dy
        return math.hypot(px - hx, py - hy)

    if _segments_intersect(p1, p2, p3, p4):
        return 0.0
    return min(pt_seg_dist(p1, p3, p4), pt_seg_dist(p2, p3, p4),
               pt_seg_dist(p3, p1, p2), pt_seg_dist(p4, p1, p2))


def _segments_intersect(p1, p2, p3, p4):
    """True if open segments (p1,p2) and (p3,p4)
    properly cross.  Shared endpoints do NOT count as a crossing
    (flight lines that meet at a common pin are fine).  Used by the
    Kind-1 rotation crossing reducer."""
    def ccw(a, b, c):
        return ((c[1] - a[1]) * (b[0] - a[0])
                - (b[1] - a[1]) * (c[0] - a[0]))
    # Shared endpoint → not a crossing.
    if p1 in (p3, p4) or p2 in (p3, p4):
        return False
    d1 = ccw(p3, p4, p1)
    d2 = ccw(p3, p4, p2)
    d3 = ccw(p1, p2, p3)
    d4 = ccw(p1, p2, p4)
    return ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0))


def _seg_xpoint(p1, p2, p3, p4):
    """Intersection point of segments (p1,p2) and (p3,p4),
    or None if parallel.  Used only to dot the crossing for the Self-X
    overlay; callers gate on _segments_intersect first, so the point is
    expected to lie on both segments."""
    x1, y1 = p1
    x2, y2 = p2
    x3, y3 = p3
    x4, y4 = p4
    den = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
    if abs(den) < 1e-9:
        return None
    t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / den
    return (x1 + t * (x2 - x1), y1 + t * (y2 - y1))


def _stable_blocks(d):
    """In : a frozenset-keyed mapping or set.  Out: its keys sorted by
    (size, sorted members) — total for distinct blocks, and free here.
    _sp_block_layout and _sp_rigid_blocks are keyed by frozensets of ref
    STRINGS, and Python randomises string hashing per process, so walking
    them directly gives a different order every run.  That order sets
    each block's index in _place_groups_as_lanes, hence block_of_ref's
    last writer and the frame a merged unit is composed in: the same
    LM324.lib gave 11 or 12 crossings on PYTHONHASHSEED alone, invisible
    to `parity` because one process is self-consistent.  A bare
    `for m in self._sp_block_layout` is a bug."""
    return sorted((d or ()), key=lambda fs: (len(fs), sorted(fs)))


def _stable_block_items(d):
    """items() counterpart of _stable_blocks, same ordering rule."""
    return [(k, (d or {})[k]) for k in _stable_blocks(d)]


def _geometric_median(pts, iters=64, tol=1e-3):
    """In : the points.  Out: the point minimizing the SUM of Euclidean
    distances to them, by Weiszfeld's iteration.
    The degenerate cases are handled so one routine covers every arity
    the callers need: one point returns itself, two return a point on the
    segment, three or more iterate from the centroid.
    This is the "where could it have gone" reference the placement-slack
    metric measures against, so it must be the true optimum — the
    centroid minimizes SQUARED distance and would understate the slack
    for a lopsided fan-out."""
    pts = list(pts)
    if not pts:
        return (0.0, 0.0)
    if len(pts) == 1:
        return pts[0]
    x = sum(p[0] for p in pts) / len(pts)
    y = sum(p[1] for p in pts) / len(pts)
    for _ in range(iters):
        num_x = num_y = den = 0.0
        for px, py in pts:
            d = math.hypot(px - x, py - y)
            if d < tol:
                # sitting on a sample point: it is already a candidate
                # optimum for that term, skip to avoid a divide-by-zero.
                return (px, py)
            num_x += px / d
            num_y += py / d
            den += 1.0 / d
        nx, ny = num_x / den, num_y / den
        if math.hypot(nx - x, ny - y) < tol:
            return (nx, ny)
        x, y = nx, ny
    return (x, y)


def _mst_edges_manhattan(points, groups=None):
    """In : a list of 2D points and, optionally, a group key per point
    (None = ungrouped).  Out: a list of (i, j) index pairs forming their
    minimum spanning tree under Manhattan distance.
    Points sharing a group key are already joined -- by a user wire --
    so they enter the tree together and no edge is emitted between them;
    only the hops that still join separate groups come back.
    Prim on the complete graph: O(N^2) time, but an N-pin net then draws
    N-1 flight lines instead of N(N-1)/2, and the edge count is what
    costs canvas items."""
    n = len(points)
    if n < 2:
        return []
    if groups is not None and any(g is not None for g in groups):
        return _mst_edges_grouped(points, groups)
    # min_dist[k] = current shortest Manhattan distance from any
    # in-tree vertex to vertex k (∞ for vertices not yet reached).
    INF = float('inf')
    min_dist = [INF] * n
    nearest  = [0]   * n         # which in-tree vertex achieves min_dist[k]
    in_tree  = [False] * n
    in_tree[0] = True
    px0, py0 = points[0]
    for k in range(1, n):
        pkx, pky = points[k]
        min_dist[k] = abs(pkx - px0) + abs(pky - py0)
    edges = []
    for _ in range(n - 1):
        # Find non-tree vertex with smallest min_dist.
        best_k = -1
        best_d = INF
        for k in range(n):
            if not in_tree[k] and min_dist[k] < best_d:
                best_d = min_dist[k]
                best_k = k
        if best_k < 0:
            break
        edges.append((nearest[best_k], best_k))
        in_tree[best_k] = True
        # Update min_dist for the remaining non-tree vertices.
        bx, by = points[best_k]
        for k in range(n):
            if in_tree[k]:
                continue
            kx, ky = points[k]
            d = abs(kx - bx) + abs(ky - by)
            if d < min_dist[k]:
                min_dist[k] = d
                nearest[k]  = best_k
    return edges


def _mst_edges_grouped(points, groups):
    """In : points and a group key per point.  Out: the MST edges (i, j)
    between groups, as _mst_edges_manhattan.  A vertex joins the tree
    with every other vertex of its group at zero cost, and no edge is
    emitted for that join."""
    n = len(points)
    members = defaultdict(list)
    for k, g in enumerate(groups):
        members[g if g is not None else ('_solo', k)].append(k)
    key = [g if g is not None else ('_solo', k) for k, g in enumerate(groups)]
    INF = float('inf')
    min_dist = [INF] * n
    nearest = [0] * n
    in_tree = [False] * n
    edges = []

    def admit(v):
        for m in members[key[v]]:
            in_tree[m] = True
        for m in members[key[v]]:
            mx, my = points[m]
            for k in range(n):
                if in_tree[k]:
                    continue
                d = abs(points[k][0] - mx) + abs(points[k][1] - my)
                if d < min_dist[k]:
                    min_dist[k] = d
                    nearest[k] = m
    admit(0)
    while True:
        best_k, best_d = -1, INF
        for k in range(n):
            if not in_tree[k] and min_dist[k] < best_d:
                best_k, best_d = k, min_dist[k]
        if best_k < 0:
            break
        edges.append((nearest[best_k], best_k))
        admit(best_k)
    return edges


def _build_pin_flight_data(instances, canvas_w=1600, canvas_h=900):
    """In : instances, plus the canvas size used to anchor power rails.
    Out: (net_to_pins, inst_to_pairs) for the pin-to-pin flight lines.
    A signal net's members are (inst, pin_num) pairs; a power net also
    gets a sentinel (None, anchor_xy) at the rail position, so flight
    lines and rotation scoring include power connections.
    Reads inst._pin_net_pairs, cached after the R/C/L orientation flip in
    CompInstance.build, and falls back to resolve_pin_nets only when that
    is missing: the fallback re-derives the PRE-flip pairing and would
    draw a resistor's line from the wrong physical pin."""
    net_to_pins = {}   # nl → [(inst_or_None, pnum_or_xy)]
    inst_to_pairs = {} # id(inst) → [(pnum, net_lower)]
    for inst in instances:
        pairs = getattr(inst, '_pin_net_pairs', None)
        if not pairs:
            se = inst.sym_entry
            pairs = resolve_pin_nets(inst.comp, se)
            if not pairs:
                pins = se.get('pins', {})
                sp = sorted(pins, key=lambda k: int(k) if k.isdigit() else 0)
                pairs = list(zip(sp, inst.comp['nets']))
        my_pairs = []
        for pnum, net in pairs:
            nl = net.lower()
            anchor = _power_anchor_pos(nl, canvas_w, canvas_h)
            if anchor is not None:
                # Power/GND net: register this pin as a member.
                net_to_pins.setdefault(nl, []).append((inst, pnum))
                my_pairs.append((pnum, nl))
                # Add the rail anchor exactly once per net — check if any
                # existing entry on this net is already a sentinel (inst=None).
                if not any(m is None for m, _ in net_to_pins[nl]):
                    net_to_pins[nl].append((None, anchor))
            elif nl not in _PWR_NETS_LC_FOR_T:
                # Keep MID-like nets (MID, R_NOISELESS) in net_to_pins: only
                # real power nets have a power anchor.
                net_to_pins.setdefault(nl, []).append((inst, pnum))
                my_pairs.append((pnum, nl))
        inst_to_pairs[id(inst)] = my_pairs
    return net_to_pins, inst_to_pairs


def _point_seg_dist(pt, a, b):
    """Shortest distance from point `pt` to the segment a-b.

    Clamped to the segment (not the infinite line), so a pin sitting
    off the END of a short line is correctly far away rather than
    counted against its extension."""
    px, py = pt
    x1, y1 = a
    x2, y2 = b
    dx, dy = x2 - x1, y2 - y1
    den = dx * dx + dy * dy
    if den <= 0.0:
        return ((px - x1) ** 2 + (py - y1) ** 2) ** 0.5
    t = ((px - x1) * dx + (py - y1) * dy) / den
    t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t)
    cx, cy = x1 + t * dx, y1 + t * dy
    return ((px - cx) ** 2 + (py - cy) ** 2) ** 0.5


def _pin_canvas_pos(inst, pin_num_or_anchor):
    """
    Canvas (x,y) of a pin anchor.
    inst=None means pin_num_or_anchor is already an (x,y) canvas position
    (used for power rail sentinels).
    """
    if inst is None:
        return pin_num_or_anchor   # sentinel: anchor is the position
    pins = inst.sym_entry.get('pins', {})
    geom = pins.get(str(pin_num_or_anchor))
    if geom is None:
        return inst.ox_px, inst.oy_px
    ax, ay, _adeg, _alen = geom
    rx, ry = inst.kicad_rel(ax, ay)
    return inst.ox_px + rx, inst.oy_px + ry


# ══════════════════════════════════════════════════════════════════════════════
#  §5e  instLine placement engine (rev 32 — implements §9a–§9c exactly)
# ══════════════════════════════════════════════════════════════════════════════

# Power/ground net names — excluded from SIGNAL force calculations but
# replaced with virtual anchor points (see _power_anchor_pos).
# Removed the circuit-specific hard-coded net names
# 'mid' and 'r_noiseless'.  High-fanout internal nets are now handled
# generically by fanout-based rail promotion (_detect_promoted_rails)
# and by the user-configurable port-net selection, not by hard-coding
# particular net names from one model.
_PWR_NETS = GND_NETS | VCC_NETS | frozenset({
    'vee', 'avss', 'avdd', 'avcc',
})

# Canvas y-fraction for power rail anchors (as fraction of canvas height)
_GND_Y_FRAC  = 0.92   # GND/VSS near bottom
_VCC_Y_FRAC  = 0.08   # VCC/VDD near top
_GND_NETS_LC = frozenset({'0','gnd','vss','vee','agnd','dgnd','sgnd','pgnd',
                           'gnda','gndd','gndpwr','avss'})
_VCC_NETS_LC = frozenset({'vcc','vdd','vpp','v+','avcc','dvcc','vccio',
                           'vbat','vpwr','pwr','avdd'})

# Power/ground net set used by per-pin T-symbol routing.
# A pin whose net name is in this set gets its OWN T-symbol placed close
# to the pin, rather than sharing one cluster-level T with sibling pins.
# Includes "0" (SPICE ground) plus the conventional ground and power
# spellings.  SUBCKT IO ports (INPUT, OUTPUT, SENSE, etc.) are NOT in
# this set — they continue to use cluster-level Ts.
_PWR_NETS_LC_FOR_T = frozenset({'0'} | _GND_NETS_LC | _VCC_NETS_LC)



def _pin_outward_direction(inst, pin_num):
    """In : an instance and a pin number.  Out: a canvas-space (dx, dy)
    pointing FROM the body centre TOWARDS the pin anchor, or (0, 1) —
    below the body — when the geometry is unavailable.
    Decides which side of a pin a per-pin power or ground T-symbol sits
    on: the same side the pin extends to.  The vector is NOT unit length,
    so callers read the sign of dx and dy, or |dx| against |dy|, to pick
    a dominant axis."""
    pin_xy = _pin_canvas_pos(inst, pin_num)
    bb = getattr(inst, 'sym_body_rel', None)
    if bb is None:
        return (0.0, 1.0)
    bx_rel = (bb[0] + bb[2]) / 2
    by_rel = (bb[1] + bb[3]) / 2
    body_cx = inst.ox_px + bx_rel
    body_cy = inst.oy_px + by_rel
    return (pin_xy[0] - body_cx, pin_xy[1] - body_cy)


def _io_port_t_side_pos(nl, rot, pin_xy, input_nets_lc, output_nets_lc,
                        t_pin_dist):
    """For a top-level .SUBCKT input or output net, return the T position forced
    to the pin's left (input, rot 270) or right (output, rot 90); None
    otherwise.
    """
    if nl in input_nets_lc and rot == 270:
        return pin_xy[0] - t_pin_dist, pin_xy[1]
    if nl in output_nets_lc and rot == 90:
        return pin_xy[0] + t_pin_dist, pin_xy[1]
    return None


# The side of its pin a T sits on, as a unit (ux, uy), for each rotation.
# A T's rotation is its ROLE and is fixed per net; the side follows from
# it, so the stem always points back at the pin: input (270) left, output
# (90) right, ground/-power (0) below, +power (180) above.
_T_SIDE_FOR_ROT = {90: (1.0, 0.0), 270: (-1.0, 0.0),
                   0: (0.0, 1.0), 180: (0.0, -1.0)}


def _seg_enters_box(p, q, box, pad=1.0):
    """In : the endpoints p and q, an axis-aligned box and a pad.
    Out: True when the straight segment passes through the box INTERIOR.
    `pad` shrinks the box first, so a segment merely running along an
    edge — what a pin sitting exactly on its own body outline does — is
    not counted as entering.  Liang-Barsky clipping, so it is exact
    rather than sampled and costs the same at any segment length.
    The geometric half of "a pin's T belongs on the side the PIN FACES,
    or above or below it, never back across its own body":
    _t_escape_dir asks this once per candidate side and takes the first
    that answers False."""
    x0, y0, x1, y1 = box
    x0 += pad; y0 += pad; x1 -= pad; y1 -= pad
    if x1 <= x0 or y1 <= y0:
        return False
    dx = q[0] - p[0]; dy = q[1] - p[1]
    t0 = 0.0; t1 = 1.0
    for num, den in ((-dx, p[0] - x0), (dx, x1 - p[0]),
                     (-dy, p[1] - y0), (dy, y1 - p[1])):
        if num == 0:
            if den < 0:
                return False
            continue
        r = den / num
        if num < 0:
            if r > t1:
                return False
            t0 = max(t0, r)
        else:
            if r < t0:
                return False
            t1 = min(t1, r)
    return t0 < t1


def _power_anchor_pos(net_lower, canvas_w, canvas_h):
    """
    Return the fixed canvas (x, y) representing a power rail anchor,
    or None if the net is not a power/ground net.
    The x is the centre of the canvas; the rail runs the full width.
    """
    if net_lower in _GND_NETS_LC:
        return canvas_w / 2, canvas_h * _GND_Y_FRAC
    if net_lower in _VCC_NETS_LC:
        return canvas_w / 2, canvas_h * _VCC_Y_FRAC
    return None



def _build_netlist_graph(instances):
    """
    Build adjacency structures needed for force-directed placement.

    Returns
    -------
    net_members : dict  net_name → [inst, ...]   (signal nets only)
    inst_nets   : dict  inst     → [net_name, ...]
    subckt_ports: dict  port_name → inst  (ports that match exactly one inst)
    """
    net_members = {}   # net → [inst]
    inst_nets   = {}   # inst → [net]

    for inst in instances:
        comp = inst.comp
        nets_seen = []
        for net in comp['nets']:
            nl = net.lower()
            if nl in _PWR_NETS:
                continue
            nets_seen.append(nl)
            net_members.setdefault(nl, []).append(inst)
        inst_nets[id(inst)] = nets_seen

    return net_members, inst_nets


# Geometry constants: the pre-Place grid spaces instance centers one
# instance box plus a 2-symbol gap apart.
_INST_W           = 80                        # nominal instance bbox W (px)
_2SYM_SPACE       = 60                        # "2-symbol-space" gap   (px)
_GRID_PITCH       = _INST_W + _2SYM_SPACE     # centre-to-centre grid  (px)



# ══════════════════════════════════════════════════════════════════════════════
#  §6  Schematic-canvas renderer
# ══════════════════════════════════════════════════════════════════════════════

# Colours
C_BG        = '#ffffff'
C_OUTLINE   = '#1a3a6a'
C_FILL      = '#ddeeff'
C_PIN       = '#888888'
C_REF       = '#aa3300'
C_NET       = '#007700'
# A user-forced feedback edge (double right-click) is left out of Sugiyama's
# layering and drawn in C_FEEDBACK.
C_FEEDBACK  = '#cc0000'

# single font family for BOTH text measurement
# (_measure_text) and all canvas drawing, so the bounding box used during
# placement matches what is actually drawn.  FONT_FAMILY was not installed
# in every environment and tk substituted different fonts with different
# metrics (TeX Gyre Heros here vs the user's resolved Helvetica), which made
# placement diverge between the SVG export and the live tkinter screen
# (different cluster order / mirrored label sides).  DejaVu Sans is present
# in both, so measurement and drawing now agree everywhere.
FONT_FAMILY = 'DejaVu Sans'
# the schematic's font SIZES are in points; Tk converts points
# to pixels using the display's DPI, so the SAME label is a different pixel
# width on a 96-DPI vs a HiDPI/4K screen while the symbol geometry stays in
# fixed pixels — making layouts (and overlap counts) differ machine-to-machine.
# Pin Tk's point->pixel scaling to this fixed reference DPI in __init__ so the
# schematic renders identically on every machine (96 = the common desktop DPI
# the geometry was tuned at; verified headless at ~96-100 -> 0 overlaps).
_SCHEM_REF_DPI = 96.0

# Layout constants (mm — same coordinate space as KiCad shapes).
# Rev 36 cut the cell size and gap to roughly 1/3 of their rev-35
# values so the initial placement is much tighter; the 0.82 scale-down
# inside the cell keeps symbol bodies the same size as before, the
# saved area comes from the surrounding whitespace.
CELL_W_MM   = 10.0    # was 14.0
CELL_H_MM   = 10.0    # was 14.0
COLS        = 10      # components per row
GAP_MM      = 0.7     # was 2.0  (≈ 1/3)


# ══════════════════════════════════════════════════════════════════════════════
#  §7  Main application window
# ══════════════════════════════════════════════════════════════════════════════

# Stock KiCad symbol libraries, loaded and merged at startup: Device supplies
# R/C/L; Simulation_SPICE supplies sources and active devices.
STD_SYM_LIBS = [
    'Device.kicad_sym',
    'Simulation_SPICE.kicad_sym',
]


def _windows_kicad_symbol_dirs():
    """Best-effort discovery of a Windows KiCad
    install's symbol directory, e.g.
    C:\\Program Files\\KiCad\\9.0\\share\\kicad\\symbols.  KiCad's Windows
    installer versions the path by release (9.0, 8.0, ...), so this globs
    under %ProgramFiles% / %ProgramFiles(x86)% rather than hardcoding one
    version.  Harmless and returns [] on Linux/macOS, or on Windows with
    no KiCad install found there (the '.' / project-directory fallback in
    STD_SYM_DIRS still covers that user — see the rev note above)."""
    found = []
    for envvar in ('ProgramFiles', 'ProgramFiles(x86)'):
        base = os.environ.get(envvar)
        if not base:
            continue
        found.extend(str(p) for p in
                     Path(base).glob('KiCad/*/share/kicad/symbols'))
    return found


def _env_symbol_dirs():
    """In : the process environment.  Out: the directories named by
    SP2SCH_SYMBOL_DIR, highest priority first; [] when it is unset.
    This script's own override: an os.pathsep-separated list, ranked
    above '.' so a stray local Device.kicad_sym cannot beat it.  KiCad's
    own KICAD<n>_SYMBOL_DIR variables are _kicad_env_symbol_dirs'."""
    out = []
    raw = os.environ.get('SP2SCH_SYMBOL_DIR')
    if raw:
        out.extend(d for d in raw.split(os.pathsep) if d.strip())
    return out


def _kicad_env_symbol_dirs():
    """KiCad's own KICAD<n>_SYMBOL_DIR variables, newest release first."""
    out = []
    for var in ('KICAD10_SYMBOL_DIR', 'KICAD9_SYMBOL_DIR',
                'KICAD8_SYMBOL_DIR'):
        d = os.environ.get(var)
        if d and d.strip():
            out.append(d)
    return out


# '.' moved to FIRST (was last): see the rev note on
# STD_SYM_LIBS above.  Windows KiCad paths are discovered, not hardcoded
# (_windows_kicad_symbol_dirs); the two /usr/... entries cover the
# standard Linux install locations.
STD_SYM_DIRS = [
    *_env_symbol_dirs(),        # SP2SCH_SYMBOL_DIR — explicit override
    '.',
    *_kicad_env_symbol_dirs(),  # KICAD<n>_SYMBOL_DIR — KiCad's own
    *_windows_kicad_symbol_dirs(),
    # A KiCad 10 AppImage unpacked into the user's home, which is how
    # 10.0.5 is commonly run on Linux — it installs no system-wide
    # symbols, so without this entry a machine with only the AppImage
    # finds nothing outside '.'.
    str(Path.home() / 'Applications/AppDir/share/kicad/symbols'),
    '/usr/share/kicad/symbols',
    '/usr/local/share/kicad/symbols',
]


def _sym_lib_paths(d, fname):
    """The file(s) to parse for library `fname` in `d`: KiCad 10's split
    .kicad_symdir directory if present, else the classic .kicad_sym file.
    """
    base = Path(d)
    sdir = base / f'{Path(fname).stem}.kicad_symdir'
    if sdir.is_dir():
        paths = sorted(str(q) for q in sdir.glob('*.kicad_sym'))
        if paths:
            return str(sdir), paths
    p = base / fname
    if p.exists():
        return str(p), [str(p)]
    return '', []


def load_merged_symbols(dirs=None, files=None):
    """Load and MERGE the standard KiCad symbol libraries.

    Searches each directory in `dirs` for each filename in `files`,
    loading every one found and merging them into a single
    {name: entry} dict.  Earlier files win on a name clash (Device's
    R_Small_US/C_Small/L_Small take precedence; Simulation_SPICE adds the
    sources/devices).  Returns (merged_dict, [loaded_paths])."""
    dirs = dirs if dirs is not None else STD_SYM_DIRS
    files = files if files is not None else STD_SYM_LIBS
    merged = {}
    loaded = []
    for fname in files:
        for d in dirs:
            label, paths = _sym_lib_paths(d, fname)
            if not paths:
                continue
            got = False
            for q in paths:
                lib = load_kicad_symbols(q)
                if lib:
                    for name, entry in lib.items():
                        merged.setdefault(name, entry)
                    got = True
            if got:
                loaded.append(label)
            break          # first dir that has this library wins
    # the standard libraries have no EVALUE / GVALUE (the
    # 2-pin diamond variants the curated lib added for ngspice behavioral
    # E/G sources in VALUE form).  Alias them to the standard BSOURCE
    # behavioral-source body (same 2-pin ±5.08 layout); all the existing
    # EVALUE/GVALUE handling — value-expression wrapping, BOXED_SYMS inside
    # labels — still keys off the override NAME, so only the drawn body
    # changes (from the custom diamond to the stock behavioral source).
    if 'BSOURCE' in merged:
        merged.setdefault('EVALUE', merged['BSOURCE'])
        merged.setdefault('GVALUE', merged['BSOURCE'])
    return merged, loaded

# The "items with the most RECOVERABLE flight-line length" table is a
# development diagnostic, not something a normal run should print, so it
# is OFF unless -r/--slack asks for it.  Module-level rather than an app
# attribute because both the GUI startup and the -v harness build their
# own SpiceSchem and both must honour the same one switch.
_SHOW_SLACK_REPORT = False

SCALE = 14.0   # pixels per KiCad mm (canvas display scale)


# Font measurement function — initialised in SpiceSchem.__init__ once the
# Tk window exists. Before that, falls back to the static estimate.
# Signature: _measure_text(text, font_size) -> (width_px, height_px)
def _measure_text_estimate(text, font_size, bold=False):
    """Static fallback estimate used before Tk is ready.

    Rev 44: handles embedded '\\n' by measuring each line and returning
    (max_line_width, total_height_in_lines × line_height).
    Bold text renders ~11% wider (DejaVu Sans), so widen.
    """
    cw, lh = _font_size_metrics(font_size)
    if bold:
        cw *= 1.11
    if '\n' in text:
        lines = text.split('\n')
        return max(len(L) for L in lines) * cw, lh * len(lines)
    return len(text) * cw, lh

# Replaced by the real Tk-backed measurement in __init__.
_measure_text = _measure_text_estimate


# ══════════════════════════════════════════════════════════════════════════════
#  §3.5  TOPOLOGICAL SIGNAL ORDER  (PERT model: instances = events/nodes,
#         nets = activities/edges, oriented driver→load).  Used to feed the
#         Sugiyama placer a DAG.  When the signal graph has feedback (it is
#         not a DAG) we remove the FEWEST nets needed to break every cycle
#         via the Eades–Lin–Smyth greedy minimum-feedback-arc-set heuristic.
#         The removed ("feedback") nets are excluded only from the PLACEMENT
#         layering — they are still drawn as flight lines by the renderer.
# ══════════════════════════════════════════════════════════════════════════════

def _electrical_net_roles(comp):
    """In : a component dict.  Out: (out_nets, in_nets) as lowercase
    lists — the nets this element DRIVES and the ones it SENSES — so the
    signal graph can be oriented driver->load.
    A passive or direction-less element (R, C, L, D, X-subckt, K, T) gets
    ([], []) and is treated as bidirectional, taking its orientation from
    the column potential in _compute_signal_topo_order instead.
    The ONLY place that encodes per-kind pin direction, so adding a new
    directed device type is a one-line change here."""
    kind = (comp.get('kind') or (comp.get('ref', '')[:1])).upper()
    nets = [n.lower() for n in (comp.get('nets') or [])]
    if len(nets) < 2:
        return [], []
    # Voltage/current controlled sources and the voltage-controlled switch:
    # SPICE order is  <out+> <out-> <ctrl+> <ctrl-> …  (POLY forms add more
    # control-node pairs).  First pair is driven; everything after is sensed.
    # behavioral (VALUE/{…}) sources carry their control
    # inputs INSIDE the equation as V(net), not as positional nodes, so
    # merge those equation nets into the sensed inputs (see
    # _equation_signal_refs).  This is what lets an ABM/GVO source position
    # after the signals it reads instead of looking input-less.
    if kind in ('E', 'G', 'S', 'B'):
        # prefer the parser's pre-mapped control inputs:
        # expand_subckt already records comp['sense_nets'] (the V()/I()
        # arguments of the VALUE equation) substituted through the SAME
        # port/internal mapping as the pins, so for a nested source like
        # OPAX197's GVO+ the equation's local VIN/VC+ resolve to the real
        # flattened nets (VOUT_S/VCC_CLP).  Fall back to raw-equation
        # parsing only for sources the parser left without that field
        # (e.g. a top-level source not flattened through a subckt, whose
        # node names are already the real nets).
        sense = comp.get('sense_nets')
        if sense is not None:
            extra = [s.lower() for s in sense]
        else:
            v_eq, _i = _equation_signal_refs(comp)
            extra = sorted(v_eq)
        ins = list(dict.fromkeys(list(nets[2:]) + extra))
        return nets[0:2], ins
    # Current-controlled sources sense current through a named V-source
    # (not a node), so they have a driven output pair and no sensed NODE.
    if kind in ('H', 'F'):
        return nets[0:2], []
    # Independent sources drive their first node pair.
    if kind in ('V', 'I'):
        return nets[0:2], []
    # 3-terminal actives — control pin is the gate/base; the other two are
    # the driven channel/junction terminals.  Q: C B E ; M/J: D G S.
    if kind in ('Q', 'M', 'J') and len(nets) >= 3:
        return [nets[0], nets[2]], [nets[1]]
    return [], []


def _equation_signal_refs(comp):
    """In : a comp dict.  Out: (v_nets, i_srcs), lowercase sets of the
    SIGNAL dependencies a behavioral or controlled source senses that are
    NOT among its positional output nodes.
      v_nets  nets read as V(net) or V(net+,net-) inside a VALUE or {…}
              equation (E/G/B sources) — the control INPUTS.
      i_srcs  V-sources whose CURRENT the element senses: I(vsrc) in a
              VALUE equation, or an F/H source's positional sense-source
              names, the POLY(k) v1..vk form included.
    Feeds the matching left-side edges into the signal topological graph,
    so these sources rank after what they read."""
    kind = (comp.get('kind') or (comp.get('ref', '')[:1])).upper()
    text = str(comp.get('value') or '') or str(comp.get('raw') or '')
    v_nets, i_srcs = set(), set()
    for mobj in re.finditer(
            r'\bV\(\s*([^,)\s]+)(?:\s*,\s*([^)\s]+))?\s*\)', text, re.I):
        v_nets.add(mobj.group(1).lower())
        if mobj.group(2):
            v_nets.add(mobj.group(2).lower())
    for mobj in re.finditer(r'\bI\(\s*([^,)\s]+)\s*\)', text, re.I):
        i_srcs.add(mobj.group(1).lower())
    # F/H name their sense V-source positionally:
    #   F/H  n+ n-  [POLY(k)]  vsrc1 [vsrc2 …]  gain/coeffs…
    if kind in ('F', 'H'):
        toks = _tokenise_spice_line(str(comp.get('raw') or ''))
        if len(toks) >= 4:
            i = 3
            ksrc = 1
            if toks[i].upper().startswith('POLY'):
                mp = re.search(r'POLY\s*\(\s*(\d+)\s*\)',
                               ' '.join(toks[i:i + 2]), re.I)
                ksrc = int(mp.group(1)) if mp else 1
                i += 1
                if i < len(toks) and toks[i - 1].upper() == 'POLY':
                    i += 1   # 'POLY' and '(k)' were separate tokens
            for j in range(ksrc):
                if i + j < len(toks):
                    i_srcs.add(toks[i + j].lower())
    return v_nets, i_srcs


def _greedy_feedback_arc_order(node_ids, edges, prio=None):
    """In : node_ids, directed (u, v) edges (multi-edges become weight)
    and optional prio {node: number}, lower = nearer the input.
    Out: {node: 0-based left-to-right position}.
    Eades-Lin-Smyth greedy linear arrangement: linear time, deterministic
    and near-optimal on the sparse, mostly-forward graphs circuits give.
    An edge (u, v) is a BACK edge exactly when position[u] >= position[v];
    the caller maps those to the nets it must cut to get a DAG.  Every tie
    is broken by prio before input order, so the edge a cycle loses points
    back toward the input.  Works for instances or for cluster ranking."""
    nodes = list(dict.fromkeys(node_ids))
    if prio:
        _big = max(prio.values(), default=0) + 1
        nodes.sort(key=lambda n: prio.get(n, _big))   # stable
    rank = {n: i for i, n in enumerate(nodes)}        # stable tie-break key
    out_adj = {n: {} for n in nodes}
    in_adj = {n: {} for n in nodes}
    for u, v in edges:
        if u == v or u not in out_adj or v not in out_adj:
            continue
        out_adj[u][v] = out_adj[u].get(v, 0) + 1
        in_adj[v][u] = in_adj[v].get(u, 0) + 1
    outdeg = {n: sum(out_adj[n].values()) for n in nodes}
    indeg = {n: sum(in_adj[n].values()) for n in nodes}
    remaining = set(nodes)
    s1, s2 = [], []

    def _remove(n):
        for m, w in out_adj[n].items():
            if m in remaining:
                indeg[m] -= w
                in_adj[m].pop(n, None)
        for m, w in in_adj[n].items():
            if m in remaining:
                outdeg[m] -= w
                out_adj[m].pop(n, None)
        remaining.discard(n)

    while remaining:
        changed = True
        while changed:
            changed = False
            # Sinks (no outgoing) go to the right end.
            sinks = sorted((x for x in remaining if outdeg[x] == 0),
                           key=lambda x: rank[x])
            if sinks:
                for n in sinks:
                    s2.append(n)
                    _remove(n)
                changed = True
            # Sources (have outgoing, no incoming) go to the left end.
            srcs = sorted((x for x in remaining
                           if outdeg[x] > 0 and indeg[x] == 0),
                          key=lambda x: rank[x])
            if srcs:
                for n in srcs:
                    s1.append(n)
                    _remove(n)
                changed = True
        if not remaining:
            break
        # No pure source/sink left: greedily peel the vertex whose forward
        # pull (outdeg − indeg) is largest; ties by stable rank.
        best = max(remaining,
                   key=lambda x: (outdeg[x] - indeg[x], -rank[x]))
        s1.append(best)
        _remove(best)

    seq = s1 + s2[::-1]
    return {n: i for i, n in enumerate(seq)}


def _flow_distance(node_ids, edges, sources, sinks):
    """Takes nodes, directed (u, v) edges and the nodes on the input and the
    output side, and returns {node: distance} -- hop count from the inputs,
    or for nodes the inputs cannot reach, the farthest output distance minus
    their hop count back from the outputs. With no inputs every node is
    measured back from the outputs, so the part farthest from the output is
    leftmost. Unreached nodes are absent."""
    succ = defaultdict(set)
    pred = defaultdict(set)
    for u, v in edges:
        if u != v:
            succ[u].add(v)
            pred[v].add(u)

    def _bfs(start, adj):
        d = {n: 0 for n in start}
        q = deque(sorted(start, key=str))
        while q:
            u = q.popleft()
            for v in sorted(adj[u], key=str):
                if v not in d:
                    d[v] = d[u] + 1
                    q.append(v)
        return d

    fwd = _bfs([n for n in node_ids if n in set(sources)], succ)
    back = _bfs([n for n in node_ids if n in set(sinks)], pred)
    top = max(list(fwd.values()) + list(back.values()) + [0])
    out = dict(fwd)
    for n, b in back.items():
        if n not in out:
            out[n] = top - b
    return out


class SpiceSchem(tk.Tk):

    def __init__(self, spice_path, sym_path, fulltext=False, subckt=None,
                 no_pr=False, rank_grid=False, skip_bk=False,
                 post_fixups=False, no_ports=False, no_uncross=False,
                 best_of_bk=False):
        super().__init__()
        self._spice_path = spice_path
        # when True, the startup auto-load of the
        # sibling <spice_path>.pr.json is skipped, so the circuit comes up
        # on a fresh auto-Place.  Set by the -n/--no-pr command-line flag.
        # Enforced in ONE place, _read_matching_pr, which both the early
        # role-only read and _auto_open_matching_pr funnel through — the
        # manual "Open P&R…" button and every save path are unaffected, so
        # this suppresses only the automatic load, never the file itself.
        self._no_autoload_pr = bool(no_pr)
        # -g/--rank-grid and --no-bk: two diagnostics for seeing what
        # Sugiyama's LAYERING decided, separately from what the
        # coordinate passes did with it.  See _draw_rank_grid.
        self._show_rank_grid = bool(rank_grid)
        self._skip_bk = bool(skip_bk)
        # --no-ports/--sugiyama/-k: fall back to the node-index
        # barycenter.  Stashed here because __init__ sets
        # _port_constraints True further down; that assignment reads
        # this, so the flag survives however the default later moves.
        self._no_port_constraints = bool(no_ports)
        # --no-uncross: skip the post-placement self-cross rotation pass
        # (see the gate in _settle_and_rebuild_ts for why it is the one
        # post-placement pass that runs by default).
        self._no_uncross = bool(no_uncross)
        # DPI-independence: force Tk's point->pixel scaling to a
        # FIXED reference DPI BEFORE any font is created/measured, so font
        # widths/heights are identical on every machine regardless of its
        # display DPI or OS scaling (Tk uses 72 points/inch).  Without this the
        # same point-sized label measures a different number of pixels per
        # machine, so a layout clean on one screen can overlap on another.
        try:
            self.tk.call('tk', 'scaling', _SCHEM_REF_DPI / 72.0)
        except Exception:
            pass
        self.fulltext = fulltext
        # Explicit SUBCKT request from the command line (or
        # programmatic caller).  When set, the auto-expand block below
        # prefers this name over "biggest SUBCKT".  Matching is
        # case-insensitive because SPICE is.
        self._requested_subckt = subckt.upper() if subckt else None

        # Install real tkinter font measurement (replaces static estimate
        # globally).
        # We measure each string exactly using tkfont, which handles
        # proportional
        # fonts, kerning, and platform-specific rendering correctly.
        global _measure_text
        # _TIGHT_FONT_CACHE is module level, so clear it for each new app: its
        # fonts belong to the old Tk interpreter.
        _TIGHT_FONT_CACHE.clear()
        _fonts_cache:  dict = {}   # font_size → tkfont.Font object
        _text_cache:   dict = {}   # (text, font_size) → (width_px, height_px)
        def _measure_text_real(text, font_size, bold=False):
            key = (text, font_size, bold)
            if key not in _text_cache:
                fkey = (font_size, bold)
                if fkey not in _fonts_cache:
                    _fonts_cache[fkey] = tkfont.Font(
                        family=FONT_FAMILY, size=_font_px(font_size),
                        weight=('bold' if bold else 'normal'))
                f = _fonts_cache[fkey]
                # Account for embedded '\n' — Tk's
                # Font.measure() returns 0 for a newline character so
                # multi-line strings come out too short on the y-axis.
                if '\n' in text:
                    lines = text.split('\n')
                    w = max(f.measure(L) for L in lines)
                    h = f.metrics('linespace') * len(lines)
                    w += _TEXT_MEASURE_SLACK
                    h += _TEXT_MEASURE_SLACK
                else:
                    w = f.measure(text)
                    h = f.metrics('linespace')
                # RESERVE THE UPPER BOUND (see sp2Sch_info.txt §5).
                # Font.measure() returns the ADVANCE width, while the
                # canvas item Tk actually draws reports one pixel more
                # from bbox() -- so every value and ref label overhung
                # its reservation by exactly 1 px on the right.  Caught
                # on LM324.sub's V54: reserved to x=894, '0.55' drawn to
                # 895; V53 and R79 the same.  A box that is too big never
                # causes an overlap; one that is too small is a defect.
                w += _TEXT_MEASURE_SLACK
                h += _TEXT_MEASURE_SLACK
                _text_cache[key] = (w, h)
            return _text_cache[key]
        _measure_text = _measure_text_real
        self.title(f'sp2Sch  –  {Path(spice_path).name}')
        self.configure(bg='#e0e4ec')
        self.geometry('1200x750')
        self.minsize(600, 400)

        # Load KiCad symbols.  Default to the MERGED
        # standard libraries (Device + Simulation_SPICE); an explicit
        # sym_path still loads that single file for back-compat.
        self.sym_lib = {}
        # record WHICH symbol source was used.  Passing an explicit
        # sym_path loads ONLY that file; the default loads the MERGED
        # standard libraries.  The two give different symbol geometry and
        # therefore different placements — measured on OPAX197, one
        # hand-passed Simulation_SPICE file vs the merged default is 731
        # vs 674 crossings and 261621 vs 194381 px of flight-line.  A
        # whole session's baselines were quietly measured against the
        # wrong one, so -v reports this next to its fingerprints.
        if sym_path and Path(sym_path).exists():
            self.sym_lib = load_kicad_symbols(sym_path)
            self._sym_sources = [str(sym_path)]
        else:
            self.sym_lib, _loaded = load_merged_symbols()
            self._sym_sources = list(_loaded or [])
            if _loaded:
                print("Loaded symbols from: " + ", ".join(_loaded))
            # diagnose the "boxes labelled GSOURCE/ESOURCE"
            # case: the source/active-device symbols live ONLY in
            # Simulation_SPICE.kicad_sym.  If that file wasn't found, those
            # symbols are absent and every E/G/B/V/I/D/Q/M part renders as
            # an empty fallback box.  Warn loudly with the exact missing
            # names and the directories searched so it's obvious the
            # simulation library is the one that didn't load.
            _need = ['VDC', 'IDC', 'ESOURCE', 'GSOURCE', 'BSOURCE',
                     'D', 'NPN', 'PNP', 'NMOS', 'PMOS']
            _missing = [n for n in _need if n not in self.sym_lib]
            if _missing:
                print("WARNING: source/device symbols missing: "
                      + ", ".join(_missing))
                print("  These come from Simulation_SPICE.kicad_sym, which "
                      "was not found in:")
                for _d in STD_SYM_DIRS:
                    _p = Path(_d) / 'Simulation_SPICE.kicad_sym'
                    print(
                        f"    {_p}  "
                        f"{'(exists)' if _p.exists() else '(absent)'}")
                print("  Parts using these will render as empty boxes until "
                      "the file is on the search path.")
        if not self.sym_lib:
            print(f"Warning: no symbol library loaded from {sym_path!r}")

        # Parse SPICE
        parser = SpiceParser()
        self.components = parser.parse_file(spice_path)
        self._parser = parser

        # If the whole file is subckt definitions (e.g. a model library),
        # expand a subcircuit so we have something to display.  Rev 41:
        # honour an explicit --subckt request from the command line
        # first; fall back to the largest SUBCKT only when no name was
        # given (or the requested name doesn't exist in the file).
        # Remember which subckt is currently "active" so the
        # toolbar dropdown can show it and the user can switch.  When
        # the file has top-level components, _active_subckt stays None
        # and self.components keeps its original parsed value.
        self._active_subckt = None
        if not self.components and parser.subckts:
            chosen = None
            if self._requested_subckt is not None:
                if self._requested_subckt in parser.subckts:
                    chosen = self._requested_subckt
                else:
                    print(f"Warning: requested SUBCKT "
                          f"{self._requested_subckt!r} not found in "
                          f"{spice_path}.  Available: "
                          f"{sorted(parser.subckts.keys())}")
            if chosen is None:
                chosen = max(parser.subckts,
                              key=lambda k: len(parser.subckts[k]['lines']))
            self.components = parser.expand_subckt(chosen)
            self._active_subckt = chosen

        # Filter to drawable components only
        self.drawable = [c for c in self.components
                         if c['kind'] in SPICE_TO_SYM or c['kind'] == 'X']
        # Case-preserving net DISPLAY table, built
        # once here (and again in _on_subckt_pick, the only other place
        # self.drawable is rebuilt) — see _build_net_disp_table's own
        # docstring for the full rationale.
        self._build_net_disp_table()

        # Force-directed placement state (None = use grid layout)
        self._placed_instances = None
        self._placing_instances = None  # Live during a place
        self._placed_order     = None   # sorted comp list after placement
        self._placed_ref_pos   = None   # ref → (cx,cy) for direct rendering
        self._placed_text = None         # ref → [(kind, placed)]
        self._placement_errors = []      # [(label, cx, cy, w, h)]
                                         # items the placer could NOT place
                                         # clear of all bboxes (drawn as red
                                         # ERROR boxes + logged to stdout).
        self._suppressed_nets = frozenset()  # Global per-pin
                                             # label suppression set, shared
                                             # by every (re)build so placement
                                             # and render agree on text items.
        # (net_to_pins, inst_to_pairs, instances)
        self._pin_flight_data  = None

        # ── Rev 33: interactive Place & Route state ────────────────────
        # User-overridden component centres (ref → (cx, cy)).  Wins over
        # the auto-placer's _placed_ref_pos so dragging persists across
        # re-renders.
        self._user_positions = {}
        # User-overridden component rotations (ref → degrees CCW, always
        # a multiple of 90).  Applied during Phase 1 of _render.
        self._user_rotations = {}
        # Auto-rotations set by _signal_flow_rotations
        # are kept SEPARATE from genuine user (right-click) rotations.
        # Conflating them let a stale auto-rotation from a prior
        # placement (e.g. before a Nets-dialog change) block the
        # correct new rotation, because the rotation pass skips refs in
        # _user_rotations.  _render applies _user_rotations first, then
        # falls back to _auto_rotations.
        self._auto_rotations = {}
        # Orientation CLASS ('H'/'V') per ref, set
        # whenever _auto_rotations is decided from pin-role/power-adjacency
        # (currently only _signal_flow_rotations).  Sugiyama's mirror pass
        # (_place_groups_as_lanes) reads this to know which axis a part is
        # allowed to move within/across — see that method's docstring.
        self._orient_class = {}
        # flip (mirror about the vertical axis): _user_flips
        # from shift-right-click, _auto_flips from patterns (e.g. the diff
        # pair flips its 2nd device so the bases point outward).
        self._user_flips = {}
        self._auto_flips = {}
        # Refs whose mirror _io_side_mirror decided from a declared
        # .SUBCKT port.  Rebuilt every Place, like _auto_flips.
        self._port_side_locked = set()
        self._parallel_orient = {}
        # T-id.  The user rotates an IO T (e.g. an "ERROR" net the
        # heuristic guessed was an input) and the rotation persists
        # across Place runs so the new classification is honoured.
        # Key: net_lc.  Value: rot (0/90/180/270).
        # rot=270 → input (left side of cluster).
        # rot=90  → output (right side of cluster).
        # rot=0   → bottom (gnd-orientation).
        # rot=180 → top (vcc-orientation).
        self._t_net_rot_overrides = {}
        # explicit +power/ground override, set from
        # the Nets dialog's Role column.  Key: net_lc.  Value: '+' or
        # '-'.  Checked FIRST by _rail_polarity(), before its existing
        # diff-pair-structure auto-detection — so an explicit user
        # choice here also drives "positive rail up" component
        # orientation on the next Place, not just this net's own T-
        # symbol rotation (that part alone was already fully covered by
        # _t_net_rot_overrides, which rot=0/180 above already meant).
        self._rail_polarity_overrides = {}
        # Nets set to '-power' (not plain ground) in the Nets dialog: same T
        # rotation and '-' polarity as ground, kept apart so the dialog shows
        # the role.
        self._neg_power_nets = set()
        # ── Promoted-rail state ──────────────────
        # Set of lower-case net names that have been promoted to
        # "rail" status (high-fanout internal nets drawn as a single
        # vertical line, like power/ground but for internal signals).
        # Detected by _detect_promoted_rails() at the top of
        # _run_placement.  Excluded from all signal-flow algorithm
        # passes.  Rendered specially by Pass 2.
        self._promoted_rails = set()

        # User overrides for flight-line arrow direction.  Keyed by a
        # canonicalised edge identifier; value is one of 'forward',
        # 'reverse', 'none'.  Missing key means 'auto' (use auto-
        # detected direction).  See _arrow_edge_key for the key
        # format.
        self._arrow_overrides = {}
        # User-forced feedback/forward edge overrides, keyed by net plus the
        # unordered pair of refs, since one net can carry several edges.
        self._feedback_overrides = {}
        # The (edge-key, event.time) of the last right-click on a flight line,
        # so a quick second one on the same edge toggles feedback.
        self._last_right_flight_click = None
        # Routed wires drawn by the user.  Each entry is a dict
        #   {'net': str,
        #    'points': [(x, y), …],      list of canvas (x, y) waypoints
        #    'endpoints': [(ref, pin),   start pin (may be None for free end)
        #                  (ref, pin)]}  end   pin (may be None for free end)
        self._wires = []
        # Selected segments — set of (wire_idx, seg_idx) tuples.  A
        # "segment" is the line between points[i] and points[i+1] of a
        # wire.  Highlighted yellow when drawn.  [Delete] removes them;
        # [Esc] clears the selection.
        self._selected_segments = set()
        # Drag-in-progress state.  When non-None, mouse-motion translates
        # the instance and on release the new position is committed to
        # _user_positions.
        self._drag_state = None       # see _on_canvas_click for shape
        # Wire-in-progress state.  When non-None, mouse motion shows a
        # rubber-band line from the last clicked point to the cursor,
        # and clicks add waypoints.
        self._wire_in_progress = None  # see _start_wire for shape
        # Group selection / multi-drag state.
        # Set of instance refs currently grouped (highlighted with a
        # dashed orange outline).  Left-click+drag on any group member
        # moves the whole group; right-click in empty space "forgets"
        # the grouping.
        self._selected_group = set()
        # Parallel set holding T-symbol ids that are part of
        # the current rubber-band selection.  Selected Ts get the same
        # dashed-orange outline as selected instances and move with the
        # group on a group drag.  Tracked separately from
        # _selected_group because the rubber band selects by rectangle
        # geometry, NOT by cluster membership — a T whose cluster's
        # instances are outside the rectangle is NOT selected just
        # because the T happens to be a member of that cluster.
        self._selected_t_ids = set()
        # Per-net flight-line label state, keyed by lower-case net: a list of
        # {'pos', 'visible'}.  A missing key draws one label at the longest MST
        # edge's midpoint.
        self._net_labels = {}
        # In-progress drag of a single label.
        #   {'net': net_lc, 'idx': i_in_list, 'mouse_origin': (x,y),
        #    'pos_origin': (cx, cy), 'moved': bool}
        # `moved` flips True once the mouse has moved past the click-vs-
        # drag threshold (4 px); on release, moved=False → toggle hide,
        # moved=True → commit the new pos.
        self._label_drag = None
        # Per-instance VALUE-equation truncation override.
        # Keyed by ref; value True means "show full equation", False
        # means "truncate to VALUE_MAX_CHARS plus ellipsis".  Missing
        # key falls back to the global self.fulltext (True by default
        # in rev 49b).  Used to let the user toggle truncation per
        # selection (rubber-banded group) via the toolbar "Full text"
        # button.  Persisted in P&R JSON.
        self._fulltext_overrides = {}
        # Rubber-band selection state, present only while the user is
        # currently dragging a selection box in empty space.
        #   {'start': (x, y), 'rect_id': int}
        self._rubber_band = None
        # T-terminal state: each T has a net, a canvas position and a rotation
        # (0, 90, 180, 270).
        self._t_terminals = []
        self._next_t_id = 1
        # Pin → T assignment: keyed by (ref, pin_num) → t_id.  Drawn
        # by the renderer as a flight line from the pin to the T.
        # Recomputed only on Place; on instance drag, the line stretches.
        self._pin_to_t = {}
        # Selection state for T-symbols (for left-click drag & right-
        # click rotate).  Mutually exclusive with _drag_state on
        # instances — only one is active at a time.
        self._t_drag = None
        # Cache of last computed clusters: list of lists of instance refs.
        # Recomputed on Place; used by save_pr to dump cluster summary.
        self._boxes = []
        self._placement_boxes_refs = None
        # Largest sub-group _refine_clusters_into_boxes keeps whole: 6, so a
        # tightly coupled 5-part chain (LP2951's E_ABM2 -> R_R3 -> C_C3, RS_S1,
        # S_S1) is not split.
        self._box_max_group = 6
        # False until the first _run_placement; gates
        # _render's pre-placement "Placing…" hint (prevents the 0,0 flash).
        self._initial_place_done = False
        # Largest leaf cell _p2dl_pair_two_pin_nets will build.  2 is the
        # original two-pin pair; 3 lets a wire with one non-leaf and two
        # leaves become one cell (LM324.sub's I7/Q20/V53).
        self._leaf_cell_max = 4
        # Cross-box distance experiment: 0 = geometric sort only,
        # 1 = mean Sugiyama rank inside the height bucket, 2 = mean rank
        # first.  See the sort in the box packer.
        self._rank_box_order = 0
        self._chain_one_per_row = False     # the '1 chain/row' toggle
        self._INTERIOR_T_MIN_PINS = 8     # pins/cluster to trigger split
        self._INTERIOR_T_PINS_PER = 6     # ~target pins per interior T
        # POST-FIXUPS: every placement or rotation change that runs
        # after Sugiyama/BK has produced a layout.  Default OFF, so what
        # BK computes is what gets drawn and the project's own theorem —
        # "with exact lower-level bboxes, any remaining overlap is
        # Sugiyama/BK's" — is testable rather than asserted.  The -p flag
        # and the 'Post-fix' toolbar box turn them back on.
        self._post_fixups = bool(post_fixups)
        # 'Pre-grouping' toggle: fuse tightly connected parts into small (<=4)
        # units with _find_hiding_groups before Sugiyama.  Off by default.
        self._pregroup_enabled = False
        # How close in y two units must be for the rank barrier to treat
        # them as SHARING ROWS.  0 = exact y-overlap only; a huge value
        # restores the old single-max-per-rank barrier.  150px is about
        # one symbol row plus its gap, i.e. "near enough that the reader
        # takes them as side by side".  Chosen by measurement, not feel —
        # see the table at the barrier in _place_groups_as_lanes.
        self._rank_barrier_pad = 150.0
        self._rank_barrier_escape_cap = 3
        # How hard to charge Y travel against X saved when deciding a
        # row escape.  1.0 = a px moved must buy more than a px of
        # width.
        self._rank_barrier_escape_cost_w = 1.0
        # Minimum EXCESS push (px beyond what this unit's own
        # connections require) before a row escape is attempted.
        self._rank_barrier_escape_min = 1000.0
        # FIXED_ORDER port constraints in the crossing-reduction sweep (Schulze
        # et al., JVLC 2014): a symbol's pins are ports at fixed positions, so
        # each edge is ordered at the pin it attaches to.  -k turns it off.
        self._port_constraints = not getattr(
            self, '_no_port_constraints', False)
        # The barycenter sweeps keep their best ordering by crossing count; a
        # new ordering must beat the incumbent by this fraction (0.0 = any
        # improvement).
        self._keep_best_margin = 0.0
        # None = BK's published four-candidate balance; 0..3 picks one candidate
        # instead.
        self._bk_candidate = None
        # --best-of-bk: place once per BK candidate (the balance and 0..3), keep
        # the fewest drawn crossings, then place again with the winner.
        self._bk_best_of_drawn = bool(best_of_bk)
        self._in_best_of = False
        # No post-placement T movers: a T is reserved with its body and text, so
        # an overlapping T is a reservation bug to fix at the source.

        # Body-overlap resolver (quadtree
        # relocate-to-free-spot) is ALWAYS ON: PLACE should not leave
        # overlaps.  (The 'Separate' A/B toolbar toggle was removed once the
        # resolver was trusted to never create a new overlap.)
        self._show_cluster_boxes = False
        # Set by Place so the next _render rebuilds T-terminals once ox_px/oy_px
        # are final.
        self._cached_instances = []
        self._cached_inst_by_ref = {}
        # Canvas item id for the rubber-band preview line (recreated each
        # mousemove while a wire is in progress).
        self._rubber_band_id = None

        # ── Floating-nets analyzer state ─────────────
        # Set of lower-case net names currently highlighted in red by
        # the Floating-nets dialog.  Applied as a post-pass in
        # _render via itemconfig on items tagged flight_net:{nl} and
        # net_label:{nl}.  Cleared at the top of _run_placement so a
        # fresh layout shows normal colours.
        self._highlighted_nets = set()
        # The Floating-nets Toplevel dialog (or None when closed).
        # Tracked so we can refresh the list after the user runs
        # Place, and so Clear All can flip the listbox state.
        self._floating_dlg = None
        # Hidden VALUE-sense report dialog state.
        self._hidden_sense_dlg = None
        self._hidden_sense_listbox = None
        self._hidden_sense_display = []     # list of net display names
        # Cut and port are independent net properties, each with
        # force-on/force-off overrides: effective = (auto | force_on) -
        # force_off.  CUT splits clusters; PORT makes a T.
        self._cut_force_on = set()
        self._cut_force_off = set()
        self._port_force_on = set()
        self._port_force_off = set()
        # The Nets dialog handle + its working (uncommitted) snapshots,
        # used by Cancel to revert all four override sets.
        self._nets_dlg = None
        self._nets_tree = None
        self._nets_saved = None     # tuple of the four sets at open
        # Listbox handle inside the floating-nets dialog.
        self._floating_listbox = None
        # Sorted list of net names that currently populates the
        # listbox — stored in case-preserving form for display.  Index
        # in the listbox maps 1:1 to this list.  Lower-case form is
        # used internally for the highlight set.
        self._floating_nets_display = []

        # Driver->Receiver list dialog state (Toplevel
        # handle + listbox + the report lines it currently shows, so Save
        # writes exactly what's on screen).
        self._dr_dlg = None
        self._dr_lines = []

        # per-pin driver/receiver role OVERRIDES:
        # {(ref, pin_num_str): 'in' | 'out'}.  Set by clicking a pin on the
        # canvas to cycle unknown(auto)->in->out->unknown(auto).  Locked/
        # authoritative in _compute_pin_role_map (wins over the intrinsic
        # table and propagation), and — since _compute_signal_topo_order now
        # ranks Sugiyama's columns from that SAME map — an override actually
        # changes placement on the next Place, not just the arrow overlay.
        # Persists across re-Place (like _user_rotations); NOT cleared by
        # Forget-edits (semantically a circuit-understanding correction, not
        # a layout edit) — cleared only via the cycle-back-to-unknown click.
        self._pin_role_overrides = {}
        self._pin_role_marker_hits = []
        # Set only by the manual rotate/flip handlers, right before they
        # call _render() -- see the long comment on its use in _render.
        self._just_rotated_refs = set()
        # after rotating one T-symbol, every OTHER
        # T sharing its net is drawn green until the user clicks empty
        # canvas space, so a net with multiple T's scattered around the
        # schematic (e.g. several "3" markers) is easy to find and
        # reconsider together, rather than the user having to hunt for
        # them.  _highlighted_t_net is the lowercased net string to
        # highlight (None = no highlight); _highlighted_t_exclude_id is
        # the T that was just rotated, excluded from the highlight since
        # it's already been acted on.
        self._highlighted_t_net = None
        self._highlighted_t_exclude_id = None

        self._build_ui()
        # Do not render before the first Place: the draw-state store is empty
        # and every part would flash at 0,0.
        self.after_idle(self._auto_initial_place)

    def _auto_initial_place(self):
        """In : the loaded circuit and any matching .pr.json.  Out: the
        first Place run, with that file's net ROLES applied first.
        Roles before Place, the rest of the snapshot after: a role-blind
        Place treats a saved +power/ground/-power net as auto, and the
        saved T-terminals, computed under a role-aware layout, can then
        overlap or cross when overlaid on it (see _apply_pr_role_data).
        Positions, T's, wires and labels still arrive afterwards through
        _auto_open_matching_pr.  Safe to call twice — the user just sees
        a flicker."""
        if not self.drawable:
            return
        pr_data = None
        try:
            pr_data = self._read_matching_pr()
            if pr_data is not None:
                self._apply_pr_role_data(pr_data)
        except Exception:
            # Same belt-and-braces spirit as the post-Place load below:
            # a problem here should fall back to a plain role-blind
            # Place, not block startup.
            pr_data = None
        # Skip the startup Place when the saved file places every drawable ref;
        # _apply_pr_data then builds the objects without laying them out.
        _saved_inst = (pr_data or {}).get('instances') or {}
        _need = {c['ref'] for c in (self.drawable or ())}
        _has_placement = (bool(_saved_inst) and _need <= set(_saved_inst)
                          and not _pr_is_placement_free(pr_data))
        try:
            if not _has_placement:
                self._run_placement()
        except Exception as e:
            # Don't crash the UI if placement fails on startup —
            # leave the user in the grid view and surface the error.
            self.status.config(text=f'Auto-place failed: {e}')
            return
        try:
            if pr_data is not None:
                # A roles_only file (Save net &
                # pin info…) already had its role fields applied BEFORE
                # Place, above; its instances/t_terminals/pin_to_t are
                # intentionally empty (never captured placement), so
                # running it through the full _apply_pr_data here would
                # overwrite the fresh Place's own T-terminals/positions
                # with that emptiness — confirmed directly (round-trip
                # left 0 T-terminals where the fresh Place had built its
                # own).  Skip the full apply for this case; the fresh,
                # role-aware Place IS the intended result.
                if (pr_data.get('roles_only')
                        or _pr_is_placement_free(pr_data)):
                    pr_path = self._default_pr_path()
                    self.status.config(
                        text=f'Net & pin roles auto-loaded from '
                             f'{pr_path.name} — freshly placed')
                else:
                    self._apply_pr_data(pr_data)
                    pr_path = self._default_pr_path()
                    self.status.config(
                        text=f'Place & Route auto-loaded from {pr_path.name}')
            else:
                self._auto_open_matching_pr()
        except Exception:
            # Belt-and-braces: _auto_open_matching_pr already treats every
            # EXPECTED failure (missing file, bad JSON, ...) as a silent
            # no-op internally.  An exception escaping it here would be a
            # genuine bug in that method, not a reason to blame placement
            # (which already succeeded) or crash the UI over what is only
            # ever meant to be a convenience on top of a working auto-Place.
            pass
        # after the startup Place (and any .pr.json role apply that may
        # have re-Placed on top of it) has settled, so the numbers
        # describe the layout actually on screen rather than an
        # intermediate one.
        try:
            if _SHOW_SLACK_REPORT:
                self._print_placement_slack_report()
        except Exception as exc:
            # Never let a diagnostic take down a working startup.
            print('placement-slack report unavailable: %r' % (exc,))

    # ── UI ────────────────────────────────────────────────────────────────

    def _build_ui(self):
        # Top bar — Rev 49b: flow layout.  Toolbar items are created
        # detached, then placed left-to-right with line-wrapping on
        # <Configure> of the container.  The Help button is reserved
        # at the right edge of the LAST row regardless.
        #
        # Item ordering preserves natural groupings.  Where a label and
        # its input must stay together (e.g. "Filter:" + entry), they
        # live inside a small inner frame and the inner frame is the
        # toolbar item.  This way the flow algorithm never separates
        # them.
        BG = '#2b4f82'
        FG = 'white'

        self._toolbar = tk.Frame(self, bg=BG)
        self._toolbar.pack(fill=tk.X)
        # The actual flow container — a frame whose width follows the
        # toolbar's.  Items are place()'d inside it.
        self._toolbar_flow = tk.Frame(self._toolbar, bg=BG, padx=4, pady=4)
        self._toolbar_flow.pack(fill=tk.X)

        # ── Build every item as a detached widget.  Each is then
        # added to self._toolbar_items in display order.  The flow
        # layout uses place() relative to self._toolbar_flow.
        self._toolbar_items = []

        def add(widget):
            self._toolbar_items.append(widget)
            return widget

        # Title block (label + count) — group them so they always
        # stay on the same row.
        title_block = tk.Frame(self._toolbar_flow, bg=BG)
        tk.Label(title_block, text='SPICE → Schematic', bg=BG, fg=FG,
                 font=(FONT_FAMILY, 13, 'bold')).pack(side=tk.LEFT)
        # show the program rev in the toolbar so it is
        # captured in screenshots (debugging aid, user request).
        tk.Label(title_block, text=f'rev {self._PROGRAM_REV}', bg=BG,
                 fg='#88aadd',
                 font=(FONT_FAMILY, 9)).pack(side=tk.LEFT, padx=(8, 0))
        self.count_lbl = tk.Label(title_block, text='', bg=BG, fg='#ccddf5',
                                   font=(FONT_FAMILY, 10))
        self.count_lbl.pack(side=tk.LEFT, padx=(12, 0))
        # persistent CROSSINGS / mode readout in the
        # toolbar.  The bottom status bar gets overwritten by drag/drop
        # messages, hiding the crossing count; this label lives in the top
        # bar, is updated on every _render (incl. after a manual move), and
        # is never clobbered by interaction handlers.
        self.cross_lbl = tk.Label(title_block, text='', bg=BG, fg='#ffd479',
                                   font=(FONT_FAMILY, 10, 'bold'))
        self.cross_lbl.pack(side=tk.LEFT, padx=(12, 0))
        add(title_block)

        # Filter group: label + entry.
        filter_block = tk.Frame(self._toolbar_flow, bg=BG)
        tk.Label(filter_block, text='Filter:', bg=BG, fg=FG,
                 font=(FONT_FAMILY, 10)).pack(side=tk.LEFT, padx=(0, 4))
        self.filter_var = tk.StringVar()
        self.filter_var.trace_add('write', lambda *_: self._render())
        tk.Entry(filter_block, textvariable=self.filter_var, width=14,
                 font=(FONT_FAMILY, 10)).pack(side=tk.LEFT)
        add(filter_block)

        # Cols group: label + spinbox.
        cols_block = tk.Frame(self._toolbar_flow, bg=BG)
        tk.Label(cols_block, text='Cols:', bg=BG, fg=FG,
                 font=(FONT_FAMILY, 10)).pack(side=tk.LEFT, padx=(0, 4))
        self.cols_var = tk.IntVar(value=COLS)
        tk.Spinbox(cols_block, from_=1, to=30, textvariable=self.cols_var,
                   width=4, font=(FONT_FAMILY, 10),
                   command=self._render).pack(side=tk.LEFT)
        add(cols_block)

        # SUBCKT picker.
        subckt_block = tk.Frame(self._toolbar_flow, bg=BG)
        tk.Label(subckt_block, text='SUBCKT:', bg=BG, fg=FG,
                 font=(FONT_FAMILY, 10)).pack(side=tk.LEFT, padx=(0, 4))
        self.subckt_var = tk.StringVar(
            value=getattr(self, '_active_subckt', '') or '(top-level)')
        choices = self._subckt_choice_list()
        if not choices:
            choices = ['(top-level)']
        self._subckt_menu = tk.OptionMenu(
            subckt_block, self.subckt_var, *choices,
            command=self._on_subckt_pick)
        self._subckt_menu.config(bg='#3a5e8a', fg=FG,
                                   activebackground='#4a7ab5',
                                   relief=tk.FLAT,
                                   font=(FONT_FAMILY, 10),
                                   highlightthickness=0, bd=0)
        self._subckt_menu.pack(side=tk.LEFT)
        add(subckt_block)

        # File ops + placement — an earlier revision (user): reordered to
        # Open P&R… / Nets / Place… / Forget edits / Save net & pin
        # info… / Save P&R…, so the natural left-to-right flow reads
        # "load a layout (or set net roles), place, then save" with
        # the two Save buttons adjacent at the end of this group.
        add(tk.Button(self._toolbar_flow, text='Open P&R…',
                       command=self._open_pr,
                       bg='#4a7ab5', fg=FG, relief=tk.FLAT, padx=8))

        # Net-port control dialog.  Lets the user
        # choose which nets are treated as ports (cluster boundaries /
        # T-symbols), to trade off number-of-clusters vs cluster
        # complexity.  Moved up next to Open P&R…
        # since setting net roles is typically done before Place, not
        # after the D→R List / Hidden sense analysis buttons.
        add(tk.Button(self._toolbar_flow, text='Nets',
                       command=self._show_nets_dialog,
                       bg='#7a5aa5', fg=FG, relief=tk.FLAT, padx=8))

        # Placement options.  'Pre-grouping' runs the hiding-groups pass before
        # Sugiyama; off by default, so every part enters Sugiyama as its own
        # unit.
        self._pregroup_var = tk.BooleanVar(value=self._pregroup_enabled)

        def _toggle_pregroup():
            self._pregroup_enabled = bool(self._pregroup_var.get())
            self.status.config(
                text=f'Pre-grouping '
                     f'{"on" if self._pregroup_enabled else "off"}'
                     f'  •  applies at the next Place…'
                     f'  (placement unchanged)')
        add(tk.Checkbutton(self._toolbar_flow, text='Pre-grouping',
                            variable=self._pregroup_var,
                            bg=BG, fg=FG, selectcolor='#1a3a6a',
                            activebackground=BG, activeforeground=FG,
                            font=(FONT_FAMILY, 10),
                            command=_toggle_pregroup))

        # 'Sig Topo': lay each cluster out both by signal-topological ranking
        # and by the x-centroid baseline, and keep the better by overlaps, then
        # crossings.
        self._sig_topo = tk.BooleanVar(value=True)

        def _toggle_sig_topo():
            self.status.config(
                text=f'Sig Topo {"on" if self._sig_topo.get() else "off"}'
                     f'  •  applies at the next Place…'
                     f'  (placement unchanged)')
        add(tk.Checkbutton(self._toolbar_flow, text='Sig Topo',
                            variable=self._sig_topo,
                            bg=BG, fg=FG, selectcolor='#1a3a6a',
                            activebackground=BG, activeforeground=FG,
                            font=(FONT_FAMILY, 10),
                            command=_toggle_sig_topo))

        # ONE SUBCHAIN PER ROW.  An INPUT TO PLACE, so toggling must not
        # place -- that would discard a hand placement.
        self._one_row_var = tk.BooleanVar(
            value=bool(getattr(self, '_chain_one_per_row', False)))

        def _one_row_toggle():
            self._chain_one_per_row = bool(self._one_row_var.get())
            self.status.config(
                text='One subchain per row %s -- press Place to apply'
                     % ('ON' if self._chain_one_per_row else 'off'))
        add(tk.Checkbutton(self._toolbar_flow, text='1 chain/row',
                           variable=self._one_row_var,
                           bg=BG, fg=FG, selectcolor='#1a3a6a',
                           activebackground=BG, activeforeground=FG,
                           font=(FONT_FAMILY, 10),
                           command=_one_row_toggle))

        self._place_btn = tk.Button(
            self._toolbar_flow, text='Place…',
            command=self._run_placement,
            bg='#5a8a3a', fg=FG, relief=tk.FLAT,
            padx=14, font=(FONT_FAMILY, 10, 'bold'))
        add(self._place_btn)

        def _forget_edits():
            # 'Forget placement' clears instance and T-symbol placement and does
            # not re-Place; net roles and overrides are kept.
            self._user_positions = {}
            self._user_rotations = {}
            self._user_flips = {}
            self._auto_rotations = {}
            self._auto_flips = {}
            self._placed_instances = None
            self._placed_order = None
            self._placed_ref_pos = None
            self._pin_flight_data = None
            self._placed_text = None
            self._placed_draw_state = None
            self._t_terminals = []
            self._pin_to_t = {}
            self._boxes = []
            self._render()
        add(tk.Button(self._toolbar_flow, text='Forget placement',
                       command=_forget_edits,
                       bg='#8a5a3a', fg=FG, relief=tk.FLAT, padx=8))

        add(tk.Button(self._toolbar_flow, text='Save P&R…',
                       command=self._save_pr,
                       bg='#4a7ab5', fg=FG, relief=tk.FLAT, padx=8))

        add(tk.Button(self._toolbar_flow, text='Grid',
                       command=self._reset_placement,
                       bg='#6a6a6a', fg=FG, relief=tk.FLAT, padx=8))

        # Close the empty space by hand, after any manual edits.
        add(tk.Button(self._toolbar_flow, text='Close gaps ↕',
                       command=self._close_vertical_gaps,
                       bg='#5a6a2a', fg=FG, relief=tk.FLAT, padx=8))
        add(tk.Button(self._toolbar_flow, text='Compact sel',
                       command=lambda: self._selected_move('compact'),
                       bg='#5a6a2a', fg=FG, relief=tk.FLAT, padx=8))
        add(tk.Button(self._toolbar_flow, text='Spread sel',
                       command=lambda: self._selected_move('spread'),
                       bg='#5a6a2a', fg=FG, relief=tk.FLAT, padx=8))

        # Floating-nets analyzer.
        add(tk.Button(self._toolbar_flow, text='Floating nets',
                       command=self._show_floating_nets_dialog,
                       bg='#aa6600', fg=FG, relief=tk.FLAT, padx=8))

        # Driver->Receiver list: per-net dump of the
        # SAME pin-role classification (_compute_pin_role_map) that drives
        # both the arrow-direction overlay and the Sugiyama layering's DAG
        # edges, so the user can inspect/export what that classification
        # decided net-by-net.
        add(tk.Button(self._toolbar_flow, text='D→R List',
                       command=self._show_driver_receiver_dialog,
                       bg='#3a7a6a', fg=FG, relief=tk.FLAT, padx=8))

        # Hidden VALUE-sense net report.  Lists nets
        # that connect to a behavioral E/G source only through a sense
        # inside its VALUE expression (no drawable pin), which makes
        # them look under-connected (e.g. N31303 / R17).
        add(tk.Button(self._toolbar_flow, text='Hidden sense',
                       command=self._show_hidden_sense_dialog,
                       bg='#0a7a8a', fg=FG, relief=tk.FLAT, padx=8))

        # View toggles.
        # Flight lines default ON.  Per user feedback,
        # toggling them on right after startup was a consistent first
        # action; defaulting to ON saves the click and makes the
        # schematic connectivity visible immediately.  User can still
        # toggle them off via the Flights checkbox.
        self.show_flights = tk.BooleanVar(value=True)
        add(tk.Checkbutton(self._toolbar_flow, text='Flights',
                            variable=self.show_flights,
                            bg=BG, fg=FG, selectcolor='#1a3a6a',
                            activebackground=BG, activeforeground=FG,
                            font=(FONT_FAMILY, 10),
                            command=self._render))
        self._show_cluster_boxes_var = tk.BooleanVar(value=False)
        def _toggle_cluster_boxes():
            self._show_cluster_boxes = self._show_cluster_boxes_var.get()
            self._render()
        add(tk.Checkbutton(self._toolbar_flow, text='Boxes',
                            variable=self._show_cluster_boxes_var,
                            bg=BG, fg=FG, selectcolor='#1a3a6a',
                            activebackground=BG, activeforeground=FG,
                            font=(FONT_FAMILY, 10),
                            command=_toggle_cluster_boxes))
        self.show_bboxes = tk.BooleanVar(value=False)
        add(tk.Checkbutton(self._toolbar_flow, text='BBoxes',
                            variable=self.show_bboxes,
                            bg=BG, fg=FG, selectcolor='#1a3a6a',
                            activebackground=BG, activeforeground=FG,
                            font=(FONT_FAMILY, 10),
                            command=self._render))
        # SHOW self-crossings: ring each part whose own
        # two flight lines cross (the rCrossings count) in magenta and dot the
        # crossing.  Default ON so any residual is immediately visible; most are
        # auto-fixed, so the overlay is usually empty.
        self.show_selfcross = tk.BooleanVar(value=True)
        add(tk.Checkbutton(self._toolbar_flow, text='Self-X',
                            variable=self.show_selfcross,
                            bg=BG, fg=FG, selectcolor='#1a3a6a',
                            activebackground=BG, activeforeground=FG,
                            font=(FONT_FAMILY, 10),
                            command=self._render))
        # Pin-role dots are always shown (black unknown, blue output, green
        # input); Ctrl+click a pin to cycle its role.
        self.show_pin_roles = True
        # Group boxes: outline each tight group in red with its group id.  Off
        # by default.
        self._show_group_boxes = tk.BooleanVar(value=False)
        add(tk.Checkbutton(self._toolbar_flow, text='Group boxes',
                            variable=self._show_group_boxes,
                            bg=BG, fg=FG, selectcolor='#1a3a6a',
                            activebackground=BG, activeforeground=FG,
                            font=(FONT_FAMILY, 10),
                            command=self._render))
        # Crossings: ring every counted flight-line crossing in orange.
        # The count in the status bar says HOW MANY; when the eye finds
        # fewer than that, the rings say WHERE, which is the only way to
        # tell a miscount from a crossing that is simply hard to see.
        # Sits next to Group boxes because both answer "show me what the
        # metric is talking about".  Default OFF.
        self._show_crossings = tk.BooleanVar(value=False)
        add(tk.Checkbutton(self._toolbar_flow, text='Crossings',
                            variable=self._show_crossings,
                            bg=BG, fg=FG, selectcolor='#1a3a6a',
                            activebackground=BG, activeforeground=FG,
                            font=(FONT_FAMILY, 10),
                            command=self._render))



        # VALUE-equation truncation toggle.  Applies to the
        # selected group if any, else to all instances.
        add(tk.Button(self._toolbar_flow, text='Full text',
                       command=self._toggle_fulltext,
                       bg='#4a7ab5', fg=FG, relief=tk.FLAT, padx=8))

        # Help button — pinned to the right of the LAST row, OUTSIDE
        # the flow.  Kept as a separate attribute so the flow algorithm
        # can position it explicitly.
        self._help_btn = tk.Button(
            self._toolbar_flow, text='Help',
            command=self._show_help_dialog,
            bg='#8a6a3a', fg=FG, relief=tk.FLAT,
            padx=12, font=(FONT_FAMILY, 10, 'bold'))

        # Trigger reflow whenever the toolbar's width changes.
        self._toolbar_flow.bind('<Configure>', self._reflow_toolbar)
        # Initial layout — schedule after_idle so reqwidth is settled.
        self.after_idle(self._reflow_toolbar)

        # Canvas + scrollbars
        frame = tk.Frame(self, bg='#e0e4ec')
        frame.pack(fill=tk.BOTH, expand=True)

        self.canvas = tk.Canvas(frame, bg=C_BG, highlightthickness=0)
        vsb = tk.Scrollbar(frame, orient=tk.VERTICAL, command=self.canvas.yview)
        hsb = tk.Scrollbar(self, orient=tk.HORIZONTAL,
                           command=self.canvas.xview)
        self.canvas.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
        hsb.pack(side=tk.BOTTOM, fill=tk.X)
        vsb.pack(side=tk.RIGHT, fill=tk.Y)
        self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

        self.canvas.bind('<MouseWheel>',   self._on_wheel)
        self.canvas.bind('<Button-4>',     self._on_wheel)
        self.canvas.bind('<Button-5>',     self._on_wheel)
        # Pan with middle-button drag
        self.canvas.bind('<ButtonPress-2>',   self._pan_start)
        self.canvas.bind('<B2-Motion>',        self._pan_move)

        # ── Rev 33: interactive Place & Route bindings ─────────────────
        # Left-button: click → start drag / select segment / start wire,
        # depending on what's under the cursor (see _on_canvas_click).
        self.canvas.bind('<ButtonPress-1>',    self._on_canvas_click)
        self.canvas.bind('<B1-Motion>',         self._on_canvas_drag)
        self.canvas.bind('<ButtonRelease-1>',   self._on_canvas_release)
        # Free motion (without a button held) drives the rubber-band
        # preview when a wire is being drawn.
        self.canvas.bind('<Motion>',            self._on_canvas_motion)
        # Double-click commits the wire-in-progress.
        self.canvas.bind('<Double-Button-1>',   self._on_canvas_double)
        # Right-click cancels the wire-in-progress (in addition to Esc).
        self.canvas.bind('<ButtonPress-3>',     self._on_canvas_right)
        # No <Double-Button-3> binding: _try_cycle_arrow_under detects the
        # double right-click itself from event times on the same edge.
        self.canvas.bind('<Shift-ButtonPress-3>', self._on_canvas_shift_right)
        # Ctrl+click a pin to cycle its driver/receiver
        # role override (auto->in->out->auto).  Free gesture: plain click is
        # select/drag, right-click rotates, shift-right-click flips.
        self.canvas.bind('<Control-ButtonPress-1>', self._on_canvas_ctrl_click)

        # Keyboard bindings on the top-level so they fire regardless of
        # focus.  Tk Canvases can't take focus by default, so this is
        # the most reliable place.
        self.bind('<Escape>',          self._on_key_escape)
        self.bind('<Delete>',          self._on_key_delete)
        self.bind('<BackSpace>',       self._on_key_delete)
        self.bind('<Return>',          self._on_key_return)

        # Status bar
        self.status = tk.Label(self, text='', anchor=tk.W,
                               bg='#c8ccd8', fg='#333', padx=6)
        self.status.pack(fill=tk.X, side=tk.BOTTOM)

    def _on_wheel(self, event):
        if event.num == 4:
            self.canvas.yview_scroll(-3, 'units')
        elif event.num == 5:
            self.canvas.yview_scroll(3, 'units')
        else:
            self.canvas.yview_scroll(-1*(event.delta//120), 'units')

    def _pan_start(self, event):
        self.canvas.scan_mark(event.x, event.y)

    def _pan_move(self, event):
        self.canvas.scan_dragto(event.x, event.y, gain=1)

    # ── Rev 40: SUBCKT picker helpers ─────────────────────────────────────

    def _subckt_choice_list(self):
        """Return the labels shown in the SUBCKT dropdown.  An extra
        '(top-level)' entry is included whenever the deck has top-level
        components, so the user can switch back to them after browsing
        into a subckt."""
        labels = []
        if hasattr(self, '_parser') and self._parser:
            # Anything with top-level components keeps the '(top-level)'
            # option even when one of those components is an X-line
            # whose subckt is in the dropdown.
            has_top_level = any(
                c['kind'] != 'X'
                or self._parser.subckts.get(c['value'].upper()) is None
                for c in self._parser.components
            )
            if has_top_level:
                labels.append('(top-level)')
            labels.extend(sorted(self._parser.subckts.keys()))
        return labels

    def _on_subckt_pick(self, choice):
        """User picked an entry from the SUBCKT dropdown.  Re-expand
        and re-render.  Wipes the P&R state for the previous view
        because the component refs change."""
        if not hasattr(self, '_parser') or not self._parser:
            return
        if choice == '(top-level)':
            self.components = list(self._parser.components)
            self._active_subckt = None
        else:
            name = choice.upper()
            if name not in self._parser.subckts:
                self.status.config(text=f'No such SUBCKT: {choice}')
                return
            self.components = self._parser.expand_subckt(name)
            self._active_subckt = name
        self.drawable = [c for c in self.components
                         if c['kind'] in SPICE_TO_SYM or c['kind'] == 'X']
        # Rebuild the net display table for the
        # newly-chosen SUBCKT's own nets (see _build_net_disp_table).
        self._build_net_disp_table()
        self._user_positions = {}
        self._user_rotations = {}
        self._auto_rotations = {}
        self._orient_class = {}            # 'H'/'V'
        self._user_flips = {}; self._auto_flips = {}
        self._port_side_locked = set()
        self._t_net_rot_overrides = {}     # Net names differ per subckt
        self._rail_polarity_overrides = {}  # Same reasoning
        self._neg_power_nets = set()        # -power
        self._wires = []
        self._selected_segments = set()
        self._selected_group = set()
        self._selected_t_ids = set()
        self._placed_ref_pos = None
        self._pin_flight_data = None
        self._placed_order = None
        self._t_terminals = []
        self._pin_to_t = {}
        self._boxes = []
        self._placement_boxes_refs = None
        self._net_labels = {}
        self._label_drag = None
        self._fulltext_overrides = {}
        self.status.config(
            text=f'Showing {choice}: {len(self.drawable)} components')
        self._render()

    # ── Render ────────────────────────────────────────────────────────────

    def _reresolve_value_texts(self, instances, skip_refs=None):
        """After positioning, move any value label that overlaps a different
        instance's body to a clear side; place_texts saw only the part's own
        obstacles.
        """
        conflicts = []
        body_of = {}
        for inst in instances:
            b = inst.abs_sym_body()
            body_of[id(inst)] = (b[0], b[1], b[2], b[3])

        # T-symbol boxes + which refs legitimately
        # own each one (excluded from that T's obstacle set), built once.
        own_of_t = {}
        for (_ref, _pin), _tid in (getattr(self, '_pin_to_t', {})
                                   or {}).items():
            own_of_t.setdefault(_tid, set()).add(_ref)
        t_boxes = []
        for _t in (self._t_terminals or []):
            _tb = self._t_full_extent(_t)
            if _tb:
                t_boxes.append((_tb, own_of_t.get(_t['id'], set())))

        def hits_t(inst, bb_abs):
            ref = inst.comp['ref']
            for tb, owners in t_boxes:
                if ref in owners:
                    continue
                if (bb_abs[0] < tb[2] and bb_abs[2] > tb[0]
                        and bb_abs[1] < tb[3] and bb_abs[3] > tb[1]):
                    return True
            return False

        # Stub boxes of every instance, this one included, in absolute
        # coordinates: place_texts checks only its own part, so neighbours must
        # be checked here.
        stub_boxes_all = [sb for inst in instances
                          for sb in _instance_stub_boxes(inst)]

        def hits_stub(bb_abs):
            for sb in stub_boxes_all:
                if (bb_abs[0] < sb[2] and bb_abs[2] > sb[0]
                        and bb_abs[1] < sb[3] and bb_abs[3] > sb[1]):
                    return True
            return False

        # also collect each instance's PLACED value/ref label
        # boxes, so a value can be moved off a neighbour's LABEL (not only its
        # body).  The DP/RP case: RP's value '30.31E3' cleared all bodies on
        # its 'e' side but landed on DP's 'DX' value label; without label
        # obstacles reresolve picked 'e' (earlier in the list) over a clear
        # 's'/'n'.  Labels recomputed fresh below as placements change.
        def label_boxes(except_inst):
            boxes = []
            for other in instances:
                if other is except_inst:
                    continue
                for it in other.text_items:
                    if it['placed'] is None:
                        continue
                    orx, ory, oanc, ofs, oint = it['placed']
                    lb = _text_bbox_from_anchor(orx, ory, it['text'],
                                                oanc, ofs)
                    boxes.append(_translate_bb(lb, other.ox_px, other.oy_px))
            return boxes

        def hits_other_body(inst, bb_abs):
            for other in instances:
                if other is inst:
                    continue
                ob = body_of[id(other)]
                if (bb_abs[0] < ob[2] and bb_abs[2] > ob[0]
                        and bb_abs[1] < ob[3] and bb_abs[3] > ob[1]):
                    return True
            return False

        def hits_other(inst, bb_abs):
            if hits_other_body(inst, bb_abs):
                return True
            for lb in label_boxes(inst):
                if (bb_abs[0] < lb[2] and bb_abs[2] > lb[0]
                        and bb_abs[1] < lb[3] and bb_abs[3] > lb[1]):
                    return True
            if hits_stub(bb_abs):
                return True
            return hits_t(inst, bb_abs)

        for inst in instances:
            # skip refs whose value label was already
            # resolved at placement time (step 3a) and re-applied by the
            # render (step 3b).  Re-resolving them here is redundant work;
            # only the grid-fallback parts (not in skip_refs) still need it.
            if skip_refs and inst.comp['ref'] in skip_refs:
                continue
            prefer = getattr(inst, '_value_text_prefer', None)
            # Recheck REF labels as well as VALUE against real neighbours; REF
            # used to be placed once, against its own part only.
            for item in inst.text_items:
                if (item['kind'] not in ('value', 'ref')
                        or item['placed'] is None):
                    continue
                rx, ry, anchor, fs, is_interior = item['placed']
                if is_interior:
                    continue
                bb_rel = _text_bbox_from_anchor(rx, ry, item['text'],
                                                anchor, fs)
                bb_abs = _translate_bb(bb_rel, inst.ox_px, inst.oy_px)
                if not hits_other(inst, bb_abs):
                    continue        # current placement is already clear
                # also avoid this part's OWN other label (value<->ref),
                # so a label relocated to clear a neighbour doesn't land
                # on its own sibling label (the RP case: value moved
                # down onto 'RP').
                other_kind = 'ref' if item['kind'] == 'value' else 'value'
                own_ref_box = None
                for it2 in inst.text_items:
                    if it2['kind'] == other_kind and it2['placed'] is not None:
                        orx, ory, oanc, ofs, oint = it2['placed']
                        rb = _text_bbox_from_anchor(orx, ory, it2['text'],
                                                    oanc, ofs)
                        own_ref_box = _translate_bb(rb, inst.ox_px, inst.oy_px)
                        break

                def clears(cbb):
                    if hits_other(inst, cbb):
                        return False
                    if own_ref_box is not None:
                        if (cbb[0] < own_ref_box[2] and cbb[2] > own_ref_box[0]
                                and cbb[1] < own_ref_box[3]
                                and cbb[3] > own_ref_box[1]):
                            return False
                    return True

                cands = list(item['candidates'])
                if item['kind'] == 'value' and prefer in ('left', 'right'):
                    want = 'e' if prefer == 'left' else 'w'
                    cands.sort(key=lambda c: 0 if (len(c) >= 3
                                                   and c[2] == want) else 1)
                found = False
                for cand in cands:
                    if len(cand) == 3:
                        crx, cry, canchor = cand
                        cfs = item['font_size']; cint = False
                    elif len(cand) == 5:
                        crx, cry, canchor, cfs, cint = cand
                    else:
                        continue
                    if cint:
                        continue
                    cbb_rel = _text_bbox_from_anchor(crx, cry, item['text'],
                                                     canchor, cfs)
                    cbb_abs = _translate_bb(cbb_rel, inst.ox_px, inst.oy_px)
                    if clears(cbb_abs):
                        item['placed'] = (crx, cry, canchor, cfs, cint)
                        found = True
                        break
                if found:
                    continue
                # Stage 2: no candidate clears everything, so report the
                # conflict and do not move anything; a label pass must never
                # move bodies.
                conflicts.append(
                    (f"{inst.comp['ref']} label", bb_abs[0], bb_abs[1],
                     bb_abs[2] - bb_abs[0], bb_abs[3] - bb_abs[1]))
        self._label_conflicts = conflicts

    def _overlap_boxes(self, instances):
        """The labeled boxes used for overlap detection, shared
        by _composite_overlap_pairs (the metric) and _draw_overlap_markers
        (the debug ellipses) so both see identical geometry.  Returns
        (comp_boxes, t_boxes): comp_boxes=[(ref, box)] instance composites;
        t_boxes=[(label, box, owner_refs)] every T's full bbox + the refs it
        legitimately connects to (skipped in T-vs-instance overlap tests)."""
        comp_boxes = [(it.comp['ref'], it.abs_composite()) for it in instances]
        _own = {}
        for (_ref, _pin), _tid in (getattr(self, '_pin_to_t', {})
                                   or {}).items():
            _own.setdefault(_tid, set()).add(_ref)
        t_boxes = []
        for _t in (self._t_terminals or []):
            _tb = self._t_full_extent(_t)
            if _tb:
                t_boxes.append((f"T:{_t.get('net')}#{_t['id']}", _tb,
                                _own.get(_t['id'], set()),
                                self._t_label_bbox(_t)))
        return comp_boxes, t_boxes

    def _overlap_pairs_boxed(self, instances):
        """Every overlapping pair with its boxes, [(labelA, labelB, boxA,
        boxB)]: instance-instance, T-instance and T-T.
        """
        comp_boxes, t_boxes = self._overlap_boxes(instances)
        # _MIN_CLEARANCE, not strict overlap: adjacent is a defect too.
        # The docstring below still describes the strict `<` this used
        # to use — that reasoning ('a properly-placed T only TOUCHES its
        # owner at the boundary, so `<` will not flag it') was sound for
        # its time but is now handled properly: a T is CLEARED of its
        # own body by the same _MIN_CLEARANCE at placement time, so it
        # is never merely touching in the first place.
        def _ov(a, b):
            return self._boxes_clash(a, b)
        out = []
        for i in range(len(comp_boxes)):
            ra, ba = comp_boxes[i]
            for j in range(i + 1, len(comp_boxes)):
                rb, bb = comp_boxes[j]
                if _ov(ba, bb):
                    out.append((ra, rb, ba, bb))
        for tlabel, tb, owners, lbb in t_boxes:
            for r, cb in comp_boxes:
                if _ov(tb, cb):
                    out.append((tlabel, r, tb, cb))
        for _a in range(len(t_boxes)):
            la, ba, _oa, _la = t_boxes[_a]
            for _b in range(_a + 1, len(t_boxes)):
                lb, bb, _ob, _lb2 = t_boxes[_b]
                if _ov(ba, bb):
                    out.append((la, lb, ba, bb))
        return out


    def _composite_overlap_pairs(self, instances):
        """SINGLE source of truth for bbox overlaps,
        computed from the CURRENT instances handed in (never a cached render
        snapshot): instance composite boxes (body + placed text) plus every
        T's full bbox.  Both _render and _self_check call this on the SAME
        live instances, so the on-screen count matches the drawn geometry.
        Because these boxes ENCLOSE body and text, zero bbox overlaps == zero
        instance / T / text overlaps."""
        return [(a, b) for (a, b, _ba, _bb)
                in self._overlap_pairs_boxed(instances)]

    def _build_self_flight_edges(self, instances):
        """The id(inst) -> [(near_xy, far_xy), ...]
        edge-building step factored out of _self_crossing_data, so
        _uncross_final's rotation scoring can get a 2-pin instance's OWN
        flight segments regardless of whether they currently cross
        (_self_crossing_data only returns instances that DO cross —
        exactly the ones scoring needs to look at AFTER a candidate
        rotation has already fixed that)."""
        net_to_pins, _ = _build_pin_flight_data(instances)
        pin_to_t = getattr(self, '_pin_to_t', {}) or {}
        t_pos = {t['id']: (t['cx'], t['cy'])
                 for t in (self._t_terminals or [])}
        edges_of = {}                       # id(inst) -> [(pin_xy, far_xy), …]

        def pin_xy(inst, pn):
            return _pin_canvas_pos(inst, pn)

        for _nl, members in net_to_pins.items():
            keys, pts = [], []
            for mm, pn in members:
                if mm is None:
                    keys.append(None)
                    pts.append(pn)
                else:
                    tid = pin_to_t.get((mm.comp['ref'], pn))
                    if tid is not None and tid in t_pos:
                        edges_of.setdefault(id(mm), []).append(
                            (pin_xy(mm, pn), t_pos[tid]))
                        continue
                    keys.append((mm, pn))
                    pts.append(pin_xy(mm, pn))
            for i, j in _mst_edges_manhattan(pts):
                a, b = pts[i], pts[j]
                if keys[i] is not None:
                    edges_of.setdefault(id(keys[i][0]), []).append((a, b))
                if keys[j] is not None:
                    edges_of.setdefault(id(keys[j][0]), []).append((b, a))
        return edges_of

    def _self_crossing_data(self, instances):
        """In : instances.  Out: a list of (ref, inst, seg1, seg2,
        cross_pt) for each self-crossing 2-pin part, seg being
        ((x0,y0),(x1,y1)) and cross_pt (x,y) or None.  Built on the drawn
        pin-based, T-aware topology from LIVE pin positions, so it tracks
        manual and auto rotations.
        Deliberately stateless: _settle_and_rebuild_ts and _uncross_final
        call it repeatedly on the SAME list object while mutating
        rotations in between, and an identity-keyed cache handed every
        later call the first result (OPAx197 stuck at 3).  _render calls
        it once and gives the one list to both of its consumers."""
        edges_of = self._build_self_flight_edges(instances)
        out = []
        for inst in instances:
            pairs = getattr(inst, '_pin_net_pairs', None) or []
            if len(pairs) != 2:
                continue
            segs = edges_of.get(id(inst), [])
            if len(segs) != 2:
                continue
            s1, s2 = segs[0], segs[1]
            if _segments_intersect(s1[0], s1[1], s2[0], s2[1]):
                out.append((inst.comp['ref'], inst, s1, s2,
                            _seg_xpoint(s1[0], s1[1], s2[0], s2[1])))
        return out

    def _self_crossing_refs(self, instances):
        """Out: the refs of 2-pin instances whose own two flight lines
        cross — the rotation-FIXABLE ('r') crossing count.  A thin
        wrapper over _self_crossing_data, for the many call sites that
        need only the refs."""
        return [d[0] for d in self._self_crossing_data(instances)]

    def _draw_self_crossing_markers(self, instances, data=None):
        """SHOW each remaining self-crossing: ring the
        offending part in magenta, redraw its two crossing flight lines dashed,
        and dot the crossing point, so the user can SEE exactly which part to
        rotate (the toolbar already gives the count).  Drawn on top, gated by
        the 'Self-X' toggle.

        Accepts a precomputed `data` (from
        _self_crossing_data) so _render can pass the SAME list it gives the
        toolbar count, guaranteeing the overlay and the number can never
        disagree; falls back to computing fresh if called standalone."""
        MAG = '#d000d0'
        if data is None:
            data = self._self_crossing_data(instances)
        for _ref, inst, s1, s2, cp in data:
            bb = inst.abs_sym_body()
            pad = 6
            self.canvas.create_oval(bb[0] - pad, bb[1] - pad,
                                    bb[2] + pad, bb[3] + pad,
                                    outline=MAG, width=3)
            for s in (s1, s2):
                self.canvas.create_line(s[0][0], s[0][1], s[1][0], s[1][1],
                                        fill=MAG, width=2, dash=(5, 3))
            if cp:
                self.canvas.create_oval(cp[0] - 4, cp[1] - 4,
                                        cp[0] + 4, cp[1] + 4,
                                        fill=MAG, outline='')

    # pin-role color scheme, used by BOTH the always-on
    # override rings and the toggle-gated all-pins overlay: black = unknown
    # (unresolved/conflicted), blue = output (driver), green = input
    # (receiver).
    # green was hard to spot; brighter/more saturated.
    _PIN_ROLE_COLOR = {'out': '#1a5fd8', 'in': '#12c94a', None: '#000000'}

    def _build_equation_sense_maps(self, instances):
        """In : the instances.  Out: (net_sensors, ref_sensors,
        senses_ids) for the pin-role marker's equation exception —
          net_sensors[net_lc]  instances whose equation reads V(net_lc)
          ref_sensors[ref_lc]  instances reading I(ref_lc), or an F/H
                               source's positional sense-source name
          senses_ids           ids of every instance sensing externally.
        Built once per render from the same sense_nets / sense_srcs /
        _equation_signal_refs data behind the equation-dependency edges,
        so it agrees with what actually feeds Sugiyama."""
        net_sensors = {}
        ref_sensors = {}
        senses_ids = set()
        for inst in instances:
            comp = inst.comp
            v_nets = comp.get('sense_nets')
            if v_nets is None:
                v_nets, _i = _equation_signal_refs(comp)
            i_srcs = comp.get('sense_srcs')
            if i_srcs is None:
                _v, i_srcs = _equation_signal_refs(comp)
            for nl in (v_nets or ()):
                net_sensors.setdefault(str(nl).lower(), []).append(inst)
                senses_ids.add(id(inst))
            for rl in (i_srcs or ()):
                ref_sensors.setdefault(str(rl).lower(), []).append(inst)
                senses_ids.add(id(inst))
        return net_sensors, ref_sensors, senses_ids

    def _draw_pin_role_markers(self, instances):
        """Draw each pin's role as a dot (black unknown, blue output, green
        input); pins the user overrode with Ctrl+click get a larger ring.
        """
        role_map = getattr(self, '_pin_role_map', None) or {}
        overrides = self._pin_role_overrides
        show_all = bool(getattr(self, 'show_pin_roles', True))
        if not show_all and not overrides:
            self._pin_role_marker_hits = []
            return
        _net_sensors, _ref_sensors, _senses_ids = \
            self._build_equation_sense_maps(instances)
        # GCM report: Ctrl+click missed a pin whose dot
        # had been nudged (the label-avoidance nudge below) away from the
        # raw pin position — _pick_pin only tests proximity to the true
        # pin, so clicking the VISIBLE dot missed by exactly however far
        # it had moved.  Record each dot's actual drawn (ref, pin)
        # position here; _on_canvas_ctrl_click checks this list first,
        # before falling back to _pick_pin, so clicking whatever is
        # actually on screen works regardless of any relocation.
        self._pin_role_marker_hits = []

        def _body_of(inst):
            try:
                bb = inst.abs_sym_body()
                return bb, (bb[0] + bb[2]) / 2.0, (bb[1] + bb[3]) / 2.0
            except Exception:
                return None, None, None

        for inst in instances:
            ref = inst.comp['ref']
            cid = id(inst.comp)
            pairs = getattr(inst, '_pin_net_pairs', None) or []
            bb, bx, by = _body_of(inst)
            label_bbs = self._instance_label_bboxes(inst)
            for idx, (pn, nn) in enumerate(pairs):
                ov = overrides.get((ref, pn))
                if ov is None and not show_all:
                    continue
                role = ov if ov is not None else role_map.get((cid, idx))
                try:
                    mx, my = _pin_canvas_pos(inst, pn)
                except Exception:
                    continue
                # RP report: a pin's dot landed exactly
                # on its OWN ref/value label ("30.31E3"/"RP"), obscuring it.
                # Rather than move the label (a much bigger change to the
                # separate text-placement system), nudge the DOT outward
                # along the body-centre->pin direction just far enough to
                # clear whichever of THIS instance's own placed labels it
                # overlaps — stays at the exact pin whenever there's no
                # actual conflict, per the default rule.
                if bx is not None and label_bbs:
                    mx, my = self._clear_label_nudge(mx, my, bx, by,
                                                      label_bbs)
                self._pin_role_marker_hits.append((mx, my, ref, pn))
                self._draw_one_pin_role_dot(mx, my, role, bool(ov))

    def _instance_label_bboxes(self, inst):
        """Absolute (x0,y0,x1,y1) boxes for every
        PLACED, non-interior label (ref/value) on this instance, using the
        exact same rx,ry/anchor/font_size -> bbox computation the real
        collision-avoidance code uses (_text_bbox_from_anchor), so 'does the
        dot overlap the label' agrees with what is actually drawn."""
        out = []
        for ti in getattr(inst, 'text_items', None) or []:
            placed = ti.get('placed')
            if placed is None:
                continue
            rx, ry, anchor, fs, is_interior = placed
            if is_interior:
                continue
            bb_rel = _text_bbox_from_anchor(rx, ry, ti['text'], anchor, fs,
                                            bold=(ti['kind'] == 'ref'))
            out.append((inst.ox_px + bb_rel[0], inst.oy_px + bb_rel[1],
                       inst.ox_px + bb_rel[2], inst.oy_px + bb_rel[3]))
        return out

    def _clear_label_nudge(self, mx, my, bx, by, label_bbs, pad=4,
                            step=3, max_steps=6):
        """If (mx,my) (plus a small pad for the dot's
        own radius) falls inside any box in label_bbs, push it outward
        along the body-centre(bx,by)->(mx,my) direction in `step`-px
        increments (capped at max_steps) until clear, or give up and
        return the furthest tried position.  Pure nudge, no rotation/
        geometry side effects — this only affects where the OVERLAY dot is
        drawn."""
        def _hits(x, y):
            for (x0, y0, x1, y1) in label_bbs:
                if x0 - pad <= x <= x1 + pad and y0 - pad <= y <= y1 + pad:
                    return True
            return False

        if not _hits(mx, my):
            return mx, my
        dx, dy = mx - bx, my - by
        length = math.hypot(dx, dy)
        if length < 1e-6:
            return mx, my
        ux, uy = dx / length, dy / length
        x, y = mx, my
        for _ in range(max_steps):
            x += ux * step
            y += uy * step
            if not _hits(x, y):
                return x, y
        return x, y

    def _draw_one_pin_role_dot(self, x, y, role, is_override):
        """Factored single-dot draw (override ring vs
        plain dot), shared by the per-pin loop and the equation-sensor
        marker in _draw_pin_role_markers.  A white halo is drawn first so
        the dot stays readable against ANY background — a black flight
        line, dark text, or another instance's body outline."""
        color = self._PIN_ROLE_COLOR.get(role, '#000000')
        if is_override:
            r = 5
            self.canvas.create_oval(x - r - 2, y - r - 2, x + r + 2,
                                    y + r + 2, fill='white', outline='')
            self.canvas.create_oval(x - r, y - r, x + r, y + r,
                                    outline=color, width=2, fill='')
            self.canvas.create_oval(x - 2, y - 2, x + 2, y + 2,
                                    fill=color, outline='')
        else:
            r = 4
            self.canvas.create_oval(x - r - 1, y - r - 1, x + r + 1,
                                    y + r + 1, fill='white', outline='')
            self.canvas.create_oval(x - r, y - r, x + r, y + r,
                                    fill=color, outline='')

    def _reserved_box_overlap_pairs(self, instances):
        """Takes the instances and returns [(refA, refB, boxA, boxB)] for every
        pair whose RESERVED boxes clash -- the box Sugiyama is handed and BBoxes
        draws, a stricter question than the composite metric asks. Uses
        _boxes_clash, THE box test, so a pass that separates and a check that
        gates cannot disagree."""
        # A T-symbol can be SHARED by several instances (V-Share), and
        # every sharer legitimately reserves it.  Two sharers' boxes
        # therefore both contain that one T and clash on it — the same
        # object counted twice, not a collision.  Measured on LM324.sub:
        # t1103 is shared by C22/C23/Q2 and t1104 by C25/Q12/Q13/R68,
        # which produce exactly 3 + 6 = 9 of the 17 reported pairs, and
        # in every one of the nine the BODIES are nowhere near each
        # other.  So a pair that shares T's is re-measured with the
        # shared ones excluded from BOTH boxes: what is left is the
        # space each part needs that the other does not also own.
        t_owner = {}
        for (ref, _pn), tid in (getattr(self, '_pin_to_t', None) or {}).items():
            t_owner.setdefault(ref, set()).add(tid)

        inst_by_ref = {i.comp['ref']: i for i in (instances or ())}
        boxed = []
        for inst in instances or ():
            try:
                boxed.append((inst.comp['ref'], self._abs_reserved_box(inst)))
            except Exception:
                pass
        # A T THAT OWNS ITS BOX IS AN OBJECT IN THE CHECK, NOT A HALO
        # ON TWO OWNERS.  The shared-T exclusion below exists because a
        # T reserved by several instances is one object counted twice;
        # a T with its own box is reserved by NOBODY else, so it can and
        # must be clashed like any other rectangle -- against every
        # instance and against the other own-box T's.
        _t_box_owners = {}
        for _bx in self._t_own_boxes():
            _x0, _y0, _x1, _y1, _t = _bx
            _key = 'T:%s#%s' % (_t.get('net'), _t.get('id'))
            boxed.append((_key, (_x0, _y0, _x1, _y1)))
            # Its OWN pins' instances are not a collision: a T has to sit
            # next to the pins it serves, and how close is graded by the
            # T-vs-body metric and _t_route_defect, not here.  Anything
            # else touching it is a real clash.
            _t_box_owners[_key] = {r for (r, _p), _tid
                                   in (getattr(self, '_pin_to_t', None)
                                       or {}).items()
                                   if _tid == _t.get('id')}
        out = []
        for ia in range(len(boxed)):
            ra, ba = boxed[ia]
            for ib in range(ia + 1, len(boxed)):
                rb, bb = boxed[ib]
                if not self._boxes_clash(ba, bb):
                    continue
                if (rb in _t_box_owners.get(ra, ())
                        or ra in _t_box_owners.get(rb, ())):
                    continue
                shared = t_owner.get(ra, set()) & t_owner.get(rb, set())
                if shared:
                    ia_inst = inst_by_ref.get(ra)
                    ib_inst = inst_by_ref.get(rb)
                    if ia_inst is not None and ib_inst is not None:
                        try:
                            ba2 = self._abs_reserved_box(ia_inst, shared)
                            bb2 = self._abs_reserved_box(ib_inst, shared)
                        except Exception:
                            ba2 = bb2 = None
                        if ba2 is not None and not self._boxes_clash(ba2, bb2):
                            continue
                out.append((ra, rb, ba, bb))
        return out

    def _metric_instances(self, instances=None):
        """The instance list every layout metric measures: an explicit list
        wins, else the rendered copy, else the placed one.
        """
        if instances is not None:
            return instances
        return (getattr(self, '_cached_instances', None)
                or getattr(self, '_placed_instances', None)
                or [])

    def _all_overlap_pairs_boxed(self, instances=None):
        """Every overlap the program detects, as [(labelA, labelB, boxA, boxB)],
        on the rendered instances by default: the single source of the
        overlap count and rings.
        """
        instances = self._metric_instances(instances)
        rows = list(self._overlap_pairs_boxed(instances))
        # RESERVED-box (blue BBoxes overlay) overlaps.  _placement_extent
        # is what Sugiyama is handed and what the overlay draws, but the
        # composite metric above measures a strictly smaller box, so two
        # blue boxes could plainly overlap on screen with the top bar
        # reporting nothing — the user's LM324.sub Q1/Q2 and C18/Q13 and
        # LP2951's U3_C1/U3.R1 were all this.  Measure the box we draw.
        for ra, rb, ba, bb in self._reserved_box_overlap_pairs(instances):
            rows.append((f'{ra} (bbox)', f'{rb} (bbox)', ba, bb))
        try:
            tb = self._t_body_overlap_pairs(instances)
        except Exception:
            tb = []
        for r in tb:
            bx = r.get('boxes')
            # Owner hits are ringed too: the detector now measures real overlap,
            # so a correctly placed own-T no longer trips it.
            if not bx:
                continue
            rows.append((f"T:{r['net']}#{r['t_id']}",
                         f"{r['ref']} ({r['kind']})", bx[0], bx[1]))
        seen = set()
        out = []
        for la, lb, ba, bb in rows:
            key = (la, lb, tuple(round(v / 4.0) for v in ba),
                   tuple(round(v / 4.0) for v in bb))
            if key in seen:
                continue
            seen.add(key)
            out.append((la, lb, ba, bb))
        return out

    def _draw_overlap_markers(self, instances):
        """Ring every overlap the metric reports with a solid RED ellipse
        around the UNION of the two colliding boxes (so it clearly circles
        BOTH offenders), letting the user see WHERE a collision is and check
        it against the GREEN (reserved) / RED (actual tk) text bbox overlay.

        Reads _all_overlap_pairs_boxed, so the rings cover the T-on-body
        and T-on-text collisions too, not just the composite ones —
        every overlap the top bar counts now has a circle."""
        for _la, _lb, ba, bb in self._all_overlap_pairs_boxed(instances):
            ux0 = min(ba[0], bb[0]); uy0 = min(ba[1], bb[1])
            ux1 = max(ba[2], bb[2]); uy1 = max(ba[3], bb[3])
            pad = 4
            self.canvas.create_oval(ux0 - pad, uy0 - pad, ux1 + pad, uy1 + pad,
                                    outline='#ff0000', width=3)

    def _pair_net_lengths(self, instances):
        """In : the placed instances.
        Proc: for every ordinary net joining EXACTLY TWO instances — not
              a rail, not consumed by a T-symbol — measure the distance
              between the two pins on it.
        Out : [(px, net, refA, refB), ...] longest first.
        The sharpest metric the harness has: a two-instance net is a wire
        with nowhere else to go, so its length says plainly whether the
        placer put the two parts together.  Total wire length can hide
        that behind fan-out; this cannot."""
        tnets = {str(t.get('net')).lower() for t in (self._t_terminals or [])}
        tnets |= {str(n).lower()
                  for n in (getattr(self, '_supply_rails', set()) or set())}
        tnets |= set(_PWR_NETS_LC_FOR_T)
        pins = defaultdict(list)
        for inst in instances:
            for pn, nn in (getattr(inst, '_pin_net_pairs', None) or []):
                nl = str(nn).lower()
                if nl not in tnets:
                    pins[nl].append((inst, pn))
        out = []
        for nl, lst in pins.items():
            refs = {i.comp['ref'] for i, _p in lst}
            if len(refs) != 2:
                continue
            best = None
            for ai, (ia, pa) in enumerate(lst):
                for ib, pb in lst[ai + 1:]:
                    if ia.comp['ref'] == ib.comp['ref']:
                        continue
                    p1 = _pin_canvas_pos(ia, pa)
                    p2 = _pin_canvas_pos(ib, pb)
                    if not p1 or not p2:
                        continue
                    d = math.hypot(p1[0] - p2[0], p1[1] - p2[1])
                    if best is None or d < best[0]:
                        best = (d, nl, ia.comp['ref'], ib.comp['ref'])
            if best:
                out.append(best)
        # LEAF PINS ON A MULTI-PIN NET, at a fraction of the weight.
        # The user's extension: a part with only ONE flight line into a
        # net still has to reach it, and if the rest of that net is a
        # crowd the placer has no pair to keep together -- LM324.lib's
        # EGND to REE and LM324.sub's R44 to Q8 are both this shape.
        # They are counted at _leaf_pin_weight because the placer has
        # less freedom here than with a true two-part net: the leaf must
        # reach SOMEWHERE on the net, not a specific partner.
        wt = float(0.5)
        for nl, lst in pins.items():
            refs = {i.comp['ref'] for i, _p in lst}
            if len(refs) < 3:
                continue
            for inst, pn in lst:
                r = inst.comp['ref']
                own = [n for _p2, n in
                       (getattr(inst, '_pin_net_pairs', None) or [])
                       if str(n).lower() not in tnets]
                if len(own) != 1:
                    continue           # not a leaf on the drawn graph
                p1 = _pin_canvas_pos(inst, pn)
                if not p1:
                    continue
                near = None
                for jnst, pj in lst:
                    if jnst.comp['ref'] == r:
                        continue
                    p2 = _pin_canvas_pos(jnst, pj)
                    if not p2:
                        continue
                    d = math.hypot(p1[0] - p2[0], p1[1] - p2[1])
                    if near is None or d < near[0]:
                        near = (d, nl, r, jnst.comp['ref'])
                if near:
                    out.append((near[0] * wt, near[1] + '*', near[2],
                                near[3]))
        out.sort(reverse=True)
        return out

    def _wire_length_report(self, insts, top=8):
        """In : the placed instances and how many long lines to list.
        Proc: Manhattan length of every drawn BLUE wire
              (_flight_segments) and PURPLE sense line (_sense_segments).
        Out : {'blue', 'n_blue', 'purple', 'n_purple', 'longest_blue',
              'longest_purple'}; each longest list is
              [(px, 'B'|'P', net, refA, refB)], `top` long, longest first.
        The measure a hand layout is graded by.  Both colours come from
        the routines the renderer draws with, so the number describes the
        picture on screen; Manhattan, because a reader traces a line by
        its horizontal and vertical travel."""
        rows = []
        blue = 0.0
        segs = self._flight_segments(insts)
        for a, b, ra, _pa, rb, _pb, nn in segs:
            d = abs(b[0] - a[0]) + abs(b[1] - a[1])
            blue += d
            rows.append((d, 'B', str(nn), ra or 'hub', rb))
        purple = 0.0
        sense = self._sense_segments(insts)
        for s in sense:
            a, b = s[5], s[6]
            d = abs(b[0] - a[0]) + abs(b[1] - a[1])
            purple += d
            rows.append((d, 'P', str(s[3] or s[4]), s[1].comp['ref'],
                         s[2].comp['ref']))
        rows.sort(key=lambda r: -r[0])
        return {'blue': blue, 'n_blue': len(segs), 'purple': purple,
                'n_purple': len(sense),
                'longest_blue': [r for r in rows if r[1] == 'B'][:top],
                'longest_purple': [r for r in rows if r[1] == 'P'][:top]}

    def _flight_segments(self, insts):
        """In : placed instances.  Out: a list of
        (a_xy, b_xy, ref_a, pin_a, ref_b, pin_b, net) — the flight-line
        segments the renderer actually draws.  On a hub spoke ref_a and
        pin_a are None and a_xy is the hub.
        Mirrors _draw_pin_flight_lines: a pin consumed by a T-symbol
        (_pin_to_t) contributes no segment, so a part whose only far net
        is a rail is not reported as remote; a net of 2..8 pins is joined
        by a Manhattan MST; a net over 8 gets a hub at the pin centroid
        with one spoke per pin.  Every consumer of drawn connections
        calls this, so the counts all describe the same picture."""
        p2t = getattr(self, '_pin_to_t', None) or {}
        net_inst = defaultdict(list)
        for inst in insts:
            seen = set()
            for pn, nn in (inst._pin_net_pairs or []):
                snn = str(nn)
                if snn in seen:
                    continue
                seen.add(snn)
                if (inst.comp['ref'], pn) in p2t:
                    continue
                net_inst[snn].append((inst, pn))
        segs = []
        wgroups = self._wire_pin_groups()
        for nn, members in net_inst.items():
            n = len(members)
            if n < 2:
                continue
            pin_xy = [_pin_canvas_pos(m, p) for m, p in members]
            gk = self._wire_groups_for(members, wgroups)
            if n <= 8:
                for ia, ib in _mst_edges_manhattan(pin_xy, gk):
                    segs.append((pin_xy[ia], pin_xy[ib],
                                 members[ia][0].comp['ref'], members[ia][1],
                                 members[ib][0].comp['ref'], members[ib][1],
                                 nn))
            else:
                hx = sum(c[0] for c in pin_xy) / n
                hy = sum(c[1] for c in pin_xy) / n
                # One spoke per wired group, from its pin nearest the hub.
                spoke = {}
                for k, g in enumerate(gk):
                    g = g if g is not None else ('_solo', k)
                    d = abs(pin_xy[k][0] - hx) + abs(pin_xy[k][1] - hy)
                    if g not in spoke or d < spoke[g][0]:
                        spoke[g] = (d, k)
                if len(spoke) < 2:
                    continue
                for _d, k in sorted(spoke.values(), key=lambda v: v[1]):
                    m, p = members[k]
                    segs.append(((hx, hy), pin_xy[k], None, None,
                                 m.comp['ref'], p, nn))
        return segs

    def _flight_crossing_count(self, insts):
        """Takes the instances and returns (crossings, n_segs): the crossing
        tally alone, so a rotation search loop does not pay for the overlap scan
        a full _self_check would run on every trial."""
        pairs, n_segs = self._flight_crossing_pairs(insts)
        return len(pairs), n_segs

    def _flight_crossing_pairs(self, insts):
        """In : the instances to measure.
        Proc: build the flight segments, then find every properly
              crossing pair that is not two segments of the same block.
        Out : ([(a, b, c, d), ...], n_segs) — the two segments of each
              counted crossing, and how many segments were scanned.

        THE scan.  _flight_crossing_count wants the tally and the
        Crossings overlay wants the places; both come from here, so the
        rings and the number cannot describe different sets.
        """
        nmap = {}
        bid = 0
        for members in _stable_blocks(getattr(self, '_sp_block_layout', {})):
            ms = set(members)
            if len(ms) >= 2:
                for r in ms:
                    nmap[r] = ('blk', bid)
                bid += 1
        for inst in insts:
            nmap.setdefault(inst.comp['ref'], ('loose', inst.comp['ref']))
        segs = []
        for a, b, ra, _pa, rb, _pb, _nn in self._flight_segments(insts):
            if ra is None:          # hub spoke — one real end only
                segs.append((a, b, frozenset({nmap.get(rb, rb)})))
            else:
                segs.append((a, b, frozenset({nmap.get(ra, ra),
                                              nmap.get(rb, rb)})))
        out = []
        for i in range(len(segs)):
            a, b, pair_i = segs[i]
            for j in range(i + 1, len(segs)):
                c, d, pair_j = segs[j]
                if a in (c, d) or b in (c, d):
                    continue
                if _seg_cross(a, b, c, d) and not (
                        pair_i == pair_j and len(pair_i) == 2):
                    out.append((a, b, c, d))
        return out, len(segs)

    def _flight_crossing_points(self, insts):
        """Takes the instances and returns [(x, y)] for every crossing
        _flight_crossing_count counts, so the Crossings rings and the status-
        bar number always describe the same set."""
        pts = []
        for a, b, c, d in self._flight_crossing_pairs(insts)[0]:
            p = _seg_cross_point(a, b, c, d)
            if p is not None:
                pts.append(p)
        return pts

    def _placement_slack_report(self, insts=None, top=5):
        """Rank placed items by recoverable flight-line length: how much shorter
        the drawing gets if this item alone sat where its own connections
        pull it.  Returns the report rows.
        """
        insts = (insts if insts is not None
                 else (self._placed_instances or []))
        if not insts:
            return []
        # ref -> (rank, cluster) via the unit that owns it
        rank_of = {}
        for ci, info in enumerate(getattr(self, '_dbg_lane_info', []) or []):
            ranks = info.get('ranks') or {}
            for u, mem in enumerate(info.get('unit_member_refs') or []):
                for r in mem:
                    rank_of[r] = (ranks.get(u), ci)
        # pin -> its shortest drawn segment
        best = {}          # (ref, pin) -> (dist, other_xy, other_ref, net)
        for a, b, ra, pa, rb, pb, nn in self._flight_segments(insts):
            d = math.hypot(a[0] - b[0], a[1] - b[1])
            for me_r, me_p, oxy, other_r in ((ra, pa, b, rb),
                                             (rb, pb, a, ra)):
                if me_r is None:
                    continue
                k = (me_r, me_p)
                if k not in best or d < best[k][0]:
                    best[k] = (d, oxy, other_r, nn)
        rows = []
        for inst in insts:
            pairs = inst._pin_net_pairs or []
            if len(pairs) != 2:
                continue
            ref = inst.comp['ref']
            conn = [(pn,) + best[(ref, pn)]
                    for pn, _nn in pairs if (ref, pn) in best]
            if not conn:
                continue          # both pins on T's; nothing drawn
            rk, cl = rank_of.get(ref, (None, None))
            if len(conn) == 1:
                pn, d, _oxy, other, net = conn[0]
                rows.append({
                    'ref': ref, 'kind': inst.comp.get('kind') or '?',
                    'mode': 'part/1', 'slack': d, 'cur': d, 'ideal': 0.0,
                    'rank': rk, 'cluster': cl, 'move': None,
                    'conns': [(pn, other, net, d,
                               rank_of.get(other, (None, None))[0])],
                })
                continue
            (p1, d1, A, ra_, na), (p2, d2, B, rb_, nb) = sorted(conn)
            P1 = _pin_canvas_pos(inst, p1)
            P2 = _pin_canvas_pos(inst, p2)
            cx, cy = (P1[0] + P2[0]) / 2.0, (P1[1] + P2[1]) / 2.0
            mx, my = (A[0] + B[0]) / 2.0, (B[1] + A[1]) / 2.0
            # rigid pin offsets, carried to the reference position
            o1 = (P1[0] - cx, P1[1] - cy)
            o2 = (P2[0] - cx, P2[1] - cy)
            ideal = (math.hypot(mx + o1[0] - A[0], my + o1[1] - A[1])
                     + math.hypot(mx + o2[0] - B[0], my + o2[1] - B[1]))
            cur = d1 + d2
            rows.append({
                'ref': ref, 'kind': inst.comp.get('kind') or '?',
                'mode': 'part/2', 'slack': cur - ideal,
                'cur': cur, 'ideal': ideal, 'rank': rk, 'cluster': cl,
                'move': (mx - cx, my - cy),
                'conns': [(p1, ra_, na, d1,
                           rank_of.get(ra_, (None, None))[0]),
                          (p2, rb_, nb, d2,
                           rank_of.get(rb_, (None, None))[0])],
            })
        # ── T-symbols
        by_ref = {i.comp['ref']: i for i in insts}
        owners = defaultdict(list)
        for (r, pn), tid in sorted((getattr(self, '_pin_to_t', None)
                                    or {}).items()):
            owners[tid].append((r, pn))
        for t in sorted((getattr(self, '_t_terminals', None) or []),
                        key=lambda t: t.get('id', 0)):
            pins = owners.get(t.get('id'), [])
            pts, cns = [], []
            for r, pn in pins:
                inst = by_ref.get(r)
                if inst is None:
                    continue
                xy = _pin_canvas_pos(inst, pn)
                pts.append(xy)
                cns.append((pn, r, t.get('net'), None,
                            rank_of.get(r, (None, None))[0], xy))
            if not pts:
                continue
            tx, ty = float(t['cx']), float(t['cy'])
            cur = sum(math.hypot(px - tx, py - ty) for px, py in pts)
            gx, gy = _geometric_median(pts)
            ideal = sum(math.hypot(px - gx, py - gy) for px, py in pts)
            cns = [(pn, r, net, math.hypot(xy[0] - tx, xy[1] - ty), rk)
                   for (pn, r, net, _d, rk, xy) in cns]
            rows.append({
                'ref': 'T[%s]#%s' % (t.get('net'), t.get('id')),
                'kind': 'T', 'mode': 'T/%d' % len(pts),
                'slack': cur - ideal, 'cur': cur, 'ideal': ideal,
                'rank': None, 'cluster': None,
                # Mean drawn length per served pin: total flight-line length to
                # the n pins divided by n; used with slack to decide whether to
                # split a T.
                'per_pin': cur / len(pts),
                'ideal_per_pin': ideal / len(pts),
                'move': (gx - tx, gy - ty), 'conns': cns,
            })
        rows.sort(key=lambda r: -r['slack'])
        return rows[:top]

    def _print_placement_slack_report(self, insts=None, top=5):
        """Print _placement_slack_report to stdout.  Split from the
        computation so the numbers stay usable from a harness or a
        headless probe without capturing stdout."""
        rows = self._placement_slack_report(insts, top)
        print('\n\u2500\u2500 %d items with the most RECOVERABLE flight-line '
              'length \u2500\u2500' % len(rows))
        if not rows:
            print('   (none)')
            return rows
        for n, r in enumerate(rows, 1):
            print('  %d. %-20s %-3s %-6s  slack %7.0f px   '
                  '(now %.0f, ideal %.0f)%s'
                  % (n, r['ref'][:20], r['kind'], r['mode'],
                     r['slack'], r['cur'], r['ideal'],
                     '' if 'per_pin' not in r else
                     '   per-pin %.0f -> %.0f'
                     % (r['per_pin'], r['ideal_per_pin'])))
            if r['move']:
                dx, dy = r['move']
                print('     move %s %.0f, %s %.0f%s'
                      % ('right' if dx >= 0 else 'left', abs(dx),
                         'down' if dy >= 0 else 'up', abs(dy),
                         '' if r['rank'] is None
                         else '   (rank %s, cluster %s)'
                         % (r['rank'], r['cluster'])))
            elif r['rank'] is not None:
                print('     rank %-4s (cluster %s)   whole line is '
                      'recoverable' % (r['rank'], r['cluster']))
            for pn, other, net, dist, orank in r['conns']:
                print('     pin %-3s -> %-20s rank %-4s on net %-12s '
                      '%6.0f px'
                      % (pn, str(other)[:20], orank, str(net)[:12], dist))
        print('  total recoverable in this top %d: %.0f px'
              % (len(rows), sum(r['slack'] for r in rows)))
        return rows

    def _self_check(self, instances=None, selfx_data=None):
        """In : optional live `instances` and `selfx_data`.  Out: a report
        dict, also stored on self._last_self_check.
        Combines the two metrics that grade a placement: composite
        OVERLAPS, recomputed live by _composite_overlap_pairs over the
        body+label boxes and every T's full bbox, and flight-line
        CROSSINGS, computed from the instance and pin positions.
        Callers pass their own instances (as _render does) because
        self._placed_instances holds PLACEMENT's positions, and reading
        it made the crossing count ignore manual moves."""
        # ONE resolution of "which instances?", at the top, via the
        # shared _metric_instances helper — every metric below then
        # measures the same list.  This method used to re-derive it
        # four separate times as `instances if instances is not None
        # else self._placed_instances`, which both invited the four to
        # drift apart and hard-wired PLACEMENT's copy as the default;
        # with a render on screen the RENDERED copy is what the user
        # sees, and the two can disagree (see _metric_instances).
        insts = _ilist = instances = self._metric_instances(instances)
        # recompute overlaps from the CURRENT instances (not
        # the stale _last_render_overlaps cache), so the metric reflects the
        # geometry that is actually placed/drawn.
        rovl = self._composite_overlap_pairs(insts)

        # Count crossings on the flight lines the renderer draws for a placed
        # circuit (T-aware, pin-to-pin Manhattan MST), not the pre-placement
        # center lines.
        other, n_segs = self._flight_crossing_count(insts)
        ab = 0
        rep = {'overlaps': len(rovl), 'overlap_pairs': rovl,
               'cross_ab': ab, 'cross_other': other, 'n_segs': n_segs}
        # The toolbar rCrossings count is pin-based: a part's own two flight
        # lines crossing each other.
        if selfx_data is not None:
            rself = [d[0] for d in selfx_data]
        else:
            rself = self._self_crossing_refs(_ilist)
        rep['cross_ab'] = len(rself)
        rep['rcross_refs'] = rself
        # also report T-symbol / T-label overlaps with
        # instance bodies, which the composite metric above intentionally skips
        # for a T against its own owner (E2's T on its diamond, a ground-T on
        # GA's body, …).
        tbody = self._t_body_overlap_pairs(_ilist)
        rep['tbody_overlaps'] = len(tbody)
        rep['tbody_pairs'] = tbody
        # Reserved-box (blue-overlay) clashes, kept as their own number
        # so callers can gate on it: it is the one overlap class the
        # user can see directly, and the composite count does not
        # contain it.
        rep['reserved_pairs'] = self._reserved_box_overlap_pairs(_ilist)
        rep['reserved_overlaps'] = len(rep['reserved_pairs'])
        # The number the toolbar leads with: EVERY overlap, from the one
        # detector that knows about all of them.  'overlaps' and
        # 'tbody_overlaps' each see only their own half and each miss
        # collisions the user can plainly see, so neither alone is "the"
        # overlap count; this is, and it is the same list the red rings
        # are drawn from.
        # Flight-line length from each T to its pin (the 2 px rule).
        # Reported alongside the overlap counts because it is the other
        # half of "is this T placed correctly": a T can be clear of
        # everything and still be wrong if it is jammed onto its own pin.
        rep['t_pin_gap_bugs'] = self._t_pin_gap_violations(_ilist)
        rep['t_pin_gaps'] = len(rep['t_pin_gap_bugs'])
        rep['all_overlap_pairs'] = self._all_overlap_pairs_boxed(_ilist)
        rep['all_overlaps'] = len(rep['all_overlap_pairs'])
        self._last_self_check = rep
        return rep

    def _render(self, *_):
        # Save the viewport in canvas coordinates, not scroll fractions: the
        # scroll region changes on redraw and a fraction would recenter the
        # view.
        try:
            _prev_view_x = self.canvas.canvasx(0)
            _prev_view_y = self.canvas.canvasy(0)
            _osr = [float(v) for v in
                    self.canvas.cget('scrollregion').split()] or [0.0, 0.0]
            _at_left = _prev_view_x <= _osr[0] + 0.5
            _at_top = _prev_view_y <= _osr[1] + 0.5
        except (tk.TclError, ValueError):
            _prev_view_x = _prev_view_y = None
            _at_left = _at_top = False
        self.canvas.delete('all')
        # guard against the pre-placement "0,0 flash".
        # Render is draw-only and seeds positions from placement; before the
        # FIRST _run_placement there are no positions, so drawing self.drawable
        # would pile every instance at the canvas origin.  _initial_place_done
        # is set True the first time _run_placement runs; until then (and only
        # then — the Grid button legitimately clears _placed_ref_pos LATER, and
        # must still draw) we show a hint instead.  The scheduled auto-place
        # (or the Place button) produces the first real draw.
        if (not getattr(self, '_initial_place_done', False)
                and getattr(self, 'drawable', None)):
            self.canvas.create_text(
                24, 24, anchor='nw', fill='#88aadd',
                font=(FONT_FAMILY, 13),
                text='Placing…  (press Place… if this persists)')
            return
        filt = self.filter_var.get().strip().upper()
        cols = max(1, self.cols_var.get())

        # Use placement-sorted order if available, otherwise drawable order
        source = (self._placed_order if self._placed_order is not None
                  else self.drawable)
        comps = [c for c in source
                 if not filt
                 or filt in c['ref'].upper()
                 or filt in c['sym'].upper()
                 or filt in c['value'].upper()
                 or any(filt in n for n in c['nets'])]

        self.count_lbl.config(
            text=f'{len(comps)} / {len(self.drawable)} components')
        if not comps:
            self.canvas.create_text(200, 80, text='No components to display.',
                                    font=(FONT_FAMILY, 14), fill='#888')
            return

        # Nets whose per-pin labels are suppressed: multi-pin nets (labelled
        # once on the line) and nets whose T already shows the name.
        nets_with_suppressed_per_pin_label = \
            self._suppressed_per_pin_nets(comps)
        self._suppressed_nets = nets_with_suppressed_per_pin_label

        # Flow orientation is owned by the P2DL orient phase.
        instances = []
        for comp in comps:
            sym_entry = self.sym_lib.get(comp['sym']) or \
                        {'shapes': [], 'pins': {}, 'sim_pins': {}}
            shapes = sym_entry.get('shapes', [])
            if shapes:
                bb  = _bbox_of_shapes(shapes)
                bw  = max(bb[2]-bb[0], 0.001); bh = max(bb[3]-bb[1], 0.001)
                ss  = min((CELL_W_MM-2)/bw, (CELL_H_MM-2)/bh) * 0.82 * SCALE
                mkx = (bb[0]+bb[2])/2;  mky = (bb[1]+bb[3])/2
            else:
                ss = SCALE; mkx = 0; mky = 0

            inst = CompInstance(comp, sym_entry, ss, mkx, mky)

            # Apply a user CCW rotation (a multiple of 90 in _user_rotations) by
            # rebuilding the geometry, as _apply_rotations does.
            if comp['ref'] in self._user_rotations:
                user_deg = self._user_rotations[comp['ref']]
            else:
                user_deg = self._auto_rotations.get(comp['ref'], 0)
            flipped = self._user_flips.get(
                comp['ref'], self._auto_flips.get(comp['ref'], False))
            if user_deg or flipped:
                entry = (_rotated_sym_entry(sym_entry, user_deg)
                         if user_deg else sym_entry)
                if flipped:
                    entry = _mirrored_sym_entry(entry)
                rot_entry = entry
                inst.sym_entry = rot_entry
                inst.rotation_deg = user_deg
                rshapes = rot_entry.get('shapes', [])
                if rshapes:
                    rbb = _bbox_of_shapes(rshapes)
                    rbw = max(rbb[2]-rbb[0], 0.001)
                    rbh = max(rbb[3]-rbb[1], 0.001)
                    from_cell = min((CELL_W_MM-2)/rbw, (CELL_H_MM-2)/rbh)
                    inst.sym_scale = from_cell * 0.82 * SCALE
                    inst.mid_kx = (rbb[0]+rbb[2]) / 2
                    inst.mid_ky = (rbb[1]+rbb[3]) / 2

            pin_net_pairs = resolve_pin_nets(comp, sym_entry)
            if not pin_net_pairs:
                pins = sym_entry.get('pins', {})
                sp   = sorted(pins, key=lambda k: int(k) if k.isdigit() else 0)
                pin_net_pairs = list(zip(sp, comp['nets']))

            inst.build(pin_net_pairs,
                        fulltext=self._effective_fulltext(comp['ref']),
                        multi_pin_nets=nets_with_suppressed_per_pin_label)
            instances.append(inst)

        # SEED from the captured draw
        # state instead of recomputing place_texts/Phase-B.  Behind the flag
        # _place_owns_geometry (default OFF).  The store is render's OWN prior
        # output (or place's, once place populates it), so seeding reproduces
        # the same geometry.  Seeded instances are recorded in _seeded_refs so
        # Phase A/B below skips their recompute and trusts the stored ox/oy +
        # text layout.  Falls back to recompute for any ref not in the store.
        self._seeded_refs = set()
        self._rotation_stale_refs = set()
        ds = getattr(self, '_placed_draw_state', None) or {}
        for inst in instances:
            ref = inst.comp['ref']
            rec = ds.get(ref)
            if not rec:
                continue
            if len(rec['text_items']) != len(inst.text_items):
                continue
            inst.ox_px = rec['ox']
            inst.oy_px = rec['oy']
            # A right-click rotate changes rotation_deg live; a part rotated
            # since the last Place re-places its labels here instead of
            # seeding the stale offsets, which would leave text on the wrong
            # side of the body.
            if ref in getattr(self, '_just_rotated_refs', ()):
                inst.place_texts(QuadTree(-200000, -200000,
                                           200000, 200000))
                self._rotation_stale_refs.add(ref)
                continue
            # Seed the whole stored text item, not just its position: render may
            # wrap a value differently than placement did, and the stale wrap
            # grows the box.
            for ti, sti in zip(inst.text_items, rec['text_items']):
                if sti.get('kind') != ti.get('kind'):
                    continue
                ti.update(sti)
            inst.composite_rel = rec['composite_rel']
            # Seed the frozen T offsets as well.  _predicted_pin_t
            # freezes them ON PURPOSE (see its docstring); a fresh
            # instance that recomputes gets a different, later answer
            # than the one _rebuild_t_terminals actually drew from,
            # which shows up as a reserved box that reserves space
            # for a T nobody drew.  The cache carries its own
            # geometry key, so if this instance's rotation/body does
            # not match what the entry was derived from it is
            # discarded on first use exactly as before.
            if rec.get('t_local') is not None:
                inst._t_local_cache = rec['t_local']
            self._seeded_refs.add(ref)
            # Rotation is not seeded from the stored draw state: a right-click
            # rotate sets _user_rotations and re-renders directly, and the live
            # value must win.
        self._just_rotated_refs = set()

        # Phase 2: row-based layout with a QuadTree, for the pre-placement grid
        # view.

        MARGIN_PX = SCALE           # 1 mm left/top margin
        GAP_PX    = GAP_MM * SCALE

        # Canvas is unbounded during placement; use a large initial QT
        qt = QuadTree(-200000, -200000, 200000, 200000)

        row_start_y = MARGIN_PX
        placed_bboxes = []

        for row_idx in range(math.ceil(len(instances) / cols)):
            row_insts  = instances[row_idx*cols : (row_idx+1)*cols]
            row_bboxes = []

            for inst in row_insts:
                # place OWNS geometry.
                # Every instance is seeded from _placed_draw_state (ox_px/oy_px
                # and text already final), so render no longer recomputes Phase
                # A (place_texts) or Phase B (reposition).  It only does the
                # bbox bookkeeping the rest of _render relies on; the draw loop
                # below draws.  The old recompute path (place_texts, the
                # user-pos/_placed_ref_pos/grid positioning and the rightward
                # overlap bump) was identical to place's stored result and has
                # been removed — that logic lives once, at the end of
                # _run_placement.
                ab = inst.abs_composite()
                qt.insert((ab[0]-2, ab[1]-2, ab[2]+2, ab[3]+2), ab)
                row_bboxes.append(ab)
                placed_bboxes.append(ab)

            if row_bboxes:
                row_h = max(b[3]-b[1] for b in row_bboxes)
                row_start_y += row_h + GAP_PX

        # Render never re-resolves value text: an instance Place did not reach
        # is a Place bug, flagged there, not patched here.
        for inst in instances:
            if inst.comp['ref'] in self._seeded_refs:
                continue
            inst._recompute_composite_rel()

        # ── Phase 3: draw everything ──────────────────────────────────
        total_w = max((b[2] for b in placed_bboxes), default=800) + 20
        total_h = max((b[3] for b in placed_bboxes), default=600) + 40
        # Also include T-symbols in the scrollable area (they
        # may extend past the cluster bounding boxes by ~140 px each
        # side).
        if self._t_terminals:
            t_max_x = max(t['cx'] for t in self._t_terminals) + 80
            t_max_y = max(t['cy'] for t in self._t_terminals) + 80
            total_w = max(total_w, t_max_x)
            total_h = max(total_h, t_max_y)
        self.canvas.configure(scrollregion=(0, 0, total_w, total_h))

        for idx, inst in enumerate(instances):
            comp      = inst.comp
            self._draw_one_at(inst)
            # Draw +/- polarity signs and current arrows as overlays
            self._draw_polarity_and_arrows(inst)

            # Draw text labels (and optional bbox overlays)
            dbg = self.show_bboxes.get()
            for item in inst.text_items:
                if item['placed'] is None:
                    continue
                rx, ry, anchor, fs, is_interior = item['placed']
                cx, cy = inst.canvas_xy(rx, ry)
                if item['kind'] == 'net':
                    tid = self.canvas.create_text(
                        cx, cy, text=item['text'], font=(FONT_FAMILY, fs),
                        fill=C_NET, anchor=anchor)
                elif item['kind'] == 'value':
                    color = '#1a1a6a' if is_interior else '#333'
                    bold  = ('bold',) if is_interior else ()
                    just = (tk.LEFT if anchor == 'w'
                            else tk.RIGHT if anchor == 'e' else tk.CENTER)
                    tid = self.canvas.create_text(cx, cy, text=item['text'],
                                                  font=(FONT_FAMILY, fs)+bold,
                                                  fill=color, anchor=anchor,
                                                  justify=just)
                elif item['kind'] == 'ref':
                    # ref designator, its own create_text so it
                    # keeps the distinct C_REF colour (a single tk text item
                    # can't be two colours, so it stays a separate item rather
                    # than being merged into the value string).
                    tid = self.canvas.create_text(
                        cx, cy, text=item['text'],
                        font=(FONT_FAMILY, fs, 'bold'),
                        fill=C_REF, anchor=anchor)
                else:
                    tid = None

                if dbg and tid is not None and not is_interior:
                    # GREEN = estimated bbox (what the placement algorithm used)
                    eb = _text_bbox_from_anchor(
                        rx, ry, item['text'], anchor, fs,
                        bold=(item['kind'] == 'ref'))
                    self.canvas.create_rectangle(
                        inst.ox_px+eb[0], inst.oy_px+eb[1],
                        inst.ox_px+eb[2], inst.oy_px+eb[3],
                        outline='#00aa00', width=1, dash=(3,2))
                    # RED = actual tkinter-measured bbox (ground truth)
                    ab_tk = self.canvas.bbox(tid)
                    if ab_tk:
                        self.canvas.create_rectangle(
                            ab_tk[0], ab_tk[1], ab_tk[2], ab_tk[3],
                            outline='#cc0000', width=1, dash=(2,2))

            # Debug overlay (when BBoxes checkbox is checked):
            #   GREEN  dashed = estimated label bbox (_text_bbox_from_anchor)
            #   RED    dashed = actual tkinter label bbox (canvas.bbox)
            #   ORANGE dashed = pin stub bbox (obstacle for label placement)
            #   BLUE   solid  = the RESERVED placement bbox
            #                   (_placement_extent) — body + text +
            #                   T-symbols + T-symbol text, i.e. exactly
            #                   the box Sugiyama is given
            if dbg:
                ss   = inst.sym_scale
                mkx  = inst.mid_kx; mky = inst.mid_ky
                pins = inst.sym_entry.get('pins', {})
                for _pn, (ax, ay, angle_deg, alen) in pins.items():
                    ar   = math.radians(angle_deg)
                    sdx  = math.cos(ar); sdy  = math.sin(ar)
                    arx  = (ax - mkx) * ss; ary = -(ay - mky) * ss
                    fp   = alen * ss
                    p75x = arx + 0.75*fp*sdx;  p75y = ary - 0.75*fp*sdy
                    p100x= arx + 1.0 *fp*sdx;  p100y= ary - 1.0 *fp*sdy
                    sw   = _STUB_HALF_WIDTH * 6   # widen for visibility
                    sh   = _STUB_HALF_WIDTH * 6
                    self.canvas.create_rectangle(
                        inst.ox_px + min(p75x,p100x) - sw,
                        inst.oy_px + min(p75y,p100y) - sh,
                        inst.ox_px + max(p75x,p100x) + sw,
                        inst.oy_px + max(p75y,p100y) + sh,
                        outline='#cc6600', width=1, dash=(4,2))  # ORANGE = stub
                # ONE box is drawn: the RESERVED placement extent, i.e.
                # exactly what Sugiyama is handed (_placement_extent =
                # body+pins UNION label extent UNION every T-symbol of
                # this instance).  It is drawn in the same BLUE the
                # body+text box used to use, because it supersedes it —
                # showing both boxes only invited the question of which
                # one placement actually used.
                pe = self._placement_extent(inst)
                self.canvas.create_rectangle(
                    inst.ox_px + pe[0], inst.oy_px + pe[1],
                    inst.ox_px + pe[2], inst.oy_px + pe[3],
                    outline='#0055cc', fill='', width=2)

            # the ref designator is now a first-class text
            # item, placed with the instance (stacked under the value) and
            # drawn in the text-item loop above with its C_REF colour; the
            # old ad-hoc "ref below composite bbox" draw is gone.
            ab = inst.abs_composite()

            # Invisible tooltip rect over composite
            tag = f'comp_{idx}'
            self.canvas.create_rectangle(ab[0], ab[1], ab[2], ab[3]+18,
                                         fill='', outline='', tags=tag)
            self.canvas.tag_bind(
                tag, '<Enter>',
                lambda e, c=comp, i=inst: self._show_tip(e, c, i))
            self.canvas.tag_bind(tag, '<Leave>', self._hide_tip)

        # ── Rev 33: cache instances for interactive hit-testing ───────
        self._cached_instances = instances
        self._cached_inst_by_ref = {i.comp['ref']: i for i in instances}

        # ── Rev 37: highlight instances that are part of the selected
        # group with a dashed orange outline so the user can see what
        # will move on a group drag.
        if self._selected_group:
            for inst in instances:
                if inst.comp['ref'] not in self._selected_group:
                    continue
                bb = inst.abs_sym_body()
                pad = 4
                self.canvas.create_rectangle(
                    bb[0] - pad, bb[1] - pad,
                    bb[2] + pad, bb[3] + pad,
                    outline='#cc6600', width=2, dash=(4, 2), fill='',
                    tags='group_select')
        # Same dashed-orange highlight for selected T-symbols.
        # Drawn now (before the Ts themselves are drawn later in
        # _render) so the highlight sits BENEATH the T graphics.  Ts
        # in _selected_t_ids that are no longer in _t_terminals (e.g.
        # cleared by a re-Place) are skipped silently.
        if self._selected_t_ids:
            for t in self._t_terminals:
                if t['id'] not in self._selected_t_ids:
                    continue
                x0, y0, x1, y1 = self._t_hit_bbox(t)
                pad = 4
                self.canvas.create_rectangle(
                    x0 - pad, y0 - pad, x1 + pad, y1 + pad,
                    outline='#cc6600', width=2, dash=(4, 2), fill='',
                    tags='group_select')

        # ── Rev 33: draw user-routed wires (before flight lines so the ──
        # flight overlay can be suppressed by them) ────────────────────
        self._draw_user_wires()

        # Compute which nets are fully connected by user wires.  Those
        # nets' flight lines will be suppressed below.
        fully_connected = self._nets_fully_connected_by_wires(instances)

        # Flight lines overlay
        # Wipe any old flight-line items first.  Belt-and-suspenders for
        # canvas.delete('all') above — if a future code path skips the
        # full render but still wants to redraw flights, deleting by
        # tag remains correct.
        self.canvas.delete('flight_line')
        # Also clear any T-symbol items from the prior render.
        self.canvas.delete('t_term')
        # Wipe flight-line net-name labels from the prior
        # render.  They have their own tag so they survive
        # delete('flight_line') alone.
        self.canvas.delete('net_label')

        # Rebuild T-terminals before drawing flight lines when Place just ran,
        # so the T-net filter sees the new T's on the first render.

        if self.show_flights.get():
            # Use the actual canvas size so power-rail anchors land at the
            # current top/bottom of the visible area, not the static
            # 1600×900 default in _build_pin_flight_data.
            cw_now, ch_now = self._canvas_size()
            if self._pin_flight_data is not None:
                # Post-placement: show pin-to-pin lines (teal).  We
                # deliberately ignore self._pin_flight_data's cached dict
                # — it references the CompInstance objects from the run
                # that produced it.  Build fresh from the current
                # instances so the lines reflect the live positions.
                nm_cur, _ip_cur = _build_pin_flight_data(
                    instances, canvas_w=cw_now, canvas_h=ch_now)
                # Drop fully-connected nets from the overlay.
                nm_cur = {n: v for n, v in nm_cur.items()
                          if n not in fully_connected}
                # With T-terminals active, skip the old edge-of-canvas flight
                # line for any net with a T; compare net names in lower case.
                if self._t_terminals:
                    _p2t = getattr(self, '_pin_to_t', None) or {}
                    _keep = {}
                    for n, v in nm_cur.items():
                        _real = [(m, p) for m, p in v if m is not None]
                        if _real and all((m.comp['ref'], p) in _p2t
                                         for m, p in _real):
                            continue        # fully T'd: stubs suffice
                        _keep[n] = v
                    nm_cur = _keep
                self._draw_pin_flight_lines(nm_cur)
                # overlay behavioral-source control
                # dependencies (equation V(net) inputs + sensed V-sources)
                # as distinct left-anchored dashed lines.
                self._draw_sense_flight_lines(instances)
            else:
                # Pre-placement or grid view: centroid-to-centroid (olive)
                net_members, inst_nets = _build_netlist_graph(instances)
                net_members = {n: v for n, v in net_members.items()
                               if n not in fully_connected}
                self._draw_flight_lines(net_members, {})

        # Render T-symbols and their flight lines on top of
        # the legacy flight overlay.  Always drawn (independent of the
        # show_flights toggle) so the T-based connectivity remains
        # visible even when the user hides flight lines.
        # -g/--rank-grid overlay, drawn before the T's so the bands sit
        # under the symbols rather than over them.
        if getattr(self, '_show_rank_grid', False):
            try:
                self._draw_rank_grid(instances)
            except Exception as _exc:
                print('rank grid unavailable: %r' % (_exc,))

        if self._t_terminals:
            # place owns T geometry (build +
            # repin + nudge in _settle_and_rebuild_ts).  Render just DRAWS the
            # committed T list and its flight lines; no re-pin / nudge here.
            self._draw_all_t_terminals()
            self._draw_t_flight_lines(instances)

        # Net-name labels on multi-pin non-T nets.  Drawn
        # UNCONDITIONALLY (no show_flights gate) because per-pin labels
        # are now suppressed for multi-pin nets, so these are the only
        # signage for those nets.  Labels for T-routed nets and for
        # nets the user has fully wired are skipped.
        self._draw_multi_pin_net_labels(instances)

        # compute self-crossing data ONCE here and hand
        # the SAME list to both the marker overlay below and _self_check
        # further down (see _self_crossing_data docstring) — guarantees the
        # 'Self-X' overlay and the toolbar rCrossings count can never disagree,
        # since they are reading the identical computation, not two separate
        # ones that are merely expected to match.
        _selfx_data = self._self_crossing_data(instances)

        # debug: ring every overlap the metric reports with a
        # solid RED ellipse (drawn last, on top), so the user can SEE where a
        # collision is and check it against the GREEN (reserved) / RED (actual
        # tk) text-bbox overlay above.  Same 'BBoxes' checkbox.
        if self.show_bboxes.get():
            self._draw_overlap_markers(instances)

        # SHOW remaining self-crossings (magenta ring +
        # dashed crossing lines + crossing dot), drawn last so they sit on top.
        if getattr(self, 'show_selfcross', None) and self.show_selfcross.get():
            self._draw_self_crossing_markers(instances, data=_selfx_data)

        # SHOW pin roles: overrides always (ring),
        # all pins always too (an earlier revision — the 'Pin Roles' checkbox
        # that used to gate this was removed; small dot).
        self._draw_pin_role_markers(instances)

        # Optional cluster box overlay (debug): part bodies unioned with the
        # T-symbols they actually own in _t_terminals / _pin_to_t.
        _clu_draw = (getattr(self, '_signal_segments', None)
                     or self._boxes)
        if self._show_cluster_boxes and _clu_draw:
            inst_by_ref = {i.comp['ref']: i for i in instances}
            t_owner_refs = {}
            for (ref, _pn), tid in (
                    getattr(self, '_pin_to_t', None) or {}).items():
                t_owner_refs.setdefault(tid, set()).add(ref)
            t_by_id = {t.get('id'): t
                       for t in (getattr(self, '_t_terminals', None) or [])}
            for refs in _clu_draw:
                # draw the ONE true cluster box (the shared
                # _cluster_true_bbox the packer will also use).
                box = self._cluster_true_bbox(
                    refs, inst_by_ref, t_owner_refs, t_by_id)
                if box is None:
                    continue
                x0, y0, x1, y1 = box
                self.canvas.create_rectangle(
                    x0, y0, x1, y1,
                    outline='#aaaaaa', dash=(2, 4), width=1,
                    tags='cluster_box')

        # Group boxes: outline each tight group (parallel
        # siblings / series chains, >=2 members) in red and label it with
        # its group-id number at the inside top-left corner.
        if (getattr(self, '_show_group_boxes', None) is not None
                and self._show_group_boxes.get()
                and getattr(self, '_group_id_of', None)):
            inst_by_ref = {i.comp['ref']: i for i in instances}
            members_by_gid = {}
            for ref, gid in self._group_id_of.items():
                members_by_gid.setdefault(gid, []).append(ref)
            # the group box must ENCLOSE each member's
            # owned T-symbols AND their net text too, not just body+labels.
            # A T is owned by a member if one of that member's pins maps to it
            # (_pin_to_t).  The T canvas items (tag 't_term:<id>', incl. the
            # net-label text) are already drawn by _draw_all_t_terminals above,
            # so canvas.bbox() gives their TRUE drawn extent — no geometry
            # re-derivation.  Previously the box enveloped only member
            # composites, so T-symbols/T-text fell ON or OUTSIDE the box.
            _ref_t_ids = {}
            for (mref, _pin), tid in (getattr(self, '_pin_to_t', {})
                                      or {}).items():
                _ref_t_ids.setdefault(mref, set()).add(tid)
            for gid, refs in members_by_gid.items():
                # the group box now envelopes each member's
                # COMPOSITE extent (body + value/ref/net labels), not just
                # abs_sym_body, so a member's value label (e.g. X_U18.G1's
                # GVALUE equation) is CONTAINED by the box.  The label extent
                # already reflects the truncate-vs-fulltext state (value text
                # is truncated to VALUE_MAX_CHARS+'…' unless the Full-text box
                # is on), so checking Full text grows the box automatically —
                # exactly the requested "include full equation iff checked".
                bbs = []
                for r in refs:
                    inst = inst_by_ref.get(r)
                    if inst is None:
                        continue
                    b = inst.abs_composite()
                    # abs_composite is the sentinel 2x2 until place_texts has
                    # run on THIS instance; here in _render it has, so it is
                    # the real box.  Fall back to body if it looks unset.
                    if b[2] - b[0] < 3 and b[3] - b[1] < 3:
                        b = inst.abs_sym_body()
                    bbs.append(b)
                    # enclose this member's owned T-symbols
                    # (stem + bar + net-label text) via their drawn bbox.
                    for tid in _ref_t_ids.get(r, ()):
                        tb = self.canvas.bbox(f't_term:{tid}')
                        if tb:
                            bbs.append(tb)
                if len(bbs) < 2:        # singletons are not a group
                    continue
                x0 = min(b[0] for b in bbs); y0 = min(b[1] for b in bbs)
                x1 = max(b[2] for b in bbs); y1 = max(b[3] for b in bbs)
                pad = 16
                self.canvas.create_rectangle(
                    x0 - pad, y0 - pad, x1 + pad, y1 + pad,
                    outline='#d00000', width=2, tags='group_box')
                self.canvas.create_text(
                    x0 - pad + 4, y0 - pad + 2, text=str(gid),
                    font=(FONT_FAMILY, 10, 'bold'), fill='#d00000',
                    anchor=tk.NW, tags='group_box')

        # Crossings: an orange ring on every crossing the count counts.
        # Drawn from _flight_crossing_points, which shares its scan with
        # _flight_crossing_count, so the rings and the status-bar number
        # always describe the same set.  Two rings on one spot means two
        # genuinely different segment pairs meet there -- which is what a
        # "the bar says 2 and I can only see 1" report looks like.
        if (getattr(self, '_show_crossings', None) is not None
                and self._show_crossings.get()):
            try:
                _xp = self._flight_crossing_points(instances)
            except Exception:
                _xp = []
            _r = 9
            for _cx, _cy in _xp:
                self.canvas.create_oval(
                    _cx - _r, _cy - _r, _cx + _r, _cy + _r,
                    outline='#ff8000', width=2, tags='crossing_mark')

        # Bus stubs feature REMOVED (checkbox and
        # drawing code both) — narrow-purpose visualization of parallel-
        # sibling shared nets as solid rails; the user didn't find it
        # useful and it was already off by default.

        kinds = {}
        for c in comps: kinds[c['kind']] = kinds.get(c['kind'],0)+1
        summary = '  '.join(f"{k}:{v}" for k,v in sorted(kinds.items()))

        # ── Overlap readout ─────────────────────────────────────────────
        # the single overlap measure is computed once
        # below from _self_check (see _metrics_txt); the old label/stub
        # scanline pass that produced a SECOND, different number here was
        # removed so the screen and the work-against count never diverge.
        overlap_msg = ''

        # persistent top-bar metrics readout, matching
        # the verify measures:
        #   M overlaps   = label/stub overlaps (instance_bboxes + scanline,
        #                  the SAME count verify.py / -v reports)
        #   N rCrossings = same-net-pair crossings (cross_ab) — likely
        #                  fixable by ROTATION (>1 flight line between 2 insts)
        #   P crossings  = the other crossings (cross_other), NOT incl. rCross
        try:
            _sc = self._self_check(instances=instances, selfx_data=_selfx_data)
        except Exception:
            # if _self_check throws for any reason, this
            # used to silently fall back to the STALE _last_self_check from a
            # PRIOR render, while the marker overlay above (no try/except) had
            # already drawn the FRESH _selfx_data — exactly a toolbar/overlay
            # mismatch.  Keep the stale dict for everything else it reports,
            # but patch in the rCrossings count/refs we already computed
            # successfully, so at minimum that number always matches the
            # overlay regardless of what failed in the rest of _self_check.
            _sc = dict(getattr(self, '_last_self_check', None) or {})
            _sc['cross_ab'] = len(_selfx_data)
            _sc['rcross_refs'] = [d[0] for d in _selfx_data]
        _rcross = _sc.get('cross_ab', 0)
        _pcross = _sc.get('cross_other', 0)
        # One overlap measure everywhere: the toolbar shows _self_check's count,
        # which treats every T-symbol as a box of its own.
        _ovn = _sc.get('overlaps', 0)
        _tbody = _sc.get('tbody_overlaps', 0)
        # LEAD with the unified total (_all_overlaps): the two detectors
        # behind the old pair of numbers each miss collisions the other
        # finds, so neither was the answer to "how many overlaps are on
        # this schematic".  The breakdown follows it, and the red rings
        # are drawn from the same list, so every counted overlap is also
        # circled.
        _alln = _sc.get('all_overlaps', _ovn + _tbody)

        # singular/plural agreement: each of the 4
        # metric words drops its trailing 's' only when its own count is
        # exactly 1 (independently — "1 overlap · 0 T-on-body/stubs ·
        # 1 rCrossing · 1 crossing" is correct with three different
        # counts).  A tiny local helper rather than 4 near-identical
        # ternaries inline.
        def _plural(n, word):
            return word if n == 1 else word + 's'
        _metrics_txt = (
            f'{_alln} {_plural(_alln, "overlap")} '
            f'({_ovn} body · {_tbody} T-on-body/stub) · '
            f'{_rcross} {_plural(_rcross, "rCrossing")} · '
            f'{_pcross} {_plural(_pcross, "crossing")}')
        if hasattr(self, 'cross_lbl'):
            self.cross_lbl.config(text=_metrics_txt)

        # Floating-nets red-highlight post-pass.
        # Recolour any flight-line items and net-name labels whose net
        # is currently in self._highlighted_nets.  Done last so we
        # overwrite the default fills set during the drawing passes
        # above.  itemconfig on a tag silently does nothing if no items
        # match, so this is cheap when the set is empty.
        self._apply_net_highlight()

        # Set the final scroll region from the drawn extent (bbox of all items)
        # plus a 10 px border; the pre-draw estimate misses labels and T's.
        try:
            self._capture_draw_state(instances)
        except Exception:
            self._placed_draw_state = None

        bb = self.canvas.bbox('all')
        if bb:
            m = 10
            self.canvas.configure(
                scrollregion=(bb[0]-m, bb[1]-m, bb[2]+m, bb[3]+m))

        # Restore the viewport saved at the top of this method, now that
        # the new scrollregion is final — same reasoning as
        # _centre_view_on_net's fraction conversion, just aiming for
        # "unchanged" instead of "centred on a net".  A stale/first-ever
        # position (no scrollregion existed yet, or the canvas wasn't
        # realized) degrades to a harmless no-op via the try/except:
        # worst case the view lands wherever it would have anyway.
        if _prev_view_x is not None and bb:
            try:
                sx0, sy0, sx1, sy1 = (bb[0]-m, bb[1]-m, bb[2]+m, bb[3]+m)
                if _at_left:
                    _prev_view_x = sx0
                if _at_top:
                    _prev_view_y = sy0
                sw = max(1.0, sx1 - sx0)
                sh = max(1.0, sy1 - sy0)
                self.canvas.xview_moveto(
                    max(0.0, min(1.0, (_prev_view_x - sx0) / sw)))
                self.canvas.yview_moveto(
                    max(0.0, min(1.0, (_prev_view_y - sy0) / sh)))
            except tk.TclError:
                pass

        # THE CANVAS MUST HOLD WHAT WAS DRAWN.  The
        # scrollregion set in phase 3 comes from the instance composite
        # boxes plus the T-symbols, so anything drawn OUTSIDE those --
        # a long chain's flight lines, a wide label, a stub running to
        # the margin -- fell off the end with no way to scroll to it.
        # Ask the canvas what it actually holds and grow to fit, never
        # shrinking below the computed region so the view maths above
        # stays valid.
        try:
            _bb = self.canvas.bbox('all')
            if _bb:
                # Keep the region's own top-left: compaction can put parts
                # above or left of 0, and resetting the origin to (0, 0)
                # hid them under the toolbar (LM324.sub's Q9-Q12, I4).
                _sr = [float(v) for v in
                       self.canvas.cget('scrollregion').split()]
                if len(_sr) != 4:
                    _sr = [0.0, 0.0, 0.0, 0.0]
                self.canvas.configure(scrollregion=(
                    min(_sr[0], _bb[0] - 10), min(_sr[1], _bb[1] - 10),
                    max(_sr[2], _bb[2] + 40), max(_sr[3], _bb[3] + 40)))
        except Exception:
            pass

        self.status.config(
            text=f'{_metrics_txt}{overlap_msg}  •  '
                 f'Drag inst • Click pin to wire • Click segment • '
                 f'Del removes • Esc cancels  •  {summary}')

    def _capture_draw_state(self, instances):
        """Snapshot the FINAL per-instance
        draw geometry render just produced, keyed by ref, so a later render
        can be SEEDED from it (skipping the place_texts/Phase-B recompute that
        masks placement).  Stored in render's CURRENT convention, so seeding is
        byte-identical (the no-op refactor).  Captures: ox_px, oy_px,
        rotation_deg, the finalized text_items (deep-ish copy of placed
        positions) and composite_rel per ref; plus the T-terminal list and
        pin->T map already live on self."""
        prev = getattr(self, '_placed_draw_state', None) or {}
        st = {}
        for inst in instances:
            ref = inst.comp['ref']
            # 't_local' is _predicted_pin_t's frozen per-instance T offsets;
            # store it so a recompute reads the same values placement reserved.
            t_local = getattr(inst, '_t_local_cache', None)
            if t_local is None:
                t_local = (prev.get(ref) or {}).get('t_local')
            st[ref] = {
                'ox': inst.ox_px,
                'oy': inst.oy_px,
                'rot': inst.rotation_deg,
                'text_items': [dict(ti) for ti in inst.text_items],
                'composite_rel': tuple(inst.composite_rel),
                't_local': t_local,
            }
        self._placed_draw_state = st

    def _draw_one_at(self, inst):
        """Draw symbol shapes using inst's resolved ox_px/oy_px position."""
        shapes = inst.sym_entry.get('shapes', [])
        if not shapes:
            self._draw_unknown(inst.comp, inst.ox_px/SCALE, inst.oy_px/SCALE)
            return
        ss      = inst.sym_scale
        # Reconstruct cell_cx,cell_cy so that kicad mid_kx,mid_ky →
        # (cell_cx,cell_cy)
        # From kicad_rel: rx = (kx-mid_kx)*ss  → cell_cx = ox_px - rx_at_midkx =
        # ox_px
        # But: canvas_xy(rx,ry) = (ox_px+rx, oy_px+ry)
        # At kx=mid_kx: rx=0, so canvas x = ox_px.  cell_cx = ox_px
        cell_cx = inst.ox_px
        cell_cy = inst.oy_px
        mkx     = inst.mid_kx
        mky     = inst.mid_ky

        def tx(kx): return cell_cx + (kx-mkx)*ss
        def ty(ky): return cell_cy - (ky-mky)*ss

        for sh in shapes:
            k   = sh['kind']
            sw  = max(1.0, min(sh.get('stroke_w',0)*ss*0.5, 3.0))
            fc  = (C_FILL
                   if sh.get('fill','none') in ('background','outline')
                   else '')

            if k == 'polyline':
                pts = sh['pts']
                if len(pts) < 2: continue
                coords = []
                for px,py in pts: coords += [tx(px), ty(py)]
                if fc and len(pts) >= 3:
                    self.canvas.create_polygon(coords, fill=fc,
                                               outline=C_OUTLINE, width=sw)
                else:
                    self.canvas.create_line(coords, fill=C_OUTLINE, width=sw)
            elif k == 'circle':
                self.canvas.create_oval(
                    tx(sh['cx']-sh['r']), ty(sh['cy']+sh['r']),
                    tx(sh['cx']+sh['r']), ty(sh['cy']-sh['r']),
                    outline=C_OUTLINE, fill=fc, width=sw)
            elif k == 'arc':
                sx2,sy2=sh['start']; mx2,my2=sh['mid']; ex2,ey2=sh['end']
                res=_arc_3pt_to_canvas(sx2,sy2,mx2,my2,ex2,ey2,
                                        ss, cell_cx-mkx*ss, cell_cy+mky*ss)
                if res:
                    ax0,ay0,ax1,ay1,a_s,ext=res
                    self.canvas.create_arc(
                        ax0, ay0, ax1, ay1, start=a_s, extent=ext,
                        style=tk.ARC, outline=C_OUTLINE, width=sw)
            elif k == 'rectangle':
                self.canvas.create_rectangle(
                    tx(sh['x1']), ty(sh['y1']),
                    tx(sh['x2']), ty(sh['y2']),
                    outline=C_OUTLINE, fill=fc, width=sw)
            elif k == 'text':
                fs=max(6,int(sh['size']*ss*0.5))
                self.canvas.create_text(tx(sh['x']),ty(sh['y']),
                                        text=sh['text'], fill=C_OUTLINE,
                                        font=(FONT_FAMILY,fs),
                                        angle=sh.get('angle',0))
            elif k == 'pin':
                ar = math.radians(sh['angle'])
                # anchor (outer, wire-connection end) → inner (body-touching
                # end)
                full = sh['length'] * ss
                px0 = tx(sh['x']); py0 = ty(sh['y'])           # anchor
                px1 = px0 + full*math.cos(ar)      # inner (body) end
                py1 = py0 - full*math.sin(ar)
                # Draw only the last 25% of the stub (body side), 1-2 dashes,
                # so the line visually connects to the component body.
                px_near = px0 + 0.75*full*math.cos(ar)
                py_near = py0 - 0.75*full*math.sin(ar)
                self.canvas.create_line(px_near, py_near, px1, py1,
                                        fill=C_PIN, width=1, dash=(4,3))


    def _draw_polarity_and_arrows(self, inst):
        """In : an instance.  Out: the SWITCH symbol's '+' and '-' marks
        drawn; every other symbol returns at once.
        The source symbols carry baked-in '+' and '-' glyphs in
        Sim_SPICE.kicad_sym, so only the switch is left here: its marks
        sit beside the contact circles, a position the library cannot
        anticipate, because the offset needs the contact-circle radius
        and that is known only at render time."""
        sym = inst.comp['sym']
        if sym != 'SWITCH':
            return

        ss   = inst.sym_scale
        cx0  = inst.ox_px
        cy0  = inst.oy_px
        mkx  = inst.mid_kx
        mky  = inst.mid_ky

        def tx(kx): return cx0 + (kx-mkx)*ss
        def ty(ky): return cy0 - (ky-mky)*ss

        # SWITCH output port: small + and − beside the contact circles.
        # Contact circles are at KiCad (0, +2.032) and (0, -2.032).
        c_plus_x  = tx(0.0);  c_plus_y  = ty( 2.032)
        c_minus_x = tx(0.0);  c_minus_y = ty(-2.032)
        dot_r_px = 0.508 * ss   # small contact circle radius in px
        self.canvas.create_text(c_plus_x  + dot_r_px + 4, c_plus_y,
                                text='+', font=(FONT_FAMILY, 9, 'bold'),
                                fill=C_OUTLINE, anchor='w')
        self.canvas.create_text(c_minus_x + dot_r_px + 4, c_minus_y,
                                text='−', font=(FONT_FAMILY, 9, 'bold'),
                                fill=C_OUTLINE, anchor='w')

    # ── Placement helpers ──

    def _reset_placement(self):
        """Clear placement state and return to grid layout."""
        self._placed_order     = None
        self._placed_instances = None
        self._placed_ref_pos   = None
        self._pin_flight_data  = None
        self._render()

    # ── Cluster-based 2-D layout: cut the T-symbol nets, lay each cluster out
    # on its own, then pack the cluster boxes.

    def _cluster_cut_nets(self, in_nets, out_nets):
        """The set of lower-case net names that CUT the connectivity
        graph (cluster boundaries): power, ground, top-level IO, and
        promoted rails — adjusted by the user's CUT overrides:
            (auto ∪ _cut_force_on) − _cut_force_off
        Cut-ness is now independent of port-ness.
        A cut net forces clusters apart; whether it also renders as a
        T-symbol is the separate _port_nets() question."""
        auto = self._auto_port_cut_nets(in_nets, out_nets)
        return (auto | self._cut_force_on) - self._cut_force_off

    def _all_subckt_port_nets(self):
        """In : the parsed deck and the active subckt.  Out: ALL of that
        subckt's declared .SUBCKT port nets, lowercased, whatever role
        each carries.
        Factored out of _subckt_io_nets so _auto_port_cut_nets can use it
        directly: a declared port is ALWAYS a legitimate cluster boundary
        and must not depend on being name-matched or inferred as a rail
        first.  LM324.sub's net '4' reached the eligible set only through
        the old over-broad _detect_supply_rails, so tightening that
        silently dropped a genuine port net along with it."""
        if not (self._parser and self._parser.subckts):
            return set()
        active = (self._active_subckt or '').upper()
        if active and active in self._parser.subckts:
            ports = self._parser.subckts[active].get('ports', [])
        else:
            ports = [p for sc in self._parser.subckts.values()
                     for p in sc.get('ports', [])]
        return {p.lower() for p in ports}

    def _auto_port_cut_nets(self, in_nets, out_nets):
        """In : the top-level in and out nets.  Out: the baseline set of
        nets that are BOTH cut and port by default — power/ground, top-
        level IO, promoted rails, the structurally detected _supply_rails
        and every declared .SUBCKT port (a declared port is eligible
        whatever its role).
        _supply_rails catches NUMERIC rails like LM324's 3 and 4, so a
        block reaching the rest of the circuit only through a T-symbol
        net becomes its own cluster.  Equation V()/I() senses need no
        entry: the cluster union joins parts on drawable PINS only.  User
        CUT overrides compose with this same set."""
        return (set(_PWR_NETS_LC_FOR_T) | set(in_nets) | set(out_nets)
                | set(self._promoted_rails)
                | {str(r).lower()
                   for r in (getattr(self, '_supply_rails', None) or set())}
                | self._all_subckt_port_nets())

    def _compute_signal_segments(self, instances, cut_nets):
        """In : instances and the cut-net set.  Out: a list of clusters
        (lists of CompInstance), ordered by first appearance.
        Union-find over shared non-cut nets, after FUSING tightly-coupled
        pairs so they cluster together even when their shared net is cut:
        parallel 2-pin R/C/L sharing both nets, and short series chains
        joined by a degree-2 pass-through node.  Fusion is placement-only
        and only ADDS cohesion; Cut splits a fused pair again."""
        parent = {id(i): id(i) for i in instances}

        def find(x):
            root = x
            while parent[root] != root:
                root = parent[root]
            while parent[x] != root:
                parent[x], x = root, parent[x]
            return root

        def union(a, b):
            ra, rb = find(a), find(b)
            if ra != rb:
                parent[ra] = rb

        # ── Fusion pre-pass (placement-only grouping) ─────────────
        by_ref = {i.comp['ref']: i for i in instances}
        # Parallel sibling groups (R/C/L sharing both nets).
        for refs in self._parallel_groups(instances):
            members = [by_ref[r] for r in refs if r in by_ref]
            for m in members[1:]:
                union(id(members[0]), id(m))
        # Short series chains (degree-2 pass-through nodes).
        for chain in self._compute_series_chains(instances):
            for m in chain[1:]:
                union(id(chain[0]), id(m))
        # Keep each cached P2DL block in one cluster: later passes treat it as
        # one object, so a member split off by clustering would be placed twice.
        for members in _stable_blocks(getattr(self, '_sp_block_layout', None)):
            ms = [by_ref[r] for r in sorted(members) if r in by_ref]
            for m in ms[1:]:
                union(id(ms[0]), id(m))

        net_to_insts = {}
        for inst in instances:
            seen = set()
            for nn in inst.comp.get('nets', []) or []:
                nl = nn.lower()
                if nl in cut_nets or nl in seen:
                    continue
                seen.add(nl)
                net_to_insts.setdefault(nl, []).append(inst)
        for members in net_to_insts.values():
            first = members[0]
            for m in members[1:]:
                union(id(first), id(m))

        groups = {}
        for inst in instances:           # preserve first-appearance order
            r = find(id(inst))
            groups.setdefault(r, []).append(inst)
        return list(groups.values())

    def _merge_equation_feeder_segments(self, clusters):
        """Fold a standalone feeder cluster (joined only through cut nets) into
        the cluster of the equation source that senses it.
        """
        if len(clusters) < 2:
            return clusters

        # Per-cluster pin-nets (ALL, incl. cut — the feeding net is usually a
        # port/rail) and member refs, both lowercase.
        cl_nets, cl_refs = [], []
        for cl in clusters:
            nets, refs = set(), set()
            for inst in cl:
                refs.add(inst.comp['ref'].lower())
                for nn in (inst.comp.get('nets') or []):
                    nets.add(nn.lower())
            cl_nets.append(nets)
            cl_refs.append(refs)

        parent = list(range(len(clusters)))

        def find(x):
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x

        def union(a, b):
            ra, rb = find(a), find(b)
            if ra != rb:
                parent[max(ra, rb)] = min(ra, rb)

        for ci, cl in enumerate(clusters):
            for inst in cl:
                c = inst.comp
                sense = c.get('sense_nets')
                isrc = c.get('sense_srcs')
                if sense is None or isrc is None:
                    v_eq, i_eq = _equation_signal_refs(c)
                    if sense is None:
                        sense = v_eq
                    if isrc is None:
                        isrc = i_eq
                vnets = {str(s).lower() for s in sense}
                isrcs = {str(s).lower() for s in isrc}
                if not vnets and not isrcs:
                    continue
                for aj in range(len(clusters)):
                    if aj == ci:
                        continue
                    if (vnets & cl_nets[aj]) or (isrcs & cl_refs[aj]):
                        union(aj, ci)

        # Rebuild merged clusters, preserving first-appearance order of the
        # representative cluster, then of members within each.
        merged = {}
        order = []
        for ci, cl in enumerate(clusters):
            r = find(ci)
            if r not in merged:
                merged[r] = []
                order.append(r)
            merged[r].extend(cl)

        # Track which original clusters were merged in, so a P2DL rule can place
        # each absorbed feeder as one group; the largest original cluster is the
        # untagged destination.
        FEEDER_GROUP_MAX_SIZE = 8
        by_root = {}
        for ci, cl in enumerate(clusters):
            by_root.setdefault(find(ci), []).append(ci)
        self._feeder_groups = []
        for _root, idxs in by_root.items():
            if len(idxs) < 2:
                continue
            main_ci = max(idxs, key=lambda k: len(clusters[k]))
            for ci in idxs:
                if ci == main_ci:
                    continue
                refs = frozenset(inst.comp['ref'] for inst in clusters[ci])
                if 2 <= len(refs) <= FEEDER_GROUP_MAX_SIZE:
                    self._feeder_groups.append(refs)

        return [merged[r] for r in order]

    def _merge_singleton_segments(self, clusters):
        """In : the clusters.  Out: the same list with every one-member
        cluster folded into the largest remaining one.
        A cluster's nets are .SUBCKT ports and nets appearing nowhere
        else — a genuinely PRIVATE net — and hiding a net takes at least
        two instances sharing it.  So a one-member cluster never had a
        hidden net: it is an ordinary instance exposed entirely through
        cut nets, and should not be drawn with its own floating cluster
        box.  The largest cluster stands in for the main schematic, as in
        _merge_equation_feeder_segments just above."""
        if len(clusters) < 2:
            return clusters
        sizes = [len(cl) for cl in clusters]
        max_size = max(sizes)
        if max_size < 2:
            return clusters      # nothing here has a real hidden net either
        target_idx = sizes.index(max_size)
        merged = list(clusters[target_idx])
        kept = []
        for i, cl in enumerate(clusters):
            if i == target_idx:
                continue
            if len(cl) == 1:
                merged.extend(cl)
            else:
                kept.append(cl)
        return [merged] + kept

    # A cluster of this many parts or fewer is laid out whole rather than split
    # into boxes (see TERMINOLOGY in the module docstring).
    _CLUSTER_WHOLE_MAX = 30

    def _refine_clusters_into_boxes(self, clusters, cut_nets,
                                     max_group=4):
        """In : the clusters, the cut-net set and a group cap.
        Out: one or more packable boxes per cluster, every member in
        exactly one box, ordered 4-member, 3-member, 2-member, then
        singletons (stable within a tier) so the shelf packer lays the
        larger groups first.
        A cluster of _CLUSTER_WHOLE_MAX members or fewer is handed back
        untouched, so the chain placer owns it whole.  A larger one is
        DISSOLVED into the tight boundary-cost sub-groups
        _boundary_cost_subgroups finds, plus a singleton per leftover.
        Each box packs separately, so the box IS a sub-group's adjacency."""
        boxes = []
        for cl in clusters:
            if len(cl) <= max_group:
                boxes.append(list(cl))
                continue
            if len(cl) <= self._CLUSTER_WHOLE_MAX:
                boxes.append(list(cl))   # chain placer owns it whole
                continue
            by_ref = {i.comp['ref']: i for i in cl}
            subs = self._boundary_cost_subgroups(cl, cut_nets,
                                                  max_group=max_group)
            claimed = set()
            for refs in subs:
                members = [by_ref[r] for r in refs if r in by_ref]
                members = [mm for mm in members
                           if id(mm) not in claimed]
                if len(members) >= 2:
                    boxes.append(members)
                    claimed.update(id(mm) for mm in members)
            # Leftover singletons.
            for inst in cl:
                if id(inst) not in claimed:
                    boxes.append([inst])
                    claimed.add(id(inst))
        # Order: 4-,3-,2-member groups first, singletons last; stable
        # within each size tier (preserve discovery order).
        boxes.sort(key=lambda b: -len(b))
        return boxes

    def _merge_clusters_for_p2dl_blocks(self, clusters):
        """In : the clusters.  Out: those a single P2DL block spans are
        merged, so every cached cell lands wholly inside ONE box.
        Called after the P2DL group phase, the earliest point the cells
        are known.  A merge can push a box past _box_max_group, and that
        is intended: the cap exists to give the packer freedom, but a
        cell the user reads as one figure must not be cut to buy it.
        Iterates to a fixed point, because two blocks can chain through a
        shared cluster — A spanning boxes 1 and 2, B spanning 2 and 3,
        so all three must end up together."""
        blocks = [set(k) for k in
                  (getattr(self, '_sp_block_layout', None) or {})]
        blocks = [b for b in blocks if len(b) > 1]
        if not blocks:
            return clusters
        groups = [list(cl) for cl in clusters]
        merged_any = True
        while merged_any:
            merged_any = False
            for blk in blocks:
                hit = [gi for gi, g in enumerate(groups)
                       if any(i.comp['ref'] in blk for i in g)]
                if len(hit) <= 1:
                    continue
                keep = []
                fused = []
                for gi, g in enumerate(groups):
                    (fused if gi in hit else keep).append(g)
                keep.append([i for g in fused for i in g])
                groups = keep
                merged_any = True
                break
        return groups

    def _box_flow_order(self, entries):
        """Order the packer's boxes by signal flow: treat boxes as nodes with
        driver->receiver edges from shared nets, break cycles and rank by
        longest path.  Returns the reordered entries.
        """
        # COMPUTE THE ROLES HERE.  _pin_role_map is built per cluster
        # during placement and is empty by the time packing runs, so
        # reading it gave an edgeless graph and the order never changed.
        _all = [i for t in entries for i in t[0]]
        _in, _out = self._subckt_io_nets(_all)
        try:
            rmap = self._compute_pin_role_map(_all, set(_in) | set(_out),
                                              self._promoted_rails)
        except Exception:
            rmap = {}
        rails = set(_PWR_NETS_LC_FOR_T) | set(self._promoted_rails)
        box_of, drives, senses = {}, defaultdict(set), defaultdict(set)
        for bi, t in enumerate(entries):
            for inst in t[0]:
                box_of[inst.comp['ref']] = bi
                cid = id(inst.comp)
                for k, (_pn, nn) in enumerate(
                        getattr(inst, '_pin_net_pairs', None) or []):
                    nl = str(nn).lower()
                    if nl in rails:
                        continue
                    role = rmap.get((cid, k))
                    if role == 'out':
                        drives[nl].add(bi)
                    elif role == 'in':
                        senses[nl].add(bi)
        adj = defaultdict(set)
        for nl, srcs in drives.items():
            for a in srcs:
                for b in senses.get(nl, ()):
                    if a != b:
                        adj[a].add(b)
        if not adj:
            return entries
        # Longest path over the DAG left after a DFS drops back edges --
        # the same cycle-break the instance-level graph uses.
        colour, dag = {}, defaultdict(set)
        for s0 in sorted(set(adj) | {v for x in adj.values() for v in x}):
            if colour.get(s0) is not None:
                continue
            stack = [(s0, iter(sorted(adj[s0])))]
            colour[s0] = 1
            while stack:
                u, it = stack[-1]
                nxt = next(it, None)
                if nxt is None:
                    colour[u] = 2; stack.pop(); continue
                if colour.get(nxt) == 1:
                    continue
                dag[u].add(nxt)
                if colour.get(nxt) is None:
                    colour[nxt] = 1
                    stack.append((nxt, iter(sorted(adj[nxt]))))
        rank = {}
        for _ in range(len(entries) + 1):
            changed = False
            for u in sorted(dag):
                for v in sorted(dag[u]):
                    r = rank.get(u, 0) + 1
                    if r > rank.get(v, 0):
                        rank[v] = r; changed = True
            if not changed:
                break
        self._box_graph_cache = (dict(adj), rank)
        order = sorted(range(len(entries)),
                       key=lambda bi: (rank.get(bi, 0), -entries[bi][2],
                                       bi))
        return [entries[bi] for bi in order]

    def _box_flow_graph(self, entries):
        """In : the per-box entries.
        Proc: run _box_flow_order for its side effect and hand back the
              box adjacency and rank it computed.
        Out : (adj, rank); ({}, {}) when there is no usable graph.
        """
        self._box_graph_cache = ({}, {})
        try:
            self._box_flow_order(entries)
        except Exception:
            pass
        return self._box_graph_cache

    def _box_chain_offsets(self, entries, gap=60.0, vgap=60.0):
        """In : the per-box entries (instances, local_pos, w, h, sug).
        Proc: place the BOXES the way _chain_stack_layout places
              instances — a spine laid left to right by flow rank, every
              off-spine box hung above or below it at the x of the spine
              box it shares a net with, pushed out only on a collision.
        Out : ({entry_index: (x, y)}, total_w, total_h), the same
              contract as _pack_cluster_boxes, or None with no flow graph.
        The shelf packer cannot express "above": every ordering key tried
        landed within one crossing of the rest on LM324.sub, where 52 of
        that deck's 60 crossings are between boxes."""
        adj, rank = self._box_flow_graph(entries)
        if not adj:
            return None
        n = len(entries)
        srails = self._south_rails()
        # Spine = the boxes on the longest rank path; everything else
        # hangs off the spine box it shares a net with.
        by_rank = defaultdict(list)
        for bi in range(n):
            by_rank[rank.get(bi, 0)].append(bi)
        spine = [sorted(by_rank[r], key=lambda b: -entries[b][2])[0]
                 for r in sorted(by_rank)]
        spine_set = set(spine)
        xof, x = {}, 0.0
        for bi in spine:
            xof[bi] = x
            x += entries[bi][2] + gap
        total_w = max(x - gap, 0.0)
        h_spine = max((entries[bi][3] for bi in spine), default=0.0)
        # Which spine box does an off-spine box join, and on which side?
        nets_of = {}
        for bi, t in enumerate(entries):
            nets_of[bi] = {str(nn).lower()
                           for i in t[0]
                           for _p, nn in (i._pin_net_pairs or [])}
        north, south = [], []
        for bi in range(n):
            if bi in spine_set:
                continue
            shared = [sb for sb in spine if nets_of[bi] & nets_of[sb]]
            at = xof[shared[0]] if shared else 0.0
            hit = {nl for nl in nets_of[bi] if nl in _PWR_NETS_LC_FOR_T}
            (south if (hit and hit <= srails) else north).append(
                (at, bi))
        def _stack(items):
            placed, out, depth = [], [], 0.0
            for at, bi in sorted(items):
                w, h = entries[bi][2], entries[bi][3]
                x0, x1, d = at, at + w + gap, vgap
                moved = True
                while moved:
                    moved = False
                    for px0, px1, pd0, pd1 in placed:
                        if (x0 < px1 and px0 < x1
                                and d < pd1 and pd0 < d + h):
                            d = pd1 + vgap; moved = True
                placed.append((x0, x1, d, d + h))
                out.append((bi, at, d, h))
                depth = max(depth, d + h)
            return out, depth
        north_p, north_d = _stack(north)
        south_p, south_d = _stack(south)
        y_spine = north_d
        offs = {}
        for bi in spine:
            offs[bi] = (xof[bi], y_spine)
        for bi, at, d, h in north_p:
            offs[bi] = (at, y_spine - d - h)
            total_w = max(total_w, at + entries[bi][2])
        for bi, at, d, h in south_p:
            offs[bi] = (at, y_spine + h_spine + d)
            total_w = max(total_w, at + entries[bi][2])
        total_h = north_d + h_spine + south_d
        return offs, total_w, total_h

    def _pack_cluster_boxes(self, boxes, target_ratio=11.0 / 17.0,
                              gap=60.0, area_slack=1.25, fixed_width=None,
                              width_is_floor=False):
        """Shelf packer: lay the boxes left to right in the order given,
        starting a new row only when the next box would overflow the page
        width.  Returns (offsets, width, height).
        """
        if not boxes:
            return {}, 0.0, 0.0
        widest = max(w for w, h, _ in boxes)
        if fixed_width is not None and width_is_floor:
            total_area = sum((w + gap) * (h + gap) for w, h, _ in boxes)
            area_w = math.sqrt(total_area * area_slack
                               / max(target_ratio, 1e-6))
            page_w = max(widest, fixed_width, area_w)
        elif fixed_width is not None:
            page_w = max(widest, fixed_width)
        else:
            # (a) area-based target width.  target_ratio = height/width =
            # 11/17, so width = sqrt(area * (17/11)) = sqrt(area /
            # target_ratio).
            total_area = sum((w + gap) * (h + gap) for w, h, _ in boxes)
            area_w = math.sqrt(total_area * area_slack
                               / max(target_ratio, 1e-6))
            page_w = max(widest, area_w)

        placements = {}
        x = 0.0
        y = 0.0
        row_h = 0.0
        first_in_row = True
        for w, h, key in boxes:
            # Wrap to a new row only on width overflow (never on height).
            if not first_in_row and x + w > page_w:
                y += row_h + gap
                x = 0.0
                row_h = 0.0
                first_in_row = True
            placements[key] = (x, y)        # TOP-aligned within the row
            x += w + gap
            row_h = max(row_h, h)
            first_in_row = False
        total_h = y + row_h
        total_w = max(placements[k][0] + w for w, h, k in boxes)
        return placements, total_w, total_h

    def _find_hiding_groups(self, refs, all_nets_by_ref, rail_nets,
                            max_group_size=6, max_hidden_nets=5):
        """One level of the grouping search: find groups of parts whose shared
        nets stay hidden inside the group, so each group can be placed as one
        unit.
        """
        claimed = set()
        candidates = []
        for seed_net, seed_touchers in self._net_touchers_map(
                refs, all_nets_by_ref, rail_nets).items():
            if len(seed_touchers) < 2 or len(seed_touchers) > max_group_size:
                continue
            cand = frozenset(seed_touchers)
            other_nets = set()
            for r in cand:
                other_nets |= (all_nets_by_ref.get(r, set()) - rail_nets)
            internal = 0
            external = 0
            for nl in other_nets:
                touchers = self._touchers_of_net(nl, refs, all_nets_by_ref)
                if touchers <= cand:
                    internal += 1
                else:
                    external += 1
            if internal == 0 or internal > max_hidden_nets:
                continue
            score = (-external, internal, -len(cand))
            candidates.append((score, cand))
        candidates.sort(key=lambda t: t[0], reverse=True)

        groups = []
        for _score, cand in candidates:
            if cand & claimed:
                continue
            groups.append(cand)
            claimed |= cand
        for r in refs:
            if r not in claimed:
                groups.append(frozenset({r}))
        return groups

    def _net_touchers_map(self, refs, all_nets_by_ref, rail_nets):
        """{net_lc: set(refs touching it)} restricted
        to non-rail nets, for the current `refs` universe.  Factored out
        since both _find_hiding_groups and _touchers_of_net need the
        same net->refs view."""
        m = defaultdict(set)
        for r in refs:
            for nl in (all_nets_by_ref.get(r, set()) - rail_nets):
                m[nl].add(r)
        return m

    def _touchers_of_net(self, nl, refs, all_nets_by_ref):
        """Every ref in `refs` touching net `nl`
        (no rail filtering needed here — callers only ask this for nets
        already known non-rail)."""
        return {r for r in refs if nl in all_nets_by_ref.get(r, set())}

    def _rank_group_members(self, group_refs, all_nets_by_ref,
                            pin_role_map, pin_net_pairs_by_ref):
        """Rank the members of one hiding group by real pin-role edges (a
        mini-Sugiyama), not by shared-net count.
        """
        refs = list(group_refs)
        if len(refs) <= 1:
            return refs
        edges = []
        for a in refs:
            for b in refs:
                if a == b:
                    continue
                shared = (all_nets_by_ref.get(a, set())
                         & all_nets_by_ref.get(b, set()))
                for nl in shared:
                    a_out = any(
                        nn == nl and pin_role_map.get((a, pn)) == 'out'
                        for pn, nn in pin_net_pairs_by_ref.get(a, []))
                    b_in = any(
                        nn == nl and pin_role_map.get((b, pn)) == 'in'
                        for pn, nn in pin_net_pairs_by_ref.get(b, []))
                    if a_out and b_in:
                        edges.append((a, b))
        if not edges:
            return sorted(refs)
        order = _greedy_feedback_arc_order(refs, edges)
        return sorted(refs, key=lambda r: order.get(r, 0))

    def _affinity_layout_group(self, group_insts, in_nets, out_nets):
        """In : one cluster's instances and its in and out nets.
        Out: {id(inst): (x, y)} local positions.  The only per-cluster
        layout routine, and deterministic (stable ref tie-breaks).
        Pipeline: affinity-group the instances by shared NON-rail nets,
        merging the top pairs first, at most 4 per group; lay each group's
        members in a tight affinity chain, bumped by member width so big
        parts cannot overlap; rank the groups left to right by their
        shared non-rail nets (network-simplex), ordering within a column
        by barycenter; place the groups on a grid sized to bbox + GAP."""
        GAP = 60.0
        rail = (set(in_nets) | set(out_nets) | set(self._promoted_rails)
                | set(_PWR_NETS_LC_FOR_T))

        insts = list(group_insts)
        if len(insts) <= 1:
            return {id(i): (0.0, 0.0) for i in insts}
        # composite_rel is the default (-1,-1,1,1) phantom at
        # layout time (place_texts runs only at render), so reading it here
        # spaced affinity members by 2px boxes — bodies/labels unreserved,
        # letting REE & VLIM (main cluster) overlap ~30px and REE's ref land
        # under VLIM.  Use the real estimated extent (body U value/net/ref,
        # rotation applied) when composite_rel is unpopulated.
        def _bbox_for(i):
            if tuple(i.composite_rel) == (-1, -1, 1, 1):
                return tuple(self._estimated_composite_extent(i))
            return tuple(i.composite_rel)
        bbox_of = {id(i): _bbox_for(i) for i in insts}
        nets_of = {}
        for i in insts:
            s = set()
            for nn in (i.comp.get('nets', []) or []):
                nl = nn.lower()
                if nl not in rail:
                    s.add(nl)
            nets_of[id(i)] = s

        inst_by_id = {id(i): i for i in insts}
        ref_of = {id(i): i.comp['ref'] for i in insts}

        # Group by the hiding-groups search, not a greedy shared-net count, and
        # keep each P2DL block's members together with its cached layout so the
        # reservation matches the block's real size.
        _p2dl_owned = set()
        _present_refs = set(ref_of.values())
        _ordered_blocks = sorted(
            (getattr(self, '_sp_block_layout', None) or {}).items(),
            key=lambda kv: (-len([r for r in kv[0] if r in _present_refs]),
                            sorted(kv[0])))
        _p2dl_block_groups = []
        for _members, (_pos, _bbox) in _ordered_blocks:
            # sorted for the same reason as
            # _apply_cached_blocks_local's `refs` — see the note there.
            _refs = sorted(r for r in _members if r in _present_refs)
            if len(_refs) < 2 or len(_refs) != len(_members):
                continue
            if _p2dl_owned.intersection(_refs):
                continue
            _p2dl_owned.update(_refs)
            _p2dl_block_groups.append((frozenset(_members), _pos, _bbox))
        all_nets_by_ref = {ref_of[k]: nets_of[k] for k in nets_of}
        hiding_refs = [r for r in ref_of.values() if r not in _p2dl_owned]
        # 'Pre-grouping' toolbar checkbox gates
        # this greedy net-seeded pre-clustering pass; see
        # self._pregroup_enabled's own comment for why it can hide a
        # real, direct pin-to-pin edge inside a losing candidate when
        # two different nets compete for the same shared instances.
        # Explicit P2DL/sp_pack blocks (_p2dl_block_groups, appended
        # below either way) are a different, user-directed mechanism
        # and are NOT gated by this flag.
        if getattr(self, '_pregroup_enabled', False):
            ref_groups = self._find_hiding_groups(
                hiding_refs, all_nets_by_ref, set(),
                max_group_size=4, max_hidden_nets=5)
        else:
            ref_groups = [frozenset({r}) for r in hiding_refs]
        for _fs, _pos, _bbox in _p2dl_block_groups:
            ref_groups.append(_fs)
        ref_to_id = {v: k for k, v in ref_of.items()}
        groups = [sorted((ref_to_id[r] for r in g),
                         key=lambda k: ref_of[k])
                 for g in ref_groups]
        groups.sort(key=lambda g: ref_of[g[0]])

        # ── 2. tight intra-group layout (affinity chain)
        PITCH = 160.0
        member_local = {}
        group_bbox = {}
        _sp_layout = getattr(self, '_sp_block_layout', None) or {}
        for gi, mem in enumerate(groups):
            if len(mem) == 1:
                k = mem[0]
                member_local[k] = (-bbox_of[k][0], -bbox_of[k][1])
                group_bbox[gi] = (bbox_of[k][2] - bbox_of[k][0],
                                  bbox_of[k][3] - bbox_of[k][1])
                continue

            # A group whose members are exactly one P2DL block is placed by that
            # block's layout, not as a simple lane.
            _mem_refs_fs = frozenset(ref_of[k] for k in mem)
            if _mem_refs_fs in _sp_layout:
                _relpos, _bbox = _sp_layout[_mem_refs_fs]
                for k in mem:
                    r = ref_of[k]
                    if r in _relpos:
                        rx, ry = _relpos[r]
                        member_local[k] = (rx - _bbox[0], ry - _bbox[1])
                group_bbox[gi] = (_bbox[2] - _bbox[0], _bbox[3] - _bbox[1])
                continue

            # STEP 2 REPLACED: the greedy shared-
            # net-count chain (plus an earlier override-swap patch
            # bolted onto it) is replaced outright by the verified mini-
            # Sugiyama ranking — real pin-role-driven edges via the same
            # feedback-arc-set heuristic the main placer already trusts,
            # rather than a symmetric degree-based chain patched after
            # the fact.  Confirmed on the exact case this investigation
            # started from: ranking {R74,R75,X_U19.G1} with the user's
            # override now correctly puts R74 before R75 THROUGH this
            # call, not via a bolted-on swap pass.
            mem_refs = [ref_of[k] for k in mem]
            pin_net_pairs_by_ref = {
                ref_of[k]: [(pn, nn.lower()) for pn, nn in
                           (getattr(inst_by_id[k], '_pin_net_pairs', None)
                            or [])]
                for k in mem}
            ranked_refs = self._rank_group_members(
                mem_refs, all_nets_by_ref, self._pin_role_overrides,
                pin_net_pairs_by_ref)
            order = [ref_to_id[r] for r in ranked_refs]
            cx = 0.0
            y0 = min(bbox_of[k][1] for k in mem)
            y1 = max(bbox_of[k][3] for k in mem)
            for k in order:
                bb = bbox_of[k]
                member_local[k] = (cx - bb[0], -y0)
                cx += max(PITCH, (bb[2] - bb[0]) + 20.0)
            group_bbox[gi] = (cx, y1 - y0)

        # 3. Rank the groups left to right by shared non-rail nets and place
        # them as signal lanes (_place_groups_as_lanes).
        return self._place_groups_as_lanes(
            groups, member_local, group_bbox, nets_of, ref_of, GAP,
            bbox_of, inst_by_id)

    _BK_GAP_Y = 24

    def _bk_vertical_alignment(self, layer_ids, cols_as_lists, pos_of,
                               upper_neighbors, lower_neighbors,
                               marked_edges, vert_dir, horiz_dir):
        """Brandes-Kopf (2002) vertical alignment in one of four orientations:
        align each vertex with a median upper or lower neighbour, avoiding
        marked conflicts.
        """
        all_v = [v for col in cols_as_lists for v in col]
        root = {v: v for v in all_v}
        align = {v: v for v in all_v}

        if vert_dir == 'up':
            neighbours_in_adj = upper_neighbors
            col_order = range(len(cols_as_lists))
        else:
            neighbours_in_adj = lower_neighbors
            col_order = range(len(cols_as_lists) - 1, -1, -1)

        for ci in col_order:
            col_members = cols_as_lists[ci]
            if horiz_dir == 'left':
                members_iter = enumerate(col_members)
            else:
                members_iter = enumerate(reversed(col_members))

            if vert_dir == 'up':
                adj_col_size = (len(cols_as_lists[ci - 1])
                                if ci > 0 else 0)
            else:
                adj_col_size = (len(cols_as_lists[ci + 1])
                                if ci + 1 < len(cols_as_lists) else 0)
            frontier = -1 if horiz_dir == 'left' else adj_col_size

            for _idx, v in members_iter:
                neighs = neighbours_in_adj[v]
                if not neighs:
                    continue
                d = len(neighs)
                med_lo = (d + 1) // 2 - 1
                med_hi = (d - 1) // 2
                if horiz_dir == 'left':
                    medians = ([med_lo, med_hi]
                              if med_lo != med_hi else [med_lo])
                else:
                    medians = ([med_hi, med_lo]
                              if med_lo != med_hi else [med_hi])

                for m in medians:
                    if align[v] != v:
                        break
                    u = neighs[m]
                    u_pos = pos_of[u]
                    if (u, v) in marked_edges or (v, u) in marked_edges:
                        continue
                    if horiz_dir == 'left':
                        if u_pos <= frontier:
                            continue
                    else:
                        if u_pos >= frontier:
                            continue
                    align[u] = v
                    root[v] = root[u]
                    align[v] = root[v]
                    frontier = u_pos
        return root, align

    def _bk_horizontal_compaction(self, cols_as_lists, layer_of, pos_of,
                                  height_of, root, align):
        """RESTORED and adapted from the
        original _bk_horizontal_compaction (see _bk_vertical_
        alignment's docstring for the full restoration story).  BK
        Alg 3a + Alg 3b from the BWZ-2020 erratum ("double shifting"
        and "shift accumulation" corrections), adapted to operate on
        vertex integers rather than id(inst) keys.

        Returns {v: y_top} for every vertex, or None on failure."""
        all_v = [v for col in cols_as_lists for v in col]
        pred = {}
        for col_members in cols_as_lists:
            for pi, v in enumerate(col_members):
                if pi > 0:
                    pred[v] = col_members[pi - 1]

        def sep(earlier_v):
            return height_of(earlier_v) + self._BK_GAP_Y

        sink = {v: v for v in all_v}
        shift = {v: float('inf') for v in all_v}
        x = {}

        def place_block(v):
            if v in x:
                return
            x[v] = 0.0
            w = v
            while True:
                if w in pred:
                    u = root[pred[w]]
                    place_block(u)
                    if sink[v] == v:
                        sink[v] = sink[u]
                    if sink[v] == sink[u]:
                        x[v] = max(x[v], x[u] + sep(pred[w]))
                w = align[w]
                if w == v:
                    break
            w = v
            while True:
                x[w] = x[v]
                sink[w] = sink[v]
                w = align[w]
                if w == v:
                    break

        for v in all_v:
            if root[v] == v:
                place_block(v)

        for ci in range(len(cols_as_lists)):
            col_members = cols_as_lists[ci]
            if not col_members:
                continue
            first_v = col_members[0]
            if sink[first_v] != first_v:
                continue
            if shift[sink[first_v]] == float('inf'):
                shift[sink[first_v]] = 0.0
            j = ci
            k_idx = 0
            seen_pairs = set()
            while True:
                cur_col = cols_as_lists[j]
                if k_idx < 0 or k_idx >= len(cur_col):
                    break
                v_v = cur_col[k_idx]
                while align[v_v] != root[v_v]:
                    next_v = align[v_v]
                    next_col_idx = layer_of[next_v]
                    if next_col_idx != j:
                        j = next_col_idx
                    v_v = next_v
                if v_v in pred:
                    u_v = pred[v_v]
                    if sink[u_v] != sink[v_v]:
                        pair_key = (sink[u_v], sink[v_v])
                        if pair_key not in seen_pairs:
                            seen_pairs.add(pair_key)
                            new_shift = (shift[sink[v_v]]
                                        + x[v_v]
                                        - (x[u_v] + sep(u_v)))
                            if new_shift < shift[sink[u_v]]:
                                shift[sink[u_v]] = new_shift
                v_col_idx = layer_of[v_v]
                if v_col_idx != j:
                    j = v_col_idx
                v_pos = pos_of[v_v]
                k_idx = v_pos + 1
                if k_idx >= len(cols_as_lists[j]):
                    break
                next_v = cols_as_lists[j][k_idx]
                if sink[next_v] != sink[first_v]:
                    break

        result = {}
        for v in all_v:
            s = sink[v]
            sh = shift.get(s, 0.0)
            if sh == float('inf'):
                sh = 0.0
            result[v] = x[v] + sh
        return result

    def _bk_mark_type1_conflicts(self, cols_as_lists, pos_of,
                                 upper_neighbors, is_dummy):
        """BK-2002 Algorithm 1: mark type-1 conflicts, where a non-inner segment
        crosses an inner one (both ends dummies), so inner segments stay
        straight.
        """
        marked = set()
        for ci in range(1, len(cols_as_lists) - 1):
            lower = cols_as_lists[ci + 1]
            upper_len = len(cols_as_lists[ci])
            k0, li = 0, 0
            for l1, v in enumerate(lower):
                # upper neighbour reached by an inner segment, if any
                inner_u = None
                if is_dummy(v):
                    for u in upper_neighbors[v]:
                        if is_dummy(u):
                            inner_u = u
                            break
                if l1 == len(lower) - 1 or inner_u is not None:
                    k1 = upper_len - 1 if inner_u is None else pos_of[inner_u]
                    while li <= l1:
                        w = lower[li]
                        for u in upper_neighbors[w]:
                            k = pos_of[u]
                            if k < k0 or k > k1:
                                marked.add((u, w))
                        li += 1
                    k0 = k1
        return marked

    def _bk_align_layered_graph(self, layers, layer_ids, vadj, vlayer,
                                pos_in_layer, height_of, is_dummy=None):
        """In : the layers, adjacency, per-vertex layer and position, and
        the heights.  Out: {v: y_top} for every vertex, real and dummy,
        or None when any of the four passes fails.
        The top-level Brandes-Kopf orchestrator, working on
        _place_groups_as_lanes' single unified layered graph directly —
        no per-row splitting, since the whole cluster is one BK problem.
        Runs all four alignment and compaction combinations, then BK
        section 4.3 balancing: align to the smallest-width candidate and
        average the two medians of the four results per vertex."""
        cols_as_lists = [layers[r] for r in layer_ids]
        all_v = [v for col in cols_as_lists for v in col]
        if not all_v:
            return {}
        if is_dummy is None:
            # No caller-supplied predicate: nothing counts as an inner
            # segment, so Algorithm 1 marks nothing and this degenerates
            # to the previous behaviour rather than guessing.
            def is_dummy(_v):
                return False
        layer_of = {}
        for ci, col in enumerate(cols_as_lists):
            for v in col:
                layer_of[v] = ci

        upper_neighbors = {v: [] for v in all_v}
        lower_neighbors = {v: [] for v in all_v}
        for v in all_v:
            lv = vlayer[v]
            for w in vadj.get(v, ()):
                lw = vlayer[w]
                if lw < lv:
                    upper_neighbors[v].append(w)
                elif lw > lv:
                    lower_neighbors[v].append(w)
        for v in all_v:
            upper_neighbors[v].sort(key=lambda w: pos_in_layer[w])
            lower_neighbors[v].sort(key=lambda w: pos_in_layer[w])

        marked_edges = self._bk_mark_type1_conflicts(
            cols_as_lists, pos_in_layer, upper_neighbors, is_dummy)

        candidate_assignments = []
        for vert_dir in ('up', 'down'):
            for horiz_dir in ('left', 'right'):
                root, align = self._bk_vertical_alignment(
                    layer_ids, cols_as_lists, pos_in_layer,
                    upper_neighbors, lower_neighbors, marked_edges,
                    vert_dir, horiz_dir)
                ys = self._bk_horizontal_compaction(
                    cols_as_lists, layer_of, pos_in_layer,
                    height_of, root, align)
                if ys is None:
                    return None
                candidate_assignments.append((horiz_dir, ys))

        widths = []
        for hd, ys in candidate_assignments:
            real_ys = [ys[v] for v in all_v]
            widths.append(max(real_ys) - min(real_ys))
        smallest = min(widths)
        smallest_min = smallest_max = None
        for (hd, ys), w in zip(candidate_assignments, widths):
            if w == smallest:
                real_ys = [ys[v] for v in all_v]
                smallest_min = min(real_ys)
                smallest_max = max(real_ys)
                break

        shifted = []
        for hd, ys in candidate_assignments:
            real_ys = [ys[v] for v in all_v]
            if hd == 'left':
                shift = smallest_min - min(real_ys)
            else:
                shift = smallest_max - max(real_ys)
            shifted.append({v: y + shift for v, y in ys.items()})

        # BK's balance: average the two MIDDLE values of the four
        # candidate assignments, which is what keeps one bad candidate
        # from dominating.  _bk_candidate (None = balance, 0..3 = use
        # that single candidate) exists only to measure whether the
        # balance is actually the best choice for THIS problem; see the
        # measured table where it is set.
        _pick = getattr(self, '_bk_candidate', None)
        _balanced = {}
        for v in all_v:
            vals = sorted(s[v] for s in shifted)
            _balanced[v] = (vals[1] + vals[2]) / 2.0


        if _pick is not None and 0 <= _pick < len(shifted):
            final = dict(shifted[_pick])
        else:
            final = _balanced

        offset = min(final[v] for v in all_v)
        return {v: final[v] - offset for v in all_v}

    def _placement_extent(self, inst, exclude_t_ids=None):
        """Takes an instance and returns its (left, top, right, bottom) about
        its own origin -- THE box the placer reserves: rotated body and pin
        ends, label extent, and its T-symbols . Not
        abs_composite(), which stops at body plus placed labels; BBoxes draws
        the composite BLUE and this one PURPLE because the two really differ."""
        ref = inst.comp['ref']
        deg = self._user_rotations.get(
            ref, self._auto_rotations.get(ref, 0)) or 0
        # The maps hold the intended rotation and rotation_deg the geometry's;
        # if a pass rotated without recording it, trust the geometry.
        _actual = getattr(inst, 'rotation_deg', None)
        _planned = (ref in self._user_rotations or ref in self._auto_rotations)
        if (_actual is not None and ref not in self._user_rotations
                and (getattr(inst, '_geom_final', False) or not _planned)):
            deg = _actual % 360
        flip = self._user_flips.get(
            ref, self._auto_flips.get(ref, False))
        try:
            body_bb, pin_offs = self._rotated_body_and_pins(
                inst, deg, flip)
            ex = [body_bb[0], body_bb[1], body_bb[2], body_bb[3]]
        except Exception:
            sb = inst.sym_body_rel
            ex = [sb[0], sb[1], sb[2], sb[3]]
            pin_offs = []
        for rx, ry in pin_offs:
            ex[0] = min(ex[0], rx); ex[1] = min(ex[1], ry)
            ex[2] = max(ex[2], rx); ex[3] = max(ex[3], ry)
        # UNION the LABEL extent (value/ref/net) for the
        # ROTATED orientation, so the separation reserves room for the
        # labels too.  The C2/R2 case: their value labels '20.00E-12' /
        # '100.0E3' collide in the gap even though bodies+pins clear (the
        # blue BBox the user sees is abs_composite = body+placed labels).
        # Snapshot the instance, apply the rotation geometry (which
        # re-runs build() -> rotated candidates), measure the composite
        # extent, then RESTORE every mutated field — pure from the
        # caller's view.
        try:
            snap = (inst.sym_entry, inst.rotation_deg, inst.sym_scale,
                    inst.mid_kx, inst.mid_ky, inst.sym_body_rel,
                    list(inst.text_items),
                    list(getattr(inst, '_pin_net_pairs', []) or []))
            # list(inst.text_items) copies the LIST but not the dicts in
            # it, and place_texts (reached via the composite estimate
            # below) rewrites each dict's 'text', 'placed' and
            # 'candidates' — so those have to be captured separately or
            # this "pure from the caller's view" measurement is not pure
            # at all.  It wasn't: the box grew a text line per call.
            tsnap = inst._snapshot_text_items()
            self._apply_instance_rotation_geometry(inst, deg)
            ce = self._estimated_composite_extent(inst)
            ex[0] = min(ex[0], ce[0]); ex[1] = min(ex[1], ce[1])
            ex[2] = max(ex[2], ce[2]); ex[3] = max(ex[3], ce[3])
            (inst.sym_entry, inst.rotation_deg, inst.sym_scale,
             inst.mid_kx, inst.mid_ky, inst.sym_body_rel,
             _unused_ti, inst._pin_net_pairs) = snap
            # _restore_text_items puts BOTH the original list object and
            # every dict's rewritten fields back, so the tuple's own
            # (shallow) text_items copy is redundant here.
            del _unused_ti
            inst._restore_text_items(tsnap)
        except Exception:
            pass
        # T-symbols are part of the footprint: each pin's predicted T (stem, bar
        # and label, oriented by its net's role) is unioned into the reserved
        # box, so the placer packs parts with their T's already counted.
        placing = getattr(self, '_placing', False)
        boxes = ([] if placing
                 else self._owned_t_boxes_rel(inst, exclude_t_ids))
        # "No box" and "no T" are different.  An instance whose only
        # T is SHARED and attributed to a nearer owner has boxes ==
        # [] but is not un-T'd, and falling back to the prediction
        # for it re-reserves the very T that was just handed to
        # someone else — LM324.sub's Q10 kept 13 px of phantom width
        # that way, which was the last reserved-box overlap.  Fall
        # back only when this ref has no mapped T whatsoever.
        _ref = inst.comp['ref']
        _mapped = any(r == _ref for (r, _p)
                      in (getattr(self, '_pin_to_t', None) or {}))
        tbs = []
        if placing or not (boxes or _mapped):
            # While Sugiyama packs, no T exists and the prediction is
            # all there is, so 'actual' falls back to it whenever
            # this instance owns no placed T.
            try:
                tbs.append(self._instance_bbox_with_ts(inst))
            except Exception:
                pass
        tbs.extend(boxes)
        for b in tbs:
            ex[0] = min(ex[0], b[0]); ex[1] = min(ex[1], b[1])
            ex[2] = max(ex[2], b[2]); ex[3] = max(ex[3], b[3])
        return tuple(ex)

    def _obstacle_box(self, inst):
        """Takes an instance and returns the canvas box to ask "is this part in
        the way?" about: its placed composite UNIONED with the at-origin
        estimate. Both halves are needed -- abs_composite is stale or unset when
        _rebuild_t_terminals asks, and two measurements of one thing take the
        UPPER BOUND."""
        boxes = []
        try:
            b = inst.abs_composite()
            if b and len(b) == 4:
                boxes.append(tuple(b))
        except Exception:
            pass
        try:
            e = self._instance_bbox_at_origin(inst)
            if e and len(e) == 4:
                boxes.append((inst.ox_px + e[0], inst.oy_px + e[1],
                              inst.ox_px + e[2], inst.oy_px + e[3]))
        except Exception:
            pass
        if not boxes:
            return None
        return (min(b[0] for b in boxes), min(b[1] for b in boxes),
                max(b[2] for b in boxes), max(b[3] for b in boxes))

    def _owned_t_boxes_rel(self, inst, exclude_t_ids=None):
        """Takes an instance and returns the boxes of the T's mapped to its
        pins, read from where they actually ARE, in the instance's frame; empty
        before any T exists. _instance_bbox_with_ts only PREDICTS those
        positions, which is all there is while Sugiyama packs.  Afterwards a
        drag or a saved file can have moved the T, and under-reserving is the
        dangerous half: content outside the box collides without the metric,
        which grades that box, ever seeing it."""
        ref = inst.comp['ref']
        p2t = getattr(self, '_pin_to_t', None) or {}
        if not p2t:
            return []
        skip = exclude_t_ids or ()
        terms = getattr(self, '_t_terminals', None) or []
        if not terms:
            return []
        by_id = getattr(self, '_t_by_id_memo', None)
        if by_id is None or by_id[0] is not terms or by_id[1] != len(terms):
            by_id = (terms, len(terms),
                     {t.get('id'): t for t in terms})
            self._t_by_id_memo = by_id
        lookup = by_id[2]
        out = []
        for (r, _pn), tid in p2t.items():
            if r != ref or tid in skip:
                continue
            t = lookup.get(tid)
            if not t:
                continue
            # A GROUP-OWNED T names the one member whose reserved slot
            # it occupies (t['owner']).  Every other member on that net
            # wires to it but must NOT reserve it: a shared T counted by
            # each sharer stretches all of their boxes out to it, which
            # is what produced the 17 reserved-box overlaps the old
            # midpoint sharing was withdrawn over.  One object, one box.
            _own = t.get('owner')
            if _own is not None and _own != ref:
                continue
            # A T with its own box is not this instance's to reserve; apply the
            # same rule as _instance_bbox_with_ts so the two boxes agree.
            if t.get('own_box'):
                continue
            try:
                # Reserve the T's full extent (stem, bar and net label), not its
                # hit box, which leaves out the label.
                x0, y0, x1, y1 = self._t_full_extent(t)
            except Exception:
                continue
            out.append((x0 - inst.ox_px, y0 - inst.oy_px,
                        x1 - inst.ox_px, y1 - inst.oy_px))
        return out

    def _place_groups_as_lanes(self, groups, member_local, group_bbox,
                               nets_of, ref_of, GAP, bbox_of, inst_by_id):
        """In : steps 1-2 of _affinity_layout_group — the affinity groups
              with their local member offsets, bboxes and net maps.
        Proc: each signal is a horizontal LANE that advances left to right
              by its OWN units' widths, so a wide unit pushes only what
              follows it in its lane, never other lanes.  Lanes stack
              vertically; a unit fed by two lanes is centred between its
              feeders.  Affinity groups sharing a cached block coalesce
              into ONE unit, so a cell such as a diff pair flows as one
              and the cached-block overlay restores its shape later.
        Out : {id(inst): (x, y)} local positions, as the column path."""
        nG = len(groups)
        if nG == 0:
            return {}

        # ── Coalesce affinity groups that share a cached block into UNITS.
        # ref -> cached-block-id (only multi-member blocks matter).
        # also capture each block's internal geometry once:
        #   block_of_ref[ref]   -> block-id
        #   block_rel[ref]      -> (bx, by) of the member within its block
        #   block_left/top[bid] -> block bbox left/top edge
        #   blocks_dims[bid]    -> (w, h) of the block bbox
        # so the compose can lay cached blocks out as coherent sub-items and
        # pin-align can read true pin y; previously the diff-pair members
        # were scattered across affinity groups and torn apart.
        block_of_ref = {}
        block_rel = {}
        block_left = {}
        block_top = {}
        blocks_dims = {}
        ref_to_blocks = defaultdict(set)
        for bi, (members, layout) in enumerate(
                _stable_block_items(getattr(self, '_sp_block_layout', {}))):
            ms = set(members)
            if len(ms) < 2:
                continue
            for r in ms:
                block_of_ref[r] = bi
                ref_to_blocks[r].add(bi)     # ALL blocks a ref belongs to
            if isinstance(layout, tuple) and len(layout) >= 2:
                pos, bb = layout[0], layout[1]
                block_left[bi] = bb[0]
                block_top[bi] = bb[1]
                blocks_dims[bi] = (bb[2] - bb[0], bb[3] - bb[1])
                for r, (bx, by) in pos.items():
                    block_rel[r] = (bx, by)
        # Build units from cached blocks, merging blocks that share a member
        # (OPAx197's R1 is in both [R1,R2] and [E1,R1]); otherwise one block
        # lands on the other.
        bparent = {bid: bid for bid in blocks_dims}

        def _bfind(x):
            while bparent[x] != x:
                bparent[x] = bparent[bparent[x]]
                x = bparent[x]
            return x

        def _bunion(a, b):
            ra, rb = _bfind(a), _bfind(b)
            if ra != rb:
                bparent[max(ra, rb)] = min(ra, rb)
        for r, bids in ref_to_blocks.items():
            bl = sorted(bids)
            for other in bl[1:]:
                _bunion(bl[0], other)
        allk = [kk for g in groups for kk in g]
        kparent = {k: k for k in allk}

        def _kfind(x):
            while kparent[x] != x:
                kparent[x] = kparent[kparent[x]]
                x = kparent[x]
            return x

        def _kunion(a, b):
            ra, rb = _kfind(a), _kfind(b)
            if ra != rb:
                kparent[rb] = ra
        first_blk = {}
        for k in allk:
            bids = ref_to_blocks.get(ref_of[k])
            if not bids:
                continue
            rep = _bfind(sorted(bids)[0])
            if rep in first_blk:
                _kunion(first_blk[rep], k)
            else:
                first_blk[rep] = k
        # Only a gid with MORE THAN ONE member says anything: every
        # instance carries a group_id, so the singletons would otherwise
        # each "merge" with themselves and the map would be noise.
        _gid_of = dict(getattr(self, '_group_id_of', None) or {})
        _gid_n = defaultdict(int)
        for _r in (ref_of[k] for k in allk):
            if _r in _gid_of:
                _gid_n[_gid_of[_r]] += 1
        first_gid = {}
        for k in allk:
            g = _gid_of.get(ref_of[k])
            if g is None or _gid_n[g] < 2:
                continue
            if g in first_gid:
                _kunion(first_gid[g], k)
            else:
                first_gid[g] = k
        unit_member_lists = []
        unit_block_ids = []         # unit -> list of block ids it contains
        _unit_of_root = {}
        for k in allk:
            root = _kfind(k)
            ui = _unit_of_root.get(root)
            if ui is None:
                ui = len(unit_member_lists)
                _unit_of_root[root] = ui
                unit_member_lists.append([])
                unit_block_ids.append([])
            unit_member_lists[ui].append(k)
        for ui, mem in enumerate(unit_member_lists):
            bs = set()
            for k in mem:
                for b in (ref_to_blocks.get(ref_of[k]) or ()):
                    bs.add(_bfind(b))
            unit_block_ids[ui] = sorted(bs)
        nU = len(unit_member_lists)

        unit_members = []
        unit_nets = []
        unit_ref = []
        unit_subitems = []
        for ui, mem in enumerate(unit_member_lists):
            unit_members.append(mem)
            s = set()
            for k in mem:
                s |= nets_of[k]
            unit_nets.append(s)
            unit_ref.append(min((ref_of[k] for k in mem), default=''))
            # Sub-items: the unit's PRIMARY block (the one whose geometry we
            # trust — the largest by member count) lays its members by block
            # geometry; any other members (from a merged overlapping block or
            # loose) become loose sub-items appended after it.
            blk_members = defaultdict(list)
            for k in mem:
                r = ref_of[k]
                bset = ref_to_blocks.get(r)
                if bset:
                    blk_members[_bfind(sorted(bset)[0])].append(k)
            subs = []
            placed = set()
            if blk_members:
                primary = max(blk_members,
                              key=lambda bid: len(blk_members[bid]))
                subs.append(('block', primary, list(blk_members[primary])))
                placed.update(blk_members[primary])
            for k in mem:
                if k not in placed:
                    subs.append(('loose', None, [k]))
            unit_subitems.append(subs)

        # Reconcile _group_id_of to lane units: merged cached blocks get one
        # gid, so the renderer draws one group box for them.
        if getattr(self, '_group_id_of', None) is not None:
            gid_map = self._group_id_of
            for ui, mem in enumerate(unit_member_lists):
                refs = [ref_of[k] for k in mem]
                if len(refs) < 2:
                    continue
                shared = min(gid_map.get(r, r) for r in refs
                             if gid_map.get(r) is not None) \
                    if any(gid_map.get(r) is not None for r in refs) \
                    else refs[0]
                for r in refs:
                    gid_map[r] = shared

        # Per-unit flight extent: each unit's box covers its members' bodies,
        # labels and pin offsets, so its edges are where lines attach.
        GROUP_PAD = 16.0

        def _member_local_offset(u, k):
            """(dx, dy) of member k from unit u's origin: sub-items run down one
            column, and a block sub-item also advances x so what follows
            clears its width.
            """
            sx = sy = 0.0
            for kind, bid, ks in unit_subitems[u]:
                if kind == 'block':
                    rel, (bw, bh) = _block_local_layout(bid, ks)
                    if k in ks:
                        return (sx + rel[k][0], sy + rel[k][1])
                    sx += bw + GAP
                    sy += bh + GAP
                else:
                    # SPACE members by the SAME box they are MEASURED
                    # with (_member_local_extent -> _placement_extent),
                    # not by bbox_of.  bbox_of is the composite, which
                    # excludes the per-pin T-symbols the reserved box
                    # includes, so the cursor advanced by less than each
                    # member actually occupies: members were laid out
                    # closer together than the boxes used to size the
                    # unit, which is both why members of one unit
                    # overlapped each other and why the composed unit
                    # box did not match the arrangement it described.
                    bb = _member_local_extent(ks[0])
                    if ks[0] == k:
                        return (sx - bb[0], sy - bb[1])
                    sx += (bb[2] - bb[0]) + GAP
            return (0.0, 0.0)

        _ext_memo = {}

        def _member_local_extent(k):
            """In : a member key.  Out: its (left, top, right, bottom)
            relative to ITS origin — a thin lookup over _placement_extent,
            the single definition of the reserved box and what the BBoxes
            overlay draws, so the two cannot drift apart.  A non-instance
            member keeps its stored bbox.
            Memoized for one lane layout: every caller here runs after the
            mirror pass, so the answer cannot legitimately change, and the
            cache keeps _placement_extent — which rotates the instance and
            re-runs place_texts — off the block separator's inner loop."""
            hit = _ext_memo.get(k)
            if hit is not None:
                return hit
            inst = inst_by_id.get(k)
            if inst is None:
                ext = bbox_of.get(k, (0, 0, 0, 0))
            else:
                ext = self._placement_extent(inst)
            _ext_memo[k] = ext
            return ext

        _block_layout_memo = {}

        def _block_local_layout(bid, ks):
            """In : one cached block id and its member keys.
            Out: ({member: (dx, dy)}, (w, h)) with members re-spaced so
            their RESERVED boxes clear, written back to _sp_block_layout.
            The cached geometry is packed from sym_body_rel — bodies only
            — while this unit is composed with the reserved box, so a
            generous body gap can hide boxes deep in each other.  Seeds
            from the cached relative positions, then runs _separate_boxes
            with the same sep _apply_cached_blocks_local uses a stage
            later; rigid members are locked.  Without the write-back the
            two passes disagree about where a member belongs."""
            hit = _block_layout_memo.get(bid)
            if hit is not None:
                return hit
            rigid = getattr(self, '_sp_rigid_blocks', None) or set()
            rigid_refs = set()
            for _rfs in rigid:
                rigid_refs |= set(_rfs)
            refs = [ref_of[k] for k in ks]
            sep_items = []
            lock = set()
            for i, k in enumerate(ks):
                bx, by = block_rel.get(refs[i], (0.0, 0.0))
                sep_items.append([bx, by, _member_local_extent(k)])
                if refs[i] in rigid_refs:
                    lock.add(i)
            if len(sep_items) > 1 and len(lock) < len(sep_items):
                _separate_boxes(sep_items, sep=6.0, max_iter=100, lock=lock)
            if len(lock) > 1:
                _lk = sorted(lock)
                _widen_rigid_lines(sep_items, _lk, 0)
                _widen_rigid_lines(sep_items, _lk, 1)
            L = min(it[0] + it[2][0] for it in sep_items)
            T = min(it[1] + it[2][1] for it in sep_items)
            R = max(it[0] + it[2][2] for it in sep_items)
            B = max(it[1] + it[2][3] for it in sep_items)
            out = ({k: (sep_items[i][0] - L, sep_items[i][1] - T)
                    for i, k in enumerate(ks)}, (R - L, B - T))
            _block_layout_memo[bid] = out
            # Write the arrangement back so the cached-block overlay
            # applies THIS geometry.  Only when `ks` is exactly one cached
            # block: a unit can merge several blocks that share a member,
            # and those members' seeds come from different block frames, so
            # a partial write would corrupt a key another unit still reads.
            _lay = getattr(self, '_sp_block_layout', None)
            _fs = frozenset(refs)
            if _lay is not None and _fs in _lay and len(_fs) == len(refs):
                _lay[_fs] = ({r: out[0][k] for k, r in zip(ks, refs)},
                             (0.0, 0.0, out[1][0], out[1][1]))
            return out

        # unit_ext/unit_bbox — MOVED to just before
        # coordinate assignment, after ranking/ordering AND the mirror
        # pass below.  Ranking (network-simplex) and the barycenter
        # crossing-reduction sweeps are PURE net-topology (ranks/edges
        # only) and never consult bbox; mirroring a symmetric part only
        # changes ITS OWN extent (never the order), so it's safe — and
        # correct, since a mirror choice can change which way a part's
        # bbox is measured — to size bboxes only once rotation is final.
        # See _member_local_extent/_bbox_and_ext_for_units below.

        # ── Flow rank on the UNIT net-graph.
        net_units = defaultdict(set)
        for ui in range(nU):
            for net in unit_nets[ui]:
                net_units[net].add(ui)
        # Directed edges from real pin roles (driver -> receiver), not just
        # 'shares a net'.
        _cluster_insts = list(inst_by_id.values())
        _rail_nets = self._pwr_gnd_adjacent_nets(_cluster_insts)
        _feedback_nets = (self._active_feedback_nets()
                          if hasattr(self, '_active_feedback_nets') else set())
        _role_map = self._compute_pin_role_map(
            _cluster_insts, _feedback_nets, _rail_nets)

        def _unit_role_on_net(ui, net):
            """'out' if any member of unit `ui` has a resolved driving
            pin on `net`, 'in' if any has a resolved receiving pin
            (and no driving one), else None (unresolved)."""
            seen_in = False
            for k in unit_members[ui]:
                inst = inst_by_id.get(k)
                if inst is None:
                    continue
                cid = id(inst.comp)
                for idx, (_pn, nn) in enumerate(
                        getattr(inst, '_pin_net_pairs', None) or []):
                    if nn.lower() != net:
                        continue
                    r = _role_map.get((cid, idx))
                    if r == 'out':
                        return 'out'
                    if r == 'in':
                        seen_in = True
            return 'in' if seen_in else None

        # A .SUBCKT input acts as an external driver and an output as an
        # external receiver, so a port's owner is pulled toward its side of the
        # rank order.
        in_nets_lc, out_nets_lc = self._subckt_io_nets()
        in_nets_lc = {n.lower() for n in in_nets_lc}
        out_nets_lc = {n.lower() for n in out_nets_lc}
        io_net_units = defaultdict(set)
        for _ui in range(nU):
            for _k in unit_member_lists[_ui]:
                _inst = inst_by_id.get(_k)
                if _inst is None:
                    continue
                for _nn in (_inst.comp.get('nets', []) or []):
                    _nl = _nn.lower()
                    if _nl in in_nets_lc or _nl in out_nets_lc:
                        io_net_units[_nl].add(_ui)
        # SUPER-SOURCE / SUPER-SINK: port nets are excluded from net_units, so
        # without these the .SUBCKT inputs and outputs add no rank edges.
        # Inputs anchor the left end and outputs the right.
        src_units = set()
        sink_units = set()
        for _nl, _us in io_net_units.items():
            if _nl in _rail_nets or _nl in _PWR_NETS_LC_FOR_T:
                continue
            if _nl in in_nets_lc:
                src_units |= _us
            elif _nl in out_nets_lc:
                sink_units |= _us
        # A unit on BOTH an input and an output port is its own
        # source and sink; anchoring it to either end is a guess, and
        # anchoring it to both would create a cycle the feedback-arc
        # pass then has to break arbitrarily.  Leave it to its real
        # pin edges.
        _both = src_units & sink_units
        src_units -= _both
        sink_units -= _both
        virtual_units = []          # list of ('source'|'sink')
        v_src = v_sink = None
        src_units = set()
        sink_units = set()
        nU_real = nU
        nU = nU + len(virtual_units)
        # EXTEND the per-unit arrays for the virtual
        # indices.  Everything between here and the drop at
        # `nU = nU_real` below indexes unit_members/unit_nets/unit_ref by
        # unit id, so a virtual index with no entry is an IndexError
        # waiting to happen.  Empty members / empty nets / a sort key that
        # always sorts last make a virtual unit inert everywhere except
        # the rank graph, which is its only job.
        for _vkind in virtual_units:
            unit_members.append([])
            unit_nets.append(set())
            unit_ref.append('zzz~io~%s' % _vkind)
            unit_subitems.append([])

        edges = set()
        # net(s) that PRODUCED each edge, keyed by the ordered pair.  The
        # port-constrained ordering below needs to know which pin an edge
        # attaches to, and only the loop that creates the edge knows that.
        # Recovering it later as unit_nets[a] & unit_nets[b] is a coin
        # flip whenever two units share more than one net — common in a
        # deck dense with 4-pin controlled sources, which is where the
        # first port-aware measurement regressed.  A set, not a scalar:
        # the same pair can genuinely be produced by several nets, and
        # min() over the set keeps the pick deterministic.
        edge_nets = defaultdict(set)

        def _add_edge(a, b, net=None):
            edges.add((a, b))
            if net is not None:
                edge_nets[(a, b) if a <= b else (b, a)].add(net)
        # Nets drawn as per-pin T-symbols still add their driver->receiver
        # edges: a unit with no edge is unranked and Sugiyama has nothing to
        # place it against.

        def _net_edges(net, us):
            """Add this net's driver->receiver edges.  Split out so the
            fallback pass below re-uses the IDENTICAL edge shape rather
            than a second, differently-behaved copy of it."""
            ul = sorted(us)
            drivers = [u for u in ul if _unit_role_on_net(u, net) == 'out']
            if drivers:
                receivers = [u for u in ul if u not in drivers]
                d0 = drivers[0]
                prev = d0
                for r in receivers:
                    if prev != r:
                        _add_edge(prev, r, net)
                    prev = r
                # any OTHER simultaneous driver (contention, rare/
                # malformed) also feeds the same receiver chain's head,
                # so it's still correctly ranked before every receiver
                # without turning this into a full star.
                for d in drivers[1:]:
                    if d != d0 and receivers:
                        _add_edge(d, receivers[0], net)
            else:
                # No direction on this net: order tied members by the pull
                # already given by earlier-sorted nets' edges, not by unit
                # index.
                def _pull_key(u):
                    has_in = any(v == u for (_a, v) in edges)
                    has_out = any(a == u for (a, _b) in edges)
                    if has_in:
                        return (0, u)   # left: fed by a driver elsewhere
                    if has_out:
                        return (2, u)   # right: itself drives elsewhere
                    return (1, u)       # middle: no pull either way
                ul_pulled = sorted(ul, key=_pull_key)
                for a, b in zip(ul_pulled, ul_pulled[1:]):
                    if a != b:
                        _add_edge(a, b, net)

        for net, us in sorted(net_units.items()):
            _net_edges(net, us)

        # Super-source/super-sink edges apply only to units the real pin edges
        # left unconstrained on that side.
        _has_pred = {b for (_a, b) in edges}
        _has_succ = {a for (a, _b) in edges}
        if v_src is not None:
            for u in sorted(src_units - _has_pred):
                edges.add((v_src, u))
        if v_sink is not None:
            for u in sorted(sink_units - _has_succ):
                edges.add((u, v_sink))

        # A sense/equation dependency is ALSO a
        # real directional edge, same as an ordinary driver->receiver
        # pin: "the purple flight line from the net to the equation
        # should also be a net->E-source edge input to Sugiyama."  The
        # net (or source) being sensed behaves like an input to the
        # sensing instance — its carrier/source unit ranks before the
        # sensing unit, same convention as every other input.
        ref_to_unit = {}
        for ui in range(nU):
            for k in unit_members[ui]:
                r = ref_of.get(k)
                if r is not None:
                    ref_to_unit[r] = ui
        by_ref_lc = {i.comp['ref'].lower(): i for i in _cluster_insts}
        # Track which edges came from a sense
        # dependency (equation V()/I() reads) as opposed to a real
        # driver->receiver PIN connection, so a later cycle-breaking
        # pass can prefer to drop the lower-confidence sense edge
        # rather than a genuine electrical connection when the two
        # conflict — see that pass's own comment for the full case.
        sense_edges = set()

        def _resolve_sense_src(name):
            o = by_ref_lc.get(name)
            if o is not None:
                return o
            return next((v for r, v in by_ref_lc.items()
                        if r.endswith('.' + name) or r.endswith('_' + name)),
                        None)

        for ui in range(nU):
            for k in unit_members[ui]:
                inst = inst_by_id.get(k)
                if inst is None:
                    continue
                comp = inst.comp
                for s in (comp.get('sense_nets') or []):
                    nl = s.lower()
                    # Only real power nets count here; the one-hop rail-adjacent
                    # classifier is too broad and swept up signal nets like
                    # LP2951_PGY.
                    if nl in _PWR_NETS_LC_FOR_T:
                        continue        # a rail isn't a real "driver"
                    for cu in net_units.get(nl, ()):
                        if cu != ui:
                            edges.add((cu, ui))
                            sense_edges.add((cu, ui))
                for s in (comp.get('sense_srcs') or []):
                    src_inst = _resolve_sense_src(s.lower())
                    if src_inst is None:
                        continue
                    su = ref_to_unit.get(src_inst.comp['ref'])
                    if su is not None and su != ui:
                        edges.add((su, ui))
                        sense_edges.add((su, ui))

        # The edges above can form a real cycle (a drive edge on one net plus a
        # sense edge on another).  Break it with the same Eades-Lin-Smyth pass,
        # dropping sense edges before drive edges, which set where parts sit.
        _fd = getattr(self, '_flow_dist', None) or {}
        _uprio = {}
        for ui in range(nU):
            ds = [_fd[ref_of[k]] for k in (unit_members[ui]
                                           if ui < nU_real else ())
                  if ref_of.get(k) in _fd]
            if ds:
                _uprio[ui] = min(ds)
        if edges:
            hard_edges = edges - sense_edges
            _base_pos = (_greedy_feedback_arc_order(range(nU), hard_edges,
                                                    _uprio)
                         if hard_edges else {i: i for i in range(nU)})
            _rank_edges = set(hard_edges)
            for (a, b) in sense_edges:
                if _base_pos.get(a, 0) < _base_pos.get(b, 0):
                    _rank_edges.add((a, b))
            _final_pos = _greedy_feedback_arc_order(range(nU), _rank_edges,
                                                    _uprio)
            _rank_edges = {(a, b) for (a, b) in _rank_edges
                           if _final_pos.get(a, 0) < _final_pos.get(b, 0)}
        else:
            _rank_edges = edges

        seed = {i: 1 for i in range(nU)}
        succ = defaultdict(list)
        indeg = defaultdict(int)
        for a, b in sorted(_rank_edges):
            succ[a].append(b)
            indeg[b] += 1
        q = deque([i for i in range(nU) if indeg[i] == 0])
        while q:
            u = q.popleft()
            for v in succ[u]:
                if seed[v] < seed[u] + 1:
                    seed[v] = seed[u] + 1
                indeg[v] -= 1
                if indeg[v] == 0:
                    q.append(v)
        # IO-port edges carry the SAME weight as a real pin edge.  Tried
        # down-weighting them to 0.25 on the theory that a port is a
        # weaker constraint; measured worse (LM324.sub gave back its whole
        # 40 -> 27 crossing win and nothing else improved), because the
        # virtual node still fixes rank ORDER through the longest-path seed
        # and the feedback-arc pass regardless of its simplex weight — the
        # weight only tunes edge length, not direction.
        ns_edges = [(a, b, 1.0) for (a, b) in sorted(_rank_edges) if a != b]
        # Debug: the edge set ranking actually saw, so "why is X at rank
        # N?" can be answered from a normal run.  A unit with no entry
        # here has no ranking constraint at all and simply keeps its
        # seed rank.
        self._dbg_rank_edges = sorted(_rank_edges)
        ranks = (self._network_simplex_core(list(range(nU)), ns_edges, seed)
                 if ns_edges else {i: 1 for i in range(nU)})
        self._dbg_ranks_raw = dict(ranks)      # simplex output, pre-adjust
        self._dbg_rank_seed = dict(seed)

        # Virtual source/sink units have now done
        # their ONE job (pulling real units' ranks toward the correct
        # side); drop them here, before anything below that assumes
        # every unit index has real geometry (unit_ref/unit_nets/
        # unit_members/bbox etc, none of which exist for a virtual
        # index).  Their influence survives in `ranks[real_unit]`,
        # which is exactly what's needed — nothing past this point
        # cares HOW a real unit's rank got pulled, only what it is.
        edges = {(a, b) for (a, b) in edges if a < nU_real and b < nU_real}
        nU = nU_real

        adj = defaultdict(set)
        for a, b in edges:
            adj[a].add(b)
            adj[b].add(a)

        # Sugiyama-style placement with dummy nodes: an edge spanning more than
        # one rank gets a dummy vertex per rank it crosses, so ordering and
        # coordinate assignment route long nets through ordered tracks.
        layout_edges = edges
        order_by_rank = sorted(range(nU), key=lambda u: (ranks[u],
                                                         unit_ref[u]))
        # Build a layered graph: real units keep their id; dummies are new
        # ids >= nU.  vlayer[v] = rank, vadj = ordering adjacency (per rank
        # step), vsize_h for coordinate separation (dummies ~0 height).
        vlayer = {u: ranks[u] for u in range(nU)}
        vadj = defaultdict(set)            # layer-adjacent links only
        vchain = {}                        # (a,b) real edge -> [dummy ids]
        next_id = nU
        for (a, b) in sorted(layout_edges):
            ra, rb = ranks[a], ranks[b]
            lo, hi = (a, b) if ra <= rb else (b, a)
            rlo, rhi = ranks[lo], ranks[hi]
            if rhi - rlo <= 1:
                vadj[lo].add(hi); vadj[hi].add(lo)
                continue
            # insert dummies at each intermediate rank
            prev = lo
            chain = []
            for rr in range(rlo + 1, rhi):
                d = next_id; next_id += 1
                vlayer[d] = rr
                chain.append(d)
                vadj[prev].add(d); vadj[d].add(prev)
                prev = d
            vadj[prev].add(hi); vadj[hi].add(prev)
            vchain[(lo, hi)] = chain
        nV = next_id

        layers = defaultdict(list)
        for v in range(nV):
            layers[vlayer[v]].append(v)
        layer_ids = sorted(layers)

        def _vref(v):
            return unit_ref[v] if v < nU else 'zz~%05d' % v
        for r in layer_ids:
            layers[r].sort(key=_vref)
        pos_in_layer = {}
        for r in layer_ids:
            for i, v in enumerate(layers[r]):
                pos_in_layer[v] = i

        # Port positions for the barycenter (Schulze et al., JVLC 2014): an edge
        # arrives at its pin's fixed position and order, not at the unit's
        # center, so pin order drives the sweep.
        _port_rank_cache = {}

        def _unit_port_ranks(u):
            """{net: (rank, n_ports)} for unit u, pins ordered top-down.

            The FIXED_ORDER sequence.  Read from the same geometry the
            renderer draws, so the order used to minimise crossings is
            the order the reader sees."""
            hit = _port_rank_cache.get(u)
            if hit is not None:
                return hit
            ys = {}
            for k in unit_members[u]:
                inst = inst_by_id.get(k)
                if inst is None:
                    continue
                try:
                    mem_oy = _member_local_offset(u, k)[1]
                except Exception:
                    continue
                for pn, nn in (inst._pin_net_pairs or []):
                    net = str(nn).lower()
                    if net in ys:
                        continue
                    geom = inst.sym_entry.get('pins', {}).get(str(pn))
                    if geom is None:
                        continue
                    try:
                        _rx, ry = inst.kicad_rel(geom[0], geom[1])
                    except Exception:
                        continue
                    ys[net] = mem_oy + ry
            order = sorted(ys, key=lambda n: (ys[n], n))
            n = len(order)
            out = {net: (i, n) for i, net in enumerate(order)}
            _port_rank_cache[u] = out
            return out

        # A dummy carries its chain's net, so the REAL unit at the far
        # end of a chain is still entered at the right pin.
        _chain_net = {}
        for (_lo, _hi), _chain_v in vchain.items():
            _k = (_lo, _hi) if _lo <= _hi else (_hi, _lo)
            _ns = edge_nets.get(_k) or (unit_nets[_lo] & unit_nets[_hi])
            _n = min(_ns) if _ns else None
            for _d in _chain_v:
                _chain_net[_d] = _n

        def _edge_net(a, b):
            """The net the edge (a, b) actually carries.

            Recorded at edge-construction time by _add_edge, where the
            producing net is known.  The set-intersection fallback is
            only for edges no net produced (sense edges, IO anchors)."""
            if a >= nU:
                return _chain_net.get(a)
            if b >= nU:
                return _chain_net.get(b)
            ns = edge_nets.get((a, b) if a <= b else (b, a))
            if ns:
                return min(ns)
            sh = unit_nets[a] & unit_nets[b]
            return min(sh) if sh else None

        def _port_offset(u, net):
            """Where on u the pin carrying `net` sits, in (0, 1).

            Dummies have no ports and take the centre, the standard
            treatment for chain vertices."""
            if u >= nU or net is None:
                return 0.5
            tab = _unit_port_ranks(u)
            if net not in tab:
                return 0.5
            r, n = tab[net]
            return (r + 1.0) / (n + 1.0)

        # ── Crossing reduction: barycenter sweeps over the layered graph
        # (dummies included).
        _ports_on = getattr(self, '_port_constraints', False)

        def _nbr_pos(w, v):
            """In : a neighbour w and the node v.  Out: w's contribution
            to v's barycenter.
            SYMMETRIC in the ports at both ends, which is what makes this
            FIXED_ORDER rather than merely port-aware: v's ports are in a
            fixed sequence, so an edge leaving v's top pin wants v placed
            LOWER to meet its neighbour and one leaving the bottom pin
            wants it higher.  Adding only w's port offset knew which pin
            an edge arrived at but pretended every edge left v from its
            middle.  Reduces to the node-index barycenter when both ends
            are dummies."""
            if not _ports_on:
                return pos_in_layer[w]
            net = _edge_net(v, w)
            return (pos_in_layer[w] + _port_offset(w, net)
                    - _port_offset(v, net))

        def _bary_sweep(forward):
            seq = layer_ids if forward else list(reversed(layer_ids))
            for r in seq[1:]:
                ref_layer = (r - 1) if forward else (r + 1)
                if ref_layer not in layers:
                    continue
                bary = {}
                for v in layers[r]:
                    nbrs = [w for w in vadj[v] if vlayer[w] == ref_layer]
                    bary[v] = (sum(_nbr_pos(w, v) for w in nbrs) / len(nbrs)
                               if nbrs else pos_in_layer[v])
                layers[r].sort(key=lambda v: (bary[v], _vref(v)))
                for i, v in enumerate(layers[r]):
                    pos_in_layer[v] = i

        def _endpoint_pos(v, net):
            """v's position refined by the port this edge uses."""
            if not _ports_on:
                return pos_in_layer[v] + 0.5
            return pos_in_layer[v] + _port_offset(v, net)

        # Nets ending in T-symbols draw no flight line, so they cannot cross
        # anything and must not steer the sweep.
        try:
            _t_nets = {str(_n).lower()
                       for _n in (self._eligible_t_nets() or ())}
        except Exception:
            _t_nets = set()

        def _total_crossings():
            """Out: crossings summed over every adjacent layer pair,
            counted between PORTS when port constraints are on, and only
            over the edges the renderer DRAWS.
            The barycenter sweep is a heuristic that can make an ordering
            worse, so counting keeps the loop monotone: what reaches BK
            is never worse than what the sweeps started from.
            Still an approximation — a multi-pin net is a chain here and
            a Manhattan MST (or a hub above 8 pins) on the canvas — but
            dropping the T-consumed nets closes most of that gap without
            needing coordinates, which do not exist yet."""
            total = 0
            for ci in range(len(layer_ids) - 1):
                rU, rL = layer_ids[ci], layer_ids[ci + 1]
                es = []
                for v in layers[rU]:
                    for w in vadj[v]:
                        if vlayer[w] != rL:
                            continue
                        net = _edge_net(v, w)
                        if net is not None and str(net).lower() in _t_nets:
                            continue
                        es.append((_endpoint_pos(v, net),
                                   _endpoint_pos(w, net)))
                es.sort()
                # inversions in the lower endpoint == crossings
                for i in range(len(es)):
                    for j in range(i + 1, len(es)):
                        if es[j][1] < es[i][1]:
                            total += 1
            return total

        def _snapshot():
            return {r: list(layers[r]) for r in layer_ids}

        def _restore(snap):
            for r in layer_ids:
                layers[r] = list(snap[r])
                for i, v in enumerate(layers[r]):
                    pos_in_layer[v] = i

        _kb_margin = float(getattr(self, '_keep_best_margin', 0.0))
        _best_n = _total_crossings()
        _best = _snapshot()
        for _ in range(6):
            for _fwd in (True, False):
                _bary_sweep(_fwd)
                _n = _total_crossings()
                if _n < _best_n * (1.0 - _kb_margin) - 1e-9:
                    _best_n, _best = _n, _snapshot()
        _restore(_best)
        # Keep-best: the barycenter sweeps keep the ordering with the fewest
        # drawn crossings rather than whatever the last sweep left.

        # Mirror/orientation pass: order is fixed, so this only changes a
        # pure-signal R/L/C unit's own rotation, never its rank or position.
        pwr_adjacent = self._pwr_gnd_adjacent_nets(list(inst_by_id.values()))

        def _flip_eligible(inst):
            kind = inst.comp.get('kind', '').upper()
            if kind in ('R', 'L'):
                return True
            if kind == 'C':
                return 'polarized' not in inst.comp.get('sym', '').lower()
            return False

        def _flip_cost(inst, deg, pin_net_pairs, my_rank):
            try:
                _, offs_by_num = self._rotated_pins_by_num(inst, deg, False)
            except Exception:
                return 0.0
            # Direction is scored only when the netlist rules are off; with them
            # on, rules 1-4 have already aimed every part and re-aiming fights
            # them.
            if self._ROTATION_RULES_ONLY:
                return 0.0
            cost = 0.0
            for pn, net in pin_net_pairs:
                dx, dy = offs_by_num.get(pn, (0.0, 0.0))
                for w in net_units.get(net.lower(), ()):
                    if w == my_rank[1] or w not in ranks:
                        continue
                    drank = ranks[w] - my_rank[0]
                    if drank == 0:
                        continue
                    aligned = ((dx > 0) == (drank > 0)) \
                        if abs(dx) >= abs(dy) else False
                    if not aligned:
                        cost += abs(drank)
            return cost

        for ui in range(nU):
            u_rank = ranks[ui]
            for k in unit_members[ui]:
                inst = inst_by_id.get(k)
                ref = ref_of.get(k)
                if inst is None or ref is None or ref in self._user_rotations:
                    continue
                if not _flip_eligible(inst):
                    continue
                pin_net_pairs = getattr(inst, '_pin_net_pairs', None) or []
                if len(pin_net_pairs) != 2:
                    continue
                if self._has_rail_pin(inst):
                    continue          # rules 1-2 own this rotation
                if self._rigid_unit_of(ref) is not None:
                    continue          # the P2DL cell owns this rotation,
                    # and its spacing was measured at it
                axis = self._orient_class.get(ref)
                if axis is None:
                    continue          # never classified — leave alone
                # From the INSTANCE, not _auto_rotations: a part the
                # rules left alone has no map entry, so the map's 0
                # default made an untouched part look off-axis.  The
                # geometry is the truth, as it is for
                # _sync_auto_rotations.
                cur_rot = self._user_rotations.get(
                    ref, inst.rotation_deg or 0) or 0
                # A power/ground-ADJACENT part keeps its AXIS but may
                # still turn 180 within it: that swaps which pin faces
                # east without moving either up or down, so it cannot
                # disturb a rail relationship.  A part with a pin ON a
                # rail is excluded above, where the axis IS the
                # constraint.
                locked = (self._ROTATION_RULES_ONLY
                          or ref in ref_to_blocks
                          or any(n.lower() in pwr_adjacent
                                 for _, n in pin_net_pairs))
                if locked:
                    candidates = [90, 270] if axis == 'H' else [0, 180]
                else:
                    candidates = [0, 90, 180, 270]
                best_rot, best_cost = cur_rot, None
                for deg in candidates:
                    c = _flip_cost(inst, deg, pin_net_pairs, (u_rank, ui))
                    if (best_cost is None or c < best_cost - 1e-9
                            or (abs(c - best_cost) <= 1e-9
                                and deg == cur_rot)):
                        best_cost, best_rot = c, deg
                if best_rot != cur_rot:
                    self._auto_rotations[ref] = best_rot
                    self._orient_class[ref] = ('V' if best_rot % 180 == 0
                                                else 'H')

        # unit_ext/unit_bbox — computed HERE, after
        # ranking/ordering AND the mirror pass, so a mirrored rotation is
        # always reflected in the measured extent.  Ranking and the
        # barycenter sweeps above never consulted bbox, only net topology.
        unit_ext = []
        for ui in range(nU):
            kind = unit_subitems[ui][0][0]
            L = T = R = B = None
            # EVERY sub-item, at the offset compose will use.  This loop
            # used to unpack `unit_subitems[ui][0]` and measure only that
            # first sub-item's members, so a unit's second and later
            # sub-items contributed NOTHING to the box Sugiyama was given
            # — the rest of the unit was invisible to the packer.
            for _kind, _bid, ks in unit_subitems[ui]:
                for k in ks:
                    me = _member_local_extent(k)
                    ox, oy = _member_local_offset(ui, k)
                    mL, mT = ox + me[0], oy + me[1]
                    mR, mB = ox + me[2], oy + me[3]
                    L = mL if L is None else min(L, mL)
                    T = mT if T is None else min(T, mT)
                    R = mR if R is None else max(R, mR)
                    B = mB if B is None else max(B, mB)
            grouped = (kind == 'block') or (len(unit_members[ui]) >= 2)
            pad = GROUP_PAD if grouped else 0.0
            unit_ext.append((L - pad, T - pad, R + pad, B + pad))

        unit_bbox = []
        for ui in range(nU):
            e = unit_ext[ui]
            unit_bbox.append((e[2] - e[0], e[3] - e[1]))

        # ── Coordinate assignment.  Within each layer, stack vertices in the
        # fixed barycenter order with min separation (real units use their
        # bbox height; dummies use a thin DUMMY_H track).  Then relax each
        # vertex toward the median of its layer-adjacent neighbours' y,
        # WITHOUT re-sorting (order is fixed), re-applying separation after
        # each pass.  This keeps connected vertices aligned (horizontal nets)
        # while dummies carry long nets straight through.
        DUMMY_H = 12.0

        def _vh(v):
            return unit_bbox[v][1] if v < nU else DUMMY_H

        # Y coordinates come from Brandes-Kopf (_bk_align_layered_graph), which
        # aligns each vertex with a median neighbour directly instead of
        # relaxing toward it.
        bk_vy = (None if getattr(self, '_skip_bk', False) else
                 self._bk_align_layered_graph(
                     layers, layer_ids, vadj, vlayer, pos_in_layer, _vh,
                     is_dummy=lambda v: v >= nU))
        if bk_vy is not None and all(v in bk_vy for v in range(nV)):
            vy = bk_vy
        else:
            vy = {}
            for r in layer_ids:
                yy = 0.0
                for v in layers[r]:
                    vy[v] = yy
                    yy += _vh(v) + GAP
            if getattr(self, '_skip_bk', False):
                _relax_passes = 0        # raw stack only, see above
            else:
                _relax_passes = 12

            def _separate(r):
                ordered = layers[r]
                for i in range(1, len(ordered)):
                    a, b = ordered[i - 1], ordered[i]
                    lo = vy[a] + _vh(a) + GAP
                    if vy[b] < lo:
                        vy[b] = lo

            for _ in range(_relax_passes):
                for r in layer_ids:
                    for v in layers[r]:
                        nbrs = list(vadj[v])
                        if not nbrs:
                            continue
                        ys = sorted(vy[w] + (_vh(w) - _vh(v)) / 2.0
                                    for w in nbrs)
                        n = len(ys)
                        vy[v] = (ys[n // 2] if n % 2 else
                                 (ys[n // 2 - 1] + ys[n // 2]) / 2.0)
                    _separate(r)
                for r in reversed(layer_ids):
                    _separate(r)


        # Snap isolated units (no cross-rank neighbours) next to their nearest
        # connected same-rank neighbour; the relaxation above never moves them.
        for r in layer_ids:
            ordered = layers[r]
            for i, v in enumerate(ordered):
                if v >= nU or vadj[v]:
                    continue        # dummy, or has real neighbours
                before = next((ordered[j] for j in range(i - 1, -1, -1)
                              if ordered[j] >= nU or vadj[ordered[j]]),
                             None)
                after = next((ordered[j] for j in range(i + 1, len(ordered))
                             if ordered[j] >= nU or vadj[ordered[j]]),
                            None)
                if before is not None and after is not None:
                    d_before = abs(vy[v] - (vy[before] + _vh(before)))
                    d_after = abs(vy[v] - (vy[after] - _vh(v)))
                    use_before = d_before <= d_after
                else:
                    use_before = before is not None
                if use_before and before is not None:
                    vy[v] = vy[before] + _vh(before) + GAP
                elif after is not None:
                    vy[v] = vy[after] - _vh(v) - GAP

        # Close excess within-rank gaps: a vertex with no cross-rank neighbours
        # never moves off its initial stack spot, so cap the gap at GAP_CAP.
        GAP_CAP = 400.0
        for r in layer_ids:
            ordered = layers[r]
            for i in range(1, len(ordered)):
                a, b = ordered[i - 1], ordered[i]
                slack = vy[b] - (vy[a] + _vh(a) + GAP)
                excess = slack - GAP_CAP
                if excess > 0:
                    for j in range(i, len(ordered)):
                        vy[ordered[j]] -= excess

        ymin = min((vy[u] for u in range(nU)), default=0.0)
        unit_y = {u: vy[u] - ymin for u in range(nU)}

        # Member-y helper shared by pin-align and compose: y of member k's
        # ORIGIN within unit u, laying sub-items (cached blocks coherent,
        # loose members individually) stacked from the unit top.  Block
        # members use their block-relative offset; this matches the compose
        # exactly so pin-align predicts the final geometry.
        def _member_local_y(u, k):
            """Absolute y of member k — the unit's y plus the shared
            sub-item offset, so the y a member is DRAWN at and the y the
            extent RESERVED for it come from one routine."""
            return unit_y[u] + _member_local_offset(u, k)[1]

        def _pin_local_y(u, net):
            """Local-frame y of the pin on unit u carrying `net`."""
            ny = None
            for k in unit_members[u]:
                inst = inst_by_id.get(k)
                if inst is None or net not in nets_of[k]:
                    continue
                mem_oy = _member_local_y(u, k)
                for pn, nn in (inst._pin_net_pairs or []):
                    if str(nn).lower() != str(net):
                        continue
                    geom = inst.sym_entry.get('pins', {}).get(str(pn))
                    if geom is None:
                        continue
                    _rx, ry = inst.kicad_rel(geom[0], geom[1])
                    cand = mem_oy + ry
                    if ny is None:
                        ny = cand
            return ny

        # ── Pin-align as a BOUNDED nudge: shift a single-predecessor unit so
        # its connecting pin matches the predecessor's pin y, within
        # +/- bound, rejecting any move that overlaps a same-layer neighbour.
        row_pitch = max((unit_bbox[u][1] for u in range(nU)), default=0.0)
        bound = row_pitch / 2.0 if row_pitch else 0.0
        # A unit can align with one neighbour in an adjacent rank in either
        # direction; predecessors alone left over half of OPAx197's units
        # unalignable.
        for u in order_by_rank:
            preds = [nb for nb in adj[u] if ranks[nb] < ranks[u]]
            if len(preds) == 1:
                p = preds[0]
            elif not preds:
                succs = [nb for nb in adj[u] if ranks[nb] > ranks[u]]
                if len(succs) != 1:
                    continue
                p = succs[0]
            else:
                continue
            shared = unit_nets[p] & unit_nets[u]
            if not shared:
                continue
            net = sorted(shared)[0]
            py = _pin_local_y(p, net)
            uy = _pin_local_y(u, net)
            if py is None or uy is None:
                continue
            shift = max(-bound, min(bound, py - uy))
            new_top = unit_y[u] + shift
            ok = True
            for v in layers[ranks[u]]:
                if v == u or v >= nU:
                    continue
                if (new_top < unit_y[v] + unit_bbox[v][1] + GAP and
                        unit_y[v] < new_top + unit_bbox[u][1] + GAP):
                    ok = False
                    break
            if ok:
                if abs(new_top - unit_y[u]) > 0.01:
                    _d = getattr(self, '_dbg_pinalign', None)
                    if _d is None:
                        _d = self._dbg_pinalign = {'back': 0, 'fwd': 0,
                                                   'px': 0.0}
                    _d['back' if preds else 'fwd'] += 1
                    _d['px'] += abs(new_top - unit_y[u])
                unit_y[u] = new_top


        # ── X by COLLISION-FREE SWEEP.  Sweep units L->R in rank order; a
        # unit's left edge is pushed right of (a) each placed predecessor +
        # FLIGHT_GAP and (b) any already-placed unit overlapping it in Y, by
        # CLEAR.  FLIGHT_GAP is the inter-layer flight run (replaces the old
        # 30px WIRE / 100px guess); CLEAR keeps vertically-overlapping units
        # apart.  This lets units find their natural x (better than forcing a
        # rigid per-rank column) while keeping connected units a short,
        # readable flight apart.
        FLIGHT_GAP = 30.0
        CLEAR = 30.0
        # PER-RANK LEFT BARRIER: the sweep alone pushes a unit right only past
        # its own predecessors and Y-overlapping units, so rank k+1 could start
        # left of rank k.  This keeps each rank right of the one before it.
        RANK_GAP = 0.0

        def y_overlap(a, b, pad=0.0):
            ay0, ay1 = unit_y[a], unit_y[a] + unit_bbox[a][1]
            by0, by1 = unit_y[b], unit_y[b] + unit_bbox[b][1]
            return ay0 - pad < by1 and by0 - pad < ay1 + pad

        # a closure, so the MST/BK refinement below can
        # re-run it against an updated unit_y instead of a second copy of
        # the same sweep drifting out of step with this one.
        unit_x = {}

        def _assign_x():
            unit_x.clear()
            # PER-ROW rank barrier: rank k+1 may not start left of a rank-k unit
            # that shares one of its rows.  A single per-rank maximum let one
            # wide box wall off rows it never came near.
            placed_lower = []
            pending = []
            cur_rank = None

            # Row escape: a unit the rank barrier would push far right tries
            # stepping just above or below the blocking unit's band instead,
            # which moves it into a clear row.
            if getattr(self, '_dbg_escape', None) is None:
                self._dbg_escape = {}
            for _k in ('tried', 'evaluated', 'accepted', 'row_taken',
                       'neg_y', 'no_saving', 'cost_exceeds',
                       'saved_px', 'lost_px'):
                self._dbg_escape.setdefault(_k, 0)
            _esc_cap = getattr(self, '_rank_barrier_escape_cap', 3)
            _esc_w = getattr(self, '_rank_barrier_escape_cost_w', 1.0)
            _esc_min = getattr(self, '_rank_barrier_escape_min', 400.0)

            def _barrier_at(u, y0):
                """Barrier for unit u if its top were at y0, plus the
                unit that sets it.  Mirrors y_overlap()'s asymmetric pad
                so the escape and the sweep agree on 'shares rows'."""
                y1 = y0 + unit_bbox[u][1]
                best, who = 0.0, None
                for v, v_right in placed_lower:
                    if v_right <= best:
                        continue
                    vy0 = unit_y[v]
                    vy1 = vy0 + unit_bbox[v][1]
                    if y0 - _pad < vy1 and vy0 - _pad < y1 + _pad:
                        best, who = v_right, v
                return best, who

            def _free_slots(u, h):
                """In : a unit u and the height h it needs.  Out: the
                candidate tops — the FREE gaps in its own rank's row
                structure, plus above the topmost and below the
                bottommost peer.
                Two fixed candidates either side of the blocker were not
                enough: on OPAx197, 188 of 316 landed on an occupied row
                and another 88 cleared one blocker only to sit in the
                next one's band.  Enumerating the gaps that can actually
                HOLD the unit makes every candidate free by construction,
                and the barrier is evaluated at each so the best wins."""
                peers = sorted((unit_y[v], unit_y[v] + unit_bbox[v][1])
                               for v in layers[ranks[u]]
                               if v != u and v < nU)
                need = h + 2.0 * GAP
                out = []
                if peers:
                    out.append(peers[0][0] - need)
                    for _i in range(1, len(peers)):
                        a1 = peers[_i - 1][1]
                        if peers[_i][0] - a1 >= need:
                            out.append(a1 + GAP)
                    out.append(peers[-1][1] + GAP)
                return out

            def _row_free(u, y0):
                h = unit_bbox[u][1]
                for v in layers[ranks[u]]:
                    if v == u or v >= nU:
                        continue
                    if (y0 < unit_y[v] + unit_bbox[v][1] + GAP
                            and unit_y[v] < y0 + h + GAP):
                        return False
                return True

            for u in order_by_rank:
                if ranks[u] != cur_rank:
                    placed_lower.extend(pending)
                    pending = []
                    cur_rank = ranks[u]
                barrier = 0.0
                _pad = getattr(self, '_rank_barrier_pad', 0.0)
                barrier, _blk = _barrier_at(u, unit_y[u])
                _dbg = self._dbg_escape
                # Escape only where the push is PATHOLOGICAL.  Firing
                # on every unit costs crossings badly (LM324.lib
                # 7 -> 13) for a small slack gain, and rightly so: Y
                # belongs to crossing minimisation, so moving a
                # unit's row afterwards undoes that pass's work by
                # construction.  It is only worth undoing when the
                # unit is pushed far beyond what its OWN connections
                # require -- exactly the accumulation case, and rare.
                _own = 0.0
                for _v, _vr in placed_lower:
                    if _vr <= _own or not y_overlap(u, _v, _pad):
                        continue
                    if not unit_nets[u] or not unit_nets[_v]:
                        continue
                    _sh = {n for n in unit_nets[u] & unit_nets[_v]
                           if n not in _rail_nets
                           and n not in _PWR_NETS_LC_FOR_T}
                    if _sh:
                        _own = _vr
                if barrier - _own < _esc_min:
                    _blk = None
                for _ in range(_esc_cap):
                    if _blk is None or barrier <= 0.0:
                        break
                    _dbg['tried'] += 1
                    y_here = unit_y[u]
                    h = unit_bbox[u][1]
                    cands = _free_slots(u, h)
                    best = None
                    for cy in cands:
                        if cy < 0.0:
                            _dbg['neg_y'] += 1
                            continue
                        if not _row_free(u, cy):
                            _dbg['row_taken'] += 1
                            continue
                        b2, blk2 = _barrier_at(u, cy)
                        saved = barrier - b2
                        cost = abs(cy - y_here)
                        _dbg['evaluated'] += 1
                        if saved <= 0:
                            _dbg['no_saving'] += 1
                        elif saved <= cost:
                            _dbg['cost_exceeds'] += 1
                            _dbg['lost_px'] += saved
                        if saved > cost * _esc_w and (best is None
                                                      or b2 < best[0]):
                            best = (b2, cy, blk2)
                    if best is None:
                        break
                    _dbg['accepted'] += 1
                    _dbg['saved_px'] += barrier - best[0]
                    barrier, _newy, _blk = best
                    unit_y[u] = _newy
                # barrier is a TRUE-EXTENT edge, so compare it against
                # this unit's own true left (origin + unit_ext[u][0]).
                # unit_bbox is the body alone; the drawn span, the
                # separation pass, and the user's eye all work on
                # body+labels, so a barrier in body coordinates lets a
                # left-hanging value label cross back over it.
                want = barrier - unit_ext[u][0]
                for p in (nb for nb in adj[u] if ranks[nb] < ranks[u]):
                    if p in unit_x:
                        want = max(want,
                                   unit_x[p] + unit_bbox[p][0] + FLIGHT_GAP)
                for v in unit_x:
                    if y_overlap(u, v):
                        want = max(want,
                                   unit_x[v] + unit_bbox[v][0] + CLEAR)
                unit_x[u] = want
                pending.append((u, want + unit_ext[u][2] + RANK_GAP))
            for u in range(nU):
                unit_x.setdefault(u, 0.0)

        _assign_x()

        # Order-preserving separation: push overlapping unit flight boxes apart
        # along the axis of smaller overlap, keeping the Sugiyama order.
        _sep_items = []

        def _separate_units():
            # rebuild in place so _residual_conflicts below always
            # inspects the LAST separation run, not the first one.
            _sep_items[:] = [[unit_x[u], unit_y[u], unit_ext[u]]
                             for u in range(nU)]
            _separate_boxes(_sep_items, sep=6.0, max_iter=200)
            for u in range(nU):
                unit_x[u] = _sep_items[u][0]
                unit_y[u] = _sep_items[u][1]

        _separate_units()

        _ul = getattr(self, '_dbg_unit_layout', None)
        if not isinstance(_ul, list):
            _ul = []
        _base = len(_ul)      # this runs once per LANE; keep them all
        _lane = len({e['lane'] for e in _ul}) if _ul else 0
        _ul.extend(
            {'unit': _base + u, 'lane': _lane,
             'ref': (unit_ref[u] if u < len(unit_ref) else f'unit{u}'),
             'xy': (unit_x[u], unit_y[u]),
             'ext': tuple(unit_ext[u]),
             'members': [ref_of.get(k) for k in unit_members[u]]}
            for u in range(nU))
        self._dbg_unit_layout = _ul
        for u in _residual_conflicts(_sep_items):
            ex = unit_ext[u]
            label = unit_ref[u] if u < len(unit_ref) else f'unit{u}'
            self._placement_errors.append(
                (str(label), unit_x[u] + ex[0], unit_y[u] + ex[1],
                 ex[2] - ex[0], ex[3] - ex[1]))

        # ── Compose: lay out each unit's SUB-ITEMS (cached blocks coherent,
        # loose members individually).  Y from _member_local_y (shared with
        # pin-align); X: block members at block-relative x from the unit
        # left, loose members at the unit left.
        loc = {}
        for ui in range(nU):
            ux = unit_x[ui]
            # advance a running X cursor across sub-items so a
            # LOOSE member (e.g. E2, merged in from a second block that shared
            # a ref with the primary block) is placed to the RIGHT of the
            # primary block instead of stacked at the unit's left edge.  The
            # old code set every loose member to (ux - bb[0]), i.e. all at the
            # unit left, so a block+loose unit overlapped (OPAX197 group 163:
            # X_U1.R5 over X_U1.E2's sense body).  Mirrors the Y cursor logic.
            for _kind, _bid, ks in unit_subitems[ui]:
                for k in ks:
                    dx, dy = _member_local_offset(ui, k)
                    loc[k] = (ux + dx, unit_y[ui] + dy)
        # Lightweight debug capture: per-cluster
        # rank/layer/Y internals, appended (not overwritten) so a design
        # with multiple clusters keeps all of them.  Cheap (small dicts of
        # numbers), always on, so a vertical-gap report can be diagnosed
        # from a normal run without special instrumentation.  See §7.
        if not hasattr(self, '_dbg_lane_info'):
            self._dbg_lane_info = []
        self._dbg_lane_info.append({
            'ranks': dict(ranks), 'unit_ref': list(unit_ref),
            'unit_x': dict(unit_x), 'unit_y': dict(unit_y),
            'unit_bbox': list(unit_bbox), 'nU': nU,
            'layers': {r: list(v) for r, v in layers.items()},
            # ref-level membership so a consumer can map a REF to its
            # unit (and so its rank) — unit_ref alone only names each
            # unit's alphabetically-first member, which loses every
            # other member of a multi-part unit such as a diff pair.
            # ref -> (rank, order-within-layer), so the
            # --rank-grid overlay can draw the Sugiyama structure over the
            # finished schematic.  Rank is the LAYER (drawn as a vertical
            # band, since this layout runs left-to-right); order is the
            # position within that layer after crossing reduction (drawn
            # as horizontal dividers inside the band).
            'rank_of_ref': {ref_of[k]: ranks[u]
                            for u in range(nU) for k in unit_members[u]},
            'order_of_ref': {
                ref_of[k]: oi
                for r in layer_ids
                for oi, v in enumerate([w for w in layers[r] if w < nU])
                for k in unit_members[v]},
            'unit_member_refs': [[ref_of[k] for k in unit_members[u]]
                                 for u in range(nU)],
            'rank_edges': list(getattr(self, '_dbg_rank_edges', []) or []),
            'ranks_raw': dict(getattr(self, '_dbg_ranks_raw', {}) or {}),
            'rank_seed': dict(getattr(self, '_dbg_rank_seed', {}) or {}),
        })
        return loc

    def _apply_cached_blocks_local(self, instances, positions):
        """In : the instances and `positions`, keyed by id(inst).
        Out: the cached _sp_block_layout geometry overlaid for any block
        fully present, restoring a cell's internal 2-D arrangement.
        Runs AFTER the P2DL group phase, so the cache is populated for
        THIS run; calling it during the per-cluster layout used the
        previous Place's cache and broke startup-equals-button parity.
        Anchoring is DETERMINISTIC: the cached cell's bounding-box
        top-left is pinned to the member set's current top-left, so the
        cell keeps the slot the layout chose for it and the result
        depends on this run alone."""
        blocks = getattr(self, '_sp_block_layout', None)
        if not blocks:
            return
        by_ref = {i.comp['ref']: i for i in instances}
        present = set(by_ref)
        # when two cached blocks SHARE a member (e.g. {R4,R5}
        # and {E2,R4} share R4), applying BOTH independently clobbers the
        # shared member's position and the two non-shared members (R5, E2)
        # were never spaced against each other, so they overlap (OPAX197
        # group 163).  Apply a NON-OVERLAPPING set only: take blocks
        # largest-first and skip any whose members intersect an already-
        # applied block.  The skipped block's unique members keep the
        # position the lane layout gave them (which already spaces sub-items
        # left-to-right), so the cell stays coherent and nothing overlaps.
        applied_refs = set()
        ordered = sorted(
            blocks.items(),
            key=lambda kv: (-len([r for r in kv[0] if r in present]),
                            sorted(kv[0])))
        for members, (pos, bbox) in ordered:
            # Sorted, not frozenset order: frozenset iteration follows the
            # per-process string hash.
            refs = sorted(r for r in members if r in present)
            if len(refs) < 2 or len(refs) != len(members):
                continue
            if any(id(by_ref[r]) not in positions for r in refs):
                continue
            if any(r not in pos for r in refs):
                continue
            if applied_refs.intersection(refs):
                continue        # shares a member with a bigger applied block
            base_x0 = min(positions[id(by_ref[r])][0] for r in refs)
            base_y0 = min(positions[id(by_ref[r])][1] for r in refs)
            cell_x0 = min(pos[r][0] for r in refs)
            cell_y0 = min(pos[r][1] for r in refs)
            for r in refs:
                lx, ly = pos[r]
                inst = by_ref[r]
                nx = base_x0 + (lx - cell_x0)
                ny = base_y0 + (ly - cell_y0)
                positions[id(inst)] = (nx, ny)
                inst.ox_px = nx
                inst.oy_px = ny
            applied_refs.update(refs)
            # Enforce non-overlap inside a generic SP block: its cached relpos
            # ignores labels, so separate members using _placement_extent, the
            # reserved box.
            rigid = getattr(self, '_sp_rigid_blocks', None) or set()
            if len(refs) >= 2 and frozenset(members) not in rigid:
                # a generic block may CONTAIN a rigid sub-block
                # (the diff-pair {IEE,Q1,Q2,RC1,RC2,RE1,RE2} gets merged with
                # C1/GA into a bigger generic block).  Separating the whole
                # block would break the diff-pair's column alignment, so LOCK
                # every member that belongs to ANY rigid block — the rigid
                # skeleton stays put while the free members (C1, GA, a lone
                # label-overhanging part) move to clear overlaps.
                rigid_refs = set()
                for rfs in rigid:
                    rigid_refs |= set(rfs)
                sep_items = []
                lock = set()
                for i, r in enumerate(refs):
                    inst = by_ref[r]
                    try:
                        ce = self._placement_extent(inst)
                    except Exception:
                        ce = inst.sym_body_rel
                    sep_items.append([inst.ox_px, inst.oy_px, ce])
                    if r in rigid_refs:
                        lock.add(i)
                if len(lock) < len(refs):
                    _separate_boxes(sep_items, sep=6.0, max_iter=100,
                                    lock=lock)
                    for r, it in zip(refs, sep_items):
                        inst = by_ref[r]
                        inst.ox_px = it[0]
                        inst.oy_px = it[1]
                        positions[id(inst)] = (it[0], it[1])


    def _edge_t_clamp(self, v):
        """In : a coordinate.  Out: it, floored at 5.0 in the GLOBAL
        frame only.
        _measure_cluster_true_box runs _rebuild_t_terminals in the
        cluster's LOCAL frame, where a floor would shove an edge-T inward
        and leave the packer measuring one T while the renderer drew
        another, so the measuring pass is returned unclamped and the
        packer normalises the whole cluster box instead."""
        if getattr(self, '_measuring_cluster_box', False):
            return v
        return max(5.0, v)

    def _measure_cluster_true_box(self, cl, loc):
        """In : a cluster and its `loc` positions.  Out: (minx, miny,
        maxx, maxy) in the SAME frame as `loc`; side-effect free, since
        global T state is saved and restored.
        Measures the TRUE box — the one _render draws and the packer must
        use — by positioning members at `loc` with their final rotations
        and building this cluster's T's in isolation.  T placement is
        cluster-local (a rail T sits at pin +/- T_PIN_DIST, an IO T at
        this cluster's own edge, the overlap spread is cluster-scoped),
        so the T's built here match what the global render draws.  The
        member composites are then unioned with their owned T extents."""
        # Place members at their local positions, applying final rotation.
        saved_pos = {}
        for inst in cl:
            saved_pos[id(inst)] = (inst.ox_px, inst.oy_px)
            deg = self._auto_rotations.get(inst.comp['ref'], 0) or 0
            if deg % 360 != (inst.rotation_deg or 0) % 360:
                self._apply_instance_rotation_geometry(inst, deg)
            lx, ly = loc[id(inst)]
            inst.ox_px = lx
            inst.oy_px = ly
        saved = (self._t_terminals, self._pin_to_t, self._next_t_id,
                 getattr(self, '_placement_boxes_refs', None),
                 getattr(self, '_boxes', None))
        prev_measuring = getattr(self, '_measuring_cluster_box', False)
        self._measuring_cluster_box = True
        try:
            self._placement_boxes_refs = [[i.comp['ref'] for i in cl]]
            self._rebuild_t_terminals(cl)
            inst_by_ref = {i.comp['ref']: i for i in cl}
            refs = [i.comp['ref'] for i in cl]
            box = self._cluster_true_bbox(refs, inst_by_ref)
            # Upper bound, not just the isolated measurement: T's at a cluster
            # edge depend on global context, so also union each part's predicted
            # T box.
            for inst in cl:
                try:
                    pb = self._instance_bbox_with_ts(inst)
                except Exception:
                    continue
                if not pb:
                    continue
                pb = _translate_bb(pb, inst.ox_px, inst.oy_px)
                box = (min(box[0], pb[0]), min(box[1], pb[1]),
                       max(box[2], pb[2]), max(box[3], pb[3])) \
                    if box else pb
        finally:
            self._measuring_cluster_box = prev_measuring
            (self._t_terminals, self._pin_to_t, self._next_t_id,
             self._placement_boxes_refs, self._boxes) = saved
            for inst in cl:
                inst.ox_px, inst.oy_px = saved_pos[id(inst)]
        return box

    # ── Staged placement DEBUG metrics (user request) ───────────────────
    # All gated behind self._debug_stages (default OFF), so normal runs
    # are silent.  They report the pipeline stage-by-stage so a placement
    # question can be localized to one stage at a glance.
    def _debug_segments(self, clusters, in_nets, out_nets):
        """Metric 1 — after clustering: each cluster's members, its I/O
        nets (cluster nets that are subckt IO or cross a cluster
        boundary) and its purely-internal nets."""
        if not getattr(self, '_debug_stages', False):
            return
        io_lc = {n.lower() for n in in_nets} | {n.lower() for n in out_nets}
        # net -> count of clusters that touch it (to find boundary nets)
        net_clusters = {}
        for ci, cl in enumerate(clusters):
            for inst in cl:
                for nn in inst.comp.get('nets', []) or []:
                    net_clusters.setdefault(nn.lower(), set()).add(ci)
        print('\n=== DEBUG stage 1: clusters (%d) ===' % len(clusters))
        for ci, cl in enumerate(clusters):
            refs = [inst.comp['ref'] for inst in cl]
            nets = set()
            for inst in cl:
                nets |= {nn.lower() for nn in inst.comp.get('nets', []) or []}
            io = sorted(n for n in nets
                        if n in io_lc or len(net_clusters.get(n, ())) > 1)
            internal = sorted(n for n in nets if n not in io)
            print('  cluster %2d (%2d parts): %s' % (ci, len(refs),
                                                     ', '.join(sorted(refs))))
            print('      io nets:       %s' % (', '.join(io) or '(none)'))
            print('      internal nets: %s' % (', '.join(internal) or '(none)'))

    def _debug_cluster_placed(self, cl, loc, w, h):
        """Metric 2 — after a cluster is placed+oriented: each member's
        cluster-origin x,y and rotation, plus the cluster bbox as
        upper-left .. lower-right (tkinter 0,0 is top-left)."""
        if not getattr(self, '_debug_stages', False):
            return
        refs = sorted(cl, key=lambda i: i.comp['ref'])
        print('  -- cluster placed (%d parts) bbox UL(0,0)..LR(%d,%d) '
              'w=%d h=%d' % (len(cl), round(w), round(h), round(w), round(h)))
        for inst in refs:
            lx, ly = loc[id(inst)]
            r = inst.comp['ref']
            print('       %-10s origin=(%4d,%4d) rot=%s' %
                  (r, round(lx), round(ly),
                   self._auto_rotations.get(r, 0)))

    def _debug_cluster_rows(self, ordered, offsets):
        """Metric 3 — after row-packing: each cluster's placed upper-left
        corner and which row it landed in (rows inferred from the packed
        y-offsets)."""
        if not getattr(self, '_debug_stages', False):
            return
        ys = sorted({round(offsets.get(idx, (0.0, 0.0))[1])
                     for idx in range(len(ordered))})
        row_of = {y: r for r, y in enumerate(ys)}
        print('\n=== DEBUG stage 3: cluster row placement ===')
        for idx, (cl, loc, w, h, _is_sug) in enumerate(ordered):
            ox, oy = offsets.get(idx, (0.0, 0.0))
            refs = sorted(inst.comp['ref'] for inst in cl)
            tag = refs[0] if refs else '?'
            print('  box %2d row %d UL=(%4d,%4d) w=%3d h=%3d  [%s%s]'
                  % (idx, row_of[round(oy)], round(ox), round(oy),
                     round(w), round(h), tag,
                     '…' if len(refs) > 1 else ''))

    def _index_ts_by_sole_owner(self):
        """Group the T-symbols that belong to exactly one instance.

        In   : self._t_terminals and self._pin_to_t.
        Proc : collect each T's owning refs from the pin map and keep
               only the T's whose owner set is a single ref.
        Out  : none; self._ts_by_sole_owner = {ref: [T, ...]}.

        A T with several owners is shared between parts, so no one part
        may drag it; a solely-owned T sits at a body-derived offset from
        its owner and has to travel with it (see _shift_instance)."""
        owners = {}
        for (ref, _pn), tid in (self._pin_to_t or {}).items():
            owners.setdefault(tid, set()).add(ref)
        by_ref = {}
        for t in (self._t_terminals or []):
            o = owners.get(t['id'], ())
            if len(o) == 1:
                by_ref.setdefault(next(iter(o)), []).append(t)
        self._ts_by_sole_owner = by_ref

    def _shift_instance(self, inst, dx, dy):
        """In : the instance and a canvas-space delta.
        Proc: move the origin, keep _user_positions and _placed_ref_pos
               in step so the draw-state build sees the move, and carry
               the T-symbols this instance solely owns.
        Out : none; the instance, the placement store and its own T's are
               updated.
        Labels are stored RELATIVE to the origin and travel for free.
        T's hold ABSOLUTE canvas coordinates, so a mover running after
        _rebuild_t_terminals would leave one behind on its owner.  A
        shared T is deliberately not moved: no one part may drag it."""
        inst.ox_px += dx
        inst.oy_px += dy
        ref = inst.comp['ref']
        for t in (getattr(self, '_ts_by_sole_owner', None) or {}).get(ref, ()):
            t['cx'] += dx
            t['cy'] += dy
        if ref in self._user_positions:
            ox, oy = self._user_positions[ref]
            self._user_positions[ref] = (ox + dx, oy + dy)
        elif self._placed_ref_pos and ref in self._placed_ref_pos:
            px, py = self._placed_ref_pos[ref]
            self._placed_ref_pos[ref] = (px + dx, py + dy)

    def _push_blockers_away(self, anchor_box, blockers, box_of, shift_of,
                             pad=4.0, max_rounds=8):
        """In : a FIXED anchor_box, a pool of movable blockers, and the
        box_of and shift_of callables.  Out: whether the anchor is clear.
        Pushes each blocker that overlaps the anchor directly away, along
        whichever axis needs the smaller shift, by _min_separating_shift
        plus pad.  Second-stage fallback for _resolve_body_overlaps,
        _resolve_t_overlaps and _reresolve_value_texts: when stage one
        finds no clear spot, move what is blocking instead.  The
        callables let one implementation serve instances, T-symbols and
        labels.  Up to max_rounds, as a push can reveal a
        blocker-vs-blocker clash; only an ACTUAL blocker ever moves."""
        moved = []
        for _ in range(max_rounds):
            moved_any = False
            for obj in blockers:
                bb = box_of(obj)
                if not _overlaps(anchor_box, bb):
                    continue
                ox = (min(anchor_box[2], bb[2])
                      - max(anchor_box[0], bb[0]))
                oy = (min(anchor_box[3], bb[3])
                      - max(anchor_box[1], bb[1]))
                # Uses the shared
                # _min_separating_shift (module scope) instead of a
                # local copy; see that function's own docstring.
                dx_full = _min_separating_shift(
                    anchor_box[0], anchor_box[2], bb[0], bb[2], pad)
                dy_full = _min_separating_shift(
                    anchor_box[1], anchor_box[3], bb[1], bb[3], pad)
                if ox <= oy:
                    shift_of(obj, dx_full, 0.0)
                else:
                    shift_of(obj, 0.0, dy_full)
                moved.append(obj)
                moved_any = True
            if not moved_any:
                break
        ok = not any(_overlaps(anchor_box, box_of(obj)) for obj in blockers)
        return ok, moved

    def _dbg_track(self, stage, members=None, loc=None, instances=None):
        """In : a stage name, plus members and either `loc` or instances.
        Out: {stage, pos:{ref:(x, y)}} appended to _dbg_position_log,
        which _dbg_position_report turns into "which stage first made
        this pair overlap".
        Off unless _dbg_track_positions is set (SP2SCH_TRACK=1), so a
        normal Place pays nothing.  Positions live in two shapes — a
        `loc` dict keyed by id(inst) while a cluster is laid out, then
        ox_px/oy_px once _run_placement pushes them onto the instances —
        and taking either is the point: the stages on both sides of that
        boundary become comparable."""
        if not getattr(self, '_dbg_track_positions', False):
            return
        pos = {}
        if loc is not None:
            for inst in (members or instances or []):
                xy = loc.get(id(inst))
                if xy is not None:
                    pos[inst.comp['ref']] = (float(xy[0]), float(xy[1]))
        else:
            for inst in (members or instances or []):
                pos[inst.comp['ref']] = (float(inst.ox_px), float(inst.oy_px))
        log = getattr(self, '_dbg_position_log', None)
        if log is None:
            log = self._dbg_position_log = []
        log.append((stage, pos))

    def _separate_overlapping_groups(self, groups, margin=None,
                                     max_iter=None, clearance=None,
                                     carry_side=False):
        """In : a list of group dicts, each carrying 'box' (mutated in
        place), 'weight' (the smaller moves; equal weights move the LATER
        one), 'shift' (moves the real objects) and 'pos'.
        Out: the number of shifts made.
        THE one pairwise separator; _resolve_intra_rigid_overlaps and
        _resolve_cluster_box_overlaps are thin adapters over it.
        DETECTION uses _boxes_clash at `clearance`, the gate's own rule,
        so the pass cannot finish on a layout the check fails; separation
        uses the roomier `margin` and _min_separating_shift.  carry_side
        moves everything already beyond a mover by the same delta."""
        if margin is None:
            margin = max(4.0, self._MIN_CLEARANCE)
        if max_iter is None:
            max_iter = len(groups) + 2
        moved = 0
        for _pass in range(max_iter):
            any_fixed = False
            for i in range(len(groups)):
                for j in range(i + 1, len(groups)):
                    ga, gb = groups[i], groups[j]
                    if not self._boxes_clash(ga['box'], gb['box'],
                                             clearance):
                        continue
                    if gb['weight'] <= ga['weight']:
                        mover, anchor = gb, ga
                    else:
                        mover, anchor = ga, gb
                    fixed_box = anchor['box']
                    mbox = mover['box']
                    dx_c = _min_separating_shift(fixed_box[0], fixed_box[2],
                                                 mbox[0], mbox[2], margin)
                    dy_c = _min_separating_shift(fixed_box[1], fixed_box[3],
                                                 mbox[1], mbox[3], margin)
                    if abs(dx_c) <= abs(dy_c):
                        dx, dy = dx_c, 0.0
                    else:
                        dx, dy = 0.0, dy_c
                    riders = []
                    if carry_side:
                        px, py = mover.get('pos', (mbox[0], mbox[1]))
                        for g in groups:
                            # The ANCHOR never rides along: it is the box
                            # the mover is being cleared of, so carrying
                            # it moves the pair as a unit and the overlap
                            # survives every iteration (the mover and the
                            # anchor simply drift off together — measured
                            # 2096 px on LM324's C1/IEE with the anchor
                            # left in).
                            if g is mover or g is anchor:
                                continue
                            gx, gy = g.get('pos', (g['box'][0], g['box'][1]))
                            if ((dx > 0 and gx >= px) or (dx < 0 and gx <= px)
                                    or (dy > 0 and gy >= py)
                                    or (dy < 0 and gy <= py)):
                                riders.append(g)
                    for g in [mover] + riders:
                        g['shift'](dx, dy)
                        b = g['box']
                        b[0] += dx; b[2] += dx
                        b[1] += dy; b[3] += dy
                        if 'pos' in g:
                            g['pos'] = (g['pos'][0] + dx, g['pos'][1] + dy)
                    moved += 1
                    any_fixed = True
            if not any_fixed:
                break
        return moved

    def _resolve_intra_rigid_overlaps(self, instances, max_iter=20):
        """In : the instances.  Out: the number of nudges; two members of
        the SAME rigid block that still overlap are moved apart.
        _resolve_body_overlaps deliberately never touches spacing WITHIN
        a rigid unit, trusting cache_block's relpos.  That is fine for a
        validated pattern such as a diff-pair cell, but not for a newer
        rigid block whose members can be repositioned by a later
        reorientation that does not preserve the original spacing.  A
        minimal single-axis shift per overlapping pair — a safety net,
        not a re-layout, so it acts only on an overlap actually found."""
        # Iterate members in sorted order: a frozenset of ref strings follows
        # the hash seed, which decided which part of a pair stays put.
        by_ref = {i.comp['ref']: i for i in instances}
        moved = 0
        for members in _stable_blocks(getattr(self, '_sp_rigid_blocks', None)):
            insts = [by_ref[r] for r in sorted(members) if r in by_ref]
            if len(insts) < 2:
                continue
            # Same box the sibling resolver and the BBoxes overlay use.
            # The composite EXCLUDES the T-symbols, so two diff-pair
            # devices could be separated by body and still have their
            # blue boxes overlapping (LM324.sub's Q1/Q2 and Q14/Q15,
            # which no pass touched because no pass was measuring the
            # box the user could see).
            groups = []
            for inst in insts:
                groups.append({
                    'box': list(self._abs_reserved_box(inst)),
                    # equal weights: the LATER of a pair moves, which is
                    # the old a-stays / b-shifts order that the sorted()
                    # above makes hash-seed independent
                    'weight': 0,
                    'pos': (inst.ox_px, inst.oy_px),
                    'shift': (lambda dx, dy, _i=inst:
                              self._shift_instance(_i, dx, dy)),
                })
            moved += self._separate_overlapping_groups(
                groups, max_iter=max_iter, carry_side=True)
        return moved

    def _resolve_body_overlaps(self, instances, step=18.0, max_ring=900.0,
                               margin=None):
        """In : the placed instances, after labels exist.  Out: any whose
        composite box conflicts with one already placed is relocated,
        through _shift_instance, so _placed_ref_pos and _user_positions
        stay authoritative for the draw-state build.
        Inserts into a quad tree LARGEST-first, so big parts anchor and
        small parts yield, and QUERIES the target empty before committing
        — relocating into a fresh overlap is structurally impossible.
        Boxes are inflated by `margin`, deliberately roomier than
        _MIN_CLEARANCE, so this pass and its gate agree.  A rigid P2DL
        cell moves as ONE unit, but the tree holds per-MEMBER boxes."""
        if margin is None:
            margin = max(3.0, self._MIN_CLEARANCE)
        qt = QuadTree(-400000, -400000, 400000, 400000)


        def _infl(b):
            return (b[0]-margin, b[1]-margin, b[2]+margin, b[3]+margin)

        # ── Build movable UNITS: each TAGGED-RIGID cell is one unit (union-find
        # over instances sharing a rigid-block membership, since cells can
        # overlap); every other instance is its own singleton unit.
        by_ref = {i.comp['ref']: i for i in instances}
        parent = {id(i): id(i) for i in instances}

        def find(x):
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x

        def union(a, b):
            ra, rb = find(a), find(b)
            if ra != rb:
                parent[ra] = rb

        # sorted() for consistency with the real fix
        # in _resolve_intra_rigid_overlaps (see its rev note).  This
        # particular usage only picks which id() becomes the union-find
        # bookkeeping root — verified it doesn't affect the final `units`
        # grouping or list order (both come from the stable `instances`
        # list, not from this iteration), so it wasn't actually causing
        # wrong output.  Sorting anyway removes any doubt and matches the
        # one call site that WAS a real bug, rather than leaving a
        # not-quite-identical sibling nearby for the next person to
        # re-litigate.
        for members in _stable_blocks(getattr(self, '_sp_rigid_blocks', None)):
            ids = [id(by_ref[r]) for r in sorted(members) if r in by_ref]
            for j in ids[1:]:
                union(ids[0], j)
        units = {}
        for inst in instances:
            units.setdefault(find(id(inst)), []).append(inst)

        # Keep other parts out of a cell's whole bbox, not just its members'
        # boxes, so nothing lands in the gap between two members.
        rigid_unit_roots = set()
        for members in _stable_blocks(getattr(self, '_sp_rigid_blocks', None)):
            ids = [id(by_ref[r]) for r in sorted(members) if r in by_ref]
            if len(ids) >= 2:
                rigid_unit_roots.add(find(ids[0]))

        def unit_boxes(u):
            # Clear the SAME box Sugiyama was handed — body + labels +
            # every T this instance owns (_abs_reserved_box) — not the
            # composite alone.  Resolving on abs_composite() declared
            # two parts separated while their T's were still on top of
            # each other, because a T lives outside the composite by
            # construction; measured that way, LP2951 finished with 1
            # and OPAX197 with 18 pairs of RESERVED boxes overlapping
            # even though every T sat correctly inside its own owner's
            # box.  That is the whole non-owner T-overlap class.
            return [_infl(self._abs_reserved_box(i)) for i in u]

        def unit_bbox_area(u):
            bs = unit_boxes(u)
            return (max(b[2] for b in bs) - min(b[0] for b in bs)) * \
                   (max(b[3] for b in bs) - min(b[1] for b in bs))

        def whole_bbox(boxes):
            return (min(b[0] for b in boxes), min(b[1] for b in boxes),
                   max(b[2] for b in boxes), max(b[3] for b in boxes))

        order = sorted(units.values(), key=lambda u: -unit_bbox_area(u))
        rings = [step * k for k in range(1, int(max_ring / step) + 1)]
        angs = [math.radians(a) for a in range(0, 360, 20)]
        placed_units = []   # [(unit_list, is_rigid)], for stage-2 pushes
        for u in order:
            is_rigid = find(id(u[0])) in rigid_unit_roots
            boxes = unit_boxes(u)
            obstacle = whole_bbox(boxes) if is_rigid else None
            check = boxes + ([obstacle] if obstacle else [])
            if not any(qt.query_overlaps(b) for b in check):
                for b, inst in zip(boxes, u):
                    qt.insert(b, inst)
                if obstacle:
                    qt.insert(obstacle, u[0])
                placed_units.append(u)
                continue
            placed = None
            for r in rings:
                for a in angs:
                    dx = r * math.cos(a)
                    dy = r * math.sin(a)
                    cand = [(b[0]+dx, b[1]+dy, b[2]+dx, b[3]+dy)
                            for b in boxes]
                    cand_obs = ((obstacle[0]+dx, obstacle[1]+dy,
                               obstacle[2]+dx, obstacle[3]+dy)
                               if obstacle else None)
                    check_cand = cand + ([cand_obs] if cand_obs else [])
                    if not any(qt.query_overlaps(c) for c in check_cand):
                        placed = (dx, dy, cand, cand_obs)
                        break
                if placed is not None:
                    break
            if placed is None:
                # Stage 1 (ring search) found no spot for this unit; stage 2
                # pushes whatever blocks its preferred spot instead.
                anchor = whole_bbox(check)

                def _unit_box(pu, _infl=_infl):
                    return whole_bbox([_infl(i.abs_composite())
                                       for i in pu])

                def _shift_unit(pu, dx, dy, _qt=qt,
                                _roots=rigid_unit_roots, _find=find):
                    old_boxes = [_infl(i.abs_composite()) for i in pu]
                    # a RIGID cell also has a whole-cell
                    # obstacle rectangle in the tree (see the
                    # rigid_unit_roots comment above).  Only the per-member
                    # boxes used to be updated here, so pushing a rigid
                    # blocker left its cell footprint reserved at the OLD
                    # location and unreserved at the new one — the exact
                    # "something can land inside the cell" hole that box
                    # exists to close.
                    was_rigid = _find(id(pu[0])) in _roots
                    old_obs = whole_bbox(old_boxes) if was_rigid else None
                    for i in pu:
                        self._shift_instance(i, dx, dy)
                    for old_b, i in zip(old_boxes, pu):
                        _qt.update(old_b, _infl(i.abs_composite()), i)
                    if old_obs is not None:
                        new_obs = whole_bbox(
                            [_infl(i.abs_composite()) for i in pu])
                        _qt.update(old_obs, new_obs, pu[0])

                self._push_blockers_away(
                    anchor, placed_units,
                    box_of=_unit_box, shift_of=_shift_unit)
                for b, inst in zip(boxes, u):
                    qt.insert(b, inst)        # settled here either way
                if obstacle:
                    qt.insert(obstacle, u[0])
                placed_units.append(u)
                continue
            dx, dy, cand, cand_obs = placed
            for inst in u:
                self._shift_instance(inst, dx, dy)
            for c, inst in zip(cand, u):
                qt.insert(c, inst)
            if cand_obs:
                qt.insert(cand_obs, u[0])
            placed_units.append(u)

    def _build_instances(self):
        """Construct and build one CompInstance per drawable part, after
        filtering and computing the per-pin label suppression set.  Returns
        the list.
        """
        filt = self.filter_var.get().strip().upper()
        comps = [c for c in self.drawable
                 if not filt
                 or filt in c['ref'].upper()
                 or filt in c['sym'].upper()
                 or filt in c['value'].upper()
                 or any(filt in n for n in c['nets'])]
        if not comps:
            return []
        suppressed = self._suppressed_per_pin_nets(comps)
        self._suppressed_nets = suppressed
        instances = []
        for comp in comps:
            sym_entry = self.sym_lib.get(comp['sym']) or \
                        {'shapes': [], 'pins': {}, 'sim_pins': {}}
            shapes = sym_entry.get('shapes', [])
            if shapes:
                bb  = _bbox_of_shapes(shapes)
                bw  = max(bb[2]-bb[0], 0.001); bh = max(bb[3]-bb[1], 0.001)
                ss  = min((CELL_W_MM-2)/bw, (CELL_H_MM-2)/bh) * 0.82 * SCALE
                mkx = (bb[0]+bb[2])/2;  mky = (bb[1]+bb[3])/2
            else:
                ss = SCALE; mkx = 0; mky = 0
            inst = CompInstance(comp, sym_entry, ss, mkx, mky)
            pin_net_pairs = resolve_pin_nets(comp, sym_entry)
            if not pin_net_pairs:
                pins = sym_entry.get('pins', {})
                sp   = sorted(pins, key=lambda k: int(k) if k.isdigit() else 0)
                pin_net_pairs = list(zip(sp, comp['nets']))
            inst.build(pin_net_pairs,
                        fulltext=self._effective_fulltext(comp['ref']),
                        multi_pin_nets=suppressed)
            instances.append(inst)
        return instances

    def _place_best_of_drawn(self):
        """In : nothing; drives self._bk_candidate.
        Proc: for each of (balance, 0, 1, 2, 3) run a full placement and
               render, read cross_other from _self_check, then re-place
               with the winner so the state left behind is that one.
        Out : None; self._bk_candidate holds the winning choice.
        _in_best_of guards the recursion: _run_placement delegates here
        when the flag is on, and an inner call must not delegate again.
        Placing repeatedly in one app is what the Place button already
        does, so the inner calls need nothing special."""
        self._in_best_of = True
        prev = self._bk_candidate
        try:
            best = None
            for cand in (None, 0, 1, 2, 3):
                self._bk_candidate = cand
                self._run_placement()
                try:
                    self._render()
                    insts = (getattr(self, '_cached_instances', None)
                             or self._placed_instances or [])
                    n = self._self_check(instances=insts).get('cross_other')
                except Exception:
                    n = None
                if n is not None and (best is None or n < best[0]):
                    best = (n, cand)
            self._bk_candidate = best[1] if best else prev
            self._run_placement()
            if best:
                self.status.config(
                    text=f'Best-of-5 BK: candidate '
                         f'{"balance" if best[1] is None else best[1]} '
                         f'-> {best[0]} crossings')
        finally:
            self._in_best_of = False

    _CHAIN_MARGIN = 40.0
    # The temporary x spacing between consecutive parts on a chain.  §7
    # asks for "~30px to show the net and net name", and that is all a
    # provisional coordinate needs -- the stack model widens a spine
    # slot where subchains actually compete, so the gap does not have to
    # carry that room speculatively.  It was 120.0, repeated as three
    # separate getattr defaults; one constant so they cannot diverge.
    _CHAIN_GAP = 30.0

    def _shift_to_origin(self, instances, margin=10.0):
        """In : the placed instances.
        Proc: find the top-left corner of everything that will be drawn
              and subtract it, leaving `margin` px.
        Out : nothing; adjusts ox_px / oy_px by one constant offset.
        A whole-layout TRANSLATION, so it cannot create an overlap or
        change a relative position — it only removes the empty space the
        packer leaves above and left when the first box does not start at
        the corner.  The box measured is the at-origin composite extended
        by the T-symbols, the same one the chain layout separates with."""
        x0 = y0 = None
        for inst in instances:
            bb = None
            try:
                bb = self._instance_bbox_with_ts(inst)
            except Exception:
                bb = None
            if bb is None or len(bb) != 4:
                try:
                    bb = self._instance_bbox_at_origin(inst)
                except Exception:
                    continue
            if not bb or len(bb) != 4:
                continue
            lx, ly = inst.ox_px + bb[0], inst.oy_px + bb[1]
            x0 = lx if x0 is None else min(x0, lx)
            y0 = ly if y0 is None else min(y0, ly)
        if x0 is None:
            return
        dx, dy = margin - x0, margin - y0
        if abs(dx) < 0.5 and abs(dy) < 0.5:
            return
        for inst in instances:
            inst.ox_px += dx
            inst.oy_px += dy

    def _place_unplaced_texts(self, instances):
        """Takes the placed instances and gives every part with an unplaced
        label a fresh self-only placement, then cross-checks just those parts
        against their neighbors (_reresolve_value_texts) and refreshes their
        composite boxes. Already-placed parts are left alone.

        A whole-part place_texts is deliberate: keeping a part's other
        labels where they were kept stale positions and caused body
        overlaps. _rebuild_t_terminals unplaces every label, so any pass
        that rebuilds T's after this point must call this again."""
        touched = set()
        for inst in instances:
            if any(ti.get('placed') is None for ti in inst.text_items):
                inst.place_texts(QuadTree(-200000, -200000, 200000, 200000))
                touched.add(inst.comp['ref'])
        if not touched:
            return
        self._reresolve_value_texts(
            instances,
            skip_refs={i.comp['ref'] for i in instances
                       if i.comp['ref'] not in touched})
        for inst in instances:
            if inst.comp['ref'] in touched:
                inst._recompute_composite_rel()

    _SWEEP_GAP = 20.0

    _GAP_CLOSE_FACTOR = 3.0    # an empty band this many part-heights tall
    _GAP_KEEP = 60.0           # ...is cut down to this
    _SEL_GAP = 10.0            # Compact sel: space left between parts

    def _unit_boxes(self, instances, t_owned=True):
        """Takes instances and returns [(box, members)], one per rigid P2DL
        cell or loose part: the union of each member's reserved box and, with
        `t_owned`, the drawn box of every T only that member owns."""
        by_ref = {i.comp['ref']: i for i in instances}
        gid = {}
        for refs in sorted(tuple(sorted(f)) for f in (
                getattr(self, '_sp_rigid_blocks', None) or set())):
            for r in refs:
                if r in by_ref:
                    gid[r] = refs
        units = {}
        for inst in instances:
            units.setdefault(gid.get(inst.comp['ref'], inst.comp['ref']),
                             []).append(inst)
        owners = defaultdict(set)
        for (r, _pn), tid in (getattr(self, '_pin_to_t', None) or {}).items():
            owners[tid].add(r)
        t_of = defaultdict(list)
        if t_owned and not getattr(self, '_placing', False):
            for t in getattr(self, '_t_terminals', None) or []:
                own = owners.get(t.get('id'))
                if own and len(own) == 1:
                    t_of[next(iter(own))].append(t)
        out = []
        for key in sorted(units, key=str):
            members = units[key]
            bs = []
            for m in members:
                e = self._placement_extent(m)
                bs.append((m.ox_px + e[0], m.oy_px + e[1],
                           m.ox_px + e[2], m.oy_px + e[3]))
                for t in t_of.get(m.comp['ref'], ()):
                    bs.append(tuple(self._t_hit_bbox(t)))
            out.append(((min(b[0] for b in bs), min(b[1] for b in bs),
                         max(b[2] for b in bs), max(b[3] for b in bs)),
                        members))
        return out

    def _commit_moves(self, insts, before, t_shift=None):
        """Takes the live instances, their positions before an edit, and an
        optional T -> (dx, dy) function, and records every moved part the way
        a drag does (kept until Place, wires follow). A T moves by
        `t_shift` when given, else by its owners' mean shift, and the drag's
        split pass then decides whether a shared T divides. Returns
        (parts moved, T's split) and redraws."""
        delta = {}
        ds = getattr(self, '_placed_draw_state', None)
        for i in insts:
            r = i.comp['ref']
            d = (i.ox_px - before[r][0], i.oy_px - before[r][1])
            if abs(d[0]) < 1e-6 and abs(d[1]) < 1e-6:
                continue
            delta[r] = d
            self._user_positions[r] = (i.ox_px, i.oy_px)
            if ds and r in ds:
                ds[r]['ox'], ds[r]['oy'] = i.ox_px, i.oy_px
            self._reflow_wires_on_move(r, *d)
        owners = defaultdict(set)
        for (r, _pn), tid in (self._pin_to_t or {}).items():
            owners[tid].add(r)
        for t in self._t_terminals or []:
            if t_shift is not None:
                dx, dy = t_shift(t)
            else:
                own = owners.get(t['id'], ())
                if not any(r in delta for r in own):
                    continue
                ds_t = [delta.get(r, (0.0, 0.0)) for r in own]
                dx = sum(d[0] for d in ds_t) / len(ds_t)
                dy = sum(d[1] for d in ds_t) / len(ds_t)
            t['cx'] += dx
            t['cy'] += dy
        n_split = self._t_split_pass(insts, moved_refs=set(delta))
        self._render()
        return len(delta), n_split

    def _cut_bands(self, units, limit):
        """Takes [(box, members)] and a height, and moves members up to cut
        every empty horizontal band taller than `limit` -- no box of these
        units anywhere across it -- down to _GAP_KEEP. Everything below a
        band moves together, so nothing new overlaps. Returns the number
        of bands cut."""
        spans = sorted((b[1], b[3]) for b, _m in units)
        if not spans:
            return 0
        cuts, reach = [], spans[0][1]
        for lo, hi in spans[1:]:
            if lo - reach > limit:
                cuts.append((lo, lo - reach - self._GAP_KEEP))
            reach = max(reach, hi)
        for b, members in units:
            d = sum(c for at, c in cuts if b[1] >= at - 1e-6)
            for m in members:
                m.oy_px -= d
        return len(cuts)

    def _raise_boxes(self, insts, limit):
        """Takes the live instances and lifts each independent box, whole,
        into the empty space above it: the lift is the smallest gap between
        any of its parts and the parts of higher boxes in that part's x
        span, less _GAP_KEEP, and is made only when that gap exceeds
        `limit`. A box keeps its shape, so no flight line inside it changes.
        The bottom band under OPAx197's tall right-hand column is the
        case a band cut cannot reach. Returns the number of boxes lifted."""
        boxes = []
        for cl in self._box_partition(insts):
            us = self._unit_boxes(cl)
            if us:
                boxes.append((min(b[1] for b, _m in us), us))
        boxes.sort(key=lambda t: t[0])
        placed, n = [], 0
        for _top, us in boxes:
            lift = None
            for b, _m in us:
                # Everything in this part's x span that is not wholly below
                # it limits the lift; one level with it forbids any.
                above = max((p[3] for p in placed
                             if b[0] < p[2] and p[0] < b[2]
                             and p[1] < b[3]), default=None)
                if above is None:
                    continue
                g = max(0.0, b[1] - above)
                lift = g if lift is None else min(lift, g)
            d = 0.0
            if lift is not None and lift > limit:
                d = lift - self._GAP_KEEP
                n += 1
                for _b, members in us:
                    for m in members:
                        m.oy_px -= d
            placed.extend((b[0], b[1] - d, b[2], b[3] - d) for b, _m in us)
        return n

    def _close_vertical_gaps(self):
        """Toolbar 'Close gaps'. Cuts every empty horizontal band taller than
        _GAP_CLOSE_FACTOR typical part heights down to _GAP_KEEP: first
        across the whole sheet, then inside each independent box. Last,
        each whole box rises into empty space above it (_raise_boxes).
        Each cut moves everything below it up together, so the drawing
        keeps its shape and nothing new overlaps."""
        insts = list(getattr(self, '_cached_instances', None) or [])
        if not insts:
            self.status.config(text='Nothing to close -- Place first')
            return
        heights = sorted(b[3] - b[1] for b, _m in self._unit_boxes(insts))
        typ = heights[len(heights) // 2] if heights else 100.0
        limit = max(self._GAP_KEEP, self._GAP_CLOSE_FACTOR * typ)
        before = {i.comp['ref']: (i.ox_px, i.oy_px) for i in insts}
        # Sheet-wide first, so everything below a gap rises together; then
        # inside each box, where another box beside it hid the gap.  A box
        # cut only lifts parts within that box's own rows, so boxes stay
        # apart.
        n_cut = self._cut_bands(self._unit_boxes(insts), limit)
        for box in self._box_partition(insts):
            n_cut += self._cut_bands(self._unit_boxes(box), limit)
        n_cut += self._raise_boxes(insts, limit)
        if not n_cut:
            self.status.config(text='No vertical gap taller than '
                                    f'{limit:.0f} px')
            return
        n, _s = self._commit_moves(insts, before)
        self.status.config(
            text=f'Closed {n_cut} vertical gap(s); {n} instance(s) moved')

    def _selected_move(self, mode):
        """Toolbar 'Compact sel' / 'Spread sel'. Takes the selected parts
        (rubber band or click) and either pulls them together -- up, then
        left, each stopping _SEL_GAP px from a selected part or from any
        unselected part in the way -- or spreads them, adding one typical
        part width between columns and one typical height between rows so
        there is room to drop parts in.  A spread opens a gap: unselected
        parts of the same box further along a selected row (or down a
        selected column) move with it.  Order is kept both ways."""
        insts = list(getattr(self, '_cached_instances', None) or [])
        sel = set(getattr(self, '_selected_group', None) or ())
        if len(sel) < 2:
            self.status.config(text='Select two or more instances first')
            return
        before = {i.comp['ref']: (i.ox_px, i.oy_px) for i in insts}
        mine = [u for u in self._unit_boxes(insts)
                if any(m.comp['ref'] in sel for m in u[1])]
        if mode == 'spread':
            ws = sorted(b[2] - b[0] for b, _m in mine)
            hs = sorted(b[3] - b[1] for b, _m in mine)
            part = {}
            for k, cl in enumerate(self._box_partition(insts)):
                for i in cl:
                    part[i.comp['ref']] = k
            homes = {part.get(m.comp['ref']) for _b, ms in mine for m in ms}
            others = [u for u in self._unit_boxes(insts)
                      if not any(m.comp['ref'] in sel for m in u[1])
                      and part.get(u[1][0].comp['ref']) in homes]
            for axis, step in ((0, ws[len(ws) // 2]), (1, hs[len(hs) // 2])):
                o = 1 - axis
                order = sorted(mine, key=lambda u: u[0][axis])
                rank, last, shift = -1, None, []
                for b, members in order:
                    # Parts starting within 10 px share a column / row.
                    if last is None or b[axis] - last > 10.0:
                        rank += 1
                        last = b[axis]
                    shift.append((b, members, rank * step))
                # OPEN A GAP: an unselected part further along the same row
                # (column) moves with the nearest selected part before it,
                # so the parts after the spread keep their spacing.
                for b, members in others:
                    prior = [(sb[axis], d) for sb, _m, d in shift
                             if sb[axis] <= b[axis]
                             and sb[o] < b[o + 2] and b[o] < sb[o + 2]]
                    if prior:
                        shift.append((b, members, max(prior)[1]))
                for _b, members, d in shift:
                    for m in members:
                        if axis:
                            m.oy_px += d
                        else:
                            m.ox_px += d
                # Boxes have moved; the next axis reads the new ones.
                cur = {id(m): u for u in self._unit_boxes(insts)
                       for m in u[1]}
                mine = [cur[id(ms[0])] for _b, ms in mine]
                others = [cur[id(ms[0])] for _b, ms in others]
            word = 'Spread'
        else:
            gap = self._SEL_GAP
            for axis in (1, 0):
                o = 1 - axis
                cur = self._unit_boxes(insts)
                mine = [u for u in cur
                        if any(m.comp['ref'] in sel for m in u[1])]
                fixed = [u[0] for u in cur
                         if not any(m.comp['ref'] in sel for m in u[1])]
                mine.sort(key=lambda u: (u[0][axis], u[0][o]))
                edge = min(u[0][axis] for u in mine)
                placed = []
                for b, members in mine:
                    want = edge
                    for f in fixed + placed:
                        if (b[o] < f[o + 2] and f[o] < b[o + 2]
                                and f[axis + 2] <= b[axis] + 1e-6):
                            want = max(want, f[axis + 2] + gap)
                    want = min(want, b[axis])     # never pushed away
                    d = want - b[axis]
                    for m in members:
                        if axis:
                            m.oy_px += d
                        else:
                            m.ox_px += d
                    nb = list(b)
                    nb[axis] += d
                    nb[axis + 2] += d
                    placed.append(tuple(nb))
            word = 'Compacted'
        n, n_split = self._commit_moves(insts, before)
        self.status.config(
            text=f'{word} {len(sel)} selected; {n} instance(s) moved'
                 + (f', {n_split} T split(s)' if n_split else ''))

    _MEDIAN_MOVE = True
    _MEDIAN_GROUP_MAX = 4      # parts in a group the median pass may move
    _MEDIAN_JOIN = 40.0        # parts this close, sharing a net, move together
    _MEDIAN_STEP = 20.0        # search grid for a free spot
    _MEDIAN_REACH = 2000.0     # farthest a spot is looked for from the median
    _MEDIAN_TRIES = 6          # free spots tried per group, nearest first
    # A move must SHORTEN wire; it need not shorten it by much, because
    # the crossing budget and the flow test already bound what it can cost.
    # Measured on OPAx197 at budget 5: gain 0.2 -> 77 crossings / 75.5k px,
    # 0.05 -> 49 / 70.1k, 0.0 -> 40 / 68.7k with flow 64 of 188.
    _MEDIAN_GAIN = 0.0         # a move must save this fraction of its wire
    _MEDIAN_SWEEPS = 5         # sweeps, stopping early when nothing moves
    _MEDIAN_LONGEST = 10       # longest blue / purple lines nominated
    # A nominated end is tied into a row, so moving it lengthens its other
    # lines: measured on OPAx197, the best such moves save 5-12%.
    _MEDIAN_GAIN_LONG = 0.05
    # CLOSER PACKING IS WORTH A FEW CROSSINGS: a move that
    # shortens wire may ADD crossings, up to this many over the whole pass.
    # Flow is never traded -- a move that turns another driver -> receiver
    # pair backward is refused whatever it saves.
    _MEDIAN_CROSS_BUDGET = 5
    # Measured on OPAx197: 1 px packs to 40 crossings / 68.7k px but leaves
    # labels touching; 20 px costs 3 crossings and 0.6k px and reads.
    _MEDIAN_CLEAR = 20.0       # air a moved part keeps around it
    _MEDIAN_FLOW_WIRE = 2.0    # a flow move may double its wire (I1 -> Q16)
    _MEDIAN_HIT_WIRE = 1.15    # so may a move that clears a line off a part
    _MEDIAN_LEVEL = 0.5        # |dy|/|dx| of a part's lines that lays it flat

    def _body_box(self, inst, inset=2.0):
        """Takes a placed part and returns its body-plus-labels box, drawn
        in by `inset` so a line that only touches an edge does not count."""
        b = self._instance_bbox_at_origin(inst)
        return (inst.ox_px + b[0] + inset, inst.oy_px + b[1] + inset,
                inst.ox_px + b[2] - inset, inst.oy_px + b[3] - inset)

    def _t_glyph_boxes(self, skip_owners=()):
        """Returns [(label, net, box)] for every drawn T-symbol whose owners
        are not all in `skip_owners`: a line across a T reads as joining
        its net as surely as a line across a part (user, LM324.lib's VB)."""
        owners = defaultdict(set)
        for (r, _pn), tid in (getattr(self, '_pin_to_t', None) or {}).items():
            owners[tid].add(r)
        out = []
        for t in getattr(self, '_t_terminals', None) or []:
            own = owners.get(t.get('id'), set())
            if own and own <= set(skip_owners):
                continue
            try:
                bb = tuple(self._t_hit_bbox(t))
            except Exception:
                continue
            out.append(('T:' + str(t.get('net')), str(t.get('net')), bb))
        return sorted(out)

    def _line_body_hits(self, instances, segs=None):
        """In : the placed instances; optionally their flight segments.
        Proc: every blue flight line and purple sense line that runs
              through the body or label box of a part it does not join, or
              through a T-symbol of another net.
        Out : [(kind, ref_a, ref_b, ref_hit), ...].

        A line across a part reads as a connection to it (user, LM324.sub
        and LP2951 hand edits), so this is counted beside crossings.
        """
        if segs is None:
            segs = self._flight_segments(instances)
        boxes = [(i.comp['ref'], None, self._body_box(i)) for i in instances]
        boxes += self._t_glyph_boxes()
        lines = [('blue', sg[0], sg[1], sg[2], sg[4], sg[6]) for sg in segs]
        for sg in self._sense_segments(instances):
            lines.append(('purple', sg[5], sg[6], sg[1].comp['ref'],
                          sg[2].comp['ref'], None))
        out = []
        for kind, a, b, ra, rb, net in lines:
            lx0, lx1 = min(a[0], b[0]), max(a[0], b[0])
            ly0, ly1 = min(a[1], b[1]), max(a[1], b[1])
            for r, tnet, bb in boxes:
                if (r in (ra, rb) if tnet is None else tnet == net) \
                        or bb[0] > lx1 or bb[2] < lx0 \
                        or bb[1] > ly1 or bb[3] < ly0:
                    continue
                if _seg_intersects_rect(a[0], a[1], b[0], b[1], bb):
                    out.append((kind, ra, rb, r))
        return out

    def _median_move_pass(self, instances):
        """Move small groups of loose parts (up to _MEDIAN_GROUP_MAX, sharing a
        signal net) toward the median of the pins they connect to, into the
        nearest free spot that shortens wire without adding crossings.  Also
        lays level-wired R/C/L flat and turns parts 180 degrees.  Returns the
        number of moves.
        """
        by = {i.comp['ref']: i for i in instances}
        p2t = getattr(self, '_pin_to_t', None) or {}
        rails = {str(n).lower() for n in (
            set(_PWR_NETS_LC_FOR_T)
            | set(getattr(self, '_promoted_rails', None) or ())
            | set(getattr(self, '_supply_rails', None) or ()))}
        sig = {}
        for i in instances:
            r = i.comp['ref']
            sig[r] = {str(n) for p, n in (i._pin_net_pairs or [])
                      if (r, p) not in p2t and str(n).lower() not in rails}
        on_net = defaultdict(set)
        for r, ns in sig.items():
            for n in ns:
                on_net[n].add(r)
        units = self._unit_boxes(instances, t_owned=False)
        box_of = {}
        loose = []
        for k, (b, ms) in enumerate(units):
            for m in ms:
                box_of[m.comp['ref']] = k
            if len(ms) == 1:
                loose.append(k)
        boxes = [list(b) for b, _m in units]
        part = {}
        for k, cl in enumerate(self._box_partition(instances)):
            for i in cl:
                part[i.comp['ref']] = k
        hull = {}
        for r, k in part.items():
            b = boxes[box_of[r]]
            h = hull.get(k)
            hull[k] = list(b) if h is None else [
                min(h[0], b[0]), min(h[1], b[1]),
                max(h[2], b[2]), max(h[3], b[3])]
        # Groups: loose units joined by a shared signal net and nearness.
        up = {k: k for k in loose}

        def find(k):
            while up[k] != k:
                up[k] = up[up[k]]
                k = up[k]
            return k
        for ia, a in enumerate(loose):
            ra = units[a][1][0].comp['ref']
            for b in loose[ia + 1:]:
                rb = units[b][1][0].comp['ref']
                if not (sig[ra] & sig[rb]) or part.get(ra) != part.get(rb):
                    continue
                if not self._boxes_clash(boxes[a], boxes[b],
                                         clearance=self._MEDIAN_JOIN):
                    continue
                up[find(a)] = find(b)
        groups = defaultdict(list)
        for k in loose:
            groups[find(k)].append(k)
        groups = [g for g in groups.values()
                  if len(g) <= self._MEDIAN_GROUP_MAX]
        cell = 200.0
        grid = defaultdict(set)

        def cells(b):
            for cx in range(int(b[0] // cell), int(b[2] // cell) + 1):
                for cy in range(int(b[1] // cell), int(b[3] // cell) + 1):
                    yield cx, cy

        def index(k, add):
            for c in cells(boxes[k]):
                (grid[c].add if add else grid[c].discard)(k)
        for k in range(len(boxes)):
            index(k, True)
        # Packing to _MIN_CLEARANCE (1 px) is overlap-free but unreadable:
        # neighbouring labels sit shoulder to shoulder.  A moved part keeps
        # this much air instead.
        clr = max(self._MIN_CLEARANCE, self._MEDIAN_CLEAR)

        others_of = {}      # partition -> the other partitions' hulls

        def free(g, dx, dy):
            # _boxes_clash_at written out: this runs 600,000 times on
            # OPAx197, and the call overhead was most of its cost.
            mine = set(g)
            ph = part.get(units[g[0]][1][0].comp['ref'])
            others = others_of.get(ph)
            if others is None:
                others = others_of[ph] = [h for pk, h in hull.items()
                                          if pk != ph]
            for k in g:
                b = boxes[k]
                x0, y0, x1, y1 = b[0] + dx, b[1] + dy, b[2] + dx, b[3] + dy
                seen = set()
                cy0, cy1 = int(y0 // cell), int(y1 // cell) + 1
                for cx in range(int(x0 // cell), int(x1 // cell) + 1):
                    for cy in range(cy0, cy1):
                        for o in grid.get((cx, cy), ()):
                            if o in mine or o in seen:
                                continue
                            seen.add(o)
                            q = boxes[o]
                            if (min(x1, q[2]) - max(x0, q[0]) > -clr
                                    and min(y1, q[3]) - max(y0, q[1])
                                    > -clr):
                                return False
                for q in others:
                    if (min(x1, q[2]) - max(x0, q[0]) > -clr
                            and min(y1, q[3]) - max(y0, q[1]) > -clr):
                        return False
            return True

        body_rel = {}
        body_grid = defaultdict(list)   # strip -> (ref, kept body box)
        strip = 400.0
        seg_bb = {}          # bounding boxes of the kept layout's lines
        body_cache = {}      # ref -> body box at the current (kept) layout

        def body(r):
            """_body_box, with the at-origin box measured once per part."""
            i = by[r]
            if r not in body_rel:
                body_rel[r] = self._body_box(i)
                body_rel[r] = tuple(v - (i.ox_px, i.oy_px)[k % 2]
                                    for k, v in enumerate(body_rel[r]))
            q = body_rel[r]
            return (i.ox_px + q[0], i.oy_px + q[1],
                    i.ox_px + q[2], i.oy_px + q[3])

        def cost(refs, nets, all_segs):
            sub = [by[r] for r in sorted({r for n in nets
                                          for r in on_net[n]})]
            mine = [s for s in self._flight_segments(sub) if s[6] in nets]
            wire = sum(abs(s[1][0] - s[0][0]) + abs(s[1][1] - s[0][1])
                       for s in mine)
            cross = 0
            # A bounding-box test first: most pairs are far apart, and
            # _seg_cross was a quarter of OPAx197's placement time.
            def _bb(t):
                return (min(t[0][0], t[1][0]), max(t[0][0], t[1][0]),
                        min(t[0][1], t[1][1]), max(t[0][1], t[1][1]), t)
            # The kept layout's boxes are measured once per layout, not
            # once per trial.
            if seg_bb.get('of') is not all_segs:
                seg_bb.clear()
                seg_bb['of'] = all_segs
                seg_bb['bb'] = [_bb(t) for t in all_segs]
            fk = frozenset(nets)
            if fk not in seg_bb:
                seg_bb[fk] = [q for q in seg_bb['bb'] if q[4][6] not in nets]
            obb = seg_bb[fk]
            mbb = [_bb(t) for t in mine]
            if mbb:
                hx0 = min(q[0] for q in mbb); hx1 = max(q[1] for q in mbb)
                hy0 = min(q[2] for q in mbb); hy1 = max(q[3] for q in mbb)
                near = [q for q in obb if not (q[0] > hx1 or q[1] < hx0
                                               or q[2] > hy1 or q[3] < hy0)]
            else:
                near = []
            for ia, (x0, x1, y0, y1, s) in enumerate(mbb):
                a, b = s[0], s[1]
                for tx0, tx1, ty0, ty1, t in near + mbb[ia + 1:]:
                    if tx0 > x1 or tx1 < x0 or ty0 > y1 or ty1 < y0:
                        continue
                    c, d = t[0], t[1]
                    if a in (c, d) or b in (c, d):
                        continue
                    if _seg_cross(a, b, c, d):
                        cross += 1
            purple = 0.0
            lines = [(s[0], s[1], s[2], s[4], s[6]) for s in mine]
            if sensing & refs:
                near = [by[q] for q in sorted(
                    refs | set().union(*(sense_mates.get(q, set())
                                         for q in refs)))]
                for s in self._sense_segments(near):
                    if s[1].comp['ref'] in refs or s[2].comp['ref'] in refs:
                        purple += (abs(s[6][0] - s[5][0])
                                   + abs(s[6][1] - s[5][1]))
                        lines.append((s[5], s[6], s[1].comp['ref'],
                                      s[2].comp['ref'], None))
            # Lines through a part they do not join: the group's own lines
            # against every part, and everyone else's against the group.
            # T-symbols are left out here: during Place they still sit where
            # the layout before the chain relayout put them, and scoring
            # against them made 40 more lines cross parts on OPAx197.
            hits = 0
            # Every other part's box is unchanged by a trial, so it comes
            # from the cache; only the moving parts are measured again.
            if not body_cache:
                body_cache.update((r, body(r)) for r in by)
            # Kept bodies bucketed into vertical strips, so a line only
            # meets the bodies in the strips its x span covers.
            if not body_grid:
                for r in by:
                    q = body_cache[r]
                    for cx in range(int(q[0] // strip),
                                    int(q[2] // strip) + 1):
                        body_grid[cx].append((r, q))
            mov = [(r, body(r)) for r in sorted(refs)]
            for a, b, ra, rb, _net in lines:
                lx0, lx1 = min(a[0], b[0]), max(a[0], b[0])
                ly0, ly1 = min(a[1], b[1]), max(a[1], b[1])
                cand = {}
                for cx in range(int(lx0 // strip), int(lx1 // strip) + 1):
                    for r, bb in body_grid.get(cx, ()):
                        if bb[1] <= ly1 and bb[3] >= ly0 \
                                and r not in refs:
                            cand[r] = bb
                cand.pop(ra, None); cand.pop(rb, None)
                for r, bb in list(cand.items()) + [
                        (r, bb) for r, bb in mov if r not in (ra, rb)]:
                    if bb[0] > lx1 or bb[2] < lx0 or bb[1] > ly1 \
                            or bb[3] < ly0:
                        continue
                    if _seg_intersects_rect(a[0], a[1], b[0], b[1], bb):
                        hits += 1
            for r, bb in mov:
                for tx0, tx1, ty0, ty1, t in obb:
                    if tx0 > bb[2] or tx1 < bb[0] or ty0 > bb[3] \
                            or ty1 < bb[1] or r in (t[2], t[4]):
                        continue
                    if _seg_intersects_rect(t[0][0], t[0][1], t[1][0],
                                            t[1][1], bb):
                        hits += 1
            return wire + 0.5 * purple, cross, hits

        sensing = set()
        sense_mates = defaultdict(set)
        for s in self._sense_segments(instances):
            sensing.add(s[1].comp['ref'])
            sensing.add(s[2].comp['ref'])
            sense_mates[s[1].comp['ref']].add(s[2].comp['ref'])
            sense_mates[s[2].comp['ref']].add(s[1].comp['ref'])

        def targets(refs, nets):
            """Offsets that bring the group's pin centroid to the median of
            its partners' pins, then to each partner pin in turn: when the
            partners are two clusters the median sits in the bigger one,
            and the smaller one may still be the better home."""
            pts, own = [], []
            for r in sorted(refs):
                for p, n in (by[r]._pin_net_pairs or []):
                    if str(n) not in nets:
                        continue
                    q = _pin_canvas_pos(by[r], p)
                    if q:
                        own.append(q)
                    for o in sorted(on_net[str(n)] - refs):
                        for po, no in (by[o]._pin_net_pairs or []):
                            if str(no) == str(n) and (o, po) not in p2t:
                                q = _pin_canvas_pos(by[o], po)
                                if q:
                                    pts.append(q)
            if not pts or not own:
                return []
            ox = sum(q[0] for q in own) / len(own)
            oy = sum(q[1] for q in own) / len(own)
            xs = sorted(q[0] for q in pts)
            ys = sorted(q[1] for q in pts)
            out = [(xs[len(xs) // 2] - ox, ys[len(ys) // 2] - oy)]
            for q in pts:
                t = (q[0] - ox, q[1] - oy)
                if all(abs(t[0] - u[0]) + abs(t[1] - u[1]) > 2 * cell
                       for u in out):
                    out.append(t)
            return out

        step = self._MEDIAN_STEP
        lim = int(self._MEDIAN_REACH // step)
        state = {'segs': self._flight_segments(instances),
                 'back': self._flow_report(instances)[1]}
        log = []
        spent = [0]        # crossings added so far, against the budget

        def members(g):
            return sorted(m.comp['ref'] for k in g for m in units[k][1])

        ring_pts = {}        # ring -> its offsets, nearest first

        def spots_near(g, t):
            """Up to _MEDIAN_TRIES free offsets nearest offset `t`."""
            out, ring = [], 0
            tx = round(t[0] / step) * step
            ty = round(t[1] / step) * step
            while ring <= lim and len(out) < self._MEDIAN_TRIES:
                pts = ring_pts.get(ring)
                if pts is None:
                    pts = ring_pts[ring] = sorted(
                        ((i, j) for i in range(-ring, ring + 1)
                         for j in range(-ring, ring + 1)
                         if max(abs(i), abs(j)) == ring),
                        key=lambda p: (p[0] ** 2 + p[1] ** 2, p))
                for i, j in pts:
                    if free(g, tx + i * step, ty + j * step):
                        out.append((tx + i * step, ty + j * step))
                        if len(out) >= self._MEDIAN_TRIES:
                            break
                ring += 3 if out else 1
            return out

        def why(refs, nets):
            """The pattern a kept move expresses: each moved part's type
            and how it relates to its nearest partner -- parallel (shares
            every signal net), series (shares one) or sense (purple)."""
            out = []
            for r in sorted(refs):
                best = None
                for o in sorted({o for n in sig[r] for o in on_net[n]}
                                - refs):
                    d = (abs(by[o].ox_px - by[r].ox_px)
                         + abs(by[o].oy_px - by[r].oy_px))
                    if best is None or d < best[0]:
                        rel = ('parallel' if sig[r] and sig[r] <= sig[o]
                               else 'series')
                        side = 'left of' if by[r].ox_px < by[o].ox_px \
                            else 'right of'
                        best = (d, o, rel, side)
                if best is None:
                    if r in sensing:
                        out.append(f'{_ref_kind(r)} sense line shortened')
                    continue
                _d, o, rel, side = best
                out.append(f'{_ref_kind(r)} {rel} with {_ref_kind(o)}, '
                           f'{side} it ({r} by {o})')
            return '; '.join(out)

        def attempt(g, ts, how):
            """Try group `g` at the free spots nearest each offset in `ts`;
            keep the best one that passes the gain, crossing and flow
            tests. Returns True when it moved."""
            refs = set(members(g))
            nets = set().union(*(sig[r] for r in refs))
            w0, c0, h0 = cost(refs, nets, state['segs'])
            if w0 <= 0.0:
                return False
            lb0 = back_of(refs)
            gain = (self._MEDIAN_GAIN if how == 'median'
                    else self._MEDIAN_GAIN_LONG)
            best = None
            for t in ts:
                if abs(t[0]) + abs(t[1]) <= 2 * cell \
                        and how not in ('flow', 'hit'):
                    continue
                for dx, dy in spots_near(g, t):
                    for r in refs:
                        by[r].ox_px += dx; by[r].oy_px += dy
                    w1, c1, h1 = cost(refs, nets, state['segs'])
                    back = state['back'] - lb0 + back_of(refs)
                    if how == 'flow':
                        # Worth some wire to read left to right.
                        better = (back < state['back'] and h1 <= h0
                                  and w1 <= self._MEDIAN_FLOW_WIRE * w0)
                    elif how == 'hit':
                        better = (h1 < h0 and back <= state['back']
                                  and w1 <= self._MEDIAN_HIT_WIRE * w0)
                    else:
                        better = (w1 <= (1.0 - gain) * w0
                                  and back <= state['back'])
                    # Only a wire-saving move may spend the crossing
                    # budget; a flow or hit move must not add one.
                    ok = better and (c1 <= c0 or (
                        how not in ('flow', 'hit')
                        and spent[0] + c1 - c0
                        <= self._MEDIAN_CROSS_BUDGET))
                    for r in refs:
                        by[r].ox_px -= dx; by[r].oy_px -= dy
                    if ok and (best is None or (c1, h1, w1) < best[0]):
                        best = ((c1, h1, w1), dx, dy)
            if best is None:
                return False
            (c1, _h1, w1), dx, dy = best
            spent[0] += max(0, c1 - c0)
            for r in refs:
                by[r].ox_px += dx; by[r].oy_px += dy
            for k in g:
                index(k, False)
                b = boxes[k]
                boxes[k] = [b[0] + dx, b[1] + dy, b[2] + dx, b[3] + dy]
                index(k, True)
            state['segs'] = self._flight_segments(instances)
            state['back'] = self._flow_report(instances)[1]
            for r in refs:
                body_cache[r] = body(r)
            body_grid.clear()
            log.append({'sweep': sweep, 'how': how, 'refs': sorted(refs),
                        'dx': dx, 'dy': dy, 'saved': w0 - w1,
                        'cross': c1 - c0, 'why': why(refs, nets)})
            return True

        def nominees():
            """Units at the shorter end of the longest blue and purple
            lines, each with the offset that takes its end to the other."""
            rows = []
            for a, b, ra, _pa, rb, _pb, _n in state['segs']:
                if ra is not None:
                    rows.append((abs(b[0] - a[0]) + abs(b[1] - a[1]),
                                 'B', ra, a, rb, b))
            blue = sorted(rows, key=lambda r: (-r[0], r[2], r[4]))
            rows = []
            for s in self._sense_segments(instances):
                a, b = s[5], s[6]
                rows.append((abs(b[0] - a[0]) + abs(b[1] - a[1]), 'P',
                             s[2].comp['ref'], a, s[1].comp['ref'], b))
            purple = sorted(rows, key=lambda r: (-r[0], r[2], r[4]))
            out = []
            n = self._MEDIAN_LONGEST
            for _d, kind, ra, a, rb, b in blue[:n] + purple[:n]:
                ka, kb = box_of.get(ra), box_of.get(rb)
                if ka is None or kb is None or ka == kb:
                    continue
                size = {k: (len(units[k][1]),
                            (boxes[k][2] - boxes[k][0])
                            * (boxes[k][3] - boxes[k][1]), k)
                        for k in (ka, kb)}
                k = min(size, key=lambda q: size[q])
                p, q = (a, b) if k == ka else (b, a)
                out.append(([k], [(q[0] - p[0], q[1] - p[1])],
                            'longest-' + kind))
            return out

        # _flow_report's non-feedback driver -> receiver pairs, indexed by
        # ref, so a trial recounts only the pairs its parts are in (the
        # whole report per trial was a tenth of OPAx197's placement time).
        fbn = {str(n).lower() for n in (self._feedback_nets or ())}
        pairs_of = defaultdict(list)
        for nl, (drv, lod) in sorted(
                (getattr(self, '_net_flow_refs', None) or {}).items()):
            if nl in fbn:
                continue
            for d in sorted(drv):
                for r in sorted(lod - drv):
                    if d in by and r in by:
                        pairs_of[d].append((nl, d, r))
                        pairs_of[r].append((nl, d, r))

        def back_of(refs):
            seen, n = set(), 0
            for q in refs:
                for pr in pairs_of.get(q, ()):
                    if pr in seen:
                        continue
                    seen.add(pr)
                    ba, bb = by[pr[1]].abs_sym_body(), by[pr[2]].abs_sym_body()
                    if (bb[0] + bb[2]) / 2 - (ba[0] + ba[2]) / 2 < -5.0:
                        n += 1
            return n

        def small(k):
            return len(units[k][1]) <= self._MEDIAN_GROUP_MAX

        def net_pin(r, nl):
            for p, n in (by[r]._pin_net_pairs or []):
                if str(n).lower() == nl:
                    return _pin_canvas_pos(by[r], p)
            return None

        def flow_nominees():
            """Driver -> receiver pairs that run backward: the smaller unit
            is offered the spot that puts the driver just left of the
            receiver, level with the pins the net joins."""
            out = []
            for _dx, nl, d, r in self._flow_report(instances)[4]:
                kd, kr = box_of.get(d), box_of.get(r)
                if kd is None or kr is None or kd == kr:
                    continue
                pd, pr = net_pin(d, nl), net_pin(r, nl)
                if not pd or not pr:
                    continue
                bd, br = boxes[kd], boxes[kr]
                if small(kd) and (not small(kr) or len(units[kd][1])
                                  <= len(units[kr][1])):
                    out.append(([kd], [(br[0] - clr - bd[2],
                                        pr[1] - pd[1])], 'flow'))
                elif small(kr):
                    out.append(([kr], [(bd[2] + clr - br[0],
                                        pd[1] - pr[1])], 'flow'))
            return out

        def hit_nominees():
            """A small unit a line runs through is offered the spots just
            clear of that line on each side, and its own median."""
            out, seen = [], set()
            # Sorted, so the order never follows the T list or any set:
            # it made OPAx197's layout depend on PYTHONHASHSEED.
            for _kind, ra, rb, r in sorted(self._line_body_hits(
                    instances, state['segs']), key=lambda h: (
                        str(h[3]), str(h[1]), str(h[2]), h[0])):
                k = box_of.get(r)
                if k is None or k in seen or not small(k) \
                        or k in (box_of.get(ra), box_of.get(rb)):
                    continue
                seen.add(k)
                b = boxes[k]
                w, h = b[2] - b[0], b[3] - b[1]
                refs = set(members([k]))
                nets = set().union(*(sig[q] for q in refs))
                ts = [(0.0, -(h + clr)), (0.0, h + clr),
                      (-(w + clr), 0.0), (w + clr, 0.0)]
                out.append(([k], ts + (targets(refs, nets) if nets else []),
                            'hit'))
            return out

        def grazes(r):
            """Lines passing a pin of part r they do not join: they read as
            joining both of its pins (user: LM324.lib's RO2)."""
            mine = self._flight_segments(
                [by[q] for q in sorted({q for n in sig[r]
                                        for q in on_net[n]} | {r})])
            return sum(1 for q, _p, _d in self._self_graze_pairs(
                instances, mine) if q == r)

        def turn_nominees():
            """Loose parts with a line grazing one of their own pins."""
            out = []
            for q, _p, _d in sorted(self._self_graze_pairs(
                    instances, state['segs']), key=lambda t: (t[0], t[1])):
                k = box_of.get(q)
                if (k is not None and k not in out
                        and len(units[k][1]) == 1
                        and q not in self._user_rotations
                        and self._rigid_unit_of(q) is None):
                    out.append(k)
            return out

        def turn180(k):
            """Turn a loose part 180 degrees -- its box keeps its size, so
            the turn is legal after placement -- and keep it only when its
            pins are grazed less, no line crosses more parts, no crossing
            is added, no pair turns backward and its wire grows by no more
            than _MEDIAN_HIT_WIRE."""
            inst = units[k][1][0]
            r = inst.comp['ref']
            refs, nets = {r}, set(sig[r])
            if not nets:
                return False
            w0, c0, h0 = cost(refs, nets, state['segs'])
            g0, lb0 = grazes(r), back_of(refs)
            old = (inst.rotation_deg or 0) % 360
            new = (old + 180) % 360
            old_box = list(boxes[k])
            try:
                self._apply_instance_rotation_geometry(inst, new)
            except Exception:
                return False
            inst.rotation_deg = new
            body_rel.pop(r, None)
            e = self._placement_extent(inst)
            boxes[k] = [inst.ox_px + e[0], inst.oy_px + e[1],
                        inst.ox_px + e[2], inst.oy_px + e[3]]
            ok = free([k], 0.0, 0.0)
            if ok:
                w1, c1, h1 = cost(refs, nets, state['segs'])
                back = state['back'] - lb0 + back_of(refs)
                ok = (grazes(r) < g0 and h1 <= h0 and c1 <= c0
                      and back <= state['back']
                      and w1 <= self._MEDIAN_HIT_WIRE * w0)
            if not ok:
                self._apply_instance_rotation_geometry(inst, old)
                inst.rotation_deg = old
                body_rel.pop(r, None)
                boxes[k] = old_box
                return False
            nb = boxes[k]
            boxes[k] = old_box
            index(k, False)
            boxes[k] = nb
            index(k, True)
            self._auto_rotations[r] = new
            state['segs'] = self._flight_segments(instances)
            state['back'] = self._flow_report(instances)[1]
            body_cache.pop(r, None)
            body_rel.pop(r, None)
            if body_cache:
                body_cache[r] = body(r)
            body_grid.clear()
            log.append({'sweep': sweep, 'how': 'turn180', 'refs': [r],
                        'dx': 0.0, 'dy': 0.0, 'saved': w0 - w1,
                        'cross': c1 - c0, 'why': f'{r} turned 180'})
            return True

        def level_nominees():
            """Loose upright R, C or L parts whose flight lines run mostly
            level: the mean |dy| of their lines is under _MEDIAN_LEVEL
            times the mean |dx|.  No pin may be on a T-symbol."""
            out = []
            for k, (_b, ms) in enumerate(units):
                if len(ms) != 1:
                    continue
                inst = ms[0]
                r = inst.comp['ref']
                pp = inst._pin_net_pairs or []
                if (_ref_kind(r) not in 'RCL' or len(pp) != 2
                        or any((r, p) in p2t for p, _n in pp)
                        or r in self._user_rotations
                        or self._rigid_unit_of(r) is not None):
                    continue
                a, b = (_pin_canvas_pos(inst, p) for p, _n in pp)
                if not a or not b or abs(b[1] - a[1]) <= abs(b[0] - a[0]):
                    continue
                ln = [sg for sg in state['segs'] if r in (sg[2], sg[4])]
                if not ln:
                    continue
                mdx = sum(abs(sg[1][0] - sg[0][0]) for sg in ln) / len(ln)
                mdy = sum(abs(sg[1][1] - sg[0][1]) for sg in ln) / len(ln)
                if mdy < self._MEDIAN_LEVEL * mdx:
                    out.append(k)
            return out

        def turn_level(k):
            """Lay an upright part flat, trying both quarter turns: its box
            changes shape, so the new box must be free.  Kept when no
            crossing, line through a part or backward pair is added and
            its wire does not grow; the better quarter turn wins."""
            inst = units[k][1][0]
            r = inst.comp['ref']
            refs, nets = {r}, set(sig[r])
            if not nets:
                return False
            w0, c0, h0 = cost(refs, nets, state['segs'])
            lb0 = back_of(refs)
            old = (inst.rotation_deg or 0) % 360
            old_box = list(boxes[k])
            best = None
            for new in ((old + 90) % 360, (old + 270) % 360):
                self._apply_instance_rotation_geometry(inst, new)
                inst.rotation_deg = new
                body_rel.pop(r, None)
                e = self._placement_extent(inst)
                boxes[k] = [inst.ox_px + e[0], inst.oy_px + e[1],
                            inst.ox_px + e[2], inst.oy_px + e[3]]
                if free([k], 0.0, 0.0):
                    w1, c1, h1 = cost(refs, nets, state['segs'])
                    back = state['back'] - lb0 + back_of(refs)
                    if (c1 <= c0 and h1 <= h0 and back <= state['back']
                            and w1 <= w0
                            and (best is None or (c1, h1, w1) < best[0])):
                        best = ((c1, h1, w1), new, list(boxes[k]))
                boxes[k] = old_box
            self._apply_instance_rotation_geometry(inst, old)
            inst.rotation_deg = old
            body_rel.pop(r, None)
            if best is None:
                return False
            (c1, _h1, w1), new, nb = best
            self._apply_instance_rotation_geometry(inst, new)
            inst.rotation_deg = new
            body_rel.pop(r, None)
            index(k, False)
            boxes[k] = nb
            index(k, True)
            self._auto_rotations[r] = new
            state['segs'] = self._flight_segments(instances)
            state['back'] = self._flow_report(instances)[1]
            body_cache.pop(r, None)
            if body_cache:
                body_cache[r] = body(r)
            body_grid.clear()
            log.append({'sweep': sweep, 'how': 'level', 'refs': [r],
                        'dx': 0.0, 'dy': 0.0, 'saved': w0 - w1,
                        'cross': c1 - c0, 'why': f'{r} laid level'})
            return True

        moved = 0
        self._median_sweeps = []
        for sweep in range(1, self._MEDIAN_SWEEPS + 1):
            n0 = len(log)
            order = []
            for g in groups:
                refs = set(members(g))
                nets = set().union(*(sig[r] for r in refs))
                ts = targets(refs, nets) if nets else []
                d = abs(ts[0][0]) + abs(ts[0][1]) if ts else 0.0
                if d > 2 * cell or len(ts) > 1:
                    order.append((-d, members(g), g))
            order.sort()
            for _neg, _refs, g in order:
                refs = set(members(g))
                nets = set().union(*(sig[r] for r in refs))
                attempt(g, targets(refs, nets), 'median')
            for g, ts, how in nominees() + flow_nominees() + hit_nominees():
                attempt(g, ts, how)
            for k in turn_nominees():
                turn180(k)
            for k in level_nominees():
                turn_level(k)
            self._median_sweeps.append(len(log) - n0)
            if len(log) == n0:
                break
        moved = len(log)
        self._median_log = log
        return moved

    _SHEET_COMPACT = True      # whole-sheet compaction after median move
    _SHEET_GAP = 40.0          # air kept along the axis being closed
    _SHEET_SWEEPS = 3          # y then x, this many times
    _SHEET_ROW_TOL = 20.0      # a flight line this near level ties its ends
    _SHEET_ISLAND = 300.0      # so does any line this short, first pass
    _SHEET_WIRE_SLACK = 0.0    # px a move may add to the row's own wire
    _SHEET_CROSS_PX = 2000.0   # wire a move must save per crossing it adds

    def _sheet_compact(self, instances):
        """In : the placed instances, labels and T's final.
        Proc: inside each independent box, close empty space along y and
              then x, _SHEET_SWEEPS times (_sheet_compact_axis).
        Out : the number of row moves; writes ox_px / oy_px.

        Boxes share no wire, so each is closed on its own and
        _repack_boxes then brings the boxes together.
        """
        gap = float(self._SHEET_GAP)
        nmoves = 0
        for cl in self._box_partition(instances):
            units = [list(ms) for _b, ms in self._unit_boxes(cl,
                                                             t_owned=False)]
            if len(units) < 2:
                continue
            for _sweep in range(int(self._SHEET_SWEEPS)):
                n = 0
                for reach in (2 * self._SHEET_ISLAND, self._SHEET_ISLAND,
                              0.0):
                    for axis in (1, 0):
                        n += self._sheet_compact_axis(cl, units, axis, gap,
                                                      reach)
                nmoves += n
                if not n:
                    break
        return nmoves

    def _sheet_compact_axis(self, cl, units, axis, gap, reach=0.0):
        """Compact one box along one axis (1 = y, 0 = x): rows of units joined
        by level flight lines move as one toward smaller y (x) until they
        meet the row before, kept when they add no crossings and little wire.
        Returns the number of rows moved.
        """
        o = 1 - axis
        tol = float(self._SHEET_ROW_TOL)
        slack = float(self._SHEET_WIRE_SLACK)
        xpx = float(self._SHEET_CROSS_PX)
        segs = self._flight_segments(cl)
        segs_of = defaultdict(list)
        for sg in segs:
            segs_of[sg[6]].append(sg)
        on_net = defaultdict(set)
        for m in cl:
            for _p, n in (m._pin_net_pairs or []):
                on_net[str(n)].add(m)
        uof = {m.comp['ref']: k for k, ms in enumerate(units) for m in ms}
        up = list(range(len(units)))

        def find(k):
            while up[k] != k:
                up[k] = up[up[k]]
                k = up[k]
            return k
        for sg in segs:
            ka, kb = uof.get(sg[2]), uof.get(sg[4])
            if ka is None or kb is None:
                continue
            dd = abs(sg[0][axis] - sg[1][axis])
            if dd <= tol or dd + abs(sg[0][o] - sg[1][o]) <= reach:
                up[find(ka)] = find(kb)
        rows = defaultdict(list)
        for k in range(len(units)):
            rows[find(k)].append(k)

        def ubox(ms):
            bs = []
            for m in ms:
                e = self._placement_extent(m)
                bs.append((m.ox_px + e[0], m.oy_px + e[1],
                           m.ox_px + e[2], m.oy_px + e[3]))
            return [min(b[0] for b in bs), min(b[1] for b in bs),
                    max(b[2] for b in bs), max(b[3] for b in bs)]
        boxes = [ubox(ms) for ms in units]
        floor = min(b[axis] for b in boxes)

        def crossings(nets, mine):
            n = 0
            others = [sg for nn, ss in segs_of.items() if nn not in nets
                      for sg in ss]
            for ia, sg in enumerate(mine):
                a, b = sg[0], sg[1]
                for t in others + mine[ia + 1:]:
                    c, d = t[0], t[1]
                    if a in (c, d) or b in (c, d):
                        continue
                    if _seg_cross(a, b, c, d):
                        n += 1
            return n

        def wire(ss):
            return sum(abs(sg[1][0] - sg[0][0]) + abs(sg[1][1] - sg[0][1])
                       for sg in ss)

        def shift(ks, d):
            for k in ks:
                for m in units[k]:
                    if axis:
                        m.oy_px += d
                    else:
                        m.ox_px += d
                boxes[k][axis] += d
                boxes[k][axis + 2] += d

        order = sorted(rows.values(), key=lambda ks: (
            round(min(boxes[k][axis] for k in ks), 3),
            round(min(boxes[k][o] for k in ks), 3),
            min(m.comp['ref'] for k in ks for m in units[k])))
        moved = 0
        for ks in order:
            mine_k = set(ks)
            # The furthest the row may travel: each unit stops at the
            # nearest unit wholly above it that shares its span.
            d = None
            for k in ks:
                b = boxes[k]
                lo = floor
                for j, pb in enumerate(boxes):
                    if j in mine_k or pb[axis + 2] > b[axis] + 1.0:
                        continue
                    if b[o] < pb[o + 2] and pb[o] < b[o + 2]:
                        lo = max(lo, pb[axis + 2] + gap)
                dk = min(0.0, lo - b[axis])
                d = dk if d is None else max(d, dk)
            if d is None or d > -0.5:
                continue
            nets = {str(n) for k in ks for m in units[k]
                    for _p, n in (m._pin_net_pairs or [])}
            sub = sorted({m for n in nets for m in on_net[n]},
                         key=lambda m: m.comp['ref'])
            old = [sg for n in nets for sg in segs_of.get(n, ())]
            c0, w0 = crossings(nets, old), wire(old)
            for f in (1.0, 0.5, 0.25):
                shift(ks, d * f)
                new = [sg for sg in self._flight_segments(sub)
                       if sg[6] in nets]
                c1, w1 = crossings(nets, new), wire(new)
                if (c1 <= c0 and w1 <= w0 + slack) or (
                        w1 <= w0 and w1 + xpx * (c1 - c0) <= w0):
                    for n in nets:
                        segs_of[n] = [sg for sg in new if sg[6] == n]
                    moved += 1
                    break
                shift(ks, -d * f)
        return moved

    def _compaction_pass(self, instances):
        """In : the placed instances, rotations, labels and T's final.
        Proc: freeze one reserved box at a time in (x, y) order, pushing a
              clashing item DOWN until it clears everything frozen.
        Out : a stats dict; adjusts oy_px on the items it moves.
        Column-major order is a linear extension of "right of or below",
        so an item can only be pushed away from what is already frozen.
        Down and never right: y is free while x carries the left-to-right
        reading the chain layout decided.  P2DL members move as ONE item,
        and a clash INSIDE a cell is counted and left alone.  Every repair
        is an earlier pass's bug, so repairs are reported, not absorbed."""
        # Packing to _MIN_CLEARANCE (1 px) is overlap-free but unreadable:
        # neighbouring labels sit shoulder to shoulder.  A moved part keeps
        # this much air instead.
        clr = max(self._MIN_CLEARANCE, self._MEDIAN_CLEAR)
        box_of = {}
        for inst in instances:
            try:
                e = self._placement_extent(inst)
            except Exception:
                continue
            box_of[id(inst)] = (inst.ox_px + e[0], inst.oy_px + e[1],
                                inst.ox_px + e[2], inst.oy_px + e[3])
        # One item per rigid P2DL block, one per loose instance.  Keyed on
        # the block's sorted refs so the grouping is deterministic.
        by_ref = {i.comp['ref']: i for i in instances}
        gid = {}
        for refs in sorted(tuple(sorted(f))
                           for f in (getattr(self, '_sp_rigid_blocks', None)
                                     or set())):
            for r in refs:
                inst = by_ref.get(r)
                if inst is not None and id(inst) in box_of:
                    gid[id(inst)] = refs
        groups = {}
        for inst in instances:
            if id(inst) not in box_of:
                continue
            groups.setdefault(gid.get(id(inst), inst.comp['ref']),
                              []).append(inst)
        intra = 0
        for members in groups.values():
            for ia in range(len(members)):
                for ib in range(ia + 1, len(members)):
                    if self._boxes_clash(box_of[id(members[ia])],
                                         box_of[id(members[ib])]):
                        intra += 1
        # Boxes share no wire and _repack_boxes separates them afterwards,
        # so a part is only ever pushed by a part of its own box.
        box_no = {}
        for k, cl in enumerate(self._box_partition(instances)):
            for inst in cl:
                box_no[id(inst)] = k
        items = []
        for members in groups.values():
            bs = [box_of[id(mm)] for mm in members]
            items.append((min(b[0] for b in bs), min(b[1] for b in bs),
                          max(b[2] for b in bs), max(b[3] for b in bs),
                          min(mm.comp['ref'] for mm in members), members))
        items.sort(key=lambda t: (round(t[0], 3), round(t[1], 3), t[4]))
        frozen_by_box, nmoved, tot, worst = {}, 0, 0.0, None
        for x0, y0, x1, y1, ref, members in items:
            frozen = frozen_by_box.setdefault(
                box_no.get(id(members[0]), -1), [])
            dy = 0.0
            for _ in range(len(frozen) + 2):
                hit = None
                for fb in frozen:
                    if not self._boxes_clash((x0, y0 + dy, x1, y1 + dy), fb):
                        continue
                    if hit is None or fb[3] > hit[3]:
                        hit = fb
                if hit is None:
                    break
                dy = hit[3] + clr + 0.001 - y0
            if dy > 0.0:
                for mm in members:
                    mm.oy_px += dy
                    b = box_of[id(mm)]
                    box_of[id(mm)] = (b[0], b[1] + dy, b[2], b[3] + dy)
                nmoved += len(members)
                tot += dy
                if worst is None or dy > worst[1]:
                    worst = (ref, dy)
            frozen.extend(box_of[id(mm)] for mm in members)
        hull = {}
        for inst in instances:
            k, bb = box_no.get(id(inst)), box_of.get(id(inst))
            if k is None or bb is None:
                continue
            h = hull.get(k)
            hull[k] = bb if h is None else (min(h[0], bb[0]), min(h[1], bb[1]),
                                            max(h[2], bb[2]), max(h[3], bb[3]))
        hl = [hull[k] for k in sorted(hull)]
        cross = sum(1 for i in range(len(hl)) for j in range(i + 1, len(hl))
                    if self._boxes_clash(hl[i], hl[j]))
        return {'items': len(items), 'moved': nmoved, 'total_dy': tot,
                'worst': worst, 'intra_cell': intra, 'cross_box': cross}

    def _chain_graph(self, instances):
        """In : the placed instances.
        Proc: build the CHAIN graph by the five direction rules — a
              T-consumed net carries no edge; a 2-pin passive with one
              pin on a RAIL is a shunt; a sensed V source is an ammeter;
              a passive with both nets driven is a bridge; and a
              behavioral source's sense_srcs and sense_nets are control
              edges.
        Out : (adj, spine, comps) — the directed graph, the longest
              input-to-output chain through the DAG, and the components
              left when the spine is removed."""
        inp, outp, tnets, rails = set(), set(), {'0'}, {'0'}
        for t in (self._t_terminals or []):
            n, r = str(t.get('net')).lower(), int(t.get('rot', 0))
            tnets.add(n)
            if r == 270:
                inp.add(n)
            elif r == 90:
                outp.add(n)
            else:
                rails.add(n)
        # THE T's DO NOT EXIST YET when placement runs -- they are
        # rebuilt from the finished coordinates -- so fall back to the
        # declared ports and the detected rails, which are the same
        # information one step earlier.
        if not inp and not outp:
            try:
                _i, _o = self._subckt_io_nets()
                inp |= {str(n).lower() for n in _i}
                outp |= {str(n).lower() for n in _o}
            except Exception:
                pass
        try:
            _pol = self._rail_polarity()
            rails |= {str(n).lower() for n in (_pol or ()) if n}
        except Exception:
            pass
        rails |= {str(n).lower()
                  for n in (getattr(self, '_supply_rails', set()) or set())}
        rails |= {str(n).lower() for n in _PWR_NETS_LC_FOR_T}
        # A PROMOTED RAIL (OPAx197's MID, by fanout) is a rail to the
        # spine too.  Without it the spine ran through MID's 25 drivers
        # on a first Place, when no saved glyph says MID is a T.
        rails |= {str(n).lower()
                  for n in (getattr(self, '_promoted_rails', None) or ())}
        inp -= rails
        outp -= rails
        tnets |= rails
        sensed = set()
        for i in instances:
            for x in (i.comp.get('sense_srcs') or []):
                sensed.add(str(x).lower())
        nets_of, by_name = {}, {}
        active_out = defaultdict(set)
        for i in instances:
            r = i.comp['ref']
            by_name[r.lower()] = r
            nets_of[r] = [str(x).lower() for x in (i.comp.get('nets') or [])]
            if r.lower() in sensed:
                continue
            for n in {str(x).lower()
                      for x in _electrical_net_roles(i.comp)[0]}:
                if n not in tnets:
                    active_out[n].add(r)
        drv, rcv = defaultdict(set), defaultdict(set)
        port_fed = {}
        for i in instances:
            r = i.comp['ref']
            ns = nets_of[r]
            free = [n for n in ns if n not in tnets]
            o, n_in = _electrical_net_roles(i.comp)[0:2]
            o = {str(x).lower() for x in o} - tnets
            n_in = {str(x).lower() for x in n_in} - tnets
            passive = (not o and not n_in) or r.lower() in sensed
            if r.lower() in sensed:
                o, n_in = set(), set()
            on_rail = [n for n in ns if n in rails]
            if (passive and len(set(ns)) == 2 and len(free) == 1
                    and on_rail and r.lower() not in sensed):
                continue                       # shunt: no through edge
            if passive and len(free) == 2:
                a, b = free
                if (a in inp) != (b in inp):
                    # A declared input port feeds this passive, so it
                    # drives its other pin whatever else drives that net.
                    if b in inp:
                        a, b = b, a
                    port_fed[r] = (a, b)
                    rcv[a].add(r); drv[b].add(r); continue
                da = bool(active_out[a] - {r})
                db = bool(active_out[b] - {r})
                if da and db:
                    continue                   # bridge: feedback
                if da:
                    rcv[a].add(r); drv[b].add(r); continue
                if db:
                    rcv[b].add(r); drv[a].add(r); continue
                drv[a].add(r); drv[b].add(r)
                rcv[a].add(r); rcv[b].add(r); continue
            for n in free:
                if n in o:
                    drv[n].add(r)
                elif n in n_in:
                    rcv[n].add(r)
                else:
                    drv[n].add(r); rcv[n].add(r)
        # The user's pin marks (Nets dialog, Ctrl+click) outrank the device
        # table's driver/receiver roles.
        metered = {n for r, ns in nets_of.items() if r.lower() in sensed
                   for n in ns}
        for n, (dset, lset) in (getattr(self, '_net_flow_refs', None)
                                or {}).items():
            if n in tnets or n in metered or not (dset and lset):
                continue
            mine = {r for r, ns in nets_of.items() if n in ns}
            drv[n] = set(dset) & mine
            rcv[n] = (set(lset) - set(dset)) & mine
        # A PORT-FED PASSIVE KEEPS ITS DIRECTION.  On OPAx197 the role map
        # has the ESD clamps drive esdp back into R_R1, so neither input
        # resistor had an outgoing edge and no input reached anything.
        for r, (a, b) in port_fed.items():
            rcv[a].add(r); drv[a].discard(r)
            drv[b].add(r); rcv[b].discard(r)
        ovr = getattr(self, '_pin_role_overrides', None) or {}
        if ovr:
            by_ref_inst = {i.comp['ref']: i for i in instances}
            for (oref, opin), orole in ovr.items():
                inst = by_ref_inst.get(oref)
                if inst is None or orole not in ('in', 'out'):
                    continue
                for pn, nn in (getattr(inst, '_pin_net_pairs', None) or []):
                    if str(pn) != str(opin):
                        continue
                    nl = str(nn).lower()
                    if nl in tnets:
                        break
                    if orole == 'out':
                        drv[nl].add(oref); rcv[nl].discard(oref)
                    else:
                        rcv[nl].add(oref); drv[nl].discard(oref)
                    break
        adj = defaultdict(set)
        for n in set(drv) | set(rcv):
            for a in drv[n]:
                for b in rcv[n]:
                    if a != b:
                        adj[a].add(b)
        for i in instances:
            r = i.comp['ref']
            for x in (i.comp.get('sense_srcs') or []):
                src = by_name.get(str(x).lower())
                if src and src != r:
                    adj[src].add(r)
            for sn in (i.comp.get('sense_nets') or []):
                nl = str(sn).lower()
                for src in (drv.get(nl, set()) | active_out.get(nl, set())):
                    if src != r:
                        adj[src].add(r)
        touch = defaultdict(set)
        for r, ns in nets_of.items():
            for n in set(ns):
                touch[n].add(r)
        starts = sorted({r for n in inp for r in touch[n]})
        # An output net's end is its driver, not every part on it: the
        # per-branch cut refuses every edge leaving an end.
        ends = {r for n in outp for r in (drv.get(n) or touch[n])}
        # THE MARKED OUTPUT ANCHORS THE RIGHT END (user's rule).  An
        # oscillator names its output with a pin mark rather than a port,
        # so a part holding a pin the user forced to 'out' is an end of
        # the spine just as a part on an output NET is.
        out_marked = {r for (r, _p), role in ovr.items() if role == 'out'}
        ends |= out_marked
        colour, dag = {}, defaultdict(set)
        back_edges = set()
        stack = []
        # ...AND CYCLES BREAK BEHIND IT, NOT IN FRONT.  A tank oscillator
        # is one loop, so which edge the DFS drops decides the whole
        # left-to-right order, and seeding in sorted(adj) order picked it
        # alphabetically: Osc2 started at C2, dropped L1->C2, and drew
        # C2 / Q1 / L1 with the output stranded mid-spine.  Visiting the
        # marked output LAST makes the walk arrive at it instead of
        # leaving from it, so the dropped edge is the one leaving the
        # output and the spine reads L1 -> C2 -> Q1: the filter to the
        # left of the base, output on the right, which is how the user
        # draws it.
        seeds = starts + sorted(adj, key=lambda r: (r in out_marked, r))
        for s0 in seeds:
            if colour.get(s0) is not None:
                continue
            stack = [(s0, iter(sorted(adj[s0])))]
            colour[s0] = 1
            while stack:
                u, it = stack[-1]
                nxt = next(it, None)
                if nxt is None:
                    colour[u] = 2; stack.pop(); continue
                if colour.get(nxt) == 1:
                    # BACK EDGE: the loop closes here and this is the
                    # edge the cycle-break drops.  Remember it -- a leg
                    # tapping a net whose flow was reversed belongs at
                    # the net's UPSTREAM end, not beside the driver.
                    back_edges.add((u, nxt))
                    continue                   # back edge: feedback
                if u in ends and u not in starts:
                    # A branch stops at its own output: walking through an
                    # output part put LM324.lib's DC and DE on the spine, far
                    # from VC and VE.
                    back_edges.add((u, nxt))
                    continue
                dag[u].add(nxt)
                if colour.get(nxt) is None:
                    colour[nxt] = 1
                    stack.append((nxt, iter(sorted(adj[nxt]))))
        nodes = set(dag) | {v for s in dag.values() for v in s} | set(starts)
        indeg = defaultdict(int)
        for u in dag:
            for v in dag[u]:
                indeg[v] += 1
        # No input to anchor the left end (an oscillator): put the output on the
        # right and work back from it.
        seed = set(starts)
        if not seed:
            seed = {u for u in nodes if indeg[u] == 0}
        cand_ends = set(ends) or set(nodes)
        indeg0 = {u: indeg[u] == 0 for u in nodes}
        q = deque(sorted(u for u in nodes if indeg[u] == 0))
        order = []
        while q:
            u = q.popleft(); order.append(u)
            for v in sorted(dag[u]):
                indeg[v] -= 1
                if indeg[v] == 0:
                    q.append(v)
        def _longest(seed, cand_ends):
            dist, prev = {}, {}
            for u in order:
                dist.setdefault(u, 1 if u in seed else 0)
                for v in sorted(dag[u]):
                    if dist.get(u, 0) and dist[u] + 1 > dist.get(v, 0):
                        dist[v] = dist[u] + 1; prev[v] = u
            be = max((e for e in cand_ends if dist.get(e, 0) > 1),
                     key=lambda e: (dist[e], e), default=None)
            return be, prev

        # NO SILENT EMPTY SPINE.  When no input reaches an output, take the
        # longest chain from an input; when the inputs lead nowhere (OPAx197's
        # input parts have no outgoing edge), the longest chain in the deck.
        # An empty spine skips the whole chain layout without a word.
        be, prev = _longest(seed, cand_ends)
        if be is None:
            be, prev = _longest(seed, nodes)
        if be is None:
            be, prev = _longest({u for u in nodes if indeg0[u]}, nodes)
        spine = []
        if be:
            x = be
            while x:
                spine.append(x); x = prev.get(x)
            spine.reverse()
            # The spine stops at the first output it reaches; walking through
            # one output into another put LM324.lib's clamp diodes on the spine.
        und = defaultdict(set)
        for a, bs in adj.items():
            for b in bs:
                und[a].add(b); und[b].add(a)
        netmates = defaultdict(set)
        for r, ns in nets_of.items():
            for n in set(ns):
                if n not in tnets:
                    netmates[n].add(r)
        on = set(spine)
        # A subchain is laid out left to right in list order, so the list
        # is in FLOW order, never alphabetical: LM324.sub's 31-part
        # subchain ran C15 ... V54 across the page.  The order is the one
        # _compute_signal_topo_order chose from the pin-role map, the same
        # direction every other flow decision and the flow metric read.
        _pos = dict(getattr(self, '_flow_order', None) or
                    {u: k for k, u in enumerate(order)})
        seen, comps = set(), []
        for r in sorted({i.comp['ref'] for i in instances} - on):
            if r in seen:
                continue
            qq, comp = deque([r]), []
            seen.add(r)
            while qq:
                x = qq.popleft(); comp.append(x)
                for y in sorted(und[x]):
                    if y not in on and y not in seen:
                        seen.add(y); qq.append(y)
            comps.append(sorted(comp, key=lambda x: (_pos.get(x, len(_pos)),
                                                     x)))
        if self._CHAIN_INSERT_ROWS:
            sig = {r: set(ns) - tnets for r, ns in nets_of.items()}
            pins = {i.comp['ref']: {p for p, _n in
                                    (getattr(i, '_pin_net_pairs', None) or ())}
                    for i in instances}
            blk = {r: k for k in _stable_blocks(
                       getattr(self, '_sp_block_layout', None))
                   if len(k) > 1 for r in k}
            # Terminal pairs: a 2-pin part's two nets, and a controlled
            # source's output pair and sensing pair.
            tpairs = {i.comp['ref']:
                      _terminal_pairs(i.comp,
                                      getattr(i, '_pin_net_pairs', None))
                      for i in instances}
            if self._CHAIN_MIGRATE_PARALLEL:
                comps, spine = self._chain_migrate_parallel(
                    comps, spine, blk, tpairs, sig,
                    to_spine=self._CHAIN_MIGRATE_SPINE)
            comps = [self._chain_insert_into_row(c, sig, pins, blk, tpairs)
                     for c in comps]
        return dict(spine=spine, comps=comps, und=und, netmates=netmates,
                    nets_of=nets_of, rails=rails, tnets=tnets, drv=drv,
                    rcv=rcv, back_edges=back_edges)

    _CHAIN_INSERT_ROWS = True

    # OFF: moving a part out of its own row into its partner's took
    # OPAx197 from 73 crossings to 87, with or without the spine, for
    # 1.2k px of wire.  Needs a keep test before it can be turned on.
    _CHAIN_MIGRATE_PARALLEL = True
    _CHAIN_MIGRATE_SPINE = False  # a part may also join the spine's row

    @staticmethod
    def _chain_migrate_parallel(comps, spine, blk, tpairs, sig=None,
                                to_spine=True):
        """In : the subchains in flow order, the spine, {ref: P2DL cell}
              and {ref: terminal pairs}.
        Proc: A PARALLEL PART BELONGS IN ITS PARTNER'S ROW, even when the
              two landed in different subchains.  A two-terminal part
              with no partner on its pair in its own row moves to the
              first row that has one, landing right after it, but only
              when it shares no signal net with anything left behind.
              Cell members never move and a row is never emptied.
        Out : (comps, spine).  This is what lets a shunt reach the
              divider it parallels across a subchain boundary."""
        rows = [list(c) for c in comps] + ([list(spine)] if to_spine else [])
        sig = sig or {}
        for k, row in enumerate(rows):
            for h in list(row):
                pr = tpairs.get(h) or set()
                if len(pr) != 1 or h in blk or len(rows[k]) < 2:
                    continue
                if any(pr & (tpairs.get(o) or set())
                       for o in rows[k] if o != h):
                    continue
                for j, other in enumerate(rows):
                    if j == k:
                        continue
                    hit = [i for i, o in enumerate(other)
                           if pr & (tpairs.get(o) or set())]
                    # KEEP TEST: leave nothing behind.  Every part h shares
                    # a signal net with must be in the row it is moving to,
                    # or h's old row keeps a line reaching after it -- which
                    # is what made this pass cost 14 crossings on OPAx197
                    # when it moved parts out of rows they still fed.
                    if hit and not [o for o in rows[k]
                                    if o != h and (sig.get(h, set())
                                                   & sig.get(o, set()))]:
                        rows[k].remove(h)
                        other.insert(hit[0] + 1, h)
                        break
        if to_spine:
            return [r for r in rows[:-1] if r], rows[-1]
        return [r for r in rows if r], spine


    @staticmethod
    def _chain_insert_into_row(seq, sig, pins, blk, tpairs=None):
        """In : a subchain in flow order, plus per-ref maps of signal
              nets, pin names, P2DL cell key and terminal-pair net sets.
              Out: the reordered list.
        Proc: a 2-pin part whose two nets are another unit's terminal
              pair follows that unit (parallel parts sit together) unless
              a neighbor is already parallel with it.  Other 2-pin parts
              are branches: one with no neighbor sharing a net moves
              between the leftmost adjacent pair sharing a net with it,
              or, if a shunt, just before the first unit on its net.
              A P2DL cell is one unit; its members never move."""
        # A P2DL cell is laid out at its first member, so the row is a row
        # of UNITS: a cell, or a loose part.
        units, at = [], {}
        for r in seq:
            k = blk.get(r)
            if k is not None and k in at:
                units[at[k]].append(r)
                continue
            if k is not None:
                at[k] = len(units)
            units.append([r])
        tpairs = tpairs or {}
        branch = {r for r in seq
                  if len(tpairs.get(r, ())) == 1 and sig.get(r)
                  and r not in blk}

        def shares(h, u):
            return any(sig.get(h, set()) & sig.get(m, set()) for m in u)

        def parallel(h, u):
            return any(tpairs.get(h, set()) & tpairs.get(m, set()) for m in u)
        for h in [r for r in seq if r in branch]:
            k = units.index([h])
            nbr = [units[j] for j in (k - 1, k + 1) if 0 <= j < len(units)]
            if any(parallel(h, u) for u in nbr):
                continue
            rest = units[:k] + units[k + 1:]
            hit = [j for j, u in enumerate(rest) if parallel(h, u)]
            if hit:
                units = rest[:hit[0] + 1] + [[h]] + rest[hit[0] + 1:]
        branch = {r for r in branch
                  if not any(parallel(r, u) for u in units if u != [r])}
        for h in [r for r in seq if r in branch]:
            k = units.index([h])
            nbr = [units[j] for j in (k - 1, k + 1)
                   if 0 <= j < len(units) and units[j][0] not in branch]
            if any(shares(h, u) for u in nbr):
                continue
            rest = units[:k] + units[k + 1:]
            gaps = [j + 1 for j in range(len(rest) - 1)
                    if shares(h, rest[j]) and shares(h, rest[j + 1])]
            # A shunt (one signal net) with no such pair goes just before
            # the first unit on its net, where its node begins.
            if not gaps and len(sig[h]) == 1:
                gaps = [j for j, u in enumerate(rest) if shares(h, u)]
            if gaps:
                units = rest[:gaps[0]] + [[h]] + rest[gaps[0]:]
        return [r for u in units for r in u]

    # A subchain larger than _CHAIN_SUB_MIN gets a spine of its own when
    # _CHAIN_RECURSE is on.  Off: on LM324.sub, the one deck where it
    # fires, it took backward pairs 8 -> 11 for 56 -> 54 crossings, and
    # flow ranks above crossings.
    _CHAIN_SUB_MIN = 6
    _CHAIN_RECURSE = False

    def _chain_cell_rel(self, key):
        """Takes a cell key and returns its (relpos, bbox): a recursive
        subchain layout first, else the cached P2DL cell, else None."""
        sub = (getattr(self, '_chain_sub_layout', None) or {}).get(key)
        if sub is not None:
            return sub
        return (getattr(self, '_sp_block_layout', None) or {}).get(key)

    def _chain_sub_layout_of(self, comp, by_ref, box, depth):
        """Takes a large subchain's refs and returns (relpos, bbox) for it
        laid out as a chain of its own -- its own spine with its own hangers
        -- or None when it has no spine. Chain state is saved and restored
        around the nested call, and every member's position is put back, so
        the caller sees only the returned shape."""
        names = ('_chain_by_ref', '_chain_placed', '_chain_cursor',
                 '_chain_block_of', '_chain_spine_cell', '_chain_cell_y',
                 '_chain_span', '_chain_g', '_chain_elsewhere')
        saved = {n: getattr(self, n, None) for n in names}
        pos = {r: (by_ref[r].ox_px, by_ref[r].oy_px) for r in comp}
        rel = None
        try:
            if self._chain_relayout([by_ref[r] for r in comp],
                                    _depth=depth + 1):
                x0 = min(by_ref[r].ox_px + box[r][0] for r in comp)
                y0 = min(by_ref[r].oy_px + box[r][1] for r in comp)
                x1 = max(by_ref[r].ox_px + box[r][2] for r in comp)
                y1 = max(by_ref[r].oy_px + box[r][3] for r in comp)
                rel = ({r: (by_ref[r].ox_px - x0, by_ref[r].oy_px - y0)
                        for r in comp}, (0.0, 0.0, x1 - x0, y1 - y0))
        finally:
            for n, v in saved.items():
                setattr(self, n, v)
            for r, (x, y) in pos.items():
                by_ref[r].ox_px, by_ref[r].oy_px = x, y
        # A subchain with no useful spine of its own comes back as one
        # long row (OPAx197: 164 parts, 24,700 px).  Wrap it instead.
        if rel is None:
            return self._chain_wrap_rel(comp, box)
        w, h = rel[1][2], rel[1][3]
        if w > 4.0 * h:
            rel = self._chain_wrap_rel(comp, box) or rel
        return rel

    def _chain_wrap_rel(self, comp, box):
        """Takes a subchain's refs, in flow order, and the at-origin boxes,
        and returns (relpos, bbox) with the refs laid left to right in lines
        no wider than about twice the square root of their total area."""
        gap = float(self._CHAIN_GAP)
        # Units: a cached cell moves whole, at its own relative positions.
        units, seen = [], set()
        for r in comp:
            if r in seen or r not in box:
                continue
            key = (getattr(self, '_chain_block_of', None) or {}).get(r)
            rel = self._chain_cell_rel(key) if key else None
            if rel:
                members = [m for m in rel[0] if m in box]
                seen |= set(key)
                bb = rel[1]
                units.append(({m: rel[0][m] for m in members},
                              (0.0, 0.0, bb[2] - bb[0], bb[3] - bb[1])))
            else:
                seen.add(r)
                b = box[r]
                units.append(({r: (-b[0], -b[1])},
                              (0.0, 0.0, b[2] - b[0], b[3] - b[1])))
        if not units:
            return None
        area = sum((b[2] + gap) * (b[3] + gap) for _p, b in units)
        limit = max(max(b[2] for _p, b in units), 2.0 * area ** 0.5)
        relpos, x, y, line_h, x_max = {}, 0.0, 0.0, 0.0, 0.0
        for pos, b in units:
            w, h = b[2], b[3]
            if x > 0 and x + w > limit:
                y += line_h + gap
                x, line_h = 0.0, 0.0
            for m, (px, py) in pos.items():
                relpos[m] = (x + px, y + py)
            x += w + gap
            line_h = max(line_h, h)
            x_max = max(x_max, x - gap)
        return relpos, (0.0, 0.0, x_max, y + line_h)

    def _chain_relayout(self, instances, _depth=0):
        """In : the placed instances, already oriented and measured.
        Proc: lay the deck out CHAIN-FIRST — the longest input-to-output
              chain runs left to right down the middle and every other
              part hangs off it in a row above or below, ordered by where
              it joins.  Topology decides the order; coordinates are
              assigned last, from the measured boxes.
        Out : nothing; writes ox_px / oy_px.
        Not yet done: contour packing of a fork, and a subchain gets a row
        rather than a SPAN over the spine, which grading against three
        hand layouts says is the right model."""
        g = self._chain_graph(instances)
        spine, comps = g['spine'], g['comps']
        if not spine:
            return False
        by_ref = {i.comp['ref']: i for i in instances}
        self._chain_by_ref = by_ref
        self._chain_placed = set(spine)
        self._chain_cursor = {}
        # Respect the P2DL placement: a cached block is one node whose members
        # keep their relative positions; the chain moves the whole block.
        self._chain_block_of = {}
        for k in _stable_blocks(getattr(self, '_sp_block_layout', None)):
            if len(k) < 2:
                continue
            for r in k:
                self._chain_block_of[r] = k
        # Separate by the drawn box, T-symbols and labels included, not the body
        # box, which reserves less than the part occupies.
        box = {}
        for r, inst in by_ref.items():
            bb = None
            try:
                bb = self._instance_bbox_with_ts(inst)
            except Exception:
                bb = None
            if bb is None or len(bb) != 4:
                try:
                    bb = self._instance_bbox_at_origin(inst)
                except Exception:
                    bb = None
            box[r] = bb if (bb and len(bb) == 4) else (0.0, 0.0, 40.0, 40.0)
        # The gap has to cover what the at-origin box leaves out -- the
        # T-symbols and labels rebuilt after placement.  60 px left
        # LM324.sub with 2 composite overlaps; measured, 120 clears it.
        gap = float(self._CHAIN_GAP)
        vgap = float(60.0)
        idx = {r: k for k, r in enumerate(spine)}
        # A P2DL CELL ON THE SPINE IS ONE SPINE SLOT.  The spine lists
        # refs, so a cell with one member on it and the rest in a
        # subchain was laid out twice and torn apart: LM324.sub's six-
        # transistor cell ended up in two pieces 2900 px apart.  The
        # whole cell takes the slot of its first spine member, every
        # member answers to that slot's index, and subchains no longer
        # carry its members.
        self._chain_spine_cell = {}
        for r in spine:
            key = self._chain_block_of.get(r)
            if key and key not in self._chain_spine_cell:
                self._chain_spine_cell[key] = r
        _cell_refs = set()
        for key, head in self._chain_spine_cell.items():
            for m in key:
                if m in by_ref:
                    _cell_refs.add(m)
                    idx.setdefault(m, idx[head])
        self._chain_placed |= _cell_refs
        comps = [c for c in ([r for r in comp if r not in _cell_refs]
                             for comp in comps) if c]
        # ONLY THE SPINE'S OWN BOX IS STACKED AROUND IT.  A subchain from
        # another independent box is moved away by the box packer, but it
        # still pushed later subchains outward first: LM324.sub's C18 sat
        # above I6 and the V53 cell, far over Q9, after both had left.
        box_of = {r: k for k, seg in enumerate(
            getattr(self, '_signal_segments', None) or []) for r in seg}
        home = {box_of[r] for r in spine if r in box_of}
        self._chain_elsewhere = []
        if _depth == 0 and home:
            mine = [c for c in comps
                    if any(box_of.get(r) in home or r not in box_of
                           for r in c)]
            self._chain_elsewhere = [c for c in comps if c not in mine]
            comps = mine
        # A LARGE SUBCHAIN IS A CHAIN OF ITS OWN.  Laid out as one row it
        # runs the width of the page in flow order but with no structure:
        # LM324.sub hung 31 parts off a 3-part spine.  Give it its own
        # spine and hangers, then place the result as one cell.
        if _depth == 0:
            self._chain_sub_layout = {}
        if _depth < 3 and self._CHAIN_RECURSE:
            for comp in comps:
                if len(comp) <= self._CHAIN_SUB_MIN:
                    continue
                rel = self._chain_sub_layout_of(comp, by_ref, box, _depth)
                if rel is None:
                    continue
                key = frozenset(comp)
                self._chain_sub_layout[key] = rel
                for r in comp:
                    self._chain_block_of[r] = key
        # A LONG SUBCHAIN WRAPS.  Laid as one row, OPAx197's 164-part
        # subchain ran 24,700 px; wrapped, it is a block of a few rows.
        for comp in comps:
            if (len(comp) <= self._CHAIN_SUB_MIN
                    or frozenset(comp) in self._chain_sub_layout):
                continue
            row_w = self._chain_extent(comp, box)[0]
            rel = self._chain_wrap_rel(comp, box)
            if rel is None or rel[1][2] > 0.5 * row_w:
                continue
            key = frozenset(rel[0])
            self._chain_sub_layout[key] = rel
            for r in rel[0]:
                self._chain_block_of[r] = key
        # which side does each subchain take, and where does it start
        north, south, span = [], [], {}
        for comp in comps:
            merges = sorted({s for r in comp for s in g['und'][r]
                             if s in idx}
                            | {s for r in comp
                               for n in set(g['nets_of'].get(r, ()))
                               for s in g['netmates'].get(n, ())
                               if s in idx},
                            key=lambda m: idx[m])
            at = idx[merges[0]] if merges else len(spine)
            # Attach a subchain at the spine part that drives the shared net,
            # not the leftmost one on it, so Osc2's RL sits over Q1's collector.
            anchored = False
            if len(merges) > 1:
                own_nets = {n for r in comp
                            for n in set(g['nets_of'].get(r, ()))}
                gdrv = g.get('drv') or {}
                grcv = g.get('rcv') or {}
                # A true driver only: a passive entered as both driver and
                # receiver of a net does not drive it.  A reversed net anchors
                # upstream, not at the driver.
                _rev = {n for u, v in (g.get('back_edges') or ())
                        for n in (set(g['nets_of'].get(u, ()))
                                  & set(g['nets_of'].get(v, ())))}
                _srails = self._south_rails()
                _hit = {str(n).lower() for r in comp
                        for n in set(g['nets_of'].get(r, ()))
                        if n in g['rails']}
                _to_ground = bool(_hit) and _hit <= _srails
                if (_rev & own_nets) and _to_ground:
                    at = idx[merges[0]]
                    anchored = True
                    span.pop(tuple(comp), None)
                drivers = [] if ((_rev & own_nets) and _to_ground) else [
                           m for m in merges
                           if any(m in (gdrv.get(n) or ())
                                  and m not in (grcv.get(n) or ())
                                  for n in own_nets)]
                if drivers:
                    at = idx[drivers[0]]
                    # AND DROP THE SPAN.  The span rule centres a
                    # subchain over the whole stretch it touches, which
                    # is right for one that PARALLELS a stretch and
                    # wrong for a rail-tied leg serving a single pin:
                    # Amp1's RL touches Q1 and C2 through net c, so
                    # centring put it midway between them instead of
                    # over the collector it feeds.  Having identified
                    # the pin, place it there.
                    anchored = True
            # A SUBCHAIN PARALLELS A STRETCH, NOT A POINT.  Grading 33
            # multi-merge subchains against three hand layouts found no
            # merge POINT rule better than chance, and the reason was in
            # the data: a subchain touching six spine parts does not
            # attach at one of them.  Record the SPAN so the row layout
            # can centre a narrow subchain over the stretch it serves
            # instead of hanging it off the leftmost part.
            if len(merges) > 1 and not anchored:
                span[tuple(comp)] = (idx[merges[0]], idx[merges[-1]])
            # Which side: a subchain goes north unless all its rail nets are
            # south rails.  Balancing the sides is wrong; in the hand layout 11
            # of 12 subchains share a side.
            side = north
            rails_hit = {str(n).lower() for r in comp
                         for n in set(g['nets_of'].get(r, ()))
                         if n in g['rails']}
            if rails_hit and rails_hit <= self._south_rails():
                side = south
            side.append((at, comp))
        self._chain_span = span
        # EXPAND THE SPINE WHERE THINGS ATTACH (step 4).  Six of
        # LM324.lib's twelve subchains join at FB, and three separate
        # attempts to seat them side by side FAILED for one reason: the
        # spine gave them no room, so each one shoved into the next
        # subchain's wires.  So make room first -- a spine part is
        # allotted the width of whatever attaches there, per side, and
        # the parts after it move right.  Only THEN is a sideways cursor
        # meaningful; without the expansion it is the measured-and-
        # refused idea from an earlier revision.
        need = defaultdict(float)
        # NOTHING TO RESERVE WHEN THE ROWS ARE LEFT-JUSTIFIED.  The
        # expansion widens a spine slot to fit the subchains that merge
        # there -- but with one-chain-per-row those subchains sit at the
        # left margin, not at their merge point, so the reserved gap is
        # empty.  On LM324.lib it stretched the spine to Q1 130, GA 964,
        # R2 2184, RO1 2922 while every subchain row ended by x=320, so
        # the spine's tail read as marooned far to the right.
        _one_row = getattr(self, '_chain_one_per_row', False)
        # ON WITH THE STACK MODEL.  The expansion and the stack are one
        # idea in two halves: the stack tries sideways before outward,
        # and without the extra room there is nowhere sideways to go --
        # Osc2's C3 and RB2 both want x=348.8 with only 210 px between
        # C2 and Q1, so they stacked anyway.  Together they take Osc1
        # and Osc2 from 838 px tall to 610 at no cost in crossings.
        if not _one_row:
            for items in (north, south):
                # Allot the widest subchain's width, not the sum: the sum
                # stretched LM324.lib's spine by thousands of px.
                wide, n_at = defaultdict(float), defaultdict(int)
                for at, comp in items:
                    wide[at] = max(wide[at],
                                   self._chain_extent(comp, box)[0] + gap)
                    n_at[at] += 1
                for at, w in wide.items():
                    if n_at[at] > 1:
                        need[at] = max(need[at], w)
        x, xof = self._CHAIN_MARGIN, {}
        self._chain_cell_y = {}
        h_spine = 0.0
        for k, r in enumerate(spine):
            key = self._chain_block_of.get(r)
            rel = self._chain_cell_rel(key) if key else None
            if rel and self._chain_spine_cell.get(key) == r:
                relpos, bb = rel[0], rel[1]
                for m in key:
                    if m in relpos and m in by_ref:
                        xof[m] = x + relpos[m][0]
                        self._chain_cell_y[m] = relpos[m][1]
                x += max(bb[2] - bb[0], need.get(k, 0.0)) + gap
                h_spine = max(h_spine, bb[3] - bb[1])
                continue
            if r in xof:
                continue              # placed with its cell above
            b = box[r]
            xof[r] = x - b[0]
            x += max((b[2] - b[0]), need.get(k, 0.0)) + gap
            h_spine = max(h_spine, b[3] - b[1])
        self._chain_g = g
        # Per-subchain stacking: natural place beside the spine, pushed
        # out only on a real collision.
        return self._chain_stack_layout(north, south, spine, box, xof,
                                        by_ref, gap, h_spine, vgap)

    def _chain_pin_align_dx(self, comp, at, spine, xof, box, g):
        """In : a subchain, the spine part it joins, and the graph.
        Proc: find the net they share, then the pin each uses for it, and
              return how far the subchain's left edge must move so its
              own pin sits directly under or over the spine part's pin.
        Out : a delta to add to want_x, or None when there is no single
              shared pin to align to.
        want_x is an ORIGIN, not a pin: xof[] is the spine part's left
        edge, so a leg hung there lines up with the BODY, not the
        terminal it serves — Osc1's C1 landed under Q1 and its net-C line
        ran through the transistor.  Only single-part subchains align."""
        if len(comp) != 1:
            return None
        ref = next(iter(comp))
        owner = spine[min(at, len(spine) - 1)]
        inst = self._chain_by_ref.get(ref)
        own = self._chain_by_ref.get(owner)
        if inst is None or own is None:
            return None
        nets_a = {str(n).lower() for _p, n in (inst._pin_net_pairs or [])}
        shared = [n for _p, n in (own._pin_net_pairs or [])
                  if str(n).lower() in nets_a
                  and str(n).lower() not in g['tnets']]
        if len(shared) != 1:
            return None
        nl = str(shared[0]).lower()
        try:
            o_pin = next(p for p, n in own._pin_net_pairs
                         if str(n).lower() == nl)
            i_pin = next(p for p, n in inst._pin_net_pairs
                         if str(n).lower() == nl)
            o_x = sp_pin_x(own, o_pin)
            i_x = sp_pin_x(inst, i_pin)
        except Exception:
            return None
        if o_x is None or i_x is None:
            return None
        # ALIGN ONLY TO A TOP-OR-BOTTOM PIN.  A leg belongs over the pin
        # it serves when that pin faces up or down -- RL over Q1's
        # collector.  A pin on a SIDE face is a signal terminal, and
        # aligning to it stacks the leg on the body's own column: Q1's
        # base and collector are only ~28 px apart in x, so aligning
        # both RB1 and RL made them collide and cost Amp1 a whole level
        # (214 -> 424 px tall).  The user's own note: the bias resistors
        # "would be too close to Q1's emitter and collector circuit if
        # placed directly above the base pin".  Side pins keep the
        # body-aligned x from _chain_want_x.
        try:
            op = _pin_canvas_pos(own, o_pin)
            ob_ = box.get(owner)
            if op is None or not ob_:
                return None
            cx = own.ox_px + (ob_[0] + ob_[2]) / 2.0
            cy = own.oy_px + (ob_[1] + ob_[3]) / 2.0
            if abs(float(op[0]) - cx) >= abs(float(op[1]) - cy):
                return None          # side pin: not a stacking pin
        except Exception:
            return None
        # Where the spine part's pin will BE once the spine is laid out,
        # versus where this part's pin sits relative to its own box.
        o_abs = xof.get(owner, 0.0) + (o_x - own.ox_px)
        return o_abs - (i_x - inst.ox_px) + box[ref][0]

    def _chain_pin_align_y(self, comp, at, spine):
        """In : a subchain and the spine part it joins.
        Proc: the y, in the owner's own box frame, of the pin they share.
        Out : float, or None when there is no single shared pin.

        Only the SIDE of the owner matters here, so the owner's live
        oy_px is a fine frame -- the comparison is against the owner's
        own box mid-line, and both move together.
        """
        if len(comp) != 1:
            return None
        owner = spine[min(at, len(spine) - 1)]
        inst = self._chain_by_ref.get(next(iter(comp)))
        own = self._chain_by_ref.get(owner)
        if inst is None or own is None:
            return None
        g = getattr(self, '_chain_g', None) or {'tnets': set()}
        nets_a = {str(n).lower() for _p, n in (inst._pin_net_pairs or [])}
        shared = [n for _p, n in (own._pin_net_pairs or [])
                  if str(n).lower() in nets_a
                  and str(n).lower() not in g['tnets']]
        if len(shared) != 1:
            return None
        nl = str(shared[0]).lower()
        try:
            o_pin = next(p for p, n in own._pin_net_pairs
                         if str(n).lower() == nl)
            p = _pin_canvas_pos(own, o_pin)
            return None if p is None else float(p[1]) - own.oy_px
        except Exception:
            return None

    def _chain_want_x(self, at, comp, box, xof, spine, gap):
        """In : a subchain and the spine's x table.
        Proc: its preferred left x -- the merge point's x, shifted right
              to centre it when _chain_span says it parallels a stretch.
        Out : (want_x, width_including_gap).

        Factored out of _chain_rows so the row model and the stack model
        cannot drift apart on where a subchain WANTS to sit; they differ
        only in how they resolve conflicts.
        """
        base = xof.get(spine[min(at, len(spine) - 1)], self._CHAIN_MARGIN)
        want = max(base, {}.get(at, base))
        w = self._chain_extent(comp, box)[0] + gap
        sp = getattr(self, '_chain_span', {}).get(tuple(comp))
        if sp:
            x_end = xof.get(spine[min(sp[1], len(spine) - 1)], want)
            if x_end - want > w:
                want += (x_end - want - w) / 2.0
        return want, w

    # Prior art for the chain placer: relative-placement floorplan
    # representations such as sequence pairs (Murata et al.) fix an order first
    # and coordinates last, as the chain does.

    def _chain_stack(self, items, box, xof, spine, gap, vgap, side='n'):
        """Place one side's subchains: each takes its natural spot beside the
        spine and moves further out only when it truly collides with one
        already placed.
        """
        placed, out, depth = [], [], 0.0
        prepared = []
        # ONE SUBCHAIN PER ROW (toolbar).  Normally a
        # subchain is pushed outward only when it really collides, so
        # several share a level.  With this on every subchain gets a
        # level to itself, which makes each one separately visible and
        # draggable -- for inspecting what the chain builder actually
        # produced, and for hand-placing subchains on a deck like
        # LM324.sub where 52 of 60 crossings are between boxes.
        one_per_row = getattr(self, '_chain_one_per_row', False)
        g = getattr(self, '_chain_g', None) or {'tnets': set()}
        # ONE CACHED CELL IS ONE ITEM, HOWEVER MANY SUBCHAINS TOUCH IT.
        # _chain_extent measures a cell member by the CELL's bbox, which
        # is right for reserving space and wrong once several subchains
        # each hold a member: every one of them then reserves the whole
        # cell again.  LM324.lib's diff-pair cell has six members in six
        # subchains, so its 566 px height was booked four levels deep --
        # 2055 px of north depth for one cell, with C2 shoved to the far
        # end of it.  _chain_put places the whole block when it meets the
        # first member, so the rest have nothing left to do.
        _blk = getattr(self, '_chain_block_of', {}) or {}
        _seen_blocks = set()
        for at, comp in items:
            keys = {_blk.get(r) for r in comp}
            key = keys.pop() if len(keys) == 1 else None
            if key is not None:
                if key in _seen_blocks:
                    continue
                _seen_blocks.add(key)
            want, w = self._chain_want_x(at, comp, box, xof, spine, gap)
            # Prefer the pin-aligned x over the body-aligned one.
            try:
                dx = self._chain_pin_align_dx(comp, at, spine, xof, box, g)
            except Exception:
                dx = None
            if dx is not None:
                want = dx
                # ...UNLESS THE LEG IS ON THE FAR SIDE OF THE PIN.  A
                # pin sits on one face of its owner, so a leg hung on
                # the OPPOSITE side and aligned to it draws its flight
                # line straight through the owner's body -- Osc1's C1
                # aligned to Q1's collector from the south and the net-C
                # line ran down through the transistor and its own GND
                # T-symbol.  Clear the owner's box horizontally instead,
                # to whichever side is nearer, which is the user's "to
                # the right of Q1 rather than below it".
                owner = spine[min(at, len(spine) - 1)]
                ob = box.get(owner)
                oi = self._chain_by_ref.get(owner)
                if ob and oi is not None:
                    mid = (ob[1] + ob[3]) / 2.0
                    py = self._chain_pin_align_y(comp, at, spine)
                    far = (py is not None
                           and ((side == 's' and py < mid)
                                or (side == 'n' and py > mid)))
                    if far:
                        o_l = xof.get(owner, 0.0)
                        o_r = o_l + (ob[2] - ob[0])
                        # RIGHT BY PREFERENCE.  Clearing to whichever
                        # side was nearer sent Osc1's C1 to the LEFT of
                        # Q1 and its net-C line then ran 426 px down the
                        # page.  Signal flows left to right, so a leg
                        # that has to step aside steps DOWNSTREAM; left
                        # is only for when the owner starts the spine
                        # and there is nothing to its right to align to.
                        want = o_r + gap
            # How far right this subchain may slide before it stops
            # belonging to its merge point: the next spine part's x.
            lim = xof.get(spine[at + 1]) if at + 1 < len(spine) else None
            prepared.append((want, at, comp, w,
                             self._chain_extent(comp, box)[1], lim))
        for want, _at, comp, w, h, lim in sorted(prepared,
                                                 key=lambda t: (t[0], t[2])):
            # SIDEWAYS BEFORE OUTWARD.  want is xof[spine[at]], so every
            # subchain joining at the SAME spine part is handed the same
            # x and can then only resolve vertically -- Osc2's C3 and
            # RB2 both landed on 348.8, which cost a whole level, and C1
            # a third.  The user draws them side by side.  Sliding right
            # is bounded by the NEXT spine part's x so a subchain never
            # drifts away from the merge point it belongs to; when the
            # slide does not fit, fall back to pushing outward exactly
            # as before.
            x0, x1, d = want, want + w, vgap
            if lim is not None:
                shifted = x0
                for px0, px1, pd0, pd1 in sorted(placed):
                    if (shifted < px1 and px0 < shifted + w
                            and d < pd1 and pd0 < d + h):
                        shifted = px1
                if shifted != x0 and shifted + w <= lim:
                    x0, x1, want = shifted, shifted + w, shifted
            moved = True
            while moved:
                moved = False
                for px0, px1, pd0, pd1 in placed:
                    if (x0 < px1 and px0 < x1
                            and d < pd1 and pd0 < d + h):
                        d = pd1 + vgap
                        moved = True
            if one_per_row:
                # LEFT-JUSTIFIED.  Giving each subchain its
                # own level still left it at its merge x, so the rows
                # were far apart horizontally and the page read as mostly
                # empty.  In this mode a row starts at the margin.
                d = depth + vgap
                x0 = want = self._CHAIN_MARGIN
                x1 = x0 + w
            placed.append((x0, x1, d, d + h))
            out.append((comp, want, d, h))
            depth = max(depth, d + h)
        return out, depth

    def _chain_put_elsewhere(self, box, y, gap):
        """Takes the row y below the laid-out spine box and lays out the
        subchains that belong to other independent boxes: one row per box,
        each subchain left to right after the last. Returns the next free y.
        The box packer then moves each box as a unit."""
        box_of = {r: k for k, seg in enumerate(
            getattr(self, '_signal_segments', None) or []) for r in seg}
        rows = {}
        for comp in getattr(self, '_chain_elsewhere', None) or []:
            k = min((box_of[r] for r in comp if r in box_of), default=-1)
            rows.setdefault(k, []).append(comp)
        # A box with many subchains wraps, so no row runs wider than about
        # twice the square root of the box's own area (OPAx197's 96-part
        # box was one 25,000 px row).
        for k in sorted(rows):
            ext = [(c, self._chain_extent(c, box)) for c in rows[k]]
            area = sum((w + gap) * (h + gap) for _c, (w, h) in ext)
            limit = max(max(w for _c, (w, _h) in ext), 2.0 * area ** 0.5)
            if sum(len(c) for c, _e in ext) <= self._BOX_COMPACT_MAX:
                limit = float('inf')       # a small box stays one row
            line, used = [], 0.0
            for c, (w, h) in ext + [(None, (0.0, 0.0))]:
                if c is None or (line and used + w > limit):
                    hh = max(hh_ for _c, (_w, hh_) in line)
                    for comp, _e in line:
                        self._chain_put(comp, box, y, hh)
                    y += hh + gap
                    line, used = [], 0.0
                if c is not None:
                    line.append((c, (w, h)))
                    used += w + gap
        return y

    def _chain_stack_layout(self, north, south, spine, box, xof, by_ref,
                            gap, h_spine, vgap=60.0):
        """In : both sides' subchains, the spine and the box table.
        Proc: stack each side with _chain_stack, put the spine between
              them, then convert each subchain's distance-from-spine into
              an absolute y.
        Out : True.  Same contract as _chain_rows_layout.
        The two-sided shape is Sugiyama's layer assignment turned on its
        side, and the y is a longest-path assignment over the "is pushed
        outward by" relation — the constraint-graph compaction step every
        floorplanning representation shares."""
        north_p, north_d = self._chain_stack(north, box, xof, spine,
                                             gap, vgap, 'n')
        south_p, south_d = self._chain_stack(south, box, xof, spine,
                                             gap, vgap, 's')
        spine_y = self._CHAIN_MARGIN + north_d
        for comp, want, d, h in north_p:
            self._chain_put(comp, box, spine_y - d - h, h, want)
        self._chain_put_spine(spine_y, box, xof, by_ref)
        base_s = spine_y + h_spine
        for comp, want, d, h in south_p:
            self._chain_put(comp, box, base_s + d, h, want)
        y = base_s + south_d + vgap
        y = self._chain_put_elsewhere(box, y, vgap)
        for r, inst in by_ref.items():
            if r not in self._chain_placed:
                b = box[r]
                inst.ox_px = self._CHAIN_MARGIN - b[0]
                inst.oy_px = y - b[1]
                y += (b[3] - b[1]) + gap
        return True

    _BOX_COMPACT_GAP = 40.0   # room left for a flight line and its label
    _BOX_COMPACT_MAX = 10     # larger boxes keep the chain layout's gaps

    def _compact_box(self, cl, rel):
        """Takes one small independent box and an at-origin extent function,
        and closes the empty space inside it without changing its order:
        first upward (each part rises until it meets a part above that shares
        its x span), then leftward the same way. Rows stay rows and left stays
        left, but a part the whole-deck layout left far from its partners --
        LM324.sub's I1 and Q16 -- comes back beside them."""
        gap = float(self._BOX_COMPACT_GAP)
        for axis in (1, 0):
            o = 1 - axis
            placed = []
            for inst in sorted(cl, key=lambda i: (
                    (i.oy_px, i.ox_px) if axis else (i.ox_px, i.oy_px))
                    + (i.comp['ref'],)):
                b = rel(inst)
                org = (inst.ox_px, inst.oy_px)
                lo_o = org[o] + b[o]
                hi_o = org[o] + b[o + 2]
                want = 0.0
                for p_lo_o, p_hi_o, p_hi in placed:
                    if lo_o < p_hi_o and p_lo_o < hi_o:
                        want = max(want, p_hi + gap)
                if axis:
                    inst.oy_px = want - b[axis]
                else:
                    inst.ox_px = want - b[axis]
                placed.append((lo_o, hi_o, want + (b[axis + 2] - b[axis])))

    def _chain_relayout_boxes(self, boxes, relayout=True, rel=None,
                              keep_origin=False):
        """In : the independent boxes (instance lists sharing no wire),
        `relayout`, the at-origin extent function `rel` and keep_origin.
        Out: the boxes packed so none overlaps, each moving rigidly.
        With `relayout` each box is laid out as its own chain first;
        without it they keep the whole-deck chain layout, which measured
        far better (OPAx197 239 crossings against 448) because a lone box
        has no input to seed its spine.  The largest goes top left, the
        rest follow in shelf rows no wider than it or sqrt(total area).
        rel=None takes the chain layout's measure and compacts the small
        boxes first; a repack brings its own and keeps its origin."""
        GAP = float(60.0)
        laid = []
        biggest = max((len(cl) for cl in boxes), default=0)
        compact = rel is None

        def _rel(inst):
            b = self._instance_bbox_with_ts(inst)
            if not b or len(b) != 4:
                b = self._instance_bbox_at_origin(inst)
            return b

        if rel is None:
            rel = _rel
        for cl in boxes:
            if not cl:
                continue
            if relayout and len(cl) > 1:
                self._chain_relayout(cl)
            if (compact and 1 < len(cl) < biggest
                    and len(cl) <= self._BOX_COMPACT_MAX):
                self._compact_box(cl, rel)
            ext = []
            for inst in cl:
                b = rel(inst)
                ext.append((inst.ox_px + b[0], inst.oy_px + b[1],
                            inst.ox_px + b[2], inst.oy_px + b[3]))
            x0 = min(e[0] for e in ext); y0 = min(e[1] for e in ext)
            x1 = max(e[2] for e in ext); y1 = max(e[3] for e in ext)
            laid.append((cl, x0, y0, x1 - x0, y1 - y0))
        if not laid:
            return
        laid.sort(key=lambda t: (-(t[3] * t[4]),
                                 min(i.comp['ref'] for i in t[0])))
        big = laid[0]
        area = sum(w * h for _c, _x, _y, w, h in laid)
        limit = max(big[3], area ** 0.5)

        def _move(item, x, y):
            cl, bx, by, _w, _h = item
            for inst in cl:
                inst.ox_px += x - bx
                inst.oy_px += y - by

        ox, oy = ((min(t[1] for t in laid), min(t[2] for t in laid))
                  if keep_origin else (0.0, 0.0))
        _move(big, ox, oy)
        y = oy + big[4] + GAP
        # A first pack keeps the chain layout's left-to-right order; a
        # repack keeps the rows it made, so it reads them row by row.
        # Large boxes stack under the largest; the small ones (the parts
        # OPAx197 hangs off MID in fours) share the band at the bottom.
        def _small(t):
            return len(t[0]) <= self._BOX_COMPACT_MAX

        def _order(t):
            key = (t[1], t[2]) if not keep_origin else (round(t[2]), t[1])
            return (_small(t),) + key + (min(i.comp['ref'] for i in t[0]),)

        rest = sorted(laid[1:], key=_order)
        x, row_h = ox, 0.0
        prev_small = None
        for item in rest:
            w, h = item[3], item[4]
            new_kind = prev_small is not None and _small(item) != prev_small
            if x > ox and (new_kind or x - ox + w > limit):
                y += row_h + GAP
                x, row_h = ox, 0.0
            prev_small = _small(item)
            _move(item, x, y)
            x += w + GAP
            row_h = max(row_h, h)

    def _box_partition(self, instances):
        """Takes the placed instances and returns the independent boxes as
        lists of instances: the signal segments, plus a one-part box for any
        instance no segment names."""
        ibr = {i.comp['ref']: i for i in instances}
        segs = [[ibr[r] for r in seg if r in ibr]
                for seg in (getattr(self, '_signal_segments', None) or [])]
        seen = {r for seg in (getattr(self, '_signal_segments', None) or [])
                for r in seg}
        segs = [seg for seg in segs if seg]
        return segs + [[i] for i in instances if i.comp['ref'] not in seen]

    def _repack_boxes(self, instances):
        """Takes the placed instances after a pass that moved parts inside
        their boxes, and re-packs the boxes rigidly with the final measure
        (_placement_extent), so a box that grew pushes the band below it
        down instead of running into it."""

        def _ext(inst):
            return self._placement_extent(inst)

        self._chain_relayout_boxes(self._box_partition(instances),
                                   relayout=False, rel=_ext,
                                   keep_origin=True)

    def _chain_put_spine(self, top, box, xof, by_ref):
        """Takes the spine's top y, the boxes and the spine x table, and places
        every spine part with its box top at `top`; a spine cell's members keep
        the cell's relative positions, with the cell's top at `top`."""
        cell_y = getattr(self, '_chain_cell_y', None) or {}
        for r, x in xof.items():
            inst = by_ref.get(r)
            if inst is None:
                continue
            inst.ox_px = x
            inst.oy_px = (top + cell_y[r]) if r in cell_y \
                else top - box[r][1]

    def _chain_extent(self, refs, box):
        """In : a subchain's refs and the at-origin boxes.
        Proc: measure the width it will occupy laid left to right and
              the height of its tallest item, counting a cached P2DL
              CELL by the cell's own bbox rather than by its members.
        Out : (width, height).

        A vertical cell is taller than any single member, so measuring
        member by member under-reports the row height and the next row
        lands on top of it -- which is exactly what LM324.lib did.
        """
        gap = float(self._CHAIN_GAP)
        seen, w, h = set(), 0.0, 0.0
        for r in refs:
            if r in seen:
                continue
            key = getattr(self, '_chain_block_of', {}).get(r)
            if key:
                rel = self._chain_cell_rel(key)
                seen |= set(key)
                if rel:
                    bb = rel[1]
                    w += (bb[2] - bb[0]) + gap
                    h = max(h, bb[3] - bb[1])
                    continue
            seen.add(r)
            b = box.get(r) or (0.0, 0.0, 40.0, 40.0)
            w += (b[2] - b[0]) + gap
            h = max(h, b[3] - b[1])
        return (max(0.0, w - gap), h)

    def _chain_put_block(self, key, box, x, y, done):
        """In : a cached block's ref set, the cursor, and the row's y.
        Proc: place every member at the RELATIVE position the P2DL cell
              chose, with the cell's own top-left at (x, y).
        Out : the cursor advanced past the cell's width.

        The cell moves as a unit, which is what makes its shared T's and
        its internal geometry still true after the chain layout.
        """
        rel = self._chain_cell_rel(key)
        members = [r for r in sorted(key) if r in self._chain_by_ref]
        if not rel or not members:
            return x
        # The cached entry is (relpos, bbox), relpos keyed by REF and
        # holding each member's ORIGIN inside a block whose bbox starts
        # at (0, 0).  So the member's absolute origin is simply the
        # cell's corner plus its relative one -- subtracting the body
        # offset as well would shift the whole cell left by b[0] and is
        # what put LP2951's labels outside the page.
        relpos, bbox = rel[0], rel[1]
        for r in members:
            if r not in relpos:
                continue
            inst = self._chain_by_ref.get(r)
            if inst is None:
                continue
            inst.ox_px = x + relpos[r][0]
            inst.oy_px = y + relpos[r][1]
            self._chain_placed.add(r)
            done.add(r)
        return x + (bbox[2] - bbox[0])

    def _chain_drop_to_partner(self, comp, box, y, hh):
        """Takes a subchain just laid in the row [y, y + hh] and moves each
        loose part (not in a cell) down so its pin meets, at the same
        height, the pin of a cell member of this subchain it shares a net
        with. Parts stay inside the row, and x does not change, so nothing
        in the row can collide. LM324.sub's I3 sat at the row top, far
        above the Q15 base it feeds, because the diff-pair cell made the
        row tall."""
        block_of = getattr(self, '_chain_block_of', {}) or {}
        by_ref = self._chain_by_ref
        cells = [r for r in comp if block_of.get(r) and r in by_ref]
        if not cells:
            return
        g = getattr(self, '_chain_g', None) or {}
        skip = set(g.get('rails') or ()) | set(g.get('tnets') or ())
        for r in comp:
            inst = by_ref.get(r)
            if inst is None or block_of.get(r):
                continue
            target = None
            mx = inst.ox_px + (box[r][0] + box[r][2]) / 2.0
            for pn, nn in (inst._pin_net_pairs or []):
                if str(nn).lower() in skip:
                    continue            # a T joins it, not a wire
                for c in cells:
                    ci = by_ref[c]
                    cb = box.get(c) or ci.sym_body_rel
                    ccx = ci.ox_px + (cb[0] + cb[2]) / 2.0
                    for cpn, cnn in (ci._pin_net_pairs or []):
                        if cnn != nn:
                            continue
                        cp = _pin_canvas_pos(ci, cpn)
                        mp = _pin_canvas_pos(inst, pn)
                        # Level only with a pin FACING this part, or at
                        # the end of an upright member; otherwise the wire
                        # would run through the member's body.
                        if cp and mp and ((cp[0] - ccx) * (mx - ccx) > 0
                                          or abs(cp[0] - ccx) < 1.0):
                            target = cp[1] - mp[1]
                            break
                    if target is not None:
                        break
                if target is not None:
                    break
            if target is None:
                continue
            b = box[r]
            top = inst.oy_px + b[1] + target
            top = max(y, min(top, y + hh - (b[3] - b[1])))
            inst.oy_px = top - b[1]

    def _chain_put(self, comp, box, y, hh, want=None):
        """Lay one subchain left to right at row y, STARTING AT ITS MERGE
        POINT'S x, and record its refs.

        Starting at the row cursor instead was the first cut's real
        defect: the subchain kept its ORDER along the spine but lost its
        POSITION, so a part that joins at x=1200 was drawn at x=40 and
        the wire ran the width of the page."""
        cur = getattr(self, '_chain_cursor', {}).get(round(y),
                                                    self._CHAIN_MARGIN)
        x = max(cur, want if want is not None else cur)
        done = set()
        gap = float(self._CHAIN_GAP)
        for r in comp:
            if r in done:
                continue
            inst = self._chain_by_ref.get(r)
            if inst is None:
                continue
            key = getattr(self, '_chain_block_of', {}).get(r)
            if key:
                if r in self._chain_placed:
                    # Its block was already laid with another subchain.
                    done |= set(key)
                    continue
                x = self._chain_put_block(key, box, x, y, done)
                x += gap
                continue
            b = box[r]
            inst.ox_px = x - b[0]
            inst.oy_px = y - b[1]
            x += (b[2] - b[0]) + gap
            self._chain_placed.add(r)
            done.add(r)
        self._chain_drop_to_partner(comp, box, y, hh)
        cur = getattr(self, '_chain_cursor', None)
        if cur is None:
            cur = self._chain_cursor = {}
        cur[round(y)] = x

    def _run_placement(self):

        """Rev 53 signal-flow placement (replaces force-directed)."""
        if not self.drawable:
            return
        if (getattr(self, '_bk_best_of_drawn', False)
                and not getattr(self, '_in_best_of', False)):
            return self._place_best_of_drawn()
        # mark that placement has run at least once, so
        # _render stops showing the pre-placement "Placing…" hint and draws
        # the real (now-positioned) schematic.
        self._initial_place_done = True
        # PLACEMENT IS RUNNING.  _placement_extent must not read the
        # ACTUAL T-symbol coordinates while this is set: the T's still
        # in self._t_terminals belong to the PREVIOUS layout, so
        # unioning them reserves space around positions this Place is
        # about to invalidate.  Measured with the guard missing: the
        # harness FAILED and OPAX197 went 617 -> 981 crossings, because
        # every box grew to swallow its old T's.  The prediction is the
        # only correct source while packing; the real coordinates become
        # correct only once _rebuild_t_terminals has re-derived them for
        # THIS layout.
        self._placing = True
        self._placement_errors = []      # Reset per Place
        self._dbg_lane_info = []         # Reset per Place
        self._dbg_unit_layout = []       # reset per Place (see _assign_x)
        self._dbg_position_log = []      # reset per Place (see _dbg_track)
        if os.environ.get('SP2SCH_TRACK'):
            self._dbg_track_positions = True
        # Reset P2DL's match output every Place: it derives from the netlist,
        # and carrying it over only costs reproducibility.
        self._sp_block_layout = {}
        self._sp_rigid_blocks = set()
        # Place starts from scratch: clear both the user-edit lock and the drag
        # positions, or old drags would override the new layout.
        self._user_positions = {}
        # Clear floating-net red highlight on Place.
        # Per the spec: "If the dialog box is not present and the user
        # presses the Place button, the entire circuit is re-placed
        # and new flight lines are drawn.  The new flight lines should
        # be drawn in their normal color, not in red."  We clear the
        # highlight set unconditionally — the dialog itself is left
        # open if present, but its listbox selection is cleared via
        # the _refresh_floating_dialog call done below after Place
        # completes (the net list has likely changed anyway).
        self._highlighted_nets = set()
        self._place_btn.config(state=tk.DISABLED, text='Placing…',
                                bg='#888888')
        self.update_idletasks()

        instances = self._build_instances()
        if not instances:
            self._place_btn.config(state=tk.NORMAL, text='Place…',
                                    bg='#5a8a3a')
            return

        # expose the instances being placed NOW so the
        # structural port classifier (_classify_port_structural, used by
        # _subckt_io_nets) can see the live circuit on the FIRST placement.
        # Previously it read self._placed_instances, which is only set at
        # the END of _run_placement, so on the startup auto-place it was
        # None — the output net (LM324 net 5) went unclassified, ctx.out
        # was empty, and chain_to_out never matched (VC/DC/VE/DE were left
        # to the generic flow rules, mis-oriented).  Pressing Place a
        # second time fixed it because _placed_instances was then set.
        # With this transient the first placement matches the second.
        self._placing_instances = instances

        # Detect the supply rails before the first _subckt_io_nets call, which
        # needs them to classify ports.
        self._supply_rails = self._detect_supply_rails(instances)

        # pre-compute the rail-polarity hint HERE too.
        # _instance_bbox_with_ts (used to size every cluster box just
        # below) now reserves room for the numeric-rail T-symbols and
        # needs _rail_pos_hint/_rail_neg_hint to know which rail draws on
        # top (180) vs bottom (0).  Those hints were otherwise not set
        # until _assign_pattern_groups, which runs AFTER the cluster
        # layout — so on the FIRST placement the boxes were sized with no
        # rail-T reservation and the floorplan differed from the second
        # Place (startup-parity regression).  This mirrors the tail-rail
        # logic in _layout_diff_pair without doing the full layout.
        if getattr(self, '_rail_pos_hint', None) is None and \
                getattr(self, '_rail_neg_hint', None) is None:
            by_ref0 = {i.comp['ref']: i for i in instances}
            for roles in self._match_diff_pairs(instances):
                tail = roles.get('tail_source')
                ti = by_ref0.get(tail) if tail else None
                if ti is None:
                    continue
                tn = [n.lower() for n in (ti.comp.get('nets', []) or [])]
                tnode = str(roles.get('tail_node', '')).lower()
                if len(tn) == 2 and tnode in tn:
                    rail_side = tn[0] if tn[1] == tnode else tn[1]
                    devs = roles.get('devices') or ()
                    qa_ref = devs[0] if devs else None
                    qa_inst = by_ref0.get(qa_ref)
                    sym_u = ((qa_inst.comp.get('sym') or '').upper()
                             if qa_inst is not None else '')
                    pnp = ('PNP' in sym_u or 'PMOS' in sym_u
                           or 'PJF' in sym_u)
                    if pnp:
                        self._rail_pos_hint = rail_side
                    else:
                        self._rail_neg_hint = rail_side
                    break

        # ── Signal-flow Pass 1 ────────────────────────────────────
        in_nets, out_nets = self._subckt_io_nets()
        # cache the IO nets so _instance_bbox_with_ts can
        # reserve room for the per-pin IO T-symbols (e.g. LM324's net-5
        # output T on DC) the same way it reserves for rail/ground T's,
        # keeping the cluster boxes from overlapping at the IO edges.
        self._io_in_nets = set(in_nets)
        self._io_out_nets = set(out_nets)

        # ── detect promoted rails ────────────────
        # High-fanout internal nets are CUT (along with power, ground
        # and top-level IO) to define cluster boundaries, and rendered
        # as black T-symbols at each cluster edge.
        self._promoted_rails = self._detect_promoted_rails(
            instances, in_nets, out_nets)

        # ── apply user CUT overrides to the rail set ─
        # _promoted_rails drives clustering (cut-ness).  A cut-force-ON
        # net that isn't already power/ground/IO joins the rail set so
        # it cuts the graph; a cut-force-OFF net leaves it so it reverts
        # to ordinary connections (merging clusters).  Port-ness (T-
        # symbol rendering) is handled separately via _port_nets and is
        # NOT tied to this set anymore.
        toplevel_io = set(in_nets) | set(out_nets) | set(_PWR_NETS_LC_FOR_T)
        for nl in self._cut_force_on:
            if nl not in toplevel_io:
                self._promoted_rails.add(nl)
        self._promoted_rails -= self._cut_force_off

        # Parallel-part orientation consensus: R/C/L parts sharing both nets are
        # drawn with one orientation.
        self._parallel_orient = self._compute_parallel_orient(
            instances, in_nets, out_nets)

        # ── cluster-based 2-D layout ─────────────
        # Cut the T-symbol nets, partition into clusters, lay each out
        # independently with the instance-level passes, then pack the
        # cluster bboxes in 2-D.
        cut_nets = self._cluster_cut_nets(in_nets, out_nets)
        clusters = self._compute_signal_segments(instances, cut_nets)
        # SNAPSHOT THE TRUE CLUSTERS HERE, before any merge or refine.
        # This is the only point at which the list satisfies the
        # definition the overlay claims to draw: parts joined by ordinary
        # (non-cut) nets, with no such net leaving the set.  That is a
        # SEGMENT (see the terminology block above _CLUSTER_WHOLE_MAX).
        # The merges below fuse segments that share an EQUATION feeder --
        # a purple sense line, not a wire -- into CLUSTERS, and the
        # refine dissolves the big clusters into packing BOXES.
        self._signal_segments = [[i.comp['ref'] for i in cl]
                                 for cl in clusters]
        # a former standalone cluster that only SUPPLIES a value
        # to an equation in another cluster (V(net) tap or I(source) sense) is
        # not a separate circuit: fold it into the cluster of the equation it
        # feeds, so it lays out as part of the main schematic (left of that
        # equation, via the precedence edge added in
        # _compute_signal_topo_order).
        clusters = self._merge_equation_feeder_segments(clusters)
        # a cluster left with exactly one member
        # never had a real hidden net (see _merge_singleton_segments'
        # docstring); fold it into the main schematic instead of giving
        # it its own floating cluster box.
        clusters = self._merge_singleton_segments(clusters)
        # Dissolve large net-components into small boxes (<=4 tightly coupled
        # parts, plus singletons).  Disjoint boxes cannot cross each other, so
        # this removes inter-component crossings by construction.
        self._box_parent_of_ref = {}
        for _ci, _cl in enumerate(clusters):
            for _inst in _cl:
                self._box_parent_of_ref[_inst.comp['ref']] = _ci
        clusters = self._refine_clusters_into_boxes(
            clusters, cut_nets,
            max_group=getattr(self, '_box_max_group', 4))

        # DEBUG metric 1: after
        # clustering, report each cluster's members and its I/O vs
        # internal nets.  Enable with self._debug_stages = True.
        self._debug_segments(clusters, in_nets, out_nets)

        # Lay out every cluster locally; collect per-cluster positions
        # and bounding boxes (in local, cluster-relative coordinates).
        cluster_local = []      # list of (cluster_insts, local_positions, w, h)
        # Edge T's and their labels are measured directly by
        # _measure_cluster_true_box, so no extra reservation is added here.
        self._assign_group_ids(instances)
        self._preplace_axis(instances)
        self._run_p2dl(instances, phase='group')
        # RE-JOIN any box a P2DL block spans.  _refine_clusters_into_boxes
        # runs ABOVE this line, before the group phase exists, so it
        # cannot know that a set of parts is a diff-pair cell -- and it
        # cut straight through one: on LM324.lib the diff pair landed in
        # two boxes (Q2/RE2 separated from IEE/Q1/RE1), destroying the
        # mirror symmetry that is the entire point of the pattern.  The
        # P2DL cell is the STRUCTURE the reader follows; a packing box is
        # only a container, so the cell wins -- including past
        # _box_max_group, because signal flow beats close packing.
        clusters = self._merge_clusters_for_p2dl_blocks(clusters)
        self._supply_rails = self._detect_supply_rails(instances)
        # topological signal sort AFTER grouping, BEFORE the
        # per-cluster place loop.  Identifies the minimum set of feedback
        # nets so the downstream Sugiyama layering sees a DAG; the feedback
        # nets stay in the render (drawn as ordinary flight lines).
        self._compute_signal_topo_order(instances, in_nets, out_nets)
        # P2DL makes diff-pair and Darlington groups plus cluster connectivity;
        # orientation comes from the netlist rules.
        pin_role_map = self._compute_pin_role_map(
            instances, set(in_nets) | set(out_nets), self._promoted_rails)
        for cl in clusters:
            # Large clusters (4 or more parts) use the Sugiyama lane layout;
            # smaller ones keep the simple signal-flow layout.
            self._preplace_orientations(cl, pin_role_map)
            # Finish every instance BEFORE anything measures one.  The
            # packer, Sugiyama and BK all size their work from
            # _placement_extent, which is only exact once the labels are
            # placed — see _finalize_instance_geometry.
            self._finalize_instance_geometry(cl)
            # Port-side mirror runs after _finalize_instance_geometry: before
            # it, a P2DL member still holds rotation 0 instead of its cell's.
            _mirrored = [self._io_side_mirror(i, in_nets, out_nets)
                         for i in cl
                         if i.comp['ref'] not in self._user_rotations
                         and i.comp['ref'] not in self._user_flips]
            if any(_mirrored):
                # A mirror moves pins, so the labels and composite
                # extents just computed have to be rebuilt before
                # anything measures them.
                self._finalize_instance_geometry(cl)
            loc = self._affinity_layout_group(cl, in_nets, out_nets)
            # Align series/parallel groups on the box's
            # LOCAL positions BEFORE measuring its bbox, so the box
            # width reflects the final (possibly horizontally-spread)
            # member arrangement.  Previously these ran after packing,
            # which let an aligned chain spill past the reserved box
            # width and overlap the neighbouring box.
            self._dbg_track('lane-layout', cl, loc=loc)
            if self._post_fixups:
                self._align_series_chains(cl, loc)
            self._dbg_track('series-align', cl, loc=loc)
            if self._post_fixups:
                self._align_parallel_groups(cl, loc)
            self._dbg_track('parallel-align', cl, loc=loc)
            # Apply the cached block layouts; no spacing bump here, Sugiyama
            # spaces every cluster.
            self._apply_cached_blocks_local(cl, loc)
            self._dbg_track('cached-blocks', cl, loc=loc)
            # Apply each member's orientation to its geometry before measuring
            # the bbox; _auto_rotations alone does not move pins.
            for inst in cl:
                deg = self._auto_rotations.get(inst.comp['ref'], 0) or 0
                if deg % 360 != (inst.rotation_deg or 0) % 360:
                    self._apply_instance_rotation_geometry(inst, deg)
            # measure the cluster's TRUE box: build its
            # T-symbols in its own local frame and union member composites
            # with their owned-T extents (the exact box _render draws).
            # This replaces the predicted _instance_bbox_with_ts + left_pad
            # reservation, which diverged from where the T's actually land
            # (rail T's predicted vertical but placed sideways), leaving the
            # packer to over- or under-reserve and letting cluster boxes
            # overlap.  Now packer box == render box by construction.
            tbox = self._measure_cluster_true_box(cl, loc)
            if tbox is None:
                # Degenerate cluster — fall back to a tiny box.
                minx = miny = 0.0
                maxx = maxy = 1.0
            else:
                minx, miny, maxx, maxy = tbox
            # Normalise so local origin is (0, 0) at the bbox top-left.
            for inst in cl:
                lx, ly = loc[id(inst)]
                loc[id(inst)] = (lx - minx, ly - miny)
            w = maxx - minx
            h = maxy - miny
            # Laying a cluster out with Sugiyama (4+ parts) and giving it its
            # own packed region (12+ parts) are separate questions with separate
            # thresholds.
            is_big_for_packing = len(cl) >= 12
            _dw = getattr(self, '_dbg_cluster_wh', None)
            if _dw is None:
                _dw = self._dbg_cluster_wh = {}
            _dw[tuple(sorted(i.comp['ref'] for i in cl))] = (w, h)
            cluster_local.append((cl, loc, w, h, is_big_for_packing))
            # DEBUG metric 2: this
            # cluster is now fully placed+oriented (frozen).  Report each
            # member's cluster-origin x,y + rotation and the cluster bbox
            # as (upper-left)-(lower-right), tkinter-compatible.
            self._debug_cluster_placed(cl, loc, w, h)

        # ── Cluster ordering: smallest bbox area first, with a
        # MID-proximity secondary key so a cluster that DRIVES a rail
        # net sits near one that RECEIVES it.  We approximate this by
        # tagging each cluster with the sorted tuple of rail nets it
        # touches; clusters sharing a rail net sort together within
        # the same area band.
        rail_nets = set(self._promoted_rails)

        def cluster_rail_signature(cl):
            touched = set()
            for inst in cl:
                for nn in inst.comp.get('nets', []) or []:
                    nl = nn.lower()
                    if nl in rail_nets:
                        touched.add(nl)
            return tuple(sorted(touched))

        def cluster_ref_key(cl):
            # deterministic, environment-independent
            # tiebreaker: the cluster's lexically-smallest ref.  Without
            # it, two clusters with equal (height, area, rail-sig) — e.g.
            # LM324's VC-DC and VE-DE (both 326x65) — fell back to the
            # build order of cluster_local, which depends on set/dict
            # iteration and so differed between the interactive Tk render
            # and a fresh process (the user saw the HLIM clamp cluster on
            # the OTHER side of DP||RP in the script vs the SVG).  A ref-
            # based key is identical on every platform and every run.
            return min((inst.comp['ref'] for inst in cl), default='')

        # Sort clusters by height (10 px buckets), then area, rail signature and
        # a stable ref key, so each shelf row holds similar heights.
        _rank_of = {}
        for _inf in (getattr(self, '_dbg_lane_info', None) or []):
            _rank_of.update(_inf.get('rank_of_ref') or {})

        def cluster_rank_key(cl):
            """A cluster's MEAN Sugiyama rank, or -1 when none of its
            members were ranked.  Sorting boxes by this is the second of
            the two candidate fixes for cross-box distance: rank survives
            Sugiyama as an ORDER but not as a column, because the packer
            that decides page adjacency has never read it."""
            rs = [_rank_of[i.comp['ref']] for i in cl
                  if i.comp['ref'] in _rank_of]
            return round(sum(rs) / float(len(rs)), 3) if rs else -1.0
        _mode = getattr(self, '_rank_box_order', 0)
        if _mode == 3:
            def _pack_key(t):
                return (round(cluster_rank_key(t[0])),
                        round(t[3] / 10.0), round(t[2] * t[3] / 100.0),
                        cluster_rail_signature(t[0]),
                        cluster_ref_key(t[0]))
        elif _mode == 2:
            def _pack_key(t):
                return (cluster_rank_key(t[0]),
                        round(t[3] / 10.0), round(t[2] * t[3] / 100.0),
                        cluster_rail_signature(t[0]),
                        cluster_ref_key(t[0]))
        elif _mode == 1:
            def _pack_key(t):
                return (round(t[3] / 10.0), cluster_rank_key(t[0]),
                        round(t[2] * t[3] / 100.0),
                        cluster_rail_signature(t[0]),
                        cluster_ref_key(t[0]))
        else:
            def _pack_key(t):
                return (round(t[3] / 10.0), round(t[2] * t[3] / 100.0),
                        cluster_rail_signature(t[0]),
                        cluster_ref_key(t[0]))
        ordered = sorted(cluster_local, key=_pack_key)

        # Stack the big Sugiyama cluster and the small clusters as separate
        # regions, each packed with its own width.
        small_entries = [(i, t) for i, t in enumerate(ordered) if not t[4]]
        big_entries = [(i, t) for i, t in enumerate(ordered) if t[4]]
        # THE USER'S PAGE WIDTH: build everything first, then let the
        # page be as wide as a ROW OF TWELVE of the widest instances, or
        # as wide as the widest group, whichever is more.  The shipped
        # width is an area-based guess reflowed to an 11x17 aspect,
        # which has no reason to match the parts actually on the page.
        _user_w = None

        offsets = {}
        # BOX-LEVEL CHAIN LAYOUT FIRST.  Tried ahead of
        # BOTH packer branches -- LM324.sub takes the small/big split,
        # not the unified one, so hooking only the else never ran.
        _bc = None
        if getattr(self, '_box_chain_pack',
                   getattr(type(self), '_BOX_CHAIN_PACK', False)):
            _bc = self._box_chain_offsets(ordered)
        if _bc:
            offsets, _tw, _th = _bc
        elif small_entries and big_entries:
            big_boxes = [(t[2], t[3], local_i)
                        for local_i, (_orig_i, t) in enumerate(big_entries)]
            big_offsets, big_w, big_h = self._pack_cluster_boxes(big_boxes)
            small_boxes = [
                (t[2], t[3], local_i)
                for local_i, (_orig_i, t) in enumerate(small_entries)]
            # Do not force the small clusters narrower than their natural width
            # just to match the Sugiyama cluster's width.
            _dry_offsets, small_natural_w, _dry_h = self._pack_cluster_boxes(
                small_boxes)
            shared_w = max(big_w, small_natural_w)
            if _user_w:
                shared_w = _user_w
            small_offsets, small_w, small_h = self._pack_cluster_boxes(
                small_boxes, fixed_width=shared_w)
            STACK_GAP = 60.0
            y_shift = (small_h + STACK_GAP) if small_h > 0 else 0.0
            for local_i, (orig_i, _t) in enumerate(small_entries):
                offsets[orig_i] = small_offsets.get(local_i, (0.0, 0.0))
            for local_i, (orig_i, _t) in enumerate(big_entries):
                bx, by = big_offsets.get(local_i, (0.0, 0.0))
                offsets[orig_i] = (bx, by + y_shift)
        else:
            # BOX-LEVEL CHAIN LAYOUT -- spine and sides one
            # level up, instead of a shelf packer that can only wrap.
            # ── Pack the cluster bboxes in 2-D (single unified region
            # — no meaningful small/big split to stack separately).
            boxes = [(w, h, idx) for idx, (cl, loc, w, h, _is_sug)
                     in enumerate(ordered)]
            offsets, _tw, _th = self._pack_cluster_boxes(
                boxes, fixed_width=_user_w,
                width_is_floor=False)

        # Debug report of the packed rows.
        self._debug_cluster_rows(ordered, offsets)

        # ── Compose global positions = cluster offset + local pos.
        # Page boundary of 10 px at the top and left.
        # The cluster bboxes now fully contain their edge-T labels (see
        # the left_pad/right_pad reservation above), so the page margin
        # is purely a small cosmetic gap, not load-bearing.
        PAGE_MARGIN = 10
        positions = {}
        for idx, (cl, loc, _w, _h, _is_sug) in enumerate(ordered):
            ox, oy = offsets.get(idx, (0.0, 0.0))
            for inst in cl:
                lx, ly = loc[id(inst)]
                positions[id(inst)] = (lx + ox + PAGE_MARGIN,
                                        ly + oy + PAGE_MARGIN)
        self._boxes = [[inst.comp['ref'] for inst in cl]
                          for (cl, loc, w, h, _is_sug) in ordered]
        # Remember the exact placement partition (the
        # boxes the packer used) so _rebuild_t_terminals reuses it
        # instead of recomputing a possibly-divergent net-component
        # clustering.  Stored by REF because _render recreates the
        # CompInstance objects each time, so id()-based matching won't
        # work across the placement→render boundary.
        self._placement_boxes_refs = [[inst.comp['ref'] for inst in cl]
                                      for (cl, loc, w, h, _is_sug) in ordered]

        # Push positions onto the instances.  Rev 53 does NOT
        # run a post-Pass-2 _apply_rotations polish because that
        # function rotates the CURRENT sym_entry (which we've already
        # rotated in _signal_flow_rotations), producing a compounded
        # rotation rather than an absolute one.  The signal-flow
        # rotation rule alone is sufficient: R/C/L in the signal path
        # get rot=90, R/C/L on power/ground get rot=0/180.  Other
        # instance kinds keep their default rot=0.
        for inst in instances:
            cx, cy = positions[id(inst)]
            inst.ox_px = cx
            inst.oy_px = cy
        # CHAIN-FIRST RELAYOUT, the only mode.  Runs
        # after the normal placement so orientation and the measured
        # boxes are already settled, and overwrites the coordinates from
        # the chain topology instead.
        self._chain_ran = False
        try:
            # The independent boxes are the SIGNAL SEGMENTS -- the
            # partition the Boxes overlay draws -- not the packer's
            # merged clusters.
            self._chain_ran = bool(self._chain_relayout(instances))
            self._chain_relayout_boxes(
                self._box_partition(instances), relayout=False)
        except Exception as _exc:
            print('chain relayout skipped: %r' % (_exc,))
        self._shift_to_origin(instances)
        self._dbg_track('cluster-pack', instances=instances)
        # Series/parallel alignment now runs per-box on
        # local coordinates BEFORE bbox measurement (see the layout
        # loop above), so the packed box widths already reflect it and
        # there is no post-packing position mutation that could break
        # the non-overlap guarantee.  The boundary-subgroup gather
        # remains optional/experimental.

        # the cached-cell
        # application, the P2DL orient phase, the chain_to_out re-anchor
        # AND the group-internal packing all run inside the per-cluster
        # pass above now, so each cluster is fully placed+oriented (frozen)
        # before packing.  The box-packer below only TRANSLATES finished
        # clusters.  (_place_group_internally verified redundant: removing
        # its global call leaves both circuits identical — parity 0, same
        # overlaps.)
        self._supply_rails = self._detect_supply_rails(instances)

        # GENERAL positive-rail-up orientation: vertically
        # mirror any loose part whose VCC/positive rail ended up below its
        # VEE/negative rail (e.g. OPAX197 I_I_Q, X_U21.S1), so VCC reads on top
        # and VEE on the bottom.  Runs before T emission / overlap resolution so
        # both reflect the corrected orientation.


        # Store the result for _render.
        self._placed_ref_pos   = {inst.comp['ref']: positions[id(inst)]
                                   for inst in instances}
        # Build a simple left-to-right ordering for _render's
        # "_placed_order" field (used by some legacy code paths).
        # piece (1): when render atomicity is on, keep a
        # tight group's members consecutive (units ordered by their
        # leftmost member, members within a unit by intended x) so the
        # render's row packing treats each group as one block and
        # outsiders cannot interleave between its members.
        # _enable_cluster_place is
        # retired, so the placed-cluster unit key is gone; render
        # atomicity now keys purely on the tight group_id.
        def _unit_key(i):
            return ('g', i.group_id)       # the tight group

        units = defaultdict(list)
        for i in instances:
            units[_unit_key(i)].append(i)
        for u in units.values():
            u.sort(key=lambda i: (positions[id(i)][0],
                                   positions[id(i)][1]))
        unit_list = sorted(
            units.values(),
            key=lambda u: min(positions[id(i)][0] for i in u))
        sorted_insts = [i for u in unit_list for i in u]
        self._placed_order     = [inst.comp for inst in sorted_insts]
        self._placed_instances = instances
        # placement committed; the transient is no longer
        # needed (later _subckt_io_nets calls use _placed_instances).
        self._placing_instances = None
        # Empty clusters — rev 53 doesn't use the clustering machinery.
        # _render handles empty clusters gracefully.
        net_to_pins, inst_to_pairs = _build_pin_flight_data(instances)
        self._pin_flight_data  = (net_to_pins, inst_to_pairs, instances)
        self._place_btn.config(state=tk.NORMAL, text='Place…',
                                bg='#5a8a3a')
        # Render only after rotation is settled and the draw-state store is
        # built, so it never draws stale rotations.
        def _tail_pos_sig():
            return tuple(
                (id(inst), round(inst.ox_px, 2), round(inst.oy_px, 2),
                 inst.rotation_deg)
                for inst in instances)

        try:
            self._dbg_track('settle-loop entry', instances=instances)
            for _outer_fix in range(4):
                sig_before = _tail_pos_sig()
                # Re-place labels inside this loop: the overlap resolvers read
                # abs_composite (body plus labels).
                for inst in instances:
                    lqt = QuadTree(-200000, -200000, 200000, 200000)
                    inst.place_texts(lqt)
                # COMMITTED LABELS: the side was chosen at reservation
                # time and the placer separated the boxes on that basis,
                # so re-deciding it here would invalidate the reservation
                # — and re-deciding is measurably where the overlaps come
                # from.  Traced per pass: the lane layout hands over 0
                # overlapping pairs on LM324.lib/.sub/LP2951 and 1 of 194
                # on OPAX197, and _reresolve_value_texts then creates them
                # (OPAX197 1 -> 6, LP2951 0 -> 2), which
                # _resolve_body_overlaps cleans up by moving whole PARTS.
                self._reresolve_value_texts(instances)
                for inst in instances:
                    inst._recompute_composite_rel()
                # Re-assert the per-rank barrier with the real label extents:
                # final labels come out wider on the left than the estimate
                # _assign_x used.
                self._dbg_track(f'{_outer_fix}:labels', instances=instances)
                if self._post_fixups:
                    self._enforce_rank_monotonicity(instances)
                self._dbg_track(f'{_outer_fix}:rank-monotonic',
                                instances=instances)
                # Runs only with post-placement fixups on; it is idle on both
                # LM324 decks.
                if self._post_fixups:
                    self._resolve_body_overlaps(instances)
                self._dbg_track(f'{_outer_fix}:body-resolve',
                                instances=instances)
                if self._post_fixups:
                    self._resolve_intra_rigid_overlaps(instances)
                self._settle_and_rebuild_ts(instances)
                self._dbg_track(f'{_outer_fix}:settle-Ts',
                                instances=instances)
                if _tail_pos_sig() == sig_before:
                    break
            # run the SAME label
            # pipeline the renderer runs, ONE MORE TIME, now that the loop
            # above has converged (or hit its cap) — the loop's OWN last
            # label placement ran BEFORE that iteration's resolve/settle
            # calls, so it can be stale relative to whatever THEY just
            # moved; this final pass is what actually gets stored, on
            # truly-final positions with nothing after it able to move an
            # instance body again.
            for inst in instances:
                lqt = QuadTree(-200000, -200000, 200000, 200000)
                inst.place_texts(lqt)
            # _reresolve_value_texts is T-symbol-
            # aware (checks T positions as obstacles, not just other
            # instances — see its own docstring); safe to treat this as
            # truly final because T's were just settled, above, and
            # nothing after this call moves an instance body.
            self._reresolve_value_texts(instances)
            # _reresolve_value_texts can reassign a
            # value label to a DIFFERENT candidate (to dodge a neighbour)
            # but never updated composite_rel to match — found via the
            # user's own visual catch (HLIM's value text partly outside
            # its drawn bbox).  Recompute for real here.
            for inst in instances:
                inst._recompute_composite_rel()
            # One more rotation pass,
            # now that positions/labels are final and cross_other is a
            # real, rotation-sensitive metric: try each eligible 2-pin
            # part's other rotation(s) for real and keep any that
            # measurably help.  If it changes anything it re-settles
            # labels/T's/composite_rel itself (see its own docstring),
            # so the snapshots just below always reflect its result.
            self._dbg_track('post-loop labels', instances=instances)
            _pre_touchup_cross_ab = self._self_check(instances)['cross_ab']
            _touchup_orig_rot = (
                {} if self._ROTATION_RULES_ONLY
                else self._metric_driven_rotation_touchup(instances))
            # Final cluster-box-overlap
            # safety net, run here (after every rotation-affecting pass,
            # before the placed-draw-state snapshot below so the shift
            # actually reaches render) — see _resolve_cluster_box_
            # overlaps' own docstring for the full root cause.
            self._dbg_track('rot-touchup', instances=instances)
            if self._post_fixups:
                self._resolve_cluster_box_overlaps(instances)
            self._dbg_track('cluster-box', instances=instances)
            # _metric_driven_rotation_touchup is the last pass that can rotate a
            # part, and rotation resizes _placement_extent, so labels are placed
            # after it.
            self._place_unplaced_texts(instances)
            # Publish the LAST label pass's unresolved labels.  Every
            # earlier call's list is superseded — see the note in
            # _reresolve_value_texts about intermediate complaints.
            _published = list(getattr(self, '_label_conflicts', None) or [])
            self._placement_errors.extend(_published)
            # LAST STOP before the store render seeds from: make the
            # rotation maps say what these instances' geometry actually
            # is.  Everything above this line is free to rotate a part;
            # this is the one place that guarantees render rebuilds the
            # SAME orientation placement just measured, reserved space
            # for and routed (see _sync_auto_rotations).
            self._sync_auto_rotations(instances)
            # THE GATE, last thing before the draw state is captured:
            # everything above may still move a part, choose a label side
            # or re-seat a T, so this is the first point where the box
            # being frozen is the box that gets drawn.  T's are rebuilt
            # after a move because their coordinates are absolute.
            self._compaction_stats = None
            self._median_moved = 0
            if self._MEDIAN_MOVE:
                try:
                    self._median_moved = self._median_move_pass(instances)
                except Exception as _exc:
                    print('median move skipped: %r' % (_exc,))
            self._sheet_moved = 0
            if self._SHEET_COMPACT:
                try:
                    self._sheet_moved = self._sheet_compact(instances)
                except Exception as _exc:
                    print('sheet compaction skipped: %r' % (_exc,))
            try:
                self._compaction_stats = self._compaction_pass(instances)
                _cs = self._compaction_stats
                if (_cs['moved'] or _cs['cross_box'] or self._median_moved
                        or self._sheet_moved):
                    self._repack_boxes(instances)
                    self._rebuild_t_terminals(instances)
                    # The rebuild leaves every label unplaced.
                    self._place_unplaced_texts(instances)
                    # That pass's complaints describe the layout that
                    # is drawn; the earlier ones described parts that
                    # have since moved.
                    for _c in _published:
                        if _c in self._placement_errors:
                            self._placement_errors.remove(_c)
                    self._placement_errors.extend(
                        getattr(self, '_label_conflicts', None) or [])
                    for _i in instances:
                        _i._recompute_composite_rel()
            except Exception as _exc:
                print('compaction skipped: %r' % (_exc,))
            self._placed_text = {
                inst.comp['ref']: [
                    (ti.get('kind'), ti.get('placed'))
                    for ti in inst.text_items
                ]
                for inst in instances
            }
            # Report, do not patch: a drawable instance that never reached
            # placement is a bug, so name it.
            _all_drawable_refs = {c['ref'] for c in (self.drawable or [])}
            _unplaced_refs = _all_drawable_refs - set(self._placed_text)
            if _unplaced_refs:
                _msg = (f'WARNING: Place left {len(_unplaced_refs)} '
                        f'instance(s) unplaced: '
                        f'{sorted(_unplaced_refs)[:10]}')
                print(_msg)
                self.status.config(text=_msg[:200])
            # place populates the store — _placed_draw_state is built
            # from place's finalized instances so render SEEDS FROM
            # PLACE.  This used to be an inline copy of
            # _capture_draw_state, field for field; two routines writing
            # the same record is how one of them ends up missing a field
            # the other has (which is exactly what happened to the
            # T-offset cache).  One routine now, called from both places.
            self._capture_draw_state(instances)
        except Exception:
            self._placed_text = None

        # NOW render, with rotation final
        # (settle done) and the draw-state store populated.  This is the single
        # authoritative draw of the placement; render seeds from the store.
        self._render()
        # _render() rebuilds its OWN instance
        # objects from the draw-state store rather than reusing the
        # `instances` list _metric_driven_rotation_touchup just verified
        # safe on — confirmed this can genuinely differ: a candidate
        # that passed the touchup's own pre-render self-crossing check
        # still showed a self-crossing in the toolbar after render, on
        # OPAX197.  This is the real, final check, against what render
        # actually produced; revert-and-re-render once if it regressed.
        if (_touchup_orig_rot
                and self._last_self_check.get('cross_ab', 0)
                > _pre_touchup_cross_ab):
            for ref, rot in _touchup_orig_rot.items():
                inst = next((i for i in instances if i.comp['ref'] == ref),
                            None)
                if inst is not None:
                    self._apply_instance_rotation_geometry(inst, rot)
            # Same fix as the touchup's own revert: record the reverted
            # rotation instead of erasing the entry (_sync_auto_rotations).
            self._sync_auto_rotations(instances)
            for inst in instances:
                lqt = QuadTree(-200000, -200000, 200000, 200000)
                inst.place_texts(lqt)
            self._reresolve_value_texts(instances)
            for inst in instances:
                inst._recompute_composite_rel()
            self._rebuild_t_terminals(instances)
            self._render()
        # If the Floating-nets dialog is open, refresh
        # its listbox.  The net topology hasn't changed (Place doesn't
        # alter connectivity), so the list will normally be identical,
        # but the highlight selection has been cleared above, so the
        # listbox needs to redraw with no items selected.
        if self._floating_dlg is not None and self._floating_dlg.winfo_exists():
            self._refresh_floating_dialog()

        # surface any items the
        # placer could not place clear of all bboxes.  These are drawn as red
        # ERROR boxes by _render; log them to stdout so the failing
        # instance/group + xy are visible for diagnosing the algorithm.
        # Every compaction repair is a defect in an earlier pass, so the
        # count is the diagnostic: a healthy layout reports 0 moved.
        _cs = getattr(self, '_compaction_stats', None)
        if _cs and (_cs['moved'] or _cs['intra_cell'] or _cs['cross_box']):
            print('COMPACTION: %d/%d item(s) pushed down, total %.0f px, '
                  'worst %s %.0f px; %d clash(es) inside P2DL cells left '
                  'alone; %d box pair(s) repacked'
                  % (_cs['moved'], _cs['items'], _cs['total_dy'],
                     _cs['worst'][0] if _cs['worst'] else '-',
                     _cs['worst'][1] if _cs['worst'] else 0.0,
                     _cs['intra_cell'], _cs['cross_box']))
        if self._placement_errors:
            print(f"PLACEMENT ERROR: {len(self._placement_errors)} item(s) "
                  f"could not be placed without bbox conflict:")
            for label, x, y, w, h in self._placement_errors:
                print(f"  ERROR  {label}  at ({x:.0f},{y:.0f})  "
                      f"size {w:.0f}x{h:.0f}")

        # Placement is done; from here the T-symbols in self._t_terminals
        # are the ones belonging to THIS layout, so _placement_extent may
        # read their real coordinates.
        self._placing = False
        # ...except a T that owns its box, whose coordinates still
        # describe the layout this Place replaced.  Re-seat it against
        # the pins it serves now that they have stopped moving; the
        # reservation _instance_bbox_with_ts took for it while _placing
        # was true is the space this spends.
        try:
            self._reseat_own_box_ts(instances)
        except Exception:
            pass

    # ── Signal-flow Pass 1 ──────────────────────────────────────────────

    def _signal_flow_pass1(self, instances, in_nets, out_nets):
        """In : the instances and the top-level in and out nets.
        Out: order[id(inst)] = (row_id, col, y_rank) — row_id starting at
        0 and incrementing per seed, col 1 leftmost, y_rank a coarse y
        within the row, centred on the seed's.
        A topological walk seeded from .SUBCKT input ports (net seeds)
        plus VALUE-driven and independent V/I sources (instance seeds);
        output ports form a sink set whose instances feed the rightmost
        column.  Power and ground nets are not walked, and already-placed
        instances and nets are skipped, which terminates feedback
        cycles."""
        net_to_insts = {}     # net_lc → [CompInstance]
        # skip the feedback nets chosen by
        # _compute_signal_topo_order so the walk lays out a DAG (left→right
        # signal flow, no back-edges).  Empty when pass1 is called FROM the
        # topo computation itself (its reset clears the set first), and
        # empty when the 'Sig Topo' option is off, so that call / that mode
        # sees the full graph.  The feedback nets are still drawn as flight
        # lines by the renderer.
        _fb_nets = self._active_feedback_nets()
        for inst in instances:
            for n in inst.comp.get('nets', []):
                nl = n.lower()
                if nl in _PWR_NETS_LC_FOR_T:
                    continue
                # Also skip promoted rails so the
                # walk doesn't traverse a high-fanout internal net.
                if nl in self._promoted_rails:
                    continue
                if nl in _fb_nets:
                    continue
                net_to_insts.setdefault(nl, []).append(inst)

        order = {}
        placed_nets = set()
        Y_STEP = 1024
        # Per-instance per-pin role: 'in' (signal arrives via
        # this pin from a lower-column net) or 'out' (signal leaves via
        # this pin to a higher-column net).  Used by
        # _signal_flow_rotations to choose rot=90 vs rot=270 for
        # 2-signal R/C/L so the 'in' pin lands on the canvas-left side.
        pin_role = {}    # (id(inst), pin_num_str) → 'in' | 'out'

        # Identify seed instances.
        seed_instances = []
        for inst in instances:
            comp = inst.comp
            kind = comp.get('kind', '').upper()
            val = (comp.get('value') or '').strip()
            is_value_src = (kind in ('E', 'G', 'H', 'F')
                             and (val.upper().startswith('VALUE')
                                  or '{' in val))
            is_indep_src = kind in ('V', 'I')
            if is_value_src or is_indep_src:
                seed_instances.append(inst)

        # Identify seed nets: SUBCKT input ports.
        seed_nets = [nl for nl in sorted(in_nets) if nl in net_to_insts]
        row_y_centre = Y_STEP
        next_row = 0

        def place_targets_in_column(targets, col, y_centre, row_id):
            n = len(targets)
            if n == 0:
                return
            if n == 1:
                order[id(targets[0])] = (row_id, col, y_centre)
                return
            spread = Y_STEP / max(n, 2)
            for i, t in enumerate(targets):
                y_rank = y_centre + (i - (n - 1) / 2.0) * spread
                order[id(t)] = (row_id, col, y_rank)

        def walk_from_net(net_lc, col, y_centre, row_id):
            if net_lc in placed_nets:
                return
            placed_nets.add(net_lc)
            members = net_to_insts.get(net_lc, [])
            targets = [m for m in members if id(m) not in order]
            if not targets:
                return
            place_targets_in_column(targets, col, y_centre, row_id)
            # Record the pin role: each placed target's pin
            # on `net_lc` is its 'in' pin (signal arrives from the
            # lower-column side via this pin).
            for t in targets:
                for pn, nn in (getattr(t, '_pin_net_pairs', None) or []):
                    if nn.lower() == net_lc:
                        pin_role[(id(t), pn)] = 'in'
                        break
            for t in targets:
                walk_from_instance(t, col, order[id(t)][2], row_id)

        def walk_from_instance(inst, this_col, this_y, row_id):
            pin_net_pairs = getattr(inst, '_pin_net_pairs', None) or []
            for pn, nn in pin_net_pairs:
                nl = nn.lower()
                if nl in _PWR_NETS_LC_FOR_T:
                    continue
                if nl in placed_nets:
                    continue
                if nl in out_nets:
                    # Pin going to an output port is an 'out'
                    # pin for this instance.
                    pin_role[(id(inst), pn)] = 'out'
                    continue
                # Honor an explicit 'in' pin-role override: the walk must not
                # advance the column through a pin the user marked as an input.
                if self._pin_role_overrides.get(
                        (inst.comp['ref'], pn)) == 'in':
                    pin_role[(id(inst), pn)] = 'in'
                    continue
                # This pin leaves to a higher-column net → 'out' pin.
                pin_role[(id(inst), pn)] = 'out'
                walk_from_net(nl, this_col + 1, this_y, row_id)

        for nl in seed_nets:
            row_id = next_row; next_row += 1
            y_seed = row_y_centre + row_id * Y_STEP
            placed_nets.add(nl)
            members = [m for m in net_to_insts.get(nl, [])
                       if id(m) not in order]
            if members:
                place_targets_in_column(members, 1, y_seed, row_id)
                # Pin on the seed net is the 'in' pin.
                for m in members:
                    for pn, nn in (getattr(m, '_pin_net_pairs', None) or []):
                        if nn.lower() == nl:
                            pin_role[(id(m), pn)] = 'in'
                            break
                for m in members:
                    walk_from_instance(m, 1, order[id(m)][2], row_id)

        for inst in seed_instances:
            if id(inst) in order:
                continue
            row_id = next_row; next_row += 1
            y_seed = row_y_centre + row_id * Y_STEP
            order[id(inst)] = (row_id, 1, y_seed)
            walk_from_instance(inst, 1, y_seed, row_id)

        # Orphans.
        while True:
            orphans = [i for i in instances if id(i) not in order]
            if not orphans:
                break

            def orphan_score(inst):
                nets = {n.lower() for n in inst.comp.get('nets', [])
                        if n.lower() not in _PWR_NETS_LC_FOR_T}
                return len(nets)

            orphans.sort(key=orphan_score, reverse=True)
            seed = orphans[0]
            row_id = next_row; next_row += 1
            y_seed = row_y_centre + row_id * Y_STEP
            order[id(seed)] = (row_id, 1, y_seed)
            walk_from_instance(seed, 1, y_seed, row_id)

        return order, pin_role

    # ── Topological signal order + feedback-net selection ──

    def _compute_signal_topo_order(self, instances, in_nets, out_nets):
        """Build the directed signal graph (parts are nodes, nets driver->load
        edges) and order it topologically; when it has cycles, drop the
        fewest nets (Eades-Lin-Smyth) so Sugiyama sees a DAG.
        """
        # Clear any prior run's feedback set BEFORE the seed-walk below, so
        # the layering passes it calls compute this run's raw columns on the
        # full graph (the global call here is exactly how we FIND feedback).
        self._feedback_nets = set()
        if not instances:
            return {}, set()

        # Column potential from the existing seed-walk (reused, not
        # duplicated).
        glob_order, _pin_role = self._signal_flow_pass1(
            instances, in_nets, out_nets)
        col = {}
        for inst in instances:
            iid = id(inst)
            col[iid] = glob_order[iid][1] if iid in glob_order else 0

        exclude = (set(_PWR_NETS_LC_FOR_T) | set(in_nets) | set(out_nets)
                   | set(self._promoted_rails)
                   | set(self._eligible_t_nets()))
        id2inst = {id(i): i for i in instances}

        # Driver/load classification comes from the same role map as the arrow
        # overlay and D->R List (_compute_pin_role_map: table, overrides,
        # propagation).
        role_map = self._compute_pin_role_map(
            instances, set(in_nets) | set(out_nets), self._promoted_rails)
        # confidence tier per (cid,idx), computed by the
        # SAME call above (_compute_pin_role_map stashes it as a side
        # effect): 'high' = intrinsic table or user override, 'low' =
        # propagation-only.  Used below to decide, for a net whose driver
        # relationship turns out to create a cycle, whether that's GENUINE
        # feedback (high confidence both ends — keep today's behavior: drop
        # from layering, still drawn as a flight line) or just a probably-
        # wrong guess (low confidence somewhere — REVERSE it for Sugiyama
        # instead, per the user's rule: prefer assuming the direction that
        # keeps the graph acyclic over asserting low-confidence feedback).
        conf_map = getattr(self, '_pin_role_confidence', None) or {}

        net_insts = defaultdict(set)     # net_lc → {id(inst)}
        net_drivers = defaultdict(set)   # net_lc → {id(inst) that DRIVES it}
        net_loads = defaultdict(set)     # net_lc → {id(inst) that SENSES it}
        # net_lc → 'high' if ANY contributing pin (driver or
        # load side) on this net is high-confidence, else 'low' if every
        # contributing pin was propagation-only.  A net is only as
        # confident as its LEAST confident contributing pin — one
        # propagated guess is enough to make the whole net's direction
        # suspect for feedback-vs-reverse purposes.
        net_conf = {}

        def _mark_conf(nl, hi):
            cur = net_conf.get(nl)
            if cur is None:
                net_conf[nl] = 'high' if hi else 'low'
            elif not hi:
                net_conf[nl] = 'low'
        # UNFILTERED driver map (ports/rails included), used
        # only to resolve equation V(net) senses below.  An equation that
        # reads V(port) must still rank after whatever DRIVES that port, even
        # though the port is excluded from the ordinary net→edge graph.
        net_drivers_all = defaultdict(set)
        for inst in instances:
            cid = id(inst.comp)
            for nn in (inst.comp.get('nets') or []):
                nl = nn.lower()
                if nl not in exclude:
                    net_insts[nl].add(id(inst))
            for idx, (_pn, nn) in enumerate(
                    getattr(inst, '_pin_net_pairs', None) or []):
                nl = nn.lower()
                r = role_map.get((cid, idx))
                if r == 'out':
                    net_drivers_all[nl].add(id(inst))
                    if nl not in exclude:
                        net_drivers[nl].add(id(inst))
                        _mark_conf(nl, conf_map.get((cid, idx)) == 'high')
                elif r == 'in':
                    if nl not in exclude:
                        net_loads[nl].add(id(inst))
                        _mark_conf(nl, conf_map.get((cid, idx)) == 'high')
                        # a behavioral source's equation input
                        # (V(net)) is NOT among its positional nets, so
                        # register it as a member of that net too;
                        # otherwise the net has <2 positional members and
                        # no driver->source edge forms.
                        net_insts[nl].add(id(inst))
            # Equation-only sensed nets — not a positional pin on THIS
            # instance, so role_map has no entry to read; source directly,
            # same as _electrical_net_roles did for the E/G/S/B branch.
            # These are structural (the equation genuinely names this net),
            # not a propagation guess, so treat as high-confidence.
            sense = inst.comp.get('sense_nets')
            if sense is None:
                v_eq, _i = _equation_signal_refs(inst.comp)
                sense = v_eq
            for nl in sense:
                nl = str(nl).lower()
                if nl not in exclude:
                    net_loads[nl].add(id(inst))
                    net_insts[nl].add(id(inst))
                    _mark_conf(nl, True)

        # equation VOLTAGE-sense dependencies across a port/rail.
        # The main loop excludes port/rail nets, so an E/G/B source that reads
        # V(port) gets NO driver→source edge — which is exactly why a feeder
        # block that drives that port stayed a disconnected island.  Register
        # the dependency here using the UNFILTERED driver map, so the driver of
        # the sensed net (incl. a port) ranks before the equation that reads it.
        # This is the ordering half of _merge_equation_feeder_segments; the
        # I(source) half is already handled by the sense-source pass below.
        for inst in instances:
            sense = inst.comp.get('sense_nets')
            if sense is None:
                v_eq, _i = _equation_signal_refs(inst.comp)
                sense = v_eq
            for nl in sense:
                nl = str(nl).lower()
                drv = {u for u in net_drivers_all.get(nl, ()) if u != id(inst)}
                if not drv:
                    continue
                net_drivers[nl] |= drv
                net_loads[nl].add(id(inst))
                net_insts[nl] |= drv | {id(inst)}

        # The user's per-edge feedback/forward overrides (double right-click on
        # a flight line) are applied here, as the directed edges are built.
        def _edge_fb_override(u, v, nl):
            ref_u = id2inst[u].comp['ref']
            ref_v = id2inst[v].comp['ref']
            return self._feedback_overrides.get(
                self._feedback_edge_key(nl, ref_u, ref_v))

        edges = []                       # (u, v, net_lc) directed driver→load
        edge_forced_forward = set()       # {(u, v, net_lc)}
        for nl, members in net_insts.items():
            if len(members) < 2:
                continue
            drv = net_drivers.get(nl, set()) & members
            lod = net_loads.get(nl, set()) & members
            if drv and (members - drv):
                # Intrinsic direction: every driver feeds every non-driver
                # (the explicit loads, or — if none are tagged — the passive
                # members downstream of the driver).
                heads = lod if lod else (members - drv)
                for u in drv:
                    for v in heads:
                        if u != v:
                            fb = _edge_fb_override(u, v, nl)
                            if fb is True:
                                continue
                            edges.append((u, v, nl))
                            if fb is False:
                                edge_forced_forward.add((u, v, nl))
            else:
                # No intrinsic driver (passive-only net, or every member a
                # driver): orient by the acyclic column potential.
                ms = sorted(members,
                            key=lambda iid: (col.get(iid, 0),
                                             id2inst[iid].comp['ref']))
                tail = ms[0]
                for v in ms[1:]:
                    fb = _edge_fb_override(tail, v, nl)
                    if fb is True:
                        continue
                    edges.append((tail, v, nl))
                    if fb is False:
                        edge_forced_forward.add((tail, v, nl))
                # no pin on this net resolved a role at all
                # (fully unclassified, or every member conflicted) — there
                # is NO direction evidence, only the acyclic column-potential
                # ordering.  Mark low-confidence so a resulting backward
                # edge (from interaction with the REST of the graph) gets
                # reversed rather than asserted as genuine feedback.
                net_conf.setdefault(nl, 'low')

        # current-sense dependencies: an F/H source (or an
        # I(vsrc) equation term) reads the current through a named V-source,
        # so that V-source must rank BEFORE it.  Resolve the sense-source
        # name to an instance (exact lc ref, else a dotted/underscored
        # suffix for subckt-prefixed refs) and add a direct edge; unmatched
        # names are skipped.  The synthetic '__i__' label can never collide
        # with a real net, so if such an edge is a back-edge it drops
        # harmlessly from the layering.
        ref2inst = {}
        for inst in instances:
            ref2inst[inst.comp['ref'].lower()] = inst
        for inst in instances:
            # prefer the parser's pre-mapped sense_srcs
            # (full flattened refs like 'X_H1.VH_H1'); fall back to raw
            # parsing for any source the parser left without them.
            i_srcs = inst.comp.get('sense_srcs')
            if i_srcs is None:
                _vn, i_srcs = _equation_signal_refs(inst.comp)
            for src in i_srcs:
                src = src.lower()
                tgt = ref2inst.get(src)
                if tgt is None:
                    tgt = next((o for r, o in ref2inst.items()
                                if r.endswith('.' + src)
                                or r.endswith('_' + src)), None)
                if tgt is not None and tgt is not inst:
                    nl = '__i__' + src
                    u, v = id(tgt), id(inst)
                    fb = _edge_fb_override(u, v, nl)
                    if fb is True:
                        continue
                    edges.append((u, v, nl))
                    if fb is False:
                        edge_forced_forward.add((u, v, nl))

        node_ids = [id(i) for i in instances]
        _src, _snk = self._flow_seeds(instances, in_nets, out_nets,
                                      net_drivers, conf_map, edges)
        dist = _flow_distance(node_ids, [(u, v) for (u, v, _n) in edges],
                              _src, _snk)
        self._flow_dist = {id2inst[k].comp['ref']: d
                           for k, d in dist.items()}
        order = _greedy_feedback_arc_order(
            node_ids, [(u, v) for (u, v, _n) in edges], dist)
        self._flow_order = {id2inst[k].comp['ref']: p
                            for k, p in order.items()}

        # Confidence-tiered feedback: a backward edge on a net whose pin roles
        # are all intrinsic or user-set is genuine feedback; a low-confidence
        # one is reversed.
        feedback = set()
        reverse_nets = set()
        for u, v, nl in edges:
            if order.get(u, 0) >= order.get(v, 0):
                # An edge the user explicitly forced FORWARD
                # (edge_forced_forward) must not itself be the reason
                # its net gets marked feedback here — that would
                # silently drop every OTHER edge on the net too,
                # undermining the whole point of tracking this per
                # edge rather than per net.  Other, un-forced backward
                # edges on the same net are still free to mark it.
                if (u, v, nl) in edge_forced_forward:
                    continue
                if net_conf.get(nl, 'high') == 'high':
                    feedback.add(nl)
                else:
                    reverse_nets.add(nl)
        reverse_nets -= feedback
        for nl in reverse_nets:
            net_drivers[nl], net_loads[nl] = net_loads[nl], net_drivers[nl]

        self._feedback_nets = feedback
        # diagnostic: nets whose driver/load direction was
        # REVERSED (not dropped) for Sugiyama, because the backward edge
        # was low-confidence.  Not consumed elsewhere yet; exposed for
        # inspection/debugging.
        # expose driver/load maps so _rank_place_groups can
        # orient group edges by DIRECT driver→load precedence (an earlier
        # revision
        # made this the sole signal ordering; the old greedy-FAS linear order
        # no longer drives placement).
        _rf = {id(i): i.comp['ref'] for i in instances}
        self._net_flow_refs = {
            nl: ({_rf[u] for u in net_drivers.get(nl, ()) if u in _rf},
                 {_rf[u] for u in net_loads.get(nl, ()) if u in _rf})
            for nl in set(net_drivers) | set(net_loads)}
        return order, feedback

    def _flow_seeds(self, instances, in_nets, out_nets, net_drivers,
                    conf_map, edges):
        """Takes the instances, port nets and the driver map and returns
        (sources, sinks) as id() sets: parts with a pin on an input net, and
        parts driving an output net. With no declared output, the output is
        inferred as the net driven by an intrinsic (high-confidence) output
        pin whose driver has the most parts upstream of it, and recorded in
        _inferred_out_net so the user can see and correct it."""
        ins = {str(n).lower() for n in (in_nets or ())}
        outs = {str(n).lower() for n in (out_nets or ())}
        src, snk = set(), set()
        for inst in instances:
            nets = {str(n).lower() for _p, n in
                    (getattr(inst, '_pin_net_pairs', None) or [])}
            if nets & ins:
                src.add(id(inst))
        drv_all = {}
        for inst in instances:
            cid = id(inst.comp)
            for idx, (_pn, nn) in enumerate(
                    getattr(inst, '_pin_net_pairs', None) or []):
                nl = str(nn).lower()
                if nl in outs:
                    snk.add(id(inst))
                if conf_map.get((cid, idx)) == 'high' and \
                        nl in net_drivers and id(inst) in net_drivers[nl]:
                    drv_all.setdefault(nl, set()).add(id(inst))
        if snk or not drv_all:
            return src, snk
        pred = defaultdict(set)
        for u, v, _n in edges:
            pred[v].add(u)

        def _upstream(k):
            seen, stack = set(), [k]
            while stack:
                for p in pred[stack.pop()]:
                    if p not in seen:
                        seen.add(p)
                        stack.append(p)
            return len(seen - {k})

        best = max(sorted(drv_all),
                   key=lambda nl: max(_upstream(k) for k in drv_all[nl]))
        return src, set(drv_all[best])

    def _flow_report(self, instances, tol=5.0):
        """Takes placed instances and returns (fwd, back, fb_fwd, fb_back,
        worst): driver->receiver pairs whose receiver body centre is right of
        (fwd) or left of (back) the driver's, split by whether the net is
        feedback, plus up to 10 (dx, net, driver, receiver) of the worst
        non-feedback back pairs. A pair within `tol` px counts as forward, so a
        receiver hung directly under its driver is not an inversion."""
        by = {i.comp['ref']: i for i in instances}
        fbn = {str(n).lower() for n in (self._feedback_nets or ())}
        fwd = back = fb_fwd = fb_back = 0
        worst = []
        for nl, (drv, lod) in sorted(
                (getattr(self, '_net_flow_refs', None) or {}).items()):
            for d in sorted(drv):
                for r in sorted(lod - drv):
                    a, b = by.get(d), by.get(r)
                    if a is None or b is None:
                        continue
                    ba, bb = a.abs_sym_body(), b.abs_sym_body()
                    dx = (bb[0] + bb[2]) / 2 - (ba[0] + ba[2]) / 2
                    ok = dx >= -tol
                    if nl in fbn:
                        fb_fwd += ok; fb_back += not ok
                    elif ok:
                        fwd += 1
                    else:
                        back += 1
                        worst.append((dx, nl, d, r))
        worst.sort()
        return fwd, back, fb_fwd, fb_back, worst[:10]

    def _sig_topo_on(self):
        """Is 'Sig Topo' placement mode enabled?

        In   : self._sig_topo (toolbar BooleanVar) if a GUI exists,
               else ON.
        Proc : prefer the toolbar var; fall back to the attribute if it
               is absent or unreadable, so headless runs work.
        Out  : bool, defaulting ON when neither is set.

        The mode drives the Sugiyama placer from the signal topological
        order; with it off, layering keeps every feedback net."""
        var = getattr(self, '_sig_topo', None)
        if var is not None:
            try:
                return bool(var.get())
            except Exception:
                pass
        return True

    def _active_feedback_nets(self):
        """In : self._feedback_nets (auto-detected, recomputed by
               _compute_signal_topo_order on every Place) and the Sig
               Topo mode flag.
        Out : the nets to exclude from the Sugiyama LAYERING; empty when
               Sig Topo is off.
        The renderer never calls this, so a dropped net is still drawn.
        The set is net-level and auto-detected only: a user's own
        feedback override is EDGE-level and is applied where each edge is
        built, since one net can carry several driver->load edges and
        forcing one out must not drop the rest with it."""
        if not self._sig_topo_on():
            return set()
        return getattr(self, '_feedback_nets', None) or set()

    def _feedback_edge_key(self, net_lc, ref_a, ref_b):
        """In : a lower-case net name and the two instance refs the edge
               connects (order irrelevant).
        Proc: pair the net with a frozenset of the refs.
        Out : a hashable key for self._feedback_overrides.
        Three places share this one definition — the double-click that
        sets an override, the flight-line drawing that colors that edge,
        and the layering that includes or excludes it — so a click and
        the graph always name the same edge.  A key holding a single ref
        (a rail-stub click) is legal and matches nothing, rail and power
        nets not being in the graph."""
        return (net_lc, frozenset((ref_a, ref_b)))

    # ── Rail detection ─────────────────────────────────

    # Fanout at which an internal net becomes a rail.  Set by measuring
    # the reference decks: OPAx197's MID net touches ~50 instances and
    # must promote; LP2951's largest internal net touches under 20 and
    # must not, so it gets no rails at all.  Assign to the attribute to
    # retune for a particular deck.
    _RAIL_PROMOTION_THRESHOLD = 20

    def _detect_promoted_rails(self, instances, in_nets, out_nets):
        """In : the instance list and the SUBCKT's input and output nets.
        Proc: count DISTINCT instances touching each net, skipping power,
               ground and the SUBCKT's own IO nets, and keep the nets at
               or above _RAIL_PROMOTION_THRESHOLD.  An instance touching
               one net on several pins counts once.
        Out : lower-case net names; the caller stores them on
               self._promoted_rails, where every placement pass reads it.
        A promoted net is drawn as a vertical bus line rather than as
        pin-to-pin flight lines."""
        excluded = set(_PWR_NETS_LC_FOR_T) | set(in_nets) | set(out_nets)
        net_to_inst_ids = {}
        for inst in instances:
            seen_this_inst = set()
            for n in inst.comp.get('nets', []):
                nl = n.lower()
                if nl in excluded or nl in seen_this_inst:
                    continue
                seen_this_inst.add(nl)
                net_to_inst_ids.setdefault(nl, set()).add(id(inst))
        rails = {nl for nl, ids in net_to_inst_ids.items()
                 if len(ids) >= self._RAIL_PROMOTION_THRESHOLD}
        return rails

    # ── Pass 1a-NS: network-simplex column re-assignment ─

    # Anti-cycling safety cap: the loop below runs at most this many
    # times |E|.  Per the GKNV-1993 paper the simplex rarely needs more
    # than a few iterations and |E| covers any practical graph, so 4|E|
    # is a ceiling that should never be reached.
    _NS_MAX_ITERS_FACTOR = 4

    def _network_simplex_core(self, node_ids, edges, init_cols):
        """In : node_ids, (tail, head, weight) edges with tail west of
               head and minimum length 1, and a feasible {node: column}.
        Proc: repeatedly apply the single best improving move — slide a
               node right when its outgoing weight exceeds its incoming,
               left when the reverse, by the whole slack its tightest
               edge allows — until no move gains or the cap trips, let
               _network_simplex_balance_core settle what the move rule
               cannot choose for, then squeeze out any empty column.
        Out : {node: column}, contiguous 1..K.  Opaque node keys, so the
               same code ranks instances and PlaceNodes/clusters."""
        nodes = list(node_ids)
        if not nodes:
            return dict(init_cols)
        cols = dict(init_cols)

        # Adjacency: node -> list of (other, weight, direction)
        # direction +1 = outgoing (node is tail), -1 = incoming.
        adj = {n: [] for n in nodes}
        for u, v, w in edges:
            if u == v:
                continue
            adj[u].append((v, w, +1))
            adj[v].append((u, w, -1))

        n_edges = sum(len(a) for a in adj.values()) // 2
        max_iters = self._NS_MAX_ITERS_FACTOR * max(1, n_edges)
        for _iter in range(max_iters):
            best_gain = 0.0
            best_move = None
            for nid in nodes:
                w_in = sum(w for (_o, w, d) in adj[nid] if d == -1)
                w_out = sum(w for (_o, w, d) in adj[nid] if d == +1)
                if w_out > w_in:
                    max_shift = float('inf')
                    for (other, _w, d) in adj[nid]:
                        if d != +1:
                            continue
                        slack = cols[other] - cols[nid] - 1
                        if slack < max_shift:
                            max_shift = slack
                    if max_shift >= 1:
                        gain = (w_out - w_in) * max_shift
                        if gain > best_gain:
                            best_gain = gain
                            best_move = (nid, +int(max_shift))
                elif w_in > w_out:
                    max_shift = float('inf')
                    for (other, _w, d) in adj[nid]:
                        if d != -1:
                            continue
                        slack = cols[nid] - cols[other] - 1
                        if slack < max_shift:
                            max_shift = slack
                    if max_shift >= 1:
                        gain = (w_in - w_out) * max_shift
                        if gain > best_gain:
                            best_gain = gain
                            best_move = (nid, -int(max_shift))
            if best_move is None:
                break
            nid, delta = best_move
            cols[nid] += delta

        self._network_simplex_balance_core(nodes, adj, cols)

        used_cols = sorted(set(cols.values()))
        compact = {c: i + 1 for i, c in enumerate(used_cols)}
        return {nid: compact[c] for nid, c in cols.items()}

    @staticmethod
    def _network_simplex_balance_core(nodes, adj, cols):
        """In : nodes, the adjacency built by _network_simplex_core, and
               the current {node: column} map.
        Proc: for each node whose incoming and outgoing weights are
               EQUAL — total edge length is then the same anywhere in its
               feasible range, so the objective has no opinion — find
               that range and move it to the emptiest column in it.
        Out : none; `cols` is mutated in place.
        Emptier columns win because a packed column is a tall one, and
        vertical span inside a rank is what drives flight-line length:
        pulling these toward their neighbors instead cost OPAx197 16%."""
        col_counts = Counter(cols.values())
        for nid in nodes:
            w_in = sum(w for (_o, w, d) in adj[nid] if d == -1)
            w_out = sum(w for (_o, w, d) in adj[nid] if d == +1)
            if w_in != w_out:
                continue
            min_col = 1
            max_col = None
            for (other, _w, d) in adj[nid]:
                if d == -1:
                    c = cols[other] + 1
                    if c > min_col:
                        min_col = c
                else:
                    c = cols[other] - 1
                    if max_col is None or c < max_col:
                        max_col = c
            if max_col is None:
                max_col = max(cols.values())
            if max_col <= min_col:
                continue
            cur_col = cols[nid]
            best_col = cur_col
            best_pop = col_counts[cur_col] - 1
            for c in range(min_col, max_col + 1):
                pop = col_counts[c] - (1 if c == cur_col else 0)
                if pop < best_pop:
                    best_pop = pop
                    best_col = c
            if best_col != cur_col:
                col_counts[cur_col] -= 1
                col_counts[best_col] += 1
                cols[nid] = best_col

    def _compute_series_chains(self, instances):
        """In : the instance list.
        Proc: keep 2-pin R/C/L/I parts on two distinct nets, find the nets
               linking exactly two of them and nothing else — degree
               counted over ALL instances, so a net that also reaches a
               transistor is not a link — and walk those links from each
               chain end.
        Out : the chains, each a list of CompInstance in connection order;
               only lengths 2-3 are returned.
        Short chains give most of the benefit, and a long one starts to
        fight the cluster crossing-reduction."""
        SERIES_KINDS = ('R', 'C', 'L', 'I')
        two_pin = [i for i in instances
                   if i.comp.get('kind', '').upper() in SERIES_KINDS
                   and len((i.comp.get('nets', []) or [])) == 2
                   and len({n.lower()
                            for n in i.comp.get('nets', [])}) == 2]
        # net → list of (inst) for the candidate parts only.  But the
        # degree must be measured over ALL instances, not just RCLI,
        # so a node touching a transistor isn't mistaken for degree-2.
        net_all_pins = defaultdict(int)
        for inst in instances:
            for nn in (inst.comp.get('nets', []) or []):
                net_all_pins[nn.lower()] += 1
        net_to_parts = defaultdict(list)
        for inst in two_pin:
            for nn in inst.comp.get('nets', []):
                net_to_parts[nn.lower()].append(inst)

        # A net is a "series link" iff it has total degree 2 AND both
        # those pins are on candidate RCLI parts, and it is not power/
        # ground (a power/ground node is never an internal link).
        link_nets = {}
        for nl, parts in net_to_parts.items():
            if (net_all_pins.get(nl, 0) == 2 and len(parts) == 2
                    and nl not in _PWR_NETS_LC_FOR_T):
                link_nets[nl] = parts

        # Build adjacency among parts via link nets, then extract
        # connected paths.  Each part has at most 2 link-net neighbors
        # (it has 2 pins), so components are simple paths/cycles.
        adj = defaultdict(list)
        for _nl, (a, b) in link_nets.items():
            adj[id(a)].append((id(b), a, b))
            adj[id(b)].append((id(a), b, a))
        by_id = {id(i): i for i in two_pin}

        seen = set()
        chains = []
        for inst in two_pin:
            if id(inst) in seen or len(adj[id(inst)]) > 1:
                continue   # start only from a chain END (degree<=1)
            # Walk from this endpoint.
            chain = [inst]
            seen.add(id(inst))
            prev = None
            cur = inst
            while True:
                nxt = [t for t in adj[id(cur)] if t[0] != id(prev)]
                if not nxt:
                    break
                nbr_id = nxt[0][0]
                if nbr_id in seen:
                    break
                prev, cur = cur, by_id[nbr_id]
                chain.append(cur)
                seen.add(nbr_id)
            if len(chain) >= 2:
                chains.append(chain)
        # Any remaining unseen parts that are in cycles or interior —
        # ignore (rare).  Keep only short chains (2-3).
        return [c for c in chains if 2 <= len(c) <= 3]

    def _align_series_chains(self, instances, positions):
        """In : instances, positions, chains from _compute_series_chains.
        Proc: lay each chain's members side by side on a common baseline
               y, so every shared node is a short horizontal stub.  While
               _ROTATION_RULES_ONLY holds this is POSITION ONLY, rules
               1-3 having decided the rotations; with the rules off it
               turns members horizontal, except one pinned to real
               power/ground, left vertical so a decoupling cap hangs off
               the chain.  A user-rotated or cell-frozen member skips it.
        Out : none; mutates ox_px/oy_px, rotation_deg, _auto_rotations,
               positions."""
        chains = self._compute_series_chains(instances)
        if not chains:
            return
        # Refs already placed and oriented as a cached diff-pair /
        # SP-block cell are FROZEN: re-laying out a chain that touches
        # one would rotate a member out of its cell and break it.
        frozen = getattr(self, '_pattern_oriented', set())
        SPACING = max(60, int(_GRID_PITCH * 1.1))
        for chain in chains:
            # Skip if any member carries a genuine user rotation — don't
            # override an explicit choice.
            if any(c.comp['ref'] in self._user_rotations for c in chain):
                continue
            if any(c.comp['ref'] in frozen for c in chain):
                continue
            # Baseline y = the median of members' current y (keeps the
            # chain near where the layout already wanted it).
            ys = sorted(positions[id(c)][1] for c in chain)
            base_y = ys[len(ys) // 2]
            # Order left-to-right by current x, then re-space tightly.
            ordered = sorted(chain, key=lambda c: positions[id(c)][0])
            x0 = min(positions[id(c)][0] for c in chain)
            # Propose, check, then commit: re-spacing and rotations are tested
            # against everything outside the chain before they touch positions.
            prop_pos = {}
            prop_rot = {}
            for k, inst in enumerate(ordered):
                if not self._ROTATION_RULES_ONLY:
                    nets_lc = [n.lower()
                               for n in (inst.comp.get('nets', []) or [])]
                    on_power = any(n in _PWR_NETS_LC_FOR_T
                                   for n in nets_lc)
                    if (not on_power
                            and inst.rotation_deg not in (90, 270)):
                        # Vertical members keep their power/ground-aware
                        # rotation (a decoupling cap hangs off the
                        # chain).
                        prop_rot[id(inst)] = 90
                prop_pos[id(inst)] = (x0 + k * SPACING, base_y)
            if not self._chain_alignment_is_clear(instances, positions,
                                                  prop_pos, prop_rot):
                continue
            for inst in ordered:
                if id(inst) in prop_rot:
                    inst.rotation_deg = prop_rot[id(inst)]
                    self._auto_rotations[inst.comp['ref']] = \
                        prop_rot[id(inst)]
                positions[id(inst)] = prop_pos[id(inst)]

    def _chain_alignment_is_clear(self, instances, positions,
                                  prop_pos, prop_rot):
        """In : the instance list, the current positions, and the proposed
               prop_pos and prop_rot for a chain's members.
        Proc: apply the proposed rotations temporarily — a member is
               usually re-rotated to horizontal, which changes its
               extent, so the pre-rotation box would answer the wrong
               question — then measure the same reserved box the packer
               reserved (_placement_extent) and test every pair with
               _boxes_clash.  Rotations are restored either way.
        Out : True when no pair clashes."""
        saved = {}
        for inst in instances:
            if id(inst) in prop_rot:
                saved[id(inst)] = inst.rotation_deg
                inst.rotation_deg = prop_rot[id(inst)]
        try:
            boxes = []
            for inst in instances:
                xy = prop_pos.get(id(inst)) or positions.get(id(inst))
                if xy is None:
                    continue
                e = self._placement_extent(inst)
                boxes.append((inst.comp['ref'],
                              (xy[0] + e[0], xy[1] + e[1],
                               xy[0] + e[2], xy[1] + e[3])))
            moved = {id_ for id_ in prop_pos}
            movers = {inst.comp['ref'] for inst in instances
                      if id(inst) in moved}
            for i in range(len(boxes)):
                for j in range(i + 1, len(boxes)):
                    if (boxes[i][0] not in movers
                            and boxes[j][0] not in movers):
                        continue      # a pre-existing clash is not ours
                    if self._boxes_clash(boxes[i][1], boxes[j][1]):
                        return False
            return True
        finally:
            for inst in instances:
                if id(inst) in saved:
                    inst.rotation_deg = saved[id(inst)]

    # ── Default rotation per signal-flow context ────────────────────────

    # True: only _preplace_orientations sets an orientation, so what Sugiyama/BK
    # measures is what is drawn.  False re-enables the coordinate-based rotation
    # passes (_flip_cost's rank term, lane mirror 90/270,
    # _reduce_rotation_crossings, _metric_driven_rotation_touchup,
    # chain-alignment rotations, 90/270 in _uncross_pass), which is why the flag
    # is kept.
    _ROTATION_RULES_ONLY = True

    def _has_rail_pin(self, inst):
        """Does this part have a pin on a supply or ground net?

        In   : the instance; the rail polarity from _rail_polarity.
        Proc : test each pin's net against ground, the negative supply
               and the positive supply — the same test rules 1 and 2 use
               to choose a rotation.
        Out  : True when any pin sits on a rail.

        Such a part's rotation carries the supply direction, so later
        passes leave it alone rather than re-decide it."""
        pos_net, neg_net = self._rail_polarity()
        for _pn, net in (getattr(inst, '_pin_net_pairs', None) or []):
            nl = str(net).lower()
            if (nl == '0' or nl in _GND_NETS_LC or nl in _VCC_NETS_LC
                    or (neg_net is not None and nl == neg_net)
                    or (pos_net is not None and nl == pos_net)):
                return True
        return False

    def _finalize_instance_geometry(self, instances):
        """Build each instance once at its final orientation: apply the decided
        rotation, place its labels against a private QuadTree and recompute
        composite_rel.
        """
        for inst in instances:
            ref = inst.comp['ref']
            deg = self._user_rotations.get(
                ref, self._auto_rotations.get(ref, inst.rotation_deg or 0))
            try:
                self._apply_instance_rotation_geometry(inst, (deg or 0) % 360)
                # The instance's own rotation_deg is only the truth AFTER
                # this call.  _placement_extent reads the flag to choose
                # between the instance and the rotation maps; before it,
                # a P2DL member still carries 0 while the maps already
                # hold the cell's rotation.
                inst._geom_final = True
            except Exception:
                pass
            try:
                inst.place_texts(QuadTree(-200000, -200000, 200000, 200000))
                inst._recompute_composite_rel()
            except Exception:
                pass

    def _io_side_mirror(self, inst, io_in_nets, io_out_nets):
        """In : a 2-pin instance whose rotation is settled, plus the
        declared .SUBCKT input and output net sets.  Out: True when it
        mirrored the part.
        If the part lies HORIZONTAL and a pin carries a port net, both
        mirrors are scored by "input pin leftmost, output pin rightmost"
        and the better kept.  A mirror preserves the bounding box, so it
        is legal on a cell member and cannot stand a horizontal part up.
        The sides come from the .SUBCKT line, not _compute_pin_role_map,
        which returns None for both pins of the parts needing this most.
        Kept out of the 8-state score, where the vertical states win."""
        pairs = getattr(inst, '_pin_net_pairs', None) or []
        if len(pairs) != 2:
            return False
        ref = inst.comp['ref']
        deg = (inst.rotation_deg or 0) % 360
        flip = bool(self._auto_flips.get(ref, False))

        def _score(fl):
            """Lower is better: an input pin adds its x offset, an
            output subtracts it, so the minimum puts inputs left."""
            try:
                _bb, offs = self._rotated_pins_by_num(inst, deg, fl)
            except Exception:
                return None
            total = 0.0
            for pn, net in pairs:
                off = offs.get(pn)
                if off is None:
                    return None
                nl = str(net).lower()
                if nl in io_in_nets:
                    total += off[0]
                elif nl in io_out_nets:
                    total -= off[0]
            return total

        try:
            _bb, offs = self._rotated_pins_by_num(inst, deg, flip)
            o0, o1 = offs.get(pairs[0][0]), offs.get(pairs[1][0])
        except Exception:
            return False
        if o0 is None or o1 is None:
            return False
        if abs(o0[0] - o1[0]) <= abs(o0[1] - o1[1]):
            return False
        cur, alt = _score(flip), _score(not flip)
        if cur is None or alt is None:
            return False
        # This part has a declared port pin and lies horizontal, so the
        # rule HAS an opinion about its mirror — record that even when
        # the current mirror already satisfies it.  _uncross_pass reads
        # this set and leaves these parts' mirrors alone, which is how
        # a port-side decision survives without having to outbid a
        # crossing.  Recorded on opinion, not on change, because the
        # already-correct case is exactly the one that used to get
        # mirrored away.
        if cur != alt:
            self._port_side_locked.add(ref)
        if alt >= cur:
            return False
        if flip:
            self._auto_flips.pop(ref, None)
        else:
            self._auto_flips[ref] = True
        return True

    def _preplace_axis(self, instances):
        """Decide the axis of every 2-pin passive from the netlist: a rail-tied
        R/C/L stands vertical; one in the signal path lies horizontal.
        """
        pos_rail, neg_rail = self._rail_polarity()
        rails = (set(_PWR_NETS_LC_FOR_T) | set(self._promoted_rails)
                 | {r for r in (pos_rail, neg_rail) if r})
        for inst in instances:
            ref = inst.comp['ref']
            if ref in self._user_rotations or ref in self._user_flips:
                continue
            if inst.comp.get('kind', '') not in ROTATABLE_2PIN_KINDS:
                continue
            pairs = getattr(inst, '_pin_net_pairs', None) or []
            if len(pairs) != 2:
                continue
            on_rail = any(str(net).lower() in rails for _pn, net in pairs)
            # ASK THE SYMBOL WHICH ROTATION IS VERTICAL.  Rotation 0 is
            # not the same axis for every part: a KiCad resistor at 0 has
            # its pins one above the other, a DIODE at 0 has them side by
            # side.  Assuming 0 == vertical left LM324.lib's DP lying
            # horizontal between nets 3 (+power) and 4 (ground) while RP,
            # on the very same two nets, stood upright.  Measure the pins
            # at each candidate rotation and pick the one that actually
            # gives the axis wanted.
            want_v = on_rail
            best = None
            for cand in (0, 90):
                try:
                    saved = inst.rotation_deg
                    inst.rotation_deg = cand
                    self._finalize_instance_geometry([inst])
                    p = [_pin_canvas_pos(inst, pn) for pn, _n in pairs]
                    inst.rotation_deg = saved
                    if p[0] is None or p[1] is None:
                        continue
                    is_v = abs(p[0][1] - p[1][1]) > abs(p[0][0] - p[1][0])
                except Exception:
                    continue
                if is_v == want_v:
                    best = cand
                    break
            new_deg = best if best is not None else (0 if on_rail else 90)
            if new_deg != (inst.rotation_deg or 0) % 360:
                inst.rotation_deg = new_deg
                self._auto_rotations[ref] = new_deg
            self._orient_class[ref] = 'V' if on_rail else 'H'
        # REBUILD, DO NOT JUST RE-ANGLE.  rotation_deg is a number; the
        # boxes P2DL packs against come from sym_body_rel and the text
        # layout, and neither follows until the geometry is rebuilt.
        # Setting the angle alone let every cell pack a member using its
        # pre-rotation box: OPAx197 came back with 23 composite overlaps,
        # all of them BETWEEN MEMBERS OF ONE CELL (R_R66 x C_C20 x
        # R_R67).  With the rebuild those overlaps are gone.
        self._finalize_instance_geometry(instances)

    def _preplace_orientations(self, instances, pin_role_map=None):
        """In : the instances, rail polarity, pin->net pairs, per-pin roles
        and the user's rotations and flips.  Out: inst.rotation_deg,
        _auto_rotations and _auto_flips, set from the NETLIST alone by ONE
        rule scored lexicographically over all eight orientations.
        PRIMARY: rail score — +supply pins up, ground and -supply down.
        SECONDARY: inputs leftmost, outputs rightmost by summed pin offset,
        rail pins excluded, as the role map calls every rail pin an input.
        Ties: lowest rotation, then no flip; nothing to score goes
        horizontal.  A user-turned or P2DL-frozen part is left alone, a
        frozen one only mirrored.  Runs before _member_local_extent."""
        frozen = getattr(self, '_pattern_oriented', set()) or set()
        for members in _stable_blocks(getattr(self, '_sp_block_layout', {})):
            if len(set(members)) >= 2:
                frozen = frozen | set(members)
        pos_net, neg_net = self._rail_polarity()
        roles = pin_role_map or getattr(self, '_pin_role_map', None) or {}

        south = self._south_rails()
        states = {}        # ref -> [(deg, flip, rail, side)] for every
                           # orientation, for _parallel_rotation_consensus

        def is_neg(nl):
            return (nl == '0' or nl in _GND_NETS_LC or nl in south
                    or (neg_net is not None and nl == neg_net))

        def is_pos(nl):
            return (nl in _VCC_NETS_LC
                    or (pos_net is not None and nl == pos_net))

        def _rail_score(inst, pairs, want, deg, flip):
            """How well orientation (deg, flip) satisfies rules 1+2.

            Higher is better: y grows downward, so a pin that wants
            DOWN adds its y and one that wants UP subtracts it.  Offsets
            are looked up by PIN NUMBER (_rotated_pins_by_num), never by
            the pair index — see that helper for the bug this avoids."""
            try:
                _b, offs = self._rotated_pins_by_num(inst, deg, flip)
            except Exception:
                return None
            score = 0.0
            for k, (dn, up) in enumerate(want):
                if not (dn or up):
                    continue
                off = offs.get(pairs[k][0])
                if off is None:
                    continue
                score += off[1] if dn else -off[1]
            return score

        def _side_score(inst, pairs, sides, deg, flip):
            """How well orientation (deg, flip) puts inputs left.

            LOWER is better: inputs leftmost means most negative x, so a
            pin marked 'in' adds its x and one marked 'out' subtracts
            it.  Summed OFFSETS rather than a pin count, so a part with
            one input against three outputs scores sensibly and the
            orientation that separates them FURTHER wins over one that
            merely gets the count right.  Same pin-number lookup as
            _rail_score."""
            try:
                _b, offs = self._rotated_pins_by_num(inst, deg, flip)
            except Exception:
                return None
            score = 0.0
            for k, r in sides:
                off = offs.get(pairs[k][0])
                if off is None:
                    continue
                score += off[0] if r == 'in' else -off[0]
            return score

        for inst in instances:
            ref = inst.comp['ref']
            if ref in self._user_rotations or ref in self._user_flips:
                continue
            if ref in frozen:
                # A pattern cell owns its members' rotation, but a vertical
                # mirror is still allowed so a rail pin can go up (+) or down
                # (ground, -).
                pairs = getattr(inst, '_pin_net_pairs', None) or []
                if not pairs:
                    continue
                want = [(is_neg(str(net).lower()), is_pos(str(net).lower()))
                        for _pn, net in pairs]
                if not any(dn or up for dn, up in want):
                    continue
                cur = (inst.rotation_deg or 0) % 360
                cur_flip = bool(self._auto_flips.get(ref, False))
                s_now = _rail_score(inst, pairs, want, cur, cur_flip)
                s_vm = _rail_score(inst, pairs, want, (cur + 180) % 360,
                                   not cur_flip)
                if s_now is not None and s_vm is not None and s_vm > s_now:
                    self._auto_rotations[ref] = (cur + 180) % 360
                    if not cur_flip:
                        self._auto_flips[ref] = True
                    else:
                        self._auto_flips.pop(ref, None)
                    try:
                        self._apply_instance_rotation_geometry(
                            inst, (cur + 180) % 360)
                    except Exception:
                        pass
                continue
            pairs = getattr(inst, '_pin_net_pairs', None) or []
            if not pairs:
                continue
            cur = (inst.rotation_deg or 0) % 360
            want = [(is_neg(str(net).lower()), is_pos(str(net).lower()))
                    for _pn, net in pairs]

            # The signal pins the secondary key scores.  Rail pins are
            # excluded: _compute_pin_role_map calls every rail pin an
            # input, which would drag a ground pin leftward and fight
            # the rail term.
            sides = []
            for k, (_pn, _net) in enumerate(pairs):
                if want[k][0] or want[k][1]:
                    continue
                # _compute_pin_role_map keys on (id(comp), pin index).
                r = roles.get((id(inst.comp), k))
                if r in ('in', 'out'):
                    sides.append((k, r))
            has_rail = any(dn or up for dn, up in want)
            has_sides = (any(r == 'in' for _k, r in sides)
                         and any(r == 'out' for _k, r in sides))

            if not (has_rail or has_sides):
                # Nothing to score in either key.  A 2-pin part still
                # gets laid down — that is the old rule 3, kept only for
                # this case, since a part WITH roles now reaches
                # horizontal through the score itself.  270 is
                # horizontal too, so a part already there is left alone.
                # Anything else keeps the rotation and mirror it has.
                if len(pairs) != 2:
                    continue
                new_deg = cur if cur in (90, 270) else 90
                new_flip = bool(self._auto_flips.get(ref, False))
                states[ref] = [(d, f, 0.0, 0.0) for d in (0, 90, 180, 270)
                               for f in (False, True)]
            else:
                # The single rule: all eight states, rail score first
                # and side score second.  `>` is strict and the loops
                # run low rotation first, no-flip first, so the stated
                # tie-break falls out of the iteration order rather than
                # needing its own branch.
                best = None
                for deg in (0, 90, 180, 270):
                    for flip in (False, True):
                        rs = _rail_score(inst, pairs, want, deg, flip)
                        ss = _side_score(inst, pairs, sides, deg, flip)
                        if rs is None or ss is None:
                            continue
                        key = (rs, -ss)
                        states.setdefault(ref, []).append((deg, flip, rs,
                                                           ss))
                        if best is None or key > best[0]:
                            best = (key, deg, flip)
                if best is None:
                    continue
                _key, new_deg, new_flip = best

            if new_deg != cur:
                inst.rotation_deg = new_deg
                self._auto_rotations[ref] = new_deg
            if new_flip:
                self._auto_flips[ref] = True
            else:
                self._auto_flips.pop(ref, None)
            # Record the axis for EVERY part, moved or not.  The
            # lane-level flip pass skips any part it cannot classify,
            # and _signal_flow_rotations — the only other writer — is
            # off, so without this that pass sees nothing at all.
            self._orient_class[ref] = 'V' if new_deg % 180 == 0 else 'H'
        self._source_leg_orient(instances, frozen,
                                lambda nl: is_neg(nl) or is_pos(nl))
        self._parallel_rotation_consensus(instances, states, frozen)

    def _source_leg_orient(self, instances, frozen, is_rail):
        """In : the instances, the P2DL-frozen refs and a rail test.
        Proc: a free R, C or L on the signal net of a SENSED 2-pin source
              (an ammeter) whose other pin is a rail stands upright, its
              pin on that net at the BOTTOM, so the leg rises off the
              source's top.
              Applied to every rail-fed V source it cost OPAx197 8
              crossings; LM324.lib's VB is an ammeter FB senses.
        Out : nothing; writes rotation_deg, _auto_rotations, _auto_flips
              and _orient_class."""
        sensed = {str(x).lower() for i in instances
                  for x in (i.comp.get('sense_srcs') or [])}
        sig = set()
        for inst in instances:
            pp = inst._pin_net_pairs or []
            if (_ref_kind(inst.comp['ref']) in 'VI' and len(pp) == 2
                    and inst.comp['ref'].lower() in sensed):
                ns = [str(n).lower() for _p, n in pp]
                if is_rail(ns[0]) != is_rail(ns[1]):
                    sig.add(ns[1] if is_rail(ns[0]) else ns[0])
        for inst in instances:
            ref = inst.comp['ref']
            pp = inst._pin_net_pairs or []
            if (_ref_kind(ref) not in 'RCL' or len(pp) != 2 or ref in frozen
                    or ref in self._user_rotations
                    or ref in self._user_flips):
                continue
            ns = {str(n).lower(): p for p, n in pp}
            if any(is_rail(n) for n in ns):
                continue
            net = next((n for n in sorted(sig) if n in ns), None)
            if net is None:
                continue
            other = [p for n, p in ns.items() if n != net][0]
            for d, f in ((0, False), (180, False), (0, True), (180, True)):
                try:
                    _b, offs = self._rotated_pins_by_num(inst, d, f)
                except Exception:
                    break
                lo, hi = offs.get(ns[net]), offs.get(other)
                if lo is None or hi is None:
                    break
                if lo[1] > hi[1] and abs(lo[0] - hi[0]) < 1.0:
                    inst.rotation_deg = d
                    self._auto_rotations[ref] = d
                    if f:
                        self._auto_flips[ref] = True
                    else:
                        self._auto_flips.pop(ref, None)
                    self._orient_class[ref] = 'V'
                    break

    def _parallel_rotation_consensus(self, instances, states, frozen):
        """In : the instances, every orientation _preplace_orientations
              scored as {ref: [(deg, flip, rail, side)]}, frozen P2DL refs.
        Proc: within a set of 2-pin parts sharing a terminal pair, vote on
              the AXIS the pins run along, not the angle, since a source
              upright at 0 and a resistor at 90 are opposite axes.  An
              axis is allowed only where every member reaches it at its
              best rail score and a frozen or user-turned member lies
              along it; fewest turns wins, then side, then vertical.
        Out : count turned; writes rotation_deg, _auto_rotations,
              _auto_flips and _orient_class."""
        sets = defaultdict(list)
        for inst in instances:
            pn = getattr(inst, '_pin_net_pairs', None) or []
            if len(pn) != 2:
                continue
            for key in _terminal_pairs(inst.comp, pn):
                sets[key].append(inst)

        def axis(inst, deg, flip):
            try:
                _b, offs = self._rotated_pins_by_num(inst, deg, flip)
            except Exception:
                return None
            pts = [offs.get(p) for p, _n in inst._pin_net_pairs]
            if len(pts) != 2 or None in pts:
                return None
            return ('V' if abs(pts[0][1] - pts[1][1])
                    > abs(pts[0][0] - pts[1][0]) else 'H')

        turned = 0
        for key in sorted(sets, key=lambda k: sorted(map(str, k))):
            members = sorted(sets[key], key=lambda m: m.comp['ref'])
            if len(members) < 2:
                continue
            best_on = {}          # ref -> {axis: best (rail, -side, state)}
            now = {}
            for inst in members:
                ref = inst.comp['ref']
                cur = ((inst.rotation_deg or 0) % 360,
                       bool(self._auto_flips.get(ref, False)))
                now[ref] = axis(inst, *cur)
                if ref in frozen or ref in self._user_rotations:
                    continue
                sts = states.get(ref) or []
                if not sts:
                    continue
                top = max(st[2] for st in sts)
                for st in sts:
                    if st[2] < top:
                        continue            # never trade the rail rule
                    a = axis(inst, st[0], st[1])
                    k = (-st[3], -st[0], not st[1])
                    if a and (a not in best_on.setdefault(ref, {})
                              or k > best_on[ref][a][0]):
                        best_on[ref][a] = (k, st)
            choice = None
            for a in ('V', 'H'):
                turn, cost, ok = 0, 0.0, True
                for inst in members:
                    ref = inst.comp['ref']
                    if ref not in best_on:
                        ok = ok and now[ref] == a
                        continue
                    if a not in best_on[ref]:
                        ok = False
                        continue
                    if now[ref] != a:
                        turn += 1
                    cost -= best_on[ref][a][0][0]
                if ok and (choice is None
                           or (turn, cost) < (choice[1], choice[2])):
                    choice = (a, turn, cost)
            if choice is None or not choice[1]:
                continue
            for inst in members:
                ref = inst.comp['ref']
                if ref not in best_on or now[ref] == choice[0]:
                    continue
                deg, flip = best_on[ref][choice[0]][1][:2]
                try:
                    self._apply_instance_rotation_geometry(inst, deg)
                except Exception:
                    continue
                inst.rotation_deg = deg
                self._auto_rotations[ref] = deg
                if flip:
                    self._auto_flips[ref] = True
                else:
                    self._auto_flips.pop(ref, None)
                self._orient_class[ref] = 'V' if deg % 180 == 0 else 'H'
                turned += 1
        return turned

    def _sync_auto_rotations(self, instances):
        """In : the placement instance list, _auto_rotations and
               _user_rotations.
        Proc: for each ref WITHOUT a user rotation, write the geometry's
               rotation_deg into _auto_rotations, dropping the entry when
               the part is upright.
        Out : the refs whose entry changed, for diagnostics.
        Render rebuilds its own instances from these maps and cannot read
        placement's objects, so a pass that turns a part without writing
        the map leaves it drawn at one angle but reserved, measured and
        routed at another.  A user rotation is never touched."""
        fixed = []
        for inst in instances or []:
            ref = inst.comp['ref']
            if ref in self._user_rotations:
                continue
            actual = (getattr(inst, 'rotation_deg', None) or 0) % 360
            if self._auto_rotations.get(ref, 0) % 360 == actual:
                continue
            if actual:
                self._auto_rotations[ref] = actual
            else:
                self._auto_rotations.pop(ref, None)
            fixed.append(ref)
        return fixed

    def _apply_instance_rotation_geometry(self, inst, abs_deg):
        """In : an instance and an absolute angle; the base library symbol
               and the ref's entry in _user_flips / _auto_flips.
        Proc: run the EXACT recompute _render runs, in _render's order —
               rotate the base sym_entry, mirror it if the ref is
               flipped, recompute sym_scale and mid_kx/mid_ky from the
               rotated bbox, rebuild sym_body_rel, then re-run build() so
               every label candidate regenerates against the new body.
        Out : none; the instance's geometry and text items are updated.
        Matching _render is the point: _pin_canvas_pos then reports the
        pins where the glyph is drawn.  Right-click rotate takes this."""
        base = self.sym_lib.get(inst.comp['sym']) or {'shapes': [],
                                                       'pins': {}}
        if abs_deg % 360:
            re = _rotated_sym_entry(base, abs_deg % 360)
        else:
            re = base
        flipped = self._user_flips.get(
            inst.comp['ref'], self._auto_flips.get(inst.comp['ref'], False))
        if flipped:
            re = _mirrored_sym_entry(re)
        inst.sym_entry = re
        inst.rotation_deg = abs_deg % 360
        shapes = re.get('shapes', [])
        if shapes:
            bb = _bbox_of_shapes(shapes)
            bw = max(bb[2] - bb[0], 0.001)
            bh = max(bb[3] - bb[1], 0.001)
            inst.sym_scale = min((CELL_W_MM - 2) / bw,
                                 (CELL_H_MM - 2) / bh) * 0.82 * SCALE
            inst.mid_kx = (bb[0] + bb[2]) / 2
            inst.mid_ky = (bb[1] + bb[3]) / 2
        else:
            inst.sym_scale, inst.mid_kx, inst.mid_ky = SCALE, 0, 0
        # sym_body_rel is recomputed from the ROTATED geometry using the
        # SAME transform the renderer and kicad_rel use: subtract
        # mid_kx/mid_ky, scale by sym_scale, flip y.  This is THE single
        # source of body geometry, so abs_sym_body() matches the drawn
        # glyph at every rotation and flip; build()'s initial estimate
        # describes the pre-rotation state only.
        inst.sym_body_rel = self._true_sym_body_rel(inst)
        # Rotation moves text without rotating it, so rebuild the label
        # candidates against the rotated body.
        pnp = getattr(inst, '_pin_net_pairs', None)
        if pnp is not None:
            try:
                body_rel_keep = inst.sym_body_rel
                # pass the GLOBAL suppression set so this
                # rotation-time rebuild keeps the SAME text-item set the
                # initial build used (it is NOT label-neutral without it:
                # default multi_pin_nets=∅ regenerates suppressed per-pin net
                # labels, leaking a richer text set into the stored layout and
                # causing the placement-vs-render mismatch).
                inst.build(pnp, fulltext=self._effective_fulltext(
                    inst.comp['ref']),
                    multi_pin_nets=getattr(self, '_suppressed_nets', None)
                    or frozenset())
                inst.sym_body_rel = body_rel_keep   # keep the true rotated body
            except Exception:
                pass

    def _rotated_body_and_pins(self, inst, deg, flip):
        """Predict an instance's body bbox + pin offsets AS
        THEY WILL BE after rotation `deg` (+flip), WITHOUT mutating it.
        Pure: reads only self.sym_lib + the passed deg/flip.  Lets the
        Sugiyama separation measure the TRUE rotated footprint at layout
        time (instances rotate only at _render)."""
        base = self.sym_lib.get(inst.comp['sym']) or {'shapes': [],
                                                       'pins': {}}
        re = _rotated_sym_entry(base, deg % 360) if (deg % 360) else base
        if flip:
            re = _mirrored_sym_entry(re)
        shapes = re.get('shapes', [])
        if shapes:
            bb = _bbox_of_shapes(shapes)
            bw = max(bb[2] - bb[0], 0.001)
            bh = max(bb[3] - bb[1], 0.001)
            ss = min((CELL_W_MM - 2) / bw,
                     (CELL_H_MM - 2) / bh) * 0.82 * SCALE
            mkx = (bb[0] + bb[2]) / 2
            mky = (bb[1] + bb[3]) / 2
        else:
            ss, mkx, mky = SCALE, 0, 0

        def krel(kx, ky):
            return ((kx - mkx) * ss, -(ky - mky) * ss)
        no_pin = [s for s in shapes if s['kind'] != 'pin']
        pin10 = [{**s, 'length': s['length'] * 0.10}
                 for s in shapes if s['kind'] == 'pin']
        body_sh = no_pin + pin10
        if body_sh:
            bbb = _bbox_of_shapes(body_sh)
            c0 = krel(bbb[0], bbb[1]); c1 = krel(bbb[2], bbb[3])
            body_bb = (min(c0[0], c1[0]), min(c0[1], c1[1]),
                       max(c0[0], c1[0]), max(c0[1], c1[1]))
        else:
            hw = CELL_W_MM / 2 * SCALE; hh = CELL_H_MM / 2 * SCALE
            body_bb = (-hw, -hh, hw, hh)
        pin_offsets = [krel(g[0], g[1]) for g in re.get('pins', {}).values()]
        return body_bb, pin_offsets

    def _rotated_pins_by_num(self, inst, deg, flip):
        """Like _rotated_body_and_pins, but returns (body_bb, {pin_num: (dx,
        dy)}) keyed by pin number.
        """
        base = self.sym_lib.get(inst.comp['sym']) or {'shapes': [],
                                                       'pins': {}}
        body_bb, offs = self._rotated_body_and_pins(inst, deg, flip)
        return body_bb, dict(zip(list(base.get('pins', {}).keys()), offs))

    def _true_sym_body_rel(self, inst):
        """Body bbox in relative canvas px, computed the EXACT way the
        renderer draws it: take the (already-rotated/flipped) sym_entry's
        body shapes, and map their kicad-mm bbox through the instance's
        current kicad_rel transform (mid_kx/mid_ky/sym_scale).  Returns
        (x0, y0, x1, y1) relative to the instance origin.  Pin stubs are
        included at 10% like build() so the body reserves a little of the
        pin.  This is the ONE trusted body-geometry routine — abs_sym_body()
        builds on it."""
        shapes = inst.sym_entry.get('shapes', [])
        no_pin = [s for s in shapes if s['kind'] != 'pin']
        pin10 = [{**s, 'length': s['length'] * 0.10}
                 for s in shapes if s['kind'] == 'pin']
        body_sh = no_pin + pin10
        if not body_sh:
            hw = CELL_W_MM / 2 * SCALE; hh = CELL_H_MM / 2 * SCALE
            return (-hw, -hh, hw, hh)
        bb = _bbox_of_shapes(body_sh)          # kicad mm
        c0 = inst.kicad_rel(bb[0], bb[1])
        c1 = inst.kicad_rel(bb[2], bb[3])
        return (min(c0[0], c1[0]), min(c0[1], c1[1]),
                max(c0[0], c1[0]), max(c0[1], c1[1]))

    def _settle_and_rebuild_ts(self, instances):
        """In : the instances.  Out: none; rotations settled and the
        T-terminals rebuilt once against the settled positions, so the
        committed T list is deterministic and harness-independent.
        An OUTER fixpoint over {crossing-fix -> rebuild T's -> settle
        T's}, run synchronously.  The fix is T-aware, but the T's are
        rebuilt AFTER it, so a self-crossing that only shows against the
        NEW T positions was invisible to a single pass (OPAx197's C_C27,
        V_V_ORN, X_U31.G1, R_R80, R_R66).  Stops when no self-crossing
        remains or the residual set stops shrinking, capped at 4 rounds;
        a clean design settles in one, so it costs nothing."""
        prev_sc = None
        for _outer in range(4):
            for _ in range(8):
                # ensure each instance carries its current rotation
                # geometry so pin positions are accurate.
                for inst in instances:
                    eff = self._user_rotations.get(inst.comp['ref'])
                    if eff is None:
                        eff = self._auto_rotations.get(
                            inst.comp['ref'], inst.rotation_deg or 0)
                    self._apply_instance_rotation_geometry(inst, eff or 0)
                pos = {id(i): (i.ox_px, i.oy_px) for i in instances}
                if self._ROTATION_RULES_ONLY:
                    break
                if not self._reduce_rotation_crossings(instances, pos):
                    break
            self._rebuild_t_terminals(instances)
            # Final single-owner T re-pin against the settled geometry, so no T
            # is stranded away from a pin that moved after the last rebuild.
            sc = set(self._self_crossing_refs(instances))
            if not sc or sc == prev_sc:
                break
            prev_sc = sc
        # Mop-up for self-crossings the MST reducer could not predict: turn a
        # rail-free 2-pin part 180 degrees within its axis.  The body box is
        # kept, so no overlap can appear.
        if not getattr(self, '_no_uncross', False):
            if self._self_crossing_refs(instances):
                self._uncross_final(instances)
            self._uncross_pass(instances, 'mirror')
            # 180-degree rotation counterpart, for orientations a mirror
            # cannot re-decide (see _uncross_pass).  Inside the same
            # _no_uncross gate.
            self._uncross_pass(instances, 'rotate180')
            # A mirror can leave a part crossing ITSELF, which is
            # _uncross_final's job and it has already run.  Re-run
            # it on whatever the mirrors introduced (OPAX197: 1
            # rCrossing, otherwise permanent).  It reverts anything
            # that does not help, so a second call is safe.
            if self._self_crossing_refs(instances):
                self._uncross_final(instances)

    def _reserved_clashers(self, res_box, insts):
        """Takes a {ref: reserved box} map of the sheet and the instances being
        transformed, and returns (pairs, boxes): the set's (ref, other)
        reserved clashes, with each other and with the rest of the sheet, and
        the set's current boxes. A
        post-placement transform compares pairs before and after -- a mirror or
        half turn moves a pin but not its role-fixed T, so the reserved box is
        NOT preserved, and a trial may only keep clashes it already had."""
        refs = {i.comp['ref'] for i in insts}
        boxes = {}
        for i in insts:
            try:
                boxes[i.comp['ref']] = self._abs_reserved_box(i)
            except Exception:
                pass
        pairs = set()
        for r, bx in boxes.items():
            for o, ob in res_box.items():
                if o not in refs and self._boxes_clash(bx, ob):
                    pairs.add((r, o))
            # members against each other too: a rigid turn keeps their
            # relative BODY layout, not their role-fixed T's
            for o, ob in boxes.items():
                if r < o and self._boxes_clash(bx, ob):
                    pairs.add((r, o))
        return pairs, boxes

    def _rigid_unit_of(self, ref):
        """Return the frozenset of refs forming ref's rigid unit (from
        _sp_rigid_blocks), or None if ref isn't a rigid-block member."""
        for members in _stable_blocks(getattr(self, '_sp_rigid_blocks', None)):
            if ref in members:
                return members
        return None

    def _rotate_rigid_unit(self, member_insts, delta_deg):
        """In : the CompInstance objects of one _sp_rigid_blocks entry and
        a delta of 90, 180 or 270.  Out: the unit rotated as ONE RIGID
        BODY about its own centroid.
        Each member's absolute position is recomputed by rotating its
        offset from that centroid with the SAME _rotate_kicad_point CCW
        convention _rotated_sym_entry uses for symbol geometry; a
        different basis for positions than for the members' own rotation
        would misalign the two.  Each member's rotation_deg advances by
        the same delta, so the internal arrangement — what makes the unit
        safe to treat as a cell — is preserved exactly."""
        if not member_insts:
            return
        cx = sum(i.ox_px for i in member_insts) / len(member_insts)
        cy = sum(i.oy_px for i in member_insts) / len(member_insts)
        for inst in member_insts:
            dx, dy = inst.ox_px - cx, inst.oy_px - cy
            ndx, ndy = _rotate_kicad_point(dx, dy, delta_deg)
            inst.ox_px, inst.oy_px = cx + ndx, cy + ndy
            ref = inst.comp['ref']
            new_rot = (inst.rotation_deg + delta_deg) % 360
            self._auto_rotations[ref] = new_rot
            self._apply_instance_rotation_geometry(inst, new_rot)

    def _uncross_final(self, instances):
        """In : the placed instances.  Out: the parts still in the
        self-cross set re-rotated wherever a rotation helps.
        Tries every remaining rotation, applying each for real and
        rebuilding T-terminals so a trial matches what will be rendered,
        and keeps the candidate that removes the part without growing the
        set — among those, the one leaving the MOST clearance between the
        part's own two flight lines (_seg_seg_min_dist on real geometry).
        Catches what the Kind-1 reducer misses, that one scoring against
        a FROZEN per-net MST.  User-rotated parts are skipped,
        pattern-oriented get 180 only, a rigid member turns with its unit."""
        rigid = set()
        for _mem in _stable_blocks(getattr(self, '_sp_rigid_blocks', None)):
            rigid |= set(_mem)
        po = getattr(self, '_pattern_oriented', set()) or set()
        res_box = {}
        for i in instances:
            try:
                res_box[i.comp['ref']] = self._abs_reserved_box(i)
            except Exception:
                pass

        def eff_rot(inst):
            ref = inst.comp['ref']
            r = self._user_rotations.get(ref)
            if r is None:
                r = self._auto_rotations.get(ref, inst.rotation_deg or 0)
            return r or 0

        for _round in range(4):
            sc = self._self_crossing_refs(instances)
            if not sc:
                break
            by_ref = {i.comp['ref']: i for i in instances}
            progress = False
            for ref in list(sc):
                inst = by_ref.get(ref)
                if inst is None:
                    continue
                pairs = getattr(inst, '_pin_net_pairs', None) or []
                if len(pairs) != 2:
                    continue
                if ref in self._user_rotations:
                    continue
                nls = [str(n).lower() for _p, n in pairs]
                if any(n in _VCC_NETS_LC or n in _GND_NETS_LC or n == '0'
                       for n in nls):
                    continue
                unit_refs = self._rigid_unit_of(ref) if ref in rigid else None
                if unit_refs is not None:
                    # Any user-rotated member vetoes rotating the whole
                    # unit — an explicit rotation on ONE member is a
                    # stronger signal than an automatic crossing fix.
                    if any(r in self._user_rotations for r in unit_refs):
                        continue
                    unit_insts = [by_ref[r] for r in unit_refs
                                  if r in by_ref]
                    if len(unit_insts) < 2:
                        continue
                    steps = ((180,)
                             if (ref in po or self._ROTATION_RULES_ONLY)
                             else (180, 90, 270))
                    saved = [(i, i.ox_px, i.oy_px, i.rotation_deg,
                             self._auto_rotations.get(i.comp['ref']))
                             for i in unit_insts]
                    base = set(self._self_crossing_refs(instances))
                    had = self._reserved_clashers(res_box, unit_insts)[0]
                    best_step, best_score = None, -1.0
                    for step in steps:
                        self._rotate_rigid_unit(unit_insts, step)
                        self._rebuild_t_terminals(instances)
                        now = set(self._self_crossing_refs(instances))
                        grew = self._reserved_clashers(
                            res_box, unit_insts)[0] - had
                        if ref not in now and len(now) <= len(base) \
                                and not grew:
                            edges = self._build_self_flight_edges(instances)
                            segs = edges.get(id(inst), [])
                            score = (_seg_seg_min_dist(
                                        segs[0][0], segs[0][1],
                                        segs[1][0], segs[1][1])
                                     if len(segs) == 2 else 0.0)
                            if score > best_score:
                                best_score, best_step = score, step
                        # Revert to the SAVED state before trying the
                        # next step — each trial is relative to the
                        # unit's ORIGINAL orientation, not cumulative.
                        for i, ox, oy, rot, ar in saved:
                            i.ox_px, i.oy_px = ox, oy
                            r2 = i.comp['ref']
                            if ar is None:
                                self._auto_rotations.pop(r2, None)
                            else:
                                self._auto_rotations[r2] = ar
                            self._apply_instance_rotation_geometry(i, rot)
                    if best_step is None:
                        self._rebuild_t_terminals(instances)
                    else:
                        self._rotate_rigid_unit(unit_insts, best_step)
                        self._rebuild_t_terminals(instances)
                        res_box.update(
                            self._reserved_clashers(res_box, unit_insts)[1])
                        progress = True
                    continue
                # 180 ONLY under the netlist rules: 90/270 would turn
                # the part off the axis rule 3 put it on, and the axis
                # is a rule decision, not this pass's to revisit.  A
                # 180 turn keeps the axis and just swaps which pin
                # faces which way — the one bit of port order a 2-pin
                # part has.
                steps = ((180,) if (ref in po or self._ROTATION_RULES_ONLY)
                         else (180, 90, 270))
                cur = eff_rot(inst)
                base = set(self._self_crossing_refs(instances))
                had = self._reserved_clashers(res_box, [inst])[0]
                best_nr, best_score = None, -1.0
                for step in steps:
                    nr = (cur + step) % 360
                    self._auto_rotations[ref] = nr
                    self._apply_instance_rotation_geometry(inst, nr)
                    self._rebuild_t_terminals(instances)
                    now = set(self._self_crossing_refs(instances))
                    grew = self._reserved_clashers(res_box, [inst])[0] - had
                    if ref not in now and len(now) <= len(base) \
                            and not grew:
                        edges = self._build_self_flight_edges(instances)
                        segs = edges.get(id(inst), [])
                        score = (_seg_seg_min_dist(segs[0][0], segs[0][1],
                                                    segs[1][0], segs[1][1])
                                 if len(segs) == 2 else 0.0)
                        if score > best_score:
                            best_score, best_nr = score, nr
                if best_nr is None:                # nothing helped → revert
                    self._auto_rotations[ref] = cur
                    self._apply_instance_rotation_geometry(inst, cur)
                    self._rebuild_t_terminals(instances)
                else:
                    # The loop's last trial may not be the WINNING one —
                    # re-apply whichever rotation actually scored best.
                    self._auto_rotations[ref] = best_nr
                    self._apply_instance_rotation_geometry(inst, best_nr)
                    self._rebuild_t_terminals(instances)
                    res_box.update(
                        self._reserved_clashers(res_box, [inst])[1])
                    progress = True
            if not progress:
                break
        # Re-settle T's against the committed rotations.
        self._rebuild_t_terminals(instances)
        # NO SPLIT PASS HERE.  This call used to sit inside a
        # _T_MERGE_SPLIT gate with both of the pass's flags left off, so
        # it did nothing at all; the split half is deliberately confined
        # to the drag path (see _t_split_pass), where a re-Place will
        # re-reserve afterwards.  The gate had no other reader.


    # How close a flight line may pass to a pin on its OWN owner before
    # it counts as a graze.  Measured on LM324.lib: a real graze runs
    # 6-7 px from the pin (VLIM's net-7 line against its own pin 2)
    # while a clean orientation of the same part clears 32 px, so
    # anything in between separates the two cases.  A pin dot is ~4 px
    # radius, so below about 8 px the line is visually touching it.
    _SELF_GRAZE_PX = 12.0

    def _self_graze_pairs(self, instances, segs=None):
        """In : the instances, optionally a pre-built segment list.
        Proc: for each segment, for each pin of the instance at either
               end, measure point-to-segment distance, skipping the pin
               the segment actually attaches to.
        Out : list of (ref, pin, dist) under _SELF_GRAZE_PX.
        A line leaving a pin and passing within a few pixels of the SAME
        part's other pin reads as connecting to both, yet it is not a
        crossing — the segments diverge, so _seg_cross never fires.
        Nothing measured it, and _uncross_pass reverted rule 4's correct
        mirror for VLIM because the trade's second half was invisible."""
        if segs is None:
            try:
                segs = list(self._flight_segments(instances))
            except Exception:
                return []
        by_ref = {i.comp['ref']: i for i in instances}
        out = []
        for sg in segs:
            a, b = sg[0], sg[1]
            for ref, own_pin in ((sg[2], sg[3]), (sg[4], sg[5])):
                inst = by_ref.get(ref)
                if inst is None:
                    continue
                for pn, _net in (getattr(inst, '_pin_net_pairs', None) or ()):
                    if pn == own_pin:
                        continue
                    try:
                        pt = _pin_canvas_pos(inst, pn)
                    except Exception:
                        continue
                    # a pin sitting exactly at either end is the line's
                    # own attachment seen from the other side, not a graze
                    if (abs(pt[0] - a[0]) < 1 and abs(pt[1] - a[1]) < 1) or \
                       (abs(pt[0] - b[0]) < 1 and abs(pt[1] - b[1]) < 1):
                        continue
                    d = _point_seg_dist(pt, a, b)
                    if d < self._SELF_GRAZE_PX:
                        out.append((ref, pn, d))
        return out

    def _self_graze_participants(self, instances, segs=None):
        """Refs owning at least one self-graze."""
        return {r for r, _p, _d in self._self_graze_pairs(instances, segs)}


    # What one wrong-facing pin costs, in crossings, inside
    # _uncross_pass.  1 weights it exactly like a crossing and a graze.
    # Measured across the four decks at 1: wrong-facing pins 11 -> 4,
    # crossings 341 -> 356 (LM324.lib 5 -> 5, LM324.sub 30 -> 32,
    # LP2951 2 -> 2, OPAX197 304 -> 317).  The whole cost is the pass
    # REFUSING trades it used to take, not making new ones — raising
    # the candidate gate was tried separately and changed nothing.
    # Set to 0 to restore the old behavior exactly.
    _FACING_PENALTY = 1

    def _facing_penalty(self, inst, io_in, io_out, pos_net, neg_net):
        """In : a placed instance, the declared port net sets and the two
        rail nets.  Out: the number of pins whose FACING contradicts a
        pre-placement decision — a declared input pointing right or an
        output pointing left, and a rail pin pointing away from its rail.
        Added to the crossing score because both post-placement
        transforms choose blind: a 180 inverts which way a rail pin
        points and a mirror which side a port pin leaves from, so either
        can revert a correct earlier decision to buy one crossing.
        Nothing is drawn wrongly — the T follows the pin — but the score
        could not see the cost.  Weighted 1, like a crossing and a graze."""
        n = 0
        for pn, net in (getattr(inst, '_pin_net_pairs', None) or []):
            nl = str(net).lower()
            try:
                dx, dy = _pin_outward_direction(inst, pn)
            except Exception:
                continue
            if nl == pos_net:
                if dy > abs(dx):
                    n += 1          # +power pin pointing DOWN
            elif nl == neg_net or nl == '0' or nl in _GND_NETS_LC:
                if -dy > abs(dx):
                    n += 1          # ground / -power pin pointing UP
            elif nl in io_in:
                if dx > abs(dy):
                    n += 1          # input arriving from the right
            elif nl in io_out:
                if -dx > abs(dy):
                    n += 1          # output leaving to the left
        return n

    def _uncross_pass(self, instances, kind):
        """In : the placed instances and a transform kind — 'mirror'
        toggles the horizontal flip, 'rotate180' turns the part half a
        turn.  Out: what was kept, in _auto_flips / _auto_rotations.
        Scores each candidate LOCALLY, applies it for real, re-scores and
        keeps it only when it strictly lowers the score without adding a
        reservation clash; up to three rounds.  Both keep the BODY box,
        which is why 90 and 270 are not offered, but not the reserved
        box: a role-fixed T stays on its side while the pin moves, so
        every trial goes through _reserved_clashers.  Only mirror serves
        self-grazing parts and rebuilds T's; only rotate180 skips cells."""
        if kind == 'mirror':
            def _apply(inst):
                ref = inst.comp['ref']
                was = bool(self._auto_flips.get(ref, False))
                if was:
                    self._auto_flips.pop(ref, None)
                else:
                    self._auto_flips[ref] = True
                self._apply_instance_rotation_geometry(
                    inst, (inst.rotation_deg or 0) % 360)
                return was

            def _revert(inst, was):
                ref = inst.comp['ref']
                if was:
                    self._auto_flips[ref] = True
                else:
                    self._auto_flips.pop(ref, None)
                self._apply_instance_rotation_geometry(
                    inst, (inst.rotation_deg or 0) % 360)

            def _keep(inst, _was):
                return None
        else:
            def _apply(inst):
                old_rot = (inst.rotation_deg or 0) % 360
                self._apply_instance_rotation_geometry(
                    inst, (old_rot + 180) % 360)
                return old_rot

            def _revert(inst, old_rot):
                self._apply_instance_rotation_geometry(inst, old_rot)

            def _keep(inst, old_rot):
                self._auto_rotations[inst.comp['ref']] = (old_rot + 180) % 360

        by_ref = {i.comp['ref']: i for i in instances}
        # The neighbour boxes a GROWN transform is judged against.  Built
        # ONCE: only the transformed part's own box changes, so every
        # other entry stays valid, and an accepted change updates just
        # its own.  Same _abs_reserved_box / _boxes_clash pair the
        # harness gate and the BBoxes overlay read, so this pass and the
        # check that grades it cannot disagree about what a clash is.
        res_box = {}
        for i in instances:
            try:
                res_box[i.comp['ref']] = self._abs_reserved_box(i)
            except Exception:
                pass

        seg_cache = {}
        ref_nets, on_net_u = {}, defaultdict(set)
        for i in instances:
            r = i.comp['ref']
            ref_nets[r] = frozenset(str(n) for _p, n in
                                    (i._pin_net_pairs or []))
            for n in ref_nets[r]:
                on_net_u[n].add(r)

        def _local_cross(ref):
            """In : a ref.  Out: that part's badness — the crossings it
            takes part in PLUS the flight lines grazing its own pins.
            One number, a graze weighted the same as a crossing, because
            the two are alternatives rather than separate concerns and
            the decision here is exactly a choice between them.  Summing
            is what lets a graze OUTWEIGH a crossing reduction: reverting
            VLIM's correct mirror on LM324.lib removed one crossing and
            created two grazes, which a crossings-only score read as an
            improvement."""
            # Only ref's own nets can change under its transform, so the
            # rest of the sheet's lines come from a cache that a KEPT
            # transform clears, and only ref's nets are rebuilt here.
            try:
                if seg_cache.get('rest') is None:
                    seg_cache['all'] = list(self._flight_segments(instances))
                    seg_cache['rest'] = {}
                own = ref_nets.get(ref, frozenset())
                rest = seg_cache['rest'].get(own)
                if rest is None:
                    rest = seg_cache['rest'][own] = [
                        sg for sg in seg_cache['all'] if sg[6] not in own]
                near = sorted({q for n in own for q in on_net_u.get(n, ())})
                segs = rest + [sg for sg in self._flight_segments(
                    [by_ref[q] for q in near]) if sg[6] in own]
            except Exception:
                return None
            mine = [k for k, sg in enumerate(segs)
                    if sg[2] == ref or sg[4] == ref]
            if not mine:
                return 0
            n = 0
            # Bounding boxes first, and only this part's own lines for the
            # graze test: together these were 22 s of OPAx197's Place.
            bbs = [(min(sg[0][0], sg[1][0]), max(sg[0][0], sg[1][0]),
                    min(sg[0][1], sg[1][1]), max(sg[0][1], sg[1][1]))
                   for sg in segs]
            for k in mine:
                a1, b1 = segs[k][0], segs[k][1]
                x0, x1, y0, y1 = bbs[k]
                for j in range(len(segs)):
                    if j == k:
                        continue
                    q = bbs[j]
                    if q[0] > x1 or q[1] < x0 or q[2] > y1 or q[3] < y0:
                        continue
                    a2, b2 = segs[j][0], segs[j][1]
                    if a1 in (a2, b2) or b1 in (a2, b2):
                        continue
                    if _seg_cross(a1, b1, a2, b2):
                        n += 1
            n += sum(1 for r, _p, _d in self._self_graze_pairs(
                instances, [segs[k] for k in mine]) if r == ref)
            return n

        def _score(ref):
            """In : a ref.  Out: its crossing score plus what this part's
            FACING costs.
            The facing term joins the comparison but deliberately NOT the
            candidate gate below, which still asks for a crossing or a
            graze.  The passes were never accused of ignoring a
            badly-faced part but of CREATING one to buy a crossing, and
            that needs the term only on both sides of the accept test.
            Letting facing alone nominate a part measured badly: many
            more moves, each judged on its own local crossing view, and
            OPAx197 rose 304 -> 317 for one facing fix."""
            n = _local_cross(ref)
            inst = by_ref.get(ref)
            if n is None or inst is None:
                return n
            return n + self._FACING_PENALTY * self._facing_penalty(
                inst, _io_in, _io_out, _pos_net, _neg_net)

        # Read once: none of these change while the pass runs.
        _io_in, _io_out = self._subckt_io_nets()
        _pos_net, _neg_net = self._rail_polarity()
        _cands = set(self._crossing_participants(instances))
        if kind == 'mirror':
            _cands |= self._self_graze_participants(instances)
        _pat = ((getattr(self, '_pattern_oriented', None) or set())
                if kind != 'mirror'
                # A part whose mirror _io_side_mirror decided from a
                # DECLARED .SUBCKT port is not a free variable here.
                # The rule saw the netlist; this pass sees only local
                # crossings, and on OPAX197 it used to mirror
                # X_H1.VH_H1 back so the OUT port left to the LEFT.
                # Holding the mirror costs nothing elsewhere, unlike
                # charging for it in the score.
                else (getattr(self, '_port_side_locked', None) or set()))
        for _round in range(3):
            improved = False
            for ref in sorted(_cands):
                inst = by_ref.get(ref)
                if inst is None or ref in _pat:
                    continue
                if ref in self._user_flips or ref in self._user_rotations:
                    continue
                if not _local_cross(ref):
                    continue
                base = _score(ref)
                # The reserved box BEFORE, plus whoever it already
                # clashes with, so the growth test can tell a NEW clash
                # from one that was already there.
                had, _b0 = self._reserved_clashers(res_box, [inst])
                ext0 = _b0.get(ref)
                if ext0 is None:
                    continue
                try:
                    token = _apply(inst)
                except Exception:
                    continue
                try:
                    now = _score(ref)
                    ext1 = self._abs_reserved_box(inst)
                except Exception:
                    now = ext1 = None
                # Rotating re-places the text, and label placement is not
                # symmetric, so recheck the reserved box even when the body box
                # is unchanged.
                if ext1 is not None:
                    grew = (ext1[0] < ext0[0] - 0.01
                            or ext1[1] < ext0[1] - 0.01
                            or ext1[2] > ext0[2] + 0.01
                            or ext1[3] > ext0[3] + 0.01)
                    if grew and (self._reserved_clashers(
                            res_box, [inst])[0] - had):
                        now = None
                if now is not None and now < base:
                    _keep(inst, token)
                    seg_cache.clear()
                    if ext1 is not None:
                        res_box[ref] = ext1
                    improved = True
                    continue
                try:
                    _revert(inst, token)
                except Exception:
                    pass
            if not improved:
                break
        # Rebuild T-terminals after both kinds: a rotation moves pins just as a
        # mirror does.
        self._rebuild_t_terminals(instances)


    def _crossing_participants(self, instances):
        """Refs that own at least one flight segment involved in a
        crossing.  Only these are worth trying a mirror on — every other
        part's mirror cannot reduce a count it does not contribute to."""
        out = set()
        try:
            segs = list(self._flight_segments(instances))
        except Exception:
            return out
        for i in range(len(segs)):
            a1, b1, ra, _pa, rb, _pb, _n1 = segs[i]
            for j in range(i + 1, len(segs)):
                a2, b2, rc, _pc, rd, _pd, _n2 = segs[j]
                if a1 in (a2, b2) or b1 in (a2, b2):
                    continue
                if _seg_cross(a1, b1, a2, b2):
                    for r in (ra, rb, rc, rd):
                        if r:
                            out.add(r)
        return out

    def _resolve_cluster_box_overlaps(self, instances):
        """In : the placed instances.  Out: the number of shifts applied;
        any pair of clusters whose TRUE bboxes still overlap is moved
        apart.
        The safety net after every rotation-affecting pass: the packer
        sizes each cluster once, and a later pass can change a rotation
        and grow the real footprint past what was reserved.  Rather than
        predict that, this re-measures the actual result at the one point
        where every earlier pass has run.  A cluster moves as a RIGID
        TRANSLATION, members and wholly-owned T's by the same delta, and
        the smaller one moves; _separate_overlapping_groups does the rest."""
        inst_by_ref = {i.comp['ref']: i for i in instances}
        t_owner_refs = {}
        for (ref, _pn), tid in (getattr(self, '_pin_to_t', None)
                                 or {}).items():
            t_owner_refs.setdefault(tid, set()).add(ref)
        t_by_id = {t.get('id'): t
                   for t in (getattr(self, '_t_terminals', None) or [])}

        entries = []   # [refset, bbox(list, mutated in place), owned_t_ids]
        for cl in (getattr(self, '_boxes', None) or []):
            refset = {r for r in cl if r in inst_by_ref}
            if not refset:
                continue
            bb = self._cluster_true_bbox(list(refset), inst_by_ref,
                                         t_owner_refs=t_owner_refs,
                                         t_by_id=t_by_id)
            if bb is None:
                continue
            owned_ts = [tid for tid, owners in t_owner_refs.items()
                       if owners and owners <= refset]
            entries.append([refset, list(bb), owned_ts])

        if len(entries) < 2:
            return 0

        # Move whichever cluster has FEWER members (less visual
        # disruption, and it avoids re-positioning an entire large
        # Sugiyama layout to dodge a small cell): 'weight' is the member
        # count and _separate_overlapping_groups moves the lighter group.
        groups = []
        for refs, bb, owned_ts in entries:
            groups.append({
                'box': bb,
                'weight': len(refs),
                'shift': (lambda dx, dy, _r=refs, _t=owned_ts:
                          self._shift_cluster(_r, _t, inst_by_ref,
                                              t_by_id, dx, dy)),
            })
        # A safety net must not make things worse: keep the box separation only
        # if the instance clash count does not rise.
        before = self._instance_clash_count(instances)
        undo = [(i, i.ox_px, i.oy_px) for i in instances]
        undo_t = [(t, t.get('cx'), t.get('cy'))
                  for t in (getattr(self, '_t_terminals', None) or [])]
        moved = self._separate_overlapping_groups(groups)
        if moved and self._instance_clash_count(instances) > before:
            for inst, ox, oy in undo:
                self._shift_instance(inst, ox - inst.ox_px, oy - inst.oy_px)
            for t, cx, cy in undo_t:
                t['cx'], t['cy'] = cx, cy
            return 0
        return moved

    def _instance_clash_count(self, instances):
        """How many instance pairs violate the clearance rule right now.

        The number a pass is judged by: same box (_abs_reserved_box) and
        same predicate (_boxes_clash) the gate uses, so 'this pass made
        it worse' means worse by the standard that decides PASS/FAIL.
        """
        boxes = [self._abs_reserved_box(i) for i in (instances or [])]
        return sum(1 for a in range(len(boxes))
                   for b in range(a + 1, len(boxes))
                   if self._boxes_clash(boxes[a], boxes[b]))

    def _shift_cluster(self, refs, t_ids, inst_by_ref, t_by_id, dx, dy):
        """In : the cluster's refs and T ids, their lookups, and (dx, dy).
        Out: a rigid translation — every member instance AND every T the
        cluster wholly owns moves by the identical delta, so nothing
        disconnects and no internal geometry changes.
        Instance moves go through _shift_instance rather than a bare
        ox_px/oy_px bump: that is the one place which also keeps
        _placed_ref_pos in step, the same reason _resolve_body_overlaps
        routes its moves through it.  A raw bump left _placed_ref_pos
        describing the pre-shift layout for every member."""
        for r in refs:
            inst = inst_by_ref.get(r)
            if inst is not None:
                self._shift_instance(inst, dx, dy)
        for tid in t_ids:
            t = t_by_id.get(tid)
            if t is not None:
                t['cx'] += dx
                t['cy'] += dy


    def _metric_driven_rotation_touchup(self, instances):
        """In : the fully placed, fully labelled instances.  Out: each
        eligible 2-pin part left at the rotation that scores best.
        Tries the others for real through
        _apply_instance_rotation_geometry — the exact geometry _render
        would produce — scoring against the REAL _self_check report
        rather than a proxy, and keeps a change only when it strictly
        reduces the score.  A P2DL member gets the same-axis mirror only,
        its cell's shape depending on the axis; a free part gets all
        three.  A rail-pinned part never switches axis, which pin faces
        the rail being a correctness signal.  Runs once, in ref order."""
        by_ref = {i.comp['ref']: i for i in instances}
        base = self._self_check(instances)
        base_score = base['cross_ab'] + base['cross_other']
        pwr_adjacent = self._pwr_gnd_adjacent_nets(instances)
        ref_to_blocks = set()
        for members in _stable_blocks(getattr(self, '_sp_block_layout', {})):
            if len(members) >= 2:
                ref_to_blocks |= set(members)
        orig_rot = {}
        changed = []
        for inst in sorted(instances, key=lambda i: i.comp['ref']):
            ref = inst.comp['ref']
            if ref in self._user_rotations:
                continue
            kind = inst.comp.get('kind', '').upper()
            eligible = kind in ('R', 'L') or (
                kind == 'C' and 'polarized' not in inst.comp.get(
                    'sym', '').lower())
            if not eligible:
                continue
            pairs = getattr(inst, '_pin_net_pairs', None) or []
            if len(pairs) != 2:
                continue
            cur_rot = inst.rotation_deg or 0
            is_pwr = any(str(n).lower() in pwr_adjacent for _p, n in pairs)
            locked = ref in ref_to_blocks
            if is_pwr or locked:
                # rail-pinned: same-axis 180 flip only (item #1 — a
                # genuine candidate now, not a skip).  P2DL-block member:
                # same-axis mirror only (the cell's shape depends on it).
                candidates = [(cur_rot + 180) % 360]
            else:
                candidates = [d for d in (0, 90, 180, 270) if d != cur_rot]
            best_rot, best_score = cur_rot, base_score
            # Trialling mutates the part: _apply_instance_rotation_
            # geometry rebuilds its candidates and drops its 'placed'
            # positions.  Restoring only the ROTATION leaves the labels
            # in the rejected candidate's state, which is a real change
            # to the reserved box even when the pass keeps nothing —
            # measured as reserved-box clashes appearing with
            # changed == 0 (LM324.lib's C2/RO2, OPAX197's R_R15/R_R8).
            # Snapshot the text state so a rejected trial is a true
            # no-op.
            tsnap = inst._snapshot_text_items()
            for cand in candidates:
                self._apply_instance_rotation_geometry(inst, cand)
                # crossings only — no overlap scan — during search; the
                # end-of-pass full _self_check below is the real safety
                # net, and it's a single call regardless of how many
                # candidates were tried above.
                score, _n = self._flight_crossing_count(instances)
                if score < best_score:
                    best_score, best_rot = score, cand
            self._apply_instance_rotation_geometry(inst, best_rot)
            if best_rot == cur_rot:
                inst._restore_text_items(tsnap)
                inst._recompute_composite_rel()
                continue
            base_score = best_score
            orig_rot[ref] = cur_rot
            self._auto_rotations[ref] = best_rot
            changed.append(ref)
        if not changed:
            # Every trial restored its own text state above, so nothing
            # was touched — leave the committed geometry exactly as the
            # resolvers left it.  Refreshing labels here instead was
            # tried and is wrong: a re-place is itself a change, and it
            # reintroduced the very reserved-box clashes this pass is
            # supposed not to create.
            return {}
        # Labels/composite boxes were stale during the trial loop above
        # (trials only need pin positions — see the docstring); re-place
        # them for real now and re-check overlaps against the true
        # geometry.  All-or-nothing revert if that made things worse —
        # simple and safe, and changed refs are typically few.
        for inst in instances:
            lqt = QuadTree(-200000, -200000, 200000, 200000)
            inst.place_texts(lqt)
        self._reresolve_value_texts(instances)
        for inst in instances:
            inst._recompute_composite_rel()
        final = self._self_check(instances)
        # Also reject a new self-crossing: the trial loop scores only other
        # crossings, so a part's own two lines could start crossing unnoticed.
        if (final['overlaps'] > base['overlaps']
                or final['reserved_overlaps'] > base['reserved_overlaps']
                or final['cross_ab'] > base['cross_ab']):
            for ref in changed:
                inst = by_ref.get(ref)
                if inst is None:
                    continue
                self._apply_instance_rotation_geometry(inst, orig_rot[ref])
            # The map is rebuilt FROM the reverted geometry, not popped.
            # Popping was wrong whenever orig_rot[ref] was itself a
            # recorded rotation from an earlier pass: the instance went
            # back to 90 while its map entry vanished, so render — whose
            # only channel to this decision is that map — rebuilt the
            # part upright.  See _sync_auto_rotations.
            self._sync_auto_rotations(instances)
            for inst in instances:
                lqt = QuadTree(-200000, -200000, 200000, 200000)
                inst.place_texts(lqt)
            self._reresolve_value_texts(instances)
            for inst in instances:
                inst._recompute_composite_rel()
            self._rebuild_t_terminals(instances)
            # _self_check's side effect of caching
            # self._last_self_check means the PRE-revert `final` computed
            # above is otherwise left stale in that cache even though the
            # geometry was just correctly reverted — refresh it here so
            # anything reading self._last_self_check (the toolbar, the
            # caller, a test) sees the TRUE final state, not a rejected
            # candidate's numbers.
            self._self_check(instances)
            return {}
        self._rebuild_t_terminals(instances)
        self._self_check(instances)
        return orig_rot

    def _reduce_rotation_crossings(self, instances, positions):
        """In : the instances and their positions.  Out: the number
        rotated; each choice is written to _user_rotations so the next
        _render makes it stick, exactly like a right-click rotate.
        Kind-1 crossing reduction by rotating ANY 2-pin instance (R/C/L,
        V/I sources).  Crossings are detected on the ACTUAL rendered
        flight topology, the per-net Manhattan MST, and the orientation
        that minimises crossings on that part's own edges is kept.
        Trial geometry REUSES the render path through
        _apply_instance_rotation_geometry, so trial pins match what is
        drawn.  Existing user rotations are respected."""
        inst_by_id = {id(i): i for i in instances}

        # Normalise EVERY instance to its render
        # geometry first.  During placement an instance can carry
        # rotation_deg/_auto_rotations WITHOUT its sym_entry pins
        # actually being rotated (sym_entry stays base; _render rotates
        # fresh copies).  So _pin_canvas_pos on placement instances
        # returns pre-rotation positions — not what's drawn.  Applying
        # the effective rotation here makes the pin geometry match the
        # rendered schematic, so crossing detection is on the real
        # layout.
        for inst in instances:
            eff = self._user_rotations.get(inst.comp['ref'])
            if eff is None:
                eff = self._auto_rotations.get(inst.comp['ref'],
                                               inst.rotation_deg or 0)
            self._apply_instance_rotation_geometry(inst, eff or 0)

        def pin_xy_at(inst, pnum, abs_deg, ox, oy):
            """True canvas pos of pin pnum at absolute rotation abs_deg,
            using the render geometry path.  Saves/restores the inst's
            geometry so the caller is unaffected."""
            saved = (inst.sym_entry, inst.rotation_deg,
                     inst.sym_scale, inst.mid_kx, inst.mid_ky,
                     inst.ox_px, inst.oy_px)
            try:
                self._apply_instance_rotation_geometry(inst, abs_deg)
                inst.ox_px, inst.oy_px = ox, oy
                return _pin_canvas_pos(inst, pnum)
            finally:
                (inst.sym_entry, inst.rotation_deg, inst.sym_scale,
                 inst.mid_kx, inst.mid_ky, inst.ox_px,
                 inst.oy_px) = saved

        def cur_pin_xy(key):
            if key[0] == 'anchor':
                return (key[1], key[2])
            inst = inst_by_id.get(key[1])
            if inst is None:
                return None
            ox, oy = positions[id(inst)]
            return pin_xy_at(inst, key[2], inst.rotation_deg or 0, ox, oy)

        # Real flight topology: per net, MST edges over pin points.
        # T-aware: a pin that is wired to a T-symbol
        # (self._pin_to_t) does NOT participate in the pin-to-pin MST;
        # instead its flight line goes straight to that T's (cx,cy).
        # This models the ACTUAL rendered lines (e.g. C5's MID pin flies
        # to the nearby interior MID-T, not to a distant MID pin), so
        # crossings involving rail-T stubs are visible to the detector.
        net_to_pins, _ = _build_pin_flight_data(instances)
        pin_to_t = getattr(self, '_pin_to_t', {}) or {}
        t_pos = {t['id']: (t['cx'], t['cy'])
                 for t in (self._t_terminals or [])}

        # All flight segments as (keyA, keyB).
        all_edges = []
        edges_of_inst = {}      # id(inst) → list of edge indices

        def add_edge(ka, kb):
            ei = len(all_edges)
            all_edges.append([ka, kb])
            for k in (ka, kb):
                if k[0] == 'pin':
                    edges_of_inst.setdefault(k[1], []).append(ei)

        for _nl, members in net_to_pins.items():
            keys = []
            pts = []
            for m, pn in members:
                if m is None:
                    keys.append(('anchor', pn[0], pn[1]))
                    pts.append(pn)
                else:
                    # Pin wired to a T → direct edge to the T position;
                    # exclude it from this net's MST.
                    tid = pin_to_t.get((m.comp['ref'], pn))
                    if tid is not None and tid in t_pos:
                        tp = t_pos[tid]
                        add_edge(('pin', id(m), pn), ('anchor', tp[0], tp[1]))
                        continue
                    keys.append(('pin', id(m), pn))
                    xy = cur_pin_xy(('pin', id(m), pn))
                    pts.append(xy if xy else (0, 0))
            for i, j in _mst_edges_manhattan(pts):
                add_edge(keys[i], keys[j])

        def seg_xy(edge, overrides):
            a = overrides.get(edge[0]) or cur_pin_xy(edge[0])
            b = overrides.get(edge[1]) or cur_pin_xy(edge[1])
            return a, b

        def spread_for(inst, overrides):
            """Angular spread between the inst's two
            pins' flight-line directions.  Each pin's direction is the
            unit vector from the pin to the OTHER endpoint of its
            incident MST edge.  Score = 1 - cos(angle between the two
            directions): 0 when the two lines point the same way, up to
            2 when they point opposite.  Higher = lines diverge more =
            cleaner (per user: the best rotation spreads the two flight
            lines apart the most).  Returns None if not exactly two
            incident edges with valid endpoints."""
            my = edges_of_inst.get(id(inst), [])
            dirs = []
            for ei in my:
                e = all_edges[ei]
                # the pin end is whichever key is this inst's pin
                pa, pb = e[0], e[1]
                if pa[0] == 'pin' and pa[1] == id(inst):
                    pin_end, far_end = pa, pb
                elif pb[0] == 'pin' and pb[1] == id(inst):
                    pin_end, far_end = pb, pa
                else:
                    continue
                p = overrides.get(pin_end) or cur_pin_xy(pin_end)
                q = overrides.get(far_end) or cur_pin_xy(far_end)
                if p is None or q is None:
                    continue
                dx, dy = q[0] - p[0], q[1] - p[1]
                mag = (dx * dx + dy * dy) ** 0.5
                if mag < 1e-6:
                    continue
                dirs.append((dx / mag, dy / mag))
            if len(dirs) != 2:
                return None
            cos = dirs[0][0] * dirs[1][0] + dirs[0][1] * dirs[1][1]
            return 1.0 - cos

        def self_cross(inst, overrides):
            """Do THIS instance's own two flight lines
            cross each other?  (pin1→its far endpoint vs pin2→its far
            endpoint).  This is the primary Kind-1 test — the user's
            original rule — and the correct objective: C5's lines cross
            at rot 0 and don't at 90/180/270, matching observation.
            Returns True if they cross."""
            my = edges_of_inst.get(id(inst), [])
            segs = []
            for ei in my:
                a1, a2 = seg_xy(all_edges[ei], overrides)
                if a1 is not None and a2 is not None:
                    segs.append((a1, a2))
            if len(segs) != 2:
                return False
            return _segments_intersect(segs[0][0], segs[0][1],
                                       segs[1][0], segs[1][1])

        flips = 0
        # members of a RIGID cell (diff-pair skeleton,
        # grounded-series column, …) must never be rotated individually, and a
        # part with a rail pin must keep its positive-rail-up orientation; both
        # are excluded from the relaxed pattern-oriented fix below.
        rigid_members = set()
        for _mem in _stable_blocks(getattr(self, '_sp_rigid_blocks', None)):
            rigid_members |= set(_mem)
        for inst in instances:
            # ANY 2-pin instance (R/C/L, V/I sources, etc.).
            pairs = getattr(inst, '_pin_net_pairs', None) or []
            if len(pairs) != 2:
                continue
            if inst.comp['ref'] in self._user_rotations:
                continue
            # A 2-pin part with a literal rail pin (0, gnd, vcc, ...) keeps its
            # rail-down/up orientation: no crossing fix may flip it 180 degrees.
            _rail_role_nets = set(
                getattr(self, '_rail_polarity_overrides', None) or {})
            _nls_rail_check = [str(n).lower() for _p, n in pairs]
            if any(n in _PWR_NETS_LC_FOR_T or n in _rail_role_nets
                   for n in _nls_rail_check):
                continue
            # A rule-oriented part (diff-pair cell, column, chain_to_out row)
            # has an intentional rotation; the crossing fix may only turn it 180
            # degrees, never across its axis.
            pat_only_180 = False
            if inst.comp['ref'] in getattr(self, '_pattern_oriented', set()):
                _nls = [str(n).lower() for _p, n in pairs]
                _has_rail = any(n in _VCC_NETS_LC or n in _GND_NETS_LC
                                or n == '0' for n in _nls)
                if inst.comp['ref'] in rigid_members or _has_rail:
                    continue
                pat_only_180 = True
            if id(inst) not in edges_of_inst:
                continue
            ox, oy = positions[id(inst)]
            cur_abs = inst.rotation_deg or 0
            base_self = self_cross(inst, {})
            if not base_self:
                continue          # own lines already don't cross — leave it
            # Self-crossings and placement crossings have different cures
            # (rotation vs movement), so never trade one for the other.
            best_abs = None
            best_key = None
            for step in ((180,) if pat_only_180 else (180, 90, 270)):
                abs_deg = (cur_abs + step) % 360
                ov = {}
                for pn, _nn in pairs:
                    npos = pin_xy_at(inst, pn, abs_deg, ox, oy)
                    if npos:
                        ov[('pin', id(inst), pn)] = npos
                if self_cross(inst, ov):
                    continue                       # still self-crosses
                sp = spread_for(inst, ov)
                sp = sp if sp is not None else 0.0
                # Rank: 180 preferred (step==180 → 1), then by spread.
                key = (1 if step == 180 else 0, sp)
                if best_key is None or key > best_key:
                    best_key = key
                    best_abs = abs_deg
            if best_abs is not None and best_abs != cur_abs:
                self._apply_instance_rotation_geometry(inst, best_abs)
                inst.ox_px, inst.oy_px = ox, oy
                self._auto_rotations[inst.comp['ref']] = best_abs
                flips += 1
        return flips

    def _enforce_rank_monotonicity(self, instances):
        """In : the lane layouts.  Out: the number of boundaries corrected.
        Walks each lane's ranks left to right keeping the rightmost
        composite edge seen so far, and where the next rank starts left
        of it, translates that rank and every rank above it right by the
        deficit.  Shifting a whole SUFFIX is what makes this
        overlap-safe: it moves rigidly, so nothing inside changes
        relative position and its distance from every lower rank only
        grows.  Label-extent inversions ONLY — where the BODIES are
        inverted an earlier pass used the wrong column, and a suffix
        shift would buy the order back only by widening the drawing."""
        info_list = getattr(self, '_dbg_lane_info', None) or []
        if not info_list:
            return 0
        by_ref = {i.comp['ref']: i for i in instances}
        fixed = 0
        for info in info_list:
            members = {}
            for ref, r in (info.get('rank_of_ref') or {}).items():
                inst = by_ref.get(ref)
                if inst is not None:
                    members.setdefault(r, []).append(inst)
            order = sorted(members)
            reach = b_reach = None
            for idx, r in enumerate(order):
                boxes = [i.abs_composite() for i in members[r]]
                bodies = [i.abs_sym_body() for i in members[r]]
                lo = min(b[0] for b in boxes)
                hi = max(b[2] for b in boxes)
                b_lo = min(b[0] for b in bodies)
                b_hi = max(b[2] for b in bodies)
                # TOL: shifting makes lo == reach exactly, so a bare `<`
                # re-triggers on float noise every call.  Adjacent ranks
                # touching is the correct outcome — _assign_x already put
                # FLIGHT_GAP/CLEAR between them and this is only a
                # corrective — so treat a sub-pixel overlap as clean.
                bodies_ok = b_reach is None or b_lo >= b_reach - 0.5
                if reach is not None and lo < reach - 0.5 and bodies_ok:
                    dx = reach - lo
                    for rr in order[idx:]:
                        for i in members[rr]:
                            i.ox_px += dx
                    hi += dx
                    b_hi += dx
                    fixed += 1
                reach = hi if reach is None else max(reach, hi)
                b_reach = b_hi if b_reach is None else max(b_reach, b_hi)
        return fixed

    def _align_parallel_groups(self, instances, positions):
        """Takes the parallel sibling groups (2-pin R/C/L sharing both nets) and
        puts each group's members side by side at a common y, so the pair reads
        as parallel rather than as a series run.  Only ox_px / oy_px and
        `positions` change; orientation is left as _compute_parallel_orient set
        it. Runs after series alignment, so a part in both arrangements ends up
        parallel. RANKS WIN: a group is only laid
        out when all its members share one Sugiyama rank.  Spreading across x is
        unconditional, so a group straddling a rank boundary used to drag a
        member into the next rank's column; left-to-right rank order is what the
        reader needs, so such a group stays where Sugiyama put it."""
        groups = self._parallel_groups(instances)
        if not groups:
            return
        by_ref = {i.comp['ref']: i for i in instances}
        # ref -> rank, unioned over every lane-layout this run has done.
        # _place_groups_as_lanes appends its entry before this pass runs
        # for the same cluster, so the current cluster's ranks are here.
        rank_of = {}
        for info in (getattr(self, '_dbg_lane_info', None) or []):
            rank_of.update(info.get('rank_of_ref') or {})
        # frozen cell members are not
        # re-laid-out (see _align_series_chains).
        frozen = getattr(self, '_pattern_oriented', set())
        SPACING = max(60, int(_GRID_PITCH * 1.1))
        orient = getattr(self, '_parallel_orient', None) or {}
        for refs in groups:
            members = [by_ref[r] for r in refs if r in by_ref]
            if len(members) < 2:
                continue
            if any(m.comp['ref'] in self._user_rotations for m in members):
                continue
            if any(m.comp['ref'] in frozen for m in members):
                continue
            # ranks win — see the docstring.
            mranks = {rank_of.get(m.comp['ref']) for m in members}
            if len(mranks) > 1:
                self._parallel_skipped_cross_rank = getattr(
                    self, '_parallel_skipped_cross_rank', 0) + 1
                continue
            self._parallel_applied = getattr(self, '_parallel_applied', 0) + 1
            ordered = sorted(members, key=lambda m: m.comp['ref'])
            horizontal = orient.get(ordered[0].comp['ref'], 0) in (90, 270)
            if horizontal:
                # signal pair: parts are horizontal -> STACK at a common
                # x so left pins form the 'in' bus, right pins the 'out'
                # bus (the rungs of a left/right rectangle).
                xs = sorted(positions[id(m)][0] for m in members)
                base_x = xs[len(xs) // 2]
                y0 = min(positions[id(m)][1] for m in members)
                for k, inst in enumerate(ordered):
                    positions[id(inst)] = (base_x, y0 + k * SPACING)
            else:
                # grounded pair: parts are vertical -> side-by-side at a
                # common y so top pins form the signal bus, bottom pins
                # the rail bus (the rungs of a top/bottom rectangle).
                ys = sorted(positions[id(m)][1] for m in members)
                base_y = ys[len(ys) // 2]
                x0 = min(positions[id(m)][0] for m in members)
                for k, inst in enumerate(ordered):
                    positions[id(inst)] = (x0 + k * SPACING, base_y)

    def _boundary_cost_subgroups(self, instances, cut_nets,
                                  max_group=4):
        """In : instances, the cut-net set and a member cap.
        Out: a list of ref-lists, size >= 2 only; singletons are omitted.
        Agglomerative: from singletons, repeatedly merge the two groups
        sharing a non-cut net whose merge most REDUCES the nets crossing
        their boundary, stopping at max_group or when no merge helps.
        The metric counts NETS, not pins, and excludes cut and T nets
        (power, ground, IO, rails), which are high-fanout and drawn as
        T-symbols, so they do not count against tidiness.  This finds the
        tight sub-units an analog engineer recognises: a gain stage with
        its RC compensation, a source with its sense resistor."""
        # net -> set of instance ids, non-cut nets only.
        net_ids = defaultdict(set)
        for inst in instances:
            seen = set()
            for nn in (inst.comp.get('nets', []) or []):
                nl = nn.lower()
                if nl in cut_nets or nl in seen:
                    continue
                seen.add(nl)
                net_ids[nl].add(id(inst))

        gid_of = {id(i): id(i) for i in instances}
        members = {id(i): {id(i)} for i in instances}

        def boundary(idset):
            n = 0
            for ids in net_ids.values():
                if (ids & idset) and (ids - idset):
                    n += 1
            return n

        # Deterministic ordering.  Groups were keyed by
        # id(inst), so candidate-pair iteration order and gain-tie
        # breaks varied per process (id() differs each launch), making
        # the grouping — and any placement built on it — non-repeatable
        # (measured swing -7% to -25% flight on the same circuit).  Use
        # a stable per-id sort key = the instance's ref, so ties break
        # the same way every run.
        ref_of = {id(i): i.comp['ref'] for i in instances}

        def shared_group_pairs():
            pairs = set()
            for ids in net_ids.values():
                gids = {gid_of[i] for i in ids}
                if len(gids) >= 2:
                    # Order gids by ref, not raw id: ids are memory addresses,
                    # and the order decides which group absorbs which in the
                    # merge below.
                    pairs.update(combinations(
                        sorted(gids, key=lambda g: ref_of.get(g, '')), 2))
            # stable order: sort pairs by the ref-name of each group's
            # representative id (gid is itself an id, map via ref_of).
            return sorted(pairs,
                          key=lambda ab: (ref_of.get(ab[0], ''),
                                          ref_of.get(ab[1], '')))

        improved = True
        while improved:
            improved = False
            best = None
            best_gain = 0
            for a, b in shared_group_pairs():
                ma, mb = members[a], members[b]
                if len(ma) + len(mb) > max_group:
                    continue
                gain = (boundary(ma) + boundary(mb)) - boundary(ma | mb)
                if gain > best_gain:
                    best_gain = gain
                    best = (a, b)
            if best is not None and best_gain > 0:
                a, b = best
                members[a] |= members[b]
                for i in members[b]:
                    gid_of[i] = a
                del members[b]
                improved = True

        by_id = {id(i): i for i in instances}
        out = []
        for idset in members.values():
            if len(idset) >= 2:
                # stable member order within each group too
                out.append(sorted(by_id[i].comp['ref'] for i in idset))
        # stable group order
        out.sort()
        return out

    def _port_elements(self, inst):
        """In : an instance.  Out: a list of (port_label, frozenset(nets))
        with exactly two nets each — its 2-pin 'port elements', used for
        grouping.
        A 2-pin R/C/L or a 2-pin behavioral controlled source is a single
        element, its node pair.  A 4-pin VCVS/VCCS (E/G) exposes its
        CONTROL port only, pins 3 and 4, the sense side that can sit in
        parallel with a sense R or C; the driven OUTPUT port is
        deliberately omitted, since output nodes chain stages together
        and would over-merge a source ladder into one group."""
        pairs = getattr(inst, '_pin_net_pairs', []) or []
        nets = [n.lower() for _, n in pairs]
        k = inst.comp.get('kind', '').upper()
        out = []
        if k in ('R', 'C', 'L') and len(nets) == 2:
            out.append(('body', frozenset(nets)))
        elif k in ('E', 'G'):
            if len(nets) == 4:
                out.append(('ctrl', frozenset(nets[2:4])))
            elif len(nets) == 2:
                out.append(('body', frozenset(nets)))
        return [(lbl, key) for lbl, key in out if len(key) == 2]

    def _assign_group_ids(self, instances):
        """In : the instances.  Out: {ref: group_id}; also stamps
        inst.group_id and inst.group_kind ('parallel' | 'series' |
        'single').
        Members of a parallel sibling set (_parallel_groups) or a short
        series chain (_compute_series_chains) share one id; an instance
        in neither is its own singleton group.  These tight groups are
        the ATOMS the placer orders, rotates and places as a unit.  This
        method only ASSIGNS ids — it changes no placement."""
        refs = [i.comp['ref'] for i in instances]
        idx = {r: k for k, r in enumerate(refs)}
        parent = list(range(len(refs)))

        def find(x):
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x

        def union(a, b):
            ra, rb = find(a), find(b)
            if ra != rb:
                parent[ra] = rb

        kind = {}
        for grp in self._parallel_groups(instances):
            base = idx[grp[0]]
            for r in grp[1:]:
                if r in idx:
                    union(base, idx[r])
            for r in grp:
                kind[r] = 'parallel'
        for chain in self._compute_series_chains(instances):
            cr = [c.comp['ref'] for c in chain]
            base = idx[cr[0]]
            for r in cr[1:]:
                if r in idx:
                    union(base, idx[r])
            for r in cr:
                kind.setdefault(r, 'series')

        # #3: union via controlled-source PORTS so a sense
        # R/C parallel to a source's control port (or a 2-pin behavioral
        # source) joins the group.  _port_elements yields control-port /
        # 2-pin-body elements only (never the driven output port), which
        # avoids chaining a source ladder into one group.
        port_by_net = defaultdict(list)
        for inst in instances:
            for _lbl, key in self._port_elements(inst):
                port_by_net[key].append(inst.comp['ref'])
        for key, prefs in port_by_net.items():
            prefs = [r for r in prefs if r in idx]
            if len(prefs) < 2:
                continue
            base = idx[prefs[0]]
            for r in prefs[1:]:
                union(base, idx[r])
            for r in prefs:
                kind.setdefault(r, 'parallel')

        root_to_id = {}
        gid_map = {}
        nxt = 0
        for r in refs:
            root = find(idx[r])
            if root not in root_to_id:
                root_to_id[root] = nxt
                nxt += 1
            gid_map[r] = root_to_id[root]

        for inst in instances:
            r = inst.comp['ref']
            inst.group_id = gid_map[r]
        # expose ref->group_id for the Group-boxes overlay
        # (_render rebuilds instances, so it can't read inst.group_id).
        self._group_id_of = dict(gid_map)
        return gid_map

    def _match_diff_pairs(self, instances):
        """In : instances.  Out: a list of role dicts, one per pair found;
        pure analysis, no placement.
        Anchors on a matched transistor pair (same kind Q/M/J and same
        polarity) whose emitters or sources reach a COMMON tail node,
        directly or each through a degeneration resistor, then walks the
        nets for the roles the layout template uses: devices, inputs,
        collectors, loads, deg_resistors, tail_node, tail_source (a real
        I source, else a resistor acting as one) and tail_parts."""
        rail_nets = {str(n).lower() for n in (
            set(_PWR_NETS_LC_FOR_T)
            | set(getattr(self, '_promoted_rails', None) or ())
            | set(getattr(self, '_supply_rails', None) or ()))} | {'0'}
        by_ref = {i.comp['ref']: i for i in instances}
        net_refs = defaultdict(list)
        for i in instances:
            for n in i.comp.get('nets', []) or []:
                net_refs[n.lower()].append(i.comp['ref'])

        def kind(i):
            return i.comp.get('kind', '').upper()

        trans = [i for i in instances if kind(i) in ('Q', 'M', 'J')]
        used = set()
        out = []
        for a in range(len(trans)):
            ta = trans[a]
            if ta.comp['ref'] in used:
                continue
            na = [n.lower() for n in ta.comp.get('nets', []) or []]
            if len(na) < 3:
                continue
            for b in range(a + 1, len(trans)):
                tb = trans[b]
                if tb.comp['ref'] in used:
                    continue
                if kind(tb) != kind(ta):
                    continue
                if ta.comp.get('sym') != tb.comp.get('sym'):
                    continue            # same polarity (PNP/NPN/PMOS/...)
                nb = [n.lower() for n in tb.comp.get('nets', []) or []]
                if len(nb) < 3:
                    continue
                ea, eb = na[2], nb[2]    # emitter / source
                tail, dega, degb = None, None, None
                if ea == eb:
                    tail = ea
                else:
                    def via_r(enode):
                        for r in net_refs[enode]:
                            ri = by_ref[r]
                            if kind(ri) == 'R':
                                rn = [n.lower()
                                      for n in ri.comp.get('nets', []) or []]
                                if len(rn) == 2 and enode in rn:
                                    yield r, (rn[0] if rn[1] == enode
                                              else rn[1])
                    for ra, fa in via_r(ea):
                        for rb, fb in via_r(eb):
                            if fa == fb and ra != rb:
                                tail, dega, degb = fa, ra, rb
                                break
                        if tail:
                            break
                if tail is None:
                    continue
                # A degeneration path that lands on one device's own base is
                # feedback or a cascode, not a diff pair (LM324.sub Q10/Q13);
                # reject it.
                if tail in (na[1], nb[1]):
                    continue
                # Emitters tied straight to a SUPPLY are two common-emitter
                # stages, not a pair: nothing steers a shared current, and
                # "tail parts" there are just other parts on the rail --
                # LM324.sub's Q14/Q15 on VCC took the bias source feeding
                # Q14's base in as their tail.
                if str(tail).lower() in rail_nets:
                    continue
                consumed = {ta.comp['ref'], tb.comp['ref'], dega, degb}
                tail_parts = [r for r in net_refs[tail] if r not in consumed]
                if not tail_parts:
                    continue
                tail_src = None
                for r in tail_parts:
                    if kind(by_ref[r]) == 'I':
                        tail_src = r
                        break
                if tail_src is None:
                    for r in tail_parts:
                        if kind(by_ref[r]) == 'R':
                            tail_src = r
                            break
                ca, cb = na[0], nb[0]
                out.append({
                    'devices': (ta.comp['ref'], tb.comp['ref']),
                    'type': kind(ta), 'polarity': ta.comp.get('sym'),
                    'inputs': (na[1], nb[1]),
                    'collectors': (ca, cb),
                    'loads': ([r for r in net_refs[ca]
                               if r not in consumed and r != ta.comp['ref']],
                              [r for r in net_refs[cb]
                               if r not in consumed and r != tb.comp['ref']]),
                    'deg_resistors': (dega, degb),
                    'tail_node': tail,
                    'tail_source': tail_src,
                    'tail_parts': tail_parts,
                })
                used.add(ta.comp['ref'])
                used.add(tb.comp['ref'])
                break
        return out

    def _layout_diff_pair(self, roles, by_ref):
        """Stamp the canonical differential-pair cell from
        a role binding produced by _match_diff_pairs.

        Layout (body coords, +y down — same convention as _layout_sp):
        the matched devices sit side by side; each degeneration resistor
        is vertical directly below its device; the tail source is
        vertical, centred, below those; the collector/drain loads are
        vertical, above each device.  Returns (pos, bbox, members, rots),
        where `rots` is the orientation each part's pattern owns."""
        qa, qb = roles['devices']
        rea, reb = roles['deg_resistors']
        tail = roles['tail_source']

        def pick_R(lst):
            for r in lst or []:
                i = by_ref.get(r)
                if i is not None and i.comp.get('kind', '').upper() == 'R':
                    return r
            return None
        la = pick_R(roles['loads'][0])
        lb = pick_R(roles['loads'][1])
        # Optional bridging capacitor straight across the two collector/drain
        # nets (LM324.lib's C1), placed between the device columns.
        def pick_C_bridge(loads_a, loads_b):
            for r in set(loads_a or []) & set(loads_b or []):
                i = by_ref.get(r)
                if (i is not None and i.comp.get('kind', '').upper() == 'C'
                        and len(i.comp.get('nets', []) or []) == 2):
                    return r
            return None
        cap = pick_C_bridge(roles['loads'][0], roles['loads'][1])
        bodies = {}
        for r in (qa, qb, rea, reb, tail, la, lb, cap):
            if r and by_ref.get(r) is not None:
                bodies[r] = tuple(by_ref[r].sym_body_rel)
        if qa not in bodies or qb not in bodies:
            return {}, (0.0, 0.0, 0.0, 0.0), set(), {}

        def hh(r):
            b = bodies[r]
            return (b[3] - b[1]) / 2.0
        # 130 px between the devices of a PNP/PMOS pair: its load resistors face
        # inward, and their value text needs about 75 px to clear.
        _GAPL = 10.0

        def _val_label_w(r):
            """Value-label width only.  Still the right measure for the
            bridging-cap requirement below, which reasons about a part at
            a rotation it has not been given yet (the cap is turned 90
            further down, so its DRAWN width is its body HEIGHT) and so
            cannot use a measured reserved box the way _col_half does."""
            inst = by_ref.get(r)
            if inst is None:
                return 0.0
            vt = (inst.comp.get('value') or '').strip()
            if not vt:
                return 0.0
            try:
                w, _h = _measure_text(vt[:VALUE_MAX_CHARS], 10)
            except Exception:
                w = 0.0
            return w

        def _col_half(refs):
            """Widest reserved half-extent over one column's members."""
            best = 0.0
            for r in refs:
                if r not in bodies:
                    continue
                inst = by_ref.get(r)
                e = bodies[r]
                if inst is not None:
                    try:
                        e = self._placement_extent(inst)
                    except Exception:
                        e = bodies[r]
                best = max(best, abs(e[0]), abs(e[2]))
            return best
        _need = _col_half((la, rea, qa)) + _GAPL + _col_half((lb, reb, qb))
        colgap, rowgap = max(130.0, _need), 55.0
        xa, xb = -colgap / 2.0, colgap / 2.0

        def pin_dx(qref, idx):
            # x-offset (body coords) of the idx-th pin (0=collector/drain,
            # 2=emitter/source) so a resistor placed at that x lines up
            # straight under/over the pin instead of angling to the body
            # centre.
            inst = by_ref.get(qref)
            if inst is None:
                return 0.0
            pairs = getattr(inst, '_pin_net_pairs', None) or []
            if idx >= len(pairs):
                return 0.0
            p = (inst.sym_entry.get('pins', {}) or {}).get(pairs[idx][0])
            if not p:
                return 0.0
            return (p[0] - getattr(inst, 'mid_kx', 0.0)) \
                * getattr(inst, 'sym_scale', 1.0)
        cdx_a, edx_a = pin_dx(qa, 0), pin_dx(qa, 2)
        cdx_b, edx_b = pin_dx(qb, 0), pin_dx(qb, 2)
        # polarity-aware template (user's PNP insight):
        # for PNP/PMOS pairs the cell mirrors vertically — emitters and
        # their degeneration resistors face the TOP rail (tail source
        # above, centred), collectors and loads face the BOTTOM rail —
        # and the devices rotate 180 so the symbol reads correctly.
        # Pin x-offsets mirror with the 180 rotation.
        sym_u = (by_ref[qa].comp.get('sym') or '').upper()
        pnp = ('PNP' in sym_u) or ('PMOS' in sym_u) or ('PJF' in sym_u)
        sgn = -1.0 if pnp else 1.0
        if pnp:
            cdx_a, edx_a, cdx_b, edx_b = -cdx_a, -edx_a, -cdx_b, -edx_b
        # the cell FLIPS one device (qa for PNP, qb for NPN;
        # see `flips` below) to face its base outward.  A flip MIRRORS that
        # device's pin x-offsets, so the collector/emitter load and
        # degeneration resistors — which sit straight under/over those pins
        # — must use the MIRRORED offset too, or they land on the opposite
        # side from the flipped device's pins (the LM324 Q1 report: Q1's
        # collector/emitter ended up to the right of RC1/RE1).  Negate the
        # offsets of whichever device the cell flips.
        if pnp:
            cdx_a, edx_a = -cdx_a, -edx_a      # qa is flipped
        else:
            cdx_b, edx_b = -cdx_b, -edx_b      # qb is flipped
        # Widen the columns for the bridging cap: it sits between the device
        # columns on the load row, so a wide cap needs extra room.
        if cap and cap in bodies:
            _cb = bodies[cap]
            _cap_hw = (_cb[3] - _cb[1]) / 2.0 + _val_label_w(cap)

            def _load_hw(r):
                if not r or r not in bodies:
                    return 0.0
                return (bodies[r][2] - bodies[r][0]) / 2.0 + _val_label_w(r)
            _need_a = 2.0 * (_cap_hw + _GAPL + _load_hw(la) + cdx_a)
            _need_b = 2.0 * (_cap_hw + _GAPL + _load_hw(lb) - cdx_b)
            colgap = max(colgap, _need_a, _need_b)
            xa, xb = -colgap / 2.0, colgap / 2.0
        pos = {qa: (xa, 0.0), qb: (xb, 0.0)}
        yq = max(hh(qa), hh(qb))
        if la:
            pos[la] = (xa + cdx_a, sgn * -(yq + rowgap + hh(la)))
        if lb:
            pos[lb] = (xb + cdx_b, sgn * -(yq + rowgap + hh(lb)))
        if cap:
            # bridges the two collector nets directly,
            # so it belongs HORIZONTALLY between them, on the same row as
            # la/lb (their y — the collector-load row — not a new one),
            # centred (x=0, halfway between the two devices).
            cap_y = sgn * -(yq + rowgap + hh(cap))
            pos[cap] = (0.0, cap_y)
        yb = yq + rowgap
        if rea:
            pos[rea] = (xa + edx_a, sgn * (yb + hh(rea)))
        if reb:
            pos[reb] = (xb + edx_b, sgn * (yb + hh(reb)))
        if tail:
            re_h = max((2 * hh(rea)) if rea else 0.0,
                       (2 * hh(reb)) if reb else 0.0)
            pos[tail] = (0.0, sgn * (yb + re_h + rowgap + hh(tail)))
        members = set(pos.keys())
        # the load / degeneration resistors sit in two
        # columns (la/rea under qa on the left, lb/reb under qb on the
        # right).  Their value text must go on the OUTWARD side or it
        # overlaps the OTHER column's body (RE1/RC1 text over RE2/RC2 —
        # place_texts can't see the neighbour because it positions each
        # part's text at the origin before the cell is assembled).  Tag
        # each resistor with the side its value text should prefer:
        # left column → text to the LEFT ('e' anchor), right column →
        # text to the RIGHT ('w').  _value_candidates honours the hint.
        for _r in (la, rea):
            if _r and by_ref.get(_r) is not None:
                by_ref[_r]._value_text_prefer = 'left'
        for _r in (lb, reb):
            if _r and by_ref.get(_r) is not None:
                by_ref[_r]._value_text_prefer = 'right'
        # the outward-base mirror is polarity-dependent
        # (user's LM324_PNP_FIX placement): at rot 0 (NPN) the base pin
        # sits LEFT, so the RIGHT device (qb) mirrors; at rot 180 (PNP)
        # the base lands RIGHT, so the LEFT device (qa) mirrors instead.
        flips = {qa: True} if pnp else {qb: True}
        rots = {r: 0 for r in (rea, reb, la, lb, tail) if r in pos}  # vertical
        # the bridging cap's leads run LEFT-RIGHT
        # (toward each collector), unlike la/lb/rea/reb/tail which are
        # vertical, so it gets its own 90 deg rotation regardless of pnp.
        if cap in pos:
            rots[cap] = 90
        if pnp:
            for r in (qa, qb, rea, reb, la, lb):
                if r in pos:
                    rots[r] = 180
            if tail in pos:
                ti = by_ref.get(tail)
                tn = [n.lower() for n in (ti.comp.get('nets', []) or [])] \
                    if ti is not None else []
                tnode = str(roles.get('tail_node', '')).lower()
                rots[tail] = 0 if (len(tn) == 2 and tn[1] == tnode) else 180
        # rail polarity hint: the rail the tail source
        # ties to is POSITIVE for a PNP pair (emitters look up at VCC)
        # and NEGATIVE for NPN.  Used by rail_parallel_columns to pick
        # which rail is 'top' for rail-to-rail branches like DP||RP.
        ti = by_ref.get(tail) if tail else None
        if ti is not None:
            tn = [n.lower() for n in (ti.comp.get('nets', []) or [])]
            tnode = str(roles.get('tail_node', '')).lower()
            if len(tn) == 2 and tnode in tn:
                rail_side = tn[0] if tn[1] == tnode else tn[1]
                if pnp:
                    self._rail_pos_hint = rail_side
                else:
                    self._rail_neg_hint = rail_side
        xs, ys = [], []
        for r, (x, y) in pos.items():
            b = bodies[r]
            xs += [x + b[0], x + b[2]]
            ys += [y + b[1], y + b[3]]
        bbox = (min(xs), min(ys), max(xs), max(ys))
        return pos, bbox, members, rots, flips

    def _match_darlington_diff_pairs(self, instances):
        """In : the instances.  Out: a list of role dicts {front, rear,
        tail_node, inputs, diodes, tail_source}; pure analysis, placement
        being _layout_darlington_diff_pair's job.
        Recognises a DARLINGTON differential input pair: a matched FRONT
        pair whose EMITTERS each drive the BASE of a matched REAR pair
        sharing a common emitter TAIL — the ON-Semi LM324's Q1->Q18 and
        Q2->Q19, with Q3/Q4 diode-connected on inter-stage nets 9 and 10.
        Tighter than a plain diff-pair match, so it does not fire on one:
        the rear devices must be non-diode-connected, share an emitter
        and have distinct bases, each the emitter of a matched front."""
        by_ref = {i.comp['ref']: i for i in instances}
        net_refs = defaultdict(list)
        for i in instances:
            for n in (i.comp.get('nets') or []):
                net_refs[n.lower()].append(i.comp['ref'])

        def kind(i):
            return i.comp.get('kind', '').upper()

        def nets(i):
            return [n.lower() for n in (i.comp.get('nets', []) or [])]

        trans = [i for i in instances if kind(i) in ('Q', 'M', 'J')]
        out, used = [], set()
        for a in range(len(trans)):
            ta = trans[a]
            if ta.comp['ref'] in used:
                continue
            na = nets(ta)
            if len(na) < 3 or na[0] == na[1]:      # rear must not be diode-conn
                continue
            for b in range(a + 1, len(trans)):
                tb = trans[b]
                if tb.comp['ref'] in used:
                    continue
                if kind(tb) != kind(ta):
                    continue
                if ta.comp.get('sym') != tb.comp.get('sym'):
                    continue
                nb = nets(tb)
                if len(nb) < 3 or nb[0] == nb[1]:
                    continue
                if na[2] != nb[2]:                 # common emitter tail
                    continue
                if na[1] == nb[1]:                 # distinct rear bases
                    continue
                tail = na[2]

                def front_of(base_net, excl):
                    for r in net_refs[base_net]:
                        if r in excl:
                            continue
                        fi = by_ref[r]
                        if kind(fi) not in ('Q', 'M', 'J'):
                            continue
                        fn = nets(fi)
                        if (len(fn) >= 3 and fn[2] == base_net
                                and fn[0] != fn[1]):
                            # emitter on base_net, non-diode
                            return r
                    return None

                excl = {ta.comp['ref'], tb.comp['ref']}
                fa = front_of(na[1], excl)
                fb = front_of(nb[1], excl)
                if not fa or not fb or fa == fb:
                    continue
                fia, fib = by_ref[fa], by_ref[fb]
                if kind(fia) != kind(fib):
                    continue
                if fia.comp.get('sym') != fib.comp.get('sym'):
                    continue
                # distinct front (input) bases
                if nets(fia)[1] == nets(fib)[1]:
                    continue

                def diode_on(net, excl):
                    for r in net_refs[net]:
                        if r in excl:
                            continue
                        di = by_ref[r]
                        if kind(di) in ('Q', 'M', 'J'):
                            dn = nets(di)
                            if len(dn) >= 3 and dn[0] == dn[1] == net:
                                return r
                    return None

                allx = excl | {fa, fb}
                da = diode_on(na[1], allx)
                db = diode_on(nb[1], allx)
                tail_parts = [r for r in net_refs[tail]
                              if r not in (allx | {da, db})]
                tail_src = None
                for r in tail_parts:
                    if kind(by_ref[r]) == 'I':
                        tail_src = r
                        break
                out.append({
                    'front': (fa, fb),
                    'rear': (ta.comp['ref'], tb.comp['ref']),
                    'type': kind(ta), 'polarity': ta.comp.get('sym'),
                    'tail_node': tail,
                    'inputs': (nets(fia)[1], nets(fib)[1]),
                    'diodes': (da, db),
                    'tail_source': tail_src,
                })
                used.update({ta.comp['ref'], tb.comp['ref'], fa, fb})
                break
        return out

    def _layout_darlington_diff_pair(self, roles, by_ref):
        """In : the matched roles and the ref lookup.  Out: (pos, bbox,
        members, rots, flips) in body coords, +y down.
        Stamps a Darlington diff-pair cell: two matched vertical stacks,
        front transistor over rear transistor sharing a column x, the
        inputs entering the FRONT bases on the OUTER sides, the rear
        emitters meeting at a common tail at the bottom centre, and the
        diode-connected loads just outside each stack.
        Each device is rotated by _col_rot_down so its EMITTER faces the
        part below it, and the LEFT stack is flipped so the two bases
        face outward and the inputs read inward to outward."""
        fa, fb = roles['front']
        ra, rb = roles['rear']
        da, db = roles['diodes']
        tail = roles['tail_source']
        refs = [r for r in (fa, fb, ra, rb, da, db, tail) if r]
        bodies = {}
        for r in refs:
            inst = by_ref.get(r)
            if inst is not None:
                bodies[r] = tuple(inst.sym_body_rel)
        if not all(x in bodies for x in (fa, fb, ra, rb)):
            return {}, (0.0, 0.0, 0.0, 0.0), set(), {}, {}

        # Emitter net of each transistor = nets[2]; rotate emitter-down.
        def emit_net(r):
            ns = [n.lower() for n in (by_ref[r].comp.get('nets', []) or [])]
            return ns[2] if len(ns) >= 3 else None

        rots = {}
        for r in (fa, fb, ra, rb):
            en = emit_net(r)
            rots[r] = _col_rot_down(self, by_ref[r], en) if en else 0
        # diodes: collector=base on top, emitter (tail) down
        for r in (da, db):
            if r in bodies:
                en = emit_net(r)
                rots[r] = _col_rot_down(self, by_ref[r], en) if en else 0
        if tail in bodies:
            rots[tail] = 0

        # Measure each body AT its chosen rotation for honest spacing.
        rb_box = {r: _body_box_at_rot(self, by_ref[r], rots[r]) for r in bodies}

        def bh(r):
            bx = rb_box[r]
            return (bx[3] - bx[1]) / 2.0

        def bw(r):
            bx = rb_box[r]
            return (bx[2] - bx[0]) / 2.0

        colgap = max(170.0, 2 * max(bw(ra), bw(rb)) + 90.0)
        rowgap = 46.0
        xL, xR = -colgap / 2.0, colgap / 2.0

        # Rear devices at y=0; front devices stacked above; tail below centre.
        pos = {ra: (xL, 0.0), rb: (xR, 0.0)}
        qh = max(bh(ra), bh(rb))
        fh = max(bh(fa), bh(fb))
        pos[fa] = (xL, -(qh + rowgap + fh))
        pos[fb] = (xR, -(qh + rowgap + fh))
        if tail in bodies:
            pos[tail] = (0.0, qh + rowgap + bh(tail))
        # Diode loads: just OUTSIDE each stack, vertical, aligned to rear row.
        # Space by the actual rear+diode half-widths plus clearance for the
        # model-name label, since the rigid cell is never internally separated.
        if da in bodies:
            pos[da] = (xL - (bw(ra) + bw(da) + 74.0), 0.0)
        if db in bodies:
            pos[db] = (xR + (bw(rb) + bw(db) + 74.0), 0.0)

        members = set(pos)
        # Bases face OUTWARD (inputs enter from the sides without crossing):
        # an unflipped device's base sits on the LEFT, so the LEFT column is
        # already outward and the RIGHT column flips to face its base right.
        flips = {fb: True, rb: True}
        if db in bodies:
            flips[db] = True

        # Rail-polarity flip: devices are stamped emitter-down, which puts a
        # negative rail on top when the pair's rail pin is its collector
        # (LM324.sub Q1/Q2).  Flip such a cell vertically.
        _ovr = getattr(self, '_rail_polarity_overrides', None) or {}
        _neg_set = {str(n).lower() for n in
                    (getattr(self, '_neg_power_nets', None) or ())}
        _neg_set |= {str(k).lower() for k, v in _ovr.items() if v == '-'}
        _pos_set = {str(k).lower() for k, v in _ovr.items() if v == '+'}
        _auto_pos, _auto_neg = self._rail_polarity()
        if not _neg_set and _auto_neg:
            _neg_set = {str(_auto_neg).lower()}
        if not _pos_set and _auto_pos:
            _pos_set = {str(_auto_pos).lower()}

        def _cell_is_inverted():
            for r in (fa, fb):
                inst = by_ref.get(r)
                if inst is None:
                    continue
                nets = [str(n).lower()
                        for n in (inst.comp.get('nets') or [])]
                cands = [(n, True) for n in nets if n in _neg_set]
                cands += [(n, False) for n in nets if n in _pos_set]
                for rail, want_down in cands:
                    down = _col_rot_down(self, inst, rail)
                    if down is None:
                        continue
                    want = down if want_down else (down + 180) % 360
                    return (rots.get(r, 0) % 360) != (want % 360)
            return False

        if _cell_is_inverted():
            for r in list(rots):
                rots[r] = (rots[r] + 180) % 360
            for r in list(pos):
                x, y = pos[r]
                pos[r] = (x, -y)
            for r in members:
                flips[r] = not flips.get(r, False)
            rb_box = {r: _body_box_at_rot(self, by_ref[r], rots[r])
                      for r in bodies}

        xs, ys = [], []
        for r, (x, y) in pos.items():
            bx = rb_box[r]
            xs += [x + bx[0], x + bx[2]]
            ys += [y + bx[1], y + bx[3]]
        bbox = (min(xs), min(ys), max(xs), max(ys))
        return pos, bbox, members, rots, flips

    def _rails_from_t_rot(self):
        """In : _t_net_rot_overrides (the saved T rotation per net) and
               _neg_power_nets.
        Proc: read each net's rail glyph — 180 is +power, 0 is
              ground/-power — and split the rot-0 family into true ground
              ('0' and the gnd synonyms) and the rest, a negative supply.
        Out : (pos_set, neg_set, gnd_set) of lowercase net names.
        The rotation is the user's own statement of what a net IS, so it
        outranks a structural guess: on LM324.sub the diff-pair detector
        infers ordinary internal nets 18 and 7 while the saved file says
        3 and 4.  A 90 or 270 is an IO port and is ignored here."""
        pos, neg, gnd = set(), set(), set()
        for nl, rot in (getattr(self, '_t_net_rot_overrides', None)
                        or {}).items():
            nl = str(nl).lower()
            if rot == 180:
                pos.add(nl)
            elif rot == 0:
                (gnd if (nl == '0' or nl in _GND_NETS_LC) else neg).add(nl)
        neg |= {str(n).lower()
                for n in (getattr(self, '_neg_power_nets', None) or ())}
        return pos, neg - gnd, gnd

    def _rail_polarity(self):
        """Return (pos_net_lc, neg_net_lc) or (None, None), from the Nets dialog
        roles, then saved T glyphs, then diff-pair polarity hints.
        """
        overrides = {str(n).lower(): p
                     for n, p in (getattr(self, '_rail_polarity_overrides',
                                          None) or {}).items()}

        def _pick(cands):
            """Prefer a non-ground net, then sort for a stable answer."""
            real = {n for n in cands
                    if n != '0' and n not in _GND_NETS_LC}
            return min(real) if real else (min(cands) if cands else None)

        pos = _pick({n for n, p in overrides.items() if p == '+'})
        neg = _pick({n for n, p in overrides.items() if p == '-'})
        t_pos, t_neg, _t_gnd = self._rails_from_t_rot()
        if pos is None:
            pos = _pick(t_pos)
        if neg is None:
            neg = _pick(t_neg)
        _sp = getattr(self, '_supply_port_pol', None) or {}
        if pos is None:
            pos = _pick({n for n, p in _sp.items() if p == '+'})
        if neg is None:
            neg = _pick({n for n, p in _sp.items() if p == '-'})
        if pos is None:
            pos = getattr(self, '_rail_pos_hint', None)
        if neg is None:
            neg = getattr(self, '_rail_neg_hint', None)
        rails = {str(r).lower()
                 for r in (getattr(self, '_supply_rails', None) or set())}
        if pos and neg is None and pos in rails and len(rails) == 2:
            neg = next(r for r in rails if r != pos)
        if neg and pos is None and neg in rails and len(rails) == 2:
            pos = next(r for r in rails if r != neg)
        return pos, neg

    def _active_ports_lc(self):
        """The viewed .SUBCKT's port names, lower case, in declared order."""
        if not (self._parser and self._parser.subckts):
            return []
        active = (self._active_subckt or '').upper()
        if active and active in self._parser.subckts:
            ports = self._parser.subckts[active].get('ports', [])
        else:
            ports = [p for sc in self._parser.subckts.values()
                     for p in sc.get('ports', [])]
        return [str(p).lower() for p in ports]

    def _supply_ports_structural(self, instances):
        """Takes the instances and returns {port: '+' or '-'} for ports that
        are supplies by structure alone, whatever they are called. A supply
        port carries 3+ transistor current terminals (collector/emitter,
        drain/source) and no control terminal, and is joined to another such
        port by an element that bridges the two directly -- the quiescent
        current source, bleed resistor or supply-current G of a macro model.
        Polarity is the vote of those terminals: an NPN collector or PNP
        emitter (NMOS drain, PMOS source) returns to the positive supply."""
        ports = set(self._active_ports_lc()) - {'0'} - _GND_NETS_LC
        if not ports:
            return {}
        votes = defaultdict(int)
        cur = defaultdict(int)
        ctrl = set()
        for i in instances:
            nets = [str(n).lower() for n in (i.comp.get('nets') or [])]
            sym = str(i.comp.get('sym') or '').upper()
            kind = str(i.comp.get('kind') or i.comp['ref'][:1]).upper()
            if kind in ('Q', 'M', 'J') and len(nets) >= 3:
                # Q is C B E and M/J is D G S: the high side is the PNP
                # emitter or PMOS source, the NPN collector or NMOS drain.
                hi, lo = (2, 0) if sym.startswith('P') else (0, 2)
                ctrl.add(nets[1])
                cur[nets[hi]] += 1
                cur[nets[lo]] += 1
                votes[nets[hi]] += 1
                votes[nets[lo]] -= 1
            elif kind == 'D' and len(nets) >= 2:
                votes[nets[1]] += 1          # cathode sits high
                votes[nets[0]] -= 1
            elif kind == 'I' and len(nets) >= 2:
                votes[nets[0]] += 1          # current leaves the high node
                votes[nets[1]] -= 1
            elif kind in ('E', 'F', 'G', 'H') and len(nets) >= 2 \
                    and not set(nets[:2]) <= ports:
                # A source output is a signal -- unless it spans two
                # ports, which is a macro model's supply-current source.
                ctrl.update(nets[:2])
        # A transistor's control pin disqualifies a port; an E/G control
        # input does not -- macro models routinely sense their supplies.
        cand = {p for p in ports if p not in ctrl}
        links = defaultdict(int)
        for i in instances:
            kind = str(i.comp.get('kind') or i.comp['ref'][:1]).upper()
            nets = [str(n).lower() for n in (i.comp.get('nets') or [])[:2]]
            if kind in ('R', 'I', 'D', 'G') and len(set(nets)) == 2 \
                    and set(nets) <= cand:
                links[frozenset(nets)] += 1
        paired = set()
        for pair, n in links.items():
            if n >= 2 or any(cur[p] >= 3 for p in pair):
                paired |= pair
        return {p: ('+' if votes[p] > 0 else '-') for p in sorted(paired)
                if votes[p]}

    def _detect_supply_rails(self, instances):
        """Return the supply rails: the rails declared by Nets-dialog roles and
        saved T glyphs, united with rails inferred from diff-pair structure.
        """
        by_ref = {i.comp['ref']: i for i in instances}
        candidates = {}       # net_lc -> set of contributing instance ids
        for roles in self._match_diff_pairs(instances):
            tail = roles['tail_source']
            tnode = str(roles['tail_node']).lower()
            if tail and by_ref.get(tail) is not None:
                tail_inst = by_ref[tail]
                for n in tail_inst.comp.get('nets', []) or []:
                    if n.lower() != tnode:
                        candidates.setdefault(n.lower(), set()).add(
                            id(tail_inst))
            for coll, loads in zip(roles['collectors'], roles['loads']):
                cl = str(coll).lower()
                for r in loads:
                    ri = by_ref.get(r)
                    if (ri is not None
                            and ri.comp.get('kind', '').upper() == 'R'):
                        for n in ri.comp.get('nets', []) or []:
                            if n.lower() != cl:
                                candidates.setdefault(n.lower(), set()).add(
                                    id(ri))
        t_pos, t_neg, _t_gnd = self._rails_from_t_rot()
        declared = t_pos | t_neg | {
            str(n).lower()
            for n, p in (getattr(self, '_rail_polarity_overrides', None)
                         or {}).items() if p in ('+', '-')}
        declared.discard('0')
        declared -= _GND_NETS_LC
        # Supply PORTS found by structure outrank the diff-pair guess,
        # which on LM324.sub proposes the internal nets 18 and 7.
        self._supply_port_pol = self._supply_ports_structural(instances)
        if self._supply_port_pol:
            return declared | set(self._supply_port_pol)
        return declared | {n for n, contributors in candidates.items()
                           if len(contributors) >= 2}



    def _p2dl_rules(self):
        """Out: the default P2DL rule list, reproducing today's pipeline
        declaratively for the parity gate.
          sp_blocks   _assign_sp_groups — group and cache every non-leaf
                      series-parallel composite, with consume=False,
                      since grouping does not own orientation.
          the four orientation rules   _apply_flow_orientation, by
                      consume-on-match: the specific on-rail rule fires
                      and consumes first, the general in-flow rule sweeps
                      the rest, and a part with BOTH pins on rails falls
                      through to in-flow/horizontal, matching the XOR."""
        def _sp_teardown(ctx):
            ctx.app._group_id_of = {
                i.comp['ref']: i.group_id for i in ctx.instances}
            # sp_pack respects the unconsumed set like other P2DL rules;
            # sp_blocks runs with consume=False so a more specific rule can
            # still override its layout.
            for members in _stable_blocks(ctx.app._sp_block_layout):
                if all(r in ctx.unconsumed for r in members):
                    ctx.unconsumed.difference_update(members)

        def _dp_setup(ctx):
            ctx.app._pattern_oriented = set()
            ctx.app._auto_flips = {}
            ctx.app._port_side_locked = set()
            ctx.app._rail_pos_hint = None
            ctx.app._rail_neg_hint = None
            # Rebuilt every Place: a second Place must not start from the first
            # one's cached block geometry.
            ctx.app._sp_block_layout = {}
            ctx.app._sp_rigid_blocks = set()

        def _pb_setup(ctx):
            """Ensure the block caches exist WITHOUT clearing them.
            _dp_setup already rebuilt them fresh this Place if the
            diff-pair rules ran (they run first), so clearing here would
            throw away whatever they just cached; but those rules are
            behind their own gate, so the structures cannot simply be
            assumed to be there either."""
            if getattr(ctx.app, '_sp_block_layout', None) is None:
                ctx.app._sp_block_layout = {}
            if getattr(ctx.app, '_sp_rigid_blocks', None) is None:
                ctx.app._sp_rigid_blocks = set()
            if getattr(ctx.app, '_pattern_oriented', None) is None:
                ctx.app._pattern_oriented = set()

        rules = []
        # recognise a DARLINGTON differential
        # input pair (front emitter-followers feeding a common-tail rear
        # pair, e.g. ON-Semi LM324 Q1/Q2→Q18/Q19) and lay each side out as
        # a vertical front-over-rear stack.  Runs BEFORE the generic
        # diff_pair rule so it claims the four transistors first; on a
        # netlist with no Darlington input it simply never matches.
        rules.append(
            _P2DLRule('darlington_diff_pair', _P2DLDarlingtonPairs(),
                      [_p2dl_act_darlington_diff_pair],
                      phase='group', consume=True,
                      setup=_dp_setup, teardown=_sp_teardown))
        # template.diff_pair (port of
        # _assign_pattern_groups), same checkbox gate as legacy.
        rules.append(
            _P2DLRule('diff_pair', _P2DLDiffPairs(),
                      [_p2dl_act_diff_pair],
                      phase='group', consume=True,
                      setup=_dp_setup, teardown=_sp_teardown))
        # group-relative rule: the compensation cap
        # spanning BOTH collector nets joins the pair cell,
        # centered between the device and load rows (user key:
        # C1 rot 90 between the rows).
        rules.append(
            _P2DLRule('pair_comp_cap',
                      _P2DLGroupDev('diff_pair', 'C',
                                    lambda meta:
                                    meta.get('collectors') or ()),
                      [_p2dl_act_join_pair_center],
                      phase='group', consume=True,
                      teardown=_sp_teardown, rounds=True))
        # a controlled source whose CONTROL pair is
        # the pair's collector nets (GA senses 11/12) joins at the
        # cell's right edge.
        rules.append(
            _P2DLRule('pair_sense_src',
                      _P2DLGroupDev('diff_pair', 'E|G',
                                    lambda meta:
                                    meta.get('collectors') or (),
                                    npins=(4, None),
                                    dev_nets=lambda inst, nets:
                                    [tuple(nets[2:4])]
                                    if len(nets) >= 4 else []),
                      [_p2dl_act_join_pair_right],
                      phase='group', consume=True,
                      teardown=_sp_teardown, rounds=True))
        # sense pairs outside any group (REE||GCM).
        # DEFAULT OFF: on OPAx197 this fires 14x (46 refs
        # regrouped) with unvetted downstream rotation cascades
        # (incl. an unexplained X_U35.G1 180) — needs visual
        # verification on the behavioral standard before default-on.
        rules.append(
            _P2DLRule('sense_pair', _P2DLSensePair(),
                      [_p2dl_act_sense_pair],
                      phase='group', consume=True,
                      teardown=_sp_teardown, rounds=True))
        # SHUNT-R / SERIES-RC TAP.  Runs BEFORE parallel_bank: the two
        # legs share <tap> and <mid> but are not parallel MEMBERS (one
        # leg is two parts), so the bank rule cannot see the cell, while
        # this rule consuming first keeps the bank rule off its parts.
        rules.append(
            _P2DLRule('shunt_rc_tap', _P2DLShuntRCTap(),
                      [_p2dl_act_shunt_rc_tap],
                      phase='group', consume=True,
                      setup=_pb_setup, teardown=_sp_teardown))
        # PARALLEL BANK.  Gated separately from the diff-pair family
        # because it answers a different question (self-graze
        # elimination, not device matching) and has to be switchable on
        # its own to measure.  Runs after them so a bank member already
        # claimed by a pair cell stays claimed — ctx.unconsumed does the
        # arbitration.
        rules.append(
            _P2DLRule('parallel_bank', _P2DLParallelBank(),
                      [_p2dl_act_parallel_bank],
                      phase='group', consume=True,
                      setup=_pb_setup, teardown=_sp_teardown))
        return rules

    def _run_p2dl(self, instances, phase):
        """Execute the P2DL rules of one pipeline phase.

        'group'  runs where _assign_sp_groups ran (before pattern groups,
                 which need the gids); 'orient' runs after supply-rail
                 detection, where the rail facts exist.  One ordered rule
                 list, staged execution.  At orient, user-rotated and
                 pattern-owned refs are pre-consumed (the legacy skip
                 set), and _flow_orient_applied is raised so the render
                 pass doesn't overwrite the rules' orientations."""
        ctx = _P2DLContext(self, instances)
        if phase == 'orient':
            ctx.unconsumed.difference_update(self._user_rotations)
            ctx.unconsumed.difference_update(
                getattr(self, '_pattern_oriented', set()))
        rules = [r for r in self._p2dl_rules() if r.phase == phase]
        # STAGED GROUP-RELATIVE ROUNDS (user's design):
        # setups bracket the phase ONCE (re-running them would wipe the
        # earlier rounds' artifacts), then the rule list repeats until
        # a round fires nothing (rounds=True rules only after round 1;
        # one-shot structural rules would mint fresh gids).  Round
        # counts land in ctx.rule_hits['_rounds'] for debugging.
        for rule in rules:
            if rule.setup:
                rule.setup(ctx)
        round_no, cap = 0, 6
        while round_no < cap:
            round_no += 1
            fired = 0
            for rule in rules:
                if round_no > 1 and not rule.rounds:
                    continue
                fired += rule.run_acts(ctx)
            if not fired:
                break
        ctx.rule_hits['_rounds'] = [round_no]
        if phase == 'group':
            self._p2dl_pair_two_pin_nets(ctx)
            self._p2dl_cache_bare_groups(ctx)
        for rule in rules:
            if rule.teardown:
                rule.teardown(ctx)
        self._p2dl_pin_t_requests = ctx.pin_t_requests
        hits = getattr(self, '_p2dl_rule_hits', {})
        hits[phase] = ctx.rule_hits
        self._p2dl_rule_hits = hits
        return ctx

    def _p2dl_pair_two_pin_nets(self, ctx):
        """Give each plain wire (a two-pin net between two parts, neither a rail
        nor a port) a group id so the two parts sit side by side.
        """
        # A ref is unavailable when it ALREADY belongs to something --
        # a cached block or a multi-member gid.  Record WHAT it belongs
        # to rather than just that it does: a candidate cell that
        # contains the whole of that something can ABSORB it (step (c)
        # of the hierarchical-group work), while one that would split it
        # still has to leave it alone.
        belongs = {}
        skip = set()

        def _claim(ref, whole):
            belongs.setdefault(ref, set()).update(whole)
        # A ref inside a cached BLOCK stays off limits.  Growing a
        # GROUP is free -- it has no geometry yet, so the cell simply
        # gets built bigger -- but swallowing a block throws away a
        # layout some idiom rule already decided, and doing that to
        # three of them cost OPAx197 122 crossings (234 -> 356).
        for k in (self._sp_block_layout or {}):
            skip |= set(k)
        gid_n = defaultdict(int)
        gid_refs = defaultdict(set)
        for inst in ctx.instances:
            g = getattr(inst, 'group_id', None)
            if g is not None:
                gid_n[g] += 1
                gid_refs[g].add(inst.comp['ref'])
        for inst in ctx.instances:
            g = getattr(inst, 'group_id', None)
            if g is not None and gid_n[g] > 1:
                _claim(inst.comp['ref'], gid_refs[g])
        rails = set(ctx.pwr) | set(ctx.gnd) | set(ctx.out)
        try:
            rails |= {str(n).lower()
                      for n in (self._eligible_t_nets() or ())}
        except Exception:
            pass
        pins = defaultdict(set)
        for inst in ctx.instances:
            for n in (inst.comp.get('nets', []) or []):
                pins[str(n).lower()].add(inst.comp['ref'])
        for net in sorted(pins):
            if net in rails:
                continue
            refs = sorted(pins[net])
            if not 2 <= len(refs) <= self._leaf_cell_max:
                continue
            if any(r in skip for r in refs):
                continue
            # ABSORB, don't split.  A member that already belongs to a
            # smaller cell is allowed in only when that WHOLE cell is
            # inside this one -- E4's control pair on LM324.sub, where
            # V54/R81 are already a cell and the net says all three
            # belong together.  A cell that would be cut in half is
            # still refused.
            if any(r in belongs for r in refs):
                continue

            def _n_outside(r):
                """How many non-rail nets this part has BESIDES this one
                -- 0 makes it a LEAF of the drawn graph."""
                own = {str(n).lower()
                       for n in (ctx.by_ref[r].comp.get('nets') or [])}
                return len([n for n in own if n not in rails and n != net])
            if any(r not in ctx.by_ref for r in refs):
                continue
            # ALL BUT ONE MEMBER MUST BE A LEAF.  The leaf has nowhere
            # else to be, so pinning it beside the others cannot fight
            # another wire; the one non-leaf is the member that carries
            # the cell's signal onward.  Two members that both have
            # somewhere else to be is where this turns into plain
            # affinity grouping, and the cost shows on the big deck.
            # Counting INSTANCES rather than pins is what lets a
            # diode-connected part join: LM324.sub's Q20 puts two of its
            # pins on net 25, so the pin count says 4 while the picture
            # says three parts on one wire.
            if len([r for r in refs if _n_outside(r) > 0]) > 1:
                continue
            ctx.assign_group(refs)
            skip.update(refs)

    def _chain_related_sets(self, instances):
        """In : the cluster's instances.
        Proc: build the chain graph and report the ref sets it already
              relates — the spine as one set, each subchain as another.
        Out : [set(refs), ...]; empty when the chain placer is off or the
              graph cannot be built.
        Cheap enough to call from the P2DL group phase, being the same
        graph _chain_relayout builds a moment later, and the only way to
        ask at that point whether the chain layout already has an opinion
        about a set of parts."""
        try:
            g = self._chain_graph(instances)
        except Exception:
            return []
        out = []
        sp = set(g.get('spine') or ())
        if sp:
            out.append(sp)
        for comp in (g.get('comps') or ()):
            out.append(set(comp))
        return out

    def _p2dl_cache_bare_groups(self, ctx):
        """In : the P2DL context at the end of the group phase.
        Proc: for every gid with 2+ members and NO cached block, cache a
              left-to-right ROW as its layout.
        Out : nothing; writes _sp_block_layout through ctx.cache_block.
        A group with no cached block is a cell in name only: the lane
        units, the block overlay and the packer all key off
        _sp_block_layout, so its members are ranked and packed
        separately (LP2951's gid 20 finished 545 px apart).  A row is the
        default — left-to-right flow, and the y axis has no slack — and
        rules that cache a better shape are left alone."""
        members = defaultdict(list)
        for inst in ctx.instances:
            g = getattr(inst, 'group_id', None)
            if g is not None:
                members[g].append(inst.comp['ref'])
        cached = {frozenset(k) for k in (self._sp_block_layout or {})}
        # A group that touches the spine belongs to the chain layout: a row
        # minted here would override where the chain puts those parts.
        related = self._chain_related_sets(ctx.instances)
        for g in sorted(members, key=str):
            refs = sorted(members[g])
            if len(refs) < 2 or frozenset(refs) in cached:
                continue
            if related and len(set(refs) & related[0]) > 1:
                continue
            # A BLOCK WHOLLY INSIDE THIS GROUP BECOMES ONE CHILD of its
            # layout — this is the nesting step (c) asks for, and it
            # needs no new key: _p2dl_pack already takes child LAYOUTS,
            # so a cached (relpos, bbox) can be handed to it exactly as
            # a leaf is.  The inner block keeps its own shape and the
            # outer cell places it as a unit.  A block that sticks OUT
            # of this group is a different question and still refuses.
            inner = [k for k in cached if set(k) & set(refs)]
            if any(not (set(k) <= set(refs)) for k in inner):
                continue
            # ONLY A LEAF CELL MAY ABSORB.  The gids this permits are
            # the ones _p2dl_pair_two_pin_nets built, whose members are
            # dead ends on the net that named them.  Every other gid was
            # placed by an IDIOM rule -- a diff pair, a shunt-RC tap --
            # whose block is the specific answer to a question this
            # generic packer cannot ask, and swallowing one costs
            # OPAx197 122 crossings (234 -> 356).
            if inner:
                continue
            inner.sort(key=lambda kk: sorted(kk))
            children = [(dict(self._sp_block_layout[k][0]),
                         tuple(self._sp_block_layout[k][1]))
                        for k in inner]
            held = set()
            for k in inner:
                held |= set(k)
            loose = [r for r in refs if r not in held]
            pack_x = True
            if children:
                children.extend(_p2dl_leaf_layouts(ctx, loose))
            else:
                self._p2dl_parallel_agree(ctx, loose)
                loose, pack_x = self._p2dl_cell_direction(ctx, loose)
                children = _p2dl_leaf_layouts(ctx, loose)
            relpos, bbox = _p2dl_pack(children, pack_x=pack_x)
            for k in inner:
                self._sp_block_layout.pop(k, None)
                if getattr(self, '_sp_rigid_blocks', None):
                    self._sp_rigid_blocks.discard(k)
            ctx.cache_block(refs, relpos, bbox)

    def _p2dl_parallel_agree(self, ctx, refs):
        """In : the P2DL context and a bare cell's member refs.
        Proc: when every member is a 2-pin part across the SAME two nets
              and one is not a passive (a source keeps the way it is
              drawn), turn each R, C or L member so its pins run the same
              way as that anchor's, same net at the same end.
        Out : nothing; writes rotations and mirrors through ctx.
        LM324.lib's FB (upright) and RO2 (level) were cached as one cell
        lying two ways, and a cached cell is frozen to every later
        orientation pass, so the agreement has to be made here."""
        insts = [ctx.by_ref.get(r) for r in refs]
        if len(insts) < 2 or None in insts:
            return
        pairs = [inst._pin_net_pairs or [] for inst in insts]
        nets = {frozenset(str(n).lower() for _p, n in pp) for pp in pairs}
        if len(nets) != 1 or any(len(pp) != 2 for pp in pairs):
            return
        net_a = sorted(next(iter(nets)))[0]

        def vec(inst, deg, flip):
            try:
                _b, offs = self._rotated_pins_by_num(inst, deg, flip)
            except Exception:
                return None
            pts = {str(n).lower(): offs.get(p)
                   for p, n in (inst._pin_net_pairs or [])}
            pa = pts.get(net_a)
            pb = [v for k, v in pts.items() if k != net_a]
            if pa is None or not pb or pb[0] is None:
                return None
            return (pb[0][0] - pa[0], pb[0][1] - pa[1])

        passive = {r for r in refs if _ref_kind(r) in 'RCL'}
        anchors = [r for r in refs if r not in passive]
        if not anchors or not passive:
            return
        anc = ctx.by_ref[anchors[0]]
        want = vec(anc, self._auto_rotations.get(anchors[0],
                                                 anc.rotation_deg or 0) % 360,
                   bool(self._auto_flips.get(anchors[0])))
        if want is None:
            return
        for r in sorted(passive):
            inst = ctx.by_ref[r]
            for d in (0, 90, 180, 270):
                for f in (False, True):
                    v = vec(inst, d, f)
                    if v is None:
                        continue
                    if (v[0] * want[0] + v[1] * want[1]) > 0.9 * (
                            abs(v[0]) + abs(v[1])) * (
                            abs(want[0]) + abs(want[1])) / 2:
                        ctx.rot(r, d)
                        ctx.flip(r, f)
                        self._pattern_oriented.add(r)
                        # The lane pass keeps a part on this axis.
                        self._orient_class[r] = (
                            'V' if abs(v[1]) > abs(v[0]) else 'H')
                        break
                else:
                    continue
                break

    def _p2dl_cell_direction(self, ctx, refs):
        """Choose which neighbouring slot each cell member takes, from the
        electrical relationship rather than the ref name.  Returns (ordered
        refs, pack_x) for _p2dl_pack.
        """
        if len(refs) != 2:
            return refs, True
        a, b = refs
        ia, ib = ctx.by_ref.get(a), ctx.by_ref.get(b)
        if ia is None or ib is None:
            return refs, True
        nets_a = [str(n).lower() for n in (ia.comp.get('nets') or [])]
        nets_b = [str(n).lower() for n in (ib.comp.get('nets') or [])]
        shared = set(nets_a) & set(nets_b)
        if not shared:
            return refs, True
        gnd = {str(n).lower() for n in (set(ctx.gnd)
                                        | set(getattr(self,
                                                      '_neg_power_nets',
                                                      set()) or set()))}
        pwr = {str(n).lower() for n in set(ctx.pwr)} - gnd
        # THE TWO HALVES MUST AGREE ABOUT A NET.  The rule that BUILDS a
        # leaf cell treats every eligible T net as a rail -- a declared
        # port or a detected supply is consumed by a T-symbol and draws
        # no wire -- so a member is a leaf there when its only remaining
        # net is the shared one.  This routine used to ask only ctx.gnd
        # and ctx.pwr, so the same part looked non-leaf here: LM324.lib's
        # RO1 (nets 8 and 5, where 5 is a declared port) was built into a
        # cell as a leaf and then ordered as though it had somewhere else
        # to be, which leaves the ordering rule with nothing to read on
        # exactly the cells it was written for.
        try:
            _elig = {str(n).lower()
                     for n in (self._eligible_t_nets() or ())}
        except Exception:
            _elig = set()
        gnd |= {n for n in _elig if n in gnd}
        rails = gnd | pwr | _elig | {str(n).lower() for n in set(ctx.out)}

        def _shunt_side(mine, theirs):
            """Which rail this member hangs on, or None if it is not a
            shunt: every net of its own except the shared one must be a
            rail, and its partner must not be a shunt on the same rail
            (two shunts are a parallel bank, not a stack)."""
            rest = [n for n in mine if n not in shared]
            if not rest:
                return None
            if all(n in gnd for n in rest):
                return 'down'
            if all(n in pwr for n in rest):
                return 'up'
            return None
        sa, sb = _shunt_side(nets_a, nets_b), _shunt_side(nets_b, nets_a)
        if sa and not sb:
            return ([a, b], False) if sa == 'up' else ([b, a], False)
        if sb and not sa:
            return ([b, a], False) if sb == 'up' else ([a, b], False)
        # WHICH MEMBER FACES THE REST OF THE CIRCUIT decides the order,
        # not which of them drives the shared net.  Ordering driver-first
        # was tried and cost OPAx197 30 crossings: in a leaf pair the
        # driver is often the leaf itself, and putting it on the left
        # turns its partner's external wire back across the cell.  So ask
        # about the EXTERNAL net instead — the partner leaves rightward
        # when it drives outward and sits leftward when it is fed.
        def _outer(mine):
            return [n for n in mine if n not in shared and n not in rails]
        ext_a, ext_b = _outer(nets_a), _outer(nets_b)
        for m, o, hot, quiet in ((a, b, ext_a, ext_b),
                                 (b, a, ext_b, ext_a)):
            if quiet or not hot:
                continue
            outs, ins = _electrical_net_roles(ctx.by_ref[m].comp)[0:2]
            if any(n in outs for n in hot):
                return [o, m], True
            if any(n in ins for n in hot):
                return [m, o], True
        return refs, True

    def _parallel_groups(self, instances):
        """In : the instances.  Out: a list of ref-lists, one per group of
        parts DRAWN with two pins that share both nets; size >= 2 only.
        Shared by the orientation-consensus pass and the cluster-fusion
        pre-pass.  Matches any 2-pin part, not just R/C/L, so a pair like
        LM324's DP||RP fuses into one cluster.
        NOT the wider _terminal_pairs definition: letting diode-connected
        transistors join a parallel sibling here took OPAx197 from 73
        crossings to 85 and LM324.sub from 10 to 11, because these
        callers also STACK a group and a stacked 3-pin body reads worse
        than the wire it saves.  The wider rule is for ORDER only."""
        groups = defaultdict(list)
        for inst in instances:
            nets = inst.comp.get('nets', []) or []
            if len(nets) != 2:
                continue
            key = frozenset(n.lower() for n in nets)
            if len(key) != 2:
                continue
            groups[key].append(inst.comp['ref'])
        return [refs for refs in groups.values() if len(refs) >= 2]

    def _compute_parallel_orient(self, instances, in_nets, out_nets):
        """In : the instances and the in and out net sets.  Out: {ref: rot}
        for every TURNABLE member of a parallel sibling group of size >=
        2 — parts sharing a terminal pair — so the group reads alike.
        A part drawn with more than two pins anchors the group but keeps
        its own orientation; singletons are absent and fall through to
        _signal_flow_rotations.  For shared nets {a, b}: horizontal when
        exactly one is a declared port, that being a left-to-right signal
        path, else vertical (0), which reads as "in parallel".  The
        horizontal case picks 90 or 270 PER MEMBER from which pin carries
        the port net, since members need not carry it on the same pin."""
        groups = defaultdict(list)
        sized = defaultdict(set)
        for inst in instances:
            pn = getattr(inst, '_pin_net_pairs', None) or []
            for key in _terminal_pairs(inst.comp, pn):
                sized[key].add(inst.comp['ref'])
                # Only a part DRAWN with two pins can be turned to match:
                # a four-pin source or switch keeps its own orientation and
                # just anchors the group.
                if len(pn) == 2 or len(inst.sym_entry.get('pins', {})) == 2:
                    groups[key].append(inst.comp['ref'])

        # grounded pair (>=1 shared net is power/ground or
        # a promoted rail) renders VERTICAL (rot 0): the parts stand
        # side-by-side between a top signal bus and a bottom rail bus.
        # A signal pair (both shared nets carry signal between instances)
        # renders HORIZONTAL (rot 90): the parts stack as two rungs
        # between a left 'in' bus and a right 'out' bus.  Either way the
        # two shared nets become opposite edges of a clean rectangle.
        railish = set(_PWR_NETS_LC_FOR_T) | set(self._promoted_rails)
        in_nets_lc = {n.lower() for n in in_nets}
        out_nets_lc = {n.lower() for n in out_nets}
        inst_by_ref = {i.comp['ref']: i for i in instances}
        orient = {}
        for key, refs in groups.items():
            # Two or more parts on the pair, of which at least one can turn.
            if len(sized[key]) < 2 or not refs:
                continue
            a, b = tuple(key)
            grounded = (a in railish) or (b in railish)
            if not grounded:
                a_port = (a in in_nets_lc or a in out_nets_lc)
                b_port = (b in in_nets_lc or b in out_nets_lc)
                if a_port != b_port:   # exactly one side is a port
                    port_net = a if a_port else b
                    is_input = port_net in in_nets_lc
                    for ref in refs:
                        inst = inst_by_ref.get(ref)
                        pairs = (getattr(inst, '_pin_net_pairs', None)
                                or []) if inst else []
                        port_pin = next(
                            (pn for pn, nn in pairs
                             if nn.lower() == port_net), None)
                        if port_pin is None:
                            continue
                        # rot=90: pin1->left, pin2->right.
                        # rot=270: pin1->right, pin2->left.
                        pin2_side = (port_pin == '2')
                        if is_input:
                            orient[ref] = 270 if pin2_side else 90
                        else:
                            orient[ref] = 90 if pin2_side else 270
                    continue
            rot = 0 if grounded else 90
            for ref in refs:
                orient[ref] = rot
        return orient

    def _pwr_gnd_adjacent_nets(self, instances):
        """In : the instances.  Out: the nets to treat as power or ground
        for 2-pin ORIENTATION: the directly power/ground ones
        (_PWR_NETS_LC_FOR_T plus _rail_polarity's inferred or
        user-overridden rail), unioned with any net exactly ONE 2-pin hop
        away from those.
        A decoupling cap's far pin sits on an ordinary signal-looking
        net, but its near pin — on the SAME instance — touches ground, so
        that net should still read as rail-adjacent rather than as a
        genuine horizontal signal path.  One hop only; a longer series
        chain is not chased."""
        rp, rn = self._rail_polarity()
        rail_lc = {str(x).lower() for x in (rp, rn) if x}
        base = set(_PWR_NETS_LC_FOR_T) | rail_lc
        adjacent = set()
        for inst in instances:
            pairs = getattr(inst, '_pin_net_pairs', None) or []
            if len(pairs) != 2:
                continue
            nls = [n.lower() for _p, n in pairs]
            if nls[0] == nls[1]:
                continue
            touches_base = [n in base for n in nls]
            if touches_base[0] != touches_base[1]:   # exactly one does
                adjacent.add(nls[1] if touches_base[0] else nls[0])
        return base | adjacent




    # ── Signal-flow Pass 2 ──────────────────────────────────────────────

    def _instance_bbox_at_origin(self, inst):
        """Composite bbox as if ox_px=oy_px=0.  Uses
        _estimated_composite_extent (rev 51+), falls back to
        sym_body_rel.  Does NOT include the space occupied by per-pin
        gnd/vcc T-symbols — see _instance_bbox_with_ts for that."""
        try:
            return self._estimated_composite_extent(inst)
        except Exception:
            return getattr(inst, 'sym_body_rel', (-20, -20, 20, 20))

    def _t_clears_instances(self, cx, cy, rot, net_name, refs):
        """Takes a T's centre, rotation and net plus the refs to test against,
        and returns True when the T's predicted full extent touches none of
        their composites. Same geometry and same _boxes_clash the harness grades
        with, so an answer here agrees with what it will report."""
        tl, tt, tr, tb = self._predict_t_extent(net_name, rot)
        box = (cx + tl, cy + tt, cx + tr, cy + tb)
        by_ref = getattr(self, '_t_owner_inst', None) or {}
        for ref in refs:
            inst = by_ref.get(ref)
            if inst is None:
                continue
            own = self._instance_bbox_at_origin(inst)
            ox, oy = inst.ox_px, inst.oy_px
            if self._boxes_clash(box, (own[0] + ox, own[1] + oy,
                                       own[2] + ox, own[3] + oy)):
                return False
        return True

    # ── T-symbol bbox prediction ──────────────────────────────

    # T-symbol drawing constants — must match _draw_t_terminal.
    _T_STEM      = 20
    _T_BAR       = 18
    _T_LABEL_GAP = 3         # Bar↔label gap (was 10); user
                             # asked for a tight 2–4 px gap.  SINGLE source of
                             # truth: _draw_t_terminal (draw), _t_label_bbox /
                             # _t_label_box / _predict_t_extent (overlap +
                             # flight metrics) and _t_node_extent (placement
                             # footprint) all read this so the drawn gap and
                             # every measured gap stay identical.
    _T_LABEL_FS  = 9         # FONT_FAMILY size 9 in _draw_t_terminal
    # Visible flight line between a pin and the tip of its T's stem
    # (user: "5~10px of flight line between the T-symbol stem connect
    # point and the body pin").  The T's CENTRE therefore sits
    # _T_STEM + _T_PIN_GAP from the pin, since the stem points back at
    # it.  DERIVED, not a second magic number: the pin-to-T distance was
    # written as a bare `30` in three separate routines with a comment in
    # one of them asking the others to match, so changing _T_STEM would
    # have silently eaten the gap.  This gap is also what lets an owned T
    # be measured against its owner with a strict overlap test rather
    # than a proximity margin — see _t_body_overlap_pairs.
    _T_PIN_GAP   = 10.0
    _T_PIN_DIST  = _T_STEM + _T_PIN_GAP
    # Minimum flight-line length from a T is 2 px; _T_PIN_GAP is what placement
    # aims for.
    _T_PIN_MIN_GAP = 2.0
    # Nothing touches: the minimum empty pixels between any two drawn things.
    # Every overlap test uses it, so adjacent counts as overlapping.
    _MIN_CLEARANCE = globals()['_MIN_CLEARANCE']
    # Clearance a T must keep from any body/text that is NOT its owner's.
    # SINGLE source of truth: _t_body_overlap_pairs measures against it
    # (its touch_margin) and _instance_bbox_with_ts RESERVES it, so the
    # placer is asked to deliver exactly what the metric checks for.
    # Reserving the bare T extent instead left legitimate near-misses —
    # a 9.6 px gap on LP2951's VTAP T and 6.8 px on OPAX197's MID T —
    # that were not overlaps at all but still failed the harness,
    # because no box anywhere described the gap the metric wanted.
    _T_BODY_CLEARANCE = 10.0

    def _foreign_box_lines(self, instances=None):
        """Takes the placed instances and returns (lines, bridges): how many
        drawn flight segments pass through a box they do not belong to, and
        how many join two different boxes. Both are 0 when the boxes are a
        true partition packed apart."""
        insts = instances if instances is not None else (
            self._placed_instances or [])
        ibr = {i.comp['ref']: i for i in insts}
        town = {}
        for (ref, _pn), tid in (getattr(self, '_pin_to_t', None) or {}).items():
            town.setdefault(tid, set()).add(ref)
        tid_map = {t.get('id'): t
                   for t in (getattr(self, '_t_terminals', None) or [])}
        boxes, box_of = [], {}
        for cl in self._box_partition(insts):
            rl = [i.comp['ref'] for i in cl]
            b = self._cluster_true_bbox(rl, ibr, town, tid_map)
            if b:
                for r in rl:
                    box_of[r] = len(boxes)
                boxes.append(b)

        def _through(a, c, b):
            t0, t1 = 0.0, 1.0
            dx, dy = c[0] - a[0], c[1] - a[1]
            for pp, q in ((-dx, a[0] - b[0]), (dx, b[2] - a[0]),
                          (-dy, a[1] - b[1]), (dy, b[3] - a[1])):
                if pp == 0:
                    if q < 0:
                        return False
                elif pp < 0:
                    t0 = max(t0, q / pp)
                else:
                    t1 = min(t1, q / pp)
            return t0 < t1

        lines = bridges = 0
        for a, c, ra, _pa, rb, _pb, _nn in self._flight_segments(insts):
            own = {box_of.get(ra), box_of.get(rb)} - {None}
            if len(own) > 1:
                bridges += 1
            for k, b in enumerate(boxes):
                if k not in own and _through(a, c, b):
                    lines += 1
        return lines, bridges

    def _box_overlap_metric(self, instances=None):
        """In : the placed instances (defaults to _placed_instances).
        Proc: measure how much the drawn 'Boxes' rectangles overlap.
        Out : (n_boxes, n_overlapping_pairs, overlap_px, pct_of_box_area).
        OVERLAPPING BOXES MEAN THE PARTS INSIDE THEM ARE TOO FAR APART: a
        box is the hull of its members, so it can only grow past its
        neighbours once its own members have spread out, which is why
        pulling them together shrinks it.  Cheap, and a good proxy —
        LM324.sub places with 8 overlapping pairs at 6% of box area and
        66 crossings, where the hand placement of the same circuit has 2
        pairs at 0% and 33.  Reported by -v; nothing reads it yet."""
        insts = instances if instances is not None else (
            self._placed_instances or [])
        if not insts:
            return (0, 0, 0.0, 0.0)
        ibr = {i.comp['ref']: i for i in insts}
        town = {}
        for (ref, _pn), tid in (getattr(self, '_pin_to_t', None) or {}).items():
            town.setdefault(tid, set()).add(ref)
        tid_map = {t.get('id'): t
                   for t in (getattr(self, '_t_terminals', None) or [])}
        boxes = []
        for refs in (getattr(self, '_signal_segments', None)
                     or self._boxes or []):
            rl = [r if isinstance(r, str) else r.comp['ref'] for r in refs]
            b = self._cluster_true_bbox(rl, ibr, town, tid_map)
            if b:
                boxes.append(b)
        tot, pairs = 0.0, 0
        for k, a in enumerate(boxes):
            for b in boxes[k + 1:]:
                ox = min(a[2], b[2]) - max(a[0], b[0])
                oy = min(a[3], b[3]) - max(a[1], b[1])
                if ox > 0 and oy > 0:
                    tot += ox * oy; pairs += 1
        area = sum((b[2] - b[0]) * (b[3] - b[1]) for b in boxes)
        return (len(boxes), pairs, tot, (100.0 * tot / area) if area else 0.0)

    def _box_fill_metric(self, instances=None):
        """In : the placed instances.
        Proc: packing efficiency per drawn box — the total area of the
              member composite boxes inside it over the box's own area.
        Out : [(size, pct), ...] worst-filled first, and the overall pct.
        A mostly empty box is one whose members are spread out.  100% is
        neither achievable nor wanted, and a one-part box is ~100% by
        construction and says nothing, but a LARGE box at 10-20% is a
        real signal.  Reported, not gated: 50-80% for a big box looks
        about right and wants checking against more hand placements."""
        insts = instances if instances is not None else (
            self._placed_instances or [])
        if not insts:
            return [], 0.0
        ibr = {i.comp['ref']: i for i in insts}
        town = {}
        for (ref, _pn), tid in (getattr(self, '_pin_to_t', None) or {}).items():
            town.setdefault(tid, set()).add(ref)
        tid_map = {t.get('id'): t
                   for t in (getattr(self, '_t_terminals', None) or [])}
        rows, fill_t, box_t = [], 0.0, 0.0
        for refs in (getattr(self, '_signal_segments', None)
                     or self._boxes or []):
            rl = [r if isinstance(r, str) else r.comp['ref'] for r in refs]
            box = self._cluster_true_bbox(rl, ibr, town, tid_map)
            if not box:
                continue
            barea = (box[2] - box[0]) * (box[3] - box[1])
            if barea <= 0:
                continue
            inner = 0.0
            for r in rl:
                inst = ibr.get(r)
                if inst is None:
                    continue
                at = self._instance_bbox_at_origin(inst)
                if at:
                    inner += (at[2] - at[0]) * (at[3] - at[1])
            rows.append((len(rl), 100.0 * inner / barea))
            fill_t += inner; box_t += barea
        rows.sort(key=lambda t: t[1])
        return rows, (100.0 * fill_t / box_t) if box_t else 0.0

    def _cluster_true_bbox(self, refs, inst_by_ref,
                           t_owner_refs=None, t_by_id=None, pad=8.0):
        """In : the cluster's refs and the instance lookup, with optional
        T-owner and T-by-id maps and a pad.
        Out: (x0, y0, x1, y1) enclosing every member's COMPOSITE bbox
        (symbol body plus its placed value and net labels), unioned with
        every T-symbol owned wholly by this cluster (its placed centre
        plus _predict_t_extent) and expanded by `pad`; None when there is
        nothing measurable.
        Intended for BOTH the box-packer and _render.  _render uses it;
        the packer does not yet, because a provisional-position T-build
        cannot reproduce a context-dependent single-pin T's final seat."""
        if t_owner_refs is None:
            t_owner_refs = {}
            for (ref, _pn), tid in (
                    getattr(self, '_pin_to_t', None) or {}).items():
                t_owner_refs.setdefault(tid, set()).add(ref)
        if t_by_id is None:
            t_by_id = {t.get('id'): t
                       for t in (getattr(self, '_t_terminals', None) or [])}
        refset = set(refs)
        xs0 = []; ys0 = []; xs1 = []; ys1 = []
        for r in refs:
            inst = inst_by_ref.get(r)
            if inst is None:
                continue
            # composite_rel is only populated by place_texts
            # at render time; at cluster-PLACEMENT time it is still the
            # default (-1,-1,1,1) (a 2px phantom), so abs_composite() here
            # would reserve neither the body nor the value/ref labels and
            # the packer would space clusters too tightly (RP's value
            # intruding into the HLIM cluster; VLIM body over REE's ref).
            # When composite_rel is unpopulated, fall back to
            # _estimated_composite_extent (body UNION value/net/ref labels,
            # rotation already applied) translated to the member's position.
            if tuple(inst.composite_rel) == (-1, -1, 1, 1):
                est = self._estimated_composite_extent(inst)
                sb = _translate_bb(est, inst.ox_px, inst.oy_px)
            else:
                sb = inst.abs_composite()
            # TAKE THE OUTER HULL OF BOTH MEASUREMENTS (§5).  There are
            # two notions of "the instance's box" -- the composite from
            # place_texts, and the at-origin reservation the placer uses
            # -- and they do not always agree: on LM324.sub the at-origin
            # box of R68 and C22 reached 16 px ABOVE the composite, so
            # the drawn cluster outline cut through them.  A box that is
            # too big never causes an overlap; one that is too small is a
            # defect, so the overlay reserves whichever is larger on each
            # edge rather than trusting one of them.
            try:
                _at = self._instance_bbox_at_origin(inst)
                if _at:
                    _ab = _translate_bb(_at, inst.ox_px, inst.oy_px)
                    sb = (min(sb[0], _ab[0]), min(sb[1], _ab[1]),
                          max(sb[2], _ab[2]), max(sb[3], _ab[3]))
            except Exception:
                pass
            xs0.append(sb[0]); ys0.append(sb[1])
            xs1.append(sb[2]); ys1.append(sb[3])
        for tid, owners in t_owner_refs.items():
            if not owners or not (owners <= refset):
                continue
            t = t_by_id.get(tid)
            if t is None:
                continue
            te = self._predict_t_extent(t.get('net', ''), t.get('rot', 0))
            xs0.append(t['cx'] + te[0]); ys0.append(t['cy'] + te[1])
            xs1.append(t['cx'] + te[2]); ys1.append(t['cy'] + te[3])
        if not xs0:
            return None
        return (min(xs0) - pad, min(ys0) - pad,
                max(xs1) + pad, max(ys1) + pad)

    def _t_geometry(self, cx, cy, rot):
        """Takes a T's centre and rotation and returns (stem_end, bar_pts,
        label_xy, label_anchor) in canvas coordinates -- THE encoding of a T's
        shape, read by the drawer, both measurers and the predictor so none can
        disagree. The stem always points INTO the circuit and the label sits on
        the far side of the bar:
          rot   0  stem UP     label BELOW
          rot  90  stem LEFT   label RIGHT
          rot 180  stem DOWN   label ABOVE
          rot 270  stem RIGHT  label LEFT"""
        STEM = self._T_STEM
        BAR = self._T_BAR
        LG = self._T_LABEL_GAP
        if rot == 0:
            return ((cx, cy - STEM),
                    ((cx - BAR / 2, cy), (cx + BAR / 2, cy)),
                    (cx, cy + LG), 'n')
        if rot == 90:
            return ((cx - STEM, cy),
                    ((cx, cy - BAR / 2), (cx, cy + BAR / 2)),
                    (cx + LG, cy), 'w')
        if rot == 180:
            return ((cx, cy + STEM),
                    ((cx - BAR / 2, cy), (cx + BAR / 2, cy)),
                    (cx, cy - LG), 's')
        return ((cx + STEM, cy),
                ((cx, cy - BAR / 2), (cx, cy + BAR / 2)),
                (cx - LG, cy), 'e')

    def _t_symbol_bbox_at(self, cx, cy, rot, tol=0):
        """(x0, y0, x1, y1) of the T's STEM + BAR only (no label),
        expanded by `tol`.  Derived from _t_geometry, so it is the box
        around what is actually drawn."""
        stem_end, bar_pts, _lxy, _anch = self._t_geometry(cx, cy, rot)
        xs = [cx, stem_end[0], bar_pts[0][0], bar_pts[1][0]]
        ys = [cy, stem_end[1], bar_pts[0][1], bar_pts[1][1]]
        return (min(xs) - tol, min(ys) - tol,
                max(xs) + tol, max(ys) + tol)

    # Merge/split hysteresis, in units of _T_PIN_DIST (the distance a T
    # sits from its pin).  TWO thresholds, not one: a single cut-off makes
    # a drag thrash -- merge, split, merge -- on sub-pixel movement.
    _T_SPLIT_SPAN = 5.5      # split when any pin is beyond this
    # 2.5/4.5 was too tight to ever fire: after a split the two T's sit
    # one _T_PIN_DIST from their own pins, so the far pin is roughly the
    # instance separation away -- 81 px for I6/R71, past a 75 px merge
    # span.  A split then never re-merged when the part was dragged back.
    # Widening is safe now that a merge which ADDS crossings is reverted.

    def _t_for_pin(self, ref, pn):
        """In : a ref and pin number.
        Proc: the live T that pin is wired to, if any.
        Out : the T dict, or None."""
        tid = (getattr(self, '_pin_to_t', None) or {}).get((ref, str(pn)))
        if tid is None:
            return None
        for t in (getattr(self, '_t_terminals', None) or []):
            if t.get('id') == tid:
                return t
        return None

    def _t_own_boxes(self):
        """Returns [(x0, y0, x1, y1, t)] for every T that owns its own box, with
        the clearance halo an instance reservation uses. These are placement
        objects, not decoration: the overlap check clashes them like any other
        rectangle, so a merged T occupies space of its own instead of inflating
        both its owners."""
        out = []
        pad = self._T_BODY_CLEARANCE
        for t in (getattr(self, '_t_terminals', None) or []):
            if not t.get('own_box'):
                continue
            try:
                tl, tt, tr, tb = self._predict_t_extent(t.get('net'),
                                                        t.get('rot'))
            except Exception:
                continue
            out.append((t['cx'] + tl - pad, t['cy'] + tt - pad,
                        t['cx'] + tr + pad, t['cy'] + tb + pad, t))
        return out

    def _t_split_one(self, t, instances=None):
        """Takes a T that several instances own and replaces it with one T per
        owner at that owner's predicted offset; returns how many it became, 0
        when it could not be split. The pieces keep the parent's rotation -- a
        split divides a T, it does not re-classify it -- and clear own_box and
        user_moved, since each now serves one instance and must follow it."""
        insts = instances if instances is not None else (
            self._placed_instances or [])
        iref = {i.comp['ref']: i for i in insts}
        owners = [(r, p) for (r, p), tid in (self._pin_to_t or {}).items()
                  if tid == t.get('id')]
        refs = sorted({r for r, _p in owners})
        if len(refs) < 2 or t not in (self._t_terminals or []):
            return 0
        made = []
        for ref in refs:
            sub = [(r, p) for r, p in owners if r == ref]
            inst = iref.get(ref)
            if inst is None:
                return 0
            try:
                got = self._predicted_pin_t(inst, sub[0][1], t.get('net'))
            except Exception:
                got = None
            if got is None:
                return 0
            _rot, lx, ly = got
            made.append((sub, inst.ox_px + lx, inst.oy_px + ly,
                         t.get('rot', _rot)))
        self._t_terminals.remove(t)
        for sub, cx, cy, rot in made:
            nt = dict(t)
            nt['id'] = self._next_t_id
            self._next_t_id += 1
            nt['cx'], nt['cy'], nt['rot'] = cx, cy, rot
            nt.pop('group', None); nt.pop('owner', None)
            # A single-owner T follows its instance: do not mark split pieces
            # user_moved, or a dragged part leaves its own T behind.
            nt.pop('own_box', None)
            nt.pop('user_moved', None)
            self._t_terminals.append(nt)
            for key in sub:
                self._pin_to_t[key] = nt['id']
        return len(made)

    def _t_split_pass(self, instances=None, moved_refs=None):
        """In : the placed instances and optionally the refs just dragged.
        Out: how many shared T's were split — those whose pins have
        drifted past _T_SPLIT_SPAN, or whose owners' boxes have begun to
        overlap on the shared T.  The drag path only.
        A split seats its pieces from the CURRENT geometry, not where the
        reservation booked them, so it can land a T on an unreserved
        body; acceptable while the user pulls parts apart by hand, and a
        re-Place re-reserves.  Merging is not this pass's job — an
        automatic merge undid the split just asked for.  With `moved_refs`
        only a dragged part's T counts, so a hand-merged T stays."""
        insts = instances if instances is not None else (
            self._placed_instances or [])
        ts = self._t_terminals or []
        if not insts or not ts:
            return 0
        iref = {i.comp['ref']: i for i in insts}
        pins_of = defaultdict(list)
        for (ref, pn), tid in (self._pin_to_t or {}).items():
            pins_of[tid].append((ref, pn))
        split_d = self._T_PIN_DIST * self._T_SPLIT_SPAN
        n_split = 0

        def _pin_xy(ref, pn):
            inst = iref.get(ref)
            if inst is None:
                return None
            try:
                return _pin_canvas_pos(inst, pn)
            except Exception:
                return None

        # ── SPLIT: a T whose pins have drifted too far apart ──────────
        # THE DRAG PATH ONLY.  A split puts new T's at positions
        # _predicted_pin_t computes from the CURRENT geometry, which is
        # not where the reservation booked them, so it can land a T on a
        # body that nothing reserved space against (measured: splitting
        # on LM324.lib produced T 0#564 overlapping VLN's composite).
        # That is acceptable here because the user is pulling parts apart
        # by hand and expects the shared T to divide, and a re-Place
        # re-reserves afterwards -- which is why _run_placement does not
        # call this pass at all.
        for t in list(ts):
            owners = pins_of.get(t.get('id')) or []
            if len({r for r, _p in owners}) < 2:
                continue
            if moved_refs is not None:
                _own = {r for r, _p in owners}
                # Untouched, or carried whole by a group drag: the T and
                # its pins kept their relative geometry, so nothing to do.
                # OPAx197's merged R59/V_ISCP MID T split on exactly that.
                if not (_own & set(moved_refs)) or _own <= set(moved_refs):
                    continue
            far = False
            for ref, pn in owners:
                p = _pin_xy(ref, pn)
                if p is None:
                    continue
                if math.hypot(p[0] - t['cx'], p[1] - t['cy']) > split_d:
                    far = True
                    break
            # ...or the shared T is making its owners' boxes overlap: each
            # owner's box stretches to cover it.
            if (not far and not t.get('own_box')
                    and len({r for r, _p in owners}) > 1):
                _bx = []
                for _r in sorted({r for r, _p in owners}):
                    _i = iref.get(_r)
                    if _i is None:
                        continue
                    _b = self._instance_bbox_with_ts(_i)
                    if _b:
                        _bx.append((_i.ox_px + _b[0], _i.oy_px + _b[1],
                                    _i.ox_px + _b[2], _i.oy_px + _b[3]))
                for _k, _a in enumerate(_bx):
                    for _c in _bx[_k + 1:]:
                        if (min(_a[2], _c[2]) - max(_a[0], _c[0]) > 0.5 and
                                min(_a[3], _c[3]) - max(_a[1], _c[1]) > 0.5):
                            far = True
                            break
                    if far:
                        break
            if not far:
                continue
            made = []
            for ref in sorted({r for r, _p in owners}):
                sub = [(r, p) for r, p in owners if r == ref]
                inst = iref.get(ref)
                got = None
                if inst is not None:
                    try:
                        got = self._predicted_pin_t(inst, sub[0][1],
                                                    t.get('net'))
                    except Exception:
                        got = None
                if got is None:
                    made = []
                    break
                rot, lx, ly = got
                # Splitting a T keeps its rotation: on a rail net the rotation
                # is the polarity, and recomputing it from geometry would change
                # it.
                rot = t.get('rot', rot)
                made.append((ref, sub, inst.ox_px + lx, inst.oy_px + ly,
                             rot))
            if not made:
                continue
            # Grade every piece at its forced rotation before committing.
            if any(any(self._t_route_defect(
                        iref.get(ref), sub[0][1],
                        {'cx': cx, 'cy': cy, 'rot': rot,
                         'net': t.get('net')}))
                   for ref, sub, cx, cy, rot in made):
                continue
            ts.remove(t)
            for ref, sub, cx, cy, rot in made:
                nt = dict(t)
                nt['id'] = self._next_t_id
                self._next_t_id += 1
                nt['cx'], nt['cy'], nt['rot'] = cx, cy, rot
                nt.pop('group', None); nt.pop('owner', None)
                # Same rule as _t_split_one: a piece serves one instance
                # and must follow it, so neither flag is inherited.
                nt.pop('own_box', None)
                nt.pop('user_moved', None)
                ts.append(nt)
                for key in sub:
                    self._pin_to_t[key] = nt['id']
            n_split += 1

        return n_split

    def _fill_missing_pin_ts(self, instances=None):
        """In : the instances as drawn.  Out: (added, unclear) — every pin
        on a T-net that had none is given a T.
        A saved file restores pin_to_t verbatim, which freezes its gaps,
        and a pin with no T of its own is not drawn bare: it flies to
        somebody ELSE'S T on that net, dragging its owner into that
        part's box.  Eligibility comes from the file's own evidence (one
        T per (instance, net), on a net that already has one), and the
        rotation is inherited by majority, since on a rail the rotation
        IS the polarity.  A new T tries its predicted seat then the four
        axis seats; if none clears it is placed and counted `unclear`."""
        insts = instances if instances is not None else (
            getattr(self, '_cached_instances', None)
            or self._placed_instances or [])
        ts = self._t_terminals or []
        if not insts or not ts:
            return (0, 0)
        by_net = defaultdict(list)
        for t in ts:
            by_net[str(t.get('net')).lower()].append(t)
        # Which (ref, net) pairs already hold a T.
        t_by_id = {t.get('id'): t for t in ts}
        have = set()
        for (ref, _pn), tid in (self._pin_to_t or {}).items():
            t = t_by_id.get(tid)
            if t is not None:
                have.add((ref, str(t.get('net')).lower()))
        all_refs = [i.comp['ref'] for i in insts]
        added = unclear = 0
        for inst in insts:
            ref = inst.comp['ref']
            for pn, nn in (getattr(inst, '_pin_net_pairs', None) or []):
                nl = str(nn).lower()
                if nl not in by_net or (ref, nl) in have:
                    continue
                if (ref, pn) in (self._pin_to_t or {}):
                    continue
                try:
                    got = self._predicted_pin_t(inst, pn, nn)
                except Exception:
                    got = None
                if got is None:
                    continue
                _rot, lx, ly = got
                rots = [t.get('rot') for t in by_net[nl]]
                rot = max(set(rots), key=rots.count)
                cx, cy = inst.ox_px + lx, inst.oy_px + ly
                # A SEAT LADDER, NOT THE FIRST GUESS.  The predicted
                # offset is where the emitter WOULD have put this T on a
                # clean placement; on a hand-edited one the space may
                # since have been taken, and dropping the T there anyway
                # trades a flight line across the drawing for a T on
                # somebody's body (measured: R68's net-5 T landed on Q7,
                # composite 0 -> 2).  The four axis seats at _T_PIN_DIST
                # from the pin are the same positions _compute_pin_t
                # chooses between, so trying them costs nothing new and
                # keeps the T beside the pin it serves.
                cands = [(cx, cy)]
                try:
                    px, py = _pin_canvas_pos(inst, pn)
                    d = self._T_PIN_DIST
                    cands += [(px + d, py), (px - d, py),
                              (px, py + d), (px, py - d)]
                except Exception:
                    pass
                for _cx, _cy in cands:
                    probe = {'cx': _cx, 'cy': _cy, 'rot': rot, 'net': nn}
                    try:
                        if not self._t_clears_instances(_cx, _cy, rot, nn,
                                                        all_refs):
                            continue
                        if any(self._t_route_defect(inst, pn, probe)):
                            continue
                    except Exception:
                        continue
                    cx, cy = _cx, _cy
                    break
                nt = {'id': self._next_t_id, 'net': nn,
                      'cx': cx, 'cy': cy, 'rot': rot}
                self._next_t_id += 1
                ts.append(nt)
                self._pin_to_t[(ref, pn)] = nt['id']
                have.add((ref, nl))
                added += 1
                # REPORTED, NOT REFUSED.  A pin on a T-net needs its
                # terminal whether or not the seat the file left room
                # for is clear; withholding it puts the flight line
                # back across the drawing, which is the worse of the
                # two.  The count goes to the caller so a crowded seat
                # is visible rather than silent, and the overlap
                # metrics grade it like any other T.
                try:
                    if not self._t_clears_instances(cx, cy, rot, nn,
                                                    all_refs):
                        unclear += 1
                except Exception:
                    pass
        return (added, unclear)

    def _reseat_own_box_ts(self, instances=None):
        """Takes the placed instances and re-seats every T that owns its box at
        the verified-clear midpoint of the pins it serves, splitting the ones
        that no longer have a seat every pin can reach; returns (moved, split).
        A Place invalidates an own_box T's absolute position -- the parts it
        serves have just moved -- so a merge is treated as a RELATIONSHIP, not a
        coordinate. The seat is CHECKED, not reserved: _instance_bbox_with_ts
        books each owner's per-pin T while _placing is true, so the space
        between them is free by construction and a split's pieces land in
        exactly that space."""
        insts = instances if instances is not None else (
            self._placed_instances or [])
        ts = self._t_terminals or []
        if not insts or not ts:
            return (0, 0)
        iref = {i.comp['ref']: i for i in insts}
        all_refs = [i.comp['ref'] for i in insts]
        pins_of = defaultdict(list)
        for (ref, pn), tid in (self._pin_to_t or {}).items():
            pins_of[tid].append((ref, pn))
        # ONE RULE FOR "TOO FAR TO SHARE", shared with _t_split_pass: a
        # seat every pin can reach, or no seat at all.
        span = self._T_PIN_DIST * self._T_SPLIT_SPAN
        n_moved = n_split = 0
        for t in [t for t in list(ts) if t.get('own_box')]:
            owners = pins_of.get(t.get('id')) or []
            pts = []
            for ref, pn in owners:
                inst = iref.get(ref)
                if inst is None:
                    continue
                try:
                    pts.append((_pin_canvas_pos(inst, pn), (ref, pn)))
                except Exception:
                    pass
            if len(pts) < 2:
                continue
            mx = sum(q[0] for q, _k in pts) / len(pts)
            my = sum(q[1] for q, _k in pts) / len(pts)
            # Candidates: the midpoint first, then each owner's own
            # predicted seat -- the same ladder the drop handler walks,
            # so a merge that was accepted there is accepted here.
            cands = [(mx, my)]
            for _q, (ref, pn) in pts:
                inst = iref.get(ref)
                try:
                    got = self._predicted_pin_t(inst, pn, t.get('net'))
                except Exception:
                    got = None
                if got is not None:
                    cands.append((inst.ox_px + got[1], inst.oy_px + got[2]))
            seat = None
            for cx, cy in cands:
                if any(math.hypot(q[0] - cx, q[1] - cy) > span
                       for q, _k in pts):
                    continue
                if not self._t_clears_instances(cx, cy, t.get('rot'),
                                                t.get('net'), all_refs):
                    continue
                probe = {'cx': cx, 'cy': cy, 'rot': t.get('rot'),
                         'net': t.get('net')}
                if any(any(self._t_route_defect(iref.get(r), p, probe))
                       for _q, (r, p) in pts):
                    continue
                seat = (cx, cy)
                break
            if seat is None:
                if self._t_split_one(t, insts):
                    n_split += 1
                continue
            if (abs(seat[0] - t['cx']) > 0.5 or abs(seat[1] - t['cy']) > 0.5):
                t['cx'], t['cy'] = seat
                n_moved += 1
        return (n_moved, n_split)

    def _t_route_defect(self, inst, pn, t):
        """Takes a pin's owner, its pin number and a T dict -- which need not be
        emitted yet, so a candidate position can be graded before it is
        committed -- and returns (back_across, label_on_wire) for the route
        _t_flight_route would draw, elbow included. THE one grader, so a T
        cannot be placed where the audit will report it."""
        route = self._t_flight_route(inst, pn, t)
        if not route:
            return (False, False)
        segs = [(route[k], route[k + 1]) for k in range(len(route) - 1)]
        back = wire = False
        rel = getattr(inst, 'sym_body_rel', None)
        if rel and len(rel) == 4:
            body = (inst.ox_px + rel[0], inst.oy_px + rel[1],
                    inst.ox_px + rel[2], inst.oy_px + rel[3])
            back = any(_seg_enters_box(a, b, body) for a, b in segs)
        try:
            lb = self._t_label_bbox_at(t['cx'], t['cy'], t['rot'],
                                       t.get('net', ''))
        except Exception:
            lb = None
        if lb:
            wire = any(_seg_enters_box(a, b, lb, pad=0.0) for a, b in segs)
        return (back, wire)

    def _t_defect_counts(self, instances):
        """Takes the placed instances and returns (back_across, label_on_wire)
        counted over every T-linked pin, grading the segment the renderer really
        draws -- pin to STEM END, not centre. Informational, not a gate: one
        rail case is still open and filed."""
        t_by_id = {t['id']: t for t in (self._t_terminals or [])}
        back = wire = 0
        for (ref, pn), tid in ((k, v) for k, v in
                               sorted((getattr(self, '_pin_to_t', None)
                                       or {}).items())):
            inst = next((i for i in instances
                         if i.comp['ref'] == ref), None)
            t = t_by_id.get(tid)
            if inst is None or t is None:
                continue
            b, w = self._t_route_defect(inst, pn, t)
            back += 1 if b else 0
            wire += 1 if w else 0
        return back, wire

    def _t_label_bbox_at(self, cx, cy, rot, net):
        """(x0, y0, x1, y1) of the T's net-label text, from the same
        anchor and font _draw_t_terminal uses."""
        _se, _bp, lxy, anch = self._t_geometry(cx, cy, rot)
        return _text_bbox_from_anchor(lxy[0], lxy[1], str(net), anch,
                                      self._T_LABEL_FS)

    def _t_extent_at(self, cx, cy, rot, net, tol=0):
        """Full drawn extent of a T: symbol UNION label."""
        s = self._t_symbol_bbox_at(cx, cy, rot, tol)
        b = self._t_label_bbox_at(cx, cy, rot, net)
        return (min(s[0], b[0]), min(s[1], b[1]),
                max(s[2], b[2]), max(s[3], b[3]))

    def _predict_t_extent(self, net_name, rot):
        """Takes a net and a rotation and returns a T's (left, top, right,
        bottom) about its centre -- the same box _t_full_extent measures on a
        placed T, expressed at the origin so the placer can use it before a
        position exists. Both call _t_extent_at, so predicted and measured
        cannot drift apart."""
        return self._t_extent_at(0.0, 0.0, rot, net_name)

    def _predicted_pin_t(self, inst, pn, nn):
        """Cached front end for _compute_pin_t: computes a pin's T offset once
        per instance per orientation and freezes it, so the RESERVATION and the
        EMISSION cannot get different answers and leave the T outside the box
        Sugiyama was given. Keyed by the geometry the answer was DERIVED from
        (rotation_deg, mid_kx, mid_ky, sym_body_rel), never by the rotation the
        instance is scheduled to get: those differ until
        _apply_instance_rotation_geometry syncs them, and keying on the intended
        one stamped rot-0 answers with the rot-90 key and never invalidated."""
        ref = inst.comp['ref']
        flip = self._user_flips.get(ref, self._auto_flips.get(ref, False))
        key = ((inst.rotation_deg or 0) % 360, bool(flip),
               getattr(inst, 'mid_kx', None), getattr(inst, 'mid_ky', None),
               tuple(getattr(inst, 'sym_body_rel', ()) or ()))
        cache = getattr(inst, '_t_local_cache', None)
        if cache is None or cache[0] != key:
            # Settle the instance's T's as a SET, not one at a time: a
            # T has to clear its siblings as well as the body, and only
            # a whole-instance pass knows where the siblings went.
            # Filling the cache in one deterministic shot also makes the
            # answer independent of which caller asked first.
            cache = (key, self._compute_instance_ts(inst))
            inst._t_local_cache = cache
        hit = cache[1]
        ck = (pn, str(nn).lower())
        if ck not in hit:
            # A pin that _pin_net_pairs does not list (so the
            # whole-instance pass never saw it) — answer it directly.
            hit[ck] = self._compute_pin_t(inst, pn, nn)
        return hit[ck]

    def _compute_pin_t(self, inst, pn, nn, obstacles=()):
        """Takes an instance, a pin and its net, plus the sibling T extents
        already settled for this instance, and returns (rot, tx, ty) in the
        instance's own frame for the T the emitter will draw there -- or None if
        that pin gets no T. Classes are tested in the order the EMITTER resolves
        them, so a net belonging to two is predicted the way it is drawn: ports
        and promoted rails first (rotation from _rail_t_rot, then
        _t_default_rot_for_net), then power and ground (rotation from the net
        name, then rail polarity). Deliberately COORDINATE-FREE -- it reads only
        the net, the pin's role and the pin's outward direction -- which is what
        lets it run before placement."""
        nl = str(nn).lower()
        io_out = {str(n).lower()
                  for n in (getattr(self, '_io_out_nets', None) or set())}
        io_in = {str(n).lower()
                 for n in (getattr(self, '_io_in_nets', None) or set())}
        pwr_nets = (set(_PWR_NETS_LC_FOR_T)
                    | {str(r).lower()
                       for r in (getattr(self, '_supply_rails', None)
                                 or set())})
        # promoted signal rails are reserved only when the gate is on —
        # see _reserve_rail_ts for the measurements behind the default.
        rails = {str(r).lower() for r in (self._promoted_rails or set())}
        # A .SUBCKT port always connects through a T.  Its rotation comes from
        # the net's role, which the user can change in the Nets dialog.
        ports = {str(n).lower()
                 for n in (self._eligible_t_nets() or set())}
        if nl not in io_out and nl not in io_in \
                and nl not in pwr_nets and nl not in rails \
                and nl not in ports:
            return None
        T_PIN_DIST = self._T_PIN_DIST
        # Pin position relative to the instance origin.  _pin_canvas_pos
        # adds ox_px/oy_px, so zeroing them makes it return exactly the
        # relative position the at-origin bbox is expressed in.
        saved_ox, saved_oy = inst.ox_px, inst.oy_px
        inst.ox_px = 0; inst.oy_px = 0
        try:
            pin_xy = _pin_canvas_pos(inst, pn)
            dx, dy = _pin_outward_direction(inst, pn)
        finally:
            inst.ox_px, inst.oy_px = saved_ox, saved_oy
        if pin_xy is None:
            return None

        if nl in io_out or nl in io_in or nl in rails:
            # Use exactly the emitter's rotation rule (_rail_t_rot for a
            # promoted rail, the net's role otherwise) so the prediction matches
            # the drawn T.
            rot = self._rail_t_rot(nl, [(inst, pn)],
                                   self._t_default_rot_for_net(nl, io_out))
            forced = _io_port_t_side_pos(nl, rot, pin_xy, io_in, io_out,
                                         T_PIN_DIST)
            if forced is not None:
                ux, uy = (1.0, 0.0) if nl in io_out else (-1.0, 0.0)
                return self._clear_t_of_own_body(
                    inst, nn, rot, forced[0], forced[1], ux, uy, obstacles,
                    pin_xy=pin_xy, pin_dir=(dx, dy))
            # A rail-GLYPH rotation (0 = ground/-power, 180 = +power)
            # means this net is a rail even though it is also declared a
            # port — LM324's net 3 is both.  Its side is then decided by
            # role exactly like the power branch below (positive above,
            # negative/ground below), NOT by _pin_outward_direction,
            # which reports the wrong sign for these parts and sent DP's
            # and RP's rail T's through their own bodies.
            if rot in (0, 180):
                uy = 1.0 if rot == 0 else -1.0
                return self._clear_t_of_own_body(
                    inst, nn, rot, pin_xy[0], pin_xy[1] + uy * T_PIN_DIST,
                    0.0, uy, obstacles, pin_xy=pin_xy, pin_dir=(dx, dy))
            # A sideways signal T (90/270) sits on the side its rotation
            # names, whichever way the pin points; the escape handles a
            # pin that faces away.
            ux, uy = _T_SIDE_FOR_ROT.get(rot, (-1.0, 0.0))
            return self._clear_t_of_own_body(
                inst, nn, rot, pin_xy[0] + ux * T_PIN_DIST,
                pin_xy[1] + uy * T_PIN_DIST, ux, uy, obstacles,
                pin_xy=pin_xy, pin_dir=(dx, dy))

        # power / ground / structurally-detected supply rail
        if nl in _VCC_NETS_LC:
            rot = 180
        elif nl in _GND_NETS_LC or nl == '0':
            rot = 0
        else:
            # a negative rail and any other member of the power
            # family both resolve to the bottom glyph, so only the
            # POSITIVE rail needs testing here.
            pos_rail = self._rail_polarity()[0]
            rot = 180 if nl == (pos_rail or '') else 0
        # A rail T goes on the side its role gives it: ground and -power below
        # (rot 0), +power above (rot 180).
        uy = 1.0 if rot == 0 else -1.0
        return self._clear_t_of_own_body(
            inst, nn, rot, pin_xy[0], pin_xy[1] + uy * T_PIN_DIST,
            0.0, uy, obstacles, pin_xy=pin_xy, pin_dir=(dx, dy))

    def _t_escape_dir(self, inst, pin_xy, pin_dir, ux, uy, tx, ty, nn,
                      rot):
        """Takes a pin, the side (ux, uy) its fixed rotation demands and the
        T's start point, and returns (px, py, tx, ty): where the T goes and
        which way clearance should push it. The side is never changed -- the
        rotation is the net's role and the stem must point back at the pin.
        When the straight pin->T line would cross the owner's body (a pin
        facing away from its T), the T slides perpendicular, nearest first,
        until an L route pin->corner->T misses the body and the T clears it;
        that is the elbow _t_flight_route draws, and the push then continues
        along the slide so the corner stays outside the body."""
        body = getattr(inst, 'sym_body_rel', None)
        if not body or len(body) != 4:
            return ux, uy, tx, ty
        px0, py0 = pin_xy
        if not _seg_enters_box(pin_xy, (tx, ty), body):
            return ux, uy, tx, ty
        d = float(self._T_PIN_DIST)
        tl, tt, tr, tb = self._predict_t_extent(nn, rot)
        perps = [(-uy, ux), (uy, -ux)]
        # the pin's own lean picks which way to try first on a tie
        if pin_dir and (perps[1][0] * pin_dir[0]
                        + perps[1][1] * pin_dir[1]) > (
                perps[0][0] * pin_dir[0] + perps[0][1] * pin_dir[1]):
            perps.reverse()
        span = max(body[2] - body[0], body[3] - body[1]) + 2 * d \
            + max(tr - tl, tb - tt)
        k = 3.0
        while k <= span:
            for qx, qy in perps:
                cx, cy = px0 + qx * k, py0 + qy * k
                nx, ny = cx + ux * d, cy + uy * d
                if _seg_enters_box(pin_xy, (cx, cy), body) \
                        or _seg_enters_box((cx, cy), (nx, ny), body) \
                        or self._boxes_clash(
                            (nx + tl, ny + tt, nx + tr, ny + tb), body):
                    continue
                return qx, qy, nx, ny
            k += 3.0
        # Every slide crosses the body -- a pin drawn INSIDE its own
        # outline.  Nothing here can help; keep the side's own spot.
        return ux, uy, tx, ty

    def _clear_t_of_own_body(self, inst, nn, rot, tx, ty, ux, uy,
                             obstacles=(), pin_xy=None, pin_dir=None):
        """Push a predicted T outward along (ux, uy) until it clears its owner's
        body and text and every box in `obstacles`.  Local frame; returns
        (rot, tx, ty).
        """
        own = self._instance_bbox_at_origin(inst)
        if not (ux or uy):
            return rot, tx, ty
        # The rotation is fixed by the net's role and never re-aimed;
        # _t_escape_dir only slides the T when its straight line would
        # cross the body, and returns the direction to push it in.
        if pin_xy is not None:
            ux, uy, tx, ty = self._t_escape_dir(inst, pin_xy, pin_dir,
                                                ux, uy, tx, ty, nn, rot)
        boxes = [own]
        boxes.extend(obstacles or ())
        boxes.extend(self._sibling_pin_keepouts(inst, nn))
        STEP = 6.0
        for _ in range(40):
            tl, tt, tr, tb = self._predict_t_extent(nn, rot)
            x0 = tx + tl; y0 = ty + tt; x1 = tx + tr; y1 = ty + tb
            # Same _boxes_clash the CHECK uses, so a T that satisfies
            # this loop satisfies the gate by construction.  With the
            # old strict test the loop stopped at the first step that
            # merely stopped overlapping, which is how four LM324.sub
            # T's ended up 0.04 px off their own composite.
            if not any(self._boxes_clash((x0, y0, x1, y1), b)
                       for b in boxes):
                break
            tx += ux * STEP
            ty += uy * STEP
        return rot, tx, ty

    def _sibling_pin_keepouts(self, inst, nn):
        """In : an instance and the net a T is being placed for.
        Out: small keep-out boxes around that instance's OTHER pins, in
        its local frame.
        A flight line ends exactly at its pin, so anything drawn beside
        that pin reads as being on it; keeping a T off a sibling's pin is
        what stops a net-0 T appearing to claim the net-6 line arriving
        one pin over (LM324.lib's GA/GCM).  Pins on the SAME net are not
        obstacles — a T there is genuinely connected.  The radius is
        _T_PIN_DIST, the distance a T sits from its own pin, so the rule
        needs no second constant."""
        out = []
        try:
            pairs = inst._pin_net_pairs or []
        except Exception:
            return out
        want = str(nn).lower()
        r = float(self._T_PIN_DIST)
        saved_ox, saved_oy = inst.ox_px, inst.oy_px
        inst.ox_px = 0; inst.oy_px = 0
        try:
            for pn, net in pairs:
                if str(net).lower() == want:
                    continue
                try:
                    xy = _pin_canvas_pos(inst, pn)
                except Exception:
                    xy = None
                if xy is None:
                    continue
                out.append((xy[0] - r, xy[1] - r, xy[0] + r, xy[1] + r))
        finally:
            inst.ox_px, inst.oy_px = saved_ox, saved_oy
        return out

    def _compute_instance_ts(self, inst):
        """In : an instance.  Out: {(pin, net_lc): (rot, tx, ty)} in the
        instance's own frame, settling EVERY T it owns in one pass.
        The QuadTree-style occupancy step, done at the only point where
        it can be: a part's T's are decided together, each clearing the
        body, the body text and the T's already settled for this part.
        One at a time — what a per-pin cache front end gives — cannot see
        a sibling T.  Pins are walked in _pin_net_pairs order and at most
        ONE T is settled per net (first pin wins), matching the emitter's
        dedup and _instance_bbox_with_ts's reservation rule; a later pin
        on a settled net adds no second obstacle."""
        out = {}
        placed = []
        seen = set()
        for pn, nn in (getattr(inst, '_pin_net_pairs', None) or []):
            nl = str(nn).lower()
            first = nl not in seen
            got = self._compute_pin_t(inst, pn, nn,
                                      tuple(placed) if first else ())
            out[(pn, nl)] = got
            if got is None or not first:
                continue
            seen.add(nl)
            t_rot, tx, ty = got
            tl, tt, tr, tb = self._predict_t_extent(nn, t_rot)
            placed.append((tx + tl, ty + tt, tx + tr, ty + tb))
        return out

    def _instance_bbox_with_ts(self, inst):
        """Takes an instance and returns its at-origin bbox extended by every
        per-pin T the emitter will draw for it, so Sugiyama packs AROUND the T's
        instead of leaving them to be squeezed in afterwards. At most ONE T per
        (instance, net), first pin wins -- the emitter's own rule, since a part
        with several pins on one T-net gets one T and routes the rest to it."""
        x0, y0, x1, y1 = self._instance_bbox_at_origin(inst)
        seen = set()
        for pn, nn in (getattr(inst, '_pin_net_pairs', None) or []):
            nl = str(nn).lower()
            if nl in seen:
                continue
            got = self._predicted_pin_t(inst, pn, nn)
            if got is None:
                continue
            seen.add(nl)
            # Once a T has a position it has its own box, so the owner must not
            # reserve it too; during a Place the T has no box yet and the owner
            # reserves it.
            _live = self._t_for_pin(inst.comp['ref'], pn)
            if (_live is not None and _live.get('own_box')
                    and not getattr(self, '_placing', False)):
                continue
            t_rot, tx_off, ty_off = got
            tl, tt, tr, tb = self._predict_t_extent(nn, t_rot)
            # Reserve the T with its clearance halo (see
            # _T_BODY_CLEARANCE): the box handed to the placer has to
            # describe the separation the T-vs-body metric asks for, or
            # a perfectly-packed layout still reports a "touch".
            pad = self._T_BODY_CLEARANCE
            x0 = min(x0, tx_off + tl - pad)
            y0 = min(y0, ty_off + tt - pad)
            x1 = max(x1, tx_off + tr + pad)
            y1 = max(y1, ty_off + tb + pad)
        return (x0, y0, x1, y1)

    def _abs_reserved_box(self, inst, exclude_t_ids=None):
        """In : an instance and optionally T ids to exclude.  Out: its
        reserved footprint at the CURRENT position — _placement_extent
        translated by (ox_px, oy_px), or the composite when the extent
        cannot be built.
        The one absolute-coordinate view of everything this part owns:
        body, pin ends, labels, and every T-symbol with its label.
        _placement_extent is both what Sugiyama is handed and what the
        BBoxes overlay draws in blue, so measuring anything else lets the
        user see two boxes overlap that no metric reports and no pass
        resolves.  Measure the box you draw."""
        try:
            b = self._placement_extent(inst, exclude_t_ids)
        except Exception:
            return inst.abs_composite()
        return (inst.ox_px + b[0], inst.oy_px + b[1],
                inst.ox_px + b[2], inst.oy_px + b[3])

    # Direction-arrow auto-detection: an MST edge gets an arrow when one end is
    # a known output pin and the other a known input pin.
    _PIN_ROLE_INTRINSIC_TABLE = {
        # E (4-pin VCVS): n+(0) n-(1) nc+(2) nc-(3).  Pins 0,1 are
        # the controlled-output pair; 2,3 are the voltage-sense
        # inputs.
        ('E',  None):       ((2, 3), (0, 1)),
        # E in EVALUE/POLY/TABLE form: only 2 pins, both output.
        ('E',  'EVALUE'):   ((),     (0, 1)),
        ('E',  'POLY'):     ((),     (0, 1)),
        ('E',  'TABLE'):    ((),     (0, 1)),
        # G (4-pin VCCS): same convention.
        ('G',  None):       ((2, 3), (0, 1)),
        ('G',  'GVALUE'):   ((),     (0, 1)),
        ('G',  'POLY'):     ((),     (0, 1)),
        ('G',  'TABLE'):    ((),     (0, 1)),
        # F/H (controlled by current through a Vsource — control isn't
        # in the netlist as net pins): output pair is 0,1.
        ('F',  None):       ((),     (0, 1)),
        ('H',  None):       ((),     (0, 1)),
        # MOSFET D(0) G(1) S(2) [B(3)]: the gate is the high-Z input; drain and
        # source default to outputs.
        ('M',  None):       ((1,),   (0, 2)),
        # JFET: D G S — gate input, D/S output (same reasoning as MOSFET).
        ('J',  None):       ((1,),   (0, 2)),
        # BJT: C(0) B(1) E(2) [S(3)].  Base is high-Z input.
        # emitter now defaults to OUTPUT too (usually
        # is one, common-base being the exception); same overridable-
        # default reasoning as M/J above.
        ('Q',  None):       ((1,),   (0, 2)),
        # S (n+ n- nc+ nc-) drives 0,1 and senses 2,3 like E/G; V and I drive
        # both terminals.
        ('S',  None):       ((2, 3), (0, 1)),
        ('V',  None):       ((),     (0, 1)),
        ('I',  None):       ((),     (0, 1)),
        # B (generic behavioral source, Bxxx n+ n- V=expr / I=expr) is
        # always 2-terminal — same EVALUE-equivalent shape.
        ('B',  None):       ((),     (0, 1)),
    }

    def _pin_directions(self, comp):
        """Return (inputs, outputs) — frozensets of pin indices that
        are known inputs and known outputs based on the component's
        intrinsic semantics.  Symmetric/unknown pins (R/L/C ends,
        MOSFET D/S, BJT emitter, etc.) are not in either set; those
        get resolved by propagation.
        """
        kind = comp.get('kind', '')
        sym = (comp.get('sym', '') or '').upper()
        # Try exact (kind, sym) match first, then (kind, None).
        key = (kind, sym if sym in
               ('EVALUE', 'GVALUE', 'POLY', 'TABLE') else None)
        if key not in self._PIN_ROLE_INTRINSIC_TABLE:
            key = (kind, None)
        inputs, outputs = self._PIN_ROLE_INTRINSIC_TABLE.get(
            key, ((), ()))
        # Trim to actual pin count (some forms have fewer pins).
        n_pins = len(comp.get('nets', []))
        inputs = frozenset(i for i in inputs if i < n_pins)
        outputs = frozenset(i for i in outputs if i < n_pins)
        return inputs, outputs

    # Kinds whose pins are symmetric so we propagate direction through
    # them.  R, L, C are the classic ones.  B-sources are excluded
    # (they're behavioral sources, their pin direction is meaningful
    # and we DON'T have a table entry, so they're treated as opaque
    # like X-instances — no propagation).
    _DIR_PROPAGATE_KINDS = frozenset({'R', 'L', 'C'})

    def _compute_pin_role_map(self, instances, exclude_nets,
                                rail_nets):
        """Build the per-pin direction map: user overrides first (locked), then
        the intrinsic device table, then propagation through R/L/C chains.
        Returns {(comp id, pin index): 'in' | 'out'}.
        """
        role = {}            # (cid, idx) -> 'in' | 'out'
        intrinsic = set()    # (cid, idx) that came from the table
        conflicted = set()   # (cid, idx) demoted; never re-add
        locked = set()       # (cid, idx) user-overridden; immutable

        def set_role(key, val, is_intrinsic):
            """Assign a role, honouring authority and detecting
            conflicts.  Returns True if something changed."""
            if key in locked or key in conflicted:
                return False
            cur = role.get(key)
            if cur is None:
                role[key] = val
                if is_intrinsic:
                    intrinsic.add(key)
                return True
            if cur == val:
                # Re-affirming; upgrade to intrinsic if applicable.
                if is_intrinsic and key not in intrinsic:
                    intrinsic.add(key)
                    return True
                return False
            # Contradiction.
            if key in intrinsic and not is_intrinsic:
                # Intrinsic wins; ignore the inferred contradiction.
                return False
            if is_intrinsic and key not in intrinsic:
                # New intrinsic overrides a prior inferred value.
                role[key] = val
                intrinsic.add(key)
                return True
            # Two inferred values disagree (or two intrinsic disagree,
            # which shouldn't happen): back off — drop the pin.
            role.pop(key, None)
            conflicted.add(key)
            return True

        # Phase 0: user overrides — locked, override everything else and
        # are set before the table so Phase 1 can never contest them.
        overrides = getattr(self, '_pin_role_overrides', None) or {}
        if overrides:
            for inst in instances:
                ref = inst.comp['ref']
                cid = id(inst.comp)
                for idx, (pn, _nn) in enumerate(
                        getattr(inst, '_pin_net_pairs', None) or []):
                    val = overrides.get((ref, pn))
                    if val in ('in', 'out'):
                        role[(cid, idx)] = val
                        locked.add((cid, idx))

        # Rail polarity is needed by Phase 1 too: a source pin wired to a power
        # net acts as an input, so the source drives only its other pin.
        pos_rail, neg_rail = self._rail_polarity()
        rail_or_power = (set(_PWR_NETS_LC_FOR_T) | set(rail_nets)
                         | {r for r in (pos_rail, neg_rail) if r})
        # The user's OWN net classification, read here rather than
        # taken as an argument so all seven call sites agree by
        # construction and none can pass a stale set.  This is the
        # point after parse and after the .pr.json load, so the Nets
        # dialog's input/output/+power/ground marks are all visible.
        io_in_nets, io_out_nets = self._subckt_io_nets()

        # Phase 1: intrinsic pin roles.  The table is indexed by
        # comp['nets'] position; the map is keyed by _pin_net_pairs
        # position, so every index crosses _net_index_to_pair_index.
        for inst in instances:
            inputs, outputs = self._pin_directions(inst.comp)
            cid = id(inst.comp)
            nets = inst.comp.get('nets', []) or []
            n2p = _net_index_to_pair_index(inst)
            pairs = getattr(inst, '_pin_net_pairs', None) or []
            if (not inputs and set(outputs) == {0, 1} and len(nets) == 2
                    and len(pairs) == 2):
                # A generic 2-terminal source drives BOTH terminals, but
                # if one of them sits on a rail that terminal is the
                # source's REFERENCE, not something it drives.  This
                # must stay INTRINSIC: Phase 2's inferred rail seed
                # cannot demote an intrinsic 'out', which is how
                # LM324's VLP came out driving ground.
                on_rail = [str(pairs[i][1]).lower() in rail_or_power
                           for i in (0, 1)]
                if on_rail[0] != on_rail[1]:   # exactly one on a rail
                    rail_idx = 0 if on_rail[0] else 1
                    set_role((cid, rail_idx), 'in', True)
                    set_role((cid, 1 - rail_idx), 'out', True)
                    continue
            for idx in inputs:
                set_role((cid, n2p.get(idx, idx)), 'in', True)
            for idx in outputs:
                set_role((cid, n2p.get(idx, idx)), 'out', True)

        # Phase 2: seed each pin from its net's role; a pin's default direction
        # is the opposite of what the net's T does (a rail T drives, so the pin
        # receives).
        for inst in instances:
            cid = id(inst.comp)
            for idx, (_pn, nn) in enumerate(
                    getattr(inst, '_pin_net_pairs', None) or []):
                nl = str(nn).lower()
                if nl in rail_or_power or nl in io_in_nets:
                    set_role((cid, idx), 'in', False)
                elif nl in io_out_nets:
                    set_role((cid, idx), 'out', False)

        # Build a signal-net → [(cid, idx)] index for the net rule,
        # skipping excluded nets (power/ground/IO/rail) so direction
        # never flows through them.
        skip_nets = (set(_PWR_NETS_LC_FOR_T) | set(exclude_nets)
                     | set(rail_nets) | set(io_in_nets) | set(io_out_nets))
        net_pins = {}
        for inst in instances:
            cid = id(inst.comp)
            for idx, (_pn, nn) in enumerate(
                    getattr(inst, '_pin_net_pairs', None) or []):
                nl = str(nn).lower()
                if nl in skip_nets:
                    continue
                net_pins.setdefault(nl, []).append((cid, idx))

        # Phase 3: interleaved propagation to a fixpoint.
        max_iters = 32
        for _ in range(max_iters):
            changed = False

            # Component rule — 2-pin symmetric parts.
            for inst in instances:
                if inst.comp.get('kind', '') not in self._DIR_PROPAGATE_KINDS:
                    continue
                if len(getattr(inst, '_pin_net_pairs', None) or []) != 2:
                    continue
                cid = id(inst.comp)
                r0 = role.get((cid, 0))
                r1 = role.get((cid, 1))
                if r0 is None and r1 is not None and (cid, 0) not in conflicted:
                    changed |= set_role((cid, 0),
                                         'out' if r1 == 'in' else 'in', False)
                elif (r1 is None and r0 is not None
                        and (cid, 1) not in conflicted):
                    changed |= set_role((cid, 1),
                                         'out' if r0 == 'in' else 'in', False)

            # Net rule — a unique output on a net drives unknown pins
            # to input.
            for _nl, pins in net_pins.items():
                outs = [k for k in pins if role.get(k) == 'out']
                if len(outs) != 1:
                    continue
                for k in pins:
                    if k in outs:
                        continue
                    if role.get(k) is None and k not in conflicted:
                        changed |= set_role(k, 'in', False)

            if not changed:
                break
        # expose the CONFIDENCE tier alongside role, for
        # _compute_signal_topo_order's edge policy: 'high' = intrinsic table
        # or user override (authoritative, never guessed); 'low' = resolved
        # only by propagation (component-rule / net-rule) — right often
        # enough to place a part, but not confident enough to treat a
        # resulting cycle as GENUINE feedback rather than a probably-backward
        # guess.  Stashed on self rather than widening this function's
        # return (many existing call sites only want the plain role dict).
        self._pin_role_confidence = {
            k: ('high' if (k in intrinsic or k in locked) else 'low')
            for k in role}
        return role

    # ── Arrow rendering geometry ────────────────────────────────────────

    # Length of each leg of the arrow tip (two 6-px lines at 45°).
    _ARROW_LEG_LEN = 6

    def _draw_direction_arrow(self, x_tail, y_tail, x_head, y_head,
                                color):
        """Draw a midpoint arrow tip on a line from (x_tail, y_tail)
        toward (x_head, y_head).  Two 6-px lines at 45° to the line
        direction, meeting at the arrow tip in the middle of the
        segment.  Tagged 'flight_line' + 'direction_arrow' so it
        gets wiped along with other flight-line items on re-render.
        """
        dx = x_head - x_tail
        dy = y_head - y_tail
        L = math.hypot(dx, dy)
        if L < 1e-3:
            return
        # Midpoint of the segment is where the arrow TIP sits.
        mx = (x_tail + x_head) / 2.0
        my = (y_tail + y_head) / 2.0
        # Unit vector along the segment (tail → head).
        ux = dx / L
        uy = dy / L
        # Two legs of the arrow: tip is at (mx, my); legs go BACK
        # toward the tail at ±45°.  Rotate (-ux, -uy) by +45° and
        # -45° to get leg directions; scale by _ARROW_LEG_LEN.
        cos45 = 0.7071067811865476    # sqrt(2)/2
        sin45 = 0.7071067811865476
        # Back-vector = (-ux, -uy).
        bx, by = -ux, -uy
        # Rotate by +45°:
        rx1 = bx * cos45 - by * sin45
        ry1 = bx * sin45 + by * cos45
        # Rotate by -45°:
        rx2 = bx * cos45 + by * sin45
        ry2 = -bx * sin45 + by * cos45
        ex1 = mx + rx1 * self._ARROW_LEG_LEN
        ey1 = my + ry1 * self._ARROW_LEG_LEN
        ex2 = mx + rx2 * self._ARROW_LEG_LEN
        ey2 = my + ry2 * self._ARROW_LEG_LEN
        self.canvas.create_line(mx, my, ex1, ey1, fill=color, width=1,
                                  tags=('flight_line', 'direction_arrow'))
        self.canvas.create_line(mx, my, ex2, ey2, fill=color, width=1,
                                  tags=('flight_line', 'direction_arrow'))

    def _draw_direction_none_dot(self, x_tail, y_tail, x_head, y_head,
                                   color):
        """Draw a small filled dot at the midpoint of the segment to
        indicate the user has explicitly forced 'no arrow' on this
        flight line.  This distinguishes 'forced none' from 'auto
        decided no arrow' when both states render the same.
        """
        mx = (x_tail + x_head) / 2.0
        my = (y_tail + y_head) / 2.0
        r = 2
        self.canvas.create_oval(mx - r, my - r, mx + r, my + r,
                                  fill=color, outline=color,
                                  tags=('flight_line', 'direction_arrow',
                                        'direction_none_dot'))

    @staticmethod
    def _arrow_edge_key(net_lc, ref_a, pin_a, ref_b, pin_b):
        """Canonical edge identifier for _arrow_overrides storage.
        Pins (a, b) are sorted so the same edge yields the same key
        regardless of iteration order.
        """
        end_a = (ref_a, pin_a)
        end_b = (ref_b, pin_b)
        if end_a > end_b:
            end_a, end_b = end_b, end_a
        return (net_lc, end_a, end_b)

    @staticmethod
    def _rail_arrow_edge_key(net_lc, ref, pin):
        """Edge key for a rail stub (one endpoint is the rail
        itself, not a pin).  Distinct format so right-click cycles
        them independently from pin-to-pin edges.
        """
        return (net_lc, 'RAIL', (ref, pin))

    def _maybe_draw_arrow_on_edge(self, net_lc, inst_a, pin_a,
                                    ax, ay, inst_b, pin_b, bx, by,
                                    color):
        """In : the net, both endpoints (instance, pin and canvas xy) and
        a colour.  Out: a direction arrow drawn on that MST edge, or none.
        Auto: the arrow points out -> in when pin_role_map gives one
        endpoint 'out' and the other 'in'; otherwise nothing is drawn.
        A user override from the right-click cycle wins —
          'forward'  always a -> b
          'reverse'  always b -> a
          'none'     no arrow, marked with a midpoint dot
        and a missing key means 'auto', the detected direction."""
        ref_a = inst_a.comp['ref']
        ref_b = inst_b.comp['ref']
        try:
            pin_a_idx = int(pin_a) - 1
        except (TypeError, ValueError):
            return
        try:
            pin_b_idx = int(pin_b) - 1
        except (TypeError, ValueError):
            return
        key = self._arrow_edge_key(net_lc, ref_a, pin_a, ref_b, pin_b)
        override = self._arrow_overrides.get(key, 'auto')
        # Determine canonicalised "forward" direction from auto-detect.
        # Note: arrow_edge_key sorted (ref, pin) tuples, so the
        # "forward" direction (a→b in the key's canonical order) is
        # the alphabetic-first endpoint → the alphabetic-second.  But
        # in this draw call (a, b) may already be in either order;
        # decide direction in CALL order and translate at the end.
        role_a = self._pin_role_map.get((id(inst_a.comp), pin_a_idx))
        role_b = self._pin_role_map.get((id(inst_b.comp), pin_b_idx))
        auto_direction = None     # +1 means a→b, -1 means b→a, 0 = none
        if role_a == 'out' and role_b == 'in':
            auto_direction = +1
        elif role_a == 'in' and role_b == 'out':
            auto_direction = -1
        # Resolve effective direction by override.
        if override == 'forward':
            effective = +1   # canonical (in the KEY ordering)
        elif override == 'reverse':
            effective = -1
        elif override == 'none':
            # Draw the midpoint dot and stop.
            self._draw_direction_none_dot(ax, ay, bx, by, color)
            return
        else:
            effective = auto_direction    # may be None

        if effective is None:
            return    # auto says "no arrow"

        # auto_direction is already in call order; only an override needs
        # translating from canonical to call order.
        if override in ('forward', 'reverse'):
            # Translate the canonical KEY direction to (ax, ay)→(bx, by)
            # call-order direction.  Canonical key has end_a < end_b
            # alphabetically; if the caller's (ref_a, pin_a) is the
            # canonical end_a then +1 == a→b; if reversed, +1 == b→a.
            end_a = (ref_a, pin_a)
            end_b = (ref_b, pin_b)
            if end_a < end_b:
                # Caller order matches canonical.
                tail_xy, head_xy = (((ax, ay), (bx, by)) if effective == +1
                                    else ((bx, by), (ax, ay)))
            else:
                # Caller order is reversed from canonical.
                tail_xy, head_xy = (((bx, by), (ax, ay)) if effective == +1
                                    else ((ax, ay), (bx, by)))
        else:
            # effective came from auto_direction — already call-order.
            tail_xy, head_xy = (((ax, ay), (bx, by)) if effective == +1
                                else ((bx, by), (ax, ay)))

        self._draw_direction_arrow(tail_xy[0], tail_xy[1],
                                     head_xy[0], head_xy[1], color)

    # ── Force-directed placement (rev 52a; superseded but kept for ref) ──

    def _sense_segments(self, instances):
        """Return the purple sense lines as (comp, inst, tgt, net_name,
        fb_net_lc, a_xy, b_xy): the sensed pin (or nearest body point) joined
        to the sensing part's equation text.
        """
        rail = (set(_PWR_NETS_LC_FOR_T) | set(self._promoted_rails)
                | set(self._eligible_t_nets()))

        def body_centre(o):
            sb = o.sym_body_rel
            return (o.ox_px + (sb[0] + sb[2]) / 2,
                    o.oy_px + (sb[1] + sb[3]) / 2)

        def body_bbox(o):
            sb = o.sym_body_rel
            return (o.ox_px + sb[0], o.oy_px + sb[1],
                    o.ox_px + sb[2], o.oy_px + sb[3])

        def value_text_bbox(o):
            """The equation/VALUE text item's own bbox,
            in absolute canvas coords, or None if it has no placed
            position (falls back to the composite bbox at the call
            site)."""
            for ti in o.text_items:
                if ti['kind'] == 'value' and ti['placed'] is not None:
                    rx, ry, anchor, fs, is_interior = ti['placed']
                    bb_rel = _text_bbox_from_anchor(
                        rx, ry, ti['text'], anchor, fs, bold=False)
                    return (o.ox_px + bb_rel[0], o.oy_px + bb_rel[1],
                            o.ox_px + bb_rel[2], o.oy_px + bb_rel[3])
            return None

        by_net = {}
        by_ref = {}
        for o in instances:
            by_ref[o.comp['ref'].lower()] = o
            for nn in (o.comp.get('nets') or []):
                by_net.setdefault(nn.lower(), []).append(o)

        def resolve_src(name):
            o = by_ref.get(name)
            if o is not None:
                return o
            return next((v for r, v in by_ref.items()
                         if r.endswith('.' + name) or r.endswith('_' + name)),
                        None)

        out = []
        for inst in instances:
            comp = inst.comp
            sn = comp.get('sense_nets') or []
            ss = comp.get('sense_srcs') or []
            if not sn and not ss:
                continue
            # Prefer the equation text's own bbox; the
            # composite (body+labels) bbox is only a fallback for the
            # rare case the value text isn't placed yet.
            inst_bb = value_text_bbox(inst) or inst.abs_composite()
            icx, icy = body_centre(inst)
            targets = []
            for s in sn:
                nl = s.lower()
                if nl in rail:
                    continue
                carriers = [o for o in by_net.get(nl, []) if o is not inst]
                if carriers:
                    best = min(
                        carriers,
                        key=lambda o: abs(body_centre(o)[0] - icx)
                        + abs(body_centre(o)[1] - icy))
                    targets.append((best, s, s.lower()))
            for s in ss:
                tgt = resolve_src(s.lower())
                if tgt is not None and tgt is not inst:
                    # Synthetic net_lc matching the '__i__' + src label
                    # _compute_signal_topo_order uses for this SAME
                    # current-sense edge (see its sense_srcs loop) —
                    # this is what lets a click on this line toggle
                    # its feedback status via the same
                    # self._feedback_overrides / _feedback_edge_key
                    # mechanism as any other edge, even though a
                    # current-sense dependency isn't a real net.
                    targets.append((tgt, None, '__i__' + s.lower()))
            for tgt, net_name, fb_net_lc in targets:
                tgt_bb = body_bbox(tgt)
                # Anchor at the pin that carries this net, not at the carrier
                # instance's body.
                pin_xy = None
                if net_name:
                    for pn, nn in (getattr(tgt, '_pin_net_pairs', None)
                                   or []):
                        if nn.lower() == net_name.lower():
                            pin_xy = _pin_canvas_pos(tgt, pn)
                            break
                if pin_xy is not None:
                    tx, ty = pin_xy
                else:
                    tx, ty = _rect_closest_point(tgt_bb, icx, icy)
                # The equation-side end is still "closest spot on the
                # equation bbox" — now measured against wherever the
                # line actually starts from (the real pin, when we have
                # one), not the carrier's body centre.
                lx, ly = _rect_closest_point(inst_bb, tx, ty)
                out.append((comp, inst, tgt, net_name,
                            fb_net_lc, (lx, ly), (tx, ty)))
        return out

    def _draw_sense_flight_lines(self, instances):
        """Draw each behavioral source's control dependencies (V(net) inputs and
        sensed V-sources) as purple fine-dash lines from the sensed point to
        the source.
        """
        C_SENSE = '#9933cc'    # purple — control / sense dependency
        for (comp, inst, tgt, net_name, fb_net_lc,
             (lx, ly), (tx, ty)) in self._sense_segments(instances):
            # Tag the sense line and its label like an ordinary net label, so
            # the existing click, drag and hide handlers work on them unchanged.
            sense_key = (f'sense:{comp["ref"].lower()}:'
                         f'{net_name.lower()}') if net_name else None
            # Tag with flight_net:<net> like ordinary MST lines, so
            # _pick_flight_line_edge resolves a clicked sense line to its net.
            owner_ref = comp['ref']
            target_ref = tgt.comp['ref']
            fb_key = self._feedback_edge_key(
                fb_net_lc, owner_ref, target_ref)
            line_tags = [
                'flight_line', f'flight_net:{fb_net_lc}',
                f'flight_edge_refs:{owner_ref}\x1f{target_ref}',
            ]
            if sense_key:
                line_tags.append(f'flight_line_net:{sense_key}')
            # _ln_color is reused below for this edge's own net-
            # name LABEL text too, not just the line, so a forced-
            # feedback edge's label matches its (now red) line
            # instead of staying the usual purple.
            _ln_color = (C_FEEDBACK
                         if self._feedback_overrides.get(fb_key)
                         else C_SENSE)
            self.canvas.create_line(lx, ly, tx, ty, fill=_ln_color,
                                    width=1, dash=(1, 3),
                                    tags=tuple(line_tags))
            # Give each sense line a net-name label like ordinary nets have.
            if sense_key:
                if sense_key in self._net_labels:
                    for i, entry in enumerate(
                            self._net_labels[sense_key]):
                        if not entry.get('visible', True):
                            continue
                        cx, cy = entry['pos']
                        self.canvas.create_text(
                            cx, cy, text=net_name,
                            font=(FONT_FAMILY, 8), fill=_ln_color,
                            anchor=tk.W,
                            tags=('net_label',
                                  f'net_label:{sense_key}',
                                  f'net_label_idx:{i}'))
                else:
                    mx, my = (lx + tx) / 2, (ly + ty) / 2
                    ddx, ddy = tx - lx, ty - ly
                    if abs(ddx) >= abs(ddy):
                        lbl_xy, anchor = (mx, my - 4), tk.S
                    else:
                        lbl_xy, anchor = (mx + 4, my), tk.W
                    self.canvas.create_text(
                        lbl_xy[0], lbl_xy[1], text=net_name,
                        font=(FONT_FAMILY, 8), fill=_ln_color,
                        anchor=anchor,
                        tags=('net_label',
                              f'net_label:{sense_key}',
                              'net_label_idx:-1'))

    def _draw_pin_flight_lines(self, net_to_pins):
        """In : net_to_pins.  Out: the flight lines drawn on the canvas,
        every item tagged 'flight_line' so the move handler can wipe them
        in O(1).
        A signal net gets teal dashed lines pin to pin.  A power or IO pin
        gets its OWN stub to the margin instead — GND down, VCC up,
        .SUBCKT inputs left, outputs right — because one shared sentinel
        point per power net drew a dot near the middle of the sheet."""
        C_PIN_FLIGHT = '#006688'   # teal for signal nets
        C_GND_FLIGHT = '#884400'   # brown for GND connections
        C_VCC_FLIGHT = '#004488'   # dark blue for VCC connections
        C_IO_FLIGHT  = '#aa6600'   # orange for SUBCKT input / output
        # C_FEEDBACK (module-level, shared with _draw_sense_flight_
        # lines) — yellow for a user-forced-feedback net.

        # Compute pin-role map for direction arrows.
        # Walk every placed instance once, derive each pin's intrinsic
        # direction (input/output/unknown), then propagate through R/L/C
        # symmetric components.  See _compute_pin_role_map.
        instances_for_roles = self._placed_instances or []
        in_nets_for_roles, out_nets_for_roles = self._subckt_io_nets()
        self._pin_role_map = self._compute_pin_role_map(
            instances_for_roles,
            set(in_nets_for_roles) | set(out_nets_for_roles),
            self._promoted_rails)

        # SUBCKT input/output still draw all the way to the canvas edge
        # so they're easy to trace.  Power/ground use a short stub
        # (per pin) — handled below — so cw/ch aren't needed there.
        cw, ch = self._canvas_size()
        margin = max(20, SCALE)
        left_x   = margin
        right_x  = cw - margin
        _ = ch

        # Lift the SUBCKT input/output net heuristic so flight lines can
        # use it.  Recomputed every render so a fresh _parser.subckts is
        # honoured.  Net names are case-insensitive.
        subckt_in_nets, subckt_out_nets = self._subckt_io_nets()

        # Length of the short power/ground stub, in pixels.  Rev 35
        # used full-canvas-height lines; rev 36 uses a short stub with
        # the net name labelled at the far end (matches schematic
        # convention).
        _STUB_LEN = max(20, int(SCALE * 1.8))

        for nl, members in net_to_pins.items():
            # Filter out sentinel entries (inst=None) — we no longer use
            # a shared anchor for GND/VCC.
            real_members = [(m, p) for (m, p) in members if m is not None]

            # ── GND/VSS: short vertical stub DOWN, net name below ─────
            if nl in _GND_NETS_LC:
                _ftag = f'flight_net:{nl}'
                for inst, pnum in real_members:
                    px, py = _pin_canvas_pos(inst, pnum)
                    ey = py + _STUB_LEN
                    self.canvas.create_line(
                        px, py, px, ey,
                        fill=C_GND_FLIGHT, width=2,
                        tags=('flight_line', _ftag))
                    # Small triangle tick to mark the rail end (schematic
                    # GND symbol convention).
                    self.canvas.create_line(
                        px - 5, ey, px + 5, ey,
                        fill=C_GND_FLIGHT, width=2,
                        tags=('flight_line', _ftag))
                    # Net name just below the tick.
                    self.canvas.create_text(
                        px, ey + 2, text=self._disp_net(nl), anchor=tk.N,
                        font=(FONT_FAMILY, 9), fill=C_GND_FLIGHT,
                        tags=('flight_line', _ftag))
                continue
            # ── VCC/VDD: short vertical stub UP, net name above ───────
            if nl in _VCC_NETS_LC:
                _ftag = f'flight_net:{nl}'
                for inst, pnum in real_members:
                    px, py = _pin_canvas_pos(inst, pnum)
                    ey = py - _STUB_LEN
                    self.canvas.create_line(
                        px, py, px, ey,
                        fill=C_VCC_FLIGHT, width=2,
                        tags=('flight_line', _ftag))
                    # Small triangle tick to mark the rail end.
                    self.canvas.create_line(
                        px - 5, ey, px + 5, ey,
                        fill=C_VCC_FLIGHT, width=2,
                        tags=('flight_line', _ftag))
                    self.canvas.create_text(
                        px, ey - 2, text=self._disp_net(nl), anchor=tk.S,
                        font=(FONT_FAMILY, 9), fill=C_VCC_FLIGHT,
                        tags=('flight_line', _ftag))
                continue


            # ── SUBCKT inputs/outputs: per-pin horizontal stub ─────
            _ftag = f'flight_net:{nl}'
            if nl in subckt_in_nets:
                for inst, pnum in real_members:
                    px, py = _pin_canvas_pos(inst, pnum)
                    self.canvas.create_line(
                        px, py, left_x, py,
                        fill=C_IO_FLIGHT, width=1, dash=(4, 2),
                        tags=('flight_line', _ftag))
                # Inputs are still also drawn pin-to-pin below so users
                # can see multiple input-connected pins relate to one
                # another.  Fall through.
            elif nl in subckt_out_nets:
                # One stub, at the driving pin: stubbing every pin on an output
                # net makes the node look torn apart.
                _povr = getattr(self, '_pin_role_overrides', None) or {}
                _drivers = []
                for inst, pnum in real_members:
                    _mark = _povr.get((inst.comp['ref'], pnum))
                    if _mark == 'in':
                        continue
                    if _mark == 'out' or nl in {
                            str(x).lower()
                            for x in _electrical_net_roles(inst.comp)[0]}:
                        _drivers.append((inst, pnum))
                # NO STUB WHERE A T ALREADY MARKS THE PORT.  The
                # run-to-the-margin stub predates the T-symbol system and
                # says the same thing an output T says, so a driving pin
                # that owns one was drawing both -- Osc1 and Osc2 showed
                # a full-width orange line leaving net C's T.  This is
                # the rail case's "ghost T" duplication (see the filter
                # in _render) one net class over.  A driver with no T
                # still gets its stub, so a declared port whose T was
                # suppressed is unaffected.
                _p2t_o = getattr(self, '_pin_to_t', None) or {}
                for inst, pnum in (_drivers or real_members):
                    if (inst.comp['ref'], pnum) in _p2t_o:
                        continue
                    px, py = _pin_canvas_pos(inst, pnum)
                    self.canvas.create_line(
                        px, py, right_x, py,
                        fill=C_IO_FLIGHT, width=1, dash=(4, 2),
                        tags=('flight_line', _ftag))
                # Same fall-through reasoning as inputs.

            # Ordinary signal nets: pin-to-pin dashed lines along an N-1 edge
            # Manhattan MST.
            p2t = getattr(self, '_pin_to_t', None) or {}
            _t_owned = [(m, p) for m, p in real_members
                        if (m.comp['ref'], p) in p2t]
            if len(_t_owned) == len(real_members):
                continue
            if len(_t_owned) > 1:
                # Several T's on one net: keep one owner as the meeting
                # point so the rest join it, and let the other stubs
                # stand on their own as before.
                _keep_owner = _t_owned[0]
                real_members = [(m, p) for m, p in real_members
                                if (m.comp['ref'], p) not in p2t
                                or (m, p) == _keep_owner]
            n = len(real_members)
            if n < 2:
                continue
            pin_xy = [_pin_canvas_pos(m, p) for m, p in real_members]
            mst = _mst_edges_manhattan(
                pin_xy, self._wire_groups_for(real_members))
            for ia, ib in mst:
                ax, ay = pin_xy[ia]
                bx, by = pin_xy[ib]
                inst_a, pin_a = real_members[ia]
                inst_b, pin_b = real_members[ib]
                # Feedback color is per edge (this inst_a<->inst_b connection on
                # net nl), not per net: only one of a net's edges may be the
                # feedback one.
                fb_key = self._feedback_edge_key(
                    nl, inst_a.comp['ref'], inst_b.comp['ref'])
                _fl_color = (C_FEEDBACK
                             if self._feedback_overrides.get(fb_key)
                             else C_PIN_FLIGHT)
                self.canvas.create_line(
                    ax, ay, bx, by,
                    fill=_fl_color, width=1, dash=(4, 2),
                    tags=('flight_line', _ftag))
                # Direction arrow.
                self._maybe_draw_arrow_on_edge(
                    nl, inst_a, pin_a, ax, ay,
                    inst_b, pin_b, bx, by, _fl_color)
            # Flight-line net-name labels are drawn by
            # _draw_multi_pin_net_labels, called from _render
            # unconditionally (NOT gated on show_flights).  The label
            # is the PRIMARY signage for the net; it must be visible
            # even when flight lines themselves are hidden.

    def _draw_multi_pin_net_labels(self, instances):
        """Draw one net-name label for each multi-pin non-T net, at the midpoint
        of its longest MST edge, nudged beside the line.
        """
        if not instances:
            return
        # Build net → list of (instance, pin_num) on canvas.
        net_to_pins = {}
        for inst in instances:
            pn_pairs = getattr(inst, '_pin_net_pairs', None) or []
            for pp, nn in pn_pairs:
                key = nn.lower()
                net_to_pins.setdefault(key, []).append((inst, pp))
        t_nets = {t['net'].lower() for t in self._t_terminals}
        fully_connected = self._nets_fully_connected_by_wires(instances)
        for nl, members in net_to_pins.items():
            if len(members) < 2:
                continue                # single-pin net — keeps per-pin label
            if nl in t_nets:
                continue                # T-symbol carries the label
            if nl in fully_connected:
                continue                # user already wired this net

            # If the user has edited labels for this net,
            # honour those entries verbatim instead of the default.
            if nl in self._net_labels:
                for i, entry in enumerate(self._net_labels[nl]):
                    if not entry.get('visible', True):
                        continue
                    cx, cy = entry['pos']
                    self.canvas.create_text(
                        cx, cy, text=self._disp_net(nl),
                        font=(FONT_FAMILY, 9), fill=C_NET,
                        anchor=tk.W,
                        tags=('net_label',
                               f'net_label:{nl}',
                               f'net_label_idx:{i}'))
                continue

            # Default placement: longest-MST-edge midpoint.
            pin_xy = [_pin_canvas_pos(m, p) for m, p in members]
            mst = _mst_edges_manhattan(pin_xy,
                                       self._wire_groups_for(members))
            if not mst:
                continue
            longest = max(mst,
                          key=lambda e: abs(pin_xy[e[0]][0] - pin_xy[e[1]][0])
                                       + abs(pin_xy[e[0]][1] - pin_xy[e[1]][1]))
            ax, ay = pin_xy[longest[0]]
            bx, by = pin_xy[longest[1]]
            mid_x = (ax + bx) / 2
            mid_y = (ay + by) / 2
            dx_e = bx - ax; dy_e = by - ay
            if abs(dx_e) >= abs(dy_e):
                label_xy = (mid_x, mid_y - 4)
                anchor = tk.S
            else:
                label_xy = (mid_x + 4, mid_y)
                anchor = tk.W
            # idx=-1 marks the default-midpoint label (not yet
            # persisted in self._net_labels).  Click handlers turn it
            # into a real entry when interacted with.
            self.canvas.create_text(
                label_xy[0], label_xy[1], text=self._disp_net(nl),
                font=(FONT_FAMILY, 9), fill=C_NET,
                anchor=anchor,
                tags=('net_label',
                       f'net_label:{nl}',
                       'net_label_idx:-1'))

    # T-terminal and cluster machinery: power, ground and .SUBCKT-port nets end
    # in per-pin T-symbols instead of wires; clusters are parts joined by
    # internal nets.

    def _rail_t_rot(self, net_lc, pins, default_rot):
        """Takes a net, the pins asking (unused -- kept for callers) and the
        net's default rotation, and returns the ONE rotation every T on a
        promoted rail (MID-type) gets: a user override, else 0, the ground
        glyph. A promoted rail is a reference node the circuit hangs from
        (OPAx197's MID), so it is drawn like ground: T below, the part's
        MID pin down. Decided per NET, so a reservation and a drawing cannot
        disagree. Non-promoted nets keep `default_rot`."""
        if net_lc not in self._promoted_rails:
            return default_rot
        ov = (self._t_net_rot_overrides or {}).get(net_lc)
        return 0 if ov is None else ov

    def _t_default_rot_for_net(self, net_lc, subckt_out_nets):
        """Takes a net name and returns the default T rotation (0/90/180/270)
        for it. A user override, set by right-click-rotating a T, wins over the
        heuristic, so a reclassified net keeps its side on every Place."""
        ov = (self._t_net_rot_overrides or {}).get(net_lc)
        if ov is not None:
            return ov
        if net_lc == '0' or net_lc in _GND_NETS_LC:
            return 0       # bottom-side
        if net_lc in _VCC_NETS_LC:
            return 180     # top-side
        # numeric rails classified by inferred polarity
        # (the user's LM324 overrides 3->180 / 4->0 are now defaults).
        pos, neg = self._rail_polarity()
        if net_lc == neg:
            return 0       # negative rail behaves like a bottom rail
        if net_lc == pos:
            return 180     # positive rail on top
        if net_lc in subckt_out_nets:
            return 90      # right-side
        # Anything else that's a port (incl. ambiguous names) → input.
        return 270         # left-side

    def _canonical_t_net(self, net_lc):
        """Rev 46/47 — kept for API compatibility but now an identity
        function.  GND and 0 are placed on the bottom side of a cluster
        (both default to rot=0 via _t_default_rot_for_net) but they
        remain DISTINCT nets — collapsing them would draw a flight
        line between unrelated pins.  In ngspice the literal node "0"
        is the global reference; a net named "GND" inside a SUBCKT is
        a port-passed reference to whatever the caller wired in, which
        is usually but not always the global "0"."""
        return net_lc

    def _is_toplevel_io_net(self, net):
        """In : a net name.  Out: True when it is a port of the
        currently-viewed top-level SUBCKT.
        These get bright-blue T-symbols; every other T-net — an internal
        cut net such as MID, an internal regulated supply, ground — keeps
        the internal colour.  The literal global ground '0' is NOT
        top-level IO, and a power net is blue only when it appears in
        that port list, an externally supplied rail; an internally
        generated supply with a power-like name is not a port and stays
        internal-coloured."""
        if not net:
            return False
        nl = net.lower()
        if nl == '0':
            return False
        if not (self._parser and self._parser.subckts):
            return False
        active = (self._active_subckt or '').upper()
        if active and active in self._parser.subckts:
            ports = [p.lower()
                     for p in self._parser.subckts[active].get('ports', [])]
            return nl in ports
        return False

    def _eligible_t_nets(self):
        """Out: a frozenset of lower-case net names that should get
        T-symbols — '0', the promoted rails, and the port names of the
        CURRENTLY VIEWED top-level SUBCKT.
        Only the active subckt's ports count.  A union across ALL subckts
        in the file drew internal signal nets as IO T's: OPAx197's MID is
        a port of the sub-SUBCKT SW_OL_OPAx197 but purely internal to the
        top-level OPAx197.  At the top-level view only the top-level
        ports are real IO; everything else is an internal signal."""
        nets = {'0'}
        # Promoted rails (>=20-fanout internal nets
        # like MID) render as T-symbols at each cluster edge.
        nets |= set(self._promoted_rails)
        if self._parser and self._parser.subckts:
            active = (self._active_subckt or '').upper()
            if active and active in self._parser.subckts:
                # Only the active SUBCKT's ports count as IO.
                for p in self._parser.subckts[active].get('ports', []):
                    nets.add(p.lower())
            else:
                # Top-level deck (no SUBCKT expanded — viewing the
                # main circuit directly).  Use union of all subckt
                # ports as a permissive fallback so X-instances of
                # any subckt visible at this level still get IO Ts
                # where appropriate.
                for sc in self._parser.subckts.values():
                    for p in sc.get('ports', []):
                        nets.add(p.lower())
        # Apply the user's PORT overrides (independent
        # of cut-ness): force-ON nets render as T-symbols even if they
        # don't cut the graph; force-OFF nets render as flight lines
        # even if they're cut (e.g. a cut-only net the user wants
        # without T-clutter).
        nets |= set(self._port_force_on)
        nets -= set(self._port_force_off)
        return frozenset(nets)

    def _suppressed_per_pin_nets(self, comps):
        """The set of (lower-case) nets
        whose per-pin labels are suppressed: multi-pin nets (labelled once
        on the flight-line midpoint) UNION T-eligible nets (labelled on the
        T-symbol).  This computation was DUPLICATED verbatim in both
        _run_placement and _render (the classic do-it-twice => 2x bug
        surface); both now call this one helper with their own comps list so
        the instances they build carry the SAME label set.  Pure: depends
        only on `comps` net counts and _eligible_t_nets()."""
        net_pin_counts = {}
        for c in comps:
            for n in c.get('nets', []):
                k = n.lower()
                net_pin_counts[k] = net_pin_counts.get(k, 0) + 1
        multi_pin_nets = frozenset(k for k, v in net_pin_counts.items()
                                   if v >= 2)
        # A net the user classified in the Nets dialog is drawn as a T, which
        # already carries the net name.
        user_t_nets = {str(n).lower() for n in
                       (getattr(self, '_t_net_rot_overrides', None) or {})}
        return multi_pin_nets | self._eligible_t_nets() | user_t_nets

    def _compute_clusters(self, instances):
        """In : the instances.  Out: a list of lists of CompInstance, one
        per cluster: two instances share a cluster when they share a net
        that is NOT a CUT net.
        The cut set is power/ground, IO, rails and cut overrides — NOT
        T-eligibility.  Cut-ness and port-ness are independent, so a
        PORT-only net, drawn as a T but not cutting the graph, must not
        split a cluster here; that keeps the T-symbol clustering and the
        placement clustering in agreement and leaves parallel siblings on
        a port-only net together."""
        in_nets, out_nets = self._subckt_io_nets()
        eligible = self._cluster_cut_nets(in_nets, out_nets)

        # Build net → [insts] map for ONLY non-eligible (internal) nets.
        # Each instance's contribution is its POSITIONAL nets (the
        # comp['nets'] list — NOT references inside value expressions).
        net_to_insts = {}
        idx_of = {id(inst): i for i, inst in enumerate(instances)}
        for inst in instances:
            for net in inst.comp.get('nets', []):
                nl = net.lower()
                if nl in eligible:
                    continue
                net_to_insts.setdefault(nl, []).append(inst)

        # Union-find over instance indices.
        parent = list(range(len(instances)))
        def find(x):
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x
        def union(a, b):
            ra, rb = find(a), find(b)
            if ra != rb:
                parent[ra] = rb

        for insts in net_to_insts.values():
            if len(insts) < 2:
                continue
            base = idx_of[id(insts[0])]
            for other in insts[1:]:
                union(base, idx_of[id(other)])

        # Apply the SAME fusion the placement
        # clusterer uses (parallel siblings + short series chains) so
        # the T-symbol clustering agrees with the placement clustering.
        by_ref = {i.comp['ref']: i for i in instances}
        for refs in self._parallel_groups(instances):
            members = [by_ref[r] for r in refs if r in by_ref]
            for m in members[1:]:
                union(idx_of[id(members[0])], idx_of[id(m)])
        for chain in self._compute_series_chains(instances):
            for m in chain[1:]:
                union(idx_of[id(chain[0])], idx_of[id(m)])

        # Group instances by root.
        groups = {}
        for i, inst in enumerate(instances):
            r = find(i)
            groups.setdefault(r, []).append(inst)
        # Order: by smallest index in each group.
        clusters = sorted(groups.values(),
                          key=lambda g: min(idx_of[id(i)] for i in g))
        # same singleton-cluster fold as
        # _compute_signal_segments/_merge_singleton_segments, so this
        # fallback clusterer (used by _rebuild_t_terminals when no
        # stored placement partition is available) agrees with the
        # main placement clusterer.
        clusters = self._merge_singleton_segments(clusters)
        return clusters

    def _cluster_bbox(self, cluster):
        """Return (x0, y0, x1, y1) in canvas pixels of the union of
        each instance's COMPOSITE extent (symbol body + ref/value/
        equation text + per-pin power/ground T-symbols).  Rev
        an earlier revision — was previously symbol-body-only, which let
        cluster-edge IO/MID T-symbols be positioned overlapping the
        instances' value/ref labels.  Using the composite extent
        keeps edge Ts clear of the cluster's text."""
        if not cluster:
            return (0, 0, 0, 0)
        xs0 = []; ys0 = []; xs1 = []; ys1 = []
        for inst in cluster:
            bb = self._instance_bbox_with_ts(inst)   # at-origin extent
            xs0.append(inst.ox_px + bb[0])
            ys0.append(inst.oy_px + bb[1])
            xs1.append(inst.ox_px + bb[2])
            ys1.append(inst.oy_px + bb[3])
        return (min(xs0), min(ys0), max(xs1), max(ys1))

    def _pins_of_net_in_cluster(self, cluster, net_lc):
        """List of (inst, pin_num, px, py) tuples for every pin of every
        instance in `cluster` whose net (case-insensitive) is `net_lc`.
        An instance with two pins on the same net contributes two
        entries (rare but possible)."""
        out = []
        for inst in cluster:
            pn_pairs = getattr(inst, '_pin_net_pairs', None) or []
            for pp, nn in pn_pairs:
                if nn.lower() != net_lc:
                    continue
                pxy = _pin_canvas_pos(inst, pp)
                if pxy is None:
                    continue
                out.append((inst, pp, pxy[0], pxy[1]))
        return out

    def _estimated_composite_extent(self, inst):
        """In : an instance.  Out: its (left, top, right, bottom) extent
        in pixels around the origin, INCLUDING label overhang.
        inst.composite_rel is still the sentinel at _arrange_into_clusters
        time, because place_texts sets it later, so this unions
        sym_body_rel with each text item's first non-interior candidate
        at its measured size.
        Candidates come in two shapes — a net-name label is (rx, ry,
        anchor), a value label (rx, ry, anchor, font_size, is_interior) —
        so the unpack is tolerant and falls back to ti['font_size'] when
        the candidate carries none."""
        # An envelope over every value-label side, unioned below with the
        # committed extent, so the reservation is an upper bound.
        bx0, by0, bx1, by1 = inst.sym_body_rel
        ex0, ey0, ex1, ey1 = bx0, by0, bx1, by1
        for ti in getattr(inst, 'text_items', []):
            cands = ti.get('candidates') or []
            # Which candidates to reserve: place_texts may pick any of them at
            # render time, so a value label reserves them all.
            nonint = [c for c in cands
                      if not (len(c) >= 5 and c[4]) and len(c) >= 3]
            if not nonint:
                continue
            # A value label reserves every candidate side (the envelope); other
            # labels reserve only their first candidate.
            use = nonint[:1] if ti.get('kind') != 'value' else nonint
            for chosen in use:
                rx, ry, anchor = chosen[0], chosen[1], chosen[2]
                fs = (chosen[3] if len(chosen) >= 4
                      else ti.get('font_size', 10))
                text = ti.get('text', '')
                tw, th = _measure_text(text, fs)
                a = (anchor if isinstance(anchor, str)
                     else str(anchor)).lower()
                if   a == 'nw':                 lx, ty =  0,    0
                elif a == 'n':                  lx, ty = -tw/2,  0
                elif a == 'ne':                 lx, ty = -tw,    0
                elif a == 'w':                  lx, ty =  0,    -th/2
                elif a in ('center', 'c', ''):  lx, ty = -tw/2, -th/2
                elif a == 'e':                  lx, ty = -tw,    -th/2
                elif a == 'sw':                 lx, ty =  0,    -th
                elif a == 's':                  lx, ty = -tw/2, -th
                elif a == 'se':                 lx, ty = -tw,    -th
                else:                           lx, ty = -tw/2, -th/2
                tx0 = rx + lx; ty0 = ry + ty
                tx1 = tx0 + tw; ty1 = ty0 + th
                ex0 = min(ex0, tx0); ey0 = min(ey0, ty0)
                ex1 = max(ex1, tx1); ey1 = max(ey1, ty1)
        # UPPER BOUND.  The envelope above is generous on the value
        # label's axis but silent about everything else, so measured
        # against the drawn composite it under-reserved 21 of 36 LP2951
        # instances and 4 of 30 LM324 ones — by 2-6 px for the ordinary
        # parts (padding place_texts applies and this loop does not) and
        # by 100-121 px for the two V sources whose long expression the
        # renderer wraps differently.  Unioning with the committed
        # extent closes both gaps at once and cannot shrink anything.
        _c = self._committed_composite_extent(inst)
        if _c is not None:
            ex0 = min(ex0, _c[0]); ey0 = min(ey0, _c[1])
            ex1 = max(ex1, _c[2]); ey1 = max(ey1, _c[3])
        return (ex0, ey0, ex1, ey1)

    def _committed_composite_extent(self, inst):
        """In : an instance.  Out: the EXACT at-origin composite (body
        plus labels as drawn), or None when it cannot be computed.
        place_texts is a purely local decision — always handed a fresh
        empty QuadTree, so it sees only the part's own stubs and its own
        other labels — so running it here on a scratch tree reproduces
        the render-time placement exactly: 0 of 36 (LP2951) and 0 of 30
        (LM324) instances differed from the drawn composite_rel.  That
        makes this the true extent, not an estimate, and the envelope
        path unions with it to stay an upper bound.  The instance is
        left as it was found."""
        # MEMOIZED.  The answer is a pure function of the part's own
        # geometry and text (see above), and OPAx197 asked for it 68,000
        # times per Place -- 11 s, each call re-running place_texts.
        try:
            key = (id(inst), inst.rotation_deg or 0,
                   tuple(getattr(inst, 'sym_body_rel', None) or ()),
                   bool(self._auto_flips.get(inst.comp['ref'])),
                   bool(self._user_flips.get(inst.comp['ref'])),
                   tuple((ti.get('kind'), str(ti.get('text')),
                          ti.get('font_size'))
                         for ti in inst.text_items))
        except Exception:
            key = None
        memo = self.__dict__.setdefault('_committed_extent_memo', {})
        if key is not None and key in memo:
            return memo[key]
        c = self._committed_composite_extent_uncached(inst)
        if key is not None and c is not None:
            memo[key] = c
        return c

    def _committed_composite_extent_uncached(self, inst):
        """_committed_composite_extent without the memo."""
        try:
            # Measuring must not change what it measures.  place_texts
            # rewrites 'text' (fresh word-wrap) and 'candidates' (fresh
            # layout decision), so re-running it to restore is NOT a
            # no-op on an already-wrapped string — it re-wraps the wrap,
            # a line taller each time.  Snapshot and put the fields back
            # instead.
            tsnap = inst._snapshot_text_items()
            inst.place_texts(QuadTree(-200000, -200000, 200000, 200000))
            c = tuple(inst.composite_rel)
            inst._restore_text_items(tsnap)
            inst._recompute_composite_rel()
            return c
        except Exception:
            return None

    def _clump_pins_for_interior_t(self, pins, pins_per=None):
        """Split a rail's pins (list of
        (inst, pin_num, px, py)) into spatial clumps for interior
        T-symbol placement.  Targets ~self._INTERIOR_T_PINS_PER pins
        per clump (override with `pins_per`, used by the V-share path).
        Splits along the dominant spread axis by sorting
        and banding — deterministic and dependency-free; each pin ends
        up nearest its own clump's centroid for short stubs.

        Returns a list of clumps (each a list of pin tuples).  Returns
        a single clump if the pin count doesn't warrant splitting."""
        n = len(pins)
        target = pins_per or self._INTERIOR_T_PINS_PER
        k = max(1, round(n / float(target)))
        if k < 2:
            return [list(pins)]
        xs = [p[2] for p in pins]
        ys = [p[3] for p in pins]
        spread_x = max(xs) - min(xs)
        spread_y = max(ys) - min(ys)
        axis = 2 if spread_x >= spread_y else 3   # tuple index of px/py
        ordered = sorted(pins, key=lambda p: p[axis])
        clumps = []
        for i in range(k):
            band = ordered[i * n // k:(i + 1) * n // k]
            if band:
                clumps.append(band)
        return clumps

    def _emit_t(self, ts_list, pin_to_t, inst_net_t, net, cx, cy, rot,
                pins, net_lc=None, first_wins=False, **flags):
        """Create ONE T-symbol, register it, and map its owning pins — the
        single home for the build / bump-id / append / pin-map
        boilerplate that all ~9 T-placement paths in
        _rebuild_t_terminals used to repeat.  `pins` is an iterable of
        (ref, pin) pairs.  When `net_lc` is given, also records
        inst_net_t[(ref, net_lc)] = id.  `first_wins` keeps any existing pin
        mapping (an earlier revision first-map-wins).  Extra keyword flags
        (single_pin / interior / vshare / …) are attached to the T dict.
        Returns the new T."""
        # No T is ever shared: a T is a label on one pin, so a multi-owner
        # request is split here into one T per pin.  Every T path funnels
        # through _emit_t, so ownership and the reserved box stay 1:1.
        _pins = list(pins)
        _refs = sorted({r for (r, _p) in _pins})
        if len(_refs) > 1:
            _by = (getattr(self, '_placing_instances', None)
                   or getattr(self, '_placed_instances', None) or ())
            _by = {i.comp['ref']: i for i in _by}
            _last = None
            for _r in _refs:
                _sub = [(rr, pp) for (rr, pp) in _pins if rr == _r]
                _cx, _cy, _rot = cx, cy, rot
                _inst = _by.get(_r)
                if _inst is not None:
                    try:
                        _got = self._predicted_pin_t(_inst, _sub[0][1], net)
                    except Exception:
                        _got = None
                    if _got is not None:
                        _rot, _lx, _ly = _got
                        _cx = _inst.ox_px + _lx
                        _cy = _inst.oy_px + _ly
                _last = self._emit_t(ts_list, pin_to_t, inst_net_t, net,
                                     _cx, _cy, _rot, _sub, net_lc=net_lc,
                                     first_wins=first_wins, **flags)
            return _last

        t = {'id': self._next_t_id, 'net': net, 'cx': cx, 'cy': cy,
             'rot': rot}
        if flags:
            t.update(flags)
        self._next_t_id += 1
        ts_list.append(t)
        for ref, pn in pins:
            if not (first_wins and (ref, pn) in pin_to_t):
                pin_to_t[(ref, pn)] = t['id']
            if net_lc is not None:
                inst_net_t[(ref, net_lc)] = t['id']
        return t

    def _rebuild_t_terminals(self, instances):
        """Compute clusters, place T-symbols for every eligible net (per-pin T's
        for power, ground and promoted rails; one T per IO net), and rewrite
        _t_terminals and _pin_to_t.
        """
        # _t_clears_instances looks an owner up by ref.
        self._t_owner_inst = {i.comp['ref']: i for i in instances}
        eligible = self._eligible_t_nets()
        # Pass the instances: the pin-mark output promotion needs pin
        # numbers, and _placed_instances is not set yet at this point.
        subckt_in_nets, subckt_out_nets = self._subckt_io_nets(instances)
        # item #3: top-level INPUT ports get per-pin Ts
        # near each consuming pin (like rails) instead of one T forced
        # to the canvas's left edge.  Outputs are unchanged (single
        # right-edge T via Phase 2/3).
        input_nets_lc = {n.lower() for n in subckt_in_nets}
        # items (b)/(c): top-level OUTPUTS also get per-pin
        # Ts near each DRIVING pin (placed to the right, the 90 output
        # glyph) instead of one T forced to the canvas's right edge.
        output_nets_lc = {n.lower() for n in subckt_out_nets}

        # Reuse the EXACT placement box partition so
        # the T-symbol clustering matches where the parts were actually
        # placed (otherwise the two diverge and Ts/edges land wrong,
        # which previously caused overlaps).  Reconstruct the instance
        # lists from the stored refs against the current instances;
        # fall back to recomputing only if no stored partition exists
        # (e.g. T-rebuild invoked before any placement).
        boxes_refs = getattr(self, '_placement_boxes_refs', None)
        if boxes_refs:
            by_ref = {i.comp['ref']: i for i in instances}
            clusters = []
            placed = set()
            for refs in boxes_refs:
                cl = [by_ref[r] for r in refs if r in by_ref]
                if cl:
                    clusters.append(cl)
                    placed.update(id(c) for c in cl)
            # Any instance not in the stored partition (shouldn't
            # normally happen) becomes its own cluster so it isn't lost.
            for inst in instances:
                if id(inst) not in placed:
                    clusters.append([inst])
        else:
            clusters = self._compute_clusters(instances)
        self._boxes = [[i.comp['ref'] for i in c] for c in clusters]
        # "when V-Share is off, generate one T per
        # net for small clusters (<=8 instances), one T per (instance,
        # net) otherwise": ref_to_cluster_size lets the per-pin loop
        # below tell which regime a given instance's own cluster is in.
        ref_to_cluster_size = {i.comp['ref']: len(cl)
                               for cl in clusters for i in cl}
        # ref -> which cluster (by index) owns it,
        # needed by the Phase 3 input/output T-merge below: merging a
        # net's T's across DIFFERENT clusters silently violates the
        # cluster boundary a T is supposed to represent (confirmed a
        # real bug this way — DP||RP's own net-4 T was merging with
        # VE's, across two genuinely separate clusters, because that
        # merge only ever checked (net, side), never cluster identity).
        ref_to_cluster_idx = {i.comp['ref']: idx
                              for idx, cl in enumerate(clusters) for i in cl}
        SMALL_CLUSTER_T_MAX = 8

        new_ts = []
        new_pin_to_t = {}
        # item (d): at most ONE T per (instance, net).
        # When an instance has several pins on the same T-net (e.g. a
        # VCVS with two ground pins), the first pin gets the T and the
        # others route to it via a flight line, instead of stacking
        # duplicate, overlapping Ts on one part.
        inst_net_t = {}        # (ref, net_lc) -> t_id
        T_MARGIN = max(60, int(_GRID_PITCH * 0.8))   # pixel gap to bbox
        T_PIN_DIST = self._T_PIN_DIST

        # Phase 1: a dedicated T for each pin on a power or ground net.
        req = {(r, str(n).lower())
               for r, n in (getattr(self, '_p2dl_pin_t_requests', None)
                            or [])}
        # (user suggestion) when SEVERAL requested pins
        # of the SAME tight group share a net (DP||RP both touching
        # net 3 on top, net 4 below), build ONE T per (group, net) at
        # the MIDPOINT between the pins, and map every pin to it — so
        # the block reads as one column pair with a single shared rail
        # T above and below, no per-instance duplicates.
        # group ids from the persistent ref-keyed map — the instance
        # objects handed to the T rebuild are re-built per render and
        # do NOT carry placement-time attributes like group_id.
        gid_of = dict(getattr(self, '_group_id_of', None) or {})
        iref = {}
        for i in instances:
            iref[i.comp['ref']] = i
        gnet = defaultdict(set)
        _rp, _rn = self._rail_polarity()
        _rail_lc = {str(x).lower() for x in (_rp, _rn) if x}
        # _rail_polarity reports one net per sign, but ground and -power share
        # '-', so add every net the user marked +power, ground or -power.
        _rail_lc |= {str(n).lower()
                    for n in (getattr(self, '_rail_polarity_overrides', None)
                              or {})}
        # Add the structurally detected supply rails too: _rail_polarity reports
        # one net per sign, which misses LM324.lib's net 4.
        _rail_lc |= {str(r).lower()
                     for r in (getattr(self, '_supply_rails', None)
                               or set())}
        for r, nl in req:
            g = gid_of.get(r)
            if g is not None:
                gnet[(g, nl)].add(r)
        # also group the POWER/GROUND pins of
        # members that share a tight group id (e.g. the diff-pair load
        # resistors RC1/RC2 both on net 4 / Vee).  These aren't in
        # _p2dl_pin_t_requests (that list is the SP/parallel rail pins like
        # DP||RP), so without this they fell through to a single cluster-edge
        # rail T placed far below the whole cluster — its flight lines then
        # crossed the EGND symbol.  Treating them as a shared-rail pair puts
        # ONE T at their pin midpoint, just below the pins (see the
        # half-span offset below), mirroring how IEE fans up to RE1/RE2.
        for inst in instances:
            r = inst.comp['ref']
            g = gid_of.get(r)
            if g is None:
                continue
            for pn, nn in (getattr(inst, '_pin_net_pairs', None) or []):
                nlc = nn.lower()
                if (nlc in _PWR_NETS_LC_FOR_T or nlc in _rail_lc):
                    gnet[(g, nlc)].add(r)
        # A group-owned T: one T per (P2DL cell, rail net), owned by the cell,
        # with every member pin on that net wired to it.
        for (g, nl), refs in sorted(gnet.items(),
                                    key=lambda kv: (str(kv[0][0]),
                                                    kv[0][1])):
            if len(refs) < 2:
                continue
            pins, pts, nets = [], [], []
            for r in sorted(refs):
                inst = iref.get(r)
                for pn, nn in (getattr(inst, '_pin_net_pairs', None)
                               or []):
                    if nn.lower() == nl:
                        pins.append((r, pn))
                        pts.append(_pin_canvas_pos(inst, pn))
                        nets.append(nn)
            if len(pts) < 2 or any(p is None for p in pts):
                continue
            rot = 0
            if nl in _VCC_NETS_LC:
                rot = 180
            elif nl not in _PWR_NETS_LC_FOR_T:
                rp_, rn_ = self._rail_polarity()
                if nl == rp_:
                    rot = 180
            mx = sum(p[0] for p in pts) / len(pts)
            # offset the shared-rail T from the pins by HALF
            # the horizontal pin span (user request), so for a two-column
            # pair like RC1/RC2 the T sits ~half the RC1↔RC2 distance below
            # (mirroring IEE→RE1/RE2 above the cell) and its flight lines stay
            # short and clear of anything below (the EGND symbol).  Floored at
            # T_PIN_DIST so a single-column / coincident-x group keeps the old
            # gap.
            xspan = max(p[0] for p in pts) - min(p[0] for p in pts)
            off = max(T_PIN_DIST, xspan / 2.0)
            if rot == 180:
                my = min(p[1] for p in pts) - off
            else:
                my = max(p[1] for p in pts) + off
            # ANCHOR the cell's T in a MEMBER'S OWN RESERVED SLOT.
            # The midpoint above is where the pins average out, which is
            # a point NOBODY reserved: it sits between the members, so a
            # T drawn there can be outside every box on the page — the
            # exact defect that turned group sharing off in the first
            # place.  _predicted_pin_t is the one routine the RESERVATION
            # asks, so taking the answer for the member pin nearest that
            # midpoint puts the shared T at a place the placer already
            # kept clear, and the other members' pins fly to it.  The
            # cell is rigid, so this is a fixed CELL coordinate.
            cy_mid = sum(p[1] for p in pts) / len(pts)
            best = None
            for (r, pn), pt, nn in zip(pins, pts, nets):
                inst = iref.get(r)
                if inst is None:
                    continue
                try:
                    got = self._predicted_pin_t(inst, pn, nn)
                except Exception:
                    got = None
                if got is None:
                    continue
                key = ((pt[0] - mx) ** 2 + (pt[1] - cy_mid) ** 2, r, pn)
                if best is None or key < best[0]:
                    best = (key, inst, got)
            if best is None:
                # NO MEMBER RESERVED A SLOT ON THIS NET, so there is no
                # place to put a shared T that the placer kept clear.
                # Leave the pins to the per-pin phases rather than drop
                # the T at the midpoint: that point is the one the old
                # sharing put it at, and it is outside every box.
                continue
            _inst, (t_rot, lx, ly) = best[1], best[2]
            mx, my, rot = _inst.ox_px + lx, _inst.oy_px + ly, t_rot
            owner = _inst.comp['ref']
            # A shared T must be reachable without crossing a part; otherwise
            # use per-pin T's.
            _outsiders = [i for i in instances
                          if i.comp['ref'] not in refs]
            _probe = {'cx': mx, 'cy': my, 'rot': rot, 'net': nl}
            _blocked = False
            for (r, pn), pt in zip(pins, pts):
                # The member's OWN body and the T's own label are graded
                # by the one route grader, so a shared T is never created
                # where _t_defect_counts would then report it.  A cell
                # whose members Sugiyama now stacks in one column is
                # exactly where that bites: the shared T can end up on
                # the far side of the far member.
                if any(self._t_route_defect(iref.get(r), pn, _probe)):
                    _blocked = True
                    break
                if r == owner:
                    continue
                for other in _outsiders:
                    ob = self._obstacle_box(other)
                    if ob and _seg_enters_box(pt, (mx, my), ob):
                        _blocked = True
                        break
                if _blocked:
                    break
            if _blocked:
                continue
            self._emit_t(new_ts, new_pin_to_t, inst_net_t, nl, mx, my, rot,
                         pins, net_lc=nl, group=g, owner=owner)
        for inst in instances:
            ref = inst.comp['ref']
            for pn, nn in (getattr(inst, '_pin_net_pairs', None) or []):
                nl = nn.lower()
                # Small clusters (<=8 parts) get one T per net for the whole
                # cluster; leave the pin unclaimed here so Phase 2 builds that
                # shared-per-cluster T.
                if (ref_to_cluster_size.get(ref, 0)
                        <= SMALL_CLUSTER_T_MAX
                        and nl in eligible
                        and nl not in _PWR_NETS_LC_FOR_T
                        and nl not in _rail_lc):
                    continue
                # Treat both named power nets and the computed rails (_rail_lc,
                # including the user's Nets-dialog roles) as rails here.
                if (nl not in _PWR_NETS_LC_FOR_T and nl not in _rail_lc
                        and (ref, nl) not in req):
                    continue
                prev = inst_net_t.get((ref, nl))
                if prev is not None:        # item (d): reuse this part's T
                    new_pin_to_t[(ref, pn)] = prev
                    continue
                # Classification by net name, then inferred/override
                # polarity (numeric rails: positive top, negative
                # bottom) — _rp/_rn were computed above from the SAME
                # _rail_polarity() the grouped-pin branch already uses.
                if nl in _VCC_NETS_LC or (nl and nl == _rp):
                    rot = 180        # top-side (+V): bar above, label up
                else:
                    rot = 0          # bottom-side (gnd/0): bar below
                if nl not in _PWR_NETS_LC_FOR_T and nl not in _rail_lc:
                    # requested pins on NON-family nets
                    # use the FULL default classifier (rail polarity,
                    # subckt inputs 270, outputs 90), so e.g. the
                    # chain_to_out request on net 5 yields a sideways
                    # output T, not a bottom-style one.
                    try:
                        outs_lc = {str(x).lower()
                                   for x in (self._subckt_io_nets()[1]
                                             or ())}
                    except Exception:
                        outs_lc = set()
                    rot = self._t_default_rot_for_net(nl, outs_lc)

                # ONE routine decides where a T goes.  _predicted_pin_t
                # is the same call the RESERVATION makes, so the T is
                # emitted at exactly the offset the placer reserved for
                # it and can never land outside the instance's bbox.
                # (It also supersedes the rot chosen just above, which
                # is why that value is only used as the fallback.)
                got = self._predicted_pin_t(inst, pn, nn)
                if got is None:
                    continue
                t_rot, lx, ly = got
                tx = inst.ox_px + lx
                ty = inst.oy_px + ly
                rot = t_rot
                self._emit_t(new_ts, new_pin_to_t, inst_net_t, nn, tx, ty,
                             rot, [(ref, pn)], net_lc=nl)

        # Phase 1b: per-pin T's for promoted rails (e.g. MID), so each T follows
        # its own part when moved.
        rails = set(self._promoted_rails)
        # V-share: group each promoted rail's pins within a cluster into spatial
        # clumps and give each clump one T.
        per_pin_nets = rails | input_nets_lc | output_nets_lc  # rails + IO
        # A .SUBCKT port T sits near its pin, on the side its role gives it:
        # left for an input, right for an output (_io_port_t_side_pos).
        # Promoted rails keep their outward placement.
        out_driver_pins = {}
        _povr = getattr(self, '_pin_role_overrides', None) or {}
        if output_nets_lc:
            for inst in instances:
                iout = {str(x).lower()
                        for x in _electrical_net_roles(inst.comp)[0]}
                rf = inst.comp['ref']
                for pn, nn in (getattr(inst, '_pin_net_pairs', None) or []):
                    nl = nn.lower()
                    if nl not in output_nets_lc:
                        continue
                    mark = _povr.get((rf, pn))
                    if mark == 'in':
                        continue
                    if mark == 'out' or nl in iout:
                        out_driver_pins.setdefault(nl, set()).add((rf, pn))
        # ...UNLESS NO DRIVER SHARES ITS BOX.  Boxes are packed apart, so a
        # receiver in a box with no driver (LM324.sub's C25 on net 5) would
        # otherwise reach across into another box.  It gets its own T.
        # A receiver that would be the only unmarked pin of its net in its
        # box also gets a T, or nothing would draw its connection.
        box_of = {r: k for k, seg in enumerate(
            getattr(self, '_signal_segments', None) or []) for r in seg}
        lone = defaultdict(list)
        for inst in instances:
            ref = inst.comp['ref']
            for pn, nn in (getattr(inst, '_pin_net_pairs', None) or []):
                nl = nn.lower()
                dh = out_driver_pins.get(nl)
                if nl in per_pin_nets and dh and (ref, pn) not in dh:
                    lone[(nl, box_of.get(ref, ref))].append((ref, pn))
        for inst in instances:
            ref = inst.comp['ref']
            for pn, nn in (getattr(inst, '_pin_net_pairs', None) or []):
                nl = nn.lower()
                if nl not in per_pin_nets:
                    continue
                drivers_here = out_driver_pins.get(nl)
                if drivers_here and (ref, pn) not in drivers_here:
                    my_box = box_of.get(ref, ref)
                    if (len(lone[(nl, my_box)]) > 1
                            and any(box_of.get(r, r) == my_box
                                    for r, _p in drivers_here)):
                        continue    # receiver on an output net: no T
                if (ref, pn) in new_pin_to_t:
                    continue        # already placed (e.g. also pwr/gnd)
                prev = inst_net_t.get((ref, nl))
                if prev is not None:        # item (d): reuse this part's T
                    new_pin_to_t[(ref, pn)] = prev
                    continue
                # SAME single source of truth as the reservation, so
                # this T is emitted exactly where the placer reserved
                # room for it — see _predicted_pin_t.  The IO-port side
                # rule (_io_port_t_side_pos) and the rail drive/receive
                # rotation (_rail_t_rot) both live in there now, so this
                # loop no longer duplicates either of them.
                got = self._predicted_pin_t(inst, pn, nn)
                if got is None:
                    continue
                rot, lx, ly = got
                tx = inst.ox_px + lx
                ty = inst.oy_px + ly
                self._emit_t(new_ts, new_pin_to_t, inst_net_t, nn, tx, ty,
                             rot, [(ref, pn)], net_lc=nl, single_pin=True)

        # ── Phase 2: cluster-level Ts for IO eligible nets ─────────
        # The remaining eligible nets (anything in `eligible` minus
        # the power/ground class) are SUBCKT-IO ports.  For each
        # cluster, place ONE T per IO net it touches, using the
        # pre-rev-50 cluster-edge geometry.
        for cluster in clusters:
            bbox = self._cluster_bbox(cluster)
            cx_left, cy_top, cx_right, cy_bot = bbox

            nets_here = {}      # canon_net → list of (inst, pin_num, px, py)
            raw_synonyms = {}   # canon_net → set of raw names seen
            for inst in cluster:
                comp = inst.comp
                for n in comp.get('nets', []):
                    nl = n.lower()
                    if nl not in eligible:
                        continue
                    if nl in _PWR_NETS_LC_FOR_T:
                        continue       # handled in phase 1 (per-pin)
                    if nl in _rail_lc:
                        # Check the override-aware _rail_lc, not only the static
                        # power-name set, so a user-marked rail gets no extra T
                        # here.
                        continue
                    if nl in self._promoted_rails:
                        continue       # handled in phase 1b (per-pin rail)
                    if nl in input_nets_lc:
                        continue       # handled in phase 1b (per-pin input)
                    if nl in output_nets_lc:
                        continue       # handled in phase 1b (per-pin output)
                    canon = self._canonical_t_net(nl)
                    nets_here.setdefault(canon, None)
                    raw_synonyms.setdefault(canon, set()).add(nl)

            for canon in list(nets_here.keys()):
                # Gather pins from EVERY synonym that mapped to this
                # canonical name.
                pins = []
                for raw in raw_synonyms[canon]:
                    pins.extend(self._pins_of_net_in_cluster(cluster, raw))
                if not pins:
                    nets_here.pop(canon)
                    continue
                nets_here[canon] = pins

            # ── interior multi-T for high-fanout rails.
            # For a promoted rail with many pins in THIS cluster, split
            # the pins into spatial clumps and drop one interior T per
            # clump at the clump centroid (floating inside the cluster),
            # wiring each pin to its clump's T.  Removes the net from
            # nets_here so the edge-T logic below skips it.
            for canon in list(nets_here.keys()):
                if canon not in self._promoted_rails:
                    continue
                pins = nets_here[canon]
                if len(pins) < self._INTERIOR_T_MIN_PINS:
                    continue
                clumps = self._clump_pins_for_interior_t(pins)
                if len(clumps) < 2:
                    continue       # one clump → let edge logic handle
                # Use the net's PROPER T orientation
                # (input/output/power/ground glyph) instead of the
                # hardcoded rot=0, which drew signal rails like MID
                # as if they were ground.  MID and other promoted
                # signal rails resolve to 270 (input-style glyph).
                irot = self._rail_t_rot(
                    canon, pins,
                    self._t_default_rot_for_net(canon, subckt_out_nets))
                for clump in clumps:
                    cxx = sum(p[2] for p in clump) / len(clump)
                    cyy = sum(p[3] for p in clump) / len(clump)
                    self._emit_t(
                        new_ts, new_pin_to_t, inst_net_t, canon, cxx,
                        cyy, irot,
                        [(i.comp['ref'], p) for i, p, _x, _y in clump],
                        first_wins=True, interior=True)
                nets_here.pop(canon)

            # Group nets by side (default rotation) so we can stack
            # multiples on the same edge.
            by_side = {0: [], 90: [], 180: [], 270: []}
            for nl, pins in nets_here.items():
                rot = self._rail_t_rot(
                    nl, pins,
                    self._t_default_rot_for_net(nl, subckt_out_nets))
                # Centroid of this net's pins in this cluster:
                cx_c = sum(p[2] for p in pins) / len(pins)
                cy_c = sum(p[3] for p in pins) / len(pins)
                by_side[rot].append((nl, rot, pins, cx_c, cy_c))

            # Place Ts per side.
            for rot, entries in by_side.items():
                if not entries:
                    continue
                if rot == 0:          # bottom-side: x=centroid_x, y=bbox_bot+M
                    entries.sort(key=lambda e: e[3])   # sort by centroid x
                    for nl, _r, pins, cxc, _cyc in entries:
                        ty = cy_bot + T_MARGIN
                        tx = cxc
                        self._emit_t(
                            new_ts, new_pin_to_t, inst_net_t, nl, tx, ty,
                            rot,
                            [(i.comp['ref'], p) for i, p, _x, _y in pins],
                            first_wins=True)
                elif rot == 180:      # top-side
                    entries.sort(key=lambda e: e[3])
                    for nl, _r, pins, cxc, _cyc in entries:
                        # No clamp: this runs in both the cluster's local frame
                        # (for sizing) and global coordinates, so a floor would
                        # give different answers.
                        ty = self._edge_t_clamp(cy_top - T_MARGIN)
                        tx = cxc
                        self._emit_t(
                            new_ts, new_pin_to_t, inst_net_t, nl, tx, ty,
                            rot,
                            [(i.comp['ref'], p) for i, p, _x, _y in pins],
                            first_wins=True)
                elif rot == 270:      # left-side: x=bbox_left-M, y=centroid_y
                    entries.sort(key=lambda e: e[4])   # sort by centroid y
                    for nl, _r, pins, _cxc, cyc in entries:
                        # NO CLAMP — same frame-dependence as the rot-180
                        # case above.  Measured on LM324.sub: the cluster
                        # box was sized with this T at local x=5 (clamped),
                        # the packer then shifted the cluster +16 px, and
                        # the render clamped to 5 AGAIN in global
                        # coordinates — dropping the shift and leaving the
                        # T 16 px inside its own cluster, clipping C22.
                        tx = self._edge_t_clamp(cx_left - T_MARGIN)
                        # When this T serves exactly
                        # ONE pin in the cluster, put it at that pin's
                        # y (not the centroid) so the flight line is
                        # perfectly horizontal.  Tag it single_pin so
                        # the overlap-spread pass leaves it alone.
                        single = (len(pins) == 1)
                        ty = pins[0][3] if single else cyc
                        self._emit_t(
                            new_ts, new_pin_to_t, inst_net_t, nl, tx, ty,
                            rot,
                            [(i.comp['ref'], p) for i, p, _x, _y in pins],
                            first_wins=True, single_pin=single)
                elif rot == 90:       # right-side
                    entries.sort(key=lambda e: e[4])
                    for nl, _r, pins, _cxc, cyc in entries:
                        tx = cx_right + T_MARGIN
                        single = (len(pins) == 1)
                        ty = pins[0][3] if single else cyc
                        self._emit_t(
                            new_ts, new_pin_to_t, inst_net_t, nl, tx, ty,
                            rot,
                            [(i.comp['ref'], p) for i, p, _x, _y in pins],
                            first_wins=True, single_pin=single)

            # If two Ts on the same side ended up at the same y/x
            # (centroids coincide), nudge them apart.  Simple post-pass:
            # for any pair of Ts within 16 px on the cross-axis on the
            # same side, push the second one further out by 18 px.
            self._spread_overlapping_ts(new_ts, by_side, bbox)

        # Phase 3: merge per-cluster input/output T's into one per net, so each
        # named IO net shows a single T.
        t_id_to_pins = {}
        for (ref, pn), tid in new_pin_to_t.items():
            t_id_to_pins.setdefault(tid, []).append((ref, pn))

        ts_by_io_net = {}
        for t in new_ts:
            if t['rot'] not in (90, 270):
                continue
            # Promoted-rail nets (MID etc.) keep ONE
            # T PER CLUSTER, so they are NOT merged here.  Only true
            # top-level IO nets get collapsed to a single T.
            if t['net'].lower() in self._promoted_rails:
                continue
            # item #3: top-level inputs are now per-pin
            # (Phase 1b), placed near each use; do NOT merge them back
            # into one canvas-left T.  Outputs (rot=90) still merge.
            if t['net'].lower() in input_nets_lc:
                continue
            if t['net'].lower() in output_nets_lc:
                continue
            # merging must never cross a cluster
            # boundary (see ref_to_cluster_idx's own comment above) — a
            # T on this net FROM one cluster and a T on the same net
            # from a DIFFERENT cluster are not interchangeable, even
            # though they share (net, side).  Key off whichever cluster
            # owns this T's first pin; a T with no resolvable owner
            # (shouldn't normally happen) falls back to net-only
            # grouping rather than being silently dropped from merging
            # entirely.
            t_pins = t_id_to_pins.get(t['id'], [])
            t_cluster = None
            for (r, _p) in t_pins:
                if r in ref_to_cluster_idx:
                    t_cluster = ref_to_cluster_idx[r]
                    break
            key = (t['net'].lower(), t['rot'], t_cluster)
            ts_by_io_net.setdefault(key, []).append(t)

        merged_ts = set()        # T-ids removed by merge
        for (_net_lc, rot, _cluster), group in ts_by_io_net.items():
            if len(group) < 2:
                continue
            # Choose which T to keep: leftmost x for inputs (rot=270),
            # rightmost for outputs (rot=90).
            if rot == 270:
                group.sort(key=lambda t: t['cx'])
                keeper = group[0]
            else:  # rot == 90
                group.sort(key=lambda t: -t['cx'])
                keeper = group[0]
            losers = group[1:]
            # Reroute every pin pointing to a loser to point to keeper.
            for loser in losers:
                for (ref, pn) in t_id_to_pins.get(loser['id'], []):
                    if (ref, pn) not in new_pin_to_t:
                        new_pin_to_t[(ref, pn)] = keeper['id']
                merged_ts.add(loser['id'])
            # Reposition keeper at average y/x of all pins on this net.
            all_pins = []
            for t in group:
                all_pins.extend(t_id_to_pins.get(t['id'], []))
            if all_pins:
                pin_xys = []
                for ref, pn in all_pins:
                    inst = next((i for i in instances
                                 if i.comp['ref'] == ref), None)
                    if inst is None:
                        continue
                    pxy = _pin_canvas_pos(inst, pn)
                    if pxy:
                        pin_xys.append(pxy)
                if pin_xys:
                    avg_y = sum(p[1] for p in pin_xys) / len(pin_xys)
                    if rot == 270:
                        # Left side: keep keeper's x (already at
                        # leftmost cluster's left edge), use avg y.
                        keeper['cy'] = avg_y
                    else:  # rot == 90
                        # Right side: keep keeper's x (rightmost
                        # cluster's right edge), use avg y.
                        keeper['cy'] = avg_y

        # Drop merged Ts.
        new_ts = [t for t in new_ts if t['id'] not in merged_ts]

        # item (a): pull apart per-pin Ts whose glyph/label
        # boxes overlap a NEIGHBOURING instance's T.  (Item (d) already
        # removed intra-instance duplicates; this handles inter-instance
        # overlaps, e.g. X_U22.G1's and R_R35's mid stubs landing on top
        # of each other.)
        # reposition every single-owner T from its
        # owner's LIVE pin just before drawing, so a T created on an
        # earlier (pre-settle) rebuild pass can't strand far from the
        # pin it serves (the RO1 net-5 case).  A T with exactly one
        # owning pin is single-owner.
        owners = {}
        for (ref, pn), tid in new_pin_to_t.items():
            owners.setdefault(tid, []).append((ref, pn))
        t_by_id = {t['id']: t for t in new_ts}
        for tid, pins in owners.items():
            if len(pins) != 1:
                continue
            t = t_by_id.get(tid)
            if t is None:
                continue
            # Genuine top-level SUBCKT IN/OUT
            # port T's get their side forced by net role (see
            # _io_port_t_side_pos's docstring), same as Phase 1b and
            # _repin_single_owner_ts, so all three passes stay in
            # agreement.  Reverts an earlier revision's edge_pin skip here (that
            # revision pinned these T's to the whole schematic's edge;
            # per user feedback they should stay close to their pin).
            ref, pn = pins[0]
            inst = iref.get(ref)
            if inst is None:
                continue
            # apply the instance's FINAL rotation
            # geometry before reading the pin, so the T tracks the
            # drawn pin (the rebuild's cached geometry can lag the
            # settled rotation — RO1 net-5 read 1100 px off otherwise).
            eff = self._user_rotations.get(ref)
            if eff is None:
                eff = self._auto_rotations.get(ref, inst.rotation_deg or 0)
            self._apply_instance_rotation_geometry(inst, eff or 0)
            # This re-pin used to recompute the T's position from the pin
            # with its own copy of the side rules — three passes all
            # claiming to "stay in agreement" by repeating the same
            # arithmetic.  They now agree by construction instead: ask
            # the ONE predictor, which is also what the reservation used,
            # so re-pinning cannot move a T out of its reserved box.  The
            # instance's final rotation geometry was applied just above,
            # so the predictor reads the drawn pin.
            got = self._predicted_pin_t(inst, pn, t.get('net', ''))
            if got is not None:
                t['rot'] = got[0]
                t['cx'] = inst.ox_px + got[1]
                t['cy'] = inst.oy_px + got[2]
            # _orient_t_stem_toward_pin (-> _place_t_45) used to run
            # here.  Despite the name it does not orient anything — rot
            # is fixed by net role — it MOVES the T onto a 45-degree
            # diagonal from the pin, which overwrites the position the
            # predictor just chose and the placer reserved.  That is the
            # same defect the note below records for _nudge_t_clear, by
            # a different wrapper.  Position is owned by
            # _predicted_pin_t alone, so reserved == drawn.

        # final sweep: for EVERY single-owner T (regardless
        # of which placement path produced it), make its stem point back
        # toward its pin so the pin→stem-tip flight line doesn't run over
        # the stem/bar.  The per-path re-pins above only touch some T's;
        # this catches the rest (e.g. LM324 VC net-3 / VE net-4 and the
        # many OPAX197 per-pin T's placed straight by Phase 1/1b).
        owners_all = {}
        for (rf, pnn), ti in new_pin_to_t.items():
            owners_all.setdefault(ti, []).append((rf, pnn))
        t_by_id_all = {tt['id']: tt for tt in new_ts}
        for ti, pins_all in owners_all.items():
            if len(pins_all) != 1:
                continue
            tt = t_by_id_all.get(ti)
            if tt is None:
                continue
            rf, pnn = pins_all[0]
            inst2 = iref.get(rf)
            if inst2 is None:
                continue
            # NORMALISE: whichever phase built this T, its final
            # position is the one the RESERVATION used — the cached
            # _predicted_pin_t offset in the owner's frame.  Phases 1,
            # 1b, 2 and the P2DL request path each used to compute their
            # own position, so a T could be drawn somewhere the placer
            # never reserved (LM324.lib's RP net-3 T sat 60 px below the
            # top of its own reserved box, inside RP's own text).  One
            # sweep, one rule, applied to every single-owner T, is what
            # makes "reserved == drawn" true for all of them instead of
            # for most of them.
            pnet = tt.get('net', '')
            got = self._predicted_pin_t(inst2, pnn, pnet)
            if got is not None:
                tt['rot'] = got[0]
                tt['cx'] = inst2.ox_px + got[1]
                tt['cy'] = inst2.oy_px + got[2]
            # _nudge_t_clear (-> _place_t_45) used to run here too, and
            # it is what silently overwrote the position the predictor
            # had just chosen — EGND's net-0 T came out at pin+30 (on
            # top of EGND's own text) instead of the cleared offset
            # _clear_t_of_own_body returned.  Clearance now happens in
            # the predictor, in the instance's local frame, so the T is
            # already correct here; only the stem orientation is still
            # worth fixing up, and that moves nothing.

        per_pin_ids = set(new_pin_to_t.values())
        # make the T overlap-spread CLUSTER-LOCAL.  A per-pin
        # T is nudged only to clear obstacles in ITS OWN cluster (other Ts
        # and instance bodies of the same cluster), NOT neighbours.  This
        # removes the only cross-cluster dependency in T placement (a T's
        # final position no longer depends on adjacent clusters), so a
        # cluster's T's are deterministic in its own local frame.  Safe
        # because the true-extent packing keeps cluster boxes — and hence
        # their T's/bodies — from overlapping across clusters anyway.
        ref_cluster = {}
        for ci, cl in enumerate(clusters):
            for inst in cl:
                ref_cluster[inst.comp['ref']] = ci
        t_cluster = {}
        for t in new_ts:
            if t['id'] not in per_pin_ids:
                continue
            owners = [ref for (ref, _pn), tid in new_pin_to_t.items()
                      if tid == t['id']]
            if owners:
                t_cluster[t['id']] = ref_cluster.get(owners[0])

        # drop ORPHAN rail T's: a rail/supply T (net 3/4
        # etc.) that ended up with NO pin mapped to it.  These appear when
        # two requests for the same (group, net) both mint a T but only
        # one wins the pin mapping — the loser is left as a duplicate
        # stacked ~12 px from the real T (the user's "duplicate net 3/4
        # T-symbols above/below DP||RP").  A pinless SUPPLY-rail T is
        # always useless (rails connect through their pins), so it is safe
        # to drop; IO and signal T's may legitimately be pinless (they
        # connect via flight lines) and are kept.
        rail_lc = ({str(r).lower() for r in
                    (getattr(self, '_supply_rails', None) or set())}
                   | set(_PWR_NETS_LC_FOR_T))
        orphan_ids = set()
        for t in new_ts:
            tid = t.get('id')
            if tid in per_pin_ids:
                continue
            if str(t.get('net', '')).lower() in rail_lc:
                orphan_ids.add(tid)
        if orphan_ids:
            new_ts = [t for t in new_ts if t.get('id') not in orphan_ids]

        # Self-heal: drop any _pin_to_t entry whose T id is not in new_ts, so no
        # pin points at a T that does not exist.
        valid_ids = {t['id'] for t in new_ts}
        stale = [k for k, tid in new_pin_to_t.items() if tid not in valid_ids]
        if stale:
            ts_by_net = {}
            for t in new_ts:
                ts_by_net.setdefault(
                    str(t.get('net', '')).lower(), []).append(t)
            iref = {i.comp['ref']: i for i in instances}
            for (ref, pn) in stale:
                repl = None
                inst = iref.get(ref)
                if inst is not None:
                    for _pn2, nn in (getattr(inst, '_pin_net_pairs', None)
                                     or []):
                        if _pn2 == pn:
                            cands = ts_by_net.get(str(nn).lower())
                            if cands:
                                repl = cands[0]['id']
                            break
                if repl is not None:
                    new_pin_to_t[(ref, pn)] = repl
                else:
                    del new_pin_to_t[(ref, pn)]

        # AN OWN-BOX T SURVIVES THE REBUILD.  It exists
        # because the user explicitly dropped one T on another across two
        # instances, and the rebuild recreates every T from scratch --
        # which silently destroyed that merge on the next render, so
        # "the T stays where it is" lasted until the first redraw.  The
        # T's own position is kept; the auto-generated T's for the pins
        # it serves are dropped in its favour.  Only own_box survives:
        # a single-owner T is rebuilt normally so it follows its part.
        _keep = [t for t in (getattr(self, '_t_terminals', None) or [])
                 if t.get('own_box')]
        if _keep:
            _old_p2t = getattr(self, '_pin_to_t', None) or {}
            for _kt in _keep:
                _pins = [k for k, v in _old_p2t.items()
                         if v == _kt.get('id')]
                if not _pins:
                    continue
                _drop_ids = {new_pin_to_t[k] for k in _pins
                             if k in new_pin_to_t}
                new_ts = [t for t in new_ts
                          if t.get('id') not in _drop_ids]
                new_ts.append(_kt)
                for k in _pins:
                    new_pin_to_t[k] = _kt['id']
        self._t_terminals = new_ts
        self._pin_to_t = new_pin_to_t
        self._index_ts_by_sole_owner()

    def _spread_overlapping_ts(self, t_list, by_side, bbox):
        """Nudge Ts apart when their centroids on a side coincide.
        Operates in place on t_list."""
        SEP = 18
        for rot, entries in by_side.items():
            if len(entries) < 2:
                continue
            # Find every T in t_list that matches this side + net set.
            # Single-pin Ts are deliberately aligned to
            # their pin's y (for a horizontal flight line) and must not
            # be nudged by the spread pass.
            net_set = {e[0] for e in entries}
            same_side = [t for t in t_list
                         if t['rot'] == rot and t['net'] in net_set
                         and not t.get('single_pin')]
            if rot in (0, 180):
                same_side.sort(key=lambda t: t['cx'])
                for i in range(1, len(same_side)):
                    if same_side[i]['cx'] - same_side[i-1]['cx'] < SEP:
                        same_side[i]['cx'] = same_side[i-1]['cx'] + SEP
            else:
                same_side.sort(key=lambda t: t['cy'])
                for i in range(1, len(same_side)):
                    if same_side[i]['cy'] - same_side[i-1]['cy'] < SEP:
                        same_side[i]['cy'] = same_side[i-1]['cy'] + SEP
        _ = bbox   # currently unused, may be needed for clipping later

    def _draw_t_terminal(self, t):
        """Render one T-symbol at (cx, cy) with the given rotation,
        labelled with its net name.  Returns the list of canvas item
        ids (tagged 't_term:<id>' for hit-testing)."""
        cx, cy = t['cx'], t['cy']
        rot = t['rot']
        tag = f't_term:{t["id"]}'
        # T geometry, sized so the bar never visually touches the adjacent
        # label.
        C_T_INTERNAL = '#553388'   # purple — internal cut nets
        C_T_IO       = '#0066ff'   # bright blue — top-level IO / external
        C_T = C_T_IO if self._is_toplevel_io_net(t.get('net', '')) \
            else C_T_INTERNAL
        # highlight every OTHER T on the same net
        # as the one just rotated, in green, until the user clicks empty
        # canvas space (see _highlighted_t_net's init comment).
        C_T_HIGHLIGHT = '#00aa00'
        if (self._highlighted_t_net is not None
                and t['id'] != self._highlighted_t_exclude_id
                and str(t.get('net', '')).lower() == self._highlighted_t_net):
            C_T = C_T_HIGHLIGHT

        # Stem endpoints: the stem points back to the owner's pin; the T sits
        # below (rot 0), right (90), above (180) or left (270) of that pin.
        stem_end, bar_pts, label_xy, label_anch = self._t_geometry(
            cx, cy, rot)

        items = []
        items.append(self.canvas.create_line(
            cx, cy, stem_end[0], stem_end[1],
            fill=C_T, width=2, tags=('t_term', tag)))
        items.append(self.canvas.create_line(
            bar_pts[0][0], bar_pts[0][1], bar_pts[1][0], bar_pts[1][1],
            fill=C_T, width=2, tags=('t_term', tag)))
        items.append(self.canvas.create_text(
            label_xy[0], label_xy[1], text=t['net'],
            font=(FONT_FAMILY, 9), fill=C_T,
            anchor=label_anch, tags=('t_term', tag)))
        return items

    def _draw_rank_grid(self, instances):
        """In : instances, plus _dbg_lane_info.  Out: a small orange chip
        at each part's top-left carrying its Sugiyama `rank.order`.
        Enabled by -g/--rank-grid.  Rank is the LAYER, drawn left to
        right as a vertical band; order is the position within it after
        crossing reduction — exactly what Sugiyama decides, shown against
        where the part actually ended up.
        The old rank bands are gone: a band can only be derived from
        where a rank's members average out, so once two ranks overlap in
        x it runs through both and says nothing about any INDIVIDUAL
        part, which is the case it existed to diagnose."""
        info_list = getattr(self, '_dbg_lane_info', None) or []
        if not info_list:
            return
        C_RANK = '#ff8800'
        for info in info_list:
            rank_of = info.get('rank_of_ref') or {}
            order_of = info.get('order_of_ref') or {}
            if not rank_of:
                continue
            # per-part `rank.order` badge at each body's top-left, on an
            # opaque chip so it stays readable over wires and text.  Drawn
            # after the bands so it is never occluded by them.
            for inst in instances:
                r = rank_of.get(inst.comp['ref'])
                if r is None:
                    continue
                bb = inst.abs_composite()
                txt = f"{r}.{order_of.get(inst.comp['ref'], 0)}"
                tx, ty = bb[0] + 1, bb[1] + 1
                tid = self.canvas.create_text(
                    tx, ty, text=txt, fill='#ffffff', anchor='nw',
                    font=('TkDefaultFont', 8, 'bold'), tags=('rank_grid',))
                cb = self.canvas.bbox(tid)
                if cb:
                    self.canvas.create_rectangle(
                        cb[0] - 1, cb[1] - 1, cb[2] + 1, cb[3] + 1,
                        fill=C_RANK, outline=C_RANK, tags=('rank_grid',))
                    self.canvas.tag_raise(tid)

    def _draw_all_t_terminals(self):
        """Render every T in self._t_terminals."""
        for t in self._t_terminals:
            self._draw_t_terminal(t)

    def _t_flight_route(self, inst, pn, t):
        """In : a T-linked pin's owner, its pin number and the placed T.
        Out: the 2 or 3 canvas points routing the pin to the T's stem tip
        — straight where that works, else one right-angle elbow via
        whichever corner clears both the T's label box and the body.
        THE one place that knows how a T connects: the renderer draws
        what this returns and _t_defect_counts grades the same points, so
        the audit cannot pass a line the renderer did not draw.  The
        elbow exists because a rail rotation cannot be re-aimed, so when
        the pin faces the way the stem points every straight line crosses
        the glyph.  Both elbows lie inside the pin-to-tip bounding box."""
        try:
            pxy = _pin_canvas_pos(inst, pn)
            tip = self._t_geometry(t['cx'], t['cy'], t['rot'])[0]
        except Exception:
            return None
        if pxy is None or tip is None:
            return None
        boxes = []
        try:
            lb = self._t_label_bbox_at(t['cx'], t['cy'], t['rot'],
                                       t.get('net', ''))
            if lb:
                boxes.append((lb, 0.0))
        except Exception:
            pass
        rel = getattr(inst, 'sym_body_rel', None)
        if rel and len(rel) == 4:
            boxes.append(((inst.ox_px + rel[0], inst.oy_px + rel[1],
                           inst.ox_px + rel[2], inst.oy_px + rel[3]), 1.0))

        def _clear(pts):
            for k in range(len(pts) - 1):
                for box, pad in boxes:
                    if _seg_enters_box(pts[k], pts[k + 1], box, pad=pad):
                        return False
            return True

        straight = [pxy, tip]
        if _clear(straight):
            return straight
        # Two elbows are possible.  Prefer the one that leaves the pin
        # along the pin's OWN axis first: it starts the line the way a
        # reader expects a wire to leave a pin, and on the rail case it
        # is also the one that misses the label.
        for elbow in ((pxy[0], tip[1]), (tip[0], pxy[1])):
            route = [pxy, elbow, tip]
            if _clear(route):
                return route
        return straight

    def _draw_t_flight_lines(self, instances):
        """For every (ref, pin) → t_id assignment, draw a dashed teal
        line from the pin's canvas position to the T's stem endpoint."""
        if not self._pin_to_t:
            return
        # Build id → T lookup once.
        t_by_id = {t['id']: t for t in self._t_terminals}
        # Build (ref) → instance lookup.
        inst_by_ref = {i.comp['ref']: i for i in instances}
        C_FLIGHT = '#006688'
        for (ref, pn), tid in self._pin_to_t.items():
            inst = inst_by_ref.get(ref)
            t    = t_by_id.get(tid)
            if inst is None or t is None:
                continue
            pxy = _pin_canvas_pos(inst, pn)
            if pxy is None:
                continue
            # Endpoint of the line on the T side is the stem TIP
            # (the side that points toward the circuit).  Rev 48: the
            # rot=90 / rot=270 cases had been left as the rev-45/46
            # (incorrect) directions even after _draw_t_terminal was
            #  That made the flight line terminate
            # on the LABEL side of the bar instead of the stem side
            # — visually the line passed THROUGH the net-name text.
            # Symptom: user saw the "error" net-name label sitting
            # on top of the input T's flight line.
            route = self._t_flight_route(inst, pn, t)
            if not route:
                continue
            _ftag = f'flight_net:{(t.get("net") or "").lower()}'
            flat = [v for pt in route for v in pt]
            self.canvas.create_line(
                *flat, fill=C_FLIGHT, width=1, dash=(4, 2),
                tags=('flight_line', _ftag))

    def _t_hit_bbox(self, t, tol=0):
        """Takes a placed T and a tolerance and returns its (x0, y0, x1, y1)
        hit-test box, covering the stem and bar only -- the label is a separate
        gesture.  Reads _t_geometry, so it cannot describe a different T from
        the one on screen."""
        return self._t_symbol_bbox_at(t['cx'], t['cy'], t['rot'], tol)

    def _t_label_bbox(self, t):
        """Takes a placed T and returns just its net-label text box. Kept
        separate so the overlap metric can flag a label sitting on an instance
        the T legitimately CONNECTS to: the stem touching the owner's pin is
        expected, the label on its body is not."""
        return self._t_label_bbox_at(t['cx'], t['cy'], t['rot'],
                                     t.get('net', ''))

    def _t_full_extent(self, t):
        """The FULL bbox of a placed T-symbol: stem+bar UNION its
        net-label text box.  Same computation as _predict_t_extent, just
        at the T's real position instead of the origin."""
        return self._t_extent_at(t['cx'], t['cy'], t['rot'],
                                 t.get('net', ''))

    def _boxes_clash(self, a, b, clearance=None):
        """In : boxes a and b and an optional clearance.  Out: True when
        they are NOT separated by that many empty pixels on either axis.
        Delegates to the module-level _boxes_clash_at so the separators
        (_separate_boxes, _widen_rigid_lines), which cannot see this
        class, share the identical predicate and default.  clearance
        defaults to _MIN_CLEARANCE (1 px: nothing may neighbour a pixel);
        0 degenerates to the strict overlap test that allows a boundary
        touch, and _T_BODY_CLEARANCE gives the roomier proximity test the
        non-owner T rule wants."""
        return _boxes_clash_at(a, b, self._MIN_CLEARANCE
                               if clearance is None else clearance)

    def _t_pin_gap_violations(self, instances, min_gap=None):
        """Takes the placed instances and returns [{ref, pin, net, t_id, gap}]
        for every pin->T flight line shorter than _T_PIN_MIN_GAP, measured pin
        to stem tip on the final positions rather than trusting _T_PIN_DIST to
        have been honoured -- a frozen T offset can predate a pin move. A T
        shared by several pins is measured once per pin, since each gets its own
        flight line."""
        if min_gap is None:
            min_gap = self._T_PIN_MIN_GAP
        out = []
        t_by_id = {t['id']: t for t in (self._t_terminals or [])}
        by_ref = {i.comp['ref']: i for i in (instances or [])}
        for (ref, pin), tid in (getattr(self, '_pin_to_t', None) or {}).items():
            t = t_by_id.get(tid)
            inst = by_ref.get(ref)
            if t is None or inst is None:
                continue
            try:
                px, py = _pin_canvas_pos(inst, pin)
                tip, _bar, _lxy, _anch = self._t_geometry(
                    t['cx'], t['cy'], t['rot'])
            except (KeyError, IndexError, TypeError, ValueError):
                tip = None
            if tip is None:
                continue
            gap = math.hypot(tip[0] - px, tip[1] - py)
            if gap < min_gap:
                out.append({'ref': ref, 'pin': pin, 't_id': tid,
                            'net': str(t.get('net', '')), 'gap': gap})
        return out

    def _t_body_overlap_pairs(self, instances, touch_margin=None):
        """Takes the placed instances and returns [{net, t_id, ref, kind,
        owner}] for every T glyph or net label overlapping an instance BODY
        (within touch_margin) or crossing a stub hairline (exact, no margin);
        kind is symbol, text, symbol_stub or text_stub. The standard metric
        skips a T against its own OWNER, since the stem must reach the owner's
        pin, so a T whose glyph lands on its owner's body is invisible to it;
        this reporter checks owner and non-owner alike.  A T's own owner's STUB
        is still excluded -- that is where the wire and the T connect."""
        out = []
        if touch_margin is None:
            touch_margin = self._T_BODY_CLEARANCE
        if not self._t_terminals:
            return out
        own = {}
        for (ref, pin), tid in (getattr(self, '_pin_to_t', {}) or {}).items():
            own.setdefault(tid, set()).add(ref)
        bodies = []
        stub_boxes = []
        text_boxes = []
        for inst in instances:
            try:
                bodies.append((inst.comp['ref'], inst.abs_sym_body()))
                ref = inst.comp['ref']
                for sb in _instance_stub_boxes(inst):
                    stub_boxes.append((ref, sb))
                # "T-symbols placed without regard
                # to the body text": _t_body_overlap_pairs (despite its
                # name) only ever checked a T against instance BODIES —
                # the ref/value labels themselves were invisible to it.
                # Confirmed directly on the exact case this session
                # flagged (T:645 vs C24): C24's value label sits right
                # where the T needed to connect, and nothing was
                # checking for that.  Add each instance's PLACED ref/
                # value text boxes (absolute coords) to the same check.
                for ti in getattr(inst, 'text_items', None) or []:
                    if not ti.get('placed'):
                        continue
                    rx, ry, anchor, fs, _is_int = ti['placed']
                    bb = _text_bbox_from_anchor(rx, ry, ti['text'],
                                                anchor, fs)
                    abs_bb = _translate_bb(bb, inst.ox_px, inst.oy_px)
                    text_boxes.append((ref, ti['kind'], abs_bb))
            except Exception:
                pass

        def hit(a, b, owner):
            """Does box `a` (a T symbol or label) collide with box `b`?  Against
            its owner only _MIN_CLEARANCE applies: an owned T may sit in the
            owner's reserved box, never on its body or text.
            """
            return self._boxes_clash(
                a, b, None if owner else touch_margin)

        for t in self._t_terminals:
            sym = self._t_hit_bbox(t)
            lab = self._t_label_bbox(t)
            owners = own.get(t['id'], set())
            net = str(t.get('net', ''))
            for ref, body in bodies:
                own_r = ref in owners
                if hit(sym, body, own_r):
                    out.append({'net': net, 't_id': t['id'], 'ref': ref,
                                'kind': 'symbol', 'owner': own_r,
                                'boxes': (sym, body)})
                if hit(lab, body, own_r):
                    out.append({'net': net, 't_id': t['id'], 'ref': ref,
                                'kind': 'text', 'owner': own_r,
                                'boxes': (lab, body)})
            for ref, label_kind, tbox in text_boxes:
                own_r = ref in owners
                if hit(sym, tbox, own_r):
                    out.append({'net': net, 't_id': t['id'], 'ref': ref,
                                'kind': 'symbol_' + label_kind,
                                'owner': own_r,
                                'boxes': (sym, tbox)})
                if hit(lab, tbox, own_r):
                    out.append({'net': net, 't_id': t['id'], 'ref': ref,
                                'kind': 'text_' + label_kind,
                                'owner': own_r,
                                'boxes': (lab, tbox)})
            # Own STUBS are checked too now, not skipped.  The stem no
            # longer runs INTO the stub — it stops _T_PIN_GAP short of
            # the pin and a flight line spans the rest — so a T that
            # actually covers its owner's pin lead is a real collision
            # and no longer has to be excused.
            for ref, sb in stub_boxes:
                own_r = ref in owners
                if self._boxes_clash(sym, sb):
                    out.append({'net': net, 't_id': t['id'], 'ref': ref,
                                'kind': 'symbol_stub', 'owner': own_r,
                                'boxes': (sym, sb)})
                if self._boxes_clash(lab, sb):
                    out.append({'net': net, 't_id': t['id'], 'ref': ref,
                                'kind': 'text_stub', 'owner': own_r,
                                'boxes': (lab, sb)})
        return out

    def _pick_t_terminal(self, x, y, tol=10):
        """Return the T dict under (x, y) within `tol` px of its body,
        or None."""
        for t in self._t_terminals:
            x0, y0, x1, y1 = self._t_hit_bbox(t, tol=tol)
            if x0 <= x <= x1 and y0 <= y <= y1:
                return t
        return None

    def _pick_net_label(self, x, y, tol=2):
        """Find a flight-line net-name label under (x, y).
        Returns (net_lc, idx) or None.
            idx = -1 marks the default-midpoint label that has NOT yet
                  been persisted in self._net_labels; click handlers
                  promote it to a real entry when first interacted.
            idx >= 0 indexes self._net_labels[net_lc].
        Uses Tk's bbox() of the text item plus a small tolerance so
        clicks near (but not exactly on) the text still hit."""
        items = self.canvas.find_withtag('net_label')
        # Tk's find_overlapping is faster but bbox-based hit is simple
        # and reliable for short text items.  Iterate; net-label count
        # is typically <50 even for big SUBCKTs.
        for item in items:
            bbox = self.canvas.bbox(item)
            if bbox is None:
                continue
            bx0, by0, bx1, by1 = bbox
            if bx0 - tol <= x <= bx1 + tol and by0 - tol <= y <= by1 + tol:
                # Pull net and idx from the item's tags.
                tags = self.canvas.gettags(item)
                net_lc = None
                idx = -1
                for t in tags:
                    if t.startswith('net_label:'):
                        net_lc = t.split(':', 1)[1]
                    elif t.startswith('net_label_idx:'):
                        try:
                            idx = int(t.split(':', 1)[1])
                        except ValueError:
                            idx = -1
                if net_lc is not None:
                    return (net_lc, idx)
        return None

    def _pick_flight_line(self, x, y, tol=4):
        """In : canvas (x, y) and a pick tolerance.  Out: the lower-case
        net name of the flight-line segment under the point, else None;
        used by the click-to-add-label gesture.  Scans items tagged
        'flight_line', skips label text and non-line items, and measures
        perpendicular distance.
        A purple sense line carries an explicit 'flight_line_net:<key>'
        tag, checked FIRST: its equation end is a point on a bbox EDGE,
        not a pin, so the pin-geometry fallback below can never match
        both of its ends.  Pin-to-pin lines carry no such tag and fall
        through to that lookup."""
        items = self.canvas.find_withtag('flight_line')
        for item in items:
            tags = self.canvas.gettags(item)
            # Skip text items (they're also tagged flight_line in some
            # paths) and items that already represent a label.
            if any(t.startswith('net_label') for t in tags):
                continue
            if self.canvas.type(item) != 'line':
                continue
            try:
                ax, ay, bx, by = self.canvas.coords(item)
            except ValueError:
                continue
            # Distance from (x, y) to the line segment [a, b].
            dx_e = bx - ax; dy_e = by - ay
            seg_len2 = dx_e * dx_e + dy_e * dy_e
            if seg_len2 < 1e-6:
                continue
            t = ((x - ax) * dx_e + (y - ay) * dy_e) / seg_len2
            t = max(0.0, min(1.0, t))
            cx = ax + t * dx_e
            cy = ay + t * dy_e
            dist2 = (x - cx) ** 2 + (y - cy) ** 2
            if dist2 <= tol * tol:
                # Explicit tag, when present, IS the key
                # into self._net_labels (see _draw_sense_flight_lines'
                # sense_key comment) — use it directly, no geometry
                # guessing needed.
                for tg in tags:
                    if tg.startswith('flight_line_net:'):
                        return (tg.split(':', 1)[1], (cx, cy))
                # Now resolve which net this line belongs to.  Flight-
                # line items don't carry a per-net tag currently, so we
                # have to figure it out from the pin geometry.  Find a
                # net whose pin geometry includes BOTH endpoints (a, b).
                for inst in self._cached_instances:
                    pn_pairs = getattr(inst, '_pin_net_pairs', None) or []
                    for pp, nn in pn_pairs:
                        pxy = _pin_canvas_pos(inst, pp)
                        if pxy is None:
                            continue
                        if (abs(pxy[0] - ax) < 1 and abs(pxy[1] - ay) < 1) or \
                           (abs(pxy[0] - bx) < 1 and abs(pxy[1] - by) < 1):
                            return (nn.lower(), (cx, cy))
                return (None, (cx, cy))
        return None

    def _classify_port_structural(self, port_lc):
        """Takes a port net and returns 'in', 'out' or None from structure
        alone. A supply port is None (it is a rail, not a signal). A port on a
        transistor current terminal (collector/emitter, drain/source) or a
        grounded source output is an output. A port on a control pin (base,
        gate, E/G control input, an equation's V()) is an input. Otherwise
        the walk continues through SERIES elements -- R, L, a V or E whose
        other side is not a rail -- and the first of those it reaches
        decides; a capacitor is a shunt and is not followed."""
        insts = (getattr(self, '_placing_instances', None)
                 or getattr(self, '_placed_instances', None) or [])
        if not insts:
            return None
        if port_lc in (getattr(self, '_supply_port_pol', None) or {}):
            return None
        rails = ({'0'} | set(_GND_NETS_LC) | set(_VCC_NETS_LC)
                 | set(getattr(self, '_supply_port_pol', None) or {}))
        by_net = defaultdict(list)
        sensed = defaultdict(int)
        for i in insts:
            nets = [str(n).lower() for n in (i.comp.get('nets') or [])]
            for n in dict.fromkeys(nets):
                by_net[n].append((i, nets))
            for n in (i.comp.get('sense_nets') or ()):
                sensed[str(n).lower()] += 1
        # A REFERENCE net is held by an E or V source against a rail (a
        # macro model's virtual ground).  A source output returning to one
        # drives the port just as one returning to a rail does.
        for i in insts:
            k = str(i.comp.get('kind') or i.comp['ref'][:1]).upper()
            nets = [str(n).lower() for n in (i.comp.get('nets') or [])]
            if k in ('E', 'V') and len(nets) >= 2 and nets[1] in rails \
                    and nets[0] not in rails:
                rails = rails | {nets[0]}

        def _verdict(net, via):
            """'in'/'out' from the pins directly on `net`, plus the series
            neighbours to walk next."""
            is_in = sensed.get(net, 0) > 0
            is_out = False
            nxt = []
            for i, nets in by_net.get(net, ()):
                if i is via:
                    continue
                k = str(i.comp.get('kind') or i.comp['ref'][:1]).upper()
                if k in ('Q', 'M', 'J') and len(nets) >= 3:
                    if nets[1] == net:
                        is_in = True
                    if net in (nets[0], nets[2]):
                        is_out = True
                    continue
                if k in ('E', 'G', 'F', 'H') and len(nets) >= 2:
                    if net in nets[2:4] and k in ('E', 'G'):
                        is_in = True
                    if net in nets[:2]:
                        other = nets[1] if nets[0] == net else nets[0]
                        if k == 'E' and other not in rails:
                            nxt.append((other, i))
                        elif other in rails:
                            is_out = True
                    continue
                if len(nets) == 2 and k in ('R', 'L', 'V', 'I'):
                    other = nets[1] if nets[0] == net else nets[0]
                    if other in rails:
                        if k in ('V', 'I'):
                            is_out = True
                    elif k in ('R', 'L', 'V'):
                        nxt.append((other, i))
            if is_out and not is_in:
                return 'out', nxt
            if is_in and not is_out:
                return 'in', nxt
            return None, nxt

        seen = {port_lc}
        frontier = [(port_lc, None)]
        for _ in range(12):                  # bounded walk
            found, nxt_all = set(), []
            for net, via in frontier:
                v, nxt = _verdict(net, via)
                if v:
                    found.add(v)
                for o, el in nxt:
                    if o not in seen:
                        seen.add(o)
                        nxt_all.append((o, el))
            if len(found) == 1:
                return found.pop()
            if found:
                return None
            frontier = nxt_all
            if not frontier:
                break
        return None

    def _south_rails(self):
        """In : nothing (reads the rail conventions and the user's marks).
        Proc: union the ground and negative-supply name conventions with
              the nets the user marked '-' in the Nets dialog and with
              the detected negative rail.
        Out : a frozenset of lower-case net names belonging BELOW the
              circuit.
        One place answers "is this a south rail", so the chain placer,
        the T-symbol glyphs and the orientation rules cannot drift apart
        on a deck that spells ground something other than '0'."""
        _pos, neg = self._rail_polarity()
        out = {'0'} | set(_GND_NETS_LC) | {
            str(n).lower() for n in (self._neg_power_nets or ())}
        out |= {str(n).lower()
                for n, p in (self._rail_polarity_overrides or {}).items()
                if p == '-'}
        if neg:
            out.add(str(neg).lower())
        # A promoted rail is drawn as a ground (_rail_t_rot), unless the
        # user turned its T to another role.
        ov = self._t_net_rot_overrides or {}
        out |= {str(n).lower()
                for n in (getattr(self, '_promoted_rails', None) or ())
                if ov.get(str(n).lower(), 0) == 0}
        return frozenset(out)

    def _canvas_size(self):
        """In : nothing (reads the canvas widget).
        Proc: return the canvas's pixel size, substituting the layout
              default for any dimension Tk has not measured yet.
        Out : (width, height) in pixels, never smaller than the default.
        AN UNMAPPED TK WIDGET REPORTS 1, NOT 0, so the three callers that
        wrote `winfo_width() or 1600` never fired their fallback on a
        render that beat the first geometry pass.  right_x then went to
        1 - 20 = -19 and every SUBCKT OUTPUT stub ran off the far LEFT of
        the sheet; input stubs hid it, left_x being just the margin."""
        cw = self.canvas.winfo_width()
        ch = self.canvas.winfo_height()
        return (cw if cw > 1 else 1600), (ch if ch > 1 else 900)

    def _subckt_io_nets(self, instances=None):
        """In : the parsed deck and the active .SUBCKT.  Out: (in_nets,
        out_nets), frozensets of lower-case port names.
        Only the CURRENTLY VIEWED subckt's ports count: a union over all
        subckts tagged OPAx197's MID — a port of the internal
        SW_OL_OPAx197 — as a top-level input merely because 'in' occurs
        in the name, when at the top level MID is an internal signal net.
        Port names are the net names when expand_subckt ran at startup.
        The auto-placer's own heuristic decides: a name containing 'in'
        and not 'out' is an input, one containing 'out' is an output."""
        in_nets = set()
        out_nets = set()
        if self._parser and self._parser.subckts:
            active = (self._active_subckt or '').upper()
            if active and active in self._parser.subckts:
                ports = self._parser.subckts[active].get('ports', [])
            else:
                # Top-level fallback: union across all subckts.
                ports = [p for sc in self._parser.subckts.values()
                          for p in sc.get('ports', [])]
            for p in ports:
                pl = p.lower()
                if 'in' in pl and 'out' not in pl:
                    in_nets.add(pl)
                elif 'out' in pl:
                    out_nets.add(pl)
            # STRUCTURAL fallback for ports the name
            # heuristic leaves unclassified (numeric ports like
            # LM324's 1 2 3 4 5 — output nets are rarely literally
            # named 'out').  Rails are excluded via _supply_rails; a
            # port touching ONLY control pins (Q/M/J base/gate, E/G
            # control pair) is an INPUT; a port reaching a source's
            # output pin through a series R/L/V path is an OUTPUT.
            rails = {str(r).lower() for r in
                     (getattr(self, '_supply_rails', None) or set())}
            for p in ports:
                pl = p.lower()
                if pl in in_nets or pl in out_nets or pl in rails:
                    continue
                if pl == '0' or pl in _GND_NETS_LC or pl in _VCC_NETS_LC:
                    continue
                cls = self._classify_port_structural(pl)
                if cls == 'in':
                    in_nets.add(pl)
                elif cls == 'out':
                    out_nets.add(pl)
        # Apply user reclassification overrides.  When the
        # user right-click-rotates an IO T-symbol, the net's rotation
        # is stored in self._t_net_rot_overrides.  rot=90 means "output"
        # (right side); rot=270 means "input" (left side).  Other
        # rotations (0/180 = gnd/vcc orientation) are not IO, so the
        # net is removed from BOTH in and out sets.
        for nl, rot in (self._t_net_rot_overrides or {}).items():
            in_nets.discard(nl)
            out_nets.discard(nl)
            if rot == 90:
                out_nets.add(nl)
            elif rot == 270:
                in_nets.add(nl)
            # rot 0 or 180 is not IO, so the net stays an ordinary signal.  A
            # pin marked 'out' names its net as the output.
        for (_r, _p), _role in (
                getattr(self, '_pin_role_overrides', None) or {}).items():
            if _role != 'out':
                continue
            for _i in (instances
                       or getattr(self, '_placed_instances', None)
                       or getattr(self, '_cached_instances', None)
                       or ()):
                if _i.comp['ref'] != _r:
                    continue
                # On a passive an 'out' mark is only the direction the
                # signal passes through it: LP2951's C1 and R1 are marked
                # 'out' toward LP2951_VXX, an internal net, which drew two
                # stubs to the right margin.
                if str(_i.comp.get('kind', '')).upper() in ('R', 'C', 'L'):
                    break
                for _pn, _nn in (getattr(_i, '_pin_net_pairs', None) or []):
                    if str(_pn) == str(_p):
                        _nl = str(_nn).lower()
                        if _nl not in _PWR_NETS_LC_FOR_T:
                            out_nets.add(_nl)
                        break
        return frozenset(in_nets), frozenset(out_nets)

    def _draw_flight_lines(self, net_members, _positions=None):
        """In : net_members.  Out: olive centroid-to-centroid guide lines
        between every pair of components sharing a signal net, drawn from
        each body centre rather than the composite left edge.
        Pair-to-pair rather than a star keeps an individual connection
        traceable; a net with more than 8 members draws a star instead,
        to avoid O(n^2) clutter.  A .SUBCKT input net also draws a
        horizontal stub from each instance to the left margin, an output
        net one to the right.  Power and ground never reach here —
        _build_netlist_graph filters them out of net_members."""
        C_FLIGHT    = '#888800'   # olive — visible but not distracting
        C_IO_FLIGHT = '#aa6600'   # orange for SUBCKT input / output

        cw, ch = self._canvas_size()
        margin = max(20, SCALE)
        left_x  = margin
        right_x = cw - margin
        _ = ch

        in_nets, out_nets = self._subckt_io_nets()

        def body_centre(inst):
            """Canvas centre of the component body (not composite left)."""
            sb = inst.sym_body_rel
            bx = inst.ox_px + (sb[0] + sb[2]) / 2
            by = inst.oy_px + (sb[1] + sb[3]) / 2
            return bx, by

        for nl, members in net_members.items():
            # SUBCKT IO stubs first.
            if nl in in_nets:
                for inst in members:
                    bx, by = body_centre(inst)
                    self.canvas.create_line(
                        bx, by, left_x, by,
                        fill=C_IO_FLIGHT, width=1, dash=(3, 3),
                        tags='flight_line')
            elif nl in out_nets:
                for inst in members:
                    bx, by = body_centre(inst)
                    self.canvas.create_line(
                        bx, by, right_x, by,
                        fill=C_IO_FLIGHT, width=1, dash=(3, 3),
                        tags='flight_line')

            n = len(members)
            if n < 2:
                continue
            if n <= 8:
                # Direct lines between every connected pair
                for i, ma in enumerate(members):
                    ax, ay = body_centre(ma)
                    for mb in members[i+1:]:
                        bx, by = body_centre(mb)
                        self.canvas.create_line(ax, ay, bx, by,
                                                fill=C_FLIGHT, width=1,
                                                dash=(3, 3),
                                                tags='flight_line')
            else:
                # Star topology for high-fanout nets — hub at centroid
                centres = [body_centre(m) for m in members]
                hx = sum(c[0] for c in centres) / n
                hy = sum(c[1] for c in centres) / n
                for cx, cy in centres:
                    self.canvas.create_line(hx, hy, cx, cy,
                                            fill=C_FLIGHT, width=1,
                                            dash=(3, 3),
                                            tags='flight_line')


    @staticmethod
    def _find_primary_closed_shape(shapes):
        """Delegated to module-level function (needed by CompInstance.build)."""
        return _find_primary_closed_shape(shapes)


    def _draw_unknown(self, comp, ox_mm, oy_mm):
        """Draw a generic box for unrecognised symbols."""
        x0 = (ox_mm + 1) * SCALE; y0 = (oy_mm + 2) * SCALE
        x1 = (ox_mm + CELL_W_MM - 1) * SCALE
        y1 = (oy_mm + CELL_H_MM - 2) * SCALE
        self.canvas.create_rectangle(x0, y0, x1, y1,
                                     outline=C_OUTLINE, fill=C_FILL, width=1)
        self.canvas.create_text((x0+x1)/2, (y0+y1)/2,
                                text=comp['sym'],
                                font=(FONT_FAMILY, 8), fill=C_OUTLINE)

    # ── Tooltip ───────────────────────────────────────────────────────────

    def _show_tip(self, event, comp, inst=None):
        self._hide_tip(None)
        nets_str = '\n  '.join(comp['nets']) if comp['nets'] else '(none)'
        # Find full value from text_items (may differ from comp['value'] if
        # truncated)
        full_val = comp['value']
        for ti in getattr(inst, 'text_items', []):
            if ti['kind'] == 'value':
                full_val = ti.get('text_full', comp['value'])
                break
        lines = [
            f"Ref:    {shorten_ref(comp['ref'])}  ({comp['ref']})",
            f"Kind:   {comp['kind']}",
            f"Symbol: {comp['sym']}",
            f"Value:  {full_val}",
            f"Nets:\n  {nets_str}",
        ]
        self._tip = tw = tk.Toplevel(self)
        tw.wm_overrideredirect(True)
        tw.wm_geometry(f'+{event.x_root+12}+{event.y_root+8}')
        tk.Label(tw, text='\n'.join(lines), justify=tk.LEFT,
                 bg='#fffde0', fg='#111', relief=tk.SOLID, borderwidth=1,
                 font=(FONT_FAMILY, 9), padx=6, pady=4).pack()

    def _hide_tip(self, _event):
        if hasattr(self, '_tip') and self._tip:
            try: self._tip.destroy()
            except Exception: pass
            self._tip = None

    # ══════════════════════════════════════════════════════════════════
    #  §10  Rev 33: Interactive Place & Route
    # ══════════════════════════════════════════════════════════════════

    # Pixel tolerances ------------------------------------------------------
    _PIN_HIT_TOL = 12          # px from pin endpoint to count as a pin click
    _SEG_HIT_TOL = 6           # px from segment to count as a segment click

    # ── Coordinate helpers ────────────────────────────────────────────────

    def _event_canvas_xy(self, event):
        """Convert a Tk event's widget-relative (x, y) to canvas coords
        (accounting for scroll position)."""
        return (self.canvas.canvasx(event.x), self.canvas.canvasy(event.y))

    # ── Hit-testing ────────────────────────────────────────────────────────

    def _pick_instance(self, x, y):
        """Return the CompInstance whose symbol-body bbox contains (x, y),
        or None.  Searches the cached instances built by the most recent
        _render() — so users drag the same instances they see."""
        for inst in self._cached_instances:
            bb = inst.abs_sym_body()
            if bb[0] <= x <= bb[2] and bb[1] <= y <= bb[3]:
                return inst
        return None

    def _pick_pin(self, x, y, tol=None):
        """Return (inst, pin_num, px, py) of the nearest pin endpoint within
        `tol` pixels of (x, y), or None.  Iterates every pin on every cached
        instance — O(N · pins) but plenty fast for typical schematics."""
        if tol is None:
            tol = self._PIN_HIT_TOL
        best = None
        best_d2 = tol * tol
        for inst in self._cached_instances:
            pins = inst.sym_entry.get('pins', {})
            for pn in pins:
                px, py = _pin_canvas_pos(inst, pn)
                d2 = (px - x) ** 2 + (py - y) ** 2
                if d2 < best_d2:
                    best_d2 = d2
                    best = (inst, pn, px, py)
        return best

    @staticmethod
    def _point_to_segment_dist2(px, py, ax, ay, bx, by):
        """Squared distance from (px,py) to the segment (ax,ay)-(bx,by)."""
        vx, vy = bx - ax, by - ay
        wx, wy = px - ax, py - ay
        seg_len2 = vx * vx + vy * vy
        if seg_len2 < 1e-9:
            return wx * wx + wy * wy
        t = (wx * vx + wy * vy) / seg_len2
        t = max(0.0, min(1.0, t))
        cx, cy = ax + t * vx, ay + t * vy
        return (px - cx) ** 2 + (py - cy) ** 2

    def _pick_segment(self, x, y, tol=None):
        """Return (wire_idx, seg_idx) of the nearest wire segment within
        `tol` pixels of (x, y), or None."""
        if tol is None:
            tol = self._SEG_HIT_TOL
        best = None
        best_d2 = tol * tol
        for wi, wire in enumerate(self._wires):
            pts = wire['points']
            for si in range(len(pts) - 1):
                ax, ay = pts[si]
                bx, by = pts[si + 1]
                d2 = self._point_to_segment_dist2(x, y, ax, ay, bx, by)
                if d2 < best_d2:
                    best_d2 = d2
                    best = (wi, si)
        return best

    # ── Click / drag dispatcher ────────────────────────────────────────────

    def _on_canvas_click(self, event):
        """In : a left-button press.  Out: "break" when consumed, to stop
        Tk's default handling.  Dispatches in priority order:
          1) wire in progress -> add a waypoint, or finish on a pin;
          2) pin endpoint -> start a new wire;
          3) instance -> a GROUP DRAG when it is already in
             self._selected_group, every member following by the same
             delta; otherwise clear the group and drag it alone;
          4) wire segment -> toggle its selection;
          5) empty space -> start a rubber-band selection rectangle."""
        x, y = self._event_canvas_xy(event)

        # 1) Wire in progress.
        if self._wire_in_progress is not None:
            pin_hit = self._pick_pin(x, y)
            if pin_hit is not None:
                inst_end, pin_end, px, py = pin_hit
                self._finish_wire((px, py),
                                  end_pin=(inst_end.comp['ref'], pin_end))
            else:
                snap = self._snap_manhattan(
                    self._wire_in_progress['points'][-1], (x, y))
                self._wire_in_progress['points'].append(snap)
                self._update_rubber_band(snap)
            return 'break'

        # 2) Pin → start wire.
        pin_hit = self._pick_pin(x, y)
        if pin_hit is not None:
            inst, pin, px, py = pin_hit
            self._start_wire(inst, pin, (px, py))
            return 'break'

        # an instance body under the cursor takes priority
        # over the single-T-drag / net-label / add-label gestures below, so
        # a T, flight line (incl. the new purple sense lines), or net label
        # lying OVER a symbol body can't shadow dragging the symbol itself.
        # The symbol's owned T's follow it on the release re-render.  Those
        # gestures still fire normally when the click is NOT over a body.
        inst_here = self._pick_instance(x, y)

        # 2.5) Rev 45 — T-terminal → start drag.  Rev 48a: if the
        # clicked T is part of the current rubber-band selection, fall
        # through to the group-drag path instead of single-T drag.
        t = self._pick_t_terminal(x, y)
        # TIGHT pick (tol=0): is the cursor on the T's actual stem/bar,
        # or merely inside its 10px grab halo?  The body-priority rule
        # below needs to tell those apart -- see _t_drag gate.
        t_tight = self._pick_t_terminal(x, y, tol=0)
        if t is not None:
            if t['id'] in self._selected_t_ids and (
                    self._selected_group or len(self._selected_t_ids) > 1):
                # Build the group-drag state from the selected Ts +
                # instances, using THIS T as the anchor.
                origins = {}
                ghost_bb = None
                for gref in self._selected_group:
                    ginst = self._cached_inst_by_ref.get(gref)
                    if ginst is None:
                        continue
                    origins[gref] = (ginst.ox_px, ginst.oy_px)
                    gb = ginst.abs_sym_body()
                    if ghost_bb is None:
                        ghost_bb = list(gb)
                    else:
                        ghost_bb[0] = min(ghost_bb[0], gb[0])
                        ghost_bb[1] = min(ghost_bb[1], gb[1])
                        ghost_bb[2] = max(ghost_bb[2], gb[2])
                        ghost_bb[3] = max(ghost_bb[3], gb[3])
                t_origins = {}
                for tt in self._t_terminals:
                    if tt['id'] in self._selected_t_ids:
                        t_origins[tt['id']] = (tt['cx'], tt['cy'])
                        tb = self._t_hit_bbox(tt)
                        if ghost_bb is None:
                            ghost_bb = list(tb)
                        else:
                            ghost_bb[0] = min(ghost_bb[0], tb[0])
                            ghost_bb[1] = min(ghost_bb[1], tb[1])
                            ghost_bb[2] = max(ghost_bb[2], tb[2])
                            ghost_bb[3] = max(ghost_bb[3], tb[3])
                self._drag_state = {
                    'is_group': True,
                    'refs': list(self._selected_group),
                    'origins': origins,
                    't_ids': list(self._selected_t_ids),
                    't_origins': t_origins,
                    'mouse_origin': (x, y),
                    'ghost_bb_origin': tuple(ghost_bb) if ghost_bb else None,
                }
                self.status.config(
                    text=f'Dragging group of {len(origins)}'
                          f'{f" + {len(t_origins)} T" if t_origins else ""}'
                          f'  •  release to drop')
                return 'break'
            # Single-T drag: allowed when the click misses every body or lands
            # on the T's own glyph, so a T sitting on a body can still be moved.
            if inst_here is None or t_tight is t:
                self._t_drag = {
                    't': t,
                    'mouse_origin': (x, y),
                    't_origin': (t['cx'], t['cy']),
                }
                self.status.config(
                    text=f'Dragging T ({t["net"]})  •  release to drop')
                return 'break'
            # else: fall through to the instance-drag path below.

        # 2.7) Rev 48c — net-name label → start label drag.  Release
        # without moving past the click-vs-drag threshold (4 px) toggles
        # the label hidden.  Release after movement commits the move.
        nl_hit = self._pick_net_label(x, y)
        if nl_hit is not None and inst_here is None:
            net_lc, idx = nl_hit
            # If this is the default-midpoint label (idx=-1), materialise
            # it in self._net_labels[net_lc] now so the drag/hide
            # operation has something to mutate.  Default position
            # captured from the current label item's coords.
            if idx == -1:
                items = self.canvas.find_withtag(f'net_label:{net_lc}')
                cx_def = cy_def = 0.0
                for it in items:
                    bb = self.canvas.bbox(it)
                    if bb:
                        cx_def = (bb[0] + bb[2]) / 2
                        cy_def = (bb[1] + bb[3]) / 2
                        break
                self._net_labels.setdefault(net_lc, []).append(
                    {'pos': (cx_def, cy_def), 'visible': True})
                idx = len(self._net_labels[net_lc]) - 1
            self._label_drag = {
                'net': net_lc, 'idx': idx,
                'mouse_origin': (x, y),
                'pos_origin': self._net_labels[net_lc][idx]['pos'],
                'moved': False,
            }
            self.status.config(
                text=f'Net label ({net_lc}): drag to move, '
                      f'release without moving to hide')
            return 'break'

        # 2.8) Rev 48c — bare flight-line segment → add a new label
        # at the click point.  Only fires when no label was hit (the
        # label check above takes priority).  The new entry is
        # appended to self._net_labels[net_lc].
        fl_hit = self._pick_flight_line(x, y)
        if fl_hit is not None and inst_here is None:
            net_lc, snap_xy = fl_hit
            if net_lc is not None:
                self._net_labels.setdefault(net_lc, []).append(
                    {'pos': snap_xy, 'visible': True})
                self._render()
                self.status.config(
                    text=f'Added label for {net_lc} at '
                          f'({snap_xy[0]:.0f}, {snap_xy[1]:.0f})')
                return 'break'

        # 3) Instance.
        inst = inst_here
        if inst is not None:
            ref = inst.comp['ref']
            if ref in self._selected_group:
                # Group drag — record origin of every member.
                origins = {}
                ghost_bb = None
                for gref in self._selected_group:
                    ginst = self._cached_inst_by_ref.get(gref)
                    if ginst is None:
                        continue
                    origins[gref] = (ginst.ox_px, ginst.oy_px)
                    gb = ginst.abs_sym_body()
                    if ghost_bb is None:
                        ghost_bb = list(gb)
                    else:
                        ghost_bb[0] = min(ghost_bb[0], gb[0])
                        ghost_bb[1] = min(ghost_bb[1], gb[1])
                        ghost_bb[2] = max(ghost_bb[2], gb[2])
                        ghost_bb[3] = max(ghost_bb[3], gb[3])
                # Also record origin of every selected T and
                # extend the ghost bbox to include them.  Live drag
                # only animates the ghost rectangle; the Ts (and the
                # instances) jump to their new positions on release.
                t_origins = {}
                for t in self._t_terminals:
                    if t['id'] in self._selected_t_ids:
                        t_origins[t['id']] = (t['cx'], t['cy'])
                        tb = self._t_hit_bbox(t)
                        if ghost_bb is None:
                            ghost_bb = list(tb)
                        else:
                            ghost_bb[0] = min(ghost_bb[0], tb[0])
                            ghost_bb[1] = min(ghost_bb[1], tb[1])
                            ghost_bb[2] = max(ghost_bb[2], tb[2])
                            ghost_bb[3] = max(ghost_bb[3], tb[3])
                self._drag_state = {
                    'is_group': True,
                    'refs': list(self._selected_group),
                    'origins': origins,
                    't_ids': list(self._selected_t_ids),
                    't_origins': t_origins,
                    'mouse_origin': (x, y),
                    'ghost_bb_origin': tuple(ghost_bb) if ghost_bb else None,
                }
                self.status.config(
                    text=f'Dragging group of {len(origins)}'
                          f'{f" + {len(t_origins)} T" if t_origins else ""}'
                          f'  •  release to drop')
                return 'break'
            else:
                # Single-instance drag — clear any group first.
                if self._selected_group or self._selected_t_ids:
                    self._selected_group.clear()
                    self._selected_t_ids.clear()
                    # No need to re-render now; the drag will re-render on
                    # release anyway.
                self._drag_state = {
                    'is_group': False,
                    'ref': ref,
                    'inst': inst,
                    'mouse_origin': (x, y),
                    'inst_origin':  (inst.ox_px, inst.oy_px),
                }
                self.status.config(
                    text=f'Dragging {ref}  •  release to drop')
                return 'break'

        # 4) Wire segment → toggle.
        seg_hit = self._pick_segment(x, y)
        if seg_hit is not None:
            if seg_hit in self._selected_segments:
                self._selected_segments.discard(seg_hit)
            else:
                self._selected_segments.add(seg_hit)
            self._render()
            return 'break'

        # 5) Empty space → start a rubber-band selection rectangle.
        # Wipe any prior segment selection visually, and (an earlier revision,
        # user) any active T-net highlight from a previous rotate.
        if self._selected_segments or self._highlighted_t_net is not None:
            self._selected_segments.clear()
            self._highlighted_t_net = None
            self._highlighted_t_exclude_id = None
            self._render()
        rect_id = self.canvas.create_rectangle(
            x, y, x, y,
            outline='#3366aa', width=1, dash=(2, 2), fill='')
        self._rubber_band = {'start': (x, y), 'rect_id': rect_id}
        return 'break'

    def _on_canvas_drag(self, event):
        """B1-Motion: handles three concurrent modes:
          • Rubber-band selection in progress → resize the rectangle.
          • Single-instance drag → move ghost.
          • Group drag → move group ghost (union bbox).
        """
        x, y = self._event_canvas_xy(event)

        # Rubber-band selection rectangle.
        if self._rubber_band is not None:
            sx, sy = self._rubber_band['start']
            self.canvas.coords(self._rubber_band['rect_id'],
                                sx, sy, x, y)
            return

        # T-symbol drag (in progress).
        if self._t_drag is not None:
            dx = x - self._t_drag['mouse_origin'][0]
            dy = y - self._t_drag['mouse_origin'][1]
            tox, toy = self._t_drag['t_origin']
            self._t_drag['t']['cx'] = tox + dx
            self._t_drag['t']['cy'] = toy + dy
            # Cheap visual update: redraw only the T's and flight lines, not a
            # full _render, so a drag stays responsive.
            self.canvas.delete('t_term')
            self.canvas.delete('flight_line')
            self._draw_all_t_terminals()
            if self._cached_instances:
                self._draw_t_flight_lines(self._cached_instances)
            return

        # Net-label drag (in progress).  Track movement; once
        # past the click-vs-drag threshold, flip `moved`=True so release
        # commits the new position instead of toggling visibility.
        if self._label_drag is not None:
            dx = x - self._label_drag['mouse_origin'][0]
            dy = y - self._label_drag['mouse_origin'][1]
            if abs(dx) >= 4 or abs(dy) >= 4:
                self._label_drag['moved'] = True
            if self._label_drag['moved']:
                pox, poy = self._label_drag['pos_origin']
                net_lc = self._label_drag['net']
                idx = self._label_drag['idx']
                new_pos = (pox + dx, poy + dy)
                self._net_labels[net_lc][idx]['pos'] = new_pos
                # Move this label item in place: a full label redraw recreates
                # only ordinary net labels and would lose a sense-line label.
                items = self.canvas.find_withtag(f'net_label:{net_lc}')
                moved_any = False
                for it in items:
                    if f'net_label_idx:{idx}' in self.canvas.gettags(it):
                        self.canvas.coords(it, new_pos[0], new_pos[1])
                        moved_any = True
                if not moved_any:
                    # Fallback for any future label kind this cheap,
                    # tag-based move doesn't cover — a full re-render is
                    # slower but always correct.
                    self._render()
            return

        if self._drag_state is None:
            return

        dx = x - self._drag_state['mouse_origin'][0]
        dy = y - self._drag_state['mouse_origin'][1]

        if self._drag_state.get('is_group'):
            # Group ghost = union bbox shifted by (dx, dy).
            bb = self._drag_state.get('ghost_bb_origin')
            if bb is None:
                return
            gx0, gy0 = bb[0] + dx, bb[1] + dy
            gx1, gy1 = bb[2] + dx, bb[3] + dy
            gid = self._drag_state.get('ghost_id')
            if gid is None:
                gid = self.canvas.create_rectangle(
                    gx0, gy0, gx1, gy1,
                    outline='#cc6600', width=2, dash=(4, 2), fill='')
                self._drag_state['ghost_id'] = gid
            else:
                self.canvas.coords(gid, gx0, gy0, gx1, gy1)
            self._drag_state['last_delta'] = (dx, dy)
            return

        # Single-instance drag.
        inst = self._drag_state['inst']
        bb = inst.abs_sym_body()
        gx0, gy0 = bb[0] + dx, bb[1] + dy
        gx1, gy1 = bb[2] + dx, bb[3] + dy
        gid = self._drag_state.get('ghost_id')
        if gid is None:
            gid = self.canvas.create_rectangle(
                gx0, gy0, gx1, gy1,
                outline='#cc6600', width=2, dash=(4, 2), fill='')
            self._drag_state['ghost_id'] = gid
        else:
            self.canvas.coords(gid, gx0, gy0, gx1, gy1)
        self._drag_state['last_delta'] = (dx, dy)

    def _on_canvas_release(self, event):
        """B1-Release: handles three concurrent modes:
          • Rubber-band selection → enclosed instances become the new
            group; rectangle is removed.
          • Group drag → every group member shifts by the same delta;
            wires whose endpoints anchor on any of them follow.
          • Single-instance drag → existing behavior.
        Old flight lines are wiped explicitly here as well as inside
        _render — defensive layering."""
        x, y = self._event_canvas_xy(event)

        # T-symbol drag commit.
        if self._t_drag is not None:
            dx = x - self._t_drag['mouse_origin'][0]
            dy = y - self._t_drag['mouse_origin'][1]
            tox, toy = self._t_drag['t_origin']
            self._t_drag['t']['cx'] = tox + dx
            self._t_drag['t']['cy'] = toy + dy
            # mark this T as USER-MOVED so the single-owner
            # re-pin (which otherwise snaps every single-owner T back to its
            # owner's pin every render) leaves it where the user dropped it.
            # This was the "spring-back" bug (LM324 net-1 T on Q2, GCM net-0).
            self._t_drag['t']['user_moved'] = True
            net = self._t_drag['t']['net']
            # DROP A T ON ANOTHER T TO MERGE THEM.  The
            # explicit counterpart to double-click-to-split: the user
            # says merge these by putting one on the other, so no
            # distance threshold has to guess.  Same net and same
            # rotation only -- a rail T's rotation IS its polarity, and
            # merging a +power T into a ground one would be a worse
            # error than leaving them apart.
            _merged = 0
            _drop = self._t_drag['t']
            for _o in list(self._t_terminals):
                if _o is _drop or _o.get('id') == _drop.get('id'):
                    continue
                if str(_o.get('net')).lower() != str(net).lower():
                    continue
                if _o.get('rot') != _drop.get('rot'):
                    continue
                if math.hypot(_o['cx'] - _drop['cx'],
                              _o['cy'] - _drop['cy']) > self._T_PIN_DIST:
                    continue
                _own_before = {r for (r, _p), _tid
                               in (self._pin_to_t or {}).items()
                               if _tid == _drop.get('id')}
                _own_other = {r for (r, _p), _tid
                              in (self._pin_to_t or {}).items()
                              if _tid == _o.get('id')}
                for _k, _tid in list((self._pin_to_t or {}).items()):
                    if _tid == _o.get('id'):
                        self._pin_to_t[_k] = _drop['id']
                self._t_terminals.remove(_o)
                # A T THAT CROSSES A BBOX BOUNDARY OWNS ITS OWN BOX
                #.  Merged INSIDE one instance is fine and
                # encouraged -- that T still belongs to its owner and the
                # owner's box covers it.  Merged ACROSS two instances is
                # what made both boxes stretch to cover the same T and
                # therefore overlap; such a T becomes an object in its
                # own right and _instance_bbox_with_ts stops extending
                # for it.
                if _own_before and _own_other and \
                        (_own_before | _own_other) != _own_before:
                    _drop['own_box'] = True
                _merged += 1
            self._t_drag = None
            self._render()
            self.status.config(
                text=(f'T ({net}) merged with {_merged} other'
                      f'{"s" if _merged != 1 else ""}')
                if _merged else f'T ({net}) moved')
            return

        # Net-label drag commit.  If `moved`=True, the new
        # position has already been written by the motion handler and
        # this is a no-op aside from clearing the state.  If False
        # (a click without movement past the 4 px threshold), REMOVE
        # the label entirely — the user said "I'm done with this one".
        # To bring labels back, click on the flight line (adds a new
        # one) or double-click on the flight line / any remaining
        # label (resets to default midpoint).
        if self._label_drag is not None:
            net_lc = self._label_drag['net']
            idx = self._label_drag['idx']
            moved = self._label_drag['moved']
            self._label_drag = None
            if not moved:
                if (net_lc in self._net_labels
                        and 0 <= idx < len(self._net_labels[net_lc])):
                    del self._net_labels[net_lc][idx]
                    # If the list is now empty, leave it that way (the
                    # net has no labels — different from "missing key"
                    # which would draw the default).  This lets a user
                    # explicitly hide ALL labels for a net by clicking
                    # each one in turn.
                    self._render()
                    self.status.config(
                        text=f'Net label ({net_lc}) removed')
            else:
                self._render()
                self.status.config(text=f'Net label ({net_lc}) moved')
            return

        # ── Rubber-band selection finalisation ────────────────────
        if self._rubber_band is not None:
            sx, sy = self._rubber_band['start']
            rid = self._rubber_band['rect_id']
            self.canvas.delete(rid)
            self._rubber_band = None
            # Box coords (normalised).
            x0, x1 = min(sx, x), max(sx, x)
            y0, y1 = min(sy, y), max(sy, y)
            # Empty/tiny boxes (a click without a drag) just clear the
            # group instead of selecting nothing.
            if x1 - x0 < 4 or y1 - y0 < 4:
                if self._selected_group or self._selected_t_ids:
                    self._selected_group.clear()
                    self._selected_t_ids.clear()
                    self._render()
                return
            new_group = set()
            for inst in self._cached_instances:
                bb = inst.abs_sym_body()
                bx = (bb[0] + bb[2]) / 2
                by = (bb[1] + bb[3]) / 2
                if x0 <= bx <= x1 and y0 <= by <= y1:
                    new_group.add(inst.comp['ref'])
            # Also collect T-symbols the rectangle TOUCHES.  Testing the
            # anchor (cx, cy) alone mirrored the instance test, but the
            # two are not alike: an instance's centroid sits in the
            # middle of what you see, while a T's anchor is at one end
            # and the glyph -- bar, stem and net label -- extends away
            # from it.  So a T drawn plainly inside the band could have
            # its anchor outside and be missed, which is why a selection
            # that visibly enclosed the ground T's still left them
            # behind.  _t_hit_bbox is what the click hit-test uses, so
            # band and click now agree on where a T is.
            new_t_ids = set()
            for t in self._t_terminals:
                hit = False
                try:
                    tb = self._t_hit_bbox(t)
                    hit = (tb[0] <= x1 and tb[2] >= x0
                           and tb[1] <= y1 and tb[3] >= y0)
                except Exception:
                    hit = False
                if hit or (x0 <= t['cx'] <= x1 and y0 <= t['cy'] <= y1):
                    new_t_ids.add(t['id'])
            self._selected_group = new_group
            self._selected_t_ids = new_t_ids
            if new_group or new_t_ids:
                bits = []
                if new_group:
                    bits.append(f'{len(new_group)} instance'
                                f'{"s" if len(new_group) != 1 else ""}')
                if new_t_ids:
                    bits.append(f'{len(new_t_ids)} T-symbol'
                                f'{"s" if len(new_t_ids) != 1 else ""}')
                self.status.config(
                    text=f'Grouped {" + ".join(bits)}  •  '
                          f'click an instance to drag the group, '
                          f'right-click empty space to ungroup')
            else:
                self.status.config(text='Empty selection')
            self._render()
            return

        if self._drag_state is None:
            return

        # ── Group drag commit ─────────────────────────────────────
        if self._drag_state.get('is_group'):
            dx, dy = self._drag_state.get('last_delta', (0, 0))
            refs = self._drag_state['refs']
            origins = self._drag_state['origins']
            _already = set(self._drag_state.get('t_origins', {}).keys())
            for ref in refs:
                ox0, oy0 = origins[ref]
                self._user_positions[ref] = (ox0 + dx, oy0 + dy)
                # FIX (same as single-instance): render seeds
                # from _placed_draw_state, so update the stored origin or the
                # body snaps back.
                ds = getattr(self, '_placed_draw_state', None)
                if ds and ref in ds:
                    ds[ref]['ox'] = ox0 + dx
                    ds[ref]['oy'] = oy0 + dy
                ginst = self._cached_inst_by_ref.get(ref)
                if ginst is not None:
                    ginst.ox_px, ginst.oy_px = ox0 + dx, oy0 + dy
                self._reflow_wires_on_move(ref, dx, dy)
                # Every per-pin power/ground T attached to
                # this instance follows the same delta.  Without this,
                # ground/power stubs get stretched into long flight
                # lines whenever a circuit is rearranged — which was
                # consistently the most-noticed friction in user
                # testing of rev 50.
                # ACCUMULATE ACROSS THE LOOP.  `moved` is local to each
                # call, so a T shared by two dragged parts was shifted
                # once per owner -- twice the delta, which looks exactly
                # like "did not move" when you measure against dx.
                _already |= self._auto_move_pin_pwr_ts(
                    ref, dx, dy,
                    already_moved_t_ids=_already,
                    moved_refs=set(origins)) or set()
            # Also move the selected T-symbols by the same
            # delta.  T positions live on the t dict (cx, cy) directly,
            # so we mutate the existing _t_terminals entries.
            t_origins = self._drag_state.get('t_origins', {})
            if t_origins:
                tdict = {t['id']: t for t in self._t_terminals}
                for tid, (tox, toy) in t_origins.items():
                    t = tdict.get(tid)
                    if t is not None:
                        t['cx'] = tox + dx
                        t['cy'] = toy + dy
            gid = self._drag_state.get('ghost_id')
            if gid is not None:
                self.canvas.delete(gid)
            self._drag_state = None
            self.canvas.delete('flight_line')
            # RECONSIDER MERGED-VS-SPLIT AT THE NEW POSITIONS.  This is
            # the drag case the pass exists for: pull two parts apart and
            # their shared T divides; push them together and two T's on
            # one net become one.  Split is allowed here (unlike the
            # placement path) because the user is moving things by hand
            # and a re-Place re-reserves afterwards.
            try:
                # THE LIVE INSTANCES, NOT THE PLACED ONES.  A drag moves
                # _cached_inst_by_ref; _placed_instances is a DIFFERENT set of
                # objects (measured: 0 of 51 shared) still holding the pre-drag
                # coordinates.  Defaulting to those made the split compute each
                # piece's position from stale geometry -- LM324.sub's shared
                # ground T's split into pieces left at the parts' OLD x, which
                # is exactly the "some T's didn't move" report.
                _s = self._t_split_pass(self._cached_instances,
                                        moved_refs=set(refs))
            except Exception:
                _s = 0
            self._render()
            n_inst = len(refs); n_t = len(t_origins)
            bits = []
            if n_inst:
                bits.append(f'{n_inst} instance{"s" if n_inst != 1 else ""}')
            if n_t:
                bits.append(f'{n_t} T{"s" if n_t != 1 else ""}')
            if _s:
                bits.append(f'{_s} T split{"s" if _s != 1 else ""}')
            self.status.config(text=f'Group dropped: {" + ".join(bits)}')
            _ = event
            return

        # ── Single-instance drag commit (rev 33 behavior) ────────
        ref = self._drag_state['ref']
        dx, dy = self._drag_state.get('last_delta', (0, 0))
        new_ox = self._drag_state['inst_origin'][0] + dx
        new_oy = self._drag_state['inst_origin'][1] + dy
        self._user_positions[ref] = (new_ox, new_oy)
        # FIX the "body won't move / only its T moves" drag
        # bug.  Render builds FRESH instances each pass and (with
        # _place_owns_geometry, now default True) seeds their ox_px/oy_px
        # from _placed_draw_state — NOT from _user_positions, which is only
        # applied inside _run_placement.  So a manual drag must update the
        # stored draw-state origin, or render snaps the body back to the
        # place position while the owned T's (moved below) drift off.
        ds = getattr(self, '_placed_draw_state', None)
        if ds and ref in ds:
            ds[ref]['ox'] = new_ox
            ds[ref]['oy'] = new_oy
        inst = self._drag_state.get('inst')
        if inst is not None:
            inst.ox_px, inst.oy_px = new_ox, new_oy
        self._reflow_wires_on_move(ref, dx, dy)
        # Also move every per-pin power/ground T that belongs
        # to this instance so the short stub-flight stays the same
        # length and direction.  Cluster-level IO Ts (input/output/
        # sense/etc.) are NOT moved — they belong to the cluster, not
        # to any one instance, and engineers want them to stay where
        # they help the eye follow signal flow.
        self._auto_move_pin_pwr_ts(ref, dx, dy)
        gid = self._drag_state.get('ghost_id')
        if gid is not None:
            self.canvas.delete(gid)
        self._drag_state = None
        self.canvas.delete('flight_line')
        # SINGLE-INSTANCE DROP GETS THE PASS TOO.  It was wired only into
        # the GROUP branch, so dragging one part -- the common case --
        # never reconsidered its T's and a shared T never divided.
        try:
            # THE LIVE INSTANCES, NOT THE PLACED ONES.  A drag moves
            # _cached_inst_by_ref; _placed_instances is a DIFFERENT set of
            # objects (measured: 0 of 51 shared) still holding the pre-drag
            # coordinates.  Defaulting to those made the split compute each
            # piece's position from stale geometry -- LM324.sub's shared
            # ground T's split into pieces left at the parts' OLD x, which
            # is exactly the "some T's didn't move" report.
            _s = self._t_split_pass(self._cached_instances,
                                    moved_refs={ref})
        except Exception:
            _s = 0
        self._render()
        _sfx = (' (%d T split%s)'
                % (_s, '' if _s == 1 else 's')) if _s else ''
        self.status.config(text=f'{ref} dropped{_sfx}')

    def _auto_move_pin_pwr_ts(self, ref, dx, dy,
                              already_moved_t_ids=None,
                              moved_refs=None):
        """In : a ref, its (dx, dy) move, T ids the caller already moved,
        and a moved-refs set.  Out: every T that `ref` OWNS shifted by the
        same delta, so its T's keep their position relative to it.
        The relationship is asymmetric: moving an instance moves its T's,
        while dragging one T moves neither the instance nor its siblings.
        A T is OWNED only when every pin on it belongs to `ref`; one
        shared between instances (a single 'mid' T can serve many) has no
        owner to follow and stays put.  already_moved_t_ids prevents a
        double shift, and `moved` de-dupes a T reached through several
        pins of the same instance."""
        skip = already_moved_t_ids or set()
        t_by_id = {t['id']: t for t in self._t_terminals}
        owners = {}
        for (r2, _pn), tid in self._pin_to_t.items():
            owners.setdefault(tid, set()).add(r2)
        moved = set()
        for (r2, _pn), tid in self._pin_to_t.items():
            if r2 != ref or tid in skip or tid in moved:
                continue
            # A T MOVES WHEN EVERY OWNER MOVES.  The test
            # used to be "ref is the sole owner", so in a group drag a T
            # shared by two of the moved parts stayed behind even though
            # everything it connects to had gone -- the ground T's left
            # stranded at the bottom of LM324.sub while their instances
            # went up and right, reported as 11 overlaps.  moved_refs is
            # the whole set being dragged; a T all of whose owners are in
            # it is carried along, which is the same rule as before when
            # the set is a single part.
            if owners.get(tid, set()) - (moved_refs or {ref}):
                continue
            t = t_by_id.get(tid)
            if t is None:
                continue
            t['cx'] += dx
            t['cy'] += dy
            moved.add(tid)
        return moved

    def _reflow_wires_on_move(self, ref, dx, dy):
        """When an instance is dragged, every wire endpoint anchored to
        one of its pins should follow the instance.  We update the
        first or last waypoint of every affected wire by (dx, dy)."""
        for wire in self._wires:
            for end_idx, ep in enumerate(wire['endpoints']):
                if ep is None or ep[0] != ref:
                    continue
                # Move the corresponding endpoint waypoint.
                if end_idx == 0:
                    x, y = wire['points'][0]
                    wire['points'][0] = (x + dx, y + dy)
                else:
                    x, y = wire['points'][-1]
                    wire['points'][-1] = (x + dx, y + dy)

    def _on_canvas_motion(self, event):
        """Free motion (no button held) — rubber-band the wire-in-progress."""
        if self._wire_in_progress is None:
            return
        x, y = self._event_canvas_xy(event)
        # Snap rubber-band end-point to Manhattan from the last waypoint.
        last = self._wire_in_progress['points'][-1]
        snap = self._snap_manhattan(last, (x, y))
        self._update_rubber_band(snap)

    def _on_canvas_double(self, event):
        """Double-click:
          • Wire-in-progress → commit at current position (with a free
            end if no pin under cursor).
          • Net-name label   → Rev 48c: remove the user's customisation
            for this net (returns it to the default-midpoint placement
            via the missing-key rule in _draw_multi_pin_net_labels).
          • Bare flight line → same: remove customisation for that
            net.  Useful when all labels have been deleted and the
            user wants the default one back.
        """
        x, y = self._event_canvas_xy(event)
        if self._wire_in_progress is None:
            # DOUBLE-CLICK A MERGED T TO SPLIT IT.
            # Explicit beats inferred: the automatic rules had to guess
            # from distance or from box overlap when the user wanted a
            # shared T divided, and every threshold was either too eager
            # or -- as with LM324.sub's I4/Q17, 131 px out and still
            # under a 165 px trigger -- never reached at all.  Asking
            # directly cannot be wrong, and it cannot oscillate.
            _t = self._pick_t_terminal(x, y)
            if _t is not None:
                _own = sorted({r for (r, _p), tid
                               in (self._pin_to_t or {}).items()
                               if tid == _t.get('id')})
                if len(_own) > 1:
                    _n = self._t_split_one(_t)
                    if _n:
                        self._render()
                        self.status.config(
                            text='T (%s) split into %d — %s'
                                 % (_t.get('net'), _n, ', '.join(_own)))
                        return 'break'
                self.status.config(
                    text='T (%s) is not shared — nothing to split'
                         % _t.get('net'))
                return 'break'
            # Double-click on a net label restores the default.
            nl_hit = self._pick_net_label(x, y)
            if nl_hit is not None:
                net_lc, _idx = nl_hit
                if net_lc in self._net_labels:
                    del self._net_labels[net_lc]
                    self._render()
                    self.status.config(
                        text=f'Net label ({net_lc}) reset to default')
                return 'break'
            # Double-click on a flight line ALSO resets to default.
            fl_hit = self._pick_flight_line(x, y)
            if fl_hit is not None:
                net_lc, _xy = fl_hit
                if net_lc is not None and net_lc in self._net_labels:
                    del self._net_labels[net_lc]
                    self._render()
                    self.status.config(
                        text=f'Net label ({net_lc}) reset to default')
                return 'break'
            return 'break'
        pin_hit = self._pick_pin(x, y)
        if pin_hit is not None:
            inst_end, pin_end, px, py = pin_hit
            self._finish_wire((px, py),
                              end_pin=(inst_end.comp['ref'], pin_end))
        else:
            snap = self._snap_manhattan(
                self._wire_in_progress['points'][-1], (x, y))
            self._finish_wire(snap, end_pin=None)
        return 'break'

    def _place_owns_rotation_or_flip(self, inst, new_rot):
        """Apply a live rotate or flip edit to one instance: set its geometry,
        re-place its own text, check it against other bodies and T's, and
        refresh its draw-state record.
        """
        self._apply_instance_rotation_geometry(inst, new_rot)
        pairs = getattr(inst, '_pin_net_pairs', None)
        if pairs:
            inst.place_texts(QuadTree(-200000, -200000, 200000, 200000))
        inst._recompute_composite_rel()
        ref = inst.comp['ref']
        others = getattr(self, '_cached_instances', None) or [inst]
        if others:
            self._reresolve_value_texts(
                others,
                skip_refs={i.comp['ref'] for i in others
                          if i.comp['ref'] != ref})
        ds = getattr(self, '_placed_draw_state', None)
        if ds is not None:
            ds[ref] = {
                'ox': inst.ox_px, 'oy': inst.oy_px,
                'rot': inst.rotation_deg,
                'text_items': [dict(ti) for ti in inst.text_items],
                'composite_rel': tuple(inst.composite_rel),
            }

    def _on_canvas_right(self, event):
        """In : a right-button press.  Out: the first of these that hits —
          1) wire in progress -> cancel it;
          2) flight line under the cursor -> cycle its arrow state
             (auto -> forward -> reverse -> none); a SECOND right-click on
             the same edge within _DBL_CLICK_MS instead toggles that
             edge's feedback status for the Sugiyama placer;
          3) T-terminal -> rotate 90 CCW;  4) instance -> rotate 90 CCW;
          5) empty space -> forget the current group.
        The line is picked BEFORE the T because a T's hit box is generous
        enough to swallow a click on a line merely passing near it."""
        if self._wire_in_progress is not None:
            self._cancel_wire()
            return 'break'
        x, y = self._event_canvas_xy(event)
        # Check the flight-line edge before the T pick: a T's hit box is
        # generous and would swallow clicks on nearby lines.
        cycled = self._try_cycle_arrow_under(x, y, event.time)
        if cycled:
            return 'break'
        # T next — they're smaller than instances and often near pins,
        # so their priority over instance-rotate still matters.
        t = self._pick_t_terminal(x, y)
        if t is not None:
            t['rot'] = (t['rot'] + 90) % 360
            # Persist this T's new rotation as the
            # classification for its net.  Next Place will use this
            # override instead of the heuristic, so a user-reclassified
            # input-becomes-output stays on the right side after Place.
            self._t_net_rot_overrides[t['net'].lower()] = t['rot']
            # A direct, hands-on rotation always
            # means the four concrete roles (or auto), never -power:
            # -power is reachable ONLY from the Nets dialog, and only
            # THAT path should be able to set it.  Landing back on the
            # ground orientation this way must read as plain 'ground'
            # even if this net was previously marked -power there.
            self._neg_power_nets.discard(t['net'].lower())
            # light up every OTHER T on this same
            # net in green, so the user can see at a glance whether any
            # others might also need rotating.  Cleared on the next
            # empty-canvas click (_on_canvas_click).
            self._highlighted_t_net = t['net'].lower()
            self._highlighted_t_exclude_id = t['id']
            # Full re-render: a partial redraw rebuilds only T-routed lines and
            # would drop ordinary pin-to-pin ones.
            self._render()
            self.status.config(
                text=f'T ({t["net"]}) rotated to {t["rot"]}°')
            return 'break'
        inst = self._pick_instance(x, y)
        if inst is None:
            # Empty space → ungroup.
            if self._selected_group or self._selected_t_ids:
                self._selected_group.clear()
                self._selected_t_ids.clear()
                self._render()
                self.status.config(text='Group forgotten')
                return 'break'
            return None
        ref = inst.comp['ref']
        cur = self._current_rotation(inst)
        new_rot = (cur + 90) % 360
        self._user_rotations[ref] = new_rot
        # PLACE (this handler) now fixes up this
        # instance's geometry directly, right here, instead of just
        # flagging it stale and letting render do that work inside its
        # own seed step — see _place_owns_rotation_or_flip's own
        # docstring for the full reasoning.  self._just_rotated_refs
        # is deliberately left unset: render's matching branch is now
        # dead code by construction (nothing sets the signal it reads).
        self._place_owns_rotation_or_flip(inst, new_rot)
        # Wipe old flight lines so nothing stale survives the rotation.
        self.canvas.delete('flight_line')
        self._render()
        self.status.config(
            text=f'{ref} rotated to {self._user_rotations[ref]}° CCW')
        return 'break'

    def _current_rotation(self, inst):
        """In : a render instance (what _pick_instance returns).
        Proc: prefer the geometry's own rotation_deg — that IS what is
              drawn — and fall back to the user/auto maps only when the
              instance carries none.
        Out : degrees CCW, 0/90/180/270.
        The maps hold what the USER has asked for and are empty for a
        part the rules turned, so a handler taking its next angle from
        them sets a rotation the part already has."""
        ref = inst.comp['ref']
        deg = inst.rotation_deg
        if deg is None:
            deg = self._user_rotations.get(
                ref, self._auto_rotations.get(ref, 0))
        return int(deg) % 360

    def _on_canvas_shift_right(self, event):
        # shift-right-click flips (mirrors) an instance about
        # its vertical axis, the same way plain right-click rotates it.
        x = self.canvas.canvasx(event.x)
        y = self.canvas.canvasy(event.y)
        inst = self._pick_instance(x, y)
        if inst is None:
            return None
        ref = inst.comp['ref']
        cur = self._user_flips.get(ref, self._auto_flips.get(ref, False))
        self._user_flips[ref] = not cur
        # Same ownership move as the rotate
        # handler just above: PLACE fixes this instance's geometry
        # directly here (the flip already changed self._user_flips[ref]
        # above; _apply_instance_rotation_geometry reads it internally),
        # not render.  Same rotation value, geometry recomputed for the
        # new flip state via _place_owns_rotation_or_flip.
        cur_rot = self._current_rotation(inst)
        self._place_owns_rotation_or_flip(inst, cur_rot)
        self.canvas.delete('flight_line')
        self._render()
        self.status.config(
            text=f'{ref} {"flipped" if self._user_flips[ref] else "unflipped"}')
        return 'break'

    def _on_canvas_ctrl_click(self, event):
        """In : a Ctrl+click event on a pin.  Out: that pin's role cycled
        auto -> forced INPUT -> forced OUTPUT -> auto in
        self._pin_role_overrides, plus a re-render.
        _compute_pin_role_map treats the overrides as locked and
        _compute_signal_topo_order ranks Sugiyama's columns from the same
        map, so this changes PLACEMENT at the next Place; the handler
        never Places itself, so several pins can be clicked first.
        Hit-tests the ACTUAL drawn markers (_pin_role_marker_hits, within
        10 px) before _pick_pin, since the body-edge and label-avoidance
        moves can carry a dot away from its raw pin."""
        x, y = self._event_canvas_xy(event)
        best = None
        best_d2 = 10.0 ** 2
        for mx, my, ref, pn in (getattr(self, '_pin_role_marker_hits', None)
                                or ()):
            d2 = (mx - x) ** 2 + (my - y) ** 2
            if d2 <= best_d2:
                best_d2 = d2
                best = (ref, pn)
        if best is not None:
            ref, pn = best
        else:
            pin_hit = self._pick_pin(x, y)
            if pin_hit is None:
                return None
            inst, pn, _px, _py = pin_hit
            ref = inst.comp['ref']
        key = (ref, pn)
        cur = self._pin_role_overrides.get(key)
        if cur is None:
            new = 'in'
        elif cur == 'in':
            new = 'out'
        else:
            new = None
        if new is None:
            self._pin_role_overrides.pop(key, None)
            label = 'auto'
        else:
            self._pin_role_overrides[key] = new
            label = 'forced IN' if new == 'in' else 'forced OUT'
        self.canvas.delete('flight_line')
        self._render()
        self.status.config(text=f'{ref}.{pn} -> {label}  '
                                '(run Place to see the placement effect)')
        return 'break'

    _ARROW_STATE_CYCLE = ('auto', 'forward', 'reverse', 'none')
    # How close together (ms, by event.time) two right-clicks on the
    # SAME flight-line edge must land to count as the feedback-toggle
    # double-click gesture — see _try_cycle_arrow_under.
    _DBL_CLICK_MS = 600

    def _try_cycle_arrow_under(self, x, y, event_time=None):
        """If (x, y) is on a flight-line edge, a right-click cycles its arrow; a
        second right-click on the same edge within _DBL_CLICK_MS toggles its
        feedback status instead.  Returns True if it handled the click.
        """
        edge = self._pick_flight_line_edge(x, y)
        if edge is None:
            return False
        key, label, net_lc, fb_key = edge
        prev = self._last_right_flight_click
        if (prev is not None and prev['key'] == key and event_time is not None
                and event_time - prev['time'] <= self._DBL_CLICK_MS):
            # Second right-click on the SAME edge, soon enough after
            # the first -> the feedback-toggle gesture.  Restore the
            # arrow to what it was BEFORE the first click's cycle,
            # rather than leaving it changed — see the docstring above.
            self._last_right_flight_click = None
            if prev['pre_arrow'] == 'auto':
                self._arrow_overrides.pop(key, None)
            else:
                self._arrow_overrides[key] = prev['pre_arrow']
            # Three states: auto -> force feedback -> force forward -> auto.
            # False (forward) is not the same as absent (auto).
            cur_fb = self._feedback_overrides.get(fb_key)
            if cur_fb is None:
                self._feedback_overrides[fb_key] = True
                _msg = (f'{label}: forced FEEDBACK (red — excluded from '
                        f'next Place)')
            elif cur_fb:
                self._feedback_overrides[fb_key] = False
                _msg = (f'{label}: forced FORWARD (kept in the graph for '
                        f'the next Place)')
            else:
                self._feedback_overrides.pop(fb_key, None)
                _msg = f'{label}: feedback back to AUTO'
            self._render()
            self.status.config(text=_msg)
            return True
        cur = self._arrow_overrides.get(key, 'auto')
        self._last_right_flight_click = {
            'key': key, 'net_lc': net_lc, 'time': event_time or 0,
            'pre_arrow': cur,
        }
        try:
            i = self._ARROW_STATE_CYCLE.index(cur)
        except ValueError:
            i = 0
        new = self._ARROW_STATE_CYCLE[(i + 1) % len(self._ARROW_STATE_CYCLE)]
        if new == 'auto':
            # Drop the override entirely.
            self._arrow_overrides.pop(key, None)
        else:
            self._arrow_overrides[key] = new
        # Full re-render: a partial redraw rebuilds only T-routed lines and
        # would drop ordinary pin-to-pin ones.
        self._render()
        self.status.config(
            text=f'Arrow ({label}) → {new}')
        return True

    def _pick_flight_line_edge(self, x, y, tol=7):
        """Return the flight-line edge nearest (x, y) as (override key, label,
        net_lc, fb_key), or None; arrow legs are skipped.
        """
        items = self.canvas.find_withtag('flight_line')
        # Collect every candidate within tol and prefer the topmost (highest
        # item id, since flight lines are never restacked).
        candidates = []
        for item in items:
            if self.canvas.type(item) != 'line':
                continue
            tags = self.canvas.gettags(item)
            # Skip direction-arrow legs and net-label items.
            if any(t == 'direction_arrow' for t in tags):
                continue
            if any(t == 'direction_none_dot' for t in tags):
                continue
            if any(t.startswith('net_label') for t in tags):
                continue
            try:
                ax, ay, bx, by = self.canvas.coords(item)
            except ValueError:
                continue
            dx_e = bx - ax
            dy_e = by - ay
            seg_len2 = dx_e * dx_e + dy_e * dy_e
            if seg_len2 < 1e-6:
                continue
            t = ((x - ax) * dx_e + (y - ay) * dy_e) / seg_len2
            t = max(0.0, min(1.0, t))
            cx = ax + t * dx_e
            cy = ay + t * dy_e
            d2 = (x - cx) ** 2 + (y - cy) ** 2
            if d2 < tol * tol:
                candidates.append((item, ax, ay, bx, by))
        # find_withtag returns items in canvas STACKING order (bottom
        # to top), so the LAST matching candidate is whatever is drawn
        # on top at (x, y) — exactly what the user's click visually
        # landed on.
        best_item = candidates[-1] if candidates else None
        if best_item is None:
            return None
        item, ax, ay, bx, by = best_item
        # Resolve net name from tags.
        tags = self.canvas.gettags(item)
        net_lc = None
        for t in tags:
            if t.startswith('flight_net:'):
                net_lc = t[len('flight_net:'):]
                break
        if net_lc is None:
            return None
        # A sense line's flight_edge_refs tag is authoritative for fb_key; its
        # carrier end may resolve to a real pin that is not the edge's.
        fb_key_from_tag = None
        for t in tags:
            if t.startswith('flight_edge_refs:'):
                parts = t[len('flight_edge_refs:'):].split('\x1f')
                if len(parts) == 2:
                    fb_key_from_tag = self._feedback_edge_key(
                        net_lc, parts[0], parts[1])
                break
        # Resolve endpoints to (inst, pin) by checking each placed
        # instance's pin canvas positions.
        end_a = self._resolve_xy_to_pin(ax, ay)
        end_b = self._resolve_xy_to_pin(bx, by)
        if end_a is None and end_b is None:
            # Neither end is a real pin, so this is a sense line; it still
            # carries a feedback key.
            fb_key = (fb_key_from_tag if fb_key_from_tag is not None
                      else (net_lc, frozenset()))
            return (('net', net_lc), f'{net_lc} (sense)', net_lc, fb_key)
        # If one endpoint is a pin and the other is the rail X (no
        # pin matches), it's a rail stub — UNLESS a flight_edge_refs:
        # tag says this is actually a sense line whose carrier end
        # happened to resolve to a pin (see the note above); that tag
        # wins when present.
        if end_a is None or end_b is None:
            pin_end = end_a if end_b is None else end_b
            inst, pin_num = pin_end
            ref = inst.comp['ref']
            fb_key = (fb_key_from_tag if fb_key_from_tag is not None
                      else self._feedback_edge_key(net_lc, ref, ref))
            return (self._rail_arrow_edge_key(net_lc, ref, pin_num),
                    f'{net_lc} {ref}.{pin_num}↔rail', net_lc, fb_key)
        # Both ends are pins.
        inst_a, pin_a = end_a
        inst_b, pin_b = end_b
        ref_a, ref_b = inst_a.comp['ref'], inst_b.comp['ref']
        fb_key = (fb_key_from_tag if fb_key_from_tag is not None
                  else self._feedback_edge_key(net_lc, ref_a, ref_b))
        return (self._arrow_edge_key(net_lc, ref_a, pin_a, ref_b, pin_b),
                f'{net_lc} {ref_a}.{pin_a}↔{ref_b}.{pin_b}',
                net_lc, fb_key)

    def _resolve_xy_to_pin(self, x, y, tol=3):
        """Find the (inst, pin_num) whose canvas pin position is
        within `tol` pixels of (x, y).  Returns None if no pin
        matches (e.g. the position is a rail endpoint, not a pin).
        """
        for inst in self._placed_instances or []:
            pins = inst.comp.get('pin_names') or []
            for idx in range(len(inst.comp.get('nets', []) or [])):
                pin_num = (pins[idx] if idx < len(pins) and pins[idx]
                            else str(idx + 1))
                pxy = _pin_canvas_pos(inst, pin_num)
                if pxy is None:
                    continue
                if (pxy[0] - x) ** 2 + (pxy[1] - y) ** 2 < tol * tol:
                    return (inst, pin_num)
        return None

    # ── Keyboard ──────────────────────────────────────────────────────────

    def _on_key_escape(self, _event):
        """Esc: cancel wire-in-progress; failing that, clear segment
        selection; failing that, clear group selection."""
        if self._wire_in_progress is not None:
            self._cancel_wire()
            return
        if self._selected_segments:
            self._selected_segments.clear()
            self._render()
            return
        if self._selected_group or self._selected_t_ids:
            self._selected_group.clear()
            self._selected_t_ids.clear()
            self._render()

    def _on_key_return(self, _event):
        """Enter: commit wire-in-progress at last waypoint with a free end."""
        if self._wire_in_progress is None:
            return
        if len(self._wire_in_progress['points']) < 2:
            self._cancel_wire()
            return
        self._finish_wire(self._wire_in_progress['points'][-1],
                          end_pin=None)

    def _on_key_delete(self, _event):
        """Delete: remove selected segments.  When a segment in the
        middle of a wire is removed, the wire is split in two.  When the
        first or last segment is removed, the wire shrinks."""
        if not self._selected_segments:
            return
        # Group by wire_idx, descending order so list indices stay valid
        by_wire = {}
        for wi, si in self._selected_segments:
            by_wire.setdefault(wi, []).append(si)
        # Build a list of new wires; older ones get removed/replaced
        new_wires = []
        for wi, wire in enumerate(self._wires):
            kill = sorted(by_wire.get(wi, []))
            if not kill:
                new_wires.append(wire)
                continue
            pts = wire['points']
            net = wire['net']
            ep_start, ep_end = wire['endpoints']
            # Split points into runs separated by killed segments.
            runs = []
            cur = [pts[0]]
            for si in range(len(pts) - 1):
                if si in kill:
                    if len(cur) >= 2:
                        runs.append(cur)
                    cur = [pts[si + 1]]
                else:
                    cur.append(pts[si + 1])
            if len(cur) >= 2:
                runs.append(cur)
            # Emit each surviving run as its own wire.  Endpoints attach
            # to the original endpoints only when the first/last point
            # is preserved.
            for _ri, run in enumerate(runs):
                first_kept = run[0] == pts[0]
                last_kept  = run[-1] == pts[-1]
                new_wires.append({
                    'net': net,
                    'points': list(run),
                    'endpoints': [
                        ep_start if first_kept else None,
                        ep_end   if last_kept  else None,
                    ],
                })
        self._wires = new_wires
        self._selected_segments.clear()
        self._render()

    # ── Wire drawing ──────────────────────────────────────────────────────

    @staticmethod
    def _snap_manhattan(prev_xy, new_xy):
        """Snap (new_xy) so the line from prev_xy is horizontal or
        vertical, whichever is closer to the current cursor angle."""
        ax, ay = prev_xy
        bx, by = new_xy
        if abs(bx - ax) >= abs(by - ay):
            return (bx, ay)         # horizontal
        return (ax, by)             # vertical

    def _start_wire(self, inst, pin, start_xy):
        """Begin rubber-band wire drawing from a pin."""
        # Identify the net the pin belongs to (from the SPICE net list).
        net = self._pin_net(inst, pin)
        self._wire_in_progress = {
            'net': net,
            'start_pin': (inst.comp['ref'], pin),
            'points': [start_xy],
        }
        self.status.config(
            text=f'Drawing wire on net "{net}" — '
                 f'click to add waypoint, click pin to finish, Esc to cancel')

    def _update_rubber_band(self, current_xy):
        """Redraw the rubber-band preview line from the last committed
        waypoint to current_xy.  Called frequently — kept cheap."""
        if self._rubber_band_id is not None:
            self.canvas.delete(self._rubber_band_id)
            self._rubber_band_id = None
        if self._wire_in_progress is None:
            return
        pts = self._wire_in_progress['points']
        coords = []
        for x, y in pts:
            coords += [x, y]
        coords += [current_xy[0], current_xy[1]]
        self._rubber_band_id = self.canvas.create_line(
            coords, fill='#cc6600', width=2, dash=(4, 2))

    def _finish_wire(self, end_xy, end_pin):
        """Commit the wire-in-progress to self._wires and re-render."""
        if self._wire_in_progress is None:
            return
        pts = list(self._wire_in_progress['points'])
        if pts[-1] != end_xy:
            pts.append(end_xy)
        # Reject trivial wires (single point).
        if len(pts) >= 2:
            self._wires.append({
                'net': self._wire_in_progress['net'],
                'points': pts,
                'endpoints': [self._wire_in_progress['start_pin'], end_pin],
            })
        self._wire_in_progress = None
        if self._rubber_band_id is not None:
            self.canvas.delete(self._rubber_band_id)
            self._rubber_band_id = None
        self._render()
        self.status.config(text='Wire committed')

    def _cancel_wire(self):
        """Abort the wire-in-progress without committing."""
        self._wire_in_progress = None
        if self._rubber_band_id is not None:
            self.canvas.delete(self._rubber_band_id)
            self._rubber_band_id = None
        self.status.config(text='Wire canceled')

    @staticmethod
    def _pin_net(inst, pin):
        """Return the net name connected to a given pin of an instance,
        as recorded in the SPICE netlist."""
        # resolve_pin_nets returns [(pin_str, net), …]; use it for
        # consistency with the rest of the codebase.
        try:
            pairs = resolve_pin_nets(inst.comp, inst.sym_entry)
        except Exception:
            pairs = []
        for pn, nl in pairs:
            if str(pn) == str(pin):
                return nl
        # Fallback: position-based pairing.
        pins = inst.sym_entry.get('pins', {})
        sp = sorted(pins, key=lambda k: int(k) if k.isdigit() else 0)
        nets = inst.comp.get('nets', [])
        for i, p in enumerate(sp):
            if str(p) == str(pin) and i < len(nets):
                return nets[i]
        return '?'

    # ── Wire rendering ────────────────────────────────────────────────────

    def _draw_user_wires(self):
        """Draw every user-routed wire.  Selected segments are highlighted
        yellow; unselected segments use a per-net hashed colour."""
        C_WIRE_DEFAULT = '#0044cc'   # match the schematic wire colour
        C_WIRE_SEL     = '#ffaa00'   # selected segment highlight
        for wi, wire in enumerate(self._wires):
            pts = wire['points']
            for si in range(len(pts) - 1):
                ax, ay = pts[si]
                bx, by = pts[si + 1]
                selected = (wi, si) in self._selected_segments
                colour = C_WIRE_SEL if selected else C_WIRE_DEFAULT
                width  = 3 if selected else 2
                self.canvas.create_line(ax, ay, bx, by,
                                          fill=colour, width=width,
                                          capstyle=tk.ROUND)
            # Draw a small junction dot at every interior waypoint where
            # 3+ segments meet (T-junctions in this wire alone — full
            # multi-wire junction detection would need a cross-wire
            # pass, deferred).
            for si in range(1, len(pts) - 1):
                x, y = pts[si]
                self.canvas.create_oval(x - 2, y - 2, x + 2, y + 2,
                                         fill='#0044cc', outline='')

    # ── Net coverage ──────────────────────────────────────────────────────

    def _wire_pin_groups(self):
        """In : self._wires.  Out: {(ref, pin): root} for every pin a
        user wire ends on, where pins joined through one wire or a chain
        of wires share a root.  A wire's free (None) end joins nothing.
        Flight lines treat each root as one node, so a wire that joins
        two pins removes the flight line between them."""
        parent = {}

        def find(x):
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x
        for wi, wire in enumerate(self._wires or ()):
            wkey = ('__wire__', wi)
            parent.setdefault(wkey, wkey)
            for ep in wire.get('endpoints') or ():
                if not ep:
                    continue
                pk = (ep[0], str(ep[1]))
                parent.setdefault(pk, pk)
                ra, rb = find(wkey), find(pk)
                if ra != rb:
                    parent[ra] = rb
        return {k: find(k) for k in parent if k[0] != '__wire__'}

    def _wire_groups_for(self, members, groups=None):
        """In : [(inst, pin)] on one net, and _wire_pin_groups() if the
        caller already has it.  Out: a group key per member (None when
        no wire touches that pin), for _mst_edges_manhattan."""
        if groups is None:
            groups = self._wire_pin_groups()
        return [groups.get((m.comp['ref'], str(p))) for m, p in members]

    def _nets_fully_connected_by_wires(self, instances):
        """In : the instances.  Out: the lowercase names of nets whose
        every pin is joined to every other by user wires.  _render and
        the net labels drop such nets."""
        groups = self._wire_pin_groups()
        if not groups:
            return set()
        net_pins = defaultdict(list)
        for inst in instances:
            for pn, nl in (getattr(inst, '_pin_net_pairs', None) or ()):
                net_pins[str(nl).lower()].append((inst.comp['ref'], str(pn)))
        fully = set()
        for net_lc, pins_on_net in net_pins.items():
            if len(pins_on_net) < 2:
                continue
            roots = {groups.get(ep) for ep in pins_on_net}
            if len(roots) == 1 and None not in roots:
                fully.add(net_lc)
        return fully

    # ── Save / Load ───────────────────────────────────────────────────────

    def _build_pr_payload(self):
        """Out: the .pr.json payload for the CURRENT placement, as a plain
        dict — no file, no dialog, no side effects.
        Split out of _save_pr so the payload can be built without a save
        dialog, which is what makes a save-reload-compare round trip
        testable at all: while this was inline, the only way to produce a
        full-placement file was to click through filedialog.  _save_pr
        now just picks a path, calls this and writes it."""
        # Save each instance's live rotation: _user_rotations only holds parts
        # the user rotated by hand, not ones placement turned.
        _live_rot = {i.comp['ref']: i.rotation_deg
                     for i in (self._placed_instances or [])}
        # The same for position: _user_positions holds only dragged refs, so
        # save live positions for every part.
        _live_pos = {i.comp['ref']: (i.ox_px, i.oy_px)
                     for i in (self._placed_instances or [])}
        # _placed_instances is placement's copy and a drag never touches it, so
        # layer the draw state, then the user's drags, over it (last wins).
        _save_pos = dict(_live_pos)
        for ref, st in (getattr(self, '_placed_draw_state', None)
                        or {}).items():
            if 'ox' in st and 'oy' in st:
                _save_pos[ref] = (st['ox'], st['oy'])
        _save_pos.update(self._user_positions)
        # Rotation uses the same three tiers: placement's copy, then the draw
        # state, then _user_rotations.
        _save_rot = dict(_live_rot)
        for ref, st in (getattr(self, '_placed_draw_state', None)
                        or {}).items():
            if st.get('rot') is not None:
                _save_rot[ref] = st['rot']
        _save_rot.update(self._user_rotations)
        # Save the mirror too, or a mirrored part reloads with its pins on the
        # wrong sides.
        _flip_of = {}
        for ref in set(_save_pos) | set(self._user_flips) | set(
                self._auto_flips):
            _flip_of[ref] = bool(self._user_flips.get(
                ref, self._auto_flips.get(ref, False)))
        data = {
            'rev': 48,
            'spice_file': self.title().replace('sp2Sch  –  ', ''),
            # Which subckt this save is for, when
            # the file defines more than one selectable subckt (see
            # _default_pr_path).  None/absent for a file with no subckt
            # selection at all.
            'subckt': self._active_subckt,
            'instances': {
                ref: {'cx': cx, 'cy': cy,
                      'rot': _save_rot.get(
                          ref, self._user_rotations.get(ref, 0)),
                      'flip': _flip_of.get(ref, False)}
                for ref, (cx, cy) in _save_pos.items()
            },
            'rotations_only': {
                ref: deg for ref, deg in self._user_rotations.items()
                if ref not in _save_pos and deg
            },
            # The mirror half of rotations_only: a ref that is mirrored
            # but has no saved POSITION (the roles-only / forgotten-
            # placement file) still needs its flip carried, for the same
            # reason rotations_only exists.
            'flips_only': sorted(
                ref for ref, f in _flip_of.items()
                if f and ref not in _save_pos
            ),
            # IO classification overrides by net.  The user
            # rotates a T-symbol on an IO/internal net to change its
            # input/output classification for the next Place run.
            't_net_rot_overrides': dict(self._t_net_rot_overrides),
            # +power/ground Role overrides by net,
            # set from the Nets dialog; checked first by _rail_polarity().
            'rail_polarity_overrides': dict(self._rail_polarity_overrides),
            # Which of the '-' polarity nets above
            # are specifically '-power' rather than plain 'ground' (same
            # rotation/polarity either way — see _net_role_of); this was
            # missing entirely, so a -power net came back as 'ground'
            # after every Save/Open P&R round-trip even though the T
            # itself stayed correctly oriented.
            'neg_power_nets': sorted(self._neg_power_nets),
            # User-forced feedback/forward edge overrides (double right-click on
            # a flight line); like net roles they survive Forget placement and
            # are saved.
            'feedback_overrides': [
                {'net': fb_key[0], 'refs': sorted(fb_key[1]),
                 'feedback': bool(v)}
                for fb_key, v in self._feedback_overrides.items()
                if fb_key[1]
            ],
            'wires': [
                {
                    'net': w['net'],
                    'points': [[x, y] for x, y in w['points']],
                    'endpoints': [
                        list(ep) if ep else None
                        for ep in w['endpoints']
                    ],
                }
                for w in self._wires
            ],
            # T-terminals.  Stored as a list so the file-on-disk
            # order matches the in-memory order.  Each entry includes its
            # stable id; the load path uses that id to rebuild _pin_to_t.
            't_terminals': [
                # own_box / user_moved ARE PART OF WHAT A T IS, not
                # decoration.  own_box says this T serves several
                # instances and holds a box of its own; user_moved says
                # the single-owner re-pin must leave it where it was
                # put.  Dropping them on save silently undid a
                # cross-instance merge and un-pinned every hand-moved T
                # on the next load.  Written only when set, so a file
                # stays byte-identical for a layout that uses neither.
                dict({'id': t['id'], 'net': t['net'],
                      'cx': t['cx'], 'cy': t['cy'], 'rot': t['rot']},
                     **{k: True for k in ('own_box', 'user_moved')
                        if t.get(k)})
                for t in self._t_terminals
            ],
            'pin_to_t': [
                {'ref': ref, 'pin': pn, 't_id': tid}
                for (ref, pn), tid in self._pin_to_t.items()
            ],
            # pin-role driver/receiver OVERRIDES (set
            # via Ctrl+click, see _on_canvas_ctrl_click).  This was missing
            # entirely — Save P&R silently dropped them, so a user's I/O
            # reclassification never survived a save/reload, and pressing
            # Place after a reload showed no change even though the click
            # itself had worked in the live session.  Stored as a list
            # (mirrors pin_to_t's shape) rather than a dict, since JSON
            # object keys must be strings and (ref, pin) is a tuple.
            'pin_role_overrides': [
                {'ref': ref, 'pin': pn, 'role': role}
                for (ref, pn), role in self._pin_role_overrides.items()
            ],
            # Per-net flight-line label customisation.  Empty
            # dict (nothing user-edited) saves as {}.  Each value is a
            # list of {'pos': [cx, cy], 'visible': bool} entries.
            'net_labels': {
                nl: [{'pos': [float(e['pos'][0]), float(e['pos'][1])],
                       'visible': bool(e.get('visible', True))}
                     for e in entries]
                for nl, entries in self._net_labels.items()
            },
            # VALUE-equation display state.
            #   'fulltext_global' is the toolbar's current default
            #   for instances without an override.
            #   'fulltext_overrides' overrides that default per ref.
            'fulltext_global': bool(self.fulltext),
            'fulltext_overrides': {
                ref: bool(v)
                for ref, v in self._fulltext_overrides.items()},
        }
        # Auto-tag roles_only when the save holds no positions, T's or wires
        # (for example right after Forget placement).
        if (_pr_is_placement_free(data) and not data['t_terminals']
                and not data['wires']):
            data['roles_only'] = True
        return data

    def _save_pr(self):
        """In : the current placement and routing.  Out: them serialised
        to a JSON file the user picks.
        The dialog defaults to the EXACT sibling filename
        _auto_open_matching_pr looks for at startup, in the loaded SPICE
        file's own directory, so the easiest save — hit Save, keep the
        suggested name — is also the one that auto-loads next time,
        without the user having to know the convention.  It is only a
        suggestion; any other name simply does not auto-load, and is
        still one Open P&R... click away."""
        initialdir = initialfile = None
        p = self._default_pr_path()
        if p is not None:
            initialdir = str(p.parent)
            initialfile = p.name
        kwargs = dict(
            title='Save Place & Route',
            defaultextension='.pr.json',
            filetypes=[('Place & Route JSON', '*.pr.json'),
                        ('All files', '*.*')])
        if initialdir:
            kwargs['initialdir'] = initialdir
        if initialfile:
            kwargs['initialfile'] = initialfile
        path = filedialog.asksaveasfilename(**kwargs)
        if not path:
            return
        data = self._build_pr_payload()
        try:
            with open(path, 'w') as f:
                json.dump(data, f, indent=2)
            self.status.config(text=f'P&R saved to {Path(path).name}')
        except OSError as e:
            self.status.config(text=f'Save failed: {e}')

    def _read_matching_pr(self):
        """In : the loaded SPICE file.  Out: the parsed default *.pr.json
        dict (see _default_pr_path) when it is present, readable, valid
        and recorded for THIS spice file and subckt; None otherwise.
        Every expected failure is a silent no-op rather than an error,
        matching _auto_open_matching_pr's contract.  Factored out so
        _auto_initial_place reads the file ONCE for both the early
        role-only pass and the later full apply.  It is also the ONE
        enforcement point for -n/--no-pr: one check here suppresses the
        whole automatic load while Open P&R and the saves still work."""
        if getattr(self, '_no_autoload_pr', False):
            return None
        if not self._spice_path:
            return None
        pr_path = self._default_pr_path()
        if pr_path is None or not pr_path.is_file():
            return None
        try:
            if not os.access(pr_path, os.R_OK):
                return None
            with open(pr_path) as f:
                data = json.load(f)
        except (OSError, ValueError):
            return None
        recorded = data.get('spice_file')
        if recorded and recorded != Path(self._spice_path).name:
            return None
        rec_subckt = data.get('subckt')
        if (rec_subckt and self._active_subckt
                and rec_subckt != self._active_subckt):
            return None
        return data

    def _parse_feedback_overrides(self, data):
        """Parse Save P&R's 'feedback_overrides' into {(net_lc,
        frozenset(refs)): bool}; an older per-net {net_lc: bool} form is
        still accepted.
        """
        raw = data.get('feedback_overrides', {})
        out = {}
        if isinstance(raw, dict):
            return out          # old net-level format: unmatchable, drop
        for rec in raw:
            nl = str(rec.get('net', '')).lower()
            refs = frozenset(str(r) for r in rec.get('refs', []))
            if not nl or not refs:
                continue
            out[(nl, refs)] = bool(rec.get('feedback', False))
        return out

    def _apply_pr_role_data(self, data):
        """In : a saved P&R dict.  Out: only its role fields applied —
        t_net_rot_overrides, rail_polarity_overrides, neg_power_nets and
        pin_role_overrides.
        Split out of _apply_pr_data so _auto_initial_place can call it
        BEFORE the initial Place.  A role-blind Place positions bodies as
        if every net were auto, while the saved T-terminals were computed
        under a role-aware layout, so overlaying the whole snapshot on a
        blind one can overlap: LM324.lib with net 4 set to -power gave 4
        overlaps that way and 0 with the roles applied first."""
        self._t_net_rot_overrides = {
            k.lower(): int(v) % 360
            for k, v in data.get('t_net_rot_overrides', {}).items()
        }
        self._rail_polarity_overrides = {
            k.lower(): v
            for k, v in data.get('rail_polarity_overrides', {}).items()
            if v in ('+', '-')
        }
        self._neg_power_nets = {
            str(n).lower() for n in data.get('neg_power_nets', [])
            if str(n).lower() in self._rail_polarity_overrides
            and self._rail_polarity_overrides[str(n).lower()] == '-'
        }
        self._pin_role_overrides = {
            (o['ref'], o['pin']): o['role']
            for o in data.get('pin_role_overrides', [])
            if o.get('role') in ('in', 'out')
        }
        # Restore user-forced feedback/forward EDGE overrides (see the
        # save side in _save_pr and the parse-side helper
        # _parse_feedback_overrides).
        self._feedback_overrides = self._parse_feedback_overrides(data)
        # Rotation and mirror carried without a placement: a roles_only file
        # still restores them.
        for _r, _deg in (data.get('rotations_only') or {}).items():
            self._user_rotations[str(_r)] = int(_deg) % 360
        for _r in (data.get('flips_only') or []):
            self._user_flips[str(_r)] = True

    def _apply_pr_data(self, data):
        """Apply a saved P&R dict and re-render; shared by Open P&R and the
        startup load of a matching file.
        """
        self._placed_draw_state = None
        # Apply instance positions.
        self._user_positions = {
            ref: (info['cx'], info['cy'])
            for ref, info in data.get('instances', {}).items()
        }
        # Apply rotations — both for positioned and unpositioned instances.
        self._user_rotations = {}
        self._auto_rotations = {}
        self._orient_class = {}            # 'H'/'V'
        self._user_flips = {}; self._auto_flips = {}
        # A saved rot of 0 is a real choice: test for None, not truthiness.
        for ref, info in data.get('instances', {}).items():
            rot = info.get('rot', 0) if isinstance(info, dict) else 0
            self._user_rotations[ref] = rot
            # Restore the MIRROR the same way, and only when the key is
            # actually present: a file written before flips were saved
            # has no opinion about the mirror, and inventing False for
            # it would be asserting something the file never said.
            # Absent means "behave exactly as this file always did".
            if isinstance(info, dict) and 'flip' in info:
                self._user_flips[ref] = bool(info['flip'])
        for ref in data.get('flips_only', []) or []:
            self._user_flips[str(ref)] = True
        for ref, deg in data.get('rotations_only', {}).items():
            self._user_rotations[ref] = deg
        # Restore T-symbol classification overrides (user
        # reclassified some IO ports/internal nets via right-click
        # rotation).  Stored as {net_lc: rot}.
        self._t_net_rot_overrides = {
            k.lower(): int(v) % 360
            for k, v in data.get('t_net_rot_overrides', {}).items()
        }
        # restore +power/ground Role overrides.
        self._rail_polarity_overrides = {
            k.lower(): v
            for k, v in data.get('rail_polarity_overrides', {}).items()
            if v in ('+', '-')
        }
        # Restore which '-' nets are specifically
        # -power rather than plain ground (see the save side).  Only
        # keep entries that are actually '-' polarity in what we just
        # restored above, in case of a hand-edited or stale file.
        self._neg_power_nets = {
            str(n).lower() for n in data.get('neg_power_nets', [])
            if str(n).lower() in self._rail_polarity_overrides
            and self._rail_polarity_overrides[str(n).lower()] == '-'
        }
        # Apply wires.
        self._wires = []
        for w in data.get('wires', []):
            self._wires.append({
                'net': w['net'],
                'points': [tuple(p) for p in w['points']],
                'endpoints': [
                    tuple(ep) if ep else None
                    for ep in w.get('endpoints', [None, None])
                ],
            })
        self._selected_segments.clear()
        # Restore T-terminals and pin-to-T assignment.
        self._t_terminals = []
        self._pin_to_t = {}
        max_id = 0
        for tdat in data.get('t_terminals', []):
            _t = {
                'id': int(tdat['id']),
                'net': tdat['net'],
                'cx': float(tdat['cx']),
                'cy': float(tdat['cy']),
                'rot': int(tdat.get('rot', 270)) % 360,
            }
            # Absent means "this file has no opinion", which for both
            # flags is the same as off -- so an older file loads exactly
            # as it always did.
            for _k in ('own_box', 'user_moved'):
                if tdat.get(_k):
                    _t[_k] = True
            self._t_terminals.append(_t)
            if int(tdat['id']) > max_id:
                max_id = int(tdat['id'])
        for assoc in data.get('pin_to_t', []):
            self._pin_to_t[(assoc['ref'], assoc['pin'])] = int(assoc['t_id'])
        self._next_t_id = max_id + 1
        # restore pin-role driver/receiver overrides.
        self._pin_role_overrides = {
            (o['ref'], o['pin']): o['role']
            for o in data.get('pin_role_overrides', [])
            if o.get('role') in ('in', 'out')
        }
        # Restore user-forced feedback/forward EDGE overrides (see the
        # save side in _save_pr and the parse-side helper
        # _parse_feedback_overrides).
        self._feedback_overrides = self._parse_feedback_overrides(data)
        # Net-label customisation.
        self._net_labels = {}
        for nl, entries in (data.get('net_labels') or {}).items():
            out = []
            for e in entries:
                pos = e.get('pos', [0, 0])
                out.append({
                    'pos': (float(pos[0]), float(pos[1])),
                    'visible': bool(e.get('visible', True)),
                })
            self._net_labels[nl] = out
        # VALUE-equation display state.
        if 'fulltext_global' in data:
            self.fulltext = bool(data['fulltext_global'])
        self._fulltext_overrides = {
            ref: bool(v)
            for ref, v in (data.get('fulltext_overrides') or {}).items()
        }
        # See this method's own docstring
        # note above self._placed_draw_state = None: _user_positions
        # alone is never enough to move anything on screen; this call
        # actually applies it to the current instances and rebuilds a
        # correct draw-state snapshot for render to seed from.
        self._apply_saved_positions_and_text(data)
        self._render()
        # A RESTORE IS ADDITIVE, NOT A FREEZE.  The file says where the
        # T's it knows about go; it does not get to say that a pin on a
        # T-net has none.  Filling the gaps needs the instances as
        # DRAWN, which is why this runs after the render rather than
        # beside the t_terminals restore above, and a second render
        # draws whatever it added.  Loading is rare; two passes are
        # cheaper than a gap that follows the layout around.
        try:
            _n, _bad = self._fill_missing_pin_ts(self._cached_instances)
        except Exception:
            _n = _bad = 0
        if _n:
            self._render()
            self.status.config(
                text='P&R restored — added %d missing T%s%s'
                     % (_n, '' if _n == 1 else 's',
                        ' (%d on a crowded seat)' % _bad if _bad else ''))

    def _apply_saved_positions_and_text(self, data):
        """Apply a saved dict's positions and rotations to the current placed
        instances, re-place each one's labels to match, and rebuild
        _placed_draw_state.
        """
        if not self._placed_instances:
            # Build the instance objects without laying them out: every saved
            # position, rotation and mirror overwrites them below.
            _saved_user_pos = dict(self._user_positions)
            built = self._build_instances()
            if built and set(i.comp['ref'] for i in built) <= set(
                    _saved_user_pos):
                self._placed_instances = built
                self._placing_instances = built
                self._supply_rails = self._detect_supply_rails(built)
                # _render draws its pre-placement "Placing… (press
                # Place… if this persists)" hint INSTEAD of the
                # schematic until _initial_place_done is set, and
                # _run_placement is what normally sets it.  This path
                # deliberately skips _run_placement, so it has to make
                # the same statement itself: positions now exist, so
                # render must draw them rather than the hint.  Without
                # this, restoring a full placement showed the hint
                # forever and the schematic never appeared.
                self._initial_place_done = True
                # Set _pin_flight_data too: without it _render falls back to the
                # pre-placement centroid view.
                net_to_pins, inst_to_pairs = _build_pin_flight_data(built)
                self._pin_flight_data = (net_to_pins, inst_to_pairs, built)
            else:
                self._run_placement()
            self._user_positions = _saved_user_pos
        inst_by_ref = {i.comp['ref']: i
                       for i in (self._placed_instances or [])}
        touched_refs = set()
        for ref, (cx, cy) in self._user_positions.items():
            inst = inst_by_ref.get(ref)
            if inst is None:
                continue
            inst.ox_px, inst.oy_px = cx, cy
            rot = self._user_rotations.get(ref)
            if rot is not None:
                self._apply_instance_rotation_geometry(inst, rot)
            touched_refs.add(ref)
        if not touched_refs:
            return
        for ref in touched_refs:
            inst = inst_by_ref[ref]
            inst.place_texts(QuadTree(-200000, -200000, 200000, 200000))
        self._reresolve_value_texts(
            list(inst_by_ref.values()),
            skip_refs={ref for ref in inst_by_ref
                      if ref not in touched_refs})
        for ref in touched_refs:
            inst_by_ref[ref]._recompute_composite_rel()
        ds = {}
        for inst in (self._placed_instances or []):
            ref = inst.comp['ref']
            ds[ref] = {
                'ox': inst.ox_px, 'oy': inst.oy_px,
                'rot': inst.rotation_deg,
                'text_items': [dict(ti) for ti in inst.text_items],
                'composite_rel': tuple(inst.composite_rel),
            }
        self._placed_draw_state = ds

    def _open_pr(self):
        """In : a file chosen from the Open Place & Route dialog.
        Out: that saved P&R JSON applied.
        A file from Save net & pin info... is tagged 'roles_only' and
        needs a DIFFERENT path: its instances, t_terminals and pin_to_t
        are intentionally empty, so the normal full _apply_pr_data would
        overwrite what is currently placed with that emptiness — it wiped
        a fresh Place's T-terminals to zero.  A roles_only file instead
        gets only its role fields applied and a re-Place, so the roles
        feed a fresh role-aware layout, as the startup auto-load does."""
        path = filedialog.askopenfilename(
            title='Open Place & Route',
            filetypes=[('Place & Route JSON', '*.pr.json'),
                        ('JSON', '*.json'),
                        ('All files', '*.*')])
        if not path:
            return
        try:
            with open(path) as f:
                data = json.load(f)
        except (OSError, ValueError) as e:
            self.status.config(text=f'Load failed: {e}')
            return
        if data.get('roles_only') or _pr_is_placement_free(data):
            self._apply_pr_role_data(data)
            self._run_placement()
            self.status.config(
                text=f'Net & pin roles loaded from {Path(path).name} '
                     f'— re-placed')
            return
        self._apply_pr_data(data)
        self.status.config(text=f'P&R loaded from {Path(path).name}')

    def _default_pr_path(self):
        """In : the SPICE path and the active subckt.  Out: the matching
        *.pr.json Path, or None with no deck loaded.
        The ONE place that decides it, so Save P&R's suggested name, the
        startup auto-load lookup and the recorded-subckt check always
        agree.  <spice_path>.<subckt>.pr.json when a subckt is active,
        because LM324.sub defines both LM324 and LM324Q and the plain
        <spice_path>.pr.json let saving one silently overwrite the other,
        then auto-load whichever was saved last.  Falls back to the plain
        name when _active_subckt is None and nothing needs distinguishing."""
        if not self._spice_path:
            return None
        base = str(self._spice_path)
        if self._active_subckt:
            return Path(f'{base}.{self._active_subckt}.pr.json')
        return Path(base + '.pr.json')

    def _auto_open_matching_pr(self):
        """In : the SPICE file just loaded.  Out: its saved placement
        applied, as clicking Open P&R... would.  Called once after the
        initial auto-Place, from _auto_initial_place.
        Only the EXACT sibling <spice_path>.pr.json is tried — a fuzzy
        guess could silently apply another circuit's placement — and any
        problem is a silent no-op, never a dialog.  A roles-only or
        placement-free file gets its roles applied and a re-Place."""
        data = self._read_matching_pr()
        if data is None:
            return
        pr_path = self._default_pr_path()
        # Same roles_only guard as
        # _auto_initial_place's main call site (see its comment): a
        # roles_only file's empty placement fields must not overwrite
        # whatever is currently placed.  This fallback path is only
        # reached if the earlier early-role-read attempt failed, so
        # apply roles + re-Place here too rather than a full snapshot.
        if data.get('roles_only') or _pr_is_placement_free(data):
            self._apply_pr_role_data(data)
            self._run_placement()
            self.status.config(
                text=f'Net & pin roles auto-loaded from {pr_path.name} '
                     f'— re-placed')
            return
        self._apply_pr_data(data)
        self.status.config(
            text=f'Place & Route auto-loaded from {pr_path.name}')

    # ── Help dialog ──────────────────────────────────────────
    _PROGRAM_REV = '21Sep26b'
    _HELP_TEXT = """\
sp2Sch rev {rev}

Turns a SPICE deck into a schematic you can read and edit.  The first
placement is a guess built from the netlist alone; everything you do
afterwards tells the placer something it could not work out, then
asks it to try again.

══════════════════════════════════════════════════════════════════════
1. The loop: place, correct, place again
══════════════════════════════════════════════════════════════════════
  1. Launch:  sp2Sch.py deck.lib [-s SUBCKT]
     The window opens already placed.  Pick another circuit from the
     SUBCKT: menu if needed.
  2. Fix the net roles (input, output, +power, ground, -power).  This
     is the highest-value correction: it decides which side a signal
     enters from and which way the supply rails point.
  3. Fix the pin roles (driver / receiver) where the arrows are wrong.
  4. Press Place… again.  Nothing in steps 2-3 places by itself, so
     correct as many nets and pins as you like first.
  5. Tidy by hand, then Save P&R….  A saved file beside the deck is
     loaded automatically next time.

══════════════════════════════════════════════════════════════════════
2. Moving and orienting parts
══════════════════════════════════════════════════════════════════════
  Move a part           Drag it with the left button.  If it is in
                        the selected group, the whole group moves.
  Rotate a part         Right-click it: 90° counter-clockwise per
                        click; four clicks return it to the start.
  Mirror a part         Shift + right-click it: flips it about its
                        vertical axis, for a part lying the right way
                        round with its input on the wrong side.
  Select several        Drag in empty space to rubber-band.  Every
                        instance and T whose center falls inside joins
                        the group; dragging any member moves them all.
  Clear the selection   Right-click in empty space, or press Esc.
  Pull a group together Select it, then Compact sel (up, then left).
  Make room in a group  Select it, then Spread sel (one part width
                        between columns, one height between rows).
  Close empty bands     Close gaps ↕ removes tall empty horizontal
                        bands; everything below moves up together.
  Undo hand work        Forget placement drops moves and rotations
                        but keeps net and pin roles.
  Scroll and pan        Mouse wheel scrolls; middle-button drag pans.

══════════════════════════════════════════════════════════════════════
3. Net names
══════════════════════════════════════════════════════════════════════
  Add a net name        Left-click the flight line where you want it.
  Move a net name       Drag the label with the left button.
  Delete a net name     Left-click the label (click the LINE to add,
                        click the LABEL to delete).
  Reset a net's labels  Double-click the label or its flight line.

══════════════════════════════════════════════════════════════════════
4. Net roles
══════════════════════════════════════════════════════════════════════
  Inputs enter from the left, outputs leave to the right, +power goes
  to the top, ground and -power to the bottom.  Two ways to set one:

  Nets button           Opens every net with its role.  Sets all five
                        roles, or auto to let the placer decide.  Use
                        it for a real negative supply.
  Rotate the T-symbol   Right-click a T.  Its four positions ARE four
                        roles (see section 11).  Ground and -power
                        share the bottom position, so this way sets
                        ground.  The role belongs to the net: every T
                        on that net follows.

══════════════════════════════════════════════════════════════════════
5. Pin roles: driver and receiver
══════════════════════════════════════════════════════════════════════
  Change a pin's role   Ctrl + left-click the pin's dot.  Each click
                        steps auto -> driver -> receiver -> auto.
  See what changed      The arrow updates at once; an overridden pin
                        gets a colored ring.
  Make it count         Press Place….  A forced role is authoritative
                        and sets the left-to-right order.

══════════════════════════════════════════════════════════════════════
6. T-symbols
══════════════════════════════════════════════════════════════════════
  Move a T              Drag it.  It stays where you drop it.
  Merge two T's         Drop one onto the other.  Both must be on the
                        same net at the same rotation.
  Split a shared T      Double-click it; each pin gets its own stub.
  Change its meaning    Right-click it to rotate (section 4).

══════════════════════════════════════════════════════════════════════
7. Wires
══════════════════════════════════════════════════════════════════════
  Start a wire          Left-click a pin.
  Route it              Each further click adds a Manhattan waypoint.
  Finish it             Double-click at the end, or press Enter to
                        commit at the last point.  Ending away from a
                        pin leaves a free end.
  Cancel it             Right-click while drawing, or press Esc.
  Delete a segment      Left-click it, then Delete or Backspace.  A
                        middle segment splits the wire in two.
  Replace a flight line A wire joining two pins removes the flight
                        line and its crossings between them; delete
                        the wire and the flight line comes back.

══════════════════════════════════════════════════════════════════════
8. Flight lines and feedback edges
══════════════════════════════════════════════════════════════════════
  Arrow direction       Right-click a flight line: cycles its arrow
                        auto -> forward -> reverse -> none (drawing
                        only).
  Force feedback        Right-click the same spot twice in a row.  The
                        edge turns red and is left out of the next
                        Place (still drawn).  Twice more forces it back
                        to forward.  Affects only that one segment;
                        survives Forget placement; saved by Save P&R….

══════════════════════════════════════════════════════════════════════
9. Saving and reopening
══════════════════════════════════════════════════════════════════════
  Save P&R…             Writes positions, rotations, mirrors, T's,
                        wires, labels and all net and pin roles.  With
                        nothing placed, it writes the roles only.
  Automatic reopening   Keep the file beside the deck under the
                        suggested name <deck>.<SUBCKT>.pr.json.
  Open P&R…             Load a layout or a roles-only file.
  Start fresh           Launch with -n to skip the automatic load.

  Worth adopting: keep two files.  Save P&R… as <name>_place.pr.json
  (roles + layout), then Forget placement and Save P&R… under the
  default name (roles only).  The default loads by itself and places
  fresh from your roles; Open P&R… the _place file to get your own
  arrangement back.  A saved file needs the KiCad library version it
  was made with.

══════════════════════════════════════════════════════════════════════
10. Toolbar
══════════════════════════════════════════════════════════════════════
  What is shown
    Filter              Show only parts whose ref contains this text.
    Cols                Columns in the unplaced Grid view.
    SUBCKT              Which .SUBCKT (or top level) to display.
  Placing and files
    Open P&R…           Load a saved layout or roles-only file.
    Nets                Set net roles.
    Pre-grouping        Group parts before layering (off by default).
    Sig Topo            Try signal-topological ranking too; keep the
                        better (on by default).
    1 chain/row         Put each signal subchain on its own row.
                        These three take effect at the NEXT Place….
    Place…              Run automatic placement now.
    Forget placement    Discard moves and rotations; keep roles.
    Save P&R…           Save the layout and roles.
    Grid                Show the parts as a plain row-by-row grid.
  Tidying
    Close gaps ↕        Remove tall empty horizontal bands.
    Compact sel         Pull the selected parts together.
    Spread sel          Open space between the selected parts.
  Analysis
    Floating nets       Nets with no DC path to ground.  Click an
                        entry to center on it and paint it red.
    D→R List            Each net's drivers and receivers, as the
                        placer classified them.
    Hidden sense        Nets reached only through a sense inside an
                        E/G VALUE expression (no drawable pin).
  Overlays (drawing only; never change the placement)
    Flights             Dashed pin-to-pin flight lines.
    Boxes               Dotted outline around each cluster.
    BBoxes              Blue box: the space reserved for each part
                        (body, text, T's and their labels).
    Self-X              Magenta ring on a part whose own flight lines
                        cross.
    Group boxes         Red outline and id around each tight group.
    Crossings           Orange ring on every counted crossing.
    Full text           Show VALUE equations in full or truncated
                        (the selected group only, if one exists).
    Help                This dialog.

══════════════════════════════════════════════════════════════════════
11. T-symbol orientations  (right-click cycles; rotation = role)
══════════════════════════════════════════════════════════════════════
  Ground / 0   rot 0     Horizontal bar, stem up, label below.
  +Power       rot 180   Horizontal bar, stem down, label above.
  Input        rot 270   Vertical bar, stem right, label left of bar.
  Output       rot 90    Vertical bar, stem left, label right of bar.

══════════════════════════════════════════════════════════════════════
12. Keyboard
══════════════════════════════════════════════════════════════════════
  Esc                   Cancel the wire, or clear the selection.
  Enter                 Commit the wire at its last point.
  Delete / Backspace    Remove the selected wire segments.

══════════════════════════════════════════════════════════════════════
13. Command line  (sp2Sch.py -h lists every option)
══════════════════════════════════════════════════════════════════════
  -s NAME        Show this .SUBCKT instead of the largest one.
  -n             Do not auto-load the saved .pr.json.
  -v             Run the self-check harness and exit.
  -g             Label each part with its rank.order.
  -p             Run the post-placement fix-up passes.
  -B             Place once per Brandes-Köpf candidate; keep the best.
  --svg PATH     Write a placed-block preview to PATH.
  Run without a .kicad_sym argument: the KiCad library is found
  automatically, and naming one file loads only that file.
"""

    def _reflow_toolbar(self, _event=None):
        """In : the toolbar container's available width.  Out: the items
        flow-packed left to right with line wrapping, Help pinned to the
        right edge of the last row.
        Each item advances the x cursor by winfo_reqwidth() plus a gap
        and wraps to a new row when the next would pass
        (width - help_w - help_pad).  If the last row's final item lies
        within help_w of the right edge, Help gets a fresh row below.
        place() rather than pack or grid, because the handler needs
        absolute positions it can recompute cheaply."""
        flow = self._toolbar_flow
        # winfo_width is reliable here because <Configure> just fired
        # OR after_idle has run; fall back to reqwidth for the very
        # first call before mapping.
        avail = flow.winfo_width()
        if avail <= 1:
            avail = flow.winfo_reqwidth()
        # Update reqwidth/reqheight for every item.
        flow.update_idletasks()
        items = self._toolbar_items
        if not items:
            return
        # Help button reserved space.
        help_w = self._help_btn.winfo_reqwidth() or 50
        help_pad_right = 8
        H_GAP = 8                # gap between items horizontally
        V_GAP = 4                # gap between rows vertically
        # Row height — tallest reqheight among items (one number used
        # for all rows so they stack uniformly).
        row_h = max((it.winfo_reqheight() for it in items),
                    default=24)
        help_h = self._help_btn.winfo_reqheight() or row_h
        row_h = max(row_h, help_h)

        # Available width for items (excluding the Help button's
        # reserved zone on the LAST row only — but we don't know which
        # row is last until we've placed everything.  Solution: pack
        # everything assuming Help takes the full row width.  Then
        # check whether Help fits on the LAST row to the right of the
        # last item.  If yes, place it there.  If no, push it to a new
        # row.
        x = 0
        y = 0
        row_max_x = 0
        item_positions = []      # parallel list of (x, y) per item
        for _i, it in enumerate(items):
            w = it.winfo_reqwidth()
            if x > 0 and x + w > avail:
                # wrap
                y += row_h + V_GAP
                x = 0
            item_positions.append((x, y))
            x += w + H_GAP
            row_max_x = max(row_max_x, x)
        # Apply positions.
        for it, (ix, iy) in zip(items, item_positions):
            it.place(x=ix, y=iy)

        # Position Help.  If the LAST item ends at x <= avail - help_w
        # - help_pad_right, place Help on that row aligned right.
        # Otherwise put Help on a new row.
        last_y = item_positions[-1][1] if item_positions else 0
        last_row_end_x = 0
        for it, (ix, iy) in zip(items, item_positions):
            if iy == last_y:
                last_row_end_x = max(last_row_end_x,
                                      ix + it.winfo_reqwidth())
        if last_row_end_x + H_GAP + help_w + help_pad_right <= avail:
            help_y = last_y
        else:
            help_y = last_y + row_h + V_GAP
        help_x = max(0, avail - help_w - help_pad_right)
        self._help_btn.place(x=help_x, y=help_y)

        # Total height the flow needs.
        total_h = help_y + row_h
        # Tell the flow frame its required height so the canvas below
        # doesn't get clipped.
        flow.configure(height=total_h)
        flow.pack_propagate(False)

    def _effective_fulltext(self, ref):
        """Return whether instance `ref` should render its
        VALUE equation in full or truncated.  Per-instance overrides
        take precedence over the global self.fulltext (which is True
        by default in rev 49b but the user may have flipped it via the
        toolbar with no selection)."""
        if ref in self._fulltext_overrides:
            return self._fulltext_overrides[ref]
        return self.fulltext

    def _toggle_fulltext(self):
        """In : the toolbar 'Full text' button and the current selection.
        Out: VALUE-equation truncation flipped, with per-instance
        overrides kept in self._fulltext_overrides by ref.
        With NO selection, self.fulltext flips globally and every
        per-instance override is cleared, so the flip is uniform.  With a
        group selected, each ref's EFFECTIVE setting flips — an override
        appears where there was none, or an existing one inverts — and
        instances outside the group are untouched."""
        if self._selected_group:
            for ref in self._selected_group:
                cur = self._effective_fulltext(ref)
                self._fulltext_overrides[ref] = not cur
            n = len(self._selected_group)
            self.status.config(
                text=f'Toggled VALUE display on {n} selected '
                      f'instance{"s" if n != 1 else ""}')
        else:
            self.fulltext = not self.fulltext
            self._fulltext_overrides.clear()
            state = 'FULL' if self.fulltext else 'truncated'
            self.status.config(
                text=f'VALUE equations: all instances → {state}')
        self._render()

    def _show_help_dialog(self):
        """In : _HELP_TEXT and _PROGRAM_REV.  Out: a scrolled modal dialog with
        the user guide, organized by task, then the toolbar and keyboard."""
        dlg = tk.Toplevel(self)
        dlg.title(f'sp2Sch rev {self._PROGRAM_REV} — Help')
        dlg.geometry('700x600')
        dlg.transient(self)
        # Scrolled text widget.
        frame = tk.Frame(dlg)
        frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)
        scroll = tk.Scrollbar(frame, orient=tk.VERTICAL)
        scroll.pack(side=tk.RIGHT, fill=tk.Y)
        txt = tk.Text(frame, wrap=tk.WORD, font=('Courier', 10),
                      yscrollcommand=scroll.set,
                      bg='#fafafa', fg='#222',
                      padx=8, pady=8, relief=tk.FLAT)
        txt.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scroll.config(command=txt.yview)
        txt.insert(tk.END, self._HELP_TEXT.format(rev=self._PROGRAM_REV))
        txt.config(state=tk.DISABLED)
        # Close button.
        btn = tk.Button(dlg, text='Close', command=dlg.destroy,
                        bg='#4a7ab5', fg='white', relief=tk.FLAT,
                        padx=16, font=(FONT_FAMILY, 10, 'bold'))
        btn.pack(pady=(0, 8))
        dlg.bind('<Escape>', lambda _e: dlg.destroy())

    # Floating-nets analyzer: finds nets whose only connections present infinite
    # DC impedance (capacitor terminals, current sources, MOSFET gates).

    # SPICE pin indices (0-based) for the non-DC categories.
    _FLOATING_PIN_INDICES = {
        # MOSFET: D(0) G(1) S(2) [B(3)] — gate is index 1.
        'M': (1,),
        # VCVS / VCCS: n+(0) n-(1) nc+(2) nc-(3) — control inputs are
        # indices 2 and 3.  We DO NOT include the output pair (0, 1),
        # since those carry the source current/voltage at DC.  Note
        # _split_nets_value keeps the 'VALUE' form differently — the
        # behavioral form keeps only 2 pins, so its control nets
        # don't appear in c['nets'] at all and there's nothing to
        # detect from netlist topology.
        'E': (2, 3),
        'G': (2, 3),
    }
    # Whole-component kinds whose every pin is a no-DC-path pin.
    _FLOATING_ALL_PINS_KINDS = frozenset({'C', 'I'})

    def _find_hidden_sense_nets(self):
        """Out: [(net_display_name, [sensing_source_refs],
        drawable_pin_count)], most confusing (fewest pins) first.
        Finds nets reaching a behavioral E/G source ONLY through a
        voltage or current SENSE inside its VALUE expression — the
        arguments of V() and I() — with no drawable pin on that source.
        The connection is real electrically but invisible on the sheet,
        so the net can look under-connected: N31303's only pin is R17 and
        its other end is the VC- sense of X_U30's gain stage, so it
        appears to go nowhere."""
        insts = self._cached_instances or []
        sensed_by = defaultdict(list)      # net_lc -> [source refs]
        pin_count = defaultdict(int)       # net_lc -> drawable pin count
        disp = {}                          # net_lc -> display name
        for inst in insts:
            for nn in (inst.comp.get('nets', []) or []):
                nl = nn.lower()
                pin_count[nl] += 1
                disp.setdefault(nl, nn)
            for sn in (inst.comp.get('sense_nets', []) or []):
                nl = sn.lower()
                sensed_by[nl].append(inst.comp['ref'])
                disp.setdefault(nl, sn)
        # Exclude power/ground — a sensed rail is not a surprise.
        out = []
        for nl, refs in sensed_by.items():
            if nl in _PWR_NETS_LC_FOR_T:
                continue
            out.append((disp.get(nl, nl), sorted(set(refs)),
                        pin_count.get(nl, 0)))
        out.sort(key=lambda t: (t[2], t[0].lower()))
        return out

    def _find_floating_nets(self):
        """Return the sorted display names of nets with no DC path: every pin is
        a capacitor terminal, current-source terminal or MOSFET gate.
        """
        info = {}

        for comp in self.drawable:
            nets = comp.get('nets', []) or []
            floating_indices = self._floating_pin_indices_for(comp)
            for pin_idx, net in enumerate(nets):
                if not net:
                    continue
                net_lc = net.lower()
                if net_lc in _PWR_NETS_LC_FOR_T:
                    continue  # power/ground — skip
                entry = info.get(net_lc)
                if entry is None:
                    entry = {'all_floating': True,
                             'display_name': net,
                             'count': 0}
                    info[net_lc] = entry
                entry['count'] += 1
                if pin_idx not in floating_indices:
                    # This pin presents a DC path.  Mark net non-floating.
                    entry['all_floating'] = False

        flagged = [v['display_name'] for v in info.values()
                   if v['all_floating'] and v['count'] >= 1]


        flagged.sort(key=lambda s: s.lower())
        return flagged

    def _floating_pin_indices_for(self, comp):
        """In : a comp dict.  Out: the frozenset of its pin indices that
        count as no-DC-path pins; empty means every pin presents a DC
        path, so the component never flags anything.
        C and I have every pin no-DC-path, so all indices come back.  M
        and a non-VALUE E/G return the documented indices.  An E/G in the
        VALUE form still has kind 'E' or 'G' but sym 'EVALUE'/'GVALUE'
        and only 2 recorded pins — the source output pair, which carries
        current — so it returns an empty set and flags no net."""
        kind = comp.get('kind', '')
        sym = comp.get('sym', '')
        nets = comp.get('nets', []) or []
        if kind in self._FLOATING_ALL_PINS_KINDS:
            return frozenset(range(len(nets)))
        if kind in ('E', 'G'):
            # VALUE-form E/G sources: only 2 pins, both output.  No
            # floating-pin contribution.
            if sym in ('EVALUE', 'GVALUE'):
                return frozenset()
            # Standard 4-pin E/G — pins 2 and 3 are control inputs.
            return frozenset(
                idx for idx in self._FLOATING_PIN_INDICES.get(kind, ())
                if idx < len(nets))
        if kind == 'M':
            # Standard MOSFET: gate is pin index 1.  Some decks use
            # the W=…/L=… 'NMOS' syntax with the bulk omitted, leaving
            # 3 pins (D G S).  Either way pin 1 is the gate.
            return frozenset(
                idx for idx in self._FLOATING_PIN_INDICES.get(kind, ())
                if idx < len(nets))
        return frozenset()

    # ── Net-port control dialog ────────────────────────
    #
    # Lets the user choose which nets act as ports (cluster boundaries
    # rendered as T-symbols).  Defaults = auto-detection (power/ground
    # ∪ top-level IO ∪ fanout>=20).  User toggles become overrides in
    # self._port_force_on / self._port_force_off, applied by
    # _cluster_cut_nets and the rail bookkeeping in _run_placement.

    _NET_ROLE_ROT = {'input': 270, 'output': 90, '+power': 180, 'ground': 0,
                     '-power': 0}
    _NET_ROLE_CYCLE = ('auto', 'input', 'output', '+power', 'ground',
                       '-power')

    def _net_role_of(self, nl):
        """In : a lower-case net name.  Out: its current Role as the Nets
        dialog shows it — auto, input, output, +power, ground or -power.
        Polarity is checked FIRST.  _set_net_role always writes the rot
        and polarity overrides together for +power/ground/-power, so
        either alone would do, but polarity is the more specific signal:
        a bare rot of 0 or 180 from an OLDER override, written before
        this feature existed, only ever meant top or bottom placement.
        -power shares ground's '-' polarity AND rotation, so
        _neg_power_nets is the only thing telling the two labels apart."""
        pol = (getattr(self, '_rail_polarity_overrides', None) or {}).get(nl)
        if pol == '+':
            return '+power'
        if pol == '-':
            neg = getattr(self, '_neg_power_nets', None) or set()
            return '-power' if nl in neg else 'ground'
        rot = (getattr(self, '_t_net_rot_overrides', None) or {}).get(nl)
        if rot == 90:
            return 'output'
        if rot == 270:
            return 'input'
        return 'auto'

    def _build_net_disp_table(self):
        """Build one case-preserving display spelling per net, whenever
        self.drawable is rebuilt.
        """
        table = {}
        for comp in (self.drawable or []):
            for n in (comp.get('nets') or []):
                if n:
                    table.setdefault(n.lower(), n)
            for n in (comp.get('sense_nets') or []):
                if n:
                    table.setdefault(n.lower(), n)
        self._net_disp_name = table

    def _disp_net(self, net_lc):
        """Case-preserving DISPLAY form of a
        lower-case net key, for any text the user actually reads
        (canvas labels, dialog rows).  Falls back to net_lc itself if
        the net isn't in the table (e.g. called before the first
        circuit load, or for a synthetic/internal key that was never a
        real parsed net) — never raises, and never changes anything
        besides the displayed string."""
        return getattr(self, '_net_disp_name', {}).get(net_lc, net_lc)

    def _net_inventory(self):
        """Out: one dict per net —
            net     lower-case net name
            count   number of DISTINCT instances touching it
            io      True for a top-level circuit input or output
            is_cut  True when currently CUT (a cluster boundary)
            is_port True when currently a PORT (drawn as a T-symbol)
            role    auto | input | output | +power | ground | -power
            conns   [('REF.pin', driver|receiver|notSet), ...] sorted by
                    ref then pin, feeding the Nets dialog's child rows.
        Cut and port are independent."""
        insts = self._cached_instances or []
        net_to_ids = {}
        net_conns = defaultdict(list)
        in_nets, out_nets = self._subckt_io_nets()
        role_map = self._compute_pin_role_map(
            insts, set(in_nets) | set(out_nets), self._promoted_rails)
        for inst in insts:
            seen = set()
            ref = inst.comp['ref']
            cid = id(inst.comp)
            for nn in inst.comp.get('nets', []) or []:
                nl = nn.lower()
                if nl in seen:
                    continue
                seen.add(nl)
                net_to_ids.setdefault(nl, set()).add(id(inst))
            for idx, (pn, nn) in enumerate(inst._pin_net_pairs or []):
                r = role_map.get((cid, idx))
                label = ('driver' if r == 'out' else
                         'receiver' if r == 'in' else 'notSet')
                net_conns[nn.lower()].append((f'{ref}.{pn}', label))
        # A declared .SUBCKT port can be a real input even if no component pin
        # uses it directly (LP2951's SHUTDOWN, FEEDBACK); find it through sense
        # lines.
        sense_only_conns = defaultdict(list)
        sense_only_ids = defaultdict(set)
        active = (self._active_subckt or '').upper()
        declared_ports = set()
        if self._parser and active in (self._parser.subckts or {}):
            declared_ports = {p.lower() for p in
                              self._parser.subckts[active].get('ports', [])}
        for nl in (declared_ports - set(net_to_ids)):
            for inst in insts:
                ref = inst.comp['ref']
                if nl in [str(s).lower() for s in
                         (inst.comp.get('sense_nets') or [])]:
                    sense_only_ids[nl].add(id(inst))
                    sense_only_conns[nl].append((f'{ref} (sense)',
                                                 'receiver'))
        for nl in sense_only_conns:
            sense_only_conns[nl].sort(key=lambda t: t[0])
        _rp_key = cmp_to_key(_net_name_compare)
        for nl in net_conns:
            net_conns[nl].sort(key=lambda t: _rp_key(t[0]))

        auto = self._auto_port_cut_nets(in_nets, out_nets)
        eff_cut = (auto | self._cut_force_on) - self._cut_force_off
        eff_port = (auto | self._port_force_on) - self._port_force_off

        rows = []
        for nl, ids in net_to_ids.items():
            rows.append({
                'net': nl,
                'count': len(ids),
                'io': self._is_toplevel_io_net(nl),
                'is_cut': nl in eff_cut,
                'is_port': nl in eff_port,
                'role': self._net_role_of(nl),
                'conns': net_conns.get(nl, []),
            })
        for nl, ids in sense_only_ids.items():
            rows.append({
                'net': nl,
                'count': len(ids),
                'io': self._is_toplevel_io_net(nl),
                'is_cut': nl in eff_cut,
                'is_port': nl in eff_port,
                'role': self._net_role_of(nl),
                'conns': sense_only_conns.get(nl, []),
            })
        return rows


    def _show_nets_dialog(self):
        """Open (or re-focus) the non-modal net Port/Cut/Role control
        dialog (an earlier revision — Cut/Port independent; an earlier revision,
        user — added Role)."""
        if self._nets_dlg is not None and self._nets_dlg.winfo_exists():
            self._nets_dlg.lift()
            self._refresh_nets_dialog()
            return

        # Snapshot everything Cancel can revert.
        self._nets_saved = (set(self._cut_force_on),
                            set(self._cut_force_off),
                            set(self._port_force_on),
                            set(self._port_force_off),
                            dict(self._t_net_rot_overrides),
                            dict(self._rail_polarity_overrides))

        dlg = tk.Toplevel(self)
        dlg.title(f'sp2Sch rev {self._PROGRAM_REV} — Net Port/Cut/Role')
        dlg.geometry('620x610')
        self._nets_dlg = dlg

        hdr = tk.Label(
            dlg, anchor=tk.W, justify=tk.LEFT, padx=8, pady=6,
            text=('Two independent net properties (a Cut net is always a\n'
                  'Port too):  PORT = drawn as a T-symbol, not point-to-\n'
                  'point lines.  CUT = also breaks the circuit into\n'
                  'separate clusters.  Click a Cut or Port cell to toggle.\n'
                  'ROLE sets every T-symbol on that net at once — input\n'
                  '(left), output (right), +power (top), or ground/-power\n'
                  '(bottom) — and rotates them immediately.  +power/ground\n'
                  'also make the next Place treat this net as a real rail\n'
                  'when orienting nearby parts.  Click a Role cell to\n'
                  'cycle auto → input → output → +power → ground →\n'
                  '-power → auto (-power looks/behaves just like\n'
                  'ground, only the label differs).\n'
                  'Click the + to expand a net and see every ref.pin on\n'
                  'it with its driver/receiver/notSet role (read-only).\n'
                  'Blue = top-level I/O.  Click Net or # conn to sort.'),
            font=(FONT_FAMILY, 9), bg='#f4f4f4', fg='#333')
        hdr.pack(fill=tk.X)

        tree_frame = tk.Frame(dlg)
        tree_frame.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6))
        scroll = tk.Scrollbar(tree_frame, orient=tk.VERTICAL)
        scroll.pack(side=tk.RIGHT, fill=tk.Y)
        tree = ttk.Treeview(
            tree_frame, columns=('cut', 'port', 'role', 'count'),
            show='tree headings',
            yscrollcommand=scroll.set, selectmode='none')
        tree.heading('#0', text='Net',
                      command=lambda: self._sort_nets_dialog('name'))
        tree.heading('cut', text='Cut')
        tree.heading('port', text='Port')
        tree.heading('role', text='Role')
        tree.heading('count', text='# conn',
                      command=lambda: self._sort_nets_dialog('count'))
        tree.column('#0', width=200, anchor=tk.W)
        tree.column('cut', width=45, anchor=tk.CENTER)
        tree.column('port', width=45, anchor=tk.CENTER)
        tree.column('role', width=90, anchor=tk.CENTER)
        tree.column('count', width=70, anchor=tk.E)
        tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scroll.config(command=tree.yview)
        tree.tag_configure('io', foreground='#0066ff')
        tree.tag_configure('internal', foreground='#553388')
        tree.tag_configure('conn', foreground='#666666')
        tree.bind('<ButtonRelease-1>', self._on_nets_tree_click)
        self._nets_tree = tree
        self._nets_sort_key = 'name'   # Was 'count'

        btn_row = tk.Frame(dlg)
        btn_row.pack(fill=tk.X, padx=6, pady=(0, 8))
        tk.Button(btn_row, text='Cancel', command=self._nets_cancel,
                   bg='#6a6a6a', fg='white', relief=tk.FLAT,
                   padx=12, font=(FONT_FAMILY, 10)).pack(side=tk.LEFT)
        tk.Button(btn_row, text='Place', command=self._nets_place,
                   bg='#5a8a3a', fg='white', relief=tk.FLAT, padx=14,
                   font=(FONT_FAMILY, 10, 'bold')).pack(side=tk.RIGHT)
        tk.Button(btn_row, text='OK', command=self._nets_ok,
                   bg='#4a7ab5', fg='white', relief=tk.FLAT, padx=12,
                   font=(FONT_FAMILY, 10)).pack(side=tk.RIGHT, padx=(0, 8))

        dlg.protocol('WM_DELETE_WINDOW', self._nets_cancel)
        self._refresh_nets_dialog()

    def _refresh_nets_dialog(self):
        """Rebuild the Treeview rows from a fresh inventory."""
        if self._nets_dlg is None or not self._nets_dlg.winfo_exists():
            return
        tree = self._nets_tree
        if tree is None:
            return
        rows = self._net_inventory()
        key = getattr(self, '_nets_sort_key', 'count')
        name_key = cmp_to_key(_net_name_compare)
        if key == 'name':
            rows.sort(key=lambda r: name_key(r['net']))
        else:
            rows.sort(key=lambda r: (-r['count'], name_key(r['net'])))
        # Remember which nets were expanded so a
        # rebuild (Cut/Port/Role toggle elsewhere in the tree) doesn't
        # silently re-collapse everything the person had open.
        was_open = {iid for iid in tree.get_children()
                   if tree.item(iid, 'open')}
        tree.delete(*tree.get_children())
        for r in rows:
            cut_mark = '☑' if r['is_cut'] else '☐'
            port_mark = '☑' if r['is_port'] else '☐'
            tag = 'io' if r['io'] else 'internal'
            tree.insert('', tk.END, iid=r['net'], text=self._disp_net(r['net']),
                        values=(cut_mark, port_mark, r['role'], r['count']),
                        tags=(tag,), open=(r['net'] in was_open))
            for tag_ref_pin, role_label in r['conns']:
                tree.insert(r['net'], tk.END,
                            iid=f"{r['net']}\uE000{tag_ref_pin}",
                            text=tag_ref_pin,
                            values=('', '', role_label, ''),
                            tags=('conn',))

    def _sort_nets_dialog(self, key):
        self._nets_sort_key = key
        self._refresh_nets_dialog()

    def _cycle_net_role(self, nl):
        """Advance net `nl` to the next Role in _NET_ROLE_CYCLE and
        apply it.  Called by a Role-column click, same click-to-advance
        interaction as the existing Cut/Port toggle cells."""
        cur = self._net_role_of(nl)
        cyc = self._NET_ROLE_CYCLE
        nxt = cyc[(cyc.index(cur) + 1) % len(cyc)] if cur in cyc else cyc[0]
        self._set_net_role(nl, nxt)

    def _set_net_role(self, nl, role):
        """Set net `nl`'s role from the Nets dialog ('auto', 'input', 'output',
        '+power', 'ground' or '-power'), updating every T-symbol on the net.
        """
        self._t_net_rot_overrides.pop(nl, None)
        self._rail_polarity_overrides.pop(nl, None)
        self._neg_power_nets.discard(nl)
        if role in self._NET_ROLE_ROT:
            new_rot = self._NET_ROLE_ROT[role]
            self._t_net_rot_overrides[nl] = new_rot
            if role == '+power':
                self._rail_polarity_overrides[nl] = '+'
            elif role == 'ground':
                self._rail_polarity_overrides[nl] = '-'
            elif role == '-power':
                self._rail_polarity_overrides[nl] = '-'
                self._neg_power_nets.add(nl)
            for t in self._t_terminals:
                if t['net'].lower() == nl:
                    t['rot'] = new_rot
            # Full re-render (see _on_canvas_right's T-rotate branch
            # and _try_cycle_arrow_under for the same choice): a
            # partial canvas.delete('t_term'/'flight_line') +
            # _draw_all_t_terminals() + _draw_t_flight_lines(...) pair
            # only rebuilds T-terminals and T-routed lines, silently
            # dropping any ordinary or sense/control flight line
            # elsewhere on the canvas until the next full _render().
            # Called once per dialog action, not per-frame, so the
            # full redraw costs nothing noticeable here.
            self._render()
        # role == 'auto': overrides already cleared above; on-canvas
        # rotation intentionally left as-is (see docstring).

    def _toggle_net_property(self, nl, prop):
        """Toggle CUT or PORT for net `nl`, expressed as an override
        relative to auto-detection.  Enforces the
        invariant CUT ⟹ PORT (a cut net is always drawn as a T-symbol;
        the contradictory 'cut but flight-lines' state is disallowed
        because a cut net's endpoints live in different clusters that
        may be far apart, so a point-to-point line would span the
        page — exactly what T-symbols exist to avoid).  Three reachable
        states: (Port✗Cut✗) ordinary, (Port✓Cut✗) T-symbol only,
        (Port✓Cut✓) T-symbol + cluster split."""
        in_nets, out_nets = self._subckt_io_nets()
        auto = self._auto_port_cut_nets(in_nets, out_nets)

        def cur(force_on, force_off):
            return nl in ((auto | force_on) - force_off)

        def setval(force_on, force_off, want):
            if want:
                force_off.discard(nl)
                if nl not in auto:
                    force_on.add(nl)
            else:
                force_on.discard(nl)
                if nl in auto:
                    force_off.add(nl)

        is_cut = cur(self._cut_force_on, self._cut_force_off)
        is_port = cur(self._port_force_on, self._port_force_off)

        if prop == 'cut':
            new_cut = not is_cut
            setval(self._cut_force_on, self._cut_force_off, new_cut)
            # CUT ⟹ PORT: turning cut ON forces port ON.
            if new_cut and not is_port:
                setval(self._port_force_on, self._port_force_off, True)
        else:   # prop == 'port'
            new_port = not is_port
            setval(self._port_force_on, self._port_force_off, new_port)
            # CUT ⟹ PORT: turning port OFF forces cut OFF.
            if not new_port and is_cut:
                setval(self._cut_force_on, self._cut_force_off, False)

    def _on_nets_tree_click(self, event):
        """Toggle Cut/Port or cycle Role depending on which column cell
        was clicked.  A click on the Net name or # conn column toggles
        Cut (the granularity knob the user adjusts most)."""
        tree = self._nets_tree
        if tree is None:
            return
        nl = tree.identify_row(event.y)
        if not nl:
            return
        if '\uE000' in nl:
            return   # a ref.pin connection child row — informational only
        if tree.identify_element(event.x, event.y) == 'Treeitem.indicator':
            return   # the expand/collapse triangle — not a property toggle
        col = tree.identify_column(event.x)
        # columns: #0=Net, #1=cut, #2=port, #3=role, #4=count
        if col == '#2':
            self._toggle_net_property(nl, 'port')
        elif col == '#3':
            self._cycle_net_role(nl)
        else:
            # Net name, Cut column, or # conn → toggle Cut.
            self._toggle_net_property(nl, 'cut')
        self._refresh_nets_dialog()

    def _nets_cancel(self):
        """Revert all overrides (Cut/Port sets and Role dicts) to the
        open-time snapshot.  The Role dicts are
        reverted the same way; the LIVE on-canvas T rotation from any
        Role change made in this dialog session is deliberately left as
        drawn rather than reconstructed (see _set_net_role's docstring —
        'auto' has no cheap way to recompute the pre-existing rotation
        without a full Place, so Cancel doesn't attempt it either)."""
        if self._nets_saved is not None:
            (self._cut_force_on, self._cut_force_off,
             self._port_force_on, self._port_force_off,
             t_rot, rail_pol) = (
                set(self._nets_saved[0]), set(self._nets_saved[1]),
                set(self._nets_saved[2]), set(self._nets_saved[3]),
                dict(self._nets_saved[4]), dict(self._nets_saved[5]))
            self._t_net_rot_overrides = t_rot
            self._rail_polarity_overrides = rail_pol
        self._close_nets_dialog()

    def _nets_ok(self):
        """Keep the current overrides (already live) but do NOT
        re-place.  Just close."""
        self._close_nets_dialog()

    def _nets_place(self):
        """Keep the current overrides and run Place."""
        self._close_nets_dialog()
        self._run_placement()

    def _close_nets_dialog(self):
        if self._nets_dlg is not None and self._nets_dlg.winfo_exists():
            self._nets_dlg.destroy()
        self._nets_dlg = None
        self._nets_tree = None

    def _show_floating_nets_dialog(self):
        """Open (or re-focus) the non-modal Floating-nets dialog."""
        if self._floating_dlg is not None and self._floating_dlg.winfo_exists():
            # Already open — just raise it and refresh.
            self._floating_dlg.lift()
            self._refresh_floating_dialog()
            return

        dlg = tk.Toplevel(self)
        dlg.title(f'sp2Sch rev {self._PROGRAM_REV} — Floating nets')
        dlg.geometry('360x420')
        # Intentionally NOT transient — the user needs to see the
        # canvas underneath, and the dialog must be draggable
        # independently.  Also non-modal — interaction with the main
        # window remains live.
        self._floating_dlg = dlg

        # ── Header label.
        hdr = tk.Label(dlg, anchor=tk.W, justify=tk.LEFT, padx=6, pady=6,
                       text='Nets whose only connections are to capacitors,\n'
                            'current sources, MOSFET gates, and the voltage-\n'
                            'control inputs of E/G sources.\n'
                            'Click a net to centre the view and highlight\n'
                            'it in red.  Click again to clear.',
                       font=(FONT_FAMILY, 9), bg='#f4f4f4', fg='#333')
        hdr.pack(fill=tk.X)

        # ── Scrolled listbox.
        list_frame = tk.Frame(dlg)
        list_frame.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6))
        scroll = tk.Scrollbar(list_frame, orient=tk.VERTICAL)
        scroll.pack(side=tk.RIGHT, fill=tk.Y)
        # selectmode=MULTIPLE so several nets can be highlighted at once;
        # we drive selection/deselection explicitly from the click
        # binding so each click is a toggle.
        lb = tk.Listbox(list_frame, selectmode=tk.MULTIPLE,
                        font=(FONT_FAMILY, 10),
                        yscrollcommand=scroll.set,
                        activestyle='dotbox',
                        exportselection=False)
        lb.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scroll.config(command=lb.yview)
        # Custom click handler so we can toggle and trigger the
        # centre-and-highlight side effect.  We bind on ButtonRelease
        # so Tk has already updated the listbox's hit-test result;
        # nearest(y) then gives the index the user clicked.
        lb.bind('<ButtonRelease-1>', self._on_floating_listbox_click)
        self._floating_listbox = lb

        # ── Button row at the bottom.
        btn_row = tk.Frame(dlg)
        btn_row.pack(fill=tk.X, padx=6, pady=(0, 8))
        tk.Button(btn_row, text='Clear All',
                   command=self._clear_floating_highlights,
                   bg='#aa6600', fg='white', relief=tk.FLAT,
                   padx=10, font=(FONT_FAMILY, 10)).pack(side=tk.LEFT)
        tk.Button(btn_row, text='Refresh',
                   command=self._refresh_floating_dialog,
                   bg='#4a7ab5', fg='white', relief=tk.FLAT,
                   padx=10, font=(FONT_FAMILY, 10)).pack(side=tk.LEFT,
                                                          padx=(8, 0))
        tk.Button(btn_row, text='Close',
                   command=self._close_floating_dialog,
                   bg='#6a6a6a', fg='white', relief=tk.FLAT,
                   padx=10, font=(FONT_FAMILY, 10)).pack(side=tk.RIGHT)

        # Track destruction (user clicking the WM close button) so we
        # don't leave a dangling Toplevel reference.  We do NOT clear
        # the highlight set on close — the spec says highlights
        # persist "until cleared in the dialog box or until the next
        # Place operation".
        dlg.protocol('WM_DELETE_WINDOW', self._close_floating_dialog)

        self._refresh_floating_dialog()

    def _refresh_floating_dialog(self):
        """Rebuild the listbox contents from a fresh _find_floating_nets
        scan, preserving the existing highlight selection where the
        net names still appear in the new list."""
        if self._floating_dlg is None or not self._floating_dlg.winfo_exists():
            return
        lb = self._floating_listbox
        if lb is None:
            return
        flagged = self._find_floating_nets()
        self._floating_nets_display = flagged
        lb.delete(0, tk.END)
        if not flagged:
            lb.insert(tk.END, '(no floating nets detected)')
            # Disable selection in the placeholder case — listbox row 0
            # is the placeholder, not a real net.
            lb.itemconfig(0, fg='#888')
        else:
            for name in flagged:
                lb.insert(tk.END, name)
            # Re-select rows whose net is still highlighted.
            for i, name in enumerate(flagged):
                if name.lower() in self._highlighted_nets:
                    lb.selection_set(i)

    def _on_floating_listbox_click(self, event):
        """Toggle the clicked net's highlight, centre the view on it."""
        lb = self._floating_listbox
        if lb is None or not self._floating_nets_display:
            return
        # nearest(y) gives the index closest to the y coordinate of the
        # click within the listbox widget.
        idx = lb.nearest(event.y)
        if idx < 0 or idx >= len(self._floating_nets_display):
            return
        net = self._floating_nets_display[idx]
        net_lc = net.lower()
        # Tk's default behavior for selectmode=MULTIPLE on a click is
        # to add the row to the selection.  We override: a click on a
        # row whose net is already in the highlight set REMOVES it
        # (and clears the listbox selection for that row).  A click on
        # a row whose net is not yet highlighted ADDS it (and sets the
        # listbox selection).
        if net_lc in self._highlighted_nets:
            self._highlighted_nets.discard(net_lc)
            lb.selection_clear(idx)
            # Toggle-off: re-render to restore the default flight-line
            # and label colours, since itemconfig can't recover the
            # per-net default fill on its own.
            self._render()
        else:
            self._highlighted_nets.add(net_lc)
            lb.selection_set(idx)
            self._apply_net_highlight()
            self._centre_view_on_net(net_lc)

    def _clear_floating_highlights(self):
        """Clear the entire red-highlight set and refresh the dialog."""
        if not self._highlighted_nets:
            return
        self._highlighted_nets = set()
        if self._floating_listbox is not None:
            self._floating_listbox.selection_clear(0, tk.END)
        # Full re-render so the default fills come back.  itemconfig
        # alone can't recover them — the original colour varies by
        # net category and isn't stored anywhere we can look up.
        self._render()

    def _close_floating_dialog(self):
        """Close the dialog but leave any existing highlights in place
        (per the spec: highlights persist across dialog close)."""
        if self._floating_dlg is not None and self._floating_dlg.winfo_exists():
            self._floating_dlg.destroy()
        self._floating_dlg = None
        self._floating_listbox = None

    def _build_driver_receiver_lines(self):
        """One line per net, 'net driver.pin,...->receiver.pin,...
        unknown.pin,...', from _compute_pin_role_map (the D->R List).
        """
        instances = self._placed_instances or []
        if not instances:
            return []
        in_nets, out_nets = self._subckt_io_nets()
        role = self._compute_pin_role_map(
            instances, set(in_nets) | set(out_nets), self._promoted_rails)

        nets = {}   # net_lc -> dict
        for inst in instances:
            ref = inst.comp['ref']
            cid = id(inst.comp)
            cx = inst.ox_px
            for idx, (pn, nn) in enumerate(inst._pin_net_pairs or []):
                nl = str(nn).lower()
                rec = nets.get(nl)
                if rec is None:
                    rec = {'display': str(nn), 'drv': [], 'rcv': [],
                           'unk': [], 'drv_xs': [], 'all_xs': []}
                    nets[nl] = rec
                tag = f'{ref}.{pn}'
                rec['all_xs'].append(cx)
                r = role.get((cid, idx))
                if r == 'out':
                    rec['drv'].append(tag)
                    rec['drv_xs'].append(cx)
                elif r == 'in':
                    rec['rcv'].append(tag)
                else:
                    rec['unk'].append(tag)

        def sort_key(item):
            nl, rec = item
            if rec['drv_xs']:
                return (0, sum(rec['drv_xs']) / len(rec['drv_xs']), nl)
            if rec['all_xs']:
                return (1, sum(rec['all_xs']) / len(rec['all_xs']), nl)
            return (2, 0.0, nl)

        lines = []
        for nl, rec in sorted(nets.items(), key=sort_key):
            drv = ','.join(sorted(rec['drv']))
            rcv = ','.join(sorted(rec['rcv']))
            line = f"{rec['display']} {drv}->{rcv}"
            if rec['unk']:
                line += f"  {','.join(sorted(rec['unk']))}"
            lines.append(line)
        return lines

    def _show_driver_receiver_dialog(self):
        """Open (or re-focus) the non-modal Driver->Receiver list dialog."""
        if self._dr_dlg is not None and self._dr_dlg.winfo_exists():
            self._dr_dlg.lift()
            self._refresh_driver_receiver_dialog()
            return

        dlg = tk.Toplevel(self)
        dlg.title(f'sp2Sch rev {self._PROGRAM_REV} — Driver→Receiver')
        dlg.geometry('640x520')
        self._dr_dlg = dlg

        hdr = tk.Label(
            dlg, anchor=tk.W, justify=tk.LEFT, padx=6, pady=6,
            text=('One line per net:  netname  drivers->receivers  unknown\n'
                  'Same pin-role classification used for the arrow overlay\n'
                  'and the Sugiyama DAG edges.  Sorted ~left-to-right by\n'
                  "driver X position (nets with no resolved driver last)."),
            font=(FONT_FAMILY, 9), bg='#f4f4f4', fg='#333')
        hdr.pack(fill=tk.X)

        text_frame = tk.Frame(dlg)
        text_frame.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6))
        vscroll = tk.Scrollbar(text_frame, orient=tk.VERTICAL)
        vscroll.pack(side=tk.RIGHT, fill=tk.Y)
        hscroll = tk.Scrollbar(text_frame, orient=tk.HORIZONTAL)
        hscroll.pack(side=tk.BOTTOM, fill=tk.X)
        txt = tk.Text(text_frame, wrap=tk.NONE, font=('Courier', 10),
                      yscrollcommand=vscroll.set, xscrollcommand=hscroll.set,
                      bg='#fafafa', fg='#222', padx=8, pady=8,
                      relief=tk.FLAT)
        txt.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        vscroll.config(command=txt.yview)
        hscroll.config(command=txt.xview)
        self._dr_textbox = txt

        btn_row = tk.Frame(dlg)
        btn_row.pack(fill=tk.X, padx=6, pady=(0, 8))
        tk.Button(btn_row, text='Refresh',
                   command=self._refresh_driver_receiver_dialog,
                   bg='#4a7ab5', fg='white', relief=tk.FLAT,
                   padx=10, font=(FONT_FAMILY, 10)).pack(side=tk.LEFT)
        tk.Button(btn_row, text='Save…',
                   command=self._save_driver_receiver_report,
                   bg='#5a8a3a', fg='white', relief=tk.FLAT,
                   padx=10, font=(FONT_FAMILY, 10)).pack(side=tk.LEFT,
                                                          padx=(8, 0))
        tk.Button(btn_row, text='Close',
                   command=self._close_driver_receiver_dialog,
                   bg='#6a6a6a', fg='white', relief=tk.FLAT,
                   padx=10, font=(FONT_FAMILY, 10)).pack(side=tk.RIGHT)

        dlg.protocol('WM_DELETE_WINDOW', self._close_driver_receiver_dialog)
        self._refresh_driver_receiver_dialog()

    def _refresh_driver_receiver_dialog(self):
        """Rebuild the report lines and repopulate the text box."""
        if self._dr_dlg is None or not self._dr_dlg.winfo_exists():
            return
        txt = getattr(self, '_dr_textbox', None)
        if txt is None:
            return
        lines = self._build_driver_receiver_lines()
        self._dr_lines = lines
        txt.config(state=tk.NORMAL)
        txt.delete('1.0', tk.END)
        if lines:
            txt.insert(tk.END, '\n'.join(lines) + '\n')
        else:
            txt.insert(tk.END, '(no placed instances — run Place first)\n')
        txt.config(state=tk.DISABLED)

    def _save_driver_receiver_report(self):
        """Save the CURRENTLY DISPLAYED report lines to a text file."""
        if not self._dr_lines:
            self._refresh_driver_receiver_dialog()
        if not self._dr_lines:
            return
        path = filedialog.asksaveasfilename(
            title='Save Driver→Receiver list',
            defaultextension='.txt',
            filetypes=[('Text file', '*.txt'), ('All files', '*.*')])
        if not path:
            return
        header = (
            '# Driver->Receiver list  (netname  drivers->receivers  unknown)\n'
            f'# sp2Sch rev {self._PROGRAM_REV}\n'
            f'# {self.title()}\n')
        with open(path, 'w', encoding='utf-8') as f:
            f.write(header)
            f.write('\n'.join(self._dr_lines) + '\n')
        self.status.config(text=f'Saved {len(self._dr_lines)} nets to {path}')

    def _close_driver_receiver_dialog(self):
        if self._dr_dlg is not None and self._dr_dlg.winfo_exists():
            self._dr_dlg.destroy()
        self._dr_dlg = None
        self._dr_textbox = None

    def _show_hidden_sense_dialog(self):
        """Open (or re-focus) the Hidden VALUE-sense net report."""
        if (self._hidden_sense_dlg is not None
                and self._hidden_sense_dlg.winfo_exists()):
            self._hidden_sense_dlg.lift()
            self._refresh_hidden_sense_dialog()
            return

        dlg = tk.Toplevel(self)
        dlg.title(f'sp2Sch rev {self._PROGRAM_REV} — Hidden sense')
        dlg.geometry('460x440')
        self._hidden_sense_dlg = dlg

        hdr = tk.Label(
            dlg, anchor=tk.W, justify=tk.LEFT, padx=6, pady=6,
            text=('Nets that drive a behavioral E/G source through a\n'
                  'sense inside its VALUE expression — V(...) or I(...) —\n'
                  'with NO drawable pin on that source.  The connection\n'
                  'is real but invisible, so the net can look under-\n'
                  'connected (e.g. R17 / N31303).  "pins" counts drawable\n'
                  'pins; pins=1 with a sense is the classic "goes\n'
                  'nowhere" case.  Click a net to centre + highlight it.'),
            font=(FONT_FAMILY, 9), bg='#f4f4f4', fg='#333')
        hdr.pack(fill=tk.X)

        list_frame = tk.Frame(dlg)
        list_frame.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6))
        scroll = tk.Scrollbar(list_frame, orient=tk.VERTICAL)
        scroll.pack(side=tk.RIGHT, fill=tk.Y)
        lb = tk.Listbox(list_frame, selectmode=tk.MULTIPLE,
                        font=(FONT_FAMILY, 10),
                        yscrollcommand=scroll.set,
                        activestyle='dotbox', exportselection=False)
        lb.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scroll.config(command=lb.yview)
        lb.bind('<ButtonRelease-1>', self._on_hidden_sense_click)
        self._hidden_sense_listbox = lb

        btn_row = tk.Frame(dlg)
        btn_row.pack(fill=tk.X, padx=6, pady=(0, 8))
        tk.Button(btn_row, text='Clear All',
                   command=self._clear_floating_highlights,
                   bg='#0a7a8a', fg='white', relief=tk.FLAT,
                   padx=10, font=(FONT_FAMILY, 10)).pack(side=tk.LEFT)
        tk.Button(btn_row, text='Refresh',
                   command=self._refresh_hidden_sense_dialog,
                   bg='#4a7ab5', fg='white', relief=tk.FLAT,
                   padx=10, font=(FONT_FAMILY, 10)).pack(side=tk.LEFT,
                                                          padx=(8, 0))
        tk.Button(btn_row, text='Close',
                   command=self._close_hidden_sense_dialog,
                   bg='#6a6a6a', fg='white', relief=tk.FLAT,
                   padx=10, font=(FONT_FAMILY, 10)).pack(side=tk.RIGHT)

        dlg.protocol('WM_DELETE_WINDOW', self._close_hidden_sense_dialog)
        self._refresh_hidden_sense_dialog()

    def _refresh_hidden_sense_dialog(self):
        """Rebuild the hidden-sense listbox from a fresh scan."""
        if (self._hidden_sense_dlg is None
                or not self._hidden_sense_dlg.winfo_exists()):
            return
        lb = self._hidden_sense_listbox
        if lb is None:
            return
        rows = self._find_hidden_sense_nets()
        self._hidden_sense_display = [r[0] for r in rows]
        lb.delete(0, tk.END)
        if not rows:
            lb.insert(tk.END, '(no hidden-sense nets detected)')
            return
        for name, refs, pc in rows:
            srcs = ', '.join(refs)
            lb.insert(tk.END, f'{name}  (pins={pc})  \u2190 {srcs}')

    def _on_hidden_sense_click(self, event):
        """Toggle the clicked net's highlight + centre on it."""
        lb = self._hidden_sense_listbox
        if lb is None or not self._hidden_sense_display:
            return
        idx = lb.nearest(event.y)
        if idx < 0 or idx >= len(self._hidden_sense_display):
            return
        net_lc = self._hidden_sense_display[idx].lower()
        if net_lc in self._highlighted_nets:
            self._highlighted_nets.discard(net_lc)
            lb.selection_clear(idx)
            self._render()
        else:
            self._highlighted_nets.add(net_lc)
            lb.selection_set(idx)
            self._apply_net_highlight()
            self._centre_view_on_net(net_lc)

    def _close_hidden_sense_dialog(self):
        if (self._hidden_sense_dlg is not None
                and self._hidden_sense_dlg.winfo_exists()):
            self._hidden_sense_dlg.destroy()
        self._hidden_sense_dlg = None
        self._hidden_sense_listbox = None

    _RED_HIGHLIGHT = '#cc0000'

    def _apply_net_highlight(self):
        """In : self._highlighted_nets.  Out: those nets' flight-line
        items and net labels recoloured to _RED_HIGHLIGHT.
        Colours UP only.  Default colours differ by net category, so a
        caller that DROPS a net (toggle-off, Clear All) calls _render()
        to restore them; callers that only add nets need just this."""
        if not self._highlighted_nets:
            return
        for net_lc in self._highlighted_nets:
            ftag = f'flight_net:{net_lc}'
            ltag = f'net_label:{net_lc}'
            # itemconfig on a tag iterates over all items carrying
            # that tag.  Silent no-op if no items match.
            try:
                self.canvas.itemconfig(ftag, fill=self._RED_HIGHLIGHT)
            except tk.TclError:
                pass
            try:
                self.canvas.itemconfig(ltag, fill=self._RED_HIGHLIGHT)
            except tk.TclError:
                pass

    def _net_bbox_on_canvas(self, net_lc):
        """Return the (x0, y0, x1, y1) bounding box on the canvas of
        every pin attached to `net_lc`, or None if the net has no
        positioned pins.  Used to centre the view on a clicked net."""
        xs, ys = [], []
        instances = self._cached_instances or []
        for inst in instances:
            pn_pairs = getattr(inst, '_pin_net_pairs', None) or []
            for pp, nn in pn_pairs:
                if nn.lower() != net_lc:
                    continue
                pxy = _pin_canvas_pos(inst, pp)
                if pxy is None:
                    continue
                xs.append(pxy[0])
                ys.append(pxy[1])
        if not xs:
            return None
        return (min(xs), min(ys), max(xs), max(ys))

    def _centre_view_on_net(self, net_lc):
        """Pan the canvas so the centroid of the net's pins is in the
        centre of the viewport, clamped so the entire net bbox stays
        visible when possible.  Per spec Q3 option (c): centre on the
        bbox centre, clamped to keep the entire net visible when the
        viewport is large enough."""
        bb = self._net_bbox_on_canvas(net_lc)
        if bb is None:
            return
        x0, y0, x1, y1 = bb
        # Canvas viewport size (visible).
        vw = self.canvas.winfo_width()
        vh = self.canvas.winfo_height()
        if vw <= 1 or vh <= 1:
            # Window not realised yet — try again after idle.
            self.after_idle(lambda: self._centre_view_on_net(net_lc))
            return
        # Scrollregion bounds.
        try:
            sr = self.canvas.cget('scrollregion').split()
            sx0 = float(sr[0]); sy0 = float(sr[1])
            sx1 = float(sr[2]); sy1 = float(sr[3])
        except (ValueError, IndexError):
            sx0 = sy0 = 0.0
            sx1 = max(x1, vw) + 50
            sy1 = max(y1, vh) + 50
        sw = max(1.0, sx1 - sx0)
        sh = max(1.0, sy1 - sy0)

        # Desired top-left of viewport (in canvas coords) that centres
        # the net bbox centre.
        cx = (x0 + x1) / 2.0
        cy = (y0 + y1) / 2.0
        tl_x = cx - vw / 2.0
        tl_y = cy - vh / 2.0

        # Clamp so we keep as much of the net visible as we can.  If
        # the net bbox fits in the viewport, also nudge the viewport
        # so the WHOLE bbox is visible — this matters for nets that
        # span a wide horizontal strip.
        net_w = x1 - x0
        net_h = y1 - y0
        if net_w < vw:
            # Net fits horizontally — clamp top-left so net stays in
            # view AND the centroid is as close to centre as possible.
            tl_x = max(x1 - vw, min(tl_x, x0))
        if net_h < vh:
            tl_y = max(y1 - vh, min(tl_y, y0))

        # Convert top-left into scrollregion fractions and apply.
        frac_x = max(0.0, min(1.0, (tl_x - sx0) / sw))
        frac_y = max(0.0, min(1.0, (tl_y - sy0) / sh))
        self.canvas.xview_moveto(frac_x)
        self.canvas.yview_moveto(frac_y)


# ══════════════════════════════════════════════════════════════════════════════
#  §8  Entry point
# ══════════════════════════════════════════════════════════════════════════════

# Affinity grouping (CLI -m only): a greedy Pass-1 grouper that decides who
# groups with whom, not where parts go.
class _AGNode:
    __slots__ = ('nid', 'members', 'nets')

    def __init__(self, nid, members, nets):
        self.nid = nid
        self.members = list(members)
        self.nets = set(nets)

    def __repr__(self):
        return '{' + '+'.join(self.members) + '}'


def _p2dl_q_bounds(q):
    """RE-style repeat count → (lo, hi); hi=None means unbounded.
    2 → (2,2);  '+' → (1,None);  '*' → (0,None);  (2,5) → (2,5);
    (2,None) → {2,};  (None,4) → {,4}."""
    if isinstance(q, int):
        return q, q
    if q == '+':
        return 1, None
    if q == '*':
        return 0, None
    lo, hi = q
    return (0 if lo is None else int(lo)), (None if hi is None else int(hi))


class _P2DLBinding:
    """What a successful MATCH hands to the ACTs: the flat ref list,
    per-element items ($1, $2, …; a quantified element binds the list of
    its repetitions), and the matched _SPNode when the pattern came from
    the series-parallel decomposition."""
    __slots__ = ('refs', 'items', 'node', 'meta')

    def __init__(self, refs, items=None, node=None, meta=None):
        self.refs = list(refs)
        self.items = items if items is not None else [list(refs)]
        self.node = node
        self.meta = meta            # matcher-specific payload (e.g. the
                                    # role dict from a pattern matcher)


class _P2DLDev:
    """dev('R|C|L', pin='PWR'|'GND', npins=2) — match ONE instance.

    kinds:  '|'-separated SPICE kind letters, '*' = any kind.
    pin:    'PWR'/'GND' — exactly ONE of the first two nets ties to that
            rail set.  XOR on purpose: a part with BOTH pins on rails is
            not "hanging off" a rail and stays in the signal flow,
            matching _apply_flow_orientation's vert test.
    npins:  exact int or (lo, hi) bounds on the net count.
    """

    def __init__(self, kinds, pin=None, npins=None):
        ks = [k.strip().upper() for k in kinds.split('|')]
        self.any_kind = '*' in ks
        self.kinds = set(ks)
        self.pin = pin
        self.npins = npins

    def matches(self, inst, ctx):
        comp = inst.comp
        if not self.any_kind and comp.get('kind', '').upper() not in self.kinds:
            return False
        nets = [n.lower() for n in comp.get('nets', []) or []]
        if self.npins is not None:
            if isinstance(self.npins, int):
                if len(nets) != self.npins:
                    return False
            else:
                lo, hi = _p2dl_q_bounds(self.npins)
                if len(nets) < lo or (hi is not None and len(nets) > hi):
                    return False
        if self.pin is not None:
            if len(nets) < 2:
                return False
            rail = ctx.pwr if self.pin == 'PWR' else ctx.gnd
            if not ((nets[0] in rail) ^ (nets[1] in rail)):
                return False
        return True


    def find(self, ctx):
        for inst in list(ctx.instances):
            ref = inst.comp['ref']
            if ref not in ctx.unconsumed:
                continue
            if self.matches(inst, ctx):
                yield _P2DLBinding([ref], items=[[ref]])


class _P2DLContext:
    """Execution state for one P2DL phase: the instance pool with
    consume-on-match, the rail sets, the cached SP decomposition, and
    the artifact writers.  Acts write through these into the same
    intermediates the legacy placer produces."""

    def __init__(self, app, instances):
        self.app = app
        self.instances = instances
        self.by_ref = {i.comp['ref']: i for i in instances}
        self.unconsumed = set(self.by_ref)
        try:
            self.out = {str(n).lower()
                        for n in (app._subckt_io_nets()[1] or ())}
        except Exception:
            self.out = set()
        self.pwr = (set(_PWR_NETS_LC_FOR_T)
                    | getattr(app, '_supply_rails', set()))
        self.gnd = {'0'} | set(_GND_NETS_LC)
        self.next_gid = max((i.group_id for i in instances),
                            default=-1) + 1
        self.pin_t_requests = []
        self.rule_hits = {}
        self.groups = []        # Records registered by
                                # group-phase acts so LATER rules can
                                # match against formed groups:
                                # {gid, members, rule, meta, pos, bbox}

    def rot(self, ref, deg):
        self.app._auto_rotations[ref] = deg

    def flip(self, ref, on=True):
        """Mirror a part about its own vertical axis, swapping which of
        its pins faces left.  The counterpart to rot(): an act that
        decides an orientation usually has to decide a SIDE as well, and
        before this the only way to say so was to reach into
        app._auto_flips directly.  Writes the same dict every geometry
        consumer already reads (_user_flips wins over it, so a user's
        own mirror still overrides a pattern's)."""
        if on:
            self.app._auto_flips[ref] = True
        else:
            self.app._auto_flips.pop(ref, None)

    def new_gid(self):
        g = self.next_gid
        self.next_gid += 1
        return g

    def register_group(self, gid, members, rule, meta=None,
                       pos=None, bbox=None):
        self.groups.append({'gid': gid, 'members': set(members),
                            'rule': rule, 'meta': meta,
                            'pos': pos, 'bbox': bbox})

    def assign_group(self, refs, gid=None):
        if gid is None:
            gid = self.new_gid()
        for r in refs:
            inst = self.by_ref.get(r)
            if inst is not None:
                inst.group_id = gid
        return gid

    def cache_block(self, members, relpos, bbox, rigid=False):
        if relpos:
            fs = frozenset(members)
            self.app._sp_block_layout[fs] = (relpos, bbox)
            # a RIGID block has intentional internal geometry
            # (diff-pair device columns share an x, etc.) that the block-
            # internal _separate_boxes pass in _apply_cached_blocks_local must
            # NOT disturb.  Record it so that pass skips it.  Generic SP
            # series/parallel blocks are NOT rigid — their members may be
            # pushed apart to clear label overlaps.
            if rigid:
                if getattr(self.app, '_sp_rigid_blocks', None) is None:
                    self.app._sp_rigid_blocks = set()
                self.app._sp_rigid_blocks.add(fs)


class _P2DLRule:
    """A rule: name + MATCH + ACT chain + phase.
    consume  remove matched refs from the pool so a later rule cannot
             re-match them (general -> specific layering).  The sp
             grouping rule sets consume=False, grouping not owning
             orientation.
    phase    'group', where _assign_sp_groups ran, or 'orient', after
             supply-rail detection, where the orientation facts exist.
             One ordered rule list, staged execution.
    setup and teardown bracket the whole rule: reset the block cache,
    rebuild _group_id_of."""

    def __init__(self, name, match, acts, phase='orient', consume=True,
                 setup=None, teardown=None, rounds=False):
        self.name = name
        self.match = match
        self.acts = list(acts)
        self.phase = phase
        self.consume = consume
        self.setup = setup
        self.teardown = teardown
        # rounds=True opts the rule into LATER rounds of
        # the staged group phase, so group-relative rules can chain on
        # groups formed (or enlarged) by other rules in earlier rounds.
        # Structural one-shot rules (sp_blocks, diff_pair) stay
        # round-1-only: re-firing them would mint fresh gids.
        self.rounds = rounds

    def run_acts(self, ctx):
        """Match + act once over the current ctx; returns the number of
        bindings that ACTED (vetoes excluded).  setup/teardown are NOT
        called here — the engine brackets them once per phase."""
        fired = 0
        for b in self.match.find(ctx):
            acted = False
            for act in self.acts:
                r = act(ctx, b)
                acted = acted or (r is not False)
            # an act may VETO by returning False (e.g.
            # rail columns with no polarity hint): the binding then
            # neither consumes nor logs, so generic rules still apply.
            if not acted:
                continue
            fired += 1
            ctx.rule_hits.setdefault(self.name, []).extend(b.refs)
            if self.consume:
                ctx.unconsumed.difference_update(b.refs)
        return fired

    def run(self, ctx):
        if self.setup:
            self.setup(ctx)
        self.run_acts(ctx)
        if self.teardown:
            self.teardown(ctx)


# ── P2DL ACT vocabulary (milestone 1: 5 verbs + the sp_pack bridge) ──



def _p2dl_pack(child_layouts, pack_x, gap=40.0):
    """Pack child ({ref:(x,y)}, bbox) layouts left-to-right (pack_x) or
    top-to-bottom — the same geometry as _layout_sp's two packing
    branches, exposed as the engine primitive behind row/stack."""
    pos = {}
    if pack_x:
        x = 0.0
        lo, hi = [], []
        for cp, cb in child_layouts:
            cyc = (cb[1] + cb[3]) / 2.0
            dx = x - cb[0]
            for r, (px, py) in cp.items():
                pos[r] = (px + dx, py - cyc)
            lo.append(cb[1] - cyc)
            hi.append(cb[3] - cyc)
            x += (cb[2] - cb[0]) + gap
        bbox = (0.0, min(lo) if lo else 0.0,
                (x - gap) if child_layouts else 0.0,
                max(hi) if hi else 0.0)
    else:
        y = 0.0
        lo, hi = [], []
        for cp, cb in child_layouts:
            cxc = (cb[0] + cb[2]) / 2.0
            dy = y - cb[1]
            for r, (px, py) in cp.items():
                pos[r] = (px - cxc, py + dy)
            lo.append(cb[0] - cxc)
            hi.append(cb[2] - cxc)
            y += (cb[3] - cb[1]) + gap
        bbox = (min(lo) if lo else 0.0, 0.0,
                max(hi) if hi else 0.0,
                (y - gap) if child_layouts else 0.0)
    return pos, bbox


def _p2dl_leaf_layouts(ctx, refs):
    out = []
    for r in refs:
        inst = ctx.by_ref.get(r)
        if inst is None:
            out.append(({}, (0.0, 0.0, 0.0, 0.0)))
        else:
            out.append(({r: (0.0, 0.0)}, tuple(inst.sym_body_rel)))
    return out








class _P2DLGroupDev:
    """Milestone 3 engine semantic (user's proposal):
    match a DEVICE in relation to a GROUP formed by an earlier rule in
    the same phase.  For every group registered under `group_rule`,
    yields a binding for each unconsumed device of `kinds` whose net
    set equals nets_from(group_meta) — e.g. a compensation cap whose
    two nets are exactly the diff pair's collector nets.  binding.meta
    = {'group': record, 'ref': device_ref}."""

    def __init__(self, group_rule, kinds, nets_from, npins=2,
                 dev_nets=None):
        self.group_rule = group_rule
        self.kinds = kinds
        self.nets_from = nets_from
        self.npins = npins
        # dev_nets(inst) -> iterable of candidate net
        # tuples to compare against nets_from(meta).  Default: the
        # device's full net set.  For 4-pin controlled sources (E/G:
        # out+,out-,in+,in-) the natural subsets are the OUTPUT pair
        # nets[0:2] and the CONTROL pair nets[2:4] (user guidance);
        # F/H control via a named V source, so no control-net pair.
        self.dev_nets = dev_nets
        self._dev = _P2DLDev(kinds, npins=npins)

    def find(self, ctx):
        for rec in list(ctx.groups):
            if rec['rule'] != self.group_rule:
                continue
            try:
                want = {str(n).lower() for n in self.nets_from(rec['meta'])}
            except Exception:
                continue
            if not want:
                continue
            for ref in sorted(ctx.unconsumed):
                inst = ctx.by_ref.get(ref)
                if inst is None or not self._dev.matches(inst, ctx):
                    continue
                all_nets = [str(n).lower()
                            for n in (inst.comp.get('nets', []) or [])]
                cands = ([set(t) for t in self.dev_nets(inst, all_nets)]
                         if self.dev_nets else [set(all_nets)])
                if any(c == want for c in cands):
                    yield _P2DLBinding([ref],
                                       meta={'group': rec, 'ref': ref})


def _p2dl_act_join_pair_center(ctx, b):
    """Place the matched device centered between the pair devices,
    midway between the device row and the load row, horizontal, and
    ABSORB it into the pair's group (gid + block-cache replacement) so
    the placer moves them as one rigid cell."""
    rec, cref = b.meta['group'], b.meta['ref']
    roles, pos = rec['meta'], rec['pos']
    qa, qb = roles['devices']
    if qa not in pos or qb not in pos:
        return False
    inst = ctx.by_ref.get(cref)
    if inst is None:
        return False
    x_c = (pos[qa][0] + pos[qb][0]) / 2.0
    la = None
    for r in (roles.get('loads') or ((), ()))[0]:
        if r in pos:
            la = r
            break
    y_c = (pos[qa][1] + pos[la][1]) / 2.0 if la else pos[qa][1]
    new_pos = dict(pos)
    new_pos[cref] = (x_c, y_c)
    old_members = frozenset(rec['members'])
    new_members = set(rec['members']) | {cref}
    bx = inst.sym_body_rel
    x0, y0, x1, y1 = rec['bbox']
    nb = (min(x0, x_c + bx[0]), min(y0, y_c + bx[1]),
          max(x1, x_c + bx[2]), max(y1, y_c + bx[3]))
    ctx.app._sp_block_layout.pop(old_members, None)
    ctx.assign_group([cref], gid=rec['gid'])
    ctx.cache_block(new_members, new_pos, nb)
    rec['members'], rec['pos'], rec['bbox'] = new_members, new_pos, nb
    ctx.rot(cref, 90)                       # horizontal (user key: C1=90)
    ctx.app._pattern_oriented.add(cref)


class _P2DLParallelBank:
    """Two or more 2-pin devices in PARALLEL — every member across the
    SAME unordered pair of nets.  binding.meta = {'nets': (a, b)}.
    A bank laid out collinear and side by side is the one self-graze
    family no mirror can fix: BOTH of a member's nets terminate on the
    same side, so whichever pin ends up nearest the partner, that net's
    line runs the length of the member's own body and out the far pin.
    _uncross_pass scores every such mirror NEUTRAL, since the exit side
    comes from the netlist rather than the orientation.  Stacking puts
    each net's pins in a column — a short vertical hop, no body in the
    way.  Any size >= 2 matches; the general case costs one loop."""

    def __init__(self, kinds='R|C|L'):
        self._dev = _P2DLDev(kinds, npins=2)

    def find(self, ctx):
        banks = {}
        rails = set(ctx.pwr) | set(ctx.gnd)
        for ref in sorted(ctx.unconsumed):
            inst = ctx.by_ref.get(ref)
            if inst is None or not self._dev.matches(inst, ctx):
                continue
            nets = [str(n).lower()
                    for n in (inst.comp.get('nets', []) or [])]
            # A part with both pins on ONE net is a short, not a bank
            # member; it has no two sides to align.
            if len(nets) != 2 or nets[0] == nets[1]:
                continue
            # Ordinary signal nets only: a vertical stack needs its members
            # horizontal, which rail-connected parts are not.
            if rails.intersection(nets):
                continue
            banks.setdefault(frozenset(nets), []).append(ref)
        for key in sorted(banks, key=lambda k: sorted(banks[k])):
            refs = sorted(banks[key])
            if len(refs) < 2:
                continue
            yield _P2DLBinding(refs, meta={'nets': tuple(sorted(key))})


def _p2dl_act_parallel_bank(ctx, b):
    """Stack a parallel bank top to bottom as one block, members
    horizontal and pin-ALIGNED.
    The alignment is the point, not merely the stacking: the members have
    to agree on which net sits on the left, or the two connecting lines
    run diagonally back across the bodies and the graze survives the
    move.  Each member's own net order decides it, so one listing the
    nets the other way round gets a mirror, which is exactly what swaps
    its pins left for right.  The lead member — lowest ref, so the choice
    is deterministic — defines which net is the left column."""
    refs = list(b.refs)
    lead = ctx.by_ref.get(refs[0])
    if lead is None:
        return False
    lead_nets = [str(n).lower() for n in (lead.comp.get('nets') or [])]
    if len(lead_nets) != 2:
        return False
    left_net = lead_nets[0]
    for r in refs:
        inst = ctx.by_ref.get(r)
        if inst is None:
            return False
        nets = [str(n).lower() for n in (inst.comp.get('nets') or [])]
        if len(nets) != 2:
            return False
        # Horizontal with left_net on the left, judged from the PINS: a
        # source is drawn upright at 0 and a resistor level at 90, so one
        # angle for every member left LM324.lib's FB upright beside RO2.
        deg, flip = 90, nets[0] != left_net
        for d, f in ((90, False), (90, True), (270, False), (270, True),
                     (0, False), (0, True), (180, False), (180, True)):
            try:
                _b, offs = ctx.app._rotated_pins_by_num(inst, d, f)
                pts = {str(n).lower(): offs.get(p)
                       for p, n in (inst._pin_net_pairs or [])}
            except Exception:
                break
            pl, pr = pts.get(left_net), pts.get(
                nets[1] if nets[0] == left_net else nets[0])
            if pl is None or pr is None:
                break
            if abs(pl[0] - pr[0]) > abs(pl[1] - pr[1]) and pl[0] < pr[0]:
                deg, flip = d, f
                break
        ctx.rot(r, deg)
        ctx.app._pattern_oriented.add(r)
        ctx.flip(r, flip)
    relpos, bbox = _p2dl_pack(_p2dl_leaf_layouts(ctx, refs), pack_x=False)
    gid = ctx.assign_group(refs)
    ctx.cache_block(refs, relpos, bbox)
    ctx.register_group(gid, set(refs), 'parallel_bank',
                       meta=dict(b.meta), pos=relpos, bbox=bbox)


class _P2DLShuntRCTap:
    """Match the shunt-R / series-RC tap: Ra from <tap> to <mid> in parallel
    with Rb (<cnode> to <tap>) in series with Cc (<mid> to <cnode>).
    """

    def __init__(self):
        self._res = _P2DLDev('R', npins=2)
        self._cap = _P2DLDev('C', npins=2)

    @staticmethod
    def _nets(inst):
        return [str(n).lower() for n in (inst.comp.get('nets') or [])]

    def find(self, ctx):
        # Fanout over the WHOLE instance list, not just the unconsumed
        # pool: privacy of <cnode> is a property of the circuit, and a
        # neighbour that some earlier rule already claimed still touches
        # the node.
        fan = {}
        for inst in ctx.instances:
            for n in self._nets(inst):
                fan.setdefault(n, set()).add(inst.comp['ref'])
        res, caps = [], []
        for ref in sorted(ctx.unconsumed):
            inst = ctx.by_ref.get(ref)
            if inst is None:
                continue
            nets = self._nets(inst)
            if len(nets) != 2 or nets[0] == nets[1]:
                continue
            if self._res.matches(inst, ctx):
                res.append(ref)
            elif self._cap.matches(inst, ctx):
                caps.append(ref)
        by_net = {}
        for ref in res:
            for n in self._nets(ctx.by_ref[ref]):
                by_net.setdefault(n, []).append(ref)
        seen = set()
        for cref in caps:
            cn = self._nets(ctx.by_ref[cref])
            for cnode, mid in ((cn[0], cn[1]), (cn[1], cn[0])):
                for rb in by_net.get(cnode, []):
                    bn = self._nets(ctx.by_ref[rb])
                    tap = bn[0] if bn[1] == cnode else bn[1]
                    if tap in (cnode, mid):
                        continue
                    # <cnode> must be private to exactly these two.
                    if fan.get(cnode, set()) != {cref, rb}:
                        continue
                    for ra in by_net.get(tap, []):
                        if ra == rb:
                            continue
                        if set(self._nets(ctx.by_ref[ra])) != {tap, mid}:
                            continue
                        key = frozenset((ra, rb, cref))
                        if key in seen:
                            continue
                        seen.add(key)
                        # An unconsumed SOURCE across the same two nets
                        # is this tap's driver and belongs in the cell.
                        # Lowest ref when several qualify, so the choice
                        # is deterministic.
                        srcs = sorted(
                            r for r in fan.get(tap, set()) & fan.get(mid, set())
                            if r in ctx.unconsumed and r not in (ra, rb, cref)
                            and (ctx.by_ref[r].comp.get('kind', '').upper()
                                 in ('G', 'E', 'I', 'V')))
                        yield _P2DLBinding(
                            [ra, rb, cref] + srcs[:1],
                            meta={'tap': tap, 'mid': mid, 'cnode': cnode,
                                  'src': srcs[0] if srcs else None})


def _p2dl_shunt_rc_ladder(ctx, b, ra, rb, cc, tap, mid, src):
    """Draw the tap as a ladder when its common net is drawn as ground.
        tap +----[ Rb ]----+ cnode
            |              |
         [src] [Ra]       [Cc]          every leg vertical,
            |    |         |            MID at the bottom
           MID  MID       MID
    Left to right: source, shunt resistor, series resistor at the tap's
    height, capacitor; the legs share one baseline so every MID T hangs
    straight down.  Rotation 0 puts a 2-pin part's first net on TOP and
    90 puts it LEFT, so a leg turns 180 when MID is its first net."""
    def nets(r):
        inst = ctx.by_ref.get(r)
        return None if inst is None else [
            str(n).lower() for n in (inst.comp.get('nets') or [])]

    legs = [r for r in (src, ra, cc) if r]
    for r in legs:
        ns = nets(r)
        if not ns or len(ns) < 2:
            return False
        ctx.rot(r, 180 if ns[0] == mid else 0)       # MID at the bottom
        ctx.flip(r, False)
        ctx.app._pattern_oriented.add(r)
    bn = nets(rb)
    if not bn or len(bn) != 2:
        return False
    ctx.rot(rb, 90)                                  # horizontal
    ctx.flip(rb, bn[0] != tap)                       # <tap> on the left
    ctx.app._pattern_oriented.add(rb)

    gap = 40.0
    relpos, x = {}, 0.0
    tap_y = None
    for r in [r for r in (src, ra) if r] + [rb, cc]:
        bb = tuple(ctx.by_ref[r].sym_body_rel)
        if r == rb:
            y = tap_y - (bb[1] + bb[3]) / 2.0
        else:
            y = -bb[3]                               # bottom on y = 0
            if r == ra:
                tap_y = y + bb[1]
        relpos[r] = (x - bb[0], y)
        x += (bb[2] - bb[0]) + gap
    boxes = []
    for r, (px, py) in relpos.items():
        bb = ctx.by_ref[r].sym_body_rel
        boxes.append((px + bb[0], py + bb[1], px + bb[2], py + bb[3]))
    x0 = min(q[0] for q in boxes); y0 = min(q[1] for q in boxes)
    relpos = {r: (px - x0, py - y0) for r, (px, py) in relpos.items()}
    bbox = (0.0, 0.0, max(q[2] for q in boxes) - x0,
            max(q[3] for q in boxes) - y0)
    members = list(b.refs) + ([src] if src else [])
    gid = ctx.assign_group(members)
    ctx.cache_block(members, relpos, bbox, rigid=True)
    ctx.register_group(gid, set(members), 'shunt_rc_tap',
                       meta=dict(b.meta), pos=relpos, bbox=bbox)


def _p2dl_act_shunt_rc_tap(ctx, b):
    """Lay out the tap as it is drawn by hand: horizontal rows (source, Ra, Cc)
    leaving on <mid> down the left, and Rb vertical on the right from <tap>
    down to <cnode>.
    """
    ra, rb, cc = b.refs[0], b.refs[1], b.refs[2]
    tap, mid = b.meta['tap'], b.meta['mid']
    src = b.meta.get('src')
    if mid in ctx.app._south_rails():
        return _p2dl_shunt_rc_ladder(ctx, b, ra, rb, cc, tap, mid, src)

    rows = [r for r in (src, ra, cc) if r]
    for r in rows:
        inst = ctx.by_ref.get(r)
        if inst is None:
            return False
        nets = [str(n).lower() for n in (inst.comp.get('nets') or [])]
        if len(nets) < 2:
            return False
        ctx.rot(r, 90)                       # horizontal
        ctx.flip(r, nets[0] != mid)          # MID on the left
        ctx.app._pattern_oriented.add(r)
    inst_b = ctx.by_ref.get(rb)
    if inst_b is None:
        return False
    bn = [str(n).lower() for n in (inst_b.comp.get('nets') or [])]
    if len(bn) != 2:
        return False
    ctx.rot(rb, 0 if bn[0] == tap else 180)  # vertical, tap on top
    ctx.flip(rb, False)
    ctx.app._pattern_oriented.add(rb)

    # Stack the rows, then hang the vertical leg off the right edge.
    # Built by hand rather than with a second _p2dl_pack because the leg
    # spans the WHOLE stack: packing it as a sibling would centre two
    # boxes of very different heights and leave <tap> running diagonally
    # instead of straight across.
    col_pos, col_bb = _p2dl_pack(_p2dl_leaf_layouts(ctx, rows),
                                 pack_x=False)
    leg_bb = tuple(inst_b.sym_body_rel)
    gap = 40.0
    lx = col_bb[2] + gap - leg_bb[0]
    # HANG the leg from the LAST tap row — Ra's right pin — rather than
    # centring it on anything.  Its top pin then meets Ra's right pin at
    # the same height, so <tap> leaves Ra horizontally, and the leg
    # descends toward Cc's row so <cnode> is a short hop at the bottom.
    # Centring the leg on the tap rows instead left it floating above
    # Ra, with <tap> reaching UP from Ra and down from the source —
    # both diagonal, which is what the user's redraw corrects.
    ra_rel = ctx.by_ref[ra].sym_body_rel
    ra_mid_y = col_pos[ra][1] + (ra_rel[1] + ra_rel[3]) / 2.0
    ly = ra_mid_y - leg_bb[1]
    relpos = dict(col_pos)
    relpos[rb] = (lx, ly)
    bbox = (min(col_bb[0], lx + leg_bb[0]), min(col_bb[1], ly + leg_bb[1]),
            max(col_bb[2], lx + leg_bb[2]), max(col_bb[3], ly + leg_bb[3]))
    members = list(b.refs) + ([src] if src else [])
    gid = ctx.assign_group(members)
    ctx.cache_block(members, relpos, bbox, rigid=True)
    ctx.register_group(gid, set(members), 'shunt_rc_tap',
                       meta=dict(b.meta), pos=relpos, bbox=bbox)


class _P2DLSensePair:
    """An E/G controlled source paired with the 2-pin
    device its CONTROL pins sense (the user's REE||GCM-control
    observation: GCM(0,6,10,99) senses exactly REE's nets {10,99}).
    Group-independent — both parts come straight from the unconsumed
    pool.  binding.meta = {'dev': ref, 'src': ref}."""

    def __init__(self, src_kinds='E|G', dev_kinds='R|C|L'):
        self._src = _P2DLDev(src_kinds, npins=(4, None))
        self._dev = _P2DLDev(dev_kinds, npins=2)

    def find(self, ctx):
        for sref in sorted(ctx.unconsumed):
            sinst = ctx.by_ref.get(sref)
            if sinst is None or not self._src.matches(sinst, ctx):
                continue
            nets = [str(n).lower()
                    for n in (sinst.comp.get('nets', []) or [])]
            if len(nets) < 4:
                continue
            want = set(nets[2:4])
            for dref in sorted(ctx.unconsumed):
                if dref == sref:
                    continue
                dinst = ctx.by_ref.get(dref)
                if dinst is None or not self._dev.matches(dinst, ctx):
                    continue
                dnets = {str(n).lower()
                         for n in (dinst.comp.get('nets', []) or [])}
                if dnets == want:
                    yield _P2DLBinding([dref, sref],
                                       meta={'dev': dref, 'src': sref})
                    break


def _p2dl_act_sense_pair(ctx, b):
    """Place the sensed device LEFT, the sensing source RIGHT, centers
    aligned (the user's spaced layout: REE left of GCM), as one rigid
    group.  The sensed device orients vertical."""
    dref, sref = b.meta['dev'], b.meta['src']
    di, si = ctx.by_ref.get(dref), ctx.by_ref.get(sref)
    if di is None or si is None:
        return False
    db, sb = di.sym_body_rel, si.sym_body_rel
    gap = 60.0
    sx = (db[2] - db[0]) / 2.0 + gap + (sb[2] - sb[0]) / 2.0
    pos = {dref: (0.0, 0.0), sref: (sx, 0.0)}
    bbox = (db[0], min(db[1], sb[1]), sx + sb[2], max(db[3], sb[3]))
    gid = ctx.assign_group([dref, sref])
    ctx.cache_block(set(pos), pos, bbox)
    ctx.register_group(gid, set(pos), 'sense_pair',
                       meta=dict(b.meta), pos=pos, bbox=bbox)
    ctx.rot(dref, 0)                         # sensed device vertical
    ctx.app._pattern_oriented.add(dref)


def _p2dl_act_join_pair_right(ctx, b):
    """Attach the matched device at the RIGHT edge of
    the pair cell, in the between-rows band (user key: GA right of Q2,
    rot 180), absorbed into the group like pair_comp_cap."""
    rec, cref = b.meta['group'], b.meta['ref']
    roles, pos = rec['meta'], rec['pos']
    qa, qb = roles['devices']
    if qa not in pos or qb not in pos:
        return False
    inst = ctx.by_ref.get(cref)
    if inst is None:
        return False
    bx = inst.sym_body_rel
    x0, y0, x1, y1 = rec['bbox']
    x_c = x1 + 40.0 + (bx[2] - bx[0]) / 2.0
    la = None
    for r in (roles.get('loads') or ((), ()))[0]:
        if r in pos:
            la = r
            break
    y_c = (pos[qa][1] + pos[la][1]) / 2.0 if la else pos[qa][1]
    new_pos = dict(pos)
    new_pos[cref] = (x_c, y_c)
    old_members = frozenset(rec['members'])
    new_members = set(rec['members']) | {cref}
    nb = (min(x0, x_c + bx[0]), min(y0, y_c + bx[1]),
          max(x1, x_c + bx[2]), max(y1, y_c + bx[3]))
    ctx.app._sp_block_layout.pop(old_members, None)
    ctx.assign_group([cref], gid=rec['gid'])
    ctx.cache_block(new_members, new_pos, nb)
    rec['members'], rec['pos'], rec['bbox'] = new_members, new_pos, nb
    # orient the sensed source (GA) by MEASURING its pins
    # instead of hardcoding 180.  GA is a VCCS: its CONTROL/sense pair
    # (the circle side, dev_nets[2:4]) measures the differential pair / C1
    # to its LEFT, and its OUTPUT pair (the diamond side, nets[0:2]) drives
    # the output circuitry to its RIGHT.  Pick the rotation that puts the
    # OUTPUT pins furthest right of the sense pins, so the diamond faces
    # the output and the circle faces C1.  Symbol-agnostic and flip-proof
    # (same measure-each-rotation approach as _chain_rot_toward_right);
    # generalises to other sensed E/G sources (helps OPAX197 too).
    nets = inst.comp.get('nets') or []
    out_nets = {str(n) for n in nets[0:2]}
    sense_nets = {str(n) for n in nets[2:4]}
    best_rot = _sense_src_rot_out_right(ctx.app, inst, out_nets, sense_nets)
    ctx.rot(cref, best_rot if best_rot is not None else 180)
    ctx.app._pattern_oriented.add(cref)


def _sense_src_rot_out_right(app, inst, out_nets, sense_nets):
    """In : the app, a controlled source, its output nets (the diamond
    drive side) and its sense nets (the circle side).
    Out: the absolute rotation (0/90/180/270) putting the OUTPUT pins
    furthest RIGHT of the sense pins, so the drive faces the downstream
    circuitry and the sense the upstream measured nodes; None when the
    nets cannot be measured.
    Measures the actual rendered pin x at each candidate, so it is immune
    to the base symbol's pin layout and to flip or net remapping, and
    restores the geometry before returning.  Prefers a LEFT/RIGHT split
    over a stacked one, so the source reads side to side."""
    saved = (inst.sym_entry, inst.rotation_deg, inst.sym_scale,
             inst.mid_kx, inst.mid_ky)
    best_rot, best_score = None, None
    try:
        for cand in (0, 90, 180, 270):
            app._apply_instance_rotation_geometry(inst, cand)
            ox = []; oy = []; sx = []; sy = []
            for pn, nn in (getattr(inst, '_pin_net_pairs', None) or []):
                px, py = _pin_canvas_pos(inst, pn)
                if str(nn) in out_nets:
                    ox.append(px); oy.append(py)
                elif str(nn) in sense_nets:
                    sx.append(px); sy.append(py)
            if not ox or not sx:
                continue
            dx = sum(ox) / len(ox) - sum(sx) / len(sx)   # out minus sense, x
            dy = abs((sum(oy) / len(oy)) - (sum(sy) / len(sy)))
            horizontal = abs(dx) >= dy                   # left/right split?
            # Score: reward output-right-of-sense (dx>0) and a horizontal
            # split.  A stacked (vertical) orientation scores below any
            # horizontal one.
            score = dx + (1000.0 if horizontal else 0.0)
            if best_score is None or score > best_score:
                best_score, best_rot = score, cand
    finally:
        (inst.sym_entry, inst.rotation_deg, inst.sym_scale,
         inst.mid_kx, inst.mid_ky) = saved
    return best_rot


def _col_rot_down(app, inst, down_net):
    """Return the absolute rotation (0/90/180/270) that
    places `inst`'s pin on `down_net` furthest DOWN (max canvas y), so a
    rail/ground column element stands vertically reading top→down toward
    its negative/ground end.  Symbol-agnostic and flip-proof: it measures
    the actual rendered pin y at each rotation, restoring geometry after.
    The companion of _chain_rot_toward_right (which works on x)."""
    saved = (inst.sym_entry, inst.rotation_deg, inst.sym_scale,
             inst.mid_kx, inst.mid_ky)
    best_rot, best_dy = 0, None
    try:
        for cand in (0, 90, 180, 270):
            app._apply_instance_rotation_geometry(inst, cand)
            for pn, nn in (getattr(inst, '_pin_net_pairs', None) or []):
                if str(nn).lower() != str(down_net).lower():
                    continue
                _px, py = _pin_canvas_pos(inst, pn)
                dy = py - inst.oy_px        # +y is downward in canvas
                if best_dy is None or dy > best_dy:
                    best_dy, best_rot = dy, cand
                break
    finally:
        (inst.sym_entry, inst.rotation_deg, inst.sym_scale,
         inst.mid_kx, inst.mid_ky) = saved
    return best_rot


def _body_box_at_rot(app, inst, rot):
    """Return inst.sym_body_rel measured AT rotation `rot`
    (origin-relative body box), restoring the live geometry afterward.  A
    column/row packer must size each element by its TARGET-rotation body, not
    the stale current sym_body_rel: _col_rot_down restores the instance to its
    prior rotation, so reading sym_body_rel straight after gives the
    pre-rotation dims (a resistor still horizontal), which mis-spaced the
    column."""
    saved = (inst.sym_entry, inst.rotation_deg, inst.sym_scale,
             inst.mid_kx, inst.mid_ky)
    try:
        app._apply_instance_rotation_geometry(inst, rot)
        return tuple(inst.sym_body_rel)
    finally:
        (inst.sym_entry, inst.rotation_deg, inst.sym_scale,
         inst.mid_kx, inst.mid_ky) = saved


class _P2DLDiffPairs:
    """Diff_pair MATCH primitive: a view over the proven
    _match_diff_pairs recognizer (the same wrap pattern as seq/par over
    _sp_decompose).  Yields one binding per matched pair; binding.meta
    is the role dict (devices, degeneration, tail, loads, ...) so acts
    and future user rules can address parts by role."""

    def find(self, ctx):
        for roles in ctx.app._match_diff_pairs(ctx.instances):
            # the binding CONSUMES only the pair's CORE
            # (devices, degeneration, tail source); loads and other
            # tail parts are context the act may place, and the act
            # extends consumption to what it actually placed.  This
            # leaves e.g. the compensation cap matchable by the
            # group-relative pair_comp_cap rule.
            refs = list(roles.get('devices') or ())
            refs += [r for r in (roles.get('deg_resistors') or ())
                     if isinstance(r, str)]
            ts = roles.get('tail_source')
            if isinstance(ts, str):
                refs.append(ts)
            refs = [r for r in refs if r in ctx.by_ref]
            if refs and all(r in ctx.unconsumed for r in refs):
                yield _P2DLBinding(refs, meta=roles)


def _p2dl_act_diff_pair(ctx, b):
    """template.diff_pair — the _assign_pattern_groups loop body as a
    P2DL act (parity by construction: same _layout_diff_pair geometry,
    same flip/gid/cache/orientation stamping, gids continuing in the
    same ctx sequence the sp_blocks rule used)."""
    roles = b.meta
    pos, bbox, members, rots, flips = \
        ctx.app._layout_diff_pair(roles, ctx.by_ref)
    if not pos:
        return
    # outward-base mirror, polarity-aware (template decides).
    ctx.app._auto_flips.update(flips)
    gid = ctx.assign_group(members)
    ctx.cache_block(members, pos, bbox, rigid=True)
    ctx.register_group(gid, members, 'diff_pair', meta=roles,
                       pos=pos, bbox=bbox)
    ctx.unconsumed.difference_update(members)   # own what was placed
    for r, deg in rots.items():
        ctx.rot(r, deg)
        ctx.app._pattern_oriented.add(r)


class _P2DLDarlingtonPairs:
    """MATCH primitive over
    _match_darlington_diff_pairs, the Darlington analogue of _P2DLDiffPairs.
    Consumes the front, rear, diode and tail-source refs so the generic
    diff_pair rule (which runs after) cannot re-grab the rear pair as an
    ordinary diff pair."""

    def find(self, ctx):
        for roles in ctx.app._match_darlington_diff_pairs(ctx.instances):
            refs = (list(roles.get('front') or ())
                    + list(roles.get('rear') or ()))
            refs += [r for r in (roles.get('diodes') or ())
                     if isinstance(r, str)]
            ts = roles.get('tail_source')
            if isinstance(ts, str):
                refs.append(ts)
            refs = [r for r in refs if r in ctx.by_ref]
            if refs and all(r in ctx.unconsumed for r in refs):
                yield _P2DLBinding(refs, meta=roles)


def _p2dl_act_darlington_diff_pair(ctx, b):
    """Stamp the Darlington diff-pair cell from a
    role binding (front/rear/diodes/tail).  Same flip/gid/cache/orientation
    stamping as _p2dl_act_diff_pair, calling _layout_darlington_diff_pair."""
    roles = b.meta
    pos, bbox, members, rots, flips = \
        ctx.app._layout_darlington_diff_pair(roles, ctx.by_ref)
    if not pos:
        return False
    ctx.app._auto_flips.update(flips)
    gid = ctx.assign_group(members)
    ctx.cache_block(members, pos, bbox, rigid=True)
    ctx.register_group(gid, members, 'darlington_diff_pair', meta=roles,
                       pos=pos, bbox=bbox)
    ctx.unconsumed.difference_update(members)
    for r, deg in rots.items():
        ctx.rot(r, deg)
        ctx.app._pattern_oriented.add(r)
    return True



def _ag_from_components(components, instances=None):
    """Accept either the parser's comp dicts (keys 'ref','nets') or
    CompInstance-like objects exposing `.comp`.  Returns [(ref, nets)]."""
    src = instances if instances is not None else components
    out = []
    for it in src:
        comp = getattr(it, 'comp', it)
        out.append((comp['ref'], comp.get('nets', []) or []))
    return out


def _ag_autodetect_rails(instances, fanout_threshold=8):
    """Rails = obvious power/ground names + any net whose fan-out
    (number of instances touching it) >= fanout_threshold."""
    power = {'0', 'gnd', 'gnd!', 'vss', 'vdd', 'vcc', 'vee', 'v+', 'v-',
             'vplus', 'vminus', 'agnd', 'dgnd'}
    fan = defaultdict(int)
    for _ref, nets in instances:
        for n in set(x.lower() for x in nets):
            fan[n] += 1
    rails = {n for n, c in fan.items() if c >= fanout_threshold}
    rails |= {n for n in fan if n in power}
    return rails


def _ag_build_nodes(instances, rail_nets):
    rail = {n.lower() for n in rail_nets}
    nodes = []
    for k, (ref, nets) in enumerate(instances):
        keep = {n.lower() for n in nets if n.lower() not in rail}
        nodes.append(_AGNode(k, [ref], keep))
    return nodes


def _ag_net_to_nodes(nodes):
    idx = defaultdict(set)
    for nd in nodes:
        for net in nd.nets:
            idx[net].add(nd.nid)
    return idx


def _ag_externals(node, idx):
    return {net for net in node.nets if idx[net] - {node.nid}}


def _ag_pair_terms(a, b, idx):
    ea, eb = _ag_externals(a, idx), _ag_externals(b, idx)
    shared = len(ea & eb)
    elsewhere = (len(ea) - shared) + (len(eb) - shared)
    return shared, elsewhere






def _ag_score_lex(a, b, idx):
    """Lexicographic: (shared desc, elsewhere asc).  Default ranking."""
    shared, elsewhere = _ag_pair_terms(a, b, idx)
    return (shared, -elsewhere)


def _ag_group(instances, rail_nets, score=_ag_score_lex, gate=None,
              min_shared=1, max_size=4, trace=False):
    """Agglomerative grouping.  Returns list of (members, external_pins).
    `score(a, b, idx)` returns a comparable key; larger == better.
    Eligibility: pair stays <= max_size and passes `gate(a,b,idx)` if
    given, else shares >= min_shared nets.
    """
    nodes = _ag_build_nodes(instances, rail_nets)
    step = 0
    while True:
        idx = _ag_net_to_nodes(nodes)
        best = None
        best_key = None
        for a, b in combinations(nodes, 2):
            if len(a.members) + len(b.members) > max_size:
                continue
            if gate is not None:
                if not gate(a, b, idx):
                    continue
            else:
                shared, _elsewhere = _ag_pair_terms(a, b, idx)
                if shared < min_shared:
                    continue
            key = (score(a, b, idx), -min(a.nid, b.nid))
            if best_key is None or key > best_key:
                best, best_key = (a, b), key
        if best is None:
            break
        a, b = best
        if trace:
            step += 1
            sh, el = _ag_pair_terms(a, b, idx)
            print(f'  merge #{step}: {a} + {b}  shared={sh} elsewhere={el}')
        a.members += b.members
        a.nets |= b.nets
        nodes.remove(b)
    idx = _ag_net_to_nodes(nodes)
    return [(nd.members, sorted(_ag_externals(nd, idx))) for nd in nodes]


def _ag_spec_funcs(spec):
    """spec = (kind, val, max_size).  kind 'raw' -> gate shared>=val(int),
    rank lexicographic.  kind 'jac' -> gate jaccard>=val(float) over
    external pins, rank by jaccard (then shared).  Returns (gate, score,
    max_size, display)."""
    kind, val, max_size = spec
    if kind == 'jac':
        thr = float(val)

        def gate(a, b, idx):
            s, e = _ag_pair_terms(a, b, idx)
            u = s + e
            return u > 0 and (s / u) >= thr

        def score(a, b, idx):
            s, e = _ag_pair_terms(a, b, idx)
            u = s + e
            return ((s / u) if u else 0.0, s)
        return gate, score, max_size, f'j{thr},{max_size}'
    ms = int(val)

    def gate(a, b, idx):
        s, _e = _ag_pair_terms(a, b, idx)
        return s >= ms
    return gate, _ag_score_lex, max_size, f'{ms},{max_size}'


def _ag_group_levels(instances, rail_nets, level_specs):
    """Hierarchical grouping driven by a list of per-level specs.  Level 1
    groups raw instances; each higher level treats the previous level's
    groups as super-nodes whose nets are their EXTERNAL pins (absorbed
    internal pins are gone) and re-runs the agglomerator -> groups-of-
    groups.  Rails are cut once (level 1).  Stops at the last spec or when
    a pass makes no merges.  Returns (blocks, history)."""
    def hist(bs):
        h = {}
        for mem, _e in bs:
            h[len(mem)] = h.get(len(mem), 0) + 1
        return dict(sorted(h.items()))

    gate, score, max_size, disp = _ag_spec_funcs(level_specs[0])
    groups = _ag_group(instances, rail_nets, score=score, gate=gate,
                       max_size=max_size)
    blocks = [(list(mem), set(ext)) for mem, ext in groups]
    history = [{'level': 1, 'spec': disp, 'nodes_in': len(instances),
                'groups_out': len(blocks), 'hist': hist(groups)}]
    for lvl, spec in enumerate(level_specs[1:], start=2):
        gate, score, max_size, disp = _ag_spec_funcs(spec)
        labels = [f'_B{lvl}_{k}' for k in range(len(blocks))]
        pseudo = [(labels[k], blocks[k][1]) for k in range(len(blocks))]
        idx = {labels[k]: k for k in range(len(blocks))}
        sub = _ag_group(pseudo, set(), score=score, gate=gate,
                        max_size=max_size)
        newblocks = []
        for mem_labels, ext in sub:
            flat = []
            for lab in mem_labels:
                flat += blocks[idx[lab]][0]
            newblocks.append((flat, set(ext)))
        history.append({'level': lvl, 'spec': disp, 'nodes_in': len(blocks),
                        'groups_out': len(newblocks),
                        'hist': hist(newblocks)})
        converged = len(newblocks) == len(blocks)
        blocks = newblocks
        if converged:
            break
    return [(mem, sorted(ext)) for mem, ext in blocks], history


def _ag_classify_ports(ports, rails):
    """Split a subckt's ports into left (input) / right (output) net sets,
    ignoring rails.  Heuristic by name."""
    inp, outp = set(), set()
    for port in ports:
        pl = port.lower()
        if pl in rails:
            continue
        if pl.startswith(('in', 'vin', '+', '-')) or pl in ('in+', 'in-'):
            inp.add(pl)
        elif pl.startswith(('out', 'vout', 'o')):
            outp.add(pl)
    return inp, outp


def _ag_spectral_xy(pairs, rails, input_nets, output_nets):
    """Return (xs, ys) in [0,1] per instance.  x = Fiedler vector #1 of
    the rail-excluded weighted signal Laplacian (each net spreads total
    weight 1 over its pin-pairs), oriented so inputs land left / output
    right.  y = Fiedler vector #2 (orthogonal spread) to fan out dense
    regions.  A tiny uniform regularizer keeps the graph connected."""
    import numpy as np
    n = len(pairs)
    sig = [{x.lower() for x in nets if x.lower() not in rails}
           for _r, nets in pairs]
    full = [{x.lower() for x in nets} for _r, nets in pairs]
    w = np.full((n, n), 1e-4 / max(n, 1))
    net2i = {}
    for i, s in enumerate(sig):
        for net in s:
            net2i.setdefault(net, []).append(i)
    for members in net2i.values():
        k = len(members)
        if k < 2:
            continue
        ww = 1.0 / (k - 1)
        for a in members:
            for b in members:
                if a != b:
                    w[a, b] += ww
    lap = np.diag(w.sum(1)) - w
    _vals, vecs = np.linalg.eigh(lap)
    fx = vecs[:, 1].copy()
    fy = vecs[:, 2].copy() if n > 2 else np.zeros(n)
    in_idx = [i for i, s in enumerate(full) if s & input_nets]
    out_idx = [i for i, s in enumerate(full) if s & output_nets]
    if in_idx and out_idx and fx[in_idx].mean() > fx[out_idx].mean():
        fx = -fx

    def norm(v):
        lo, hi = float(v.min()), float(v.max())
        return ((v - lo) / (hi - lo)) if hi > lo else (v * 0 + 0.5)
    return norm(fx).tolist(), norm(fy).tolist()


def _ag_write_schematic(pairs, rails, blocks, ports, out_path, ncols=12):
    """Assign physical (x, y) coordinates from the group structure +
    spectral order, then write a placed-block SVG.  Columns = signal-flow
    stages (x); groups stacked within a column by y and the column is
    centered vertically (dense core piles up, sides centered).  Canvas
    width is fixed-ish; height grows to fit every bbox.  Returns a dict
    with the per-instance coordinates and canvas size."""
    inp, outp = _ag_classify_ports(ports, rails)
    xs, ys = _ag_spectral_xy(pairs, rails, inp, outp)
    ref_to_i = {r: i for i, (r, _n) in enumerate(pairs)}

    # ---- geometry constants (px) ----
    cw, ch, gpad, hdr, cgap, margin = 78.0, 16.0, 6.0, 14.0, 10.0, 50.0
    pal = ['#d7263d', '#1b9e77', '#7570b3', '#c79100', '#386cb0',
           '#a6761d', '#e7298a', '#66a61e', '#1f78b4', '#b15928']

    def gdims(nmem):
        gc = min(nmem, 3)
        rows = (nmem + gc - 1) // gc
        return gc * cw + 2 * gpad, rows * ch + hdr + gpad

    # ---- group records with mean x/y ----
    recs = []
    for members, ext in blocks:
        idxs = [ref_to_i[r] for r in members]
        mx = sum(xs[i] for i in idxs) / len(idxs)
        my = sum(ys[i] for i in idxs) / len(idxs)
        mem_sorted = [r for _y, r in sorted(zip([ys[i] for i in idxs],
                                                members))]
        gw, gh = gdims(len(members))
        recs.append({'members': mem_sorted, 'ext': ext, 'mx': mx, 'my': my,
                     'gw': gw, 'gh': gh})

    # ---- bin into columns by x; sort each column by y ----
    columns = [[] for _ in range(ncols)]
    for r in recs:
        c = min(ncols - 1, int(r['mx'] * ncols + 1e-9))
        columns[c].append(r)
    for col in columns:
        col.sort(key=lambda r: r['my'])

    # ---- x of each column = cumulative max width; y centered ----
    col_w = [max([r['gw'] for r in col], default=0.0) for col in columns]
    col_h = [sum(r['gh'] for r in col) + cgap * max(len(col) - 1, 0)
             for col in columns]
    canvas_h = max(col_h, default=0.0) + 2 * margin + 30
    x_cursor = margin
    col_x = []
    for c in range(ncols):
        col_x.append(x_cursor)
        x_cursor += (col_w[c] + 40.0) if col_w[c] else 0.0
    canvas_w = x_cursor + margin

    coords = {}              # ref -> (cx, cy) center px
    gboxes = []              # (x, y, w, h, rec, color)
    ci = 0
    for c in range(ncols):
        col = columns[c]
        y = margin + 30 + (max(col_h) - col_h[c]) / 2 if col_h and col else \
            margin + 30
        for r in col:
            gx = col_x[c]
            color = '#999' if len(r['members']) == 1 else pal[ci % len(pal)]
            if len(r['members']) > 1:
                ci += 1
            gboxes.append((gx, y, r['gw'], r['gh'], r, color))
            gc = min(len(r['members']), 3)
            for k, ref in enumerate(r['members']):
                rr, cc = divmod(k, gc)
                px = gx + gpad + cc * cw + cw / 2
                py = y + hdr + rr * ch + ch / 2
                coords[ref] = (px, py)
            y += r['gh'] + cgap

    # ---- SVG ----
    svg = [f'<svg viewBox="0 0 {canvas_w:.0f} {canvas_h:.0f}" '
           f'xmlns="http://www.w3.org/2000/svg" font-family="monospace" '
           f'font-size="8">',
           f'<rect width="{canvas_w:.0f}" height="{canvas_h:.0f}" '
           f'fill="#ffffff"/>',
           f'<text x="{margin}" y="24" font-size="14" fill="#111">'
           f'placed-block preview — {len(pairs)} instances, {ncols} '
           f'signal-flow columns (INPUTS left → OUTPUT right)</text>']
    # connection lines between group centers sharing an external pin
    net2g = {}
    gcenter = {id(r): (gx + gw / 2, gy + gh / 2)
               for gx, gy, gw, gh, r, _c in gboxes}
    for _gx, _gy, _gw, _gh, r, _c in gboxes:
        for pin in r['ext']:
            net2g.setdefault(pin, []).append(r)
    seen = set()
    for pin, gs in net2g.items():
        for a in range(len(gs)):
            for b in range(a + 1, len(gs)):
                ka, kb = id(gs[a]), id(gs[b])
                if (ka, kb) in seen:
                    continue
                seen.add((ka, kb))
                x1, y1 = gcenter[ka]
                x2, y2 = gcenter[kb]
                svg.append(f'<line x1="{x1:.0f}" y1="{y1:.0f}" '
                           f'x2="{x2:.0f}" y2="{y2:.0f}" stroke="#9aa" '
                           f'stroke-width="0.4" stroke-opacity="0.25"/>')
    for gx, gy, gw, gh, r, color in gboxes:
        fill = '#f4f4f4' if color == '#999' else color
        op = 1.0 if color == '#999' else 0.12
        svg.append(f'<rect x="{gx:.1f}" y="{gy:.1f}" width="{gw:.1f}" '
                   f'height="{gh:.1f}" rx="4" fill="{fill}" '
                   f'fill-opacity="{op}" stroke="{color}" '
                   f'stroke-width="1"/>')
        svg.append(f'<text x="{gx+4:.1f}" y="{gy+10:.1f}" fill="{color}" '
                   f'font-weight="bold">[{len(r["members"])}] '
                   f'p{len(r["ext"])}</text>')
        for ref, (px, py) in ((m, coords[m]) for m in r['members']):
            svg.append(f'<text x="{px:.1f}" y="{py+3:.1f}" '
                       f'text-anchor="middle" fill="#333">{ref}</text>')
    svg.append('</svg>')
    open(out_path, 'w').write('\n'.join(svg))
    return {'coords': coords, 'w': canvas_w, 'h': canvas_h, 'ncols': ncols}




def _run_verify(spice_path=None, sym_path=None, subckt=None,
                skip_bk=False, use_pr=False, post_fixups=False,
                no_ports=False, no_uncross=False, grade=None):
    """Self-check harness (-v): place the deck from a clean state, twice and
    once with roles kept, and report overlaps, crossings, lines through
    parts, grazes, backward pairs and wire length.  `grade` names a
    hand-placed .pr.json to compare against.
    """
    import os as _os
    here = _os.path.dirname(_os.path.abspath(__file__))

    def _forget_placement(app):
        """Reproduce the toolbar's "Forget placement" state: drop every
        placement decision, keep every net/pin ROLE.

        Mirrors _forget_edits in the toolbar; kept in step with it."""
        app._user_positions = {}
        app._user_rotations = {}
        app._user_flips = {}
        app._auto_rotations = {}
        app._auto_flips = {}
        app._placed_instances = None
        app._placed_order = None
        app._placed_ref_pos = None
        app._pin_flight_data = None
        app._placed_text = None
        app._placed_draw_state = None
        app._t_terminals = []
        app._pin_to_t = {}
        app._boxes = []

    def _gate(app, label, note=''):
        """Print the pass/fail line for one placed app and return ok."""
        nonlocal all_ok
        # No explicit instance list: _self_check then measures
        # _metric_instances(), i.e. the RENDERED copy (_cached_instances)
        # that _render just drew.  It used to pass _placed_instances,
        # which measured PLACEMENT's copy of every instance — a
        # different set of objects, built from a different rotation
        # source — so the harness printed ALL PASS on layouts whose
        # screen plainly showed reserved-box overlaps (OPAX197 4,
        # LM324 2).  §7's "MEASURE WHAT THE USER SEES".
        sc = app._self_check()
        composite_ov = sc.get('overlaps', 0)
        tbody_pairs = sc.get('tbody_pairs', [])
        # EVERY T-body hit is a bug now, owner included — see
        # _t_body_overlap_pairs / _all_overlap_pairs_boxed.  The
        # non-owner filter existed because the detector flagged
        # proximity, not overlap, so an owned T placed exactly right
        # tripped it; with the owner test now strict, excluding owners
        # would only hide the real thing.
        tbody_bugs = list(tbody_pairs)
        # RESERVED-box clashes are gated too.  They are the overlap the
        # user sees directly — _placement_extent is what the BBoxes
        # overlay draws — and the composite count does not contain them,
        # so a change could take a circuit from 0 to 3 blue-box
        # collisions with -v still reporting ALL PASS.  That happened,
        # twice, and both times the regression was found by hand.
        reserved_ov = sc.get('reserved_overlaps', 0)
        reserved_pairs = sc.get('reserved_pairs', [])
        tgap_bugs = sc.get('t_pin_gap_bugs', [])
        # A label the placer could not seat is drawn as an ERROR box on
        # screen; it used to print but not fail the gate.
        place_errs = list(getattr(app, '_placement_errors', None) or [])
        ok = (composite_ov == 0 and len(tbody_bugs) == 0
              and reserved_ov == 0 and len(tgap_bugs) == 0
              and not place_errs)
        if not ok:
            all_ok = False
        print('=== %s%s ===' % (label, note))
        print(f'  [{"PASS" if ok else "FAIL"}] overlap-free clean Place '
              f'(composite_overlaps==0, T-body overlaps==0, '
              f'reserved_overlaps==0, T-pin flight lines >= '
              f'{app._T_PIN_MIN_GAP:.0f}px, placement_errors==0)')
        print(f'  composite_overlaps={composite_ov}  '
              f'tbody_overlaps={len(tbody_pairs)}  '
              f'reserved_overlaps={reserved_ov}  '
              f'placement_errors={len(place_errs)}')
        # Flow is measured HERE, on the Place being gated, because the
        # caller's later metrics run after a second Place.
        _ff, _fb, _gf, _gb, _fw = app._flow_report(app._metric_instances())
        print(f'  flow: {_fb} of {_ff + _fb} driver->receiver pairs run '
              f'backward; feedback nets {_gf} forward / {_gb} backward  '
              f'[informational]')
        for _dx, _n, _d, _r in _fw[:5]:
            print(f'      {_dx:6.0f} px  net {_n:<12} {_d:<16} -> {_r}')
        # MEDIAN MOVES, with the pattern each expresses.  A pattern that
        # recurs is a candidate for a pre-placement rule.
        _ml = getattr(app, '_median_log', None) or []
        print(f'  median moves: {len(_ml)} over sweeps '
              f'{getattr(app, "_median_sweeps", [])}  [informational]')
        _pat = defaultdict(int)
        for _m in _ml:
            for _w in (_m['why'] or '(no partner)').split('; '):
                _pat[_w.split(' (')[0]] += 1
        for _w, _k in sorted(_pat.items(), key=lambda t: (-t[1], t[0])):
            print(f'      {_k:3d}x  {_w}')
        for _m in _ml:
            print(f'      s{_m["sweep"]} {_m["how"]:<10} '
                  f'd=({_m["dx"]:.0f},{_m["dy"]:.0f}) '
                  f'saved {_m["saved"]:.0f} px, crossings {_m["cross"]:+d}: '
                  f'{_m["why"]}')
        if tgap_bugs:
            print(f'  T-pin flight lines under {app._T_PIN_MIN_GAP:.1f} px '
                  f'({len(tgap_bugs)}):')
            for g in tgap_bugs[:10]:
                print(f'      {g["ref"]:<20} pin {g["pin"]:<4} '
                      f'net {g["net"]:<10} {g["gap"]:.2f} px')
        if reserved_pairs:
            print('  reserved-box overlap detail (%d):' % len(reserved_pairs))
            for ra, rb, _ba, _bb in reserved_pairs[:10]:
                print('      %-28s X  %s' % (ra, rb))
        if not ok:
            # HEADERS, because the reserved list above is capped at 10.
            # Without them these two lists ran straight on from that cap
            # and read as continued reserved-box detail -- which is how
            # OPAX197's T:MID pairs looked like 19 reserved clashes on
            # T's owning their own box, when in fact own_boxes==0 and
            # reserved_pairs held no T key at all.
            _cp = sorted(sc.get('overlap_pairs', []))
            if _cp:
                print(f'  composite overlap pairs ({len(_cp)}):')
            for a, b in _cp:
                print('      %-28s X  %s' % (a, b))
            if tbody_bugs:
                print(f'  T-body overlap pairs ({len(tbody_bugs)}):')
            for p in tbody_bugs:
                print('      T net=%-10s id=%-4s X  %s (%s)'
                      % (p.get('net'), p.get('t_id'), p.get('ref'),
                         p.get('kind')))
        return sc

    def _wire_rows(app):
        """Takes an app and returns (report, crossings) for what it draws."""
        ins = (getattr(app, '_cached_instances', None)
               or app._placed_instances or [])
        _fw, _fb = app._flow_report(ins)[:2]
        return (app._wire_length_report(ins, top=6),
                app._flight_crossing_count(ins)[0], _fb, _fw + _fb)

    def _print_longest(rep):
        for key, word in (('longest_blue', 'blue'),
                          ('longest_purple', 'purple')):
            for d, _c, net, a, b in rep[key]:
                print('      %7.0f px  %-6s net %-16s %-18s %s'
                      % (d, word, net, a, b))

    def _grade(lib_path, sub, sym, auto):
        """Takes the circuit, the hand-placed file named by `grade` and the
        automatic layout's (report, crossings), loads the file exactly as
        Open P&R does and prints both side by side, with hand/auto."""
        import json as _json
        with open(grade) as f:
            data = _json.load(f)
        app3 = SpiceSchem(lib_path, sym, fulltext=True, subckt=sub,
                          no_pr=True, skip_bk=skip_bk, no_ports=no_ports,
                          no_uncross=no_uncross)
        for _ in range(12):
            app3.update_idletasks(); app3.update()
        app3._apply_pr_data(data)
        for _ in range(12):
            app3.update_idletasks(); app3.update()
        hand = _wire_rows(app3)
        print('=== GRADE: %s against the automatic layout ==='
              % _os.path.basename(grade))
        print('                    %12s %12s %8s' % ('auto', 'hand',
                                                    'hand/auto'))
        for word, a, h in (('blue wire px', auto[0]['blue'], hand[0]['blue']),
                           ('purple px', auto[0]['purple'],
                            hand[0]['purple']),
                           ('crossings', auto[1], hand[1]),
                           ('backward pairs', auto[2], hand[2]),
                           ('pairs scored', auto[3], hand[3])):
            print('  %-17s %12.0f %12.0f %8s'
                  % (word, a, h, ('%.2f' % (h / a)) if a else '-'))
        print('  hand layout, longest lines:')
        _print_longest(hand[0])

    def _check_one(lib_path, sub, sym):
        nonlocal all_ok
        # no_pr defaults on, so -v measures the same layout on any machine
        # whatever .pr.json files sit beside the netlist.
        app = SpiceSchem(lib_path, sym, fulltext=True, subckt=sub,
                         no_pr=not use_pr, skip_bk=skip_bk,
                         no_ports=no_ports, no_uncross=no_uncross,
                         post_fixups=post_fixups)
        app.update()
        for _ in range(12):
            app.update_idletasks(); app.update()
        s0 = {i.comp['ref']: (round(i.ox_px), round(i.oy_px))
              for i in app._placed_instances}
        # MEASURE THE FIRST PLACE.  Everything below used
        # to be measured AFTER a second _run_placement(), so the headline
        # numbers described a layout nobody ever sees: the toolbar shows
        # the first Place.  The gap is not cosmetic -- on OPAx197 the
        # first Place is 199 crossings and the second 358 -- and it made
        # -v disagree with every hand probe.  The second Place still runs
        # below, but only to compute `parity`.
        # Legacy body-only overlap check — INFORMATIONAL ONLY, may
        # diverge from what render actually draws (see docstring).
        ab = []
        for inst in app._placed_instances:
            ab.extend(instance_bboxes(inst))
        legacy_ov_pairs = scanline_overlaps(ab)
        nbox = len(app._boxes)
        label = sub or _os.path.basename(lib_path)
        sc = _gate(app, label)
        # Measured now: the second Place below replaces the layout.
        try:
            _hits = app._line_body_hits(
                getattr(app, '_cached_instances', None)
                or app._placed_instances)
        except Exception:
            _hits = None
        try:
            _grz = len(app._self_graze_pairs(
                getattr(app, '_cached_instances', None)
                or app._placed_instances))
        except Exception:
            _grz = None
        _auto = _wire_rows(app) if grade else None
        _ran = 'yes' if getattr(app, '_chain_ran', False) else 'NO'
        app._run_placement()
        for _ in range(12):
            app.update_idletasks(); app.update()
        s1 = {i.comp['ref']: (round(i.ox_px), round(i.oy_px))
              for i in app._placed_instances}
        # INFORMATIONAL ONLY (see docstring) — a second Place from a
        # clean state legitimately need not match the first.
        parity = sum(1 for r in s0 if s0[r] != s1.get(r))
        composite_ov = sc.get('overlaps', 0)
        composite_ov_pairs = sc.get('overlap_pairs', [])
        tbody_pairs = sc.get('tbody_pairs', [])
        tbody_bugs = [p for p in tbody_pairs if not p.get('owner')]
        # BOXES, not clusters -- app._boxes is the post-refine list.
        try:
            _nb, _np, _ov, _pc = app._box_overlap_metric()
            print('  box overlap=%d pair(s) among %d boxes, %.2fMpx '
                  '(%.0f%% of box area)  [informational]'
                  % (_np, _nb, _ov / 1e6, _pc))
            _fl, _fb = app._foreign_box_lines()
            print(f'  box separation: {_fl} flight line(s) through a '
                  f'foreign box, {_fb} line(s) joining two boxes  '
                  '[informational]')
            _rows, _fill = app._box_fill_metric()
            _big = [p for n, p in _rows if n >= 3]
            print('  box fill=%.0f%% overall; %d box(es) of 3+ parts, '
                  'worst %s  [informational]'
                  % (_fill, len(_big),
                     ('%.0f%%' % _big[0]) if _big else 'n/a'))
        except Exception:
            pass
        if _grz is not None:
            print('  lines grazing a pin of their own part=%d  '
                  '[informational]' % _grz)
        if _hits is not None:
            print('  lines through a part=%d (blue %d, purple %d)  '
                  '[informational]'
                  % (len(_hits), sum(h[0] == 'blue' for h in _hits),
                     sum(h[0] == 'purple' for h in _hits)))
        print(f'  chain layout ran: {_ran} (first Place)  [informational]')
        print('  boxes=%d  rCrossings=%d (rotation-fixable)  '
              'crossings=%d (other)  [informational]'
              % (nbox, sc.get('cross_ab', 0), sc.get('cross_other', 0)))
        print('  parity(2nd Place vs 1st)=%d of %d refs moved '
              '[informational — idempotence not required, see docstring]'
              % (parity, len(s0)))
        # a fingerprint of the final layout, so
        # CROSS-PROCESS determinism is checkable.  `parity` compares two
        # Places inside ONE process and is blind to anything driven by
        # Python's per-process string-hash randomisation — it read 0
        # while the same circuit was in fact laying out two different
        # ways depending on PYTHONHASHSEED (a frozenset of refs being
        # iterated raw, then fed to an order-preserving separation pass).
        # Run the harness twice under different PYTHONHASHSEED values and
        # compare this line; it must be identical.  Example:
        #   for s in 0 1 2; do PYTHONHASHSEED=$s ./sp2Sch.py -v ... ; done
        try:
            import hashlib as _hl
            _fp = _hl.sha1(
                ';'.join('%s:%.2f:%.2f:%s'
                         % (i.comp['ref'], i.ox_px, i.oy_px,
                            i.rotation_deg)
                         for i in sorted(app._placed_instances or [],
                                         key=lambda x: x.comp['ref'])
                         ).encode()).hexdigest()[:16]
            print('  layout fingerprint=%s  [compare across '
                  'PYTHONHASHSEED values]' % _fp)
        except Exception as _exc:
            print('  layout fingerprint unavailable: %r' % (_exc,))
        # Font fingerprint: boxes are sized from Tk font measurements, so layout
        # fingerprints only compare between machines whose fonts measure alike.
        try:
            import tkinter.font as _tkf
            _f = _tkf.Font(family=FONT_FAMILY, size=10)
            _probe = ('100.0E3', 'R_Small_US', '4.5E5',
                      'X_LP2951_U1_U3.E1')
            _widths = ','.join(str(_f.measure(t)) for t in _probe)
            _fam, _ls = _f.actual('family'), _f.metrics('linespace')
            _ffp = _hl.sha1(
                f'{_fam}|{_ls}|{_widths}'.encode()).hexdigest()[:12]
            print(f'  font fingerprint={_ffp}  ({_fam}, linespace {_ls}, '
                  f'widths {_widths})  [layout fingerprints are only '
                  'comparable between machines with the SAME font '
                  'fingerprint]')
            _srcs = getattr(app, '_sym_sources', None) or ['<none>']
            _sfp = _hl.sha1('|'.join(_srcs).encode()).hexdigest()[:12]
            print('  symbol fingerprint=%s  (%s)  [same caveat: different '
                  'symbol libraries give different geometry]'
                  % (_sfp, ', '.join(_srcs)))
        except Exception as _exc:
            print('  font fingerprint unavailable: %r' % (_exc,))
        # the placement-slack totals, so any attempt to
        # "shrink the metric" has a single number to move.  top5 is the
        # working queue; all is the whole circuit, which is the honest
        # figure of merit — a change that only shuffles which five items
        # are worst has not improved anything.
        try:
            _all = app._placement_slack_report(top=10 ** 9)
            _t5 = sum(r['slack'] for r in _all[:5])
            _ta = sum(r['slack'] for r in _all)
            print('  recoverable flight-line: top5=%.0f px  all=%.0f px '
                  'over %d items  [informational]' % (_t5, _ta, len(_all)))
        except Exception as _exc:
            print('  placement slack unavailable: %r' % (_exc,))
        # Flight line by axis: too much |dx| points at rank or barrier width,
        # too much |dy| at rows, so report them separately.
        try:
            _insts = (getattr(app, '_cached_instances', None)
                      or app._placed_instances or [])
            _segs = list(app._flight_segments(_insts))
            _rk = {}
            for _inf in (getattr(app, '_dbg_lane_info', None) or []):
                _rk.update(_inf.get('rank_of_ref') or {})
            _pop = {}
            for _v in _rk.values():
                _pop[_v] = _pop.get(_v, 0) + 1
            _tdx = _tdy = 0.0
            _rows = []
            for _sg in _segs:
                _a, _b = _sg[0], _sg[1]
                _dx = abs(_b[0] - _a[0]); _dy = abs(_b[1] - _a[1])
                _tdx += _dx; _tdy += _dy
                _cr = max(_pop.get(_rk.get(_sg[2]), 0),
                          _pop.get(_rk.get(_sg[4]), 0))
                _rows.append((_cr, _dy))
            print('  flight-line by axis: |dx|=%.0f px  |dy|=%.0f px  '
                  'dy/dx=%.2f  [informational, BLUE wires only]'
                  % (_tdx, _tdy, (_tdy / _tdx) if _tdx else 0.0))
            # TOTAL WIRE LENGTH is the number that separates a hand
            # placement from this one, and nothing printed it.  The
            # user's LM324.sub layouts draw the SAME 51 wires as the
            # placer on a page of the same size, in 11243 px (4
            # crossings) and 13700 px (11 crossings) against the
            # placer's 15530 (17).  Crossings alone cannot see that gap:
            # two layouts with equal crossings are not equally readable
            # if one runs its wires twice as far.  A number nothing
            # prints every run is a number that goes stale, so it is
            # printed here rather than left in a probe.
            _len = [((_s[1][0] - _s[0][0]) ** 2
                     + (_s[1][1] - _s[0][1]) ** 2) ** 0.5 for _s in _segs]
            print('  wire length: total=%.0f px over %d wires  '
                  'mean=%.0f  longest=%.0f  [informational]'
                  % (sum(_len), len(_len),
                     (sum(_len) / len(_len)) if _len else 0.0,
                     max(_len) if _len else 0.0))
            _wr = app._wire_length_report(_insts, top=6)
            print('  wire length (Manhattan): blue=%.0f px over %d wires  '
                  'purple=%.0f px over %d lines  [informational]'
                  % (_wr['blue'], _wr['n_blue'], _wr['purple'],
                     _wr['n_purple']))
            _print_longest(_wr)
            # PAIR NETS: the user's metric.  A net that joins exactly TWO
            # instances and is neither T-consumed nor a rail is a plain
            # wire between two parts, and its length is a direct measure
            # of whether they were placed together.  Unlike the totals
            # above it cannot be diluted by fan-out, so it is the number
            # to watch when judging a placement change.  Lengths under
            # 30 px are already as short as the pins allow and are
            # counted but not listed.
            _pairs = app._pair_net_lengths(_insts)
            if _pairs:
                _long = [p for p in _pairs if p[0] >= 30.0]
                print('  pair nets: %d (a * is a LEAF PIN on a crowded '
                      'net, at half weight), total=%.0f px, '
                      'longest=%.0f  [informational]'
                      % (len(_pairs), sum(p[0] for p in _pairs),
                         _pairs[0][0]))
                for _d, _net, _a, _b in _long[:12]:
                    print('      %7.0f px  net %-10s %-16s %s'
                          % (_d, _net, _a, _b))
            # SPLIT BY COLOUR.  The totals above count only the BLUE
            # pin-to-pin wires _flight_segments builds; the PURPLE
            # value -> equation lines had never been measured at all.
            # They matter less — a sense line is a dependency, not a
            # wire — but they are still better horizontal, and lumping
            # them in (or leaving them out silently) makes a dy/dx
            # figure impossible to act on.  Reported separately so a
            # bad ratio can be attributed to the right kind of line.
            _sdx = _sdy = 0.0
            _sn = 0
            for _ss in app._sense_segments(_insts):
                _a, _b = _ss[5], _ss[6]
                _sdx += abs(_b[0] - _a[0]); _sdy += abs(_b[1] - _a[1])
                _sn += 1
            if _sn:
                print('  sense-line by axis: |dx|=%.0f px  |dy|=%.0f px  '
                      'dy/dx=%.2f  over %d purple lines  [informational]'
                      % (_sdx, _sdy, (_sdy / _sdx) if _sdx else 0.0, _sn))
            else:
                print('  sense-line by axis: no purple lines on this deck'
                      '  [informational]')
            _tb, _tw = app._t_defect_counts(_insts)
            print('  T-symbol defects: %d back across own body, %d label '
                  'on own flight line  [informational]' % (_tb, _tw))
            _rows.sort()
            _h = len(_rows) // 2
            if _h:
                _lo = sum(r[1] for r in _rows[:_h]) / _h
                _hi = sum(r[1] for r in _rows[_h:]) / (len(_rows) - _h)
                print('  mean |dy| by rank crowding: sparse=%.0f px  '
                      'crowded=%.0f px  [informational]' % (_lo, _hi))
        except Exception as _exc:
            print('  flight-line by axis unavailable: %r' % (_exc,))
        if legacy_ov_pairs:
            print('  legacy body-only scanline overlaps=%d '
                  '[informational, see docstring]:' % len(legacy_ov_pairs))
            rows = sorted((la, lb) if la <= lb else (lb, la)
                          for (la, _ba), (lb, _bb) in legacy_ov_pairs)
            for la, lb in rows:
                print('      %-28s  X  %s' % (la, lb))
        if composite_ov_pairs:
            print('  composite overlap detail (%d):' % composite_ov)
            for a, b in sorted(composite_ov_pairs):
                print('      %-28s  X  %s' % (a, b))
        if tbody_bugs:
            print('  non-owner T-body overlap detail (%d):' % len(tbody_bugs))
            for p in tbody_bugs:
                print('      T net=%-10s id=%-4s  X  %s (%s)'
                      % (p.get('net'), p.get('t_id'), p.get('ref'),
                         p.get('kind')))
        # Roles-only baseline: Place again keeping net roles, the state after
        # Forget placement.
        try:
            _pr = app._default_pr_path()
        except Exception:
            _pr = None
        if not use_pr and _pr and _os.path.exists(_pr):
            app2 = SpiceSchem(lib_path, sym, fulltext=True, subckt=sub,
                              no_pr=False,
                              skip_bk=skip_bk, no_ports=no_ports,
                              no_uncross=no_uncross)
            app2.update()
            for _ in range(12):
                app2.update_idletasks(); app2.update()
            _forget_placement(app2)
            app2._run_placement()
            for _ in range(12):
                app2.update_idletasks(); app2.update()
            _gate(app2, label, ' [roles kept, placement forgotten]')
        if grade:
            _grade(lib_path, sub, sym, _auto)

    all_ok = True
    if spice_path:
        # Verify the user-specified circuit.  Resolve a symbol path the same
        # way the GUI does (None -> merged std libs) unless one was given.
        _check_one(spice_path, subckt, sym_path)
    else:
        # Built-in reference pair — same "overlap-free clean Place"
        # contract as any other circuit, no hardcoded expected counts
        # (see docstring for why those went stale).
        sym = _os.path.join(here, 'Sim_SPICE.kicad_sym')
        if not _os.path.exists(sym):
            sym = None
        refs = [
            ('LM324.lib',  'LM324'),
            ('OPAx197.LIB', 'OPAX197'),
        ]
        missing = [lib for lib, _s in refs
                   if not _os.path.exists(_os.path.join(here, lib))]
        if missing:
            print('sp2Sch.py -v: reference libraries not found next to the '
                  'script: %s' % ', '.join(missing))
            print('Pass a circuit to verify, e.g.:  sp2Sch.py -v '
                  'path/to/circuit.lib -s SUBCKT')
            return 1
        for lib, sub in refs:
            _check_one(_os.path.join(here, lib), sub, sym)
    print('\nRESULT:', 'ALL PASS' if all_ok else 'FAILURES ABOVE')
    return 0 if all_ok else 1


def _usage(prog=None):
    """Print the script's current command-line options.

    Kept next to main()'s parser so the two are edited together — the
    single most common way a usage message goes stale is living far away
    from the code that actually reads the arguments."""
    prog = prog or os.path.basename(sys.argv[0] or 'sp2Sch.py')
    print(f"""\
{prog} — convert a SPICE netlist into an editable schematic.

Usage:
  {prog} [options] <netlist> [<symbols>.kicad_sym]

Arguments:
  <netlist>              SPICE file to read (.sp .cir .net .lib .LIB .asc).
                         Omit it and a file-open dialog appears.
  <symbols>.kicad_sym    Optional single symbol library.  When omitted, the
                         standard KiCad libraries are merged instead, which
                         is what you normally want (a single file usually
                         lacks the V/I/E/G/B source symbols).

Environment:
  SP2SCH_SYMBOL_DIR      Where to find the KiCad symbol libraries.
                         os.pathsep-separated list (':' Linux/macOS,
                         ';' Windows); searched FIRST, ahead of even the
                         current directory, so it overrides everything.
                         Each entry may hold KiCad 10 split libraries
                         (Device.kicad_symdir/) or the classic single
                         files (Device.kicad_sym).
  KICAD10_SYMBOL_DIR     KiCad's OWN variables, honoured so an already-
  KICAD9_SYMBOL_DIR      configured machine needs no extra setup.  Newest
  KICAD8_SYMBOL_DIR      first, ranked below '.' so a project's bundled
                         copies still win.
  SP2SCH_TRACK           Development position-log tracing (see _dbg_track).

  Without any of these the search order is: '.', the KiCad env vars, a
  Windows KiCad install, ~/Applications/AppDir/share/kicad/symbols (an
  unpacked KiCad 10 AppImage), /usr/share/kicad/symbols,
  /usr/local/share/kicad/symbols.

Options:
  -h, -?, --help         Show this message and exit.
  -s, --subckt NAME      Elaborate this .SUBCKT instead of the largest one.
                         Also accepted as -s=NAME / --subckt=NAME.
  -p, --post-fixups      Run the post-Sugiyama/BK fix-up passes (chain and
                         parallel alignment, rank monotonicity, body and
                         cluster overlap resolution, self-cross rotation).
                         Off by default: what BK computes is what is drawn.
                         The self-cross ROTATION pass is no longer one of
                         these — it rotates in place, never moves, and now
                         runs by default; --no-uncross disables it.
  -n, --no-pr            Do NOT auto-load the sibling <netlist>.pr.json at
                         startup; come up on a fresh auto-Place instead.
                         The file is left untouched and "Open P&R…" still
                         loads it on demand.
  -r, --slack            Print the "items with the most RECOVERABLE
                         flight-line length" table after Place.  Off by
                         default; it is a development diagnostic.
  -g, --rank-grid        Label every part with its `rank.order` on a small
                         orange chip at the part's top-left: rank is the
                         layer (drawn left-to-right, so a vertical band),
                         order is its position within that layer after
                         crossing reduction.  The older orange/yellow
                         BAND overlay is gone — a band had to be inferred
                         from where a rank's members averaged out, so two
                         overlapping ranks drew a band through both and
                         said nothing about any individual part.
      --with-pr          With -v, apply saved .pr.json overrides so the
                         harness measures the same configuration the GUI
                         toolbar reports (default: overrides ignored, for
                         a machine-independent baseline).
  -k, --no-ports         Order the layered graph with the plain node-index
      --sugiyama         barycenter (classic Sugiyama/BK) instead of the
                         default FIXED_ORDER port-constrained one, which
                         places each edge at the PIN it really attaches to.
                         For comparison; the port-constrained ordering is
                         the default because a symbol's pins are ports.
      --no-uncross       Skip the post-placement self-cross ROTATION pass.
                         That pass rotates parts in place (never moves
                         them), so it cannot disturb a reservation, and it
                         runs by DEFAULT: it is the only thing that clears
                         rotation-fixable crossings (rCrossings 5/1/2 -> 0
                         on OPAX197/LM324.sub/LP2951), which no
                         pre-placement rule has ever moved.
      --no-bk            Skip Brandes-Kopf coordinate assignment (and the
                         relaxation fallback) and keep the RAW ordering
                         Sugiyama produced.  Pair with -g to see what the
                         layering alone decided.
  -B, --best-of-bk       Place once per Brandes-Kopf candidate (the
                         published balance plus its four single
                         candidates) and keep whichever DRAWS the fewest
                         crossings.  Costs 6 placements instead of 1.
                         Measured: LM324.sub 15 -> 9, LP2951 4 -> 2,
                         LM324.lib 7 -> 7 (the balance already wins).
  -v, --verify           Run the self-check harness and exit.  With a
                         netlist (and -s) it verifies THAT circuit;
                         with no netlist it runs the built-in reference
                         pair (LM324.lib + OPAx197.LIB, read from beside
                         the script).  Always verifies a clean Place —
                         .pr.json auto-load is suppressed.
  -G, --grade PATH       With -v, load the hand-placed PATH (.pr.json) and
                         print its blue/purple wire length and crossings
                         beside the automatic layout's.
  -m, --group MIN[,MAX]  Run the grouper, print its result, and exit.
                         Also accepted as -m=MIN[,MAX] / --group=MIN[,MAX].
      --svg PATH         Write a placed-block schematic preview to PATH.
      --cols N           Columns for --svg (default 12).

Examples:
  {prog} LM324.lib -s LM324
  {prog} -n LP2951.lib -s LP2951        # ignore LP2951.lib.pr.json
  {prog} -v OPAx197.LIB -s OPAX197 Sim_SPICE.kicad_sym
  {prog} -v OPAx197.LIB -s OPAX197 -G OPAx197.LIB.OPAX197_placeX.pr.json""")


def main():
    spice_path = None
    sym_path = None
    # GUI defaults to full VALUE-equation display.  The
    # toolbar "Full text" button now toggles between full and truncated
    # per-instance (selection-scoped).  The command-line --fulltext
    # flag is removed; truncation is a runtime UI choice, not a
    # startup option.
    fulltext = True
    post_fixups = False   # -p: allow the post-Sugiyama/BK fix-up passes
    subckt_name = None  # --subckt NAME / -s NAME: which .SUBCKT to elaborate
    group_spec = None   # -m MIN_SHARED[,MAX_SIZE]: run grouper, print, exit
    svg_path = None     # --svg PATH: write placed-block schematic preview
    svg_cols = 12       # --cols N: number of signal-flow columns
    best_of_bk = False  # -B/--best-of-bk: place 5x, keep fewest crossings
    do_verify = False   # -v/--verify: run the self-check harness and exit
    no_pr = False       # -n/--no-pr: skip the startup .pr.json auto-load
    rank_grid = False   # -g/--rank-grid: draw the Sugiyama layer overlay
    skip_bk = False     # --no-bk: keep the raw ordering, skip coord assign
    no_ports = False    # -k/--no-ports/--sugiyama: node-index barycenter
    no_uncross = False  # --no-uncross: skip the self-cross rotation pass
    use_pr = False      # --with-pr: let -v apply saved .pr.json overrides
    grade_path = None   # -G/--grade PATH: -v grades this hand layout
    args = list(sys.argv[1:])
    i = 0
    while i < len(args):
        a = args[i]
        if a in ('-h', '-?', '--help'):
            _usage()
            sys.exit(0)
        elif a in ('-n', '--no-pr', '--nopr'):
            no_pr = True
        elif a in ('-g', '--rank-grid', '--grid'):
            rank_grid = True
        elif a in ('-r', '--slack'):
            global _SHOW_SLACK_REPORT
            _SHOW_SLACK_REPORT = True
        elif a in ('--with-pr', '--withpr'):
            use_pr = True
        elif a in ('-p', '--post-fixups', '--postfix'):
            post_fixups = True
        elif a in ('--no-bk', '--nobk'):
            skip_bk = True
        elif a in ('-k', '--no-ports', '--noports', '--sugiyama'):
            no_ports = True
        elif a in ('--no-uncross', '--nouncross'):
            no_uncross = True
        elif a in ('-B', '--best-of-bk'):
            best_of_bk = True
        elif a in ('-v', '--verify'):
            do_verify = True
        elif a in ('-G', '--grade'):
            if i + 1 < len(args):
                grade_path = args[i + 1]
                i += 1
            else:
                print("Error: -G/--grade requires a .pr.json path.")
                sys.exit(1)
        elif a.startswith('--grade='):
            grade_path = a.split('=', 1)[1]
        elif a in ('-s', '--subckt'):
            # Two-token form: -s NAME
            if i + 1 < len(args):
                subckt_name = args[i + 1]
                i += 1
            else:
                print("Error: -s/--subckt requires a SUBCKT name argument.")
                sys.exit(1)
        elif a.startswith('--subckt='):
            subckt_name = a.split('=', 1)[1]
        elif a.startswith('-s='):
            subckt_name = a.split('=', 1)[1]
        elif a in ('-m', '--group'):
            # Two-token form: -m MIN_SHARED[,MAX_SIZE]
            if i + 1 < len(args):
                group_spec = args[i + 1]
                i += 1
            else:
                print("Error: -m/--group requires MIN_SHARED[,MAX_SIZE].")
                sys.exit(1)
        elif a.startswith('--group='):
            group_spec = a.split('=', 1)[1]
        elif a.startswith('-m='):
            group_spec = a.split('=', 1)[1]
        elif a in ('--svg',):
            if i + 1 < len(args):
                svg_path = args[i + 1]
                i += 1
            else:
                print("Error: --svg requires an output path.")
                sys.exit(1)
        elif a.startswith('--svg='):
            svg_path = a.split('=', 1)[1]
        elif a in ('--cols',):
            if i + 1 < len(args):
                svg_cols = int(args[i + 1])
                i += 1
        elif a.startswith('--cols='):
            svg_cols = int(a.split('=', 1)[1])
        elif a.endswith('.kicad_sym'):
            sym_path  = a
        elif a.startswith('-') and a != '-':
            # an unrecognised dash-argument used to fall
            # through to the `else` below and be adopted as the SPICE
            # filename, so a typo'd flag produced "file not found: -q"
            # instead of naming the real problem.
            print(f"Error: unknown option {a!r}.")
            _usage()
            sys.exit(1)
        else:
            spice_path = a
        i += 1

    # Leave sym_path None so SpiceSchem merges the standard libraries (Device
    # and Simulation_SPICE); one file would lack half the symbols.

    # -v/--verify: run the self-check harness and exit.
    # If a SPICE file + subckt are given (sp2Sch.py -v circuit.lib -s NAME),
    # verify THAT circuit; otherwise run the built-in reference pair.  Passing
    # the parsed args fixes the crash where -v ignored the user's file and
    # looked for LM324.lib/OPAx197.LIB next to the script.
    if do_verify:
        sys.exit(_run_verify(spice_path=spice_path, sym_path=sym_path,
                             subckt=subckt_name, skip_bk=skip_bk,
                             no_ports=no_ports, no_uncross=no_uncross,
                             use_pr=use_pr, post_fixups=post_fixups,
                             grade=grade_path))

    if spice_path is None:
        root = tk.Tk(); root.withdraw()
        spice_path = filedialog.askopenfilename(
            title='Open SPICE file',
            filetypes=[('SPICE netlist', '*.sp *.cir *.net *.lib *.LIB *.asc'),
                       ('All files', '*.*')])
        root.destroy()
        if not spice_path:
            print('No SPICE file selected. Exiting.')
            return

    # Friendly error instead of a stack trace when the user
    # mistypes the SPICE filename on the command line.  Same check for
    # the symbol library, though sym_path may legitimately be None
    # (it'll just produce a "no symbol library loaded" warning below).
    if not Path(spice_path).exists():
        print(f"Error: SPICE file not found: {spice_path}")
        # Offer the user a Tk-style dialog as a second chance.
        try:
            root = tk.Tk(); root.withdraw()
            messagebox.showerror(
                'sp2Sch — file not found',
                f"Cannot open SPICE file:\n  {spice_path}\n\n"
                f"Check the filename and try again.")
            root.destroy()
        except Exception:
            pass
        sys.exit(1)
    # -m/--group: run the experimental affinity grouper and exit.  No GUI,
    # no symbol library needed.  Spec grammar (no spaces, so no quoting):
    #   LEVEL[:LEVEL...]   where each LEVEL is  MIN_SHARED[,MAX_SIZE]
    #                      or  jJACCARD[,MAX_SIZE]  (normalized bonding).
    # Colons add hierarchy levels (groups-of-groups).  MAX_SIZE caps the
    # number of THIS level's nodes per merged group (default 4).
    #   e.g.  -m 2,4            one level, share>=2, cap 4
    #         -m 2,4:1,8:j0.5,16  three levels, last bonds by Jaccard>=0.5
    if group_spec is not None or svg_path is not None:
        if group_spec is None:
            group_spec = '2,4:1,8:j0.5,16'
        level_specs = []
        try:
            for seg in group_spec.split(':'):
                parts = [t for t in seg.replace(' ', '').split(',') if t]
                if not parts or len(parts) > 2:
                    raise ValueError
                head = parts[0]
                if head[:1].lower() == 'j':
                    kind, val = 'jac', float(head[1:])
                else:
                    kind, val = 'raw', int(head)
                max_size = int(parts[1]) if len(parts) > 1 else 4
                level_specs.append((kind, val, max_size))
        except ValueError:
            print("Error: -m expects LEVEL[:LEVEL...], each "
                  "MIN_SHARED[,MAX_SIZE] or jJACCARD[,MAX_SIZE], "
                  "e.g.  -m 2,4:1,8:j0.5,16")
            sys.exit(1)
        parser = SpiceParser()
        parser.parse_file(spice_path)
        if subckt_name:
            comps = parser.expand_subckt(subckt_name)
            scope = f"subckt {subckt_name}"
        else:
            comps = parser.components
            scope = "top level"
        if not comps:
            print(f"No components found at {scope}.  "
                  f"For a .SUBCKT library, name one with -s NAME.")
            return
        pairs = _ag_from_components(comps)
        rails = _ag_autodetect_rails(pairs)
        if len(level_specs) > 1:
            groups, history = _ag_group_levels(pairs, rails, level_specs)
        else:
            gate, score, max_size, _disp = _ag_spec_funcs(level_specs[0])
            groups = _ag_group(pairs, rails, score=score, gate=gate,
                               max_size=max_size)
            history = None
        multi = [g for g in groups if len(g[0]) > 1]
        print(f"Affinity grouping — {scope}: {len(comps)} instances")
        print(f"  spec={group_spec}  levels={len(level_specs)}  "
              f"score=lexicographic / jaccard (per level)")
        print(f"  rails excluded ({len(rails)}): {sorted(rails)}")
        if history:
            for h in history:
                print(f"  level {h['level']} [{h['spec']}]: "
                      f"{h['nodes_in']} nodes -> {h['groups_out']} groups   "
                      f"leaf-size hist {h['hist']}")
        else:
            sizes = {}
            for mem, _ext in groups:
                sizes[len(mem)] = sizes.get(len(mem), 0) + 1
            print(f"  {len(groups)} groups   size histogram "
                  f"{dict(sorted(sizes.items()))}   "
                  f"({len(multi)} multi-member)")
        print(f"  final multi-member groups ({len(multi)}):")
        for mem, ext in sorted(multi, key=lambda g: (-len(g[0]), g[0][0])):
            ms = '+'.join(mem if len(mem) <= 8 else mem[:8] + ['...'])
            es = ext if len(ext) <= 6 else ext[:6] + ['...']
            print(f"    [{len(mem):2d}] {ms}")
            print(f"         ext_pins({len(ext)}) = {es}")
        if svg_path is not None:
            if not subckt_name:
                print("  --svg needs -s NAME (a subckt to place).")
                return
            ports = parser.subckts[subckt_name.upper()]['ports']
            info = _ag_write_schematic(pairs, rails, groups, ports,
                                       svg_path, ncols=svg_cols)
            ar = info['w'] / info['h'] if info['h'] else 0.0
            print(f"  wrote {svg_path}: {info['w']:.0f} x {info['h']:.0f} px "
                  f"({svg_cols} cols, aspect W:H = {ar:.2f} : 1, "
                  f"i.e. ~{17:.0f} wide x {17/ar:.0f} high)")
        return

    if sym_path is not None and not Path(sym_path).exists():
        print(f"Error: symbol library not found: {sym_path}")
        try:
            root = tk.Tk(); root.withdraw()
            messagebox.showerror(
                'sp2Sch — file not found',
                f"Cannot open symbol library:\n  {sym_path}\n\n"
                f"Check the filename and try again.")
            root.destroy()
        except Exception:
            pass
        sys.exit(1)

    app = SpiceSchem(spice_path, sym_path, fulltext=fulltext,
                     post_fixups=post_fixups, best_of_bk=best_of_bk,
                      subckt=subckt_name, no_pr=no_pr,
                      rank_grid=rank_grid, skip_bk=skip_bk,
                      no_ports=no_ports, no_uncross=no_uncross)
    app.mainloop()


if __name__ == '__main__':
    main()
