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
+60 -18
View File
@@ -1,8 +1,11 @@
/// SVG path tessellator using `usvg`.
///
/// Parses an SVG string, extracts `<path>` 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<Vec<Vec<(f32, f32)>>, 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<(f32, f32)>> = 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<Vec<(f32, f32)>>,
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<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
}
}
+27
View File
@@ -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<TemplateEntry> {
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() },
]
}