Skip to main content

brep_render/engine_state/
construction_datums.rs

1use super::*;
2
3/// The calm base color of an unselected construction datum/plane (a soft blue).
4const DATUM_PLANE_COLOR: &str = "#6b8fd0";
5/// The selection accent for a selected datum/plane (matches `faceSelectedColor`).
6const DATUM_PLANE_SELECTED_COLOR: &str = "#ffc400";
7/// The HOVER accent for a datum/plane under the pointer (matches the faces'
8/// default `hoverColor`) — distinct from the selection accent, so a plane reads
9/// hovered-vs-selected exactly like a face does.
10const DATUM_PLANE_HOVERED_COLOR: &str = "#fbff00";
11
12impl EngineState {
13    /// Map every feature id at the CURRENT rollback (`0..=rollback`) to its TYPE
14    /// token — the lookup that classifies a frame name's producing feature so only
15    /// DATUM (`"D"`) / PLANE (`"P"`) frames display as datum planes (a SKETCH `"S"`
16    /// frame renders as curves, not a datum).
17    fn feature_type_map(&self) -> HashMap<String, String> {
18        let rollback = self.history.rollback();
19        let mut map = HashMap::new();
20        for index in 0..=rollback {
21            if let (Some(id), Some(ty)) =
22                (self.history.feature_id(index), self.history.feature_type(index))
23            {
24                map.insert(id, ty);
25            }
26        }
27        map
28    }
29
30    /// Classify a plane-frame NAME against the feature type map: strip a trailing
31    /// DATUM sub-plane suffix (`:XY`/`:XZ`/`:YZ`) to the producing feature id, look
32    /// up its type, and keep only `"D"`/`"P"` producers. Returns `(producing
33    /// feature id, feature type)` for a datum/plane frame, else `None` (a SKETCH
34    /// `"S"` frame, or a feature past the rollback / not in the history).
35    fn datum_feature_of(
36        name: &str,
37        type_map: &HashMap<String, String>,
38    ) -> Option<(String, String)> {
39        let base = [":XY", ":XZ", ":YZ"]
40            .iter()
41            .find_map(|suffix| name.strip_suffix(suffix))
42            .unwrap_or(name);
43        let ty = type_map.get(base)?;
44        if ty == "D" || ty == "P" {
45            Some((base.to_string(), ty.clone()))
46        } else {
47            None
48        }
49    }
50
51    /// The construction datum/plane frame NAMES the last run resolved, filtered to
52    /// the D/P producing features at the current rollback, in run order. Every
53    /// DATUM contributes three (`{id}:XY|XZ|YZ`), every PLANE one (`{id}`); a
54    /// SKETCH's own plane frame is excluded (it renders as curves).
55    fn construction_datum_names(&self) -> Vec<String> {
56        let type_map = self.feature_type_map();
57        self.construction_frames
58            .iter()
59            .filter(|(name, _)| Self::datum_feature_of(name, &type_map).is_some())
60            .map(|(name, _)| name.clone())
61            .collect()
62    }
63
64    /// The producing `(feature id, feature type)` of a datum/plane frame NAME, but
65    /// ONLY when the name is an actually-resolved D/P frame at the current rollback
66    /// — the provenance the Properties Info tab reports for a selected datum.
67    pub fn datum_feature_for_name(&self, name: &str) -> Option<(String, String)> {
68        if !self.construction_frames.iter().any(|(n, _)| n == name) {
69            return None;
70        }
71        Self::datum_feature_of(name, &self.feature_type_map())
72    }
73
74    /// (Re)build the persistent construction datum/plane overlays. Feeds every D/P
75    /// frame the last run resolved (minus [`hidden_datums`]) to the datum-plane
76    /// widget channel as a screen-constant NAMED plane in the calm datum color — or
77    /// the selection accent when it is in `emphasis.selected_datums` / hovered when
78    /// it is in `emphasis.hovered_datums` (hover wins, the `EmphasisState` order).
79    /// The feed
80    /// REPLACES the widget's datum set wholesale, so a departed/hidden/rolled-back
81    /// plane is auto-dropped; `shown_datum_names` mirrors what was fed. Marks dirty.
82    ///
83    /// The fed set is also exactly what is PICKABLE: `plane_candidates_at` hit-tests
84    /// these cards, so a hidden or rolled-back plane can no more be picked than it
85    /// can be seen.
86    pub fn refresh_construction_datums(&mut self) {
87        let type_map = self.feature_type_map();
88        let mut planes: Vec<serde_json::Value> = Vec::new();
89        let mut fed: Vec<String> = Vec::new();
90        for (name, frame) in &self.construction_frames {
91            if Self::datum_feature_of(name, &type_map).is_none() {
92                continue;
93            }
94            if self.hidden_datums.contains(name) {
95                continue;
96            }
97            let selected = self.emphasis.selected_datums.contains(name);
98            let hovered = self.emphasis.hovered_datums.contains(name);
99            // Hover WINS over selected — the `EmphasisState` order faces follow.
100            let color = if hovered {
101                DATUM_PLANE_HOVERED_COLOR
102            } else if selected {
103                DATUM_PLANE_SELECTED_COLOR
104            } else {
105                DATUM_PLANE_COLOR
106            };
107            planes.push(serde_json::json!({
108                "name": name,
109                "origin": [frame.origin.x, frame.origin.y, frame.origin.z],
110                "x": [frame.x_axis.x, frame.x_axis.y, frame.x_axis.z],
111                "y": [frame.y_axis.x, frame.y_axis.y, frame.y_axis.z],
112                "color": color,
113                "selected": selected,
114                "hovered": hovered,
115            }));
116            fed.push(name.clone());
117        }
118        // `set_datums` replaces its whole datum set, so a full re-feed each call
119        // drops any plane no longer present (rolled back / deleted / hidden).
120        let payload = serde_json::json!({ "planes": planes }).to_string();
121        let _ = self.set_datums_json(&payload);
122        self.shown_datum_names = fed;
123        self.dirty = true;
124    }
125
126    /// Whether the construction datum/plane `name`'s plane is shown (absent from
127    /// [`hidden_datums`] = visible).
128    pub fn datum_visible(&self, name: &str) -> bool {
129        !self.hidden_datums.contains(name)
130    }
131
132    /// Show/hide the construction datum/plane `name`'s plane (the Scene-tree
133    /// checkbox). Toggles [`hidden_datums`] and re-feeds the datum planes so the
134    /// plane appears/disappears immediately.
135    pub fn set_datum_visible(&mut self, name: &str, visible: bool) {
136        if visible {
137            self.hidden_datums.remove(name);
138        } else {
139            self.hidden_datums.insert(name.to_string());
140        }
141        self.refresh_construction_datums();
142    }
143
144    /// The construction datums/planes to list in the Scene tree: every D/P frame at
145    /// the current rollback, each with its live visibility (hidden ones included,
146    /// like [`committed_sketches`](Self::committed_sketches)).
147    pub fn construction_datums(&self) -> Vec<(String, bool)> {
148        self.construction_datum_names()
149            .into_iter()
150            .map(|name| {
151                let visible = !self.hidden_datums.contains(&name);
152                (name, visible)
153            })
154            .collect()
155    }
156
157    /// The construction datums/planes as JSON (`[{"name","visible"}]`) — the datum
158    /// sibling of [`sketch_entities_json`](Self::sketch_entities_json) the Scene
159    /// panel publishes (`__brepDatums`) for the headed verifier.
160    pub fn datum_entities_json(&self) -> String {
161        let list: Vec<serde_json::Value> = self
162            .construction_datums()
163            .into_iter()
164            .map(|(name, visible)| serde_json::json!({ "name": name, "visible": visible }))
165            .collect();
166        serde_json::Value::Array(list).to_string()
167    }
168
169    /// Select a construction datum/plane by frame NAME (replacing the whole
170    /// selection): a Scene-tree row click or a viewport datum pick. Only a name
171    /// that is an actually-resolved D/P frame at the current rollback selects;
172    /// others return false without changing the selection. Re-feeds the datum
173    /// planes so the selected one shows the accent, and bumps the generation.
174    pub fn select_datum(&mut self, name: &str) -> bool {
175        if name.is_empty() || !self.construction_frames.iter().any(|(n, _)| n == name) {
176            return false;
177        }
178        if Self::datum_feature_of(name, &self.feature_type_map()).is_none() {
179            return false;
180        }
181        self.emphasis.selected_solids.clear();
182        self.emphasis.selected_faces.clear();
183        self.emphasis.selected_edges.clear();
184        self.emphasis.selected_vertices.clear();
185        self.emphasis.selected_datums.clear();
186        self.emphasis.selected_datums.insert(name.to_string());
187        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
188        self.refresh_construction_datums();
189        self.dirty = true;
190        true
191    }
192}
193
194
195// Construction datum/plane persistent-display + Scene-tree-listing + selection
196// tests — their OWN module (appended last) so they do not conflict with the
197// modules above.
198#[cfg(test)]
199mod construction_datum_tests {
200    use super::*;
201
202    /// A cube (index 0) followed by a DATUM feature `Datum` (index 1). The datum
203    /// registers three base-plane frames `Datum:XY|XZ|YZ`.
204    fn cube_and_datum_history() -> String {
205        serde_json::json!({
206            "features": [
207                {
208                    "type": "P.CU",
209                    "inputParams": {
210                        "id": "Box",
211                        "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
212                        "transform": {
213                            "position": [0.0, 0.0, 0.0],
214                            "rotationEuler": [0.0, 0.0, 0.0],
215                            "scale": [1.0, 1.0, 1.0]
216                        },
217                        "boolean": { "targets": [], "operation": "NONE" }
218                    },
219                    "persistentData": {}
220                },
221                {
222                    "type": "D",
223                    "inputParams": { "id": "Datum" },
224                    "persistentData": {}
225                }
226            ]
227        })
228        .to_string()
229    }
230
231    /// A PLANE feature `Pl` (XZ orientation, offset 3) — registers ONE frame `Pl`.
232    fn plane_history() -> String {
233        serde_json::json!({
234            "features": [{
235                "type": "P",
236                "inputParams": { "id": "Pl", "orientation": "XZ", "offset_distance": 3.0 },
237                "persistentData": {}
238            }]
239        })
240        .to_string()
241    }
242
243    /// A DATUM `Datum` (index 0) followed by a committed rectangle SKETCH `Sk`
244    /// (index 1) on the XY plane. The sketch publishes its OWN plane frame `Sk`,
245    /// which must NOT surface as a datum plane (it renders as curves).
246    fn datum_and_sketch_history() -> String {
247        serde_json::json!({
248            "features": [
249                {
250                    "type": "D",
251                    "inputParams": { "id": "Datum" },
252                    "persistentData": {}
253                },
254                {
255                    "type": "S",
256                    "inputParams": { "id": "Sk" },
257                    "persistentData": {
258                        "basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
259                        "sketch": {
260                            "points": [
261                                { "id": 0, "x": 0.0,  "y": 0.0 },
262                                { "id": 1, "x": 10.0, "y": 0.0 },
263                                { "id": 2, "x": 10.0, "y": 6.0 },
264                                { "id": 3, "x": 0.0,  "y": 6.0 }
265                            ],
266                            "geometries": [
267                                { "id": 10, "type": "line", "points": [0, 1] },
268                                { "id": 11, "type": "line", "points": [1, 2] },
269                                { "id": 12, "type": "line", "points": [2, 3] },
270                                { "id": 13, "type": "line", "points": [3, 0] }
271                            ],
272                            "constraints": []
273                        }
274                    }
275                }
276            ]
277        })
278        .to_string()
279    }
280
281    /// The datum plane NAMES currently fed to the widget (sorted for stable
282    /// comparison).
283    fn fed_datum_names(engine: &EngineState) -> Vec<String> {
284        let mut names: Vec<String> = engine
285            .widgets
286            .datum_plane_names()
287            .iter()
288            .map(|(n, _)| n.to_string())
289            .collect();
290        names.sort();
291        names
292    }
293
294    #[test]
295    fn datum_feature_feeds_three_named_planes_and_lists_them() {
296        let mut engine = EngineState::new();
297        engine.set_history_json(&cube_and_datum_history()).unwrap();
298
299        // The widget received the three base-plane frames as named datum planes.
300        assert_eq!(
301            fed_datum_names(&engine),
302            vec!["Datum:XY".to_string(), "Datum:XZ".to_string(), "Datum:YZ".to_string()]
303        );
304
305        // `construction_datums` lists all three (all visible), and the JSON sibling
306        // publishes the same.
307        let listed = engine.construction_datums();
308        assert_eq!(listed.len(), 3);
309        assert!(listed.iter().all(|(_, visible)| *visible));
310        let json: serde_json::Value =
311            serde_json::from_str(&engine.datum_entities_json()).unwrap();
312        assert_eq!(json.as_array().unwrap().len(), 3);
313        assert!(json.as_array().unwrap().iter().any(|d| d["name"] == "Datum:XY"));
314
315        // The solid listing is unchanged (datums ride a separate sibling method).
316        let solids: serde_json::Value =
317            serde_json::from_str(&engine.scene_entities_json()).unwrap();
318        assert_eq!(solids.as_array().unwrap().len(), 1);
319        assert_eq!(solids[0]["name"], "Box");
320    }
321
322    #[test]
323    fn plane_feature_feeds_and_lists_its_single_frame() {
324        let mut engine = EngineState::new();
325        engine.set_history_json(&plane_history()).unwrap();
326        assert_eq!(fed_datum_names(&engine), vec!["Pl".to_string()]);
327        assert_eq!(engine.construction_datums(), vec![("Pl".to_string(), true)]);
328    }
329
330    #[test]
331    fn creating_feature_resolves_datum_and_plane_frames() {
332        // A lone datum-plane / plane pick must resolve its producing feature so
333        // the context bar's "Edit owning feature" button appears. Planes are not
334        // resident solids/faces/edges, so `creating_feature` falls back to
335        // `datum_feature_for_name` — this pins that.
336        let mut engine = EngineState::new();
337        engine.set_history_json(&cube_and_datum_history()).unwrap();
338        assert_eq!(
339            engine.creating_feature("Datum:YZ"),
340            Some(("Datum".to_string(), "D".to_string())),
341            "a datum plane resolves to its D feature"
342        );
343
344        let mut engine = EngineState::new();
345        engine.set_history_json(&plane_history()).unwrap();
346        assert_eq!(
347            engine.creating_feature("Pl"),
348            Some(("Pl".to_string(), "P".to_string())),
349            "a construction plane resolves to its P feature"
350        );
351    }
352
353    #[test]
354    fn sketch_frame_is_not_fed_as_a_datum() {
355        let mut engine = EngineState::new();
356        engine.set_history_json(&datum_and_sketch_history()).unwrap();
357        // Only the DATUM's three planes are datum planes; the sketch's own frame
358        // `Sk` is excluded (it renders as curves, not a datum plane).
359        assert_eq!(
360            fed_datum_names(&engine),
361            vec!["Datum:XY".to_string(), "Datum:XZ".to_string(), "Datum:YZ".to_string()]
362        );
363        assert!(!fed_datum_names(&engine).contains(&"Sk".to_string()));
364        assert!(engine.construction_datums().iter().all(|(n, _)| n != "Sk"));
365    }
366
367    #[test]
368    fn rollback_before_datum_clears_its_planes() {
369        let mut engine = EngineState::new();
370        engine.set_history_json(&cube_and_datum_history()).unwrap();
371        assert_eq!(fed_datum_names(&engine).len(), 3);
372
373        // Roll to the cube (index 0), before the datum: its planes are cleared and it
374        // drops out of the listing.
375        engine.roll_to(0);
376        assert!(fed_datum_names(&engine).is_empty(), "rolled-back datum cleared");
377        assert!(engine.construction_datums().is_empty());
378
379        // Rolling forward again re-feeds the three planes.
380        engine.roll_to(1);
381        assert_eq!(fed_datum_names(&engine).len(), 3, "rolled-forward datum re-shown");
382    }
383
384    #[test]
385    fn set_datum_visible_hides_and_restores_a_plane() {
386        let mut engine = EngineState::new();
387        engine.set_history_json(&cube_and_datum_history()).unwrap();
388
389        // Hide one sub-plane: it is dropped from the feed but still LISTED (visible:false).
390        engine.set_datum_visible("Datum:XZ", false);
391        assert!(!engine.datum_visible("Datum:XZ"));
392        assert!(!fed_datum_names(&engine).contains(&"Datum:XZ".to_string()));
393        assert_eq!(fed_datum_names(&engine).len(), 2);
394        assert!(engine
395            .construction_datums()
396            .contains(&("Datum:XZ".to_string(), false)));
397
398        // Show it again: re-fed.
399        engine.set_datum_visible("Datum:XZ", true);
400        assert!(engine.datum_visible("Datum:XZ"));
401        assert_eq!(fed_datum_names(&engine).len(), 3);
402    }
403
404    #[test]
405    fn select_datum_sets_selection_and_emphasizes_it() {
406        let mut engine = EngineState::new();
407        engine.set_history_json(&cube_and_datum_history()).unwrap();
408
409        assert!(engine.select_datum("Datum:XY"));
410        let sel: serde_json::Value = serde_json::from_str(&engine.selection_json()).unwrap();
411        assert_eq!(sel["datums"].as_array().unwrap().len(), 1);
412        assert_eq!(sel["datums"][0], "Datum:XY");
413
414        // The fed plane carries the selection emphasis (its `hot` flag is set).
415        let hot: Vec<(&str, bool)> = engine.widgets.datum_plane_names();
416        assert!(
417            hot.iter().any(|(n, hot)| *n == "Datum:XY" && *hot),
418            "selected datum is emphasized: {hot:?}"
419        );
420
421        // A bogus / non-D-P name does not select.
422        assert!(!engine.select_datum("NotADatum"));
423
424        // Clearing the selection drops the datum.
425        assert!(engine.clear_selection());
426        let sel: serde_json::Value = serde_json::from_str(&engine.selection_json()).unwrap();
427        assert!(sel["datums"].as_array().unwrap().is_empty());
428    }
429
430    #[test]
431    fn select_by_name_routes_datum_kind() {
432        let mut engine = EngineState::new();
433        engine.set_history_json(&cube_and_datum_history()).unwrap();
434        assert!(engine.select_by_name("datum", "Datum:YZ"));
435        let sel: serde_json::Value = serde_json::from_str(&engine.selection_json()).unwrap();
436        assert_eq!(sel["datums"][0], "Datum:YZ");
437
438        // Selecting a solid supersedes the datum (they share the one selection).
439        assert!(engine.select_by_name("solid", "Box"));
440        let sel: serde_json::Value = serde_json::from_str(&engine.selection_json()).unwrap();
441        assert!(sel["datums"].as_array().unwrap().is_empty());
442        assert_eq!(sel["solids"][0], "Box");
443    }
444
445    #[test]
446    fn object_info_json_on_a_datum_returns_a_graceful_record() {
447        let mut engine = EngineState::new();
448        engine.set_history_json(&cube_and_datum_history()).unwrap();
449
450        // A DATUM base plane: ok, kind "datum", provenance points at the D feature.
451        let info: serde_json::Value =
452            serde_json::from_str(&engine.object_info_json("Datum:XY")).unwrap();
453        assert_eq!(info["ok"], true, "datum info must not error: {info}");
454        assert_eq!(info["kind"], "datum");
455        assert_eq!(info["creatingFeature"]["id"], "Datum");
456        assert_eq!(info["creatingFeature"]["type"], "D");
457
458        // An unrelated name still reports the unknown-object error.
459        let unknown: serde_json::Value =
460            serde_json::from_str(&engine.object_info_json("Nope")).unwrap();
461        assert_eq!(unknown["ok"], false);
462    }
463
464    #[test]
465    fn object_info_json_on_a_plane_reports_kind_plane() {
466        let mut engine = EngineState::new();
467        engine.set_history_json(&plane_history()).unwrap();
468        let info: serde_json::Value =
469            serde_json::from_str(&engine.object_info_json("Pl")).unwrap();
470        assert_eq!(info["ok"], true, "plane info must not error: {info}");
471        assert_eq!(info["kind"], "plane");
472        assert_eq!(info["creatingFeature"]["type"], "P");
473    }
474
475    /// Ref-select geometry-miss → datum fallback: a `["PLANE","FACE"]` field
476    /// (the sketch `sketchPlane`) picks a construction datum when the click misses
477    /// scene geometry. Rolled to "before Sk" the scene has ONLY the datum's planes
478    /// (no solid to occlude), so a center-of-viewport click misses `pick_filtered`
479    /// and falls through to `datum_pick`, landing the resolved frame name.
480    #[test]
481    fn ref_select_falls_back_to_datum_pick_on_a_geometry_miss() {
482        let mut engine = EngineState::new();
483        engine.set_history_json(&datum_and_sketch_history()).unwrap();
484        // Camera looking straight down +Z at the origin — a center ray hits the XY
485        // datum face-on (XZ/YZ are edge-on), mirroring the widget pick test.
486        engine.resize(800.0, 600.0);
487        engine.camera.eye = [0.0, 0.0, 40.0];
488        engine.camera.target = [0.0, 0.0, 0.0];
489        engine.camera.up = [0.0, 1.0, 0.0];
490        engine.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
491
492        engine.begin_ref_select(
493            "Sk",
494            vec!["sketchPlane".to_string()],
495            "Sketch plane".to_string(),
496            vec!["PLANE".to_string(), "FACE".to_string()],
497            false,
498            Vec::new(),
499        );
500        // A center click: no face under the cursor → the datum fallback records the
501        // XY datum frame as the reference value.
502        engine.ref_select_click(400.0, 300.0);
503        assert_eq!(
504            engine.ref_select_names(),
505            vec!["Datum:XY".to_string()],
506            "geometry-miss click picks the construction datum"
507        );
508    }
509}
510