From 6e0d179be9078e41210f8a876b572cdd96704fce Mon Sep 17 00:00:00 2001 From: Halit Can Date: Sun, 19 Jul 2026 13:50:17 +0300 Subject: [PATCH] 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. --- .kilo/plans/1784472200000-svg-disk-shapes.md | 358 +++++++++++++++ Cargo.lock | 38 +- Cargo.toml | 1 + .../crates/hcie-gui-egui/src/app/mod.rs | 44 ++ .../hcie-gui-egui/src/app/panels/mod.rs | 1 + .../hcie-gui-egui/src/app/panels/panels.rs | 33 +- .../src/app/panels/svg_editor_panel.rs | 434 ++++++++++++++++++ .../src/app/tools/geometry_panel.rs | 22 + .../hcie-gui-egui/src/app/tools/toolbox.rs | 233 +++++++++- .../crates/hcie-gui-egui/src/canvas/mod.rs | 27 ++ .../crates/hcie-gui-egui/src/event_bus.rs | 4 + hcie-engine-api/Cargo.toml | 2 + hcie-engine-api/src/lib.rs | 56 +++ hcie-engine-api/src/shape_catalog.rs | 165 +++++++ hcie-engine-api/src/svg_editor.rs | 197 ++++++++ hcie-iced-app/crates/hcie-iced-gui/src/app.rs | 22 + .../crates/hcie-iced-gui/src/sidebar/mod.rs | 1 + hcie-protocol/src/tools.rs | 5 +- hcie-vector/src/svg_render.rs | 78 +++- hcie-vector/src/svg_templates.rs | 27 ++ 20 files changed, 1723 insertions(+), 25 deletions(-) create mode 100644 .kilo/plans/1784472200000-svg-disk-shapes.md create mode 100644 hcie-egui-app/crates/hcie-gui-egui/src/app/panels/svg_editor_panel.rs create mode 100644 hcie-engine-api/src/shape_catalog.rs create mode 100644 hcie-engine-api/src/svg_editor.rs diff --git a/.kilo/plans/1784472200000-svg-disk-shapes.md b/.kilo/plans/1784472200000-svg-disk-shapes.md new file mode 100644 index 0000000..8b38051 --- /dev/null +++ b/.kilo/plans/1784472200000-svg-disk-shapes.md @@ -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 { + 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>, 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, + shapes_dir: PathBuf, + builtin_kinds: HashMap, // 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) -> 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 { + // 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>, // one per subpath +} + +impl SvgEditable { + /// Parse an SVG string into editable nodes. + pub fn from_svg(svg: &str) -> Result; + + /// 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 ``, 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 `` with viewBox +2. For each polygon, emit `` + +### 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, + +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 diff --git a/Cargo.lock b/Cargo.lock index 9fb0ff6..54d4d42 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index ab5b633..9e88240 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/mod.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/mod.rs index f077f13..93c5519 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/mod.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/mod.rs @@ -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, + /// SVG editor modal state. `Some` when the editor is open. + pub svg_editor_state: Option, + 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; diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/mod.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/mod.rs index 5759452..15a808b 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/mod.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/mod.rs @@ -1,3 +1,4 @@ mod panels; pub use panels::*; pub mod color; +pub mod svg_editor_panel; diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/panels.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/panels.rs index 3292260..fcb9543 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/panels.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/panels.rs @@ -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(); diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/svg_editor_panel.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/svg_editor_panel.rs new file mode 100644 index 0000000..79d35fb --- /dev/null +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/panels/svg_editor_panel.rs @@ -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, + pub drag_node: Option<(usize, usize)>, // (poly_idx, node_idx) + pub rendered_preview: Option, +} + +// ── 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 = 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); + } +} diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/geometry_panel.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/geometry_panel.rs index 741041c..da75361 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/geometry_panel.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/geometry_panel.rs @@ -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() { diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/toolbox.rs b/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/toolbox.rs index 5ed0b2d..891127d 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/toolbox.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/app/tools/toolbox.rs @@ -58,6 +58,7 @@ fn tool_icon_source(tool: Tool) -> Option> { 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 = 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; + } + } + } +} diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs b/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs index 84f4438..fcfd595 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs @@ -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#""#.to_string(), + stroke, color, fill_color, fill, + angle: 0.0, opacity, hardness, + } + } + }, _ => VectorShape::Line { x1, y1, diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/event_bus.rs b/hcie-egui-app/crates/hcie-gui-egui/src/event_bus.rs index 5ef716c..6c599b7 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/event_bus.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/event_bus.rs @@ -67,6 +67,10 @@ pub enum AppEvent { // Vector VectorShapeDeleted(u64, usize), + OpenSvgEditor { + layer_id: u64, + shape_idx: usize, + }, // Clipboard ClipboardCopy, diff --git a/hcie-engine-api/Cargo.toml b/hcie-engine-api/Cargo.toml index f5c3b9e..b40b3a4 100644 --- a/hcie-engine-api/Cargo.toml +++ b/hcie-engine-api/Cargo.toml @@ -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"] diff --git a/hcie-engine-api/src/lib.rs b/hcie-engine-api/src/lib.rs index 1c56f4d..4c61191 100644 --- a/hcie-engine-api/src/lib.rs +++ b/hcie-engine-api/src/lib.rs @@ -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>>, + /// 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; } diff --git a/hcie-engine-api/src/shape_catalog.rs b/hcie-engine-api/src/shape_catalog.rs new file mode 100644 index 0000000..271d61d --- /dev/null +++ b/hcie-engine-api/src/shape_catalog.rs @@ -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, + 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 = Vec::new(); + let mut seen_kinds: HashMap = 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::>() + .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::>() + .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 + } +} diff --git a/hcie-engine-api/src/svg_editor.rs b/hcie-engine-api/src/svg_editor.rs new file mode 100644 index 0000000..c6fc2dc --- /dev/null +++ b/hcie-engine-api/src/svg_editor.rs @@ -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>, +} + +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 { + 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::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 + /// `` 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#""#, + vx, vy, vw, vh + ); + for poly in &self.polygons { + if poly.len() < 2 { + continue; + } + svg.push_str(r#""); + } + svg.push_str(""); + 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>, +) -> Result<(), String> { + for child in group.children() { + match child { + usvg::Node::Path(path) => { + let mut current: Vec = 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 = 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 + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index c3e0119..0e5479f 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -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, }; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs index 32abba1..6e1e610 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/sidebar/mod.rs @@ -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", } } diff --git a/hcie-protocol/src/tools.rs b/hcie-protocol/src/tools.rs index c11c18e..b0c104f 100644 --- a/hcie-protocol/src/tools.rs +++ b/hcie-protocol/src/tools.rs @@ -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(_) ) } diff --git a/hcie-vector/src/svg_render.rs b/hcie-vector/src/svg_render.rs index b4c9f52..6c31e06 100644 --- a/hcie-vector/src/svg_render.rs +++ b/hcie-vector/src/svg_render.rs @@ -1,8 +1,11 @@ /// SVG path tessellator using `usvg`. /// /// Parses an SVG string, extracts `` 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>, 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::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>, 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 = 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 + } +} diff --git a/hcie-vector/src/svg_templates.rs b/hcie-vector/src/svg_templates.rs index e3eb042..a9ac859 100644 --- a/hcie-vector/src/svg_templates.rs +++ b/hcie-vector/src/svg_templates.rs @@ -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 { + 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() }, + ] +}