Skip to main content

brep_render/engine_state/
committed_sketches.rs

1use super::*;
2
3/// The committed-sketch SHEET base color (dim cyan, matching the retired overlay
4/// `COMMITTED_COLOR` 0x67c7d4) — a sketch sheet reads distinctly from a real solid.
5const SKETCH_SHEET_COLOR: [f32; 3] = [
6    0x67 as f32 / 255.0,
7    0xc7 as f32 / 255.0,
8    0xd4 as f32 / 255.0,
9];
10
11impl EngineState {
12
13    /// The committed sketches present at the CURRENT rollback: every `"S"` feature at
14    /// index `0..=rollback`, EXCEPT the one being edited (it displays via the live
15    /// editing overlay). Hidden ids are still returned (the Scene tree lists them);
16    /// visibility is filtered by the caller against [`hidden_sketches`].
17    fn committed_sketch_ids(&self) -> Vec<String> {
18        let editing = self.sketch_edit.as_ref().map(|edit| edit.feature_id.as_str());
19        let rollback = self.history.rollback();
20        let mut ids = Vec::new();
21        for index in 0..=rollback {
22            if self.history.feature_type(index).as_deref() != Some("S") {
23                continue;
24            }
25            let Some(id) = self.history.feature_id(index) else {
26                continue;
27            };
28            if Some(id.as_str()) == editing {
29                continue;
30            }
31            ids.push(id);
32        }
33        ids
34    }
35
36    /// (Re)build the persistent committed-sketch SHEET SOLIDS. For every committed
37    /// sketch that should show — [`committed_sketch_ids`](Self::committed_sketch_ids)
38    /// minus [`hidden_sketches`] — synthesize its planar sheet from the run's solved
39    /// profile and insert it as a scene solid (keyed by the sketch id, dim-cyan,
40    /// flagged `is_sketch`); remove any sheet inserted on the PREVIOUS refresh but
41    /// not this one (rolled back, deleted, hidden, or became the active edit). A
42    /// sketch with no closed profile (open / underconstrained) yields no sheet and
43    /// is skipped. Marks dirty.
44    pub fn refresh_committed_sketches(&mut self) {
45        let visible_ids: Vec<String> = self
46            .committed_sketch_ids()
47            .into_iter()
48            .filter(|id| !self.hidden_sketches.contains(id))
49            .collect();
50
51        // Phase 1 (immutable borrow of the surfaced profiles): build a display
52        // payload per visible sketch that HAS a closed profile. A sketch with no
53        // sheet (no face + no edges) is skipped.
54        let payloads: Vec<(String, brep_kernel::DisplaySolidPayload)> = visible_ids
55            .iter()
56            .filter_map(|id| {
57                let profile = self
58                    .sketch_profiles
59                    .iter()
60                    .find(|(name, _)| name == id)
61                    .map(|(_, profile)| profile)?;
62                let payload = brep_kernel::sketch_profile_display_payload(profile);
63                if payload.mesh.indices.is_empty() && payload.edges.is_empty() {
64                    return None;
65                }
66                Some((id.clone(), payload))
67            })
68            .collect();
69
70        // Phase 2 (mutable borrow of the scene): insert each sheet as a scene solid.
71        let mut fed: Vec<String> = Vec::with_capacity(payloads.len());
72        for (id, payload) in payloads {
73            let mut solid = crate::scene::solid_display_from_payload(&id, payload);
74            solid.is_sketch = true;
75            solid.color_override = Some(SKETCH_SHEET_COLOR);
76            self.scene.insert_solid(solid);
77            fed.push(id);
78        }
79
80        // Remove any sketch sheet inserted last time but not now.
81        let fed_set: std::collections::HashSet<&str> = fed.iter().map(String::as_str).collect();
82        let stale: Vec<String> = self
83            .shown_sketch_ids
84            .iter()
85            .filter(|id| !fed_set.contains(id.as_str()))
86            .cloned()
87            .collect();
88        for id in stale {
89            self.scene.remove_solid(&id);
90        }
91
92        self.shown_sketch_ids = fed;
93        self.dirty = true;
94    }
95
96    /// Whether the committed sketch `id`'s persistent overlay is shown (absent from
97    /// [`hidden_sketches`] = visible).
98    pub fn sketch_visible(&self, id: &str) -> bool {
99        !self.hidden_sketches.contains(id)
100    }
101
102    /// Show/hide the committed sketch `id`'s persistent overlay (the Scene-tree
103    /// checkbox). Toggles [`hidden_sketches`], rebuilds the committed overlays (so the
104    /// group is fed or cleared immediately), and marks dirty.
105    pub fn set_sketch_visible(&mut self, id: &str, visible: bool) {
106        if visible {
107            self.hidden_sketches.remove(id);
108        } else {
109            self.hidden_sketches.insert(id.to_string());
110        }
111        self.refresh_committed_sketches();
112    }
113
114    /// The committed sketches to list in the Scene tree: every `"S"` feature at the
115    /// current rollback (minus the active edit), each with its live visibility — the
116    /// ordered `(id, visible)` list the Scene panel snapshots.
117    pub fn committed_sketches(&self) -> Vec<(String, bool)> {
118        self.committed_sketch_ids()
119            .into_iter()
120            .map(|id| {
121                let visible = !self.hidden_sketches.contains(&id);
122                (id, visible)
123            })
124            .collect()
125    }
126
127    /// The committed sketches as JSON (`[{"name":<id>,"visible":<bool>}]`) — the
128    /// sibling of [`scene_entities_json`](Self::scene_entities_json) the Scene panel
129    /// publishes for the headed verifier (kept a SEPARATE method so the solids array's
130    /// shape is unchanged).
131    pub fn sketch_entities_json(&self) -> String {
132        let list: Vec<serde_json::Value> = self
133            .committed_sketches()
134            .into_iter()
135            .map(|(id, visible)| serde_json::json!({ "name": id, "visible": visible }))
136            .collect();
137        serde_json::Value::Array(list).to_string()
138    }
139}
140
141// ===========================================================================
142// Construction datums — DATUM + PLANE feature frames rendered as first-class,
143// SELECTABLE scene citizens (the committed-sketch parallel). The frames the last
144// history run resolved ride `construction_frames`; this block filters them to the
145// D/P producing features (a frame name → its feature TYPE via the history), feeds
146// the kept ones to the datum-plane widget channel (`set_datums`, which REPLACES
147// its set wholesale each call — so re-feeding the full current set auto-drops
148// departed/hidden/rolled-back planes), lists them in the Scene tree, and selects
149// one BY NAME (`emphasis.selected_datums`, re-colored with the accent).
150// `hidden_datums` drives per-plane visibility. Kept in ONE appended block so
151// concurrent edits to the primary impl land clean.
152// ===========================================================================
153
154
155// Committed-sketch persistent-display + Scene-tree-listing tests — their OWN module
156// (appended last) so they do not conflict with the modules above.
157#[cfg(test)]
158mod committed_sketch_tests {
159    use super::*;
160
161    /// A two-feature history: a cube (solid) followed by a committed closed-rectangle
162    /// sketch on the XY plane (the sketch is the LAST feature).
163    fn cube_and_sketch_history() -> String {
164        serde_json::json!({
165            "features": [
166                {
167                    "type": "P.CU",
168                    "inputParams": {
169                        "id": "Box",
170                        "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
171                        "transform": {
172                            "position": [0.0, 0.0, 0.0],
173                            "rotationEuler": [0.0, 0.0, 0.0],
174                            "scale": [1.0, 1.0, 1.0]
175                        },
176                        "boolean": { "targets": [], "operation": "NONE" }
177                    },
178                    "persistentData": {}
179                },
180                {
181                    "type": "S",
182                    "inputParams": { "id": "Sk" },
183                    "persistentData": {
184                        "basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
185                        "sketch": {
186                            "points": [
187                                { "id": 0, "x": 0.0,  "y": 0.0 },
188                                { "id": 1, "x": 10.0, "y": 0.0 },
189                                { "id": 2, "x": 10.0, "y": 6.0 },
190                                { "id": 3, "x": 0.0,  "y": 6.0 }
191                            ],
192                            "geometries": [
193                                { "id": 10, "type": "line", "points": [0, 1] },
194                                { "id": 11, "type": "line", "points": [1, 2] },
195                                { "id": 12, "type": "line", "points": [2, 3] },
196                                { "id": 13, "type": "line", "points": [3, 0] }
197                            ],
198                            "constraints": []
199                        }
200                    }
201                }
202            ]
203        })
204        .to_string()
205    }
206
207    fn has_group(engine: &EngineState, name: &str) -> bool {
208        engine.widgets.overlay_group_names().contains(&name)
209    }
210
211    /// The synthesized committed-sketch SHEET solid for `id`, if present.
212    fn sketch_sheet<'a>(
213        engine: &'a EngineState,
214        id: &str,
215    ) -> Option<&'a crate::scene::SolidDisplay> {
216        engine.scene.solid(id).filter(|solid| solid.is_sketch)
217    }
218
219    fn has_sketch_sheet(engine: &EngineState, id: &str) -> bool {
220        sketch_sheet(engine, id).is_some()
221    }
222
223    #[test]
224    fn committed_sketch_renders_and_lists_after_rerun() {
225        let mut engine = EngineState::new();
226        engine.set_history_json(&cube_and_sketch_history()).unwrap();
227
228        // The committed sketch is now a SHEET SOLID keyed by its id: a planar face,
229        // its four named boundary edges, and four corner vertices — pickable via the
230        // solid infrastructure.
231        let sheet = sketch_sheet(&engine, "Sk").expect("committed sketch sheet solid");
232        assert!(sheet.is_sketch, "flagged as a sketch");
233        assert_eq!(sheet.faces.len(), 1, "one sheet face");
234        assert_eq!(sheet.edges.len(), 4, "four boundary edges");
235        assert_eq!(sheet.vertices.len(), 4, "four corner vertices");
236        assert!(!sheet.mesh.positions.is_empty(), "filled face mesh");
237
238        // The sketch sheet is NOT in the solids listing (it lists under "Sketches").
239        let solids: serde_json::Value =
240            serde_json::from_str(&engine.scene_entities_json()).unwrap();
241        assert_eq!(solids.as_array().unwrap().len(), 1);
242        assert_eq!(solids[0]["name"], "Box");
243
244        // The committed sketch is listed by the sibling methods.
245        assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
246        let sketches: serde_json::Value =
247            serde_json::from_str(&engine.sketch_entities_json()).unwrap();
248        assert_eq!(sketches[0]["name"], "Sk");
249        assert_eq!(sketches[0]["visible"], true);
250        assert!(engine.sketch_visible("Sk"));
251    }
252
253    #[test]
254    fn rolling_back_before_sketch_clears_committed_sheet() {
255        let mut engine = EngineState::new();
256        engine.set_history_json(&cube_and_sketch_history()).unwrap();
257        assert!(has_sketch_sheet(&engine, "Sk"));
258
259        // Roll to the cube (index 0), before the sketch: its sheet is removed and it
260        // drops out of the listing.
261        engine.roll_to(0);
262        assert!(!has_sketch_sheet(&engine, "Sk"), "rolled-back sketch sheet removed");
263        assert!(engine.committed_sketches().is_empty());
264
265        // Rolling forward again re-adds it.
266        engine.roll_to(1);
267        assert!(has_sketch_sheet(&engine, "Sk"), "rolled-forward sketch re-shown");
268        assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
269    }
270
271    #[test]
272    fn set_sketch_visible_hides_and_restores() {
273        let mut engine = EngineState::new();
274        engine.set_history_json(&cube_and_sketch_history()).unwrap();
275        assert!(has_sketch_sheet(&engine, "Sk"));
276
277        // Hide: sheet removed from the scene, still LISTED but visible:false.
278        engine.set_sketch_visible("Sk", false);
279        assert!(!has_sketch_sheet(&engine, "Sk"), "hidden sketch sheet removed");
280        assert!(!engine.sketch_visible("Sk"));
281        assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), false)]);
282        let sketches: serde_json::Value =
283            serde_json::from_str(&engine.sketch_entities_json()).unwrap();
284        assert_eq!(sketches[0]["visible"], false);
285
286        // Show: sheet re-inserted.
287        engine.set_sketch_visible("Sk", true);
288        assert!(has_sketch_sheet(&engine, "Sk"), "re-shown sketch sheet inserted");
289        assert!(engine.sketch_visible("Sk"));
290    }
291
292    #[test]
293    fn entering_sketch_mode_removes_committed_sheet_then_commit_readds() {
294        let mut engine = EngineState::new();
295        engine.set_history_json(&cube_and_sketch_history()).unwrap();
296        assert!(has_sketch_sheet(&engine, "Sk"));
297
298        // Enter: the committed sheet is removed (no double display); the LIVE editing
299        // overlay group is fed instead (unchanged active-edit path).
300        engine.enter_sketch_mode("Sk").expect("enter");
301        assert!(engine.sketch_mode());
302        assert!(
303            !has_sketch_sheet(&engine, "Sk"),
304            "active sketch's committed sheet removed"
305        );
306        assert!(has_group(&engine, "sketch-geometry"), "live editing overlay fed");
307        // While editing, the sketch is not in the committed listing.
308        assert!(engine.committed_sketches().is_empty());
309
310        // Commit exit: the just-committed sketch reappears as a sheet solid and the
311        // live editing group is cleared.
312        engine.exit_sketch_mode(true);
313        assert!(!engine.sketch_mode());
314        assert!(
315            has_sketch_sheet(&engine, "Sk"),
316            "committed sketch reappears as a sheet after commit"
317        );
318        assert!(!has_group(&engine, "sketch-geometry"), "live editing overlay cleared");
319        assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
320    }
321
322    /// A committed sketch sheet is MEASURABLE through the handle-less metadata path:
323    /// its Info reports `kind:"sketch"` with the profile area (10x6 = 60 mm²) and
324    /// total boundary length (perimeter 2*(10+6) = 32 mm), no volume, and its
325    /// creating feature is the sketch.
326    #[test]
327    fn sketch_sheet_info_reports_area_and_edge_length() {
328        let mut engine = EngineState::new();
329        engine.set_history_json(&cube_and_sketch_history()).unwrap();
330        let info: serde_json::Value =
331            serde_json::from_str(&engine.object_info_json("Sk")).unwrap();
332        assert_eq!(info["ok"], true, "sketch info must not error: {info}");
333        assert_eq!(info["kind"], "sketch");
334        assert!(info.get("volume").is_none(), "a sheet has no volume");
335        assert!((info["area"].as_f64().unwrap() - 60.0).abs() < 1e-3, "area {}", info["area"]);
336        assert!(
337            (info["edgeLengthTotal"].as_f64().unwrap() - 32.0).abs() < 1e-3,
338            "perimeter {}",
339            info["edgeLengthTotal"]
340        );
341        assert_eq!(info["creatingFeature"]["id"], "Sk");
342        assert_eq!(info["creatingFeature"]["type"], "S");
343    }
344
345    /// A boundary edge of the sheet is a normal selectable/measurable edge: its Info
346    /// reports `kind:"edge"` and the segment length (a 10 or 6 mm rectangle side).
347    #[test]
348    fn sketch_sheet_edge_is_measurable() {
349        let mut engine = EngineState::new();
350        engine.set_history_json(&cube_and_sketch_history()).unwrap();
351        let edge_name = sketch_sheet(&engine, "Sk").unwrap().edges[0].name.clone();
352        assert!(!edge_name.is_empty(), "boundary edge is named");
353        let info: serde_json::Value =
354            serde_json::from_str(&engine.object_info_json(&edge_name)).unwrap();
355        assert_eq!(info["ok"], true, "edge info: {info}");
356        assert_eq!(info["kind"], "edge");
357        assert_eq!(info["solid"], "Sk");
358        let length = info["length"].as_f64().unwrap();
359        assert!(
360            (length - 10.0).abs() < 1e-3 || (length - 6.0).abs() < 1e-3,
361            "rectangle side length {length}"
362        );
363    }
364
365    /// The sheet is selectable BY NAME through the shared solid selection (the
366    /// viewport pick and the Scene tree both route here), so a sketch highlights
367    /// like any solid.
368    #[test]
369    fn sketch_sheet_is_selectable_by_name() {
370        let mut engine = EngineState::new();
371        engine.set_history_json(&cube_and_sketch_history()).unwrap();
372        assert!(engine.select_by_name("solid", "Sk"));
373        let sel: serde_json::Value = serde_json::from_str(&engine.selection_json()).unwrap();
374        assert_eq!(sel["solids"][0], "Sk");
375    }
376}
377