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… 0009b41 14d ago
//! Schematic analysis on one sheet: what is connected at a point, the connectivity and
//! layout check (dangling wire ends, floating labels, unconnected pins, single-pin nets,
//! distinct names shorted together, overlapping symbols and text), and the near-miss wire
//! end fixer. Everything here reads `sch_model`'s model; only `apply_moves` edits a file.

use serde::Serialize;
use serde_json::{json, Value};

use crate::schematic::{Node, Schematic};
use crate::sch_model::{dist, on_interior, on_segment, overlap, r4, same, BBox, Connectivity, Item, Pt, SheetModel};

#[derive(Clone, Debug, Serialize)]
pub struct Finding {
    pub check: &'static str,
    /// error | warning | info
    pub severity: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub at: Option<[f64; 2]>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub uuid: Option<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub refs: Vec<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub nets: Vec<String>,
    pub message: String,
    pub fix: String,
}

/// `U1.3 EN`, or `R1.2` when the pin has no name of its own.
fn pin_label(p: &crate::sch_model::Pin) -> String {
    if p.name.is_empty() || p.name == "~" || p.name == p.number {
        format!("{}.{}", p.reference, p.number)
    } else {
        format!("{}.{} {}", p.reference, p.number, p.name)
    }
}

fn pt(p: Pt) -> Option<[f64; 2]> {
    Some([r4(p.0), r4(p.1)])
}

fn fmt(p: Pt) -> String {
    format!("({}, {})", crate::schematic::fmt_num(r4(p.0)), crate::schematic::fmt_num(r4(p.1)))
}

/// Is a wire end at `p` (of segment `seg`) terminated by anything other than itself?
fn end_terminated(m: &SheetModel, seg: usize, p: Pt) -> bool {
    m.wires.iter().enumerate().any(|(j, w)| j != seg && (same(w.a, p) || same(w.b, p) || on_interior(p, w.a, w.b)))
        || m.pins.iter().any(|x| same(x.at, p))
        || m.labels.iter().any(|x| same(x.at, p))
        || m.sheet_pins().any(|x| same(x.at, p))
        || m.junctions.iter().any(|x| same(x.at, p))
        || m.no_connects.iter().any(|x| same(x.at, p))
        || m.buses.iter().any(|b| on_segment(p, b.a, b.b))
}

/// Every wire end nothing terminates: (segment index, end point, the other end).
pub fn dangling_ends(m: &SheetModel) -> Vec<(usize, Pt, Pt)> {
    let mut out = Vec::new();
    for (i, w) in m.wires.iter().enumerate() {
        for (p, other) in [(w.a, w.b), (w.b, w.a)] {
            if !end_terminated(m, i, p) {
                out.push((i, p, other));
            }
        }
    }
    out
}

/// A point item physically touches a wire end, or mid-wire through a junction dot (pins and
/// sheet pins), or anywhere along a wire (labels).
fn on_wire(m: &SheetModel, p: Pt, anywhere: bool) -> bool {
    m.wires.iter().any(|w| same(w.a, p) || same(w.b, p) || ((anywhere || m.junctions.iter().any(|j| same(j.at, p))) && on_segment(p, w.a, w.b)))
}

/// The nearest connectable thing to `p` within `tol` (not on wire segment `skip`):
/// (point, description).
pub fn nearest_target(m: &SheetModel, p: Pt, tol: f64, skip_uuid: &str) -> Option<(Pt, String)> {
    let mut best: Option<(f64, Pt, String)> = None;
    let mut consider = |q: Pt, what: String| {
        let d = dist(p, q);
        if d > 1e-6 && d <= tol && best.as_ref().map_or(true, |b| d < b.0) {
            best = Some((d, q, what));
        }
    };
    for x in &m.pins {
        consider(x.at, if x.power_symbol { format!("power symbol {} ({})", x.reference, x.power_net.clone().unwrap_or_default()) } else { format!("pin {}.{}", x.reference, x.number) });
    }
    for x in &m.labels {
        consider(x.at, format!("{} label {}", x.kind, x.text));
    }
    for x in m.sheet_pins() {
        consider(x.at, format!("sheet pin {} of {}", x.name, x.sheet_name));
    }
    for w in m.wires.iter().filter(|w| w.uuid != skip_uuid) {
        consider(w.a, format!("wire end {}", w.uuid));
        consider(w.b, format!("wire end {}", w.uuid));
    }
    best.map(|(_, q, w)| (q, w))
}

#[derive(Clone, Debug, Default)]
pub struct CheckOpts {
    pub overlaps: bool,
}

pub struct CheckReport {
    pub findings: Vec<Finding>,
    /// Nets that carry a global, hierarchical or power name: single-pin judgement needs the
    /// other sheets, so they are not flagged here.
    pub cross_sheet_skipped: usize,
}

pub fn check(m: &SheetModel, c: &Connectivity, opts: &CheckOpts) -> CheckReport {
    let mut f: Vec<Finding> = Vec::new();

    // dangling wire ends
    for (seg, p, _) in dangling_ends(m) {
        let w = &m.wires[seg];
        let near = nearest_target(m, p, 1.27, &w.uuid);
        let (message, fix) = match &near {
            Some((q, what)) => (
                format!("Wire end at {} connects to nothing; {what} is {} mm away at {}", fmt(p), crate::schematic::fmt_num(r4(dist(p, *q))), fmt(*q)),
                "A near miss: kicad_sch_fix_connectivity {dryRun:false} snaps it onto that point (tolerance 0.5 mm by default; pass tolerance to widen).".to_string(),
            ),
            None => (
                format!("Wire end at {} connects to nothing", fmt(p)),
                "Extend the wire onto the pin or label it should reach, or delete the stub with kicad_sch_delete {uuid}.".to_string(),
            ),
        };
        f.push(Finding { check: "dangling_wire_end", severity: "warning", at: pt(p), uuid: Some(w.uuid.clone()), refs: vec![], nets: vec![], message, fix });
    }

    // floating labels
    for l in &m.labels {
        let touches = on_wire(m, l.at, true) || m.pins.iter().any(|p| same(p.at, l.at)) || m.sheet_pins().any(|s| same(s.at, l.at));
        if !touches {
            f.push(Finding {
                check: "floating_label",
                severity: "error",
                at: pt(l.at),
                uuid: Some(l.uuid.clone()),
                refs: vec![],
                nets: vec![l.text.clone()],
                message: format!("{} label {} at {} touches no wire or pin, so it names nothing", l.kind, l.text, fmt(l.at)),
                fix: "A label connects only where its anchor sits on a wire or a pin end: move it there (delete with kicad_sch_delete and re-add with kicad_sch_label at the wire point), or delete it.".into(),
            });
        }
    }

    // unconnected pins (not passive, not power symbols, not hidden power pins), and flags on wired pins
    for (i, p) in m.pins.iter().enumerate() {
        if p.power_symbol || p.hidden {
            continue;
        }
        let flagged = m.no_connects.iter().any(|n| same(n.at, p.at));
        let attached = on_wire(m, p.at, false) || m.labels.iter().any(|l| same(l.at, p.at)) || m.sheet_pins().any(|s| same(s.at, p.at)) || m.pins.iter().enumerate().any(|(k, q)| k != i && same(q.at, p.at));
        if flagged && attached {
            f.push(Finding {
                check: "no_connect_connected",
                severity: "warning",
                at: pt(p.at),
                uuid: None,
                refs: vec![p.reference.clone()],
                nets: vec![],
                message: format!("{} is wired but also carries a no-connect flag", pin_label(p)),
                fix: "Delete the no-connect flag (kicad_sch_delete with its uuid from kicad_sch_trace_point at the pin) or the wire, whichever is wrong.".into(),
            });
            continue;
        }
        if flagged || attached || p.electrical == "passive" || p.electrical == "no_connect" {
            continue;
        }
        f.push(Finding {
            check: "unconnected_pin",
            severity: "error",
            at: pt(p.at),
            uuid: Some(p.symbol_uuid.clone()),
            refs: vec![p.reference.clone()],
            nets: vec![],
            message: format!("{} ({}) is not connected", pin_label(p), p.electrical),
            fix: if p.electrical == "input" || p.electrical == "power_in" {
                "An open input floats: wire it to its net (kicad_sch_wire to the pin point, at), tie it high or low, or put a no-connect flag on it if the datasheet allows (kicad_sch_no_connect {pin}).".into()
            } else {
                "Wire it (kicad_sch_wire) or flag it intentionally open (kicad_sch_no_connect {pin:\"Ref.Pin\"}).".into()
            },
        });
    }

    // single-pin nets and distinct names shorted together
    let mut cross = 0;
    for (gi, g) in c.groups.iter().enumerate() {
        let part_pins: Vec<_> = g.part_pins(m).collect();
        let has_wire_or_label = g.has(|i| matches!(i, Item::Wire(_) | Item::Label(_)));
        let flagged = g.has(|i| matches!(i, Item::NoConnect(_)));
        if part_pins.len() == 1 && has_wire_or_label && !flagged {
            if g.leaves_sheet() {
                cross += 1;
            } else {
                let p = part_pins[0];
                f.push(Finding {
                    check: "single_pin_net",
                    severity: "warning",
                    at: pt(p.at),
                    uuid: None,
                    refs: vec![p.reference.clone()],
                    nets: vec![c.net_name(m, gi)],
                    message: format!("Net {} reaches only {}: a missing counterpart, an orphan label or a stub", c.net_name(m, gi), pin_label(p)),
                    fix: "Wire the other end, or if the pin is meant to be open replace the stub with a no-connect flag (kicad_sch_no_connect).".into(),
                });
            }
        }
        let names: Vec<&(String, String)> = g.names.iter().filter(|(k, _)| k != "sheet_pin").collect();
        let mut distinct: Vec<&str> = names.iter().map(|(_, t)| t.as_str()).collect();
        distinct.sort();
        distinct.dedup();
        if distinct.len() >= 2 {
            let rails: Vec<&str> = {
                let mut v: Vec<&str> = names.iter().filter(|(k, _)| k == "power" || k == "global").map(|(_, t)| t.as_str()).collect();
                v.sort();
                v.dedup();
                v
            };
            let hard = rails.len() >= 2;
            let at = g.items.iter().find_map(|i| if let Item::Label(k) = i { Some(m.labels[*k].at) } else { None }).or_else(|| part_pins.first().map(|p| p.at));
            f.push(Finding {
                check: "shorted_nets",
                severity: if hard { "error" } else { "warning" },
                at: at.and_then(pt),
                uuid: None,
                refs: part_pins.iter().map(|p| format!("{}.{}", p.reference, p.number)).collect(),
                nets: distinct.iter().map(|s| s.to_string()).collect(),
                message: format!(
                    "One net carries {} names: {}{}",
                    distinct.len(),
                    names.iter().map(|(k, t)| format!("{t} ({k})")).collect::<Vec<_>>().join(", "),
                    if hard { "; two power or global rails are shorted" } else { "; eeschema keeps one and warns (multiple_net_names)" }
                ),
                fix: if hard {
                    "Two rails meet on one wire: find the wire joining them (kicad_sch_trace_point at the location) and break it, unless they really are one rail, then keep one name.".into()
                } else {
                    "If they are meant to be one net keep one name (a hierarchical label beside a power symbol is common and harmless); otherwise separate the wires.".into()
                },
            });
        }
    }

    if opts.overlaps {
        overlaps(m, &mut f);
    }
    CheckReport { findings: f, cross_sheet_skipped: cross }
}

fn bb_json(b: BBox) -> Value {
    json!([r4(b.0), r4(b.1), r4(b.2), r4(b.3)])
}

fn overlaps(m: &SheetModel, f: &mut Vec<Finding>) {
    let syms: Vec<(&str, BBox, bool)> = m.symbols.iter().filter_map(|s| s.bbox.map(|b| (s.reference.as_str(), b, s.is_power))).collect();
    for i in 0..syms.len() {
        for j in (i + 1)..syms.len() {
            let (a, b) = (&syms[i], &syms[j]);
            if a.0 == b.0 {
                continue; // units of one part
            }
            if let Some(o) = overlap(a.1, b.1, 0.25) {
                f.push(Finding {
                    check: "overlap",
                    severity: if a.2 || b.2 { "info" } else { "warning" },
                    at: Some([r4((o.0 + o.2) / 2.0), r4((o.1 + o.3) / 2.0)]),
                    uuid: None,
                    refs: vec![a.0.to_string(), b.0.to_string()],
                    nets: vec![],
                    message: format!("{} and {} overlap (bodies and pins share {} x {} mm)", a.0, b.0, crate::schematic::fmt_num(r4(o.2 - o.0)), crate::schematic::fmt_num(r4(o.3 - o.1))),
                    fix: format!("Move one of them clear: kicad_sch_move {{reference:\"{}\", to:[x, y]}}; bodies span {} and {}.", b.0, bb_json(a.1), bb_json(b.1)),
                });
            }
        }
    }
    // labels and free text over symbol bodies
    let mut texts: Vec<(String, BBox, Option<String>, &'static str, String)> = Vec::new();
    for l in &m.labels {
        let pad = if l.kind == "local" { 0.0 } else { l.size * 1.5 };
        let bb = crate::sch_model::text_bbox(l.at, &l.text, l.size, l.rotation, false, pad);
        texts.push((format!("{} label {}", l.kind, l.text), bb, None, "label", l.uuid.clone()));
    }
    for t in &m.texts {
        let what = match &t.owner {
            Some(o) => format!("field {:?} of {o}", t.text),
            None => format!("text {:?}", t.text),
        };
        texts.push((what, t.bbox, t.owner.clone(), if t.owner.is_some() { "field" } else { "text" }, t.uuid.clone()));
    }
    for (what, bb, owner, kind, uuid) in &texts {
        for (r, sb, is_power) in &syms {
            if owner.as_deref() == Some(*r) || *is_power {
                continue;
            }
            if let Some(o) = overlap(*bb, *sb, 0.15) {
                f.push(Finding {
                    check: "overlap",
                    severity: if *kind == "field" { "info" } else { "warning" },
                    at: Some([r4((o.0 + o.2) / 2.0), r4((o.1 + o.3) / 2.0)]),
                    uuid: Some(uuid.clone()),
                    refs: vec![r.to_string()],
                    nets: vec![],
                    message: format!("{what} runs over the body of {r}"),
                    fix: "Move the text clear of the symbol, or move the symbol (kicad_sch_move).".into(),
                });
            }
        }
    }
    for i in 0..texts.len() {
        for j in (i + 1)..texts.len() {
            let (a, b) = (&texts[i], &texts[j]);
            if a.2.is_some() && a.2 == b.2 {
                continue;
            }
            if let Some(o) = overlap(a.1, b.1, 0.15) {
                f.push(Finding {
                    check: "overlap",
                    severity: "info",
                    at: Some([r4((o.0 + o.2) / 2.0), r4((o.1 + o.3) / 2.0)]),
                    uuid: None,
                    refs: [a.2.clone(), b.2.clone()].into_iter().flatten().collect(),
                    nets: vec![],
                    message: format!("{} and {} overlap", a.0, b.0),
                    fix: "Move one of the two texts so both read.".into(),
                });
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Trace from a point
// ---------------------------------------------------------------------------

/// Everything electrically connected at `at`: the items under the point and, for each net
/// they belong to, the whole net.
pub fn trace_point(m: &SheetModel, c: &Connectivity, at: Pt, tol: f64) -> Value {
    let near = |p: Pt| dist(p, at) <= tol;
    let mut here: Vec<Item> = Vec::new();
    for (i, w) in m.wires.iter().enumerate() {
        let seg_hit = {
            // distance from the point to the segment
            let (dx, dy) = (w.b.0 - w.a.0, w.b.1 - w.a.1);
            let l2 = dx * dx + dy * dy;
            let t = if l2 == 0.0 { 0.0 } else { (((at.0 - w.a.0) * dx + (at.1 - w.a.1) * dy) / l2).clamp(0.0, 1.0) };
            dist(at, (w.a.0 + t * dx, w.a.1 + t * dy)) <= tol
        };
        if seg_hit {
            here.push(Item::Wire(i));
        }
    }
    here.extend(m.pins.iter().enumerate().filter(|(_, p)| near(p.at)).map(|(i, _)| Item::Pin(i)));
    here.extend(m.labels.iter().enumerate().filter(|(_, p)| near(p.at)).map(|(i, _)| Item::Label(i)));
    let sps: Vec<_> = m.sheet_pins().collect();
    here.extend(sps.iter().enumerate().filter(|(_, p)| near(p.at)).map(|(i, _)| Item::SheetPin(i)));
    here.extend(m.junctions.iter().enumerate().filter(|(_, p)| near(p.at)).map(|(i, _)| Item::Junction(i)));
    here.extend(m.no_connects.iter().enumerate().filter(|(_, p)| near(p.at)).map(|(i, _)| Item::NoConnect(i)));

    let item_json = |it: &Item| -> Value {
        match *it {
            Item::Wire(i) => json!({"type": "wire", "uuid": m.wires[i].uuid, "from": [m.wires[i].a.0, m.wires[i].a.1], "to": [m.wires[i].b.0, m.wires[i].b.1]}),
            Item::Pin(i) => {
                let p = &m.pins[i];
                if p.power_symbol {
                    json!({"type": "power_symbol", "reference": p.reference, "net": p.power_net, "at": [p.at.0, p.at.1], "uuid": p.symbol_uuid})
                } else {
                    json!({"type": "pin", "pin": format!("{}.{}", p.reference, p.number), "name": p.name, "electrical": p.electrical, "at": [p.at.0, p.at.1], "symbolUuid": p.symbol_uuid})
                }
            }
            Item::Label(i) => json!({"type": "label", "kind": m.labels[i].kind, "text": m.labels[i].text, "at": [m.labels[i].at.0, m.labels[i].at.1], "uuid": m.labels[i].uuid}),
            Item::SheetPin(i) => json!({"type": "sheet_pin", "name": sps[i].name, "sheet": sps[i].sheet_name, "shape": sps[i].shape, "at": [sps[i].at.0, sps[i].at.1], "uuid": sps[i].uuid}),
            Item::Junction(i) => json!({"type": "junction", "at": [m.junctions[i].at.0, m.junctions[i].at.1], "uuid": m.junctions[i].uuid}),
            Item::NoConnect(i) => json!({"type": "no_connect", "at": [m.no_connects[i].at.0, m.no_connects[i].at.1], "uuid": m.no_connects[i].uuid}),
        }
    };
    let mut groups: Vec<usize> = here.iter().filter_map(|i| c.of.get(i).copied()).collect();
    groups.sort();
    groups.dedup();
    let nets: Vec<Value> = groups
        .iter()
        .map(|&g| {
            let grp = &c.groups[g];
            let pins: Vec<Value> = grp.part_pins(m).map(|p| json!({"pin": format!("{}.{}", p.reference, p.number), "name": p.name, "electrical": p.electrical, "at": [p.at.0, p.at.1]})).collect();
            let labels: Vec<Value> = grp.items.iter().filter_map(|i| if let Item::Label(k) = i { Some(json!({"kind": m.labels[*k].kind, "text": m.labels[*k].text, "at": [m.labels[*k].at.0, m.labels[*k].at.1], "uuid": m.labels[*k].uuid})) } else { None }).collect();
            let power: Vec<String> = grp.pins(m).filter_map(|p| p.power_net.clone()).collect::<std::collections::BTreeSet<_>>().into_iter().collect();
            let wires: Vec<String> = grp.items.iter().filter_map(|i| if let Item::Wire(k) = i { Some(m.wires[*k].uuid.clone()) } else { None }).collect::<std::collections::BTreeSet<_>>().into_iter().collect();
            let sheet_pins: Vec<Value> = grp.items.iter().filter_map(|i| if let Item::SheetPin(k) = i { Some(json!({"name": sps[*k].name, "sheet": sps[*k].sheet_name})) } else { None }).collect();
            let junctions = grp.items.iter().filter(|i| matches!(i, Item::Junction(_))).count();
            let ncs = grp.items.iter().filter(|i| matches!(i, Item::NoConnect(_))).count();
            json!({
                "net": c.net_name(m, g),
                "named": grp.best_name().is_some(),
                "leavesSheet": grp.leaves_sheet(),
                "pins": pins,
                "labels": labels,
                "powerSymbols": power,
                "sheetPins": sheet_pins,
                "wires": wires,
                "junctionCount": junctions,
                "noConnectCount": ncs,
            })
        })
        .collect();
    let crossing = {
        let wires_here: Vec<usize> = here.iter().filter_map(|i| if let Item::Wire(k) = i { Some(*k) } else { None }).collect();
        let gs: std::collections::BTreeSet<usize> = wires_here.iter().filter_map(|k| c.of.get(&Item::Wire(*k)).copied()).collect();
        gs.len() >= 2
    };
    json!({
        "at": [at.0, at.1],
        "tolerance": tol,
        "itemsHere": here.iter().map(item_json).collect::<Vec<_>>(),
        "netCount": nets.len(),
        "nets": nets,
        "crossingWithoutJunction": crossing,
    })
}

// ---------------------------------------------------------------------------
// Near-miss fixer
// ---------------------------------------------------------------------------

#[derive(Clone, Debug, Serialize)]
pub struct Move {
    #[serde(rename = "wireUuid")]
    pub wire_uuid: String,
    pub from: [f64; 2],
    pub to: [f64; 2],
    pub distance: f64,
    pub target: String,
    /// The snapped wire is no longer horizontal or vertical.
    pub diagonal: bool,
}

/// Dangling wire ends within `tol` of a pin, label, sheet pin or another wire's end.
pub fn plan_fixes(m: &SheetModel, tol: f64) -> (Vec<Move>, Vec<Value>) {
    let mut moves = Vec::new();
    let mut left = Vec::new();
    for (seg, p, other) in dangling_ends(m) {
        let w = &m.wires[seg];
        match nearest_target(m, p, tol, &w.uuid) {
            Some((q, what)) => {
                let diagonal = (q.0 - other.0).abs() > 1e-6 && (q.1 - other.1).abs() > 1e-6;
                moves.push(Move { wire_uuid: w.uuid.clone(), from: [r4(p.0), r4(p.1)], to: [r4(q.0), r4(q.1)], distance: r4(dist(p, q)), target: what, diagonal });
            }
            None => left.push(json!({"wireUuid": w.uuid, "at": [r4(p.0), r4(p.1)], "why": format!("nothing connectable within {tol} mm")})),
        }
    }
    (moves, left)
}

/// Rewrite the matching `(xy ..)` of each wire. Returns how many points moved.
pub fn apply_moves(sch: &mut Schematic, moves: &[Move]) -> usize {
    let mut n = 0;
    for mv in moves {
        let Some(idx) = sch.find_uuid_index(&mv.wire_uuid) else { continue };
        let node = &mut sch.doc.root.children_mut().expect("list")[idx];
        let Some(pts) = node.child_mut("pts") else { continue };
        for xy in pts.children_mut().expect("list").iter_mut().filter(|c| c.head() == Some("xy")) {
            let (Some(x), Some(y)) = (xy.num(0), xy.num(1)) else { continue };
            if same((x, y), (mv.from[0], mv.from[1])) {
                xy.set_arg(0, Node::number(mv.to[0]));
                xy.set_arg(1, Node::number(mv.to[1]));
                n += 1;
                break;
            }
        }
    }
    n
}