vectorcraft/crates/engine/src/tests_persp_text.rs

272 lines
13 KiB
Rust

//! Type and symbols in perspective (projected, still type and symbol instances, Edit Text),
//! attachment bookkeeping (copy, paste, duplicate, Selection-tool moves, release) and drawing in
//! perspective (flare, spiral, polar grid, click-to-size dialogs).
use serde_json::json;
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::{Point, Rect};
use vectorcraft_tools::Mods;
use vectorcraft_tools::distort::perspective::{self as persp, Plane};
use super::tests_persp_select::{attached, drag, grid, persp_session, undo_len};
use super::*;
/// Point type "HELLO" (48 pt) at (430, 400), attached to the right plane.
fn persp_text(s: &mut Session) -> NodeId {
let r = s.execute("text.create", &json!({"x": 430, "y": 400, "text": "HELLO", "size": 48})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("perspective.attach", &json!({"ids": [id.0], "plane": "right"})).unwrap();
id
}
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
/// The art the renderer and exporters draw for `id` (its outlines, projected).
fn drawn_bounds(s: &Session, id: NodeId) -> Rect {
let n = node(s, id);
vectorcraft_render::effects::reshape(&n, None).unwrap().geometric_bounds().unwrap()
}
#[test]
fn type_in_perspective_is_projected_and_stays_type() {
let (mut s, _) = persp_session();
let id = persp_text(&mut s);
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::Text(_)), "still type");
assert_eq!(attached(&s, id), Some((Plane::Right, 0.0)));
let h = n.projection().expect("drawn through a projection");
// Really foreshortened: the right plane recedes to the right, so the letters' far end is
// shorter than their near end.
let out = vectorcraft_render::effects::reshape(&n, None).unwrap();
let mut pts = vec![];
out.walk(&mut |c| {
if let Some(p) = c.path_data() {
pts.extend(p.anchors().map(|(_, _, a)| a.p));
}
});
let b = drawn_bounds(&s, id);
let near: Vec<&Point> = pts.iter().filter(|p| p.x < b.x0 + 0.2 * b.width()).collect();
let far: Vec<&Point> = pts.iter().filter(|p| p.x > b.x1 - 0.2 * b.width()).collect();
let span = |v: &[&Point]| v.iter().map(|p| p.y).fold(f64::MIN, f64::max) - v.iter().map(|p| p.y).fold(f64::MAX, f64::min);
assert!(span(&far) < span(&near) * 0.97, "far {} near {}", span(&far), span(&near));
// Bounds, hit testing and the selection box follow the drawn type.
let gb = n.geometric_bounds().unwrap();
assert!((gb.x0 - b.x0).abs() < 12.0 && (gb.x1 - b.x1).abs() < 12.0, "{gb:?} {b:?}");
let hit = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, b.center(), Default::default()).unwrap();
assert_eq!(hit.leaf, id);
let _ = h;
}
#[test]
fn type_in_perspective_exports_projected_outlines() {
let (mut s, _) = persp_session();
let id = persp_text(&mut s);
let b = drawn_bounds(&s, id);
let doc = s.doc().unwrap().doc.clone();
let svg = vectorcraft_svg::export(&doc, &Default::default());
assert!(!svg.contains("HELLO"), "no flat text in the SVG");
let pdf = vectorcraft_render::effects::bake_document(&doc).unwrap();
let baked = pdf.node(id).unwrap();
assert!(!matches!(baked.kind, NodeKind::Text(_)));
let bb = baked.geometric_bounds().unwrap();
assert!((bb.x0 - b.x0).abs() < 1e-6 && (bb.y1 - b.y1).abs() < 1e-6, "{bb:?} {b:?}");
}
#[test]
fn moving_type_in_perspective_moves_its_projection() {
let (mut s, rect) = persp_session();
let id = persp_text(&mut s);
let NodeKind::Text(t) = &node(&s, id).kind else { panic!("type") };
let flat = t.xf;
s.execute("perspective.transform", &json!({"ids": [id.0], "matrix": [1, 0, 0, 1, 40, 0]})).unwrap();
let n = node(&s, id);
let NodeKind::Text(t) = &n.kind else { panic!() };
assert_eq!(t.xf, flat, "the type itself is untouched");
// Its drawn corner moved 40 pt along the plane.
let g = grid(&s);
let hi = g.homography(Plane::Right).unwrap().inverse().unwrap();
let b0 = {
s.execute("edit.undo", &json!({})).unwrap();
drawn_bounds(&s, id)
};
s.execute("edit.redo", &json!({})).unwrap();
let b1 = drawn_bounds(&s, id);
let d = hi.apply(Point::new(b1.x0, b1.y1)).unwrap() - hi.apply(Point::new(b0.x0, b0.y1)).unwrap();
assert!((d.x - 40.0).abs() < 1.0 && d.y.abs() < 1.0, "{d:?}");
let _ = rect;
}
#[test]
fn edit_text_shows_it_flat_in_isolation_then_projects_it_again() {
let (mut s, _) = persp_session();
let id = persp_text(&mut s);
let drawn = node(&s, id).geometric_bounds().unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("perspective.editText", &json!({})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.isolation, Some(id));
let n = node(&s, id);
assert!(n.projection().is_none(), "flat while editing");
// Shown where it is drawn in perspective.
assert!(n.geometric_bounds().unwrap().center().distance(drawn.center()) < 1e-6);
s.execute("text.setText", &json!({"id": id.0, "text": "HELLO WORLD"})).unwrap();
s.execute("object.exitIsolation", &json!({})).unwrap();
let n = node(&s, id);
assert!(n.projection().is_some(), "projected again");
// Nothing left flat anywhere in the history.
for e in &s.doc().unwrap().history.undo {
if let Some(p) = e.doc.node(id).and_then(|n| n.perspective.as_deref()) {
assert!(!p.editing);
}
}
// Not type in perspective: refused.
let (mut s2, rect) = persp_session();
assert!(s2.execute("perspective.editText", &json!({"id": rect.0})).is_err());
}
#[test]
fn symbols_in_perspective_stay_instances() {
let (mut s, rect) = persp_session();
s.execute("perspective.release", &json!({"ids": [rect.0]})).unwrap();
s.execute("select.set", &json!({"ids": [rect.0]})).unwrap();
let sym = s.execute("symbol.new", &json!({"name": "Box"})).unwrap();
let _ = sym;
let r = s.execute("symbol.place", &json!({"name": "Box", "x": 470, "y": 380})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("perspective.attach", &json!({"ids": [id.0], "plane": "right"})).unwrap();
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::SymbolInstance { .. }));
assert!(n.projection().is_some());
let baked = vectorcraft_render::effects::bake_document(&s.doc().unwrap().doc).unwrap();
assert!(!matches!(baked.node(id).unwrap().kind, NodeKind::SymbolInstance { .. }), "exports draw the projected art");
}
#[test]
fn copies_pastes_and_duplicates_stay_attached() {
let (mut s, id) = persp_session();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let pasted = *s.doc().unwrap().selection.objects.first().unwrap();
assert_ne!(pasted, id);
assert_eq!(attached(&s, pasted), Some((Plane::Right, 0.0)));
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 30, 0], "copy": true})).unwrap();
let copy = *s.doc().unwrap().selection.objects.first().unwrap();
assert_eq!(attached(&s, copy), Some((Plane::Right, 0.0)));
// Deleting leaves nothing stale behind.
s.execute("edit.clear", &json!({})).unwrap();
assert!(grid(&s).attached_plane(copy).is_none());
}
#[test]
fn a_selection_tool_move_keeps_the_attachment_and_type_follows() {
let (mut s, rect) = persp_session();
let id = persp_text(&mut s);
let before = drawn_bounds(&s, id);
s.execute("select.set", &json!({"ids": [id.0, rect.0]})).unwrap();
let c = node(&s, rect).geometric_bounds().unwrap().center();
s.select_tool("selection", ViewInfo::default()).unwrap();
drag(&mut s, &[c, Point::new(c.x + 25.0, c.y + 10.0), Point::new(c.x + 25.0, c.y + 10.0)], Mods::default());
assert_eq!(attached(&s, rect), Some((Plane::Right, 0.0)));
assert_eq!(attached(&s, id), Some((Plane::Right, 0.0)));
let after = drawn_bounds(&s, id);
assert!((after.x0 - before.x0 - 25.0).abs() < 1e-6 && (after.y0 - before.y0 - 10.0).abs() < 1e-6, "{before:?} {after:?}");
}
#[test]
fn release_keeps_the_look_of_type_in_perspective() {
let (mut s, _) = persp_session();
let id = persp_text(&mut s);
let b = drawn_bounds(&s, id);
s.execute("perspective.release", &json!({"ids": [id.0]})).unwrap();
assert_eq!(attached(&s, id), None);
assert_eq!(drawn_bounds(&s, id), b);
assert!(persp::attachment(&node(&s, id)).is_none());
}
#[test]
fn flare_spiral_and_polar_grid_draw_in_perspective() {
let (mut s, _) = persp_session();
s.execute("perspective.grid.show", &json!({"visible": true})).unwrap();
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
for (tool, pts) in [("spiral", [(300.0, 380.0), (320.0, 390.0), (330.0, 400.0)]), ("polarGrid", [(250.0, 350.0), (280.0, 380.0), (300.0, 410.0)])]
{
let n = undo_len(&s);
s.select_tool(tool, ViewInfo::default()).unwrap();
drag(&mut s, &pts.map(|(x, y)| Point::new(x, y)), Mods::default());
assert_eq!(undo_len(&s), n + 1, "{tool}");
assert_eq!(s.journal.last().unwrap().0, "perspective.draw", "{tool}");
let id = *s.doc().unwrap().selection.objects.first().unwrap();
assert_eq!(attached(&s, id).map(|a| a.0), Some(Plane::Left), "{tool}");
}
let r = s.execute("perspective.draw", &json!({"command": "shape.flare", "params": {"cx": 300, "cy": 380, "diameter": 40}})).unwrap();
assert_eq!(attached(&s, NodeId(r["id"].as_u64().unwrap())).map(|a| a.0), Some(Plane::Left));
}
#[test]
fn click_to_size_dialogs_draw_on_the_active_plane_in_plane_units() {
let (mut s, _) = persp_session();
s.execute("perspective.grid.show", &json!({"visible": true})).unwrap();
s.execute("perspective.plane.set", &json!({"plane": "right"})).unwrap();
let at = Point::new(470.0, 300.0);
let (cmd, p) = crate::perspective_click(&s, "shape.rectangle", &json!({"x": at.x, "y": at.y, "width": 50, "height": 30}), at).unwrap();
assert_eq!(cmd, "perspective.draw");
let r = s.execute(&cmd, &p).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let g = grid(&s);
let hi = g.homography(Plane::Right).unwrap().inverse().unwrap();
let q: Vec<Point> = node(&s, id).path_data().unwrap().anchors().map(|(_, _, a)| hi.apply(a.p).unwrap()).collect();
let b = q.iter().skip(1).fold(Rect::from_points(q[0], q[0]), |r, p| r.union_pt(*p));
assert!((b.width() - 50.0).abs() < 1e-6 && (b.height() - 30.0).abs() < 1e-6, "{b:?}");
assert!(q.iter().any(|p| (g.homography(Plane::Right).unwrap().apply(*p).unwrap() - at).hypot() < 1e-6), "the click stays a corner");
// Without a shown grid (or with no active plane) nothing changes.
s.execute("perspective.plane.set", &json!({"plane": "none"})).unwrap();
assert!(crate::perspective_click(&s, "shape.rectangle", &json!({}), at).is_none());
}
#[test]
fn the_canvas_draws_type_in_perspective_foreshortened() {
let (mut s, rect) = persp_session();
s.execute("edit.clear", &json!({"ids": [rect.0]})).ok();
let id = persp_text(&mut s);
let b = drawn_bounds(&s, id);
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, b, 2.0, true);
// The inked height of a strip of columns near each end of the line.
let height = |x0: f64, x1: f64| {
let (mut lo, mut hi) = (u32::MAX, 0);
for x in (x0 * img.width as f64) as u32..(x1 * img.width as f64) as u32 {
for y in 0..img.height {
if img.pixel(x, y)[0] < 128 {
lo = lo.min(y);
hi = hi.max(y);
}
}
}
hi.saturating_sub(lo)
};
let (near, far) = (height(0.0, 0.25), height(0.75, 1.0));
assert!(near > 10 && far * 100 < near * 97, "near {near} far {far}");
}
#[test]
fn double_clicking_type_in_perspective_edits_it_and_projections_round_trip() {
let (mut s, _) = persp_session();
let id = persp_text(&mut s);
// Saved and opened again: still type, still projected.
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(doc.node(id).unwrap().projection(), node(&s, id).projection());
let v = ViewInfo::default();
s.select_tool("perspectiveSelection", v).unwrap();
let c = node(&s, id).geometric_bounds().unwrap().center();
let r = s.pointer(&vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::DoubleClick, c.x, c.y), v).unwrap();
assert_eq!(r, vec![crate::UiRequest::SwitchTool("type".into())]);
assert_eq!(s.doc().unwrap().isolation, Some(id));
// Saved while editing: the file has it projected.
let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert!(doc.node(id).unwrap().projection().is_some());
s.execute("object.exitIsolation", &json!({})).unwrap();
assert!(node(&s, id).projection().is_some());
}