mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Geometry effects on a group or layer, and effects on a symbol instance, blend or repeat, outline the type inside it. outline_nested_text replaced each type object with a plain group of its outlines, so the type lost its opacity, blend mode, opacity mask, knockout shape option, own effects and perspective, and hidden type was drawn, exported and made visible by Expand Appearance. outline_nested_text now replaces type with the art that as_art makes for it, which is also what reshape makes for type with geometry effects of its own. That art has the type's own geometry effects and perspective applied and keeps its name, transparency, opacity mask and other effects. as_art also keeps the object's visibility and knockout shape option, so hidden type with a geometry effect of its own is left out of exports. Type in a clip group's clipping path still becomes its plain outlines and now keeps its visibility.
287 lines
14 KiB
Rust
287 lines
14 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 type_in_perspective_in_a_group_with_a_geometry_effect_exports_projected() {
|
|
let (mut s, rect) = persp_session();
|
|
let id = persp_text(&mut s);
|
|
let b = drawn_bounds(&s, id);
|
|
s.execute("select.set", &json!({"ids": [rect.0, id.0]})).unwrap();
|
|
s.execute("object.group", &json!({})).unwrap();
|
|
// Offset Path 0 on the group leaves every path as it is.
|
|
s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 0}})).unwrap();
|
|
let doc = s.doc().unwrap().doc.clone();
|
|
let baked = vectorcraft_render::effects::bake_document(&doc).unwrap();
|
|
let bb = baked.node(id).unwrap().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.extras().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());
|
|
}
|