From 9c20d06abd7091429ee92aeb2e7bff2deda24764 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 15:53:33 -0700 Subject: [PATCH] M7: with Contents only on, a slice in a frame or group exports only that container A slice exported every layer of its page that showed in its area, whatever its Contents only setting said. With Contents only on (the default), a slice inside a frame or group now exports only that frame or group and the layers in it. With it off, or for a slice on the page or in a section, the slice exports everything in its area as before. The Export section's preview of a slice follows its first export setting in the same way. --- crates/engine/src/cmd/file.rs | 37 +++++++++++++++++---- crates/engine/src/export_behaviour_tests.rs | 37 +++++++++++++++++++++ crates/ui-egui/src/assets_panel.rs | 15 +++++---- crates/ui-egui/src/export_preview_tests.rs | 17 ++++++++++ docs/file-format-parity.md | 3 +- 5 files changed, 94 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ec579d51..4bafa56d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -941,14 +941,37 @@ pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> O .reduce(|a, b| a.union(b)) } -/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off, -/// and always for slices) every top-level layer of their page, so whatever shows in the area is in. -fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { - let slice = ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice))); - match ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) { - Some(page) if slice || !setting.contents_only => page.children.clone(), - _ => super::edit::z_sorted(doc, ids), +/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off) +/// every top-level layer of their page, so whatever shows in the area is in. A slice paints nothing +/// itself: with contents only on, a slice inside a frame or group draws that frame or group, and a +/// slice anywhere else draws every top-level layer of its page. +pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { + let Some(page) = ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) else { + return super::edit::z_sorted(doc, ids); + }; + if !setting.contents_only { + return page.children.clone(); } + let mut own = Vec::with_capacity(ids.len()); + for &i in ids { + if !doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)) { + own.push(i); + continue; + } + match slice_container(doc, i) { + Some(c) => own.push(c), + None => return page.children.clone(), + } + } + own.sort_unstable(); + own.dedup(); + super::edit::z_sorted(doc, &super::edit::roots(doc, &own)) +} + +/// The frame or group a slice sits in: None on the page or in a section. +fn slice_container(doc: &Document, slice: NodeId) -> Option { + let parent = doc.node(slice)?.parent?; + doc.node(parent).is_some_and(|p| p.kind.is_frame_like() || matches!(p.kind, NodeKind::Group)).then_some(parent) } /// [`export_bytes`] with an export setting's format and options (its size is `scale`: see diff --git a/crates/engine/src/export_behaviour_tests.rs b/crates/engine/src/export_behaviour_tests.rs index 6896ce3e..ec1a56e9 100644 --- a/crates/engine/src/export_behaviour_tests.rs +++ b/crates/engine/src/export_behaviour_tests.rs @@ -256,6 +256,43 @@ fn masks_and_slices_export_what_shows() { let _ = under; } +/// Ignore overlapping layers (contents only) on a slice: inside a frame or group the slice exports +/// only what is in that container; off, or on the page, everything in its area. +#[test] +fn a_slice_in_a_frame_or_group_exports_only_that_container() { + let mut s = Session::new(); + let slice_in = |s: &mut Session, parent: Option, x: f64| { + let mut p = json!({"type": "slice", "x": x, "y": 25, "width": 50, "height": 50}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(&ex(s, "node.create", p)) + }; + let at = |s: &mut Session, p: Value| pixels(&export(s, p)).get_pixel(40, 40).0; + // A frame holding a red square and a slice, with a blue square on the page above it. + let f = frame(&mut s, 0.0, 0.0, 100.0, 100.0, false); + rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#FF0000", Some(f)); + let in_frame = slice_in(&mut s, Some(f), 25.0); + rect(&mut s, 50.0, 50.0, 100.0, 100.0, "#0000FF", None); + assert_eq!(png_size(&export(&mut s, json!({"ids": [in_frame]}))), (50, 50), "the slice still exports its own area"); + assert_eq!(at(&mut s, json!({"ids": [in_frame]})), [255, 0, 0, 255], "on by default: only the frame's content, the blue square left out"); + assert_eq!(at(&mut s, json!({"ids": [in_frame], "contentsOnly": false})), [0, 0, 255, 255], "off: everything in the slice's area"); + // The same in a group. + let red = rect(&mut s, 300.0, 0.0, 100.0, 100.0, "#FF0000", None); + let grouped = slice_in(&mut s, None, 325.0); + let g = id(&ex(&mut s, "object.group", json!({"ids": [red, grouped]}))); + assert_eq!(s.doc().unwrap().node(grouped).unwrap().parent, Some(g)); + rect(&mut s, 350.0, 50.0, 100.0, 100.0, "#0000FF", None); + assert_eq!(at(&mut s, json!({"ids": [grouped]})), [255, 0, 0, 255], "in a group: only the group's content"); + assert_eq!(at(&mut s, json!({"ids": [grouped], "contentsOnly": false})), [0, 0, 255, 255], "off: everything in its area"); + // On the page the option has no effect. + rect(&mut s, 600.0, 0.0, 100.0, 100.0, "#FF0000", None); + let top = slice_in(&mut s, None, 625.0); + rect(&mut s, 650.0, 50.0, 100.0, 100.0, "#0000FF", None); + assert_eq!(at(&mut s, json!({"ids": [top]})), [0, 0, 255, 255], "on the page: everything in its area, with the option on"); + assert_eq!(at(&mut s, json!({"ids": [top], "contentsOnly": false})), [0, 0, 255, 255], "on the page: everything in its area, with it off"); +} + #[test] fn several_layers_and_frames_to_pdf() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 36636c3d..288e3d2a 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -195,19 +195,20 @@ pub(crate) fn thumbnail(ctx: &egui::Context, doc: &Document, id: NodeId, px: f32 Some(ctx.load_texture(format!("asset-thumb-{}", id.0), img, egui::TextureOptions::LINEAR)) } -/// The pixels a thumbnail shows. A slice paints nothing itself, so it shows the page content -/// under it, exactly as exporting one does (`figcraft_engine::cmd::file::drawn_layers`): without -/// this a slice's preview is a blank checkerboard next to a correct export. +/// The pixels a thumbnail shows. A slice paints nothing itself, so it shows the content under it +/// that exporting it with its first export setting writes (`figcraft_engine::cmd::file::drawn_layers`): +/// without this a slice's preview is a blank checkerboard next to a correct export. pub(crate) fn thumbnail_pixmap(doc: &Document, id: NodeId, px: f32) -> Option { - let slice = doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Slice)); - let b = if slice { doc.world_bounds(id)? } else { figcraft_render::export_bounds(doc, id)? }; + let slice = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Slice)); + let b = if slice.is_some() { doc.world_bounds(id)? } else { figcraft_render::export_bounds(doc, id)? }; let side = b.width().max(b.height()); if !(side.is_finite() && side > 0.0) { return None; } let scale = (f64::from(px) / side).min(2.0); - if slice { - let layers = doc.page_of(id).and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default(); + if let Some(n) = slice { + let setting = n.exports.first().cloned().unwrap_or_default(); + let layers = figcraft_engine::cmd::file::drawn_layers(doc, &[id], &setting); return figcraft_render::render_area(doc, &layers, b, scale); } figcraft_render::render_node(doc, id, scale) diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs index d57e294d..468a5f17 100644 --- a/crates/ui-egui/src/export_preview_tests.rs +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -33,6 +33,23 @@ fn a_slice_previews_the_content_under_it() { assert!(paints(&pix), "the rectangle under the slice shows"); } +/// The preview follows the slice's first export setting: with Ignore overlapping layers (contents +/// only) on, a slice in a frame shows only the frame's content, as its export does. +#[test] +fn a_slice_in_a_frame_previews_only_the_frame() { + let mut s = Session::new(); + let new = |s: &mut Session, p: Value| NodeId(s.execute("node.create", &p).unwrap()["id"].as_u64().unwrap()); + let f = new(&mut s, json!({"type": "frame", "x": 0, "y": 0, "width": 200, "height": 100, "props": {"fills": []}})); + let slice = new(&mut s, json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100, "parent": f})); + new(&mut s, json!({"type": "rectangle", "x": 0, "y": 0, "width": 200, "height": 100})); + let pix = thumbnail_pixmap(s.doc().unwrap(), slice, 200.0).expect("the slice previews"); + assert_eq!([pix.width(), pix.height()], [200, 100]); + assert!(!paints(&pix), "the rectangle on the page above the empty frame is left out"); + s.execute("export.add", &json!({"ids": [slice], "setting": {"size": "1x", "contentsOnly": false}})).unwrap(); + let pix = thumbnail_pixmap(s.doc().unwrap(), slice, 200.0).expect("the slice previews"); + assert!(paints(&pix), "with the option off, the rectangle shows"); +} + #[test] fn a_slice_with_nothing_under_it_is_blank() { let (doc, ids) = doc_after(&[("node.create", json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100}))]); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 73fbc602..6ccc80d1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -49,7 +49,7 @@ export limit, not a native document nesting limit. | Format | Figma | FigCraft | Notes | Hours | |---|---|---|---|---| -| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them; sides round up to whole pixels | — | +| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them, except that with contents only on a slice inside a frame or group exports only that frame or group; sides round up to whole pixels | — | | JPG | yes | yes | white where transparent; fixed quality (90) | — | | SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `` with one `` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, nested frames clipped by their parent's clip (#327), simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — | | PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 | @@ -165,6 +165,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before | | 2026-10-24 | minor | Boards (M12): `.figcraft` saves the file kind (`kind: "board"`, absent for design files) and each board object's `board` metadata; the `.jam` row points at boards | | 2026-10-15 | minor | `.fig` import (#279, #281; #266, #267): resized instances lay out their layers by their constraints, and explicit variable modes whose collection is named by its asset key come across (before, they were dropped and bound layers resolved in the default mode); fixture-tested, real files still owed. `.figcraft`: atomic saves and exports through unique staging files (#284), optional auto-save (#305). SVG export keeps a nested frame's fill and stroke inside its parent's clip (#327). Numbers unchanged | | 2026-10-20 | minor | `.fig` export writes grid track sizes (`gridColumnsSizing` / `gridRowsSizing`): fixed px, fractional fill and content-sized tracks keep their sizes and come back through our own importer (#465). Tracks whose size the format can't hold (a negative or out-of-range value) keep their id and read back equal, with a warning. Numbers unchanged |