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.items = 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.selection.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 if edit.session.doc.geometry(&geo_id).is_some_and(|g| g.extra.contains_key("textFrame") || g.geom_type == "text") { return false; }
554 // A geometry IS under the cursor → this click may mutate the doc; snapshot for
555 // undo before it does (S6a). Popped below if the trim changed nothing.
556 edit.record_undo();
557 let changed = crate::sketch::trim::trim_geometry(&mut edit.session.doc, &geo_id, u, v);
558 if !changed {
559 edit.undo_stack.pop();
560 return false;
561 }
562 // A trim clears the selection (the trimmed entity is gone) + hover, then
563 // re-solves so coordinates + mobility stay fresh (keeping the doc on failure).
564 edit.session.clear_selection();
565 edit.session.set_hover(None);
566 self.resolve_active_sketch("trim");
567 self.refresh_sketch_overlay();
568 self.dirty = true;
569 true
570 }
571}
572
573// ===========================================================================
574// Sketch pickEdges tool (S6b-2) — link an external solid edge as a reference.
575//
576// A CLICK tool that acts on the 3D SCENE EDGE under the cursor (NOT a plane uv):
577// the pixel is ranked through the modeling picker (`pick_json`), the top EDGE
578// candidate's world polyline is fetched from the scene, projected into the active
579// sketch plane, classified (straight `line` / fitted `circle`/`arc` / a faithful
580// `line`-chain fallback) and materialized as external-reference geometry —
581// `{fixed, construction, externalReference}` points, a `⏚` ground each, and the
582// construction geometry — via `crate::sketch::external_ref`. A per-session
583// `ExternalRef` mapping (keyed by edge name) dedups a re-pick and round-trips
584// through `persistentData.externalRefs`. Kept in ONE appended block so concurrent
585// edits to the primary impl land clean.
586// ===========================================================================
587impl EngineState {
588 /// The name of the top EDGE candidate under CSS-pixel `(x, y)` (ranked by the
589 /// same modeling picker), or `None` when nothing / no edge is there.
590 fn sketch_top_edge_name(&self, x: f64, y: f64) -> Option<String> {
591 let candidates: serde_json::Value = serde_json::from_str(&self.pick_json(x, y)).ok()?;
592 candidates
593 .as_array()?
594 .iter()
595 .find(|c| c.get("kind").and_then(|k| k.as_str()) == Some("EDGE"))
596 .and_then(|c| c.get("name").and_then(|n| n.as_str()))
597 .filter(|name| !name.is_empty())
598 .map(str::to_string)
599 }
600
601 /// Link (as a construction reference) the 3D solid edge under CSS-pixel `(x, y)`
602 /// into the active sketch (S6b-2). Ranks the pixel through the modeling picker,
603 /// takes the top EDGE candidate, and delegates to [`sketch_link_edge`](Self::sketch_link_edge). Returns
604 /// whether a link was made / updated. A no-op returning `false` when not in sketch
605 /// mode or no scene edge is under the cursor.
606 pub fn sketch_pick_edge_at(&mut self, x: f64, y: f64) -> bool {
607 if self.sketch_edit.is_none() {
608 return false;
609 }
610 // A Work part's context edge in front is COPIED (it is not the part's
611 // to link); an edge of the part's own, nearer or flush, is linked.
612 if let Some(context) = self.context_edge_at(x, y) {
613 let tolerance = 1e-6 * context.depth.abs().max(1.0) + 1e-4;
614 let own_in_front = pick::pick_unoccluded(&self.scene, &self.context_scene, &self.camera, x, y, &self.pick_options())
615 .into_iter()
616 .find(|candidate| candidate.kind == pick::PickKind::Edge)
617 .is_some_and(|own| own.depth <= context.depth + tolerance);
618 if !own_in_front {
619 return self.sketch_copy_context_edge(&context);
620 }
621 }
622 let Some(edge_name) = self.sketch_top_edge_name(x, y) else {
623 return false;
624 };
625 self.sketch_link_edge(&edge_name)
626 }
627
628 /// Link a scene edge by kernel NAME into the active sketch as an external
629 /// reference (the headless-testable core `sketch_pick_edge_at` delegates to).
630 /// Fetches the edge's world polyline from the scene, projects it into the sketch
631 /// plane, classifies + materializes (or updates) the reference, records one undo
632 /// snapshot (popped on a no-op), re-solves, and refreshes. Returns whether the doc
633 /// changed. A no-op returning `false` when not in sketch mode or the edge is
634 /// absent / degenerate.
635 pub fn sketch_link_edge(&mut self, edge_name: &str) -> bool {
636 // Fetch the world polyline (+ owning solid) from the backdrop scene first —
637 // an immutable borrow that must end before mutating the sketch edit.
638 let Some(world_poly) = self.scene.edge_polyline_world(edge_name) else {
639 return false;
640 };
641 let solid_name = self
642 .scene
643 .edge_solid_name(edge_name)
644 .unwrap_or_default()
645 .to_string();
646 let Some(edit) = self.sketch_edit.as_mut() else {
647 return false;
648 };
649 // A scene edge IS under the cursor → this may mutate the doc; snapshot for undo
650 // before it does (S6a). Popped below when the link changes nothing.
651 edit.record_undo();
652 let changed = crate::sketch::external_ref::link_or_update(
653 &mut edit.session.doc,
654 &mut edit.external_refs,
655 edge_name,
656 &solid_name,
657 &world_poly,
658 &edit.session.plane,
659 );
660 if !changed {
661 edit.undo_stack.pop();
662 return false;
663 }
664 self.resolve_active_sketch("pickEdges");
665 self.refresh_sketch_overlay();
666 self.dirty = true;
667 true
668 }
669
670 /// Sync the active sketch's edge links with its `projectedEdges` list against
671 /// the CURRENT backdrop scene (called on enter):
672 ///
673 /// - every loaded ref whose edge is present re-projects in place (via
674 /// [`link_or_update`](crate::sketch::external_ref::link_or_update), which
675 /// updates without duplicating) so a linked edge tracks upstream edits;
676 /// - every `listed` edge with no ref that the backdrop resolves is linked
677 /// (an entry added in the feature dialog appears in the editor); one the
678 /// backdrop does not resolve is remembered in `listed_unlinked` so Finish
679 /// keeps it listed;
680 /// - when `links_listed` (a unified Finish stamped the marker), a ref the
681 /// list no longer names was removed in the dialog and is RELEASED — its
682 /// geometry stays as fixed construction geometry — whether or not its edge
683 /// is present. Before the marker the entries predate the list and are kept.
684 ///
685 /// A ref whose edge is absent keeps its persisted coordinates: the backdrop
686 /// can lag the run on a background runner, so a miss here is never a
687 /// release; the regenerate fold releases on the kernel's word. Re-solves +
688 /// refreshes when anything changed. Returns whether it did. No undo
689 /// snapshot (it is part of entering, not a user edit).
690 pub(super) fn sketch_sync_external_refs(&mut self, listed: &[String], links_listed: bool) -> bool {
691 // Gather the current world polylines without holding a borrow across the
692 // mutation.
693 let Some(edit) = self.sketch_edit.as_ref() else {
694 return false;
695 };
696 let mut names: Vec<String> = listed.to_vec();
697 for r in &edit.external_refs {
698 if !names.contains(&r.edge_name) {
699 names.push(r.edge_name.clone());
700 }
701 }
702 let mut jobs: Vec<(String, String, Vec<[f64; 3]>)> = Vec::new();
703 let mut unlinked: Vec<String> = Vec::new();
704 for name in &names {
705 match self.scene.edge_polyline_world(name) {
706 Some(poly) => {
707 let solid = self.scene.edge_solid_name(name).unwrap_or_default().to_string();
708 jobs.push((name.clone(), solid, poly));
709 }
710 None => {
711 if listed.contains(name) && !edit.external_refs.iter().any(|r| &r.edge_name == name) {
712 unlinked.push(name.clone());
713 }
714 }
715 }
716 }
717 let released: Vec<crate::sketch::ExternalRef> = if links_listed {
718 edit.external_refs
719 .iter()
720 .filter(|r| !listed.contains(&r.edge_name))
721 .cloned()
722 .collect()
723 } else {
724 Vec::new()
725 };
726 let Some(edit) = self.sketch_edit.as_mut() else {
727 return false;
728 };
729 edit.listed_unlinked = unlinked;
730 let mut changed = false;
731 for r in &released {
732 crate::sketch::external_ref::release_ref(&mut edit.session.doc, r);
733 edit.external_refs.retain(|live| live.edge_name != r.edge_name);
734 changed = true;
735 }
736 for (edge_name, solid_name, world_poly) in &jobs {
737 if released.iter().any(|r| &r.edge_name == edge_name) {
738 continue;
739 }
740 changed |= crate::sketch::external_ref::link_or_update(
741 &mut edit.session.doc,
742 &mut edit.external_refs,
743 edge_name,
744 solid_name,
745 world_poly,
746 &edit.session.plane,
747 );
748 }
749 if changed {
750 self.resolve_active_sketch("external-ref sync");
751 self.refresh_sketch_overlay();
752 self.dirty = true;
753 }
754 changed
755 }
756
757 /// The number of external-reference edge links in the active sketch (0 when not in
758 /// sketch mode) — the `__brepSketch` verifier readout.
759 pub fn sketch_external_ref_count(&self) -> usize {
760 self.sketch_edit
761 .as_ref()
762 .map_or(0, |edit| edit.external_refs.len())
763 }
764}
765
766// ===========================================================================
767// Sketch handdraw tool (S6b-3) — freehand stroke → recognized primitive.
768//
769// A DRAG tool: `sketch_handdraw_begin` snapshots undo + starts a stroke, each
770// `sketch_handdraw_move` appends a plane-`(u, v)` sample (throttled to ~2px world so
771// dense pointer events don't bloat the stroke), and `sketch_handdraw_end` recognizes
772// the stroke into ONE shape (line / circle / arc / bezier fallback via
773// `crate::sketch::handdraw`), materializes it (endpoints snap to existing points so a
774// stroke drawn onto prior geometry coincides), and re-solves. A too-short / tiny
775// stroke is discarded and pops its own undo snapshot so no dead step is left. The raw
776// stroke renders live through the `sketch-preview` overlay group. The pixel entry
777// points map the cursor to plane uv (the S2 pixel→plane math) and delegate to the
778// headless-testable uv cores. Kept in ONE appended block so concurrent edits to the
779// primary impl land clean.
780// ===========================================================================
781impl EngineState {
782 /// Begin a freehand stroke at CSS-pixel `(x, y)` (a handdraw drag start): map to
783 /// plane uv and delegate. No-op when not in sketch mode or the ray misses the plane.
784 pub fn sketch_handdraw_begin(&mut self, x: f64, y: f64) {
785 if let Some((u, v)) = self.sketch_uv_at(x, y) {
786 self.sketch_handdraw_begin_uv(u, v);
787 }
788 }
789
790 /// Extend the freehand stroke toward CSS-pixel `(x, y)` (a handdraw drag move).
791 pub fn sketch_handdraw_move(&mut self, x: f64, y: f64) {
792 if let Some((u, v)) = self.sketch_uv_at(x, y) {
793 self.sketch_handdraw_move_uv(u, v);
794 }
795 }
796
797 /// Begin a freehand stroke at plane `(u, v)` (the headless-testable core): snapshot
798 /// for undo (S6a), clear any prior stroke, and seed it with the start sample. The
799 /// undo snapshot is popped in [`sketch_handdraw_end`](Self::sketch_handdraw_end) if the stroke produces nothing.
800 /// No-op when not in sketch mode.
801 pub fn sketch_handdraw_begin_uv(&mut self, u: f64, v: f64) {
802 if let Some(edit) = self.sketch_edit.as_mut() {
803 edit.record_undo();
804 edit.handdraw_stroke.clear();
805 edit.handdraw_stroke.push((u, v));
806 } else {
807 return;
808 }
809 self.refresh_sketch_overlay();
810 self.dirty = true;
811 }
812
813 /// Append plane `(u, v)` to the live stroke (the headless-testable core), throttled
814 /// so a sample nearer than ~2px world to the last is skipped. No-op when not in
815 /// sketch mode or no stroke is live.
816 pub fn sketch_handdraw_move_uv(&mut self, u: f64, v: f64) {
817 let min_step = 2.0 * self.camera.world_per_pixel();
818 if let Some(edit) = self.sketch_edit.as_mut() {
819 let Some(&last) = edit.handdraw_stroke.last() else {
820 return; // no stroke in progress
821 };
822 if (u - last.0).hypot(v - last.1) < min_step {
823 return;
824 }
825 edit.handdraw_stroke.push((u, v));
826 } else {
827 return;
828 }
829 self.refresh_sketch_overlay();
830 self.dirty = true;
831 }
832
833 /// End the freehand stroke (a handdraw drag stop): recognize it into one shape and
834 /// materialize the geometry, re-solve, and clear the stroke + preview. A stroke that
835 /// is too short (fewer than 3 samples) or too tiny (extent below the grab radius) is
836 /// discarded, popping the undo snapshot recorded at begin so no dead step remains.
837 /// Returns whether geometry was created. No-op returning `false` when not in sketch
838 /// mode / no stroke is live.
839 pub fn sketch_handdraw_end(&mut self) -> bool {
840 let radius = self.sketch_pick_radius();
841 let Some(edit) = self.sketch_edit.as_mut() else {
842 return false;
843 };
844 if edit.handdraw_stroke.is_empty() {
845 return false; // no live stroke (a plain click, or already ended)
846 }
847 let stroke = std::mem::take(&mut edit.handdraw_stroke);
848 // Discard a stroke too short / tiny to be a deliberate shape, undoing the
849 // snapshot the begin recorded so it never leaves a dead undo step.
850 if stroke.len() < 3 || crate::sketch::handdraw::stroke_extent(&stroke) < radius {
851 edit.undo_stack.pop();
852 self.refresh_sketch_overlay();
853 self.dirty = true;
854 return false;
855 }
856 let shape = crate::sketch::handdraw::recognize(&stroke);
857 crate::sketch::handdraw::emit_shape(&mut edit.session.doc, &shape, radius);
858 self.resolve_active_sketch("handdraw");
859 self.refresh_sketch_overlay();
860 self.dirty = true;
861 true
862 }
863
864 /// The number of samples in the live handdraw stroke (0 when none / not in sketch
865 /// mode) — the `__brepSketch` verifier readout.
866 pub fn sketch_handdraw_len(&self) -> usize {
867 self.sketch_edit
868 .as_ref()
869 .map_or(0, |edit| edit.handdraw_stroke.len())
870 }
871}
872
873
874// ---- Angle measure cycling ----------------------------------------------------
875//
876// An `∠` constraint reads its angle from the ORDER of its four points: the solver
877// measures `direction(p0→p1) − direction(p2→p3)` in [0, 360). Reversing one line
878// adds 180°, swapping the two lines negates the reading, so the four sectors
879// around the lines' intersection (θ, 180−θ, θ, 180−θ) are four point orderings of
880// the same record. The order is what the document persists, so a chosen measure
881// survives solves and saves, and a sketch that predates the toggle reads exactly
882// as it did.
883impl EngineState {
884 /// The id of the ONE selected `∠` constraint when the sketch selection is
885 /// exactly that constraint and nothing else — the condition under which the
886 /// Sketch actions card offers "Angle measure". `None` otherwise / not in sketch
887 /// mode.
888 pub fn sketch_selected_angle_constraint(&self) -> Option<serde_json::Value> {
889 use crate::sketch::doc::id_key;
890 let edit = self.sketch_edit.as_ref()?;
891 if edit.session.selection.items.len() != 1 {
892 return None;
893 }
894 let r = &edit.session.selection.items[0];
895 if r.get("kind").and_then(serde_json::Value::as_str) != Some("constraint") {
896 return None;
897 }
898 let id = r.get("id")?;
899 let key = id_key(id);
900 let c = edit
901 .session
902 .doc
903 .constraints
904 .iter()
905 .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))?;
906 (c.ctype() == Some("∠") && c.points().len() >= 4).then(|| id.clone())
907 }
908
909 /// Cycle the selected `∠` constraint to the NEXT way of measuring the angle
910 /// between its two lines, without moving any geometry:
911 ///
912 /// * a convex reading (≤ 180°) steps to the adjacent sector counter-clockwise
913 /// around the intersection — points `[p0,p1,p2,p3]` become `[p3,p2,p0,p1]`
914 /// and the value becomes `180 − value`, so 167° flips to its 13°
915 /// supplement, then 167°, 13°, and back to the start after four steps;
916 /// * a reflex reading (> 180°) first comes back to the SAME sector measured
917 /// the short way — the lines swap (`[p2,p3,p0,p1]`) and the value becomes
918 /// `360 − value` — after which the steps above apply.
919 ///
920 /// The value is transformed, never re-measured, so a conflicting constraint
921 /// keeps the intent it had; an expression dimension keeps its expression,
922 /// wrapped as `180 - (…)` / `360 - (…)`. An unset value (`valueNeedsSetup`) is
923 /// seeded from the geometry first. Records an undo step, re-solves, refreshes
924 /// the overlay and marks the document dirty. Returns whether a constraint
925 /// changed; `false` unless exactly one `∠` constraint is selected.
926 pub fn sketch_cycle_angle_measure(&mut self) -> bool {
927 use crate::sketch::doc::id_key;
928
929 let Some(id) = self.sketch_selected_angle_constraint() else {
930 return false;
931 };
932 let key = id_key(&id);
933 let Some(edit) = self.sketch_edit.as_mut() else {
934 return false;
935 };
936 // Measure the current reading first (the seed for an unset value), with
937 // the doc borrowed immutably.
938 let measured = {
939 let doc = &edit.session.doc;
940 let Some(c) = doc
941 .constraints
942 .iter()
943 .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))
944 else {
945 return false;
946 };
947 let pts = c.points();
948 let coord = |v: &serde_json::Value| doc.point(v).map(|p| (p.x, p.y));
949 let (Some(p0), Some(p1), Some(p2), Some(p3)) =
950 (coord(&pts[0]), coord(&pts[1]), coord(&pts[2]), coord(&pts[3]))
951 else {
952 return false;
953 };
954 let dir = |a: (f64, f64), b: (f64, f64)| (b.1 - a.1).atan2(b.0 - a.0).to_degrees();
955 (dir(p0, p1) - dir(p2, p3)).rem_euclid(360.0)
956 };
957
958 edit.record_undo();
959 let doc = &mut edit.session.doc;
960 let Some(c) = doc
961 .constraints
962 .iter_mut()
963 .find(|c| c.raw.get("id").map(id_key) == Some(key.clone()))
964 else {
965 return false;
966 };
967 let pts: Vec<serde_json::Value> = c.points().to_vec();
968 let value = c
969 .raw
970 .get("value")
971 .and_then(serde_json::Value::as_f64)
972 .filter(|v| v.is_finite())
973 .map(|v| v.rem_euclid(360.0))
974 .unwrap_or_else(|| (measured * 1e4).round() / 1e4);
975 let reflex = value > 180.0;
976 let (order, pivot): (Vec<serde_json::Value>, f64) = if reflex {
977 (vec![pts[2].clone(), pts[3].clone(), pts[0].clone(), pts[1].clone()], 360.0)
978 } else {
979 (vec![pts[3].clone(), pts[2].clone(), pts[0].clone(), pts[1].clone()], 180.0)
980 };
981 let next = ((pivot - value).rem_euclid(360.0) * 1e4).round() / 1e4;
982 c.raw.insert("points".to_string(), serde_json::Value::Array(order));
983 c.raw.insert("value".to_string(), serde_json::json!(next));
984 c.raw.insert("valueNeedsSetup".to_string(), serde_json::Value::Bool(false));
985 if let Some(expr) = c
986 .raw
987 .get("valueExpr")
988 .and_then(serde_json::Value::as_str)
989 .filter(|s| !s.trim().is_empty())
990 .map(str::to_string)
991 {
992 c.raw.insert(
993 "valueExpr".to_string(),
994 serde_json::Value::String(format!("{pivot} - ({expr})")),
995 );
996 }
997 // The solver's per-constraint caches are slot-wise; a reordered record must
998 // not carry the old slots' signature or solve value.
999 c.raw.remove("previousPointValues");
1000 c.raw.remove("_previousSolveValue");
1001 c.raw.remove("status");
1002 c.raw.remove("error");
1003
1004 self.resolve_active_sketch("angle-measure");
1005 self.refresh_sketch_overlay();
1006 self.dirty = true;
1007 true
1008 }
1009}