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