Compare commits
16 Commits
d248864ee2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5faf69cad | ||
|
|
519ed74a3a | ||
|
|
64b4f667fb | ||
|
|
9e9d33eb84 | ||
|
|
519d1f2459 | ||
|
|
8390d01f85 | ||
|
|
740fa2f5f9 | ||
|
|
5ca1e85209 | ||
|
|
9b8acd4002 | ||
|
|
1929023409 | ||
|
|
ce2079ad95 | ||
|
|
9489c390fa | ||
|
|
e8ec44d961 | ||
|
|
233cb5798c | ||
|
|
43f1beea16 | ||
|
|
b140d93163 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,8 @@
|
||||
/target
|
||||
crates/*/target
|
||||
Cargo.lock
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
opencode.jsonc
|
||||
.opencode
|
||||
|
||||
19
AGENTS.md
19
AGENTS.md
@@ -11,6 +11,7 @@ Guidelines for AI agents working in this Rust codebase.
|
||||
- WebSocket: tokio-tungstenite
|
||||
- Serialization: serde/serde_json
|
||||
- Error handling: anyhow/thiserror
|
||||
- Boolean ops: i_overlay, triangulation: earcutr
|
||||
- Logging: tracing
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
@@ -29,16 +30,31 @@ cargo test -- --nocapture # Show println! output
|
||||
|
||||
cargo check # Type check only (fast)
|
||||
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
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs # Entry point, window setup
|
||||
├── 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
|
||||
├── 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/
|
||||
│ ├── state.rs # Pan/zoom transformation state
|
||||
│ └── interaction.rs # Mouse/keyboard input handling
|
||||
@@ -46,7 +62,7 @@ src/
|
||||
│ ├── parser.rs # SVG -> ElementTree conversion
|
||||
│ └── renderer.rs # SVG -> pixels (resvg/tiny-skia)
|
||||
└── agent/
|
||||
├── protocol.rs # JSON message types
|
||||
├── protocol.rs # JSON message types (incl. BooleanOp)
|
||||
└── 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": "Describe"}
|
||||
{"type": "GenerateCode", "target": "react", "element_id": null}
|
||||
{"type": "BooleanOp", "operation": "union", "element_ids": ["id1", "id2"], "consume_sources": true}
|
||||
|
||||
// Response
|
||||
{"type": "Tree", "tree": {...}}
|
||||
|
||||
191
README.md
191
README.md
@@ -1,10 +1,10 @@
|
||||
# agcanvas
|
||||
# agcanvas — Augmented Canvas
|
||||
|
||||
A system-level interactive canvas for agent-human collaboration. Draw shapes, paste SVGs from Figma, render Mermaid diagrams, and let AI agents understand your designs via MCP or WebSocket.
|
||||
|
||||
## What is this?
|
||||
|
||||
agcanvas bridges the gap between visual design and code generation. It's a **collaboration surface** for humans and AI agents — draw your ideas, paste designs, and let agents see exactly what's on the canvas.
|
||||
agcanvas (short for **Augmented Canvas**) bridges the gap between visual design and code generation. It's a **collaboration surface** for humans and AI agents — draw your ideas, paste designs, and let agents see exactly what's on the canvas.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
@@ -31,10 +31,16 @@ agcanvas bridges the gap between visual design and code generation. It's a **col
|
||||
### Canvas & Drawing
|
||||
- **SVG Paste** — Copy frames from Figma, paste directly (Cmd+V)
|
||||
- **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
|
||||
- **Mermaid Diagrams** — Write Mermaid syntax, render as SVG on canvas
|
||||
- **Sessions/Tabs** — Multiple canvases in tabs, each with independent state
|
||||
- **Pan/Zoom** — Smooth canvas navigation
|
||||
- **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
|
||||
- **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
|
||||
- **Command Palette** — Cmd+K to search and execute any command with fuzzy matching
|
||||
|
||||
### AI Agent Integration
|
||||
- **MCP Server** — `agcanvas-mcp` bridge for Claude Code, OpenCode, and Codex
|
||||
@@ -58,6 +64,63 @@ This builds two binaries:
|
||||
- `target/release/agcanvas` — The desktop app
|
||||
- `target/release/agcanvas-mcp` — The MCP server bridge
|
||||
|
||||
### Install to PATH
|
||||
|
||||
After building, symlink (or copy) the binaries so they're available system-wide:
|
||||
|
||||
**macOS / Linux:**
|
||||
|
||||
```bash
|
||||
sudo ln -sf "$(pwd)/target/release/agcanvas" /usr/local/bin/agcanvas
|
||||
sudo ln -sf "$(pwd)/target/release/agcanvas-mcp" /usr/local/bin/agcanvas-mcp
|
||||
```
|
||||
|
||||
Or install to a user-local directory (no sudo):
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf "$(pwd)/target/release/agcanvas" ~/.local/bin/agcanvas
|
||||
ln -sf "$(pwd)/target/release/agcanvas-mcp" ~/.local/bin/agcanvas-mcp
|
||||
```
|
||||
|
||||
> Make sure `~/.local/bin` is in your `PATH`. Add `export PATH="$HOME/.local/bin:$PATH"` to your `~/.zshrc` or `~/.bashrc` if needed.
|
||||
|
||||
**Windows (PowerShell, run as Administrator):**
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.local\bin\agcanvas.exe" -Target "$(Get-Location)\target\release\agcanvas.exe" -Force
|
||||
New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.local\bin\agcanvas-mcp.exe" -Target "$(Get-Location)\target\release\agcanvas-mcp.exe" -Force
|
||||
```
|
||||
|
||||
> Make sure `%USERPROFILE%\.local\bin` is in your system `PATH`. Or use an existing PATH directory like `C:\Users\<you>\AppData\Local\Microsoft\WindowsApps`.
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
agcanvas --help
|
||||
agcanvas-mcp --help
|
||||
```
|
||||
|
||||
### macOS `.app` 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`:
|
||||
|
||||
```bash
|
||||
./scripts/bundle-macos.sh
|
||||
```
|
||||
|
||||
This builds a release binary and packages it into `target/release/bundle/Augmented Canvas.app`.
|
||||
|
||||
To install directly to `/Applications`:
|
||||
|
||||
```bash
|
||||
./scripts/bundle-macos.sh --install
|
||||
```
|
||||
|
||||
To add a custom icon, place an `AppIcon.icns` file in `assets/` before bundling.
|
||||
|
||||
> **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.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Rust 1.70+
|
||||
@@ -85,6 +148,7 @@ This builds two binaries:
|
||||
| Action | Shortcut |
|
||||
|--------|----------|
|
||||
| Select tool | V |
|
||||
| Pan tool | H |
|
||||
| Rectangle tool | R |
|
||||
| Ellipse tool | E |
|
||||
| Line tool | L |
|
||||
@@ -93,8 +157,14 @@ This builds two binaries:
|
||||
| Delete selected | Delete / Backspace |
|
||||
| Cancel / back to Select | Escape |
|
||||
| Paste SVG | Cmd+V |
|
||||
| Export as PNG | Cmd+Shift+E |
|
||||
| New Tab | Cmd+T |
|
||||
| Close Tab | Cmd+W |
|
||||
| Save workspace | Cmd+S |
|
||||
| Command palette | Cmd+K |
|
||||
| Toggle history panel | Cmd+H |
|
||||
| Undo | Cmd+Z |
|
||||
| Redo | Cmd+Shift+Z |
|
||||
| Reset zoom | Cmd+0 |
|
||||
|
||||
## MCP Server (AI Agent Integration)
|
||||
@@ -118,14 +188,15 @@ Add to your project's `.mcp.json` (or `~/.claude/mcp.json` for global):
|
||||
|
||||
### Setup for OpenCode
|
||||
|
||||
Add to your `opencode.json`:
|
||||
Add to your `opencode.json` (project-level) or `~/.config/opencode/opencode.json` (global):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcp": {
|
||||
"agcanvas": {
|
||||
"command": "agcanvas-mcp",
|
||||
"args": ["--port", "9876"]
|
||||
"type": "local",
|
||||
"command": ["agcanvas-mcp", "--port", "9876"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,21 +206,29 @@ Add to your `opencode.json`:
|
||||
|
||||
Same MCP config format — add the `agcanvas` entry to your Codex MCP configuration.
|
||||
|
||||
> **Note:** Make sure `agcanvas-mcp` is in your PATH, or use the full path to the binary (e.g., `/path/to/target/release/agcanvas-mcp`). agcanvas must be running for the MCP tools to work.
|
||||
|
||||
See [`examples/mcp-configs/`](examples/mcp-configs/) for ready-to-copy configuration files.
|
||||
> **Note:** Make sure `agcanvas-mcp` is in your PATH (e.g., `~/.local/bin`), or use the full path to the binary. agcanvas must be running for the MCP tools to work.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_sessions` | List all open tabs/sessions in agcanvas |
|
||||
| `list_sessions` | List all open tabs/sessions with creator info, descriptions, timestamps. Supports sorting by name, created_at, created_by, element_count |
|
||||
| `create_session` | Create a new session/tab from an agent, with name, description, and creator identity |
|
||||
| `update_session` | Update an existing session's name or description |
|
||||
| `get_element_tree` | Get the full parsed SVG element tree (structured JSON) |
|
||||
| `describe_canvas` | Get a human-readable description of the canvas |
|
||||
| `get_element_by_id` | Look up a specific element by ID |
|
||||
| `get_elements_at_point` | Find elements at an (x, y) coordinate |
|
||||
| `get_drawing_elements` | Get all user-drawn shapes (rects, ellipses, lines, arrows, text) |
|
||||
| `generate_code` | Generate code stubs (html, react, tailwind, svelte, vue) |
|
||||
| `create_drawing_element` | Create a shape on the canvas (Rectangle, Ellipse, Line, Arrow, Text) |
|
||||
| `update_drawing_element` | Update an existing drawing element's shape or style |
|
||||
| `delete_drawing_element` | Delete a drawing element by ID |
|
||||
| `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.
|
||||
|
||||
@@ -174,20 +253,45 @@ All requests support an optional `session_id` parameter. If omitted, the active
|
||||
|
||||
```json
|
||||
{"type": "ListSessions"}
|
||||
{"type": "ListSessions", "sort_by": "created_at", "sort_order": "desc"}
|
||||
```
|
||||
|
||||
Sort fields: `name`, `created_at` (default), `created_by`, `element_count`. Order: `asc` (default), `desc`.
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"type": "Sessions",
|
||||
"sessions": [
|
||||
{"id": "session-1", "name": "Tab 1", "has_svg": true, "element_count": 15},
|
||||
{"id": "session-2", "name": "Tab 2", "has_svg": false, "element_count": null}
|
||||
{"id": "session-1", "name": "Tab 1", "has_svg": true, "element_count": 15, "description": null, "created_by": {"type": "Human"}, "created_at": 1707500000},
|
||||
{"id": "session-2", "name": "Agent Work", "has_svg": false, "element_count": null, "description": "Architecture diagram", "created_by": {"type": "Agent", "name": "Claude"}, "created_at": 1707500100}
|
||||
],
|
||||
"active_session": "session-1"
|
||||
}
|
||||
```
|
||||
|
||||
#### Create session (agent)
|
||||
|
||||
```json
|
||||
{"type": "CreateSession", "name": "My Session", "description": "Working on auth flow", "created_by_name": "Claude"}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{"type": "SessionCreated", "session": {"id": "session-3", "name": "My Session", ...}}
|
||||
```
|
||||
|
||||
#### Update session
|
||||
|
||||
```json
|
||||
{"type": "UpdateSession", "session_id": "session-1", "name": "Renamed", "description": "Updated description"}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{"type": "SessionUpdated", "session": {"id": "session-1", "name": "Renamed", ...}}
|
||||
```
|
||||
|
||||
#### Get full element tree
|
||||
|
||||
```json
|
||||
@@ -228,6 +332,34 @@ Response:
|
||||
|
||||
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
|
||||
|
||||
```json
|
||||
@@ -270,19 +402,25 @@ crates/
|
||||
│ └── src/
|
||||
│ ├── main.rs # Entry point, window setup
|
||||
│ ├── 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/)
|
||||
│ ├── command_palette.rs # Cmd+K command palette with fuzzy search
|
||||
│ ├── element_tree.rs # Structured element representation
|
||||
│ ├── 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/
|
||||
│ │ ├── element.rs # DrawingElement, Shape, ShapeStyle, hit testing
|
||||
│ │ ├── boolean.rs # Boolean shape operations (union, intersection, difference, xor)
|
||||
│ │ ├── tool.rs # Tool enum, DragState, ResizeHandle
|
||||
│ │ └── render.rs # Shape rendering via egui Painter
|
||||
│ │ └── render.rs # Shape rendering via egui Painter (incl. Path triangulation)
|
||||
│ ├── canvas/
|
||||
│ │ ├── state.rs # Pan/zoom transformation state
|
||||
│ │ └── interaction.rs # Mouse/keyboard input handling
|
||||
│ ├── svg/
|
||||
│ │ ├── parser.rs # SVG → ElementTree conversion
|
||||
│ │ ├── converter.rs # SVG → DrawingElements (Mermaid conversion)
|
||||
│ │ └── renderer.rs # SVG → pixels (resvg/tiny-skia)
|
||||
│ └── agent/
|
||||
│ ├── protocol.rs # JSON message types
|
||||
@@ -305,6 +443,9 @@ crates/
|
||||
| `arboard` | Clipboard access |
|
||||
| `tokio-tungstenite` | WebSocket (both server and client) |
|
||||
| `rmcp` | MCP server SDK (Anthropic official) |
|
||||
| `dirs` | Platform data directory paths |
|
||||
| `i_overlay` | Boolean shape operations (union, intersection, difference, xor) |
|
||||
| `earcutr` | Polygon triangulation for Path rendering |
|
||||
| `serde`/`serde_json` | Serialization |
|
||||
|
||||
## Roadmap
|
||||
@@ -314,13 +455,19 @@ crates/
|
||||
- [x] Selection, move, resize with handles
|
||||
- [x] Mermaid diagram rendering
|
||||
- [x] MCP server bridge for AI coding tools
|
||||
- [x] Agent draw commands (modify canvas from agent)
|
||||
- [x] Session metadata (creator tracking, descriptions, timestamps, sorting)
|
||||
- [x] Session persistence (auto-save/restore workspace)
|
||||
- [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)
|
||||
- [ ] Agent draw commands (modify canvas from agent)
|
||||
- [ ] Export to file
|
||||
- [ ] Diff view (before/after agent changes)
|
||||
- [ ] Plugin system for code generators
|
||||
- [ ] Undo/redo
|
||||
- [ ] Multi-select and group operations
|
||||
- [ ] App icon
|
||||
|
||||
## 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
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "MCP server bridge for agcanvas — exposes canvas tools to Claude Code, OpenCode, and Codex"
|
||||
description = "MCP server bridge for Augmented Canvas — exposes canvas tools to Claude Code, OpenCode, and Codex"
|
||||
|
||||
[[bin]]
|
||||
name = "agcanvas-mcp"
|
||||
|
||||
@@ -7,7 +7,7 @@ pub async fn send_request(ws_url: &str, request_json: &str) -> Result<String> {
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Cannot connect to agcanvas at {}. Is agcanvas running? Error: {}",
|
||||
"Cannot connect to Augmented Canvas at {}. Is Augmented Canvas running? Error: {}",
|
||||
ws_url,
|
||||
e
|
||||
)
|
||||
|
||||
@@ -8,7 +8,10 @@ use tools::AgCanvasServer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "agcanvas-mcp", about = "MCP server bridge for agcanvas")]
|
||||
#[command(
|
||||
name = "agcanvas-mcp",
|
||||
about = "MCP server bridge for Augmented Canvas"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = "9876")]
|
||||
port: u16,
|
||||
@@ -26,7 +29,10 @@ async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let ws_url = format!("ws://127.0.0.1:{}", cli.port);
|
||||
|
||||
tracing::info!("Starting agcanvas MCP server, connecting to {}", ws_url);
|
||||
tracing::info!(
|
||||
"Starting Augmented Canvas MCP server, connecting to {}",
|
||||
ws_url
|
||||
);
|
||||
|
||||
let server = AgCanvasServer::new(ws_url);
|
||||
let service = server.serve(rmcp::transport::stdio()).await?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ name = "agcanvas"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Interactive canvas for agent-human collaboration with SVG support"
|
||||
description = "Augmented Canvas — interactive canvas for agent-human collaboration with SVG support"
|
||||
|
||||
[dependencies]
|
||||
# GUI
|
||||
@@ -28,20 +28,28 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# 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"
|
||||
futures-util = "0.3"
|
||||
|
||||
# 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
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Filesystem paths
|
||||
dirs = "5.0"
|
||||
|
||||
# Singleton lock
|
||||
fs2 = "0.4"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
# 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,5 +1,5 @@
|
||||
mod protocol;
|
||||
mod server;
|
||||
|
||||
pub use protocol::GuiEvent;
|
||||
pub use protocol::{DrawingCommand, GuiEvent, SessionCommand};
|
||||
pub use server::AgentServer;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use crate::drawing::DrawingElement;
|
||||
use crate::drawing::{BooleanOpType, DrawingElement, Shape, ShapeStyle};
|
||||
use crate::element_tree::{ElementTree, TreeMetadata};
|
||||
use crate::session::SessionInfo;
|
||||
use crate::session::{SessionCreator, SessionInfo, SessionSortField, SortOrder};
|
||||
use egui::{Color32, Pos2, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GUI → Agent events (broadcast to all connected agents)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum GuiEvent {
|
||||
@@ -27,12 +32,56 @@ pub enum GuiEvent {
|
||||
SvgCleared {
|
||||
session_id: String,
|
||||
},
|
||||
DrawingElementCreated {
|
||||
session_id: String,
|
||||
element: DrawingElement,
|
||||
},
|
||||
DrawingElementUpdated {
|
||||
session_id: String,
|
||||
element: DrawingElement,
|
||||
},
|
||||
DrawingElementDeleted {
|
||||
session_id: String,
|
||||
id: String,
|
||||
},
|
||||
DrawingElementsCleared {
|
||||
session_id: String,
|
||||
},
|
||||
ScreenshotCaptured {
|
||||
path: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent → Server requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AgentRequest {
|
||||
ListSessions,
|
||||
ListSessions {
|
||||
#[serde(default)]
|
||||
sort_by: Option<SessionSortField>,
|
||||
#[serde(default)]
|
||||
sort_order: Option<SortOrder>,
|
||||
},
|
||||
CreateSession {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
created_by_name: Option<String>,
|
||||
},
|
||||
UpdateSession {
|
||||
session_id: String,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
},
|
||||
GetTree {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
@@ -62,9 +111,268 @@ pub enum AgentRequest {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
|
||||
// ---- Drawing mutations ----
|
||||
CreateDrawingElement {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
shape_type: String,
|
||||
#[serde(default)]
|
||||
x: Option<f32>,
|
||||
#[serde(default)]
|
||||
y: Option<f32>,
|
||||
#[serde(default)]
|
||||
width: Option<f32>,
|
||||
#[serde(default)]
|
||||
height: Option<f32>,
|
||||
#[serde(default)]
|
||||
center_x: Option<f32>,
|
||||
#[serde(default)]
|
||||
center_y: Option<f32>,
|
||||
#[serde(default)]
|
||||
radius_x: Option<f32>,
|
||||
#[serde(default)]
|
||||
radius_y: Option<f32>,
|
||||
#[serde(default)]
|
||||
x1: Option<f32>,
|
||||
#[serde(default)]
|
||||
y1: Option<f32>,
|
||||
#[serde(default)]
|
||||
x2: Option<f32>,
|
||||
#[serde(default)]
|
||||
y2: Option<f32>,
|
||||
#[serde(default)]
|
||||
control_offset_x: Option<f32>,
|
||||
#[serde(default)]
|
||||
control_offset_y: Option<f32>,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
#[serde(default)]
|
||||
font_size: Option<f32>,
|
||||
#[serde(default)]
|
||||
fill: Option<String>,
|
||||
#[serde(default)]
|
||||
stroke_color: Option<String>,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
shape_type: Option<String>,
|
||||
#[serde(default)]
|
||||
x: Option<f32>,
|
||||
#[serde(default)]
|
||||
y: Option<f32>,
|
||||
#[serde(default)]
|
||||
width: Option<f32>,
|
||||
#[serde(default)]
|
||||
height: Option<f32>,
|
||||
#[serde(default)]
|
||||
center_x: Option<f32>,
|
||||
#[serde(default)]
|
||||
center_y: Option<f32>,
|
||||
#[serde(default)]
|
||||
radius_x: Option<f32>,
|
||||
#[serde(default)]
|
||||
radius_y: Option<f32>,
|
||||
#[serde(default)]
|
||||
x1: Option<f32>,
|
||||
#[serde(default)]
|
||||
y1: Option<f32>,
|
||||
#[serde(default)]
|
||||
x2: Option<f32>,
|
||||
#[serde(default)]
|
||||
y2: Option<f32>,
|
||||
#[serde(default)]
|
||||
control_offset_x: Option<f32>,
|
||||
#[serde(default)]
|
||||
control_offset_y: Option<f32>,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
#[serde(default)]
|
||||
font_size: Option<f32>,
|
||||
#[serde(default)]
|
||||
fill: Option<String>,
|
||||
#[serde(default)]
|
||||
stroke_color: Option<String>,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
id: String,
|
||||
},
|
||||
ClearDrawingElements {
|
||||
#[serde(default)]
|
||||
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,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server → Agent responses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AgentResponse {
|
||||
@@ -72,6 +380,12 @@ pub enum AgentResponse {
|
||||
sessions: Vec<SessionInfo>,
|
||||
active_session: Option<String>,
|
||||
},
|
||||
SessionCreated {
|
||||
session: SessionInfo,
|
||||
},
|
||||
SessionUpdated {
|
||||
session: SessionInfo,
|
||||
},
|
||||
Tree {
|
||||
session_id: String,
|
||||
tree: ElementTree,
|
||||
@@ -97,12 +411,157 @@ pub enum AgentResponse {
|
||||
session_id: String,
|
||||
elements: Vec<DrawingElement>,
|
||||
},
|
||||
DrawingElementCreated {
|
||||
session_id: String,
|
||||
element: DrawingElement,
|
||||
},
|
||||
DrawingElementUpdated {
|
||||
session_id: String,
|
||||
element: DrawingElement,
|
||||
},
|
||||
DrawingElementDeleted {
|
||||
session_id: String,
|
||||
id: String,
|
||||
},
|
||||
DrawingElementsCleared {
|
||||
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,
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent → GUI drawing commands (reverse sync channel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DrawingCommand {
|
||||
Create {
|
||||
session_id: String,
|
||||
element: DrawingElement,
|
||||
},
|
||||
Update {
|
||||
session_id: String,
|
||||
element: DrawingElement,
|
||||
},
|
||||
Delete {
|
||||
session_id: String,
|
||||
id: String,
|
||||
},
|
||||
Clear {
|
||||
session_id: String,
|
||||
},
|
||||
CaptureScreenshot {
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent → GUI session commands (reverse sync channel for session management)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SessionCommand {
|
||||
Create {
|
||||
id: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
created_by: SessionCreator,
|
||||
},
|
||||
Update {
|
||||
session_id: String,
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code generation targets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CodeGenTarget {
|
||||
@@ -124,3 +583,148 @@ impl std::fmt::Display for CodeGenTarget {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversion: flat agent-friendly params → internal drawing types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a hex color string like "#ff0000" or "#f00" into a Color32.
|
||||
pub fn parse_hex_color(hex: &str) -> Option<Color32> {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
match hex.len() {
|
||||
3 => {
|
||||
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
|
||||
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
|
||||
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
|
||||
Some(Color32::from_rgb(r, g, b))
|
||||
}
|
||||
6 => {
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||
Some(Color32::from_rgb(r, g, b))
|
||||
}
|
||||
8 => {
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||
let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
|
||||
Some(Color32::from_rgba_unmultiplied(r, g, b, a))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `Shape` from flat agent-friendly parameters.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_shape(
|
||||
shape_type: &str,
|
||||
x: Option<f32>,
|
||||
y: Option<f32>,
|
||||
width: Option<f32>,
|
||||
height: Option<f32>,
|
||||
center_x: Option<f32>,
|
||||
center_y: Option<f32>,
|
||||
radius_x: Option<f32>,
|
||||
radius_y: Option<f32>,
|
||||
x1: Option<f32>,
|
||||
y1: Option<f32>,
|
||||
x2: Option<f32>,
|
||||
y2: Option<f32>,
|
||||
control_offset_x: Option<f32>,
|
||||
control_offset_y: Option<f32>,
|
||||
text: Option<String>,
|
||||
font_size: Option<f32>,
|
||||
sides: Option<u32>,
|
||||
star_inner_ratio: Option<f32>,
|
||||
max_width: Option<f32>,
|
||||
) -> Result<Shape, String> {
|
||||
match shape_type {
|
||||
"Rectangle" | "rectangle" | "rect" | "Rect" => Ok(Shape::Rectangle {
|
||||
pos: Pos2::new(x.unwrap_or(0.0), y.unwrap_or(0.0)),
|
||||
size: Vec2::new(width.unwrap_or(100.0), height.unwrap_or(100.0)),
|
||||
}),
|
||||
"Ellipse" | "ellipse" => Ok(Shape::Ellipse {
|
||||
center: Pos2::new(center_x.or(x).unwrap_or(0.0), center_y.or(y).unwrap_or(0.0)),
|
||||
radii: Vec2::new(radius_x.unwrap_or(50.0), radius_y.unwrap_or(50.0)),
|
||||
}),
|
||||
"Line" | "line" => {
|
||||
let sx = x1.or(x).unwrap_or(0.0);
|
||||
let sy = y1.or(y).unwrap_or(0.0);
|
||||
Ok(Shape::Line {
|
||||
start: Pos2::new(sx, sy),
|
||||
end: Pos2::new(x2.unwrap_or(sx + 100.0), y2.unwrap_or(sy)),
|
||||
})
|
||||
}
|
||||
"Arrow" | "arrow" => {
|
||||
let sx = x1.or(x).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 {
|
||||
start: Pos2::new(sx, sy),
|
||||
end: Pos2::new(x2.unwrap_or(sx + 100.0), y2.unwrap_or(sy)),
|
||||
control_offset,
|
||||
})
|
||||
}
|
||||
"Text" | "text" => Ok(Shape::Text {
|
||||
pos: Pos2::new(x.unwrap_or(0.0), y.unwrap_or(0.0)),
|
||||
content: text.unwrap_or_else(|| "Text".to_string()),
|
||||
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!(
|
||||
"Unknown shape_type '{}'. Expected: Rectangle, Ellipse, Line, Arrow, Polygon, Text, or Path",
|
||||
other
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_style(
|
||||
fill: Option<String>,
|
||||
stroke_color: Option<String>,
|
||||
stroke_width: Option<f32>,
|
||||
opacity: Option<f32>,
|
||||
rotation: Option<f32>,
|
||||
corner_radius: Option<f32>,
|
||||
font_family: Option<String>,
|
||||
) -> ShapeStyle {
|
||||
ShapeStyle {
|
||||
fill: fill
|
||||
.as_deref()
|
||||
.and_then(parse_hex_color)
|
||||
.map(crate::drawing::Fill::solid),
|
||||
stroke_color: stroke_color
|
||||
.as_deref()
|
||||
.and_then(parse_hex_color)
|
||||
.unwrap_or(Color32::WHITE),
|
||||
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};
|
||||
|
||||
pub struct 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 {
|
||||
let available_size = ui.available_size();
|
||||
let (rect, response) = ui.allocate_exact_size(available_size, Sense::click_and_drag());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mod state;
|
||||
mod interaction;
|
||||
mod state;
|
||||
|
||||
pub use state::CanvasState;
|
||||
pub use interaction::CanvasInteraction;
|
||||
pub use state::CanvasState;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use egui::{Pos2, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CanvasState {
|
||||
pub offset: Vec2,
|
||||
pub zoom: f32,
|
||||
|
||||
@@ -20,6 +20,14 @@ impl ClipboardManager {
|
||||
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 {
|
||||
|
||||
466
crates/agcanvas/src/command_palette.rs
Normal file
466
crates/agcanvas/src/command_palette.rs
Normal file
@@ -0,0 +1,466 @@
|
||||
use egui::{Color32, Key};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CommandId {
|
||||
NewTab,
|
||||
CloseTab,
|
||||
Undo,
|
||||
Redo,
|
||||
Duplicate,
|
||||
ConvertToPath,
|
||||
Group,
|
||||
Ungroup,
|
||||
BringForward,
|
||||
SendBackward,
|
||||
BringToFront,
|
||||
SendToBack,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
AlignTop,
|
||||
AlignBottom,
|
||||
AlignCenterH,
|
||||
AlignCenterV,
|
||||
DistributeH,
|
||||
DistributeV,
|
||||
SaveWorkspace,
|
||||
ClearCanvas,
|
||||
PasteSvg,
|
||||
PasteMermaid,
|
||||
ExportPng,
|
||||
ExportSvg,
|
||||
ToolSelect,
|
||||
ToolDirectSelect,
|
||||
ToolPan,
|
||||
ToolRectangle,
|
||||
ToolEllipse,
|
||||
ToolLine,
|
||||
ToolArrow,
|
||||
ToolPolygon,
|
||||
ToolText,
|
||||
ResetZoom,
|
||||
FitToView,
|
||||
ToggleTreePanel,
|
||||
ToggleDescription,
|
||||
ToggleHistory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PaletteCommand {
|
||||
pub id: CommandId,
|
||||
pub label: String,
|
||||
pub shortcut: Option<String>,
|
||||
pub category: &'static str,
|
||||
}
|
||||
|
||||
impl PaletteCommand {
|
||||
pub fn new(id: CommandId, label: &str, shortcut: Option<&str>, category: &'static str) -> Self {
|
||||
Self {
|
||||
id,
|
||||
label: label.to_string(),
|
||||
shortcut: shortcut.map(|s| s.to_string()),
|
||||
category,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_commands() -> Vec<PaletteCommand> {
|
||||
vec![
|
||||
PaletteCommand::new(CommandId::NewTab, "New Tab", Some("Cmd+T"), "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(
|
||||
CommandId::SaveWorkspace,
|
||||
"Save Workspace",
|
||||
Some("Cmd+S"),
|
||||
"Session",
|
||||
),
|
||||
PaletteCommand::new(CommandId::ClearCanvas, "Clear Canvas", None, "Canvas"),
|
||||
PaletteCommand::new(CommandId::PasteSvg, "Paste", Some("Cmd+V"), "Canvas"),
|
||||
PaletteCommand::new(
|
||||
CommandId::PasteMermaid,
|
||||
"Paste Mermaid Diagram",
|
||||
None,
|
||||
"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::ToolDirectSelect,
|
||||
"Direct Select Tool",
|
||||
Some("D"),
|
||||
"Tool",
|
||||
),
|
||||
PaletteCommand::new(CommandId::ToolPan, "Pan Tool", Some("H"), "Tool"),
|
||||
PaletteCommand::new(
|
||||
CommandId::ToolRectangle,
|
||||
"Rectangle Tool",
|
||||
Some("R"),
|
||||
"Tool",
|
||||
),
|
||||
PaletteCommand::new(CommandId::ToolEllipse, "Ellipse Tool", Some("E"), "Tool"),
|
||||
PaletteCommand::new(CommandId::ToolLine, "Line Tool", Some("L"), "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::ResetZoom, "Reset Zoom", Some("Cmd+0"), "View"),
|
||||
PaletteCommand::new(CommandId::FitToView, "Fit to View", None, "View"),
|
||||
PaletteCommand::new(
|
||||
CommandId::ToggleTreePanel,
|
||||
"Toggle Element Tree Panel",
|
||||
None,
|
||||
"View",
|
||||
),
|
||||
PaletteCommand::new(
|
||||
CommandId::ToggleDescription,
|
||||
"Toggle Description Panel",
|
||||
None,
|
||||
"View",
|
||||
),
|
||||
PaletteCommand::new(
|
||||
CommandId::ToggleHistory,
|
||||
"Toggle History Panel",
|
||||
Some("Cmd+H"),
|
||||
"View",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub struct CommandPalette {
|
||||
pub visible: bool,
|
||||
pub query: String,
|
||||
pub selected_idx: usize,
|
||||
commands: Vec<PaletteCommand>,
|
||||
filtered: Vec<usize>,
|
||||
}
|
||||
|
||||
impl CommandPalette {
|
||||
pub fn new() -> Self {
|
||||
let commands = all_commands();
|
||||
let filtered: Vec<usize> = (0..commands.len()).collect();
|
||||
Self {
|
||||
visible: false,
|
||||
query: String::new(),
|
||||
selected_idx: 0,
|
||||
commands,
|
||||
filtered,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(&mut self) {
|
||||
self.visible = true;
|
||||
self.query.clear();
|
||||
self.selected_idx = 0;
|
||||
self.update_filter();
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
self.visible = false;
|
||||
self.query.clear();
|
||||
self.selected_idx = 0;
|
||||
}
|
||||
|
||||
pub fn toggle(&mut self) {
|
||||
if self.visible {
|
||||
self.close();
|
||||
} else {
|
||||
self.open();
|
||||
}
|
||||
}
|
||||
|
||||
fn update_filter(&mut self) {
|
||||
if self.query.is_empty() {
|
||||
self.filtered = (0..self.commands.len()).collect();
|
||||
} else {
|
||||
let query_lower = self.query.to_lowercase();
|
||||
self.filtered = self
|
||||
.commands
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, cmd)| fuzzy_match(&cmd.label, &query_lower))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
}
|
||||
if self.selected_idx >= self.filtered.len() {
|
||||
self.selected_idx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ctx: &egui::Context) -> Option<CommandId> {
|
||||
if !self.visible {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut executed: Option<CommandId> = None;
|
||||
let mut should_close = false;
|
||||
|
||||
let screen = ctx.screen_rect();
|
||||
let palette_width = (screen.width() * 0.5).clamp(300.0, 500.0);
|
||||
let palette_x = (screen.width() - palette_width) / 2.0;
|
||||
let palette_y = screen.height() * 0.15;
|
||||
|
||||
egui::Area::new(egui::Id::new("command_palette_backdrop"))
|
||||
.fixed_pos(screen.min)
|
||||
.order(egui::Order::Foreground)
|
||||
.show(ctx, |ui| {
|
||||
let response = ui.allocate_response(screen.size(), egui::Sense::click());
|
||||
if response.clicked() {
|
||||
should_close = true;
|
||||
}
|
||||
ui.painter().rect_filled(
|
||||
screen,
|
||||
0.0,
|
||||
Color32::from_rgba_unmultiplied(0, 0, 0, 120),
|
||||
);
|
||||
});
|
||||
|
||||
egui::Area::new(egui::Id::new("command_palette"))
|
||||
.fixed_pos(egui::pos2(palette_x, palette_y))
|
||||
.order(egui::Order::Foreground)
|
||||
.show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style())
|
||||
.fill(Color32::from_gray(30))
|
||||
.stroke(egui::Stroke::new(1.0, Color32::from_gray(60)))
|
||||
.rounding(8.0)
|
||||
.inner_margin(8.0)
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(palette_width);
|
||||
|
||||
let text_edit = egui::TextEdit::singleline(&mut self.query)
|
||||
.desired_width(palette_width - 16.0)
|
||||
.font(egui::TextStyle::Body)
|
||||
.hint_text("Type a command...");
|
||||
let response = ui.add(text_edit);
|
||||
response.request_focus();
|
||||
|
||||
if response.changed() {
|
||||
self.update_filter();
|
||||
}
|
||||
|
||||
ctx.input(|i| {
|
||||
if i.key_pressed(Key::Escape) {
|
||||
should_close = true;
|
||||
}
|
||||
if i.key_pressed(Key::ArrowDown) && !self.filtered.is_empty() {
|
||||
self.selected_idx = (self.selected_idx + 1) % self.filtered.len();
|
||||
}
|
||||
if i.key_pressed(Key::ArrowUp) && !self.filtered.is_empty() {
|
||||
self.selected_idx = if self.selected_idx == 0 {
|
||||
self.filtered.len() - 1
|
||||
} else {
|
||||
self.selected_idx - 1
|
||||
};
|
||||
}
|
||||
if i.key_pressed(Key::Enter) && !self.filtered.is_empty() {
|
||||
let cmd_idx = self.filtered[self.selected_idx];
|
||||
executed = Some(self.commands[cmd_idx].id);
|
||||
should_close = true;
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.separator();
|
||||
|
||||
let max_visible = 10;
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(max_visible as f32 * 28.0)
|
||||
.show(ui, |ui| {
|
||||
if self.filtered.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new("No matching commands")
|
||||
.color(Color32::from_gray(100)),
|
||||
);
|
||||
}
|
||||
|
||||
for (display_idx, &cmd_idx) in self.filtered.iter().enumerate() {
|
||||
let cmd = &self.commands[cmd_idx];
|
||||
let is_selected = display_idx == self.selected_idx;
|
||||
|
||||
let bg = if is_selected {
|
||||
Color32::from_gray(50)
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
|
||||
let frame = egui::Frame::none()
|
||||
.fill(bg)
|
||||
.rounding(4.0)
|
||||
.inner_margin(egui::Margin::symmetric(8.0, 4.0));
|
||||
|
||||
let resp = frame
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(palette_width - 32.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(&cmd.label)
|
||||
.color(Color32::WHITE),
|
||||
);
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(
|
||||
egui::Align::Center,
|
||||
),
|
||||
|ui| {
|
||||
if let Some(shortcut) = &cmd.shortcut {
|
||||
ui.label(
|
||||
egui::RichText::new(shortcut)
|
||||
.small()
|
||||
.color(Color32::from_gray(100)),
|
||||
);
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new(cmd.category)
|
||||
.small()
|
||||
.color(Color32::from_gray(80)),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
})
|
||||
.response;
|
||||
|
||||
if resp.clicked() {
|
||||
executed = Some(cmd.id);
|
||||
should_close = true;
|
||||
}
|
||||
if resp.hovered() {
|
||||
self.selected_idx = display_idx;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if should_close {
|
||||
self.close();
|
||||
}
|
||||
|
||||
executed
|
||||
}
|
||||
}
|
||||
|
||||
fn fuzzy_match(text: &str, query: &str) -> bool {
|
||||
let text_lower = text.to_lowercase();
|
||||
let mut text_chars = text_lower.chars();
|
||||
for qchar in query.chars() {
|
||||
loop {
|
||||
match text_chars.next() {
|
||||
Some(tc) if tc == qchar => break,
|
||||
Some(_) => continue,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fuzzy_match_exact() {
|
||||
assert!(fuzzy_match("New Tab", "new tab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_match_subsequence() {
|
||||
assert!(fuzzy_match("New Tab", "ntb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_match_no_match() {
|
||||
assert!(!fuzzy_match("New Tab", "xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_match_empty_query() {
|
||||
assert!(fuzzy_match("Anything", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_filters_commands() {
|
||||
let mut palette = CommandPalette::new();
|
||||
palette.open();
|
||||
palette.query = "rect".to_string();
|
||||
palette.update_filter();
|
||||
assert!(!palette.filtered.is_empty());
|
||||
let matched: Vec<_> = palette
|
||||
.filtered
|
||||
.iter()
|
||||
.map(|&i| palette.commands[i].id)
|
||||
.collect();
|
||||
assert!(matched.contains(&CommandId::ToolRectangle));
|
||||
}
|
||||
}
|
||||
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 std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
static DRAWING_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
pub const ARROW_CURVE_SEGMENTS: usize = 20;
|
||||
|
||||
pub fn generate_drawing_id() -> String {
|
||||
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)]
|
||||
pub struct DrawingElement {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub group_id: Option<String>,
|
||||
pub shape: Shape,
|
||||
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 {
|
||||
pub fn new(shape: Shape, style: ShapeStyle) -> Self {
|
||||
Self {
|
||||
id: generate_drawing_id(),
|
||||
group_id: None,
|
||||
shape,
|
||||
style,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,19 +44,62 @@ impl DrawingElement {
|
||||
Shape::Ellipse { center, radii } => {
|
||||
egui::Rect::from_center_size(*center, egui::vec2(radii.x * 2.0, radii.y * 2.0))
|
||||
}
|
||||
Shape::Line { start, end } | Shape::Arrow { start, end } => {
|
||||
egui::Rect::from_two_pos(*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)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
pos,
|
||||
content: _,
|
||||
content,
|
||||
font_size,
|
||||
max_width,
|
||||
} => {
|
||||
// Approximate: we'll refine during rendering when we know actual text size.
|
||||
let approx_width = 8.0 * font_size * 0.6;
|
||||
let approx_height = *font_size * 1.4;
|
||||
egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height))
|
||||
let (w, h) = estimate_text_bounds(content, *font_size, *max_width);
|
||||
egui::Rect::from_min_size(*pos, egui::vec2(w, h))
|
||||
}
|
||||
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;
|
||||
(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
|
||||
}
|
||||
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 {
|
||||
pos,
|
||||
content: _,
|
||||
content,
|
||||
font_size,
|
||||
max_width,
|
||||
} => {
|
||||
let approx_width = 8.0 * font_size * 0.6;
|
||||
let approx_height = *font_size * 1.4;
|
||||
let rect = egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height));
|
||||
let (w, h) = estimate_text_bounds(content, *font_size, *max_width);
|
||||
let rect = egui::Rect::from_min_size(*pos, egui::vec2(w, h));
|
||||
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 {
|
||||
Shape::Rectangle { pos, .. } => *pos += delta,
|
||||
Shape::Ellipse { center, .. } => *center += delta,
|
||||
Shape::Line { start, end } | Shape::Arrow { start, end } => {
|
||||
Shape::Line { start, end } => {
|
||||
*start += delta;
|
||||
*end += delta;
|
||||
}
|
||||
Shape::Arrow { start, end, .. } => {
|
||||
*start += delta;
|
||||
*end += delta;
|
||||
}
|
||||
Shape::Polygon { center, .. } => *center += 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.
|
||||
pub fn resize_to(&mut self, new_rect: egui::Rect) {
|
||||
let old_rect = self.bounding_rect();
|
||||
match &mut self.shape {
|
||||
Shape::Rectangle { pos, size } => {
|
||||
*pos = new_rect.min;
|
||||
@@ -103,15 +227,144 @@ impl DrawingElement {
|
||||
*center = new_rect.center();
|
||||
*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;
|
||||
*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, .. } => {
|
||||
*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],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -132,19 +385,200 @@ pub enum Shape {
|
||||
Arrow {
|
||||
start: Pos2,
|
||||
end: Pos2,
|
||||
#[serde(default)]
|
||||
control_offset: Option<egui::Vec2>,
|
||||
},
|
||||
Polygon {
|
||||
center: Pos2,
|
||||
radius: f32,
|
||||
sides: u32,
|
||||
star_inner_ratio: Option<f32>,
|
||||
},
|
||||
Text {
|
||||
pos: Pos2,
|
||||
content: String,
|
||||
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)]
|
||||
pub struct ShapeStyle {
|
||||
pub fill: Option<Color32>,
|
||||
#[serde(deserialize_with = "deserialize_fill_compat")]
|
||||
pub fill: Option<Fill>,
|
||||
pub stroke_color: Color32,
|
||||
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 {
|
||||
@@ -153,10 +587,125 @@ impl Default for ShapeStyle {
|
||||
fill: None,
|
||||
stroke_color: Color32::WHITE,
|
||||
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`.
|
||||
fn point_to_segment_distance(p: Pos2, a: Pos2, b: Pos2) -> f32 {
|
||||
let ab = b - a;
|
||||
@@ -173,6 +722,183 @@ fn point_to_segment_distance(p: Pos2, a: Pos2, b: Pos2) -> f32 {
|
||||
(p - closest).length()
|
||||
}
|
||||
|
||||
fn point_in_polygon(point: Pos2, ring: &[Pos2]) -> bool {
|
||||
if ring.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut winding_number = 0;
|
||||
|
||||
for (a, b) in ring
|
||||
.iter()
|
||||
.zip(ring.iter().cycle().skip(1))
|
||||
.take(ring.len())
|
||||
.map(|(a, b)| (*a, *b))
|
||||
{
|
||||
if point_to_segment_distance(point, a, b) <= 1e-3 {
|
||||
return true;
|
||||
}
|
||||
|
||||
if a.y <= point.y {
|
||||
if b.y > point.y && cross(a, b, point) > 0.0 {
|
||||
winding_number += 1;
|
||||
}
|
||||
} else if b.y <= point.y && cross(a, b, point) < 0.0 {
|
||||
winding_number -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
winding_number != 0
|
||||
}
|
||||
|
||||
fn cross(a: Pos2, b: Pos2, p: Pos2) -> f32 {
|
||||
(b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)
|
||||
}
|
||||
|
||||
fn 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -256,4 +982,26 @@ mod tests {
|
||||
);
|
||||
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 render;
|
||||
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::{
|
||||
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,
|
||||
screen_to_canvas,
|
||||
draw_arrow_control_handle, draw_creation_preview, draw_elements, draw_marquee, draw_selection,
|
||||
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};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,13 @@ use serde::{Deserialize, Serialize};
|
||||
pub enum Tool {
|
||||
#[default]
|
||||
Select,
|
||||
DirectSelect,
|
||||
Pan,
|
||||
Rectangle,
|
||||
Ellipse,
|
||||
Line,
|
||||
Arrow,
|
||||
Polygon,
|
||||
Text,
|
||||
}
|
||||
|
||||
@@ -16,10 +19,13 @@ impl Tool {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Tool::Select => "Select",
|
||||
Tool::DirectSelect => "Direct",
|
||||
Tool::Pan => "Pan",
|
||||
Tool::Rectangle => "Rect",
|
||||
Tool::Ellipse => "Ellipse",
|
||||
Tool::Line => "Line",
|
||||
Tool::Arrow => "Arrow",
|
||||
Tool::Polygon => "Polygon",
|
||||
Tool::Text => "Text",
|
||||
}
|
||||
}
|
||||
@@ -27,10 +33,13 @@ impl Tool {
|
||||
pub fn shortcut(&self) -> Option<char> {
|
||||
match self {
|
||||
Tool::Select => Some('V'),
|
||||
Tool::DirectSelect => Some('D'),
|
||||
Tool::Pan => Some('H'),
|
||||
Tool::Rectangle => Some('R'),
|
||||
Tool::Ellipse => Some('E'),
|
||||
Tool::Line => Some('L'),
|
||||
Tool::Arrow => Some('A'),
|
||||
Tool::Polygon => Some('P'),
|
||||
Tool::Text => Some('T'),
|
||||
}
|
||||
}
|
||||
@@ -45,13 +54,26 @@ pub enum DragState {
|
||||
current: Pos2,
|
||||
},
|
||||
Moving {
|
||||
element_id: String,
|
||||
element_ids: Vec<String>,
|
||||
},
|
||||
MarqueeSelecting {
|
||||
start: Pos2,
|
||||
current: Pos2,
|
||||
},
|
||||
Resizing {
|
||||
handle: ResizeHandle,
|
||||
element_id: String,
|
||||
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)]
|
||||
|
||||
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>"));
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,44 @@
|
||||
mod agent;
|
||||
mod app;
|
||||
mod canvas;
|
||||
mod clipboard;
|
||||
mod command_palette;
|
||||
mod drawing;
|
||||
mod element_tree;
|
||||
mod export;
|
||||
mod history;
|
||||
mod mermaid;
|
||||
mod svg;
|
||||
mod clipboard;
|
||||
mod agent;
|
||||
mod persistence;
|
||||
mod session;
|
||||
mod svg;
|
||||
mod theme;
|
||||
|
||||
pub use theme::CanvasTheme;
|
||||
|
||||
use anyhow::Result;
|
||||
use fs2::FileExt;
|
||||
use std::fs::{self, File};
|
||||
use std::path::PathBuf;
|
||||
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<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
@@ -19,16 +47,29 @@ fn main() -> Result<()> {
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.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 {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([1400.0, 900.0])
|
||||
.with_min_inner_size([800.0, 600.0])
|
||||
.with_title("agcanvas"),
|
||||
.with_title("Augmented Canvas"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"agcanvas",
|
||||
"Augmented Canvas",
|
||||
native_options,
|
||||
Box::new(|cc| Ok(Box::new(app::AgCanvasApp::new(cc)))),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,67 @@
|
||||
use std::panic;
|
||||
|
||||
use anyhow::Result;
|
||||
use mermaid_rs_renderer::RenderOptions;
|
||||
|
||||
pub fn render_mermaid_to_svg(mermaid_source: &str) -> Result<String> {
|
||||
let svg = mermaid_rs_renderer::render(mermaid_source)
|
||||
.map_err(|e| anyhow::anyhow!("Mermaid render failed: {}", e))?;
|
||||
Ok(svg)
|
||||
let source = mermaid_source.to_string();
|
||||
|
||||
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)]
|
||||
@@ -21,4 +79,23 @@ mod tests {
|
||||
let svg = render_mermaid_to_svg("sequenceDiagram\n Alice->>Bob: Hello").unwrap();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
144
crates/agcanvas/src/persistence.rs
Normal file
144
crates/agcanvas/src/persistence.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use crate::canvas::CanvasState;
|
||||
use crate::drawing::DrawingElement;
|
||||
use crate::session::SessionCreator;
|
||||
use crate::theme::CanvasTheme;
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavedSession {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub canvas_state: CanvasState,
|
||||
pub svg_source: Option<String>,
|
||||
pub drawing_elements: Vec<DrawingElement>,
|
||||
pub description: Option<String>,
|
||||
pub created_by: SessionCreator,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavedWorkspace {
|
||||
pub version: u32,
|
||||
pub active_session_idx: usize,
|
||||
pub session_counter: usize,
|
||||
#[serde(default)]
|
||||
pub group_counter: usize,
|
||||
#[serde(default)]
|
||||
pub theme: CanvasTheme,
|
||||
pub sessions: Vec<SavedSession>,
|
||||
}
|
||||
|
||||
impl SavedWorkspace {
|
||||
const CURRENT_VERSION: u32 = 1;
|
||||
|
||||
pub fn new(
|
||||
active_session_idx: usize,
|
||||
session_counter: usize,
|
||||
group_counter: usize,
|
||||
theme: CanvasTheme,
|
||||
sessions: Vec<SavedSession>,
|
||||
) -> Self {
|
||||
Self {
|
||||
version: Self::CURRENT_VERSION,
|
||||
active_session_idx,
|
||||
session_counter,
|
||||
group_counter,
|
||||
theme,
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn data_dir() -> Result<PathBuf> {
|
||||
let base = dirs::data_dir()
|
||||
.or_else(dirs::home_dir)
|
||||
.ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
|
||||
Ok(base.join("agcanvas"))
|
||||
}
|
||||
|
||||
fn workspace_path() -> Result<PathBuf> {
|
||||
Ok(data_dir()?.join("workspace.json"))
|
||||
}
|
||||
|
||||
pub fn save_workspace(workspace: &SavedWorkspace) -> Result<()> {
|
||||
let path = workspace_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
let json = serde_json::to_string_pretty(workspace)?;
|
||||
std::fs::write(&tmp_path, &json)?;
|
||||
std::fs::rename(&tmp_path, &path)?;
|
||||
|
||||
tracing::debug!("Saved workspace: {} sessions", workspace.sessions.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_workspace() -> Result<Option<SavedWorkspace>> {
|
||||
let path = workspace_path()?;
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let json = std::fs::read_to_string(&path)?;
|
||||
let workspace: SavedWorkspace = serde_json::from_str(&json)?;
|
||||
|
||||
if workspace.version > SavedWorkspace::CURRENT_VERSION {
|
||||
tracing::warn!(
|
||||
"Workspace file version {} is newer than supported version {}",
|
||||
workspace.version,
|
||||
SavedWorkspace::CURRENT_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!("Loaded workspace: {} sessions", workspace.sessions.len());
|
||||
Ok(Some(workspace))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_empty_workspace() {
|
||||
let workspace = SavedWorkspace::new(0, 1, 0, CanvasTheme::default(), Vec::new());
|
||||
let json = serde_json::to_string(&workspace).unwrap();
|
||||
let loaded: SavedWorkspace = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(loaded.version, 1);
|
||||
assert_eq!(loaded.sessions.len(), 0);
|
||||
assert_eq!(loaded.session_counter, 1);
|
||||
assert_eq!(loaded.group_counter, 0);
|
||||
assert_eq!(loaded.theme, CanvasTheme::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_workspace_with_session() {
|
||||
let session = SavedSession {
|
||||
id: "session-1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
canvas_state: CanvasState::default(),
|
||||
svg_source: Some("<svg></svg>".to_string()),
|
||||
drawing_elements: Vec::new(),
|
||||
description: Some("A test session".to_string()),
|
||||
created_by: SessionCreator::Human,
|
||||
created_at: 1234567890,
|
||||
};
|
||||
let workspace = SavedWorkspace::new(0, 1, 0, CanvasTheme::Light, vec![session]);
|
||||
let json = serde_json::to_string(&workspace).unwrap();
|
||||
let loaded: SavedWorkspace = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(loaded.sessions.len(), 1);
|
||||
assert_eq!(loaded.sessions[0].name, "Test");
|
||||
assert_eq!(loaded.theme, CanvasTheme::Light);
|
||||
assert_eq!(
|
||||
loaded.sessions[0].svg_source.as_deref(),
|
||||
Some("<svg></svg>")
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.sessions[0].description.as_deref(),
|
||||
Some("A test session")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,64 @@
|
||||
use crate::canvas::CanvasState;
|
||||
use crate::drawing::{DragState, DrawingElement, Tool};
|
||||
use crate::element_tree::ElementTree;
|
||||
use crate::svg::SvgRenderer;
|
||||
use egui::TextureHandle;
|
||||
use crate::history::{ChangeSource, DocumentSnapshot, HistoryTree, NodeId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session metadata types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum SessionCreator {
|
||||
#[default]
|
||||
Human,
|
||||
Agent {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SessionCreator {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SessionCreator::Human => write!(f, "Human"),
|
||||
SessionCreator::Agent { name: Some(n) } => write!(f, "Agent: {}", n),
|
||||
SessionCreator::Agent { name: None } => write!(f, "Agent"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionSortField {
|
||||
Name,
|
||||
#[default]
|
||||
CreatedAt,
|
||||
CreatedBy,
|
||||
ElementCount,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SortOrder {
|
||||
#[default]
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
fn unix_now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SessionInfo — lightweight summary sent over the wire
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionInfo {
|
||||
@@ -13,40 +67,65 @@ pub struct SessionInfo {
|
||||
pub has_svg: bool,
|
||||
pub element_count: Option<usize>,
|
||||
pub drawing_element_count: usize,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_by: SessionCreator,
|
||||
#[serde(default)]
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub canvas_state: CanvasState,
|
||||
pub svg_renderer: Option<SvgRenderer>,
|
||||
pub svg_texture: Option<TextureHandle>,
|
||||
pub element_tree: Option<ElementTree>,
|
||||
pub description_text: String,
|
||||
pub svg_source: Option<String>,
|
||||
|
||||
pub svg_textures: HashMap<String, egui::TextureHandle>,
|
||||
|
||||
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 drag_state: DragState,
|
||||
pub history: HistoryTree,
|
||||
pub polygon_sides: u32,
|
||||
pub polygon_star_ratio: Option<f32>,
|
||||
|
||||
pub description: Option<String>,
|
||||
pub created_by: SessionCreator,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(id: String, name: String) -> Self {
|
||||
pub fn new(id: String, name: String, created_by: SessionCreator) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
canvas_state: CanvasState::default(),
|
||||
svg_renderer: None,
|
||||
svg_texture: None,
|
||||
element_tree: None,
|
||||
description_text: String::new(),
|
||||
svg_source: None,
|
||||
svg_textures: HashMap::new(),
|
||||
drawing_elements: Vec::new(),
|
||||
selected_element_id: None,
|
||||
selected_element_ids: Vec::new(),
|
||||
selected_vertex: None,
|
||||
active_tool: Tool::default(),
|
||||
drag_state: DragState::default(),
|
||||
history: HistoryTree::new(DocumentSnapshot::new_empty()),
|
||||
polygon_sides: 6,
|
||||
polygon_star_ratio: None,
|
||||
description: None,
|
||||
created_by,
|
||||
created_at: unix_now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_description(mut self, description: Option<String>) -> Self {
|
||||
self.description = description;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn info(&self) -> SessionInfo {
|
||||
SessionInfo {
|
||||
id: self.id.clone(),
|
||||
@@ -54,34 +133,101 @@ impl Session {
|
||||
has_svg: self.element_tree.is_some(),
|
||||
element_count: self.element_tree.as_ref().map(|t| t.metadata.element_count),
|
||||
drawing_element_count: self.drawing_elements.len(),
|
||||
description: self.description.clone(),
|
||||
created_by: self.created_by.clone(),
|
||||
created_at: self.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.svg_renderer = None;
|
||||
self.svg_texture = None;
|
||||
self.element_tree = None;
|
||||
self.description_text.clear();
|
||||
self.svg_source = None;
|
||||
|
||||
self.svg_textures.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.canvas_state.reset();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn selected_element(&self) -> Option<&DrawingElement> {
|
||||
self.selected_element_id
|
||||
.as_ref()
|
||||
.and_then(|id| self.drawing_elements.iter().find(|e| e.id == *id))
|
||||
self.selected_element_ids
|
||||
.iter()
|
||||
.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)]
|
||||
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))
|
||||
}
|
||||
|
||||
pub fn delete_selected(&mut self) {
|
||||
if let Some(id) = self.selected_element_id.take() {
|
||||
self.drawing_elements.retain(|e| e.id != id);
|
||||
if !self.selected_element_ids.is_empty() {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,13 +236,36 @@ impl Session {
|
||||
pub struct SessionData {
|
||||
pub info: SessionInfo,
|
||||
pub tree: Option<ElementTree>,
|
||||
pub svg_source: Option<String>,
|
||||
pub drawing_elements: Vec<DrawingElement>,
|
||||
}
|
||||
|
||||
pub type ExportSessionData = (String, Option<String>, Vec<DrawingElement>);
|
||||
|
||||
/// 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)]
|
||||
pub struct SessionStore {
|
||||
sessions: HashMap<String, SessionData>,
|
||||
active_session_id: Option<String>,
|
||||
session_counter: usize,
|
||||
app_state: Option<AppStateSnapshot>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
@@ -104,13 +273,19 @@ impl SessionStore {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn add_session(&mut self, info: SessionInfo, tree: Option<ElementTree>) {
|
||||
pub fn add_session(
|
||||
&mut self,
|
||||
info: SessionInfo,
|
||||
tree: Option<ElementTree>,
|
||||
svg_source: Option<String>,
|
||||
) {
|
||||
let id = info.id.clone();
|
||||
self.sessions.insert(
|
||||
id.clone(),
|
||||
SessionData {
|
||||
info,
|
||||
tree,
|
||||
svg_source,
|
||||
drawing_elements: Vec::new(),
|
||||
},
|
||||
);
|
||||
@@ -132,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) {
|
||||
data.info.has_svg = tree.is_some();
|
||||
data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count);
|
||||
data.tree = tree;
|
||||
data.svg_source = svg_source;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,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(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
@@ -166,11 +353,147 @@ impl SessionStore {
|
||||
Some((id, &data.drawing_elements))
|
||||
}
|
||||
|
||||
pub fn get_export_data(&self, session_id: Option<&str>) -> Option<ExportSessionData> {
|
||||
let id = session_id
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| self.active_session_id.clone())?;
|
||||
let data = self.sessions.get(&id)?;
|
||||
Some((id, data.svg_source.clone(), data.drawing_elements.clone()))
|
||||
}
|
||||
|
||||
pub fn add_drawing_element(&mut self, session_id: &str, element: DrawingElement) -> bool {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
data.drawing_elements.push(element);
|
||||
data.info.drawing_element_count = data.drawing_elements.len();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_drawing_element(
|
||||
&mut self,
|
||||
session_id: &str,
|
||||
element_id: &str,
|
||||
new_shape: Option<crate::drawing::Shape>,
|
||||
new_style: Option<crate::drawing::ShapeStyle>,
|
||||
) -> Option<DrawingElement> {
|
||||
let data = self.sessions.get_mut(session_id)?;
|
||||
let el = data
|
||||
.drawing_elements
|
||||
.iter_mut()
|
||||
.find(|e| e.id == element_id)?;
|
||||
if let Some(shape) = new_shape {
|
||||
el.shape = shape;
|
||||
}
|
||||
if let Some(style) = new_style {
|
||||
el.style = style;
|
||||
}
|
||||
Some(el.clone())
|
||||
}
|
||||
|
||||
pub fn delete_drawing_element(&mut self, session_id: &str, element_id: &str) -> bool {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
let before = data.drawing_elements.len();
|
||||
data.drawing_elements.retain(|e| e.id != element_id);
|
||||
data.info.drawing_element_count = data.drawing_elements.len();
|
||||
data.drawing_elements.len() < before
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_drawing_elements(&mut self, session_id: &str) -> bool {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
data.drawing_elements.clear();
|
||||
data.info.drawing_element_count = 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_session_id(&self, session_id: Option<&str>) -> Option<String> {
|
||||
let id = session_id
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| self.active_session_id.clone())?;
|
||||
if self.sessions.contains_key(&id) {
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
||||
self.sessions.values().map(|d| d.info.clone()).collect()
|
||||
}
|
||||
|
||||
pub fn list_sessions_sorted(
|
||||
&self,
|
||||
sort_by: SessionSortField,
|
||||
order: SortOrder,
|
||||
) -> Vec<SessionInfo> {
|
||||
let mut sessions: Vec<SessionInfo> = self.list_sessions();
|
||||
sessions.sort_by(|a, b| {
|
||||
let cmp = match sort_by {
|
||||
SessionSortField::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
SessionSortField::CreatedAt => a.created_at.cmp(&b.created_at),
|
||||
SessionSortField::CreatedBy => {
|
||||
a.created_by.to_string().cmp(&b.created_by.to_string())
|
||||
}
|
||||
SessionSortField::ElementCount => {
|
||||
let ac = a.element_count.unwrap_or(0) + a.drawing_element_count;
|
||||
let bc = b.element_count.unwrap_or(0) + b.drawing_element_count;
|
||||
ac.cmp(&bc)
|
||||
}
|
||||
};
|
||||
match order {
|
||||
SortOrder::Asc => cmp,
|
||||
SortOrder::Desc => cmp.reverse(),
|
||||
}
|
||||
});
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn next_session_id(&mut self) -> String {
|
||||
self.session_counter += 1;
|
||||
format!("session-{}", self.session_counter)
|
||||
}
|
||||
|
||||
pub fn set_counter_minimum(&mut self, min: usize) {
|
||||
if min > self.session_counter {
|
||||
self.session_counter = min;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_session_meta(
|
||||
&mut self,
|
||||
session_id: &str,
|
||||
name: Option<String>,
|
||||
description: Option<Option<String>>,
|
||||
) -> bool {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
if let Some(n) = name {
|
||||
data.info.name = n;
|
||||
}
|
||||
if let Some(d) = description {
|
||||
data.info.description = d;
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_session_id(&self) -> Option<&str> {
|
||||
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 renderer;
|
||||
|
||||
pub use converter::svg_to_drawing_elements;
|
||||
pub use export_svg::export_drawing_elements_to_svg;
|
||||
pub use parser::parse_svg;
|
||||
pub use renderer::SvgRenderer;
|
||||
|
||||
@@ -11,7 +11,8 @@ fn generate_id() -> String {
|
||||
}
|
||||
|
||||
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 size = tree.size();
|
||||
|
||||
@@ -42,6 +42,7 @@ impl SvgRenderer {
|
||||
.ok_or_else(|| anyhow::anyhow!("Pixmap not available"))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn size(&self) -> (f32, f32) {
|
||||
let size = self.tree.size();
|
||||
(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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.schema.json",
|
||||
"mcpServers": {
|
||||
"agcanvas": {
|
||||
"command": "agcanvas-mcp",
|
||||
"args": ["--port", "9876"]
|
||||
}
|
||||
}
|
||||
}
|
||||
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