Skip to main content

brep_app/panels/
auto_constraints.rs

1//! Auto Constraints window — the Assembly workbench's `⚿` toolbar button.
2//!
3//! An imported STEP assembly is fully posed and completely unconstrained: every
4//! part sits where the file put it, and nothing holds it there. This window
5//! reads that placement back as constraint intent — the kernel's inference lane
6//! ([`brep_kernel::feature_pipeline::assembly::infer`], reached through
7//! [`EngineState::assembly_infer_constraints`]) — so an import becomes an
8//! editable assembly without re-picking every mate by hand.
9//!
10//! The window is the pinned Info/Interference idiom: a movable, resizable
11//! [`egui::Window`] drawn at ctx level, owning only its last scan. It decides
12//! nothing about geometry:
13//!
14//! * the LIST of constraint types it offers is the kernel's rule table
15//!   ([`EngineState::assembly_inferable_types`]) — label, icon and the
16//!   "what it detects" line all come from there, so a rule added in the kernel
17//!   appears here as a row with no change to this file;
18//! * ticking a type re-runs the SCAN, because which types are on changes what
19//!   the kernel accepts (a Concentric can make a second Touch Align redundant);
20//! * Generate hands the same ticked list to the apply lane, which creates and
21//!   solves the whole batch as ONE undo step.
22//!
23//! Nothing here writes a constraint itself, and a scan never mutates: pressing
24//! the button to look is free, and pressing Generate twice is a no-op because
25//! the kernel leaves an already-constrained pair alone.
26
27use 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/// One offered constraint type, as the kernel describes it.
35#[derive(Clone)]
36struct OfferedType {
37    /// Canonical type id (`"touch_align"`) — the scan's `byType` key.
38    id: String,
39    /// `"⪥ Touch Align"` — icon + label, drawn as artwork by `icon_text`.
40    long_name: String,
41    /// What placement makes this constraint.
42    detects: String,
43}
44
45/// The shell-owned Auto Constraints window.
46#[derive(Default)]
47pub struct AutoConstraintsWindow {
48    open: bool,
49    /// The kernel's rule table, read once on first open.
50    types: Vec<OfferedType>,
51    /// Which rules are ticked (seeded from each rule's `defaultOn`).
52    enabled: BTreeMap<String, bool>,
53    /// Contact tolerance in mm — how far apart two faces may sit and still
54    /// read as touching. Exposed because an imported assembly's real gaps are a
55    /// property of the file, not of this tool: when the scan reports a nearest
56    /// miss just above this, raising it is the fix.
57    tolerance: f64,
58    /// The last scan report, or `None` before the first one.
59    scan: Option<Value>,
60    /// The last Generate reply — kept so its outcome line survives the rescan
61    /// that follows it.
62    generated: Option<Value>,
63    /// Per-frame interactive-widget screen rects for the headed verifier.
64    hits: HashMap<String, egui::Rect>,
65}
66
67/// An [`egui::Color32`] off the shared assembly-status palette — the same map
68/// the constraints panel and the interference window read.
69fn 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
74/// The kernel's own default contact tolerance (mm).
75const 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    /// The toolbar entry point: show the window and scan NOW, so it opens with
86    /// real counts rather than an empty form waiting to be pressed.
87    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    /// Read the kernel's rule table (once) and seed the tick boxes from it.
95    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    /// The ticked type ids, in the kernel's order.
125    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    /// The kernel `InferOptions` body for the current ticks and tolerance.
134    fn options(&self) -> String {
135        serde_json::json!({
136            "types": self.ticked(),
137            "tolerance": self.tolerance,
138        })
139        .to_string()
140    }
141
142    /// Re-run the (read-only) scan for the current ticks.
143    fn rescan(&mut self, state: &mut EngineState) {
144        self.scan = Some(state.assembly_infer_constraints(&self.options()));
145    }
146
147    /// Create every candidate the current ticks accept, then rescan so the
148    /// counts show what is left (nothing, in a healthy pass).
149    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    /// How many candidates the last scan holds in total.
156    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    /// The per-type count from the last scan.
166    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    /// Draw the window (if open) at ctx level, like the Info windows.
176    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        // --- the offered rules, each with what the last scan found ----------
222        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        // --- the contact tolerance -------------------------------------------
256        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            // Which rules are on and how close counts as touching both change
273            // what the kernel accepts, so the counts are only true for the
274            // settings that produced them.
275            self.rescan(state);
276        }
277
278        ui.separator();
279
280        // --- what the scan looked at ----------------------------------------
281        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        // --- generate --------------------------------------------------------
308        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    /// The candidate rows — what WOULD be created, with the measurement each
327    /// was read from, so the button is never a blind commitment.
328    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    /// The near-miss line — the single most useful thing to show a user who
377    /// expected more contacts than the scan found. The kernel measures the
378    /// closest pair of parallel planes that failed the coincidence test; if that
379    /// distance is just above the tolerance, the geometry is fine and the
380    /// tolerance is the answer, and this says so with the number in hand.
381    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        // Leads, because it is the usual answer to "you missed faces that are
386        // touching": they were found, they ARE in contact, and they lie in a
387        // plane (or on an axis) a created constraint already holds. One mate
388        // per plane is the whole point — a second would over-constrain it.
389        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    /// The candidates that WERE found and are deliberately not being created.
434    /// A user who counts touching faces and finds fewer constraints is owed
435    /// this list: each row says which pair it would have held and why the
436    /// already-accepted set makes it unnecessary.
437    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    /// The outcome of the last Generate: what was created, and what the solve
479    /// made of it (an inferred batch is read off the current pose, so a healthy
480    /// assembly solves without moving).
481    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        // An inferred set is read off ONE pose, so several constraints can hold
519        // the same freedom and the solver reports the set as over-determined
520        // even though it satisfied every one of them. Say so plainly — it is a
521        // property of inferring everything, not a failure, and hiding it would
522        // leave the constraints panel's own status word unexplained.
523        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    /// The window's logical state for the headed verifier
537    /// (`__brepAutoConstraints`): `{open, types, enabled, scan, generated}`.
538    #[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    /// Per-frame widget rects (`autoconstraints:type:<id>`,
551    /// `autoconstraints:generate`) for the headed verifier.
552    #[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
558// BREP private tests: 5bc01ce4cb6a28c2
559
560/// The hit keys this panel publishes (see `automation::hit_keys`).
561pub 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];