Arbitrary rotation starts at the ruler line's straightening angle (#660)

* fix(image): Arbitrary rotation starts at the ruler's straightening angle

Fixes #602

* Arbitrary rotation: ignore a non-finite ruler angle (from #659)

Co-Authored-By: Alex Gorevski <alex.gorevski@live.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Alex Gorevski <alex.gorevski@live.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
reese 2026-10-07 15:33:52 -05:00 committed by GitHub
parent 1e1f78325c
commit 9d46c8ea80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 40 additions and 4 deletions

View File

@ -629,13 +629,17 @@ fn inscribed(w: f64, h: f64, a: f64) -> (f64, f64) {
}
}
/// Clockwise rotation in degrees that turns the ruler line onto its nearest axis.
pub fn straighten_angle(r: &Ruler) -> f64 {
let (dx, dy) = (r.end[0] - r.start[0], r.end[1] - r.start[1]);
let a = (-dy).atan2(dx).to_degrees();
a - 90.0 * (a / 90.0).round()
}
fn straighten(s: &mut Session, p: &Value) -> Result<Value> {
let d = doc(s)?;
let r = d.measurement.ruler.ok_or_else(|| EngineError::Other("no ruler line".into()))?;
let (dx, dy) = (r.end[0] - r.start[0], r.end[1] - r.start[1]);
let a = (-dy).atan2(dx).to_degrees();
// Straighten to the nearest axis: rotate clockwise by the remaining angle.
let rot = a - 90.0 * (a / 90.0).round();
let rot = straighten_angle(&r);
if rot.abs() < 1e-9 {
return Ok(json!({"angle": 0.0}));
}

View File

@ -902,6 +902,23 @@ pub(crate) mod tests {
}
}
#[test]
fn arbitrary_rotation_starts_at_the_ruler_angle() {
let (mut app, ctx) = app();
let open = |app: &mut PhotocraftApp| {
let r = crate::menus::invoke(app, &ctx, "image.rotation.arbitrary", json!({})).unwrap();
let f = &app.ui.dialog_mut(r["dialog"].as_u64().unwrap()).unwrap().fields;
(f["angle"].as_f64().unwrap(), f["direction"].clone())
};
assert_eq!(open(&mut app), (0.0, json!("cw")));
// A line falling to the right straightens counter-clockwise, a near-vertical one to the y axis.
for (end, dir) in [([150, 60], "ccw"), ([150, 40], "cw"), ([60, -50], "ccw")] {
app.run("image.analysis.rulerTool", json!({"start": [50, 50], "end": end})).unwrap();
let (a, d) = open(&mut app);
assert!((a - 5.7106).abs() < 1e-3 && d == json!(dir), "{end:?}: {a} {d}");
}
}
#[test]
fn ruler_drag_protractor_and_clear() {
let (mut app, _) = app();

View File

@ -192,6 +192,9 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
};
fields.insert(p.key, v);
}
if command == "image.rotation.arbitrary" {
straighten_defaults(app, &mut fields);
}
if parse_spec(spec.params).iter().any(|p| p.kind == Kind::Document) {
// The document picker lists every open document (params refer to them by index).
let names: Vec<String> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect();
@ -201,6 +204,18 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
Some(id)
}
/// Arbitrary rotation starts at the angle that straightens the ruler line, when there is one.
fn straighten_defaults(app: &PhotocraftApp, fields: &mut Map<String, Value>) {
let Some(r) = app.session.active().and_then(|d| d.doc.measurement.ruler) else { return };
let rot = photocraft_engine::analysis_cmds::straighten_angle(&r);
// A ruler read from a damaged file could hold non-finite ends: keep the 0° default then.
if !rot.is_finite() {
return;
}
fields.insert("angle".into(), json!(rot.abs()));
fields.insert("direction".into(), json!(if rot < 0.0 { "ccw" } else { "cw" }));
}
/// A filter dialog with live preview for `command` whose parameters follow `spec` (registry
/// notation) instead of the command's own; `fixed` params (e.g. a plug-in id) are passed through.
pub fn open_with_spec(app: &mut PhotocraftApp, command: &str, label: &str, spec: &str, fixed: Map<String, Value>) -> u64 {