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