Render: level of detail and view clipping for pattern hatches on screen (fixes #544) (#638)

Squash of PR #638 by @STE1200.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
STE1200andClaude Opus 5.5 authored and GitHub committed 2026-10-11 20:10:37 -04:00
1 parent 135c928fea
commit 244feb6e89
6 files changed
+514 -87

No files matched your search

+46 -1
View File
@@ -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<cadcraft_engine::doc::Drawing, Strin
Ok(dr)
}
/// `perf FILE`: the display list built as the canvas builds it (a 1600 x 1000 px view at the
/// drawing's centre: tolerance, dash level of detail and view window from the zoom band), at
/// zoom extents and zoomed in 4x at a time, next to the full-detail build plots use.
fn perf_file(path: &str) -> 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::<usize>().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,
+10 -1
View File
@@ -301,7 +301,16 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<SheetPage> {
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));
+194 -72
View File
@@ -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<Vec2>]) -> Vec<Vec<Vec2>> {
// 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<PatternLine> {
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<Vec2>], px: f64, view: Option<&Bounds2>) -> Vec<Vec<Vec2>> {
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<Vec2>]) -> Vec<Vec<Vec
continue;
}
let dashes: Vec<f64> = 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<Vec2>], origin: Vec2, ang: f64, delta: Vec2, spacing: f64, dashes: &[f64], out: &mut Vec<Vec<Vec2>>) {
// 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<Vec<Vec2>> = 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<Vec2>], px: f64, view: Option<&Bounds2>, out: &mut Vec<Vec<Vec2>>) {
let local: Vec<Vec<Vec2>> = 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<f64> = 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<f64> = 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<Vec<Vec2>>) {
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;
}
}
}
+15 -1
View File
@@ -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<std::sync::Arc<cadcraft_doc::PlotStyleTable>>,
/// 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<Bounds2>,
}
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> {
+198
View File
@@ -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<Vec<Vec2>> {
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<Bounds2>) -> 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<Vec2>]| 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);
}
+51 -12
View File
@@ -38,7 +38,10 @@ impl Xf {
#[derive(Default)]
pub struct CanvasState {
pub list: Option<DisplayList>,
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<Bounds2>,
/// 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<Bounds2> {
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 {