mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
#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 <[email protected]> # Conflicts: # ROADMAP.md
This commit is contained in:
1 parent
cacc9e4f36
commit
0497c9e46d
5 files changed
+49
-21
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). `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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user