diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 22690ac4..a0e8b3ce 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -21,6 +21,7 @@ pub mod rastersettings; mod reach; pub mod selection; pub mod setup; +pub mod slices; pub mod style_libs; pub mod swatches; pub mod text; @@ -60,6 +61,7 @@ pub use profiles::ColorProfiles; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; pub use selection::{AnchorRef, Selection}; pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; +pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; pub use text::{ AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, @@ -507,6 +509,13 @@ pub struct Document { /// lose them. Separate from [`Document::unknown`], which holds foreign data by design. #[serde(flatten)] pub extra: serde_json::Map, + /// User slices (Slice tool, Object → Slice); object slices are [`Node::slice`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub slices: Vec, + /// Object → Slice → Clip to Artboard: slices are clipped to the artboards and auto slices fill + /// them (on, the default); off, auto slices cover the art and the slices. + #[serde(default = "yes", skip_serializing_if = "skip::is_true")] + pub slices_clip_to_artboard: bool, } fn ppi72() -> f64 { @@ -569,6 +578,8 @@ impl Document { last_view: None, color_profiles: ColorProfiles::default(), extra: Default::default(), + slices: vec![], + slices_clip_to_artboard: true, }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); @@ -590,6 +601,7 @@ impl Document { for l in &self.layers { l.walk(&mut |n| max = max.max(n.id.0)); } + max = self.slices.iter().fold(max, |m, s| m.max(s.id.0)); self.next_id = self.next_id.max(max + 1); } @@ -1018,5 +1030,7 @@ mod tests { } } +#[cfg(test)] +mod tests_slices; #[cfg(test)] mod tests_units; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 378d4b2d..c9125a16 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -331,6 +331,10 @@ pub struct Node { /// Attributes panel: centre point display, image map, URL and note (`None`: all defaults). #[serde(default, skip_serializing_if = "Option::is_none")] pub attrs: Option>, + /// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with + /// these options. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slice: Option>, } /// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). @@ -378,6 +382,7 @@ impl Node { kind, graphic_style: None, attrs: None, + slice: None, } } pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 1f820d49..6c598f87 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -24,6 +24,11 @@ pub struct Selection { /// art selected). Any other selection change clears it. #[serde(default, skip_serializing_if = "Option::is_none")] pub target: Option, + /// Slices selected with the Slice Selection tool: user slice ids and the ids of objects whose + /// object slice is selected (see [`crate::slices`]). Any change to the object selection + /// clears them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub slices: Vec, } impl Selection { @@ -41,6 +46,7 @@ impl Selection { self.anchors.clear(); self.key = None; self.target = None; + self.slices.clear(); } pub fn set(&mut self, ids: impl IntoIterator) { self.clear(); @@ -50,18 +56,29 @@ impl Selection { } pub fn add(&mut self, id: NodeId) { self.target = None; + self.slices.clear(); if !self.objects.contains(&id) { self.objects.push(id); } } pub fn remove(&mut self, id: NodeId) { self.target = None; + self.slices.clear(); self.objects.retain(|x| *x != id); self.anchors.remove(&id); if self.key == Some(id) { self.key = None; } } + /// Select slices `ids` (see [`Selection::slices`]) and nothing else. + pub fn set_slices(&mut self, ids: impl IntoIterator) { + self.clear(); + for id in ids { + if !self.slices.contains(&id) { + self.slices.push(id); + } + } + } pub fn toggle(&mut self, id: NodeId) { if self.contains(id) { self.remove(id) } else { self.add(id) } } @@ -79,6 +96,7 @@ impl Selection { if self.target.is_some_and(|t| doc.node(t).is_none()) { self.target = None; } + self.slices.retain(|id| doc.is_slice(*id)); } /// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected, /// anything else is selected itself. diff --git a/crates/doc/src/slices.rs b/crates/doc/src/slices.rs new file mode 100644 index 00000000..f62d7fa0 --- /dev/null +++ b/crates/doc/src/slices.rs @@ -0,0 +1,315 @@ +//! Slices (Object → Slice): rectangles that cut the artwork into the pieces web output saves. +//! +//! User slices are rectangles of their own ([`Document::slices`]); object slices follow the +//! bounds of an object with [`Node::slice`] options; auto slices fill the rest of the slice region +//! (the artboards with Clip to Artboard on, else the art and the slices) and are never stored. +//! User slice ids come from the document's id counter, so a slice id never names an object and +//! one id list can hold both kinds (an object slice goes by its object's id). +//! +//! Slices are numbered left to right, top to bottom, auto slices included. + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use vectorcraft_geom::Rect; + +use crate::{Document, Node, NodeId}; + +/// Below this a slice edge or size is no edge or size (points). +const EPS: f64 = 1e-6; + +/// Enums whose values have a stable key (params, JSON) and a label (dialogs). +macro_rules! keyed { + ($(#[$m:meta])* $name:ident { $($(#[$vm:meta])* $v:ident = ($key:literal, $label:literal)),+ $(,)? }) => { + $(#[$m])* + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub enum $name { + $($(#[$vm])* $v,)+ + } + + impl $name { + pub const ALL: &'static [$name] = &[$($name::$v),+]; + /// The value params and JSON use (`noImage`). + pub fn key(self) -> &'static str { + match self { + $($name::$v => $key,)+ + } + } + /// The dialog label (`No Image`). + pub fn label(self) -> &'static str { + match self { + $($name::$v => $label,)+ + } + } + /// The value `s` names: its key or label in any case. + pub fn parse(s: &str) -> Option { + let s = s.trim(); + Self::ALL.iter().copied().find(|v| v.key().eq_ignore_ascii_case(s) || v.label().eq_ignore_ascii_case(s)) + } + } + }; +} + +keyed!( + /// Slice Options → Slice Type: what web output puts in the slice's cell. + SliceKind { + /// The artwork as an image (with a link, alt text and a status message). + #[default] + Image = ("image", "Image"), + /// An empty cell showing `text` (HTML allowed) on the background. + NoImage = ("noImage", "No Image"), + /// The text of the sliced type object as HTML (object slices of type only). + HtmlText = ("htmlText", "HTML Text"), + } +); + +keyed!( + /// Horizontal alignment of a No Image / HTML Text cell's text. + CellAlign { + #[default] + Default = ("default", "Default"), + Left = ("left", "Left"), + Center = ("center", "Center"), + Right = ("right", "Right"), + } +); + +keyed!( + /// Vertical alignment of a No Image / HTML Text cell's text. + CellVAlign { + #[default] + Default = ("default", "Default"), + Top = ("top", "Top"), + Middle = ("middle", "Middle"), + Baseline = ("baseline", "Baseline"), + Bottom = ("bottom", "Bottom"), + } +); + +/// Slice Options: how web output writes a slice. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SliceOptions { + #[serde(skip_serializing_if = "crate::skip::is_default")] + pub kind: SliceKind, + /// The slice's file name in web output; empty: `_` ([`Document::slice_name`]). + #[serde(skip_serializing_if = "String::is_empty")] + pub name: String, + /// Image: the link the slice's image opens. + #[serde(skip_serializing_if = "String::is_empty")] + pub url: String, + /// Image: the frame the link opens in (`_blank`, `_self`, `_parent`, `_top` or a name). + #[serde(skip_serializing_if = "String::is_empty")] + pub target: String, + /// Image: the browser's status message over the slice. + #[serde(skip_serializing_if = "String::is_empty")] + pub message: String, + /// Image: the alternative text. + #[serde(skip_serializing_if = "String::is_empty")] + pub alt: String, + /// No Image: the text the cell shows (HTML allowed). + #[serde(skip_serializing_if = "String::is_empty")] + pub text: String, + /// The cell's background: empty (none), `matte` or `#rrggbb`. + #[serde(skip_serializing_if = "String::is_empty")] + pub background: String, + #[serde(skip_serializing_if = "crate::skip::is_default")] + pub h_align: CellAlign, + #[serde(skip_serializing_if = "crate::skip::is_default")] + pub v_align: CellVAlign, +} + +/// A user slice: a rectangle drawn with the Slice tool or made by an Object → Slice command. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Slice { + /// From the document's id counter (never an object's id). + pub id: NodeId, + pub rect: Rect, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub options: SliceOptions, +} + +/// Where a slice comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SliceSource { + /// A [`Slice`] of the document. + User, + /// The bounds of an object with slice options. + Object, + /// Generated over what no other slice covers. + Auto, +} + +impl SliceSource { + pub fn key(self) -> &'static str { + match self { + SliceSource::User => "user", + SliceSource::Object => "object", + SliceSource::Auto => "auto", + } + } +} + +/// One slice as laid out: its id (none for auto slices), source, rectangle (clipped to the +/// artboards with Clip to Artboard on) and number (1-based). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SliceArea { + pub id: Option, + pub source: SliceSource, + pub rect: Rect, + pub number: usize, +} + +/// A rectangle with a size and finite coordinates. +pub fn valid_rect(r: Rect) -> bool { + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) && r.width() > EPS && r.height() > EPS +} + +impl Document { + /// The user slice `id`. + pub fn slice(&self, id: NodeId) -> Option<&Slice> { + self.slices.iter().find(|s| s.id == id) + } + pub fn slice_mut(&mut self, id: NodeId) -> Option<&mut Slice> { + self.slices.iter_mut().find(|s| s.id == id) + } + /// Is `id` a user slice or an object with slice options? + pub fn is_slice(&self, id: NodeId) -> bool { + self.slice(id).is_some() || self.node(id).is_some_and(|n| n.slice.is_some()) + } + /// The options of slice `id` (user or object). + pub fn slice_options(&self, id: NodeId) -> Option<&SliceOptions> { + match self.slice(id) { + Some(s) => Some(&s.options), + None => self.node(id)?.slice.as_deref(), + } + } + /// The rectangle of slice `id` before clipping: a user slice's own, an object slice's object's + /// visual bounds. + pub fn slice_bounds(&self, id: NodeId) -> Option { + match self.slice(id) { + Some(s) => Some(s.rect), + None => self.node(id).filter(|n| n.slice.is_some())?.visual_bounds(), + } + } + /// The visible objects with slice options and their visual bounds, in paint order. + pub fn object_slices(&self) -> Vec<(NodeId, Rect)> { + fn walk(nodes: &[Arc], out: &mut Vec<(NodeId, Rect)>) { + for n in nodes.iter().filter(|n| n.visible) { + if n.slice.is_some() + && let Some(b) = n.visual_bounds() + { + out.push((n.id, b)); + } + if let Some(ch) = n.children() { + walk(ch, out); + } + } + } + let mut out = vec![]; + walk(&self.layers, &mut out); + out + } + /// Every slice id: the user slices, then the object slices. + pub fn slice_ids(&self) -> Vec { + self.slices.iter().map(|s| s.id).chain(self.object_slices().into_iter().map(|(id, _)| id)).collect() + } + /// The box Clip to Artboard clips slices to: the artboards' bounds. + pub fn slice_clip(&self) -> Option { + self.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)) + } + /// The user and object slices with their rectangles as laid out (clipped to [`Self::slice_clip`] + /// when Clip to Artboard is on; one wholly outside stays as it is). + fn cut_slices(&self) -> Vec<(NodeId, SliceSource, Rect)> { + let clip = self.slices_clip_to_artboard.then(|| self.slice_clip()).flatten(); + let clipped = |r: Rect| clip.map(|c| c.intersect(r)).filter(|c| valid_rect(*c)).unwrap_or(r); + let users = self.slices.iter().map(|s| (s.id, SliceSource::User, s.rect)); + let objects = self.object_slices().into_iter().map(|(id, r)| (id, SliceSource::Object, r)); + users.chain(objects).map(|(id, src, r)| (id, src, clipped(r))).collect() + } + /// The slices as laid out, numbered left to right, top to bottom: the user and object slices + /// and the auto slices filling the rest of the region (the artboards with Clip to Artboard on, + /// else the visible art and the slices). Empty while the document has no user or object slice. + pub fn slice_layout(&self) -> Vec { + let cut = self.cut_slices(); + if cut.is_empty() { + return vec![]; + } + let region = if self.slices_clip_to_artboard { self.slice_clip() } else { None } + .or_else(|| cut.iter().fold(self.art_bounds(), |acc, (_, _, r)| vectorcraft_geom::union_opt(acc, Some(*r)))); + let rects: Vec = cut.iter().map(|(_, _, r)| *r).collect(); + let autos = region.map(|g| auto_slices(g, &rects)).unwrap_or_default(); + let mut out: Vec = cut + .into_iter() + .map(|(id, source, rect)| SliceArea { id: Some(id), source, rect, number: 0 }) + .chain(autos.into_iter().map(|rect| SliceArea { id: None, source: SliceSource::Auto, rect, number: 0 })) + .collect(); + out.sort_by(|a, b| a.rect.y0.total_cmp(&b.rect.y0).then(a.rect.x0.total_cmp(&b.rect.x0))); + for (i, a) in out.iter_mut().enumerate() { + a.number = i + 1; + } + out + } + /// The name web output gives slice `area`: its options' name, else `_` + /// (two digits at least, `Untitled-1_03`). + pub fn slice_name(&self, area: &SliceArea) -> String { + match area.id.and_then(|id| self.slice_options(id)).filter(|o| !o.name.is_empty()) { + Some(o) => o.name.clone(), + None => { + let stem = self.title.rsplit_once('.').map_or(self.title.as_str(), |(s, _)| s); + format!("{stem}_{:02}", area.number) + } + } + } +} + +/// The auto slices of `region`: rectangles covering what none of `cut` covers, without +/// overlapping each other or `cut`. The region is cut into rows at every slice's top and bottom; +/// each row's uncovered spans become slices, and a span continuing the one above it (same left and +/// right) extends it. +pub fn auto_slices(region: Rect, cut: &[Rect]) -> Vec { + if !valid_rect(region) { + return vec![]; + } + let inside: Vec = cut.iter().map(|r| region.intersect(*r)).filter(|r| valid_rect(*r)).collect(); + let mut ys: Vec = inside.iter().flat_map(|r| [r.y0, r.y1]).chain([region.y0, region.y1]).collect(); + ys.sort_by(f64::total_cmp); + ys.dedup_by(|a, b| (*a - *b).abs() <= EPS); + let (mut open, mut done): (Vec, Vec) = (vec![], vec![]); + for band in ys.windows(2) { + let [y0, y1] = [band[0], band[1]]; + let mid = (y0 + y1) / 2.0; + let mut spans: Vec<(f64, f64)> = inside.iter().filter(|r| r.y0 < mid && mid < r.y1).map(|r| (r.x0, r.x1)).collect(); + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + // The uncovered spans of this row. + let mut free = vec![]; + let mut x = region.x0; + for (a, b) in spans { + if a - x > EPS { + free.push((x, a)); + } + x = x.max(b); + } + if region.x1 - x > EPS { + free.push((x, region.x1)); + } + // Spans continuing an open slice extend it; the others close. + let mut next = Vec::with_capacity(free.len()); + for (a, b) in free { + match open.iter().position(|r: &Rect| (r.x0 - a).abs() <= EPS && (r.x1 - b).abs() <= EPS) { + Some(i) => { + let mut r = open.swap_remove(i); + r.y1 = y1; + next.push(r); + } + None => next.push(Rect::new(a, y0, b, y1)), + } + } + done.append(&mut open); + open = next; + } + done.append(&mut open); + done +} diff --git a/crates/doc/src/tests_slices.rs b/crates/doc/src/tests_slices.rs new file mode 100644 index 00000000..3a63fe0c --- /dev/null +++ b/crates/doc/src/tests_slices.rs @@ -0,0 +1,125 @@ +//! Slice layout: object slices follow their objects, clipping, numbering and auto slices. + +use proptest::prelude::*; +use vectorcraft_geom::{Point, Rect, shapes}; + +use crate::slices::auto_slices; +use crate::{Appearance, Document, Node, Slice, SliceOptions, SliceSource}; + +fn rect(d: &mut Document, r: Rect) -> crate::NodeId { + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), usize::MAX, Node::path(id, shapes::rectangle(r), Appearance::basic(crate::color::Paint::None, crate::color::Paint::None, 0.0))) + .unwrap(); + id +} + +fn user(d: &mut Document, r: Rect) -> crate::NodeId { + let id = d.alloc_id(); + d.slices.push(Slice { id, rect: r, options: SliceOptions::default() }); + id +} + +/// How many of `rects` hold point `p` (strictly inside). +fn holders(rects: &[Rect], p: Point) -> usize { + rects.iter().filter(|r| r.x0 < p.x && p.x < r.x1 && r.y0 < p.y && p.y < r.y1).count() +} + +#[test] +fn an_object_slice_follows_its_object_and_numbers_run_by_rows() { + let mut d = Document::new(300.0, 200.0); + let a = rect(&mut d, Rect::new(100.0, 50.0, 200.0, 150.0)); + assert!(d.slice_layout().is_empty(), "no slices, no auto slices"); + d.node_mut(a).unwrap().slice = Some(Box::default()); + let lay = d.slice_layout(); + let own = lay.iter().find(|s| s.id == Some(a)).unwrap(); + assert_eq!((own.source, own.rect), (SliceSource::Object, Rect::new(100.0, 50.0, 200.0, 150.0))); + // Three rows: the band above, the object's row (left, object, right) and the band below. + assert_eq!(lay.len(), 5); + assert_eq!(lay.iter().map(|s| s.number).collect::>(), [1, 2, 3, 4, 5]); + assert_eq!(own.number, 3); + assert_eq!(d.slice_name(own), "Untitled-1_03"); + // Moving the object moves its slice. + d.node_mut(a).unwrap().transform(vectorcraft_geom::Affine::translate((-50.0, 0.0)), crate::Scaling::default()); + assert_eq!(d.slice_layout().iter().find(|s| s.id == Some(a)).unwrap().rect, Rect::new(50.0, 50.0, 150.0, 150.0)); + // Hidden objects have no slice. + d.node_mut(a).unwrap().visible = false; + assert!(d.slice_layout().is_empty()); +} + +#[test] +fn clip_to_artboard_clips_slices_and_off_covers_the_art() { + let mut d = Document::new(100.0, 100.0); + let s = user(&mut d, Rect::new(50.0, 50.0, 150.0, 150.0)); + let own = |d: &Document| d.slice_layout().into_iter().find(|a| a.id == Some(s)).unwrap().rect; + assert_eq!(own(&d), Rect::new(50.0, 50.0, 100.0, 100.0)); + let lay = d.slice_layout(); + let autos: Vec = lay.iter().filter(|a| a.source == SliceSource::Auto).map(|a| a.rect).collect(); + assert_eq!(autos.iter().map(|r| r.area()).sum::(), 100.0 * 100.0 - 50.0 * 50.0); + d.slices_clip_to_artboard = false; + assert_eq!(own(&d), Rect::new(50.0, 50.0, 150.0, 150.0)); + // The region is now the slice itself (no art): nothing left for auto slices. + assert_eq!(d.slice_layout().len(), 1); + rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0)); + let total: f64 = d.slice_layout().iter().map(|a| a.rect.area()).sum(); + assert_eq!(total, 150.0 * 150.0); +} + +#[test] +fn slices_survive_a_native_round_trip_and_old_files_load() { + let mut d = Document::new(100.0, 100.0); + let a = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0)); + d.node_mut(a).unwrap().slice = Some(Box::new(SliceOptions { kind: crate::SliceKind::NoImage, text: "hi".into(), ..Default::default() })); + let s = user(&mut d, Rect::new(30.0, 30.0, 60.0, 60.0)); + d.slice_mut(s).unwrap().options.url = "https://example.com".into(); + d.slices_clip_to_artboard = false; + let json = serde_json::to_string(&d).unwrap(); + let back: Document = serde_json::from_str(&json).unwrap(); + assert_eq!(back.slices, d.slices); + assert_eq!(back.node(a).unwrap().slice, d.node(a).unwrap().slice); + assert!(!back.slices_clip_to_artboard); + // A document without slices writes none of the new keys (older readers see what they knew). + let plain = serde_json::to_string(&Document::new(10.0, 10.0)).unwrap(); + assert!(!plain.contains("slice"), "{plain}"); + let old: Document = serde_json::from_str(&plain).unwrap(); + assert!(old.slices.is_empty() && old.slices_clip_to_artboard); +} + +#[test] +fn auto_slices_fill_around_a_slice_in_rows() { + let autos = auto_slices(Rect::new(0.0, 0.0, 90.0, 90.0), &[Rect::new(30.0, 30.0, 60.0, 60.0)]); + let mut got: Vec<(f64, f64, f64, f64)> = autos.iter().map(|r| (r.x0, r.y0, r.x1, r.y1)).collect(); + got.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.total_cmp(&b.0))); + assert_eq!(got, [(0.0, 0.0, 90.0, 30.0), (0.0, 30.0, 30.0, 60.0), (60.0, 30.0, 90.0, 60.0), (0.0, 60.0, 90.0, 90.0)]); + // Spans that continue down one column merge. + let autos = auto_slices(Rect::new(0.0, 0.0, 90.0, 90.0), &[Rect::new(30.0, 0.0, 60.0, 40.0), Rect::new(30.0, 40.0, 60.0, 90.0)]); + assert_eq!(autos.len(), 2, "{autos:?}"); +} + +fn arb_rect() -> impl Strategy { + (-20.0..120.0f64, -20.0..120.0f64, 1.0..80.0f64, 1.0..80.0f64).prop_map(|(x, y, w, h)| Rect::new(x, y, x + w, y + h)) +} + +proptest! { + /// Auto slices never overlap each other or the slices, and with the slices they cover the + /// whole region. + #[test] + fn auto_slices_cover_without_overlap(cut in prop::collection::vec(arb_rect(), 0..8)) { + let region = Rect::new(0.0, 0.0, 100.0, 100.0); + let autos = auto_slices(region, &cut); + for r in &autos { + prop_assert!(r.x0 >= region.x0 && r.y0 >= region.y0 && r.x1 <= region.x1 && r.y1 <= region.y1, "{r:?} leaves the region"); + } + // Sample off every edge (edges are at integers plus random fractions; the samples sit at + // odd 1/8ths, which a random float edge practically never hits). + for i in 0..50 { + for j in 0..50 { + let p = Point::new(i as f64 * 2.0 + 0.125 * 7.0, j as f64 * 2.0 + 0.125 * 3.0); + let in_cut = holders(&cut, p) > 0; + let in_auto = holders(&autos, p); + prop_assert!(in_auto <= 1, "{p:?} in {in_auto} auto slices"); + prop_assert!(in_cut != (in_auto == 1), "{p:?}: in a slice {in_cut}, in an auto slice {in_auto}"); + } + } + } +} diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index c4f6f67e..d1be6134 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -28,6 +28,10 @@ pub struct MenuState { pub new_masks_inverted: bool, /// Magic Wand panel settings. pub wand: super::wand::WandSettings, + /// View → Hide Slices. + pub slices_hidden: bool, + /// View → Lock Slices. + pub slices_locked: bool, } pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 941d84ae..aae1919b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -47,6 +47,7 @@ pub mod prefscmds; pub mod rasterfx; mod recolor; mod select; +pub(crate) mod slices; mod stroke; mod style; pub mod stylelib; @@ -225,6 +226,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(pdfcmds::specs()); v.extend(fileio::dxf::specs()); v.extend(package::specs()); + v.extend(slices::specs()); v }) } diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs new file mode 100644 index 00000000..4482955c --- /dev/null +++ b/crates/engine/src/cmd/slices.rs @@ -0,0 +1,567 @@ +//! Object → Slice, View → Hide/Lock Slices and Select → Object → Slices. +//! +//! The model is in [`vectorcraft_doc::slices`]: user slices live in [`Document::slices`], object +//! slices are objects with slice options, auto slices are laid out around them. The Object → Slice +//! commands act on the selected slices: those the Slice Selection tool (or `object.slice.select`) +//! selected and the selected objects that are object slices. Commands that take slice ids take +//! user slice ids and the ids of objects with an object slice. + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::slices::valid_rect; +use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKind, Slice, SliceKind, SliceOptions}; +use vectorcraft_geom::{Rect, shapes}; + +use super::*; + +/// The most slices one Divide Slices makes. +const MAX_PIECES: usize = 1000; + +pub fn specs() -> Vec { + vec![ + cmd!( + "object.slice.make", + "Make", + ["Object", "Slice"], + None, + "{ids?} make each selected object (or ids) an object slice: its slice follows the object's bounds → {ids: the new object slices}", + has_selection, + make + ), + cmd!( + "object.slice.release", + "Release", + ["Object", "Slice"], + None, + "{} release the selected slices: an object slice leaves its object as it was, a user slice becomes an unpainted rectangle; the objects end up selected → {ids}", + slices_targeted, + release + ), + cmd!( + "object.slice.fromGuides", + "Create from Guides", + ["Object", "Slice"], + None, + "{} replace the user slices with the grid the ruler guides cut the artboards into (two vertical and two horizontal guides give 9) → {ids}", + has_doc, + from_guides + ), + cmd!( + "object.slice.fromSelection", + "Create from Selection", + ["Object", "Slice"], + None, + "{ids?} a user slice over the visual bounds of the selected objects (or ids), selected → {id}", + has_selection, + from_selection + ), + cmd!( + "object.slice.duplicate", + "Duplicate Slice", + ["Object", "Slice"], + None, + "{dx?: pt (default 10), dy?: pt (default 10)} copy the selected slices as user slices with their options, offset by dx, dy; the copies are selected → {ids}", + slices_targeted, + duplicate + ), + cmd!( + "object.slice.combine", + "Combine Slices", + ["Object", "Slice"], + None, + "{} replace the two or more selected slices with one user slice over their bounds (the first one's options; object slices are released) → {id}", + slices_targeted, + combine + ), + cmd!( + "object.slice.divide", + "Divide Slices…", + ["Object", "Slice"], + None, + "{rows?: slices down, rowHeight?: pt per slice, columns?: slices across, columnWidth?: pt per slice} cut each selected slice into a grid of user slices (evenly by count, or by size with a smaller last one; an object slice is released); the pieces are selected → {ids}", + slices_targeted, + divide + ), + cmd!( + "object.slice.deleteAll", + "Delete All", + ["Object", "Slice"], + None, + "{} delete every user slice and release every object slice → {count}", + doc_has_slices, + delete_all + ), + cmd!( + "object.slice.options", + "Slice Options…", + ["Object", "Slice"], + None, + "{kind?: \"image\"|\"noImage\"|\"htmlText\" (object slices of type only), name?, url?, target?, message?, alt?: (image), text?: cell text, HTML allowed (noImage), background?: \"\"|\"matte\"|\"#rrggbb\", hAlign?: \"default\"|\"left\"|\"center\"|\"right\", vAlign?: \"default\"|\"top\"|\"middle\"|\"baseline\"|\"bottom\"} set the selected slices' options as one undo step; no options: read them → the first slice's {kind, name, url, target, message, alt, text, background, hAlign, vAlign, htmlText: whether HTML Text applies}", + slices_targeted, + options + ), + cmd!( + "object.slice.clipToArtboard", + "Clip to Artboard", + ["Object", "Slice"], + None, + "{on?: bool} toggle (or set) Clip to Artboard: on, slices are clipped to the artboards and auto slices fill them; off, auto slices cover the art and the slices → {on}", + has_doc, + clip_to_artboard + ), + cmd!(query "view.slices.hide", "Hide Slices", ["View"], None, "{hidden?: bool} toggle (or set) whether the canvas hides the slices (session view state) → {hidden}", has_doc, hide), + cmd!( + query "view.slices.lock", + "Lock Slices", + ["View"], + None, + "{locked?: bool} toggle (or set) the slice lock: locked slices can't be selected or edited with the Slice Selection tool → {locked}", + has_doc, + lock + ), + cmd!( + query "slice.list", + "List Slices", + [], + None, + "{} the slices as laid out, numbered left to right and top to bottom → {slices: [{number, id (null for auto slices), source: \"user\"|\"object\"|\"auto\", name, x, y, width, height, options, selected}], clipToArtboard, hidden, locked}", + has_doc, + list + ), + cmd!("select.object.slices", "Slices", ["Select", "Object"], None, "{} select every user and object slice → {count}", has_doc, select_all), + ] +} + +impl Session { + /// View → Hide Slices. + pub fn slices_hidden(&self) -> bool { + self.menu.slices_hidden + } + /// View → Lock Slices. + pub fn slices_locked(&self) -> bool { + self.menu.slices_locked + } +} + +// ---------- targets and params ---------- + +/// The selected slices: those the Slice Selection tool selected and the selected objects that are +/// object slices. +fn selected(s: &Session) -> Result> { + let st = s.doc()?; + Ok(selected_slices(&st.doc, &st.selection)) +} + +fn selected_slices(d: &Document, sel: &vectorcraft_doc::Selection) -> Vec { + let objects = sel.objects.iter().filter(|id| d.node(**id).is_some_and(|n| n.slice.is_some())); + dedup(sel.slices.iter().chain(objects).copied().collect()) +} + +fn dedup(mut ids: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + ids.retain(|id| seen.insert(*id)); + ids +} + +fn slices_targeted(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if selected_slices(&st.doc, &st.selection).is_empty() { Err("no slice selected".into()) } else { Ok(()) } +} + +fn doc_has_slices(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.doc.slices.is_empty() && object_slice_ids(&st.doc).is_empty() { Err("the document has no slices".into()) } else { Ok(()) } +} + +/// Every object with slice options, hidden ones included. +fn object_slice_ids(d: &Document) -> Vec { + let mut ids = vec![]; + d.walk(|n| { + if n.slice.is_some() { + ids.push(n.id); + } + }); + ids +} + +/// A rectangle a slice can have: a size, and coordinates on the canvas. +pub(crate) fn slice_rect_ok(r: Rect) -> bool { + valid_rect(r) && [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= crate::MAX_COORD) +} + +/// A new user slice over `rect` with `options`. +pub(crate) fn add_slice(d: &mut Document, rect: Rect, options: SliceOptions) -> NodeId { + let id = d.alloc_id(); + d.slices.push(Slice { id, rect, options }); + id +} + +/// Remove slice `id`: a user slice goes, an object slice's object loses its slice options. +pub(crate) fn remove_slice(d: &mut Document, id: NodeId) { + let before = d.slices.len(); + d.slices.retain(|s| s.id != id); + if d.slices.len() == before + && let Some(n) = d.node_mut(id) + { + n.slice = None; + } +} + +fn ids_json(ids: &[NodeId]) -> Value { + json!(ids.iter().map(|i| i.0).collect::>()) +} + +/// Slice options as the commands give them back (every field). +pub(crate) fn options_json(o: &SliceOptions) -> Value { + json!({ + "kind": o.kind.key(), "name": o.name, "url": o.url, "target": o.target, "message": o.message, "alt": o.alt, + "text": o.text, "background": o.background, "hAlign": o.h_align.key(), "vAlign": o.v_align.key(), + }) +} + +// ---------- Object → Slice ---------- + +fn make(s: &mut Session, p: &Value) -> Result { + let ids = match ids_param(p, "ids") { + Some(ids) => ids, + None => super::edit::selected_roots(s)?, + }; + let d = &s.doc()?.doc; + let made: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer() && n.slice.is_none())).collect(); + if made.is_empty() { + return Ok(json!({ "ids": [] })); + } + s.edit("Make Slice", |d, _| { + for id in &made { + if let Some(n) = d.node_mut(*id) { + n.slice = Some(Box::default()); + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids_json(&made) })) +} + +fn release(s: &mut Session, _: &Value) -> Result { + let targets = selected(s)?; + let parent = s.doc()?.insertion_parent(); + let ids = s.edit("Release Slice", |d, sel| { + let mut out = vec![]; + for id in &targets { + match d.slice(*id).map(|s| s.rect) { + // A user slice becomes an unpainted rectangle. + Some(r) => { + d.slices.retain(|s| s.id != *id); + let parent = parent.ok_or_else(|| EngineError::Other("Release: no layer to put the rectangle in".into()))?; + let rid = d.alloc_id(); + d.insert(Some(parent), usize::MAX, Node::path(rid, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 1.0)))?; + out.push(rid); + } + None => { + remove_slice(d, *id); + out.push(*id); + } + } + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +fn from_guides(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let region = d.slice_clip().ok_or_else(|| EngineError::Other("Create from Guides: the document has no artboard".into()))?; + let cuts = |vertical: bool, lo: f64, hi: f64| -> Vec { + let mut v: Vec = d.guides.iter().filter(|g| g.vertical == vertical && g.pos > lo && g.pos < hi).map(|g| g.pos).collect(); + v.extend([lo, hi]); + v.sort_by(f64::total_cmp); + v.dedup(); + v + }; + let (xs, ys) = (cuts(true, region.x0, region.x1), cuts(false, region.y0, region.y1)); + let rects: Vec = + ys.windows(2).flat_map(|y| xs.windows(2).map(move |x| Rect::new(x[0], y[0], x[1], y[1]))).filter(|r| valid_rect(*r)).collect(); + if rects.len() > MAX_PIECES { + return Err(EngineError::Other(format!("Create from Guides: the guides make more than {MAX_PIECES} slices"))); + } + let ids = s.edit("Create Slices from Guides", |d, _| { + d.slices.clear(); + Ok(rects.iter().map(|r| add_slice(d, *r, SliceOptions::default())).collect::>()) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +fn from_selection(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let r = s.doc()?.doc.bounds_of(&ids, true).filter(|r| slice_rect_ok(*r)); + let r = r.ok_or_else(|| EngineError::Other("Create from Selection: the selection has no area".into()))?; + let id = s.edit("Create Slice from Selection", |d, sel| { + let id = add_slice(d, r, SliceOptions::default()); + sel.set_slices([id]); + Ok(id) + })?; + Ok(json!({ "id": id.0 })) +} + +fn duplicate(s: &mut Session, p: &Value) -> Result { + let targets = selected(s)?; + let (dx, dy) = (f64_or(p, "dx", 10.0), f64_or(p, "dy", 10.0)); + let ids = s.edit("Duplicate Slice", |d, sel| { + let mut out = vec![]; + for id in &targets { + let (Some(r), Some(o)) = (d.slice_bounds(*id), d.slice_options(*id).cloned()) else { continue }; + let r = r + vectorcraft_geom::Vec2::new(dx, dy); + if !slice_rect_ok(r) { + return Err(bad("object.slice.duplicate", "the copy would leave the canvas")); + } + out.push(add_slice(d, r, o)); + } + sel.set_slices(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +fn combine(s: &mut Session, _: &Value) -> Result { + let targets = selected(s)?; + if targets.len() < 2 { + return Err(bad("object.slice.combine", "select two or more slices to combine")); + } + let id = s.edit("Combine Slices", |d, sel| { + let r = targets.iter().filter_map(|id| d.slice_bounds(*id)).reduce(|a, b| a.union(b)); + let r = r.ok_or_else(|| EngineError::Other("Combine Slices: the slices have no bounds".into()))?; + let o = targets.first().and_then(|id| d.slice_options(*id)).cloned().unwrap_or_default(); + for id in &targets { + remove_slice(d, *id); + } + let id = add_slice(d, r, o); + sel.set_slices([id]); + Ok(id) + })?; + Ok(json!({ "id": id.0 })) +} + +/// The cuts that split `lo..hi` into `count` equal parts or parts `size` long (the last one +/// shorter), whichever `count`/`size` gives; `None` for neither. +fn splits(lo: f64, hi: f64, count: Option, size: Option) -> std::result::Result, String> { + let len = hi - lo; + let n = match (count, size) { + (Some(n), _) => n, + (None, Some(sz)) if sz.is_finite() && sz > 0.0 => (len / sz - 1e-9).ceil().max(1.0).min(MAX_PIECES as f64 + 1.0) as u64, + (None, Some(_)) => return Err("a slice size must be a positive number of points".into()), + (None, None) => 1, + }; + if n == 0 || n as usize > MAX_PIECES { + return Err(format!("divide into 1 to {MAX_PIECES} slices")); + } + let step = match (count, size) { + (None, Some(sz)) => sz, + _ => len / n as f64, + }; + Ok((0..=n).map(|i| if i == n { hi } else { lo + step * i as f64 }).collect()) +} + +fn divide(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.slice.divide"; + let targets = selected(s)?; + let count = |k: &str| p.get(k).map(|v| v.as_u64().ok_or_else(|| bad(C, format!("`{k}` must be a whole number")))).transpose(); + let size = |k: &str| p.get(k).map(|v| v.as_f64().ok_or_else(|| bad(C, format!("`{k}` must be a number of points")))).transpose(); + let (rows, row_h, cols, col_w) = (count("rows")?, size("rowHeight")?, count("columns")?, size("columnWidth")?); + if rows.is_none() && row_h.is_none() && cols.is_none() && col_w.is_none() { + return Err(bad(C, "give rows, rowHeight, columns or columnWidth")); + } + let ids = s.edit("Divide Slices", |d, sel| { + let mut out = vec![]; + for id in &targets { + let (Some(r), Some(o)) = (d.slice_bounds(*id), d.slice_options(*id).cloned()) else { continue }; + let ys = splits(r.y0, r.y1, rows, row_h).map_err(|e| bad(C, e))?; + let xs = splits(r.x0, r.x1, cols, col_w).map_err(|e| bad(C, e))?; + if (ys.len() - 1) * (xs.len() - 1) + out.len() > MAX_PIECES { + return Err(bad(C, format!("that makes more than {MAX_PIECES} slices"))); + } + remove_slice(d, *id); + for (i, rect) in ys.windows(2).flat_map(|y| xs.windows(2).map(move |x| Rect::new(x[0], y[0], x[1], y[1]))).enumerate() { + // The pieces keep the options; only the first keeps the name. + let o = if i == 0 { o.clone() } else { SliceOptions { name: String::new(), ..o.clone() } }; + out.push(add_slice(d, rect, o)); + } + } + sel.set_slices(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids_json(&ids) })) +} + +fn delete_all(s: &mut Session, _: &Value) -> Result { + let n = s.edit("Delete All Slices", |d, _| { + let objects = object_slice_ids(d); + let n = d.slices.len() + objects.len(); + d.slices.clear(); + for id in objects { + remove_slice(d, id); + } + Ok(n) + })?; + Ok(json!({ "count": n })) +} + +/// The Slice Options edits a command's params ask for. +#[derive(Default)] +struct Edits { + kind: Option, + text: Vec<(&'static str, String)>, + background: Option, + h_align: Option, + v_align: Option, +} + +impl Edits { + const TEXT: [&'static str; 6] = ["name", "url", "target", "message", "alt", "text"]; + + fn parse(p: &Value) -> Result { + const C: &str = "object.slice.options"; + let text = |k: &str| -> Result> { + p.get(k).map(|v| v.as_str().map(str::to_string).ok_or_else(|| bad(C, format!("`{k}` must be a string")))).transpose() + }; + let named = |k: &str| text(k).map(|v| v.map(|s| s.trim().to_string())); + let mut e = Edits { + kind: named("kind")?.map(|k| SliceKind::parse(&k).ok_or_else(|| bad(C, "`kind` must be image, noImage or htmlText"))).transpose()?, + h_align: named("hAlign")? + .map(|k| CellAlign::parse(&k).ok_or_else(|| bad(C, "`hAlign` must be default, left, center or right"))) + .transpose()?, + v_align: named("vAlign")? + .map(|k| CellVAlign::parse(&k).ok_or_else(|| bad(C, "`vAlign` must be default, top, middle, baseline or bottom"))) + .transpose()?, + ..Default::default() + }; + e.background = named("background")? + .map(|b| match b.to_ascii_lowercase().as_str() { + "" | "none" => Ok(String::new()), + "matte" => Ok("matte".to_string()), + _ => vectorcraft_color::Color::from_hex(&b) + .map(|c| c.to_hex()) + .ok_or_else(|| bad(C, "`background` must be \"\", \"matte\" or #rrggbb")), + }) + .transpose()?; + for k in Self::TEXT { + // Names, links and frames are trimmed; the cell text and alt text keep their spaces. + let v = if matches!(k, "text" | "alt" | "message") { text(k)? } else { named(k)? }; + if let Some(v) = v { + e.text.push((k, v)); + } + } + Ok(e) + } + + fn is_empty(&self) -> bool { + self.kind.is_none() && self.text.is_empty() && self.background.is_none() && self.h_align.is_none() && self.v_align.is_none() + } + + fn apply(&self, o: &mut SliceOptions) { + if let Some(k) = self.kind { + o.kind = k; + } + for (k, v) in &self.text { + let field = match *k { + "name" => &mut o.name, + "url" => &mut o.url, + "target" => &mut o.target, + "message" => &mut o.message, + "alt" => &mut o.alt, + _ => &mut o.text, + }; + field.clone_from(v); + } + if let Some(b) = &self.background { + o.background.clone_from(b); + } + if let Some(a) = self.h_align { + o.h_align = a; + } + if let Some(a) = self.v_align { + o.v_align = a; + } + } +} + +/// Can slice `id` be HTML Text: an object slice of a type object? +fn html_text_allowed(d: &Document, id: NodeId) -> bool { + d.slice(id).is_none() && d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) +} + +fn options(s: &mut Session, p: &Value) -> Result { + let targets = selected(s)?; + let edits = Edits::parse(p)?; + let html_ok = |d: &Document| targets.iter().all(|id| html_text_allowed(d, *id)); + if edits.kind == Some(SliceKind::HtmlText) && !html_ok(&s.doc()?.doc) { + return Err(bad("object.slice.options", "HTML Text applies to object slices of type only")); + } + if !edits.is_empty() { + s.edit("Slice Options", |d, _| { + for id in &targets { + let o = match d.slice_mut(*id) { + Some(sl) => Some(&mut sl.options), + None => d.node_mut(*id).and_then(|n| n.slice.as_deref_mut()), + }; + if let Some(o) = o { + edits.apply(o); + } + } + Ok(()) + })?; + } + let d = &s.doc()?.doc; + let o = targets.first().and_then(|id| d.slice_options(*id)).cloned().unwrap_or_default(); + let mut v = options_json(&o); + v["htmlText"] = json!(html_ok(d)); + Ok(v) +} + +fn clip_to_artboard(s: &mut Session, p: &Value) -> Result { + let on = p.get("on").and_then(Value::as_bool).unwrap_or(!s.doc()?.doc.slices_clip_to_artboard); + if on != s.doc()?.doc.slices_clip_to_artboard { + s.edit("Clip to Artboard", |d, _| { + d.slices_clip_to_artboard = on; + Ok(()) + })?; + } + Ok(json!({ "on": on })) +} + +fn hide(s: &mut Session, p: &Value) -> Result { + let v = p.get("hidden").and_then(Value::as_bool).unwrap_or(!s.menu.slices_hidden); + s.menu.slices_hidden = v; + Ok(json!({ "hidden": v })) +} + +fn lock(s: &mut Session, p: &Value) -> Result { + let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.slices_locked); + s.menu.slices_locked = v; + Ok(json!({ "locked": v })) +} + +fn list(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + let d = &st.doc; + let slices: Vec = d + .slice_layout() + .iter() + .map(|a| { + let o = a.id.and_then(|id| d.slice_options(id)).cloned().unwrap_or_default(); + json!({ + "number": a.number, "id": a.id.map(|i| i.0), "source": a.source.key(), "name": d.slice_name(a), + "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height(), + "options": options_json(&o), "selected": a.id.is_some_and(|id| st.selection.slices.contains(&id)), + }) + }) + .collect(); + Ok(json!({ "slices": slices, "clipToArtboard": d.slices_clip_to_artboard, "hidden": s.menu.slices_hidden, "locked": s.menu.slices_locked })) +} + +fn select_all(s: &mut Session, _: &Value) -> Result { + let ids = s.doc()?.doc.slice_ids(); + let n = ids.len(); + s.select(|_, sel| sel.set_slices(ids))?; + Ok(json!({ "count": n })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d3b8706a..291d37cc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1116,6 +1116,8 @@ mod tests_save; #[cfg(test)] mod tests_scalestrokes; #[cfg(test)] +mod tests_slices; +#[cfg(test)] mod tests_strokegeom; #[cfg(test)] mod tests_strokegradient; diff --git a/crates/engine/src/tests_slices.rs b/crates/engine/src/tests_slices.rs new file mode 100644 index 00000000..67a2f941 --- /dev/null +++ b/crates/engine/src/tests_slices.rs @@ -0,0 +1,252 @@ +//! Object → Slice, View → Hide/Lock Slices, Select → Object → Slices and `slice.list`. + +use serde_json::{Value, json}; +use vectorcraft_doc::SliceKind; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + // Unstroked art: visual bounds are the geometry. + s.paint.stroke = vectorcraft_color::Paint::None; + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> u64 { + run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap() +} + +/// `slice.list`'s slices. +fn slices(s: &mut Session) -> Vec { + run(s, "slice.list", json!({}))["slices"].as_array().unwrap().clone() +} + +/// The laid-out rectangle of slice `id` as [x, y, width, height]. +fn rect_of(s: &mut Session, id: u64) -> [f64; 4] { + let v = slices(s).into_iter().find(|v| v["id"] == json!(id)).unwrap(); + ["x", "y", "width", "height"].map(|k| v[k].as_f64().unwrap()) +} + +fn user_count(s: &Session) -> usize { + s.doc().unwrap().doc.slices.len() +} + +/// A user slice over `[x, y, w, h]`, made from a temporary rectangle. +fn user_slice(s: &mut Session, r: [f64; 4]) -> u64 { + let tmp = rect(s, r[0], r[1], r[2], r[3]); + let id = run(s, "object.slice.fromSelection", json!({"ids": [tmp]}))["id"].as_u64().unwrap(); + run(s, "select.set", json!({"ids": [tmp]})); + run(s, "edit.clear", json!({})); + id +} + +#[test] +fn an_object_slice_follows_its_object_and_releases_back_to_it() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let made = run(&mut s, "object.slice.make", json!({})); + assert_eq!(made["ids"], json!([a])); + assert_eq!(rect_of(&mut s, a), [100.0, 100.0, 50.0, 40.0]); + let list = slices(&mut s); + assert!(list.iter().any(|v| v["source"] == "auto"), "auto slices fill the artboard"); + let area: f64 = list.iter().map(|v| v["width"].as_f64().unwrap() * v["height"].as_f64().unwrap()).sum(); + assert_eq!(area, 300.0 * 300.0); + // Moving the object moves its slice; one undo step brings it back. + run(&mut s, "object.move", json!({"dx": 20, "dy": 10})); + assert_eq!(rect_of(&mut s, a), [120.0, 110.0, 50.0, 40.0]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(rect_of(&mut s, a), [100.0, 100.0, 50.0, 40.0]); + // Make again changes nothing; Release leaves the object. + assert_eq!(run(&mut s, "object.slice.make", json!({}))["ids"], json!([])); + assert_eq!(run(&mut s, "object.slice.release", json!({}))["ids"], json!([a])); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_none()); + assert!(slices(&mut s).is_empty()); + run(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_some()); +} + +#[test] +fn releasing_a_user_slice_leaves_an_unpainted_rectangle() { + let mut s = session(); + let id = user_slice(&mut s, [10.0, 20.0, 30.0, 40.0]); + run(&mut s, "select.object.slices", json!({})); + let r = run(&mut s, "object.slice.release", json!({})); + assert_eq!(s.doc().unwrap().doc.slice(NodeId(id)), None); + let rid = r["ids"][0].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.slices.is_empty()); + let n = d.node(NodeId(rid)).unwrap(); + assert_eq!(n.geometric_bounds().unwrap(), vectorcraft_geom::Rect::new(10.0, 20.0, 40.0, 60.0)); + assert!(matches!(n.appearance.fill_paint(), vectorcraft_color::Paint::None)); + assert_eq!(s.doc().unwrap().selection.objects, vec![NodeId(rid)]); +} + +#[test] +fn guides_give_nine_slices() { + let mut s = session(); + for (vertical, pos) in [(true, 100.0), (true, 200.0), (false, 50.0), (false, 250.0), (true, 400.0)] { + run(&mut s, "guide.add", json!({"vertical": vertical, "pos": pos})); + } + user_slice(&mut s, [0.0, 0.0, 10.0, 10.0]); + let ids = run(&mut s, "object.slice.fromGuides", json!({}))["ids"].as_array().unwrap().len(); + assert_eq!(ids, 9, "the guide off the artboard cuts nothing"); + assert_eq!(user_count(&s), 9, "the earlier slice is replaced"); + let list = slices(&mut s); + assert_eq!(list.len(), 9, "the grid covers the artboard: no auto slices"); + assert_eq!(list[4]["x"], 100.0); + assert_eq!(list[4]["width"], 100.0); + assert_eq!(list[4]["height"], 200.0); + assert_eq!(list.iter().map(|v| v["number"].as_u64().unwrap()).collect::>(), (1..=9).collect::>()); + // One undo step takes the grid back. + run(&mut s, "edit.undo", json!({})); + assert_eq!(user_count(&s), 1); +} + +#[test] +fn divide_two_by_three_and_by_size() { + let mut s = session(); + user_slice(&mut s, [0.0, 0.0, 90.0, 60.0]); + run(&mut s, "select.object.slices", json!({})); + run(&mut s, "object.slice.options", json!({"name": "hero", "url": "https://example.com"})); + let pieces = run(&mut s, "object.slice.divide", json!({"rows": 2, "columns": 3}))["ids"].as_array().unwrap().clone(); + assert_eq!(pieces.len(), 6); + let d = &s.doc().unwrap().doc; + let rects: Vec<_> = d.slices.iter().map(|sl| (sl.rect.x0, sl.rect.y0, sl.rect.width(), sl.rect.height())).collect(); + assert_eq!( + rects, + [ + (0.0, 0.0, 30.0, 30.0), + (30.0, 0.0, 30.0, 30.0), + (60.0, 0.0, 30.0, 30.0), + (0.0, 30.0, 30.0, 30.0), + (30.0, 30.0, 30.0, 30.0), + (60.0, 30.0, 30.0, 30.0) + ] + ); + assert!(d.slices.iter().all(|sl| sl.options.url == "https://example.com")); + assert_eq!(d.slices.iter().filter(|sl| sl.options.name == "hero").count(), 1, "only the first piece keeps the name"); + assert_eq!(s.doc().unwrap().selection.slices.len(), 6, "the pieces are selected"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(user_count(&s), 1); + // By size: 25 pt rows of a 60 pt slice: 25, 25 and 10. + run(&mut s, "select.object.slices", json!({})); + run(&mut s, "object.slice.divide", json!({"rowHeight": 25})); + let hs: Vec = s.doc().unwrap().doc.slices.iter().map(|sl| sl.rect.height()).collect(); + assert_eq!(hs, [25.0, 25.0, 10.0]); + assert!(s.execute("object.slice.divide", &json!({"rows": 0})).is_err()); + assert!(s.execute("object.slice.divide", &json!({"columns": 5000})).is_err()); + assert!(s.execute("object.slice.divide", &json!({})).is_err(), "nothing to divide by"); +} + +#[test] +fn combine_and_duplicate() { + let mut s = session(); + user_slice(&mut s, [0.0, 0.0, 20.0, 20.0]); + run(&mut s, "select.object.slices", json!({})); + assert!(s.execute("object.slice.combine", &json!({})).is_err(), "two or more"); + let b = rect(&mut s, 50.0, 40.0, 10.0, 10.0); + run(&mut s, "object.slice.make", json!({})); + run(&mut s, "select.object.slices", json!({})); + let c = run(&mut s, "object.slice.combine", json!({}))["id"].as_u64().unwrap(); + assert_eq!(rect_of(&mut s, c), [0.0, 0.0, 60.0, 50.0]); + assert_eq!(user_count(&s), 1); + assert!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().slice.is_none(), "the object slice was released"); + assert_eq!(s.doc().unwrap().selection.slices, vec![NodeId(c)]); + let dup = run(&mut s, "object.slice.duplicate", json!({}))["ids"][0].as_u64().unwrap(); + assert_eq!(rect_of(&mut s, dup), [10.0, 10.0, 60.0, 50.0]); + assert_eq!(s.doc().unwrap().selection.slices, vec![NodeId(dup)]); +} + +#[test] +fn slice_options_set_read_and_undo_in_one_step() { + let mut s = session(); + user_slice(&mut s, [0.0, 0.0, 50.0, 50.0]); + run(&mut s, "select.object.slices", json!({})); + let o = run(&mut s, "object.slice.options", json!({})); + assert_eq!((o["kind"].clone(), o["htmlText"].clone()), (json!("image"), json!(false))); + let undo = s.doc().unwrap().history.undo.len(); + let o = run( + &mut s, + "object.slice.options", + json!({"kind": "No Image", "name": " cell ", "text": " Hi", "background": "#FF0000", "hAlign": "center", "vAlign": "bottom"}), + ); + assert_eq!( + o, + json!({"kind": "noImage", "name": "cell", "url": "", "target": "", "message": "", "alt": "", "text": " Hi", "background": "#ff0000", "hAlign": "center", "vAlign": "bottom", "htmlText": false}) + ); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(slices(&mut s)[0]["name"], "cell"); + assert!(s.execute("object.slice.options", &json!({"kind": "htmlText"})).is_err(), "HTML Text is for type"); + assert!(s.execute("object.slice.options", &json!({"background": "pink-ish"})).is_err()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap().doc.slices[0].options, Default::default()); + // An object slice of type can be HTML Text. + let t = run(&mut s, "text.create", json!({"x": 10, "y": 100, "text": "Hello"}))["id"].as_u64().unwrap(); + run(&mut s, "object.slice.make", json!({"ids": [t]})); + run(&mut s, "select.set", json!({"ids": [t]})); + let o = run(&mut s, "object.slice.options", json!({"kind": "htmlText"})); + assert_eq!((o["kind"].clone(), o["htmlText"].clone()), (json!("htmlText"), json!(true))); + assert_eq!(s.doc().unwrap().doc.node(NodeId(t)).unwrap().slice.as_ref().unwrap().kind, SliceKind::HtmlText); +} + +#[test] +fn clip_to_artboard_hide_lock_select_and_delete_all() { + let mut s = session(); + let id = user_slice(&mut s, [250.0, 250.0, 100.0, 100.0]); + assert_eq!(rect_of(&mut s, id), [250.0, 250.0, 50.0, 50.0], "clipped to the artboard"); + assert_eq!(run(&mut s, "object.slice.clipToArtboard", json!({}))["on"], false); + assert_eq!(rect_of(&mut s, id), [250.0, 250.0, 100.0, 100.0]); + run(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().doc.slices_clip_to_artboard); + assert_eq!(run(&mut s, "object.slice.clipToArtboard", json!({"on": true}))["on"], true); + + assert_eq!(run(&mut s, "view.slices.hide", json!({}))["hidden"], true); + assert!(s.slices_hidden()); + assert_eq!(run(&mut s, "view.slices.lock", json!({"locked": true}))["locked"], true); + let l = run(&mut s, "slice.list", json!({})); + assert_eq!((l["hidden"].clone(), l["locked"].clone(), l["clipToArtboard"].clone()), (json!(true), json!(true), json!(true))); + run(&mut s, "view.slices.hide", json!({"hidden": false})); + assert!(!s.slices_hidden()); + + let a = rect(&mut s, 10.0, 10.0, 10.0, 10.0); + run(&mut s, "object.slice.make", json!({})); + assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 2); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.slices.clone(), sel.objects.is_empty()), (vec![NodeId(id), NodeId(a)], true)); + assert!(slices(&mut s).iter().filter(|v| v["selected"] == true).count() == 2); + // Selecting objects drops the slice selection. + run(&mut s, "select.all", json!({})); + assert!(s.doc().unwrap().selection.slices.is_empty()); + + assert_eq!(run(&mut s, "object.slice.deleteAll", json!({}))["count"], 2); + assert_eq!(user_count(&s), 0); + assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_none()); + assert!(s.execute("object.slice.deleteAll", &json!({})).is_err(), "nothing to delete"); +} + +#[test] +fn slices_survive_a_native_round_trip() { + let mut s = session(); + user_slice(&mut s, [100.0, 100.0, 40.0, 40.0]); + run(&mut s, "select.object.slices", json!({})); + run(&mut s, "object.slice.options", json!({"kind": "noImage", "text": "x"})); + let a = rect(&mut s, 10.0, 10.0, 10.0, 10.0); + run(&mut s, "object.slice.make", json!({})); + run(&mut s, "object.slice.options", json!({"alt": "logo"})); + run(&mut s, "object.slice.clipToArtboard", json!({"on": false})); + let doc = s.doc().unwrap().doc.clone(); + vectorcraft_testkit::invariants::check_native_roundtrip_exact(&doc).unwrap(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + assert_eq!(back.slices, doc.slices); + assert_eq!(back.node(NodeId(a)).unwrap().slice.as_ref().unwrap().alt, "logo"); + assert!(!back.slices_clip_to_artboard); + // A copy of an object slice is an object slice too, under its own id. + run(&mut s, "select.set", json!({"ids": [a]})); + run(&mut s, "edit.duplicate", json!({})); + assert_eq!(s.doc().unwrap().doc.object_slices().len(), 2); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 435ebced..f7a40fb9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -286,6 +286,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } + if !app.session.slices_hidden() { + slice_overlay(app, &painter, &xf); + } + // Selection visuals and tool overlays. if app.ui.view.edges { hover_highlight(app, &painter, &xf); @@ -902,6 +906,53 @@ fn thread_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { } } +/// The slices (unless View → Hide Slices): user and object slices outlined in the Slices +/// preferences' line colour, auto slices dashed and dimmer, each numbered at its top left (Show +/// Slice Numbers); selected slices have a bolder outline. The layout is cached per revision. +fn slice_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { + let Some(st) = app.session.active() else { return }; + let key = (st.uid, st.revision); + let slices = match &app.canvas.slices { + Some((k, s)) if *k == key => s.clone(), + _ => { + let s = std::sync::Arc::new(st.doc.slice_layout()); + app.canvas.slices = Some((key, s.clone())); + s + } + }; + if slices.is_empty() { + return; + } + let prefs = &app.session.prefs; + let line = vectorcraft_color::Color::from_hex(&prefs.slice_line_color).map_or(Color32::from_rgb(0xff, 0x3f, 0x3f), |c| { + let [r, g, b, _] = c.to_rgba8(1.0); + Color32::from_rgb(r, g, b) + }); + let dim = line.gamma_multiply(0.55); + let selected = &st.selection.slices; + let objects = &st.selection.objects; + for a in slices.iter() { + let auto = a.source == vectorcraft_doc::SliceSource::Auto; + let on = a.id.is_some_and(|id| selected.contains(&id) || objects.contains(&id)); + let q = xf.quad(a.rect); + let ring: Vec = q.iter().chain(q.first()).copied().collect(); + if auto { + p.extend(Shape::dashed_line(&ring, Stroke::new(1.0, dim), 3.0, 3.0)); + } else { + p.add(Shape::line(ring, Stroke::new(if on { 2.0 } else { 1.0 }, line))); + } + if prefs.show_slice_numbers + && let Some(tl) = q.first() + { + let font = egui::FontId::proportional(9.0); + let galley = p.layout_no_wrap(format!("{:02}", a.number), font, Color32::WHITE); + let badge = egui::Rect::from_min_size(*tl + vec2(1.0, 1.0), galley.size() + vec2(6.0, 2.0)); + p.rect_filled(badge, CornerRadius::ZERO, if auto { dim } else { line }); + p.galley(badge.min + vec2(3.0, 1.0), galley, Color32::WHITE); + } + } +} + fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { let Some(st) = app.session.active() else { return }; let tool = app.session.tool_id(); diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index bfdd5227..425ce655 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -48,6 +48,7 @@ mod save_pdf; pub mod save_style_library; pub mod save_swatch_library; mod shapes; +pub mod slices; pub mod spot_colors; pub(crate) mod svg_options; pub mod swatch_conflict; @@ -213,6 +214,8 @@ registry! { DxfOptions: [dxf_options::KIND] => dxf_options::SPEC, PlacementOptions: [placement_options::KIND] => placement_options::SPEC, Package: [package::KIND] => package::SPEC, + SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC, + DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/slices.rs b/crates/ui-egui/src/dialogs/slices.rs new file mode 100644 index 00000000..524ca5b9 --- /dev/null +++ b/crates/ui-egui/src/dialogs/slices.rs @@ -0,0 +1,257 @@ +//! Object → Slice → Slice Options… and Divide Slices…. +//! +//! Slice Options (`sliceOptions`, also a double click with the Slice Selection tool) edits the +//! selected slices' options: fields `kind` (`image`, `noImage`, `htmlText`), `name`, `url`, +//! `target`, `message`, `alt`, `text`, `background` (`""`, `matte` or `#rrggbb`), `hAlign`, +//! `vAlign`, and `__html` (whether HTML Text applies). OK runs `object.slice.options`. +//! +//! Divide Slices (`divideSlices`): `divideRows` with `rows` (slices down) or, with `rowMode: +//! "size"`, `rowHeight` (pt); `divideColumns` with `columns` or `columnWidth`. OK runs +//! `object.slice.divide`. + +use serde_json::{Map, Value, json}; +use vectorcraft_color::Color; +use vectorcraft_doc::{CellAlign, CellVAlign, SliceKind}; + +use super::{DialogSpec, form, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Slice Options. +pub const OPTIONS: &str = "sliceOptions"; +/// The dialog kind of Divide Slices. +pub const DIVIDE: &str = "divideSlices"; + +pub(super) const OPTIONS_SPEC: DialogSpec = + DialogSpec { heading: |_| "Slice Options".into(), body: options_body, confirm: options_confirm, min_width: 380.0, ..DialogSpec::FORM }; + +pub(super) const DIVIDE_SPEC: DialogSpec = + DialogSpec { heading: |_| "Divide Slices".into(), body: divide_body, confirm: divide_confirm, min_width: 320.0, ..DialogSpec::FORM }; + +/// Label column and field widths of the dialogs. +const LABEL_W: f32 = 110.0; +const FIELD_W: f32 = 220.0; + +/// Open Slice Options on the selected slices' options (those of the first one). +pub fn open_options(app: &mut VectorcraftApp) -> Result { + let v = app.session.execute("object.slice.options", &json!({})).map_err(|e| e.to_string())?; + let mut fields: Map = v.as_object().cloned().unwrap_or_default(); + let html = fields.remove("htmlText").unwrap_or(json!(false)); + fields.insert("__html".into(), html); + app.ui.dialog = Some(Dialog { kind: OPTIONS.into(), fields }); + Ok(Value::Null) +} + +/// Open Divide Slices: two rows and two columns, the sizes halving the first selected slice. +pub fn open_divide(app: &mut VectorcraftApp) -> Result { + let st = app.session.active().ok_or("no document open")?; + let first = st.selection.slices.iter().chain(&st.selection.objects).find_map(|id| st.doc.slice_bounds(*id)); + let (w, h) = first.map_or((50.0, 50.0), |r| (r.width() / 2.0, r.height() / 2.0)); + app.ui.dialog = Some(Dialog::new( + DIVIDE, + json!({"divideRows": true, "rows": 2, "rowMode": "count", "rowHeight": h, "divideColumns": true, "columns": 2, "columnMode": "count", "columnWidth": w}), + )); + Ok(Value::Null) +} + +/// `(value, label)` pairs of a keyed enum's values. +fn pairs(all: &[T], key: fn(T) -> &'static str, label: fn(T) -> &'static str) -> Vec<(&'static str, &'static str)> { + all.iter().map(|v| (key(*v), label(*v))).collect() +} + +fn options_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let kinds: Vec<_> = SliceKind::ALL.iter().filter(|k| **k != SliceKind::HtmlText || d.bool("__html")).copied().collect(); + form::choice(ui, d, "kind", "Slice Type:", (LABEL_W, FIELD_W), &pairs(&kinds, SliceKind::key, SliceKind::label)); + ui.add_space(6.0); + let kind = SliceKind::parse(&d.str("kind")).unwrap_or_default(); + match kind { + SliceKind::Image => { + for (key, label) in [("name", "Name:"), ("url", "URL:"), ("target", "Target:"), ("message", "Message:"), ("alt", "Alt:")] { + widgets::label_row(ui, label, LABEL_W, |ui| { + form::text(ui, d, key, FIELD_W - 12.0); + }); + } + } + SliceKind::NoImage => { + widgets::dim_label(ui, "Text Displayed in Cell:"); + form::text_area(ui, d, "text", LABEL_W + FIELD_W - 12.0, 4); + } + SliceKind::HtmlText => { + widgets::dim_label(ui, "The cell shows the type object's text as HTML."); + } + } + if kind != SliceKind::Image { + ui.add_space(6.0); + form::choice(ui, d, "hAlign", "Horizontal:", (LABEL_W, FIELD_W), &pairs(CellAlign::ALL, CellAlign::key, CellAlign::label)); + form::choice(ui, d, "vAlign", "Vertical:", (LABEL_W, FIELD_W), &pairs(CellVAlign::ALL, CellVAlign::key, CellVAlign::label)); + } + ui.add_space(6.0); + background_row(ui, d); + false +} + +/// Background: None, Matte, White, Black or Other (a colour button for any colour). +fn background_row(ui: &mut egui::Ui, d: &mut Dialog) { + const NAMES: [&str; 5] = ["None", "Matte", "White", "Black", "Other"]; + let cur = d.str("background").to_ascii_lowercase(); + let shown = match cur.as_str() { + "" => 0, + "matte" => 1, + "#ffffff" => 2, + "#000000" => 3, + _ => 4, + }; + widgets::label_row(ui, "Background:", LABEL_W, |ui| { + if let Some(i) = widgets::dropdown(ui, "slice-background", NAMES[shown], &NAMES, 120.0) { + let v = match i { + 0 => "", + 1 => "matte", + 2 => "#ffffff", + 3 => "#000000", + _ => "#808080", + }; + d.fields.insert("background".into(), json!(v)); + } + if let Some(c) = Color::from_hex(&cur) { + let [r, g, b, _] = c.to_rgba8(1.0); + let mut rgb = [r, g, b]; + if ui.color_edit_button_srgb(&mut rgb).changed() { + d.fields.insert("background".into(), json!(Color::rgb8(rgb[0], rgb[1], rgb[2]).to_hex())); + } + } + }); +} + +fn options_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let p: Map = d.fields.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect(); + run_and_close(app, "object.slice.options", Value::Object(p)) +} + +/// One direction of Divide Slices: the checkbox, then "N slices …, evenly spaced" or "N pt per +/// slice". +fn divide_section(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, keys: [&str; 4], labels: [&str; 2]) { + let [on, count, mode, size] = keys; + if widgets::check(ui, labels[0], d.bool(on), true) { + d.fields.insert(on.into(), json!(!d.bool(on))); + } + let enabled = d.bool(on); + let by_size = d.str(mode) == "size"; + ui.add_enabled_ui(enabled, |ui| { + ui.indent(on, |ui| { + ui.horizontal(|ui| { + if widgets::radio(ui, "", !by_size, enabled) { + d.fields.insert(mode.into(), json!("count")); + } + if let Some(n) = widgets::plain_field(ui, ("divide", count), d.f64(count, 2.0), "", 0, 50.0) { + d.fields.insert(count.into(), json!(n.round().max(1.0) as u64)); + } + widgets::dim_label(ui, labels[1]); + }); + ui.horizontal(|ui| { + if widgets::radio(ui, "", by_size, enabled) { + d.fields.insert(mode.into(), json!("size")); + } + form::length(ui, d, size, app.session.general_unit(), 70.0); + widgets::dim_label(ui, "per slice"); + }); + }); + }); +} + +fn divide_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + divide_section(app, ui, d, ["divideRows", "rows", "rowMode", "rowHeight"], ["Divide Horizontally Into", "slices down, evenly spaced"]); + ui.add_space(8.0); + divide_section(app, ui, d, ["divideColumns", "columns", "columnMode", "columnWidth"], ["Divide Vertically Into", "slices across, evenly spaced"]); + false +} + +fn divide_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut p = json!({}); + for [on, count, mode, size] in [["divideRows", "rows", "rowMode", "rowHeight"], ["divideColumns", "columns", "columnMode", "columnWidth"]] { + if !d.bool(on) { + continue; + } + if d.str(mode) == "size" { + p[size] = json!(d.f64(size, 0.0)); + } else { + p[count] = json!(d.f64(count, 2.0).round().max(1.0) as u64); + } + } + if p.as_object().is_none_or(|o| o.is_empty()) { + return Err("choose a direction to divide in".into()); + } + run_and_close(app, "object.slice.divide", p) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.session.paint.stroke = vectorcraft_color::Paint::None; + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 60, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("object.slice.make", json!({"ids": [id]})).unwrap(); + app + } + + /// Draw the open dialog once, headlessly. + fn draw(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn set(app: &mut VectorcraftApp, k: &str, v: Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + } + + #[test] + fn slice_options_opens_on_the_selection_and_ok_sets_them() { + let mut app = app(); + // The menu item (no params) opens the dialog through the app. + app.run("object.slice.options", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, OPTIONS); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("kind"), "image"); + for kind in ["image", "noImage"] { + set(&mut app, "kind", json!(kind)); + draw(&mut app); + } + set(&mut app, "text", json!("Hello cell")); + set(&mut app, "background", json!("#00ff00")); + draw(&mut app); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let l = app.run("slice.list", json!({})).unwrap(); + let own = l["slices"].as_array().unwrap().iter().find(|s| s["source"] == "object").unwrap().clone(); + assert_eq!(own["options"]["kind"], "noImage"); + assert_eq!(own["options"]["text"], "Hello cell"); + assert_eq!(own["options"]["background"], "#00ff00"); + // One undo step. + app.run("edit.undo", json!({})).unwrap(); + let l = app.run("slice.list", json!({})).unwrap(); + assert!(l["slices"].as_array().unwrap().iter().any(|s| s["options"]["kind"] == "image" && s["source"] == "object")); + } + + #[test] + fn divide_slices_cuts_by_count_or_size() { + let mut app = app(); + app.run("object.slice.divide", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, DIVIDE); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("columnWidth", 0.0), 30.0); + draw(&mut app); + set(&mut app, "columns", json!(3)); + set(&mut app, "rowMode", json!("size")); + set(&mut app, "rowHeight", json!(15)); + super::super::confirm(&mut app).unwrap(); + // 40 pt in 15 pt rows: 3 rows; 3 columns. + assert_eq!(app.session.active().unwrap().doc.slices.len(), 9); + app.run("object.slice.divide", json!({})).unwrap(); + set(&mut app, "divideRows", json!(false)); + set(&mut app, "divideColumns", json!(false)); + assert!(super::super::confirm(&mut app).is_err()); + } +} diff --git a/crates/ui-egui/src/dialogs/tools.rs b/crates/ui-egui/src/dialogs/tools.rs index 1d74481e..a56e35fd 100644 --- a/crates/ui-egui/src/dialogs/tools.rs +++ b/crates/ui-egui/src/dialogs/tools.rs @@ -56,6 +56,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) { } return; } + // Double-clicking a slice with the Slice Selection tool: Slice Options. + super::slices::OPTIONS => { + if let Err(e) = super::slices::open_options(app) { + app.status(e); + } + return; + } _ => return, }; app.ui.dialog = Some(d); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6c8c496a..02e81234 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -56,6 +56,8 @@ mod tests_recolor; #[cfg(test)] mod tests_save; #[cfg(test)] +mod tests_slices; +#[cfg(test)] mod tests_svg; #[cfg(test)] mod tests_svgsave; @@ -156,8 +158,13 @@ pub struct CanvasCache { pub last_ms: f64, pub worker: Option, pub worker_started: bool, + /// The slices as laid out for (document uid, revision): the canvas draws them every frame. + pub slices: Option, } +/// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision). +pub type SliceCache = ((u64, u64), std::sync::Arc>); + #[derive(Clone, Debug, PartialEq)] pub struct CacheKey { pub doc: usize, @@ -253,6 +260,7 @@ impl VectorcraftApp { last_ms: 0.0, worker: None, worker_started: false, + slices: None, }, perf: Perf::default(), integrated_titlebar: false, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a6cf8e75..7164d903 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -841,6 +841,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::package::open(app), "file.showPackage" => crate::dialogs::package::show_package(app, p), "docInfo.save" => crate::panels::doc_info::save_report(app, p), + // Slice Options… and Divide Slices…: their dialogs, on the selected slices. + "object.slice.options" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_options(app), + "object.slice.divide" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_divide(app), _ => return None, }; Some(r) @@ -883,6 +886,8 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { } "window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()), "window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness), + "view.slices.lock" => app.session.slices_locked(), + "object.slice.clipToArtboard" => app.session.active().is_some_and(|d| d.doc.slices_clip_to_artboard), "view.proofColors" => vectorcraft_render::proof::view().proof_colors, "view.overprintPreview" => vectorcraft_render::proof::view().overprint, "view.proofSetup" => p.get("target").and_then(Value::as_str) == Some(vectorcraft_render::proof::view().setup.target.id().as_str()), @@ -926,6 +931,7 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { "view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(), "view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(), "view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(), + "view.slices.hide" => if app.session.slices_hidden() { "Show Slices" } else { "Hide Slices" }.into(), id if id.starts_with("file.openRecent") => recent_slot(app, id) .map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string())) .unwrap_or_else(|| "—".into()), @@ -1285,7 +1291,23 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { Sep, c("Make Pixel Perfect", "object.makePixelPerfect"), Sep, - sub("Slice", vec![todo("Make"), todo("Release"), todo("Create from Guides"), todo("Create from Selection")]), + sub( + "Slice", + vec![ + c("Make", "object.slice.make"), + c("Release", "object.slice.release"), + c("Create from Guides", "object.slice.fromGuides"), + c("Create from Selection", "object.slice.fromSelection"), + Sep, + c("Duplicate Slice", "object.slice.duplicate"), + c("Combine Slices", "object.slice.combine"), + c("Divide Slices…", "object.slice.divide"), + Sep, + c("Delete All", "object.slice.deleteAll"), + c("Slice Options…", "object.slice.options"), + c("Clip to Artboard", "object.slice.clipToArtboard"), + ], + ), Sep, sub( "Path", @@ -1512,6 +1534,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Brush Strokes", "select.object.brushStrokes"), c("Bristle Brush Strokes", "select.object.bristleBrushStrokes"), c("Clipping Masks", "select.object.clippingMasks"), + c("Slices", "select.object.slices"), c("Stray Points", "select.object.strayPoints"), c("Open Paths", "select.object.openPaths"), Sep, @@ -1569,6 +1592,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Hide Edges", "view.edges"), c("Hide Artboards", "view.artboards"), todo("Show Print Tiling"), + c("Hide Slices", "view.slices.hide"), + c("Lock Slices", "view.slices.lock"), Sep, sub("Rulers", vec![c("Show Rulers", "view.rulers"), todos("Change to Global Rulers", "Cmd+Alt+R"), todo("Show Video Rulers")]), c("Hide Bounding Box", "view.boundingBox"), diff --git a/crates/ui-egui/src/tests_slices.rs b/crates/ui-egui/src/tests_slices.rs new file mode 100644 index 00000000..5a5180e4 --- /dev/null +++ b/crates/ui-egui/src/tests_slices.rs @@ -0,0 +1,74 @@ +//! Slices on the canvas and in the menus. + +use egui::{Pos2, vec2}; +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::VectorcraftApp; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.session.paint.stroke = vectorcraft_color::Paint::None; + app +} + +/// One headless canvas frame on an 800 × 600 window; the texts it painted. +fn frame_texts(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| crate::canvas::show(app, ui)); + out.textures_delta.clear(); + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_string()), + _ => None, + }) + .collect() +} + +#[test] +fn the_canvas_numbers_slices_until_they_are_hidden() { + let mut app = app(); + let ctx = egui::Context::default(); + assert!(!frame_texts(&mut app, &ctx).iter().any(|t| t == "01"), "no slices, no numbers"); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap(); + app.run("object.slice.make", json!({})).unwrap(); + let texts = frame_texts(&mut app, &ctx); + // The object slice and the four auto slices around it. + for n in ["01", "02", "03", "04", "05"] { + assert!(texts.iter().any(|t| t == n), "slice {n} numbered: {texts:?}"); + } + let cached = app.canvas.slices.as_ref().map(|(_, s)| s.len()); + assert_eq!(cached, Some(5)); + // Show Slice Numbers off: outlines only. + app.run("prefs.set", json!({"key": "showSliceNumbers", "value": false})).unwrap(); + assert!(!frame_texts(&mut app, &ctx).iter().any(|t| t == "01")); + app.run("prefs.set", json!({"key": "showSliceNumbers", "value": true})).unwrap(); + app.run("view.slices.hide", json!({})).unwrap(); + assert!(!frame_texts(&mut app, &ctx).iter().any(|t| t == "01"), "hidden"); +} + +#[test] +fn slice_menu_items_follow_the_state() { + let mut app = app(); + let en = |app: &VectorcraftApp, id: &str| crate::menus::enabled(app, id); + assert!(!en(&app, "object.slice.make") && !en(&app, "object.slice.release") && !en(&app, "object.slice.deleteAll")); + assert!(en(&app, "object.slice.fromGuides")); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + assert!(en(&app, "object.slice.make") && !en(&app, "object.slice.release")); + app.run("object.slice.make", json!({})).unwrap(); + for id in ["object.slice.release", "object.slice.duplicate", "object.slice.divide", "object.slice.options", "object.slice.deleteAll"] { + assert!(en(&app, id), "{id}"); + } + assert!(app.session.execute("object.slice.combine", &json!({})).is_err(), "one slice can't be combined"); + assert_eq!(crate::menus::checked(&app, "object.slice.clipToArtboard", &json!({})), Some(true)); + app.run("object.slice.clipToArtboard", json!({})).unwrap(); + assert_eq!(crate::menus::checked(&app, "object.slice.clipToArtboard", &json!({})), Some(false)); + assert_eq!(crate::menus::dynamic_label(&app, "view.slices.hide", "Hide Slices"), "Hide Slices"); + app.run("view.slices.hide", json!({})).unwrap(); + assert_eq!(crate::menus::dynamic_label(&app, "view.slices.hide", "Hide Slices"), "Show Slices"); + assert_eq!(crate::menus::checked(&app, "view.slices.lock", &json!({})), Some(false)); + app.run("view.slices.lock", json!({})).unwrap(); + assert_eq!(crate::menus::checked(&app, "view.slices.lock", &json!({})), Some(true)); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7ac6e247..2cde208d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -318,3 +318,10 @@ gets a `confirm` dialog whose OK runs `file.saveAs`). Fields: `folder` (the loca `copyLinks`, `linksFolder`, `relink`, `copyFonts`, `report`. `ui.dialog.confirm` saves unsaved changes, runs `file.package` and then opens a `confirm` dialog whose OK runs `file.showPackage {folder}`; the web downloads the zip instead. Document Info's flyout picks a category and Save… runs `docInfo.save {path?, selectionOnly?}`. + +Slices: through the app, `object.slice.options` and `object.slice.divide` with no params open their dialogs on the +selected slices. `sliceOptions` (fields `kind`: `image`/`noImage`/`htmlText`, `name`, `url`, `target`, `message`, +`alt`, `text`, `background`: `""`/`matte`/`#rrggbb`, `hAlign`, `vAlign`; `__html` says whether HTML Text applies) +runs `object.slice.options` on `ui.dialog.confirm`; `divideSlices` (fields `divideRows`, `rows`, `rowMode`: +`count`/`size`, `rowHeight`, and `divideColumns`, `columns`, `columnMode`, `columnWidth`) runs `object.slice.divide`. +Read slice options with `slice.list`. diff --git a/docs/mcp.md b/docs/mcp.md index c38c9cae..7ecab56e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1114,3 +1114,27 @@ the package report write. {"name":"run_command","arguments":{"command":"document.info","params":{"format":"text","category":"fontDetails"}}} ``` +## Slices + +Object → Slice cuts the art into the pieces web output saves. User slices are rectangles of their own +(`document.slices`, ids from the object id counter); `object.slice.make` turns the selected objects into object +slices, whose slice follows the object's bounds; auto slices fill what no other slice covers (the artboards with +Clip to Artboard on, the default, else the art and the slices). `slice.list` returns every slice as laid out, +numbered left to right and top to bottom: `{number, id (null for auto slices), source: user|object|auto, name, x, y, +width, height, options, selected}`, plus `clipToArtboard`, `hidden` and `locked`. + +The Object → Slice commands act on the selected slices: the ones `select.object.slices` (Select → Object → Slices) +selects, and the selected objects that are object slices. `object.slice.release` (an object slice leaves its object, +a user slice becomes an unpainted rectangle), `object.slice.fromGuides` (the grid the ruler guides cut the artboards +into), `object.slice.fromSelection`, `object.slice.duplicate {dx?, dy?}`, `object.slice.combine`, +`object.slice.divide {rows? | rowHeight?, columns? | columnWidth?}`, `object.slice.deleteAll`, +`object.slice.options {kind?: image|noImage|htmlText, name?, url?, target?, message?, alt?, text?, background?: +""|matte|#rrggbb, hAlign?, vAlign?}` and `object.slice.clipToArtboard {on?}` are each one undo step. +`view.slices.hide {hidden?}` and `view.slices.lock {locked?}` are session view state; the canvas draws the slices in +the `sliceLineColor` preference, numbered while `showSliceNumbers` is on. + +```json +{"name":"run_command","arguments":{"command":"object.slice.make","params":{}}} +{"name":"run_command","arguments":{"command":"object.slice.options","params":{"name":"logo","url":"https://example.com","alt":"Logo"}}} +{"name":"run_command","arguments":{"command":"slice.list","params":{}}} +```