mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-15 10:23:43 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68fbf2a58d | ||
|
|
ee25f48c7b | ||
|
|
a6981415bf |
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user