brep_render/engine_state/sketch_input.rs
1use super::*;
2use super::sketch_panel::{sketch_constraint_signature, sketch_perpendicular_should_swap};
3
4// ===========================================================================
5// Sketch interaction (S2) — plane-space picking: hover, selection, point drag.
6//
7// All operate on the active `self.sketch_edit` and no-op (false / 0) when not in
8// sketch mode. Pixel→plane→uv goes through the SAME `camera.pick_ray` the modeling
9// picker uses, intersected with the sketch plane (`crate::sketch::ray_plane_uv`).
10// Hit-testing (`SketchSession::pick_entity` / `pick_draggable_point`) is pure uv
11// math; points win over geometry within the ~8px grab radius. Every mutator
12// re-pushes the overlay via `refresh_sketch_overlay` (which colors the live hover +
13// selection) and marks the engine dirty. Kept in ONE appended block so concurrent
14// edits to the primary impl land clean.
15// ===========================================================================
16impl EngineState {
17 /// Re-push the sketch overlay reflecting the live hover + selection. Reads the
18 /// active `sketch_edit`'s session; a no-op when not in sketch mode. Called at
19 /// the end of every S2 mutator (the reusable counterpart of the initial
20 /// [`set_sketch_overlay`](Self::set_sketch_overlay) push).
21 pub(super) fn refresh_sketch_overlay(&mut self) {
22 let world_per_pixel = self.camera.world_per_pixel();
23 let (json, preview, leaders, glyphs) = match self.sketch_edit.as_ref() {
24 Some(edit) => (
25 edit.session.overlay_json_with_state(world_per_pixel),
26 edit.session.preview_overlay_json(
27 world_per_pixel,
28 &edit.pending,
29 edit.hover_uv,
30 &edit.handdraw_stroke,
31 ),
32 edit.session.dim_leaders_overlay_json_with_state(world_per_pixel),
33 edit.session
34 .constraint_glyphs_overlay_json_with_state(world_per_pixel),
35 ),
36 None => return,
37 };
38 let _ = self.set_overlay_json(&json);
39 // The draw-tool rubber-band rides in its own `sketch-preview` group so it
40 // upserts/clears independently of the solved geometry + point groups.
41 let _ = self.set_overlay_json(&preview);
42 // The dimension leaders + arrows ride in `sketch-dim-leaders`, refreshed
43 // alongside everything else (S5).
44 let _ = self.set_overlay_json(&leaders);
45 // The geometric-constraint glyphs ride in `sketch-constraint-glyphs` (S6c).
46 let _ = self.set_overlay_json(&glyphs);
47 // Remember the zoom these groups were baked at: their construction dashes,
48 // dimension arrowheads and constraint glyphs are all screen-constant, so
49 // `ensure_sketch_overlay_current` re-bakes them when it moves.
50 self.sketch_overlay_wpp = if world_per_pixel > 0.0 {
51 world_per_pixel
52 } else {
53 f64::MIN_POSITIVE
54 };
55 }
56
57 /// Per-frame upkeep for the live SKETCH overlay (driven by
58 /// [`Self::ensure_overlays_current`]) — the sketch-mode sibling of
59 /// [`Self::ensure_feature_dimension_overlay_current`]. The dimension leaders
60 /// (draggable), constraint glyphs and construction dashes are sized in PIXELS
61 /// against the camera at bake time, so a zoom leaves them stale until
62 /// something else mutates the sketch. Re-bakes on a material
63 /// `world_per_pixel` change only, so a quiet frame stays quiet.
64 pub(super) fn ensure_sketch_overlay_current(&mut self) {
65 if !self.sketch_mode() {
66 self.sketch_overlay_wpp = 0.0;
67 return;
68 }
69 let wpp = self.camera.world_per_pixel();
70 if super::overlay_wpp_stale(self.sketch_overlay_wpp, wpp) {
71 self.refresh_sketch_overlay();
72 }
73 }
74
75 /// Map CSS-pixel `(x, y)` to the active sketch plane's `(u, v)` via the camera
76 /// pick ray ∩ the sketch plane. `None` when not in sketch mode or the ray misses
77 /// the plane (parallel / behind).
78 pub fn sketch_uv_at(&self, x: f64, y: f64) -> Option<(f64, f64)> {
79 let edit = self.sketch_edit.as_ref()?;
80 let ray = self.camera.pick_ray(x, y);
81 crate::sketch::ray_plane_uv(&edit.session.plane, ray.origin, ray.dir)
82 }
83
84 /// The world-space pick tolerance at the current zoom — the ONE radius that
85 /// drives hover-highlight, click-select, drag-grab, draw-snap AND trim, for
86 /// points AND geometry, so "what highlights" is exactly "what you can grab".
87 /// Sized at 1.5× the visualized point (`POINT_SIZE_PX`) for forgiving clicking.
88 pub(super) fn sketch_pick_radius(&self) -> f64 {
89 f64::from(crate::sketch::tessellate::POINT_SIZE_PX) * 1.5 * self.camera.world_per_pixel()
90 }
91
92 /// The entity ref under CSS-pixel `(x, y)` within the grab radius, or `None`.
93 /// Priority is points > geometry > constraint: [`pick_entity`] resolves the first
94 /// two, and only when neither is in range do we consult [`pick_constraint`] (a glyph
95 /// or dimension leader that overlaps a point/edge never shadows it).
96 ///
97 /// [`pick_entity`]: crate::sketch::SketchSession::pick_entity
98 /// [`pick_constraint`]: crate::sketch::SketchSession::pick_constraint
99 fn sketch_entity_at(&self, x: f64, y: f64) -> Option<serde_json::Value> {
100 let (u, v) = self.sketch_uv_at(x, y)?;
101 let radius = self.sketch_pick_radius();
102 let wpp = self.camera.world_per_pixel();
103 self.sketch_edit.as_ref().and_then(|edit| {
104 edit.session
105 .pick_entity(u, v, radius)
106 .or_else(|| edit.session.pick_constraint(u, v, radius, wpp))
107 })
108 }
109
110 /// Set (or clear) the sketch hover, re-pushing the overlay + marking dirty only
111 /// when it actually changed. Returns whether the hover changed.
112 pub(super) fn set_sketch_hover(&mut self, new_hover: Option<serde_json::Value>) -> bool {
113 let changed = match self.sketch_edit.as_ref() {
114 Some(edit) => {
115 !crate::sketch::entity_ref_eq(edit.session.hovered.as_ref(), new_hover.as_ref())
116 }
117 None => false,
118 };
119 if changed {
120 if let Some(edit) = self.sketch_edit.as_mut() {
121 edit.session.set_hover(new_hover);
122 }
123 self.refresh_sketch_overlay();
124 self.dirty = true;
125 }
126 changed
127 }
128
129 /// Update the sketch hover to the entity under CSS-pixel `(x, y)` (S2). Returns
130 /// whether the hover changed. A no-op returning `false` when not in sketch mode.
131 pub fn sketch_hover_at(&mut self, x: f64, y: f64) -> bool {
132 if self.sketch_edit.is_none() {
133 return false;
134 }
135 // Track the live cursor uv for the S3a rubber-band. In DRAW mode with pending
136 // clicks the preview follows the cursor even when the hovered ENTITY is
137 // unchanged, so force an overlay refresh there.
138 let uv = self.sketch_uv_at(x, y);
139 let preview_live = match self.sketch_edit.as_mut() {
140 Some(edit) => {
141 edit.hover_uv = uv;
142 edit.session.tool.is_some() && !edit.pending.is_empty()
143 }
144 None => false,
145 };
146 let new_hover = self.sketch_entity_at(x, y);
147 let changed = self.set_sketch_hover(new_hover);
148 if preview_live && !changed {
149 self.refresh_sketch_overlay();
150 self.dirty = true;
151 }
152 changed
153 }
154
155 /// Clear the sketch hover (pointer left the viewport / moved over the ViewCube).
156 /// Returns whether a hover was cleared.
157 pub fn sketch_clear_hover(&mut self) -> bool {
158 self.set_sketch_hover(None)
159 }
160
161 /// Click-select in sketch mode: pick the entity under `(x, y)`; nothing → clear
162 /// the selection. Otherwise honor the SAME "Multi-select" setting the 3D viewport
163 /// reads (`settings.multi_select`): under `ClickToggles` a plain click toggles the
164 /// hit in the set (no modifier needed for a multi-selection); under
165 /// `CtrlClick` a plain click replaces the set with just the hit and `additive`
166 /// (Ctrl/Cmd) toggles. Re-pushes the overlay + marks dirty.
167 pub fn sketch_click_at(&mut self, x: f64, y: f64, additive: bool) {
168 // Read the setting before the mutable `sketch_edit` borrow.
169 let toggles = self.settings.multi_select == crate::style::MultiSelectMode::ClickToggles;
170 let hit = self.sketch_entity_at(x, y);
171 let Some(edit) = self.sketch_edit.as_mut() else {
172 return;
173 };
174 match hit {
175 None => edit.session.clear_selection(),
176 Some(entity_ref) => {
177 if additive || toggles {
178 edit.session.toggle_selection(entity_ref);
179 } else {
180 edit.session.clear_selection();
181 edit.session.toggle_selection(entity_ref);
182 }
183 }
184 }
185 self.refresh_sketch_overlay();
186 self.dirty = true;
187 }
188
189 /// Begin a point drag if a DRAGGABLE point is under `(x, y)` (S2): remember it
190 /// (id + original `fixed` flag). Returns `true` iff a point was grabbed (the
191 /// viewport routes the drag to the sketch; otherwise it orbits the camera). A
192 /// locked / fully-constrained point is not draggable, so an empty-space or
193 /// locked-point drag falls through to a camera orbit.
194 pub fn sketch_drag_begin(&mut self, x: f64, y: f64) -> bool {
195 let Some((u, v)) = self.sketch_uv_at(x, y) else {
196 return false;
197 };
198 let radius = self.sketch_pick_radius();
199 // Grab EXACTLY what's HIGHLIGHTED: the hovered entity was picked at the exact
200 // cursor position on the last move, so it is immune to egui reporting the
201 // drag-start ~6px into the gesture (the "highlighted but won't grab"
202 // intermittency — and it's what lets a whole geometry drag). A hovered LOCKED
203 // point yields `None` → the drag falls through to a camera gesture; it must
204 // NOT positional-fall-back there (that would grab a nearby UNhighlighted
205 // point). Only an EMPTY hover (a press with no prior move) falls back to a
206 // fresh positional pick.
207 let points = self.sketch_edit.as_ref().and_then(|edit| {
208 match edit.session.hovered.as_ref() {
209 Some(entity_ref) => edit.session.drag_points_from_ref(entity_ref),
210 None => edit
211 .session
212 .pick_draggable_point(u, v, radius)
213 .and_then(|(id, fixed)| {
214 edit.session
215 .doc
216 .point(&id)
217 .map(|p| vec![(id, p.x, p.y, fixed)])
218 }),
219 }
220 });
221 let Some(points) = points else {
222 return false;
223 };
224 if let Some(edit) = self.sketch_edit.as_mut() {
225 // Snapshot ONCE at the gesture start so the whole drag is one undo step
226 // (S6a); `sketch_drag_to` never snapshots. A grab that moves nothing is
227 // discarded in `sketch_drag_end`.
228 edit.record_undo();
229 edit.drag = Some(SketchDrag { points, anchor: (u, v) });
230 }
231 true
232 }
233
234 /// Drag the grabbed target to `(x, y)` (S2): pin every grabbed point at its
235 /// ORIGINAL position plus the cursor delta (`fixed = true`) so the solver anchors
236 /// the whole shape there, re-solve, then restore each point's ORIGINAL `fixed`
237 /// flag. Absolute-from-anchor (never incremental), so a rigid geometry translate
238 /// tracks the cursor 1:1 without drifting as the solver nudges points between
239 /// frames. A resolve error rolls every grabbed point back to its pre-drag coords
240 /// (the last good state). No-op when nothing is grabbed / not in sketch mode / the
241 /// ray misses the plane.
242 pub fn sketch_drag_to(&mut self, x: f64, y: f64) {
243 let Some((u, v)) = self.sketch_uv_at(x, y) else {
244 return;
245 };
246 let Some(edit) = self.sketch_edit.as_mut() else {
247 return;
248 };
249 let Some(drag) = edit.drag.clone() else {
250 return;
251 };
252 let (du, dv) = (u - drag.anchor.0, v - drag.anchor.1);
253 let session = &mut edit.session;
254 for (id, ox, oy, _) in &drag.points {
255 if let Some(p) = session.doc.point_mut(id) {
256 p.x = *ox + du;
257 p.y = *oy + dv;
258 p.fixed = true;
259 }
260 }
261 match session.resolve() {
262 Ok(()) => {
263 for (id, _, _, orig_fixed) in &drag.points {
264 if let Some(p) = session.doc.point_mut(id) {
265 p.fixed = *orig_fixed;
266 }
267 }
268 }
269 Err(_) => {
270 // Unsolvable target: roll every grabbed point back to its pre-drag
271 // coords + flag (keep the last good state).
272 for (id, ox, oy, orig_fixed) in &drag.points {
273 if let Some(p) = session.doc.point_mut(id) {
274 p.x = *ox;
275 p.y = *oy;
276 p.fixed = *orig_fixed;
277 }
278 }
279 }
280 }
281 self.refresh_sketch_overlay();
282 self.dirty = true;
283 }
284
285 /// End a point drag (S2): clear the grab, then one final re-solve + overlay
286 /// refresh. No-op when no drag is live.
287 pub fn sketch_drag_end(&mut self) {
288 let had_grab = self
289 .sketch_edit
290 .as_ref()
291 .map_or(false, |edit| edit.drag.is_some());
292 if !had_grab {
293 return;
294 }
295 // Drop radius for constraint inference == the point grab radius (12 px in
296 // world units) — read before the `&mut` borrow of `sketch_edit`.
297 let drop_tol = self.sketch_pick_radius();
298 if let Some(edit) = self.sketch_edit.as_mut() {
299 // Drop-time inference (S6c): a SINGLE-point drop snaps to a coincident
300 // point / point-on-line at the release position, mirroring the previous
301 // coincident-on-drop / point-on-line-on-drop inference. A whole-
302 // geometry drag (multiple points) never infers — running it per
303 // endpoint could glue or collapse the curve in one solve.
304 let dragged_single = edit.drag.as_ref().and_then(|drag| {
305 (drag.points.len() == 1).then(|| drag.points[0].0.clone())
306 });
307 edit.drag = None;
308 if let Some(point_id) = dragged_single {
309 crate::sketch::infer::infer_drop_constraint(
310 &mut edit.session.doc,
311 &point_id,
312 drop_tol,
313 );
314 }
315 let _ = edit.session.resolve();
316 // Discard the drag's undo snapshot when the doc is unchanged (a mere
317 // grab-and-release, no move and no inferred constraint), so it neither
318 // pollutes undo nor clobbers redo. An inferred constraint changes the
319 // doc, so the snapshot is kept — one Ctrl+Z then undoes move+constraint.
320 if edit
321 .undo_stack
322 .last()
323 .map_or(false, |snap| snap.doc == edit.session.doc)
324 {
325 edit.undo_stack.pop();
326 }
327 }
328 self.refresh_sketch_overlay();
329 self.dirty = true;
330 }
331
332 /// The number of selected sketch entities (0 when not in sketch mode) — the mode
333 /// bar / verifier readout.
334 pub fn sketch_selection_count(&self) -> usize {
335 self.sketch_edit
336 .as_ref()
337 .map_or(0, |edit| edit.session.selection.len())
338 }
339
340 /// The number of selected CONSTRAINTS (refs whose `kind` is `"constraint"`; 0 when
341 /// not in sketch mode) — the `__brepSketch` verifier readout for constraint
342 /// selection + delete.
343 pub fn sketch_selected_constraint_count(&self) -> usize {
344 self.sketch_edit.as_ref().map_or(0, |edit| {
345 edit.session
346 .selection
347 .iter()
348 .filter(|r| r.get("kind").and_then(|v| v.as_str()) == Some("constraint"))
349 .count()
350 })
351 }
352}
353
354// ===========================================================================
355// Sketch draw tools (S3a) — primitive placement: point / line / rect / circle / arc.
356//
357// A click-state machine over the active `self.sketch_edit`. The active tool lives
358// on `session.tool` ("select"/None = selection mode, S2); a DRAW tool routes clicks
359// to `sketch_tool_click_at` (pixel → plane uv via the same S2 `sketch_uv_at`, then
360// `sketch_tool_place_uv`). Points/geometries are minted through `SketchDoc`
361// (`next_point_id`/`next_geometry_id` + `snap_or_add_point`, so shared vertices
362// coincide). Each placement re-solves (swallowing solve errors), re-pushes the
363// overlay (incl. the rubber-band preview), and marks dirty. The bezier tool has a
364// second mode on the same click: idle, a click ON an existing spline REFINES it
365// rather than starting a new one (`crate::sketch::spline`). Kept in ONE appended
366// block so concurrent edits to the primary impl land clean.
367// ===========================================================================
368impl EngineState {
369 /// Set (or clear) the active draw tool: `"select"`/`None` → selection mode (S2);
370 /// `"point"|"line"|"rect"|"circle"|"arc"|"bezier"` arm the corresponding draw
371 /// tool; `"handdraw"` arms the freehand stroke tool (S6b-3); `"trim"` arms the
372 /// trim tool (S6b); `"pickEdges"` arms the external-edge link tool (S6b-2). Clears
373 /// any in-progress click buffer + preview and refreshes the overlay. No-op when not
374 /// in sketch mode.
375 pub fn sketch_set_tool(&mut self, tool: Option<&str>) {
376 let normalized = normalize_sketch_tool(tool);
377 if let Some(edit) = self.sketch_edit.as_mut() {
378 edit.session.tool = normalized;
379 edit.pending.clear();
380 edit.hover_uv = None;
381 edit.handdraw_stroke.clear();
382 } else {
383 return;
384 }
385 self.refresh_sketch_overlay();
386 self.dirty = true;
387 }
388
389 /// The active draw tool (`"point"|"line"|"rect"|"circle"|"arc"`), or `None` in
390 /// selection mode / when not in sketch mode.
391 pub fn sketch_active_tool(&self) -> Option<&str> {
392 self.sketch_edit
393 .as_ref()
394 .and_then(|edit| edit.session.tool.as_deref())
395 }
396
397 /// The number of in-progress draw-tool clicks buffered (0 in selection mode /
398 /// when not in sketch mode) — the UI/preview + verifier readout.
399 pub fn sketch_pending_len(&self) -> usize {
400 self.sketch_edit.as_ref().map_or(0, |edit| edit.pending.len())
401 }
402
403 /// A draw-tool click at CSS-pixel `(x, y)`: map to plane uv (the S2 pixel→plane
404 /// math) and drive the tool state machine. No-op when not in sketch mode, in
405 /// selection mode, or the ray misses the plane.
406 pub fn sketch_tool_click_at(&mut self, x: f64, y: f64) {
407 // pickEdges (S6b-2) acts on the 3D SCENE EDGE under the cursor — it needs the
408 // PIXEL coords (a scene pick), not a plane uv, so short-circuit before the
409 // pixel→plane projection (which would drop clicks that miss the plane).
410 if self.sketch_active_tool() == Some("pickEdges") {
411 self.sketch_pick_edge_at(x, y);
412 return;
413 }
414 let Some((u, v)) = self.sketch_uv_at(x, y) else {
415 return;
416 };
417 self.sketch_tool_place_uv(u, v);
418 }
419
420 /// The per-tool placement logic, in plane `(u, v)` (the headless-testable core
421 /// `sketch_tool_click_at` delegates to). Snaps to existing points within the grab
422 /// radius so shared vertices coincide; appends geometry and re-solves when a
423 /// primitive completes; carries the line chain via `pending`.
424 pub fn sketch_tool_place_uv(&mut self, u: f64, v: f64) {
425 let radius = self.sketch_pick_radius();
426 // Selection mode (no tool / "select") never places. Read the tool without
427 // holding a borrow so the trim branch can call back into `self`.
428 let tool = match self.sketch_edit.as_ref() {
429 Some(edit) => match edit.session.tool.clone() {
430 Some(tool) => tool,
431 None => return,
432 },
433 None => return,
434 };
435 // Trim (S6b) is a click tool that acts IMMEDIATELY on the geometry under the
436 // cursor — it never buffers `pending` or places a point. It owns its own undo
437 // snapshot (and pops it on a no-op), so short-circuit before the draw path.
438 if tool == "trim" {
439 self.sketch_trim_uv(u, v);
440 return;
441 }
442 // pickEdges is NOT a uv-placement tool — it acts on a 3D scene edge (routed via
443 // pixel coords in `sketch_tool_click_at`), so a stray uv place is a no-op here.
444 if tool == "pickEdges" {
445 return;
446 }
447 // handdraw (S6b-3) captures a DRAG as a stroke (routed via `sketch_handdraw_*`),
448 // not a click-placed point — a plain click is a no-op (and never records a dead
449 // undo step here, since we return before `record_undo`).
450 if tool == "handdraw" {
451 return;
452 }
453 let Some(edit) = self.sketch_edit.as_mut() else {
454 return;
455 };
456 // A draw tool is active → this click WILL mutate the doc (a point and/or a
457 // geometry); snapshot for undo before it does (S6a).
458 edit.record_undo();
459 let doc = &mut edit.session.doc;
460 match tool.as_str() {
461 "point" => {
462 doc.snap_or_add_point(u, v, radius);
463 edit.pending.clear();
464 }
465 "line" => {
466 if edit.pending.is_empty() {
467 let a = doc.snap_or_add_point(u, v, radius);
468 edit.pending.push(a);
469 } else {
470 let start = edit.pending.last().cloned().expect("pending non-empty");
471 let end = doc.snap_or_add_point(u, v, radius);
472 push_sketch_geometry(doc, "line", vec![start, end.clone()]);
473 // Continue the chain: the just-placed end is the next start.
474 edit.pending = vec![end];
475 }
476 }
477 "rect" => {
478 if edit.pending.is_empty() {
479 let a = doc.snap_or_add_point(u, v, radius);
480 edit.pending.push(a);
481 } else {
482 let a_id = edit.pending[0].clone();
483 let Some((ax, ay)) = doc.point(&a_id).map(|p| (p.x, p.y)) else {
484 edit.pending.clear();
485 return;
486 };
487 let (bx, by) = (u, v);
488 // Corners A=(ax,ay), (bx,ay), (bx,by), (ax,by) → 4 closed lines.
489 let b1 = doc.snap_or_add_point(bx, ay, radius);
490 let b2 = doc.snap_or_add_point(bx, by, radius);
491 let b3 = doc.snap_or_add_point(ax, by, radius);
492 push_sketch_geometry(doc, "line", vec![a_id.clone(), b1.clone()]);
493 push_sketch_geometry(doc, "line", vec![b1.clone(), b2.clone()]);
494 push_sketch_geometry(doc, "line", vec![b2.clone(), b3.clone()]);
495 push_sketch_geometry(doc, "line", vec![b3.clone(), a_id.clone()]);
496 // Keep the rectangle rectangular under drag: three ⟂ constraints on
497 // the adjacent-edge pairs (the 4th corner's right angle follows from
498 // the closed loop). This is the minimal rigid set — it removes 3 DOF
499 // from the 8-DOF four-corner quad, leaving position (2) + rotation (1)
500 // + width + height = 5 DOF, so the sketch is neither over-constrained
501 // nor conflicting.
502 push_rect_perpendicular_constraints(doc, [a_id, b1, b2, b3]);
503 edit.pending.clear();
504 }
505 }
506 "circle" => {
507 if edit.pending.is_empty() {
508 let c = doc.snap_or_add_point(u, v, radius);
509 edit.pending.push(c);
510 } else {
511 let center = edit.pending[0].clone();
512 let r = doc.snap_or_add_point(u, v, radius);
513 push_sketch_geometry(doc, "circle", vec![center, r]);
514 edit.pending.clear();
515 }
516 }
517 "arc" => {
518 // Clicks: center, start, then end completes [center, start, end].
519 if edit.pending.len() < 2 {
520 let p = doc.snap_or_add_point(u, v, radius);
521 edit.pending.push(p);
522 } else {
523 let center = edit.pending[0].clone();
524 let start = edit.pending[1].clone();
525 let end = doc.snap_or_add_point(u, v, radius);
526 push_sketch_geometry(doc, "arc", vec![center, start, end]);
527 edit.pending.clear();
528 }
529 }
530 "bezier" => {
531 // Cubic Bezier: 4 clicks place end0, ctrl0, ctrl1, end1 (in order).
532 // The 4th click commits the span [p0, p1, p2, p3] PLUS two dashed
533 // construction guide lines for the control handles (end0→ctrl0 and
534 // end1→ctrl1), matching the previous basic bezier tool. One INVOCATION
535 // authors one span; a chained multi-span polygon (3n+1 ids in ONE
536 // geometry — the model the solver, tessellator and profile builder all
537 // already read) is grown by insertion instead, below.
538 //
539 // IDLE + a click on an existing spline = refine it: subdivide the
540 // clicked span so a new anchor lands under the cursor without the curve
541 // moving (see `crate::sketch::spline`). Only while `pending` is empty —
542 // mid-draw the click still means "place the next control point", so a
543 // new spline can be drawn across an old one. A refusal (nothing under
544 // the cursor, a point winning the pick, a degenerate span) falls
545 // through to that placement, which mutates the doc too, so the
546 // `record_undo` above never leaves a dead step either way.
547 let refined = edit.pending.is_empty() && spline_insert_anchor(doc, u, v, radius);
548 if !refined {
549 if edit.pending.len() < 3 {
550 let p = doc.snap_or_add_point(u, v, radius);
551 edit.pending.push(p);
552 } else {
553 let p0 = edit.pending[0].clone();
554 let p1 = edit.pending[1].clone();
555 let p2 = edit.pending[2].clone();
556 let p3 = doc.snap_or_add_point(u, v, radius);
557 push_sketch_geometry(
558 doc,
559 "bezier",
560 vec![p0.clone(), p1.clone(), p2.clone(), p3.clone()],
561 );
562 // Construction guide lines (dashed, non-modeling) for the two
563 // control handles — separate freshly minted geometry ids.
564 push_sketch_construction_line(doc, vec![p0, p1]);
565 push_sketch_construction_line(doc, vec![p3, p2]);
566 edit.pending.clear();
567 }
568 }
569 }
570 _ => return,
571 }
572 // Every draw click mutates the doc (a new point and/or geometry); re-solve so
573 // coordinates + mobility stay fresh, keeping the doc if the solve fails.
574 self.resolve_active_sketch("draw-tool");
575 self.refresh_sketch_overlay();
576 self.dirty = true;
577 }
578
579 /// Abort the in-progress draw geometry (Escape / right-click): clear the pending
580 /// clicks + preview and refresh. No-op when not in sketch mode.
581 pub fn sketch_tool_cancel(&mut self) {
582 if let Some(edit) = self.sketch_edit.as_mut() {
583 edit.pending.clear();
584 } else {
585 return;
586 }
587 self.refresh_sketch_overlay();
588 self.dirty = true;
589 }
590}
591
592/// Normalize a tool name to the stored form: `None`/`"select"`/`""` → selection mode
593/// (`None`), else the tool string (`"point"|"line"|"rect"|"circle"|"arc"|"bezier"|
594/// "trim"|"pickEdges"|"handdraw"`).
595fn normalize_sketch_tool(tool: Option<&str>) -> Option<String> {
596 match tool {
597 None | Some("select") | Some("") => None,
598 Some(t) => Some(t.to_string()),
599 }
600}
601
602/// Append a geometry to a sketch doc with a freshly minted id (the caller passes the
603/// solver `type` — `rect` corners are pushed as `line`s), carrying an explicit
604/// `construction: false` so it matches the authored shape and round-trips.
605fn push_sketch_geometry(
606 doc: &mut crate::sketch::SketchDoc,
607 geom_type: &str,
608 points: Vec<serde_json::Value>,
609) {
610 let id = doc.next_geometry_id();
611 let mut extra = serde_json::Map::new();
612 extra.insert("construction".to_string(), serde_json::Value::Bool(false));
613 doc.geometries.push(crate::sketch::SketchGeometry {
614 id,
615 geom_type: geom_type.to_string(),
616 points,
617 extra,
618 });
619}
620
621/// Append a CONSTRUCTION `line` geometry (dashed, non-modeling — `construction: true`)
622/// with a freshly minted id: the bezier tool's control-handle guide lines. Mirrors
623/// [`push_sketch_geometry`] but flips the construction flag so the line renders dashed
624/// and is excluded from profiles while still being constrainable.
625fn push_sketch_construction_line(
626 doc: &mut crate::sketch::SketchDoc,
627 points: Vec<serde_json::Value>,
628) {
629 let id = doc.next_geometry_id();
630 let mut extra = serde_json::Map::new();
631 extra.insert("construction".to_string(), serde_json::Value::Bool(true));
632 doc.geometries.push(crate::sketch::SketchGeometry {
633 id,
634 geom_type: "line".to_string(),
635 points,
636 extra,
637 });
638}
639
640/// Subdivide the spline under the plane click `(u, v)` — the bezier tool's "refine
641/// what I already drew" click. Delegates the pick + de Casteljau split to
642/// [`crate::sketch::spline::insert_anchor`] (which decides whether the click is a
643/// subdivision at all) and, when it lands, hangs the SAME two dashed construction
644/// guides on the new anchor that the 4-click path hangs on the two drawn ends:
645/// anchor→handle on each side. Consistency is the whole point — after a refine every
646/// anchor of the spline, drawn or inserted, has a guide to each of its neighbouring
647/// handles, so the handles stay visible and constrainable (a tangent on a guide is
648/// how a spline is made to meet a line smoothly) and a refined curve is
649/// indistinguishable from one drawn that way. The two ORIGINAL guides still read
650/// correctly without being touched, because the split reuses the end handles' ids and
651/// they remain the handles adjacent to those same two end anchors.
652///
653/// Returns whether the click was consumed as an insertion; `false` means the caller
654/// should treat it as an ordinary control-point placement.
655fn spline_insert_anchor(doc: &mut crate::sketch::SketchDoc, u: f64, v: f64, radius: f64) -> bool {
656 let Some(added) = crate::sketch::spline::insert_anchor(doc, u, v, radius) else {
657 return false;
658 };
659 push_sketch_construction_line(doc, vec![added.anchor.clone(), added.before]);
660 push_sketch_construction_line(doc, vec![added.anchor, added.after]);
661 true
662}
663
664/// Append the three perpendicular (`⟂`) constraints that keep a freshly drawn
665/// rectangle rectangular when a corner is dragged. `corners` are the rect's four
666/// points in loop order `[a, b1, b2, b3]` (edges a→b1, b1→b2, b2→b3, b3→a); the
667/// constraints go on the adjacent-edge pairs sharing corners b1 / b2 / b3. The fourth
668/// corner (a) is left implied — a closed quad with three right angles is a rectangle —
669/// so this is the MINIMAL rigid set (3 equations, no over-constraint / redundancy).
670///
671/// Each `⟂` stores the two edges' endpoint pairs `[l1a, l1b, l2a, l2b]`, swap-oriented
672/// exactly like a palette-added perpendicular ([`sketch_build_and_add_constraint`]), and
673/// is deduped on its signature. No-op unless the four corners are all distinct (a
674/// degenerate rect whose corners snapped together would otherwise carry a `⟂` on a
675/// zero-length edge, which is meaningless and can wedge the solver).
676fn push_rect_perpendicular_constraints(
677 doc: &mut crate::sketch::SketchDoc,
678 corners: [serde_json::Value; 4],
679) {
680 use crate::sketch::doc::id_key;
681 let [a, b1, b2, b3] = corners;
682 let keys = [id_key(&a), id_key(&b1), id_key(&b2), id_key(&b3)];
683 for i in 0..keys.len() {
684 for j in (i + 1)..keys.len() {
685 if keys[i] == keys[j] {
686 return; // two corners collapsed → skip (no zero-length-edge ⟂).
687 }
688 }
689 }
690 // Adjacent edge pairs sharing corner b1 / b2 / b3.
691 let pairs = [
692 [a.clone(), b1.clone(), b1.clone(), b2.clone()],
693 [b1.clone(), b2.clone(), b2.clone(), b3.clone()],
694 [b2.clone(), b3.clone(), b3.clone(), a.clone()],
695 ];
696 for pair in pairs {
697 let mut pts = pair.to_vec();
698 if sketch_perpendicular_should_swap(doc, &pts) {
699 pts.swap(0, 1);
700 }
701 push_geometric_constraint(doc, "⟂", pts);
702 }
703}
704
705/// Append a NON-dimensional geometric constraint (`type` + ordered `points`) with a
706/// freshly minted id and the same base fields a palette-added constraint carries
707/// (`labelX`/`labelY` = 0, `displayStyle` = "", `value` = null, `valueNeedsSetup` =
708/// true — see [`sketch_build_and_add_constraint`]). Deduped on `type + sorted-points`
709/// (the solver runs with `remove_implied_duplicates: false`, so this is the only
710/// dedup); a no-op on a duplicate.
711fn push_geometric_constraint(
712 doc: &mut crate::sketch::SketchDoc,
713 ctype: &str,
714 points: Vec<serde_json::Value>,
715) {
716 let sig = sketch_constraint_signature(ctype, &points);
717 let duplicate = doc.constraints.iter().any(|c| match c.ctype() {
718 Some(t) => sketch_constraint_signature(t, c.points()) == sig,
719 None => false,
720 });
721 if duplicate {
722 return;
723 }
724 let id = doc.next_constraint_id();
725 let mut raw = serde_json::Map::new();
726 raw.insert("id".to_string(), id);
727 raw.insert("type".to_string(), serde_json::Value::String(ctype.to_string()));
728 raw.insert("points".to_string(), serde_json::Value::Array(points));
729 raw.insert("labelX".to_string(), serde_json::Value::from(0));
730 raw.insert("labelY".to_string(), serde_json::Value::from(0));
731 raw.insert(
732 "displayStyle".to_string(),
733 serde_json::Value::String(String::new()),
734 );
735 raw.insert("value".to_string(), serde_json::Value::Null);
736 raw.insert("valueNeedsSetup".to_string(), serde_json::Value::Bool(true));
737 doc.constraints.push(crate::sketch::SketchConstraint { raw });
738}
739
740// ===========================================================================
741// Auto-constrain — infer the constraints implied by the rough-in geometry.
742//
743// A one-click "constrain what I drew": snap nearly-axis-aligned lines to `━`/`│` and
744// near-coincident points to `≡`, going through the same `push_geometric_constraint`
745// dedup the palette uses so the solver picks them up and a re-run is idempotent.
746// ===========================================================================
747
748/// Auto-constrain tolerances (v1 constants; a future pass could surface them as
749/// settings). A line whose direction is within [`AUTO_HV_ANGLE_TOL_DEG`] of an axis
750/// gets a horizontal/vertical constraint — loose enough to catch a rough-in, far
751/// below the slope a user clearly intended.
752const AUTO_HV_ANGLE_TOL_DEG: f64 = 3.0;
753/// Two points auto-snap to COINCIDENT when within this fraction of the sketch's
754/// bounding-box diagonal (scale-invariant, so it works at any sketch size)…
755const AUTO_COINCIDENT_FRAC: f64 = 0.01;
756/// …floored at this absolute distance for a tiny or single-cluster sketch.
757const AUTO_COINCIDENT_ABS: f64 = 1e-4;
758
759/// Infer + add the constraints implied by the current geometry: `━`/`│` on
760/// nearly-axis-aligned lines and `≡` between near-coincident points. Conservative —
761/// never adds an unsatisfiable constraint on two already-fixed points, never collapses
762/// a curve by coinciding its own endpoints, never re-adds a coincident already implied
763/// (directly or transitively), and dedups H/V via [`push_geometric_constraint`]. So a
764/// second pass adds nothing (idempotent). Returns the number of constraints added; the
765/// caller re-solves.
766pub(super) fn auto_constrain_doc(doc: &mut crate::sketch::SketchDoc) -> usize {
767 use crate::sketch::doc::id_key;
768 use serde_json::Value;
769 use std::collections::{HashMap, HashSet};
770
771 let before = doc.constraints.len();
772
773 // ---- Horizontal / vertical on nearly-axis-aligned lines. ----
774 // Threshold on |unit component off the axis| = |sin(angle deviation)|.
775 let hv_sin_tol = AUTO_HV_ANGLE_TOL_DEG.to_radians().sin();
776 let mut hv: Vec<(&str, Vec<Value>)> = Vec::new();
777 for g in &doc.geometries {
778 if g.geom_type != "line" || g.points.len() < 2 {
779 continue;
780 }
781 let (Some(a), Some(b)) = (doc.point(&g.points[0]), doc.point(&g.points[1])) else {
782 continue;
783 };
784 // Skip a line pinned at BOTH ends (no freedom → an H/V that isn't already
785 // exactly true is unsatisfiable). This also excludes linked reference lines,
786 // whose endpoints are all fixed.
787 if a.fixed && b.fixed {
788 continue;
789 }
790 let (dx, dy) = (b.x - a.x, b.y - a.y);
791 let len = dx.hypot(dy);
792 if len < 1e-9 {
793 continue;
794 }
795 // Skip a line that already carries an H or V constraint on these endpoints
796 // (never add the opposite one; keeps the pass idempotent).
797 let mut keys: Vec<String> = g.points[..2].iter().map(id_key).collect();
798 keys.sort();
799 let already_hv = doc.constraints.iter().any(|c| {
800 if !matches!(c.ctype(), Some("━") | Some("│")) {
801 return false;
802 }
803 let mut k: Vec<String> = c.points().iter().map(id_key).collect();
804 k.sort();
805 k == keys
806 });
807 if already_hv {
808 continue;
809 }
810 let (ux, uy) = (dx / len, dy / len);
811 if uy.abs() <= hv_sin_tol {
812 hv.push(("━", vec![g.points[0].clone(), g.points[1].clone()]));
813 } else if ux.abs() <= hv_sin_tol {
814 hv.push(("│", vec![g.points[0].clone(), g.points[1].clone()]));
815 }
816 }
817 for (ct, pts) in hv {
818 push_geometric_constraint(doc, ct, pts);
819 }
820
821 // ---- Coincident between near-coincident, mergeable point pairs. ----
822 let n = doc.points.len();
823 if n >= 2 {
824 fn find(parent: &mut [usize], mut x: usize) -> usize {
825 while parent[x] != x {
826 parent[x] = parent[parent[x]]; // path halving
827 x = parent[x];
828 }
829 x
830 }
831 fn union(parent: &mut [usize], a: usize, b: usize) {
832 let (ra, rb) = (find(parent, a), find(parent, b));
833 if ra != rb {
834 parent[ra] = rb;
835 }
836 }
837
838 // Union-find over point indices, SEEDED with the existing coincidents so we
839 // never re-add one (directly or transitively).
840 let index: HashMap<String, usize> = doc
841 .points
842 .iter()
843 .enumerate()
844 .map(|(i, p)| (id_key(&p.id), i))
845 .collect();
846 let mut parent: Vec<usize> = (0..n).collect();
847 for c in &doc.constraints {
848 if c.ctype() == Some("≡") {
849 let pts = c.points();
850 if let (Some(p0), Some(p1)) = (pts.first(), pts.get(1)) {
851 if let (Some(&i), Some(&j)) = (index.get(&id_key(p0)), index.get(&id_key(p1))) {
852 union(&mut parent, i, j);
853 }
854 }
855 }
856 }
857
858 // Distance tolerance relative to the sketch extent.
859 let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
860 for p in &doc.points {
861 lo[0] = lo[0].min(p.x);
862 lo[1] = lo[1].min(p.y);
863 hi[0] = hi[0].max(p.x);
864 hi[1] = hi[1].max(p.y);
865 }
866 let extent = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2)).sqrt();
867 let tol = (extent * AUTO_COINCIDENT_FRAC).max(AUTO_COINCIDENT_ABS);
868
869 // Point-key set per geometry — skip a pair that are the two ends of the SAME
870 // curve (coinciding them would collapse it).
871 let geo_sets: Vec<HashSet<String>> = doc
872 .geometries
873 .iter()
874 .map(|g| g.points.iter().map(id_key).collect())
875 .collect();
876
877 let mut coincidents: Vec<Vec<Value>> = Vec::new();
878 for i in 0..n {
879 for j in (i + 1)..n {
880 let (pi, pj) = (&doc.points[i], &doc.points[j]);
881 if pi.fixed && pj.fixed {
882 continue; // both pinned → a coincident is unsatisfiable
883 }
884 let d = ((pi.x - pj.x).powi(2) + (pi.y - pj.y).powi(2)).sqrt();
885 if d > tol {
886 continue;
887 }
888 if find(&mut parent, i) == find(&mut parent, j) {
889 continue; // already coincident (directly or transitively)
890 }
891 let (ki, kj) = (id_key(&pi.id), id_key(&pj.id));
892 if geo_sets.iter().any(|s| s.contains(&ki) && s.contains(&kj)) {
893 continue; // endpoints of one curve — do not collapse it
894 }
895 union(&mut parent, i, j);
896 coincidents.push(vec![pi.id.clone(), pj.id.clone()]);
897 }
898 }
899 for pts in coincidents {
900 push_geometric_constraint(doc, "≡", pts);
901 }
902 }
903
904 doc.constraints.len().saturating_sub(before)
905}
906
907impl EngineState {
908 /// Auto-constrain the active sketch (the toolbar's one-click "constrain what I
909 /// roughed in"): infer `━`/`│` on nearly-axis-aligned lines and `≡` between
910 /// near-coincident points, then re-solve. Records ONE undo step, popped when the
911 /// pass adds nothing so a dead click neither pollutes undo nor clobbers redo.
912 /// Returns the number of constraints added; a no-op (0) when not in sketch mode.
913 pub fn sketch_auto_constrain(&mut self) -> usize {
914 let Some(edit) = self.sketch_edit.as_mut() else {
915 return 0;
916 };
917 edit.record_undo();
918 let added = auto_constrain_doc(&mut edit.session.doc);
919 if added == 0 {
920 edit.undo_stack.pop();
921 return 0;
922 }
923 self.resolve_active_sketch("auto-constrain");
924 self.refresh_sketch_overlay();
925 self.dirty = true;
926 added
927 }
928}
929
930// ===========================================================================
931// Sketch delete-selected (S3b) — remove the selected entities + orphan cleanup.
932//
933// Operates on the active `self.sketch_edit`. Rule (chosen so a remaining geometry
934// NEVER references a missing point):
935// 1. Partition the selection into selected geometry / point / constraint ids.
936// 2. Drop every geometry that is SELECTED *or* references any selected point (the
937// remove-point cascade — deleting a vertex kills geometry that used it).
938// 3. Drop the selected points.
939// 4. Orphan cleanup: drop any remaining point NOT referenced by any surviving
940// geometry (a shared vertex — still referenced — stays; a deleted line's now
941// unshared endpoints vanish). Always on for this slice.
942// 5. Drop any constraint that is SELECTED *or* references a removed point (selected ∪
943// orphaned) — done LAST, over the full removed-point set, so no constraint dangles
944// either. A selected constraint drops ONLY itself; the geometry/points it
945// referenced are untouched (deleting a constraint never deletes geometry).
946// Then clear selection + hover, re-solve (swallowing errors), refresh, mark dirty.
947// Kept in ONE appended block so concurrent edits to the primary impl land clean.
948// ===========================================================================
949impl EngineState {
950 /// Delete the selected sketch entities (S3b): the selected geometries + points,
951 /// plus any geometry orphaned by a deleted vertex, plus orphaned points and the
952 /// constraints referencing any removed point. Re-solves + refreshes the overlay.
953 /// Returns `true` when something was deleted; `false` when not in sketch mode or
954 /// the selection is empty.
955 pub fn sketch_delete_selection(&mut self) -> bool {
956 use crate::sketch::doc::id_key;
957 use std::collections::HashSet;
958
959 let Some(edit) = self.sketch_edit.as_mut() else {
960 return false;
961 };
962 if edit.session.selection.is_empty() {
963 return false;
964 }
965 // A non-empty selection always removes something → snapshot for undo (S6a).
966 edit.record_undo();
967
968 // 1. Partition the selection into selected geometry / point / constraint ids
969 // (keyed via `id_key`, so 4 / 4.0 / "4" all match).
970 let mut sel_geo: HashSet<String> = HashSet::new();
971 let mut sel_pt: HashSet<String> = HashSet::new();
972 let mut sel_constraint: HashSet<String> = HashSet::new();
973 for r in &edit.session.selection {
974 match (r.get("kind").and_then(|v| v.as_str()), r.get("id")) {
975 (Some("geometry"), Some(id)) => {
976 sel_geo.insert(id_key(id));
977 }
978 (Some("point"), Some(id)) => {
979 sel_pt.insert(id_key(id));
980 }
981 (Some("constraint"), Some(id)) => {
982 sel_constraint.insert(id_key(id));
983 }
984 _ => {}
985 }
986 }
987
988 let doc = &mut edit.session.doc;
989
990 // 2. Drop geometries that are selected OR reference any selected point (so a
991 // deleted vertex never leaves a geometry dangling).
992 doc.geometries.retain(|g| {
993 if sel_geo.contains(&id_key(&g.id)) {
994 return false;
995 }
996 !g.points.iter().any(|pid| sel_pt.contains(&id_key(pid)))
997 });
998
999 // 3. Drop the explicitly-selected points.
1000 doc.points.retain(|p| !sel_pt.contains(&id_key(&p.id)));
1001
1002 // 4. Orphan cleanup: drop points no longer referenced by any surviving
1003 // geometry. Accumulate every removed point id (selected ∪ orphaned).
1004 let referenced: HashSet<String> = doc
1005 .geometries
1006 .iter()
1007 .flat_map(|g| g.points.iter().map(id_key))
1008 .collect();
1009 let mut removed_pts = sel_pt;
1010 doc.points.retain(|p| {
1011 let key = id_key(&p.id);
1012 if referenced.contains(&key) {
1013 true
1014 } else {
1015 removed_pts.insert(key);
1016 false
1017 }
1018 });
1019
1020 // 5. Drop constraints that are EXPLICITLY selected OR reference ANY removed
1021 // point (done last, over the full removed set, so no constraint dangles onto
1022 // a missing point). A selected constraint drops ONLY itself — the geometry /
1023 // points it references are left intact (deleting a constraint never deletes
1024 // geometry).
1025 doc.constraints.retain(|c| {
1026 if let Some(id) = c.raw.get("id") {
1027 if sel_constraint.contains(&id_key(id)) {
1028 return false;
1029 }
1030 }
1031 !c.points().iter().any(|pid| removed_pts.contains(&id_key(pid)))
1032 });
1033
1034 // Drop any external-reference bookkeeping whose materialized entities this
1035 // delete removed — otherwise its stale entry (keyed by edge name, now holding
1036 // dangling point ids) permanently blocks RE-LINKING the same edge.
1037 crate::sketch::external_ref::prune_dead_refs(&edit.session.doc, &mut edit.external_refs);
1038
1039 // Clear the interaction state, re-solve (keep the doc on failure), refresh.
1040 edit.session.clear_selection();
1041 edit.session.set_hover(None);
1042 self.resolve_active_sketch("delete");
1043 self.refresh_sketch_overlay();
1044 self.dirty = true;
1045 true
1046 }
1047}
1048
1049// ===========================================================================
1050// Sketch constraint palette (S4) — selection → applicable constraints + apply.
1051//
1052// A faithful port of the previous sketcher's TWO authoritative pieces, kept engine-side:
1053// * `SketchMode3D.#refreshContextBar` → [`sketch_applicable_constraints`] (which
1054// selection surfaces which palette buttons).
1055// * `ConstraintEngine.createConstraint` → [`sketch_add_constraint`] (selection →
1056// ordered point-id list per symbol, incl. the arc-pop, the geometry-role
1057// specials `◎/⊜/⌒/⋰/⋈`, the `⋯` point-first reverse, the `⟂` 4-point
1058// orientation swap, and the `⏛`-from-2-lines DOUBLE push).
1059//
1060// Dimensional constraints (`⟺ ↥ ∠ R ⌀`) are added with `value:null` +
1061// `valueNeedsSetup:true` (matching the previous app) — the Rust solver seeds a NaN target to
1062// the CURRENT measured value on the next solve (`c_distance`/`c_angle`), so S4 never
1063// prompts for a value (S5 makes it editable). Every constraint carries the
1064// base fields (`labelX/labelY/displayStyle`) so a load/save round-trips unchanged.
1065//
1066// Adds are DEDUP'd on `type + sorted-point-ids` (the solver runs with
1067// `remove_implied_duplicates:false`, so this is the only dedup). Kept in ONE
1068// appended block so concurrent edits to the primary impl land clean.
1069// ===========================================================================