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