use crate::automation::hit_keys::HitKeyDoc;
use crate::form_view::{form_view, FormViewSpec};
use crate::panels::tree::{self, TreeRow};
use crate::panels::update_components::UpdateComponents;
use crate::store::ModelStore;
use brep_render::assembly_status;
use brep_render::engine_state::EngineState;
use brep_render::features::form_fields_from_schema;
use eframe::egui;
use serde_json::Value;
use std::collections::HashMap;
const DIALOG_HOVER_OWNER: &str = "constraints";
const DELETE_RED: egui::Color32 = egui::Color32::from_rgb(0xd8, 0x54, 0x4f);
struct ConstraintRow {
id: String,
type_id: String,
icon: String,
label: String,
title: String,
enabled: bool,
open: bool,
status: String,
input_params: Value,
}
enum Action {
Add(String, Value),
SetEnabled(String, bool),
SetOpen(String, bool),
Delete(String),
Move(String, usize),
UpdateParams(String, Value),
Solve,
UpdateComponents,
BeginRefSelect {
id: String,
path: Vec<String>,
label: String,
filter: Vec<String>,
multiple: bool,
seed: Vec<String>,
},
}
#[derive(Default)]
pub struct AssemblyConstraintsPanel {
hits: HashMap<String, egui::Rect>,
drag_src: Option<usize>,
}
impl AssemblyConstraintsPanel {
pub fn new() -> Self {
Self::default()
}
pub fn show(
&mut self,
ui: &mut egui::Ui,
state: &mut EngineState,
model_store: &dyn ModelStore,
updates: &mut UpdateComponents,
) {
self.hits.clear();
self.hits.insert("acon:panel:clip".into(), ui.clip_rect());
state.ensure_assembly_synced();
let catalogue = brep_render::brep_kernel::constraint_schema_catalogue();
let schemas: Vec<Value> = catalogue.as_array().cloned().unwrap_or_default();
let statuses = state.assembly_statuses_value();
let overlay = state.assembly_overlay_value();
let dof = state.assembly_dof_value();
let constraint_state = state.assembly_state_value();
let rows = snapshot_rows(&constraint_state, &statuses, &overlay, &schemas);
ui.spacing_mut().item_spacing.y = 2.0;
let mut action: Option<Action> = None;
let mut close: Option<String> = None;
let mut hover: Option<String> = None;
match rows.iter().find(|row| row.open) {
Some(row) => self.show_form(ui, row, &schemas, &mut action, &mut close, &mut hover),
None => self.show_tree(
ui,
state,
model_store,
updates,
&rows,
&schemas,
&dof,
&mut action,
),
}
let result: Result<(), String> = match action {
Some(Action::Add(type_id, params)) => state
.assembly_add_constraint(&type_id, ¶ms.to_string())
.map(|_id| ()),
Some(Action::SetEnabled(id, enabled)) => {
state.assembly_set_constraint_enabled(&id, enabled)
}
Some(Action::SetOpen(id, open)) => state.assembly_set_constraint_open(&id, open),
Some(Action::Delete(id)) => state.assembly_remove_constraint(&id),
Some(Action::Move(id, index)) => state.assembly_move_constraint(&id, index),
Some(Action::UpdateParams(id, params)) => {
state.assembly_update_constraint(&id, ¶ms.to_string())
}
Some(Action::Solve) => state.assembly_run_solve(),
Some(Action::UpdateComponents) => updates.run(state, model_store).map(|_| ()),
Some(Action::BeginRefSelect {
id,
path,
label,
filter,
multiple,
seed,
}) => {
state.begin_ref_select_for_constraint(&id, path, label, filter, multiple, seed);
Ok(())
}
None => Ok(()),
};
if let Err(error) = result {
state.push_notice(format!("Assembly constraints: {error}"));
}
if let Some(id) = close {
if let Err(error) = state.assembly_set_constraint_open(&id, false) {
state.push_notice(format!("Assembly constraints: {error}"));
}
}
let hover_changed = match &hover {
Some(name) => state.hover_entity_by_name(DIALOG_HOVER_OWNER, name),
None => state.dialog_hover_end(DIALOG_HOVER_OWNER),
};
if hover_changed {
ui.ctx().request_repaint();
}
if crate::automation::registry::enabled() {
let listing: Vec<Value> = rows
.iter()
.map(|row| {
serde_json::json!({
"id": row.id,
"type": row.type_id,
"label": row.label,
"icon": row.icon,
"enabled": row.enabled,
"open": row.open,
"status": row.status,
"statusLabel": assembly_status::status_label(&row.status),
"statusColor": assembly_status::status_color_hex(&row.status),
})
})
.collect();
crate::automation::registry::publish("__brepAssemblyConstraints", "assembly constraint rows and DOF summary",
&serde_json::json!({
"rows": listing,
"dof": dof,
"updateCount": updates.outdated_count(),
})
.to_string(),
);
crate::automation::registry::publish("__brepAssemblyConstraintsHit", "assembly constraints panel widget rects (acon:)", &self.hits_json());
}
}
#[allow(clippy::too_many_arguments)]
fn show_tree(
&mut self,
ui: &mut egui::Ui,
state: &mut EngineState,
model_store: &dyn ModelStore,
updates: &mut UpdateComponents,
rows: &[ConstraintRow],
schemas: &[Value],
dof: &Value,
action: &mut Option<Action>,
) {
egui::CollapsingHeader::new("Solver")
.id_salt("acon-solver")
.default_open(true)
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 4.0;
let full = egui::vec2(ui.available_width(), 0.0);
let solve = ui
.add(crate::icon_text::icon_button(ui, "\u{25B6} Solve").min_size(full))
.on_hover_text(
"Solve the assembly constraints now (works with auto-solve off)",
);
self.hits.insert("acon:solve".into(), solve.rect);
if solve.clicked() {
*action = Some(Action::Solve);
}
let mut auto = state.settings.assembly_auto_solve;
let auto_resp = ui
.checkbox(&mut auto, "Auto-solve")
.on_hover_text("Re-solve after every constraint change");
self.hits.insert("acon:autosolve".into(), auto_resp.rect);
if auto_resp.changed() {
state.settings.assembly_auto_solve = auto;
state.settings_generation = state.settings_generation.wrapping_add(1);
let _ = model_store.write(crate::store::SETTINGS_KEY, &state.settings_json());
}
let mut graphics = state.settings.show_constraint_graphics;
let graphics_resp = ui
.checkbox(&mut graphics, "Show constraint graphics")
.on_hover_text("Draw per-constraint leaders + labels in the viewport");
self.hits.insert("acon:graphics".into(), graphics_resp.rect);
if graphics_resp.changed() {
state.settings.show_constraint_graphics = graphics;
state.settings_generation = state.settings_generation.wrapping_add(1);
state.dirty = true;
let _ = model_store.write(crate::store::SETTINGS_KEY, &state.settings_json());
}
let outdated = updates.outdated_count();
let mut hover =
"Refresh outdated parts from their source — every instance follows".to_string();
if !updates.missing().is_empty() {
hover.push_str(&format!(
"; no source document for {}",
updates.missing().join(", ")
));
}
let update = ui
.add_enabled(
outdated > 0,
egui::Button::new(format!("Update components ({outdated})"))
.min_size(full),
)
.on_hover_text(hover.clone())
.on_disabled_hover_text(hover);
self.hits.insert("acon:update".into(), update.rect);
if update.clicked() {
*action = Some(Action::UpdateComponents);
}
ui.label(egui::RichText::new(dof_summary(dof)).weak());
});
ui.add_space(4.0);
tree::node(
ui,
TreeRow {
guides: &[],
is_last: true,
expandable: true,
expanded: true,
root: true,
glyph: None,
label: "Assembly Constraints",
selected: false,
draggable: false,
tint: None,
},
|ui| {
ui.label(egui::RichText::new(format!("{}", rows.len())).weak());
},
);
if rows.is_empty() {
let g = tree::child_guides(&[], true);
tree::node(ui, TreeRow::leaf(&g, true, "(no constraints)"), |_| {});
}
let mut row_rects: Vec<(usize, egui::Rect)> = Vec::with_capacity(rows.len());
let mut drag_move: Option<(usize, usize)> = None;
let n = rows.len();
for (i, row) in rows.iter().enumerate() {
let rect = self.render_row(ui, row, i + 1 == n, action);
row_rects.push((i, rect));
}
if let Some(src) = self.drag_src {
let released = ui.input(|i| i.pointer.any_released());
let ptr = ui.input(|i| i.pointer.interact_pos());
match (ptr, released) {
(Some(p), released) => {
let target = row_rects
.iter()
.min_by(|a, b| {
let da = (a.1.center().y - p.y).abs();
let db = (b.1.center().y - p.y).abs();
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(index, _)| *index)
.unwrap_or(src);
if released && target == src {
if let Some(row) = rows.get(src) {
*action = Some(Action::SetOpen(row.id.clone(), true));
}
self.drag_src = None;
} else if released {
drag_move = Some((src, target));
self.drag_src = None;
} else if target != src {
if let Some((_, rect)) =
row_rects.iter().find(|(index, _)| *index == target)
{
let y = if target >= src { rect.bottom() } else { rect.top() };
ui.painter().hline(
rect.x_range(),
y,
egui::Stroke::new(2.0, ui.visuals().selection.bg_fill),
);
}
}
}
(None, true) => self.drag_src = None,
_ => {}
}
}
if let Some((src, target)) = drag_move {
if src != target {
if let Some(row) = rows.get(src) {
*action = Some(Action::Move(row.id.clone(), target));
}
}
}
ui.add_space(6.0);
let mut add_type: Option<String> = None;
let combo = egui::ComboBox::from_id_salt("acon-add")
.selected_text("+ Add constraint")
.width(ui.available_width())
.show_ui(ui, |ui| {
for schema in schemas {
let type_id = schema.get("type").and_then(Value::as_str).unwrap_or("");
let label = schema
.get("longName")
.and_then(Value::as_str)
.unwrap_or(type_id);
let item = crate::icon_text::selectable_icon_label(ui, false, label);
self.hits
.insert(format!("acon:add:{type_id}"), item.rect);
if item.clicked() {
add_type = Some(type_id.to_string());
}
}
});
self.hits.insert("acon:add".into(), combo.response.rect);
if let Some(type_id) = add_type {
let seed = seeded_elements(state, schemas, &type_id);
*action = Some(Action::Add(type_id, seed));
}
}
fn render_row(
&mut self,
ui: &mut egui::Ui,
row: &ConstraintRow,
is_last: bool,
action: &mut Option<Action>,
) -> egui::Rect {
let mut enabled = row.enabled;
let mut enabled_rect = egui::Rect::NOTHING;
let mut enabled_clicked = false;
let mut del_rect = egui::Rect::NOTHING;
let mut del_clicked = false;
let mut edit_rect = egui::Rect::NOTHING;
let mut edit_clicked = false;
let status_label = assembly_status::status_label(&row.status);
let [r, g, b] = assembly_status::status_color_rgb(&row.status);
let status_color = egui::Color32::from_rgb(r, g, b);
let resp = tree::node(
ui,
TreeRow::branch(&[], is_last, row.open, &row.label)
.glyph(Some(row.icon.as_str()).filter(|icon| !icon.is_empty()))
.draggable(true),
|ui| {
let del = ui.add(
crate::icon_text::icon_button_colored(ui, "\u{2715}", Some(DELETE_RED))
.stroke(egui::Stroke::new(1.0, DELETE_RED))
.small(),
);
del_rect = del.rect;
del_clicked = del.clicked();
ui.add_space(4.0);
let edit = ui
.add(crate::icon_text::icon_button(ui, "\u{270E}").small())
.on_hover_text("Edit this constraint");
edit_rect = edit.rect;
edit_clicked = edit.clicked();
ui.add_space(4.0);
let cb = ui
.add(egui::Checkbox::new(&mut enabled, ""))
.on_hover_text("Enable/disable this constraint");
enabled_rect = cb.rect;
enabled_clicked = cb.clicked();
ui.label(egui::RichText::new(status_label).color(status_color).small());
},
);
self.hits.insert(format!("acon:row:{}", row.id), resp.label.rect);
self.hits.insert(format!("acon:box:{}", row.id), resp.box_rect);
self.hits.insert(format!("acon:del:{}", row.id), del_rect);
self.hits.insert(format!("acon:edit:{}", row.id), edit_rect);
self.hits
.insert(format!("acon:enable:{}", row.id), enabled_rect);
if del_clicked {
*action = Some(Action::Delete(row.id.clone()));
} else if enabled_clicked {
*action = Some(Action::SetEnabled(row.id.clone(), enabled));
} else if edit_clicked || resp.toggled || resp.label.clicked() {
*action = Some(Action::SetOpen(row.id.clone(), !row.open));
}
if resp.label.drag_started() {
self.drag_src = Some(index_of_hit(&self.hits, &row.id));
}
resp.row_rect
}
fn show_form(
&mut self,
ui: &mut egui::Ui,
row: &ConstraintRow,
schemas: &[Value],
action: &mut Option<Action>,
close: &mut Option<String>,
hover: &mut Option<String>,
) {
let Some(schema) = schemas.iter().find(|schema| {
schema.get("type").and_then(Value::as_str) == Some(row.type_id.as_str())
}) else {
*close = Some(row.id.clone());
return;
};
let fields = form_fields_from_schema(schema);
let mut params = row.input_params.clone();
let status_label = assembly_status::status_label(&row.status);
let [r, g, b] = assembly_status::status_color_rgb(&row.status);
let spec = FormViewSpec {
title: &row.title,
subtitle: None,
fields: &fields,
banner: Some((status_label, egui::Color32::from_rgb(r, g, b))),
trailing: None,
exit_label: "Return to tree",
extra: None,
rollback: false,
hits_prefix: "acon:",
};
let out = form_view(ui, &spec, &mut params, Some(&mut self.hits));
let anchor = self
.hits
.get("acon:form:feature")
.map(|rect| rect.min)
.unwrap_or(egui::Pos2::ZERO);
self.hits.insert(
format!("acon:form:constraint:{}", row.id),
egui::Rect::from_min_size(anchor, egui::Vec2::ZERO),
);
if let Some(activate) = out.ref_activate {
*action = Some(Action::BeginRefSelect {
id: row.id.clone(),
path: activate.path,
label: activate.label,
filter: activate.filter,
multiple: activate.multiple,
seed: activate.seed,
});
}
if out.changed {
*action = Some(Action::UpdateParams(row.id.clone(), params));
}
*hover = out.hovered_entity;
if out.exit_clicked {
*close = Some(row.id.clone());
}
debug_assert!(
out.button_clicked.is_none(),
"no constraint schema declares a button param"
);
debug_assert!(!out.roll_to_tip, "constraints declare rollback: false");
}
pub fn hits_json(&self) -> String {
crate::automation::hit_rects::hits_json(&self.hits)
}
}
fn index_of_hit(hits: &HashMap<String, egui::Rect>, id: &str) -> usize {
let Some(own) = hits.get(&format!("acon:row:{id}")) else {
return 0;
};
hits.iter()
.filter(|(key, _)| key.starts_with("acon:row:"))
.filter(|(_, rect)| rect.center().y < own.center().y)
.count()
}
fn snapshot_rows(
constraint_state: &Value,
statuses: &Value,
overlay: &Value,
schemas: &[Value],
) -> Vec<ConstraintRow> {
let status_of = |id: &str| -> String {
statuses
.as_array()
.and_then(|rows| {
rows.iter().find(|row| {
row.get("id").and_then(Value::as_str) == Some(id)
})
})
.and_then(|row| row.get("status").and_then(Value::as_str))
.unwrap_or("")
.to_string()
};
let overlay_value = |id: &str| -> Option<f64> {
overlay
.as_array()
.and_then(|rows| {
rows.iter()
.find(|row| row.get("id").and_then(Value::as_str) == Some(id))
})
.and_then(|row| row.get("value").and_then(Value::as_f64))
};
let schema_of = |type_id: &str| -> Option<&Value> {
schemas
.iter()
.find(|schema| schema.get("type").and_then(Value::as_str) == Some(type_id))
};
let label_of = |type_id: &str| -> String {
schema_of(type_id)
.and_then(|schema| schema.get("label").and_then(Value::as_str))
.map(str::to_string)
.unwrap_or_else(|| type_id.to_string())
};
let icon_of = |type_id: &str| -> String {
schema_of(type_id)
.and_then(|schema| schema.get("icon").and_then(Value::as_str))
.map(str::to_string)
.unwrap_or_default()
};
constraint_state
.get("constraints")
.and_then(Value::as_array)
.map(|constraints| {
constraints
.iter()
.map(|entry| {
let type_id = entry
.get("type")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let params = entry
.get("inputParams")
.cloned()
.unwrap_or_else(|| Value::Object(serde_json::Map::new()));
let id = params
.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let suffix = match type_id.as_str() {
"distance" => overlay_value(&id)
.or_else(|| params.get("distance").and_then(Value::as_f64))
.map(|value| format!(" {value:.3}"))
.unwrap_or_default(),
"angle" => overlay_value(&id)
.or_else(|| params.get("angle").and_then(Value::as_f64))
.map(|value| format!(" {value:.1}\u{00B0}"))
.unwrap_or_default(),
_ => String::new(),
};
let name = label_of(&type_id);
let title = format!("{name} {id}");
ConstraintRow {
label: format!("{id} {name}{suffix}"),
title,
icon: icon_of(&type_id),
enabled: entry.get("enabled").and_then(Value::as_bool).unwrap_or(true),
open: entry.get("open").and_then(Value::as_bool).unwrap_or(false),
status: status_of(&id),
input_params: params,
id,
type_id,
}
})
.collect()
})
.unwrap_or_default()
}
fn dof_summary(dof: &Value) -> String {
if dof.get("ok").and_then(Value::as_bool) == Some(false) {
let error = dof.get("error").and_then(Value::as_str).unwrap_or("solve failed");
return format!("Solve failed: {error}");
}
let mates = dof.get("mates").and_then(Value::as_u64).unwrap_or(0);
let Some(free) = dof.get("dof").and_then(Value::as_u64) else {
return if mates == 0 {
"No constraints solved yet".to_string()
} else {
format!("{mates} mate(s)")
};
};
let rank = dof.get("rank").and_then(Value::as_u64).unwrap_or(0);
let redundant = dof.get("redundant").and_then(Value::as_u64).unwrap_or(0);
let wording = match (free, redundant) {
(0, 0) => "fully constrained".to_string(),
(0, r) => format!("over-constrained ({r} redundant)"),
(d, 0) => format!("under-constrained ({d} DOF free)"),
(d, r) => format!("under-constrained ({d} DOF free, {r} redundant)"),
};
format!("DOF {free} \u{00B7} rank {rank} \u{00B7} redundant {redundant} \u{2014} {wording}")
}
pub(crate) fn seeded_elements(state: &mut EngineState, schemas: &[Value], type_id: &str) -> Value {
let Some(schema) = schemas
.iter()
.find(|schema| schema.get("type").and_then(Value::as_str) == Some(type_id))
else {
return serde_json::json!({});
};
let elements_spec = schema
.get("inputParamsSchema")
.and_then(|params| params.get("elements"));
let filter: Vec<String> = elements_spec
.and_then(|spec| spec.get("selectionFilter"))
.and_then(Value::as_array)
.map(|kinds| {
kinds
.iter()
.filter_map(|kind| kind.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let cap = elements_spec
.and_then(|spec| spec.get("maxSelections"))
.and_then(Value::as_u64)
.unwrap_or(2) as usize;
let selection: Value = serde_json::from_str(&state.selection_json()).unwrap_or(Value::Null);
let names = |key: &str| -> Vec<String> {
selection[key]
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
};
let mut seeded: Vec<String> = Vec::new();
let mut push = |src: Vec<String>| {
for name in src {
if seeded.len() < cap && !seeded.contains(&name) {
seeded.push(name);
}
}
};
for kind in &filter {
match kind.as_str() {
"COMPONENT" => {
let owners: Vec<String> = names("solids")
.into_iter()
.filter_map(|solid| {
state
.assembly_components()
.iter()
.find(|record| record.solids.contains(&solid))
.map(|record| record.id.clone())
})
.collect();
push(owners);
}
"FACE" => push(names("faces")),
"EDGE" => push(names("edges")),
_ => {}
}
}
serde_json::json!({ "elements": seeded })
}
pub static HIT_KEYS: &[HitKeyDoc] = &[
HitKeyDoc { panel: "assemblyconstraints", prefix: "acon:add", meaning: "add a constraint", command: Some("assembly_add_constraint") },
HitKeyDoc { panel: "assemblyconstraints", prefix: "acon:solve", meaning: "solve", command: Some("assembly_solve") },
HitKeyDoc { panel: "assemblyconstraints", prefix: "acon:autosolve", meaning: "toggle auto-solve", command: Some("settings_set") },
HitKeyDoc { panel: "assemblyconstraints", prefix: "acon:update", meaning: "update components", command: Some("component_update") },
HitKeyDoc { panel: "assemblyconstraints", prefix: "acon:graphics", meaning: "toggle overlay graphics", command: Some("settings_set") },
HitKeyDoc { panel: "assemblyconstraints", prefix: "acon:row:", meaning: "select a constraint row (acon:row:i)", command: None },
HitKeyDoc { panel: "assemblyconstraints", prefix: "acon:edit:", meaning: "open a constraint's form", command: None },
HitKeyDoc { panel: "assemblyconstraints", prefix: "acon:del:", meaning: "delete a constraint", command: Some("assembly_remove_constraint") },
HitKeyDoc { panel: "assemblyconstraints", prefix: "acon:box:", meaning: "a constraint's enable box", command: Some("assembly_set_constraint_enabled") },
HitKeyDoc { panel: "assemblyconstraints", prefix: "acon:panel:clip", meaning: "the visible region of the pane", command: None },
HitKeyDoc { panel: "assemblyconstraints", prefix: "acon:", meaning: "a constraint form control", command: Some("assembly_update_constraint") },
];