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