From 94141eca828cd55f463be913d9400c756fba7e3f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 06:57:15 +0700 Subject: [PATCH] Fix #826: rulers cover their length in a rotated view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Rotate View, each ruler measured document x (or y) along a line that crossed both axes: its ticks covered only a short stretch and the numbers bunched up. Each ruler now measures along its own edge in the canvas's coordinates turned with the view, so it is ticked over its whole length at every angle, and reads x and y as before at 0°. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 109 ++++++++++++++++++++++++----------- 1 file changed, 74 insertions(+), 35 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d59de728..2c508553 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1081,47 +1081,46 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, unit: Unit, let step = unit.ruler_step(xf.zoom); let minor = step / 10.0; let font = egui::FontId::proportional(9.5); - let a = xf.to_doc(top.left_top()); - let b = xf.to_doc(top.right_top()); - let clip_top = p.with_clip_rect(top); - let mut x = (a.x / per / minor).floor() * minor; - while x * per <= b.x { - let sx = xf.to_screen(Point::new(x * per, 0.0)).x; - let is_major = ((x / step).round() * step - x).abs() < minor * 0.01; - let is_mid = ((x / (step / 2.0)).round() * (step / 2.0) - x).abs() < minor * 0.01; - let len = if is_major { + let near = |v: f64, every: f64| ((v / every).round() * every - v).abs() < minor * 0.01; + // A tick's length: the labelled ones longest, the halves between them shorter. + let tick = |v: f64| { + if near(v, step) { RULER - } else if is_mid { + } else if near(v, step / 2.0) { 7.0 } else { 4.0 - }; - clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick)); - if is_major { + } + }; + // Each ruler measures along its own edge, in the canvas's coordinates turned with the view + // (#826): at 0° the top one reads x and the left one y. `draw` gets each tick's distance + // (screen points) from `from` along the ruler, and its value in `unit`. + let ticks = |from: Pos2, to: Pos2, axis: vectorcraft_geom::Vec2, draw: &mut dyn FnMut(f32, f64)| { + let along = |s: Pos2| xf.to_doc(s).to_vec2().dot(axis); + let (a, b) = (along(from), along(to)); + let mut v = (a / per / minor).floor() * minor; + while v * per <= b { + draw(((v * per - a) * xf.zoom) as f32, v); + v += minor; + } + }; + // The canvas's directions along the screen's x and y. + let (sn, cs) = xf.rot.sin_cos(); + let clip_top = p.with_clip_rect(top); + ticks(top.left_top(), top.right_top(), vectorcraft_geom::Vec2::new(cs, -sn), &mut |d, x| { + let sx = top.left() + d; + clip_top.line_segment([pos2(sx, top.bottom() - tick(x)), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick)); + if near(x, step) { clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, ruler_label(x, step), font.clone(), t.ruler_tick); } - x += minor; - } - let a = xf.to_doc(left.left_top()); - let b = xf.to_doc(left.left_bottom()); + }); let clip_left = p.with_clip_rect(left); - let mut y = (a.y / per / minor).floor() * minor; - while y * per <= b.y { - let sy = xf.to_screen(Point::new(0.0, y * per)).y; - let is_major = ((y / step).round() * step - y).abs() < minor * 0.01; - let is_mid = ((y / (step / 2.0)).round() * (step / 2.0) - y).abs() < minor * 0.01; - let len = if is_major { - RULER - } else if is_mid { - 7.0 - } else { - 4.0 - }; - clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick)); - if is_major { + ticks(left.left_top(), left.left_bottom(), vectorcraft_geom::Vec2::new(sn, cs), &mut |d, y| { + let sy = left.top() + d; + clip_left.line_segment([pos2(left.right() - tick(y), sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick)); + if near(y, step) { // Vertical labels read top-to-bottom, one digit per line like Illustrator. - let s = ruler_label(y, step); - for (k, ch) in s.chars().enumerate() { + for (k, ch) in ruler_label(y, step).chars().enumerate() { clip_left.text( pos2(left.left() + 4.0, sy + 2.0 + k as f32 * 8.5), egui::Align2::LEFT_TOP, @@ -1131,8 +1130,7 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, unit: Unit, ); } } - y += minor; - } + }); if let Some(h) = hover { let s = xf.to_screen(h); clip_top.line_segment([pos2(s.x, top.top()), pos2(s.x, top.bottom())], Stroke::new(1.0, t.text)); @@ -2053,6 +2051,47 @@ mod tests { use vectorcraft_engine::Session; use vectorcraft_geom::Shape as _; + /// #826: in a turned view each ruler still has ticks along its whole length; at 0° the top one + /// reads x where the canvas has it. + #[test] + fn rulers_cover_their_length_at_any_rotation() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let full = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let [top, left, _] = ruler_rects(full); + for deg in [0.0_f64, 30.0, 90.0, 135.0, 200.0] { + let xf = Xf { rect: full, zoom: 1.0, center: Point::new(300.0, 200.0), rot: deg.to_radians() }; + let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, Unit::Points, &Tokens::get(ui.ctx()))); + out.textures_delta.clear(); + // The ticks' positions along each ruler. + let along = |ruler: egui::Rect, x: bool| -> Vec { + let mut at: Vec = out + .shapes + .iter() + .filter(|c| c.clip_rect == ruler) + .filter_map(|c| match &c.shape { + Shape::LineSegment { points: [a, b], .. } if x && a.x == b.x => Some(a.x), + Shape::LineSegment { points: [a, b], .. } if !x && a.y == b.y => Some(a.y), + _ => None, + }) + .collect(); + at.sort_by(f32::total_cmp); + at + }; + for (ruler, x) in [(top, true), (left, false)] { + let at = along(ruler, x); + let (start, end) = if x { (ruler.left(), ruler.right()) } else { (ruler.top(), ruler.bottom()) }; + let (Some(first), Some(last)) = (at.first(), at.last()) else { panic!("{deg}°: no ticks") }; + assert!(first - start < 20.0 && end - last < 20.0, "{deg}°: ticks from {first} to {last} on {start}..{end}"); + assert!(at.windows(2).all(|w| w[1] - w[0] < 20.0), "{deg}°: a gap in the ticks"); + } + if deg == 0.0 { + let origin = xf.to_screen(Point::ZERO).x; + assert!(along(top, true).iter().any(|x| (x - origin).abs() < 0.01), "a tick at x = 0"); + } + } + } + #[test] fn shaper_selection_highlights_only_the_visible_result() { let mut s = Session::new();