|
| 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, ¶ms) { |
| 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 | +} |
0 commit comments