diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 82a538067..81f8616a8 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -27,7 +27,8 @@ pub struct ViewInfo { pub snap_to_pixel: bool, /// View → Snap to Point: picked points (a transform's reference point) land on anchors. pub snap_to_point: bool, - /// View → Snap to Glyph: drawing snaps to live-text baselines and glyph bounds (needs Smart Guides). + /// View → Snap to Glyph: drawing snaps to live-text glyph bounds, and on a single line of type to + /// its baseline, x-height, character widths and glyph anchor points (needs Smart Guides). pub snap_to_glyph: bool, /// View → Show Corner Widget. pub corner_widgets: bool, diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index 6276f86cd..758acc2c2 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -214,6 +214,11 @@ impl FontFace { pub fn vertical_metrics(&self) -> (f64, f64) { (self.ascent, self.descent) } + /// The height of the lowercase letters in font units, positive (estimated from the ascent + /// when the font has no OS/2 value). + pub fn x_height(&self) -> f64 { + self.x_height + } /// The line gap the font asks for between lines, in font units (from the same table as /// [`vertical_metrics`](Self::vertical_metrics); 0 when it has none). pub fn line_gap(&self) -> f64 { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 838940ddb..092a2516e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -216,6 +216,12 @@ impl Ctx<'_> { Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx - lead, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy) } }; + let unturned = if g.inline.is_none() && g.rotation != 0.0 && g.sx != 0.0 && g.sy != 0.0 { + let placed = Affine::translate((g.dx - lead, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); + placed.inverse() * Affine::rotate(g.rotation.to_radians()) * placed + } else { + Affine::IDENTITY + }; let mut m = pre * local; if self.vertical && self.on_path { // Vertical path type keeps the baseline path and turns each glyph across it. @@ -271,6 +277,7 @@ impl Ctx<'_> { image, colour, bend: None, + unturned, }); } diff --git a/crates/text/src/layout/ruby.rs b/crates/text/src/layout/ruby.rs index 4f2c76fe2..54681da89 100644 --- a/crates/text/src/layout/ruby.rs +++ b/crates/text/src/layout/ruby.rs @@ -367,5 +367,6 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi image: None, colour: None, bend: None, + unturned: Affine::IDENTITY, } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 4299e61d2..b401905bb 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -136,6 +136,9 @@ pub struct PositionedGlyph { /// the path ([`GlyphBend`]) so it meets its neighbours, and `xf` is only the straight glyph /// nearest it (for the caret, hit testing and tools). `None`: the glyph is drawn through `xf`. pub bend: Option, + /// Font units → font units: `xf * unturned` places the glyph as it would be placed without + /// its Character Rotation. The identity for a glyph without one. + pub unturned: Affine, } /// How a glyph on a path is bent along it: a point of its pen space (x along its advance from its diff --git a/crates/text/src/tests/bidi.rs b/crates/text/src/tests/bidi.rs index af67365b0..1ec51d15a 100644 --- a/crates/text/src/tests/bidi.rs +++ b/crates/text/src/tests/bidi.rs @@ -163,6 +163,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout image: None, colour: None, bend: None, + unturned: Affine::IDENTITY, }); x += advance; } diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index f3a0e3c19..97b9f8597 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -96,7 +96,8 @@ pub struct ToolContext<'a> { pub snap_tangent: bool, /// View → Snapping → Snap to Perpendicular: likewise where it meets a nearby segment square. pub snap_perpendicular: bool, - /// View → Snap to Glyph: drawn points snap to live-text baselines and glyph bounds (with Smart Guides). + /// View → Snap to Glyph: drawn points snap to live-text glyph bounds, and on a single line of type + /// to its baseline, x-height, character widths and glyph anchor points (with Smart Guides). pub snap_to_glyph: bool, /// View → Show Corner Widget: paths show draggable Live Corners widgets in their corners. pub corner_widgets: bool, diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index c3401b010..894e7ca5a 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -307,7 +307,7 @@ impl SnapKey { exclude: exclude.to_vec(), screen: cx.screen, view: [cx.smart_guides, cx.snap_to_point, cx.snap_to_pixel, cx.snap_to_grid, cx.guides, cx.snap_tangent, cx.snap_perpendicular], - snap_to_glyph: cx.snap_to_glyph && cx.alignment_guides, + snap_to_glyph: cx.snap_to_glyph && cx.smart_guides && cx.alignment_guides, style: GuideStyle::of(cx), tolerances: [cx.snapping_tolerance, cx.snap_tolerance], } diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index 5c5c061e9..994e28b34 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -47,6 +47,10 @@ pub struct Targets { /// it ends, as the end that stays put and the unit direction towards the dragged one /// ([`Self::for_anchor_drag`]). pub(super) extensions: Vec<(Point, Vec2)>, + /// Snap to Glyph's guides that run at an angle (in rotated type, or along a glyph's slanted + /// segments), as a point on each and its unit direction: a drawn point lands on the nearest + /// ([`Self::for_drawing`]). + pub(super) angled: Vec<(Point, Vec2)>, /// Snap to Tangent and Snap to Perpendicular: a drawn line from an anchor lands where it /// grazes a curve, or meets a segment square ([`Self::touching`]). pub(super) tangent: bool, @@ -209,12 +213,34 @@ impl Targets { self.ys.push((a.y, a, Kind::Anchor)); } - /// Live-text baselines and glyph bounds (View › Snap to Glyph). + /// A glyph guide through `at` along `dir` (on the page): a line the art lines up with when it + /// runs across or down the page, else an angled line a point lands on. + fn push_glyph_line(&mut self, at: Point, dir: Vec2) { + let len = dir.hypot(); + if !(len.is_finite() && len > 1e-12 && at.is_finite()) { + return; + } + let d = dir / len; + if d.y.abs() <= 1e-9 { + self.ys.push((at.y, at, Kind::Glyph)); + } else if d.x.abs() <= 1e-9 { + self.xs.push((at.x, at, Kind::Glyph)); + } else { + self.angled.push((at, d)); + } + } + + /// Live text as View › Snap to Glyph shows it: the glyph bounds of every text object, and for + /// a single line of type also its baseline and x-height, the proximity guides at the edges of + /// each character's advance width, and its glyphs' anchor points. In rotated type these + /// guides run at its angle (angular guides), and type of one glyph also has angular guides + /// along the glyph's slanted straight segments. fn push_glyphs(&mut self, doc: &Document, exclude: &[NodeId], visible: Option) { let skip: HashSet = exclude.iter().copied().collect(); let db = FontDb::global(); let kind = Kind::Glyph; let mut budget = BUDGET / 4; + let (mut anchors, mut angular) = (BUDGET, BUDGET / 4); walk_but(&doc.layers, &skip, &mut |n| { if budget == 0 || !n.visible { return; @@ -231,25 +257,84 @@ impl Targets { { return; } + // A paragraph shows glyph bounds only. + let single = lay.lines.len() == 1; + // Angular guides along a glyph's slanted segments: type of one glyph only. + let one_glyph = lay.glyphs.iter().filter(|g| !g.outline.elements().is_empty()).count() == 1; + // Text space → the page, and a direction in text space → one on the page. let to_doc = |p: Point| t.xf * p; - for line in &lay.lines { - let at = to_doc(Point::new(line.x0, line.baseline)); - self.ys.push((at.y, at, kind)); + let along = |m: Affine, v: Vec2| (m * v.to_point()) - (m * Point::ZERO); + if single { + let line_doc = t.xf * lay.line_xf; + for line in &lay.lines { + self.push_glyph_line(line_doc * Point::new(line.x0, line.baseline), along(line_doc, Vec2::new(1.0, 0.0))); + } } + // The last x-height pushed, to push each run of glyphs alike once. + let mut last_x_height: Option<(Point, Vec2)> = None; for g in &lay.glyphs { if budget == 0 { break; } + // Glyphs that draw nothing (spaces) give no guides. let bb = g.outline.bounding_box(); if !(bb.width() > 0.0 && bb.height() > 0.0) { continue; } + budget -= 1; + // Horizontal type and type on a path: the baseline runs from where the pen stood + // along the glyph's angle (not the glyph's own drawing, which Character Rotation + // turns), up being to its left. + if single + && !lay.vertical + && let Some(face) = db.face_by_id(g.font_id) + { + let ahead = Vec2::new(g.angle.cos(), g.angle.sin()); + let up = Vec2::new(ahead.y, -ahead.x); + let dir = along(t.xf, ahead); + // The x-height of the glyph as placed without its Character Rotation (raised or + // lowered by its baseline shift), measured across the baseline. + let top = g.xf * g.unturned * Point::new(0.0, -face.x_height()); + let at = to_doc(g.origin + up * (top - g.origin).dot(up)); + let same = last_x_height.is_some_and(|(a, d)| { + let (l, k) = (d.hypot(), dir.hypot()); + l > 1e-12 && k > 1e-12 && (d / l - dir / k).hypot() <= 1e-9 && ((at - a).cross(d / l)).abs() <= 1e-6 + }); + if face.x_height() > 0.0 && !same { + self.push_glyph_line(at, dir); + last_x_height = Some((at, dir)); + } + // Proximity guides: across the baseline at both edges of the width the layout + // gives the character (its advance with tracking and kerning). + for e in [g.origin, g.origin + ahead * g.advance] { + self.push_glyph_line(to_doc(e), along(t.xf, up)); + } + } + if single { + for seg in g.outline.segments() { + if anchors > 0 { + self.points.push((to_doc(seg.start()), Kind::Anchor)); + anchors -= 1; + } + if one_glyph + && angular > 0 + && let PathSeg::Line(l) = seg + { + let (p, q) = (to_doc(l.p0), to_doc(l.p1)); + let d = q - p; + let len = d.hypot(); + if len > 1e-6 && d.x.abs() > 1e-9 * len && d.y.abs() > 1e-9 * len { + self.push_glyph_line(p, d); + angular -= 1; + } + } + } + } let r = t.xf.transform_rect_bbox(bb); for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] { self.xs.push((p.x, p, kind)); self.ys.push((p.y, p, kind)); } - budget -= 1; } }); } @@ -399,6 +484,7 @@ impl Targets { self.ys.clear(); self.segments.clear(); self.extensions.clear(); + self.angled.clear(); self } @@ -508,7 +594,11 @@ impl Targets { } // A construction guide through the point it leaves, else a dragged segment's extension: // it lands on the guide, sliding into line with a target crossing it nearby. - if let Some((at, dir, q)) = from.and_then(|f| f.construction(p, tol).map(|(dir, q)| (f.at, dir, q))).or_else(|| self.extension(p, tol)) { + if let Some((at, dir, q)) = from + .and_then(|f| f.construction(p, tol).map(|(dir, q)| (f.at, dir, q))) + .or_else(|| self.extension(p, tol)) + .or_else(|| self.on_angled(p, tol)) + { let construction = |q: Point| Overlay::Line { a: at, b: q, color: self.style.color, dashed: false }; return match self.slide(at, dir, q, tol, false) { Some((q, target)) => (q, std::iter::once(construction(q)).chain(self.lined_up(q, target)).collect()), @@ -606,6 +696,18 @@ impl Targets { .map(|(at, d, q, _)| (at, d, q)) } + /// The angled glyph guide ([`Self::angled`]) nearest `p`, within `tol` of it: a point on it, its + /// direction and where `p` lands on it. + fn on_angled(&self, p: Point, tol: f64) -> Option<(Point, Vec2, Point)> { + self.angled + .iter() + .map(|&(at, d)| (at, d, at + d * (p - at).dot(d))) + .map(|(at, d, q)| (at, d, q, q.distance(p))) + .filter(|(.., dist)| *dist <= tol) + .min_by(|a, b| a.3.total_cmp(&b.3)) + .map(|(at, d, q, _)| (at, d, q)) + } + /// The anchor or centre nearest `p`, within `tol`. /// Is a target point (an anchor, a centre) within `tol` of `p`? pub(crate) fn has_point_near(&self, p: Point, tol: f64) -> bool { @@ -621,7 +723,7 @@ impl Targets { } /// Slide `q`, on the line through `at` along `dir` (beyond `at` only, for a `ray`), to where - /// the nearest target line crosses it, at most twice `tol` away: where it lands and the + /// the nearest target line or angled glyph guide crosses it, at most twice `tol` away: where it lands and the /// target's own point (where its line starts). fn slide(&self, at: Point, dir: Vec2, q: Point, tol: f64, ray: bool) -> Option<(Point, Point)> { if !dir.hypot2().is_finite() || dir.hypot2() <= 1e-18 { @@ -646,6 +748,19 @@ impl Targets { } } } + // The angled glyph guides it crosses. + for &(on, e) in &self.angled { + let across = dir.cross(e); + if across.abs() <= 1e-9 * dir.hypot() { + continue; + } + let s = (on - at).cross(e) / across; + let hit = at + dir * s; + let moved = hit.distance(q); + if moved <= reach && (!ray || s > 0.0) && best.is_none_or(|b| moved < b.0) { + best = Some((moved, hit, on)); + } + } best.map(|(_, hit, from)| (hit, from)) } diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index 517937c74..200ab5008 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -608,3 +608,208 @@ fn draw_snap_gathers_once_per_document_state() { let q = snap.press(&c, Point::new(31.0, 2.0), &[], Some(&from)); assert!((q.y - 0.0).abs() < 1e-9, "{q:?}"); } + +/// A document holding point type `text` at (100, 300), 60 pt, turned `angle` radians about +/// where it starts, and the type. +fn glyph_doc(text: &str, angle: f64) -> (vectorcraft_doc::Document, vectorcraft_doc::TextObject) { + glyph_doc_with(text, angle, vectorcraft_doc::CharStyle { size: 60.0, ..Default::default() }) +} + +/// [`glyph_doc`] with the type in `style`. +fn glyph_doc_with(text: &str, angle: f64, style: vectorcraft_doc::CharStyle) -> (vectorcraft_doc::Document, vectorcraft_doc::TextObject) { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let at = Point::new(100.0, 300.0); + let mut t = vectorcraft_doc::TextObject::point(at, text, style); + t.xf = Affine::rotate_about(angle, at) * t.xf; + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t.clone())))).unwrap(); + (d, t) +} + +/// Snap to Glyph: a Pen click near an anchor point of a glyph's outline lands on it, and not with +/// Snap to Glyph off. +#[test] +fn pen_click_near_a_glyph_anchor_lands_on_it() { + use crate::{Action, PointerEvent, PointerKind, Tool}; + let (d, t) = glyph_doc("H", 0.0); + let lay = layout_text(FontDb::global(), &t); + let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline"); + // An anchor inside the glyph's bounds (a corner where the crossbar meets a stem), which no + // glyph bounds line passes through. + let bb = g.outline.bounding_box(); + let inside = |p: Point| p.x > bb.x0 + 1.0 && p.x < bb.x1 - 1.0 && p.y > bb.y0 + 1.0 && p.y < bb.y1 - 1.0; + let anchor = t.xf * g.outline.segments().map(|s| s.start()).find(|p| inside(*p)).expect("an anchor inside the glyph"); + let (s, p) = (Selection::default(), paint()); + let click = anchor + Vec2::new(1.5, -1.0); + let first_anchor = |glyphs: bool| { + let c = ToolContext { snap_to_glyph: glyphs, ..cx(&d, &s, &p) }; + let mut pen = crate::pen::PenTool::default(); + let acts = pen.pointer(&c, &PointerEvent::new(PointerKind::Down, click.x, click.y)); + let Some(Action::Preview(_, v)) = acts.get(1) else { panic!("{acts:?}") }; + Point::new(v["anchors"][0]["x"].as_f64().unwrap(), v["anchors"][0]["y"].as_f64().unwrap()) + }; + assert!(first_anchor(true).distance(anchor) < 1e-9, "{:?} vs {anchor:?}", first_anchor(true)); + assert!(first_anchor(false).distance(anchor) > 1e-3); + // Snap to Glyph works with Smart Guides on: Snap to Point alone does not land on glyph anchors. + let c = ToolContext { snap_to_glyph: true, smart_guides: false, snap_to_point: true, ..cx(&d, &s, &p) }; + let mut pen = crate::pen::PenTool::default(); + let acts = pen.pointer(&c, &PointerEvent::new(PointerKind::Down, click.x, click.y)); + let Some(Action::Preview(_, v)) = acts.get(1) else { panic!("{acts:?}") }; + let q = Point::new(v["anchors"][0]["x"].as_f64().unwrap(), v["anchors"][0]["y"].as_f64().unwrap()); + assert!(q.distance(anchor) > 1e-3, "{q:?} landed on the glyph anchor with Smart Guides off"); +} + +/// Snap to Glyph: a single line of type has an x-height guide; a paragraph shows glyph bounds only. +#[test] +fn drawn_points_line_up_with_the_x_height_of_a_single_line() { + let (d, t) = glyph_doc("H", 0.0); + let lay = layout_text(FontDb::global(), &t); + let g = &lay.glyphs[0]; + let face = FontDb::global().face_by_id(g.font_id).unwrap(); + let x_height = (t.xf * g.xf * Point::new(0.0, -face.x_height())).y; + let p = Point::new(400.0, x_height + 2.0); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(p, 4.0); + assert!((q.y - x_height).abs() < 1e-9, "{q:?}, x-height {x_height}"); + let (q, _) = Targets::for_drawing(&d, &[], None, false).snap_point(p, 4.0); + assert_eq!(q.y, p.y); + let (d, _) = glyph_doc("H\nH", 0.0); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(p, 4.0); + assert_eq!(q.y, p.y, "a paragraph has no x-height guide"); + // Baseline Shift raises the x-height with the glyphs. + let (d, _) = glyph_doc_with("H", 0.0, vectorcraft_doc::CharStyle { size: 60.0, baseline_shift: 10.0, ..Default::default() }); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(Point::new(400.0, x_height - 10.0 + 2.0), 4.0); + assert!((q.y - (x_height - 10.0)).abs() < 1e-9, "{q:?}, shifted x-height {}", x_height - 10.0); + // Character Rotation turns the glyph, not its baseline shift. + let style = vectorcraft_doc::CharStyle { size: 60.0, baseline_shift: 10.0, rotation: 30.0, ..Default::default() }; + let (d, _) = glyph_doc_with("H", 0.0, style); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(Point::new(400.0, x_height - 10.0 + 2.0), 4.0); + assert!((q.y - (x_height - 10.0)).abs() < 1e-9, "{q:?}, shifted x-height {}", x_height - 10.0); + // Glyph Scaling widens a rotated glyph without raising its x-height. + let mut t = + vectorcraft_doc::TextObject::point(Point::ZERO, "HH HH", vectorcraft_doc::CharStyle { size: 60.0, rotation: 90.0, ..Default::default() }); + let frame = kurbo::Shape::to_path(&kurbo::Rect::new(50.0, 100.0, 450.0, 400.0), 0.1); + t.kind = vectorcraft_doc::TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&frame) }; + t.xf = Affine::IDENTITY; + t.para.justify = vectorcraft_doc::Justify::JustifyAll; + t.para.justification.glyph_scaling = [100.0, 100.0, 200.0]; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t.clone())))).unwrap(); + let lay = layout_text(FontDb::global(), &t); + assert_eq!(lay.lines.len(), 1); + let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline"); + assert!(g.scale > 1.2, "Glyph Scaling widens the glyph: {}", g.scale); + let expected = (t.xf * g.origin).y - (300.0 - x_height); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(Point::new(30.0, expected + 2.0), 4.0); + assert!((q.y - expected).abs() < 1e-9, "{q:?}, x-height {expected}"); +} + +/// Snap to Glyph: the x-height guide of type skewed along a slanted path runs through the glyph's +/// x-height. +#[test] +fn the_x_height_guide_of_skewed_path_type_runs_through_the_glyphs_x_height() { + let mut path = kurbo::BezPath::new(); + path.move_to((100.0, 400.0)); + path.line_to((300.0, 54.0)); + let mut t = vectorcraft_doc::TextObject::point(Point::ZERO, "H", vectorcraft_doc::CharStyle { size: 60.0, ..Default::default() }); + t.kind = vectorcraft_doc::TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t.path_effect = vectorcraft_doc::PathEffect::Skew; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t.clone())))).unwrap(); + let lay = layout_text(FontDb::global(), &t); + let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline"); + let face = FontDb::global().face_by_id(g.font_id).unwrap(); + let top = t.xf * g.xf * Point::new(0.0, -face.x_height()); + let ahead = Vec2::new(g.angle.cos(), g.angle.sin()); + let normal = Vec2::new(ahead.y, -ahead.x); + // Beside the guide's line, past the glyph's end along it. + let p = top + ahead * 80.0 + normal * 1.5; + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(p, 4.0); + assert!((q - top).cross(ahead).abs() < 1e-6, "{q:?} is off the x-height line through {top:?}"); +} + +/// Snap to Glyph › Proximity Guides: the edges of each character's advance width are guides. +#[test] +fn drawn_points_line_up_with_the_edge_of_a_characters_width() { + let (d, t) = glyph_doc("HH", 0.0); + let lay = layout_text(FontDb::global(), &t); + let edge = (t.xf * lay.glyphs[1].origin).x; + let p = Point::new(edge + 0.3, 450.0); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(p, 4.0); + assert!((q.x - edge).abs() < 1e-9, "{q:?}, edge {edge}"); + let (q, _) = Targets::for_drawing(&d, &[], None, false).snap_point(p, 4.0); + assert_eq!(q.x, p.x); + // Character Rotation turns the glyphs, not their widths along the baseline. + let (d, t) = glyph_doc_with("HH", 0.0, vectorcraft_doc::CharStyle { size: 60.0, rotation: 30.0, ..Default::default() }); + let lay = layout_text(FontDb::global(), &t); + let edge = (t.xf * lay.glyphs[1].origin).x; + let p = Point::new(edge + 0.3, 450.0); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(p, 4.0); + assert!((q.x - edge).abs() < 1e-9, "{q:?}, edge {edge}"); +} + +/// Snap to Glyph › Angular Guides: in a rotated glyph a straight upright stroke is a guide at its +/// angle, which a drawn point lands on beyond the glyph. +#[test] +fn a_rotated_glyph_has_angular_guides_along_its_strokes() { + let angle = 20f64.to_radians(); + let (d, t) = glyph_doc("H", angle); + let lay = layout_text(FontDb::global(), &t); + let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline"); + let stem = g + .outline + .segments() + .find_map(|s| match s { + PathSeg::Line(l) if (l.p1.x - l.p0.x).abs() < 1e-6 && (l.p1.y - l.p0.y).abs() > 10.0 => Some(l), + _ => None, + }) + .expect("an upright stroke"); + let (a, b) = (t.xf * stem.p0, t.xf * stem.p1); + let dir = (b - a) / (b - a).hypot(); + // Past the far end of the stroke, a little beside its line. + let far = if (a - t.xf * Point::new(0.0, 0.0)).hypot() > (b - t.xf * Point::new(0.0, 0.0)).hypot() { a } else { b }; + let out = if far == a { -dir } else { dir }; + let p = far + out * 30.0 + Vec2::new(-dir.y, dir.x) * 1.5; + let on_line = |q: Point| (q - a).cross(dir).abs(); + let (q, ov) = Targets::for_drawing(&d, &[], None, true).snap_point(p, 4.0); + assert!(on_line(q) < 1e-9, "{q:?} is {} off the stroke's line", on_line(q)); + assert!(!lines(&ov).is_empty()); + let (q, _) = Targets::for_drawing(&d, &[], None, false).snap_point(p, 4.0); + assert!(on_line(q) > 1e-3, "no angular guide with Snap to Glyph off"); +} + +/// Snap to Glyph: the baseline of type turned slightly runs at its angle, and a point near it +/// lands on it even close to where it starts, far nearer the horizontal through that point. +#[test] +fn a_slightly_rotated_baseline_pulls_points_near_its_start() { + let (d, t) = glyph_doc("H", 2f64.to_radians()); + let lay = layout_text(FontDb::global(), &t); + let m = t.xf * lay.line_xf; + let start = m * Point::new(lay.lines[0].x0, lay.lines[0].baseline); + let dir = (m * Point::new(1.0, 0.0)) - (m * Point::ZERO); + let dir = dir / dir.hypot(); + let p = start - dir * 10.0 + Vec2::new(-dir.y, dir.x) * 1.0; + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_point(p, 4.0); + assert!((q - start).cross(dir).abs() < 1e-9, "{q:?} is off the baseline"); +} + +/// Snap to Glyph: a segment Shift keeps level slides along to where it crosses the baseline of +/// rotated type. +#[test] +fn a_shift_constrained_point_slides_onto_a_rotated_baseline() { + let (d, t) = glyph_doc("H", 20f64.to_radians()); + let lay = layout_text(FontDb::global(), &t); + let m = t.xf * lay.line_xf; + let start = m * Point::new(lay.lines[0].x0, lay.lines[0].baseline); + let dir = (m * Point::new(1.0, 0.0)) - (m * Point::ZERO); + let dir = dir / dir.hypot(); + let crossing = start - dir * 40.0; + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let from = Leave::segment(&c, crossing - Vec2::new(60.0, 0.0), true); + let (q, _) = Targets::for_drawing(&d, &[], None, true).snap_from(crossing + Vec2::new(1.5, 0.3), Some(&from), 4.0); + assert!(q.distance(crossing) < 1e-9, "{q:?} vs {crossing:?}"); +} diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 63b60e2b5..02b954d11 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -161,7 +161,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("view.trimView", "Trim View", "", "{} toggle: hide everything outside the artboards"), ("view.cornerWidget", "Hide Corner Widget", "", "{} toggle the live corner widgets"), ("view.snapToPixel", "Snap to Pixel", "", "{} toggle: drawing and moving land on whole pixels"), - ("view.snapToGlyph", "Snap to Glyph", "", "{} toggle: drawing snaps to live-text baselines and glyph bounds"), + ( + "view.snapToGlyph", + "Snap to Glyph", + "", + "{} toggle: drawing snaps to live-text glyph bounds, and on a single line of type to its baseline, x-height, character widths and glyph anchor points", + ), ( "view.preciseCursors", "Precise Cursors", diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7f9771e68..207e200c7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -729,6 +729,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A move pressed on a path's stroke, or within 6 px of its outline away from its anchors and center, is held by the nearest point of that path, with the Selection, Group Selection or Direct Selection tool, and a straight segment Direct Selection drags is held by its own nearest point. That point is what snaps, so the path lands exactly on a ruler guide (with "align") instead of the pressed spot landing there and the path a few pixels beside it, an offset that showed only zoomed far in (#3701). A press in the middle of a fill still snaps the pressed spot: the public [Align objects with guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) page has Snap to Point align the point under the pointer, which is placed exactly where the object should meet the guide. A bounding-box handle dragged with the Selection or Free Transform tool snaps what it moves as a dragged point snaps (#2054). With View › Snap to Grid on, it snaps to the nearest gridline, and with Snap to Pixel on, it snaps to whole pixels. With Smart Guides off and Snap to Point on, it snaps to the other objects' anchors and the ruler guides within the Snap to Point distance. With Smart Guides and Snap to Point both on, it lines up with the ruler guides as well as the other objects. Shift still keeps the proportions, and Alt still scales about the center. A user slice's edge or corner handle dragged with the Slice Selection tool snaps as the Slice tool's drag does. With View › Snap to Grid on, it snaps to the nearest gridline, and with Snap to Pixel on, it snaps to whole pixels. Otherwise, with Smart Guides on, it lands on or lines up with the anchors, centers and edges of the art and the artboards, and with Smart Guides off and Snap to Point on, it snaps to the art's anchors and the ruler guides within the Snap to Point distance. Then a ruler guide within 5 px, shown and unlocked, moves the side or corner onto it. Shift still keeps the proportions, and Alt still resizes about the center. + View › Snap to Glyph: with Smart Guides on, a point drawn near a single line of live type lands on its glyphs' anchor points and lines up with its baseline, its x-height and the edges of each character's advance width (proximity guides), as well as the glyph bounds. In rotated type these guides run at the type's angle, and type of one glyph also has angular guides along its slanted straight segments. A paragraph shows glyph bounds only. - **Artboard click-to-fit (#1227):** a plain click fits the topmost editable object or group to a new artboard, including its stroke like Fit to Selected Art, and makes the new artboard active. Art and its selection stay unchanged; creation is one undo step. Handles, modified clicks, drags, Escape and tool changes retain their meaning. Tested inside an artboard and on the pasteboard (`crates/engine/tests/it/artboard_click.rs`); the public [CS6 Printing Guide](https://helpx.adobe.com/content/dam/help/en/indesign/pdf/CS6_PrintGuide.pdf) documents the object-click gesture. This legacy gesture adds evidence without regrading the 2026 canvas controls. - **Rotated views:** with the view rotated, Fit Artboard in Window, Fit All in Window and the status bar's artboard navigator fit the artboard or the art as the view turns it, and a Zoom tool marquee (Animated Zoom or GPU Performance off) zooms to the area dragged across. Ruler guides on no artboard turn with the view. The canvas draws the selection marquee where the tools select, Show Hidden Characters draws the marks of type partly inside the canvas, the Contextual Task Bar stays under the selection, and the Navigator's red box outlines what the window shows. - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, `view.screenMode` from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486).