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 { 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>> { 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::>().map(Some) } pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result { 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::>>()?, ), 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::(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> { 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>, partial: Option<&mut BTreeSet>, picked: &BTreeSet, radius: Option, kind: Option, ) { 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); } }