feat: add singleton enforcement, extended MCP tools, and drawing improvements

Prevent multiple instances via fs2 file locking and improve agent server
port-in-use detection. Add polygon shapes, vertex editing, grouping,
alignment, z-ordering, duplication, SVG export, screenshot capture,
app state introspection, and extended shape properties (opacity, rotation,
corner radius, font family, max width) across both WebSocket and MCP APIs.
This commit is contained in:
David Ibia
2026-02-15 14:46:47 +01:00
parent 519ed74a3a
commit f5faf69cad
21 changed files with 7010 additions and 467 deletions

View File

@@ -105,6 +105,10 @@ pub struct CreateDrawingElementParam {
pub x2: Option<f32>, pub x2: Option<f32>,
#[schemars(description = "End Y (Line/Arrow)")] #[schemars(description = "End Y (Line/Arrow)")]
pub y2: Option<f32>, pub y2: Option<f32>,
#[schemars(description = "Arrow control offset X from midpoint (optional)")]
pub control_offset_x: Option<f32>,
#[schemars(description = "Arrow control offset Y from midpoint (optional)")]
pub control_offset_y: Option<f32>,
#[schemars(description = "Text content (Text shape only)")] #[schemars(description = "Text content (Text shape only)")]
pub text: Option<String>, pub text: Option<String>,
#[schemars(description = "Font size in pixels (Text shape, default 20)")] #[schemars(description = "Font size in pixels (Text shape, default 20)")]
@@ -115,6 +119,24 @@ pub struct CreateDrawingElementParam {
pub stroke_color: Option<String>, pub stroke_color: Option<String>,
#[schemars(description = "Stroke width in pixels (default 2.0)")] #[schemars(description = "Stroke width in pixels (default 2.0)")]
pub stroke_width: Option<f32>, pub stroke_width: Option<f32>,
#[schemars(description = "Opacity from 0.0 to 1.0 (default 1.0)")]
pub opacity: Option<f32>,
#[schemars(description = "Rotation in degrees (default 0)")]
pub rotation: Option<f32>,
#[schemars(description = "Corner radius for rectangles (default 0)")]
pub corner_radius: Option<f32>,
#[schemars(description = "Font family for text: 'monospace' or omit for default")]
pub font_family: Option<String>,
#[schemars(description = "Number of sides for Polygon (default 6)")]
pub sides: Option<u32>,
#[schemars(description = "Inner radius ratio for star polygon (0.0-1.0)")]
pub star_inner_ratio: Option<f32>,
#[schemars(
description = "Maximum text width for wrapping (Text shape only). Text will wrap at this width."
)]
pub max_width: Option<f32>,
#[schemars(description = "Group ID to assign to the element")]
pub group_id: Option<String>,
} }
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
@@ -149,6 +171,10 @@ pub struct UpdateDrawingElementParam {
pub x2: Option<f32>, pub x2: Option<f32>,
#[schemars(description = "End Y (Line/Arrow)")] #[schemars(description = "End Y (Line/Arrow)")]
pub y2: Option<f32>, pub y2: Option<f32>,
#[schemars(description = "Arrow control offset X from midpoint")]
pub control_offset_x: Option<f32>,
#[schemars(description = "Arrow control offset Y from midpoint")]
pub control_offset_y: Option<f32>,
#[schemars(description = "Text content (Text shape)")] #[schemars(description = "Text content (Text shape)")]
pub text: Option<String>, pub text: Option<String>,
#[schemars(description = "Font size (Text shape)")] #[schemars(description = "Font size (Text shape)")]
@@ -159,6 +185,144 @@ pub struct UpdateDrawingElementParam {
pub stroke_color: Option<String>, pub stroke_color: Option<String>,
#[schemars(description = "Stroke width in pixels")] #[schemars(description = "Stroke width in pixels")]
pub stroke_width: Option<f32>, pub stroke_width: Option<f32>,
#[schemars(description = "Opacity from 0.0 to 1.0")]
pub opacity: Option<f32>,
#[schemars(description = "Rotation in degrees")]
pub rotation: Option<f32>,
#[schemars(description = "Corner radius for rectangles")]
pub corner_radius: Option<f32>,
#[schemars(description = "Font family for text: 'monospace' or omit for default")]
pub font_family: Option<String>,
#[schemars(description = "Number of sides for Polygon")]
pub sides: Option<u32>,
#[schemars(description = "Inner radius ratio for star polygon (0.0-1.0)")]
pub star_inner_ratio: Option<f32>,
#[schemars(
description = "Maximum text width for wrapping (Text shape only). Text will wrap at this width."
)]
pub max_width: Option<f32>,
#[schemars(description = "Group ID to assign to the element")]
pub group_id: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GroupElementsParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "IDs of drawing elements to group")]
pub element_ids: Vec<String>,
#[schemars(description = "Optional group ID. If omitted, agcanvas auto-generates one")]
pub group_id: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct UngroupElementsParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "IDs of drawing elements to ungroup")]
pub element_ids: Vec<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AlignElementsParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "IDs of drawing elements to align")]
pub element_ids: Vec<String>,
#[schemars(
description = "Alignment operation: left, right, top, bottom, center_h, center_v, distribute_h, distribute_v"
)]
pub operation: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ExportSvgParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "File path to save the SVG export to")]
pub path: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ConvertToPathParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "IDs of drawing elements to convert to editable path shapes")]
pub element_ids: Vec<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct MoveVertexParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "ID of the Path element")]
pub element_id: String,
#[schemars(description = "Index of the polygon within the Path (usually 0)")]
pub polygon_idx: usize,
#[schemars(description = "Index of the vertex to move")]
pub vertex_idx: usize,
#[schemars(description = "If true, targets hole ring instead of exterior (default false)")]
pub is_hole: Option<bool>,
#[schemars(description = "New X position for the vertex")]
pub x: f32,
#[schemars(description = "New Y position for the vertex")]
pub y: f32,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AddVertexParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "ID of the Path element")]
pub element_id: String,
#[schemars(description = "Index of the polygon within the Path (usually 0)")]
pub polygon_idx: usize,
#[schemars(description = "Insert after this vertex index")]
pub after_vertex_idx: usize,
#[schemars(description = "If true, targets hole ring instead of exterior (default false)")]
pub is_hole: Option<bool>,
#[schemars(description = "X position for the new vertex")]
pub x: f32,
#[schemars(description = "Y position for the new vertex")]
pub y: f32,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct DeleteVertexParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "ID of the Path element")]
pub element_id: String,
#[schemars(description = "Index of the polygon within the Path (usually 0)")]
pub polygon_idx: usize,
#[schemars(description = "Index of the vertex to delete")]
pub vertex_idx: usize,
#[schemars(description = "If true, targets hole ring instead of exterior (default false)")]
pub is_hole: Option<bool>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ReorderElementParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "IDs of drawing elements to reorder")]
pub element_ids: Vec<String>,
#[schemars(
description = "Reorder operation: bring_forward, send_backward, bring_to_front, send_to_back"
)]
pub operation: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct DuplicateElementsParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "IDs of drawing elements to duplicate")]
pub element_ids: Vec<String>,
#[schemars(description = "X offset for duplicated elements (default 20)")]
pub offset_x: Option<f32>,
#[schemars(description = "Y offset for duplicated elements (default 20)")]
pub offset_y: Option<f32>,
} }
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
@@ -215,6 +379,20 @@ pub struct ExportCanvasParam {
pub background: Option<String>, pub background: Option<String>,
} }
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct CaptureScreenshotParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "File path to save the screenshot PNG to")]
pub path: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GetAppStateParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BatchParam { pub struct BatchParam {
#[schemars( #[schemars(
@@ -386,7 +564,7 @@ impl AgCanvasServer {
} }
#[tool( #[tool(
description = "Create a drawing element on the canvas. Shape types: Rectangle (x,y,width,height), Ellipse (center_x,center_y,radius_x,radius_y), Line (x1,y1,x2,y2), Arrow (x1,y1,x2,y2), Text (x,y,text,font_size). Colors are hex strings like '#ff0000'. Returns the created element with its auto-generated ID." description = "Create a drawing element on the canvas. Shape types: Rectangle (x,y,width,height), Ellipse (center_x,center_y,radius_x,radius_y), Line (x1,y1,x2,y2), Arrow (x1,y1,x2,y2), Polygon (center_x,center_y,radius_x,sides,star_inner_ratio), Text (x,y,text,font_size). Colors are hex strings like '#ff0000'. Returns the created element with its auto-generated ID."
)] )]
async fn create_drawing_element( async fn create_drawing_element(
&self, &self,
@@ -436,6 +614,12 @@ impl AgCanvasServer {
if let Some(v) = params.y2 { if let Some(v) = params.y2 {
obj.insert("y2".into(), v.into()); obj.insert("y2".into(), v.into());
} }
if let Some(v) = params.control_offset_x {
obj.insert("control_offset_x".into(), v.into());
}
if let Some(v) = params.control_offset_y {
obj.insert("control_offset_y".into(), v.into());
}
if let Some(v) = params.text { if let Some(v) = params.text {
obj.insert("text".into(), v.into()); obj.insert("text".into(), v.into());
} }
@@ -451,6 +635,30 @@ impl AgCanvasServer {
if let Some(v) = params.stroke_width { if let Some(v) = params.stroke_width {
obj.insert("stroke_width".into(), v.into()); obj.insert("stroke_width".into(), v.into());
} }
if let Some(v) = params.opacity {
obj.insert("opacity".into(), v.into());
}
if let Some(v) = params.rotation {
obj.insert("rotation".into(), v.into());
}
if let Some(v) = params.corner_radius {
obj.insert("corner_radius".into(), v.into());
}
if let Some(v) = params.font_family {
obj.insert("font_family".into(), v.into());
}
if let Some(v) = params.sides {
obj.insert("sides".into(), v.into());
}
if let Some(v) = params.star_inner_ratio {
obj.insert("star_inner_ratio".into(), v.into());
}
if let Some(v) = params.max_width {
obj.insert("max_width".into(), v.into());
}
if let Some(v) = params.group_id {
obj.insert("group_id".into(), v.into());
}
self.call_agcanvas(&request).await self.call_agcanvas(&request).await
} }
@@ -508,6 +716,12 @@ impl AgCanvasServer {
if let Some(v) = params.y2 { if let Some(v) = params.y2 {
obj.insert("y2".into(), v.into()); obj.insert("y2".into(), v.into());
} }
if let Some(v) = params.control_offset_x {
obj.insert("control_offset_x".into(), v.into());
}
if let Some(v) = params.control_offset_y {
obj.insert("control_offset_y".into(), v.into());
}
if let Some(v) = params.text { if let Some(v) = params.text {
obj.insert("text".into(), v.into()); obj.insert("text".into(), v.into());
} }
@@ -523,6 +737,229 @@ impl AgCanvasServer {
if let Some(v) = params.stroke_width { if let Some(v) = params.stroke_width {
obj.insert("stroke_width".into(), v.into()); obj.insert("stroke_width".into(), v.into());
} }
if let Some(v) = params.opacity {
obj.insert("opacity".into(), v.into());
}
if let Some(v) = params.rotation {
obj.insert("rotation".into(), v.into());
}
if let Some(v) = params.corner_radius {
obj.insert("corner_radius".into(), v.into());
}
if let Some(v) = params.font_family {
obj.insert("font_family".into(), v.into());
}
if let Some(v) = params.sides {
obj.insert("sides".into(), v.into());
}
if let Some(v) = params.star_inner_ratio {
obj.insert("star_inner_ratio".into(), v.into());
}
if let Some(v) = params.max_width {
obj.insert("max_width".into(), v.into());
}
if let Some(v) = params.group_id {
obj.insert("group_id".into(), v.into());
}
self.call_agcanvas(&request).await
}
#[tool(description = "Group drawing elements together so they move/select as one unit")]
async fn group_elements(
&self,
Parameters(params): Parameters<GroupElementsParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "GroupElements",
"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.group_id {
obj.insert("group_id".into(), v.into());
}
self.call_agcanvas(&request).await
}
#[tool(description = "Ungroup drawing elements (remove group association)")]
async fn ungroup_elements(
&self,
Parameters(params): Parameters<UngroupElementsParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "UngroupElements",
"element_ids": params.element_ids,
});
if let Some(v) = params.session_id {
request["session_id"] = serde_json::Value::String(v);
}
self.call_agcanvas(&request).await
}
#[tool(
description = "Align selected drawing elements (left, right, top, bottom, center_h, center_v, distribute_h, distribute_v)"
)]
async fn align_elements(
&self,
Parameters(params): Parameters<AlignElementsParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "AlignElements",
"element_ids": params.element_ids,
"operation": params.operation,
});
if let Some(v) = params.session_id {
request["session_id"] = serde_json::Value::String(v);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Export drawing elements as SVG vector file")]
async fn export_svg(
&self,
Parameters(params): Parameters<ExportSvgParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "ExportSvg",
"path": params.path,
});
if let Some(v) = params.session_id {
request["session_id"] = serde_json::Value::String(v);
}
self.call_agcanvas(&request).await
}
#[tool(
description = "Reorder drawing elements in the z-stack. Operations: bring_forward (up one), send_backward (down one), bring_to_front (topmost), send_to_back (bottommost)."
)]
async fn reorder_element(
&self,
Parameters(params): Parameters<ReorderElementParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "ReorderElement",
"element_ids": params.element_ids,
"operation": params.operation,
});
if let Some(v) = params.session_id {
request["session_id"] = serde_json::Value::String(v);
}
self.call_agcanvas(&request).await
}
#[tool(
description = "Duplicate drawing elements. Creates copies offset from the originals. Returns the new elements with their auto-generated IDs."
)]
async fn duplicate_elements(
&self,
Parameters(params): Parameters<DuplicateElementsParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "DuplicateElements",
"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.offset_x {
obj.insert("offset_x".into(), v.into());
}
if let Some(v) = params.offset_y {
obj.insert("offset_y".into(), v.into());
}
self.call_agcanvas(&request).await
}
#[tool(
description = "Convert drawing elements to editable Path shapes with explicit vertices. Works on Rectangle, Ellipse, Polygon, Line, Arrow. Text and Path shapes are skipped. After conversion, use move_vertex/add_vertex/delete_vertex to edit individual points."
)]
async fn convert_to_path(
&self,
Parameters(params): Parameters<ConvertToPathParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "ConvertToPath",
"element_ids": params.element_ids,
});
if let Some(v) = params.session_id {
request["session_id"] = serde_json::Value::String(v);
}
self.call_agcanvas(&request).await
}
#[tool(
description = "Move a vertex on a Path element to a new position. The element must be a Path shape (use convert_to_path first if needed)."
)]
async fn move_vertex(
&self,
Parameters(params): Parameters<MoveVertexParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "MoveVertex",
"element_id": params.element_id,
"polygon_idx": params.polygon_idx,
"vertex_idx": params.vertex_idx,
"x": params.x,
"y": params.y,
});
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.is_hole {
obj.insert("is_hole".into(), v.into());
}
self.call_agcanvas(&request).await
}
#[tool(
description = "Insert a new vertex on a Path element after the specified vertex index. The element must be a Path shape."
)]
async fn add_vertex(
&self,
Parameters(params): Parameters<AddVertexParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "AddVertex",
"element_id": params.element_id,
"polygon_idx": params.polygon_idx,
"after_vertex_idx": params.after_vertex_idx,
"x": params.x,
"y": params.y,
});
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.is_hole {
obj.insert("is_hole".into(), v.into());
}
self.call_agcanvas(&request).await
}
#[tool(
description = "Delete a vertex from a Path element. Exterior ring must keep at least 3 vertices. The element must be a Path shape."
)]
async fn delete_vertex(
&self,
Parameters(params): Parameters<DeleteVertexParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "DeleteVertex",
"element_id": params.element_id,
"polygon_idx": params.polygon_idx,
"vertex_idx": params.vertex_idx,
});
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.is_hole {
obj.insert("is_hole".into(), v.into());
}
self.call_agcanvas(&request).await self.call_agcanvas(&request).await
} }
@@ -600,6 +1037,37 @@ impl AgCanvasServer {
self.call_agcanvas(&request).await self.call_agcanvas(&request).await
} }
#[tool(
description = "Capture a pixel-perfect screenshot of the entire Augmented Canvas application window (including UI chrome, toolbar, panels). Saves as PNG. The screenshot is taken asynchronously and may take 1-2 frames."
)]
async fn capture_screenshot(
&self,
Parameters(params): Parameters<CaptureScreenshotParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "CaptureScreenshot",
"path": params.path,
});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(
description = "Get the current application state including active tool, selected elements, zoom level, pan offset, theme, panel visibility, session info, and canvas dimensions. Useful for debugging and understanding the current UI state."
)]
async fn get_app_state(
&self,
Parameters(params): Parameters<GetAppStateParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({"type": "GetAppState"});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool( #[tool(
description = "Send multiple Augmented Canvas operations in one request. Accepts a JSON array of request objects and returns one response per operation in a BatchResult." description = "Send multiple Augmented Canvas operations in one request. Accepts a JSON array of request objects and returns one response per operation in a BatchResult."
)] )]

View File

@@ -28,7 +28,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
# Agent communication # Agent communication
tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "macros"] } tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "macros", "time"] }
tokio-tungstenite = "0.24" tokio-tungstenite = "0.24"
futures-util = "0.3" futures-util = "0.3"
@@ -42,11 +42,14 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Filesystem paths # Filesystem paths
dirs = "5.0" dirs = "5.0"
# Singleton lock
fs2 = "0.4"
# Error handling # Error handling
anyhow = "1.0" anyhow = "1.0"
thiserror = "1.0" thiserror = "1.0"
# Image handling # Image handling
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
i_overlay = "4.4.0" i_overlay = "4.4.0"
earcutr = "0.5.0" earcutr = "0.5.0"

View File

@@ -47,6 +47,11 @@ pub enum GuiEvent {
DrawingElementsCleared { DrawingElementsCleared {
session_id: String, session_id: String,
}, },
ScreenshotCaptured {
path: String,
width: u32,
height: u32,
},
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -137,6 +142,10 @@ pub enum AgentRequest {
#[serde(default)] #[serde(default)]
y2: Option<f32>, y2: Option<f32>,
#[serde(default)] #[serde(default)]
control_offset_x: Option<f32>,
#[serde(default)]
control_offset_y: Option<f32>,
#[serde(default)]
text: Option<String>, text: Option<String>,
#[serde(default)] #[serde(default)]
font_size: Option<f32>, font_size: Option<f32>,
@@ -146,6 +155,22 @@ pub enum AgentRequest {
stroke_color: Option<String>, stroke_color: Option<String>,
#[serde(default)] #[serde(default)]
stroke_width: Option<f32>, stroke_width: Option<f32>,
#[serde(default)]
opacity: Option<f32>,
#[serde(default)]
rotation: Option<f32>,
#[serde(default)]
corner_radius: Option<f32>,
#[serde(default)]
font_family: Option<String>,
#[serde(default)]
sides: Option<u32>,
#[serde(default)]
star_inner_ratio: Option<f32>,
#[serde(default)]
max_width: Option<f32>,
#[serde(default)]
group_id: Option<String>,
}, },
UpdateDrawingElement { UpdateDrawingElement {
#[serde(default)] #[serde(default)]
@@ -178,6 +203,10 @@ pub enum AgentRequest {
#[serde(default)] #[serde(default)]
y2: Option<f32>, y2: Option<f32>,
#[serde(default)] #[serde(default)]
control_offset_x: Option<f32>,
#[serde(default)]
control_offset_y: Option<f32>,
#[serde(default)]
text: Option<String>, text: Option<String>,
#[serde(default)] #[serde(default)]
font_size: Option<f32>, font_size: Option<f32>,
@@ -187,6 +216,22 @@ pub enum AgentRequest {
stroke_color: Option<String>, stroke_color: Option<String>,
#[serde(default)] #[serde(default)]
stroke_width: Option<f32>, stroke_width: Option<f32>,
#[serde(default)]
opacity: Option<f32>,
#[serde(default)]
rotation: Option<f32>,
#[serde(default)]
corner_radius: Option<f32>,
#[serde(default)]
font_family: Option<String>,
#[serde(default)]
sides: Option<u32>,
#[serde(default)]
star_inner_ratio: Option<f32>,
#[serde(default)]
max_width: Option<f32>,
#[serde(default)]
group_id: Option<String>,
}, },
DeleteDrawingElement { DeleteDrawingElement {
#[serde(default)] #[serde(default)]
@@ -197,6 +242,81 @@ pub enum AgentRequest {
#[serde(default)] #[serde(default)]
session_id: Option<String>, session_id: Option<String>,
}, },
GroupElements {
#[serde(default)]
session_id: Option<String>,
element_ids: Vec<String>,
#[serde(default)]
group_id: Option<String>,
},
UngroupElements {
#[serde(default)]
session_id: Option<String>,
element_ids: Vec<String>,
},
AlignElements {
#[serde(default)]
session_id: Option<String>,
element_ids: Vec<String>,
operation: String,
},
ReorderElement {
#[serde(default)]
session_id: Option<String>,
element_ids: Vec<String>,
/// One of: bring_forward, send_backward, bring_to_front, send_to_back
operation: String,
},
DuplicateElements {
#[serde(default)]
session_id: Option<String>,
element_ids: Vec<String>,
#[serde(default)]
offset_x: Option<f32>,
#[serde(default)]
offset_y: Option<f32>,
},
ConvertToPath {
#[serde(default)]
session_id: Option<String>,
element_ids: Vec<String>,
},
MoveVertex {
#[serde(default)]
session_id: Option<String>,
element_id: String,
polygon_idx: usize,
vertex_idx: usize,
#[serde(default)]
is_hole: bool,
x: f32,
y: f32,
},
AddVertex {
#[serde(default)]
session_id: Option<String>,
element_id: String,
polygon_idx: usize,
after_vertex_idx: usize,
#[serde(default)]
is_hole: bool,
x: f32,
y: f32,
},
DeleteVertex {
#[serde(default)]
session_id: Option<String>,
element_id: String,
polygon_idx: usize,
vertex_idx: usize,
#[serde(default)]
is_hole: bool,
},
ExportSvg {
#[serde(default)]
session_id: Option<String>,
path: String,
},
BooleanOp { BooleanOp {
#[serde(default)] #[serde(default)]
session_id: Option<String>, session_id: Option<String>,
@@ -233,6 +353,15 @@ pub enum AgentRequest {
#[serde(default)] #[serde(default)]
background: Option<String>, background: Option<String>,
}, },
CaptureScreenshot {
path: String,
#[serde(default)]
session_id: Option<String>,
},
GetAppState {
#[serde(default)]
session_id: Option<String>,
},
Batch { Batch {
requests: Vec<AgentRequest>, requests: Vec<AgentRequest>,
}, },
@@ -297,6 +426,50 @@ pub enum AgentResponse {
DrawingElementsCleared { DrawingElementsCleared {
session_id: String, session_id: String,
}, },
ElementsGrouped {
session_id: String,
group_id: String,
element_ids: Vec<String>,
},
ElementsUngrouped {
session_id: String,
element_ids: Vec<String>,
},
ElementsAligned {
session_id: String,
operation: String,
element_ids: Vec<String>,
},
ElementsReordered {
session_id: String,
operation: String,
element_ids: Vec<String>,
},
ElementsDuplicated {
session_id: String,
original_ids: Vec<String>,
new_elements: Vec<DrawingElement>,
},
ElementsConverted {
session_id: String,
element_ids: Vec<String>,
},
VertexMoved {
session_id: String,
element: DrawingElement,
},
VertexAdded {
session_id: String,
element: DrawingElement,
},
VertexDeleted {
session_id: String,
element: DrawingElement,
},
SvgExported {
session_id: String,
path: String,
},
MermaidRendered { MermaidRendered {
session_id: String, session_id: String,
overlay_id: String, overlay_id: String,
@@ -310,6 +483,27 @@ pub enum AgentResponse {
width: u32, width: u32,
height: u32, height: u32,
}, },
ScreenshotCaptured {
path: String,
width: u32,
height: u32,
},
AppState {
session_id: String,
active_tool: String,
selected_element_ids: Vec<String>,
zoom: f32,
pan_offset_x: f32,
pan_offset_y: f32,
theme: String,
show_tree_panel: bool,
show_description_panel: bool,
show_history_panel: bool,
session_name: String,
element_count: usize,
canvas_width: f32,
canvas_height: f32,
},
BatchResult { BatchResult {
results: Vec<AgentResponse>, results: Vec<AgentResponse>,
}, },
@@ -340,6 +534,9 @@ pub enum DrawingCommand {
Clear { Clear {
session_id: String, session_id: String,
}, },
CaptureScreenshot {
path: String,
},
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -434,8 +631,13 @@ pub fn build_shape(
y1: Option<f32>, y1: Option<f32>,
x2: Option<f32>, x2: Option<f32>,
y2: Option<f32>, y2: Option<f32>,
control_offset_x: Option<f32>,
control_offset_y: Option<f32>,
text: Option<String>, text: Option<String>,
font_size: Option<f32>, font_size: Option<f32>,
sides: Option<u32>,
star_inner_ratio: Option<f32>,
max_width: Option<f32>,
) -> Result<Shape, String> { ) -> Result<Shape, String> {
match shape_type { match shape_type {
"Rectangle" | "rectangle" | "rect" | "Rect" => Ok(Shape::Rectangle { "Rectangle" | "rectangle" | "rect" | "Rect" => Ok(Shape::Rectangle {
@@ -457,38 +659,72 @@ pub fn build_shape(
"Arrow" | "arrow" => { "Arrow" | "arrow" => {
let sx = x1.or(x).unwrap_or(0.0); let sx = x1.or(x).unwrap_or(0.0);
let sy = y1.or(y).unwrap_or(0.0); let sy = y1.or(y).unwrap_or(0.0);
let control_offset = if control_offset_x.is_some() || control_offset_y.is_some() {
Some(Vec2::new(
control_offset_x.unwrap_or(0.0),
control_offset_y.unwrap_or(0.0),
))
} else {
None
};
Ok(Shape::Arrow { Ok(Shape::Arrow {
start: Pos2::new(sx, sy), start: Pos2::new(sx, sy),
end: Pos2::new(x2.unwrap_or(sx + 100.0), y2.unwrap_or(sy)), end: Pos2::new(x2.unwrap_or(sx + 100.0), y2.unwrap_or(sy)),
control_offset,
}) })
} }
"Text" | "text" => Ok(Shape::Text { "Text" | "text" => Ok(Shape::Text {
pos: Pos2::new(x.unwrap_or(0.0), y.unwrap_or(0.0)), pos: Pos2::new(x.unwrap_or(0.0), y.unwrap_or(0.0)),
content: text.unwrap_or_else(|| "Text".to_string()), content: text.unwrap_or_else(|| "Text".to_string()),
font_size: font_size.unwrap_or(20.0), font_size: font_size.unwrap_or(20.0),
max_width,
}), }),
"Polygon" | "polygon" => {
let cx = center_x.or(x).unwrap_or(0.0);
let cy = center_y.or(y).unwrap_or(0.0);
let radius = radius_x
.or_else(|| width.map(|w| w.abs() / 2.0))
.unwrap_or(50.0);
Ok(Shape::Polygon {
center: Pos2::new(cx, cy),
radius,
sides: sides.unwrap_or(6),
star_inner_ratio,
})
}
"Path" | "path" => { "Path" | "path" => {
Err("Path shapes are created via boolean operations, not directly".to_string()) Err("Path shapes are created via boolean operations, not directly".to_string())
} }
other => Err(format!( other => Err(format!(
"Unknown shape_type '{}'. Expected: Rectangle, Ellipse, Line, Arrow, Text, or Path", "Unknown shape_type '{}'. Expected: Rectangle, Ellipse, Line, Arrow, Polygon, Text, or Path",
other other
)), )),
} }
} }
/// Build a `ShapeStyle` from optional hex color strings.
pub fn build_style( pub fn build_style(
fill: Option<String>, fill: Option<String>,
stroke_color: Option<String>, stroke_color: Option<String>,
stroke_width: Option<f32>, stroke_width: Option<f32>,
opacity: Option<f32>,
rotation: Option<f32>,
corner_radius: Option<f32>,
font_family: Option<String>,
) -> ShapeStyle { ) -> ShapeStyle {
ShapeStyle { ShapeStyle {
fill: fill.as_deref().and_then(parse_hex_color), fill: fill
.as_deref()
.and_then(parse_hex_color)
.map(crate::drawing::Fill::solid),
stroke_color: stroke_color stroke_color: stroke_color
.as_deref() .as_deref()
.and_then(parse_hex_color) .and_then(parse_hex_color)
.unwrap_or(Color32::WHITE), .unwrap_or(Color32::WHITE),
stroke_width: stroke_width.unwrap_or(2.0), stroke_width: stroke_width.unwrap_or(2.0),
opacity: opacity.unwrap_or(1.0),
rotation_degrees: rotation.unwrap_or(0.0),
corner_radius: corner_radius.unwrap_or(0.0),
font_family,
stroke_dash: None,
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,14 @@ impl ClipboardManager {
None None
} }
} }
pub fn get_image(&mut self) -> Option<(Vec<u8>, usize, usize)> {
let img = self.clipboard.get_image().ok()?;
if img.width == 0 || img.height == 0 {
return None;
}
Some((img.bytes.into_owned(), img.width, img.height))
}
} }
fn is_svg_content(text: &str) -> bool { fn is_svg_content(text: &str) -> bool {

View File

@@ -6,17 +6,36 @@ pub enum CommandId {
CloseTab, CloseTab,
Undo, Undo,
Redo, Redo,
Duplicate,
ConvertToPath,
Group,
Ungroup,
BringForward,
SendBackward,
BringToFront,
SendToBack,
AlignLeft,
AlignRight,
AlignTop,
AlignBottom,
AlignCenterH,
AlignCenterV,
DistributeH,
DistributeV,
SaveWorkspace, SaveWorkspace,
ClearCanvas, ClearCanvas,
PasteSvg, PasteSvg,
PasteMermaid, PasteMermaid,
ExportPng, ExportPng,
ExportSvg,
ToolSelect, ToolSelect,
ToolDirectSelect,
ToolPan, ToolPan,
ToolRectangle, ToolRectangle,
ToolEllipse, ToolEllipse,
ToolLine, ToolLine,
ToolArrow, ToolArrow,
ToolPolygon,
ToolText, ToolText,
ResetZoom, ResetZoom,
FitToView, FitToView,
@@ -55,6 +74,62 @@ pub fn all_commands() -> Vec<PaletteCommand> {
Some("Cmd+Shift+Z"), Some("Cmd+Shift+Z"),
"Edit", "Edit",
), ),
PaletteCommand::new(
CommandId::Duplicate,
"Duplicate Selection",
Some("Cmd+D"),
"Edit",
),
PaletteCommand::new(
CommandId::ConvertToPath,
"Convert to Path",
Some("Cmd+Shift+P"),
"Edit",
),
PaletteCommand::new(CommandId::Group, "Group Selection", Some("Cmd+G"), "Edit"),
PaletteCommand::new(
CommandId::Ungroup,
"Ungroup Selection",
Some("Cmd+Shift+G"),
"Edit",
),
PaletteCommand::new(
CommandId::BringForward,
"Bring Forward",
Some("Cmd+]"),
"Edit",
),
PaletteCommand::new(
CommandId::SendBackward,
"Send Backward",
Some("Cmd+["),
"Edit",
),
PaletteCommand::new(
CommandId::BringToFront,
"Bring to Front",
Some("Cmd+Shift+]"),
"Edit",
),
PaletteCommand::new(
CommandId::SendToBack,
"Send to Back",
Some("Cmd+Shift+["),
"Edit",
),
PaletteCommand::new(CommandId::AlignLeft, "Align Left", None, "Edit"),
PaletteCommand::new(CommandId::AlignRight, "Align Right", None, "Edit"),
PaletteCommand::new(CommandId::AlignTop, "Align Top", None, "Edit"),
PaletteCommand::new(CommandId::AlignBottom, "Align Bottom", None, "Edit"),
PaletteCommand::new(CommandId::AlignCenterH, "Align Center H", None, "Edit"),
PaletteCommand::new(CommandId::AlignCenterV, "Align Center V", None, "Edit"),
PaletteCommand::new(
CommandId::DistributeH,
"Distribute Horizontal",
None,
"Edit",
),
PaletteCommand::new(CommandId::DistributeV, "Distribute Vertical", None, "Edit"),
PaletteCommand::new( PaletteCommand::new(
CommandId::SaveWorkspace, CommandId::SaveWorkspace,
"Save Workspace", "Save Workspace",
@@ -62,7 +137,7 @@ pub fn all_commands() -> Vec<PaletteCommand> {
"Session", "Session",
), ),
PaletteCommand::new(CommandId::ClearCanvas, "Clear Canvas", None, "Canvas"), PaletteCommand::new(CommandId::ClearCanvas, "Clear Canvas", None, "Canvas"),
PaletteCommand::new(CommandId::PasteSvg, "Paste SVG", Some("Cmd+V"), "Canvas"), PaletteCommand::new(CommandId::PasteSvg, "Paste", Some("Cmd+V"), "Canvas"),
PaletteCommand::new( PaletteCommand::new(
CommandId::PasteMermaid, CommandId::PasteMermaid,
"Paste Mermaid Diagram", "Paste Mermaid Diagram",
@@ -75,7 +150,19 @@ pub fn all_commands() -> Vec<PaletteCommand> {
Some("Cmd+Shift+E"), Some("Cmd+Shift+E"),
"Canvas", "Canvas",
), ),
PaletteCommand::new(
CommandId::ExportSvg,
"Export as SVG",
Some("Cmd+Shift+S"),
"Canvas",
),
PaletteCommand::new(CommandId::ToolSelect, "Select Tool", Some("V"), "Tool"), PaletteCommand::new(CommandId::ToolSelect, "Select Tool", Some("V"), "Tool"),
PaletteCommand::new(
CommandId::ToolDirectSelect,
"Direct Select Tool",
Some("D"),
"Tool",
),
PaletteCommand::new(CommandId::ToolPan, "Pan Tool", Some("H"), "Tool"), PaletteCommand::new(CommandId::ToolPan, "Pan Tool", Some("H"), "Tool"),
PaletteCommand::new( PaletteCommand::new(
CommandId::ToolRectangle, CommandId::ToolRectangle,
@@ -86,6 +173,7 @@ pub fn all_commands() -> Vec<PaletteCommand> {
PaletteCommand::new(CommandId::ToolEllipse, "Ellipse Tool", Some("E"), "Tool"), PaletteCommand::new(CommandId::ToolEllipse, "Ellipse Tool", Some("E"), "Tool"),
PaletteCommand::new(CommandId::ToolLine, "Line Tool", Some("L"), "Tool"), PaletteCommand::new(CommandId::ToolLine, "Line Tool", Some("L"), "Tool"),
PaletteCommand::new(CommandId::ToolArrow, "Arrow Tool", Some("A"), "Tool"), PaletteCommand::new(CommandId::ToolArrow, "Arrow Tool", Some("A"), "Tool"),
PaletteCommand::new(CommandId::ToolPolygon, "Polygon Tool", Some("P"), "Tool"),
PaletteCommand::new(CommandId::ToolText, "Text Tool", Some("T"), "Tool"), PaletteCommand::new(CommandId::ToolText, "Text Tool", Some("T"), "Tool"),
PaletteCommand::new(CommandId::ResetZoom, "Reset Zoom", Some("Cmd+0"), "View"), PaletteCommand::new(CommandId::ResetZoom, "Reset Zoom", Some("Cmd+0"), "View"),
PaletteCommand::new(CommandId::FitToView, "Fit to View", None, "View"), PaletteCommand::new(CommandId::FitToView, "Fit to View", None, "View"),
@@ -367,7 +455,7 @@ mod tests {
palette.open(); palette.open();
palette.query = "rect".to_string(); palette.query = "rect".to_string();
palette.update_filter(); palette.update_filter();
assert!(palette.filtered.len() >= 1); assert!(!palette.filtered.is_empty());
let matched: Vec<_> = palette let matched: Vec<_> = palette
.filtered .filtered
.iter() .iter()

View File

@@ -1,4 +1,4 @@
use super::element::{DrawingElement, PathPolygon, Shape}; use super::element::{polygon_vertices, DrawingElement, PathPolygon, Shape};
use egui::Pos2; use egui::Pos2;
use i_overlay::core::fill_rule::FillRule; use i_overlay::core::fill_rule::FillRule;
use i_overlay::core::overlay_rule::OverlayRule; use i_overlay::core::overlay_rule::OverlayRule;
@@ -34,9 +34,24 @@ pub fn shape_to_contour(shape: &Shape) -> Result<Vec<[f64; 2]>, String> {
] ]
}) })
.collect()), .collect()),
Shape::Polygon {
center,
radius,
sides,
star_inner_ratio,
} => Ok(
polygon_vertices(*center, *radius, *sides, *star_inner_ratio)
.into_iter()
.map(|point| [point.x as f64, point.y as f64])
.collect(),
),
Shape::Path { .. } => Err("Path shape cannot be used as boolean input yet".to_string()), Shape::Path { .. } => Err("Path shape cannot be used as boolean input yet".to_string()),
Shape::Line { .. } | Shape::Arrow { .. } | Shape::Text { .. } => { Shape::Line { .. }
Err("Boolean operations only support Rectangle and Ellipse".to_string()) | Shape::Arrow { .. }
| Shape::Text { .. }
| Shape::SvgImage { .. }
| Shape::Group => {
Err("Boolean operations only support Rectangle, Ellipse, and Polygon".to_string())
} }
} }
} }

View File

@@ -1,8 +1,9 @@
use egui::{Color32, Pos2}; use egui::{Color32, Pos2, Vec2};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
static DRAWING_ID_COUNTER: AtomicUsize = AtomicUsize::new(0); static DRAWING_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
pub const ARROW_CURVE_SEGMENTS: usize = 20;
pub fn generate_drawing_id() -> String { pub fn generate_drawing_id() -> String {
format!("draw_{}", DRAWING_ID_COUNTER.fetch_add(1, Ordering::SeqCst)) format!("draw_{}", DRAWING_ID_COUNTER.fetch_add(1, Ordering::SeqCst))
@@ -11,8 +12,12 @@ pub fn generate_drawing_id() -> String {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DrawingElement { pub struct DrawingElement {
pub id: String, pub id: String,
#[serde(default)]
pub group_id: Option<String>,
pub shape: Shape, pub shape: Shape,
pub style: ShapeStyle, pub style: ShapeStyle,
#[serde(default)]
pub children: Vec<DrawingElement>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -25,8 +30,10 @@ impl DrawingElement {
pub fn new(shape: Shape, style: ShapeStyle) -> Self { pub fn new(shape: Shape, style: ShapeStyle) -> Self {
Self { Self {
id: generate_drawing_id(), id: generate_drawing_id(),
group_id: None,
shape, shape,
style, style,
children: Vec::new(),
} }
} }
@@ -37,18 +44,37 @@ impl DrawingElement {
Shape::Ellipse { center, radii } => { Shape::Ellipse { center, radii } => {
egui::Rect::from_center_size(*center, egui::vec2(radii.x * 2.0, radii.y * 2.0)) egui::Rect::from_center_size(*center, egui::vec2(radii.x * 2.0, radii.y * 2.0))
} }
Shape::Line { start, end } | Shape::Arrow { start, end } => { Shape::Line { start, end } => egui::Rect::from_two_pos(*start, *end),
Shape::Arrow {
start,
end,
control_offset,
} => {
if let Some(control_point) = arrow_control_point(*start, *end, *control_offset) {
rect_from_points(&[*start, control_point, *end])
.unwrap_or_else(|| egui::Rect::from_two_pos(*start, *end))
} else {
egui::Rect::from_two_pos(*start, *end) egui::Rect::from_two_pos(*start, *end)
} }
}
Shape::Polygon {
center,
radius,
sides,
star_inner_ratio,
} => {
let vertices = polygon_vertices(*center, *radius, *sides, *star_inner_ratio);
rect_from_points(&vertices)
.unwrap_or_else(|| egui::Rect::from_center_size(*center, egui::Vec2::ZERO))
}
Shape::Text { Shape::Text {
pos, pos,
content: _, content,
font_size, font_size,
max_width,
} => { } => {
// Approximate: we'll refine during rendering when we know actual text size. let (w, h) = estimate_text_bounds(content, *font_size, *max_width);
let approx_width = 8.0 * font_size * 0.6; egui::Rect::from_min_size(*pos, egui::vec2(w, h))
let approx_height = *font_size * 1.4;
egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height))
} }
Shape::Path { polygons } => { Shape::Path { polygons } => {
let mut min_x = f32::INFINITY; let mut min_x = f32::INFINITY;
@@ -72,6 +98,8 @@ impl DrawingElement {
egui::Rect::from_min_size(Pos2::ZERO, egui::Vec2::ZERO) egui::Rect::from_min_size(Pos2::ZERO, egui::Vec2::ZERO)
} }
} }
Shape::SvgImage { pos, size, .. } => egui::Rect::from_min_size(*pos, *size),
Shape::Group => children_bounding_rect(&self.children),
} }
} }
@@ -91,17 +119,49 @@ impl DrawingElement {
let ry = radii.y + tolerance; let ry = radii.y + tolerance;
(dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1.0 (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1.0
} }
Shape::Line { start, end } | Shape::Arrow { start, end } => { Shape::Line { start, end } => {
point_to_segment_distance(point, *start, *end) <= tolerance point_to_segment_distance(point, *start, *end) <= tolerance
} }
Shape::Arrow {
start,
end,
control_offset,
} => {
if let Some(control_point) = arrow_control_point(*start, *end, *control_offset) {
point_to_bezier_distance(
point,
*start,
control_point,
*end,
ARROW_CURVE_SEGMENTS,
) <= tolerance
} else {
point_to_segment_distance(point, *start, *end) <= tolerance
}
}
Shape::Polygon {
center,
radius,
sides,
star_inner_ratio,
} => {
let vertices = polygon_vertices(*center, *radius, *sides, *star_inner_ratio);
point_in_polygon(point, &vertices)
|| vertices
.iter()
.zip(vertices.iter().cycle().skip(1))
.take(vertices.len())
.map(|(a, b)| (*a, *b))
.any(|(a, b)| point_to_segment_distance(point, a, b) <= tolerance)
}
Shape::Text { Shape::Text {
pos, pos,
content: _, content,
font_size, font_size,
max_width,
} => { } => {
let approx_width = 8.0 * font_size * 0.6; let (w, h) = estimate_text_bounds(content, *font_size, *max_width);
let approx_height = *font_size * 1.4; let rect = egui::Rect::from_min_size(*pos, egui::vec2(w, h));
let rect = egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height));
rect.expand(tolerance).contains(point) rect.expand(tolerance).contains(point)
} }
Shape::Path { polygons } => polygons.iter().any(|polygon| { Shape::Path { polygons } => polygons.iter().any(|polygon| {
@@ -111,6 +171,11 @@ impl DrawingElement {
.iter() .iter()
.any(|hole| point_in_polygon(point, hole)) .any(|hole| point_in_polygon(point, hole))
}), }),
Shape::SvgImage { pos, size, .. } => {
let rect = egui::Rect::from_min_size(*pos, *size);
rect.expand(tolerance).contains(point)
}
Shape::Group => self.children.iter().any(|c| c.contains_point(point)),
} }
} }
@@ -119,10 +184,15 @@ impl DrawingElement {
match &mut self.shape { match &mut self.shape {
Shape::Rectangle { pos, .. } => *pos += delta, Shape::Rectangle { pos, .. } => *pos += delta,
Shape::Ellipse { center, .. } => *center += delta, Shape::Ellipse { center, .. } => *center += delta,
Shape::Line { start, end } | Shape::Arrow { start, end } => { Shape::Line { start, end } => {
*start += delta; *start += delta;
*end += delta; *end += delta;
} }
Shape::Arrow { start, end, .. } => {
*start += delta;
*end += delta;
}
Shape::Polygon { center, .. } => *center += delta,
Shape::Text { pos, .. } => *pos += delta, Shape::Text { pos, .. } => *pos += delta,
Shape::Path { polygons } => { Shape::Path { polygons } => {
for polygon in polygons { for polygon in polygons {
@@ -136,6 +206,12 @@ impl DrawingElement {
} }
} }
} }
Shape::SvgImage { pos, .. } => *pos += delta,
Shape::Group => {
for child in &mut self.children {
child.translate(delta);
}
}
} }
} }
@@ -151,10 +227,42 @@ impl DrawingElement {
*center = new_rect.center(); *center = new_rect.center();
*radii = egui::vec2(new_rect.width() / 2.0, new_rect.height() / 2.0); *radii = egui::vec2(new_rect.width() / 2.0, new_rect.height() / 2.0);
} }
Shape::Line { start, end } | Shape::Arrow { start, end } => { Shape::Line { start, end } => {
*start = new_rect.min; *start = new_rect.min;
*end = new_rect.max; *end = new_rect.max;
} }
Shape::Arrow {
start,
end,
control_offset,
} => {
*start = new_rect.min;
*end = new_rect.max;
if let Some(offset) = control_offset.as_mut() {
let old_w = old_rect.width();
let old_h = old_rect.height();
let scale_x = if old_w.abs() <= f32::EPSILON {
1.0
} else {
new_rect.width() / old_w
};
let scale_y = if old_h.abs() <= f32::EPSILON {
1.0
} else {
new_rect.height() / old_h
};
*offset = egui::vec2(offset.x * scale_x, offset.y * scale_y);
}
}
Shape::Polygon { center, radius, .. } => {
*center = new_rect.center();
let old_diameter = (old_rect.width().abs() + old_rect.height().abs()) / 2.0;
let new_diameter = (new_rect.width().abs() + new_rect.height().abs()) / 2.0;
if old_diameter > f32::EPSILON {
*radius *= new_diameter / old_diameter;
}
}
Shape::Text { pos, .. } => { Shape::Text { pos, .. } => {
*pos = new_rect.min; *pos = new_rect.min;
} }
@@ -173,7 +281,89 @@ impl DrawingElement {
} }
} }
} }
Shape::SvgImage {
pos,
size,
aspect_ratio,
..
} => {
let new_w = new_rect.width();
let new_h = new_w / *aspect_ratio;
*pos = new_rect.min;
*size = egui::vec2(new_w, new_h);
} }
Shape::Group => {
let old_w = old_rect.width();
let old_h = old_rect.height();
for child in &mut self.children {
let child_rect = child.bounding_rect();
let mapped = egui::Rect::from_min_max(
map_point_to_rect(child_rect.min, old_rect, new_rect, old_w, old_h),
map_point_to_rect(child_rect.max, old_rect, new_rect, old_w, old_h),
);
child.resize_to(mapped);
}
}
}
}
pub fn to_path(&self) -> Option<Shape> {
const ELLIPSE_SEGMENTS: usize = 64;
let polygon = match &self.shape {
Shape::Rectangle { pos, size } => {
let rect = egui::Rect::from_min_size(*pos, *size);
PathPolygon {
exterior: vec![
rect.left_top(),
rect.right_top(),
rect.right_bottom(),
rect.left_bottom(),
],
holes: Vec::new(),
}
}
Shape::Ellipse { center, radii } => PathPolygon {
exterior: (0..ELLIPSE_SEGMENTS)
.map(|i| {
let angle = i as f32 / ELLIPSE_SEGMENTS as f32 * std::f32::consts::TAU;
Pos2::new(
center.x + radii.x * angle.cos(),
center.y + radii.y * angle.sin(),
)
})
.collect(),
holes: Vec::new(),
},
Shape::Polygon {
center,
radius,
sides,
star_inner_ratio,
} => PathPolygon {
exterior: polygon_vertices(*center, *radius, *sides, *star_inner_ratio),
holes: Vec::new(),
},
Shape::Line { start, end } => PathPolygon {
exterior: vec![*start, *end],
holes: Vec::new(),
},
Shape::Arrow {
start,
end,
control_offset,
} => PathPolygon {
exterior: arrow_curve_points(*start, *end, *control_offset, ARROW_CURVE_SEGMENTS),
holes: Vec::new(),
},
Shape::Text { .. } | Shape::Path { .. } | Shape::SvgImage { .. } | Shape::Group => {
return None;
}
};
Some(Shape::Path {
polygons: vec![polygon],
})
} }
} }
@@ -195,22 +385,200 @@ pub enum Shape {
Arrow { Arrow {
start: Pos2, start: Pos2,
end: Pos2, end: Pos2,
#[serde(default)]
control_offset: Option<egui::Vec2>,
},
Polygon {
center: Pos2,
radius: f32,
sides: u32,
star_inner_ratio: Option<f32>,
}, },
Text { Text {
pos: Pos2, pos: Pos2,
content: String, content: String,
font_size: f32, font_size: f32,
#[serde(default)]
max_width: Option<f32>,
}, },
Path { Path {
polygons: Vec<PathPolygon>, polygons: Vec<PathPolygon>,
}, },
SvgImage {
pos: Pos2,
size: egui::Vec2,
aspect_ratio: f32,
#[serde(default)]
svg_source: String,
},
Group,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GradientStop {
pub offset: f32,
pub color: Color32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Fill {
Solid {
color: Color32,
},
LinearGradient {
angle_deg: f32,
stops: Vec<GradientStop>,
},
RadialGradient {
stops: Vec<GradientStop>,
},
}
impl Fill {
/// Create a solid fill from a Color32.
pub fn solid(color: Color32) -> Self {
Fill::Solid { color }
}
/// Return the primary/first color for contexts that need a single color
/// (e.g. color picker, fallback rendering).
pub fn primary_color(&self) -> Color32 {
match self {
Fill::Solid { color } => *color,
Fill::LinearGradient { stops, .. } | Fill::RadialGradient { stops } => {
stops.first().map(|s| s.color).unwrap_or(Color32::WHITE)
}
}
}
/// Compute the gradient color at a position within a bounding rect.
/// `pos` is in the same coordinate space as `bounds`.
/// Returns a solid color for Solid fills.
pub fn color_at(&self, pos: Pos2, bounds: egui::Rect) -> Color32 {
match self {
Fill::Solid { color } => *color,
Fill::LinearGradient { angle_deg, stops } => {
if stops.is_empty() {
return Color32::TRANSPARENT;
}
if stops.len() == 1 {
return stops[0].color;
}
let angle = angle_deg.to_radians();
let center = bounds.center();
let dx = pos.x - center.x;
let dy = pos.y - center.y;
// Project onto gradient axis
let half_w = bounds.width() * 0.5;
let half_h = bounds.height() * 0.5;
let max_proj = (half_w * angle.cos().abs() + half_h * angle.sin().abs()).max(1.0);
let proj = dx * angle.cos() + dy * angle.sin();
let t = ((proj / max_proj) * 0.5 + 0.5).clamp(0.0, 1.0);
lerp_stops(stops, t)
}
Fill::RadialGradient { stops } => {
if stops.is_empty() {
return Color32::TRANSPARENT;
}
if stops.len() == 1 {
return stops[0].color;
}
let center = bounds.center();
let dx = pos.x - center.x;
let dy = pos.y - center.y;
let half_w = bounds.width() * 0.5;
let half_h = bounds.height() * 0.5;
let max_r = (half_w * half_w + half_h * half_h).sqrt().max(1.0);
let dist = (dx * dx + dy * dy).sqrt();
let t = (dist / max_r).clamp(0.0, 1.0);
lerp_stops(stops, t)
}
}
}
}
fn lerp_stops(stops: &[GradientStop], t: f32) -> Color32 {
if t <= stops[0].offset {
return stops[0].color;
}
if t >= stops[stops.len() - 1].offset {
return stops[stops.len() - 1].color;
}
for window in stops.windows(2) {
let a = &window[0];
let b = &window[1];
if t >= a.offset && t <= b.offset {
let range = b.offset - a.offset;
let local_t = if range > f32::EPSILON {
(t - a.offset) / range
} else {
0.0
};
return lerp_color(a.color, b.color, local_t);
}
}
stops[stops.len() - 1].color
}
fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
Color32::from_rgba_unmultiplied(
(a.r() as f32 + (b.r() as f32 - a.r() as f32) * t) as u8,
(a.g() as f32 + (b.g() as f32 - a.g() as f32) * t) as u8,
(a.b() as f32 + (b.b() as f32 - a.b() as f32) * t) as u8,
(a.a() as f32 + (b.a() as f32 - a.a() as f32) * t) as u8,
)
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShapeStyle { pub struct ShapeStyle {
pub fill: Option<Color32>, #[serde(deserialize_with = "deserialize_fill_compat")]
pub fill: Option<Fill>,
pub stroke_color: Color32, pub stroke_color: Color32,
pub stroke_width: f32, pub stroke_width: f32,
#[serde(default = "default_opacity")]
pub opacity: f32,
#[serde(default)]
pub rotation_degrees: f32,
#[serde(default)]
pub corner_radius: f32,
#[serde(default)]
pub font_family: Option<String>,
#[serde(default)]
pub stroke_dash: Option<(f32, f32)>,
}
fn deserialize_fill_compat<'de, D>(deserializer: D) -> Result<Option<Fill>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
use serde_json::Value;
let value: Option<Value> = Option::deserialize(deserializer)?;
match value {
None => Ok(None),
Some(Value::Array(arr)) => {
let rgba: Vec<u8> = arr
.iter()
.filter_map(|v| v.as_u64().map(|n| n as u8))
.collect();
if rgba.len() == 4 {
Ok(Some(Fill::solid(Color32::from_rgba_premultiplied(
rgba[0], rgba[1], rgba[2], rgba[3],
))))
} else {
Err(de::Error::custom("expected 4-element color array"))
}
}
Some(obj @ Value::Object(_)) => serde_json::from_value::<Fill>(obj)
.map(Some)
.map_err(de::Error::custom),
Some(other) => Err(de::Error::custom(format!(
"expected null, array, or object for fill, got: {}",
other
))),
}
} }
impl Default for ShapeStyle { impl Default for ShapeStyle {
@@ -219,10 +587,125 @@ impl Default for ShapeStyle {
fill: None, fill: None,
stroke_color: Color32::WHITE, stroke_color: Color32::WHITE,
stroke_width: 2.0, stroke_width: 2.0,
opacity: 1.0,
rotation_degrees: 0.0,
corner_radius: 0.0,
font_family: None,
stroke_dash: None,
} }
} }
} }
fn default_opacity() -> f32 {
1.0
}
pub fn polygon_vertices(
center: Pos2,
radius: f32,
sides: u32,
star_inner_ratio: Option<f32>,
) -> Vec<Pos2> {
let side_count = sides.max(3) as usize;
let start_angle = -std::f32::consts::FRAC_PI_2;
match star_inner_ratio {
Some(inner_ratio) => {
let clamped_inner = radius * inner_ratio.clamp(0.0, 1.0);
let steps = side_count * 2;
(0..steps)
.map(|i| {
let angle = start_angle + i as f32 * std::f32::consts::TAU / steps as f32;
let r = if i % 2 == 0 { radius } else { clamped_inner };
Pos2::new(center.x + r * angle.cos(), center.y + r * angle.sin())
})
.collect()
}
None => (0..side_count)
.map(|i| {
let angle = start_angle + i as f32 * std::f32::consts::TAU / side_count as f32;
Pos2::new(
center.x + radius * angle.cos(),
center.y + radius * angle.sin(),
)
})
.collect(),
}
}
fn children_bounding_rect(children: &[DrawingElement]) -> egui::Rect {
if children.is_empty() {
return egui::Rect::from_min_size(Pos2::ZERO, egui::Vec2::ZERO);
}
let mut result = children[0].bounding_rect();
for child in children.iter().skip(1) {
result = result.union(child.bounding_rect());
}
result
}
pub fn arrow_midpoint(start: Pos2, end: Pos2) -> Pos2 {
Pos2::new((start.x + end.x) * 0.5, (start.y + end.y) * 0.5)
}
pub fn arrow_handle_position(start: Pos2, end: Pos2, control_offset: Option<Vec2>) -> Pos2 {
arrow_midpoint(start, end) + control_offset.unwrap_or(Vec2::ZERO)
}
pub fn arrow_control_point(start: Pos2, end: Pos2, control_offset: Option<Vec2>) -> Option<Pos2> {
control_offset.map(|offset| arrow_midpoint(start, end) + offset)
}
pub fn quadratic_bezier_point(p0: Pos2, p1: Pos2, p2: Pos2, t: f32) -> Pos2 {
let inv = 1.0 - t;
Pos2::new(
inv * inv * p0.x + 2.0 * inv * t * p1.x + t * t * p2.x,
inv * inv * p0.y + 2.0 * inv * t * p1.y + t * t * p2.y,
)
}
pub fn quadratic_bezier_end_tangent(start: Pos2, end: Pos2, control_offset: Option<Vec2>) -> Vec2 {
if let Some(control) = arrow_control_point(start, end, control_offset) {
(end - control) * 2.0
} else {
end - start
}
}
pub fn arrow_curve_points(
start: Pos2,
end: Pos2,
control_offset: Option<Vec2>,
segments: usize,
) -> Vec<Pos2> {
if let Some(control) = arrow_control_point(start, end, control_offset) {
let steps = segments.max(1);
(0..=steps)
.map(|index| {
let t = index as f32 / steps as f32;
quadratic_bezier_point(start, control, end, t)
})
.collect()
} else {
vec![start, end]
}
}
fn point_to_bezier_distance(point: Pos2, p0: Pos2, p1: Pos2, p2: Pos2, samples: usize) -> f32 {
let steps = samples.max(1);
let mut min_distance = f32::INFINITY;
let mut previous = p0;
for index in 1..=steps {
let t = index as f32 / steps as f32;
let current = quadratic_bezier_point(p0, p1, p2, t);
min_distance = min_distance.min(point_to_segment_distance(point, previous, current));
previous = current;
}
min_distance
}
/// Distance from point `p` to segment `a``b`. /// Distance from point `p` to segment `a``b`.
fn point_to_segment_distance(p: Pos2, a: Pos2, b: Pos2) -> f32 { fn point_to_segment_distance(p: Pos2, a: Pos2, b: Pos2) -> f32 {
let ab = b - a; let ab = b - a;
@@ -272,6 +755,46 @@ 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) (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)
} }
fn rect_from_points(points: &[Pos2]) -> Option<egui::Rect> {
if points.is_empty() {
return None;
}
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 point in points {
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() {
Some(egui::Rect::from_min_max(
Pos2::new(min_x, min_y),
Pos2::new(max_x, max_y),
))
} else {
None
}
}
fn estimate_text_bounds(content: &str, font_size: f32, max_width: Option<f32>) -> (f32, f32) {
let char_count = content.chars().count().max(1) as f32;
let text_width = char_count * font_size * 0.6;
if let Some(max_width) = max_width {
let wrapped_width = max_width.max(font_size);
let line_count = (text_width / wrapped_width).ceil().max(1.0);
(wrapped_width, line_count * font_size * 1.4)
} else {
(text_width.max(font_size), font_size * 1.4)
}
}
fn map_point_to_rect( fn map_point_to_rect(
point: Pos2, point: Pos2,
old_rect: egui::Rect, old_rect: egui::Rect,
@@ -296,6 +819,86 @@ fn map_point_to_rect(
) )
} }
pub fn element_label(element: &DrawingElement) -> String {
match &element.shape {
Shape::Rectangle { size, .. } => {
format!("Rect ({}x{})", size.x as i32, size.y as i32)
}
Shape::Ellipse { radii, .. } => {
format!(
"Ellipse ({}x{})",
(radii.x * 2.0) as i32,
(radii.y * 2.0) as i32
)
}
Shape::Line { .. } => "Line".to_string(),
Shape::Arrow { .. } => "Arrow".to_string(),
Shape::Polygon {
sides,
star_inner_ratio,
..
} => {
if star_inner_ratio.is_some() {
format!("Star ({}-point)", sides)
} else {
format!("Polygon ({}-sided)", sides)
}
}
Shape::Text { content, .. } => {
let display = if content.len() > 20 {
format!("{}...", &content[..17])
} else {
content.clone()
};
format!("Text '{}'", display)
}
Shape::Path { polygons } => {
format!("Path ({} sub-paths)", polygons.len())
}
Shape::SvgImage {
size, svg_source, ..
} => {
if svg_source.is_empty() {
format!("Image ({}x{})", size.x as i32, size.y as i32)
} else {
format!("SVG Image ({}x{})", size.x as i32, size.y as i32)
}
}
Shape::Group => format!("Group ({} children)", element.children.len()),
}
}
pub fn describe_elements(elements: &[DrawingElement]) -> String {
let mut desc = String::new();
desc.push_str(&format!(
"Canvas: {} top-level elements\n\n",
elements.len()
));
for element in elements {
describe_element_recursive(&mut desc, element, 0);
}
desc
}
fn describe_element_recursive(desc: &mut String, element: &DrawingElement, depth: usize) {
let indent = " ".repeat(depth);
let label = element_label(element);
let style_info = match &element.style.fill {
Some(fill) => {
let c = fill.primary_color();
format!(" fill=#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b())
}
None => String::new(),
};
desc.push_str(&format!("{}- {}{}\n", indent, label, style_info));
for child in &element.children {
describe_element_recursive(desc, child, depth + 1);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -379,4 +982,26 @@ mod tests {
); );
assert!((dist - 5.0).abs() < 0.01); assert!((dist - 5.0).abs() < 0.01);
} }
#[test]
fn fill_deserializes_from_legacy_color_array() {
let json = r#"{"fill":[255,0,0,255],"stroke_color":[255,255,255,255],"stroke_width":2.0,"opacity":1.0}"#;
let style: ShapeStyle = serde_json::from_str(json).expect("should deserialize old format");
assert!(matches!(style.fill, Some(Fill::Solid { .. })));
}
#[test]
fn fill_deserializes_from_new_tagged_enum() {
let json = r#"{"fill":{"type":"Solid","color":[0,128,255,255]},"stroke_color":[255,255,255,255],"stroke_width":2.0,"opacity":1.0}"#;
let style: ShapeStyle = serde_json::from_str(json).expect("should deserialize new format");
assert!(matches!(style.fill, Some(Fill::Solid { .. })));
}
#[test]
fn fill_deserializes_null() {
let json =
r#"{"fill":null,"stroke_color":[255,255,255,255],"stroke_width":2.0,"opacity":1.0}"#;
let style: ShapeStyle = serde_json::from_str(json).expect("should deserialize null fill");
assert!(style.fill.is_none());
}
} }

View File

@@ -4,9 +4,14 @@ mod render;
mod tool; mod tool;
pub use boolean::BooleanOpType; pub use boolean::BooleanOpType;
pub use element::{DrawingElement, Shape, ShapeStyle}; pub use element::{
arrow_control_point, arrow_curve_points, arrow_midpoint, describe_elements, element_label,
generate_drawing_id, polygon_vertices, quadratic_bezier_end_tangent, DrawingElement, Fill,
GradientStop, PathPolygon, Shape, ShapeStyle, ARROW_CURVE_SEGMENTS,
};
pub use render::{ pub use render::{
draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos, draw_arrow_control_handle, draw_creation_preview, draw_elements, draw_marquee, draw_selection,
screen_to_canvas, draw_tool_icon, draw_vertex_handles, find_arrow_control_handle_at_screen_pos,
find_handle_at_screen_pos, screen_to_canvas,
}; };
pub use tool::{DragState, Tool}; pub use tool::{DragState, Tool};

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,13 @@ use serde::{Deserialize, Serialize};
pub enum Tool { pub enum Tool {
#[default] #[default]
Select, Select,
DirectSelect,
Pan, Pan,
Rectangle, Rectangle,
Ellipse, Ellipse,
Line, Line,
Arrow, Arrow,
Polygon,
Text, Text,
} }
@@ -17,11 +19,13 @@ impl Tool {
pub fn label(&self) -> &'static str { pub fn label(&self) -> &'static str {
match self { match self {
Tool::Select => "Select", Tool::Select => "Select",
Tool::DirectSelect => "Direct",
Tool::Pan => "Pan", Tool::Pan => "Pan",
Tool::Rectangle => "Rect", Tool::Rectangle => "Rect",
Tool::Ellipse => "Ellipse", Tool::Ellipse => "Ellipse",
Tool::Line => "Line", Tool::Line => "Line",
Tool::Arrow => "Arrow", Tool::Arrow => "Arrow",
Tool::Polygon => "Polygon",
Tool::Text => "Text", Tool::Text => "Text",
} }
} }
@@ -29,11 +33,13 @@ impl Tool {
pub fn shortcut(&self) -> Option<char> { pub fn shortcut(&self) -> Option<char> {
match self { match self {
Tool::Select => Some('V'), Tool::Select => Some('V'),
Tool::DirectSelect => Some('D'),
Tool::Pan => Some('H'), Tool::Pan => Some('H'),
Tool::Rectangle => Some('R'), Tool::Rectangle => Some('R'),
Tool::Ellipse => Some('E'), Tool::Ellipse => Some('E'),
Tool::Line => Some('L'), Tool::Line => Some('L'),
Tool::Arrow => Some('A'), Tool::Arrow => Some('A'),
Tool::Polygon => Some('P'),
Tool::Text => Some('T'), Tool::Text => Some('T'),
} }
} }
@@ -59,6 +65,15 @@ pub enum DragState {
element_id: String, element_id: String,
original_rect: egui::Rect, original_rect: egui::Rect,
}, },
VertexDrag {
element_id: String,
polygon_idx: usize,
vertex_idx: usize,
is_hole: bool,
},
ArrowControlDrag {
element_id: String,
},
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@@ -1,6 +1,9 @@
use std::path::Path; use std::path::Path;
use crate::drawing::{DrawingElement, Shape, ShapeStyle}; use crate::drawing::{
arrow_curve_points, polygon_vertices, quadratic_bezier_end_tangent, DrawingElement, Shape,
ShapeStyle, ARROW_CURVE_SEGMENTS,
};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform};
@@ -181,18 +184,51 @@ fn render_drawing_elements(
stroke_path(pixmap, &path, &element.style, scale); stroke_path(pixmap, &path, &element.style, scale);
} }
} }
Shape::Arrow { start, end } => { Shape::Arrow {
if let Some(path) = build_line_path(*start, *end, min_x, min_y, scale) { start,
end,
control_offset,
} => {
let curve_points =
arrow_curve_points(*start, *end, *control_offset, ARROW_CURVE_SEGMENTS);
if let Some(path) = build_polyline_path(&curve_points, min_x, min_y, scale) {
stroke_path(pixmap, &path, &element.style, scale); stroke_path(pixmap, &path, &element.style, scale);
} }
if let Some(arrowhead) = build_arrowhead_path(*start, *end, min_x, min_y, scale) { let direction = quadratic_bezier_end_tangent(*start, *end, *control_offset);
if let Some(arrowhead) = build_arrowhead_path(*end, direction, min_x, min_y, scale)
{
stroke_path(pixmap, &arrowhead, &element.style, scale); stroke_path(pixmap, &arrowhead, &element.style, scale);
} }
} }
Shape::Polygon {
center,
radius,
sides,
star_inner_ratio,
} => {
let points = polygon_vertices(*center, *radius, *sides, *star_inner_ratio);
if points.len() < 3 {
continue;
}
let mut pb = PathBuilder::new();
if let Some(first) = points.first() {
pb.move_to((first.x - min_x) * scale, (first.y - min_y) * scale);
for point in points.iter().skip(1) {
pb.line_to((point.x - min_x) * scale, (point.y - min_y) * scale);
}
pb.close();
}
if let Some(path) = pb.finish() {
fill_and_stroke_path(pixmap, &path, &element.style, scale);
}
}
Shape::Text { Shape::Text {
pos, pos,
content, content,
font_size, font_size,
max_width,
} => { } => {
render_text_element( render_text_element(
pixmap, pixmap,
@@ -200,6 +236,7 @@ fn render_drawing_elements(
(pos.x - min_x) * scale, (pos.x - min_x) * scale,
(pos.y - min_y) * scale, (pos.y - min_y) * scale,
*font_size * scale, *font_size * scale,
max_width.map(|w| w * scale),
element.style.stroke_color, element.style.stroke_color,
)?; )?;
} }
@@ -216,6 +253,10 @@ fn render_drawing_elements(
fill_and_stroke_path_even_odd(pixmap, &path, &element.style, scale); fill_and_stroke_path_even_odd(pixmap, &path, &element.style, scale);
} }
} }
Shape::SvgImage { .. } => {}
Shape::Group => {
render_drawing_elements(pixmap, &element.children, min_x, min_y, scale)?;
}
} }
} }
@@ -228,9 +269,9 @@ fn fill_and_stroke_path(
style: &ShapeStyle, style: &ShapeStyle,
scale: f32, scale: f32,
) { ) {
if let Some(fill) = style.fill { if let Some(fill) = &style.fill {
let mut fill_paint = Paint::default(); let mut fill_paint = Paint::default();
fill_paint.set_color(egui_to_skia_color(fill)); fill_paint.set_color(egui_to_skia_color(fill.primary_color()));
pixmap.fill_path( pixmap.fill_path(
path, path,
&fill_paint, &fill_paint,
@@ -249,9 +290,9 @@ fn fill_and_stroke_path_even_odd(
style: &ShapeStyle, style: &ShapeStyle,
scale: f32, scale: f32,
) { ) {
if let Some(fill) = style.fill { if let Some(fill) = &style.fill {
let mut fill_paint = Paint::default(); let mut fill_paint = Paint::default();
fill_paint.set_color(egui_to_skia_color(fill)); fill_paint.set_color(egui_to_skia_color(fill.primary_color()));
pixmap.fill_path( pixmap.fill_path(
path, path,
&fill_paint, &fill_paint,
@@ -296,23 +337,39 @@ fn build_line_path(
pb.finish() pb.finish()
} }
fn build_arrowhead_path( fn build_polyline_path(
start: egui::Pos2, points: &[egui::Pos2],
end: egui::Pos2,
min_x: f32, min_x: f32,
min_y: f32, min_y: f32,
scale: f32, scale: f32,
) -> Option<tiny_skia::Path> { ) -> Option<tiny_skia::Path> {
let dx = end.x - start.x; if points.len() < 2 {
let dy = end.y - start.y; return None;
let len = (dx * dx + dy * dy).sqrt(); }
let mut pb = PathBuilder::new();
pb.move_to((points[0].x - min_x) * scale, (points[0].y - min_y) * scale);
for point in points.iter().skip(1) {
pb.line_to((point.x - min_x) * scale, (point.y - min_y) * scale);
}
pb.finish()
}
fn build_arrowhead_path(
end: egui::Pos2,
direction: egui::Vec2,
min_x: f32,
min_y: f32,
scale: f32,
) -> Option<tiny_skia::Path> {
let len = direction.length();
if len < 1e-6 { if len < 1e-6 {
return None; return None;
} }
let dir_x = dx / len; let dir_x = direction.x / len;
let dir_y = dy / len; let dir_y = direction.y / len;
let perp_x = -dir_y; let perp_x = -dir_y;
let perp_y = dir_x; let perp_y = dir_x;
let arrow_size = 12.0; let arrow_size = 12.0;
@@ -361,27 +418,47 @@ fn render_text_element(
x: f32, x: f32,
y: f32, y: f32,
font_size: f32, font_size: f32,
max_width: Option<f32>,
color: egui::Color32, color: egui::Color32,
) -> Result<()> { ) -> Result<()> {
if text.is_empty() || font_size <= 0.0 { if text.is_empty() || font_size <= 0.0 {
return Ok(()); return Ok(());
} }
let escaped = escape_xml(text); let line_height = font_size * 1.4;
let approx_width = (font_size * text.chars().count() as f32 * 0.7).max(font_size); let lines = wrap_text_lines(text, font_size, max_width);
let approx_height = (font_size * 1.6).max(font_size); let wrapped_width = lines
.iter()
.map(|line| approximate_text_width(line, font_size))
.fold(0.0_f32, f32::max)
.max(font_size);
let approx_width = max_width.unwrap_or(wrapped_width).max(font_size);
let approx_height =
(font_size + (lines.len().saturating_sub(1) as f32 * line_height)).max(font_size);
let opacity = color.a() as f32 / 255.0; let opacity = color.a() as f32 / 255.0;
let text_body = if lines.len() <= 1 {
escape_xml(lines.first().map_or(text, String::as_str))
} else {
lines
.iter()
.enumerate()
.map(|(index, line)| {
let dy = if index == 0 { font_size } else { line_height };
format!(r#"<tspan x="0" dy="{dy}">{}</tspan>"#, escape_xml(line))
})
.collect::<Vec<_>>()
.join("")
};
let svg_text = format!( let svg_text = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}"><text x="0" y="{baseline}" font-family="Inter, system-ui, -apple-system, sans-serif" font-size="{size}" fill="rgb({r},{g},{b})" fill-opacity="{opacity}">{text}</text></svg>"#, r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}"><text x="0" y="0" font-family="Inter, system-ui, -apple-system, sans-serif" font-size="{size}" fill="rgb({r},{g},{b})" fill-opacity="{opacity}">{text}</text></svg>"#,
w = approx_width, w = approx_width,
h = approx_height, h = approx_height,
baseline = font_size,
size = font_size, size = font_size,
r = color.r(), r = color.r(),
g = color.g(), g = color.g(),
b = color.b(), b = color.b(),
opacity = opacity, opacity = opacity,
text = escaped, text = text_body,
); );
let tree = parse_svg(&svg_text)?; let tree = parse_svg(&svg_text)?;
@@ -390,6 +467,48 @@ fn render_text_element(
Ok(()) Ok(())
} }
fn wrap_text_lines(text: &str, font_size: f32, max_width: Option<f32>) -> Vec<String> {
let Some(max_width) = max_width else {
return vec![text.to_owned()];
};
if max_width <= 0.0 {
return vec![text.to_owned()];
}
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
let candidate = if current.is_empty() {
word.to_owned()
} else {
format!("{} {}", current, word)
};
if approximate_text_width(&candidate, font_size) <= max_width || current.is_empty() {
current = candidate;
} else {
lines.push(current);
current = word.to_owned();
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
vec![text.to_owned()]
} else {
lines
}
}
fn approximate_text_width(text: &str, font_size: f32) -> f32 {
text.chars().count() as f32 * font_size * 0.6
}
fn parse_svg(svg_source: &str) -> Result<usvg::Tree> { fn parse_svg(svg_source: &str) -> Result<usvg::Tree> {
let mut options = usvg::Options::default(); let mut options = usvg::Options::default();
options.fontdb_mut().load_system_fonts(); options.fontdb_mut().load_system_fonts();

View File

@@ -11,10 +11,34 @@ mod mermaid;
mod persistence; mod persistence;
mod session; mod session;
mod svg; mod svg;
mod theme;
pub use theme::CanvasTheme;
use anyhow::Result; use anyhow::Result;
use fs2::FileExt;
use std::fs::{self, File};
use std::path::PathBuf;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
fn lock_file_path() -> Result<PathBuf> {
let base = dirs::data_dir()
.or_else(dirs::home_dir)
.ok_or_else(|| anyhow::anyhow!("Cannot determine data directory"))?;
let dir = base.join("agcanvas");
fs::create_dir_all(&dir)?;
Ok(dir.join("agcanvas.lock"))
}
fn acquire_singleton_lock() -> Result<Option<File>> {
let path = lock_file_path()?;
let file = File::create(&path)?;
match file.try_lock_exclusive() {
Ok(()) => Ok(Some(file)),
Err(_) => Ok(None),
}
}
fn main() -> Result<()> { fn main() -> Result<()> {
tracing_subscriber::registry() tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new( .with(tracing_subscriber::EnvFilter::new(
@@ -23,6 +47,19 @@ fn main() -> Result<()> {
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
let _lock = match acquire_singleton_lock() {
Ok(Some(lock)) => Some(lock),
Ok(None) => {
tracing::warn!("Another instance of Augmented Canvas is already running");
eprintln!("Augmented Canvas is already running. Only one instance is allowed.");
std::process::exit(0);
}
Err(e) => {
tracing::warn!("Could not acquire singleton lock: {}", e);
None
}
};
let native_options = eframe::NativeOptions { let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_inner_size([1400.0, 900.0]) .with_inner_size([1400.0, 900.0])

View File

@@ -1,6 +1,7 @@
use crate::canvas::CanvasState; use crate::canvas::CanvasState;
use crate::drawing::DrawingElement; use crate::drawing::DrawingElement;
use crate::session::SessionCreator; use crate::session::SessionCreator;
use crate::theme::CanvasTheme;
use anyhow::Result; use anyhow::Result;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
@@ -22,6 +23,10 @@ pub struct SavedWorkspace {
pub version: u32, pub version: u32,
pub active_session_idx: usize, pub active_session_idx: usize,
pub session_counter: usize, pub session_counter: usize,
#[serde(default)]
pub group_counter: usize,
#[serde(default)]
pub theme: CanvasTheme,
pub sessions: Vec<SavedSession>, pub sessions: Vec<SavedSession>,
} }
@@ -31,12 +36,16 @@ impl SavedWorkspace {
pub fn new( pub fn new(
active_session_idx: usize, active_session_idx: usize,
session_counter: usize, session_counter: usize,
group_counter: usize,
theme: CanvasTheme,
sessions: Vec<SavedSession>, sessions: Vec<SavedSession>,
) -> Self { ) -> Self {
Self { Self {
version: Self::CURRENT_VERSION, version: Self::CURRENT_VERSION,
active_session_idx, active_session_idx,
session_counter, session_counter,
group_counter,
theme,
sessions, sessions,
} }
} }
@@ -95,12 +104,14 @@ mod tests {
#[test] #[test]
fn round_trip_empty_workspace() { fn round_trip_empty_workspace() {
let workspace = SavedWorkspace::new(0, 1, Vec::new()); let workspace = SavedWorkspace::new(0, 1, 0, CanvasTheme::default(), Vec::new());
let json = serde_json::to_string(&workspace).unwrap(); let json = serde_json::to_string(&workspace).unwrap();
let loaded: SavedWorkspace = serde_json::from_str(&json).unwrap(); let loaded: SavedWorkspace = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.version, 1); assert_eq!(loaded.version, 1);
assert_eq!(loaded.sessions.len(), 0); assert_eq!(loaded.sessions.len(), 0);
assert_eq!(loaded.session_counter, 1); assert_eq!(loaded.session_counter, 1);
assert_eq!(loaded.group_counter, 0);
assert_eq!(loaded.theme, CanvasTheme::Dark);
} }
#[test] #[test]
@@ -115,11 +126,12 @@ mod tests {
created_by: SessionCreator::Human, created_by: SessionCreator::Human,
created_at: 1234567890, created_at: 1234567890,
}; };
let workspace = SavedWorkspace::new(0, 1, vec![session]); let workspace = SavedWorkspace::new(0, 1, 0, CanvasTheme::Light, vec![session]);
let json = serde_json::to_string(&workspace).unwrap(); let json = serde_json::to_string(&workspace).unwrap();
let loaded: SavedWorkspace = serde_json::from_str(&json).unwrap(); let loaded: SavedWorkspace = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.sessions.len(), 1); assert_eq!(loaded.sessions.len(), 1);
assert_eq!(loaded.sessions[0].name, "Test"); assert_eq!(loaded.sessions[0].name, "Test");
assert_eq!(loaded.theme, CanvasTheme::Light);
assert_eq!( assert_eq!(
loaded.sessions[0].svg_source.as_deref(), loaded.sessions[0].svg_source.as_deref(),
Some("<svg></svg>") Some("<svg></svg>")

View File

@@ -2,8 +2,6 @@ use crate::canvas::CanvasState;
use crate::drawing::{DragState, DrawingElement, Tool}; use crate::drawing::{DragState, DrawingElement, Tool};
use crate::element_tree::ElementTree; use crate::element_tree::ElementTree;
use crate::history::{ChangeSource, DocumentSnapshot, HistoryTree, NodeId}; use crate::history::{ChangeSource, DocumentSnapshot, HistoryTree, NodeId};
use crate::svg::SvgRenderer;
use egui::TextureHandle;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -81,17 +79,19 @@ pub struct Session {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub canvas_state: CanvasState, pub canvas_state: CanvasState,
pub svg_renderer: Option<SvgRenderer>,
pub svg_texture: Option<TextureHandle>,
pub element_tree: Option<ElementTree>, pub element_tree: Option<ElementTree>,
pub svg_source: Option<String>, pub svg_source: Option<String>,
pub description_text: String,
pub svg_textures: HashMap<String, egui::TextureHandle>,
pub drawing_elements: Vec<DrawingElement>, pub drawing_elements: Vec<DrawingElement>,
pub selected_element_ids: Vec<String>, pub selected_element_ids: Vec<String>,
pub selected_vertex: Option<(String, usize, usize, bool)>,
pub active_tool: Tool, pub active_tool: Tool,
pub drag_state: DragState, pub drag_state: DragState,
pub history: HistoryTree, pub history: HistoryTree,
pub polygon_sides: u32,
pub polygon_star_ratio: Option<f32>,
pub description: Option<String>, pub description: Option<String>,
pub created_by: SessionCreator, pub created_by: SessionCreator,
@@ -104,16 +104,17 @@ impl Session {
id, id,
name, name,
canvas_state: CanvasState::default(), canvas_state: CanvasState::default(),
svg_renderer: None,
svg_texture: None,
element_tree: None, element_tree: None,
svg_source: None, svg_source: None,
description_text: String::new(), svg_textures: HashMap::new(),
drawing_elements: Vec::new(), drawing_elements: Vec::new(),
selected_element_ids: Vec::new(), selected_element_ids: Vec::new(),
selected_vertex: None,
active_tool: Tool::default(), active_tool: Tool::default(),
drag_state: DragState::default(), drag_state: DragState::default(),
history: HistoryTree::new(DocumentSnapshot::new_empty()), history: HistoryTree::new(DocumentSnapshot::new_empty()),
polygon_sides: 6,
polygon_star_ratio: None,
description: None, description: None,
created_by, created_by,
created_at: unix_now(), created_at: unix_now(),
@@ -139,13 +140,13 @@ impl Session {
} }
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.svg_renderer = None;
self.svg_texture = None;
self.element_tree = None; self.element_tree = None;
self.svg_source = None; self.svg_source = None;
self.description_text.clear();
self.svg_textures.clear();
self.drawing_elements.clear(); self.drawing_elements.clear();
self.selected_element_ids.clear(); self.selected_element_ids.clear();
self.selected_vertex = None;
self.drag_state = DragState::default(); self.drag_state = DragState::default();
self.canvas_state.reset(); self.canvas_state.reset();
} }
@@ -173,9 +174,13 @@ impl Session {
pub fn delete_selected(&mut self) { pub fn delete_selected(&mut self) {
if !self.selected_element_ids.is_empty() { if !self.selected_element_ids.is_empty() {
let selected_ids = self.selected_element_ids.clone(); let selected_ids = self.selected_element_ids.clone();
for id in &selected_ids {
self.svg_textures.remove(id);
}
self.drawing_elements self.drawing_elements
.retain(|e| !selected_ids.contains(&e.id)); .retain(|e| !selected_ids.contains(&e.id));
self.selected_element_ids.clear(); self.selected_element_ids.clear();
self.selected_vertex = None;
} }
} }
@@ -190,10 +195,9 @@ impl Session {
self.drawing_elements = (*snapshot.drawing_elements).clone(); self.drawing_elements = (*snapshot.drawing_elements).clone();
self.svg_source = snapshot.svg_source.map(|s| s.to_string()); self.svg_source = snapshot.svg_source.map(|s| s.to_string());
self.element_tree = None; self.element_tree = None;
self.svg_renderer = None;
self.svg_texture = None;
self.description_text.clear();
self.selected_element_ids.clear(); self.selected_element_ids.clear();
self.selected_vertex = None;
self.drag_state = DragState::default(); self.drag_state = DragState::default();
} }
@@ -202,10 +206,9 @@ impl Session {
self.drawing_elements = (*snapshot.drawing_elements).clone(); self.drawing_elements = (*snapshot.drawing_elements).clone();
self.svg_source = snapshot.svg_source.map(|s| s.to_string()); self.svg_source = snapshot.svg_source.map(|s| s.to_string());
self.element_tree = None; self.element_tree = None;
self.svg_renderer = None;
self.svg_texture = None;
self.description_text.clear();
self.selected_element_ids.clear(); self.selected_element_ids.clear();
self.selected_vertex = None;
self.drag_state = DragState::default(); self.drag_state = DragState::default();
true true
} else { } else {
@@ -218,10 +221,9 @@ impl Session {
self.drawing_elements = (*snapshot.drawing_elements).clone(); self.drawing_elements = (*snapshot.drawing_elements).clone();
self.svg_source = snapshot.svg_source.map(|s| s.to_string()); self.svg_source = snapshot.svg_source.map(|s| s.to_string());
self.element_tree = None; self.element_tree = None;
self.svg_renderer = None;
self.svg_texture = None;
self.description_text.clear();
self.selected_element_ids.clear(); self.selected_element_ids.clear();
self.selected_vertex = None;
self.drag_state = DragState::default(); self.drag_state = DragState::default();
true true
} else { } else {
@@ -240,11 +242,30 @@ pub struct SessionData {
pub type ExportSessionData = (String, Option<String>, Vec<DrawingElement>); pub type ExportSessionData = (String, Option<String>, Vec<DrawingElement>);
/// Snapshot of app-level state for agent inspection.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AppStateSnapshot {
pub active_tool: String,
pub selected_element_ids: Vec<String>,
pub zoom: f32,
pub pan_offset_x: f32,
pub pan_offset_y: f32,
pub theme: String,
pub show_tree_panel: bool,
pub show_description_panel: bool,
pub show_history_panel: bool,
pub session_name: String,
pub element_count: usize,
pub canvas_width: f32,
pub canvas_height: f32,
}
#[derive(Default)] #[derive(Default)]
pub struct SessionStore { pub struct SessionStore {
sessions: HashMap<String, SessionData>, sessions: HashMap<String, SessionData>,
active_session_id: Option<String>, active_session_id: Option<String>,
session_counter: usize, session_counter: usize,
app_state: Option<AppStateSnapshot>,
} }
impl SessionStore { impl SessionStore {
@@ -467,4 +488,12 @@ impl SessionStore {
pub fn active_session_id(&self) -> Option<&str> { pub fn active_session_id(&self) -> Option<&str> {
self.active_session_id.as_deref() self.active_session_id.as_deref()
} }
pub fn set_app_state(&mut self, state: AppStateSnapshot) {
self.app_state = Some(state);
}
pub fn get_app_state(&self) -> Option<&AppStateSnapshot> {
self.app_state.as_ref()
}
} }

View File

@@ -2,10 +2,73 @@ use anyhow::Result;
use egui::{Color32, Pos2, Vec2}; use egui::{Color32, Pos2, Vec2};
use usvg::tiny_skia_path::PathSegment; use usvg::tiny_skia_path::PathSegment;
use crate::drawing::{DrawingElement, Shape, ShapeStyle}; use crate::drawing::{DrawingElement, Fill, GradientStop, PathPolygon, Shape, ShapeStyle};
#[derive(Clone, Copy, Debug)]
struct AffineTransform {
sx: f32,
kx: f32,
ky: f32,
sy: f32,
tx: f32,
ty: f32,
}
impl AffineTransform {
fn identity() -> Self {
Self {
sx: 1.0,
kx: 0.0,
ky: 0.0,
sy: 1.0,
tx: 0.0,
ty: 0.0,
}
}
fn from_translate(tx: f32, ty: f32) -> Self {
Self {
tx,
ty,
..Self::identity()
}
}
fn from_usvg(transform: usvg::Transform) -> Self {
Self {
sx: transform.sx,
kx: transform.kx,
ky: transform.ky,
sy: transform.sy,
tx: transform.tx,
ty: transform.ty,
}
}
fn concat(self, other: Self) -> Self {
Self {
sx: self.sx * other.sx + self.kx * other.ky,
kx: self.sx * other.kx + self.kx * other.sy,
ky: self.ky * other.sx + self.sy * other.ky,
sy: self.ky * other.kx + self.sy * other.sy,
tx: self.sx * other.tx + self.kx * other.ty + self.tx,
ty: self.ky * other.tx + self.sy * other.ty + self.ty,
}
}
fn apply(self, x: f32, y: f32) -> Pos2 {
Pos2::new(
self.sx * x + self.kx * y + self.tx,
self.ky * x + self.sy * y + self.ty,
)
}
fn is_axis_aligned(self) -> bool {
self.kx.abs() <= f32::EPSILON && self.ky.abs() <= f32::EPSILON
}
}
/// Convert an SVG string into editable DrawingElements. /// Convert an SVG string into editable DrawingElements.
/// `offset_x` and `offset_y` shift all elements (for positioning on canvas).
pub fn svg_to_drawing_elements( pub fn svg_to_drawing_elements(
svg_source: &str, svg_source: &str,
offset_x: f32, offset_x: f32,
@@ -16,39 +79,68 @@ pub fn svg_to_drawing_elements(
let tree = usvg::Tree::from_str(svg_source, &options)?; let tree = usvg::Tree::from_str(svg_source, &options)?;
let mut elements = Vec::new(); let mut elements = Vec::new();
walk_group(tree.root(), offset_x, offset_y, &mut elements); let base_transform = AffineTransform::from_translate(offset_x, offset_y);
walk_group(tree.root(), base_transform, &mut elements);
tracing::info!(
"svg_to_drawing_elements: produced {} drawing elements from SVG ({}x{})",
elements.len(),
tree.size().width(),
tree.size().height()
);
Ok(elements) Ok(elements)
} }
fn walk_group(group: &usvg::Group, ox: f32, oy: f32, elements: &mut Vec<DrawingElement>) { fn walk_group(group: &usvg::Group, transform: AffineTransform, elements: &mut Vec<DrawingElement>) {
let (gx, gy) = extract_group_translate(group); let next_transform = transform.concat(AffineTransform::from_usvg(group.transform()));
let next_ox = ox + gx; let child_count = group.children().len();
let next_oy = oy + gy;
tracing::debug!(
"walk_group: {} children, transform: ({:.1},{:.1},{:.1},{:.1},{:.1},{:.1})",
child_count,
next_transform.sx,
next_transform.kx,
next_transform.ky,
next_transform.sy,
next_transform.tx,
next_transform.ty,
);
let start_idx = elements.len();
for node in group.children() { for node in group.children() {
match node { match node {
usvg::Node::Group(g) => walk_group(g, next_ox, next_oy, elements), usvg::Node::Group(g) => walk_group(g, next_transform, elements),
usvg::Node::Path(path) => { usvg::Node::Path(path) => {
if let Some(element) = convert_path(path, next_ox, next_oy) { let before = elements.len();
if let Some(element) = convert_path(path, next_transform) {
elements.push(element); elements.push(element);
} }
if elements.len() == before {
let bbox = path.bounding_box();
tracing::debug!(
" SKIP path: bbox=({:.1},{:.1} {:.1}x{:.1}) fill={} stroke={}",
bbox.left(),
bbox.top(),
bbox.width(),
bbox.height(),
path.fill().is_some(),
path.stroke().is_some(),
);
}
} }
usvg::Node::Text(text) => { usvg::Node::Text(text) => {
if let Some(element) = convert_text(text, next_ox, next_oy) { if let Some(element) = convert_text(text, next_transform) {
elements.push(element); elements.push(element);
} }
} }
usvg::Node::Image(_) => {} usvg::Node::Image(_) => {}
} }
} }
assign_text_max_widths(elements, start_idx);
} }
fn extract_group_translate(group: &usvg::Group) -> (f32, f32) { fn convert_path(path: &usvg::Path, transform: AffineTransform) -> Option<DrawingElement> {
let t = group.transform();
(t.tx, t.ty)
}
fn convert_path(path: &usvg::Path, ox: f32, oy: f32) -> Option<DrawingElement> {
let bbox = path.bounding_box(); let bbox = path.bounding_box();
// Skip degenerate paths (zero area AND zero length — truly empty) // Skip degenerate paths (zero area AND zero length — truly empty)
if bbox.width() <= 0.0 && bbox.height() <= 0.0 { if bbox.width() <= 0.0 && bbox.height() <= 0.0 {
@@ -71,73 +163,133 @@ fn convert_path(path: &usvg::Path, ox: f32, oy: f32) -> Option<DrawingElement> {
let style = extract_style(path); let style = extract_style(path);
if is_rect_path(&segments) { if transform.is_axis_aligned() && is_rect_path(&segments) {
return Some(DrawingElement::new( let (pos, size) = transformed_rect_from_bbox(bbox, transform);
Shape::Rectangle { return Some(DrawingElement::new(Shape::Rectangle { pos, size }, style));
pos: Pos2::new(bbox.left() + ox, bbox.top() + oy),
size: Vec2::new(bbox.width(), bbox.height()),
},
style,
));
} }
if is_line_path(&segments) { if is_line_path(&segments) {
let (start, end) = extract_line_endpoints(&segments)?; let (start, end) = extract_line_endpoints(&segments)?;
let start = transform.apply(start.0, start.1);
let end = transform.apply(end.0, end.1);
let shape = if is_stroke_only(path) { let shape = if is_stroke_only(path) {
Shape::Arrow { Shape::Arrow {
start: Pos2::new(start.0 + ox, start.1 + oy), start,
end: Pos2::new(end.0 + ox, end.1 + oy), end,
control_offset: None,
} }
} else { } else {
Shape::Line { Shape::Line { start, end }
start: Pos2::new(start.0 + ox, start.1 + oy),
end: Pos2::new(end.0 + ox, end.1 + oy),
}
}; };
return Some(DrawingElement::new(shape, style)); return Some(DrawingElement::new(shape, style));
} }
let aspect = bbox.width() / bbox.height().max(0.001); let aspect = bbox.width() / bbox.height().max(0.001);
if (0.9..=1.1).contains(&aspect) && has_curves(&segments) { if transform.is_axis_aligned() && (0.9..=1.1).contains(&aspect) && has_curves(&segments) {
let (pos, size) = transformed_rect_from_bbox(bbox, transform);
return Some(DrawingElement::new( return Some(DrawingElement::new(
Shape::Ellipse { Shape::Ellipse {
center: Pos2::new( center: Pos2::new(pos.x + size.x * 0.5, pos.y + size.y * 0.5),
bbox.left() + bbox.width() * 0.5 + ox, radii: Vec2::new(size.x * 0.5, size.y * 0.5),
bbox.top() + bbox.height() * 0.5 + oy,
),
radii: Vec2::new(bbox.width() * 0.5, bbox.height() * 0.5),
}, },
style, style,
)); ));
} }
None let polygons = flatten_path_segments(&segments, transform);
if polygons.is_empty() {
return None;
} }
fn convert_text(text: &usvg::Text, ox: f32, oy: f32) -> Option<DrawingElement> { Some(DrawingElement::new(Shape::Path { polygons }, style))
}
fn convert_text(text: &usvg::Text, transform: AffineTransform) -> Option<DrawingElement> {
let content = extract_text_content(text); let content = extract_text_content(text);
if content.trim().is_empty() { if content.trim().is_empty() {
return None; return None;
} }
let bbox = text.bounding_box(); let bbox = text.bounding_box();
let pos = transform.apply(bbox.left(), bbox.top());
let font_size = extract_font_size(text); let font_size = extract_font_size(text);
let fill_color = extract_text_color(text); let fill_color = extract_text_color(text);
Some(DrawingElement::new( Some(DrawingElement::new(
Shape::Text { Shape::Text {
pos: Pos2::new(bbox.left() + ox, bbox.top() + oy), pos,
content, content,
font_size, font_size,
max_width: None,
}, },
ShapeStyle { ShapeStyle {
fill: None, fill: Some(Fill::solid(fill_color)),
stroke_color: fill_color, stroke_color: fill_color,
stroke_width: 0.0, stroke_width: 0.0,
..ShapeStyle::default()
}, },
)) ))
} }
fn assign_text_max_widths(elements: &mut [DrawingElement], start_idx: usize) {
let group_elements = &elements[start_idx..];
// Track container bounds and whether it's a non-rectangular shape (diamond/polygon)
// where the usable text area is narrower than the bounding box.
let containers: Vec<(egui::Rect, bool)> = group_elements
.iter()
.filter_map(|el| match &el.shape {
Shape::Rectangle { pos, size } => Some((egui::Rect::from_min_size(*pos, *size), false)),
Shape::Ellipse { center, radii } => Some((
egui::Rect::from_center_size(*center, egui::vec2(radii.x * 2.0, radii.y * 2.0)),
true,
)),
Shape::Path { .. } => Some((el.bounding_rect(), true)),
_ => None,
})
.collect();
if containers.is_empty() {
return;
}
for el in &mut elements[start_idx..] {
if let Shape::Text {
pos,
content,
font_size,
max_width,
} = &mut el.shape
{
let text_point = *pos;
let mut best: Option<(f32, egui::Rect, bool)> = None;
for (rect, is_non_rect) in &containers {
if rect.contains(text_point) && rect.width() > 1.0 {
let area = rect.width() * rect.height();
if best.is_none() || area < best.unwrap().0 {
best = Some((area, *rect, *is_non_rect));
}
}
}
if let Some((_area, container, is_non_rect)) = best {
let padding = 16.0;
// For diamond/polygon/ellipse shapes, usable width at center is ~50%
let width_factor = if is_non_rect { 0.5 } else { 1.0 };
let usable_width = (container.width() * width_factor - padding).max(20.0);
*max_width = Some(usable_width);
pos.x = container.center().x - usable_width * 0.5;
let text_width = content.chars().count() as f32 * *font_size * 0.6;
let line_count = (text_width / usable_width).ceil().max(1.0);
let total_text_height = line_count * *font_size * 1.4;
pos.y = container.center().y - total_text_height * 0.5;
}
}
}
}
fn extract_text_content(text: &usvg::Text) -> String { fn extract_text_content(text: &usvg::Text) -> String {
text.chunks() text.chunks()
.iter() .iter()
@@ -170,26 +322,66 @@ fn extract_text_color(text: &usvg::Text) -> Color32 {
} }
fn extract_style(path: &usvg::Path) -> ShapeStyle { fn extract_style(path: &usvg::Path) -> ShapeStyle {
let fill = path.fill().and_then(|f| match f.paint() { let fill = path.fill().and_then(|f| extract_fill(f.paint()));
usvg::Paint::Color(c) => Some(Color32::from_rgb(c.red, c.green, c.blue)),
_ => None,
});
let (stroke_color, stroke_width) = path let (stroke_color, stroke_width, stroke_dash) = path
.stroke() .stroke()
.map(|s| { .map(|s| {
let color = match s.paint() { let color = match s.paint() {
usvg::Paint::Color(c) => Color32::from_rgb(c.red, c.green, c.blue), usvg::Paint::Color(c) => Color32::from_rgb(c.red, c.green, c.blue),
_ => Color32::WHITE, _ => Color32::WHITE,
}; };
(color, s.width().get()) let dash = s.dasharray().and_then(|dash_array| {
(dash_array.len() >= 2).then_some((dash_array[0], dash_array[1]))
});
(color, s.width().get(), dash)
}) })
.unwrap_or((Color32::WHITE, 2.0)); .unwrap_or((Color32::WHITE, 2.0, None));
ShapeStyle { ShapeStyle {
fill, fill,
stroke_color, stroke_color,
stroke_width, stroke_width,
stroke_dash,
..ShapeStyle::default()
}
}
fn extract_fill(paint: &usvg::Paint) -> Option<Fill> {
match paint {
usvg::Paint::Color(c) => Some(Fill::solid(Color32::from_rgb(c.red, c.green, c.blue))),
usvg::Paint::LinearGradient(gradient) => {
let angle_deg = (gradient.y2() - gradient.y1())
.atan2(gradient.x2() - gradient.x1())
.to_degrees();
let stops = gradient
.stops()
.iter()
.map(|stop| {
let c = stop.color();
GradientStop {
offset: stop.offset().get(),
color: Color32::from_rgb(c.red, c.green, c.blue),
}
})
.collect();
Some(Fill::LinearGradient { angle_deg, stops })
}
usvg::Paint::RadialGradient(gradient) => {
let stops = gradient
.stops()
.iter()
.map(|stop| {
let c = stop.color();
GradientStop {
offset: stop.offset().get(),
color: Color32::from_rgb(c.red, c.green, c.blue),
}
})
.collect();
Some(Fill::RadialGradient { stops })
}
usvg::Paint::Pattern(_) => None,
} }
} }
@@ -225,6 +417,9 @@ fn is_rect_path(segments: &[PathSegment]) -> bool {
fn is_line_path(segments: &[PathSegment]) -> bool { fn is_line_path(segments: &[PathSegment]) -> bool {
!segments.iter().any(|s| matches!(s, PathSegment::Close)) !segments.iter().any(|s| matches!(s, PathSegment::Close))
&& segments
.iter()
.all(|s| matches!(s, PathSegment::MoveTo(_) | PathSegment::LineTo(_)))
} }
fn extract_line_endpoints(segments: &[PathSegment]) -> Option<((f32, f32), (f32, f32))> { fn extract_line_endpoints(segments: &[PathSegment]) -> Option<((f32, f32), (f32, f32))> {
@@ -262,6 +457,155 @@ fn is_stroke_only(path: &usvg::Path) -> bool {
path.fill().is_none() && path.stroke().is_some() path.fill().is_none() && path.stroke().is_some()
} }
fn transformed_rect_from_bbox(bbox: usvg::Rect, transform: AffineTransform) -> (Pos2, Vec2) {
let x0 = bbox.left();
let y0 = bbox.top();
let x1 = x0 + bbox.width();
let y1 = y0 + bbox.height();
let p0 = transform.apply(x0, y0);
let p1 = transform.apply(x1, y0);
let p2 = transform.apply(x1, y1);
let p3 = transform.apply(x0, y1);
let min_x = p0.x.min(p1.x).min(p2.x).min(p3.x);
let min_y = p0.y.min(p1.y).min(p2.y).min(p3.y);
let max_x = p0.x.max(p1.x).max(p2.x).max(p3.x);
let max_y = p0.y.max(p1.y).max(p2.y).max(p3.y);
(
Pos2::new(min_x, min_y),
Vec2::new((max_x - min_x).max(0.0), (max_y - min_y).max(0.0)),
)
}
fn flatten_path_segments(segments: &[PathSegment], transform: AffineTransform) -> Vec<PathPolygon> {
let mut polygons = Vec::new();
let mut current_ring: Vec<Pos2> = Vec::new();
let mut current_point: Option<(f32, f32)> = None;
let mut subpath_start: Option<(f32, f32)> = None;
for segment in segments {
match segment {
PathSegment::MoveTo(p) => {
finalize_ring(&mut polygons, &mut current_ring);
let point = (p.x, p.y);
current_point = Some(point);
subpath_start = Some(point);
push_transformed_point(&mut current_ring, transform, point);
}
PathSegment::LineTo(p) => {
let point = (p.x, p.y);
push_transformed_point(&mut current_ring, transform, point);
current_point = Some(point);
}
PathSegment::QuadTo(control, p) => {
if let Some(from) = current_point {
let to = (p.x, p.y);
flatten_quad_segment(
&mut current_ring,
transform,
from,
(control.x, control.y),
to,
8,
);
current_point = Some(to);
}
}
PathSegment::CubicTo(control1, control2, p) => {
if let Some(from) = current_point {
let to = (p.x, p.y);
flatten_cubic_segment(
&mut current_ring,
transform,
from,
(control1.x, control1.y),
(control2.x, control2.y),
to,
16,
);
current_point = Some(to);
}
}
PathSegment::Close => {
if let Some(start) = subpath_start {
push_transformed_point(&mut current_ring, transform, start);
}
finalize_ring(&mut polygons, &mut current_ring);
current_point = None;
subpath_start = None;
}
}
}
finalize_ring(&mut polygons, &mut current_ring);
polygons
}
fn finalize_ring(polygons: &mut Vec<PathPolygon>, ring: &mut Vec<Pos2>) {
if ring.len() >= 2 {
polygons.push(PathPolygon {
exterior: std::mem::take(ring),
holes: Vec::new(),
});
} else {
ring.clear();
}
}
fn push_transformed_point(ring: &mut Vec<Pos2>, transform: AffineTransform, point: (f32, f32)) {
let transformed = transform.apply(point.0, point.1);
if let Some(last) = ring.last() {
let delta = *last - transformed;
if delta.length_sq() <= 1e-6 {
return;
}
}
ring.push(transformed);
}
fn flatten_quad_segment(
ring: &mut Vec<Pos2>,
transform: AffineTransform,
from: (f32, f32),
control: (f32, f32),
to: (f32, f32),
steps: usize,
) {
for i in 1..=steps {
let t = i as f32 / steps as f32;
let it = 1.0 - t;
let x = it * it * from.0 + 2.0 * it * t * control.0 + t * t * to.0;
let y = it * it * from.1 + 2.0 * it * t * control.1 + t * t * to.1;
push_transformed_point(ring, transform, (x, y));
}
}
fn flatten_cubic_segment(
ring: &mut Vec<Pos2>,
transform: AffineTransform,
from: (f32, f32),
control1: (f32, f32),
control2: (f32, f32),
to: (f32, f32),
steps: usize,
) {
for i in 1..=steps {
let t = i as f32 / steps as f32;
let it = 1.0 - t;
let x = it * it * it * from.0
+ 3.0 * it * it * t * control1.0
+ 3.0 * it * t * t * control2.0
+ t * t * t * to.0;
let y = it * it * it * from.1
+ 3.0 * it * it * t * control1.1
+ 3.0 * it * t * t * control2.1
+ t * t * t * to.1;
push_transformed_point(ring, transform, (x, y));
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -300,4 +644,106 @@ mod tests {
assert!(rect.min.y >= 200.0, "Expected y offset applied"); assert!(rect.min.y >= 200.0, "Expected y offset applied");
} }
} }
#[test]
fn converts_complex_svg_with_paths_and_circles() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="3809" viewBox="0 0 1280 3809">
<g>
<path d="M0,0 L1280,0 L1280,3809 L0,3809 Z" fill="#171717"/>
<path d="M0,0 L1280,0 L1280,75 L0,75 Z" fill="#0f0f0f"/>
<path d="M48,31 L54,29 C54.8,29.15 55.1,29.15 55.4,29.2 L61.6,31.3 L61.6,35.9 C61.6,36.6 61.2,37.2 60.5,37.4 L54.6,39 L49,37.2 C48.5,37 48,36.4 48,35.8 Z" fill="#45cca7"/>
<circle cx="50.7" cy="37.5" r="18.7" fill="#013b65"/>
<circle cx="1202" cy="37.5" r="10"/>
<path d="M795,632 L1318,352" stroke="#f5f5f5" stroke-width="4" fill="none"/>
<circle cx="903" cy="359.4" r="5.75" fill="#37a386" stroke="#171717" stroke-width="0.5"/>
<path d="M852,663.8 L1228,663.8 C1245.7,663.8 1260,678.1 1260,695.8 L1260,981.8 C1260,999.5 1245.7,1013.8 1228,1013.8 L852,1013.8 C834.3,1013.8 820,999.5 820,981.8 L820,695.8 C820,678.1 834.3,663.8 852,663.8 Z" fill="#1c1c1c"/>
<circle cx="360" cy="2300" r="60"/>
<text x="500" y="200" font-size="16" fill="#f5f5f5">Sample Text</text>
</g>
</svg>"##;
let elements =
svg_to_drawing_elements(svg, 0.0, 0.0).expect("converter should parse complex svg");
eprintln!("Complex SVG produced {} elements:", elements.len());
for (i, el) in elements.iter().enumerate() {
eprintln!(" [{i}] {:?}", std::mem::discriminant(&el.shape));
}
assert!(
elements.len() >= 5,
"Expected at least 5 elements from complex SVG, got {}",
elements.len()
);
}
#[test]
fn converts_circles_to_ellipses() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<circle cx="100" cy="100" r="50" fill="#ff0000"/>
</svg>"##;
let elements =
svg_to_drawing_elements(svg, 0.0, 0.0).expect("converter should parse circle svg");
eprintln!("Circle SVG produced {} elements:", elements.len());
for (i, el) in elements.iter().enumerate() {
eprintln!(" [{i}] {:?}", el.shape);
}
assert!(
!elements.is_empty(),
"Expected at least 1 element from circle SVG"
);
}
#[test]
fn converts_stroke_only_line() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500">
<path d="M100,100 L400,300" stroke="#ff0000" stroke-width="3" fill="none"/>
</svg>"##;
let elements =
svg_to_drawing_elements(svg, 0.0, 0.0).expect("converter should parse line svg");
eprintln!("Line SVG produced {} elements:", elements.len());
for (i, el) in elements.iter().enumerate() {
eprintln!(" [{i}] {:?}", el.shape);
}
assert!(
!elements.is_empty(),
"Expected at least 1 element from stroke-only line"
);
}
#[test]
fn converts_rounded_rect() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500">
<path d="M52,63.8 L228,63.8 C245.7,63.8 260,78.1 260,95.8 L260,381.8 C260,399.5 245.7,413.8 228,413.8 L52,413.8 C34.3,413.8 20,399.5 20,381.8 L20,95.8 C20,78.1 34.3,63.8 52,63.8 Z" fill="#1c1c1c"/>
</svg>"##;
let elements = svg_to_drawing_elements(svg, 0.0, 0.0)
.expect("converter should parse rounded rect svg");
eprintln!("Rounded rect SVG produced {} elements:", elements.len());
for (i, el) in elements.iter().enumerate() {
eprintln!(" [{i}] {:?}", std::mem::discriminant(&el.shape));
}
assert!(
!elements.is_empty(),
"Expected at least 1 element from rounded rect path"
);
}
#[test]
fn invisible_elements_are_skipped() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<circle cx="100" cy="100" r="50"/>
</svg>"##;
let elements =
svg_to_drawing_elements(svg, 0.0, 0.0).expect("converter should parse invisible svg");
eprintln!("Invisible circle SVG produced {} elements:", elements.len());
for (i, el) in elements.iter().enumerate() {
eprintln!(" [{i}] {:?} style={:?}", el.shape, el.style);
}
}
} }

View File

@@ -0,0 +1,382 @@
use crate::drawing::{
arrow_control_point, polygon_vertices, quadratic_bezier_end_tangent, DrawingElement, Shape,
};
pub fn export_drawing_elements_to_svg(elements: &[DrawingElement]) -> String {
let bounds = compute_bounds(elements).unwrap_or_else(|| {
egui::Rect::from_min_size(egui::Pos2::new(0.0, 0.0), egui::Vec2::new(1.0, 1.0))
});
let width = bounds.width().max(1.0);
let height = bounds.height().max(1.0);
let mut svg = String::new();
svg.push_str(&format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{} {} {} {}" width="{}" height="{}">"#,
fmt(bounds.min.x),
fmt(bounds.min.y),
fmt(width),
fmt(height),
fmt(width),
fmt(height)
));
for element in elements {
push_element_svg(&mut svg, element);
}
svg.push_str("</svg>");
svg
}
fn compute_bounds(elements: &[DrawingElement]) -> Option<egui::Rect> {
let mut bounds: Option<egui::Rect> = None;
for element in elements {
let half_stroke = (element.style.stroke_width * 0.5).max(0.0);
let rect = element.bounding_rect().expand(half_stroke);
bounds = Some(match bounds {
Some(existing) => existing.union(rect),
None => rect,
});
}
bounds
}
fn push_element_svg(output: &mut String, element: &DrawingElement) {
let stroke = color_to_hex(element.style.stroke_color);
let stroke_opacity = (element.style.opacity.clamp(0.0, 1.0)
* (element.style.stroke_color.a() as f32 / 255.0))
.clamp(0.0, 1.0);
match &element.shape {
Shape::Rectangle { pos, size } => {
let (fill, fill_opacity) = fill_attrs(element);
let center_x = pos.x + size.x * 0.5;
let center_y = pos.y + size.y * 0.5;
output.push_str(&format!(
r#"<rect x="{}" y="{}" width="{}" height="{}" rx="{}" fill="{}" stroke="{}" stroke-width="{}" opacity="{}"{} />"#,
fmt(pos.x),
fmt(pos.y),
fmt(size.x),
fmt(size.y),
fmt(element.style.corner_radius.max(0.0)),
fill,
stroke,
fmt(element.style.stroke_width.max(0.0)),
fmt(fill_opacity.max(stroke_opacity)),
rotation_attr(element.style.rotation_degrees, center_x, center_y)
));
}
Shape::Ellipse { center, radii } => {
let (fill, fill_opacity) = fill_attrs(element);
output.push_str(&format!(
r#"<ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="{}" stroke="{}" stroke-width="{}" opacity="{}"{} />"#,
fmt(center.x),
fmt(center.y),
fmt(radii.x.max(0.0)),
fmt(radii.y.max(0.0)),
fill,
stroke,
fmt(element.style.stroke_width.max(0.0)),
fmt(fill_opacity.max(stroke_opacity)),
rotation_attr(element.style.rotation_degrees, center.x, center.y)
));
}
Shape::Line { start, end } => {
output.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="{}" opacity="{}" />"#,
fmt(start.x),
fmt(start.y),
fmt(end.x),
fmt(end.y),
stroke,
fmt(element.style.stroke_width.max(0.0)),
fmt(stroke_opacity)
));
}
Shape::Arrow {
start,
end,
control_offset,
} => {
if let Some(control_point) = arrow_control_point(*start, *end, *control_offset) {
output.push_str(&format!(
r#"<path d="M {} {} Q {} {} {} {}" fill="none" stroke="{}" stroke-width="{}" opacity="{}" />"#,
fmt(start.x),
fmt(start.y),
fmt(control_point.x),
fmt(control_point.y),
fmt(end.x),
fmt(end.y),
stroke,
fmt(element.style.stroke_width.max(0.0)),
fmt(stroke_opacity)
));
} else {
output.push_str(&format!(
r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="{}" opacity="{}" />"#,
fmt(start.x),
fmt(start.y),
fmt(end.x),
fmt(end.y),
stroke,
fmt(element.style.stroke_width.max(0.0)),
fmt(stroke_opacity)
));
}
if let Some(points) = arrowhead_points(
*end,
quadratic_bezier_end_tangent(*start, *end, *control_offset),
) {
output.push_str(&format!(
r#"<polygon points="{}" fill="{}" opacity="{}" />"#,
points,
stroke,
fmt(stroke_opacity)
));
}
}
Shape::Text {
pos,
content,
font_size,
..
} => {
let font_family = element.style.font_family.as_deref().unwrap_or("sans-serif");
let text_rect = element.bounding_rect();
let center = text_rect.center();
output.push_str(&format!(
r#"<text x="{}" y="{}" font-size="{}" font-family="{}" fill="{}" opacity="{}"{}>{}</text>"#,
fmt(pos.x),
fmt(pos.y + font_size),
fmt(*font_size),
escape_attr(font_family),
stroke,
fmt(stroke_opacity),
rotation_attr(element.style.rotation_degrees, center.x, center.y),
escape_text(content)
));
}
Shape::Polygon {
center,
radius,
sides,
star_inner_ratio,
} => {
let (fill, fill_opacity) = fill_attrs(element);
let points = polygon_vertices(*center, *radius, *sides, *star_inner_ratio)
.into_iter()
.map(|point| format!("{},{}", fmt(point.x), fmt(point.y)))
.collect::<Vec<_>>()
.join(" ");
output.push_str(&format!(
r#"<polygon points="{}" fill="{}" stroke="{}" stroke-width="{}" opacity="{}"{} />"#,
points,
fill,
stroke,
fmt(element.style.stroke_width.max(0.0)),
fmt(fill_opacity.max(stroke_opacity)),
rotation_attr(element.style.rotation_degrees, center.x, center.y)
));
}
Shape::Path { polygons } => {
let (fill, fill_opacity) = fill_attrs(element);
let mut d = String::new();
for polygon in polygons {
append_ring_path(&mut d, &polygon.exterior);
for hole in &polygon.holes {
append_ring_path(&mut d, hole);
}
}
output.push_str(&format!(
r#"<path d="{}" fill="{}" fill-rule="evenodd" stroke="{}" stroke-width="{}" opacity="{}" />"#,
d.trim(),
fill,
stroke,
fmt(element.style.stroke_width.max(0.0)),
fmt(fill_opacity.max(stroke_opacity))
));
}
Shape::SvgImage { .. } => {}
Shape::Group => {
output.push_str("<g>");
for child in &element.children {
push_element_svg(output, child);
}
output.push_str("</g>");
}
}
}
fn fill_attrs(element: &DrawingElement) -> (String, f32) {
match &element.style.fill {
Some(fill) => {
let color = fill.primary_color();
let opacity = (element.style.opacity.clamp(0.0, 1.0) * (color.a() as f32 / 255.0))
.clamp(0.0, 1.0);
(color_to_hex(color), opacity)
}
None => ("none".to_string(), 0.0),
}
}
fn append_ring_path(d: &mut String, ring: &[egui::Pos2]) {
if ring.is_empty() {
return;
}
d.push_str(&format!("M {} {} ", fmt(ring[0].x), fmt(ring[0].y)));
for point in ring.iter().skip(1) {
d.push_str(&format!("L {} {} ", fmt(point.x), fmt(point.y)));
}
d.push_str("Z ");
}
fn rotation_attr(rotation_degrees: f32, cx: f32, cy: f32) -> String {
let rotation = rotation_degrees.rem_euclid(360.0);
if rotation.abs() <= f32::EPSILON {
String::new()
} else {
format!(
" transform=\"rotate({} {} {})\"",
fmt(rotation),
fmt(cx),
fmt(cy)
)
}
}
fn arrowhead_points(end: egui::Pos2, direction: egui::Vec2) -> Option<String> {
let len = direction.length();
if len <= f32::EPSILON {
return None;
}
let dir_x = direction.x / len;
let dir_y = direction.y / len;
let perp_x = -dir_y;
let perp_y = dir_x;
let arrow_size = 12.0;
let left = egui::pos2(
end.x - dir_x * arrow_size + perp_x * (arrow_size * 0.4),
end.y - dir_y * arrow_size + perp_y * (arrow_size * 0.4),
);
let right = egui::pos2(
end.x - dir_x * arrow_size - perp_x * (arrow_size * 0.4),
end.y - dir_y * arrow_size - perp_y * (arrow_size * 0.4),
);
Some(format!(
"{},{} {},{} {},{}",
fmt(left.x),
fmt(left.y),
fmt(end.x),
fmt(end.y),
fmt(right.x),
fmt(right.y)
))
}
fn color_to_hex(color: egui::Color32) -> String {
format!("#{:02X}{:02X}{:02X}", color.r(), color.g(), color.b())
}
fn fmt(value: f32) -> String {
let mut out = format!("{:.3}", value);
while out.ends_with('0') {
out.pop();
}
if out.ends_with('.') {
out.pop();
}
if out.is_empty() {
"0".to_string()
} else {
out
}
}
fn escape_text(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
_ => escaped.push(ch),
}
}
escaped
}
fn escape_attr(text: &str) -> String {
escape_text(text)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::drawing::ShapeStyle;
#[test]
fn exports_svg_root_and_rect() {
let elements = vec![DrawingElement::new(
Shape::Rectangle {
pos: egui::pos2(10.0, 20.0),
size: egui::vec2(100.0, 40.0),
},
ShapeStyle::default(),
)];
let svg = export_drawing_elements_to_svg(&elements);
assert!(svg.contains("<svg"));
assert!(svg.contains("<rect"));
}
#[test]
fn exports_arrow_and_text() {
let elements = vec![
DrawingElement::new(
Shape::Arrow {
start: egui::pos2(0.0, 0.0),
end: egui::pos2(20.0, 10.0),
control_offset: None,
},
ShapeStyle::default(),
),
DrawingElement::new(
Shape::Text {
pos: egui::pos2(4.0, 8.0),
content: "hello".to_string(),
font_size: 16.0,
max_width: None,
},
ShapeStyle::default(),
),
];
let svg = export_drawing_elements_to_svg(&elements);
assert!(svg.contains("<line"));
assert!(svg.contains("<polygon"));
assert!(svg.contains("<text"));
}
#[test]
fn exports_curved_arrow_as_quadratic_path() {
let elements = vec![DrawingElement::new(
Shape::Arrow {
start: egui::pos2(0.0, 0.0),
end: egui::pos2(100.0, 0.0),
control_offset: Some(egui::vec2(0.0, 30.0)),
},
ShapeStyle::default(),
)];
let svg = export_drawing_elements_to_svg(&elements);
assert!(svg.contains("<path"));
assert!(svg.contains(" Q "));
}
}

View File

@@ -1,7 +1,9 @@
mod converter; mod converter;
mod export_svg;
mod parser; mod parser;
mod renderer; mod renderer;
pub use converter::svg_to_drawing_elements; pub use converter::svg_to_drawing_elements;
pub use export_svg::export_drawing_elements_to_svg;
pub use parser::parse_svg; pub use parser::parse_svg;
pub use renderer::SvgRenderer; pub use renderer::SvgRenderer;

View File

@@ -42,6 +42,7 @@ impl SvgRenderer {
.ok_or_else(|| anyhow::anyhow!("Pixmap not available")) .ok_or_else(|| anyhow::anyhow!("Pixmap not available"))
} }
#[allow(dead_code)]
pub fn size(&self) -> (f32, f32) { pub fn size(&self) -> (f32, f32) {
let size = self.tree.size(); let size = self.tree.size();
(size.width(), size.height()) (size.width(), size.height())

View File

@@ -0,0 +1,67 @@
use egui::Color32;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub enum CanvasTheme {
#[default]
Dark,
Light,
Custom {
background: [u8; 3],
grid: [u8; 3],
stroke: [u8; 3],
},
}
impl CanvasTheme {
pub fn background(&self) -> Color32 {
match self {
CanvasTheme::Dark => Color32::from_gray(30),
CanvasTheme::Light => Color32::from_rgb(250, 250, 250),
CanvasTheme::Custom { background, .. } => {
Color32::from_rgb(background[0], background[1], background[2])
}
}
}
pub fn grid_color(&self) -> Color32 {
match self {
CanvasTheme::Dark => Color32::from_gray(40),
CanvasTheme::Light => Color32::from_gray(220),
CanvasTheme::Custom { grid, .. } => Color32::from_rgb(grid[0], grid[1], grid[2]),
}
}
pub fn default_stroke(&self) -> Color32 {
match self {
CanvasTheme::Dark => Color32::WHITE,
CanvasTheme::Light => Color32::from_gray(30),
CanvasTheme::Custom { stroke, .. } => {
Color32::from_rgb(stroke[0], stroke[1], stroke[2])
}
}
}
pub fn export_background(&self) -> tiny_skia::Color {
let bg = self.background();
tiny_skia::Color::from_rgba8(bg.r(), bg.g(), bg.b(), 255)
}
pub fn text_color(&self) -> Color32 {
let bg = self.background();
let luminance = 0.2126 * bg.r() as f32 + 0.7152 * bg.g() as f32 + 0.0722 * bg.b() as f32;
if luminance > 140.0 {
Color32::from_gray(45)
} else {
Color32::from_gray(210)
}
}
pub fn label(&self) -> &str {
match self {
CanvasTheme::Dark => "Dark",
CanvasTheme::Light => "Light",
CanvasTheme::Custom { .. } => "Custom",
}
}
}