diff --git a/ROADMAP.md b/ROADMAP.md index e5f6ec0d..0feb25bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 0275953a..fc7a8e0c 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -16,7 +16,9 @@ pub struct Selection { /// Direct-selected anchors per path. A path in `objects` with no entry here is fully selected. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub anchors: BTreeMap>, - /// Key object for Align. + /// Key object for Align, one object of a selection of two or more. [`Selection::prune`] clears + /// it when every selected object is the key or lies inside it, as when one object is left + /// selected or when the key is a group selected with only its own members. #[serde(default, skip_serializing_if = "Option::is_none")] pub key: Option, /// The object, group or layer targeted through its target circle in the Layers panel, which @@ -118,7 +120,15 @@ impl Selection { pub fn prune(&mut self, doc: &Document) { self.objects.retain(|id| doc.node(*id).is_some()); self.anchors.retain(|id, _| doc.node(*id).is_some()); - if self.key.is_some_and(|k| doc.node(k).is_none()) { + // The key object stays while another selected object lies outside it. + let keep = self.key.and_then(|k| doc.node(k)).is_some_and(|n| { + let mut inside = BTreeSet::new(); + n.walk(&mut |c| { + inside.insert(c.id); + }); + self.objects.iter().any(|o| !inside.contains(o)) + }); + if !keep { self.key = None; } if self.target.is_some_and(|t| doc.node(t).is_none()) { @@ -253,4 +263,47 @@ mod tests { s.set([NodeId(3)]); assert_eq!(s.target, None); } + + #[test] + fn a_key_object_needs_two_or_more_selected_objects() { + let mut s = Selection::default(); + s.set([NodeId(1), NodeId(2)]); + s.key = Some(NodeId(2)); + s.remove(NodeId(1)); + assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key"); + s.remove(NodeId(2)); + assert_eq!(s.key, None, "the key object itself left"); + // Pruning drops objects the document no longer has and clears the key once no other + // selected object lies outside it. + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let mut ids = vec![]; + for _ in 0..4 { + let id = d.alloc_id(); + ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap()); + } + s.set(ids.iter().copied()); + s.key = Some(ids[3]); + d.remove(ids[0]).unwrap(); + s.prune(&d); + assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays"); + s.toggle(ids[1]); + s.prune(&d); + assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays"); + s.toggle(ids[2]); + s.prune(&d); + assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left"); + // A group key selected with only its own member is cleared too; another selected object + // outside the group keeps it. + let member = d.alloc_id(); + d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap(); + s.set([member, ids[3]]); + s.key = Some(ids[3]); + s.prune(&d); + assert_eq!(s.key, None, "a group and its own member"); + s.set([member, ids[3], ids[2]]); + s.key = Some(ids[3]); + s.prune(&d); + assert_eq!(s.key, Some(ids[3]), "another object outside the group"); + } } diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 319a7b33..314a2e8f 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Set Key Object", [], None, - "{id?: a selected object (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it", + "{id?: a selected object, one of two or more (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members)", has_doc, key ), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5b630f6f..8b011cbb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -311,6 +311,57 @@ fn layers_and_lock_hide() { assert!(s.doc().unwrap().doc.node(b).is_none()); } +/// A key object is one object of a selection of several: a Shift-click (`select.toggle`) or a +/// hidden or deleted row that leaves one object selected clears the key, and `select.key` sets +/// none on a single selected object. A Shift-drag marquee that leaves two or more objects selected +/// keeps the key in either order of its ids. `select.key` also sets none on a group selected with +/// only its own member, and sets it once another object is selected. +#[test] +fn a_lone_selected_object_is_not_the_key_object() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 100.0, 30.0, 20.0, 20.0); + let c = rect(&mut s, 200.0, 60.0, 10.0, 10.0); + let key = |s: &Session| s.doc().unwrap().selection.key; + let objects = |s: &Session| s.doc().unwrap().selection.objects.clone(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("select.key", &json!({"id": b.0})).unwrap(); + assert_eq!(key(&s), Some(b)); + s.execute("select.toggle", &json!({"id": a.0})).unwrap(); + assert_eq!(objects(&s), vec![b]); + assert_eq!(key(&s), None, "the Shift-click left one object"); + s.execute("select.key", &json!({"id": b.0})).unwrap(); + assert_eq!(key(&s), None, "one object selected"); + for ids in [[a.0, c.0], [c.0, a.0]] { + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("select.key", &json!({"id": b.0})).unwrap(); + s.execute("select.toggle", &json!({"ids": ids})).unwrap(); + assert_eq!((objects(&s), key(&s)), (vec![b, c], Some(b)), "the marquee toggled {ids:?}"); + } + // The Group Selection tool's second click on a member adds its group. + s.execute("select.set", &json!({"ids": [b.0, c.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.execute("select.add", &json!({"ids": [g.0]})).unwrap(); + s.execute("select.key", &json!({"id": g.0})).unwrap(); + assert_eq!(key(&s), None, "a group and its own member"); + s.execute("select.add", &json!({"ids": [a.0]})).unwrap(); + s.execute("select.key", &json!({"id": g.0})).unwrap(); + assert_eq!(key(&s), Some(g), "another object outside the group"); + s.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap(); + s.execute("select.key", &json!({"id": g.0})).unwrap(); + assert_eq!(key(&s), Some(g)); + s.execute("layer.setProps", &json!({"id": a.0, "visible": false})).unwrap(); + assert_eq!((objects(&s), key(&s)), (vec![g], None), "hiding the other object's row left one object"); + s.execute("layer.setProps", &json!({"id": a.0, "visible": true})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap(); + s.execute("select.key", &json!({"id": g.0})).unwrap(); + assert_eq!(key(&s), Some(g)); + s.execute("layer.delete", &json!({"id": a.0})).unwrap(); + assert_eq!(objects(&s), vec![g]); + assert_eq!(key(&s), None, "deleting the other object's row left one object"); +} + #[test] fn clipping_and_compound() { let mut s = session(); diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 6c9c9e76..ba7a1507 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -29,7 +29,7 @@ impl AlignTo { /// `object.align` params: a single object aligns to the artboard (Illustrator does the same). pub fn align_params(base: Value, to: AlignTo, n_selected: usize) -> Value { let mut p = base; - let to = if n_selected == 1 && to == AlignTo::Selection { AlignTo::Artboard } else { to }; + let to = if n_selected == 1 { AlignTo::Artboard } else { to }; p["to"] = json!(to.param()); p } @@ -184,7 +184,47 @@ mod tests { assert_eq!(p["to"], "artboard"); let p = align_params(json!({"vertical": "top"}), AlignTo::Selection, 3); assert_eq!(p["to"], "selection"); - assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "key"); + assert_eq!(align_params(json!({}), AlignTo::Key, 1)["to"], "artboard"); + } + + /// A Shift-click that leaves the key object selected on its own lets the key go. With Align to + /// Key Object chosen, the align buttons of the Control bar and the Properties panel, and then + /// the Align panel's, align that object to the artboard. + #[test] + fn a_lone_object_aligns_to_the_artboard() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone(); + let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + set_pstate(&ctx, "align-to", AlignTo::Key); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.run("select.key", json!({"id": b})).unwrap(); + app.run("select.toggle", json!({"id": a})).unwrap(); + let id = vectorcraft_doc::NodeId(b.as_u64().unwrap()); + let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + // `align_buttons` (the Control bar and the Properties panel): one frame finds the first + // button, and the next clicks Horizontal Align Right, the third. + let at = std::cell::Cell::new(egui::Pos2::ZERO); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + ui.horizontal(|ui| { + at.set(ui.cursor().min); + align_buttons(app, ui, 24.0); + }); + }); + out.textures_delta.clear(); + }; + frame(&mut app, vec![]); + let right = at.get() + egui::vec2(2.0 * (24.0 + ctx.global_style().spacing.item_spacing.x) + 12.0, 12.0); + let press = |pressed| egui::Event::PointerButton { pos: right, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(right), press(true)]); + frame(&mut app, vec![press(false)]); + assert_eq!(bounds(&app).x1, 200.0, "the artboard's right edge"); + // The Align panel's Horizontal Align Left. + let p = align_params(json!({"horizontal": "left", "bounds": "geometric"}), align_to(&app, &ctx), selection_len(&app)); + app.run("object.align", p).unwrap(); + assert_eq!(bounds(&app).x0, 0.0, "the artboard's left edge"); } /// #541: while the selection has a key object Align aligns to it, whatever Align To says; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4e0a47af..5d5513a7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -396,6 +396,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Tools:** - **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain. - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). + A key object needs another selected object outside it. A Shift-click or any other change that leaves one object selected clears the key, and `select.key` sets none on one object or on a group selected with only its own members. With one object selected, the Align panel, the Control bar and the Properties panel align it to the artboard whatever Align To is set to. Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784). Align and Distribute Spacing keep a compound path or selected group that contains the key object in place, as they keep the key object itself. Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).