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
26/// How a notice is drawn: an [`Error`](Self::Error) (a refusal, a failure —
27/// what [`EngineState::push_notice`] queues), a [`Warning`](Self::Warning)
28/// (it worked, and something needs a look) or [`Info`](Self::Info) (it worked:
29/// "Updated 2 part(s)", "Saved 'widget'").
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub enum NoticeSeverity {
32    #[default]
33    Error,
34    Warning,
35    Info,
36}
37
38impl EngineState {
39    /// Queue a notice of `severity`. [`Self::push_notice`] is this with
40    /// [`NoticeSeverity::Error`]; bounded the same way.
41    pub fn push_notice_as(&mut self, severity: NoticeSeverity, message: impl Into<String>) {
42        if severity == NoticeSeverity::Error {
43            return self.push_notice(message);
44        }
45        let message = message.into();
46        #[cfg(not(target_arch = "wasm32"))]
47        eprintln!("{message}");
48        self.graded_notices.push((severity, message));
49        if self.graded_notices.len() > 8 {
50            let overflow = self.graded_notices.len() - 8;
51            self.graded_notices.drain(0..overflow);
52        }
53    }
54
55    /// Drain every queued notice with its severity: the errors, then the
56    /// warnings and successes. The shell's toast overlay reads this.
57    pub fn take_graded_notices(&mut self) -> Vec<(NoticeSeverity, String)> {
58        let errors = std::mem::take(&mut self.notices).into_iter().map(|text| (NoticeSeverity::Error, text));
59        errors.chain(std::mem::take(&mut self.graded_notices)).collect()
60    }
61
62    /// Queue a transient user-facing notice (shown as a toast by the shell) and,
63    /// on native, also log it. Bounded so a pathological loop can't grow it.
64    pub fn push_notice(&mut self, message: impl Into<String>) {
65        let message = message.into();
66        #[cfg(not(target_arch = "wasm32"))]
67        eprintln!("{message}");
68        self.notices.push(message);
69        if self.notices.len() > 8 {
70            let overflow = self.notices.len() - 8;
71            self.notices.drain(0..overflow);
72        }
73    }
74
75    /// Drain the queued notices (the shell calls this once per frame and shows
76    /// each as a toast).
77    pub fn take_notices(&mut self) -> Vec<String> {
78        self.take_graded_notices().into_iter().map(|(_, text)| text).collect()
79    }
80
81    /// Re-solve the ACTIVE sketch session and, on failure, queue a user notice
82    /// naming `context`. Replaces the scattered swallowed `eprintln!` on the
83    /// interactive re-solve paths — the solver rarely fails, but when an edit
84    /// leaves the sketch unsolvable the user should see why.
85    pub(super) fn resolve_active_sketch(&mut self, context: &str) {
86        let error = self
87            .sketch_edit
88            .as_mut()
89            .and_then(|edit| edit.session.resolve().err());
90        if let Some(error) = error {
91            self.push_notice(format!("Sketch solve failed ({context}): {error}"));
92        }
93    }
94
95    /// Whether `(kind, id)` is in the current sketch selection.
96    fn sketch_ref_selected(session: &crate::sketch::SketchSession, kind: &str, id: &serde_json::Value) -> bool {
97        use crate::sketch::doc::id_key;
98        let key = id_key(id);
99        session.selection.iter().any(|r| {
100            r.get("kind").and_then(serde_json::Value::as_str) == Some(kind)
101                && r.get("id").map(id_key).as_deref() == Some(key.as_str())
102        })
103    }
104
105    /// The Points list: `P{id} (x, y)` plus ⛓ external / ◐ construction / ⏚ ground
106    /// markers, mirroring the previous Points rows.
107    pub fn sketch_point_rows(&self) -> Vec<SketchEntityRow> {
108        use crate::sketch::doc::id_key;
109        let Some(edit) = self.sketch_edit.as_ref() else {
110            return Vec::new();
111        };
112        let session = &edit.session;
113        session
114            .doc
115            .points
116            .iter()
117            .map(|p| {
118                let grounded = session.doc.constraints.iter().any(|c| {
119                    c.ctype() == Some("⏚")
120                        && c.points().first().map(id_key).as_deref() == Some(id_key(&p.id).as_str())
121                });
122                let mut marks = String::new();
123                if p.external_reference {
124                    marks.push_str(" \u{26D3}"); // ⛓ chain-links (bundled font; 🔗 is not)
125                }
126                if p.construction {
127                    marks.push_str(" ◐");
128                }
129                if grounded {
130                    marks.push_str(" ⏚");
131                }
132                SketchEntityRow {
133                    kind: "point",
134                    id: p.id.clone(),
135                    label: format!("P{} ({:.1}, {:.1}){marks}", id_key(&p.id), p.x, p.y),
136                    selected: Self::sketch_ref_selected(session, "point", &p.id),
137                    construction: p.construction,
138                    conflicting: false,
139                }
140            })
141            .collect()
142    }
143
144    /// The Curves list: `{type}:{id} [p0,p1,…]` (◐ for construction), mirroring the
145    /// previous Curves rows.
146    pub fn sketch_geometry_rows(&self) -> Vec<SketchEntityRow> {
147        use crate::sketch::doc::id_key;
148        let Some(edit) = self.sketch_edit.as_ref() else {
149            return Vec::new();
150        };
151        let session = &edit.session;
152        session
153            .doc
154            .geometries
155            .iter()
156            .map(|g| {
157                let pts = g
158                    .points
159                    .iter()
160                    .map(id_key)
161                    .collect::<Vec<_>>()
162                    .join(",");
163                let construction = g.construction();
164                let mark = if construction { " ◐" } else { "" };
165                SketchEntityRow {
166                    kind: "geometry",
167                    id: g.id.clone(),
168                    label: format!("{}:{}{mark} [{pts}]", g.geom_type, id_key(&g.id)),
169                    selected: Self::sketch_ref_selected(session, "geometry", &g.id),
170                    construction,
171                    conflicting: false,
172                }
173            })
174            .collect()
175    }
176
177    /// The Constraints list: `{id} {type} {value} [points]`, mirroring the previous
178    /// Constraints rows.
179    pub fn sketch_constraint_rows(&self) -> Vec<SketchEntityRow> {
180        use crate::sketch::doc::id_key;
181        let Some(edit) = self.sketch_edit.as_ref() else {
182            return Vec::new();
183        };
184        let session = &edit.session;
185        session
186            .doc
187            .constraints
188            .iter()
189            .filter_map(|c| {
190                let id = c.raw.get("id")?.clone();
191                let ctype = c.ctype().unwrap_or("?");
192                let value = c
193                    .raw
194                    .get("value")
195                    .and_then(serde_json::Value::as_f64)
196                    .map(|v| format!(" {v:.3}"))
197                    .unwrap_or_default();
198                let pts = c
199                    .points()
200                    .iter()
201                    .map(id_key)
202                    .collect::<Vec<_>>()
203                    .join(",");
204                Some(SketchEntityRow {
205                    kind: "constraint",
206                    id: id.clone(),
207                    label: format!("{} {ctype}{value} [{pts}]", id_key(&id)),
208                    selected: Self::sketch_ref_selected(session, "constraint", &id),
209                    construction: false,
210                    conflicting: session.diagnostics.constraint_conflicting(&id),
211                })
212            })
213            .collect()
214    }
215
216    /// Select an entity BY ref from a list row (mirrors [`sketch_click_at`]): honors the
217    /// SAME "Multi-select" setting — under `ClickToggles` a plain click toggles the row
218    /// (no modifier needed); under `CtrlClick` a plain click replaces the
219    /// selection and an additive (Ctrl/Cmd) click toggles.
220    pub fn sketch_select_entity(&mut self, kind: &str, id: serde_json::Value, additive: bool) {
221        let toggles = self.settings.multi_select == crate::style::MultiSelectMode::ClickToggles;
222        let entity_ref = serde_json::json!({ "kind": kind, "id": id });
223        let Some(edit) = self.sketch_edit.as_mut() else {
224            return;
225        };
226        if !additive && !toggles {
227            edit.session.clear_selection();
228        }
229        edit.session.toggle_selection(entity_ref);
230        self.refresh_sketch_overlay();
231        self.dirty = true;
232    }
233
234    /// Hover an entity BY ref from a list row (list→canvas highlight). Sets the
235    /// one-frame guard so the viewport — which draws after the panel and would
236    /// otherwise clear the hover because the pointer is off the viewport — keeps it.
237    pub fn sketch_hover_entity(&mut self, kind: &str, id: serde_json::Value) {
238        if self.sketch_edit.is_none() {
239            return;
240        }
241        // `set_sketch_hover` re-tessellates only when the hovered ref actually
242        // changes, so holding the pointer on one row does not churn the overlay.
243        let entity_ref = serde_json::json!({ "kind": kind, "id": id });
244        self.set_sketch_hover(Some(entity_ref));
245        self.sketch_list_hover_active = true;
246    }
247
248    /// Consume the "list panel set the hover this frame" guard (read + reset). The
249    /// viewport calls this before its off-viewport `sketch_clear_hover` so a
250    /// panel-set hover survives the frame.
251    pub fn take_sketch_list_hover(&mut self) -> bool {
252        std::mem::take(&mut self.sketch_list_hover_active)
253    }
254
255    /// The active sketch's solver settings (the Solver Settings panel reads this).
256    /// `None` when not editing a sketch.
257    pub fn sketch_solver_settings(&self) -> Option<crate::sketch::SketchSolverSettings> {
258        self.sketch_edit
259            .as_ref()
260            .map(|edit| edit.session.solver_settings.clone())
261    }
262
263    /// Replace the active sketch's solver settings and re-solve so the change
264    /// takes effect immediately. No-op when not editing a sketch.
265    pub fn sketch_set_solver_settings(&mut self, settings: crate::sketch::SketchSolverSettings) {
266        if let Some(edit) = self.sketch_edit.as_mut() {
267            edit.session.solver_settings = settings;
268        } else {
269            return;
270        }
271        self.resolve_active_sketch("solver settings");
272        self.refresh_sketch_overlay();
273        self.dirty = true;
274    }
275}
276
277/// One applicable-constraint palette entry: the glyph passed back to
278/// [`EngineState::sketch_add_constraint`] plus a human tooltip and a dimensional
279/// tag (whether it opens a value the solver seeds from the current measurement).
280#[derive(Clone, Debug, PartialEq)]
281pub struct SketchConstraintAction {
282    /// The constraint glyph (e.g. `"━"`, `"⟂"`, `"R"`) — the argument to
283    /// [`EngineState::sketch_add_constraint`].
284    pub symbol: String,
285    /// A human tooltip label (e.g. `"Horizontal"`, `"Radius"`).
286    pub label: String,
287    /// Whether this is a dimensional constraint (its value is seeded from the
288    /// current measurement; S5 makes it editable).
289    pub dimensional: bool,
290}
291
292impl EngineState {
293    /// The ordered palette of constraints applicable to the active sketch selection
294    /// (a faithful port of `#refreshContextBar`). Empty when not in sketch mode or
295    /// the selection surfaces no constraint. The Fix/Unfix + construction + cleanup
296    /// affordances are exposed separately (see [`sketch_selection_all_grounded`] /
297    /// [`sketch_selection_all_construction`] / [`sketch_cleanup_unused_points`]).
298    ///
299    /// [`sketch_selection_all_grounded`]: Self::sketch_selection_all_grounded
300    /// [`sketch_selection_all_construction`]: Self::sketch_selection_all_construction
301    /// [`sketch_cleanup_unused_points`]: Self::sketch_cleanup_unused_points
302    pub fn sketch_applicable_constraints(&self) -> Vec<SketchConstraintAction> {
303        match self.sketch_edit.as_ref() {
304            Some(edit) => sketch_palette_for(&edit.session),
305            None => Vec::new(),
306        }
307    }
308
309    /// Add the constraint named by `symbol` from the current selection (a port of
310    /// `createConstraint`): build the ordered point-id list, dedup on
311    /// `type + sorted-points`, append (dimensional → `value:null`), then re-solve +
312    /// refresh (keeping the selection). Returns whether a constraint was added.
313    pub fn sketch_add_constraint(&mut self, symbol: &str) -> bool {
314        // Snapshot BEFORE the (in-place) add; discard it below when the add is a
315        // dedup no-op so a dead click neither pollutes undo nor clobbers redo (S6a).
316        let added = match self.sketch_edit.as_mut() {
317            Some(edit) => {
318                edit.record_undo();
319                sketch_build_and_add_constraint(&mut edit.session, symbol)
320            }
321            None => return false,
322        };
323        if added {
324            self.resolve_active_sketch("add-constraint");
325            self.refresh_sketch_overlay();
326            self.dirty = true;
327        } else if let Some(edit) = self.sketch_edit.as_mut() {
328            edit.undo_stack.pop();
329        }
330        added
331    }
332
333    /// Toggle the ground (`⏚`) constraint on the selected points: if ALL selected
334    /// points are already grounded → remove those grounds (and clear their `fixed`
335    /// flag); else add a `⏚` for each ungrounded selected point (and set `fixed`).
336    /// Re-solves + refreshes. Returns whether anything changed. No-op with no point
337    /// selected / not in sketch mode.
338    pub fn sketch_toggle_ground(&mut self) -> bool {
339        use crate::sketch::doc::id_key;
340        use std::collections::HashSet;
341
342        let Some(edit) = self.sketch_edit.as_mut() else {
343            return false;
344        };
345        let sel_ids: Vec<serde_json::Value> = edit
346            .session
347            .selection
348            .iter()
349            .filter(|r| r.get("kind").and_then(|v| v.as_str()) == Some("point"))
350            .filter_map(|r| r.get("id").cloned())
351            .collect();
352        if sel_ids.is_empty() {
353            return false;
354        }
355        // Selected points → the toggle always adds or removes grounds; snapshot (S6a).
356        edit.record_undo();
357        let doc = &mut edit.session.doc;
358        let has_ground = |doc: &crate::sketch::SketchDoc, id: &serde_json::Value| -> bool {
359            doc.constraints.iter().any(|c| {
360                c.ctype() == Some("⏚")
361                    && c.points().first().map(id_key) == Some(id_key(id))
362            })
363        };
364        let all_grounded = sel_ids.iter().all(|id| has_ground(doc, id));
365        if all_grounded {
366            let sel_keys: HashSet<String> = sel_ids.iter().map(id_key).collect();
367            doc.constraints.retain(|c| {
368                if c.ctype() != Some("⏚") {
369                    return true;
370                }
371                match c.points().first() {
372                    Some(p) => !sel_keys.contains(&id_key(p)),
373                    None => true,
374                }
375            });
376            for id in &sel_ids {
377                if let Some(p) = doc.point_mut(id) {
378                    p.fixed = false;
379                }
380            }
381        } else {
382            for id in &sel_ids {
383                if has_ground(doc, id) {
384                    continue;
385                }
386                let cid = doc.next_constraint_id();
387                let mut raw = serde_json::Map::new();
388                raw.insert("id".to_string(), cid);
389                raw.insert("type".to_string(), serde_json::Value::String("⏚".to_string()));
390                raw.insert(
391                    "points".to_string(),
392                    serde_json::Value::Array(vec![id.clone()]),
393                );
394                doc.constraints.push(crate::sketch::SketchConstraint { raw });
395                if let Some(p) = doc.point_mut(id) {
396                    p.fixed = true;
397                }
398            }
399        }
400        self.resolve_active_sketch("toggle-ground");
401        self.refresh_sketch_overlay();
402        self.dirty = true;
403        true
404    }
405
406    /// Flip the `construction` flag on ALL selected points AND geometries: if every
407    /// selected entity is already construction → make them regular, else make them
408    /// all construction. Re-solves + refreshes. Returns whether anything was
409    /// selected. No-op with nothing selected / not in sketch mode.
410    pub fn sketch_toggle_construction(&mut self) -> bool {
411        let Some(edit) = self.sketch_edit.as_mut() else {
412            return false;
413        };
414        let mut pt_ids: Vec<serde_json::Value> = Vec::new();
415        let mut geo_ids: Vec<serde_json::Value> = Vec::new();
416        for r in &edit.session.selection {
417            match r.get("kind").and_then(|v| v.as_str()) {
418                Some("point") => {
419                    if let Some(id) = r.get("id") {
420                        pt_ids.push(id.clone());
421                    }
422                }
423                Some("geometry") => {
424                    if let Some(id) = r.get("id") {
425                        geo_ids.push(id.clone());
426                    }
427                }
428                _ => {}
429            }
430        }
431        if pt_ids.is_empty() && geo_ids.is_empty() {
432            return false;
433        }
434        // Selected entities → the flip always changes something; snapshot (S6a).
435        edit.record_undo();
436        let doc = &mut edit.session.doc;
437        let all_construction = pt_ids
438            .iter()
439            .all(|id| doc.point(id).map_or(false, |p| p.construction))
440            && geo_ids
441                .iter()
442                .all(|id| doc.geometry(id).map_or(false, |g| g.construction()));
443        let next = !all_construction;
444        for id in &pt_ids {
445            if let Some(p) = doc.point_mut(id) {
446                p.construction = next;
447            }
448        }
449        for id in &geo_ids {
450            if let Some(g) = doc.geometry_mut(id) {
451                g.extra
452                    .insert("construction".to_string(), serde_json::Value::Bool(next));
453            }
454        }
455        self.resolve_active_sketch("toggle-construction");
456        self.refresh_sketch_overlay();
457        self.dirty = true;
458        true
459    }
460
461    /// Remove points referenced by NO geometry AND no constraint (the 🧹 action).
462    /// Re-solves + refreshes only when something was dropped. Returns whether a
463    /// point was removed. No-op when not in sketch mode.
464    pub fn sketch_cleanup_unused_points(&mut self) -> bool {
465        use crate::sketch::doc::id_key;
466        use std::collections::HashSet;
467
468        let Some(edit) = self.sketch_edit.as_mut() else {
469            return false;
470        };
471        // Snapshot before the cleanup; discarded below when nothing is removed (S6a).
472        edit.record_undo();
473        let doc = &mut edit.session.doc;
474        let mut used: HashSet<String> = HashSet::new();
475        for g in &doc.geometries {
476            for pid in &g.points {
477                used.insert(id_key(pid));
478            }
479        }
480        for c in &doc.constraints {
481            for pid in c.points() {
482                used.insert(id_key(pid));
483            }
484        }
485        let before = doc.points.len();
486        doc.points.retain(|p| used.contains(&id_key(&p.id)));
487        let removed = doc.points.len() != before;
488        if !removed {
489            edit.undo_stack.pop();
490            return false;
491        }
492        self.resolve_active_sketch("cleanup");
493        self.refresh_sketch_overlay();
494        self.dirty = true;
495        true
496    }
497
498    /// The number of constraints in the active sketch (0 when not in sketch mode) —
499    /// the verifier / palette readout.
500    pub fn sketch_constraint_count(&self) -> usize {
501        self.sketch_edit
502            .as_ref()
503            .map_or(0, |edit| edit.session.doc.constraints.len())
504    }
505
506    /// Whether the selected points are ALL grounded (`Some(true)`), NOT all grounded
507    /// (`Some(false)`), or no point is selected (`None`) — labels the Fix vs Unfix
508    /// button.
509    pub fn sketch_selection_all_grounded(&self) -> Option<bool> {
510        use crate::sketch::doc::id_key;
511        let edit = self.sketch_edit.as_ref()?;
512        let doc = &edit.session.doc;
513        let sel: Vec<&serde_json::Value> = edit
514            .session
515            .selection
516            .iter()
517            .filter(|r| r.get("kind").and_then(|v| v.as_str()) == Some("point"))
518            .filter_map(|r| r.get("id"))
519            .collect();
520        if sel.is_empty() {
521            return None;
522        }
523        let all = sel.iter().all(|id| {
524            doc.constraints.iter().any(|c| {
525                c.ctype() == Some("⏚")
526                    && c.points().first().map(id_key) == Some(id_key(id))
527            })
528        });
529        Some(all)
530    }
531
532    /// Whether the selected points + geometries are ALL construction (`Some(true)`),
533    /// NOT all construction (`Some(false)`), or nothing is selected (`None`) — labels
534    /// the ◐ construction-toggle button's direction.
535    pub fn sketch_selection_all_construction(&self) -> Option<bool> {
536        let edit = self.sketch_edit.as_ref()?;
537        let doc = &edit.session.doc;
538        let mut any = false;
539        let mut all = true;
540        for r in &edit.session.selection {
541            match r.get("kind").and_then(|v| v.as_str()) {
542                Some("point") => {
543                    any = true;
544                    if let Some(id) = r.get("id") {
545                        if !doc.point(id).map_or(false, |p| p.construction) {
546                            all = false;
547                        }
548                    }
549                }
550                Some("geometry") => {
551                    any = true;
552                    if let Some(id) = r.get("id") {
553                        if !doc.geometry(id).map_or(false, |g| g.construction()) {
554                            all = false;
555                        }
556                    }
557                }
558                _ => {}
559            }
560        }
561        if !any {
562            return None;
563        }
564        Some(all)
565    }
566}
567
568/// Build the applicable-constraint palette for a session's selection — the port of
569/// `SketchMode3D.#refreshContextBar`'s branch logic (constraint buttons only; the
570/// state toggles + cleanup + delete are returned via companion accessors).
571fn sketch_palette_for(session: &crate::sketch::SketchSession) -> Vec<SketchConstraintAction> {
572    use crate::sketch::doc::id_key;
573    use std::collections::HashSet;
574
575    let doc = &session.doc;
576    let mut sel_points: Vec<serde_json::Value> = Vec::new();
577    let mut geos: Vec<&crate::sketch::SketchGeometry> = Vec::new();
578    for r in &session.selection {
579        match r.get("kind").and_then(|v| v.as_str()) {
580            Some("point") => {
581                if let Some(id) = r.get("id") {
582                    sel_points.push(id.clone());
583                }
584            }
585            Some("geometry") => {
586                if let Some(id) = r.get("id") {
587                    if let Some(g) = doc.geometry(id) {
588                        geos.push(g);
589                    }
590                }
591            }
592            _ => {}
593        }
594    }
595
596    // point-coverage = selected point ids ∪ endpoints of selected geometries
597    // (arc: only points[0..2] — center + start).
598    let mut point_set: HashSet<String> = HashSet::new();
599    for id in &sel_points {
600        point_set.insert(id_key(id));
601    }
602    for g in &geos {
603        let pts: &[serde_json::Value] = if g.geom_type == "arc" {
604            &g.points[..g.points.len().min(2)]
605        } else {
606            &g.points
607        };
608        for pid in pts {
609            point_set.insert(id_key(pid));
610        }
611    }
612    let point_count = point_set.len();
613    let selected_point_ids_len = sel_points.len();
614    let is_radial =
615        |g: &crate::sketch::SketchGeometry| g.geom_type == "arc" || g.geom_type == "circle";
616
617    let mut out: Vec<SketchConstraintAction> = Vec::new();
618    let mut push = |symbol: &str, label: &str, dimensional: bool| {
619        out.push(SketchConstraintAction {
620            symbol: symbol.to_string(),
621            label: label.to_string(),
622            dimensional,
623        });
624    };
625
626    // 1 arc/circle → radial dims (defer the leader to S5).
627    if geos.len() == 1 && is_radial(geos[0]) {
628        push("R", "Radius", true);
629        push("⌀", "Diameter", true);
630        return out;
631    }
632    // 2 lines → parallel / perp / angle / equal-length / collinear / point-on-line,
633    // plus tangent when either line is a spline HANDLE GUIDE. A guide IS a line, so
634    // every offer above stays legitimate on one (its two ends are still two points
635    // whose direction can be made parallel to another line's); tangency is the extra
636    // question only a guide can be asked, and it is the reason a guide gets selected
637    // at all. Both new pairs — guide + line and guide + guide — are pairs the
638    // solver's `c_tangent` already reads as a spline tangent, at a drawn end and at
639    // an inserted interior anchor alike.
640    if geos.len() == 2 && geos.iter().all(|g| g.geom_type == "line") {
641        push("∥", "Parallel", false);
642        push("⟂", "Perpendicular", false);
643        push("∠", "Angle", true);
644        push("⇌", "Equal distance", false);
645        push("⋰", "Collinear", false);
646        push("⏛", "Point on line", false);
647        if geos
648            .iter()
649            .any(|g| crate::sketch::spline::is_handle_guide(doc, g))
650        {
651            push("⌒", "Tangent", false);
652        }
653        // Curvature (G2) is the narrower question: the two guides have to be the
654        // two sides of ONE joint — either side of one interior anchor, or two
655        // ends the document already holds together. A guide paired with a plain
656        // line is never one (a line has no curvature to match), and neither are
657        // two guides at unrelated anchors.
658        if crate::sketch::spline::is_curvature_joint(doc, geos[0], geos[1]) {
659            push("ϰ", "Curvature (G2)", false);
660        }
661        return out;
662    }
663    // 2 arcs/circles → equal-radius / concentric / tangent.
664    if geos.len() == 2 && geos.iter().all(|g| is_radial(g)) {
665        push("⊜", "Equal radius", false);
666        push("◎", "Concentric", false);
667        push("⌒", "Tangent", false);
668        return out;
669    }
670    // line + arc/circle → tangent, plus curvature when the line is a spline
671    // handle guide at an END anchor: the spline's curvature there becomes the
672    // circle's 1/ρ, which is how a curve is continued into an arc.
673    if geos.len() == 2
674        && ((geos[0].geom_type == "line" && is_radial(geos[1]))
675            || (geos[1].geom_type == "line" && is_radial(geos[0])))
676    {
677        push("⌒", "Tangent", false);
678        if geos
679            .iter()
680            .any(|g| crate::sketch::spline::is_end_handle_guide(doc, g))
681        {
682            push("ϰ", "Curvature (G2)", false);
683        }
684        return out;
685    }
686    // A spline BODY + a line, or a spline body + an arc/circle → tangency at a
687    // point INTERIOR to one of the spline's spans (`∿`).
688    //
689    // The PICK is the whole difference from `⌒`. Selecting an anchor's handle
690    // guide asks for tangency AT that anchor, where the spline's tangent has a
691    // name (anchor→handle) and no touch parameter is needed — that stays `⌒`.
692    // Selecting the curve ITSELF asks for tangency wherever the two geometries
693    // actually touch, which is the foot-parameter lane and a different
694    // constraint type. So a guide is refused here, and a body is refused there.
695    if sketch_foot_tangent_pair(doc, &geos).is_some() {
696        push("∿", "Tangent (span point)", false);
697        return out;
698    }
699    // 1 line + 2 selected points → perpendicular / symmetric-about-line (no return).
700    if geos.len() == 1 && geos[0].geom_type == "line" && selected_point_ids_len == 2 {
701        push("⟂", "Perpendicular", false);
702        push("⋈", "Symmetric about line", false);
703    }
704
705    if point_count == 1 {
706        push("⏚", "Ground (fix point)", false);
707    }
708    if point_count == 2 {
709        push("━", "Horizontal", false);
710        push("│", "Vertical", false);
711        push("≡", "Coincident", false);
712        push("⟺", "Distance", true);
713    }
714    if point_count == 3 {
715        push("⋯", "Midpoint", false);
716        push("⏛", "Point on line", false);
717        push("↥", "Line to point distance", true);
718        push("∠", "Angle", true);
719        // Collinear needs 3+ ACTUAL selected points (not derived from geometry).
720        if selected_point_ids_len >= 3 {
721            push("⋰", "Collinear", false);
722        }
723    }
724    // 4+ free-standing points → still offer Collinear.
725    if point_count > 3 && geos.is_empty() && selected_point_ids_len >= 3 {
726        push("⋰", "Collinear", false);
727    }
728
729    out
730}
731
732/// The point-id list for a tangent (`⌒`) constraint
733/// from two selected geometries — `[lineA, lineB, center, boundary]` for line+circle,
734/// `[c1, boundary1, c2, boundary2]` for circle+circle, and `[a0, a1, b0, b1]` for two
735/// lines of which at least one is a spline handle guide. `None` for any other pair.
736///
737/// The guide gate on the two-line case is not decoration: the solver's `c_tangent`
738/// falls through to `c_tangent_line_circle` for a pair it cannot read as a spline,
739/// which would take the second line's two ends for a circle's centre and rim. A pair
740/// of plain lines has no tangency to state, so it is refused here rather than
741/// silently stored as a nonsense circle.
742fn sketch_build_tangent_points(
743    doc: &crate::sketch::SketchDoc,
744    geos: &[&crate::sketch::SketchGeometry],
745) -> Option<Vec<serde_json::Value>> {
746    if geos.len() != 2 {
747        return None;
748    }
749    let is_radial = |g: &crate::sketch::SketchGeometry| g.geom_type == "arc" || g.geom_type == "circle";
750    if geos.iter().all(|g| g.geom_type == "line") {
751        let (a, b) = (geos[0], geos[1]);
752        if a.points.len() < 2 || b.points.len() < 2 {
753            return None;
754        }
755        if !crate::sketch::spline::is_handle_guide(doc, a)
756            && !crate::sketch::spline::is_handle_guide(doc, b)
757        {
758            return None;
759        }
760        return Some(vec![
761            a.points[0].clone(),
762            a.points[1].clone(),
763            b.points[0].clone(),
764            b.points[1].clone(),
765        ]);
766    }
767    let line = geos.iter().find(|g| g.geom_type == "line");
768    let circ = geos.iter().find(|g| is_radial(g));
769    if let (Some(line), Some(circ)) = (line, circ) {
770        if line.points.len() < 2 || circ.points.len() < 2 {
771            return None;
772        }
773        return Some(vec![
774            line.points[0].clone(),
775            line.points[1].clone(),
776            circ.points[0].clone(),
777            circ.points[1].clone(),
778        ]);
779    }
780    if geos.iter().all(|g| is_radial(g)) {
781        let (a, b) = (geos[0], geos[1]);
782        if a.points.len() < 2 || b.points.len() < 2 {
783            return None;
784        }
785        return Some(vec![
786            a.points[0].clone(),
787            a.points[1].clone(),
788            b.points[0].clone(),
789            b.points[1].clone(),
790        ]);
791    }
792    None
793}
794
795/// The `(spline body, line-or-circle)` pair a foot-parameter tangency (`∿`) is
796/// offered for, or `None` for any other selection.
797///
798/// ONE gate, read by both the palette offer and the point-list builder, so an
799/// offered `∿` is an `∿` that builds and solves — the same contract the tangent's
800/// guide gate carries. What it admits:
801///
802/// * exactly two geometries, one of them a spline (a `3n + 1` control polygon),
803/// * the other a plain line or an arc/circle,
804/// * and NOT a spline handle guide: a guide is a line whose two ends are the
805///   spline's own control points, so tangency to it would be tangency of the
806///   curve to its own construction artifact. Tangency at the anchor a guide
807///   stands for is `⌒`'s lane, which is where that selection belongs.
808fn sketch_foot_tangent_pair<'a>(
809    doc: &crate::sketch::SketchDoc,
810    geos: &[&'a crate::sketch::SketchGeometry],
811) -> Option<(&'a crate::sketch::SketchGeometry, &'a crate::sketch::SketchGeometry)> {
812    if geos.len() != 2 {
813        return None;
814    }
815    let is_spline = |g: &crate::sketch::SketchGeometry| {
816        crate::sketch::spline::is_spline_type(&g.geom_type)
817    };
818    let is_radial =
819        |g: &crate::sketch::SketchGeometry| g.geom_type == "arc" || g.geom_type == "circle";
820    let spline = *geos.iter().find(|g| is_spline(g))?;
821    let target = *geos.iter().find(|g| !is_spline(g))?;
822    if spline.points.len() < 4 || target.points.len() < 2 {
823        return None;
824    }
825    let target_is_usable = if target.geom_type == "line" {
826        !crate::sketch::spline::is_handle_guide(doc, target)
827    } else {
828        is_radial(target)
829    };
830    target_is_usable.then_some((spline, target))
831}
832
833/// The point-id list for a foot-parameter tangency (`∿`) —
834/// `[lineA, lineB, s0, s1]` against a line, `[center, boundary, s0, s1]` against
835/// an arc/circle, where `(s0, s1)` are the SPLINE's first two control ids acting
836/// as a geometry handle.
837///
838/// The spline is named by a pair rather than by its geometry id because a
839/// constraint record's only geometric field is `points` — the same reason the
840/// tangent names a circle by `(centre, rim)`. Two leading controls identify one
841/// spline: a document cannot hold two curves that start from the same two points
842/// without them being the same curve.
843fn sketch_build_foot_tangent_points(
844    doc: &crate::sketch::SketchDoc,
845    geos: &[&crate::sketch::SketchGeometry],
846) -> Option<Vec<serde_json::Value>> {
847    let (spline, target) = sketch_foot_tangent_pair(doc, geos)?;
848    Some(vec![
849        target.points[0].clone(),
850        target.points[1].clone(),
851        spline.points[0].clone(),
852        spline.points[1].clone(),
853    ])
854}
855
856/// The point-id list for a curvature (`ϰ`) constraint from two selected
857/// geometries — the SAME two-pair shape the tangent uses, so the solver's
858/// dispatch reads it the same way: `[anchorA, handleA, anchorB, handleB]` for a
859/// joint, `[anchor, handle, center, boundary]` against a circle/arc.
860///
861/// Gated on exactly what the palette offers, for the same reason the tangent's
862/// guide gate exists: an offered curvature continuity is one that solves. A
863/// spline end against a circle is a join; two guides are a join only when they
864/// are the two sides of one joint; anything else — two plain lines, a guide and
865/// an unrelated line, two ends with nothing holding them together — has no
866/// curvature continuity to state and is refused here rather than stored as a
867/// constraint the solver would have to name an error on.
868fn sketch_build_curvature_points(
869    doc: &crate::sketch::SketchDoc,
870    geos: &[&crate::sketch::SketchGeometry],
871) -> Option<Vec<serde_json::Value>> {
872    if geos.len() != 2 {
873        return None;
874    }
875    let is_radial =
876        |g: &crate::sketch::SketchGeometry| g.geom_type == "arc" || g.geom_type == "circle";
877    if geos.iter().all(|g| g.geom_type == "line") {
878        if !crate::sketch::spline::is_curvature_joint(doc, geos[0], geos[1]) {
879            return None;
880        }
881        let (a, b) = (geos[0], geos[1]);
882        return Some(vec![
883            a.points[0].clone(),
884            a.points[1].clone(),
885            b.points[0].clone(),
886            b.points[1].clone(),
887        ]);
888    }
889    let guide = geos
890        .iter()
891        .find(|g| crate::sketch::spline::is_end_handle_guide(doc, g))?;
892    let circ = geos.iter().find(|g| is_radial(g))?;
893    if circ.points.len() < 2 {
894        return None;
895    }
896    Some(vec![
897        guide.points[0].clone(),
898        guide.points[1].clone(),
899        circ.points[0].clone(),
900        circ.points[1].clone(),
901    ])
902}
903
904/// Whether the perpendicular (`⟂`) 4-point list should SWAP its first two points to
905/// orient line 1 closer to 90° against line 2 — a faithful port of the previous
906/// angle-calculation block. `pts = [l1a, l1b, l2a, l2b]`.
907pub(super) fn sketch_perpendicular_should_swap(
908    doc: &crate::sketch::SketchDoc,
909    pts: &[serde_json::Value],
910) -> bool {
911    let coord = |v: &serde_json::Value| doc.point(v).map(|p| (p.x, p.y));
912    let (Some(p0), Some(p1), Some(p2), Some(p3)) =
913        (coord(&pts[0]), coord(&pts[1]), coord(&pts[2]), coord(&pts[3]))
914    else {
915        return false;
916    };
917    // calculateAngle(a, b) = atan2(b.y-a.y, b.x-a.x) in [0, 360).
918    let angle = |a: (f64, f64), b: (f64, f64)| -> f64 {
919        let deg = (b.1 - a.1).atan2(b.0 - a.0) * 180.0 / std::f64::consts::PI;
920        (deg + 360.0) % 360.0
921    };
922    // Fold into (-180, 180]: (a + 180) % 360 - 180.
923    let fold = |a: f64| (a + 180.0) % 360.0 - 180.0;
924    let line1_a = fold(angle(p0, p1));
925    let line1_b = fold(angle(p1, p0));
926    let line2 = fold(angle(p2, p3));
927    let diff_a = line1_a - line2;
928    let diff_b = line1_b - line2;
929    (90.0 - diff_a).abs() > (90.0 - diff_b).abs()
930}
931
932/// The `type + sorted-point-ids` dedup signature (the solver runs with
933/// `remove_implied_duplicates:false`, so this is the only dedup on adds).
934pub(super) fn sketch_constraint_signature(ctype: &str, points: &[serde_json::Value]) -> String {
935    use crate::sketch::doc::id_key;
936    let mut keys: Vec<String> = points.iter().map(id_key).collect();
937    keys.sort();
938    format!("{ctype}|{}", keys.join(","))
939}
940
941/// The port of `ConstraintEngine.createConstraint`: from the session's selection build
942/// the ordered point-id list(s) for `symbol`, dedup on `type + sorted-points`, and
943/// append the constraint(s) (dimensional → `value:null` + `valueNeedsSetup:true`).
944/// Returns whether at least one constraint was added. The caller re-solves.
945fn sketch_build_and_add_constraint(
946    session: &mut crate::sketch::SketchSession,
947    symbol: &str,
948) -> bool {
949    /// The constraint(s) to append for a symbol: the stored solver `type`, its
950    /// display style, and one or more ordered point-id lists (`⏛`-from-2-lines and
951    /// the like push several).
952    struct Built {
953        store_type: String,
954        display_style: &'static str,
955        lists: Vec<Vec<serde_json::Value>>,
956    }
957
958    // --- Phase 1: read-only — assemble the constraint(s) from the selection. ---
959    let built: Option<Built> = {
960        let doc = &session.doc;
961        // `selected` mirrors the previous behavior: point → push it; geometry → push all its
962        // points, arc pops the last (center + start). `geo_items`/`point_items` are
963        // the role-based lists the specials use; `geometry_type` is the LAST
964        // geometry's type; `first_kind` drives the `⋯` reverse.
965        let mut selected: Vec<serde_json::Value> = Vec::new();
966        let mut geo_items: Vec<&crate::sketch::SketchGeometry> = Vec::new();
967        let mut point_items: Vec<serde_json::Value> = Vec::new();
968        let mut geometry_type: Option<String> = None;
969        let mut first_kind: Option<String> = None;
970        let mut has_geometry = false;
971        for (i, r) in session.selection.iter().enumerate() {
972            let kind = r.get("kind").and_then(|v| v.as_str());
973            let Some(id) = r.get("id") else { continue };
974            if i == 0 {
975                first_kind = kind.map(|k| k.to_string());
976            }
977            match kind {
978                Some("point") => {
979                    if let Some(p) = doc.point(id) {
980                        selected.push(p.id.clone());
981                        point_items.push(p.id.clone());
982                    }
983                }
984                Some("geometry") => {
985                    if let Some(g) = doc.geometry(id) {
986                        for pid in &g.points {
987                            if doc.point(pid).is_some() {
988                                selected.push(pid.clone());
989                            }
990                        }
991                        if g.geom_type == "arc" {
992                            selected.pop();
993                        }
994                        geometry_type = Some(g.geom_type.clone());
995                        geo_items.push(g);
996                        has_geometry = true;
997                    }
998                }
999                _ => {}
1000            }
1001        }
1002        if selected.is_empty() {
1003            None
1004        } else {
1005            let radial = |g: &crate::sketch::SketchGeometry| {
1006                g.geom_type == "arc" || g.geom_type == "circle"
1007            };
1008            let simple = |t: &str, list: Vec<serde_json::Value>, ds: &'static str| Built {
1009                store_type: t.to_string(),
1010                display_style: ds,
1011                lists: vec![list],
1012            };
1013
1014            // ---- Geometry-role specials (dispatched by role, not point count). ----
1015            match symbol {
1016                "◎" => {
1017                    if geo_items.len() == 2 && geo_items.iter().all(|g| radial(g)) {
1018                        Some(simple(
1019                            "◎",
1020                            vec![geo_items[0].points[0].clone(), geo_items[1].points[0].clone()],
1021                            "",
1022                        ))
1023                    } else {
1024                        None
1025                    }
1026                }
1027                "⊜" => {
1028                    if geo_items.len() == 2 && geo_items.iter().all(|g| radial(g)) {
1029                        let (g0, g1) = (geo_items[0], geo_items[1]);
1030                        Some(simple(
1031                            "⊜",
1032                            vec![
1033                                g0.points[0].clone(),
1034                                g0.points[1].clone(),
1035                                g1.points[0].clone(),
1036                                g1.points[1].clone(),
1037                            ],
1038                            "",
1039                        ))
1040                    } else {
1041                        None
1042                    }
1043                }
1044                "⌒" => sketch_build_tangent_points(doc, &geo_items).map(|pts| simple("⌒", pts, "")),
1045                "ϰ" => {
1046                    sketch_build_curvature_points(doc, &geo_items).map(|pts| simple("ϰ", pts, ""))
1047                }
1048                "∿" => sketch_build_foot_tangent_points(doc, &geo_items)
1049                    .map(|pts| simple("∿", pts, "")),
1050                "⋰" => {
1051                    let ids = if geo_items.len() >= 2
1052                        && geo_items.iter().all(|g| g.geom_type == "line")
1053                    {
1054                        let mut v = Vec::new();
1055                        for g in &geo_items {
1056                            v.push(g.points[0].clone());
1057                            v.push(g.points[1].clone());
1058                        }
1059                        Some(v)
1060                    } else if point_items.len() >= 3 {
1061                        Some(point_items.clone())
1062                    } else {
1063                        None
1064                    };
1065                    ids.filter(|v| v.len() >= 3).map(|v| simple("⋰", v, ""))
1066                }
1067                "⋈" => geo_items
1068                    .iter()
1069                    .find(|g| g.geom_type == "line")
1070                    .filter(|line| line.points.len() >= 2 && point_items.len() == 2)
1071                    .map(|line| {
1072                        simple(
1073                            "⋈",
1074                            vec![
1075                                line.points[0].clone(),
1076                                line.points[1].clone(),
1077                                point_items[0].clone(),
1078                                point_items[1].clone(),
1079                            ],
1080                            "",
1081                        )
1082                    }),
1083                // Radial dims: a `⟺` on [center, boundary] with a radius/diameter
1084                // display style (single arc/circle; `selected` is arc-popped to 2).
1085                "R" | "⌀" => {
1086                    if selected.len() == 2 {
1087                        let ds = if symbol == "⌀" { "diameter" } else { "radius" };
1088                        Some(Built {
1089                            store_type: "⟺".to_string(),
1090                            display_style: ds,
1091                            lists: vec![selected.clone()],
1092                        })
1093                    } else {
1094                        None
1095                    }
1096                }
1097                _ => {
1098                    // ---- Count-based (mirrors the previous `selected.length` blocks). ----
1099                    match selected.len() {
1100                        1 => match symbol {
1101                            "⏚" => Some(simple("⏚", selected.clone(), "")),
1102                            _ => None,
1103                        },
1104                        2 => match symbol {
1105                            "━" | "│" | "≡" => Some(simple(symbol, selected.clone(), "")),
1106                            "⟺" => {
1107                                let ds = if matches!(
1108                                    geometry_type.as_deref(),
1109                                    Some("arc") | Some("circle")
1110                                ) {
1111                                    "radius"
1112                                } else {
1113                                    ""
1114                                };
1115                                Some(Built {
1116                                    store_type: "⟺".to_string(),
1117                                    display_style: ds,
1118                                    lists: vec![selected.clone()],
1119                                })
1120                            }
1121                            _ => None,
1122                        },
1123                        3 => match symbol {
1124                            "⏛" => Some(simple("⏛", selected.clone(), "")),
1125                            "⋯" => {
1126                                let mut pts = selected.clone();
1127                                if has_geometry && first_kind.as_deref() == Some("point") {
1128                                    pts.reverse();
1129                                }
1130                                Some(simple("⋯", pts, ""))
1131                            }
1132                            "↥" => {
1133                                if geo_items.len() == 1 && point_items.len() == 1 {
1134                                    let line = geo_items[0];
1135                                    if line.geom_type == "line" && line.points.len() >= 2 {
1136                                        Some(simple(
1137                                            "↥",
1138                                            vec![
1139                                                line.points[0].clone(),
1140                                                line.points[1].clone(),
1141                                                point_items[0].clone(),
1142                                            ],
1143                                            "",
1144                                        ))
1145                                    } else {
1146                                        None
1147                                    }
1148                                } else {
1149                                    // 3 raw points (no geometry) → point-line-distance
1150                                    // over the selection as-is.
1151                                    Some(simple("↥", selected.clone(), ""))
1152                                }
1153                            }
1154                            "⇌" => Some(simple("⇌", selected.clone(), "")),
1155                            _ => None,
1156                        },
1157                        4 | 5 => match symbol {
1158                            "⏛" => {
1159                                // Two lines → constrain BOTH endpoints of line 2 onto
1160                                // line 1 (two point-on-line constraints).
1161                                if geo_items.len() == 2
1162                                    && geo_items.iter().all(|g| g.geom_type == "line")
1163                                    && geo_items[0].points.len() >= 2
1164                                    && geo_items[1].points.len() >= 2
1165                                {
1166                                    let (g0, g1) = (geo_items[0], geo_items[1]);
1167                                    Some(Built {
1168                                        store_type: "⏛".to_string(),
1169                                        display_style: "",
1170                                        lists: vec![
1171                                            vec![
1172                                                g0.points[0].clone(),
1173                                                g0.points[1].clone(),
1174                                                g1.points[0].clone(),
1175                                            ],
1176                                            vec![
1177                                                g0.points[0].clone(),
1178                                                g0.points[1].clone(),
1179                                                g1.points[1].clone(),
1180                                            ],
1181                                        ],
1182                                    })
1183                                } else {
1184                                    None
1185                                }
1186                            }
1187                            "⟂" => {
1188                                if selected.len() != 4 {
1189                                    None
1190                                } else {
1191                                    let mut pts = selected.clone();
1192                                    if sketch_perpendicular_should_swap(doc, &pts) {
1193                                        pts.swap(0, 1);
1194                                    }
1195                                    Some(simple("⟂", pts, ""))
1196                                }
1197                            }
1198                            "∥" => Some(simple("∥", selected.clone(), "")),
1199                            "∠" => Some(simple("∠", selected.clone(), "")),
1200                            "⇌" => Some(simple("⇌", selected.clone(), "")),
1201                            _ => None,
1202                        },
1203                        _ => None,
1204                    }
1205                }
1206            }
1207        }
1208    };
1209
1210    let Some(built) = built else {
1211        return false;
1212    };
1213
1214    // --- Phase 2: mutate — dedup + append (every constraint carries the base
1215    //     fields; the solver seeds a null value from the current measurement). ---
1216    let doc = &mut session.doc;
1217    let mut added_any = false;
1218    for pts in &built.lists {
1219        let sig = sketch_constraint_signature(&built.store_type, pts);
1220        let duplicate = doc.constraints.iter().any(|c| match c.ctype() {
1221            Some(t) => sketch_constraint_signature(t, c.points()) == sig,
1222            None => false,
1223        });
1224        if duplicate {
1225            continue;
1226        }
1227        let id = doc.next_constraint_id();
1228        let mut raw = serde_json::Map::new();
1229        raw.insert("id".to_string(), id);
1230        raw.insert(
1231            "type".to_string(),
1232            serde_json::Value::String(built.store_type.clone()),
1233        );
1234        raw.insert("points".to_string(), serde_json::Value::Array(pts.clone()));
1235        raw.insert("labelX".to_string(), serde_json::Value::from(0));
1236        raw.insert("labelY".to_string(), serde_json::Value::from(0));
1237        raw.insert(
1238            "displayStyle".to_string(),
1239            serde_json::Value::String(built.display_style.to_string()),
1240        );
1241        raw.insert("value".to_string(), serde_json::Value::Null);
1242        raw.insert("valueNeedsSetup".to_string(), serde_json::Value::Bool(true));
1243        doc.constraints.push(crate::sketch::SketchConstraint { raw });
1244        added_any = true;
1245    }
1246    added_any
1247}
1248