From 8f3f6a82d7df3b66d5aa328e0e0ef6d1f5ce557a Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 20:44:39 +0000 Subject: [PATCH] .ai import: a warp of type opens as a mesh envelope on its saved grid A warp of type whose own warp doesn't draw as its drawn art is checked again as a mesh envelope on the grid the editing data saves for it, its points and handles as saved. Where that draws as the drawn art, it opens live in its place: the type stays editable in that shape, and the warp's style and bend are lost. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/envelope.rs | 77 ++++++++++----- crates/eps/src/import/tests_native.rs | 137 +++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 24 deletions(-) diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index c0da27f2a..fe3ba51f4 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -20,8 +20,9 @@ //! `(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`. While the contents are edited, the group //! holds the contents and the dictionary the mesh. A top object's outline is the edge of its -//! one-patch mesh. An envelope whose parts don't read, or that doesn't come out as drawn, keeps its -//! drawn art. +//! one-patch mesh. A warp's mesh is a grid of its shape: a warp of type whose own warp doesn't come +//! out as drawn opens as a mesh envelope on that grid where that does. An envelope whose parts don't +//! read, or that doesn't come out as drawn, keeps its drawn art. use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; @@ -269,6 +270,23 @@ fn unwrapped(mut nodes: Vec) -> Vec> { /// The live envelope of dictionary `o`, the group on `%_` lines before it (`before`) and the art /// in its `AIDeformArtKey` block (`inner`); `None` where they don't make one. pub(super) fn live(o: &Obj, before: Option, inner: Vec) -> Option { + envelope_as(o, before, inner, false) +} + +/// Is the envelope of dictionary `o` a warp? +fn is_warp(o: &Obj) -> bool { + whole(o, "AIDeformStyle") == Some(Some(1)) +} + +/// [`live`] for a warp, as a mesh envelope on the grid its editing data saves (the mesh that shapes +/// it), its points and handles as saved: it draws as that grid does, without the warp's style, bend +/// and distortions. `None` for an envelope that isn't a warp, or whose grid doesn't read. +fn live_on_saved_grid(o: &Obj, before: Option, inner: Vec) -> Option { + envelope_as(o, before, inner, true) +} + +/// [`live`], or with `on_grid` [`live_on_saved_grid`]. +fn envelope_as(o: &Obj, before: Option, inner: Vec, on_grid: bool) -> Option { let editing = switch(o, "AIDeformEditModeKey")?; let (mut options, fidelity) = options(o)?; let before = before?; @@ -290,14 +308,14 @@ pub(super) fn live(o: &Obj, before: Option, inner: Vec) -> Option warp(o)?, - 2 => { + let kind = match (whole(o, "AIDeformStyle")??, on_grid) { + (1, false) => warp(o)?, + (1, true) | (2, false) => { let (rows, cols, pts) = upright(shape?)?; let (points, handles) = pts.into_iter().unzip(); EnvelopeKind::Mesh { rows, cols, points, handles } } - 3 => { + (3, false) => { let (rows, cols, pts) = upright(shape?)?; // The outline runs clockwise from the top left corner, one segment per grid edge. let corners = [0, cols, cols.checked_add(rows)?, cols.checked_mul(2)?.checked_add(rows)?]; @@ -448,10 +466,13 @@ impl WarpedType { /// Make each envelope of type live where it draws as its drawn art, its contents' text slots /// holding their type: `type_of` gives, for a slot's id, the hidden text object it became in /// `doc` and the type the contents take in its place (the slot's visibility and lock are the - /// type's). The live envelope takes its drawn art's place and the hidden type goes. Each check - /// uses one of the import's checks and, for an envelope that shows, adds to what the page check - /// allows ([`drawn_alike`]). Elsewhere, and where the type is set in a font that isn't - /// available, the drawn art and the hidden type stay. + /// type's). The live envelope takes its drawn art's place and the hidden type goes. A warp that + /// doesn't draw as its drawn art is checked again as a mesh envelope on the grid the file saves + /// for it ([`live_on_saved_grid`]), which opens in its place where it does: its type stays + /// editable in that shape, and the warp's style and bend are lost. Each check uses one of the + /// import's checks and, for an envelope that shows, adds to what the page check allows + /// ([`drawn_alike`]). Elsewhere, and where the type is set in a font that isn't available, the + /// drawn art and the hidden type stay. pub(crate) fn settle_envelopes(&mut self, doc: &mut Document, type_of: impl Fn(&Document, NodeId) -> Option<(NodeId, Node)>) -> TypedSettled { let mut settled = TypedSettled::default(); if self.envelopes.is_empty() { @@ -503,22 +524,34 @@ impl WarpedType { for n in before.iter_mut().chain(inner.iter_mut()) { put_type(n, &texts); } - let kind = live(&obj, before, inner).ok_or(TypeKept::Read)?; - if self.checks == 0 { - return Err(TypeKept::Budget); + // A warp that VectorCraft's own warp doesn't draw as its drawn art may still draw as the + // grid the file saves for it: then it opens as a mesh envelope on that grid. + let on_grid = if is_warp(&obj) { live_on_saved_grid(&obj, before.clone(), inner.clone()) } else { None }; + let kinds: Vec = live(&obj, before, inner).into_iter().chain(on_grid).collect(); + if kinds.is_empty() { + return Err(TypeKept::Read); } - self.checks -= 1; - let mut envelope = art.clone(); - (envelope.id, envelope.kind) = (doc.alloc_id(), kind); - let layer = doc.alloc_id(); - let shared = Shared { patterns: &doc.patterns, images: &doc.images }; - let (mut live_one, mut plain) = (envelope.clone(), art.clone()); - (live_one.visible, live_one.opacity) = (true, 1.0); + let (id, layer) = (doc.alloc_id(), doc.alloc_id()); + let mut plain = art.clone(); (plain.visible, plain.opacity) = (true, 1.0); let page = self.page.filter(|_| shown.contains(&drawn)); - if !drawn_alike(&live_one, &plain, layer, &shared, page, &mut self.spent) { - return Err(TypeKept::Differs); + let mut alike = None; + for kind in kinds { + if self.checks == 0 { + return Err(TypeKept::Budget); + } + self.checks -= 1; + let mut envelope = art.clone(); + (envelope.id, envelope.kind) = (id, kind); + let shared = Shared { patterns: &doc.patterns, images: &doc.images }; + let mut live_one = envelope.clone(); + (live_one.visible, live_one.opacity) = (true, 1.0); + if drawn_alike(&live_one, &plain, layer, &shared, page, &mut self.spent) { + alike = Some(envelope); + break; + } } + let envelope = alike.ok_or(TypeKept::Differs)?; if doc.node(drawn).is_none() { return Err(TypeKept::Read); } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 867d36d1a..e1b4f68e8 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5346,7 +5346,6 @@ fn type_envelope(keys: &str, drawn: &str) -> String { /// [`type_envelope`], with `after` (on plain lines, written on `%_` ones) after the text object in /// its contents, such as the text object's XML id. fn type_envelope_with(keys: &str, drawn: &str, after: &str) -> String { - let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); let mesh = format!( "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n", @@ -5355,9 +5354,16 @@ fn type_envelope_with(keys: &str, drawn: &str, after: &str) -> String { corner(80.0, 70.0, 80.0, 60.0, 70.0, 70.0), corner(80.0, 40.0, 70.0, 40.0, 80.0, 50.0), ); + type_envelope_on(keys, &mesh, drawn, after) +} + +/// [`type_envelope_with`], with `shape` (on plain lines, written on `%_` ones) in the group before +/// its dictionary, where the editing data saves the envelope's mesh. +fn type_envelope_on(keys: &str, shape: &str, drawn: &str, after: &str) -> String { + let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; format!( "{}{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_1 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n", - hidden(&format!("u\n{mesh}U\n")), + hidden(&format!("u\n{shape}U\n")), hidden(&format!("/ArtDictionary :\n{keys}")), hidden(&format!("u\n{}{after}U\n9 () XW\n", text_object(0))), ) @@ -5654,6 +5660,133 @@ fn an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art() { assert!(!r.warnings.iter().any(|n| n.contains("variable bindings")), "{:?}", r.warnings); } +/// A one-patch warp grid as the editing data saves it, which no warp style draws: its top edge +/// rises to the right and bulges up. In art space, corners (20, 40) (20, 73) (80, 61) (80, 40), each +/// with its handles towards the next corner and the previous one. +fn rising_grid() -> String { + let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); + format!( + "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n", + corner(20.0, 40.0, 20.0, 51.0, 40.0, 40.0), + corner(20.0, 73.0, 40.0, 80.0, 20.0, 62.0), + corner(80.0, 61.0, 80.0, 54.0, 60.0, 72.0), + corner(80.0, 40.0, 60.0, 40.0, 80.0, 47.0), + ) +} + +/// [`rising_grid`] on the page axes as a mesh envelope reads it: its points from the top left, row +/// by row, and their handles (towards the next column, the previous one, the next row and the +/// previous one; none out of the grid). +fn rising_kind() -> vectorcraft_doc::live::EnvelopeKind { + use kurbo::{Point, Vec2}; + let z = Vec2::ZERO; + vectorcraft_doc::live::EnvelopeKind::Mesh { + rows: 1, + cols: 1, + points: vec![Point::new(20.0, 27.0), Point::new(80.0, 39.0), Point::new(20.0, 60.0), Point::new(80.0, 60.0)], + handles: vec![ + [Vec2::new(20.0, -7.0), z, Vec2::new(0.0, 11.0), z], + [z, Vec2::new(-20.0, -11.0), Vec2::new(0.0, 7.0), z], + [Vec2::new(20.0, 0.0), z, z, Vec2::new(0.0, -11.0)], + [z, Vec2::new(-20.0, 0.0), z, Vec2::new(0.0, -7.0)], + ], + } +} + +/// The anchors of the paths `n` draws once its live art is expanded. +fn drawn_anchors(n: &Node) -> Vec { + fn walk(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Path { path, .. } => out.extend(path.subpaths.iter().flat_map(|s| s.anchors.iter().map(|a| a.p))), + _ => n.children().into_iter().flatten().for_each(|c| walk(c, out)), + } + } + let mut out = vec![]; + walk(&vectorcraft_effects::expand_live_deep(None, n), &mut out); + out +} + +/// A warp of type whose own warp draws apart from its drawn art, where the grid the file saves for +/// it draws as that art, opens as a mesh envelope on that grid, its points and handles as saved: +/// the type is editable, and new text fills the same shape (#3179). +#[test] +fn a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes() { + use vectorcraft_doc::live::EnvelopeKind; + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let mut text = made_envelope_type(&doc); + text.visible = true; + let options = vectorcraft_doc::live::EnvelopeOptions::NEW; + let shaped = |text: Node| { + let content = vec![std::sync::Arc::new(text)]; + let kind = Box::new(rising_kind()); + Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { content, kind, fidelity: 50.0, editing: false, options, frame: Box::default() }, + ) + }; + let (drawn, page) = drawn_envelope(&shaped(text)); + let art = + native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope_on(&arc_keys(0.3), &rising_grid(), &drawn, ""))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings); + assert!(loose.is_empty(), "{loose:?}"); + assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{:?}", r.warnings); + let mut live = None; + r.document.walk(|n| { + if matches!(n.kind, NodeKind::Envelope { .. }) { + live = Some(n.clone()); + } + }); + let mut live = live.unwrap(); + let NodeKind::Envelope { kind, content, .. } = &mut live.kind else { panic!("not an envelope: {live:?}") }; + let (EnvelopeKind::Mesh { rows, cols, points, handles }, EnvelopeKind::Mesh { points: want, handles: want_h, .. }) = (&**kind, rising_kind()) + else { + panic!("not a mesh envelope: {kind:?}") + }; + assert_eq!((*rows, *cols), (1, 1)); + assert!(points.iter().zip(&want).all(|(p, q)| (*p - *q).hypot() < 1e-6), "{points:?}"); + assert!(handles.iter().flatten().zip(want_h.iter().flatten()).all(|(h, g)| (*h - *g).hypot() < 1e-6), "{handles:?}"); + // New text in the envelope fills the grid as the old did: "HIH", whose corners are its glyphs', + // reaches each corner of the grid. + let NodeKind::Text(t) = &mut std::sync::Arc::make_mut(&mut content[0]).kind else { panic!("type") }; + let style = t.runs[0].style.clone(); + t.runs = vec![vectorcraft_doc::TextRun::new("HIH", style)]; + (t.cached_bounds, t.cached_ink) = (None, None); + t.cached_baselines.clear(); + let anchors = drawn_anchors(&live); + for corner in want { + let near = anchors.iter().map(|a| (*a - corner).hypot()).fold(f64::INFINITY, f64::min); + assert!(near < 0.25, "{corner:?} is {near} from the new text"); + } +} + +/// A warp of type whose editing data saves no grid it can be shaped by keeps its drawn art and its +/// type, hidden, with the note, when its own warp draws apart from that art (#3179). +#[test] +fn a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art() { + let Some(family) = installed_family() else { return }; + let doc = type_envelope_text(&family, None); + let mut text = made_envelope_type(&doc); + text.visible = true; + let content = vec![std::sync::Arc::new(text)]; + let options = vectorcraft_doc::live::EnvelopeOptions::NEW; + let shaped = Node::new( + vectorcraft_doc::NodeId(1_000_000), + NodeKind::Envelope { content, kind: Box::new(rising_kind()), fidelity: 50.0, editing: false, options, frame: Box::default() }, + ); + let (drawn, page) = drawn_envelope(&shaped); + let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope_on(&arc_keys(0.3), "", &drawn, ""))), &doc); + let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let (envelopes, loose) = envelopes_and_type(&r.document); + assert!(envelopes.is_empty(), "{envelopes:?}"); + assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings); + assert_eq!(super::tests::program_warnings(&r.warnings), [crate::import::ai::ENVELOPE_TYPE_DRAWN], "{:?}", r.warnings); +} + /// The fill of the first run of type `n`. fn text_fill(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None };