feat: convert Mermaid diagrams to native DrawingElements instead of raster overlays
Mermaid SVG is now parsed into interactive shapes (rectangles, arrows, text) that can be selected, moved, and resized. Removes the MermaidOverlay system entirely in favor of first-class drawing elements.
This commit is contained in:
@@ -224,6 +224,15 @@ pub enum AgentRequest {
|
||||
#[serde(default)]
|
||||
height: Option<f32>,
|
||||
},
|
||||
ExportCanvas {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
scale: Option<f32>,
|
||||
#[serde(default)]
|
||||
background: Option<String>,
|
||||
},
|
||||
Batch {
|
||||
requests: Vec<AgentRequest>,
|
||||
},
|
||||
@@ -292,6 +301,14 @@ pub enum AgentResponse {
|
||||
session_id: String,
|
||||
overlay_id: String,
|
||||
svg_source: String,
|
||||
#[serde(default)]
|
||||
element_ids: Vec<String>,
|
||||
},
|
||||
CanvasExported {
|
||||
session_id: String,
|
||||
path: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
},
|
||||
BatchResult {
|
||||
results: Vec<AgentResponse>,
|
||||
@@ -323,14 +340,6 @@ pub enum DrawingCommand {
|
||||
Clear {
|
||||
session_id: String,
|
||||
},
|
||||
RenderMermaid {
|
||||
session_id: String,
|
||||
overlay_id: String,
|
||||
mermaid_source: String,
|
||||
svg_source: String,
|
||||
position: Pos2,
|
||||
size: Vec2,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,7 @@ use super::protocol::{
|
||||
DrawingCommand, GuiEvent, SessionCommand,
|
||||
};
|
||||
use crate::drawing::{boolean, DrawingElement, Shape, ShapeStyle};
|
||||
use crate::export::ExportData;
|
||||
use crate::session::{SessionCreator, SessionStore};
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
@@ -11,10 +12,9 @@ use std::sync::Arc;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{broadcast, mpsc, RwLock};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use usvg::Tree;
|
||||
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
||||
static OVERLAY_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
static MERMAID_RENDER_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub struct AgentServer {
|
||||
sessions: Arc<RwLock<SessionStore>>,
|
||||
@@ -237,7 +237,7 @@ async fn process_single_request(
|
||||
.unwrap_or(0),
|
||||
};
|
||||
|
||||
store.add_session(info.clone(), None);
|
||||
store.add_session(info.clone(), None, None);
|
||||
drop(store);
|
||||
|
||||
let _ = session_command_tx.send(SessionCommand::Create {
|
||||
@@ -690,8 +690,8 @@ async fn process_single_request(
|
||||
mermaid_source,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
width: _,
|
||||
height: _,
|
||||
} => {
|
||||
let sid = {
|
||||
let store = sessions.read().await;
|
||||
@@ -714,47 +714,104 @@ async fn process_single_request(
|
||||
}
|
||||
};
|
||||
|
||||
let options = usvg::Options::default();
|
||||
let tree = match Tree::from_str(&svg_source, &options) {
|
||||
Ok(tree) => tree,
|
||||
let elements = match crate::svg::svg_to_drawing_elements(
|
||||
&svg_source,
|
||||
x.unwrap_or(0.0),
|
||||
y.unwrap_or(0.0),
|
||||
) {
|
||||
Ok(elements) => elements,
|
||||
Err(e) => {
|
||||
return AgentResponse::Error {
|
||||
message: format!("Failed to parse Mermaid SVG: {}", e),
|
||||
message: format!("Failed to convert Mermaid SVG: {}", e),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let natural_size = tree.size();
|
||||
let overlay_id = format!(
|
||||
"mermaid_{}",
|
||||
OVERLAY_ID_COUNTER.fetch_add(1, Ordering::SeqCst)
|
||||
);
|
||||
let position = egui::pos2(x.unwrap_or(0.0), y.unwrap_or(0.0));
|
||||
let size = egui::vec2(
|
||||
width.unwrap_or(natural_size.width()),
|
||||
height.unwrap_or(natural_size.height()),
|
||||
"mermaid_group_{}",
|
||||
MERMAID_RENDER_COUNTER.fetch_add(1, Ordering::SeqCst)
|
||||
);
|
||||
|
||||
if command_tx
|
||||
.send(DrawingCommand::RenderMermaid {
|
||||
session_id: sid.clone(),
|
||||
overlay_id: overlay_id.clone(),
|
||||
mermaid_source,
|
||||
svg_source: svg_source.clone(),
|
||||
position,
|
||||
size,
|
||||
})
|
||||
.is_err()
|
||||
let mut element_ids = Vec::with_capacity(elements.len());
|
||||
{
|
||||
return AgentResponse::Error {
|
||||
message: "Failed to enqueue Mermaid render command".to_string(),
|
||||
};
|
||||
let mut store = sessions.write().await;
|
||||
for element in &elements {
|
||||
element_ids.push(element.id.clone());
|
||||
store.add_drawing_element(&sid, element.clone());
|
||||
|
||||
if command_tx
|
||||
.send(DrawingCommand::Create {
|
||||
session_id: sid.clone(),
|
||||
element: element.clone(),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return AgentResponse::Error {
|
||||
message: "Failed to send drawing command".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let _ = event_tx.send(GuiEvent::DrawingElementCreated {
|
||||
session_id: sid.clone(),
|
||||
element: element.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
AgentResponse::MermaidRendered {
|
||||
session_id: sid,
|
||||
overlay_id,
|
||||
svg_source,
|
||||
element_ids,
|
||||
}
|
||||
}
|
||||
AgentRequest::ExportCanvas {
|
||||
session_id,
|
||||
path,
|
||||
scale,
|
||||
background,
|
||||
} => {
|
||||
let export_scale = scale.unwrap_or(2.0);
|
||||
if !(export_scale.is_finite() && export_scale > 0.0) {
|
||||
return AgentResponse::Error {
|
||||
message: "scale must be a positive finite value".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let background_color = background
|
||||
.as_deref()
|
||||
.and_then(parse_hex_color)
|
||||
.map(color32_to_skia)
|
||||
.unwrap_or_else(|| tiny_skia::Color::from_rgba8(30, 30, 30, 255));
|
||||
|
||||
let (sid, svg_source, drawing_elements) = {
|
||||
let store = sessions.read().await;
|
||||
match store.get_export_data(session_id.as_deref()) {
|
||||
Some(data) => data,
|
||||
None => {
|
||||
return AgentResponse::Error {
|
||||
message: "No session found".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let export_data = ExportData {
|
||||
svg_source,
|
||||
drawing_elements,
|
||||
background_color,
|
||||
};
|
||||
|
||||
match crate::export::export_canvas_to_png(&export_data, &path, export_scale) {
|
||||
Ok((width, height)) => AgentResponse::CanvasExported {
|
||||
session_id: sid,
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
Err(e) => AgentResponse::Error {
|
||||
message: format!("Failed to export canvas: {}", e),
|
||||
},
|
||||
}
|
||||
}
|
||||
AgentRequest::Batch { .. } => AgentResponse::Error {
|
||||
@@ -763,6 +820,10 @@ async fn process_single_request(
|
||||
}
|
||||
}
|
||||
|
||||
fn color32_to_skia(c: egui::Color32) -> tiny_skia::Color {
|
||||
tiny_skia::Color::from_rgba8(c.r(), c.g(), c.b(), c.a())
|
||||
}
|
||||
|
||||
fn generate_code_stub(element: &crate::element_tree::Element, target: CodeGenTarget) -> String {
|
||||
let description = crate::element_tree::ElementTree {
|
||||
root: element.clone(),
|
||||
|
||||
@@ -3,14 +3,14 @@ use crate::canvas::{CanvasInteraction, CanvasState};
|
||||
use crate::clipboard::ClipboardManager;
|
||||
use crate::command_palette::{CommandId, CommandPalette};
|
||||
use crate::drawing::{
|
||||
canvas_to_screen, draw_creation_preview, draw_elements, draw_marquee, draw_selection,
|
||||
find_handle_at_screen_pos, screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle,
|
||||
Tool,
|
||||
draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos,
|
||||
screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool,
|
||||
};
|
||||
use crate::export::{self, ExportData};
|
||||
use crate::history::{ChangeSource, HistoryTree, NodeId};
|
||||
use crate::mermaid::render_mermaid_to_svg;
|
||||
use crate::persistence::{self, SavedSession, SavedWorkspace};
|
||||
use crate::session::{MermaidOverlay, Session, SessionCreator, SessionStore};
|
||||
use crate::session::{Session, SessionCreator, SessionStore};
|
||||
use crate::svg::{parse_svg, SvgRenderer};
|
||||
use egui::{Color32, ColorImage, TextureOptions};
|
||||
use std::path::Path;
|
||||
@@ -21,6 +21,12 @@ use tokio::sync::{broadcast, mpsc, RwLock};
|
||||
const AGENT_PORT: u16 = 9876;
|
||||
const MIN_SHAPE_SIZE: f32 = 5.0;
|
||||
|
||||
type SessionExportSnapshot = (
|
||||
String,
|
||||
Vec<DrawingElement>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
pub struct AgCanvasApp {
|
||||
sessions: Vec<Session>,
|
||||
active_session_idx: usize,
|
||||
@@ -112,7 +118,7 @@ impl AgCanvasApp {
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut store = sessions_handle.write().await;
|
||||
store.add_session(info_clone.clone(), None);
|
||||
store.add_session(info_clone.clone(), None, None);
|
||||
store.set_active(&info_clone.id);
|
||||
});
|
||||
let _ = event_tx.send(GuiEvent::SessionCreated {
|
||||
@@ -205,12 +211,13 @@ impl AgCanvasApp {
|
||||
let sessions_handle = self.sessions_handle.clone();
|
||||
let event_tx = self.event_tx.clone();
|
||||
let tree_clone = tree.clone();
|
||||
let svg_source = svg_data.to_string();
|
||||
let sid = session_id.clone();
|
||||
std::thread::spawn(move || {
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut store = sessions_handle.write().await;
|
||||
store.update_tree(&sid, Some(tree_clone));
|
||||
store.update_tree(&sid, Some(tree_clone), Some(svg_source));
|
||||
});
|
||||
let _ = event_tx.send(GuiEvent::SvgLoaded {
|
||||
session_id,
|
||||
@@ -256,7 +263,7 @@ impl AgCanvasApp {
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut store = sessions_handle.write().await;
|
||||
store.update_tree(&sid, None);
|
||||
store.update_tree(&sid, None, None);
|
||||
});
|
||||
let _ = event_tx.send(GuiEvent::SvgCleared { session_id });
|
||||
});
|
||||
@@ -291,44 +298,7 @@ impl AgCanvasApp {
|
||||
self.status_message = Some((message, std::time::Instant::now()));
|
||||
}
|
||||
|
||||
fn render_mermaid_overlays_to_texture(&mut self, ctx: &egui::Context) {
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let scale = self.active_session().canvas_state.zoom.max(1.0) * ppp;
|
||||
let session = self.active_session_mut();
|
||||
let session_id = session.id.clone();
|
||||
|
||||
for overlay in &mut session.mermaid_overlays {
|
||||
if overlay.texture.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(pixmap) = overlay.renderer.render(scale) {
|
||||
let size = [pixmap.width() as usize, pixmap.height() as usize];
|
||||
let pixels: Vec<Color32> = pixmap
|
||||
.pixels()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let image = ColorImage { size, pixels };
|
||||
overlay.texture = Some(ctx.load_texture(
|
||||
format!(
|
||||
"mermaid-{}-{}-{}-{}",
|
||||
session_id,
|
||||
overlay.id,
|
||||
overlay.mermaid_source.len(),
|
||||
overlay.svg_source.len()
|
||||
),
|
||||
image,
|
||||
TextureOptions::LINEAR,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_mermaid_render(&mut self, ctx: &egui::Context) {
|
||||
fn handle_mermaid_render(&mut self, _ctx: &egui::Context) {
|
||||
let source = self.mermaid_input.trim().to_string();
|
||||
if source.is_empty() {
|
||||
self.set_status("Mermaid input is empty".to_string());
|
||||
@@ -336,11 +306,23 @@ impl AgCanvasApp {
|
||||
}
|
||||
|
||||
match render_mermaid_to_svg(&source) {
|
||||
Ok(svg_string) => {
|
||||
Ok(svg_source) => {
|
||||
self.show_mermaid_dialog = false;
|
||||
self.mermaid_input.clear();
|
||||
self.load_svg_data(&svg_string, ctx);
|
||||
self.set_status("Rendered Mermaid diagram".to_string());
|
||||
|
||||
match crate::svg::svg_to_drawing_elements(&svg_source, 0.0, 0.0) {
|
||||
Ok(elements) => {
|
||||
let count = elements.len();
|
||||
self.active_session_mut().drawing_elements.extend(elements);
|
||||
self.active_session_mut()
|
||||
.record_edit("Paste Mermaid", crate::history::ChangeSource::Human);
|
||||
self.sync_drawing_elements_to_store();
|
||||
self.set_status(format!("Rendered Mermaid diagram ({} elements)", count));
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_status(format!("Failed to convert Mermaid: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_status(format!("Mermaid error: {}", e));
|
||||
@@ -402,38 +384,6 @@ impl AgCanvasApp {
|
||||
.record_edit("Agent: Clear Canvas", ChangeSource::Agent { name: None });
|
||||
}
|
||||
}
|
||||
DrawingCommand::RenderMermaid {
|
||||
session_id,
|
||||
overlay_id,
|
||||
mermaid_source,
|
||||
svg_source,
|
||||
position,
|
||||
size,
|
||||
} => {
|
||||
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
|
||||
match parse_svg(&svg_source) {
|
||||
Ok((_, usvg_tree)) => {
|
||||
let overlay = MermaidOverlay {
|
||||
id: overlay_id,
|
||||
mermaid_source,
|
||||
svg_source,
|
||||
renderer: SvgRenderer::new(usvg_tree),
|
||||
texture: None,
|
||||
position,
|
||||
size,
|
||||
};
|
||||
session.mermaid_overlays.push(overlay);
|
||||
session.record_edit(
|
||||
"Agent: Render Mermaid",
|
||||
ChangeSource::Agent { name: None },
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse Mermaid overlay SVG: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -480,23 +430,47 @@ impl AgCanvasApp {
|
||||
|
||||
fn sync_drawing_elements_to_store(&self) {
|
||||
let sessions_handle = self.sessions_handle.clone();
|
||||
let elements_by_session: Vec<(String, Vec<DrawingElement>)> = self
|
||||
let session_snapshots: Vec<SessionExportSnapshot> = self
|
||||
.sessions
|
||||
.iter()
|
||||
.map(|s| (s.id.clone(), s.drawing_elements.clone()))
|
||||
.map(|s| (s.id.clone(), s.drawing_elements.clone(), s.svg_source.clone()))
|
||||
.collect();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut store = sessions_handle.write().await;
|
||||
for (sid, elements) in elements_by_session {
|
||||
for (sid, elements, svg_source) in session_snapshots {
|
||||
store.update_drawing_elements(&sid, elements);
|
||||
store.update_export_layers(&sid, svg_source);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn export_canvas_png(&mut self) {
|
||||
let session = self.active_session();
|
||||
let session_id = session.id.clone();
|
||||
let export_data = ExportData {
|
||||
svg_source: session.svg_source.clone(),
|
||||
drawing_elements: session.drawing_elements.clone(),
|
||||
background_color: tiny_skia::Color::from_rgba8(30, 30, 30, 255),
|
||||
};
|
||||
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!("canvas_export_{}.png", session_id));
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
|
||||
match export::export_canvas_to_png(&export_data, &path_str, 2.0) {
|
||||
Ok((width, height)) => {
|
||||
self.set_status(format!("Exported PNG: {} ({}x{})", path_str, width, height));
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_status(format!("Export failed: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_saved_workspace(&self) -> SavedWorkspace {
|
||||
let sessions: Vec<SavedSession> = self
|
||||
.sessions
|
||||
@@ -557,6 +531,7 @@ impl AgCanvasApp {
|
||||
|
||||
let info = session.info();
|
||||
let tree_clone = session.element_tree.clone();
|
||||
let svg_source_clone = saved.svg_source.clone();
|
||||
self.sessions.push(session);
|
||||
|
||||
let sessions_handle = self.sessions_handle.clone();
|
||||
@@ -565,7 +540,7 @@ impl AgCanvasApp {
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut store = sessions_handle.write().await;
|
||||
store.add_session(info_clone, tree_clone);
|
||||
store.add_session(info_clone, tree_clone, svg_source_clone);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -619,6 +594,7 @@ impl AgCanvasApp {
|
||||
CommandId::ClearCanvas => self.clear_canvas(),
|
||||
CommandId::PasteSvg => self.handle_paste(ctx),
|
||||
CommandId::PasteMermaid => self.show_mermaid_dialog = true,
|
||||
CommandId::ExportPng => self.export_canvas_png(),
|
||||
CommandId::ToolSelect => self.active_session_mut().active_tool = Tool::Select,
|
||||
CommandId::ToolPan => self.active_session_mut().active_tool = Tool::Pan,
|
||||
CommandId::ToolRectangle => self.active_session_mut().active_tool = Tool::Rectangle,
|
||||
@@ -931,6 +907,7 @@ impl eframe::App for AgCanvasApp {
|
||||
let mut toggle_history = false;
|
||||
let mut undo = false;
|
||||
let mut redo = false;
|
||||
let mut export_png = false;
|
||||
let mut tool_switch: Option<Tool> = None;
|
||||
|
||||
let palette_open = self.command_palette.visible;
|
||||
@@ -962,6 +939,9 @@ impl eframe::App for AgCanvasApp {
|
||||
undo = true;
|
||||
}
|
||||
}
|
||||
if i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::E) {
|
||||
export_png = true;
|
||||
}
|
||||
if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) {
|
||||
delete_selected = true;
|
||||
}
|
||||
@@ -1011,6 +991,9 @@ impl eframe::App for AgCanvasApp {
|
||||
if toggle_history {
|
||||
self.show_history_panel = !self.show_history_panel;
|
||||
}
|
||||
if export_png {
|
||||
self.export_canvas_png();
|
||||
}
|
||||
if delete_selected
|
||||
&& !self.show_text_input
|
||||
&& !self.show_mermaid_dialog
|
||||
@@ -1102,14 +1085,6 @@ impl eframe::App for AgCanvasApp {
|
||||
{
|
||||
self.render_svg_to_texture(ctx);
|
||||
}
|
||||
if self
|
||||
.active_session()
|
||||
.mermaid_overlays
|
||||
.iter()
|
||||
.any(|overlay| overlay.texture.is_none())
|
||||
{
|
||||
self.render_mermaid_overlays_to_texture(ctx);
|
||||
}
|
||||
|
||||
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
||||
egui::menu::bar(ui, |ui| {
|
||||
@@ -1133,6 +1108,11 @@ impl eframe::App for AgCanvasApp {
|
||||
ui.close_menu();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Export as PNG (Cmd+Shift+E)").clicked() {
|
||||
self.export_canvas_png();
|
||||
ui.close_menu();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Clear Canvas").clicked() {
|
||||
self.clear_canvas();
|
||||
ui.close_menu();
|
||||
@@ -1577,21 +1557,6 @@ impl eframe::App for AgCanvasApp {
|
||||
let offset = self.active_session().canvas_state.offset;
|
||||
let zoom = self.active_session().canvas_state.zoom;
|
||||
|
||||
for overlay in &self.active_session().mermaid_overlays {
|
||||
if let Some(texture) = &overlay.texture {
|
||||
let screen_pos =
|
||||
canvas_to_screen(overlay.position, canvas_center, offset, zoom);
|
||||
let screen_size = overlay.size * zoom;
|
||||
let rect = egui::Rect::from_min_size(screen_pos, screen_size);
|
||||
painter.image(
|
||||
texture.id(),
|
||||
rect,
|
||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
Color32::WHITE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
draw_elements(
|
||||
&painter,
|
||||
&self.active_session().drawing_elements,
|
||||
@@ -1621,7 +1586,6 @@ impl eframe::App for AgCanvasApp {
|
||||
);
|
||||
|
||||
if self.active_session().svg_texture.is_none()
|
||||
&& self.active_session().mermaid_overlays.is_empty()
|
||||
&& self.active_session().drawing_elements.is_empty()
|
||||
{
|
||||
painter.text(
|
||||
|
||||
@@ -6,7 +6,7 @@ mod tool;
|
||||
pub use boolean::BooleanOpType;
|
||||
pub use element::{DrawingElement, Shape, ShapeStyle};
|
||||
pub use render::{
|
||||
canvas_to_screen, draw_creation_preview, draw_elements, draw_marquee, draw_selection,
|
||||
find_handle_at_screen_pos, screen_to_canvas,
|
||||
draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos,
|
||||
screen_to_canvas,
|
||||
};
|
||||
pub use tool::{DragState, Tool};
|
||||
|
||||
@@ -77,16 +77,6 @@ pub struct SessionInfo {
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
pub struct MermaidOverlay {
|
||||
pub id: String,
|
||||
pub mermaid_source: String,
|
||||
pub svg_source: String,
|
||||
pub renderer: SvgRenderer,
|
||||
pub texture: Option<TextureHandle>,
|
||||
pub position: egui::Pos2,
|
||||
pub size: egui::Vec2,
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -98,7 +88,6 @@ pub struct Session {
|
||||
pub description_text: String,
|
||||
|
||||
pub drawing_elements: Vec<DrawingElement>,
|
||||
pub mermaid_overlays: Vec<MermaidOverlay>,
|
||||
pub selected_element_ids: Vec<String>,
|
||||
pub active_tool: Tool,
|
||||
pub drag_state: DragState,
|
||||
@@ -121,7 +110,6 @@ impl Session {
|
||||
svg_source: None,
|
||||
description_text: String::new(),
|
||||
drawing_elements: Vec::new(),
|
||||
mermaid_overlays: Vec::new(),
|
||||
selected_element_ids: Vec::new(),
|
||||
active_tool: Tool::default(),
|
||||
drag_state: DragState::default(),
|
||||
@@ -157,7 +145,6 @@ impl Session {
|
||||
self.svg_source = None;
|
||||
self.description_text.clear();
|
||||
self.drawing_elements.clear();
|
||||
self.mermaid_overlays.clear();
|
||||
self.selected_element_ids.clear();
|
||||
self.drag_state = DragState::default();
|
||||
self.canvas_state.reset();
|
||||
@@ -206,7 +193,6 @@ impl Session {
|
||||
self.svg_renderer = None;
|
||||
self.svg_texture = None;
|
||||
self.description_text.clear();
|
||||
self.mermaid_overlays.clear();
|
||||
self.selected_element_ids.clear();
|
||||
self.drag_state = DragState::default();
|
||||
}
|
||||
@@ -219,7 +205,6 @@ impl Session {
|
||||
self.svg_renderer = None;
|
||||
self.svg_texture = None;
|
||||
self.description_text.clear();
|
||||
self.mermaid_overlays.clear();
|
||||
self.selected_element_ids.clear();
|
||||
self.drag_state = DragState::default();
|
||||
true
|
||||
@@ -236,7 +221,6 @@ impl Session {
|
||||
self.svg_renderer = None;
|
||||
self.svg_texture = None;
|
||||
self.description_text.clear();
|
||||
self.mermaid_overlays.clear();
|
||||
self.selected_element_ids.clear();
|
||||
self.drag_state = DragState::default();
|
||||
true
|
||||
@@ -250,9 +234,12 @@ impl Session {
|
||||
pub struct SessionData {
|
||||
pub info: SessionInfo,
|
||||
pub tree: Option<ElementTree>,
|
||||
pub svg_source: Option<String>,
|
||||
pub drawing_elements: Vec<DrawingElement>,
|
||||
}
|
||||
|
||||
pub type ExportSessionData = (String, Option<String>, Vec<DrawingElement>);
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SessionStore {
|
||||
sessions: HashMap<String, SessionData>,
|
||||
@@ -265,13 +252,19 @@ impl SessionStore {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn add_session(&mut self, info: SessionInfo, tree: Option<ElementTree>) {
|
||||
pub fn add_session(
|
||||
&mut self,
|
||||
info: SessionInfo,
|
||||
tree: Option<ElementTree>,
|
||||
svg_source: Option<String>,
|
||||
) {
|
||||
let id = info.id.clone();
|
||||
self.sessions.insert(
|
||||
id.clone(),
|
||||
SessionData {
|
||||
info,
|
||||
tree,
|
||||
svg_source,
|
||||
drawing_elements: Vec::new(),
|
||||
},
|
||||
);
|
||||
@@ -293,11 +286,17 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_tree(&mut self, session_id: &str, tree: Option<ElementTree>) {
|
||||
pub fn update_tree(
|
||||
&mut self,
|
||||
session_id: &str,
|
||||
tree: Option<ElementTree>,
|
||||
svg_source: Option<String>,
|
||||
) {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
data.info.has_svg = tree.is_some();
|
||||
data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count);
|
||||
data.tree = tree;
|
||||
data.svg_source = svg_source;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +315,12 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_export_layers(&mut self, session_id: &str, svg_source: Option<String>) {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
data.svg_source = svg_source;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_drawing_elements(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
@@ -327,6 +332,14 @@ impl SessionStore {
|
||||
Some((id, &data.drawing_elements))
|
||||
}
|
||||
|
||||
pub fn get_export_data(&self, session_id: Option<&str>) -> Option<ExportSessionData> {
|
||||
let id = session_id
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| self.active_session_id.clone())?;
|
||||
let data = self.sessions.get(&id)?;
|
||||
Some((id, data.svg_source.clone(), data.drawing_elements.clone()))
|
||||
}
|
||||
|
||||
pub fn add_drawing_element(&mut self, session_id: &str, element: DrawingElement) -> bool {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
data.drawing_elements.push(element);
|
||||
|
||||
303
crates/agcanvas/src/svg/converter.rs
Normal file
303
crates/agcanvas/src/svg/converter.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
use anyhow::Result;
|
||||
use egui::{Color32, Pos2, Vec2};
|
||||
use usvg::tiny_skia_path::PathSegment;
|
||||
|
||||
use crate::drawing::{DrawingElement, Shape, ShapeStyle};
|
||||
|
||||
/// 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(
|
||||
svg_source: &str,
|
||||
offset_x: f32,
|
||||
offset_y: f32,
|
||||
) -> Result<Vec<DrawingElement>> {
|
||||
let mut options = usvg::Options::default();
|
||||
options.fontdb_mut().load_system_fonts();
|
||||
let tree = usvg::Tree::from_str(svg_source, &options)?;
|
||||
|
||||
let mut elements = Vec::new();
|
||||
walk_group(tree.root(), offset_x, offset_y, &mut elements);
|
||||
Ok(elements)
|
||||
}
|
||||
|
||||
fn walk_group(group: &usvg::Group, ox: f32, oy: f32, elements: &mut Vec<DrawingElement>) {
|
||||
let (gx, gy) = extract_group_translate(group);
|
||||
let next_ox = ox + gx;
|
||||
let next_oy = oy + gy;
|
||||
|
||||
for node in group.children() {
|
||||
match node {
|
||||
usvg::Node::Group(g) => walk_group(g, next_ox, next_oy, elements),
|
||||
usvg::Node::Path(path) => {
|
||||
if let Some(element) = convert_path(path, next_ox, next_oy) {
|
||||
elements.push(element);
|
||||
}
|
||||
}
|
||||
usvg::Node::Text(text) => {
|
||||
if let Some(element) = convert_text(text, next_ox, next_oy) {
|
||||
elements.push(element);
|
||||
}
|
||||
}
|
||||
usvg::Node::Image(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_group_translate(group: &usvg::Group) -> (f32, f32) {
|
||||
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();
|
||||
// Skip degenerate paths (zero area AND zero length — truly empty)
|
||||
if bbox.width() <= 0.0 && bbox.height() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if is_background_rect(path) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip tiny marker definition fragments
|
||||
if bbox.width() < 1.0 && bbox.height() < 1.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let segments: Vec<_> = path.data().segments().collect();
|
||||
if segments.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let style = extract_style(path);
|
||||
|
||||
if is_rect_path(&segments) {
|
||||
return Some(DrawingElement::new(
|
||||
Shape::Rectangle {
|
||||
pos: Pos2::new(bbox.left() + ox, bbox.top() + oy),
|
||||
size: Vec2::new(bbox.width(), bbox.height()),
|
||||
},
|
||||
style,
|
||||
));
|
||||
}
|
||||
|
||||
if is_line_path(&segments) {
|
||||
let (start, end) = extract_line_endpoints(&segments)?;
|
||||
let shape = if is_stroke_only(path) {
|
||||
Shape::Arrow {
|
||||
start: Pos2::new(start.0 + ox, start.1 + oy),
|
||||
end: Pos2::new(end.0 + ox, end.1 + oy),
|
||||
}
|
||||
} else {
|
||||
Shape::Line {
|
||||
start: Pos2::new(start.0 + ox, start.1 + oy),
|
||||
end: Pos2::new(end.0 + ox, end.1 + oy),
|
||||
}
|
||||
};
|
||||
return Some(DrawingElement::new(shape, style));
|
||||
}
|
||||
|
||||
let aspect = bbox.width() / bbox.height().max(0.001);
|
||||
if (0.9..=1.1).contains(&aspect) && has_curves(&segments) {
|
||||
return Some(DrawingElement::new(
|
||||
Shape::Ellipse {
|
||||
center: Pos2::new(
|
||||
bbox.left() + bbox.width() * 0.5 + ox,
|
||||
bbox.top() + bbox.height() * 0.5 + oy,
|
||||
),
|
||||
radii: Vec2::new(bbox.width() * 0.5, bbox.height() * 0.5),
|
||||
},
|
||||
style,
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn convert_text(text: &usvg::Text, ox: f32, oy: f32) -> Option<DrawingElement> {
|
||||
let content = extract_text_content(text);
|
||||
if content.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bbox = text.bounding_box();
|
||||
let font_size = extract_font_size(text);
|
||||
let fill_color = extract_text_color(text);
|
||||
|
||||
Some(DrawingElement::new(
|
||||
Shape::Text {
|
||||
pos: Pos2::new(bbox.left() + ox, bbox.top() + oy),
|
||||
content,
|
||||
font_size,
|
||||
},
|
||||
ShapeStyle {
|
||||
fill: None,
|
||||
stroke_color: fill_color,
|
||||
stroke_width: 0.0,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_text_content(text: &usvg::Text) -> String {
|
||||
text.chunks()
|
||||
.iter()
|
||||
.map(|chunk| chunk.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn extract_font_size(text: &usvg::Text) -> f32 {
|
||||
text.chunks()
|
||||
.iter()
|
||||
.flat_map(|chunk| chunk.spans().iter())
|
||||
.next()
|
||||
.map(|span| span.font_size().get())
|
||||
.unwrap_or(16.0)
|
||||
}
|
||||
|
||||
fn extract_text_color(text: &usvg::Text) -> Color32 {
|
||||
for chunk in text.chunks() {
|
||||
for span in chunk.spans() {
|
||||
if let Some(fill) = span.fill() {
|
||||
if let usvg::Paint::Color(c) = fill.paint() {
|
||||
return Color32::from_rgb(c.red, c.green, c.blue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Color32::WHITE
|
||||
}
|
||||
|
||||
fn extract_style(path: &usvg::Path) -> ShapeStyle {
|
||||
let fill = path.fill().and_then(|f| match f.paint() {
|
||||
usvg::Paint::Color(c) => Some(Color32::from_rgb(c.red, c.green, c.blue)),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let (stroke_color, stroke_width) = path
|
||||
.stroke()
|
||||
.map(|s| {
|
||||
let color = match s.paint() {
|
||||
usvg::Paint::Color(c) => Color32::from_rgb(c.red, c.green, c.blue),
|
||||
_ => Color32::WHITE,
|
||||
};
|
||||
(color, s.width().get())
|
||||
})
|
||||
.unwrap_or((Color32::WHITE, 2.0));
|
||||
|
||||
ShapeStyle {
|
||||
fill,
|
||||
stroke_color,
|
||||
stroke_width,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_background_rect(path: &usvg::Path) -> bool {
|
||||
let Some(fill) = path.fill() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let usvg::Paint::Color(c) = fill.paint() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !(c.red > 240 && c.green > 240 && c.blue > 240) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let bbox = path.bounding_box();
|
||||
bbox.left().abs() < 1.0 && bbox.top().abs() < 1.0
|
||||
}
|
||||
|
||||
fn is_rect_path(segments: &[PathSegment]) -> bool {
|
||||
let has_close = segments.iter().any(|s| matches!(s, PathSegment::Close));
|
||||
if !has_close {
|
||||
return false;
|
||||
}
|
||||
|
||||
let line_count = segments
|
||||
.iter()
|
||||
.filter(|s| matches!(s, PathSegment::LineTo(_)))
|
||||
.count();
|
||||
line_count >= 3
|
||||
}
|
||||
|
||||
fn is_line_path(segments: &[PathSegment]) -> bool {
|
||||
!segments.iter().any(|s| matches!(s, PathSegment::Close))
|
||||
}
|
||||
|
||||
fn extract_line_endpoints(segments: &[PathSegment]) -> Option<((f32, f32), (f32, f32))> {
|
||||
let mut first: Option<(f32, f32)> = None;
|
||||
let mut last: Option<(f32, f32)> = None;
|
||||
|
||||
for segment in segments {
|
||||
let point = match segment {
|
||||
PathSegment::MoveTo(p) => (p.x, p.y),
|
||||
PathSegment::LineTo(p) => (p.x, p.y),
|
||||
PathSegment::QuadTo(_, p) => (p.x, p.y),
|
||||
PathSegment::CubicTo(_, _, p) => (p.x, p.y),
|
||||
PathSegment::Close => continue,
|
||||
};
|
||||
|
||||
if first.is_none() {
|
||||
first = Some(point);
|
||||
}
|
||||
last = Some(point);
|
||||
}
|
||||
|
||||
match (first, last) {
|
||||
(Some(a), Some(b)) => Some((a, b)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_curves(segments: &[PathSegment]) -> bool {
|
||||
segments
|
||||
.iter()
|
||||
.any(|s| matches!(s, PathSegment::QuadTo(_, _) | PathSegment::CubicTo(_, _, _)))
|
||||
}
|
||||
|
||||
fn is_stroke_only(path: &usvg::Path) -> bool {
|
||||
path.fill().is_none() && path.stroke().is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn converts_simple_mermaid_svg() {
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="70" viewBox="0 0 100 70"><rect x="0" y="0" width="100" height="70" fill="#FFFFFF"/><rect x="10" y="10" width="80" height="50" rx="3" ry="3" fill="#F8FAFF" stroke="#C7D2E5" stroke-width="1"/><text x="50" y="40" text-anchor="middle" font-family="sans-serif" font-size="13" fill="#1C2430"><tspan x="50" dy="0">Hello</tspan></text></svg>"##;
|
||||
let elements = svg_to_drawing_elements(svg, 0.0, 0.0).expect("converter should parse svg");
|
||||
|
||||
assert!(
|
||||
elements.len() >= 2,
|
||||
"Expected at least 2 elements, got {}",
|
||||
elements.len()
|
||||
);
|
||||
|
||||
let has_rect = elements
|
||||
.iter()
|
||||
.any(|element| matches!(element.shape, Shape::Rectangle { .. }));
|
||||
assert!(has_rect, "Expected a rectangle element");
|
||||
|
||||
let has_text = elements
|
||||
.iter()
|
||||
.any(|element| matches!(element.shape, Shape::Text { .. }));
|
||||
assert!(has_text, "Expected a text element");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_offset() {
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="70"><rect x="0" y="0" width="100" height="70" fill="#FFFFFF"/><rect x="10" y="10" width="80" height="50" fill="#F8FAFF" stroke="#C7D2E5"/></svg>"##;
|
||||
let elements =
|
||||
svg_to_drawing_elements(svg, 100.0, 200.0).expect("converter should parse svg");
|
||||
|
||||
if let Some(element) = elements.first() {
|
||||
let rect = element.bounding_rect();
|
||||
assert!(rect.min.x >= 100.0, "Expected x offset applied");
|
||||
assert!(rect.min.y >= 200.0, "Expected y offset applied");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
mod converter;
|
||||
mod parser;
|
||||
mod renderer;
|
||||
|
||||
pub use converter::svg_to_drawing_elements;
|
||||
pub use parser::parse_svg;
|
||||
pub use renderer::SvgRenderer;
|
||||
|
||||
Reference in New Issue
Block a user