Skip to main content

brep_kernel/feature_pipeline/
part_pins.rs

1//! A part's symbol PINS are its declared connection POINTS (eCAD plan
2//! decision 8: pins and points are bound in the part, never on an assembly or a
3//! placed component).
4//!
5//! # One identity: the pin label IS the point name
6//!
7//! eCAD names a pin with a free string (`Pin::number`: `1`, `VCC`, `A12`) and a
8//! pad matches a pin by that same string. BREP used to refer to a connection
9//! point by a PORT FEATURE ID (`PORT7`), which made the pin label a second,
10//! separate identity that this module existed to keep paired. With the `ports`
11//! block (`feature_pipeline/ports.rs`) a point's NAME is its identity — the
12//! address `J1.VCC` is built out of it — so pin `VCC` and point `VCC` are one
13//! name, not two that must be kept equal.
14//!
15//! # One symbol UNIT per port group
16//!
17//! A part may declare SEVERAL port groups, and `J1.VCC` and `J2.VCC` are both
18//! "point VCC". The symbol says which one a pin means: eCAD's `Symbol` carries
19//! `unit_count` and each `brep_ecad_core::Pin` carries its `unit`, which
20//! is KiCad's own model for a multi-unit device. ONE UNIT MAPS TO ONE PORT
21//! GROUP ([`ports::unit_map`], [`unit_groups`](crate::feature_pipeline::part_pins::unit_groups)), so pin `1` of unit 1 is `J1.1`
22//! and pin `1` of unit 2 is `J2.1`. Two identical 8-pin headers on one part are
23//! then ordinary rather than a collision, and the pin labels stay short.
24//!
25//! Everything below is therefore SCOPED TO A UNIT: a pin binds to the point of
26//! the same name IN ITS UNIT'S GROUP, the follow calls diff one unit's pins
27//! against one group's points, and a repeated name is only a clash within one
28//! group. A group that maps to no unit — the piping port of a valve whose
29//! symbol is only its pressure sensor — takes no pins and is left alone.
30//!
31//! A part that declares no ports at all reads as "unit 1 is the group a new pin
32//! creates" ([`DEFAULT_PORT_NAME`](crate::feature_pipeline::part_pins::DEFAULT_PORT_NAME)), which is the KiCad import's case.
33//!
34//! WAYPOINTS never enter here. A waypoint is a history feature and a
35//! pass-through, never a cable end, so it has no pin and cannot be named by one.
36//!
37//! # Where the set lives
38//!
39//! A part document keeps its symbol and pads as the top-level [`SYMBOL_BLOCK`](crate::feature_pipeline::part_pins::SYMBOL_BLOCK)
40//! and [`PADS_BLOCK`](crate::feature_pipeline::part_pins::PADS_BLOCK) (eCAD's `Symbol` and `Footprint` JSON), and its connection
41//! points as the `ports` block. The binding stores nothing of its own.
42//!
43//! The binding applies once a part carries a symbol. A 3D-only part keeps its
44//! points exactly as they are, repeated names included.
45//!
46//! # Edits are diffed from a BASE, and an invalid state is held
47//!
48//! Each keystroke in a text field is an edit. Renaming pin `2` to `10` passes
49//! through `` (no label) and then `1`, which repeats pin `1`. So a follow call
50//! compares the edited side against `base`, the document at the START of the
51//! edit's coalesced run: the snapshot BREP's history keeps for undo while one
52//! coalesce key repeats. It then REWRITES the other side from `base`'s pairing,
53//! addressing both sides BY INDEX while the list is the one `base` had (and by
54//! name once it is not). Replaying a run from its base is idempotent, so the
55//! point reads `10` whichever keystroke got there. A state that cannot be bound,
56//! such as two pins of one unit sharing a label or two points of one group
57//! sharing a name, returns `Err` naming it. That is a HOLD, not a refusal: the
58//! user's edit stands, the other side keeps what the last bound keystroke gave
59//! it, and the next keystroke is diffed from the same base again.
60//!
61//! Resolution is where a name must name exactly one point.
62//! [`resolve_component_pin`](crate::feature_pipeline::part_pins::resolve_component_pin) and [`resolve_part_pin`](crate::feature_pipeline::part_pins::resolve_part_pin) REFUSE a missing or
63//! repeated name by name; each takes an optional port group, which is how a
64//! caller that knows the unit narrows two identical headers to one.
65//!
66//! [`resolve_component_pin`](crate::feature_pipeline::part_pins::resolve_component_pin) names the device by its OCCURRENCE CHAIN, not by
67//! one ACOMP id: a wiring diagram reaches a device nested at any depth
68//! (`ACOMP7:ACOMP1`), and the endpoint's own address is what says where it sits
69//! ([`endpoint_occurrence`](crate::feature_pipeline::part_pins::endpoint_occurrence)).
70
71use serde::{Deserialize, Serialize};
72use std::collections::{BTreeMap, BTreeSet};
73
74use serde_json::Value;
75
76use crate::feature_pipeline::ports::{self, PortDeclaration, UnitProblem, PORTS_BLOCK};
77use crate::feature_pipeline::wire_harness::WireHarnessEndpoint;
78use crate::feature_pipeline::{component, PortKind, PortRecord};
79
80/// The part document's top-level key for its schematic symbol (eCAD `Symbol`).
81pub const SYMBOL_BLOCK: &str = "symbol";
82/// The part document's top-level key for its footprint pads (eCAD `Footprint`).
83pub const PADS_BLOCK: &str = "pads";
84/// The port group a new point goes into when a part declares none yet. An
85/// electronics part's pins ARE its pads, so the group it makes for them is a
86/// `pcb` one.
87pub const DEFAULT_PORT_NAME: &str = "Pins";
88/// The purpose that group takes.
89pub const DEFAULT_PORT_PURPOSE: &str = "pcb";
90
91/// eCAD's grid step (µm): new pins are spaced by two of these.
92const PIN_PITCH: i64 = 2540;
93/// Where the first pin created for a point goes when the symbol has none yet:
94/// its connection tip, a pin length left of the origin.
95const FIRST_PIN_TIP: (i64, i64) = (-7620, 0);
96
97// ===========================================================================
98// The two sides as a document declares them
99// ===========================================================================
100
101/// One connection point as a part document declares it.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct DeclaredPoint {
104    /// The port group it belongs to (`J1`).
105    pub port: String,
106    /// The point name, which is what a pin binds to (`VCC`).
107    pub point: String,
108    /// The group's purpose (`pcb`, `wiring`, `piping`, …).
109    pub purpose: String,
110}
111
112impl DeclaredPoint {
113    /// Its part-local address (`J1.VCC`).
114    pub fn address(&self) -> String {
115        ports::address(&self.port, &self.point)
116    }
117}
118
119/// One symbol pin as a part document declares it.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct DeclaredPin {
122    /// The symbol unit it belongs to, 1-based. `0` on the wire reads as `1`,
123    /// which is what a symbol written before units carries.
124    pub unit: u32,
125    /// Its `number`, the label eCAD matches by.
126    pub label: String,
127}
128
129/// Every connection point `document` declares, in block order. Only the
130/// document's OWN block counts: a nested assembly's component points reach the
131/// part's library entry namespaced, and they are not this part's pins.
132pub fn declared_points(document: &Value) -> Vec<DeclaredPoint> {
133    declarations(document)
134        .into_iter()
135        .flat_map(|group| {
136            let (port, purpose) = (group.name.trim().to_string(), group.purpose);
137            group.points.into_iter().map(move |point| DeclaredPoint {
138                port: port.clone(),
139                point: point.name.trim().to_string(),
140                purpose: purpose.clone(),
141            })
142        })
143        .collect()
144}
145
146/// The document's `ports` block, parsed. An unreadable block reads as none —
147/// the history run reports it, and the binding has nothing to bind.
148pub fn declarations(document: &Value) -> Vec<PortDeclaration> {
149    document
150        .get(PORTS_BLOCK)
151        .cloned()
152        .and_then(|block| serde_json::from_value::<Vec<PortDeclaration>>(block).ok())
153        .unwrap_or_default()
154}
155
156fn port_blocks(document: &Value) -> &[Value] {
157    document
158        .get(PORTS_BLOCK)
159        .and_then(Value::as_array)
160        .map(Vec::as_slice)
161        .unwrap_or(&[])
162}
163
164/// The symbol's pins in pin order, or `None` when the document carries no
165/// symbol (the binding is off).
166pub fn pins(document: &Value) -> Option<Vec<DeclaredPin>> {
167    let symbol = document.get(SYMBOL_BLOCK).filter(|block| !block.is_null())?;
168    Some(
169        symbol
170            .get("pins")
171            .and_then(Value::as_array)
172            .map(|pins| {
173                pins.iter()
174                    .map(|pin| DeclaredPin {
175                        unit: pin.get("unit").and_then(Value::as_u64).unwrap_or(0).max(1) as u32,
176                        label: label_of(pin),
177                    })
178                    .collect()
179            })
180            .unwrap_or_default(),
181    )
182}
183
184/// The symbol's pin labels in pin order, units flattened — what a caller that
185/// only wants the labels reads.
186pub fn pin_labels(document: &Value) -> Option<Vec<String>> {
187    Some(pins(document)?.into_iter().map(|pin| pin.label).collect())
188}
189
190/// The symbol's `unit_count`, with `0` (a symbol written before units, or one
191/// serialized without the field) reading as `1`.
192pub fn unit_count(document: &Value) -> u32 {
193    document
194        .get(SYMBOL_BLOCK)
195        .and_then(|symbol| symbol.get("unit_count"))
196        .and_then(Value::as_u64)
197        .unwrap_or(0)
198        .max(1) as u32
199}
200
201/// Which port GROUP each symbol unit binds its pins into, by name, and
202/// everything about the mapping that does not hold.
203///
204/// The rule is [`ports::unit_map`]'s. The one thing added here is the empty
205/// part: a document that declares no ports at all maps unit 1 to the
206/// [`DEFAULT_PORT_NAME`] group, which the first new pin creates. That is the
207/// KiCad import's case and the reason importing a symbol yields ports.
208pub fn unit_groups(document: &Value) -> (BTreeMap<u32, String>, Vec<UnitProblem>) {
209    let mut declarations = declarations(document);
210    if declarations.is_empty() {
211        declarations.push(PortDeclaration {
212            name: DEFAULT_PORT_NAME.to_string(),
213            purpose: DEFAULT_PORT_PURPOSE.to_string(),
214            ..PortDeclaration::default()
215        });
216    }
217    let (map, problems) = ports::unit_map(
218        &declarations,
219        unit_count(document),
220        pins(document).is_some(),
221    );
222    let named = map
223        .into_iter()
224        .map(|(unit, index)| (unit, declarations[index].name.trim().to_string()))
225        .collect();
226    (named, problems)
227}
228
229/// The port group pin of unit `unit` binds into, when one is mapped.
230pub fn port_group_for_unit(document: &Value, unit: u32) -> Option<String> {
231    unit_groups(document).0.get(&unit.max(1)).cloned()
232}
233
234/// The footprint's pad numbers in pad order (empty = a mechanical pad).
235fn pad_numbers(document: &Value) -> Vec<String> {
236    document
237        .get(PADS_BLOCK)
238        .and_then(|block| block.get("pads"))
239        .and_then(Value::as_array)
240        .map(|pads| pads.iter().map(|pad| label_of(pad)).collect())
241        .unwrap_or_default()
242}
243
244/// A pin's or pad's `number`: the label eCAD matches by, whatever it spells.
245fn label_of(item: &Value) -> String {
246    item.get("number").and_then(Value::as_str).unwrap_or_default().to_string()
247}
248
249// ---------------------------------------------------------------------------
250// Slicing either side by unit / by group
251// ---------------------------------------------------------------------------
252
253/// The indices of the pins in `unit`, in pin order.
254fn pins_of_unit(pins: &[DeclaredPin], unit: u32) -> Vec<usize> {
255    pins.iter()
256        .enumerate()
257        .filter(|(_, pin)| pin.unit == unit)
258        .map(|(index, _)| index)
259        .collect()
260}
261
262/// The FLAT indices of the points of the group named `group`, in block order.
263fn points_of_group(points: &[DeclaredPoint], group: &str) -> Vec<usize> {
264    points
265        .iter()
266        .enumerate()
267        .filter(|(_, point)| point.port == group)
268        .map(|(index, _)| index)
269        .collect()
270}
271
272fn labels_at(pins: &[DeclaredPin], indices: &[usize]) -> Vec<String> {
273    indices.iter().map(|index| pins[*index].label.clone()).collect()
274}
275
276fn names_at(points: &[DeclaredPoint], indices: &[usize]) -> Vec<String> {
277    indices.iter().map(|index| points[*index].point.clone()).collect()
278}
279
280/// Why a pin list cannot be bound: a pin with no label, or a label two pins of
281/// the same unit share. eCAD's own `Document::validate` rejects both.
282fn pin_list_problem(labels: &[String]) -> Option<String> {
283    if labels.iter().any(String::is_empty) {
284        return Some("a pin has no label".into());
285    }
286    let mut seen = BTreeSet::new();
287    labels
288        .iter()
289        .find(|label| !seen.insert(label.as_str()))
290        .map(|label| format!("two pins are labelled '{label}'"))
291}
292
293/// Why a point list cannot be bound: a point with no name, or a name two points
294/// of one group share — a pin could not tell them apart.
295fn point_list_problem(names: &[String]) -> Option<String> {
296    if names.iter().any(String::is_empty) {
297        return Some("a connection point has no name".into());
298    }
299    let mut seen = BTreeSet::new();
300    names
301        .iter()
302        .find(|name| !seen.insert(name.as_str()))
303        .map(|name| format!("two connection points are named '{name}'"))
304}
305
306/// Which point each pin pairs with, within one unit and its group: the ONE
307/// point carrying its name. Indices are into the flat point list.
308fn pairing(pin_labels: &[String], point_names: &[String], point_indices: &[usize]) -> Vec<Option<usize>> {
309    pin_labels
310        .iter()
311        .map(|pin| {
312            let hits: Vec<usize> = point_names
313                .iter()
314                .enumerate()
315                .filter(|(_, name)| *name == pin)
316                .map(|(local, _)| point_indices[local])
317                .collect();
318            match hits.as_slice() {
319                [index] => Some(*index),
320                _ => None,
321            }
322        })
323        .collect()
324}
325
326// ===========================================================================
327// The report: what does not pair, by name
328// ===========================================================================
329
330/// One pin and the connection point it names, by address.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct PinPoint {
333    pub pin: String,
334    /// The pin's symbol unit.
335    pub unit: u32,
336    pub point: String,
337}
338
339/// Something about a part's pins and points that does not pair. Each variant
340/// names what is concerned; [`PinPointProblem::message`] says it in words.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342#[serde(tag = "problem", rename_all = "kebab-case")]
343pub enum PinPointProblem {
344    /// A pin with no label.
345    UnlabelledPin { unit: u32 },
346    /// Two pins of one unit share a label.
347    RepeatedPin { unit: u32, pin: String },
348    /// No connection point in the pin's unit group carries its label.
349    PinWithoutPoint { unit: u32, pin: String },
350    /// Several points of one group carry one name, so a pin cannot name one.
351    RepeatedPointName { name: String, points: Vec<String> },
352    /// A connection point in a pin-bound group whose name no pin carries.
353    PointWithoutPin { point: String },
354    /// A numbered pad whose number no pin carries (an empty number is a
355    /// mechanical pad and is not listed).
356    PadWithoutPin { pad: String },
357    /// The symbol units and the port groups disagree — see [`UnitProblem`].
358    Unit(UnitProblem),
359}
360
361impl PinPointProblem {
362    pub fn message(&self) -> String {
363        match self {
364            PinPointProblem::UnlabelledPin { unit } => format!("a pin of unit {unit} has no label"),
365            PinPointProblem::RepeatedPin { unit, pin } => {
366                format!("two pins of unit {unit} are labelled '{pin}'")
367            }
368            PinPointProblem::PinWithoutPoint { unit, pin } => {
369                format!("pin '{pin}' of unit {unit} has no connection point")
370            }
371            PinPointProblem::RepeatedPointName { name, points } => {
372                format!("{} are all named '{name}'", points.join(", "))
373            }
374            PinPointProblem::PointWithoutPin { point } => format!("{point} has no pin"),
375            PinPointProblem::PadWithoutPin { pad } => format!("pad '{pad}' matches no pin"),
376            PinPointProblem::Unit(problem) => problem.message(),
377        }
378    }
379}
380
381/// How a part's pins and connection points pair: every pair, then every problem.
382#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
383pub struct PinPointReport {
384    pub pairs: Vec<PinPoint>,
385    pub problems: Vec<PinPointProblem>,
386}
387
388/// How `document`'s pins and connection points pair, or `None` when it carries
389/// no symbol (the binding is off; a 3D-only part's points are nobody's pins).
390///
391/// This is the consistency report the old `pin_port_report` was, stated against
392/// names and scoped by unit. A document that arrives inconsistent — written
393/// before the binding existed, replaced wholesale, or edited outside the follow
394/// calls — is what it exists to describe.
395pub fn pin_point_report(document: &Value) -> Option<PinPointReport> {
396    let pins = pins(document)?;
397    let points = declared_points(document);
398    let (groups, unit_problems) = unit_groups(document);
399    let mut report = PinPointReport::default();
400    report
401        .problems
402        .extend(unit_problems.into_iter().map(PinPointProblem::Unit));
403
404    // Every pin label carried by any unit — a pad matches a pin symbol-wide.
405    let mut all_labels: BTreeSet<String> = BTreeSet::new();
406    // The groups a unit binds into, so a point in an unbound group is not
407    // reported as pinless.
408    let bound: BTreeSet<&str> = groups.values().map(String::as_str).collect();
409
410    for unit in units_present(&pins, &groups) {
411        let pin_indices = pins_of_unit(&pins, unit);
412        let labels = labels_at(&pins, &pin_indices);
413        let mut seen: BTreeSet<String> = BTreeSet::new();
414        let mut repeated: BTreeSet<String> = BTreeSet::new();
415        for label in &labels {
416            if label.is_empty() {
417                report.problems.push(PinPointProblem::UnlabelledPin { unit });
418            } else if !seen.insert(label.clone()) && repeated.insert(label.clone()) {
419                report
420                    .problems
421                    .push(PinPointProblem::RepeatedPin { unit, pin: label.clone() });
422            }
423        }
424        all_labels.extend(seen.iter().cloned());
425
426        let group = groups.get(&unit).cloned().unwrap_or_default();
427        let point_indices = points_of_group(&points, &group);
428        let names = names_at(&points, &point_indices);
429        let mut reported: BTreeSet<String> = BTreeSet::new();
430        for label in labels.iter().filter(|label| !label.is_empty()) {
431            if !reported.insert(label.clone()) {
432                continue;
433            }
434            let hits: Vec<usize> = names
435                .iter()
436                .enumerate()
437                .filter(|(_, name)| *name == label)
438                .map(|(local, _)| point_indices[local])
439                .collect();
440            match hits.as_slice() {
441                [index] => report.pairs.push(PinPoint {
442                    pin: label.clone(),
443                    unit,
444                    point: points[*index].address(),
445                }),
446                [] => report
447                    .problems
448                    .push(PinPointProblem::PinWithoutPoint { unit, pin: label.clone() }),
449                // Reported once, below, with every point that carries it.
450                _ => {}
451            }
452        }
453        // A point of a BOUND group that no pin of that unit names.
454        for index in &point_indices {
455            if !seen.contains(&points[*index].point) {
456                report
457                    .problems
458                    .push(PinPointProblem::PointWithoutPin { point: points[*index].address() });
459            }
460        }
461    }
462
463    // A name repeated WITHIN one group: no pin could tell the two apart.
464    let mut by_group: BTreeMap<(&str, &str), Vec<String>> = BTreeMap::new();
465    for point in &points {
466        by_group
467            .entry((point.port.as_str(), point.point.as_str()))
468            .or_default()
469            .push(point.address());
470    }
471    for ((group, name), addresses) in &by_group {
472        if addresses.len() > 1 && bound.contains(group) {
473            report.problems.push(PinPointProblem::RepeatedPointName {
474                name: (*name).to_string(),
475                points: addresses.clone(),
476            });
477        }
478    }
479
480    let mut pads_reported = BTreeSet::new();
481    for pad in pad_numbers(document) {
482        if !pad.is_empty() && !all_labels.contains(pad.as_str()) && pads_reported.insert(pad.clone())
483        {
484            report.problems.push(PinPointProblem::PadWithoutPin { pad });
485        }
486    }
487    Some(report)
488}
489
490/// Every unit worth walking: the ones the symbol's pins are in, plus the ones
491/// a port group claims. In ascending order.
492fn units_present(pins: &[DeclaredPin], groups: &BTreeMap<u32, String>) -> Vec<u32> {
493    let mut units: BTreeSet<u32> = pins.iter().map(|pin| pin.unit).collect();
494    units.extend(groups.keys().copied());
495    units.into_iter().collect()
496}
497
498// ===========================================================================
499// Following an edit from one side to the other
500// ===========================================================================
501
502/// One change a follow call made to the side the user did not edit.
503#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
504#[serde(tag = "change", rename_all = "kebab-case")]
505pub enum PinPointChange {
506    PointRenamed { from: String, to: String },
507    PointAdded { point: String },
508    PointRemoved { point: String },
509    PinRelabelled { from: String, to: String },
510    PinAdded { pin: String, unit: u32 },
511    PinRemoved { pin: String },
512    PadsRenumbered { from: String, to: String, count: usize },
513    /// A pad was NOT renumbered because several units carry the old label, so
514    /// the footprint cannot say which pin's pad it is (see `renumber_pads`).
515    PadsAmbiguous { from: String, to: String, units: Vec<u32> },
516}
517
518/// The user edited the SYMBOL (or its pins' labels): carry the pin changes
519/// between `base` and `after` to the connection points and pads of `after`.
520///
521/// Each UNIT is diffed against its own port group. Within a unit, pins are
522/// matched by label first; a label in both lists is the same pin. What remains
523/// pairs up in place (see `diff_labels`): a label that left and one that
524/// arrived between the same two unchanged pins is a RENAME, and its point is
525/// renamed. A label that only left removes its point. A label that only arrived
526/// pairs with a point of that group already carrying it, or gets a new point in
527/// it (the group is created when the part declares none). Pads follow a rename.
528///
529/// `Err` holds the edit (see the module docs) and leaves `after` untouched.
530pub fn follow_pin_edit(base: &Value, after: &mut Value) -> Result<Vec<PinPointChange>, String> {
531    let Some(pins_after) = pins(after) else {
532        return Ok(Vec::new());
533    };
534    let pins_base = pins(base).unwrap_or_default();
535    let points_base = declared_points(base);
536    // The mapping is the one the EDITED document declares: a unit that gained a
537    // group in this same edit binds straight away.
538    let (groups, _) = unit_groups(after);
539
540    let mut document = after.clone();
541    let mut changes = Vec::new();
542    // Deferred so every flat point index stays the one `base` published until
543    // the renames are done (see the module docs on replaying from a base).
544    let mut doomed: Vec<usize> = Vec::new();
545    let mut arrivals: Vec<(String, String)> = Vec::new();
546
547    for unit in units_present(&pins_after, &groups) {
548        let after_indices = pins_of_unit(&pins_after, unit);
549        let labels_after = labels_at(&pins_after, &after_indices);
550        if let Some(problem) = pin_list_problem(&labels_after) {
551            return Err(problem);
552        }
553        let Some(group) = groups.get(&unit) else {
554            continue; // No group takes this unit's pins; the report says so.
555        };
556        let labels_base = labels_at(&pins_base, &pins_of_unit(&pins_base, unit));
557        let point_indices = points_of_group(&points_base, group);
558        let names_base = names_at(&points_base, &point_indices);
559        let paired = pairing(&labels_base, &names_base, &point_indices);
560        let (renames, removed, added) = diff_labels(&labels_base, &labels_after);
561
562        for (index, from, to) in &renames {
563            match paired[*index] {
564                Some(at) => {
565                    // Nothing may take a name a point this edit KEEPS already
566                    // has, within the same group.
567                    let current = declared_points(&document);
568                    let holder = points_of_group(&current, group).into_iter().find(|other| {
569                        *other != at && current[*other].point == *to
570                    });
571                    if let Some(holder) = holder {
572                        return Err(format!(
573                            "pin '{from}' cannot become '{to}': {} is already named '{to}'",
574                            current[holder].address()
575                        ));
576                    }
577                    if rename_point(base, &mut document, at, from, to) {
578                        changes.push(PinPointChange::PointRenamed {
579                            from: from.clone(),
580                            to: to.clone(),
581                        });
582                    }
583                }
584                // The pin had no point: the new label is an arrival.
585                None => arrivals.push((group.clone(), to.clone())),
586            }
587            // Only a pad that exists is ambiguous; a part with no footprint has
588            // nothing to say here.
589            let shared = units_labelled(&pins_base, from);
590            if shared.len() > 1 && pad_numbers(base).iter().any(|number| number == from) {
591                changes.push(PinPointChange::PadsAmbiguous {
592                    from: from.clone(),
593                    to: to.clone(),
594                    units: shared,
595                });
596            } else {
597                let count = renumber_pads(base, &mut document, from, to);
598                if count > 0 {
599                    changes.push(PinPointChange::PadsRenumbered {
600                        from: from.clone(),
601                        to: to.clone(),
602                        count,
603                    });
604                }
605            }
606        }
607        doomed.extend(removed.iter().filter_map(|index| paired[*index]));
608        arrivals.extend(
609            added
610                .iter()
611                .map(|index| (group.clone(), labels_after[*index].clone())),
612        );
613    }
614
615    // Removals highest flat index first, so the earlier indices stay valid.
616    doomed.sort_unstable();
617    doomed.dedup();
618    for at in doomed.into_iter().rev() {
619        if let Some(address) = remove_point(&mut document, at) {
620            changes.push(PinPointChange::PointRemoved { point: address });
621        }
622    }
623    for (group, name) in arrivals {
624        let current = declared_points(&document);
625        let holders: Vec<usize> = points_of_group(&current, &group)
626            .into_iter()
627            .filter(|index| current[*index].point == name)
628            .collect();
629        match holders.as_slice() {
630            [] => {
631                let address = push_point(&mut document, &group, &name);
632                changes.push(PinPointChange::PointAdded { point: address });
633            }
634            [_] => {}
635            several => {
636                return Err(format!(
637                    "pin '{name}' names {}, which share that name",
638                    several
639                        .iter()
640                        .map(|at| current[*at].address())
641                        .collect::<Vec<_>>()
642                        .join(", ")
643                ));
644            }
645        }
646    }
647    *after = document;
648    Ok(changes)
649}
650
651/// The user edited the CONNECTION POINTS (a point renamed, added or deleted):
652/// carry the change between `base` and `after` to the pins and pads of `after`.
653///
654/// The two sides are symmetric: a point's name is its identity exactly as a
655/// pin's label is, so this runs the same positional diff the other way, one
656/// group against one unit's pins. A renamed point relabels the pin `base`
657/// paired with it, a point that is gone removes its pin, and a point this edit
658/// brought gets a pin below the others unless a pin of that unit already
659/// carries its name. Pads follow a rename.
660///
661/// `Err` holds the edit (see the module docs) and leaves `after` untouched.
662pub fn follow_point_edit(base: &Value, after: &mut Value) -> Result<Vec<PinPointChange>, String> {
663    let Some(pins_before) = pins(after) else {
664        return Ok(Vec::new());
665    };
666    let points_after = declared_points(after);
667    let points_base = declared_points(base);
668    let pins_base = pins(base).unwrap_or_default();
669    let (groups, _) = unit_groups(after);
670
671    let mut document = after.clone();
672    let mut changes = Vec::new();
673    // The symbol is not what this edit changed, so `after` still lists the pins
674    // in `base`'s order (a run that has already relabelled one only changed its
675    // label; the index is the same pin).
676    let mut labels: Vec<String> = pins_before.iter().map(|pin| pin.label.clone()).collect();
677    let mut doomed: Vec<usize> = Vec::new();
678    let mut arrivals: Vec<(u32, String)> = Vec::new();
679
680    for unit in units_present(&pins_before, &groups) {
681        let Some(group) = groups.get(&unit) else { continue };
682        let after_group = points_of_group(&points_after, group);
683        let names_after = names_at(&points_after, &after_group);
684        if let Some(problem) = point_list_problem(&names_after) {
685            return Err(problem);
686        }
687        let names_base = names_at(&points_base, &points_of_group(&points_base, group));
688        let unit_pins_base = pins_of_unit(&pins_base, unit);
689        let (renames, removed, added) = diff_labels(&names_base, &names_after);
690
691        // Where pin `from` of this unit sits in the CURRENT label list.
692        let locate = |labels: &[String], from: &str| -> Option<usize> {
693            let same_list = labels.len() == pins_base.len();
694            if same_list {
695                unit_pins_base
696                    .iter()
697                    .copied()
698                    .find(|index| pins_base[*index].label == from)
699            } else {
700                labels.iter().position(|label| label == from)
701            }
702        };
703        for (_, from, to) in &renames {
704            let Some(at) = locate(&labels, from) else {
705                arrivals.push((unit, to.clone()));
706                continue;
707            };
708            if labels.iter().enumerate().any(|(other, label)| other != at && label == to) {
709                return Err(format!(
710                    "a connection point cannot become '{to}': pin '{to}' already exists"
711                ));
712            }
713            if labels[at] != *to {
714                labels[at] = to.clone();
715                changes.push(PinPointChange::PinRelabelled {
716                    from: from.clone(),
717                    to: to.clone(),
718                });
719            }
720            // Only a pad that exists is ambiguous; a part with no footprint has
721            // nothing to say here.
722            let shared = units_labelled(&pins_base, from);
723            if shared.len() > 1 && pad_numbers(base).iter().any(|number| number == from) {
724                changes.push(PinPointChange::PadsAmbiguous {
725                    from: from.clone(),
726                    to: to.clone(),
727                    units: shared,
728                });
729            } else {
730                let count = renumber_pads(base, &mut document, from, to);
731                if count > 0 {
732                    changes.push(PinPointChange::PadsRenumbered {
733                        from: from.clone(),
734                        to: to.clone(),
735                        count,
736                    });
737                }
738            }
739        }
740        doomed.extend(
741            removed
742                .iter()
743                .filter_map(|index| locate(&labels, &names_base[*index])),
744        );
745        arrivals.extend(added.iter().map(|index| (unit, names_after[*index].clone())));
746    }
747
748    doomed.sort_unstable();
749    doomed.dedup();
750    for at in doomed.into_iter().rev() {
751        changes.push(PinPointChange::PinRemoved { pin: labels[at].clone() });
752        remove_pin(&mut document, at);
753        labels.remove(at);
754    }
755    set_pin_labels(&mut document, &labels);
756    for (unit, name) in arrivals {
757        let existing = pins(&document).unwrap_or_default();
758        if existing
759            .iter()
760            .any(|pin| pin.unit == unit && pin.label == name)
761        {
762            continue;
763        }
764        push_pin(&mut document, unit, &name);
765        changes.push(PinPointChange::PinAdded { pin: name, unit });
766    }
767    *after = document;
768    Ok(changes)
769}
770
771/// What the two versions of a part say about which point became which, and on
772/// what evidence.
773#[derive(Debug, Clone, Default, PartialEq, Eq)]
774pub struct PointRenames {
775    /// Renames a shared ANCHOR proves: both versions seat the point with the
776    /// same geometry, so one IS the other whatever it is spelled.
777    pub anchored: BTreeMap<String, String>,
778    /// Renames read POSITIONALLY, which a delete-and-add of a point in the same
779    /// place is indistinguishable from. A consumer should still APPLY them.
780    pub positional: BTreeMap<String, String>,
781}
782
783impl PointRenames {
784    /// Every rename, whatever the evidence — what a consumer applies.
785    pub fn all(&self) -> BTreeMap<String, String> {
786        let mut all = self.anchored.clone();
787        all.extend(self.positional.clone());
788        all
789    }
790
791    pub fn is_empty(&self) -> bool {
792        self.anchored.is_empty() && self.positional.is_empty()
793    }
794}
795
796/// Everything about a declared point EXCEPT its NAME: what seats it, what aims
797/// it, and the descendant it maps to. A rename moves the name and leaves this
798/// alone, so two versions of one part that share an anchor share a point
799/// however it is spelled.
800///
801/// `None` for a point that carries no anchor at all. An unseated point is
802/// evidence of nothing, and the display fields (`extension`, `displayLength`)
803/// are left out because they say where a wire is DRAWN, not which point it is.
804fn anchor(point: &ports::PortPoint) -> Option<String> {
805    let seats = [
806        point.point_ref.clone().unwrap_or_default(),
807        point.direction_ref.clone().unwrap_or_default(),
808        point.maps_to.clone().unwrap_or_default(),
809        if point.transform.is_null() { String::new() } else { point.transform.to_string() },
810    ];
811    seats
812        .iter()
813        .any(|seat| !seat.is_empty())
814        .then(|| seats.join("\u{1}"))
815}
816
817/// The points of one group across the two versions, paired by an anchor that is
818/// UNIQUE on both sides. An anchor several points share proves nothing, so it
819/// pairs nothing.
820fn anchored_pairs(base: &[ports::PortPoint], after: &[ports::PortPoint]) -> Vec<(usize, usize)> {
821    let unique = |points: &[ports::PortPoint]| -> BTreeMap<String, Option<usize>> {
822        let mut seen: BTreeMap<String, Option<usize>> = BTreeMap::new();
823        for (index, point) in points.iter().enumerate() {
824            if let Some(key) = anchor(point) {
825                seen.entry(key)
826                    .and_modify(|slot| *slot = None)
827                    .or_insert(Some(index));
828            }
829        }
830        seen
831    };
832    let arrived = unique(after);
833    unique(base)
834        .into_iter()
835        .filter_map(|(key, from)| Some((from?, (*arrived.get(&key)?)?)))
836        .collect()
837}
838
839/// The points of the group named `group`, in block order. Two declarations
840/// sharing a name are one group, as [`points_of_group`] reads them.
841fn group_points(document: &Value, group: &str) -> Vec<ports::PortPoint> {
842    declarations(document)
843        .into_iter()
844        .filter(|declaration| declaration.name.trim() == group)
845        .flat_map(|declaration| declaration.points)
846        .collect()
847}
848
849/// Which point of one part document became which in another, per group, and on
850/// what evidence. There is no id behind a point any more, so this is how a
851/// consumer that must carry something across a part refresh — an eCAD sheet's
852/// terminals, for one — learns that `VCC` became `VDD` rather than being
853/// deleted and replaced.
854///
855/// Two passes. The ANCHOR pass pairs points that both versions seat the same
856/// way; that pairing is evidence, not a guess, and it is what makes a rename
857/// survive across two SAVED versions of a part. Whatever it does not pair falls
858/// to the POSITIONAL pass, which reads the remaining names the same way a
859/// follow call reads one edit's worth of them — and which cannot tell a rename
860/// from a delete-and-add. The two are kept apart so the consumer can say which
861/// it acted on.
862pub fn point_renames(base: &Value, after: &Value) -> PointRenames {
863    let (points_base, points_after) = (declared_points(base), declared_points(after));
864    let mut groups: Vec<&str> = points_base.iter().map(|point| point.port.as_str()).collect();
865    groups.extend(points_after.iter().map(|point| point.port.as_str()));
866    groups.sort_unstable();
867    groups.dedup();
868    let mut renames = PointRenames::default();
869    for group in groups {
870        let (seated_base, seated_after) = (group_points(base, group), group_points(after, group));
871        let pairs = anchored_pairs(&seated_base, &seated_after);
872        for (from, to) in &pairs {
873            let (from, to) = (seated_base[*from].name.trim(), seated_after[*to].name.trim());
874            if from != to {
875                renames.anchored.insert(from.to_string(), to.to_string());
876            }
877        }
878        let left: BTreeSet<usize> = pairs.iter().map(|(from, _)| *from).collect();
879        let arrived: BTreeSet<usize> = pairs.iter().map(|(_, to)| *to).collect();
880        let rest = |points: &[ports::PortPoint], paired: &BTreeSet<usize>| -> Vec<String> {
881            points
882                .iter()
883                .enumerate()
884                .filter(|(index, _)| !paired.contains(index))
885                .map(|(_, point)| point.name.trim().to_string())
886                .collect()
887        };
888        for (_, from, to) in diff_labels(&rest(&seated_base, &left), &rest(&seated_after, &arrived)).0
889        {
890            renames.positional.insert(from, to);
891        }
892    }
893    renames
894}
895
896/// Split a label list's edit into renames `(base index, from, to)`, removals
897/// (base indices) and arrivals (after indices).
898///
899/// A label in both lists is the same item wherever it moved. The labels that
900/// left and the labels that arrived are grouped by how many kept labels come
901/// before them. In each group they pair up in order as renames. Renaming pin 3
902/// and deleting pin 1 of `[1, 2, 3]` in one change gives `[2, X]`: `1` sits
903/// before the kept `2` and `3` after it, so `3 → X` pairs and `1` is removed.
904///
905/// What is left over then pairs across groups, in order, before anything is
906/// removed or added. Shifting labels by renaming, `[A, B]` to `[B, C]`, keeps
907/// `B`, and `A` (before it) and `C` (after it) sit in different groups. Pairing
908/// them renames A's point `C` instead of deleting a point, and whatever
909/// referenced it, only to mint a new one. Only a difference in count removes or
910/// adds.
911type LabelDiff = (Vec<(usize, String, String)>, Vec<usize>, Vec<usize>);
912
913fn diff_labels(base: &[String], after: &[String]) -> LabelDiff {
914    let kept: BTreeSet<&str> = base
915        .iter()
916        .filter(|label| after.contains(label))
917        .map(String::as_str)
918        .collect();
919    let groups = |labels: &[String]| -> BTreeMap<usize, Vec<usize>> {
920        let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
921        let mut before = 0;
922        for (index, label) in labels.iter().enumerate() {
923            if kept.contains(label.as_str()) {
924                before += 1;
925            } else {
926                groups.entry(before).or_default().push(index);
927            }
928        }
929        groups
930    };
931    let mut left = groups(base);
932    let mut arrived = groups(after);
933    let (mut renames, mut removed, mut added) = (Vec::new(), Vec::new(), Vec::new());
934    for (group, lefts) in &mut left {
935        let arrivals = arrived.remove(group).unwrap_or_default();
936        let pairs = lefts.len().min(arrivals.len());
937        for (from, to) in lefts.iter().zip(&arrivals) {
938            renames.push((*from, base[*from].clone(), after[*to].clone()));
939        }
940        removed.extend(lefts.drain(pairs..));
941        added.extend(arrivals.into_iter().skip(pairs));
942    }
943    added.extend(arrived.into_values().flatten());
944    added.sort_unstable();
945    let pairs = removed.len().min(added.len());
946    for (from, to) in removed.iter().zip(&added) {
947        renames.push((*from, base[*from].clone(), after[*to].clone()));
948    }
949    removed.drain(..pairs);
950    added.drain(..pairs);
951    (renames, removed, added)
952}
953
954// ===========================================================================
955// Writing either side
956// ===========================================================================
957
958/// The point at flat index `at`, as `(block index, point index)`.
959fn locate(document: &Value, at: usize) -> Option<(usize, usize)> {
960    let mut seen = 0;
961    for (block_index, block) in port_blocks(document).iter().enumerate() {
962        let count = block.get("points").and_then(Value::as_array).map_or(0, Vec::len);
963        if at < seen + count {
964            return Some((block_index, at - seen));
965        }
966        seen += count;
967    }
968    None
969}
970
971fn point_mut<'a>(document: &'a mut Value, at: usize) -> Option<&'a mut Value> {
972    let (block, index) = locate(document, at)?;
973    document
974        .get_mut(PORTS_BLOCK)?
975        .as_array_mut()?
976        .get_mut(block)?
977        .get_mut("points")?
978        .as_array_mut()?
979        .get_mut(index)
980}
981
982/// Rename the point `base` had at flat index `at`. Addressed by that index
983/// while the list is the one `base` had (so replaying a typing run from its
984/// base finds the same point whatever intermediate name it is wearing), else by
985/// the base name. Whether anything changed.
986fn rename_point(base: &Value, document: &mut Value, at: usize, from: &str, to: &str) -> bool {
987    let current = declared_points(document);
988    let at = if current.len() == declared_points(base).len() {
989        at
990    } else {
991        match current
992            .iter()
993            .enumerate()
994            .filter(|(_, point)| point.point == from)
995            .map(|(index, _)| index)
996            .collect::<Vec<_>>()
997            .as_slice()
998        {
999            [index] => *index,
1000            _ => return false,
1001        }
1002    };
1003    let Some(point) = point_mut(document, at).and_then(Value::as_object_mut) else {
1004        return false;
1005    };
1006    if point.get("name").and_then(Value::as_str) == Some(to) {
1007        return false;
1008    }
1009    point.insert("name".into(), Value::String(to.to_string()));
1010    true
1011}
1012
1013/// Remove the point at flat index `at`; its address when one went.
1014fn remove_point(document: &mut Value, at: usize) -> Option<String> {
1015    let address = declared_points(document).get(at)?.address();
1016    let (block, index) = locate(document, at)?;
1017    let points = document
1018        .get_mut(PORTS_BLOCK)?
1019        .as_array_mut()?
1020        .get_mut(block)?
1021        .get_mut("points")?
1022        .as_array_mut()?;
1023    if index >= points.len() {
1024        return None;
1025    }
1026    points.remove(index);
1027    Some(address)
1028}
1029
1030/// A new point named `name` in the port group `group`, creating that group
1031/// (with [`DEFAULT_PORT_PURPOSE`]) when the part declares no such one. Its
1032/// address.
1033fn push_point(document: &mut Value, group: &str, name: &str) -> String {
1034    let Some(object) = document.as_object_mut() else {
1035        return ports::address(group, name);
1036    };
1037    let blocks = object
1038        .entry(PORTS_BLOCK)
1039        .or_insert_with(|| Value::Array(Vec::new()));
1040    let Some(blocks) = blocks.as_array_mut() else {
1041        return ports::address(group, name);
1042    };
1043    if !blocks
1044        .iter()
1045        .any(|block| block.get("name").and_then(Value::as_str).map(str::trim) == Some(group))
1046    {
1047        blocks.push(serde_json::json!({
1048            "name": group, "purpose": DEFAULT_PORT_PURPOSE, "points": []
1049        }));
1050    }
1051    let block = blocks
1052        .iter_mut()
1053        .find(|block| block.get("name").and_then(Value::as_str).map(str::trim) == Some(group));
1054    let points = block
1055        .and_then(Value::as_object_mut)
1056        .map(|block| block.entry("points").or_insert_with(|| Value::Array(Vec::new())));
1057    if let Some(points) = points.and_then(Value::as_array_mut) {
1058        points.push(serde_json::json!({
1059            "name": name,
1060            "transform": { "position": [0, 0, 0], "rotationEuler": [0, 0, 0] }
1061        }));
1062    }
1063    ports::address(group, name)
1064}
1065
1066fn pins_mut(document: &mut Value) -> Option<&mut Vec<Value>> {
1067    document.get_mut(SYMBOL_BLOCK)?.get_mut("pins")?.as_array_mut()
1068}
1069
1070fn set_pin_labels(document: &mut Value, labels: &[String]) {
1071    if let Some(pins) = pins_mut(document) {
1072        for (pin, label) in pins.iter_mut().zip(labels) {
1073            if let Some(pin) = pin.as_object_mut() {
1074                pin.insert("number".into(), Value::String(label.clone()));
1075            }
1076        }
1077    }
1078}
1079
1080fn remove_pin(document: &mut Value, index: usize) {
1081    if let Some(pins) = pins_mut(document) {
1082        if index < pins.len() {
1083            pins.remove(index);
1084        }
1085    }
1086}
1087
1088/// A new passive pin for a point, in `unit`, one pin pitch below the lowest pin
1089/// of that unit, its connection tip in line with that unit's leftmost tip and
1090/// pointing right (toward the body). eCAD's sheet Y points down. The user moves
1091/// it in the Symbol workbench.
1092fn push_pin(document: &mut Value, unit: u32, label: &str) {
1093    let coordinate = |point: &Value, axis: &str| point.get(axis).and_then(Value::as_i64);
1094    let Some(symbol) = document.get_mut(SYMBOL_BLOCK).and_then(Value::as_object_mut) else {
1095        return;
1096    };
1097    let pins = symbol.entry("pins").or_insert_with(|| Value::Array(Vec::new()));
1098    let Some(pins) = pins.as_array_mut() else {
1099        return;
1100    };
1101    let mine = |pin: &Value| pin.get("unit").and_then(Value::as_u64).unwrap_or(0).max(1) == u64::from(unit);
1102    let tip_x = pins
1103        .iter()
1104        .filter(|pin| mine(pin))
1105        .filter_map(|pin| pin.get("at").and_then(|at| coordinate(at, "x")))
1106        .min()
1107        .unwrap_or(FIRST_PIN_TIP.0);
1108    let tip_y = pins
1109        .iter()
1110        .filter(|pin| mine(pin))
1111        .flat_map(|pin| ["at", "end"].map(|end| pin.get(end).and_then(|point| coordinate(point, "y"))))
1112        .flatten()
1113        .max()
1114        .map_or(FIRST_PIN_TIP.1, |lowest| lowest + PIN_PITCH);
1115    let mut pin = serde_json::json!({
1116        "hidden": false,
1117        "number": label,
1118        "name": label,
1119        "electrical_type": "passive",
1120        "at": { "x": tip_x, "y": tip_y },
1121        "end": { "x": tip_x + PIN_PITCH, "y": tip_y },
1122    });
1123    // Unit 1 is what an absent `unit` means, so a single-unit symbol keeps the
1124    // shape it had before units existed (`brep_ecad_core::Pin::unit`).
1125    if unit > 1 {
1126        if let Some(pin) = pin.as_object_mut() {
1127            pin.insert("unit".into(), serde_json::json!(unit));
1128        }
1129    }
1130    pins.push(pin);
1131}
1132
1133/// The units of `base` whose pins carry the label `label`.
1134fn units_labelled(base: &[DeclaredPin], label: &str) -> Vec<u32> {
1135    let mut units: Vec<u32> = base
1136        .iter()
1137        .filter(|pin| pin.label == label)
1138        .map(|pin| pin.unit)
1139        .collect();
1140    units.sort_unstable();
1141    units.dedup();
1142    units
1143}
1144
1145/// Renumber the pads `base` numbered `from` to `to`, by their index in `base`
1146/// when the pad list is the one `base` had (so replaying a typing run from its
1147/// base finds them again), else by the label. Returns how many changed.
1148///
1149/// Pads are matched across the WHOLE footprint, not within a unit, because that
1150/// is how eCAD matches one (`Pad::number` against `Pin::number`, symbol-wide).
1151/// When TWO units of `base` carry the label, that match cannot say whose pad it
1152/// is, and renumbering would take the other unit's pad along: renaming unit 2's
1153/// pin `1` would renumber unit 1's pad `1`. So nothing is renumbered and the
1154/// caller reports [`PinPointChange::PadsAmbiguous`] instead. The user renumbers
1155/// the pad, which is the only place that knows.
1156///
1157/// A footprint cannot hold two pads called `1` for two different pins either,
1158/// so a two-unit part with repeated pin labels has no correct pad mapping to
1159/// make — the same symbol-wide-identity seam, one layer down. This REFUSES to
1160/// guess at it rather than settling it.
1161fn renumber_pads(base: &Value, document: &mut Value, from: &str, to: &str) -> usize {
1162    let base_pads = pad_numbers(base);
1163    let same_list = pad_numbers(document).len() == base_pads.len();
1164    let Some(pads) = document
1165        .get_mut(PADS_BLOCK)
1166        .and_then(|block| block.get_mut("pads"))
1167        .and_then(Value::as_array_mut)
1168    else {
1169        return 0;
1170    };
1171    let mut count = 0;
1172    for (index, pad) in pads.iter_mut().enumerate() {
1173        let carried = if same_list { base_pads[index] == from } else { label_of(pad) == from };
1174        if carried && label_of(pad) != to {
1175            if let Some(pad) = pad.as_object_mut() {
1176                pad.insert("number".into(), Value::String(to.to_string()));
1177                count += 1;
1178            }
1179        }
1180    }
1181    count
1182}
1183
1184// ===========================================================================
1185// Resolution: a placed part's pin label -> its connection point ADDRESS
1186// ===========================================================================
1187
1188/// The connection point a pin names among `(address, port group, point name,
1189/// kind)` candidates. Exactly one TERMINATION must carry the name. `group`
1190/// narrows the search to one port group — that is how a caller that knows the
1191/// pin's symbol unit tells two identical headers apart; `None` searches them
1192/// all and refuses a name several carry.
1193fn resolve<'a>(
1194    candidates: impl Iterator<Item = (&'a str, &'a str, &'a str, PortKind)>,
1195    owner: &str,
1196    group: Option<&str>,
1197    pin: &str,
1198) -> Result<String, String> {
1199    let ends: Vec<&str> = candidates
1200        .filter(|(_, port, name, kind)| {
1201            *name == pin
1202                && *kind == PortKind::Termination
1203                && group.is_none_or(|group| *port == group)
1204        })
1205        .map(|(address, _, _, _)| address)
1206        .collect();
1207    let where_ = group.map_or(String::new(), |group| format!(" in port '{group}'"));
1208    match ends.as_slice() {
1209        [address] => Ok((*address).to_string()),
1210        [] => Err(format!("{owner} has no connection point named '{pin}'{where_}")),
1211        several => Err(format!(
1212            "{owner} has {} connection points named '{pin}'{where_} ({})",
1213            several.len(),
1214            several.join(", ")
1215        )),
1216    }
1217}
1218
1219/// The OCCURRENCE CHAIN an endpoint address is placed under, and the
1220/// part-local address inside it: `ACOMP7:ACOMP1:J1.VCC` reads as
1221/// `("ACOMP7:ACOMP1", "J1.VCC")`, and a document's OWN point `J1.VCC` as
1222/// `("", "J1.VCC")`.
1223///
1224/// Read from the right, not with [`crate::split_component_namespace`]: a port
1225/// and a point name may not contain `:` ([`ports::RESERVED`]), so everything
1226/// before the LAST `:` is the chain whatever the occurrence ids are spelled
1227/// like. A WAYPOINT id, which is an address belonging to no port, splits the
1228/// same way (`ACOMP1:WP3` is unit `WP3` of `ACOMP1`).
1229pub fn endpoint_occurrence(address: &str) -> (&str, &str) {
1230    match address.rsplit_once(':') {
1231        Some((chain, local)) => (chain, local),
1232        None => ("", address),
1233    }
1234}
1235
1236/// The connection-point ADDRESS that pin `pin` of the placed component
1237/// `occurrence` names, read from a routing report's endpoints. `occurrence` is
1238/// the placed component's OCCURRENCE CHAIN — `ACOMP3` for a component of this
1239/// assembly, `ACOMP7:ACOMP1` for one nested a level down; plan decision 5
1240/// resolves a Diagram reference such as `J1` to it. The endpoint's `point` is
1241/// the PART-LOCAL name, untouched by namespacing, so pin `VCC` of `ACOMP3` is
1242/// the endpoint whose point is `VCC`, and the result is its address, e.g.
1243/// `ACOMP3:J1.VCC`.
1244///
1245/// Candidates are matched on the endpoint's own ADDRESS
1246/// ([`endpoint_occurrence`]) rather than on [`WireHarnessEndpoint::component`],
1247/// which is the OUTERMOST occurrence alone (`SceneMap::owning_component` peels
1248/// one prefix) and so cannot tell `ACOMP7:ACOMP1` from `ACOMP7:ACOMP2`.
1249///
1250/// `group` is the port group the pin's symbol unit maps to
1251/// ([`port_group_for_unit`]); `None` means "any", and then a name several
1252/// groups carry is refused by name rather than guessed at.
1253pub fn resolve_component_pin(
1254    endpoints: &[WireHarnessEndpoint],
1255    occurrence: &str,
1256    group: Option<&str>,
1257    pin: &str,
1258) -> Result<String, String> {
1259    resolve(
1260        endpoints
1261            .iter()
1262            .filter(|endpoint| endpoint_occurrence(&endpoint.id).0 == occurrence)
1263            .map(|endpoint| {
1264                (
1265                    endpoint.id.as_str(),
1266                    endpoint.port.as_str(),
1267                    endpoint.point.as_str(),
1268                    endpoint.kind,
1269                )
1270            }),
1271        occurrence,
1272        group,
1273        pin,
1274    )
1275}
1276
1277/// The part-local address that pin `pin` names among a part's own connection
1278/// points, as its parts-library entry carries them
1279/// ([`crate::PartsLibraryEntry::ports`]). `part` only names the part in a
1280/// refusal; `group` narrows it as [`resolve_component_pin`]'s does.
1281pub fn resolve_part_pin(
1282    ports: &BTreeMap<String, PortRecord>,
1283    part: &str,
1284    group: Option<&str>,
1285    pin: &str,
1286) -> Result<String, String> {
1287    resolve(
1288        ports.iter().map(|(address, record)| {
1289            (
1290                address.as_str(),
1291                record.port_name.as_str(),
1292                record.point_name.as_str(),
1293                record.kind,
1294            )
1295        }),
1296        &format!("part '{part}'"),
1297        group,
1298        pin,
1299    )
1300}
1301
1302/// A placed component's connection points, namespaced — the shape
1303/// [`resolve_component_pin`] reads when a caller has the part's declarations
1304/// rather than a routing report (an eCAD refresh before the new parts have run).
1305///
1306/// `occurrence` is the chain the points are placed under, so a device nested a
1307/// level down passes `ACOMP7:ACOMP1` and its points address as
1308/// `ACOMP7:ACOMP1:J1.VCC`. [`WireHarnessEndpoint::component`] is the OUTERMOST
1309/// occurrence, which is what the scene lane's `owning_component` reports for
1310/// the same address.
1311pub fn declared_endpoints(occurrence: &str, part_document: &Value) -> Vec<WireHarnessEndpoint> {
1312    let outermost = occurrence.split(':').next().unwrap_or(occurrence);
1313    declared_points(part_document)
1314        .into_iter()
1315        .map(|declared| WireHarnessEndpoint {
1316            id: component::namespaced(occurrence, &declared.address()),
1317            port: declared.port,
1318            point: declared.point,
1319            purpose: declared.purpose,
1320            kind: PortKind::Termination,
1321            component: Some(outermost.to_string()),
1322        })
1323        .collect()
1324}
1325