From b2fc6d1fac3a5fddb1fc63180a195d22b7cf2b72 Mon Sep 17 00:00:00 2001 From: "Vitali M." Date: Mon, 12 Oct 2026 14:47:11 -0300 Subject: [PATCH] Keep a zoomed-in profile's hover fill: count the slabs each edge crosses (#336) fill_triangles bounded the slab triangulator by edges x slabs, as if every edge crossed every slab. A ring sampled finely at a close zoom has hundreds of edges and slabs but only four edges in each slab, so the estimate ran a hundred times over the hover highlight's budget and the fill was dropped, leaving only the outline. Count the slabs each edge actually spans instead. Region2::triangulate tested every edge against every slab, so its work was the overestimate. It now sweeps upward with the edges crossing the current slab, giving the same triangles in time proportional to them. Fixes #164 Co-authored-by: Vitali M. <6042958+vitalibr@users.noreply.github.com> --- crates/geom/src/profile.rs | 65 ++++++++++++++++++- crates/ui-egui/src/sketch_display.rs | 17 +++-- crates/ui-egui/src/sketch_display_ui_tests.rs | 34 ++++++++++ 3 files changed, 108 insertions(+), 8 deletions(-) diff --git a/crates/geom/src/profile.rs b/crates/geom/src/profile.rs index 80fe248e..4145c585 100644 --- a/crates/geom/src/profile.rs +++ b/crates/geom/src/profile.rs @@ -527,11 +527,23 @@ impl Region2 { ys.sort_by(f64::total_cmp); ys.dedup_by(|a, b| (*a - *b).abs() < 1e-12); let x_at = |e: &(Vec2, Vec2), y: f64| e.0.x + (y - e.0.y) / (e.1.y - e.0.y) * (e.1.x - e.0.x); + // Sweep upward: an edge joins the slabs once its lower end is passed and leaves after + // its upper end, so each slab sorts only the edges crossing it (a finely sampled ring + // has hundreds of slabs and edges, but four edges in each slab). + edges.sort_by(|a, b| a.0.y.total_cmp(&b.0.y)); + let mut next = 0; + let mut span: Vec<&(Vec2, Vec2)> = Vec::new(); let mut out = Vec::new(); for w in ys.windows(2) { let (y0, y1) = (w[0], w[1]); let ym = (y0 + y1) * 0.5; - let mut span: Vec<&(Vec2, Vec2)> = edges.iter().filter(|e| e.0.y <= ym && e.1.y >= ym).collect(); + while let Some(e) = edges.get(next) + && e.0.y <= ym + { + span.push(e); + next += 1; + } + span.retain(|e| e.1.y >= ym); span.sort_by(|a, b| x_at(a, ym).total_cmp(&x_at(b, ym))); for pair in span.chunks(2) { let [l, r] = pair else { continue }; @@ -630,6 +642,57 @@ mod tests { } } + /// The sweep visits only the edges crossing each slab; it must give the triangles that + /// testing every edge against every slab gives, for a finely sampled ring too (#164). + #[test] + fn triangulation_sweep_matches_every_edge_against_every_slab() { + fn every_edge(r: &Region2, tol: f64) -> Vec<[Vec2; 3]> { + let mut edges = Vec::new(); + for l in std::iter::once(&r.outer).chain(&r.holes) { + let pl = l.polyline(tol); + for i in 0..pl.len() { + let (a, b) = (pl[i], pl[(i + 1) % pl.len()]); + if (a.y - b.y).abs() > 1e-12 { + edges.push(if a.y < b.y { (a, b) } else { (b, a) }); + } + } + } + let mut ys: Vec = edges.iter().flat_map(|(a, b): &(Vec2, Vec2)| [a.y, b.y]).collect(); + ys.sort_by(f64::total_cmp); + ys.dedup_by(|a, b| (*a - *b).abs() < 1e-12); + let x_at = |e: &(Vec2, Vec2), y: f64| e.0.x + (y - e.0.y) / (e.1.y - e.0.y) * (e.1.x - e.0.x); + let mut out = Vec::new(); + for w in ys.windows(2) { + let ym = (w[0] + w[1]) * 0.5; + let mut span: Vec<_> = edges.iter().filter(|e| e.0.y <= ym && e.1.y >= ym).collect(); + span.sort_by(|a, b| x_at(a, ym).total_cmp(&x_at(b, ym))); + for pair in span.chunks(2) { + let [l, r] = pair else { continue }; + let (a, b) = (Vec2::new(x_at(l, w[0]), w[0]), Vec2::new(x_at(r, w[0]), w[0])); + let (c, d) = (Vec2::new(x_at(r, w[1]), w[1]), Vec2::new(x_at(l, w[1]), w[1])); + out.extend([[a, b, c], [a, c, d]]); + } + } + out + } + let comb = Loop2::polygon(&[ + Vec2::new(0.0, 0.0), + Vec2::new(9.0, 0.0), + Vec2::new(9.0, 6.0), + Vec2::new(7.0, 2.0), + Vec2::new(5.0, 7.0), + Vec2::new(3.0, 2.5), + Vec2::new(1.0, 6.0), + ]); + let ring = Region2 { outer: Loop2::circle(Vec2::ZERO, 45.0), holes: vec![Loop2::circle(Vec2::ZERO, 40.0).reversed()] }; + let regions = [Region2 { outer: comb, holes: vec![Loop2::circle(Vec2::new(4.5, 1.2), 0.6).reversed()] }, ring]; + for (r, tol) in regions.iter().zip([1e-3, 1e-4]) { + let tris = r.triangulate(tol); + assert!(tris.len() > 100); + assert_eq!(tris, every_edge(r, tol)); + } + } + #[test] fn triangulated_area() { let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(5.0, 3.0), Vec2::new(0.0, 8.0)]); diff --git a/crates/ui-egui/src/sketch_display.rs b/crates/ui-egui/src/sketch_display.rs index 981c8193..b9eae224 100644 --- a/crates/ui-egui/src/sketch_display.rs +++ b/crates/ui-egui/src/sketch_display.rs @@ -114,23 +114,26 @@ pub fn region_triangles(region: &Region2, plane: &Plane, proj: &Proj, ppp: f32) display_region(region, plane, proj, ppp).map_or_else(Vec::new, |display| fill_triangles(&display, &mut 65_536)) } -/// Bound the slab triangulator's allocation before calling it. Every slab can emit -/// at most one triangle per edge; complex fills are omitted rather than cut into holes. +/// Bound the slab triangulator's allocation before calling it. Every slab emits one +/// triangle per edge crossing it; complex fills are omitted rather than cut into holes. pub fn fill_triangles(display: &Region2, budget: &mut usize) -> Vec<[Vec2; 3]> { - let mut heights = Vec::new(); - let mut edges = 0usize; + let mut spans = Vec::new(); for loop_ in std::iter::once(&display.outer).chain(&display.holes) { for segment in &loop_.segs { let (a, b) = (segment.start(), segment.end()); if (a.y - b.y).abs() > 1e-12 { - edges += 1; - heights.extend([a.y, b.y]); + spans.push((a.y.min(b.y), a.y.max(b.y))); } } } + let mut heights: Vec = spans.iter().flat_map(|&(lo, hi)| [lo, hi]).collect(); heights.sort_by(f64::total_cmp); heights.dedup_by(|a, b| (*a - *b).abs() < 1e-12); - if edges.saturating_mul(heights.len().saturating_sub(1)) > *budget { + // An edge crosses the slabs between its end heights. Counting every edge in every slab + // overestimated a zoomed-in ring a hundredfold and dropped its hover fill (#164). + let slab = |y: f64| heights.partition_point(|h| *h < y - 1e-12); + let crossings = spans.iter().fold(0usize, |n, &(lo, hi)| n.saturating_add(slab(hi).saturating_sub(slab(lo)))); + if crossings > *budget { return Vec::new(); } let triangles = display.triangulate(1.0); diff --git a/crates/ui-egui/src/sketch_display_ui_tests.rs b/crates/ui-egui/src/sketch_display_ui_tests.rs index f20f5727..a654a448 100644 --- a/crates/ui-egui/src/sketch_display_ui_tests.rs +++ b/crates/ui-egui/src/sketch_display_ui_tests.rs @@ -289,3 +289,37 @@ fn pixel_frame_budget_does_not_cache_unbounded_empty_curve_results() { assert!(h.app.viewport.display.lock().unwrap().len() <= 4096); assert_eq!(h.app.session.doc.to_json(), baseline); } + +/// A zoomed-in ring is sampled finely, but each slab of the fill still crosses only its +/// four boundary edges: its fill must fit the hover highlight's budget (#164). +#[test] +fn zoomed_in_ring_fill_fits_the_hover_highlight_budget() { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.ui.perspective = true; + h.app.run("sketch.create", json!({"plane":"XY","name":"Ring"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":40})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":45})).unwrap(); + h.settle(); + h.step(&json!({"call":"ui.view","params":{"view":"iso"}})).unwrap(); + h.step(&json!({"call":"ui.view","params":{"view":"fit"}})).unwrap(); + h.app.cam.zoom_at(0.15, Some(Vec3::new(42.5, 0.0, 0.0))); + h.settle(); + let baseline = h.app.session.doc.to_json(); + let sid = h.app.session.active_sketch.unwrap(); + let state = h.app.session.world_state(); + let ss = state.sketch(sid).unwrap(); + let proj = projection(&h.app, h.app.viewport.rect.unwrap()); + let ppp = h.app.viewport.pixels_per_point(); + let ring = ss.profiles.iter().find(|p| p.region.holes.len() == 1).unwrap(); + let display = crate::sketch_display::display_region(&ring.region, &ss.plane, &proj, ppp).unwrap(); + let edges = std::iter::once(&display.outer).chain(&display.holes).map(|l| l.segs.len()).sum::(); + assert!(edges * edges / 2 > 65_536, "fixture must sample the ring finely enough to exceed the old estimate: {edges} edges"); + let triangles = crate::sketch_display::region_triangles(&ring.region, &ss.plane, &proj, ppp); + assert!(!triangles.is_empty(), "the ring's fill was dropped at this zoom"); + for t in &triangles { + let r = ((t[0] + t[1] + t[2]) / 3.0).len(); + assert!((39.9..=45.1).contains(&r), "fill triangle outside the ring: r = {r}"); + } + assert_eq!(h.app.session.doc.to_json(), baseline); +}