1use super::*;
2
3#[derive(Clone, Debug, PartialEq)]
10pub struct SketchEntityRow {
11 pub kind: &'static str,
13 pub id: serde_json::Value,
15 pub label: String,
17 pub selected: bool,
19 pub construction: bool,
21 pub conflicting: bool,
24}
25
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub enum NoticeSeverity {
32 #[default]
33 Error,
34 Warning,
35 Info,
36}
37
38impl EngineState {
39 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 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 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 pub fn take_notices(&mut self) -> Vec<String> {
78 self.take_graded_notices().into_iter().map(|(_, text)| text).collect()
79 }
80
81 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 fn sketch_ref_selected(session: &crate::sketch::SketchSession, kind: &str, id: &serde_json::Value) -> bool {
97 use crate::sketch::doc::id_key;
98 let key = id_key(id);
99 session.selection.items.iter().any(|r| {
100 r.get("kind").and_then(serde_json::Value::as_str) == Some(kind)
101 && r.get("id").map(id_key).as_deref() == Some(key.as_str())
102 })
103 }
104
105 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}"); }
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 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 let note = g.extra.get("note").and_then(serde_json::Value::as_str).map(|n| format!(" \u{2014} {n}")).unwrap_or_default();
171 SketchEntityRow {
172 kind: "geometry",
173 id: g.id.clone(),
174 label: if g.geom_type == "text" {
175 format!("Text: {}", g.extra.get("text").and_then(|v|v.as_str()).unwrap_or(""))
176 } else { format!("{}:{}{mark} [{pts}]{note}", g.geom_type, id_key(&g.id)) },
177 selected: Self::sketch_ref_selected(session, "geometry", &g.id),
178 construction,
179 conflicting: false,
180 }
181 })
182 .collect()
183 }
184
185 pub fn sketch_constraint_rows(&self) -> Vec<SketchEntityRow> {
188 use crate::sketch::doc::id_key;
189 let Some(edit) = self.sketch_edit.as_ref() else {
190 return Vec::new();
191 };
192 let session = &edit.session;
193 session
194 .doc
195 .constraints
196 .iter()
197 .filter_map(|c| {
198 let id = c.raw.get("id")?.clone();
199 let ctype = c.ctype().unwrap_or("?");
200 let value = c
201 .raw
202 .get("value")
203 .and_then(serde_json::Value::as_f64)
204 .map(|v| format!(" {v:.3}"))
205 .unwrap_or_default();
206 let pts = c
207 .points()
208 .iter()
209 .map(id_key)
210 .collect::<Vec<_>>()
211 .join(",");
212 Some(SketchEntityRow {
213 kind: "constraint",
214 id: id.clone(),
215 label: format!("{} {ctype}{value} [{pts}]", id_key(&id)),
216 selected: Self::sketch_ref_selected(session, "constraint", &id),
217 construction: false,
218 conflicting: session.diagnostics.constraint_conflicting(&id),
219 })
220 })
221 .collect()
222 }
223
224 pub fn sketch_select_entity(&mut self, kind: &str, id: serde_json::Value, additive: bool) {
229 let toggles = self.settings.multi_select == crate::style::MultiSelectMode::ClickToggles;
230 let entity_ref = serde_json::json!({ "kind": kind, "id": id });
231 let Some(edit) = self.sketch_edit.as_mut() else {
232 return;
233 };
234 if !additive && !toggles {
235 edit.session.clear_selection();
236 }
237 edit.session.toggle_selection(entity_ref);
238 self.refresh_sketch_overlay();
239 self.dirty = true;
240 }
241
242 pub fn sketch_delete_entity(&mut self, kind: &str, id: serde_json::Value) -> bool {
251 use crate::sketch::doc::id_key;
252 let target = serde_json::json!({ "kind": kind, "id": id });
253 let same = |r: &serde_json::Value| {
254 r.get("kind") == target.get("kind")
255 && r.get("id").map(id_key) == target.get("id").map(id_key)
256 };
257 let Some(edit) = self.sketch_edit.as_mut() else {
258 return false;
259 };
260 let kept: Vec<serde_json::Value> =
261 edit.session.selection.items.iter().filter(|r| !same(r)).cloned().collect();
262 edit.session.clear_selection();
263 edit.session.toggle_selection(target);
264 let deleted = self.sketch_delete_selection();
265 if let Some(edit) = self.sketch_edit.as_mut() {
266 let doc = &edit.session.doc;
267 let alive = |r: &serde_json::Value| {
268 let Some(id) = r.get("id") else { return false };
269 match r.get("kind").and_then(|k| k.as_str()) {
270 Some("geometry") => doc.geometry(id).is_some(),
271 Some("point") => doc.point(id).is_some(),
272 Some("constraint") => doc
273 .constraints
274 .iter()
275 .any(|c| c.raw.get("id").map(id_key) == Some(id_key(id))),
276 _ => false,
277 }
278 };
279 let restore: Vec<serde_json::Value> = kept.into_iter().filter(|r| alive(r)).collect();
280 if !deleted {
281 edit.session.clear_selection();
282 }
283 for r in restore {
284 edit.session.toggle_selection(r);
285 }
286 }
287 self.refresh_sketch_overlay();
288 self.dirty = true;
289 deleted
290 }
291
292 pub fn sketch_hover_entity(&mut self, kind: &str, id: serde_json::Value) {
296 if self.sketch_edit.is_none() {
297 return;
298 }
299 let entity_ref = serde_json::json!({ "kind": kind, "id": id });
302 self.set_sketch_hover(Some(entity_ref));
303 self.sketch_list_hover_active = true;
304 }
305
306 pub fn take_sketch_list_hover(&mut self) -> bool {
310 std::mem::take(&mut self.sketch_list_hover_active)
311 }
312
313 pub fn sketch_solver_settings(&self) -> Option<crate::sketch::SketchSolverSettings> {
316 self.sketch_edit
317 .as_ref()
318 .map(|edit| edit.session.solver_settings.clone())
319 }
320
321 pub fn sketch_set_solver_settings(&mut self, settings: crate::sketch::SketchSolverSettings) {
324 if let Some(edit) = self.sketch_edit.as_mut() {
325 edit.session.solver_settings = settings;
326 } else {
327 return;
328 }
329 self.resolve_active_sketch("solver settings");
330 self.refresh_sketch_overlay();
331 self.dirty = true;
332 }
333}
334
335#[derive(Clone, Debug, PartialEq)]
339pub struct SketchConstraintAction {
340 pub symbol: String,
343 pub label: String,
345 pub dimensional: bool,
348}
349
350impl EngineState {
351 pub fn sketch_applicable_constraints(&self) -> Vec<SketchConstraintAction> {
361 match self.sketch_edit.as_ref() {
362 Some(edit) => sketch_palette_for(&edit.session),
363 None => Vec::new(),
364 }
365 }
366
367 pub fn sketch_add_constraint(&mut self, symbol: &str) -> bool {
372 let added = match self.sketch_edit.as_mut() {
375 Some(edit) => {
376 edit.record_undo();
377 sketch_build_and_add_constraint(&mut edit.session, symbol)
378 }
379 None => return false,
380 };
381 if added {
382 self.resolve_active_sketch("add-constraint");
383 self.refresh_sketch_overlay();
384 self.dirty = true;
385 } else if let Some(edit) = self.sketch_edit.as_mut() {
386 edit.undo_stack.pop();
387 }
388 added
389 }
390
391 pub fn sketch_toggle_ground(&mut self) -> bool {
397 use crate::sketch::doc::id_key;
398 use std::collections::HashSet;
399
400 let Some(edit) = self.sketch_edit.as_mut() else {
401 return false;
402 };
403 let sel_ids: Vec<serde_json::Value> = edit
404 .session
405 .selection.items
406 .iter()
407 .filter(|r| r.get("kind").and_then(|v| v.as_str()) == Some("point"))
408 .filter_map(|r| r.get("id").cloned())
409 .filter(|id| !crate::sketch::external_ref::is_origin_id(id))
411 .collect();
412 if sel_ids.is_empty() {
413 return false;
414 }
415 edit.record_undo();
417 let doc = &mut edit.session.doc;
418 let has_ground = |doc: &crate::sketch::SketchDoc, id: &serde_json::Value| -> bool {
419 doc.constraints.iter().any(|c| {
420 c.ctype() == Some("⏚")
421 && c.points().first().map(id_key) == Some(id_key(id))
422 })
423 };
424 let all_grounded = sel_ids.iter().all(|id| has_ground(doc, id));
425 if all_grounded {
426 let sel_keys: HashSet<String> = sel_ids.iter().map(id_key).collect();
427 doc.constraints.retain(|c| {
428 if c.ctype() != Some("⏚") {
429 return true;
430 }
431 match c.points().first() {
432 Some(p) => !sel_keys.contains(&id_key(p)),
433 None => true,
434 }
435 });
436 for id in &sel_ids {
437 if let Some(p) = doc.point_mut(id) {
438 p.fixed = false;
439 }
440 }
441 } else {
442 for id in &sel_ids {
443 if has_ground(doc, id) {
444 continue;
445 }
446 let cid = doc.next_constraint_id();
447 let mut raw = serde_json::Map::new();
448 raw.insert("id".to_string(), cid);
449 raw.insert("type".to_string(), serde_json::Value::String("⏚".to_string()));
450 raw.insert(
451 "points".to_string(),
452 serde_json::Value::Array(vec![id.clone()]),
453 );
454 doc.constraints.push(crate::sketch::SketchConstraint { raw });
455 if let Some(p) = doc.point_mut(id) {
456 p.fixed = true;
457 }
458 }
459 }
460 self.resolve_active_sketch("toggle-ground");
461 self.refresh_sketch_overlay();
462 self.dirty = true;
463 true
464 }
465
466 pub fn sketch_toggle_construction(&mut self) -> bool {
471 if self.sketch_selection_has_text_frame() { return false; }
472 let Some(edit) = self.sketch_edit.as_mut() else {
473 return false;
474 };
475 let mut pt_ids: Vec<serde_json::Value> = Vec::new();
476 let mut geo_ids: Vec<serde_json::Value> = Vec::new();
477 for r in &edit.session.selection.items {
478 match r.get("kind").and_then(|v| v.as_str()) {
479 Some("point") => {
480 if let Some(id) = r.get("id").filter(|id| !crate::sketch::external_ref::is_origin_id(id)) {
482 pt_ids.push(id.clone());
483 }
484 }
485 Some("geometry") => {
486 if let Some(id) = r.get("id") {
487 geo_ids.push(id.clone());
488 }
489 }
490 _ => {}
491 }
492 }
493 if pt_ids.is_empty() && geo_ids.is_empty() {
494 return false;
495 }
496 edit.record_undo();
498 let doc = &mut edit.session.doc;
499 let all_construction = pt_ids
500 .iter()
501 .all(|id| doc.point(id).map_or(false, |p| p.construction))
502 && geo_ids
503 .iter()
504 .all(|id| doc.geometry(id).map_or(false, |g| g.construction()));
505 let next = !all_construction;
506 for id in &pt_ids {
507 if let Some(p) = doc.point_mut(id) {
508 p.construction = next;
509 }
510 }
511 for id in &geo_ids {
512 if let Some(g) = doc.geometry_mut(id) {
513 g.extra
514 .insert("construction".to_string(), serde_json::Value::Bool(next));
515 }
516 }
517 self.resolve_active_sketch("toggle-construction");
518 self.refresh_sketch_overlay();
519 self.dirty = true;
520 true
521 }
522
523 pub fn sketch_cleanup_unused_points(&mut self) -> bool {
527 use crate::sketch::doc::id_key;
528 use std::collections::HashSet;
529
530 let Some(edit) = self.sketch_edit.as_mut() else {
531 return false;
532 };
533 edit.record_undo();
535 let doc = &mut edit.session.doc;
536 let mut used: HashSet<String> = HashSet::new();
537 for g in &doc.geometries {
538 for pid in &g.points {
539 used.insert(id_key(pid));
540 }
541 }
542 for c in &doc.constraints {
543 for pid in c.points() {
544 used.insert(id_key(pid));
545 }
546 }
547 let before = doc.points.len();
548 doc.points.retain(|p| used.contains(&id_key(&p.id)));
549 let removed = doc.points.len() != before;
550 if !removed {
551 edit.undo_stack.pop();
552 return false;
553 }
554 self.resolve_active_sketch("cleanup");
555 self.refresh_sketch_overlay();
556 self.dirty = true;
557 true
558 }
559
560 pub fn sketch_constraint_count(&self) -> usize {
563 self.sketch_edit
564 .as_ref()
565 .map_or(0, |edit| edit.session.doc.constraints.len())
566 }
567
568 pub fn sketch_selection_all_grounded(&self) -> Option<bool> {
572 use crate::sketch::doc::id_key;
573 let edit = self.sketch_edit.as_ref()?;
574 let doc = &edit.session.doc;
575 let sel: Vec<&serde_json::Value> = edit
576 .session
577 .selection.items
578 .iter()
579 .filter(|r| r.get("kind").and_then(|v| v.as_str()) == Some("point"))
580 .filter_map(|r| r.get("id"))
581 .collect();
582 if sel.is_empty() {
583 return None;
584 }
585 let all = sel.iter().all(|id| {
586 doc.constraints.iter().any(|c| {
587 c.ctype() == Some("⏚")
588 && c.points().first().map(id_key) == Some(id_key(id))
589 })
590 });
591 Some(all)
592 }
593
594 pub fn sketch_selection_all_construction(&self) -> Option<bool> {
598 if self.sketch_selection_has_text_frame() { return None; }
599 let edit = self.sketch_edit.as_ref()?;
600 let doc = &edit.session.doc;
601 let mut any = false;
602 let mut all = true;
603 for r in &edit.session.selection.items {
604 match r.get("kind").and_then(|v| v.as_str()) {
605 Some("point") => {
606 any = true;
607 if let Some(id) = r.get("id") {
608 if !doc.point(id).map_or(false, |p| p.construction) {
609 all = false;
610 }
611 }
612 }
613 Some("geometry") => {
614 any = true;
615 if let Some(id) = r.get("id") {
616 if !doc.geometry(id).map_or(false, |g| g.construction()) {
617 all = false;
618 }
619 }
620 }
621 _ => {}
622 }
623 }
624 if !any {
625 return None;
626 }
627 Some(all)
628 }
629}
630
631fn sketch_palette_for(session: &crate::sketch::SketchSession) -> Vec<SketchConstraintAction> {
635 use crate::sketch::doc::id_key;
636 use std::collections::HashSet;
637
638 let doc = &session.doc;
639 let mut sel_points: Vec<serde_json::Value> = Vec::new();
640 let mut geos: Vec<&crate::sketch::SketchGeometry> = Vec::new();
641 for r in &session.selection.items {
642 match r.get("kind").and_then(|v| v.as_str()) {
643 Some("point") => {
644 if let Some(id) = r.get("id") {
645 sel_points.push(id.clone());
646 }
647 }
648 Some("geometry") => {
649 if let Some(id) = r.get("id") {
650 if let Some(g) = doc.geometry(id) {
651 geos.push(g);
652 }
653 }
654 }
655 _ => {}
656 }
657 }
658
659 if geos.iter().any(|g| g.geom_type == "text") { return Vec::new(); }
660
661 let mut point_set: HashSet<String> = HashSet::new();
664 for id in &sel_points {
665 point_set.insert(id_key(id));
666 }
667 for g in &geos {
668 let pts: &[serde_json::Value] = if g.geom_type == "arc" {
669 &g.points[..g.points.len().min(2)]
670 } else {
671 &g.points
672 };
673 for pid in pts {
674 point_set.insert(id_key(pid));
675 }
676 }
677 let point_count = point_set.len();
678 let selected_point_ids_len = sel_points.len();
679 let is_radial =
680 |g: &crate::sketch::SketchGeometry| g.geom_type == "arc" || g.geom_type == "circle";
681
682 let mut out: Vec<SketchConstraintAction> = Vec::new();
683 let mut push = |symbol: &str, label: &str, dimensional: bool| {
684 out.push(SketchConstraintAction {
685 symbol: symbol.to_string(),
686 label: label.to_string(),
687 dimensional,
688 });
689 };
690
691 if geos.len() == 1 && is_radial(geos[0]) {
693 push("R", "Radius", true);
694 push("⌀", "Diameter", true);
695 return out;
696 }
697 if geos.len() == 2 && geos.iter().all(|g| g.geom_type == "line") {
706 push("∥", "Parallel", false);
707 push("⟂", "Perpendicular", false);
708 push("∠", "Angle", true);
709 push("⇌", "Equal distance", false);
710 push("⋰", "Collinear", false);
711 push("⏛", "Point on line", false);
712 if geos
713 .iter()
714 .any(|g| crate::sketch::spline::is_handle_guide(doc, g))
715 {
716 push("⌒", "Tangent", false);
717 }
718 if crate::sketch::spline::is_curvature_joint(doc, geos[0], geos[1]) {
724 push("ϰ", "Curvature (G2)", false);
725 }
726 return out;
727 }
728 if geos.len() == 2 && geos.iter().all(|g| is_radial(g)) {
730 push("⊜", "Equal radius", false);
731 push("◎", "Concentric", false);
732 push("⌒", "Tangent", false);
733 return out;
734 }
735 if geos.len() > 2 && sel_points.is_empty() {
739 if geos.iter().all(|g| g.geom_type == "line") {
740 push("⇌", "Equal distance", false);
741 return out;
742 }
743 if geos.iter().all(|g| is_radial(g)) {
744 push("⊜", "Equal radius", false);
745 return out;
746 }
747 }
748 if geos.len() == 2
752 && ((geos[0].geom_type == "line" && is_radial(geos[1]))
753 || (geos[1].geom_type == "line" && is_radial(geos[0])))
754 {
755 push("⌒", "Tangent", false);
756 if geos
757 .iter()
758 .any(|g| crate::sketch::spline::is_end_handle_guide(doc, g))
759 {
760 push("ϰ", "Curvature (G2)", false);
761 }
762 return out;
763 }
764 if sketch_foot_tangent_pair(doc, &geos).is_some() {
774 push("∿", "Tangent (span point)", false);
775 return out;
776 }
777 if geos.len() == 1 && geos[0].geom_type == "line" && selected_point_ids_len == 2 {
779 push("⟂", "Perpendicular", false);
780 push("⋈", "Symmetric about line", false);
781 }
782
783 if point_count == 1 {
784 push("⏚", "Ground (fix point)", false);
785 }
786 if point_count == 2 {
787 push("━", "Horizontal", false);
788 push("│", "Vertical", false);
789 push("≡", "Coincident", false);
790 push("⟺", "Distance", true);
791 }
792 if point_count == 3 {
793 push("⋯", "Midpoint", false);
794 push("⏛", "Point on line", false);
795 push("↥", "Line to point distance", true);
796 push("∠", "Angle", true);
797 if selected_point_ids_len >= 3 {
799 push("⋰", "Collinear", false);
800 }
801 }
802 if point_count > 3 && geos.is_empty() && selected_point_ids_len >= 3 {
804 push("⋰", "Collinear", false);
805 }
806
807 out
808}
809
810fn sketch_build_tangent_points(
821 doc: &crate::sketch::SketchDoc,
822 geos: &[&crate::sketch::SketchGeometry],
823) -> Option<Vec<serde_json::Value>> {
824 if geos.len() != 2 {
825 return None;
826 }
827 let is_radial = |g: &crate::sketch::SketchGeometry| g.geom_type == "arc" || g.geom_type == "circle";
828 if geos.iter().all(|g| g.geom_type == "line") {
829 let (a, b) = (geos[0], geos[1]);
830 if a.points.len() < 2 || b.points.len() < 2 {
831 return None;
832 }
833 if !crate::sketch::spline::is_handle_guide(doc, a)
834 && !crate::sketch::spline::is_handle_guide(doc, b)
835 {
836 return None;
837 }
838 return Some(vec![
839 a.points[0].clone(),
840 a.points[1].clone(),
841 b.points[0].clone(),
842 b.points[1].clone(),
843 ]);
844 }
845 let line = geos.iter().find(|g| g.geom_type == "line");
846 let circ = geos.iter().find(|g| is_radial(g));
847 if let (Some(line), Some(circ)) = (line, circ) {
848 if line.points.len() < 2 || circ.points.len() < 2 {
849 return None;
850 }
851 return Some(vec![
852 line.points[0].clone(),
853 line.points[1].clone(),
854 circ.points[0].clone(),
855 circ.points[1].clone(),
856 ]);
857 }
858 if geos.iter().all(|g| is_radial(g)) {
859 let (a, b) = (geos[0], geos[1]);
860 if a.points.len() < 2 || b.points.len() < 2 {
861 return None;
862 }
863 return Some(vec![
864 a.points[0].clone(),
865 a.points[1].clone(),
866 b.points[0].clone(),
867 b.points[1].clone(),
868 ]);
869 }
870 None
871}
872
873fn sketch_foot_tangent_pair<'a>(
887 doc: &crate::sketch::SketchDoc,
888 geos: &[&'a crate::sketch::SketchGeometry],
889) -> Option<(&'a crate::sketch::SketchGeometry, &'a crate::sketch::SketchGeometry)> {
890 if geos.len() != 2 {
891 return None;
892 }
893 let is_spline = |g: &crate::sketch::SketchGeometry| {
894 crate::sketch::spline::is_spline_type(&g.geom_type)
895 };
896 let is_radial =
897 |g: &crate::sketch::SketchGeometry| g.geom_type == "arc" || g.geom_type == "circle";
898 let spline = *geos.iter().find(|g| is_spline(g))?;
899 let target = *geos.iter().find(|g| !is_spline(g))?;
900 if spline.points.len() < 4 || target.points.len() < 2 {
901 return None;
902 }
903 let target_is_usable = if target.geom_type == "line" {
904 !crate::sketch::spline::is_handle_guide(doc, target)
905 } else {
906 is_radial(target)
907 };
908 target_is_usable.then_some((spline, target))
909}
910
911fn sketch_build_foot_tangent_points(
922 doc: &crate::sketch::SketchDoc,
923 geos: &[&crate::sketch::SketchGeometry],
924) -> Option<Vec<serde_json::Value>> {
925 let (spline, target) = sketch_foot_tangent_pair(doc, geos)?;
926 Some(vec![
927 target.points[0].clone(),
928 target.points[1].clone(),
929 spline.points[0].clone(),
930 spline.points[1].clone(),
931 ])
932}
933
934fn sketch_build_curvature_points(
947 doc: &crate::sketch::SketchDoc,
948 geos: &[&crate::sketch::SketchGeometry],
949) -> Option<Vec<serde_json::Value>> {
950 if geos.len() != 2 {
951 return None;
952 }
953 let is_radial =
954 |g: &crate::sketch::SketchGeometry| g.geom_type == "arc" || g.geom_type == "circle";
955 if geos.iter().all(|g| g.geom_type == "line") {
956 if !crate::sketch::spline::is_curvature_joint(doc, geos[0], geos[1]) {
957 return None;
958 }
959 let (a, b) = (geos[0], geos[1]);
960 return Some(vec![
961 a.points[0].clone(),
962 a.points[1].clone(),
963 b.points[0].clone(),
964 b.points[1].clone(),
965 ]);
966 }
967 let guide = geos
968 .iter()
969 .find(|g| crate::sketch::spline::is_end_handle_guide(doc, g))?;
970 let circ = geos.iter().find(|g| is_radial(g))?;
971 if circ.points.len() < 2 {
972 return None;
973 }
974 Some(vec![
975 guide.points[0].clone(),
976 guide.points[1].clone(),
977 circ.points[0].clone(),
978 circ.points[1].clone(),
979 ])
980}
981
982pub(super) fn sketch_perpendicular_should_swap(
986 doc: &crate::sketch::SketchDoc,
987 pts: &[serde_json::Value],
988) -> bool {
989 let coord = |v: &serde_json::Value| doc.point(v).map(|p| (p.x, p.y));
990 let (Some(p0), Some(p1), Some(p2), Some(p3)) =
991 (coord(&pts[0]), coord(&pts[1]), coord(&pts[2]), coord(&pts[3]))
992 else {
993 return false;
994 };
995 let angle = |a: (f64, f64), b: (f64, f64)| -> f64 {
997 let deg = (b.1 - a.1).atan2(b.0 - a.0) * 180.0 / std::f64::consts::PI;
998 (deg + 360.0) % 360.0
999 };
1000 let fold = |a: f64| (a + 180.0) % 360.0 - 180.0;
1002 let line1_a = fold(angle(p0, p1));
1003 let line1_b = fold(angle(p1, p0));
1004 let line2 = fold(angle(p2, p3));
1005 let diff_a = line1_a - line2;
1006 let diff_b = line1_b - line2;
1007 (90.0 - diff_a).abs() > (90.0 - diff_b).abs()
1008}
1009
1010pub(super) fn sketch_constraint_signature(ctype: &str, points: &[serde_json::Value]) -> String {
1013 use crate::sketch::doc::id_key;
1014 let mut keys: Vec<String> = points.iter().map(id_key).collect();
1015 keys.sort();
1016 format!("{ctype}|{}", keys.join(","))
1017}
1018
1019fn sketch_build_and_add_constraint(
1024 session: &mut crate::sketch::SketchSession,
1025 symbol: &str,
1026) -> bool {
1027 struct Built {
1031 store_type: String,
1032 display_style: &'static str,
1033 lists: Vec<Vec<serde_json::Value>>,
1034 }
1035
1036 fn sketch_equal_to_first(t: &str, geos: &[&crate::sketch::SketchGeometry]) -> Built {
1039 let first = geos[0];
1040 Built {
1041 store_type: t.to_string(),
1042 display_style: "",
1043 lists: geos[1..]
1044 .iter()
1045 .map(|g| {
1046 vec![
1047 first.points[0].clone(),
1048 first.points[1].clone(),
1049 g.points[0].clone(),
1050 g.points[1].clone(),
1051 ]
1052 })
1053 .collect(),
1054 }
1055 }
1056
1057 let built: Option<Built> = {
1059 let doc = &session.doc;
1060 let mut selected: Vec<serde_json::Value> = Vec::new();
1065 let mut geo_items: Vec<&crate::sketch::SketchGeometry> = Vec::new();
1066 let mut point_items: Vec<serde_json::Value> = Vec::new();
1067 let mut geometry_type: Option<String> = None;
1068 let mut first_kind: Option<String> = None;
1069 let mut has_geometry = false;
1070 for (i, r) in session.selection.items.iter().enumerate() {
1071 let kind = r.get("kind").and_then(|v| v.as_str());
1072 let Some(id) = r.get("id") else { continue };
1073 if i == 0 {
1074 first_kind = kind.map(|k| k.to_string());
1075 }
1076 match kind {
1077 Some("point") => {
1078 if let Some(p) = doc.point(id) {
1079 selected.push(p.id.clone());
1080 point_items.push(p.id.clone());
1081 }
1082 }
1083 Some("geometry") => {
1084 if let Some(g) = doc.geometry(id) {
1085 for pid in &g.points {
1086 if doc.point(pid).is_some() {
1087 selected.push(pid.clone());
1088 }
1089 }
1090 if g.geom_type == "arc" {
1091 selected.pop();
1092 }
1093 geometry_type = Some(g.geom_type.clone());
1094 geo_items.push(g);
1095 has_geometry = true;
1096 }
1097 }
1098 _ => {}
1099 }
1100 }
1101 if selected.is_empty() {
1102 None
1103 } else {
1104 let radial = |g: &crate::sketch::SketchGeometry| {
1105 g.geom_type == "arc" || g.geom_type == "circle"
1106 };
1107 let simple = |t: &str, list: Vec<serde_json::Value>, ds: &'static str| Built {
1108 store_type: t.to_string(),
1109 display_style: ds,
1110 lists: vec![list],
1111 };
1112
1113 match symbol {
1115 "◎" => {
1116 if geo_items.len() == 2 && geo_items.iter().all(|g| radial(g)) {
1117 Some(simple(
1118 "◎",
1119 vec![geo_items[0].points[0].clone(), geo_items[1].points[0].clone()],
1120 "",
1121 ))
1122 } else {
1123 None
1124 }
1125 }
1126 "⊜" => {
1130 if geo_items.len() >= 2
1131 && geo_items.iter().all(|g| radial(g) && g.points.len() >= 2)
1132 {
1133 Some(sketch_equal_to_first("⊜", &geo_items))
1134 } else {
1135 None
1136 }
1137 }
1138 "⇌" if geo_items.len() >= 2
1141 && point_items.is_empty()
1142 && geo_items
1143 .iter()
1144 .all(|g| g.geom_type == "line" && g.points.len() >= 2) =>
1145 {
1146 Some(sketch_equal_to_first("⇌", &geo_items))
1147 }
1148 "⌒" => sketch_build_tangent_points(doc, &geo_items).map(|pts| simple("⌒", pts, "")),
1149 "ϰ" => {
1150 sketch_build_curvature_points(doc, &geo_items).map(|pts| simple("ϰ", pts, ""))
1151 }
1152 "∿" => sketch_build_foot_tangent_points(doc, &geo_items)
1153 .map(|pts| simple("∿", pts, "")),
1154 "⋰" => {
1155 let ids = if geo_items.len() >= 2
1156 && geo_items.iter().all(|g| g.geom_type == "line")
1157 {
1158 let mut v = Vec::new();
1159 for g in &geo_items {
1160 v.push(g.points[0].clone());
1161 v.push(g.points[1].clone());
1162 }
1163 Some(v)
1164 } else if point_items.len() >= 3 {
1165 Some(point_items.clone())
1166 } else {
1167 None
1168 };
1169 ids.filter(|v| v.len() >= 3).map(|v| simple("⋰", v, ""))
1170 }
1171 "⋈" => geo_items
1172 .iter()
1173 .find(|g| g.geom_type == "line")
1174 .filter(|line| line.points.len() >= 2 && point_items.len() == 2)
1175 .map(|line| {
1176 simple(
1177 "⋈",
1178 vec![
1179 line.points[0].clone(),
1180 line.points[1].clone(),
1181 point_items[0].clone(),
1182 point_items[1].clone(),
1183 ],
1184 "",
1185 )
1186 }),
1187 "R" | "⌀" => {
1190 if selected.len() == 2 {
1191 let ds = if symbol == "⌀" { "diameter" } else { "radius" };
1192 Some(Built {
1193 store_type: "⟺".to_string(),
1194 display_style: ds,
1195 lists: vec![selected.clone()],
1196 })
1197 } else {
1198 None
1199 }
1200 }
1201 _ => {
1202 match selected.len() {
1204 1 => match symbol {
1205 "⏚" => Some(simple("⏚", selected.clone(), "")),
1206 _ => None,
1207 },
1208 2 => match symbol {
1209 "━" | "│" | "≡" => Some(simple(symbol, selected.clone(), "")),
1210 "⟺" => {
1211 let ds = if matches!(
1212 geometry_type.as_deref(),
1213 Some("arc") | Some("circle")
1214 ) {
1215 "radius"
1216 } else {
1217 ""
1218 };
1219 Some(Built {
1220 store_type: "⟺".to_string(),
1221 display_style: ds,
1222 lists: vec![selected.clone()],
1223 })
1224 }
1225 _ => None,
1226 },
1227 3 => match symbol {
1228 "⏛" => Some(simple("⏛", selected.clone(), "")),
1229 "⋯" => {
1230 let mut pts = selected.clone();
1231 if has_geometry && first_kind.as_deref() == Some("point") {
1232 pts.reverse();
1233 }
1234 Some(simple("⋯", pts, ""))
1235 }
1236 "↥" => {
1237 if geo_items.len() == 1 && point_items.len() == 1 {
1238 let line = geo_items[0];
1239 if line.geom_type == "line" && line.points.len() >= 2 {
1240 Some(simple(
1241 "↥",
1242 vec![
1243 line.points[0].clone(),
1244 line.points[1].clone(),
1245 point_items[0].clone(),
1246 ],
1247 "",
1248 ))
1249 } else {
1250 None
1251 }
1252 } else {
1253 Some(simple("↥", selected.clone(), ""))
1256 }
1257 }
1258 "⇌" => Some(simple("⇌", selected.clone(), "")),
1259 _ => None,
1260 },
1261 4 | 5 => match symbol {
1262 "⏛" => {
1263 if geo_items.len() == 2
1266 && geo_items.iter().all(|g| g.geom_type == "line")
1267 && geo_items[0].points.len() >= 2
1268 && geo_items[1].points.len() >= 2
1269 {
1270 let (g0, g1) = (geo_items[0], geo_items[1]);
1271 Some(Built {
1272 store_type: "⏛".to_string(),
1273 display_style: "",
1274 lists: vec![
1275 vec![
1276 g0.points[0].clone(),
1277 g0.points[1].clone(),
1278 g1.points[0].clone(),
1279 ],
1280 vec![
1281 g0.points[0].clone(),
1282 g0.points[1].clone(),
1283 g1.points[1].clone(),
1284 ],
1285 ],
1286 })
1287 } else {
1288 None
1289 }
1290 }
1291 "⟂" => {
1292 if selected.len() != 4 {
1293 None
1294 } else {
1295 let mut pts = selected.clone();
1296 if sketch_perpendicular_should_swap(doc, &pts) {
1297 pts.swap(0, 1);
1298 }
1299 Some(simple("⟂", pts, ""))
1300 }
1301 }
1302 "∥" => Some(simple("∥", selected.clone(), "")),
1303 "∠" => Some(simple("∠", selected.clone(), "")),
1304 "⇌" => Some(simple("⇌", selected.clone(), "")),
1305 _ => None,
1306 },
1307 _ => None,
1308 }
1309 }
1310 }
1311 }
1312 };
1313
1314 let Some(built) = built else {
1315 return false;
1316 };
1317
1318 let doc = &mut session.doc;
1321 let mut added_any = false;
1322 for pts in &built.lists {
1323 let sig = sketch_constraint_signature(&built.store_type, pts);
1324 let duplicate = doc.constraints.iter().any(|c| match c.ctype() {
1325 Some(t) => sketch_constraint_signature(t, c.points()) == sig,
1326 None => false,
1327 });
1328 if duplicate {
1329 continue;
1330 }
1331 let id = doc.next_constraint_id();
1332 let mut raw = serde_json::Map::new();
1333 raw.insert("id".to_string(), id);
1334 raw.insert(
1335 "type".to_string(),
1336 serde_json::Value::String(built.store_type.clone()),
1337 );
1338 raw.insert("points".to_string(), serde_json::Value::Array(pts.clone()));
1339 raw.insert("labelX".to_string(), serde_json::Value::from(0));
1340 raw.insert("labelY".to_string(), serde_json::Value::from(0));
1341 raw.insert(
1342 "displayStyle".to_string(),
1343 serde_json::Value::String(built.display_style.to_string()),
1344 );
1345 raw.insert("value".to_string(), serde_json::Value::Null);
1346 raw.insert("valueNeedsSetup".to_string(), serde_json::Value::Bool(true));
1347 doc.constraints.push(crate::sketch::SketchConstraint { raw });
1348 added_any = true;
1349 }
1350 added_any
1351}
1352
1353
1354impl EngineState {
1355 fn sketch_selection_has_text_frame(&self) -> bool {
1356 let Some(edit) = &self.sketch_edit else { return false; };
1357 edit.session.selection.items.iter().any(|r| {
1358 let id = &r["id"];
1359 if r["kind"] == "geometry" {
1360 edit.session.doc.geometry(id).is_some_and(|g| g.geom_type == "text" || g.extra.contains_key("textFrame"))
1361 } else {
1362 edit.session.doc.geometries.iter().any(|g| g.geom_type == "text" && g.points.contains(id))
1363 }
1364 })
1365 }
1366
1367 pub fn sketch_selected_text(&self) -> Option<(serde_json::Value, String, String)> {
1368 let session = &self.sketch_edit.as_ref()?.session;
1369 if session.selection.items.len() != 1 { return None; }
1370 let r = &session.selection.items[0];
1371 if r["kind"] != "geometry" { return None; }
1372 let g = session.doc.geometry(&r["id"])?;
1373 if g.geom_type != "text" { return None; }
1374 Some((g.id.clone(), g.extra["text"].as_str()?.into(), g.extra["font"].as_str()?.into()))
1375 }
1376
1377 pub fn sketch_edit_text(&mut self, id: &serde_json::Value, text: &str, font: &str) -> Result<(), String> {
1378 let contours = crate::sketch::text::outlines(text,font)?;
1379 let edit = self.sketch_edit.as_mut().ok_or("No active sketch")?;
1380 let g = edit.session.doc.geometry(id).filter(|g| g.geom_type == "text").ok_or("Text object no longer exists")?;
1381 if g.extra["text"] == text && g.extra["font"] == font { return Ok(()); }
1382 edit.record_undo();
1383 let g = edit.session.doc.geometry_mut(id).unwrap();
1384 g.extra.insert("text".into(), serde_json::json!(text));
1385 g.extra.insert("font".into(), serde_json::json!(font));
1386 g.extra.insert("textContours".into(), contours);
1387 self.refresh_sketch_overlay();
1388 self.dirty = true;
1389 Ok(())
1390 }
1391}