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:
bflatastic 2026-10-06 11:50:03 +07:00
parent 5a13626faa
commit 8255d606ae
18 changed files with 612 additions and 30 deletions

View File

@ -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();

View File

@ -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());
}
}
}

View File

@ -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,

View File

@ -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()

View File

@ -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, &params)?;
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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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());
}

View File

@ -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}})),

View File

@ -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();

View File

@ -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)

View File

@ -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);
}
}
_ => {

View File

@ -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;

View File

@ -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, &params, pt))
{
return app.run(&c, p);
}
app.run(&cmd, params)
}

View File

@ -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));
}
}

View File

@ -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, &params, vectorcraft_geom::Point::new(x, y)) {
Some((id, params)) => run_and_close(app, &id, params),
None => run_and_close(app, id, params),
}
}

View File

@ -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(

View File

@ -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