Skip to main content

brep_app/panels/
qualify.rs

1//! The **Qualify** panel — a part's declared CONNECTION POINTS, on whichever
2//! surface the user is looking at.
3//!
4//! A part document carries `ports` beside `features`, `symbol` and `pads`: a
5//! list of named GROUPS, each with a `purpose` (`pcb`, `wiring`, `piping`, …)
6//! and a list of points whose NAME is their identity. The kernel resolves that
7//! block at the tail of every history run (`brep_kernel`'s `ports`), binds each
8//! point to the symbol pin of the same name within its unit's group
9//! (`part_pins`), and reports what does not hold. Until now nothing drew any of
10//! it: the block was written by the KiCad import, by editing a pin, or by hand.
11//!
12//! This panel is that UI. It is ONE panel on THREE surfaces — the schematic
13//! symbol, the pads, and the 3D model — because a connection point is one thing
14//! seen from three places, and the job it exists for is crossing between them:
15//! pick a point here and jump to its pin, its pad or its geometry with the
16//! point still selected on the other side.
17//!
18//! # What it draws
19//!
20//! * the port GROUPS, each with its name, its purpose and the symbol unit whose
21//!   pins bind into it, and the points in it;
22//! * the SELECTED point's fields, expanded in place: its placement (position
23//!   and rotation, each component a number or an expression string, exactly as
24//!   the block carries it), the geometry that seats it (`pointRef` /
25//!   `directionRef`), its straight run, and — for an ASSEMBLY — the descendant
26//!   point it maps to;
27//! * the three JUMP buttons, which switch surface AND carry the selection;
28//! * the two CONSISTENCY REPORTS the model already produces: `History::pin_point_report`
29//!   (every pin paired with its point, and everything that does not pair) and
30//!   the ports tail's own report (`EngineState::ports_report` — whether this
31//!   document is an encapsulation boundary, every point it resolved, and every
32//!   name it refused).
33//!
34//! # When it is on screen
35//!
36//! Exactly while the part declares a connection point. That is not a workbench
37//! claim — it is a DOCUMENT condition ([`crate::workbench::PANEL_CONDITIONS`]),
38//! so the pane appears on every surface at once and a part with no connection
39//! points is not offered an empty panel. The first group therefore comes from
40//! somewhere else: the KiCad import, the pin binding, or the Wire harness
41//! workbench's **Declare connection point** button, which exists for exactly
42//! this reason.
43//!
44//! # Edits
45//!
46//! Every edit is a write of the WHOLE `ports` block through
47//! `History::set_ports_block`, which carries the pin/point follow inside it: a
48//! renamed point renames its pin (and the pad matching that pin) in the same
49//! undo step, a new point gets a pin, and a removed point loses one. The block
50//! is edited as raw JSON rather than round-tripped through
51//! [`brep_kernel::PortDeclaration`], so a field this panel does not know about
52//! survives an edit to the field beside it.
53//!
54//! Each field carries its own COALESCE KEY, so typing a name is one undo entry
55//! and not one per keystroke — the same key the eCAD editors hand the history.
56
57use std::collections::HashMap;
58
59use eframe::egui;
60use serde_json::Value;
61
62use crate::automation::hit_keys::HitKeyDoc;
63use crate::workbench::ecad::{Editors, Target};
64use brep_render::brep_kernel::{self, PinPointReport, PortDeclaration};
65use brep_render::engine_state::{EngineState, PortRefField};
66
67/// The row glyphs, every one catalogued artwork (`assets/glyphs`) drawn by
68/// [`crate::icon_text`] — this app ships no icon font, so an uncatalogued
69/// character paints as tofu.
70const GLYPH_SELECT: &str = "\u{25CE}"; // ◎ select this connection point
71const GLYPH_REMOVE: &str = "\u{2715}"; // ✕ remove
72const GLYPH_ADD: &str = "\u{FF0B}"; // + add
73const GLYPH_PICK: &str = "\u{2316}"; // ⌖ pick this reference in the 3D view
74const GLYPH_PRESENT: &str = "\u{25CF}"; // ● this surface carries the point
75const GLYPH_ABSENT: &str = "\u{25CB}"; // ○ it does not
76
77/// The purposes offered in the dropdown. The kernel never enumerates
78/// `purpose` — it carries the string — so a value the document already holds is
79/// offered beside these rather than silently rewritten.
80const PURPOSES: [&str; 3] = ["pcb", "wiring", "piping"];
81
82/// A problem's amber and a refusal's red, as the harness panel uses them.
83const WARN_COLOR: egui::Color32 = egui::Color32::from_rgb(0xd2, 0x99, 0x22);
84const ERROR_COLOR: egui::Color32 = egui::Color32::from_rgb(0xf8, 0x51, 0x49);
85const OK_COLOR: egui::Color32 = egui::Color32::from_rgb(0x3f, 0xb9, 0x50);
86/// The destructive red the other panels use for a remove affordance.
87const REMOVE_RED: egui::Color32 = egui::Color32::from_rgb(0xd8, 0x54, 0x4f);
88
89/// The 3D workbench a jump from an eCAD editor lands in. A part's connection
90/// points are the harness's wire ends, so this is where looking at one in 3D
91/// means something; a jump from a workbench that ALREADY draws the model does
92/// not switch at all (see [`QualifyPanel::jump`]).
93const MODEL_WORKBENCH: &str = "wireHarness";
94
95/// The key the 3D surface's adopted selection is remembered under, beside the
96/// two eCAD editors'.
97const MODEL_SURFACE: &str = "model";
98
99/// One edit the panel's draw asked for, applied after it.
100enum Edit {
101    GroupName(usize, String),
102    GroupPurpose(usize, String),
103    GroupUnit(usize, Option<u32>),
104    AddGroup,
105    RemoveGroup(usize),
106    PointName(usize, usize, String),
107    /// One of the point's optional STRING fields, by JSON key; an empty string
108    /// removes the key.
109    PointText(usize, usize, &'static str, String),
110    /// One component of `transform.position` / `transform.rotationEuler`: a
111    /// number when it parses as one, else an expression string.
112    PointTransform(usize, usize, &'static str, usize, String),
113    PointReverse(usize, usize, bool),
114    AddPoint(usize),
115    RemovePoint(usize, usize),
116}
117
118/// Where a jump goes.
119#[derive(Clone, Copy, PartialEq, Eq)]
120enum Jump {
121    Symbol,
122    Pads,
123    Model,
124}
125
126/// The panel's transient UI state. Everything it draws comes from the engine.
127pub struct QualifyPanel {
128    hits: HashMap<String, egui::Rect>,
129    /// The selected connection point, by part-local ADDRESS (`J1.VCC`). Held
130    /// by address rather than by index so an edit that reorders the block does
131    /// not move the selection to another point.
132    selected: Option<String>,
133    /// The editor selection last adopted, so an eCAD pick is adopted ONCE: a
134    /// user who picks a point here and then a different pin there gets the pin,
135    /// and one who picks here and leaves the editor alone keeps their pick.
136    /// The 3D surface goes through the same field under [`MODEL_SURFACE`].
137    adopted: Option<(&'static str, String)>,
138    /// The selection this panel last acted on for the GIZMO. The gizmo is
139    /// shared — the History panel arms it on a feature — so this panel acts on
140    /// a CHANGE of selection and not every frame: otherwise it would take the
141    /// handles back off a feature dialog on the frame after it armed them, and
142    /// it would re-ask (and re-notice) a refusal once a frame.
143    gizmo_armed_for: Option<String>,
144}
145
146impl Default for QualifyPanel {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152impl QualifyPanel {
153    pub fn new() -> Self {
154        Self { hits: HashMap::new(), selected: None, adopted: None, gizmo_armed_for: None }
155    }
156
157    pub fn hits_json(&self) -> String {
158        crate::automation::hit_rects::hits_json(&self.hits)
159    }
160
161    /// Drop the published rects: the dock calls this for a pane it did not
162    /// draw this frame (`DockState::ui`), whose widgets are not on screen.
163    pub fn clear_hits(&mut self) {
164        self.hits.clear();
165    }
166
167    /// The panel's verifier global: the selected address and the surface it is
168    /// drawn on, beside the block and report the engine publishes.
169    pub fn state_json(&self) -> String {
170        serde_json::json!({
171            "selected": self.selected,
172            "gizmoArmedFor": self.gizmo_armed_for,
173        })
174        .to_string()
175    }
176
177    /// Draw the panel. `target` is the eCAD editor on screen, or `None` where
178    /// the central tile draws the 3D model.
179    pub fn show(
180        &mut self,
181        ui: &mut egui::Ui,
182        engine: &mut EngineState,
183        ecad: &mut Editors,
184        target: Option<Target>,
185    ) {
186        self.hits.clear();
187        self.hits.insert("qualify:panel:clip".into(), ui.clip_rect());
188
189        let groups: Vec<PortDeclaration> = engine
190            .history
191            .ports_block()
192            .cloned()
193            .and_then(|block| serde_json::from_value(block).ok())
194            .unwrap_or_default();
195
196        // An eCAD pick is a pick of the same thing: adopt it, once.
197        self.adopt_editor_selection(engine, ecad, target);
198        // A selection whose point is gone (removed, or renamed out from under
199        // it) is dropped rather than kept as a stale address.
200        if let Some(address) = &self.selected {
201            if !groups.iter().any(|group| {
202                group.points.iter().any(|point| &brep_kernel::port_address(&group.name, &point.name) == address)
203            }) {
204                self.selected = None;
205            }
206        }
207
208        let mut edit: Option<(Edit, Option<String>)> = None;
209        let mut jump: Option<Jump> = None;
210        let mut pick: Option<Option<String>> = None;
211        // `(address, field, enter the picker)` — clearing a reference is the
212        // same act said with `false`, so both go through one commit.
213        let mut refs: Option<(String, PortRefField, bool)> = None;
214
215        // --- header -----------------------------------------------------------
216        let points: usize = groups.iter().map(|group| group.points.len()).sum();
217        ui.horizontal(|ui| {
218            let add = ui
219                .add(crate::icon_text::icon_button(ui, &format!("{GLYPH_ADD} Add group")).small())
220                .on_hover_text("Declare another port group on this part — a second connector, or a port with a different purpose");
221            self.hits.insert("qualify:add_group".into(), add.rect);
222            if add.clicked() {
223                edit = Some((Edit::AddGroup, None));
224            }
225            ui.label(
226                egui::RichText::new(format!(
227                    "{} group{} | {points} point{}",
228                    groups.len(),
229                    if groups.len() == 1 { "" } else { "s" },
230                    if points == 1 { "" } else { "s" },
231                ))
232                .weak(),
233            );
234        });
235        if self.selected.is_none() {
236            // A pane whose whole lower half is blank until something is
237            // selected should say what to select and what it will get.
238            let hint = ui.label(
239                egui::RichText::new(
240                    "Select a point to place it, seat it on geometry and cross to its pin and pad",
241                )
242                .weak()
243                .small(),
244            );
245            self.hits.insert("qualify:hint".into(), hint.rect);
246        }
247        ui.add_space(2.0);
248
249        // --- the groups -------------------------------------------------------
250        for (gi, group) in groups.iter().enumerate() {
251            egui::Frame::group(ui.style())
252                .inner_margin(egui::Margin::same(4))
253                .show(ui, |ui| {
254                    // The frame must not lay out wider than what is on screen,
255                    // or its right-aligned ✕ is published past the pane's edge
256                    // and a click aims into the viewport (the same clamp the
257                    // spline-anchor rows carry, for the same reason).
258                    let room = (ui.clip_rect().right() - ui.max_rect().left()).max(0.0);
259                    ui.set_width(ui.available_width().min(room));
260                    if let Some(asked) = self.group_header(ui, gi, group) {
261                        edit = Some(asked);
262                    }
263                    for (pi, point) in group.points.iter().enumerate() {
264                        let address = brep_kernel::port_address(&group.name, &point.name);
265                        let selected = self.selected.as_deref() == Some(address.as_str());
266                        if let Some(asked) = self
267                            .point_row(ui, engine, gi, pi, point, &address, selected, &mut pick)
268                        {
269                            edit = Some(asked);
270                        }
271                        if selected {
272                            if let Some(asked) = self
273                                .point_fields(ui, engine, gi, pi, point, &address, target, &mut refs)
274                            {
275                                edit = Some(asked);
276                            }
277                            if let Some(asked) = self.jump_row(ui, &address, target) {
278                                jump = Some(asked);
279                            }
280                        }
281                    }
282                    let add = ui
283                        .add(crate::icon_text::icon_button(ui, &format!("{GLYPH_ADD} Add point")).small())
284                        .on_hover_text("Add a connection point to this group. It arrives at the part origin, facing +X; place it below.");
285                    self.hits.insert(format!("qualify:group:{gi}:add_point"), add.rect);
286                    if add.clicked() {
287                        edit = Some((Edit::AddPoint(gi), None));
288                    }
289                });
290        }
291
292        // --- the reports ------------------------------------------------------
293        ui.add_space(4.0);
294        self.reports(ui, engine);
295
296        // --- act on what the draw asked for (at most one of each) --------------
297        if let Some(pick) = pick {
298            self.pick(engine, ecad, target, pick);
299        }
300        if let Some((address, field, enter)) = refs {
301            match enter {
302                true => engine.begin_ref_select_for_port_point(&address, field),
303                false => engine.clear_port_point_ref(&address, field),
304            }
305        }
306        if let Some((edit, coalesce)) = edit {
307            self.apply(engine, edit, coalesce.as_deref());
308        }
309        if let Some(jump) = jump {
310            self.jump(engine, ecad, jump);
311        }
312        // Last, so it follows whatever the frame just did to the selection.
313        self.follow_gizmo(engine, target);
314    }
315
316    /// Put the move/rotate handles on the selected point, or take them off.
317    ///
318    /// Selecting a row IS arming the gizmo, which is the convention the spline
319    /// anchors panel already set: one affordance, not a select and then a
320    /// separate arm. Only on the 3D surface, because an eCAD editor has no
321    /// viewport to drag handles in, and never while the reference picker is
322    /// up, because that picker owns the clicks.
323    fn follow_gizmo(&mut self, engine: &mut EngineState, target: Option<Target>) {
324        let wanted = match target.is_none() && !engine.ref_select_active() {
325            true => self.selected.clone(),
326            false => None,
327        };
328        // The gizmo is ONE shared widget, so this is deliberately not "arm what
329        // is selected, every frame". It is SETTLED — and left alone — when the
330        // selection has not changed AND one of three things is true:
331        //
332        // * nothing is wanted (and the disarm below has already run);
333        // * SOMETHING is armed. Whose it is does not matter: if a feature
334        //   dialog has taken the handles, re-arming would take them straight
335        //   back on the next frame, and this pane is drawn beside every
336        //   feature dialog in the product;
337        // * the point REFUSES the handles, which would otherwise re-ask —
338        //   and re-push its notice — sixty times a second.
339        //
340        // What that leaves is the case the plain change check got wrong: the
341        // reference picker takes the handles off at its entry, and the pane is
342        // not drawn while it is up, so on the frame it closes the selection has
343        // NOT changed and nothing is armed. That is exactly when they go back.
344        let refusal = wanted
345            .as_deref()
346            .and_then(|address| engine.port_point_gizmo_refusal(address));
347        let settled = wanted == self.gizmo_armed_for
348            && (wanted.is_none() || engine.transform_armed() || refusal.is_some());
349        if settled {
350            return;
351        }
352        self.gizmo_armed_for = wanted.clone();
353        match wanted {
354            // A refusal is asked ONCE — the arm pushes the notice — and the
355            // reason the gizmo line shows is read from the engine, not from
356            // anything kept here.
357            Some(address) => {
358                engine.arm_transform_for_port_point(&address);
359            }
360            // Take away only what THIS panel put there. A feature's gizmo,
361            // armed from the History panel, is not ours — and neither is one
362            // on another connection point that something else armed.
363            None => {
364                if engine.transform_armed_port_point().is_some() {
365                    engine.disarm_transform();
366                }
367            }
368        }
369    }
370
371    /// Take a pick made in the panel: select `address` (or nothing) and LIGHT
372    /// IT UP on the surface the user is on.
373    ///
374    /// Lighting it up is not decoration — it is the other half of the same
375    /// sentence the jump buttons speak, and without it a pick here means
376    /// nothing until the user leaves. It also has to drive the editor through
377    /// the SAME deferred path a jump uses: the editor's own selection is what
378    /// [`Self::adopt_editor_selection`] reads back, so a panel pick that left
379    /// the editor sitting on another pin would be overwritten by that pin on
380    /// the very next frame.
381    fn pick(
382        &mut self,
383        engine: &mut EngineState,
384        ecad: &mut Editors,
385        target: Option<Target>,
386        address: Option<String>,
387    ) {
388        self.selected = address.clone();
389        match target {
390            Some(target) => {
391                // Agreeing with the editor NOW as well as after its next draw:
392                // `apply_focus` is what survives a pull, and this is what makes
393                // the pin light up on the frame of the click.
394                if let Some((_, name)) =
395                    address.as_deref().and_then(brep_kernel::split_address)
396                {
397                    ecad.focus(target, name.to_string());
398                    ecad.apply_focus(target);
399                }
400                // What is remembered is what the editor ACTUALLY holds now, not
401                // what was asked of it. They differ for a point with no pin —
402                // the focus finds nothing and the editor stays where it was —
403                // and remembering the ask would make the next frame read that
404                // stale pin as a NEW editor pick and overwrite the user's.
405                self.adopted = editor_selection(ecad, target)
406                    .map(|held| (surface_of(target), held));
407            }
408            // The 3D surface: the point's own scene entities are published
409            // under its address, so this is the selection a click in the
410            // viewport makes — the Scene tree's row click, by name.
411            None => {
412                if let Some(address) = address.as_deref() {
413                    if resolved(engine, address) {
414                        engine.select_by_name("solid", address);
415                    }
416                }
417                // The same rule, on this surface: what is remembered is what
418                // the VIEWPORT holds afterwards. Deselecting here leaves the
419                // geometry lit, and remembering the ask instead would make the
420                // next frame read that as a fresh viewport pick and undo the
421                // deselection.
422                self.adopted = engine
423                    .selected_port_point()
424                    .map(|held| (MODEL_SURFACE, held));
425            }
426        }
427    }
428
429    /// One group's header row: name, purpose, symbol unit, remove.
430    fn group_header(
431        &mut self,
432        ui: &mut egui::Ui,
433        gi: usize,
434        group: &PortDeclaration,
435    ) -> Option<(Edit, Option<String>)> {
436        let mut asked = None;
437        ui.horizontal(|ui| {
438            let mut name = group.name.clone();
439            let field = ui
440                .add(egui::TextEdit::singleline(&mut name).desired_width(70.0))
441                .on_hover_text("The group's name — the first half of every address in it (`J1` in `J1.VCC`). `:`, `.` and `/` are refused.");
442            self.hits.insert(format!("qualify:group:{gi}:name"), field.rect);
443            if field.changed() {
444                asked = Some((Edit::GroupName(gi, name), Some(format!("group:{gi}:name"))));
445            }
446
447            let mut purpose = group.purpose.clone();
448            let combo = egui::ComboBox::from_id_salt(("qualify-purpose", gi))
449                .selected_text(if purpose.is_empty() { "purpose" } else { purpose.as_str() })
450                .width(76.0)
451                .show_ui(ui, |ui| {
452                    // The document's own value is offered beside the three when
453                    // it is none of them: the kernel carries `purpose` as a
454                    // string and never enumerates it, so a purpose a later
455                    // slice invents must not vanish because this list has not
456                    // heard of it.
457                    let mut offered: Vec<String> =
458                        PURPOSES.iter().map(|p| (*p).to_string()).collect();
459                    if !group.purpose.is_empty() && !PURPOSES.contains(&group.purpose.as_str()) {
460                        offered.push(group.purpose.clone());
461                    }
462                    for candidate in offered {
463                        let item = ui.selectable_label(purpose == candidate, &candidate);
464                        self.hits
465                            .insert(format!("qualify:group:{gi}:purpose:{candidate}"), item.rect);
466                        if item.clicked() {
467                            purpose = candidate;
468                        }
469                    }
470                });
471            self.hits.insert(format!("qualify:group:{gi}:purpose"), combo.response.rect);
472            if purpose != group.purpose {
473                asked = Some((Edit::GroupPurpose(gi, purpose), None));
474            }
475
476            ui.label(egui::RichText::new("unit").weak());
477            let mut unit = group.symbol_unit.map(|unit| unit.to_string()).unwrap_or_default();
478            let field = ui
479                .add(egui::TextEdit::singleline(&mut unit).desired_width(24.0))
480                .on_hover_text("The SYMBOL UNIT whose pins bind into this group, 1-based. Empty binds no pins — which is right for a piping port — unless this is the part's only group, when it takes unit 1.");
481            self.hits.insert(format!("qualify:group:{gi}:unit"), field.rect);
482            if field.changed() {
483                let parsed = unit.trim().parse::<u32>().ok();
484                // A field being cleared reads as "no unit"; a field holding
485                // something that is not a number keeps what was there, so a
486                // half-typed value never silently unbinds a group.
487                if parsed.is_some() || unit.trim().is_empty() {
488                    asked = Some((Edit::GroupUnit(gi, parsed), Some(format!("group:{gi}:unit"))));
489                }
490            }
491
492            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
493                let remove = ui
494                    .add(crate::icon_text::icon_button_colored(ui, GLYPH_REMOVE, Some(REMOVE_RED)).small())
495                    .on_hover_text("Remove this group and every point in it — and the pins bound to them");
496                self.hits.insert(format!("qualify:group:{gi}:remove"), remove.rect);
497                if remove.clicked() {
498                    asked = Some((Edit::RemoveGroup(gi), None));
499                }
500            });
501        });
502        asked
503    }
504
505    /// One point's row: select, name, remove. A click on ◎ is reported as a
506    /// PICK rather than applied here, because a pick is not only a selection:
507    /// it lights the point on whatever surface is on screen, which needs the
508    /// editors and the engine.
509    #[allow(clippy::too_many_arguments)]
510    fn point_row(
511        &mut self,
512        ui: &mut egui::Ui,
513        engine: &EngineState,
514        gi: usize,
515        pi: usize,
516        point: &brep_kernel::PortPoint,
517        address: &str,
518        selected: bool,
519        pick: &mut Option<Option<String>>,
520    ) -> Option<(Edit, Option<String>)> {
521        let mut asked = None;
522        ui.horizontal(|ui| {
523            let label = crate::icon_text::selectable_icon_label(ui, selected, GLYPH_SELECT)
524                .on_hover_text(format!("Select {address} — it lights up on the surface you are on, its fields open below, and the jump buttons carry it to the others"));
525            self.hits.insert(format!("qualify:point:{gi}:{pi}:select"), label.rect);
526            if label.clicked() {
527                *pick = Some((!selected).then(|| address.to_string()));
528            }
529            let mut name = point.name.clone();
530            let field = ui
531                .add(egui::TextEdit::singleline(&mut name).desired_width(70.0))
532                .on_hover_text("The point's name, which is its identity: the symbol pin of this name in this group's unit binds to it, and the pad of that number follows.");
533            self.hits.insert(format!("qualify:point:{gi}:{pi}:name"), field.rect);
534            if field.changed() {
535                asked = Some((Edit::PointName(gi, pi, name), Some(format!("point:{gi}:{pi}:name"))));
536            }
537            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
538                let remove = ui
539                    .add(crate::icon_text::icon_button_colored(ui, GLYPH_REMOVE, Some(REMOVE_RED)).small())
540                    .on_hover_text("Remove this connection point — and the pin bound to it");
541                self.hits.insert(format!("qualify:point:{gi}:{pi}:remove"), remove.rect);
542                if remove.clicked() {
543                    asked = Some((Edit::RemovePoint(gi, pi), None));
544                }
545                ui.add_space(4.0);
546                self.presence(ui, engine, gi, pi, address, &point.name);
547            });
548        });
549        asked
550    }
551
552    /// The selected point's fields, indented under its row, in four sections:
553    /// where it is SEATED, the PLACEMENT read in that seat, what a WIRE leaving
554    /// it does, and — for an assembly — the descendant it maps to.
555    ///
556    /// The sections are the point's own sentence in order: seat it, nudge it,
557    /// say how the wire leaves, and (an assembly only) say whose point it
558    /// really is. Before this they were one flat list of eight fields in which
559    /// `pointRef` and `extension` looked like the same kind of thing.
560    fn point_fields(
561        &mut self,
562        ui: &mut egui::Ui,
563        engine: &EngineState,
564        gi: usize,
565        pi: usize,
566        point: &brep_kernel::PortPoint,
567        address: &str,
568        target: Option<Target>,
569        refs: &mut Option<(String, PortRefField, bool)>,
570    ) -> Option<(Edit, Option<String>)> {
571        let mut asked = None;
572        // A MAPPED point has no placement of its own: the kernel's
573        // `resolve_point` returns on `mapsTo` before it reads the transform or
574        // either reference. Offering a seat and an offset it will ignore is
575        // three sections of lie, so a mapped point shows only the one field
576        // that decides where it is.
577        let mapped = point
578            .maps_to
579            .as_deref()
580            .map(str::trim)
581            .is_some_and(|target| !target.is_empty());
582        ui.indent(("qualify-point", gi, pi), |ui| {
583            if mapped {
584                if let Some(mapped_asked) = self.maps_to_field(ui, gi, pi, point) {
585                    asked = Some(mapped_asked);
586                }
587                self.seat_line(ui, engine, address, point);
588                return;
589            }
590            // --- seat ---------------------------------------------------------
591            self.section(ui, "Seated on");
592            for (field, caption, hover) in [
593                (PortRefField::Point, "Point", "The geometry this point SITS on: a face, an edge, a circle's centre, a vertex, a datum frame. Pick it in the 3D view with \u{2316}. Empty places the point by the offset below alone."),
594                (PortRefField::Direction, "Direction", "The geometry it FACES along: a face's normal, a bore's axis, an edge, a datum frame. Empty takes the direction the point reference implies, or +X. Seat the POINT first where you can: a direction on its own still re-reads the offset in its own axes, so a placement you typed is zeroed rather than turned."),
595            ] {
596                ui.horizontal(|ui| {
597                    ui.label(egui::RichText::new(caption).weak());
598                    let key = field.key();
599                    let mut text = optional_text(point, key);
600                    let field_ui = ui
601                        .add(egui::TextEdit::singleline(&mut text).desired_width(120.0))
602                        .on_hover_text(hover);
603                    self.hits.insert(format!("qualify:point:{gi}:{pi}:{key}"), field_ui.rect);
604                    if field_ui.changed() {
605                        asked = Some((
606                            Edit::PointText(gi, pi, key, text),
607                            Some(format!("point:{gi}:{pi}:{key}")),
608                        ));
609                    }
610                    // Picking is offered only where there is a model to pick
611                    // IN. From an eCAD editor the button would open a picker
612                    // over a schematic, which has no faces.
613                    let pickable = target.is_none();
614                    let pick = ui
615                        .add_enabled(
616                            pickable,
617                            crate::icon_text::icon_button(ui, GLYPH_PICK).small(),
618                        )
619                        .on_hover_text("Pick it in the 3D view")
620                        .on_disabled_hover_text("Go to the 3D model to pick geometry");
621                    self.hits.insert(format!("qualify:point:{gi}:{pi}:{key}:pick"), pick.rect);
622                    if pick.clicked() {
623                        *refs = Some((address.to_string(), field, true));
624                    }
625                    let has = !optional_text(point, key).trim().is_empty();
626                    let clear = ui
627                        .add_enabled(
628                            has,
629                            crate::icon_text::icon_button_colored(ui, GLYPH_REMOVE, Some(REMOVE_RED)).small(),
630                        )
631                        .on_hover_text("Unseat it — the offset below stays as it is, read from the part origin");
632                    self.hits.insert(format!("qualify:point:{gi}:{pi}:{key}:clear"), clear.rect);
633                    if clear.clicked() {
634                        *refs = Some((address.to_string(), field, false));
635                    }
636                });
637            }
638            self.seat_line(ui, engine, address, point);
639
640            // --- placement ----------------------------------------------------
641            self.section(ui, "Placement");
642            let seated = point.point_ref.is_some() || point.direction_ref.is_some();
643            for (key, caption, hover) in [
644                ("position", "Offset", if seated {
645                    "Where the point sits RELATIVE TO ITS SEAT, in millimetres: x runs along the direction the reference gives, y and z across it. A component that is not a number is kept as an EXPRESSION."
646                } else {
647                    "Where the point sits, in millimetres from the part origin. Seat it on geometry above and these become an offset from that. A component that is not a number is kept as an EXPRESSION."
648                }),
649                ("rotationEuler", "Angle", if seated {
650                    "The rotation applied to the seat's own axes to get the direction a wire leaves along, in degrees. All zero means \"straight out along the reference\"."
651                } else {
652                    "The rotation applied to +X to get the direction a wire leaves along, in degrees."
653                }),
654            ] {
655                ui.horizontal(|ui| {
656                    ui.label(egui::RichText::new(caption).weak());
657                    for axis in 0..3 {
658                        let mut text = transform_component(point, key, axis);
659                        let field = ui
660                            .add(egui::TextEdit::singleline(&mut text).desired_width(46.0))
661                            .on_hover_text(hover);
662                        self.hits
663                            .insert(format!("qualify:point:{gi}:{pi}:{key}{axis}"), field.rect);
664                        if field.changed() {
665                            asked = Some((
666                                Edit::PointTransform(gi, pi, key, axis, text),
667                                Some(format!("point:{gi}:{pi}:{key}{axis}")),
668                            ));
669                        }
670                    }
671                });
672            }
673            self.gizmo_line(ui, engine, address, target);
674
675            // --- the wire -----------------------------------------------------
676            self.section(ui, "Wire");
677            ui.horizontal(|ui| {
678                for (key, caption, hover) in [
679                    ("extension", "Extension", "The straight run a wire keeps before it may bend. Empty takes the default."),
680                    ("displayLength", "Drawn", "How long the point's line is drawn. Empty takes the default."),
681                ] {
682                    ui.label(egui::RichText::new(caption).weak());
683                    let mut text = optional_text(point, key);
684                    let field = ui
685                        .add(egui::TextEdit::singleline(&mut text).desired_width(46.0))
686                        .on_hover_text(hover);
687                    self.hits.insert(format!("qualify:point:{gi}:{pi}:{key}"), field.rect);
688                    if field.changed() {
689                        asked = Some((
690                            Edit::PointText(gi, pi, key, text),
691                            Some(format!("point:{gi}:{pi}:{key}")),
692                        ));
693                    }
694                }
695            });
696            let mut reverse = point.reverse_direction;
697            let toggle = ui
698                .checkbox(&mut reverse, "Leaves the other way")
699                .on_hover_text("Flip the direction a wire leaves along, without disturbing the seat");
700            self.hits.insert(format!("qualify:point:{gi}:{pi}:reverse"), toggle.rect);
701            if toggle.changed() {
702                asked = Some((Edit::PointReverse(gi, pi, reverse), None));
703            }
704
705            // --- the assembly declaration -------------------------------------
706            // Shown only where it can mean something: `mapsTo` says "this point
707            // IS a descendant's point", so on a part with no components it is a
708            // field that can only ever be wrong.
709            if !engine.assembly_components().is_empty() {
710                if let Some(mapped_asked) = self.maps_to_field(ui, gi, pi, point) {
711                    asked = Some(mapped_asked);
712                }
713            }
714        });
715        asked
716    }
717
718    /// The `mapsTo` field and its heading — the only field a MAPPED point has,
719    /// and the last section of an unmapped one in a document with components.
720    fn maps_to_field(
721        &mut self,
722        ui: &mut egui::Ui,
723        gi: usize,
724        pi: usize,
725        point: &brep_kernel::PortPoint,
726    ) -> Option<(Edit, Option<String>)> {
727        let mut asked = None;
728        self.section(ui, "Assembly");
729        ui.horizontal(|ui| {
730            ui.label(egui::RichText::new("Maps to").weak());
731            let mut text = optional_text(point, "mapsTo");
732            let field = ui
733                .add(egui::TextEdit::singleline(&mut text).desired_width(130.0))
734                .on_hover_text("The descendant point this one IS, by address (`ACOMP2:J5.VCC`). It takes the target's resolved placement, so the interface follows the child — and this point has no seat, no offset and no handles of its own. Clear it to place this point here instead.");
735            self.hits.insert(format!("qualify:point:{gi}:{pi}:mapsTo"), field.rect);
736            if field.changed() {
737                asked = Some((
738                    Edit::PointText(gi, pi, "mapsTo", text),
739                    Some(format!("point:{gi}:{pi}:mapsTo")),
740                ));
741            }
742        });
743        asked
744    }
745
746    /// A section heading inside the selected point's fields.
747    fn section(&mut self, ui: &mut egui::Ui, caption: &str) {
748        ui.add_space(3.0);
749        ui.label(egui::RichText::new(caption).small().strong());
750    }
751
752    /// What the last run made of the point's seat, in words: where it sits and
753    /// which way it faces. This is the answer to "did my pick take?", and
754    /// without it a reference field is a string the user must trust.
755    fn seat_line(
756        &mut self,
757        ui: &mut egui::Ui,
758        engine: &EngineState,
759        address: &str,
760        point: &brep_kernel::PortPoint,
761    ) {
762        let seated = !optional_text(point, "pointRef").trim().is_empty()
763            || !optional_text(point, "directionRef").trim().is_empty();
764        let text = match engine.port_point_row(address) {
765            Some(row) if seated => format!(
766                "seated at ({}), facing ({})",
767                triple(row.seat.origin),
768                triple(row.seat.x),
769            ),
770            Some(row) => format!("at ({}), facing ({})", triple(row.position), triple(row.direction)),
771            // The tail resolves at the END of the run, so a point just added
772            // has nothing yet — which is a state, not a fault.
773            None => "not resolved by the last run".to_string(),
774        };
775        let label = ui.label(egui::RichText::new(text).weak().small());
776        self.hits.insert("qualify:seat".into(), label.rect);
777    }
778
779    /// Whether the handles are on this point, and why not when they are not.
780    fn gizmo_line(
781        &mut self,
782        ui: &mut egui::Ui,
783        engine: &EngineState,
784        address: &str,
785        target: Option<Target>,
786    ) {
787        // Asked of the ENGINE rather than inferred from the gizmo not being
788        // armed: this line is drawn a moment before `follow_gizmo` runs, so on
789        // the frame a point is selected an inference would show the refusal
790        // reason for a point that is about to arm perfectly well.
791        let text = match (target, engine.port_point_gizmo_refusal(address)) {
792            (Some(_), _) => "the move/rotate handles arm on the 3D surface".to_string(),
793            (None, Some(expression)) => format!(
794                "no handles: '{expression}' places this point, and a drag writes numbers"
795            ),
796            (None, None) => {
797                "the move/rotate handles are on it — a drag writes the offset above".to_string()
798            }
799        };
800        let label = ui.label(egui::RichText::new(text).weak().small());
801        self.hits.insert("qualify:gizmo".into(), label.rect);
802    }
803
804    /// Three lamps per point: does a symbol PIN carry this name, does a PAD,
805    /// and did the last run resolve it in 3D. A connection point is ONE thing
806    /// on three surfaces, and this row is where that claim is either true in
807    /// front of the user or visibly not.
808    fn presence(
809        &mut self,
810        ui: &mut egui::Ui,
811        engine: &EngineState,
812        gi: usize,
813        pi: usize,
814        address: &str,
815        name: &str,
816    ) {
817        let pinned = engine
818            .history
819            .pin_point_report()
820            .map(|report| report.pairs.iter().any(|pair| pair.point == address));
821        let padded = pad_numbers(engine).iter().any(|number| number == name);
822        for (key, state, present, absent) in [
823            (
824                "symbol",
825                pinned,
826                "a symbol pin carries this name",
827                "no symbol pin carries this name, so nothing on the schematic is this point",
828            ),
829            (
830                "pads",
831                Some(padded),
832                "a pad carries this number",
833                "no pad carries this number",
834            ),
835            (
836                "model",
837                Some(resolved(engine, address)),
838                "the last run resolved it in 3D",
839                "the last run did not resolve it, so there is no geometry to show",
840            ),
841        ] {
842            let (glyph, color, hover) = match state {
843                Some(true) => (GLYPH_PRESENT, OK_COLOR, present),
844                Some(false) => (GLYPH_ABSENT, WARN_COLOR, absent),
845                // No symbol at all: the question does not arise, and an amber
846                // lamp on a 3D-only part would be a fault where there is none.
847                None => (
848                    GLYPH_ABSENT,
849                    ui.visuals().weak_text_color(),
850                    "this part has no symbol",
851                ),
852            };
853            let lamp = lamp(ui, glyph, color).on_hover_text(format!("{key}: {hover}"));
854            self.hits
855                .insert(format!("qualify:point:{gi}:{pi}:lamp:{key}"), lamp.rect);
856        }
857    }
858
859    /// The three JUMP buttons for the selected point. The one for the surface
860    /// already on screen is drawn pressed and does nothing.
861    fn jump_row(&mut self, ui: &mut egui::Ui, address: &str, target: Option<Target>) -> Option<Jump> {
862        let mut asked = None;
863        ui.horizontal(|ui| {
864            ui.label(egui::RichText::new("Look at it in").weak());
865            for (jump, key, label, hover) in [
866                (Jump::Symbol, "symbol", "Symbol", "Open the schematic symbol with this point's PIN selected"),
867                (Jump::Pads, "pads", "Pads", "Open the footprint with this point's PAD selected"),
868                (Jump::Model, "model", "3D", "Show the model with this point's geometry selected"),
869            ] {
870                let here = match jump {
871                    Jump::Symbol => target == Some(Target::Symbol),
872                    Jump::Pads => target == Some(Target::Pads),
873                    Jump::Model => target.is_none(),
874                };
875                let button = ui
876                    .add_enabled(!here, egui::Button::new(label).small())
877                    .on_hover_text(format!("{hover} ({address})"))
878                    .on_disabled_hover_text("You are looking at it");
879                self.hits.insert(format!("qualify:jump:{key}"), button.rect);
880                if button.clicked() {
881                    asked = Some(jump);
882                }
883            }
884        });
885        asked
886    }
887
888    /// The two consistency reports, which exist to be shown and were shown
889    /// nowhere: the pin/point pairing and the ports tail's own resolution.
890    fn reports(&mut self, ui: &mut egui::Ui, engine: &EngineState) {
891        let mut problem = 0usize;
892        let mut line = |ui: &mut egui::Ui,
893                        hits: &mut HashMap<String, egui::Rect>,
894                        text: String,
895                        color: egui::Color32| {
896            let label = ui.label(egui::RichText::new(text).weak().color(color));
897            hits.insert(format!("qualify:problem:{problem}"), label.rect);
898            problem += 1;
899        };
900
901        ui.label(egui::RichText::new("Pins and points").strong());
902        match engine.history.pin_point_report() {
903            None => {
904                ui.label(
905                    egui::RichText::new("this part has no symbol, so its points bind to no pins")
906                        .weak(),
907                );
908            }
909            Some(PinPointReport { pairs, problems }) => {
910                ui.label(
911                    egui::RichText::new(format!(
912                        "{} pin{} paired",
913                        pairs.len(),
914                        if pairs.len() == 1 { "" } else { "s" }
915                    ))
916                    .weak()
917                    .color(if problems.is_empty() { OK_COLOR } else { WARN_COLOR }),
918                );
919                for entry in &problems {
920                    line(ui, &mut self.hits, entry.message(), WARN_COLOR);
921                }
922            }
923        }
924        // A HOLD is the live state of the edit in flight, not a property of the
925        // document: the last keystroke could not be carried to the other side,
926        // and the next one will be diffed from the same base again.
927        if let Some(hold) = engine.history.pin_port_hold() {
928            line(ui, &mut self.hits, format!("not carried to the pins: {hold}"), WARN_COLOR);
929        }
930
931        ui.add_space(2.0);
932        ui.label(egui::RichText::new("Ports").strong());
933        match engine.ports_report() {
934            None => {
935                ui.label(egui::RichText::new("the model has not run yet").weak());
936            }
937            Some(report) => {
938                ui.label(
939                    egui::RichText::new(format!(
940                        "{} point{} resolved | {}",
941                        report.points.len(),
942                        if report.points.len() == 1 { "" } else { "s" },
943                        if report.boundary {
944                            "a boundary: a parent sees only what this document declares"
945                        } else {
946                            "transparent: a parent sees this document's children too"
947                        },
948                    ))
949                    .weak()
950                    .color(if report.problems.is_empty() { OK_COLOR } else { ERROR_COLOR }),
951                )
952                .on_hover_text(if report.boundary {
953                    "A board hides its components behind the ports it declares itself (`portBoundary`, or a `pcb` block)."
954                } else {
955                    "An enclosure does not hide the parts in it: their points are exported namespaced."
956                });
957                for entry in &report.problems {
958                    line(ui, &mut self.hits, entry.clone(), ERROR_COLOR);
959                }
960            }
961        }
962        // A point that maps DOWN says where to, since `mapsTo` is the one field
963        // whose meaning is another document's.
964        if let Some(report) = engine.ports_report() {
965            for row in report.points.iter().filter(|row| row.maps_to.is_some()) {
966                let target = row.maps_to.clone().unwrap_or_default();
967                line(
968                    ui,
969                    &mut self.hits,
970                    format!("{} \u{2192} {target}", row.address),
971                    ui.visuals().weak_text_color(),
972                );
973            }
974        }
975    }
976
977    // --- acting -------------------------------------------------------------
978
979    /// Apply one edit to the raw block and write it back. The write carries the
980    /// pin/point follow, and sets the flag that re-runs the history next frame.
981    fn apply(&mut self, engine: &mut EngineState, edit: Edit, coalesce: Option<&str>) {
982        let mut block = engine
983            .history
984            .ports_block()
985            .and_then(Value::as_array)
986            .cloned()
987            .unwrap_or_default();
988        match edit {
989            Edit::AddGroup => {
990                block.push(serde_json::json!({
991                    "name": unique_group_name(&block),
992                    "purpose": PURPOSES[0],
993                    "points": [],
994                }));
995            }
996            Edit::RemoveGroup(gi) => {
997                if gi < block.len() {
998                    block.remove(gi);
999                }
1000            }
1001            Edit::GroupName(gi, name) => {
1002                // Renaming a group moves EVERY address in it, and the selection
1003                // is held by address — so carry it, exactly as a point rename
1004                // does, or the point being edited deselects itself the moment
1005                // its group's name is touched.
1006                let was = block.get(gi).and_then(|group| group.get("name")).and_then(Value::as_str);
1007                if let (Some(was), Some(address)) = (was, self.selected.clone()) {
1008                    if let Some((port, point)) = brep_kernel::split_address(&address) {
1009                        if port == was {
1010                            self.selected = Some(brep_kernel::port_address(&name, point));
1011                        }
1012                    }
1013                }
1014                set_group(&mut block, gi, "name", Value::String(name));
1015            }
1016            Edit::GroupPurpose(gi, purpose) => {
1017                set_group(&mut block, gi, "purpose", Value::String(purpose));
1018            }
1019            Edit::GroupUnit(gi, unit) => match unit {
1020                Some(unit) => set_group(&mut block, gi, "symbolUnit", Value::from(unit)),
1021                None => remove_group_key(&mut block, gi, "symbolUnit"),
1022            },
1023            Edit::AddPoint(gi) => {
1024                if let Some(points) = points_mut(&mut block, gi) {
1025                    let name = unique_point_name(points);
1026                    points.push(serde_json::json!({
1027                        "name": name,
1028                        "transform": { "position": [0.0, 0.0, 0.0], "rotationEuler": [0.0, 0.0, 0.0] },
1029                    }));
1030                }
1031            }
1032            Edit::RemovePoint(gi, pi) => {
1033                if let Some(points) = points_mut(&mut block, gi) {
1034                    if pi < points.len() {
1035                        points.remove(pi);
1036                    }
1037                }
1038            }
1039            Edit::PointName(gi, pi, name) => {
1040                // The selection is held by ADDRESS, so a rename has to move it
1041                // or the point the user is editing deselects itself mid-word.
1042                if let Some(group) = block.get(gi).and_then(|group| group.get("name")).and_then(Value::as_str) {
1043                    self.selected = Some(brep_kernel::port_address(group, &name));
1044                }
1045                set_point(&mut block, gi, pi, "name", Value::String(name));
1046            }
1047            Edit::PointText(gi, pi, key, text) => match text.trim().is_empty() {
1048                true => remove_point_key(&mut block, gi, pi, key),
1049                false => set_point(&mut block, gi, pi, key, scalar(&text)),
1050            },
1051            Edit::PointTransform(gi, pi, key, axis, text) => {
1052                set_transform_component(&mut block, gi, pi, key, axis, scalar(&text));
1053            }
1054            Edit::PointReverse(gi, pi, reverse) => match reverse {
1055                true => set_point(&mut block, gi, pi, "reverseDirection", Value::Bool(true)),
1056                false => remove_point_key(&mut block, gi, pi, "reverseDirection"),
1057            },
1058        }
1059        // An EMPTY block is not a block: the kernel omits it on re-serialize,
1060        // and leaving `[]` behind would keep this pane up over a part with
1061        // nothing to qualify. Removing the last group therefore closes the pane,
1062        // which is worth saying out loud.
1063        if block.is_empty() {
1064            engine.history.set_ports_block(None, None);
1065            engine.push_notice("the last connection-point group is gone; the Qualify pane closes with it");
1066            return;
1067        }
1068        engine.history.set_ports_block(Some(Value::Array(block)), coalesce);
1069    }
1070
1071    /// Carry the selected point to another surface: switch the workbench and
1072    /// hand the editor the selection to make when it next draws.
1073    ///
1074    /// The editor selection is DEFERRED rather than set here: the editor is
1075    /// re-handed its block on the frame it is drawn (`viewport::ecad`'s pull),
1076    /// which clears whatever it had selected, so a selection set now would be
1077    /// wiped by the switch itself.
1078    fn jump(&mut self, engine: &mut EngineState, ecad: &mut Editors, jump: Jump) {
1079        let Some(address) = self.selected.clone() else {
1080            return;
1081        };
1082        let Some((_, name)) = brep_kernel::split_address(&address) else {
1083            return;
1084        };
1085        let (target, workbench) = match jump {
1086            Jump::Symbol => (Some(Target::Symbol), "symbol"),
1087            Jump::Pads => (Some(Target::Pads), "pads"),
1088            Jump::Model => (None, MODEL_WORKBENCH),
1089        };
1090        match target {
1091            Some(target) => ecad.focus(target, name.to_string()),
1092            None => {
1093                // The part's OWN points resolve at the TAIL of the run, so one
1094                // the last run did not resolve has nothing in the scene to
1095                // select. Selecting its name anyway would light nothing and
1096                // leave a phantom selection behind, so say so instead.
1097                if resolved(engine, &address) {
1098                    engine.select_by_name("solid", &address);
1099                } else {
1100                    engine.push_notice(format!(
1101                        "{address} has no geometry yet \u{2014} the last run did not resolve it"
1102                    ));
1103                }
1104            }
1105        }
1106        // A workbench that ALREADY draws what was asked for is left alone: a
1107        // user in Modeling who asks to see the point in 3D wants the selection,
1108        // not to be moved to another workbench.
1109        let already = match jump {
1110            Jump::Model => Target::of_workbench(&engine.settings.workbench).is_none(),
1111            _ => false,
1112        };
1113        if !already && engine.settings.workbench != workbench {
1114            // The SAME seam the workbench dropdown writes through, but NOT
1115            // persisted to the settings blob: a jump is navigation within a
1116            // document, like the assembly auto-switch, and the blob stays the
1117            // user's boot preference.
1118            let _ = engine
1119                .apply_settings_json(&serde_json::json!({ "workbench": workbench }).to_string());
1120        }
1121    }
1122
1123    /// Adopt the eCAD editor's own selection when it names a connection point:
1124    /// picking pin `VCC` in the symbol selects `J1.VCC` here, which is the half
1125    /// of "select it there, look at it here" the jump buttons do not cover.
1126    fn adopt_editor_selection(&mut self, engine: &EngineState, ecad: &Editors, target: Option<Target>) {
1127        // The 3D surface has a selection of its own, and it already names the
1128        // point: a declared point draws as a sheet keyed by its address. This
1129        // is the direction the panel could not reach before — clicking a point
1130        // in the viewport now opens it here.
1131        let (surface, label) = match target {
1132            None => (MODEL_SURFACE, engine.selected_port_point()),
1133            Some(target @ (Target::Symbol | Target::Pads)) => {
1134                (surface_of(target), editor_selection(ecad, target))
1135            }
1136            // A schematic sheet or a board: neither selects a pin or a pad, so
1137            // there is nothing of this part's to adopt.
1138            Some(_) => return,
1139        };
1140        let Some(label) = label else {
1141            // Nothing selected over there clears what was adopted FROM there,
1142            // so re-picking the same pin is a new pick.
1143            if self.adopted.as_ref().is_some_and(|(from, _)| *from == surface) {
1144                self.adopted = None;
1145            }
1146            return;
1147        };
1148        if self.adopted.as_ref().is_some_and(|(from, what)| *from == surface && *what == label) {
1149            return;
1150        }
1151        self.adopted = Some((surface, label.clone()));
1152        // The 3D surface's selection IS the address — the join the two eCAD
1153        // surfaces need is already made there.
1154        if surface == MODEL_SURFACE {
1155            self.selected = Some(label);
1156            return;
1157        }
1158        // A pin's group is its UNIT's group: a pad matches its pin by number
1159        // symbol-wide, so both surfaces route through the same join.
1160        let unit = engine
1161            .history
1162            .pins()
1163            .unwrap_or_default()
1164            .into_iter()
1165            .find(|pin| pin.label == label)
1166            .map(|pin| pin.unit)
1167            .unwrap_or(1);
1168        if let Some(group) = engine.history.port_group_for_unit(unit) {
1169            self.selected = Some(brep_kernel::port_address(&group, &label));
1170        }
1171    }
1172}
1173
1174/// Whether the LAST RUN resolved `address` — whether, in other words, there is
1175/// anything in the scene under that name to select. A part's own points are
1176/// resolved at the tail of the run, so a point just typed in has none until the
1177/// next one finishes.
1178fn resolved(engine: &EngineState, address: &str) -> bool {
1179    engine
1180        .ports_report()
1181        .is_some_and(|report| report.points.iter().any(|row| row.address == address))
1182}
1183
1184/// What `target`'s editor currently has selected, by NUMBER — the one reading
1185/// of it, so a pick and the adopt that follows it cannot disagree about what
1186/// the editor holds. `None` for a surface that selects no pin or pad.
1187fn editor_selection(ecad: &Editors, target: Target) -> Option<String> {
1188    match target {
1189        Target::Symbol => ecad.symbol.selected_pin().map(str::to_string),
1190        Target::Pads => ecad.pads.selected_pad().map(str::to_string),
1191        Target::Diagram | Target::Pcb => None,
1192    }
1193}
1194
1195/// The key an adopted editor selection is remembered under. A `&'static str`
1196/// rather than the `Target` itself, because what is remembered is "the last
1197/// thing I saw selected over there" and the two eCAD editors are the only
1198/// surfaces that have one.
1199fn surface_of(target: Target) -> &'static str {
1200    match target {
1201        Target::Symbol => "symbol",
1202        Target::Pads => "pads",
1203        Target::Diagram => "diagram",
1204        Target::Pcb => "pcb",
1205    }
1206}
1207
1208// ---------------------------------------------------------------------------
1209// Raw-block helpers: every edit touches ONE key, so a field this panel does not
1210// know about survives an edit to the field beside it.
1211// ---------------------------------------------------------------------------
1212
1213fn set_group(block: &mut [Value], gi: usize, key: &str, value: Value) {
1214    if let Some(object) = block.get_mut(gi).and_then(Value::as_object_mut) {
1215        object.insert(key.into(), value);
1216    }
1217}
1218
1219fn remove_group_key(block: &mut [Value], gi: usize, key: &str) {
1220    if let Some(object) = block.get_mut(gi).and_then(Value::as_object_mut) {
1221        object.remove(key);
1222    }
1223}
1224
1225fn points_mut(block: &mut [Value], gi: usize) -> Option<&mut Vec<Value>> {
1226    let group = block.get_mut(gi)?.as_object_mut()?;
1227    group.entry("points").or_insert_with(|| Value::Array(Vec::new())).as_array_mut()
1228}
1229
1230fn point_mut<'a>(block: &'a mut [Value], gi: usize, pi: usize) -> Option<&'a mut serde_json::Map<String, Value>> {
1231    points_mut(block, gi)?.get_mut(pi)?.as_object_mut()
1232}
1233
1234fn set_point(block: &mut [Value], gi: usize, pi: usize, key: &str, value: Value) {
1235    if let Some(point) = point_mut(block, gi, pi) {
1236        point.insert(key.into(), value);
1237    }
1238}
1239
1240fn remove_point_key(block: &mut [Value], gi: usize, pi: usize, key: &str) {
1241    if let Some(point) = point_mut(block, gi, pi) {
1242        point.remove(key);
1243    }
1244}
1245
1246/// Write one component of `transform.position` / `transform.rotationEuler`,
1247/// filling in the array (and the transform) when the point has none.
1248fn set_transform_component(
1249    block: &mut [Value],
1250    gi: usize,
1251    pi: usize,
1252    key: &str,
1253    axis: usize,
1254    value: Value,
1255) {
1256    let Some(point) = point_mut(block, gi, pi) else {
1257        return;
1258    };
1259    let transform = point
1260        .entry("transform")
1261        .or_insert_with(|| Value::Object(Default::default()));
1262    if !transform.is_object() {
1263        *transform = Value::Object(Default::default());
1264    }
1265    let Some(transform) = transform.as_object_mut() else {
1266        return;
1267    };
1268    let axes = transform
1269        .entry(key.to_string())
1270        .or_insert_with(|| Value::Array(vec![Value::from(0.0), Value::from(0.0), Value::from(0.0)]));
1271    if !axes.is_array() {
1272        *axes = Value::Array(vec![Value::from(0.0), Value::from(0.0), Value::from(0.0)]);
1273    }
1274    if let Some(axes) = axes.as_array_mut() {
1275        while axes.len() < 3 {
1276            axes.push(Value::from(0.0));
1277        }
1278        axes[axis] = value;
1279    }
1280}
1281
1282/// A typed field's value: a NUMBER when it parses as one, else the string,
1283/// which the transform lane reads as an expression. An empty field is `0`
1284/// rather than an empty expression, because a blank component is not a
1285/// placement.
1286fn scalar(text: &str) -> Value {
1287    let text = text.trim();
1288    if text.is_empty() {
1289        return Value::from(0.0);
1290    }
1291    match text.parse::<f64>() {
1292        Ok(number) => Value::from(number),
1293        Err(_) => Value::String(text.to_string()),
1294    }
1295}
1296
1297/// One component of a point's transform as the field shows it: a number
1298/// formatted plainly, an expression verbatim, and `0` for a point with no
1299/// transform at all.
1300fn transform_component(point: &brep_kernel::PortPoint, key: &str, axis: usize) -> String {
1301    match point.transform.get(key).and_then(Value::as_array).and_then(|axes| axes.get(axis)) {
1302        // COMPACT, not `to_string`: the block carries a component the panel
1303        // wrote as a JSON float, so an untouched zero read back as `0.0` and a
1304        // whole millimetre as `2.0`. Six places is past anything a placement
1305        // field is typed to and short of f64 noise.
1306        Some(Value::Number(number)) => match number.as_f64() {
1307            Some(value) => brep_render::formatting::compact_decimal(value, 6),
1308            None => number.to_string(),
1309        },
1310        Some(Value::String(expression)) => expression.clone(),
1311        _ => "0".to_string(),
1312    }
1313}
1314
1315/// One of the point's optional scalar fields as its field shows it — empty
1316/// where the point does not carry it.
1317fn optional_text(point: &brep_kernel::PortPoint, key: &str) -> String {
1318    match key {
1319        "pointRef" => point.point_ref.clone().unwrap_or_default(),
1320        "directionRef" => point.direction_ref.clone().unwrap_or_default(),
1321        "mapsTo" => point.maps_to.clone().unwrap_or_default(),
1322        "extension" => point.extension.map(|v| v.to_string()).unwrap_or_default(),
1323        "displayLength" => point.display_length.map(|v| v.to_string()).unwrap_or_default(),
1324        _ => String::new(),
1325    }
1326}
1327
1328/// One status lamp: catalogued artwork where the glyph has some, a coloured
1329/// character where it does not.
1330fn lamp(ui: &mut egui::Ui, glyph: &str, color: egui::Color32) -> egui::Response {
1331    match crate::icon_text::glyph(ui, glyph, color) {
1332        Some(art) => ui.add(art),
1333        None => ui.label(egui::RichText::new(glyph).color(color)),
1334    }
1335}
1336
1337/// A vector as the seat readout prints it: three numbers, short.
1338fn triple(values: [f64; 3]) -> String {
1339    values
1340        .iter()
1341        .map(|value| {
1342            let rounded = (value * 1000.0).round() / 1000.0;
1343            // `-0` is the same place as `0` and reads as a mistake.
1344            if rounded == 0.0 { "0".to_string() } else { format!("{rounded}") }
1345        })
1346        .collect::<Vec<_>>()
1347        .join(", ")
1348}
1349
1350/// The footprint's pad numbers. A pad matches a pin by number symbol-wide, and
1351/// a pin carries its point's name, so a pad is this point's when its number is
1352/// the point's name — which is exactly the join `part_pins` makes.
1353fn pad_numbers(engine: &EngineState) -> Vec<String> {
1354    engine
1355        .history
1356        .pads_block()
1357        .and_then(|block| block.get("pads"))
1358        .and_then(Value::as_array)
1359        .map(|pads| {
1360            pads.iter()
1361                .filter_map(|pad| pad.get("number").and_then(Value::as_str))
1362                .map(str::to_string)
1363                .collect()
1364        })
1365        .unwrap_or_default()
1366}
1367
1368/// `J1`, `J2`, … — the first that no group in the block has taken.
1369fn unique_group_name(block: &[Value]) -> String {
1370    let taken: Vec<&str> = block
1371        .iter()
1372        .filter_map(|group| group.get("name").and_then(Value::as_str))
1373        .collect();
1374    (1..).map(|n| format!("J{n}")).find(|name| !taken.contains(&name.as_str())).unwrap_or_default()
1375}
1376
1377/// `1`, `2`, … — the first that no point in the group has taken. A number, not
1378/// a word, because a pin's label is what this becomes and pins are numbered.
1379fn unique_point_name(points: &[Value]) -> String {
1380    let taken: Vec<&str> = points
1381        .iter()
1382        .filter_map(|point| point.get("name").and_then(Value::as_str))
1383        .collect();
1384    (1..).map(|n| n.to_string()).find(|name| !taken.contains(&name.as_str())).unwrap_or_default()
1385}
1386
1387pub static HIT_KEYS: &[HitKeyDoc] = &[
1388    HitKeyDoc { panel: "qualify", prefix: "qualify:panel:clip", meaning: "the visible region of the pane", command: None },
1389    HitKeyDoc { panel: "qualify", prefix: "qualify:add_group", meaning: "declare another port group", command: None },
1390    HitKeyDoc { panel: "qualify", prefix: "qualify:group:", meaning: "a port group's name, purpose, symbol unit, add-point or remove", command: None },
1391    HitKeyDoc { panel: "qualify", prefix: "qualify:point:", meaning: "a connection point's select, name, remove, or one of the selected point's fields", command: None },
1392    HitKeyDoc { panel: "qualify", prefix: "qualify:jump:", meaning: "carry the selected point to the symbol, the pads or the 3D model", command: None },
1393    HitKeyDoc { panel: "qualify", prefix: "qualify:hint", meaning: "what selecting a point is for, shown while none is", command: None },
1394    HitKeyDoc { panel: "qualify", prefix: "qualify:seat", meaning: "what the last run made of the selected point's seat", command: None },
1395    HitKeyDoc { panel: "qualify", prefix: "qualify:gizmo", meaning: "whether the move/rotate handles are on the selected point", command: None },
1396    HitKeyDoc { panel: "qualify", prefix: "qualify:problem:", meaning: "one line of the pin/point or ports report", command: None },
1397];
1398