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