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:
@@ -3,7 +3,7 @@ name = "agcanvas-mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "MCP server bridge for agcanvas — exposes canvas tools to Claude Code, OpenCode, and Codex"
|
||||
description = "MCP server bridge for Augmented Canvas — exposes canvas tools to Claude Code, OpenCode, and Codex"
|
||||
|
||||
[[bin]]
|
||||
name = "agcanvas-mcp"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "agcanvas"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Interactive canvas for agent-human collaboration with SVG support"
|
||||
description = "Augmented Canvas — interactive canvas for agent-human collaboration with SVG support"
|
||||
|
||||
[dependencies]
|
||||
# GUI
|
||||
@@ -48,3 +48,5 @@ thiserror = "1.0"
|
||||
|
||||
# Image handling
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
i_overlay = "4.4.0"
|
||||
earcutr = "0.5.0"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::drawing::{DrawingElement, Shape, ShapeStyle};
|
||||
use crate::drawing::{BooleanOpType, DrawingElement, Shape, ShapeStyle};
|
||||
use crate::element_tree::{ElementTree, TreeMetadata};
|
||||
use crate::session::{SessionCreator, SessionInfo, SessionSortField, SortOrder};
|
||||
use egui::{Color32, Pos2, Vec2};
|
||||
@@ -197,6 +197,20 @@ pub enum AgentRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
BooleanOp {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
operation: BooleanOpType,
|
||||
element_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
consume: Option<bool>,
|
||||
#[serde(default)]
|
||||
fill: Option<String>,
|
||||
#[serde(default)]
|
||||
stroke_color: Option<String>,
|
||||
#[serde(default)]
|
||||
stroke_width: Option<f32>,
|
||||
},
|
||||
|
||||
Ping,
|
||||
}
|
||||
@@ -412,8 +426,11 @@ pub fn build_shape(
|
||||
content: text.unwrap_or_else(|| "Text".to_string()),
|
||||
font_size: font_size.unwrap_or(20.0),
|
||||
}),
|
||||
"Path" | "path" => {
|
||||
Err("Path shapes are created via boolean operations, not directly".to_string())
|
||||
}
|
||||
other => Err(format!(
|
||||
"Unknown shape_type '{}'. Expected: Rectangle, Ellipse, Line, Arrow, or Text",
|
||||
"Unknown shape_type '{}'. Expected: Rectangle, Ellipse, Line, Arrow, Text, or Path",
|
||||
other
|
||||
)),
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use super::protocol::{
|
||||
build_shape, build_style, AgentRequest, AgentResponse, CodeGenTarget, DrawingCommand, GuiEvent,
|
||||
build_shape, build_style, parse_hex_color, AgentRequest, AgentResponse, CodeGenTarget,
|
||||
DrawingCommand, GuiEvent,
|
||||
SessionCommand,
|
||||
};
|
||||
use crate::drawing::DrawingElement;
|
||||
use crate::drawing::{boolean, DrawingElement, Shape, ShapeStyle};
|
||||
use crate::session::{SessionCreator, SessionStore};
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
@@ -536,6 +537,109 @@ async fn process_request(
|
||||
});
|
||||
AgentResponse::DrawingElementsCleared { session_id: sid }
|
||||
}
|
||||
|
||||
AgentRequest::BooleanOp {
|
||||
session_id,
|
||||
operation,
|
||||
element_ids,
|
||||
consume,
|
||||
fill,
|
||||
stroke_color,
|
||||
stroke_width,
|
||||
} => {
|
||||
if element_ids.len() < 2 {
|
||||
return AgentResponse::Error {
|
||||
message: "Boolean operation requires at least 2 element IDs".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut store = sessions.write().await;
|
||||
let sid = match store.resolve_session_id(session_id.as_deref()) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return AgentResponse::Error {
|
||||
message: "No session found".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let source_elements = match store.get_drawing_elements(Some(&sid)) {
|
||||
Some((_, elements)) => elements,
|
||||
None => {
|
||||
return AgentResponse::Error {
|
||||
message: "No session found".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut selected_elements = Vec::with_capacity(element_ids.len());
|
||||
for element_id in &element_ids {
|
||||
match source_elements.iter().find(|element| element.id == *element_id) {
|
||||
Some(element) => selected_elements.push(element),
|
||||
None => {
|
||||
return AgentResponse::Error {
|
||||
message: format!("Element '{}' not found in session", element_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let polygons = match boolean::boolean_op(operation, &selected_elements) {
|
||||
Ok(polygons) => polygons,
|
||||
Err(message) => return AgentResponse::Error { message },
|
||||
};
|
||||
|
||||
if polygons.is_empty() {
|
||||
return AgentResponse::Error {
|
||||
message: "Boolean operation produced empty result".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let first_style = selected_elements[0].style.clone();
|
||||
let result_style = ShapeStyle {
|
||||
fill: match fill {
|
||||
Some(hex) => parse_hex_color(&hex),
|
||||
None => first_style.fill,
|
||||
},
|
||||
stroke_color: match stroke_color {
|
||||
Some(hex) => parse_hex_color(&hex).unwrap_or(first_style.stroke_color),
|
||||
None => first_style.stroke_color,
|
||||
},
|
||||
stroke_width: stroke_width.unwrap_or(first_style.stroke_width),
|
||||
};
|
||||
|
||||
let element = DrawingElement::new(Shape::Path { polygons }, result_style);
|
||||
|
||||
if consume == Some(true) {
|
||||
for element_id in &element_ids {
|
||||
if store.delete_drawing_element(&sid, element_id) {
|
||||
let _ = command_tx.send(DrawingCommand::Delete {
|
||||
session_id: sid.clone(),
|
||||
id: element_id.clone(),
|
||||
});
|
||||
let _ = event_tx.send(GuiEvent::DrawingElementDeleted {
|
||||
session_id: sid.clone(),
|
||||
id: element_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
store.add_drawing_element(&sid, element.clone());
|
||||
let _ = command_tx.send(DrawingCommand::Create {
|
||||
session_id: sid.clone(),
|
||||
element: element.clone(),
|
||||
});
|
||||
let _ = event_tx.send(GuiEvent::DrawingElementCreated {
|
||||
session_id: sid.clone(),
|
||||
element: element.clone(),
|
||||
});
|
||||
|
||||
AgentResponse::DrawingElementCreated {
|
||||
session_id: sid,
|
||||
element,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::drawing::{
|
||||
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,
|
||||
screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool,
|
||||
};
|
||||
use crate::history::{ChangeSource, HistoryTree, NodeId};
|
||||
use crate::mermaid::render_mermaid_to_svg;
|
||||
use crate::persistence::{self, SavedSession, SavedWorkspace};
|
||||
use crate::session::{Session, SessionCreator, SessionStore};
|
||||
@@ -29,6 +30,7 @@ pub struct AgCanvasApp {
|
||||
clipboard: Option<ClipboardManager>,
|
||||
show_tree_panel: bool,
|
||||
show_description: bool,
|
||||
show_history_panel: bool,
|
||||
status_message: Option<(String, std::time::Instant)>,
|
||||
_runtime: Runtime,
|
||||
|
||||
@@ -72,6 +74,7 @@ impl AgCanvasApp {
|
||||
clipboard,
|
||||
show_tree_panel: false,
|
||||
show_description: false,
|
||||
show_history_panel: false,
|
||||
status_message: None,
|
||||
_runtime: runtime,
|
||||
show_mermaid_dialog: false,
|
||||
@@ -190,6 +193,7 @@ impl AgCanvasApp {
|
||||
session.svg_renderer = Some(SvgRenderer::new(usvg_tree));
|
||||
session.svg_source = Some(svg_data.to_string());
|
||||
session.svg_texture = None;
|
||||
session.record_edit("Load SVG", ChangeSource::Human);
|
||||
session
|
||||
.canvas_state
|
||||
.fit_to_rect(egui::vec2(width, height), ctx.screen_rect().size() * 0.8);
|
||||
@@ -227,6 +231,7 @@ impl AgCanvasApp {
|
||||
let session = self.active_session_mut();
|
||||
let session_id = session.id.clone();
|
||||
session.clear();
|
||||
session.record_edit("Clear Canvas", ChangeSource::Human);
|
||||
|
||||
let sessions_handle = self.sessions_handle.clone();
|
||||
let event_tx = self.event_tx.clone();
|
||||
@@ -244,7 +249,8 @@ impl AgCanvasApp {
|
||||
fn render_svg_to_texture(&mut self, ctx: &egui::Context) {
|
||||
let session = self.active_session_mut();
|
||||
if let Some(renderer) = &mut session.svg_renderer {
|
||||
let scale = session.canvas_state.zoom.max(1.0);
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let scale = session.canvas_state.zoom.max(1.0) * ppp;
|
||||
if let Ok(pixmap) = renderer.render(scale) {
|
||||
let size = [pixmap.width() as usize, pixmap.height() as usize];
|
||||
let pixels: Vec<Color32> = pixmap
|
||||
@@ -298,6 +304,10 @@ impl AgCanvasApp {
|
||||
} => {
|
||||
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
|
||||
session.drawing_elements.push(element);
|
||||
session.record_edit(
|
||||
"Agent: Create Element",
|
||||
ChangeSource::Agent { name: None },
|
||||
);
|
||||
}
|
||||
}
|
||||
DrawingCommand::Update {
|
||||
@@ -312,6 +322,10 @@ impl AgCanvasApp {
|
||||
{
|
||||
el.shape = element.shape;
|
||||
el.style = element.style;
|
||||
session.record_edit(
|
||||
"Agent: Update Element",
|
||||
ChangeSource::Agent { name: None },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,12 +335,20 @@ impl AgCanvasApp {
|
||||
if session.selected_element_id.as_deref() == Some(&id) {
|
||||
session.selected_element_id = None;
|
||||
}
|
||||
session.record_edit(
|
||||
"Agent: Delete Element",
|
||||
ChangeSource::Agent { name: None },
|
||||
);
|
||||
}
|
||||
}
|
||||
DrawingCommand::Clear { session_id } => {
|
||||
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
|
||||
session.drawing_elements.clear();
|
||||
session.selected_element_id = None;
|
||||
session.record_edit(
|
||||
"Agent: Clear Canvas",
|
||||
ChangeSource::Agent { name: None },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,6 +538,7 @@ impl AgCanvasApp {
|
||||
}
|
||||
CommandId::ToggleTreePanel => self.show_tree_panel = !self.show_tree_panel,
|
||||
CommandId::ToggleDescription => self.show_description = !self.show_description,
|
||||
CommandId::ToggleHistory => self.show_history_panel = !self.show_history_panel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -627,6 +650,13 @@ fn handle_select_tool(
|
||||
}
|
||||
|
||||
if response.drag_stopped() {
|
||||
match &session.drag_state {
|
||||
DragState::Moving { .. } => session.record_edit("Move Element", ChangeSource::Human),
|
||||
DragState::Resizing { .. } => {
|
||||
session.record_edit("Resize Element", ChangeSource::Human)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
session.drag_state = DragState::None;
|
||||
}
|
||||
|
||||
@@ -700,10 +730,18 @@ fn handle_shape_tool(
|
||||
},
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let tool_label = match session.active_tool {
|
||||
Tool::Rectangle => "Rectangle",
|
||||
Tool::Ellipse => "Ellipse",
|
||||
Tool::Line => "Line",
|
||||
Tool::Arrow => "Arrow",
|
||||
_ => "Shape",
|
||||
};
|
||||
|
||||
let element = DrawingElement::new(shape, ShapeStyle::default());
|
||||
session.selected_element_id = Some(element.id.clone());
|
||||
session.drawing_elements.push(element);
|
||||
session.record_edit(&format!("Draw {}", tool_label), ChangeSource::Human);
|
||||
}
|
||||
}
|
||||
session.drag_state = DragState::None;
|
||||
@@ -721,6 +759,7 @@ impl eframe::App for AgCanvasApp {
|
||||
let mut save_workspace = false;
|
||||
let mut toggle_palette = false;
|
||||
let mut delete_selected = false;
|
||||
let mut toggle_history = false;
|
||||
let mut tool_switch: Option<Tool> = None;
|
||||
|
||||
let palette_open = self.command_palette.visible;
|
||||
@@ -742,6 +781,9 @@ impl eframe::App for AgCanvasApp {
|
||||
if i.modifiers.command && i.key_pressed(egui::Key::W) {
|
||||
close_tab = true;
|
||||
}
|
||||
if i.modifiers.command && i.key_pressed(egui::Key::H) {
|
||||
toggle_history = true;
|
||||
}
|
||||
if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) {
|
||||
delete_selected = true;
|
||||
}
|
||||
@@ -785,8 +827,13 @@ impl eframe::App for AgCanvasApp {
|
||||
let idx = self.active_session_idx;
|
||||
self.close_session(idx);
|
||||
}
|
||||
if toggle_history {
|
||||
self.show_history_panel = !self.show_history_panel;
|
||||
}
|
||||
if delete_selected && !self.show_text_input && !self.show_mermaid_dialog {
|
||||
self.active_session_mut().delete_selected();
|
||||
self.active_session_mut()
|
||||
.record_edit("Delete Element", ChangeSource::Human);
|
||||
}
|
||||
if let Some(tool) = tool_switch {
|
||||
if !self.show_text_input && !self.show_mermaid_dialog {
|
||||
@@ -840,6 +887,9 @@ impl eframe::App for AgCanvasApp {
|
||||
{
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.checkbox(&mut self.show_history_panel, "History").clicked() {
|
||||
ui.close_menu();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Reset Zoom (Cmd+0)").clicked() {
|
||||
self.active_session_mut().canvas_state.reset();
|
||||
@@ -972,6 +1022,48 @@ impl eframe::App for AgCanvasApp {
|
||||
}
|
||||
}
|
||||
|
||||
let mut checkout_node_id: Option<NodeId> = None;
|
||||
|
||||
if self.show_history_panel {
|
||||
egui::SidePanel::right("history_panel")
|
||||
.default_width(220.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("History");
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.small_button("×").clicked() {
|
||||
self.show_history_panel = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
let session = &self.sessions[self.active_session_idx];
|
||||
let history = &session.history;
|
||||
let active_path = history.path_to_root(history.current);
|
||||
let current_node = history.node(history.current);
|
||||
let root_suffix = if history.current == history.root {
|
||||
" (root)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
ui.label(format!(
|
||||
"{} entries - current: {}{}",
|
||||
history.node_count(),
|
||||
current_node.label,
|
||||
root_suffix
|
||||
));
|
||||
ui.separator();
|
||||
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
if let Some(node_id) = render_history_tree(ui, history, &active_path) {
|
||||
checkout_node_id = Some(node_id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if self.show_tree_panel {
|
||||
egui::SidePanel::right("tree_panel")
|
||||
.default_width(300.0)
|
||||
@@ -988,6 +1080,28 @@ impl eframe::App for AgCanvasApp {
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(node_id) = checkout_node_id {
|
||||
let idx = self.active_session_idx;
|
||||
let previous_svg = self.sessions[idx]
|
||||
.history
|
||||
.current_snapshot()
|
||||
.svg_source
|
||||
.as_deref()
|
||||
.map(str::to_string);
|
||||
self.sessions[idx].checkout_history(node_id);
|
||||
if self.sessions[idx].svg_source != previous_svg {
|
||||
if let Some(svg_data) = self.sessions[idx].svg_source.clone() {
|
||||
if let Ok((tree, usvg_tree)) = parse_svg(&svg_data) {
|
||||
let session = &mut self.sessions[idx];
|
||||
session.description_text = tree.to_semantic_description();
|
||||
session.svg_renderer = Some(SvgRenderer::new(usvg_tree));
|
||||
session.element_tree = Some(tree);
|
||||
session.svg_texture = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.show_description {
|
||||
let desc = self.active_session().description_text.clone();
|
||||
egui::SidePanel::left("description_panel")
|
||||
@@ -1098,6 +1212,8 @@ impl eframe::App for AgCanvasApp {
|
||||
);
|
||||
let eid = element.id.clone();
|
||||
self.active_session_mut().drawing_elements.push(element);
|
||||
self.active_session_mut()
|
||||
.record_edit("Draw Text", ChangeSource::Human);
|
||||
self.active_session_mut().selected_element_id = Some(eid);
|
||||
}
|
||||
self.show_text_input = false;
|
||||
@@ -1162,8 +1278,10 @@ impl eframe::App for AgCanvasApp {
|
||||
|
||||
if let Some(texture) = &self.active_session().svg_texture {
|
||||
let canvas_state = &self.active_session().canvas_state;
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let center = response.rect.center();
|
||||
let size = texture.size_vec2() / canvas_state.zoom.max(1.0) * canvas_state.zoom;
|
||||
let size =
|
||||
texture.size_vec2() / (canvas_state.zoom.max(1.0) * ppp) * canvas_state.zoom;
|
||||
let offset = canvas_state.offset * canvas_state.zoom;
|
||||
let rect = egui::Rect::from_center_size(center + offset, size);
|
||||
painter.image(
|
||||
@@ -1295,6 +1413,135 @@ fn draw_grid(painter: &egui::Painter, rect: &egui::Rect, state: &CanvasState) {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_history_tree(
|
||||
ui: &mut egui::Ui,
|
||||
history: &HistoryTree,
|
||||
active_path: &[NodeId],
|
||||
) -> Option<NodeId> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
let active_nodes: HashSet<NodeId> = active_path.iter().copied().collect();
|
||||
let lanes = history_lanes(history);
|
||||
let mut checkout: Option<NodeId> = None;
|
||||
let mut node_centers: HashMap<NodeId, egui::Pos2> = HashMap::new();
|
||||
|
||||
let row_height = 26.0;
|
||||
let lane_spacing = 18.0;
|
||||
let dot_radius = 6.0;
|
||||
let line_color = Color32::from_rgb(64, 64, 64);
|
||||
let current_fill = Color32::WHITE;
|
||||
let active_fill = Color32::from_gray(180);
|
||||
let inactive_stroke = Color32::from_gray(80);
|
||||
|
||||
for node in history.nodes.iter().rev() {
|
||||
let node_id = node.id;
|
||||
let lane = lanes[node_id.0] as f32;
|
||||
let (rect, response) = ui.allocate_exact_size(
|
||||
egui::vec2(ui.available_width(), row_height),
|
||||
egui::Sense::click(),
|
||||
);
|
||||
|
||||
if response.clicked() {
|
||||
checkout = Some(node_id);
|
||||
}
|
||||
|
||||
let center = egui::pos2(rect.left() + 10.0 + lane * lane_spacing, rect.center().y);
|
||||
node_centers.insert(node_id, center);
|
||||
|
||||
let is_current = node_id == history.current;
|
||||
let is_active = active_nodes.contains(&node_id);
|
||||
|
||||
if is_current {
|
||||
ui.painter().circle_filled(center, dot_radius, current_fill);
|
||||
} else if is_active {
|
||||
ui.painter().circle_filled(center, dot_radius, active_fill);
|
||||
} else {
|
||||
ui.painter().circle_stroke(
|
||||
center,
|
||||
dot_radius,
|
||||
egui::Stroke::new(1.5, inactive_stroke),
|
||||
);
|
||||
}
|
||||
|
||||
let source_prefix = match node.source {
|
||||
ChangeSource::Human => "",
|
||||
ChangeSource::Agent { .. } => "🤖 ",
|
||||
};
|
||||
let label = format!(
|
||||
"{}{} {}",
|
||||
source_prefix,
|
||||
node.label,
|
||||
relative_time_label(node.timestamp)
|
||||
);
|
||||
|
||||
let label_color = if is_current {
|
||||
Color32::WHITE
|
||||
} else if matches!(node.source, ChangeSource::Agent { .. }) {
|
||||
Color32::from_gray(185)
|
||||
} else {
|
||||
Color32::from_gray(160)
|
||||
};
|
||||
|
||||
ui.painter().text(
|
||||
egui::pos2(center.x + 12.0, rect.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
label,
|
||||
egui::FontId::proportional(13.0),
|
||||
label_color,
|
||||
);
|
||||
}
|
||||
|
||||
for node in &history.nodes {
|
||||
if let Some(parent_id) = node.parent {
|
||||
if let (Some(parent_center), Some(child_center)) =
|
||||
(node_centers.get(&parent_id), node_centers.get(&node.id))
|
||||
{
|
||||
ui.painter().line_segment(
|
||||
[*parent_center, egui::pos2(child_center.x, parent_center.y)],
|
||||
egui::Stroke::new(1.0, line_color),
|
||||
);
|
||||
ui.painter().line_segment(
|
||||
[egui::pos2(child_center.x, parent_center.y), *child_center],
|
||||
egui::Stroke::new(1.0, line_color),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkout
|
||||
}
|
||||
|
||||
fn history_lanes(history: &HistoryTree) -> Vec<usize> {
|
||||
let mut lanes = vec![0; history.nodes.len()];
|
||||
|
||||
for node in &history.nodes {
|
||||
if let Some(parent_id) = node.parent {
|
||||
let parent = &history.nodes[parent_id.0];
|
||||
let sibling_idx = parent
|
||||
.children
|
||||
.iter()
|
||||
.position(|child_id| *child_id == node.id)
|
||||
.unwrap_or(0);
|
||||
lanes[node.id.0] = lanes[parent_id.0] + sibling_idx;
|
||||
}
|
||||
}
|
||||
|
||||
lanes
|
||||
}
|
||||
|
||||
fn relative_time_label(timestamp: std::time::Instant) -> String {
|
||||
let secs = timestamp.elapsed().as_secs();
|
||||
if secs < 60 {
|
||||
format!("{}s ago", secs)
|
||||
} else if secs < 60 * 60 {
|
||||
format!("{}m ago", secs / 60)
|
||||
} else if secs < 60 * 60 * 24 {
|
||||
format!("{}h ago", secs / (60 * 60))
|
||||
} else {
|
||||
format!("{}d ago", secs / (60 * 60 * 24))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tree_ui(ui: &mut egui::Ui, element: &crate::element_tree::Element, depth: usize) {
|
||||
let kind_name = match &element.kind {
|
||||
crate::element_tree::ElementKind::Group { name } => {
|
||||
|
||||
@@ -18,6 +18,7 @@ pub enum CommandId {
|
||||
FitToView,
|
||||
ToggleTreePanel,
|
||||
ToggleDescription,
|
||||
ToggleHistory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -82,6 +83,12 @@ pub fn all_commands() -> Vec<PaletteCommand> {
|
||||
None,
|
||||
"View",
|
||||
),
|
||||
PaletteCommand::new(
|
||||
CommandId::ToggleHistory,
|
||||
"Toggle History Panel",
|
||||
Some("Cmd+H"),
|
||||
"View",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
179
crates/agcanvas/src/drawing/boolean.rs
Normal file
179
crates/agcanvas/src/drawing/boolean.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
use super::element::{DrawingElement, PathPolygon, Shape};
|
||||
use egui::Pos2;
|
||||
use i_overlay::core::fill_rule::FillRule;
|
||||
use i_overlay::core::overlay_rule::OverlayRule;
|
||||
use i_overlay::float::overlay::FloatOverlay;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ELLIPSE_SEGMENTS: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BooleanOpType {
|
||||
Union,
|
||||
Intersection,
|
||||
Difference,
|
||||
Xor,
|
||||
}
|
||||
|
||||
pub fn shape_to_contour(shape: &Shape) -> Result<Vec<[f64; 2]>, String> {
|
||||
match shape {
|
||||
Shape::Rectangle { pos, size } => {
|
||||
let x0 = pos.x as f64;
|
||||
let y0 = pos.y as f64;
|
||||
let x1 = (pos.x + size.x) as f64;
|
||||
let y1 = (pos.y + size.y) as f64;
|
||||
Ok(vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]])
|
||||
}
|
||||
Shape::Ellipse { center, radii } => Ok((0..ELLIPSE_SEGMENTS)
|
||||
.map(|i| {
|
||||
let angle = (i as f64 / ELLIPSE_SEGMENTS as f64) * std::f64::consts::TAU;
|
||||
[
|
||||
center.x as f64 + radii.x as f64 * angle.cos(),
|
||||
center.y as f64 + radii.y as f64 * angle.sin(),
|
||||
]
|
||||
})
|
||||
.collect()),
|
||||
Shape::Path { .. } => Err("Path shape cannot be used as boolean input yet".to_string()),
|
||||
Shape::Line { .. } | Shape::Arrow { .. } | Shape::Text { .. } => {
|
||||
Err("Boolean operations only support Rectangle and Ellipse".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn boolean_op(
|
||||
op: BooleanOpType,
|
||||
elements: &[&DrawingElement],
|
||||
) -> Result<Vec<PathPolygon>, String> {
|
||||
if elements.len() < 2 {
|
||||
return Err("Boolean operation requires at least 2 elements".to_string());
|
||||
}
|
||||
|
||||
let contours: Vec<Vec<[f64; 2]>> = elements
|
||||
.iter()
|
||||
.map(|element| shape_to_contour(&element.shape))
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
let mut current_shapes = vec![vec![contours[0].clone()]];
|
||||
let rule = overlay_rule(op);
|
||||
|
||||
for contour in contours.iter().skip(1) {
|
||||
let clip_shapes = vec![vec![contour.clone()]];
|
||||
let mut overlay = FloatOverlay::with_subj_and_clip(¤t_shapes, &clip_shapes);
|
||||
current_shapes = overlay.overlay(rule, FillRule::EvenOdd);
|
||||
}
|
||||
|
||||
Ok(to_path_polygons(current_shapes))
|
||||
}
|
||||
|
||||
fn overlay_rule(op: BooleanOpType) -> OverlayRule {
|
||||
match op {
|
||||
BooleanOpType::Union => OverlayRule::Union,
|
||||
BooleanOpType::Intersection => OverlayRule::Intersect,
|
||||
BooleanOpType::Difference => OverlayRule::Difference,
|
||||
BooleanOpType::Xor => OverlayRule::Xor,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_path_polygons(shapes: Vec<Vec<Vec<[f64; 2]>>>) -> Vec<PathPolygon> {
|
||||
shapes
|
||||
.into_iter()
|
||||
.filter_map(|shape| {
|
||||
let mut contours = shape.into_iter();
|
||||
let exterior = contours.next()?;
|
||||
if exterior.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let holes = contours
|
||||
.filter(|contour| contour.len() >= 3)
|
||||
.map(to_pos2_ring)
|
||||
.collect();
|
||||
|
||||
Some(PathPolygon {
|
||||
exterior: to_pos2_ring(exterior),
|
||||
holes,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn to_pos2_ring(ring: Vec<[f64; 2]>) -> Vec<Pos2> {
|
||||
ring.into_iter()
|
||||
.map(|point| Pos2::new(point[0] as f32, point[1] as f32))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::drawing::ShapeStyle;
|
||||
use egui::vec2;
|
||||
|
||||
fn rect(x: f32, y: f32, w: f32, h: f32) -> DrawingElement {
|
||||
DrawingElement::new(
|
||||
Shape::Rectangle {
|
||||
pos: Pos2::new(x, y),
|
||||
size: vec2(w, h),
|
||||
},
|
||||
ShapeStyle::default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn ellipse(cx: f32, cy: f32, rx: f32, ry: f32) -> DrawingElement {
|
||||
DrawingElement::new(
|
||||
Shape::Ellipse {
|
||||
center: Pos2::new(cx, cy),
|
||||
radii: vec2(rx, ry),
|
||||
},
|
||||
ShapeStyle::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rect_union() {
|
||||
let a = rect(0.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(50.0, 0.0, 100.0, 100.0);
|
||||
let result = boolean_op(BooleanOpType::Union, &[&a, &b]).unwrap();
|
||||
assert!(!result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rect_intersection() {
|
||||
let a = rect(0.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(50.0, 0.0, 100.0, 100.0);
|
||||
let result = boolean_op(BooleanOpType::Intersection, &[&a, &b]).unwrap();
|
||||
assert!(!result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disjoint_intersection() {
|
||||
let a = rect(0.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(200.0, 0.0, 100.0, 100.0);
|
||||
let result = boolean_op(BooleanOpType::Intersection, &[&a, &b]);
|
||||
assert!(result.is_err() || result.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ellipse_union() {
|
||||
let a = ellipse(80.0, 80.0, 60.0, 40.0);
|
||||
let b = ellipse(120.0, 80.0, 60.0, 40.0);
|
||||
let result = boolean_op(BooleanOpType::Union, &[&a, &b]).unwrap();
|
||||
assert!(!result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_rejected() {
|
||||
let a = DrawingElement::new(
|
||||
Shape::Line {
|
||||
start: Pos2::new(0.0, 0.0),
|
||||
end: Pos2::new(100.0, 0.0),
|
||||
},
|
||||
ShapeStyle::default(),
|
||||
);
|
||||
let b = rect(0.0, 0.0, 100.0, 100.0);
|
||||
|
||||
let result = boolean_op(BooleanOpType::Union, &[&a, &b]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,12 @@ pub struct DrawingElement {
|
||||
pub style: ShapeStyle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PathPolygon {
|
||||
pub exterior: Vec<Pos2>,
|
||||
pub holes: Vec<Vec<Pos2>>,
|
||||
}
|
||||
|
||||
impl DrawingElement {
|
||||
pub fn new(shape: Shape, style: ShapeStyle) -> Self {
|
||||
Self {
|
||||
@@ -44,6 +50,28 @@ impl DrawingElement {
|
||||
let approx_height = *font_size * 1.4;
|
||||
egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height))
|
||||
}
|
||||
Shape::Path { polygons } => {
|
||||
let mut min_x = f32::INFINITY;
|
||||
let mut min_y = f32::INFINITY;
|
||||
let mut max_x = f32::NEG_INFINITY;
|
||||
let mut max_y = f32::NEG_INFINITY;
|
||||
|
||||
for polygon in polygons {
|
||||
for point in &polygon.exterior {
|
||||
min_x = min_x.min(point.x);
|
||||
min_y = min_y.min(point.y);
|
||||
max_x = max_x.max(point.x);
|
||||
max_y = max_y.max(point.y);
|
||||
}
|
||||
}
|
||||
|
||||
if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()
|
||||
{
|
||||
egui::Rect::from_min_max(Pos2::new(min_x, min_y), Pos2::new(max_x, max_y))
|
||||
} else {
|
||||
egui::Rect::from_min_size(Pos2::ZERO, egui::Vec2::ZERO)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +104,13 @@ impl DrawingElement {
|
||||
let rect = egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height));
|
||||
rect.expand(tolerance).contains(point)
|
||||
}
|
||||
Shape::Path { polygons } => polygons.iter().any(|polygon| {
|
||||
point_in_polygon(point, &polygon.exterior)
|
||||
&& !polygon
|
||||
.holes
|
||||
.iter()
|
||||
.any(|hole| point_in_polygon(point, hole))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,11 +124,24 @@ impl DrawingElement {
|
||||
*end += delta;
|
||||
}
|
||||
Shape::Text { pos, .. } => *pos += delta,
|
||||
Shape::Path { polygons } => {
|
||||
for polygon in polygons {
|
||||
for point in &mut polygon.exterior {
|
||||
*point += delta;
|
||||
}
|
||||
for hole in &mut polygon.holes {
|
||||
for point in hole {
|
||||
*point += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize to fit a new bounding rect, preserving shape semantics.
|
||||
pub fn resize_to(&mut self, new_rect: egui::Rect) {
|
||||
let old_rect = self.bounding_rect();
|
||||
match &mut self.shape {
|
||||
Shape::Rectangle { pos, size } => {
|
||||
*pos = new_rect.min;
|
||||
@@ -110,6 +158,21 @@ impl DrawingElement {
|
||||
Shape::Text { pos, .. } => {
|
||||
*pos = new_rect.min;
|
||||
}
|
||||
Shape::Path { polygons } => {
|
||||
let old_w = old_rect.width();
|
||||
let old_h = old_rect.height();
|
||||
|
||||
for polygon in polygons {
|
||||
for point in &mut polygon.exterior {
|
||||
*point = map_point_to_rect(*point, old_rect, new_rect, old_w, old_h);
|
||||
}
|
||||
for hole in &mut polygon.holes {
|
||||
for point in hole {
|
||||
*point = map_point_to_rect(*point, old_rect, new_rect, old_w, old_h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,6 +201,9 @@ pub enum Shape {
|
||||
content: String,
|
||||
font_size: f32,
|
||||
},
|
||||
Path {
|
||||
polygons: Vec<PathPolygon>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -173,6 +239,63 @@ fn point_to_segment_distance(p: Pos2, a: Pos2, b: Pos2) -> f32 {
|
||||
(p - closest).length()
|
||||
}
|
||||
|
||||
fn point_in_polygon(point: Pos2, ring: &[Pos2]) -> bool {
|
||||
if ring.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut winding_number = 0;
|
||||
|
||||
for (a, b) in ring
|
||||
.iter()
|
||||
.zip(ring.iter().cycle().skip(1))
|
||||
.take(ring.len())
|
||||
.map(|(a, b)| (*a, *b))
|
||||
{
|
||||
if point_to_segment_distance(point, a, b) <= 1e-3 {
|
||||
return true;
|
||||
}
|
||||
|
||||
if a.y <= point.y {
|
||||
if b.y > point.y && cross(a, b, point) > 0.0 {
|
||||
winding_number += 1;
|
||||
}
|
||||
} else if b.y <= point.y && cross(a, b, point) < 0.0 {
|
||||
winding_number -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
winding_number != 0
|
||||
}
|
||||
|
||||
fn cross(a: Pos2, b: Pos2, p: Pos2) -> f32 {
|
||||
(b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)
|
||||
}
|
||||
|
||||
fn map_point_to_rect(
|
||||
point: Pos2,
|
||||
old_rect: egui::Rect,
|
||||
new_rect: egui::Rect,
|
||||
old_w: f32,
|
||||
old_h: f32,
|
||||
) -> Pos2 {
|
||||
let rel_x = if old_w.abs() <= f32::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(point.x - old_rect.min.x) / old_w
|
||||
};
|
||||
let rel_y = if old_h.abs() <= f32::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(point.y - old_rect.min.y) / old_h
|
||||
};
|
||||
|
||||
Pos2::new(
|
||||
new_rect.min.x + rel_x * new_rect.width(),
|
||||
new_rect.min.y + rel_y * new_rect.height(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod boolean;
|
||||
mod element;
|
||||
mod render;
|
||||
mod tool;
|
||||
|
||||
pub use boolean::BooleanOpType;
|
||||
pub use element::{DrawingElement, Shape, ShapeStyle};
|
||||
pub use render::{
|
||||
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use super::element::DrawingElement;
|
||||
use super::element::Shape;
|
||||
use super::element::{DrawingElement, PathPolygon, Shape};
|
||||
use super::tool::{DragState, ResizeHandle, Tool};
|
||||
use egui::{Color32, Painter, Pos2, Stroke, Vec2};
|
||||
|
||||
@@ -86,6 +85,27 @@ pub fn draw_element(
|
||||
style.stroke_color,
|
||||
);
|
||||
}
|
||||
Shape::Path { polygons } => {
|
||||
if let Some(fill) = style.fill {
|
||||
for polygon in polygons {
|
||||
draw_path_fill(painter, polygon, canvas_center, offset, zoom, fill);
|
||||
}
|
||||
}
|
||||
|
||||
for polygon in polygons {
|
||||
draw_closed_ring(
|
||||
painter,
|
||||
&polygon.exterior,
|
||||
canvas_center,
|
||||
offset,
|
||||
zoom,
|
||||
stroke,
|
||||
);
|
||||
for hole in &polygon.holes {
|
||||
draw_closed_ring(painter, hole, canvas_center, offset, zoom, stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +134,100 @@ pub fn draw_selection(
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_path_fill(
|
||||
painter: &Painter,
|
||||
polygon: &PathPolygon,
|
||||
canvas_center: Pos2,
|
||||
offset: Vec2,
|
||||
zoom: f32,
|
||||
fill: Color32,
|
||||
) {
|
||||
if polygon.exterior.len() < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut vertices = Vec::new();
|
||||
let mut coords = Vec::new();
|
||||
let mut hole_indices = Vec::new();
|
||||
|
||||
for point in &polygon.exterior {
|
||||
let screen = canvas_to_screen(*point, canvas_center, offset, zoom);
|
||||
vertices.push(screen);
|
||||
coords.push(screen.x as f64);
|
||||
coords.push(screen.y as f64);
|
||||
}
|
||||
|
||||
let mut vertex_count = polygon.exterior.len();
|
||||
for hole in &polygon.holes {
|
||||
if hole.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
hole_indices.push(vertex_count);
|
||||
for point in hole {
|
||||
let screen = canvas_to_screen(*point, canvas_center, offset, zoom);
|
||||
vertices.push(screen);
|
||||
coords.push(screen.x as f64);
|
||||
coords.push(screen.y as f64);
|
||||
}
|
||||
vertex_count += hole.len();
|
||||
}
|
||||
|
||||
let triangles = match earcutr::earcut(&coords, &hole_indices, 2) {
|
||||
Ok(indices) => indices,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut mesh = egui::epaint::Mesh::default();
|
||||
mesh.reserve_vertices(vertices.len());
|
||||
mesh.reserve_triangles(triangles.len() / 3);
|
||||
|
||||
for vertex in &vertices {
|
||||
mesh.colored_vertex(*vertex, fill);
|
||||
}
|
||||
|
||||
for triangle in triangles.chunks_exact(3) {
|
||||
let a = match u32::try_from(triangle[0]) {
|
||||
Ok(i) => i,
|
||||
Err(_) => return,
|
||||
};
|
||||
let b = match u32::try_from(triangle[1]) {
|
||||
Ok(i) => i,
|
||||
Err(_) => return,
|
||||
};
|
||||
let c = match u32::try_from(triangle[2]) {
|
||||
Ok(i) => i,
|
||||
Err(_) => return,
|
||||
};
|
||||
mesh.add_triangle(a, b, c);
|
||||
}
|
||||
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
fn draw_closed_ring(
|
||||
painter: &Painter,
|
||||
ring: &[Pos2],
|
||||
canvas_center: Pos2,
|
||||
offset: Vec2,
|
||||
zoom: f32,
|
||||
stroke: Stroke,
|
||||
) {
|
||||
if ring.len() < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut points: Vec<Pos2> = ring
|
||||
.iter()
|
||||
.map(|point| canvas_to_screen(*point, canvas_center, offset, zoom))
|
||||
.collect();
|
||||
|
||||
if let Some(first) = points.first().copied() {
|
||||
points.push(first);
|
||||
}
|
||||
|
||||
painter.add(egui::Shape::line(points, stroke));
|
||||
}
|
||||
|
||||
pub fn draw_creation_preview(
|
||||
painter: &Painter,
|
||||
tool: Tool,
|
||||
|
||||
235
crates/agcanvas/src/history.rs
Normal file
235
crates/agcanvas/src/history.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::drawing::DrawingElement;
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
|
||||
pub struct NodeId(pub usize);
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum ChangeSource {
|
||||
Human,
|
||||
Agent { name: Option<String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DocumentSnapshot {
|
||||
pub drawing_elements: Arc<Vec<DrawingElement>>,
|
||||
pub svg_source: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
impl DocumentSnapshot {
|
||||
pub fn new_empty() -> Self {
|
||||
Self {
|
||||
drawing_elements: Arc::new(Vec::new()),
|
||||
svg_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_state(elements: &[DrawingElement], svg: Option<&str>) -> Self {
|
||||
Self {
|
||||
drawing_elements: Arc::new(elements.to_vec()),
|
||||
svg_source: svg.map(Arc::<str>::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HistoryNode {
|
||||
pub id: NodeId,
|
||||
pub parent: Option<NodeId>,
|
||||
pub children: Vec<NodeId>,
|
||||
pub label: String,
|
||||
pub source: ChangeSource,
|
||||
pub timestamp: Instant,
|
||||
pub snapshot: DocumentSnapshot,
|
||||
}
|
||||
|
||||
pub struct HistoryTree {
|
||||
pub nodes: Vec<HistoryNode>,
|
||||
pub root: NodeId,
|
||||
pub current: NodeId,
|
||||
}
|
||||
|
||||
impl HistoryTree {
|
||||
pub fn new(initial_snapshot: DocumentSnapshot) -> Self {
|
||||
let root = NodeId(0);
|
||||
let root_node = HistoryNode {
|
||||
id: root,
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
label: "Initial State".to_string(),
|
||||
source: ChangeSource::Human,
|
||||
timestamp: Instant::now(),
|
||||
snapshot: initial_snapshot,
|
||||
};
|
||||
|
||||
Self {
|
||||
nodes: vec![root_node],
|
||||
root,
|
||||
current: root,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(
|
||||
&mut self,
|
||||
label: String,
|
||||
source: ChangeSource,
|
||||
snapshot: DocumentSnapshot,
|
||||
) -> NodeId {
|
||||
let parent = self.current;
|
||||
let id = NodeId(self.nodes.len());
|
||||
let node = HistoryNode {
|
||||
id,
|
||||
parent: Some(parent),
|
||||
children: Vec::new(),
|
||||
label,
|
||||
source,
|
||||
timestamp: Instant::now(),
|
||||
snapshot,
|
||||
};
|
||||
|
||||
self.nodes[parent.0].children.push(id);
|
||||
self.nodes.push(node);
|
||||
self.current = id;
|
||||
id
|
||||
}
|
||||
|
||||
pub fn checkout(&mut self, id: NodeId) -> &DocumentSnapshot {
|
||||
assert!(id.0 < self.nodes.len(), "invalid history node id");
|
||||
self.current = id;
|
||||
&self.nodes[id.0].snapshot
|
||||
}
|
||||
|
||||
pub fn current_snapshot(&self) -> &DocumentSnapshot {
|
||||
&self.nodes[self.current.0].snapshot
|
||||
}
|
||||
|
||||
pub fn node(&self, id: NodeId) -> &HistoryNode {
|
||||
&self.nodes[id.0]
|
||||
}
|
||||
|
||||
pub fn node_count(&self) -> usize {
|
||||
self.nodes.len()
|
||||
}
|
||||
|
||||
pub fn path_to_root(&self, id: NodeId) -> Vec<NodeId> {
|
||||
let mut path = Vec::new();
|
||||
let mut cursor = Some(id);
|
||||
while let Some(node_id) = cursor {
|
||||
path.push(node_id);
|
||||
cursor = self.nodes[node_id.0].parent;
|
||||
}
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::Pos2;
|
||||
|
||||
use super::*;
|
||||
use crate::drawing::{DrawingElement, Shape, ShapeStyle};
|
||||
|
||||
#[test]
|
||||
fn test_new_tree_has_one_node() {
|
||||
let tree = HistoryTree::new(DocumentSnapshot::new_empty());
|
||||
assert_eq!(tree.node_count(), 1);
|
||||
assert_eq!(tree.root, NodeId(0));
|
||||
assert_eq!(tree.current, NodeId(0));
|
||||
assert_eq!(tree.node(NodeId(0)).label, "Initial State");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_creates_child() {
|
||||
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
|
||||
let id = tree.push(
|
||||
"Draw Rectangle".to_string(),
|
||||
ChangeSource::Human,
|
||||
DocumentSnapshot::new_empty(),
|
||||
);
|
||||
|
||||
assert_eq!(id, NodeId(1));
|
||||
assert_eq!(tree.current, NodeId(1));
|
||||
assert_eq!(tree.node(NodeId(0)).children, vec![NodeId(1)]);
|
||||
assert_eq!(tree.node(NodeId(1)).parent, Some(NodeId(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkout_changes_current() {
|
||||
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
|
||||
let first = tree.push(
|
||||
"First".to_string(),
|
||||
ChangeSource::Human,
|
||||
DocumentSnapshot::new_empty(),
|
||||
);
|
||||
let second = tree.push(
|
||||
"Second".to_string(),
|
||||
ChangeSource::Human,
|
||||
DocumentSnapshot::new_empty(),
|
||||
);
|
||||
|
||||
assert_eq!(tree.current, second);
|
||||
let _ = tree.checkout(first);
|
||||
assert_eq!(tree.current, first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fork_creates_branch() {
|
||||
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
|
||||
let parent = tree.push(
|
||||
"Parent".to_string(),
|
||||
ChangeSource::Human,
|
||||
DocumentSnapshot::new_empty(),
|
||||
);
|
||||
let _child_a = tree.push(
|
||||
"Child A".to_string(),
|
||||
ChangeSource::Human,
|
||||
DocumentSnapshot::new_empty(),
|
||||
);
|
||||
|
||||
let _ = tree.checkout(parent);
|
||||
let child_b = tree.push(
|
||||
"Child B".to_string(),
|
||||
ChangeSource::Human,
|
||||
DocumentSnapshot::new_empty(),
|
||||
);
|
||||
|
||||
assert_eq!(tree.node(parent).children.len(), 2);
|
||||
assert_eq!(tree.node(parent).children[1], child_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_to_root() {
|
||||
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
|
||||
let n1 = tree.push(
|
||||
"n1".to_string(),
|
||||
ChangeSource::Human,
|
||||
DocumentSnapshot::new_empty(),
|
||||
);
|
||||
let n2 = tree.push(
|
||||
"n2".to_string(),
|
||||
ChangeSource::Human,
|
||||
DocumentSnapshot::new_empty(),
|
||||
);
|
||||
|
||||
assert_eq!(tree.path_to_root(n2), vec![n2, n1, tree.root]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_preserves_elements() {
|
||||
let element = DrawingElement::new(
|
||||
Shape::Rectangle {
|
||||
pos: Pos2::new(10.0, 20.0),
|
||||
size: egui::vec2(50.0, 30.0),
|
||||
},
|
||||
ShapeStyle::default(),
|
||||
);
|
||||
|
||||
let snapshot = DocumentSnapshot::from_state(&[element], Some("<svg></svg>"));
|
||||
assert_eq!(snapshot.drawing_elements.len(), 1);
|
||||
assert_eq!(snapshot.svg_source.as_deref(), Some("<svg></svg>"));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod clipboard;
|
||||
mod command_palette;
|
||||
mod drawing;
|
||||
mod element_tree;
|
||||
mod history;
|
||||
mod mermaid;
|
||||
mod persistence;
|
||||
mod session;
|
||||
@@ -25,12 +26,12 @@ fn main() -> Result<()> {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([1400.0, 900.0])
|
||||
.with_min_inner_size([800.0, 600.0])
|
||||
.with_title("agcanvas"),
|
||||
.with_title("Augmented Canvas"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"agcanvas",
|
||||
"Augmented Canvas",
|
||||
native_options,
|
||||
Box::new(|cc| Ok(Box::new(app::AgCanvasApp::new(cc)))),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::canvas::CanvasState;
|
||||
use crate::drawing::{DragState, DrawingElement, Tool};
|
||||
use crate::element_tree::ElementTree;
|
||||
use crate::history::{ChangeSource, DocumentSnapshot, HistoryTree, NodeId};
|
||||
use crate::svg::SvgRenderer;
|
||||
use egui::TextureHandle;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -90,6 +91,7 @@ pub struct Session {
|
||||
pub selected_element_id: Option<String>,
|
||||
pub active_tool: Tool,
|
||||
pub drag_state: DragState,
|
||||
pub history: HistoryTree,
|
||||
|
||||
pub description: Option<String>,
|
||||
pub created_by: SessionCreator,
|
||||
@@ -111,6 +113,7 @@ impl Session {
|
||||
selected_element_id: None,
|
||||
active_tool: Tool::default(),
|
||||
drag_state: DragState::default(),
|
||||
history: HistoryTree::new(DocumentSnapshot::new_empty()),
|
||||
description: None,
|
||||
created_by,
|
||||
created_at: unix_now(),
|
||||
@@ -164,6 +167,24 @@ impl Session {
|
||||
self.drawing_elements.retain(|e| e.id != id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_edit(&mut self, label: &str, source: ChangeSource) {
|
||||
let snapshot =
|
||||
DocumentSnapshot::from_state(&self.drawing_elements, self.svg_source.as_deref());
|
||||
self.history.push(label.to_string(), source, snapshot);
|
||||
}
|
||||
|
||||
pub fn checkout_history(&mut self, node_id: NodeId) {
|
||||
let snapshot = self.history.checkout(node_id).clone();
|
||||
self.drawing_elements = (*snapshot.drawing_elements).clone();
|
||||
self.svg_source = snapshot.svg_source.map(|s| s.to_string());
|
||||
self.element_tree = None;
|
||||
self.svg_renderer = None;
|
||||
self.svg_texture = None;
|
||||
self.description_text.clear();
|
||||
self.selected_element_id = None;
|
||||
self.drag_state = DragState::default();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
Reference in New Issue
Block a user