feat: add boolean shape ops, visual undo tree, and Augmented Canvas branding

- Boolean operations (union, intersection, difference, xor) via i_overlay
  with Path shape rendering using earcutr triangulation
- Visual undo tree with branching history, checkout, and fork (Cmd+H)
  using Arc-based snapshots for structural sharing
- Consistent Augmented Canvas branding across app title, MCP server,
  CLI help text, and error messages
- macOS .app bundle script and Info.plist for Finder integration
- New MCP tool: boolean_op for agent-driven shape composition
- 26 tests passing (5 boolean, 6 history, 15 existing)
This commit is contained in:
David Ibia
2026-02-10 00:01:45 +01:00
parent e8ec44d961
commit 9489c390fa
18 changed files with 1202 additions and 21 deletions

View File

@@ -7,7 +7,7 @@ pub async fn send_request(ws_url: &str, request_json: &str) -> Result<String> {
.await
.map_err(|e| {
anyhow::anyhow!(
"Cannot connect to agcanvas at {}. Is agcanvas running? Error: {}",
"Cannot connect to Augmented Canvas at {}. Is Augmented Canvas running? Error: {}",
ws_url,
e
)

View File

@@ -8,7 +8,7 @@ use tools::AgCanvasServer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Parser)]
#[command(name = "agcanvas-mcp", about = "MCP server bridge for agcanvas")]
#[command(name = "agcanvas-mcp", about = "MCP server bridge for Augmented Canvas")]
struct Cli {
#[arg(long, default_value = "9876")]
port: u16,
@@ -26,7 +26,7 @@ async fn main() -> Result<()> {
let cli = Cli::parse();
let ws_url = format!("ws://127.0.0.1:{}", cli.port);
tracing::info!("Starting agcanvas MCP server, connecting to {}", ws_url);
tracing::info!("Starting Augmented Canvas MCP server, connecting to {}", ws_url);
let server = AgCanvasServer::new(ws_url);
let service = server.serve(rmcp::transport::stdio()).await?;

View File

@@ -169,6 +169,24 @@ pub struct DeleteDrawingElementParam {
pub id: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BooleanOpParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "Boolean operation: union, intersection, difference, or xor")]
pub operation: String,
#[schemars(description = "IDs of drawing elements to combine (minimum 2)")]
pub element_ids: Vec<String>,
#[schemars(description = "If true, delete source elements after combining")]
pub consume: Option<bool>,
#[schemars(description = "Fill color for result as hex e.g. '#ff0000'")]
pub fill: Option<String>,
#[schemars(description = "Stroke color for result as hex e.g. '#ffffff'")]
pub stroke_color: Option<String>,
#[schemars(description = "Stroke width for result in pixels")]
pub stroke_width: Option<f32>,
}
#[derive(Debug, Clone)]
pub struct AgCanvasServer {
ws_url: String,
@@ -185,7 +203,7 @@ impl AgCanvasServer {
}
#[tool(
description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, creator info, description, timestamps, and whether they have SVG or drawing content loaded. Supports sorting."
description = "List all open sessions/tabs in Augmented Canvas. Returns session IDs, names, creator info, description, timestamps, and whether they have SVG or drawing content loaded. Supports sorting."
)]
async fn list_sessions(
&self,
@@ -203,7 +221,7 @@ impl AgCanvasServer {
}
#[tool(
description = "Create a new session/tab in agcanvas. The session is created by an agent. Returns the created session with its ID and metadata."
description = "Create a new session/tab in Augmented Canvas. The session is created by an agent. Returns the created session with its ID and metadata."
)]
async fn create_session(
&self,
@@ -472,6 +490,37 @@ impl AgCanvasServer {
self.call_agcanvas(&request).await
}
#[tool(
description = "Perform a boolean operation (union, intersection, difference, xor) on two or more drawing elements. Combines filled shapes into a new path element. Only works on Rectangle and Ellipse shapes."
)]
async fn boolean_op(
&self,
Parameters(params): Parameters<BooleanOpParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "BooleanOp",
"operation": params.operation,
"element_ids": params.element_ids,
});
let obj = request.as_object_mut().unwrap();
if let Some(v) = params.session_id {
obj.insert("session_id".into(), v.into());
}
if let Some(v) = params.consume {
obj.insert("consume".into(), v.into());
}
if let Some(v) = params.fill {
obj.insert("fill".into(), v.into());
}
if let Some(v) = params.stroke_color {
obj.insert("stroke_color".into(), v.into());
}
if let Some(v) = params.stroke_width {
obj.insert("stroke_width".into(), v.into());
}
self.call_agcanvas(&request).await
}
#[tool(description = "Delete a drawing element by its ID.")]
async fn delete_drawing_element(
&self,
@@ -517,7 +566,7 @@ impl AgCanvasServer {
let msg = parsed
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("Unknown error from agcanvas");
.unwrap_or("Unknown error from Augmented Canvas");
return Ok(CallToolResult::error(vec![Content::text(msg)]));
}
@@ -526,7 +575,7 @@ impl AgCanvasServer {
Ok(CallToolResult::success(vec![Content::text(pretty)]))
}
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
"Failed to communicate with agcanvas: {}. Make sure agcanvas is running.",
"Failed to communicate with Augmented Canvas: {}. Make sure Augmented Canvas is running.",
e
))])),
}
@@ -538,10 +587,10 @@ impl ServerHandler for AgCanvasServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
instructions: Some(
"agcanvas MCP server — connects to agcanvas desktop app to query SVG designs, \
"Augmented Canvas MCP server — connects to the Augmented Canvas desktop app to query SVG designs, \
element trees, and user-drawn shapes. Use describe_canvas to understand the \
current design, get_element_tree for structured data, and generate_code for \
code stubs. Requires agcanvas to be running."
code stubs. Requires Augmented Canvas to be running."
.into(),
),
capabilities: ServerCapabilities::builder().enable_tools().build(),