diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index f61acf9cf..920a60d3b 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -849,6 +849,33 @@ fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() { } } +#[test] +fn a_selected_curve_whose_handles_are_near_1e308_is_refused() { + // The canvas check measures a selected curve whose handles are near ±1e308 by its control + // points, so `path.create` refuses such a curve, given as anchors or as path data: the document + // stays as it was, with no undo step. + let mut s = session(); + curve(&mut s); + let h = 1e308; + for p in [ + json!({"anchors": [{"x": 100, "y": 100, "out": [150, h]}, {"x": 300, "y": 100, "in": [250, h]}]}), + json!({"anchors": [{"x": 100, "y": 100, "out": [150, -h]}, {"x": 300, "y": 100, "in": [250, h]}]}), + json!({"anchors": [{"x": 100, "y": 100, "out": [1.7e308, 100]}, {"x": 300, "y": 100}]}), + json!({"d": "M100 100 C150 1e308 250 1e308 300 100"}), + ] { + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let e = s.execute("path.create", &p).unwrap_err().to_string(); + assert!(e.contains("exceed the canvas"), "{p}: {e}"); + assert!(s.doc().unwrap().doc == before, "{p} changed the document"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{p} left an undo step"); + } + // A curve whose handles stay on the canvas is kept. + let p = json!({"anchors": [{"x": 100, "y": 100, "out": [150, 3e6]}, {"x": 300, "y": 100, "in": [250, 3e6]}]}); + let id = NodeId(s.execute("path.create", &p).unwrap()["id"].as_u64().unwrap()); + assert!(path(&s, id).bounds().is_some_and(|b| b.y1 > 2e6)); +} + /// A curve from (100, 100) to (300, 100) whose first anchor has an out handle at (150, 100). fn curve(s: &mut Session) -> NodeId { let anchors = json!([{"x": 100, "y": 100, "out": [150, 100]}, {"x": 300, "y": 100, "in": [250, 100]}]); diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index 976879d6d..31866ed97 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; -use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, Point, Rect, Shape}; +use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveExtrema, ParamCurveNearest, PathEl, PathSeg, Point, Rect, Shape}; use serde::{Deserialize, Serialize}; use crate::EPS; @@ -482,7 +482,9 @@ impl PathData { } Self { subpaths } } - /// Tight geometric bounds (curve extrema, not control points). + /// Tight geometric bounds (curve extrema, not control points), measured by [`curve_bounds`]: a + /// segment with a control point that is not finite or past `f64::MAX / 16` in x or y is measured + /// by its control points. pub fn bounds(&self) -> Option { if self.is_empty() { return None; @@ -493,7 +495,7 @@ impl PathData { { return Some(Rect::from_points(a.p, a.p)); } - Some(bp.bounding_box()) + Some(curve_bounds(&bp)) } /// Bounds including control handles (used for quick culling). pub fn control_bounds(&self) -> Option { @@ -550,6 +552,36 @@ impl PathData { } } +/// The largest x or y, in size, of a segment's control points for which [`segment_bounds`] +/// measures the segment by its extrema. kurbo adds up to eight coordinates together when it finds a +/// segment's extrema and evaluates the segment there, and a sum of eight coordinates of this size or +/// smaller is finite. +const EXTREMA_LIMIT: f64 = f64::MAX / 16.0; + +/// The box of `path`'s segments, each measured by [`segment_bounds`] and joined in order as kurbo's +/// `bounding_box` joins them. A path with no segments gives the empty rect at the origin, as kurbo +/// does. +pub fn curve_bounds(path: &BezPath) -> Rect { + path.segments().map(segment_bounds).reduce(|bb, seg_bb| bb.union(seg_bb)).unwrap_or_default() +} + +/// The box of `seg` as kurbo's `bounding_box` measures it (its start, end and extrema), except +/// that a segment with a control point whose x or y is not finite or is larger in size than +/// `f64::MAX / 16` is measured by its control points. The box then holds every point the segment +/// draws, and it reaches infinity when a control point is infinite. +pub fn segment_bounds(seg: PathSeg) -> Rect { + let pts = match seg { + PathSeg::Line(l) => [l.p0, l.p1, l.p1, l.p1], + PathSeg::Quad(q) => [q.p0, q.p1, q.p2, q.p2], + PathSeg::Cubic(c) => [c.p0, c.p1, c.p2, c.p3], + }; + if pts.iter().all(|p| p.x.abs() <= EXTREMA_LIMIT && p.y.abs() <= EXTREMA_LIMIT) { + ParamCurveExtrema::bounding_box(&seg) + } else { + pts.iter().fold(Rect::from_points(pts[0], pts[0]), |r, &p| r.union_pt(p)) + } +} + fn push_curve(cur: &mut Option, last: Point, c1: Point, c2: Point, p: Point) { let sp = cur.get_or_insert_with(|| SubPath { anchors: vec![Anchor::corner(last)], closed: false }); if let Some(a) = sp.anchors.last_mut() { @@ -654,6 +686,71 @@ mod tests { assert_eq!(p.bounds(), Some(Rect::new(5.0, 5.0, 15.0, 15.0))); } + /// A curve from (100, 100) to (300, 100) with the given out and in handles. + fn curve(h_out: Point, h_in: Point) -> PathData { + let a = Anchor::with_handles(Point::new(100.0, 100.0), Point::new(100.0, 100.0), h_out); + let b = Anchor::with_handles(Point::new(300.0, 100.0), h_in, Point::new(300.0, 100.0)); + PathData::single(SubPath::new(vec![a, b], false)) + } + + /// A curve whose handles are at ±1e308 or infinite is measured by its control points: its box + /// holds every finite point of the curve, and an infinite handle makes the box infinite. Handles + /// at ±`f64::MAX / 16` are measured by the curve's extrema, and that box holds the curve too. + #[test] + fn bounds_hold_a_curve_whose_handles_are_huge() { + // `eval` rounds: a point may sit a few ulps outside the box. + let within = |v: f64, lo: f64, hi: f64| { + let e = 1e-12 * v.abs().max(1.0); + lo - e <= v && v <= hi + e + }; + let holds_curve = |h_out: Point, h_in: Point, b: Rect| { + let c = CubicBez::new(Point::new(100.0, 100.0), h_out, h_in, Point::new(300.0, 100.0)); + for i in 0..=20 { + let q = c.eval(f64::from(i) / 20.0); + if q.is_finite() { + assert!(within(q.x, b.x0, b.x1) && within(q.y, b.y0, b.y1), "{h_out:?} {h_in:?}: {q:?} is outside {b:?}"); + } + } + }; + let cases = [ + (Point::new(100.0, 1e308), Point::new(300.0, 1e308)), + (Point::new(100.0, -1e308), Point::new(300.0, 1e308)), + (Point::new(-1.7e308, 100.0), Point::new(1.7e308, 100.0)), + (Point::new(f64::INFINITY, 100.0), Point::new(250.0, 100.0)), + ]; + for (h_out, h_in) in cases { + let p = curve(h_out, h_in); + let b = p.bounds().unwrap(); + assert_eq!(Some(b), p.control_bounds(), "{h_out:?} {h_in:?}"); + holds_curve(h_out, h_in, b); + } + assert!(!curve(Point::new(f64::INFINITY, 100.0), Point::new(250.0, 100.0)).bounds().unwrap().x1.is_finite()); + let m = f64::MAX / 16.0; + for (h_out, h_in) in [(Point::new(100.0, m), Point::new(300.0, m)), (Point::new(100.0, -m), Point::new(300.0, m))] { + let b = curve(h_out, h_in).bounds().unwrap(); + assert!(b.y0.is_finite() && b.y1.is_finite() && b.y1 < m, "{h_out:?} {h_in:?}: {b:?}"); + holds_curve(h_out, h_in, b); + } + } + + /// Ordinary paths, and curves whose handles are large but below `f64::MAX / 16`, measure as + /// kurbo's `bounding_box` measures them, bit for bit. + #[test] + fn bounds_of_ordinary_paths_match_kurbo_bit_for_bit() { + let circle = PathData::from_bezpath(&kurbo::Circle::new((50.0, 50.0), 20.0).to_path(1e-3)); + let s_curve = curve(Point::new(180.0, -40.0), Point::new(220.0, 240.0)); + let mut two = square(); + two.subpaths.extend(circle.transformed(Affine::translate((-75.5, 30.25))).subpaths); + let lone = PathData::new(vec![SubPath::new(vec![Anchor::corner(Point::new(1.0, 2.0))], false), square().subpaths[0].clone()]); + let quad = PathData::from_bezpath(&BezPath::from_svg("M0 0 Q 50 -80 100 0 Q 150 80 200 0").unwrap()); + for p in [square(), circle, s_curve, two, lone, quad, curve(Point::new(100.0, 1e300), Point::new(300.0, -1e300))] { + let ours = p.bounds().unwrap(); + let kurbo = p.to_bezpath().bounding_box(); + let bits = |r: Rect| [r.x0, r.y0, r.x1, r.y1].map(f64::to_bits); + assert_eq!(bits(ours), bits(kurbo), "{ours:?} {kurbo:?}"); + } + } + #[test] fn smooth_detection() { let a = Anchor::with_handles(Point::new(0.0, 0.0), Point::new(-1.0, 0.0), Point::new(2.0, 0.0)); diff --git a/docs/mcp.md b/docs/mcp.md index 31db6bd54..7f5bc558b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1940,7 +1940,10 @@ line between its anchors, on the same side and at the same length, and the point as the point moved by `dx`, `dy` does; a segment whose anchors coincide bends as it does without `perpendicular`. `path.reshapeSegment` refuses a delta of 1e9 pt or longer ("invalid delta"), as `object.blend.spine.reshapeSegment` does. `path.convertAnchor` (its `x`, `y`) and `path.freehand` (each of its `points`) refuse a point whose x or y is -1e9 pt or more in size ("x and y must be finite"). +1e9 pt or more in size ("x and y must be finite"). The canvas check after an edit measures a segment of a selected +path by its control points when one of them has an x or y that is infinite or larger in size than `f64::MAX / 16` +(about 1.1e307), so an edit that leaves such a path selected, `path.create` included, fails with "result would exceed +the canvas (coordinates out of range)". A `pointer_gesture` with `mods: {cmd: true}` held at the press, with any tool but the selection tools (Command on macOS, Ctrl elsewhere), drags with the selection tool chosen last (`select_tool`), or Direct Selection with the Pen, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 13574ce9c..843e678a1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -535,6 +535,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Transform Again uses the Copy, Transform Patterns, Scale Strokes & Effects and Scale Corners choices of the transform it repeats; after a bounding-box handle drag on area type, it resizes the type area again and the type keeps its size (`object.transformAgain`). With anchors selected with Direct Selection, Move, Rotate, Scale, Reflect, Shear and Transform Again (the commands, their dialogs and the four tools' drags) transform only those anchors, their handles with them, about the centre of the box around them unless a point was clicked; a copy still copies the whole object (#1408; behaviour reference: [adjust path segments](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html); the default reference point and the copy are our choices). The Move, Rotate, Scale, Reflect and Shear dialogs have a Preview checkbox, off when they open: while it is on the canvas follows the values, Cancel rolls it back and OK is still one undo step; Up/Down step the Rotate and Shear angles and the Scale percentages by 1, Shift by 10 (#1412). The five dialogs and Transform Each have Transform Objects beside Transform Patterns: off, the art stays and only its pattern tiles transform (`objects` on the transform commands), and Transform Again repeats the choice (#1448; behaviour reference: [scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html); the checkboxes are always enabled and the Scale page's pair is applied to all of them, our choices). Reflect's axis is Horizontal, Vertical or an angle (#1460; [reflect objects along an axis](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/reflect-objects-along-an-axis.html)). The link that keeps Transform Each's two scales equal is not built. - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494). With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). - **Direct Selection (#1278):** clicking a segment picks its connection, with hollow endpoint anchors and only that curve's direction handles. Delete leaves the neighboring curves and appearance intact, opens/splits contours in one undo step, and another Delete removes the remaining selected path. A whole-path drag and deliberate anchor selection keep their existing behavior; egui frames and native control/art checked, OS mouse/visual QA pending. Where anchors of overlapping objects coincide, a click or drag takes the anchor of the selected object, also when another object lies over it (#1509, fixes #1508). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Double-clicking type (including type nested inside a group) switches to the Type tool and puts the caret at the clicked character. + A path segment with a control point whose x or y is infinite or larger in size than `f64::MAX / 16` is measured by its control points, so its bounds hold the whole curve and the canvas check refuses an edit that leaves such a path selected (`path.create` included). With Direct Selection, the Pen and the anchor tools, a group, compound path or clip group selected whole shows the anchors of its paths filled, as a path selected whole does; the direction handles still show only on a path that is selected itself. Public references: [Adjust anchor point, handle, and bounding box display size](https://web.archive.org/web/20251114182206/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/learn-drawing-basics/adjust-anchor-point-handle-and-bounding-box-display-size.html) and [Drawing basics](https://web.archive.org/web/20231130064834/https://helpx.adobe.com/illustrator/using/drawing-basics.html) (archived copies). `path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size. - **Group Selection (#2441):** a click picks one path of a compound path (its outline within the pick tolerance, else the innermost path whose area holds the click: the outer shape or a hole), Shift-click adds another, and a click on a selected path adds the compound path, as a click on a group member climbs to its group. A path selected this way moves, rotates and scales on its own; paint still goes to the compound path.