diff --git a/ROADMAP.md b/ROADMAP.md index 485f7fb7..77a3a3e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). | +| 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 | diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 3f910872..1c2b96c6 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -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 = r.stops.iter().map(|s| s.midpoint).collect(); + let want: Vec = 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 } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 6942edff..aa0a8536 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -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 = 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; diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests_panelcmds.rs index 1844a810..fc2c4e6a 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests_panelcmds.rs @@ -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()); } diff --git a/crates/svg/src/import/spread.rs b/crates/svg/src/import/spread.rs index b88ecf3e..fa4f7015 100644 --- a/crates/svg/src/import/spread.rs +++ b/crates/svg/src/import/spread.rs @@ -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 { /// A period mirrored: the stops in reverse, each midpoint measured from the other side. fn mirror(period: &[GradientStop]) -> Vec { - 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)]