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:
parent
5869e3ced1
commit
1fa4d744be
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue