Compare commits

...
Author SHA1 Message Date
skylar taylor-barrick 68fbf2a58d #618: test that OFFSET takes a clicked distance and picks its source under grid snap
OFFSET only accepted a typed distance, and with grid snap on a click meant to pick the source object was moved to the nearest grid point.

The engine test clicks two points for the distance and checks the second-point prompt and its base, the guide line in the preview, OFFSETDIST and the resulting offset. The canvas test does the same through grid-snapped clicks, then picks the source line and checks the side click puts the copy 5 units up, that OFFSET asks for the next object and that Enter ends it. Both live in the per-crate integration-test binary (#1989).

Without the fix the engine and canvas tests stay at the distance prompt; with only the cursor's point-prompt test reverted the canvas test never picks the line.
2026-11-15 05:59:54 +01:00
skylar taylor-barrick ee25f48c7b #651: test that small filled text is simplified on screen and keeps full detail in plots
Filled TrueType text was triangulated at full outline detail at every zoom, which froze the canvas on pages of small text.

The test draws four O glyphs in a font built in the test itself (a 128-point ring registered through `cadcraft_fonts::ttf::register`), so it needs no installed font and no font file in the repository, and it never skips. At 10 px per cap height the rings are simplified and the shapes keep their extent; with an em under two pixels each glyph is the ink's box, two triangles; a plot keeps every point. Without the fix the on-screen build has the plot's 1024 triangles.

`crates/render/src/text.rs` gains the same policy without any font: `detail` returns Full for a plot, Box for an em under two pixels and Reduced for a small one, and `simplify` drops the points of a flattened curve at a coarse band and keeps them at a fine one.
2026-11-15 05:59:54 +01:00
skylar taylor-barrick a6981415bf #1336: test that the DXF LAYER table stops at the record cap
The LAYER, LTYPE, STYLE and DIMSTYLE tables had no record cap, unlike VIEW, UCS and VPORT. The test reads a DXF with over a million LAYER records and checks the layer list stays at the cap; without the guard it holds every record (1000102 layers). The other three tables scan their lists linearly per record, so a million-record file is too slow to build in a test and they stay uncovered.
2026-11-15 05:53:20 +01:00
6 changed files with 333 additions and 0 deletions
+23
View File
@@ -1,7 +1,9 @@
//! OFFSET Erase/Layer/Multiple/Undo and the MOVE/COPY Displacement option at the command line.
use cadcraft_engine::Input;
use cadcraft_engine::Session;
use cadcraft_engine::doc::EntityKind;
use cadcraft_engine::geom::Vec2;
use serde_json::json;
/// Every line as ((ax, ay), (bx, by), layer), sorted by the first y.
@@ -62,6 +64,27 @@ fn offset_multiple_and_undo() {
assert_eq!(lines(&s), vec![l((0.0, 0.0), (10.0, 0.0), "0"), l((0.0, 2.0), (10.0, 2.0), "0"), l((0.0, 4.0), (10.0, 4.0), "0")]);
}
// Regression test for #618: OFFSET's distance prompt took only a typed number; clicking two points
// (the distance between them) was rejected, and the second click had no distance guide.
#[test]
fn offset_distance_from_two_clicked_points() {
let mut s = one_line();
s.start("offset").unwrap();
s.input(Input::Point(Vec2::new(20.0, 20.0))).unwrap();
let p = s.current_prompt().unwrap();
assert!(p.message.contains("second point"), "{}", s.prompt_text());
assert!(p.base.is_some_and(|b| b.near(Vec2::new(20.0, 20.0), 1e-9)), "the second click is measured from the first");
// The guide from the first click to the cursor is the previewed distance.
let guide = s.preview(Vec2::new(20.0, 22.5));
assert!(matches!(guide.first().map(|e| &e.kind), Some(EntityKind::Line(g)) if g.a.xy().near(Vec2::new(20.0, 20.0), 1e-9)), "{guide:?}");
s.input(Input::Point(Vec2::new(20.0, 22.5))).unwrap();
assert!(s.current_prompt().unwrap().message.contains("Select object"), "{}", s.prompt_text());
assert_eq!(s.doc().unwrap().header.f64("OFFSETDIST", -1.0), 2.5);
s.script("5,0 5,1\n\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(lines(&s), vec![l((0.0, 0.0), (10.0, 0.0), "0"), l((0.0, 2.5), (10.0, 2.5), "0")]);
}
#[test]
fn offset_json_erase_and_layer() {
let mut s = one_line();
+16
View File
@@ -422,6 +422,22 @@ fn hostile_dxf_does_not_panic() {
}
}
// Regression test for #1336: the LAYER table of a DXF had no record cap, so a hostile file could
// grow the layer list without bound (its VIEW/UCS/VPORT peers stop at one million records).
#[test]
fn dxf_layer_table_stops_at_the_record_cap() {
use std::fmt::Write as _;
let records = 1_000_100;
let mut text = String::from("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nLAYER\n");
for i in 0..records {
let _ = write!(text, "0\nLAYER\n2\nL{i}\n");
}
text.push_str("0\nENDTAB\n0\nENDSEC\n0\nEOF\n");
let d = read(text.as_bytes(), "layers.dxf").unwrap();
assert!(d.layers.len() <= 1_000_000, "{} layers read from {records} records", d.layers.len());
assert!(d.layers.len() > 900_000, "the records below the cap are kept: {}", d.layers.len());
}
#[test]
fn svg_and_png_export() {
let d = sample();
+182
View File
@@ -277,6 +277,163 @@ fn system_font() -> Option<&'static str> {
["Arial", "Helvetica", "DejaVuSans", "Verdana", "LiberationSans-Regular"].into_iter().find(|n| cadcraft_fonts::ttf::find(n).is_some())
}
// ---------- a fixture TrueType font, made here: no font file, no installed font ----------
fn be16(v: &mut Vec<u8>, x: u16) {
v.extend_from_slice(&x.to_be_bytes());
}
fn be32(v: &mut Vec<u8>, x: u32) {
v.extend_from_slice(&x.to_be_bytes());
}
/// One simple glyph: closed contours of on-curve points, with the box the file records for it.
fn simple_glyph(contours: &[Vec<(i16, i16)>]) -> Vec<u8> {
let points: Vec<(i16, i16)> = contours.iter().flatten().copied().collect();
let mut g = Vec::new();
be16(&mut g, contours.len() as u16);
let box_ = points.iter().fold((i16::MAX, i16::MAX, i16::MIN, i16::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)));
for v in [box_.0, box_.1, box_.2, box_.3] {
be16(&mut g, v as u16);
}
let mut end = 0u16;
for c in contours {
end = end.saturating_add(c.len() as u16);
be16(&mut g, end.saturating_sub(1));
}
be16(&mut g, 0);
g.extend(std::iter::repeat_n(1u8, points.len()));
let (mut px, mut py) = (0i16, 0i16);
for (x, _) in &points {
be16(&mut g, x.wrapping_sub(px) as u16);
px = *x;
}
for (_, y) in &points {
be16(&mut g, y.wrapping_sub(py) as u16);
py = *y;
}
while g.len() % 4 != 0 {
g.push(0);
}
g
}
/// A closed ellipse round (500, 350), wound by `clockwise`: one contour of a glyph outline.
fn ring(rx: f64, ry: f64, points: usize, clockwise: bool) -> Vec<(i16, i16)> {
let dir = if clockwise { -1.0 } else { 1.0 };
(0..points)
.map(|i| {
(
(500.0 + rx * (dir * std::f64::consts::TAU * i as f64 / points as f64).cos()) as i16,
(350.0 + ry * (dir * std::f64::consts::TAU * i as f64 / points as f64).sin()) as i16,
)
})
.collect()
}
/// A TrueType font whose only glyph, "O", is a 128-point ring: the flattened outline a real font's
/// curve gives, at the same scale, with plenty of detail to simplify. It is built in code so the
/// level-of-detail test needs no installed font and no font file in the repository (#651).
fn fixture_font() -> &'static str {
const NAME: &str = "cadcraft-render-lod.ttf";
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| cadcraft_fonts::ttf::register(NAME, fixture_font_bytes()));
NAME
}
fn fixture_font_bytes() -> Vec<u8> {
let mut glyf = Vec::new();
let mut loca = Vec::new();
be32(&mut loca, 0); // .notdef: no contours
be32(&mut loca, 0);
glyf.extend_from_slice(&simple_glyph(&[ring(500.0, 350.0, 128, false), ring(250.0, 150.0, 128, true)]));
be32(&mut loca, glyf.len() as u32);
let mut head = Vec::new();
be32(&mut head, 0x0001_0000);
be32(&mut head, 0x0001_0000);
be32(&mut head, 0);
be32(&mut head, 0x5F0F_3CF5);
be16(&mut head, 0);
be16(&mut head, 1000);
head.extend_from_slice(&[0; 16]);
for v in [0i16, 0, 1000, 700] {
be16(&mut head, v as u16);
}
for v in [0u16, 8, 2, 1, 0] {
be16(&mut head, v);
}
let mut hhea = Vec::new();
be32(&mut hhea, 0x0001_0000);
for v in [800i16, -200, 0, 1000, 0, 0, 1000, 1, 0, 0, 0, 0, 0, 0, 0] {
be16(&mut hhea, v as u16);
}
be16(&mut hhea, 2);
let mut maxp = Vec::new();
be32(&mut maxp, 0x0001_0000);
for v in [2u16, 256, 2, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0] {
be16(&mut maxp, v);
}
let mut hmtx = Vec::new();
for _ in 0..2 {
be16(&mut hmtx, 1000);
be16(&mut hmtx, 0);
}
// cmap: format 4, 'O' → glyph 1.
let mut sub = Vec::new();
let segs: [(u16, u16, u16); 2] = [(0x4F, 0x4F, 1u16.wrapping_sub(0x4F)), (0xFFFF, 0xFFFF, 1)];
be16(&mut sub, 4);
be16(&mut sub, (16 + segs.len() * 8) as u16);
be16(&mut sub, 0);
be16(&mut sub, (segs.len() * 2) as u16);
for v in [4u16, 1, 0] {
be16(&mut sub, v);
}
for s in segs {
be16(&mut sub, s.1);
}
be16(&mut sub, 0);
for s in segs {
be16(&mut sub, s.0);
}
for s in segs {
be16(&mut sub, s.2);
}
for _ in segs {
be16(&mut sub, 0);
}
let mut cmap = Vec::new();
for v in [0u16, 1, 3, 1] {
be16(&mut cmap, v);
}
be32(&mut cmap, 12);
cmap.extend_from_slice(&sub);
let mut os2 = vec![0u8; 78];
os2[8..10].copy_from_slice(&0u16.to_be_bytes());
let mut tables: Vec<(&[u8; 4], Vec<u8>)> =
vec![(b"OS/2", os2), (b"cmap", cmap), (b"glyf", glyf), (b"head", head), (b"hhea", hhea), (b"hmtx", hmtx), (b"loca", loca), (b"maxp", maxp)];
tables.sort_by_key(|t| *t.0);
let mut out = Vec::new();
be32(&mut out, 0x0001_0000);
for v in [tables.len() as u16, 128, 3, tables.len() as u16 * 16 - 128] {
be16(&mut out, v);
}
let mut off = 12 + 16 * tables.len();
for (t, d) in &tables {
out.extend_from_slice(*t);
be32(&mut out, 0);
be32(&mut out, off as u32);
be32(&mut out, d.len() as u32);
off += d.len().div_ceil(4) * 4;
}
for (_, d) in &tables {
out.extend_from_slice(d);
while out.len() % 4 != 0 {
out.push(0);
}
}
out
}
#[test]
fn truetype_text_is_filled_or_outlined() {
let Some(font) = system_font() else { return };
@@ -298,6 +455,31 @@ fn truetype_text_is_filled_or_outlined() {
assert!(l.prims.len() >= 3, "O has two contours, H one");
}
// Regression test for #651: filled TrueType text was triangulated at full outline detail at every
// zoom, so a page of small text on screen produced millions of triangles and froze the canvas.
#[test]
fn small_filled_text_on_screen_is_simplified_but_plots_keep_full_detail() {
let d = text_doc(fixture_font(), "OOOO");
let tris = |l: &DisplayList| l.prims.iter().filter(|p| p.kind == Kind::Tris).map(|p| l.points(p).len() / 3).sum::<usize>();
let plot = build_plot(&d, &Space::Model, &Options::default());
let full = tris(&plot);
assert!(full > 100, "a plot keeps every point of the 128-point rings: {full} triangles");
// One pixel is a tenth of the cap height: the rings are simplified to a few points, the shapes
// stay.
let reduced = build(&d, &Space::Model, &screen(0.1, None));
assert!(tris(&reduced) < full / 2, "{} triangles at 10 px per cap height, {full} in a plot", tris(&reduced));
assert!(tris(&reduced) > 0 && reduced.bounds.width() > plot.bounds.width() * 0.9, "{:?} vs {:?}", reduced.bounds, plot.bounds);
// An em under two pixels is drawn as the ink's box: two triangles a glyph, same extent.
let tiny = build(&d, &Space::Model, &screen(2.0, None));
assert_eq!(tris(&tiny), 2 * 4, "one box per glyph");
assert!(
tiny.bounds.width() > plot.bounds.width() * 0.9 && tiny.bounds.height() > plot.bounds.height() * 0.9,
"{:?} vs {:?}",
tiny.bounds,
plot.bounds
);
}
#[test]
fn missing_font_falls_back_to_stroke() {
let d = text_doc("NoSuchFontAnywhere.ttf", "OH");
+28
View File
@@ -256,6 +256,34 @@ mod tests {
assert!(tris.iter().all(|p| (5.0e5..=5.0e5 + 2.0).contains(&p.x) && (-3.0e5..=-3.0e5 + 2.0).contains(&p.y)));
}
// Regression test for #651: the level of detail followed the pixel size, so a page of small
// filled text on screen kept a few points a glyph while a plot kept all of them.
#[test]
fn a_small_on_screen_pixel_simplifies_glyphs_and_a_plot_does_not() {
// Text of height 1 has a 700-unit cap height: one font unit is 1/700 of a drawing unit.
let xf = Mat3 { a: 1.0 / 700.0, b: 0.0, c: 0.0, d: 1.0 / 700.0, e: 0.0, f: 0.0 };
assert!(matches!(detail(xf, 0.0), Detail::Full), "a plot keeps every point");
assert!(matches!(detail(xf, 2.0), Detail::Box), "an em under two pixels is the ink's box");
assert!(matches!(detail(xf, 0.1), Detail::Reduced(band) if band >= 2), "10 px per cap height simplifies");
assert!(matches!(detail(xf, f64::NAN), Detail::Full), "a pixel size that is not a size keeps full detail");
}
#[test]
fn a_coarse_band_drops_the_points_of_a_flattened_curve() {
// A ring of 64 points, the outline a flattened curve gives.
let ring: Vec<Vec2> = (0..64u32)
.map(|i| {
let t = std::f64::consts::TAU * f64::from(i) / 64.0;
Vec2::new(500.0 * t.cos(), 350.0 * t.sin())
})
.collect();
let coarse = simplify(&ring, 8.0);
assert!(coarse.len() < ring.len() / 2, "{} of {} points kept at 8 font units", coarse.len(), ring.len());
assert!(coarse.len() >= 4, "a ring never collapses to a line: {}", coarse.len());
let fine = simplify(&ring, 0.01);
assert!(fine.len() >= ring.len(), "a fine band keeps every point: {} of {}", fine.len(), ring.len());
}
#[test]
fn hostile_placed_outline_never_panics() {
assert!(triangulate_placed(&[]).is_empty());
+1
View File
@@ -22,6 +22,7 @@ mod mspace_viewport_view;
mod named_viewports_dialog;
mod native_menu_text_keys;
mod native_windows;
mod offset_clicks;
mod options_dialog;
mod options_right_click;
mod parameters_rename;
+83
View File
@@ -0,0 +1,83 @@
//! OFFSET on the canvas: the distance can be clicked, and picking the source is not moved by the
//! grid snap (#618).
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Common, Drawing, EntityKind, Line, Space};
use cadcraft_engine::geom::Vec2;
use cadcraft_ui_egui::{CadApp, Services};
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
fn harness() -> Harness<'static, CadApp> {
let mut d = Drawing::new_imperial();
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec2::new(0.0, 2.0).to3(0.0), b: Vec2::new(10.0, 2.0).to3(0.0) })).unwrap();
let mut session = Session::empty();
session.open_drawing(d, "Offset clicks", None);
session.execute("view.set", &json!({"center":[5,5],"height":20})).unwrap();
session.settings.osmode = 0;
session.settings.snapmode = true;
session.execute("setvar", &json!({"name":"SNAPUNIT","value":[5,5]})).unwrap();
session.settings.orthomode = false;
let mut app = CadApp::new(session, Services::default());
app.canvas.zoom_pending = false;
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut CadApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(5);
h
}
fn click(h: &mut Harness<'_, CadApp>, world: Vec2) {
let pos: Pos2 = h.state().canvas.xf.unwrap().to_screen(world);
h.event(Event::PointerMoved(pos));
h.run_steps(2);
for pressed in [true, false] {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(2);
}
}
// Regression test for #618: OFFSET's distance could not be clicked, and with grid snap on a click
// meant to pick the source object was moved to the nearest grid point, missing the object.
#[test]
fn offset_takes_a_clicked_distance_and_picks_its_source_under_grid_snap() {
let mut h = harness();
h.state_mut().start("offset");
h.run_steps(2);
// Two clicks, snapped to the grid points (5, 5) and (10, 5): a distance of 5.
click(&mut h, Vec2::new(5.1, 4.9));
assert!(h.state().session.prompt_text().contains("second point"), "{}", h.state().session.prompt_text());
click(&mut h, Vec2::new(9.9, 5.1));
assert_eq!(h.state().session.doc().unwrap().header.f64("OFFSETDIST", -1.0), 5.0);
assert!(h.state().session.prompt_text().contains("Select object"), "{}", h.state().session.prompt_text());
// The source line is at y = 2, two units from the grid point (5, 0) the click would snap to.
click(&mut h, Vec2::new(7.0, 2.04));
let prompt = h.state().session.prompt_text();
assert!(prompt.contains("side to offset"), "the line was not picked: {prompt}");
// A side click above the line (snapped to (5, 5)) makes the copy 5 units up, at y = 7.
click(&mut h, Vec2::new(7.0, 7.0));
let lines: Vec<(f64, f64)> = h
.state()
.session
.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Line(line) => Some((line.a.y, line.b.y)),
_ => None,
})
.collect();
assert_eq!(lines.len(), 2, "OFFSET added no line: {lines:?}");
assert!(lines.iter().any(|&(a, b)| (a - 7.0).abs() < 1e-9 && (b - 7.0).abs() < 1e-9), "no copy at y = 7: {lines:?}");
// OFFSET asks for the next object; Enter ends it.
assert!(h.state().session.prompt_text().contains("Select object to offset"), "{}", h.state().session.prompt_text());
h.key_press(egui::Key::Enter);
h.run_steps(2);
assert_eq!(h.state().session.prompt_text(), "Command:", "Enter ends OFFSET");
}