feat(svg-editor): Implement SVG shape loading and editing functionality

- Add SVG shape loading from disk, allowing users to customize shapes by placing SVG files in the designated directory.
- Introduce a new `ShapeCatalog` to manage SVG shapes, prioritizing user-defined shapes over built-in templates.
- Implement an SVG editor with node-based editing capabilities, enabling users to manipulate SVG paths directly.
- Create a new `SvgEditable` structure to represent editable SVG shapes, supporting operations like adding, removing, and moving nodes.
- Enhance the GUI with an SVG editor panel for visual editing of SVG shapes, including controls for node manipulation and saving changes.
- Ensure compatibility with existing shape tools and maintain a seamless user experience across the application.
This commit is contained in:
2026-07-19 13:50:17 +03:00
parent 5632c916ee
commit 6e0d179be9
20 changed files with 1723 additions and 25 deletions
@@ -0,0 +1,358 @@
# 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
Generated
+36 -2
View File
@@ -1199,6 +1199,15 @@ dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
@@ -1206,7 +1215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users",
"redox_users 0.4.6",
"winapi",
]
@@ -1218,10 +1227,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"redox_users 0.4.6",
"windows-sys 0.48.0",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -2612,6 +2633,7 @@ name = "hcie-engine-api"
version = "0.1.0"
dependencies = [
"approx",
"dirs 6.0.0",
"hcie-blend",
"hcie-brush-engine",
"hcie-composite",
@@ -2639,6 +2661,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"usvg 0.43.0",
]
[[package]]
@@ -5389,6 +5412,17 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.18",
]
[[package]]
name = "regex"
version = "1.12.4"
+1
View File
@@ -73,6 +73,7 @@ anyhow = "1.0"
futures-util = "0.3"
arboard = "3.4"
env_logger = "0.11"
dirs = "6.0"
# Test frameworks
rstest = "0.23"
@@ -176,6 +176,9 @@ pub struct HcieApp {
/// Used to keep documents in the center area and prevent swapping with utility panels.
pub canonical_doc_node: Option<egui_dock::NodePath>,
/// SVG editor modal state. `Some` when the editor is open.
pub svg_editor_state: Option<panels::svg_editor_panel::SvgEditorState>,
pub paste_counter: u64,
/// Hash of last clipboard image to detect new clipboard content.
pub clipboard_hash: u64,
@@ -631,6 +634,7 @@ impl HcieApp {
app_icon: None,
pending_paste_data: None,
hide_panels: false,
svg_editor_state: None,
};
if std::env::var("FLOAT_TEST_PANELS").is_ok() {
app.panels_to_float.push(dock::HciePane::Brushes);
@@ -1178,6 +1182,41 @@ impl HcieApp {
}
ctx.request_repaint();
}
AppEvent::OpenSvgEditor {
layer_id,
shape_idx,
} => {
if let Some(doc) = self.documents.get(self.active_doc) {
if let Some(shapes) = doc.engine.active_vector_shapes() {
if let Some(shape) = shapes.get(shape_idx) {
if let hcie_engine_api::VectorShape::SvgShape { svg, .. } = shape {
match hcie_engine_api::SvgEditable::from_svg(svg) {
Ok(editable) => {
self.svg_editor_state = Some(
panels::svg_editor_panel::SvgEditorState {
layer_id,
shape_idx,
editable,
selected_poly: 0,
selected_node: None,
drag_node: None,
rendered_preview: None,
},
);
}
Err(e) => {
self.status_message = Some((
format!("SVG Editor: {}", e),
std::time::Instant::now()
+ std::time::Duration::from_secs(3),
));
}
}
}
}
}
}
}
AppEvent::ToolTextStart { x, y } => {
if self.state.text_edit.active {
let (layer_id, draft_text) = (
@@ -4812,6 +4851,11 @@ impl eframe::App for HcieApp {
}
self.ui_main_assembly(&ctx);
// SVG Editor modal (overlays the main UI)
if self.svg_editor_state.is_some() {
panels::svg_editor_panel::show_svg_editor(self, &ctx);
}
// Dev-only: handle screenshot events triggered by F12
if self.screenshot_requested {
let mut captured = None;
@@ -1,3 +1,4 @@
mod panels;
pub use panels::*;
pub mod color;
pub mod svg_editor_panel;
@@ -1733,6 +1733,23 @@ pub fn show_properties(
}
changed |= ui.add(PlainSlider::new("Hardness", &mut state.tool_configs.vector.hardness, 0.0..=1.0, state.settings.theme)).changed();
// Edit SVG button for CustomShape (visible in Properties panel without switching to VectorSelect)
if matches!(state.active_tool, Tool::CustomShape(_)) {
if let Some(shape_idx) = state.selected_vector_shape {
let layer_id = doc.engine.active_layer_id();
if let Some(shapes) = doc.engine.active_vector_shapes() {
if let Some(shape) = shapes.get(shape_idx) {
if matches!(shape, hcie_engine_api::VectorShape::SvgShape { .. }) {
ui.add_space(4.0);
if ui.button("Edit SVG").on_hover_text("Open SVG node editor").clicked() {
event_bus.push(crate::event_bus::AppEvent::OpenSvgEditor { layer_id, shape_idx });
}
}
}
}
}
}
}
Tool::Text => {
crate::app::tools::text_editor::draw_text_properties(doc, state, event_bus, ui);
@@ -1748,8 +1765,20 @@ pub fn show_properties(
let layer_id = doc.engine.active_layer_id();
ui.label(RichText::new(format!("Shape #{}", shape_idx + 1)).strong().color(colors.accent));
ui.add_space(4.0);
if let Some(shapes) = doc.engine.active_vector_shapes() {
if let Some(shape) = shapes.get(shape_idx) {
if let Some(shapes) = doc.engine.active_vector_shapes() {
if let Some(shape) = shapes.get(shape_idx) {
// ── Edit SVG button for SvgShape variants ──
if matches!(shape, hcie_engine_api::VectorShape::SvgShape { .. }) {
ui.horizontal(|ui| {
if ui.button("Edit SVG").on_hover_text("Open SVG node editor").clicked() {
event_bus.push(crate::event_bus::AppEvent::OpenSvgEditor {
layer_id,
shape_idx,
});
}
});
ui.add_space(4.0);
}
let stroke = shape.stroke();
let mut new_stroke = stroke;
changed |= ui.add(PlainSlider::new("Stroke", &mut new_stroke, 0.0..=100.0, state.settings.theme).suffix("px")).changed();
@@ -0,0 +1,434 @@
use crate::app::{HcieApp, ThemeColors};
use eframe::egui;
use egui::Vec2;
use hcie_engine_api::SvgEditable;
// ── SvgEditorState ────────────────────────────────────────────────────────────
/// Runtime state for the SVG editor panel.
pub struct SvgEditorState {
pub layer_id: u64,
pub shape_idx: usize,
pub editable: SvgEditable,
pub selected_poly: usize,
pub selected_node: Option<usize>,
pub drag_node: Option<(usize, usize)>, // (poly_idx, node_idx)
pub rendered_preview: Option<egui::TextureHandle>,
}
// ── Coordinate helpers ────────────────────────────────────────────────────────
/// Map an SVG viewBox coordinate to a pixel position within the canvas rect.
fn svg_to_screen(
sx: f32,
sy: f32,
vb: (f32, f32, f32, f32),
canvas_rect: egui::Rect,
) -> egui::Pos2 {
let (vbx, vby, vbw, vbh) = vb;
let margin = 16.0;
let draw_w = canvas_rect.width() - margin * 2.0;
let draw_h = canvas_rect.height() - margin * 2.0;
let scale = (draw_w / vbw).min(draw_h / vbh).max(1.0);
let ox = canvas_rect.left() + margin + (draw_w - vbw * scale) * 0.5;
let oy = canvas_rect.top() + margin + (draw_h - vbh * scale) * 0.5;
egui::pos2(ox + (sx - vbx) * scale, oy + (sy - vby) * scale)
}
/// Map a pixel position back to SVG viewBox coordinates.
fn screen_to_svg(
p: egui::Pos2,
vb: (f32, f32, f32, f32),
canvas_rect: egui::Rect,
) -> (f32, f32) {
let (vbx, vby, vbw, vbh) = vb;
let margin = 16.0;
let draw_w = canvas_rect.width() - margin * 2.0;
let draw_h = canvas_rect.height() - margin * 2.0;
let scale = (draw_w / vbw).min(draw_h / vbh).max(1.0);
let ox = canvas_rect.left() + margin + (draw_w - vbw * scale) * 0.5;
let oy = canvas_rect.top() + margin + (draw_h - vbh * scale) * 0.5;
((p.x - ox) / scale + vbx, (p.y - oy) / scale + vby)
}
// ── Render ────────────────────────────────────────────────────────────────────
pub fn show_svg_editor(app: &mut HcieApp, ctx: &egui::Context) {
let colors = ThemeColors::get(app.state.settings.theme);
// Extract state pointer — safe because we don't re-borrow app inside the window closure.
let state_ptr: *mut SvgEditorState = match &mut app.svg_editor_state {
Some(s) => s,
None => return,
};
let mut action: SvgEditorAction = SvgEditorAction::None;
let window = egui::Window::new("SVG Editor")
.id(egui::Id::new("svg_editor_window"))
.default_size(ctx.used_size() * 0.6)
.min_size([400.0, 300.0])
.collapsible(false)
.resizable(true)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]);
window.show(ctx, |ui| {
// SAFETY: We do not access `app` through any other path inside this closure.
let state = unsafe { &mut *state_ptr };
let available = ui.available_size();
let canvas_height = (available.y - 120.0).max(200.0);
let vb = state.editable.view_box;
// ── Canvas area ─────────────────────────────────────────────────
let (canvas_rect, canvas_response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), canvas_height),
egui::Sense::click_and_drag(),
);
let painter = ui.painter_at(canvas_rect);
// Draw background
painter.rect_filled(canvas_rect, 0.0, colors.bg_dark_depth);
// Draw filled polygon (preview)
draw_polygons(&painter, &state.editable, vb, canvas_rect, colors);
// Draw nodes
draw_nodes(&painter, &state.editable, vb, canvas_rect, colors, state);
// Draw edge midpoints
draw_midpoints(&painter, &state.editable, vb, canvas_rect, colors, state);
// ── Mouse handling ─────────────────────────────────────────────
if canvas_response.hovered() {
let pointer_pos = ui.input(|i| i.pointer.interact_pos());
if let Some(pos) = pointer_pos {
if canvas_response.clicked() {
// Try to select a node
let hit = hit_test_node(&state.editable, vb, canvas_rect, pos);
if let Some((pi, ni)) = hit {
state.selected_poly = pi;
state.selected_node = Some(ni);
} else {
// Try to add a node on midpoint click
let mid_hit = hit_test_midpoint(&state.editable, vb, canvas_rect, pos);
if let Some((pi, ei)) = mid_hit {
state.editable.add_node(pi, ei);
state.selected_poly = pi;
state.selected_node = Some((ei + 1) % state.editable.polygons[pi].len());
} else {
state.selected_node = None;
}
}
}
// Drag existing node
if canvas_response.dragged_by(egui::PointerButton::Primary) {
if let Some((pi, ni)) = state.drag_node {
let (sx, sy) = screen_to_svg(pos, vb, canvas_rect);
state.editable.move_node(pi, ni, sx, sy);
state.rendered_preview = None;
}
}
}
}
// Track drag start/stop
if canvas_response.drag_started_by(egui::PointerButton::Primary) {
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
let hit = hit_test_node(&state.editable, vb, canvas_rect, pos);
if let Some((pi, ni)) = hit {
state.selected_poly = pi;
state.selected_node = Some(ni);
state.drag_node = Some((pi, ni));
}
}
}
if state.drag_node.is_some() && !canvas_response.dragged_by(egui::PointerButton::Primary) {
// Drag ended this frame
state.drag_node = None;
}
ui.add_space(8.0);
// ── Polygon selector and controls ──────────────────────────────
ui.horizontal(|ui| {
ui.label("Polygon:");
let poly_count = state.editable.polygons.len();
let mut poly_sel = state.selected_poly;
egui::ComboBox::from_id_salt("svg_poly_sel")
.width(120.0)
.selected_text(format!("#{} ({} nodes)", poly_sel + 1, {
if poly_sel < state.editable.polygons.len() {
state.editable.polygons[poly_sel].len()
} else {
0
}
}))
.show_ui(ui, |ui| {
for i in 0..poly_count {
let label =
format!("#{} ({} nodes)", i + 1, state.editable.polygons[i].len());
ui.selectable_value(&mut poly_sel, i, label);
}
});
state.selected_poly = poly_sel.min(poly_count.max(1) - 1);
ui.separator();
if ui.button(" Add Node").on_hover_text("Add node after selected")
.clicked()
{
if let Some(ni) = state.selected_node {
state.editable.add_node(state.selected_poly, ni);
state.selected_node = Some(ni + 1);
state.rendered_preview = None;
}
}
if ui.button("✕ Remove").on_hover_text("Remove selected node")
.clicked()
{
if let Some(ni) = state.selected_node {
state.editable.remove_node(state.selected_poly, ni);
state.selected_node = None;
state.rendered_preview = None;
}
}
});
ui.add_space(8.0);
// ── Node list ──────────────────────────────────────────────────
if state.selected_poly < state.editable.polygons.len() {
let poly = &state.editable.polygons[state.selected_poly];
egui::ScrollArea::vertical()
.max_height(80.0)
.show(ui, |ui| {
for (i, node) in poly.iter().enumerate() {
let is_sel = state.selected_node == Some(i);
let label = format!("#{}: ({:.1}, {:.1})", i, node.x, node.y);
if ui.selectable_label(is_sel, label).clicked() {
state.selected_node = Some(i);
}
}
});
}
ui.add_space(8.0);
// ── Close / Save / Cancel ──────────────────────────────────────
ui.horizontal(|ui| {
if ui.button("Save").clicked() {
action = SvgEditorAction::Save;
}
if ui.button("Cancel").clicked() {
action = SvgEditorAction::Close;
}
});
});
match action {
SvgEditorAction::Save => {
let action_layer_id;
let action_shape_idx;
let new_svg;
{
let state = unsafe { &mut *state_ptr };
new_svg = state.editable.to_svg();
action_layer_id = state.layer_id;
action_shape_idx = state.shape_idx;
}
if let Some(doc) = app.documents.get_mut(app.active_doc) {
doc.engine
.set_vector_shape_svg(action_layer_id, action_shape_idx, &new_svg);
app.event_bus.push(crate::event_bus::AppEvent::RenderRequested);
}
app.svg_editor_state = None;
}
SvgEditorAction::Close => {
app.svg_editor_state = None;
}
SvgEditorAction::None => {}
}
}
#[derive(PartialEq)]
enum SvgEditorAction {
None,
Save,
Close,
}
// ── Drawing helpers ───────────────────────────────────────────────────────────
fn draw_polygons(
painter: &egui::Painter,
editable: &SvgEditable,
vb: (f32, f32, f32, f32),
canvas_rect: egui::Rect,
colors: ThemeColors,
) {
let fill_color = egui::Color32::from_rgba_premultiplied(
colors.accent.r(),
colors.accent.g(),
colors.accent.b(),
60,
);
let stroke = egui::Stroke::new(1.5, colors.text_primary);
for poly in &editable.polygons {
if poly.len() < 2 {
continue;
}
let points: Vec<egui::Pos2> = poly
.iter()
.map(|n| svg_to_screen(n.x, n.y, vb, canvas_rect))
.collect();
// Fill
if poly.len() >= 3 {
painter.add(egui::Shape::convex_polygon(
points.clone(),
fill_color,
stroke,
));
} else {
// Line
painter.add(egui::Shape::line_segment(
[points[0], points[1]],
stroke,
));
}
// Outline edges
for i in 0..poly.len() {
let a = points[i];
let b = points[(i + 1) % poly.len()];
painter.add(egui::Shape::line_segment([a, b], stroke));
}
}
}
fn draw_nodes(
painter: &egui::Painter,
editable: &SvgEditable,
vb: (f32, f32, f32, f32),
canvas_rect: egui::Rect,
colors: ThemeColors,
state: &SvgEditorState,
) {
let node_radius = 5.0;
let fill = egui::Color32::WHITE;
let selected_fill = colors.accent;
for (pi, poly) in editable.polygons.iter().enumerate() {
for (ni, node) in poly.iter().enumerate() {
let pos = svg_to_screen(node.x, node.y, vb, canvas_rect);
let is_sel = pi == state.selected_poly && state.selected_node == Some(ni);
let color = if is_sel { selected_fill } else { fill };
painter.circle(pos, node_radius, color, egui::Stroke::new(1.5, colors.text_primary));
}
}
}
fn draw_midpoints(
painter: &egui::Painter,
editable: &SvgEditable,
vb: (f32, f32, f32, f32),
canvas_rect: egui::Rect,
colors: ThemeColors,
_state: &SvgEditorState,
) {
let _ = _state;
let mid_radius = 3.0;
let fill = egui::Color32::from_gray(180);
for (_pi, poly) in editable.polygons.iter().enumerate() {
if poly.len() < 2 {
continue;
}
for ei in 0..poly.len() {
let a = svg_to_screen(poly[ei].x, poly[ei].y, vb, canvas_rect);
let b = svg_to_screen(poly[(ei + 1) % poly.len()].x, poly[(ei + 1) % poly.len()].y, vb, canvas_rect);
let mid = egui::pos2((a.x + b.x) * 0.5, (a.y + b.y) * 0.5);
let is_hovered = {
let pointer_pos = painter.ctx().input(|i| i.pointer.interact_pos());
pointer_pos.map_or(false, |p| p.distance(mid) < 8.0)
};
let color = if is_hovered { colors.accent } else { fill };
painter.circle(mid, mid_radius, color, egui::Stroke::new(1.0, colors.text_secondary));
}
}
}
// ── Hit testing ───────────────────────────────────────────────────────────────
fn hit_test_node(
editable: &SvgEditable,
vb: (f32, f32, f32, f32),
canvas_rect: egui::Rect,
pos: egui::Pos2,
) -> Option<(usize, usize)> {
let threshold = 8.0;
let mut best: Option<(usize, usize, f32)> = None;
for (pi, poly) in editable.polygons.iter().enumerate() {
for (ni, node) in poly.iter().enumerate() {
let sp = svg_to_screen(node.x, node.y, vb, canvas_rect);
let dist = pos.distance(sp);
if dist < threshold {
let better = best.as_ref().map_or(true, |(_, _, d)| dist < *d);
if better {
best = Some((pi, ni, dist));
}
}
}
}
best.map(|(pi, ni, _)| (pi, ni))
}
fn hit_test_midpoint(
editable: &SvgEditable,
vb: (f32, f32, f32, f32),
canvas_rect: egui::Rect,
pos: egui::Pos2,
) -> Option<(usize, usize)> {
let threshold = 8.0;
let mut best: Option<(usize, usize, f32)> = None;
for (pi, poly) in editable.polygons.iter().enumerate() {
if poly.len() < 2 {
continue;
}
for ei in 0..poly.len() {
let a = svg_to_screen(poly[ei].x, poly[ei].y, vb, canvas_rect);
let b = svg_to_screen(
poly[(ei + 1) % poly.len()].x,
poly[(ei + 1) % poly.len()].y,
vb,
canvas_rect,
);
let mid = egui::pos2((a.x + b.x) * 0.5, (a.y + b.y) * 0.5);
let dist = pos.distance(mid);
if dist < threshold {
let better = best.as_ref().map_or(true, |(_, _, d)| dist < *d);
if better {
best = Some((pi, ei, dist));
}
}
}
}
best.map(|(pi, ei, _)| (pi, ei))
}
// ── Save ──────────────────────────────────────────────────────────────────────
fn save_svg(app: &mut HcieApp) {
let state = match &app.svg_editor_state {
Some(s) => s,
None => return,
};
let new_svg = state.editable.to_svg();
let layer_id = state.layer_id;
let shape_idx = state.shape_idx;
if let Some(doc) = app.documents.get_mut(app.active_doc) {
doc.engine
.set_vector_shape_svg(layer_id, shape_idx, &new_svg);
app.event_bus.push(crate::event_bus::AppEvent::RenderRequested);
}
}
@@ -272,6 +272,28 @@ pub fn show_geometry(
.size(11.0),
);
});
// ── Edit SVG button for SvgShape variants ────────────────────
{
let svg_shapes = doc.engine.active_vector_shapes();
if let Some(shapes) = svg_shapes {
if shape_idx < shapes.len() {
if matches!(&shapes[shape_idx], VectorShape::SvgShape { .. }) {
let layer_id = doc.engine.active_layer_id();
ui.horizontal(|ui| {
ui.add_space(8.0);
if ui.button("Edit SVG").on_hover_text("Open SVG node editor").clicked() {
event_bus.push(AppEvent::OpenSvgEditor {
layer_id,
shape_idx,
});
}
});
ui.add_space(2.0);
}
}
}
}
ui.add_space(4.0);
let shapes = match doc.engine.active_vector_shapes() {
@@ -58,6 +58,7 @@ fn tool_icon_source(tool: Tool) -> Option<egui::ImageSource<'static>> {
Tool::VectorCrescent => Some(icon!("vector_crescent.svg")),
Tool::VectorBolt => Some(icon!("vector_bolt.svg")),
Tool::VectorArrow4 => Some(icon!("vector_arrow4.svg")),
Tool::CustomShape(_) => None, // loaded dynamically via shape catalog
}
}
@@ -245,9 +246,12 @@ pub fn show_tool_panel_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
});
// Render active popup (if any) on top of everything
// (custom shape popup is rendered inside render_custom_shape_slot)
if let Some(slot_idx) = app.state.active_popup_slot {
if let Some(anchor) = app.state.popup_anchor_pos {
show_slot_popup(app, ui.ctx(), slot_idx, anchor);
if slot_idx < TOOL_SLOTS.len() {
if let Some(anchor) = app.state.popup_anchor_pos {
show_slot_popup(app, ui.ctx(), slot_idx, anchor);
}
}
}
}
@@ -449,6 +453,9 @@ fn render_tool_slots_single_column(app: &mut HcieApp, ui: &mut egui::Ui) {
let _ = resp.on_hover_text(last_used_tool.label());
}
// ── Custom shape slot ──
render_custom_shape_slot(app, ui);
}
fn render_tool_slots_two_column(app: &mut HcieApp, ui: &mut egui::Ui) {
@@ -498,6 +505,26 @@ fn render_tool_slots_two_column(app: &mut HcieApp, ui: &mut egui::Ui) {
}
ui.add_space(row_height);
}
// ── Custom shape slot (full width in two-column mode) ──
let engine = app.documents.get(app.active_doc).map(|d| &d.engine);
let catalog = match engine {
Some(e) => &e.shape_catalog,
None => return,
};
if !catalog.is_empty() {
let top = ui.min_rect().bottom();
// Use one column width for the slot
let x = ui.min_rect().left();
let slot_rect = egui::Rect::from_min_size(
egui::pos2(x, top),
egui::vec2(SLOT_W, TOOL_BTN_SIZE),
);
let _ = ui.allocate_ui_at_rect(slot_rect, |ui| {
ui.set_width(SLOT_W);
render_custom_shape_slot(app, ui);
});
}
}
fn handle_slot_response(
@@ -570,3 +597,205 @@ fn handle_slot_response(
let _ = resp.on_hover_text(last_used_tool.label());
}
/// Render a single slot for custom SVG shapes with a popup listing all entries.
fn render_custom_shape_slot(app: &mut HcieApp, ui: &mut egui::Ui) {
let engine = app
.documents
.get(app.active_doc)
.map(|d| &d.engine);
let catalog = match engine {
Some(e) => &e.shape_catalog,
None => return,
};
if catalog.is_empty() {
return;
}
let theme = app.state.settings.theme;
let colors = ThemeColors::get(theme);
// Determine the currently selected custom shape index
let current_idx = match app.state.active_tool {
Tool::CustomShape(i) => Some(i),
_ => None,
};
// Button label from the catalog
let _entry = current_idx.and_then(|i| catalog.get(i));
let _label = _entry.map(|e| e.label.as_str()).unwrap_or("Custom Shape");
let icon_str = _entry.map(|_| "").unwrap_or("");
let slot_idx = usize::MAX; // custom slot sentinel
let is_active = current_idx.is_some();
let has_submenu = catalog.len() > 1;
let icon = tool_icon_source(app.state.active_tool);
let fallback = if icon.is_none() {
Some(icon_str.to_string())
} else {
None
};
let layout_id = format!("toolbox.custom_shape_slot");
let resp = ui.add(
ToolSlotButton::new(icon, fallback, is_active, has_submenu, theme)
.layout_id(layout_id.clone()),
);
let resp = crate::app::shell::gui_layout::track(&layout_id, ui, &resp);
let resp_rect = resp.rect;
let resp_clicked = resp.clicked();
let resp_secondary = resp.secondary_clicked();
let pointer_down_on_this = resp.is_pointer_button_down_on();
if has_submenu {
let pressing_this = app
.state
.slot_press_start
.as_ref()
.map(|(idx, _)| *idx == slot_idx)
.unwrap_or(false);
if pointer_down_on_this && !pressing_this && app.state.active_popup_slot.is_none() {
app.state.slot_press_start = Some((slot_idx, Instant::now()));
}
if !pointer_down_on_this && pressing_this {
app.state.slot_press_start = None;
}
if let Some((idx, start)) = app.state.slot_press_start {
if idx == slot_idx
&& start.elapsed().as_millis() >= LONG_PRESS_MS
&& app.state.active_popup_slot.is_none()
{
let anchor = resp_rect.right_center();
app.state.active_popup_slot = Some(99); // custom slot popup marker
app.state.popup_anchor_pos = Some(anchor);
app.state.slot_press_start = None;
app.state.popup_opened_via_drag = true;
}
}
if resp_clicked && app.state.active_popup_slot.is_none() {
// Activate currently selected or first custom shape
let idx = current_idx.unwrap_or(0);
app.state.active_tool = Tool::CustomShape(idx);
app.event_bus.push(AppEvent::ToolChanged(app.state.active_tool));
}
if resp_secondary && app.state.active_popup_slot.is_none() {
let anchor = resp_rect.right_center();
app.state.active_popup_slot = Some(99);
app.state.popup_anchor_pos = Some(anchor);
app.state.popup_opened_via_drag = false;
}
} else {
if resp_clicked {
app.state.active_tool = Tool::CustomShape(0);
app.event_bus.push(AppEvent::ToolChanged(app.state.active_tool));
}
}
let _ = resp.on_hover_text(_label);
// ── Render custom shapes popup if active ──
if app.state.active_popup_slot == Some(99) {
if let Some(anchor) = app.state.popup_anchor_pos {
let popup_count = catalog.len() as f32;
let popup_h = (popup_count * POPUP_ROW_H + 8.0).min(400.0);
let screen = ui.ctx().content_rect();
let popup_pos = egui::pos2(
(anchor.x + 4.0)
.min(screen.max.x - POPUP_W - 4.0)
.max(screen.min.x + 4.0),
(anchor.y - popup_h / 2.0)
.min(screen.max.y - popup_h - 4.0)
.max(screen.min.y + 4.0),
);
let popup_id = egui::Id::new("custom_shape_popup");
let popup_response = egui::Area::new(popup_id)
.order(egui::Order::Foreground)
.fixed_pos(popup_pos)
.show(ui.ctx(), |ui| {
egui::Frame::NONE
.fill(colors.bg_elevated)
.stroke(egui::Stroke::new(1.0, colors.border_high))
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::same(4))
.show(ui, |ui| {
ui.set_min_width(POPUP_W);
egui::ScrollArea::vertical()
.max_height(popup_h)
.scroll_bar_visibility(
egui::scroll_area::ScrollBarVisibility::AlwaysHidden,
)
.show(ui, |ui| {
ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0);
let mut clicked_idx: Option<u32> = None;
for entry in catalog.all() {
let active = current_idx == Some(entry.index);
let r = ui.allocate_response(
egui::vec2(POPUP_W - 8.0, POPUP_ROW_H),
egui::Sense::click(),
);
let rect = r.rect;
if ui.is_rect_visible(rect) {
let fill = if active {
colors.accent.gamma_multiply(0.25)
} else if r.hovered() {
colors.bg_hover
} else {
egui::Color32::TRANSPARENT
};
if fill != egui::Color32::TRANSPARENT {
ui.painter().rect_filled(
rect,
egui::CornerRadius::same(4),
fill,
);
}
let tint = if active {
colors.accent
} else {
colors.text_primary
};
ui.painter().text(
egui::pos2(rect.left() + 6.0, rect.center().y),
egui::Align2::LEFT_CENTER,
&entry.label,
egui::FontId::proportional(12.0),
tint,
);
}
if r.clicked() {
clicked_idx = Some(entry.index);
}
}
if let Some(idx) = clicked_idx {
app.state.active_tool = Tool::CustomShape(idx);
app.event_bus.push(AppEvent::ToolChanged(
app.state.active_tool,
));
app.state.popup_close_request = true;
}
});
});
})
.response;
let popup_rect = popup_response.rect;
let pointer_pressed = ui.ctx().input(|i| i.pointer.any_pressed());
let pointer_in_popup = ui.ctx().input(|i| {
i.pointer
.hover_pos()
.map_or(false, |p| popup_rect.contains(p))
});
if pointer_pressed && !pointer_in_popup && !app.state.popup_close_request {
app.state.popup_close_request = true;
}
}
}
}
@@ -1301,8 +1301,14 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
x2 as f32,
y2 as f32,
&self.state,
&self.doc.engine.shape_catalog,
);
self.doc.engine.add_vector_shape(shape);
// Auto-select the newly created shape
if let Some(shapes) = self.doc.engine.active_vector_shapes() {
self.state.selected_vector_shape = Some(shapes.len() - 1);
self.state.vector_shapes_snapshot = Some(shapes.clone());
}
self.event_bus.push(AppEvent::RenderRequested);
self.event_bus.push(AppEvent::DrawingFinished);
}
@@ -2166,6 +2172,7 @@ fn create_shape_from_drag(
x2: f32,
y2: f32,
state: &ToolState,
catalog: &hcie_engine_api::ShapeCatalog,
) -> hcie_engine_api::VectorShape {
use hcie_engine_api::VectorShape;
let color = state.primary_color;
@@ -2334,6 +2341,26 @@ fn create_shape_from_drag(
_ => unreachable!(),
}
},
Tool::CustomShape(idx) => {
if let Some(entry) = catalog.get(idx) {
VectorShape::SvgShape {
x1, y1, x2, y2,
kind: entry.kind.clone(),
svg: entry.svg.clone(),
stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
}
} else {
// Fallback: empty SVG rectangle
VectorShape::SvgShape {
x1, y1, x2, y2,
kind: "custom".to_string(),
svg: r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><path d="M 0,0 L 1,0 L 1,1 L 0,1 Z"/></svg>"#.to_string(),
stroke, color, fill_color, fill,
angle: 0.0, opacity, hardness,
}
}
},
_ => VectorShape::Line {
x1,
y1,
@@ -67,6 +67,10 @@ pub enum AppEvent {
// Vector
VectorShapeDeleted(u64, usize),
OpenSvgEditor {
layer_id: u64,
shape_idx: usize,
},
// Clipboard
ClipboardCopy,
+2
View File
@@ -29,6 +29,8 @@ serde_json = "1.0"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "bmp", "gif", "tiff"] }
rayon = "1.10"
log = "0.4"
dirs = { workspace = true }
usvg = { workspace = true }
[lib]
crate-type = ["rlib", "staticlib"]
+56
View File
@@ -7,6 +7,8 @@
mod ai_templates;
pub mod dynamic_loader;
pub mod ffi;
pub mod shape_catalog;
pub mod svg_editor;
// Performance-critical engine paths isolated into dedicated modules.
mod layer_property_ops;
@@ -39,6 +41,8 @@ pub use hcie_protocol::{
FilterType, LayerData, LayerInfo, LayerStyle, LayerType, LineCap, ModulePanel, ToolConfigs,
VectorEditHandle, VectorShape, WidgetDescription, ZOOM_MAX, ZOOM_MIN,
};
pub use crate::shape_catalog::{ShapeCatalog, ShapeEntry};
pub use crate::svg_editor::{SvgEditable, SvgNode};
pub mod brush {
pub use hcie_brush_engine::{BrushStyle, BrushTip};
}
@@ -413,6 +417,8 @@ pub struct Engine {
/// Polled and committed on the UI thread via `commit_pending_history()`.
pub pending_history:
std::sync::Arc<std::sync::Mutex<Vec<crate::stroke_cache::PendingHistoryItem>>>,
/// Shape catalog for diskbased SVG shapes.
pub shape_catalog: crate::shape_catalog::ShapeCatalog,
}
/// Create a `VectorShape::SvgShape` from a shape kind name and parameter map.
@@ -448,6 +454,35 @@ pub fn create_vector_shape(
}
}
/// Create a `VectorShape::SvgShape` from an arbitrary SVG string.
///
/// Used for custom/usersupplied shapes loaded from disk.
/// The `kind` is the filename stem (e.g. `"my_shape"`).
pub fn create_vector_shape_from_svg(
kind: &str,
svg: &str,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
) -> VectorShape {
VectorShape::SvgShape {
x1,
y1,
x2,
y2,
kind: kind.to_string(),
svg: svg.to_string(),
stroke: 2.0,
color: [0, 0, 0, 255],
fill_color: [255, 255, 255, 255],
fill: true,
angle: 0.0,
opacity: 1.0,
hardness: 1.0,
}
}
impl Engine {
/// Create a new engine with a default transparent document.
///
@@ -518,6 +553,12 @@ impl Engine {
below_cache_dirty: true,
cached_selection_mask: None,
pending_history: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
shape_catalog: {
let base = dirs::data_local_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."));
let shapes_dir = base.join("hcie").join("shapes");
crate::shape_catalog::ShapeCatalog::new(shapes_dir)
},
}
}
@@ -1558,6 +1599,21 @@ impl Engine {
}
}
pub fn set_vector_shape_svg(&mut self, layer_id: u64, shape_idx: usize, new_svg: &str) {
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
if let VectorShape::SvgShape { ref mut svg, .. } = shape {
*svg = new_svg.to_string();
layer.dirty = true;
self.document.composite_dirty = true;
self.document.modified = true;
}
}
}
}
}
pub fn mark_composite_dirty(&mut self) {
self.document.composite_dirty = true;
}
+165
View File
@@ -0,0 +1,165 @@
use hcie_vector::svg_templates::all_templates;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
/// A single shape entry in the catalog — backed either by a file on disk or
/// by a built-in template string.
#[derive(Debug, Clone)]
pub struct ShapeEntry {
pub index: u32,
pub kind: String,
pub label: String,
pub svg: String,
}
/// Manages discovery and caching of SVG shapes from a userwritable directory.
///
/// **Purpose:** On init the catalog writes builtin template SVGs to disk (only
/// if they do not already exist), then scans `shapes_dir` for all `*.svg` files.
/// Any shape whose file is present on disk takes priority over the builtin
/// template — users can edit the SVG file from the GUI.
///
/// **Workflow:**
/// 1. Create `shapes_dir` if it does not exist.
/// 2. Write each builtin SVG to `shapes_dir/{kind}.svg` — skip if file exists.
/// 3. Scan `shapes_dir` for `*.svg` files.
/// 4. For each file, read content and create a `ShapeEntry`.
/// 5. Builtins that are *not* on disk are appended using their template SVG.
/// (Normally step2 writes them, so this is a safety fallback.)
pub struct ShapeCatalog {
entries: Vec<ShapeEntry>,
shapes_dir: PathBuf,
}
impl ShapeCatalog {
/// Create a new catalog at `shapes_dir` using `builtins` as the default set.
///
/// - `shapes_dir`: Directory that holds (or will hold) the SVG files.
/// - `builtins`: Builtin templates from `hcie_vector::svg_templates::all_templates()`.
pub fn new(shapes_dir: PathBuf) -> Self {
let mut catalog = Self {
entries: Vec::new(),
shapes_dir,
};
catalog.refresh();
catalog
}
/// Rescan the shapes directory and rebuild the entry list.
pub fn refresh(&mut self) {
// Ensure directory exists
let _ = fs::create_dir_all(&self.shapes_dir);
// Write builtin templates to disk if they are missing.
// This lets users edit the default shapes in any SVG editor.
let builtins = all_templates();
for tmpl in &builtins {
let path = self.shapes_dir.join(format!("{}.svg", tmpl.kind));
if !path.exists() {
let _ = fs::write(&path, &tmpl.svg);
}
}
// Gather files: disk first (priority), then builtin templates as fallback.
let mut entries: Vec<ShapeEntry> = Vec::new();
let mut seen_kinds: HashMap<String, bool> = HashMap::new();
// 1) Scan disk for *.svg files.
if let Ok(dir) = fs::read_dir(&self.shapes_dir) {
for entry in dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("svg") {
continue;
}
let kind = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_default();
if kind.is_empty() {
continue;
}
if let Ok(svg) = fs::read_to_string(&path) {
seen_kinds.insert(kind.clone(), true);
let label = kind
.replace('_', " ")
.split(' ')
.map(|w| {
let mut c = w.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().to_string() + c.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ");
entries.push(ShapeEntry {
index: 0, // will reassign below
kind,
label,
svg,
});
}
}
}
// 2) Append builtins that were NOT found on disk (safety fallback).
for tmpl in &builtins {
if !seen_kinds.contains_key(tmpl.kind) {
let label = tmpl
.kind
.replace('_', " ")
.split(' ')
.map(|w| {
let mut c = w.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().to_string() + c.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ");
entries.push(ShapeEntry {
index: 0,
kind: tmpl.kind.to_string(),
label,
svg: tmpl.svg.clone(),
});
}
}
// Assign sequential indices.
for (i, entry) in entries.iter_mut().enumerate() {
entry.index = i as u32;
}
self.entries = entries;
}
/// Number of shapes in the catalog.
pub fn len(&self) -> usize {
self.entries.len()
}
/// Whether the catalog is empty.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Look up a shape by its sequential index.
pub fn get(&self, index: u32) -> Option<&ShapeEntry> {
self.entries.get(index as usize)
}
/// Return a reference to all entries.
pub fn all(&self) -> &[ShapeEntry] {
&self.entries
}
/// The directory where shape SVG files are stored.
pub fn shapes_dir(&self) -> &Path {
&self.shapes_dir
}
}
+197
View File
@@ -0,0 +1,197 @@
use usvg::tiny_skia_path::PathSegment;
/// A single editable node in an SVG path (MoveTo / LineTo endpoint).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SvgNode {
pub x: f32,
pub y: f32,
}
/// Parsed, editable representation of a simple SVG shape.
///
/// **Purpose:** Extracts MoveTo/LineTo/Close path segments from an SVG string
/// into a list of editable polygons. Only straightline paths are editable;
/// quadratic and cubic bezier segments cause `from_svg` to return an error.
///
/// **Workflow:**
/// 1. Call `SvgEditable::from_svg(svg)` to parse.
/// 2. Manipulate nodes with `move_node`, `add_node`, `remove_node`.
/// 3. Call `to_svg()` to produce the modified SVG string.
/// 4. Pass the SVG string back to `VectorShape::SvgShape.svg`.
#[derive(Debug, Clone)]
pub struct SvgEditable {
/// (min_x, min_y, width, height) from the original SVG viewBox.
pub view_box: (f32, f32, f32, f32),
/// One polygon per subpath (separated by MoveTo + Close).
pub polygons: Vec<Vec<SvgNode>>,
}
impl SvgEditable {
/// Parse an SVG string into editable nodes.
///
/// Returns `Err` if the SVG contains bezier curves (QuadTo / CubicTo),
/// which are not supported in V1 of the editor.
pub fn from_svg(svg: &str) -> Result<Self, String> {
let opt = usvg::Options::default();
let tree = usvg::Tree::from_data(svg.as_bytes(), &opt).map_err(|e| e.to_string())?;
// Extract viewBox from raw SVG text
let vb = parse_viewbox(svg).unwrap_or((0.0, 0.0, 1.0, 1.0));
let mut polygons: Vec<Vec<SvgNode>> = Vec::new();
extract_editable_nodes(tree.root(), &mut polygons)?;
Ok(SvgEditable {
view_box: vb,
polygons,
})
}
/// Reconstruct an SVG string from the edited nodes.
///
/// The output uses the same `viewBox` as the original SVG and contains one
/// `<path>` element per polygon, each written as `M x,y L x,y ... Z`.
pub fn to_svg(&self) -> String {
let (vx, vy, vw, vh) = self.view_box;
let mut svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{} {} {} {}">"#,
vx, vy, vw, vh
);
for poly in &self.polygons {
if poly.len() < 2 {
continue;
}
svg.push_str(r#"<path d=""#);
for (i, node) in poly.iter().enumerate() {
if i == 0 {
svg.push_str(&format!("M {},{}", node.x, node.y));
} else {
svg.push_str(&format!(" L {},{}", node.x, node.y));
}
}
svg.push_str(" Z\"/>");
}
svg.push_str("</svg>");
svg
}
/// Add a new node at the midpoint of the edge at `edge_idx` in polygon
/// `poly_idx`. The new node is inserted between the two endpoints.
pub fn add_node(&mut self, poly_idx: usize, edge_idx: usize) {
let poly = match self.polygons.get_mut(poly_idx) {
Some(p) => p,
None => return,
};
if edge_idx >= poly.len() {
return;
}
let next = (edge_idx + 1) % poly.len();
let a = poly[edge_idx];
let b = poly[next];
let mid = SvgNode {
x: (a.x + b.x) * 0.5,
y: (a.y + b.y) * 0.5,
};
poly.insert(edge_idx + 1, mid);
}
/// Remove a node from polygon `poly_idx` at `node_idx`.
///
/// A polygon must keep at least 3 nodes (otherwise it would collapse).
/// If removing would leave fewer than 3 nodes the operation is ignored.
pub fn remove_node(&mut self, poly_idx: usize, node_idx: usize) {
let poly = match self.polygons.get_mut(poly_idx) {
Some(p) => p,
None => return,
};
if poly.len() <= 3 {
return; // keep at least a triangle
}
if node_idx < poly.len() {
poly.remove(node_idx);
}
}
/// Move a node to a new position (in SVG viewBox coordinates).
pub fn move_node(&mut self, poly_idx: usize, node_idx: usize, x: f32, y: f32) {
if let Some(node) = self.polygons.get_mut(poly_idx).and_then(|p| p.get_mut(node_idx)) {
node.x = x;
node.y = y;
}
}
/// The total number of editable nodes across all polygons.
pub fn total_nodes(&self) -> usize {
self.polygons.iter().map(|p| p.len()).sum()
}
}
/// Walk the usvg tree and collect editable (straightline) path nodes.
fn extract_editable_nodes(
group: &usvg::Group,
out: &mut Vec<Vec<SvgNode>>,
) -> Result<(), String> {
for child in group.children() {
match child {
usvg::Node::Path(path) => {
let mut current: Vec<SvgNode> = Vec::new();
for seg in path.data().segments() {
match seg {
PathSegment::MoveTo(p) => {
// Flush previous polygon
if !current.is_empty() {
if current.len() > 2 {
out.push(current.clone());
}
current.clear();
}
current.push(SvgNode { x: p.x, y: p.y });
}
PathSegment::LineTo(p) => {
current.push(SvgNode { x: p.x, y: p.y });
}
PathSegment::Close => {
if current.len() > 2 {
out.push(current.clone());
}
current.clear();
}
PathSegment::QuadTo(_, _) | PathSegment::CubicTo(_, _, _) => {
return Err(
"SVG contains curves. Only straight-line paths are editable."
.to_string(),
);
}
}
}
// Flush any remaining open path
if current.len() > 2 {
out.push(current);
}
}
usvg::Node::Group(g) => {
extract_editable_nodes(g, out)?;
}
_ => {}
}
}
Ok(())
}
/// Extract the `viewBox` attribute from an SVG string.
fn parse_viewbox(svg: &str) -> Option<(f32, f32, f32, f32)> {
let lower = svg.to_lowercase();
let needle = "viewbox=\"";
let start = lower.find(needle)?;
let after_eq = &svg[start + needle.len()..];
let end = after_eq.find('"')?;
let val = after_eq[..end].trim();
let nums: Vec<f32> = val.split_whitespace()
.filter_map(|s| s.parse().ok())
.collect();
if nums.len() == 4 {
Some((nums[0], nums[1], nums[2], nums[3]))
} else {
None
}
}
@@ -5010,6 +5010,14 @@ impl HcieIcedApp {
.engine
.set_active_layer(safe_source);
}
// Snapshot shape catalog entries for CustomShape tool
let custom_shapes: Vec<(String, String)> = self.documents
.get(self.active_doc)
.map(|d| {
d.engine.shape_catalog.all().iter().map(|e| (e.kind.clone(), e.svg.clone())).collect()
})
.unwrap_or_default();
let engine = &mut self.documents[self.active_doc].engine;
let color = self.fg_color;
let stroke = self.settings.tool_settings.vector_stroke_width;
@@ -5222,6 +5230,20 @@ impl HcieIcedApp {
_ => unreachable!(),
}
}
Tool::CustomShape(idx) => {
let entry = custom_shapes.get(idx as usize);
match entry {
Some((kind, svg)) => Some(hcie_engine_api::VectorShape::SvgShape {
x1: x0.min(x1), y1: y0.min(y1),
x2: x0.max(x1), y2: y0.max(y1),
kind: kind.clone(),
svg: svg.clone(),
stroke, color, fill_color: color, fill: true,
angle: 0.0, opacity, hardness: 0.5,
}),
None => None,
}
}
_ => None,
};
@@ -773,6 +773,7 @@ fn tool_icon(tool: Tool) -> &'static str {
Tool::SpotRemoval => "icons/spot.svg",
Tool::RedEyeRemoval => "icons/redeye.svg",
Tool::SmartPatch | Tool::AiObjectRemoval => "icons/patch.svg",
Tool::CustomShape(_) => "icons/star.svg",
}
}
+4 -1
View File
@@ -43,6 +43,7 @@ pub enum Tool {
AiObjectRemoval,
SmartSelect,
VisionSelect,
CustomShape(u32),
}
impl Tool {
@@ -100,6 +101,7 @@ impl Tool {
Tool::AiObjectRemoval => "AI Object Removal",
Tool::SmartSelect => "Smart Select",
Tool::VisionSelect => "Vision Tools",
Tool::CustomShape(_) => "Custom Shape",
}
}
@@ -141,6 +143,7 @@ impl Tool {
Tool::AiObjectRemoval => "🪄",
Tool::SmartSelect => "🎯",
Tool::VisionSelect => "👁",
Tool::CustomShape(_) => "",
}
}
@@ -187,7 +190,7 @@ impl Tool {
Tool::VectorPolygon | Tool::VectorRhombus | Tool::VectorCylinder |
Tool::VectorHeart | Tool::VectorBubble | Tool::VectorGear |
Tool::VectorCross | Tool::VectorCrescent | Tool::VectorBolt |
Tool::VectorArrow4
Tool::VectorArrow4 | Tool::CustomShape(_)
)
}
+60 -18
View File
@@ -1,8 +1,11 @@
/// SVG path tessellator using `usvg`.
///
/// Parses an SVG string, extracts `<path>` elements, tessellates bezier curves
/// to line segments, and scales the result from normalized `viewBox="0 0 1 1"`
/// coordinates to the target bounding box `(x1,y1)-(x2,y2)`.
/// to line segments, and scales the result from the SVG's viewBox coordinate
/// space to the target bounding box `(x1,y1)-(x2,y2)`.
///
/// The viewBox is extracted from the raw SVG string so non-zero min-x/min-y
/// values are correctly handled ($viewBox="-50 -50 100 100" etc.).
use usvg::tiny_skia_path::PathSegment;
@@ -19,13 +22,19 @@ pub fn svg_to_points(
) -> Result<Vec<Vec<(f32, f32)>>, String> {
let opt = usvg::Options::default();
let tree = usvg::Tree::from_data(svg.as_bytes(), &opt).map_err(|e| e.to_string())?;
let w = (x2 - x1).abs().max(1.0);
let h = (y2 - y1).abs().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);
// Extract viewBox from raw SVG so we can handle non-zero min-x/min-y.
let (vb_min_x, vb_min_y, vb_w, vb_h) = parse_viewbox(svg).unwrap_or((0.0, 0.0, 1.0, 1.0));
let svg_w = vb_w.max(0.001);
let svg_h = vb_h.max(0.001);
let mut result: Vec<Vec<(f32, f32)>> = Vec::new();
collect_paths(tree.root(), &mut result, ox, oy, w, h);
collect_paths(tree.root(), &mut result, ox, oy, target_w, target_h, vb_min_x, vb_min_y, svg_w, svg_h);
Ok(result)
}
@@ -34,8 +43,12 @@ fn collect_paths(
out: &mut Vec<Vec<(f32, f32)>>,
ox: f32,
oy: f32,
w: f32,
h: f32,
target_w: f32,
target_h: f32,
vb_min_x: f32,
vb_min_y: f32,
svg_w: f32,
svg_h: f32,
) {
for child in node.children() {
match child {
@@ -43,21 +56,21 @@ fn collect_paths(
let data = path.data();
let mut current_poly: Vec<(f32, f32)> = Vec::new();
for seg in data.segments() {
for seg in data.segments() {
match seg {
PathSegment::MoveTo(p) => {
if !current_poly.is_empty() && current_poly.len() > 2 {
out.push(std::mem::take(&mut current_poly));
}
current_poly.push((
ox + p.x * w,
oy + p.y * h,
ox + ((p.x - vb_min_x) / svg_w) * target_w,
oy + ((p.y - vb_min_y) / svg_h) * target_h,
));
}
PathSegment::LineTo(p) => {
current_poly.push((
ox + p.x * w,
oy + p.y * h,
ox + ((p.x - vb_min_x) / svg_w) * target_w,
oy + ((p.y - vb_min_y) / svg_h) * target_h,
));
}
PathSegment::QuadTo(p1, p) => {
@@ -65,8 +78,12 @@ fn collect_paths(
let steps = 8;
for i in 1..=steps {
let t = i as f32 / steps as f32;
let bx = quad_bezier(sx, ox + p1.x * w, ox + p.x * w, t);
let by = quad_bezier(sy, oy + p1.y * w, oy + p.y * h, t);
let bx = quad_bezier(sx,
ox + ((p1.x - vb_min_x) / svg_w) * target_w,
ox + ((p.x - vb_min_x) / svg_w) * target_w, t);
let by = quad_bezier(sy,
oy + ((p1.y - vb_min_y) / svg_h) * target_h,
oy + ((p.y - vb_min_y) / svg_h) * target_h, t);
current_poly.push((bx, by));
}
}
@@ -76,8 +93,14 @@ fn collect_paths(
let steps = 8;
for i in 1..=steps {
let t = i as f32 / steps as f32;
let bx = cubic_bezier(sx, ox + p1.x * w, ox + p2.x * w, ox + p.x * w, t);
let by = cubic_bezier(sy, oy + p1.y * h, oy + p2.y * h, oy + p.y * h, t);
let bx = cubic_bezier(sx,
ox + ((p1.x - vb_min_x) / svg_w) * target_w,
ox + ((p2.x - vb_min_x) / svg_w) * target_w,
ox + ((p.x - vb_min_x) / svg_w) * target_w, t);
let by = cubic_bezier(sy,
oy + ((p1.y - vb_min_y) / svg_h) * target_h,
oy + ((p2.y - vb_min_y) / svg_h) * target_h,
oy + ((p.y - vb_min_y) / svg_h) * target_h, t);
current_poly.push((bx, by));
}
}
@@ -95,7 +118,6 @@ fn collect_paths(
// Flush remaining open poly
if !current_poly.is_empty() && current_poly.len() > 2 {
// Close it if it looks like a closed shape
if let Some(&first) = current_poly.first() {
current_poly.push(first);
}
@@ -103,7 +125,7 @@ fn collect_paths(
}
}
usvg::Node::Group(g) => {
collect_paths(g, out, ox, oy, w, h);
collect_paths(g, out, ox, oy, target_w, target_h, vb_min_x, vb_min_y, svg_w, svg_h);
}
_ => {}
}
@@ -119,3 +141,23 @@ fn quad_bezier(p0: f32, p1: f32, p2: f32, t: f32) -> f32 {
let u = 1.0 - t;
u * u * p0 + 2.0 * u * t * p1 + t * t * p2
}
/// Extract the viewBox attribute from an SVG string.
///
/// Returns `(min_x, min_y, width, height)` or `None` if no viewBox found.
fn parse_viewbox(svg: &str) -> Option<(f32, f32, f32, f32)> {
let lower = svg.to_lowercase();
let needle = "viewbox=\"";
let start = lower.find(needle)?;
let after_eq = &svg[start + needle.len()..];
let end = after_eq.find('"')?;
let val = after_eq[..end].trim();
let nums: Vec<f32> = val.split_whitespace()
.filter_map(|s| s.parse().ok())
.collect();
if nums.len() == 4 {
Some((nums[0], nums[1], nums[2], nums[3]))
} else {
None
}
}
+27
View File
@@ -1,5 +1,11 @@
use std::collections::HashMap;
/// A built-in SVG template entry.
pub struct TemplateEntry {
pub kind: &'static str,
pub svg: String,
}
/// Generate an SVG string for a vector shape.
///
/// Each shape is defined in a normalized `viewBox="0 0 1 1"` coordinate system.
@@ -274,3 +280,24 @@ fn arrow4_svg() -> String {
d.push_str(" Z");
wrap(&d)
}
/// Return all built-in SVG templates with their kind names.
///
/// These are the default shapes shipped with the application. Each template
/// uses a `viewBox="0 0 1 1"` and generates an SVG suitable for rendering.
/// When written to disk, users can edit them in any SVG editor.
pub fn all_templates() -> Vec<TemplateEntry> {
vec![
TemplateEntry { kind: "arrow", svg: arrow_svg(false) },
TemplateEntry { kind: "star", svg: star_svg(5, 0.5) },
TemplateEntry { kind: "rhombus", svg: rhombus_svg() },
TemplateEntry { kind: "cylinder", svg: cylinder_svg() },
TemplateEntry { kind: "heart", svg: heart_svg() },
TemplateEntry { kind: "bubble", svg: bubble_svg() },
TemplateEntry { kind: "gear", svg: gear_svg() },
TemplateEntry { kind: "cross", svg: cross_svg() },
TemplateEntry { kind: "crescent", svg: crescent_svg() },
TemplateEntry { kind: "bolt", svg: bolt_svg() },
TemplateEntry { kind: "arrow4", svg: arrow4_svg() },
]
}