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) and EXCEPT any sketch a downstream feature within the current
16    /// rollback CONSUMED (see [`consumed_feature_names`](Self::consumed_feature_names)).
17    /// Hidden ids are still returned (the Scene tree lists them); visibility is
18    /// filtered by the caller against [`hidden_sketches`].
19    fn committed_sketch_ids(&self) -> Vec<String> {
20        let editing = self.sketch_edit.as_ref().map(|edit| edit.feature_id.as_str());
21        let rollback = self.history.rollback();
22        let mut ids = Vec::new();
23        for index in 0..=rollback {
24            if self.history.feature_type(index).as_deref() != Some("S") {
25                continue;
26            }
27            let Some(id) = self.history.feature_id(index) else {
28                continue;
29            };
30            if Some(id.as_str()) == editing {
31                continue;
32            }
33            ids.push(id);
34        }
35        // Drop any sketch a downstream feature CONSUMED: extrude/revolve (gated by
36        // `consumeProfileSketch`) and the sheet-metal consumers push the consumed
37        // sketch id into their `result.removed`, so a consumed sketch must vanish
38        // from the scene — no sheet, no tree row — exactly like a consumed solid.
39        // Only replay when there is a candidate to test, so a sketch-less scene pays
40        // nothing for the check.
41        if !ids.is_empty() {
42            let consumed = self.consumed_feature_names();
43            ids.retain(|id| !consumed.contains(id));
44        }
45        ids
46    }
47
48    /// The set of output names CONSUMED (removed) by some feature within the CURRENT
49    /// rollback prefix — a solid absorbed by a boolean, or a sketch absorbed by a
50    /// downstream extrude / revolve / sheet-metal consumer. Sourced from the SAME
51    /// per-feature `removed` signal the scene build honors
52    /// (`scene_query::resident_solid_handles`): replay the rolled-to prefix (a clean
53    /// cache hit after a build — re-tessellates nothing) and union every feature's
54    /// `removed`. Because [`prefix_request`](crate::history::History::prefix_request)
55    /// stops at the rolled-to feature, a consumer ABOVE the rollback never runs, so
56    /// its removed names are (correctly) absent — a sketch consumed above the
57    /// rollback point still shows. A malformed request yields an empty set (same as
58    /// `resident_solid_handles`).
59    fn consumed_feature_names(&self) -> std::collections::HashSet<String> {
60        let request: HistoryRequest = match serde_json::from_value(self.history.prefix_request()) {
61            Ok(request) => request,
62            Err(_) => return std::collections::HashSet::new(),
63        };
64        brep_kernel::execute_history(&request)
65            .results
66            .iter()
67            .flat_map(|feature| feature.removed.iter().cloned())
68            .collect()
69    }
70
71    /// (Re)build the persistent committed-sketch SHEET SOLIDS. For every committed
72    /// sketch that should show — [`committed_sketch_ids`](Self::committed_sketch_ids)
73    /// minus [`hidden_sketches`] — synthesize its display from the run's solved
74    /// profile AND its own model segments, and insert it as a scene solid (keyed by
75    /// the sketch id, dim-cyan, flagged `is_sketch`); remove any sheet inserted on
76    /// the PREVIOUS refresh but not this one (rolled back, deleted, hidden, or
77    /// became the active edit). A sketch that closes a region gets its planar sheet;
78    /// one that closes NOTHING (an open chain — the single line of the 2026-09-02
79    /// report) still draws its segments as named edges, so it is visible and
80    /// pickable instead of vanishing. Only a sketch with no drawable geometry at all
81    /// (empty / construction-only) is skipped. Marks dirty.
82    pub fn refresh_committed_sketches(&mut self) {
83        let visible_ids: Vec<String> = self
84            .committed_sketch_ids()
85            .into_iter()
86            .filter(|id| !self.hidden_sketches.contains(id))
87            .collect();
88
89        // Phase 1 (immutable borrow of the surfaced profiles + paths): build a
90        // display payload per visible sketch — its closed profile's sheet (when it
91        // has one) plus every model segment that sheet does not already draw. A
92        // sketch with nothing to draw (no face + no edges) is skipped.
93        let payloads: Vec<(String, brep_kernel::DisplaySolidPayload)> = visible_ids
94            .iter()
95            .filter_map(|id| {
96                let profile = self
97                    .sketch_profiles
98                    .iter()
99                    .find(|(name, _)| name == id)
100                    .map(|(_, profile)| profile);
101                // The sketch's own model segments, published one per geometry under
102                // `{id}:G{gid}`. The whole-chain `{id}` path duplicates them and
103                // `{id}:REF:{source}` is projected reference geometry, so neither
104                // is drawn here.
105                let prefix = format!("{id}:G");
106                let segments: Vec<(String, Vec<brep_kernel::NurbsCurve>)> = self
107                    .sketch_paths
108                    .iter()
109                    .filter(|(name, _)| name.starts_with(&prefix))
110                    .cloned()
111                    .collect();
112                let payload = brep_kernel::sketch_display_payload(profile, &segments);
113                if payload.mesh.indices.is_empty() && payload.edges.is_empty() {
114                    return None;
115                }
116                Some((id.clone(), payload))
117            })
118            .collect();
119
120        // Phase 2 (mutable borrow of the scene): insert each sheet as a scene solid.
121        let mut fed: Vec<String> = Vec::with_capacity(payloads.len());
122        for (id, payload) in payloads {
123            let mut solid = crate::scene::solid_display_from_payload(&id, payload);
124            solid.is_sketch = true;
125            solid.color_override = Some(SKETCH_SHEET_COLOR);
126            self.scene.insert_solid(solid);
127            fed.push(id);
128        }
129
130        // Remove any sketch sheet inserted last time but not now.
131        let fed_set: std::collections::HashSet<&str> = fed.iter().map(String::as_str).collect();
132        let stale: Vec<String> = self
133            .shown_sketch_ids
134            .iter()
135            .filter(|id| !fed_set.contains(id.as_str()))
136            .cloned()
137            .collect();
138        for id in stale {
139            self.scene.remove_solid(&id);
140        }
141
142        self.shown_sketch_ids = fed;
143        self.dirty = true;
144    }
145
146    /// Whether the committed sketch `id`'s persistent overlay is shown (absent from
147    /// [`hidden_sketches`] = visible).
148    pub fn sketch_visible(&self, id: &str) -> bool {
149        !self.hidden_sketches.contains(id)
150    }
151
152    /// Show/hide the committed sketch `id`'s persistent overlay (the Scene-tree
153    /// checkbox). Toggles [`hidden_sketches`], rebuilds the committed overlays (so the
154    /// group is fed or cleared immediately), and marks dirty.
155    pub fn set_sketch_visible(&mut self, id: &str, visible: bool) {
156        if visible {
157            self.hidden_sketches.remove(id);
158        } else {
159            self.hidden_sketches.insert(id.to_string());
160        }
161        self.refresh_committed_sketches();
162    }
163
164    /// The committed sketches to list in the Scene tree: every `"S"` feature at the
165    /// current rollback (minus the active edit), each with its live visibility — the
166    /// ordered `(id, visible)` list the Scene panel snapshots.
167    pub fn committed_sketches(&self) -> Vec<(String, bool)> {
168        self.committed_sketch_ids()
169            .into_iter()
170            .map(|id| {
171                let visible = !self.hidden_sketches.contains(&id);
172                (id, visible)
173            })
174            .collect()
175    }
176
177    /// The committed sketches as JSON (`[{"name":<id>,"visible":<bool>}]`) — the
178    /// sibling of [`scene_entities_json`](Self::scene_entities_json) the Scene panel
179    /// publishes for the headed verifier (kept a SEPARATE method so the solids array's
180    /// shape is unchanged).
181    pub fn sketch_entities_json(&self) -> String {
182        let list: Vec<serde_json::Value> = self
183            .committed_sketches()
184            .into_iter()
185            .map(|(id, visible)| serde_json::json!({ "name": id, "visible": visible }))
186            .collect();
187        serde_json::Value::Array(list).to_string()
188    }
189}
190
191// ===========================================================================
192// Construction datums — DATUM + PLANE feature frames rendered as first-class,
193// SELECTABLE scene citizens (the committed-sketch parallel). The frames the last
194// history run resolved ride `construction_frames`; this block filters them to the
195// D/P producing features (a frame name → its feature TYPE via the history), feeds
196// the kept ones to the datum-plane widget channel (`set_datums`, which REPLACES
197// its set wholesale each call — so re-feeding the full current set auto-drops
198// departed/hidden/rolled-back planes), lists them in the Scene tree, and selects
199// one BY NAME (`emphasis.selected_datums`, re-colored with the accent).
200// `hidden_datums` drives per-plane visibility. Kept in ONE appended block so
201// concurrent edits to the primary impl land clean.
202// ===========================================================================
203
204
205// Committed-sketch persistent-display + Scene-tree-listing tests — their OWN module
206// (appended last) so they do not conflict with the modules above.
207#[cfg(test)]
208mod committed_sketch_tests {
209    use super::*;
210
211    /// A two-feature history: a cube (solid) followed by a committed closed-rectangle
212    /// sketch on the XY plane (the sketch is the LAST feature).
213    fn cube_and_sketch_history() -> String {
214        serde_json::json!({
215            "features": [
216                {
217                    "type": "P.CU",
218                    "inputParams": {
219                        "id": "Box",
220                        "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
221                        "transform": {
222                            "position": [0.0, 0.0, 0.0],
223                            "rotationEuler": [0.0, 0.0, 0.0],
224                            "scale": [1.0, 1.0, 1.0]
225                        },
226                        "boolean": { "targets": [], "operation": "NONE" }
227                    },
228                    "persistentData": {}
229                },
230                {
231                    "type": "S",
232                    "inputParams": { "id": "Sk" },
233                    "persistentData": {
234                        "basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
235                        "sketch": {
236                            "points": [
237                                { "id": 0, "x": 0.0,  "y": 0.0 },
238                                { "id": 1, "x": 10.0, "y": 0.0 },
239                                { "id": 2, "x": 10.0, "y": 6.0 },
240                                { "id": 3, "x": 0.0,  "y": 6.0 }
241                            ],
242                            "geometries": [
243                                { "id": 10, "type": "line", "points": [0, 1] },
244                                { "id": 11, "type": "line", "points": [1, 2] },
245                                { "id": 12, "type": "line", "points": [2, 3] },
246                                { "id": 13, "type": "line", "points": [3, 0] }
247                            ],
248                            "constraints": []
249                        }
250                    }
251                }
252            ]
253        })
254        .to_string()
255    }
256
257    fn has_group(engine: &EngineState, name: &str) -> bool {
258        engine.widgets.overlay_group_names().contains(&name)
259    }
260
261    /// The synthesized committed-sketch SHEET solid for `id`, if present.
262    fn sketch_sheet<'a>(
263        engine: &'a EngineState,
264        id: &str,
265    ) -> Option<&'a crate::scene::SolidDisplay> {
266        engine.scene.solid(id).filter(|solid| solid.is_sketch)
267    }
268
269    fn has_sketch_sheet(engine: &EngineState, id: &str) -> bool {
270        sketch_sheet(engine, id).is_some()
271    }
272
273    /// The same history, but the sketch holds ONE OPEN LINE — the shape of the
274    /// 2026-09-02 report ("Sketch with single edge not visible in 3D").
275    fn cube_and_open_sketch_history() -> String {
276        let mut history: serde_json::Value =
277            serde_json::from_str(&cube_and_sketch_history()).expect("history parses");
278        history["features"][1]["persistentData"]["sketch"] = serde_json::json!({
279            "points": [
280                { "id": 0, "x": 1.0, "y": 2.0 },
281                { "id": 1, "x": 7.0, "y": 4.0 }
282            ],
283            "geometries": [
284                { "id": 10, "type": "line", "points": [0, 1] }
285            ],
286            "constraints": []
287        });
288        history.to_string()
289    }
290
291    /// An OPEN sketch closes no region, so it publishes no profile and the sheet
292    /// builder used to be handed nothing — the sketch was invisible in 3D and
293    /// unpickable there (the reported symptom). It now draws its own model
294    /// segments: one named edge, both endpoints, and no face.
295    #[test]
296    fn open_sketch_renders_its_segments_without_a_sheet_face() {
297        let mut engine = EngineState::new();
298        engine.set_history_json(&cube_and_open_sketch_history()).unwrap();
299
300        let sheet = sketch_sheet(&engine, "Sk").expect("open sketch still displays");
301        assert!(sheet.is_sketch, "flagged as a sketch");
302        assert!(sheet.faces.is_empty(), "nothing closes, so there is no sheet face");
303        assert!(sheet.mesh.positions.is_empty(), "and no face mesh");
304        assert_eq!(sheet.edges.len(), 1, "the single line draws as one edge");
305        assert_eq!(sheet.edges[0].name, "Sk:G10", "named by its sketch geometry");
306        assert_eq!(sheet.vertices.len(), 2, "both endpoints draw");
307        // The drawn extent feeds zoom-to-fit and every bbox-gated traversal, so it
308        // must cover the line rather than staying empty.
309        assert!(!sheet.bbox.is_empty(), "the drawn segment gives the sketch an extent");
310
311        // ...and it lists in the Scene tree like any other committed sketch.
312        assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
313    }
314
315    /// A CLOSED sketch draws each boundary edge exactly ONCE: the sheet already
316    /// carries them, and the per-segment paths that now also reach the display
317    /// must not double them up.
318    #[test]
319    fn closed_sketch_draws_each_boundary_edge_once() {
320        let mut engine = EngineState::new();
321        engine.set_history_json(&cube_and_sketch_history()).unwrap();
322        let sheet = sketch_sheet(&engine, "Sk").expect("committed sketch sheet solid");
323        assert_eq!(sheet.edges.len(), 4, "four boundary edges, not eight");
324        let mut names: Vec<&str> = sheet.edges.iter().map(|e| e.name.as_str()).collect();
325        names.sort_unstable();
326        names.dedup();
327        assert_eq!(names.len(), 4, "each edge name appears once: {names:?}");
328    }
329
330    #[test]
331    fn committed_sketch_renders_and_lists_after_rerun() {
332        let mut engine = EngineState::new();
333        engine.set_history_json(&cube_and_sketch_history()).unwrap();
334
335        // The committed sketch is now a SHEET SOLID keyed by its id: a planar face,
336        // its four named boundary edges, and four corner vertices — pickable via the
337        // solid infrastructure.
338        let sheet = sketch_sheet(&engine, "Sk").expect("committed sketch sheet solid");
339        assert!(sheet.is_sketch, "flagged as a sketch");
340        assert_eq!(sheet.faces.len(), 1, "one sheet face");
341        assert_eq!(sheet.edges.len(), 4, "four boundary edges");
342        assert_eq!(sheet.vertices.len(), 4, "four corner vertices");
343        assert!(!sheet.mesh.positions.is_empty(), "filled face mesh");
344
345        // The sketch sheet is NOT in the solids listing (it lists under "Sketches").
346        let solids: serde_json::Value =
347            serde_json::from_str(&engine.scene_entities_json()).unwrap();
348        assert_eq!(solids.as_array().unwrap().len(), 1);
349        assert_eq!(solids[0]["name"], "Box");
350
351        // The committed sketch is listed by the sibling methods.
352        assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
353        let sketches: serde_json::Value =
354            serde_json::from_str(&engine.sketch_entities_json()).unwrap();
355        assert_eq!(sketches[0]["name"], "Sk");
356        assert_eq!(sketches[0]["visible"], true);
357        assert!(engine.sketch_visible("Sk"));
358    }
359
360    #[test]
361    fn rolling_back_before_sketch_clears_committed_sheet() {
362        let mut engine = EngineState::new();
363        engine.set_history_json(&cube_and_sketch_history()).unwrap();
364        assert!(has_sketch_sheet(&engine, "Sk"));
365
366        // Roll to the cube (index 0), before the sketch: its sheet is removed and it
367        // drops out of the listing.
368        engine.roll_to(0);
369        assert!(!has_sketch_sheet(&engine, "Sk"), "rolled-back sketch sheet removed");
370        assert!(engine.committed_sketches().is_empty());
371
372        // Rolling forward again re-adds it.
373        engine.roll_to(1);
374        assert!(has_sketch_sheet(&engine, "Sk"), "rolled-forward sketch re-shown");
375        assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
376    }
377
378    #[test]
379    fn set_sketch_visible_hides_and_restores() {
380        let mut engine = EngineState::new();
381        engine.set_history_json(&cube_and_sketch_history()).unwrap();
382        assert!(has_sketch_sheet(&engine, "Sk"));
383
384        // Hide: sheet removed from the scene, still LISTED but visible:false.
385        engine.set_sketch_visible("Sk", false);
386        assert!(!has_sketch_sheet(&engine, "Sk"), "hidden sketch sheet removed");
387        assert!(!engine.sketch_visible("Sk"));
388        assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), false)]);
389        let sketches: serde_json::Value =
390            serde_json::from_str(&engine.sketch_entities_json()).unwrap();
391        assert_eq!(sketches[0]["visible"], false);
392
393        // Show: sheet re-inserted.
394        engine.set_sketch_visible("Sk", true);
395        assert!(has_sketch_sheet(&engine, "Sk"), "re-shown sketch sheet inserted");
396        assert!(engine.sketch_visible("Sk"));
397    }
398
399    #[test]
400    fn entering_sketch_mode_removes_committed_sheet_then_commit_readds() {
401        let mut engine = EngineState::new();
402        engine.set_history_json(&cube_and_sketch_history()).unwrap();
403        assert!(has_sketch_sheet(&engine, "Sk"));
404
405        // Enter: the committed sheet is removed (no double display); the LIVE editing
406        // overlay group is fed instead (unchanged active-edit path).
407        engine.enter_sketch_mode("Sk").expect("enter");
408        assert!(engine.sketch_mode());
409        assert!(
410            !has_sketch_sheet(&engine, "Sk"),
411            "active sketch's committed sheet removed"
412        );
413        assert!(has_group(&engine, "sketch-geometry"), "live editing overlay fed");
414        // While editing, the sketch is not in the committed listing.
415        assert!(engine.committed_sketches().is_empty());
416
417        // Commit exit: the just-committed sketch reappears as a sheet solid and the
418        // live editing group is cleared.
419        engine.exit_sketch_mode(true);
420        assert!(!engine.sketch_mode());
421        assert!(
422            has_sketch_sheet(&engine, "Sk"),
423            "committed sketch reappears as a sheet after commit"
424        );
425        assert!(!has_group(&engine, "sketch-geometry"), "live editing overlay cleared");
426        assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
427    }
428
429    /// A committed sketch sheet is MEASURABLE through the handle-less metadata path:
430    /// its Info reports `kind:"sketch"` with the profile area (10x6 = 60 mm²) and
431    /// total boundary length (perimeter 2*(10+6) = 32 mm), no volume, and its
432    /// creating feature is the sketch.
433    #[test]
434    fn sketch_sheet_info_reports_area_and_edge_length() {
435        let mut engine = EngineState::new();
436        engine.set_history_json(&cube_and_sketch_history()).unwrap();
437        let info: serde_json::Value =
438            serde_json::from_str(&engine.object_info_json("Sk")).unwrap();
439        assert_eq!(info["ok"], true, "sketch info must not error: {info}");
440        assert_eq!(info["kind"], "sketch");
441        assert!(info.get("volume").is_none(), "a sheet has no volume");
442        assert!((info["area"].as_f64().unwrap() - 60.0).abs() < 1e-3, "area {}", info["area"]);
443        assert!(
444            (info["edgeLengthTotal"].as_f64().unwrap() - 32.0).abs() < 1e-3,
445            "perimeter {}",
446            info["edgeLengthTotal"]
447        );
448        assert_eq!(info["creatingFeature"]["id"], "Sk");
449        assert_eq!(info["creatingFeature"]["type"], "S");
450    }
451
452    /// A boundary edge of the sheet is a normal selectable/measurable edge: its Info
453    /// reports `kind:"edge"` and the segment length (a 10 or 6 mm rectangle side).
454    #[test]
455    fn sketch_sheet_edge_is_measurable() {
456        let mut engine = EngineState::new();
457        engine.set_history_json(&cube_and_sketch_history()).unwrap();
458        let edge_name = sketch_sheet(&engine, "Sk").unwrap().edges[0].name.clone();
459        assert!(!edge_name.is_empty(), "boundary edge is named");
460        let info: serde_json::Value =
461            serde_json::from_str(&engine.object_info_json(&edge_name)).unwrap();
462        assert_eq!(info["ok"], true, "edge info: {info}");
463        assert_eq!(info["kind"], "edge");
464        assert_eq!(info["solid"], "Sk");
465        let length = info["length"].as_f64().unwrap();
466        assert!(
467            (length - 10.0).abs() < 1e-3 || (length - 6.0).abs() < 1e-3,
468            "rectangle side length {length}"
469        );
470    }
471
472    /// The sheet is selectable BY NAME through the shared solid selection (the
473    /// viewport pick and the Scene tree both route here), so a sketch highlights
474    /// like any solid.
475    #[test]
476    fn sketch_sheet_is_selectable_by_name() {
477        let mut engine = EngineState::new();
478        engine.set_history_json(&cube_and_sketch_history()).unwrap();
479        assert!(engine.select_by_name("solid", "Sk"));
480        let sel: serde_json::Value = serde_json::from_str(&engine.selection_json()).unwrap();
481        assert_eq!(sel["solids"][0], "Sk");
482    }
483
484    /// A two-feature history: a committed closed-rectangle sketch on the XY plane
485    /// (feature 0), then an extrude of it (feature 1). `consume` sets the extrude's
486    /// `consumeProfileSketch` — `true` consumes the sketch (removed from the scene),
487    /// `false` keeps it.
488    fn sketch_then_extrude_history(consume: bool) -> String {
489        serde_json::json!({
490            "features": [
491                {
492                    "type": "S",
493                    "inputParams": { "id": "Sk" },
494                    "persistentData": {
495                        "basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
496                        "sketch": {
497                            "points": [
498                                { "id": 0, "x": 0.0,  "y": 0.0 },
499                                { "id": 1, "x": 10.0, "y": 0.0 },
500                                { "id": 2, "x": 10.0, "y": 6.0 },
501                                { "id": 3, "x": 0.0,  "y": 6.0 }
502                            ],
503                            "geometries": [
504                                { "id": 10, "type": "line", "points": [0, 1] },
505                                { "id": 11, "type": "line", "points": [1, 2] },
506                                { "id": 12, "type": "line", "points": [2, 3] },
507                                { "id": 13, "type": "line", "points": [3, 0] }
508                            ],
509                            "constraints": []
510                        }
511                    }
512                },
513                {
514                    "type": "E",
515                    "inputParams": {
516                        "id": "Ext",
517                        "profile": "Sk",
518                        "distance": 4.0,
519                        "consumeProfileSketch": consume
520                    },
521                    "persistentData": {}
522                }
523            ]
524        })
525        .to_string()
526    }
527
528    /// An extrude that CONSUMES its profile (`consumeProfileSketch` true) makes the
529    /// consumed sketch vanish from the scene — no committed listing and no
530    /// synthesized sheet solid — exactly like a consumed solid. The extrude result
531    /// stands as a real solid.
532    #[test]
533    fn extrude_consumed_sketch_absent_from_scene() {
534        let mut engine = EngineState::new();
535        engine
536            .set_history_json(&sketch_then_extrude_history(true))
537            .unwrap();
538        assert!(
539            engine.committed_sketches().is_empty(),
540            "a consumed sketch is not listed: {:?}",
541            engine.committed_sketches()
542        );
543        assert!(
544            !has_sketch_sheet(&engine, "Sk"),
545            "a consumed sketch has no synthesized sheet"
546        );
547        assert!(engine.scene.solid("Ext").is_some(), "the extrude solid is present");
548    }
549
550    /// With `consumeProfileSketch=false` the extrude keeps its profile: the sketch is
551    /// NOT consumed, so it remains a listed committed sketch WITH its sheet.
552    #[test]
553    fn extrude_kept_sketch_remains_in_scene() {
554        let mut engine = EngineState::new();
555        engine
556            .set_history_json(&sketch_then_extrude_history(false))
557            .unwrap();
558        assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
559        assert!(has_sketch_sheet(&engine, "Sk"), "a kept sketch still has a sheet");
560    }
561
562    /// A sketch consumed by a feature ABOVE the rollback point is NOT YET consumed:
563    /// rolling to the sketch (before its extrude) shows it; rolling forward to the
564    /// extrude consumes it — the removed signal is read only over the rolled-to
565    /// prefix.
566    #[test]
567    fn sketch_consumed_above_rollback_still_shows() {
568        let mut engine = EngineState::new();
569        engine
570            .set_history_json(&sketch_then_extrude_history(true))
571            .unwrap();
572        // Loaded rolled to the extrude (index 1): consumed → absent.
573        assert!(engine.committed_sketches().is_empty());
574        assert!(!has_sketch_sheet(&engine, "Sk"));
575
576        // Roll back to the sketch (index 0), BEFORE its consumer: it shows again.
577        engine.roll_to(0);
578        assert_eq!(engine.committed_sketches(), vec![("Sk".to_string(), true)]);
579        assert!(
580            has_sketch_sheet(&engine, "Sk"),
581            "sketch shows while its consumer is above the rollback"
582        );
583
584        // Roll forward to the extrude: consumed again.
585        engine.roll_to(1);
586        assert!(engine.committed_sketches().is_empty());
587        assert!(!has_sketch_sheet(&engine, "Sk"));
588    }
589}
590