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