Skip to main content

brep_render/engine_state/
sketch_mode.rs

1use super::*;
2
3/// One in-flight sketch drag (S2): the grabbed points and the anchor uv. A single
4/// entry for a point grab; all of a geometry's (deduped) points for a geometry
5/// grab (a rigid translate). Each point tracks the cursor by an ABSOLUTE
6/// `orig + (uv - anchor)` (never incremental — the solver moves points between
7/// frames, so incremental deltas would drift).
8#[derive(Clone)]
9pub struct SketchDrag {
10    /// Per grabbed point: `(id, orig_x, orig_y, orig_fixed)` captured once at grab.
11    pub(super) points: Vec<(serde_json::Value, f64, f64, bool)>,
12    /// The plane `(u, v)` where the grab started — the anchor for the delta.
13    pub(super) anchor: (f64, f64),
14}
15
16/// The active engine-native sketch edit — the live session plus the state to
17/// restore when sketch mode exits.
18pub struct SketchEdit {
19    /// The live solved sketch session (the doc being edited + its plane).
20    pub session: crate::sketch::SketchSession,
21    /// The id of the `"S"` feature this edit belongs to.
22    pub feature_id: String,
23    /// The rolled-to step to restore on exit (the model state before entering).
24    prev_rollback: usize,
25    /// The camera to restore on exit (the vantage before the plane orient).
26    prev_camera: crate::view::ViewCamera,
27    /// Whether this sketch was created THIS edit (so a cancel deletes it).
28    is_new: bool,
29    /// For a sketch created by this session: the undo step its creation took
30    /// (`History::undo_top` right after the push). Finish writes into that
31    /// step and Cancel retracts it, while nothing else has taken a step since,
32    /// so a new sketch is one undo step and a cancelled one is none.
33    creation_step: Option<u64>,
34    /// The in-flight drag (S2): the grabbed points + anchor, or `None` when no drag
35    /// is live. Grabs a point OR a whole geometry (rigid translate). See [`SketchDrag`].
36    pub(super) drag: Option<SketchDrag>,
37    /// The in-progress draw-tool click buffer (S3a): the point ids placed so far for
38    /// the geometry being drawn (a line-chain's running start, a rect/circle/arc's
39    /// first clicks). Cleared when a geometry lands, the tool changes, or on cancel.
40    pub(super) pending: Vec<serde_json::Value>,
41    /// The last hovered plane `(u, v)` (S3a) — the anchor for the rubber-band preview
42    /// so it follows the cursor while a tool has pending clicks.
43    pub(super) hover_uv: Option<(f64, f64)>,
44    /// Per-session UNDO stack (S6a): snapshots of the visible edit state taken at the
45    /// START of each discrete mutating op. Separate from the model-level undo (which
46    /// owns the FEATURE document). Cleared implicitly on enter/exit (a fresh
47    /// `SketchEdit` starts empty; exit drops the whole struct).
48    pub(super) undo_stack: Vec<SketchSnapshot>,
49    /// Per-session REDO stack (S6a): states popped by `sketch_undo`, restored by
50    /// `sketch_redo`. Cleared whenever a new mutation records an undo snapshot.
51    pub(super) redo_stack: Vec<SketchSnapshot>,
52    /// First-move guard for a dimension-label DRAG (S6a): set when the drag's single
53    /// undo snapshot has been taken, reset at the gesture end
54    /// ([`sketch_dimension_drag_end`](EngineState::sketch_dimension_drag_end)) so the
55    /// whole drag is ONE undo step, not one per motion frame.
56    pub(super) dim_drag_snapshotted: bool,
57    /// The external-reference edge links (S6b-2): one [`crate::sketch::ExternalRef`]
58    /// per linked scene edge (by name), mapping it to the materialized sketch points +
59    /// construction geometry. Loaded from `persistentData.externalRefs` on enter,
60    /// written back on commit, and captured in the undo snapshot so undo restores it
61    /// alongside the doc.
62    pub(super) external_refs: Vec<crate::sketch::ExternalRef>,
63    /// The in-progress freehand stroke (S6b-3): raw plane `(u, v)` samples captured
64    /// during a handdraw drag, recognized into geometry on drag-end. Empty when no
65    /// stroke is live. Transient interaction state — NOT part of the undo snapshot.
66    pub(super) handdraw_stroke: Vec<(f64, f64)>,
67}
68
69/// One reversible sketch-edit state (S6a): a clone of everything the visible edit
70/// carries — the solved [`SketchDoc`](crate::sketch::SketchDoc), the dimension label
71/// offsets, and the selection — enough to fully restore the session on undo/redo.
72/// Transient interaction state (in-flight drag, draw pending buffer) is deliberately
73/// NOT captured; undo/redo clears it instead.
74#[derive(Clone)]
75pub(super) struct SketchSnapshot {
76    pub(super) doc: crate::sketch::SketchDoc,
77    pub(super) dim_offsets: serde_json::Map<String, serde_json::Value>,
78    pub(super) selection: Vec<serde_json::Value>,
79    /// The external-reference mapping (S6b-2) — captured so undoing a pickEdges link
80    /// also drops its `ExternalRef` entry, keeping the mapping consistent with the doc.
81    pub(super) external_refs: Vec<crate::sketch::ExternalRef>,
82}
83
84impl SketchEdit {
85    /// A snapshot of the CURRENT visible edit state (doc + dim offsets + selection +
86    /// external refs).
87    pub(super) fn snapshot(&self) -> SketchSnapshot {
88        SketchSnapshot {
89            doc: self.session.doc.clone(),
90            dim_offsets: self.session.dim_offsets.clone(),
91            selection: self.session.selection.clone(),
92            external_refs: self.external_refs.clone(),
93        }
94    }
95
96    /// Push the current state onto the undo stack and clear redo — the discrete-op
97    /// undo primitive. Callers invoke this at the START of a mutation (before the doc
98    /// changes) so one Ctrl+Z reverts that op.
99    pub(super) fn record_undo(&mut self) {
100        let snap = self.snapshot();
101        self.undo_stack.push(snap);
102        self.redo_stack.clear();
103    }
104}
105
106/// Convert a kernel [`brep_kernel::Frame`] into the sketch module's
107/// [`crate::sketch::PlaneFrame`]. Kept HERE (not in `sketch/mod.rs`, which is
108/// deliberately kernel-free) so a sketch's kernel-resolved plane frame — the exact
109/// frame the committed sheet is materialized against — can seed the live session.
110fn plane_frame_from_kernel(frame: &brep_kernel::Frame) -> crate::sketch::PlaneFrame {
111    crate::sketch::PlaneFrame {
112        origin: [frame.origin.x, frame.origin.y, frame.origin.z],
113        x_axis: [frame.x_axis.x, frame.x_axis.y, frame.x_axis.z],
114        y_axis: [frame.y_axis.x, frame.y_axis.y, frame.y_axis.z],
115        z_axis: [frame.z_axis.x, frame.z_axis.y, frame.z_axis.z],
116    }
117}
118
119impl EngineState {
120    /// True while a sketch is being edited (the shell hides the normal side panel
121    /// and shows the sketch-mode bar).
122    pub fn sketch_mode(&self) -> bool {
123        self.sketch_edit.is_some()
124    }
125
126    /// The live sketch session while in sketch mode (for the DOF readout / overlay
127    /// / the headless verifier), else `None`.
128    pub fn sketch_edit_session(&self) -> Option<&crate::sketch::SketchSession> {
129        self.sketch_edit.as_ref().map(|edit| &edit.session)
130    }
131
132    /// The id of the feature being edited while in sketch mode, else `None`.
133    pub fn sketch_edit_feature_id(&self) -> Option<&str> {
134        self.sketch_edit.as_ref().map(|edit| edit.feature_id.as_str())
135    }
136
137    /// Whether the sketch camera is locked flat to the plane (only panning). On by
138    /// default every sketch entry; the sketch-mode bar's checkbox reflects this.
139    pub fn sketch_camera_locked(&self) -> bool {
140        self.sketch_camera_locked
141    }
142
143    /// Toggle the sketch camera lock. Turning it ON re-faces the camera to the
144    /// current sketch plane (so "off → spin around → on" snaps back flat); turning
145    /// it OFF just frees orbiting. No-op outside sketch mode.
146    pub fn toggle_sketch_camera_lock(&mut self) {
147        if !self.sketch_mode() {
148            return;
149        }
150        self.sketch_camera_locked = !self.sketch_camera_locked;
151        if self.sketch_camera_locked {
152            // `PlaneFrame` is `Copy`, so this ends the `&self` borrow before the
153            // `&mut self` re-orient.
154            if let Some(plane) = self.sketch_edit_session().map(|s| s.plane) {
155                self.face_camera_to_plane(&plane);
156                self.dirty = true;
157            }
158        }
159    }
160
161    /// Orient the camera flat-on to a sketch plane: look along the NEGATIVE normal
162    /// with the plane's +y as up, so the plane faces the viewer. Shared by sketch
163    /// entry and the lock toggle.
164    fn face_camera_to_plane(&mut self, plane: &crate::sketch::PlaneFrame) {
165        let dir = [
166            -(plane.z_axis[0] as f32),
167            -(plane.z_axis[1] as f32),
168            -(plane.z_axis[2] as f32),
169        ];
170        let up = [
171            plane.y_axis[0] as f32,
172            plane.y_axis[1] as f32,
173            plane.y_axis[2] as f32,
174        ];
175        self.apply_look_direction(dir, up);
176    }
177
178    /// Enter sketch mode for the `"S"` feature with id `feature_id`: snapshot the
179    /// camera + rolled-to step, roll to the step BEFORE the sketch (its backdrop),
180    /// read the persisted plane `basis` + `sketch` doc off the history JSON, solve
181    /// a live session, orient the camera onto the plane, and push the read-only
182    /// overlay. Returns the session's diagnostics JSON. Errors when the feature is
183    /// absent or is not a sketch.
184    pub fn enter_sketch_mode(&mut self, feature_id: &str) -> Result<String, String> {
185        let index = self
186            .history
187            .index_of(feature_id)
188            .ok_or_else(|| format!("no feature with id '{feature_id}'"))?;
189        if self.history.feature_type(index).as_deref() != Some("S") {
190            return Err(format!("feature '{feature_id}' is not a sketch (type \"S\")"));
191        }
192
193        // The frame the kernel resolved for THIS sketch on the last full run is
194        // published under the sketch's own id in `construction_frames` (see
195        // `feature_pipeline::features::sketch::execute` → `result.frames`). It is, by
196        // construction, the EXACT frame the committed sheet was materialized against
197        // (the sheet's world curves come from the same `SketchProfile` frame). Capture
198        // it NOW, before the roll below drops the sketch from `construction_frames` —
199        // seeding the live session with it keeps the editing overlay on the same plane
200        // the committed sheet lands on. This fixes the off-location sheet for
201        // face/datum-attached sketches whose live-resolved frame differs from the
202        // persisted `basis` (which the kernel only uses as a missing-reference fallback).
203        let resolved_frame = self
204            .construction_frames
205            .iter()
206            .find(|(name, _)| name == feature_id)
207            .map(|(_, frame)| plane_frame_from_kernel(frame));
208
209        // Snapshot the pre-entry view + roll, then roll to the step just before the
210        // sketch so the model up to (not including) it is the sketching backdrop.
211        let prev_camera = self.camera.clone();
212        let prev_rollback = self.history.rollback();
213        self.history.set_rollback(index.saturating_sub(1));
214        self.rerun_history();
215
216        // Read the persisted plane + doc straight off the history JSON (headless).
217        // The plane is the kernel-resolved frame when available (live-first, matching
218        // the SKETCH feature's `resolve_frame`), else the persisted `basis`, else XY.
219        let persistent = self.history.feature_persistent_data(index);
220        let plane = resolved_frame
221            .or_else(|| {
222                persistent
223                    .as_ref()
224                    .and_then(|p| p.get("basis"))
225                    .map(crate::sketch::PlaneFrame::from_basis_json)
226            })
227            .unwrap_or_else(crate::sketch::PlaneFrame::xy);
228        let doc_value = persistent
229            .as_ref()
230            .and_then(|p| p.get("sketch"))
231            .cloned()
232            .unwrap_or_else(|| {
233                serde_json::json!({ "points": [], "geometries": [], "constraints": [] })
234            });
235        let mut doc: crate::sketch::SketchDoc = serde_json::from_value(doc_value)
236            .map_err(|error| format!("sketch doc parse: {error}"))?;
237        // Every sketch carries the part origin projected onto its plane, as a
238        // fixed reference to constrain to (added on first entry, re-projected
239        // when the plane moved).
240        crate::sketch::external_ref::ensure_origin_point(&mut doc, &plane);
241        let mut session = crate::sketch::SketchSession::new(doc, plane)?;
242        // Seed the overlay palette from the live display settings so the sketch colors
243        // are managed there like the rest of the display (kept in sync by
244        // `apply_settings_json` whenever the user edits a sketch color).
245        session.colors = self.settings.sketch_colors();
246        // Load the persisted per-dimension label offsets (S5) — plane-space
247        // `{du, dv}` keyed by constraint id, written back on commit below.
248        if let Some(offsets) = persistent
249            .as_ref()
250            .and_then(|p| p.get("dimOffsets"))
251            .and_then(serde_json::Value::as_object)
252        {
253            session.dim_offsets = offsets.clone();
254        }
255        // Load the persisted external-reference edge links (S6b-2) — the mapping from
256        // linked scene edges to their materialized points/geometry, round-tripped on
257        // commit below. A malformed array degrades to no refs.
258        let mut external_refs: Vec<crate::sketch::ExternalRef> = persistent
259            .as_ref()
260            .and_then(|p| p.get("externalRefs"))
261            .and_then(|v| serde_json::from_value(v.clone()).ok())
262            .unwrap_or_default();
263        // Heal docs poisoned by the pre-fix delete: drop any linked-edge entry whose
264        // materialized points/geometry are gone, so its edge can be RE-LINKED (a stale
265        // entry's dead dedup path would otherwise block the re-link forever). Reproject
266        // (below) then only touches live refs.
267        crate::sketch::external_ref::prune_dead_refs(&session.doc, &mut external_refs);
268
269        // Drop any MODEL selection carried in from picking the sketch plane, so no
270        // faces/edges stay highlighted inside the sketch — the only highlight the
271        // user should see there is the sketch's own geometry (or an entity they
272        // pick to link in). Covers the new-sketch path too (`new_sketch` enters
273        // through here).
274        self.clear_selection();
275
276        // Every sketch entry starts with the camera LOCKED flat to the plane (the
277        // default): face it now, and `face_camera_to_plane` re-does this when the
278        // user toggles the lock back on.
279        self.sketch_camera_locked = true;
280        self.face_camera_to_plane(&plane);
281
282        // Push the overlay + capture diagnostics BEFORE moving the session into the
283        // edit (both are borrows of the local `session`).
284        self.set_sketch_overlay(&session);
285        let diagnostics =
286            serde_json::to_string(&session.diagnostics).unwrap_or_else(|_| "{}".to_string());
287
288        self.sketch_edit = Some(SketchEdit {
289            session,
290            feature_id: feature_id.to_string(),
291            prev_rollback,
292            prev_camera,
293            is_new: false,
294            creation_step: None,
295            drag: None,
296            pending: Vec::new(),
297            hover_uv: None,
298            // A fresh session starts with empty undo/redo history (S6a).
299            undo_stack: Vec::new(),
300            redo_stack: Vec::new(),
301            dim_drag_snapshotted: false,
302            external_refs,
303            handdraw_stroke: Vec::new(),
304        });
305        // Re-project the loaded external refs against the CURRENT backdrop model so a
306        // linked edge tracks upstream geometry edits (best-effort; a no-op when the
307        // edge is absent — the persisted coords then stand as the fallback). This may
308        // move the ref points + re-solve, so re-read diagnostics afterward.
309        let reprojected = self.sketch_reproject_external_refs();
310        let diagnostics = if reprojected {
311            self.sketch_edit
312                .as_ref()
313                .map(|edit| {
314                    serde_json::to_string(&edit.session.diagnostics)
315                        .unwrap_or_else(|_| "{}".to_string())
316                })
317                .unwrap_or(diagnostics)
318        } else {
319            diagnostics
320        };
321        // Clear the now-active sketch's committed overlay: the mid-enter rerun above
322        // (rolled to the step before the sketch, `sketch_edit` not yet set) may have
323        // fed it; now `sketch_edit` is armed, so it is excluded and cleared — the live
324        // editing overlay is its only display while editing (no double display).
325        self.refresh_committed_sketches();
326        self.dirty = true;
327        Ok(diagnostics)
328    }
329
330    /// Exit sketch mode. `commit` writes the edited doc back to the feature's
331    /// `persistentData.sketch`; a non-commit exit of a brand-new sketch DELETES the
332    /// feature (a fresh sketch nobody kept). Restores the snapshotted camera +
333    /// rolled-to step, clears the overlay, re-runs the history, and returns the
334    /// build report. A no-op (returns `"{}"`) when not in sketch mode.
335    pub fn exit_sketch_mode(&mut self, commit: bool) -> String {
336        let Some(edit) = self.sketch_edit.take() else {
337            return "{}".to_string();
338        };
339        if commit {
340            if let Some(index) = self.history.index_of(&edit.feature_id) {
341                let mut sketch_value = serde_json::to_value(&edit.session.doc).unwrap_or_else(|_| {
342                    serde_json::json!({ "points": [], "geometries": [], "constraints": [] })
343                });
344                // Stamp each closed loop's STABLE id onto its geometries. The
345                // kernel derives the same ids when it reads the sketch, so this
346                // changes no name — it persists the id against the one edit
347                // deriving cannot survive: deleting the edge the id came from.
348                // Per-loop face names (extrude/sweep/revolve/loft caps) embed it.
349                brep_kernel::assign_sketch_loop_ids(&mut sketch_value);
350                // The dimension label offsets persist alongside the doc (S5) so a
351                // re-enter restores each dimension's dragged label position, and
352                // the external-reference edge links (S6b-2) so a re-enter reloads
353                // (and re-projects) each linked scene edge.
354                let refs_value = serde_json::to_value(&edit.external_refs)
355                    .unwrap_or_else(|_| serde_json::Value::Array(Vec::new()));
356                // One write, so Finish is ONE undo step (and none when the
357                // session changed nothing). A sketch this session created
358                // writes into its creation's step, so the whole new sketch is
359                // one step.
360                let fields = vec![
361                    ("sketch", sketch_value),
362                    ("dimOffsets", serde_json::Value::Object(edit.session.dim_offsets.clone())),
363                    ("externalRefs", refs_value),
364                ];
365                match edit.creation_step {
366                    Some(step) => {
367                        self.history.set_feature_persistent_fields_in_step(index, fields, step);
368                    }
369                    None => {
370                        self.history.set_feature_persistent_fields(index, fields);
371                    }
372                }
373            }
374        } else if edit.is_new {
375            // Retract the creation's step, so the cancel leaves nothing to
376            // undo; only if something else took a step since, delete the
377            // feature as a step of its own.
378            if edit.creation_step.is_some_and(|step| self.history.retract(step)) {
379                self.reinstall_rewound_parts_library();
380            } else if let Some(index) = self.history.index_of(&edit.feature_id) {
381                self.history.remove_feature(index);
382            }
383        }
384        self.history.set_rollback(edit.prev_rollback);
385        self.clear_sketch_overlay();
386        self.camera = edit.prev_camera;
387        self.dirty = true;
388        self.rerun_history()
389    }
390
391    /// Create a NEW engine-native sketch on a base plane (`"XY" | "XZ" | "YZ"`) and
392    /// enter sketch mode on it. The feature persists an analytic `basis` (computed
393    /// via [`crate::sketch::PlaneFrame`]) and an empty `sketch` doc; a cancel exit
394    /// deletes it. Returns the new session's diagnostics JSON. Errors on an unknown
395    /// plane name.
396    pub fn new_sketch(&mut self, plane: &str) -> Result<String, String> {
397        let frame = match plane {
398            "XY" => crate::sketch::PlaneFrame::xy(),
399            "XZ" => crate::sketch::PlaneFrame::xz(),
400            "YZ" => crate::sketch::PlaneFrame::yz(),
401            other => {
402                return Err(format!("unknown base plane '{other}' (expected XY|XZ|YZ)"));
403            }
404        };
405        let id = self
406            .history
407            .next_feature_id(&crate::features::feature_short_name("S"));
408        let feature = serde_json::json!({
409            "type": "S",
410            "inputParams": { "id": id, "sketchPlane": plane },
411            "persistentData": {
412                "basis": {
413                    "origin": frame.origin,
414                    "x": frame.x_axis,
415                    "y": frame.y_axis,
416                    "z": frame.z_axis,
417                },
418                "sketch": { "points": [], "geometries": [], "constraints": [] }
419            }
420        });
421        self.history.push_feature(feature);
422        let creation_step = self.history.undo_top();
423        let diagnostics = self.enter_sketch_mode(&id)?;
424        if let Some(edit) = self.sketch_edit.as_mut() {
425            edit.is_new = true;
426            edit.creation_step = creation_step;
427        }
428        Ok(diagnostics)
429    }
430}