From 785e05d8054737ce4cfed250a59533cb6a224823 Mon Sep 17 00:00:00 2001 From: ysdirector Date: Sat, 10 Oct 2026 07:17:44 +0800 Subject: [PATCH] feat(automation): create and serialize splines --- src/app/automation.rs | 21 ++++++ src/app/control/entities.rs | 147 ++++++++++++++++++++++++++++++++++-- src/app/control/p1_tests.rs | 31 ++++++++ 3 files changed, 191 insertions(+), 8 deletions(-) diff --git a/src/app/automation.rs b/src/app/automation.rs index 088493ab7..bc7a4c2a7 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -286,6 +286,27 @@ pub(crate) fn entity_json(e: &codec::EntityType, detail: &str) -> Value { map.insert("start_angle".into(), json!(a.start_angle)); map.insert("end_angle".into(), json!(a.end_angle)); } + E::Spline(spline) => { + map.insert("degree".into(), json!(spline.degree)); + map.insert("knots".into(), json!(spline.knots)); + map.insert( + "control_points".into(), + json!(spline + .control_points + .iter() + .map(|point| v3(*point)) + .collect::>()), + ); + map.insert( + "fit_points".into(), + json!(spline + .fit_points + .iter() + .map(|point| v3(*point)) + .collect::>()), + ); + map.insert("weights".into(), json!(spline.weights)); + } E::Point(p) => { map.insert("location".into(), v3(p.location)); } diff --git a/src/app/control/entities.rs b/src/app/control/entities.rs index c4da959ac..8d562111b 100644 --- a/src/app/control/entities.rs +++ b/src/app/control/entities.rs @@ -8,19 +8,84 @@ use crate::command::EntityTransform; type Parsed = Result; -/// `[x,y]` or `[x,y,z]` (missing z = 0) from `req[key]`. -fn point_field(req: &Value, key: &str) -> Parsed { - let values = req[key] - .as_array() +/// Parse `[x,y]` or `[x,y,z]` without silently turning a malformed +/// coordinate into zero. +fn point_value(value: Option<&Value>, label: &str, code: &str) -> Parsed { + let values = value + .and_then(Value::as_array) .filter(|v| (2..=3).contains(&v.len())) - .ok_or_else(|| failure("invalid_point", format!("{key} must be [x,y] or [x,y,z]")))?; + .ok_or_else(|| failure(code, format!("{label} must be [x,y] or [x,y,z]")))?; let coord = |i: usize| { values[i] .as_f64() .filter(|v| v.is_finite()) - .ok_or_else(|| failure("invalid_point", format!("{key} needs finite coordinates"))) + .ok_or_else(|| failure(code, format!("{label} needs finite coordinates"))) }; - Ok(codec::types::Vector3::new(coord(0)?, coord(1)?, values.get(2).and_then(Value::as_f64).unwrap_or(0.0))) + let z = values + .get(2) + .map(|value| { + value + .as_f64() + .filter(|v| v.is_finite()) + .ok_or_else(|| failure(code, format!("{label} needs finite coordinates"))) + }) + .transpose()? + .unwrap_or(0.0); + Ok(codec::types::Vector3::new(coord(0)?, coord(1)?, z)) +} + +/// `[x,y]` or `[x,y,z]` (missing z = 0) from `req[key]`. +fn point_field(req: &Value, key: &str) -> Parsed { + point_value(req.get(key), key, "invalid_point") +} + +fn point_list_field( + spec: &Value, + key: &str, + minimum: usize, +) -> Parsed> { + let points = spec[key] + .as_array() + .filter(|points| points.len() >= minimum) + .ok_or_else(|| { + failure( + "invalid_spline_points", + format!("{key} needs at least {minimum} points"), + ) + })?; + points + .iter() + .enumerate() + .map(|(index, point)| { + point_value( + Some(point), + &format!("{key}[{index}]"), + "invalid_spline_points", + ) + }) + .collect() +} + +fn finite_number_list_field(spec: &Value, key: &str) -> Parsed> { + let values = spec[key] + .as_array() + .filter(|values| !values.is_empty()) + .ok_or_else(|| failure("invalid_spline_values", format!("{key} needs a non-empty array")))?; + values + .iter() + .enumerate() + .map(|(index, value)| { + value + .as_f64() + .filter(|value| value.is_finite()) + .ok_or_else(|| { + failure( + "invalid_spline_values", + format!("{key}[{index}] must be finite"), + ) + }) + }) + .collect() } fn deg_field(req: &Value, key: &str) -> f64 { @@ -120,6 +185,72 @@ fn build_entity(spec: &Value) -> Parsed { deg_field(spec, "end_angle_deg"), )) } + "SPLINE" => { + let control_points = if spec.get("control_points").is_some() { + point_list_field(spec, "control_points", 2)? + } else { + Vec::new() + }; + let fit_points = if spec.get("fit_points").is_some() { + point_list_field(spec, "fit_points", 2)? + } else { + Vec::new() + }; + if control_points.is_empty() && fit_points.is_empty() { + return Err(failure( + "invalid_spline_points", + "spline needs control_points or fit_points (at least 2 points)", + )); + } + let degree = match spec.get("degree") { + None | Some(Value::Null) => 3, + Some(value) => value.as_i64().unwrap_or(3), + }; + let mut spline = if control_points.is_empty() { + Spline::from_fit_points(fit_points.clone()) + } else { + Spline::from_control_points(degree as i32, control_points) + }; + spline.degree = degree as i32; + if !fit_points.is_empty() { + spline.fit_points = fit_points; + } + if spec.get("knots").is_some() { + if spline.control_points.is_empty() { + return Err(failure( + "invalid_spline_values", + "spline knots require control_points", + )); + } + spline.knots = finite_number_list_field(spec, "knots")?; + } + if spec.get("weights").is_some() { + if spline.control_points.is_empty() { + return Err(failure( + "invalid_spline_values", + "spline weights require control_points", + )); + } + spline.weights = finite_number_list_field(spec, "weights")?; + spline.flags.rational = true; + } + if let Some(value) = spec["closed"].as_bool() { + spline.flags.closed = value; + } + if let Some(value) = spec["periodic"].as_bool() { + spline.flags.periodic = value; + } + if let Some(value) = spec["rational"].as_bool() { + spline.flags.rational = value; + } + if let Some(value) = spec["planar"].as_bool() { + spline.flags.planar = value; + } + if let Some(value) = spec["linear"].as_bool() { + spline.flags.linear = value; + } + EntityType::Spline(spline) + } "LWPOLYLINE" | "POLYLINE" => { let vertices = spec["vertices"] .as_array() @@ -252,7 +383,7 @@ fn build_entity(spec: &Value) -> Parsed { return Err(failure( "unknown_entity_type", format!( - "Unknown entity type '{other}'. Supported: Line, Circle, Arc, LwPolyline, Point, Text, MText, Insert, Solid, Hatch" + "Unknown entity type '{other}'. Supported: Line, Circle, Arc, Spline, LwPolyline, Point, Text, MText, Insert, Solid, Hatch" ), )) } diff --git a/src/app/control/p1_tests.rs b/src/app/control/p1_tests.rs index 331142ce6..78953ff7f 100644 --- a/src/app/control/p1_tests.rs +++ b/src/app/control/p1_tests.rs @@ -373,6 +373,37 @@ fn plot_per_page_writes_one_pdf_per_layout() { } } +#[test] +fn entities_create_supports_control_and_fit_point_splines() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let control = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"spline-control","document_id":{doc},"entities":[ + {"type":"Spline","degree":2,"knots":[0,0,0,1,1,1],"control_points":[[0,0,0],[5,5,0],[10,0,0]],"weights":[1,2,1],"layer":"CURVES"} + ]}"#, + ); + assert_eq!(control["ok"], true, "{control}"); + let fit = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"spline-fit","document_id":{doc},"entities":[ + {"type":"Spline","degree":3,"fit_points":[[20,0],[25,5],[30,0]]} + ]}"#, + ); + assert_eq!(fit["ok"], true, "{fit}"); + let query = app.automation_op(r#"{"op":"query","type":"Spline","detail":"geometry"}"#); + assert_eq!(query["count"], 2, "{query}"); + let control_entity = query["entities"] + .as_array() + .unwrap() + .iter() + .find(|entity| entity["layer"] == "CURVES") + .unwrap(); + assert_eq!(control_entity["degree"], 2, "{control_entity}"); + assert_eq!(control_entity["control_points"].as_array().unwrap().len(), 3); + assert_eq!(control_entity["weights"], json!([1.0, 2.0, 1.0])); +} + #[test] fn hatch_boundary_is_flattened_in_query() { let mut app = OpenCADStudio::new_for_test();