Merge main into #2199

This commit is contained in:
bflatastic
2026-11-12 12:57:19 +07:00
8 changed files with 390 additions and 25 deletions
+101 -13
View File
@@ -10,6 +10,8 @@
pub mod ops;
pub(crate) mod point_snap;
mod pointer;
#[cfg(test)]
mod position_tests;
pub mod regions;
#[cfg(test)]
mod regions_tests;
@@ -706,7 +708,61 @@ fn handle_list(p: &Value, key: &str) -> Option<Vec<(usize, bool)>> {
}
}
/// Selection bounds' top-left in the parent's coordinates (what the X/Y fields show).
/// One axis of `vector.edit.position`: left alone, set, or relative to each point's own value.
#[derive(Clone, Copy)]
enum AxisEdit {
Keep,
Set(f64),
Add(f64),
Mul(f64),
Div(f64),
}
impl AxisEdit {
fn apply(self, cur: f64) -> Result<f64> {
let v = match self {
AxisEdit::Keep => cur,
AxisEdit::Set(v) => v,
AxisEdit::Add(n) => cur + n,
AxisEdit::Mul(n) => cur * n,
AxisEdit::Div(n) if n != 0.0 => cur / n,
AxisEdit::Div(_) => return Err(bad("vector.edit.position", "cannot divide by zero")),
};
if v.is_finite() { Ok(v) } else { Err(bad("vector.edit.position", "the result is not a number")) }
}
}
/// Parse `p[key]`: absent is `Keep`, a number is `Set`, `{add|mul|div: n}` is relative; anything else is refused.
fn axis_edit(p: &Value, key: &str) -> Result<AxisEdit> {
let junk = || bad("vector.edit.position", format!("`{key}` must be a number or {{add|mul|div: number}}"));
match get(p, key) {
None => Ok(AxisEdit::Keep),
Some(Value::Object(o)) => {
let (Some((k, v)), 1) = (o.iter().next(), o.len()) else { return Err(junk()) };
let n = match v {
Value::Number(n) => n.as_f64().filter(|n| n.is_finite()),
_ => None,
}
.ok_or_else(junk)?;
match k.as_str() {
"add" => Ok(AxisEdit::Add(n)),
"mul" => Ok(AxisEdit::Mul(n)),
"div" if n != 0.0 => Ok(AxisEdit::Div(n)),
_ => Err(junk()),
}
}
Some(v) => f64_of(v).filter(|_| !v.is_boolean()).map(AxisEdit::Set).ok_or_else(junk),
}
}
/// The selected points in the parent's coordinates (what the X/Y fields show).
pub fn selected_parent_points(doc: &Document, ve: &VectorEdit) -> Vec<Point> {
let Some(net) = network(doc, ve.id) else { return Vec::new() };
let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default();
selected_points(net, ve).into_iter().filter_map(|v| net.vertices.get(v)).map(|v| t * v.point()).collect()
}
/// Selection bounds' top-left in the parent's coordinates (the `x` and `y` of `vector.edit.bounds`).
pub fn selection_position(doc: &Document, ve: &VectorEdit) -> Option<Point> {
let net = network(doc, ve.id)?;
let t = doc.node(ve.id)?.transform;
@@ -935,18 +991,50 @@ pub fn specs() -> Vec<CommandSpec> {
edit_sets(s, sets, |net, v, w| ops::move_vertices(net, v, local_offset(w, d)))?;
Ok(json!({"ok": true}))
}),
spec!("vector.edit.position", "Point position", "{x?, y?} (the selected points' top-left, in the parent's coordinates)", true, |s, p| {
let ve = editing(s)?;
let doc = s.doc()?;
let cur = selection_position(doc, &ve).ok_or_else(|| bad("vector.edit.position", "select points"))?;
let target = Point::new(num_or(p, "x", cur.x), num_or(p, "y", cur.y));
let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default();
let inv = t.inverse();
let d = inv * target - inv * cur;
let v = network(doc, ve.id).map(|n| selected_points(n, &ve)).unwrap_or_default();
edit(s.doc_mut()?, ve.id, |net| ops::move_vertices(net, &v, d))?;
Ok(json!({"ok": true}))
}),
spec!(
"vector.edit.position",
"Point position",
"{x?, y?}: each a number (every selected point's own coordinate becomes it) or {add: n}, {mul: n} or {div: n} (applied to each selected point's own coordinate); in the parent's coordinates",
true,
|s, p| {
let ve = editing(s)?;
let doc = s.doc()?;
let (ax, ay) = (axis_edit(p, "x")?, axis_edit(p, "y")?);
let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default();
let inv = t.inverse();
let net = network(doc, ve.id).ok_or_else(|| bad("vector.edit.position", "select points"))?;
let mut moves: Vec<(usize, Vec2)> = Vec::new();
for i in selected_points(net, &ve) {
let Some(v) = net.vertices.get(i) else { continue };
let cur = t * v.point();
let target = Point::new(ax.apply(cur.x)?, ay.apply(cur.y)?);
let d = inv * target - v.point();
if !(d.x.is_finite() && d.y.is_finite()) {
return Err(bad("vector.edit.position", "the result is not a number"));
}
moves.push((i, d));
}
if moves.is_empty() {
return Err(bad("vector.edit.position", "select points"));
}
// Points that all share one offset move as one, so moving every point of a layer
// by one offset still carries the regions' paint frames, as a drag of all of them
// does. Otherwise no one move covers every point, so each point moves alone.
let d0 = moves[0].1;
let uniform = moves.iter().all(|(_, d)| (*d - d0).hypot() < 1e-9);
edit(s.doc_mut()?, ve.id, |net| {
if uniform {
let all: Vec<usize> = moves.iter().map(|(i, _)| *i).collect();
ops::move_vertices(net, &all, d0);
} else {
for (i, d) in &moves {
ops::move_vertices(net, &[*i], *d);
}
}
})?;
Ok(json!({"ok": true}))
}
),
spec!("vector.edit.setHandle", "Set handle", "{segment, start (bool), x, y (local), independent?}", true, |s, p| {
let ve = editing(s)?;
let seg = index("vector.edit.setHandle", p, "segment")?;
@@ -0,0 +1,171 @@
//! `vector.edit.position`: with several points, a number sets every selected point's own
//! coordinate, and `{add}`, `{mul}`, `{div}` apply to each point's own.
use serde_json::json;
use super::tests::{ex, net, rect};
use super::*;
use crate::Session;
/// Every point in the parent's coordinates (the layer re-fits its box after an edit, so its own
/// coordinates shift).
fn xs(s: &Session, id: NodeId) -> Vec<(f64, f64)> {
let t = s.doc().unwrap().node(id).unwrap().transform;
net(s, id).vertices.iter().map(|v| t * v.point()).map(|p| (p.x, p.y)).collect()
}
fn parent_xy(s: &Session) -> Vec<(f64, f64)> {
let ve = s.ds().unwrap().vector_edit.clone().unwrap();
selected_parent_points(s.doc().unwrap(), &ve).iter().map(|p| (p.x, p.y)).collect()
}
fn undo_steps(s: &Session) -> usize {
s.ds().unwrap().history.undo.len()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn a_number_sets_every_selected_points_own_coordinate() {
let mut s = Session::new();
let id = rect(&mut s);
let before = xs(&s, id);
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
// The rectangle's points 0 and 1 differ in x; both end at -200 and keep their y.
assert!(!near(before[0].0, before[1].0));
ex(&mut s, "vector.edit.position", json!({"x": -200}));
let after = xs(&s, id);
assert!(near(after[0].0, -200.0) && near(after[1].0, -200.0), "{after:?}");
assert!(near(after[0].1, before[0].1) && near(after[1].1, before[1].1));
assert_eq!(after[2], before[2]);
// One undo step restores.
assert!(s.undo().unwrap());
assert_eq!(xs(&s, id), before);
}
#[test]
fn one_point_sets_its_own_coordinates() {
let mut s = Session::new();
let id = rect(&mut s);
let before = xs(&s, id);
ex(&mut s, "vector.edit.select", json!({"vertices": [2]}));
ex(&mut s, "vector.edit.position", json!({"x": 7, "y": 9}));
let after = xs(&s, id);
assert!(near(after[2].0, 7.0) && near(after[2].1, 9.0));
assert_eq!(after[0], before[0]);
}
#[test]
fn relative_edits_apply_to_each_points_own_value() {
let mut s = Session::new();
let id = rect(&mut s);
let before = xs(&s, id);
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
ex(&mut s, "vector.edit.position", json!({"x": {"add": 10}}));
let a = xs(&s, id);
assert!(near(a[0].0, before[0].0 + 10.0) && near(a[1].0, before[1].0 + 10.0), "{a:?}");
ex(&mut s, "vector.edit.position", json!({"y": {"mul": 2}}));
let b = xs(&s, id);
assert!(near(b[0].1, a[0].1 * 2.0) && near(b[1].1, a[1].1 * 2.0), "{b:?}");
ex(&mut s, "vector.edit.position", json!({"x": {"div": 2}}));
let c = xs(&s, id);
assert!(near(c[0].0, a[0].0 / 2.0) && near(c[1].0, a[1].0 / 2.0), "{c:?}");
}
#[test]
fn a_point_keeps_its_handles_relative_to_it() {
let mut s = Session::new();
let id = rect(&mut s);
ex(&mut s, "vector.edit.select", json!({"vertices": [1]}));
ex(&mut s, "vector.edit.smooth", json!({}));
let before = net(&s, id);
assert!(before.segments.iter().any(|g| g.ta != (0.0, 0.0) || g.tb != (0.0, 0.0)));
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
ex(&mut s, "vector.edit.position", json!({"x": {"add": 25}}));
let after = net(&s, id);
let tangents = |n: &VectorNetwork| n.segments.iter().map(|g| (g.ta, g.tb)).collect::<Vec<_>>();
assert_eq!(tangents(&before), tangents(&after));
}
#[test]
fn the_values_are_in_the_parents_coordinates() {
let mut s = Session::new();
let r = ex(&mut s, "shape.rectangle", json!({"x": 40, "y": 30, "width": 100, "height": 100}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
let before = parent_xy(&s);
assert!(near(before[0].0, 40.0), "{before:?}");
ex(&mut s, "vector.edit.position", json!({"x": 10}));
let after = parent_xy(&s);
assert!(near(after[0].0, 10.0) && near(after[1].0, 10.0), "{after:?}");
assert!(near(after[0].1, before[0].1));
}
#[test]
fn moving_every_point_by_one_offset_carries_the_regions_paint_frame() {
// `{add}` on every point is a plain translation: the region's paint frame goes with the
// points, as when they are dragged together.
let mut s = Session::new();
let id = rect(&mut s);
{
let node = s.doc_mut().unwrap().get_mut(id).unwrap();
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected a vector") };
assert!(!network.regions.is_empty(), "the rectangle has a region");
for region in &mut network.regions {
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: 100.0, height: 100.0, transform: Affine::IDENTITY });
}
}
let frame = |s: &Session| {
let world = s.doc().unwrap().world_transform(id);
world * net(s, id).regions[0].paint_space.as_ref().unwrap().transform
};
let before = frame(&s);
let all: Vec<usize> = (0..net(&s, id).vertices.len()).collect();
ex(&mut s, "vector.edit.select", json!({"vertices": all}));
ex(&mut s, "vector.edit.position", json!({"x": {"add": 10}}));
let (want, got) = ((Affine::translate((10.0, 0.0)) * before).as_coeffs(), frame(&s).as_coeffs());
assert!(want.iter().zip(got).all(|(a, b)| near(*a, b)), "{got:?} vs {want:?}");
}
#[test]
fn junk_is_refused_and_changes_nothing() {
let mut s = Session::new();
let id = rect(&mut s);
let before = xs(&s, id);
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
let steps = undo_steps(&s);
for junk in [
json!({"x": {"div": 0}}),
json!({"x": {"pow": 2}}),
json!({"x": "a"}),
json!({"x": {"add": 1, "mul": 2}}),
json!({"y": {"add": "x"}}),
json!({"x": {}}),
json!({"x": [1]}),
json!({"x": true}),
json!({"x": {"add": true}}),
// A valid x with a refused y changes neither.
json!({"x": 5, "y": {"div": 0}}),
// Point 1's x of 100 overflows; point 0's 0 alone would not.
json!({"x": {"mul": 1e308}}),
] {
assert!(s.execute("vector.edit.position", &junk).is_err(), "{junk}");
}
assert_eq!(xs(&s, id), before);
assert_eq!(undo_steps(&s), steps, "a refused edit leaves no undo step");
}
#[test]
fn nothing_selected_is_refused() {
let mut s = Session::new();
let id = rect(&mut s);
ex(&mut s, "vector.edit.select", json!({"vertices": []}));
let (before, steps) = (xs(&s, id), undo_steps(&s));
assert!(parent_xy(&s).is_empty());
let err = s.execute("vector.edit.position", &json!({"x": 5})).unwrap_err();
assert!(err.to_string().contains("select points"), "{err}");
assert_eq!((xs(&s, id), undo_steps(&s)), (before, steps));
}
+4 -4
View File
@@ -189,7 +189,7 @@ fn setup() -> (Ui, NodeId, NodeId) {
/// The topmost field named `name` in the Design panel: its value centre and displayed text.
/// Layer opacity precedes the equally named opacity fields in paint rows.
fn field(u: &mut Ui, name: &str) -> (Pos2, String) {
pub(crate) fn field(u: &mut Ui, name: &str) -> (Pos2, String) {
u.frame(vec![]);
let out = u.frame_output(vec![]);
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
@@ -234,7 +234,7 @@ fn geom(u: &Ui, n: NodeId) -> (f64, f64, f64, f64) {
(b.x0, b.y0, b.width(), b.height())
}
fn undo_steps(u: &Ui) -> usize {
pub(crate) fn undo_steps(u: &Ui) -> usize {
u.app.session.ds().unwrap().history.undo.len()
}
@@ -252,7 +252,7 @@ fn click(u: &mut Ui, p: Pos2) {
}
/// Click into field `name`, type `text` over what it shows and press `end`.
fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) {
pub(crate) fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) {
let (p, _) = field(u, name);
click(u, p);
u.frame(vec![Event::Text(text.into())]);
@@ -261,7 +261,7 @@ fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) {
}
/// Scrub field `name` by its label: `dx` points with `modifiers` held, then let go.
fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) {
pub(crate) fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) {
let from = label(u, name);
drag(u, from, dx, modifiers);
}
+2
View File
@@ -292,6 +292,8 @@ mod vector_keys_tests;
#[cfg(test)]
mod vector_panel_tests;
#[cfg(test)]
mod vector_point_xy_tests;
#[cfg(test)]
mod vector_tail_ui_tests;
#[cfg(test)]
mod vote_history_ui_tests;
+28 -6
View File
@@ -12,7 +12,7 @@ use crate::FigApp;
use crate::i18n::tr;
use crate::icons::Icon;
use crate::right::{CAP_ART_W, cap_art, join_label, menu_item, menu_item_with, menu_popup, row};
use crate::widgets::{self, FieldEdit, Lead};
use crate::widgets::{self, Edit, FieldEdit, Lead, Rel};
/// Run a point command from a field: typed values are one step, scrubs one step when released.
fn point_field(app: &mut FigApp, cmd: &str, edit: Option<FieldEdit>, params: impl Fn(f64) -> Value) {
@@ -31,6 +31,23 @@ fn point_field(app: &mut FigApp, cmd: &str, edit: Option<FieldEdit>, params: imp
}
}
/// A point position field's edit: a value (typed or scrubbed) sets every selected point's own
/// coordinate; a relative edit of a Mixed field (`+10`, `*2`) applies to each point's own.
fn point_edit(app: &mut FigApp, axis: &'static str, edit: Option<Edit>) {
match edit {
Some(Edit::All(e)) => point_field(app, "vector.edit.position", Some(e), |v| json!({ axis: v })),
Some(Edit::Each(rel)) => {
let op = match rel {
Rel::Add(n) => json!({"add": n}),
Rel::Mul(n) => json!({"mul": n}),
Rel::Div(n) => json!({"div": n}),
};
app.run("vector.edit.position", json!({ axis: op }));
}
None => {}
}
}
/// The Variable width tool's selected width point: where it is along the path and its width.
fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
widgets::section_header(ui, tr("Width point"), &[], false);
@@ -88,14 +105,19 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
return;
}
let col = widgets::col_width(ui.available_width());
let pos = vectoredit::selection_position(doc, ve);
let round = |v: f64| (v * 100.0).round() / 100.0;
let parent_pts = vectoredit::selected_parent_points(doc, ve);
// The value the selected points share (within 1e-6), or none: Mixed.
let shared = |axis: fn(&figcraft_geom::Point) -> f64| {
let first = parent_pts.first().map(axis)?;
parent_pts.iter().all(|p| (axis(p) - first).abs() < 1e-6).then(|| round(first))
};
widgets::sublabels(ui, tr("Position"), None);
row(ui, 32.0, |ui| {
let e = widgets::number_field(ui, Id::unique("vpt_x"), Lead::Text("X"), pos.map(|p| round(p.x)), "", col, 1.0);
point_field(app, "vector.edit.position", e, |v| json!({"x": v}));
let e = widgets::number_field(ui, Id::unique("vpt_y"), Lead::Text("Y"), pos.map(|p| round(p.y)), "", col, 1.0);
point_field(app, "vector.edit.position", e, |v| json!({"y": v}));
let e = widgets::number_field_mixed(ui, Id::unique("vpt_x"), Lead::Text("X"), shared(|p| p.x), "", col, 1.0);
point_edit(app, "x", e);
let e = widgets::number_field_mixed(ui, Id::unique("vpt_y"), Lead::Text("Y"), shared(|p| p.y), "", col, 1.0);
point_edit(app, "y", e);
});
if let Some(b) = vectoredit::transform::point_box(doc, ve).filter(|b| b.points.len() > 1) {
widgets::sublabels(ui, tr("Dimensions"), None);
@@ -0,0 +1,79 @@
//! The vector edit panel's Point section: X and Y show the value the selected points share, or
//! "Mixed", and a typed value sets every selected point's own coordinate.
use egui::{Key, Modifiers};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::field_tests::{field, scrub, type_into, undo_steps};
use crate::layers_drag_tests::Ui;
fn setup() -> (Ui, NodeId) {
let mut s = Session::new();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("vector.edit.begin", &json!({"id": id})).unwrap();
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
u.frame(vec![]);
(u, id)
}
fn xs(u: &Ui, id: NodeId) -> Vec<(f64, f64)> {
let doc = u.app.session.doc().unwrap();
// In the parent's coordinates: the layer re-fits its box after an edit.
let t = doc.node(id).unwrap().transform;
figcraft_engine::vectoredit::network(doc, id).unwrap().vertices.iter().map(|v| t * v.point()).map(|p| (p.x, p.y)).collect()
}
#[test]
fn x_reads_mixed_for_points_that_differ_and_typing_sets_every_point() {
let (mut u, id) = setup();
u.app.session.execute("vector.edit.select", &json!({"vertices": [0, 1]})).unwrap();
let before = xs(&u, id);
// Points 0 and 1 share their y but not their x.
assert_eq!(field(&mut u, "X").1, "Mixed");
assert_eq!(field(&mut u, "Y").1, "0");
type_into(&mut u, "X", "-200", Key::Enter);
let after = xs(&u, id);
assert_eq!((after[0].0, after[1].0), (-200.0, -200.0));
assert_eq!((after[0].1, after[1].1), (before[0].1, before[1].1));
assert_eq!(field(&mut u, "X").1, "-200", "the points share an x now");
// A relative edit of a Mixed field goes to each point from its own value.
u.app.session.execute("vector.edit.select", &json!({"vertices": [1, 2]})).unwrap();
assert_eq!(field(&mut u, "X").1, "Mixed");
let b = xs(&u, id);
type_into(&mut u, "X", "+10", Key::Enter);
let a = xs(&u, id);
assert_eq!((a[1].0, a[2].0), (b[1].0 + 10.0, b[2].0 + 10.0));
}
#[test]
fn one_point_shows_its_own_value() {
let (mut u, id) = setup();
u.app.session.execute("vector.edit.select", &json!({"vertices": [2]})).unwrap();
let p = xs(&u, id)[2];
assert_eq!(field(&mut u, "X").1, format!("{}", p.0));
assert_eq!(field(&mut u, "Y").1, format!("{}", p.1));
}
#[test]
fn a_shared_value_scrubs_every_point_in_one_step_and_mixed_does_not_scrub() {
let (mut u, id) = setup();
// Points 0 and 1 share their y: a scrub moves both to the same new y, as one undo step.
u.app.session.execute("vector.edit.select", &json!({"vertices": [0, 1]})).unwrap();
let (before, steps) = (xs(&u, id), undo_steps(&u));
scrub(&mut u, "Y", 20.0, Modifiers::NONE);
let after = xs(&u, id);
assert!(after[0].1 > before[0].1, "the scrub moved y: {before:?} -> {after:?}");
assert_eq!(after[0].1, after[1].1, "both points take the scrubbed value");
assert_eq!((after[0].0, after[1].0), (before[0].0, before[1].0), "x is untouched");
assert_eq!(undo_steps(&u), steps + 1, "one undo step for the whole scrub");
// Their x differs (Mixed): there is no value to scrub from, so nothing moves.
assert_eq!(field(&mut u, "X").1, "Mixed");
let (before, steps) = (xs(&u, id), undo_steps(&u));
scrub(&mut u, "X", 20.0, Modifiers::NONE);
assert_eq!((xs(&u, id), undo_steps(&u)), (before, steps));
}
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long