Merge main into fix/type-no-op-undo-more

This commit is contained in:
dotCooCoo
2026-11-14 09:49:48 -08:00
124 changed files with 9903 additions and 540 deletions
+4
View File
@@ -892,6 +892,10 @@ pub use buildcmds::live_paint_gaps;
/// Control bar enable it when it changes one).
pub use brushsym::reset_transformation_changes;
/// The byte order of UTF-16 text without a byte-order mark, by Import Data Sets' rule (the Graph
/// Data window reads files by it too).
pub use variablecmds::utf16_order_without_mark;
/// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`.
/// They run as before but the command palette leaves them out (it lists the command once).
pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")];
+7 -5
View File
@@ -518,16 +518,18 @@ fn sort(s: &mut Session, p: &Value) -> Result<Value> {
}
let wanted: Vec<(&Sorted, f64)> = rank.iter().filter_map(|i| items.get(*i)).zip(values).collect();
if apply == "opacity" {
if wanted.iter().all(|(o, v)| (o.opacity - v).abs() < 1e-6) {
// The opacity each object is given, as stored; an object whose stored opacity it already is has not changed.
let given: Vec<(NodeId, f32)> = wanted.iter().map(|(o, v)| (o.id, (v / 100.0) as f32)).collect();
let changed: Vec<NodeId> = given.iter().filter(|(id, a)| d.node(*id).is_some_and(|n| n.opacity != *a)).map(|(id, _)| *id).collect();
if changed.is_empty() {
return Ok(count);
}
s.edit("Sort Objects", |d, _| {
for (o, v) in &wanted {
if let Some(n) = d.node_mut(o.id) {
n.opacity = (v / 100.0) as f32;
for (id, a) in &given {
if let Some(n) = d.node_mut(*id) {
n.opacity = *a;
}
}
let changed: Vec<NodeId> = wanted.iter().filter(|(o, v)| (o.opacity - v).abs() >= 1e-6).map(|(o, _)| o.id).collect();
keep_series_opacity(d, &changed);
Ok(())
})?;
+1 -1
View File
@@ -11,7 +11,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pencil",
[],
None,
"{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
"{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end, and closes it when `closed` is true (left out, when the stroke ends within four Fidelities of the path's other end, 6 to 400 pt); with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
has_doc,
freehand
),
+22 -5
View File
@@ -217,11 +217,18 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
None => None,
};
// An extending stroke that ends within four Fidelities of the path's other end closes it:
// at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at.
// `closed`, when given, says whether the stroke closes the path: the Pencil and the
// Paintbrush send it, by their Close Paths When Ends Are Within and Alt at the release.
// Left out, an extending stroke that ends within four Fidelities of the path's other end
// closes it: at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at.
let close_tol = (tol * 4.0).clamp(6.0, 400.0);
let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol);
if closing && let Some(l) = pts.last_mut() {
let near_first = pts.last().is_some_and(|p| p.distance(first) <= close_tol);
let wants_close = if p.get("closed").is_some_and(|v| !v.is_null()) { closed } else { near_first };
let closing = connect.is_none() && sp.anchors.len() >= 2 && wants_close;
// A stroke ending near the other end ends on it; one ending farther away closes with a
// straight segment back to it.
let ends_on_first = closing && near_first;
if ends_on_first && let Some(l) = pts.last_mut() {
*l = first;
}
if let Some((_, _, y, _)) = &connect
@@ -238,13 +245,23 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
add.remove(0);
}
sp.anchors.extend(add);
if closing
if ends_on_first
&& sp.anchors.len() > 2
&& let Some(l) = sp.anchors.pop()
&& let Some(a0) = sp.anchors.first_mut()
{
a0.h_in = l.h_in;
sp.closed = true;
} else if closing && !ends_on_first && sp.anchors.len() > 2 {
// Closed from away from the other end: a straight segment back to it, whatever
// handles the open ends carried.
sp.closed = true;
if let Some(l) = sp.anchors.last_mut() {
l.h_out = l.p;
}
if let Some(f) = sp.anchors.first_mut() {
f.h_in = f.p;
}
}
for a in &mut sp.anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
+2 -14
View File
@@ -352,20 +352,8 @@ fn svg_darkness_preserves_source_model_and_gradient_appearance() {
#[test]
fn svg_darkness_uses_rendered_shadow_in_non_srgb_working_space() {
const CHILD: &str = "VECTORCRAFT_TEST_SHADOW_RGB_PROFILE";
if std::env::var_os(CHILD).is_none() {
// Color settings are process-wide: exercise this profile in a separate test process.
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
"isolated color profile test failed:\n{stdout}\n{}",
String::from_utf8_lossy(&output.stderr),
);
// Color settings are process-wide: exercise this profile in a separate test process.
if !crate::tests::in_own_process("cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space") {
return;
}
use vectorcraft_color::{Color, Paint, cms};
+18
View File
@@ -840,6 +840,24 @@ fn an_opacity_sort_objects_stores_the_frontmost_mark_it_changes() {
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.6));
}
/// A Sort Objects that gives series marks the opacities they already have changes nothing: the value the graph stores
/// stays and no undo step is made, even when the evenly stepped percentage differs from a mark's in the last digits.
#[test]
fn an_opacity_sort_objects_that_changes_no_mark_keeps_the_stored_opacity() {
let (mut s, id, _, marks) = column_marks();
s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 65})).unwrap();
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 30})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3));
// From left to right the columns have 30%, 65% and 100%, the values an even step from 30% to 100% gives them.
let undo = s.doc().unwrap().history.undo.len();
let ids = [marks[0].0, marks[1].0, marks[2].0];
s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity", "distribute": true})).unwrap();
let opacities: Vec<f32> = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect();
assert_eq!(opacities, [0.3, 0.65, 1.0]);
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3));
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
}
/// An object opacity, blend mode, fill, stroke and weight that Global Edit gives a mark of another series from the mark
/// that was edited are stored on the graph for that series in the same undo step, and every mark of the series has them
/// when the graph is drawn again.
+3 -2
View File
@@ -472,7 +472,7 @@ impl Session {
pub fn transparency_info(&self) -> TransparencyInfo {
match (self.active(), transparency_targets(self, &Value::Null)) {
(Some(st), Ok(ids)) => {
let points = super::paint::picked_mesh_points_of(self, &ids);
let points = self.mesh_points_picked();
TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points)
}
_ => TransparencyInfo::default(),
@@ -483,7 +483,8 @@ impl Session {
fn info(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let ids = transparency_targets(s, p)?;
let points = super::paint::picked_mesh_points_of(s, &ids);
// The points `transparency.set` with the same `ids` (or none) fades.
let points = if p.get("ids").is_none() && p.get("id").is_none() { s.mesh_points_picked() } else { super::paint::picked_mesh_points_of(s, &ids) };
let i = TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points);
Ok(json!({
"ids": i.ids.iter().map(|id| id.0).collect::<Vec<_>>(),
+74 -10
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
has_doc,
|s, p| set_paint(s, p, true)
),
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transparency",
["Window", "Transparency"],
None,
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too",
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too",
has_doc,
transparency
),
@@ -154,7 +154,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Fill and Stroke",
[],
None,
"{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy}",
"{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art; when that object is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso and no fill item is active, the fill is its first picked point's color), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy (a gradient mesh with points picked counts with their colors)}",
always,
proxies
),
@@ -179,9 +179,13 @@ impl Session {
/// The fill and stroke the Fill/Stroke proxies show: the Appearance panel's active item (in the
/// first selected object's own stack) for the proxy of its kind, else the first selected
/// object's (a group's first painted object's; type: its first run's), else the defaults for
/// new art.
/// new art. When the object whose fill the proxy shows is a gradient mesh with points picked
/// ([`Session::fill_mesh_points`]), the fill is its first picked point's color, which a solid
/// fill color changes.
pub fn proxy_paints(&self) -> (Paint, Paint) {
let points = self.fill_mesh_points();
let show = |stroke: bool| match self.proxy_source(stroke) {
Some((n, None)) if !stroke && let Some(c) = picked_colors(&n, &points).first() => Paint::solid(*c),
Some((n, item)) => proxy_paint(&n, stroke, item),
None if stroke => self.paint.stroke.clone(),
None => self.paint.fill.clone(),
@@ -273,6 +277,13 @@ impl DocState {
/// each selected object's own stack is compared. One walk of the document, so callers that ask
/// every frame cache it by revision.
pub fn proxy_mixed(&self, item: Option<usize>) -> (bool, bool) {
self.proxy_mixed_with(item, &[])
}
/// [`DocState::proxy_mixed`] with the gradient mesh points a solid fill color recolors
/// (`points`, see [`Session::fill_mesh_points`]): a mesh among them counts with the colors of
/// its picked points instead of its fill.
fn proxy_mixed_with(&self, item: Option<usize>, points: &[(NodeId, Vec<usize>)]) -> (bool, bool) {
if self.selection.is_empty() {
return (false, false);
}
@@ -291,7 +302,10 @@ impl DocState {
let differ = |stroke: bool| {
let item = first.and_then(|f| f.appearance.item_of_kind(item, !stroke));
let set = if item.is_some() { &selected } else { &nodes };
let mut paints = set.iter().map(|n| proxy_paint(n, stroke, item));
let mut paints = set.iter().flat_map(|n| match picked_colors(n, if stroke || item.is_some() { &[] } else { points }) {
c if c.is_empty() => vec![proxy_paint(n, stroke, item)],
c => c.into_iter().map(Paint::solid).collect(),
});
let Some(p0) = paints.next() else { return false };
paints.any(|p| !same_in_proxy(&p0, &p))
};
@@ -313,8 +327,9 @@ impl DocState {
impl Session {
/// [`DocState::proxy_mixed`] of the active document for the Appearance panel's active item.
/// A gradient mesh with points picked ([`Session::fill_mesh_points`]) counts with their colors.
pub fn proxy_mixed(&self) -> (bool, bool) {
self.active().map_or((false, false), |st| st.proxy_mixed(self.appearance_item()))
self.active().map_or((false, false), |st| st.proxy_mixed_with(self.appearance_item(), &self.fill_mesh_points()))
}
}
@@ -539,8 +554,8 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
let selected = item.targets(s, p)?;
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint };
// The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill
// recolours just those points of their meshes.
// The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso: a solid
// fill recolors just those points of their meshes (the proxies show them: `fill_mesh_points`).
let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] };
// Without `item`, unless the Appearance panel's active fill item takes the paint.
let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top {
@@ -871,6 +886,55 @@ pub(crate) fn picked_mesh_points_of(s: &Session, ids: &[NodeId]) -> Option<Vec<(
(!ids.is_empty() && ids.iter().all(|id| v.iter().any(|(m, _)| m == id))).then_some(v)
}
/// The colors of the picked points of `n` (see [`picked_mesh_points`]) when it is a gradient mesh
/// among `points`, in the order they were picked; empty otherwise.
fn picked_colors(n: &Node, points: &[(NodeId, Vec<usize>)]) -> Vec<Color> {
match (&n.kind, points.iter().find(|(id, _)| *id == n.id)) {
(NodeKind::Mesh(m), Some((_, v))) => v.iter().filter_map(|i| m.points.get(*i)).map(|q| q.color).collect(),
_ => vec![],
}
}
impl Session {
/// The picked gradient mesh points that a solid fill color (`paint.setFill` without `ids` or
/// `item`) recolors instead of their meshes' fills, each mesh with its points: the targets are
/// resolved as [`set_paint`] resolves them, so this is empty while the Appearance panel's
/// active item is a fill row or an object of a symbol instance is selected, and a mesh with
/// points picked counts even when other objects are selected with it.
pub fn fill_mesh_points(&self) -> Vec<(NodeId, Vec<usize>)> {
let Some(st) = self.active() else { return vec![] };
// Only gradient meshes have points to pick: a selection with no mesh and no container that
// could hold one skips reading the tool's options.
let may_hold_mesh = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_)) || n.is_container());
if st.selection.symbol_part.is_some() || !st.selection.subjects().iter().any(may_hold_mesh) {
return vec![];
}
let Ok(item) = item_target(self, &Value::Null, "paint.setFill").map(|t| t.of_kind(self, true)) else { return vec![] };
if item != ItemTarget::Top {
return vec![];
}
let Ok(selected) = item.targets(self, &Value::Null) else { return vec![] };
picked_mesh_points(self, &super::graph::paint_targets(&st.doc, &selected))
}
/// The picked gradient mesh points that the Transparency panel's opacity (`transparency.set`
/// without `ids` or `item`) fades instead of their objects: `None` unless every target is a
/// gradient mesh with points picked, and while the Appearance panel's active item takes the
/// opacity (outside opacity mask editing).
pub fn mesh_points_picked(&self) -> Option<Vec<(NodeId, Vec<usize>)>> {
let st = self.active()?;
let ids = super::opacitymask::transparency_targets(self, &Value::Null).ok()?;
// Only gradient meshes have points to pick: other targets skip reading the tool's options.
if ids.is_empty() || !ids.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Mesh(_)))) {
return None;
}
if st.doc.mask_edit.is_none() && self.appearance_item().is_some() {
return None;
}
picked_mesh_points_of(self, &ids)
}
}
/// A type run's stroke; a painted stroke on a run without one gets 1 pt.
pub(super) fn set_run_stroke(style: &mut CharStyle, paint: Paint) {
if !paint.is_none() {
@@ -1086,8 +1150,8 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
q.remove("item");
q.remove("id");
q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::<Vec<_>>()));
// The gradient mesh points picked with the Mesh tool or Direct Selection take the opacity
// themselves, so only the mesh around them fades (#4206, #4170, #4208).
// The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso take the
// opacity themselves, so only the mesh around them fades (#4206, #4170, #4208).
if matches!(item, ItemTarget::Top)
&& let Some(o) = q.get("opacity").and_then(Value::as_f64)
&& let Some(points) = picked_mesh_points_of(s, &ids)
+16 -14
View File
@@ -77,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reverse Path Direction",
["Object", "Path"],
None,
"{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}",
"{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step; when every subpath of a path turns alike, its strokes' width profiles (sides swapped) and pen pressure, tilt, bearing and rotation turn with it, so they draw as before → {changed: paths}",
has_selected_path,
reverse
),
@@ -462,22 +462,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
let mut changed = 0;
for id in &ids {
let path = path_mut(d, *id)?;
if let Some(si) = subpath {
if let Some(sp) = path.subpaths.get_mut(si) {
let flip: Vec<bool> = match subpath {
Some(si) => (0..path.subpaths.len()).map(|i| i == si).collect(),
None => path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(),
};
if !flip.contains(&true) {
continue;
}
// The stroke's width profile and pen recordings turn with the path, read before it turns.
if let Some(n) = d.node_mut(*id) {
vectorcraft_doc::blend::reverse_stroke_records(n, &flip);
}
let path = path_mut(d, *id)?;
for (sp, f) in path.subpaths.iter_mut().zip(flip) {
if f {
sp.reverse();
changed += 1;
}
} else {
let flip: Vec<bool> = path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect();
if flip.contains(&true) {
for (sp, f) in path.subpaths.iter_mut().zip(flip) {
if f {
sp.reverse();
}
}
changed += 1;
}
}
changed += 1;
}
Ok(changed)
})?;
+1 -1
View File
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
has_doc,
place
),
+16 -3
View File
@@ -3,6 +3,7 @@
use serde_json::Value;
use super::super::utf16_order_without_mark;
use super::{Point, Rect, Result, bad, bool_or, fileio, shapes, str_param};
/// Text files as `file.place` reports them (not a format `document.open` reads).
@@ -76,8 +77,10 @@ pub(super) fn import(bytes: &[u8], o: TextOptions, cmd: &str) -> Result<String>
Ok(clean(&decode(bytes, o), o))
}
/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark; bytes that aren't UTF-8 are
/// read as the platform's 8-bit set), or the platform's 8-bit character set.
/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark, or without one when the
/// file has an even length, reads as valid UTF-16 with no NUL character in the byte order
/// [`utf16_order_without_mark`] gives; bytes that aren't UTF-8 are read as the platform's 8-bit
/// set), or the platform's 8-bit character set.
fn decode(bytes: &[u8], o: TextOptions) -> String {
let eight_bit = |b: &[u8]| -> String { b.iter().map(|&c| if o.mac { mac_roman(c) } else { windows_1252(c) }).collect() };
if o.ansi {
@@ -91,7 +94,17 @@ fn decode(bytes: &[u8], o: TextOptions) -> String {
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
[0xFF, 0xFE, rest @ ..] => utf16(rest, true),
[0xFE, 0xFF, rest @ ..] => utf16(rest, false),
_ => std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string),
_ => {
let without_mark = (bytes.len().is_multiple_of(2) && bytes.contains(&0))
.then(|| utf16_order_without_mark(bytes))
.flatten()
.and_then(|le| {
let units = bytes.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
char::decode_utf16(units).collect::<std::result::Result<String, _>>().ok()
})
.filter(|text| !text.contains('\0'));
without_mark.unwrap_or_else(|| std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string))
}
}
}
+1
View File
@@ -26,6 +26,7 @@ use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids
mod csv;
mod library;
pub use csv::utf16_order_without_mark;
use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import};
pub(crate) use library::{give_svg_library, svg_library};
use library::{load_library, save_library};
+23 -13
View File
@@ -59,25 +59,35 @@ fn utf16(bytes: &[u8], le: bool) -> Option<String> {
char::decode_utf16(units).collect::<std::result::Result<String, _>>().ok().filter(|_| odd.is_empty())
}
/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16, or `None`. At least half of
/// the two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with
/// fewer, such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is
/// the one that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or
/// U+0D00, so a file that reads one in both orders is refused. A file that reads none in either
/// order has no data rows, and is read little-endian so that the import reports that. The text
/// must be valid UTF-16 in that order and hold no NUL character.
fn utf16_without_mark(bytes: &[u8]) -> Option<String> {
let (units, odd) = bytes.as_chunks::<2>();
if !odd.is_empty() || units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() {
/// The byte order of `bytes`, which hold NUL bytes and no byte-order mark, read as UTF-16:
/// `Some(true)` for little-endian, or `None` when they don't read as UTF-16. At least half of the
/// two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with fewer,
/// such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is the one
/// that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or U+0D00,
/// so a file that reads one in both orders is refused. A file that reads none in either order (one
/// line) is big-endian when more of its units start with a NUL byte than end with one, as
/// characters from U+0001 to U+00FF do in that order, and little-endian otherwise. An odd last
/// byte is left out of the count.
pub fn utf16_order_without_mark(bytes: &[u8]) -> Option<bool> {
let units = bytes.as_chunks::<2>().0;
if units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() {
return None;
}
let is_line_break = |u: u16| matches!(u, 0x0A | 0x0D);
let lines_le = units.iter().any(|&c| is_line_break(u16::from_le_bytes(c)));
let lines_be = units.iter().any(|&c| is_line_break(u16::from_be_bytes(c)));
if lines_le && lines_be {
return None;
match (lines_le, lines_be) {
(true, true) => None,
(false, false) => Some(units.iter().filter(|c| c[0] == 0).count() <= units.iter().filter(|c| c[1] == 0).count()),
(_, lines_be) => Some(!lines_be),
}
utf16(bytes, !lines_be).filter(|text| !text.contains('\0'))
}
/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16 in the byte order
/// [`utf16_order_without_mark`] gives, or `None`. The file must have an even length, and the text
/// must be valid UTF-16 in that order and hold no NUL character.
fn utf16_without_mark(bytes: &[u8]) -> Option<String> {
utf16(bytes, utf16_order_without_mark(bytes)?).filter(|text| !text.contains('\0'))
}
/// A row of a datasets CSV: the file line it starts on, its cells, and for each cell whether it was quoted.
+19
View File
@@ -241,6 +241,25 @@ pub(crate) fn key_guide(s: &Session) -> Option<usize> {
st.selection.key_guide.and_then(|k| crate::cmd::guide_indexes(&st.doc, &[k]).first().copied())
}
/// Runs the unit test `path` (`tests::newdoc::...`) again in a child process of the test binary
/// → whether this is that child. For a test that changes process-wide state the tests running
/// beside it read, such as the active color settings.
pub(crate) fn in_own_process(path: &str) -> bool {
const CHILD: &str = "VECTORCRAFT_TEST_OWN_PROCESS";
if std::env::var_os(CHILD).is_some() {
return true;
}
let output =
std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact", path, "--nocapture"]).env(CHILD, "1").output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
"{path} failed in its own process:\n{stdout}\n{}",
String::from_utf8_lossy(&output.stderr),
);
false
}
#[test]
fn create_and_undo_redo() {
let mut s = session();
+55
View File
@@ -163,6 +163,61 @@ fn reverse_path_direction_on_and_off_set_the_winding() {
assert!(s.execute("path.reverse", &json!({})).is_err(), "nothing to reverse");
}
#[test]
fn reverse_path_direction_turns_the_width_profile_and_pen_records_with_the_path() {
use vectorcraft_doc::{AngleProfile, PressureProfile, WidthProfile};
let mut s = session();
let open =
NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 0, "y": 50}, {"x": 40, "y": 50}, {"x": 100, "y": 50}]}))["id"].as_u64().unwrap());
let closed = rect(&mut s, 10.0);
let profile = WidthProfile { points: vec![(0.0, 0.2, 0.6), (0.3, 1.0, 0.4), (1.0, 0.5, 0.9)] };
let pressure = PressureProfile { points: vec![(0.0, 0.1), (0.25, 0.9), (1.0, 0.4)] };
let bearing = AngleProfile { points: vec![(0.0, 10.0), (0.6, 80.0), (1.0, 170.0)] };
s.edit("set", |d, _| {
for id in [open, closed] {
let st = d.node_mut(id).unwrap().appearance.stroke_mut().unwrap();
st.profile = Some(profile.clone());
st.pressure = Some(pressure.clone());
st.tilt = Some(pressure.clone());
st.bearing = Some(bearing.clone());
st.rotation = Some(bearing.clone());
}
Ok(())
})
.unwrap();
// (anchor point, its fraction of the path's length) for each anchor.
let along = |s: &Session, id: NodeId| {
let sp = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].clone();
let pos = sp.anchor_arc_positions();
let total = *pos.last().unwrap();
sp.anchors.iter().zip(pos).map(|(a, at)| (a.p, at / total)).collect::<Vec<_>>()
};
let before = [along(&s, open), along(&s, closed)];
run(&mut s, "select.set", json!({"ids": [open.0, closed.0]}));
assert_eq!(run(&mut s, "path.reverse", json!({}))["changed"], 2);
for ((id, before), closed) in [open, closed].into_iter().zip(before).zip([false, true]) {
let after = along(&s, id);
let st = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone();
// Each anchor keeps the widths it had (its sides swapped, as the path runs the other way)
// and what the pen recorded there. (A closed path's start is where its profile steps.) A
// closed path now starts part way along a span of the width profile, which is resampled
// there, so its smooth curve moves a little.
let near = if closed { 0.02 } else { 1e-6 };
for (p, t) in before.iter().filter(|(_, t)| !closed || *t > 0.0) {
let u = after.iter().find(|(q, _)| q.distance(*p) < 1e-9).unwrap().1;
let (l, r) = profile.at(*t);
let (l2, r2) = st.profile.as_ref().unwrap().at(u);
assert!((l - r2).abs() < near && (r - l2).abs() < near, "{id:?} at {p:?}: {:?} then {:?}", (l, r), (l2, r2));
for got in [&st.pressure, &st.tilt] {
assert!((pressure.at(*t) - got.as_ref().unwrap().at(u)).abs() < 1e-6, "{id:?} pressure at {p:?}");
}
for got in [&st.bearing, &st.rotation] {
assert!((bearing.at(*t) - got.as_ref().unwrap().at(u)).abs() < 1e-6, "{id:?} bearing at {p:?}");
}
}
}
}
#[test]
fn attributes_survive_a_native_round_trip() {
let mut s = session();
+31
View File
@@ -3878,6 +3878,37 @@ fn live_paint_bucket_alt_shift_gradient_samples_active_stroke_only() {
assert!(matches!(node(&s, src).appearance.fill_paint(), Paint::Gradient(_)));
}
#[test]
fn live_paint_selection_double_click_selects_the_faces_no_stroked_edge_parts() {
let mut s = session();
let g = live(&mut s);
// The edge between the middle and right faces draws no stroke.
s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100.0, 50.0], "none": true})).unwrap();
let v = ViewInfo::default();
s.select_tool("livePaintSelection", v).unwrap();
let gn = node(&s, g);
let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().id;
let before = s.doc().unwrap().doc.clone();
let double_click = |s: &mut Session, x: f64, m: Mods| {
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] {
s.pointer(&PointerEvent::new(kind, x, 50.0).with_mods(m), v).unwrap();
}
let mut ids = s.doc().unwrap().selection.objects.clone();
ids.sort();
ids
};
let sorted = |mut ids: Vec<NodeId>| {
ids.sort();
ids
};
assert_eq!(double_click(&mut s, 125.0, Mods::default()), sorted(vec![face(75.0), face(125.0)]));
// The stroked edge at x = 50 parts the left face from the others.
assert_eq!(double_click(&mut s, 25.0, Mods::default()), vec![face(25.0)]);
// Shift-double-click adds the faces reached to the selection.
assert_eq!(double_click(&mut s, 75.0, Mods { shift: true, ..Default::default() }), sorted(vec![face(25.0), face(75.0), face(125.0)]));
assert_eq!(s.doc().unwrap().doc, before, "selecting changes no paint");
}
#[test]
fn live_paint_selection_marquee_selects_what_it_touches() {
let mut s = session();
+4 -1
View File
@@ -16,7 +16,10 @@ use crate::*;
/// Every test here reads or changes the process-wide proof view or colour settings: one at a time.
/// Tests that switch the Color Settings away from the defaults live in `tests/color_settings.rs`
/// (their own process): every colour shown or rendered goes through them, so no lock could cover
/// all the tests that would see the change.
/// all the tests that would see the change. A unit test that assigns its front document a profile
/// other than the working space changes the active settings the same way until another document
/// comes to the front: it starts with `crate::tests::in_own_process`, which runs it again in a
/// child process.
pub(crate) static GLOBAL: Mutex<()> = Mutex::new(());
fn session() -> Session {
+41
View File
@@ -5269,6 +5269,47 @@ fn pencil_extend_closes_within_400_pt_at_any_fidelity() {
}
}
/// `closed`, when given, decides whether a stroke continuing an open path closes it (the tools
/// send it by Close Paths When Ends Are Within): false leaves it open even ending on the other end;
/// true closes one ending 50 pt away with a straight segment back, the stroke's end kept, and one
/// ending 2 pt away on the other end itself.
#[test]
fn pencil_extend_closes_as_closed_says() {
let mut s = session();
for (end, closed, ends_on_start) in [([0i32, 0], false, false), ([0, 50], true, false), ([0, 2], true, true)] {
let l = line(&mut s, 0.0, 0.0, 200.0, 0.0);
let points = json!([[200, 0], [200, 100], [100, 150], end]);
let p = json!({"points": points, "fidelity": 1.5, "closed": closed, "extend": {"id": l.0}, "keepSelected": false});
s.execute("path.freehand", &p).unwrap();
let pd = path(&s, l);
let sp = &pd.subpaths[0];
assert_eq!(sp.closed, closed, "ending at {end:?}");
if closed {
let stroke_end = Point::new(f64::from(end[0]), f64::from(end[1]));
let kept = sp.anchors.iter().any(|a| a.p.distance(stroke_end) < 1e-6);
assert_eq!(kept, !ends_on_start, "ending at {end:?}: {:?}", sp.anchors);
let on_start = sp.anchors.iter().filter(|a| a.p.distance(Point::new(0.0, 0.0)) < 1e-6).count();
assert_eq!((sp.anchors[0].p, on_start), (Point::new(0.0, 0.0), 1), "ending at {end:?}");
}
}
}
/// A continued path closed from away from its other end closes with a straight segment, even when
/// that end carries a handle the open path didn't use.
#[test]
fn pencil_extend_closes_far_away_with_a_straight_segment() {
let mut s = session();
let anchors = json!([{"x": 0, "y": 0, "in": [-40, 30]}, {"x": 200, "y": 0}]);
let l = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
let points = json!([[200, 0], [200, 100], [100, 150], [0, 50]]);
let p = json!({"points": points, "fidelity": 1.5, "closed": true, "extend": {"id": l.0}, "keepSelected": false});
s.execute("path.freehand", &p).unwrap();
let sp = path(&s, l).subpaths[0].clone();
assert!(sp.closed);
let (first, last) = (sp.anchors[0], sp.anchors[sp.anchors.len() - 1]);
assert_eq!((first.h_in, last.h_out), (first.p, last.p), "the closing segment has no handles: {:?}", sp.anchors);
}
/// At Fidelity 0.01 short, one-point and repeated-point strokes still draw or are refused, never
/// crash, and what they draw has finite anchors.
#[test]
+3
View File
@@ -683,6 +683,9 @@ fn the_new_document_links_from_the_profile_file() {
/// is a warning.
#[test]
fn a_profile_brings_the_color_profile_its_file_carries() {
if !super::in_own_process("tests::newdoc::a_profile_brings_the_color_profile_its_file_carries") {
return;
}
use vectorcraft_color::cms;
let (mut s, dir) = with_profiles("icc");
let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap();
+9 -2
View File
@@ -5,6 +5,7 @@
//! as images of the effected object.
use serde_json::{Value, json};
use vectorcraft_color::cms;
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::Rect;
use vectorcraft_render::{Rendered, Renderer};
@@ -173,16 +174,22 @@ fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(SKETCH).chain(TEXTURE).chain(VIDEO)
{
let (mut s, g) = striped();
// Every color drawn goes through the process-wide color settings. A test that changes them
// runs in a process of its own (`crate::tests::in_own_process`).
let colors = cms::active();
assert_eq!(cms::active_settings(), cms::ColorSettings::default(), "{id}: another test left the color settings changed");
let plain = render(&s);
let params: Value = serde_json::from_str(params).unwrap();
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
let a = render(&s);
assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a));
assert_eq!(a.pixels, render(&s).pixels, "{id} is deterministic");
let again = render(&s);
// The multithreaded pipeline draws the same.
let mut mt = Renderer::new();
mt.threads = 4;
let b = mt.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
assert!(std::sync::Arc::ptr_eq(&colors, &cms::active()), "{id}: another test changed the color settings while this one drew");
assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a));
assert_eq!(a.pixels, again.pixels, "{id} is deterministic");
assert!(mean_diff(&a, &b) < 0.002, "{id}: multithreaded differs by {}", mean_diff(&a, &b));
}
}
+3
View File
@@ -112,6 +112,9 @@ fn a_linked_pngs_bytes_are_embedded() {
#[test]
fn an_embedded_profile_is_restored() {
if !super::in_own_process("tests::saveoptions::an_embedded_profile_is_restored") {
return;
}
let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap();
let icc = cms::icc_bytes(&builtin.name).unwrap();
let name = format!("Studio RGB {}", std::process::id());
+43
View File
@@ -68,6 +68,49 @@ fn type_placed_on_an_artboard_edge_takes_that_artboards_frame() {
}
}
/// `text` as UTF-16 without a byte-order mark, little-endian when `le` is set.
fn utf16(text: &str, le: bool) -> Vec<u8> {
text.encode_utf16().flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() }).collect()
}
/// Unicode text in UTF-16 without a byte-order mark is read as UTF-16 when at least half of its
/// two-byte units hold a NUL byte, in the byte order that reads a line break, or for one line, the
/// order its NUL bytes give. Other bytes keep today's reading.
#[test]
fn utf16_without_a_byte_order_mark_places_by_its_nul_bytes_and_line_breaks() {
let mut s = session();
for le in [true, false] {
let r = place_text(&mut s, &utf16("Hello, 世界 café!\r\nSecond line", le), json!({}));
assert_eq!(placed(&s, &r).0, "Hello, 世界 café!\nSecond line", "little-endian: {le}");
let r = place_text(&mut s, &utf16("One line, 世界", le), json!({}));
assert_eq!(placed(&s, &r).0, "One line, 世界", "little-endian: {le}");
}
// One line with NUL bytes in both places (U+4E00 ends in a zero byte) takes the order more of
// its units give, and a tie is little-endian.
let r = place_text(&mut s, &utf16("一AB", false), json!({}));
assert_eq!(placed(&s, &r).0, "一AB");
let r = place_text(&mut s, &utf16("一A", true), json!({}));
assert_eq!(placed(&s, &r).0, "一A");
// Not read as UTF-16, so read as UTF-8 or else Windows-1252 with NUL bytes dropped: an odd
// length, an unpaired surrogate, a NUL character, and line breaks in both byte orders (U+0A00).
let r = place_text(&mut s, b"A\0B", json!({}));
assert_eq!(placed(&s, &r).0, "AB");
let r = place_text(&mut s, b"\0a\0b\xd8\0", json!({}));
assert_eq!(placed(&s, &r).0, "abØ");
let r = place_text(&mut s, b"\0\0\xc3\xa9", json!({}));
assert_eq!(placed(&s, &r).0, "é");
let r = place_text(&mut s, &utf16("A\u{0A00}\nB", true), json!({}));
assert_eq!(placed(&s, &r).0, "A\n\nB");
// Fewer than half of the two-byte units hold a NUL byte: UTF-8, and Windows-1252.
let r = place_text(&mut s, "Fünf\0 Zeilen!".as_bytes(), json!({}));
assert_eq!(placed(&s, &r).0, "Fünf Zeilen!");
let r = place_text(&mut s, b"caf\xe9\0x", json!({}));
assert_eq!(placed(&s, &r).0, "caféx");
// ANSI reads each byte as a character of the 8-bit set; NUL and other control bytes are dropped.
let r = place_text(&mut s, &utf16("Hi, 世界", true), json!({"text": {"characterSet": "ansi"}}));
assert_eq!(placed(&s, &r).0, "Hi, NLu");
}
#[test]
fn text_files_report_and_queue_with_their_options() {
let mut s = session();
+10
View File
@@ -2201,6 +2201,16 @@ fn datasets_csv_reads_utf16_files_without_a_byte_order_mark() {
let e = import(&mut s, &bytes).unwrap_err().to_string();
assert!(e.contains("CSV has a header but no data rows"), "{e}");
}
// With no line break, the side of each unit that holds the NUL byte gives the byte order, and a
// tie is little-endian.
let order = crate::cmd::utf16_order_without_mark;
assert_eq!(order(&utf16("City", false)), Some(false));
assert_eq!(order(&utf16("City", true)), Some(true));
assert_eq!(order(b"\0aa\0"), Some(true));
assert_eq!(order(&[utf16("City\n", false), vec![0x0A]].concat()), Some(false), "an odd last byte is left out");
// Read big-endian, this one-line file ends in an unpaired surrogate.
let e = import(&mut s, b"\0a\0b\xd8\0").unwrap_err().to_string();
assert!(e.contains("CSV holds NUL bytes"), "{e}");
import(&mut s, &utf16("City\tName\n一二三丁目\tKai\n", true)).unwrap();
assert_eq!(value(&s, "Data Set 1"), text_value("一二三丁目"));
// Most units hold their NUL byte where a big-endian file would (U+4E00); the line breaks
+86
View File
@@ -1115,6 +1115,92 @@ fn lasso_picks_gradient_mesh_points() {
assert_eq!(red, top, "only the points inside the loop are recoloured");
}
/// With gradient mesh points picked, the Fill proxy (and the Color panel that edits it) shows
/// the color a solid fill gives them, not the mesh object's own fill: "?" when the picked
/// points' colors differ, the object's fill again once no point is picked.
#[test]
fn the_fill_proxy_shows_the_picked_mesh_points_color() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff8500"})).unwrap();
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
let object_fill = s.proxy_paints().0;
// Round the top row of points.
gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default());
s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap();
let shown = s.execute("paint.proxies", &json!({})).unwrap();
assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into()), "the picked points' color: {shown}");
assert_eq!(shown["fillMixed"], json!(false));
assert_eq!(shown["fill"], json!(s.proxy_paints().0));
// One picked point takes another color: the points differ.
let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") };
let first = m.idx(0, 0);
s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": first, "color": "#00ff00"})).unwrap();
assert!(s.proxy_mixed().0, "the picked points' colors differ");
// Picking none (the Selection tool) shows the object's fill again.
s.select_tool("selection", ViewInfo::default()).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
assert_eq!(s.proxy_paints().0, object_fill);
assert!(!s.proxy_mixed().0);
}
/// With the Appearance panel's stroke row active, a solid fill still recolors the picked mesh
/// points, so the Fill proxy shows them; with its fill row active, the fill goes to that row, and
/// the proxy shows the row.
#[test]
fn the_fill_proxy_follows_where_the_fill_goes_with_an_appearance_row_active() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff8500"})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
let items = &s.doc().unwrap().doc.node(a).unwrap().appearance.items;
let fill_row = items.iter().position(|it| it.is_fill()).expect("a fill row");
let stroke_row = items.iter().position(|it| !it.is_fill()).expect("a stroke row");
gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default());
assert_eq!(s.fill_mesh_points().first().map(|(_, v)| v.len()), Some(3), "the top row is picked");
let point_color = |s: &Session| {
let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") };
m.points[m.idx(0, 0)].color.to_hex()
};
s.execute("appearance.setActiveItem", &json!({"index": stroke_row})).unwrap();
s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap();
assert_eq!(point_color(&s), "#3366ff", "the stroke row leaves the fill on the picked points");
assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into()));
assert!(!s.proxy_mixed().0);
s.execute("appearance.setActiveItem", &json!({"index": fill_row})).unwrap();
assert!(s.fill_mesh_points().is_empty());
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
assert_eq!(point_color(&s), "#3366ff", "the fill row takes the fill");
assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#00ff00".into()));
}
/// A gradient mesh with points picked selected with another object: a solid fill recolors the
/// points and fills the other object, and the Fill proxy and its "?" count the points' colors.
#[test]
fn the_fill_proxy_counts_picked_mesh_points_beside_other_objects() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let b = rect(&mut s, 300.0, 300.0, 50.0, 50.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default());
s.execute("select.add", &json!({"ids": [b.0]})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]);
assert_eq!(s.fill_mesh_points().first().map(|(id, v)| (*id, v.len())), Some((a, 3)), "the top row is still picked");
s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap();
let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") };
assert_eq!(m.points[m.idx(0, 0)].color.to_hex(), "#3366ff");
assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into()));
assert!(!s.proxy_mixed().0, "the points and the other object are the same color");
s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap();
assert!(s.proxy_mixed().0, "the picked points and the other object's fill differ");
}
/// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are
/// deselected and the others selected, the rest of the selection staying as it was.
#[test]