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