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 <path> per fill (solid or
gradient via <defs>) 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).
This commit is contained in:
Skyler Lehmkuhl 2026-06-26 16:40:29 -04:00
parent 5869e3ced1
commit 1fa4d744be
6 changed files with 678 additions and 6 deletions

View File

@ -15,7 +15,9 @@ pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "avi", "mkv", "webm", "m4v
/// Supported MIDI file extensions /// Supported MIDI file extensions
pub const MIDI_EXTENSIONS: &[&str] = &["mid", "midi"]; 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 // Note: .beam project files handled separately in file save/load feature
/// File type categories for import routing /// File type categories for import routing
@ -25,6 +27,7 @@ pub enum FileType {
Audio, Audio,
Video, Video,
Midi, Midi,
Vector,
} }
/// Detect file type from extension string /// Detect file type from extension string
@ -53,6 +56,9 @@ pub fn get_file_type(extension: &str) -> Option<FileType> {
if MIDI_EXTENSIONS.contains(&ext.as_str()) { if MIDI_EXTENSIONS.contains(&ext.as_str()) {
return Some(FileType::Midi); return Some(FileType::Midi);
} }
if VECTOR_EXTENSIONS.contains(&ext.as_str()) {
return Some(FileType::Vector);
}
None None
} }
@ -65,6 +71,7 @@ pub fn all_supported_extensions() -> Vec<&'static str> {
all.extend_from_slice(AUDIO_EXTENSIONS); all.extend_from_slice(AUDIO_EXTENSIONS);
all.extend_from_slice(VIDEO_EXTENSIONS); all.extend_from_slice(VIDEO_EXTENSIONS);
all.extend_from_slice(MIDI_EXTENSIONS); all.extend_from_slice(MIDI_EXTENSIONS);
all.extend_from_slice(VECTOR_EXTENSIONS);
all all
} }
@ -90,7 +97,8 @@ mod tests {
assert_eq!(get_file_type("midi"), Some(FileType::Midi)); assert_eq!(get_file_type("midi"), Some(FileType::Midi));
assert_eq!(get_file_type("unknown"), None); 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] #[test]

View File

@ -1344,7 +1344,7 @@ fn render_video_layer(
/// The axis is centred on the bbox midpoint and oriented at `angle_deg` degrees /// 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 /// (0 = left→right, 90 = top→bottom). The axis extends ± half the bbox diagonal
/// so the gradient covers the entire shape regardless of angle. /// 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 cx = bbox.center().x;
let cy = bbox.center().y; let cy = bbox.center().y;
let dx = bbox.width(); let dx = bbox.width();

View File

@ -218,3 +218,262 @@ fn cubic_to_svg_path(curve: &CubicBez) -> String {
curve.p3.x, curve.p3.y, 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 `<image>`). 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#"<rect x="0" y="0" width="{w:.3}" height="{h:.3}" {}/>"#,
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#"<svg xmlns="http://www.w3.org/2000/svg" width="{:.0}" height="{:.0}" "#,
r#"viewBox="0 0 {:.3} {:.3}"><defs>{}</defs>{}</svg>"#
),
w, h, w, h, defs, body
)
}
/// Append one layer's SVG. Recurses into groups (`<g>`); 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#"<g opacity="{opacity:.4}">"#));
}
vector_graph_to_svg(&graph, body, defs, grad_n);
if wrap {
body.push_str("</g>");
}
}
// 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#"<g opacity="{opacity:.4}">"#));
for child in &g.children {
layer_to_svg(child, time, 1.0, body, defs, grad_n);
}
body.push_str("</g>");
}
// Raster/Video/Audio/Effect have no lossless vector representation — skipped this pass.
_ => {}
}
}
/// Emit a vector graph's fills (`<path fill>`) and stroked edges (`<path stroke>`) 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#"<path fill="url(#{id})" fill-rule="{rule}" d="{d}"/>"#));
} else if fill.image_fill.is_some() {
// Image fills need <image>/<pattern> + asset embedding — skipped this (vector-only) pass.
continue;
} else if let Some(c) = &fill.color {
body.push_str(&format!(r#"<path {} fill-rule="{rule}" d="{d}"/>"#, fill_attrs(c)));
}
}
// Strokes: one <path> 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#"<path fill="none" {} stroke-width="{:.3}" stroke-linecap="{}" stroke-linejoin="{}" stroke-miterlimit="{:.3}" d="{d}"/>"#,
stroke_attrs(color), style.width, cap_str(style.cap), join_str(style.join), style.miter_limit
));
}
}
}
/// `<linearGradient>` / `<radialGradient>` 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##"<stop offset="{:.4}" stop-color="#{:02x}{:02x}{:02x}" stop-opacity="{:.4}"/>"##,
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#"<linearGradient id="{id}" gradientUnits="userSpaceOnUse" x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" spreadMethod="{spread}">{stops}</linearGradient>"#,
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#"<radialGradient id="{id}" gradientUnits="userSpaceOnUse" cx="{cx:.3}" cy="{cy:.3}" r="{r:.3}" spreadMethod="{spread}">{stops}</radialGradient>"#
)
}
}
}
/// 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 <path> elements.
assert_eq!(body.matches("<path").count(), 4, "{body}");
}
}

View File

@ -25,6 +25,8 @@ pub enum ExportType {
Audio, Audio,
Image, Image,
Video, Video,
/// Vector-only SVG of the current frame (lossless; raster/video layers skipped).
Svg,
} }
/// Export result from dialog /// Export result from dialog
@ -34,6 +36,8 @@ pub enum ExportResult {
Image(ImageExportSettings, PathBuf), Image(ImageExportSettings, PathBuf),
VideoOnly(VideoExportSettings, PathBuf), VideoOnly(VideoExportSettings, PathBuf),
VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf), VideoWithAudio(VideoExportSettings, AudioExportSettings, PathBuf),
/// SVG of vector layers at the given document time.
Svg(f64, PathBuf),
} }
/// Export dialog state /// Export dialog state
@ -156,6 +160,7 @@ impl ExportDialog {
ExportType::Audio => self.audio_settings.format.extension(), ExportType::Audio => self.audio_settings.format.extension(),
ExportType::Image => self.image_settings.format.extension(), ExportType::Image => self.image_settings.format.extension(),
ExportType::Video => self.video_settings.codec.container_format(), ExportType::Video => self.video_settings.codec.container_format(),
ExportType::Svg => "svg",
} }
} }
@ -198,6 +203,7 @@ impl ExportDialog {
ExportType::Audio => "Export Audio", ExportType::Audio => "Export Audio",
ExportType::Image => "Export Image", ExportType::Image => "Export Image",
ExportType::Video => "Export Video", ExportType::Video => "Export Video",
ExportType::Svg => "Export SVG",
}; };
let modal_response = egui::Modal::new(egui::Id::new("export_dialog_modal")) let modal_response = egui::Modal::new(egui::Id::new("export_dialog_modal"))
@ -219,6 +225,7 @@ impl ExportDialog {
(ExportType::Audio, "Audio"), (ExportType::Audio, "Audio"),
(ExportType::Image, "Image"), (ExportType::Image, "Image"),
(ExportType::Video, "Video"), (ExportType::Video, "Video"),
(ExportType::Svg, "SVG"),
] { ] {
if ui.selectable_value(&mut self.export_type, variant, label).clicked() { if ui.selectable_value(&mut self.export_type, variant, label).clicked() {
self.update_filename_extension(); self.update_filename_extension();
@ -235,6 +242,7 @@ impl ExportDialog {
ExportType::Audio => self.render_audio_basic(ui), ExportType::Audio => self.render_audio_basic(ui),
ExportType::Image => self.render_image_settings(ui), ExportType::Image => self.render_image_settings(ui),
ExportType::Video => self.render_video_basic(ui), ExportType::Video => self.render_video_basic(ui),
ExportType::Svg => self.render_svg_settings(ui),
} }
ui.add_space(12.0); ui.add_space(12.0);
@ -253,6 +261,7 @@ impl ExportDialog {
ExportType::Audio => self.render_audio_advanced(ui), ExportType::Audio => self.render_audio_advanced(ui),
ExportType::Image => self.render_image_advanced(ui), ExportType::Image => self.render_image_advanced(ui),
ExportType::Video => self.render_video_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). /// Render advanced image export settings (time, resolution override).
fn render_image_advanced(&mut self, ui: &mut egui::Ui) { fn render_image_advanced(&mut self, ui: &mut egui::Ui) {
// Time (which frame to export) // Time (which frame to export)
@ -595,7 +618,7 @@ impl ExportDialog {
fn render_time_range(&mut self, ui: &mut egui::Ui) { fn render_time_range(&mut self, ui: &mut egui::Ui) {
let (start_time, end_time) = match self.export_type { let (start_time, end_time) = match self.export_type {
ExportType::Audio => (&mut self.audio_settings.start_time, &mut self.audio_settings.end_time), 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), 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)) Some(ExportResult::Image(self.image_settings.clone(), output_path))
} }
ExportType::Svg => Some(ExportResult::Svg(self.image_settings.time, output_path)),
ExportType::Audio => { ExportType::Audio => {
// Validate audio settings // Validate audio settings
if let Err(err) = self.audio_settings.validate() { if let Err(err) = self.audio_settings.validate() {

View File

@ -60,6 +60,7 @@ mod test_mode;
mod sample_import; mod sample_import;
mod sample_import_dialog; mod sample_import_dialog;
mod svg_import;
mod curve_editor; mod curve_editor;
@ -3238,7 +3239,11 @@ impl EditorApp {
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
.unwrap_or(""); .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) => { Some(FileType::Image) => {
self.last_import_filter = ImportFilter::Images; self.last_import_filter = ImportFilter::Images;
self.import_image(&path) self.import_image(&path)
@ -3255,11 +3260,12 @@ impl EditorApp {
self.last_import_filter = ImportFilter::Midi; self.last_import_filter = ImportFilter::Midi;
self.import_midi(&path) self.import_midi(&path)
} }
Some(FileType::Vector) => None, // handled by the svg intercept above
None => { None => {
println!("Unsupported file type: {}", extension); println!("Unsupported file type: {}", extension);
None None
} }
}; } };
eprintln!("[TIMING] import took {:.1}ms", _import_timer.elapsed().as_secs_f64() * 1000.0); eprintln!("[TIMING] import took {:.1}ms", _import_timer.elapsed().as_secs_f64() * 1000.0);
// Auto-place if this is "Import" (not "Import to Library") // 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 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<ImportedAssetInfo> { fn import_image(&mut self, path: &std::path::Path) -> Option<ImportedAssetInfo> {
use lightningbeam_core::clip::ImageAsset; use lightningbeam_core::clip::ImageAsset;
self.note_possible_large_media(path); self.note_possible_large_media(path);
@ -6132,6 +6185,19 @@ impl eframe::App for EditorApp {
); );
false // image export is silent (no progress dialog) 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) => { ExportResult::AudioOnly(settings, output_path) => {
println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display()); println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display());

View File

@ -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. `<image>`
//! and `<text>` 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<VectorGraph, String> {
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<StrokeStyle>, Option<ShapeColor>) {
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<GradientStop> {
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##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect x="10" y="10" width="80" height="80" fill="#ff0000"/></svg>"##;
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##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><path d="M0 0 L50 50" fill="none" stroke="#00ff00" stroke-width="3"/></svg>"##;
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##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<defs><linearGradient id="g" x1="0" y1="0" x2="100" y2="0">
<stop offset="0" stop-color="#ff0000"/><stop offset="1" stop-color="#0000ff"/>
</linearGradient></defs>
<rect x="0" y="0" width="100" height="100" fill="url(#g)"/></svg>"##;
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#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>"#;
assert!(import_svg(svg).is_err());
}
}