Skip to main content

brep_render/engine_state/
sketch_edit_ops.rs

1use super::*;
2
3// ===========================================================================
4// Sketch editable dimensions (S5) — leaders + label text + numeric/expression
5// value editing + drag-to-reposition.
6//
7// The leader/arrow geometry + label anchors live in `crate::sketch::dimensions`
8// (ported from the retired sketcher's `dimensions`); they refresh through the
9// state-aware overlay path (`sketch-dim-leaders` group). This block owns the
10// EDITING surface: reading a dimension's value/expr for the edit field, writing a
11// new numeric OR expression value (evaluated LIVE against the history's
12// `expressions` + `configurator` via the kernel's `eval_expression`), and dragging
13// a label to a new plane-space `{du, dv}` offset. Every mutator re-solves
14// (swallowing errors), refreshes the overlay, and marks dirty. Kept in ONE appended
15// block so concurrent edits to the primary impl land clean.
16// ===========================================================================
17impl EngineState {
18    /// The dimension labels for the active sketch (S5): one entry per dimensional
19    /// constraint — `[{ id, text, world:[x,y,z], value, valueExpr, mode }]`. `world`
20    /// is the label anchor in world space; brep-app projects it via
21    /// [`world_to_screen_json`](Self::world_to_screen_json) and draws editable text
22    /// there. `[]` when not in sketch mode.
23    pub fn sketch_dimension_labels_json(&self) -> String {
24        let world_per_pixel = self.camera.world_per_pixel();
25        let labels = match self.sketch_edit.as_ref() {
26            Some(edit) => edit.session.dimension_labels(world_per_pixel),
27            None => return "[]".to_string(),
28        };
29        let out: Vec<serde_json::Value> = labels
30            .into_iter()
31            .map(|l| {
32                serde_json::json!({
33                    "id": l.id,
34                    "text": l.text,
35                    "world": l.world,
36                    "value": l.value,
37                    "valueExpr": l.value_expr,
38                    "mode": l.mode,
39                })
40            })
41            .collect();
42        serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
43    }
44
45    /// The current `{value, valueExpr, mode}` for a dimensional constraint (S5) — the
46    /// seed for the inline edit field. `{}` when the constraint is absent / not in
47    /// sketch mode. `mode` is `"distance" | "radius" | "diameter" | "angle"`. For a
48    /// diameter dim `value` is the DISPLAYED diameter (twice the stored radius), so
49    /// the edit field round-trips what the user sees.
50    pub fn sketch_dimension_value_json(&self, constraint_id: &serde_json::Value) -> String {
51        use crate::sketch::doc::id_key;
52        let Some(edit) = self.sketch_edit.as_ref() else {
53            return "{}".to_string();
54        };
55        let key = id_key(constraint_id);
56        let Some(c) = edit
57            .session
58            .doc
59            .constraints
60            .iter()
61            .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))
62        else {
63            return "{}".to_string();
64        };
65        let is_diameter = c.ctype() == Some("⟺")
66            && c.raw.get("displayStyle").and_then(serde_json::Value::as_str) == Some("diameter");
67        let mode = dimension_mode(c);
68        let stored = c
69            .raw
70            .get("value")
71            .and_then(serde_json::Value::as_f64)
72            .filter(|v| v.is_finite());
73        let display_value = stored.map(|v| if is_diameter { v * 2.0 } else { v });
74        let value_expr = c
75            .raw
76            .get("valueExpr")
77            .and_then(serde_json::Value::as_str)
78            .filter(|s| !s.is_empty())
79            .map(str::to_string);
80        serde_json::json!({
81            "value": display_value,
82            "valueExpr": value_expr,
83            "mode": mode,
84        })
85        .to_string()
86    }
87
88    /// Edit a dimensional constraint's value (S5), a port of the previous double-click edit:
89    ///
90    /// * A PLAIN NUMBER (`^-?\d*\.?\d+$`, optional exponent) → set `value` and REMOVE
91    ///   `valueExpr` / `valueExprMode` (a literal dimension).
92    /// * Otherwise → an EXPRESSION: set `valueExpr`, evaluate it LIVE against the
93    ///   history's `expressions` + `configurator` (the kernel's `eval_expression`).
94    ///   On eval success set `value`; on FAILURE keep the old value and return
95    ///   `false` (never corrupt the doc). A diameter dim stores half the entered/
96    ///   evaluated diameter as the solver radius and tags `valueExprMode:"diameter"`.
97    ///
98    /// Sets `valueNeedsSetup:false` once a real value lands, re-solves (swallowing
99    /// errors), refreshes the overlay + marks dirty. Returns whether the value was
100    /// applied. No-op returning `false` when the constraint is absent / not in
101    /// sketch mode.
102    pub fn sketch_set_dimension_value(
103        &mut self,
104        constraint_id: &serde_json::Value,
105        input: &str,
106    ) -> bool {
107        use crate::sketch::doc::id_key;
108
109        // Read the LIVE expression environment before borrowing the session mutably.
110        let expressions = self.history.expressions();
111        let configurator = self.history.configurator();
112
113        let key = id_key(constraint_id);
114        let Some(edit) = self.sketch_edit.as_mut() else {
115            return false;
116        };
117        // Locate the constraint + read its diameter mode.
118        let is_diameter = {
119            let Some(c) = edit
120                .session
121                .doc
122                .constraints
123                .iter()
124                .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))
125            else {
126                return false;
127            };
128            c.ctype() == Some("⟺")
129                && c.raw.get("displayStyle").and_then(serde_json::Value::as_str)
130                    == Some("diameter")
131        };
132
133        let trimmed = input.trim();
134        if trimmed.is_empty() {
135            return false;
136        }
137
138        // Compute the DISPLAYED number the user typed/meant (a diameter for a
139        // diameter dim), plus whether it came from an expression, without mutating.
140        let is_plain_number = is_plain_number_literal(trimmed);
141        let displayed: f64 = if is_plain_number {
142            match trimmed.parse::<f64>() {
143                Ok(n) if n.is_finite() => n,
144                _ => return false,
145            }
146        } else {
147            match brep_kernel::eval_expression(&expressions, &configurator, trimmed) {
148                Ok(n) if n.is_finite() => n,
149                // Bad expression: keep the old value, do not corrupt the doc.
150                _ => return false,
151            }
152        };
153        // The solver stores a radius for radial dims; a diameter input halves.
154        let solver_value = if is_diameter { displayed * 0.5 } else { displayed };
155
156        // Validation passed → the value WILL change; snapshot for undo now (S6a).
157        edit.record_undo();
158
159        // Apply to the constraint's raw map.
160        {
161            let Some(c) = edit
162                .session
163                .doc
164                .constraints
165                .iter_mut()
166                .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))
167            else {
168                return false;
169            };
170            if is_plain_number {
171                c.raw.remove("valueExpr");
172                c.raw.remove("valueExprMode");
173            } else {
174                c.raw.insert(
175                    "valueExpr".to_string(),
176                    serde_json::Value::String(trimmed.to_string()),
177                );
178                if is_diameter {
179                    c.raw.insert(
180                        "valueExprMode".to_string(),
181                        serde_json::Value::String("diameter".to_string()),
182                    );
183                } else {
184                    c.raw.remove("valueExprMode");
185                }
186            }
187            c.raw.insert(
188                "value".to_string(),
189                serde_json::Value::from(solver_value),
190            );
191            c.raw.insert(
192                "valueNeedsSetup".to_string(),
193                serde_json::Value::Bool(false),
194            );
195        }
196
197        self.resolve_active_sketch("set-dimension-value");
198        self.refresh_sketch_overlay();
199        self.dirty = true;
200        true
201    }
202
203    /// Drag a dimension label to CSS-pixel `(x, y)` (S5): map the pixel to plane
204    /// `(u, v)` (the S2 pixel→plane math), compute the label offset `{du, dv}` =
205    /// (label uv) − (the dimension's anchor uv), and store it in `session.dim_offsets`
206    /// keyed by the constraint id. Refreshes the overlay (leaders + labels follow the
207    /// cursor). No-op when not in sketch mode / the ray misses the plane / the
208    /// constraint is not dimensional.
209    pub fn sketch_dimension_drag_to(
210        &mut self,
211        constraint_id: &serde_json::Value,
212        x: f64,
213        y: f64,
214    ) {
215        use crate::sketch::doc::id_key;
216        let Some((u, v)) = self.sketch_uv_at(x, y) else {
217            return;
218        };
219        let key = id_key(constraint_id);
220        let Some(edit) = self.sketch_edit.as_mut() else {
221            return;
222        };
223        let anchor = {
224            let Some(c) = edit
225                .session
226                .doc
227                .constraints
228                .iter()
229                .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))
230            else {
231                return;
232            };
233            crate::sketch::dimensions::dimension_anchor_uv(c, &edit.session.doc)
234        };
235        let Some(anchor) = anchor else {
236            return;
237        };
238        // Snapshot ONCE on the first move of this drag gesture so the whole drag is a
239        // single undo step; the guard resets in `sketch_dimension_drag_end` (S6a).
240        if !edit.dim_drag_snapshotted {
241            edit.record_undo();
242            edit.dim_drag_snapshotted = true;
243        }
244        edit.session.dim_offsets.insert(
245            key,
246            serde_json::json!({ "du": u - anchor[0], "dv": v - anchor[1] }),
247        );
248        self.refresh_sketch_overlay();
249        self.dirty = true;
250    }
251}
252
253/// Whether `s` is a plain numeric literal: an optional
254/// sign, digits with an optional decimal (or a leading-dot decimal), and an optional
255/// exponent. An expression (anything with a variable / operator) fails this and is
256/// evaluated instead.
257pub(super) fn is_plain_number_literal(s: &str) -> bool {
258    let s = s.trim();
259    if s.is_empty() {
260        return false;
261    }
262    let bytes = s.as_bytes();
263    let mut i = 0;
264    if bytes[i] == b'+' || bytes[i] == b'-' {
265        i += 1;
266    }
267    let mut digits_before = 0;
268    while i < bytes.len() && bytes[i].is_ascii_digit() {
269        i += 1;
270        digits_before += 1;
271    }
272    let mut digits_after = 0;
273    let mut had_dot = false;
274    if i < bytes.len() && bytes[i] == b'.' {
275        had_dot = true;
276        i += 1;
277        while i < bytes.len() && bytes[i].is_ascii_digit() {
278            i += 1;
279            digits_after += 1;
280        }
281    }
282    // A dot must be followed by ≥1 digit (the pattern has no `\d+\.` form), and
283    // the mantissa needs at least one digit overall.
284    if had_dot && digits_after == 0 {
285        return false;
286    }
287    if digits_before == 0 && digits_after == 0 {
288        return false;
289    }
290    // Optional exponent `e[+-]?digits`.
291    if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
292        i += 1;
293        if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
294            i += 1;
295        }
296        let mut exp_digits = 0;
297        while i < bytes.len() && bytes[i].is_ascii_digit() {
298            i += 1;
299            exp_digits += 1;
300        }
301        if exp_digits == 0 {
302            return false;
303        }
304    }
305    i == bytes.len()
306}
307
308/// The display mode string for a dimensional constraint (mirrors the label builder):
309/// `"radius" | "diameter"` for a radial `⟺`, `"angle"` for `∠`, else `"distance"`.
310fn dimension_mode(c: &crate::sketch::SketchConstraint) -> &'static str {
311    match c.ctype() {
312        Some("∠") => "angle",
313        Some("⟺") => match c.raw.get("displayStyle").and_then(serde_json::Value::as_str) {
314            Some("diameter") => "diameter",
315            Some("radius") => "radius",
316            _ => "distance",
317        },
318        _ => "distance",
319    }
320}
321
322// ===========================================================================
323// Sketch undo/redo (S6a) — a per-session history SEPARATE from the model-level
324// undo. Each discrete mutating op snapshots the visible edit state (doc + dim
325// offsets + selection) via `SketchEdit::record_undo` at its start; `sketch_undo`
326// / `sketch_redo` walk those snapshots, swapping the current state onto the
327// opposite stack. Continuous gestures (point drag, dimension-label drag) snapshot
328// ONCE at the gesture start so one Ctrl+Z reverts the whole drag. Restores clear
329// transient interaction state (pending draw chain, in-flight drag) that a doc swap
330// could invalidate. Kept in ONE appended block so concurrent edits land clean.
331// ===========================================================================
332impl EngineState {
333    /// Undo the last sketch edit (S6a) — pop the undo stack, stash the current state
334    /// on redo, restore. Returns whether anything was undone. A no-op (false) when
335    /// not in sketch mode or the stack is empty.
336    pub fn sketch_undo(&mut self) -> bool {
337        self.sketch_undo_redo(true)
338    }
339
340    /// Redo the last undone sketch edit (S6a) — the inverse of [`sketch_undo`].
341    /// Returns whether anything was redone.
342    pub fn sketch_redo(&mut self) -> bool {
343        self.sketch_undo_redo(false)
344    }
345
346    /// Whether a sketch undo is available (drives the mode-bar Undo button).
347    pub fn sketch_can_undo(&self) -> bool {
348        self.sketch_edit
349            .as_ref()
350            .map_or(false, |edit| !edit.undo_stack.is_empty())
351    }
352
353    /// Whether a sketch redo is available (drives the mode-bar Redo button).
354    pub fn sketch_can_redo(&self) -> bool {
355        self.sketch_edit
356            .as_ref()
357            .map_or(false, |edit| !edit.redo_stack.is_empty())
358    }
359
360    /// End a dimension-label drag gesture (S6a): reset the first-move snapshot guard
361    /// so the NEXT drag records its own single undo step. No-op when not in sketch
362    /// mode. Called from the viewport when the label drag stops.
363    pub fn sketch_dimension_drag_end(&mut self) {
364        if let Some(edit) = self.sketch_edit.as_mut() {
365            edit.dim_drag_snapshotted = false;
366        }
367    }
368
369    /// Shared undo/redo core: move one snapshot between the undo and redo stacks
370    /// (direction chosen by `undo`) and restore it into the live session, re-solving
371    /// (errors swallowed + logged) and refreshing the overlay.
372    fn sketch_undo_redo(&mut self, undo: bool) -> bool {
373        let acted = {
374            let Some(edit) = self.sketch_edit.as_mut() else {
375                return false;
376            };
377            let popped = if undo {
378                edit.undo_stack.pop()
379            } else {
380                edit.redo_stack.pop()
381            };
382            let Some(snapshot) = popped else {
383                return false;
384            };
385            // Stash the CURRENT state on the opposite stack, then restore the pop.
386            let current = edit.snapshot();
387            if undo {
388                edit.redo_stack.push(current);
389            } else {
390                edit.undo_stack.push(current);
391            }
392            edit.session.doc = snapshot.doc;
393            edit.session.dim_offsets = snapshot.dim_offsets;
394            edit.session.selection = snapshot.selection;
395            edit.external_refs = snapshot.external_refs;
396            // Drop transient interaction state a restore can invalidate (an in-flight
397            // draw chain / drag / freehand stroke could reference points the snapshot no
398            // longer holds).
399            edit.pending.clear();
400            edit.hover_uv = None;
401            edit.drag = None;
402            edit.handdraw_stroke.clear();
403            edit.dim_drag_snapshotted = false;
404            edit.session.hovered = None;
405            self.resolve_active_sketch("undo/redo");
406            true
407        };
408        if acted {
409            self.refresh_sketch_overlay();
410            self.dirty = true;
411        }
412        acted
413    }
414
415    /// A debug dump of a SKETCH feature's document + solved diagnostics (S6a — the
416    /// `dumpSketchDiagnostics` button). Uses the LIVE session when that feature is
417    /// being edited, else reads the persisted `sketch`/`basis` off the history and
418    /// solves a throwaway session. Returns `{ error }` when the feature is absent /
419    /// not a sketch / unparseable.
420    pub fn sketch_diagnostics_dump_json(&self, feature_id: &str) -> String {
421        if let Some(edit) = self.sketch_edit.as_ref() {
422            if edit.feature_id == feature_id {
423                return sketch_dump_value(feature_id, &edit.session).to_string();
424            }
425        }
426        let Some(index) = self.history.index_of(feature_id) else {
427            return serde_json::json!({ "error": format!("no feature '{feature_id}'") })
428                .to_string();
429        };
430        if self.history.feature_type(index).as_deref() != Some("S") {
431            return serde_json::json!({ "error": format!("'{feature_id}' is not a sketch") })
432                .to_string();
433        }
434        let persistent = self.history.feature_persistent_data(index);
435        let plane = persistent
436            .as_ref()
437            .and_then(|p| p.get("basis"))
438            .map(crate::sketch::PlaneFrame::from_basis_json)
439            .unwrap_or_else(crate::sketch::PlaneFrame::xy);
440        let doc_value = persistent
441            .as_ref()
442            .and_then(|p| p.get("sketch"))
443            .cloned()
444            .unwrap_or_else(|| {
445                serde_json::json!({ "points": [], "geometries": [], "constraints": [] })
446            });
447        let doc: crate::sketch::SketchDoc = match serde_json::from_value(doc_value) {
448            Ok(doc) => doc,
449            Err(error) => {
450                return serde_json::json!({ "error": format!("sketch doc parse: {error}") })
451                    .to_string();
452            }
453        };
454        match crate::sketch::SketchSession::new(doc, plane) {
455            Ok(session) => sketch_dump_value(feature_id, &session).to_string(),
456            Err(error) => serde_json::json!({ "error": error }).to_string(),
457        }
458    }
459}
460
461/// Build the `dumpSketchDiagnostics` payload for one solved session: the sketch doc,
462/// its solve diagnostics, the dimension offsets, and per-kind counts.
463fn sketch_dump_value(
464    feature_id: &str,
465    session: &crate::sketch::SketchSession,
466) -> serde_json::Value {
467    serde_json::json!({
468        "featureId": feature_id,
469        "sketch": serde_json::to_value(&session.doc).unwrap_or(serde_json::Value::Null),
470        "diagnostics": serde_json::to_value(&session.diagnostics)
471            .unwrap_or(serde_json::Value::Null),
472        "dimOffsets": serde_json::Value::Object(session.dim_offsets.clone()),
473        "pointCount": session.doc.points.len(),
474        "geometryCount": session.doc.geometries.len(),
475        "constraintCount": session.doc.constraints.len(),
476    })
477}
478
479// ===========================================================================
480// Sketch trim tool (S6b) — sampled-polyline intersection split/delete.
481//
482// A CLICK tool that acts IMMEDIATELY on the geometry under the cursor (no `pending`
483// buffer). Routed from the tool state machine (`sketch_tool_place_uv`'s `"trim"`
484// arm) and directly callable headless via `sketch_trim_at`. Delegates the geometry
485// math to `crate::sketch::trim` (a port of the previous trim-geometry family): sample
486// the target, collect its intersections with every other geometry, bracket the
487// click, split per type (line/circle/arc/bezier) or delete when unbounded. Owns its
488// own undo snapshot — recorded only once a geometry is actually under the cursor,
489// and popped when the trim changes nothing (no dead undo step). Kept in ONE appended
490// block so concurrent edits to the primary impl land clean.
491// ===========================================================================
492impl EngineState {
493    /// Trim the geometry under CSS-pixel `(x, y)` (S6b): map to plane uv (the S2
494    /// pixel→plane math) and trim the geometry there. Returns whether the doc
495    /// changed. A no-op returning `false` when not in sketch mode, the ray misses the
496    /// plane, or no geometry is under the cursor.
497    pub fn sketch_trim_at(&mut self, x: f64, y: f64) -> bool {
498        let Some((u, v)) = self.sketch_uv_at(x, y) else {
499            return false;
500        };
501        self.sketch_trim_uv(u, v)
502    }
503
504    /// The plane-space trim core (`sketch_trim_at` delegates here; so does the tool
505    /// state machine, which already has uv). Picks the nearest geometry within the
506    /// grab radius, snapshots undo, trims it (split or delete), re-solves + refreshes.
507    /// Returns whether the doc changed; a no-op pops its own undo snapshot so it never
508    /// leaves a dead step.
509    pub fn sketch_trim_uv(&mut self, u: f64, v: f64) -> bool {
510        let radius = self.sketch_pick_radius();
511        // Old-trim parity: a trim click on a POINT deletes that point (and any
512        // geometry/constraints depending on it), taking priority over curve trim —
513        // `pick_entity` returns a point ref when one is under the cursor. Reuse the
514        // full selection-delete cleanup (dependent geometry + orphan + constraint
515        // pruning) by targeting just this point.
516        let point_id = match self.sketch_edit.as_ref() {
517            Some(edit) => edit.session.pick_entity(u, v, radius).and_then(|entity_ref| {
518                (entity_ref.get("kind").and_then(|k| k.as_str()) == Some("point"))
519                    .then(|| entity_ref.get("id").cloned())
520                    .flatten()
521            }),
522            None => return false,
523        };
524        if let Some(point_id) = point_id {
525            if let Some(edit) = self.sketch_edit.as_mut() {
526                edit.session.clear_selection();
527                edit.session
528                    .toggle_selection(serde_json::json!({ "kind": "point", "id": point_id }));
529            }
530            return self.sketch_delete_selection();
531        }
532
533        // Otherwise a trim click targets a CURVE under the cursor.
534        let geo_id = match self.sketch_edit.as_ref() {
535            Some(edit) => crate::sketch::trim::pick_geometry_id(&edit.session.doc, u, v, radius),
536            None => return false,
537        };
538        let Some(geo_id) = geo_id else {
539            return false;
540        };
541        let Some(edit) = self.sketch_edit.as_mut() else {
542            return false;
543        };
544        // A geometry IS under the cursor → this click may mutate the doc; snapshot for
545        // undo before it does (S6a). Popped below if the trim changed nothing.
546        edit.record_undo();
547        let changed = crate::sketch::trim::trim_geometry(&mut edit.session.doc, &geo_id, u, v);
548        if !changed {
549            edit.undo_stack.pop();
550            return false;
551        }
552        // A trim clears the selection (the trimmed entity is gone) + hover, then
553        // re-solves so coordinates + mobility stay fresh (keeping the doc on failure).
554        edit.session.clear_selection();
555        edit.session.set_hover(None);
556        self.resolve_active_sketch("trim");
557        self.refresh_sketch_overlay();
558        self.dirty = true;
559        true
560    }
561}
562
563// ===========================================================================
564// Sketch pickEdges tool (S6b-2) — link an external solid edge as a reference.
565//
566// A CLICK tool that acts on the 3D SCENE EDGE under the cursor (NOT a plane uv):
567// the pixel is ranked through the modeling picker (`pick_json`), the top EDGE
568// candidate's world polyline is fetched from the scene, projected into the active
569// sketch plane, classified (straight `line` / fitted `circle`/`arc` / a faithful
570// `line`-chain fallback) and materialized as external-reference geometry —
571// `{fixed, construction, externalReference}` points, a `⏚` ground each, and the
572// construction geometry — via `crate::sketch::external_ref`. A per-session
573// `ExternalRef` mapping (keyed by edge name) dedups a re-pick and round-trips
574// through `persistentData.externalRefs`. Kept in ONE appended block so concurrent
575// edits to the primary impl land clean.
576// ===========================================================================
577impl EngineState {
578    /// The name of the top EDGE candidate under CSS-pixel `(x, y)` (ranked by the
579    /// same modeling picker), or `None` when nothing / no edge is there.
580    fn sketch_top_edge_name(&self, x: f64, y: f64) -> Option<String> {
581        let candidates: serde_json::Value = serde_json::from_str(&self.pick_json(x, y)).ok()?;
582        candidates
583            .as_array()?
584            .iter()
585            .find(|c| c.get("kind").and_then(|k| k.as_str()) == Some("EDGE"))
586            .and_then(|c| c.get("name").and_then(|n| n.as_str()))
587            .filter(|name| !name.is_empty())
588            .map(str::to_string)
589    }
590
591    /// Link (as a construction reference) the 3D solid edge under CSS-pixel `(x, y)`
592    /// into the active sketch (S6b-2). Ranks the pixel through the modeling picker,
593    /// takes the top EDGE candidate, and delegates to [`sketch_link_edge`]. Returns
594    /// whether a link was made / updated. A no-op returning `false` when not in sketch
595    /// mode or no scene edge is under the cursor.
596    pub fn sketch_pick_edge_at(&mut self, x: f64, y: f64) -> bool {
597        if self.sketch_edit.is_none() {
598            return false;
599        }
600        let Some(edge_name) = self.sketch_top_edge_name(x, y) else {
601            return false;
602        };
603        self.sketch_link_edge(&edge_name)
604    }
605
606    /// Link a scene edge by kernel NAME into the active sketch as an external
607    /// reference (the headless-testable core `sketch_pick_edge_at` delegates to).
608    /// Fetches the edge's world polyline from the scene, projects it into the sketch
609    /// plane, classifies + materializes (or updates) the reference, records one undo
610    /// snapshot (popped on a no-op), re-solves, and refreshes. Returns whether the doc
611    /// changed. A no-op returning `false` when not in sketch mode or the edge is
612    /// absent / degenerate.
613    pub fn sketch_link_edge(&mut self, edge_name: &str) -> bool {
614        // Fetch the world polyline (+ owning solid) from the backdrop scene first —
615        // an immutable borrow that must end before mutating the sketch edit.
616        let Some(world_poly) = self.scene.edge_polyline_world(edge_name) else {
617            return false;
618        };
619        let solid_name = self
620            .scene
621            .edge_solid_name(edge_name)
622            .unwrap_or_default()
623            .to_string();
624        let Some(edit) = self.sketch_edit.as_mut() else {
625            return false;
626        };
627        // A scene edge IS under the cursor → this may mutate the doc; snapshot for undo
628        // before it does (S6a). Popped below when the link changes nothing.
629        edit.record_undo();
630        let changed = crate::sketch::external_ref::link_or_update(
631            &mut edit.session.doc,
632            &mut edit.external_refs,
633            edge_name,
634            &solid_name,
635            &world_poly,
636            &edit.session.plane,
637        );
638        if !changed {
639            edit.undo_stack.pop();
640            return false;
641        }
642        self.resolve_active_sketch("pickEdges");
643        self.refresh_sketch_overlay();
644        self.dirty = true;
645        true
646    }
647
648    /// Re-project every loaded external-reference edge against the CURRENT backdrop
649    /// scene (called on enter): for each ref whose edge is still present, refresh its
650    /// points' coordinates in place (via [`link_or_update`](crate::sketch::external_ref::link_or_update),
651    /// which updates without duplicating). Best-effort — a ref whose edge is gone
652    /// keeps its persisted coordinates as the fallback. Re-solves + refreshes when any
653    /// ref moved. Returns whether anything changed. No undo snapshot (it is part of
654    /// entering, not a user edit).
655    pub(super) fn sketch_reproject_external_refs(&mut self) -> bool {
656        // Gather the current world polylines for the loaded refs without holding a
657        // borrow across the mutation.
658        let jobs: Vec<(String, String, Vec<[f64; 3]>)> = match self.sketch_edit.as_ref() {
659            Some(edit) if !edit.external_refs.is_empty() => edit
660                .external_refs
661                .iter()
662                .filter_map(|r| {
663                    let poly = self.scene.edge_polyline_world(&r.edge_name)?;
664                    let solid = self
665                        .scene
666                        .edge_solid_name(&r.edge_name)
667                        .unwrap_or_default()
668                        .to_string();
669                    Some((r.edge_name.clone(), solid, poly))
670                })
671                .collect(),
672            _ => return false,
673        };
674        if jobs.is_empty() {
675            return false;
676        }
677        let Some(edit) = self.sketch_edit.as_mut() else {
678            return false;
679        };
680        let mut changed = false;
681        for (edge_name, solid_name, world_poly) in &jobs {
682            changed |= crate::sketch::external_ref::link_or_update(
683                &mut edit.session.doc,
684                &mut edit.external_refs,
685                edge_name,
686                solid_name,
687                world_poly,
688                &edit.session.plane,
689            );
690        }
691        if changed {
692            self.resolve_active_sketch("external-ref reproject");
693            self.refresh_sketch_overlay();
694            self.dirty = true;
695        }
696        changed
697    }
698
699    /// The number of external-reference edge links in the active sketch (0 when not in
700    /// sketch mode) — the `__brepSketch` verifier readout.
701    pub fn sketch_external_ref_count(&self) -> usize {
702        self.sketch_edit
703            .as_ref()
704            .map_or(0, |edit| edit.external_refs.len())
705    }
706}
707
708// ===========================================================================
709// Sketch handdraw tool (S6b-3) — freehand stroke → recognized primitive.
710//
711// A DRAG tool: `sketch_handdraw_begin` snapshots undo + starts a stroke, each
712// `sketch_handdraw_move` appends a plane-`(u, v)` sample (throttled to ~2px world so
713// dense pointer events don't bloat the stroke), and `sketch_handdraw_end` recognizes
714// the stroke into ONE shape (line / circle / arc / bezier fallback via
715// `crate::sketch::handdraw`), materializes it (endpoints snap to existing points so a
716// stroke drawn onto prior geometry coincides), and re-solves. A too-short / tiny
717// stroke is discarded and pops its own undo snapshot so no dead step is left. The raw
718// stroke renders live through the `sketch-preview` overlay group. The pixel entry
719// points map the cursor to plane uv (the S2 pixel→plane math) and delegate to the
720// headless-testable uv cores. Kept in ONE appended block so concurrent edits to the
721// primary impl land clean.
722// ===========================================================================
723impl EngineState {
724    /// Begin a freehand stroke at CSS-pixel `(x, y)` (a handdraw drag start): map to
725    /// plane uv and delegate. No-op when not in sketch mode or the ray misses the plane.
726    pub fn sketch_handdraw_begin(&mut self, x: f64, y: f64) {
727        if let Some((u, v)) = self.sketch_uv_at(x, y) {
728            self.sketch_handdraw_begin_uv(u, v);
729        }
730    }
731
732    /// Extend the freehand stroke toward CSS-pixel `(x, y)` (a handdraw drag move).
733    pub fn sketch_handdraw_move(&mut self, x: f64, y: f64) {
734        if let Some((u, v)) = self.sketch_uv_at(x, y) {
735            self.sketch_handdraw_move_uv(u, v);
736        }
737    }
738
739    /// Begin a freehand stroke at plane `(u, v)` (the headless-testable core): snapshot
740    /// for undo (S6a), clear any prior stroke, and seed it with the start sample. The
741    /// undo snapshot is popped in [`sketch_handdraw_end`] if the stroke produces nothing.
742    /// No-op when not in sketch mode.
743    pub fn sketch_handdraw_begin_uv(&mut self, u: f64, v: f64) {
744        if let Some(edit) = self.sketch_edit.as_mut() {
745            edit.record_undo();
746            edit.handdraw_stroke.clear();
747            edit.handdraw_stroke.push((u, v));
748        } else {
749            return;
750        }
751        self.refresh_sketch_overlay();
752        self.dirty = true;
753    }
754
755    /// Append plane `(u, v)` to the live stroke (the headless-testable core), throttled
756    /// so a sample nearer than ~2px world to the last is skipped. No-op when not in
757    /// sketch mode or no stroke is live.
758    pub fn sketch_handdraw_move_uv(&mut self, u: f64, v: f64) {
759        let min_step = 2.0 * self.camera.world_per_pixel();
760        if let Some(edit) = self.sketch_edit.as_mut() {
761            let Some(&last) = edit.handdraw_stroke.last() else {
762                return; // no stroke in progress
763            };
764            if (u - last.0).hypot(v - last.1) < min_step {
765                return;
766            }
767            edit.handdraw_stroke.push((u, v));
768        } else {
769            return;
770        }
771        self.refresh_sketch_overlay();
772        self.dirty = true;
773    }
774
775    /// End the freehand stroke (a handdraw drag stop): recognize it into one shape and
776    /// materialize the geometry, re-solve, and clear the stroke + preview. A stroke that
777    /// is too short (fewer than 3 samples) or too tiny (extent below the grab radius) is
778    /// discarded, popping the undo snapshot recorded at begin so no dead step remains.
779    /// Returns whether geometry was created. No-op returning `false` when not in sketch
780    /// mode / no stroke is live.
781    pub fn sketch_handdraw_end(&mut self) -> bool {
782        let radius = self.sketch_pick_radius();
783        let Some(edit) = self.sketch_edit.as_mut() else {
784            return false;
785        };
786        if edit.handdraw_stroke.is_empty() {
787            return false; // no live stroke (a plain click, or already ended)
788        }
789        let stroke = std::mem::take(&mut edit.handdraw_stroke);
790        // Discard a stroke too short / tiny to be a deliberate shape, undoing the
791        // snapshot the begin recorded so it never leaves a dead undo step.
792        if stroke.len() < 3 || crate::sketch::handdraw::stroke_extent(&stroke) < radius {
793            edit.undo_stack.pop();
794            self.refresh_sketch_overlay();
795            self.dirty = true;
796            return false;
797        }
798        let shape = crate::sketch::handdraw::recognize(&stroke);
799        crate::sketch::handdraw::emit_shape(&mut edit.session.doc, &shape, radius);
800        self.resolve_active_sketch("handdraw");
801        self.refresh_sketch_overlay();
802        self.dirty = true;
803        true
804    }
805
806    /// The number of samples in the live handdraw stroke (0 when none / not in sketch
807    /// mode) — the `__brepSketch` verifier readout.
808    pub fn sketch_handdraw_len(&self) -> usize {
809        self.sketch_edit
810            .as_ref()
811            .map_or(0, |edit| edit.handdraw_stroke.len())
812    }
813}
814