use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct SketchPoint {
pub id: Value,
pub x: f64,
pub y: f64,
#[serde(default)]
pub fixed: bool,
#[serde(default)]
pub construction: bool,
#[serde(default, rename = "externalReference")]
pub external_reference: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct SketchGeometry {
pub id: Value,
#[serde(rename = "type")]
pub geom_type: String,
#[serde(default)]
pub points: Vec<Value>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
impl SketchGeometry {
pub fn construction(&self) -> bool {
self.extra
.get("construction")
.and_then(Value::as_bool)
.unwrap_or(false)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(transparent)]
pub struct SketchConstraint {
pub raw: Map<String, Value>,
}
impl SketchConstraint {
pub fn ctype(&self) -> Option<&str> {
self.raw.get("type").and_then(Value::as_str)
}
pub fn points(&self) -> &[Value] {
self.raw
.get("points")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[])
}
pub fn temporary(&self) -> bool {
self.raw
.get("temporary")
.and_then(Value::as_bool)
.unwrap_or(false)
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct SketchDoc {
#[serde(default)]
pub points: Vec<SketchPoint>,
#[serde(default)]
pub geometries: Vec<SketchGeometry>,
#[serde(default)]
pub constraints: Vec<SketchConstraint>,
}
impl SketchDoc {
pub fn point(&self, id: &Value) -> Option<&SketchPoint> {
let key = id_key(id);
self.points.iter().find(|p| id_key(&p.id) == key)
}
pub fn point_mut(&mut self, id: &Value) -> Option<&mut SketchPoint> {
let key = id_key(id);
self.points.iter_mut().find(|p| id_key(&p.id) == key)
}
pub fn constrained_point_keys(&self) -> std::collections::HashSet<String> {
let mut set = std::collections::HashSet::new();
for c in &self.constraints {
if c.temporary() {
continue;
}
for pid in c.points() {
set.insert(id_key(pid));
}
}
set
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct SketchDiagnostics {
#[serde(default)]
pub dof: i64,
#[serde(default)]
pub rank: i64,
#[serde(default)]
pub unknowns: i64,
#[serde(default)]
pub equations: i64,
#[serde(default)]
pub redundant: i64,
#[serde(default)]
pub status: String,
#[serde(default)]
pub conflicting: bool,
#[serde(default, rename = "pointMobility")]
pub point_mobility: std::collections::BTreeMap<String, String>,
#[serde(default, rename = "geometryMobility")]
pub geometry_mobility: std::collections::BTreeMap<String, String>,
}
impl SketchDiagnostics {
pub fn point_movable(&self, id: &Value) -> Option<bool> {
self.point_mobility
.get(&id_key(id))
.map(|v| v == "movable")
}
pub fn geometry_movable(&self, id: &Value) -> Option<bool> {
self.geometry_mobility
.get(&id_key(id))
.map(|v| v == "movable")
}
}
pub fn id_key(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
Value::Number(number) => number
.as_f64()
.map(fmt_number)
.unwrap_or_else(|| number.to_string()),
Value::Bool(flag) => flag.to_string(),
Value::Null => "null".to_string(),
other => other.to_string(),
}
}
fn fmt_number(x: f64) -> String {
if x.is_nan() {
"NaN".to_string()
} else if x == 0.0 {
"0".to_string()
} else {
format!("{x}")
}
}
impl SketchDoc {
pub fn next_point_id(&self) -> Value {
let ids: Vec<&Value> = self.points.iter().map(|p| &p.id).collect();
mint_next_id(&ids)
}
pub fn next_geometry_id(&self) -> Value {
let ids: Vec<&Value> = self.geometries.iter().map(|g| &g.id).collect();
mint_next_id(&ids)
}
pub fn snap_or_add_point(&mut self, u: f64, v: f64, radius: f64) -> Value {
let mut best: Option<(f64, Value)> = None;
for p in &self.points {
let d = ((p.x - u).powi(2) + (p.y - v).powi(2)).sqrt();
if d <= radius && best.as_ref().map_or(true, |(bd, _)| d < *bd) {
best = Some((d, p.id.clone()));
}
}
if let Some((_, id)) = best {
return id;
}
let id = self.next_point_id();
self.points.push(SketchPoint {
id: id.clone(),
x: u,
y: v,
fixed: false,
construction: false,
external_reference: false,
});
id
}
}
impl SketchDoc {
pub fn next_constraint_id(&self) -> Value {
let ids: Vec<&Value> = self
.constraints
.iter()
.filter_map(|c| c.raw.get("id"))
.collect();
mint_next_id(&ids)
}
pub fn geometry(&self, id: &Value) -> Option<&SketchGeometry> {
let key = id_key(id);
self.geometries.iter().find(|g| id_key(&g.id) == key)
}
pub fn geometry_mut(&mut self, id: &Value) -> Option<&mut SketchGeometry> {
let key = id_key(id);
self.geometries.iter_mut().find(|g| id_key(&g.id) == key)
}
}
fn id_num(value: &Value) -> Option<f64> {
match value {
Value::Number(number) => number.as_f64(),
Value::String(text) => text.trim().parse::<f64>().ok(),
_ => None,
}
}
fn mint_next_id(ids: &[&Value]) -> Value {
let mut max: Option<i64> = None;
for &id in ids {
if let Some(n) = id_num(id) {
if n.is_finite() {
let i = n.floor() as i64;
max = Some(max.map_or(i, |m| m.max(i)));
}
}
}
let mut candidate = max.map(|m| m + 1).unwrap_or(ids.len() as i64);
let existing: std::collections::HashSet<String> = ids.iter().map(|&id| id_key(id)).collect();
while existing.contains(&id_key(&Value::from(candidate))) {
candidate += 1;
}
Value::from(candidate)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn doc_from(value: serde_json::Value) -> SketchDoc {
serde_json::from_value(value).expect("sketch doc")
}
#[test]
fn next_ids_are_max_numeric_plus_one_per_collection() {
let doc = doc_from(json!({
"points": [{ "id": 0, "x": 0.0, "y": 0.0 }, { "id": 5, "x": 1.0, "y": 1.0 }],
"geometries": [{ "id": 10, "type": "line", "points": [0, 5] }],
"constraints": []
}));
assert_eq!(doc.next_point_id(), json!(6));
assert_eq!(doc.next_geometry_id(), json!(11));
}
#[test]
fn next_id_falls_back_to_count_when_non_numeric_and_avoids_collision() {
let doc = doc_from(json!({
"points": [{ "id": "a", "x": 0.0, "y": 0.0 }, { "id": "b", "x": 1.0, "y": 1.0 }],
"geometries": [],
"constraints": []
}));
assert_eq!(doc.next_point_id(), json!(2));
}
#[test]
fn next_ids_on_empty_doc_start_at_zero() {
let doc = SketchDoc::default();
assert_eq!(doc.next_point_id(), json!(0));
assert_eq!(doc.next_geometry_id(), json!(0));
}
#[test]
fn next_id_handles_float_and_string_numbers() {
let doc = doc_from(json!({
"points": [{ "id": 3.0, "x": 0.0, "y": 0.0 }, { "id": "7", "x": 1.0, "y": 1.0 }],
"geometries": [],
"constraints": []
}));
assert_eq!(doc.next_point_id(), json!(8));
}
#[test]
fn snap_or_add_reuses_within_radius_else_mints() {
let mut doc = doc_from(json!({
"points": [{ "id": 0, "x": 0.0, "y": 0.0 }],
"geometries": [],
"constraints": []
}));
let a = doc.snap_or_add_point(0.1, 0.0, 1.0);
assert_eq!(id_key(&a), "0");
assert_eq!(doc.points.len(), 1);
let b = doc.snap_or_add_point(10.0, 0.0, 1.0);
assert_eq!(id_key(&b), "1");
assert_eq!(doc.points.len(), 2);
let p = doc.point(&b).unwrap();
assert!((p.x - 10.0).abs() < 1e-9 && p.y.abs() < 1e-9 && !p.fixed && !p.construction);
}
#[test]
fn next_constraint_id_is_max_numeric_plus_one() {
let doc = doc_from(json!({
"points": [{ "id": 0, "x": 0.0, "y": 0.0 }],
"geometries": [],
"constraints": [
{ "id": 0, "type": "⏚", "points": [0] },
{ "id": 6, "type": "━", "points": [0, 1] }
]
}));
assert_eq!(doc.next_constraint_id(), json!(7));
let empty = SketchDoc::default();
assert_eq!(empty.next_constraint_id(), json!(0));
}
#[test]
fn geometry_lookup_matches_via_id_key() {
let doc = doc_from(json!({
"points": [],
"geometries": [{ "id": 10, "type": "line", "points": [0, 1] }],
"constraints": []
}));
assert_eq!(doc.geometry(&json!(10.0)).map(|g| g.geom_type.as_str()), Some("line"));
assert!(doc.geometry(&json!(99)).is_none());
}
#[test]
fn snap_or_add_picks_the_nearest_point_within_radius() {
let mut doc = doc_from(json!({
"points": [
{ "id": 0, "x": 0.0, "y": 0.0 },
{ "id": 1, "x": 0.5, "y": 0.0 }
],
"geometries": [],
"constraints": []
}));
let hit = doc.snap_or_add_point(0.6, 0.0, 2.0);
assert_eq!(id_key(&hit), "1");
assert_eq!(doc.points.len(), 2);
}
}