mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 22:20:50 -06:00
Path Precision Tools gains two tools, kept out of the toolbars (All Tools drawer and Object › Plug-ins › Path Precision Tools, no shortcuts): - Corner Tool (cornerTool): editable corners on any path through the existing Live Corners. Hover shows an eligible anchor, a click cuts a corner with the tool's radius and kind, a drag sizes it (Shift: whole values), a corner's marker selects it and loads its values, resizes it and removes it at 0 or on a double-click; a corner never grows past what fits; an Alt-marquee cuts every eligible anchor inside. - Corners section in the Anchor Inspector: radius, kind, selected count, Apply to Selected Anchors. - Slide Anchor Along Path (slideAnchor): a drag runs an anchor along its curve, the neighbouring segments refitted by the keep-shape kernel; Shift snaps where the tangent is a multiple of 45° from the constrain angle. - Commands addon.pathtools.cornerSet, cornerRemove and slideAnchor, one undo step each, params validated and covered by the command sweep. - Cursor stand-ins, catalog rows in every language, docs and tests.
281 lines
12 KiB
Rust
281 lines
12 KiB
Rust
use vectorcraft_doc::AnchorRef;
|
||
use vectorcraft_geom::PathData;
|
||
|
||
use super::spans::Span;
|
||
use super::{
|
||
BTreeSet, CornerKind, EngineError, LiveCorners, LiveShape, NodeId, NodeKind, Result, Session, Value, bad, check_numbers, check_switches,
|
||
convert_radii_relative, ok, selected_roots, set_corners, targets,
|
||
};
|
||
|
||
/// The largest scale of 0 or more at which `spans`, with the margins that scale grown by `k`,
|
||
/// reach across `size` together. Returns 1 when their geometric boxes reach 1e-9 or less across
|
||
/// together, as the closed form in [`set_bounds`] does, and 0 when the spans reach farther than
|
||
/// `size` at the scale 0.
|
||
pub(super) fn scale_across(spans: &[Span], k: f64, size: f64) -> f64 {
|
||
let o = spans.iter().map(|s| s.lo).fold(f64::INFINITY, f64::min);
|
||
let across = spans.iter().map(|s| s.hi).fold(f64::NEG_INFINITY, f64::max) - o;
|
||
// How far the spans reach together after a scale by `s` about `o`, and how fast that changes
|
||
// with `s`: the far geometric end minus the near one, of the objects that reach farthest at the
|
||
// two ends.
|
||
let reach = |s: f64| {
|
||
let (mut start, mut end, mut slope_start, mut slope_end) = (f64::INFINITY, f64::NEG_INFINITY, 0.0, 0.0);
|
||
for sp in spans {
|
||
let (a, b) = sp.scaled(o, s, k);
|
||
if a < start {
|
||
(start, slope_start) = (a, sp.lo - o);
|
||
}
|
||
if b > end {
|
||
(end, slope_end) = (b, sp.hi - o);
|
||
}
|
||
}
|
||
(end - start, slope_end - slope_start)
|
||
};
|
||
if across.is_nan() || across <= 1e-9 {
|
||
return 1.0;
|
||
}
|
||
let least = reach(0.0).0;
|
||
if least.is_nan() || least > size {
|
||
return 0.0;
|
||
}
|
||
// `reach` is convex in `s` and at most `size` at 0, so it equals `size` at one largest scale.
|
||
// Newton's method, started at a larger scale, reaches it one linear piece of `reach` at a
|
||
// time; bisection finishes the search when a step makes no progress.
|
||
let (mut lo, mut hi) = (0.0, size / across);
|
||
for _ in 0..64 {
|
||
if reach(hi).0 >= size {
|
||
break;
|
||
}
|
||
hi = 2.0 * hi + 1.0;
|
||
}
|
||
for _ in 0..64 {
|
||
let (r, slope) = reach(hi);
|
||
if r <= size {
|
||
return hi;
|
||
}
|
||
let next = hi - (r - size) / slope;
|
||
if next.is_nan() || next <= lo || next >= hi {
|
||
break;
|
||
}
|
||
hi = next;
|
||
}
|
||
for _ in 0..64 {
|
||
let mid = lo + (hi - lo) / 2.0;
|
||
if mid <= lo || mid >= hi {
|
||
break;
|
||
}
|
||
if reach(mid).0 <= size {
|
||
lo = mid;
|
||
} else {
|
||
hi = mid;
|
||
}
|
||
}
|
||
if size - reach(lo).0 <= reach(hi).0 - size { lo } else { hi }
|
||
}
|
||
|
||
pub(super) fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||
let ids = selected_roots(s)?;
|
||
// With no live shape selected, the document and its undo history stay as they are.
|
||
let d = &s.doc()?.doc;
|
||
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { live: Some(_), .. }))) {
|
||
return ok();
|
||
}
|
||
s.edit("Expand Shape", |d, _| {
|
||
for id in &ids {
|
||
if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||
*live = None;
|
||
}
|
||
}
|
||
Ok(())
|
||
})?;
|
||
ok()
|
||
}
|
||
|
||
/// The `corners` of `object.setLiveShape`: anchor indices of a path's uncut outline.
|
||
fn corners_param(p: &Value) -> Result<Option<BTreeSet<usize>>> {
|
||
let Some(v) = p.get("corners").filter(|v| !v.is_null()) else { return Ok(None) };
|
||
let err = || bad("object.setLiveShape", format!("`corners` must list anchor indices (0, 1, 2…), not {v}"));
|
||
let list = v.as_array().ok_or_else(err)?;
|
||
list.iter().map(|k| k.as_u64().and_then(|k| usize::try_from(k).ok()).ok_or_else(err)).collect::<Result<_>>().map(Some)
|
||
}
|
||
|
||
pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||
check_switches(p, &["invertPie", "makeSidesEqual", "relativeRadii"], "object.setLiveShape")?;
|
||
check_numbers(p, &["radius", "sides"], "object.setLiveShape")?;
|
||
// `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them.
|
||
let items = match p.get("items").filter(|v| !v.is_null()) {
|
||
None => None,
|
||
Some(Value::Array(a)) => Some(
|
||
a.iter()
|
||
.map(|it| {
|
||
let id = it
|
||
.get("id")
|
||
.and_then(Value::as_u64)
|
||
.map(NodeId)
|
||
.ok_or_else(|| bad("object.setLiveShape", format!("each of `items` is {{id, corners?}} with an object id, not {it}")))?;
|
||
Ok((id, corners_param(it)?))
|
||
})
|
||
.collect::<Result<Vec<_>>>()?,
|
||
),
|
||
Some(v) => return Err(bad("object.setLiveShape", format!("`items` must be an array of {{id, corners?}}, not {v}"))),
|
||
};
|
||
let ids = match &items {
|
||
Some(items) => items.iter().map(|(id, _)| *id).collect(),
|
||
None => targets(s, p)?,
|
||
};
|
||
let shared = corners_param(p)?;
|
||
let corners_of = |id: NodeId| match &items {
|
||
Some(items) => items.iter().find(|(i, _)| *i == id).and_then(|(_, c)| c.clone()),
|
||
None => shared.clone(),
|
||
};
|
||
let kind = p
|
||
.get("kind")
|
||
.filter(|v| !v.is_null())
|
||
.map(|v| {
|
||
serde_json::from_value::<CornerKind>(v.clone())
|
||
.map_err(|_| bad("object.setLiveShape", format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}")))
|
||
})
|
||
.transpose()?;
|
||
let radius = p.get("radius").and_then(Value::as_f64);
|
||
let relative_radii = p.get("relativeRadii").and_then(Value::as_bool);
|
||
let sides = p.get("sides").and_then(Value::as_u64);
|
||
// A number that must be `what` (None when absent).
|
||
let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result<Option<f64>> {
|
||
match p.get(k) {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(v) => v
|
||
.as_f64()
|
||
.filter(|x| x.is_finite() && ok(*x))
|
||
.map(Some)
|
||
.ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be {what}, not {v}"))),
|
||
}
|
||
};
|
||
let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0)));
|
||
let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0");
|
||
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
|
||
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
|
||
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
|
||
// A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal.
|
||
let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?);
|
||
let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true);
|
||
// An ellipse's pie becomes `pie_of(pie)`. An end of 0 is a full turn (Illustrator shows a whole
|
||
// ellipse as 0° to 360°).
|
||
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
|
||
let pie_of = |pie: (f64, f64)| {
|
||
let pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
|
||
if invert { (pie.1.rem_euclid(360.0), end_of(pie.0)) } else { pie }
|
||
};
|
||
// When every value given is the current one, the document and its undo history stay as they
|
||
// are.
|
||
let st = s.doc()?;
|
||
let has_values = |id: &NodeId| {
|
||
let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true };
|
||
match live.as_deref() {
|
||
Some(l @ LiveShape::Polygon { sides: now, .. })
|
||
if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now))
|
||
&& (!equal || l.polygon_sides_equal())
|
||
&& polygon_angle.is_none_or(|a| l.polygon_angle() == Some(a))
|
||
&& side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius).is_none_or(|r| l.polygon_radius() == Some(r))) =>
|
||
{
|
||
return false;
|
||
}
|
||
Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false,
|
||
_ => {}
|
||
}
|
||
if relative_radii.is_some_and(|on| live.as_deref().map(|l| l.radii_relative()) != Some(on)) {
|
||
return false;
|
||
}
|
||
if radius.is_none() && kind.is_none() {
|
||
return true;
|
||
}
|
||
let Some(c) = LiveCorners::new(path, live.as_deref()) else { return true };
|
||
let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id)));
|
||
// Setting corners folds a scale left in a rectangle's transform into its size, which
|
||
// changes it.
|
||
live.as_deref().is_none_or(|l| l.folded() == *l)
|
||
&& picked.iter().all(|k| {
|
||
*k < c.base.anchor_count()
|
||
&& radius.is_none_or(|r| (c.drawn_radius(*k) - r.max(0.0)).abs() < 1e-9)
|
||
&& kind.is_none_or(|kd| c.kind(*k) == kd)
|
||
})
|
||
};
|
||
if ids.iter().all(has_values) {
|
||
return ok();
|
||
}
|
||
s.edit("Live Shape", |d, sel| {
|
||
for id in &ids {
|
||
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||
if let Some(l @ LiveShape::Polygon { .. }) = live.as_deref_mut()
|
||
&& (sides.is_some() || polygon_angle.is_some() || polygon_radius.is_some() || side.is_some() || equal)
|
||
{
|
||
if let Some(n) = sides {
|
||
l.set_sides(n);
|
||
}
|
||
if equal {
|
||
l.make_sides_equal();
|
||
}
|
||
if let Some(a) = polygon_angle {
|
||
l.set_polygon_angle(a);
|
||
}
|
||
// A side length sets the radius that gives it (with the new side count).
|
||
if let Some(r) = side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius) {
|
||
l.set_polygon_radius(r);
|
||
}
|
||
*path = l.to_path();
|
||
}
|
||
if let Some(l @ LiveShape::Ellipse { .. }) = live.as_deref_mut()
|
||
&& (pie_start.is_some() || pie_end.is_some() || invert)
|
||
{
|
||
if let LiveShape::Ellipse { pie, .. } = l {
|
||
*pie = pie_of(*pie);
|
||
}
|
||
*path = l.to_path();
|
||
}
|
||
if let Some(on) = relative_radii
|
||
&& let Some(l) = live.as_deref_mut()
|
||
{
|
||
convert_radii_relative(l, on);
|
||
*path = l.to_path();
|
||
}
|
||
if radius.is_none() && kind.is_none() {
|
||
continue;
|
||
}
|
||
let partial = sel.anchors.get_mut(id);
|
||
// The corners to edit, the Direct-Selected ones and the anchor layout before the edit.
|
||
let Some(c) = LiveCorners::new(path, live.as_deref()) else { continue };
|
||
let corners = corners_of(*id);
|
||
let picked = match &corners {
|
||
Some(k) if k.last().is_some_and(|k| *k >= c.base.anchor_count()) => {
|
||
let n = c.base.anchor_count();
|
||
let past: Vec<_> = k.range(n..).collect();
|
||
return Err(bad("object.setLiveShape", format!("object {id} has anchors 0–{} for `corners`, not {past:?}", n.saturating_sub(1))));
|
||
}
|
||
Some(k) => k.clone(),
|
||
None => c.picked(partial.as_deref()),
|
||
};
|
||
recut(path, live, partial, &picked, radius, kind);
|
||
}
|
||
Ok(())
|
||
})?;
|
||
ok()
|
||
}
|
||
|
||
/// [`set_corners`] with the Direct-Selected corners (`partial`, the path's selected anchors)
|
||
/// staying selected as they gain or lose anchors.
|
||
pub(in crate::cmd) fn recut(
|
||
path: &mut PathData,
|
||
live: &mut Option<Box<LiveShape>>,
|
||
partial: Option<&mut BTreeSet<AnchorRef>>,
|
||
picked: &BTreeSet<usize>,
|
||
radius: Option<f64>,
|
||
kind: Option<CornerKind>,
|
||
) {
|
||
let before = LiveCorners::new(path, live.as_deref()).map(|c| (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec()));
|
||
set_corners(path, live, picked, radius, kind);
|
||
if let (Some(anchors), Some((Some(selected), layout))) = (partial, before)
|
||
&& let Some(c) = LiveCorners::new(path, live.as_deref())
|
||
&& c.sources() != layout
|
||
{
|
||
*anchors = c.anchors_of(&selected);
|
||
}
|
||
}
|