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 DELETE_RED: egui::Color32 = egui::Color32::from_rgb(0xd8, 0x54, 0x4f);
struct ConstraintRow {
id: String,
type_id: 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;
match rows.iter().find(|row| row.open) {
Some(row) => self.show_form(ui, row, &schemas, &mut action, &mut close),
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}"));
}
}
#[cfg(target_arch = "wasm32")]
{
let listing: Vec<Value> = rows
.iter()
.map(|row| {
serde_json::json!({
"id": row.id,
"type": row.type_id,
"label": row.label,
"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();
publish(
"__brepAssemblyConstraints",
&serde_json::json!({
"rows": listing,
"dof": dof,
"updateCount": updates.outdated_count(),
})
.to_string(),
);
publish("__brepAssemblyConstraintsHit", &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(egui::Button::new("\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,
},
|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("\u{FF0B} 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 = ui.selectable_label(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).draggable(true),
|ui| {
let del = ui.add(
egui::Button::new(egui::RichText::new("\u{2715}").color(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(egui::Button::new("\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>,
) {
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",
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));
}
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");
}
#[cfg(target_arch = "wasm32")]
pub fn hits_json(&self) -> String {
let map: serde_json::Map<String, Value> = self
.hits
.iter()
.map(|(k, r)| {
(
k.clone(),
serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
)
})
.collect();
Value::Object(map).to_string()
}
}
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 label_of = |type_id: &str| -> String {
schemas
.iter()
.find(|schema| schema.get("type").and_then(Value::as_str) == Some(type_id))
.and_then(|schema| schema.get("longName").and_then(Value::as_str))
.map(|long| long.trim_start_matches(|c: char| !c.is_alphanumeric()).trim().to_string())
.unwrap_or_else(|| type_id.to_string())
};
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 title = format!("{} {id}", label_of(&type_id));
ConstraintRow {
label: format!("{title}{suffix}"),
title,
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 })
}
#[cfg(target_arch = "wasm32")]
fn publish(name: &str, json: &str) {
if let Some(win) = web_sys::window() {
let _ = js_sys::Reflect::set(
&win,
&wasm_bindgen::JsValue::from_str(name),
&wasm_bindgen::JsValue::from_str(json),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use brep_render::engine_state::ComponentInsert;
fn part_document() -> String {
serde_json::json!({
"expressions": "",
"configurator": {},
"features": [{
"type": "P.CU",
"inputParams": {
"id": "Part",
"sizeX": 2.0, "sizeY": 3.0, "sizeZ": 4.0,
"transform": {
"position": [0.0, 0.0, 0.0],
"rotationEuler": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"boolean": { "targets": [], "operation": "NONE" }
},
"persistentData": {}
}]
})
.to_string()
}
fn two_instance_state() -> EngineState {
brep_render::brep_kernel::clear_history_cache();
let mut state = EngineState::new();
state
.insert_component(ComponentInsert::New {
name: "bracket",
source_key: "bracket",
source_signature: "sig-1",
document_json: &part_document(),
})
.unwrap();
state
.insert_component(ComponentInsert::Existing { part_name: "bracket" })
.unwrap();
state
}
fn run_frame(
ctx: &egui::Context,
panel: &mut AssemblyConstraintsPanel,
state: &mut EngineState,
model_store: &dyn ModelStore,
updates: &mut UpdateComponents,
events: Vec<egui::Event>,
) {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(800.0, 600.0),
)),
events,
..Default::default()
};
let _ = ctx.run_ui(raw, |ui| {
panel.show(ui, state, model_store, updates)
});
}
fn click_at(
ctx: &egui::Context,
panel: &mut AssemblyConstraintsPanel,
state: &mut EngineState,
model_store: &dyn ModelStore,
updates: &mut UpdateComponents,
pos: egui::Pos2,
) {
run_frame(
ctx,
panel,
state,
model_store,
updates,
vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
},
],
);
run_frame(
ctx,
panel,
state,
model_store,
updates,
vec![egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
}],
);
}
fn add_fixed_through_the_panel(
ctx: &egui::Context,
panel: &mut AssemblyConstraintsPanel,
state: &mut EngineState,
store: &dyn ModelStore,
updates: &mut UpdateComponents,
) {
run_frame(ctx, panel, state, store, updates, vec![]);
let add = *panel.hits.get("acon:add").expect("add combo rect");
click_at(ctx, panel, state, store, updates, add.center());
run_frame(ctx, panel, state, store, updates, vec![]);
let item = *panel
.hits
.get("acon:add:fixed")
.expect("fixed item rect while open");
click_at(ctx, panel, state, store, updates, item.center());
run_frame(ctx, panel, state, store, updates, vec![]);
let back = *panel
.hits
.get("acon:form:return")
.expect("the add landed in form mode");
click_at(ctx, panel, state, store, updates, back.center());
run_frame(ctx, panel, state, store, updates, vec![]);
}
#[test]
fn add_toggle_delete_through_the_panel() {
let ctx = egui::Context::default();
let mut state = two_instance_state();
let mut panel = AssemblyConstraintsPanel::new();
let store = crate::store::MemModelStore::new();
let mut updates = UpdateComponents::new();
add_fixed_through_the_panel(&ctx, &mut panel, &mut state, &store, &mut updates);
let statuses = state.assembly_statuses_value();
assert_eq!(statuses[0]["id"], "FIXD1", "id minted through the panel add");
let enable = *panel.hits.get("acon:enable:FIXD1").expect("enable rect");
click_at(&ctx, &mut panel, &mut state, &store, &mut updates, enable.center());
let statuses = state.assembly_statuses_value();
assert_eq!(statuses[0]["enabled"], false);
assert_eq!(statuses[0]["status"], "disabled");
run_frame(&ctx, &mut panel, &mut state, &store, &mut updates, vec![]);
let del = *panel.hits.get("acon:del:FIXD1").expect("delete rect");
click_at(&ctx, &mut panel, &mut state, &store, &mut updates, del.center());
let statuses = state.assembly_statuses_value();
assert_eq!(statuses.as_array().map(Vec::len), Some(0), "row deleted");
}
#[test]
fn opening_a_constraint_takes_over_the_panel_and_returning_restores_the_tree() {
let ctx = egui::Context::default();
let mut state = two_instance_state();
let mut panel = AssemblyConstraintsPanel::new();
let store = crate::store::MemModelStore::new();
let mut updates = UpdateComponents::new();
add_fixed_through_the_panel(&ctx, &mut panel, &mut state, &store, &mut updates);
assert!(panel.hits.contains_key("acon:row:FIXD1"), "the tree row");
assert!(panel.hits.contains_key("acon:solve"), "the solver header");
assert!(panel.hits.contains_key("acon:add"), "the add dropdown");
assert!(!panel.hits.contains_key("acon:form:return"), "no form yet");
let edit = *panel.hits.get("acon:edit:FIXD1").expect("edit rect");
click_at(&ctx, &mut panel, &mut state, &store, &mut updates, edit.center());
run_frame(&ctx, &mut panel, &mut state, &store, &mut updates, vec![]);
assert_eq!(
state.assembly_state_value()["constraints"][0]["open"],
serde_json::json!(true),
"the edit button set the kernel's open flag"
);
assert!(
panel.hits.contains_key("acon:form:constraint:FIXD1"),
"the form says WHICH constraint it is showing: {:?}",
panel.hits.keys().collect::<Vec<_>>()
);
for gone in ["acon:row:FIXD1", "acon:solve", "acon:add", "acon:del:FIXD1"] {
assert!(!panel.hits.contains_key(gone), "{gone} is gone in form mode");
}
for present in ["acon:form:feature", "acon:form:return", "acon:field:elements"] {
assert!(
panel.hits.contains_key(present),
"{present}: {:?}",
panel.hits.keys().collect::<Vec<_>>()
);
}
let back = *panel.hits.get("acon:form:return").expect("return rect");
click_at(&ctx, &mut panel, &mut state, &store, &mut updates, back.center());
run_frame(&ctx, &mut panel, &mut state, &store, &mut updates, vec![]);
assert_eq!(
state.assembly_state_value()["constraints"][0]["open"],
serde_json::json!(false),
"the exit cleared the kernel's open flag"
);
assert!(panel.hits.contains_key("acon:row:FIXD1"), "the tree is back");
assert!(!panel.hits.contains_key("acon:form:return"), "the form is gone");
}
#[test]
fn a_row_label_click_opens_the_form() {
let ctx = egui::Context::default();
let mut state = two_instance_state();
let mut panel = AssemblyConstraintsPanel::new();
let store = crate::store::MemModelStore::new();
let mut updates = UpdateComponents::new();
add_fixed_through_the_panel(&ctx, &mut panel, &mut state, &store, &mut updates);
let row = *panel.hits.get("acon:row:FIXD1").expect("row label rect");
click_at(&ctx, &mut panel, &mut state, &store, &mut updates, row.center());
run_frame(&ctx, &mut panel, &mut state, &store, &mut updates, vec![]);
assert!(panel.hits.contains_key("acon:form:constraint:FIXD1"));
}
#[test]
fn a_drifted_press_on_a_row_opens_the_form() {
let ctx = egui::Context::default();
let mut state = two_instance_state();
let mut panel = AssemblyConstraintsPanel::new();
let store = crate::store::MemModelStore::new();
let mut updates = UpdateComponents::new();
add_fixed_through_the_panel(&ctx, &mut panel, &mut state, &store, &mut updates);
assert_eq!(
state.assembly_state_value()["constraints"][0]["open"],
serde_json::json!(false),
"the tree is showing"
);
let row = *panel.hits.get("acon:row:FIXD1").expect("row rect");
let at = row.center();
let drifted = at + egui::vec2(0.0, 12.0);
run_frame(
&ctx,
&mut panel,
&mut state,
&store,
&mut updates,
vec![
egui::Event::PointerMoved(at),
egui::Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
},
],
);
run_frame(
&ctx,
&mut panel,
&mut state,
&store,
&mut updates,
vec![egui::Event::PointerMoved(drifted)],
);
run_frame(
&ctx,
&mut panel,
&mut state,
&store,
&mut updates,
vec![egui::Event::PointerButton {
pos: drifted,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
}],
);
run_frame(&ctx, &mut panel, &mut state, &store, &mut updates, vec![]);
assert_eq!(
state.assembly_state_value()["constraints"][0]["open"],
serde_json::json!(true),
"the drifted press opened the form rather than vanishing"
);
assert!(panel.hits.contains_key("acon:form:constraint:FIXD1"));
}
#[test]
fn removing_an_element_chip_commits_live() {
let ctx = egui::Context::default();
let mut state = two_instance_state();
state
.assembly_add_constraint(
"distance",
&serde_json::json!({ "elements": ["ACOMP1", "ACOMP2"] }).to_string(),
)
.unwrap();
let mut panel = AssemblyConstraintsPanel::new();
let store = crate::store::MemModelStore::new();
let mut updates = UpdateComponents::new();
run_frame(&ctx, &mut panel, &mut state, &store, &mut updates, vec![]);
let params = &state.assembly_state_value()["constraints"][0]["inputParams"];
assert_eq!(
params["elements"],
serde_json::json!(["ACOMP1", "ACOMP2"]),
"seeded"
);
let x = *panel.hits.get("acon:field:elements#x0").unwrap_or_else(|| {
panic!(
"chip remove rect: {:?}",
panel.hits.keys().collect::<Vec<_>>()
)
});
click_at(&ctx, &mut panel, &mut state, &store, &mut updates, x.center());
let params = &state.assembly_state_value()["constraints"][0]["inputParams"];
assert_eq!(
params["elements"],
serde_json::json!(["ACOMP2"]),
"the chip removal committed straight to the kernel"
);
}
#[test]
fn the_form_title_is_free_of_the_evaluated_value() {
let mut state = two_instance_state();
state
.assembly_add_constraint(
"distance",
&serde_json::json!({ "elements": [], "distance": 12.5 }).to_string(),
)
.unwrap();
let statuses = state.assembly_statuses_value();
let overlay = state.assembly_overlay_value();
let constraint_state = state.assembly_state_value();
let catalogue = brep_render::brep_kernel::constraint_schema_catalogue();
let schemas: Vec<Value> = catalogue.as_array().cloned().unwrap();
let rows = snapshot_rows(&constraint_state, &statuses, &overlay, &schemas);
let row = &rows[0];
assert_eq!(row.title, "Distance DIST1", "the stable form title");
assert!(
row.label.starts_with(&row.title) && row.label.len() > row.title.len(),
"the ROW label still appends the measure: {}",
row.label
);
}
#[test]
fn update_components_button_counts_and_runs() {
use crate::panels::assembly_edit::document_signature;
use crate::store::MemModelStore;
brep_render::brep_kernel::clear_history_cache();
let ctx = egui::Context::default();
let store = MemModelStore::new();
let content = part_document();
store.put("bracket", &content);
let mut state = EngineState::new();
state
.insert_component(ComponentInsert::New {
name: "bracket",
source_key: "bracket",
source_signature: &document_signature(&content),
document_json: &content,
})
.unwrap();
state
.insert_component(ComponentInsert::Existing { part_name: "bracket" })
.unwrap();
let mut panel = AssemblyConstraintsPanel::new();
let mut updates = UpdateComponents::new();
updates.ensure_current(&mut state, &store, 0);
run_frame(&ctx, &mut panel, &mut state, &store, &mut updates, vec![]);
let update = *panel.hits.get("acon:update").expect("update rect");
click_at(&ctx, &mut panel, &mut state, &store, &mut updates, update.center());
let doc: Value = serde_json::from_str(&state.history_request_json()).unwrap();
assert_eq!(
doc["partsLibrary"]["bracket"]["sourceSignature"],
document_signature(&content),
"disabled button never refreshes"
);
let mut edited: Value = serde_json::from_str(&content).unwrap();
edited["features"][0]["inputParams"]["sizeX"] = serde_json::json!(7.0);
let edited = edited.to_string();
store.put("bracket", &edited);
updates.ensure_current(&mut state, &store, 1);
assert_eq!(updates.outdated_count(), 1, "the changed source is counted");
run_frame(&ctx, &mut panel, &mut state, &store, &mut updates, vec![]);
let update = *panel.hits.get("acon:update").expect("update rect");
click_at(&ctx, &mut panel, &mut state, &store, &mut updates, update.center());
for solid in ["ACOMP1:Part", "ACOMP2:Part"] {
let bbox = &state.scene.solid(solid).expect(solid).bbox;
assert!(
(bbox.max[0] - bbox.min[0] - 7.0).abs() < 1e-6,
"{solid} rebuilt with the refreshed part"
);
}
updates.ensure_current(&mut state, &store, 1);
assert_eq!(updates.outdated_count(), 0, "badge clears after the run");
}
#[test]
fn add_seeds_elements_from_selection_context() {
let mut state = two_instance_state();
let catalogue = brep_render::brep_kernel::constraint_schema_catalogue();
let schemas: Vec<Value> = catalogue.as_array().cloned().unwrap();
state.select_component("ACOMP2");
let seed = seeded_elements(&mut state, &schemas, "fixed");
assert_eq!(seed["elements"], serde_json::json!(["ACOMP2"]));
state.select_by_name("face", "ACOMP1:Part_PZ");
let seed = seeded_elements(&mut state, &schemas, "tangent");
assert_eq!(seed["elements"], serde_json::json!(["ACOMP1:Part_PZ"]));
let seed = seeded_elements(&mut state, &schemas, "distance");
assert!(seed["elements"].as_array().unwrap().len() <= 2);
}
#[test]
fn row_status_uses_the_one_map() {
let mut state = two_instance_state();
state
.assembly_add_constraint(
"fixed",
&serde_json::json!({ "elements": ["ACOMP2"] }).to_string(),
)
.unwrap();
let statuses = state.assembly_statuses_value();
let overlay = state.assembly_overlay_value();
let constraint_state = state.assembly_state_value();
let catalogue = brep_render::brep_kernel::constraint_schema_catalogue();
let schemas: Vec<Value> = catalogue.as_array().cloned().unwrap();
let rows = snapshot_rows(&constraint_state, &statuses, &overlay, &schemas);
assert_eq!(rows.len(), 1);
let status = &rows[0].status;
assert!(!assembly_status::status_label(status).is_empty());
let _ = assembly_status::status_color_rgb(status);
assert!(rows[0].label.contains("FIXD1"), "label carries the id: {}", rows[0].label);
}
#[test]
fn reorder_moves_the_constraint() {
let mut state = two_instance_state();
let first = state
.assembly_add_constraint(
"fixed",
&serde_json::json!({ "elements": ["ACOMP2"] }).to_string(),
)
.unwrap();
let second = state.assembly_add_constraint("parallel", "{}").unwrap();
state.assembly_move_constraint(&second, 0).unwrap();
let statuses = state.assembly_statuses_value();
assert_eq!(statuses[0]["id"], second.as_str());
assert_eq!(statuses[1]["id"], first.as_str());
}
#[test]
fn dof_summary_wording() {
let under = serde_json::json!({ "ok": true, "mates": 1, "dof": 3, "rank": 3, "redundant": 0 });
assert!(dof_summary(&under).contains("under-constrained (3 DOF free)"));
let full = serde_json::json!({ "ok": true, "mates": 6, "dof": 0, "rank": 6, "redundant": 0 });
assert!(dof_summary(&full).contains("fully constrained"));
let over = serde_json::json!({ "ok": true, "mates": 7, "dof": 0, "rank": 6, "redundant": 1 });
assert!(dof_summary(&over).contains("over-constrained (1 redundant)"));
let failed = serde_json::json!({ "ok": false, "error": "conflict" });
assert!(dof_summary(&failed).contains("Solve failed: conflict"));
let empty = serde_json::json!({ "ok": true, "mates": 0 });
assert!(dof_summary(&empty).contains("No constraints"));
}
}