Compare commits
12 Commits
e8ec44d961
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5faf69cad | ||
|
|
519ed74a3a | ||
|
|
64b4f667fb | ||
|
|
9e9d33eb84 | ||
|
|
519d1f2459 | ||
|
|
8390d01f85 | ||
|
|
740fa2f5f9 | ||
|
|
5ca1e85209 | ||
|
|
9b8acd4002 | ||
|
|
1929023409 | ||
|
|
ce2079ad95 | ||
|
|
9489c390fa |
19
AGENTS.md
19
AGENTS.md
@@ -11,6 +11,7 @@ Guidelines for AI agents working in this Rust codebase.
|
|||||||
- WebSocket: tokio-tungstenite
|
- WebSocket: tokio-tungstenite
|
||||||
- Serialization: serde/serde_json
|
- Serialization: serde/serde_json
|
||||||
- Error handling: anyhow/thiserror
|
- Error handling: anyhow/thiserror
|
||||||
|
- Boolean ops: i_overlay, triangulation: earcutr
|
||||||
- Logging: tracing
|
- Logging: tracing
|
||||||
|
|
||||||
## Build/Lint/Test Commands
|
## Build/Lint/Test Commands
|
||||||
@@ -29,16 +30,31 @@ cargo test -- --nocapture # Show println! output
|
|||||||
|
|
||||||
cargo check # Type check only (fast)
|
cargo check # Type check only (fast)
|
||||||
cargo doc --open # Generate and open docs
|
cargo doc --open # Generate and open docs
|
||||||
|
|
||||||
|
./scripts/bundle-macos.sh --install # Rebuild + install .app to /Applications
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**IMPORTANT:** After every release build or code change that requires testing the running app, you MUST run `./scripts/bundle-macos.sh --install` to update the macOS `.app` bundle in `/Applications`. The running `Augmented Canvas.app` uses a copied binary — a bare `cargo build --release` alone does NOT update it. Kill the running app first with `pkill -f "Augmented Canvas"`, then rebuild and relaunch with `open "/Applications/Augmented Canvas.app"`.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── main.rs # Entry point, window setup
|
├── main.rs # Entry point, window setup
|
||||||
├── app.rs # Main app state, UI (eframe::App impl)
|
├── app.rs # Main app state, UI (eframe::App impl)
|
||||||
|
├── session.rs # Session/tab state with history integration
|
||||||
|
├── history.rs # Undo tree: branching history, snapshots, checkout, fork
|
||||||
|
├── persistence.rs # Workspace save/load
|
||||||
|
├── command_palette.rs # Cmd+K fuzzy command palette
|
||||||
├── element_tree.rs # ElementTree, Element, ElementKind types
|
├── element_tree.rs # ElementTree, Element, ElementKind types
|
||||||
├── clipboard.rs # System clipboard integration
|
├── clipboard.rs # System clipboard integration
|
||||||
|
├── mermaid.rs # Mermaid -> SVG rendering
|
||||||
|
├── drawing/
|
||||||
|
│ ├── element.rs # DrawingElement, Shape (incl. Path), ShapeStyle, hit testing
|
||||||
|
│ ├── boolean.rs # Boolean shape ops (union, intersection, difference, xor)
|
||||||
|
│ ├── tool.rs # Tool enum, DragState, ResizeHandle
|
||||||
|
│ ├── render.rs # Shape rendering via egui Painter + triangulation
|
||||||
|
│ └── mod.rs # Re-exports
|
||||||
├── canvas/
|
├── canvas/
|
||||||
│ ├── state.rs # Pan/zoom transformation state
|
│ ├── state.rs # Pan/zoom transformation state
|
||||||
│ └── interaction.rs # Mouse/keyboard input handling
|
│ └── interaction.rs # Mouse/keyboard input handling
|
||||||
@@ -46,7 +62,7 @@ src/
|
|||||||
│ ├── parser.rs # SVG -> ElementTree conversion
|
│ ├── parser.rs # SVG -> ElementTree conversion
|
||||||
│ └── renderer.rs # SVG -> pixels (resvg/tiny-skia)
|
│ └── renderer.rs # SVG -> pixels (resvg/tiny-skia)
|
||||||
└── agent/
|
└── agent/
|
||||||
├── protocol.rs # JSON message types
|
├── protocol.rs # JSON message types (incl. BooleanOp)
|
||||||
└── server.rs # WebSocket server (ws://127.0.0.1:9876)
|
└── server.rs # WebSocket server (ws://127.0.0.1:9876)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -163,6 +179,7 @@ WebSocket server on `ws://127.0.0.1:9876`:
|
|||||||
{"type": "GetElementById", "id": "button-1"}
|
{"type": "GetElementById", "id": "button-1"}
|
||||||
{"type": "Describe"}
|
{"type": "Describe"}
|
||||||
{"type": "GenerateCode", "target": "react", "element_id": null}
|
{"type": "GenerateCode", "target": "react", "element_id": null}
|
||||||
|
{"type": "BooleanOp", "operation": "union", "element_ids": ["id1", "id2"], "consume_sources": true}
|
||||||
|
|
||||||
// Response
|
// Response
|
||||||
{"type": "Tree", "tree": {...}}
|
{"type": "Tree", "tree": {...}}
|
||||||
|
|||||||
95
README.md
95
README.md
@@ -31,10 +31,14 @@ agcanvas (short for **Augmented Canvas**) bridges the gap between visual design
|
|||||||
### Canvas & Drawing
|
### Canvas & Drawing
|
||||||
- **SVG Paste** — Copy frames from Figma, paste directly (Cmd+V)
|
- **SVG Paste** — Copy frames from Figma, paste directly (Cmd+V)
|
||||||
- **Shape Drawing** — Rectangles, ellipses, lines, arrows, text directly on canvas
|
- **Shape Drawing** — Rectangles, ellipses, lines, arrows, text directly on canvas
|
||||||
|
- **Boolean Shape Operations** — Union, intersection, difference, XOR on overlapping shapes via agent API
|
||||||
- **Selection & Editing** — Select, move, resize shapes with corner handles
|
- **Selection & Editing** — Select, move, resize shapes with corner handles
|
||||||
- **Mermaid Diagrams** — Write Mermaid syntax, render as SVG on canvas
|
- **Mermaid Diagrams** — Write Mermaid syntax, renders as interactive drawing elements (rectangles, arrows, text) that can be selected, moved, and resized. Supports edge labels (`-->|Yes|`)
|
||||||
|
- **Export to PNG** — Export canvas as high-DPI PNG via File menu (Cmd+Shift+E) or MCP tool
|
||||||
- **Sessions/Tabs** — Multiple canvases in tabs, each with independent state, creator tracking (human vs agent), descriptions, and timestamps
|
- **Sessions/Tabs** — Multiple canvases in tabs, each with independent state, creator tracking (human vs agent), descriptions, and timestamps
|
||||||
- **Pan/Zoom** — Smooth canvas navigation
|
- **Visual Undo Tree** — Git-like branching history with checkout, fork, and tree visualization (Cmd+H)
|
||||||
|
- **Pan/Zoom** — Smooth canvas navigation with Pan tool (H), middle-click drag, and zoom reset (click zoom %)
|
||||||
|
- **Batch Commands** — Agents can send multiple operations in a single request for faster workflows
|
||||||
- **Session Persistence** — Auto-saves workspace to `~/Library/Application Support/agcanvas/`, restores all tabs on launch
|
- **Session Persistence** — Auto-saves workspace to `~/Library/Application Support/agcanvas/`, restores all tabs on launch
|
||||||
- **Command Palette** — Cmd+K to search and execute any command with fuzzy matching
|
- **Command Palette** — Cmd+K to search and execute any command with fuzzy matching
|
||||||
|
|
||||||
@@ -99,36 +103,23 @@ agcanvas-mcp --help
|
|||||||
|
|
||||||
### macOS `.app` Bundle
|
### macOS `.app` Bundle
|
||||||
|
|
||||||
agcanvas compiles into a native macOS application. The release binary (`target/release/agcanvas`) runs directly, but if you want a proper `.app` bundle you can double-click in Finder or drag to `/Applications`, use [`cargo-bundle`](https://github.com/burtonageo/cargo-bundle):
|
agcanvas compiles into a native macOS application (Apple Silicon and Intel). Use the bundling script to create an `Augmented Canvas.app` you can open from Finder or drag to `/Applications`:
|
||||||
|
|
||||||
1. **Install cargo-bundle:**
|
|
||||||
```bash
|
```bash
|
||||||
cargo install cargo-bundle
|
./scripts/bundle-macos.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Add bundle metadata** to `crates/agcanvas/Cargo.toml`:
|
This builds a release binary and packages it into `target/release/bundle/Augmented Canvas.app`.
|
||||||
```toml
|
|
||||||
[package.metadata.bundle]
|
To install directly to `/Applications`:
|
||||||
name = "Augmented Canvas"
|
|
||||||
identifier = "com.agcanvas.app"
|
|
||||||
icon = ["assets/icon.icns"]
|
|
||||||
category = "public.app-category.developer-tools"
|
|
||||||
short_description = "Interactive canvas for agent-human collaboration"
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Bundle it:**
|
|
||||||
```bash
|
```bash
|
||||||
cargo bundle --release -p agcanvas
|
./scripts/bundle-macos.sh --install
|
||||||
```
|
```
|
||||||
|
|
||||||
This produces `target/release/bundle/osx/Augmented Canvas.app`.
|
To add a custom icon, place an `AppIcon.icns` file in `assets/` before bundling.
|
||||||
|
|
||||||
4. **Install:**
|
> **Note:** The raw `cargo build --release` binary already runs as a native macOS app — the bundle script wraps it in a `.app` with Info.plist, Finder integration, and HiDPI support. No third-party tools required.
|
||||||
```bash
|
|
||||||
cp -r "target/release/bundle/osx/Augmented Canvas.app" /Applications/
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** The raw `cargo build --release` binary already works as a native macOS app — `cargo-bundle` just wraps it in a `.app` bundle with an icon, metadata, and Finder integration. No code changes are needed.
|
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
@@ -157,6 +148,7 @@ agcanvas compiles into a native macOS application. The release binary (`target/r
|
|||||||
| Action | Shortcut |
|
| Action | Shortcut |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
| Select tool | V |
|
| Select tool | V |
|
||||||
|
| Pan tool | H |
|
||||||
| Rectangle tool | R |
|
| Rectangle tool | R |
|
||||||
| Ellipse tool | E |
|
| Ellipse tool | E |
|
||||||
| Line tool | L |
|
| Line tool | L |
|
||||||
@@ -165,10 +157,14 @@ agcanvas compiles into a native macOS application. The release binary (`target/r
|
|||||||
| Delete selected | Delete / Backspace |
|
| Delete selected | Delete / Backspace |
|
||||||
| Cancel / back to Select | Escape |
|
| Cancel / back to Select | Escape |
|
||||||
| Paste SVG | Cmd+V |
|
| Paste SVG | Cmd+V |
|
||||||
|
| Export as PNG | Cmd+Shift+E |
|
||||||
| New Tab | Cmd+T |
|
| New Tab | Cmd+T |
|
||||||
| Close Tab | Cmd+W |
|
| Close Tab | Cmd+W |
|
||||||
| Save workspace | Cmd+S |
|
| Save workspace | Cmd+S |
|
||||||
| Command palette | Cmd+K |
|
| Command palette | Cmd+K |
|
||||||
|
| Toggle history panel | Cmd+H |
|
||||||
|
| Undo | Cmd+Z |
|
||||||
|
| Redo | Cmd+Shift+Z |
|
||||||
| Reset zoom | Cmd+0 |
|
| Reset zoom | Cmd+0 |
|
||||||
|
|
||||||
## MCP Server (AI Agent Integration)
|
## MCP Server (AI Agent Integration)
|
||||||
@@ -229,6 +225,10 @@ Same MCP config format — add the `agcanvas` entry to your Codex MCP configurat
|
|||||||
| `update_drawing_element` | Update an existing drawing element's shape or style |
|
| `update_drawing_element` | Update an existing drawing element's shape or style |
|
||||||
| `delete_drawing_element` | Delete a drawing element by ID |
|
| `delete_drawing_element` | Delete a drawing element by ID |
|
||||||
| `clear_drawing_elements` | Clear all drawing elements from the canvas |
|
| `clear_drawing_elements` | Clear all drawing elements from the canvas |
|
||||||
|
| `boolean_op` | Perform boolean operations (union, intersection, difference, xor) on two or more shapes |
|
||||||
|
| `render_mermaid` | Render a Mermaid diagram as interactive drawing elements on the canvas |
|
||||||
|
| `export_canvas` | Export the canvas as a high-DPI PNG image |
|
||||||
|
| `batch` | Send multiple operations in one request for faster agent workflows |
|
||||||
|
|
||||||
All tools accept an optional `session_id` parameter. If omitted, the active session is used.
|
All tools accept an optional `session_id` parameter. If omitted, the active session is used.
|
||||||
|
|
||||||
@@ -332,6 +332,34 @@ Response:
|
|||||||
|
|
||||||
Targets: `html`, `react`, `tailwind`, `svelte`, `vue`
|
Targets: `html`, `react`, `tailwind`, `svelte`, `vue`
|
||||||
|
|
||||||
|
#### Boolean operation on shapes
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "BooleanOp", "operation": "union", "element_ids": ["elem-1", "elem-2"], "consume_sources": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Operations: `union`, `intersection`, `difference`, `xor`. Set `consume_sources` to `true` to delete the source shapes after the operation. Optional `style` object to override the result's appearance.
|
||||||
|
|
||||||
|
#### Render Mermaid diagram
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "RenderMermaid", "mermaid_source": "flowchart LR\n A-->|Yes| B[OK]", "x": 0, "y": 0}
|
||||||
|
```
|
||||||
|
|
||||||
|
Renders Mermaid syntax into interactive drawing elements (rectangles, arrows, text). Supports edge labels (`-->|Yes|`), decision diamonds, and all flowchart/sequence/class diagram types.
|
||||||
|
|
||||||
|
#### Export canvas as PNG
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "ExportCanvas", "path": "/tmp/canvas.png", "scale": 2.0, "background": "#1e1e1e"}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Batch operations
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "Batch", "requests": [{"type": "Ping"}, {"type": "GetTree"}]}
|
||||||
|
```
|
||||||
|
|
||||||
#### Ping
|
#### Ping
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -374,21 +402,25 @@ crates/
|
|||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── main.rs # Entry point, window setup
|
│ ├── main.rs # Entry point, window setup
|
||||||
│ ├── app.rs # Main app state, UI, toolbar, drawing interaction
|
│ ├── app.rs # Main app state, UI, toolbar, drawing interaction
|
||||||
│ ├── session.rs # Session/tab state management
|
│ ├── session.rs # Session/tab state management with history integration
|
||||||
|
│ ├── history.rs # Undo tree: branching history with snapshots, checkout, fork
|
||||||
│ ├── persistence.rs # Workspace save/load (~/.agcanvas/)
|
│ ├── persistence.rs # Workspace save/load (~/.agcanvas/)
|
||||||
│ ├── command_palette.rs # Cmd+K command palette with fuzzy search
|
│ ├── command_palette.rs # Cmd+K command palette with fuzzy search
|
||||||
│ ├── element_tree.rs # Structured element representation
|
│ ├── element_tree.rs # Structured element representation
|
||||||
│ ├── clipboard.rs # System clipboard integration
|
│ ├── clipboard.rs # System clipboard integration
|
||||||
│ ├── mermaid.rs # Mermaid → SVG rendering
|
│ ├── mermaid.rs # Mermaid → SVG rendering (v0.2.0, edge labels)
|
||||||
|
│ ├── export.rs # Canvas → PNG export (composites all layers)
|
||||||
│ ├── drawing/
|
│ ├── drawing/
|
||||||
│ │ ├── element.rs # DrawingElement, Shape, ShapeStyle, hit testing
|
│ │ ├── element.rs # DrawingElement, Shape, ShapeStyle, hit testing
|
||||||
|
│ │ ├── boolean.rs # Boolean shape operations (union, intersection, difference, xor)
|
||||||
│ │ ├── tool.rs # Tool enum, DragState, ResizeHandle
|
│ │ ├── tool.rs # Tool enum, DragState, ResizeHandle
|
||||||
│ │ └── render.rs # Shape rendering via egui Painter
|
│ │ └── render.rs # Shape rendering via egui Painter (incl. Path triangulation)
|
||||||
│ ├── canvas/
|
│ ├── canvas/
|
||||||
│ │ ├── state.rs # Pan/zoom transformation state
|
│ │ ├── state.rs # Pan/zoom transformation state
|
||||||
│ │ └── interaction.rs # Mouse/keyboard input handling
|
│ │ └── interaction.rs # Mouse/keyboard input handling
|
||||||
│ ├── svg/
|
│ ├── svg/
|
||||||
│ │ ├── parser.rs # SVG → ElementTree conversion
|
│ │ ├── parser.rs # SVG → ElementTree conversion
|
||||||
|
│ │ ├── converter.rs # SVG → DrawingElements (Mermaid conversion)
|
||||||
│ │ └── renderer.rs # SVG → pixels (resvg/tiny-skia)
|
│ │ └── renderer.rs # SVG → pixels (resvg/tiny-skia)
|
||||||
│ └── agent/
|
│ └── agent/
|
||||||
│ ├── protocol.rs # JSON message types
|
│ ├── protocol.rs # JSON message types
|
||||||
@@ -412,6 +444,8 @@ crates/
|
|||||||
| `tokio-tungstenite` | WebSocket (both server and client) |
|
| `tokio-tungstenite` | WebSocket (both server and client) |
|
||||||
| `rmcp` | MCP server SDK (Anthropic official) |
|
| `rmcp` | MCP server SDK (Anthropic official) |
|
||||||
| `dirs` | Platform data directory paths |
|
| `dirs` | Platform data directory paths |
|
||||||
|
| `i_overlay` | Boolean shape operations (union, intersection, difference, xor) |
|
||||||
|
| `earcutr` | Polygon triangulation for Path rendering |
|
||||||
| `serde`/`serde_json` | Serialization |
|
| `serde`/`serde_json` | Serialization |
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
@@ -425,12 +459,15 @@ crates/
|
|||||||
- [x] Session metadata (creator tracking, descriptions, timestamps, sorting)
|
- [x] Session metadata (creator tracking, descriptions, timestamps, sorting)
|
||||||
- [x] Session persistence (auto-save/restore workspace)
|
- [x] Session persistence (auto-save/restore workspace)
|
||||||
- [x] Command palette (Cmd+K)
|
- [x] Command palette (Cmd+K)
|
||||||
|
- [x] Boolean shape operations (union, intersection, difference, xor)
|
||||||
|
- [x] Visual undo tree with branching history
|
||||||
|
- [x] Export to PNG (GUI + MCP tool)
|
||||||
|
- [x] Pan tool and batch commands
|
||||||
|
- [x] Mermaid edge labels and native element conversion
|
||||||
- [ ] Real code generation (not just stubs)
|
- [ ] Real code generation (not just stubs)
|
||||||
- [ ] Export to file
|
|
||||||
- [ ] Diff view (before/after agent changes)
|
- [ ] Diff view (before/after agent changes)
|
||||||
- [ ] Plugin system for code generators
|
- [ ] Plugin system for code generators
|
||||||
- [ ] Undo/redo
|
- [ ] App icon
|
||||||
- [ ] Multi-select and group operations
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
32
assets/Info.plist
Normal file
32
assets/Info.plist
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Augmented Canvas</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Augmented Canvas</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.agcanvas.app</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>agcanvas</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>11.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||||
|
<true/>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.developer-tools</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -3,7 +3,7 @@ name = "agcanvas-mcp"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "MCP server bridge for agcanvas — exposes canvas tools to Claude Code, OpenCode, and Codex"
|
description = "MCP server bridge for Augmented Canvas — exposes canvas tools to Claude Code, OpenCode, and Codex"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "agcanvas-mcp"
|
name = "agcanvas-mcp"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub async fn send_request(ws_url: &str, request_json: &str) -> Result<String> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"Cannot connect to agcanvas at {}. Is agcanvas running? Error: {}",
|
"Cannot connect to Augmented Canvas at {}. Is Augmented Canvas running? Error: {}",
|
||||||
ws_url,
|
ws_url,
|
||||||
e
|
e
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ use tools::AgCanvasServer;
|
|||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "agcanvas-mcp", about = "MCP server bridge for agcanvas")]
|
#[command(
|
||||||
|
name = "agcanvas-mcp",
|
||||||
|
about = "MCP server bridge for Augmented Canvas"
|
||||||
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[arg(long, default_value = "9876")]
|
#[arg(long, default_value = "9876")]
|
||||||
port: u16,
|
port: u16,
|
||||||
@@ -26,7 +29,10 @@ async fn main() -> Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let ws_url = format!("ws://127.0.0.1:{}", cli.port);
|
let ws_url = format!("ws://127.0.0.1:{}", cli.port);
|
||||||
|
|
||||||
tracing::info!("Starting agcanvas MCP server, connecting to {}", ws_url);
|
tracing::info!(
|
||||||
|
"Starting Augmented Canvas MCP server, connecting to {}",
|
||||||
|
ws_url
|
||||||
|
);
|
||||||
|
|
||||||
let server = AgCanvasServer::new(ws_url);
|
let server = AgCanvasServer::new(ws_url);
|
||||||
let service = server.serve(rmcp::transport::stdio()).await?;
|
let service = server.serve(rmcp::transport::stdio()).await?;
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -169,6 +333,74 @@ pub struct DeleteDrawingElementParam {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct BooleanOpParam {
|
||||||
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[schemars(description = "Boolean operation: union, intersection, difference, or xor")]
|
||||||
|
pub operation: String,
|
||||||
|
#[schemars(description = "IDs of drawing elements to combine (minimum 2)")]
|
||||||
|
pub element_ids: Vec<String>,
|
||||||
|
#[schemars(description = "If true, delete source elements after combining")]
|
||||||
|
pub consume: Option<bool>,
|
||||||
|
#[schemars(description = "Fill color for result as hex e.g. '#ff0000'")]
|
||||||
|
pub fill: Option<String>,
|
||||||
|
#[schemars(description = "Stroke color for result as hex e.g. '#ffffff'")]
|
||||||
|
pub stroke_color: Option<String>,
|
||||||
|
#[schemars(description = "Stroke width for result in pixels")]
|
||||||
|
pub stroke_width: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct RenderMermaidParam {
|
||||||
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[schemars(description = "Mermaid diagram source code (e.g., 'flowchart LR\\n A-->B')")]
|
||||||
|
pub mermaid_source: String,
|
||||||
|
#[schemars(description = "X position on canvas (default: 0)")]
|
||||||
|
pub x: Option<f32>,
|
||||||
|
#[schemars(description = "Y position on canvas (default: 0)")]
|
||||||
|
pub y: Option<f32>,
|
||||||
|
#[schemars(description = "Override width (default: natural SVG width)")]
|
||||||
|
pub width: Option<f32>,
|
||||||
|
#[schemars(description = "Override height (default: natural SVG height)")]
|
||||||
|
pub height: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct ExportCanvasParam {
|
||||||
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[schemars(description = "File path to save the PNG export to")]
|
||||||
|
pub path: String,
|
||||||
|
#[schemars(description = "Scale factor for the export (default 2.0 for high DPI)")]
|
||||||
|
pub scale: Option<f32>,
|
||||||
|
#[schemars(description = "Background color as hex e.g. '#1a1a2e' (default dark canvas color)")]
|
||||||
|
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)]
|
||||||
|
pub struct BatchParam {
|
||||||
|
#[schemars(
|
||||||
|
description = "JSON array of request objects. Each object must have a 'type' field and the same parameters as individual tool calls. Example: [{\"type\": \"CreateDrawingElement\", \"shape_type\": \"rectangle\", \"x\": 0, \"y\": 0, \"width\": 100, \"height\": 100}, {\"type\": \"CreateDrawingElement\", \"shape_type\": \"ellipse\", \"center_x\": 200, \"center_y\": 200, \"radius_x\": 50, \"radius_y\": 50}]"
|
||||||
|
)]
|
||||||
|
pub requests_json: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AgCanvasServer {
|
pub struct AgCanvasServer {
|
||||||
ws_url: String,
|
ws_url: String,
|
||||||
@@ -185,7 +417,7 @@ impl AgCanvasServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, creator info, description, timestamps, and whether they have SVG or drawing content loaded. Supports sorting."
|
description = "List all open sessions/tabs in Augmented Canvas. Returns session IDs, names, creator info, description, timestamps, and whether they have SVG or drawing content loaded. Supports sorting."
|
||||||
)]
|
)]
|
||||||
async fn list_sessions(
|
async fn list_sessions(
|
||||||
&self,
|
&self,
|
||||||
@@ -203,7 +435,7 @@ impl AgCanvasServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
description = "Create a new session/tab in agcanvas. The session is created by an agent. Returns the created session with its ID and metadata."
|
description = "Create a new session/tab in Augmented Canvas. The session is created by an agent. Returns the created session with its ID and metadata."
|
||||||
)]
|
)]
|
||||||
async fn create_session(
|
async fn create_session(
|
||||||
&self,
|
&self,
|
||||||
@@ -332,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,
|
||||||
@@ -382,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());
|
||||||
}
|
}
|
||||||
@@ -397,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,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());
|
||||||
}
|
}
|
||||||
@@ -469,6 +737,363 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
description = "Perform a boolean operation (union, intersection, difference, xor) on two or more drawing elements. Combines filled shapes into a new path element. Only works on Rectangle and Ellipse shapes."
|
||||||
|
)]
|
||||||
|
async fn boolean_op(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<BooleanOpParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request = serde_json::json!({
|
||||||
|
"type": "BooleanOp",
|
||||||
|
"operation": params.operation,
|
||||||
|
"element_ids": params.element_ids,
|
||||||
|
});
|
||||||
|
let obj = request.as_object_mut().unwrap();
|
||||||
|
if let Some(v) = params.session_id {
|
||||||
|
obj.insert("session_id".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.consume {
|
||||||
|
obj.insert("consume".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.fill {
|
||||||
|
obj.insert("fill".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.stroke_color {
|
||||||
|
obj.insert("stroke_color".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.stroke_width {
|
||||||
|
obj.insert("stroke_width".into(), v.into());
|
||||||
|
}
|
||||||
|
self.call_agcanvas(&request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "render_mermaid",
|
||||||
|
description = "Render a Mermaid diagram (flowchart, sequence, etc.) as an SVG overlay at a specific position on the canvas. The diagram appears as a visual element that can coexist with other shapes and diagrams."
|
||||||
|
)]
|
||||||
|
async fn render_mermaid(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<RenderMermaidParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let request = serde_json::json!({
|
||||||
|
"type": "RenderMermaid",
|
||||||
|
"session_id": params.session_id,
|
||||||
|
"mermaid_source": params.mermaid_source,
|
||||||
|
"x": params.x,
|
||||||
|
"y": params.y,
|
||||||
|
"width": params.width,
|
||||||
|
"height": params.height,
|
||||||
|
});
|
||||||
|
self.call_agcanvas(&request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
description = "Export the canvas as a PNG image. Renders all layers (SVG, drawing elements) into a single image file."
|
||||||
|
)]
|
||||||
|
async fn export_canvas(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<ExportCanvasParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request = serde_json::json!({
|
||||||
|
"type": "ExportCanvas",
|
||||||
|
"path": params.path,
|
||||||
|
});
|
||||||
|
if let Some(sid) = params.session_id {
|
||||||
|
request["session_id"] = serde_json::Value::String(sid);
|
||||||
|
}
|
||||||
|
if let Some(s) = params.scale {
|
||||||
|
request["scale"] = serde_json::json!(s);
|
||||||
|
}
|
||||||
|
if let Some(bg) = params.background {
|
||||||
|
request["background"] = serde_json::Value::String(bg);
|
||||||
|
}
|
||||||
|
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(
|
||||||
|
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."
|
||||||
|
)]
|
||||||
|
async fn batch(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<BatchParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let requests = match serde_json::from_str::<serde_json::Value>(¶ms.requests_json) {
|
||||||
|
Ok(serde_json::Value::Array(requests)) => requests,
|
||||||
|
Ok(_) => {
|
||||||
|
return Ok(CallToolResult::error(vec![Content::text(
|
||||||
|
"Invalid requests_json: expected a JSON array of request objects",
|
||||||
|
)]))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(CallToolResult::error(vec![Content::text(format!(
|
||||||
|
"Invalid requests_json: {}",
|
||||||
|
e
|
||||||
|
))]))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = serde_json::json!({
|
||||||
|
"type": "Batch",
|
||||||
|
"requests": requests,
|
||||||
|
});
|
||||||
self.call_agcanvas(&request).await
|
self.call_agcanvas(&request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,7 +1142,7 @@ impl AgCanvasServer {
|
|||||||
let msg = parsed
|
let msg = parsed
|
||||||
.get("message")
|
.get("message")
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or("Unknown error from agcanvas");
|
.unwrap_or("Unknown error from Augmented Canvas");
|
||||||
return Ok(CallToolResult::error(vec![Content::text(msg)]));
|
return Ok(CallToolResult::error(vec![Content::text(msg)]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +1151,7 @@ impl AgCanvasServer {
|
|||||||
Ok(CallToolResult::success(vec![Content::text(pretty)]))
|
Ok(CallToolResult::success(vec![Content::text(pretty)]))
|
||||||
}
|
}
|
||||||
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
|
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
|
||||||
"Failed to communicate with agcanvas: {}. Make sure agcanvas is running.",
|
"Failed to communicate with Augmented Canvas: {}. Make sure Augmented Canvas is running.",
|
||||||
e
|
e
|
||||||
))])),
|
))])),
|
||||||
}
|
}
|
||||||
@@ -538,10 +1163,10 @@ impl ServerHandler for AgCanvasServer {
|
|||||||
fn get_info(&self) -> ServerInfo {
|
fn get_info(&self) -> ServerInfo {
|
||||||
ServerInfo {
|
ServerInfo {
|
||||||
instructions: Some(
|
instructions: Some(
|
||||||
"agcanvas MCP server — connects to agcanvas desktop app to query SVG designs, \
|
"Augmented Canvas MCP server — connects to the Augmented Canvas desktop app to query SVG designs, \
|
||||||
element trees, and user-drawn shapes. Use describe_canvas to understand the \
|
element trees, and user-drawn shapes. Use describe_canvas to understand the \
|
||||||
current design, get_element_tree for structured data, and generate_code for \
|
current design, get_element_tree for structured data, and generate_code for \
|
||||||
code stubs. Requires agcanvas to be running."
|
code stubs. Requires Augmented Canvas to be running."
|
||||||
.into(),
|
.into(),
|
||||||
),
|
),
|
||||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name = "agcanvas"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Interactive canvas for agent-human collaboration with SVG support"
|
description = "Augmented Canvas — interactive canvas for agent-human collaboration with SVG support"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# GUI
|
# GUI
|
||||||
@@ -28,12 +28,12 @@ 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"
|
||||||
|
|
||||||
# Mermaid diagram rendering
|
# Mermaid diagram rendering
|
||||||
mermaid-rs-renderer = { version = "0.1.2", default-features = false }
|
mermaid-rs-renderer = { git = "https://github.com/1jehuang/mermaid-rs-renderer", tag = "v0.2.0", default-features = false }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
@@ -42,9 +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"
|
||||||
|
earcutr = "0.5.0"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::drawing::{DrawingElement, Shape, ShapeStyle};
|
use crate::drawing::{BooleanOpType, DrawingElement, Shape, ShapeStyle};
|
||||||
use crate::element_tree::{ElementTree, TreeMetadata};
|
use crate::element_tree::{ElementTree, TreeMetadata};
|
||||||
use crate::session::{SessionCreator, SessionInfo, SessionSortField, SortOrder};
|
use crate::session::{SessionCreator, SessionInfo, SessionSortField, SortOrder};
|
||||||
use egui::{Color32, Pos2, Vec2};
|
use egui::{Color32, Pos2, Vec2};
|
||||||
@@ -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,129 @@ 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 {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
operation: BooleanOpType,
|
||||||
|
element_ids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
consume: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
fill: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
stroke_color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
stroke_width: Option<f32>,
|
||||||
|
},
|
||||||
|
RenderMermaid {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
mermaid_source: String,
|
||||||
|
#[serde(default)]
|
||||||
|
x: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
y: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
width: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
height: Option<f32>,
|
||||||
|
},
|
||||||
|
ExportCanvas {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
scale: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
background: Option<String>,
|
||||||
|
},
|
||||||
|
CaptureScreenshot {
|
||||||
|
path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
},
|
||||||
|
GetAppState {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
},
|
||||||
|
Batch {
|
||||||
|
requests: Vec<AgentRequest>,
|
||||||
|
},
|
||||||
|
|
||||||
Ping,
|
Ping,
|
||||||
}
|
}
|
||||||
@@ -258,6 +426,87 @@ 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 {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
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 {
|
||||||
|
results: Vec<AgentResponse>,
|
||||||
|
},
|
||||||
Pong,
|
Pong,
|
||||||
Error {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
@@ -285,6 +534,9 @@ pub enum DrawingCommand {
|
|||||||
Clear {
|
Clear {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
},
|
},
|
||||||
|
CaptureScreenshot {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -379,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 {
|
||||||
@@ -402,35 +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" => {
|
||||||
|
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, or Text",
|
"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
@@ -1,34 +1,8 @@
|
|||||||
use super::CanvasState;
|
|
||||||
use egui::{Response, Sense, Ui};
|
use egui::{Response, Sense, Ui};
|
||||||
|
|
||||||
pub struct CanvasInteraction;
|
pub struct CanvasInteraction;
|
||||||
|
|
||||||
impl CanvasInteraction {
|
impl CanvasInteraction {
|
||||||
pub fn handle(ui: &Ui, response: &Response, state: &mut CanvasState) {
|
|
||||||
if response.dragged_by(egui::PointerButton::Middle)
|
|
||||||
|| (response.dragged_by(egui::PointerButton::Primary)
|
|
||||||
&& ui.input(|i| i.modifiers.command))
|
|
||||||
{
|
|
||||||
state.pan(response.drag_delta());
|
|
||||||
}
|
|
||||||
|
|
||||||
if response.hovered() {
|
|
||||||
let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y);
|
|
||||||
if scroll_delta != 0.0 {
|
|
||||||
let zoom_factor = 1.0 + scroll_delta * 0.001;
|
|
||||||
if let Some(pointer_pos) = response.hover_pos() {
|
|
||||||
state.zoom_at(pointer_pos, response.rect.center(), zoom_factor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.input(|i| {
|
|
||||||
if i.key_pressed(egui::Key::Num0) && i.modifiers.command {
|
|
||||||
state.reset();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn allocate_canvas(ui: &mut Ui) -> Response {
|
pub fn allocate_canvas(ui: &mut Ui) -> Response {
|
||||||
let available_size = ui.available_size();
|
let available_size = ui.available_size();
|
||||||
let (rect, response) = ui.allocate_exact_size(available_size, Sense::click_and_drag());
|
let (rect, response) = ui.allocate_exact_size(available_size, Sense::click_and_drag());
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -4,20 +4,44 @@ use egui::{Color32, Key};
|
|||||||
pub enum CommandId {
|
pub enum CommandId {
|
||||||
NewTab,
|
NewTab,
|
||||||
CloseTab,
|
CloseTab,
|
||||||
|
Undo,
|
||||||
|
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,
|
||||||
|
ExportSvg,
|
||||||
ToolSelect,
|
ToolSelect,
|
||||||
|
ToolDirectSelect,
|
||||||
|
ToolPan,
|
||||||
ToolRectangle,
|
ToolRectangle,
|
||||||
ToolEllipse,
|
ToolEllipse,
|
||||||
ToolLine,
|
ToolLine,
|
||||||
ToolArrow,
|
ToolArrow,
|
||||||
|
ToolPolygon,
|
||||||
ToolText,
|
ToolText,
|
||||||
ResetZoom,
|
ResetZoom,
|
||||||
FitToView,
|
FitToView,
|
||||||
ToggleTreePanel,
|
ToggleTreePanel,
|
||||||
ToggleDescription,
|
ToggleDescription,
|
||||||
|
ToggleHistory,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -43,6 +67,69 @@ pub fn all_commands() -> Vec<PaletteCommand> {
|
|||||||
vec![
|
vec![
|
||||||
PaletteCommand::new(CommandId::NewTab, "New Tab", Some("Cmd+T"), "Session"),
|
PaletteCommand::new(CommandId::NewTab, "New Tab", Some("Cmd+T"), "Session"),
|
||||||
PaletteCommand::new(CommandId::CloseTab, "Close Tab", Some("Cmd+W"), "Session"),
|
PaletteCommand::new(CommandId::CloseTab, "Close Tab", Some("Cmd+W"), "Session"),
|
||||||
|
PaletteCommand::new(CommandId::Undo, "Undo (Cmd+Z)", Some("Cmd+Z"), "Edit"),
|
||||||
|
PaletteCommand::new(
|
||||||
|
CommandId::Redo,
|
||||||
|
"Redo (Cmd+Shift+Z)",
|
||||||
|
Some("Cmd+Shift+Z"),
|
||||||
|
"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",
|
||||||
@@ -50,14 +137,33 @@ 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",
|
||||||
None,
|
None,
|
||||||
"Canvas",
|
"Canvas",
|
||||||
),
|
),
|
||||||
|
PaletteCommand::new(
|
||||||
|
CommandId::ExportPng,
|
||||||
|
"Export as PNG",
|
||||||
|
Some("Cmd+Shift+E"),
|
||||||
|
"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(
|
PaletteCommand::new(
|
||||||
CommandId::ToolRectangle,
|
CommandId::ToolRectangle,
|
||||||
"Rectangle Tool",
|
"Rectangle Tool",
|
||||||
@@ -67,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"),
|
||||||
@@ -82,6 +189,12 @@ pub fn all_commands() -> Vec<PaletteCommand> {
|
|||||||
None,
|
None,
|
||||||
"View",
|
"View",
|
||||||
),
|
),
|
||||||
|
PaletteCommand::new(
|
||||||
|
CommandId::ToggleHistory,
|
||||||
|
"Toggle History Panel",
|
||||||
|
Some("Cmd+H"),
|
||||||
|
"View",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,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()
|
||||||
|
|||||||
194
crates/agcanvas/src/drawing/boolean.rs
Normal file
194
crates/agcanvas/src/drawing/boolean.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
use super::element::{polygon_vertices, DrawingElement, PathPolygon, Shape};
|
||||||
|
use egui::Pos2;
|
||||||
|
use i_overlay::core::fill_rule::FillRule;
|
||||||
|
use i_overlay::core::overlay_rule::OverlayRule;
|
||||||
|
use i_overlay::float::overlay::FloatOverlay;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const ELLIPSE_SEGMENTS: usize = 64;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum BooleanOpType {
|
||||||
|
Union,
|
||||||
|
Intersection,
|
||||||
|
Difference,
|
||||||
|
Xor,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shape_to_contour(shape: &Shape) -> Result<Vec<[f64; 2]>, String> {
|
||||||
|
match shape {
|
||||||
|
Shape::Rectangle { pos, size } => {
|
||||||
|
let x0 = pos.x as f64;
|
||||||
|
let y0 = pos.y as f64;
|
||||||
|
let x1 = (pos.x + size.x) as f64;
|
||||||
|
let y1 = (pos.y + size.y) as f64;
|
||||||
|
Ok(vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]])
|
||||||
|
}
|
||||||
|
Shape::Ellipse { center, radii } => Ok((0..ELLIPSE_SEGMENTS)
|
||||||
|
.map(|i| {
|
||||||
|
let angle = (i as f64 / ELLIPSE_SEGMENTS as f64) * std::f64::consts::TAU;
|
||||||
|
[
|
||||||
|
center.x as f64 + radii.x as f64 * angle.cos(),
|
||||||
|
center.y as f64 + radii.y as f64 * angle.sin(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect()),
|
||||||
|
Shape::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::Line { .. }
|
||||||
|
| Shape::Arrow { .. }
|
||||||
|
| Shape::Text { .. }
|
||||||
|
| Shape::SvgImage { .. }
|
||||||
|
| Shape::Group => {
|
||||||
|
Err("Boolean operations only support Rectangle, Ellipse, and Polygon".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn boolean_op(
|
||||||
|
op: BooleanOpType,
|
||||||
|
elements: &[&DrawingElement],
|
||||||
|
) -> Result<Vec<PathPolygon>, String> {
|
||||||
|
if elements.len() < 2 {
|
||||||
|
return Err("Boolean operation requires at least 2 elements".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let contours: Vec<Vec<[f64; 2]>> = elements
|
||||||
|
.iter()
|
||||||
|
.map(|element| shape_to_contour(&element.shape))
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
|
let mut current_shapes = vec![vec![contours[0].clone()]];
|
||||||
|
let rule = overlay_rule(op);
|
||||||
|
|
||||||
|
for contour in contours.iter().skip(1) {
|
||||||
|
let clip_shapes = vec![vec![contour.clone()]];
|
||||||
|
let mut overlay = FloatOverlay::with_subj_and_clip(¤t_shapes, &clip_shapes);
|
||||||
|
current_shapes = overlay.overlay(rule, FillRule::EvenOdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(to_path_polygons(current_shapes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlay_rule(op: BooleanOpType) -> OverlayRule {
|
||||||
|
match op {
|
||||||
|
BooleanOpType::Union => OverlayRule::Union,
|
||||||
|
BooleanOpType::Intersection => OverlayRule::Intersect,
|
||||||
|
BooleanOpType::Difference => OverlayRule::Difference,
|
||||||
|
BooleanOpType::Xor => OverlayRule::Xor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_path_polygons(shapes: Vec<Vec<Vec<[f64; 2]>>>) -> Vec<PathPolygon> {
|
||||||
|
shapes
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|shape| {
|
||||||
|
let mut contours = shape.into_iter();
|
||||||
|
let exterior = contours.next()?;
|
||||||
|
if exterior.len() < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let holes = contours
|
||||||
|
.filter(|contour| contour.len() >= 3)
|
||||||
|
.map(to_pos2_ring)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Some(PathPolygon {
|
||||||
|
exterior: to_pos2_ring(exterior),
|
||||||
|
holes,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_pos2_ring(ring: Vec<[f64; 2]>) -> Vec<Pos2> {
|
||||||
|
ring.into_iter()
|
||||||
|
.map(|point| Pos2::new(point[0] as f32, point[1] as f32))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::drawing::ShapeStyle;
|
||||||
|
use egui::vec2;
|
||||||
|
|
||||||
|
fn rect(x: f32, y: f32, w: f32, h: f32) -> DrawingElement {
|
||||||
|
DrawingElement::new(
|
||||||
|
Shape::Rectangle {
|
||||||
|
pos: Pos2::new(x, y),
|
||||||
|
size: vec2(w, h),
|
||||||
|
},
|
||||||
|
ShapeStyle::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ellipse(cx: f32, cy: f32, rx: f32, ry: f32) -> DrawingElement {
|
||||||
|
DrawingElement::new(
|
||||||
|
Shape::Ellipse {
|
||||||
|
center: Pos2::new(cx, cy),
|
||||||
|
radii: vec2(rx, ry),
|
||||||
|
},
|
||||||
|
ShapeStyle::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rect_union() {
|
||||||
|
let a = rect(0.0, 0.0, 100.0, 100.0);
|
||||||
|
let b = rect(50.0, 0.0, 100.0, 100.0);
|
||||||
|
let result = boolean_op(BooleanOpType::Union, &[&a, &b]).unwrap();
|
||||||
|
assert!(!result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rect_intersection() {
|
||||||
|
let a = rect(0.0, 0.0, 100.0, 100.0);
|
||||||
|
let b = rect(50.0, 0.0, 100.0, 100.0);
|
||||||
|
let result = boolean_op(BooleanOpType::Intersection, &[&a, &b]).unwrap();
|
||||||
|
assert!(!result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_disjoint_intersection() {
|
||||||
|
let a = rect(0.0, 0.0, 100.0, 100.0);
|
||||||
|
let b = rect(200.0, 0.0, 100.0, 100.0);
|
||||||
|
let result = boolean_op(BooleanOpType::Intersection, &[&a, &b]);
|
||||||
|
assert!(result.is_err() || result.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ellipse_union() {
|
||||||
|
let a = ellipse(80.0, 80.0, 60.0, 40.0);
|
||||||
|
let b = ellipse(120.0, 80.0, 60.0, 40.0);
|
||||||
|
let result = boolean_op(BooleanOpType::Union, &[&a, &b]).unwrap();
|
||||||
|
assert!(!result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_line_rejected() {
|
||||||
|
let a = DrawingElement::new(
|
||||||
|
Shape::Line {
|
||||||
|
start: Pos2::new(0.0, 0.0),
|
||||||
|
end: Pos2::new(100.0, 0.0),
|
||||||
|
},
|
||||||
|
ShapeStyle::default(),
|
||||||
|
);
|
||||||
|
let b = rect(0.0, 0.0, 100.0, 100.0);
|
||||||
|
|
||||||
|
let result = boolean_op(BooleanOpType::Union, &[&a, &b]);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,16 +12,28 @@ 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)]
|
||||||
|
pub struct PathPolygon {
|
||||||
|
pub exterior: Vec<Pos2>,
|
||||||
|
pub holes: Vec<Vec<Pos2>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DrawingElement {
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,19 +44,62 @@ 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 } => {
|
||||||
|
let mut min_x = f32::INFINITY;
|
||||||
|
let mut min_y = f32::INFINITY;
|
||||||
|
let mut max_x = f32::NEG_INFINITY;
|
||||||
|
let mut max_y = f32::NEG_INFINITY;
|
||||||
|
|
||||||
|
for polygon in polygons {
|
||||||
|
for point in &polygon.exterior {
|
||||||
|
min_x = min_x.min(point.x);
|
||||||
|
min_y = min_y.min(point.y);
|
||||||
|
max_x = max_x.max(point.x);
|
||||||
|
max_y = max_y.max(point.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()
|
||||||
|
{
|
||||||
|
egui::Rect::from_min_max(Pos2::new(min_x, min_y), Pos2::new(max_x, max_y))
|
||||||
|
} else {
|
||||||
|
egui::Rect::from_min_size(Pos2::ZERO, egui::Vec2::ZERO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Shape::SvgImage { pos, size, .. } => egui::Rect::from_min_size(*pos, *size),
|
||||||
|
Shape::Group => children_bounding_rect(&self.children),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,19 +119,63 @@ 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| {
|
||||||
|
point_in_polygon(point, &polygon.exterior)
|
||||||
|
&& !polygon
|
||||||
|
.holes
|
||||||
|
.iter()
|
||||||
|
.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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,16 +184,40 @@ 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 } => {
|
||||||
|
for polygon in polygons {
|
||||||
|
for point in &mut polygon.exterior {
|
||||||
|
*point += delta;
|
||||||
|
}
|
||||||
|
for hole in &mut polygon.holes {
|
||||||
|
for point in hole {
|
||||||
|
*point += delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Shape::SvgImage { pos, .. } => *pos += delta,
|
||||||
|
Shape::Group => {
|
||||||
|
for child in &mut self.children {
|
||||||
|
child.translate(delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resize to fit a new bounding rect, preserving shape semantics.
|
/// Resize to fit a new bounding rect, preserving shape semantics.
|
||||||
pub fn resize_to(&mut self, new_rect: egui::Rect) {
|
pub fn resize_to(&mut self, new_rect: egui::Rect) {
|
||||||
|
let old_rect = self.bounding_rect();
|
||||||
match &mut self.shape {
|
match &mut self.shape {
|
||||||
Shape::Rectangle { pos, size } => {
|
Shape::Rectangle { pos, size } => {
|
||||||
*pos = new_rect.min;
|
*pos = new_rect.min;
|
||||||
@@ -103,14 +227,143 @@ 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;
|
||||||
}
|
}
|
||||||
|
Shape::Path { polygons } => {
|
||||||
|
let old_w = old_rect.width();
|
||||||
|
let old_h = old_rect.height();
|
||||||
|
|
||||||
|
for polygon in polygons {
|
||||||
|
for point in &mut polygon.exterior {
|
||||||
|
*point = map_point_to_rect(*point, old_rect, new_rect, old_w, old_h);
|
||||||
}
|
}
|
||||||
|
for hole in &mut polygon.holes {
|
||||||
|
for point in hole {
|
||||||
|
*point = map_point_to_rect(*point, old_rect, new_rect, old_w, old_h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,19 +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 {
|
||||||
|
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 {
|
||||||
@@ -153,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;
|
||||||
@@ -173,6 +722,183 @@ fn point_to_segment_distance(p: Pos2, a: Pos2, b: Pos2) -> f32 {
|
|||||||
(p - closest).length()
|
(p - closest).length()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn point_in_polygon(point: Pos2, ring: &[Pos2]) -> bool {
|
||||||
|
if ring.len() < 3 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut winding_number = 0;
|
||||||
|
|
||||||
|
for (a, b) in ring
|
||||||
|
.iter()
|
||||||
|
.zip(ring.iter().cycle().skip(1))
|
||||||
|
.take(ring.len())
|
||||||
|
.map(|(a, b)| (*a, *b))
|
||||||
|
{
|
||||||
|
if point_to_segment_distance(point, a, b) <= 1e-3 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.y <= point.y {
|
||||||
|
if b.y > point.y && cross(a, b, point) > 0.0 {
|
||||||
|
winding_number += 1;
|
||||||
|
}
|
||||||
|
} else if b.y <= point.y && cross(a, b, point) < 0.0 {
|
||||||
|
winding_number -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
winding_number != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cross(a: Pos2, b: Pos2, p: Pos2) -> f32 {
|
||||||
|
(b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn 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(
|
||||||
|
point: Pos2,
|
||||||
|
old_rect: egui::Rect,
|
||||||
|
new_rect: egui::Rect,
|
||||||
|
old_w: f32,
|
||||||
|
old_h: f32,
|
||||||
|
) -> Pos2 {
|
||||||
|
let rel_x = if old_w.abs() <= f32::EPSILON {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(point.x - old_rect.min.x) / old_w
|
||||||
|
};
|
||||||
|
let rel_y = if old_h.abs() <= f32::EPSILON {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(point.y - old_rect.min.y) / old_h
|
||||||
|
};
|
||||||
|
|
||||||
|
Pos2::new(
|
||||||
|
new_rect.min.x + rel_x * new_rect.width(),
|
||||||
|
new_rect.min.y + rel_y * new_rect.height(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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::*;
|
||||||
@@ -256,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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
pub mod boolean;
|
||||||
mod element;
|
mod element;
|
||||||
mod render;
|
mod render;
|
||||||
mod tool;
|
mod tool;
|
||||||
|
|
||||||
pub use element::{DrawingElement, Shape, ShapeStyle};
|
pub use boolean::BooleanOpType;
|
||||||
|
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_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
@@ -5,10 +5,13 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub enum Tool {
|
pub enum Tool {
|
||||||
#[default]
|
#[default]
|
||||||
Select,
|
Select,
|
||||||
|
DirectSelect,
|
||||||
|
Pan,
|
||||||
Rectangle,
|
Rectangle,
|
||||||
Ellipse,
|
Ellipse,
|
||||||
Line,
|
Line,
|
||||||
Arrow,
|
Arrow,
|
||||||
|
Polygon,
|
||||||
Text,
|
Text,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,10 +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::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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,10 +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::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'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,13 +54,26 @@ pub enum DragState {
|
|||||||
current: Pos2,
|
current: Pos2,
|
||||||
},
|
},
|
||||||
Moving {
|
Moving {
|
||||||
element_id: String,
|
element_ids: Vec<String>,
|
||||||
|
},
|
||||||
|
MarqueeSelecting {
|
||||||
|
start: Pos2,
|
||||||
|
current: Pos2,
|
||||||
},
|
},
|
||||||
Resizing {
|
Resizing {
|
||||||
handle: ResizeHandle,
|
handle: ResizeHandle,
|
||||||
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)]
|
||||||
|
|||||||
561
crates/agcanvas/src/export.rs
Normal file
561
crates/agcanvas/src/export.rs
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::drawing::{
|
||||||
|
arrow_curve_points, polygon_vertices, quadratic_bezier_end_tangent, DrawingElement, Shape,
|
||||||
|
ShapeStyle, ARROW_CURVE_SEGMENTS,
|
||||||
|
};
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform};
|
||||||
|
|
||||||
|
const EXPORT_PADDING: f32 = 20.0;
|
||||||
|
|
||||||
|
/// Data needed to export a canvas snapshot (no egui dependency).
|
||||||
|
pub struct ExportData {
|
||||||
|
pub svg_source: Option<String>,
|
||||||
|
pub drawing_elements: Vec<DrawingElement>,
|
||||||
|
pub background_color: tiny_skia::Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Bounds {
|
||||||
|
min_x: f32,
|
||||||
|
min_y: f32,
|
||||||
|
max_x: f32,
|
||||||
|
max_y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bounds {
|
||||||
|
fn from_rect(min_x: f32, min_y: f32, max_x: f32, max_y: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
min_x,
|
||||||
|
min_y,
|
||||||
|
max_x,
|
||||||
|
max_y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn union(self, other: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
min_x: self.min_x.min(other.min_x),
|
||||||
|
min_y: self.min_y.min(other.min_y),
|
||||||
|
max_x: self.max_x.max(other.max_x),
|
||||||
|
max_y: self.max_y.max(other.max_y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export_canvas_to_png(data: &ExportData, path: &str, scale: f32) -> Result<(u32, u32)> {
|
||||||
|
if !(scale.is_finite() && scale > 0.0) {
|
||||||
|
return Err(anyhow!("Scale must be a positive finite value"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = Path::new(path).parent() {
|
||||||
|
if !parent.as_os_str().is_empty() && !parent.exists() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Export directory does not exist: {}",
|
||||||
|
parent.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut total_bounds = compute_total_bounds(data)?;
|
||||||
|
total_bounds.min_x -= EXPORT_PADDING;
|
||||||
|
total_bounds.min_y -= EXPORT_PADDING;
|
||||||
|
total_bounds.max_x += EXPORT_PADDING;
|
||||||
|
total_bounds.max_y += EXPORT_PADDING;
|
||||||
|
|
||||||
|
let width_f = (total_bounds.max_x - total_bounds.min_x).max(1.0) * scale;
|
||||||
|
let height_f = (total_bounds.max_y - total_bounds.min_y).max(1.0) * scale;
|
||||||
|
let width = width_f.ceil().max(1.0) as u32;
|
||||||
|
let height = height_f.ceil().max(1.0) as u32;
|
||||||
|
|
||||||
|
let mut pixmap =
|
||||||
|
Pixmap::new(width, height).ok_or_else(|| anyhow!("Failed to create export pixmap"))?;
|
||||||
|
pixmap.fill(data.background_color);
|
||||||
|
|
||||||
|
if let Some(svg_source) = &data.svg_source {
|
||||||
|
let tree = parse_svg(svg_source)?;
|
||||||
|
let tx = -total_bounds.min_x * scale;
|
||||||
|
let ty = -total_bounds.min_y * scale;
|
||||||
|
let transform = Transform::from_row(scale, 0.0, 0.0, scale, tx, ty);
|
||||||
|
resvg::render(&tree, transform, &mut pixmap.as_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
render_drawing_elements(
|
||||||
|
&mut pixmap,
|
||||||
|
&data.drawing_elements,
|
||||||
|
total_bounds.min_x,
|
||||||
|
total_bounds.min_y,
|
||||||
|
scale,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
pixmap.save_png(path)?;
|
||||||
|
Ok((width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_total_bounds(data: &ExportData) -> Result<Bounds> {
|
||||||
|
let mut bounds: Option<Bounds> = None;
|
||||||
|
|
||||||
|
if let Some(svg_source) = &data.svg_source {
|
||||||
|
let tree = parse_svg(svg_source)?;
|
||||||
|
let size = tree.size();
|
||||||
|
bounds = Some(Bounds::from_rect(0.0, 0.0, size.width(), size.height()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(drawing_bounds) = compute_drawing_bounds(&data.drawing_elements) {
|
||||||
|
bounds = Some(match bounds {
|
||||||
|
Some(existing) => existing.union(drawing_bounds),
|
||||||
|
None => drawing_bounds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds.ok_or_else(|| anyhow!("Cannot export empty canvas"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_drawing_bounds(elements: &[DrawingElement]) -> Option<Bounds> {
|
||||||
|
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 element in elements {
|
||||||
|
let rect = element.bounding_rect();
|
||||||
|
let half_stroke = (element.style.stroke_width * 0.5).max(0.0);
|
||||||
|
min_x = min_x.min(rect.min.x - half_stroke);
|
||||||
|
min_y = min_y.min(rect.min.y - half_stroke);
|
||||||
|
max_x = max_x.max(rect.max.x + half_stroke);
|
||||||
|
max_y = max_y.max(rect.max.y + half_stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite() {
|
||||||
|
Some(Bounds::from_rect(min_x, min_y, max_x, max_y))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_drawing_elements(
|
||||||
|
pixmap: &mut Pixmap,
|
||||||
|
elements: &[DrawingElement],
|
||||||
|
min_x: f32,
|
||||||
|
min_y: f32,
|
||||||
|
scale: f32,
|
||||||
|
) -> Result<()> {
|
||||||
|
for element in elements {
|
||||||
|
match &element.shape {
|
||||||
|
Shape::Rectangle { pos, size } => {
|
||||||
|
if size.x <= 0.0 || size.y <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let x = (pos.x - min_x) * scale;
|
||||||
|
let y = (pos.y - min_y) * scale;
|
||||||
|
let w = size.x * scale;
|
||||||
|
let h = size.y * scale;
|
||||||
|
|
||||||
|
let Some(rect) = tiny_skia::Rect::from_xywh(x, y, w, h) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
pb.push_rect(rect);
|
||||||
|
if let Some(path) = pb.finish() {
|
||||||
|
fill_and_stroke_path(pixmap, &path, &element.style, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Shape::Ellipse { center, radii } => {
|
||||||
|
if radii.x <= 0.0 || radii.y <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let x = (center.x - radii.x - min_x) * scale;
|
||||||
|
let y = (center.y - radii.y - min_y) * scale;
|
||||||
|
let w = radii.x * 2.0 * scale;
|
||||||
|
let h = radii.y * 2.0 * scale;
|
||||||
|
|
||||||
|
let Some(rect) = tiny_skia::Rect::from_xywh(x, y, w, h) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
pb.push_oval(rect);
|
||||||
|
if let Some(path) = pb.finish() {
|
||||||
|
fill_and_stroke_path(pixmap, &path, &element.style, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Shape::Line { start, end } => {
|
||||||
|
if let Some(path) = build_line_path(*start, *end, min_x, min_y, scale) {
|
||||||
|
stroke_path(pixmap, &path, &element.style, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Shape::Arrow {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
pos,
|
||||||
|
content,
|
||||||
|
font_size,
|
||||||
|
max_width,
|
||||||
|
} => {
|
||||||
|
render_text_element(
|
||||||
|
pixmap,
|
||||||
|
content,
|
||||||
|
(pos.x - min_x) * scale,
|
||||||
|
(pos.y - min_y) * scale,
|
||||||
|
*font_size * scale,
|
||||||
|
max_width.map(|w| w * scale),
|
||||||
|
element.style.stroke_color,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Shape::Path { polygons } => {
|
||||||
|
for polygon in polygons {
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
add_ring_to_path(&mut pb, &polygon.exterior, min_x, min_y, scale, true);
|
||||||
|
for hole in &polygon.holes {
|
||||||
|
add_ring_to_path(&mut pb, hole, min_x, min_y, scale, true);
|
||||||
|
}
|
||||||
|
let Some(path) = pb.finish() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
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)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_and_stroke_path(
|
||||||
|
pixmap: &mut Pixmap,
|
||||||
|
path: &tiny_skia::Path,
|
||||||
|
style: &ShapeStyle,
|
||||||
|
scale: f32,
|
||||||
|
) {
|
||||||
|
if let Some(fill) = &style.fill {
|
||||||
|
let mut fill_paint = Paint::default();
|
||||||
|
fill_paint.set_color(egui_to_skia_color(fill.primary_color()));
|
||||||
|
pixmap.fill_path(
|
||||||
|
path,
|
||||||
|
&fill_paint,
|
||||||
|
FillRule::Winding,
|
||||||
|
Transform::identity(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
stroke_path(pixmap, path, style, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_and_stroke_path_even_odd(
|
||||||
|
pixmap: &mut Pixmap,
|
||||||
|
path: &tiny_skia::Path,
|
||||||
|
style: &ShapeStyle,
|
||||||
|
scale: f32,
|
||||||
|
) {
|
||||||
|
if let Some(fill) = &style.fill {
|
||||||
|
let mut fill_paint = Paint::default();
|
||||||
|
fill_paint.set_color(egui_to_skia_color(fill.primary_color()));
|
||||||
|
pixmap.fill_path(
|
||||||
|
path,
|
||||||
|
&fill_paint,
|
||||||
|
FillRule::EvenOdd,
|
||||||
|
Transform::identity(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
stroke_path(pixmap, path, style, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stroke_path(pixmap: &mut Pixmap, path: &tiny_skia::Path, style: &ShapeStyle, scale: f32) {
|
||||||
|
let stroke_width = style.stroke_width * scale;
|
||||||
|
if stroke_width <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_color(egui_to_skia_color(style.stroke_color));
|
||||||
|
|
||||||
|
let stroke = Stroke {
|
||||||
|
width: stroke_width,
|
||||||
|
line_cap: LineCap::Round,
|
||||||
|
line_join: LineJoin::Round,
|
||||||
|
..Stroke::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
pixmap.stroke_path(path, &paint, &stroke, Transform::identity(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_line_path(
|
||||||
|
start: egui::Pos2,
|
||||||
|
end: egui::Pos2,
|
||||||
|
min_x: f32,
|
||||||
|
min_y: f32,
|
||||||
|
scale: f32,
|
||||||
|
) -> Option<tiny_skia::Path> {
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
pb.move_to((start.x - min_x) * scale, (start.y - min_y) * scale);
|
||||||
|
pb.line_to((end.x - min_x) * scale, (end.y - min_y) * scale);
|
||||||
|
pb.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_polyline_path(
|
||||||
|
points: &[egui::Pos2],
|
||||||
|
min_x: f32,
|
||||||
|
min_y: f32,
|
||||||
|
scale: f32,
|
||||||
|
) -> Option<tiny_skia::Path> {
|
||||||
|
if points.len() < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
pb.move_to((left.x - min_x) * scale, (left.y - min_y) * scale);
|
||||||
|
pb.line_to((end.x - min_x) * scale, (end.y - min_y) * scale);
|
||||||
|
pb.move_to((right.x - min_x) * scale, (right.y - min_y) * scale);
|
||||||
|
pb.line_to((end.x - min_x) * scale, (end.y - min_y) * scale);
|
||||||
|
pb.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_ring_to_path(
|
||||||
|
pb: &mut PathBuilder,
|
||||||
|
ring: &[egui::Pos2],
|
||||||
|
min_x: f32,
|
||||||
|
min_y: f32,
|
||||||
|
scale: f32,
|
||||||
|
close: bool,
|
||||||
|
) {
|
||||||
|
if ring.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pb.move_to((ring[0].x - min_x) * scale, (ring[0].y - min_y) * scale);
|
||||||
|
for point in ring.iter().skip(1) {
|
||||||
|
pb.line_to((point.x - min_x) * scale, (point.y - min_y) * scale);
|
||||||
|
}
|
||||||
|
if close {
|
||||||
|
pb.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_text_element(
|
||||||
|
pixmap: &mut Pixmap,
|
||||||
|
text: &str,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
font_size: f32,
|
||||||
|
max_width: Option<f32>,
|
||||||
|
color: egui::Color32,
|
||||||
|
) -> Result<()> {
|
||||||
|
if text.is_empty() || font_size <= 0.0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let line_height = font_size * 1.4;
|
||||||
|
let lines = wrap_text_lines(text, font_size, max_width);
|
||||||
|
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 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!(
|
||||||
|
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,
|
||||||
|
h = approx_height,
|
||||||
|
size = font_size,
|
||||||
|
r = color.r(),
|
||||||
|
g = color.g(),
|
||||||
|
b = color.b(),
|
||||||
|
opacity = opacity,
|
||||||
|
text = text_body,
|
||||||
|
);
|
||||||
|
|
||||||
|
let tree = parse_svg(&svg_text)?;
|
||||||
|
let transform = Transform::from_translate(x, y);
|
||||||
|
resvg::render(&tree, transform, &mut pixmap.as_mut());
|
||||||
|
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> {
|
||||||
|
let mut options = usvg::Options::default();
|
||||||
|
options.fontdb_mut().load_system_fonts();
|
||||||
|
Ok(usvg::Tree::from_str(svg_source, &options)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_xml(text: &str) -> String {
|
||||||
|
let mut escaped = String::with_capacity(text.len());
|
||||||
|
for ch in text.chars() {
|
||||||
|
match ch {
|
||||||
|
'&' => escaped.push_str("&"),
|
||||||
|
'<' => escaped.push_str("<"),
|
||||||
|
'>' => escaped.push_str(">"),
|
||||||
|
'"' => escaped.push_str("""),
|
||||||
|
'\'' => escaped.push_str("'"),
|
||||||
|
_ => escaped.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
escaped
|
||||||
|
}
|
||||||
|
|
||||||
|
fn egui_to_skia_color(c: egui::Color32) -> tiny_skia::Color {
|
||||||
|
tiny_skia::Color::from_rgba8(c.r(), c.g(), c.b(), c.a())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_simple_rectangle() {
|
||||||
|
let data = ExportData {
|
||||||
|
svg_source: None,
|
||||||
|
drawing_elements: vec![DrawingElement::new(
|
||||||
|
Shape::Rectangle {
|
||||||
|
pos: egui::Pos2::new(10.0, 10.0),
|
||||||
|
size: egui::Vec2::new(100.0, 50.0),
|
||||||
|
},
|
||||||
|
ShapeStyle::default(),
|
||||||
|
)],
|
||||||
|
background_color: tiny_skia::Color::from_rgba8(26, 26, 46, 255),
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = "/tmp/agcanvas_test_export.png";
|
||||||
|
let result = export_canvas_to_png(&data, path, 1.0);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(std::path::Path::new(path).exists());
|
||||||
|
std::fs::remove_file(path).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
367
crates/agcanvas/src/history.rs
Normal file
367
crates/agcanvas/src/history.rs
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::drawing::DrawingElement;
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
|
||||||
|
pub struct NodeId(pub usize);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum ChangeSource {
|
||||||
|
Human,
|
||||||
|
Agent { name: Option<String> },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct DocumentSnapshot {
|
||||||
|
pub drawing_elements: Arc<Vec<DrawingElement>>,
|
||||||
|
pub svg_source: Option<Arc<str>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DocumentSnapshot {
|
||||||
|
pub fn new_empty() -> Self {
|
||||||
|
Self {
|
||||||
|
drawing_elements: Arc::new(Vec::new()),
|
||||||
|
svg_source: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_state(elements: &[DrawingElement], svg: Option<&str>) -> Self {
|
||||||
|
Self {
|
||||||
|
drawing_elements: Arc::new(elements.to_vec()),
|
||||||
|
svg_source: svg.map(Arc::<str>::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct HistoryNode {
|
||||||
|
pub id: NodeId,
|
||||||
|
pub parent: Option<NodeId>,
|
||||||
|
pub children: Vec<NodeId>,
|
||||||
|
pub last_active_child: Option<NodeId>,
|
||||||
|
pub label: String,
|
||||||
|
pub source: ChangeSource,
|
||||||
|
pub timestamp: Instant,
|
||||||
|
pub snapshot: DocumentSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HistoryTree {
|
||||||
|
pub nodes: Vec<HistoryNode>,
|
||||||
|
pub root: NodeId,
|
||||||
|
pub current: NodeId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryTree {
|
||||||
|
pub fn new(initial_snapshot: DocumentSnapshot) -> Self {
|
||||||
|
let root = NodeId(0);
|
||||||
|
let root_node = HistoryNode {
|
||||||
|
id: root,
|
||||||
|
parent: None,
|
||||||
|
children: Vec::new(),
|
||||||
|
last_active_child: None,
|
||||||
|
label: "Initial State".to_string(),
|
||||||
|
source: ChangeSource::Human,
|
||||||
|
timestamp: Instant::now(),
|
||||||
|
snapshot: initial_snapshot,
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
nodes: vec![root_node],
|
||||||
|
root,
|
||||||
|
current: root,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(
|
||||||
|
&mut self,
|
||||||
|
label: String,
|
||||||
|
source: ChangeSource,
|
||||||
|
snapshot: DocumentSnapshot,
|
||||||
|
) -> NodeId {
|
||||||
|
let parent = self.current;
|
||||||
|
let id = NodeId(self.nodes.len());
|
||||||
|
let node = HistoryNode {
|
||||||
|
id,
|
||||||
|
parent: Some(parent),
|
||||||
|
children: Vec::new(),
|
||||||
|
last_active_child: None,
|
||||||
|
label,
|
||||||
|
source,
|
||||||
|
timestamp: Instant::now(),
|
||||||
|
snapshot,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.nodes[parent.0].children.push(id);
|
||||||
|
self.nodes[parent.0].last_active_child = Some(id);
|
||||||
|
self.nodes.push(node);
|
||||||
|
self.current = id;
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checkout(&mut self, id: NodeId) -> &DocumentSnapshot {
|
||||||
|
assert!(id.0 < self.nodes.len(), "invalid history node id");
|
||||||
|
let mut target_ancestors = std::collections::HashSet::new();
|
||||||
|
let mut cursor = Some(id);
|
||||||
|
while let Some(node_id) = cursor {
|
||||||
|
target_ancestors.insert(node_id);
|
||||||
|
cursor = self.nodes[node_id.0].parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cursor = self.current;
|
||||||
|
while !target_ancestors.contains(&cursor) {
|
||||||
|
if let Some(parent) = self.nodes[cursor.0].parent {
|
||||||
|
self.nodes[parent.0].last_active_child = Some(cursor);
|
||||||
|
cursor = parent;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lca = cursor;
|
||||||
|
|
||||||
|
let mut path_down = Vec::new();
|
||||||
|
let mut cursor = id;
|
||||||
|
while cursor != lca {
|
||||||
|
path_down.push(cursor);
|
||||||
|
if let Some(parent) = self.nodes[cursor.0].parent {
|
||||||
|
cursor = parent;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path_down.reverse();
|
||||||
|
|
||||||
|
let mut parent = lca;
|
||||||
|
for child in path_down {
|
||||||
|
self.nodes[parent.0].last_active_child = Some(child);
|
||||||
|
parent = child;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current = id;
|
||||||
|
&self.nodes[id.0].snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn undo(&mut self) -> Option<&DocumentSnapshot> {
|
||||||
|
let parent = self.nodes[self.current.0].parent?;
|
||||||
|
Some(self.checkout(parent))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redo(&mut self) -> Option<&DocumentSnapshot> {
|
||||||
|
let child = self.nodes[self.current.0].last_active_child?;
|
||||||
|
Some(self.checkout(child))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn node(&self, id: NodeId) -> &HistoryNode {
|
||||||
|
&self.nodes[id.0]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn node_count(&self) -> usize {
|
||||||
|
self.nodes.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path_to_root(&self, id: NodeId) -> Vec<NodeId> {
|
||||||
|
let mut path = Vec::new();
|
||||||
|
let mut cursor = Some(id);
|
||||||
|
while let Some(node_id) = cursor {
|
||||||
|
path.push(node_id);
|
||||||
|
cursor = self.nodes[node_id.0].parent;
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use egui::Pos2;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::drawing::{DrawingElement, Shape, ShapeStyle};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_tree_has_one_node() {
|
||||||
|
let tree = HistoryTree::new(DocumentSnapshot::new_empty());
|
||||||
|
assert_eq!(tree.node_count(), 1);
|
||||||
|
assert_eq!(tree.root, NodeId(0));
|
||||||
|
assert_eq!(tree.current, NodeId(0));
|
||||||
|
assert_eq!(tree.node(NodeId(0)).label, "Initial State");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_push_creates_child() {
|
||||||
|
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
|
||||||
|
let id = tree.push(
|
||||||
|
"Draw Rectangle".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::new_empty(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(id, NodeId(1));
|
||||||
|
assert_eq!(tree.current, NodeId(1));
|
||||||
|
assert_eq!(tree.node(NodeId(0)).children, vec![NodeId(1)]);
|
||||||
|
assert_eq!(tree.node(NodeId(0)).last_active_child, Some(NodeId(1)));
|
||||||
|
assert_eq!(tree.node(NodeId(1)).parent, Some(NodeId(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_checkout_changes_current() {
|
||||||
|
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
|
||||||
|
let first = tree.push(
|
||||||
|
"First".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::new_empty(),
|
||||||
|
);
|
||||||
|
let second = tree.push(
|
||||||
|
"Second".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::new_empty(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tree.current, second);
|
||||||
|
let _ = tree.checkout(first);
|
||||||
|
assert_eq!(tree.current, first);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fork_creates_branch() {
|
||||||
|
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
|
||||||
|
let parent = tree.push(
|
||||||
|
"Parent".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::new_empty(),
|
||||||
|
);
|
||||||
|
let _child_a = tree.push(
|
||||||
|
"Child A".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::new_empty(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = tree.checkout(parent);
|
||||||
|
let child_b = tree.push(
|
||||||
|
"Child B".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::new_empty(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tree.node(parent).children.len(), 2);
|
||||||
|
assert_eq!(tree.node(parent).children[1], child_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_path_to_root() {
|
||||||
|
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
|
||||||
|
let n1 = tree.push(
|
||||||
|
"n1".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::new_empty(),
|
||||||
|
);
|
||||||
|
let n2 = tree.push(
|
||||||
|
"n2".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::new_empty(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tree.path_to_root(n2), vec![n2, n1, tree.root]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_undo_moves_to_parent_and_stops_at_root() {
|
||||||
|
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
|
||||||
|
let n1 = tree.push(
|
||||||
|
"n1".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::from_state(&[], Some("<svg id='n1'/>")),
|
||||||
|
);
|
||||||
|
let n2 = tree.push(
|
||||||
|
"n2".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::from_state(&[], Some("<svg id='n2'/>")),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tree.current, n2);
|
||||||
|
let undo_1 = tree.undo().and_then(|s| s.svg_source.as_deref());
|
||||||
|
assert_eq!(undo_1, Some("<svg id='n1'/>"));
|
||||||
|
assert_eq!(tree.current, n1);
|
||||||
|
|
||||||
|
let undo_2 = tree.undo().and_then(|s| s.svg_source.as_deref());
|
||||||
|
assert_eq!(undo_2, None);
|
||||||
|
assert_eq!(tree.current, tree.root);
|
||||||
|
|
||||||
|
assert!(tree.undo().is_none());
|
||||||
|
assert_eq!(tree.current, tree.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redo_follows_last_active_child() {
|
||||||
|
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
|
||||||
|
let n1 = tree.push(
|
||||||
|
"n1".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::from_state(&[], Some("<svg id='n1'/>")),
|
||||||
|
);
|
||||||
|
let n2 = tree.push(
|
||||||
|
"n2".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::from_state(&[], Some("<svg id='n2'/>")),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(tree.current, n2);
|
||||||
|
let _ = tree.undo();
|
||||||
|
assert_eq!(tree.current, n1);
|
||||||
|
|
||||||
|
let redo = tree.redo().and_then(|s| s.svg_source.as_deref());
|
||||||
|
assert_eq!(redo, Some("<svg id='n2'/>"));
|
||||||
|
assert_eq!(tree.current, n2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_redo_after_fork_tracks_most_recent_branch() {
|
||||||
|
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
|
||||||
|
let fork = tree.push(
|
||||||
|
"fork".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::from_state(&[], Some("<svg id='fork'/>")),
|
||||||
|
);
|
||||||
|
let branch_a = tree.push(
|
||||||
|
"branch_a".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::from_state(&[], Some("<svg id='a'/>")),
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = tree.checkout(fork);
|
||||||
|
let branch_b = tree.push(
|
||||||
|
"branch_b".to_string(),
|
||||||
|
ChangeSource::Human,
|
||||||
|
DocumentSnapshot::from_state(&[], Some("<svg id='b'/>")),
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = tree.undo();
|
||||||
|
assert_eq!(tree.current, fork);
|
||||||
|
let redo_b = tree.redo().and_then(|s| s.svg_source.as_deref());
|
||||||
|
assert_eq!(redo_b, Some("<svg id='b'/>"));
|
||||||
|
assert_eq!(tree.current, branch_b);
|
||||||
|
|
||||||
|
let _ = tree.checkout(branch_a);
|
||||||
|
let _ = tree.undo();
|
||||||
|
assert_eq!(tree.current, fork);
|
||||||
|
let redo_a = tree.redo().and_then(|s| s.svg_source.as_deref());
|
||||||
|
assert_eq!(redo_a, Some("<svg id='a'/>"));
|
||||||
|
assert_eq!(tree.current, branch_a);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_snapshot_preserves_elements() {
|
||||||
|
let element = DrawingElement::new(
|
||||||
|
Shape::Rectangle {
|
||||||
|
pos: Pos2::new(10.0, 20.0),
|
||||||
|
size: egui::vec2(50.0, 30.0),
|
||||||
|
},
|
||||||
|
ShapeStyle::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let snapshot = DocumentSnapshot::from_state(&[element], Some("<svg></svg>"));
|
||||||
|
assert_eq!(snapshot.drawing_elements.len(), 1);
|
||||||
|
assert_eq!(snapshot.svg_source.as_deref(), Some("<svg></svg>"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,40 @@ mod clipboard;
|
|||||||
mod command_palette;
|
mod command_palette;
|
||||||
mod drawing;
|
mod drawing;
|
||||||
mod element_tree;
|
mod element_tree;
|
||||||
|
mod export;
|
||||||
|
mod history;
|
||||||
mod mermaid;
|
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(
|
||||||
@@ -21,16 +47,29 @@ 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])
|
||||||
.with_min_inner_size([800.0, 600.0])
|
.with_min_inner_size([800.0, 600.0])
|
||||||
.with_title("agcanvas"),
|
.with_title("Augmented Canvas"),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"agcanvas",
|
"Augmented Canvas",
|
||||||
native_options,
|
native_options,
|
||||||
Box::new(|cc| Ok(Box::new(app::AgCanvasApp::new(cc)))),
|
Box::new(|cc| Ok(Box::new(app::AgCanvasApp::new(cc)))),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,67 @@
|
|||||||
|
use std::panic;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use mermaid_rs_renderer::RenderOptions;
|
||||||
|
|
||||||
pub fn render_mermaid_to_svg(mermaid_source: &str) -> Result<String> {
|
pub fn render_mermaid_to_svg(mermaid_source: &str) -> Result<String> {
|
||||||
let svg = mermaid_rs_renderer::render(mermaid_source)
|
let source = mermaid_source.to_string();
|
||||||
.map_err(|e| anyhow::anyhow!("Mermaid render failed: {}", e))?;
|
|
||||||
Ok(svg)
|
let mut options = RenderOptions::modern();
|
||||||
|
options.theme.font_family =
|
||||||
|
"Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif".to_string();
|
||||||
|
|
||||||
|
let result = panic::catch_unwind(|| mermaid_rs_renderer::render_with_options(&source, options));
|
||||||
|
match result {
|
||||||
|
Ok(Ok(svg)) => Ok(sanitize_svg_font_families(&svg)),
|
||||||
|
Ok(Err(e)) => Err(anyhow::anyhow!("Mermaid render failed: {}", e)),
|
||||||
|
Err(_) => Err(anyhow::anyhow!(
|
||||||
|
"Mermaid renderer panicked (unsupported syntax)"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips nested double quotes inside `font-family` XML attributes.
|
||||||
|
///
|
||||||
|
/// mermaid-rs-renderer v0.2.0 emits `font-family="..., "Segoe UI", ..."`
|
||||||
|
/// which is invalid XML — usvg rejects it. This rewrites those attributes
|
||||||
|
/// so the inner quotes are removed.
|
||||||
|
fn sanitize_svg_font_families(svg: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(svg.len());
|
||||||
|
let mut chars = svg.chars().peekable();
|
||||||
|
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
result.push(ch);
|
||||||
|
|
||||||
|
if ch == 'f' && result.ends_with("font-family=\"") {
|
||||||
|
let mut value = String::new();
|
||||||
|
loop {
|
||||||
|
match chars.next() {
|
||||||
|
Some('"') => {
|
||||||
|
if let Some(&next) = chars.peek() {
|
||||||
|
if next == ' ' || next == '/' || next == '>' || next == '\n' {
|
||||||
|
result.push_str(&value);
|
||||||
|
result.push('"');
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push_str(&value);
|
||||||
|
result.push('"');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(c) => value.push(c),
|
||||||
|
None => {
|
||||||
|
result.push_str(&value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -21,4 +79,23 @@ mod tests {
|
|||||||
let svg = render_mermaid_to_svg("sequenceDiagram\n Alice->>Bob: Hello").unwrap();
|
let svg = render_mermaid_to_svg("sequenceDiagram\n Alice->>Bob: Hello").unwrap();
|
||||||
assert!(svg.contains("<svg"));
|
assert!(svg.contains("<svg"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_edge_labels() {
|
||||||
|
let svg = render_mermaid_to_svg(
|
||||||
|
"flowchart LR\n A{Decision} -->|Yes| B[OK]\n A -->|No| C[Cancel]",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(svg.contains("<svg"));
|
||||||
|
assert!(svg.contains("Yes"));
|
||||||
|
assert!(svg.contains("No"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitized_svg_is_valid_xml() {
|
||||||
|
let svg = render_mermaid_to_svg("flowchart LR\n A-->B").unwrap();
|
||||||
|
let mut options = usvg::Options::default();
|
||||||
|
options.fontdb_mut().load_system_fonts();
|
||||||
|
usvg::Tree::from_str(&svg, &options).expect("sanitized SVG should parse with usvg");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>")
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
use crate::canvas::CanvasState;
|
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::svg::SvgRenderer;
|
use crate::history::{ChangeSource, DocumentSnapshot, HistoryTree, NodeId};
|
||||||
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};
|
||||||
@@ -80,16 +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_id: Option<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 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,
|
||||||
@@ -102,15 +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_id: None,
|
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()),
|
||||||
|
polygon_sides: 6,
|
||||||
|
polygon_star_ratio: None,
|
||||||
description: None,
|
description: None,
|
||||||
created_by,
|
created_by,
|
||||||
created_at: unix_now(),
|
created_at: unix_now(),
|
||||||
@@ -136,32 +140,94 @@ 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_id = None;
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn selected_element(&self) -> Option<&DrawingElement> {
|
pub fn selected_element(&self) -> Option<&DrawingElement> {
|
||||||
self.selected_element_id
|
self.selected_element_ids
|
||||||
.as_ref()
|
.iter()
|
||||||
.and_then(|id| self.drawing_elements.iter().find(|e| e.id == *id))
|
.find_map(|id| self.drawing_elements.iter().find(|e| e.id == *id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_elements(&self) -> Vec<&DrawingElement> {
|
||||||
|
self.selected_element_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| self.drawing_elements.iter().find(|e| e.id == *id))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn selected_element_mut(&mut self) -> Option<&mut DrawingElement> {
|
pub fn selected_element_mut(&mut self) -> Option<&mut DrawingElement> {
|
||||||
let id = self.selected_element_id.clone();
|
let id = self.selected_element_ids.first().cloned();
|
||||||
id.and_then(move |id| self.drawing_elements.iter_mut().find(|e| e.id == id))
|
id.and_then(move |id| self.drawing_elements.iter_mut().find(|e| e.id == id))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_selected(&mut self) {
|
pub fn delete_selected(&mut self) {
|
||||||
if let Some(id) = self.selected_element_id.take() {
|
if !self.selected_element_ids.is_empty() {
|
||||||
self.drawing_elements.retain(|e| e.id != id);
|
let selected_ids = self.selected_element_ids.clone();
|
||||||
|
for id in &selected_ids {
|
||||||
|
self.svg_textures.remove(id);
|
||||||
|
}
|
||||||
|
self.drawing_elements
|
||||||
|
.retain(|e| !selected_ids.contains(&e.id));
|
||||||
|
self.selected_element_ids.clear();
|
||||||
|
self.selected_vertex = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_edit(&mut self, label: &str, source: ChangeSource) {
|
||||||
|
let snapshot =
|
||||||
|
DocumentSnapshot::from_state(&self.drawing_elements, self.svg_source.as_deref());
|
||||||
|
self.history.push(label.to_string(), source, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checkout_history(&mut self, node_id: NodeId) {
|
||||||
|
let snapshot = self.history.checkout(node_id).clone();
|
||||||
|
self.drawing_elements = (*snapshot.drawing_elements).clone();
|
||||||
|
self.svg_source = snapshot.svg_source.map(|s| s.to_string());
|
||||||
|
self.element_tree = None;
|
||||||
|
|
||||||
|
self.selected_element_ids.clear();
|
||||||
|
self.selected_vertex = None;
|
||||||
|
self.drag_state = DragState::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn undo(&mut self) -> bool {
|
||||||
|
if let Some(snapshot) = self.history.undo().cloned() {
|
||||||
|
self.drawing_elements = (*snapshot.drawing_elements).clone();
|
||||||
|
self.svg_source = snapshot.svg_source.map(|s| s.to_string());
|
||||||
|
self.element_tree = None;
|
||||||
|
|
||||||
|
self.selected_element_ids.clear();
|
||||||
|
self.selected_vertex = None;
|
||||||
|
self.drag_state = DragState::default();
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn redo(&mut self) -> bool {
|
||||||
|
if let Some(snapshot) = self.history.redo().cloned() {
|
||||||
|
self.drawing_elements = (*snapshot.drawing_elements).clone();
|
||||||
|
self.svg_source = snapshot.svg_source.map(|s| s.to_string());
|
||||||
|
self.element_tree = None;
|
||||||
|
|
||||||
|
self.selected_element_ids.clear();
|
||||||
|
self.selected_vertex = None;
|
||||||
|
self.drag_state = DragState::default();
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,14 +236,36 @@ impl Session {
|
|||||||
pub struct SessionData {
|
pub struct SessionData {
|
||||||
pub info: SessionInfo,
|
pub info: SessionInfo,
|
||||||
pub tree: Option<ElementTree>,
|
pub tree: Option<ElementTree>,
|
||||||
|
pub svg_source: Option<String>,
|
||||||
pub drawing_elements: Vec<DrawingElement>,
|
pub drawing_elements: 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 {
|
||||||
@@ -185,13 +273,19 @@ impl SessionStore {
|
|||||||
Self::default()
|
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();
|
let id = info.id.clone();
|
||||||
self.sessions.insert(
|
self.sessions.insert(
|
||||||
id.clone(),
|
id.clone(),
|
||||||
SessionData {
|
SessionData {
|
||||||
info,
|
info,
|
||||||
tree,
|
tree,
|
||||||
|
svg_source,
|
||||||
drawing_elements: Vec::new(),
|
drawing_elements: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -213,11 +307,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) {
|
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||||
data.info.has_svg = tree.is_some();
|
data.info.has_svg = tree.is_some();
|
||||||
data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count);
|
data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count);
|
||||||
data.tree = tree;
|
data.tree = tree;
|
||||||
|
data.svg_source = svg_source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +336,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(
|
pub fn get_drawing_elements(
|
||||||
&self,
|
&self,
|
||||||
session_id: Option<&str>,
|
session_id: Option<&str>,
|
||||||
@@ -247,6 +353,14 @@ impl SessionStore {
|
|||||||
Some((id, &data.drawing_elements))
|
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 {
|
pub fn add_drawing_element(&mut self, session_id: &str, element: DrawingElement) -> bool {
|
||||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||||
data.drawing_elements.push(element);
|
data.drawing_elements.push(element);
|
||||||
@@ -374,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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
749
crates/agcanvas/src/svg/converter.rs
Normal file
749
crates/agcanvas/src/svg/converter.rs
Normal file
@@ -0,0 +1,749 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use egui::{Color32, Pos2, Vec2};
|
||||||
|
use usvg::tiny_skia_path::PathSegment;
|
||||||
|
|
||||||
|
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.
|
||||||
|
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();
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_group(group: &usvg::Group, transform: AffineTransform, elements: &mut Vec<DrawingElement>) {
|
||||||
|
let next_transform = transform.concat(AffineTransform::from_usvg(group.transform()));
|
||||||
|
let child_count = group.children().len();
|
||||||
|
|
||||||
|
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() {
|
||||||
|
match node {
|
||||||
|
usvg::Node::Group(g) => walk_group(g, next_transform, elements),
|
||||||
|
usvg::Node::Path(path) => {
|
||||||
|
let before = elements.len();
|
||||||
|
if let Some(element) = convert_path(path, next_transform) {
|
||||||
|
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) => {
|
||||||
|
if let Some(element) = convert_text(text, next_transform) {
|
||||||
|
elements.push(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usvg::Node::Image(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assign_text_max_widths(elements, start_idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_path(path: &usvg::Path, transform: AffineTransform) -> 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 transform.is_axis_aligned() && is_rect_path(&segments) {
|
||||||
|
let (pos, size) = transformed_rect_from_bbox(bbox, transform);
|
||||||
|
return Some(DrawingElement::new(Shape::Rectangle { pos, size }, style));
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_line_path(&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) {
|
||||||
|
Shape::Arrow {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
control_offset: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Shape::Line { start, end }
|
||||||
|
};
|
||||||
|
return Some(DrawingElement::new(shape, style));
|
||||||
|
}
|
||||||
|
|
||||||
|
let aspect = bbox.width() / bbox.height().max(0.001);
|
||||||
|
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(
|
||||||
|
Shape::Ellipse {
|
||||||
|
center: Pos2::new(pos.x + size.x * 0.5, pos.y + size.y * 0.5),
|
||||||
|
radii: Vec2::new(size.x * 0.5, size.y * 0.5),
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let polygons = flatten_path_segments(&segments, transform);
|
||||||
|
if polygons.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(DrawingElement::new(Shape::Path { polygons }, style))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_text(text: &usvg::Text, transform: AffineTransform) -> Option<DrawingElement> {
|
||||||
|
let content = extract_text_content(text);
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bbox = text.bounding_box();
|
||||||
|
let pos = transform.apply(bbox.left(), bbox.top());
|
||||||
|
let font_size = extract_font_size(text);
|
||||||
|
let fill_color = extract_text_color(text);
|
||||||
|
|
||||||
|
Some(DrawingElement::new(
|
||||||
|
Shape::Text {
|
||||||
|
pos,
|
||||||
|
content,
|
||||||
|
font_size,
|
||||||
|
max_width: None,
|
||||||
|
},
|
||||||
|
ShapeStyle {
|
||||||
|
fill: Some(Fill::solid(fill_color)),
|
||||||
|
stroke_color: fill_color,
|
||||||
|
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 {
|
||||||
|
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| extract_fill(f.paint()));
|
||||||
|
|
||||||
|
let (stroke_color, stroke_width, stroke_dash) = path
|
||||||
|
.stroke()
|
||||||
|
.map(|s| {
|
||||||
|
let color = match s.paint() {
|
||||||
|
usvg::Paint::Color(c) => Color32::from_rgb(c.red, c.green, c.blue),
|
||||||
|
_ => Color32::WHITE,
|
||||||
|
};
|
||||||
|
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, None));
|
||||||
|
|
||||||
|
ShapeStyle {
|
||||||
|
fill,
|
||||||
|
stroke_color,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
&& segments
|
||||||
|
.iter()
|
||||||
|
.all(|s| matches!(s, PathSegment::MoveTo(_) | PathSegment::LineTo(_)))
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
382
crates/agcanvas/src/svg/export_svg.rs
Normal file
382
crates/agcanvas/src/svg/export_svg.rs
Normal 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("&"),
|
||||||
|
'<' => escaped.push_str("<"),
|
||||||
|
'>' => escaped.push_str(">"),
|
||||||
|
'"' => escaped.push_str("""),
|
||||||
|
'\'' => escaped.push_str("'"),
|
||||||
|
_ => 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 "));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
mod converter;
|
||||||
|
mod export_svg;
|
||||||
mod parser;
|
mod parser;
|
||||||
mod renderer;
|
mod renderer;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ fn generate_id() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_svg(svg_data: &str) -> Result<(ElementTree, Tree)> {
|
pub fn parse_svg(svg_data: &str) -> Result<(ElementTree, Tree)> {
|
||||||
let options = usvg::Options::default();
|
let mut options = usvg::Options::default();
|
||||||
|
options.fontdb_mut().load_system_fonts();
|
||||||
let tree = Tree::from_str(svg_data, &options)?;
|
let tree = Tree::from_str(svg_data, &options)?;
|
||||||
|
|
||||||
let size = tree.size();
|
let size = tree.size();
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
67
crates/agcanvas/src/theme.rs
Normal file
67
crates/agcanvas/src/theme.rs
Normal 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
scripts/bundle-macos.sh
Executable file
48
scripts/bundle-macos.sh
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Bundle agcanvas as a macOS .app for Finder.
|
||||||
|
# Works on both Apple Silicon and Intel.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/bundle-macos.sh # Build release + bundle
|
||||||
|
# ./scripts/bundle-macos.sh --install # Also copy to /Applications
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
APP_NAME="Augmented Canvas"
|
||||||
|
BUNDLE_DIR="$PROJECT_ROOT/target/release/bundle"
|
||||||
|
APP_BUNDLE="$BUNDLE_DIR/$APP_NAME.app"
|
||||||
|
|
||||||
|
echo "==> Building agcanvas (release)..."
|
||||||
|
cargo build --release -p agcanvas --manifest-path "$PROJECT_ROOT/Cargo.toml"
|
||||||
|
|
||||||
|
echo "==> Creating $APP_NAME.app bundle..."
|
||||||
|
rm -rf "$APP_BUNDLE"
|
||||||
|
mkdir -p "$APP_BUNDLE/Contents/MacOS"
|
||||||
|
mkdir -p "$APP_BUNDLE/Contents/Resources"
|
||||||
|
|
||||||
|
cp "$PROJECT_ROOT/target/release/agcanvas" "$APP_BUNDLE/Contents/MacOS/agcanvas"
|
||||||
|
cp "$PROJECT_ROOT/assets/Info.plist" "$APP_BUNDLE/Contents/Info.plist"
|
||||||
|
|
||||||
|
if [ -f "$PROJECT_ROOT/assets/AppIcon.icns" ]; then
|
||||||
|
cp "$PROJECT_ROOT/assets/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/AppIcon.icns"
|
||||||
|
echo " Icon: AppIcon.icns"
|
||||||
|
else
|
||||||
|
echo " Icon: none (add assets/AppIcon.icns for a custom icon)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'APPL????' > "$APP_BUNDLE/Contents/PkgInfo"
|
||||||
|
|
||||||
|
echo "==> Bundle created: $APP_BUNDLE"
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--install" ]]; then
|
||||||
|
echo "==> Installing to /Applications..."
|
||||||
|
rm -rf "/Applications/$APP_NAME.app"
|
||||||
|
cp -r "$APP_BUNDLE" "/Applications/$APP_NAME.app"
|
||||||
|
echo "==> Installed: /Applications/$APP_NAME.app"
|
||||||
|
echo " Open Finder → Applications → Augmented Canvas"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Done."
|
||||||
Reference in New Issue
Block a user