Files
hcie-rust-v3.05/.kilo/plans/1784472200000-svg-disk-shapes.md
phantom 6e0d179be9 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.
2026-07-19 13:50:17 +03:00

12 KiB
Raw Permalink Blame History

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.rsTool::CustomShape(u32) variant
2 hcie-vector (locked) src/svg_templates.rsall_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)

#[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()

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():

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)

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:

pub shape_catalog: ShapeCatalog,

Init in Engine::new_with_options:

let shapes_dir = /* app data dir */;
let builtins = hcie_vector::svg_templates::all_templates();
self.shape_catalog = ShapeCatalog::new(shapes_dir, builtins);

Re-export:

pub use crate::shape_catalog::{ShapeCatalog, ShapeEntry};

Add helper:

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:

// 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:

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:

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:

/// 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:

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

# 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