master
John Lauer 1.0.61: Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schemati… baad156 14d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711
//! Schematic edit, second half: the geometry and connectivity the wiring verbs need, on top
//! of the lossless document model in `schematic`.
//!
//! * symbol transforms: a library point or vector through a placed unit's angle and mirror
//!   (the same order `Schematic::pin_points` uses, checked against KiCad's demo sheets);
//! * every pin of every placed unit with its sheet point, outward direction, name, number
//!   and electrical type;
//! * wires, buses, labels, junctions, no-connects, bus entries, texts and sheets as plain
//!   lists;
//! * KiCad's junction rule (three or more distinct exits at a point, a wire end on another
//!   wire's interior counting as two), used to add dots after new wiring and to prune them
//!   after a delete;
//! * the edit operations behind the newer kicad_sch_* verbs: multi-unit move with wire
//!   stretch, rotate, region move, annotate, symbol replace and library refresh, field reset,
//!   wire delete and split, label edit, junctions, stubs to a net, pin to pin routes, text,
//!   buses and bus entries.
//!
//! Everything here is pure document work; the verb layer resolves libraries, runs ERC and
//! writes the file.

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

use serde_json::{json, Value};

use crate::schematic::{
    fmt_num, font_node, is_symbol_instance, lib_symbol_pins, new_uuid, norm_rotation, pretty, project_name_for, property_node, quote, sub_unit, symbol_property, xy, Kind, Node, Schematic,
};

pub type Pt = (f64, f64);

/// Coordinates closer than this are the same point (KiCad stores nanometres; files carry
/// at most four decimals of a millimetre).
pub const EPS: f64 = 1e-3;

/// The schematic grid KiCad's connection points sit on.
pub const GRID: f64 = 1.27;

/// An operation error with a stable code for the verb layer.
#[derive(Clone, Debug)]
pub struct OpError {
    pub code: &'static str,
    pub msg: String,
}

impl OpError {
    pub fn new(code: &'static str, msg: impl Into<String>) -> OpError {
        OpError { code, msg: msg.into() }
    }
}

impl From<String> for OpError {
    fn from(msg: String) -> OpError {
        OpError { code: "edit_failed", msg }
    }
}

impl std::fmt::Display for OpError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}: {}", self.code, self.msg)
    }
}

pub type R<T> = Result<T, OpError>;

pub fn same(a: Pt, b: Pt) -> bool {
    (a.0 - b.0).abs() < EPS && (a.1 - b.1).abs() < EPS
}

pub fn r4(v: f64) -> f64 {
    (v * 1e4).round() / 1e4 + 0.0
}

pub fn rp(p: Pt) -> Pt {
    (r4(p.0), r4(p.1))
}

/// Round to the nearest 1.27 mm grid point.
pub fn snap(p: Pt) -> Pt {
    (r4((p.0 / GRID).round() * GRID), r4((p.1 / GRID).round() * GRID))
}

/// `p` lies on the segment a-b (ends included).
pub fn on_segment(p: Pt, a: Pt, b: Pt) -> bool {
    let (dx, dy) = (b.0 - a.0, b.1 - a.1);
    let len = (dx * dx + dy * dy).sqrt();
    if len < EPS {
        return same(p, a);
    }
    let cross = ((p.0 - a.0) * dy - (p.1 - a.1) * dx) / len;
    if cross.abs() > EPS {
        return false;
    }
    let t = ((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len;
    t > -EPS && t < len + EPS
}

/// `p` lies on a-b but is neither end.
pub fn on_interior(p: Pt, a: Pt, b: Pt) -> bool {
    !same(p, a) && !same(p, b) && on_segment(p, a, b)
}

pub fn in_box(p: Pt, bx: [f64; 4]) -> bool {
    let (x0, x1) = (bx[0].min(bx[2]), bx[0].max(bx[2]));
    let (y0, y1) = (bx[1].min(bx[3]), bx[1].max(bx[3]));
    p.0 > x0 - EPS && p.0 < x1 + EPS && p.1 > y0 - EPS && p.1 < y1 + EPS
}

fn unit_dir(from: Pt, to: Pt) -> Pt {
    let (dx, dy) = (to.0 - from.0, to.1 - from.1);
    let l = (dx * dx + dy * dy).sqrt();
    if l < EPS {
        (0.0, 0.0)
    } else {
        (r4(dx / l), r4(dy / l))
    }
}

/// "right", "left", "up", "down" (sheet view, y down) for an axis vector, else the angle.
pub fn dir_name(v: Pt) -> String {
    if same(v, (1.0, 0.0)) {
        "right".into()
    } else if same(v, (-1.0, 0.0)) {
        "left".into()
    } else if same(v, (0.0, -1.0)) {
        "up".into()
    } else if same(v, (0.0, 1.0)) {
        "down".into()
    } else {
        format!("{}deg", fmt_num((-v.1).atan2(v.0).to_degrees()))
    }
}

pub fn pt_json(p: Pt) -> Value {
    json!([r4(p.0), r4(p.1)])
}

fn at_of(n: &Node) -> Option<Pt> {
    let a = n.child("at")?;
    Some((a.num(0)?, a.num(1)?))
}

fn set_at_xy(n: &mut Node, p: Pt) {
    if let Some(a) = n.child_mut("at") {
        a.set_arg(0, Node::number(r4(p.0)));
        a.set_arg(1, Node::number(r4(p.1)));
    }
}

/// Shift every `(at x y ..)`, `(xy x y)`, `(start x y)`, `(end x y)` and `(center x y)` in a
/// top-level item by (dx, dy): a symbol instance's anchor and fields, a label and its
/// properties, a sheet and its pins, a text box.
pub fn shift_node(n: &mut Node, dx: f64, dy: f64) {
    let Some(children) = n.children_mut() else { return };
    for c in children.iter_mut() {
        match c.head() {
            Some("at") | Some("xy") | Some("start") | Some("end") | Some("center") | Some("mid") => {
                if let (Some(x), Some(y)) = (c.num(0), c.num(1)) {
                    c.set_arg(0, Node::number(r4(x + dx)));
                    c.set_arg(1, Node::number(r4(y + dy)));
                }
            }
            // An instance's `instances` block and stroke/effects carry no coordinates.
            Some("effects") | Some("stroke") | Some("instances") | Some("size") => {}
            _ => {
                if c.is_list() {
                    shift_node(c, dx, dy);
                }
            }
        }
    }
}

/// A canonical, whitespace-free spelling of a node, to compare two library blocks.
pub fn canon(n: &Node) -> String {
    match &n.kind {
        Kind::Atom(s) => s.clone(),
        Kind::List { children, .. } => format!("({})", children.iter().map(canon).collect::<Vec<_>>().join(" ")),
    }
}

// ---------------------------------------------------------------------------
// Symbol transform and pins
// ---------------------------------------------------------------------------

/// A placed unit's placement: anchor, angle (counterclockwise as seen) and mirror.
#[derive(Clone, Copy, Debug)]
pub struct SymXf {
    pub x: f64,
    pub y: f64,
    pub rot: f64,
    pub mirror: Option<char>,
}

impl SymXf {
    pub fn of(inst: &Node) -> SymXf {
        let at = inst.child("at");
        SymXf {
            x: at.and_then(|a| a.num(0)).unwrap_or(0.0),
            y: at.and_then(|a| a.num(1)).unwrap_or(0.0),
            rot: at.and_then(|a| a.num(2)).unwrap_or(0.0),
            mirror: inst.child_value("mirror").and_then(|m| m.chars().next()),
        }
    }
    /// A library vector (y up) as a sheet vector (y down).
    pub fn vec(&self, v: Pt) -> Pt {
        let (s, c) = self.rot.to_radians().sin_cos();
        let (mut qx, mut qy) = (v.0 * c - v.1 * s, v.0 * s + v.1 * c);
        match self.mirror {
            Some('x') => qy = -qy,
            Some('y') => qx = -qx,
            _ => {}
        }
        (r4(qx), r4(-qy))
    }
    /// A library point as a sheet point.
    pub fn point(&self, p: Pt) -> Pt {
        let v = self.vec(p);
        rp((self.x + v.0, self.y + v.1))
    }
}

/// Rotate a sheet vector (y down) counterclockwise as seen by `deg`.
pub fn rotate_sheet_vec(v: Pt, deg: f64) -> Pt {
    let (s, c) = deg.to_radians().sin_cos();
    let (ux, uy) = (v.0, -v.1);
    let (rx, ry) = (ux * c - uy * s, ux * s + uy * c);
    (r4(rx), r4(-ry))
}

/// One pin of a library symbol.
#[derive(Clone, Debug)]
pub struct LibPin {
    pub number: String,
    pub name: String,
    pub etype: String,
    pub at: Pt,
    pub angle: f64,
    pub length: f64,
    pub unit: u32,
    pub hidden: bool,
}

/// The pins a placed unit carries: shared (unit 0) and its own, body style `style` (or the
/// shared style 0). Library coordinates.
pub fn lib_unit_pins(lib: &Node, base: &str, unit: u32, style: u32) -> Vec<LibPin> {
    let mut out = Vec::new();
    for sub in lib.children().iter().filter(|n| n.head() == Some("symbol")) {
        let Some(name) = sub.arg(0) else { continue };
        let Some(u) = sub_unit(&name, base) else { continue };
        let st: u32 = name.rsplit('_').next().and_then(|s| s.parse().ok()).unwrap_or(1);
        if (u != 0 && u != unit) || (st != 0 && st != style) {
            continue;
        }
        for pin in sub.children().iter().filter(|n| n.head() == Some("pin")) {
            let (Some(num), Some(pat)) = (pin.child_value("number"), pin.child("at")) else { continue };
            let hidden = pin.children().iter().any(|c| c.raw() == Some("hide")) || pin.child("hide").and_then(|h| h.arg(0)).as_deref() == Some("yes");
            out.push(LibPin {
                number: num,
                name: pin.child_value("name").unwrap_or_default(),
                etype: pin.arg(0).unwrap_or_default(),
                at: (pat.num(0).unwrap_or(0.0), pat.num(1).unwrap_or(0.0)),
                angle: pat.num(2).unwrap_or(0.0),
                length: pin.child("length").and_then(|l| l.num(0)).unwrap_or(0.0),
                unit: u,
                hidden,
            });
        }
    }
    out
}

/// Number of units a library symbol defines (1 when it has only shared sub-symbols).
pub fn lib_unit_count(lib: &Node, base: &str) -> u32 {
    lib.children()
        .iter()
        .filter(|n| n.head() == Some("symbol"))
        .filter_map(|n| n.arg(0).and_then(|name| sub_unit(&name, base)))
        .max()
        .unwrap_or(1)
        .max(1)
}

/// A pin of a placed unit, in sheet coordinates.
#[derive(Clone, Debug)]
pub struct PinInfo {
    pub reference: String,
    pub unit: u32,
    pub number: String,
    pub name: String,
    pub etype: String,
    pub at: Pt,
    /// Unit vector from the pin's connection point away from the body (sheet, y down).
    pub out: Pt,
    pub length: f64,
    pub symbol_uuid: String,
    pub hidden: bool,
    pub lib_id: String,
}

impl PinInfo {
    pub fn spec(&self) -> String {
        format!("{}.{}", self.reference, self.number)
    }
}

/// The key of an instance's `lib_symbols` entry (lib_name wins over lib_id).
pub fn lib_key(inst: &Node) -> String {
    inst.child_value("lib_name").or_else(|| inst.child_value("lib_id")).unwrap_or_default()
}

pub fn base_name(key: &str) -> String {
    key.rsplit(':').next().unwrap_or(key).to_string()
}

fn inst_unit(inst: &Node) -> u32 {
    inst.child("unit").and_then(|u| u.num(0)).unwrap_or(1.0) as u32
}

fn inst_style(inst: &Node) -> u32 {
    inst.child("body_style").or_else(|| inst.child("convert")).and_then(|c| c.num(0)).map(|v| v as u32).unwrap_or(1)
}

/// A wire or bus segment.
#[derive(Clone, Debug)]
pub struct Seg {
    pub uuid: String,
    pub a: Pt,
    pub b: Pt,
}

fn seg_of(n: &Node) -> Option<Seg> {
    let pts = n.child("pts")?;
    let xys: Vec<&Node> = pts.children().iter().filter(|c| c.head() == Some("xy")).collect();
    if xys.len() < 2 {
        return None;
    }
    Some(Seg { uuid: n.child_value("uuid").unwrap_or_default(), a: (xys[0].num(0)?, xys[0].num(1)?), b: (xys[1].num(0)?, xys[1].num(1)?) })
}

fn set_seg_end(n: &mut Node, end: usize, p: Pt) {
    let Some(pts) = n.child_mut("pts") else { return };
    let Some(children) = pts.children_mut() else { return };
    if let Some(x) = children.iter_mut().filter(|c| c.head() == Some("xy")).nth(end) {
        x.set_arg(0, Node::number(r4(p.0)));
        x.set_arg(1, Node::number(r4(p.1)));
    }
}

pub const LABEL_HEADS: &[&str] = &["label", "global_label", "hierarchical_label"];

/// A net label of any kind.
#[derive(Clone, Debug)]
pub struct LabelInfo {
    pub kind: String,
    pub text: String,
    pub at: Pt,
    pub rotation: f64,
    pub uuid: String,
    pub shape: Option<String>,
}

/// What touches one point: for the junction rule and the pin reports.
struct Conn {
    wires: Vec<Seg>,
    pin_pts: Vec<Pt>,
}

impl Conn {
    /// KiCad's explicit-junction rule: three or more distinct exits meet at `p` (a wire end
    /// gives one exit, a wire passing through gives two, every pin its own), and a wire is
    /// one of them.
    fn needed(&self, p: Pt) -> bool {
        let mut dirs: Vec<Pt> = Vec::new();
        let mut uniques = 0usize;
        let mut wire = false;
        let push = |d: Pt, dirs: &mut Vec<Pt>| {
            if !dirs.iter().any(|e| same(*e, d)) {
                dirs.push(d);
            }
        };
        for s in &self.wires {
            if same(p, s.a) {
                wire = true;
                push(unit_dir(s.a, s.b), &mut dirs);
            } else if same(p, s.b) {
                wire = true;
                push(unit_dir(s.b, s.a), &mut dirs);
            } else if on_interior(p, s.a, s.b) {
                wire = true;
                push(unit_dir(p, s.a), &mut dirs);
                push(unit_dir(p, s.b), &mut dirs);
            }
        }
        for q in &self.pin_pts {
            if same(*q, p) {
                uniques += 1;
            }
        }
        wire && dirs.len() + uniques >= 3
    }
}

// ---------------------------------------------------------------------------
// Schematic: queries
// ---------------------------------------------------------------------------

impl Schematic {
    /// Top-level indices of every placed unit of `reference`.
    pub fn unit_indices(&self, reference: &str) -> Vec<usize> {
        self.top().iter().enumerate().filter(|(_, n)| is_symbol_instance(n) && symbol_property(n, "Reference").as_deref() == Some(reference)).map(|(i, _)| i).collect()
    }

    fn require_units(&self, reference: &str) -> R<Vec<usize>> {
        let v = self.unit_indices(reference);
        if v.is_empty() {
            return Err(OpError::new("not_found", format!("no symbol with reference {reference} on this sheet")));
        }
        Ok(v)
    }

    /// The pins of one placed unit (top-level node).
    pub fn instance_pins(&self, inst: &Node) -> R<Vec<PinInfo>> {
        let key = lib_key(inst);
        let reference = symbol_property(inst, "Reference").unwrap_or_default();
        let lib = self.lib_symbol(&key).ok_or_else(|| OpError::new("lib_symbol_missing", format!("{reference}: its library symbol {key} is not in the sheet's lib_symbols")))?;
        let xf = SymXf::of(inst);
        let unit = inst_unit(inst);
        let uuid = inst.child_value("uuid").unwrap_or_default();
        let lib_id = inst.child_value("lib_id").unwrap_or_default();
        Ok(lib_unit_pins(lib, &base_name(&key), unit, inst_style(inst))
            .into_iter()
            .map(|p| {
                let a = p.angle.to_radians();
                let out = xf.vec((-a.cos(), -a.sin()));
                PinInfo {
                    reference: reference.clone(),
                    unit,
                    number: p.number,
                    name: p.name,
                    etype: p.etype,
                    at: xf.point(p.at),
                    out,
                    length: p.length,
                    symbol_uuid: uuid.clone(),
                    hidden: p.hidden,
                    lib_id: lib_id.clone(),
                }
            })
            .collect())
    }

    /// Every pin of every placed unit of `reference`.
    pub fn pins_of(&self, reference: &str) -> R<Vec<PinInfo>> {
        let mut out = Vec::new();
        for i in self.require_units(reference)? {
            out.extend(self.instance_pins(&self.top()[i])?);
        }
        Ok(out)
    }

    /// Every pin on the sheet (units whose library symbol is missing are skipped).
    pub fn all_pins(&self) -> Vec<PinInfo> {
        self.top().iter().filter(|n| is_symbol_instance(n)).filter_map(|n| self.instance_pins(n).ok()).flatten().collect()
    }

    /// `U1.3` or `U1.PA10`: by number first, then by name (case-insensitive).
    pub fn find_pin(&self, spec: &str) -> R<PinInfo> {
        let (reference, want) = spec.trim().split_once('.').ok_or_else(|| OpError::new("bad_arg", format!("pin {spec} is not Ref.Pin (e.g. U1.12 or U1.PA10)")))?;
        let pins = self.pins_of(reference)?;
        if let Some(p) = pins.iter().find(|p| p.number == want) {
            return Ok(p.clone());
        }
        if let Some(p) = pins.iter().find(|p| p.name.eq_ignore_ascii_case(want)) {
            return Ok(p.clone());
        }
        Err(OpError::new("pin_not_found", format!("{reference} has no pin {want} (numbers: {})", pins.iter().map(|p| p.number.clone()).collect::<Vec<_>>().join(", "))))
    }

    pub fn segments(&self, head: &str) -> Vec<Seg> {
        self.top().iter().filter(|n| n.head() == Some(head)).filter_map(seg_of).collect()
    }

    pub fn wires(&self) -> Vec<Seg> {
        self.segments("wire")
    }

    pub fn labels(&self) -> Vec<LabelInfo> {
        self.top()
            .iter()
            .filter(|n| n.head().map_or(false, |h| LABEL_HEADS.contains(&h)))
            .map(|n| {
                let at = n.child("at");
                LabelInfo {
                    kind: n.head().unwrap_or("").to_string(),
                    text: n.arg(0).unwrap_or_default(),
                    at: (at.and_then(|a| a.num(0)).unwrap_or(0.0), at.and_then(|a| a.num(1)).unwrap_or(0.0)),
                    rotation: at.and_then(|a| a.num(2)).unwrap_or(0.0),
                    uuid: n.child_value("uuid").unwrap_or_default(),
                    shape: n.child_value("shape"),
                }
            })
            .collect()
    }

    /// (uuid, point) of every top-level item with head `head` and an `(at x y)`.
    pub fn point_items(&self, head: &str) -> Vec<(String, Pt)> {
        self.top().iter().filter(|n| n.head() == Some(head)).filter_map(|n| Some((n.child_value("uuid").unwrap_or_default(), at_of(n)?))).collect()
    }

    pub fn junction_at(&self, p: Pt) -> Option<String> {
        self.point_items("junction").into_iter().find(|(_, q)| same(*q, p)).map(|(u, _)| u)
    }

    fn conn(&self) -> Conn {
        Conn { wires: self.wires(), pin_pts: self.all_pins().into_iter().map(|p| p.at).collect() }
    }

    /// True when KiCad would want a junction dot at `p` (ignoring any dot already there).
    pub fn junction_needed(&self, p: Pt) -> bool {
        self.conn().needed(p)
    }

    /// What touches a point: wires (end or interior), labels, no-connects, junctions, pins.
    pub fn touching(&self, p: Pt) -> Value {
        let wires: Vec<Value> = self
            .wires()
            .into_iter()
            .filter(|s| on_segment(p, s.a, s.b))
            .map(|s| json!({"uuid": s.uuid, "from": pt_json(s.a), "to": pt_json(s.b), "atEnd": same(p, s.a) || same(p, s.b)}))
            .collect();
        let labels: Vec<Value> = self.labels().into_iter().filter(|l| same(l.at, p)).map(|l| json!({"kind": l.kind, "text": l.text, "uuid": l.uuid})).collect();
        json!({
            "wires": wires,
            "labels": labels,
            "noConnect": self.point_items("no_connect").iter().any(|(_, q)| same(*q, p)),
            "junction": self.junction_at(p),
        })
    }

    /// The net names reachable from `p` through wires (shared ends, junctions on interiors,
    /// labels anywhere on a wire), and the other pins on that copper. A label or power pin
    /// sitting directly on `p` counts too.
    pub fn net_at(&self, p: Pt, pins: &[PinInfo]) -> (Vec<String>, Vec<String>) {
        let wires = self.wires();
        let junctions: Vec<Pt> = self.point_items("junction").into_iter().map(|(_, q)| q).collect();
        let labels = self.labels();
        let mut names: BTreeSet<String> = BTreeSet::new();
        let mut reached_pts: Vec<Pt> = vec![p];
        let mut used = vec![false; wires.len()];
        let mut frontier = vec![p];
        while let Some(q) = frontier.pop() {
            for (i, s) in wires.iter().enumerate() {
                if used[i] {
                    continue;
                }
                let at_end = same(q, s.a) || same(q, s.b);
                let mid = on_interior(q, s.a, s.b) && (junctions.iter().any(|j| same(*j, q)) || same(q, p));
                if !(at_end || mid) {
                    continue;
                }
                used[i] = true;
                for e in [s.a, s.b] {
                    if !reached_pts.iter().any(|r| same(*r, e)) {
                        reached_pts.push(e);
                        frontier.push(e);
                    }
                }
                for j in &junctions {
                    if on_interior(*j, s.a, s.b) && !reached_pts.iter().any(|r| same(*r, *j)) {
                        reached_pts.push(*j);
                        frontier.push(*j);
                    }
                }
                for l in &labels {
                    if on_segment(l.at, s.a, s.b) {
                        names.insert(l.text.clone());
                    }
                }
            }
        }
        for l in &labels {
            if same(l.at, p) {
                names.insert(l.text.clone());
            }
        }
        let mut others = Vec::new();
        for pin in pins {
            if reached_pts.iter().any(|r| same(*r, pin.at)) || wires.iter().enumerate().any(|(i, s)| used[i] && on_interior(pin.at, s.a, s.b)) {
                if pin.reference.starts_with('#') {
                    // power symbol: its Value is the net name
                    if let Some(inst) = self.top().iter().find(|n| n.child_value("uuid").as_deref() == Some(pin.symbol_uuid.as_str())) {
                        if inst.child_value("lib_id").map_or(false, |l| l.starts_with("power:")) || self.lib_symbol(&lib_key(inst)).map_or(false, |l| l.child("power").is_some()) {
                            if let Some(v) = symbol_property(inst, "Value") {
                                names.insert(v);
                            }
                        }
                    }
                }
                others.push(pin.spec());
            }
        }
        (names.into_iter().collect(), others)
    }

    /// Every item kind as plain lists (for kicad_sch_items).
    pub fn items(&self, kinds: Option<&[String]>) -> Value {
        let want = |k: &str| kinds.map_or(true, |ks| ks.iter().any(|x| x == k));
        let mut v = serde_json::Map::new();
        let segs = |head: &str| -> Vec<Value> { self.segments(head).into_iter().map(|s| json!({"uuid": s.uuid, "from": pt_json(s.a), "to": pt_json(s.b)})).collect() };
        if want("wires") {
            v.insert("wires".into(), json!(segs("wire")));
        }
        if want("buses") {
            v.insert("buses".into(), json!(segs("bus")));
        }
        if want("busEntries") {
            let e: Vec<Value> = self
                .top()
                .iter()
                .filter(|n| n.head() == Some("bus_entry"))
                .filter_map(|n| {
                    let at = at_of(n)?;
                    let sz = n.child("size").map(|s| (s.num(0).unwrap_or(0.0), s.num(1).unwrap_or(0.0))).unwrap_or((2.54, 2.54));
                    Some(json!({"uuid": n.child_value("uuid"), "from": pt_json(at), "to": pt_json((at.0 + sz.0, at.1 + sz.1)), "size": pt_json(sz)}))
                })
                .collect();
            v.insert("busEntries".into(), json!(e));
        }
        if want("labels") {
            let l: Vec<Value> = self.labels().into_iter().map(|l| json!({"kind": l.kind, "text": l.text, "at": pt_json(l.at), "rotation": l.rotation, "shape": l.shape, "uuid": l.uuid})).collect();
            v.insert("labels".into(), json!(l));
        }
        if want("powerSymbols") {
            let p: Vec<Value> = self
                .top()
                .iter()
                .filter(|n| is_symbol_instance(n) && (n.child_value("lib_id").map_or(false, |l| l.starts_with("power:")) || self.lib_symbol(&lib_key(n)).map_or(false, |l| l.child("power").is_some())))
                .map(|n| json!({"reference": symbol_property(n, "Reference"), "net": symbol_property(n, "Value"), "libId": n.child_value("lib_id"), "at": at_of(n).map(pt_json), "uuid": n.child_value("uuid")}))
                .collect();
            v.insert("powerSymbols".into(), json!(p));
        }
        let pts = |head: &str| -> Vec<Value> { self.point_items(head).into_iter().map(|(u, p)| json!({"uuid": u, "at": pt_json(p)})).collect() };
        if want("junctions") {
            v.insert("junctions".into(), json!(pts("junction")));
        }
        if want("noConnects") {
            v.insert("noConnects".into(), json!(pts("no_connect")));
        }
        if want("texts") {
            let t: Vec<Value> = self
                .top()
                .iter()
                .filter(|n| n.head() == Some("text"))
                .map(|n| json!({"text": n.arg(0), "at": at_of(n).map(pt_json), "rotation": n.child("at").and_then(|a| a.num(2)).unwrap_or(0.0), "uuid": n.child_value("uuid")}))
                .collect();
            v.insert("texts".into(), json!(t));
        }
        if want("sheets") {
            let s: Vec<Value> = self
                .top()
                .iter()
                .filter(|n| n.head() == Some("sheet"))
                .map(|n| {
                    let size = n.child("size").map(|s| json!([s.num(0), s.num(1)]));
                    let pins: Vec<Value> = n.children().iter().filter(|c| c.head() == Some("pin")).map(|c| json!({"name": c.arg(0), "shape": c.arg(1), "at": at_of(c).map(pt_json), "uuid": c.child_value("uuid")})).collect();
                    json!({"name": symbol_property(n, "Sheetname").or_else(|| symbol_property(n, "Sheet name")), "file": symbol_property(n, "Sheetfile").or_else(|| symbol_property(n, "Sheet file")), "at": at_of(n).map(pt_json), "size": size, "pins": pins, "uuid": n.child_value("uuid")})
                })
                .collect();
            v.insert("sheets".into(), json!(s));
        }
        Value::Object(v)
    }

    /// Placed symbols grouped by reference with every unit and every field.
    pub fn components(&self, filter: Option<&[String]>) -> Vec<Value> {
        let mut order: Vec<String> = Vec::new();
        let mut by_ref: BTreeMap<String, Vec<&Node>> = BTreeMap::new();
        for n in self.top().iter().filter(|n| is_symbol_instance(n)) {
            let r = symbol_property(n, "Reference").unwrap_or_default();
            if let Some(f) = filter {
                if !f.iter().any(|x| x == &r) {
                    continue;
                }
            }
            // unannotated X? parts are separate parts, not units of one
            let key = if r.trim().ends_with('?') { format!("{r}\u{0}{}", n.child_value("uuid").unwrap_or_default()) } else { r };
            if !by_ref.contains_key(&key) {
                order.push(key.clone());
            }
            by_ref.entry(key).or_default().push(n);
        }
        order
            .iter()
            .map(|r| {
                let units = &by_ref[r];
                let first = units[0];
                let fields: Vec<Value> = first
                    .children()
                    .iter()
                    .filter(|c| c.head() == Some("property"))
                    .map(|c| {
                        let hidden = c.child("effects").map_or(false, |e| e.child("hide").map_or(false, |h| h.arg(0).as_deref() != Some("no")) || e.children().iter().any(|x| x.raw() == Some("hide")));
                        json!({"name": c.arg(0), "value": c.arg(1), "at": at_of(c).map(pt_json), "angle": c.child("at").and_then(|a| a.num(2)).unwrap_or(0.0), "hidden": hidden})
                    })
                    .collect();
                let key = lib_key(first);
                let unit_count = self.lib_symbol(&key).map(|l| lib_unit_count(l, &base_name(&key)));
                let us: Vec<Value> = units
                    .iter()
                    .map(|u| {
                        let xf = SymXf::of(u);
                        json!({
                            "unit": inst_unit(u),
                            "uuid": u.child_value("uuid"),
                            "at": pt_json((xf.x, xf.y)),
                            "rotation": xf.rot,
                            "mirror": u.child_value("mirror"),
                            "inBom": u.child_value("in_bom").map(|v| v == "yes"),
                            "onBoard": u.child_value("on_board").map(|v| v == "yes"),
                            "dnp": u.child_value("dnp").map(|v| v == "yes"),
                        })
                    })
                    .collect();
                json!({
                    "reference": symbol_property(first, "Reference"),
                    "value": symbol_property(first, "Value"),
                    "footprint": symbol_property(first, "Footprint"),
                    "datasheet": symbol_property(first, "Datasheet"),
                    "libId": first.child_value("lib_id"),
                    "libName": first.child_value("lib_name"),
                    "unitCount": unit_count,
                    "units": us,
                    "fields": fields,
                })
            })
            .collect()
    }
}

// ---------------------------------------------------------------------------
// Schematic: wiring edits
// ---------------------------------------------------------------------------

fn stroke() -> Node {
    Node::list("stroke", vec![Node::list("width", vec![Node::number(0.0)]), Node::list("type", vec![Node::atom("default")])])
}

impl Schematic {
    fn index_of_uuid(&self, uuid: &str) -> Option<usize> {
        self.find_uuid_index(uuid)
    }

    /// One `(wire)` or `(bus)` per straight segment of `points`.
    pub fn add_segments(&mut self, head: &str, points: &[Pt]) -> R<Vec<String>> {
        if points.len() < 2 {
            return Err(OpError::new("bad_arg", format!("a {head} needs at least two points")));
        }
        let mut uuids = Vec::new();
        for s in points.windows(2) {
            if same(s[0], s[1]) {
                continue;
            }
            let uuid = new_uuid();
            self.insert_item(Node::list(head, vec![Node::list("pts", vec![xy(rp(s[0])), xy(rp(s[1]))]), stroke(), Node::list("uuid", vec![Node::quoted(&uuid)])]));
            uuids.push(uuid);
        }
        if uuids.is_empty() {
            return Err(OpError::new("bad_arg", "all points are the same; nothing to draw"));
        }
        Ok(uuids)
    }

    pub fn add_junction(&mut self, p: Pt) -> String {
        let uuid = new_uuid();
        self.insert_item(Node::list(
            "junction",
            vec![
                Node::list("at", vec![Node::number(r4(p.0)), Node::number(r4(p.1))]),
                Node::list("diameter", vec![Node::number(0.0)]),
                Node::list("color", vec![Node::atom("0"), Node::atom("0"), Node::atom("0"), Node::atom("0")]),
                Node::list("uuid", vec![Node::quoted(&uuid)]),
            ],
        ));
        uuid
    }

    /// Add a dot at every point in `candidates` that KiCad's rule wants and that has none.
    pub fn auto_junctions(&mut self, candidates: &[Pt]) -> Vec<(String, Pt)> {
        let conn = self.conn();
        let mut done: Vec<Pt> = Vec::new();
        let mut out = Vec::new();
        for &p in candidates {
            if done.iter().any(|d| same(*d, p)) {
                continue;
            }
            done.push(p);
            if self.junction_at(p).is_none() && conn.needed(p) {
                out.push((self.add_junction(p), rp(p)));
            }
        }
        out
    }

    /// The points worth testing after new wires were drawn: their ends, and every existing
    /// wire end or pin that lies on one of them.
    pub fn junction_candidates(&self, new_uuids: &[String]) -> Vec<Pt> {
        let wires = self.wires();
        let new: Vec<&Seg> = wires.iter().filter(|s| new_uuids.contains(&s.uuid)).collect();
        let mut pts: Vec<Pt> = Vec::new();
        for s in &new {
            pts.push(s.a);
            pts.push(s.b);
        }
        for w in wires.iter().filter(|s| !new_uuids.contains(&s.uuid)) {
            for e in [w.a, w.b] {
                if new.iter().any(|s| on_interior(e, s.a, s.b)) {
                    pts.push(e);
                }
            }
        }
        for p in self.all_pins() {
            if new.iter().any(|s| on_interior(p.at, s.a, s.b)) {
                pts.push(p.at);
            }
        }
        pts
    }

    /// Draw a polyline and add the junctions it needs.
    pub fn add_wire_auto(&mut self, points: &[Pt]) -> R<(Vec<String>, Vec<(String, Pt)>)> {
        let uuids = self.add_segments("wire", points)?;
        let cands = self.junction_candidates(&uuids);
        let j = self.auto_junctions(&cands);
        Ok((uuids, j))
    }

    /// Remove every junction at one of `points` (or on one of `segs`) that the rule no
    /// longer wants. Returns the removed uuids.
    pub fn prune_junctions(&mut self, points: &[Pt], segs: &[(Pt, Pt)]) -> Vec<String> {
        let conn = self.conn();
        let doomed: Vec<String> = self
            .point_items("junction")
            .into_iter()
            .filter(|(_, p)| points.iter().any(|q| same(*q, *p)) || segs.iter().any(|(a, b)| on_segment(*p, *a, *b)))
            .filter(|(_, p)| !conn.needed(*p))
            .map(|(u, _)| u)
            .collect();
        for u in &doomed {
            if let Some(i) = self.index_of_uuid(u) {
                self.top_mut().remove(i);
            }
        }
        doomed
    }

    /// Delete wires by uuid; returns their geometry and the junctions pruned.
    pub fn delete_wires(&mut self, uuids: &[String]) -> R<(Vec<Seg>, Vec<String>)> {
        let mut gone = Vec::new();
        for u in uuids {
            let i = self.index_of_uuid(u).ok_or_else(|| OpError::new("not_found", format!("no item with uuid {u} on this sheet")))?;
            let n = &self.top()[i];
            if n.head() != Some("wire") && n.head() != Some("bus") {
                return Err(OpError::new("wrong_kind", format!("{u} is a {}, not a wire", n.head().unwrap_or("item"))));
            }
            let s = seg_of(n).ok_or_else(|| OpError::new("parse_failed", format!("wire {u} has no (pts ..)")))?;
            self.top_mut().remove(i);
            gone.push(s);
        }
        let pts: Vec<Pt> = gone.iter().flat_map(|s| [s.a, s.b]).collect();
        let segs: Vec<(Pt, Pt)> = gone.iter().map(|s| (s.a, s.b)).collect();
        let pruned = self.prune_junctions(&pts, &segs);
        Ok((gone, pruned))
    }

    /// Wires whose two ends are `a` and `b` (either order).
    pub fn wires_between(&self, a: Pt, b: Pt) -> Vec<String> {
        self.wires().into_iter().filter(|s| (same(s.a, a) && same(s.b, b)) || (same(s.a, b) && same(s.b, a))).map(|s| s.uuid).collect()
    }

    /// Split the wire passing through `at` into two; optionally a junction at the cut.
    pub fn split_wire(&mut self, at: Pt, uuid: Option<&str>, junction: bool) -> R<(String, String, Option<String>)> {
        let wires = self.wires();
        let hit: Vec<&Seg> = wires.iter().filter(|s| uuid.map_or(true, |u| s.uuid == u) && on_interior(at, s.a, s.b)).collect();
        let s = match hit.len() {
            0 => return Err(OpError::new("not_on_wire", format!("no wire{} passes through ({}, {}) away from its ends", uuid.map(|u| format!(" {u}")).unwrap_or_default(), fmt_num(at.0), fmt_num(at.1)))),
            1 => hit[0].clone(),
            _ => return Err(OpError::new("ambiguous", format!("{} wires pass through ({}, {}); pass uuid to pick one", hit.len(), fmt_num(at.0), fmt_num(at.1)))),
        };
        let i = self.index_of_uuid(&s.uuid).expect("found above");
        let mut second = self.top()[i].clone();
        set_seg_end(&mut self.top_mut()[i], 1, at);
        let new = new_uuid();
        set_seg_end(&mut second, 0, at);
        if let Some(u) = second.child_mut("uuid") {
            u.set_arg(0, Node::quoted(&new));
        }
        self.top_mut().insert(i + 1, second);
        let j = if junction && self.junction_at(at).is_none() { Some(self.add_junction(at)) } else { None };
        Ok((s.uuid, new, j))
    }

    pub fn add_no_connects(&mut self, pts: &[Pt]) -> Vec<String> {
        pts.iter().map(|p| self.add_no_connect(rp(*p)).expect("infallible")).collect()
    }

    /// Remove top-level items of kind `head` at `p`; returns their uuids.
    pub fn remove_point_items(&mut self, head: &str, p: Pt) -> Vec<String> {
        let doomed: Vec<String> = self.point_items(head).into_iter().filter(|(_, q)| same(*q, p)).map(|(u, _)| u).collect();
        for u in &doomed {
            if let Some(i) = self.index_of_uuid(u) {
                self.top_mut().remove(i);
            }
        }
        doomed
    }

    /// Remove a top-level item by uuid, checking its kind.
    pub fn remove_kind(&mut self, head: &str, uuid: &str) -> R<()> {
        let i = self.index_of_uuid(uuid).ok_or_else(|| OpError::new("not_found", format!("no item with uuid {uuid} on this sheet")))?;
        if self.top()[i].head() != Some(head) {
            return Err(OpError::new("wrong_kind", format!("{uuid} is a {}, not a {head}", self.top()[i].head().unwrap_or("item"))));
        }
        self.top_mut().remove(i);
        Ok(())
    }

    /// Free text: `(text "..." (exclude_from_sim no) (at x y r) (effects ..) (uuid ..))`.
    pub fn add_text(&mut self, text: &str, at: Pt, rotation: f64, size: f64, justify: &[String], bold: bool, italic: bool) -> R<String> {
        if text.is_empty() {
            return Err(OpError::new("bad_arg", "text is required"));
        }
        let rot = norm_rotation(rotation).map_err(|e| OpError::new("bad_arg", e))?;
        if !(size > 0.0 && size < 100.0) {
            return Err(OpError::new("bad_arg", format!("size must be a positive height in mm (got {size})")));
        }
        for j in justify {
            if !["left", "right", "top", "bottom", "mirror"].contains(&j.as_str()) {
                return Err(OpError::new("bad_arg", format!("justify takes left, right, top, bottom (got {j})")));
            }
        }
        let mut font = vec![Node::list("size", vec![Node::number(size), Node::number(size)])];
        if bold {
            font.push(Node::list("bold", vec![Node::atom("yes")]));
        }
        if italic {
            font.push(Node::list("italic", vec![Node::atom("yes")]));
        }
        let mut effects = vec![Node::list("font", font)];
        if !justify.is_empty() {
            effects.push(Node::list("justify", justify.iter().map(|j| Node::atom(j.as_str())).collect()));
        }
        let uuid = new_uuid();
        self.insert_item(Node::list(
            "text",
            vec![
                Node::quoted(text),
                Node::list("exclude_from_sim", vec![Node::atom("no")]),
                Node::list("at", vec![Node::number(r4(at.0)), Node::number(r4(at.1)), Node::number(rot)]),
                Node::list("effects", effects),
                Node::list("uuid", vec![Node::quoted(&uuid)]),
            ],
        ));
        Ok(uuid)
    }

    /// A 45-degree bus entry from `at` to `at + size` (size is (+-2.54, +-2.54) normally).
    pub fn add_bus_entry(&mut self, at: Pt, size: Pt) -> R<String> {
        if size.0.abs() < EPS || size.1.abs() < EPS {
            return Err(OpError::new("bad_arg", "a bus entry needs a non-zero size in both x and y (e.g. [2.54, -2.54])"));
        }
        let uuid = new_uuid();
        self.insert_item(Node::list(
            "bus_entry",
            vec![
                Node::list("at", vec![Node::number(r4(at.0)), Node::number(r4(at.1))]),
                Node::list("size", vec![Node::number(r4(size.0)), Node::number(r4(size.1))]),
                stroke(),
                Node::list("uuid", vec![Node::quoted(&uuid)]),
            ],
        ));
        Ok(uuid)
    }
}

/// Label rotation and justify for a label whose text runs along `dir` away from its point.
pub fn label_rotation_for(dir: Pt) -> f64 {
    if dir.0 > 0.5 {
        0.0
    } else if dir.0 < -0.5 {
        180.0
    } else if dir.1 < -0.5 {
        90.0
    } else {
        270.0
    }
}

// ---------------------------------------------------------------------------
// Schematic: labels
// ---------------------------------------------------------------------------

impl Schematic {
    /// Indices of labels by uuid, or by text (optionally only the one at `at`).
    pub fn label_indices(&self, uuid: Option<&str>, text: Option<&str>, at: Option<Pt>) -> Vec<usize> {
        self.top()
            .iter()
            .enumerate()
            .filter(|(_, n)| n.head().map_or(false, |h| LABEL_HEADS.contains(&h)))
            .filter(|(_, n)| uuid.map_or(true, |u| n.child_value("uuid").as_deref() == Some(u)))
            .filter(|(_, n)| text.map_or(true, |t| n.arg(0).as_deref() == Some(t)))
            .filter(|(_, n)| at.map_or(true, |p| at_of(n).map_or(false, |q| same(p, q))))
            .map(|(i, _)| i)
            .collect()
    }

    /// Set a label's angle and the justify KiCad pairs with it (left for 0 and 90, right for
    /// 180 and 270; local labels keep `bottom`).
    pub fn set_label_rotation(&mut self, idx: usize, rotation: f64) -> R<f64> {
        let rot = norm_rotation(rotation).map_err(|e| OpError::new("bad_arg", e))?;
        let n = &mut self.top_mut()[idx];
        let local = n.head() == Some("label");
        let old = n.child("at").and_then(|a| a.num(2)).unwrap_or(0.0);
        if let Some(a) = n.child_mut("at") {
            if a.children().len() > 3 {
                a.set_arg(2, Node::number(rot));
            } else if let Some(c) = a.children_mut() {
                let mut r = Node::number(rot);
                r.lead = " ".into();
                c.push(r);
            }
        }
        let side = if rot == 180.0 || rot == 270.0 { "right" } else { "left" };
        let mut new_j = vec![Node::atom(side)];
        if local {
            new_j.push(Node::atom("bottom"));
        }
        if let Some(e) = n.child_mut("effects") {
            let keep: Vec<Node> = e.child("justify").map(|j| j.children().iter().skip(1).filter(|c| !matches!(c.raw(), Some("left") | Some("right") | Some("bottom") | Some("top"))).cloned().collect()).unwrap_or_default();
            new_j.extend(keep.into_iter().map(|mut c| {
                c.lead.clear();
                c
            }));
            let mut j = Node::list("justify", new_j);
            let ch = e.children_mut().expect("list");
            if let Some(slot) = ch.iter_mut().find(|c| c.head() == Some("justify")) {
                let lead = slot.lead.clone();
                pretty(&mut j, 0);
                j.lead = lead;
                *slot = j;
            } else {
                // effects sits at depth 2 for a top-level label
                pretty(&mut j, 3);
                ch.push(j);
                if let Kind::List { close_lead, .. } = &mut e.kind {
                    if close_lead.is_empty() {
                        *close_lead = "\n\t\t".into();
                    }
                }
            }
        }
        Ok(old)
    }
}

// ---------------------------------------------------------------------------
// Schematic: moving and rotating symbols
// ---------------------------------------------------------------------------

/// What a stretch did to the wires.
#[derive(Clone, Debug, Default)]
pub struct Stretch {
    pub wires_moved: Vec<String>,
    pub wires_added: Vec<String>,
    pub junctions: Vec<(String, Pt)>,
    pub flags_moved: Vec<String>,
}

impl Schematic {
    /// Move every unit of `reference` by (dx, dy). With `stretch`, wire ends on the old pin
    /// points follow (an H or V wire gets a dogleg so it stays H or V), and no-connect flags,
    /// labels and junctions sitting on those pins move with them.
    pub fn move_units(&mut self, reference: &str, d: Pt, stretch: bool) -> R<(Vec<String>, Stretch)> {
        let idxs = self.require_units(reference)?;
        let old_pins: Vec<Pt> = if stretch { self.pins_of(reference).map(|v| v.into_iter().map(|p| p.at).collect()).unwrap_or_default() } else { vec![] };
        let mut uuids = Vec::new();
        for &i in &idxs {
            let n = &mut self.top_mut()[i];
            shift_node(n, d.0, d.1);
            uuids.push(n.child_value("uuid").unwrap_or_default());
        }
        let mut st = Stretch::default();
        if stretch && !old_pins.is_empty() {
            st = self.stretch_wires(&old_pins, d);
        }
        Ok((uuids, st))
    }

    /// Wire ends at any of `old` move by `d`; point items there move too.
    pub fn stretch_wires(&mut self, old: &[Pt], d: Pt) -> Stretch {
        let on = |p: Pt| old.iter().any(|q| same(*q, p));
        let mut st = Stretch::default();
        let mut new_segs: Vec<(Pt, Pt)> = Vec::new();
        let mut touched_pts: Vec<Pt> = Vec::new();
        let idxs: Vec<usize> = self.top().iter().enumerate().filter(|(_, n)| n.head() == Some("wire")).map(|(i, _)| i).collect();
        for i in idxs {
            let Some(s) = seg_of(&self.top()[i]) else { continue };
            let (ma, mb) = (on(s.a), on(s.b));
            if !ma && !mb {
                continue;
            }
            st.wires_moved.push(s.uuid.clone());
            let n = &mut self.top_mut()[i];
            if ma && mb {
                set_seg_end(n, 0, (s.a.0 + d.0, s.a.1 + d.1));
                set_seg_end(n, 1, (s.b.0 + d.0, s.b.1 + d.1));
                continue;
            }
            let (end, fixed, moving) = if ma { (0, s.b, s.a) } else { (1, s.a, s.b) };
            let new = rp((moving.0 + d.0, moving.1 + d.1));
            let horizontal = (fixed.1 - moving.1).abs() < EPS;
            let vertical = (fixed.0 - moving.0).abs() < EPS;
            let corner = if horizontal { rp((new.0, fixed.1)) } else if vertical { rp((fixed.0, new.1)) } else { new };
            if same(corner, new) || same(corner, fixed) {
                set_seg_end(n, end, new);
            } else {
                set_seg_end(n, end, corner);
                new_segs.push((corner, new));
                touched_pts.push(corner);
            }
            touched_pts.push(new);
        }
        for (a, b) in new_segs {
            if let Ok(u) = self.add_segments("wire", &[a, b]) {
                st.wires_added.extend(u);
            }
        }
        // flags, labels and dots that sat on the pins
        let heads = ["no_connect", "junction", "label", "global_label", "hierarchical_label"];
        let idxs: Vec<usize> = self.top().iter().enumerate().filter(|(_, n)| n.head().map_or(false, |h| heads.contains(&h)) && at_of(n).map_or(false, on)).map(|(i, _)| i).collect();
        for i in idxs {
            let n = &mut self.top_mut()[i];
            shift_node(n, d.0, d.1);
            st.flags_moved.push(n.child_value("uuid").unwrap_or_default());
        }
        st.junctions = self.auto_junctions(&touched_pts);
        st
    }

    /// Rotate every unit of `reference` to the absolute angle `angle` (the first unit's
    /// delta is applied to every unit, each about its own anchor; fields turn with it).
    pub fn rotate_units(&mut self, reference: &str, angle: f64) -> R<(f64, f64, Vec<String>)> {
        let target = norm_rotation(angle).map_err(|e| OpError::new("bad_arg", e))?;
        let idxs = self.require_units(reference)?;
        let first = SymXf::of(&self.top()[idxs[0]]);
        // seen delta: a mirrored unit's stored angle runs the other way
        let delta = if first.mirror.is_some() { first.rot - target } else { target - first.rot };
        let mut uuids = Vec::new();
        for &i in &idxs {
            let n = &mut self.top_mut()[i];
            let xf = SymXf::of(n);
            let new = (((if xf.mirror.is_some() { xf.rot - delta } else { xf.rot + delta }) % 360.0) + 360.0) % 360.0;
            if let Some(a) = n.child_mut("at") {
                if a.children().len() > 3 {
                    a.set_arg(2, Node::number(new));
                } else if let Some(c) = a.children_mut() {
                    let mut r = Node::number(new);
                    r.lead = " ".into();
                    c.push(r);
                }
            }
            for prop in n.children_mut().expect("list").iter_mut().filter(|c| c.head() == Some("property")) {
                if let Some(p) = at_of(prop) {
                    let v = rotate_sheet_vec((p.0 - xf.x, p.1 - xf.y), delta);
                    set_at_xy(prop, (xf.x + v.0, xf.y + v.1));
                }
            }
            uuids.push(n.child_value("uuid").unwrap_or_default());
        }
        Ok((first.rot, target, uuids))
    }

    /// Move everything inside a box by (dx, dy). Units by their anchor, wires by each end
    /// inside (a wire crossing the edge stretches), labels, junctions, no-connects, texts,
    /// bus entries and sheets by their point.
    pub fn move_region(&mut self, bx: [f64; 4], d: Pt, wires: bool, labels: bool, junctions: bool) -> Value {
        let mut moved: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
        let n_items = self.top().len();
        for i in 0..n_items {
            let n = &self.top()[i];
            let head = n.head().unwrap_or("").to_string();
            let key: &'static str = match head.as_str() {
                "symbol" if is_symbol_instance(n) => "symbols",
                "wire" | "bus" if wires => {
                    let Some(s) = seg_of(n) else { continue };
                    let (ia, ib) = (in_box(s.a, bx), in_box(s.b, bx));
                    if !ia && !ib {
                        continue;
                    }
                    let n = &mut self.top_mut()[i];
                    if ia {
                        set_seg_end(n, 0, (s.a.0 + d.0, s.a.1 + d.1));
                    }
                    if ib {
                        set_seg_end(n, 1, (s.b.0 + d.0, s.b.1 + d.1));
                    }
                    moved.entry(if head == "bus" { "buses" } else { "wires" }).or_default().push(s.uuid);
                    continue;
                }
                "label" | "global_label" | "hierarchical_label" if labels => "labels",
                "junction" if junctions => "junctions",
                "no_connect" if junctions => "noConnects",
                "bus_entry" if wires => "busEntries",
                "text" if labels => "texts",
                "sheet" => "sheets",
                _ => continue,
            };
            let Some(p) = at_of(n) else { continue };
            if !in_box(p, bx) {
                continue;
            }
            let n = &mut self.top_mut()[i];
            shift_node(n, d.0, d.1);
            moved.entry(key).or_default().push(n.child_value("uuid").unwrap_or_default());
        }
        let mut v = serde_json::Map::new();
        for (k, list) in moved {
            v.insert(k.to_string(), json!(list));
        }
        Value::Object(v)
    }

    /// Put fields back at the library symbol's offsets through the unit's transform (and the
    /// library's angle and justify). Returns the fields reset per unit.
    pub fn reset_fields(&mut self, reference: &str, names: &[String]) -> R<Vec<String>> {
        let idxs = self.require_units(reference)?;
        let mut done = Vec::new();
        for &i in &idxs {
            let key = lib_key(&self.top()[i]);
            let lib = self.lib_symbol(&key).ok_or_else(|| OpError::new("lib_symbol_missing", format!("{reference}: library symbol {key} is not in lib_symbols")))?.clone();
            let n = &mut self.top_mut()[i];
            let xf = SymXf::of(n);
            let unit = inst_unit(n);
            for prop in n.children_mut().expect("list").iter_mut().filter(|c| c.head() == Some("property")) {
                let name = prop.arg(0).unwrap_or_default();
                if !names.iter().any(|x| x == &name) {
                    continue;
                }
                let Some(lp) = lib.children().iter().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some(name.as_str())) else { continue };
                let Some(la) = lp.child("at") else { continue };
                let off = (la.num(0).unwrap_or(0.0), la.num(1).unwrap_or(0.0));
                let p = xf.point(off);
                let angle = la.num(2).unwrap_or(0.0);
                if let Some(a) = prop.child_mut("at") {
                    a.set_arg(0, Node::number(p.0));
                    a.set_arg(1, Node::number(p.1));
                    if a.children().len() > 3 {
                        a.set_arg(2, Node::number(angle));
                    }
                }
                // the library's justify, keeping the instance's font and hide
                let lib_j = lp.child("effects").and_then(|e| e.child("justify")).cloned();
                if let Some(e) = prop.child_mut("effects") {
                    let ch = e.children_mut().expect("list");
                    let pos = ch.iter().position(|c| c.head() == Some("justify"));
                    match (pos, lib_j) {
                        (Some(k), Some(mut j)) => {
                            j.lead = ch[k].lead.clone();
                            ch[k] = j;
                        }
                        (Some(k), None) => {
                            ch.remove(k);
                        }
                        (None, Some(mut j)) => {
                            pretty(&mut j, 4);
                            let at = ch.iter().position(|c| c.head() == Some("hide")).unwrap_or(ch.len());
                            ch.insert(at, j);
                        }
                        (None, None) => {}
                    }
                }
                done.push(format!("{unit}:{name}"));
            }
        }
        Ok(done)
    }
}

// ---------------------------------------------------------------------------
// Schematic: library-level edits
// ---------------------------------------------------------------------------

/// What happened to one placed unit's pin list.
#[derive(Clone, Debug, Default)]
pub struct PinSync {
    pub added: Vec<String>,
    pub removed: Vec<String>,
}

fn sync_instance_pins(inst: &mut Node, want: &[String]) -> PinSync {
    let ch = inst.children_mut().expect("list");
    let have: Vec<String> = ch.iter().filter(|c| c.head() == Some("pin")).filter_map(|c| c.arg(0)).collect();
    let mut sync = PinSync::default();
    for h in &have {
        if !want.contains(h) {
            sync.removed.push(h.clone());
        }
    }
    ch.retain(|c| !(c.head() == Some("pin") && c.arg(0).map_or(false, |a| !want.contains(&a))));
    let insert_at = ch.iter().rposition(|c| c.head() == Some("pin")).map(|i| i + 1).unwrap_or_else(|| ch.iter().position(|c| c.head() == Some("instances")).unwrap_or(ch.len()));
    let mut k = insert_at;
    for w in want {
        if !have.contains(w) {
            let mut p = Node::list("pin", vec![Node::quoted(w), Node::list("uuid", vec![Node::quoted(&new_uuid())])]);
            pretty(&mut p, 2);
            ch.insert(k, p);
            k += 1;
            sync.added.push(w.clone());
        }
    }
    sync
}

/// The outcome of a replace.
#[derive(Clone, Debug)]
pub struct Replaced {
    pub old_lib_id: String,
    pub units: Vec<String>,
    pub lib_symbol_added: bool,
    pub pins_added: Vec<String>,
    pub pins_removed: Vec<String>,
    pub old_entry_pruned: bool,
}

impl Schematic {
    /// Point every unit of `reference` at `new_lib_id`, keeping unit, fields and position.
    /// Refuses (pins_lost) when a pin number the units carry would disappear, unless `force`.
    pub fn replace_symbol(&mut self, reference: &str, new_lib_id: &str, block: Option<&Node>, force: bool) -> R<Replaced> {
        let idxs = self.require_units(reference)?;
        let old_key = lib_key(&self.top()[idxs[0]]);
        let old_lib_id = self.top()[idxs[0]].child_value("lib_id").unwrap_or_default();
        let mut added = false;
        let new_entry = match self.lib_symbol(new_lib_id) {
            Some(n) => n.clone(),
            None => {
                let b = block.ok_or_else(|| OpError::new("symbol_not_found", format!("no lib_symbols entry for {new_lib_id} and no library block supplied")))?;
                let mut probe = b.clone();
                probe.set_arg(0, Node::quoted(new_lib_id));
                probe
            }
        };
        let base = base_name(new_lib_id);
        let units = lib_unit_count(&new_entry, &base);
        let mut plan: Vec<(usize, Vec<String>)> = Vec::new();
        let mut lost: Vec<String> = Vec::new();
        let mut gained: Vec<String> = Vec::new();
        for &i in &idxs {
            let inst = &self.top()[i];
            let u = inst_unit(inst);
            if u > units {
                return Err(OpError::new("unit_missing", format!("{reference} places unit {u} but {new_lib_id} has {units} unit(s)")));
            }
            let have: Vec<String> = inst.children().iter().filter(|c| c.head() == Some("pin")).filter_map(|c| c.arg(0)).collect();
            let want = lib_symbol_pins(&new_entry, &base, u);
            for h in &have {
                if !want.contains(h) && !lost.contains(h) {
                    lost.push(h.clone());
                }
            }
            for w in &want {
                if !have.contains(w) && !gained.contains(w) {
                    gained.push(w.clone());
                }
            }
            plan.push((i, want));
        }
        if !lost.is_empty() && !force {
            return Err(OpError::new("pins_lost", format!("{new_lib_id} has no pin {} that {reference} carries now; wires on those pins would dangle (pass force:true to replace anyway)", lost.join(", "))));
        }
        if self.lib_symbol(new_lib_id).is_none() {
            added = self.add_lib_symbol(new_lib_id, block.expect("checked"))?;
        }
        let mut uuids = Vec::new();
        for (i, want) in plan {
            let n = &mut self.top_mut()[i];
            if let Some(l) = n.child_mut("lib_id") {
                l.set_arg(0, Node::quoted(new_lib_id));
            }
            n.children_mut().expect("list").retain(|c| c.head() != Some("lib_name"));
            sync_instance_pins(n, &want);
            uuids.push(n.child_value("uuid").unwrap_or_default());
        }
        let pruned = old_key != new_lib_id && self.prune_lib_symbol(&old_key);
        Ok(Replaced { old_lib_id, units: uuids, lib_symbol_added: added, pins_added: gained, pins_removed: lost, old_entry_pruned: pruned })
    }

    /// Drop a lib_symbols entry no placed unit uses. True when removed.
    pub fn prune_lib_symbol(&mut self, key: &str) -> bool {
        if self.top().iter().any(|n| is_symbol_instance(n) && lib_key(n) == key) {
            return false;
        }
        let Some(ls) = self.doc.root.child_mut("lib_symbols") else { return false };
        let ch = ls.children_mut().expect("list");
        let before = ch.len();
        ch.retain(|n| !(n.head() == Some("symbol") && n.arg(0).as_deref() == Some(key)));
        ch.len() != before
    }

    /// lib_symbols keys in file order.
    pub fn lib_symbol_keys(&self) -> Vec<String> {
        self.doc.root.child("lib_symbols").map(|ls| ls.children().iter().filter(|n| n.head() == Some("symbol")).filter_map(|n| n.arg(0)).collect()).unwrap_or_default()
    }

    /// Replace the embedded definition `key` with `block` (renamed to `key`) when they
    /// differ, and resync the pin lists of every unit that uses it. Returns None when the
    /// definitions already match; refuses (pins_lost) when placed pins would vanish unless
    /// `force`.
    pub fn update_lib_symbol(&mut self, key: &str, block: &Node, force: bool) -> R<Option<PinSync>> {
        let old = self.lib_symbol(key).ok_or_else(|| OpError::new("not_found", format!("{key} is not in lib_symbols")))?;
        let mut entry = block.clone();
        entry.set_arg(0, Node::quoted(key));
        if canon(old) == canon(&entry) {
            return Ok(None);
        }
        let base = base_name(key);
        let users: Vec<usize> = self.top().iter().enumerate().filter(|(_, n)| is_symbol_instance(n) && lib_key(n) == key).map(|(i, _)| i).collect();
        let mut lost: Vec<String> = Vec::new();
        for &i in &users {
            let inst = &self.top()[i];
            let want = lib_symbol_pins(&entry, &base, inst_unit(inst));
            for h in inst.children().iter().filter(|c| c.head() == Some("pin")).filter_map(|c| c.arg(0)) {
                if !want.contains(&h) && !lost.contains(&h) {
                    lost.push(format!("{}.{h}", symbol_property(inst, "Reference").unwrap_or_default()));
                }
            }
        }
        if !lost.is_empty() && !force {
            return Err(OpError::new("pins_lost", format!("the library's {key} drops pins placed on this sheet ({}); pass force:true to update anyway", lost.join(", "))));
        }
        pretty(&mut entry, 2);
        let ls = self.doc.root.child_mut("lib_symbols").expect("entry exists");
        let ch = ls.children_mut().expect("list");
        let k = ch.iter().position(|n| n.head() == Some("symbol") && n.arg(0).as_deref() == Some(key)).expect("entry exists");
        ch[k] = entry.clone();
        let mut sync = PinSync::default();
        for i in users {
            let want = lib_symbol_pins(&entry, &base, inst_unit(&self.top()[i]));
            let s = sync_instance_pins(&mut self.top_mut()[i], &want);
            sync.added.extend(s.added);
            sync.removed.extend(s.removed);
        }
        sync.added.sort();
        sync.added.dedup();
        sync.removed.sort();
        sync.removed.dedup();
        Ok(Some(sync))
    }
}

// ---------------------------------------------------------------------------
// Annotation
// ---------------------------------------------------------------------------

/// `R12` -> ("R", Some(12)); `R?` -> ("R", None); `#PWR03` -> ("#PWR", Some(3)).
pub fn split_reference(r: &str) -> (String, Option<u32>) {
    let t = r.trim();
    if let Some(p) = t.strip_suffix('?') {
        return (p.to_string(), None);
    }
    let digits = t.chars().rev().take_while(|c| c.is_ascii_digit()).count();
    if digits == 0 {
        return (t.to_string(), None);
    }
    let (p, n) = t.split_at(t.len() - digits);
    (p.to_string(), n.parse().ok())
}

/// One planned renumbering.
#[derive(Clone, Debug, serde::Serialize)]
pub struct AnnotateStep {
    pub uuid: String,
    pub from: String,
    pub to: String,
    pub unit: u32,
    pub at: [f64; 2],
    #[serde(rename = "libId")]
    pub lib_id: String,
}

impl Schematic {
    /// Every reference on this sheet (Reference fields and instance records).
    pub fn references_used(&self) -> BTreeSet<String> {
        let mut out = BTreeSet::new();
        for n in self.top().iter().filter(|n| is_symbol_instance(n)) {
            if let Some(r) = symbol_property(n, "Reference") {
                out.insert(r);
            }
            if let Some(inst) = n.child("instances") {
                for proj in inst.children().iter().filter(|c| c.head() == Some("project")) {
                    for path in proj.children().iter().filter(|c| c.head() == Some("path")) {
                        if let Some(r) = path.child_value("reference") {
                            out.insert(r);
                        }
                    }
                }
            }
        }
        out
    }

    /// Plan numbers for every `X?` reference: the first number per prefix free in `used`
    /// (references from every sheet of the project), in position order (x then y, or y then
    /// x). The units of an unannotated multi-unit part are packed into one designator per
    /// lib id and value, the way eeschema does.
    pub fn annotate_plan(&self, used: &BTreeSet<String>, by_y: bool) -> Vec<AnnotateStep> {
        let mut taken: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
        for r in used {
            if let (p, Some(n)) = split_reference(r) {
                taken.entry(p).or_default().insert(n);
            }
        }
        let mut pending: Vec<(usize, String, Pt, u32, String, String)> = self
            .top()
            .iter()
            .enumerate()
            .filter(|(_, n)| is_symbol_instance(n))
            .filter_map(|(i, n)| {
                let r = symbol_property(n, "Reference")?;
                if !r.trim().ends_with('?') {
                    return None;
                }
                let xf = SymXf::of(n);
                Some((i, split_reference(&r).0, (xf.x, xf.y), inst_unit(n), n.child_value("lib_id").unwrap_or_default(), symbol_property(n, "Value").unwrap_or_default()))
            })
            .collect();
        pending.sort_by(|a, b| {
            let (ka, kb) = if by_y { ((a.2 .1, a.2 .0), (b.2 .1, b.2 .0)) } else { (a.2, b.2) };
            ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal)
        });
        // open packages of multi-unit parts: (prefix, lib id, value) -> [(number, units used)]
        let mut packages: BTreeMap<(String, String, String), Vec<(u32, BTreeSet<u32>, u32)>> = BTreeMap::new();
        let mut out = Vec::new();
        for (i, prefix, at, unit, lib_id, value) in pending {
            let n = &self.top()[i];
            let key = lib_key(n);
            let units = self.lib_symbol(&key).map(|l| lib_unit_count(l, &base_name(&key))).unwrap_or(1);
            let free = |taken: &mut BTreeMap<String, BTreeSet<u32>>| -> u32 {
                let set = taken.entry(prefix.clone()).or_default();
                let mut k = 1;
                while set.contains(&k) {
                    k += 1;
                }
                set.insert(k);
                k
            };
            let num = if units > 1 {
                let pk = packages.entry((prefix.clone(), lib_id.clone(), value.clone())).or_default();
                if let Some(slot) = pk.iter_mut().find(|(_, us, total)| !us.contains(&unit) && (us.len() as u32) < *total) {
                    slot.1.insert(unit);
                    slot.0
                } else {
                    let k = free(&mut taken);
                    let mut s = BTreeSet::new();
                    s.insert(unit);
                    packages.get_mut(&(prefix.clone(), lib_id.clone(), value.clone())).expect("inserted").push((k, s, units));
                    k
                }
            } else {
                free(&mut taken)
            };
            let to = if prefix.starts_with('#') { format!("{prefix}{num:02}") } else { format!("{prefix}{num}") };
            out.push(AnnotateStep { uuid: n.child_value("uuid").unwrap_or_default(), from: symbol_property(n, "Reference").unwrap_or_default(), to, unit, at: [at.0, at.1], lib_id });
        }
        out
    }

    /// Set one placed unit's Reference field and every instance record's reference.
    pub fn set_unit_reference(&mut self, uuid: &str, reference: &str) -> R<()> {
        let i = self.find_uuid_index(uuid).ok_or_else(|| OpError::new("not_found", format!("no item with uuid {uuid}")))?;
        let n = &mut self.top_mut()[i];
        let ch = n.children_mut().expect("list");
        if let Some(p) = ch.iter_mut().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some("Reference")) {
            p.set_arg(1, Node::quoted(reference));
        }
        if let Some(inst) = ch.iter_mut().find(|c| c.head() == Some("instances")) {
            for proj in inst.children_mut().expect("list").iter_mut().filter(|c| c.head() == Some("project")) {
                for path in proj.children_mut().expect("list").iter_mut().filter(|c| c.head() == Some("path")) {
                    if let Some(r) = path.child_mut("reference") {
                        r.set_arg(0, Node::quoted(reference));
                    }
                }
            }
        }
        Ok(())
    }
}

/// The sheets of the project `sheet` belongs to: the root (`<project>.kicad_sch` beside the
/// `.kicad_pro`) and everything its `(sheet ..)` items reach, plus `sheet` itself.
pub fn project_sheets(sheet: &Path) -> Vec<PathBuf> {
    let dir = sheet.parent().map(Path::to_path_buf).unwrap_or_default();
    let mut out: Vec<PathBuf> = Vec::new();
    let mut queue: Vec<PathBuf> = Vec::new();
    if let Some(stem) = project_name_for(sheet) {
        let root = dir.join(format!("{stem}.kicad_sch"));
        if root.is_file() {
            queue.push(root);
        }
    }
    queue.push(sheet.to_path_buf());
    while let Some(p) = queue.pop() {
        let canon_p = std::fs::canonicalize(&p).unwrap_or_else(|_| p.clone());
        if out.iter().any(|q| std::fs::canonicalize(q).unwrap_or_else(|_| q.clone()) == canon_p) {
            continue;
        }
        let Ok(text) = std::fs::read_to_string(&p) else { continue };
        out.push(p.clone());
        let Ok(s) = Schematic::parse(&text) else { continue };
        let base = p.parent().map(Path::to_path_buf).unwrap_or_default();
        for n in s.top().iter().filter(|n| n.head() == Some("sheet")) {
            if let Some(f) = symbol_property(n, "Sheetfile").or_else(|| symbol_property(n, "Sheet file")) {
                queue.push(base.join(f.replace('\\', "/")));
            }
        }
    }
    out
}

// ---------------------------------------------------------------------------
// Small builders the verbs share
// ---------------------------------------------------------------------------

/// Two-point L route from `a` to `b`: straight when aligned, else the corner that leaves
/// `a` along its own axis (`a_out` horizontal -> go horizontal first). `first` forces "h"
/// or "v".
pub fn l_route(a: Pt, a_out: Pt, b: Pt, first: Option<&str>) -> Vec<Pt> {
    if (a.0 - b.0).abs() < EPS || (a.1 - b.1).abs() < EPS {
        return vec![a, b];
    }
    let h_first = match first {
        Some("h") | Some("horizontal") => true,
        Some("v") | Some("vertical") => false,
        _ => a_out.0.abs() > 0.5,
    };
    let corner = if h_first { (b.0, a.1) } else { (a.0, b.1) };
    vec![a, rp(corner), b]
}

/// A label node text for diagnostics.
pub fn describe_pt(p: Pt) -> String {
    format!("({}, {})", fmt_num(p.0), fmt_num(p.1))
}

/// Quote helper re-exported for the verb layer.
pub fn q(s: &str) -> String {
    quote(s)
}

/// A hidden property node (re-exported builder).
pub fn hidden_property(name: &str, value: &str, at: Pt) -> Node {
    property_node(name, value, at, None, true)
}

/// The default font node (re-exported builder).
pub fn default_font() -> Node {
    font_node()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn segment_geometry() {
        assert!(on_interior((5.0, 0.0), (0.0, 0.0), (10.0, 0.0)));
        assert!(!on_interior((0.0, 0.0), (0.0, 0.0), (10.0, 0.0)));
        assert!(on_segment((0.0, 0.0), (0.0, 0.0), (10.0, 0.0)));
        assert!(!on_segment((11.0, 0.0), (0.0, 0.0), (10.0, 0.0)));
        assert!(!on_segment((5.0, 0.1), (0.0, 0.0), (10.0, 0.0)));
        assert!(on_interior((2.54, 2.54), (0.0, 0.0), (5.08, 5.08)));
    }

    #[test]
    fn transform_matches_kicad_order() {
        let xf = SymXf { x: 100.0, y: 50.0, rot: 90.0, mirror: None };
        // a pin 3.81 above the anchor in the library (y up) rotated 90 ccw lands to the left
        assert_eq!(xf.point((0.0, 3.81)), (96.19, 50.0));
        let m = SymXf { x: 100.0, y: 50.0, rot: 0.0, mirror: Some('x') };
        assert_eq!(m.point((0.0, 3.81)), (100.0, 53.81));
        assert_eq!(rotate_sheet_vec((1.0, 0.0), 90.0), (0.0, -1.0));
    }

    #[test]
    fn references_split() {
        assert_eq!(split_reference("R12"), ("R".to_string(), Some(12)));
        assert_eq!(split_reference("R?"), ("R".to_string(), None));
        assert_eq!(split_reference("#PWR03"), ("#PWR".to_string(), Some(3)));
    }

    #[test]
    fn l_routes() {
        assert_eq!(l_route((0.0, 0.0), (1.0, 0.0), (10.0, 5.0), None), vec![(0.0, 0.0), (10.0, 0.0), (10.0, 5.0)]);
        assert_eq!(l_route((0.0, 0.0), (0.0, 1.0), (10.0, 5.0), None), vec![(0.0, 0.0), (0.0, 5.0), (10.0, 5.0)]);
        assert_eq!(l_route((0.0, 0.0), (0.0, 1.0), (10.0, 0.0), None).len(), 2);
    }

    #[test]
    fn snaps_to_grid() {
        assert_eq!(snap((1.3, 2.5)), (1.27, 2.54));
    }
}