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            "constraintGlyphs": session
412                .constraint_glyphs(self.camera.world_per_pixel())
413                .into_iter()
414                .map(|(id, position, shape)| json!({ "id": id, "position": position, "shape": shape }))
415                .collect::<Vec<_>>(),
416            "hovered": session.selection.hovered,
417            "applicableConstraints": session.applicable_constraints(),
418            "dimensions": session.dimension_labels(),
419            "externalRefs": edit.external_refs,
420            "lostReferences": session.lost_reference_messages(),
421            "lastHit": edit.last_hit,
422            "canUndo": !edit.undo_stack.is_empty(),
423            "canRedo": !edit.redo_stack.is_empty(),
424            "entities": session.entity_rows_json(),
425        })
426        .to_string()
427    }
428
429    /// The dimension labels as JSON (the same shape as the planar sketch's).
430    pub fn sketch3d_dimension_labels_json(&self) -> String {
431        self.sketch3d_edit
432            .as_ref()
433            .and_then(|edit| serde_json::to_string(&edit.session.dimension_labels()).ok())
434            .unwrap_or_else(|| "[]".to_string())
435    }
436
437    pub fn sketch3d_active_tool(&self) -> Option<&str> {
438        self.sketch3d_edit.as_ref().and_then(|edit| edit.session.tool.as_deref())
439    }
440
441    pub fn sketch3d_selection_count(&self) -> usize {
442        self.sketch3d_edit.as_ref().map_or(0, |edit| edit.session.selection.items.len())
443    }
444
445    pub fn sketch3d_pending_len(&self) -> usize {
446        self.sketch3d_edit.as_ref().map_or(0, |edit| edit.pending.len())
447    }
448
449    pub fn sketch3d_work_plane(&self) -> Option<&str> {
450        self.sketch3d_edit.as_ref().map(|edit| edit.session.work_plane.name.as_str())
451    }
452
453    // --- tools ---------------------------------------------------------------
454
455    /// Arm a draw tool (`point`, `line`, `arc`, `bezier`, `pickRef`); `select`
456    /// / `None` returns to selection. Errors on an unknown tool.
457    pub fn sketch3d_set_tool(&mut self, tool: Option<&str>) -> Result<(), String> {
458        let tool = match tool {
459            None | Some("select") | Some("") => None,
460            Some(name) if crate::sketch3d::TOOLS.contains(&name) => Some(name.to_string()),
461            Some(other) => return Err(format!("unknown 3D sketch tool '{other}'")),
462        };
463        let Some(edit) = self.sketch3d_edit.as_mut() else {
464            return Err("not in 3D sketch mode".into());
465        };
466        edit.session.tool = tool;
467        edit.pending.clear();
468        edit.drag = None;
469        self.refresh_sketch3d_overlay();
470        self.dirty = true;
471        Ok(())
472    }
473
474    /// Drop the in-progress placement (right-click / Escape with clicks
475    /// pending) without changing the armed tool.
476    pub fn sketch3d_tool_cancel(&mut self) {
477        if let Some(edit) = self.sketch3d_edit.as_mut() {
478            edit.pending.clear();
479        }
480        self.refresh_sketch3d_overlay();
481        self.dirty = true;
482    }
483
484    /// Set the working plane by name (`XY` / `YZ` / `XZ`); it keeps passing
485    /// through the current anchor.
486    pub fn sketch3d_set_work_plane(&mut self, name: &str) -> Result<(), String> {
487        let Some(edit) = self.sketch3d_edit.as_mut() else {
488            return Err("not in 3D sketch mode".into());
489        };
490        let anchor = edit.session.work_plane.anchor;
491        edit.session.work_plane =
492            WorkPlane::new(name, anchor).ok_or_else(|| format!("unknown working plane '{name}' (expected XY|YZ|XZ)"))?;
493        self.refresh_sketch3d_overlay();
494        self.dirty = true;
495        Ok(())
496    }
497
498    /// Cycle the working plane `XY → YZ → XZ → XY` (the Tab key).
499    pub fn sketch3d_cycle_work_plane(&mut self) {
500        let next = self
501            .sketch3d_edit
502            .as_ref()
503            .map(|edit| edit.session.work_plane.next_name());
504        if let Some(next) = next {
505            let _ = self.sketch3d_set_work_plane(next);
506        }
507    }
508
509    fn sketch3d_pick_radius_px(&self) -> f64 {
510        f64::from(crate::sketch::tessellate::POINT_SIZE_PX) * 1.5
511    }
512
513    /// What the cursor at CSS-pixel `(x, y)` lands on when PLACING a point:
514    /// an existing sketch point within the grab radius, else a model vertex /
515    /// edge / planar face under the cursor (the pick ranking, committed
516    /// sketch displays excluded), else the working plane.
517    pub fn sketch3d_cursor_hit(&self, x: f64, y: f64) -> Option<Sketch3dCursorHit> {
518        let edit = self.sketch3d_edit.as_ref()?;
519        let radius = self.sketch3d_pick_radius_px();
520        if let Some(entity) = edit.session.pick_entity(&self.camera, x, y, radius) {
521            if entity.get("kind").and_then(Value::as_str) == Some("point") {
522                return entity.get("id").cloned().map(Sketch3dCursorHit::SketchPoint);
523            }
524        }
525        for candidate in crate::pick::pick_unoccluded(&self.scene, &self.context_scene, &self.camera, x, y, &self.pick_options()) {
526            let Some(solid) = self.scene.solids().iter().find(|s| s.name == candidate.solid) else {
527                continue;
528            };
529            if solid.is_sketch || !solid.visible {
530                continue;
531            }
532            match candidate.kind {
533                crate::pick::PickKind::Vertex => {
534                    return Some(Sketch3dCursorHit::Vertex {
535                        solid: candidate.solid,
536                        position: candidate.position,
537                    });
538                }
539                crate::pick::PickKind::Edge if !candidate.name.is_empty() => {
540                    return Some(Sketch3dCursorHit::Edge {
541                        name: candidate.name,
542                        position: candidate.position,
543                    });
544                }
545                crate::pick::PickKind::Face if !candidate.name.is_empty() => {
546                    if self.scene.face_is_planar(&candidate.name) {
547                        return Some(Sketch3dCursorHit::Face {
548                            name: candidate.name,
549                            position: candidate.position,
550                        });
551                    }
552                    // A curved face is not a plane to land on; fall through
553                    // to the working plane.
554                    break;
555                }
556                _ => continue,
557            }
558        }
559        let ray = self.camera.pick_ray(x, y);
560        let frame = edit.session.work_plane.frame();
561        let (u, v) = crate::sketch::ray_plane_uv(&frame, ray.origin, ray.dir)?;
562        Some(Sketch3dCursorHit::WorkPlane(frame.to_world(u, v)))
563    }
564
565    /// The world point of a cursor hit.
566    fn sketch3d_hit_world(&self, hit: &Sketch3dCursorHit) -> Option<[f64; 3]> {
567        match hit {
568            Sketch3dCursorHit::SketchPoint(id) => self.sketch3d_edit.as_ref()?.session.point_world(id),
569            Sketch3dCursorHit::Vertex { position, .. }
570            | Sketch3dCursorHit::Edge { position, .. }
571            | Sketch3dCursorHit::Face { position, .. }
572            | Sketch3dCursorHit::WorkPlane(position) => Some(*position),
573        }
574    }
575
576    /// The world point the cursor would place at `(x, y)` (the readback /
577    /// rubber band), `None` when the ray misses the working plane.
578    pub fn sketch3d_cursor_world(&self, x: f64, y: f64) -> Option<[f64; 3]> {
579        let hit = self.sketch3d_cursor_hit(x, y)?;
580        self.sketch3d_hit_world(&hit)
581    }
582
583    /// Resolve the cursor into a point id for the armed tool: an existing
584    /// sketch point is reused; a model vertex / edge / face hit places a point
585    /// there AND links the entity (coincident / on-line / on-plane); a working
586    /// plane hit places a free point. The working plane then moves to the
587    /// placed point.
588    fn sketch3d_place_point(&mut self, hit: Sketch3dCursorHit) -> Option<Value> {
589        let world = self.sketch3d_hit_world(&hit)?;
590        let id = match &hit {
591            Sketch3dCursorHit::SketchPoint(id) => id.clone(),
592            _ => {
593                let edit = self.sketch3d_edit.as_mut()?;
594                let radius = 0.0;
595                edit.session.doc.snap_or_add_point(brep_kernel::Vec3::new(world[0], world[1], world[2]), radius)
596            }
597        };
598        match &hit {
599            Sketch3dCursorHit::Vertex { solid, position } => {
600                let name = format!("{solid}@{},{},{}", fmt_coord(position[0]), fmt_coord(position[1]), fmt_coord(position[2]));
601                if let Some(ids) = self.sketch3d_link_reference_silent(&name) {
602                    if let Some(edit) = self.sketch3d_edit.as_mut() {
603                        if let Some(ref_point) = ids.first() {
604                            edit.session.doc.add_constraint("coincident", vec![id.clone(), ref_point.clone()], None);
605                        }
606                    }
607                }
608            }
609            Sketch3dCursorHit::Edge { name, .. } => {
610                if let Some(ids) = self.sketch3d_link_reference_silent(name) {
611                    if let Some(edit) = self.sketch3d_edit.as_mut() {
612                        match ids.len() {
613                            // A line: on the infinite line through its ends; a
614                            // circle, published as its axis: on the circle.
615                            2 => {
616                                let circle = edit.session.doc.geometries.iter().any(|g| {
617                                    g.points == ids && g.extra.get("circle").and_then(Value::as_bool) == Some(true)
618                                });
619                                let ctype = if circle { "onCircle" } else { "onLine" };
620                                edit.session.doc.add_constraint(ctype, vec![id.clone(), ids[0].clone(), ids[1].clone()], None);
621                            }
622                            // An arc [center, start, end]: on the arc.
623                            3 => {
624                                edit.session.doc.add_constraint("onArc", vec![id.clone(), ids[0].clone(), ids[1].clone(), ids[2].clone()], None);
625                            }
626                            _ => {}
627                        }
628                    }
629                }
630            }
631            Sketch3dCursorHit::Face { name, .. } => {
632                if let Some(ids) = self.sketch3d_link_reference_silent(name) {
633                    if let Some(edit) = self.sketch3d_edit.as_mut() {
634                        if ids.len() == 2 {
635                            edit.session.doc.add_constraint("onPlane", vec![id.clone(), ids[0].clone(), ids[1].clone()], None);
636                        }
637                    }
638                }
639            }
640            _ => {}
641        }
642        if let Some(edit) = self.sketch3d_edit.as_mut() {
643            edit.session.work_plane.anchor = world;
644            edit.last_hit = Some(hit.kind());
645        }
646        Some(id)
647    }
648
649    /// A click with a draw tool armed: place the next point of the geometry
650    /// being drawn; when the tool has all its points, create the geometry.
651    /// `line` chains: each click after the first lands a segment and starts
652    /// the next from its end.
653    pub fn sketch3d_tool_click_at(&mut self, x: f64, y: f64) {
654        let Some(tool) = self.sketch3d_active_tool().map(String::from) else {
655            return;
656        };
657        if tool == "pickRef" {
658            self.sketch3d_pick_reference_at(x, y);
659            return;
660        }
661        let Some(hit) = self.sketch3d_cursor_hit(x, y) else {
662            return;
663        };
664        if let Some(edit) = self.sketch3d_edit.as_mut() {
665            edit.record_undo();
666        }
667        let Some(id) = self.sketch3d_place_point(hit) else {
668            return;
669        };
670        let Some(edit) = self.sketch3d_edit.as_mut() else {
671            return;
672        };
673        // A repeat click on the pending point is a no-op.
674        if edit.pending.last().map_or(false, |last| brep_kernel::sketch_id_key(last) == brep_kernel::sketch_id_key(&id)) {
675            edit.undo_stack.pop();
676            return;
677        }
678        edit.pending.push(id);
679        match tool.as_str() {
680            "point" => {
681                let id = edit.pending.pop().unwrap();
682                edit.session.doc.add_geometry("point", vec![id], false);
683            }
684            "line" if edit.pending.len() >= 2 => {
685                let b = edit.pending.pop().unwrap();
686                let a = edit.pending.pop().unwrap();
687                edit.session.doc.add_geometry("line", vec![a, b.clone()], false);
688                edit.pending.push(b);
689            }
690            "arc" if edit.pending.len() >= 3 => {
691                let pts = std::mem::take(&mut edit.pending);
692                edit.session.doc.add_geometry("arc", pts, false);
693            }
694            "bezier" if edit.pending.len() >= 4 => {
695                let pts = std::mem::take(&mut edit.pending);
696                edit.session.doc.add_geometry("bezier", pts, false);
697            }
698            _ => {}
699        }
700        let _ = edit.session.resolve();
701        // Nothing changed beyond a pending click: the snapshot still stands so
702        // the whole geometry is one undo step, which Escape or the next click
703        // completes.
704        self.refresh_sketch3d_overlay();
705        self.dirty = true;
706    }
707
708    // --- references ----------------------------------------------------------
709
710    /// The `pickRef` tool: link the model entity under the cursor (vertex,
711    /// edge, planar face or datum plane) into the sketch as fixed construction
712    /// geometry. Returns whether something was linked.
713    pub fn sketch3d_pick_reference_at(&mut self, x: f64, y: f64) -> bool {
714        let Some(name) = self.sketch3d_model_reference_at(x, y) else { return false };
715        self.sketch3d_link_reference(&name)
716    }
717
718    /// The reference name of the model entity under the cursor: a
719    /// `{solid}@x,y,z` vertex, an edge or face name, or a datum plane.
720    fn sketch3d_model_reference_at(&self, x: f64, y: f64) -> Option<String> {
721        let candidates = self.pick_candidates_at(x, y);
722        let mut name: Option<String> = None;
723        for candidate in candidates {
724            match candidate.kind {
725                crate::pick::PickKind::Vertex => {
726                    if self.scene.solids().iter().any(|s| s.name == candidate.solid && !s.is_sketch) {
727                        let p = candidate.position;
728                        name = Some(format!("{}@{},{},{}", candidate.solid, fmt_coord(p[0]), fmt_coord(p[1]), fmt_coord(p[2])));
729                        break;
730                    }
731                }
732                crate::pick::PickKind::Edge | crate::pick::PickKind::Face | crate::pick::PickKind::Plane
733                    if !candidate.name.is_empty() =>
734                {
735                    if candidate.kind == crate::pick::PickKind::Plane
736                        || self.scene.solids().iter().any(|s| s.name == candidate.solid && !s.is_sketch)
737                    {
738                        name = Some(candidate.name);
739                        break;
740                    }
741                }
742                _ => {}
743            }
744        }
745        name
746    }
747
748    /// Link the model entity under the cursor (once: a name already linked is
749    /// reused, with no new undo step) and return the sketch entity standing
750    /// for it, its geometry or, for a vertex, its point.
751    fn sketch3d_link_model_at(&mut self, x: f64, y: f64) -> Option<Value> {
752        let name = self.sketch3d_model_reference_at(x, y)?;
753        let record_of = |refs: &[Value]| refs.iter().find(|r| r.get("name").and_then(Value::as_str) == Some(name.as_str())).cloned();
754        let known = record_of(&self.sketch3d_edit.as_ref()?.external_refs).is_some();
755        if !known && !self.sketch3d_link_reference(&name) {
756            return None;
757        }
758        let record = record_of(&self.sketch3d_edit.as_ref()?.external_refs)?;
759        if let Some(geometry) = record.get("geometry").filter(|g| !g.is_null()) {
760            return Some(crate::sketch3d::entity_geometry_ref(geometry));
761        }
762        record.get("points")?.get(0).map(crate::sketch3d::entity_point_ref)
763    }
764
765    /// Link a model entity BY NAME (a `{solid}@x,y,z` vertex, an edge or face
766    /// name, a datum / plane frame name): materialize it as fixed construction
767    /// geometry and record it. Re-linking an already linked name updates its
768    /// coordinates in place. Returns whether it resolved.
769    pub fn sketch3d_link_reference(&mut self, name: &str) -> bool {
770        if let Some(edit) = self.sketch3d_edit.as_mut() {
771            edit.record_undo();
772        }
773        let linked = self.sketch3d_link_reference_silent(name).is_some();
774        if !linked {
775            if let Some(edit) = self.sketch3d_edit.as_mut() {
776                edit.undo_stack.pop();
777            }
778        }
779        if let Some(edit) = self.sketch3d_edit.as_mut() {
780            let _ = edit.session.resolve();
781        }
782        self.refresh_sketch3d_overlay();
783        self.dirty = true;
784        linked
785    }
786
787    /// Materialize (or update) a reference without an undo snapshot or
788    /// overlay refresh; returns the record's point ids.
789    fn sketch3d_link_reference_silent(&mut self, name: &str) -> Option<Vec<Value>> {
790        let prior = self.sketch3d_edit.as_ref().and_then(|edit| {
791            edit.external_refs
792                .iter()
793                .find(|r| r.get("name").and_then(Value::as_str) == Some(name))
794                .cloned()
795        });
796        let anchors: Option<Vec<String>> = prior
797            .as_ref()
798            .and_then(|r| r.get(brep_kernel::SKETCH3D_ANCHOR_EDGES_KEY))
799            .and_then(|a| serde_json::from_value(a.clone()).ok());
800        // An anchored vertex is found through its edges; its coordinate name
801        // goes stale the moment it moves.
802        let resolved = match &anchors {
803            Some(edges) => brep_kernel::Sketch3dResolvedReference::Point(self.sketch3d_anchored_vertex(edges)?),
804            None => self.sketch3d_resolve_reference_from_display(name)?,
805        };
806        let resolved = match prior.as_ref().and_then(|r| r.get("kind")).and_then(Value::as_str) {
807            Some(kind) => resolved.as_recorded(kind),
808            None => resolved,
809        };
810        let new_anchors = match (&resolved, &anchors) {
811            (brep_kernel::Sketch3dResolvedReference::Point(p), None) => self.sketch3d_vertex_anchor_edges(name, [p.x, p.y, p.z]),
812            _ => None,
813        };
814        let edit = self.sketch3d_edit.as_mut()?;
815        let positions = resolved.positions();
816        // Update an existing record of the same kind in place.
817        if let Some(record) = edit
818            .external_refs
819            .iter()
820            .find(|r| r.get("name").and_then(Value::as_str) == Some(name))
821            .cloned()
822        {
823            let ids: Vec<Value> = record.get("points").and_then(Value::as_array).cloned().unwrap_or_default();
824            if record.get("kind").and_then(Value::as_str) == Some(resolved.kind()) && ids.len() == positions.len() {
825                for (id, p) in ids.iter().zip(&positions) {
826                    if let Some(point) = edit.session.doc.point_mut(id) {
827                        point.x = p.x;
828                        point.y = p.y;
829                        point.z = p.z;
830                        point.fixed = true;
831                        point.construction = true;
832                        point.external_reference = true;
833                    }
834                }
835                if matches!(resolved, brep_kernel::Sketch3dResolvedReference::CircleAxis(..)) {
836                    if let Some(g) = record.get("geometry").and_then(|id| edit.session.doc.geometry_mut(id)) {
837                        g.extra.insert("circle".into(), Value::Bool(true));
838                    }
839                }
840                // A vertex linked before anchors existed gets one the first
841                // time it is seen where its name says it is.
842                if let Some(anchor) = new_anchors {
843                    if let Some(map) = edit
844                        .external_refs
845                        .iter_mut()
846                        .find(|r| r.get("name").and_then(Value::as_str) == Some(name))
847                        .and_then(Value::as_object_mut)
848                    {
849                        map.insert(brep_kernel::SKETCH3D_ANCHOR_EDGES_KEY.into(), json!(anchor));
850                    }
851                }
852                return Some(ids);
853            }
854            // The shape changed under constraints that need these points:
855            // that is a lost reference, not something to rebuild over them.
856            let held = edit.session.doc.constraints.iter().any(|c| {
857                c.points().iter().any(|p| ids.iter().any(|id| brep_kernel::sketch_id_key(id) == brep_kernel::sketch_id_key(p)))
858            });
859            if held {
860                return None;
861            }
862            // The shape changed: drop the old materialization and rebuild.
863            edit.external_refs.retain(|r| r.get("name").and_then(Value::as_str) != Some(name));
864            for id in &ids {
865                edit.session.doc.points.retain(|p| brep_kernel::sketch_id_key(&p.id) != brep_kernel::sketch_id_key(id));
866            }
867            if let Some(gid) = record.get("geometry").filter(|g| !g.is_null()) {
868                edit.session.doc.geometries.retain(|g| brep_kernel::sketch_id_key(&g.id) != brep_kernel::sketch_id_key(gid));
869            }
870        }
871        let mut ids: Vec<Value> = Vec::with_capacity(positions.len());
872        for p in &positions {
873            let id = edit.session.doc.add_point(*p);
874            if let Some(point) = edit.session.doc.point_mut(&id) {
875                point.fixed = true;
876                point.construction = true;
877                point.external_reference = true;
878            }
879            ids.push(id);
880        }
881        let geometry = resolved
882            .geometry_type()
883            .map(|ty| edit.session.doc.add_geometry(ty, ids.clone(), true));
884        if matches!(resolved, brep_kernel::Sketch3dResolvedReference::CircleAxis(..)) {
885            if let Some(g) = geometry.as_ref().and_then(|id| edit.session.doc.geometry_mut(id)) {
886                g.extra.insert("circle".into(), Value::Bool(true));
887            }
888        }
889        let mut record = reference_record(name, &resolved, &ids, geometry.as_ref());
890        if let (Some(anchor), Some(map)) = (new_anchors, record.as_object_mut()) {
891            map.insert(brep_kernel::SKETCH3D_ANCHOR_EDGES_KEY.into(), json!(anchor));
892        }
893        edit.external_refs.push(record);
894        Some(ids)
895    }
896
897    /// The ends of a display edge by name.
898    fn sketch3d_display_edge_ends(&self, name: &str) -> Option<(brep_kernel::Vec3, brep_kernel::Vec3)> {
899        let polyline = self.scene.edge_polyline_world(name)?;
900        let v = |a: [f64; 3]| brep_kernel::Vec3::new(a[0], a[1], a[2]);
901        Some((v(*polyline.first()?), v(*polyline.last()?)))
902    }
903
904    /// The vertex two anchor edges share, on the display scene.
905    fn sketch3d_anchored_vertex(&self, edges: &[String]) -> Option<brep_kernel::Vec3> {
906        let a = self.sketch3d_display_edge_ends(edges.first()?)?;
907        let b = self.sketch3d_display_edge_ends(edges.get(1)?)?;
908        brep_kernel::sketch3d_common_edge_end(a, b)
909    }
910
911    /// Two named edges of the `{solid}@x,y,z` vertex at `position` that share
912    /// only that vertex: its topological anchor. `None` for a name that is not
913    /// a vertex, or a vertex with no such pair (a lone seam vertex).
914    fn sketch3d_vertex_anchor_edges(&self, name: &str, position: [f64; 3]) -> Option<Vec<String>> {
915        let (solid_name, _) = name.rsplit_once('@')?;
916        let solid = self.scene.solids().iter().find(|s| s.name == solid_name)?;
917        let tol = 1e-4 * (1.0 + position.iter().fold(0.0f64, |m, c| m.max(c.abs())));
918        let at_vertex: Vec<&str> = solid
919            .edges
920            .iter()
921            .filter(|edge| !edge.aux && !edge.name.is_empty())
922            .filter(|edge| {
923                let touches = |p: Option<&[f32; 3]>| p.map_or(false, |p| dist([p[0] as f64, p[1] as f64, p[2] as f64], position) <= tol);
924                touches(edge.polyline.first()) || touches(edge.polyline.last())
925            })
926            .map(|edge| edge.name.as_str())
927            .collect();
928        for (i, first) in at_vertex.iter().enumerate() {
929            for second in &at_vertex[i + 1..] {
930                let (Some(a), Some(b)) = (self.sketch3d_display_edge_ends(first), self.sketch3d_display_edge_ends(second)) else {
931                    continue;
932                };
933                if brep_kernel::sketch3d_common_edge_end(a, b).is_some() {
934                    return Some(vec![first.to_string(), second.to_string()]);
935                }
936            }
937        }
938        None
939    }
940
941    /// Resolve a reference name against the DISPLAY scene (the backdrop as
942    /// drawn): a datum / plane frame, a planar face, an edge polyline
943    /// classified as line / arc / circle axis, or a `{solid}@x,y,z` vertex.
944    fn sketch3d_resolve_reference_from_display(&self, name: &str) -> Option<brep_kernel::Sketch3dResolvedReference> {
945        use brep_kernel::{Sketch3dResolvedReference as R, Vec3, SKETCH3D_REFERENCE_TIP_LENGTH as TIP};
946        let v = |a: [f64; 3]| Vec3::new(a[0], a[1], a[2]);
947        if let Some((_, frame)) = self.construction_frames.iter().find(|(n, _)| n == name) {
948            return Some(R::Plane(frame.origin, frame.origin.add(frame.z_axis.scale(TIP))));
949        }
950        if let Some((_, axis)) = self.sketch_axes.iter().find(|(n, _)| n == name) {
951            return Some(R::Line(axis.point, axis.point.add(axis.direction.scale(TIP))));
952        }
953        if let Some((_, point)) = self.sketch_points.iter().find(|(n, _)| n == name) {
954            return Some(R::Point(point.position));
955        }
956        if let Some((centroid, normal)) = self.scene.face_plane_world(name) {
957            if !self.scene.face_is_planar(name) {
958                return None;
959            }
960            return Some(R::Plane(v(centroid), v(centroid).add(v(normal).scale(TIP))));
961        }
962        if let Some(polyline) = self.scene.edge_polyline_world(name) {
963            return classify_edge_polyline(&polyline);
964        }
965        if let Some((solid_name, coords)) = name.rsplit_once('@') {
966            let mut parts = coords.split(',').map(|p| p.trim().parse::<f64>().ok());
967            let position = [parts.next()??, parts.next()??, parts.next()??];
968            let solid = self.scene.solids().iter().find(|s| s.name == solid_name)?;
969            let best = solid
970                .vertices
971                .iter()
972                .map(|vertex| vertex.position)
973                .min_by(|a, b| dist(*a, position).total_cmp(&dist(*b, position)))?;
974            if dist(best, position) > 1e-4 * (1.0 + best.iter().fold(0.0f64, |m, c| m.max(c.abs()))) {
975                return None;
976            }
977            return Some(R::Point(v(best)));
978        }
979        None
980    }
981
982    /// Sync the links with the dialog's `references` list against the current
983    /// backdrop: a record the list no longer names was removed in the dialog
984    /// and is UNLINKED (its construction geometry and the constraints on it
985    /// go); every remaining record re-materializes (so a linked entity tracks
986    /// an upstream edit); every listed name without a record is linked; and
987    /// the listed names the backdrop cannot see are remembered for Finish.
988    /// The list is authoritative from the first Finish, which is what wrote
989    /// it: a record can only exist because a Finish listed its name.
990    fn sketch3d_sync_references(&mut self, listed: &[String]) {
991        let names: Vec<String> = self
992            .sketch3d_edit
993            .as_ref()
994            .map(|edit| {
995                edit.external_refs
996                    .iter()
997                    .filter_map(|r| r.get("name").and_then(Value::as_str).map(String::from))
998                    .collect()
999            })
1000            .unwrap_or_default();
1001        for name in &names {
1002            if !listed.contains(name) {
1003                self.sketch3d_unlink_reference(name);
1004                if let Some(edit) = self.sketch3d_edit.as_mut() {
1005                    // An enter-time release is not an edit the user made.
1006                    edit.undo_stack.clear();
1007                    edit.redo_stack.clear();
1008                }
1009            }
1010        }
1011        let names: Vec<String> = names.into_iter().filter(|n| listed.contains(n)).collect();
1012        let mut lost: Vec<String> = Vec::new();
1013        for name in &names {
1014            if self.sketch3d_link_reference_silent(name).is_none() {
1015                lost.push(name.clone());
1016            }
1017        }
1018        let mut unlinked = Vec::new();
1019        for name in listed {
1020            if names.contains(name) {
1021                continue;
1022            }
1023            if self.sketch3d_link_reference_silent(name).is_none() {
1024                unlinked.push(name.clone());
1025            }
1026        }
1027        if let Some(edit) = self.sketch3d_edit.as_mut() {
1028            edit.listed_unlinked = unlinked;
1029            let _ = edit.session.resolve();
1030            edit.session.lost_references = lost
1031                .iter()
1032                .filter_map(|name| {
1033                    let record = edit.external_refs.iter().find(|r| r.get("name").and_then(Value::as_str) == Some(name.as_str()))?;
1034                    let ids: Vec<Value> = record.get("points").and_then(Value::as_array).cloned().unwrap_or_default();
1035                    Some((name.clone(), ids))
1036                })
1037                .collect();
1038        }
1039        let notices: Vec<String> = self
1040            .sketch3d_edit
1041            .as_ref()
1042            .map(|edit| edit.session.lost_reference_messages())
1043            .unwrap_or_default();
1044        for message in notices {
1045            self.push_notice(message);
1046        }
1047    }
1048
1049    /// Drop a linked reference by name: its record, its construction geometry
1050    /// and points, and every constraint touching them.
1051    pub fn sketch3d_unlink_reference(&mut self, name: &str) -> bool {
1052        let Some(edit) = self.sketch3d_edit.as_mut() else { return false };
1053        let Some(record) = edit
1054            .external_refs
1055            .iter()
1056            .find(|r| r.get("name").and_then(Value::as_str) == Some(name))
1057            .cloned()
1058        else {
1059            return false;
1060        };
1061        edit.record_undo();
1062        edit.external_refs.retain(|r| r.get("name").and_then(Value::as_str) != Some(name));
1063        let ids: Vec<Value> = record.get("points").and_then(Value::as_array).cloned().unwrap_or_default();
1064        let gid = record.get("geometry").filter(|g| !g.is_null()).cloned();
1065        let mut refs: Vec<Value> = ids.iter().map(crate::sketch3d::entity_point_ref).collect();
1066        if let Some(gid) = gid {
1067            refs.push(crate::sketch3d::entity_geometry_ref(&gid));
1068        }
1069        edit.session.selection.items = refs;
1070        self.sketch3d_delete_selection();
1071        true
1072    }
1073
1074    // --- selection / hover / drag --------------------------------------------
1075
1076    pub fn sketch3d_hover_at(&mut self, x: f64, y: f64) -> bool {
1077        if self.sketch3d_edit.is_none() {
1078            return false;
1079        }
1080        let world = self.sketch3d_cursor_world(x, y);
1081        let radius = self.sketch3d_pick_radius_px();
1082        let new_hover = self
1083            .sketch3d_edit
1084            .as_ref()
1085            .and_then(|edit| edit.session.pick_entity(&self.camera, x, y, radius));
1086        let (changed, preview_live) = match self.sketch3d_edit.as_mut() {
1087            Some(edit) => {
1088                let changed = !crate::sketch::session::entity_ref_eq(edit.session.selection.hovered.as_ref(), new_hover.as_ref());
1089                edit.session.selection.hovered = new_hover;
1090                let moved = edit.hover_world != world;
1091                edit.hover_world = world;
1092                (changed, edit.session.tool.is_some() && moved)
1093            }
1094            None => (false, false),
1095        };
1096        if changed || preview_live {
1097            self.refresh_sketch3d_overlay();
1098            self.dirty = true;
1099        }
1100        changed
1101    }
1102
1103    pub fn sketch3d_clear_hover(&mut self) -> bool {
1104        let changed = self
1105            .sketch3d_edit
1106            .as_mut()
1107            .map_or(false, |edit| {
1108                let had = edit.session.selection.hovered.take().is_some() || edit.hover_world.take().is_some();
1109                had
1110            });
1111        if changed {
1112            self.refresh_sketch3d_overlay();
1113            self.dirty = true;
1114        }
1115        changed
1116    }
1117
1118    /// Click-select: the entity under the cursor replaces (or, additive /
1119    /// click-toggles, toggles in) the selection; empty space clears it.
1120    pub fn sketch3d_click_at(&mut self, x: f64, y: f64, additive: bool) {
1121        let toggles = self.settings.multi_select == crate::style::MultiSelectMode::ClickToggles;
1122        let radius = self.sketch3d_pick_radius_px();
1123        let hit = self
1124            .sketch3d_edit
1125            .as_ref()
1126            .and_then(|edit| edit.session.pick_entity(&self.camera, x, y, radius));
1127        // A model entity is linked where it is clicked and joins the selection,
1128        // so the constraints for point + target are offered with no tool change.
1129        let linked = if hit.is_none() { self.sketch3d_link_model_at(x, y) } else { None };
1130        let Some(edit) = self.sketch3d_edit.as_mut() else { return };
1131        if let Some(entity) = linked {
1132            edit.session.toggle_selection(entity);
1133            self.refresh_sketch3d_overlay();
1134            self.dirty = true;
1135            return;
1136        }
1137        match hit {
1138            None => edit.session.clear_selection(),
1139            Some(entity) => {
1140                if additive || toggles {
1141                    edit.session.toggle_selection(entity);
1142                } else {
1143                    edit.session.clear_selection();
1144                    edit.session.toggle_selection(entity);
1145                }
1146            }
1147        }
1148        self.refresh_sketch3d_overlay();
1149        self.dirty = true;
1150    }
1151
1152    /// Select an entity by ref from a list row (replacing the selection).
1153    pub fn sketch3d_select_entity(&mut self, entity_ref: Value, additive: bool) {
1154        if let Some(edit) = self.sketch3d_edit.as_mut() {
1155            if !additive {
1156                edit.session.clear_selection();
1157            }
1158            edit.session.toggle_selection(entity_ref);
1159        }
1160        self.refresh_sketch3d_overlay();
1161        self.dirty = true;
1162    }
1163
1164    pub fn sketch3d_clear_selection(&mut self) {
1165        if let Some(edit) = self.sketch3d_edit.as_mut() {
1166            edit.session.clear_selection();
1167        }
1168        self.refresh_sketch3d_overlay();
1169        self.dirty = true;
1170    }
1171
1172    /// Begin a drag of the hovered (or picked) draggable entity. Returns
1173    /// whether something was grabbed; otherwise the drag is the camera's.
1174    pub fn sketch3d_drag_begin(&mut self, x: f64, y: f64) -> bool {
1175        let radius = self.sketch3d_pick_radius_px();
1176        let points = self.sketch3d_edit.as_ref().and_then(|edit| match edit.session.selection.hovered.as_ref() {
1177            Some(entity) => edit.session.drag_points_from_ref(entity),
1178            None => edit
1179                .session
1180                .pick_draggable_point(&self.camera, x, y, radius)
1181                .and_then(|(id, fixed)| edit.session.point_world(&id).map(|p| vec![(id, p, fixed)])),
1182        });
1183        let Some(points) = points else { return false };
1184        // The drag plane: through the first grabbed point, facing the camera.
1185        let (_, _, forward) = self.camera.basis();
1186        let plane = crate::sketch::PlaneFrame::from_normal(points[0].1, [-forward[0], -forward[1], -forward[2]]);
1187        let ray = self.camera.pick_ray(x, y);
1188        let Some((u, v)) = crate::sketch::ray_plane_uv(&plane, ray.origin, ray.dir) else {
1189            return false;
1190        };
1191        let anchor = plane.to_world(u, v);
1192        if let Some(edit) = self.sketch3d_edit.as_mut() {
1193            edit.record_undo();
1194            edit.drag = Some(Sketch3dDrag { points, plane, anchor });
1195        }
1196        true
1197    }
1198
1199    /// Drag the grabbed points to the cursor: each is pinned at its original
1200    /// position plus the cursor delta on the drag plane, the sketch re-solved,
1201    /// and the original fixed flags restored.
1202    pub fn sketch3d_drag_to(&mut self, x: f64, y: f64) {
1203        let ray = self.camera.pick_ray(x, y);
1204        let Some(edit) = self.sketch3d_edit.as_mut() else { return };
1205        let Some(drag) = edit.drag.clone() else { return };
1206        let Some((u, v)) = crate::sketch::ray_plane_uv(&drag.plane, ray.origin, ray.dir) else {
1207            return;
1208        };
1209        let here = drag.plane.to_world(u, v);
1210        let delta = [here[0] - drag.anchor[0], here[1] - drag.anchor[1], here[2] - drag.anchor[2]];
1211        let session = &mut edit.session;
1212        for (id, orig, _) in &drag.points {
1213            if let Some(p) = session.doc.point_mut(id) {
1214                p.x = orig[0] + delta[0];
1215                p.y = orig[1] + delta[1];
1216                p.z = orig[2] + delta[2];
1217                p.fixed = true;
1218            }
1219        }
1220        match session.resolve() {
1221            Ok(()) => {
1222                for (id, _, fixed) in &drag.points {
1223                    if let Some(p) = session.doc.point_mut(id) {
1224                        p.fixed = *fixed;
1225                    }
1226                }
1227            }
1228            Err(_) => {
1229                for (id, orig, fixed) in &drag.points {
1230                    if let Some(p) = session.doc.point_mut(id) {
1231                        p.x = orig[0];
1232                        p.y = orig[1];
1233                        p.z = orig[2];
1234                        p.fixed = *fixed;
1235                    }
1236                }
1237            }
1238        }
1239        self.refresh_sketch3d_overlay();
1240        self.dirty = true;
1241    }
1242
1243    pub fn sketch3d_drag_end(&mut self) {
1244        let Some(edit) = self.sketch3d_edit.as_mut() else { return };
1245        if edit.drag.take().is_none() {
1246            return;
1247        }
1248        let _ = edit.session.resolve();
1249        if edit.undo_stack.last().map_or(false, |snap| snap.doc == edit.session.doc) {
1250            edit.undo_stack.pop();
1251        }
1252        self.refresh_sketch3d_overlay();
1253        self.dirty = true;
1254    }
1255
1256    // --- edits ---------------------------------------------------------------
1257
1258    /// Add a constraint of `ctype` over the palette's point ids for the
1259    /// current selection (`sketch3d_applicable_constraints`); a dimensional
1260    /// one is seeded with the current measurement. Returns the constraint id.
1261    pub fn sketch3d_add_constraint(&mut self, ctype: &str) -> Result<Value, String> {
1262        let Some(edit) = self.sketch3d_edit.as_mut() else {
1263            return Err("not in 3D sketch mode".into());
1264        };
1265        let offer = edit
1266            .session
1267            .applicable_constraints()
1268            .into_iter()
1269            .find(|c| c.ctype == ctype)
1270            .ok_or_else(|| format!("constraint '{ctype}' does not apply to the current selection"))?;
1271        edit.record_undo();
1272        if ctype == "fixed" {
1273            for id in &offer.points {
1274                if let Some(p) = edit.session.doc.point_mut(id) {
1275                    p.fixed = true;
1276                }
1277            }
1278            let _ = edit.session.resolve();
1279            self.refresh_sketch3d_overlay();
1280            self.dirty = true;
1281            return Ok(Value::Null);
1282        }
1283        let value = if offer.dimensional {
1284            edit.session.measure(ctype, &offer.points)
1285        } else {
1286            None
1287        };
1288        let id = edit.session.doc.add_constraint(ctype, offer.points, value);
1289        if let Err(error) = edit.session.resolve() {
1290            // An unsolvable addition is taken back.
1291            if let Some(snap) = edit.undo_stack.pop() {
1292                edit.restore(snap);
1293            }
1294            return Err(error);
1295        }
1296        self.refresh_sketch3d_overlay();
1297        self.dirty = true;
1298        Ok(id)
1299    }
1300
1301    /// Add a constraint over EXPLICIT point ids (automation / tests).
1302    pub fn sketch3d_add_constraint_over(&mut self, ctype: &str, points: Vec<Value>, value: Option<f64>) -> Result<Value, String> {
1303        let Some(edit) = self.sketch3d_edit.as_mut() else {
1304            return Err("not in 3D sketch mode".into());
1305        };
1306        edit.record_undo();
1307        let id = edit.session.doc.add_constraint(ctype, points, value);
1308        if let Err(error) = edit.session.resolve() {
1309            if let Some(snap) = edit.undo_stack.pop() {
1310                edit.restore(snap);
1311            }
1312            return Err(error);
1313        }
1314        self.refresh_sketch3d_overlay();
1315        self.dirty = true;
1316        Ok(id)
1317    }
1318
1319    /// The palette for the current selection, as JSON.
1320    pub fn sketch3d_applicable_constraints_json(&self) -> String {
1321        self.sketch3d_edit
1322            .as_ref()
1323            .and_then(|edit| serde_json::to_string(&edit.session.applicable_constraints()).ok())
1324            .unwrap_or_else(|| "[]".to_string())
1325    }
1326
1327    /// Set a dimensional constraint's value (a number, or an expression
1328    /// string kept as `valueExpr` and evaluated now against the document's
1329    /// expressions). Re-solves; an unsolvable value is taken back.
1330    pub fn sketch3d_set_dimension_value(&mut self, id: &Value, value: &str) -> Result<(), String> {
1331        let trimmed = value.trim();
1332        let number: f64 = match trimmed.parse::<f64>() {
1333            Ok(n) => n,
1334            Err(_) => brep_kernel::eval_expression(
1335                &self.history.expressions(),
1336                &self.history.configurator(),
1337                trimmed,
1338            )
1339            .map_err(|e| format!("dimension `{trimmed}`: {e}"))?,
1340        };
1341        if !number.is_finite() {
1342            return Err(format!("dimension `{trimmed}` is not finite"));
1343        }
1344        let Some(edit) = self.sketch3d_edit.as_mut() else {
1345            return Err("not in 3D sketch mode".into());
1346        };
1347        edit.record_undo();
1348        {
1349            let key = brep_kernel::sketch_id_key(id);
1350            let Some(constraint) = edit
1351                .session
1352                .doc
1353                .constraints
1354                .iter_mut()
1355                .find(|c| c.id().map(brep_kernel::sketch_id_key) == Some(key.clone()))
1356            else {
1357                edit.undo_stack.pop();
1358                return Err(format!("no constraint with id {key}"));
1359            };
1360            constraint.raw.insert("value".into(), Value::from(number));
1361            if trimmed.parse::<f64>().is_ok() {
1362                constraint.raw.remove("valueExpr");
1363            } else {
1364                constraint.raw.insert("valueExpr".into(), Value::String(trimmed.to_string()));
1365            }
1366        }
1367        if let Err(error) = edit.session.resolve() {
1368            if let Some(snap) = edit.undo_stack.pop() {
1369                edit.restore(snap);
1370            }
1371            return Err(error);
1372        }
1373        self.refresh_sketch3d_overlay();
1374        self.dirty = true;
1375        Ok(())
1376    }
1377
1378    /// Toggle the `construction` flag of every selected geometry.
1379    pub fn sketch3d_toggle_construction(&mut self) {
1380        let Some(edit) = self.sketch3d_edit.as_mut() else { return };
1381        let (_, geometries, _) = edit.session.selection_by_kind();
1382        if geometries.is_empty() {
1383            return;
1384        }
1385        edit.record_undo();
1386        for gid in &geometries {
1387            if let Some(g) = edit.session.doc.geometry_mut(gid) {
1388                if g.geom_type == "plane" {
1389                    continue;
1390                }
1391                let now = g.construction();
1392                g.extra.insert("construction".into(), Value::Bool(!now));
1393            }
1394        }
1395        self.refresh_sketch3d_overlay();
1396        self.dirty = true;
1397    }
1398
1399    /// Toggle the `fixed` flag of every selected point (a linked reference
1400    /// point stays fixed).
1401    pub fn sketch3d_toggle_fixed(&mut self) {
1402        let Some(edit) = self.sketch3d_edit.as_mut() else { return };
1403        let (points, _, _) = edit.session.selection_by_kind();
1404        if points.is_empty() {
1405            return;
1406        }
1407        edit.record_undo();
1408        for pid in &points {
1409            if let Some(p) = edit.session.doc.point_mut(pid) {
1410                if p.external_reference {
1411                    continue;
1412                }
1413                p.fixed = !p.fixed;
1414            }
1415        }
1416        let _ = edit.session.resolve();
1417        self.refresh_sketch3d_overlay();
1418        self.dirty = true;
1419    }
1420
1421    /// Delete the selection: constraints by id; geometries with every
1422    /// constraint touching their points; points with their geometries and
1423    /// constraints. Orphaned points no geometry or constraint uses are swept
1424    /// away, and reference records whose entities are gone are dropped.
1425    pub fn sketch3d_delete_selection(&mut self) {
1426        let Some(edit) = self.sketch3d_edit.as_mut() else { return };
1427        let (mut points, mut geometries, constraints) = edit.session.selection_by_kind();
1428        if points.is_empty() && geometries.is_empty() && constraints.is_empty() {
1429            return;
1430        }
1431        edit.record_undo();
1432        let key = brep_kernel::sketch_id_key;
1433        // A linked reference is one thing: deleting any of its points or its
1434        // construction geometry unlinks the whole record, the way the
1435        // References list's ✕ does, so no fixed reference point is left
1436        // behind that nothing can move.
1437        let mut taken: Vec<Value> = Vec::new();
1438        for record in &edit.external_refs {
1439            let ids: Vec<Value> = record.get("points").and_then(Value::as_array).cloned().unwrap_or_default();
1440            let gid = record.get("geometry").filter(|g| !g.is_null()).cloned();
1441            let hit = ids.iter().any(|id| points.iter().any(|p| key(p) == key(id)))
1442                || gid.as_ref().map_or(false, |g| geometries.iter().any(|s| key(s) == key(g)));
1443            if hit {
1444                for id in ids {
1445                    if !points.iter().any(|p| key(p) == key(&id)) {
1446                        points.push(id);
1447                    }
1448                }
1449                if let Some(g) = gid {
1450                    if !geometries.iter().any(|s| key(s) == key(&g)) {
1451                        geometries.push(g);
1452                    }
1453                }
1454                taken.push(record.clone());
1455            }
1456        }
1457        edit.external_refs.retain(|r| !taken.iter().any(|t| t == r));
1458        let doc = &mut edit.session.doc;
1459        let ckeys: Vec<String> = constraints.iter().map(key).collect();
1460        doc.constraints.retain(|c| c.id().map_or(true, |id| !ckeys.contains(&key(id))));
1461        // Points to remove: the selected ones plus every point owned ONLY by a
1462        // deleted geometry (its interior / endpoints no other geometry shares
1463        // and no constraint uses, swept below).
1464        let gkeys: Vec<String> = geometries.iter().map(key).collect();
1465        let mut pkeys: Vec<String> = points.iter().map(key).collect();
1466        doc.geometries.retain(|g| !gkeys.contains(&key(&g.id)));
1467        // A geometry touching a removed point goes with it.
1468        doc.geometries.retain(|g| !g.points.iter().any(|pid| pkeys.contains(&key(pid))));
1469        doc.points.retain(|p| !pkeys.contains(&key(&p.id)));
1470        // Sweep orphans: a non-reference point no geometry uses any more (a
1471        // deleted line's free end). Every bare point is wrapped by a `point`
1472        // geometry, so a point without one is nothing the user can see.
1473        let used: std::collections::HashSet<String> = doc
1474            .geometries
1475            .iter()
1476            .flat_map(|g| g.points.iter().map(key))
1477            .collect();
1478        let orphans: Vec<String> = doc
1479            .points
1480            .iter()
1481            .filter(|p| !p.external_reference && !used.contains(&key(&p.id)))
1482            .map(|p| key(&p.id))
1483            .collect();
1484        doc.points.retain(|p| !orphans.contains(&key(&p.id)));
1485        pkeys.extend(orphans);
1486        // Constraints touching a removed point go with it.
1487        doc.constraints.retain(|c| !c.points().iter().any(|pid| pkeys.contains(&key(pid))));
1488        // Reference records whose geometry or points are gone.
1489        edit.external_refs.retain(|r| {
1490            let ids: Vec<Value> = r.get("points").and_then(Value::as_array).cloned().unwrap_or_default();
1491            let gid = r.get("geometry").filter(|g| !g.is_null());
1492            let points_alive = ids.iter().all(|id| doc.point(id).is_some());
1493            let geometry_alive = gid.map_or(true, |g| doc.geometry(g).is_some());
1494            points_alive && geometry_alive
1495        });
1496        edit.session.selection.items.clear();
1497        edit.session.selection.hovered = None;
1498        edit.pending.clear();
1499        let _ = edit.session.resolve();
1500        self.refresh_sketch3d_overlay();
1501        self.dirty = true;
1502    }
1503
1504    pub fn sketch3d_undo(&mut self) -> bool {
1505        let Some(edit) = self.sketch3d_edit.as_mut() else { return false };
1506        let Some(snap) = edit.undo_stack.pop() else { return false };
1507        let current = edit.snapshot();
1508        edit.redo_stack.push(current);
1509        edit.restore(snap);
1510        self.refresh_sketch3d_overlay();
1511        self.dirty = true;
1512        true
1513    }
1514
1515    pub fn sketch3d_redo(&mut self) -> bool {
1516        let Some(edit) = self.sketch3d_edit.as_mut() else { return false };
1517        let Some(snap) = edit.redo_stack.pop() else { return false };
1518        let current = edit.snapshot();
1519        edit.undo_stack.push(current);
1520        edit.restore(snap);
1521        self.refresh_sketch3d_overlay();
1522        self.dirty = true;
1523        true
1524    }
1525
1526    pub fn sketch3d_can_undo(&self) -> bool {
1527        self.sketch3d_edit.as_ref().map_or(false, |edit| !edit.undo_stack.is_empty())
1528    }
1529
1530    pub fn sketch3d_can_redo(&self) -> bool {
1531        self.sketch3d_edit.as_ref().map_or(false, |edit| !edit.redo_stack.is_empty())
1532    }
1533}
1534
1535fn fmt_coord(value: f64) -> String {
1536    let text = format!("{value:.6}");
1537    let text = text.trim_end_matches('0').trim_end_matches('.').to_string();
1538    if text.is_empty() || text == "-0" {
1539        "0".to_string()
1540    } else {
1541        text
1542    }
1543}
1544
1545fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
1546    ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
1547}
1548
1549/// Classify a display edge polyline: a straight run → a line through its
1550/// ends; a run whose samples sit at one radius about a fitted center → an arc
1551/// `[center, start, end]`, or, closed, the circle's axis as a line; anything
1552/// else → its chord, the same best effort the kernel makes.
1553fn classify_edge_polyline(polyline: &[[f64; 3]]) -> Option<brep_kernel::Sketch3dResolvedReference> {
1554    use brep_kernel::{Sketch3dResolvedReference as R, Vec3};
1555    let v = |a: [f64; 3]| Vec3::new(a[0], a[1], a[2]);
1556    if polyline.len() < 2 {
1557        return None;
1558    }
1559    let start = v(polyline[0]);
1560    let end = v(*polyline.last()?);
1561    let extent = polyline
1562        .iter()
1563        .map(|p| v(*p).sub(start).length())
1564        .fold(0.0f64, f64::max)
1565        .max(1e-12);
1566    let chord = end.sub(start);
1567    // Straight: every sample within tolerance of the chord.
1568    if chord.length() > 1e-9 * extent {
1569        let d = chord.normalized().ok()?;
1570        let off = polyline
1571            .iter()
1572            .map(|p| {
1573                let w = v(*p).sub(start);
1574                w.sub(d.scale(d.dot(w))).length()
1575            })
1576            .fold(0.0f64, f64::max);
1577        if off <= 1e-6 * extent {
1578            return Some(R::Segment(start, end));
1579        }
1580    }
1581    // Circular: fit the circle through three well-spread samples and check
1582    // every sample's distance from its centre.
1583    if polyline.len() >= 3 {
1584        let a = start;
1585        let b = v(polyline[polyline.len() / 2]);
1586        let c = if chord.length() > 1e-9 * extent { end } else { v(polyline[polyline.len() / 4]) };
1587        if let Some((center, radius, axis)) = circle_through(a, b, c) {
1588            let worst = polyline
1589                .iter()
1590                .map(|p| (v(*p).sub(center).length() - radius).abs())
1591                .fold(0.0f64, f64::max);
1592            let planar = polyline
1593                .iter()
1594                .map(|p| v(*p).sub(center).dot(axis).abs())
1595                .fold(0.0f64, f64::max);
1596            if worst <= 1e-4 * radius && planar <= 1e-4 * radius {
1597                if chord.length() <= 1e-6 * extent {
1598                    return Some(R::CircleAxis(center, center.add(axis.scale(radius))));
1599                }
1600                return Some(R::Arc(center, start, end));
1601            }
1602        }
1603    }
1604    let samples: Vec<Vec3> = polyline.iter().map(|p| v(*p)).collect();
1605    match brep_kernel::sketch3d_curve_reference_controls(&samples) {
1606        Some(controls) => Some(R::Curve(controls)),
1607        None => (chord.length() > 1e-9 * extent).then_some(R::Segment(start, end)),
1608    }
1609}
1610
1611/// The circle through three points: `(center, radius, unit axis)`.
1612fn circle_through(a: brep_kernel::Vec3, b: brep_kernel::Vec3, c: brep_kernel::Vec3) -> Option<(brep_kernel::Vec3, f64, brep_kernel::Vec3)> {
1613    let ab = b.sub(a);
1614    let ac = c.sub(a);
1615    let n = ab.cross(ac);
1616    let n2 = n.dot(n);
1617    if n2 <= 1e-24 {
1618        return None;
1619    }
1620    // Circumcenter: a + (n × (|ac|² ab − |ab|² ac)) / (2 |n|²)
1621    let term = n.cross(ab.scale(ac.dot(ac)).sub(ac.scale(ab.dot(ab)))).scale(1.0 / (2.0 * n2));
1622    let center = a.add(term);
1623    let radius = a.sub(center).length();
1624    let axis = n.normalized().ok()?;
1625    Some((center, radius, axis))
1626}