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