graphic design tool
git clone https://git.lucas.co/cce-designer.git
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;