Skip to main content

halley_core/
visual.rs

1use crate::field::{Field, NodeId, NodeKind, NodeState, Rect, Vec2};
2use crate::viewport::Viewport;
3
4/// Render-facing snapshot of a node.
5/// All coordinates/sizes are in Field-space; renderer applies Viewport transform.
6#[derive(Clone, Debug, PartialEq)]
7pub struct NodeVisual {
8    pub id: NodeId,
9
10    // Geometry
11    pub pos: Vec2,
12    pub size: Vec2,
13
14    // Semantics
15    pub kind: NodeKind,
16    pub state: NodeState,
17
18    /// Back-compat field name: this is "pinned in place" (movement constraint).
19    /// (Routing anchor marker is `Node.anchor` in Field.)
20    pub anchored: bool,
21
22    // Label rendering (hover above node; becomes more prominent when zoomed out)
23    pub label: String,
24    pub label_scale: f32,
25
26    // Cluster marker/badge support
27    pub is_cluster_core: bool,
28    pub cluster_member_count: Option<usize>,
29
30    // Z ordering hint (higher draws on top)
31    pub z: i32,
32
33    // Optional fade hint (renderer can ignore)
34    pub alpha: f32,
35}
36
37/// Parameters controlling how visuals are derived (not stored in Field).
38#[derive(Clone, Copy, Debug)]
39pub struct VisualParams {
40    /// 1.0 = normal; smaller means zoomed OUT (you see more).
41    pub zoom: f32,
42
43    /// Optional focused node id (draw on top).
44    pub focused: Option<NodeId>,
45
46    /// Clamp range for label growth.
47    pub min_label_scale: f32,
48    pub max_label_scale: f32,
49}
50
51impl Default for VisualParams {
52    fn default() -> Self {
53        Self {
54            zoom: 1.0,
55            focused: None,
56            min_label_scale: 1.0,
57            max_label_scale: 4.0,
58        }
59    }
60}
61
62fn make_visual(field: &Field, id: NodeId, params: VisualParams) -> NodeVisual {
63    let n = field
64        .node(id)
65        .expect("make_visual called with missing node");
66
67    let zoom = params.zoom.max(0.0001);
68
69    // As you zoom OUT (zoom < 1), labels should grow.
70    // If zoom=1 => scale=1
71    // If zoom=0.5 => scale=2
72    let label_scale = (1.0 / zoom).clamp(params.min_label_scale, params.max_label_scale);
73
74    let is_cluster_core = n.kind == NodeKind::Core;
75
76    // Optional badge count: find the cluster that owns this core, if any.
77    let cluster_member_count = if is_cluster_core {
78        field
79            .cluster_id_for_core_public(id)
80            .and_then(|cid| field.cluster(cid))
81            .map(|c| c.members().len())
82    } else {
83        None
84    };
85
86    // Z ordering:
87    // - focused highest
88    // - cores above normal nodes
89    // - active above node
90    let mut z = 0;
91    if params.focused == Some(id) {
92        z += 10_000;
93    }
94    if is_cluster_core {
95        z += 1_000;
96    }
97    z += match n.state {
98        NodeState::Active => 300,
99        NodeState::Drifting => 150,
100        NodeState::Node => 100,
101        NodeState::Core => 400,
102    };
103
104    // Alpha hint (optional): make “Node” representation a bit lighter.
105    let alpha = match n.state {
106        NodeState::Node => 0.85,
107        _ => 1.0,
108    };
109
110    NodeVisual {
111        id,
112        pos: n.pos,
113        size: n.footprint,
114        kind: n.kind.clone(),
115        state: n.state.clone(),
116
117        // IMPORTANT: old name, new meaning
118        anchored: n.pinned,
119
120        label: n.label.clone(),
121        label_scale,
122
123        is_cluster_core,
124        cluster_member_count,
125
126        z,
127        alpha,
128    }
129}
130
131/// Build a render-friendly list of visuals from the current Field + Viewport.
132/// - Skips nodes that are not experience-visible.
133/// - Emits label scaling that grows as you zoom out.
134/// - Marks Core nodes as cluster cores (badge is optional; uses cluster lookup if available).
135pub fn build_visuals(field: &Field, _vp: &Viewport, params: VisualParams) -> Vec<NodeVisual> {
136    let mut out = Vec::new();
137
138    for (&id, _) in field.nodes().iter() {
139        if !field.participates_in_field_view(id) {
140            continue;
141        }
142        if !field.is_visible(id) {
143            continue;
144        }
145        out.push(make_visual(field, id, params));
146    }
147
148    // Stable draw order: sort by z then id
149    out.sort_by(|a, b| {
150        a.z.cmp(&b.z)
151            .then_with(|| a.id.as_u64().cmp(&b.id.as_u64()))
152    });
153    out
154}
155
156/// Like `build_visuals`, but only returns visuals whose bounds intersect `view` (in Field-space).
157pub fn build_visuals_in_view(
158    field: &Field,
159    _vp: &Viewport,
160    view: Rect,
161    params: VisualParams,
162) -> Vec<NodeVisual> {
163    let mut out = Vec::new();
164
165    for (&id, _) in field.nodes().iter() {
166        if !field.participates_in_field_view(id) {
167            continue;
168        }
169        if !field.is_visible(id) {
170            continue;
171        }
172        if field.bounds(id).is_some_and(|b| b.intersects(view)) {
173            out.push(make_visual(field, id, params));
174        }
175    }
176
177    out.sort_by(|a, b| {
178        a.z.cmp(&b.z)
179            .then_with(|| a.id.as_u64().cmp(&b.id.as_u64()))
180    });
181    out
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::field::Field;
188
189    #[test]
190    fn visuals_skip_hidden_nodes() {
191        let mut f = Field::new();
192        let a = f.spawn_surface("A", Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
193        let b = f.spawn_surface("B", Vec2 { x: 20.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
194
195        assert!(f.set_hidden(b, true));
196
197        let vp = Viewport::new(Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 100.0, y: 100.0 });
198        let visuals = build_visuals(&f, &vp, VisualParams::default());
199
200        assert_eq!(visuals.len(), 1);
201        assert_eq!(visuals[0].id, a);
202        assert_eq!(visuals[0].label, "A");
203    }
204
205    #[test]
206    fn label_scale_grows_when_zoomed_out() {
207        let mut f = Field::new();
208        let _a = f.spawn_surface("A", Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
209
210        let vp = Viewport::new(Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 100.0, y: 100.0 });
211
212        let v1 = build_visuals(
213            &f,
214            &vp,
215            VisualParams {
216                zoom: 1.0,
217                ..Default::default()
218            },
219        );
220        let v2 = build_visuals(
221            &f,
222            &vp,
223            VisualParams {
224                zoom: 0.5,
225                ..Default::default()
226            },
227        );
228
229        assert!(v2[0].label_scale > v1[0].label_scale);
230        assert_eq!(v1[0].label_scale, 1.0);
231        assert_eq!(v2[0].label_scale, 2.0);
232    }
233
234    #[test]
235    fn focused_node_draws_on_top() {
236        let mut f = Field::new();
237        let a = f.spawn_surface("A", Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
238        let b = f.spawn_surface("B", Vec2 { x: 20.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
239
240        let vp = Viewport::new(Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 100.0, y: 100.0 });
241
242        let visuals = build_visuals(
243            &f,
244            &vp,
245            VisualParams {
246                focused: Some(b),
247                ..Default::default()
248            },
249        );
250
251        let za = visuals.iter().find(|v| v.id == a).unwrap().z;
252        let zb = visuals.iter().find(|v| v.id == b).unwrap().z;
253        assert!(zb > za);
254    }
255
256    #[test]
257    fn in_view_filters_nodes() {
258        let mut f = Field::new();
259        let a = f.spawn_surface("A", Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
260        let _b = f.spawn_surface("B", Vec2 { x: 100.0, y: 100.0 }, Vec2 { x: 10.0, y: 10.0 });
261
262        let vp = Viewport::new(Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 100.0, y: 100.0 });
263
264        let view = Rect {
265            min: Vec2 { x: -20.0, y: -20.0 },
266            max: Vec2 { x: 20.0, y: 20.0 },
267        };
268
269        let visuals = build_visuals_in_view(&f, &vp, view, VisualParams::default());
270        assert_eq!(visuals.len(), 1);
271        assert_eq!(visuals[0].id, a);
272    }
273}