- 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.
12 KiB
SVG Shape File Loading + SVG Editor
Goal
- SVG şekilleri diskten
.svgdosyaları 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)
#[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 trueallowed_layer_type()— Vectoris_vector()— return truehas_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
.svgfile per template) — SKIP if file already exists - Scans
shapes_dirfor*.svgfiles - 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
resvgtoiced::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:
- Parse SVG with
usvg::Tree::from_data - Extract viewBox from
tree.size() - For each
<path>, extract segments viapath.data().segments() - Convert MoveTo/LineTo/Close segments to
SvgNodelist - Reject quadratic/cubic beziers for V1 (or tessellate and mark as non-editable)
to_svg:
- Build
<svg>with viewBox - 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
svgfield - 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
- Run the app, verify shapes/ directory is created with SVG files
- Verify all built-in shapes still render correctly
- Add a new SVG file to shapes/, verify it appears in the toolbox
- Edit an SVG file in shapes/, verify the change appears in the tool
- Draw a custom SVG shape on canvas
- 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 1as default - SVG has curves (C, Q) → V1 treats them as non-editable, shows "Edit not supported" warning