diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 43074a4e80..d561663524 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -363,6 +363,14 @@ pub struct SeriesPaint { /// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal. #[serde(skip_serializing_if = "Option::is_none")] pub blend: Option, + /// A gradient in [`Self::fill`] is placed relative to the unit box (0, 0)–(1, 1), and each mark takes it on its own + /// geometric bounds. False: the gradient is in document coordinates, and every mark takes it unchanged. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub fill_unit_box: bool, + /// A gradient in [`Self::stroke`] is placed relative to the unit box, as [`Self::fill_unit_box`], and each mark takes + /// it on its stroke's box (the geometric bounds grown by half the weight). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stroke_unit_box: bool, } /// The object rows one kind of graph label keeps ([`LabelStyles::values_rows`]): the paint, weight, opacity and blend mode diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 7500af0617..ac9de02cad 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask}; use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2}; use vectorcraft_pathops::BoolOp; -use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; +use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, MeshedSolid, PlanarFace, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -656,7 +656,7 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec>, Rect) { #[cfg(test)] thread_local! { - /// How many solids [`built_solids`] has built on this thread, for the tests. + /// How many solids [`built_solids`] and [`meshes_for`] have built on this thread, for the tests. static SOLIDS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -2039,31 +2039,35 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) return Ok(out); } let (parts, bounds) = solid_parts(shapes, b); - // The solids the art draws, past the limits of the object's solids together as it is - // ([`built_solids`]), each built and its faces counted as the loop reaches it, then dropped. - let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some())); - // Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel - // solid and its mesh are built as the loop reaches them. + // Inflate lays out every puff at once (they share one eye). Each Inflate and Extrude & Bevel + // solid is built once, as the loop reaches it: its facets decide whether the art draws it, as + // [`built_solids`] does, and its faces before projection make its mesh. let mut inflated = (fx.id == INFLATE).then(|| { let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); - vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) + vectorcraft_three_d::inflate_meshed(&shapes, bounds, inflate_options(&p), view) }); let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) }; - for ((item, member, area, rule, _, _), drawn) in parts.iter().zip(drawn) { + let mut kept = 0usize; + for (item, member, area, rule, _, _) in &parts { let item: &AppearanceItem = item; - if !drawn? { - // A stroke the art leaves out is left out of the meshes too; Inflate's next puff - // still belongs to the next part. - if let Some(built) = inflated.as_mut() { - built.next(); - } - continue; - } - let mesh = match inflated.as_mut() { - Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), - None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view), + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); + let built = match inflated.as_mut() { + Some(each) => each.next().unwrap_or_else(|| Ok(MeshedSolid::default())), + None => vectorcraft_three_d::extrude_meshed(area, *rule, bounds, extrude, view), }; - match mesh { + // A stroke the art leaves out is left out of the meshes too. The faces of the solids kept + // count together against the object's limit. + let solid = match built { + Ok(solid) => solid, + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => continue, + Err(e) => return Err(e), + }; + kept = kept.saturating_add(solid.facets.len()); + if kept > MAX_OBJECT_FACES { + return Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()); + } + match solid.mesh() { Ok(mesh) => { let (paint, opacity, _) = paint_of(item, *member); keep(&mut out, mesh, mesh_color(&paint, opacity))?; diff --git a/crates/effects/src/solids/tests_mesh.rs b/crates/effects/src/solids/tests_mesh.rs index aeefafbc77..a9de7cd7f9 100644 --- a/crates/effects/src/solids/tests_mesh.rs +++ b/crates/effects/src/solids/tests_mesh.rs @@ -208,3 +208,151 @@ fn a_gradient_takes_the_average_of_its_stops() { let pattern = Paint::Pattern { pattern: "x".into(), xf: vectorcraft_geom::Affine::IDENTITY }; assert!(close(mesh_color(&pattern, 0.5), [0.5, 0.5, 0.5, 0.5])); } + +/// The meshes of Extrude & Bevel or Inflate `fx` on `shapes`, each built apart from the facets that +/// decide which solids are drawn ([`built_solids`], then [`vectorcraft_three_d::extrude_mesh`] or +/// [`vectorcraft_three_d::inflate_meshes`]), for comparing with [`meshes_for`]. +fn meshes_built_apart(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) -> Result, String> { + let p = crate::merged_params(&fx.id, &fx.params); + let view = view_options(&p); + let (parts, bounds) = solid_parts(shapes, union_bounds(shapes).unwrap()); + let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some())); + let mut inflated = (fx.id == INFLATE).then(|| { + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); + vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) + }); + let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) }; + let mut out = vec![]; + for ((item, opacity, area, rule, _, _), drawn) in parts.iter().zip(drawn) { + let item: &AppearanceItem = item; + if !drawn? { + if let Some(built) = inflated.as_mut() { + built.next(); + } + continue; + } + let mesh = match inflated.as_mut() { + Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), + None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view), + }; + match mesh { + Ok(mesh) => { + let (paint, opacity, _) = paint_of(item, *opacity); + keep_mesh(&mut out, total, SolidMesh { mesh, color: mesh_color(&paint, opacity) }, limit)?; + } + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} + Err(e) => return Err(e), + } + } + Ok(out) +} + +/// The bits of every number in `meshes`, for comparing two exports exactly. +type MeshBits = Vec<([u32; 4], Vec<[u64; 3]>, Vec<[u64; 3]>, Vec<[u32; 3]>)>; + +fn mesh_bits(meshes: &[SolidMesh]) -> MeshBits { + meshes + .iter() + .map(|m| { + let (positions, normals) = (&m.mesh.positions, &m.mesh.normals); + ( + m.color.map(f32::to_bits), + positions.iter().map(|p| p.map(f64::to_bits)).collect(), + normals.iter().map(|n| n.map(f64::to_bits)).collect(), + m.mesh.triangles.clone(), + ) + }) + .collect() +} + +/// A five-pointed star about `(x, 150)` with curved edges, filled red and stroked black. +fn star_node(id: u64, x: f64) -> Node { + let mut b = BezPath::new(); + for k in 0..10 { + let (s, c) = (k as f64 * std::f64::consts::PI / 5.0).sin_cos(); + let r = if k % 2 == 0 { 60.0 } else { 25.0 }; + let p = (x + r * c, 150.0 + r * s); + if k == 0 { b.move_to(p) } else { b.curve_to((p.0 + 4.0, p.1), (p.0, p.1 + 4.0), p) } + } + b.close_path(); + Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 6.0)) +} + +/// The shapes `meshes_for` builds from: one node's, or each of several. +fn shapes_of(nodes: &[&Node]) -> Vec { + nodes + .iter() + .map(|n| Profile { path: profile(n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(n), opacity: 1.0, scopes: vec![] }) + .collect() +} + +/// Building each Extrude & Bevel and Inflate solid once exports the same meshes, to the bit, as +/// building its facets and its mesh apart, within and past the triangle budget. +#[test] +fn one_build_exports_the_meshes_built_apart() { + let mut second_stroke = square_node(3, 400.0); + if let Some(stroke) = second_stroke.appearance.items.get(1).cloned() { + second_stroke.appearance.items.push(stroke); + } + let (star, square, other) = (star_node(1, 100.0), square_node(2, 250.0), star_node(4, 600.0)); + let cases = [ + (EXTRUDE, json!({})), + (EXTRUDE, json!({ "bevel": "rounded", "bevelWidth": 6.0, "bevelHeight": 5.0, "twist": 60.0, "taper": 70.0 })), + (EXTRUDE, json!({ "bevel": "cove", "bevelInside": true, "bevelBothSides": true, "cap": false, "perspectiveAngle": 50.0 })), + (EXTRUDE, json!({ "depth": 0.0 })), + (CLASSIC_EXTRUDE, json!({ "bevel": "classic", "bevelHeight": 8.0 })), + (INFLATE, json!({})), + (INFLATE, json!({ "depth": 30.0, "bothSides": true, "volume": 80.0 })), + (INFLATE, json!({ "depth": 40.0, "twist": 360.0, "taper": 70.0, "cap": false })), + (INFLATE, json!({ "depth": 0.1, "twist": 0.1, "bothSides": true })), + ]; + let sets: [Vec<&Node>; 3] = [vec![&star], vec![&second_stroke], vec![&star, &square, &other]]; + for (id, params) in cases { + let effect = fx(id, params.clone()); + for nodes in &sets { + let shapes = shapes_of(nodes); + let (mut once, mut apart) = (0, 0); + let built = meshes_for(&effect, &shapes, &mut once, MAX_MESH_TRIANGLES).unwrap_or_else(|e| panic!("{id} {params}: {e}")); + let reference = meshes_built_apart(&effect, &shapes, &mut apart, MAX_MESH_TRIANGLES).unwrap(); + assert!(!reference.is_empty()); + assert_eq!(mesh_bits(&built), mesh_bits(&reference), "{id} {params}"); + assert_eq!(once, apart); + // Past the budget, at the same mesh, with the same count. + let limit = once - 1; + let (mut once, mut apart) = (0, 0); + let built = meshes_for(&effect, &shapes, &mut once, limit); + assert!(built.is_err(), "{id} {params}"); + assert_eq!(built.err(), meshes_built_apart(&effect, &shapes, &mut apart, limit).err(), "{id} {params}"); + assert_eq!(once, apart, "{id} {params}"); + } + } +} + +/// A stroke whose solid is past the limits is left out of the export and the fill's mesh exports, +/// as when the facets and the mesh are built apart. +#[test] +fn a_stroke_past_the_limits_is_left_out_of_the_export() { + // 40,000 corners: within the 65,536 points of the fill's solid, but not the stroke's outline. + let points: Vec = (0..40_000) + .map(|k| { + let (s, c) = (k as f64 * std::f64::consts::TAU / 40_000.0).sin_cos(); + let r = if k % 2 == 0 { 10_000.0 } else { 9_999.0 }; + Point::new(20_000.0 + r * c, 20_000.0 + r * s) + }) + .collect(); + let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + let n = Node::path(NodeId(1), path, Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 2.0)); + for id in [EXTRUDE, INFLATE] { + // Without caps: a cap of 40,000 corners takes more work to cut into triangles than an + // export allows. + let effect = fx(id, json!({ "cap": false })); + let shapes = shapes_of(&[&n]); + // The fill's mesh alone has more triangles than an export takes. + let (mut once, mut apart) = (0, 0); + let built = meshes_for(&effect, &shapes, &mut once, usize::MAX).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(built.len(), 1, "{id}: the fill's mesh alone"); + assert!(close(built[0].color, [0.8, 0.2, 0.1, 1.0]), "{id}"); + let reference = meshes_built_apart(&effect, &shapes, &mut apart, usize::MAX).unwrap(); + assert_eq!(mesh_bits(&built), mesh_bits(&reference), "{id}"); + } +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index d4fb2c39cd..19695fd4ff 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -559,6 +559,15 @@ pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result usize { usize::try_from(v).unwrap_or(usize::MAX) } +/// The whole number of 0 or more that `p` gives for `key`, read with [`index`], or `None` when `p` +/// leaves it out or gives null. Any other value, such as `"3"`, `2.5`, `-1` or `true`, is an error +/// under `cmd`: "`{key}` must be a whole number". +pub(crate) fn index_opt(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(|n| Some(index(n))).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))), + } +} /// Validate all explicit targets before a command changes the document or selection. pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result> { checked_nodes_param(s, p, key, command, false) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 576b05ffd6..9ecb5c3984 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -568,12 +568,11 @@ fn switch_units(s: &mut Session, _: &Value) -> Result { Ok(json!({ "units": next.label() })) } -/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given. +/// The `artboard` param of the ruler origin commands: an artboard index, 0 when left out or null. A +/// value that is not a whole number is "`artboard` must be a whole number"; one past the artboards +/// is "index out of range". fn artboard_param(s: &Session, p: &Value, c: &str) -> Result { - let index = match p.get("artboard") { - None => 0, - Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(c, "index out of range"))?, - }; + let index = super::index_opt(p, "artboard", c)?.unwrap_or(0); if index >= s.doc()?.doc.artboards.len() { return Err(bad(c, "index out of range")); } @@ -583,14 +582,15 @@ fn artboard_param(s: &Session, p: &Value, c: &str) -> Result { fn ruler_origin(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; // A document without artboards measures from its global zero point. - let index = if d.artboards.is_empty() && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, "document.rulerOrigin")? }; + let index = + if d.artboards.is_empty() && p.get("artboard").is_none_or(Value::is_null) { 0 } else { artboard_param(s, p, "document.rulerOrigin")? }; Ok(json!(s.doc()?.doc.ruler_origin_for(index))) } fn set_ruler_origin(s: &mut Session, p: &Value) -> Result { const C: &str = "document.setRulerOrigin"; let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); - let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, C)? }; + let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none_or(Value::is_null) { 0 } else { artboard_param(s, p, C)? }; change_ruler_origin(s, index, Some(origin), "Ruler Origin") } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 54ca07da7e..b47fd0e5e3 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label", + "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement relative to the path's box when it is on the topmost visible fill or stroke, and every path of the series takes it at the same place on its own box (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label", has_doc, set_gradient_geom ), diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index a789304b24..69510652b9 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -6,7 +6,7 @@ use super::labels::{ recolour_text, runs, shadow_of, }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; -use super::series::{series_marks, with_series_opacity}; +use super::series::{series_marks, with_series_gradients, with_series_opacity}; use super::{ Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes, }; @@ -1006,7 +1006,7 @@ impl<'a> Plot<'a> { } } let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1)); - let grp = self.b.series(&name, s, with_series_opacity(g, s, items)); + let grp = self.b.series(&name, s, with_series_opacity(g, s, with_series_gradients(g, s, items))); self.b.out.push(grp); } self.b.out.append(&mut self.on_top); diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 51a7ead550..aeaceb9e91 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -2,7 +2,7 @@ use vectorcraft_color::BlendMode; use super::axis::radar_axis; use super::labels::{Role, filled_lines_on, label_para, label_style}; -use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; +use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Rect, Result, Session, Value, bad}; // ---------- generation ---------- @@ -229,7 +229,8 @@ pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, b continue; } let (paint, width) = shown_paint(node, *fill); - captures.push((r.graph, r.index, *fill, paint, (!*fill).then_some(width))); + let width = (!*fill).then_some(width); + captures.push((r.graph, r.index, *fill, paint, width, paint_box(node, width))); } store_series_paints(doc, captures) } @@ -281,11 +282,129 @@ fn settle_series_paints(doc: &mut Document, before: SeriesPaintsBefore) -> bool changed } -/// Store each capture (graph, series index, fill or stroke, paint, stroke weight) in its series. Returns whether a +/// The box a series keeps a gradient relative to: (0, 0)–(1, 1), as graphic styles keep theirs. +const UNIT_BOX: Rect = vectorcraft_doc::GraphicStyle::UNIT_BOX; + +/// The gradient `slot` keeps for its fill (`fill`) or stroke as the graph draws it on box `on`: one kept relative to the +/// unit box placed on `on`, also when `on` has no width or no height, and one kept in document coordinates as it is kept. +/// None when the slot keeps no gradient, or keeps one relative to the unit box and there is no box. +fn kept_on(slot: &vectorcraft_doc::SeriesPaint, fill: bool, on: Option) -> Option { + let (kept, unit) = if fill { (&slot.fill, slot.fill_unit_box) } else { (&slot.stroke, slot.stroke_unit_box) }; + let Some(Paint::Gradient(g)) = kept else { return None }; + if !unit { + return Some(Paint::Gradient(g.clone())); + } + let on = on?; + let mut g = g.clone(); + g.rebase(UNIT_BOX, on); + Some(Paint::Gradient(g)) +} + +/// Whether `a` and `b` are the same paint, a gradient's vector, focal point and freeform points each within 1e-6 pt (the +/// rounding a move or a placement on another box leaves). +fn same_paint_placed(a: &Paint, b: &Paint) -> bool { + let (Paint::Gradient(a), Paint::Gradient(b)) = (a, b) else { return a == b }; + let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| (p - q).hypot() <= 1e-6; + let mut a = (**a).clone(); + match (&mut a.geom, &b.geom) { + (Some(ag), Some(bg)) => { + let focal = match (ag.focal, bg.focal) { + (Some(p), Some(q)) => near(p, q), + (p, q) => p.is_none() && q.is_none(), + }; + if !(near(ag.start, bg.start) && near(ag.end, bg.end) && focal) { + return false; + } + (ag.start, ag.end, ag.focal) = (bg.start, bg.end, bg.focal); + } + (None, None) => {} + _ => return false, + } + match (&mut a.freeform, &b.freeform) { + (Some(af), Some(bf)) => { + if af.points.len() != bf.points.len() || !af.points.iter().zip(&bf.points).all(|(p, q)| near(p.at, q.at)) { + return false; + } + for (p, q) in af.points.iter_mut().zip(&bf.points) { + p.at = q.at; + } + } + (None, None) => {} + _ => return false, + } + a == **b +} + +/// The box a gradient on `node`'s fill (`width` None) or on its stroke of `width` is placed on: the geometric bounds, grown +/// by half the weight for a stroke. The box can have no width or no height. +fn paint_box(node: &Node, width: Option) -> Option { + let b = node.geometric_bounds()?; + Some(width.map_or(b, |w| vectorcraft_doc::appearance::stroke_paint_bounds(b, w))) +} + +/// `paint` as a series keeps it: a gradient placed on `from` (the edited mark's [`paint_box`]) is moved to the same place +/// relative to [`UNIT_BOX`], so each mark takes it on its own box. A box with no width or no height, which a gradient's +/// place cannot be kept relative to, leaves the paint in document coordinates. Returns the paint and whether it was moved. +fn unit_box_paint(paint: Paint, from: Option) -> (Paint, bool) { + match (paint, from.filter(|b| b.width() > 1e-9 && b.height() > 1e-9)) { + (Paint::Gradient(mut g), Some(from)) => { + g.rebase(from, UNIT_BOX); + (Paint::Gradient(g), true) + } + (paint, _) => (paint, false), + } +} + +/// The paths directly inside series `i`'s group (`items`) with the series' kept gradients, which are kept relative to the +/// unit box, placed on each path's own box: a fill on its geometric bounds, a stroke on its stroke's box. Paints kept in +/// document coordinates (from files saved before the unit box) are left as they are. +pub(super) fn with_series_gradients(g: &GraphSpec, i: usize, mut items: Vec>) -> Vec> { + let Some(slot) = g.series_paints.get(i) else { return items }; + fn kept(p: &Option, unit: bool) -> Option<&Paint> { + p.as_ref().filter(|p| unit && matches!(p, Paint::Gradient(_))) + } + let (fill, stroke) = (kept(&slot.fill, slot.fill_unit_box), kept(&slot.stroke, slot.stroke_unit_box)); + if fill.is_none() && stroke.is_none() { + return items; + } + // A Filled Lines band takes the kept stroke as its fill when no fill is kept. + let band_fill = if slot.fill.is_none() { stroke } else { None }; + let fill_from_store = |p: &Paint| fill == Some(p) || band_fill == Some(p); + let stroke_from_store = |p: &Paint| stroke == Some(p); + for item in &mut items { + if !matches!(item.kind, NodeKind::Path { .. }) { + continue; + } + let Some(b) = item.geometric_bounds() else { continue }; + let ap = &item.appearance; + if !ap.fill().is_some_and(|f| fill_from_store(&f.paint)) && !ap.stroke().is_some_and(|st| stroke_from_store(&st.paint)) { + continue; + } + let ap = &mut Arc::make_mut(item).appearance; + if let Some(f) = ap.fill_mut().filter(|f| fill_from_store(&f.paint)) + && let Paint::Gradient(gp) = &mut f.paint + { + gp.rebase(UNIT_BOX, b); + } + if let Some(st) = ap.stroke_mut().filter(|st| stroke_from_store(&st.paint)) { + let to = st.paint_bounds(b); + if let Paint::Gradient(gp) = &mut st.paint { + gp.rebase(UNIT_BOX, to); + } + } + } + items +} + +/// A paint taken from a series mark: graph, series index, fill or stroke, paint, stroke weight and the mark's +/// [`paint_box`]. +type Capture = (NodeId, usize, bool, Paint, Option, Option); + +/// Store each capture in its series; a gradient is kept relative to the unit box ([`unit_box_paint`]). Returns whether a /// series' captured fill or stroke differs from what the graph stored before the first capture. -fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, Paint, Option)>) -> bool { +fn store_series_paints(doc: &mut Document, captures: Vec) -> bool { let mut before = SeriesPaintsBefore::new(); - for (graph, index, fill, paint, width) in captures { + for (graph, index, fill, paint, width, from) in captures { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; before.entry(graph).or_insert_with(|| spec.series_paints.clone()); // `index` is below the graph's series count (`series_members`). @@ -293,10 +412,17 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); } let Some(slot) = spec.series_paints.get_mut(index) else { continue }; + // A mark that shows the kept gradient as it was drawn leaves the kept gradient as it is. + let drawn = kept_on(slot, fill, from).is_some_and(|kept| same_paint_placed(&kept, &paint)); + let (paint, unit) = unit_box_paint(paint, from); if fill { - slot.fill = Some(paint); + if !drawn { + (slot.fill, slot.fill_unit_box) = (Some(paint), unit); + } } else { - slot.stroke = Some(paint); + if !drawn { + (slot.stroke, slot.stroke_unit_box) = (Some(paint), unit); + } slot.stroke_width = width; } } diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index c997e803d5..45ce3e22e9 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -2754,10 +2754,39 @@ fn a_gradient_edit_on_a_series_mark_is_kept() { series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was); } -/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored on the graph as it is placed, and -/// every mark of the series has that fill, placement included, after the graph is drawn again. +/// Where gradient `paint` sits on box `b`: its vector's start and end, then each freeform point, as fractions of the box. +fn gradient_place(paint: &Paint, b: vectorcraft_geom::Rect) -> Vec { + let Paint::Gradient(g) = paint else { panic!("not a gradient: {paint:?}") }; + let points = g.geom.iter().flat_map(|geom| [geom.start, geom.end]).chain(g.freeform.iter().flat_map(|f| f.points.iter().map(|p| p.at))); + points.flat_map(|p| [(p.x - b.x0) / b.width(), (p.y - b.y0) / b.height()]).collect() +} + +/// The box a gradient on `mark`'s fill (`stroke` false) or stroke is placed on. +fn paint_box_of(mark: &vectorcraft_doc::Node, stroke: bool) -> vectorcraft_geom::Rect { + let b = mark.geometric_bounds().unwrap(); + if stroke { vectorcraft_doc::appearance::stroke_paint_bounds(b, mark.appearance.stroke_width()) } else { b } +} + +/// The fill (`stroke` false) or stroke of every path of series 0, the legend swatch included, has a gradient at `place` on +/// its own box, and the gradients are not all at one place in the document. Returns how many paths were checked. +fn series_0_gradients_at(s: &Session, id: NodeId, stroke: bool, place: &[f64]) -> usize { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let paths: Vec<_> = series(&n, 0).children().unwrap().iter().filter(|m| matches!(m.kind, NodeKind::Path { .. })).cloned().collect(); + let paints: Vec = paths.iter().map(|m| if stroke { m.appearance.stroke_paint() } else { m.appearance.fill_paint() }).collect(); + for (m, paint) in paths.iter().zip(&paints) { + let at = gradient_place(paint, paint_box_of(m, stroke)); + assert_eq!(at.len(), place.len(), "{at:?}"); + assert!(at.iter().zip(place).all(|(a, b)| (a - b).abs() < 1e-9), "{at:?} is not {place:?}"); + } + assert!(paints.windows(2).any(|w| w[0] != w[1]), "every mark has the gradient at one place: {paints:?}"); + paths.len() +} + +/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored relative to the mark's box, and every +/// mark of the series has it at the same place on its own box after the graph is drawn again, also after a data change +/// moves and resizes the marks and after the graph is moved. #[test] -fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() { +fn a_gradient_placed_on_a_series_mark_takes_the_same_place_on_every_mark() { let (mut s, id, _, marks) = column_marks(); // `paint.setFill` stores the gradient before it is placed. s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap(); @@ -2766,26 +2795,258 @@ fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() { s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap(); let placed = fill_of(&s, marks[0]); assert_ne!(placed, fill, "the command places the gradient"); - assert_eq!(spec(&s, id).series_paints[0].fill, Some(placed.clone())); - s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); - assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0)); + let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + let place = gradient_place(&placed, paint_box_of(&mark, false)); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.fill_unit_box); + assert_eq!(gradient_place(kept.fill.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX), place); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + assert!(series_0_gradients_at(&s, id, false, &place) >= 4); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 50, "dy": 30})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 2], [5, 4], [1, 1]]})).unwrap(); + series_0_gradients_at(&s, id, false, &place); } -/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph. +/// A gradient placed on a series mark's stroke is kept relative to the mark's stroke box, and every mark of the series has +/// it at the same place on its own stroke box after the graph is drawn again. +#[test] +fn a_gradient_placed_on_a_series_marks_stroke_takes_the_same_place_on_every_mark() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap(); + s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 4})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone(); + let place = gradient_place(&mark.appearance.stroke_paint(), paint_box_of(&mark, true)); + assert!(spec(&s, id).series_paints[0].stroke_unit_box); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + series_0_gradients_at(&s, id, true, &place); +} + +/// A placed gradient picked up with the Eyedropper from art outside the graph is kept relative to the series mark it is +/// dropped on, and every mark of the series has it at the same place on its own box. +#[test] +fn a_placed_gradient_dropped_on_a_series_mark_takes_the_same_place_on_every_mark() { + let (mut s, id, _, marks) = column_marks(); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [r.0], "start": [610, 600], "end": [630, 620]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": r.0, "ids": [marks[2].0]})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[2]).unwrap().clone(); + let place = gradient_place(&mark.appearance.fill_paint(), paint_box_of(&mark, false)); + assert!(spec(&s, id).series_paints[0].fill_unit_box); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + series_0_gradients_at(&s, id, false, &place); +} + +/// A graphic style with a placed gradient applied to a series mark is kept relative to that mark's box, and every mark of +/// the series has the gradient at the same place on its own box. +#[test] +fn a_graphic_style_gradient_applied_to_a_series_mark_takes_the_same_place_on_every_mark() { + let (mut s, id, _, marks) = column_marks(); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [r.0], "start": [610, 600], "end": [630, 620]})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": r.0, "name": "Placed"})).unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Placed", "ids": [marks[2].0]})).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[2]).unwrap().clone(); + let place = gradient_place(&mark.appearance.fill_paint(), paint_box_of(&mark, false)); + assert!(spec(&s, id).series_paints[0].fill_unit_box); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + series_0_gradients_at(&s, id, false, &place); +} + +/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph, +/// and every mark of the series has the gradient's points at the same place on its own box after the graph is drawn again. #[test] fn a_freeform_gradient_edit_on_a_series_mark_is_kept() { let (mut s, id, _, marks) = column_marks(); let points = json!([{"at": [20, 20], "color": "#53aadf"}, {"at": [50, 50], "color": "#ff8000"}]); // `paint.setFill` stores the gradient before its point is recolored. s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap(); - let fill = fill_of(&s, marks[0]); - assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); s.execute("paint.freeform.setPoint", &json!({"ids": [marks[0].0], "index": 1, "color": "#00ff00"})).unwrap(); let edited = fill_of(&s, marks[0]); - assert_ne!(edited, fill, "the command recolors the point"); - assert_eq!(spec(&s, id).series_paints[0].fill, Some(edited.clone())); + let Paint::Gradient(g) = &edited else { panic!("{edited:?}") }; + assert_eq!(g.freeform.as_ref().unwrap().points[1].color.to_hex(), "#00ff00"); + let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone(); + let place = gradient_place(&edited, paint_box_of(&mark, false)); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.fill_unit_box); + assert_eq!(gradient_place(kept.fill.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX), place); s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); - assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == edited), "{:?}", marks_of(&s, id, 0)); + series_0_gradients_at(&s, id, false, &place); +} + +/// A gradient a graph kept in document coordinates (a file saved before gradients were kept relative to the unit box) is +/// drawn on every mark as it was kept. +#[test] +fn a_gradient_kept_in_document_coordinates_is_drawn_as_kept() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap(); + let placed = fill_of(&s, marks[0]); + s.edit("Kept in document coordinates", |d, _| { + let mut extras = d.node_mut(id).unwrap().extras_mut(); + let slot = &mut extras.graph.as_mut().unwrap().series_paints[0]; + (slot.fill, slot.fill_unit_box) = (Some(placed.clone()), false); + Ok(()) + }) + .unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0)); + // The same gradient kept for the fill relative to the unit box leaves the stroke kept in document coordinates as it is. + s.edit("Kept fill and stroke", |d, _| { + let mut extras = d.node_mut(id).unwrap().extras_mut(); + let slot = &mut extras.graph.as_mut().unwrap().series_paints[0]; + (slot.fill, slot.fill_unit_box) = (Some(placed.clone()), true); + (slot.stroke, slot.stroke_unit_box, slot.stroke_width) = (Some(placed.clone()), false, Some(2.0)); + Ok(()) + }) + .unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 2], [3, 4]]})).unwrap(); + let drawn = marks_of(&s, id, 0); + assert!(drawn.iter().all(|m| m.1 == placed), "{drawn:?}"); + series_0_gradients_at(&s, id, false, &gradient_place(&placed, vectorcraft_doc::GraphicStyle::UNIT_BOX)); +} + +/// A gradient placed on a series path with no height (a flat line's fill) is kept in document coordinates, and every +/// path of the series that takes the kept fill has it at that place in the document. +#[test] +fn a_gradient_placed_on_a_series_path_with_no_height_is_kept_in_document_coordinates() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "line"); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 2], [3, 4], [3, 6]]})).unwrap(); + let flat = |s: &Session| { + let parts = series_parts(s, id, 0); + parts.into_iter().find(|m| m.appearance.fill_paint().is_none() && m.geometric_bounds().is_some_and(|b| b.height() < 1e-9)).unwrap().id + }; + let line = flat(&s); + s.execute("paint.setFill", &json!({"ids": [line.0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [line.0], "start": [150, 120], "end": [300, 280]})).unwrap(); + let placed = fill_of(&s, line); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(!kept.fill_unit_box); + assert_eq!(kept.fill, Some(placed.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 1], [3, 5], [3, 2]]})).unwrap(); + let fills: Vec = series_parts(&s, id, 0).iter().map(|m| m.appearance.fill_paint()).filter(|p| matches!(p, Paint::Gradient(_))).collect(); + assert!(fills.len() >= 2 && fills.iter().all(|p| *p == placed), "{fills:?}"); +} + +/// Writing a series mark's stroke weight as it is, after the graph drew the mark's stroke from a gradient the series +/// keeps relative to the unit box, leaves the kept gradient, the document and its undo history as they are, on every mark. +#[test] +fn rewriting_a_series_marks_drawn_gradient_stroke_records_no_undo_step() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap(); + s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 4})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1.3, 2], [3.7, 4], [6.1, 1]]})).unwrap(); + let was = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let drawn: Vec<_> = series_parts(&s, id, 0).into_iter().filter(|m| matches!(m.appearance.stroke_paint(), Paint::Gradient(_))).collect(); + assert!(drawn.len() >= 4, "{}", drawn.len()); + for mark in drawn { + let row = top_row(&s, mark.id, false); + s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap(); + assert!(s.doc().unwrap().doc == was, "writing mark {:?} changed the document", mark.id); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + // A mark moved with its gradient shows the kept gradient on its moved box, up to rounding. + let kept = spec(&s, id).series_paints[0].clone(); + for (i, mark) in series_parts(&s, id, 0).into_iter().filter(|m| matches!(m.appearance.stroke_paint(), Paint::Gradient(_))).enumerate() { + s.execute("select.set", &json!({"ids": [mark.id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 13.37 + i as f64 * 0.731, "dy": -7.19})).unwrap(); + let moved = s.doc().unwrap().doc.node(mark.id).unwrap().geometric_bounds().unwrap(); + assert!((moved.x0 - mark.geometric_bounds().unwrap().x0 - 13.37 - i as f64 * 0.731).abs() < 1e-9, "mark {:?} did not move", mark.id); + let undo = s.doc().unwrap().history.undo.len(); + let row = top_row(&s, mark.id, false); + s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0], kept, "writing moved mark {:?} changed the kept stroke", mark.id); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } +} + +/// Writing the fill of a series mark with no height (a column for a value of 0) as it is, after the graph drew the mark's +/// fill from a gradient the series keeps relative to the unit box, leaves the kept gradient and its unit-box flag as they +/// are. +#[test] +fn rewriting_a_drawn_gradient_fill_on_a_series_mark_with_no_height_keeps_the_kept_gradient() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.fill_unit_box); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[0, 2], [3, 4], [6, 1]]})).unwrap(); + let flat = series_parts(&s, id, 0) + .into_iter() + .find(|m| matches!(m.appearance.fill_paint(), Paint::Gradient(_)) && m.geometric_bounds().is_some_and(|b| b.height() < 1e-9)) + .unwrap() + .id; + let changed = s.edit("Write the fill as it is", |d, _| Ok(super::capture_series_top_paints(d, &[(flat, true)]))).unwrap(); + assert!(!changed); + assert_eq!(spec(&s, id).series_paints[0], kept); +} + +/// Writing a series mark's stroke weight as it is, after the graph drew the mark's stroke from a gradient the series keeps +/// in document coordinates (a file saved before the unit box), leaves the kept gradient and its unit-box flag as they are +/// and records no undo step. +#[test] +fn rewriting_a_series_marks_stroke_drawn_from_a_gradient_kept_in_document_coordinates_keeps_it() { + let (mut s, id, _, marks) = column_marks(); + s.execute("paint.setStroke", &json!({"ids": [marks[1].0], "gradient": {"kind": "radial"}})).unwrap(); + s.execute("stroke.set", &json!({"ids": [marks[1].0], "weight": 2})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[1].0], "stroke": true, "start": [150, 200], "end": [170, 260]})).unwrap(); + let placed = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.stroke_paint(); + s.edit("Kept in document coordinates", |d, _| { + let mut extras = d.node_mut(id).unwrap().extras_mut(); + let slot = &mut extras.graph.as_mut().unwrap().series_paints[0]; + (slot.stroke, slot.stroke_unit_box, slot.stroke_width) = (Some(placed.clone()), false, Some(2.0)); + Ok(()) + }) + .unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4], [6, 1]]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + let undo = s.doc().unwrap().history.undo.len(); + let drawn: Vec<_> = series_parts(&s, id, 0).into_iter().filter(|m| m.appearance.stroke_paint() == placed).collect(); + assert!(drawn.len() >= 3, "{}", drawn.len()); + for mark in drawn { + let row = top_row(&s, mark.id, false); + s.execute("appearance.setItem", &json!({"ids": [mark.id.0], "index": row, "weight": mark.appearance.stroke_width()})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0], kept, "writing mark {:?} changed the kept stroke", mark.id); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } +} + +/// A gradient placed on a line series' stroke is kept relative to the line's stroke box, and with Draw Filled Lines the +/// band, which takes the kept stroke as its fill, has it at the same place on its own box. +#[test] +fn a_gradient_kept_for_a_lines_stroke_takes_the_same_place_on_its_filled_line() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "line"); + let line = series_parts(&s, id, 0).into_iter().find(|m| m.appearance.fill_paint().is_none()).unwrap().id; + s.execute("paint.setStroke", &json!({"ids": [line.0], "gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setGradientGeom", &json!({"ids": [line.0], "stroke": true, "start": [150, 120], "end": [300, 280]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert!(kept.stroke_unit_box && kept.fill.is_none()); + let place = gradient_place(kept.stroke.as_ref().unwrap(), vectorcraft_doc::GraphicStyle::UNIT_BOX); + s.execute("graph.setType", &json!({"id": id.0, "filledLines": true})).unwrap(); + let band = series_parts(&s, id, 0).into_iter().find(|m| matches!(m.appearance.fill_paint(), Paint::Gradient(_))).unwrap(); + let at = gradient_place(&band.appearance.fill_paint(), band.geometric_bounds().unwrap()); + assert!(at.iter().zip(&place).all(|(a, b)| (a - b).abs() < 1e-9), "{at:?} is not {place:?}"); +} + +/// A series' kept unit-box flags are read from and written to a file, and a file without them reads as document +/// coordinates. +#[test] +fn a_series_paints_unit_box_flags_round_trip() { + let kept = vectorcraft_doc::SeriesPaint { fill_unit_box: true, stroke_unit_box: true, ..Default::default() }; + let text = serde_json::to_string(&kept).unwrap(); + assert_eq!(text, r#"{"fillUnitBox":true,"strokeUnitBox":true}"#); + assert_eq!(serde_json::from_str::(&text).unwrap(), kept); + let old: vectorcraft_doc::SeriesPaint = serde_json::from_str("{}").unwrap(); + assert!(!old.fill_unit_box && !old.stroke_unit_box); } /// A series mark's fill recolored with Invert or Complement (the proxy, or one appearance row), an Edit Colors command, diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index ae8a7ef6b2..ced9347bc1 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -21,7 +21,7 @@ use vectorcraft_color::{Color, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, Node, NodeId, TextStyleDef}; use super::clipboard::SwatchChoices; -use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, color_value, has_doc, ids_param, index, ok, point_param, str_param}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, color_value, has_doc, ids_param, index_opt, ok, point_param, str_param}; use crate::Clipboard; /// The extension of a library file. @@ -1649,11 +1649,11 @@ fn move_group(s: &mut Session, p: &Value) -> Result { const C: &str = "library.moveGroup"; let id = library_id(s, p, C)?; let group = group_param(p, C)?; - let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing `to` (an index)"))?; + let to = index_opt(p, "to", C)?.ok_or_else(|| bad(C, "missing `to` (an index)"))?; let (name, at) = s.libraries.change(&id, |lib| { let i = group_at(lib, group, C)?; let g = lib.groups.remove(i); - let at = index(to).min(lib.groups.len()); + let at = to.min(lib.groups.len()); let name = g.name.clone(); lib.groups.insert(at, g); Ok((name, at)) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index f9c4b10049..c6731118d7 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -88,7 +88,7 @@ pub fn specs() -> Vec { fn artboard_reorder(s: &mut Session, p: &Value) -> Result { const C: &str = "artboard.reorder"; let i = artboard_index(&s.doc()?.doc, p, C)?; - let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing to"))?; + let to = super::index_opt(p, "to", C)?.ok_or_else(|| bad(C, "missing to"))?; s.edit("Reorder Artboards", |d, _| { if i >= d.artboards.len() { return Err(EngineError::Other("no such artboard".into())); diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 33bde936bf..9d7b42cca2 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -12,7 +12,7 @@ use super::draw2::usize_opt; use super::pathops::Beside; use super::{ CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path, - has_selection, ids_param, index, number_or, object_param, ok, str_param, switch_or, targets, + has_selection, ids_param, index_opt, number_or, object_param, ok, str_param, switch_or, targets, }; use crate::EngineError; @@ -170,7 +170,7 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result< fn append_anchor(s: &mut Session, p: &Value) -> Result { let id = object_param(p, "id", "path.appendAnchor")?.ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = anchor_from_json(p, "path.appendAnchor")?; - let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); + let subpath = index_opt(p, "subpath", "path.appendAnchor")?; s.edit("Pen", |d, sel| { let path = path_mut(d, id)?; let si = match subpath { @@ -208,7 +208,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result { /// `at` first moves the anchor, its handles with it (the Pen's closing drag with Space held). fn close(s: &mut Session, p: &Value) -> Result { let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?; - let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); + let subpath = index_opt(p, "subpath", "path.close")?; let handle = |key: &str| match p.get(key).filter(|h| !h.is_null()) { None => Ok(None), Some(h) => finite_numbers(h).map(|[x, y]| Some(Point::new(x, y))).ok_or_else(|| bad("path.close", format!("`{key}` must be [x, y]"))), @@ -310,7 +310,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge fn set_handle(s: &mut Session, p: &Value) -> Result { let id = object_param(p, "id", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing id"))?; let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0); - let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?; + let ai = index_opt(p, "anchor", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing anchor"))?; let out = str_param(p, "which") != Some("in"); let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?); let independent = switch_or(p, "independent", false, "path.setHandle")?; @@ -337,7 +337,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result { fn move_anchor(s: &mut Session, p: &Value) -> Result { let id = object_param(p, "id", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0); - let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?; + let ai = index_opt(p, "anchor", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?; let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?); // An anchor already at (x, y) changes nothing and records no undo step. if let Some(a) = anchor_of(s, id, si, ai)? @@ -453,7 +453,7 @@ pub(super) fn paths_in(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec Result { const C: &str = "path.reverse"; let want = p.get("reversed").map(|v| v.as_bool().ok_or_else(|| bad(C, "`reversed` must be true or false"))).transpose()?; - let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); + let subpath = index_opt(p, "subpath", C)?; let ids = paths_in(&s.doc()?.doc, &targets(s, p)?); if ids.is_empty() { return Err(bad(C, "select paths")); @@ -1784,7 +1784,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result { fn insert_anchor(s: &mut Session, p: &Value) -> Result { let id = object_param(p, "id", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0); - let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?; + let seg = index_opt(p, "segment", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing segment"))?; let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0); let idx = s.edit("Add Anchor Point", |d, sel| { let path = path_mut(d, id)?; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 370664f795..bb45bf0fca 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -21,7 +21,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind, Shared}; use vectorcraft_geom::Rect; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, object_param, ok, str_param, unique_name}; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index_opt, object_param, ok, str_param, unique_name}; use crate::EngineError; pub fn specs() -> Vec { @@ -585,10 +585,10 @@ fn style_merge(s: &mut Session, p: &Value) -> Result { fn style_move(s: &mut Session, p: &Value) -> Result { let cmd = "graphicStyle.move"; let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?; - let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `to` (an index)"))?; + let to = index_opt(p, "to", cmd)?.ok_or_else(|| bad(cmd, "missing `to` (an index)"))?; let d = &s.doc()?.doc; let from = style_index(d, name)?; - let to = index(to).min(d.graphic_styles.len() - 1); + let to = to.min(d.graphic_styles.len() - 1); if from == to { return ok(); } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 6b14c98916..b3fab4a2bc 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -12,8 +12,8 @@ use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; use super::pathops::{length_given, number_given, shape_node}; use super::{ - CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, number_or, object_param, - ok, str_param, switch_or, targets, text_param, + CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, index_opt, number_or, + object_param, ok, str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec { @@ -1376,7 +1376,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setPathHandle"; let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0); - let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?; + let ai = index_opt(p, "anchor", C)?.ok_or_else(|| bad(C, "missing anchor"))?; let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in"); let pos = vectorcraft_geom::Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); let independent = switch_or(p, "independent", false, C)?; diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index 8a33376075..52cb314de0 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -849,3 +849,75 @@ fn journal_entries_pass_the_check() { assert!(check_params(id, params).is_ok(), "{id} {params}"); } } + +/// An index of a path, type, panel, library, style or ruler command given as something other than a +/// whole number of 0 or more (`"3"`, `2.5`, `-1`, `true`, `[]`, `{}`) is refused as the wrong type, +/// naming its key. A required one is not reported as missing, and an optional one (`subpath` of +/// `path.appendAnchor`, `path.close` and `path.reverse`, `artboard` of the ruler origin commands) is +/// not read as its default. A required one left out or null is still missing, and an optional one left +/// out or null keeps its default. Neither changes the document or records an undo step. +#[test] +fn a_malformed_index_is_not_missing_or_its_default() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("path.create", &json!({"d": "M0 0 L10 0 M20 0 L30 0"})).unwrap()["id"].clone(); + s.execute("library.create", &json!({"name": "Brand"})).unwrap(); + s.execute("library.createGroup", &json!({"name": "Warm"})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let with = |base: &serde_json::Value, key: &str, v: Option| { + let mut p = base.clone(); + let o = p.as_object_mut().unwrap(); + match v { + Some(v) => o.insert(key.into(), v), + None => o.remove(key), + }; + p + }; + let refused = |s: &mut Session, cmd: &str, p: &serde_json::Value, want: &str| match s.execute(cmd, p) { + Err(EngineError::BadParams { cmd: c, msg }) => assert_eq!((c.as_str(), msg.as_str()), (cmd, want), "{p}"), + other => panic!("{cmd} {p}: {other:?}"), + }; + let malformed = [json!("3"), json!(2.5), json!(-1), json!(true), json!([]), json!({})]; + let handle = json!({"id": id, "x": 5, "y": 5}); + let required = [ + ("path.setHandle", handle.clone(), "anchor", "missing anchor"), + ("path.moveAnchor", handle.clone(), "anchor", "missing anchor"), + ("path.insertAnchor", json!({"id": id}), "segment", "missing segment"), + ("text.setPathHandle", handle.clone(), "anchor", "missing anchor"), + ("artboard.reorder", json!({"index": 0}), "to", "missing to"), + ("library.moveGroup", json!({"group": "Warm"}), "to", "missing `to` (an index)"), + ("graphicStyle.move", json!({"name": "Default"}), "to", "missing `to` (an index)"), + ]; + for (cmd, base, key, missing) in &required { + for v in &malformed { + refused(&mut s, cmd, &with(base, key, Some(v.clone())), &format!("`{key}` must be a whole number")); + } + for p in [with(base, key, None), with(base, key, Some(json!(null)))] { + refused(&mut s, cmd, &p, missing); + } + } + let optional = [ + ("path.appendAnchor", handle.clone(), "subpath"), + ("path.close", json!({"id": id}), "subpath"), + ("path.reverse", json!({"ids": [id]}), "subpath"), + ("document.rulerOrigin", json!({}), "artboard"), + ("document.setRulerOrigin", json!({"x": 5, "y": 5}), "artboard"), + ("document.resetRulerOrigin", json!({}), "artboard"), + ]; + for (cmd, base, key) in &optional { + for v in &malformed { + refused(&mut s, cmd, &with(base, key, Some(v.clone())), &format!("`{key}` must be a whole number")); + } + } + // A whole number that names no artboard is still out of range. + refused(&mut s, "document.resetRulerOrigin", &json!({"artboard": 1}), "index out of range"); + assert_eq!(s.doc().unwrap().doc, doc); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + // Left out or null, an optional one keeps its default. + for (cmd, base, key) in &optional { + for p in [with(base, key, None), with(base, key, Some(json!(null)))] { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); + } + } +} diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 0f58e00181..a3b2800fd3 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -8321,7 +8321,7 @@ impl ArtBrushOptions { /// A path with the Calligraphic brush, and one with an Art brush in Tints whose art bends along the /// path, come back as those paths with the brush on their stroke at its weight, not as their drawn -/// look; a path with the Art brush that keeps its art's colors comes back as its drawn look. +/// look; so does a path with the Art brush that keeps its art's colors. #[test] fn brushed_paths_are_read_back_brushed() { let mut doc = brushes_document(); @@ -8341,8 +8341,63 @@ fn brushed_paths_are_read_back_brushed() { assert_eq!(art[1].path_data(), ours[1].path_data()); brushed(&mut doc, "Plain ribbon", (20.0, 180.0), (280.0, 180.0), 1.0); let out = export_ai_editing(&doc, &opts()).unwrap(); - assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); - assert!(matches!(read_back(&doc).layers[0].children().unwrap()[2].kind, NodeKind::Group { .. })); + assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[2]; + assert!(matches!(n.kind, NodeKind::Path { .. }), "{n:#?}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Plain ribbon")); +} + +/// Art brushes at another width, flipped along or across, scaled proportionately or stretched +/// between guides (flipped along too, or at guides the written numbers round) are written with those +/// options, and a path with each comes back as the path with the brush on its stroke, except Scale +/// Proportionately's, which is written as its brush's art. +#[test] +fn art_brushes_with_options_are_read_back_with_them() { + use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind}; + let thirds = ArtScale::BetweenGuides { start: 1.0 / 3.0, end: 2.0 / 3.0 }; + let options: Vec<(&str, ArtBrush)> = vec![ + ("Wide", ArtBrush { width: 250.0, ..ArtBrush::default() }), + ("Along", ArtBrush { flip_along: true, ..ArtBrush::default() }), + ("Across", ArtBrush { flip_across: true, ..ArtBrush::default() }), + ("Proportional", ArtBrush { scale: ArtScale::Proportional, width: 50.0, ..ArtBrush::default() }), + ("Guides", ArtBrush { scale: ArtScale::BetweenGuides { start: 0.25, end: 0.5 }, ..ArtBrush::default() }), + ("Guides along", ArtBrush { scale: ArtScale::BetweenGuides { start: 0.25, end: 0.5 }, flip_along: true, ..ArtBrush::default() }), + ("Thirds", ArtBrush { scale: thirds, flip_along: true, ..ArtBrush::default() }), + ]; + let lib: Vec = options + .iter() + .map(|(name, a)| Brush { name: (*name).into(), kind: BrushKind::Art(ArtBrush { art: brush_art_node(), ..a.clone() }) }) + .collect(); + let mut doc = Document::new(300.0, 200.0); + vectorcraft_brush::store(&mut doc, &lib); + for (i, b) in lib.iter().enumerate() { + let y = 20.0 + 25.0 * i as f64; + brushed(&mut doc, &b.name, (20.0, y), (280.0, y), 1.0); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings); + let back = read_back(&doc); + let got = vectorcraft_brush::library(&back); + assert_eq!(got.len(), lib.len()); + for (g, w) in got.iter().zip(&lib) { + let (BrushKind::Art(g), BrushKind::Art(w)) = (&g.kind, &w.kind) else { panic!("art") }; + if w.scale == thirds { + let ArtScale::BetweenGuides { start, end } = g.scale else { panic!("guides: {g:?}") }; + assert!((start - 1.0 / 3.0).abs() < 1e-6 && (end - 2.0 / 3.0).abs() < 1e-6, "{g:?}"); + assert_eq!(ArtBrushOptions::of(&ArtBrush { scale: thirds, ..g.clone() }), ArtBrushOptions::of(w)); + } else { + assert_eq!(ArtBrushOptions::of(g), ArtBrushOptions::of(w)); + } + } + for (n, b) in back.layers[0].children().unwrap().iter().zip(&lib) { + if b.name == "Proportional" { + assert!(matches!(n.kind, NodeKind::Group { .. }), "{}: the brush's art: {n:#?}", b.name); + continue; + } + assert!(matches!(n.kind, NodeKind::Path { .. }), "{}: {n:#?}", b.name); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some(b.name.as_str())); + } } /// A compound path with the Calligraphic brush comes back as that compound path with the brush on its @@ -8646,8 +8701,8 @@ fn brushes_write_the_same_bytes_again() { /// beside a brush that comes back, and each changed from a brush that comes back by the one setting /// that leaves it out: an Art brush whose art is a group with a fill of its own, a Scatter brush whose /// art is a path without points, an Art brush whose art is a group without members, Pattern and Bristle -/// brushes, Art brushes flipped along or across, overlapping, at another width, with a width that -/// varies, in another direction or scale, Art and Scatter brushes in Tints and Shades or Hue Shift, a +/// brushes, Art brushes overlapping, with a width that varies, in another direction or with guides +/// that leave no art between them, Art and Scatter brushes in Tints and Shades or Hue Shift, a /// brush whose name holds a slash and a second brush of a name. A brush left out doesn't take its /// name from a later one. #[test] @@ -8671,13 +8726,10 @@ fn brushes_the_reader_cant_read_are_left_out() { ("empty group", art_with(&|a| a.art = Node::group(vectorcraft_doc::NodeId(1), vec![]))), ("pattern", BrushKind::Pattern(PatternBrush::default())), ("bristle", BrushKind::Bristle(Bristle::default())), - ("flipped along", art_with(&|a| a.flip_along = true)), - ("flipped across", art_with(&|a| a.flip_across = true)), ("overlap", art_with(&|a| a.overlap = true)), - ("width", art_with(&|a| a.width = 50.0)), ("width varies", art_with(&|a| a.width_mode = Some(Variation::Pressure))), ("direction", art_with(&|a| a.direction = Direction::RightToLeft)), - ("proportional", art_with(&|a| a.scale = ArtScale::Proportional)), + ("guides with no art between them", art_with(&|a| a.scale = ArtScale::BetweenGuides { start: 0.5, end: 0.5 })), ("art tints and shades", art_with(&|a| a.colorization = Colorization::TintsAndShades)), ("art hue shift", art_with(&|a| a.colorization = hue)), ( @@ -8887,8 +8939,8 @@ fn an_empty_brushes_panel_comes_back_empty() { } /// A path with one of a new document's brushes writes the brushes the reader reads, and keeps its -/// brush. The new document's Art brush with other options, its Pattern brushes and its Bristle -/// brushes are left out with a warning, so the file opens without them. +/// brush. The new document's Pattern brushes and its Bristle brushes are left out with a warning, +/// so the file opens without them. #[test] fn a_new_documents_brush_on_a_path_writes_the_brushes() { let mut doc = Document::new(300.0, 200.0); @@ -8898,7 +8950,7 @@ fn a_new_documents_brush_on_a_path_writes_the_brushes() { let back = read_back(&doc); let n = &back.layers[0].children().unwrap()[0]; assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("3 pt. Round")); - let left_out = ["Arrow", "Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"]; + let left_out = ["Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"]; let want: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).filter(|n| !left_out.contains(n)).collect(); let lib = vectorcraft_brush::library(&back); assert_eq!(lib.iter().map(|b| b.name.as_str()).collect::>(), want); diff --git a/crates/eps/src/ai_editing/writer/brushes.rs b/crates/eps/src/ai_editing/writer/brushes.rs index 2c281e702d..e8a7d10b15 100644 --- a/crates/eps/src/ai_editing/writer/brushes.rs +++ b/crates/eps/src/ai_editing/writer/brushes.rs @@ -23,7 +23,7 @@ use super::{Writer, Xy}; use crate::ai_ops; use crate::ps; -pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't stretch their art along the path from left to right at 100% unflipped without overlap, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps"; +pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't run their art from left to right, that overlap or whose width follows the pen, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps"; /// Most brushes written (the reader's limit). const MAX_BRUSHES: usize = 4096; @@ -145,22 +145,71 @@ fn scatter_params(s: &Scatter, art: &str) -> Option { Some(format!("2 /{art}/ 5 0 0 0 / {}", nums6(&numbers))) } -/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read -/// (it reads an Art brush that stretches its art along the path from left to right at 100%, -/// unflipped, without overlap, in Tints or keeping its colors). -fn art_params(a: &ArtBrush, art: &str) -> Option { - let plain = a.direction == Direction::LeftToRight - && matches!(a.scale, ArtScale::Stretch) - && a.width == 100.0 - && a.width_mode.is_none() - && !a.flip_along - && !a.flip_across - && !a.overlap; - if !plain { +/// An Art brush's settings as its parameters' numbers 2 to 15 give them and a brushed stroke's filter +/// holds them ([`ART_SETTINGS`]): `None` for options the reader doesn't read (it reads an Art brush +/// that runs its art from left to right without overlap, at a fixed width, in Tints or keeping its +/// colors). The guides of Stretch Between Guides are the lengths of the art before the first and +/// after the second, measured before the art is flipped along the path. +fn art_settings(a: &ArtBrush) -> Option<[f64; 14]> { + if a.direction != Direction::LeftToRight || a.width_mode.is_some() || a.overlap { return None; } - let c = colorization_code(a.colorization)?; - let numbers = [1.0, c, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]; + let length = a.art.geometric_bounds().map_or(0.0, |b| b.width()); + let (proportional, segmented, before, after) = match a.scale { + ArtScale::Stretch => (0.0, 0.0, 0.0, 0.0), + ArtScale::Proportional => (1.0, 0.0, 0.0, 0.0), + ArtScale::BetweenGuides { start, end } if start < end => { + let (before, after) = (start * length, (1.0 - end) * length); + let (before, after) = if a.flip_along { (after, before) } else { (before, after) }; + (0.0, 1.0, before, after) + } + ArtScale::BetweenGuides { .. } => return None, + }; + let scaling = a.width / 100.0; + let flip = |on: bool| f64::from(u8::from(on)); + Some([ + colorization_code(a.colorization)?, + scaling, + 0.0, + 1.0, + flip(a.flip_along), + flip(a.flip_across), + proportional, + scaling, + scaling, + 0.0, + segmented, + before, + after, + 1.0, + ]) +} + +/// The entries of a brushed stroke's filter that hold an Art brush's settings, in the order of +/// [`art_settings`], with how each is written. +const ART_SETTINGS: [(&str, &str); 14] = [ + ("Colorization", "Int"), + ("Scaling", "Real"), + ("Spacing", "Real"), + ("Adjustment", "Int"), + ("Counterclockwise", "Int"), + ("UpsideDown", "Int"), + ("Proportional", "Bool"), + ("MinScaling", "Real"), + ("MaxScaling", "Real"), + ("ScaleYDependsOn", "Int"), + ("Segmented", "Bool"), + ("StartSegmentLength", "Real"), + ("EndSegmentLength", "Real"), + ("FoldAdjustment", "Bool"), +]; + +/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read +/// ([`art_settings`]). +fn art_params(a: &ArtBrush, art: &str) -> Option { + let settings = art_settings(a)?; + let mut numbers = vec![1.0]; + numbers.extend(settings); Some(format!("2 / {art}/ / / / / 0 0/ 5 0 0 0 / {}", nums6(&numbers))) } @@ -458,25 +507,10 @@ impl Writer<'_> { d.push_str("0 /Int (AngleRelativeTo) ,\n"); "(Calligraphic Brush) /Title ,\n" } - BrushKind::Art(_) => { + BrushKind::Art(a) => { d.push_str("15 /Int (Adobe Effect Expand Before Version) ,\n"); // brand-ok: the entry's name in the files - for (key, value, kind) in [ - ("StartSegmentLength", "0", "Real"), - ("EndSegmentLength", "0", "Real"), - ("Counterclockwise", "0", "Int"), - ("MinScaling", "1", "Real"), - ("MaxScaling", "1", "Real"), - ("Colorization", "1", "Int"), - ("Spacing", "0", "Real"), - ("Adjustment", "1", "Int"), - ("Proportional", "0", "Bool"), - ("Segmented", "0", "Bool"), - ("UpsideDown", "0", "Int"), - ("FoldAdjustment", "1", "Bool"), - ("ScaleYDependsOn", "0", "Int"), - ("Scaling", "1", "Real"), - ] { - let _ = writeln!(d, "{value} /{kind} ({key}) ,"); + for ((key, kind), value) in ART_SETTINGS.iter().zip(art_settings(a)?) { + let _ = writeln!(d, "{} /{kind} ({key}) ,", nums6(&[value])); } "(Art Brush) /Title ,\n" } diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 5a5db47114..1180e42fa9 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -36,7 +36,13 @@ //! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the //! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the //! rotation follows the path (14), and 1 Tints (1). -//! - Art: 1 Tints (2). +//! - Art: numbers 2 to 15 are the settings a path's art style holds as entries ([`ArtSettings`]): +//! the colorization (2: 0 None, 1 Tints; other codes color the art otherwise), the width as a factor +//! (3), `Spacing` (4), `Adjustment` (5), flipped along (6) and across (7), Scale Proportionately +//! (8), the lowest and highest width when the width follows the pen (9, 10, not drawn otherwise), +//! how the width varies (11, 0 Fixed), Stretch Between Guides (12) and the lengths of the art +//! before its first guide and after its second (13, 14, in the art's units, measured before +//! the art is flipped along the path), and `FoldAdjustment` (15). //! - Pattern: its scale as a factor (3). //! - A Bristle brush's parameters start with 8 numbers: two codes, then the stiffness, bristle //! thickness, density, paint opacity and bristle length as fractions and the size in @@ -45,14 +51,15 @@ //! [`super::read`]), usually in a group after its frame, an unpainted rectangle around it. A frame //! at the art's bounds isn't kept ([`without_frame`]); a wider one is. //! -//! What isn't read keeps its default: the Art brush's other options, the pattern brush's tiles +//! What isn't read keeps its default: an Art brush's settings that VectorCraft doesn't draw as the +//! file does ([`art_options`]) apart from its colorization, the pattern brush's tiles //! (which slot is which tile isn't known) and the colorization of the other brushes, which is left //! at None so the art keeps its own colors. use std::collections::{HashMap, HashSet}; use kurbo::Affine; -use vectorcraft_brush::{ArtBrush, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; +use vectorcraft_brush::{ArtBrush, ArtScale, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation}; use vectorcraft_color::Paint; use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; @@ -118,13 +125,13 @@ pub(super) enum DraftKind { brush: Scatter, art: String, }, - /// The art is the section named `art`. `plain`: the brush stretches its art along the path, - /// unflipped, at 100% in Tints (see [`art_is_plain`]), so [`Self::Art`]'s `brush` draws as the - /// file's brush does. + /// The art is the section named `art`. `settings`: the brush's settings when [`art_options`] + /// reads them, so [`Self::Art`]'s `brush` draws as the file's brush does (its guides set from + /// its art's length by [`finish_brushes`]). Art { brush: ArtBrush, art: String, - plain: bool, + settings: Option, }, /// The five art sections the brush names, in its order (which is which tile isn't known), and /// its scale (%). @@ -226,12 +233,17 @@ pub(super) fn calligraphic(values: [f64; 3], variation: [f64; 3], codes: [f64; 3 Calligraphic { angle, roundness, size, variation, modes: Some(modes) } } +/// An Art brush's settings: its object's numbers 2 to 15, which a path's art style holds as the +/// entries `Colorization`, `Scaling`, `Spacing`, `Adjustment`, `Counterclockwise`, `UpsideDown`, +/// `Proportional`, `MinScaling`, `MaxScaling`, `ScaleYDependsOn`, `Segmented`, +/// `StartSegmentLength`, `EndSegmentLength` and `FoldAdjustment`, in that order. +pub(super) type ArtSettings = [f64; 14]; + /// The settings a path's art style gives its stroke's brush. #[derive(Clone, Debug, PartialEq)] pub(super) enum PathBrush { Calligraphic(Calligraphic), - /// An Art brush whose art stretches along the path, at the brush's own width, in Tints. - Art, + Art(ArtSettings), } /// Does an Art brush draw `art` as the file does? It bends paths in solid colors: other objects @@ -250,8 +262,9 @@ pub(super) fn art_bends(art: &Node) -> bool { bends } -/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does. -pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap) -> HashSet { +/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does, with the +/// length of each (its width, the art running left to right). +pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap) -> HashMap { let names: HashSet<&String> = drafts .iter() .filter_map(|d| match &d.kind { @@ -259,13 +272,15 @@ pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap) -> _ => None, }) .collect(); - names.into_iter().filter(|n| art.get(*n).is_some_and(art_bends)).cloned().collect() + names.into_iter().filter_map(|n| art.get(n).filter(|a| art_bends(a)).and_then(|a| a.geometric_bounds()).map(|b| (n.clone(), b.width()))).collect() } /// Does the brush `draft` draw as `settings`, the settings a path's art style gives it? A path whose /// settings differ (Options of Selected Object) keeps its drawn look: a stroke names a brush, and -/// has no settings of its own. `bending`: [`bending_art`]. -pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashSet) -> bool { +/// has no settings of its own. An Art brush's settings that don't draw ([`drawn`]) may differ, and +/// the path's own settings must be ones the brush would open with ([`art_options`], [`opens_live`]). +/// `bending`: [`bending_art`]. +pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashMap) -> bool { let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0); match (&draft.kind, settings) { (DraftKind::Calligraphic(d), PathBrush::Calligraphic(c)) => { @@ -275,16 +290,90 @@ pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashS && d.variation.iter().zip(&c.variation).all(|(a, b)| close(*a, *b)) && d.modes() == c.modes() } - (DraftKind::Art { plain: true, art: name, .. }, PathBrush::Art) => bending.contains(name), + (DraftKind::Art { settings: Some(d), art: name, .. }, PathBrush::Art(s)) => { + let (dd, ds) = (drawn(d), drawn(s)); + art_options(s).is_some() + && bending.get(name).is_some_and(|length| { + // The guides match as lengths and as fractions of the art's length, so they differ + // neither on short art nor near the ends of long art. + dd.iter().zip(ds).all(|(a, b)| close(*a, b)) + && [11, 12].iter().all(|&i| close(dd[i] / length, ds[i] / length)) + && opens_live(d, *length) + && opens_live(s, *length) + }) + } _ => false, } } -/// Are an Art brush's last 15 numbers those of a brush that stretches its art along the path, -/// unflipped, at 100% in Tints? The numbers aren't read otherwise: any other value keeps its paths' -/// drawn look. -fn art_is_plain(numbers: &[f64; 15]) -> bool { - *numbers == [1.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0] +/// Art brush settings `s` with those that don't draw set to 0: the lowest and highest width while the +/// width is fixed, and the guides' lengths without Stretch Between Guides. +fn drawn(s: &ArtSettings) -> ArtSettings { + let mut out = *s; + if s[9] == 0.0 { + (out[7], out[8]) = (0.0, 0.0); + } + if s[10] == 0.0 { + (out[11], out[12]) = (0.0, 0.0); + } + out +} + +/// What an Art brush's settings give VectorCraft's Art brush, its guides apart ([`guides`]): `None` +/// when VectorCraft doesn't draw them as the file does: a colorization other than None or Tints, a +/// width outside [`ArtBrush::WIDTH_RANGE`] or one that varies, both Scale Proportionately and Stretch +/// Between Guides, a flip or scale that isn't 0 or 1, or a `Spacing`, `Adjustment` or +/// `FoldAdjustment` other than 0, 1 and 1 (what other values change isn't known). +fn art_options(s: &ArtSettings) -> Option { + let [colorization, scaling, spacing, adjustment, along, across, proportional, _, _, varies, segmented, _, _, fold] = *s; + let switch = |v: f64| match v { + 0.0 => Some(false), + 1.0 => Some(true), + _ => None, + }; + let colorization = match colorization { + 0.0 => Colorization::None, + 1.0 => Colorization::Tints, + _ => return None, + }; + let width = scaling * 100.0; + let (low, high) = ArtBrush::WIDTH_RANGE; + if !(low..=high).contains(&width) || varies != 0.0 || spacing != 0.0 || adjustment != 1.0 || fold != 1.0 { + return None; + } + let scale = match (switch(proportional)?, switch(segmented)?) { + (false, false) => ArtScale::Stretch, + (true, false) => ArtScale::Proportional, + (false, true) => ArtScale::BetweenGuides { start: 0.0, end: 1.0 }, + (true, true) => return None, + }; + Some(ArtBrush { scale, width, flip_along: switch(along)?, flip_across: switch(across)?, colorization, ..ArtBrush::default() }) +} + +/// The guides of Art brush settings `s` (with Stretch Between Guides) for art of length `length`, as +/// [`ArtScale::BetweenGuides`] fractions of the art as it runs along the path: `None` when they leave +/// no art between them. The settings measure the art before it is flipped along the path. +fn guides(s: &ArtSettings, length: f64) -> Option<(f64, f64)> { + let (before, after) = (s[11], s[12]); + if !(before >= 0.0 && after >= 0.0 && before + after < length) { + return None; + } + let (before, after) = if s[4] == 1.0 { (after, before) } else { (before, after) }; + Some((before / length, 1.0 - after / length)) +} + +/// Do Art brush settings `s` draw art of length `length`: no Stretch Between Guides, or guides with +/// art between them? +fn guides_fit(s: &ArtSettings, length: f64) -> bool { + s[10] == 0.0 || guides(s, length).is_some() +} + +/// Does a path with Art brush settings `s`, its art of length `length`, open with the brush on its +/// stroke? Its guides must fit ([`guides_fit`]). Scale Proportionately keeps the drawn look: +/// VectorCraft's Art brush sizes proportional art by the path's length and not by the stroke's +/// weight. +fn opens_live(s: &ArtSettings, length: f64) -> bool { + s[6] == 0.0 && guides_fit(s, length) } /// The brush named `name` of kind `kind` from its parameters `params`. @@ -342,15 +431,22 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< Tool::Art => { // The older form's 8 numbers aren't known: its brush keeps the defaults and its // paths their drawn look. The count tells the form, not the first number. - // Any other count isn't a form this reads. - let (colorization, plain) = if let Ok(n15) = <&[f64; 15]>::try_from(n.as_slice()) { - (if n15[1] == 1.0 { Colorization::Tints } else { Colorization::None }, art_is_plain(n15)) + // Any other count isn't a form this reads. Settings that aren't read keep the + // defaults but the colorization, and their paths their drawn look. + let (brush, settings) = if let Ok(&[first, ref settings @ ..]) = <&[f64; 15]>::try_from(n.as_slice()) { + match art_options(settings).filter(|_| first == 1.0) { + Some(brush) => (brush, Some(*settings)), + None => { + let colorization = if settings[0] == 1.0 { Colorization::Tints } else { Colorization::None }; + (ArtBrush { colorization, ..ArtBrush::default() }, None) + } + } } else if n.len() == 8 { - (ArtBrush::default().colorization, false) + (ArtBrush::default(), None) } else { return Err(BrushError::Values); }; - DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain } + DraftKind::Art { brush, art: art()?, settings } } _ => { let [_, _, p3, ..] = n.as_slice() else { return Err(BrushError::Values) }; @@ -453,10 +549,15 @@ pub(super) fn finish_brushes_within( _ if !kept => None, DraftKind::Calligraphic(c) => Some(BrushKind::Calligraphic(c)), DraftKind::Scatter { brush, art: name } => copy(&name).map(|a| BrushKind::Scatter(Scatter { art: a, ..brush })), - DraftKind::Art { brush, art: name, .. } => copy(&name).map(|mut a| { + DraftKind::Art { mut brush, art: name, settings } => copy(&name).map(|mut a| { // The side of the section with y up draws on the right of the path, and a brush // draws art's y (down here) on the left of the path. a.transform(Affine::scale_non_uniform(1.0, -1.0), false); + if let ArtScale::BetweenGuides { start, end } = &mut brush.scale { + // Guides that leave no art between them stretch the whole art. + let length = a.geometric_bounds().map_or(0.0, |b| b.width()); + (*start, *end) = settings.and_then(|s| guides(&s, length)).unwrap_or((0.0, 1.0)); + } BrushKind::Art(ArtBrush { art: a, ..brush }) }), DraftKind::Pattern { .. } => None, @@ -586,8 +687,8 @@ pub(crate) struct BrushRead { pub brush: Brush, /// A path whose art style names the brush, at the brush's own settings, opens with the brush on /// its stroke: a Calligraphic brush, or an Art brush whose art an Art brush draws as the file - /// does ([`art_bends`]) and whose numbers are those of a brush that stretches its art along the - /// path, unflipped, at 100% in Tints. + /// does ([`art_bends`]) and whose settings VectorCraft draws as the file does ([`art_options`], + /// with guides that leave art between them and without Scale Proportionately: [`opens_live`]). pub live: bool, } @@ -604,7 +705,9 @@ pub(crate) fn read_brush_object(plugin: &str, name: &str, params: &str, art: Opt let live = kept(&drafts, §ions).first().copied().unwrap_or(false) && match &drafts[0].kind { DraftKind::Calligraphic(_) => true, - DraftKind::Art { plain: true, art: section, .. } => bending_art(&drafts, §ions).contains(section), + DraftKind::Art { settings: Some(s), art: section, .. } => { + bending_art(&drafts, §ions).get(section).is_some_and(|length| opens_live(s, *length)) + } _ => false, }; let [draft] = drafts; @@ -643,8 +746,8 @@ pub(crate) struct BrushesRead { /// The brush a stroke that names a brush is drawn with, by name: the first one kept /// ([`kept`]), as an index in [`Self::drafts`]. by_name: HashMap, - /// The art an Art brush draws as the file does ([`bending_art`]). - bending: HashSet, + /// The art an Art brush draws as the file does, with its length ([`bending_art`]). + bending: HashMap, } impl BrushesRead { @@ -681,7 +784,7 @@ impl BrushesRead { st.items.iter().all(|item| match item { StyleItem::Stroke { brush: Some((name, settings)), .. } => { let Some(draft) = self.by_name.get(name).and_then(|i| self.drafts.get(*i)) else { return false }; - draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art) && !st.effects.is_empty()) + draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art(_)) && !st.effects.is_empty()) } _ => true, }) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index d31ada250c..9d66bd2d17 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -438,8 +438,8 @@ struct BrushesKept { read: (usize, usize), /// Which of [`Reader::brush_drafts`] are kept as brushes. kept: Vec, - /// The art an Art brush draws as the file does. - bending: HashSet, + /// The art an Art brush draws as the file does, with its length. + bending: HashMap, } struct Reader<'a> { diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 911d8340cc..f86ee6603b 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1181,7 +1181,7 @@ mod tests { assert!(nodes > 0); let draft = |i: usize| brush::BrushDraft { name: format!("Brush {i}"), - kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), plain: false }, + kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), settings: None }, }; let drafts: Vec<_> = (0..5).map(draft).collect(); let (kept, left_out) = brush::finish_brushes_within(drafts.clone(), &reader.brush_art, None, 3 * nodes); diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 87ba9752ef..f10682b592 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -992,8 +992,8 @@ struct StylePaints<'a> { brushes: &'a [super::super::brush::BrushDraft], /// Which of [`Self::brushes`] are kept as brushes ([`super::super::brush::kept`]). kept: &'a [bool], - /// The art an Art brush draws as the file does ([`super::super::brush::bending_art`]). - bending: &'a HashSet, + /// The art an Art brush draws as the file does, with its length ([`super::super::brush::bending_art`]). + bending: &'a HashMap, space: Affine, } @@ -1270,7 +1270,7 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p if !super::super::brush::draws_as(draft, settings, paints.bending) { return None; } - let art = matches!(settings, super::super::brush::PathBrush::Art); + let art = matches!(settings, super::super::brush::PathBrush::Art(_)); let on_a_path = matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); if !on_a_path && (art || !slot) { return None; @@ -1570,7 +1570,7 @@ mod tests { let st = &styles["Inked"]; let brushes = [parse_tool_object(Tool::Calligraphic, "N", "1 4 8 30 50 -150 210 2 5 6 0").unwrap()]; let (gradients, patterns, names, v7_patterns, bending) = - (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new()); + (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashMap::new()); let paints = StylePaints { gradients: &gradients, patterns: &patterns, @@ -1616,7 +1616,7 @@ mod tests { // GUARDS: an image with a Warp in its art style came in as its drawn look. let warp = ("Adobe Deform", "1 /Int (DeformStyle) ,\n0.4 /Real (DeformValue) ,\n"); // brand-ok: the filter's name in the files let (gradients, patterns, names, v7_patterns, bending) = - (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new()); + (HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashMap::new()); let paints = StylePaints { gradients: &gradients, patterns: &patterns, diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index d27a297925..891c058bb6 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -1500,11 +1500,30 @@ const ART_ENTRIES: [&str; 18] = [ "Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files ]; -/// An Art brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a color, names -/// no Art brush, holds an entry that isn't read, or its settings aren't those of art that stretches -/// along the path, unflipped, at the brush's own width (`Scaling` 1), in Tints (`Colorization` 1), -/// with `FoldAdjustment` 1, which is [`vectorcraft_brush::ArtBrush`]'s `overlap: false`: its drawn -/// look stays. +/// The entries of an Art brush's filter that hold its settings, in the order of +/// [`super::brush::ArtSettings`], with how each is written and its value when the filter leaves it +/// out (`None`: the filter must hold it). +const ART_SETTINGS: [(&str, Kind, Option); 14] = [ + ("Colorization", Kind::Int, None), + ("Scaling", Kind::Real, None), + ("Spacing", Kind::Real, Some(0.0)), + ("Adjustment", Kind::Int, Some(1.0)), + ("Counterclockwise", Kind::Int, Some(0.0)), + ("UpsideDown", Kind::Int, Some(0.0)), + ("Proportional", Kind::Bool, Some(0.0)), + ("MinScaling", Kind::Real, Some(1.0)), + ("MaxScaling", Kind::Real, Some(1.0)), + ("ScaleYDependsOn", Kind::Int, Some(0.0)), + ("Segmented", Kind::Bool, Some(0.0)), + ("StartSegmentLength", Kind::Real, Some(0.0)), + ("EndSegmentLength", Kind::Real, Some(0.0)), + ("FoldAdjustment", Kind::Bool, Some(1.0)), +]; + +/// An Art brush's filter `f` as the stroke it paints, with its settings: `None` when it isn't a +/// stroke in a color, names no Art brush, holds an entry that isn't read or a setting that isn't a +/// number of its kind, or tints its art with a swatch's color: its drawn look stays. Whether the +/// brush draws as these settings is [`super::brush::draws_as`]'s to say. fn art_stroke(f: &Filter) -> Option { if !f.paints.0 || f.gradient @@ -1519,28 +1538,13 @@ fn art_stroke(f: &Filter) -> Option { if plugin != ART_BRUSH { return None; } - let off = |key: &str, kind: Kind| matches!(entry(f, key, kind), Ok(None | Some(0.0))); - let plain = off("Proportional", Kind::Bool) - && off("Segmented", Kind::Bool) - && off("UpsideDown", Kind::Int) - && off("Counterclockwise", Kind::Int) - && off("ScaleYDependsOn", Kind::Int) - && off("Spacing", Kind::Real) - && off("StartSegmentLength", Kind::Real) - && off("EndSegmentLength", Kind::Real); - if !plain || entry(f, "Adjustment", Kind::Int).ok()?.unwrap_or(1.0) != 1.0 || entry(f, "FoldAdjustment", Kind::Bool).ok()?.unwrap_or(1.0) != 1.0 { - return None; - } - let one = |key: &str| entry(f, key, Kind::Real).is_ok_and(|v| v.unwrap_or(1.0) == 1.0); - if entry(f, "Scaling", Kind::Real).ok()?? != 1.0 || !one("MinScaling") || !one("MaxScaling") { - return None; - } - if entry(f, "Colorization", Kind::Int).ok()?? != 1.0 { - return None; + let mut settings = [0.0; 14]; + for (slot, (key, kind, missing)) in settings.iter_mut().zip(ART_SETTINGS) { + *slot = entry(f, key, kind).ok()?.or(missing)?; } let paint = f.stroke.clone()?; // The tinted pieces are process colors, so a swatch's ink would be lost. - if matches!(paint, Paint::Solid { swatch: Some(_), .. }) { + if settings[0] == 1.0 && matches!(paint, Paint::Solid { swatch: Some(_), .. }) { return None; } Some(StyleItem::Stroke { @@ -1552,7 +1556,7 @@ fn art_stroke(f: &Filter) -> Option { dash: None, profile: None, align: StrokeAlign::Center, - brush: Some((name.clone(), PathBrush::Art)), + brush: Some((name.clone(), PathBrush::Art(settings))), }) } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 8a9bab7530..7a821bf184 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -9797,7 +9797,7 @@ fn a_pattern_brushs_five_slots_are_kept() { fn an_older_art_brush_comes_in_with_its_art() { const OLD_RIBBON: &str = "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0"; let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", OLD_RIBBON).unwrap(); - assert!(matches!(d.kind, brush::DraftKind::Art { ref art, plain: false, .. } if art == "Ribbon art"), "{d:?}"); + assert!(matches!(d.kind, brush::DraftKind::Art { ref art, settings: None, .. } if art == "Ribbon art"), "{d:?}"); let brush::DraftKind::Art { ref brush, .. } = d.kind else { panic!("art") }; assert_eq!(brush.colorization, vectorcraft_brush::ArtBrush::default().colorization, "the default colorization"); // A count of numbers neither form has isn't read. @@ -9807,8 +9807,8 @@ fn an_older_art_brush_comes_in_with_its_art() { assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0"), Err(brush::BrushError::Values)); // 8 numbers are the older form whatever the first number says: read with the defaults. let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", "0 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0").unwrap(); - let brush::DraftKind::Art { ref brush, ref art, plain } = d.kind else { panic!("art") }; - assert_eq!((art.as_str(), plain, brush.colorization), ("Ribbon art", false, vectorcraft_brush::ArtBrush::default().colorization)); + let brush::DraftKind::Art { ref brush, ref art, settings } = d.kind else { panic!("art") }; + assert_eq!((art.as_str(), settings, brush.colorization), ("Ribbon art", None, vectorcraft_brush::ArtBrush::default().colorization)); let objects = [plugin_object(ART_BRUSH, "Ribbon", OLD_RIBBON), plugin_object(ART_BRUSH, "Bare", "1 / / / / / / 0 1 / 1 0 1 0 1 0 0 0")].concat(); let r = read_ok(&format!("{}{objects}{}", brush_art("Ribbon art", 4.0), layer(""))); let lib = vectorcraft_brush::library(&r.doc); @@ -10471,9 +10471,109 @@ fn an_art_brush_bends_only_paths_in_solid_fills() { assert!(wrong.is_empty(), "{wrong:?}"); } +/// The entries of an Art brush's filter that hold its settings, in the order of its object's numbers 2 +/// to 15, with how each is written. +const ART_KEYS: [(&str, &str); 14] = [ + ("Colorization", "Int"), + ("Scaling", "Real"), + ("Spacing", "Real"), + ("Adjustment", "Int"), + ("Counterclockwise", "Int"), + ("UpsideDown", "Int"), + ("Proportional", "Bool"), + ("MinScaling", "Real"), + ("MaxScaling", "Real"), + ("ScaleYDependsOn", "Int"), + ("Segmented", "Bool"), + ("StartSegmentLength", "Real"), + ("EndSegmentLength", "Real"), + ("FoldAdjustment", "Bool"), +]; + +/// The Art brush `Ribbon` (its art 40 long: [`lopsided_art`]) whose settings, numbers 2 to 15 of its +/// object, are those of [`RIBBON`] with `changes` (index in the 14, value), and the style `Inked` that +/// gives a path the same settings. +fn ribbon_with(changes: &[(usize, &str)]) -> (String, String) { + let mut settings = ["1", "1", "0", "1", "0", "0", "0", "1", "1", "0", "0", "0", "0", "1"]; + for (i, v) in changes { + settings[*i] = v; + } + let brush = format!("2 / Ribbon art/ / / / / 0 0/ 5 0 0 0 / 1 {}", settings.join(" ")); + let entries: Vec<(&str, &str, &str)> = ART_KEYS.iter().zip(settings).map(|((key, kind), value)| (*key, value, *kind)).collect(); + (format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", &brush)), art_style("Ribbon", 1.0, &entries, "")) +} + +/// A path with an Art brush whose settings are its own comes in brushed, the brush with those +/// settings: None, another width (the lowest and highest widths aren't drawn while the width is +/// fixed), flipped along or across, and Stretch Between Guides, whose guides come in as fractions of +/// the art as it runs along the path (measured before it is flipped along it, so a flip along swaps +/// them). Lengths without Stretch Between Guides aren't guides. A brush that scales its art +/// proportionately comes into the panel with that option, and its path keeps its drawn look. +#[test] +fn an_art_brush_with_options_comes_in_brushed_with_them() { + use vectorcraft_brush::{ArtBrush, ArtScale, Colorization}; + let check_file = |why: &str, (def, style): (String, String), want: &dyn Fn(&ArtBrush) -> bool| { + let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path()))); + let a = art(&r.doc); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{why}: the path, not its look: {a:#?}"); + assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon"), "{why}"); + let lib = vectorcraft_brush::library(&r.doc); + let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("{why}: art") }; + assert!(want(brush), "{why}: {brush:?}"); + r + }; + let check = |why: &str, changes: &[(usize, &str)], want: &dyn Fn(&ArtBrush) -> bool| check_file(why, ribbon_with(changes), want); + let near = |a: f64, b: f64| (a - b).abs() < 1e-9; + // Settings that don't draw may differ between the brush and the path: the lowest and highest + // width while the width is fixed, and lengths without Stretch Between Guides. + let (def, style) = ribbon_with(&[(1, "2"), (7, "2"), (8, "2")]); + let style = style.replace("2 /Real (MinScaling) ,\n", "").replace("2 /Real (MaxScaling) ,\n", ""); + check_file("the path leaves out the widths the pen would give", (def, style), &|b| b.width == 200.0); + let (def, _) = ribbon_with(&[]); + let (_, style) = ribbon_with(&[(11, "5"), (12, "10")]); + check_file("lengths on the path alone", (def, style), &|b| b.scale == ArtScale::Stretch); + check("its art's colors", &[(0, "0")], &|b| b.colorization == Colorization::None); + check("twice as wide", &[(1, "2"), (7, "2"), (8, "2")], &|b| b.width == 200.0); + check("half as wide, the pen's widths left", &[(1, "0.5")], &|b| b.width == 50.0 && b.width_mode.is_none()); + let along = check("flipped along", &[(4, "1")], &|b| b.flip_along && !b.flip_across); + let across = check("flipped across", &[(5, "1")], &|b| b.flip_across && !b.flip_along); + let (def, style) = ribbon_with(&[(6, "1")]); + let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path()))); + assert!(matches!(art(&r.doc)[0].kind, NodeKind::Group { .. }), "proportional: the drawn look"); + let lib = vectorcraft_brush::library(&r.doc); + let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("proportional: art") }; + assert_eq!(brush.scale, ArtScale::Proportional); + check( + "between guides", + &[(10, "1"), (11, "5"), (12, "10")], + &|b| matches!(b.scale, ArtScale::BetweenGuides { start, end } if near(start, 5.0 / 40.0) && near(end, 30.0 / 40.0)), + ); + check("between guides, flipped along", &[(10, "1"), (11, "5"), (12, "10"), (4, "1")], &|b| { + b.flip_along && matches!(b.scale, ArtScale::BetweenGuides { start, end } if near(start, 10.0 / 40.0) && near(end, 35.0 / 40.0)) + }); + check("lengths without guides", &[(11, "5"), (12, "10")], &|b| b.scale == ArtScale::Stretch); + // Along a straight line, y down: unflipped, the wide block is above the line and the narrow one + // below it, at the start. + let line = kurbo::BezPath::from_vec(vec![kurbo::PathEl::MoveTo(Point::new(0.0, 0.0)), kurbo::PathEl::LineTo(Point::new(100.0, 0.0))]); + let filled = |r: &Structure, x: f64, y: f64| { + let lib = vectorcraft_brush::library(&r.doc); + let st = art(&r.doc)[0].appearance.stroke().cloned().unwrap(); + let ribbon = lib.iter().find(|b| b.name == "Ribbon").unwrap(); + let pieces = vectorcraft_brush::stroke_pieces(&r.doc, ribbon, &line, &st); + pieces.iter().any(|n| { + let mut hit = false; + n.walk(&mut |c| hit |= c.path_data().is_some_and(|p| kurbo::Shape::winding(&p.to_bezpath(), Point::new(x, y)) != 0)); + hit + }) + }; + // At weight 1 the art is 8 across: each block is 4 across, on its side of the line. + assert!(filled(&across, 50.0, 2.0) && filled(&across, 10.0, -2.0) && !filled(&across, 50.0, -2.0), "across: the wide block below"); + assert!(filled(&along, 50.0, -2.0) && filled(&along, 90.0, 2.0) && !filled(&along, 10.0, 2.0), "along: the narrow block at the end"); +} + /// An Art brushed path keeps its drawn look when the brush wouldn't draw it the way the file does: -/// its settings aren't those of art that stretches along the path at the brush's own width and -/// color, the brush's own aren't, its art isn't read, or the brush isn't one the panel keeps. +/// its settings differ from the brush's, the brush's are ones VectorCraft doesn't draw as the file +/// does, its art isn't read, or the brush isn't one the panel keeps. #[test] fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { let good = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON)); @@ -10516,21 +10616,7 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { ), plain_style.clone(), ), - ( - "a width other than the brush's 100%", - format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", wide)), - art_style( - "Ribbon", - 1.0, - &STRETCHED.map(|e| if matches!(e.0, "Scaling" | "MinScaling" | "MaxScaling") { (e.0, "2", e.2) } else { e }), - "", - ), - ), - ( - "a brush that keeps its art's colors", - format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", own_colors)), - art_style("Ribbon", 1.0, &STRETCHED.map(|e| if e.0 == "Colorization" { (e.0, "0", e.2) } else { e }), ""), - ), + ("a brush that keeps its art's colors, the path in Tints", format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", own_colors)), plain_style.clone()), ("an entry that isn't read", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "1 /Int (SomeOtherSetting) ,\n")), ( "an effect on the path", @@ -10543,17 +10629,16 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { ("proportional", good.clone(), style_with("Proportional", "1")), ("between guides", good.clone(), style_with("Segmented", "1")), ("its own width", good.clone(), style_with("Scaling", "2")), - ("a width that varies", good.clone(), style_with("MaxScaling", "2")), ("a width on the pen", good.clone(), style_with("ScaleYDependsOn", "2")), ("spacing", good.clone(), style_with("Spacing", "1")), - ("flipped", good.clone(), style_with("UpsideDown", "1")), + ("a spacing close to the brush's 0", good.clone(), style_with("Spacing", "0.0005")), + ("flipped",good.clone(), style_with("UpsideDown", "1")), ("flipped the other way", good.clone(), style_with("Counterclockwise", "1")), ("another adjustment", good.clone(), style_with("Adjustment", "2")), ("no fold adjustment", good.clone(), style_with("FoldAdjustment", "0")), ("its own colorization", good.clone(), style_with("Colorization", "0")), ("tints and shades", good.clone(), style_with("Colorization", "2")), ("hue shift", good.clone(), style_with("Colorization", "3")), - ("start of a guide", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "5 /Real (StartSegmentLength) ,\n")), ("variable width", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "2 /Int (BSWidthSource) ,\n")), ( "width arrays", @@ -10570,6 +10655,35 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { ("art not read", plugin_object(ART_BRUSH, "Ribbon", RIBBON), plain_style.clone()), ("no such brush", good.clone(), art_style("Other", 1.0, &STRETCHED, "")), ]; + // The brush's own settings, which the path shares, but VectorCraft doesn't draw as the file does. + let own = [ + ("both brush and path: tints and shades", &[(0, "2")][..]), + ("both: hue shift", &[(0, "3")]), + ("both: spacing", &[(2, "1")]), + ("both: another adjustment", &[(3, "2")]), + ("both: no fold adjustment", &[(13, "0")]), + ("both: a width on the pen", &[(9, "1"), (7, "0.5"), (8, "2")]), + ("both: a width past the range", &[(1, "20"), (7, "20"), (8, "20")]), + ("both: a flip of another value", &[(4, "2")]), + ("both: proportional", &[(6, "1")]), + ("both: proportional and between guides", &[(6, "1"), (10, "1"), (11, "5"), (12, "10")]), + ("both: guides that leave no art between them", &[(10, "1"), (11, "30"), (12, "10")]), + ("both: a guide past the start", &[(10, "1"), (11, "-5"), (12, "10")]), + ]; + let mut cases: Vec<(&str, String, String)> = cases + .into_iter() + .chain(own.iter().map(|(why, changes)| { + let (def, style) = ribbon_with(changes); + (*why, def, style) + })) + .collect(); + // The brush's guides leave art between them, and the path's, close to them, leave none. + let (def, style) = ribbon_with(&[(10, "1"), (11, "20"), (12, "19.99")]); + cases.push(("path guides close to the brush's that leave no art between them", def, style.replace("19.99 /", "20 /"))); + // On art 0.01 long, a guide at 0.0035 is 35% of the way along it, and the brush's at 0.003 is 30%. + let (def, style) = ribbon_with(&[(10, "1"), (11, "0.003"), (12, "0.002")]); + let short = format!("%AI8_BeginBrushPattern\n(Ribbon art)\n0 A\n0 g\n{}f\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 0.01, 4.0)); + cases.push(("path guides elsewhere on short art", def.replace(&lopsided_art("Ribbon art"), &short), style.replace("0.003 /", "0.0035 /"))); let mut wrong = vec![]; for (why, def, style) in cases { let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path()))); diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 309aa77409..7a83c811a1 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -19,7 +19,7 @@ mod lights; pub use lights::{Light, Lights, MAX_LIGHTS}; mod caps; mod mesh; -pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh}; +pub use mesh::{Mesh, MeshedSolid, extrude_mesh, extrude_meshed, inflate_meshed, inflate_meshes, plane_mesh, revolve_mesh}; mod shadow; pub use shadow::{MAX_SHADOW_FACES, Shadow, ShadowCaster, ShadowPlane, ShadowPosition, TOO_COMPLEX_SHADOW}; diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs index b12bd315f5..cd9623191c 100644 --- a/crates/three-d/src/mesh.rs +++ b/crates/three-d/src/mesh.rs @@ -10,8 +10,8 @@ use vectorcraft_geom::{FillRule, PathData, Rect}; -use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces}; -use crate::{Extrude, Inflate, Revolve, V3, View}; +use crate::solid::{Face3, Keep, extrude_faces, inflate_faces, plane_faces}; +use crate::{Extrude, Facet, Inflate, Revolve, V3, View}; /// Most steps the triangulation of one solid may take: a face's ears and bridges each test the /// face's other corners. @@ -56,12 +56,12 @@ impl Mesh { /// Extrude & Bevel's solid ([`crate::extrude`]) as a mesh. pub fn extrude_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result { - extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces, _)| mesh(&faces)) + extrude_faces(path, rule, bounds, options, view, Keep::Faces).and_then(|(_, faces, _)| mesh(&faces?)) } /// Rotate's plane ([`crate::plane`]) as a mesh. pub fn plane_mesh(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result { - plane_faces(path, rule, bounds, view, true).and_then(|(_, faces, _)| mesh(&faces)) + plane_faces(path, rule, bounds, view, Keep::Faces).and_then(|(_, faces, _)| mesh(&faces?)) } /// Inflate's solids for several shapes of one object ([`crate::inflate_together`]) as meshes, one @@ -72,7 +72,49 @@ pub fn inflate_meshes( options: Inflate, view: View, ) -> impl Iterator> + use<> { - inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces, _)| mesh(&faces))) + inflate_faces(shapes, bounds, options, view, Keep::Faces).map(|r| r.and_then(|(_, faces, _)| mesh(&faces?))) +} + +/// One solid built once for both its drawing and its mesh: the facets [`crate::extrude`] or +/// [`crate::inflate_each`] gives, and the faces before projection that [`MeshedSolid::mesh`] cuts +/// into the mesh [`extrude_mesh`] or [`inflate_meshes`] gives. +#[derive(Clone, Debug)] +pub struct MeshedSolid { + pub facets: Vec, + /// The faces before projection, or the error the mesh alone gives when there are too many. + faces: Result, String>, +} + +/// A solid with no facets and an empty mesh. +impl Default for MeshedSolid { + fn default() -> Self { + Self { facets: vec![], faces: Ok(vec![]) } + } +} + +impl MeshedSolid { + /// The solid as a mesh. The faces are cut into triangles on each call. + pub fn mesh(&self) -> Result { + mesh(self.faces.as_ref().map_err(String::clone)?) + } +} + +/// Extrude & Bevel's solid ([`crate::extrude`]) and its mesh ([`extrude_mesh`]) from one build. An +/// error where [`crate::extrude`] gives one. +pub fn extrude_meshed(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result { + extrude_faces(path, rule, bounds, options, view, Keep::Both).map(|(facets, faces, _)| MeshedSolid { facets, faces }) +} + +/// Inflate's solids for several shapes of one object ([`crate::inflate_each`]) and their meshes +/// ([`inflate_meshes`]), each solid built once, as the iterator reaches it. An error where +/// [`crate::inflate_each`] gives one. +pub fn inflate_meshed( + shapes: &[(&PathData, FillRule)], + bounds: Rect, + options: Inflate, + view: View, +) -> impl Iterator> + use<> { + inflate_faces(shapes, bounds, options, view, Keep::Both).map(|r| r.map(|(facets, faces, _)| MeshedSolid { facets, faces })) } /// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs index 2011d6689c..76f9b04ecc 100644 --- a/crates/three-d/src/mesh/tests.rs +++ b/crates/three-d/src/mesh/tests.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use vectorcraft_geom::{BezPath, FillRule, PathData, Rect}; use super::*; -use crate::{Bevel, Edge}; +use crate::{Bevel, BevelShape, Edge}; fn square(b: &mut BezPath, x: f64, y: f64, s: f64, clockwise: bool) { b.move_to((x, y)); @@ -372,3 +372,130 @@ fn join_numbers_each_parts_corners_after_the_last() { assert!(both.triangles.iter().all(|t| t.iter().all(|i| (*i as usize) < both.positions.len()))); assert_eq!(both.triangles[one.triangles.len()][0], one.triangles[0][0] + one.positions.len() as u32); } + +/// A mesh's positions and normals as bits, and its triangles. +type MeshBits = (Vec<[u64; 3]>, Vec<[u64; 3]>, Vec<[u32; 3]>); + +/// The bits of every number in `m`, for comparing two meshes exactly. +fn bits(m: &Mesh) -> MeshBits { + (m.positions.iter().map(|p| p.map(f64::to_bits)).collect(), m.normals.iter().map(|n| n.map(f64::to_bits)).collect(), m.triangles.clone()) +} + +/// A star of `points` points about (100, 100), with a square hole: curved and straight edges both. +fn star(points: usize) -> (PathData, Rect) { + let mut b = BezPath::new(); + for k in 0..2 * points { + let (s, c) = (k as f64 * std::f64::consts::PI / points as f64).sin_cos(); + let r = if k % 2 == 0 { 90.0 } else { 45.0 }; + let p = (100.0 + r * c, 100.0 + r * s); + if k == 0 { b.move_to(p) } else { b.curve_to((p.0 + 3.0, p.1), (p.0, p.1 + 3.0), p) } + } + b.close_path(); + square(&mut b, 90.0, 90.0, 20.0, true); + shape(&b) +} + +/// The views the solids are compared in: straight on, turned, with perspective, and with colored +/// lights (Inflate's smooth triangles). +fn views() -> Vec { + let turned = View { rotation_x: -25.0, rotation_y: 35.0, rotation_z: 10.0, ..View::default() }; + vec![front(), turned, View { perspective: 60.0, ..turned }, View { light_color: [1.0, 0.5, 0.25], ..turned }] +} + +/// Extrude & Bevel built once for its facets and its mesh gives what [`crate::extrude`] and +/// [`extrude_mesh`] give apart, to the bit, errors included. +#[test] +fn an_extruded_solid_and_its_mesh_match_the_two_built_apart() { + let bevel = Bevel { shape: BevelShape::Rounded, width: 6.0, height: 5.0, ..Bevel::default() }; + let options = [ + Extrude::default(), + Extrude { depth: 0.0, ..Extrude::default() }, + Extrude { cap: false, ..Extrude::default() }, + Extrude { hidden: true, ..Extrude::default() }, + Extrude { twist: 90.0, taper: 0.6, ..Extrude::default() }, + Extrude { bevel: Some(bevel), ..Extrude::default() }, + Extrude { bevel: Some(Bevel { both_sides: true, inside: true, ..bevel }), twist: 45.0, ..Extrude::default() }, + ]; + let (p, r) = star(7); + for view in views() { + for o in options { + let meshed = extrude_meshed(&p, FillRule::NonZero, r, o, view).unwrap(); + let facets = crate::extrude(&p, FillRule::NonZero, r, o, view).unwrap(); + assert_eq!(format!("{:?}", meshed.facets), format!("{facets:?}"), "{o:?} {view:?}"); + let apart = extrude_mesh(&p, FillRule::NonZero, r, o, view).unwrap(); + assert!(!apart.is_empty()); + assert_eq!(bits(&meshed.mesh().unwrap()), bits(&apart), "{o:?} {view:?}"); + } + } + // A solid that can't be built gives the same error. + let flat = Rect::new(0.0, 0.0, f64::INFINITY, 10.0); + let o = Extrude::default(); + let error = crate::extrude(&p, FillRule::NonZero, flat, o, front()).err(); + assert!(error.is_some()); + assert_eq!(extrude_meshed(&p, FillRule::NonZero, flat, o, front()).err(), error); +} + +/// Inflate's solids built once for their facets and meshes give what [`crate::inflate_each`] and +/// [`inflate_meshes`] give apart, to the bit: several puffs under one eye, both sides, open +/// backs, and twisted sides cut finer than room is left for. +#[test] +fn inflated_solids_and_their_meshes_match_the_two_built_apart() { + let (star, _) = star(5); + let mut b = BezPath::new(); + square(&mut b, 200.0, 20.0, 60.0, false); + let (box_, _) = shape(&b); + let ring: Vec = (0..600) + .map(|k| { + let (s, c) = (k as f64 * std::f64::consts::TAU / 600.0).sin_cos(); + vectorcraft_geom::Point::new(100.0 + 90.0 * c, 100.0 + 90.0 * s) + }) + .collect(); + let ring = PathData::single(vectorcraft_geom::SubPath::polyline(&ring, true)); + let sets = [vec![(&star, FillRule::NonZero), (&box_, FillRule::NonZero), (&star, FillRule::EvenOdd)], vec![(&ring, FillRule::NonZero)]]; + let bounds = Rect::new(0.0, 0.0, 280.0, 200.0); + let options = [ + Inflate::default(), + Inflate { depth: 30.0, both_sides: true, ..Inflate::default() }, + Inflate { depth: 20.0, cap: false, volume: 80.0, ..Inflate::default() }, + Inflate { depth: 40.0, twist: 360.0, taper: 0.7, both_sides: true, ..Inflate::default() }, + // Thin and barely twisted: the star's sides want more pieces than room is left after its + // front and back, and the mesh, which keeps the faces turned away, has less room left. + Inflate { depth: 0.1, twist: 0.1, both_sides: true, ..Inflate::default() }, + ]; + for shapes in &sets { + for view in views() { + for o in options { + let meshed: Vec<_> = inflate_meshed(shapes, bounds, o, view).collect(); + let facets: Vec<_> = crate::inflate_each(shapes, bounds, o, view).collect(); + let apart: Vec<_> = inflate_meshes(shapes, bounds, o, view).collect(); + assert_eq!(meshed.len(), shapes.len()); + for ((m, f), a) in meshed.iter().zip(&facets).zip(&apart) { + let (m, f, a) = (m.as_ref().unwrap(), f.as_ref().unwrap(), a.as_ref().unwrap()); + assert_eq!(format!("{:?}", m.facets), format!("{f:?}"), "{o:?} {view:?}"); + assert!(!a.is_empty()); + assert_eq!(bits(&m.mesh().unwrap()), bits(a), "{o:?} {view:?}"); + } + } + } + } +} + +/// One build gives both the facets and the mesh: each solid is built once. +#[test] +fn a_meshed_solid_is_built_once() { + let (p, r) = star(5); + let extruded = || crate::solid::EXTRUDED.with(|n| n.get()); + let start = extruded(); + let solid = extrude_meshed(&p, FillRule::NonZero, r, Extrude::default(), front()).unwrap(); + assert!(!solid.facets.is_empty() && solid.mesh().is_ok_and(|m| !m.is_empty())); + assert_eq!(extruded(), start + 1); + let inflated = || crate::solid::INFLATED.with(|n| n.get()); + let start = inflated(); + let mut b = BezPath::new(); + square(&mut b, 200.0, 20.0, 60.0, false); + let (q, _) = shape(&b); + for solid in inflate_meshed(&[(&p, FillRule::NonZero), (&q, FillRule::NonZero)], Rect::new(0.0, 0.0, 280.0, 200.0), Inflate::default(), front()) { + assert!(solid.is_ok_and(|s| !s.facets.is_empty() && s.mesh().is_ok_and(|m| !m.is_empty()))); + } + assert_eq!(inflated(), start + 2); +} diff --git a/crates/three-d/src/shadow.rs b/crates/three-d/src/shadow.rs index d7cd01a869..66842abad2 100644 --- a/crates/three-d/src/shadow.rs +++ b/crates/three-d/src/shadow.rs @@ -11,7 +11,7 @@ use std::ops::Range; use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect}; -use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces}; +use crate::solid::{Face3, Keep, extrude_faces, inflate_faces, plane_faces}; use crate::{Extrude, Inflate, MAX_SOLID_FACES, Revolve, V3, View, finite, perspective_scale}; /// Where the shadow falls. @@ -160,14 +160,14 @@ impl ShadowCaster { /// Add Extrude & Bevel's solid ([`crate::extrude`]). pub fn extrude(&mut self, path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { - let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, true)?; - self.add(faces, projection) + let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, Keep::Faces)?; + self.add(faces?, projection) } /// Add Rotate's plane ([`crate::plane`]). pub fn plane(&mut self, path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { - let (_, faces, projection) = plane_faces(path, rule, bounds, view, true)?; - self.add(faces, projection) + let (_, faces, projection) = plane_faces(path, rule, bounds, view, Keep::Faces)?; + self.add(faces?, projection) } /// Add Inflate's solids for several shapes of one object ([`crate::inflate_together`]), each @@ -182,11 +182,11 @@ impl ShadowCaster { options: Inflate, view: View, ) -> Vec, String>> { - inflate_faces(shapes, bounds, options, view, true) + inflate_faces(shapes, bounds, options, view, Keep::Faces) .enumerate() .map(|(i, r)| { let (_, faces, projection) = r?; - if casts.get(i).copied().unwrap_or(true) { self.add(faces, projection) } else { Ok(self.faces.len()..self.faces.len()) } + if casts.get(i).copied().unwrap_or(true) { self.add(faces?, projection) } else { Ok(self.faces.len()..self.faces.len()) } }) .collect() } diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 49b0738d9e..f67f6c96e8 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -20,9 +20,11 @@ mod membrane; mod outline; mod rings; +#[cfg(test)] +pub(crate) use build::EXTRUDED; #[cfg(test)] use build::{Camera, cuts, edge_distance, newell, section}; -pub(crate) use build::{Face3, extrude_faces, plane_faces}; +pub(crate) use build::{Face3, Keep, extrude_faces, plane_faces}; pub use build::{extrude, plane}; pub use face_map::{FaceMap, extrude_face_map, plane_face_map}; #[cfg(test)] diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 6d21805e74..2aa218e558 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -64,9 +64,25 @@ impl Camera { /// and its outward normal. pub(crate) type Face3 = (Vec>, V3); -/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection, -/// and the projection it used (`None` when nothing was built). -pub(crate) type Built = (Vec, Vec, Option); +/// What a solid's builder makes: its facets, its faces before projection when a mesh is asked for +/// (an error when they pass [`MAX_SOLID_FACES`] while the facets are built too), and the +/// projection it used (`None` when nothing was built). +pub(crate) type Built = (Vec, Result, String>, Option); + +#[cfg(test)] +thread_local! { + /// How many Extrude & Bevel solids this thread has built, for the tests. + pub(crate) static EXTRUDED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// What a solid's builder keeps: its facets, its faces before projection for a mesh, or both +/// from one build. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Keep { + Facets, + Faces, + Both, +} /// Facets being collected for one solid. pub(super) struct Builder<'a> { @@ -80,6 +96,11 @@ pub(super) struct Builder<'a> { pub(super) facets: Vec, /// With `Some`, every face goes here as it is, none hidden or projected, for a mesh. pub(super) mesh: Option>, + /// The faces are projected into `facets`; without it only `mesh` is kept. + pub(super) draw: bool, + /// The faces kept for the mesh passed [`MAX_SOLID_FACES`] while the facets were built too: the + /// mesh is too complex, and `mesh` keeps no more faces. + pub(super) lost: bool, } /// Where a face goes in the order the facets are drawn, back to front. @@ -98,20 +119,46 @@ pub(super) enum Order { Last, } -impl Builder<'_> { +impl<'a> Builder<'a> { + /// A builder for one solid seen by `camera`, keeping what `keep` asks for. + pub(super) fn new(camera: &'a Camera, closed: bool, hidden: bool, keep: Keep) -> Self { + Self { camera, closed, hidden, facets: vec![], mesh: (keep != Keep::Facets).then(Vec::new), draw: keep != Keep::Faces, lost: false } + } + /// Add a face of view-space contours with outward normal `n` (view space), drawn in `order`. pub(super) fn face(&mut self, contours: Vec>, n: V3, order: Order) -> Result<(), String> { let count: usize = contours.iter().map(Vec::len).sum(); if count < 3 { return Ok(()); } - if let Some(mesh) = &mut self.mesh { - mesh.push((contours, n.unit())); - if mesh.len() > MAX_SOLID_FACES { + if self.draw { + self.facet(&contours, count, n, order)?; + } + let Some(mesh) = &mut self.mesh else { return Ok(()) }; + mesh.push((contours, n.unit())); + if mesh.len() > MAX_SOLID_FACES { + if !self.draw { return Err(TOO_COMPLEX_SURFACE.into()); } - return Ok(()); + self.lose_mesh(); } + Ok(()) + } + + /// Keep no more faces for the mesh, which is too complex. + pub(super) fn lose_mesh(&mut self) { + self.mesh = None; + self.lost = true; + } + + /// How many faces the mesh holds, while the facets are built too and the mesh is kept. + pub(super) fn faces_kept(&self) -> Option { + self.mesh.as_ref().filter(|_| self.draw).map(Vec::len) + } + + /// Project a face of `count` view-space corners with outward normal `n` into a facet, drawn in + /// `order`, unless it is hidden or has no area. + fn facet(&mut self, contours: &[Vec], count: usize, n: V3, order: Order) -> Result<(), String> { let centre = contours.iter().flatten().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2)); let centre = V3(centre.0 / count as f64, centre.1 / count as f64, centre.2 / count as f64); let n = n.unit(); @@ -138,7 +185,7 @@ impl Builder<'_> { let flat = !held && contours.iter().flatten().all(|v| (n.dot(*v) - offset).abs() <= 1e-3); let plane = flat.then_some(DepthPlane { normal: [n.0, n.1, n.2], offset, center: self.camera.origin, eye: self.camera.eye }); #[cfg(test)] - let view = contours.clone(); + let view = contours.to_vec(); let contours: Vec> = contours.iter().map(|c| c.iter().map(|v| self.camera.project(*v)).collect()).collect(); if contours.iter().flatten().any(|p| !p.x.is_finite() || !p.y.is_finite()) { return Err("The 3D projection is not finite".into()); @@ -262,7 +309,7 @@ impl Builder<'_> { /// Give the facet that [`Builder::face`] added, when it added one after `before` facets, the /// outline `outline` makes from its projected contours. pub(super) fn outline(&mut self, before: usize, outline: impl FnOnce(&[Vec]) -> Option) { - if self.mesh.is_some() || self.facets.len() <= before { + if !self.draw || self.facets.len() <= before { return; } if let Some(f) = self.facets.last_mut() { @@ -273,7 +320,7 @@ impl Builder<'_> { /// Mark the facet that [`Builder::face`] added, when it added one after `before` facets, as the /// planar end `end` of the solid. pub(super) fn end(&mut self, before: usize, end: PlanarFace) { - if self.mesh.is_some() || self.facets.len() <= before { + if !self.draw || self.facets.len() <= before { return; } if let Some(f) = self.facets.last_mut() { @@ -281,15 +328,19 @@ impl Builder<'_> { } } - /// How many faces the builder holds: facets, or faces kept for a mesh. + /// How many faces the builder holds: facets, or without them the faces kept for a mesh. pub(super) fn len(&self) -> usize { - self.mesh.as_ref().map_or(self.facets.len(), Vec::len) + match &self.mesh { + Some(mesh) if !self.draw => mesh.len(), + _ => self.facets.len(), + } } /// The facets back to front, and the faces kept for a mesh (none without [`Builder::mesh`]). pub(super) fn finish(mut self) -> Built { self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth)); - (self.facets, self.mesh.unwrap_or_default(), Some(self.camera.projection())) + let faces = if self.lost { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(self.mesh.unwrap_or_default()) }; + (self.facets, faces, Some(self.camera.projection())) } } @@ -536,16 +587,18 @@ fn steps(o: &Extrude) -> Vec { /// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels /// and caps. pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { - extrude_faces(path, rule, bounds, options, view, false).map(|(facets, ..)| facets) + extrude_faces(path, rule, bounds, options, view, Keep::Facets).map(|(facets, ..)| facets) } -/// [`extrude`], or with `mesh` the solid's faces before projection instead of its facets. -pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, mesh: bool) -> Result { +/// [`extrude`], keeping the solid's facets, its faces before projection, or both, as `keep` asks. +pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, keep: Keep) -> Result { + #[cfg(test)] + EXTRUDED.with(|n| n.set(n.get() + 1)); check_bounds(bounds)?; let o = options.sanitized(); // No depth: the flat shape, seen from either side. if o.depth <= 0.0 { - return plane_faces(path, rule, bounds, view, mesh); + return plane_faces(path, rule, bounds, view, keep); } let steps = steps(&o); // One face for each edge of the outline in each band between two steps, and the two caps: @@ -561,7 +614,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio return Err(TOO_COMPLEX_SURFACE.into()); } let camera = Camera::new(view, bounds, o.depth); - let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder::new(&camera, o.cap, o.hidden, keep); let z = |t: f64| o.depth / 2.0 - o.depth * t; let ring_at = |ring: &[Point], s: &Step| -> Vec { section(&inset(ring, s.inset), bounds, &o, s.t).iter().map(|p| camera.place(*p, z(s.t))).collect() }; @@ -647,15 +700,15 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio /// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the /// shape's own, so that every part of one object turns about the same point). pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { - plane_faces(path, rule, bounds, view, false).map(|(facets, ..)| facets) + plane_faces(path, rule, bounds, view, Keep::Facets).map(|(facets, ..)| facets) } -/// [`plane`], or with `mesh` its face before projection instead of its facet. -pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result { +/// [`plane`], keeping its facet, its face before projection, or both, as `keep` asks. +pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, keep: Keep) -> Result { check_bounds(bounds)?; let rings = solid_rings_traced(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS, CURVE_PIECES)?; let camera = Camera::new(view, bounds, 0.0); - let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder::new(&camera, false, false, keep); let contours: Vec> = rings.iter().map(|r| r.points.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index d3773a92f9..677e9cc8bc 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,4 +1,4 @@ -use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; +use super::build::{Builder, Built, Camera, Keep, Order, Turn, check_bounds, cuts, fit, newell, outward}; use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, crossings, filled, material}; use super::rings::{MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, signed_area, solid_rings}; use super::{Facet, FillRule, Inflate, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, V3, Vec2, View}; @@ -73,26 +73,26 @@ pub fn inflate_each( options: Inflate, view: View, ) -> impl Iterator, String>> + use<> { - inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets)) + inflate_faces(shapes, bounds, options, view, Keep::Facets).map(|r| r.map(|(facets, ..)| facets)) } -/// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets. -/// Every shape's membrane is solved at once (the eye depends on the highest puff); each solid is -/// built as the iterator reaches it. +/// [`inflate_together`], keeping each solid's facets, its faces before projection, or both, as +/// `keep` asks. Every shape's membrane is solved at once (the eye depends on the highest puff); +/// each solid is built as the iterator reaches it. pub(crate) fn inflate_faces( shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View, - mesh: bool, + keep: Keep, ) -> impl Iterator> + use<> { let o = options.sanitized(); let puffs: Vec, String>> = shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max); puffs.into_iter().map(move |p| match p { - Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh), - Ok(None) => Ok((vec![], vec![], None)), + Ok(Some(p)) => inflated(p, bounds, o, view, reach, keep), + Ok(None) => Ok((vec![], Ok(vec![]), None)), Err(e) => Err(e), }) } @@ -106,9 +106,9 @@ thread_local! { } /// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the -/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. With `mesh`, -/// its faces before projection instead. -fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: bool) -> Result { +/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. Its faces +/// before projection instead, or both, as `keep` asks. +fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, keep: Keep) -> Result { #[cfg(test)] INFLATED.with(|n| n.set(n.get() + 1)); let Puff { rings, edges, cell, nx, ny, m, height } = p; @@ -149,7 +149,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo let smooth = view.shade && view.has_colored_lights(); let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach)); // Without its back cap the object is a shell open behind, its inside showing. - let mut b = Builder { camera: &camera, closed: o.cap, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder::new(&camera, o.cap, false, keep); let half = o.depth / 2.0; // The back: turned and scaled by the twist and taper, its puff too. let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0); @@ -375,15 +375,40 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo let turn = Turn::new(bounds, o.twist, o.taper, t); ring.iter().map(|p| camera.place(turn.apply(*p), half - o.depth * t)).collect() }; - let mut pieces: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); + let cut: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); + let mut pieces = cut.clone(); fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.len())); let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); if b.len().saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES { return Err(TOO_COMPLEX_SURFACE.into()); } - for (ring, pieces) in rings.iter().zip(&pieces) { - for k in 0..slices { - b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; + let sides = |b: &mut Builder<'_>, pieces: &[Vec]| -> Result<(), String> { + for (ring, pieces) in rings.iter().zip(pieces) { + for k in 0..slices { + b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; + } + } + Ok(()) + }; + // A mesh kept with the facets has the sides a mesh alone has: limited, and with a twist + // cut, by the faces the mesh holds, which can be more than the facets. + let mut own_sides = None; + if let Some(kept) = b.faces_kept() { + let mut own = cut; + fit(&mut own, slices, MAX_FACES.saturating_sub(kept)); + if kept.saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES { + b.lose_mesh(); + } else if own != pieces { + own_sides = Some((own, b.mesh.take())); + } + } + sides(&mut b, &pieces)?; + if let Some((own, faces)) = own_sides { + let mut m = Builder::new(&camera, o.cap, false, Keep::Faces); + m.mesh = faces; + match sides(&mut m, &own) { + Ok(()) => b.mesh = m.mesh, + Err(_) => b.lose_mesh(), } } } diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index e89a29afea..335f434419 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -225,12 +225,12 @@ fn a_flat_facet_measures_its_depth_on_its_plane() { /// eye, has no plane: its drawing no longer matches the plane's projection there. #[test] fn a_face_reaching_past_the_eye_has_no_plane() { - use super::build::{Builder, Order}; + use super::build::{Builder, Keep, Order}; let (_, b) = shape(&square(0.0, 0.0, 100.0)); let camera = Camera::new(View { perspective: 50.0, ..front() }, b, 0.0); let eye = camera.center_and_eye().1.unwrap(); for (far, has_plane) in [(eye / 2.0, true), (2.0 * eye, false)] { - let mut builder = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: None }; + let mut builder = Builder::new(&camera, false, false, Keep::Facets); let corners = vec![V3(-10.0, -10.0, 0.0), V3(10.0, -10.0, 0.0), V3(0.0, 10.0, far)]; builder.face(vec![corners], V3(0.0, -far, 20.0), Order::Depth).unwrap(); assert_eq!(builder.facets.len(), 1, "{far}"); diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index d606f630d5..c870110434 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -55,6 +55,42 @@ fn selection_tool_with_selection(app: &VectorcraftApp) -> bool { && app.session.active().is_some_and(|st| !st.selection.is_empty()) } +/// Whether the bar shows its blank canvas actions, Draw (Pencil) and Place Image: a selection tool is +/// in use and the active document has no artwork, only layers and guides. +fn blank_canvas(app: &VectorcraftApp) -> bool { + fn no_art(nodes: &[std::sync::Arc]) -> bool { + nodes.iter().all(|n| n.is_guide() || (n.is_layer() && n.children().is_none_or(|c| no_art(c)))) + } + matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") + && app.session.active().is_some_and(|st| st.selection.is_empty() && no_art(&st.doc.layers)) +} + +/// Where the bar's top edge centers, on screen: under the lowest corner on screen of the selected +/// artboards or the selection, centered on them (the view may be rotated); on a blank canvas, which +/// has no selection, [`blank_canvas_anchor`]. None with nothing to sit under. +fn bar_anchor(app: &mut VectorcraftApp, xf: &Xf, on_artboards: bool, blank: bool) -> Option { + if blank { + return Some(blank_canvas_anchor(app, xf)); + } + let b = if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }?; + let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); + Some(pos2(xf.to_screen(b.center()).x, lowest)) +} + +/// Where the blank canvas bar's top edge centers, on screen: under the bottom of the active artboard +/// when part of it is in view, else the canvas's center. +fn blank_canvas_anchor(app: &VectorcraftApp, xf: &Xf) -> Pos2 { + let board = app.session.active().and_then(|st| { + let last = st.doc.artboards.len().checked_sub(1)?; + st.doc.artboards.get(app.view().map_or(0, |v| v.artboard).min(last)).map(|a| a.rect) + }); + let shown = board.map(|b| (b, xf.quad(b))).filter(|(_, q)| egui::Rect::from_points(q).intersects(xf.rect)); + match shown { + Some((b, q)) => pos2(xf.to_screen(b.center()).x, q.iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max)), + None => xf.rect.center(), + } +} + /// Whether a Type tool edits type, or has type selected: its bar then shows only the text /// formatting controls. fn type_tool_on_type(app: &VectorcraftApp) -> bool { @@ -75,6 +111,8 @@ pub(crate) fn task_bar_shows_type(app: &VectorcraftApp) -> bool { /// While a Type tool edits type, or has type selected, the bar shows only those controls and the /// Fill. When one row would be wider than the canvas, the font controls take a row of their own, /// narrowed to fit it, and the actions wrap onto rows under the handle's ([`bar_layout`]). +/// On a blank canvas, a document with no artwork and a selection tool in use, it sits under the +/// active artboard and offers Draw (Pencil) and Place Image. /// Its handle drags it anywhere on the canvas; unpinned it keeps that offset as it follows the /// selection, pinned it stays put ([`crate::state::TaskBarPlace`]). pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { @@ -83,13 +121,15 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { // Text added with a Type tool shows its formatting controls, as the help page describes // (). let typing = type_tool_on_type(app); - if !(on_artboards || typing || selection_tool_with_selection(app)) { + // A blank canvas offers Draw (Pencil) and Place Image, as the same help page describes. + let blank = !on_artboards && blank_canvas(app); + if !(on_artboards || typing || blank || selection_tool_with_selection(app)) { return; } // Selected type has the font controls too, as the help page describes // (). let fonts = task_bar_shows_type(app); - let Some(b) = (if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }) else { return }; + let Some(anchor) = bar_anchor(app, xf, on_artboards, blank) else { return }; let Some(st) = app.session.active() else { return }; let n = st.selection.len(); let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)); @@ -137,6 +177,10 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none())); } items.push((tl!("Export"), "share-2", "file.exportForScreens", none())); + } else if blank { + // Draw (Pencil) switches to the Pencil tool; Place Image picks files for the Place dialog. + items.push((tl!("Draw (Pencil)"), "pencil", "tool.select", json!({"tool": "pencil"}))); + items.push((tl!("Place Image"), "image", "file.place", none())); } else if !typing { // The selection tools' object actions. A Type tool's bar leaves them out, Create Outlines // and the type conversions included. @@ -234,27 +278,20 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { let paints = chip_paints(app, ui.ctx()); let colored = has_colors(&st.doc, &st.selection.objects); let doc = st.uid; - // Under the selection's lowest corner on screen, centered on the selection (the view may be - // rotated). - let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); - let anchor = pos2(xf.to_screen(b.center()).x, lowest); let bounds = xf.rect.shrink(8.0); // The canvas's width less the frame's margins and stroke. let room = bounds.width() - BAR_FRAME; // On a canvas too narrow for the type controls at their narrowest, the bar leaves them out. let fonts = fonts && room >= crate::panels::character::task_bar_min_room(); // The selected objects' Fill, Stroke, Recolor Artwork (when they have colours to recolour) and - // Lock (a basic shape's properties instead), left out with the Artboard tool. A Type tool's bar - // has the Fill alone. - let chips: Vec = [ - (!on_artboards, Chip::Fill), - (!on_artboards && !typing, Chip::Stroke), - (!on_artboards && !typing && colored, Chip::Recolor), - (!on_artboards && !typing, Chip::Lock), - ] - .into_iter() - .filter_map(|(shown, chip)| shown.then_some(chip)) - .collect(); + // Lock (a basic shape's properties instead), left out with the Artboard tool and on a blank + // canvas, which have no selected objects. A Type tool's bar has the Fill alone. + let objects = !on_artboards && !blank; + let chips: Vec = + [(objects, Chip::Fill), (objects && !typing, Chip::Stroke), (objects && !typing && colored, Chip::Recolor), (objects && !typing, Chip::Lock)] + .into_iter() + .filter_map(|(shown, chip)| shown.then_some(chip)) + .collect(); let widths: Vec = items.iter().map(|(label, ..)| action_width(ui, label)).collect(); let font_w = fonts.then(|| { let w = crate::panels::character::TASK_BAR_WIDTHS; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 2a7162f9a1..4d609f2b5e 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -10333,7 +10333,7 @@ fn task_bar_labels(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String /// With the Artboard tool, the Contextual Task Bar sits under the selected artboards and offers /// Resize, Duplicate, Rearrange All (with two or more artboards) and Export instead of the /// selected objects' actions. With the Selection tool it offers the objects' actions again, and -/// with nothing selected it doesn't show. +/// with nothing in the document the blank canvas actions. #[test] fn the_task_bar_offers_the_artboard_tools_actions() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -10341,7 +10341,7 @@ fn the_task_bar_offers_the_artboard_tools_actions() { app.select_tool("selection"); let ctx = egui::Context::default(); let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::>(); - assert!(names(&mut app).is_empty() && task_bar_rect(&ctx).is_none(), "nothing selected: no bar"); + assert_eq!(names(&mut app), ["Draw (Pencil)", "Place Image"], "a blank canvas"); app.select_tool("artboard"); crate::panels::artboards::select(&mut app, 1); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 4b5982cecd..f0ab11fc4a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -691,6 +691,8 @@ font-technology Variable Променливи Crop Image Изрязване на изображение Image Tools Инструменти за изображения Remove Background… Премахване на фона… + Draw (Pencil) Рисуване (Молив) + Place Image Вмъкване на изображение Remove Background Премахване на фона Background Фон Edge Color Цвят на ръба diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 1576b72373..f73b5a407f 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -201,6 +201,8 @@ Crop Image Izreži sliku Image Tools Alati za slike Remove Background… Ukloni pozadinu… + Draw (Pencil) Crtaj (olovka) + Place Image Postavi sliku Remove Background Ukloni pozadinu Background Pozadina Edge Color Boja ruba diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 99ecf7dd95..ddfed44ad0 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1006,6 +1006,8 @@ antiAlias Sharp Nítid Crop Image Retalla la imatge Image Tools Eines d’imatge Remove Background… Elimina el fons… + Draw (Pencil) Dibuixa (llapis) + Place Image Col·loca una imatge Remove Background Elimina el fons Background Fons Edge Color Color de la vora diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d58e5bd051..3b46d0600a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -207,6 +207,8 @@ Crop Image Oříznout obraz Image Tools Nástroje obrazu Remove Background… Odstranit pozadí… + Draw (Pencil) Kreslit (tužka) + Place Image Umístit obraz Remove Background Odstranit pozadí Background Pozadí Edge Color Barva okraje diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 5fad269299..81b6195bdd 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -194,6 +194,8 @@ Crop Image Beskær billede Image Tools Billedværktøjer Remove Background… Fjern baggrund… + Draw (Pencil) Tegn (blyant) + Place Image Placér billede Remove Background Fjern baggrund Background Baggrund Edge Color Kantfarve diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0143f55457..8f6bc1aeeb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -684,6 +684,8 @@ font-technology Variable Variabel Crop Image Bild beschneiden Image Tools Bildwerkzeuge Remove Background… Hintergrund entfernen… + Draw (Pencil) Zeichnen (Buntstift) + Place Image Bild platzieren Remove Background Hintergrund entfernen Background Hintergrund Edge Color Randfarbe diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 172e91d0ef..7b61589c18 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -665,6 +665,8 @@ font-technology Variable Μεταβλητές Crop Image Περικοπή εικόνας Image Tools Εργαλεία εικόνας Remove Background… Αφαίρεση φόντου… + Draw (Pencil) Σχεδίαση (μολύβι) + Place Image Τοποθέτηση εικόνας Remove Background Αφαίρεση φόντου Background Φόντο Edge Color Χρώμα άκρης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 865b2b532f..89ea2c088c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -705,6 +705,8 @@ font-technology Variable Variables Crop Image Recortar imagen Image Tools Herramientas de imagen Remove Background… Quitar fondo… + Draw (Pencil) Dibujar (Lápiz) + Place Image Colocar imagen Remove Background Quitar fondo Background Fondo Edge Color Color del borde diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 88581f6ace..655936f6c2 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -214,6 +214,8 @@ Crop Image Rajaa kuva Image Tools Kuvatyökalut Remove Background… Poista tausta… + Draw (Pencil) Piirrä (lyijykynä) + Place Image Sijoita kuva Remove Background Poista tausta Background Tausta Edge Color Reunan väri diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0063c984a1..07209a70dd 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -707,6 +707,8 @@ font-technology Variable Variables Crop Image Recadrer l’image Image Tools Outils d’image Remove Background… Supprimer l’arrière-plan… + Draw (Pencil) Dessiner (Crayon) + Place Image Importer une image Remove Background Supprimer l’arrière-plan Background Arrière-plan Edge Color Couleur du bord diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index a31993c84a..4a11fad73d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1140,6 +1140,8 @@ antiAlias Sharp शार्प Crop Image इमेज क्रॉप करें Image Tools इमेज टूल Remove Background… पृष्ठभूमि हटाएँ… + Draw (Pencil) ड्रा (पेंसिल) + Place Image इमेज प्लेस करें Remove Background पृष्ठभूमि हटाएँ Background पृष्ठभूमि Edge Color किनारे का रंग diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7b5d4eb4ae..925670bbc3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -906,6 +906,8 @@ font-technology Variable Változó Crop Image Kép körülvágása Image Tools Képeszközök Remove Background… Háttér eltávolítása… + Draw (Pencil) Rajzolás (Ceruza) + Place Image Kép elhelyezése Remove Background Háttér eltávolítása Background Háttér Edge Color Szélszín diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e27253376e..f8069f6a71 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -194,6 +194,8 @@ Crop Image Pangkas gambar Image Tools Alat Gambar Remove Background… Hapus latar belakang… + Draw (Pencil) Menggambar (pensil) + Place Image Tempatkan gambar Remove Background Hapus latar belakang Background Latar belakang Edge Color Warna tepi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7d74485aac..af875b4e42 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -705,6 +705,8 @@ font-technology Variable Variabili Crop Image Ritaglia immagine Image Tools Strumenti immagine Remove Background… Rimuovi sfondo… + Draw (Pencil) Disegna (Matita) + Place Image Inserisci immagine Remove Background Rimuovi sfondo Background Sfondo Edge Color Colore del bordo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 31526809eb..e6d40c9ae5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -242,6 +242,8 @@ digits Malayalam マラヤーラム数字 Crop Image 画像の切り抜き Image Tools 画像ツール Remove Background… 背景を削除… + Draw (Pencil) 描画 (鉛筆) + Place Image 画像を配置 Remove Background 背景を削除 Background 背景 Edge Color 端の色 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a6429f06ee..fc649cd87a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -166,6 +166,8 @@ Crop Image გამოსახულების ჩამოჭრა Image Tools გამოსახულების ხელსაწყოები Remove Background… ფონის მოშორება… + Draw (Pencil) ხატვა (ფანქარი) + Place Image გამოსახულების განთავსება Remove Background ფონის მოშორება Background ფონი Edge Color კიდის ფერი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7141602a3d..71a45e307d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -235,6 +235,8 @@ digits Malayalam 말라얄람어 Crop Image 이미지 자르기 Image Tools 이미지 도구 Remove Background… 배경 제거… + Draw (Pencil) 그리기(연필) + Place Image 이미지 가져오기 Remove Background 배경 제거 Background 배경 Edge Color 가장자리 색상 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 476f707d22..a0953094fa 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -712,6 +712,8 @@ Crop Image Afbeelding bijsnijden Image Tools Afbeeldingshulpmiddelen Remove Background… Achtergrond verwijderen… + Draw (Pencil) Tekenen (Potlood) + Place Image Afbeelding plaatsen Remove Background Achtergrond verwijderen Background Achtergrond Edge Color Randkleur diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index db2f6449c6..5b7b3e60ac 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -171,6 +171,8 @@ Crop Image Kadruj obraz Image Tools Narzędzia obrazu Remove Background… Usuń tło… + Draw (Pencil) Rysuj (ołówek) + Place Image Umieść obraz Remove Background Usuń tło Background Tło Edge Color Kolor krawędzi diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2d5047337a..90825f967f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -631,6 +631,8 @@ antiAlias Sharp Nítido Crop Image Recortar Imagem Image Tools Ferramentas de imagem Remove Background… Remover Fundo… + Draw (Pencil) Desenhar (Lápis) + Place Image Colocar Imagem Remove Background Remover Fundo Background Fundo Edge Color Cor da Borda diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f4220735a4..a235e2c9b3 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -703,6 +703,8 @@ font-technology Variable Вариативные Crop Image Обрезать изображение Image Tools Инструменты изображения Remove Background… Удалить фон… + Draw (Pencil) Рисовать (Карандаш) + Place Image Поместить изображение Remove Background Удалить фон Background Фон Edge Color Цвет края diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 1649d0140e..b491d33649 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -693,6 +693,8 @@ font-technology Variable Variabla Crop Image Beskär bild Image Tools Bildverktyg Remove Background… Ta bort bakgrund… + Draw (Pencil) Rita (blyertspenna) + Place Image Placera bild Remove Background Ta bort bakgrund Background Bakgrund Edge Color Kantfärg diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0fcf935e7e..0ecfe1dd0a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -685,6 +685,8 @@ font-technology Variable Değişken Crop Image Resmi Kırp Image Tools Resim Araçları Remove Background… Arka Planı Kaldır… + Draw (Pencil) Çiz (Kurşun Kalem) + Place Image Resim Yerleştir Remove Background Arka Planı Kaldır Background Arka Plan Edge Color Kenar Rengi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 168bd4ac84..c375bedfa2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -699,6 +699,8 @@ font-technology Variable Варіативні Crop Image Обрізати зображення Image Tools Інструменти зображення Remove Background… Видалити фон… + Draw (Pencil) Малювати (Олівець) + Place Image Помістити зображення Remove Background Видалити фон Background Фон Edge Color Колір краю diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bfc6b7876a..442690385b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -806,6 +806,8 @@ font-technology Variable Biến thiên Crop Image Cắt xén ảnh Image Tools Công cụ hình ảnh Remove Background… Xóa nền… + Draw (Pencil) Vẽ (Bút chì) + Place Image Đặt hình ảnh Remove Background Xóa nền Background Nền Edge Color Màu cạnh diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0d760c21ed..810b1b4463 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -650,6 +650,8 @@ antiAlias Sharp 锐利 Crop Image 裁剪图像 Image Tools 图像工具 Remove Background… 移除背景… + Draw (Pencil) 绘制 (铅笔) + Place Image 置入图像 Remove Background 移除背景 Background 背景 Edge Color 边缘颜色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 899520f58e..19c572e3b3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -667,6 +667,8 @@ font-technology Variable 可變 Crop Image 裁切影像 Image Tools 影像工具 Remove Background… 移除背景… + Draw (Pencil) 繪製 (鉛筆) + Place Image 置入影像 Remove Background 移除背景 Background 背景 Edge Color 邊緣顏色 diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index 9768813a70..30757d3f52 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -536,6 +536,103 @@ fn the_task_bar_follows_the_active_document() { assert!(!locked(&app, rect), "the button doesn't lock the shape"); } +/// A blank canvas, a new document with a selection tool in use, offers Draw (Pencil) and Place +/// Image centered under the active artboard (under the canvas's center when the artboard is out of +/// view), with the handle and More Options but none of a selection's actions or its Fill, Stroke, +/// Recolor Artwork and Lock. Other tools, artwork in the document and a selection each leave the +/// blank canvas actions out; ruler guides do not count as artwork. +#[test] +fn a_blank_canvas_offers_draw_and_place_image() { + let mut app = app(); + let board = app.session.active().unwrap().doc.artboards[0].rect; + // The artboard at 50% in the middle of the window, after the first frame fits it in. + bar(&mut app); + let view = app.view_mut().unwrap(); + (view.center, view.zoom) = (board.center(), 0.5); + for tool in ["selection", "directSelection", "groupSelection"] { + app.run("tool.select", json!({ "tool": tool })).unwrap(); + let (ctx, texts) = bar_texts(&mut app); + assert!(at(&texts, "Draw (Pencil)").x < at(&texts, "Place Image").x, "{tool}: {texts:?}"); + let shown = canvas::task_bar_rect(&ctx).unwrap(); + let xf = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let bottom = xf.quad(board).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); + let middle = xf.to_screen(board.center()).x; + assert!(shown.top() > bottom && (shown.center().x - middle).abs() < 2.0, "{tool}: {shown:?} centered under {board:?}"); + assert!(app.canvas_rect.unwrap().contains_rect(shown), "{tool}: {shown:?} on the canvas"); + } + // The artboard out of view: under the canvas's center. + app.view_mut().unwrap().center = board.center() + vectorcraft_geom::Vec2::new(5000.0, 5000.0); + let (ctx, _) = bar(&mut app); + let (shown, middle) = (canvas::task_bar_rect(&ctx).unwrap(), app.canvas_rect.unwrap().center()); + assert!((shown.center().x - middle.x).abs() < 2.0 && shown.top() > middle.y && shown.top() < middle.y + 60.0, "{shown:?} under {middle:?}"); + app.view_mut().unwrap().center = board.center(); + + let tree = crate::widgets::accessibility_tests::tree(|ui| canvas::show(&mut app, ui)); + for label in ["Draw (Pencil)", "Place Image", "More Options"] { + assert_eq!(crate::widgets::accessibility_tests::named(&tree, label).role(), egui::accesskit::Role::Button, "{label}"); + } + let named = |label: &str| tree.iter().any(|n| n.label().is_some_and(|l| l.starts_with(label))); + for label in ["Fill", "Stroke", "Recolor Artwork", "Lock", "Duplicate"] { + assert!(!named(label), "{label} is left out"); + } + + for tool in ["pen", "rectangle", "type", "artboard"] { + app.run("tool.select", json!({ "tool": tool })).unwrap(); + let (_, texts) = bar_texts(&mut app); + assert!(!has(&texts, "Draw (Pencil)") && !has(&texts, "Place Image"), "{tool}: {texts:?}"); + } + + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let rect = id(app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap()); + select(&mut app, &[rect]); + let (_, texts) = bar_texts(&mut app); + assert!(has(&texts, "Duplicate") && !has(&texts, "Draw (Pencil)"), "{texts:?}"); + select(&mut app, &[]); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "artwork with nothing selected: no bar"); + select(&mut app, &[rect]); + app.run("edit.clear", json!({})).unwrap(); + app.run("guide.add", json!({"pos": 50})).unwrap(); + assert!(has(&bar_texts(&mut app).1, "Draw (Pencil)"), "a ruler guide is not artwork"); +} + +/// Draw (Pencil) switches to the Pencil tool. Place Image picks files for the Place dialog; +/// canceling the pick leaves the document as it was. +#[test] +fn the_blank_canvas_bar_draws_with_the_pencil_and_places_an_image() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + click(&mut app, "Draw (Pencil)"); + assert_eq!(app.session.tool_id(), "pencil"); + + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + app.services.pick_open = Some(Box::new(|_: &crate::FilePick| None)); + click(&mut app, "Place Image"); + assert!(app.ui.dialog.is_none(), "canceled: no Place dialog"); + assert!(app.session.active().unwrap().doc.layers.iter().all(|l| l.children().is_none_or(|c| c.is_empty())), "nothing placed"); + + let path = std::env::temp_dir().join("vectorcraft-task-bar-place.png").to_string_lossy().into_owned(); + let picked = path.clone(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + click(&mut app, "Place Image"); + let d = app.ui.dialog.take().expect("the Place dialog"); + assert_eq!((d.kind.as_str(), &d.fields["files"]), (crate::dialogs::place::KIND, &json!([{ "path": path }]))); +} + +/// On a canvas too narrow for one row, the blank canvas bar's actions wrap onto a second row and the +/// bar stays whole on the canvas. +#[test] +fn the_blank_canvas_bar_wraps_on_a_narrow_canvas() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (texts, r, canvas) = bar_texts_on_screen(&mut app, &ctx, vec2(240.0, 400.0), 3); + assert!(canvas.contains_rect(r), "{r:?} on {canvas:?}"); + let (draw, place) = (at(&texts, "Draw (Pencil)"), at(&texts, "Place Image")); + assert!(place.y > draw.y + 20.0, "Place Image wraps under Draw (Pencil): {draw:?} {place:?}"); +} + /// The Type tools whose bar shows the font and Fill controls. const TYPE_TOOLS: [&str; 6] = ["type", "areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath"]; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fe1dbab127..7684f58942 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -116,15 +116,16 @@ private editing data. VectorCraft: bytes and past 4,096 styles are left out with a warning. A panel with no style written opens with a new document's styles, with a warning. No art style is written on a large canvas. The Brushes panel's Calligraphic brushes, and its Scatter and Art brushes with their art, are written in its order: Scatter and Art brushes in Tints or keeping their - art's colors, Art brushes that stretch their art along the path from left to right at 100% unflipped without - overlap, and art of paths and compound paths with points and groups with members, with one solid fill and one - plain stroke each. Other brushes, + art's colors, Art brushes that run their art from left to right without overlap at a fixed width, with their + width, flips and scale (Stretch Between Guides with guides that leave art between them), and art of paths and + compound paths with points and groups with members, with one solid fill and one plain stroke each. Other brushes, Pattern and Bristle brushes among them, and brushes past the number or the art VectorCraft's reader reads are left out with a warning; no brush is written on a large canvas. A path or compound path with a Calligraphic brush, or - with an Art brush in Tints whose art bends along the path, keeps the brush on its stroke; other brushed strokes are + with an Art brush whose art bends along the path and that doesn't scale its art proportionately, keeps the brush + on its stroke; other brushed strokes are written as their brush's art. A document that uses a new document's brushes writes none until a path keeps one on - its stroke; then the brushes are written, and the file opens without the new document's Pattern and Bristle brushes - and its Art brush with other options. + its stroke; then the brushes are written, and the file opens without the new document's Pattern and Bristle + brushes. Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, @@ -264,7 +265,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -The app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile on a Stroke Offset stroke or written as each side's widths) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way when nothing in the object is stroked: up to 180° either way (a limit VectorCraft sets) on a path or compound path whose segments are all straight, except a compound path with a guide member that reaches past its other members at an angle other than 0°, and up to 90° either way (a limit VectorCraft sets) on another path, compound path or group with no guide in it. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a variable-width stroke with one centered width array, dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +The app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile on a Stroke Offset stroke or written as each side's widths) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [layered image file format specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way when nothing in the object is stroked: up to 180° either way (a limit VectorCraft sets) on a path or compound path whose segments are all straight, except a compound path with a guide member that reaches past its other members at an angle other than 0°, and up to 90° either way (a limit VectorCraft sets) on another path, compound path or group with no guide in it. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a variable-width stroke with one centered width array, dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush is in Tints or None, at a fixed width from 1% to 1,000%, stretched to the path or stretched between guides that leave art between them, flipped along or across or not ([Art brush options](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf), printed page 193), with `Spacing` 0, `Adjustment` 1 and `FoldAdjustment` 1 (VectorCraft's own reading of these numbers; a brush that scales its art proportionately comes into the Brushes panel with that option, and its paths keep their drawn look); the path's settings are the brush's own (apart from the lowest and highest width of a fixed width and the guides' lengths without Stretch Between Guides, which don't draw), with no entry that isn't read, no variable width and no effects; a stroke in Tints isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an EPS with editing data from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so live objects that only the editing data holds arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (the PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. A run of type in the editing data whose baseline direction is 1 opens with Standard Vertical Roman Alignment (https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) on, which is VectorCraft's own mapping: its half-width characters stand upright in vertical type, and horizontal type keeps the mark with no change to its layout. A run whose baseline direction is 2 or 3 (Tate-chu-yoko), or that names none in the run, its character style or the document's defaults, opens with it off. The scripting reference lists 1, 2 and 3 as the Standard, vertical rotated and Tate-chu-yoko baseline directions (https://web.archive.org/web/20170621180804/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20VBScript%20Reference.pdf, page 256). A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. diff --git a/docs/gaps.md b/docs/gaps.md index 2d2a394bdd..8aea143a47 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -213,10 +213,12 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi weight, where the object's brush settings are the brush's own and its angle is on the page (#372, from a user's file of 6D Art Pen strokes: all 455 of its brushed styles), and a Calligraphic brush's Tilt, Bearing and Rotation (codes 4, 5 and 6) come in as those channels instead of Random. Others keep their drawn look. - Paths with an Art brush open the same way when the brush stretches art of solid-filled paths along the path, - unflipped, at 100% in Tints, and the path has none of its own settings, variable width, effects or a swatch - stroke. Other Art brushes (None or another colorization, another width, Proportional, between guides, flipped, - art with strokes, gradients or patterns) and type with an Art brush keep the drawn look. + Paths with an Art brush open the same way when the brush bends art of solid-filled paths along the path, and + the path has none of its own settings, variable width, effects or a swatch stroke in Tints. The brush may be + in None or Tints, at a fixed width, stretched to the path or between guides, and flipped along or across. + Other Art brushes (Tints and Shades or Hue Shift, a width that follows the pen, Scale Proportionately, guides + that leave no art between them, a `Spacing`, `Adjustment` or `FoldAdjustment` other than 0, 1 and 1, art with + strokes, gradients or patterns) and type with an Art brush keep the drawn look. - **Done (2026-11-12):** Explicitly requested editing data retains a single artboard larger than a capped 14,400 pt PDF page (#6865); the default import and other size mismatches remain unchanged. Synthetic importer regression; scores unchanged. - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead diff --git a/docs/mcp.md b/docs/mcp.md index 7e54356ae8..283990618f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -835,9 +835,13 @@ them sets what the graph stores. While Global Edit is on, a path directly in a s match (a graph's paths are matches only while the graph is isolated) has what changed on it stored for its series: the paint, weight, opacity and blend mode of the topmost visible fill and stroke, and the object's opacity and blend mode. `addon.arrange.randomizeOpacity` and `addon.arrange.sort` with `apply: "opacity"` store the object opacity, with the -blend mode, of the frontmost path of a series whose opacity they change. A gradient is stored with its placement, and -every path of the series takes that placement when the graph is drawn again. The graph does not store a stroke's -dashes, caps, joins, arrowheads, width profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again +blend mode, of the frontmost path of a series whose opacity they change. A gradient is stored with its placement relative +to the box of the path it was stored from (a stroke's box is the path's bounds grown by half the weight), and every path +of the series, the legend swatch included, takes it at the same place on its own box when the graph is drawn again, also +after the data changes or the graph moves. The graph saves this as `fillUnitBox` and `strokeUnitBox` in its series +paints. A gradient stored from a path with no width or no height is drawn at the place in the document where it was +stored. So is a gradient a graph stored without these flags. The graph does not store a stroke's dashes, caps, joins, +arrowheads, width profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again with the paint they had. `text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes @@ -1800,6 +1804,13 @@ commands fail and change nothing, and `appearance.removeItem` removes nothing. A the item last. A text `start` or `end` is past the end of the text. `object.slice.divide` refuses such a `rows` or `columns`, `recolor.reduce` keeps each color in its own row for such a `colors`, and `layer.setProps` refuses such a `color`. `object.setBounds` reads a `reference` past 8 as 8, as `object.transformEach` does. +An index or place given as a value that is not a whole number of 0 or more, such as `"3"`, `2.5`, `-1` or `true`, is +refused with `` `anchor` must be a whole number ``, naming the key: `anchor` of `path.setHandle`, `path.moveAnchor` and +`text.setPathHandle`, `segment` of `path.insertAnchor`, `to` of `artboard.reorder`, `library.moveGroup` and +`graphicStyle.move`, `subpath` of `path.appendAnchor`, `path.close` and `path.reverse`, and `artboard` of +`document.rulerOrigin`, `document.setRulerOrigin` and `document.resetRulerOrigin`. A required one left out or null is +reported as missing (`missing anchor`). Left out or null, `subpath` takes the command's default and `artboard` is 0. +Nothing changes and no undo step is recorded. The blend, envelope and gradient mesh commands refuse a string or another value that is not a number with the same message, as in `` `steps` must be a number, not "8" ``, and change nothing: `steps`, `distance` and `value` in `object.blend.make` and `object.blend.options`; `bend`, `h`, `v`, `horizontalDistortion` and `verticalDistortion` in diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2b25fc59e3..54e1ed3c20 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -865,6 +865,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A click on another object moves the contextual task bar under it without hiding it for the press; it gives way only to a drag (#4681). - With one image selected, the contextual task bar offers Image Trace, which traces the image at once with the Default preset (the Image Trace panel is left as it was), and Crop Image; with an Image Trace object it offers Expand, which keeps only the traced paths. This follows Illustrator's bar: for a selected image it includes Image Trace, Retype and Crop Image, and traced artwork can be expanded from it ([Contextual Task Bar announcement](https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580)). Offset Path and Simplify, which don't apply to an image, aren't offered for one. Not done: Retype, which the app doesn't have. - With the Artboard tool, the contextual task bar sits under the selected artboards and offers Resize (Artboard Options for the active artboard), Duplicate (each selected artboard, in one undo step, the copies then selected), Rearrange All (with two or more artboards) and Export (Export for Screens with the selected artboards checked). This follows Illustrator's bar: while the Artboard tool is in use, it has options for resizing, duplicating and exporting artboards (the [Contextual Task Bar announcement](https://community.adobe.com/questions-652/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580); Rearrange and Export on the bar: [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/)). + - On a blank canvas the contextual task bar offers Draw (Pencil), which switches to the Pencil tool, and Place Image, which picks files for the Place dialog, as the help page lists for a new document (). VectorCraft's own rules: the bar shows them while a selection tool is in use in a document with no artwork (layers and guides only), sits under the active artboard (under the canvas's center when the artboard is out of view), and leaves out the Fill, Stroke, Recolor Artwork and Lock controls. Not done: Generate Vectors. Evidence: `a_blank_canvas_offers_draw_and_place_image`, `the_blank_canvas_bar_draws_with_the_pencil_and_places_an_image` and `the_blank_canvas_bar_wraps_on_a_narrow_canvas` in `crates/ui-egui/src/tests/taskbar.rs`. - The contextual task bar offers more next steps by selection (): an open path Join and Edit Path (the Direct Selection tool), a closed path Edit Path, a compound path Edit Path and Release Compound Path, a clipping group Edit Clipping Path and Release Clipping Mask instead of Ungroup and Isolate, an image selected with vector art Make Clipping Mask instead of Unite, point or area type Convert To Area Type or Convert To Point Type beside Create Outlines, and type on a path, for which the overview names an item to add an effect and rearrange the text, Type on a Path Options (`type.pathOptions`; its options: ) beside Create Outlines. Evidence: `crates/ui-egui/src/tests/taskbar.rs`. - While a Type tool (Type, Area Type, Type on a Path and their vertical versions) adds or edits type, the contextual task bar shows the font family menu, the font style menu, the Font Size combo and the Fill chip (); the Touch Type tool shows no bar, as the same page describes. Type selected with a selection tool shows the same three font controls beside its Create Outlines, conversion and Type on a Path items (). The controls act as the Character panel's do: on the characters a Type tool selects, else on every selected type object, with the size left blank where the sizes differ. VectorCraft's own rules: a Type tool's bar leaves out the object actions, the Stroke chip, Recolor Artwork and Lock; a Type tool shows no bar before it adds type or with other art selected; type selected with other art shows no font controls; the font controls go on a second row when one row would be wider than the canvas, and narrow to fit a canvas narrower than they are; while a Type tool edits type, the Fill chip shows the edited characters' fill ("?" where it differs). Evidence: `the_type_tools_show_font_and_fill_controls`, `the_type_tools_show_no_bar_without_type`, `selected_type_shows_font_controls_beside_its_actions`, `the_task_bars_font_size_sets_what_the_character_panel_would`, `the_type_tools_fill_chip_activates_the_fill`, `the_type_tools_bar_is_named_and_stays_on_the_canvas`, `selected_types_font_controls_take_a_second_row_on_a_narrow_canvas` and `the_type_tools_fill_chip_shows_the_edited_characters_fill` in `crates/ui-egui/src/tests/taskbar.rs`. - The help page describes the contextual task bar as one horizontal row () and gives no rule for a canvas narrower than that row. VectorCraft's own rule: the bar stays whole on the canvas. When its actions don't fit in one row, they wrap, in order, onto rows under the row with the handle, the chips and More Options; an action wider than any row, or one that would need a row past the canvas's height, is listed in More Options instead; when the chips don't fit beside the handle and More Options, they take rows after the actions; with no row to spare for the font controls, the bar leaves them out; a canvas too small for the handle and More Options shows no bar. Evidence: `a_task_bars_actions_wrap_on_a_narrow_canvas` and `a_task_bar_action_wider_than_the_canvas_goes_in_more_options` in `crates/ui-egui/src/tests/taskbar.rs`. @@ -1118,6 +1119,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `path.curvatureEdit`, `path.removeAnchor`, `path.convertAnchor`, `path.reshapeSegment`, `path.split`, `path.setHandle`, `path.moveAnchor` and `path.insertAnchor` refuse a `subpath` that is not a whole number of 0 or more, and `path.split` refuses such an `anchor` or `segment`; without a `subpath` they edit subpath 0. A value given that is not a whole number, such as `"1"` or `1.5`, is refused with `` `subpath` must be a whole number ``, naming its key, as are a required index of the Appearance panel and Smart Pen commands, `sourceDocument` of `edit.dropSelection` and `index` and `to` of `document.activate` and `document.move`; a required one left out or null is reported as missing (`tests::params::a_malformed_required_integer_is_not_reported_as_missing`). `path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size. `path.reshapeSegment`, `object.blend.spine.reshapeSegment`, `path.convertAnchor`, `path.curvatureEdit`, `path.insertAnchor`, `path.moveAnchors`, `path.reshape`, `path.close` and the Pencil, Curvature, Knife, Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a number, point or handle given as text, such as "10", and leave the document unchanged; `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`. + `path.setHandle`, `path.moveAnchor`, `text.setPathHandle` (`anchor`), `path.insertAnchor` (`segment`), `artboard.reorder`, `library.moveGroup`, `graphicStyle.move` (`to`), `path.appendAnchor`, `path.close`, `path.reverse` (`subpath`) and the ruler origin commands (`artboard`) refuse a value that is not a whole number of 0 or more, such as `"3"` or `2.5`, with `` `anchor` must be a whole number ``, naming the key, instead of reporting it as missing or reading it as the default subpath or artboard; a null `artboard` is artboard 0 (`tests::params::a_malformed_index_is_not_missing_or_its_default`). `path.close` and `path.setHandle` (`independent`), `path.moveAnchors` (`patterns`), the Pencil (`closed`, `keepSelected`, `fill`), Curvature (`closed` and a point's `corner`), `path.curvatureEdit` (`corner`), `path.reshapeSegment` (`perpendicular`) and the Blob Brush (`keepSelected`, `mergeOnlySelection`, `merge`) refuse a value other than true, false or null, such as "true", and leave the document unchanged. Direct Selection's marquee selects the frame anchors of area type that lie inside it, and a click on a frame anchor selects that one; Shift toggles them. The selected frame anchors show filled. A drag of one of them moves all of them, the arrow keys nudge them, and the type reflows at its size (`text.reshapeArea`, which takes `items` to move the anchors of several frames in one step), as [Create text](http://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) and [Selecting objects](http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html) describe. When the selection also holds other objects, path anchors or ruler guides, or when another change to the document or the selection follows the pick, the type is selected whole. A press on a selected object's centre point (Attributes panel › Show Center) selects the object whole, and a drag from it moves the object as one Move, held by that point (#4891; test `the_centre_point_selects_and_moves_the_object_whole`). @@ -1130,7 +1132,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. The Appearance, effect and graphic style commands do not edit a design's art inside a series, which the graph draws again from the design. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo; a kept gradient takes the same place on each mark's own box. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. The Appearance, effect and graphic style commands do not edit a design's art inside a series, which the graph draws again from the design. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. What Global Edit gives a mark from another match (in an isolated graph) is kept on the graph the same way, and so is the object opacity, with its blend mode, that Randomize Opacity and Sort Objects change on marks. - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. @@ -1270,6 +1272,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The solids of one Extrude & Bevel (both kinds) or Inflate effect on one object, its fill's, its strokes' and a group's members' together, keep up to 163,840 faces (VectorCraft's own limit). Each solid is built in turn; once their faces pass the limit, no later solid is built, the object draws its source art, and applying or editing the effect, Export 3D Object and Expand as Wireframes report that the 3D surface is too complex. Tests `solids::tests::an_objects_solids_count_their_faces_together`, `a_groups_solids_together_past_the_limit_draw_the_source`, `a_fill_and_strokes_together_past_the_limit_draw_the_source`. - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset. An edge of a twisted wall that is cut into pieces, a piece of curve that does not fit after 8 halvings, and the curves past 1,048,576 for one solid stay straight (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `a_beveled_cap_and_its_walls_share_their_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). + - Export 3D Object builds each Extrude & Bevel (both kinds) and Inflate solid once: the same build gives the faces that count toward the object's limit and the faces of its mesh, and the meshes match those of a separate build bit for bit. Tests `mesh::tests::a_meshed_solid_is_built_once`, `an_extruded_solid_and_its_mesh_match_the_two_built_apart`, `inflated_solids_and_their_meshes_match_the_two_built_apart`, `solids::tests_mesh::one_build_exports_the_meshes_built_apart`, `a_stroke_past_the_limits_is_left_out_of_the_export`. - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Revolve takes graphics on its swept side, shaded with the object's light ([the archived reference, page 185](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185)). By VectorCraft's own rule a graphic wraps around the axis at its own size at every distance from it, narrows toward the silhouette, follows the profile along its length and is hidden where the side faces away (`threeD.mapGraphic`; `revolve::tests_graphics`, `tests::side_faces_lie_on_the_unrolled_surface_and_caps_do_not`, `map_graphic_puts_a_graphic_on_the_side_of_a_revolve`). - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). @@ -1690,7 +1693,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, an object in a pattern's art or a brush's art with an art style VectorCraft reads opens as itself with its effects live, as an object on a layer does, and an object in a pattern's art keeps the id of a graphic style in the file's list (`crates/eps/src/import/ai/tests.rs`). A graph in a `.ai` whose records have the shape and values the published version 7 specification gives ([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), printed pp. 109–112 and 146–158) opens as a live grouped column, stacked column, line or area graph with its saved art, table, axes and settings, and Graph Data edits it ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-data.html)); other graphs keep their art as plain objects with a note, and saving a graph to `.ai` warns that its data and settings aren't written (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/graph/tests.rs`). Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). - A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in None or Tints keeps that colorization, and one in the newer form also keeps its fixed width, Flip Along and Flip Across, and Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with its guides ([Art brush options](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf), printed page 193). Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form that bends art of solid-filled paths with those options, open as the path with the brush on its stroke, except paths whose brush scales its art proportionately. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` or EPS opened from its editing data, a Scatter brush whose last 15 numbers start with 1 comes into the Brushes panel in Tints, and each brush art section is read without its frame, an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt). A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group (`crates/eps/src/import/ai/tests.rs`). In a `.ai` opened from its editing data, type with Warp on the object or on one of its fills or strokes opens as type with the effect live when the live warp draws as the art saved with it. When it draws differently, or when the file's limit of checks has no check left for it, it opens as that art, and one note says how many did. Type stays live without a comparison when the saved art paints nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text object doesn't come in as one text object (`crates/eps/src/import/ai/envelope.rs`, `crates/eps/src/import/tests_native.rs`).