diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index f67041f82..4d4d8510c 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -6236,7 +6236,7 @@ mod selection_preview_tests { } #[test] - fn constrained_polyline_vertex_grip_reshapes_instead_of_translating() { + fn constrained_rectangle_grips_distinguish_perpendicular_corner_and_free_ends() { use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; use acadrust::{entities::LwPolyline, types::Vector2, EntityType}; @@ -6248,6 +6248,7 @@ mod selection_preview_tests { 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 mut rectangle = LwPolyline::from_points(vec![ Vector2::new(0.0, 0.0), @@ -6262,6 +6263,14 @@ mod selection_preview_tests { set.add(ConstraintKind::Parallel, vec![ParametricRef::segment(handle, 1), ParametricRef::segment(handle, 3)], None); set.add(ConstraintKind::Perpendicular, vec![ParametricRef::segment(handle, 3), ParametricRef::segment(handle, 2)], None); set.add(ConstraintKind::Horizontal, vec![ParametricRef::segment(handle, 2)], None); + let handle = 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); + Handle::new(0x556) + } else { + handle + }; let vertices = |app: &OpenCADStudio| { let EntityType::LwPolyline(polyline) = app.tabs[i].scene.document.get_entity(handle).unwrap() @@ -6271,27 +6280,82 @@ mod selection_preview_tests { polyline.vertices.iter().map(|vertex| vertex.location).collect::>() }; let before = vertices(&app); - app.tabs[i].active_grip = Some(GripEdit::single(handle, 0, false, glam::DVec3::ZERO)); + let height = before[3].y - before[0].y; + let width = before[1].x - before[0].x; + for grip_id in 0..8 { + let vertex = if grip_id < 4 { before[grip_id] } + else { (before[grip_id - 4] + before[(grip_id - 3) % 4]) * 0.5 }; + let origin = glam::DVec3::new(vertex.x, vertex.y, 0.0); + app.tabs[i].active_grip = Some(GripEdit::single(handle, grip_id, grip_id >= 4, origin)); + for offset in [[-1.0, height], [-1.0, 3.0], [-2.0, 6.0], [-2.0, 6.0], [1.0, -4.0]] { + let target = origin + glam::DVec3::new(offset[0], offset[1], 0.0); + let cursor = app.tabs[i].scene.camera.borrow().project(target, + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0))).unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + let after = vertices(&app); + let (left, right, bottom, top) = match grip_id { + 0 => (target.x, target.x + width, target.y, before[3].y), + 1 => (before[0].x, target.x, target.y, before[3].y), + 2 => (before[0].x, target.x, target.y - height, target.y), + 3 => (target.x, target.x + width, target.y - height, target.y), + 4 => (before[0].x + offset[0], before[1].x + offset[0], target.y, before[3].y), + 5 => (before[0].x, target.x, before[0].y + offset[1], before[3].y + offset[1]), + 6 => (before[0].x + offset[0], before[1].x + offset[0], before[0].y, target.y), + 7 => (target.x, before[1].x, before[0].y + offset[1], before[3].y + offset[1]), + _ => unreachable!(), + }; + let expected = [Vector2::new(left, bottom), Vector2::new(right, bottom), + Vector2::new(right, top), Vector2::new(left, top)]; + for (actual, expected) in after.iter().zip(expected) { + assert!((*actual - expected).length_squared() < 1e-10, + "grip {grip_id}: rectangle collapsed or drifted: {after:?}, expected {expected:?}"); + } + } + let preview = vertices(&app); + let _ = app.on_viewport_left_release(); + assert_eq!(vertices(&app), preview); + app.undo_steps(1); + assert_eq!(vertices(&app), before); + app.redo_steps(1); + assert_eq!(vertices(&app), preview); + app.undo_steps(1); + } + } - let _ = app.on_viewport_move(Point::new(460.0, 220.0)); + #[test] + fn axis_constrained_line_grip_changes_length_and_translates_normal_to_axis() { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; + use acadrust::{entities::Line, types::Vector3, EntityType}; - let after = vertices(&app); - let dragged = after[0] - before[0]; - assert!(dragged.length_squared() > 1.0e-12); - assert!(after.iter().zip(&before).skip(1).any(|(after, before)| - (*after - *before - dragged).length_squared() > 1.0e-12)); - let segments = [ - after[1] - after[0], - after[2] - after[1], - after[3] - after[2], - after[0] - after[3], - ]; - let cross = |a: Vector2, b: Vector2| a.x * b.y - a.y * b.x; - let dot = |a: Vector2, b: Vector2| a.x * b.x + a.y * b.y; - assert!(cross(segments[0], segments[2]).abs() < 1.0e-6); - assert!(cross(segments[1], segments[3]).abs() < 1.0e-6); - assert!(dot(segments[3], segments[2]).abs() < 1.0e-6); - assert!(segments[2].y.abs() < 1.0e-6); + for vertical in [false, true] { + 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 end = if vertical { Vector3::new(0.0, 4.0, 0.0) } else { Vector3::new(4.0, 0.0, 0.0) }; + let handle = app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points(Vector3::ZERO, end))); + app.tabs[i].scene.parametric_constraint_set_mut(ParametricScope::ModelSpace).add( + if vertical { ConstraintKind::Vertical } else { ConstraintKind::Horizontal }, + vec![ParametricRef::whole(handle)], None); + app.tabs[i].active_grip = Some(GripEdit::single(handle, 0, false, glam::DVec3::ZERO)); + for target in [glam::DVec3::new(-1.0, 3.0, 0.0), glam::DVec3::new(2.0, -2.0, 0.0)] { + let cursor = app.tabs[i].scene.camera.borrow().project(target, + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0))).unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + let EntityType::Line(line) = app.tabs[i].scene.document.get_entity(handle).unwrap() + else { panic!("line") }; + let opposite = if vertical { Vector3::new(target.x, end.y, 0.0) } + else { Vector3::new(end.x, target.y, 0.0) }; + assert!((line.start - Vector3::new(target.x, target.y, 0.0)).length() < 1e-5); + assert!((line.end - opposite).length() < 1e-5, "{line:?}"); + } + } } #[test] diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 1a3a82020..23fa5e3da 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -83,22 +83,30 @@ impl ParametricRef { } } -/// 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. +/// Grabbed points are exact kernel inputs; the solver anchors the remaining +/// endpoint coordinates according to the line's directional constraints. pub(crate) fn grip_solve_anchor_refs( entity: &acadrust::EntityType, handle: Handle, grip_id: usize, ) -> Vec { match entity { - acadrust::EntityType::Line(_) if grip_id <= 1 => vec![ - ParametricRef::point(handle, 0), - ParametricRef::point(handle, 1), - ], + acadrust::EntityType::Line(_) if grip_id <= 1 => { + vec![ParametricRef::point(handle, grip_id as i32)] + } acadrust::EntityType::LwPolyline(polyline) if grip_id < polyline.vertices.len() => { vec![ParametricRef::point(handle, grip_id as i32)] } + acadrust::EntityType::LwPolyline(polyline) => { + let segment = grip_id - polyline.vertices.len(); + if polyline.vertices.get(segment).is_some_and(|vertex| vertex.bulge.abs() < 1e-9) + && (segment + 1 < polyline.vertices.len() || polyline.is_closed) + { + vec![ParametricRef::segment(handle, segment)] + } else { + Vec::new() + } + } acadrust::EntityType::Polyline2D(polyline) if grip_id < polyline.vertices.len() => { vec![ParametricRef::point(handle, grip_id as i32)] } diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 86900a7d2..f4defa95c 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1766,6 +1766,37 @@ fn retained_radius(entity: &EntityType) -> Option { } } +/// Propagate explicit axis constraints through parallel/perpendicular relations. +fn constrained_line_axes(set: &ParametricConstraintSet) -> HashMap { + let mut axes = HashMap::new(); + for c in set.constraints.iter().filter(|c| c.enabled) { + if let [reference] = c.refs.as_slice() { + if matches!(c.kind, ConstraintKind::Horizontal | ConstraintKind::Vertical) { + axes.insert(*reference, c.kind == ConstraintKind::Vertical); + } + } + } + loop { + let before = axes.len(); + for c in set.constraints.iter().filter(|c| c.enabled) { + if !matches!(c.kind, ConstraintKind::Parallel | ConstraintKind::Perpendicular | ConstraintKind::Colinear) { + continue; + } + let [a, b] = c.refs.as_slice() else { continue }; + let perpendicular = c.kind == ConstraintKind::Perpendicular; + if let Some(axis) = axes.get(a).copied() { + axes.entry(*b).or_insert(axis ^ perpendicular); + } + if let Some(axis) = axes.get(b).copied() { + axes.entry(*a).or_insert(axis ^ perpendicular); + } + } + if before == axes.len() { + return axes; + } + } +} + /// Rebuilds `set`'s entire `cadkernel_constraints::System` from current document /// geometry, solves it, and returns the resulting entity states for every /// handle whose registered coordinates actually moved beyond floating-point @@ -1793,6 +1824,7 @@ fn solve_scope( }; let mut sys = System::new(); let mut cache: HashMap = HashMap::new(); + let line_axes = if retain_lengths { HashMap::new() } else { constrained_line_axes(set) }; // Tracks which system-level `cadkernel_constraints` constraint(s) came from which // `ParametricConstraint` — a `SubSystem`'s redundant-row indices (from // `cadkernel_constraints::diagnosis`) are local to that partition's own constraint @@ -1804,6 +1836,23 @@ fn solve_scope( let mut owner: Vec<(Rc, ConstraintId)> = Vec::new(); for c in &set.constraints { + if c.enabled && matches!(c.kind, ConstraintKind::Parallel | ConstraintKind::Perpendicular) + && c.refs.len() == 2 && c.refs.iter().all(|r| line_axes.contains_key(r)) + && (line_axes[&c.refs[0]] ^ line_axes[&c.refs[1]]) == (c.kind == ConstraintKind::Perpendicular) + && refs_share_supported_plane(document, &c.refs) + && c.refs.iter().all(|reference| { + match resolve_ref(document, &mut sys, &mut cache, *reference) { + Some(EntityGeom::Line(_)) => true, + Some(geometry @ EntityGeom::Polyline { .. }) => reference.segment_index() + .and_then(|index| geometry.line_segment(index)).is_some(), + _ => false, + } + }) + { + // Implied axis equations below replace normalized angle equations, + // which become ill-conditioned when a dragged edge crosses zero. + continue; + } 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 @@ -1837,6 +1886,126 @@ fn solve_scope( return None; } + let mut anchors = driven_refs.to_vec(); + if !retain_lengths { + let mut axis_lines: Vec<_> = line_axes.into_iter().filter_map(|(reference, vertical)| { + let geometry = cache.get(&reference.entity)?; + let line = match geometry { + EntityGeom::Line(line) => *line, + EntityGeom::Polyline { .. } => geometry.line_segment(reference.segment_index()?)?, + _ => return None, + }; + Some((reference, line, vertical)) + }).collect(); + axis_lines.sort_by_key(|(reference, _, _)| (reference.entity.value(), reference.marker)); + let driven_points: Vec<_> = driven_refs.iter().filter_map(|reference| + resolve_constraint_point(document, &mut sys, &mut cache, *reference)).collect(); + let coincident: Vec<_> = set.constraints.iter().filter(|c| + c.enabled && c.kind == ConstraintKind::Coincident).filter_map(|c| { + let [a, b] = c.refs.as_slice() else { return None }; + Some((resolve_constraint_point(document, &mut sys, &mut cache, *a)?, + resolve_constraint_point(document, &mut sys, &mut cache, *b)?)) + }).collect(); + let point_group = |point| { + let mut points = vec![point]; + let mut cursor = 0; + while cursor < points.len() { + for (a, b) in &coincident { + let next = if points[cursor] == *a { Some(*b) } + else if points[cursor] == *b { Some(*a) } else { None }; + if let Some(next) = next.filter(|next| !points.contains(next)) { + points.push(next); + } + } + cursor += 1; + } + points + }; + let perpendicular: Vec<_> = set.constraints.iter().filter(|c| + c.enabled && c.kind == ConstraintKind::Perpendicular && c.refs.len() == 2).collect(); + let mut retained_axes = HashSet::new(); + for point in &driven_points { + let group = point_group(*point); + let incident: Vec<_> = axis_lines.iter().filter(|(_, line, _)| + group.contains(&line.p1) || group.contains(&line.p2)).collect(); + if incident.is_empty() { + continue; + } + let common_corner = perpendicular.iter().any(|c| c.refs.iter() + .all(|reference| incident.iter().any(|(r, _, _)| r == reference))); + if common_corner && retain_size { + // The shared right-angle corner carries its two legs together. + retained_axes.extend([false, true]); + continue; + } + let free_legs: Vec<_> = incident.iter().copied().filter(|(reference, _, _)| + perpendicular.iter().any(|c| c.refs.contains(reference))).collect(); + let editable = if free_legs.is_empty() { &incident } else { &free_legs }; + retained_axes.extend([false, true].into_iter().filter(|axis| + !editable.iter().any(|(_, _, vertical)| vertical == axis))); + for (reference, line, vertical) in editable.iter().copied() { + let opposite = if group.contains(&line.p1) { line.p2 } else { line.p1 }; + if !driven_points.contains(&opposite) { + let parameter = if *vertical { opposite.y } else { opposite.x }; + // Only the along-edge coordinate stays fixed. The normal + // coordinate follows the grabbed point through the kernel. + if let Some(original) = retained_before.get(&reference.entity) { + let points = super::dimension_assoc::source_points(original); + let start = reference.segment_index().unwrap_or(0); + let index = if opposite == line.p1 { start } else { (start + 1) % points.len().max(1) }; + if let Some(point) = points.get(index) { + sys.store_mut().set(parameter, if *vertical { point.y } else { point.x }); + } + } + sys.store_mut().set_driven(parameter, true); + } + } + } + for (reference, line, vertical) in &axis_lines { + // These implied linear equations remain defined even while an + // edge crosses zero length; cross-product equations do not. + let (a, b) = if *vertical { (line.p1.x, line.p2.x) } else { (line.p1.y, line.p2.y) }; + sys.add_constraint(Rc::new(Equal::new(a, b, 1.0))); + let paired_edge_is_dragged = set.constraints.iter().any(|c| { + c.enabled && c.kind == ConstraintKind::Parallel && c.refs.contains(reference) + && c.refs.iter().any(|other| other != reference && driven_refs.contains(other)) + }); + if paired_edge_is_dragged && !driven_refs.contains(reference) { + // Moving a whole edge changes its separation from the opposite + // parallel edge, whose normal coordinate remains the reference. + if let Some(original) = retained_before.get(&reference.entity) { + let points = super::dimension_assoc::source_points(original); + if let Some(point) = points.get(reference.segment_index().unwrap_or(0)) { + sys.store_mut().set(a, if *vertical { point.x } else { point.y }); + sys.store_mut().set_driven(a, true); + } + } + } + if retain_size && retained_axes.contains(vertical) { + let Some(original) = retained_before.get(&reference.entity) else { continue }; + let points = super::dimension_assoc::source_points(original); + let index = reference.segment_index().unwrap_or(0); + let (Some(a), Some(b)) = (points.get(index), points.get(index + 1).or_else(|| points.first())) + else { continue }; + let (a_param, b_param, delta) = if *vertical { + (line.p1.y, line.p2.y, b.y - a.y) + } else { + (line.p1.x, line.p2.x, b.x - a.x) + }; + let target = sys.add_param(delta, true); + sys.add_constraint(Rc::new(Difference::new(a_param, b_param, target))); + } + } + for reference in driven_refs { + if matches!(document.get_entity(reference.entity), Some(EntityType::Line(_))) + && matches!(reference.marker, Some(0 | 1)) + && !axis_lines.iter().any(|(line_ref, _, _)| line_ref.entity == reference.entity) + { + anchors.push(ParametricRef::point(reference.entity, 1 - reference.marker.unwrap())); + } + } + } + // Grip edits retain radii while connected lines resize to meet their // endpoints. Other edits can retain line lengths as well. if retain_size { @@ -2000,7 +2169,7 @@ fn solve_scope( // geometry parameters as driven keeps them exact and out of the solver's // free-variable list; an approximate equality equation would still allow // visible drift after repeated grip frames. - for reference in driven_refs { + for reference in &anchors { if !set .constraints .iter() @@ -2008,7 +2177,12 @@ fn solve_scope( { continue; } - let params_to_pin = if reference.marker.is_some() && reference.segment_index().is_none() { + let params_to_pin = if let Some(index) = reference.segment_index() { + resolve_ref(document, &mut sys, &mut cache, *reference) + .and_then(|geometry| geometry.line_segment(index)) + .map(|line| vec![line.p1.x, line.p1.y, line.p2.x, line.p2.y]) + .unwrap_or_default() + } else if reference.marker.is_some() { resolve_constraint_point(document, &mut sys, &mut cache, *reference) .map(|point| vec![point.x, point.y]) .unwrap_or_default() @@ -2725,6 +2899,46 @@ mod tests { use acadrust::entities::EntityType; use acadrust::types::Vector3; + #[test] + fn perpendicular_line_grips_distinguish_shared_corner_and_free_ends() { + use acadrust::entities::Line; + for (vertical, marker) in [(false, 0), (false, 1), (true, 1)] { + let mut scene = Scene::new(); + let a = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, Vector3::new(4.0, 0.0, 0.0)))); + let b = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, Vector3::new(0.0, 2.0, 0.0)))); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add(ConstraintKind::Horizontal, vec![ParametricRef::whole(a)], None); + set.add(ConstraintKind::Perpendicular, vec![ParametricRef::whole(a), ParametricRef::whole(b)], None); + set.add(ConstraintKind::Coincident, vec![ParametricRef::point(a, 0), ParametricRef::point(b, 0)], None); + let originals = [a, b].map(|handle| (handle, scene.document.get_entity(handle).unwrap().clone())); + let handle = if vertical { b } else { a }; + let target = Vector3::new(6.0, 3.0, 0.0); + let Some(EntityType::Line(line)) = scene.document.get_entity_mut(handle) else { panic!("line") }; + if marker == 0 { line.start = target; } else { line.end = target; } + let solved = scene.solve_parametric_constraints_preview( + &[handle], &[ParametricRef::point(handle, marker)], true, &originals); + for (handle, entity) in solved { + *scene.document.get_entity_mut(handle).unwrap() = entity; + } + let expected = if marker == 0 { + [[target, Vector3::new(10.0, 3.0, 0.0)], [target, Vector3::new(6.0, 5.0, 0.0)]] + } else if vertical { + [[Vector3::new(6.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)], + [Vector3::new(6.0, 0.0, 0.0), target]] + } else { + [[Vector3::new(0.0, 3.0, 0.0), target], + [Vector3::new(0.0, 3.0, 0.0), Vector3::new(0.0, 5.0, 0.0)]] + }; + for (handle, points) in [a, b].into_iter().zip(expected) { + let Some(EntityType::Line(line)) = scene.document.get_entity(handle) else { panic!("line") }; + assert!((line.start - points[0]).length() < 1e-7, "{vertical}/{marker}: {line:?}"); + assert!((line.end - points[1]).length() < 1e-7, "{vertical}/{marker}: {line:?}"); + } + } + } + #[test] fn validation_rejects_unsupported_entity_shapes_and_planes() { let mut scene = Scene::new();