From f998b59193f690c465df93075eb9bc3aa1ab69b8 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:38:33 +0300 Subject: [PATCH 1/3] Match Fix geometric constraint behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) FXCONSTRAINT (and the new GCFIX name) prompts "Select point or [Object] :" — a pick fixes that constraint point; Enter or O asks for one line, polyline segment, circle, arc, ellipse or spline; misses and unsupported objects report the reference messages and re-prompt. 2) Point, block, text, MText, attribute and table insertion points are addressable constraint points. 3) Re-applying Fixed to the same point or object reports that the constraint already exists instead of adding a duplicate. 4) A persistent Fixed holds its pre-edit geometry through MOVE, ROTATE, SCALE, STRETCH, grip and Properties edits: fixed points stay, free points of a partly fixed entity follow the edit, and size retention skips fixed entities. 5) Object mode on a polyline fixes the picked segment (natively persisted) instead of the whole polyline; arc segments pin endpoints, center and radius. 6) Ribbon label "Fix" with a padlock icon. Co-Authored-By: Claude Fable 5.1 --- assets/icons/constrain/fixed.svg | 8 +- src/app/command_driver.rs | 85 ++++++- src/app/commands/draw.rs | 45 +++- src/command.rs | 6 + src/modules/parametric/fixed.rs | 139 +++++++++++ src/modules/parametric/mod.rs | 3 + src/modules/parametric/tools.rs | 3 +- src/scene/parametric_constraints.rs | 28 +++ src/scene/parametric_solve.rs | 372 ++++++++++++++-------------- 9 files changed, 483 insertions(+), 206 deletions(-) create mode 100644 src/modules/parametric/fixed.rs diff --git a/assets/icons/constrain/fixed.svg b/assets/icons/constrain/fixed.svg index b0e75becb..dd37ea457 100644 --- a/assets/icons/constrain/fixed.svg +++ b/assets/icons/constrain/fixed.svg @@ -1,7 +1,5 @@ - - - - - + + + diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 6de0b3768..7bb0f090e 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3177,23 +3177,23 @@ impl OpenCADStudio { driving_param, label, } => { + use crate::scene::parametric_constraints::ConstraintKind; + // GCSMOOTH and FXCONSTRAINT re-prompt after a rejected pick. + let keep_command = self.tabs[i].active_cmd.as_ref().is_some_and(|command| { + (kind == ConstraintKind::Smooth && command.name() == "GCSMOOTH") + || (kind == ConstraintKind::Fixed && command.name() == "FXCONSTRAINT") + }); if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( kind, &refs, driving_param.as_ref(), ) { - let keep_smooth_selection = kind - == crate::scene::parametric_constraints::ConstraintKind::Smooth - && self.tabs[i] - .active_cmd - .as_ref() - .is_some_and(|command| command.name() == "GCSMOOTH"); - if !keep_smooth_selection { + if !keep_command { self.tabs[i].active_cmd = None; } self.tabs[i].snap_result = None; self.command_line.push_error(message); - if keep_smooth_selection { + if keep_command { if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) { @@ -3203,6 +3203,22 @@ impl OpenCADStudio { return Task::none(); } let scope = self.tabs[i].current_parametric_scope(); + if kind == ConstraintKind::Fixed + && self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|existing| { + existing.enabled && existing.kind == kind && existing.refs == refs + }) + }) + { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_error("The constraint already exists on the selected objects."); + return Task::none(); + } let constraints_before = self.tabs[i] .scene .parametric_constraint_set(scope) @@ -3244,6 +3260,59 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::AddFixedConstraint(pick) => { + use crate::modules::parametric::FixConstraintCommand; + use crate::scene::parametric_constraints::{ + nearest_parametric_point, nearest_parametric_point_on_entity, + parametric_curve_ref_for_pick, ConstraintKind, + }; + + let scope = self.tabs[i].current_parametric_scope(); + let document = &self.tabs[i].scene.document; + let world = + acadrust::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z); + let resolved = match (pick.whole_curve, pick.handle) { + (true, Some(handle)) => { + parametric_curve_ref_for_pick(document, scope, handle, world) + .ok_or(FixConstraintCommand::INVALID_OBJECT) + } + (true, None) => Err(FixConstraintCommand::NO_OBJECT), + (false, Some(handle)) => { + nearest_parametric_point_on_entity(document, scope, handle, world) + .ok_or(FixConstraintCommand::INVALID_OBJECT) + } + (false, None) => nearest_parametric_point(document, scope, world, None) + .ok_or(FixConstraintCommand::NO_POINT), + }; + // An off-plane or 3D curve is "not a valid object" to the + // reference, not a solver limitation. + let resolved = resolved.and_then(|reference| { + let supported = !pick.whole_curve + || self.tabs[i] + .scene + .validate_parametric_constraint( + ConstraintKind::Fixed, + &[reference], + None, + ) + .is_ok(); + supported + .then_some(reference) + .ok_or(FixConstraintCommand::INVALID_OBJECT) + }); + return match resolved { + Ok(reference) => self.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Fixed, + refs: vec![reference], + driving_param: None, + label: "Fixed constraint", + }), + // A miss re-prompts: `ReportError` keeps the command. + Err(message) => { + self.apply_cmd_result(CmdResult::ReportError(message.to_string())) + } + }; + } CmdResult::AddHorizontalConstraint { selection, direction, diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 521dd44d4..abc860fbb 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1420,7 +1420,7 @@ impl OpenCADStudio { } } - "VCONSTRAINT" | "FXCONSTRAINT" => { + "VCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1433,20 +1433,49 @@ impl OpenCADStudio { } else { use crate::command::CmdResult; use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; - let (kind, label) = if cmd == "VCONSTRAINT" { - (ConstraintKind::Vertical, "Vertical constraint") - } else { - (ConstraintKind::Fixed, "Fixed constraint") - }; return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { - kind, + kind: ConstraintKind::Vertical, refs: vec![ParametricRef::whole(handles[0])], driving_param: None, - label, + label: "Vertical constraint", })); } } + "FXCONSTRAINT" | "GCFIX" => { + use crate::command::CmdResult; + use crate::modules::parametric::FixConstraintCommand; + use crate::scene::parametric_constraints::ConstraintKind; + + // One preselected whole curve applies at once (ribbon + // "select first, then click"); anything else goes through + // the reference's own point-or-object prompt. + let handles = self.tabs[i].scene.selected_handles_in_order(); + if let [handle] = handles.as_slice() { + let reference = self.tabs[i] + .scene + .document + .get_entity(*handle) + .and_then(|entity| { + FixConstraintCommand::preselected_reference(entity, *handle) + }); + if let Some(reference) = reference { + return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Fixed, + refs: vec![reference], + driving_param: None, + label: "Fixed constraint", + })); + } + } + if !handles.is_empty() { + self.tabs[i].scene.deselect_all(); + } + let command = FixConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "CCONSTRAINT" | "GCCOINCIDENT" => { use crate::modules::parametric::CoincidentConstraintCommand; let new_cmd = CoincidentConstraintCommand::new(); diff --git a/src/command.rs b/src/command.rs index 356048dd9..79eb0f5d8 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1570,6 +1570,11 @@ pub enum CmdResult { kind: crate::scene::parametric_constraints::ConstraintKind, label: &'static str, }, + /// Add a persistent `Fixed` constraint (`crate::modules::parametric::fixed`) + /// at one picked constraint point (`whole_curve == false`, resolved by the + /// host like `AddPointOnEntityConstraint`'s point) or on the whole curve / + /// polyline segment under the pick (`whole_curve == true`). + AddFixedConstraint(CoincidentPick), /// Add a persistent `EqualDistance` constraint /// (`crate::modules::parametric::equal_distance`): the distance /// between `points[0]`/`points[1]` equals the distance between @@ -2806,6 +2811,7 @@ mod constraint_registry_tests { "NRCONSTRAINT", "LCONSTRAINT", "FXCONSTRAINT", + "GCFIX", "SYCONSTRAINT", "DCONSTRAINT", "ACONSTRAINT", diff --git a/src/modules/parametric/fixed.rs b/src/modules/parametric/fixed.rs new file mode 100644 index 000000000..c4c641500 --- /dev/null +++ b/src/modules/parametric/fixed.rs @@ -0,0 +1,139 @@ +//! GCFIX / FXCONSTRAINT — locks a constraint point, or a whole curve / +//! polyline segment, at its current position. Picks resolve on the host (a +//! `CadCommand` has no document access): `AddFixedConstraint` carries the +//! click, the driver maps it to a `ParametricRef` and re-prompts on a miss, +//! the way the reference does. + +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, CoincidentPick}; +use crate::scene::parametric_constraints::ParametricRef; + +#[derive(Clone, Copy)] +enum Step { + PointOrObject, + Object, +} + +pub struct FixConstraintCommand { + step: Step, +} + +impl FixConstraintCommand { + pub const INVALID_OBJECT: &'static str = + "Invalid selection for Fixed. Select a line, polyline segment, circle, arc, ellipse or spline."; + pub const NO_POINT: &'static str = "No valid constraint point found."; + pub const NO_OBJECT: &'static str = "No object found."; + + pub fn new() -> Self { + Self { + step: Step::PointOrObject, + } + } + + /// The Object-mode reading of a single preselected entity: a whole + /// curve, or a one-segment polyline's segment. Anything else prompts. + pub fn preselected_reference(entity: &EntityType, handle: Handle) -> Option { + match entity { + EntityType::Line(_) + | EntityType::Circle(_) + | EntityType::Arc(_) + | EntityType::Ellipse(_) + | EntityType::Spline(_) => Some(ParametricRef::whole(handle)), + EntityType::LwPolyline(polyline) => (polyline.vertices.len() == 2 + && !polyline.is_closed) + .then(|| ParametricRef::segment(handle, 0)), + EntityType::Polyline2D(polyline) => (polyline.vertices.len() == 2 + && !polyline.is_closed()) + .then(|| ParametricRef::segment(handle, 0)), + _ => None, + } + } + + fn pick(&self, handle: Option, point: DVec3) -> CmdResult { + CmdResult::AddFixedConstraint(CoincidentPick { + handle, + point, + whole_curve: matches!(self.step, Step::Object), + }) + } +} + +impl CadCommand for FixConstraintCommand { + fn name(&self) -> &'static str { + "FXCONSTRAINT" + } + + fn prompt(&self) -> String { + match self.step { + Step::PointOrObject => "FXCONSTRAINT Select point or [Object] :".to_string(), + Step::Object => "FXCONSTRAINT Select object:".to_string(), + } + } + + fn options(&self) -> Vec { + matches!(self.step, Step::PointOrObject) + .then(|| vec![CmdOption::new("Object", "O")]) + .unwrap_or_default() + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::PointOrObject) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + let to_object = + matches!(self.step, Step::PointOrObject) && matches!(keyword.as_str(), "O" | "OBJECT"); + to_object.then(|| { + self.step = Step::Object; + CmdResult::NeedPoint + }) + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn entity_pick_accepts_points(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if handle.is_null() { + return self.on_point(point); + } + self.pick(Some(handle), point) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::PointOrObject => self.pick(None, point), + // A click on nothing at "Select object:". + Step::Object => CmdResult::ReportError(Self::NO_OBJECT.to_string()), + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::PointOrObject => { + self.step = Step::Object; + CmdResult::NeedPoint + } + Step::Object => CmdResult::Cancel, + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index f40701cef..ec1fc6626 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -6,6 +6,8 @@ mod coincident; mod concentric; mod constraint_bar; mod equal_distance; +#[path = "fixed.rs"] +mod fixed_command; mod geom_constraint; #[path = "horizontal.rs"] mod horizontal_command; @@ -21,6 +23,7 @@ pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use concentric::ConcentricConstraintCommand; pub use constraint_bar::ConstraintBarOptionCommand; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; +pub use fixed_command::FixConstraintCommand; pub use geom_constraint::GeomConstraintCommand; pub use horizontal_command::HorizontalConstraintCommand; pub use perpendicular_command::{PerpendicularConstraintCommand, PerpendicularPick}; diff --git a/src/modules/parametric/tools.rs b/src/modules/parametric/tools.rs index 4d2e8fd94..22e1173b5 100644 --- a/src/modules/parametric/tools.rs +++ b/src/modules/parametric/tools.rs @@ -136,7 +136,7 @@ pub mod fixed { pub fn tool() -> ToolDef { ToolDef { id: "FXCONSTRAINT", - label: "Fixed", + label: "Fix", icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/fixed.svg")), event: ModuleEvent::Command("FXCONSTRAINT".to_string()), } @@ -171,6 +171,7 @@ inventory::submit!(crate::command::CommandRegistration { "NRCONSTRAINT", "LCONSTRAINT", "FXCONSTRAINT", + "GCFIX", "SYCONSTRAINT", ] }); diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 98d983f5c..01e8029ce 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -587,7 +587,29 @@ impl ParametricConstraintSet { /// /// Solver-side registration reads raw entity fields directly. This helper is /// for UI-side consumers that need the current world-space position. +/// The single addressable point (marker `0`) of a point-like entity: the +/// node, insertion, or table origin the solver registers as its +/// `EntityGeom::Point` / text baseline start. `None` for curve entities, +/// whose marker `0` is a `source_points()` endpoint. +pub(crate) fn insertion_point(entity: &acadrust::EntityType) -> Option { + match entity { + acadrust::EntityType::Point(point) => Some(point.location), + acadrust::EntityType::Insert(insert) => Some(insert.insert_point), + acadrust::EntityType::Text(text) => Some(text.insertion_point), + acadrust::EntityType::MText(text) => Some(text.insertion_point), + acadrust::EntityType::AttributeDefinition(attribute) => Some(attribute.insertion_point), + acadrust::EntityType::AttributeEntity(attribute) => Some(attribute.insertion_point), + acadrust::EntityType::Table(table) => Some(table.insertion_point), + _ => None, + } +} + pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Option { + if marker == 0 { + if let Some(point) = insertion_point(entity) { + return Some(point); + } + } if marker == -3 { return match entity { acadrust::EntityType::Circle(circle) => Some(circle.center_wcs()), @@ -645,6 +667,12 @@ pub(crate) fn parametric_point_candidates( .enumerate() .map(|(marker, point)| (marker as i32, point)) .collect(); + // A node / insertion point is a constraint point too (the reference + // fixes a POINT via NODe and TEXT via INSert), and these entities have + // no `source_points()` to collide with marker `0`. + if let Some(point) = insertion_point(entity) { + points.push((0, point)); + } match entity { acadrust::EntityType::Circle(circle) => points.push((-3, circle.center_wcs())), acadrust::EntityType::Arc(arc) => points.push((-3, arc.center_wcs())), diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index fab85de33..688c18592 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -28,6 +28,7 @@ use cadkernel_constraints::solvers::dogleg::solve_dl; use cadkernel_constraints::solvers::lm::solve_lm; use cadkernel_constraints::solvers::SolveStatus; use cadkernel_constraints::system::System; +use cadkernel_constraints::util::ParamId; use super::named_parameters::ParameterTable; use super::parametric_constraints::{ @@ -184,6 +185,16 @@ fn register_entity( handle: Handle, ) -> Option { let entity = document.get_entity(handle)?; + register_entity_geom(sys, handle, entity) +} + +/// `register_entity` for an entity value that isn't the document's — the +/// retained pre-edit original a persistent `Fixed` pins to. +fn register_entity_geom( + sys: &mut System, + handle: Handle, + entity: &EntityType, +) -> Option { match entity { EntityType::Point(point) => Some(EntityGeom::Point(GPoint::new( sys.add_param(point.location.x, false), @@ -1027,6 +1038,74 @@ fn retained_tangent_side( Some(dx * (center[1] - y1) - dy * (center[0] - x1) >= 0.0) } +/// The kernel parameters a `Fixed` constraint on `r` holds in place: a +/// polyline segment's endpoints (plus center/radius for an arc segment), one +/// addressable point, or every intrinsic parameter of a whole entity. Arc +/// pins its five intrinsic params only — `start`/`end` are already tied to +/// those by the arc-rules `CurveValue` constraints `solve_scope` adds; +/// Ellipse likewise pins center + radmin, `focus1` being tied to `center` +/// by the ellipse-rules `Difference` constraints. +fn fixed_pin_params( + document: &acadrust::CadDocument, + sys: &mut System, + cache: &mut HashMap, + r: ParametricRef, +) -> Vec { + if let Some(index) = r.segment_index() { + let Some(geometry) = resolve_ref(document, sys, cache, r) else { + return Vec::new(); + }; + if let Some(line) = geometry.line_segment(index) { + return vec![line.p1.x, line.p1.y, line.p2.x, line.p2.y]; + } + return geometry + .arc_segment(index) + .map(|segment| { + let arc = segment.arc; + vec![ + arc.start.x, + arc.start.y, + arc.end.x, + arc.end.y, + arc.circle.center.x, + arc.circle.center.y, + arc.circle.rad, + ] + }) + .unwrap_or_default(); + } + if r.marker.is_some() { + return resolve_constraint_point(document, sys, cache, r) + .map(|point| vec![point.x, point.y]) + .unwrap_or_default(); + } + match resolve_ref(document, sys, cache, r) { + Some(EntityGeom::Point(point)) => vec![point.x, point.y], + Some(EntityGeom::Polyline { points, .. }) => { + points.iter().flat_map(|point| [point.x, point.y]).collect() + } + Some(EntityGeom::TextLine(line)) + | Some(EntityGeom::Line(line)) + | Some(EntityGeom::Ray(line)) + | Some(EntityGeom::XLine(line)) => vec![line.p1.x, line.p1.y, line.p2.x, line.p2.y], + Some(EntityGeom::Circle(circle)) => vec![circle.center.x, circle.center.y, circle.rad], + Some(EntityGeom::Arc(arc)) => vec![ + arc.circle.center.x, + arc.circle.center.y, + arc.circle.rad, + arc.start_angle, + arc.end_angle, + ], + Some(EntityGeom::Ellipse(ellipse)) => { + vec![ellipse.center.x, ellipse.center.y, ellipse.radmin] + } + Some(EntityGeom::Spline { curve, .. }) => { + curve.poles.iter().flat_map(|point| [point.x, point.y]).collect() + } + None => Vec::new(), + } +} + /// Builds the kernel equations for one constraint. Unsupported references or /// invalid dimensional targets produce no equations. fn build_constraint( @@ -1037,6 +1116,7 @@ fn build_constraint( c: &ParametricConstraint, tangent_side: Option, tangent_point: Option, + originals: &HashMap>, ) -> Vec> { if !c.enabled || !refs_share_supported_plane(document, &c.refs) { return Vec::new(); @@ -1380,175 +1460,36 @@ fn build_constraint( let Some(r) = c.refs.first() else { return Vec::new(); }; - if r.segment_index().is_some() { - let Some(line) = whole_line(sys, cache, *r) else { - return Vec::new(); - }; - let (x1, y1, x2, y2) = { - let store = sys.store(); - ( - store.get(line.p1.x), - store.get(line.p1.y), - store.get(line.p2.x), - store.get(line.p2.y), - ) - }; - return vec![ - Rc::new(Equal::new(line.p1.x, sys.add_param(x1, true), 1.0)), - Rc::new(Equal::new(line.p1.y, sys.add_param(y1, true), 1.0)), - Rc::new(Equal::new(line.p2.x, sys.add_param(x2, true), 1.0)), - Rc::new(Equal::new(line.p2.y, sys.add_param(y2, true), 1.0)), - ]; - } - if r.marker.is_some() { - let Some(point) = point_ref(sys, cache, *r) else { - return Vec::new(); - }; - let (x, y) = { - let store = sys.store(); - (store.get(point.x), store.get(point.y)) - }; - return vec![ - Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)), - Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)), - ]; - } - let Some(geom) = resolve_ref(document, sys, cache, *r) else { - return Vec::new(); - }; - match geom { - EntityGeom::Point(point) => { - let (x, y) = { + let pinned = fixed_pin_params(document, sys, cache, *r); + // A persistent Fixed holds the *pre-edit* geometry: after MOVE / + // STRETCH / a grip drag the entity in `document` is already + // displaced, so the target comes from the caller's retained + // original when there is one — the entity's own live values + // otherwise (a freshly applied Fixed pins where it stands). + let mut targets: Option> = None; + if let Some(original) = originals.get(&r.entity) { + let mut original_cache = HashMap::new(); + if let Some(geometry) = register_entity_geom(sys, r.entity, original.as_ref()) { + original_cache.insert(r.entity, geometry); + let params = fixed_pin_params(document, sys, &mut original_cache, *r); + if params.len() == pinned.len() { let store = sys.store(); - (store.get(point.x), store.get(point.y)) - }; - vec![ - Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)), - Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)), - ] - } - EntityGeom::Polyline { points, .. } => { - let values: Vec<_> = points - .iter() - .map(|point| { - let store = sys.store(); - (point, store.get(point.x), store.get(point.y)) - }) - .collect(); - values - .into_iter() - .flat_map(|(point, x, y)| { - [ - Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)) - as Rc, - Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)) - as Rc, - ] - }) - .collect() - } - EntityGeom::TextLine(l) - | EntityGeom::Line(l) - | EntityGeom::Ray(l) - | EntityGeom::XLine(l) => { - let (x1, y1, x2, y2) = { - let store = sys.store(); - ( - store.get(l.p1.x), - store.get(l.p1.y), - store.get(l.p2.x), - store.get(l.p2.y), - ) - }; - vec![ - Rc::new(Equal::new(l.p1.x, sys.add_param(x1, true), 1.0)), - Rc::new(Equal::new(l.p1.y, sys.add_param(y1, true), 1.0)), - Rc::new(Equal::new(l.p2.x, sys.add_param(x2, true), 1.0)), - Rc::new(Equal::new(l.p2.y, sys.add_param(y2, true), 1.0)), - ] - } - EntityGeom::Circle(circ) => { - let (cx, cy, r) = { - let store = sys.store(); - ( - store.get(circ.center.x), - store.get(circ.center.y), - store.get(circ.rad), - ) - }; - vec![ - Rc::new(Equal::new(circ.center.x, sys.add_param(cx, true), 1.0)), - Rc::new(Equal::new(circ.center.y, sys.add_param(cy, true), 1.0)), - Rc::new(Equal::new(circ.rad, sys.add_param(r, true), 1.0)), - ] - } - // Pinning the 5 intrinsic params (center, radius, both - // angles) is enough — `start`/`end` are already tied to - // those via the arc-rules `CurveValue` constraints - // `solve_scope` adds for every arc, so pinning them too - // would just be redundant. - EntityGeom::Arc(a) => { - let (cx, cy, r, sa, ea) = { - let store = sys.store(); - ( - store.get(a.circle.center.x), - store.get(a.circle.center.y), - store.get(a.circle.rad), - store.get(a.start_angle), - store.get(a.end_angle), - ) - }; - vec![ - Rc::new(Equal::new(a.circle.center.x, sys.add_param(cx, true), 1.0)), - Rc::new(Equal::new(a.circle.center.y, sys.add_param(cy, true), 1.0)), - Rc::new(Equal::new(a.circle.rad, sys.add_param(r, true), 1.0)), - Rc::new(Equal::new(a.start_angle, sys.add_param(sa, true), 1.0)), - Rc::new(Equal::new(a.end_angle, sys.add_param(ea, true), 1.0)), - ] - } - // Pinning center + radmin is enough — `focus1` is already - // tied to `center` by the ellipse-rules `Difference` - // constraints `solve_scope` adds for every registered - // ellipse (see this module's doc comment), so with `center` - // pinned here too, pinning `focus1` again would just be - // redundant (same reasoning as Arc's `Fixed` arm above). - EntityGeom::Ellipse(el) => { - let (cx, cy, b) = { - let store = sys.store(); - ( - store.get(el.center.x), - store.get(el.center.y), - store.get(el.radmin), - ) - }; - vec![ - Rc::new(Equal::new(el.center.x, sys.add_param(cx, true), 1.0)), - Rc::new(Equal::new(el.center.y, sys.add_param(cy, true), 1.0)), - Rc::new(Equal::new(el.radmin, sys.add_param(b, true), 1.0)), - ] - } - EntityGeom::Spline { curve, .. } => { - let values: Vec<_> = curve - .poles - .iter() - .map(|point| { - let store = sys.store(); - (point, store.get(point.x), store.get(point.y)) - }) - .collect(); - values - .into_iter() - .flat_map(|(point, x, y)| { - [ - Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)) - as Rc, - Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)) - as Rc, - ] - }) - .collect() + targets = Some(params.iter().map(|param| store.get(*param)).collect()); + } } } + let targets = targets.unwrap_or_else(|| { + let store = sys.store(); + pinned.iter().map(|param| store.get(*param)).collect() + }); + pinned + .into_iter() + .zip(targets) + .map(|(param, value)| { + Rc::new(Equal::new(param, sys.add_param(value, true), 1.0)) + as Rc + }) + .collect() } ConstraintKind::Distance => { let [a, b] = c.refs.as_slice() else { @@ -2252,6 +2193,7 @@ fn solve_scope( retain_lengths: bool, retained_before: &HashMap>, initial_fixed_refs: &[ParametricRef], + originals: &HashMap>, ) -> Option { let params = if set.local_parameters.is_empty() { drawing_params @@ -2261,6 +2203,53 @@ fn solve_scope( let mut sys = System::new(); let mut cache: HashMap = HashMap::new(); let constraints = constraints_in_drawing_order(document, set); + // A persistent Fixed outranks any edit: a point it holds is never a + // driven grip / STRETCH anchor, and a moved entity it holds gets no + // temporary whole-entity pin — the Fixed one, targeting the retained + // original, pulls it back instead (a MOVE leaves a fixed object, or the + // fixed points of a partly fixed one, where they were). + let fixed_refs: Vec = constraints + .iter() + .filter(|c| c.enabled && c.kind == ConstraintKind::Fixed) + .filter_map(|c| c.refs.first().copied()) + .collect(); + let held_by_fixed = |r: &ParametricRef| { + fixed_refs.iter().any(|f| { + f.entity == r.entity + && (f.marker.is_none() + || f.marker == r.marker + || f.segment_index().is_some_and(|index| { + matches!(r.marker, Some(marker) + if marker == index as i32 || marker == index as i32 + 1) + })) + }) + }; + let mut driven_refs: Vec = + driven_refs.iter().copied().filter(|r| !held_by_fixed(r)).collect(); + // A moved entity a Fixed holds only in part keeps its other primary + // points where the edit put them (driven), so the fixed ones pull just + // their own coordinates back — a MOVE on a line with one fixed end + // stretches the line instead of sliding it. + for reference in initial_fixed_refs { + if reference.marker.is_some() || !fixed_refs.iter().any(|f| f.entity == reference.entity) + { + continue; + } + let Some(entity) = document.get_entity(reference.entity) else { + continue; + }; + for (marker, _) in super::parametric_constraints::parametric_point_candidates(entity) { + let point = ParametricRef::point(reference.entity, marker); + if (marker >= 0 || marker == -3) + && !held_by_fixed(&point) + && !driven_refs.contains(&point) + { + driven_refs.push(point); + } + } + } + let driven_refs = driven_refs.as_slice(); + let fixed_touches = |handle: Handle| fixed_refs.iter().any(|f| f.entity == handle); let line_axes = if retain_lengths { HashMap::new() } else { constrained_line_axes(&constraints) }; // Map partition-local kernel rows back to persistent constraint IDs by Rc identity. let mut owner: Vec<(Rc, ConstraintId)> = Vec::new(); @@ -2302,6 +2291,7 @@ fn solve_scope( c, tangent_side, tangent_point, + originals, ) { owner.push((constraint.clone(), c.id)); sys.add_constraint(constraint); @@ -2313,6 +2303,9 @@ fn solve_scope( // solve only; they are deliberately omitted from `owner` and from the // persistent constraint set/native graph. for reference in initial_fixed_refs { + if fixed_touches(reference.entity) { + continue; + } let temporary = ParametricConstraint { id: ConstraintId::MAX, kind: ConstraintKind::Fixed, @@ -2334,6 +2327,7 @@ fn solve_scope( &temporary, None, None, + &HashMap::new(), ) { sys.add_constraint(constraint); } @@ -2475,9 +2469,13 @@ fn solve_scope( // endpoints. Other edits can retain line lengths as well. if retain_size { for (handle, geom) in &cache { - if initial_fixed_refs - .iter() - .any(|reference| reference.entity == *handle && reference.marker.is_none()) + // A Fixed entity's size is whatever its pins say: whole → all + // pinned anyway, partial → the reference lets it resize (MOVE + // on a one-end-fixed line shortens it). + if fixed_touches(*handle) + || initial_fixed_refs + .iter() + .any(|reference| reference.entity == *handle && reference.marker.is_none()) { continue; } @@ -3346,6 +3344,7 @@ impl Scene { &candidate, None, None, + &HashMap::new(), ) .is_empty() { @@ -3443,22 +3442,25 @@ impl Scene { } } + // Pre-edit images of everything this transaction touched: what a + // persistent Fixed pins to, and (under CONSTRAINTSOLVEMODE) what + // sizes are retained from. + let mut originals: HashMap> = self + .undo_recording + .as_ref() + .into_iter() + .flat_map(|recording| recording.before.iter()) + .filter_map(|(handle, entity)| { + entity + .as_ref() + .map(|entity| (*handle, std::sync::Arc::clone(entity))) + }) + .collect(); + for (handle, entity) in retained_originals { + originals.insert(*handle, std::sync::Arc::new(entity.clone())); + } let retained_before: HashMap> = if retain_size { - let mut retained: HashMap> = self - .undo_recording - .as_ref() - .into_iter() - .flat_map(|recording| recording.before.iter()) - .filter_map(|(handle, entity)| { - entity - .as_ref() - .map(|entity| (*handle, std::sync::Arc::clone(entity))) - }) - .collect(); - for (handle, entity) in retained_originals { - retained.insert(*handle, std::sync::Arc::new(entity.clone())); - } - retained + originals.clone() } else { HashMap::new() }; @@ -3482,6 +3484,7 @@ impl Scene { true, &retained_before, initial_fixed_refs, + &originals, ) else { continue; }; @@ -3543,6 +3546,7 @@ impl Scene { false, &retained_before, &[], + &retained_before, ) else { continue; From 45f3088a8945d9170b6dfefef35eeb7cad4700ea Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:55:11 +0300 Subject: [PATCH 2/3] Draw the Fix constraint bar glyph as a padlock 7) The constraint bar draws Fix as a padlock instead of the letter F: a white lock with a small orange point marker when one point is held, a red lock when a whole curve or polyline segment is held. Co-Authored-By: Claude Fable 5.1 --- src/scene/parametric_constraints.rs | 21 ++++++++- src/ui/overlay.rs | 73 ++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 01e8029ce..9dace36ff 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -891,6 +891,23 @@ impl ConstraintKind { } } +/// Fixed draws a padlock (`src/ui/overlay.rs`), the way the reference bar +/// does: the plain symbol for a held curve or segment, this variant when one +/// addressable point is held (white lock with the point marker). +pub(crate) const FIXED_POINT_GLYPH: &str = "F·"; + +pub(crate) fn fixed_glyph_label(constraint: &ParametricConstraint) -> &'static str { + let point = constraint + .refs + .first() + .is_some_and(|reference| reference.marker.is_some() && reference.segment_index().is_none()); + if point { + FIXED_POINT_GLYPH + } else { + ConstraintKind::Fixed.glyph_symbol() + } +} + /// The full glyph text for one constraint: its symbol, plus the driving /// value for a dimensional kind (Distance/Angle/Radius). pub(crate) fn glyph_label(constraint: &ParametricConstraint) -> String { @@ -1687,7 +1704,9 @@ impl super::Scene { }) .flat_map(|c| { let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); - let label = if show_values { + let label = if c.kind == ConstraintKind::Fixed { + fixed_glyph_label(c).to_string() + } else if show_values { glyph_label(c) } else { c.kind.glyph_symbol().to_string() diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 1aecaba9e..3a2712ce5 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -34,11 +34,16 @@ fn is_compact_coincident_glyph(label: &str) -> bool { matches!(label, "≡" | "∈") } +/// Fixed's two padlock labels (`parametric_constraints::fixed_glyph_label`). +fn is_fixed_glyph(label: &str) -> bool { + label == "F" || label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH +} + fn constraint_glyph_size(label: &str) -> Size { if is_compact_coincident_glyph(label) { return Size::new(COINCIDENT_GLYPH_SIZE, COINCIDENT_GLYPH_SIZE); } - if label == "G²" { + if label == "G²" || is_fixed_glyph(label) { let side = CONSTRAINT_GLYPH_SIZE + CONSTRAINT_GLYPH_PAD_Y * 2.0; return Size::new(side, side); } @@ -94,6 +99,64 @@ fn draw_concentric_constraint_glyph( frame.stroke(&canvas::Path::circle(center, 2.15), stroke); } +/// The reference bar's padlock: white with a small orange point marker +/// when one point is held, red when a whole curve or segment is. +fn draw_fixed_constraint_glyph( + frame: &mut canvas::Frame, + center: Point, + text: Color, + badge: Color, + point_marker: bool, +) { + let lock = if point_marker { + text + } else { + Color::from_rgb8(214, 76, 76) + }; + let shackle = canvas::Path::new(|builder| { + let shackle_center = Point::new(center.x, center.y - 1.6); + for step in 0..=12 { + let angle = std::f32::consts::PI * (1.0 - step as f32 / 12.0); + let point = Point::new( + shackle_center.x + angle.cos() * 3.1, + shackle_center.y - angle.sin() * 3.1, + ); + if step == 0 { + builder.move_to(Point::new(point.x, center.y - 0.6)); + builder.line_to(point); + } else { + builder.line_to(point); + } + } + builder.line_to(Point::new(center.x + 3.1, center.y - 0.6)); + }); + frame.stroke( + &shackle, + canvas::Stroke::default().with_color(lock).with_width(1.5), + ); + let body = canvas::Path::rounded_rectangle( + Point::new(center.x - 5.0, center.y - 0.8), + Size::new(10.0, 7.6), + 1.4.into(), + ); + frame.fill(&body, lock); + frame.stroke( + &canvas::Path::line( + Point::new(center.x, center.y + 1.4), + Point::new(center.x, center.y + 4.6), + ), + canvas::Stroke::default().with_color(badge).with_width(1.3), + ); + if point_marker { + frame.stroke( + &canvas::Path::rectangle(Point::new(center.x + 4.0, center.y + 3.6), Size::new(4.6, 4.6)), + canvas::Stroke::default() + .with_color(Color::from_rgb8(224, 130, 62)) + .with_width(1.2), + ); + } +} + fn constraint_glyph_box( anchor: Point, outward: [f32; 2], @@ -2020,6 +2083,14 @@ impl canvas::Program for SelectionCanvas { draw_smooth_constraint_glyph(&mut frame, glyph_center, fg); } else if label == "◎" { draw_concentric_constraint_glyph(&mut frame, glyph_center, fg); + } else if is_fixed_glyph(label) { + draw_fixed_constraint_glyph( + &mut frame, + glyph_center, + fg, + bg, + label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH, + ); } else { frame.fill_text(canvas::Text { content: label.clone(), From 7901383553fff6bd586493ad7a83550ef72c6074 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:04:50 +0300 Subject: [PATCH 3/3] Keep the original Fix ribbon icon Restores assets/icons/constrain/fixed.svg from main; only the ribbon label changes to "Fix". Co-Authored-By: Claude Fable 5.1 --- assets/icons/constrain/fixed.svg | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/assets/icons/constrain/fixed.svg b/assets/icons/constrain/fixed.svg index dd37ea457..b0e75becb 100644 --- a/assets/icons/constrain/fixed.svg +++ b/assets/icons/constrain/fixed.svg @@ -1,5 +1,7 @@ - - - + + + + +