diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 20d0623d4d..6b16973cf1 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -358,6 +358,34 @@ mod tests { assert!((p.x - 25.0).abs() < 1e-6 && (p.y - 25.0).abs() < 1e-6, "{p:?}"); } + #[test] + fn coons_places_are_where_the_patch_bends() { + // A rectangle's sides have 65 evenly spaced points. + let [u, v] = Coons::rect(Rect::new(0.0, 0.0, 100.0, 50.0)).places(); + assert_eq!((u.len(), v.len()), (65, 65)); + assert!(u.iter().enumerate().all(|(i, x)| (x - i as f64 / 64.0).abs() < 1e-12), "{u:?}"); + // A top object with a curved top: between two places next to each other the patch is + // straight, in `u` and in `v`. + let mut path = kurbo::BezPath::new(); + path.move_to((0.0, 0.0)); + path.curve_to((30.0, -20.0), (70.0, 10.0), (100.0, 0.0)); + path.line_to((100.0, 50.0)); + path.line_to((40.0, 50.0)); + path.line_to((0.0, 50.0)); + path.close_path(); + let c = Coons::from_top_object(&PathData::from_bezpath(&path), Some([0, 1, 2, 4])).unwrap(); + let [u, v] = c.places(); + assert!(u.first() == Some(&0.0) && u.last() == Some(&1.0) && u.windows(2).all(|w| w[0] < w[1]), "{u:?}"); + assert!(v.first() == Some(&0.0) && v.last() == Some(&1.0) && v.windows(2).all(|w| w[0] < w[1]), "{v:?}"); + for w in u.windows(2) { + for y in [0.0, 0.3, 1.0] { + let mid = c.eval((w[0] + w[1]) / 2.0, y); + let half = c.eval(w[0], y).midpoint(c.eval(w[1], y)); + assert!(mid.distance(half) < 1e-9, "{w:?} {y}: {mid:?} {half:?}"); + } + } + } + #[test] fn maps_go_on_past_the_content_box() { // A stroke outline reaches past the box: a flat mesh and a rectangle stay the identity there. diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index b6d5bbc88d..ef0a9a908d 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -338,6 +338,31 @@ impl Coons { let end = |side: &[Point]| side.last().copied().unwrap_or_default(); [self.top[0], end(&self.top), end(&self.bottom), self.bottom[0]] } + + /// The places in `u` where the top or bottom side has a point, and the places in `v` where the + /// left or right side has one, rising from 0 to 1. Between two places next to each other, each + /// side runs straight, so [`Self::eval`] changes linearly in `u` and in `v` inside each cell of + /// the grid they make. + pub fn places(&self) -> [Vec; 2] { + let sides = [&self.top, &self.right, &self.bottom, &self.left]; + let at = |which: usize| -> Vec { + match self.knots.as_deref().and_then(|k| k.get(which)) { + Some(knots) => knots.clone(), + None => { + let last = sides[which].len().saturating_sub(1).max(1); + (0..=last).map(|i| i as f64 / last as f64).collect() + } + } + }; + let merge = |a: usize, b: usize| { + let mut v = at(a); + v.extend(at(b)); + v.sort_by(f64::total_cmp); + v.dedup(); + v + }; + [merge(0, 2), merge(1, 3)] + } } /// The most points one side of [`Coons::from_top_object`] holds while it adds points between its diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 7f2ca0e3cb..e91e12a4bd 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -10457,7 +10457,7 @@ fn an_envelope_is_written_in_the_order_the_reader_reads_it() { /// Envelopes the reader would open in another shape, or not at all, are written as their distorted /// art with a warning, and open as that art: an envelope of type, one in a turned frame, a warp drawn /// without its style's net, a mesh the reader would lay out the other way round, a top object -/// whose sides don't pair up, a Fidelity that isn't a whole number and bounds margins kept with +/// with a side of more segments than the grid has columns, a Fidelity that isn't a whole number and bounds margins kept with /// Distort Appearance off. Other live objects keep their own warning. #[test] fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { @@ -10494,15 +10494,10 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { **kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: transpose(points, 3, 2), handles: vec![] }; } }); - let mut uneven = kurbo::BezPath::new(); - uneven.move_to((30.0, 10.0)); - for p in [(100.0, 0.0), (170.0, 10.0), (170.0, 90.0), (30.0, 90.0)] { - uneven.line_to(p); - } - uneven.close_path(); - let lopsided = change(&|k| { + // A top side of 51 segments: more grid columns than the reader reads. + let crowded = change(&|k| { if let NodeKind::Envelope { kind, .. } = k { - **kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven), corners: None }; + **kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven_top_object(51)), corners: None }; } }); let fractional = change(&|k| { @@ -10521,7 +10516,7 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { ("turned", turned), ("old warp", old_warp), ("sideways mesh", sideways), - ("lopsided top object", lopsided), + ("top object past the grid's limit", crowded), ("fractional fidelity", fractional), ("margin kept", margin_kept), ]; @@ -10542,6 +10537,131 @@ fn transpose(points: Vec, rows: usize, cols: usize) -> Vec kurbo::BezPath { + let mut path = kurbo::BezPath::new(); + path.move_to((30.0, 10.0)); + for k in 1..top { + path.line_to((30.0 + 140.0 * k as f64 / top as f64, if k % 2 == 1 { 20.0 } else { 10.0 })); + } + for p in [(170.0, 10.0), (170.0, 90.0), (30.0, 90.0)] { + path.line_to(p); + } + path.close_path(); + path +} + +/// Do the patches of top objects `a` and `b` lie within 0.001 pt of each other at every place of a +/// 32 × 32 grid? +fn same_top_object_patch(a: &vectorcraft_doc::live::EnvelopeKind, b: &vectorcraft_doc::live::EnvelopeKind) -> bool { + use vectorcraft_doc::live::{Coons, EnvelopeKind}; + let (EnvelopeKind::TopObject { path, corners }, EnvelopeKind::TopObject { path: p, corners: c }) = (a, b) else { return false }; + let (Some(a), Some(b)) = (Coons::from_top_object(path, *corners), Coons::from_top_object(p, *c)) else { return false }; + let at = |i: u32| f64::from(i) / 32.0; + (0..=32).all(|j| (0..=32).all(|i| a.eval(at(i), at(j)).distance(b.eval(at(i), at(j))) < 1e-3)) +} + +/// A top object whose top and bottom sides have different numbers of segments is written live, its +/// side with fewer segments split into as many as the side across from it has, with Edit Contents off +/// and on: two straight segments over one straight one, three over one, and two over one curve. +/// VectorCraft's reader opens it as a top object with its corners on the same four points, the same +/// patch, contents, Fidelity, Edit Contents and Envelope Options, and saved again it opens the same. +#[test] +fn a_top_object_with_uneven_sides_is_read_back_live() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let mut curved = kurbo::BezPath::new(); + curved.move_to((30.0, 10.0)); + curved.line_to((100.0, 0.0)); + curved.line_to((170.0, 10.0)); + curved.line_to((170.0, 90.0)); + curved.curve_to((130.0, 105.0), (70.0, 105.0), (30.0, 90.0)); + curved.close_path(); + let shapes = [("two over one", uneven_top_object(2), 6), ("three over one", uneven_top_object(3), 8), ("two over a curve", curved, 6)]; + for (name, path, anchors) in shapes { + let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&path), corners: None }; + for editing in [false, true] { + let what = format!("{name}, editing {editing}"); + let doc = envelope_doc(kind.clone(), 50.0, editing, EnvelopeOptions::NEW); + let text = program(&doc); + assert!(text.contains("AIDeformOptionsKey") && text.contains("%_3 /Int (AIDeformStyle) ,\n"), "{what}: {text}"); + let (back, warnings, notes) = envelope_back(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}: {warnings:?}"); + assert!(!notes.iter().any(|w| w == crate::import::ENVELOPE_DRAWN), "{what}: {notes:?}"); + let NodeKind::Envelope { content, kind: got, fidelity, editing: e, options, frame } = &back.kind else { panic!("{what}: {back:#?}") }; + assert_eq!((*fidelity, *e, *options, **frame), (50.0, editing, EnvelopeOptions::NEW, Affine::IDENTITY), "{what}"); + let EnvelopeKind::TopObject { path: outline, corners: Some(corners) } = &**got else { panic!("{what}: {got:?}") }; + let sp = &outline.subpaths[0]; + assert_eq!(sp.anchors.len(), anchors, "{what}: {outline:?}"); + let points = corners.map(|i| sp.anchors[i as usize].p); + let want = [(30.0, 10.0), (170.0, 10.0), (170.0, 90.0), (30.0, 90.0)].map(kurbo::Point::from); + assert!(points.iter().zip(want).all(|(a, b)| a.distance(b) < 1e-6), "{what}: {points:?}"); + assert!(same_top_object_patch(&kind, got), "{what}: {got:?}"); + let colors: Vec> = content.iter().map(|c| c.appearance.fill_paint().color()).collect(); + assert_eq!(colors, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{what}"); + let mut again = Document::new(200.0, 100.0); + let layer = again.layers[0].id; + again.insert(Some(layer), usize::MAX, back.clone()).unwrap(); + let (twice, warnings, notes) = envelope_back(&again); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}, saved again: {warnings:?}"); + let NodeKind::Envelope { kind: twice, .. } = &twice.kind else { panic!("{what}, saved again: {twice:#?} {notes:?}") }; + assert!(same_top_object_patch(&kind, twice), "{what}, saved again: {twice:?}"); + } + } +} + +/// A top object with a top side of 50 segments over a bottom side of one is written live with a grid +/// of 50 columns, the most the reader reads. +#[test] +fn a_top_object_split_to_the_grids_limit_is_read_back_live() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven_top_object(50)), corners: None }; + let doc = envelope_doc(kind.clone(), 50.0, false, EnvelopeOptions::NEW); + let text = program(&doc); + assert!(text.contains("[50 1] /Size X#"), "{text}"); + let (back, warnings, notes) = envelope_back(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let NodeKind::Envelope { kind: got, .. } = &back.kind else { panic!("{back:#?} {notes:?}") }; + assert!(same_top_object_patch(&kind, got), "{got:?}"); +} + +/// A top object whose side with fewer segments would share out the content another way once split +/// (two curves that aren't one curve, under three segments) is written as its art, with a warning. +#[test] +fn a_top_object_whose_split_side_would_map_another_way_is_written_as_its_art() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let mut path = uneven_top_object(3); + path.truncate(5); + path.curve_to((150.0, 110.0), (120.0, 110.0), (100.0, 90.0)); + path.curve_to((80.0, 95.0), (40.0, 80.0), (30.0, 90.0)); + path.close_path(); + let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&path), corners: None }; + let doc = envelope_doc(kind, 50.0, false, EnvelopeOptions::NEW); + assert!(!program(&doc).contains("AIDeformOptionsKey")); + let (back, warnings, _) = envelope_back(&doc); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + assert!(matches!(back.kind, NodeKind::Group { .. }), "{back:#?}"); +} + +/// A top object whose split side would map the content another way only between the places of an +/// even grid of eighths is written as its art too: a bottom side of a long straight segment, which is +/// split, and a short curve in its last eighth, which the reader then samples at fewer points. +#[test] +fn a_top_object_whose_split_side_differs_between_eighths_is_written_as_its_art() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let mut path = uneven_top_object(3); + path.truncate(5); + path.curve_to((168.0, 85.0), (164.0, 85.0), (162.0, 90.0)); + path.line_to((30.0, 90.0)); + path.close_path(); + let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&path), corners: None }; + let doc = envelope_doc(kind, 50.0, false, EnvelopeOptions::NEW); + assert!(!program(&doc).contains("AIDeformOptionsKey")); + let (back, warnings, _) = envelope_back(&doc); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + assert!(matches!(back.kind, NodeKind::Group { .. }), "{back:#?}"); +} + /// An envelope with its contents in an opacity mask's art is written as its art there, and an /// envelope in a group stays live. #[test] diff --git a/crates/eps/src/ai_editing/writer/envelopes.rs b/crates/eps/src/ai_editing/writer/envelopes.rs index 5e5e9068c0..cae8993827 100644 --- a/crates/eps/src/ai_editing/writer/envelopes.rs +++ b/crates/eps/src/ai_editing/writer/envelopes.rs @@ -8,13 +8,14 @@ //! contents in an `X=` … `X+` block (while they are edited, the grid) as `AIDeformArtKey`, and the //! kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object); then the distorted art on plain //! lines, and the plugin's `XP` after it. A top object's outline is the edge of the grid written -//! for it, one grid edge to each segment of its sides. An envelope the reader would open in another -//! shape is written as its distorted art. +//! for it, one grid edge to each segment of its sides, a side with fewer segments than the side +//! across from it split into more. An envelope the reader would open in another shape is written as +//! its distorted art. use std::fmt::Write; use std::sync::Arc; -use kurbo::{Point, Vec2}; +use kurbo::{CubicBez, ParamCurve, Point, Vec2}; use vectorcraft_doc::live::{ Coons, EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, PreserveShape, WARP_NET, envelope_content_boxes, envelope_grid, envelope_stroke_margin, warp_grid, @@ -173,11 +174,44 @@ fn reads_as_written(rows: usize, cols: usize, pts: &[GridPoint]) -> bool { envelope_upright(&mesh).is_some_and(|(r, c, read)| (r as usize, c as usize) == (rows, cols) && read == pts) } +/// `pieces`, one side of a top object, split into `n` pieces: each piece is split into equal spans +/// of its curve parameter, the extra pieces going one at a time to the piece with the longest +/// control polygon for each of its spans. `None` where `n` is fewer pieces than it has, more than +/// [`MAX_GRID`], or a split leaves a piece of no length. +fn split_to(pieces: &[CubicBez], n: usize) -> Option> { + if n < pieces.len() || n > MAX_GRID { + return None; + } + if n == pieces.len() { + return Some(pieces.to_vec()); + } + let polygon = |c: &CubicBez| c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3); + let lengths: Vec = pieces.iter().map(polygon).collect(); + let mut parts = vec![1usize; pieces.len()]; + for _ in pieces.len()..n { + let most = (0..pieces.len()).max_by(|a, b| (lengths[*a] / parts[*a] as f64).total_cmp(&(lengths[*b] / parts[*b] as f64)))?; + parts[most] += 1; + } + let mut out = Vec::with_capacity(n); + for (c, p) in pieces.iter().zip(parts) { + for j in 0..p { + let piece = c.subsegment(j as f64 / p as f64..(j + 1) as f64 / p as f64); + if [piece.p1, piece.p2, piece.p3].iter().all(|q| q.distance(piece.p0) <= 1e-9) { + return None; + } + out.push(piece); + } + } + Some(out) +} + /// The grid a top object with outline `path` and corners `corners` is written with: its edge runs -/// through the outline's anchors clockwise from the top left corner, one grid edge to a segment, so -/// the top and bottom sides need as many segments as each other, as do the left and right sides; -/// the points inside blend the sides. `None` where that grid's edge, read back as the top object's -/// outline, doesn't give the same patch ([`Coons::from_top_object`]) at every place checked. +/// through the outline's anchors clockwise from the top left corner, one grid edge to a segment, +/// the points inside blending the sides. The top and bottom sides need as many grid edges as each +/// other, as do the left and right sides, so where one side of a pair has fewer segments than the +/// other, its segments are split ([`split_to`]) without changing its shape. `None` where that +/// grid's edge, read back as the top object's outline, doesn't give the same patch +/// ([`Coons::from_top_object`]) at every place checked. fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, usize, Vec)> { let coons = Coons::from_top_object(path, corners)?; // The outline, as the patch takes it: the largest subpath. @@ -214,10 +248,35 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, } }; let gap = |k: usize| (at[(k + 1) % 4] + count - at[k]) % count; - let (cols, rows) = (gap(0), gap(1)); - if gap(2) != cols || gap(3) != rows || cols > MAX_GRID || rows > MAX_GRID { + if (0..4).any(|k| gap(k) > MAX_GRID) { return None; } + // Each side's segments from its corner on, a straight one as a cubic with its handles a third of + // the way along, which the patch spreads evenly along, as it does a straight segment. + let side = |k: usize| -> Vec { + (0..gap(k)) + .map(|j| { + let i = (at[k] + j) % count; + let seg = sp.segment(i); + if sp.segment_is_line(i) { + CubicBez::new(seg.p0, seg.p0.lerp(seg.p3, 1.0 / 3.0), seg.p0.lerp(seg.p3, 2.0 / 3.0), seg.p3) + } else { + seg + } + }) + .collect() + }; + let mut sides = [side(0), side(1), side(2), side(3)]; + // Opposite sides take as many segments as each other: the one with fewer has some split. + for k in 0..2 { + let n = sides[k].len().max(sides[k + 2].len()); + for s in [k, k + 2] { + sides[s] = split_to(&sides[s], n)?; + } + } + let (cols, rows) = (sides[0].len(), sides[1].len()); + let edge: Vec = sides.concat(); + let total = edge.len(); let width = cols + 1; let mut pts: Vec = vec![(Point::ZERO, [Vec2::ZERO; 4]); (rows + 1) * width]; // Where the `k`th anchor from the top left corner goes, and the slots of the handles of the @@ -233,20 +292,15 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, (rows - (k - 2 * cols - rows), 0, H_UP, H_DOWN) } }; - for k in 0..count { - let i = (at[0] + k) % count; - let seg = sp.segment(i); - // A straight segment's patch spreads evenly along it, as a cubic's with its handles a third - // of the way does. - let (p1, p2) = if sp.segment_is_line(i) { (seg.p0.lerp(seg.p3, 1.0 / 3.0), seg.p0.lerp(seg.p3, 2.0 / 3.0)) } else { (seg.p1, seg.p2) }; + for (k, seg) in edge.iter().enumerate() { let (r, c, out, into) = place(k); - let (nr, nc, _, _) = place((k + 1) % count); + let (nr, nc, _, _) = place((k + 1) % total); if let Some(q) = pts.get_mut(r * width + c) { q.0 = seg.p0; - q.1[out] = p1 - seg.p0; + q.1[out] = seg.p1 - seg.p0; } if let Some(q) = pts.get_mut(nr * width + nc) { - q.1[into] = p2 - seg.p3; + q.1[into] = seg.p2 - seg.p3; } } // The points inside, blended from the sides. @@ -274,9 +328,20 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, let read = Coons::from_top_object(&outline, Some(corners))?; let [tl, tr, br, bl] = coons.corners(); let reach = tl.distance(br).max(tr.distance(bl)).max(1.0) * TOP_OBJECT_TOLERANCE; - const STEPS: usize = 8; - let at = |i: usize| i as f64 / STEPS as f64; - let same = (0..=STEPS).all(|j| (0..=STEPS).all(|i| coons.eval(at(i), at(j)).distance(read.eval(at(i), at(j))) <= reach)); + // Each side of both patches runs straight between the places the sides have points + // ([`Coons::places`]), so two matching sides lie furthest apart at one of those places. A point + // inside a patch blends its four sides and corners, so where every side lies within a third of + // `reach` of the other patch's, the patches lie within `reach` of each other everywhere. + let merge = |a: Vec, b: Vec| { + let mut v = [a, b].concat(); + v.sort_by(f64::total_cmp); + v.dedup(); + v + }; + let ([cu, cv], [ru, rv]) = (coons.places(), read.places()); + let (us, vs) = (merge(cu, ru), merge(cv, rv)); + let near = |u: f64, v: f64| coons.eval(u, v).distance(read.eval(u, v)) <= reach / 3.0; + let same = us.iter().all(|&u| near(u, 0.0) && near(u, 1.0)) && vs.iter().all(|&v| near(0.0, v) && near(1.0, v)); same.then_some((rows, cols, pts)) } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 430f323fc5..59e0149701 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1755,6 +1755,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes of type, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 envelopes and images with a Warp in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`). + A top object whose top and bottom sides, or left and right sides, have different numbers of segments is also saved live: the side with fewer segments is split into as many as the side across from it has (each segment into equal spans of its curve, a straight one at even steps), up to the 50 a side the grid holds, so the file opens with the same corners and the same mapping of the contents, its outline holding the added anchors. Where the split side would share out the contents another way, as a side of two curves that aren't one curve can, or a side has more than 50 segments, the envelope is written as its distorted art, with the warning (`a_top_object_with_uneven_sides_is_read_back_live`, `a_top_object_split_to_the_grids_limit_is_read_back_live` and `a_top_object_whose_split_side_would_map_another_way_is_written_as_its_art` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). The same holds for the objects of an opacity mask's art, a mask inside another mask's art and a hidden art root included, so a mask whose art has Offset Path opens drawing the image it masks as it did, with the effect live; geometry effects that no art style holds on a mask's art, such as Roughen, are written applied to the art, which opens drawing what it drew (`a_masked_image_with_live_offset_path_round_trips`, `hidden_and_nested_mask_art_keep_live_offset_path` and `a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines).