diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 168d90be6..fd118b42a 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -118,8 +118,45 @@ impl OpenCADStudio { let _ = self.on_viewport_exit(); } - /// Roll a hot grip back to its pre-drag image and remove every grip-owned - /// overlay. Shared by Escape and drawing-space transitions. + pub(super) fn solve_grip_constraints( + &mut self, + i: usize, + grip: &crate::scene::pick::grip::GripEdit, + ) { + let touched: Vec<_> = grip.targets.iter().map(|target| target.handle).collect(); + let connected = self.tabs[i].scene.parametric_connected_handles( + self.tabs[i].current_parametric_scope(), &touched, true, + ); + for handle in connected { + if !self.grip_originals.iter().any(|(original, _)| *original == handle) { + if let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() { + self.grip_originals.push((handle, entity)); + } + } + if !self.grip_preview_handles.contains(&handle) { + self.grip_preview_handles.push(handle); + if !self.tabs[i].scene.meshes.contains_key(&handle) { + self.tabs[i].scene.preview_hidden.insert(handle); + } + } + } + let driven_refs: Vec<_> = grip.targets.iter().flat_map(|target| { + self.tabs[i].scene.document.get_entity(target.handle) + .map(|entity| crate::scene::parametric_constraints::grip_solve_anchor_refs( + entity, target.handle, target.grip_id, + )).unwrap_or_default() + }).collect(); + let solved = self.tabs[i].scene.solve_parametric_constraints_preview( + &touched, &driven_refs, + self.constraint_solve_mode && !driven_refs.is_empty(), &self.grip_originals, + ); + for (handle, entity) in solved { + if let Some(slot) = self.tabs[i].scene.document.get_entity_mut(handle) { + *slot = entity; + } + } + } + pub(super) fn capture_grip_history_originals(&mut self, i: usize, handles: &[Handle]) { if !self.grip_history_originals.is_empty() { return; @@ -220,7 +257,7 @@ impl OpenCADStudio { .into_iter() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.bump_entities_after_parametric_solve(&changes); if let Some(dirty_before) = self.grip_dirty_before.take() { self.tabs[i].dirty = dirty_before; } @@ -2190,7 +2227,7 @@ impl OpenCADStudio { let scope = self.tabs[i].current_parametric_scope(); handles = self.tabs[i] .scene - .parametric_connected_handles(scope, &handles); + .parametric_connected_handles(scope, &handles, false); handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); } let label = self.history_label_from_active_cmd(i, "MOVE"); diff --git a/src/app/history.rs b/src/app/history.rs index 421c495d0..3b4ac1d89 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1072,7 +1072,7 @@ impl OpenCADStudio { // references visually stale after BEDIT closes. scene.bump_geometry(); } else { - scene.bump_entities(&final_changes); + scene.bump_entities_after_parametric_solve(&final_changes); } } } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 8a3c081c4..a01824f79 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -573,6 +573,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .scene .apply_grip(handle, grip_id, apply); } + self.solve_grip_constraints(i, &grip); // Keep GripEdit synchronized so the normal commit // path records the exact final position. diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index df6f3df7a..f67041f82 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1244,7 +1244,7 @@ impl OpenCADStudio { }; let mut seen_handles = rustc_hash::FxHashSet::default(); - let mut edited_handles: Vec<_> = grip + let edited_handles: Vec<_> = grip .targets .iter() .map(|target| target.handle) @@ -1254,6 +1254,9 @@ impl OpenCADStudio { // Initialize once: constraint solves may add neighbors to this gesture. // Wire entities use the overlay; solid meshes stay visible and move live. if self.grip_preview_handles.is_empty() { + let preview_handles = self.tabs[i].scene.parametric_connected_handles( + self.tabs[i].current_parametric_scope(), &edited_handles, true, + ); if self.grip_dirty_before.is_none() { self.grip_dirty_before = Some(self.tabs[i].dirty); } @@ -1261,7 +1264,7 @@ impl OpenCADStudio { // before insertion so append + placement is one undo step. // Normal grip drags still snapshot their current entities here. if self.grip_originals.is_empty() { - self.grip_originals = edited_handles + self.grip_originals = preview_handles .iter() .filter_map(|&handle| { self.tabs[i] @@ -1274,17 +1277,17 @@ impl OpenCADStudio { .collect(); } self.capture_grip_history_originals(i, &edited_handles); - for &handle in &edited_handles { + for &handle in &preview_handles { if !self.tabs[i].scene.meshes.contains_key(&handle) { self.tabs[i].scene.preview_hidden.insert(handle); } } - let changes: Vec<_> = edited_handles + let changes: Vec<_> = preview_handles .iter() .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); - self.grip_preview_handles = edited_handles.clone(); + self.tabs[i].scene.bump_entities_after_parametric_solve(&changes); + self.grip_preview_handles = preview_handles; // Snapshot the entity's glyph quads once so each move can // slide the already-shaped text rather than re-shaping it // (issue #316). The fast slide path only runs for a rigid @@ -1292,7 +1295,7 @@ impl OpenCADStudio { // dimension re-tessellates) and a Square insertion grip (so // an MTEXT width handle, a Triangle, still re-tessellates so // the re-wrap is exact). - let snap = self.tabs[i].scene.wire_models_for(&edited_handles); + let snap = self.tabs[i].scene.wire_models_for(&self.grip_preview_handles); // Preserve the exact geometry from the instant the grip drag began. // This snapshot is visual/reference-only: do NOT append it to @@ -1317,7 +1320,7 @@ impl OpenCADStudio { grip_def.shape == crate::scene::model::object::GripShape::Square }) .unwrap_or(false); - self.grip_text_slide = edited_handles.len() == 1 + self.grip_text_slide = self.grip_preview_handles.len() == 1 && grip.targets.len() == 1 && !self.grip_text_verts.is_empty() && snap.iter().all(|w| w.points.is_empty()) @@ -1745,62 +1748,17 @@ impl OpenCADStudio { ); } } - let driven_refs: Vec<_> = grip - .targets - .iter() - .filter_map(|target| { - let entity = self.tabs[i] - .scene - .document - .get_entity(target.handle)?; - crate::scene::parametric_constraints::grip_solve_anchor_ref( - entity, - target.handle, - target.grip_id, - ) - }) - .collect(); - // Re-solve constrained neighbors on each drag frame and include - // their original state in the gesture's undo record. - let solved_by_constraints = self.tabs[i] - .scene - .solve_parametric_constraints_preview( - &edited_handles, - &driven_refs, - false, - &self.grip_originals, - ); - for (handle, _) in &solved_by_constraints { - let handle = *handle; - if !self.grip_preview_handles.contains(&handle) { - if let Some(original) = self.tabs[i].scene.document.get_entity(handle).cloned() - { - self.grip_originals.push((handle, original)); - } - self.grip_preview_handles.push(handle); - if !self.tabs[i].scene.meshes.contains_key(&handle) { - self.tabs[i].scene.preview_hidden.insert(handle); - } - } - if !edited_handles.contains(&handle) { - edited_handles.push(handle); - } - } - for (handle, new_entity) in solved_by_constraints { - if let Some(slot) = self.tabs[i].scene.document.get_entity_mut(handle) { - *slot = new_entity; - } - } - let mesh_changes: Vec<_> = edited_handles + self.solve_grip_constraints(i, &grip); + let mesh_changes: Vec<_> = self.grip_preview_handles .iter() .copied() .filter(|handle| self.tabs[i].scene.meshes.contains_key(handle)) .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); if !mesh_changes.is_empty() { - self.tabs[i].scene.bump_entities(&mesh_changes); + self.tabs[i].scene.bump_entities_after_parametric_solve(&mesh_changes); } - self.tabs[i].scene.set_preview_hatches(&edited_handles); + self.tabs[i].scene.set_preview_hatches(&self.grip_preview_handles); self.tabs[i].dirty = true; if let Some(active) = self.tabs[i].active_grip.as_mut() { active.last_world = snapped; @@ -1826,7 +1784,7 @@ impl OpenCADStudio { self.tabs[i].scene.set_preview_wires(Vec::new()); } else { // Current deformed geometry. - let mut preview = self.tabs[i].scene.grip_wire_models_for(&edited_handles); + let mut preview = self.tabs[i].scene.grip_wire_models_for(&self.grip_preview_handles); // Also show the drag-start geometry as a faint ghost. // @@ -3480,7 +3438,7 @@ impl OpenCADStudio { .into_iter() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.bump_entities_after_parametric_solve(&changes); } // Placement confirmed — keep the just-added leader. self.grip_add_provisional = None; @@ -6075,6 +6033,128 @@ mod selection_preview_tests { use super::*; use crate::app::{HoverDwell, OpenCADStudio, HOVER_DWELL_MS}; + #[test] + fn lower_endpoint_drag_previews_connected_tangent_profile_and_keeps_top_fixed() { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; + use acadrust::{entities::{Arc, Line}, types::Vector3, EntityType}; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = false; + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.constraint_solve_mode = true; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let scene = &mut app.tabs[i].scene; + let [outer, inner, upper, lower] = [ + ([0.0, 0.0], [0.0, 5.0]), + ([-1.0, -1.0], [-1.0, 5.0]), + ([4.0, 0.0], [0.0, 0.0]), + ([4.0, -1.0], [-1.0, -1.0]), + ].map(|(a, b)| scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(a[0], a[1], 0.0), Vector3::new(b[0], b[1], 0.0), + )))); + let top = scene.add_entity(EntityType::Arc(Arc::from_coords( + -0.5, 5.0, 0.0, 0.5, 0.0, std::f64::consts::PI, + ))); + let right = scene.add_entity(EntityType::Arc(Arc::from_coords( + 4.0, -0.5, 0.0, 0.5, -std::f64::consts::FRAC_PI_2, + std::f64::consts::FRAC_PI_2, + ))); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + for (a, ai, b, bi) in [ + (outer, 1, top, 0), (top, 1, inner, 1), + (outer, 0, upper, 1), (upper, 0, right, 1), + (inner, 0, lower, 1), (right, 0, lower, 0), + ] { + set.add(ConstraintKind::Coincident, + vec![ParametricRef::point(a, ai), ParametricRef::point(b, bi)], None); + } + for (kind, a, b) in [ + (ConstraintKind::Parallel, outer, inner), + (ConstraintKind::Parallel, upper, lower), + (ConstraintKind::Tangent, outer, top), + (ConstraintKind::Tangent, inner, top), + (ConstraintKind::Tangent, upper, right), + (ConstraintKind::Tangent, lower, right), + (ConstraintKind::Equal, top, right), + ] { + set.add(kind, vec![ParametricRef::whole(a), ParametricRef::whole(b)], None); + } + set.add(ConstraintKind::Horizontal, vec![ParametricRef::whole(lower)], None); + let handles = if let Ok(path) = std::env::var("OCS_GRIP_TEST_DRAWING") { + let result = app.automation_op(&serde_json::json!({"op":"open","path":path}).to_string()); + assert_eq!(result["ok"], true, "{result}"); + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + [0x57F, 0x57D, 0x57C, 0x57A, 0x57E, 0x57B].map(Handle::new) + } else { + [outer, inner, upper, lower, top, right] + }; + let inner = handles[1]; + let geometry = |app: &OpenCADStudio| handles.map(|handle| + app.tabs[i].scene.document.get_entity(handle).unwrap().clone()); + let before = geometry(&app); + let EntityType::Line(original_line) = &before[1] else { panic!("line") }; + let lower_endpoint = original_line.start; + let upper_endpoint = original_line.end; + let EntityType::Arc(original_arc) = &before[4] else { panic!("arc") }; + let radius = original_arc.radius; + app.tabs[i].scene.selected.insert(inner); + app.refresh_selected_grips(); + app.tabs[i].active_grip = Some(GripEdit::single( + inner, 0, false, glam::DVec3::new(lower_endpoint.x, lower_endpoint.y, 0.0), + )); + let check_profile = |app: &OpenCADStudio| { + let entities = geometry(app); + let EntityType::Line(line) = &entities[1] else { panic!("line") }; + assert_eq!(line.end, upper_endpoint, "top endpoint drifted"); + let EntityType::Arc(arc) = &entities[4] else { panic!("arc") }; + assert!((arc.end_point() - line.end).length() < 1e-7, "top arc detached"); + for index in [4, 5] { + let EntityType::Arc(arc) = &entities[index] else { panic!("arc") }; + assert!((arc.radius - radius).abs() < 1e-7, "parallel gap changed"); + } + for (line_index, arc_index, marker) in [(0, 4, 0), (1, 4, 1), (2, 5, 1), (3, 5, 0)] { + let EntityType::Line(line) = &entities[line_index] else { panic!("line") }; + let EntityType::Arc(arc) = &entities[arc_index] else { panic!("arc") }; + let contact = if marker == 0 { arc.start_point() } else { arc.end_point() }; + let endpoint = if line_index < 2 { line.end } else { line.start }; + assert!((contact - endpoint).length() < 1e-7, "tangent contact detached"); + let direction = line.end - line.start; + assert!(direction.dot(&(contact - arc.center)).abs() < 1e-7, + "tangency lost: line={line:?}, arc={arc:?}, error={}", direction.dot(&(contact - arc.center))); + } + }; + for offset in [[-0.4, -0.5], [-1.0, -1.0], [-1.0, -1.0], [0.3, 0.4]] { + let target = [lower_endpoint.x + offset[0], lower_endpoint.y + offset[1]]; + let cursor = app.tabs[i].scene.camera.borrow().project( + glam::DVec3::new(target[0], target[1], 0.0), + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)), + ).unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + check_profile(&app); + let EntityType::Line(line) = app.tabs[i].scene.document.get_entity(inner).unwrap() + else { panic!("line") }; + assert!((line.start - Vector3::new(target[0], target[1], 0.0)).length() < 1e-5); + for handle in handles { + assert!(app.tabs[i].scene.preview_wires.iter().any(|wire| + wire.name == handle.value().to_string() && wire.color[3] > 0.9), + "connected entity {handle:?} missing from preview"); + } + } + let preview = geometry(&app); + let _ = app.on_viewport_left_release(); + check_profile(&app); + assert_eq!(geometry(&app), preview, "release changed the preview solution"); + app.undo_steps(1); + assert_eq!(geometry(&app), before); + app.redo_steps(1); + assert_eq!(geometry(&app), preview); + } + #[test] fn grip_moves_keep_perpendicular_constraints_live_and_undoable() { use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; @@ -6137,9 +6217,9 @@ mod selection_preview_tests { .normalize() }; assert!(direction(current[0]).dot(direction(current[1])).abs() < 1e-8); - assert_eq!( + assert_ne!( current[1], before[1], - "geometry outside the grip edit must stay fixed" + "the constrained neighbor must follow the edited line" ); assert_eq!( current[0][0], before[0][0], diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 0f404a58d..ff284899e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2879,6 +2879,22 @@ impl Scene { driven_refs: &[parametric_constraints::ParametricRef], retain_size: bool, retained_originals: &[(Handle, EntityType)], + ) { + self.bump_entities_with_solve_policy(changes, driven_refs, retain_size, retained_originals, true); + } + + /// Refresh display and associations after a solved grip or exact history restore. + pub(crate) fn bump_entities_after_parametric_solve(&mut self, changes: &[(Handle, ChangeKind)]) { + self.bump_entities_with_solve_policy(changes, &[], false, &[], false); + } + + fn bump_entities_with_solve_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], + solve_parametric: bool, ) { if changes.iter().any(|(handle, kind)| { matches!(kind, ChangeKind::Removed) @@ -2912,7 +2928,7 @@ impl Scene { changes.push(change); } } - if !self.parametric_constraints.is_empty() { + if solve_parametric && !self.parametric_constraints.is_empty() { for change in self.refresh_parametric_constraints_with_originals( &changes, driven_refs, diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 982f2d94f..1a3a82020 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -83,32 +83,33 @@ impl ParametricRef { } } -/// Point reference temporarily pinned while solving a native grip edit. -/// A line endpoint anchors its opposite endpoint so the grabbed endpoint is -/// projected onto the constraint solution instead of moving the whole line. -pub(crate) fn grip_solve_anchor_ref( +/// Point references temporarily pinned while solving a native grip edit. +/// Both endpoints of an edited line are exact inputs: the opposite endpoint +/// stays fixed while the grabbed endpoint follows the cursor. +pub(crate) fn grip_solve_anchor_refs( entity: &acadrust::EntityType, handle: Handle, grip_id: usize, -) -> Option { +) -> Vec { match entity { - acadrust::EntityType::Line(_) if grip_id <= 1 => { - Some(ParametricRef::point(handle, 1 - grip_id as i32)) - } + acadrust::EntityType::Line(_) if grip_id <= 1 => vec![ + ParametricRef::point(handle, 0), + ParametricRef::point(handle, 1), + ], acadrust::EntityType::LwPolyline(polyline) if grip_id < polyline.vertices.len() => { - Some(ParametricRef::point(handle, grip_id as i32)) + vec![ParametricRef::point(handle, grip_id as i32)] } acadrust::EntityType::Polyline2D(polyline) if grip_id < polyline.vertices.len() => { - Some(ParametricRef::point(handle, grip_id as i32)) + vec![ParametricRef::point(handle, grip_id as i32)] } acadrust::EntityType::Arc(_) => match grip_id { - 0 => Some(ParametricRef::center(handle)), - 1 => Some(ParametricRef::point(handle, 0)), - 2 => Some(ParametricRef::point(handle, 1)), - _ => None, + 0 => vec![ParametricRef::center(handle)], + 1 => vec![ParametricRef::point(handle, 0)], + 2 => vec![ParametricRef::point(handle, 1)], + _ => Vec::new(), }, acadrust::EntityType::Circle(_) if grip_id == 0 => { - Some(ParametricRef::center(handle)) + vec![ParametricRef::center(handle)] } acadrust::EntityType::Point(_) | acadrust::EntityType::Insert(_) @@ -116,9 +117,9 @@ pub(crate) fn grip_solve_anchor_ref( | acadrust::EntityType::MText(_) if grip_id == 0 => { - Some(ParametricRef::point(handle, 0)) + vec![ParametricRef::point(handle, 0)] } - _ => None, + _ => Vec::new(), } } @@ -903,13 +904,13 @@ pub(crate) fn constraint_hover_points( } impl super::Scene { - /// Expand a set of entities to the full enabled constraint component in - /// one scope. Whole-object translations use this to keep every connected - /// entity's own shape while moving the assembly. + /// Expand through enabled constraints. Whole-object translations follow + /// point connections; grip previews include curve relations as well. pub(crate) fn parametric_connected_handles( &self, scope: ParametricScope, seeds: &[Handle], + include_curve_relations: bool, ) -> Vec { let mut ordered = seeds.to_vec(); let mut found: std::collections::HashSet<_> = seeds.iter().copied().collect(); @@ -920,10 +921,10 @@ impl super::Scene { let mut added = false; for constraint in set.constraints.iter().filter(|constraint| { constraint.enabled - && matches!( + && (include_curve_relations || matches!( constraint.kind, ConstraintKind::Coincident | ConstraintKind::PointOnCurve - ) + )) }) { if !constraint .refs @@ -1414,7 +1415,8 @@ impl super::Scene { let selected = c .refs .iter() - .any(|reference| self.selected.contains(&reference.entity)); + .any(|reference| self.selected.contains(&reference.entity) + || self.preview_hidden.contains(&reference.entity)); self.should_display_parametric_constraint(scope, c.id, selected, display_mode) }) .flat_map(|c| { @@ -1888,18 +1890,18 @@ mod tests { )); assert_eq!( - grip_solve_anchor_ref(&arc, handle, 0), - Some(ParametricRef::center(handle)) + grip_solve_anchor_refs(&arc, handle, 0), + vec![ParametricRef::center(handle)] ); assert_eq!( - grip_solve_anchor_ref(&arc, handle, 1), - Some(ParametricRef::point(handle, 0)) + grip_solve_anchor_refs(&arc, handle, 1), + vec![ParametricRef::point(handle, 0)] ); assert_eq!( - grip_solve_anchor_ref(&arc, handle, 2), - Some(ParametricRef::point(handle, 1)) + grip_solve_anchor_refs(&arc, handle, 2), + vec![ParametricRef::point(handle, 1)] ); - assert_eq!(grip_solve_anchor_ref(&arc, handle, 3), None); + assert_eq!(grip_solve_anchor_refs(&arc, handle, 3), Vec::new()); } #[test] @@ -2146,7 +2148,7 @@ mod tests { ); assert_eq!( - scene.parametric_connected_handles(ParametricScope::ModelSpace, &[h(1)]), + scene.parametric_connected_handles(ParametricScope::ModelSpace, &[h(1)], false), vec![h(1), h(2), h(3)] ); } diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 8297860df..86900a7d2 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -800,27 +800,54 @@ fn oriented_distance(target: f64, current_projection: f64) -> f64 { } } -/// One [`ParametricConstraint`]'s `cadkernel_constraints` construction: every system-level -/// constraint it contributes (most kinds contribute exactly one; a few — -/// `Coincident`/`Concentric`/`CenterPoint`'s X+Y halves, `Colinear`'s two -/// endpoints, `Midpoint`'s X+Y, `Symmetric`'s midpoint+perpendicular pair, -/// `Fixed`'s per-coordinate pins — contribute more). Empty means it can't be -/// built (an unsupported/not-yet-mapped kind, a dangling ref, a ref shape -/// the kind doesn't expect — e.g. `Parallel` needs two whole-line refs — or, -/// for a dimensional kind, a `driving_param` that fails to resolve). A constraint -/// that can't be built is simply skipped for this solve, not an error: -/// geometry it would have constrained is left alone, matching how a -/// dangling associative-dimension reference degrades today rather than -/// aborting the whole recompute. (A named-parameter *cycle* should be -/// unreachable here — `ParameterTable::set` already refuses to create one — -/// so it gets the same treatment as any other resolve failure rather than a -/// special case.) +fn retained_tangent_side( + document: &acadrust::CadDocument, + retained_before: &HashMap>, + constraint: &ParametricConstraint, +) -> Option { + let [a, b] = constraint.refs.as_slice() else { + return None; + }; + if constraint.kind != ConstraintKind::Tangent + || !retained_before.contains_key(&a.entity) && !retained_before.contains_key(&b.entity) + { + return None; + } + let curve = |reference: ParametricRef| { + let entity = retained_before + .get(&reference.entity) + .map(|entity| entity.as_ref()) + .or_else(|| document.get_entity(reference.entity))?; + let curve = crate::entities::curve::entity_curve_xy(entity)?; + reference + .segment_index() + .map(|index| curve.segments().into_iter().nth(index)) + .unwrap_or(Some(curve)) + }; + let circle_center = |curve: &cadkernel::geom2d::Curve| match curve { + cadkernel::geom2d::Curve::Circle(circle) => Some(circle.centre), + cadkernel::geom2d::Curve::Arc(arc) => Some(arc.centre), + _ => None, + }; + let (a, b) = (curve(*a)?, curve(*b)?); + let (line, center) = a + .as_ray() + .zip(circle_center(&b)) + .or_else(|| b.as_ray().zip(circle_center(&a)))?; + let ([x1, y1], [dx, dy]) = line; + Some(dx * (center[1] - y1) - dy * (center[0] - x1) >= 0.0) +} + +/// Builds the kernel equations for one constraint. Unsupported references or +/// invalid dimensional targets produce no equations. fn build_constraint( document: &acadrust::CadDocument, sys: &mut System, cache: &mut HashMap, params: &ParameterTable, c: &ParametricConstraint, + tangent_side: Option, + tangent_point: Option, ) -> Vec> { if !c.enabled || !refs_share_supported_plane(document, &c.refs) { return Vec::new(); @@ -1517,6 +1544,14 @@ fn build_constraint( } (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { + if let Some(point) = tangent_point.and_then(|reference| + resolve_constraint_point(document, sys, cache, reference)) + { + // At a coincident endpoint, constrain the tangent direction + // directly; a circle-to-line distance has a singular derivative. + let radius = GLine { p1: circ.center, p2: point }; + return vec![Rc::new(PerpendicularConstraint::new(sys.store(), line, radius))]; + } let store = sys.store(); let (cx, cy) = (store.get(circ.center.x), store.get(circ.center.y)); let (x1, y1) = (store.get(line.p1.x), store.get(line.p1.y)); @@ -1525,7 +1560,7 @@ fn build_constraint( // `C2LDistance::signed_value` itself uses — so `ccw`'s // sign matches whichever side the circle already sits on. let area = (x2 - x1) * (cy - y1) - (y2 - y1) * (cx - x1); - let ccw = area >= 0.0; + let ccw = tangent_side.unwrap_or(area >= 0.0); // A driven, fixed zero: with `internal: false` this makes // `C2LDistance`'s target exactly the circle's own radius // (see its `error_grad`), i.e. plain tangency rather than @@ -1748,6 +1783,7 @@ fn solve_scope( set: &ParametricConstraintSet, driven_refs: &[ParametricRef], retain_size: bool, + retain_lengths: bool, retained_before: &HashMap>, ) -> Option { let params = if set.local_parameters.is_empty() { @@ -1768,7 +1804,26 @@ fn solve_scope( let mut owner: Vec<(Rc, ConstraintId)> = Vec::new(); for c in &set.constraints { - for constraint in build_constraint(document, &mut sys, &mut cache, params, c) { + let tangent_side = retained_tangent_side(document, retained_before, c); + let tangent_point = (c.kind == ConstraintKind::Tangent && c.refs.len() == 2 + && c.refs[0].entity != c.refs[1].entity + && c.refs.iter().all(|r| r.marker.is_none())) + .then(|| set.constraints.iter().find_map(|connection| { + (connection.enabled && connection.kind == ConstraintKind::Coincident + && connection.refs.len() == 2 + && connection.refs.iter().all(|r| matches!(r.marker, Some(0 | 1))) + && c.refs.iter().all(|r| connection.refs.iter().any(|p| p.entity == r.entity))) + .then(|| connection.refs[0]) + })).flatten(); + for constraint in build_constraint( + document, + &mut sys, + &mut cache, + params, + c, + tangent_side, + tangent_point, + ) { owner.push((constraint.clone(), c.id)); sys.add_constraint(constraint); } @@ -1782,13 +1837,12 @@ fn solve_scope( return None; } - // Preserve current line lengths and radii for this solve when - // CONSTRAINTSOLVEMODE requests it. These temporary equations guide the - // solution without becoming persistent dimensional constraints. + // Grip edits retain radii while connected lines resize to meet their + // endpoints. Other edits can retain line lengths as well. if retain_size { for (handle, geom) in &cache { match geom { - EntityGeom::Line(line) => { + EntityGeom::Line(line) if retain_lengths => { let current_length = { let store = sys.store(); let dx = store.get(line.p2.x) - store.get(line.p1.x); @@ -1806,7 +1860,7 @@ fn solve_scope( points, closed, .. - } => { + } if retain_lengths => { for index in 0..points.len() { let Some(a) = points.get(index).copied() else { continue; @@ -2476,6 +2530,8 @@ impl Scene { &mut cache, &self.named_parameters, &candidate, + None, + None, ) .is_empty() { @@ -2592,6 +2648,7 @@ impl Scene { &self.parametric_constraints[i], driven_refs, retain_size, + true, &retained_before, ) else { continue; @@ -2636,25 +2693,17 @@ impl Scene { if !is_touched { continue; } - let mut anchors = driven_refs.to_vec(); - for reference in set - .constraints - .iter() - .filter(|constraint| constraint.enabled) - .flat_map(|constraint| &constraint.refs) - { - if !touched.contains(&reference.entity) - && !anchors.iter().any(|anchor| anchor.entity == reference.entity) - { - anchors.push(ParametricRef::whole(reference.entity)); - } - } + let connected = self.parametric_connected_handles(set.scope, touched, true); + let mut component = set.clone(); + component.constraints.retain(|constraint| constraint.refs.iter() + .any(|reference| connected.contains(&reference.entity))); let Some((solved, _dof, _conflicts)) = solve_scope( &self.document, &self.named_parameters, - set, - &anchors, + &component, + driven_refs, retain_size, + false, &retained_before, ) else { @@ -2925,68 +2974,87 @@ mod tests { ); } - /// A grip preview treats geometry outside the edit as a fixed reference, - /// so a parallel constraint projects the dragged endpoint without moving - /// the other line or changing their separation. + /// A grip preview lets the paired line and tangent circle follow the + /// edited line while preserving their sizes and parallel separation. #[test] - fn preview_solve_keeps_untouched_parallel_geometry_fixed() { + fn preview_solve_preserves_parallel_spacing_through_tangent_radius() { let mut scene = Scene::new(); let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( Vector3::new(0.0, 0.0, 0.0), - Vector3::new(10.0, 0.0, 0.0), + Vector3::new(0.0, 10.0, 0.0), ))); let b = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(0.0, 5.0, 0.0), - Vector3::new(10.0, 5.0, 0.0), + Vector3::new(2.0, 0.0, 0.0), + Vector3::new(2.0, 10.0, 0.0), ))); + let circle = scene.add_entity(EntityType::Circle( + acadrust::entities::Circle::from_center_radius(Vector3::new(1.0, 5.0, 0.0), 1.0), + )); let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); set.add( ConstraintKind::Parallel, vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); + set.add( + ConstraintKind::Tangent, + vec![ParametricRef::whole(a), ParametricRef::whole(circle)], + None, + ); + set.add( + ConstraintKind::Tangent, + vec![ParametricRef::whole(b), ParametricRef::whole(circle)], + None, + ); scene.parametric_constraints.push(set); - // Simulate a live grip drag: mutate `a` directly (as `apply_grip` - // would each frame), without going through `bump_entities` at all. + let a_before = scene.document.get_entity(a).unwrap().clone(); if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(a) { - l.end = Vector3::new(10.0, 6.0, 0.0); + l.end = Vector3::new(2.0, 10.0, 0.0); } let b_before = scene.document.get_entity(b).cloned(); let solved = scene.solve_parametric_constraints_preview( &[a], - &[ParametricRef::point(a, 0)], - false, - &[], + &[ParametricRef::point(a, 0), ParametricRef::point(a, 1)], + true, + &[(a, a_before)], ); - assert_eq!( scene.document.get_entity(b), b_before.as_ref(), "preview must not mutate the document" ); - let moved_a = solved - .iter() - .find(|(h, _)| *h == a) - .map(|(_, entity)| entity) - .expect("the dragged line should be projected"); - assert!(solved.iter().all(|(handle, _)| *handle != b)); - let EntityType::Line(moved_a_line) = moved_a else { + let solved_entity = |handle| { + solved + .iter() + .find(|(candidate, _)| *candidate == handle) + .map(|(_, entity)| entity) + .unwrap_or_else(|| scene.document.get_entity(handle).unwrap()) + }; + let EntityType::Line(moved_a) = solved_entity(a) else { panic!("expected a Line") }; - assert_eq!(moved_a_line.start, Vector3::new(0.0, 0.0, 0.0)); - let EntityType::Line(fixed_b) = b_before.unwrap() else { + let EntityType::Line(moved_b) = solved_entity(b) else { panic!("expected a Line") }; - let dir_a = moved_a_line.end - moved_a_line.start; - let dir_b = fixed_b.end - fixed_b.start; + let EntityType::Circle(moved_circle) = solved_entity(circle) else { + panic!("expected a Circle") + }; + assert_eq!(moved_a.start, Vector3::new(0.0, 0.0, 0.0)); + assert_eq!(moved_a.end, Vector3::new(2.0, 10.0, 0.0)); + assert_ne!(solved_entity(b), b_before.as_ref().unwrap()); + let dir_a = moved_a.end - moved_a.start; + let dir_b = moved_b.end - moved_b.start; let cross = dir_a.x * dir_b.y - dir_a.y * dir_b.x; + assert!(cross.abs() < 1.0e-6); + let offset = moved_b.start - moved_a.start; + let separation = (dir_a.x * offset.y - dir_a.y * offset.x).abs() / dir_a.length(); assert!( - cross.abs() < 1e-6, - "the previewed positions should be mutually parallel: dir_a={dir_a:?} dir_b={dir_b:?}" + (separation - 2.0).abs() < 1.0e-6, + "separation={separation}, a={moved_a:?}, b={moved_b:?}, circle={moved_circle:?}" ); - assert_eq!(fixed_b.start.y - moved_a_line.start.y, 5.0); + assert!((moved_circle.radius - 1.0).abs() < 1.0e-9); } #[test]