diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 8ede7f66..4f9ce535 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Width Point", [], None, - "{id, t: 0..1 (fraction of the path length), left, right: side widths in pt, index?: width point to edit or move, adjustAdjoining?: bool (with index: the nearest points either side change their widths in proportion)} → {index}. Without index a point already at t is replaced; a point moved onto another one's t joins it as a discontinuous point (on the side it came from), so the width steps there. Creates a uniform profile first if needed", + "{id, t: 0..1 (fraction of the path length), left, right: side widths in pt, index?: width point to edit or move, adjustAdjoining?: bool (the nearest points either side change their widths in proportion, using the sampled widths for a new point)} → {index}. Without index a point already at t is replaced; a point moved onto another one's t joins it as a discontinuous point (on the side it came from), so the width steps there. Creates a uniform profile first if needed", has_doc, width_point_set ), @@ -311,8 +311,13 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result { } } None => { + let old = adjust.then(|| prof.at(t)); prof.points.retain(|q| (q.0 - t).abs() > SAME_T); - place(&mut prof.points, pt, true) + let i = place(&mut prof.points, pt, true); + if let Some((left, right)) = old { + adjust_adjoining(&mut prof.points, i, (t, left, right), pt); + } + i } }; st.profile = Some(prof); diff --git a/crates/engine/src/tests_widthpoints.rs b/crates/engine/src/tests_widthpoints.rs index 3ef536ea..6e77cd1a 100644 --- a/crates/engine/src/tests_widthpoints.rs +++ b/crates/engine/src/tests_widthpoints.rs @@ -175,3 +175,58 @@ fn flipping_along_swaps_the_sides_of_a_discontinuous_point() { s.execute("stroke.setAdvanced", &json!({"flipProfile": "along"})).unwrap(); assert_eq!(points(&s, id), vec![(0.0, 2.0, 2.0), (0.75, 2.0, 2.0), (0.75, 1.0, 1.0), (1.0, 1.0, 1.0)]); } + +fn new_point_adjoining(adjust: bool) { + let (mut s, id) = line_session(); + s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": [[0, 1, 1], [1, 1, 1]]})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let i = set(&mut s, json!({"id": id.0, "index": null, "t": 0.5, "left": 10, "right": 10, "adjustAdjoining": adjust})); + assert_eq!(i, 1); + let end = if adjust { 2.0 } else { 1.0 }; + assert_eq!(points(&s, id), vec![(0.0, end, end), (0.5, 2.0, 2.0), (1.0, end, end)], "new-point adjoining choice must affect neighbours"); + let after = s.doc().unwrap().doc.clone(); + let st = s.doc().unwrap(); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len(), st.is_dirty()), (undo + 1, 0, true)); + assert_eq!(st.history.undo.last().unwrap().label, "Width Point"); + s.execute("edit.undo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.doc, before); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len(), st.is_dirty()), (undo, 1, false)); + s.execute("edit.redo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.doc, after); + assert_eq!(st.selection, selection); + assert_eq!((st.history.undo.len(), st.history.redo.len(), st.is_dirty()), (undo + 1, 0, true)); +} + +#[test] +fn new_width_point_adjust_adjoining_scales_neighbours() { + new_point_adjoining(true); +} + +#[test] +fn new_width_point_without_adjoining_preserves_neighbours() { + new_point_adjoining(false); +} + +#[test] +fn new_width_point_adjust_adjoining_uses_sampled_widths() { + for indexed in [false, true] { + let (mut s, id) = line_session(); + s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": [[0, 9, 9], [0.25, 1, 0], [0.75, 3, 0], [1, 7, 7]]})).unwrap(); + // At t=0.5 the old sides are 10pt and 0pt. Indexed editing of that sampled + // point is the existing convention: scale the nonzero side, add from zero. + let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 0}))) } else { None }; + set(&mut s, json!({"id": id.0, "index": index, "t": 0.5, "left": 20, "right": 5, "adjustAdjoining": true})); + assert_eq!( + points(&s, id), + vec![(0.0, 9.0, 9.0), (0.25, 2.0, 1.0), (0.5, 4.0, 1.0), (0.75, 6.0, 1.0), (1.0, 7.0, 7.0)], + "only nearest neighbours change, using the sampled sides; indexed={indexed}" + ); + } +} diff --git a/crates/tools/src/distort/width.rs b/crates/tools/src/distort/width.rs index 744797c8..e6c29e9e 100644 --- a/crates/tools/src/distort/width.rs +++ b/crates/tools/src/distort/width.rs @@ -6,8 +6,9 @@ //! a width point's centre slides it along the path; Alt-dragging the centre slides a copy; dropping //! either onto another width point makes a discontinuous point (the width steps there). Shift-click //! adds width points to the selection (or takes them out), and dragging one of several selected -//! points moves or widens them all. Double-clicking a width point opens Width Point Edit (the -//! `widthPoint` dialog). Delete/Backspace removes the selected width points. +//! points moves or widens them all. Double-clicking a stroke opens Width Point Edit (the +//! `widthPoint` dialog), editing a hit point or creating one on confirmation. Delete/Backspace +//! removes the selected width points. //! //! Gestures preview `stroke.widthPoint.set {id, t, left, right, index?}` (side widths in points), //! `stroke.widthPoint.copy {id, index, t}` or, for several points, `stroke.widthProfile.set {ids, @@ -296,7 +297,10 @@ impl Tool for WidthTool { } PointerKind::DoubleClick => { let Some(spot) = Self::spot_at(cx, p) else { return vec![] }; - let Some((w, _)) = Self::hit(cx, &spot, p) else { return vec![] }; + let Some((w, _)) = Self::hit(cx, &spot, p) else { + self.selected = Some((spot.owner, vec![spot.t])); + return vec![Action::Dialog("widthPoint".into(), json!({"id": spot.owner.0, "t": spot.t}))]; + }; self.selected = Some((spot.owner, vec![w.t])); vec![Action::Dialog("widthPoint".into(), json!({"id": spot.owner.0, "index": w.index, "adjustAdjoining": ev.mods.shift}))] } @@ -702,7 +706,7 @@ mod tests { assert_eq!(kind, "widthPoint"); assert_eq!((&p["id"], &p["index"]), (&json!(id.0), &json!(2))); assert_eq!(p.get("adjustAdjoining").and_then(serde_json::Value::as_bool).unwrap_or(false), shift); - assert!(t.pointer(&c, &ev(PointerKind::DoubleClick, 120.0, 200.0, mods)).is_empty(), "not on a width point"); + assert!(t.pointer(&c, &ev(PointerKind::DoubleClick, 120.0, 250.0, mods)).is_empty(), "not on a stroked path"); } } } diff --git a/crates/ui-egui/src/dialogs/width_point.rs b/crates/ui-egui/src/dialogs/width_point.rs index 4a252f6c..3366d4b7 100644 --- a/crates/ui-egui/src/dialogs/width_point.rs +++ b/crates/ui-egui/src/dialogs/width_point.rs @@ -1,11 +1,13 @@ -//! Width Point Edit: double-clicking a width point with the Width tool (or `ui.widthPointEdit`) -//! edits its side widths. Total Width changes both sides in proportion, the link keeps them equal, +//! Width Point Edit: double-clicking a stroke with the Width tool (or `ui.widthPointEdit`) +//! creates or edits a point and its side widths. Total Width changes both sides in proportion, +//! the link keeps them equal, //! and Adjust Adjoining Width Points changes the nearest points either side in proportion too. -//! Shift-double-click opens the dialog with that option enabled. -//! OK runs `stroke.widthPoint.set`; Delete removes the point (`stroke.widthPoint.remove`). +//! Shift-double-click on a width point opens the dialog with that option enabled. +//! OK runs `stroke.widthPoint.set`; Delete removes an existing point (`stroke.widthPoint.remove`) +//! or discards a prospective point without changing the document. //! -//! Fields: `id`, `index` (the width point), `t` (its place), `side1`, `side2` (the left and right -//! widths, points), `linked`, `adjustAdjoining`; Delete sets `discard: true` and confirms. +//! Fields: `id`, `index` (an existing width point, otherwise null), `t` (its place), `side1`, +//! `side2` (the left and right widths, points), `linked`, `adjustAdjoining`; Delete sets `discard: true` and confirms. use egui::vec2; use serde_json::{Value, json}; @@ -29,12 +31,33 @@ fn point(app: &VectorcraftApp, id: u64, index: usize) -> Option<(f64, f64, f64)> st.profile.as_ref()?.points.get(index).map(|&(t, l, r)| (t, l * half, r * half)) } -/// Open Width Point Edit on width point `index` of path `id` (`{id, index, adjustAdjoining?}`), -/// filled in with its widths. Adjoining adjustment defaults to false. +/// Open Width Point Edit for `index`, or a prospective point at fraction `t` without an index +/// (`{id, index?, t?, adjustAdjoining?}`). Opening only samples the stroke: confirmation creates +/// the point. Adjoining adjustment defaults to false. pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { - let (id, index) = (p.get("id").and_then(Value::as_u64), p.get("index").and_then(Value::as_u64)); - let (Some(id), Some(index)) = (id, index) else { return Err("give the path `id` and the width point `index`".into()) }; - let (t, l, r) = point(app, id, index as usize).ok_or("no such width point")?; + let id = p.get("id").and_then(Value::as_u64).ok_or("give the path `id`")?; + let doc = &app.session.active().ok_or("no document")?.doc; + let id = vectorcraft_tools::distort::stroke_owner(doc, NodeId(id)).0; + let index = match p.get("index").filter(|v| !v.is_null()) { + Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("no such width point")?), + None => None, + }; + let (t, l, r) = if let Some(index) = index { + point(app, id, index).ok_or("no such width point")? + } else { + let t = p.get("t").and_then(Value::as_f64).filter(|t| t.is_finite() && (0.0..=1.0).contains(t)).ok_or("give a fraction `t` from 0 to 1")?; + let st = doc + .node(NodeId(id)) + .and_then(|n| n.appearance.stroke()) + .filter(|s| s.width.is_finite() && s.width > 0.0) + .ok_or("object has no weighted stroke")?; + let (l, r) = st.profile.as_ref().map_or((1.0, 1.0), |profile| profile.at(t)); + let (l, r) = (l * st.width / 2.0, r * st.width / 2.0); + if !l.is_finite() || !r.is_finite() || l < 0.0 || r < 0.0 { + return Err("invalid stroke widths".into()); + } + (t, l, r) + }; let adjust = p.get("adjustAdjoining").and_then(Value::as_bool).unwrap_or(false); let fields = json!({"id": id, "index": index, "t": t, "side1": l, "side2": r, "linked": (l - r).abs() < 1e-9, "adjustAdjoining": adjust}); app.ui.dialog = Some(Dialog::new(KIND, fields)); @@ -88,6 +111,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let (id, index) = (d.fields.get("id").cloned().unwrap_or_default(), d.fields.get("index").cloned().unwrap_or_default()); if d.bool("discard") { + if index.is_null() { + // Deleting a prospective point discards the dialog; no document point exists yet. + app.ui.dialog = None; + return Ok(Value::Null); + } return run_and_close(app, "stroke.widthPoint.remove", json!({"id": id, "index": index})); } let (left, right) = (d.f64("side1", 0.0), d.f64("side2", 0.0)); @@ -206,4 +234,296 @@ mod tests { let (short, tall) = (height(1000.0), height(2000.0)); assert!((short - tall).abs() < 1.0 && short < 400.0, "{short} vs {tall}"); } + + fn creation_app() -> (VectorcraftApp, u64) { + let (mut app, id) = app(); + app.run("stroke.set", json!({"ids": [id], "profile": "uniform"})).unwrap(); + app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 30, "height": 40})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool("width"); + (app, id) + } + + fn creation_history(app: &VectorcraftApp) -> (usize, usize, bool) { + let st = app.session.doc().unwrap(); + (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) + } + + /// The actual tool-event sequence from two stationary canvas clicks. No profile or dialog + /// is installed by the fixture after either click; DoubleClick must open the pending point. + fn creation_double_click(app: &mut VectorcraftApp, x: f64, y: f64) { + let before = app.session.doc().unwrap().doc.clone(); + let history = creation_history(app); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(app, &PointerEvent::new(kind, x, y), view); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc, before, "click alone must not add a width point"); + assert_eq!(creation_history(app), history); + } + assert!(!app.session.in_interaction()); + crate::canvas::dispatch(app, &PointerEvent::new(PointerKind::DoubleClick, x, y), view); + assert!(app.ui.dialog.is_some(), "double-clicking a stroked segment must open Width Point Edit"); + assert_eq!(app.session.doc().unwrap().doc, before, "opening creates no point yet"); + assert_eq!(creation_history(app), history); + assert!(!app.session.in_interaction()); + } + + #[test] + fn new_width_point_double_click_creates_with_history() { + let (mut app, id) = creation_app(); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let history = creation_history(&app); + creation_double_click(&mut app, 200.0, 150.0); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.kind, KIND); + assert_eq!(d.fields["id"], json!(id)); + assert!(d.fields.get("index").is_none_or(Value::is_null)); + assert!((d.f64("t", -1.0) - 0.5).abs() < 1e-6); + assert_eq!((d.f64("side1", 0.0), d.f64("side2", 0.0), d.bool("linked")), (5.0, 5.0, true)); + assert!(!d.bool("adjustAdjoining")); + let shown = frame(&mut app); + for label in ["Width Point Edit", "Side 1:", "Side 2:", "Total Width:", "Cancel"] { + assert!(shown.iter().any(|text| text == label), "{label} in {shown:?}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("side1".into(), json!(6)); + d.fields.insert("side2".into(), json!(2)); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(points(&app, id), vec![(0.0, 1.0, 1.0), (0.5, 1.2, 0.4), (1.0, 1.0, 1.0)]); + let after = app.session.doc().unwrap().doc.clone(); + assert_eq!(after.node(NodeId(id)).unwrap().kind, before.node(NodeId(id)).unwrap().kind, "path geometry remains unchanged"); + before.walk(|node| { + if node.id != NodeId(id) && matches!(node.kind, vectorcraft_engine::doc::NodeKind::Path { .. }) { + assert_eq!(after.node(node.id), Some(node), "other artwork remains unchanged"); + } + }); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); + assert_eq!(app.session.doc().unwrap().history.undo.last().unwrap().label, "Width Point"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), (history.0, 1, false)); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, after); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); + } + + #[test] + fn new_width_point_cancel_and_discard_keep_redo() { + for discard in [false, true] { + let (mut app, _) = creation_app(); + app.run("shape.rectangle", json!({"x": 50, "y": 20, "width": 20, "height": 20})).unwrap(); + let redo = app.session.doc().unwrap().doc.clone(); + app.run("edit.undo", json!({})).unwrap(); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let history = creation_history(&app); + assert_eq!(history.1, 1); + creation_double_click(&mut app, 200.0, 150.0); + app.ui.dialog.as_mut().unwrap().fields.insert("side1".into(), json!(20)); + if discard { + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + } else { + super::super::cancel(&mut app); + } + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), history); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, redo); + } + } + + #[test] + fn new_width_point_profile_and_compound_target() { + for compound in [false, true] { + let (mut app, line) = creation_app(); + let (id, member) = if compound { + let second = app.run("shape.line", json!({"x1": 320, "y1": 50, "x2": 320, "y2": 250})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [line, second]})).unwrap(); + (app.run("object.compoundPath.make", json!({})).unwrap()["id"].as_u64().unwrap(), second) + } else { + (line, line) + }; + app.run("stroke.widthProfile.set", json!({"ids": [id], "points": [[0, 1, 0.5], [1, 2, 1.5]]})).unwrap(); + app.select_tool("width"); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let history = creation_history(&app); + // Make Compound Path can reverse the member. Choose the quarter point from its + // stored direction; on a straight segment this is exactly one quarter of its length. + let path = before.node(NodeId(member)).unwrap().path_data().unwrap(); + assert_eq!(path.subpaths.len(), 1); + let subpath = &path.subpaths[0]; + assert_eq!(subpath.anchors.len(), 2); + assert!(subpath.segment_is_line(0)); + let quarter = subpath.anchors[0].p.lerp(subpath.anchors[1].p, 0.25); + creation_double_click(&mut app, quarter.x, quarter.y); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.fields["id"], json!(id), "the compound owns its members' stroke"); + assert!(d.fields.get("index").is_none_or(Value::is_null)); + let actual_t = d.f64("t", -1.0); + assert!((actual_t - 0.25).abs() < 1e-6, "compound={compound} pointer={quarter:?} actual_t={actual_t} want=0.25"); + assert!((d.f64("side1", 0.0) - 6.25).abs() < 1e-6); + assert!((d.f64("side2", 0.0) - 3.75).abs() < 1e-6); + assert!(!d.bool("linked")); + d.fields.insert("side1".into(), json!(8)); + d.fields.insert("side2".into(), json!(3)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(points(&app, id), vec![(0.0, 1.0, 0.5), (0.25, 1.6, 0.6), (1.0, 2.0, 1.5)]); + let after = app.session.doc().unwrap().doc.clone(); + assert_eq!(after.node(NodeId(id)).unwrap().kind, before.node(NodeId(id)).unwrap().kind, "member paths retain their geometry"); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(creation_history(&app), (history.0, 1, false)); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, after); + } + } + + #[test] + fn new_width_point_command_opens_pending_point() { + let (mut app, id) = creation_app(); + let before = app.session.doc().unwrap().doc.clone(); + let history = creation_history(&app); + app.run("ui.widthPointEdit", json!({"id": id, "t": 0.25})).expect("the UI command accepts a prospective point"); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(d.fields.get("index").is_none_or(Value::is_null)); + assert_eq!((d.f64("t", -1.0), d.f64("side1", 0.0), d.f64("side2", 0.0)), (0.25, 5.0, 5.0)); + super::super::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(creation_history(&app), history); + } + + #[test] + fn new_width_point_invalid_command_keeps_document() { + let (mut app, id) = creation_app(); + let before = app.session.doc().unwrap().doc.clone(); + let history = creation_history(&app); + for p in [ + json!({"id": id, "t": -0.01}), + json!({"id": id, "t": 1.01}), + json!({"id": id, "t": "invalid"}), + json!({"id": id, "index": "invalid", "t": 0.5}), + json!({"id": id, "index": 999, "t": 0.5}), + ] { + assert!(app.run("ui.widthPointEdit", p).is_err()); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(creation_history(&app), history); + } + } + + fn creation_dialog_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { + fn collect(shape: &egui::Shape, clip: egui::Rect, texts: &mut Vec<(String, egui::Rect)>) { + match shape { + egui::Shape::Text(t) if clip.contains_rect(t.visual_bounding_rect()) => { + texts.push((t.galley.text().to_string(), t.visual_bounding_rect())); + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, clip, texts)), + _ => {} + } + } + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 700.0))), events, ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| super::super::show(app, ui.ctx())); + output.textures_delta.clear(); + let mut texts = vec![]; + output.shapes.iter().for_each(|shape| collect(&shape.shape, shape.clip_rect, &mut texts)); + texts + } + + fn creation_dialog_click(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { + for _ in 0..4 { + creation_dialog_frame(app, ctx, vec![]); + } + let texts = creation_dialog_frame(app, ctx, vec![]); + let pos = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("missing visible control {label}: {texts:?}")).1.center(); + for pressed in [true, false] { + creation_dialog_frame( + app, + ctx, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + ], + ); + } + creation_dialog_frame(app, ctx, vec![]); + } + + fn creation_adjoining_checkbox(adjust: bool) { + let (mut app, id) = creation_app(); + app.run("stroke.widthProfile.set", json!({"ids": [id], "points": [[0, 1, 1], [1, 1, 1]]})).unwrap(); + app.session.doc_mut().unwrap().mark_saved(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let history = creation_history(&app); + creation_double_click(&mut app, 200.0, 150.0); + assert!(app.ui.dialog.as_ref().unwrap().fields["index"].is_null()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + for _ in 0..5 { + creation_dialog_frame(&mut app, &ctx, vec![]); + } + // The dialog naturally focuses and selects Side 1. Its linked partner changes + // when the checkbox click commits the typed value; no fields are seeded. + assert!(ctx.memory(|m| m.focused()).is_some()); + creation_dialog_frame(&mut app, &ctx, vec![egui::Event::Text("10 pt".into())]); + creation_dialog_click(&mut app, &ctx, "Adjust Adjoining Width Points"); + assert!(app.ui.dialog.as_ref().unwrap().bool("adjustAdjoining"), "real checkbox click enables adjustment"); + if !adjust { + creation_dialog_click(&mut app, &ctx, "Adjust Adjoining Width Points"); + } + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.bool("adjustAdjoining"), adjust); + assert_eq!((d.f64("side1", 0.0), d.f64("side2", 0.0)), (10.0, 10.0)); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(creation_history(&app), history); + creation_dialog_click(&mut app, &ctx, "OK"); + assert!(app.ui.dialog.is_none(), "real OK click confirms the pending point"); + let end = if adjust { 2.0 } else { 1.0 }; + assert_eq!(points(&app, id), vec![(0.0, end, end), (0.5, 2.0, 2.0), (1.0, end, end)], "checkbox choice controls adjoining widths"); + let after = app.session.doc().unwrap().doc.clone(); + assert_eq!(after.node(NodeId(id)).unwrap().kind, before.node(NodeId(id)).unwrap().kind); + before.walk(|node| { + if node.id != NodeId(id) && matches!(node.kind, vectorcraft_engine::doc::NodeKind::Path { .. }) { + assert_eq!(after.node(node.id), Some(node), "other artwork remains unchanged"); + } + }); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); + assert_eq!(app.session.doc().unwrap().history.undo.last().unwrap().label, "Width Point"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), (history.0, 1, false)); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, after); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(creation_history(&app), (history.0 + 1, 0, true)); + } + + #[test] + fn new_width_point_checkbox_adjusts_adjoining_in_one_step() { + creation_adjoining_checkbox(true); + } + + #[test] + fn new_width_point_checkbox_off_preserves_adjoining() { + creation_adjoining_checkbox(false); + } } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f5382112..ea273e44 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4744,7 +4744,10 @@ mark-style Japanese Japonské @msg unknown parameter `{key}`; the command takes: {keys} neznámý parametr `{key}`; příkaz přijímá: {keys} Load EPS into Clipboard Načíst EPS do schránky @msg an image of {w} × {h} pixels is too large to draw obraz o velikosti {w} × {h} obrazových bodů je příliš velký na vykreslení - +@msg give a fraction `t` from 0 to 1 zadejte hodnotu `t` od 0 do 1 +@msg give the path `id` zadejte `id` cesty +@msg invalid stroke widths neplatné šířky tahu +@msg object has no weighted stroke objekt nemá tah s platnou kladnou tloušťkou # Corner Radius: a field per corner of a live rectangle. Single Corner Radius Jeden poloměr rohů Top Left: Vlevo nahoře: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 808c12bc..eb44f723 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4268,7 +4268,10 @@ theme Appearance Erscheinungsbild @msg unknown parameter `{key}`; the command takes: {keys} unbekannter Parameter `{key}`; der Befehl akzeptiert: {keys} Load EPS into Clipboard EPS in die Zwischenablage laden @msg an image of {w} × {h} pixels is too large to draw ein Bild mit {w} × {h} Pixeln ist zu groß, um gezeichnet zu werden - +@msg give a fraction `t` from 0 to 1 geben Sie einen Wert `t` von 0 bis 1 an +@msg give the path `id` geben Sie die `id` des Pfads an +@msg invalid stroke widths ungültige Konturbreiten +@msg object has no weighted stroke das Objekt hat keine Kontur mit einer gültigen positiven Stärke # Corner Radius: a field per corner of a live rectangle. Single Corner Radius Einheitlicher Eckenradius Top Left: Oben links: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b132adea..24652912 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4685,7 +4685,10 @@ mark-style Japanese Japonesas @msg unknown parameter `{key}`; the command takes: {keys} parámetro desconocido `{key}`; el comando acepta: {keys} Load EPS into Clipboard Cargar EPS en el portapapeles @msg an image of {w} × {h} pixels is too large to draw una imagen de {w} × {h} píxeles es demasiado grande para dibujarse - +@msg give a fraction `t` from 0 to 1 indica un valor `t` de 0 a 1 +@msg give the path `id` indica el `id` del trazado +@msg invalid stroke widths anchuras de trazo no válidas +@msg object has no weighted stroke el objeto no tiene un trazo con un grosor positivo válido # Corner Radius: a field per corner of a live rectangle. Single Corner Radius Radio de esquina único Top Left: Arriba a la izquierda: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b221773d..22e4f564 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4744,7 +4744,10 @@ mark-style Japanese Japonais theme Appearance Apparence Load EPS into Clipboard Charger un EPS dans le presse-papiers @msg an image of {w} × {h} pixels is too large to draw une image de {w} × {h} pixels est trop grande pour être dessinée - +@msg give a fraction `t` from 0 to 1 indiquez une valeur `t` comprise entre 0 et 1 +@msg give the path `id` indiquez l’`id` du tracé +@msg invalid stroke widths largeurs de contour non valides +@msg object has no weighted stroke l’objet n’a pas de contour d’épaisseur positive valide # Corner Radius: a field per corner of a live rectangle. Single Corner Radius Rayon d’angle unique Top Left: En haut à gauche : diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7c24c670..6091a6c5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4711,7 +4711,10 @@ imageTrace Outline Körvonal @msg unknown parameter `{key}`; the command takes: {keys} ismeretlen paraméter: `{key}`; a parancs ezeket fogadja el: {keys} Load EPS into Clipboard EPS betöltése a vágólapra @msg an image of {w} × {h} pixels is too large to draw egy {w} × {h} pixeles kép túl nagy a kirajzoláshoz - +@msg give a fraction `t` from 0 to 1 add meg a `t` értékét 0 és 1 között +@msg give the path `id` add meg az útvonal `id` értékét +@msg invalid stroke widths érvénytelen körvonalszélességek +@msg object has no weighted stroke az objektumnak nincs érvényes pozitív vastagságú körvonala # Corner Radius: a field per corner of a live rectangle. Single Corner Radius Egységes sarokkerekítés Top Left: Bal felső: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 27424bb3..aa9764b8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4313,7 +4313,10 @@ lighting Light Luce @msg unknown parameter `{key}`; the command takes: {keys} parametro sconosciuto `{key}`; il comando accetta: {keys} Load EPS into Clipboard Carica EPS negli appunti @msg an image of {w} × {h} pixels is too large to draw un'immagine di {w} × {h} pixel è troppo grande per essere disegnata - +@msg give a fraction `t` from 0 to 1 specifica un valore `t` da 0 a 1 +@msg give the path `id` specifica l'`id` del tracciato +@msg invalid stroke widths larghezze della traccia non valide +@msg object has no weighted stroke l'oggetto non ha una traccia con spessore positivo valido # Corner Radius: a field per corner of a live rectangle. Single Corner Radius Raggio angolo unico Top Left: In alto a sinistra: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1b6b606e..e2d291dc 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4307,7 +4307,10 @@ lighting Light ライト @msg unknown parameter `{key}`; the command takes: {keys} `{key}` は不明なパラメーターです。このコマンドで使えるパラメーター: {keys} Load EPS into Clipboard EPS をクリップボードに読み込む @msg an image of {w} × {h} pixels is too large to draw {w} × {h} ピクセルの画像は大きすぎて描画できません - +@msg give a fraction `t` from 0 to 1 0 から 1 までの値 `t` を指定してください +@msg give the path `id` パスの `id` を指定してください +@msg invalid stroke widths 線幅が無効です +@msg object has no weighted stroke オブジェクトに有効な正の線幅を持つ線がありません # Corner Radius: a field per corner of a live rectangle. Single Corner Radius 角丸の半径を統一 Top Left: 左上: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b4b98d0f..a643ab3b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4277,7 +4277,10 @@ lighting Light 광원 @msg unknown parameter `{key}`; the command takes: {keys} 알 수 없는 매개변수 `{key}`: 이 명령이 받는 매개변수: {keys} Load EPS into Clipboard EPS를 클립보드로 불러오기 @msg an image of {w} × {h} pixels is too large to draw {w} × {h}픽셀 이미지는 너무 커서 그릴 수 없습니다 - +@msg give a fraction `t` from 0 to 1 0에서 1 사이의 값 `t`를 지정하세요 +@msg give the path `id` 패스 `id`를 지정하세요 +@msg invalid stroke widths 선 너비가 유효하지 않습니다 +@msg object has no weighted stroke 개체에 유효한 양의 두께를 가진 선이 없습니다 # Corner Radius: a field per corner of a live rectangle. Single Corner Radius 단일 모퉁이 반지름 Top Left: 왼쪽 위: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 77077821..57d42f66 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4586,7 +4586,10 @@ mark-style Japanese Japońskie @msg unknown parameter `{key}`; the command takes: {keys} nieznany parametr `{key}`; polecenie przyjmuje: {keys} Load EPS into Clipboard Wczytaj EPS do schowka @msg an image of {w} × {h} pixels is too large to draw obraz o wymiarach {w} × {h} pikseli jest zbyt duży, aby go narysować - +@msg give a fraction `t` from 0 to 1 podaj wartość `t` od 0 do 1 +@msg give the path `id` podaj `id` ścieżki +@msg invalid stroke widths nieprawidłowe szerokości obrysu +@msg object has no weighted stroke obiekt nie ma obrysu o prawidłowej dodatniej grubości # Corner Radius: a field per corner of a live rectangle. Single Corner Radius Jeden promień narożników Top Left: Lewy górny: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d60a9166..d6e0c979 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4309,7 +4309,10 @@ lighting Light Свет @msg unknown parameter `{key}`; the command takes: {keys} неизвестный параметр `{key}`; команда принимает: {keys} Load EPS into Clipboard Загрузить EPS в буфер обмена @msg an image of {w} × {h} pixels is too large to draw изображение размером {w} × {h} пикселей слишком велико для отрисовки - +@msg give a fraction `t` from 0 to 1 укажите значение `t` от 0 до 1 +@msg give the path `id` укажите `id` контура +@msg invalid stroke widths недопустимые значения ширины обводки +@msg object has no weighted stroke у объекта нет обводки с допустимой положительной толщиной # Corner Radius: a field per corner of a live rectangle. Single Corner Radius Единый радиус углов Top Left: Вверху слева: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index fbfc58ad..bc74406e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4293,7 +4293,10 @@ lighting Light Світло @msg unknown parameter `{key}`; the command takes: {keys} невідомий параметр `{key}`; команда приймає: {keys} Load EPS into Clipboard Завантажити EPS у буфер обміну @msg an image of {w} × {h} pixels is too large to draw зображення розміром {w} × {h} пікселів завелике для відображення - +@msg give a fraction `t` from 0 to 1 вкажіть значення `t` від 0 до 1 +@msg give the path `id` вкажіть `id` контуру +@msg invalid stroke widths неприпустимі значення ширини обведення +@msg object has no weighted stroke об’єкт не має обведення з допустимою додатною товщиною # Corner Radius: a field per corner of a live rectangle. Single Corner Radius Єдиний радіус кутів Top Left: Вгорі ліворуч: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 11eb9fa6..5792d956 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4295,7 +4295,10 @@ theme Appearance Giao diện @msg {tool} isn't in a toolbar group of several tools {tool} không nằm trong nhóm thanh công cụ có nhiều công cụ Load EPS into Clipboard Tải EPS vào bảng tạm @msg an image of {w} × {h} pixels is too large to draw ảnh {w} × {h} điểm ảnh quá lớn nên không thể vẽ - +@msg give a fraction `t` from 0 to 1 hãy cung cấp giá trị `t` từ 0 đến 1 +@msg give the path `id` hãy cung cấp `id` đường dẫn +@msg invalid stroke widths chiều rộng nét không hợp lệ +@msg object has no weighted stroke đối tượng không có nét với độ dày dương hợp lệ # Corner Radius: a field per corner of a live rectangle. Single Corner Radius Một bán kính góc Top Left: Trên cùng bên trái: diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 57926da1..569b0442 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -582,7 +582,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.widthPointEdit", "Width Point Edit…", "", - "{id, index, adjustAdjoining?: bool (false)} open Width Point Edit for width point `index` of path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking a width point with the Width tool opens it too, with Shift enabling adjoining adjustment): OK runs stroke.widthPoint.set, discard: true (the Delete button) stroke.widthPoint.remove", + "{id, index?: existing width point, t?: 0..1 for a new point, adjustAdjoining?: bool (false)} open Width Point Edit on path `id` (dialog `widthPoint`: side1, side2 (pt), linked, adjustAdjoining; double-clicking the stroke opens it too, Shift on a width point enabling adjoining adjustment): OK runs stroke.widthPoint.set; discard: true (Delete) removes an existing point or discards a prospective point", ), ( "ui.corners", diff --git a/docs/gaps.md b/docs/gaps.md index bf61ec26..94f4ee72 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -43,8 +43,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G1 Interaction fidelity -- **Done:** Shift-double-click opens Width Point Edit with adjoining adjustment enabled. Plain double-click retains the default; dispatch, neighboring widths, Undo/Redo and Cancel have regression coverage. +- **Done:** double-click a stroked segment to create a width point through Width Point Edit. Opening, Cancel and deleting the prospective point preserve the document and history; OK creates the point and optionally adjusts adjoining widths in one undo step. Regression and visual evidence: [Width-point creation](target-app-parity.md#evidence-what-ships-today-by-area). +- **Done:** Shift-double-click opens Width Point Edit with adjoining adjustment enabled. Plain double-click retains the default; dispatch, neighboring widths, Undo/Redo and Cancel have regression coverage. - **Done (#1227):** object clicks with the Artboard tool create a fitted artboard, preserving art and selection; handles, modified clicks and drags retain their meaning. Covered by tool-event regression tests and undo/cancellation checks. - **Done:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. @@ -244,6 +245,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G1: create width points through Width Point Edit with optional adjoining adjustment; command/pointer/history regressions and full CI passed; dialog layout reviewed; scores and hours unchanged | | 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. | | 2026-10-13 | minor | G1: Average and Join in one step with a corner or smooth choice is listed as missing (the ui-parity row said done; the command does not exist); Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534); Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). G11: Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537); a round corner next to a curved side curves smoothly instead of sticking out of the shape and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512); Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). G9: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542); Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499); Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492); the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Polish catalog (#1498); estimates unchanged, no gap entry closed | | 2026-10-13 | minor | Interaction: Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1508), sliders in dialogs show their rail (#1483), a template layer stays in Preview in Outline view (#1486), a tab stop in the Tabs panel drags from where the press landed (#1458); type: commands and MCP `add_text` report area type that does not fit its frame (#1471); 3D and Materials on outlined type and other shapes of many small curves (#1481), Twist 0° to 360° (#1524), twisted walls keep their faces (#1525); print-size images draw, export and crop (#1517); `vectorcraft-cli perf` reports load and memory on Windows (#1502; the idle-machine run is still missing); French catalog complete again (#1511); `graph.setData`'s CSV keeps each value exactly (#1485); estimates unchanged, no gap entry closed | diff --git a/docs/roadmap.md b/docs/roadmap.md index 584f0cc4..17e94646 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -63,7 +63,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (15 presets; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | | M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | -| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 | +| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ | 60–90 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 | | | **Total to full parity** (one agent; from [target-app-parity.md](target-app-parity.md)) | | **~360–590** | @@ -80,6 +80,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14 | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G1: create width points through Width Point Edit with optional adjoining adjustment; command/pointer/history regressions and full CI passed; dialog layout reviewed; scores and hours unchanged | | 2026-10-13 | minor | Corner Radius: a link toggle behind the field in the Properties and Transform panels; off, a live rectangle shows a radius field for each corner (Top Left, Top Right, Bottom Right, Bottom Left), each setting its own corner (`singleCornerRadius` preference); split from #1594; score unchanged (estimated) | | 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. | | 2026-10-13 | minor | M12 row: Image Trace has 15 presets and the Ignore Color, Shapes, Auto Grouping and Gradients options (#1574, #1609, #1618, #1627); its speed figure, measured before these, is dropped for the measurements in `image-trace-quality.md` | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 37c4af2c..5f18f4e9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -71,7 +71,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool, Live Corners' Relative rounding; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part) | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder misses planar regions in real files (#937), its gap detection and options; Live Paint Gap Options; Simplify slider/auto, advanced UI and Show Original | 4–8 | estimated (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Divide open-cutting-path geometry and appearance regression-tested (#1307) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient dither, perceptual blending and gradient presets; freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; remaining: Art brush Overlap, Pattern brush auto corners, brush libraries, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; remaining: Art brush Overlap, Pattern brush auto corners, brush libraries, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated | | Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated | | Raster effects (Effect Gallery) | 4 | 49% | 28 Photoshop-style filters (14 Artistic, 14 Sketch), the Effect Gallery dialog, Load Texture | 23–39 | measured count 29/57; texture redraw distances optimized (#1257), pixels verified; depth estimated | @@ -373,6 +373,8 @@ since 2026-10-01. Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands. +- **Width-point creation (G1):** double-click a stroked segment with the Width tool to open Width Point Edit using sampled side widths. OK creates the point and optionally adjusts adjoining widths in one undo step. Cancel or Delete discards a prospective point without changing the document or redo history. Command and pointer tests cover profiles, compound paths, adjoining adjustment, existing-point editing and Undo/Redo. Message-catalog checks and full CI passed; the original capture verifies opening and Cancel, and the dialog layout was reviewed (2026-10-13: 28 focused Width tests, all seven cargo xtask ci stages, and a headless opening/Cancel capture with original PNG review). [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). + - **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Global/artboard mode switching and video rulers remain separate. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. @@ -546,6 +548,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G1: create width points through Width Point Edit with optional adjoining adjustment; command/pointer/history regressions and full CI passed; dialog layout reviewed; scores and hours unchanged | | 2026-10-13 | minor | Corner Radius: a link toggle behind the field in the Properties and Transform panels; off, a live rectangle shows a radius field for each corner (Top Left, Top Right, Bottom Right, Bottom Left), each setting its own corner (`singleCornerRadius` preference); split from #1594; score unchanged (estimated) | | 2026-10-13 | minor | 3D and Materials: the documented light settings, Intensity, Rotation, Height, Softness and Ambient Light ([three-d.md](three-d.md#lighting)); scores unchanged (3D ~25%) | | 2026-10-13 | minor | 3D and Materials: Perspective is the documented 0°–160° angle ([three-d.md](three-d.md#perspective)); scores unchanged (3D ~25%) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e76e5551..3092c79e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -143,6 +143,7 @@ tools, never checked side by side) isn't captured row by row. | Cmd/Ctrl: last-used selection tool; Cmd+Alt: Group Selection inside Direct Selection | partial | | | Eyedropper reads visible sources while their layer/object is locked or outside isolation | done | Ordinary/Shift/Shift+Alt and gradient-stop reads preserve the source and selection; Alt reverse remains editable-only; hidden art stays excluded (`eyedropper_sources.rs`, #1251) | | Add a visible stroke after drawing with inherited None/zero-width paint | done | Paint and Swap Fill/Stroke restore the existing 1 pt default; positive widths and painted zero widths stay as set; Undo/Redo and targeted appearance items tested (`stroke_visibility.rs`, #1253) | +| Double-click a stroked segment to create a width point in Width Point Edit | done | Command and pointer regressions cover opening, sampled widths, adjoining adjustment on/off through checkbox/OK, compound paths, Cancel/Delete and Undo/Redo; dialog layout reviewed. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) | | Shift-double-click a width point enables adjoining adjustment in Width Point Edit | done | Tool dispatch, plain double-click control, neighboring widths, Undo/Redo and Cancel in `dialogs/width_point.rs` | | Alt-drag duplicates (objects, artboards with their art) | done | | | Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool | done | Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. | @@ -202,6 +203,7 @@ re-measure of the panels added since. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G1: create width points through Width Point Edit with optional adjoining adjustment; command/pointer/history regressions and full CI passed; dialog layout reviewed; scores and hours unchanged | | 2026-10-13 | minor | Corner Radius: a link toggle behind the field in the Properties and Transform panels; off, a live rectangle shows a radius field for each corner (Top Left, Top Right, Bottom Right, Bottom Left), each setting its own corner (`singleCornerRadius` preference); split from #1594; score unchanged (estimated) | | 2026-10-13 | minor | Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); correction: "Join with corner/smooth choice" was listed as done, but `path.join` and `path.average` are separate commands and Join has no such choice, so Average and Join in one step with that choice is its own row, missing; the Smooth tool smooths only the stretch of path under the drag and a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1547, fixes #1512); Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534); Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539); Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537); scores unchanged (estimated) | | 2026-10-13 | minor | Sliders in Save for Web, the raster export options, Preferences and the opacity popup show their rail (#1506, fixes #1483); a template layer stays in Preview, dimmed, in Outline view (#1505, fixes #1486); Direct Selection takes the selected object's anchor where anchors of overlapping objects coincide (#1509, fixes #1508); a tab stop in the Tabs panel drags from where the press landed (#1487, fixes #1458); scores unchanged (estimated) |