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

- Add SVG shape loading from disk, allowing users to customize shapes by placing SVG files in the designated directory.
- Introduce a new `ShapeCatalog` to manage SVG shapes, prioritizing user-defined shapes over built-in templates.
- Implement an SVG editor with node-based editing capabilities, enabling users to manipulate SVG paths directly.
- Create a new `SvgEditable` structure to represent editable SVG shapes, supporting operations like adding, removing, and moving nodes.
- Enhance the GUI with an SVG editor panel for visual editing of SVG shapes, including controls for node manipulation and saving changes.
- Ensure compatibility with existing shape tools and maintain a seamless user experience across the application.
This commit is contained in:
2026-07-19 13:50:17 +03:00
parent 5632c916ee
commit 6e0d179be9
20 changed files with 1723 additions and 25 deletions
+2
View File
@@ -29,6 +29,8 @@ serde_json = "1.0"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "bmp", "gif", "tiff"] }
rayon = "1.10"
log = "0.4"
dirs = { workspace = true }
usvg = { workspace = true }
[lib]
crate-type = ["rlib", "staticlib"]
+56
View File
@@ -7,6 +7,8 @@
mod ai_templates;
pub mod dynamic_loader;
pub mod ffi;
pub mod shape_catalog;
pub mod svg_editor;
// Performance-critical engine paths isolated into dedicated modules.
mod layer_property_ops;
@@ -39,6 +41,8 @@ pub use hcie_protocol::{
FilterType, LayerData, LayerInfo, LayerStyle, LayerType, LineCap, ModulePanel, ToolConfigs,
VectorEditHandle, VectorShape, WidgetDescription, ZOOM_MAX, ZOOM_MIN,
};
pub use crate::shape_catalog::{ShapeCatalog, ShapeEntry};
pub use crate::svg_editor::{SvgEditable, SvgNode};
pub mod brush {
pub use hcie_brush_engine::{BrushStyle, BrushTip};
}
@@ -413,6 +417,8 @@ pub struct Engine {
/// Polled and committed on the UI thread via `commit_pending_history()`.
pub pending_history:
std::sync::Arc<std::sync::Mutex<Vec<crate::stroke_cache::PendingHistoryItem>>>,
/// Shape catalog for diskbased SVG shapes.
pub shape_catalog: crate::shape_catalog::ShapeCatalog,
}
/// Create a `VectorShape::SvgShape` from a shape kind name and parameter map.
@@ -448,6 +454,35 @@ pub fn create_vector_shape(
}
}
/// Create a `VectorShape::SvgShape` from an arbitrary SVG string.
///
/// Used for custom/usersupplied shapes loaded from disk.
/// The `kind` is the filename stem (e.g. `"my_shape"`).
pub fn create_vector_shape_from_svg(
kind: &str,
svg: &str,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
) -> VectorShape {
VectorShape::SvgShape {
x1,
y1,
x2,
y2,
kind: kind.to_string(),
svg: svg.to_string(),
stroke: 2.0,
color: [0, 0, 0, 255],
fill_color: [255, 255, 255, 255],
fill: true,
angle: 0.0,
opacity: 1.0,
hardness: 1.0,
}
}
impl Engine {
/// Create a new engine with a default transparent document.
///
@@ -518,6 +553,12 @@ impl Engine {
below_cache_dirty: true,
cached_selection_mask: None,
pending_history: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
shape_catalog: {
let base = dirs::data_local_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."));
let shapes_dir = base.join("hcie").join("shapes");
crate::shape_catalog::ShapeCatalog::new(shapes_dir)
},
}
}
@@ -1558,6 +1599,21 @@ impl Engine {
}
}
pub fn set_vector_shape_svg(&mut self, layer_id: u64, shape_idx: usize, new_svg: &str) {
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
if let Some(shape) = shapes.get_mut(shape_idx) {
if let VectorShape::SvgShape { ref mut svg, .. } = shape {
*svg = new_svg.to_string();
layer.dirty = true;
self.document.composite_dirty = true;
self.document.modified = true;
}
}
}
}
}
pub fn mark_composite_dirty(&mut self) {
self.document.composite_dirty = true;
}
+165
View File
@@ -0,0 +1,165 @@
use hcie_vector::svg_templates::all_templates;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
/// A single shape entry in the catalog — backed either by a file on disk or
/// by a built-in template string.
#[derive(Debug, Clone)]
pub struct ShapeEntry {
pub index: u32,
pub kind: String,
pub label: String,
pub svg: String,
}
/// Manages discovery and caching of SVG shapes from a userwritable directory.
///
/// **Purpose:** On init the catalog writes builtin template SVGs to disk (only
/// if they do not already exist), then scans `shapes_dir` for all `*.svg` files.
/// Any shape whose file is present on disk takes priority over the builtin
/// template — users can edit the SVG file from the GUI.
///
/// **Workflow:**
/// 1. Create `shapes_dir` if it does not exist.
/// 2. Write each builtin SVG to `shapes_dir/{kind}.svg` — skip if file exists.
/// 3. Scan `shapes_dir` for `*.svg` files.
/// 4. For each file, read content and create a `ShapeEntry`.
/// 5. Builtins that are *not* on disk are appended using their template SVG.
/// (Normally step2 writes them, so this is a safety fallback.)
pub struct ShapeCatalog {
entries: Vec<ShapeEntry>,
shapes_dir: PathBuf,
}
impl ShapeCatalog {
/// Create a new catalog at `shapes_dir` using `builtins` as the default set.
///
/// - `shapes_dir`: Directory that holds (or will hold) the SVG files.
/// - `builtins`: Builtin templates from `hcie_vector::svg_templates::all_templates()`.
pub fn new(shapes_dir: PathBuf) -> Self {
let mut catalog = Self {
entries: Vec::new(),
shapes_dir,
};
catalog.refresh();
catalog
}
/// Rescan the shapes directory and rebuild the entry list.
pub fn refresh(&mut self) {
// Ensure directory exists
let _ = fs::create_dir_all(&self.shapes_dir);
// Write builtin templates to disk if they are missing.
// This lets users edit the default shapes in any SVG editor.
let builtins = all_templates();
for tmpl in &builtins {
let path = self.shapes_dir.join(format!("{}.svg", tmpl.kind));
if !path.exists() {
let _ = fs::write(&path, &tmpl.svg);
}
}
// Gather files: disk first (priority), then builtin templates as fallback.
let mut entries: Vec<ShapeEntry> = Vec::new();
let mut seen_kinds: HashMap<String, bool> = HashMap::new();
// 1) Scan disk for *.svg files.
if let Ok(dir) = fs::read_dir(&self.shapes_dir) {
for entry in dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("svg") {
continue;
}
let kind = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_default();
if kind.is_empty() {
continue;
}
if let Ok(svg) = fs::read_to_string(&path) {
seen_kinds.insert(kind.clone(), true);
let label = kind
.replace('_', " ")
.split(' ')
.map(|w| {
let mut c = w.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().to_string() + c.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ");
entries.push(ShapeEntry {
index: 0, // will reassign below
kind,
label,
svg,
});
}
}
}
// 2) Append builtins that were NOT found on disk (safety fallback).
for tmpl in &builtins {
if !seen_kinds.contains_key(tmpl.kind) {
let label = tmpl
.kind
.replace('_', " ")
.split(' ')
.map(|w| {
let mut c = w.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().to_string() + c.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ");
entries.push(ShapeEntry {
index: 0,
kind: tmpl.kind.to_string(),
label,
svg: tmpl.svg.clone(),
});
}
}
// Assign sequential indices.
for (i, entry) in entries.iter_mut().enumerate() {
entry.index = i as u32;
}
self.entries = entries;
}
/// Number of shapes in the catalog.
pub fn len(&self) -> usize {
self.entries.len()
}
/// Whether the catalog is empty.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Look up a shape by its sequential index.
pub fn get(&self, index: u32) -> Option<&ShapeEntry> {
self.entries.get(index as usize)
}
/// Return a reference to all entries.
pub fn all(&self) -> &[ShapeEntry] {
&self.entries
}
/// The directory where shape SVG files are stored.
pub fn shapes_dir(&self) -> &Path {
&self.shapes_dir
}
}
+197
View File
@@ -0,0 +1,197 @@
use usvg::tiny_skia_path::PathSegment;
/// A single editable node in an SVG path (MoveTo / LineTo endpoint).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SvgNode {
pub x: f32,
pub y: f32,
}
/// Parsed, editable representation of a simple SVG shape.
///
/// **Purpose:** Extracts MoveTo/LineTo/Close path segments from an SVG string
/// into a list of editable polygons. Only straightline paths are editable;
/// quadratic and cubic bezier segments cause `from_svg` to return an error.
///
/// **Workflow:**
/// 1. Call `SvgEditable::from_svg(svg)` to parse.
/// 2. Manipulate nodes with `move_node`, `add_node`, `remove_node`.
/// 3. Call `to_svg()` to produce the modified SVG string.
/// 4. Pass the SVG string back to `VectorShape::SvgShape.svg`.
#[derive(Debug, Clone)]
pub struct SvgEditable {
/// (min_x, min_y, width, height) from the original SVG viewBox.
pub view_box: (f32, f32, f32, f32),
/// One polygon per subpath (separated by MoveTo + Close).
pub polygons: Vec<Vec<SvgNode>>,
}
impl SvgEditable {
/// Parse an SVG string into editable nodes.
///
/// Returns `Err` if the SVG contains bezier curves (QuadTo / CubicTo),
/// which are not supported in V1 of the editor.
pub fn from_svg(svg: &str) -> Result<Self, String> {
let opt = usvg::Options::default();
let tree = usvg::Tree::from_data(svg.as_bytes(), &opt).map_err(|e| e.to_string())?;
// Extract viewBox from raw SVG text
let vb = parse_viewbox(svg).unwrap_or((0.0, 0.0, 1.0, 1.0));
let mut polygons: Vec<Vec<SvgNode>> = Vec::new();
extract_editable_nodes(tree.root(), &mut polygons)?;
Ok(SvgEditable {
view_box: vb,
polygons,
})
}
/// Reconstruct an SVG string from the edited nodes.
///
/// The output uses the same `viewBox` as the original SVG and contains one
/// `<path>` element per polygon, each written as `M x,y L x,y ... Z`.
pub fn to_svg(&self) -> String {
let (vx, vy, vw, vh) = self.view_box;
let mut svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{} {} {} {}">"#,
vx, vy, vw, vh
);
for poly in &self.polygons {
if poly.len() < 2 {
continue;
}
svg.push_str(r#"<path d=""#);
for (i, node) in poly.iter().enumerate() {
if i == 0 {
svg.push_str(&format!("M {},{}", node.x, node.y));
} else {
svg.push_str(&format!(" L {},{}", node.x, node.y));
}
}
svg.push_str(" Z\"/>");
}
svg.push_str("</svg>");
svg
}
/// Add a new node at the midpoint of the edge at `edge_idx` in polygon
/// `poly_idx`. The new node is inserted between the two endpoints.
pub fn add_node(&mut self, poly_idx: usize, edge_idx: usize) {
let poly = match self.polygons.get_mut(poly_idx) {
Some(p) => p,
None => return,
};
if edge_idx >= poly.len() {
return;
}
let next = (edge_idx + 1) % poly.len();
let a = poly[edge_idx];
let b = poly[next];
let mid = SvgNode {
x: (a.x + b.x) * 0.5,
y: (a.y + b.y) * 0.5,
};
poly.insert(edge_idx + 1, mid);
}
/// Remove a node from polygon `poly_idx` at `node_idx`.
///
/// A polygon must keep at least 3 nodes (otherwise it would collapse).
/// If removing would leave fewer than 3 nodes the operation is ignored.
pub fn remove_node(&mut self, poly_idx: usize, node_idx: usize) {
let poly = match self.polygons.get_mut(poly_idx) {
Some(p) => p,
None => return,
};
if poly.len() <= 3 {
return; // keep at least a triangle
}
if node_idx < poly.len() {
poly.remove(node_idx);
}
}
/// Move a node to a new position (in SVG viewBox coordinates).
pub fn move_node(&mut self, poly_idx: usize, node_idx: usize, x: f32, y: f32) {
if let Some(node) = self.polygons.get_mut(poly_idx).and_then(|p| p.get_mut(node_idx)) {
node.x = x;
node.y = y;
}
}
/// The total number of editable nodes across all polygons.
pub fn total_nodes(&self) -> usize {
self.polygons.iter().map(|p| p.len()).sum()
}
}
/// Walk the usvg tree and collect editable (straightline) path nodes.
fn extract_editable_nodes(
group: &usvg::Group,
out: &mut Vec<Vec<SvgNode>>,
) -> Result<(), String> {
for child in group.children() {
match child {
usvg::Node::Path(path) => {
let mut current: Vec<SvgNode> = Vec::new();
for seg in path.data().segments() {
match seg {
PathSegment::MoveTo(p) => {
// Flush previous polygon
if !current.is_empty() {
if current.len() > 2 {
out.push(current.clone());
}
current.clear();
}
current.push(SvgNode { x: p.x, y: p.y });
}
PathSegment::LineTo(p) => {
current.push(SvgNode { x: p.x, y: p.y });
}
PathSegment::Close => {
if current.len() > 2 {
out.push(current.clone());
}
current.clear();
}
PathSegment::QuadTo(_, _) | PathSegment::CubicTo(_, _, _) => {
return Err(
"SVG contains curves. Only straight-line paths are editable."
.to_string(),
);
}
}
}
// Flush any remaining open path
if current.len() > 2 {
out.push(current);
}
}
usvg::Node::Group(g) => {
extract_editable_nodes(g, out)?;
}
_ => {}
}
}
Ok(())
}
/// Extract the `viewBox` attribute from an SVG string.
fn parse_viewbox(svg: &str) -> Option<(f32, f32, f32, f32)> {
let lower = svg.to_lowercase();
let needle = "viewbox=\"";
let start = lower.find(needle)?;
let after_eq = &svg[start + needle.len()..];
let end = after_eq.find('"')?;
let val = after_eq[..end].trim();
let nums: Vec<f32> = val.split_whitespace()
.filter_map(|s| s.parse().ok())
.collect();
if nums.len() == 4 {
Some((nums[0], nums[1], nums[2], nums[3]))
} else {
None
}
}