M8.14: Type and symbols in perspective are really projected and stay editable; attachments survive copies
Type and symbol instances attached to a plane keep their art and carry a projection (Node.perspective.projection, append-only); the canvas and every export draw their outlines through it (reshape/bake), so they are foreshortened and stay type and symbol instances. Bounds, hit testing and transforms follow the projection. Object › Perspective › Edit Text (perspective.editText, or double-clicking with the Perspective Selection tool) shows the type flat where it is drawn in isolation mode; exiting isolation projects it again. Release with Perspective keeps their look. Copies, pastes, duplicates and Selection-tool moves keep the attachment (the art moves as dragged). Flare, spiral and polar grid draw on the active plane, and so do the click-to-size shape dialogs (perspective.draw `at`: sizes in plane units). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5a13626faa
commit
8255d606ae
|
|
@ -534,9 +534,9 @@ impl Node {
|
|||
.iter()
|
||||
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())),
|
||||
NodeKind::Text(t) => t.bounds(),
|
||||
NodeKind::Text(t) => self.projected(t.bounds()),
|
||||
NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))),
|
||||
NodeKind::SymbolInstance { xf, .. } => Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0))),
|
||||
NodeKind::SymbolInstance { xf, .. } => self.projected(Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0)))),
|
||||
NodeKind::Blend { children, spec } => {
|
||||
let b = crate::live::nodes_bounds(children);
|
||||
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()))
|
||||
|
|
@ -593,6 +593,8 @@ impl Node {
|
|||
if !self.is_layer() {
|
||||
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
|
||||
}
|
||||
// Type and symbols in perspective keep looking the same, moved by `a`.
|
||||
self.transform_projection(a);
|
||||
// Refitting an unplaced gradient only reproduces moves and uniform scales.
|
||||
if !keeps_gradient_fit(a) {
|
||||
self.pin_gradients();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
//! stay attached and deleted objects leave nothing stale behind.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::{Homography, Rect};
|
||||
|
||||
use crate::{Node, NodeKind};
|
||||
|
||||
/// The plane an object in perspective lies on.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -17,10 +20,50 @@ pub struct PerspectiveAttachment {
|
|||
/// grid plane in its original place.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub depth: f64,
|
||||
/// Type and symbol instances: the projective map (row-major 3 × 3) that draws their flat art
|
||||
/// in perspective. They stay type and symbol instances; the renderer and exporters project
|
||||
/// their outlines.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub projection: Option<[f64; 9]>,
|
||||
/// Object › Perspective › Edit Text in progress: the type is drawn flat to be edited (not
|
||||
/// saved).
|
||||
#[serde(skip)]
|
||||
pub editing: bool,
|
||||
}
|
||||
|
||||
impl PerspectiveAttachment {
|
||||
pub fn new(plane: &str, depth: f64) -> Self {
|
||||
Self { plane: plane.to_string(), depth: if depth.is_finite() { depth } else { 0.0 } }
|
||||
Self { plane: plane.to_string(), depth: if depth.is_finite() { depth } else { 0.0 }, ..Self::default() }
|
||||
}
|
||||
|
||||
/// The projection, when it is a valid one.
|
||||
pub fn homography(&self) -> Option<Homography> {
|
||||
self.projection.and_then(Homography::from_array)
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// The projective map type or a symbol instance in perspective is drawn through (not while
|
||||
/// Edit Text shows it flat).
|
||||
pub fn projection(&self) -> Option<Homography> {
|
||||
let a = self.perspective.as_deref().filter(|a| !a.editing)?;
|
||||
matches!(self.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }).then(|| a.homography()).flatten()
|
||||
}
|
||||
|
||||
/// `b` (flat bounds) as drawn: the box of its projection for type and symbols in perspective.
|
||||
pub(crate) fn projected(&self, b: Option<Rect>) -> Option<Rect> {
|
||||
match (self.projection(), b) {
|
||||
(Some(h), Some(b)) => h.map_rect_bbox(b).or(Some(b)),
|
||||
(_, b) => b,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too).
|
||||
pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) {
|
||||
if let Some(p) = self.perspective.as_deref_mut()
|
||||
&& let Some(h) = p.homography().and_then(|h| h.conjugated(a))
|
||||
{
|
||||
p.projection = Some(h.to_array());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ pub fn needs_bake(n: &Node) -> bool {
|
|||
|| has_pathfinder(n)
|
||||
|| has_own_paint(n)
|
||||
|| has_geometry(&n.appearance.effects)
|
||||
|| n.projection().is_some()
|
||||
|| n.appearance.items.iter().any(|i| has_geometry(item_effects(i)))
|
||||
|| n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c)))
|
||||
}
|
||||
|
|
@ -92,7 +93,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
|||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines.
|
||||
if needs_outline(n) && has_geometry(&n.appearance.effects) {
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ fn outline_nested_text(n: &mut Node) {
|
|||
/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None`
|
||||
/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if geometry_effects(n).is_empty() || !needs_outline(n) {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) {
|
||||
return None;
|
||||
}
|
||||
as_art(n, symbol_art)
|
||||
|
|
@ -146,6 +146,10 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
|||
_ => outline_art(n, symbol_art)?,
|
||||
};
|
||||
reshape_leaves(&mut art, &fx);
|
||||
// Type and symbols in perspective: the art projected onto their plane.
|
||||
if let Some(h) = n.projection() {
|
||||
project_leaves(&mut art, &h);
|
||||
}
|
||||
// The art is a group with `n`'s id (a clip group for an image).
|
||||
let mut out = art;
|
||||
out.name = n.name.clone();
|
||||
|
|
@ -159,6 +163,33 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
|||
Some(out)
|
||||
}
|
||||
|
||||
/// Map every path under `art` (and its gradients) through the projective map `h`, curves split
|
||||
/// finely enough to follow it.
|
||||
fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography) {
|
||||
let f = |p: vectorcraft_geom::Point| h.apply(p).unwrap_or(p);
|
||||
let piece = art.geometric_bounds().map_or(1.0, |b| (b.width().max(b.height()) / 64.0).max(0.25));
|
||||
let project = |path: &mut PathData| *path = vectorcraft_doc::live::map_nonlinear(path, piece, f);
|
||||
for_each_leaf(art, &mut |leaf| {
|
||||
leaf.pin_gradients();
|
||||
leaf.appearance.warp_gradients(&|p| h.affine_at(p).unwrap_or(vectorcraft_geom::Affine::IDENTITY));
|
||||
match &mut leaf.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
project(path);
|
||||
*live = None;
|
||||
}
|
||||
NodeKind::Compound { children, .. } => {
|
||||
for c in children.iter_mut() {
|
||||
if let NodeKind::Path { path, live, .. } = &mut Arc::make_mut(c).kind {
|
||||
project(path);
|
||||
*live = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The visible geometry effects of `n`'s own effect list, in order.
|
||||
pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
|
||||
n.appearance.effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect()
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, PuppetPin, WidthProfile}
|
|||
use vectorcraft_geom::{Affine, Homography, PathData, Point, Rect};
|
||||
use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample, reach_bounds};
|
||||
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
|
||||
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, stroke_owner, warp_from_rest, warp_node_with};
|
||||
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with};
|
||||
|
||||
use super::edit::{duplicate_in, selected_roots};
|
||||
use super::*;
|
||||
|
|
@ -182,6 +182,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
plane_match
|
||||
),
|
||||
cmd!(
|
||||
"perspective.editText",
|
||||
"Edit Text",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the selected type)} Object › Perspective › Edit Text: show type in perspective flat where it is drawn, in isolation mode, to edit it (text.* commands, the Type tool); exiting isolation (object.exitIsolation, Esc) projects it again → {id}",
|
||||
has_doc,
|
||||
edit_text
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -717,7 +726,7 @@ fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let h = old.homography_at(plane, depth).and_then(|h| h.inverse()).zip(g.homography_at(plane, depth));
|
||||
let (hi, h) = h.ok_or_else(|| EngineError::Other("an object in perspective would leave the edited grid".into()))?;
|
||||
let m = h.then_after(&hi);
|
||||
warp_checked(d, id, &|q| m.apply(q))?;
|
||||
warp_checked(d, id, &m)?;
|
||||
}
|
||||
}
|
||||
store_grid(d, &g);
|
||||
|
|
@ -757,20 +766,25 @@ fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
|||
}
|
||||
|
||||
/// Map `n` with `f`, failing if any point would leave the plane's visible side.
|
||||
fn warp_checked(d: &mut Document, id: NodeId, f: &dyn Fn(Point) -> Option<Point>) -> Result<()> {
|
||||
/// Map `n` with `h` (type and symbols in perspective keep their art and are drawn through it),
|
||||
/// failing if any point would leave the plane's visible side.
|
||||
fn warp_checked(d: &mut Document, id: NodeId, h: &Homography) -> Result<()> {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
let mut pts = vec![];
|
||||
collect_points(n, &mut pts);
|
||||
if pts.iter().any(|q| f(*q).is_none()) {
|
||||
if pts.iter().any(|q| h.apply(*q).is_none()) {
|
||||
return Err(EngineError::Other("the object would cross the horizon of the perspective plane".into()));
|
||||
}
|
||||
warp_node_with(n, &|q| f(q).unwrap_or(q));
|
||||
project_node(n, h);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane, snap: bool) -> Result<()> {
|
||||
/// Attach `id` to `plane`: projected by `map`, else by the map that keeps its bounds' corners
|
||||
/// (on the nearest gridlines with `snap`).
|
||||
fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane, snap: bool, map: Option<Homography>) -> Result<()> {
|
||||
let b = d.node(id).ok_or(EngineError::NoNode(id))?.geometric_bounds().ok_or_else(|| EngineError::Other("the object has no geometry".into()))?;
|
||||
let m = g.attach_map_with(plane, b, snap).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?;
|
||||
let m =
|
||||
map.or_else(|| g.attach_homography(plane, b, snap)).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?;
|
||||
warp_checked(d, id, &m)?;
|
||||
persp::set_attachment(d.node_mut(id).ok_or(EngineError::NoNode(id))?, plane, g.offset(plane));
|
||||
g.attached.insert(id.0.to_string(), plane);
|
||||
|
|
@ -783,7 +797,7 @@ fn attach(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let plane = plane_param(&g, p, "perspective.attach")?;
|
||||
s.edit("Attach to Active Plane", |d, _| {
|
||||
for id in &ids {
|
||||
attach_in(d, &mut g, *id, plane, false)?;
|
||||
attach_in(d, &mut g, *id, plane, false, None)?;
|
||||
}
|
||||
store_grid(d, &g);
|
||||
Ok(())
|
||||
|
|
@ -798,7 +812,7 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
for id in &ids {
|
||||
g.attached.remove(&id.0.to_string());
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.perspective = None;
|
||||
persp::release(n);
|
||||
}
|
||||
}
|
||||
store_grid(d, &g);
|
||||
|
|
@ -842,13 +856,13 @@ fn persp_edit(
|
|||
Some(a) => a,
|
||||
None => {
|
||||
let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?;
|
||||
attach_in(d, &mut g, *id, pl, false)?;
|
||||
attach_in(d, &mut g, *id, pl, false, None)?;
|
||||
(pl, g.offset(pl))
|
||||
}
|
||||
};
|
||||
first.get_or_insert((plane, depth));
|
||||
let (h, depth) = step(&g, plane, depth)?;
|
||||
warp_checked(d, *id, &|q| h.apply(q))?;
|
||||
warp_checked(d, *id, &h)?;
|
||||
persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, depth);
|
||||
}
|
||||
store_grid(d, &g);
|
||||
|
|
@ -986,7 +1000,7 @@ fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
ids = if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on };
|
||||
let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?;
|
||||
for id in &ids {
|
||||
warp_checked(d, *id, &|q| m.apply(q))?;
|
||||
warp_checked(d, *id, &m)?;
|
||||
persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, to);
|
||||
}
|
||||
}
|
||||
|
|
@ -1040,11 +1054,16 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let mut g = grid_of(&s.doc()?.doc);
|
||||
let plane = plane_param(&g, p, C)?;
|
||||
let snap = bool_or(p, "snap", g.snap);
|
||||
// A click-to-size dialog's shape keeps its sizes in plane units from the click.
|
||||
let map = match point_param(p, "at") {
|
||||
Some(at) => Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?),
|
||||
None => None,
|
||||
};
|
||||
let undo_before = s.doc()?.history.undo.len();
|
||||
let r = s.execute(&command, ¶ms)?;
|
||||
let id = r.get("id").and_then(Value::as_u64).map(NodeId).ok_or_else(|| EngineError::Other(format!("{command} didn't create an object")))?;
|
||||
let res = s.edit("Draw in Perspective", |d, _| {
|
||||
attach_in(d, &mut g, id, plane, snap)?;
|
||||
attach_in(d, &mut g, id, plane, snap, map)?;
|
||||
store_grid(d, &g);
|
||||
Ok(())
|
||||
});
|
||||
|
|
@ -1062,10 +1081,85 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
/// Shape tool previews become `perspective.draw` while the grid is shown with an active plane.
|
||||
pub(crate) fn perspective_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> {
|
||||
const SHAPES: &[&str] = &["shape.rectangle", "shape.ellipse", "shape.polygon", "shape.star", "shape.line", "shape.rectangularGrid", "shape.arc"];
|
||||
const SHAPES: &[&str] = &[
|
||||
"shape.rectangle",
|
||||
"shape.ellipse",
|
||||
"shape.polygon",
|
||||
"shape.star",
|
||||
"shape.line",
|
||||
"shape.rectangularGrid",
|
||||
"shape.arc",
|
||||
"shape.flare",
|
||||
"shape.spiral",
|
||||
"shape.polarGrid",
|
||||
];
|
||||
if !SHAPES.contains(&cmd) {
|
||||
return None;
|
||||
}
|
||||
let g = PerspectiveGrid::from_doc(&s.active()?.doc)?;
|
||||
let g = PerspectiveGrid::current(&s.active()?.doc);
|
||||
(g.visible && g.plane != Plane::None).then(|| ("perspective.draw".to_string(), json!({"command": cmd, "params": params})))
|
||||
}
|
||||
|
||||
/// A click-to-size shape dialog's command (`cmd` with `params`, clicked at `at`) as it runs: on the
|
||||
/// active plane while the grid shows (`perspective.draw` keeping the sizes in plane units from the
|
||||
/// click), else `None` (run it as it is).
|
||||
pub fn perspective_click(s: &Session, cmd: &str, params: &Value, at: Point) -> Option<(String, Value)> {
|
||||
let (c, mut p) = perspective_rewrite(s, cmd, params)?;
|
||||
p["at"] = json!([at.x, at.y]);
|
||||
Some((c, p))
|
||||
}
|
||||
|
||||
/// Object › Perspective › Edit Text: type in perspective shown flat where it is drawn, in isolation
|
||||
/// mode, to be edited; exiting isolation projects it again ([`finish_edit_text`]).
|
||||
fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id =
|
||||
id_param(p, "id").or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select type in perspective".into()))?;
|
||||
if !st.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) {
|
||||
return Err(EngineError::Other("select type in perspective".into()));
|
||||
}
|
||||
s.edit("Edit Text", |d, _| {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
let h = n.projection().ok_or_else(|| EngineError::Other("select type in perspective".into()))?;
|
||||
let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("select type in perspective".into())) };
|
||||
// Shown flat where it is drawn: the type moves there and the projection moves the other
|
||||
// way, so the picture stays.
|
||||
let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) };
|
||||
let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center());
|
||||
t.transform(shift);
|
||||
if let Some(rec) = n.perspective.as_deref_mut() {
|
||||
rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array());
|
||||
rec.editing = true;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.isolation = Some(id);
|
||||
st.selection.set([id]);
|
||||
st.revision += 1;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and
|
||||
/// in every undo state (the flat view is never an undo step of its own).
|
||||
pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
|
||||
let clear = |doc: &mut Arc<Document>| {
|
||||
if doc.node(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing)
|
||||
&& let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.perspective.as_deref_mut())
|
||||
{
|
||||
p.editing = false;
|
||||
}
|
||||
};
|
||||
let saved = Arc::ptr_eq(&st.doc, &st.saved_doc);
|
||||
clear(&mut st.doc);
|
||||
if saved {
|
||||
st.saved_doc = st.doc.clone();
|
||||
}
|
||||
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
|
||||
clear(&mut e.doc);
|
||||
}
|
||||
if let Some(it) = &mut st.interaction {
|
||||
clear(&mut it.doc);
|
||||
}
|
||||
st.revision += 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -665,6 +665,7 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc_mut()?;
|
||||
if let Some(i) = st.isolation.take() {
|
||||
super::distortcmds::finish_edit_text(st, i);
|
||||
st.selection.set([i]);
|
||||
}
|
||||
st.revision += 1;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ use vectorcraft_tools::{PaintDefaults, Tool};
|
|||
|
||||
pub use cmd::EyedropperOptions;
|
||||
pub use cmd::clipboard::Clipboard;
|
||||
pub use cmd::distortcmds::perspective_click;
|
||||
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use tooling::{UiRequest, ViewInfo};
|
||||
|
|
@ -1296,6 +1297,8 @@ mod tests_persp_planes;
|
|||
#[cfg(test)]
|
||||
mod tests_persp_select;
|
||||
#[cfg(test)]
|
||||
mod tests_persp_text;
|
||||
#[cfg(test)]
|
||||
mod tests_perspgrid;
|
||||
#[cfg(test)]
|
||||
mod tests_place;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
//! 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());
|
||||
}
|
||||
|
|
@ -236,6 +236,12 @@ fn structured_junk() {
|
|||
("perspective.plane.matchObject", json!({"id": u64::MAX})),
|
||||
("perspective.grid.set", json!({"leftOffset": -1e12, "reproject": true})),
|
||||
("perspective.grid.set", json!({"vpRight": -1e6, "reproject": true})),
|
||||
("perspective.editText", json!({"id": u64::MAX})),
|
||||
(
|
||||
"perspective.draw",
|
||||
json!({"command": "shape.rectangle", "params": {"x": 1, "y": 1, "width": 10, "height": 10}, "at": [1e308, -1e308], "plane": "left"}),
|
||||
),
|
||||
("perspective.draw", json!({"command": "shape.flare", "params": {"cx": 0, "cy": 0}, "plane": "ground"})),
|
||||
("object.setProps", json!({"opacity": -1, "blend": 5})),
|
||||
("effect.apply", json!({"effect": "distort.roughen", "params": {"size": 100, "detail": 100, "relative": false}})),
|
||||
("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 1e308, "ridges": 100}})),
|
||||
|
|
|
|||
|
|
@ -102,6 +102,18 @@ impl Homography {
|
|||
Some(Self::from_affine(a).then_after(self).then_after(&Self::from_affine(a.inverse())))
|
||||
}
|
||||
|
||||
/// The affine map that matches `self` to first order at `p` (its derivative there), `None`
|
||||
/// beyond the horizon.
|
||||
pub fn affine_at(&self, p: Point) -> Option<Affine> {
|
||||
let m = &self.0;
|
||||
let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2];
|
||||
let q = self.apply(p)?;
|
||||
// d(x/w) = (dx − (x/w)·dw) / w, and the same for y.
|
||||
let j = |r: usize, c: usize| (m[r][c] - [q.x, q.y][r] * m[2][c]) / w;
|
||||
let lin = Affine::new([j(0, 0), j(1, 0), j(0, 1), j(1, 1), 0.0, 0.0]);
|
||||
Some(Affine::translate(q.to_vec2()) * lin * Affine::translate(-p.to_vec2()))
|
||||
}
|
||||
|
||||
/// The bounding box of the image of `r` (lines stay lines: its corners bound it), `None` when
|
||||
/// part of it crosses the horizon.
|
||||
pub fn map_rect_bbox(&self, r: Rect) -> Option<Rect> {
|
||||
|
|
@ -151,6 +163,18 @@ mod tests {
|
|||
assert!(h().conjugated(Affine::scale(0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_at_is_the_derivative() {
|
||||
let p = Point::new(12.0, -7.0);
|
||||
let a = h().affine_at(p).unwrap();
|
||||
assert!(a * p == h().apply(p).unwrap() || (a * p).distance(h().apply(p).unwrap()) < 1e-9);
|
||||
let e = 1e-4;
|
||||
for d in [kurbo::Vec2::new(e, 0.0), kurbo::Vec2::new(0.0, e)] {
|
||||
let exact = h().apply(p + d).unwrap();
|
||||
assert!((a * (p + d)).distance(exact) < 1e-7, "{d:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_bbox_is_the_corners_box() {
|
||||
let b = h().map_rect_bbox(Rect::new(0.0, 0.0, 10.0, 20.0)).unwrap();
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ pub(crate) fn has_fx(n: &Node) -> bool {
|
|||
/// symbol instances, live objects; Crop Marks and colour adjustments on anything), or is it a group
|
||||
/// or layer with an appearance of its own (see [`Renderer::draw_object_fx`])?
|
||||
pub(crate) fn has_object_fx(n: &Node) -> bool {
|
||||
(effects::needs_outline(n) && visible(&n.appearance.effects))
|
||||
(effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some()))
|
||||
|| effects::has_container_appearance(n)
|
||||
|| effects::has_crop_marks(n)
|
||||
|| effects::has_adjustment(n)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ mod width;
|
|||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_geom::{Homography, Point};
|
||||
|
||||
use crate::Tool;
|
||||
|
||||
|
|
@ -46,6 +46,24 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
|||
/// editable points (text, images, symbols, meshes, live objects) get the approximation at their
|
||||
/// centre. Live shapes become plain paths.
|
||||
pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) {
|
||||
warp_node(n, f, None);
|
||||
}
|
||||
|
||||
/// [`warp_node_with`] for the projective map `h` (perspective): type and symbol instances keep
|
||||
/// their flat art and are drawn through `h` (composed with the projection they had).
|
||||
pub fn project_node(n: &mut Node, h: &Homography) {
|
||||
warp_node(n, &|p| h.apply(p).unwrap_or(p), Some(h));
|
||||
}
|
||||
|
||||
fn warp_node(n: &mut Node, f: &dyn Fn(Point) -> Point, h: Option<&Homography>) {
|
||||
if let Some(h) = h
|
||||
&& matches!(n.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. })
|
||||
{
|
||||
let rec = n.perspective.get_or_insert_with(Default::default);
|
||||
let had = rec.homography().unwrap_or(Homography::IDENTITY);
|
||||
rec.projection = Some(h.then_after(&had).to_array());
|
||||
return;
|
||||
}
|
||||
let bounds = n.geometric_bounds();
|
||||
// Finite-difference step: 5% of the object (at least half a point).
|
||||
let eps = bounds.map_or(0.5, |b| (b.width().max(b.height()) * 0.05).max(0.5));
|
||||
|
|
@ -66,7 +84,7 @@ pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) {
|
|||
| NodeKind::Compound { children, .. }
|
||||
| NodeKind::Blend { children, .. } => {
|
||||
for c in children.iter_mut() {
|
||||
warp_node_with(Arc::make_mut(c), f);
|
||||
warp_node(Arc::make_mut(c), f, h);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
|
|
|||
|
|
@ -480,11 +480,18 @@ impl PerspectiveGrid {
|
|||
/// bottom-right, then the plane homography projects it (so a drawn rectangle keeps the two
|
||||
/// corners the user dragged between).
|
||||
pub fn attach_map(&self, plane: Plane, b: Rect) -> Option<impl Fn(Point) -> Option<Point> + use<>> {
|
||||
self.attach_map_with(plane, b, false)
|
||||
let h = self.attach_homography(plane, b, false)?;
|
||||
Some(move |p: Point| h.apply(p))
|
||||
}
|
||||
|
||||
/// [`Self::attach_map`]; with `snap` the corners land on the nearest gridlines (Snap to Grid).
|
||||
pub fn attach_map_with(&self, plane: Plane, b: Rect, snap: bool) -> Option<impl Fn(Point) -> Option<Point> + use<>> {
|
||||
let h = self.attach_homography(plane, b, snap)?;
|
||||
Some(move |p: Point| h.apply(p))
|
||||
}
|
||||
|
||||
/// [`Self::attach_map_with`] as one projective map.
|
||||
pub fn attach_homography(&self, plane: Plane, b: Rect, snap: bool) -> Option<Homography> {
|
||||
let h = self.homography(plane)?;
|
||||
let hi = h.inverse()?;
|
||||
let snapped = |q: Point| if snap { self.snap_plane(q) } else { q };
|
||||
|
|
@ -493,8 +500,23 @@ impl PerspectiveGrid {
|
|||
let sx = if b.width().abs() > 1e-9 { (c.x - a.x) / b.width() } else { 1.0 };
|
||||
let sy = if b.height().abs() > 1e-9 { (c.y - a.y) / b.height() } else { sx.abs() * if c.y < a.y { -1.0 } else { 1.0 } };
|
||||
let sx = if b.width().abs() > 1e-9 { sx } else { sy.abs() };
|
||||
let (x0, y0) = (b.x0, b.y0);
|
||||
Some(move |p: Point| h.apply(Point::new(a.x + (p.x - x0) * sx, a.y + (p.y - y0) * sy)))
|
||||
let m = Affine::new([sx, 0.0, 0.0, sy, a.x - b.x0 * sx, a.y - b.y0 * sy]);
|
||||
Some(h.then_after(&Homography::from_affine(m)))
|
||||
}
|
||||
|
||||
/// The map that puts flat art drawn at `at` onto `plane` keeping its sizes in plane units (a
|
||||
/// click-to-size shape dialog's shape): `at` stays where it is, and right and down on the page
|
||||
/// go the ways the plane's axes run on the page there.
|
||||
/// With `snap` the click lands on the nearest gridline intersection (Snap to Grid).
|
||||
pub fn size_homography(&self, plane: Plane, at: Point, snap: bool) -> Option<Homography> {
|
||||
let h = self.homography(plane)?;
|
||||
let q = h.inverse()?.apply(at)?;
|
||||
let q = if snap { self.snap_plane(q) } else { q };
|
||||
let at = h.apply(q)?;
|
||||
let sx = if h.apply(q + Vec2::new(1.0, 0.0))?.x < at.x { -1.0 } else { 1.0 };
|
||||
let sy = if h.apply(q + Vec2::new(0.0, 1.0))?.y < at.y { -1.0 } else { 1.0 };
|
||||
let m = Affine::new([sx, 0.0, 0.0, sy, q.x - sx * at.x, q.y - sy * at.y]);
|
||||
Some(h.then_after(&Homography::from_affine(m)))
|
||||
}
|
||||
|
||||
/// The map that slides art lying on `plane` by the plane-space offset between `from` and `to`.
|
||||
|
|
@ -667,9 +689,20 @@ pub fn attached_roots(doc: &Document) -> Vec<(NodeId, Plane, f64)> {
|
|||
out
|
||||
}
|
||||
|
||||
/// Attach `n` to `plane` at `depth`.
|
||||
/// Attach `n` to `plane` at `depth` (type and symbols keep their projection).
|
||||
pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) {
|
||||
n.perspective = Some(Box::new(PerspectiveAttachment::new(plane.id(), depth)));
|
||||
let rec = n.perspective.get_or_insert_with(Default::default);
|
||||
rec.plane = plane.id().into();
|
||||
rec.depth = if depth.is_finite() { depth } else { 0.0 };
|
||||
}
|
||||
|
||||
/// Release `n` from the grid (Release with Perspective): it keeps its look, type and symbols their
|
||||
/// projection.
|
||||
pub fn release(n: &mut Node) {
|
||||
match n.perspective.as_deref_mut() {
|
||||
Some(rec) if rec.projection.is_some() => *rec = PerspectiveAttachment { projection: rec.projection, ..Default::default() },
|
||||
_ => n.perspective = None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounds of `n` mapped by `hi`: paths by their mapped curves, other objects by their boxes'
|
||||
|
|
@ -1076,7 +1109,16 @@ impl Tool for PerspectiveSelectionTool {
|
|||
}
|
||||
(PointerKind::DoubleClick, d) => {
|
||||
self.drag = d;
|
||||
PlaneDrag::hit(&g, cx.tol(6.0), p).map(|d| d.options()).into_iter().collect()
|
||||
if let Some(w) = PlaneDrag::hit(&g, cx.tol(6.0), p) {
|
||||
return vec![w.options()];
|
||||
}
|
||||
// Type in perspective: Edit Text, then type.
|
||||
match vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()).map(|h| h.top_object(cx.isolation)) {
|
||||
Some(id) if cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) => {
|
||||
vec![Action::Exec("perspective.editText".into(), json!({"id": id.0})), Action::SwitchTool("type".into())]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
(_, d) => {
|
||||
self.drag = d;
|
||||
|
|
|
|||
|
|
@ -36,5 +36,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
|||
let cmd = d.str("__command");
|
||||
let params = form::params(d);
|
||||
app.ui.dialog = None;
|
||||
// A tool's click-to-size shape (Flare) goes on the active perspective plane while the grid shows.
|
||||
let at = |x: &str, y: &str| Some(vectorcraft_geom::Point::new(params.get(x)?.as_f64()?, params.get(y)?.as_f64()?));
|
||||
if let Some((c, p)) =
|
||||
at("cx", "cy").or_else(|| at("x", "y")).and_then(|pt| vectorcraft_engine::perspective_click(&app.session, &cmd, ¶ms, pt))
|
||||
{
|
||||
return app.run(&c, p);
|
||||
}
|
||||
app.run(&cmd, params)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,4 +127,23 @@ mod tests {
|
|||
assert_eq!(heading(app.ui.dialog.as_ref().unwrap()), "Horizontal Plane");
|
||||
assert!(app.run("ui.perspectivePlane", json!({"plane": "up"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_to_size_rectangle_goes_on_the_active_plane() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 800, "height": 600})).unwrap();
|
||||
app.run("perspective.grid.preset", json!({"kind": 2})).unwrap();
|
||||
app.run("perspective.plane.set", json!({"plane": "right"})).unwrap();
|
||||
app.select_tool("rectangle");
|
||||
let view = app.view_info();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 470.0, 300.0), view);
|
||||
}
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("rectangle"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.journal.last().unwrap().0, "perspective.draw");
|
||||
let st = app.session.active().unwrap();
|
||||
let id = *st.selection.objects.first().unwrap();
|
||||
assert_eq!(vectorcraft_tools::distort::perspective::attachment(st.doc.node(id).unwrap()).map(|a| a.0), Some(Plane::Right));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
|||
json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), "radius": d.f64("radius", 0.0)}),
|
||||
),
|
||||
};
|
||||
run_and_close(app, id, params)
|
||||
// On the active plane while the perspective grid shows, sized in plane units from the click.
|
||||
match vectorcraft_engine::perspective_click(&app.session, id, ¶ms, vectorcraft_geom::Point::new(x, y)) {
|
||||
Some((id, params)) => run_and_close(app, &id, params),
|
||||
None => run_and_close(app, id, params),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1706,6 +1706,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
c("Attach to Active Plane", "perspective.attach"),
|
||||
c("Release with Perspective", "perspective.release"),
|
||||
c("Move Plane to Match Object", "perspective.plane.matchObject"),
|
||||
c("Edit Text", "perspective.editText"),
|
||||
],
|
||||
),
|
||||
sub(
|
||||
|
|
|
|||
15
docs/mcp.md
15
docs/mcp.md
|
|
@ -1907,6 +1907,21 @@ selected object onto it.
|
|||
{"name":"run_command","arguments":{"command":"ui.perspectivePlane","params":{"plane":"ground"}}}
|
||||
```
|
||||
|
||||
Type and symbol instances attached to a plane stay type and symbol instances: their `perspective` record keeps a
|
||||
`projection` (a 3 × 3 matrix) the canvas and every export (SVG, PDF, EPS, EMF/WMF, DXF, PNG) draw their outlines
|
||||
through, so they are really foreshortened and still editable. `perspective.editText` (Object › Perspective › Edit
|
||||
Text, or double-clicking the type with the Perspective Selection tool) shows the type flat where it is drawn, in
|
||||
isolation mode, for the Type tool and `text.*` commands; `object.exitIsolation` projects it again. Release with
|
||||
Perspective keeps their look. Shape, spiral, polar grid and flare drags draw on the active plane while the grid
|
||||
shows, and so do the click-to-size dialogs (`perspective.draw` with `at`: the click; sizes are plane units).
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"perspective.editText","params":{}}}
|
||||
{"name":"run_command","arguments":{"command":"text.setText","params":{"text":"OPEN"}}}
|
||||
{"name":"run_command","arguments":{"command":"object.exitIsolation","params":{}}}
|
||||
{"name":"run_command","arguments":{"command":"perspective.draw","params":{"command":"shape.rectangle","params":{"x":470,"y":300,"width":50,"height":30},"at":[470,300]}}}
|
||||
```
|
||||
|
||||
## Blends
|
||||
|
||||
`object.blend.make` blends the selected objects (or `ids`) into a live blend. Spacing and orientation default to
|
||||
|
|
|
|||
Loading…
Reference in New Issue