Skip to content

Commit 4db3eb6

Browse files
Add understory_precise_hit crate and 2D precise hit adapters (endoli#32)
- Introduce `understory_precise_hit`: - New `no_std` crate for narrow-phase 2D hit testing built on `kurbo`. - Defines `HitParams` (fill/stroke tolerances + `prefer_fill`), `HitKind`, `HitScore` (distance + kind), and `PreciseHitTest` for local-space queries. - Provides default `PreciseHitTest` impls for `Rect`, `Circle`, `RoundedRect`, and fill-only `BezPath` (via `kurbo::Shape::contains`). - Adds a `stroke` module with a minimal `StrokedLine` helper (distance-to-segment hit; no joins/caps/variable-width). - Crate-level docs and README explain that `HitScore` is deliberately minimal and that rich metadata belongs in engine-level types (e.g. responder meta). - Wire precise hits into the responder: - Add `hit2d_adapter` feature to `understory_responder`. - New module `understory_responder::adapters::hit2d`: - `KeyHit<K> { key, distance }` and `precise_hits_for_point` for running `PreciseHitTest` on (key, shape) candidates. - `resolved_hits_from_precise` converts `KeyHit` into `ResolvedHit<K, ()>` using `DepthKey::Distance`, leaving rich metadata to callers’ meta. - Includes unit tests for adapter behavior and an integration-style test that exercises the “coarse AABB hit but precise miss” flow with `Tree + Circle + hit2d_adapter`. - Example showing end-to-end usage: - New `responder_precise_hit` example: - Uses `understory_box_tree` for broad-phase AABB culling. - Maps `NodeId` → simple `Shape` enum (`Rect` / `Circle`) implementing `PreciseHitTest`. - Demonstrates: - interior hits on rect and circle; - a point inside a node’s AABB but outside the circle (coarse hit only), which the precise test rejects. - Uses `hit2d_adapter` to produce `ResolvedHit<NodeId, ()>` and routes them through `Router::handle_with_hits`. Co-authored-by: Jared Moulton <jaredmoulton3@gmail.com>
1 parent f7e7ff0 commit 4db3eb6

File tree

16 files changed

+1182
-3
lines changed

16 files changed

+1182
-3
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ env:
1111
RUST_MIN_VER: "1.88"
1212
# List of packages that will be checked with the minimum supported Rust version.
1313
# This should be limited to packages that are intended for publishing.
14-
RUST_MIN_VER_PKGS: "-p understory_index -p understory_box_tree -p understory_responder -p understory_focus"
14+
RUST_MIN_VER_PKGS: "-p understory_index -p understory_box_tree -p understory_responder -p understory_focus -p understory_precise_hit"
1515
# List of features that depend on the standard library and will be excluded from no_std checks.
1616
FEATURES_DEPENDING_ON_STD: "std,default"
1717

@@ -104,6 +104,9 @@ jobs:
104104
- name: check understory_box_tree README
105105
run: cargo rdme --workspace-project=understory_box_tree --heading-base-level=0 --check
106106

107+
- name: check understory_precise_hit README
108+
run: cargo rdme --workspace-project=understory_precise_hit --heading-base-level=0 --check
109+
107110
- name: check understory_responder README
108111
run: cargo rdme --workspace-project=understory_responder --heading-base-level=0 --check
109112

Cargo.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ members = [
44
"understory_index",
55
"understory_box_tree",
66
"understory_focus",
7+
"understory_precise_hit",
78
"understory_responder",
89
"benches",
910
"examples",

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ The focus is on clean separation of concerns, pluggable performance trade‑offs
1919
- Not a layout engine.
2020
- Upstream code (your layout system) decides sizes and positions and then updates this tree.
2121

22+
- `understory_precise_hit`
23+
- Geometry‑level, narrow‑phase hit testing for shapes in local 2D coordinates, built on `kurbo`.
24+
- Provides a small `PreciseHitTest` trait with `HitParams`/`HitScore` helpers and default impls for `Rect`, `Circle`, `RoundedRect`, and fill‑only `BezPath`.
25+
- Designed to be paired with a broad‑phase index (e.g. `understory_index` + `understory_box_tree`) and event routing (`understory_responder`), with rich metadata carried in responder `meta` types.
26+
2227
- `understory_responder`
2328
- A deterministic event router that builds the responder chain sequence: capture → target → bubble.
2429
- Consumes pre‑resolved hits (from a picker or the box tree) and emits an ordered dispatch sequence.

examples/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ publish = false
77
[dependencies]
88
understory_responder = { path = "../understory_responder", features = [
99
"box_tree_adapter",
10+
"hit2d_adapter",
1011
"std",
1112
] }
1213
kurbo = { workspace = true, default-features = true }
1314
understory_box_tree = { path = "../understory_box_tree" }
1415
understory_focus = { path = "../understory_focus" }
1516
understory_index = { path = "../understory_index" }
17+
understory_precise_hit = { path = "../understory_precise_hit" }

examples/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ These examples form a short, progressive walkthrough from routing basics to inte
1414
- Resolve hits from `understory_box_tree`, route them, and compute hover transitions. Includes a tiny ASCII tree and prints box rects and query coordinates.
1515
- Run: `cargo run -p understory_examples --example responder_box_tree`
1616

17+
- responder_precise_hit
18+
- Combine `understory_box_tree` (broad phase) with `understory_precise_hit` (precise geometry hits) and route the result through the responder.
19+
- Run: `cargo run -p understory_examples --example responder_precise_hit`
20+
1721
- responder_focus
1822
- Dispatch to focused target via `dispatch_for` and compute focus transitions with `FocusState`.
1923
- Run: `cargo run -p understory_examples --example responder_focus`
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright 2025 the Understory Authors
2+
// SPDX-License-Identifier: Apache-2.0 OR MIT
3+
4+
//! Broad + narrow phase hit testing: box tree + `understory_precise_hit`.
5+
//!
6+
//! This example shows how to combine:
7+
//! - `understory_box_tree` for broad-phase AABB culling and z-order,
8+
//! - `understory_precise_hit` for precise local hit testing on geometry,
9+
//! - `understory_responder` for routing via `ResolvedHit`.
10+
//!
11+
//! Run:
12+
//! - `cargo run -p understory_examples --example responder_precise_hit`
13+
14+
use std::collections::HashMap;
15+
16+
use kurbo::{Affine, Circle, Point, Rect, Vec2};
17+
use understory_box_tree::{LocalNode, NodeFlags, NodeId, QueryFilter, Tree};
18+
use understory_precise_hit::{HitParams, HitScore, PreciseHitTest};
19+
use understory_responder::adapters::hit2d::{KeyHit, resolved_hits_from_precise};
20+
use understory_responder::dispatcher;
21+
use understory_responder::router::Router;
22+
use understory_responder::types::{Outcome, ResolvedHit, WidgetLookup};
23+
24+
/// Simple scene data: each node has either a rect or a circle for precise hits.
25+
#[derive(Clone, Copy, Debug)]
26+
enum Shape {
27+
Rect(Rect),
28+
Circle(Circle),
29+
}
30+
31+
/// Implement precise hit testing by delegating to the underlying geometry.
32+
impl PreciseHitTest for Shape {
33+
fn hit_test_local(&self, pt: Point, params: &HitParams) -> Option<HitScore> {
34+
match self {
35+
Shape::Rect(r) => r.hit_test_local(pt, params),
36+
Shape::Circle(c) => c.hit_test_local(pt, params),
37+
}
38+
}
39+
}
40+
41+
fn main() {
42+
// Build a small box tree with two children having different shapes.
43+
let mut tree = Tree::new();
44+
45+
let root = tree.insert(
46+
None,
47+
LocalNode {
48+
local_bounds: Rect::new(0.0, 0.0, 200.0, 200.0),
49+
flags: NodeFlags::VISIBLE | NodeFlags::PICKABLE,
50+
..Default::default()
51+
},
52+
);
53+
54+
// Node A: axis-aligned rect.
55+
let rect = Rect::new(20.0, 40.0, 120.0, 140.0);
56+
let node_a = tree.insert(
57+
Some(root),
58+
LocalNode {
59+
local_bounds: rect,
60+
z_index: 0,
61+
..Default::default()
62+
},
63+
);
64+
65+
// Node B: circle translated to the right, same AABB size for illustration.
66+
let circle = Circle::new((0.0, 0.0), 40.0);
67+
let node_b = tree.insert(
68+
Some(root),
69+
LocalNode {
70+
local_bounds: Rect::new(140.0, 40.0, 220.0, 140.0),
71+
local_transform: Affine::translate(Vec2::new(180.0, 90.0)),
72+
z_index: 5,
73+
..Default::default()
74+
},
75+
);
76+
77+
// Map NodeId → precise shape in local coordinates.
78+
let mut shapes: HashMap<NodeId, Shape> = HashMap::new();
79+
shapes.insert(node_a, Shape::Rect(rect));
80+
shapes.insert(node_b, Shape::Circle(circle));
81+
82+
let _damage = tree.commit();
83+
84+
// Minimal lookup: echo NodeId as WidgetId.
85+
struct Lookup;
86+
impl WidgetLookup<NodeId> for Lookup {
87+
type WidgetId = NodeId;
88+
fn widget_of(&self, node: &NodeId) -> Option<Self::WidgetId> {
89+
Some(*node)
90+
}
91+
}
92+
93+
let router: Router<NodeId, Lookup> = Router::new(Lookup);
94+
95+
// Query a few points and show how coarse box hits are refined by geometry.
96+
let params = HitParams {
97+
fill_tolerance: 2.0,
98+
..HitParams::default()
99+
};
100+
for (label, pt) in [
101+
("rect A interior", Point::new(30.0, 80.0)),
102+
("circle B interior", Point::new(200.0, 90.0)),
103+
(
104+
"coarse hit only (inside B AABB, outside circle)",
105+
Point::new(150.0, 50.0),
106+
),
107+
] {
108+
println!("\n== Query: {} @ ({:.1}, {:.1}) ==", label, pt.x, pt.y);
109+
110+
// Broad phase: candidate NodeIds whose AABB contains the world-space point.
111+
let filter = QueryFilter::new().visible().pickable();
112+
let broad_hits: Vec<NodeId> = tree
113+
.intersect_rect(Rect::from_points(pt, pt), filter)
114+
.collect();
115+
116+
println!("Broad-phase candidates: {:?}", broad_hits);
117+
118+
// Narrow phase: transform point into each node's local space and run precise hit.
119+
let mut precise_candidates = Vec::new();
120+
for id in &broad_hits {
121+
if let Some(shape) = shapes.get(id)
122+
&& let Some(world_to_local) = tree.world_transform(*id).map(|tf| tf.inverse())
123+
{
124+
let local_pt = world_to_local * pt;
125+
precise_candidates.push((*id, *shape, local_pt));
126+
}
127+
}
128+
129+
let mut key_hits: Vec<KeyHit<NodeId>> = Vec::new();
130+
for (id, shape, local_pt) in precise_candidates {
131+
if let Some(score) = shape.hit_test_local(local_pt, &params) {
132+
key_hits.push(understory_responder::adapters::hit2d::KeyHit {
133+
key: id,
134+
distance: score.distance,
135+
});
136+
}
137+
}
138+
139+
println!("Narrow-phase hits: {:?}", key_hits);
140+
141+
// Wrap into ResolvedHit and feed to the router.
142+
let resolved: Vec<ResolvedHit<NodeId, ()>> = resolved_hits_from_precise(&key_hits);
143+
let dispatch = router.handle_with_hits(&resolved);
144+
145+
let _ = dispatcher::run(&dispatch, &mut (), |d, _| {
146+
println!(" {:?} node={:?} widget={:?}", d.phase, d.node, d.widget);
147+
Outcome::Continue
148+
});
149+
}
150+
}

understory_precise_hit/Cargo.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[package]
2+
name = "understory_precise_hit"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
repository.workspace = true
7+
description = "Geometry-level precise hit testing helpers for Understory (kurbo-based)."
8+
keywords = ["hit-test", "geometry", "ui", "graphics"]
9+
categories = ["graphics", "no-std"]
10+
11+
[features]
12+
default = ["std"]
13+
std = ["kurbo/std"]
14+
libm = ["kurbo/libm"]
15+
16+
[dependencies]
17+
kurbo.workspace = true
18+
19+
[lints]
20+
workspace = true

0 commit comments

Comments
 (0)