Version 1

This commit is contained in:
2026-09-09 21:19:44 +02:00
commit f35e5c82e5
16 changed files with 11148 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
//! Geometry shared by the renderer and the interaction code: node rectangles,
//! edge anchor points and the cubic Bézier curves edges are drawn with.
use eframe::egui::{Pos2, Rect, Vec2, pos2, vec2};
use crate::model::{Node, Side};
/// The node's box in canvas coordinates.
pub fn node_rect(node: &Node) -> Rect {
Rect::from_min_size(
pos2(node.x as f32, node.y as f32),
vec2(node.width as f32, node.height as f32),
)
}
/// The point on `rect` where an edge attached to `side` starts or ends.
pub fn anchor(rect: Rect, side: Side) -> Pos2 {
match side {
Side::Top => pos2(rect.center().x, rect.top()),
Side::Bottom => pos2(rect.center().x, rect.bottom()),
Side::Left => pos2(rect.left(), rect.center().y),
Side::Right => pos2(rect.right(), rect.center().y),
}
}
/// The outward pointing unit normal of a side.
pub fn normal(side: Side) -> Vec2 {
match side {
Side::Top => vec2(0.0, -1.0),
Side::Bottom => vec2(0.0, 1.0),
Side::Left => vec2(-1.0, 0.0),
Side::Right => vec2(1.0, 0.0),
}
}
/// Picks the sides for an edge whose `fromSide`/`toSide` the file leaves out.
///
/// The dominant axis between the two node centres wins, which is what the
/// reference implementations do.
pub fn auto_sides(from: Rect, to: Rect) -> (Side, Side) {
let delta = to.center() - from.center();
// Compare the gaps rather than the raw centre distance so that a wide node
// sitting just above a narrow one still connects top-to-bottom.
let gap_x = (to.left() - from.right()).max(from.left() - to.right());
let gap_y = (to.top() - from.bottom()).max(from.top() - to.bottom());
let horizontal = if gap_x >= 0.0 && gap_y >= 0.0 {
gap_x >= gap_y
} else if gap_x >= 0.0 {
true
} else if gap_y >= 0.0 {
false
} else {
delta.x.abs() >= delta.y.abs()
};
if horizontal {
if delta.x >= 0.0 {
(Side::Right, Side::Left)
} else {
(Side::Left, Side::Right)
}
} else if delta.y >= 0.0 {
(Side::Bottom, Side::Top)
} else {
(Side::Top, Side::Bottom)
}
}
/// The four control points of the cubic Bézier used to draw an edge.
pub fn edge_curve(from: Rect, from_side: Side, to: Rect, to_side: Side) -> [Pos2; 4] {
let start = anchor(from, from_side);
let end = anchor(to, to_side);
curve_between(start, from_side, end, to_side)
}
/// Same as [`edge_curve`] but for free endpoints (used while dragging a new edge).
pub fn curve_between(start: Pos2, from_side: Side, end: Pos2, to_side: Side) -> [Pos2; 4] {
let distance = (end - start).length();
let strength = (distance * 0.45).clamp(30.0, 250.0);
[
start,
start + normal(from_side) * strength,
end + normal(to_side) * strength,
end,
]
}
/// Point on a cubic Bézier at `t` in `0..=1`.
pub fn bezier_point(p: [Pos2; 4], t: f32) -> Pos2 {
let u = 1.0 - t;
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
pos2(
a * p[0].x + b * p[1].x + c * p[2].x + d * p[3].x,
a * p[0].y + b * p[1].y + c * p[2].y + d * p[3].y,
)
}
/// Derivative of a cubic Bézier at `t`; the direction the curve travels in.
pub fn bezier_tangent(p: [Pos2; 4], t: f32) -> Vec2 {
let u = 1.0 - t;
let d = (p[1] - p[0]) * (3.0 * u * u) + (p[2] - p[1]) * (6.0 * u * t) + (p[3] - p[2]) * (3.0 * t * t);
if d.length() > f32::EPSILON {
d.normalized()
} else {
(p[3] - p[0]).normalized()
}
}
/// How many samples are used when approximating a curve by a polyline.
const SAMPLES: usize = 24;
/// Shortest distance from `point` to the curve, used for hit testing edges.
pub fn distance_to_curve(p: [Pos2; 4], point: Pos2) -> f32 {
let mut best = f32::INFINITY;
let mut previous = p[0];
for i in 1..=SAMPLES {
let current = bezier_point(p, i as f32 / SAMPLES as f32);
best = best.min(distance_to_segment(point, previous, current));
previous = current;
}
best
}
/// Axis aligned bounds of the curve, approximated from samples.
pub fn curve_bounds(p: [Pos2; 4]) -> Rect {
let mut rect = Rect::from_points(&[p[0], p[3]]);
for i in 1..SAMPLES {
rect = rect.union(Rect::from_points(&[bezier_point(p, i as f32 / SAMPLES as f32)]));
}
rect
}
fn distance_to_segment(point: Pos2, a: Pos2, b: Pos2) -> f32 {
let ab = b - a;
let len_sq = ab.length_sq();
if len_sq <= f32::EPSILON {
return (point - a).length();
}
let t = ((point - a).dot(ab) / len_sq).clamp(0.0, 1.0);
(point - (a + ab * t)).length()
}
/// The bounding box of everything in `rects`, or `None` when there is nothing.
pub fn bounds_of(rects: impl IntoIterator<Item = Rect>) -> Option<Rect> {
rects.into_iter().reduce(|acc, r| acc.union(r))
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
Rect::from_min_size(pos2(x, y), vec2(w, h))
}
#[test]
fn anchors_sit_on_the_middle_of_each_side() {
let r = rect(0.0, 0.0, 100.0, 50.0);
assert_eq!(anchor(r, Side::Top), pos2(50.0, 0.0));
assert_eq!(anchor(r, Side::Bottom), pos2(50.0, 50.0));
assert_eq!(anchor(r, Side::Left), pos2(0.0, 25.0));
assert_eq!(anchor(r, Side::Right), pos2(100.0, 25.0));
}
#[test]
fn auto_sides_follow_the_dominant_axis() {
let a = rect(0.0, 0.0, 100.0, 100.0);
assert_eq!(
auto_sides(a, rect(400.0, 0.0, 100.0, 100.0)),
(Side::Right, Side::Left)
);
assert_eq!(
auto_sides(a, rect(-400.0, 0.0, 100.0, 100.0)),
(Side::Left, Side::Right)
);
assert_eq!(
auto_sides(a, rect(0.0, 400.0, 100.0, 100.0)),
(Side::Bottom, Side::Top)
);
assert_eq!(
auto_sides(a, rect(0.0, -400.0, 100.0, 100.0)),
(Side::Top, Side::Bottom)
);
}
#[test]
fn a_curve_starts_and_ends_on_its_anchors() {
let from = rect(0.0, 0.0, 100.0, 100.0);
let to = rect(300.0, 0.0, 100.0, 100.0);
let curve = edge_curve(from, Side::Right, to, Side::Left);
assert_eq!(bezier_point(curve, 0.0), anchor(from, Side::Right));
assert_eq!(bezier_point(curve, 1.0), anchor(to, Side::Left));
// Leaving a right side means travelling to the right.
assert!(bezier_tangent(curve, 0.0).x > 0.9);
}
#[test]
fn hit_testing_measures_distance_to_the_curve() {
let curve = [
pos2(0.0, 0.0),
pos2(50.0, 0.0),
pos2(50.0, 0.0),
pos2(100.0, 0.0),
];
assert!(distance_to_curve(curve, pos2(50.0, 0.0)) < 0.5);
assert!((distance_to_curve(curve, pos2(50.0, 20.0)) - 20.0).abs() < 0.5);
}
#[test]
fn bounds_cover_every_rect() {
let all = bounds_of([rect(0.0, 0.0, 10.0, 10.0), rect(90.0, 40.0, 10.0, 10.0)]).unwrap();
assert_eq!(all, rect(0.0, 0.0, 100.0, 50.0));
assert!(bounds_of([]).is_none());
}
}