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