1use crate::automation::hit_keys::HitKeyDoc;
28use brep_render::assembly_status;
29use brep_render::engine_state::EngineState;
30use eframe::egui;
31use serde_json::Value;
32use std::collections::{BTreeMap, HashMap};
33
34#[derive(Clone)]
36struct OfferedType {
37 id: String,
39 long_name: String,
41 detects: String,
43}
44
45#[derive(Default)]
47pub struct AutoConstraintsWindow {
48 open: bool,
49 types: Vec<OfferedType>,
51 enabled: BTreeMap<String, bool>,
53 tolerance: f64,
58 scan: Option<Value>,
60 generated: Option<Value>,
63 hits: HashMap<String, egui::Rect>,
65}
66
67fn status_color(status: &str) -> egui::Color32 {
70 let [r, g, b] = assembly_status::status_color_rgb(status);
71 egui::Color32::from_rgb(r, g, b)
72}
73
74const DEFAULT_TOLERANCE: f64 = 0.001;
76
77impl AutoConstraintsWindow {
78 pub fn new() -> Self {
79 Self {
80 tolerance: DEFAULT_TOLERANCE,
81 ..Self::default()
82 }
83 }
84
85 pub fn open_and_scan(&mut self, state: &mut EngineState) {
88 self.load_types(state);
89 self.generated = None;
90 self.open = true;
91 self.rescan(state);
92 }
93
94 fn load_types(&mut self, state: &EngineState) {
96 if !self.types.is_empty() {
97 return;
98 }
99 let offered = state.assembly_inferable_types();
100 for row in offered.as_array().into_iter().flatten() {
101 let Some(id) = row.get("type").and_then(Value::as_str) else {
102 continue;
103 };
104 self.enabled.insert(
105 id.to_string(),
106 row.get("defaultOn").and_then(Value::as_bool).unwrap_or(true),
107 );
108 self.types.push(OfferedType {
109 id: id.to_string(),
110 long_name: row
111 .get("longName")
112 .and_then(Value::as_str)
113 .unwrap_or(id)
114 .to_string(),
115 detects: row
116 .get("detects")
117 .and_then(Value::as_str)
118 .unwrap_or_default()
119 .to_string(),
120 });
121 }
122 }
123
124 fn ticked(&self) -> Vec<String> {
126 self.types
127 .iter()
128 .filter(|offered| self.enabled.get(&offered.id).copied().unwrap_or(false))
129 .map(|offered| offered.id.clone())
130 .collect()
131 }
132
133 fn options(&self) -> String {
135 serde_json::json!({
136 "types": self.ticked(),
137 "tolerance": self.tolerance,
138 })
139 .to_string()
140 }
141
142 fn rescan(&mut self, state: &mut EngineState) {
144 self.scan = Some(state.assembly_infer_constraints(&self.options()));
145 }
146
147 fn generate(&mut self, state: &mut EngineState) {
150 let reply = state.assembly_apply_inferred_constraints(&self.options());
151 self.generated = Some(reply);
152 self.rescan(state);
153 }
154
155 fn candidate_total(&self) -> usize {
157 self.scan
158 .as_ref()
159 .and_then(|scan| scan.get("candidates"))
160 .and_then(Value::as_array)
161 .map(Vec::len)
162 .unwrap_or(0)
163 }
164
165 fn found(&self, type_id: &str) -> usize {
167 self.scan
168 .as_ref()
169 .and_then(|scan| scan.get("byType"))
170 .and_then(|counts| counts.get(type_id))
171 .and_then(Value::as_u64)
172 .unwrap_or(0) as usize
173 }
174
175 pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
177 self.hits.clear();
178 if !self.open {
179 return;
180 }
181 let mut open = true;
182 egui::Window::new("Auto Constraints")
183 .id(egui::Id::new("brep-auto-constraints-window"))
184 .open(&mut open)
185 .movable(true)
186 .resizable(true)
187 .default_size([380.0, 400.0])
188 .default_pos([820.0, 120.0])
189 .show(ctx, |ui| {
190 egui::ScrollArea::vertical()
191 .auto_shrink([false, false])
192 .show(ui, |ui| self.body(ui, state));
193 });
194 self.open = open;
195 }
196
197 fn body(&mut self, ui: &mut egui::Ui, state: &mut EngineState) {
198 self.load_types(state);
199 crate::icon_text::IconText::new(
200 "Infers the mates the components' current placement already implies \
201 \u{2014} the quickest way to make an imported assembly editable.",
202 )
203 .wrap()
204 .show(ui);
205 ui.add_space(4.0);
206
207 if state.assembly_components().len() < 2 {
208 ui.weak("Needs at least two components.");
209 return;
210 }
211 if let Some(error) = self
212 .scan
213 .as_ref()
214 .and_then(|scan| scan.get("error"))
215 .and_then(Value::as_str)
216 {
217 ui.colored_label(status_color("error"), error);
218 return;
219 }
220
221 let mut toggled = false;
223 for offered in self.types.clone() {
224 let mut on = self.enabled.get(&offered.id).copied().unwrap_or(false);
225 ui.horizontal(|ui| {
226 let box_ = ui.checkbox(&mut on, "");
227 self.hits
228 .insert(format!("autoconstraints:type:{}", offered.id), box_.rect);
229 if box_.changed() {
230 toggled = true;
231 }
232 crate::icon_text::IconText::new(offered.long_name.clone()).show(ui);
233 let found = self.found(&offered.id);
234 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
235 if !on {
236 ui.weak("off");
237 } else if found == 0 {
238 ui.weak("none found");
239 } else {
240 ui.colored_label(
241 status_color("satisfied"),
242 format!("{found} found"),
243 );
244 }
245 });
246 });
247 ui.indent(&offered.id, |ui| {
248 ui.weak(egui::RichText::new(offered.detects.clone()).small());
249 });
250 self.enabled.insert(offered.id.clone(), on);
251 ui.add_space(2.0);
252 }
253 ui.separator();
254
255 ui.horizontal(|ui| {
257 ui.label("Contact tolerance");
258 let field = ui.add(
259 egui::DragValue::new(&mut self.tolerance)
260 .speed(0.001)
261 .range(1e-6..=10.0)
262 .suffix(" mm"),
263 );
264 self.hits
265 .insert("autoconstraints:tolerance".into(), field.rect);
266 if field.changed() {
267 toggled = true;
268 }
269 });
270
271 if toggled {
272 self.rescan(state);
276 }
277
278 ui.separator();
279
280 if let Some(scan) = self.scan.clone() {
282 let number = |key: &str| scan.get(key).and_then(Value::as_u64).unwrap_or(0);
283 ui.weak(format!(
284 "{} components \u{00b7} {} pair{} examined \u{00b7} {} already constrained \u{00b7} {} not touching",
285 number("componentCount"),
286 number("pairsConsidered"),
287 if number("pairsConsidered") == 1 { "" } else { "s" },
288 number("pairsAlreadyConstrained"),
289 number("pairsApart"),
290 ));
291 for warning in scan
292 .get("warnings")
293 .and_then(Value::as_array)
294 .into_iter()
295 .flatten()
296 .filter_map(Value::as_str)
297 {
298 ui.colored_label(status_color("unsupported-selection"), warning);
299 }
300 self.candidate_list(ui, &scan);
301 self.near_miss_line(ui, &scan);
302 self.suppressed_list(ui, &scan);
303 }
304
305 ui.add_space(4.0);
306
307 let total = self.candidate_total();
309 let label = match total {
310 0 => "Nothing to create".to_string(),
311 1 => "Create 1 constraint".to_string(),
312 many => format!("Create {many} constraints"),
313 };
314 let button = ui.add_enabled(total > 0, egui::Button::new(label));
315 self.hits
316 .insert("autoconstraints:generate".into(), button.rect);
317 if button.clicked() {
318 self.generate(state);
319 }
320
321 if let Some(generated) = self.generated.clone() {
322 self.outcome_line(ui, &generated);
323 }
324 }
325
326 fn candidate_list(&mut self, ui: &mut egui::Ui, scan: &Value) {
329 let candidates = scan
330 .get("candidates")
331 .and_then(Value::as_array)
332 .cloned()
333 .unwrap_or_default();
334 if candidates.is_empty() {
335 return;
336 }
337 let icon_of = |type_id: &str| {
338 self.types
339 .iter()
340 .find(|offered| offered.id == type_id)
341 .map(|offered| offered.long_name.clone())
342 .unwrap_or_else(|| type_id.to_string())
343 };
344 egui::CollapsingHeader::new(format!("What it found ({})", candidates.len()))
345 .id_salt("brep-auto-constraints-candidates")
346 .show(ui, |ui| {
347 for candidate in &candidates {
348 let type_id = candidate
349 .get("type")
350 .and_then(Value::as_str)
351 .unwrap_or_default();
352 let components = candidate
353 .get("components")
354 .and_then(Value::as_array)
355 .map(|pair| {
356 pair.iter()
357 .filter_map(Value::as_str)
358 .collect::<Vec<_>>()
359 .join(" \u{2194} ")
360 })
361 .unwrap_or_default();
362 let detail = candidate
363 .get("detail")
364 .and_then(Value::as_str)
365 .unwrap_or_default();
366 crate::icon_text::IconText::new(format!(
367 "{} {components} \u{00b7} {detail}",
368 icon_of(type_id)
369 ))
370 .truncate()
371 .show(ui);
372 }
373 });
374 }
375
376 fn near_miss_line(&mut self, ui: &mut egui::Ui, scan: &Value) {
382 let gates = &scan["gates"];
383 let count = |key: &str| gates[key].as_u64().unwrap_or(0);
384 let mut notes: Vec<String> = Vec::new();
385 if count("alsoOnCarrier") > 0 {
390 notes.push(format!(
391 "{} more face pair{} qualified on a plane or centreline a constraint above already holds \u{2014} one mate per carrier is deliberate",
392 count("alsoOnCarrier"),
393 if count("alsoOnCarrier") == 1 { " is" } else { "s are" }
394 ));
395 }
396 if let Some(gap) = gates["nearestPlaneGap"].as_f64() {
397 if gap > self.tolerance {
398 notes.push(format!(
399 "closest untouched face pair is {} mm apart \u{2014} raise the tolerance above that to include it",
400 super::info_windows::num(gap)
401 ));
402 }
403 }
404 if count("sameFacing") > 0 {
405 notes.push(format!(
406 "{} coplanar face pair{} point the same way (flush, not in contact)",
407 count("sameFacing"),
408 if count("sameFacing") == 1 { "" } else { "s" }
409 ));
410 }
411 if count("noOverlap") > 0 {
412 notes.push(format!(
413 "{} coplanar facing pair{} do not overlap",
414 count("noOverlap"),
415 if count("noOverlap") == 1 { "" } else { "s" }
416 ));
417 }
418 if count("noExtent") > 0 {
419 notes.push(format!("{} face(s) had no measurable extent", count("noExtent")));
420 }
421 if notes.is_empty() {
422 return;
423 }
424 egui::CollapsingHeader::new("About the faces with no constraint of their own")
425 .id_salt("brep-auto-constraints-gates")
426 .show(ui, |ui| {
427 for note in notes {
428 ui.weak(egui::RichText::new(note).small());
429 }
430 });
431 }
432
433 fn suppressed_list(&mut self, ui: &mut egui::Ui, scan: &Value) {
438 let suppressed = scan
439 .get("suppressed")
440 .and_then(Value::as_array)
441 .cloned()
442 .unwrap_or_default();
443 if suppressed.is_empty() {
444 return;
445 }
446 egui::CollapsingHeader::new(format!("Found but not needed ({})", suppressed.len()))
447 .id_salt("brep-auto-constraints-suppressed")
448 .show(ui, |ui| {
449 ui.weak(
450 egui::RichText::new(
451 "These would not remove any freedom the constraints above do not \
452 already remove. Creating them would over-constrain the solve.",
453 )
454 .small(),
455 );
456 for row in &suppressed {
457 let components = row
458 .get("components")
459 .and_then(Value::as_array)
460 .map(|pair| {
461 pair.iter()
462 .filter_map(Value::as_str)
463 .collect::<Vec<_>>()
464 .join(" \u{2194} ")
465 })
466 .unwrap_or_default();
467 crate::icon_text::IconText::new(format!(
468 "{} {components} \u{00b7} {}",
469 row.get("type").and_then(Value::as_str).unwrap_or_default(),
470 row.get("why").and_then(Value::as_str).unwrap_or_default(),
471 ))
472 .truncate()
473 .show(ui);
474 }
475 });
476 }
477
478 fn outcome_line(&mut self, ui: &mut egui::Ui, generated: &Value) {
482 ui.separator();
483 if let Some(error) = generated.get("error").and_then(Value::as_str) {
484 ui.colored_label(status_color("error"), error);
485 return;
486 }
487 let created = generated
488 .get("created")
489 .and_then(Value::as_array)
490 .map(Vec::len)
491 .unwrap_or(0);
492 if created == 0 {
493 ui.weak("Nothing was created \u{2014} every pair was already constrained.");
494 return;
495 }
496 ui.colored_label(
497 status_color("satisfied"),
498 format!(
499 "\u{2713} Created {created} constraint{}",
500 if created == 1 { "" } else { "s" }
501 ),
502 );
503 let solve = &generated["solve"];
504 if solve.get("ok").and_then(Value::as_bool) == Some(false) {
505 let message = solve
506 .get("error")
507 .and_then(Value::as_str)
508 .unwrap_or("the solve refused");
509 ui.colored_label(status_color("error"), message);
510 return;
511 }
512 if let Some(dof) = solve.get("dof").and_then(Value::as_u64) {
513 ui.weak(format!(
514 "solved \u{00b7} {dof} degree{} of freedom left",
515 if dof == 1 { "" } else { "s" }
516 ));
517 }
518 if solve.get("status").and_then(Value::as_str) == Some("over") {
524 let redundant = solve.get("redundant").and_then(Value::as_u64).unwrap_or(0);
525 ui.colored_label(
526 status_color("unsupported-selection"),
527 format!(
528 "over-determined: {redundant} redundant row{} \u{2014} consistent (the parts did not \
529 move), but trimming constraints will make later edits easier",
530 if redundant == 1 { "" } else { "s" }
531 ),
532 );
533 }
534 }
535
536 #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
539 pub fn state_json(&self) -> String {
540 serde_json::json!({
541 "open": self.open,
542 "types": self.types.iter().map(|offered| offered.id.clone()).collect::<Vec<_>>(),
543 "enabled": self.ticked(),
544 "scan": self.scan.clone().unwrap_or(Value::Null),
545 "generated": self.generated.clone().unwrap_or(Value::Null),
546 })
547 .to_string()
548 }
549
550 #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
553 pub fn hits_json(&self) -> String {
554 crate::automation::hit_rects::hits_json(&self.hits)
555 }
556}
557
558pub static HIT_KEYS: &[HitKeyDoc] = &[
562 HitKeyDoc { panel: "autoConstraints", prefix: "autoconstraints:type:", meaning: "tick one inferable constraint type (rescans)", command: Some("assembly_infer_constraints") },
563 HitKeyDoc { panel: "autoConstraints", prefix: "autoconstraints:tolerance", meaning: "how close two faces must sit to read as touching, in mm (rescans)", command: Some("assembly_infer_constraints") },
564 HitKeyDoc { panel: "autoConstraints", prefix: "autoconstraints:generate", meaning: "create every inferred constraint", command: Some("assembly_apply_inferred_constraints") },
565];