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