feat(editor): add smart-guide alignment geometry
`align_guides.rs` — pure alignment-guide computation for node dragging: given the moving node's AABB + sibling AABBs it returns the guide lines to paint and the snap offset that locks the drag onto the nearest edge/centre alignment within a threshold. Two axes resolved independently; closest candidate per axis wins, edge-to-edge preferred on a tie. This is the computation core of the smart-guides gap — fully unit tested (6 tests). Host drag-handler wiring + canvas paint of the returned guides follow as separate steps. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
parent
c18ad5a776
commit
f65346c72e
208
crates/op-editor-core/src/align_guides.rs
Normal file
208
crates/op-editor-core/src/align_guides.rs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
//! Smart-guide (alignment) geometry for node dragging.
|
||||
//!
|
||||
//! While a node is dragged, an edge or centre of the moving node that
|
||||
//! lines up with an edge/centre of another node should surface a
|
||||
//! guide line and snap the drag onto it — the behaviour TS exposes
|
||||
//! via `apps/web` smart guides.
|
||||
//!
|
||||
//! This module is pure geometry: it takes the moving node's AABB plus
|
||||
//! the other nodes' AABBs and returns the guide segments to paint and
|
||||
//! the snap offset to apply. The host wires it into the drag handler;
|
||||
//! the canvas painter draws the returned guides. Keeping it free of
|
||||
//! `EditorState` makes the alignment logic fully unit-testable.
|
||||
|
||||
/// A doc-space axis-aligned bounding box: `(x, y, w, h)`.
|
||||
pub type Aabb = (f64, f64, f64, f64);
|
||||
|
||||
/// An axis-aligned guide line to paint during a drag.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AlignmentGuide {
|
||||
/// `true` → a vertical line at a constant x; `false` → horizontal
|
||||
/// at a constant y.
|
||||
pub vertical: bool,
|
||||
/// The line's fixed coordinate (doc-space x for vertical guides,
|
||||
/// y for horizontal).
|
||||
pub pos: f64,
|
||||
/// Segment extent along the other axis — start..end so the line
|
||||
/// is drawn only across the aligned nodes, not the whole canvas.
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
}
|
||||
|
||||
/// Outcome of an alignment pass: the guides to draw plus the snap
|
||||
/// offset to add to the moving node so it locks onto the alignment.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct AlignmentResult {
|
||||
pub guides: Vec<AlignmentGuide>,
|
||||
pub snap_dx: f64,
|
||||
pub snap_dy: f64,
|
||||
}
|
||||
|
||||
/// The three alignment lines of a rect on one axis: near edge,
|
||||
/// centre, far edge. For x that is `(left, center_x, right)`.
|
||||
fn axis_lines(origin: f64, extent: f64) -> [f64; 3] {
|
||||
[origin, origin + extent / 2.0, origin + extent]
|
||||
}
|
||||
|
||||
/// Best single-axis match: the moving-line / other-line pair with the
|
||||
/// smallest gap within `threshold`. Returns `(snap, other_line)` —
|
||||
/// `snap` is added to the moving rect, `other_line` is the guide's
|
||||
/// fixed coordinate.
|
||||
fn best_axis_match(
|
||||
moving_lines: [f64; 3],
|
||||
other_lines: &[[f64; 3]],
|
||||
threshold: f64,
|
||||
) -> Option<(f64, f64)> {
|
||||
let mut best: Option<(f64, f64)> = None; // snap, guide line
|
||||
let mut best_gap = f64::INFINITY;
|
||||
for others in other_lines {
|
||||
for &m in &moving_lines {
|
||||
for &o in others {
|
||||
let gap = (o - m).abs();
|
||||
// `gap < best_gap` (strict) keeps the FIRST candidate
|
||||
// on a tie — moving's near edge is checked before its
|
||||
// centre / far edge, so an exact-tie prefers the more
|
||||
// intuitive edge-to-edge alignment.
|
||||
if gap <= threshold && gap < best_gap {
|
||||
best_gap = gap;
|
||||
best = Some((o - m, o));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Compute alignment guides between `moving` and each rect in
|
||||
/// `others`. An edge or centre of `moving` within `threshold`
|
||||
/// doc-px of an edge/centre of any other rect yields a guide line +
|
||||
/// a snap offset that locks `moving` exactly onto it. The closest
|
||||
/// candidate per axis wins; the two axes are resolved independently.
|
||||
pub fn compute_alignment_guides(moving: Aabb, others: &[Aabb], threshold: f64) -> AlignmentResult {
|
||||
let (mx, my, mw, mh) = moving;
|
||||
let mut result = AlignmentResult::default();
|
||||
if others.is_empty() || threshold <= 0.0 {
|
||||
return result;
|
||||
}
|
||||
let moving_x = axis_lines(mx, mw);
|
||||
let moving_y = axis_lines(my, mh);
|
||||
let others_x: Vec<[f64; 3]> = others
|
||||
.iter()
|
||||
.map(|&(x, _, w, _)| axis_lines(x, w))
|
||||
.collect();
|
||||
let others_y: Vec<[f64; 3]> = others
|
||||
.iter()
|
||||
.map(|&(_, y, _, h)| axis_lines(y, h))
|
||||
.collect();
|
||||
|
||||
// Vertical guide (x alignment).
|
||||
if let Some((snap_dx, line_x)) = best_axis_match(moving_x, &others_x, threshold) {
|
||||
result.snap_dx = snap_dx;
|
||||
// Span the guide across the moving node + every other node
|
||||
// whose y-range it crosses, so the line reads as "these are
|
||||
// aligned" rather than a full-canvas ruler.
|
||||
let mut top = my;
|
||||
let mut bottom = my + mh;
|
||||
for &(_, y, _, h) in others {
|
||||
top = top.min(y);
|
||||
bottom = bottom.max(y + h);
|
||||
}
|
||||
result.guides.push(AlignmentGuide {
|
||||
vertical: true,
|
||||
pos: line_x,
|
||||
start: top,
|
||||
end: bottom,
|
||||
});
|
||||
}
|
||||
// Horizontal guide (y alignment).
|
||||
if let Some((snap_dy, line_y)) = best_axis_match(moving_y, &others_y, threshold) {
|
||||
result.snap_dy = snap_dy;
|
||||
let mut left = mx;
|
||||
let mut right = mx + mw;
|
||||
for &(x, _, w, _) in others {
|
||||
left = left.min(x);
|
||||
right = right.max(x + w);
|
||||
}
|
||||
result.guides.push(AlignmentGuide {
|
||||
vertical: false,
|
||||
pos: line_y,
|
||||
start: left,
|
||||
end: right,
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_others_yields_no_guides() {
|
||||
let r = compute_alignment_guides((0.0, 0.0, 10.0, 10.0), &[], 5.0);
|
||||
assert!(r.guides.is_empty());
|
||||
assert_eq!((r.snap_dx, r.snap_dy), (0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_edges_within_threshold_snap_and_guide() {
|
||||
// Moving left edge at x=3, other left edge at x=0 → gap 3 ≤ 5.
|
||||
let r = compute_alignment_guides((3.0, 100.0, 20.0, 20.0), &[(0.0, 0.0, 40.0, 40.0)], 5.0);
|
||||
// Snap moves the node left by 3 so the left edges coincide.
|
||||
assert!((r.snap_dx - (-3.0)).abs() < 1e-9);
|
||||
let v = r
|
||||
.guides
|
||||
.iter()
|
||||
.find(|g| g.vertical)
|
||||
.expect("vertical guide");
|
||||
assert!((v.pos - 0.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centers_align_on_both_axes() {
|
||||
// Moving 20×20 centred at (10,10); other 40×40 centred at
|
||||
// (12,12) → centre gap (2,2) ≤ 4.
|
||||
let r = compute_alignment_guides((0.0, 0.0, 20.0, 20.0), &[(-8.0, -8.0, 40.0, 40.0)], 4.0);
|
||||
// Moving centre is (10,10); other centre is (12,12).
|
||||
assert!((r.snap_dx - 2.0).abs() < 1e-9);
|
||||
assert!((r.snap_dy - 2.0).abs() < 1e-9);
|
||||
assert_eq!(r.guides.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_beyond_threshold_does_not_snap() {
|
||||
let r =
|
||||
compute_alignment_guides((100.0, 100.0, 10.0, 10.0), &[(0.0, 0.0, 10.0, 10.0)], 5.0);
|
||||
assert!(r.guides.is_empty());
|
||||
assert_eq!((r.snap_dx, r.snap_dy), (0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_candidate_is_ignored_near_one_snaps() {
|
||||
// One other is far out of threshold range and contributes
|
||||
// nothing; the near one's left edge (x=2) is a gap-2 match
|
||||
// for the moving node's left edge (x=0).
|
||||
let r = compute_alignment_guides(
|
||||
(0.0, 0.0, 10.0, 10.0),
|
||||
&[(500.0, 500.0, 10.0, 10.0), (2.0, 0.0, 10.0, 10.0)],
|
||||
5.0,
|
||||
);
|
||||
// Closest x alignment is moving-left(0) ↔ other-left(2): +2.
|
||||
assert!((r.snap_dx - 2.0).abs() < 1e-9);
|
||||
assert!(r.guides.iter().any(|g| g.vertical));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_guide_spans_aligned_nodes() {
|
||||
// Moving at y=100..120, other at y=0..40 → the vertical guide
|
||||
// segment should cover y 0..120.
|
||||
let r = compute_alignment_guides((0.0, 100.0, 20.0, 20.0), &[(0.0, 0.0, 20.0, 40.0)], 5.0);
|
||||
let v = r
|
||||
.guides
|
||||
.iter()
|
||||
.find(|g| g.vertical)
|
||||
.expect("vertical guide");
|
||||
assert!((v.start - 0.0).abs() < 1e-9);
|
||||
assert!((v.end - 120.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
pub mod agent_settings;
|
||||
pub mod align;
|
||||
pub mod align_guides;
|
||||
pub mod chat;
|
||||
pub mod clipboard;
|
||||
pub mod color_picker;
|
||||
|
|
|
|||
Loading…
Reference in a new issue