Skip to main content

brep_render/engine_state/
sketch_edit_ops.rs

1use super::*;
2
3// Sketch dimension editing shares geometry with crate::sketch::dimensions.
4// Values may be literals or expressions evaluated against the history variables;
5// dragged labels store plane-space offsets. Edits resolve the sketch and refresh
6// its overlay.
7impl EngineState {
8    /// The dimension labels for the active sketch (S5): one entry per dimensional
9    /// constraint — `[{ id, text, world:[x,y,z], value, valueExpr, mode }]`. `world`
10    /// is the label anchor in world space; brep-app projects it via
11    /// [`world_to_screen_json`](Self::world_to_screen_json) and draws editable text
12    /// there. `[]` when not in sketch mode.
13    pub fn sketch_dimension_labels_json(&self) -> String {
14        let world_per_pixel = self.camera.world_per_pixel();
15        let labels = match self.sketch_edit.as_ref() {
16            Some(edit) => edit.session.dimension_labels(world_per_pixel),
17            None => return "[]".to_string(),
18        };
19        let out: Vec<serde_json::Value> = labels
20            .into_iter()
21            .map(|l| {
22                serde_json::json!({
23                    "id": l.id,
24                    "text": l.text,
25                    "world": l.world,
26                    "value": l.value,
27                    "valueExpr": l.value_expr,
28                    "mode": l.mode,
29                    "conflicting": l.conflicting,
30                })
31            })
32            .collect();
33        serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
34    }
35
36    /// The current `{value, valueExpr, mode}` for a dimensional constraint (S5) — the
37    /// seed for the inline edit field. `{}` when the constraint is absent / not in
38    /// sketch mode. `mode` is `"distance" | "radius" | "diameter" | "angle"`. For a
39    /// diameter dim `value` is the DISPLAYED diameter (twice the stored radius), so
40    /// the edit field round-trips what the user sees.
41    pub fn sketch_dimension_value_json(&self, constraint_id: &serde_json::Value) -> String {
42        use crate::sketch::doc::id_key;
43        let Some(edit) = self.sketch_edit.as_ref() else {
44            return "{}".to_string();
45        };
46        let key = id_key(constraint_id);
47        let Some(c) = edit
48            .session
49            .doc
50            .constraints
51            .iter()
52            .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))
53        else {
54            return "{}".to_string();
55        };
56        let is_diameter = c.ctype() == Some("⟺")
57            && c.raw.get("displayStyle").and_then(serde_json::Value::as_str) == Some("diameter");
58        let mode = dimension_mode(c);
59        let stored = c
60            .raw
61            .get("value")
62            .and_then(serde_json::Value::as_f64)
63            .filter(|v| v.is_finite());
64        let display_value = stored.map(|v| if is_diameter { v * 2.0 } else { v });
65        let value_expr = c
66            .raw
67            .get("valueExpr")
68            .and_then(serde_json::Value::as_str)
69            .filter(|s| !s.is_empty())
70            .map(str::to_string);
71        serde_json::json!({
72            "value": display_value,
73            "valueExpr": value_expr,
74            "mode": mode,
75        })
76        .to_string()
77    }
78
79    /// Edit a dimensional constraint's value (S5), a port of the previous double-click edit:
80    ///
81    /// * A PLAIN NUMBER (`^-?\d*\.?\d+$`, optional exponent) → set `value` and REMOVE
82    ///   `valueExpr` / `valueExprMode` (a literal dimension).
83    /// * Otherwise → an EXPRESSION: set `valueExpr`, evaluate it LIVE against the
84    ///   history's `expressions` + `configurator` (the kernel's `eval_expression`).
85    ///   On eval success set `value`; on FAILURE keep the old value and return
86    ///   `false` (never corrupt the doc). A diameter dim stores half the entered/
87    ///   evaluated diameter as the solver radius and tags `valueExprMode:"diameter"`.
88    ///
89    /// Sets `valueNeedsSetup:false` once a real value lands, re-solves (swallowing
90    /// errors), refreshes the overlay + marks dirty. Returns whether the value was
91    /// applied. No-op returning `false` when the constraint is absent / not in
92    /// sketch mode.
93    pub fn sketch_set_dimension_value(
94        &mut self,
95        constraint_id: &serde_json::Value,
96        input: &str,
97    ) -> bool {
98        use crate::sketch::doc::id_key;
99
100        // Read the LIVE expression environment before borrowing the session mutably.
101        let expressions = self.history.expressions();
102        let configurator = self.history.configurator();
103
104        let key = id_key(constraint_id);
105        let Some(edit) = self.sketch_edit.as_mut() else {
106            return false;
107        };
108        // Locate the constraint + read its diameter mode.
109        let is_diameter = {
110            let Some(c) = edit
111                .session
112                .doc
113                .constraints
114                .iter()
115                .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))
116            else {
117                return false;
118            };
119            c.ctype() == Some("⟺")
120                && c.raw.get("displayStyle").and_then(serde_json::Value::as_str)
121                    == Some("diameter")
122        };
123
124        let trimmed = input.trim();
125        if trimmed.is_empty() {
126            return false;
127        }
128
129        // Compute the DISPLAYED number the user typed/meant (a diameter for a
130        // diameter dim), plus whether it came from an expression, without mutating.
131        let is_plain_number = is_plain_number_literal(trimmed);
132        let displayed: f64 = if is_plain_number {
133            match trimmed.parse::<f64>() {
134                Ok(n) if n.is_finite() => n,
135                _ => return false,
136            }
137        } else {
138            match brep_kernel::eval_expression(&expressions, &configurator, trimmed) {
139                Ok(n) if n.is_finite() => n,
140                // Bad expression: keep the old value, do not corrupt the doc.
141                _ => return false,
142            }
143        };
144        // The solver stores a radius for radial dims; a diameter input halves.
145        let solver_value = if is_diameter { displayed * 0.5 } else { displayed };
146
147        // A zero/negative radial dimension collapses the circle or arc. Validate
148        // evaluated expressions too, before touching the constraint or undo stack.
149        let constraint = edit.session.doc.constraints.iter()
150            .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))
151            .expect("constraint was located above");
152        let controls_radius = constraint.ctype() == Some("⟺")
153            && edit.session.doc.geometries.iter().any(|g| {
154                matches!(g.geom_type.as_str(), "circle" | "arc")
155                    && g.points.len() >= 2
156                    && constraint.points().len() == 2
157                    && ((constraint.points()[0] == g.points[0] && constraint.points()[1] == g.points[1])
158                        || (constraint.points()[0] == g.points[1] && constraint.points()[1] == g.points[0]))
159            });
160        if (controls_radius || matches!(dimension_mode(constraint), "radius" | "diameter"))
161            && solver_value <= 1e-5 {
162            return false;
163        }
164
165        // Validation passed → the value WILL change; snapshot for undo now (S6a).
166        edit.record_undo();
167
168        // Apply to the constraint's raw map.
169        {
170            let Some(c) = edit
171                .session
172                .doc
173                .constraints
174                .iter_mut()
175                .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))
176            else {
177                return false;
178            };
179            if is_plain_number {
180                c.raw.remove("valueExpr");
181                c.raw.remove("valueExprMode");
182            } else {
183                c.raw.insert(
184                    "valueExpr".to_string(),
185                    serde_json::Value::String(trimmed.to_string()),
186                );
187                if is_diameter {
188                    c.raw.insert(
189                        "valueExprMode".to_string(),
190                        serde_json::Value::String("diameter".to_string()),
191                    );
192                } else {
193                    c.raw.remove("valueExprMode");
194                }
195            }
196            c.raw.insert(
197                "value".to_string(),
198                serde_json::Value::from(solver_value),
199            );
200            c.raw.insert(
201                "valueNeedsSetup".to_string(),
202                serde_json::Value::Bool(false),
203            );
204        }
205
206        self.resolve_active_sketch("set-dimension-value");
207        self.refresh_sketch_overlay();
208        self.dirty = true;
209        true
210    }
211
212    /// Drag a dimension label to CSS-pixel `(x, y)` (S5): map the pixel to plane
213    /// `(u, v)` (the S2 pixel→plane math), compute the label offset `{du, dv}` =
214    /// (label uv) − (the dimension's anchor uv), and store it in `session.dim_offsets`
215    /// keyed by the constraint id. Refreshes the overlay (leaders + labels follow the
216    /// cursor). No-op when not in sketch mode / the ray misses the plane / the
217    /// constraint is not dimensional.
218    pub fn sketch_dimension_drag_to(
219        &mut self,
220        constraint_id: &serde_json::Value,
221        x: f64,
222        y: f64,
223    ) {
224        use crate::sketch::doc::id_key;
225        let Some((u, v)) = self.sketch_uv_at(x, y) else {
226            return;
227        };
228        let key = id_key(constraint_id);
229        let Some(edit) = self.sketch_edit.as_mut() else {
230            return;
231        };
232        let anchor = {
233            let Some(c) = edit
234                .session
235                .doc
236                .constraints
237                .iter()
238                .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))
239            else {
240                return;
241            };
242            crate::sketch::dimensions::dimension_anchor_uv(c, &edit.session.doc)
243        };
244        let Some(anchor) = anchor else {
245            return;
246        };
247        // Snapshot ONCE on the first move of this drag gesture so the whole drag is a
248        // single undo step; the guard resets in `sketch_dimension_drag_end` (S6a).
249        if !edit.dim_drag_snapshotted {
250            edit.record_undo();
251            edit.dim_drag_snapshotted = true;
252        }
253        edit.session.dim_offsets.insert(
254            key,
255            serde_json::json!({ "du": u - anchor[0], "dv": v - anchor[1] }),
256        );
257        self.refresh_sketch_overlay();
258        self.dirty = true;
259    }
260}
261
262/// Whether `s` is a plain numeric literal: an optional
263/// sign, digits with an optional decimal (or a leading-dot decimal), and an optional
264/// exponent. An expression (anything with a variable / operator) fails this and is
265/// evaluated instead.
266pub(super) fn is_plain_number_literal(s: &str) -> bool {
267    let s = s.trim();
268    if s.is_empty() {
269        return false;
270    }
271    let bytes = s.as_bytes();
272    let mut i = 0;
273    if bytes[i] == b'+' || bytes[i] == b'-' {
274        i += 1;
275    }
276    let mut digits_before = 0;
277    while i < bytes.len() && bytes[i].is_ascii_digit() {
278        i += 1;
279        digits_before += 1;
280    }
281    let mut digits_after = 0;
282    let mut had_dot = false;
283    if i < bytes.len() && bytes[i] == b'.' {
284        had_dot = true;
285        i += 1;
286        while i < bytes.len() && bytes[i].is_ascii_digit() {
287            i += 1;
288            digits_after += 1;
289        }
290    }
291    // A dot must be followed by ≥1 digit (the pattern has no `\d+\.` form), and
292    // the mantissa needs at least one digit overall.
293    if had_dot && digits_after == 0 {
294        return false;
295    }
296    if digits_before == 0 && digits_after == 0 {
297        return false;
298    }
299    // Optional exponent `e[+-]?digits`.
300    if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
301        i += 1;
302        if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
303            i += 1;
304        }
305        let mut exp_digits = 0;
306        while i < bytes.len() && bytes[i].is_ascii_digit() {
307            i += 1;
308            exp_digits += 1;
309        }
310        if exp_digits == 0 {
311            return false;
312        }
313    }
314    i == bytes.len()
315}
316
317/// The display mode string for a dimensional constraint (mirrors the label builder):
318/// `"radius" | "diameter"` for a radial `⟺`, `"angle"` for `∠`, else `"distance"`.
319fn dimension_mode(c: &crate::sketch::SketchConstraint) -> &'static str {
320    match c.ctype() {
321        Some("∠") => "angle",
322        Some("⟺") => match c.raw.get("displayStyle").and_then(serde_json::Value::as_str) {
323            Some("diameter") => "diameter",
324            Some("radius") => "radius",
325            _ => "distance",
326        },
327        _ => "distance",
328    }
329}
330
331// ===========================================================================
332// Sketch undo/redo (S6a) — a per-session history SEPARATE from the model-level
333// undo. Each discrete mutating op snapshots the visible edit state (doc + dim
334// offsets + selection) via `SketchEdit::record_undo` at its start; `sketch_undo`
335// / `sketch_redo` walk those snapshots, swapping the current state onto the
336// opposite stack. Continuous gestures (point drag, dimension-label drag) snapshot
337// ONCE at the gesture start so one Ctrl+Z reverts the whole drag. Restores clear
338// transient interaction state (pending draw chain, in-flight drag) that a doc swap
339// could invalidate. Kept in ONE appended block so concurrent edits land clean.
340// ===========================================================================
341impl EngineState {
342    /// Undo the last sketch edit (S6a) — pop the undo stack, stash the current state
343    /// on redo, restore. Returns whether anything was undone. A no-op (false) when
344    /// not in sketch mode or the stack is empty.
345    pub fn sketch_undo(&mut self) -> bool {
346        self.sketch_undo_redo(true)
347    }
348
349    /// Redo the last undone sketch edit (S6a) — the inverse of [`sketch_undo`](Self::sketch_undo).
350    /// Returns whether anything was redone.
351    pub fn sketch_redo(&mut self) -> bool {
352        self.sketch_undo_redo(false)
353    }
354
355    /// Whether a sketch undo is available (drives the mode-bar Undo button).
356    pub fn sketch_can_undo(&self) -> bool {
357        self.sketch_edit
358            .as_ref()
359            .map_or(false, |edit| !edit.undo_stack.is_empty())
360    }
361
362    /// Whether a sketch redo is available (drives the mode-bar Redo button).
363    pub fn sketch_can_redo(&self) -> bool {
364        self.sketch_edit
365            .as_ref()
366            .map_or(false, |edit| !edit.redo_stack.is_empty())
367    }
368
369    /// End a dimension-label drag gesture (S6a): reset the first-move snapshot guard
370    /// so the NEXT drag records its own single undo step. No-op when not in sketch
371    /// mode. Called from the viewport when the label drag stops.
372    pub fn sketch_dimension_drag_end(&mut self) {
373        if let Some(edit) = self.sketch_edit.as_mut() {
374            edit.dim_drag_snapshotted = false;
375        }
376    }
377
378    /// Shared undo/redo core: move one snapshot between the undo and redo stacks
379    /// (direction chosen by `undo`) and restore it into the live session, re-solving
380    /// (errors swallowed + logged) and refreshing the overlay.
381    fn sketch_undo_redo(&mut self, undo: bool) -> bool {
382        let acted = {
383            let Some(edit) = self.sketch_edit.as_mut() else {
384                return false;
385            };
386            let popped = if undo {
387                edit.undo_stack.pop()
388            } else {
389                edit.redo_stack.pop()
390            };
391            let Some(snapshot) = popped else {
392                return false;
393            };
394            // Stash the CURRENT state on the opposite stack, then restore the pop.
395            let current = edit.snapshot();
396            if undo {
397                edit.redo_stack.push(current);
398            } else {
399                edit.undo_stack.push(current);
400            }
401            edit.session.doc = snapshot.doc;
402            edit.session.dim_offsets = snapshot.dim_offsets;
403            edit.session.selection.items = snapshot.selection;
404            edit.external_refs = snapshot.external_refs;
405            // Drop transient interaction state a restore can invalidate (an in-flight
406            // draw chain / drag / freehand stroke could reference points the snapshot no
407            // longer holds).
408            edit.pending.clear();
409            edit.hover_uv = None;
410            edit.drag = None;
411            edit.handdraw_stroke.clear();
412            edit.dim_drag_snapshotted = false;
413            edit.session.selection.hovered = None;
414            self.resolve_active_sketch("undo/redo");
415            true
416        };
417        if acted {
418            self.refresh_sketch_overlay();
419            self.dirty = true;
420        }
421        acted
422    }
423
424    /// A debug dump of a SKETCH feature's document + solved diagnostics (S6a — the
425    /// `dumpSketchDiagnostics` button). Uses the LIVE session when that feature is
426    /// being edited, else reads the persisted `sketch`/`basis` off the history and
427    /// solves a throwaway session. Returns `{ error }` when the feature is absent /
428    /// not a sketch / unparseable.
429    pub fn sketch_diagnostics_dump_json(&self, feature_id: &str) -> String {
430        if let Some(edit) = self.sketch_edit.as_ref() {
431            if edit.feature_id == feature_id {
432                return sketch_dump_value(feature_id, &edit.session).to_string();
433            }
434        }
435        let Some(index) = self.history.index_of(feature_id) else {
436            return serde_json::json!({ "error": format!("no feature '{feature_id}'") })
437                .to_string();
438        };
439        if self.history.feature_type(index).as_deref() != Some("S") {
440            return serde_json::json!({ "error": format!("'{feature_id}' is not a sketch") })
441                .to_string();
442        }
443        let persistent = self.history.feature_persistent_data(index);
444        let plane = persistent
445            .as_ref()
446            .and_then(|p| p.get("basis"))
447            .map(crate::sketch::PlaneFrame::from_basis_json)
448            .unwrap_or_else(crate::sketch::PlaneFrame::xy);
449        let doc_value = persistent
450            .as_ref()
451            .and_then(|p| p.get("sketch"))
452            .cloned()
453            .unwrap_or_else(|| {
454                serde_json::json!({ "points": [], "geometries": [], "constraints": [] })
455            });
456        let doc: crate::sketch::SketchDoc = match serde_json::from_value(doc_value) {
457            Ok(doc) => doc,
458            Err(error) => {
459                return serde_json::json!({ "error": format!("sketch doc parse: {error}") })
460                    .to_string();
461            }
462        };
463        match crate::sketch::SketchSession::new(doc, plane) {
464            Ok(session) => sketch_dump_value(feature_id, &session).to_string(),
465            Err(error) => serde_json::json!({ "error": error }).to_string(),
466        }
467    }
468}
469
470/// Build the `dumpSketchDiagnostics` payload for one solved session: the sketch doc,
471/// its solve diagnostics, the dimension offsets, and per-kind counts.
472fn sketch_dump_value(
473    feature_id: &str,
474    session: &crate::sketch::SketchSession,
475) -> serde_json::Value {
476    serde_json::json!({
477        "featureId": feature_id,
478        "sketch": serde_json::to_value(&session.doc).unwrap_or(serde_json::Value::Null),
479        "diagnostics": serde_json::to_value(&session.diagnostics)
480            .unwrap_or(serde_json::Value::Null),
481        "dimOffsets": serde_json::Value::Object(session.dim_offsets.clone()),
482        "pointCount": session.doc.points.len(),
483        "geometryCount": session.doc.geometries.len(),
484        "constraintCount": session.doc.constraints.len(),
485    })
486}
487
488// ===========================================================================
489// Sketch trim tool (S6b) — sampled-polyline intersection split/delete.
490//
491// A CLICK tool that acts IMMEDIATELY on the geometry under the cursor (no `pending`
492// buffer). Routed from the tool state machine (`sketch_tool_place_uv`'s `"trim"`
493// arm) and directly callable headless via `sketch_trim_at`. Delegates the geometry
494// math to `crate::sketch::trim` (a port of the previous trim-geometry family): sample
495// the target, collect its intersections with every other geometry, bracket the
496// click, split per type (line/circle/arc/bezier) or delete when unbounded. Owns its
497// own undo snapshot — recorded only once a geometry is actually under the cursor,
498// and popped when the trim changes nothing (no dead undo step). Kept in ONE appended
499// block so concurrent edits to the primary impl land clean.
500// ===========================================================================
501impl EngineState {
502    /// Trim the geometry under CSS-pixel `(x, y)` (S6b): map to plane uv (the S2
503    /// pixel→plane math) and trim the geometry there. Returns whether the doc
504    /// changed. A no-op returning `false` when not in sketch mode, the ray misses the
505    /// plane, or no geometry is under the cursor.
506    pub fn sketch_trim_at(&mut self, x: f64, y: f64) -> bool {
507        let Some((u, v)) = self.sketch_uv_at(x, y) else {
508            return false;
509        };
510        self.sketch_trim_uv(u, v)
511    }
512
513    /// The plane-space trim core (`sketch_trim_at` delegates here; so does the tool
514    /// state machine, which already has uv). Picks the nearest geometry within the
515    /// grab radius, snapshots undo, trims it (split or delete), re-solves + refreshes.
516    /// Returns whether the doc changed; a no-op pops its own undo snapshot so it never
517    /// leaves a dead step.
518    pub fn sketch_trim_uv(&mut self, u: f64, v: f64) -> bool {
519        let radius = self.sketch_pick_radius();
520        // Old-trim parity: a trim click on a POINT deletes that point (and any
521        // geometry/constraints depending on it), taking priority over curve trim —
522        // `pick_entity` returns a point ref when one is under the cursor. Reuse the
523        // full selection-delete cleanup (dependent geometry + orphan + constraint
524        // pruning) by targeting just this point.
525        let point_id = match self.sketch_edit.as_ref() {
526            Some(edit) => edit.session.pick_entity(u, v, radius).and_then(|entity_ref| {
527                (entity_ref.get("kind").and_then(|k| k.as_str()) == Some("point"))
528                    .then(|| entity_ref.get("id").cloned())
529                    .flatten()
530            }),
531            None => return false,
532        };
533        if let Some(point_id) = point_id {
534            if let Some(edit) = self.sketch_edit.as_mut() {
535                edit.session.clear_selection();
536                edit.session
537                    .toggle_selection(serde_json::json!({ "kind": "point", "id": point_id }));
538            }
539            return self.sketch_delete_selection();
540        }
541
542        // Otherwise a trim click targets a CURVE under the cursor.
543        let geo_id = match self.sketch_edit.as_ref() {
544            Some(edit) => crate::sketch::trim::pick_geometry_id(&edit.session.doc, u, v, radius),
545            None => return false,
546        };
547        let Some(geo_id) = geo_id else {
548            return false;
549        };
550        let Some(edit) = self.sketch_edit.as_mut() else {
551            return false;
552        };
553        if edit.session.doc.geometry(&geo_id).is_some_and(|g| g.extra.contains_key("textFrame") || g.geom_type == "text") { return false; }
554        // A geometry IS under the cursor → this click may mutate the doc; snapshot for
555        // undo before it does (S6a). Popped below if the trim changed nothing.
556        edit.record_undo();
557        let changed = crate::sketch::trim::trim_geometry(&mut edit.session.doc, &geo_id, u, v);
558        if !changed {
559            edit.undo_stack.pop();
560            return false;
561        }
562        // A trim clears the selection (the trimmed entity is gone) + hover, then
563        // re-solves so coordinates + mobility stay fresh (keeping the doc on failure).
564        edit.session.clear_selection();
565        edit.session.set_hover(None);
566        self.resolve_active_sketch("trim");
567        self.refresh_sketch_overlay();
568        self.dirty = true;
569        true
570    }
571}
572
573// ===========================================================================
574// Sketch pickEdges tool (S6b-2) — link an external solid edge as a reference.
575//
576// A CLICK tool that acts on the 3D SCENE EDGE under the cursor (NOT a plane uv):
577// the pixel is ranked through the modeling picker (`pick_json`), the top EDGE
578// candidate's world polyline is fetched from the scene, projected into the active
579// sketch plane, classified (straight `line` / fitted `circle`/`arc` / a faithful
580// `line`-chain fallback) and materialized as external-reference geometry —
581// `{fixed, construction, externalReference}` points, a `⏚` ground each, and the
582// construction geometry — via `crate::sketch::external_ref`. A per-session
583// `ExternalRef` mapping (keyed by edge name) dedups a re-pick and round-trips
584// through `persistentData.externalRefs`. Kept in ONE appended block so concurrent
585// edits to the primary impl land clean.
586// ===========================================================================
587impl EngineState {
588    /// The name of the top EDGE candidate under CSS-pixel `(x, y)` (ranked by the
589    /// same modeling picker), or `None` when nothing / no edge is there.
590    fn sketch_top_edge_name(&self, x: f64, y: f64) -> Option<String> {
591        let candidates: serde_json::Value = serde_json::from_str(&self.pick_json(x, y)).ok()?;
592        candidates
593            .as_array()?
594            .iter()
595            .find(|c| c.get("kind").and_then(|k| k.as_str()) == Some("EDGE"))
596            .and_then(|c| c.get("name").and_then(|n| n.as_str()))
597            .filter(|name| !name.is_empty())
598            .map(str::to_string)
599    }
600
601    /// Link (as a construction reference) the 3D solid edge under CSS-pixel `(x, y)`
602    /// into the active sketch (S6b-2). Ranks the pixel through the modeling picker,
603    /// takes the top EDGE candidate, and delegates to [`sketch_link_edge`](Self::sketch_link_edge). Returns
604    /// whether a link was made / updated. A no-op returning `false` when not in sketch
605    /// mode or no scene edge is under the cursor.
606    pub fn sketch_pick_edge_at(&mut self, x: f64, y: f64) -> bool {
607        if self.sketch_edit.is_none() {
608            return false;
609        }
610        // A Work part's context edge in front is COPIED (it is not the part's
611        // to link); an edge of the part's own, nearer or flush, is linked.
612        if let Some(context) = self.context_edge_at(x, y) {
613            let tolerance = 1e-6 * context.depth.abs().max(1.0) + 1e-4;
614            let own_in_front = pick::pick_unoccluded(&self.scene, &self.context_scene, &self.camera, x, y, &self.pick_options())
615                .into_iter()
616                .find(|candidate| candidate.kind == pick::PickKind::Edge)
617                .is_some_and(|own| own.depth <= context.depth + tolerance);
618            if !own_in_front {
619                return self.sketch_copy_context_edge(&context);
620            }
621        }
622        let Some(edge_name) = self.sketch_top_edge_name(x, y) else {
623            return false;
624        };
625        self.sketch_link_edge(&edge_name)
626    }
627
628    /// Link a scene edge by kernel NAME into the active sketch as an external
629    /// reference (the headless-testable core `sketch_pick_edge_at` delegates to).
630    /// Fetches the edge's world polyline from the scene, projects it into the sketch
631    /// plane, classifies + materializes (or updates) the reference, records one undo
632    /// snapshot (popped on a no-op), re-solves, and refreshes. Returns whether the doc
633    /// changed. A no-op returning `false` when not in sketch mode or the edge is
634    /// absent / degenerate.
635    pub fn sketch_link_edge(&mut self, edge_name: &str) -> bool {
636        // Fetch the world polyline (+ owning solid) from the backdrop scene first —
637        // an immutable borrow that must end before mutating the sketch edit.
638        let Some(world_poly) = self.scene.edge_polyline_world(edge_name) else {
639            return false;
640        };
641        let solid_name = self
642            .scene
643            .edge_solid_name(edge_name)
644            .unwrap_or_default()
645            .to_string();
646        let Some(edit) = self.sketch_edit.as_mut() else {
647            return false;
648        };
649        // A scene edge IS under the cursor → this may mutate the doc; snapshot for undo
650        // before it does (S6a). Popped below when the link changes nothing.
651        edit.record_undo();
652        let changed = crate::sketch::external_ref::link_or_update(
653            &mut edit.session.doc,
654            &mut edit.external_refs,
655            edge_name,
656            &solid_name,
657            &world_poly,
658            &edit.session.plane,
659        );
660        if !changed {
661            edit.undo_stack.pop();
662            return false;
663        }
664        self.resolve_active_sketch("pickEdges");
665        self.refresh_sketch_overlay();
666        self.dirty = true;
667        true
668    }
669
670    /// Sync the active sketch's edge links with its `projectedEdges` list against
671    /// the CURRENT backdrop scene (called on enter):
672    ///
673    /// - every loaded ref whose edge is present re-projects in place (via
674    ///   [`link_or_update`](crate::sketch::external_ref::link_or_update), which
675    ///   updates without duplicating) so a linked edge tracks upstream edits;
676    /// - every `listed` edge with no ref that the backdrop resolves is linked
677    ///   (an entry added in the feature dialog appears in the editor); one the
678    ///   backdrop does not resolve is remembered in `listed_unlinked` so Finish
679    ///   keeps it listed;
680    /// - when `links_listed` (a unified Finish stamped the marker), a ref the
681    ///   list no longer names was removed in the dialog and is RELEASED — its
682    ///   geometry stays as fixed construction geometry — whether or not its edge
683    ///   is present. Before the marker the entries predate the list and are kept.
684    ///
685    /// A ref whose edge is absent keeps its persisted coordinates: the backdrop
686    /// can lag the run on a background runner, so a miss here is never a
687    /// release; the regenerate fold releases on the kernel's word. Re-solves +
688    /// refreshes when anything changed. Returns whether it did. No undo
689    /// snapshot (it is part of entering, not a user edit).
690    pub(super) fn sketch_sync_external_refs(&mut self, listed: &[String], links_listed: bool) -> bool {
691        // Gather the current world polylines without holding a borrow across the
692        // mutation.
693        let Some(edit) = self.sketch_edit.as_ref() else {
694            return false;
695        };
696        let mut names: Vec<String> = listed.to_vec();
697        for r in &edit.external_refs {
698            if !names.contains(&r.edge_name) {
699                names.push(r.edge_name.clone());
700            }
701        }
702        let mut jobs: Vec<(String, String, Vec<[f64; 3]>)> = Vec::new();
703        let mut unlinked: Vec<String> = Vec::new();
704        for name in &names {
705            match self.scene.edge_polyline_world(name) {
706                Some(poly) => {
707                    let solid = self.scene.edge_solid_name(name).unwrap_or_default().to_string();
708                    jobs.push((name.clone(), solid, poly));
709                }
710                None => {
711                    if listed.contains(name) && !edit.external_refs.iter().any(|r| &r.edge_name == name) {
712                        unlinked.push(name.clone());
713                    }
714                }
715            }
716        }
717        let released: Vec<crate::sketch::ExternalRef> = if links_listed {
718            edit.external_refs
719                .iter()
720                .filter(|r| !listed.contains(&r.edge_name))
721                .cloned()
722                .collect()
723        } else {
724            Vec::new()
725        };
726        let Some(edit) = self.sketch_edit.as_mut() else {
727            return false;
728        };
729        edit.listed_unlinked = unlinked;
730        let mut changed = false;
731        for r in &released {
732            crate::sketch::external_ref::release_ref(&mut edit.session.doc, r);
733            edit.external_refs.retain(|live| live.edge_name != r.edge_name);
734            changed = true;
735        }
736        for (edge_name, solid_name, world_poly) in &jobs {
737            if released.iter().any(|r| &r.edge_name == edge_name) {
738                continue;
739            }
740            changed |= crate::sketch::external_ref::link_or_update(
741                &mut edit.session.doc,
742                &mut edit.external_refs,
743                edge_name,
744                solid_name,
745                world_poly,
746                &edit.session.plane,
747            );
748        }
749        if changed {
750            self.resolve_active_sketch("external-ref sync");
751            self.refresh_sketch_overlay();
752            self.dirty = true;
753        }
754        changed
755    }
756
757    /// The number of external-reference edge links in the active sketch (0 when not in
758    /// sketch mode) — the `__brepSketch` verifier readout.
759    pub fn sketch_external_ref_count(&self) -> usize {
760        self.sketch_edit
761            .as_ref()
762            .map_or(0, |edit| edit.external_refs.len())
763    }
764}
765
766// ===========================================================================
767// Sketch handdraw tool (S6b-3) — freehand stroke → recognized primitive.
768//
769// A DRAG tool: `sketch_handdraw_begin` snapshots undo + starts a stroke, each
770// `sketch_handdraw_move` appends a plane-`(u, v)` sample (throttled to ~2px world so
771// dense pointer events don't bloat the stroke), and `sketch_handdraw_end` recognizes
772// the stroke into ONE shape (line / circle / arc / bezier fallback via
773// `crate::sketch::handdraw`), materializes it (endpoints snap to existing points so a
774// stroke drawn onto prior geometry coincides), and re-solves. A too-short / tiny
775// stroke is discarded and pops its own undo snapshot so no dead step is left. The raw
776// stroke renders live through the `sketch-preview` overlay group. The pixel entry
777// points map the cursor to plane uv (the S2 pixel→plane math) and delegate to the
778// headless-testable uv cores. Kept in ONE appended block so concurrent edits to the
779// primary impl land clean.
780// ===========================================================================
781impl EngineState {
782    /// Begin a freehand stroke at CSS-pixel `(x, y)` (a handdraw drag start): map to
783    /// plane uv and delegate. No-op when not in sketch mode or the ray misses the plane.
784    pub fn sketch_handdraw_begin(&mut self, x: f64, y: f64) {
785        if let Some((u, v)) = self.sketch_uv_at(x, y) {
786            self.sketch_handdraw_begin_uv(u, v);
787        }
788    }
789
790    /// Extend the freehand stroke toward CSS-pixel `(x, y)` (a handdraw drag move).
791    pub fn sketch_handdraw_move(&mut self, x: f64, y: f64) {
792        if let Some((u, v)) = self.sketch_uv_at(x, y) {
793            self.sketch_handdraw_move_uv(u, v);
794        }
795    }
796
797    /// Begin a freehand stroke at plane `(u, v)` (the headless-testable core): snapshot
798    /// for undo (S6a), clear any prior stroke, and seed it with the start sample. The
799    /// undo snapshot is popped in [`sketch_handdraw_end`](Self::sketch_handdraw_end) if the stroke produces nothing.
800    /// No-op when not in sketch mode.
801    pub fn sketch_handdraw_begin_uv(&mut self, u: f64, v: f64) {
802        if let Some(edit) = self.sketch_edit.as_mut() {
803            edit.record_undo();
804            edit.handdraw_stroke.clear();
805            edit.handdraw_stroke.push((u, v));
806        } else {
807            return;
808        }
809        self.refresh_sketch_overlay();
810        self.dirty = true;
811    }
812
813    /// Append plane `(u, v)` to the live stroke (the headless-testable core), throttled
814    /// so a sample nearer than ~2px world to the last is skipped. No-op when not in
815    /// sketch mode or no stroke is live.
816    pub fn sketch_handdraw_move_uv(&mut self, u: f64, v: f64) {
817        let min_step = 2.0 * self.camera.world_per_pixel();
818        if let Some(edit) = self.sketch_edit.as_mut() {
819            let Some(&last) = edit.handdraw_stroke.last() else {
820                return; // no stroke in progress
821            };
822            if (u - last.0).hypot(v - last.1) < min_step {
823                return;
824            }
825            edit.handdraw_stroke.push((u, v));
826        } else {
827            return;
828        }
829        self.refresh_sketch_overlay();
830        self.dirty = true;
831    }
832
833    /// End the freehand stroke (a handdraw drag stop): recognize it into one shape and
834    /// materialize the geometry, re-solve, and clear the stroke + preview. A stroke that
835    /// is too short (fewer than 3 samples) or too tiny (extent below the grab radius) is
836    /// discarded, popping the undo snapshot recorded at begin so no dead step remains.
837    /// Returns whether geometry was created. No-op returning `false` when not in sketch
838    /// mode / no stroke is live.
839    pub fn sketch_handdraw_end(&mut self) -> bool {
840        let radius = self.sketch_pick_radius();
841        let Some(edit) = self.sketch_edit.as_mut() else {
842            return false;
843        };
844        if edit.handdraw_stroke.is_empty() {
845            return false; // no live stroke (a plain click, or already ended)
846        }
847        let stroke = std::mem::take(&mut edit.handdraw_stroke);
848        // Discard a stroke too short / tiny to be a deliberate shape, undoing the
849        // snapshot the begin recorded so it never leaves a dead undo step.
850        if stroke.len() < 3 || crate::sketch::handdraw::stroke_extent(&stroke) < radius {
851            edit.undo_stack.pop();
852            self.refresh_sketch_overlay();
853            self.dirty = true;
854            return false;
855        }
856        let shape = crate::sketch::handdraw::recognize(&stroke);
857        crate::sketch::handdraw::emit_shape(&mut edit.session.doc, &shape, radius);
858        self.resolve_active_sketch("handdraw");
859        self.refresh_sketch_overlay();
860        self.dirty = true;
861        true
862    }
863
864    /// The number of samples in the live handdraw stroke (0 when none / not in sketch
865    /// mode) — the `__brepSketch` verifier readout.
866    pub fn sketch_handdraw_len(&self) -> usize {
867        self.sketch_edit
868            .as_ref()
869            .map_or(0, |edit| edit.handdraw_stroke.len())
870    }
871}
872
873
874// ---- Angle measure cycling ----------------------------------------------------
875//
876// An `∠` constraint reads its angle from the ORDER of its four points: the solver
877// measures `direction(p0→p1) − direction(p2→p3)` in [0, 360). Reversing one line
878// adds 180°, swapping the two lines negates the reading, so the four sectors
879// around the lines' intersection (θ, 180−θ, θ, 180−θ) are four point orderings of
880// the same record. The order is what the document persists, so a chosen measure
881// survives solves and saves, and a sketch that predates the toggle reads exactly
882// as it did.
883impl EngineState {
884    /// The id of the ONE selected `∠` constraint when the sketch selection is
885    /// exactly that constraint and nothing else — the condition under which the
886    /// Sketch actions card offers "Angle measure". `None` otherwise / not in sketch
887    /// mode.
888    pub fn sketch_selected_angle_constraint(&self) -> Option<serde_json::Value> {
889        use crate::sketch::doc::id_key;
890        let edit = self.sketch_edit.as_ref()?;
891        if edit.session.selection.items.len() != 1 {
892            return None;
893        }
894        let r = &edit.session.selection.items[0];
895        if r.get("kind").and_then(serde_json::Value::as_str) != Some("constraint") {
896            return None;
897        }
898        let id = r.get("id")?;
899        let key = id_key(id);
900        let c = edit
901            .session
902            .doc
903            .constraints
904            .iter()
905            .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))?;
906        (c.ctype() == Some("∠") && c.points().len() >= 4).then(|| id.clone())
907    }
908
909    /// Cycle the selected `∠` constraint to the NEXT way of measuring the angle
910    /// between its two lines, without moving any geometry:
911    ///
912    /// * a convex reading (≤ 180°) steps to the adjacent sector counter-clockwise
913    ///   around the intersection — points `[p0,p1,p2,p3]` become `[p3,p2,p0,p1]`
914    ///   and the value becomes `180 − value`, so 167° flips to its 13°
915    ///   supplement, then 167°, 13°, and back to the start after four steps;
916    /// * a reflex reading (> 180°) first comes back to the SAME sector measured
917    ///   the short way — the lines swap (`[p2,p3,p0,p1]`) and the value becomes
918    ///   `360 − value` — after which the steps above apply.
919    ///
920    /// The value is transformed, never re-measured, so a conflicting constraint
921    /// keeps the intent it had; an expression dimension keeps its expression,
922    /// wrapped as `180 - (…)` / `360 - (…)`. An unset value (`valueNeedsSetup`) is
923    /// seeded from the geometry first. Records an undo step, re-solves, refreshes
924    /// the overlay and marks the document dirty. Returns whether a constraint
925    /// changed; `false` unless exactly one `∠` constraint is selected.
926    pub fn sketch_cycle_angle_measure(&mut self) -> bool {
927        use crate::sketch::doc::id_key;
928
929        let Some(id) = self.sketch_selected_angle_constraint() else {
930            return false;
931        };
932        let key = id_key(&id);
933        let Some(edit) = self.sketch_edit.as_mut() else {
934            return false;
935        };
936        // Measure the current reading first (the seed for an unset value), with
937        // the doc borrowed immutably.
938        let measured = {
939            let doc = &edit.session.doc;
940            let Some(c) = doc
941                .constraints
942                .iter()
943                .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))
944            else {
945                return false;
946            };
947            let pts = c.points();
948            let coord = |v: &serde_json::Value| doc.point(v).map(|p| (p.x, p.y));
949            let (Some(p0), Some(p1), Some(p2), Some(p3)) =
950                (coord(&pts[0]), coord(&pts[1]), coord(&pts[2]), coord(&pts[3]))
951            else {
952                return false;
953            };
954            let dir = |a: (f64, f64), b: (f64, f64)| (b.1 - a.1).atan2(b.0 - a.0).to_degrees();
955            (dir(p0, p1) - dir(p2, p3)).rem_euclid(360.0)
956        };
957
958        edit.record_undo();
959        let doc = &mut edit.session.doc;
960        let Some(c) = doc
961            .constraints
962            .iter_mut()
963            .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))
964        else {
965            return false;
966        };
967        let pts: Vec<serde_json::Value> = c.points().to_vec();
968        let value = c
969            .raw
970            .get("value")
971            .and_then(serde_json::Value::as_f64)
972            .filter(|v| v.is_finite())
973            .map(|v| v.rem_euclid(360.0))
974            .unwrap_or_else(|| (measured * 1e4).round() / 1e4);
975        let reflex = value > 180.0;
976        let (order, pivot): (Vec<serde_json::Value>, f64) = if reflex {
977            (vec![pts[2].clone(), pts[3].clone(), pts[0].clone(), pts[1].clone()], 360.0)
978        } else {
979            (vec![pts[3].clone(), pts[2].clone(), pts[0].clone(), pts[1].clone()], 180.0)
980        };
981        let next = ((pivot - value).rem_euclid(360.0) * 1e4).round() / 1e4;
982        c.raw.insert("points".to_string(), serde_json::Value::Array(order));
983        c.raw.insert("value".to_string(), serde_json::json!(next));
984        c.raw.insert("valueNeedsSetup".to_string(), serde_json::Value::Bool(false));
985        if let Some(expr) = c
986            .raw
987            .get("valueExpr")
988            .and_then(serde_json::Value::as_str)
989            .filter(|s| !s.trim().is_empty())
990            .map(str::to_string)
991        {
992            c.raw.insert(
993                "valueExpr".to_string(),
994                serde_json::Value::String(format!("{pivot} - ({expr})")),
995            );
996        }
997        // The solver's per-constraint caches are slot-wise; a reordered record must
998        // not carry the old slots' signature or solve value.
999        c.raw.remove("previousPointValues");
1000        c.raw.remove("_previousSolveValue");
1001        c.raw.remove("status");
1002        c.raw.remove("error");
1003
1004        self.resolve_active_sketch("angle-measure");
1005        self.refresh_sketch_overlay();
1006        self.dirty = true;
1007        true
1008    }
1009}