use std::collections::HashMap;
use eframe::egui;
use serde_json::Value;
use crate::automation::hit_keys::HitKeyDoc;
use crate::workbench::ecad::{Editors, Target};
use brep_render::brep_kernel::{self, PinPointReport, PortDeclaration};
use brep_render::engine_state::{EngineState, PortRefField};
const 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"];
const WARN_COLOR: egui::Color32 = egui::Color32::from_rgb(0xd2, 0x99, 0x22);
const ERROR_COLOR: egui::Color32 = egui::Color32::from_rgb(0xf8, 0x51, 0x49);
const OK_COLOR: egui::Color32 = egui::Color32::from_rgb(0x3f, 0xb9, 0x50);
const REMOVE_RED: egui::Color32 = egui::Color32::from_rgb(0xd8, 0x54, 0x4f);
const MODEL_WORKBENCH: &str = "wireHarness";
const MODEL_SURFACE: &str = "model";
enum Edit {
GroupName(usize, String),
GroupPurpose(usize, String),
GroupUnit(usize, Option<u32>),
AddGroup,
RemoveGroup(usize),
PointName(usize, usize, String),
PointText(usize, usize, &'static str, String),
PointTransform(usize, usize, &'static str, usize, String),
PointReverse(usize, usize, bool),
AddPoint(usize),
RemovePoint(usize, usize),
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Jump {
Symbol,
Pads,
Model,
}
pub struct QualifyPanel {
hits: HashMap<String, egui::Rect>,
selected: Option<String>,
adopted: Option<(&'static str, String)>,
gizmo_armed_for: Option<String>,
}
impl Default for QualifyPanel {
fn default() -> Self {
Self::new()
}
}
impl QualifyPanel {
pub fn new() -> Self {
Self { hits: HashMap::new(), selected: None, adopted: None, gizmo_armed_for: None }
}
pub fn hits_json(&self) -> String {
crate::automation::hit_rects::hits_json(&self.hits)
}
pub fn clear_hits(&mut self) {
self.hits.clear();
}
pub fn state_json(&self) -> String {
serde_json::json!({
"selected": self.selected,
"gizmoArmedFor": self.gizmo_armed_for,
})
.to_string()
}
pub fn show(
&mut self,
ui: &mut egui::Ui,
engine: &mut EngineState,
ecad: &mut Editors,
target: Option<Target>,
) {
self.hits.clear();
self.hits.insert("qualify:panel:clip".into(), ui.clip_rect());
let groups: Vec<PortDeclaration> = engine
.history
.ports_block()
.cloned()
.and_then(|block| serde_json::from_value(block).ok())
.unwrap_or_default();
self.adopt_editor_selection(engine, ecad, target);
if let Some(address) = &self.selected {
if !groups.iter().any(|group| {
group.points.iter().any(|point| &brep_kernel::port_address(&group.name, &point.name) == address)
}) {
self.selected = None;
}
}
let mut edit: Option<(Edit, Option<String>)> = None;
let mut jump: Option<Jump> = None;
let mut pick: Option<Option<String>> = None;
let mut refs: Option<(String, PortRefField, bool)> = None;
let points: usize = groups.iter().map(|group| group.points.len()).sum();
ui.horizontal(|ui| {
let add = ui
.add(crate::icon_text::icon_button(ui, &format!("{GLYPH_ADD} Add group")).small())
.on_hover_text("Declare another port group on this part — a second connector, or a port with a different purpose");
self.hits.insert("qualify:add_group".into(), add.rect);
if add.clicked() {
edit = Some((Edit::AddGroup, None));
}
ui.label(
egui::RichText::new(format!(
"{} group{} | {points} point{}",
groups.len(),
if groups.len() == 1 { "" } else { "s" },
if points == 1 { "" } else { "s" },
))
.weak(),
);
});
if self.selected.is_none() {
let hint = ui.label(
egui::RichText::new(
"Select a point to place it, seat it on geometry and cross to its pin and pad",
)
.weak()
.small(),
);
self.hits.insert("qualify:hint".into(), hint.rect);
}
ui.add_space(2.0);
for (gi, group) in groups.iter().enumerate() {
egui::Frame::group(ui.style())
.inner_margin(egui::Margin::same(4))
.show(ui, |ui| {
let room = (ui.clip_rect().right() - ui.max_rect().left()).max(0.0);
ui.set_width(ui.available_width().min(room));
if let Some(asked) = self.group_header(ui, gi, group) {
edit = Some(asked);
}
for (pi, point) in group.points.iter().enumerate() {
let address = brep_kernel::port_address(&group.name, &point.name);
let selected = self.selected.as_deref() == Some(address.as_str());
if let Some(asked) = self
.point_row(ui, engine, gi, pi, point, &address, selected, &mut pick)
{
edit = Some(asked);
}
if selected {
if let Some(asked) = self
.point_fields(ui, engine, gi, pi, point, &address, target, &mut refs)
{
edit = Some(asked);
}
if let Some(asked) = self.jump_row(ui, &address, target) {
jump = Some(asked);
}
}
}
let add = ui
.add(crate::icon_text::icon_button(ui, &format!("{GLYPH_ADD} Add point")).small())
.on_hover_text("Add a connection point to this group. It arrives at the part origin, facing +X; place it below.");
self.hits.insert(format!("qualify:group:{gi}:add_point"), add.rect);
if add.clicked() {
edit = Some((Edit::AddPoint(gi), None));
}
});
}
ui.add_space(4.0);
self.reports(ui, engine);
if let Some(pick) = pick {
self.pick(engine, ecad, target, pick);
}
if let Some((address, field, enter)) = refs {
match enter {
true => engine.begin_ref_select_for_port_point(&address, field),
false => engine.clear_port_point_ref(&address, field),
}
}
if let Some((edit, coalesce)) = edit {
self.apply(engine, edit, coalesce.as_deref());
}
if let Some(jump) = jump {
self.jump(engine, ecad, jump);
}
self.follow_gizmo(engine, target);
}
fn follow_gizmo(&mut self, engine: &mut EngineState, target: Option<Target>) {
let wanted = match target.is_none() && !engine.ref_select_active() {
true => self.selected.clone(),
false => None,
};
let refusal = wanted
.as_deref()
.and_then(|address| engine.port_point_gizmo_refusal(address));
let settled = wanted == self.gizmo_armed_for
&& (wanted.is_none() || engine.transform_armed() || refusal.is_some());
if settled {
return;
}
self.gizmo_armed_for = wanted.clone();
match wanted {
Some(address) => {
engine.arm_transform_for_port_point(&address);
}
None => {
if engine.transform_armed_port_point().is_some() {
engine.disarm_transform();
}
}
}
}
fn pick(
&mut self,
engine: &mut EngineState,
ecad: &mut Editors,
target: Option<Target>,
address: Option<String>,
) {
self.selected = address.clone();
match target {
Some(target) => {
if let Some((_, name)) =
address.as_deref().and_then(brep_kernel::split_address)
{
ecad.focus(target, name.to_string());
ecad.apply_focus(target);
}
self.adopted = editor_selection(ecad, target)
.map(|held| (surface_of(target), held));
}
None => {
if let Some(address) = address.as_deref() {
if resolved(engine, address) {
engine.select_by_name("solid", address);
}
}
self.adopted = engine
.selected_port_point()
.map(|held| (MODEL_SURFACE, held));
}
}
}
fn group_header(
&mut self,
ui: &mut egui::Ui,
gi: usize,
group: &PortDeclaration,
) -> Option<(Edit, Option<String>)> {
let mut asked = None;
ui.horizontal(|ui| {
let mut name = group.name.clone();
let field = ui
.add(egui::TextEdit::singleline(&mut name).desired_width(70.0))
.on_hover_text("The group's name — the first half of every address in it (`J1` in `J1.VCC`). `:`, `.` and `/` are refused.");
self.hits.insert(format!("qualify:group:{gi}:name"), field.rect);
if field.changed() {
asked = Some((Edit::GroupName(gi, name), Some(format!("group:{gi}:name"))));
}
let mut purpose = group.purpose.clone();
let combo = egui::ComboBox::from_id_salt(("qualify-purpose", gi))
.selected_text(if purpose.is_empty() { "purpose" } else { purpose.as_str() })
.width(76.0)
.show_ui(ui, |ui| {
let mut offered: Vec<String> =
PURPOSES.iter().map(|p| (*p).to_string()).collect();
if !group.purpose.is_empty() && !PURPOSES.contains(&group.purpose.as_str()) {
offered.push(group.purpose.clone());
}
for candidate in offered {
let item = ui.selectable_label(purpose == candidate, &candidate);
self.hits
.insert(format!("qualify:group:{gi}:purpose:{candidate}"), item.rect);
if item.clicked() {
purpose = candidate;
}
}
});
self.hits.insert(format!("qualify:group:{gi}:purpose"), combo.response.rect);
if purpose != group.purpose {
asked = Some((Edit::GroupPurpose(gi, purpose), None));
}
ui.label(egui::RichText::new("unit").weak());
let mut unit = group.symbol_unit.map(|unit| unit.to_string()).unwrap_or_default();
let field = ui
.add(egui::TextEdit::singleline(&mut unit).desired_width(24.0))
.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.");
self.hits.insert(format!("qualify:group:{gi}:unit"), field.rect);
if field.changed() {
let parsed = unit.trim().parse::<u32>().ok();
if parsed.is_some() || unit.trim().is_empty() {
asked = Some((Edit::GroupUnit(gi, parsed), Some(format!("group:{gi}:unit"))));
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let remove = ui
.add(crate::icon_text::icon_button_colored(ui, GLYPH_REMOVE, Some(REMOVE_RED)).small())
.on_hover_text("Remove this group and every point in it — and the pins bound to them");
self.hits.insert(format!("qualify:group:{gi}:remove"), remove.rect);
if remove.clicked() {
asked = Some((Edit::RemoveGroup(gi), None));
}
});
});
asked
}
#[allow(clippy::too_many_arguments)]
fn point_row(
&mut self,
ui: &mut egui::Ui,
engine: &EngineState,
gi: usize,
pi: usize,
point: &brep_kernel::PortPoint,
address: &str,
selected: bool,
pick: &mut Option<Option<String>>,
) -> Option<(Edit, Option<String>)> {
let mut asked = None;
ui.horizontal(|ui| {
let label = crate::icon_text::selectable_icon_label(ui, selected, GLYPH_SELECT)
.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"));
self.hits.insert(format!("qualify:point:{gi}:{pi}:select"), label.rect);
if label.clicked() {
*pick = Some((!selected).then(|| address.to_string()));
}
let mut name = point.name.clone();
let field = ui
.add(egui::TextEdit::singleline(&mut name).desired_width(70.0))
.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.");
self.hits.insert(format!("qualify:point:{gi}:{pi}:name"), field.rect);
if field.changed() {
asked = Some((Edit::PointName(gi, pi, name), Some(format!("point:{gi}:{pi}:name"))));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let remove = ui
.add(crate::icon_text::icon_button_colored(ui, GLYPH_REMOVE, Some(REMOVE_RED)).small())
.on_hover_text("Remove this connection point — and the pin bound to it");
self.hits.insert(format!("qualify:point:{gi}:{pi}:remove"), remove.rect);
if remove.clicked() {
asked = Some((Edit::RemovePoint(gi, pi), None));
}
ui.add_space(4.0);
self.presence(ui, engine, gi, pi, address, &point.name);
});
});
asked
}
fn point_fields(
&mut self,
ui: &mut egui::Ui,
engine: &EngineState,
gi: usize,
pi: usize,
point: &brep_kernel::PortPoint,
address: &str,
target: Option<Target>,
refs: &mut Option<(String, PortRefField, bool)>,
) -> Option<(Edit, Option<String>)> {
let mut asked = None;
let mapped = point
.maps_to
.as_deref()
.map(str::trim)
.is_some_and(|target| !target.is_empty());
ui.indent(("qualify-point", gi, pi), |ui| {
if mapped {
if let Some(mapped_asked) = self.maps_to_field(ui, gi, pi, point) {
asked = Some(mapped_asked);
}
self.seat_line(ui, engine, address, point);
return;
}
self.section(ui, "Seated on");
for (field, caption, hover) in [
(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."),
(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."),
] {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(caption).weak());
let key = field.key();
let mut text = optional_text(point, key);
let field_ui = ui
.add(egui::TextEdit::singleline(&mut text).desired_width(120.0))
.on_hover_text(hover);
self.hits.insert(format!("qualify:point:{gi}:{pi}:{key}"), field_ui.rect);
if field_ui.changed() {
asked = Some((
Edit::PointText(gi, pi, key, text),
Some(format!("point:{gi}:{pi}:{key}")),
));
}
let pickable = target.is_none();
let pick = ui
.add_enabled(
pickable,
crate::icon_text::icon_button(ui, GLYPH_PICK).small(),
)
.on_hover_text("Pick it in the 3D view")
.on_disabled_hover_text("Go to the 3D model to pick geometry");
self.hits.insert(format!("qualify:point:{gi}:{pi}:{key}:pick"), pick.rect);
if pick.clicked() {
*refs = Some((address.to_string(), field, true));
}
let has = !optional_text(point, key).trim().is_empty();
let clear = ui
.add_enabled(
has,
crate::icon_text::icon_button_colored(ui, GLYPH_REMOVE, Some(REMOVE_RED)).small(),
)
.on_hover_text("Unseat it — the offset below stays as it is, read from the part origin");
self.hits.insert(format!("qualify:point:{gi}:{pi}:{key}:clear"), clear.rect);
if clear.clicked() {
*refs = Some((address.to_string(), field, false));
}
});
}
self.seat_line(ui, engine, address, point);
self.section(ui, "Placement");
let seated = point.point_ref.is_some() || point.direction_ref.is_some();
for (key, caption, hover) in [
("position", "Offset", if seated {
"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."
} else {
"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."
}),
("rotationEuler", "Angle", if seated {
"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\"."
} else {
"The rotation applied to +X to get the direction a wire leaves along, in degrees."
}),
] {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(caption).weak());
for axis in 0..3 {
let mut text = transform_component(point, key, axis);
let field = ui
.add(egui::TextEdit::singleline(&mut text).desired_width(46.0))
.on_hover_text(hover);
self.hits
.insert(format!("qualify:point:{gi}:{pi}:{key}{axis}"), field.rect);
if field.changed() {
asked = Some((
Edit::PointTransform(gi, pi, key, axis, text),
Some(format!("point:{gi}:{pi}:{key}{axis}")),
));
}
}
});
}
self.gizmo_line(ui, engine, address, target);
self.section(ui, "Wire");
ui.horizontal(|ui| {
for (key, caption, hover) in [
("extension", "Extension", "The straight run a wire keeps before it may bend. Empty takes the default."),
("displayLength", "Drawn", "How long the point's line is drawn. Empty takes the default."),
] {
ui.label(egui::RichText::new(caption).weak());
let mut text = optional_text(point, key);
let field = ui
.add(egui::TextEdit::singleline(&mut text).desired_width(46.0))
.on_hover_text(hover);
self.hits.insert(format!("qualify:point:{gi}:{pi}:{key}"), field.rect);
if field.changed() {
asked = Some((
Edit::PointText(gi, pi, key, text),
Some(format!("point:{gi}:{pi}:{key}")),
));
}
}
});
let mut reverse = point.reverse_direction;
let toggle = ui
.checkbox(&mut reverse, "Leaves the other way")
.on_hover_text("Flip the direction a wire leaves along, without disturbing the seat");
self.hits.insert(format!("qualify:point:{gi}:{pi}:reverse"), toggle.rect);
if toggle.changed() {
asked = Some((Edit::PointReverse(gi, pi, reverse), None));
}
if !engine.assembly_components().is_empty() {
if let Some(mapped_asked) = self.maps_to_field(ui, gi, pi, point) {
asked = Some(mapped_asked);
}
}
});
asked
}
fn maps_to_field(
&mut self,
ui: &mut egui::Ui,
gi: usize,
pi: usize,
point: &brep_kernel::PortPoint,
) -> Option<(Edit, Option<String>)> {
let mut asked = None;
self.section(ui, "Assembly");
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Maps to").weak());
let mut text = optional_text(point, "mapsTo");
let field = ui
.add(egui::TextEdit::singleline(&mut text).desired_width(130.0))
.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.");
self.hits.insert(format!("qualify:point:{gi}:{pi}:mapsTo"), field.rect);
if field.changed() {
asked = Some((
Edit::PointText(gi, pi, "mapsTo", text),
Some(format!("point:{gi}:{pi}:mapsTo")),
));
}
});
asked
}
fn section(&mut self, ui: &mut egui::Ui, caption: &str) {
ui.add_space(3.0);
ui.label(egui::RichText::new(caption).small().strong());
}
fn seat_line(
&mut self,
ui: &mut egui::Ui,
engine: &EngineState,
address: &str,
point: &brep_kernel::PortPoint,
) {
let seated = !optional_text(point, "pointRef").trim().is_empty()
|| !optional_text(point, "directionRef").trim().is_empty();
let text = match engine.port_point_row(address) {
Some(row) if seated => format!(
"seated at ({}), facing ({})",
triple(row.seat.origin),
triple(row.seat.x),
),
Some(row) => format!("at ({}), facing ({})", triple(row.position), triple(row.direction)),
None => "not resolved by the last run".to_string(),
};
let label = ui.label(egui::RichText::new(text).weak().small());
self.hits.insert("qualify:seat".into(), label.rect);
}
fn gizmo_line(
&mut self,
ui: &mut egui::Ui,
engine: &EngineState,
address: &str,
target: Option<Target>,
) {
let text = match (target, engine.port_point_gizmo_refusal(address)) {
(Some(_), _) => "the move/rotate handles arm on the 3D surface".to_string(),
(None, Some(expression)) => format!(
"no handles: '{expression}' places this point, and a drag writes numbers"
),
(None, None) => {
"the move/rotate handles are on it — a drag writes the offset above".to_string()
}
};
let label = ui.label(egui::RichText::new(text).weak().small());
self.hits.insert("qualify:gizmo".into(), label.rect);
}
fn presence(
&mut self,
ui: &mut egui::Ui,
engine: &EngineState,
gi: usize,
pi: usize,
address: &str,
name: &str,
) {
let pinned = engine
.history
.pin_point_report()
.map(|report| report.pairs.iter().any(|pair| pair.point == address));
let padded = pad_numbers(engine).iter().any(|number| number == name);
for (key, state, present, absent) in [
(
"symbol",
pinned,
"a symbol pin carries this name",
"no symbol pin carries this name, so nothing on the schematic is this point",
),
(
"pads",
Some(padded),
"a pad carries this number",
"no pad carries this number",
),
(
"model",
Some(resolved(engine, address)),
"the last run resolved it in 3D",
"the last run did not resolve it, so there is no geometry to show",
),
] {
let (glyph, color, hover) = match state {
Some(true) => (GLYPH_PRESENT, OK_COLOR, present),
Some(false) => (GLYPH_ABSENT, WARN_COLOR, absent),
None => (
GLYPH_ABSENT,
ui.visuals().weak_text_color(),
"this part has no symbol",
),
};
let lamp = lamp(ui, glyph, color).on_hover_text(format!("{key}: {hover}"));
self.hits
.insert(format!("qualify:point:{gi}:{pi}:lamp:{key}"), lamp.rect);
}
}
fn jump_row(&mut self, ui: &mut egui::Ui, address: &str, target: Option<Target>) -> Option<Jump> {
let mut asked = None;
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Look at it in").weak());
for (jump, key, label, hover) in [
(Jump::Symbol, "symbol", "Symbol", "Open the schematic symbol with this point's PIN selected"),
(Jump::Pads, "pads", "Pads", "Open the footprint with this point's PAD selected"),
(Jump::Model, "model", "3D", "Show the model with this point's geometry selected"),
] {
let here = match jump {
Jump::Symbol => target == Some(Target::Symbol),
Jump::Pads => target == Some(Target::Pads),
Jump::Model => target.is_none(),
};
let button = ui
.add_enabled(!here, egui::Button::new(label).small())
.on_hover_text(format!("{hover} ({address})"))
.on_disabled_hover_text("You are looking at it");
self.hits.insert(format!("qualify:jump:{key}"), button.rect);
if button.clicked() {
asked = Some(jump);
}
}
});
asked
}
fn reports(&mut self, ui: &mut egui::Ui, engine: &EngineState) {
let mut problem = 0usize;
let mut line = |ui: &mut egui::Ui,
hits: &mut HashMap<String, egui::Rect>,
text: String,
color: egui::Color32| {
let label = ui.label(egui::RichText::new(text).weak().color(color));
hits.insert(format!("qualify:problem:{problem}"), label.rect);
problem += 1;
};
ui.label(egui::RichText::new("Pins and points").strong());
match engine.history.pin_point_report() {
None => {
ui.label(
egui::RichText::new("this part has no symbol, so its points bind to no pins")
.weak(),
);
}
Some(PinPointReport { pairs, problems }) => {
ui.label(
egui::RichText::new(format!(
"{} pin{} paired",
pairs.len(),
if pairs.len() == 1 { "" } else { "s" }
))
.weak()
.color(if problems.is_empty() { OK_COLOR } else { WARN_COLOR }),
);
for entry in &problems {
line(ui, &mut self.hits, entry.message(), WARN_COLOR);
}
}
}
if let Some(hold) = engine.history.pin_port_hold() {
line(ui, &mut self.hits, format!("not carried to the pins: {hold}"), WARN_COLOR);
}
ui.add_space(2.0);
ui.label(egui::RichText::new("Ports").strong());
match engine.ports_report() {
None => {
ui.label(egui::RichText::new("the model has not run yet").weak());
}
Some(report) => {
ui.label(
egui::RichText::new(format!(
"{} point{} resolved | {}",
report.points.len(),
if report.points.len() == 1 { "" } else { "s" },
if report.boundary {
"a boundary: a parent sees only what this document declares"
} else {
"transparent: a parent sees this document's children too"
},
))
.weak()
.color(if report.problems.is_empty() { OK_COLOR } else { ERROR_COLOR }),
)
.on_hover_text(if report.boundary {
"A board hides its components behind the ports it declares itself (`portBoundary`, or a `pcb` block)."
} else {
"An enclosure does not hide the parts in it: their points are exported namespaced."
});
for entry in &report.problems {
line(ui, &mut self.hits, entry.clone(), ERROR_COLOR);
}
}
}
if let Some(report) = engine.ports_report() {
for row in report.points.iter().filter(|row| row.maps_to.is_some()) {
let target = row.maps_to.clone().unwrap_or_default();
line(
ui,
&mut self.hits,
format!("{} \u{2192} {target}", row.address),
ui.visuals().weak_text_color(),
);
}
}
}
fn apply(&mut self, engine: &mut EngineState, edit: Edit, coalesce: Option<&str>) {
let mut block = engine
.history
.ports_block()
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
match edit {
Edit::AddGroup => {
block.push(serde_json::json!({
"name": unique_group_name(&block),
"purpose": PURPOSES[0],
"points": [],
}));
}
Edit::RemoveGroup(gi) => {
if gi < block.len() {
block.remove(gi);
}
}
Edit::GroupName(gi, name) => {
let was = block.get(gi).and_then(|group| group.get("name")).and_then(Value::as_str);
if let (Some(was), Some(address)) = (was, self.selected.clone()) {
if let Some((port, point)) = brep_kernel::split_address(&address) {
if port == was {
self.selected = Some(brep_kernel::port_address(&name, point));
}
}
}
set_group(&mut block, gi, "name", Value::String(name));
}
Edit::GroupPurpose(gi, purpose) => {
set_group(&mut block, gi, "purpose", Value::String(purpose));
}
Edit::GroupUnit(gi, unit) => match unit {
Some(unit) => set_group(&mut block, gi, "symbolUnit", Value::from(unit)),
None => remove_group_key(&mut block, gi, "symbolUnit"),
},
Edit::AddPoint(gi) => {
if let Some(points) = points_mut(&mut block, gi) {
let name = unique_point_name(points);
points.push(serde_json::json!({
"name": name,
"transform": { "position": [0.0, 0.0, 0.0], "rotationEuler": [0.0, 0.0, 0.0] },
}));
}
}
Edit::RemovePoint(gi, pi) => {
if let Some(points) = points_mut(&mut block, gi) {
if pi < points.len() {
points.remove(pi);
}
}
}
Edit::PointName(gi, pi, name) => {
if let Some(group) = block.get(gi).and_then(|group| group.get("name")).and_then(Value::as_str) {
self.selected = Some(brep_kernel::port_address(group, &name));
}
set_point(&mut block, gi, pi, "name", Value::String(name));
}
Edit::PointText(gi, pi, key, text) => match text.trim().is_empty() {
true => remove_point_key(&mut block, gi, pi, key),
false => set_point(&mut block, gi, pi, key, scalar(&text)),
},
Edit::PointTransform(gi, pi, key, axis, text) => {
set_transform_component(&mut block, gi, pi, key, axis, scalar(&text));
}
Edit::PointReverse(gi, pi, reverse) => match reverse {
true => set_point(&mut block, gi, pi, "reverseDirection", Value::Bool(true)),
false => remove_point_key(&mut block, gi, pi, "reverseDirection"),
},
}
if block.is_empty() {
engine.history.set_ports_block(None, None);
engine.push_notice("the last connection-point group is gone; the Qualify pane closes with it");
return;
}
engine.history.set_ports_block(Some(Value::Array(block)), coalesce);
}
fn jump(&mut self, engine: &mut EngineState, ecad: &mut Editors, jump: Jump) {
let Some(address) = self.selected.clone() else {
return;
};
let Some((_, name)) = brep_kernel::split_address(&address) else {
return;
};
let (target, workbench) = match jump {
Jump::Symbol => (Some(Target::Symbol), "symbol"),
Jump::Pads => (Some(Target::Pads), "pads"),
Jump::Model => (None, MODEL_WORKBENCH),
};
match target {
Some(target) => ecad.focus(target, name.to_string()),
None => {
if resolved(engine, &address) {
engine.select_by_name("solid", &address);
} else {
engine.push_notice(format!(
"{address} has no geometry yet \u{2014} the last run did not resolve it"
));
}
}
}
let already = match jump {
Jump::Model => Target::of_workbench(&engine.settings.workbench).is_none(),
_ => false,
};
if !already && engine.settings.workbench != workbench {
let _ = engine
.apply_settings_json(&serde_json::json!({ "workbench": workbench }).to_string());
}
}
fn adopt_editor_selection(&mut self, engine: &EngineState, ecad: &Editors, target: Option<Target>) {
let (surface, label) = match target {
None => (MODEL_SURFACE, engine.selected_port_point()),
Some(target @ (Target::Symbol | Target::Pads)) => {
(surface_of(target), editor_selection(ecad, target))
}
Some(_) => return,
};
let Some(label) = label else {
if self.adopted.as_ref().is_some_and(|(from, _)| *from == surface) {
self.adopted = None;
}
return;
};
if self.adopted.as_ref().is_some_and(|(from, what)| *from == surface && *what == label) {
return;
}
self.adopted = Some((surface, label.clone()));
if surface == MODEL_SURFACE {
self.selected = Some(label);
return;
}
let unit = engine
.history
.pins()
.unwrap_or_default()
.into_iter()
.find(|pin| pin.label == label)
.map(|pin| pin.unit)
.unwrap_or(1);
if let Some(group) = engine.history.port_group_for_unit(unit) {
self.selected = Some(brep_kernel::port_address(&group, &label));
}
}
}
fn resolved(engine: &EngineState, address: &str) -> bool {
engine
.ports_report()
.is_some_and(|report| report.points.iter().any(|row| row.address == address))
}
fn editor_selection(ecad: &Editors, target: Target) -> Option<String> {
match target {
Target::Symbol => ecad.symbol.selected_pin().map(str::to_string),
Target::Pads => ecad.pads.selected_pad().map(str::to_string),
Target::Diagram | Target::Pcb => None,
}
}
fn surface_of(target: Target) -> &'static str {
match target {
Target::Symbol => "symbol",
Target::Pads => "pads",
Target::Diagram => "diagram",
Target::Pcb => "pcb",
}
}
fn set_group(block: &mut [Value], gi: usize, key: &str, value: Value) {
if let Some(object) = block.get_mut(gi).and_then(Value::as_object_mut) {
object.insert(key.into(), value);
}
}
fn remove_group_key(block: &mut [Value], gi: usize, key: &str) {
if let Some(object) = block.get_mut(gi).and_then(Value::as_object_mut) {
object.remove(key);
}
}
fn points_mut(block: &mut [Value], gi: usize) -> Option<&mut Vec<Value>> {
let group = block.get_mut(gi)?.as_object_mut()?;
group.entry("points").or_insert_with(|| Value::Array(Vec::new())).as_array_mut()
}
fn point_mut<'a>(block: &'a mut [Value], gi: usize, pi: usize) -> Option<&'a mut serde_json::Map<String, Value>> {
points_mut(block, gi)?.get_mut(pi)?.as_object_mut()
}
fn set_point(block: &mut [Value], gi: usize, pi: usize, key: &str, value: Value) {
if let Some(point) = point_mut(block, gi, pi) {
point.insert(key.into(), value);
}
}
fn remove_point_key(block: &mut [Value], gi: usize, pi: usize, key: &str) {
if let Some(point) = point_mut(block, gi, pi) {
point.remove(key);
}
}
fn set_transform_component(
block: &mut [Value],
gi: usize,
pi: usize,
key: &str,
axis: usize,
value: Value,
) {
let Some(point) = point_mut(block, gi, pi) else {
return;
};
let transform = point
.entry("transform")
.or_insert_with(|| Value::Object(Default::default()));
if !transform.is_object() {
*transform = Value::Object(Default::default());
}
let Some(transform) = transform.as_object_mut() else {
return;
};
let axes = transform
.entry(key.to_string())
.or_insert_with(|| Value::Array(vec![Value::from(0.0), Value::from(0.0), Value::from(0.0)]));
if !axes.is_array() {
*axes = Value::Array(vec![Value::from(0.0), Value::from(0.0), Value::from(0.0)]);
}
if let Some(axes) = axes.as_array_mut() {
while axes.len() < 3 {
axes.push(Value::from(0.0));
}
axes[axis] = value;
}
}
fn scalar(text: &str) -> Value {
let text = text.trim();
if text.is_empty() {
return Value::from(0.0);
}
match text.parse::<f64>() {
Ok(number) => Value::from(number),
Err(_) => Value::String(text.to_string()),
}
}
fn transform_component(point: &brep_kernel::PortPoint, key: &str, axis: usize) -> String {
match point.transform.get(key).and_then(Value::as_array).and_then(|axes| axes.get(axis)) {
Some(Value::Number(number)) => match number.as_f64() {
Some(value) => brep_render::formatting::compact_decimal(value, 6),
None => number.to_string(),
},
Some(Value::String(expression)) => expression.clone(),
_ => "0".to_string(),
}
}
fn optional_text(point: &brep_kernel::PortPoint, key: &str) -> String {
match key {
"pointRef" => point.point_ref.clone().unwrap_or_default(),
"directionRef" => point.direction_ref.clone().unwrap_or_default(),
"mapsTo" => point.maps_to.clone().unwrap_or_default(),
"extension" => point.extension.map(|v| v.to_string()).unwrap_or_default(),
"displayLength" => point.display_length.map(|v| v.to_string()).unwrap_or_default(),
_ => String::new(),
}
}
fn lamp(ui: &mut egui::Ui, glyph: &str, color: egui::Color32) -> egui::Response {
match crate::icon_text::glyph(ui, glyph, color) {
Some(art) => ui.add(art),
None => ui.label(egui::RichText::new(glyph).color(color)),
}
}
fn triple(values: [f64; 3]) -> String {
values
.iter()
.map(|value| {
let rounded = (value * 1000.0).round() / 1000.0;
if rounded == 0.0 { "0".to_string() } else { format!("{rounded}") }
})
.collect::<Vec<_>>()
.join(", ")
}
fn pad_numbers(engine: &EngineState) -> Vec<String> {
engine
.history
.pads_block()
.and_then(|block| block.get("pads"))
.and_then(Value::as_array)
.map(|pads| {
pads.iter()
.filter_map(|pad| pad.get("number").and_then(Value::as_str))
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn unique_group_name(block: &[Value]) -> String {
let taken: Vec<&str> = block
.iter()
.filter_map(|group| group.get("name").and_then(Value::as_str))
.collect();
(1..).map(|n| format!("J{n}")).find(|name| !taken.contains(&name.as_str())).unwrap_or_default()
}
fn unique_point_name(points: &[Value]) -> String {
let taken: Vec<&str> = points
.iter()
.filter_map(|point| point.get("name").and_then(Value::as_str))
.collect();
(1..).map(|n| n.to_string()).find(|name| !taken.contains(&name.as_str())).unwrap_or_default()
}
pub static HIT_KEYS: &[HitKeyDoc] = &[
HitKeyDoc { panel: "qualify", prefix: "qualify:panel:clip", meaning: "the visible region of the pane", command: None },
HitKeyDoc { panel: "qualify", prefix: "qualify:add_group", meaning: "declare another port group", command: None },
HitKeyDoc { panel: "qualify", prefix: "qualify:group:", meaning: "a port group's name, purpose, symbol unit, add-point or remove", command: None },
HitKeyDoc { panel: "qualify", prefix: "qualify:point:", meaning: "a connection point's select, name, remove, or one of the selected point's fields", command: None },
HitKeyDoc { panel: "qualify", prefix: "qualify:jump:", meaning: "carry the selected point to the symbol, the pads or the 3D model", command: None },
HitKeyDoc { panel: "qualify", prefix: "qualify:hint", meaning: "what selecting a point is for, shown while none is", command: None },
HitKeyDoc { panel: "qualify", prefix: "qualify:seat", meaning: "what the last run made of the selected point's seat", command: None },
HitKeyDoc { panel: "qualify", prefix: "qualify:gizmo", meaning: "whether the move/rotate handles are on the selected point", command: None },
HitKeyDoc { panel: "qualify", prefix: "qualify:problem:", meaning: "one line of the pin/point or ports report", command: None },
];