git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitaac7bcce2588eb7672d5c4ff30fd45bc989734be
parent0d64c4ee19
authorLucas Galante <[email protected]>
date2026-09-01 22:03
feat: curve_set_points MCP action

The automation counterpart of the curve viewer state: replace a curve
node's control points wholesale as structured [x, y, z] triples, so
agents never touch the Points param serialization. Validates the slot
is a curve and the coordinates are finite, then runs the same resync
sequence as SetParam. The tool-sync test learns an array dummy arm for
the new schema type.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/api.rs    | 16 ++++++++++++++++
 src/app.rs    |  5 +++++
 src/main.rs   | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/window.rs | 27 ++++++++++++++++++++++++++
 4 files changed, 109 insertions(+)

diff --git a/src/api.rs b/src/api.rs
index c34e567..91b2091 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -209,6 +209,22 @@ pub(crate) fn mcp_tools() -> Vec<McpTool> {
                 "required": ["frame"],
             }),
         ),
+        tool(
+            "curve_set_points",
+            "Replace a curve node's control points (world-space [x, y, z] triples). The Catmull-Rom strip re-evaluates immediately.",
+            json!({
+                "type": "object",
+                "properties": {
+                    "slot": { "type": "integer", "description": "Node index in the current directory; must be a curve node" },
+                    "points": {
+                        "type": "array",
+                        "items": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 },
+                        "description": "Control points as [x, y, z] triples; replaces the whole list",
+                    },
+                },
+                "required": ["slot", "points"],
+            }),
+        ),
         tool(
             "menu_click",
             "Click a menubar item by indices (widget_idx must be a menubar widget slot).",
diff --git a/src/app.rs b/src/app.rs
index 4e2306c..9263051 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -279,6 +279,11 @@ pub enum McpAction {
     /// Move the playhead. Simnets solve up to this frame, so it is the only way
     /// to drive a simulation without dragging the playbar.
     SetFrame { frame: f32 },
+    /// Replace a curve node's control points wholesale — the automation
+    /// counterpart of the curve viewer state's move/add/delete. Structured
+    /// [x, y, z] triples rather than the "Points" param string, so agents
+    /// never have to know the serialization.
+    CurveSetPoints { slot: usize, points: Vec<[f32; 3]> },
 }
 
 #[derive(Debug, Clone)]
diff --git a/src/main.rs b/src/main.rs
index 0ae1d8a..f1225a3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1314,6 +1314,66 @@ mod tests {
         assert_eq!(sample_catmull_rom(&pts[..1], segs), pts[..1].to_vec());
     }
 
+    /// The curve_set_points MCP action: replaces a curve node's whole point
+    /// list from structured triples, refuses non-curve slots and non-finite
+    /// coordinates, and round-trips through the same param the viewer state
+    /// and params pane edit.
+    #[test]
+    fn test_curve_set_points_mcp_action() {
+        let mut state = State::new(false);
+        let mut redraw = false;
+        state
+            .apply_action(
+                McpAction::AddNode { template_name: "Curve".to_string(), name: None, x: 0.0, y: 0.0 },
+                &mut redraw,
+            )
+            .expect("add curve node");
+        let slot = state.current_dir().children.len() - 1;
+
+        state
+            .apply_action(
+                McpAction::CurveSetPoints {
+                    slot,
+                    points: vec![[0.0, 0.0, 0.0], [1.0, 2.0, 3.0], [-1.5, 0.5, 0.25]],
+                },
+                &mut redraw,
+            )
+            .expect("set curve points");
+        let pts = crate::geometry::parse_curve_points(&crate::geometry::node_param_str(
+            &state.current_dir().children[slot],
+            "Points",
+            "",
+        ));
+        assert_eq!(
+            pts,
+            vec![Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0), Vec3::new(-1.5, 0.5, 0.25)]
+        );
+
+        // Slot 0 is the default project's camera — not a curve.
+        assert!(state
+            .apply_action(
+                McpAction::CurveSetPoints { slot: 0, points: vec![[0.0, 0.0, 0.0]] },
+                &mut redraw,
+            )
+            .is_err());
+        // Non-finite coordinates are refused, and the list stays intact.
+        assert!(state
+            .apply_action(
+                McpAction::CurveSetPoints { slot, points: vec![[f32::NAN, 0.0, 0.0]] },
+                &mut redraw,
+            )
+            .is_err());
+        assert_eq!(
+            crate::geometry::parse_curve_points(&crate::geometry::node_param_str(
+                &state.current_dir().children[slot],
+                "Points",
+                "",
+            ))
+            .len(),
+            3
+        );
+    }
+
     /// The curve viewer state end-to-end, headless: grab-and-drag moves a
     /// control point on its own depth plane, a press on empty space appends
     /// a point there, right-press and Delete remove points — all through
@@ -2924,6 +2984,7 @@ mod tests {
                         Some("number") => serde_json::json!(0.0),
                         Some("string") => serde_json::json!("x"),
                         Some("boolean") => serde_json::json!(false),
+                        Some("array") => serde_json::json!([]),
                         other => panic!("{}.{}: unhandled schema type {:?}", tool.name, key, other),
                     };
                     args.insert(key.clone(), dummy);
diff --git a/src/window.rs b/src/window.rs
index dd5b277..990d48d 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -942,6 +942,33 @@ impl State {
                 needs_redraw = true;
                 Ok(format!("frame={clamped}"))
             }
+            McpAction::CurveSetPoints { slot, points } => {
+                if points.iter().flatten().any(|c| !c.is_finite()) {
+                    return Err("Points must be finite numbers".to_string());
+                }
+                let dir = state.current_dir_mut();
+                let Some(child) = dir.children.get_mut(slot) else {
+                    return Err("Slot index out of bounds".to_string());
+                };
+                if !child.node_type.eq_ignore_ascii_case("curve") {
+                    return Err(format!(
+                        "Node in slot {slot} is '{}', not a curve",
+                        child.node_type
+                    ));
+                }
+                let pts: Vec<glam::Vec3> =
+                    points.iter().map(|p| glam::Vec3::new(p[0], p[1], p[2])).collect();
+                let Some(p) = child.params.iter_mut().find(|p| p.name == "Points") else {
+                    return Err("Curve node has no Points param".to_string());
+                };
+                p.default = crate::geometry::format_curve_points(&pts);
+                // Same resync sequence as SetParam / the viewer state.
+                state.sync_nodes();
+                state.rebuild_scene_geometry();
+                state.sync_parameters_pane();
+                needs_redraw = true;
+                Ok(format!("Curve points set ({})", pts.len()))
+            }
             McpAction::ToggleCircularPane => {
                 state.circular_network_pane = !state.circular_network_pane;
                 let val = state.circular_network_pane;