Skip to main content

brep_render/engine_state/
sketch3d_mode.rs

1//! The engine-native 3D SKETCH mode: entering and leaving the edit of an
2//! `S3D` feature, the cursor-to-3D mapping, the draw tools, model reference
3//! linking, the constraint edits and the per-session undo.
4//!
5//! The planar sketch mode (`sketch_mode.rs`) is the model; what differs is
6//! the geometry: there is no plane, so a click lands in 3D on the sketch's
7//! own points, on model geometry under the cursor, or on the WORKING PLANE
8//! (one of XY / YZ / XZ through the last placed point), and a drag moves a
9//! point in the screen-parallel plane through where it was grabbed. Picking
10//! is in screen space ([`crate::sketch3d::Sketch3dSession::pick_entity`]).
11//!
12//! A model entity linked into the sketch (the `pickRef` tool, or a snap onto a
13//! vertex / edge / face while placing a point) becomes fixed construction
14//! geometry — a point, a line, an arc or a reference plane — recorded in
15//! `persistentData.externalRefs` by name so the `S3D` feature re-resolves it
16//! against the live scene on every run
17//! ([`brep_kernel::resolve_sketch3d_reference`]). The editor materializes it
18//! from the DISPLAY scene (vertex positions, edge polylines, face planes), the
19//! feature from the exact BREP; both describe the same entity, and the feature
20//! keeps the editor's plane origin / axis point by projecting it onto the
21//! resolved plane / line, so a regenerate moves nothing the user can see.
22
23use super::*;
24use crate::sketch3d::{Sketch3dDoc, Sketch3dSession, WorkPlane};
25use serde_json::{json, Value};
26
27/// One in-flight 3D sketch drag: the grabbed points and the anchor world point
28/// on the drag plane (screen-parallel through the grab).
29#[derive(Clone)]
30pub struct Sketch3dDrag {
31    /// Per grabbed point: `(id, original position, original fixed flag)`.
32    pub(super) points: Vec<(Value, [f64; 3], bool)>,
33    /// The drag plane through the grab point, normal to the view direction.
34    pub(super) plane: crate::sketch::PlaneFrame,
35    /// Where on the plane the grab started.
36    pub(super) anchor: [f64; 3],
37}
38
39/// What a cursor position resolved to when placing a point.
40#[derive(Clone, Debug, PartialEq)]
41pub enum Sketch3dCursorHit {
42    /// An existing sketch point (snap by id).
43    SketchPoint(Value),
44    /// A model VERTEX of `solid` at `position` (reference name `{solid}@x,y,z`).
45    Vertex { solid: String, position: [f64; 3] },
46    /// A point on a model EDGE (`name`), at the closest point under the cursor.
47    Edge { name: String, position: [f64; 3] },
48    /// A point on a planar model FACE (`name`).
49    Face { name: String, position: [f64; 3] },
50    /// A point on the working plane.
51    WorkPlane([f64; 3]),
52}
53
54impl Sketch3dCursorHit {
55    pub fn kind(&self) -> &'static str {
56        match self {
57            Sketch3dCursorHit::SketchPoint(_) => "point",
58            Sketch3dCursorHit::Vertex { .. } => "vertex",
59            Sketch3dCursorHit::Edge { .. } => "edge",
60            Sketch3dCursorHit::Face { .. } => "face",
61            Sketch3dCursorHit::WorkPlane(_) => "workPlane",
62        }
63    }
64}
65
66/// The active 3D sketch edit.
67pub struct Sketch3dEdit {
68    pub session: Sketch3dSession,
69    pub feature_id: String,
70    prev_rollback: usize,
71    is_new: bool,
72    creation_step: Option<u64>,
73    pub(super) drag: Option<Sketch3dDrag>,
74    /// The armed tool's placed point ids.
75    pub(super) pending: Vec<Value>,
76    /// The last cursor world point (the rubber band's free end).
77    pub(super) hover_world: Option<[f64; 3]>,
78    pub(super) undo_stack: Vec<Sketch3dSnapshot>,
79    pub(super) redo_stack: Vec<Sketch3dSnapshot>,
80    /// The reference materialization records (`persistentData.externalRefs`):
81    /// `{name, kind, points, geometry}` per linked entity.
82    pub(super) external_refs: Vec<Value>,
83    /// Names the dialog's `references` list carried at enter that the scene
84    /// could not materialize; Finish keeps them listed.
85    pub(super) listed_unlinked: Vec<String>,
86    /// The last cursor hit while a tool is armed, for the status readout.
87    pub(super) last_hit: Option<&'static str>,
88}
89
90#[derive(Clone)]
91pub(super) struct Sketch3dSnapshot {
92    doc: Sketch3dDoc,
93    selection: Vec<Value>,
94    external_refs: Vec<Value>,
95    work_plane: WorkPlane,
96}
97
98impl Sketch3dEdit {
99    fn snapshot(&self) -> Sketch3dSnapshot {
100        Sketch3dSnapshot {
101            doc: self.session.doc.clone(),
102            selection: self.session.selection.items.clone(),
103            external_refs: self.external_refs.clone(),
104            work_plane: self.session.work_plane.clone(),
105        }
106    }
107
108    pub(super) fn record_undo(&mut self) {
109        let snap = self.snapshot();
110        self.undo_stack.push(snap);
111        self.redo_stack.clear();
112    }
113
114    fn restore(&mut self, snap: Sketch3dSnapshot) {
115        self.session.doc = snap.doc;
116        self.session.selection.items = snap.selection;
117        self.session.selection.hovered = None;
118        self.external_refs = snap.external_refs;
119        self.session.work_plane = snap.work_plane;
120        self.pending.clear();
121        self.drag = None;
122        let _ = self.session.resolve();
123    }
124}
125
126/// The reference materialization record's shape, built here and read by the
127/// kernel feature (`brep_kernel::sketch3d_reference_record` is the one author
128/// of the fields; this wraps it for the editor's `Value` ids).
129fn reference_record(name: &str, resolved: &brep_kernel::Sketch3dResolvedReference, points: &[Value], geometry: Option<&Value>) -> Value {
130    brep_kernel::sketch3d_reference_record(name, resolved, points, geometry)
131}
132
133impl EngineState {
134    /// True while a 3D sketch is being edited.
135    pub fn sketch3d_mode(&self) -> bool {
136        self.sketch3d_edit.is_some()
137    }
138
139    /// Either sketch editor is open — what the shell keys its sketch takeover
140    /// on (the dock hides, the mode-exit card shows, undo routes to the
141    /// session).
142    pub fn any_sketch_mode(&self) -> bool {
143        self.sketch_mode() || self.sketch3d_mode()
144    }
145
146    pub fn sketch3d_edit_session(&self) -> Option<&Sketch3dSession> {
147        self.sketch3d_edit.as_ref().map(|edit| &edit.session)
148    }
149
150    pub fn sketch3d_edit_feature_id(&self) -> Option<&str> {
151        self.sketch3d_edit.as_ref().map(|edit| edit.feature_id.as_str())
152    }
153
154    /// Enter 3D sketch mode on the `S3D` feature `feature_id`: snapshot the
155    /// camera + rolled-to step, roll to the step before the sketch (its
156    /// backdrop), load the document and the reference records, materialize
157    /// every listed reference the backdrop can see, solve, and show the
158    /// overlay. Returns the diagnostics JSON.
159    pub fn enter_sketch3d_mode(&mut self, feature_id: &str) -> Result<String, String> {
160        if self.sketch_mode() {
161            return Err("a planar sketch is being edited; finish it first".into());
162        }
163        if self.sketch3d_mode() {
164            return Err("a 3D sketch is already being edited".into());
165        }
166        let index = self
167            .history
168            .index_of(feature_id)
169            .ok_or_else(|| format!("no feature with id '{feature_id}'"))?;
170        let ty = self.history.feature_type(index).unwrap_or_default();
171        if ty != brep_kernel::SKETCH3D_FEATURE_TYPE {
172            return Err(format!("feature '{feature_id}' is not a 3D sketch (type \"S3D\")"));
173        }
174
175        // The camera is left alone on entry (there is no plane to face) and
176        // therefore on exit: every orbit made while sketching stands.
177        let prev_rollback = self.history.rollback();
178        self.history.set_rollback(index.saturating_sub(1));
179        self.rerun_history();
180
181        let persistent = self.history.feature_persistent_data(index);
182        let doc_value = persistent
183            .as_ref()
184            .and_then(|p| p.get(brep_kernel::SKETCH3D_DOC_KEY))
185            .cloned()
186            .unwrap_or_else(|| json!({ "points": [], "geometries": [], "constraints": [] }));
187        let doc: Sketch3dDoc = serde_json::from_value(doc_value)
188            .map_err(|error| format!("3D sketch doc parse: {error}"))?;
189        let mut session = Sketch3dSession::new(doc)?;
190        session.colors = self.settings.sketch_colors();
191        if let Some(name) = persistent
192            .as_ref()
193            .and_then(|p| p.get("workPlane"))
194            .and_then(Value::as_str)
195        {
196            if let Some(plane) = WorkPlane::new(name, [0.0; 3]) {
197                session.work_plane = plane;
198            }
199        }
200        let external_refs: Vec<Value> = persistent
201            .as_ref()
202            .and_then(|p| p.get(brep_kernel::SKETCH3D_REFS_KEY))
203            .and_then(Value::as_array)
204            .cloned()
205            .unwrap_or_default();
206        let listed = brep_kernel::reference_names(
207            self.history
208                .feature_params(index)
209                .as_ref()
210                .and_then(|p| p.get(brep_kernel::SKETCH3D_REFERENCES_PARAM)),
211        );
212
213        self.clear_selection();
214        self.set_sketch3d_overlay(&session);
215        self.sketch3d_edit = Some(Sketch3dEdit {
216            session,
217            feature_id: feature_id.to_string(),
218            prev_rollback,
219            is_new: false,
220            creation_step: None,
221            drag: None,
222            pending: Vec::new(),
223            hover_world: None,
224            undo_stack: Vec::new(),
225            redo_stack: Vec::new(),
226            external_refs,
227            listed_unlinked: Vec::new(),
228            last_hit: None,
229        });
230        // Sync the links with the dialog's list against the backdrop: every
231        // record re-materializes (so a linked entity tracks an upstream edit),
232        // a listed name without a record is linked, and a name the backdrop
233        // cannot see is remembered for Finish.
234        self.sketch3d_sync_references(&listed);
235        self.refresh_committed_sketches();
236        self.refresh_sketch3d_overlay();
237        self.dirty = true;
238        Ok(self.sketch3d_diagnostics_json())
239    }
240
241    /// Exit 3D sketch mode. `commit` writes the document, the reference
242    /// records and the working plane back; a cancel of a sketch this session
243    /// created deletes it. Restores the camera + rolled-to step and re-runs.
244    pub fn exit_sketch3d_mode(&mut self, commit: bool) -> String {
245        let Some(edit) = self.sketch3d_edit.take() else {
246            return "{}".to_string();
247        };
248        if commit {
249            if let Some(index) = self.history.index_of(&edit.feature_id) {
250                let sketch_value = serde_json::to_value(&edit.session.doc)
251                    .unwrap_or_else(|_| json!({ "points": [], "geometries": [], "constraints": [] }));
252                let refs_value = Value::Array(edit.external_refs.clone());
253                let fields = vec![
254                    (brep_kernel::SKETCH3D_DOC_KEY, sketch_value),
255                    (brep_kernel::SKETCH3D_REFS_KEY, refs_value),
256                    ("workPlane", Value::String(edit.session.work_plane.name.clone())),
257                ];
258                match edit.creation_step {
259                    Some(step) => {
260                        self.history.set_feature_persistent_fields_in_step(index, fields, step);
261                    }
262                    None => {
263                        self.history.set_feature_persistent_fields(index, fields);
264                    }
265                }
266                // The dialog's list names every link: listed order kept,
267                // dropped links gone, unmaterialized names kept, new links
268                // appended. Folded without a checkpoint so Finish is one step.
269                let current = self.history.feature_params(index);
270                let listed = brep_kernel::reference_names(
271                    current.as_ref().and_then(|p| p.get(brep_kernel::SKETCH3D_REFERENCES_PARAM)),
272                );
273                let linked: Vec<String> = edit
274                    .external_refs
275                    .iter()
276                    .filter_map(|r| r.get("name").and_then(Value::as_str).map(String::from))
277                    .collect();
278                let mut names: Vec<String> = listed
279                    .iter()
280                    .filter(|n| linked.contains(n) || edit.listed_unlinked.contains(n))
281                    .cloned()
282                    .collect();
283                for name in &linked {
284                    if !names.contains(name) {
285                        names.push(name.clone());
286                    }
287                }
288                if names != listed {
289                    self.history.fold_param_no_undo(
290                        &edit.feature_id,
291                        brep_kernel::SKETCH3D_REFERENCES_PARAM,
292                        Value::Array(names.into_iter().map(Value::String).collect()),
293                    );
294                }
295            }
296        } else if edit.is_new {
297            if edit.creation_step.is_some_and(|step| self.history.retract(step)) {
298                self.reinstall_rewound_parts_library();
299            } else if let Some(index) = self.history.index_of(&edit.feature_id) {
300                self.history.remove_feature(index);
301            }
302        }
303        self.history.set_rollback(edit.prev_rollback);
304        self.clear_sketch3d_overlay();
305        self.dirty = true;
306        self.rerun_history()
307    }
308
309    /// Create a NEW 3D sketch feature and enter its edit. A cancel exit
310    /// deletes it. Returns the diagnostics JSON.
311    pub fn new_sketch3d(&mut self) -> Result<String, String> {
312        let id = self
313            .history
314            .next_feature_id(&crate::features::feature_short_name(brep_kernel::SKETCH3D_FEATURE_TYPE));
315        let feature = json!({
316            "type": brep_kernel::SKETCH3D_FEATURE_TYPE,
317            "inputParams": { "id": id },
318            "persistentData": {
319                brep_kernel::SKETCH3D_DOC_KEY: { "points": [], "geometries": [], "constraints": [] }
320            }
321        });
322        self.history.push_feature(feature);
323        let creation_step = self.history.undo_top();
324        let diagnostics = self.enter_sketch3d_mode(&id)?;
325        if let Some(edit) = self.sketch3d_edit.as_mut() {
326            edit.is_new = true;
327            edit.creation_step = creation_step;
328        }
329        Ok(diagnostics)
330    }
331
332    // --- overlay -------------------------------------------------------------
333
334    fn set_sketch3d_overlay(&mut self, session: &Sketch3dSession) {
335        let wpp = self.camera.world_per_pixel();
336        let _ = self.set_overlay_json(&session.overlay_json(wpp));
337        let _ = self.set_overlay_json(&session.workplane_overlay_json(wpp));
338        self.sketch3d_overlay_wpp = if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE };
339    }
340
341    pub(super) fn refresh_sketch3d_overlay(&mut self) {
342        let wpp = self.camera.world_per_pixel();
343        let (geometry, preview, workplane) = match self.sketch3d_edit.as_ref() {
344            Some(edit) => (
345                edit.session.overlay_json(wpp),
346                edit.session.preview_overlay_json(&edit.pending, edit.hover_world),
347                edit.session.workplane_overlay_json(wpp),
348            ),
349            None => return,
350        };
351        let _ = self.set_overlay_json(&geometry);
352        let _ = self.set_overlay_json(&preview);
353        let _ = self.set_overlay_json(&workplane);
354        self.sketch3d_overlay_wpp = if wpp > 0.0 { wpp } else { f64::MIN_POSITIVE };
355    }
356
357    /// Per-frame upkeep: the working-plane square and the reference-plane
358    /// squares are sized in pixels, so a material zoom re-bakes them.
359    pub(super) fn ensure_sketch3d_overlay_current(&mut self) {
360        if !self.sketch3d_mode() {
361            self.sketch3d_overlay_wpp = 0.0;
362            return;
363        }
364        let wpp = self.camera.world_per_pixel();
365        if super::overlay_wpp_stale(self.sketch3d_overlay_wpp, wpp) {
366            self.refresh_sketch3d_overlay();
367        }
368    }
369
370    pub fn clear_sketch3d_overlay(&mut self) {
371        let groups: Vec<Value> = crate::sketch3d::OVERLAY_GROUPS
372            .iter()
373            .map(|name| json!({ "name": name }))
374            .collect();
375        let _ = self.set_overlay_json(&json!({ "groups": groups }).to_string());
376        self.sketch3d_overlay_wpp = 0.0;
377    }
378
379    // --- readback ------------------------------------------------------------
380
381    pub fn sketch3d_diagnostics_json(&self) -> String {
382        self.sketch3d_edit
383            .as_ref()
384            .and_then(|edit| serde_json::to_string(&edit.session.diagnostics).ok())
385            .unwrap_or_else(|| "{}".to_string())
386    }
387
388    /// The `__brepSketch3d` state: mode, feature, DOF readout, tool, working
389    /// plane, counts, selection, the palette and the labels.
390    pub fn sketch3d_state_json(&self) -> String {
391        let Some(edit) = self.sketch3d_edit.as_ref() else {
392            return json!({ "sketch3dMode": false }).to_string();
393        };
394        let session = &edit.session;
395        json!({
396            "sketch3dMode": true,
397            "featureId": edit.feature_id,
398            "dof": session.diagnostics.dof,
399            "status": session.diagnostics.status,
400            "conflicting": session.diagnostics.conflicting,
401            "conflictingConstraints": session.diagnostics.conflicting_constraints,
402            "redundant": session.diagnostics.redundant,
403            "tool": session.tool.clone().unwrap_or_else(|| "select".into()),
404            "workPlane": session.work_plane.name,
405            "workPlaneAnchor": session.work_plane.anchor,
406            "pointCount": session.doc.points.len(),
407            "geometryCount": session.doc.geometries.len(),
408            "constraintCount": session.doc.constraints.len(),
409            "pendingLen": edit.pending.len(),
410            "selection": session.selection.items,
411            "hovered": session.selection.hovered,
412            "applicableConstraints": session.applicable_constraints(),
413            "dimensions": session.dimension_labels(),
414            "externalRefs": edit.external_refs,
415            "lastHit": edit.last_hit,
416            "canUndo": !edit.undo_stack.is_empty(),
417            "canRedo": !edit.redo_stack.is_empty(),
418            "entities": session.entity_rows_json(),
419        })
420        .to_string()
421    }
422
423    /// The dimension labels as JSON (the same shape as the planar sketch's).
424    pub fn sketch3d_dimension_labels_json(&self) -> String {
425        self.sketch3d_edit
426            .as_ref()
427            .and_then(|edit| serde_json::to_string(&edit.session.dimension_labels()).ok())
428            .unwrap_or_else(|| "[]".to_string())
429    }
430
431    pub fn sketch3d_active_tool(&self) -> Option<&str> {
432        self.sketch3d_edit.as_ref().and_then(|edit| edit.session.tool.as_deref())
433    }
434
435    pub fn sketch3d_selection_count(&self) -> usize {
436        self.sketch3d_edit.as_ref().map_or(0, |edit| edit.session.selection.items.len())
437    }
438
439    pub fn sketch3d_pending_len(&self) -> usize {
440        self.sketch3d_edit.as_ref().map_or(0, |edit| edit.pending.len())
441    }
442
443    pub fn sketch3d_work_plane(&self) -> Option<&str> {
444        self.sketch3d_edit.as_ref().map(|edit| edit.session.work_plane.name.as_str())
445    }
446
447    // --- tools ---------------------------------------------------------------
448
449    /// Arm a draw tool (`point`, `line`, `arc`, `bezier`, `pickRef`); `select`
450    /// / `None` returns to selection. Errors on an unknown tool.
451    pub fn sketch3d_set_tool(&mut self, tool: Option<&str>) -> Result<(), String> {
452        let tool = match tool {
453            None | Some("select") | Some("") => None,
454            Some(name) if crate::sketch3d::TOOLS.contains(&name) => Some(name.to_string()),
455            Some(other) => return Err(format!("unknown 3D sketch tool '{other}'")),
456        };
457        let Some(edit) = self.sketch3d_edit.as_mut() else {
458            return Err("not in 3D sketch mode".into());
459        };
460        edit.session.tool = tool;
461        edit.pending.clear();
462        edit.drag = None;
463        self.refresh_sketch3d_overlay();
464        self.dirty = true;
465        Ok(())
466    }
467
468    /// Drop the in-progress placement (right-click / Escape with clicks
469    /// pending) without changing the armed tool.
470    pub fn sketch3d_tool_cancel(&mut self) {
471        if let Some(edit) = self.sketch3d_edit.as_mut() {
472            edit.pending.clear();
473        }
474        self.refresh_sketch3d_overlay();
475        self.dirty = true;
476    }
477
478    /// Set the working plane by name (`XY` / `YZ` / `XZ`); it keeps passing
479    /// through the current anchor.
480    pub fn sketch3d_set_work_plane(&mut self, name: &str) -> Result<(), String> {
481        let Some(edit) = self.sketch3d_edit.as_mut() else {
482            return Err("not in 3D sketch mode".into());
483        };
484        let anchor = edit.session.work_plane.anchor;
485        edit.session.work_plane =
486            WorkPlane::new(name, anchor).ok_or_else(|| format!("unknown working plane '{name}' (expected XY|YZ|XZ)"))?;
487        self.refresh_sketch3d_overlay();
488        self.dirty = true;
489        Ok(())
490    }
491
492    /// Cycle the working plane `XY → YZ → XZ → XY` (the Tab key).
493    pub fn sketch3d_cycle_work_plane(&mut self) {
494        let next = self
495            .sketch3d_edit
496            .as_ref()
497            .map(|edit| edit.session.work_plane.next_name());
498        if let Some(next) = next {
499            let _ = self.sketch3d_set_work_plane(next);
500        }
501    }
502
503    fn sketch3d_pick_radius_px(&self) -> f64 {
504        f64::from(crate::sketch::tessellate::POINT_SIZE_PX) * 1.5
505    }
506
507    /// What the cursor at CSS-pixel `(x, y)` lands on when PLACING a point:
508    /// an existing sketch point within the grab radius, else a model vertex /
509    /// edge / planar face under the cursor (the pick ranking, committed
510    /// sketch displays excluded), else the working plane.
511    pub fn sketch3d_cursor_hit(&self, x: f64, y: f64) -> Option<Sketch3dCursorHit> {
512        let edit = self.sketch3d_edit.as_ref()?;
513        let radius = self.sketch3d_pick_radius_px();
514        if let Some(entity) = edit.session.pick_entity(&self.camera, x, y, radius) {
515            if entity.get("kind").and_then(Value::as_str) == Some("point") {
516                return entity.get("id").cloned().map(Sketch3dCursorHit::SketchPoint);
517            }
518        }
519        for candidate in crate::pick::pick_unoccluded(&self.scene, &self.context_scene, &self.camera, x, y, &self.pick_options()) {
520            let Some(solid) = self.scene.solids().iter().find(|s| s.name == candidate.solid) else {
521                continue;
522            };
523            if solid.is_sketch || !solid.visible {
524                continue;
525            }
526            match candidate.kind {
527                crate::pick::PickKind::Vertex => {
528                    return Some(Sketch3dCursorHit::Vertex {
529                        solid: candidate.solid,
530                        position: candidate.position,
531                    });
532                }
533                crate::pick::PickKind::Edge if !candidate.name.is_empty() => {
534                    return Some(Sketch3dCursorHit::Edge {
535                        name: candidate.name,
536                        position: candidate.position,
537                    });
538                }
539                crate::pick::PickKind::Face if !candidate.name.is_empty() => {
540                    if self.scene.face_is_planar(&candidate.name) {
541                        return Some(Sketch3dCursorHit::Face {
542                            name: candidate.name,
543                            position: candidate.position,
544                        });
545                    }
546                    // A curved face is not a plane to land on; fall through
547                    // to the working plane.
548                    break;
549                }
550                _ => continue,
551            }
552        }
553        let ray = self.camera.pick_ray(x, y);
554        let frame = edit.session.work_plane.frame();
555        let (u, v) = crate::sketch::ray_plane_uv(&frame, ray.origin, ray.dir)?;
556        Some(Sketch3dCursorHit::WorkPlane(frame.to_world(u, v)))
557    }
558
559    /// The world point of a cursor hit.
560    fn sketch3d_hit_world(&self, hit: &Sketch3dCursorHit) -> Option<[f64; 3]> {
561        match hit {
562            Sketch3dCursorHit::SketchPoint(id) => self.sketch3d_edit.as_ref()?.session.point_world(id),
563            Sketch3dCursorHit::Vertex { position, .. }
564            | Sketch3dCursorHit::Edge { position, .. }
565            | Sketch3dCursorHit::Face { position, .. }
566            | Sketch3dCursorHit::WorkPlane(position) => Some(*position),
567        }
568    }
569
570    /// The world point the cursor would place at `(x, y)` (the readback /
571    /// rubber band), `None` when the ray misses the working plane.
572    pub fn sketch3d_cursor_world(&self, x: f64, y: f64) -> Option<[f64; 3]> {
573        let hit = self.sketch3d_cursor_hit(x, y)?;
574        self.sketch3d_hit_world(&hit)
575    }
576
577    /// Resolve the cursor into a point id for the armed tool: an existing
578    /// sketch point is reused; a model vertex / edge / face hit places a point
579    /// there AND links the entity (coincident / on-line / on-plane); a working
580    /// plane hit places a free point. The working plane then moves to the
581    /// placed point.
582    fn sketch3d_place_point(&mut self, hit: Sketch3dCursorHit) -> Option<Value> {
583        let world = self.sketch3d_hit_world(&hit)?;
584        let id = match &hit {
585            Sketch3dCursorHit::SketchPoint(id) => id.clone(),
586            _ => {
587                let edit = self.sketch3d_edit.as_mut()?;
588                let radius = 0.0;
589                edit.session.doc.snap_or_add_point(brep_kernel::Vec3::new(world[0], world[1], world[2]), radius)
590            }
591        };
592        match &hit {
593            Sketch3dCursorHit::Vertex { solid, position } => {
594                let name = format!("{solid}@{},{},{}", fmt_coord(position[0]), fmt_coord(position[1]), fmt_coord(position[2]));
595                if let Some(ids) = self.sketch3d_link_reference_silent(&name) {
596                    if let Some(edit) = self.sketch3d_edit.as_mut() {
597                        if let Some(ref_point) = ids.first() {
598                            edit.session.doc.add_constraint("coincident", vec![id.clone(), ref_point.clone()], None);
599                        }
600                    }
601                }
602            }
603            Sketch3dCursorHit::Edge { name, .. } => {
604                if let Some(ids) = self.sketch3d_link_reference_silent(name) {
605                    if let Some(edit) = self.sketch3d_edit.as_mut() {
606                        match ids.len() {
607                            // A line: on the infinite line through its ends.
608                            2 => {
609                                edit.session.doc.add_constraint("onLine", vec![id.clone(), ids[0].clone(), ids[1].clone()], None);
610                            }
611                            // An arc [center, start, end]: at the arc's radius
612                            // from its center (the on-arc relation this sketch
613                            // can state).
614                            3 => {
615                                let radius = edit
616                                    .session
617                                    .measure("distance", &[ids[0].clone(), ids[1].clone()])
618                                    .unwrap_or(0.0);
619                                edit.session.doc.add_constraint("distance", vec![id.clone(), ids[0].clone()], Some(radius));
620                            }
621                            _ => {}
622                        }
623                    }
624                }
625            }
626            Sketch3dCursorHit::Face { name, .. } => {
627                if let Some(ids) = self.sketch3d_link_reference_silent(name) {
628                    if let Some(edit) = self.sketch3d_edit.as_mut() {
629                        if ids.len() == 2 {
630                            edit.session.doc.add_constraint("onPlane", vec![id.clone(), ids[0].clone(), ids[1].clone()], None);
631                        }
632                    }
633                }
634            }
635            _ => {}
636        }
637        if let Some(edit) = self.sketch3d_edit.as_mut() {
638            edit.session.work_plane.anchor = world;
639            edit.last_hit = Some(hit.kind());
640        }
641        Some(id)
642    }
643
644    /// A click with a draw tool armed: place the next point of the geometry
645    /// being drawn; when the tool has all its points, create the geometry.
646    /// `line` chains: each click after the first lands a segment and starts
647    /// the next from its end.
648    pub fn sketch3d_tool_click_at(&mut self, x: f64, y: f64) {
649        let Some(tool) = self.sketch3d_active_tool().map(String::from) else {
650            return;
651        };
652        if tool == "pickRef" {
653            self.sketch3d_pick_reference_at(x, y);
654            return;
655        }
656        let Some(hit) = self.sketch3d_cursor_hit(x, y) else {
657            return;
658        };
659        if let Some(edit) = self.sketch3d_edit.as_mut() {
660            edit.record_undo();
661        }
662        let Some(id) = self.sketch3d_place_point(hit) else {
663            return;
664        };
665        let Some(edit) = self.sketch3d_edit.as_mut() else {
666            return;
667        };
668        // A repeat click on the pending point is a no-op.
669        if edit.pending.last().map_or(false, |last| brep_kernel::sketch_id_key(last) == brep_kernel::sketch_id_key(&id)) {
670            edit.undo_stack.pop();
671            return;
672        }
673        edit.pending.push(id);
674        match tool.as_str() {
675            "point" => {
676                let id = edit.pending.pop().unwrap();
677                edit.session.doc.add_geometry("point", vec![id], false);
678            }
679            "line" if edit.pending.len() >= 2 => {
680                let b = edit.pending.pop().unwrap();
681                let a = edit.pending.pop().unwrap();
682                edit.session.doc.add_geometry("line", vec![a, b.clone()], false);
683                edit.pending.push(b);
684            }
685            "arc" if edit.pending.len() >= 3 => {
686                let pts = std::mem::take(&mut edit.pending);
687                edit.session.doc.add_geometry("arc", pts, false);
688            }
689            "bezier" if edit.pending.len() >= 4 => {
690                let pts = std::mem::take(&mut edit.pending);
691                edit.session.doc.add_geometry("bezier", pts, false);
692            }
693            _ => {}
694        }
695        let _ = edit.session.resolve();
696        // Nothing changed beyond a pending click: the snapshot still stands so
697        // the whole geometry is one undo step, which Escape or the next click
698        // completes.
699        self.refresh_sketch3d_overlay();
700        self.dirty = true;
701    }
702
703    // --- references ----------------------------------------------------------
704
705    /// The `pickRef` tool: link the model entity under the cursor (vertex,
706    /// edge, planar face or datum plane) into the sketch as fixed construction
707    /// geometry. Returns whether something was linked.
708    pub fn sketch3d_pick_reference_at(&mut self, x: f64, y: f64) -> bool {
709        let candidates = self.pick_candidates_at(x, y);
710        let mut name: Option<String> = None;
711        for candidate in candidates {
712            match candidate.kind {
713                crate::pick::PickKind::Vertex => {
714                    if self.scene.solids().iter().any(|s| s.name == candidate.solid && !s.is_sketch) {
715                        let p = candidate.position;
716                        name = Some(format!("{}@{},{},{}", candidate.solid, fmt_coord(p[0]), fmt_coord(p[1]), fmt_coord(p[2])));
717                        break;
718                    }
719                }
720                crate::pick::PickKind::Edge | crate::pick::PickKind::Face | crate::pick::PickKind::Plane
721                    if !candidate.name.is_empty() =>
722                {
723                    if candidate.kind == crate::pick::PickKind::Plane
724                        || self.scene.solids().iter().any(|s| s.name == candidate.solid && !s.is_sketch)
725                    {
726                        name = Some(candidate.name);
727                        break;
728                    }
729                }
730                _ => {}
731            }
732        }
733        let Some(name) = name else { return false };
734        self.sketch3d_link_reference(&name)
735    }
736
737    /// Link a model entity BY NAME (a `{solid}@x,y,z` vertex, an edge or face
738    /// name, a datum / plane frame name): materialize it as fixed construction
739    /// geometry and record it. Re-linking an already linked name updates its
740    /// coordinates in place. Returns whether it resolved.
741    pub fn sketch3d_link_reference(&mut self, name: &str) -> bool {
742        if let Some(edit) = self.sketch3d_edit.as_mut() {
743            edit.record_undo();
744        }
745        let linked = self.sketch3d_link_reference_silent(name).is_some();
746        if !linked {
747            if let Some(edit) = self.sketch3d_edit.as_mut() {
748                edit.undo_stack.pop();
749            }
750        }
751        if let Some(edit) = self.sketch3d_edit.as_mut() {
752            let _ = edit.session.resolve();
753        }
754        self.refresh_sketch3d_overlay();
755        self.dirty = true;
756        linked
757    }
758
759    /// Materialize (or update) a reference without an undo snapshot or
760    /// overlay refresh; returns the record's point ids.
761    fn sketch3d_link_reference_silent(&mut self, name: &str) -> Option<Vec<Value>> {
762        let resolved = self.sketch3d_resolve_reference_from_display(name)?;
763        let edit = self.sketch3d_edit.as_mut()?;
764        let positions = resolved.positions();
765        // Update an existing record of the same kind in place.
766        if let Some(record) = edit
767            .external_refs
768            .iter()
769            .find(|r| r.get("name").and_then(Value::as_str) == Some(name))
770            .cloned()
771        {
772            let ids: Vec<Value> = record.get("points").and_then(Value::as_array).cloned().unwrap_or_default();
773            if record.get("kind").and_then(Value::as_str) == Some(resolved.kind()) && ids.len() == positions.len() {
774                for (id, p) in ids.iter().zip(&positions) {
775                    if let Some(point) = edit.session.doc.point_mut(id) {
776                        point.x = p.x;
777                        point.y = p.y;
778                        point.z = p.z;
779                        point.fixed = true;
780                        point.construction = true;
781                        point.external_reference = true;
782                    }
783                }
784                return Some(ids);
785            }
786            // The shape changed: drop the old materialization and rebuild.
787            edit.external_refs.retain(|r| r.get("name").and_then(Value::as_str) != Some(name));
788            for id in &ids {
789                edit.session.doc.points.retain(|p| brep_kernel::sketch_id_key(&p.id) != brep_kernel::sketch_id_key(id));
790            }
791            if let Some(gid) = record.get("geometry").filter(|g| !g.is_null()) {
792                edit.session.doc.geometries.retain(|g| brep_kernel::sketch_id_key(&g.id) != brep_kernel::sketch_id_key(gid));
793            }
794        }
795        let mut ids: Vec<Value> = Vec::with_capacity(positions.len());
796        for p in &positions {
797            let id = edit.session.doc.add_point(*p);
798            if let Some(point) = edit.session.doc.point_mut(&id) {
799                point.fixed = true;
800                point.construction = true;
801                point.external_reference = true;
802            }
803            ids.push(id);
804        }
805        let geometry = resolved
806            .geometry_type()
807            .map(|ty| edit.session.doc.add_geometry(ty, ids.clone(), true));
808        edit.external_refs.push(reference_record(name, &resolved, &ids, geometry.as_ref()));
809        Some(ids)
810    }
811
812    /// Resolve a reference name against the DISPLAY scene (the backdrop as
813    /// drawn): a datum / plane frame, a planar face, an edge polyline
814    /// classified as line / arc / circle axis, or a `{solid}@x,y,z` vertex.
815    fn sketch3d_resolve_reference_from_display(&self, name: &str) -> Option<brep_kernel::Sketch3dResolvedReference> {
816        use brep_kernel::{Sketch3dResolvedReference as R, Vec3, SKETCH3D_REFERENCE_TIP_LENGTH as TIP};
817        let v = |a: [f64; 3]| Vec3::new(a[0], a[1], a[2]);
818        if let Some((_, frame)) = self.construction_frames.iter().find(|(n, _)| n == name) {
819            return Some(R::Plane(frame.origin, frame.origin.add(frame.z_axis.scale(TIP))));
820        }
821        if let Some((_, axis)) = self.sketch_axes.iter().find(|(n, _)| n == name) {
822            return Some(R::Line(axis.point, axis.point.add(axis.direction.scale(TIP))));
823        }
824        if let Some((_, point)) = self.sketch_points.iter().find(|(n, _)| n == name) {
825            return Some(R::Point(point.position));
826        }
827        if let Some((centroid, normal)) = self.scene.face_plane_world(name) {
828            if !self.scene.face_is_planar(name) {
829                return None;
830            }
831            return Some(R::Plane(v(centroid), v(centroid).add(v(normal).scale(TIP))));
832        }
833        if let Some(polyline) = self.scene.edge_polyline_world(name) {
834            return classify_edge_polyline(&polyline);
835        }
836        if let Some((solid_name, coords)) = name.rsplit_once('@') {
837            let mut parts = coords.split(',').map(|p| p.trim().parse::<f64>().ok());
838            let position = [parts.next()??, parts.next()??, parts.next()??];
839            let solid = self.scene.solids().iter().find(|s| s.name == solid_name)?;
840            let best = solid
841                .vertices
842                .iter()
843                .map(|vertex| vertex.position)
844                .min_by(|a, b| dist(*a, position).total_cmp(&dist(*b, position)))?;
845            if dist(best, position) > 1e-4 * (1.0 + best.iter().fold(0.0f64, |m, c| m.max(c.abs()))) {
846                return None;
847            }
848            return Some(R::Point(v(best)));
849        }
850        None
851    }
852
853    /// Sync the links with the dialog's `references` list against the current
854    /// backdrop: a record the list no longer names was removed in the dialog
855    /// and is UNLINKED (its construction geometry and the constraints on it
856    /// go); every remaining record re-materializes (so a linked entity tracks
857    /// an upstream edit); every listed name without a record is linked; and
858    /// the listed names the backdrop cannot see are remembered for Finish.
859    /// The list is authoritative from the first Finish, which is what wrote
860    /// it: a record can only exist because a Finish listed its name.
861    fn sketch3d_sync_references(&mut self, listed: &[String]) {
862        let names: Vec<String> = self
863            .sketch3d_edit
864            .as_ref()
865            .map(|edit| {
866                edit.external_refs
867                    .iter()
868                    .filter_map(|r| r.get("name").and_then(Value::as_str).map(String::from))
869                    .collect()
870            })
871            .unwrap_or_default();
872        for name in &names {
873            if !listed.contains(name) {
874                self.sketch3d_unlink_reference(name);
875                if let Some(edit) = self.sketch3d_edit.as_mut() {
876                    // An enter-time release is not an edit the user made.
877                    edit.undo_stack.clear();
878                    edit.redo_stack.clear();
879                }
880            }
881        }
882        let names: Vec<String> = names.into_iter().filter(|n| listed.contains(n)).collect();
883        for name in &names {
884            let _ = self.sketch3d_link_reference_silent(name);
885        }
886        let mut unlinked = Vec::new();
887        for name in listed {
888            if names.contains(name) {
889                continue;
890            }
891            if self.sketch3d_link_reference_silent(name).is_none() {
892                unlinked.push(name.clone());
893            }
894        }
895        if let Some(edit) = self.sketch3d_edit.as_mut() {
896            edit.listed_unlinked = unlinked;
897            let _ = edit.session.resolve();
898        }
899    }
900
901    /// Drop a linked reference by name: its record, its construction geometry
902    /// and points, and every constraint touching them.
903    pub fn sketch3d_unlink_reference(&mut self, name: &str) -> bool {
904        let Some(edit) = self.sketch3d_edit.as_mut() else { return false };
905        let Some(record) = edit
906            .external_refs
907            .iter()
908            .find(|r| r.get("name").and_then(Value::as_str) == Some(name))
909            .cloned()
910        else {
911            return false;
912        };
913        edit.record_undo();
914        edit.external_refs.retain(|r| r.get("name").and_then(Value::as_str) != Some(name));
915        let ids: Vec<Value> = record.get("points").and_then(Value::as_array).cloned().unwrap_or_default();
916        let gid = record.get("geometry").filter(|g| !g.is_null()).cloned();
917        let mut refs: Vec<Value> = ids.iter().map(crate::sketch3d::entity_point_ref).collect();
918        if let Some(gid) = gid {
919            refs.push(crate::sketch3d::entity_geometry_ref(&gid));
920        }
921        edit.session.selection.items = refs;
922        self.sketch3d_delete_selection();
923        true
924    }
925
926    // --- selection / hover / drag --------------------------------------------
927
928    pub fn sketch3d_hover_at(&mut self, x: f64, y: f64) -> bool {
929        if self.sketch3d_edit.is_none() {
930            return false;
931        }
932        let world = self.sketch3d_cursor_world(x, y);
933        let radius = self.sketch3d_pick_radius_px();
934        let new_hover = self
935            .sketch3d_edit
936            .as_ref()
937            .and_then(|edit| edit.session.pick_entity(&self.camera, x, y, radius));
938        let (changed, preview_live) = match self.sketch3d_edit.as_mut() {
939            Some(edit) => {
940                let changed = !crate::sketch::session::entity_ref_eq(edit.session.selection.hovered.as_ref(), new_hover.as_ref());
941                edit.session.selection.hovered = new_hover;
942                let moved = edit.hover_world != world;
943                edit.hover_world = world;
944                (changed, edit.session.tool.is_some() && moved)
945            }
946            None => (false, false),
947        };
948        if changed || preview_live {
949            self.refresh_sketch3d_overlay();
950            self.dirty = true;
951        }
952        changed
953    }
954
955    pub fn sketch3d_clear_hover(&mut self) -> bool {
956        let changed = self
957            .sketch3d_edit
958            .as_mut()
959            .map_or(false, |edit| {
960                let had = edit.session.selection.hovered.take().is_some() || edit.hover_world.take().is_some();
961                had
962            });
963        if changed {
964            self.refresh_sketch3d_overlay();
965            self.dirty = true;
966        }
967        changed
968    }
969
970    /// Click-select: the entity under the cursor replaces (or, additive /
971    /// click-toggles, toggles in) the selection; empty space clears it.
972    pub fn sketch3d_click_at(&mut self, x: f64, y: f64, additive: bool) {
973        let toggles = self.settings.multi_select == crate::style::MultiSelectMode::ClickToggles;
974        let radius = self.sketch3d_pick_radius_px();
975        let hit = self
976            .sketch3d_edit
977            .as_ref()
978            .and_then(|edit| edit.session.pick_entity(&self.camera, x, y, radius));
979        let Some(edit) = self.sketch3d_edit.as_mut() else { return };
980        match hit {
981            None => edit.session.clear_selection(),
982            Some(entity) => {
983                if additive || toggles {
984                    edit.session.toggle_selection(entity);
985                } else {
986                    edit.session.clear_selection();
987                    edit.session.toggle_selection(entity);
988                }
989            }
990        }
991        self.refresh_sketch3d_overlay();
992        self.dirty = true;
993    }
994
995    /// Select an entity by ref from a list row (replacing the selection).
996    pub fn sketch3d_select_entity(&mut self, entity_ref: Value, additive: bool) {
997        if let Some(edit) = self.sketch3d_edit.as_mut() {
998            if !additive {
999                edit.session.clear_selection();
1000            }
1001            edit.session.toggle_selection(entity_ref);
1002        }
1003        self.refresh_sketch3d_overlay();
1004        self.dirty = true;
1005    }
1006
1007    pub fn sketch3d_clear_selection(&mut self) {
1008        if let Some(edit) = self.sketch3d_edit.as_mut() {
1009            edit.session.clear_selection();
1010        }
1011        self.refresh_sketch3d_overlay();
1012        self.dirty = true;
1013    }
1014
1015    /// Begin a drag of the hovered (or picked) draggable entity. Returns
1016    /// whether something was grabbed; otherwise the drag is the camera's.
1017    pub fn sketch3d_drag_begin(&mut self, x: f64, y: f64) -> bool {
1018        let radius = self.sketch3d_pick_radius_px();
1019        let points = self.sketch3d_edit.as_ref().and_then(|edit| match edit.session.selection.hovered.as_ref() {
1020            Some(entity) => edit.session.drag_points_from_ref(entity),
1021            None => edit
1022                .session
1023                .pick_draggable_point(&self.camera, x, y, radius)
1024                .and_then(|(id, fixed)| edit.session.point_world(&id).map(|p| vec![(id, p, fixed)])),
1025        });
1026        let Some(points) = points else { return false };
1027        // The drag plane: through the first grabbed point, facing the camera.
1028        let (_, _, forward) = self.camera.basis();
1029        let plane = crate::sketch::PlaneFrame::from_normal(points[0].1, [-forward[0], -forward[1], -forward[2]]);
1030        let ray = self.camera.pick_ray(x, y);
1031        let Some((u, v)) = crate::sketch::ray_plane_uv(&plane, ray.origin, ray.dir) else {
1032            return false;
1033        };
1034        let anchor = plane.to_world(u, v);
1035        if let Some(edit) = self.sketch3d_edit.as_mut() {
1036            edit.record_undo();
1037            edit.drag = Some(Sketch3dDrag { points, plane, anchor });
1038        }
1039        true
1040    }
1041
1042    /// Drag the grabbed points to the cursor: each is pinned at its original
1043    /// position plus the cursor delta on the drag plane, the sketch re-solved,
1044    /// and the original fixed flags restored.
1045    pub fn sketch3d_drag_to(&mut self, x: f64, y: f64) {
1046        let ray = self.camera.pick_ray(x, y);
1047        let Some(edit) = self.sketch3d_edit.as_mut() else { return };
1048        let Some(drag) = edit.drag.clone() else { return };
1049        let Some((u, v)) = crate::sketch::ray_plane_uv(&drag.plane, ray.origin, ray.dir) else {
1050            return;
1051        };
1052        let here = drag.plane.to_world(u, v);
1053        let delta = [here[0] - drag.anchor[0], here[1] - drag.anchor[1], here[2] - drag.anchor[2]];
1054        let session = &mut edit.session;
1055        for (id, orig, _) in &drag.points {
1056            if let Some(p) = session.doc.point_mut(id) {
1057                p.x = orig[0] + delta[0];
1058                p.y = orig[1] + delta[1];
1059                p.z = orig[2] + delta[2];
1060                p.fixed = true;
1061            }
1062        }
1063        match session.resolve() {
1064            Ok(()) => {
1065                for (id, _, fixed) in &drag.points {
1066                    if let Some(p) = session.doc.point_mut(id) {
1067                        p.fixed = *fixed;
1068                    }
1069                }
1070            }
1071            Err(_) => {
1072                for (id, orig, fixed) in &drag.points {
1073                    if let Some(p) = session.doc.point_mut(id) {
1074                        p.x = orig[0];
1075                        p.y = orig[1];
1076                        p.z = orig[2];
1077                        p.fixed = *fixed;
1078                    }
1079                }
1080            }
1081        }
1082        self.refresh_sketch3d_overlay();
1083        self.dirty = true;
1084    }
1085
1086    pub fn sketch3d_drag_end(&mut self) {
1087        let Some(edit) = self.sketch3d_edit.as_mut() else { return };
1088        if edit.drag.take().is_none() {
1089            return;
1090        }
1091        let _ = edit.session.resolve();
1092        if edit.undo_stack.last().map_or(false, |snap| snap.doc == edit.session.doc) {
1093            edit.undo_stack.pop();
1094        }
1095        self.refresh_sketch3d_overlay();
1096        self.dirty = true;
1097    }
1098
1099    // --- edits ---------------------------------------------------------------
1100
1101    /// Add a constraint of `ctype` over the palette's point ids for the
1102    /// current selection (`sketch3d_applicable_constraints`); a dimensional
1103    /// one is seeded with the current measurement. Returns the constraint id.
1104    pub fn sketch3d_add_constraint(&mut self, ctype: &str) -> Result<Value, String> {
1105        let Some(edit) = self.sketch3d_edit.as_mut() else {
1106            return Err("not in 3D sketch mode".into());
1107        };
1108        let offer = edit
1109            .session
1110            .applicable_constraints()
1111            .into_iter()
1112            .find(|c| c.ctype == ctype)
1113            .ok_or_else(|| format!("constraint '{ctype}' does not apply to the current selection"))?;
1114        edit.record_undo();
1115        if ctype == "fixed" {
1116            for id in &offer.points {
1117                if let Some(p) = edit.session.doc.point_mut(id) {
1118                    p.fixed = true;
1119                }
1120            }
1121            let _ = edit.session.resolve();
1122            self.refresh_sketch3d_overlay();
1123            self.dirty = true;
1124            return Ok(Value::Null);
1125        }
1126        let value = if offer.dimensional {
1127            edit.session.measure(ctype, &offer.points)
1128        } else {
1129            None
1130        };
1131        let id = edit.session.doc.add_constraint(ctype, offer.points, value);
1132        if let Err(error) = edit.session.resolve() {
1133            // An unsolvable addition is taken back.
1134            if let Some(snap) = edit.undo_stack.pop() {
1135                edit.restore(snap);
1136            }
1137            return Err(error);
1138        }
1139        self.refresh_sketch3d_overlay();
1140        self.dirty = true;
1141        Ok(id)
1142    }
1143
1144    /// Add a constraint over EXPLICIT point ids (automation / tests).
1145    pub fn sketch3d_add_constraint_over(&mut self, ctype: &str, points: Vec<Value>, value: Option<f64>) -> Result<Value, String> {
1146        let Some(edit) = self.sketch3d_edit.as_mut() else {
1147            return Err("not in 3D sketch mode".into());
1148        };
1149        edit.record_undo();
1150        let id = edit.session.doc.add_constraint(ctype, points, value);
1151        if let Err(error) = edit.session.resolve() {
1152            if let Some(snap) = edit.undo_stack.pop() {
1153                edit.restore(snap);
1154            }
1155            return Err(error);
1156        }
1157        self.refresh_sketch3d_overlay();
1158        self.dirty = true;
1159        Ok(id)
1160    }
1161
1162    /// The palette for the current selection, as JSON.
1163    pub fn sketch3d_applicable_constraints_json(&self) -> String {
1164        self.sketch3d_edit
1165            .as_ref()
1166            .and_then(|edit| serde_json::to_string(&edit.session.applicable_constraints()).ok())
1167            .unwrap_or_else(|| "[]".to_string())
1168    }
1169
1170    /// Set a dimensional constraint's value (a number, or an expression
1171    /// string kept as `valueExpr` and evaluated now against the document's
1172    /// expressions). Re-solves; an unsolvable value is taken back.
1173    pub fn sketch3d_set_dimension_value(&mut self, id: &Value, value: &str) -> Result<(), String> {
1174        let trimmed = value.trim();
1175        let number: f64 = match trimmed.parse::<f64>() {
1176            Ok(n) => n,
1177            Err(_) => brep_kernel::eval_expression(
1178                &self.history.expressions(),
1179                &self.history.configurator(),
1180                trimmed,
1181            )
1182            .map_err(|e| format!("dimension `{trimmed}`: {e}"))?,
1183        };
1184        if !number.is_finite() {
1185            return Err(format!("dimension `{trimmed}` is not finite"));
1186        }
1187        let Some(edit) = self.sketch3d_edit.as_mut() else {
1188            return Err("not in 3D sketch mode".into());
1189        };
1190        edit.record_undo();
1191        {
1192            let key = brep_kernel::sketch_id_key(id);
1193            let Some(constraint) = edit
1194                .session
1195                .doc
1196                .constraints
1197                .iter_mut()
1198                .find(|c| c.id().map(brep_kernel::sketch_id_key) == Some(key.clone()))
1199            else {
1200                edit.undo_stack.pop();
1201                return Err(format!("no constraint with id {key}"));
1202            };
1203            constraint.raw.insert("value".into(), Value::from(number));
1204            if trimmed.parse::<f64>().is_ok() {
1205                constraint.raw.remove("valueExpr");
1206            } else {
1207                constraint.raw.insert("valueExpr".into(), Value::String(trimmed.to_string()));
1208            }
1209        }
1210        if let Err(error) = edit.session.resolve() {
1211            if let Some(snap) = edit.undo_stack.pop() {
1212                edit.restore(snap);
1213            }
1214            return Err(error);
1215        }
1216        self.refresh_sketch3d_overlay();
1217        self.dirty = true;
1218        Ok(())
1219    }
1220
1221    /// Toggle the `construction` flag of every selected geometry.
1222    pub fn sketch3d_toggle_construction(&mut self) {
1223        let Some(edit) = self.sketch3d_edit.as_mut() else { return };
1224        let (_, geometries, _) = edit.session.selection_by_kind();
1225        if geometries.is_empty() {
1226            return;
1227        }
1228        edit.record_undo();
1229        for gid in &geometries {
1230            if let Some(g) = edit.session.doc.geometry_mut(gid) {
1231                if g.geom_type == "plane" {
1232                    continue;
1233                }
1234                let now = g.construction();
1235                g.extra.insert("construction".into(), Value::Bool(!now));
1236            }
1237        }
1238        self.refresh_sketch3d_overlay();
1239        self.dirty = true;
1240    }
1241
1242    /// Toggle the `fixed` flag of every selected point (a linked reference
1243    /// point stays fixed).
1244    pub fn sketch3d_toggle_fixed(&mut self) {
1245        let Some(edit) = self.sketch3d_edit.as_mut() else { return };
1246        let (points, _, _) = edit.session.selection_by_kind();
1247        if points.is_empty() {
1248            return;
1249        }
1250        edit.record_undo();
1251        for pid in &points {
1252            if let Some(p) = edit.session.doc.point_mut(pid) {
1253                if p.external_reference {
1254                    continue;
1255                }
1256                p.fixed = !p.fixed;
1257            }
1258        }
1259        let _ = edit.session.resolve();
1260        self.refresh_sketch3d_overlay();
1261        self.dirty = true;
1262    }
1263
1264    /// Delete the selection: constraints by id; geometries with every
1265    /// constraint touching their points; points with their geometries and
1266    /// constraints. Orphaned points no geometry or constraint uses are swept
1267    /// away, and reference records whose entities are gone are dropped.
1268    pub fn sketch3d_delete_selection(&mut self) {
1269        let Some(edit) = self.sketch3d_edit.as_mut() else { return };
1270        let (mut points, mut geometries, constraints) = edit.session.selection_by_kind();
1271        if points.is_empty() && geometries.is_empty() && constraints.is_empty() {
1272            return;
1273        }
1274        edit.record_undo();
1275        let key = brep_kernel::sketch_id_key;
1276        // A linked reference is one thing: deleting any of its points or its
1277        // construction geometry unlinks the whole record, the way the
1278        // References list's ✕ does, so no fixed reference point is left
1279        // behind that nothing can move.
1280        let mut taken: Vec<Value> = Vec::new();
1281        for record in &edit.external_refs {
1282            let ids: Vec<Value> = record.get("points").and_then(Value::as_array).cloned().unwrap_or_default();
1283            let gid = record.get("geometry").filter(|g| !g.is_null()).cloned();
1284            let hit = ids.iter().any(|id| points.iter().any(|p| key(p) == key(id)))
1285                || gid.as_ref().map_or(false, |g| geometries.iter().any(|s| key(s) == key(g)));
1286            if hit {
1287                for id in ids {
1288                    if !points.iter().any(|p| key(p) == key(&id)) {
1289                        points.push(id);
1290                    }
1291                }
1292                if let Some(g) = gid {
1293                    if !geometries.iter().any(|s| key(s) == key(&g)) {
1294                        geometries.push(g);
1295                    }
1296                }
1297                taken.push(record.clone());
1298            }
1299        }
1300        edit.external_refs.retain(|r| !taken.iter().any(|t| t == r));
1301        let doc = &mut edit.session.doc;
1302        let ckeys: Vec<String> = constraints.iter().map(key).collect();
1303        doc.constraints.retain(|c| c.id().map_or(true, |id| !ckeys.contains(&key(id))));
1304        // Points to remove: the selected ones plus every point owned ONLY by a
1305        // deleted geometry (its interior / endpoints no other geometry shares
1306        // and no constraint uses, swept below).
1307        let gkeys: Vec<String> = geometries.iter().map(key).collect();
1308        let mut pkeys: Vec<String> = points.iter().map(key).collect();
1309        doc.geometries.retain(|g| !gkeys.contains(&key(&g.id)));
1310        // A geometry touching a removed point goes with it.
1311        doc.geometries.retain(|g| !g.points.iter().any(|pid| pkeys.contains(&key(pid))));
1312        doc.points.retain(|p| !pkeys.contains(&key(&p.id)));
1313        // Sweep orphans: a non-reference point no geometry uses any more (a
1314        // deleted line's free end). Every bare point is wrapped by a `point`
1315        // geometry, so a point without one is nothing the user can see.
1316        let used: std::collections::HashSet<String> = doc
1317            .geometries
1318            .iter()
1319            .flat_map(|g| g.points.iter().map(key))
1320            .collect();
1321        let orphans: Vec<String> = doc
1322            .points
1323            .iter()
1324            .filter(|p| !p.external_reference && !used.contains(&key(&p.id)))
1325            .map(|p| key(&p.id))
1326            .collect();
1327        doc.points.retain(|p| !orphans.contains(&key(&p.id)));
1328        pkeys.extend(orphans);
1329        // Constraints touching a removed point go with it.
1330        doc.constraints.retain(|c| !c.points().iter().any(|pid| pkeys.contains(&key(pid))));
1331        // Reference records whose geometry or points are gone.
1332        edit.external_refs.retain(|r| {
1333            let ids: Vec<Value> = r.get("points").and_then(Value::as_array).cloned().unwrap_or_default();
1334            let gid = r.get("geometry").filter(|g| !g.is_null());
1335            let points_alive = ids.iter().all(|id| doc.point(id).is_some());
1336            let geometry_alive = gid.map_or(true, |g| doc.geometry(g).is_some());
1337            points_alive && geometry_alive
1338        });
1339        edit.session.selection.items.clear();
1340        edit.session.selection.hovered = None;
1341        edit.pending.clear();
1342        let _ = edit.session.resolve();
1343        self.refresh_sketch3d_overlay();
1344        self.dirty = true;
1345    }
1346
1347    pub fn sketch3d_undo(&mut self) -> bool {
1348        let Some(edit) = self.sketch3d_edit.as_mut() else { return false };
1349        let Some(snap) = edit.undo_stack.pop() else { return false };
1350        let current = edit.snapshot();
1351        edit.redo_stack.push(current);
1352        edit.restore(snap);
1353        self.refresh_sketch3d_overlay();
1354        self.dirty = true;
1355        true
1356    }
1357
1358    pub fn sketch3d_redo(&mut self) -> bool {
1359        let Some(edit) = self.sketch3d_edit.as_mut() else { return false };
1360        let Some(snap) = edit.redo_stack.pop() else { return false };
1361        let current = edit.snapshot();
1362        edit.undo_stack.push(current);
1363        edit.restore(snap);
1364        self.refresh_sketch3d_overlay();
1365        self.dirty = true;
1366        true
1367    }
1368
1369    pub fn sketch3d_can_undo(&self) -> bool {
1370        self.sketch3d_edit.as_ref().map_or(false, |edit| !edit.undo_stack.is_empty())
1371    }
1372
1373    pub fn sketch3d_can_redo(&self) -> bool {
1374        self.sketch3d_edit.as_ref().map_or(false, |edit| !edit.redo_stack.is_empty())
1375    }
1376}
1377
1378fn fmt_coord(value: f64) -> String {
1379    let text = format!("{value:.6}");
1380    let text = text.trim_end_matches('0').trim_end_matches('.').to_string();
1381    if text.is_empty() || text == "-0" {
1382        "0".to_string()
1383    } else {
1384        text
1385    }
1386}
1387
1388fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
1389    ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
1390}
1391
1392/// Classify a display edge polyline: a straight run → a line through its
1393/// ends; a run whose samples sit at one radius about a fitted center → an arc
1394/// `[center, start, end]`, or, closed, the circle's axis as a line; anything
1395/// else → its chord, the same best effort the kernel makes.
1396fn classify_edge_polyline(polyline: &[[f64; 3]]) -> Option<brep_kernel::Sketch3dResolvedReference> {
1397    use brep_kernel::{Sketch3dResolvedReference as R, Vec3, SKETCH3D_REFERENCE_TIP_LENGTH as TIP};
1398    let v = |a: [f64; 3]| Vec3::new(a[0], a[1], a[2]);
1399    if polyline.len() < 2 {
1400        return None;
1401    }
1402    let start = v(polyline[0]);
1403    let end = v(*polyline.last()?);
1404    let extent = polyline
1405        .iter()
1406        .map(|p| v(*p).sub(start).length())
1407        .fold(0.0f64, f64::max)
1408        .max(1e-12);
1409    let chord = end.sub(start);
1410    // Straight: every sample within tolerance of the chord.
1411    if chord.length() > 1e-9 * extent {
1412        let d = chord.normalized().ok()?;
1413        let off = polyline
1414            .iter()
1415            .map(|p| {
1416                let w = v(*p).sub(start);
1417                w.sub(d.scale(d.dot(w))).length()
1418            })
1419            .fold(0.0f64, f64::max);
1420        if off <= 1e-6 * extent {
1421            return Some(R::Line(start, end));
1422        }
1423    }
1424    // Circular: fit the circle through three well-spread samples and check
1425    // every sample's distance from its centre.
1426    if polyline.len() >= 3 {
1427        let a = start;
1428        let b = v(polyline[polyline.len() / 2]);
1429        let c = if chord.length() > 1e-9 * extent { end } else { v(polyline[polyline.len() / 4]) };
1430        if let Some((center, radius, axis)) = circle_through(a, b, c) {
1431            let worst = polyline
1432                .iter()
1433                .map(|p| (v(*p).sub(center).length() - radius).abs())
1434                .fold(0.0f64, f64::max);
1435            let planar = polyline
1436                .iter()
1437                .map(|p| v(*p).sub(center).dot(axis).abs())
1438                .fold(0.0f64, f64::max);
1439            if worst <= 1e-4 * radius && planar <= 1e-4 * radius {
1440                if chord.length() <= 1e-6 * extent {
1441                    return Some(R::Line(center, center.add(axis.scale(radius.max(TIP)))));
1442                }
1443                return Some(R::Arc(center, start, end));
1444            }
1445        }
1446    }
1447    (chord.length() > 1e-9 * extent).then_some(R::Line(start, end))
1448}
1449
1450/// The circle through three points: `(center, radius, unit axis)`.
1451fn circle_through(a: brep_kernel::Vec3, b: brep_kernel::Vec3, c: brep_kernel::Vec3) -> Option<(brep_kernel::Vec3, f64, brep_kernel::Vec3)> {
1452    let ab = b.sub(a);
1453    let ac = c.sub(a);
1454    let n = ab.cross(ac);
1455    let n2 = n.dot(n);
1456    if n2 <= 1e-24 {
1457        return None;
1458    }
1459    // Circumcenter: a + ((|ac|² ab − |ab|² ac) × n) / (2 |n|²)
1460    let term = ab.scale(ac.dot(ac)).sub(ac.scale(ab.dot(ab))).cross(n).scale(1.0 / (2.0 * n2));
1461    let center = a.add(term);
1462    let radius = a.sub(center).length();
1463    let axis = n.normalized().ok()?;
1464    Some((center, radius, axis))
1465}