Merge main into fix/remove-breaks-named-locked-type

This commit is contained in:
dotCooCoo
2026-11-14 02:11:05 -08:00
65 changed files with 1680 additions and 291 deletions
+8
View File
@@ -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<vectorcraft_color::BlendMode>,
/// 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
+25 -21
View File
@@ -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<Part<'_>>, 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<usize> = 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))?;
+148
View File
@@ -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<Vec<SolidMesh>, 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<Profile> {
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<Point> = (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}");
}
}
+9
View File
@@ -559,6 +559,15 @@ pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result<Option<Vec<No
pub(crate) fn index(v: u64) -> 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<Option<usize>> {
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<Vec<NodeId>> {
checked_nodes_param(s, p, key, command, false)
+7 -7
View File
@@ -568,12 +568,11 @@ fn switch_units(s: &mut Session, _: &Value) -> Result<Value> {
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<usize> {
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<usize> {
fn ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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")
}
+1 -1
View File
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+2 -2
View File
@@ -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);
+133 -7
View File
@@ -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<Rect>) -> Option<Paint> {
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<f64>) -> Option<Rect> {
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<Rect>) -> (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<Arc<Node>>) -> Vec<Arc<Node>> {
let Some(slot) = g.series_paints.get(i) else { return items };
fn kept(p: &Option<Paint>, 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<f64>, Option<Rect>);
/// 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<f64>)>) -> bool {
fn store_series_paints(doc: &mut Document, captures: Vec<Capture>) -> 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;
}
}
+273 -12
View File
@@ -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<f64> {
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<Paint> = 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<Paint> = 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::<vectorcraft_doc::SeriesPaint>(&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,
+3 -3
View File
@@ -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<Value> {
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))
+1 -1
View File
@@ -88,7 +88,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
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()));
+7 -7
View File
@@ -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<Value> {
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<Value> {
/// `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<Value> {
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<Value> {
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<Value> {
fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
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<Nod
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
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)?;
+3 -3
View File
@@ -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<CommandSpec> {
@@ -585,10 +585,10 @@ fn style_merge(s: &mut Session, p: &Value) -> Result<Value> {
fn style_move(s: &mut Session, p: &Value) -> Result<Value> {
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();
}
+3 -3
View File
@@ -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<CommandSpec> {
@@ -1376,7 +1376,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
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)?;
+72
View File
@@ -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<serde_json::Value>| {
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}"));
}
}
}
+64 -12
View File
@@ -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<Brush> = 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::<Vec<_>>(), want);
+67 -33
View File
@@ -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<String> {
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<String> {
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<String> {
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"
}
+135 -32
View File
@@ -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<ArtSettings>,
},
/// 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<String, Node>) -> HashSet<String> {
/// 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<String, Node>) -> HashMap<String, f64> {
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<String, Node>) ->
_ => 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<String>) -> 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<String, f64>) -> 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<ArtBrush> {
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, &sections).first().copied().unwrap_or(false)
&& match &drafts[0].kind {
DraftKind::Calligraphic(_) => true,
DraftKind::Art { plain: true, art: section, .. } => bending_art(&drafts, &sections).contains(section),
DraftKind::Art { settings: Some(s), art: section, .. } => {
bending_art(&drafts, &sections).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<String, usize>,
/// The art an Art brush draws as the file does ([`bending_art`]).
bending: HashSet<String>,
/// The art an Art brush draws as the file does, with its length ([`bending_art`]).
bending: HashMap<String, f64>,
}
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,
})
+2 -2
View File
@@ -438,8 +438,8 @@ struct BrushesKept {
read: (usize, usize),
/// Which of [`Reader::brush_drafts`] are kept as brushes.
kept: Vec<bool>,
/// The art an Art brush draws as the file does.
bending: HashSet<String>,
/// The art an Art brush draws as the file does, with its length.
bending: HashMap<String, f64>,
}
struct Reader<'a> {
+1 -1
View File
@@ -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);
+5 -5
View File
@@ -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<String>,
/// The art an Art brush draws as the file does, with its length ([`super::super::brush::bending_art`]).
bending: &'a HashMap<String, f64>,
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,
+29 -25
View File
@@ -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<f64>); 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<StyleItem> {
if !f.paints.0
|| f.gradient
@@ -1519,28 +1538,13 @@ fn art_stroke(f: &Filter) -> Option<StyleItem> {
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<StyleItem> {
dash: None,
profile: None,
align: StrokeAlign::Center,
brush: Some((name.clone(), PathBrush::Art)),
brush: Some((name.clone(), PathBrush::Art(settings))),
})
}
+137 -23
View File
@@ -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())));
+1 -1
View File
@@ -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};
+47 -5
View File
@@ -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<Mesh, String> {
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<Mesh, String> {
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<Item = Result<Mesh, String>> + 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<Facet>,
/// The faces before projection, or the error the mesh alone gives when there are too many.
faces: Result<Vec<Face3>, 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, String> {
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<MeshedSolid, String> {
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<Item = Result<MeshedSolid, String>> + 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
+128 -1
View File
@@ -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<View> {
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<vectorcraft_geom::Point> = (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);
}
+7 -7
View File
@@ -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<Range<usize>, 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<Range<usize>, 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<Result<Range<usize>, 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()
}
+3 -1
View File
@@ -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)]
+76 -23
View File
@@ -64,9 +64,25 @@ impl Camera {
/// and its outward normal.
pub(crate) type Face3 = (Vec<Vec<V3>>, 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<Facet>, Vec<Face3>, Option<Projection>);
/// 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<Facet>, Result<Vec<Face3>, String>, Option<Projection>);
#[cfg(test)]
thread_local! {
/// How many Extrude & Bevel solids this thread has built, for the tests.
pub(crate) static EXTRUDED: std::cell::Cell<usize> = 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<Facet>,
/// With `Some`, every face goes here as it is, none hidden or projected, for a mesh.
pub(super) mesh: Option<Vec<Face3>>,
/// 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<Vec<V3>>, 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<usize> {
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<V3>], 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<Vec<Point>> = 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<Point>]) -> Option<PathData>) {
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<Step> {
/// 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<Vec<Facet>, 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<Built, String> {
/// [`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<Built, String> {
#[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<V3> { 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<Vec<Facet>, 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<Built, String> {
/// [`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<Built, String> {
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<Vec<V3>> = 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.
+41 -16
View File
@@ -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<Item = Result<Vec<Facet>, 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<Item = Result<Built, String>> + use<> {
let o = options.sanitized();
let puffs: Vec<Result<Option<Puff>, 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<Built, String> {
/// 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<Built, String> {
#[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<Vec<usize>> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect();
let cut: Vec<Vec<usize>> = 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<usize>]| -> 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(),
}
}
}
+2 -2
View File
@@ -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}");
+54 -17
View File
@@ -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<vectorcraft_doc::Node>]) -> 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<Pos2> {
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
// (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>).
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
// (<https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/font-options-overview.html>).
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<Chip> = [
(!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<Chip> =
[(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<f32> = items.iter().map(|(label, ..)| action_width(ui, label)).collect();
let font_w = fonts.then(|| {
let w = crate::panels::character::TASK_BAR_WIDTHS;
+2 -2
View File
@@ -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::<Vec<_>>();
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);
+2
View File
@@ -691,6 +691,8 @@ font-technology Variable Променливи
Crop Image Изрязване на изображение
Image Tools Инструменти за изображения
Remove Background… Премахване на фона…
Draw (Pencil) Рисуване (Молив)
Place Image Вмъкване на изображение
Remove Background Премахване на фона
Background Фон
Edge Color Цвят на ръба
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -665,6 +665,8 @@ font-technology Variable Μεταβλητές
Crop Image Περικοπή εικόνας
Image Tools Εργαλεία εικόνας
Remove Background… Αφαίρεση φόντου…
Draw (Pencil) Σχεδίαση (μολύβι)
Place Image Τοποθέτηση εικόνας
Remove Background Αφαίρεση φόντου
Background Φόντο
Edge Color Χρώμα άκρης
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -1140,6 +1140,8 @@ antiAlias Sharp शार्प
Crop Image इमेज क्रॉप करें
Image Tools इमेज टूल
Remove Background… पृष्ठभूमि हटाएँ…
Draw (Pencil) ड्रा (पेंसिल)
Place Image इमेज प्लेस करें
Remove Background पृष्ठभूमि हटाएँ
Background पृष्ठभूमि
Edge Color किनारे का रंग
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
194
195
196
197
198
199
200
201
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -242,6 +242,8 @@ digits Malayalam マラヤーラム数字
Crop Image 画像の切り抜き
Image Tools 画像ツール
Remove Background… 背景を削除…
Draw (Pencil) 描画 (鉛筆)
Place Image 画像を配置
Remove Background 背景を削除
Background 背景
Edge Color 端の色
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -166,6 +166,8 @@
Crop Image გამოსახულების ჩამოჭრა
Image Tools გამოსახულების ხელსაწყოები
Remove Background… ფონის მოშორება…
Draw (Pencil) ხატვა (ფანქარი)
Place Image გამოსახულების განთავსება
Remove Background ფონის მოშორება
Background ფონი
Edge Color კიდის ფერი
Can't render this file because it is too large.
+2
View File
@@ -235,6 +235,8 @@ digits Malayalam 말라얄람어
Crop Image 이미지 자르기
Image Tools 이미지 도구
Remove Background… 배경 제거…
Draw (Pencil) 그리기(연필)
Place Image 이미지 가져오기
Remove Background 배경 제거
Background 배경
Edge Color 가장자리 색상
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -703,6 +703,8 @@ font-technology Variable Вариативные
Crop Image Обрезать изображение
Image Tools Инструменты изображения
Remove Background… Удалить фон…
Draw (Pencil) Рисовать (Карандаш)
Place Image Поместить изображение
Remove Background Удалить фон
Background Фон
Edge Color Цвет края
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -699,6 +699,8 @@ font-technology Variable Варіативні
Crop Image Обрізати зображення
Image Tools Інструменти зображення
Remove Background… Видалити фон…
Draw (Pencil) Малювати (Олівець)
Place Image Помістити зображення
Remove Background Видалити фон
Background Фон
Edge Color Колір краю
Can't render this file because it is too large.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 204 and column 5.
+2
View File
@@ -650,6 +650,8 @@ antiAlias Sharp 锐利
Crop Image 裁剪图像
Image Tools 图像工具
Remove Background… 移除背景…
Draw (Pencil) 绘制 (铅笔)
Place Image 置入图像
Remove Background 移除背景
Background 背景
Edge Color 边缘颜色
Can't render this file because it is too large.
+2
View File
@@ -667,6 +667,8 @@ font-technology Variable 可變
Crop Image 裁切影像
Image Tools 影像工具
Remove Background… 移除背景…
Draw (Pencil) 繪製 (鉛筆)
Place Image 置入影像
Remove Background 移除背景
Background 背景
Edge Color 邊緣顏色
Can't render this file because it is too large.
+97
View File
@@ -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"];
File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -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
+14 -3
View File
@@ -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
+5 -2
View File
@@ -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 (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>). 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 (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>): 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: <https://helpx.adobe.com/illustrator/using/creating-type-path.html>) 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 (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>); 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 (<https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/font-options-overview.html>). 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 (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/workspace-overview.html>) 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`).