fix(overlay): clip dashed snap/track guides to the viewport

Same hazard as the DYN guide in 48a43aa, in the three remaining dashed strokes:
a dash pattern is measured in pixels, so a path costs the tessellator a quad
every few pixels and its price is set by screen length. All three run from a
projected world point — an entity endpoint for the extension guides, the
acquired point for the OTRACK alignment line — which lands millions of pixels
away for a far or very large entity. That is millions of quads, and the process
fills RAM (#406).

The alignment line even said "the canvas clips it to the viewport" as the reason
extending it by 5000 px was safe. It isn't: the canvas clips after tessellating,
so every quad is built first. That assumption is what made these look bounded.

Clip in `clip_seg` (Liang–Barsky) before stroking, padded a dash period so a
guide still visibly leaves the frame. Unit-tested: a guide from 5,000,000 px
away comes back under 2000 px with its direction and its on-screen end intact,
a fully visible segment is untouched, and one that misses is dropped.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-17 13:52:29 +03:00
1 parent 48a43aae11
commit 72d471c2e7
1 file changed
+106 -6
+106 -6
View File
@@ -69,6 +69,52 @@ pub struct GridParams {
/// Returns the smallest power-of-5 multiple of 1.0 that places grid lines at
/// least `MIN_GRID_PX` pixels apart. This matches exactly what `draw_grid`
/// renders, so callers can sync snap spacing to the visible grid.
/// Clip the screen segment `p0`→`p1` to `bounds` (Liang–Barsky), returning the
/// visible part, or `None` when it misses entirely.
///
/// Use this before stroking anything **dashed**. A dash pattern is measured in
/// pixels, so the tessellator emits a quad every few pixels and a path's cost
/// scales with its screen length. Guides are built from projected world points,
/// which land millions of pixels away for a far or huge entity — millions of
/// quads, gigabytes, and the process dies (#406). It is tempting to assume the
/// canvas clips this for us; it does not — clipping happens after tessellation,
/// so the quads are all built first. Clip up front instead.
///
/// The box is padded by one dash period's worth so a guide still visibly runs
/// off the edge rather than stopping flush against it.
fn clip_seg(p0: Point, p1: Point, bounds: iced::Rectangle) -> Option<(Point, Point)> {
const PAD: f32 = 16.0;
let (xmin, ymin) = (-PAD, -PAD);
let (xmax, ymax) = (bounds.width + PAD, bounds.height + PAD);
let (dx, dy) = (p1.x - p0.x, p1.y - p0.y);
let (mut t0, mut t1) = (0.0f32, 1.0f32);
for (p, q) in [(-dx, p0.x - xmin), (dx, xmax - p0.x), (-dy, p0.y - ymin), (dy, ymax - p0.y)] {
if p == 0.0 {
// Parallel to this edge: outside it means the whole segment is out.
if q < 0.0 {
return None;
}
continue;
}
let r = q / p;
if p < 0.0 {
if r > t1 {
return None;
}
t0 = t0.max(r);
} else {
if r < t0 {
return None;
}
t1 = t1.min(r);
}
}
Some((
Point::new(p0.x + t0 * dx, p0.y + t0 * dy),
Point::new(p0.x + t1 * dx, p0.y + t1 * dy),
))
}
pub fn compute_grid_step(view_rot: Mat4, bounds: iced::Rectangle) -> f32 {
use glam::Vec3;
// Only the per-unit screen scale is needed; project small eye-relative
@@ -736,7 +782,11 @@ impl canvas::Program<Message> for SelectionCanvas {
sp.x + dx / len * 18.0,
sp.y + dy / len * 18.0,
);
frame.stroke(&canvas::Path::line(base, tip), dash);
// `base` is a projected entity endpoint: clip before
// stroking (see `clip_seg`).
if let Some((a, b)) = clip_seg(base, tip, bounds) {
frame.stroke(&canvas::Path::line(a, b), dash);
}
}
}
let r = 5.0_f32;
@@ -847,7 +897,11 @@ impl canvas::Program<Message> for SelectionCanvas {
..canvas::Stroke::default().with_color(marker).with_width(1.0)
};
let tip = Point::new(sp.x + u.x * 18.0, sp.y + u.y * 18.0);
frame.stroke(&canvas::Path::line(base, tip), dash);
// `base` is a projected entity endpoint: clip before
// stroking (see `clip_seg`).
if let Some((a, b)) = clip_seg(base, tip, bounds) {
frame.stroke(&canvas::Path::line(a, b), dash);
}
}
// Three dots at the snap point, strung along the extension
// direction (horizontal fallback when the base is unknown).
@@ -1021,9 +1075,11 @@ impl canvas::Program<Message> for SelectionCanvas {
let len = (dx * dx + dy * dy).sqrt();
if len > 1e-3 {
// Extend the alignment path well past both ends along its
// direction (the canvas clips it to the viewport) so it reads
// as a full construction line through the acquired point, not a
// stub between the corner and the cursor. (#219)
// direction so it reads as a full construction line through the
// acquired point, not a stub between the corner and the cursor
// (#219), then clip it to the viewport ourselves: the canvas
// clips only after tessellating, which is too late for a dash
// pattern (see `clip_seg`).
let (ux, uy) = (dx / len, dy / len);
const L: f32 = 5000.0;
let p0 = Point::new(base.x - ux * L, base.y - uy * L);
@@ -1037,7 +1093,9 @@ impl canvas::Program<Message> for SelectionCanvas {
.with_color(track_color)
.with_width(1.0)
};
frame.stroke(&canvas::Path::line(p0, p1), dash);
if let Some((a, b)) = clip_seg(p0, p1, bounds) {
frame.stroke(&canvas::Path::line(a, b), dash);
}
}
}
// The acquired Parallel-snap reference — a small ∥ glyph on its line so
@@ -2065,3 +2123,45 @@ impl canvas::Program<Message> for DynInputCanvas {
vec![frame.into_geometry()]
}
}
#[cfg(test)]
mod clip_tests {
use super::*;
fn b() -> iced::Rectangle {
iced::Rectangle { x: 0.0, y: 0.0, width: 800.0, height: 600.0 }
}
/// The #406 shape: a guide from a point projected millions of pixels away,
/// back to the cursor. Must survive, keep its direction, and come back short.
#[test]
fn clips_a_far_guide_to_a_drawable_length() {
let far = Point::new(-5_000_000.0, -1_500_000.0);
let cur = Point::new(400.0, 300.0);
let (a, c) = clip_seg(far, cur, b()).expect("crosses the viewport");
let len = ((c.x - a.x).powi(2) + (c.y - a.y).powi(2)).sqrt();
assert!(len < 2000.0, "clipped guide still {len} px long");
// Same direction as the original. Compare unit vectors, not a cross
// product: at these magnitudes (800 x 1.5e6) an f32 cross carries ~1e2
// of rounding, so only a scale-free check means anything.
let unit = |dx: f32, dy: f32| {
let l = (dx * dx + dy * dy).sqrt();
(dx / l, dy / l)
};
let (ux, uy) = unit(cur.x - far.x, cur.y - far.y);
let (vx, vy) = unit(c.x - a.x, c.y - a.y);
assert!(
(ux - vx).abs() < 1e-3 && (uy - vy).abs() < 1e-3,
"clip changed the direction"
);
// The end inside the viewport is kept as-is.
assert!((c.x - cur.x).abs() < 0.5 && (c.y - cur.y).abs() < 0.5);
}
#[test]
fn keeps_a_fully_visible_segment_and_drops_a_missing_one() {
let (a, c) = clip_seg(Point::new(10.0, 10.0), Point::new(700.0, 500.0), b()).unwrap();
assert!((a.x - 10.0).abs() < 0.01 && (c.x - 700.0).abs() < 0.01);
assert!(clip_seg(Point::new(-9000.0, -9000.0), Point::new(-8000.0, -8000.0), b()).is_none());
}
}