Merge pull request #1203 from FredApps/fix/spiral-decay-setting-20261011

Keep Spiral decay settings consistent with drawn geometry
This commit is contained in:
FredApps
2026-11-14 16:22:35 +01:00
committed by GitHub
2 changed files with 114 additions and 1 deletions
+63
View File
@@ -241,6 +241,69 @@ fn options_are_saved_with_the_preferences() {
assert_eq!(t.prefs.tool_settings["polygon"]["sides"], json!(9));
}
#[test]
fn spiral_decay_is_normalized_for_active_and_inactive_tools_and_preferences() {
let mut s = session();
for active in [false, true] {
select(&mut s, if active { "spiral" } else { "selection" });
let options = s.set_tool_option_cmd(&json!({"tool": "spiral", "values": {"decay": 150, "segments": 4, "clockwise": false}})).unwrap();
assert_eq!(options["decay"], json!(99.99));
assert_eq!(s.tool_options_of("spiral")["decay"], json!(99.99));
assert_eq!(s.prefs.tool_settings["spiral"]["decay"], json!(99.99));
assert_eq!(s.tool_id(), if active { "spiral" } else { "selection" });
select(&mut s, "selection");
select(&mut s, "spiral");
assert_eq!(s.tool_options()["decay"], json!(99.99));
}
let saved = serde_json::to_string(&s.prefs).unwrap();
let mut restored = session();
restored.apply_prefs(serde_json::from_str(&saved).unwrap());
select(&mut restored, "spiral");
assert_eq!(restored.tool_options(), json!({"decay": 99.99, "segments": 4, "clockwise": false}));
}
#[test]
fn legacy_spiral_decay_preferences_report_the_ratio_drawn_after_reopening() {
use vectorcraft_geom::Point;
use vectorcraft_tools::{PointerEvent, PointerKind};
// Older versions saved 100–150, although their geometry already capped the ratio at 0.9999.
for clockwise in [false, true] {
let mut s = session();
let mut prefs = Prefs::default();
prefs.tool_settings.insert("spiral".into(), json!({"decay": 150, "segments": 4, "clockwise": clockwise}).as_object().unwrap().clone());
s.apply_prefs(serde_json::from_str(&serde_json::to_string(&prefs).unwrap()).unwrap());
let view = ViewInfo { smart_guides: false, ..Default::default() };
s.select_tool("spiral", view).unwrap();
let decay = s.tool_options()["decay"].as_f64().unwrap();
assert_eq!(decay, 99.99);
let before = s.doc().unwrap().history.undo.len();
for (kind, x) in [(PointerKind::Down, 200.0), (PointerKind::Drag, 300.0), (PointerKind::Up, 300.0)] {
s.pointer(&PointerEvent::new(kind, x, 200.0), view).unwrap();
}
let d = s.doc().unwrap();
assert_eq!(d.history.undo.len(), before + 1);
assert_eq!(d.selection.objects.len(), 1);
let id = d.selection.objects[0];
let path = d.doc.node(id).unwrap().path_data().unwrap().clone();
let anchors = &path.subpaths[0].anchors;
assert_eq!(anchors.len(), 5);
assert!(!path.is_closed());
let center = Point::new(200.0, 200.0);
// The path runs from the centre end outwards, and decay is per full wind: four quarter-turn
// segments give the decay.
for pair in anchors.windows(2) {
let ratio = pair[0].p.distance(center) / pair[1].p.distance(center);
assert!((100.0 * ratio.powi(4) - decay).abs() < 1e-9);
}
assert_eq!(s.prefs.tool_settings["spiral"]["decay"], json!(99.99));
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_none());
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap(), &path);
}
}
/// Escape on a polygon or rounded rectangle drag puts back the side count or corner radius its
/// arrows changed, in the saved options and the Corner Radius preference too.
#[test]
+51 -1
View File
@@ -477,7 +477,8 @@ impl Tool for FamilyTool {
}
"slope" => self.slope = v.as_f64().filter(|n| n.is_finite()).map_or(self.slope, |n| n.clamp(-100.0, 100.0)),
"fillArc" => self.fill_arc = v.as_bool().unwrap_or(self.fill_arc),
"decay" => self.decay = v.as_f64().unwrap_or(self.decay).clamp(5.0, 150.0),
// Report and persist the decay that shapes::spiral can actually draw.
"decay" => self.decay = v.as_f64().unwrap_or(self.decay).clamp(5.0, 99.99),
"segments" => self.segments = v.as_u64().unwrap_or(10).clamp(2, 1000) as u32,
"clockwise" => self.clockwise = v.as_bool().unwrap_or(self.clockwise),
"rows" | "concentric" => self.rows = v.as_u64().unwrap_or(5).min(999) as u32,
@@ -534,6 +535,55 @@ mod tests {
use crate::testutil::*;
use vectorcraft_doc::Selection;
#[test]
fn spiral_reports_the_decay_its_command_draws() {
let mut t = FamilyTool::new("spiral");
assert_eq!(t.options()["decay"], json!(80.0));
for (requested, expected) in [(-10.0, 5.0), (5.0, 5.0), (80.0, 80.0), (99.99, 99.99), (100.0, 99.99), (150.0, 99.99), (200.0, 99.99)] {
t.set_option("decay", &json!(requested));
assert_eq!(t.options()["decay"], json!(expected));
let (id, params) = t.command(Point::ZERO, Point::new(100.0, 0.0), Mods::default());
assert_eq!(id, "shape.spiral");
assert_eq!(params["decay"], json!(expected));
// Inspect the actual adjacent radii, not a second copy of the tool's clamp. Decay is per full
// wind and a segment is a quarter turn, so four segments give the decay.
let path = vectorcraft_geom::shapes::spiral(Point::ZERO, 100.0, params["decay"].as_f64().unwrap(), 2, true);
let anchors = &path.subpaths[0].anchors;
let outer = anchors[2].p.distance(Point::ZERO);
let next = anchors[1].p.distance(Point::ZERO);
assert!((100.0 * (next / outer).powi(4) - expected).abs() < 1e-9);
}
for invalid in [Value::Null, json!("80"), json!(false), json!({})] {
t.set_option("decay", &invalid);
assert_eq!(t.options()["decay"], json!(99.99));
}
}
#[test]
fn normalized_spiral_decay_preserves_existing_capped_paths_within_roundoff() {
for requested in [100.0, 150.0] {
let mut t = FamilyTool::new("spiral");
t.set_option("decay", &json!(requested));
for clockwise in [false, true] {
for segments in [2, 10, 1000] {
for radius in [1.0, 100.0, 1e6] {
let previous = vectorcraft_geom::shapes::spiral(Point::ZERO, radius, requested, segments, clockwise);
let normalized = vectorcraft_geom::shapes::spiral(Point::ZERO, radius, t.decay, segments, clockwise);
let old = &previous.subpaths[0].anchors;
let new = &normalized.subpaths[0].anchors;
assert_eq!(old.len(), new.len());
for (a, b) in old.iter().zip(new) {
assert_eq!(a.kind, b.kind);
for (p, q) in [(a.p, b.p), (a.h_in, b.h_in), (a.h_out, b.h_out)] {
assert!(p.distance(q) <= 1e-10 * (1.0 + radius), "{requested}% / {segments} / {radius}: {p:?} != {q:?}");
}
}
}
}
}
}
}
#[test]
fn grid_drag_and_arrows() {
let (d, _) = doc_with_rect();