Files
deckcraft/crates/engine/src/tools/hit.rs
T

307 lines
12 KiB
Rust

use super::{Affine, Hit, Point, Rect, Shape, ShapeId, ShapeKind, TextSel, Vec2, Xfrm, cmd, xfrm_of};
pub(super) fn is_text_first(st: &crate::DocState, id: ShapeId) -> bool {
st.shape(id).is_some_and(|s| s.ph.is_some() || s.text_box)
}
/// The topmost text-holding shape under `p` inside group `sh`, recursing into nested groups.
/// Group children live in the group's child space (`ShapeKind::Group::child`), so the point is
/// mapped through `group_child_affine`, exactly as `hit_shape` does for group hit-testing.
pub(super) fn text_member_at(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<ShapeId> {
let ShapeKind::Group { children, child } = &sh.kind else { return None };
let x = xfrm_of(&st.doc, &st.selection, sh);
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
member_at(children, deckcraft_geom::group_child_affine(&x, ch), p, tol)
}
/// Depth-first (topmost-first) search of `children` for a text-holding shape under `p`.
/// `to_abs` maps this level's child space to slide space.
fn member_at(children: &[Shape], to_abs: Affine, p: Point, tol: f64) -> Option<ShapeId> {
let q = to_abs.inverse() * p;
for c in children.iter().rev() {
let Some(cx) = c.xfrm else { continue };
let local = cx.affine().inverse() * q;
if local.x < -tol || local.y < -tol || local.x > cx.w + tol || local.y > cx.h + tol {
continue;
}
if let ShapeKind::Group { children: gc, child: gch } = &c.kind {
let gch = Rect::new(gch.x, gch.y, gch.x + gch.w, gch.y + gch.h);
let nested = to_abs * deckcraft_geom::group_child_affine(&cx, gch);
if let Some(id) = member_at(gc, nested, p, tol) {
return Some(id);
}
} else if matches!(c.kind, ShapeKind::Shape | ShapeKind::Connector { .. }) && !c.is_line() {
return Some(c.id);
}
}
None
}
/// The box whose text is being edited on the slide (not speaker notes, not a table cell).
pub(super) struct EditedBox {
pub(super) id: ShapeId,
pub(super) xfrm: Xfrm,
/// Its frame can be grabbed to move it: not a line (no frame to grab) and not locked.
pub(super) movable: bool,
}
/// Whether a visible shape drawn in front of `target` is under `p`: the shapes above it in its
/// list, topmost first, and inside a group that holds it, the members above it.
pub(super) fn shape_in_front(st: &crate::DocState, shapes: &[Shape], target: ShapeId, p: Point, tol: f64, depth: usize) -> bool {
if depth > 64 {
return false;
}
for s in shapes.iter().rev() {
if s.id == target {
return false;
}
if deckcraft_model::find_shape(std::slice::from_ref(s), target).is_some() {
// The group holding the text: look among its members.
return shape_in_front(st, s.children(), target, p, tol, depth + 1);
}
if !s.hidden && hit_shape(st, s, p, tol).is_some() {
return true;
}
}
false
}
pub(super) fn edited_box(st: &crate::DocState) -> Option<EditedBox> {
let t = st.selection.text.as_ref()?;
if t.notes || t.cell.is_some() {
return None;
}
let sh = st.shape(t.shape)?;
Some(EditedBox { id: t.shape, xfrm: xfrm_of(&st.doc, &st.selection, sh), movable: !sh.is_line() && !sh.locked })
}
/// Where a point falls against the box of the text being edited.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum EditZone {
/// Well inside: the caret goes there.
Inside,
/// On the frame: a band `tol` wide on either side of the box edge.
Frame,
Outside,
}
/// Classify slide point `p` against box `x`, in the box's own (rotated, flipped) frame. Inside
/// small boxes the band narrows to a quarter of the box so their text stays reachable.
pub(super) fn edit_zone(x: &Xfrm, p: Point, tol: f64) -> EditZone {
let l = x.affine().inverse() * p;
let (w, h) = (x.w.max(0.0), x.h.max(0.0));
let tol = if tol.is_finite() { tol.max(0.0) } else { 0.0 };
if !(l.x.is_finite() && l.y.is_finite()) || l.x < -tol || l.y < -tol || l.x > w + tol || l.y > h + tol {
return EditZone::Outside;
}
let inner = tol.min(w / 4.0).min(h / 4.0);
if l.x > inner && l.y > inner && l.x < w - inner && l.y < h - inner { EditZone::Inside } else { EditZone::Frame }
}
pub(super) fn contains_rect(outer: Rect, inner: Rect) -> bool {
inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1
}
pub(super) fn constrain_45(a: Point, b: Point) -> Point {
let d = b - a;
let ang = d.y.atan2(d.x);
let snapped = (ang / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4;
let len = d.hypot();
a + Vec2::new(snapped.cos(), snapped.sin()) * len
}
/// A line-like shape's box for endpoints `a` → `b` (flips encode direction).
pub fn line_xfrm(a: Point, b: Point, _rot: f64) -> Xfrm {
let r = Rect::from_points(a, b);
Xfrm { x: r.x0, y: r.y0, w: r.width(), h: r.height(), rot: 0.0, flip_h: b.x < a.x, flip_v: b.y < a.y }
}
/// New box when dragging resize handle `h` by `d` (slide space), keeping the shape's rotation.
pub fn resize_box(o: &Xfrm, h: u8, d: Vec2, keep_aspect: bool, from_center: bool) -> Xfrm {
// Work in the shape's rotated frame.
let rot = Affine::rotate(-o.rot.to_radians());
let ld = rot * Point::new(d.x, d.y);
let (mut l, mut t, mut r, mut b) = (0.0, 0.0, o.w, o.h);
let (sx, sy) = (if o.flip_h { -1.0 } else { 1.0 }, if o.flip_v { -1.0 } else { 1.0 });
let (dx, dy) = (ld.x * sx, ld.y * sy);
match h {
0 => {
l += dx;
t += dy;
}
1 => t += dy,
2 => {
r += dx;
t += dy;
}
3 => r += dx,
4 => {
r += dx;
b += dy;
}
5 => b += dy,
6 => {
l += dx;
b += dy;
}
_ => l += dx,
}
if from_center {
// Symmetric growth about the centre.
let gw = match h {
0 | 6 | 7 => -dx,
2..=4 => dx,
_ => 0.0,
};
let gh = match h {
0..=2 => -dy,
4..=6 => dy,
_ => 0.0,
};
l = -gw;
r = o.w + gw;
t = -gh;
b = o.h + gh;
}
let mut w = r - l;
let mut hh = b - t;
if keep_aspect && o.w > 1e-9 && o.h > 1e-9 && !matches!(h, 1 | 3 | 5 | 7) {
let ar = o.w / o.h;
if (w / ar).abs() > hh.abs() {
hh = w.abs() / ar * hh.signum().max(if hh == 0.0 { 1.0 } else { hh.signum() });
} else {
w = hh.abs() * ar * w.signum().max(if w == 0.0 { 1.0 } else { w.signum() });
}
if matches!(h, 0 | 6) {
l = r - w;
} else {
r = l + w;
}
if matches!(h, 0 | 2) {
t = b - hh;
} else {
b = t + hh;
}
if from_center {
let (cx, cy) = (o.w / 2.0, o.h / 2.0);
l = cx - w / 2.0;
r = cx + w / 2.0;
t = cy - hh / 2.0;
b = cy + hh / 2.0;
}
}
// Dragging past the opposite edge flips.
let (mut fh, mut fv) = (o.flip_h, o.flip_v);
if r < l {
std::mem::swap(&mut l, &mut r);
fh = !fh;
}
if b < t {
std::mem::swap(&mut t, &mut b);
fv = !fv;
}
let (nw, nh) = (r - l, b - t);
// New centre in slide space: old centre + rotated offset of the local centre change.
let local_c = Point::new((l + r) / 2.0 * sx + if sx < 0.0 { o.w } else { 0.0 }, (t + b) / 2.0 * sy + if sy < 0.0 { o.h } else { 0.0 });
let c = o.affine() * Point::new(if o.flip_h { o.w - local_c.x } else { local_c.x }, if o.flip_v { o.h - local_c.y } else { local_c.y });
Xfrm { x: c.x - nw / 2.0, y: c.y - nh / 2.0, w: nw, h: nh, rot: o.rot, flip_h: fh, flip_v: fv }
}
/// Whether `p` lies in a cell of table `id`, not in the grab band around it (`cell_at` would take
/// the nearest cell).
pub(super) fn in_table_cell(st: &crate::DocState, id: ShapeId, p: Point) -> bool {
st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)).is_some_and(|f| {
let q = f.local(p);
(0.0..=f.width()).contains(&q.x) && (0.0..=f.height()).contains(&q.y) && f.cell_at(q).is_some()
})
}
pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit> {
let x = xfrm_of(&st.doc, &st.selection, sh);
let inv = x.affine().inverse();
let local = inv * p;
// Table borders resize rows and columns.
let near = local.x >= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol;
if near && let Some(f) = cmd::table::table_frame(st, sh) {
match f.border_at(f.local(p), tol) {
Some(cmd::table::TableBorder::Column(col)) => return Some(Hit::TableColumn { id: sh.id, col }),
Some(cmd::table::TableBorder::Row(row)) => return Some(Hit::TableRow { id: sh.id, row }),
None => {}
}
}
match &sh.kind {
ShapeKind::Group { children, child } => {
// A group is hit when any child is hit; the group is what gets selected.
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
let m = deckcraft_geom::group_child_affine(&x, ch);
let q = m.inverse() * p;
for c in children.iter().rev() {
if let Some(cx) = c.xfrm {
let cl = cx.affine().inverse() * q;
if cl.x >= -tol && cl.y >= -tol && cl.x <= cx.w + tol && cl.y <= cx.h + tol {
return Some(Hit::Shape { id: sh.id, text: false });
}
}
}
None
}
ShapeKind::Ink { strokes } => {
for s in strokes {
if s.points.iter().any(|(px, py, _)| (Point::new(*px, *py) - p).hypot() <= tol + s.width) {
return Some(Hit::Shape { id: sh.id, text: false });
}
}
None
}
_ => {
if sh.is_line() {
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
let lw = sh.line.as_ref().and_then(|l| l.width).unwrap_or(1.0);
if deckcraft_geom::near_path(&geo.outline(), local, tol.max(lw)) {
return Some(Hit::Shape { id: sh.id, text: false });
}
return None;
}
let inside = local.x >= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol;
if !inside {
return None;
}
let has_text = matches!(sh.kind, ShapeKind::Shape) && (sh.text.as_ref().is_some_and(|t| !t.is_empty()) || sh.ph.is_some() || sh.text_box);
// Empty interiors of unfilled, text-less shapes don't catch clicks.
let filled = !matches!(sh.fill, Some(deckcraft_model::Fill::None)) || sh.style.is_some();
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
let in_geo = {
use deckcraft_geom::KurboShape;
geo.outline().winding(local) != 0 || deckcraft_geom::near_path(&geo.outline(), local, tol)
};
if has_text
|| sh.ph.is_some()
|| matches!(
sh.kind,
ShapeKind::Picture { .. }
| ShapeKind::Table(_)
| ShapeKind::Chart(_)
| ShapeKind::Media(_)
| ShapeKind::Model3d { .. }
| ShapeKind::Opaque { .. }
)
|| (filled && in_geo)
|| deckcraft_geom::near_path(&geo.outline(), local, tol)
{
return Some(Hit::Shape { id: sh.id, text: has_text || matches!(sh.kind, ShapeKind::Shape) });
}
None
}
}
}
/// Text position under slide point `p` in shape `sh`.
pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) {
cmd::text::text_pos_at(st, &TextSel { shape: sh.id, ..Default::default() }, p)
}
/// Row heights of table `t` as drawn: rows grow to fit their text.
pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec<f64> {
cmd::table_drawn_heights(&st.doc, &st.selection, t)
}