cadcraft/crates/engine/tests/annotative.rs

286 lines
14 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Annotative scaling (#468): annotative objects keep their paper size at every annotation
//! scale, show only at scales they support unless ANNOALLVISIBLE is 1, and the scale list,
//! OBJECTSCALE, ANNOAUTOSCALE and ANNOUPDATE manage the scales.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{AnnoDraw, Drawing, Entity, EntityKind, Handle, Space, entity_bounds};
use serde_json::{Value, json};
fn setvar(s: &mut Session, name: &str, value: Value) {
s.execute("setvar", &json!({ "name": name, "value": value })).unwrap();
}
/// A session whose current text style is annotative, with one text 0.25 high on paper.
fn with_text() -> (Session, Handle) {
let mut s = Session::new();
s.execute("style", &json!({ "name": "Notes", "annotative": true, "current": true })).unwrap();
let r = s.execute("text", &json!({ "at": [0, 0], "text": "NOTE", "height": 0.25 })).unwrap();
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
(s, h)
}
fn entity(s: &Session, h: Handle) -> Entity {
(**s.doc().unwrap().entity(h).unwrap()).clone()
}
fn scales(s: &Session, h: Handle) -> Vec<String> {
entity(s, h).common.anno_scales.iter().map(|x| x.name.clone()).collect()
}
fn height(d: &Drawing, h: Handle) -> f64 {
let e = d.entity(h).unwrap();
entity_bounds(d, e, 0).height()
}
/// Height of what the renderer draws for `h` in model space.
fn drawn_height(d: &Drawing, h: Handle) -> Option<f64> {
let list = cadcraft_engine::render::build(d, &Space::Model, &Default::default());
let mut b = cadcraft_engine::geom::Bounds2::EMPTY;
for p in list.prims.iter().filter(|p| p.handle == h) {
for q in list.points(p) {
b.add(*q);
}
}
(!b.is_empty()).then(|| b.height())
}
#[test]
fn text_of_an_annotative_style_supports_the_current_scale() {
let (s, h) = with_text();
assert_eq!(scales(&s, h), ["1:1"]);
let mut s = s;
let p = s.execute("properties", &json!({ "handles": [h.hex()] })).unwrap();
assert_eq!(p["objects"][0]["annotative"], json!(true), "{p}");
assert_eq!(p["objects"][0]["annotativeScales"], json!(["1:1"]), "{p}");
// A plain style makes plain text.
let mut s = Session::new();
let r = s.execute("text", &json!({ "at": [0, 0], "text": "X", "height": 0.25 })).unwrap();
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
assert!(scales(&s, h).is_empty());
}
#[test]
fn annotative_text_draws_at_paper_height_times_the_scale() {
let (mut s, h) = with_text();
let at_1 = drawn_height(s.doc().unwrap(), h).unwrap();
setvar(&mut s, "CANNOSCALE", json!("1:50"));
s.execute("objectscale", &json!({ "handles": [h.hex()], "add": "1:50" })).unwrap();
assert_eq!(scales(&s, h), ["1:1", "1:50"]);
let at_50 = drawn_height(s.doc().unwrap(), h).unwrap();
assert!((at_50 / at_1 - 50.0).abs() < 1e-6, "{at_1} → {at_50}");
// Bounds (zoom, selection) follow the drawn size; the stored height stays the paper height.
assert!((height(s.doc().unwrap(), h) / at_50 - 1.0).abs() < 0.5);
match entity(&s, h).kind {
EntityKind::Text(t) => assert_eq!(t.height, 0.25),
k => panic!("{k:?}"),
}
}
#[test]
fn unsupported_scale_hides_unless_annoallvisible() {
let (mut s, h) = with_text();
setvar(&mut s, "CANNOSCALE", json!("1:20"));
// ANNOALLVISIBLE 1 (the default): shown, at the object's own scale.
assert_eq!(s.doc().unwrap().anno_draw(&entity(&s, h)), AnnoDraw::Scaled(1.0));
assert!(drawn_height(s.doc().unwrap(), h).is_some());
s.execute("annovisible.current", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().header.i64("ANNOALLVISIBLE", 1), 0);
assert_eq!(drawn_height(s.doc().unwrap(), h), None);
assert!(entity_bounds(s.doc().unwrap(), &entity(&s, h), 0).is_empty());
// Back at a supported scale it shows again.
setvar(&mut s, "CANNOSCALE", json!("1:1"));
assert!(drawn_height(s.doc().unwrap(), h).is_some());
s.execute("annovisible.toggle", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().header.i64("ANNOALLVISIBLE", 0), 1);
}
#[test]
fn annoautoscale_adds_the_new_scale() {
let (mut s, h) = with_text();
setvar(&mut s, "ANNOAUTOSCALE", json!(4));
setvar(&mut s, "CANNOSCALE", json!("1:10"));
assert_eq!(scales(&s, h), ["1:1", "1:10"]);
let v = s.execute("getvar", &json!({ "name": "CANNOSCALEVALUE" })).unwrap();
assert!((v["value"].as_f64().unwrap() - 0.1).abs() < 1e-12, "{v}");
assert!(s.execute("setvar", &json!({ "name": "CANNOSCALEVALUE", "value": 1 })).is_err());
setvar(&mut s, "ANNOAUTOSCALE", json!(-4));
setvar(&mut s, "CANNOSCALE", json!("1:20"));
assert_eq!(scales(&s, h), ["1:1", "1:10"]);
// A scale that isn't in the list is refused.
assert!(s.execute("setvar", &json!({ "name": "CANNOSCALE", "value": "1:7" })).is_err());
assert!(s.execute("setvar", &json!({ "name": "CANNOSCALE", "value": 7 })).is_err());
}
#[test]
fn add_and_delete_current_scale_keep_one_scale() {
let (mut s, h) = with_text();
setvar(&mut s, "CANNOSCALE", json!("1:4"));
s.execute("aiobjectscaleadd", &json!({ "handles": [h.hex()] })).unwrap();
assert_eq!(scales(&s, h), ["1:1", "1:4"]);
s.execute("aiobjectscaleremove", &json!({ "handles": [h.hex()] })).unwrap();
assert_eq!(scales(&s, h), ["1:1"]);
// The last scale stays.
setvar(&mut s, "CANNOSCALE", json!("1:1"));
let r = s.execute("aiobjectscaleremove", &json!({ "handles": [h.hex()] })).unwrap();
assert_eq!(scales(&s, h), ["1:1"]);
assert!(r["message"].as_str().unwrap().contains("only scale"), "{r}");
assert!(s.execute("objectscale", &json!({ "handles": [h.hex()], "add": "no such" })).is_err());
}
#[test]
fn objectscale_at_the_command_line() {
let (mut s, h) = with_text();
// The pickfirst selection is used.
s.set_selection(vec![h]);
s.script("-OBJECTSCALE\nA\n1:2\nA\n1:8\nD\n1:1\n\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(scales(&s, h), ["1:2", "1:8"]);
}
#[test]
fn scale_list_add_delete_reset() {
let (mut s, h) = with_text();
let r = s.execute("scalelistedit", &json!({ "add": { "name": "Site", "ratio": "1:250" } })).unwrap();
assert!(r["scales"].as_array().unwrap().iter().any(|x| x["name"] == "Site" && x["drawingUnits"] == 250.0), "{r}");
assert!(s.execute("scalelistedit", &json!({ "add": { "name": "site", "ratio": "1:3" } })).is_err());
assert!(s.execute("scalelistedit", &json!({ "add": { "name": "Bad", "ratio": "0:3" } })).is_err());
assert!(s.execute("scalelistedit", &json!({ "add": { "name": "Bad", "paperUnits": 1, "drawingUnits": -2 } })).is_err());
// In use by the text, or current: can't be deleted.
s.execute("objectscale", &json!({ "handles": [h.hex()], "add": "Site" })).unwrap();
assert!(s.execute("scalelistedit", &json!({ "delete": "Site" })).is_err());
assert!(s.execute("scalelistedit", &json!({ "delete": "1:1" })).is_err());
s.execute("scalelistedit", &json!({ "delete": "1:100" })).unwrap();
assert!(s.doc().unwrap().anno_scale("1:100").is_none());
// Reset brings the defaults back and keeps scales in use.
let r = s.execute("scalelistedit", &json!({ "reset": "metric" })).unwrap();
let names: Vec<&str> = r["scales"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).collect();
assert!(names.contains(&"1:100") && names.contains(&"Site") && !names.iter().any(|n| n.contains('"')), "{names:?}");
let r = s.execute("scalelistedit", &json!({ "reset": "imperial" })).unwrap();
assert!(r["scales"].as_array().unwrap().iter().any(|x| x["name"] == "1/4\" = 1'-0\"" && x["drawingUnits"] == 12.0));
}
#[test]
fn scalelistedit_at_the_command_line() {
let mut s = Session::new();
s.script("-SCALELISTEDIT\nA\nDetail\n5:1\nD\n1:16\nE\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let d = s.doc().unwrap();
let detail = d.anno_scale("detail").unwrap();
assert_eq!((detail.paper_units, detail.drawing_units), (5.0, 1.0));
assert!(d.anno_scale("1:16").is_none());
// Bad input is reported, not fatal.
s.script("-SCALELISTEDIT\nA\nOdd\nfoo\n1:3\nD\nnothing\n\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(s.doc().unwrap().anno_scale("Odd").map(|x| x.drawing_units), Some(3.0));
s.script("-SCALELISTEDIT\nR\nY\nX\n\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(s.doc().unwrap().anno_scale("1:16").is_some(), "reset restores the defaults");
assert!(s.doc().unwrap().anno_scale("Odd").is_none(), "an unused scale goes");
}
#[test]
fn annotative_dimension_scales_its_text_and_arrows() {
let mut s = Session::new();
s.execute("dimstyle", &json!({ "name": "Anno", "annotative": true, "current": true })).unwrap();
let r = s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [100, 0], "at": [50, 20] })).unwrap();
let h = Handle::parse_hex(r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap()).unwrap();
assert_eq!(scales(&s, h), ["1:1"]);
let at_1 = drawn_height(s.doc().unwrap(), h).unwrap();
s.execute("scalelistedit", &json!({ "add": { "name": "1:3", "ratio": "1:3" } })).ok();
setvar(&mut s, "ANNOAUTOSCALE", json!(1));
setvar(&mut s, "CANNOSCALE", json!("1:4"));
let at_4 = drawn_height(s.doc().unwrap(), h).unwrap();
// The dimension stays 20 tall between its points and dimension line; the extension line
// offset and overshoot (DIMEXO, DIMEXE) are 4× their paper size.
assert!(((at_4 - 20.0) / (at_1 - 20.0) - 4.0).abs() < 1e-6, "{at_1} → {at_4}");
}
#[test]
fn annotative_multileader_and_hatch() {
let mut s = Session::new();
s.execute("mleaderstyle", &json!({ "name": "Anno", "annotative": true })).unwrap();
let r = s.execute("mleader", &json!({ "points": [[0, 0], [5, 5]], "text": "A" })).unwrap();
let ml = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
assert_eq!(scales(&s, ml), ["1:1"]);
setvar(&mut s, "HPANNOTATIVE", json!(1));
s.execute("rectang", &json!({ "p1": [20, 0], "p2": [30, 10] })).unwrap();
let r = s.execute("hatch", &json!({ "points": [[25, 5]], "pattern": "ANSI31" })).unwrap();
let hh = r["handle"].as_str().or_else(|| r["handles"][0].as_str()).and_then(Handle::parse_hex).unwrap();
assert_eq!(scales(&s, hh), ["1:1"]);
// At 1:2 the hatch pattern doubles: fewer, longer lines over the same area.
let count = |s: &Session| {
let d = s.doc().unwrap();
let list = cadcraft_engine::render::build(d, &Space::Model, &Default::default());
list.prims.iter().filter(|p| p.handle == hh).count()
};
let n1 = count(&s);
setvar(&mut s, "ANNOAUTOSCALE", json!(1));
setvar(&mut s, "CANNOSCALE", json!("1:2"));
let n2 = count(&s);
assert!(n2 < n1, "{n1} → {n2}");
}
#[test]
fn explode_gives_plain_objects_at_the_current_scale() {
let (mut s, h) = with_text();
s.execute("objectscale", &json!({ "handles": [h.hex()], "add": "1:10" })).unwrap();
setvar(&mut s, "CANNOSCALE", json!("1:10"));
// Text doesn't explode; a block reference of an annotative block does.
s.execute("block", &json!({ "name": "TAG", "base": [0, 0], "handles": [h.hex()], "annotative": true })).unwrap();
let ins = s.doc().unwrap().model.last().unwrap().handle;
assert_eq!(scales(&s, ins), ["1:10"]);
s.execute("explode", &json!({ "handles": [ins.hex()] })).unwrap();
let d = s.doc().unwrap();
let t = d.model.iter().find(|e| matches!(e.kind, EntityKind::Text(_))).unwrap();
match &t.kind {
EntityKind::Text(t) => assert!((t.height - 2.5).abs() < 1e-9, "{}", t.height),
_ => unreachable!(),
}
}
#[test]
fn annoupdate_and_properties_follow_the_style() {
let mut s = Session::new();
s.execute("style", &json!({ "name": "Notes", "current": true })).unwrap();
let r = s.execute("text", &json!({ "at": [0, 0], "text": "N", "height": 0.25 })).unwrap();
let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();
assert!(scales(&s, h).is_empty());
s.execute("style", &json!({ "name": "Notes", "annotative": true })).unwrap();
s.execute("annoupdate", &json!({ "handles": [h.hex()] })).unwrap();
assert_eq!(scales(&s, h), ["1:1"]);
s.execute("properties.set", &json!({ "handles": [h.hex()], "annotative": false })).unwrap();
assert!(scales(&s, h).is_empty());
s.execute("properties.set", &json!({ "handles": [h.hex()], "annotative": true })).unwrap();
assert_eq!(scales(&s, h), ["1:1"]);
let r = s.execute("annoreset", &json!({ "handles": [h.hex()] })).unwrap();
assert_eq!(r["objects"], 1);
// Lines can't be annotative.
let r = s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap();
let l = r["handles"][0].as_str().or_else(|| r["handle"].as_str()).and_then(Handle::parse_hex).unwrap();
s.execute("properties.set", &json!({ "handles": [l.hex()], "annotative": true })).unwrap();
assert!(scales(&s, l).is_empty());
}
#[test]
fn hostile_parameters_are_refused() {
let (mut s, h) = with_text();
for p in [
json!({ "handles": [h.hex()], "add": 5 }),
json!({ "handles": [h.hex()], "delete": [1, 2] }),
json!({ "handles": "x" }),
json!({ "handles": [] }),
] {
assert!(s.execute("objectscale", &p).is_err(), "{p}");
}
for p in [
json!({ "add": { "name": "", "ratio": "1:2" } }),
json!({ "add": { "name": "x", "ratio": "1:inf" } }),
json!({ "add": { "name": "x", "paperUnits": 1e308, "drawingUnits": 1e-308 } }),
json!({ "add": "1:2" }),
json!({ "reset": 3 }),
json!({ "delete": "missing" }),
] {
assert!(s.execute("scalelistedit", &p).is_err(), "{p}");
}
}