use crate::sketch::doc::{id_key, SketchDoc};
use crate::sketch::trim::add_constraint_if_missing;
use serde_json::Value;
use std::collections::HashSet;
pub(crate) fn are_points_coincident(doc: &SketchDoc, a: &Value, b: &Value) -> bool {
let (ak, bk) = (id_key(a), id_key(b));
if ak == bk {
return true;
}
let mut visited = HashSet::new();
visited.insert(ak.clone());
let mut stack = vec![ak];
while let Some(cur) = stack.pop() {
for c in &doc.constraints {
if c.ctype() != Some("≡") {
continue;
}
let pts = c.points();
if pts.len() < 2 {
continue;
}
let (p0, p1) = (id_key(&pts[0]), id_key(&pts[1]));
let next = if p0 == cur {
Some(p1)
} else if p1 == cur {
Some(p0)
} else {
None
};
if let Some(n) = next {
if n == bk {
return true;
}
if visited.insert(n.clone()) {
stack.push(n);
}
}
}
}
false
}
pub(crate) fn infer_drop_constraint(doc: &mut SketchDoc, dragged: &Value, world_tol: f64) -> bool {
if maybe_add_coincident(doc, dragged, world_tol) {
return true;
}
maybe_add_point_on_line(doc, dragged, world_tol)
}
fn maybe_add_coincident(doc: &mut SketchDoc, dragged: &Value, tol: f64) -> bool {
let Some(p) = doc.point(dragged) else {
return false;
};
let (px, py) = (p.x, p.y);
let dk = id_key(dragged);
let siblings: HashSet<String> = doc
.geometries
.iter()
.filter(|g| g.points.iter().any(|pid| id_key(pid) == dk))
.flat_map(|g| g.points.iter().map(id_key))
.collect();
let mut best: Option<(f64, Value)> = None;
for q in &doc.points {
let qk = id_key(&q.id);
if qk == dk || siblings.contains(&qk) {
continue;
}
let d = (q.x - px).hypot(q.y - py);
if d > tol {
continue;
}
if are_points_coincident(doc, dragged, &q.id) {
continue;
}
if best.as_ref().map_or(true, |(bd, _)| d < *bd) {
best = Some((d, q.id.clone()));
}
}
match best {
Some((_, target)) => add_constraint_if_missing(doc, "≡", vec![target, dragged.clone()]),
None => false,
}
}
fn maybe_add_point_on_line(doc: &mut SketchDoc, dragged: &Value, tol: f64) -> bool {
let Some(p) = doc.point(dragged) else {
return false;
};
let (px, py) = (p.x, p.y);
let dk = id_key(dragged);
let mut best: Option<(f64, Value, f64)> = None; for g in &doc.geometries {
if g.geom_type != "line" || g.points.len() < 2 {
continue;
}
if g.points.iter().any(|pid| id_key(pid) == dk) {
continue;
}
let (a_id, b_id) = (&g.points[0], &g.points[1]);
if are_points_coincident(doc, dragged, a_id) || are_points_coincident(doc, dragged, b_id) {
continue;
}
let (Some(a), Some(b)) = (doc.point(a_id), doc.point(b_id)) else {
continue;
};
let (ax, ay, dx, dy) = (a.x, a.y, b.x - a.x, b.y - a.y);
let len2 = dx * dx + dy * dy;
if len2 <= 1e-12 {
continue;
}
let t = (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0);
let dist = (px - (ax + dx * t)).hypot(py - (ay + dy * t));
if dist > tol {
continue;
}
if best.as_ref().map_or(true, |(bd, _, _)| dist < *bd) {
best = Some((dist, g.id.clone(), t));
}
}
let Some((_, line_id, t)) = best else {
return false;
};
let Some(g) = doc.geometry(&line_id).cloned() else {
return false;
};
let (a_id, b_id) = (g.points[0].clone(), g.points[1].clone());
const ENDPOINT_EPS: f64 = 1e-3;
if t <= ENDPOINT_EPS {
add_constraint_if_missing(doc, "≡", vec![a_id, dragged.clone()])
} else if t >= 1.0 - ENDPOINT_EPS {
add_constraint_if_missing(doc, "≡", vec![b_id, dragged.clone()])
} else {
add_constraint_if_missing(doc, "⏛", vec![a_id, b_id, dragged.clone()])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sketch::SketchDoc;
fn doc_from(json: serde_json::Value) -> SketchDoc {
serde_json::from_value(json).expect("doc deserializes")
}
fn ctypes(doc: &SketchDoc) -> Vec<String> {
doc.constraints
.iter()
.filter_map(|c| c.ctype().map(str::to_string))
.collect()
}
#[test]
fn coincident_on_drop_adds_equal_to_nearest_point() {
let mut doc = doc_from(serde_json::json!({
"points": [
{"id": 1, "x": 10.0, "y": 10.0},
{"id": 2, "x": 10.02, "y": 9.99}, {"id": 3, "x": 50.0, "y": 50.0} ],
"geometries": [],
"constraints": []
}));
assert!(infer_drop_constraint(&mut doc, &serde_json::json!(2), 0.1));
assert_eq!(ctypes(&doc), vec!["≡"]);
let c = &doc.constraints[0];
let pts: Vec<_> = c.points().iter().map(id_key).collect();
assert!(pts.contains(&"1".to_string()) && pts.contains(&"2".to_string()));
}
#[test]
fn coincident_on_drop_skips_existing_and_siblings() {
let mut doc = doc_from(serde_json::json!({
"points": [
{"id": 1, "x": 10.0, "y": 10.0},
{"id": 2, "x": 10.01, "y": 10.0},
{"id": 3, "x": 10.02, "y": 10.0}
],
"geometries": [{"id": 100, "type": "line", "points": [2, 3]}],
"constraints": [{"id": 1, "type": "≡", "points": [1, 2], "value": null}]
}));
assert!(!infer_drop_constraint(&mut doc, &serde_json::json!(2), 0.1));
assert_eq!(ctypes(&doc), vec!["≡"]); }
#[test]
fn point_on_line_on_drop_adds_slide() {
let mut doc = doc_from(serde_json::json!({
"points": [
{"id": 1, "x": 0.0, "y": 0.0},
{"id": 2, "x": 20.0, "y": 0.0},
{"id": 3, "x": 10.0, "y": 0.03} ],
"geometries": [{"id": 100, "type": "line", "points": [1, 2]}],
"constraints": []
}));
assert!(infer_drop_constraint(&mut doc, &serde_json::json!(3), 0.1));
assert_eq!(ctypes(&doc), vec!["⏛"]);
}
#[test]
fn point_on_line_endpoint_becomes_coincident() {
let mut doc = doc_from(serde_json::json!({
"points": [
{"id": 1, "x": 0.0, "y": 0.0},
{"id": 2, "x": 20.0, "y": 0.0},
{"id": 3, "x": 0.02, "y": 0.01} ],
"geometries": [{"id": 100, "type": "line", "points": [1, 2]}],
"constraints": []
}));
assert!(infer_drop_constraint(&mut doc, &serde_json::json!(3), 0.1));
assert_eq!(ctypes(&doc), vec!["≡"]);
}
#[test]
fn lonely_drop_infers_nothing() {
let mut doc = doc_from(serde_json::json!({
"points": [
{"id": 1, "x": 0.0, "y": 0.0},
{"id": 2, "x": 100.0, "y": 100.0}
],
"geometries": [],
"constraints": []
}));
assert!(!infer_drop_constraint(&mut doc, &serde_json::json!(2), 0.1));
assert!(doc.constraints.is_empty());
}
}