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:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 disk‑based 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/user‑supplied 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;
|
||||
}
|
||||
|
||||
@@ -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 user‑writable directory.
|
||||
///
|
||||
/// **Purpose:** On init the catalog writes built‑in 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 built‑in
|
||||
/// template — users can edit the SVG file from the GUI.
|
||||
///
|
||||
/// **Workflow:**
|
||||
/// 1. Create `shapes_dir` if it does not exist.
|
||||
/// 2. Write each built‑in 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. Built‑ins that are *not* on disk are appended using their template SVG.
|
||||
/// (Normally step 2 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`: Built‑in 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
|
||||
}
|
||||
|
||||
/// Re‑scan 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 built‑in 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 built‑in 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 built‑ins 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
|
||||
}
|
||||
}
|
||||
@@ -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 straight‑line 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 sub‑path (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 (straight‑line) 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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(_)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user