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