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