From 2d216151c688fb74558128e8bd7943c8a0ba0ad1 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 15 Oct 2026 13:33:50 +0530 Subject: [PATCH 1/2] Import: Composition - Retain Layer Sizes for PDF / Illustrator files (#1536) The PDF / Illustrator import dialog now offers Composition - Retain Layer Sizes. Each file layer's footage is cropped to its artwork bounds (fills, stroke outlines, images, clips) and positioned to keep its place on the page. Decoding, continuous rasterisation, shape conversion and reload share one crop (effectcraft_pdf::layer_doc_with_sizes), so they agree. Also collapses a match guard in the STL reader for current stable clippy. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/file.rs | 6 +- crates/engine/src/footage_reload.rs | 7 ++ crates/engine/src/vector.rs | 26 +++++-- crates/host/tests/vector_import.rs | 108 ++++++++++++++++++++++----- crates/media/src/layered.rs | 2 +- crates/model/src/stl.rs | 6 +- crates/pdf/README.md | 4 +- crates/pdf/src/lib.rs | 21 ++++++ crates/pdf/src/tests.rs | 45 +++++++++++ crates/svg/src/bounds.rs | 64 ++++++++++++++++ crates/svg/src/lib.rs | 23 ++++++ crates/svg/src/raster.rs | 18 +---- crates/ui-egui/src/panels/forms.rs | 11 ++- crates/ui-egui/tests/ui_drag_drop.rs | 25 +++++++ 14 files changed, 314 insertions(+), 52 deletions(-) create mode 100644 crates/svg/src/bounds.rs diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 3b43eb633..501d15482 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -391,7 +391,7 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { Ok(b) if effectcraft_psd::is_psd(&b) => b, Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => { // PDF / Illustrator / EPS: one layer per file layer. - match import_vector_comp(s, &path, &b, page) { + match import_vector_comp(s, &path, &b, page, retain) { Ok((comp, made)) => { comps.push(comp); items.extend(made); @@ -540,7 +540,7 @@ pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec, retai /// Import page `page` of a PDF / Illustrator / EPS file as a composition (one undo step). /// Returns (comp, items). -fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> Result<(u64, Vec)> { +fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32, retain: bool) -> Result<(u64, Vec)> { let mut name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into()); if page > 0 { name = format!("{name} (Page {})", page + 1); @@ -549,7 +549,7 @@ fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> R let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 }; let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs)); let (comp, folder, items) = s.edit("Import", None, |proj, st| { - let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, rate, duration).map_err(EngineError::Other)?; + let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, retain, (rate, duration)).map_err(EngineError::Other)?; st.project_selection = vec![r.0]; Ok(r) })?; diff --git a/crates/engine/src/footage_reload.rs b/crates/engine/src/footage_reload.rs index a8f32a379..ca51069a4 100644 --- a/crates/engine/src/footage_reload.rs +++ b/crates/engine/src/footage_reload.rs @@ -58,6 +58,12 @@ fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u Some((u32::try_from(found.index).ok()?, size)) } +fn vector_size(s: &Session, f: &Footage) -> Option<(u32, u32)> { + let bytes = s.services.read_file(&f.path).ok()?; + let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)?.ok()?; + (!doc.root.children.is_empty()).then(|| doc.pixel_size()) +} + /// Footage `f` as its file is now, given the importer's fresh look at the file (`probed`). fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { let mut nf = f.clone(); @@ -89,6 +95,7 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { match &f.layer { // A smart object's size is its embedded file's, recorded at import. Some(l) if l.embedded.is_some() => (w, h) = (f.width, f.height), + Some(_) if crate::vector::is_vector(f) => (w, h) = vector_size(s, f).unwrap_or((f.width, f.height)), Some(l) => match psd_layer(s, &f.path, l) { Some((index, size)) => { if let Some(nl) = nf.layer.as_mut() { diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index 23400953f..3c349a7df 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -271,7 +271,7 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project:: Some( effectcraft_pdf::parse_page(bytes, page as usize) .map(|d| match layer { - Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), + Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0, None => d, }) .map_err(|e| format!("{path}: {e}")), @@ -279,7 +279,7 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project:: } /// Import page `page` of a PDF / Illustrator / EPS document as a composition the size of the -/// page: one footage item per file layer (each showing only that layer, continuously +/// page: one footage item per file layer (optionally cropped to its artwork, continuously /// rasterisable), top layer first. Returns (comp, folder, items). pub fn import_vector_comp( proj: &mut Project, @@ -287,11 +287,12 @@ pub fn import_vector_comp( bytes: &[u8], name: &str, page: u32, - rate: effectcraft_time::FrameRate, - duration: effectcraft_time::Tick, + retain: bool, + timing: (effectcraft_time::FrameRate, effectcraft_time::Tick), ) -> Result<(ItemId, ItemId, Vec), String> { use effectcraft_color::Label; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer}; + let (rate, duration) = timing; let doc = effectcraft_pdf::parse_page(bytes, page as usize).map_err(|e| format!("{path}: {e}"))?; let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF"); let (w, h) = doc.pixel_size(); @@ -301,24 +302,33 @@ pub fn import_vector_comp( let mut items = vec![]; let names = effectcraft_pdf::layer_names(&doc); for (i, lname) in names.iter().enumerate() { + let (size, offset) = if retain { + let (layer, offset) = effectcraft_pdf::layer_doc_with_sizes(&doc, i, true); + (layer.pixel_size(), offset) + } else { + ((w, h), [0.0, 0.0]) + }; let f = Footage { path: path.to_string(), kind: FootageKind::Still, - width: w, - height: h, + width: size.0, + height: size.1, pixel_aspect: 1.0, frame_rate: rate, has_video: true, alpha: AlphaMode::Straight, loop_count: 1, codec: codec.into(), - layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }), + layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: retain, ..Default::default() }), page, ..Default::default() }; let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f)); items.push(id); - let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, (w, h), None); + let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, size, None); + if retain && let Some(position) = l.props.prop_mut("transform/position") { + position.value = Value::Vec3([offset[0] + f64::from(size.0) / 2.0, offset[1] + f64::from(size.1) / 2.0, 0.0]); + } l.name = comp.unique_layer_name(lname); comp.layers.insert(0, l); } diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs index 44d2073f9..49f8d950c 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/vector_import.rs @@ -260,23 +260,25 @@ fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() { // Import As: Composition — one layer per file layer, top first, together like the page. let path = tmp("layers.ai"); std::fs::write(&path, &bytes).unwrap(); - let mut s = effectcraft_host::session(); - let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); - let cid = ItemId(r["comps"][0].as_u64().unwrap()); - let comp = s.project.comp(cid).unwrap(); - let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, vec!["Art", "Sky"]); - assert_eq!((comp.width, comp.height), (120, 80)); - let art = comp.layers[0].id.0; - let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); - let d = mean_diff(&img, &direct); - assert!(d < 1.0 / 255.0, "layered comp vs page {d}"); - // Each layer's footage shows only its layer. - s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap(); - let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); - assert_eq!(sky.get(5, 5), img.get(5, 5)); - let p = sky.get(60, 30); - assert!(p[2] > p[0], "only sky under the art {p:?}"); + for import_as in ["composition", "compositionLayerSizes"] { + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap(); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let comp = s.project.comp(cid).unwrap(); + let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["Art", "Sky"]); + assert_eq!((comp.width, comp.height), (120, 80)); + let art = comp.layers[0].id.0; + let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + let d = mean_diff(&img, &direct); + assert!(d < 1.0 / 255.0, "layered comp vs page {d}"); + // Each layer's footage shows only its layer. + s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap(); + let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + assert_eq!(sky.get(5, 5), img.get(5, 5)); + let p = sky.get(60, 30); + assert!(p[2] > p[0], "only sky under the art {p:?}"); + } } #[test] @@ -522,3 +524,75 @@ fn shapes_from_vector_layer_keeps_images_as_footage_layers() { assert!((0..3).all(|k| (a[k] - b[k]).abs() < 0.05), "({x}, {y}): {a:?} vs {b:?}"); } } + +fn retain_pdf(width: u32) -> Vec { + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /Properties << /L0 5 0 R /L1 6 0 R >> >> /Contents 4 0 R >>".into(), None), + ( + 4, + "<< >>".into(), + Some( + format!("/OC /L0 BDC q 20 15 {width} 20 re W n 1 0 0 rg 0 0 120 80 re f Q EMC /OC /L1 BDC 0 0 1 RG 4 w 70 40 m 100 40 l S EMC") + .into_bytes(), + ), + ), + (5, "<< /Type /OCG /Name (Box) >>".into(), None), + (6, "<< /Type /OCG /Name (Line) >>".into(), None), + ], + 1, + ) +} + +#[test] +fn ai_composition_retains_layer_sizes_and_positions() { + use effectcraft_project::{ItemKind, LayerSource, Project}; + use effectcraft_time::Tick; + + for name in ["retain.ai", "retain.pdf"] { + let path = tmp(name); + let bytes = retain_pdf(30); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let comp = s.project.comp(cid).unwrap(); + assert_eq!((comp.width, comp.height), (120, 80)); + assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::>(), ["Line", "Box"]); + let mut ids = vec![]; + for (name, size, anchor, position) in [("Box", (30, 20), [15.0, 10.0], [35.0, 55.0]), ("Line", (30, 4), [15.0, 2.0], [85.0, 40.0])] { + let l = comp.layer_by_name(name).unwrap(); + let LayerSource::Footage { item } = l.source else { panic!("footage") }; + let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") }; + assert_eq!((f.width, f.height), size, "{name} uses its artwork bounds"); + assert!(f.layer.as_ref().unwrap().layer_size); + assert_eq!(l.props.prop("transform/anchor").unwrap().value.as_vec2(), anchor); + assert_eq!(l.props.prop("transform/position").unwrap().value.as_vec2(), position); + let frame = s.footage.frame(item, f, Tick::ZERO).unwrap(); + assert_eq!((frame.width, frame.height), size); + let sharp = s.footage.vector_frame(item, f, 2.0).unwrap(); + assert_eq!((sharp.width, sharp.height), (size.0 * 2, size.1 * 2)); + assert!(sharp.get(1, 1)[3] > 0.99); + ids.push(item); + } + let direct = effectcraft_svg::rasterize(&effectcraft_pdf::parse(&bytes).unwrap(), 120, 80, 1.0); + assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001); + let saved = s.project.to_json(); + s.project = std::sync::Arc::new(Project::from_json(&saved).unwrap()); + assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001, "reopened project"); + let box_id = s.active_comp().unwrap().layer_by_name("Box").unwrap().id.0; + s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [box_id]})).unwrap(); + assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001, "shape conversion preserves placement"); + s.execute_checked("edit.undo", json!({})).unwrap(); + std::fs::write(&path, retain_pdf(40)).unwrap(); + s.execute_checked("file.reloadFootage", json!({"items": ids})).unwrap(); + let ItemKind::Footage(f) = &s.project.item(ids[0]).unwrap().kind else { panic!("footage") }; + assert_eq!((f.width, f.height), (40, 20), "reload refreshes the layer's own dimensions"); + let frame = s.footage.frame(ids[0], f, Tick::ZERO).unwrap(); + assert_eq!((frame.width, frame.height), (40, 20)); + assert!(frame.get(39, 10)[0] > 0.99, "reload invalidates cached pixels"); + } +} diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index 0fb40d81c..0a70d1f7f 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -28,7 +28,7 @@ pub fn vector_doc( Some( effectcraft_pdf::parse_page(bytes, page as usize) .map(|d| match layer { - Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), + Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0, None => d, }) .map_err(|e| format!("{path}: {e}")), diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs index 6684858ad..34e9a7f06 100644 --- a/crates/model/src/stl.rs +++ b/crates/model/src/stl.rs @@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result { let Some(key) = it.next() else { continue }; let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); match key.to_ascii_lowercase().as_str() { - "solid" => { - if name.is_none() { - name = Some(line.get(5..).unwrap_or("").trim().to_string()); - } + "solid" if name.is_none() => { + name = Some(line.get(5..).unwrap_or("").trim().to_string()); } "facet" => { // `facet normal nx ny nz` diff --git a/crates/pdf/README.md b/crates/pdf/README.md index b7678b096..b44e3165d 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -4,7 +4,9 @@ Vector footage from PDF files, PDF-compatible Adobe Illustrator files (`.ai`, wh documents) and Encapsulated PostScript (`.eps`). Files parse into the same render tree as SVG (`effectcraft_svg::Doc`), so they rasterise at any scale (Continuously Rasterize), convert to shape layers (Layer ▸ Create ▸ Create Shapes from Vector Layer) and import as compositions with one -layer per file layer (File ▸ Import ▸ Composition). +layer per file layer (File ▸ Import ▸ Composition). Composition - Retain Layer Sizes uses each +layer's artwork bounds and keeps its placement on the page. Images keep their rectangular bounds; +clipping paths constrain the bounds, while soft masks do not change them. Specifications used: **ISO 32000-1:2008** (PDF 1.7; the public Adobe edition) for the file structure, filters, content streams, graphics state, colour spaces, functions, shadings, text, diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 39d150749..cf3f5ca6b 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -194,6 +194,27 @@ pub fn layer_doc(doc: &Doc, index: usize) -> Doc { d } +/// One layer, optionally cropped to its artwork, and its original document-space top-left. +pub fn layer_doc_with_sizes(doc: &Doc, index: usize, retain: bool) -> (Doc, [f64; 2]) { + let mut d = layer_doc(doc, index); + if !retain { + return (d, [0.0, 0.0]); + } + let page = kurbo::Rect::new(0.0, 0.0, doc.width, doc.height); + let bounds = d.artwork_bounds().map(|b| b.intersect(page)).filter(|b| b.width() > 0.0 && b.height() > 0.0); + let (x, y, w, h) = match bounds { + Some(b) => { + let (x, y) = (b.x0.floor(), b.y0.floor()); + (x, y, b.x1.ceil() - x, b.y1.ceil() - y) + } + None => (0.0, 0.0, 1.0, 1.0), + }; + d.width = w; + d.height = h; + d.root.transform = Affine::translate((-x, -y)) * d.root.transform; + (d, [x, y]) +} + #[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 57b7e0448..e626ca40c 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1020,3 +1020,48 @@ fn multiple_pages_and_calculator_shadings() { assert!(img.get(5, 25)[0] < 0.1 && img.get(95, 25)[0] > 0.9, "{:?} {:?}", img.get(5, 25), img.get(95, 25)); assert_eq!(parse_page(&bytes, 2), Err(Error::PageOutOfRange(2))); } + +#[test] +fn retained_layers_keep_strokes_clips_and_empty_layers() { + let doc = parse(&sample_pdf(false)).unwrap(); + for i in 0..layer_names(&doc).len() { + let full = layer_doc(&doc, i); + let (cropped, offset) = layer_doc_with_sizes(&doc, i, true); + let (w, h) = cropped.pixel_size(); + let image = effectcraft_svg::rasterize(&cropped, w, h, 1.0); + let original = effectcraft_svg::rasterize(&full, 200, 100, 1.0); + for y in 0..100 { + for x in 0..200 { + let a = image.get(x - offset[0] as i64, y - offset[1] as i64); + let b = original.get(x, y); + assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-5), "layer {i} at {x},{y}: {a:?} vs {b:?}"); + } + } + } + let (empty, _) = layer_doc_with_sizes(&doc, usize::MAX, true); + assert_eq!(empty.pixel_size(), (1, 1)); +} + +#[test] +fn retained_layers_measure_transformed_artwork_without_rasterizing() { + let doc = effectcraft_svg::parse(br#""#).unwrap(); + let (rotated, offset) = layer_doc_with_sizes(&doc, 0, true); + assert_eq!(offset, [30.0, 10.0]); + assert_eq!(rotated.pixel_size(), (10, 30)); + let (stroked, offset) = layer_doc_with_sizes(&doc, 1, true); + assert_eq!(offset, [8.0, 18.0]); + assert_eq!(stroked.pixel_size(), (24, 4)); +} + +#[test] +fn retained_layers_preserve_artwork_beyond_the_bounds_depth_limit() { + let mut doc = effectcraft_svg::parse(br#""#).unwrap(); + for _ in 0..300 { + let mut group = Group::new("nested"); + group.children = std::mem::take(&mut doc.root.children); + doc.root.children.push(Node::Group(group)); + } + let (cropped, offset) = layer_doc_with_sizes(&doc, 0, true); + assert_eq!(cropped.pixel_size(), (120, 80), "unknown bounds keep the full page"); + assert_eq!(offset, [0.0, 0.0]); +} diff --git a/crates/svg/src/bounds.rs b/crates/svg/src/bounds.rs new file mode 100644 index 000000000..f0613aff8 --- /dev/null +++ b/crates/svg/src/bounds.rs @@ -0,0 +1,64 @@ +use kurbo::{Affine, BezPath, Rect, Shape as _}; + +use crate::{Doc, Group, Node}; + +impl Doc { + /// Geometric artwork bounds in document pixels, including strokes and clipping. + /// Opacity and soft masks do not change the artwork's dimensions. + pub fn artwork_bounds(&self) -> Option { + let page = Rect::new(0.0, 0.0, self.width, self.height); + group_bounds(&self.root, Affine::IDENTITY, 0, page) + } +} + +fn path_bounds(path: BezPath) -> Option { + if path.elements().is_empty() || !path.is_finite() { + return None; + } + let r = path.bounding_box(); + (r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r) +} + +fn group_bounds(group: &Group, transform: Affine, depth: usize, page: Rect) -> Option { + if depth > 256 { + return Some(page); + } + let transform = transform * group.transform; + if !transform.as_coeffs().iter().all(|v| v.is_finite()) { + return None; + } + let mut bounds: Option = None; + for node in &group.children { + let rect = match node { + Node::Group(g) => group_bounds(g, transform, depth + 1, page), + Node::Image(i) => { + let rect = Rect::new(0.0, 0.0, f64::from(i.width), f64::from(i.height)); + path_bounds(transform * i.transform * rect.to_path(0.01)) + } + Node::Shape(s) => { + let m = transform * s.transform; + let path = s.geom.to_path(); + let mut rect = s.fill.as_ref().and_then(|_| path_bounds(m * &path)); + if let Some(stroke) = &s.stroke { + let scale = m.determinant().abs().sqrt(); + if scale.is_finite() + && path.is_finite() + && let Some(outline) = effectcraft_path::stroke_outline(std::slice::from_ref(&path), &stroke.style(), scale) + && let Some(r) = path_bounds(m * outline) + { + rect = Some(rect.map_or(r, |b| b.union(r))); + } + } + rect + } + }; + if let Some(r) = rect { + bounds = Some(bounds.map_or(r, |b| b.union(r))); + } + } + let mut bounds = bounds?; + for (clip, _) in &group.clip { + bounds = bounds.intersect(path_bounds(transform * clip)?); + } + (bounds.width() > 0.0 && bounds.height() > 0.0).then_some(bounds) +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 7310814bf..bcffb8004 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod bounds; mod color; mod pathdata; mod raster; @@ -108,6 +109,28 @@ pub struct Stroke { pub dash: Option<(Vec, f64)>, } +impl Stroke { + fn style(&self) -> effectcraft_path::StrokeStyle { + effectcraft_path::StrokeStyle { + width: self.width, + cap: match self.cap { + Cap::Butt => effectcraft_path::Cap::Butt, + Cap::Round => effectcraft_path::Cap::Round, + Cap::Square => effectcraft_path::Cap::Square, + }, + join: match self.join { + Join::Miter => effectcraft_path::Join::Miter, + Join::Round => effectcraft_path::Join::Round, + Join::Bevel => effectcraft_path::Join::Bevel, + }, + miter: self.miter, + dash: self.dash.clone(), + taper: None, + wave: None, + } + } +} + /// Shape geometry in its user space (basic shapes are kept so they can become parametric shape /// layer items). #[derive(Clone, Debug, PartialEq)] diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs index b1b9d93b6..af59e33db 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -405,23 +405,7 @@ fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) { paint(dst, &cov, fill, (opacity * s.fill_opacity) as f32, m, bbox); } if let Some(st) = &s.stroke { - let style = effectcraft_path::StrokeStyle { - width: st.width, - cap: match st.cap { - crate::Cap::Butt => effectcraft_path::Cap::Butt, - crate::Cap::Round => effectcraft_path::Cap::Round, - crate::Cap::Square => effectcraft_path::Cap::Square, - }, - join: match st.join { - crate::Join::Miter => effectcraft_path::Join::Miter, - crate::Join::Round => effectcraft_path::Join::Round, - crate::Join::Bevel => effectcraft_path::Join::Bevel, - }, - miter: st.miter, - dash: st.dash.clone(), - taper: None, - wave: None, - }; + let style = st.style(); let cov = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &style, &mat3(m), w, h); paint(dst, &cov, &st.paint, (opacity * st.opacity) as f32, m, bbox); } diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 275e54b75..185c10336 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -228,7 +228,16 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { "file.import" if !has(p, &["importAs", "page"]) && is_pdf(p) => ( "Import PDF / Illustrator File".into(), vec![ - Field::choice("importAs", "Import Kind", &[("Footage", json!("footage")), ("Composition", json!("composition"))], 0), + Field::choice( + "importAs", + "Import Kind", + &[ + ("Footage", json!("footage")), + ("Composition", json!("composition")), + ("Composition - Retain Layer Sizes", json!("compositionLayerSizes")), + ], + 0, + ), Field::num("page", "Page", 1.0), ], ), diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 77a3995d6..9b66f745c 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -584,3 +584,28 @@ fn a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation() { assert!(h.state().auto.find("form.field.missingFrames").is_none()); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn illustrator_import_offers_and_applies_retain_layer_sizes() { + let path = std::env::temp_dir().join(format!("effectcraft-retain-ui-{}.ai", std::process::id())); + std::fs::write(&path, b"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Contents 4 0 R >> endobj\n4 0 obj << >> stream\n1 0 0 rg 20 15 30 20 re f\nendstream endobj\n%%EOF").unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(effectcraft_host::session())); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({"paths": [path]})).unwrap(); + h.run_steps(3); + assert!(h.query_by_label("Import PDF / Illustrator File").is_some()); + click_id(&mut h, "form.field.importAs"); + h.get_by_label("Composition - Retain Layer Sizes").click(); + h.run_steps(2); + click_id(&mut h, "form.ok"); + step_until(&mut h, |h| h.state().session.active_comp().is_some()); + let s = &h.state().session; + let comp = s.active_comp().unwrap(); + assert_eq!((comp.width, comp.height), (120, 80)); + assert_eq!(comp.layers.len(), 1); + let effectcraft_engine::project::LayerSource::Footage { item } = comp.layers[0].source else { panic!("footage") }; + let effectcraft_engine::project::ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") }; + assert_eq!((f.width, f.height), (30, 20)); + assert!(f.layer.as_ref().unwrap().layer_size); +} From 8d807e6e79d8b974038c1b9ffca53f4f9b773463 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 15 Oct 2026 14:58:22 +0530 Subject: [PATCH 2/2] Contributors: add arnav-hiwarkar Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 705feb257..3e6a78c3f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -78,3 +78,7 @@ sync_github_name = true [people.moecr7] real_name = "Moein Saharkhiz" display_name = "onlymoein" + +[people.arnav-hiwarkar] +display_name = "arnav-hiwarkar" +sync_github_name = false