Compare commits

...
Author SHA1 Message Date
bflatastic 0497c9e46d #1007: Reverse Gradient mirrors off-centre midpoints
Reversal read the midpoints after Gradient::reverse had reordered the stops, so each stop
took the wrong segment's midpoint: red-to-blue with its midpoint at 20% reversed to a 50%
midpoint instead of 80%, moving the transition.

Gradient::reverse now mirrors each segment's midpoint with it, so a reversed gradient is the
same ramp the other way round; paint.editGradient's own (wrong) pass is gone, and the SVG
importer's reflect spread, which had a correct copy of the same mirroring, uses it too.

Tests: two- and three-stop gradients with off-centre midpoints sample the same reversed (t vs
1 - t) and come back after two reversals; paint.editGradient reverse turns a 0.2 midpoint into
0.8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:31 +07:00
bflatastic cacc9e4f36 #1006: Select > All on Active Artboard selects on the active artboard
The menu item sends no artboard, so the engine fell back to the first one even with another
active in the Artboards panel.

The frontend fills in the active artboard (the view's, as the Artboards panel and navigator
show it) for select.allOnArtboard, as it already did for Paste in Place / in Front / in Back
(#693); the two now share one block, and the index is kept within the artboards. An explicit
artboard still wins; the engine is unchanged.

The test makes artboard 2 active, then 1, and invokes the menu item each time, then passes
artboard 1 explicitly with artboard 1 active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:27 +07:00
7 changed files with 79 additions and 29 deletions

No files matched your search

+1 -1
View File
@@ -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). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). |
| 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). |
| 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 |
+36 -2
View File
@@ -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
}
+1 -6
View File
@@ -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;
+7
View File
@@ -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());
}
+4 -12
View File
@@ -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)]
+10 -8
View File
@@ -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, &params) {
return r;
}
+20
View File
@@ -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.