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_hover_entity(&mut self, kind: &str, id: serde_json::Value) {
246 if self.sketch_edit.is_none() {
247 return;
248 }
249 let entity_ref = serde_json::json!({ "kind": kind, "id": id });
252 self.set_sketch_hover(Some(entity_ref));
253 self.sketch_list_hover_active = true;
254 }
255
256 pub fn take_sketch_list_hover(&mut self) -> bool {
260 std::mem::take(&mut self.sketch_list_hover_active)
261 }
262
263 pub fn sketch_solver_settings(&self) -> Option<crate::sketch::SketchSolverSettings> {
266 self.sketch_edit
267 .as_ref()
268 .map(|edit| edit.session.solver_settings.clone())
269 }
270
271 pub fn sketch_set_solver_settings(&mut self, settings: crate::sketch::SketchSolverSettings) {
274 if let Some(edit) = self.sketch_edit.as_mut() {
275 edit.session.solver_settings = settings;
276 } else {
277 return;
278 }
279 self.resolve_active_sketch("solver settings");
280 self.refresh_sketch_overlay();
281 self.dirty = true;
282 }
283}
284
285#[derive(Clone, Debug, PartialEq)]
289pub struct SketchConstraintAction {
290 pub symbol: String,
293 pub label: String,
295 pub dimensional: bool,
298}
299
300impl EngineState {
301 pub fn sketch_applicable_constraints(&self) -> Vec<SketchConstraintAction> {
311 match self.sketch_edit.as_ref() {
312 Some(edit) => sketch_palette_for(&edit.session),
313 None => Vec::new(),
314 }
315 }
316
317 pub fn sketch_add_constraint(&mut self, symbol: &str) -> bool {
322 let added = match self.sketch_edit.as_mut() {
325 Some(edit) => {
326 edit.record_undo();
327 sketch_build_and_add_constraint(&mut edit.session, symbol)
328 }
329 None => return false,
330 };
331 if added {
332 self.resolve_active_sketch("add-constraint");
333 self.refresh_sketch_overlay();
334 self.dirty = true;
335 } else if let Some(edit) = self.sketch_edit.as_mut() {
336 edit.undo_stack.pop();
337 }
338 added
339 }
340
341 pub fn sketch_toggle_ground(&mut self) -> bool {
347 use crate::sketch::doc::id_key;
348 use std::collections::HashSet;
349
350 let Some(edit) = self.sketch_edit.as_mut() else {
351 return false;
352 };
353 let sel_ids: Vec<serde_json::Value> = edit
354 .session
355 .selection.items
356 .iter()
357 .filter(|r| r.get("kind").and_then(|v| v.as_str()) == Some("point"))
358 .filter_map(|r| r.get("id").cloned())
359 .filter(|id| !crate::sketch::external_ref::is_origin_id(id))
361 .collect();
362 if sel_ids.is_empty() {
363 return false;
364 }
365 edit.record_undo();
367 let doc = &mut edit.session.doc;
368 let has_ground = |doc: &crate::sketch::SketchDoc, id: &serde_json::Value| -> bool {
369 doc.constraints.iter().any(|c| {
370 c.ctype() == Some("⏚")
371 && c.points().first().map(id_key) == Some(id_key(id))
372 })
373 };
374 let all_grounded = sel_ids.iter().all(|id| has_ground(doc, id));
375 if all_grounded {
376 let sel_keys: HashSet<String> = sel_ids.iter().map(id_key).collect();
377 doc.constraints.retain(|c| {
378 if c.ctype() != Some("⏚") {
379 return true;
380 }
381 match c.points().first() {
382 Some(p) => !sel_keys.contains(&id_key(p)),
383 None => true,
384 }
385 });
386 for id in &sel_ids {
387 if let Some(p) = doc.point_mut(id) {
388 p.fixed = false;
389 }
390 }
391 } else {
392 for id in &sel_ids {
393 if has_ground(doc, id) {
394 continue;
395 }
396 let cid = doc.next_constraint_id();
397 let mut raw = serde_json::Map::new();
398 raw.insert("id".to_string(), cid);
399 raw.insert("type".to_string(), serde_json::Value::String("⏚".to_string()));
400 raw.insert(
401 "points".to_string(),
402 serde_json::Value::Array(vec![id.clone()]),
403 );
404 doc.constraints.push(crate::sketch::SketchConstraint { raw });
405 if let Some(p) = doc.point_mut(id) {
406 p.fixed = true;
407 }
408 }
409 }
410 self.resolve_active_sketch("toggle-ground");
411 self.refresh_sketch_overlay();
412 self.dirty = true;
413 true
414 }
415
416 pub fn sketch_toggle_construction(&mut self) -> bool {
421 if self.sketch_selection_has_text_frame() { return false; }
422 let Some(edit) = self.sketch_edit.as_mut() else {
423 return false;
424 };
425 let mut pt_ids: Vec<serde_json::Value> = Vec::new();
426 let mut geo_ids: Vec<serde_json::Value> = Vec::new();
427 for r in &edit.session.selection.items {
428 match r.get("kind").and_then(|v| v.as_str()) {
429 Some("point") => {
430 if let Some(id) = r.get("id").filter(|id| !crate::sketch::external_ref::is_origin_id(id)) {
432 pt_ids.push(id.clone());
433 }
434 }
435 Some("geometry") => {
436 if let Some(id) = r.get("id") {
437 geo_ids.push(id.clone());
438 }
439 }
440 _ => {}
441 }
442 }
443 if pt_ids.is_empty() && geo_ids.is_empty() {
444 return false;
445 }
446 edit.record_undo();
448 let doc = &mut edit.session.doc;
449 let all_construction = pt_ids
450 .iter()
451 .all(|id| doc.point(id).map_or(false, |p| p.construction))
452 && geo_ids
453 .iter()
454 .all(|id| doc.geometry(id).map_or(false, |g| g.construction()));
455 let next = !all_construction;
456 for id in &pt_ids {
457 if let Some(p) = doc.point_mut(id) {
458 p.construction = next;
459 }
460 }
461 for id in &geo_ids {
462 if let Some(g) = doc.geometry_mut(id) {
463 g.extra
464 .insert("construction".to_string(), serde_json::Value::Bool(next));
465 }
466 }
467 self.resolve_active_sketch("toggle-construction");
468 self.refresh_sketch_overlay();
469 self.dirty = true;
470 true
471 }
472
473 pub fn sketch_cleanup_unused_points(&mut self) -> bool {
477 use crate::sketch::doc::id_key;
478 use std::collections::HashSet;
479
480 let Some(edit) = self.sketch_edit.as_mut() else {
481 return false;
482 };
483 edit.record_undo();
485 let doc = &mut edit.session.doc;
486 let mut used: HashSet<String> = HashSet::new();
487 for g in &doc.geometries {
488 for pid in &g.points {
489 used.insert(id_key(pid));
490 }
491 }
492 for c in &doc.constraints {
493 for pid in c.points() {
494 used.insert(id_key(pid));
495 }
496 }
497 let before = doc.points.len();
498 doc.points.retain(|p| used.contains(&id_key(&p.id)));
499 let removed = doc.points.len() != before;
500 if !removed {
501 edit.undo_stack.pop();
502 return false;
503 }
504 self.resolve_active_sketch("cleanup");
505 self.refresh_sketch_overlay();
506 self.dirty = true;
507 true
508 }
509
510 pub fn sketch_constraint_count(&self) -> usize {
513 self.sketch_edit
514 .as_ref()
515 .map_or(0, |edit| edit.session.doc.constraints.len())
516 }
517
518 pub fn sketch_selection_all_grounded(&self) -> Option<bool> {
522 use crate::sketch::doc::id_key;
523 let edit = self.sketch_edit.as_ref()?;
524 let doc = &edit.session.doc;
525 let sel: Vec<&serde_json::Value> = edit
526 .session
527 .selection.items
528 .iter()
529 .filter(|r| r.get("kind").and_then(|v| v.as_str()) == Some("point"))
530 .filter_map(|r| r.get("id"))
531 .collect();
532 if sel.is_empty() {
533 return None;
534 }
535 let all = sel.iter().all(|id| {
536 doc.constraints.iter().any(|c| {
537 c.ctype() == Some("⏚")
538 && c.points().first().map(id_key) == Some(id_key(id))
539 })
540 });
541 Some(all)
542 }
543
544 pub fn sketch_selection_all_construction(&self) -> Option<bool> {
548 if self.sketch_selection_has_text_frame() { return None; }
549 let edit = self.sketch_edit.as_ref()?;
550 let doc = &edit.session.doc;
551 let mut any = false;
552 let mut all = true;
553 for r in &edit.session.selection.items {
554 match r.get("kind").and_then(|v| v.as_str()) {
555 Some("point") => {
556 any = true;
557 if let Some(id) = r.get("id") {
558 if !doc.point(id).map_or(false, |p| p.construction) {
559 all = false;
560 }
561 }
562 }
563 Some("geometry") => {
564 any = true;
565 if let Some(id) = r.get("id") {
566 if !doc.geometry(id).map_or(false, |g| g.construction()) {
567 all = false;
568 }
569 }
570 }
571 _ => {}
572 }
573 }
574 if !any {
575 return None;
576 }
577 Some(all)
578 }
579}
580
581fn sketch_palette_for(session: &crate::sketch::SketchSession) -> Vec<SketchConstraintAction> {
585 use crate::sketch::doc::id_key;
586 use std::collections::HashSet;
587
588 let doc = &session.doc;
589 let mut sel_points: Vec<serde_json::Value> = Vec::new();
590 let mut geos: Vec<&crate::sketch::SketchGeometry> = Vec::new();
591 for r in &session.selection.items {
592 match r.get("kind").and_then(|v| v.as_str()) {
593 Some("point") => {
594 if let Some(id) = r.get("id") {
595 sel_points.push(id.clone());
596 }
597 }
598 Some("geometry") => {
599 if let Some(id) = r.get("id") {
600 if let Some(g) = doc.geometry(id) {
601 geos.push(g);
602 }
603 }
604 }
605 _ => {}
606 }
607 }
608
609 if geos.iter().any(|g| g.geom_type == "text") { return Vec::new(); }
610
611 let mut point_set: HashSet<String> = HashSet::new();
614 for id in &sel_points {
615 point_set.insert(id_key(id));
616 }
617 for g in &geos {
618 let pts: &[serde_json::Value] = if g.geom_type == "arc" {
619 &g.points[..g.points.len().min(2)]
620 } else {
621 &g.points
622 };
623 for pid in pts {
624 point_set.insert(id_key(pid));
625 }
626 }
627 let point_count = point_set.len();
628 let selected_point_ids_len = sel_points.len();
629 let is_radial =
630 |g: &crate::sketch::SketchGeometry| g.geom_type == "arc" || g.geom_type == "circle";
631
632 let mut out: Vec<SketchConstraintAction> = Vec::new();
633 let mut push = |symbol: &str, label: &str, dimensional: bool| {
634 out.push(SketchConstraintAction {
635 symbol: symbol.to_string(),
636 label: label.to_string(),
637 dimensional,
638 });
639 };
640
641 if geos.len() == 1 && is_radial(geos[0]) {
643 push("R", "Radius", true);
644 push("⌀", "Diameter", true);
645 return out;
646 }
647 if geos.len() == 2 && geos.iter().all(|g| g.geom_type == "line") {
656 push("∥", "Parallel", false);
657 push("⟂", "Perpendicular", false);
658 push("∠", "Angle", true);
659 push("⇌", "Equal distance", false);
660 push("⋰", "Collinear", false);
661 push("⏛", "Point on line", false);
662 if geos
663 .iter()
664 .any(|g| crate::sketch::spline::is_handle_guide(doc, g))
665 {
666 push("⌒", "Tangent", false);
667 }
668 if crate::sketch::spline::is_curvature_joint(doc, geos[0], geos[1]) {
674 push("ϰ", "Curvature (G2)", false);
675 }
676 return out;
677 }
678 if geos.len() == 2 && geos.iter().all(|g| is_radial(g)) {
680 push("⊜", "Equal radius", false);
681 push("◎", "Concentric", false);
682 push("⌒", "Tangent", false);
683 return out;
684 }
685 if geos.len() > 2 && sel_points.is_empty() {
689 if geos.iter().all(|g| g.geom_type == "line") {
690 push("⇌", "Equal distance", false);
691 return out;
692 }
693 if geos.iter().all(|g| is_radial(g)) {
694 push("⊜", "Equal radius", false);
695 return out;
696 }
697 }
698 if geos.len() == 2
702 && ((geos[0].geom_type == "line" && is_radial(geos[1]))
703 || (geos[1].geom_type == "line" && is_radial(geos[0])))
704 {
705 push("⌒", "Tangent", false);
706 if geos
707 .iter()
708 .any(|g| crate::sketch::spline::is_end_handle_guide(doc, g))
709 {
710 push("ϰ", "Curvature (G2)", false);
711 }
712 return out;
713 }
714 if sketch_foot_tangent_pair(doc, &geos).is_some() {
724 push("∿", "Tangent (span point)", false);
725 return out;
726 }
727 if geos.len() == 1 && geos[0].geom_type == "line" && selected_point_ids_len == 2 {
729 push("⟂", "Perpendicular", false);
730 push("⋈", "Symmetric about line", false);
731 }
732
733 if point_count == 1 {
734 push("⏚", "Ground (fix point)", false);
735 }
736 if point_count == 2 {
737 push("━", "Horizontal", false);
738 push("│", "Vertical", false);
739 push("≡", "Coincident", false);
740 push("⟺", "Distance", true);
741 }
742 if point_count == 3 {
743 push("⋯", "Midpoint", false);
744 push("⏛", "Point on line", false);
745 push("↥", "Line to point distance", true);
746 push("∠", "Angle", true);
747 if selected_point_ids_len >= 3 {
749 push("⋰", "Collinear", false);
750 }
751 }
752 if point_count > 3 && geos.is_empty() && selected_point_ids_len >= 3 {
754 push("⋰", "Collinear", false);
755 }
756
757 out
758}
759
760fn sketch_build_tangent_points(
771 doc: &crate::sketch::SketchDoc,
772 geos: &[&crate::sketch::SketchGeometry],
773) -> Option<Vec<serde_json::Value>> {
774 if geos.len() != 2 {
775 return None;
776 }
777 let is_radial = |g: &crate::sketch::SketchGeometry| g.geom_type == "arc" || g.geom_type == "circle";
778 if geos.iter().all(|g| g.geom_type == "line") {
779 let (a, b) = (geos[0], geos[1]);
780 if a.points.len() < 2 || b.points.len() < 2 {
781 return None;
782 }
783 if !crate::sketch::spline::is_handle_guide(doc, a)
784 && !crate::sketch::spline::is_handle_guide(doc, b)
785 {
786 return None;
787 }
788 return Some(vec![
789 a.points[0].clone(),
790 a.points[1].clone(),
791 b.points[0].clone(),
792 b.points[1].clone(),
793 ]);
794 }
795 let line = geos.iter().find(|g| g.geom_type == "line");
796 let circ = geos.iter().find(|g| is_radial(g));
797 if let (Some(line), Some(circ)) = (line, circ) {
798 if line.points.len() < 2 || circ.points.len() < 2 {
799 return None;
800 }
801 return Some(vec![
802 line.points[0].clone(),
803 line.points[1].clone(),
804 circ.points[0].clone(),
805 circ.points[1].clone(),
806 ]);
807 }
808 if geos.iter().all(|g| is_radial(g)) {
809 let (a, b) = (geos[0], geos[1]);
810 if a.points.len() < 2 || b.points.len() < 2 {
811 return None;
812 }
813 return Some(vec![
814 a.points[0].clone(),
815 a.points[1].clone(),
816 b.points[0].clone(),
817 b.points[1].clone(),
818 ]);
819 }
820 None
821}
822
823fn sketch_foot_tangent_pair<'a>(
837 doc: &crate::sketch::SketchDoc,
838 geos: &[&'a crate::sketch::SketchGeometry],
839) -> Option<(&'a crate::sketch::SketchGeometry, &'a crate::sketch::SketchGeometry)> {
840 if geos.len() != 2 {
841 return None;
842 }
843 let is_spline = |g: &crate::sketch::SketchGeometry| {
844 crate::sketch::spline::is_spline_type(&g.geom_type)
845 };
846 let is_radial =
847 |g: &crate::sketch::SketchGeometry| g.geom_type == "arc" || g.geom_type == "circle";
848 let spline = *geos.iter().find(|g| is_spline(g))?;
849 let target = *geos.iter().find(|g| !is_spline(g))?;
850 if spline.points.len() < 4 || target.points.len() < 2 {
851 return None;
852 }
853 let target_is_usable = if target.geom_type == "line" {
854 !crate::sketch::spline::is_handle_guide(doc, target)
855 } else {
856 is_radial(target)
857 };
858 target_is_usable.then_some((spline, target))
859}
860
861fn sketch_build_foot_tangent_points(
872 doc: &crate::sketch::SketchDoc,
873 geos: &[&crate::sketch::SketchGeometry],
874) -> Option<Vec<serde_json::Value>> {
875 let (spline, target) = sketch_foot_tangent_pair(doc, geos)?;
876 Some(vec![
877 target.points[0].clone(),
878 target.points[1].clone(),
879 spline.points[0].clone(),
880 spline.points[1].clone(),
881 ])
882}
883
884fn sketch_build_curvature_points(
897 doc: &crate::sketch::SketchDoc,
898 geos: &[&crate::sketch::SketchGeometry],
899) -> Option<Vec<serde_json::Value>> {
900 if geos.len() != 2 {
901 return None;
902 }
903 let is_radial =
904 |g: &crate::sketch::SketchGeometry| g.geom_type == "arc" || g.geom_type == "circle";
905 if geos.iter().all(|g| g.geom_type == "line") {
906 if !crate::sketch::spline::is_curvature_joint(doc, geos[0], geos[1]) {
907 return None;
908 }
909 let (a, b) = (geos[0], geos[1]);
910 return Some(vec![
911 a.points[0].clone(),
912 a.points[1].clone(),
913 b.points[0].clone(),
914 b.points[1].clone(),
915 ]);
916 }
917 let guide = geos
918 .iter()
919 .find(|g| crate::sketch::spline::is_end_handle_guide(doc, g))?;
920 let circ = geos.iter().find(|g| is_radial(g))?;
921 if circ.points.len() < 2 {
922 return None;
923 }
924 Some(vec![
925 guide.points[0].clone(),
926 guide.points[1].clone(),
927 circ.points[0].clone(),
928 circ.points[1].clone(),
929 ])
930}
931
932pub(super) fn sketch_perpendicular_should_swap(
936 doc: &crate::sketch::SketchDoc,
937 pts: &[serde_json::Value],
938) -> bool {
939 let coord = |v: &serde_json::Value| doc.point(v).map(|p| (p.x, p.y));
940 let (Some(p0), Some(p1), Some(p2), Some(p3)) =
941 (coord(&pts[0]), coord(&pts[1]), coord(&pts[2]), coord(&pts[3]))
942 else {
943 return false;
944 };
945 let angle = |a: (f64, f64), b: (f64, f64)| -> f64 {
947 let deg = (b.1 - a.1).atan2(b.0 - a.0) * 180.0 / std::f64::consts::PI;
948 (deg + 360.0) % 360.0
949 };
950 let fold = |a: f64| (a + 180.0) % 360.0 - 180.0;
952 let line1_a = fold(angle(p0, p1));
953 let line1_b = fold(angle(p1, p0));
954 let line2 = fold(angle(p2, p3));
955 let diff_a = line1_a - line2;
956 let diff_b = line1_b - line2;
957 (90.0 - diff_a).abs() > (90.0 - diff_b).abs()
958}
959
960pub(super) fn sketch_constraint_signature(ctype: &str, points: &[serde_json::Value]) -> String {
963 use crate::sketch::doc::id_key;
964 let mut keys: Vec<String> = points.iter().map(id_key).collect();
965 keys.sort();
966 format!("{ctype}|{}", keys.join(","))
967}
968
969fn sketch_build_and_add_constraint(
974 session: &mut crate::sketch::SketchSession,
975 symbol: &str,
976) -> bool {
977 struct Built {
981 store_type: String,
982 display_style: &'static str,
983 lists: Vec<Vec<serde_json::Value>>,
984 }
985
986 fn sketch_equal_to_first(t: &str, geos: &[&crate::sketch::SketchGeometry]) -> Built {
989 let first = geos[0];
990 Built {
991 store_type: t.to_string(),
992 display_style: "",
993 lists: geos[1..]
994 .iter()
995 .map(|g| {
996 vec![
997 first.points[0].clone(),
998 first.points[1].clone(),
999 g.points[0].clone(),
1000 g.points[1].clone(),
1001 ]
1002 })
1003 .collect(),
1004 }
1005 }
1006
1007 let built: Option<Built> = {
1009 let doc = &session.doc;
1010 let mut selected: Vec<serde_json::Value> = Vec::new();
1015 let mut geo_items: Vec<&crate::sketch::SketchGeometry> = Vec::new();
1016 let mut point_items: Vec<serde_json::Value> = Vec::new();
1017 let mut geometry_type: Option<String> = None;
1018 let mut first_kind: Option<String> = None;
1019 let mut has_geometry = false;
1020 for (i, r) in session.selection.items.iter().enumerate() {
1021 let kind = r.get("kind").and_then(|v| v.as_str());
1022 let Some(id) = r.get("id") else { continue };
1023 if i == 0 {
1024 first_kind = kind.map(|k| k.to_string());
1025 }
1026 match kind {
1027 Some("point") => {
1028 if let Some(p) = doc.point(id) {
1029 selected.push(p.id.clone());
1030 point_items.push(p.id.clone());
1031 }
1032 }
1033 Some("geometry") => {
1034 if let Some(g) = doc.geometry(id) {
1035 for pid in &g.points {
1036 if doc.point(pid).is_some() {
1037 selected.push(pid.clone());
1038 }
1039 }
1040 if g.geom_type == "arc" {
1041 selected.pop();
1042 }
1043 geometry_type = Some(g.geom_type.clone());
1044 geo_items.push(g);
1045 has_geometry = true;
1046 }
1047 }
1048 _ => {}
1049 }
1050 }
1051 if selected.is_empty() {
1052 None
1053 } else {
1054 let radial = |g: &crate::sketch::SketchGeometry| {
1055 g.geom_type == "arc" || g.geom_type == "circle"
1056 };
1057 let simple = |t: &str, list: Vec<serde_json::Value>, ds: &'static str| Built {
1058 store_type: t.to_string(),
1059 display_style: ds,
1060 lists: vec![list],
1061 };
1062
1063 match symbol {
1065 "◎" => {
1066 if geo_items.len() == 2 && geo_items.iter().all(|g| radial(g)) {
1067 Some(simple(
1068 "◎",
1069 vec![geo_items[0].points[0].clone(), geo_items[1].points[0].clone()],
1070 "",
1071 ))
1072 } else {
1073 None
1074 }
1075 }
1076 "⊜" => {
1080 if geo_items.len() >= 2
1081 && geo_items.iter().all(|g| radial(g) && g.points.len() >= 2)
1082 {
1083 Some(sketch_equal_to_first("⊜", &geo_items))
1084 } else {
1085 None
1086 }
1087 }
1088 "⇌" if geo_items.len() >= 2
1091 && point_items.is_empty()
1092 && geo_items
1093 .iter()
1094 .all(|g| g.geom_type == "line" && g.points.len() >= 2) =>
1095 {
1096 Some(sketch_equal_to_first("⇌", &geo_items))
1097 }
1098 "⌒" => sketch_build_tangent_points(doc, &geo_items).map(|pts| simple("⌒", pts, "")),
1099 "ϰ" => {
1100 sketch_build_curvature_points(doc, &geo_items).map(|pts| simple("ϰ", pts, ""))
1101 }
1102 "∿" => sketch_build_foot_tangent_points(doc, &geo_items)
1103 .map(|pts| simple("∿", pts, "")),
1104 "⋰" => {
1105 let ids = if geo_items.len() >= 2
1106 && geo_items.iter().all(|g| g.geom_type == "line")
1107 {
1108 let mut v = Vec::new();
1109 for g in &geo_items {
1110 v.push(g.points[0].clone());
1111 v.push(g.points[1].clone());
1112 }
1113 Some(v)
1114 } else if point_items.len() >= 3 {
1115 Some(point_items.clone())
1116 } else {
1117 None
1118 };
1119 ids.filter(|v| v.len() >= 3).map(|v| simple("⋰", v, ""))
1120 }
1121 "⋈" => geo_items
1122 .iter()
1123 .find(|g| g.geom_type == "line")
1124 .filter(|line| line.points.len() >= 2 && point_items.len() == 2)
1125 .map(|line| {
1126 simple(
1127 "⋈",
1128 vec![
1129 line.points[0].clone(),
1130 line.points[1].clone(),
1131 point_items[0].clone(),
1132 point_items[1].clone(),
1133 ],
1134 "",
1135 )
1136 }),
1137 "R" | "⌀" => {
1140 if selected.len() == 2 {
1141 let ds = if symbol == "⌀" { "diameter" } else { "radius" };
1142 Some(Built {
1143 store_type: "⟺".to_string(),
1144 display_style: ds,
1145 lists: vec![selected.clone()],
1146 })
1147 } else {
1148 None
1149 }
1150 }
1151 _ => {
1152 match selected.len() {
1154 1 => match symbol {
1155 "⏚" => Some(simple("⏚", selected.clone(), "")),
1156 _ => None,
1157 },
1158 2 => match symbol {
1159 "━" | "│" | "≡" => Some(simple(symbol, selected.clone(), "")),
1160 "⟺" => {
1161 let ds = if matches!(
1162 geometry_type.as_deref(),
1163 Some("arc") | Some("circle")
1164 ) {
1165 "radius"
1166 } else {
1167 ""
1168 };
1169 Some(Built {
1170 store_type: "⟺".to_string(),
1171 display_style: ds,
1172 lists: vec![selected.clone()],
1173 })
1174 }
1175 _ => None,
1176 },
1177 3 => match symbol {
1178 "⏛" => Some(simple("⏛", selected.clone(), "")),
1179 "⋯" => {
1180 let mut pts = selected.clone();
1181 if has_geometry && first_kind.as_deref() == Some("point") {
1182 pts.reverse();
1183 }
1184 Some(simple("⋯", pts, ""))
1185 }
1186 "↥" => {
1187 if geo_items.len() == 1 && point_items.len() == 1 {
1188 let line = geo_items[0];
1189 if line.geom_type == "line" && line.points.len() >= 2 {
1190 Some(simple(
1191 "↥",
1192 vec![
1193 line.points[0].clone(),
1194 line.points[1].clone(),
1195 point_items[0].clone(),
1196 ],
1197 "",
1198 ))
1199 } else {
1200 None
1201 }
1202 } else {
1203 Some(simple("↥", selected.clone(), ""))
1206 }
1207 }
1208 "⇌" => Some(simple("⇌", selected.clone(), "")),
1209 _ => None,
1210 },
1211 4 | 5 => match symbol {
1212 "⏛" => {
1213 if geo_items.len() == 2
1216 && geo_items.iter().all(|g| g.geom_type == "line")
1217 && geo_items[0].points.len() >= 2
1218 && geo_items[1].points.len() >= 2
1219 {
1220 let (g0, g1) = (geo_items[0], geo_items[1]);
1221 Some(Built {
1222 store_type: "⏛".to_string(),
1223 display_style: "",
1224 lists: vec![
1225 vec![
1226 g0.points[0].clone(),
1227 g0.points[1].clone(),
1228 g1.points[0].clone(),
1229 ],
1230 vec![
1231 g0.points[0].clone(),
1232 g0.points[1].clone(),
1233 g1.points[1].clone(),
1234 ],
1235 ],
1236 })
1237 } else {
1238 None
1239 }
1240 }
1241 "⟂" => {
1242 if selected.len() != 4 {
1243 None
1244 } else {
1245 let mut pts = selected.clone();
1246 if sketch_perpendicular_should_swap(doc, &pts) {
1247 pts.swap(0, 1);
1248 }
1249 Some(simple("⟂", pts, ""))
1250 }
1251 }
1252 "∥" => Some(simple("∥", selected.clone(), "")),
1253 "∠" => Some(simple("∠", selected.clone(), "")),
1254 "⇌" => Some(simple("⇌", selected.clone(), "")),
1255 _ => None,
1256 },
1257 _ => None,
1258 }
1259 }
1260 }
1261 }
1262 };
1263
1264 let Some(built) = built else {
1265 return false;
1266 };
1267
1268 let doc = &mut session.doc;
1271 let mut added_any = false;
1272 for pts in &built.lists {
1273 let sig = sketch_constraint_signature(&built.store_type, pts);
1274 let duplicate = doc.constraints.iter().any(|c| match c.ctype() {
1275 Some(t) => sketch_constraint_signature(t, c.points()) == sig,
1276 None => false,
1277 });
1278 if duplicate {
1279 continue;
1280 }
1281 let id = doc.next_constraint_id();
1282 let mut raw = serde_json::Map::new();
1283 raw.insert("id".to_string(), id);
1284 raw.insert(
1285 "type".to_string(),
1286 serde_json::Value::String(built.store_type.clone()),
1287 );
1288 raw.insert("points".to_string(), serde_json::Value::Array(pts.clone()));
1289 raw.insert("labelX".to_string(), serde_json::Value::from(0));
1290 raw.insert("labelY".to_string(), serde_json::Value::from(0));
1291 raw.insert(
1292 "displayStyle".to_string(),
1293 serde_json::Value::String(built.display_style.to_string()),
1294 );
1295 raw.insert("value".to_string(), serde_json::Value::Null);
1296 raw.insert("valueNeedsSetup".to_string(), serde_json::Value::Bool(true));
1297 doc.constraints.push(crate::sketch::SketchConstraint { raw });
1298 added_any = true;
1299 }
1300 added_any
1301}
1302
1303
1304impl EngineState {
1305 fn sketch_selection_has_text_frame(&self) -> bool {
1306 let Some(edit) = &self.sketch_edit else { return false; };
1307 edit.session.selection.items.iter().any(|r| {
1308 let id = &r["id"];
1309 if r["kind"] == "geometry" {
1310 edit.session.doc.geometry(id).is_some_and(|g| g.geom_type == "text" || g.extra.contains_key("textFrame"))
1311 } else {
1312 edit.session.doc.geometries.iter().any(|g| g.geom_type == "text" && g.points.contains(id))
1313 }
1314 })
1315 }
1316
1317 pub fn sketch_selected_text(&self) -> Option<(serde_json::Value, String, String)> {
1318 let session = &self.sketch_edit.as_ref()?.session;
1319 if session.selection.items.len() != 1 { return None; }
1320 let r = &session.selection.items[0];
1321 if r["kind"] != "geometry" { return None; }
1322 let g = session.doc.geometry(&r["id"])?;
1323 if g.geom_type != "text" { return None; }
1324 Some((g.id.clone(), g.extra["text"].as_str()?.into(), g.extra["font"].as_str()?.into()))
1325 }
1326
1327 pub fn sketch_edit_text(&mut self, id: &serde_json::Value, text: &str, font: &str) -> Result<(), String> {
1328 let contours = crate::sketch::text::outlines(text,font)?;
1329 let edit = self.sketch_edit.as_mut().ok_or("No active sketch")?;
1330 let g = edit.session.doc.geometry(id).filter(|g| g.geom_type == "text").ok_or("Text object no longer exists")?;
1331 if g.extra["text"] == text && g.extra["font"] == font { return Ok(()); }
1332 edit.record_undo();
1333 let g = edit.session.doc.geometry_mut(id).unwrap();
1334 g.extra.insert("text".into(), serde_json::json!(text));
1335 g.extra.insert("font".into(), serde_json::json!(font));
1336 g.extra.insert("textContours".into(), contours);
1337 self.refresh_sketch_overlay();
1338 self.dirty = true;
1339 Ok(())
1340 }
1341}