Compare commits

..

16 Commits

Author SHA1 Message Date
David Ibia
f5faf69cad feat: add singleton enforcement, extended MCP tools, and drawing improvements
Prevent multiple instances via fs2 file locking and improve agent server
port-in-use detection. Add polygon shapes, vertex editing, grouping,
alignment, z-ordering, duplication, SVG export, screenshot capture,
app state introspection, and extended shape properties (opacity, rotation,
corner radius, font family, max width) across both WebSocket and MCP APIs.
2026-02-15 14:46:47 +01:00
David Ibia
519ed74a3a docs: update README with export, mermaid conversion, batch commands, and new shortcuts 2026-02-10 17:07:46 +01:00
David Ibia
64b4f667fb feat: upgrade mermaid-rs-renderer to v0.2.0 with edge label support
Adds font-family sanitization to fix nested double quotes that break
usvg XML parsing. Edge labels (-->|Yes|) now render correctly as
interactive DrawingElements.
2026-02-10 17:05:55 +01:00
David Ibia
9e9d33eb84 feat: convert Mermaid diagrams to native DrawingElements instead of raster overlays
Mermaid SVG is now parsed into interactive shapes (rectangles, arrows,
text) that can be selected, moved, and resized. Removes the MermaidOverlay
system entirely in favor of first-class drawing elements.
2026-02-10 17:05:45 +01:00
David Ibia
519d1f2459 feat: add canvas export to PNG via File menu, Cmd+Shift+E, and MCP tool 2026-02-10 17:05:36 +01:00
David Ibia
8390d01f85 fix: load system fonts in SVG parser for proper text rendering 2026-02-10 17:05:29 +01:00
David Ibia
740fa2f5f9 feat: add Mermaid overlay support for agents to inject positioned diagrams
- Agents can send RenderMermaid with Mermaid source + canvas position
  to create SVG texture overlays that coexist with other elements
- MermaidOverlay struct holds source, rendered SVG, SvgRenderer, and
  lazy-loaded egui texture at a specific canvas position/size
- Server handles rendering via mermaid-rs, parses SVG for dimensions,
  sends overlay data through DrawingCommand channel to GUI thread
- Canvas renders overlays as positioned textures between base SVG and
  drawing elements, with proper pan/zoom transforms
- New MCP tool render_mermaid for agent access
- Overlays cleared on undo/redo/checkout to stay consistent with history
- 29 tests passing, clippy clean
2026-02-10 10:44:39 +01:00
David Ibia
5ca1e85209 feat: clickable zoom reset, Pan tool (H), and batch command support
- Clicking the zoom percentage in the menu bar resets zoom to 100%
- New Pan tool (H key) for explicit left-click-drag panning mode
- Batch command support: agents can send multiple operations in a
  single WebSocket message via {"type": "Batch", "requests": [...]}
  with sequential execution and collected results
- New MCP tool 'batch' accepts a JSON array of request objects
- Nested batches rejected with clear error message
- Updated AGENTS.md with .app rebuild requirement
2026-02-10 10:27:06 +01:00
David Ibia
9b8acd4002 feat: add marquee multi-select and always-on middle-button pan
- Drag on empty space in Select tool draws a marquee rectangle;
  elements intersecting it on release become selected
- Shift+click toggles elements in/out of the selection set
- Moving and deleting operate on all selected elements at once
- Middle mouse button drag now pans unconditionally in every tool mode
- Session selection state changed from Option<String> to Vec<String>
- New DragState::MarqueeSelecting variant with dashed rect rendering
- 29 tests passing, clippy clean
2026-02-10 01:08:26 +01:00
David Ibia
1929023409 feat: add undo/redo (Cmd+Z/Cmd+Shift+Z) and SVG file drag-and-drop
- Undo walks to parent node in history tree, redo follows
  last-active-child for correct branch tracking after forks
- HistoryTree tracks branch recency via last_active_child field,
  updated on push() and checkout() path transitions
- SVG files can be dragged from Finder onto the canvas window
- Edit menu with Undo/Redo items, command palette entries
- 3 new tests: undo, redo, redo-after-fork branch behavior
- 29 tests passing, clippy clean
2026-02-10 00:23:41 +01:00
David Ibia
ce2079ad95 docs: update README and AGENTS.md with boolean ops, undo tree, new architecture
- Document boolean shape operations feature and boolean_op MCP tool
- Document visual undo tree with Cmd+H shortcut
- Add BooleanOp to WebSocket protocol examples
- Update architecture tree with history.rs, boolean.rs modules
- Add i_overlay and earcutr to dependency table
- Update roadmap: mark boolean ops and undo tree as complete
2026-02-10 00:02:01 +01:00
David Ibia
9489c390fa feat: add boolean shape ops, visual undo tree, and Augmented Canvas branding
- Boolean operations (union, intersection, difference, xor) via i_overlay
  with Path shape rendering using earcutr triangulation
- Visual undo tree with branching history, checkout, and fork (Cmd+H)
  using Arc-based snapshots for structural sharing
- Consistent Augmented Canvas branding across app title, MCP server,
  CLI help text, and error messages
- macOS .app bundle script and Info.plist for Finder integration
- New MCP tool: boolean_op for agent-driven shape composition
- 26 tests passing (5 boolean, 6 history, 15 existing)
2026-02-10 00:01:45 +01:00
David Ibia
e8ec44d961 docs: add Augmented Canvas full name and macOS .app bundle instructions 2026-02-09 21:20:28 +01:00
David Ibia
233cb5798c Add session persistence and Cmd+K command palette
Workspace auto-saves every 30s and on exit, restores all tabs on
launch. Sessions persist drawing elements, SVG source, canvas state,
and metadata to ~/Library/Application Support/agcanvas/workspace.json.
Manual save via Cmd+S.

Command palette (Cmd+K) with fuzzy search over all commands: session
management, tool switching, view toggles, canvas operations. Arrow
keys to navigate, Enter to execute, Esc to dismiss.
2026-02-09 17:44:22 +01:00
David Ibia
43f1beea16 Add session metadata: creator tracking, descriptions, timestamps, and sorting
Sessions now track who created them (Human vs Agent with name),
optional descriptions, and creation timestamps. Agents can create
and update sessions via WebSocket and MCP. ListSessions supports
sorting by name, created_at, created_by, or element_count.

New MCP tools: create_session, update_session. Updated list_sessions
with sort_by/sort_order params. Tab bar shows robot icon for
agent-created sessions with hover tooltips.
2026-02-09 17:20:50 +01:00
David Ibia
b140d93163 Add agent drawing commands, session management, and MCP write tools
- Add CreateDrawingElement, UpdateDrawingElement, DeleteDrawingElement,
  ClearDrawingElements to WebSocket protocol and MCP bridge
- Add multi-session/tab support with SessionStore shared state
- Add bidirectional agent-GUI sync via broadcast + mpsc channels
- Update README with correct OpenCode MCP config format and new tools
- Fix dead code warning, clean up gitignore
2026-02-09 15:38:34 +01:00
37 changed files with 12070 additions and 476 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,8 @@
/target /target
crates/*/target
Cargo.lock Cargo.lock
*.swp *.swp
*.swo *.swo
.DS_Store .DS_Store
opencode.jsonc
.opencode

View File

@@ -11,6 +11,7 @@ Guidelines for AI agents working in this Rust codebase.
- WebSocket: tokio-tungstenite - WebSocket: tokio-tungstenite
- Serialization: serde/serde_json - Serialization: serde/serde_json
- Error handling: anyhow/thiserror - Error handling: anyhow/thiserror
- Boolean ops: i_overlay, triangulation: earcutr
- Logging: tracing - Logging: tracing
## Build/Lint/Test Commands ## Build/Lint/Test Commands
@@ -29,16 +30,31 @@ cargo test -- --nocapture # Show println! output
cargo check # Type check only (fast) cargo check # Type check only (fast)
cargo doc --open # Generate and open docs cargo doc --open # Generate and open docs
./scripts/bundle-macos.sh --install # Rebuild + install .app to /Applications
``` ```
**IMPORTANT:** After every release build or code change that requires testing the running app, you MUST run `./scripts/bundle-macos.sh --install` to update the macOS `.app` bundle in `/Applications`. The running `Augmented Canvas.app` uses a copied binary — a bare `cargo build --release` alone does NOT update it. Kill the running app first with `pkill -f "Augmented Canvas"`, then rebuild and relaunch with `open "/Applications/Augmented Canvas.app"`.
## Project Structure ## Project Structure
``` ```
src/ src/
├── main.rs # Entry point, window setup ├── main.rs # Entry point, window setup
├── app.rs # Main app state, UI (eframe::App impl) ├── app.rs # Main app state, UI (eframe::App impl)
├── session.rs # Session/tab state with history integration
├── history.rs # Undo tree: branching history, snapshots, checkout, fork
├── persistence.rs # Workspace save/load
├── command_palette.rs # Cmd+K fuzzy command palette
├── element_tree.rs # ElementTree, Element, ElementKind types ├── element_tree.rs # ElementTree, Element, ElementKind types
├── clipboard.rs # System clipboard integration ├── clipboard.rs # System clipboard integration
├── mermaid.rs # Mermaid -> SVG rendering
├── drawing/
│ ├── element.rs # DrawingElement, Shape (incl. Path), ShapeStyle, hit testing
│ ├── boolean.rs # Boolean shape ops (union, intersection, difference, xor)
│ ├── tool.rs # Tool enum, DragState, ResizeHandle
│ ├── render.rs # Shape rendering via egui Painter + triangulation
│ └── mod.rs # Re-exports
├── canvas/ ├── canvas/
│ ├── state.rs # Pan/zoom transformation state │ ├── state.rs # Pan/zoom transformation state
│ └── interaction.rs # Mouse/keyboard input handling │ └── interaction.rs # Mouse/keyboard input handling
@@ -46,7 +62,7 @@ src/
│ ├── parser.rs # SVG -> ElementTree conversion │ ├── parser.rs # SVG -> ElementTree conversion
│ └── renderer.rs # SVG -> pixels (resvg/tiny-skia) │ └── renderer.rs # SVG -> pixels (resvg/tiny-skia)
└── agent/ └── agent/
├── protocol.rs # JSON message types ├── protocol.rs # JSON message types (incl. BooleanOp)
└── server.rs # WebSocket server (ws://127.0.0.1:9876) └── server.rs # WebSocket server (ws://127.0.0.1:9876)
``` ```
@@ -163,6 +179,7 @@ WebSocket server on `ws://127.0.0.1:9876`:
{"type": "GetElementById", "id": "button-1"} {"type": "GetElementById", "id": "button-1"}
{"type": "Describe"} {"type": "Describe"}
{"type": "GenerateCode", "target": "react", "element_id": null} {"type": "GenerateCode", "target": "react", "element_id": null}
{"type": "BooleanOp", "operation": "union", "element_ids": ["id1", "id2"], "consume_sources": true}
// Response // Response
{"type": "Tree", "tree": {...}} {"type": "Tree", "tree": {...}}

191
README.md
View File

@@ -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. 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? ## 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 ### Canvas & Drawing
- **SVG Paste** — Copy frames from Figma, paste directly (Cmd+V) - **SVG Paste** — Copy frames from Figma, paste directly (Cmd+V)
- **Shape Drawing** — Rectangles, ellipses, lines, arrows, text directly on canvas - **Shape Drawing** — Rectangles, ellipses, lines, arrows, text directly on canvas
- **Boolean Shape Operations** — Union, intersection, difference, XOR on overlapping shapes via agent API
- **Selection & Editing** — Select, move, resize shapes with corner handles - **Selection & Editing** — Select, move, resize shapes with corner handles
- **Mermaid Diagrams** — Write Mermaid syntax, render as SVG on canvas - **Mermaid Diagrams** — Write Mermaid syntax, renders as interactive drawing elements (rectangles, arrows, text) that can be selected, moved, and resized. Supports edge labels (`-->|Yes|`)
- **Sessions/Tabs** — Multiple canvases in tabs, each with independent state - **Export to PNG** — Export canvas as high-DPI PNG via File menu (Cmd+Shift+E) or MCP tool
- **Pan/Zoom** — Smooth canvas navigation - **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 ### AI Agent Integration
- **MCP Server** — `agcanvas-mcp` bridge for Claude Code, OpenCode, and Codex - **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` — The desktop app
- `target/release/agcanvas-mcp` — The MCP server bridge - `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 ### Requirements
- Rust 1.70+ - Rust 1.70+
@@ -85,6 +148,7 @@ This builds two binaries:
| Action | Shortcut | | Action | Shortcut |
|--------|----------| |--------|----------|
| Select tool | V | | Select tool | V |
| Pan tool | H |
| Rectangle tool | R | | Rectangle tool | R |
| Ellipse tool | E | | Ellipse tool | E |
| Line tool | L | | Line tool | L |
@@ -93,8 +157,14 @@ This builds two binaries:
| Delete selected | Delete / Backspace | | Delete selected | Delete / Backspace |
| Cancel / back to Select | Escape | | Cancel / back to Select | Escape |
| Paste SVG | Cmd+V | | Paste SVG | Cmd+V |
| Export as PNG | Cmd+Shift+E |
| New Tab | Cmd+T | | New Tab | Cmd+T |
| Close Tab | Cmd+W | | Close Tab | Cmd+W |
| Save workspace | Cmd+S |
| Command palette | Cmd+K |
| Toggle history panel | Cmd+H |
| Undo | Cmd+Z |
| Redo | Cmd+Shift+Z |
| Reset zoom | Cmd+0 | | Reset zoom | Cmd+0 |
## MCP Server (AI Agent Integration) ## MCP Server (AI Agent Integration)
@@ -118,14 +188,15 @@ Add to your project's `.mcp.json` (or `~/.claude/mcp.json` for global):
### Setup for OpenCode ### Setup for OpenCode
Add to your `opencode.json`: Add to your `opencode.json` (project-level) or `~/.config/opencode/opencode.json` (global):
```json ```json
{ {
"mcpServers": { "mcp": {
"agcanvas": { "agcanvas": {
"command": "agcanvas-mcp", "type": "local",
"args": ["--port", "9876"] "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. 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. > **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.
See [`examples/mcp-configs/`](examples/mcp-configs/) for ready-to-copy configuration files.
### MCP Tools ### MCP Tools
| Tool | Description | | 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) | | `get_element_tree` | Get the full parsed SVG element tree (structured JSON) |
| `describe_canvas` | Get a human-readable description of the canvas | | `describe_canvas` | Get a human-readable description of the canvas |
| `get_element_by_id` | Look up a specific element by ID | | `get_element_by_id` | Look up a specific element by ID |
| `get_elements_at_point` | Find elements at an (x, y) coordinate | | `get_elements_at_point` | Find elements at an (x, y) coordinate |
| `get_drawing_elements` | Get all user-drawn shapes (rects, ellipses, lines, arrows, text) | | `get_drawing_elements` | Get all user-drawn shapes (rects, ellipses, lines, arrows, text) |
| `generate_code` | Generate code stubs (html, react, tailwind, svelte, vue) | | `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. 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 ```json
{"type": "ListSessions"} {"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: Response:
```json ```json
{ {
"type": "Sessions", "type": "Sessions",
"sessions": [ "sessions": [
{"id": "session-1", "name": "Tab 1", "has_svg": true, "element_count": 15}, {"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": "Tab 2", "has_svg": false, "element_count": null} {"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" "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 #### Get full element tree
```json ```json
@@ -228,6 +332,34 @@ Response:
Targets: `html`, `react`, `tailwind`, `svelte`, `vue` Targets: `html`, `react`, `tailwind`, `svelte`, `vue`
#### Boolean operation on shapes
```json
{"type": "BooleanOp", "operation": "union", "element_ids": ["elem-1", "elem-2"], "consume_sources": true}
```
Operations: `union`, `intersection`, `difference`, `xor`. Set `consume_sources` to `true` to delete the source shapes after the operation. Optional `style` object to override the result's appearance.
#### Render Mermaid diagram
```json
{"type": "RenderMermaid", "mermaid_source": "flowchart LR\n A-->|Yes| B[OK]", "x": 0, "y": 0}
```
Renders Mermaid syntax into interactive drawing elements (rectangles, arrows, text). Supports edge labels (`-->|Yes|`), decision diamonds, and all flowchart/sequence/class diagram types.
#### Export canvas as PNG
```json
{"type": "ExportCanvas", "path": "/tmp/canvas.png", "scale": 2.0, "background": "#1e1e1e"}
```
#### Batch operations
```json
{"type": "Batch", "requests": [{"type": "Ping"}, {"type": "GetTree"}]}
```
#### Ping #### Ping
```json ```json
@@ -270,19 +402,25 @@ crates/
│ └── src/ │ └── src/
│ ├── main.rs # Entry point, window setup │ ├── main.rs # Entry point, window setup
│ ├── app.rs # Main app state, UI, toolbar, drawing interaction │ ├── app.rs # Main app state, UI, toolbar, drawing interaction
│ ├── session.rs # Session/tab state management │ ├── session.rs # Session/tab state management with history integration
│ ├── history.rs # Undo tree: branching history with snapshots, checkout, fork
│ ├── persistence.rs # Workspace save/load (~/.agcanvas/)
│ ├── command_palette.rs # Cmd+K command palette with fuzzy search
│ ├── element_tree.rs # Structured element representation │ ├── element_tree.rs # Structured element representation
│ ├── clipboard.rs # System clipboard integration │ ├── clipboard.rs # System clipboard integration
│ ├── mermaid.rs # Mermaid → SVG rendering │ ├── mermaid.rs # Mermaid → SVG rendering (v0.2.0, edge labels)
│ ├── export.rs # Canvas → PNG export (composites all layers)
│ ├── drawing/ │ ├── drawing/
│ │ ├── element.rs # DrawingElement, Shape, ShapeStyle, hit testing │ │ ├── element.rs # DrawingElement, Shape, ShapeStyle, hit testing
│ │ ├── boolean.rs # Boolean shape operations (union, intersection, difference, xor)
│ │ ├── tool.rs # Tool enum, DragState, ResizeHandle │ │ ├── tool.rs # Tool enum, DragState, ResizeHandle
│ │ └── render.rs # Shape rendering via egui Painter │ │ └── render.rs # Shape rendering via egui Painter (incl. Path triangulation)
│ ├── canvas/ │ ├── canvas/
│ │ ├── state.rs # Pan/zoom transformation state │ │ ├── state.rs # Pan/zoom transformation state
│ │ └── interaction.rs # Mouse/keyboard input handling │ │ └── interaction.rs # Mouse/keyboard input handling
│ ├── svg/ │ ├── svg/
│ │ ├── parser.rs # SVG → ElementTree conversion │ │ ├── parser.rs # SVG → ElementTree conversion
│ │ ├── converter.rs # SVG → DrawingElements (Mermaid conversion)
│ │ └── renderer.rs # SVG → pixels (resvg/tiny-skia) │ │ └── renderer.rs # SVG → pixels (resvg/tiny-skia)
│ └── agent/ │ └── agent/
│ ├── protocol.rs # JSON message types │ ├── protocol.rs # JSON message types
@@ -305,6 +443,9 @@ crates/
| `arboard` | Clipboard access | | `arboard` | Clipboard access |
| `tokio-tungstenite` | WebSocket (both server and client) | | `tokio-tungstenite` | WebSocket (both server and client) |
| `rmcp` | MCP server SDK (Anthropic official) | | `rmcp` | MCP server SDK (Anthropic official) |
| `dirs` | Platform data directory paths |
| `i_overlay` | Boolean shape operations (union, intersection, difference, xor) |
| `earcutr` | Polygon triangulation for Path rendering |
| `serde`/`serde_json` | Serialization | | `serde`/`serde_json` | Serialization |
## Roadmap ## Roadmap
@@ -314,13 +455,19 @@ crates/
- [x] Selection, move, resize with handles - [x] Selection, move, resize with handles
- [x] Mermaid diagram rendering - [x] Mermaid diagram rendering
- [x] MCP server bridge for AI coding tools - [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) - [ ] Real code generation (not just stubs)
- [ ] Agent draw commands (modify canvas from agent)
- [ ] Export to file
- [ ] Diff view (before/after agent changes) - [ ] Diff view (before/after agent changes)
- [ ] Plugin system for code generators - [ ] Plugin system for code generators
- [ ] Undo/redo - [ ] App icon
- [ ] Multi-select and group operations
## License ## License

32
assets/Info.plist Normal file
View 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>

View File

@@ -3,7 +3,7 @@ name = "agcanvas-mcp"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
description = "MCP server bridge for agcanvas — exposes canvas tools to Claude Code, OpenCode, and Codex" description = "MCP server bridge for Augmented Canvas — exposes canvas tools to Claude Code, OpenCode, and Codex"
[[bin]] [[bin]]
name = "agcanvas-mcp" name = "agcanvas-mcp"

View File

@@ -7,7 +7,7 @@ pub async fn send_request(ws_url: &str, request_json: &str) -> Result<String> {
.await .await
.map_err(|e| { .map_err(|e| {
anyhow::anyhow!( anyhow::anyhow!(
"Cannot connect to agcanvas at {}. Is agcanvas running? Error: {}", "Cannot connect to Augmented Canvas at {}. Is Augmented Canvas running? Error: {}",
ws_url, ws_url,
e e
) )

View File

@@ -8,7 +8,10 @@ use tools::AgCanvasServer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "agcanvas-mcp", about = "MCP server bridge for agcanvas")] #[command(
name = "agcanvas-mcp",
about = "MCP server bridge for Augmented Canvas"
)]
struct Cli { struct Cli {
#[arg(long, default_value = "9876")] #[arg(long, default_value = "9876")]
port: u16, port: u16,
@@ -26,7 +29,10 @@ async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let ws_url = format!("ws://127.0.0.1:{}", cli.port); let ws_url = format!("ws://127.0.0.1:{}", cli.port);
tracing::info!("Starting agcanvas MCP server, connecting to {}", ws_url); tracing::info!(
"Starting Augmented Canvas MCP server, connecting to {}",
ws_url
);
let server = AgCanvasServer::new(ws_url); let server = AgCanvasServer::new(ws_url);
let service = server.serve(rmcp::transport::stdio()).await?; let service = server.serve(rmcp::transport::stdio()).await?;

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ name = "agcanvas"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
description = "Interactive canvas for agent-human collaboration with SVG support" description = "Augmented Canvas — interactive canvas for agent-human collaboration with SVG support"
[dependencies] [dependencies]
# GUI # GUI
@@ -28,20 +28,28 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
# Agent communication # Agent communication
tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "macros"] } tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "macros", "time"] }
tokio-tungstenite = "0.24" tokio-tungstenite = "0.24"
futures-util = "0.3" futures-util = "0.3"
# Mermaid diagram rendering # Mermaid diagram rendering
mermaid-rs-renderer = { version = "0.1.2", default-features = false } mermaid-rs-renderer = { git = "https://github.com/1jehuang/mermaid-rs-renderer", tag = "v0.2.0", default-features = false }
# Logging # Logging
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Filesystem paths
dirs = "5.0"
# Singleton lock
fs2 = "0.4"
# Error handling # Error handling
anyhow = "1.0" anyhow = "1.0"
thiserror = "1.0" thiserror = "1.0"
# Image handling # Image handling
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
i_overlay = "4.4.0"
earcutr = "0.5.0"

View File

@@ -1,5 +1,5 @@
mod protocol; mod protocol;
mod server; mod server;
pub use protocol::GuiEvent; pub use protocol::{DrawingCommand, GuiEvent, SessionCommand};
pub use server::AgentServer; pub use server::AgentServer;

View File

@@ -1,8 +1,13 @@
use crate::drawing::DrawingElement; use crate::drawing::{BooleanOpType, DrawingElement, Shape, ShapeStyle};
use crate::element_tree::{ElementTree, TreeMetadata}; 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}; use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// GUI → Agent events (broadcast to all connected agents)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum GuiEvent { pub enum GuiEvent {
@@ -27,12 +32,56 @@ pub enum GuiEvent {
SvgCleared { SvgCleared {
session_id: String, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum AgentRequest { 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 { GetTree {
#[serde(default)] #[serde(default)]
session_id: Option<String>, session_id: Option<String>,
@@ -62,9 +111,268 @@ pub enum AgentRequest {
#[serde(default)] #[serde(default)]
session_id: Option<String>, 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, Ping,
} }
// ---------------------------------------------------------------------------
// Server → Agent responses
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum AgentResponse { pub enum AgentResponse {
@@ -72,6 +380,12 @@ pub enum AgentResponse {
sessions: Vec<SessionInfo>, sessions: Vec<SessionInfo>,
active_session: Option<String>, active_session: Option<String>,
}, },
SessionCreated {
session: SessionInfo,
},
SessionUpdated {
session: SessionInfo,
},
Tree { Tree {
session_id: String, session_id: String,
tree: ElementTree, tree: ElementTree,
@@ -97,12 +411,157 @@ pub enum AgentResponse {
session_id: String, session_id: String,
elements: Vec<DrawingElement>, 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, Pong,
Error { Error {
message: String, 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)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum CodeGenTarget { 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

View File

@@ -1,34 +1,8 @@
use super::CanvasState;
use egui::{Response, Sense, Ui}; use egui::{Response, Sense, Ui};
pub struct CanvasInteraction; pub struct CanvasInteraction;
impl CanvasInteraction { impl CanvasInteraction {
pub fn handle(ui: &Ui, response: &Response, state: &mut CanvasState) {
if response.dragged_by(egui::PointerButton::Middle)
|| (response.dragged_by(egui::PointerButton::Primary)
&& ui.input(|i| i.modifiers.command))
{
state.pan(response.drag_delta());
}
if response.hovered() {
let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y);
if scroll_delta != 0.0 {
let zoom_factor = 1.0 + scroll_delta * 0.001;
if let Some(pointer_pos) = response.hover_pos() {
state.zoom_at(pointer_pos, response.rect.center(), zoom_factor);
}
}
ui.input(|i| {
if i.key_pressed(egui::Key::Num0) && i.modifiers.command {
state.reset();
}
});
}
}
pub fn allocate_canvas(ui: &mut Ui) -> Response { pub fn allocate_canvas(ui: &mut Ui) -> Response {
let available_size = ui.available_size(); let available_size = ui.available_size();
let (rect, response) = ui.allocate_exact_size(available_size, Sense::click_and_drag()); let (rect, response) = ui.allocate_exact_size(available_size, Sense::click_and_drag());

View File

@@ -1,5 +1,5 @@
mod state;
mod interaction; mod interaction;
mod state;
pub use state::CanvasState;
pub use interaction::CanvasInteraction; pub use interaction::CanvasInteraction;
pub use state::CanvasState;

View File

@@ -1,6 +1,7 @@
use egui::{Pos2, Vec2}; use egui::{Pos2, Vec2};
use serde::{Deserialize, Serialize};
#[derive(Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanvasState { pub struct CanvasState {
pub offset: Vec2, pub offset: Vec2,
pub zoom: f32, pub zoom: f32,

View File

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

View File

@@ -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));
}
}

View 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(&current_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());
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -5,10 +5,13 @@ use serde::{Deserialize, Serialize};
pub enum Tool { pub enum Tool {
#[default] #[default]
Select, Select,
DirectSelect,
Pan,
Rectangle, Rectangle,
Ellipse, Ellipse,
Line, Line,
Arrow, Arrow,
Polygon,
Text, Text,
} }
@@ -16,10 +19,13 @@ impl Tool {
pub fn label(&self) -> &'static str { pub fn label(&self) -> &'static str {
match self { match self {
Tool::Select => "Select", Tool::Select => "Select",
Tool::DirectSelect => "Direct",
Tool::Pan => "Pan",
Tool::Rectangle => "Rect", Tool::Rectangle => "Rect",
Tool::Ellipse => "Ellipse", Tool::Ellipse => "Ellipse",
Tool::Line => "Line", Tool::Line => "Line",
Tool::Arrow => "Arrow", Tool::Arrow => "Arrow",
Tool::Polygon => "Polygon",
Tool::Text => "Text", Tool::Text => "Text",
} }
} }
@@ -27,10 +33,13 @@ impl Tool {
pub fn shortcut(&self) -> Option<char> { pub fn shortcut(&self) -> Option<char> {
match self { match self {
Tool::Select => Some('V'), Tool::Select => Some('V'),
Tool::DirectSelect => Some('D'),
Tool::Pan => Some('H'),
Tool::Rectangle => Some('R'), Tool::Rectangle => Some('R'),
Tool::Ellipse => Some('E'), Tool::Ellipse => Some('E'),
Tool::Line => Some('L'), Tool::Line => Some('L'),
Tool::Arrow => Some('A'), Tool::Arrow => Some('A'),
Tool::Polygon => Some('P'),
Tool::Text => Some('T'), Tool::Text => Some('T'),
} }
} }
@@ -45,13 +54,26 @@ pub enum DragState {
current: Pos2, current: Pos2,
}, },
Moving { Moving {
element_id: String, element_ids: Vec<String>,
},
MarqueeSelecting {
start: Pos2,
current: Pos2,
}, },
Resizing { Resizing {
handle: ResizeHandle, handle: ResizeHandle,
element_id: String, element_id: String,
original_rect: egui::Rect, original_rect: egui::Rect,
}, },
VertexDrag {
element_id: String,
polygon_idx: usize,
vertex_idx: usize,
is_hole: bool,
},
ArrowControlDrag {
element_id: String,
},
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]

View 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("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
_ => 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();
}
}

View 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>"));
}
}

View File

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

View File

@@ -1,9 +1,67 @@
use std::panic;
use anyhow::Result; use anyhow::Result;
use mermaid_rs_renderer::RenderOptions;
pub fn render_mermaid_to_svg(mermaid_source: &str) -> Result<String> { pub fn render_mermaid_to_svg(mermaid_source: &str) -> Result<String> {
let svg = mermaid_rs_renderer::render(mermaid_source) let source = mermaid_source.to_string();
.map_err(|e| anyhow::anyhow!("Mermaid render failed: {}", e))?;
Ok(svg) let mut options = RenderOptions::modern();
options.theme.font_family =
"Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif".to_string();
let result = panic::catch_unwind(|| mermaid_rs_renderer::render_with_options(&source, options));
match result {
Ok(Ok(svg)) => Ok(sanitize_svg_font_families(&svg)),
Ok(Err(e)) => Err(anyhow::anyhow!("Mermaid render failed: {}", e)),
Err(_) => Err(anyhow::anyhow!(
"Mermaid renderer panicked (unsupported syntax)"
)),
}
}
/// Strips nested double quotes inside `font-family` XML attributes.
///
/// mermaid-rs-renderer v0.2.0 emits `font-family="..., "Segoe UI", ..."`
/// which is invalid XML — usvg rejects it. This rewrites those attributes
/// so the inner quotes are removed.
fn sanitize_svg_font_families(svg: &str) -> String {
let mut result = String::with_capacity(svg.len());
let mut chars = svg.chars().peekable();
while let Some(ch) = chars.next() {
result.push(ch);
if ch == 'f' && result.ends_with("font-family=\"") {
let mut value = String::new();
loop {
match chars.next() {
Some('"') => {
if let Some(&next) = chars.peek() {
if next == ' ' || next == '/' || next == '>' || next == '\n' {
result.push_str(&value);
result.push('"');
break;
} else {
continue;
}
} else {
result.push_str(&value);
result.push('"');
break;
}
}
Some(c) => value.push(c),
None => {
result.push_str(&value);
break;
}
}
}
}
}
result
} }
#[cfg(test)] #[cfg(test)]
@@ -21,4 +79,23 @@ mod tests {
let svg = render_mermaid_to_svg("sequenceDiagram\n Alice->>Bob: Hello").unwrap(); let svg = render_mermaid_to_svg("sequenceDiagram\n Alice->>Bob: Hello").unwrap();
assert!(svg.contains("<svg")); assert!(svg.contains("<svg"));
} }
#[test]
fn renders_edge_labels() {
let svg = render_mermaid_to_svg(
"flowchart LR\n A{Decision} -->|Yes| B[OK]\n A -->|No| C[Cancel]",
)
.unwrap();
assert!(svg.contains("<svg"));
assert!(svg.contains("Yes"));
assert!(svg.contains("No"));
}
#[test]
fn sanitized_svg_is_valid_xml() {
let svg = render_mermaid_to_svg("flowchart LR\n A-->B").unwrap();
let mut options = usvg::Options::default();
options.fontdb_mut().load_system_fonts();
usvg::Tree::from_str(&svg, &options).expect("sanitized SVG should parse with usvg");
}
} }

View 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")
);
}
}

View File

@@ -1,10 +1,64 @@
use crate::canvas::CanvasState; use crate::canvas::CanvasState;
use crate::drawing::{DragState, DrawingElement, Tool}; use crate::drawing::{DragState, DrawingElement, Tool};
use crate::element_tree::ElementTree; use crate::element_tree::ElementTree;
use crate::svg::SvgRenderer; use crate::history::{ChangeSource, DocumentSnapshot, HistoryTree, NodeId};
use egui::TextureHandle;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
// ---------------------------------------------------------------------------
// 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfo { pub struct SessionInfo {
@@ -13,40 +67,65 @@ pub struct SessionInfo {
pub has_svg: bool, pub has_svg: bool,
pub element_count: Option<usize>, pub element_count: Option<usize>,
pub drawing_element_count: 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 struct Session {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub canvas_state: CanvasState, pub canvas_state: CanvasState,
pub svg_renderer: Option<SvgRenderer>,
pub svg_texture: Option<TextureHandle>,
pub element_tree: Option<ElementTree>, pub element_tree: Option<ElementTree>,
pub description_text: String, pub svg_source: Option<String>,
pub svg_textures: HashMap<String, egui::TextureHandle>,
pub drawing_elements: Vec<DrawingElement>, pub drawing_elements: Vec<DrawingElement>,
pub selected_element_id: Option<String>, pub selected_element_ids: Vec<String>,
pub selected_vertex: Option<(String, usize, usize, bool)>,
pub active_tool: Tool, pub active_tool: Tool,
pub drag_state: DragState, pub drag_state: DragState,
pub history: HistoryTree,
pub polygon_sides: u32,
pub polygon_star_ratio: Option<f32>,
pub description: Option<String>,
pub created_by: SessionCreator,
pub created_at: i64,
} }
impl Session { impl Session {
pub fn new(id: String, name: String) -> Self { pub fn new(id: String, name: String, created_by: SessionCreator) -> Self {
Self { Self {
id, id,
name, name,
canvas_state: CanvasState::default(), canvas_state: CanvasState::default(),
svg_renderer: None,
svg_texture: None,
element_tree: None, element_tree: None,
description_text: String::new(), svg_source: None,
svg_textures: HashMap::new(),
drawing_elements: Vec::new(), drawing_elements: Vec::new(),
selected_element_id: None, selected_element_ids: Vec::new(),
selected_vertex: None,
active_tool: Tool::default(), active_tool: Tool::default(),
drag_state: DragState::default(), drag_state: DragState::default(),
history: HistoryTree::new(DocumentSnapshot::new_empty()),
polygon_sides: 6,
polygon_star_ratio: None,
description: None,
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 { pub fn info(&self) -> SessionInfo {
SessionInfo { SessionInfo {
id: self.id.clone(), id: self.id.clone(),
@@ -54,34 +133,101 @@ impl Session {
has_svg: self.element_tree.is_some(), has_svg: self.element_tree.is_some(),
element_count: self.element_tree.as_ref().map(|t| t.metadata.element_count), element_count: self.element_tree.as_ref().map(|t| t.metadata.element_count),
drawing_element_count: self.drawing_elements.len(), 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) { pub fn clear(&mut self) {
self.svg_renderer = None;
self.svg_texture = None;
self.element_tree = None; self.element_tree = None;
self.description_text.clear(); self.svg_source = None;
self.svg_textures.clear();
self.drawing_elements.clear(); self.drawing_elements.clear();
self.selected_element_id = None; self.selected_element_ids.clear();
self.selected_vertex = None;
self.drag_state = DragState::default(); self.drag_state = DragState::default();
self.canvas_state.reset(); self.canvas_state.reset();
} }
#[allow(dead_code)]
pub fn selected_element(&self) -> Option<&DrawingElement> { pub fn selected_element(&self) -> Option<&DrawingElement> {
self.selected_element_id self.selected_element_ids
.as_ref() .iter()
.and_then(|id| self.drawing_elements.iter().find(|e| e.id == *id)) .find_map(|id| self.drawing_elements.iter().find(|e| e.id == *id))
} }
pub fn selected_elements(&self) -> Vec<&DrawingElement> {
self.selected_element_ids
.iter()
.filter_map(|id| self.drawing_elements.iter().find(|e| e.id == *id))
.collect()
}
#[allow(dead_code)]
pub fn selected_element_mut(&mut self) -> Option<&mut DrawingElement> { pub fn selected_element_mut(&mut self) -> Option<&mut DrawingElement> {
let id = self.selected_element_id.clone(); let id = self.selected_element_ids.first().cloned();
id.and_then(move |id| self.drawing_elements.iter_mut().find(|e| e.id == id)) id.and_then(move |id| self.drawing_elements.iter_mut().find(|e| e.id == id))
} }
pub fn delete_selected(&mut self) { pub fn delete_selected(&mut self) {
if let Some(id) = self.selected_element_id.take() { if !self.selected_element_ids.is_empty() {
self.drawing_elements.retain(|e| e.id != id); let selected_ids = self.selected_element_ids.clone();
for id in &selected_ids {
self.svg_textures.remove(id);
}
self.drawing_elements
.retain(|e| !selected_ids.contains(&e.id));
self.selected_element_ids.clear();
self.selected_vertex = None;
}
}
pub fn record_edit(&mut self, label: &str, source: ChangeSource) {
let snapshot =
DocumentSnapshot::from_state(&self.drawing_elements, self.svg_source.as_deref());
self.history.push(label.to_string(), source, snapshot);
}
pub fn checkout_history(&mut self, node_id: NodeId) {
let snapshot = self.history.checkout(node_id).clone();
self.drawing_elements = (*snapshot.drawing_elements).clone();
self.svg_source = snapshot.svg_source.map(|s| s.to_string());
self.element_tree = None;
self.selected_element_ids.clear();
self.selected_vertex = None;
self.drag_state = DragState::default();
}
pub fn undo(&mut self) -> bool {
if let Some(snapshot) = self.history.undo().cloned() {
self.drawing_elements = (*snapshot.drawing_elements).clone();
self.svg_source = snapshot.svg_source.map(|s| s.to_string());
self.element_tree = None;
self.selected_element_ids.clear();
self.selected_vertex = None;
self.drag_state = DragState::default();
true
} else {
false
}
}
pub fn redo(&mut self) -> bool {
if let Some(snapshot) = self.history.redo().cloned() {
self.drawing_elements = (*snapshot.drawing_elements).clone();
self.svg_source = snapshot.svg_source.map(|s| s.to_string());
self.element_tree = None;
self.selected_element_ids.clear();
self.selected_vertex = None;
self.drag_state = DragState::default();
true
} else {
false
} }
} }
} }
@@ -90,13 +236,36 @@ impl Session {
pub struct SessionData { pub struct SessionData {
pub info: SessionInfo, pub info: SessionInfo,
pub tree: Option<ElementTree>, pub tree: Option<ElementTree>,
pub svg_source: Option<String>,
pub drawing_elements: Vec<DrawingElement>, pub drawing_elements: Vec<DrawingElement>,
} }
pub type ExportSessionData = (String, Option<String>, Vec<DrawingElement>);
/// Snapshot of app-level state for agent inspection.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AppStateSnapshot {
pub active_tool: String,
pub selected_element_ids: Vec<String>,
pub zoom: f32,
pub pan_offset_x: f32,
pub pan_offset_y: f32,
pub theme: String,
pub show_tree_panel: bool,
pub show_description_panel: bool,
pub show_history_panel: bool,
pub session_name: String,
pub element_count: usize,
pub canvas_width: f32,
pub canvas_height: f32,
}
#[derive(Default)] #[derive(Default)]
pub struct SessionStore { pub struct SessionStore {
sessions: HashMap<String, SessionData>, sessions: HashMap<String, SessionData>,
active_session_id: Option<String>, active_session_id: Option<String>,
session_counter: usize,
app_state: Option<AppStateSnapshot>,
} }
impl SessionStore { impl SessionStore {
@@ -104,13 +273,19 @@ impl SessionStore {
Self::default() Self::default()
} }
pub fn add_session(&mut self, info: SessionInfo, tree: Option<ElementTree>) { pub fn add_session(
&mut self,
info: SessionInfo,
tree: Option<ElementTree>,
svg_source: Option<String>,
) {
let id = info.id.clone(); let id = info.id.clone();
self.sessions.insert( self.sessions.insert(
id.clone(), id.clone(),
SessionData { SessionData {
info, info,
tree, tree,
svg_source,
drawing_elements: Vec::new(), drawing_elements: Vec::new(),
}, },
); );
@@ -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) { if let Some(data) = self.sessions.get_mut(session_id) {
data.info.has_svg = tree.is_some(); data.info.has_svg = tree.is_some();
data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count); data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count);
data.tree = tree; data.tree = tree;
data.svg_source = svg_source;
} }
} }
@@ -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( pub fn get_drawing_elements(
&self, &self,
session_id: Option<&str>, session_id: Option<&str>,
@@ -166,11 +353,147 @@ impl SessionStore {
Some((id, &data.drawing_elements)) Some((id, &data.drawing_elements))
} }
pub fn get_export_data(&self, session_id: Option<&str>) -> Option<ExportSessionData> {
let id = session_id
.map(|s| s.to_string())
.or_else(|| self.active_session_id.clone())?;
let data = self.sessions.get(&id)?;
Some((id, data.svg_source.clone(), data.drawing_elements.clone()))
}
pub fn add_drawing_element(&mut self, session_id: &str, element: DrawingElement) -> bool {
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> { pub fn list_sessions(&self) -> Vec<SessionInfo> {
self.sessions.values().map(|d| d.info.clone()).collect() 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> { pub fn active_session_id(&self) -> Option<&str> {
self.active_session_id.as_deref() self.active_session_id.as_deref()
} }
pub fn set_app_state(&mut self, state: AppStateSnapshot) {
self.app_state = Some(state);
}
pub fn get_app_state(&self) -> Option<&AppStateSnapshot> {
self.app_state.as_ref()
}
} }

View File

@@ -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);
}
}
}

View File

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

View File

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

View File

@@ -11,7 +11,8 @@ fn generate_id() -> String {
} }
pub fn parse_svg(svg_data: &str) -> Result<(ElementTree, Tree)> { pub fn parse_svg(svg_data: &str) -> Result<(ElementTree, Tree)> {
let options = usvg::Options::default(); let mut options = usvg::Options::default();
options.fontdb_mut().load_system_fonts();
let tree = Tree::from_str(svg_data, &options)?; let tree = Tree::from_str(svg_data, &options)?;
let size = tree.size(); let size = tree.size();

View File

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

View File

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

View File

@@ -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
View 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."