diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index a08e741..30292d5 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -7,6 +7,7 @@ //! cadcraft-cli commands [FILTER] command catalog (JSON) //! cadcraft-cli mcp [--connect HOST:PORT] MCP server on stdio (headless or bridged to the app) //! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing +//! cadcraft-cli perf FILE canvas display-list build of a drawing, zoomed in step by step //! cadcraft-cli sample (bracket|floorplan) OUT write a built-in sample drawing //! ``` #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -60,7 +61,7 @@ const USAGE: &str = "usage: cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg|pdf)] [FRAMING] cadcraft-cli commands [FILTER] cadcraft-cli mcp [--connect HOST:PORT] - cadcraft-cli perf [N] + cadcraft-cli perf [N | FILE] cadcraft-cli sample (bracket|floorplan) OUT.(dxf|dwg|svg|png) cadcraft-cli --version @@ -393,11 +394,55 @@ fn synthetic(n: usize, side: f64) -> Result Result<(), String> { + use cadcraft_engine::geom::{Bounds2, Vec2}; + use cadcraft_engine::render::{Options, build}; + use std::time::Instant; + let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0; + let mut s = Session::empty(); + let t = Instant::now(); + open(&mut s, path)?; + let open_ms = ms(t); + let st = s.state().map_err(|e| e.to_string())?; + let (d, space) = (&st.doc, &st.space); + let ext = d.extents(space); + let size = ext.width().max(ext.height()); + if ext.is_empty() || !(size.is_finite() && size > 0.0) { + return Err("perf: the drawing is empty".into()); + } + outln!("open {open_ms:.0} ms, {} entities, extents {:.1} x {:.1}", d.entity_count(), ext.width(), ext.height()); + outln!("{:>8} {:>10} {:>10}", "zoom", "build ms", "prims"); + let mut finest = Options::default(); + for k in 0..5 { + let zoom = 4f64.powi(k); + let h = size / zoom; + let pixel = 2f64.powi((h / 1000.0).max(1e-300).log2().floor() as i32); + let half = Vec2::new(h * 0.8, h * 0.5); + let visible = Bounds2::new(ext.center() - half, ext.center() + half); + let screen = Options { tolerance: pixel * 0.5, min_dash: pixel * 2.0, view: Some(visible.expand(h * 0.8)), ..Default::default() }; + let t = Instant::now(); + let n = build(d, space, &screen).prims.len(); + outln!("{:>7}x {:>10.1} {n:>10}", zoom, ms(t)); + finest = screen; + } + // Plots: every pattern line, everywhere (last, so its large list doesn't weigh on the above). + let t = Instant::now(); + let n = build(d, space, &Options { min_dash: 0.0, view: None, ..finest }).prims.len(); + outln!("{:>8} {:>10.1} {n:>10}", "full", ms(t)); + Ok(()) +} + fn perf(args: &[String]) -> Result<(), String> { use cadcraft_engine::doc::Space; use cadcraft_engine::geom::{Bounds2, Vec2}; use cadcraft_engine::{select, snap}; use std::time::Instant; + if let Some(path) = args.first().filter(|a| a.parse::().is_err()) { + return perf_file(path); + } let n: usize = match args.first() { Some(a) => a.parse().map_err(|_| format!("perf: bad entity count {a}"))?, None => 200_000, diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 2e08bb5..3e2f514 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -301,7 +301,16 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result { Space::Model => true, }); let plot_style_table = cadcraft_doc::plot_style_table(d, &page.plot_style_table).map(std::sync::Arc::new); - let ropts = cadcraft_render::Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights, view_height: 0.0, plot_style_table }; + let ropts = cadcraft_render::Options { + tolerance: 0.001, + min_dash: 0.0, + text: true, + fill: true, + lineweights, + view_height: 0.0, + plot_style_table, + view: None, + }; let k = unit_mm * PT_PER_MM; let window = o.window.filter(|w| matches!(space, Space::Model) && !w.is_empty()); let fit = o.fit.unwrap_or(window.is_some() || matches!(space, Space::Model)); diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index e9aa54d..cdecb1b 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -1,10 +1,16 @@ //! Hatch drawing: island detection picks the boundary loops that take part, then a solid, //! gradient or background fill (even-odd triangles) and the pattern lines (each pattern line //! family swept across the boundary, even-odd clipped, then dashed). +//! +//! On-screen builds get a level of detail (`Options::min_dash` > 0 is two pixels, `Options::view` +//! the area being looked at): a pattern whose lines run together on screen is drawn as a solid +//! fill of its boundary ([`reads_solid`]), a dash or dot repeat under two pixels as a continuous +//! line (as linetypes already are), and nothing is generated outside the view. Plots, exports and +//! picks pass `min_dash = 0` and `view = None` and get every line. -use super::{Builder, Ctx, Ink, fill, ink}; +use super::{Builder, Ctx, Ink, fill, ink, max_scale}; use cadcraft_color::{Color, Rgb}; -use cadcraft_doc::library::pattern_for; +use cadcraft_doc::library::{PatternLine, pattern_for}; use cadcraft_doc::{Drawing, Entity, Gradient, Hatch}; use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon}; @@ -14,7 +20,12 @@ pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, h: &Hatch, rgb: Ink, // Gradient and background colours given ByLayer follow the hatch's own layer. let layer = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() }; let layer_color = ctx.layer_color(layer).unwrap_or(Color::Index(7)); - if h.solid || h.pattern.eq_ignore_ascii_case("SOLID") || h.gradient.is_some() { + // Level of detail: a pixel in the hatch's own units, along its most stretched axis (so lines + // that show apart in any direction are never merged); 0 = full detail. + let stretch = max_scale(&ctx.xf); + let px = if stretch > 1e-12 { b.opts.min_dash / 2.0 / stretch } else { 0.0 }; + let dense = b.opts.fill && reads_solid(ctx.d, h, px); + if h.solid || h.pattern.eq_ignore_ascii_case("SOLID") || h.gradient.is_some() || dense { if b.opts.fill { let tris = fill::triangulate_evenodd(&loops); match &h.gradient { @@ -29,7 +40,12 @@ pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, h: &Hatch, rgb: Ink, { b.tris(ctx, ink(bg, layer_color, ctx.block_color), &fill::triangulate_evenodd(&loops)); } - for seg in pattern_lines(ctx.d, h, &loops) { + // The area being looked at, in the hatch's units. + let view = b.opts.view.and_then(|v| { + let inv = ctx.xf.inverse()?; + Some(Bounds2::from_points(v.corners().iter().map(|p| inv.apply(*p)))) + }); + for seg in pattern_lines(ctx.d, h, &loops, px, view.as_ref()) { if seg.len() == 1 { if let Some(p) = seg.first() { b.point(ctx, rgb, *p); @@ -208,21 +224,86 @@ impl Field { /// Upper bound on the line families drawn from a pattern definition read from a file. const MAX_PATTERN_LINES: usize = 1024; +/// Upper bound on the runs of one hatch (AutoCAD's HPMAXLINES default), checked as runs are made +/// (a hostile or mis-scaled pattern can't exhaust memory). A view-limited build makes at most the +/// runs of a full build, so it never leaves out runs that a full build draws. +const MAX_RUNS: usize = 1_000_000; +/// Upper bound on the lines one family sweeps over the whole hatch; past it the family is left +/// out (a full-detail build of a pattern far too fine for its area). +const MAX_FAMILY_LINES: i64 = 20_000; +/// The same bound when the sweep is limited to a view: the count then follows the view's size +/// on screen, and lines closer than a pixel already read as solid, so only very large displays +/// come near it. +const MAX_VIEW_FAMILY_LINES: i64 = 100_000; +/// Line families closer than this on screen (pixels) run together: the hatch reads as solid. +pub const SOLID_SPACING_PX: f64 = 1.0; +/// Average marks per pixel from which a pattern reads as solid (scattered marks leave about +/// e^-2 ≈ 14% of the pixels empty there). +pub const SOLID_COVERAGE: f64 = 2.0; -/// Upper bound on line runs drawn for one hatch (AutoCAD's HPMAXLINES default): a pattern far -/// too dense for its area stops here instead of exhausting memory. -const MAX_HATCH_SEGMENTS: usize = 1_000_000; - -/// Hatch line runs in world coordinates (single-point runs are dots) for the fill `loops`. -pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec]) -> Vec> { - // The lines the file defined (as its author's CAD drew them), else the library's pattern of - // that name, else ANSI31. - let lines = if h.pattern_lines.is_empty() { +/// The line families a hatch draws with: the lines its file defined (as its author's CAD drew +/// them), else the library's pattern of that name as drawn in `d`, else ANSI31. +fn lines_of(d: &Drawing, h: &Hatch) -> Vec { + if h.pattern_lines.is_empty() { pattern_for(d, &h.pattern).or_else(|| pattern_for(d, "ANSI31")).map(|p| p.lines).unwrap_or_default() } else { h.pattern_lines.iter().take(MAX_PATTERN_LINES).cloned().collect() - }; - let scale = if h.scale.is_finite() && h.scale > 1e-9 { h.scale } else { 1.0 }; + } +} + +/// The hatch's pattern scale (1 when unusable). +fn scale_of(h: &Hatch) -> f64 { + if h.scale.is_finite() && h.scale > 1e-9 { h.scale } else { 1.0 } +} + +/// True when, at `px` hatch units per pixel, the pattern covers its area on screen, so drawing +/// it line by line would fill (nearly) every pixel anyway. Each line, dash or dot is drawn at +/// least a pixel wide, so that is the case when +/// - some line family has its lines closer than [`SOLID_SPACING_PX`] and no gap along them a +/// pixel or longer (continuous lines, or a repeat that reads as a continuous line), or +/// - the families together put on average [`SOLID_COVERAGE`] or more marks on every pixel +/// (dense dot or dash patterns made of several families). +/// +/// A sparse dot or dash pattern keeps its look. `px <= 0` (full detail) is never solid. +pub fn reads_solid(d: &Drawing, h: &Hatch, px: f64) -> bool { + if !(px.is_finite() && px > 0.0) { + return false; + } + let scale = scale_of(h); + let mut coverage = 0.0; + for f in lines_of(d, h) { + let spacing = (f.delta.1 * scale).abs(); + if !(spacing.is_finite() && spacing > 0.0) { + continue; + } + let pat_len: f64 = f.dashes.iter().map(|d| (d * scale).abs()).sum(); + // The share of a line inked on screen (a dash or dot is at least a pixel long there). + let ink: f64 = f.dashes.iter().filter(|d| **d >= 0.0).map(|d| (d * scale).max(px)).sum(); + let inked = if f.dashes.is_empty() || (ink > 0.0 && pat_len < 2.0 * px) { + 1.0 + } else if pat_len > 0.0 { + (ink / pat_len).min(1.0) + } else { + 0.0 + }; + let gap = f.dashes.iter().filter(|d| **d < 0.0).map(|d| (d * scale).abs()).fold(0.0, f64::max); + let draws = f.dashes.is_empty() || ink > 0.0; + if spacing < SOLID_SPACING_PX * px && draws && (inked >= 1.0 || gap < px) { + return true; + } + coverage += inked * px / spacing; + } + coverage.is_finite() && coverage >= SOLID_COVERAGE +} + +/// Hatch line runs in world coordinates (single-point runs are dots) for the fill `loops`. +/// `px`: a pixel in the hatch's units for the level of detail (a dash or dot repeat under two +/// pixels is one continuous run); 0 = full detail, as plots, exports and picks use. `view`: only +/// the part of the hatch inside it is generated; `None` = all of it. +pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec], px: f64, view: Option<&Bounds2>) -> Vec> { + let lines = lines_of(d, h); + let scale = scale_of(h); + let px = if px.is_finite() && px > 0.0 { px } else { 0.0 }; let mut out = Vec::new(); for fam in &lines { let ang = fam.angle.to_radians() + h.angle; @@ -233,77 +314,118 @@ pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec]) -> Vec = fam.dashes.iter().map(|d| d * scale).collect(); - family(loops, origin, ang, delta, spacing, &dashes, &mut out); - if out.len() >= MAX_HATCH_SEGMENTS { + Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, &mut out); + if out.len() >= MAX_RUNS { break; } } out } -fn family(loops: &[Vec], origin: Vec2, ang: f64, delta: Vec2, spacing: f64, dashes: &[f64], out: &mut Vec>) { - // Work in a frame where the family's lines are horizontal. - let to_local = |p: Vec2| (p - origin).rotate(-ang); - let to_world = |p: Vec2| origin + p.rotate(ang); - let local: Vec> = loops.iter().map(|l| l.iter().map(|p| to_local(*p)).collect()).collect(); - let bb = Bounds2::from_points(local.iter().flatten().copied()); - if bb.is_empty() { - return; +/// One pattern line family, scaled and placed: parallel lines `spacing` apart through `origin` +/// at angle `ang`, each shifted along itself by `delta`'s along-line part from the last. +struct Family<'a> { + origin: Vec2, + ang: f64, + delta: Vec2, + spacing: f64, + dashes: &'a [f64], +} + +impl Family<'_> { + /// Into the frame where the family's lines are horizontal. + fn to_local(&self, p: Vec2) -> Vec2 { + (p - self.origin).rotate(-self.ang) } - let shift_per_line = delta.rotate(-ang).x; - let k0 = (bb.min.y / spacing).floor() as i64; - let k1 = (bb.max.y / spacing).ceil() as i64; - if k1.saturating_sub(k0) > 20_000 { - return; + fn to_world(&self, p: Vec2) -> Vec2 { + self.origin + p.rotate(self.ang) } - let pat_len: f64 = dashes.iter().map(|d| d.abs()).sum(); - for k in k0..=k1 { - if out.len() >= MAX_HATCH_SEGMENTS { + + fn sweep(&self, loops: &[Vec], px: f64, view: Option<&Bounds2>, out: &mut Vec>) { + let local: Vec> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect(); + let bb = Bounds2::from_points(local.iter().flatten().copied()); + if bb.is_empty() { return; } - let y = k as f64 * spacing; - let mut xs: Vec = Vec::new(); - for l in &local { - let n = l.len(); - for i in 0..n { - let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % n)) else { continue }; - if (a.y > y) != (b.y > y) { - xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y)); + // The lines and spans swept: the boundary's extent, cut to the view's (its corners in + // this frame) when there is one. + let (mut lo, mut hi) = (bb.min, bb.max); + if let Some(v) = view { + let vb = Bounds2::from_points(v.corners().iter().map(|p| self.to_local(*p))); + if vb.is_empty() || !vb.intersects(&bb) { + return; + } + lo = Vec2::new(lo.x.max(vb.min.x), lo.y.max(vb.min.y)); + hi = Vec2::new(hi.x.min(vb.max.x), hi.y.min(vb.max.y)); + } + let shift_per_line = self.delta.rotate(-self.ang).x; + let k0 = (lo.y / self.spacing).floor() as i64; + let k1 = (hi.y / self.spacing).ceil() as i64; + let max_lines = if view.is_some() { MAX_VIEW_FAMILY_LINES } else { MAX_FAMILY_LINES }; + if k1.saturating_sub(k0) > max_lines { + return; + } + let pat_len: f64 = self.dashes.iter().map(|d| d.abs()).sum(); + // Level of detail: a whole repeat under two pixels reads as a continuous line (when the + // family draws anything at all). + let draws = self.dashes.iter().any(|d| *d >= 0.0); + let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws); + for k in k0..=k1 { + if out.len() >= MAX_RUNS { + return; + } + let y = k as f64 * self.spacing; + let mut xs: Vec = Vec::new(); + for l in &local { + let n = l.len(); + for i in 0..n { + let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % n)) else { continue }; + if (a.y > y) != (b.y > y) { + xs.push(a.x + (b.x - a.x) * (y - a.y) / (b.y - a.y)); + } + } + } + xs.sort_by(f64::total_cmp); + let offset = shift_per_line * k as f64; + for span in xs.chunks(2) { + let [x0, x1] = span else { continue }; + let (x0, x1) = if view.is_some() { (x0.max(lo.x), x1.min(hi.x)) } else { (*x0, *x1) }; + if view.is_some() && x1 <= x0 { + continue; + } + if solid_line { + out.push(vec![self.to_world(Vec2::new(x0, y)), self.to_world(Vec2::new(x1, y))]); + } else { + self.dash(x0, x1, y, offset, pat_len, out); } } } - xs.sort_by(f64::total_cmp); - let offset = shift_per_line * k as f64; - for span in xs.chunks(2) { - let [x0, x1] = span else { continue }; - if dashes.is_empty() || pat_len < 1e-12 { - out.push(vec![to_world(Vec2::new(*x0, y)), to_world(Vec2::new(*x1, y))]); - continue; - } - // Dash phase anchored at x = offset. - let start_rep = ((x0 - offset) / pat_len).floor(); - let mut x = offset + start_rep * pat_len; - let mut guard = 0; - 'outer: while x < *x1 { - for d in dashes { - guard += 1; - if guard > 100_000 || out.len() >= MAX_HATCH_SEGMENTS { - break 'outer; - } - let len = d.abs(); - if *d > 0.0 { - let a = x.max(*x0); - let b = (x + len).min(*x1); - if b > a { - out.push(vec![to_world(Vec2::new(a, y)), to_world(Vec2::new(b, y))]); - } - } else if *d == 0.0 && x >= *x0 && x <= *x1 { - out.push(vec![to_world(Vec2::new(x, y))]); - } - x += len; - if x >= *x1 { - break 'outer; + } + + /// The dashes and dots of the span `x0..x1` of the line at `y`, phase anchored at x = `offset`. + fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec>) { + let start_rep = ((x0 - offset) / pat_len).floor(); + let mut x = offset + start_rep * pat_len; + let mut guard = 0; + 'outer: while x < x1 { + for d in self.dashes { + guard += 1; + if guard > 100_000 || out.len() >= MAX_RUNS { + break 'outer; + } + let len = d.abs(); + if *d > 0.0 { + let a = x.max(x0); + let b = (x + len).min(x1); + if b > a { + out.push(vec![self.to_world(Vec2::new(a, y)), self.to_world(Vec2::new(b, y))]); } + } else if *d == 0.0 && x >= x0 && x <= x1 { + out.push(vec![self.to_world(Vec2::new(x, y))]); + } + x += len; + if x >= x1 { + break 'outer; } } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a380d61..691c1c9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -185,14 +185,28 @@ pub struct Options { /// Plot style table to draw with. When `None`, [`build`] of a layout whose page setup has /// "Display plot styles" on uses the layout's table; [`build_plot`] uses none. pub plot_style_table: Option>, + /// The area being looked at (world units of the space being built), for on-screen builds: + /// hatch patterns are only generated inside it. `None` = everywhere (plots, exports, picks). + pub view: Option, } impl Default for Options { fn default() -> Self { - Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights: false, view_height: 0.0, plot_style_table: None } + Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights: false, view_height: 0.0, plot_style_table: None, view: None } } } +/// The largest axis scale of a transform's linear part (its largest singular value): a length +/// `l` in the transform's own units measures at most `l * max_scale` after it, in any direction. +/// A uniform scale gives its factor, where [`Mat3::scale_factor`] gives the mean of the axes. +fn max_scale(m: &Mat3) -> f64 { + let (a, b, c, d) = (m.a, m.b, m.c, m.d); + let e = (a * a + b * b + c * c + d * d) / 2.0; + let f = (((a * a + b * b - c * c - d * d) / 2.0).powi(2) + (a * c + b * d).powi(2)).sqrt(); + let s = (e + f).max(0.0).sqrt(); + if s.is_finite() { s } else { 0.0 } +} + /// Graphics state inherited through block references. #[derive(Clone)] struct Ctx<'a> { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index f06f3a4..443b72e 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -385,3 +385,201 @@ fn table_title_is_centred_and_merged() { let b = Bounds2::from_points(text); assert!((b.center().x - 3.0).abs() < 0.05, "{b:?}"); } + +// ---------- hatch level of detail (on-screen builds, issue #544) ---------- + +fn pattern_hatch(pattern: &str, scale: f64, (x0, y0, x1, y1): (f64, f64, f64, f64)) -> Hatch { + let sq = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| PolyVertex::new(Vec2::new(x, y))); + Hatch { + pattern: pattern.into(), + solid: false, + loops: vec![HatchLoop { vertices: sq.to_vec(), outer: true, sources: Vec::new() }], + scale, + angle: 0.0, + associative: false, + style: 0, + elevation: 0.0, + gradient: None, + origin: Vec2::ZERO, + background: None, + pattern_lines: Vec::new(), + } +} + +fn drawing_with(h: Hatch) -> Drawing { + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap(); + d +} + +fn loops_of(h: &Hatch) -> Vec> { + crate::hatch::fill_loops(h, 0.001) +} + +/// On-screen options for a view where one pixel is `px` drawing units. +fn screen(px: f64, view: Option) -> Options { + Options { tolerance: px / 2.0, min_dash: px * 2.0, view, ..Default::default() } +} + +#[test] +fn dense_hatch_on_screen_is_one_solid_fill() { + // ANSI31 at scale 0.01: lines 0.00125 apart over a 4x4 square. On a 1000 px view of it a + // pixel is 0.004, so the lines run together: one solid fill. Plots keep every line. + let d = drawing_with(pattern_hatch("ANSI31", 0.01, (0.0, 0.0, 4.0, 4.0))); + let plot = build(&d, &Space::Model, &Options::default()); + assert!(plot.prims.len() > 4000, "plots keep every line: {}", plot.prims.len()); + let l = build(&d, &Space::Model, &screen(0.004, None)); + assert_eq!(l.prims.len(), 1); + assert_eq!(l.prims[0].kind, Kind::Tris); +} + +#[test] +fn sparse_dots_keep_their_look_when_lines_are_close() { + // Dot rows half a pixel apart but dots five pixels apart along them: not solid colour. + let fam = |dashes: &[f64]| cadcraft_doc::library::PatternLine { angle: 0.0, origin: (0.0, 0.0), delta: (0.0, 0.5), dashes: dashes.to_vec() }; + let sparse = Hatch { pattern_lines: vec![fam(&[0.0, -5.0])], ..pattern_hatch("MYDOTS", 1.0, (0.0, 0.0, 40.0, 40.0)) }; + assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &sparse, 1.0)); + // Dots closer than a pixel along rows closer than a pixel: solid. + let dense = Hatch { pattern_lines: vec![fam(&[0.0, -0.5])], ..sparse.clone() }; + assert!(crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &dense, 1.0)); + // A family with only gaps draws nothing, so it never reads as solid. + let blank = Hatch { pattern_lines: vec![fam(&[-0.5])], ..sparse.clone() }; + assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &blank, 1.0)); + assert!(crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &blank, &loops_of(&blank), 1.0, None).is_empty()); + // Full detail is never solid. + assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &dense, 0.0)); +} + +#[test] +fn scattered_dot_pattern_reads_solid_once_dots_fill_the_pixels() { + // AR-SAND at scale 3/16 (the library pattern is drawn 16x coarser since #643, so this is the + // geometry of the old scale 3): three dot families, no single one dense enough on its own at + // 0.5 per pixel, but together about four dots on every pixel. Zoomed in 4x the dots stand apart. + let h = pattern_hatch("AR-SAND", 3.0 / 16.0, (0.0, 0.0, 100.0, 100.0)); + assert!(crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &h, 0.5)); + assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &h, 0.125)); +} + +#[test] +fn normal_zoom_pattern_is_unchanged() { + // Lines and dash repeats a few pixels long or more: the level of detail changes nothing. + for (pattern, px) in [("ANSI31", 0.05), ("ANSI33", 0.05), ("AR-CONC", 0.02), ("AR-SAND", 0.01), ("DOTS", 0.01)] { + let h = pattern_hatch(pattern, 1.0, (0.0, 0.0, 6.0, 4.0)); + let loops = loops_of(&h); + let full = crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, 0.0, None); + assert!(!full.is_empty(), "{pattern}"); + assert!(!crate::hatch::reads_solid(&cadcraft_doc::Drawing::new_imperial(), &h, px), "{pattern}"); + assert_eq!(crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, px, None), full, "{pattern}"); + // A view covering the whole hatch changes nothing either. + let all = Bounds2::new(Vec2::new(-1.0, -1.0), Vec2::new(7.0, 5.0)); + assert_eq!(crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, px, Some(&all)), full, "{pattern}"); + } +} + +#[test] +fn dash_repeat_under_two_pixels_is_one_run() { + // ANSI33's dashed family repeats every 0.1875: at 0.1 per pixel it reads as a continuous line, + // drawn as one run per span covering the same length as the full-detail span. + let h = pattern_hatch("ANSI33", 1.0, (0.0, 0.0, 6.0, 4.0)); + let loops = loops_of(&h); + let full = crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, 0.0, None); + let lod = crate::hatch::pattern_lines(&cadcraft_doc::Drawing::new_imperial(), &h, &loops, 0.1, None); + assert!(lod.len() < full.len(), "{} vs {}", lod.len(), full.len()); + assert!(lod.iter().all(|r| r.len() == 2)); + // The continuous family: identical runs in both. + let solid_runs = |v: &[Vec]| v.iter().filter(|r| r.len() == 2).count(); + assert!(solid_runs(&lod) > 0 && solid_runs(&full) > solid_runs(&lod)); +} + +#[test] +fn hatch_pattern_only_inside_the_view() { + // A 40x40 ANSI31 hatch looked at through a 2x2 view in a corner, at full pixel detail. + let d = drawing_with(pattern_hatch("ANSI31", 1.0, (0.0, 0.0, 40.0, 40.0))); + let full = build(&d, &Space::Model, &Options::default()); + let far = Some(Bounds2::new(Vec2::new(100.0, 100.0), Vec2::new(110.0, 110.0))); + assert_eq!(build(&d, &Space::Model, &Options { view: far, ..Default::default() }).prims.len(), 0); + let corner = Bounds2::new(Vec2::new(0.0, 0.0), Vec2::new(2.0, 2.0)); + let part = build(&d, &Space::Model, &Options { view: Some(corner), ..Default::default() }); + assert!(!part.prims.is_empty() && part.prims.len() * 10 < full.prims.len(), "{} of {}", part.prims.len(), full.prims.len()); + // Runs are cut in the family's rotated frame: a 2x2 view at 45° reaches 1 unit out, plus one + // line spacing (0.125 × √2 along an axis) from rounding the line range outwards. + let near = corner.expand(1.0 + 0.125 * 2f64.sqrt() + 1e-9); + assert!(part.verts.iter().all(|p| near.contains(*p))); + // Nothing is lost inside the view: the drawn length within an inner box is the same. + let inner = Bounds2::new(Vec2::new(0.2, 0.2), Vec2::new(1.8, 1.8)); + let (a, b) = (length_inside(&full, &inner), length_inside(&part, &inner)); + assert!(a > 1.0 && (a - b).abs() < 1e-9, "{a} vs {b}"); +} + +/// Total length of a display list's polylines inside an axis-aligned box (Liang–Barsky). +fn length_inside(l: &DisplayList, bx: &Bounds2) -> f64 { + let mut total = 0.0; + for p in l.prims.iter().filter(|p| p.kind == Kind::Polyline) { + for w in l.points(p).windows(2) { + let (p0, d) = (w[0], w[1] - w[0]); + let (mut t0, mut t1) = (0.0f64, 1.0f64); + let mut ok = true; + for (pp, q) in [(-d.x, p0.x - bx.min.x), (d.x, bx.max.x - p0.x), (-d.y, p0.y - bx.min.y), (d.y, bx.max.y - p0.y)] { + if pp == 0.0 { + ok &= q >= 0.0; + } else if pp < 0.0 { + t0 = t0.max(q / pp); + } else { + t1 = t1.min(q / pp); + } + } + if ok && t1 > t0 { + total += d.len() * (t1 - t0); + } + } + } + total +} + +#[test] +fn big_hatch_seen_through_a_wide_view_is_drawn() { + // ANSI31 (0.125 apart) over ±1e5 seen through a 3000-unit view: about 34,000 lines in the + // family's rotated frame, past the 20,000-line guard of a whole-hatch sweep, but still drawn + // when the sweep is limited to the view. + let d = drawing_with(pattern_hatch("ANSI31", 1.0, (-1e5, -1e5, 1e5, 1e5))); + let view = Some(Bounds2::new(Vec2::new(-1500.0, -1500.0), Vec2::new(1500.0, 1500.0))); + let l = build(&d, &Space::Model, &Options { view, ..Default::default() }); + assert!(l.prims.len() > 20_000, "{}", l.prims.len()); +} + +#[test] +fn hatch_in_a_squashed_block_keeps_lines_that_show() { + // A block hatch inserted 100x wider than tall: along x its lines are spread far apart on + // screen, so it is drawn with lines, not merged into a fill. + let mut d = Drawing::new_imperial(); + let mut blk = cadcraft_doc::Block::new("B"); + let mut e = cadcraft_doc::Entity::new(cadcraft_doc::Handle(1), EntityKind::Hatch(pattern_hatch("ANSI31", 1.0, (0.0, 0.0, 4.0, 4.0)))); + e.common = Common::default(); + blk.entities.push(e); + d.blocks.insert("B".into(), std::sync::Arc::new(blk)); + let ins = cadcraft_doc::Insert { + block: "B".into(), + insert: Vec3::new(0.0, 0.0, 0.0), + scale: Vec3::new(100.0, 1.0, 1.0), + rotation: 0.0, + attribs: vec![], + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap(); + // One pixel = 1 unit: 0.125 spacing × 100 along x is far more than a pixel. + let l = build(&d, &Space::Model, &screen(1.0, None)); + assert!(l.prims.iter().all(|p| p.kind != Kind::Tris), "merged into a fill"); + assert!(l.prims.len() > 10, "{}", l.prims.len()); +} + +#[test] +fn max_scale_is_the_most_stretched_axis() { + let m = Mat3::scale(1.0, 0.1); + assert!((max_scale(&m) - 1.0).abs() < 1e-12); + let r = Mat3::rotate(0.7).then(Mat3::scale(2.0, 2.0)); + assert!((max_scale(&r) - 2.0).abs() < 1e-12); + assert!((max_scale(&Mat3::IDENTITY) - 1.0).abs() < 1e-12); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b8ae0eb..a13435b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -38,7 +38,10 @@ impl Xf { #[derive(Default)] pub struct CanvasState { pub list: Option, - key: (u64, u64, i32, bool, usize), + key: (u64, u64, i32, i32, bool, usize), + /// The area `list` was built for (hatch patterns exist only inside it); a view leaving it + /// rebuilds the list. + window: Option, /// Bumped whenever `list` is rebuilt (identifies it for [`PrimIndex`]). list_gen: u64, prim_index: PrimIndex, @@ -142,32 +145,58 @@ fn color32(c: Rgb) -> Color32 { Color32::from_rgb(c.0, c.1, c.2) } -fn ensure_list(app: &mut CadApp, px: f64) { +/// Rebuild the display list when needed. `px`: drawing units per pixel in the current view; +/// `views`: every area on screen that shows the list (the current view, other model tiles) with +/// its drawing units per pixel. +fn ensure_list(app: &mut CadApp, px: f64, views: &[(Bounds2, f64)]) { let Ok(st) = app.session.state() else { return }; - // Rebuild when the drawing changes or the zoom moves by more than 2x (tessellation band). - let band = px.max(1e-300).log2().floor() as i32; - let key = (st.uid, st.revision, band, app.session.settings.lwdisplay, std::sync::Arc::as_ptr(&st.doc) as usize); - if app.canvas.list.is_some() && app.canvas.key == key { + // Rebuild when the drawing changes, the zoom moves by more than 2x (tessellation band) or a + // view leaves the area the list was built for. + let band = |px: f64| px.max(1e-300).log2().floor() as i32; + // Hatch level of detail follows the finest view showing the list. + let lod_band = views.iter().map(|v| band(v.1)).fold(band(px), i32::min); + let key = (st.uid, st.revision, band(px), lod_band, app.session.settings.lwdisplay, std::sync::Arc::as_ptr(&st.doc) as usize); + let visible = views.iter().fold(Bounds2::EMPTY, |acc, v| acc.union(&v.0)); + let inside = app.canvas.window.is_none_or(|w| visible.is_empty() || w.contains_box(&visible)); + if app.canvas.list.is_some() && app.canvas.key == key && inside { return; } let t0 = crate::now_ms(); let opts = cadcraft_render::Options { - tolerance: px * 0.5 * 2f64.powi(band) / px.max(1e-300) * px.min(1.0e300), - min_dash: px * 2.0, + tolerance: 2f64.powi(band(px)) * 0.5, + // Two pixels at the band's finest zoom: the list serves every zoom in the band, so its + // level of detail must hold at the finest one. + min_dash: 2f64.powi(lod_band) * 2.0, text: true, fill: true, lineweights: app.session.settings.lwdisplay, view_height: st.paper_view().height, plot_style_table: None, + view: build_window(views), }; - let opts = cadcraft_render::Options { tolerance: 2f64.powi(band) * 0.5, ..opts }; let space = st.space.clone(); app.canvas.list = Some(cadcraft_render::build(&st.doc, &space, &opts)); app.canvas.key = key; + app.canvas.window = opts.view; app.canvas.list_gen = app.canvas.list_gen.wrapping_add(1); app.canvas.build_ms = crate::now_ms() - t0; } +/// The area a display list is built for: each view with half its larger side of margin all +/// round (panning by up to that much doesn't rebuild), joined. `None` (everything) when a view +/// isn't usable. +fn build_window(views: &[(Bounds2, f64)]) -> Option { + let mut w = Bounds2::EMPTY; + for (v, _) in views { + let side = v.width().max(v.height()); + if v.is_empty() || !side.is_finite() { + return None; + } + w = w.union(&v.expand(side * 0.5)); + } + (!w.is_empty()).then_some(w) +} + /// Grid spacing adapted to the zoom: multiply the unit until lines are at least `min_px` apart. fn adaptive(unit: f64, scale: f64, min_px: f64) -> f64 { let mut u = unit.max(1e-12); @@ -330,7 +359,7 @@ fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdis let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - (c.key, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h); + (c.key, c.list_gen, origin.x.to_bits(), origin.y.to_bits(), bg.0, bg.1, bg.2, lwdisplay).hash(&mut h); h.finish() }; if c.mesh_key != Some(key) { @@ -852,7 +881,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { // ---------- paint ---------- painter.rect_filled(rect, 0.0, t.canvas); let t0 = crate::now_ms(); - ensure_list(app, 1.0 / xf_paper.scale); + let mut views = vec![(xf_paper.world_bounds(), 1.0 / xf_paper.scale)]; + views.extend(tiles.iter().filter(|(r, tile)| !tile.active && r.width() >= 1.0 && r.height() >= 1.0).map(|(r, tile)| { + let xf = tile_xf(*r, tile); + (xf.world_bounds(), 1.0 / xf.scale) + })); + ensure_list(app, 1.0 / xf_paper.scale, &views); let sheet = app.canvas.list.as_ref().and_then(|l| l.sheet); let bg = match sheet { Some(sh) => { @@ -1056,6 +1090,11 @@ fn tile_rects(app: &CadApp, full: Rect) -> Vec<(Rect, cadcraft_engine::ModelTile .collect() } +/// The world ↔ screen transform of a model tile drawn in `r`. +fn tile_xf(r: Rect, tile: &cadcraft_engine::ModelTile) -> Xf { + Xf { rect: r, center: tile.view.center, scale: f64::from(r.height()) / tile.view.height.max(1e-12) } +} + /// The tiles other than the current one (each with its own view, clipped to it), then the /// tile borders: thin, and heavy around the current tile. fn draw_tiles(app: &CadApp, ui: &egui::Ui, tiles: &[(Rect, cadcraft_engine::ModelTile)]) { @@ -1063,7 +1102,7 @@ fn draw_tiles(app: &CadApp, ui: &egui::Ui, tiles: &[(Rect, cadcraft_engine::Mode let bg = Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b()); for (r, tile) in tiles.iter().filter(|(r, tile)| !tile.active && r.width() >= 1.0 && r.height() >= 1.0) { let p = ui.painter_at(*r); - let xf = Xf { rect: *r, center: tile.view.center, scale: f64::from(r.height()) / tile.view.height.max(1e-12) }; + let xf = tile_xf(*r, tile); p.rect_filled(*r, 0.0, t.canvas); draw_grid(app, &p, &xf); if let Some(list) = &app.canvas.list {