Skip to main content

brep_render/engine_state/
sketch_panel.rs

1use super::*;
2
3/// One row in a sketch entity-LIST panel (Points / Curves / Constraints) — the
4/// display label plus the `(kind, id)` needed to select / hover / delete it, and
5/// its current selected/construction state for row styling. Built by
6/// [`EngineState::sketch_point_rows`] / `sketch_geometry_rows` /
7/// `sketch_constraint_rows`; the panel renders them and routes clicks back through
8/// [`EngineState::sketch_select_entity`] / `sketch_hover_entity`.
9#[derive(Clone, Debug, PartialEq)]
10pub struct SketchEntityRow {
11    /// Entity ref kind: `"point"` | `"geometry"` | `"constraint"`.
12    pub kind: &'static str,
13    /// The entity id (opaque `Value`, passed straight back to select/hover).
14    pub id: serde_json::Value,
15    /// Human display label (mirrors the previous list rows).
16    pub label: String,
17    /// Whether this entity is in the current sketch selection (row highlight).
18    pub selected: bool,
19    /// Construction-only entity (dashed / de-emphasized styling).
20    pub construction: bool,
21    /// The solver names this entity in a CONSTRAINT CONFLICT (constraint rows
22    /// only) — the panel paints the row red to match its canvas annotation.
23    pub conflicting: bool,
24}
25
26impl EngineState {
27    /// Queue a transient user-facing notice (shown as a toast by the shell) and,
28    /// on native, also log it. Bounded so a pathological loop can't grow it.
29    pub fn push_notice(&mut self, message: impl Into<String>) {
30        let message = message.into();
31        #[cfg(not(target_arch = "wasm32"))]
32        eprintln!("{message}");
33        self.notices.push(message);
34        if self.notices.len() > 8 {
35            let overflow = self.notices.len() - 8;
36            self.notices.drain(0..overflow);
37        }
38    }
39
40    /// Drain the queued notices (the shell calls this once per frame and shows
41    /// each as a toast).
42    pub fn take_notices(&mut self) -> Vec<String> {
43        std::mem::take(&mut self.notices)
44    }
45
46    /// Re-solve the ACTIVE sketch session and, on failure, queue a user notice
47    /// naming `context`. Replaces the scattered swallowed `eprintln!` on the
48    /// interactive re-solve paths — the solver rarely fails, but when an edit
49    /// leaves the sketch unsolvable the user should see why.
50    pub(super) fn resolve_active_sketch(&mut self, context: &str) {
51        let error = self
52            .sketch_edit
53            .as_mut()
54            .and_then(|edit| edit.session.resolve().err());
55        if let Some(error) = error {
56            self.push_notice(format!("Sketch solve failed ({context}): {error}"));
57        }
58    }
59
60    /// Whether `(kind, id)` is in the current sketch selection.
61    fn sketch_ref_selected(session: &crate::sketch::SketchSession, kind: &str, id: &serde_json::Value) -> bool {
62        use crate::sketch::doc::id_key;
63        let key = id_key(id);
64        session.selection.iter().any(|r| {
65            r.get("kind").and_then(serde_json::Value::as_str) == Some(kind)
66                && r.get("id").map(id_key).as_deref() == Some(key.as_str())
67        })
68    }
69
70    /// The Points list: `P{id} (x, y)` plus ⛓ external / ◐ construction / ⏚ ground
71    /// markers, mirroring the previous Points rows.
72    pub fn sketch_point_rows(&self) -> Vec<SketchEntityRow> {
73        use crate::sketch::doc::id_key;
74        let Some(edit) = self.sketch_edit.as_ref() else {
75            return Vec::new();
76        };
77        let session = &edit.session;
78        session
79            .doc
80            .points
81            .iter()
82            .map(|p| {
83                let grounded = session.doc.constraints.iter().any(|c| {
84                    c.ctype() == Some("⏚")
85                        && c.points().first().map(id_key).as_deref() == Some(id_key(&p.id).as_str())
86                });
87                let mut marks = String::new();
88                if p.external_reference {
89                    marks.push_str(" \u{26D3}"); // ⛓ chain-links (bundled font; 🔗 is not)
90                }
91                if p.construction {
92                    marks.push_str(" ◐");
93                }
94                if grounded {
95                    marks.push_str(" ⏚");
96                }
97                SketchEntityRow {
98                    kind: "point",
99                    id: p.id.clone(),
100                    label: format!("P{} ({:.1}, {:.1}){marks}", id_key(&p.id), p.x, p.y),
101                    selected: Self::sketch_ref_selected(session, "point", &p.id),
102                    construction: p.construction,
103                    conflicting: false,
104                }
105            })
106            .collect()
107    }
108
109    /// The Curves list: `{type}:{id} [p0,p1,…]` (◐ for construction), mirroring the
110    /// previous Curves rows.
111    pub fn sketch_geometry_rows(&self) -> Vec<SketchEntityRow> {
112        use crate::sketch::doc::id_key;
113        let Some(edit) = self.sketch_edit.as_ref() else {
114            return Vec::new();
115        };
116        let session = &edit.session;
117        session
118            .doc
119            .geometries
120            .iter()
121            .map(|g| {
122                let pts = g
123                    .points
124                    .iter()
125                    .map(id_key)
126                    .collect::<Vec<_>>()
127                    .join(",");
128                let construction = g.construction();
129                let mark = if construction { " ◐" } else { "" };
130                SketchEntityRow {
131                    kind: "geometry",
132                    id: g.id.clone(),
133                    label: format!("{}:{}{mark} [{pts}]", g.geom_type, id_key(&g.id)),
134                    selected: Self::sketch_ref_selected(session, "geometry", &g.id),
135                    construction,
136                    conflicting: false,
137                }
138            })
139            .collect()
140    }
141
142    /// The Constraints list: `{id} {type} {value} [points]`, mirroring the previous
143    /// Constraints rows.
144    pub fn sketch_constraint_rows(&self) -> Vec<SketchEntityRow> {
145        use crate::sketch::doc::id_key;
146        let Some(edit) = self.sketch_edit.as_ref() else {
147            return Vec::new();
148        };
149        let session = &edit.session;
150        session
151            .doc
152            .constraints
153            .iter()
154            .filter_map(|c| {
155                let id = c.raw.get("id")?.clone();
156                let ctype = c.ctype().unwrap_or("?");
157                let value = c
158                    .raw
159                    .get("value")
160                    .and_then(serde_json::Value::as_f64)
161                    .map(|v| format!(" {v:.3}"))
162                    .unwrap_or_default();
163                let pts = c
164                    .points()
165                    .iter()
166                    .map(id_key)
167                    .collect::<Vec<_>>()
168                    .join(",");
169                Some(SketchEntityRow {
170                    kind: "constraint",
171                    id: id.clone(),
172                    label: format!("{} {ctype}{value} [{pts}]", id_key(&id)),
173                    selected: Self::sketch_ref_selected(session, "constraint", &id),
174                    construction: false,
175                    conflicting: session.diagnostics.constraint_conflicting(&id),
176                })
177            })
178            .collect()
179    }
180
181    /// Select an entity BY ref from a list row (mirrors [`sketch_click_at`]): honors the
182    /// SAME "Multi-select" setting — under `ClickToggles` a plain click toggles the row
183    /// (no modifier needed); under `CtrlClick` a plain click replaces the
184    /// selection and an additive (Ctrl/Cmd) click toggles.
185    pub fn sketch_select_entity(&mut self, kind: &str, id: serde_json::Value, additive: bool) {
186        let toggles = self.settings.multi_select == crate::style::MultiSelectMode::ClickToggles;
187        let entity_ref = serde_json::json!({ "kind": kind, "id": id });
188        let Some(edit) = self.sketch_edit.as_mut() else {
189            return;
190        };
191        if !additive && !toggles {
192            edit.session.clear_selection();
193        }
194        edit.session.toggle_selection(entity_ref);
195        self.refresh_sketch_overlay();
196        self.dirty = true;
197    }
198
199    /// Hover an entity BY ref from a list row (list→canvas highlight). Sets the
200    /// one-frame guard so the viewport — which draws after the panel and would
201    /// otherwise clear the hover because the pointer is off the viewport — keeps it.
202    pub fn sketch_hover_entity(&mut self, kind: &str, id: serde_json::Value) {
203        if self.sketch_edit.is_none() {
204            return;
205        }
206        // `set_sketch_hover` re-tessellates only when the hovered ref actually
207        // changes, so holding the pointer on one row does not churn the overlay.
208        let entity_ref = serde_json::json!({ "kind": kind, "id": id });
209        self.set_sketch_hover(Some(entity_ref));
210        self.sketch_list_hover_active = true;
211    }
212
213    /// Consume the "list panel set the hover this frame" guard (read + reset). The
214    /// viewport calls this before its off-viewport `sketch_clear_hover` so a
215    /// panel-set hover survives the frame.
216    pub fn take_sketch_list_hover(&mut self) -> bool {
217        std::mem::take(&mut self.sketch_list_hover_active)
218    }
219
220    /// The active sketch's solver settings (the Solver Settings panel reads this).
221    /// `None` when not editing a sketch.
222    pub fn sketch_solver_settings(&self) -> Option<crate::sketch::SketchSolverSettings> {
223        self.sketch_edit
224            .as_ref()
225            .map(|edit| edit.session.solver_settings.clone())
226    }
227
228    /// Replace the active sketch's solver settings and re-solve so the change
229    /// takes effect immediately. No-op when not editing a sketch.
230    pub fn sketch_set_solver_settings(&mut self, settings: crate::sketch::SketchSolverSettings) {
231        if let Some(edit) = self.sketch_edit.as_mut() {
232            edit.session.solver_settings = settings;
233        } else {
234            return;
235        }
236        self.resolve_active_sketch("solver settings");
237        self.refresh_sketch_overlay();
238        self.dirty = true;
239    }
240}
241
242/// One applicable-constraint palette entry: the glyph passed back to
243/// [`EngineState::sketch_add_constraint`] plus a human tooltip and a dimensional
244/// tag (whether it opens a value the solver seeds from the current measurement).
245#[derive(Clone, Debug, PartialEq)]
246pub struct SketchConstraintAction {
247    /// The constraint glyph (e.g. `"━"`, `"⟂"`, `"R"`) — the argument to
248    /// [`EngineState::sketch_add_constraint`].
249    pub symbol: String,
250    /// A human tooltip label (e.g. `"Horizontal"`, `"Radius"`).
251    pub label: String,
252    /// Whether this is a dimensional constraint (its value is seeded from the
253    /// current measurement; S5 makes it editable).
254    pub dimensional: bool,
255}
256
257impl EngineState {
258    /// The ordered palette of constraints applicable to the active sketch selection
259    /// (a faithful port of `#refreshContextBar`). Empty when not in sketch mode or
260    /// the selection surfaces no constraint. The Fix/Unfix + construction + cleanup
261    /// affordances are exposed separately (see [`sketch_selection_all_grounded`] /
262    /// [`sketch_selection_all_construction`] / [`sketch_cleanup_unused_points`]).
263    ///
264    /// [`sketch_selection_all_grounded`]: Self::sketch_selection_all_grounded
265    /// [`sketch_selection_all_construction`]: Self::sketch_selection_all_construction
266    /// [`sketch_cleanup_unused_points`]: Self::sketch_cleanup_unused_points
267    pub fn sketch_applicable_constraints(&self) -> Vec<SketchConstraintAction> {
268        match self.sketch_edit.as_ref() {
269            Some(edit) => sketch_palette_for(&edit.session),
270            None => Vec::new(),
271        }
272    }
273
274    /// Add the constraint named by `symbol` from the current selection (a port of
275    /// `createConstraint`): build the ordered point-id list, dedup on
276    /// `type + sorted-points`, append (dimensional → `value:null`), then re-solve +
277    /// refresh (keeping the selection). Returns whether a constraint was added.
278    pub fn sketch_add_constraint(&mut self, symbol: &str) -> bool {
279        // Snapshot BEFORE the (in-place) add; discard it below when the add is a
280        // dedup no-op so a dead click neither pollutes undo nor clobbers redo (S6a).
281        let added = match self.sketch_edit.as_mut() {
282            Some(edit) => {
283                edit.record_undo();
284                sketch_build_and_add_constraint(&mut edit.session, symbol)
285            }
286            None => return false,
287        };
288        if added {
289            self.resolve_active_sketch("add-constraint");
290            self.refresh_sketch_overlay();
291            self.dirty = true;
292        } else if let Some(edit) = self.sketch_edit.as_mut() {
293            edit.undo_stack.pop();
294        }
295        added
296    }
297
298    /// Toggle the ground (`⏚`) constraint on the selected points: if ALL selected
299    /// points are already grounded → remove those grounds (and clear their `fixed`
300    /// flag); else add a `⏚` for each ungrounded selected point (and set `fixed`).
301    /// Re-solves + refreshes. Returns whether anything changed. No-op with no point
302    /// selected / not in sketch mode.
303    pub fn sketch_toggle_ground(&mut self) -> bool {
304        use crate::sketch::doc::id_key;
305        use std::collections::HashSet;
306
307        let Some(edit) = self.sketch_edit.as_mut() else {
308            return false;
309        };
310        let sel_ids: Vec<serde_json::Value> = edit
311            .session
312            .selection
313            .iter()
314            .filter(|r| r.get("kind").and_then(|v| v.as_str()) == Some("point"))
315            .filter_map(|r| r.get("id").cloned())
316            .collect();
317        if sel_ids.is_empty() {
318            return false;
319        }
320        // Selected points → the toggle always adds or removes grounds; snapshot (S6a).
321        edit.record_undo();
322        let doc = &mut edit.session.doc;
323        let has_ground = |doc: &crate::sketch::SketchDoc, id: &serde_json::Value| -> bool {
324            doc.constraints.iter().any(|c| {
325                c.ctype() == Some("⏚")
326                    && c.points().first().map(id_key) == Some(id_key(id))
327            })
328        };
329        let all_grounded = sel_ids.iter().all(|id| has_ground(doc, id));
330        if all_grounded {
331            let sel_keys: HashSet<String> = sel_ids.iter().map(id_key).collect();
332            doc.constraints.retain(|c| {
333                if c.ctype() != Some("⏚") {
334                    return true;
335                }
336                match c.points().first() {
337                    Some(p) => !sel_keys.contains(&id_key(p)),
338                    None => true,
339                }
340            });
341            for id in &sel_ids {
342                if let Some(p) = doc.point_mut(id) {
343                    p.fixed = false;
344                }
345            }
346        } else {
347            for id in &sel_ids {
348                if has_ground(doc, id) {
349                    continue;
350                }
351                let cid = doc.next_constraint_id();
352                let mut raw = serde_json::Map::new();
353                raw.insert("id".to_string(), cid);
354                raw.insert("type".to_string(), serde_json::Value::String("⏚".to_string()));
355                raw.insert(
356                    "points".to_string(),
357                    serde_json::Value::Array(vec![id.clone()]),
358                );
359                doc.constraints.push(crate::sketch::SketchConstraint { raw });
360                if let Some(p) = doc.point_mut(id) {
361                    p.fixed = true;
362                }
363            }
364        }
365        self.resolve_active_sketch("toggle-ground");
366        self.refresh_sketch_overlay();
367        self.dirty = true;
368        true
369    }
370
371    /// Flip the `construction` flag on ALL selected points AND geometries: if every
372    /// selected entity is already construction → make them regular, else make them
373    /// all construction. Re-solves + refreshes. Returns whether anything was
374    /// selected. No-op with nothing selected / not in sketch mode.
375    pub fn sketch_toggle_construction(&mut self) -> bool {
376        let Some(edit) = self.sketch_edit.as_mut() else {
377            return false;
378        };
379        let mut pt_ids: Vec<serde_json::Value> = Vec::new();
380        let mut geo_ids: Vec<serde_json::Value> = Vec::new();
381        for r in &edit.session.selection {
382            match r.get("kind").and_then(|v| v.as_str()) {
383                Some("point") => {
384                    if let Some(id) = r.get("id") {
385                        pt_ids.push(id.clone());
386                    }
387                }
388                Some("geometry") => {
389                    if let Some(id) = r.get("id") {
390                        geo_ids.push(id.clone());
391                    }
392                }
393                _ => {}
394            }
395        }
396        if pt_ids.is_empty() && geo_ids.is_empty() {
397            return false;
398        }
399        // Selected entities → the flip always changes something; snapshot (S6a).
400        edit.record_undo();
401        let doc = &mut edit.session.doc;
402        let all_construction = pt_ids
403            .iter()
404            .all(|id| doc.point(id).map_or(false, |p| p.construction))
405            && geo_ids
406                .iter()
407                .all(|id| doc.geometry(id).map_or(false, |g| g.construction()));
408        let next = !all_construction;
409        for id in &pt_ids {
410            if let Some(p) = doc.point_mut(id) {
411                p.construction = next;
412            }
413        }
414        for id in &geo_ids {
415            if let Some(g) = doc.geometry_mut(id) {
416                g.extra
417                    .insert("construction".to_string(), serde_json::Value::Bool(next));
418            }
419        }
420        self.resolve_active_sketch("toggle-construction");
421        self.refresh_sketch_overlay();
422        self.dirty = true;
423        true
424    }
425
426    /// Remove points referenced by NO geometry AND no constraint (the 🧹 action).
427    /// Re-solves + refreshes only when something was dropped. Returns whether a
428    /// point was removed. No-op when not in sketch mode.
429    pub fn sketch_cleanup_unused_points(&mut self) -> bool {
430        use crate::sketch::doc::id_key;
431        use std::collections::HashSet;
432
433        let Some(edit) = self.sketch_edit.as_mut() else {
434            return false;
435        };
436        // Snapshot before the cleanup; discarded below when nothing is removed (S6a).
437        edit.record_undo();
438        let doc = &mut edit.session.doc;
439        let mut used: HashSet<String> = HashSet::new();
440        for g in &doc.geometries {
441            for pid in &g.points {
442                used.insert(id_key(pid));
443            }
444        }
445        for c in &doc.constraints {
446            for pid in c.points() {
447                used.insert(id_key(pid));
448            }
449        }
450        let before = doc.points.len();
451        doc.points.retain(|p| used.contains(&id_key(&p.id)));
452        let removed = doc.points.len() != before;
453        if !removed {
454            edit.undo_stack.pop();
455            return false;
456        }
457        self.resolve_active_sketch("cleanup");
458        self.refresh_sketch_overlay();
459        self.dirty = true;
460        true
461    }
462
463    /// The number of constraints in the active sketch (0 when not in sketch mode) —
464    /// the verifier / palette readout.
465    pub fn sketch_constraint_count(&self) -> usize {
466        self.sketch_edit
467            .as_ref()
468            .map_or(0, |edit| edit.session.doc.constraints.len())
469    }
470
471    /// Whether the selected points are ALL grounded (`Some(true)`), NOT all grounded
472    /// (`Some(false)`), or no point is selected (`None`) — labels the Fix vs Unfix
473    /// button.
474    pub fn sketch_selection_all_grounded(&self) -> Option<bool> {
475        use crate::sketch::doc::id_key;
476        let edit = self.sketch_edit.as_ref()?;
477        let doc = &edit.session.doc;
478        let sel: Vec<&serde_json::Value> = edit
479            .session
480            .selection
481            .iter()
482            .filter(|r| r.get("kind").and_then(|v| v.as_str()) == Some("point"))
483            .filter_map(|r| r.get("id"))
484            .collect();
485        if sel.is_empty() {
486            return None;
487        }
488        let all = sel.iter().all(|id| {
489            doc.constraints.iter().any(|c| {
490                c.ctype() == Some("⏚")
491                    && c.points().first().map(id_key) == Some(id_key(id))
492            })
493        });
494        Some(all)
495    }
496
497    /// Whether the selected points + geometries are ALL construction (`Some(true)`),
498    /// NOT all construction (`Some(false)`), or nothing is selected (`None`) — labels
499    /// the ◐ construction-toggle button's direction.
500    pub fn sketch_selection_all_construction(&self) -> Option<bool> {
501        let edit = self.sketch_edit.as_ref()?;
502        let doc = &edit.session.doc;
503        let mut any = false;
504        let mut all = true;
505        for r in &edit.session.selection {
506            match r.get("kind").and_then(|v| v.as_str()) {
507                Some("point") => {
508                    any = true;
509                    if let Some(id) = r.get("id") {
510                        if !doc.point(id).map_or(false, |p| p.construction) {
511                            all = false;
512                        }
513                    }
514                }
515                Some("geometry") => {
516                    any = true;
517                    if let Some(id) = r.get("id") {
518                        if !doc.geometry(id).map_or(false, |g| g.construction()) {
519                            all = false;
520                        }
521                    }
522                }
523                _ => {}
524            }
525        }
526        if !any {
527            return None;
528        }
529        Some(all)
530    }
531}
532
533/// Build the applicable-constraint palette for a session's selection — the port of
534/// `SketchMode3D.#refreshContextBar`'s branch logic (constraint buttons only; the
535/// state toggles + cleanup + delete are returned via companion accessors).
536fn sketch_palette_for(session: &crate::sketch::SketchSession) -> Vec<SketchConstraintAction> {
537    use crate::sketch::doc::id_key;
538    use std::collections::HashSet;
539
540    let doc = &session.doc;
541    let mut sel_points: Vec<serde_json::Value> = Vec::new();
542    let mut geos: Vec<&crate::sketch::SketchGeometry> = Vec::new();
543    for r in &session.selection {
544        match r.get("kind").and_then(|v| v.as_str()) {
545            Some("point") => {
546                if let Some(id) = r.get("id") {
547                    sel_points.push(id.clone());
548                }
549            }
550            Some("geometry") => {
551                if let Some(id) = r.get("id") {
552                    if let Some(g) = doc.geometry(id) {
553                        geos.push(g);
554                    }
555                }
556            }
557            _ => {}
558        }
559    }
560
561    // point-coverage = selected point ids ∪ endpoints of selected geometries
562    // (arc: only points[0..2] — center + start).
563    let mut point_set: HashSet<String> = HashSet::new();
564    for id in &sel_points {
565        point_set.insert(id_key(id));
566    }
567    for g in &geos {
568        let pts: &[serde_json::Value] = if g.geom_type == "arc" {
569            &g.points[..g.points.len().min(2)]
570        } else {
571            &g.points
572        };
573        for pid in pts {
574            point_set.insert(id_key(pid));
575        }
576    }
577    let point_count = point_set.len();
578    let selected_point_ids_len = sel_points.len();
579    let is_radial =
580        |g: &crate::sketch::SketchGeometry| g.geom_type == "arc" || g.geom_type == "circle";
581
582    let mut out: Vec<SketchConstraintAction> = Vec::new();
583    let mut push = |symbol: &str, label: &str, dimensional: bool| {
584        out.push(SketchConstraintAction {
585            symbol: symbol.to_string(),
586            label: label.to_string(),
587            dimensional,
588        });
589    };
590
591    // 1 arc/circle → radial dims (defer the leader to S5).
592    if geos.len() == 1 && is_radial(geos[0]) {
593        push("R", "Radius", true);
594        push("⌀", "Diameter", true);
595        return out;
596    }
597    // 2 lines → parallel / perp / angle / equal-length / collinear / point-on-line.
598    if geos.len() == 2 && geos.iter().all(|g| g.geom_type == "line") {
599        push("∥", "Parallel", false);
600        push("⟂", "Perpendicular", false);
601        push("∠", "Angle", true);
602        push("⇌", "Equal distance", false);
603        push("⋰", "Collinear", false);
604        push("⏛", "Point on line", false);
605        return out;
606    }
607    // 2 arcs/circles → equal-radius / concentric / tangent.
608    if geos.len() == 2 && geos.iter().all(|g| is_radial(g)) {
609        push("⊜", "Equal radius", false);
610        push("◎", "Concentric", false);
611        push("⌒", "Tangent", false);
612        return out;
613    }
614    // line + arc/circle → tangent.
615    if geos.len() == 2
616        && ((geos[0].geom_type == "line" && is_radial(geos[1]))
617            || (geos[1].geom_type == "line" && is_radial(geos[0])))
618    {
619        push("⌒", "Tangent", false);
620        return out;
621    }
622    // 1 line + 2 selected points → perpendicular / symmetric-about-line (no return).
623    if geos.len() == 1 && geos[0].geom_type == "line" && selected_point_ids_len == 2 {
624        push("⟂", "Perpendicular", false);
625        push("⋈", "Symmetric about line", false);
626    }
627
628    if point_count == 1 {
629        push("⏚", "Ground (fix point)", false);
630    }
631    if point_count == 2 {
632        push("━", "Horizontal", false);
633        push("│", "Vertical", false);
634        push("≡", "Coincident", false);
635        push("⟺", "Distance", true);
636    }
637    if point_count == 3 {
638        push("⋯", "Midpoint", false);
639        push("⏛", "Point on line", false);
640        push("↥", "Line to point distance", true);
641        push("∠", "Angle", true);
642        // Collinear needs 3+ ACTUAL selected points (not derived from geometry).
643        if selected_point_ids_len >= 3 {
644            push("⋰", "Collinear", false);
645        }
646    }
647    // 4+ free-standing points → still offer Collinear.
648    if point_count > 3 && geos.is_empty() && selected_point_ids_len >= 3 {
649        push("⋰", "Collinear", false);
650    }
651
652    out
653}
654
655/// The point-id list for a tangent (`⌒`) constraint
656/// from two selected geometries — `[lineA, lineB, center, boundary]` for line+circle,
657/// `[c1, boundary1, c2, boundary2]` for circle+circle. `None` for any other pair.
658fn sketch_build_tangent_points(
659    geos: &[&crate::sketch::SketchGeometry],
660) -> Option<Vec<serde_json::Value>> {
661    if geos.len() != 2 {
662        return None;
663    }
664    let is_radial = |g: &crate::sketch::SketchGeometry| g.geom_type == "arc" || g.geom_type == "circle";
665    let line = geos.iter().find(|g| g.geom_type == "line");
666    let circ = geos.iter().find(|g| is_radial(g));
667    if let (Some(line), Some(circ)) = (line, circ) {
668        if line.points.len() < 2 || circ.points.len() < 2 {
669            return None;
670        }
671        return Some(vec![
672            line.points[0].clone(),
673            line.points[1].clone(),
674            circ.points[0].clone(),
675            circ.points[1].clone(),
676        ]);
677    }
678    if geos.iter().all(|g| is_radial(g)) {
679        let (a, b) = (geos[0], geos[1]);
680        if a.points.len() < 2 || b.points.len() < 2 {
681            return None;
682        }
683        return Some(vec![
684            a.points[0].clone(),
685            a.points[1].clone(),
686            b.points[0].clone(),
687            b.points[1].clone(),
688        ]);
689    }
690    None
691}
692
693/// Whether the perpendicular (`⟂`) 4-point list should SWAP its first two points to
694/// orient line 1 closer to 90° against line 2 — a faithful port of the previous
695/// angle-calculation block. `pts = [l1a, l1b, l2a, l2b]`.
696pub(super) fn sketch_perpendicular_should_swap(
697    doc: &crate::sketch::SketchDoc,
698    pts: &[serde_json::Value],
699) -> bool {
700    let coord = |v: &serde_json::Value| doc.point(v).map(|p| (p.x, p.y));
701    let (Some(p0), Some(p1), Some(p2), Some(p3)) =
702        (coord(&pts[0]), coord(&pts[1]), coord(&pts[2]), coord(&pts[3]))
703    else {
704        return false;
705    };
706    // calculateAngle(a, b) = atan2(b.y-a.y, b.x-a.x) in [0, 360).
707    let angle = |a: (f64, f64), b: (f64, f64)| -> f64 {
708        let deg = (b.1 - a.1).atan2(b.0 - a.0) * 180.0 / std::f64::consts::PI;
709        (deg + 360.0) % 360.0
710    };
711    // Fold into (-180, 180]: (a + 180) % 360 - 180.
712    let fold = |a: f64| (a + 180.0) % 360.0 - 180.0;
713    let line1_a = fold(angle(p0, p1));
714    let line1_b = fold(angle(p1, p0));
715    let line2 = fold(angle(p2, p3));
716    let diff_a = line1_a - line2;
717    let diff_b = line1_b - line2;
718    (90.0 - diff_a).abs() > (90.0 - diff_b).abs()
719}
720
721/// The `type + sorted-point-ids` dedup signature (the solver runs with
722/// `remove_implied_duplicates:false`, so this is the only dedup on adds).
723pub(super) fn sketch_constraint_signature(ctype: &str, points: &[serde_json::Value]) -> String {
724    use crate::sketch::doc::id_key;
725    let mut keys: Vec<String> = points.iter().map(id_key).collect();
726    keys.sort();
727    format!("{ctype}|{}", keys.join(","))
728}
729
730/// The port of `ConstraintEngine.createConstraint`: from the session's selection build
731/// the ordered point-id list(s) for `symbol`, dedup on `type + sorted-points`, and
732/// append the constraint(s) (dimensional → `value:null` + `valueNeedsSetup:true`).
733/// Returns whether at least one constraint was added. The caller re-solves.
734fn sketch_build_and_add_constraint(
735    session: &mut crate::sketch::SketchSession,
736    symbol: &str,
737) -> bool {
738    /// The constraint(s) to append for a symbol: the stored solver `type`, its
739    /// display style, and one or more ordered point-id lists (`⏛`-from-2-lines and
740    /// the like push several).
741    struct Built {
742        store_type: String,
743        display_style: &'static str,
744        lists: Vec<Vec<serde_json::Value>>,
745    }
746
747    // --- Phase 1: read-only — assemble the constraint(s) from the selection. ---
748    let built: Option<Built> = {
749        let doc = &session.doc;
750        // `selected` mirrors the previous behavior: point → push it; geometry → push all its
751        // points, arc pops the last (center + start). `geo_items`/`point_items` are
752        // the role-based lists the specials use; `geometry_type` is the LAST
753        // geometry's type; `first_kind` drives the `⋯` reverse.
754        let mut selected: Vec<serde_json::Value> = Vec::new();
755        let mut geo_items: Vec<&crate::sketch::SketchGeometry> = Vec::new();
756        let mut point_items: Vec<serde_json::Value> = Vec::new();
757        let mut geometry_type: Option<String> = None;
758        let mut first_kind: Option<String> = None;
759        let mut has_geometry = false;
760        for (i, r) in session.selection.iter().enumerate() {
761            let kind = r.get("kind").and_then(|v| v.as_str());
762            let Some(id) = r.get("id") else { continue };
763            if i == 0 {
764                first_kind = kind.map(|k| k.to_string());
765            }
766            match kind {
767                Some("point") => {
768                    if let Some(p) = doc.point(id) {
769                        selected.push(p.id.clone());
770                        point_items.push(p.id.clone());
771                    }
772                }
773                Some("geometry") => {
774                    if let Some(g) = doc.geometry(id) {
775                        for pid in &g.points {
776                            if doc.point(pid).is_some() {
777                                selected.push(pid.clone());
778                            }
779                        }
780                        if g.geom_type == "arc" {
781                            selected.pop();
782                        }
783                        geometry_type = Some(g.geom_type.clone());
784                        geo_items.push(g);
785                        has_geometry = true;
786                    }
787                }
788                _ => {}
789            }
790        }
791        if selected.is_empty() {
792            None
793        } else {
794            let radial = |g: &crate::sketch::SketchGeometry| {
795                g.geom_type == "arc" || g.geom_type == "circle"
796            };
797            let simple = |t: &str, list: Vec<serde_json::Value>, ds: &'static str| Built {
798                store_type: t.to_string(),
799                display_style: ds,
800                lists: vec![list],
801            };
802
803            // ---- Geometry-role specials (dispatched by role, not point count). ----
804            match symbol {
805                "◎" => {
806                    if geo_items.len() == 2 && geo_items.iter().all(|g| radial(g)) {
807                        Some(simple(
808                            "◎",
809                            vec![geo_items[0].points[0].clone(), geo_items[1].points[0].clone()],
810                            "",
811                        ))
812                    } else {
813                        None
814                    }
815                }
816                "⊜" => {
817                    if geo_items.len() == 2 && geo_items.iter().all(|g| radial(g)) {
818                        let (g0, g1) = (geo_items[0], geo_items[1]);
819                        Some(simple(
820                            "⊜",
821                            vec![
822                                g0.points[0].clone(),
823                                g0.points[1].clone(),
824                                g1.points[0].clone(),
825                                g1.points[1].clone(),
826                            ],
827                            "",
828                        ))
829                    } else {
830                        None
831                    }
832                }
833                "⌒" => sketch_build_tangent_points(&geo_items).map(|pts| simple("⌒", pts, "")),
834                "⋰" => {
835                    let ids = if geo_items.len() >= 2
836                        && geo_items.iter().all(|g| g.geom_type == "line")
837                    {
838                        let mut v = Vec::new();
839                        for g in &geo_items {
840                            v.push(g.points[0].clone());
841                            v.push(g.points[1].clone());
842                        }
843                        Some(v)
844                    } else if point_items.len() >= 3 {
845                        Some(point_items.clone())
846                    } else {
847                        None
848                    };
849                    ids.filter(|v| v.len() >= 3).map(|v| simple("⋰", v, ""))
850                }
851                "⋈" => geo_items
852                    .iter()
853                    .find(|g| g.geom_type == "line")
854                    .filter(|line| line.points.len() >= 2 && point_items.len() == 2)
855                    .map(|line| {
856                        simple(
857                            "⋈",
858                            vec![
859                                line.points[0].clone(),
860                                line.points[1].clone(),
861                                point_items[0].clone(),
862                                point_items[1].clone(),
863                            ],
864                            "",
865                        )
866                    }),
867                // Radial dims: a `⟺` on [center, boundary] with a radius/diameter
868                // display style (single arc/circle; `selected` is arc-popped to 2).
869                "R" | "⌀" => {
870                    if selected.len() == 2 {
871                        let ds = if symbol == "⌀" { "diameter" } else { "radius" };
872                        Some(Built {
873                            store_type: "⟺".to_string(),
874                            display_style: ds,
875                            lists: vec![selected.clone()],
876                        })
877                    } else {
878                        None
879                    }
880                }
881                _ => {
882                    // ---- Count-based (mirrors the previous `selected.length` blocks). ----
883                    match selected.len() {
884                        1 => match symbol {
885                            "⏚" => Some(simple("⏚", selected.clone(), "")),
886                            _ => None,
887                        },
888                        2 => match symbol {
889                            "━" | "│" | "≡" => Some(simple(symbol, selected.clone(), "")),
890                            "⟺" => {
891                                let ds = if matches!(
892                                    geometry_type.as_deref(),
893                                    Some("arc") | Some("circle")
894                                ) {
895                                    "radius"
896                                } else {
897                                    ""
898                                };
899                                Some(Built {
900                                    store_type: "⟺".to_string(),
901                                    display_style: ds,
902                                    lists: vec![selected.clone()],
903                                })
904                            }
905                            _ => None,
906                        },
907                        3 => match symbol {
908                            "⏛" => Some(simple("⏛", selected.clone(), "")),
909                            "⋯" => {
910                                let mut pts = selected.clone();
911                                if has_geometry && first_kind.as_deref() == Some("point") {
912                                    pts.reverse();
913                                }
914                                Some(simple("⋯", pts, ""))
915                            }
916                            "↥" => {
917                                if geo_items.len() == 1 && point_items.len() == 1 {
918                                    let line = geo_items[0];
919                                    if line.geom_type == "line" && line.points.len() >= 2 {
920                                        Some(simple(
921                                            "↥",
922                                            vec![
923                                                line.points[0].clone(),
924                                                line.points[1].clone(),
925                                                point_items[0].clone(),
926                                            ],
927                                            "",
928                                        ))
929                                    } else {
930                                        None
931                                    }
932                                } else {
933                                    // 3 raw points (no geometry) → point-line-distance
934                                    // over the selection as-is.
935                                    Some(simple("↥", selected.clone(), ""))
936                                }
937                            }
938                            "⇌" => Some(simple("⇌", selected.clone(), "")),
939                            _ => None,
940                        },
941                        4 | 5 => match symbol {
942                            "⏛" => {
943                                // Two lines → constrain BOTH endpoints of line 2 onto
944                                // line 1 (two point-on-line constraints).
945                                if geo_items.len() == 2
946                                    && geo_items.iter().all(|g| g.geom_type == "line")
947                                    && geo_items[0].points.len() >= 2
948                                    && geo_items[1].points.len() >= 2
949                                {
950                                    let (g0, g1) = (geo_items[0], geo_items[1]);
951                                    Some(Built {
952                                        store_type: "⏛".to_string(),
953                                        display_style: "",
954                                        lists: vec![
955                                            vec![
956                                                g0.points[0].clone(),
957                                                g0.points[1].clone(),
958                                                g1.points[0].clone(),
959                                            ],
960                                            vec![
961                                                g0.points[0].clone(),
962                                                g0.points[1].clone(),
963                                                g1.points[1].clone(),
964                                            ],
965                                        ],
966                                    })
967                                } else {
968                                    None
969                                }
970                            }
971                            "⟂" => {
972                                if selected.len() != 4 {
973                                    None
974                                } else {
975                                    let mut pts = selected.clone();
976                                    if sketch_perpendicular_should_swap(doc, &pts) {
977                                        pts.swap(0, 1);
978                                    }
979                                    Some(simple("⟂", pts, ""))
980                                }
981                            }
982                            "∥" => Some(simple("∥", selected.clone(), "")),
983                            "∠" => Some(simple("∠", selected.clone(), "")),
984                            "⇌" => Some(simple("⇌", selected.clone(), "")),
985                            _ => None,
986                        },
987                        _ => None,
988                    }
989                }
990            }
991        }
992    };
993
994    let Some(built) = built else {
995        return false;
996    };
997
998    // --- Phase 2: mutate — dedup + append (every constraint carries the base
999    //     fields; the solver seeds a null value from the current measurement). ---
1000    let doc = &mut session.doc;
1001    let mut added_any = false;
1002    for pts in &built.lists {
1003        let sig = sketch_constraint_signature(&built.store_type, pts);
1004        let duplicate = doc.constraints.iter().any(|c| match c.ctype() {
1005            Some(t) => sketch_constraint_signature(t, c.points()) == sig,
1006            None => false,
1007        });
1008        if duplicate {
1009            continue;
1010        }
1011        let id = doc.next_constraint_id();
1012        let mut raw = serde_json::Map::new();
1013        raw.insert("id".to_string(), id);
1014        raw.insert(
1015            "type".to_string(),
1016            serde_json::Value::String(built.store_type.clone()),
1017        );
1018        raw.insert("points".to_string(), serde_json::Value::Array(pts.clone()));
1019        raw.insert("labelX".to_string(), serde_json::Value::from(0));
1020        raw.insert("labelY".to_string(), serde_json::Value::from(0));
1021        raw.insert(
1022            "displayStyle".to_string(),
1023            serde_json::Value::String(built.display_style.to_string()),
1024        );
1025        raw.insert("value".to_string(), serde_json::Value::Null);
1026        raw.insert("valueNeedsSetup".to_string(), serde_json::Value::Bool(true));
1027        doc.constraints.push(crate::sketch::SketchConstraint { raw });
1028        added_any = true;
1029    }
1030    added_any
1031}
1032
1033// BREP private tests: bd659728ecbb2e0d