Files
vectorcraft/crates/doc/src/slices.rs
T
arhammusheer 5beac4f122 #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc

# Conflicts:
#	crates/engine/src/cmd/graph.rs
#	crates/svg/src/export.rs
2026-10-12 11:02:32 +07:00

316 lines
13 KiB
Rust

//! 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<Self> {
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>_<number>` ([`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<NodeId>,
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.extras().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)?.extras().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<Rect> {
match self.slice(id) {
Some(s) => Some(s.rect),
None => self.node(id).filter(|n| n.extras().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<Node>], out: &mut Vec<(NodeId, Rect)>) {
for n in nodes.iter().filter(|n| n.visible) {
if n.extras().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<NodeId> {
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<Rect> {
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<SliceArea> {
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<Rect> = cut.iter().map(|(_, _, r)| *r).collect();
let autos = region.map(|g| auto_slices(g, &rects)).unwrap_or_default();
let mut out: Vec<SliceArea> = 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 `<document>_<number>`
/// (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<Rect> {
if !valid_rect(region) {
return vec![];
}
let inside: Vec<Rect> = cut.iter().map(|r| region.intersect(*r)).filter(|r| valid_rect(*r)).collect();
let mut ys: Vec<f64> = 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<Rect>, Vec<Rect>) = (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
}