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