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); +}