From 1fa4d744be604f9081e5d300a0f1e7dfb0ae0905 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Fri, 26 Jun 2026 16:40:29 -0400 Subject: [PATCH] Add SVG vector import and export Export (lightningbeam-core/svg_export.rs): document_to_svg walks vector layers/groups at a given frame and emits per fill (solid or gradient via ) and per stroked edge. Wired into the export dialog as an "SVG" tab; written synchronously. Raster/video/effect layers are skipped (vector-only, lossless), structured for a later rasterize pass. Import (lightningbeam-editor/svg_import.rs): import_svg parses via usvg, bakes each path's absolute transform into geometry, converts segments to cubic edges, and maps solid/linear/radial paint to ShapeColor/ ShapeGradient. .svg is detected in the Ctrl+I Import handler and added as a new vector layer (keyframe at the playhead). file_types gains FileType::Vector + VECTOR_EXTENSIONS. Tests: svg_export::export_tests (core) and svg_import::tests (editor). --- .../lightningbeam-core/src/file_types.rs | 12 +- .../lightningbeam-core/src/renderer.rs | 2 +- .../lightningbeam-core/src/svg_export.rs | 259 ++++++++++++++ .../lightningbeam-editor/src/export/dialog.rs | 26 +- .../lightningbeam-editor/src/main.rs | 70 +++- .../lightningbeam-editor/src/svg_import.rs | 315 ++++++++++++++++++ 6 files changed, 678 insertions(+), 6 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-editor/src/svg_import.rs diff --git a/lightningbeam-ui/lightningbeam-core/src/file_types.rs b/lightningbeam-ui/lightningbeam-core/src/file_types.rs index 464ff36..01b837c 100644 --- a/lightningbeam-ui/lightningbeam-core/src/file_types.rs +++ b/lightningbeam-ui/lightningbeam-core/src/file_types.rs @@ -15,7 +15,9 @@ pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "avi", "mkv", "webm", "m4v /// Supported MIDI file extensions pub const MIDI_EXTENSIONS: &[&str] = &["mid", "midi"]; -// Note: SVG import deferred to future task +/// Supported vector file extensions (imported as a new vector layer, not an asset) +pub const VECTOR_EXTENSIONS: &[&str] = &["svg"]; + // Note: .beam project files handled separately in file save/load feature /// File type categories for import routing @@ -25,6 +27,7 @@ pub enum FileType { Audio, Video, Midi, + Vector, } /// Detect file type from extension string @@ -53,6 +56,9 @@ pub fn get_file_type(extension: &str) -> Option { if MIDI_EXTENSIONS.contains(&ext.as_str()) { return Some(FileType::Midi); } + if VECTOR_EXTENSIONS.contains(&ext.as_str()) { + return Some(FileType::Vector); + } None } @@ -65,6 +71,7 @@ pub fn all_supported_extensions() -> Vec<&'static str> { all.extend_from_slice(AUDIO_EXTENSIONS); all.extend_from_slice(VIDEO_EXTENSIONS); all.extend_from_slice(MIDI_EXTENSIONS); + all.extend_from_slice(VECTOR_EXTENSIONS); all } @@ -90,7 +97,8 @@ mod tests { assert_eq!(get_file_type("midi"), Some(FileType::Midi)); assert_eq!(get_file_type("unknown"), None); - assert_eq!(get_file_type("svg"), None); // SVG deferred + assert_eq!(get_file_type("svg"), Some(FileType::Vector)); + assert_eq!(get_file_type("SVG"), Some(FileType::Vector)); } #[test] diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index f838b23..63ffcb3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -1344,7 +1344,7 @@ fn render_video_layer( /// The axis is centred on the bbox midpoint and oriented at `angle_deg` degrees /// (0 = left→right, 90 = top→bottom). The axis extends ± half the bbox diagonal /// so the gradient covers the entire shape regardless of angle. -fn gradient_bbox_endpoints(angle_deg: f32, bbox: kurbo::Rect) -> (kurbo::Point, kurbo::Point) { +pub(crate) fn gradient_bbox_endpoints(angle_deg: f32, bbox: kurbo::Rect) -> (kurbo::Point, kurbo::Point) { let cx = bbox.center().x; let cy = bbox.center().y; let dx = bbox.width(); diff --git a/lightningbeam-ui/lightningbeam-core/src/svg_export.rs b/lightningbeam-ui/lightningbeam-core/src/svg_export.rs index 527aac3..ad36f80 100644 --- a/lightningbeam-ui/lightningbeam-core/src/svg_export.rs +++ b/lightningbeam-ui/lightningbeam-core/src/svg_export.rs @@ -218,3 +218,262 @@ fn cubic_to_svg_path(curve: &CubicBez) -> String { curve.p3.x, curve.p3.y, ) } + +// =========================================================================== +// Document / VectorGraph → SVG (the current model). The functions above target +// the legacy DCEL and are kept only for the clipboard stub. +// =========================================================================== + +use crate::document::Document; +use crate::gradient::{GradientExtend, GradientType, ShapeGradient}; +use crate::layer::AnyLayer; +use crate::shape::{Cap, FillRule, Join, ShapeColor}; +use crate::vector_graph::{FillId, VectorGraph}; +use kurbo::{BezPath, PathEl, Rect, Shape}; + +/// Serialize the document's **vector** content to a standalone SVG string, at document time `time`. +/// Vector layers (and groups of them) only — raster/video/audio/effect layers are skipped (a later +/// pass can rasterize them to ``). Animation is a single static frame at `time`. +pub fn document_to_svg(document: &Document, time: f64) -> String { + let (w, h) = (document.width, document.height); + let mut defs = String::new(); + let mut body = String::new(); + let mut grad_n = 0usize; + + // Opaque background rect (skip if the document background is transparent). + let bg = document.background_color; + if bg.a > 0 { + body.push_str(&format!( + r#""#, + fill_attrs(&bg) + )); + } + + for layer in &document.root.children { + layer_to_svg(layer, time, 1.0, &mut body, &mut defs, &mut grad_n); + } + + format!( + concat!( + r#"{}{}"# + ), + w, h, w, h, defs, body + ) +} + +/// Append one layer's SVG. Recurses into groups (``); other non-vector layer types are skipped. +fn layer_to_svg(layer: &AnyLayer, time: f64, parent_opacity: f64, body: &mut String, defs: &mut String, grad_n: &mut usize) { + match layer { + AnyLayer::Vector(vl) => { + let opacity = parent_opacity * vl.layer.opacity; + if let Some(graph) = vl.tweened_graph_at(time) { + let wrap = opacity < 0.999; + if wrap { + body.push_str(&format!(r#""#)); + } + vector_graph_to_svg(&graph, body, defs, grad_n); + if wrap { + body.push_str(""); + } + } + // NOTE: placed clip instances (nested clips with their own transform) are not yet + // exported — a refinement once loose-geometry export is verified. + } + AnyLayer::Group(g) => { + let opacity = parent_opacity * g.layer.opacity; + body.push_str(&format!(r#""#)); + for child in &g.children { + layer_to_svg(child, time, 1.0, body, defs, grad_n); + } + body.push_str(""); + } + // Raster/Video/Audio/Effect have no lossless vector representation — skipped this pass. + _ => {} + } +} + +/// Emit a vector graph's fills (``) and stroked edges (``) into `body`, +/// accumulating any gradients into `defs`. Geometry is in document space (no per-layer transform). +fn vector_graph_to_svg(graph: &VectorGraph, body: &mut String, defs: &mut String, grad_n: &mut usize) { + // Fills first (drawn under strokes, matching the renderer). + for (i, fill) in graph.fills.iter().enumerate() { + if fill.deleted { + continue; + } + let path = graph.fill_to_bezpath(FillId(i as u32)); + let d = bezpath_to_d(&path); + if d.is_empty() { + continue; + } + let rule = match fill.fill_rule { + FillRule::NonZero => "nonzero", + FillRule::EvenOdd => "evenodd", + }; + + if let Some(grad) = &fill.gradient_fill { + let id = format!("grad{}", *grad_n); + *grad_n += 1; + defs.push_str(&gradient_to_svg(grad, &id, path.bounding_box())); + body.push_str(&format!(r#""#)); + } else if fill.image_fill.is_some() { + // Image fills need / + asset embedding — skipped this (vector-only) pass. + continue; + } else if let Some(c) = &fill.color { + body.push_str(&format!(r#""#, fill_attrs(c))); + } + } + + // Strokes: one per stroked edge (each edge may carry its own style). + for edge in &graph.edges { + if edge.deleted { + continue; + } + if let (Some(style), Some(color)) = (&edge.stroke_style, &edge.stroke_color) { + let d = cubic_to_svg_path(&edge.curve); + body.push_str(&format!( + r#""#, + stroke_attrs(color), style.width, cap_str(style.cap), join_str(style.join), style.miter_limit + )); + } + } +} + +/// `` / `` definition matching the renderer's start/end semantics. +fn gradient_to_svg(grad: &ShapeGradient, id: &str, bbox: Rect) -> String { + use kurbo::Point; + // Mirror renderer.rs: explicit world endpoints if present (radial reflects the edge through the + // center so midpoint(start,end) == center), else derive from angle + bbox. + let (start, end) = match (grad.start_world, grad.end_world) { + (Some((sx, sy)), Some((ex, ey))) => match grad.kind { + GradientType::Linear => (Point::new(sx, sy), Point::new(ex, ey)), + GradientType::Radial => (Point::new(2.0 * sx - ex, 2.0 * sy - ey), Point::new(ex, ey)), + }, + _ => crate::renderer::gradient_bbox_endpoints(grad.angle, bbox), + }; + + let stops: String = grad + .stops + .iter() + .map(|s| { + format!( + r##""##, + s.position, s.color.r, s.color.g, s.color.b, s.color.a as f32 / 255.0 + ) + }) + .collect(); + let spread = match grad.extend { + GradientExtend::Pad => "pad", + GradientExtend::Reflect => "reflect", + GradientExtend::Repeat => "repeat", + }; + + match grad.kind { + GradientType::Linear => format!( + r#"{stops}"#, + start.x, start.y, end.x, end.y + ), + GradientType::Radial => { + let (cx, cy) = ((start.x + end.x) * 0.5, (start.y + end.y) * 0.5); + let r = (((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt()) * 0.5; + format!( + r#"{stops}"# + ) + } + } +} + +/// kurbo `BezPath` → SVG path-data string (`M/L/Q/C/Z`). +fn bezpath_to_d(path: &BezPath) -> String { + let mut d = String::new(); + for el in path.elements() { + match el { + PathEl::MoveTo(p) => d.push_str(&format!("M{:.3} {:.3} ", p.x, p.y)), + PathEl::LineTo(p) => d.push_str(&format!("L{:.3} {:.3} ", p.x, p.y)), + PathEl::QuadTo(p1, p) => d.push_str(&format!("Q{:.3} {:.3} {:.3} {:.3} ", p1.x, p1.y, p.x, p.y)), + PathEl::CurveTo(p1, p2, p) => d.push_str(&format!( + "C{:.3} {:.3} {:.3} {:.3} {:.3} {:.3} ", + p1.x, p1.y, p2.x, p2.y, p.x, p.y + )), + PathEl::ClosePath => d.push_str("Z "), + } + } + d.trim_end().to_string() +} + +// sRGB color → SVG attributes. Hex color + a separate `*-opacity` for max compatibility (Inkscape). +fn fill_attrs(c: &ShapeColor) -> String { + if c.a == 255 { + format!(r##"fill="#{:02x}{:02x}{:02x}""##, c.r, c.g, c.b) + } else { + format!(r##"fill="#{:02x}{:02x}{:02x}" fill-opacity="{:.4}""##, c.r, c.g, c.b, c.a as f32 / 255.0) + } +} +fn stroke_attrs(c: &ShapeColor) -> String { + if c.a == 255 { + format!(r##"stroke="#{:02x}{:02x}{:02x}""##, c.r, c.g, c.b) + } else { + format!(r##"stroke="#{:02x}{:02x}{:02x}" stroke-opacity="{:.4}""##, c.r, c.g, c.b, c.a as f32 / 255.0) + } +} +fn cap_str(cap: Cap) -> &'static str { + match cap { + Cap::Butt => "butt", + Cap::Round => "round", + Cap::Square => "square", + } +} +fn join_str(join: Join) -> &'static str { + match join { + Join::Miter => "miter", + Join::Round => "round", + Join::Bevel => "bevel", + } +} + +#[cfg(test)] +mod export_tests { + use super::*; + use crate::shape::{ShapeColor, StrokeStyle}; + use crate::vector_graph::{Direction, VectorGraph}; + use kurbo::{CubicBez, Point}; + + fn line(a: Point, b: Point) -> CubicBez { + // Degenerate cubic representing a straight segment (matches our model). + CubicBez::new(a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b) + } + + #[test] + fn solid_triangle_fill_and_stroke() { + let mut g = VectorGraph::new(); + let p0 = Point::new(10.0, 10.0); + let p1 = Point::new(90.0, 10.0); + let p2 = Point::new(50.0, 80.0); + let v0 = g.alloc_vertex(p0); + let v1 = g.alloc_vertex(p1); + let v2 = g.alloc_vertex(p2); + let stroke = Some(StrokeStyle { width: 2.0, ..Default::default() }); + let scol = Some(ShapeColor::rgb(0, 0, 0)); + let e0 = g.alloc_edge(line(p0, p1), v0, v1, stroke.clone(), scol); + let e1 = g.alloc_edge(line(p1, p2), v1, v2, stroke.clone(), scol); + let e2 = g.alloc_edge(line(p2, p0), v2, v0, stroke.clone(), scol); + g.alloc_fill( + vec![(e0, Direction::Forward), (e1, Direction::Forward), (e2, Direction::Forward)], + ShapeColor::rgb(255, 0, 0), + crate::shape::FillRule::NonZero, + ); + + let mut body = String::new(); + let mut defs = String::new(); + let mut n = 0; + vector_graph_to_svg(&g, &mut body, &mut defs, &mut n); + + assert!(body.contains(r##"fill="#ff0000""##), "fill color missing: {body}"); + assert!(body.contains(r#"fill-rule="nonzero""#), "fill-rule missing: {body}"); + assert!(body.contains(r#"fill="none""#), "stroke path missing: {body}"); + assert!(body.contains(r#"stroke-width="2.000""#), "stroke width missing: {body}"); + assert!(defs.is_empty(), "no gradients expected: {defs}"); + // 1 fill path + 3 stroked edges = 4 elements. + assert_eq!(body.matches(" self.audio_settings.format.extension(), ExportType::Image => self.image_settings.format.extension(), ExportType::Video => self.video_settings.codec.container_format(), + ExportType::Svg => "svg", } } @@ -198,6 +203,7 @@ impl ExportDialog { ExportType::Audio => "Export Audio", ExportType::Image => "Export Image", ExportType::Video => "Export Video", + ExportType::Svg => "Export SVG", }; let modal_response = egui::Modal::new(egui::Id::new("export_dialog_modal")) @@ -219,6 +225,7 @@ impl ExportDialog { (ExportType::Audio, "Audio"), (ExportType::Image, "Image"), (ExportType::Video, "Video"), + (ExportType::Svg, "SVG"), ] { if ui.selectable_value(&mut self.export_type, variant, label).clicked() { self.update_filename_extension(); @@ -235,6 +242,7 @@ impl ExportDialog { ExportType::Audio => self.render_audio_basic(ui), ExportType::Image => self.render_image_settings(ui), ExportType::Video => self.render_video_basic(ui), + ExportType::Svg => self.render_svg_settings(ui), } ui.add_space(12.0); @@ -253,6 +261,7 @@ impl ExportDialog { ExportType::Audio => self.render_audio_advanced(ui), ExportType::Image => self.render_image_advanced(ui), ExportType::Video => self.render_video_advanced(ui), + ExportType::Svg => {} // SVG has no advanced settings } } @@ -356,6 +365,20 @@ impl ExportDialog { } } + /// Render SVG export settings — just the frame time (reuses the image time field). + fn render_svg_settings(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.label("Time:"); + ui.add(egui::DragValue::new(&mut self.image_settings.time) + .speed(0.01) + .range(0.0..=f64::MAX) + .suffix(" s")); + }); + ui.add_space(4.0); + ui.weak("Exports vector layers losslessly at this frame. Raster, video, and"); + ui.weak("effect layers are not included."); + } + /// Render advanced image export settings (time, resolution override). fn render_image_advanced(&mut self, ui: &mut egui::Ui) { // Time (which frame to export) @@ -595,7 +618,7 @@ impl ExportDialog { fn render_time_range(&mut self, ui: &mut egui::Ui) { let (start_time, end_time) = match self.export_type { ExportType::Audio => (&mut self.audio_settings.start_time, &mut self.audio_settings.end_time), - ExportType::Image => return, // image uses a single time field, not a range + ExportType::Image | ExportType::Svg => return, // single time field, not a range ExportType::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time), }; @@ -669,6 +692,7 @@ impl ExportDialog { } Some(ExportResult::Image(self.image_settings.clone(), output_path)) } + ExportType::Svg => Some(ExportResult::Svg(self.image_settings.time, output_path)), ExportType::Audio => { // Validate audio settings if let Err(err) = self.audio_settings.validate() { diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 07fe8a0..7073e2d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -60,6 +60,7 @@ mod test_mode; mod sample_import; mod sample_import_dialog; +mod svg_import; mod curve_editor; @@ -3238,7 +3239,11 @@ impl EditorApp { .and_then(|e| e.to_str()) .unwrap_or(""); - let imported_asset = match get_file_type(extension) { + // SVG imports as a new vector layer (not a placeable asset). + let imported_asset = if extension.eq_ignore_ascii_case("svg") { + self.import_svg_file(&path); + None + } else { match get_file_type(extension) { Some(FileType::Image) => { self.last_import_filter = ImportFilter::Images; self.import_image(&path) @@ -3255,11 +3260,12 @@ impl EditorApp { self.last_import_filter = ImportFilter::Midi; self.import_midi(&path) } + Some(FileType::Vector) => None, // handled by the svg intercept above None => { println!("Unsupported file type: {}", extension); None } - }; + } }; eprintln!("[TIMING] import took {:.1}ms", _import_timer.elapsed().as_secs_f64() * 1000.0); // Auto-place if this is "Import" (not "Import to Library") @@ -4512,6 +4518,53 @@ impl EditorApp { } /// Import an image file as an ImageAsset + /// Import an `.svg` file as a new vector layer (one static keyframe at the playhead). + fn import_svg_file(&mut self, path: &std::path::Path) { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) => { + eprintln!("❌ Failed to read SVG {}: {}", path.display(), e); + return; + } + }; + + let graph = match svg_import::import_svg(&bytes) { + Ok(g) => g, + Err(e) => { + eprintln!("❌ {}", e); + return; + } + }; + + let name = path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("SVG") + .to_string(); + + // Build a vector layer holding the imported graph as a keyframe at the current time. + let mut layer = lightningbeam_core::layer::VectorLayer::new(name); + let mut keyframe = lightningbeam_core::layer::ShapeKeyframe::new(self.playback_time); + keyframe.graph = graph; + layer.keyframes.push(keyframe); + + let editing_clip_id = self.editing_context.current_clip_id(); + let action = lightningbeam_core::actions::AddLayerAction::new( + lightningbeam_core::layer::AnyLayer::Vector(layer), + ) + .with_target_clip(editing_clip_id); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("❌ Failed to add imported SVG layer: {}", e); + return; + } + + // Select the newly created layer. + let context_layers = self.action_executor.document().context_layers(editing_clip_id.as_ref()); + if let Some(last_layer) = context_layers.last() { + self.active_layer_id = Some(last_layer.id()); + } + self.last_import_filter = ImportFilter::Images; + } + fn import_image(&mut self, path: &std::path::Path) -> Option { use lightningbeam_core::clip::ImageAsset; self.note_possible_large_media(path); @@ -6132,6 +6185,19 @@ impl eframe::App for EditorApp { ); false // image export is silent (no progress dialog) } + ExportResult::Svg(time, output_path) => { + println!("🖋 [MAIN] Exporting SVG: {}", output_path.display()); + let svg = lightningbeam_core::svg_export::document_to_svg( + self.action_executor.document(), + time, + ); + if let Err(err) = std::fs::write(&output_path, svg) { + eprintln!("❌ Failed to write SVG: {}", err); + } else { + println!("✅ SVG written: {}", output_path.display()); + } + false // synchronous; no progress dialog + } ExportResult::AudioOnly(settings, output_path) => { println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display()); diff --git a/lightningbeam-ui/lightningbeam-editor/src/svg_import.rs b/lightningbeam-ui/lightningbeam-editor/src/svg_import.rs new file mode 100644 index 0000000..c0cf4b1 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/svg_import.rs @@ -0,0 +1,315 @@ +//! SVG import → `VectorGraph`. +//! +//! Parses an `.svg` with usvg (which resolves CSS, converts shapes/rects/circles to +//! paths, and computes absolute transforms), then bakes each path's absolute transform +//! into geometry and builds a single [`VectorGraph`] that becomes one new vector layer. +//! +//! Scope (matches the export pass): paths with solid/gradient fills and strokes. `` +//! and `` nodes are skipped, and nested groups are flattened (their transforms are +//! already baked into each path's `abs_transform`). +//! +//! Known limitation: imported edges are NOT intersection-split, so the paint-bucket tool +//! may need to re-process imported art. Display, transform, and round-trip are fine. + +use kurbo::{CubicBez, Point as KPoint}; +use lightningbeam_core::gradient::{GradientExtend, GradientStop, GradientType, ShapeGradient}; +use lightningbeam_core::shape::{Cap, FillRule, Join, ShapeColor, StrokeStyle}; +use lightningbeam_core::vector_graph::{Direction, EdgeId, VectorGraph, VertexId}; +use resvg::usvg; +use usvg::tiny_skia_path::{PathSegment, Point as SkPoint}; + +/// Parse SVG bytes into a single flattened [`VectorGraph`] in document (canvas) space. +pub fn import_svg(bytes: &[u8]) -> Result { + let tree = usvg::Tree::from_data(bytes, &usvg::Options::default()) + .map_err(|e| format!("Failed to parse SVG: {e}"))?; + let mut graph = VectorGraph::new(); + walk_group(tree.root(), &mut graph); + if graph.edges.is_empty() { + return Err("SVG contained no importable vector paths".to_string()); + } + Ok(graph) +} + +fn walk_group(group: &usvg::Group, graph: &mut VectorGraph) { + for node in group.children() { + match node { + usvg::Node::Group(g) => walk_group(g, graph), + usvg::Node::Path(p) => convert_path(p, graph), + usvg::Node::Image(_) | usvg::Node::Text(_) => {} // skipped this pass + } + } +} + +fn convert_path(path: &usvg::Path, graph: &mut VectorGraph) { + if !path.is_visible() { + return; + } + let ts = path.abs_transform(); + // Bake the absolute transform into the geometry so everything lives in canvas space. + let Some(data) = path.data().clone().transform(ts) else { + return; + }; + + // One stroke style/colour shared by every edge of this path. + let stroke = path.stroke().map(|s| stroke_to_style(s, ts)); + + // Walk the (transformed) segments, allocating vertices/edges and recording the + // boundary cycle. `EdgeId::NONE` separates subpaths (outer contour + holes). + let mut boundary: Vec<(EdgeId, Direction)> = Vec::new(); + let mut have_subpath = false; + let mut cur_v = VertexId(0); + let mut cur_p = SkPoint::from_xy(0.0, 0.0); + let mut start_v = VertexId(0); + let mut start_p = SkPoint::from_xy(0.0, 0.0); + + for seg in data.segments() { + match seg { + PathSegment::MoveTo(p) => { + if have_subpath { + boundary.push((EdgeId::NONE, Direction::Forward)); + } + let v = graph.alloc_vertex(kp(p)); + cur_v = v; + cur_p = p; + start_v = v; + start_p = p; + have_subpath = true; + } + PathSegment::LineTo(p) => { + let (c1, c2) = line_ctrls(cur_p, p); + cur_v = add_edge(graph, &mut boundary, cur_v, cur_p, c1, c2, p, &stroke); + cur_p = p; + } + PathSegment::QuadTo(c, p) => { + let (c1, c2) = quad_to_cubic(cur_p, c, p); + cur_v = add_edge(graph, &mut boundary, cur_v, cur_p, c1, c2, p, &stroke); + cur_p = p; + } + PathSegment::CubicTo(c1, c2, p) => { + cur_v = add_edge(graph, &mut boundary, cur_v, cur_p, c1, c2, p, &stroke); + cur_p = p; + } + PathSegment::Close => { + // Close back to the subpath start (reusing its vertex) unless already there. + if cur_p != start_p { + let (c1, c2) = line_ctrls(cur_p, start_p); + let curve = CubicBez::new(kp(cur_p), kp(c1), kp(c2), kp(start_p)); + let (style, color) = split_stroke(&stroke); + let e = graph.alloc_edge(curve, cur_v, start_v, style, color); + boundary.push((e, Direction::Forward)); + } + cur_v = start_v; + cur_p = start_p; + } + } + } + + // Fill (if any) references the whole boundary cycle. + if let Some(fill) = path.fill() { + if !boundary.is_empty() { + let rule = match fill.rule() { + usvg::FillRule::NonZero => FillRule::NonZero, + usvg::FillRule::EvenOdd => FillRule::EvenOdd, + }; + let fid = graph.alloc_fill(boundary, None, rule); + let slot = &mut graph.fills[fid.idx()]; + match fill.paint() { + usvg::Paint::Color(c) => { + slot.color = Some(ShapeColor::rgba(c.red, c.green, c.blue, opacity_u8(fill.opacity()))); + } + usvg::Paint::LinearGradient(g) => { + slot.gradient_fill = Some(linear_gradient(g, ts)); + } + usvg::Paint::RadialGradient(g) => { + slot.gradient_fill = Some(radial_gradient(g, ts)); + } + usvg::Paint::Pattern(_) => { + // Patterns aren't representable yet — neutral gray so the shape stays visible. + slot.color = Some(ShapeColor::rgba(128, 128, 128, opacity_u8(fill.opacity()))); + } + } + } + } +} + +/// Allocate the end vertex + a cubic edge from `av`/`ap` to `bp`, recording it on the boundary. +fn add_edge( + graph: &mut VectorGraph, + boundary: &mut Vec<(EdgeId, Direction)>, + av: VertexId, + ap: SkPoint, + c1: SkPoint, + c2: SkPoint, + bp: SkPoint, + stroke: &Option<(StrokeStyle, ShapeColor)>, +) -> VertexId { + let bv = graph.alloc_vertex(kp(bp)); + let curve = CubicBez::new(kp(ap), kp(c1), kp(c2), kp(bp)); + let (style, color) = split_stroke(stroke); + let e = graph.alloc_edge(curve, av, bv, style, color); + boundary.push((e, Direction::Forward)); + bv +} + +fn split_stroke(stroke: &Option<(StrokeStyle, ShapeColor)>) -> (Option, Option) { + match stroke { + Some((s, c)) => (Some(s.clone()), Some(*c)), + None => (None, None), + } +} + +fn stroke_to_style(s: &usvg::Stroke, ts: usvg::Transform) -> (StrokeStyle, ShapeColor) { + let scale = transform_scale(ts) as f64; + let style = StrokeStyle { + width: s.width().get() as f64 * scale, + cap: match s.linecap() { + usvg::LineCap::Butt => Cap::Butt, + usvg::LineCap::Round => Cap::Round, + usvg::LineCap::Square => Cap::Square, + }, + join: match s.linejoin() { + usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => Join::Miter, + usvg::LineJoin::Round => Join::Round, + usvg::LineJoin::Bevel => Join::Bevel, + }, + miter_limit: s.miterlimit().get() as f64, + }; + let color = match s.paint() { + usvg::Paint::Color(c) => ShapeColor::rgba(c.red, c.green, c.blue, opacity_u8(s.opacity())), + // Gradient/pattern strokes aren't representable per-edge — fall back to opaque black. + _ => ShapeColor::rgba(0, 0, 0, opacity_u8(s.opacity())), + }; + (style, color) +} + +/// Geometric-mean scale of the transform's linear part (for stroke-width baking). +fn transform_scale(ts: usvg::Transform) -> f32 { + (ts.sx * ts.sy - ts.kx * ts.ky).abs().sqrt() +} + +fn linear_gradient(g: &usvg::LinearGradient, abs: usvg::Transform) -> ShapeGradient { + let ct = abs.pre_concat(g.transform()); + let start = map_pt(ct, g.x1(), g.y1()); + let end = map_pt(ct, g.x2(), g.y2()); + let angle = (end.1 - start.1).atan2(end.0 - start.0).to_degrees() as f32; + ShapeGradient { + kind: GradientType::Linear, + stops: gradient_stops(g), + angle, + extend: spread(g), + start_world: Some(start), + end_world: Some(end), + } +} + +fn radial_gradient(g: &usvg::RadialGradient, abs: usvg::Transform) -> ShapeGradient { + let ct = abs.pre_concat(g.transform()); + // Our model stores center as start_world and a rim point (defining the radius) as end_world. + let center = map_pt(ct, g.cx(), g.cy()); + let rim = map_pt(ct, g.cx() + g.r().get(), g.cy()); + ShapeGradient { + kind: GradientType::Radial, + stops: gradient_stops(g), + angle: 0.0, + extend: spread(g), + start_world: Some(center), + end_world: Some(rim), + } +} + +fn gradient_stops(base: &usvg::BaseGradient) -> Vec { + base.stops() + .iter() + .map(|s| GradientStop { + position: s.offset().get(), + color: ShapeColor::rgba(s.color().red, s.color().green, s.color().blue, opacity_u8(s.opacity())), + }) + .collect() +} + +fn spread(base: &usvg::BaseGradient) -> GradientExtend { + match base.spread_method() { + usvg::SpreadMethod::Pad => GradientExtend::Pad, + usvg::SpreadMethod::Reflect => GradientExtend::Reflect, + usvg::SpreadMethod::Repeat => GradientExtend::Repeat, + } +} + +// ── small geometry helpers ────────────────────────────────────────────────── + +fn kp(p: SkPoint) -> KPoint { + KPoint::new(p.x as f64, p.y as f64) +} + +fn map_pt(ts: usvg::Transform, x: f32, y: f32) -> (f64, f64) { + let mut p = SkPoint::from_xy(x, y); + ts.map_point(&mut p); + (p.x as f64, p.y as f64) +} + +fn lerp(a: SkPoint, b: SkPoint, t: f32) -> SkPoint { + SkPoint::from_xy(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t) +} + +/// Degenerate cubic control points for a straight segment (matches our edge model). +fn line_ctrls(a: SkPoint, b: SkPoint) -> (SkPoint, SkPoint) { + (lerp(a, b, 1.0 / 3.0), lerp(a, b, 2.0 / 3.0)) +} + +/// Elevate a quadratic Bézier to a cubic. +fn quad_to_cubic(a: SkPoint, c: SkPoint, b: SkPoint) -> (SkPoint, SkPoint) { + let c1 = SkPoint::from_xy(a.x + 2.0 / 3.0 * (c.x - a.x), a.y + 2.0 / 3.0 * (c.y - a.y)); + let c2 = SkPoint::from_xy(b.x + 2.0 / 3.0 * (c.x - b.x), b.y + 2.0 / 3.0 * (c.y - b.y)); + (c1, c2) +} + +fn opacity_u8(o: usvg::Opacity) -> u8 { + (o.get() * 255.0).round().clamp(0.0, 255.0) as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn imports_solid_rect_fill() { + let svg = br##""##; + let g = import_svg(svg).expect("import"); + assert!(!g.edges.is_empty(), "expected edges from the rect"); + let fills: Vec<_> = g.fills.iter().filter(|f| !f.deleted).collect(); + assert_eq!(fills.len(), 1, "one fill expected"); + let c = fills[0].color.expect("solid color"); + assert_eq!((c.r, c.g, c.b), (255, 0, 0), "red fill"); + } + + #[test] + fn imports_stroke_only() { + let svg = br##""##; + let g = import_svg(svg).expect("import"); + let stroked = g.edges.iter().filter(|e| !e.deleted && e.stroke_color.is_some()).count(); + assert!(stroked >= 1, "expected at least one stroked edge"); + let c = g.edges.iter().find_map(|e| e.stroke_color).unwrap(); + assert_eq!((c.r, c.g, c.b), (0, 255, 0), "green stroke"); + } + + #[test] + fn imports_linear_gradient() { + let svg = br##" + + + + "##; + let g = import_svg(svg).expect("import"); + let fills: Vec<_> = g.fills.iter().filter(|f| !f.deleted).collect(); + assert_eq!(fills.len(), 1); + let grad = fills[0].gradient_fill.as_ref().expect("gradient"); + assert_eq!(grad.stops.len(), 2); + assert!(grad.start_world.is_some() && grad.end_world.is_some()); + } + + #[test] + fn empty_svg_errors() { + let svg = br#""#; + assert!(import_svg(svg).is_err()); + } +}