Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b23a402df8 | ||
|
|
fd52ca32c7 | ||
|
|
0497c9e46d | ||
|
|
cacc9e4f36 | ||
|
|
33b6a6323d |
No files matched your search
+1
-1
@@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). |
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). |
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
||||
|
||||
@@ -292,7 +292,21 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
|
||||
let mut h = Headless::new();
|
||||
let mut out = std::io::stdout().lock();
|
||||
let mut emit = |v: Value| writeln!(out, "{v}").map_err(|e| e.to_string());
|
||||
// A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later
|
||||
// steps (an export…) still run, unprinted.
|
||||
let mut closed = false;
|
||||
let mut emit = |v: Value| {
|
||||
if closed {
|
||||
return Ok(());
|
||||
}
|
||||
match writeln!(out, "{v}") {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
|
||||
closed = true;
|
||||
Ok(())
|
||||
}
|
||||
r => r.map_err(|e| e.to_string()),
|
||||
}
|
||||
};
|
||||
if let Some(path) = &input {
|
||||
let r = h.call("app.open", json!({"path": path})).map_err(|e| format!("open {path}: {e}"))?;
|
||||
emit(json!({"step": "open", "path": path, "result": r}))?;
|
||||
|
||||
@@ -297,3 +297,21 @@ fn closed_stdout_ends_quietly() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
|
||||
}
|
||||
|
||||
/// #1005: `run` whose reader has gone (`vectorcraft-cli run … | true`) ends quietly with status 0,
|
||||
/// and its later steps still run; a failing step still fails.
|
||||
#[test]
|
||||
fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() {
|
||||
let svg = tmp("closed-run.svg");
|
||||
let _ = std::fs::remove_file(&svg);
|
||||
let rect = r#"{"x":1,"y":1,"width":5,"height":5}"#;
|
||||
let args = ["run", "--cmd", "document.json", "--cmd", "shape.rectangle", "--params", rect, "--export", svg.to_str().unwrap()];
|
||||
let mut child = Command::new(BIN).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && err.is_empty(), "{:?}: {err}", out.status);
|
||||
assert!(svg.is_file(), "the export after the closed reader still ran");
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
}
|
||||
@@ -131,10 +131,18 @@ impl Gradient {
|
||||
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
|
||||
})
|
||||
}
|
||||
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
|
||||
/// segment's midpoint mirrored with it.
|
||||
pub fn reverse(&mut self) {
|
||||
self.stops.reverse();
|
||||
for s in &mut self.stops {
|
||||
s.offset = 1.0 - s.offset;
|
||||
for i in 0..self.stops.len() {
|
||||
// A midpoint belongs to the segment after its stop: reversed, stop i's segment is the
|
||||
// one the next stop (not yet mirrored) had, measured from the other end.
|
||||
let mid = self.stops.get(i + 1).map_or(0.5, |n| 1.0 - n.midpoint);
|
||||
if let Some(s) = self.stops.get_mut(i) {
|
||||
s.offset = 1.0 - s.offset;
|
||||
s.midpoint = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn sort(&mut self) {
|
||||
@@ -569,6 +577,32 @@ mod tests {
|
||||
assert_eq!(g.stops[0].offset, 0.0);
|
||||
}
|
||||
|
||||
/// #1007: reversing mirrors off-centre midpoints with their segments, so the ramp is the same
|
||||
/// the other way round.
|
||||
#[test]
|
||||
fn reverse_mirrors_midpoints() {
|
||||
let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
|
||||
let two = Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)] };
|
||||
let three =
|
||||
Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)] };
|
||||
for g in [two, three] {
|
||||
let mut r = g.clone();
|
||||
r.reverse();
|
||||
let mids: Vec<f32> = r.stops.iter().map(|s| s.midpoint).collect();
|
||||
let want: Vec<f32> = g.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).chain([0.5]).collect();
|
||||
assert!(mids.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{mids:?} {want:?}");
|
||||
for i in 0..=20 {
|
||||
let t = i as f32 / 20.0;
|
||||
let ((a, ao), (b, bo)) = (g.sample(t), r.sample(1.0 - t));
|
||||
let same = a.to_rgba8(1.0).iter().zip(b.to_rgba8(1.0)).all(|(x, y)| x.abs_diff(y) <= 1);
|
||||
assert!(same && (ao - bo).abs() < 1e-6, "t {t}: {a:?} vs {b:?}");
|
||||
}
|
||||
r.reverse();
|
||||
let back = r.stops.iter().zip(&g.stops).all(|(a, b)| (a.offset - b.offset).abs() < 1e-6 && (a.midpoint - b.midpoint).abs() < 1e-6);
|
||||
assert!(back, "reversed twice, it is as it was: {:?}", r.stops);
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-9
|
||||
}
|
||||
|
||||
@@ -270,13 +270,8 @@ pub(crate) fn apply_gradient_edit_in(
|
||||
gp.swatch = None;
|
||||
}
|
||||
if bool_or(p, "reverse", false) {
|
||||
// Midpoints mirrored with their segments.
|
||||
gp.gradient.reverse();
|
||||
// Midpoints belong to the segment to the right; mirror them.
|
||||
let n = gp.gradient.stops.len();
|
||||
let mids: Vec<f32> = gp.gradient.stops.iter().map(|s| s.midpoint).collect();
|
||||
for i in 0..n {
|
||||
gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 };
|
||||
}
|
||||
}
|
||||
// Angle and aspect edits keep the focal point in its place in the extent ellipse.
|
||||
let before = gp.geom;
|
||||
|
||||
@@ -870,6 +870,10 @@ fn the_pen_continues_any_open_path_and_joins_another() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::PenJoin);
|
||||
assert_eq!(s.cursor(Point::new(350.0, 200.0), none, v), Cursor::Pen, "the middle of the other path");
|
||||
// #1009: Use Precise Cursors makes the join pointer a crosshair too.
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": true})).unwrap();
|
||||
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::Crosshair);
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap();
|
||||
assert_eq!(gesture(&mut s, &[(300.0, 200.0)], none), 1);
|
||||
let pts = |s: &Session, id| path(s, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|x| (x.p.x, x.p.y))).collect::<Vec<_>>();
|
||||
assert_eq!(pts(&s, a), [(100.0, 100.0), (200.0, 100.0), (300.0, 200.0), (400.0, 200.0)]);
|
||||
|
||||
@@ -56,6 +56,13 @@ fn edit_gradient_angle_reverse_and_errors() {
|
||||
s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap();
|
||||
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
|
||||
assert_eq!(g.gradient.stops[0].color.to_hex(), "#000000");
|
||||
// #1007: an off-centre midpoint is mirrored, not reset.
|
||||
let stops = json!([{"offset": 0, "color": "#ff0000", "midpoint": 0.2}, {"offset": 1, "color": "#0000ff"}]);
|
||||
s.execute("paint.editGradient", &json!({"stroke": false, "stops": stops})).unwrap();
|
||||
s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap();
|
||||
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
|
||||
assert_eq!(g.gradient.stops[0].color.to_hex(), "#0000ff");
|
||||
assert!((g.gradient.stops[0].midpoint - 0.8).abs() < 1e-6, "{:?}", g.gradient.stops);
|
||||
assert!(s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#fff"}]})).is_err());
|
||||
assert!(s.execute("paint.editGradient", &json!({"kind": "conic"})).is_err());
|
||||
}
|
||||
|
||||
@@ -235,8 +235,9 @@ pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec<PathData> {
|
||||
let f = i as f64 / (concentric + 1) as f64;
|
||||
out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f))));
|
||||
}
|
||||
for i in 0..radial.max(1) {
|
||||
let a = -PI / 2.0 + TAU * i as f64 / radial.max(1) as f64;
|
||||
// No dividers at 0 (the loop never divides by it).
|
||||
for i in 0..radial {
|
||||
let a = -PI / 2.0 + TAU * i as f64 / radial as f64;
|
||||
out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin())));
|
||||
}
|
||||
out
|
||||
@@ -402,6 +403,10 @@ mod tests {
|
||||
fn grids() {
|
||||
assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11);
|
||||
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11);
|
||||
// #1008: no radial dividers is none, only the outer ellipse without concentric ones.
|
||||
for (radial, len) in [(0, 1), (1, 2), (2, 3)] {
|
||||
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, radial).len(), len, "{radial}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! repeated (every other period mirrored, for reflect) as many times as the painted area needs,
|
||||
//! and the gradient stretched over those periods.
|
||||
|
||||
use vectorcraft_color::{GradientGeom, GradientKind, GradientStop};
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
/// Most periods a gradient is expanded to.
|
||||
@@ -80,17 +80,9 @@ fn whole_period(stops: &[GradientStop]) -> Vec<GradientStop> {
|
||||
|
||||
/// A period mirrored: the stops in reverse, each midpoint measured from the other side.
|
||||
fn mirror(period: &[GradientStop]) -> Vec<GradientStop> {
|
||||
let rev: Vec<&GradientStop> = period.iter().rev().collect();
|
||||
rev.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| GradientStop {
|
||||
offset: 1.0 - s.offset,
|
||||
// The midpoint from this stop to the next one is the mirror of the one the next stop
|
||||
// had towards this one.
|
||||
midpoint: rev.get(i + 1).map_or(0.5, |next| 1.0 - next.midpoint),
|
||||
..(*s).clone()
|
||||
})
|
||||
.collect()
|
||||
let mut g = Gradient { kind: GradientKind::Linear, stops: period.to_vec() };
|
||||
g.reverse();
|
||||
g.stops
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -485,6 +485,7 @@ impl Cursor {
|
||||
| Cursor::PenDelete
|
||||
| Cursor::PenClose
|
||||
| Cursor::PenContinue
|
||||
| Cursor::PenJoin
|
||||
| Cursor::PenConvert
|
||||
| Cursor::Eyedropper
|
||||
| Cursor::Slice
|
||||
|
||||
@@ -579,6 +579,16 @@ impl VectorcraftApp {
|
||||
return r;
|
||||
}
|
||||
let mut params = params;
|
||||
// Paste in place, in front, in back (#693) and Select › All on Active Artboard (#1006): onto
|
||||
// or on the active artboard, the view's.
|
||||
if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack" | "select.allOnArtboard")
|
||||
&& params.get("artboard").is_none()
|
||||
&& self.view().is_some()
|
||||
&& let Some(n) = self.session.active().map(|d| d.doc.artboards.len())
|
||||
&& let Some(p) = params.as_object_mut()
|
||||
{
|
||||
p.insert("artboard".into(), serde_json::json!(panels::artboards::selected(self, n)));
|
||||
}
|
||||
if id.starts_with("edit.paste") {
|
||||
if let Err(e) = self.adopt_system_clipboard() {
|
||||
self.ui.status = e.clone();
|
||||
@@ -592,14 +602,6 @@ impl VectorcraftApp {
|
||||
{
|
||||
p.insert("center".into(), serde_json::json!([c.x, c.y]));
|
||||
}
|
||||
// In place, in front, in back: onto the active artboard (#693).
|
||||
if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack")
|
||||
&& params.get("artboard").is_none()
|
||||
&& let Some(i) = self.view().map(|v| v.artboard)
|
||||
&& let Some(p) = params.as_object_mut()
|
||||
{
|
||||
p.insert("artboard".into(), serde_json::json!(i));
|
||||
}
|
||||
if let Some(r) = dialogs::swatch_conflict::ask(self, id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -235,6 +235,26 @@ mod tests {
|
||||
crate::menus::invoke(&mut app, "view.goToArtboard", json!({"index": "previous"}));
|
||||
assert_eq!(selected(&app, 2), 0, "and the panel follows the navigator");
|
||||
}
|
||||
|
||||
/// #1006: Select › All on Active Artboard selects the art of the artboard made active here,
|
||||
/// and an explicit artboard still wins.
|
||||
#[test]
|
||||
fn all_on_active_artboard_takes_the_active_artboard() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 2})).unwrap();
|
||||
let mut rect = |x| app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
|
||||
let (a, b) = (rect(10), rect(130));
|
||||
let picked = |app: &mut VectorcraftApp, params| {
|
||||
crate::menus::invoke(app, "select.allOnArtboard", params);
|
||||
let ids: Vec<u64> = app.session.active().unwrap().selection.objects.iter().map(|n| n.0).collect();
|
||||
json!(ids)
|
||||
};
|
||||
select(&mut app, 1);
|
||||
assert_eq!(picked(&mut app, json!({})), json!([b]));
|
||||
select(&mut app, 0);
|
||||
assert_eq!(picked(&mut app, json!({})), json!([a]));
|
||||
assert_eq!(picked(&mut app, json!({"artboard": 1})), json!([b]));
|
||||
}
|
||||
/// Double-clicking a row's number goes to its artboard and its name renames it; a row's
|
||||
/// Options button edits that artboard with the Artboard tool, and the tool's artboard is the
|
||||
/// active one.
|
||||
|
||||
Reference in new issue
Block a user