359 lines
12 KiB
Markdown
359 lines
12 KiB
Markdown
|
|
# SVG Shape File Loading + SVG Editor
|
|||
|
|
|
|||
|
|
## Goal
|
|||
|
|
- SVG şekilleri diskten `.svg` dosyaları olarak yüklenip kullanılabilsin
|
|||
|
|
- Kullanıcı kendi SVG'lerini shapes klasörüne kopyalayıp toolbox'ta görebilsin
|
|||
|
|
- Built-in şekiller (`svg_templates.rs`) diskte yoksa yedek olarak kullanılsın, varsa diskteki dosyalar öncelikli
|
|||
|
|
- SVG şekilleri buton ikonları olarak da kullanılsın (dinamik yükleme)
|
|||
|
|
- SVG Editor (node tabanlı path düzenleyici) planı
|
|||
|
|
|
|||
|
|
## Design Decisions
|
|||
|
|
|
|||
|
|
| Decision | Choice |
|
|||
|
|
|----------|--------|
|
|||
|
|
| Shapes directory | App data dizini (`~/.local/share/hcie/shapes/`) — ilk çalıştırmada built-in SVGs buraya kopyalanır |
|
|||
|
|
| Custom shape tool | `Tool::CustomShape(u32)` — `u32` index, `Copy` korunur. Serialization ile uyumlu |
|
|||
|
|
| Toolbox UX | Mevcut slot sistemine ek olarak dinamik "Custom" slot. Tıklayınca popup'ta diskteki tüm SVG'ler listelenir |
|
|||
|
|
| Icon rendering | `usvg` parse + `resvg` render → küçük RGBA pixmap → `egui::ColorImage` / `iced::image::Handle` |
|
|||
|
|
| SVG Editor scope (V1) | Node editor: path noktalarını sürükle, yeni nokta ekle/sil, canlı preview, SVG'yi kaydet |
|
|||
|
|
|
|||
|
|
## Affected Crates
|
|||
|
|
|
|||
|
|
| # | Crate | Files |
|
|||
|
|
|---|-------|-------|
|
|||
|
|
| 1 | `hcie-protocol` (locked) | `src/tools.rs` — `Tool::CustomShape(u32)` variant |
|
|||
|
|
| 2 | `hcie-vector` (locked) | `src/svg_templates.rs` — `all_templates()` fn |
|
|||
|
|
| 3 | `hcie-engine-api` (locked) | `src/shape_catalog.rs` NEW, `src/lib.rs` — init + re-export |
|
|||
|
|
| 4 | `hcie-gui-egui` (open) | `tools/toolbox.rs`, `canvas/mod.rs`, `app/mod.rs` |
|
|||
|
|
| 5 | `hcie-iced-gui` (open) | `sidebar/mod.rs`, `app.rs`, `canvas/mod.rs` |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 1 — SVG File Loading
|
|||
|
|
|
|||
|
|
### Step 1 — `hcie-protocol/src/tools.rs` — Add `Tool::CustomShape(u32)`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
|||
|
|
pub enum Tool {
|
|||
|
|
#[default]
|
|||
|
|
Pen,
|
|||
|
|
// ... existing variants ...
|
|||
|
|
CustomShape(u32),
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Update:
|
|||
|
|
- `label()` — CustomShape(index) => `"Custom Shape"`
|
|||
|
|
- `icon()` — CustomShape(index) => `"○"` (placeholder, real icon loaded dynamically)
|
|||
|
|
- `is_shape_tool()` — return true
|
|||
|
|
- `allowed_layer_type()` — Vector
|
|||
|
|
- `is_vector()` — return true
|
|||
|
|
- `has_separator_before()` — return true (after existing shape tools)
|
|||
|
|
|
|||
|
|
Do NOT add to `ALL` const — custom shapes are dynamic.
|
|||
|
|
|
|||
|
|
### Step 2 — `hcie-vector/src/svg_templates.rs` — Add `all_templates()`
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub struct TemplateEntry {
|
|||
|
|
pub kind: &'static str,
|
|||
|
|
pub svg: &'static str,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn all_templates() -> Vec<TemplateEntry> {
|
|||
|
|
vec![
|
|||
|
|
TemplateEntry { kind: "arrow", svg: &arrow_svg(false) },
|
|||
|
|
TemplateEntry { kind: "star", svg: &star_svg(5, 0.5) },
|
|||
|
|
// ... all 11 kinds ...
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Each `svg` is the actual SVG string from the respective template function. The `kind` matches the filename stem that will be written to disk.
|
|||
|
|
|
|||
|
|
### Step 3 — `hcie-vector/src/svg_render.rs` — Fix viewBox scaling
|
|||
|
|
|
|||
|
|
Current code assumes `viewBox="0 0 1 1"`. Custom SVGs have arbitrary viewBox values. Fix `svg_to_points` to read the actual viewBox from `usvg::Tree::size()`:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub fn svg_to_points(
|
|||
|
|
svg: &str,
|
|||
|
|
x1: f32,
|
|||
|
|
y1: f32,
|
|||
|
|
x2: f32,
|
|||
|
|
y2: f32,
|
|||
|
|
) -> Result<Vec<Vec<(f32, f32)>>, String> {
|
|||
|
|
// ... parse SVG ...
|
|||
|
|
let tree_size = tree.size(); // (width, height) from viewBox
|
|||
|
|
let svg_w = tree_size.width().max(1.0);
|
|||
|
|
let svg_h = tree_size.height().max(1.0);
|
|||
|
|
let target_w = (x2 - x1).abs().max(1.0);
|
|||
|
|
let target_h = (y2 - y1).abs().max(1.0);
|
|||
|
|
let ox = x1.min(x2);
|
|||
|
|
let oy = y1.min(y2);
|
|||
|
|
// Scale: px -> ox + (px / svg_w) * target_w
|
|||
|
|
collect_paths(tree.root(), &mut result, ox, oy, target_w, target_h, svg_w, svg_h);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
This makes any viewBox work — both our normalized `0 0 1 1` templates and user SVGs with arbitrary viewBox.
|
|||
|
|
|
|||
|
|
### Step 4 — `hcie-engine-api/src/shape_catalog.rs` (NEW)
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
pub struct ShapeCatalog {
|
|||
|
|
entries: Vec<ShapeEntry>,
|
|||
|
|
shapes_dir: PathBuf,
|
|||
|
|
builtin_kinds: HashMap<String, String>, // kind -> SVG string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub struct ShapeEntry {
|
|||
|
|
pub index: u32,
|
|||
|
|
pub kind: String, // filename stem (e.g. "star", "my_shape")
|
|||
|
|
pub label: String, // human-readable (filename stem, capitalized)
|
|||
|
|
pub svg: String, // full SVG content
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Methods:**
|
|||
|
|
|
|||
|
|
- `new(shapes_dir: PathBuf, builtins: HashMap<String, String>) -> Self`
|
|||
|
|
- Creates directory if not exists
|
|||
|
|
- Writes built-in SVGs to disk (one `.svg` file per template) — SKIP if file already exists
|
|||
|
|
- Scans `shapes_dir` for `*.svg` files
|
|||
|
|
- For each file: read content, create `ShapeEntry`
|
|||
|
|
- If a built-in file exists on disk AND in templates, disk version wins (user may have edited it)
|
|||
|
|
- If a file has the same name as a built-in and is MISSING from disk, fall back to builtin SVG string
|
|||
|
|
- Result: entries list = all found SVG files (disk) + builtins not on disk
|
|||
|
|
|
|||
|
|
- `refresh(&mut self)` — rescans directory
|
|||
|
|
- `len(&self) -> usize`
|
|||
|
|
- `get(&self, index: u32) -> Option<&ShapeEntry>`
|
|||
|
|
- `all(&self) -> &[ShapeEntry]`
|
|||
|
|
- `shapes_dir(&self) -> &Path`
|
|||
|
|
- `write_to_disk(&self, path: &Path, svg: &str) -> io::Result<()>` — for SVG Editor save
|
|||
|
|
|
|||
|
|
**Built-in SVGs written to disk:**
|
|||
|
|
|
|||
|
|
Files written on first run to `shapes/`:
|
|||
|
|
- `arrow.svg`, `star.svg`, `rhombus.svg`, `cylinder.svg`, `heart.svg`, `bubble.svg`, `gear.svg`, `cross.svg`, `crescent.svg`, `bolt.svg`, `arrow4.svg`
|
|||
|
|
|
|||
|
|
Each file contains the SVG string from the corresponding template function (with default parameters: star=5 points, arrow=non-thick).
|
|||
|
|
|
|||
|
|
### Step 5 — `hcie-engine-api/src/lib.rs` — Wire up ShapeCatalog
|
|||
|
|
|
|||
|
|
Add to `Engine` struct:
|
|||
|
|
```rust
|
|||
|
|
pub shape_catalog: ShapeCatalog,
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Init in `Engine::new_with_options`:
|
|||
|
|
```rust
|
|||
|
|
let shapes_dir = /* app data dir */;
|
|||
|
|
let builtins = hcie_vector::svg_templates::all_templates();
|
|||
|
|
self.shape_catalog = ShapeCatalog::new(shapes_dir, builtins);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Re-export:
|
|||
|
|
```rust
|
|||
|
|
pub use crate::shape_catalog::{ShapeCatalog, ShapeEntry};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Add helper:
|
|||
|
|
```rust
|
|||
|
|
impl Engine {
|
|||
|
|
pub fn refresh_shape_catalog(&mut self) {
|
|||
|
|
let builtins = hcie_vector::svg_templates::all_templates();
|
|||
|
|
self.shape_catalog = ShapeCatalog::new(
|
|||
|
|
self.shape_catalog.shapes_dir().to_path_buf(),
|
|||
|
|
builtins,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Step 6 — `hcie-egui-app/.../tools/toolbox.rs` — Custom shape slot
|
|||
|
|
|
|||
|
|
After the static TOOL_SLOTS rendering, add a dynamic section for custom shapes:
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
// After all static slots:
|
|||
|
|
let catalog = &app.documents[app.active_doc].doc.shape_catalog;
|
|||
|
|
if catalog.len() > 0 {
|
|||
|
|
// Render a slot header "Custom" or with an SVG icon
|
|||
|
|
// The current custom tool selection is Tool::CustomShape(active_idx)
|
|||
|
|
// Show a popup with all catalog entries, each with:
|
|||
|
|
// - SVG icon rendered to 24x24 image
|
|||
|
|
// - Label text
|
|||
|
|
|
|||
|
|
// When user clicks an entry, set active_tool = Tool::CustomShape(entry.index)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Icon rendering helper:
|
|||
|
|
```rust
|
|||
|
|
fn load_svg_icon(svg: &str, size: u32) -> Option<egui::ColorImage> {
|
|||
|
|
// 1. Parse SVG with usvg
|
|||
|
|
// 2. Render to tiny_skia Pixmap with resvg
|
|||
|
|
// 3. Convert to egui::ColorImage
|
|||
|
|
// 4. Cache result
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Cache the rendered icons so we don't re-render every frame.
|
|||
|
|
|
|||
|
|
### Step 7 — `hcie-egui-app/.../canvas/mod.rs` — CustomShape shape creation
|
|||
|
|
|
|||
|
|
In the shape creation match, add:
|
|||
|
|
```rust
|
|||
|
|
Tool::CustomShape(idx) => {
|
|||
|
|
let catalog = /* get shape catalog */;
|
|||
|
|
if let Some(entry) = catalog.get(*idx) {
|
|||
|
|
// Create shape from entry.svg content, NOT from template
|
|||
|
|
VectorShape::SvgShape {
|
|||
|
|
x1, y1, x2, y2,
|
|||
|
|
kind: entry.kind.clone(),
|
|||
|
|
svg: entry.svg.clone(),
|
|||
|
|
stroke: config.stroke_size,
|
|||
|
|
color: [0, 0, 0, 255],
|
|||
|
|
fill_color: [255, 255, 255, 255],
|
|||
|
|
fill: config.fill_shape,
|
|||
|
|
angle: 0.0,
|
|||
|
|
opacity: config.opacity,
|
|||
|
|
hardness: config.hardness,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Step 8 — `hcie-iced-app/.../sidebar/mod.rs` — Custom shape slot
|
|||
|
|
|
|||
|
|
Same pattern: render a custom shapes section after static slots. Each entry:
|
|||
|
|
- SVG icon rendered via `resvg` to `iced::widget::image::Handle`
|
|||
|
|
- Click to set `Tool::CustomShape(idx)`
|
|||
|
|
- Tool parameter panel updates when custom shape is selected
|
|||
|
|
|
|||
|
|
### Step 9 — `hcie-iced-app/.../app.rs` + `canvas/mod.rs`
|
|||
|
|
|
|||
|
|
Handle `Tool::CustomShape(idx)` in shape creation match (same as Step 7 pattern).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 2 — SVG Editor (Node-based)
|
|||
|
|
|
|||
|
|
### Step 10 — New crate: `hcie-svg-editor` or module in `hcie-engine-api`
|
|||
|
|
|
|||
|
|
**Location:** `hcie-engine-api/src/svg_editor.rs` (keeps it in the API crate, no new crate needed)
|
|||
|
|
|
|||
|
|
**Public API:**
|
|||
|
|
|
|||
|
|
```rust
|
|||
|
|
/// Represents a single node in an SVG path.
|
|||
|
|
#[derive(Debug, Clone)]
|
|||
|
|
pub struct SvgNode {
|
|||
|
|
pub x: f32,
|
|||
|
|
pub y: f32,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Parsed editable representation of an SVG shape.
|
|||
|
|
#[derive(Debug, Clone)]
|
|||
|
|
pub struct SvgEditable {
|
|||
|
|
pub view_box: (f32, f32, f32, f32), // min_x, min_y, width, height
|
|||
|
|
pub polygons: Vec<Vec<SvgNode>>, // one per subpath
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl SvgEditable {
|
|||
|
|
/// Parse an SVG string into editable nodes.
|
|||
|
|
pub fn from_svg(svg: &str) -> Result<Self, String>;
|
|||
|
|
|
|||
|
|
/// Reconstruct SVG string from edited nodes.
|
|||
|
|
pub fn to_svg(&self) -> String;
|
|||
|
|
|
|||
|
|
/// Add a node at the midpoint of edge `edge_idx` in polygon `poly_idx`.
|
|||
|
|
pub fn add_node(&mut self, poly_idx: usize, edge_idx: usize);
|
|||
|
|
|
|||
|
|
/// Remove a node (only if polygon would still have ≥3 nodes for closed paths).
|
|||
|
|
pub fn remove_node(&mut self, poly_idx: usize, node_idx: usize);
|
|||
|
|
|
|||
|
|
/// Move a node to new (x, y).
|
|||
|
|
pub fn move_node(&mut self, poly_idx: usize, node_idx: usize, x: f32, y: f32);
|
|||
|
|
|
|||
|
|
/// Normalize all coordinates to 0..1 relative to bounding box.
|
|||
|
|
pub fn normalize(&mut self);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Implementation details:**
|
|||
|
|
|
|||
|
|
`from_svg`:
|
|||
|
|
1. Parse SVG with `usvg::Tree::from_data`
|
|||
|
|
2. Extract viewBox from `tree.size()`
|
|||
|
|
3. For each `<path>`, extract segments via `path.data().segments()`
|
|||
|
|
4. Convert MoveTo/LineTo/Close segments to `SvgNode` list
|
|||
|
|
5. Reject quadratic/cubic beziers for V1 (or tessellate and mark as non-editable)
|
|||
|
|
|
|||
|
|
`to_svg`:
|
|||
|
|
1. Build `<svg>` with viewBox
|
|||
|
|
2. For each polygon, emit `<path d="M x,y L x,y ... Z" />`
|
|||
|
|
|
|||
|
|
### Step 11 — `hcie-egui-app/.../panels/svg_editor.rs` (NEW)
|
|||
|
|
|
|||
|
|
egui panel that opens when editing an `SvgShape`:
|
|||
|
|
- Button in the shape's property panel: "Open SVG Editor"
|
|||
|
|
- Modal panel or dock widget
|
|||
|
|
- Shows:
|
|||
|
|
- Preview canvas (renders the SVG at current edit state)
|
|||
|
|
- Node list with draggable handles on the preview
|
|||
|
|
- Add/Delete node buttons
|
|||
|
|
- On save: updates the shape's `svg` field
|
|||
|
|
- If shape came from a disk file: "Save to disk" button that writes to `shapes_dir`
|
|||
|
|
|
|||
|
|
### Step 12 — `hcie-iced-app/.../panels/svg_editor.rs` (NEW)
|
|||
|
|
|
|||
|
|
Same SVG editor UI using iced widgets.
|
|||
|
|
|
|||
|
|
### Step 13 — `hcie-egui-app/.../app/mod.rs` — Wire SVG editor
|
|||
|
|
|
|||
|
|
Add SVG editor state to `HcieApp`:
|
|||
|
|
```rust
|
|||
|
|
pub svg_editor_state: Option<SvgEditorState>,
|
|||
|
|
|
|||
|
|
pub struct SvgEditorState {
|
|||
|
|
pub shape_layer_id: u64,
|
|||
|
|
pub shape_idx: usize,
|
|||
|
|
pub editable: SvgEditable,
|
|||
|
|
pub preview_pixels: egui::ColorImage,
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Property panel: when editing an `SvgShape`, add "Edit SVG" button that opens the editor.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Validation
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# Compile all
|
|||
|
|
cargo check -p hcie-protocol -p hcie-vector -p hcie-engine-api -p hcie-gui-egui -p hcie-iced-gui
|
|||
|
|
|
|||
|
|
# Test
|
|||
|
|
cargo test -p hcie-protocol
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Manual verification
|
|||
|
|
1. Run the app, verify shapes/ directory is created with SVG files
|
|||
|
|
2. Verify all built-in shapes still render correctly
|
|||
|
|
3. Add a new SVG file to shapes/, verify it appears in the toolbox
|
|||
|
|
4. Edit an SVG file in shapes/, verify the change appears in the tool
|
|||
|
|
5. Draw a custom SVG shape on canvas
|
|||
|
|
6. Open SVG Editor, move nodes, save, verify shape updates
|
|||
|
|
|
|||
|
|
## Edge Cases
|
|||
|
|
- shapes/ directory doesn't exist → create it, write built-ins
|
|||
|
|
- SVG file has invalid syntax → skip with warning, don't crash
|
|||
|
|
- User deletes all SVGs → fall back to built-in templates
|
|||
|
|
- 100+ custom SVGs → scrollable popup, lazy icon rendering
|
|||
|
|
- SVG has no viewBox → use `0 0 1 1` as default
|
|||
|
|
- SVG has curves (C, Q) → V1 treats them as non-editable, shows "Edit not supported" warning
|