mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #200 from storytold/m8-perspective-type-symbols
M8.14: Type and symbols in perspective are really projected and stay editable; attachments survive copies
This commit is contained in:
19 files changed
+613
-31
No files matched your search
+1
-1
@@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys, M8.19 blend spine and keys editable on canvas, Release keeps spine, M8.6 Puppet Warp pins appear at once, warp from rest shape, follow Undo, M8.13 movable perspective planes, plane dialog, Move Plane to Match Object, M8.10 perspective Lock Grid, Lock Station Point, Snap to Grid, Show Rulers, M8.4 Liquify hold-to-apply, safe scope, incremental strokes, M8.7 Width tool on compound paths, every subpath, cursors, M8.20 blend interpolation fidelity, knockout, Expand/Release keep attributes, M8.16 envelopes bend images, symbols, gradients, patterns, appearance; warps keep their frame) | 8–12 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys, M8.19 blend spine and keys editable on canvas, Release keeps spine, M8.6 Puppet Warp pins appear at once, warp from rest shape, follow Undo, M8.13 movable perspective planes, plane dialog, Move Plane to Match Object, M8.10 perspective Lock Grid, Lock Station Point, Snap to Grid, Show Rulers, M8.4 Liquify hold-to-apply, safe scope, incremental strokes, M8.7 Width tool on compound paths, every subpath, cursors, M8.20 blend interpolation fidelity, knockout, Expand/Release keep attributes, M8.16 envelopes bend images, symbols, gradients, patterns, appearance; warps keep their frame, M8.14 type and symbols really projected with Edit Text, attachments survive copies) | 8–12 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user