1use 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
67const GLYPH_SELECT: &str = "\u{25CE}"; const GLYPH_REMOVE: &str = "\u{2715}"; const GLYPH_ADD: &str = "\u{FF0B}"; const GLYPH_PICK: &str = "\u{2316}"; const GLYPH_PRESENT: &str = "\u{25CF}"; const GLYPH_ABSENT: &str = "\u{25CB}"; const PURPOSES: [&str; 3] = ["pcb", "wiring", "piping"];
81
82const 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);
86const REMOVE_RED: egui::Color32 = egui::Color32::from_rgb(0xd8, 0x54, 0x4f);
88
89const MODEL_WORKBENCH: &str = "wireHarness";
94
95const MODEL_SURFACE: &str = "model";
98
99enum 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 PointText(usize, usize, &'static str, String),
110 PointTransform(usize, usize, &'static str, usize, String),
113 PointReverse(usize, usize, bool),
114 AddPoint(usize),
115 RemovePoint(usize, usize),
116}
117
118#[derive(Clone, Copy, PartialEq, Eq)]
120enum Jump {
121 Symbol,
122 Pads,
123 Model,
124}
125
126pub struct QualifyPanel {
128 hits: HashMap<String, egui::Rect>,
129 selected: Option<String>,
133 adopted: Option<(&'static str, String)>,
138 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 pub fn clear_hits(&mut self) {
164 self.hits.clear();
165 }
166
167 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 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 self.adopt_editor_selection(engine, ecad, target);
198 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 let mut refs: Option<(String, PortRefField, bool)> = None;
214
215 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 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 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 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 ui.add_space(4.0);
294 self.reports(ui, engine);
295
296 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 self.follow_gizmo(engine, target);
314 }
315
316 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 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 Some(address) => {
358 engine.arm_transform_for_port_point(&address);
359 }
360 None => {
364 if engine.transform_armed_port_point().is_some() {
365 engine.disarm_transform();
366 }
367 }
368 }
369 }
370
371 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 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 self.adopted = editor_selection(ecad, target)
406 .map(|held| (surface_of(target), held));
407 }
408 None => {
412 if let Some(address) = address.as_deref() {
413 if resolved(engine, address) {
414 engine.select_by_name("solid", address);
415 }
416 }
417 self.adopted = engine
423 .selected_port_point()
424 .map(|held| (MODEL_SURFACE, held));
425 }
426 }
427 }
428
429 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 fn gizmo_line(
781 &mut self,
782 ui: &mut egui::Ui,
783 engine: &EngineState,
784 address: &str,
785 target: Option<Target>,
786 ) {
787 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let _ = engine
1119 .apply_settings_json(&serde_json::json!({ "workbench": workbench }).to_string());
1120 }
1121 }
1122
1123 fn adopt_editor_selection(&mut self, engine: &EngineState, ecad: &Editors, target: Option<Target>) {
1127 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 Some(_) => return,
1139 };
1140 let Some(label) = label else {
1141 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 if surface == MODEL_SURFACE {
1155 self.selected = Some(label);
1156 return;
1157 }
1158 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
1174fn 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
1184fn 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
1195fn 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
1208fn 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
1246fn 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
1282fn 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
1297fn 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 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
1315fn 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
1328fn 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
1337fn triple(values: [f64; 3]) -> String {
1339 values
1340 .iter()
1341 .map(|value| {
1342 let rounded = (value * 1000.0).round() / 1000.0;
1343 if rounded == 0.0 { "0".to_string() } else { format!("{rounded}") }
1345 })
1346 .collect::<Vec<_>>()
1347 .join(", ")
1348}
1349
1350fn 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
1368fn 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
1377fn 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