From ad6f902ed794b22a28a74962a84bdb91442c1f2f Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:18:40 +0300 Subject: [PATCH 01/21] Equal follow-ups: one glyph per object, unshown Multiple bars, typed object picks - An object that carries several Equal relations shows a single "=" badge, as the reference does, instead of one stacked badge per relation. - A pick made in the Multiple flow leaves its new constraint bar unshown until the object is selected or bars are shown, matching the reference; the single flow still shows the bar at once. - A typed coordinate at the Fix `Select object:` prompt and at the Horizontal/Vertical `Select an object or [2Points]` prompt picks the object under that point, like a click, instead of being read as a point. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 4 +++- src/modules/parametric/fixed.rs | 4 ++++ src/modules/parametric/horizontal.rs | 4 ++++ src/scene/parametric_constraints.rs | 23 +++++++++++++++++++++-- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0e21a8e6e..453754747 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3429,10 +3429,12 @@ impl OpenCADStudio { .scene .parametric_constraint_set_mut(scope) .add(ConstraintKind::Equal, vec![first, *follower], None); + // A Multiple pick leaves its new bar unshown, as the + // reference does; the single flow shows it at once. self.tabs[i].scene.note_parametric_constraint_applied( scope, id, - self.constraint_bar_display, + if multiple { 0 } else { self.constraint_bar_display }, ); } let changes = touched diff --git a/src/modules/parametric/fixed.rs b/src/modules/parametric/fixed.rs index c4c641500..2860ac772 100644 --- a/src/modules/parametric/fixed.rs +++ b/src/modules/parametric/fixed.rs @@ -104,6 +104,10 @@ impl CadCommand for FixConstraintCommand { true } + fn typed_point_picks_entity(&self) -> bool { + matches!(self.step, Step::Object) + } + fn entity_pick_highlights_hover(&self) -> bool { true } diff --git a/src/modules/parametric/horizontal.rs b/src/modules/parametric/horizontal.rs index b4b98911b..ca2aea80f 100644 --- a/src/modules/parametric/horizontal.rs +++ b/src/modules/parametric/horizontal.rs @@ -269,6 +269,10 @@ impl CadCommand for HorizontalConstraintCommand { true } + fn typed_point_picks_entity(&self) -> bool { + matches!(self.step, Step::ObjectOrTwoPoints) + } + fn entity_pick_highlights_hover(&self) -> bool { true } diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 0ab068550..4c9e87d34 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -1859,7 +1859,8 @@ impl super::Scene { let cam = self.camera.borrow(); (cam.view_proj_rte(bounds), cam.eye()) }; - set.constraints + let mut placements = set + .constraints .iter() .filter(|c| c.enabled) .filter(|c| { @@ -1952,7 +1953,25 @@ impl super::Scene { }) .collect::>() }) - .collect() + .collect::>(); + // The reference marks an object once for Equal however many + // relations of that kind it carries; the stacked duplicates only + // repeated the same badge. + let mut equal_anchors: Vec = Vec::new(); + placements.retain(|(_, point, _, label, _, _)| { + if label != "=" { + return true; + } + if equal_anchors + .iter() + .any(|seen| (seen.x - point.x).abs() < 0.5 && (seen.y - point.y).abs() < 0.5) + { + return false; + } + equal_anchors.push(*point); + true + }); + placements } /// Hit-tests screen point `p` (same coordinate space as `p_full` in the From 842ae461fce81c1f0eb815d9e5eb6a55831e4530 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:46:13 +0300 Subject: [PATCH 02/21] Equal: ask for the second object again after a refused pick, name the kind it needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second object of the wrong kind (a circle after a line, a line after a circle) or a pair that already carries Equal now reports and asks for the second object again instead of ending the command. The mismatch message names what the first object asks for — `Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc.` after a length object, `Select a circle or arc.` after a radius object — and the same-pick message carries the reference's double space. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 16 ++++++++++++---- src/modules/parametric/equal.rs | 6 +++++- src/modules/parametric/horizontal.rs | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 453754747..d93c47950 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3357,8 +3357,13 @@ impl OpenCADStudio { || equal_size_follower(&self.tabs[i].scene.document, first, other) .is_none() { - self.command_line - .push_error(EqualConstraintCommand::INVALID_OBJECT); + // The reference names the kind the first object + // asks for. + let message = match equal_size(&self.tabs[i].scene.document, first) { + Some(EqualSize::Radius(_)) => EqualConstraintCommand::INVALID_RADIUS_OBJECT, + _ => EqualConstraintCommand::INVALID_LENGTH_OBJECT, + }; + self.command_line.push_error(message); continue; } let exists = self.tabs[i] @@ -3378,7 +3383,10 @@ impl OpenCADStudio { } followers.push(other); } - if multiple && !finishing { + // A refused second object is asked for again, as in the + // reference; Multiple keeps asking anyway. + let keep = (multiple && !finishing) || (!multiple && followers.is_empty()); + if keep { if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) { @@ -3601,7 +3609,7 @@ impl OpenCADStudio { ("No valid constraint point found.", Some(first)) } else { ( - "The object or point is already selected. Select a different object or constraint point.", + "The object or point is already selected. Select a different object or constraint point.", Some(first), ) }; diff --git a/src/modules/parametric/equal.rs b/src/modules/parametric/equal.rs index fde973082..023487e55 100644 --- a/src/modules/parametric/equal.rs +++ b/src/modules/parametric/equal.rs @@ -31,8 +31,12 @@ impl EqualConstraintCommand { pub const NO_OBJECT: &'static str = "No object found."; pub const INVALID_OBJECT: &'static str = "Invalid selection for Equal. Select a line, polyline segment, circle or arc."; + pub const INVALID_LENGTH_OBJECT: &'static str = + "Invalid selection for Equal. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc."; + pub const INVALID_RADIUS_OBJECT: &'static str = + "Invalid selection for Equal. Select a circle or arc."; pub const SAME_OBJECT: &'static str = - "The object or point is already selected. Select a different object or constraint point."; + "The object or point is already selected. Select a different object or constraint point."; pub fn new() -> Self { Self { diff --git a/src/modules/parametric/horizontal.rs b/src/modules/parametric/horizontal.rs index ca2aea80f..26de8718f 100644 --- a/src/modules/parametric/horizontal.rs +++ b/src/modules/parametric/horizontal.rs @@ -200,7 +200,7 @@ impl HorizontalConstraintCommand { if first.handle == second.handle && (first.point - second.point).length() <= 1.0e-9 { self.step = Step::SecondPoint(first); return CmdResult::ReportError( - "The object or point is already selected. Select a different object or constraint point." + "The object or point is already selected. Select a different object or constraint point." .to_string(), ); } From 959d0011d6ace9df2dcdc5bf4436c50723cc32bb Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:58:57 +0300 Subject: [PATCH 03/21] Equal: show the bar of a Multiple pick at once, like the two-object flow A fresh reference session shows the "=" bar of every object made equal through Multiple right away; the earlier hidden state was not the rule. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index d93c47950..017600e12 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3437,12 +3437,10 @@ impl OpenCADStudio { .scene .parametric_constraint_set_mut(scope) .add(ConstraintKind::Equal, vec![first, *follower], None); - // A Multiple pick leaves its new bar unshown, as the - // reference does; the single flow shows it at once. self.tabs[i].scene.note_parametric_constraint_applied( scope, id, - if multiple { 0 } else { self.constraint_bar_display }, + self.constraint_bar_display, ); } let changes = touched From 72fd3fd35b42cb24384f68dca52622236c2f330b Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:12:46 +0300 Subject: [PATCH 04/21] Linear dimensional constraints: point flow, dynamic dimension, driving parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DCLINEAR, DCHORIZONTAL, DCVERTICAL and DCALIGNED follow the reference flow: `Specify first constraint point or [Object] :`, `Specify second constraint point:`, `Specify dimension line location:`, then `Dimension text = …` and `Enter value or name and value :`. Linear reads horizontal or vertical from the dimension line location the way a linear dimension does; Object takes a line's, an arc's or a polyline segment's ends. A miss reports `No valid constraint point found.` / `No object found.` and asks again; another object type reports `Invalid selection for Linear. Select a line, polyline segment or arc.`. Each constraint creates a drawing parameter (`d1`, `d2`, …, or the typed `name=expression`), drives the constraint through it, and draws a dynamic dimension in constraint gray whose text follows CONSTRAINTNAMEFORMAT (`d1=100.0000`); the dimension is associated with the constrained objects and its text is refreshed after every solve. A typed value other than the measured one moves the second point while the first stays, and so does a later parameter edit. Deleting the constraint removes its dimension and, when nothing else reads it, its parameter. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 248 ++++++++++++- src/app/commands/draw.rs | 42 ++- src/command.rs | 29 ++ src/modules/annotate/aligned_dim.rs | 40 +- src/modules/annotate/linear_dim.rs | 42 ++- src/modules/parametric/dim_constraint.rs | 445 +++++++++++++++++++++++ src/modules/parametric/mod.rs | 2 + src/scene/mod.rs | 5 + src/scene/parametric_constraints.rs | 81 ++++- 9 files changed, 898 insertions(+), 36 deletions(-) create mode 100644 src/modules/parametric/dim_constraint.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0e21a8e6e..6a3584ab0 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1103,6 +1103,50 @@ impl OpenCADStudio { TableCellEditStart::Started } + /// Reports a refused command-line value and shows the prompt again. + fn reprompt_active_command(&mut self, i: usize, message: &str) { + self.command_line.push_error(message); + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) { + self.command_line.push_info(&prompt); + } + } + + /// Drops what removed dimensional constraints leave behind: their dynamic + /// dimensions, and their parameters when no other constraint reads them. + pub(crate) fn purge_dimensional_extras( + &mut self, + i: usize, + dimensions: Vec, + parameters: Vec, + ) { + if !dimensions.is_empty() { + self.tabs[i].scene.erase_entities(&dimensions); + } + let unused: Vec = parameters + .into_iter() + .filter(|name| { + !self.tabs[i] + .scene + .parametric_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .any(|constraint| { + matches!( + &constraint.driving_param, + Some(crate::scene::named_parameters::DrivingValue::Named(used)) + if used == name + ) + }) + }) + .collect(); + if !unused.is_empty() { + self.tabs[i].scene.record_undo_named_parameters_before(); + for name in &unused { + self.tabs[i].scene.named_parameters_mut().remove(name); + } + } + } + fn remove_parametric_constraint( &mut self, id: crate::scene::parametric_constraints::ConstraintId, @@ -1117,8 +1161,13 @@ impl OpenCADStudio { return false; }; let touched: Vec = constraint.refs.iter().map(|r| r.entity).collect(); + let dimension = set.dimensions.get(&id).copied(); + let parameter = match &constraint.driving_param { + Some(crate::scene::named_parameters::DrivingValue::Named(name)) => Some(name.clone()), + _ => None, + }; let constraints_before = set.clone(); - let pending = self.begin_undo(i, label, touched.len(), true); + let pending = self.begin_undo(i, label, touched.len(), dimension.is_none()); self.tabs[i] .scene .record_undo_parametric_constraints_before(scope, constraints_before); @@ -1126,6 +1175,11 @@ impl OpenCADStudio { .scene .parametric_constraint_set_mut(scope) .remove(id); + self.purge_dimensional_extras( + i, + dimension.into_iter().collect(), + parameter.into_iter().collect(), + ); if self.tabs[i].scene.selected_constraint == Some(id) { self.tabs[i].scene.selected_constraint = None; } @@ -1219,6 +1273,23 @@ impl OpenCADStudio { .flat_map(|c| c.refs.iter().map(|r| r.entity)) .collect(); + // A changed value moves a dimensional constraint's second point; its + // first point stays, as in the reference. + let anchors: Vec = self.tabs[i] + .scene + .parametric_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .filter(|c| { + c.enabled + && matches!( + c.driving_param, + Some(crate::scene::named_parameters::DrivingValue::Named(_)) + ) + }) + .filter_map(|c| c.refs.first().copied()) + .filter(|reference| reference.marker.is_some()) + .collect(); let pending = self.begin_undo(i, "Apply named parameters", touched.len(), true); self.tabs[i].scene.record_undo_named_parameters_before(); self.tabs[i].scene.named_parameters = table; @@ -1228,10 +1299,15 @@ impl OpenCADStudio { .into_iter() .map(|h| (h, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &anchors, + self.constraint_solve_mode, + ); } else { self.tabs[i].scene.sync_native_parametric_graph(); } + self.tabs[i].scene.refresh_dynamic_dimension_texts(); if let Some(pd) = pending { self.commit_undo_delta(i, pd); } @@ -3450,6 +3526,174 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::CheckConstraintPoint(pick) => { + use crate::scene::parametric_constraints::{ + nearest_parametric_point, nearest_parametric_point_on_entity, resolve_point, + }; + let scope = self.tabs[i].current_parametric_scope(); + let resolved = { + let document = &self.tabs[i].scene.document; + let world = + acadrust::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z); + let reference = match pick.handle { + Some(handle) => { + nearest_parametric_point_on_entity(document, scope, handle, world) + } + None => nearest_parametric_point(document, scope, world, None), + }; + reference.map(|reference| { + let point = document + .get_entity(reference.entity) + .zip(reference.marker) + .and_then(|(entity, marker)| resolve_point(entity, marker)) + .map_or(pick.point, |p| glam::DVec3::new(p.x, p.y, p.z)); + (reference, point) + }) + }; + let Some((reference, point)) = resolved else { + return self.apply_cmd_result(CmdResult::ReportError( + crate::modules::parametric::DimConstraintCommand::NO_POINT.to_string(), + )); + }; + let result = self.tabs[i] + .active_cmd + .as_mut() + .map(|command| command.accept_constraint_point(reference, point)); + return match result { + Some(result) => self.apply_cmd_result(result), + None => Task::none(), + }; + } + CmdResult::AddDimensionalConstraint { + kind, + first, + second, + first_point, + second_point, + location, + axis, + name, + expression, + label, + } => { + use crate::modules::annotate::{aligned_dim, linear_dim}; + use crate::scene::named_parameters::{is_valid_name, DrivingValue}; + use crate::scene::parametric_constraints::{dynamic_dimension_text, ConstraintKind}; + + let scope = self.tabs[i].current_parametric_scope(); + let refs = vec![first, second]; + // A refused value keeps the prompt open, as the reference's + // editor does. + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + kind, + &refs, + Some(&DrivingValue::Literal(1.0)), + ) { + self.reprompt_active_command(i, message); + return Task::none(); + } + let name = name.trim().to_string(); + if !is_valid_name(&name) { + self.reprompt_active_command(i, "Invalid parameter name."); + return Task::none(); + } + let mut table = self.tabs[i].scene.named_parameters().clone(); + if let Err(error) = table.set(&name, &expression) { + self.reprompt_active_command(i, &error.to_string()); + return Task::none(); + } + let value = match table.resolve(&name) { + Ok(value) if value.is_finite() => value, + _ => { + self.reprompt_active_command(i, "Invalid expression."); + return Task::none(); + } + }; + let text = dynamic_dimension_text( + &name, + value, + self.tabs[i].scene.constraint_name_format, + ); + let mut entity = if kind == ConstraintKind::Distance { + aligned_dim::aligned_dimension_entity( + first_point, + second_point, + location, + Some(text), + ) + } else { + linear_dim::linear_dimension_entity( + first_point, + second_point, + location, + axis, + Some(text), + ) + }; + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + let layer = self.tabs[i].active_layer.clone(); + if layer != "0" { + entity.as_entity_mut().set_layer(layer); + } + // A dynamic dimension draws in the constraint gray, not the + // current color. + entity.common_mut().color = acadrust::types::Color::Rgb { + r: 103, + g: 109, + b: 118, + }; + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let mut touched = vec![first.entity]; + if second.entity != first.entity { + touched.push(second.entity); + } + let pending = self.begin_undo(i, label, touched.len() + 1, false); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = table; + let dimension = self.tabs[i].scene.add_entity(entity); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + let id = set.add(kind, refs, Some(DrivingValue::Named(name))); + set.dimensions.insert(id, dimension); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + self.tabs[i].scene.attach_dimension_association( + dimension, + vec![Some(first.entity), Some(second.entity)], + ); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + // The first point stays; a value other than the measured one + // moves the second, as in the reference. + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[first], + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } CmdResult::AddFixedConstraint(pick) => { use crate::modules::parametric::FixConstraintCommand; use crate::scene::parametric_constraints::{ diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 582cbfb41..79d66aa27 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1131,7 +1131,26 @@ impl OpenCADStudio { self.command_line.push_output("No constraints found."); return None; } - let pending = self.begin_undo(i, "Delete constraints", handles.len(), true); + let dimensions: Vec = ids + .iter() + .filter_map(|id| before.dimensions.get(id).copied()) + .collect(); + let parameters: Vec = ids + .iter() + .filter_map(|id| before.get(*id)) + .filter_map(|constraint| match &constraint.driving_param { + Some(crate::scene::named_parameters::DrivingValue::Named(name)) => { + Some(name.clone()) + } + _ => None, + }) + .collect(); + let pending = self.begin_undo( + i, + "Delete constraints", + handles.len(), + dimensions.is_empty(), + ); self.tabs[i] .scene .record_undo_parametric_constraints_before(scope, before); @@ -1139,6 +1158,7 @@ impl OpenCADStudio { for id in &ids { set.remove(*id); } + self.purge_dimensional_extras(i, dimensions, parameters); let changes: Vec<_> = handles .iter() .copied() @@ -1726,8 +1746,24 @@ impl OpenCADStudio { } } - "DCONSTRAINT" | "DCLINEAR" | "DCHORIZONTAL" | "DCVERTICAL" | "DCALIGNED" - | "DCRADIUS" | "DCDIAMETER" => { + "DCLINEAR" | "DCHORIZONTAL" | "DCVERTICAL" | "DCALIGNED" => { + use crate::modules::parametric::{DimConstraintAxis, DimConstraintCommand}; + use crate::scene::parametric_constraints::next_dimensional_parameter_name; + let axis = match cmd { + "DCHORIZONTAL" => DimConstraintAxis::Horizontal, + "DCVERTICAL" => DimConstraintAxis::Vertical, + "DCALIGNED" => DimConstraintAxis::Aligned, + _ => DimConstraintAxis::Linear, + }; + let name = next_dimensional_parameter_name(self.tabs[i].scene.named_parameters()); + // Both constraint points are picked inside the command. + self.tabs[i].scene.deselect_all(); + let command = DimConstraintCommand::new(axis, name); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + + "DCONSTRAINT" | "DCRADIUS" | "DCDIAMETER" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; diff --git a/src/command.rs b/src/command.rs index 6655c5351..b1b9d48e9 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1610,6 +1610,25 @@ pub enum CmdResult { multiple: bool, label: &'static str, }, + /// Resolves a constraint-point pick against the live document and hands + /// it back through `CadCommand::accept_constraint_point`; a miss reports + /// `No valid constraint point found.` and asks again. + CheckConstraintPoint(CoincidentPick), + /// Adds a dimensional constraint between two constraint points together + /// with the dynamic dimension that shows it and the parameter + /// (`name` = `expression`) that drives it. + AddDimensionalConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind, + first: crate::scene::parametric_constraints::ParametricRef, + second: crate::scene::parametric_constraints::ParametricRef, + first_point: DVec3, + second_point: DVec3, + location: DVec3, + axis: DVec3, + name: String, + expression: String, + label: &'static str, + }, /// Adds a point or object symmetry relation around a picked line. The /// first reference and axis remain fixed during initial placement. AddSymmetricConstraint { @@ -2453,6 +2472,16 @@ pub trait CadCommand: Send { false } + /// A constraint point the host resolved for a `CheckConstraintPoint` + /// pick, with its world position. + fn accept_constraint_point( + &mut self, + _reference: crate::scene::parametric_constraints::ParametricRef, + _point: DVec3, + ) -> CmdResult { + CmdResult::NeedPoint + } + /// Include filled hatch / DXF SOLID regions in the entity hit-test. /// /// Most entity-pick commands operate on curve geometry and intentionally diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 36013041b..179cefed2 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -67,26 +67,36 @@ impl AlignedDimensionCommand { let first = self.plane.to_local(first); let second = self.plane.to_local(second); let point = self.plane.to_local(point); - let mut dim = DimensionAligned::new(v3(first), v3(second)); - // The placement point is stored so commit matches the preview. - dim.definition_point = v3(point); - dim.base.definition_point = v3(point); - let (d1, d2) = dim_line_endpoints(first, second, point); - dim.base.text_middle_point = v3((d1 + d2) * 0.5); - dim.base.insertion_point = dim.base.text_middle_point; - dim.base.actual_measurement = dim.measurement(); - crate::entities::dimension::set_dimension_text_override( - &mut dim.base, - self.text_override.clone(), - ); + let mut entity = aligned_dimension_entity(first, second, point, self.text_override.clone()); // An explicit text angle overrides the default rotation. - if let Some(angle) = self.text_angle { - dim.base.text_rotation = angle; + if let (Some(angle), EntityType::Dimension(dimension)) = (self.text_angle, &mut entity) { + dimension.base_mut().text_rotation = angle; } - self.plane.place_entity(EntityType::Dimension(Dimension::Aligned(dim))) + self.plane.place_entity(entity) } } +/// An aligned dimension between two points of the working plane, its +/// dimension line through `point` — what DIMALIGNED places and what an +/// aligned dimensional constraint draws. +pub(crate) fn aligned_dimension_entity( + first: DVec3, + second: DVec3, + point: DVec3, + text_override: Option, +) -> EntityType { + let mut dim = DimensionAligned::new(v3(first), v3(second)); + // The placement point is stored so commit matches the preview. + dim.definition_point = v3(point); + dim.base.definition_point = v3(point); + let (d1, d2) = dim_line_endpoints(first, second, point); + dim.base.text_middle_point = v3((d1 + d2) * 0.5); + dim.base.insertion_point = dim.base.text_middle_point; + dim.base.actual_measurement = dim.measurement(); + crate::entities::dimension::set_dimension_text_override(&mut dim.base, text_override); + EntityType::Dimension(Dimension::Aligned(dim)) +} + impl CadCommand for AlignedDimensionCommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 5e9b00c94..ed6066506 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -15,7 +15,7 @@ use glam::DVec3; use crate::t; /// Select the measured axis from where the dimension line clears the points. -fn measure_axis(first: DVec3, second: DVec3, def: DVec3) -> DVec3 { +pub(crate) fn measure_axis(first: DVec3, second: DVec3, def: DVec3) -> DVec3 { let outside = |value: f64, a: f64, b: f64| { let (low, high) = if a <= b { (a, b) } else { (b, a) }; (low - value).max(value - high).max(0.0) @@ -113,26 +113,38 @@ impl LinearDimensionCommand { let first = self.plane.to_local(first); let second = self.plane.to_local(second); let point = self.plane.to_local(point); - let mut dim = DimensionLinear::new(v3(first), v3(second)); let axis = self.axis_mode.axis(first, second, point); - dim.rotation = axis.y.atan2(axis.x); - dim.set_offset(dimension_line_offset(second, point, axis)); - dim.base.definition_point = dim.definition_point; - dim.base.text_middle_point = v3(linear_text_pos(first, second, point, axis)); - dim.base.insertion_point = dim.base.text_middle_point; - dim.base.actual_measurement = dim.measurement(); - crate::entities::dimension::set_dimension_text_override( - &mut dim.base, - self.text_override.clone(), - ); + let mut entity = + linear_dimension_entity(first, second, point, axis, self.text_override.clone()); // An explicit text angle overrides the UCS-derived rotation. - if let Some(angle) = self.text_angle { - dim.base.text_rotation = angle; + if let (Some(angle), EntityType::Dimension(dimension)) = (self.text_angle, &mut entity) { + dimension.base_mut().text_rotation = angle; } - self.plane.place_entity(EntityType::Dimension(Dimension::Linear(dim))) + self.plane.place_entity(entity) } } +/// A linear dimension between two points of the working plane, its +/// dimension line through `point` along `axis` — what DIMLINEAR places and +/// what a dynamic dimensional constraint draws. +pub(crate) fn linear_dimension_entity( + first: DVec3, + second: DVec3, + point: DVec3, + axis: DVec3, + text_override: Option, +) -> EntityType { + let mut dim = DimensionLinear::new(v3(first), v3(second)); + dim.rotation = axis.y.atan2(axis.x); + dim.set_offset(dimension_line_offset(second, point, axis)); + dim.base.definition_point = dim.definition_point; + dim.base.text_middle_point = v3(linear_text_pos(first, second, point, axis)); + dim.base.insertion_point = dim.base.text_middle_point; + dim.base.actual_measurement = dim.measurement(); + crate::entities::dimension::set_dimension_text_override(&mut dim.base, text_override); + EntityType::Dimension(Dimension::Linear(dim)) +} + impl CadCommand for LinearDimensionCommand { fn name(&self) -> &'static str { "DIMLINEAR" diff --git a/src/modules/parametric/dim_constraint.rs b/src/modules/parametric/dim_constraint.rs new file mode 100644 index 000000000..a4ccb007d --- /dev/null +++ b/src/modules/parametric/dim_constraint.rs @@ -0,0 +1,445 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, CoincidentPick}; +use crate::scene::parametric_constraints::{ + measured_expression, resolve_point, ConstraintKind, ParametricRef, +}; + +/// Which distance a dimensional constraint measures. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum DimConstraintAxis { + /// Horizontal or vertical, decided by where the dimension line goes. + Linear, + Horizontal, + Vertical, + Aligned, +} + +#[derive(Clone, Copy)] +enum Step { + /// `Specify first constraint point or [Object] :` + First, + /// `Select object:` + Object, + /// `Specify second constraint point:` + Second { + first: ParametricRef, + first_point: DVec3, + }, + /// `Specify dimension line location:` + Location { + first: ParametricRef, + second: ParametricRef, + first_point: DVec3, + second_point: DVec3, + }, + /// `Enter value or name and value :` + Value { + first: ParametricRef, + second: ParametricRef, + first_point: DVec3, + second_point: DVec3, + location: DVec3, + axis: DVec3, + kind: ConstraintKind, + measured: f64, + }, +} + +/// The reference's Linear/Horizontal/Vertical/Aligned dimensional constraint: +/// two constraint points (or one object's ends), a dimension line location, +/// then the parameter name and expression the dynamic dimension carries. +pub struct DimConstraintCommand { + axis: DimConstraintAxis, + step: Step, + picked_entity: Option, + /// The next free `dN` the host reserved for this constraint. + default_name: String, +} + +impl DimConstraintCommand { + pub const NO_OBJECT: &'static str = "No object found."; + pub const NO_POINT: &'static str = "No valid constraint point found."; + pub const SAME_POINT: &'static str = + "The object or point is already selected. Select a different object or constraint point."; + + pub fn new(axis: DimConstraintAxis, default_name: String) -> Self { + Self { + axis, + step: Step::First, + picked_entity: None, + default_name, + } + } + + fn command_name(&self) -> &'static str { + match self.axis { + DimConstraintAxis::Linear => "DCLINEAR", + DimConstraintAxis::Horizontal => "DCHORIZONTAL", + DimConstraintAxis::Vertical => "DCVERTICAL", + DimConstraintAxis::Aligned => "DCALIGNED", + } + } + + fn noun(&self) -> &'static str { + match self.axis { + DimConstraintAxis::Linear => "Linear", + DimConstraintAxis::Horizontal => "Horizontal", + DimConstraintAxis::Vertical => "Vertical", + DimConstraintAxis::Aligned => "Aligned", + } + } + + fn report(message: &str) -> CmdResult { + CmdResult::ReportError(message.to_string()) + } + + /// The two end points an object pick constrains: a line's or an arc's + /// ends, the picked polyline segment's vertices. + fn object_ends( + entity: &EntityType, + handle: Handle, + point: DVec3, + ) -> Option<(ParametricRef, ParametricRef)> { + match entity { + EntityType::Line(_) | EntityType::Arc(_) => { + Some((ParametricRef::point(handle, 0), ParametricRef::point(handle, 1))) + } + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let (source, _, _) = + crate::scene::centerline::picked_source(entity, handle, point)?; + let index = source.segment_index; + // A closed polyline's last segment ends at the first vertex. + let end = if resolve_point(entity, index + 1).is_some() { + index + 1 + } else { + 0 + }; + Some((ParametricRef::point(handle, index), ParametricRef::point(handle, end))) + } + _ => None, + } + } + + fn world(entity: &EntityType, reference: ParametricRef) -> Option { + let point = resolve_point(entity, reference.marker?)?; + Some(DVec3::new(point.x, point.y, point.z)) + } + + /// The measured kind and axis: Linear reads the dimension line location + /// the way a linear dimension does, the others are fixed. + fn decide(&self, first: DVec3, second: DVec3, location: DVec3) -> (ConstraintKind, DVec3) { + match self.axis { + DimConstraintAxis::Linear => { + let axis = + crate::modules::annotate::linear_dim::measure_axis(first, second, location); + if axis.x.abs() > 0.5 { + (ConstraintKind::DistanceX, DVec3::X) + } else { + (ConstraintKind::DistanceY, DVec3::Y) + } + } + DimConstraintAxis::Horizontal => (ConstraintKind::DistanceX, DVec3::X), + DimConstraintAxis::Vertical => (ConstraintKind::DistanceY, DVec3::Y), + DimConstraintAxis::Aligned => ( + ConstraintKind::Distance, + (second - first).normalize_or(DVec3::X), + ), + } + } + + fn measured(kind: ConstraintKind, first: DVec3, second: DVec3) -> f64 { + match kind { + ConstraintKind::DistanceX => (second.x - first.x).abs(), + ConstraintKind::DistanceY => (second.y - first.y).abs(), + _ => (second - first).length(), + } + } + + fn build(&self, name: String, expression: String) -> Option { + let Step::Value { + first, + second, + first_point, + second_point, + location, + axis, + kind, + .. + } = self.step + else { + return None; + }; + if expression.trim().is_empty() { + return None; + } + Some(CmdResult::AddDimensionalConstraint { + kind, + first, + second, + first_point, + second_point, + location, + axis, + name, + expression, + label: match self.axis { + DimConstraintAxis::Linear => "Linear constraint", + DimConstraintAxis::Horizontal => "Horizontal distance constraint", + DimConstraintAxis::Vertical => "Vertical distance constraint", + DimConstraintAxis::Aligned => "Aligned constraint", + }, + }) + } +} + +impl CadCommand for DimConstraintCommand { + fn name(&self) -> &'static str { + self.command_name() + } + + fn prompt(&self) -> String { + let name = self.command_name(); + match self.step { + Step::First => { + format!("{name} Specify first constraint point or [Object] :") + } + Step::Object => format!("{name} Select object:"), + Step::Second { .. } => format!("{name} Specify second constraint point:"), + Step::Location { .. } => format!("{name} Specify dimension line location:"), + Step::Value { measured, .. } => format!( + "{name} Enter value or name and value <{}={}>:", + self.default_name, + measured_expression(measured) + ), + } + } + + fn options(&self) -> Vec { + matches!(self.step, Step::First) + .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::First | Step::Value { .. }) + } + + fn on_text_input(&mut self, text: &str) -> Option { + match self.step { + Step::First => { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + if matches!(keyword.as_str(), "O" | "OBJECT") { + self.step = Step::Object; + return Some(CmdResult::NeedPoint); + } + None + } + Step::Value { .. } => { + let text = text.trim(); + let (name, expression) = match text.split_once('=') { + Some((name, expression)) => (name.trim().to_string(), expression.trim().to_string()), + None => (self.default_name.clone(), text.to_string()), + }; + self.build(name, expression) + } + _ => None, + } + } + + fn needs_entity_pick(&self) -> bool { + matches!(self.step, Step::First | Step::Object | Step::Second { .. }) + } + + fn entity_pick_accepts_points(&self) -> bool { + true + } + + fn typed_point_picks_entity(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + match self.step { + // The host resolves the constraint point and hands it back + // through `accept_constraint_point`. + Step::First | Step::Second { .. } => CmdResult::CheckConstraintPoint(CoincidentPick { + handle: (!handle.is_null()).then_some(handle), + point, + whole_curve: false, + }), + Step::Object => { + if handle.is_null() { + return Self::report(Self::NO_OBJECT); + } + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some((first, second)) = Self::object_ends(&entity, handle, point) else { + return Self::report(&format!( + "Invalid selection for {}. Select a line, polyline segment or arc.", + self.noun() + )); + }; + let (Some(first_point), Some(second_point)) = + (Self::world(&entity, first), Self::world(&entity, second)) + else { + return Self::report(Self::NO_POINT); + }; + self.step = Step::Location { + first, + second, + first_point, + second_point, + }; + CmdResult::NeedPoint + } + Step::Location { .. } | Step::Value { .. } => self.on_point(point), + } + } + + fn accept_constraint_point(&mut self, reference: ParametricRef, point: DVec3) -> CmdResult { + match self.step { + Step::First => { + self.step = Step::Second { + first: reference, + first_point: point, + }; + CmdResult::NeedPoint + } + Step::Second { first, first_point } => { + if first == reference { + return Self::report(Self::SAME_POINT); + } + self.step = Step::Location { + first, + second: reference, + first_point, + second_point: point, + }; + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::First | Step::Second { .. } => CmdResult::CheckConstraintPoint(CoincidentPick { + handle: None, + point, + whole_curve: false, + }), + Step::Object => Self::report(Self::NO_OBJECT), + Step::Location { + first, + second, + first_point, + second_point, + } => { + let (kind, axis) = self.decide(first_point, second_point, point); + let measured = Self::measured(kind, first_point, second_point); + self.step = Step::Value { + first, + second, + first_point, + second_point, + location: point, + axis, + kind, + measured, + }; + CmdResult::ReportMeasurement(format!("Dimension text = {measured:.4}")) + } + Step::Value { .. } => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::First => { + self.step = Step::Object; + CmdResult::NeedPoint + } + Step::Value { measured, .. } => self + .build(self.default_name.clone(), measured_expression(measured)) + .unwrap_or(CmdResult::Cancel), + _ => CmdResult::Cancel, + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::Line; + use acadrust::types::Vector3; + + #[test] + fn object_pick_takes_the_line_ends_then_asks_for_the_location() { + let mut command = DimConstraintCommand::new(DimConstraintAxis::Linear, "d1".into()); + assert!(matches!(command.on_enter(), CmdResult::NeedPoint)); + command.inject_picked_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(100.0, 50.0, 0.0), + ))); + assert!(matches!( + command.on_entity_pick(Handle::new(7), DVec3::new(50.0, 25.0, 0.0)), + CmdResult::NeedPoint + )); + // Below the pair: horizontal, so the measured value is the X span. + let CmdResult::ReportMeasurement(text) = command.on_point(DVec3::new(50.0, -30.0, 0.0)) + else { + panic!("the location must report the dimension text"); + }; + assert_eq!(text, "Dimension text = 100.0000"); + assert_eq!( + command.prompt(), + "DCLINEAR Enter value or name and value :" + ); + let CmdResult::AddDimensionalConstraint { + kind, name, expression, .. + } = command.on_text_input("w=d1*2").expect("a value builds the constraint") + else { + panic!("the value must build the constraint"); + }; + assert_eq!(kind, ConstraintKind::DistanceX); + assert_eq!(name, "w"); + assert_eq!(expression, "d1*2"); + } + + #[test] + fn a_location_beside_the_pair_measures_vertically() { + let mut command = DimConstraintCommand::new(DimConstraintAxis::Linear, "d1".into()); + let first = ParametricRef::point(Handle::new(7), 0); + let second = ParametricRef::point(Handle::new(7), 1); + command.accept_constraint_point(first, DVec3::ZERO); + command.accept_constraint_point(second, DVec3::new(100.0, 50.0, 0.0)); + let CmdResult::ReportMeasurement(text) = command.on_point(DVec3::new(140.0, 25.0, 0.0)) + else { + panic!("the location must report the dimension text"); + }; + assert_eq!(text, "Dimension text = 50.0000"); + } +} diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index ebe13aa90..44ad59d44 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -4,6 +4,7 @@ mod coincident; mod concentric; +mod dim_constraint; mod constraint_bar; mod equal_distance; #[path = "equal.rs"] @@ -25,6 +26,7 @@ mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use concentric::ConcentricConstraintCommand; +pub use dim_constraint::{DimConstraintAxis, DimConstraintCommand}; pub use constraint_bar::ConstraintBarOptionCommand; pub use equal_command::EqualConstraintCommand; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e2b373127..60821cba1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2295,6 +2295,9 @@ pub struct Scene { has_associative_centers: std::cell::Cell>, /// Runtime parametric constraint sets decoded from standard graph scopes. pub(crate) parametric_constraints: Vec, + /// CONSTRAINTNAMEFORMAT: what a dynamic dimension's text shows — + /// 0 the parameter name, 1 the value, 2 `name=value`. + pub constraint_name_format: u8, /// Session-only visibility overrides for constraint glyphs. hidden_parametric_constraints: HashSet<( parametric_constraints::ParametricScope, @@ -2625,6 +2628,7 @@ impl Scene { dependency_index_cache: RefCell::new(None), associative_hatch_source_cache: RefCell::new(None), parametric_constraints: Vec::new(), + constraint_name_format: 2, hidden_parametric_constraints: HashSet::default(), shown_parametric_constraints: HashSet::default(), named_parameters: named_parameters::ParameterTable::new(), @@ -3319,6 +3323,7 @@ impl Scene { } } if !self.parametric_constraints.is_empty() || !self.named_parameters.is_empty() { + self.refresh_dynamic_dimension_texts(); self.sync_native_parametric_graph(); } if !changes.is_empty() { diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 0ab068550..4f9317a75 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -351,6 +351,37 @@ pub(crate) fn equal_size_follower( Some(entity) } +/// The first free `d1`, `d2`, … name a new dimensional constraint takes. +pub(crate) fn next_dimensional_parameter_name( + table: &super::named_parameters::ParameterTable, +) -> String { + (1..) + .map(|n| format!("d{n}")) + .find(|name| !table.contains(name)) + .unwrap_or_else(|| "d1".to_string()) +} + +/// The text a dynamic dimension shows: CONSTRAINTNAMEFORMAT 0 = name, +/// 1 = value, 2 = name=value. +pub(crate) fn dynamic_dimension_text(name: &str, value: f64, format: u8) -> String { + match format { + 0 => name.to_string(), + 1 => format!("{value:.4}"), + _ => format!("{name}={value:.4}"), + } +} + +/// The expression a measured value seeds a parameter with (`100`, `111.8034`). +pub(crate) fn measured_expression(value: f64) -> String { + let text = format!("{value:.4}"); + let text = text.trim_end_matches('0').trim_end_matches('.'); + if text.is_empty() || text == "-" { + "0".to_string() + } else { + text.to_string() + } +} + /// 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( @@ -585,6 +616,8 @@ pub struct ParametricConstraintSet { /// parameter names may legitimately occur in different blocks. pub(crate) local_parameters: super::named_parameters::ParameterTable, pub(crate) retained_standard_groups: Vec, + /// The dynamic dimension that shows a dimensional constraint, by id. + pub(crate) dimensions: std::collections::HashMap, /// Cached total remaining degrees of freedom, summed across every /// independent solve partition in this scope — updated by /// `parametric_solve::solve_scope` each time this set is resolved. `None` @@ -611,6 +644,7 @@ impl ParametricConstraintSet { next_id: 0, local_parameters: super::named_parameters::ParameterTable::new(), retained_standard_groups: Vec::new(), + dimensions: std::collections::HashMap::new(), dof: None, conflicts: Vec::new(), } @@ -702,6 +736,7 @@ impl ParametricConstraintSet { /// Removes a constraint by id. Returns whether one was actually removed. pub fn remove(&mut self, id: ConstraintId) -> bool { + self.dimensions.remove(&id); let before = self.constraints.len(); self.constraints.retain(|c| c.id != id); self.constraints.len() != before @@ -1420,6 +1455,49 @@ impl super::Scene { !self.hidden_parametric_constraints.contains(&(scope, id)) } + /// Rewrites every dynamic dimension's text from its constraint's + /// parameter name and current value, in the CONSTRAINTNAMEFORMAT + /// reading `constraint_name_format` selects. + pub(crate) fn refresh_dynamic_dimension_texts(&mut self) { + let format = self.constraint_name_format; + let mut updates: Vec<(Handle, String)> = Vec::new(); + for set in &self.parametric_constraints { + let table = if set.local_parameters.is_empty() { + &self.named_parameters + } else { + &set.local_parameters + }; + for (id, dimension) in &set.dimensions { + let Some(constraint) = set.get(*id) else { + continue; + }; + let (name, value) = match &constraint.driving_param { + Some(DrivingValue::Named(name)) => { + (name.clone(), table.resolve(name).unwrap_or(f64::NAN)) + } + Some(DrivingValue::Literal(value)) => (String::new(), *value), + None => continue, + }; + updates.push((*dimension, dynamic_dimension_text(&name, value, format))); + } + } + for (handle, text) in updates { + let Some(acadrust::EntityType::Dimension(mut dimension)) = + self.document.get_entity(handle).cloned() + else { + continue; + }; + if dimension.base().user_text.as_deref() == Some(text.as_str()) { + continue; + } + crate::entities::dimension::set_dimension_text_override( + dimension.base_mut(), + Some(text), + ); + self.update_entity(acadrust::EntityType::Dimension(dimension)); + } + } + pub fn should_display_parametric_constraint( &self, scope: ParametricScope, @@ -1861,7 +1939,8 @@ impl super::Scene { }; set.constraints .iter() - .filter(|c| c.enabled) + // A dynamic dimension is its constraint's whole display. + .filter(|c| c.enabled && !set.dimensions.contains_key(&c.id)) .filter(|c| { let selected = c .refs From 971abe64a4e2232bba871e9b67445cb659b98236 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:02:21 +0300 Subject: [PATCH 05/21] Dimensional constraints: dynamic dimension persistence, lock mark, display variables, DIMCONSTRAINT The dynamic dimension of a dimensional constraint is now linked in the drawing file (AcDbAssocDependency + AcDbAssocDimDependencyBody on the constraint node), reread on load, hidden by DCHIDE/DYNCONSTRAINTDISPLAY and never plotted; it carries a lock mark before its text. New CONSTRAINTNAMEFORMAT and DYNCONSTRAINTDISPLAY variables and the DIMCONSTRAINT option front end. A transformed entity whose own end points a driving dimension measures keeps only the first constraint point, so a scaled line returns to its dimension value. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 2 + src/app/commands/draw.rs | 10 ++ src/app/commands/styleprops.rs | 23 +++- src/entities/dimension.rs | 39 +++++++ src/modules/parametric/dim_constraint.rs | 64 ++++++++++ src/modules/parametric/mod.rs | 4 +- src/scene/dwg_native_constraints.rs | 142 ++++++++++++++++++++++- src/scene/entity.rs | 3 + src/scene/mod.rs | 7 ++ src/scene/parametric_constraints.rs | 96 ++++++++++++++- src/scene/parametric_solve.rs | 26 ++++- src/ui/overlay.rs | 41 ++++--- 12 files changed, 433 insertions(+), 24 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 6a3584ab0..1c435fd22 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3686,6 +3686,8 @@ impl OpenCADStudio { &[first], self.constraint_solve_mode, ); + // DYNCONSTRAINTDISPLAY 0 also keeps a new dynamic dimension off screen. + self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 79d66aa27..cf9d4686d 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1506,6 +1506,16 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "DIMCONSTRAINT" => { + use crate::modules::parametric::DimConstraintMenuCommand; + // Only the dynamic form exists here. + self.command_line + .push_output("Current settings: Constraint form = Dynamic"); + let new_cmd = DimConstraintMenuCommand; + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + "EDCONSTRAINT" => { use crate::modules::parametric::EqualDistanceConstraintCommand; let new_cmd = EqualDistanceConstraintCommand::new(); diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 0b5bc9b14..e997869cd 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1052,6 +1052,8 @@ impl OpenCADStudio { | "CONSTRAINTINFER" | "CONSTRAINTBARDISPLAY" | "CONSTRAINTBARMODE" + | "CONSTRAINTNAMEFORMAT" + | "DYNCONSTRAINTDISPLAY" ) => { return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); @@ -1075,7 +1077,7 @@ impl OpenCADStudio { if name.is_empty() || name == "?" { self.command_line.push_info(&super::plotvars::setvar_listing()); self.command_line.push_info( - crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), + crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE CONSTRAINTNAMEFORMAT DYNCONSTRAINTDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), ); } else { if name == "CETRANSPARENCY" { @@ -1275,17 +1277,26 @@ impl OpenCADStudio { | "CONSTRAINTINFER" | "CONSTRAINTBARDISPLAY" | "CONSTRAINTBARMODE" + | "CONSTRAINTNAMEFORMAT" + | "DYNCONSTRAINTDISPLAY" ) { let current = match name.as_str() { "CONSTRAINTSOLVEMODE" => i16::from(self.constraint_solve_mode), "CONSTRAINTINFER" => i16::from(self.constraint_infer), "CONSTRAINTBARDISPLAY" => self.constraint_bar_display, "CONSTRAINTBARMODE" => self.constraint_bar_mode, + "CONSTRAINTNAMEFORMAT" => { + i16::from(self.tabs[i].scene.constraint_name_format) + } + "DYNCONSTRAINTDISPLAY" => { + i16::from(self.tabs[i].scene.dynamic_constraint_display) + } _ => unreachable!(), }; let maximum = match name.as_str() { "CONSTRAINTBARDISPLAY" => 3, "CONSTRAINTBARMODE" => 4095, + "CONSTRAINTNAMEFORMAT" => 2, _ => 1, }; if let Some(value) = &value { @@ -1304,6 +1315,16 @@ impl OpenCADStudio { self.constraint_bar_display = mode } "CONSTRAINTBARMODE" => self.constraint_bar_mode = mode, + "CONSTRAINTNAMEFORMAT" => { + self.tabs[i].scene.constraint_name_format = mode as u8; + self.tabs[i].scene.refresh_dynamic_dimension_texts(); + self.tabs[i].dirty = true; + } + "DYNCONSTRAINTDISPLAY" => { + self.tabs[i].scene.dynamic_constraint_display = + mode != 0; + self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); + } _ => unreachable!(), } self.persist_settings_if_changed(); diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 6cf37e0a5..906dea36d 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -6295,6 +6295,45 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool } } +/// Where a dynamic dimension's lock mark sits: just before the text on its +/// baseline, with the direction that points away from the text. +pub(crate) fn dynamic_dimension_lock_anchor( + document: &CadDocument, + dim: &Dimension, + anno_scale: f64, +) -> Option<(Vector3, Vector3)> { + let style_name = &dim.base().style_name; + let source_style = document.dim_styles.iter().find(|s| { + s.name.eq_ignore_ascii_case(style_name) + || (style_name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) + }); + let effective_style = source_style.map(|style| resolved_dimension_style(style, dim, document)); + let style = effective_style.as_ref(); + let dim_scale = style + .map(|s| if s.dimscale > 1e-6 { s.dimscale } else { anno_scale }) + .unwrap_or(1.0); + let text_height = style.map(|s| s.dimtxt * dim_scale).unwrap_or(2.5); + let value = dimension_text_value(dim, style)?; + let stack_scale = style.map(dimtfac_or_one).unwrap_or(1.0); + let half_width = text_cells(&value, stack_scale) * text_height * CELL_WIDTH * 0.5; + let pos = dimension_text_pos_f64(dim, style, text_height, dim_scale); + let rot = if dim.base().text_rotation.abs() > 1e-9 { + dim.base().text_rotation + } else { + dimension_text_natural_rotation(dim) + }; + let (sr, cr) = rot.sin_cos(); + let outward = Vector3::new(-cr, -sr, 0.0); + Some(( + Vector3::new( + pos.x + outward.x * half_width, + pos.y + outward.y * half_width, + pos.z, + ), + outward, + )) +} + fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { let angle = match dim { Dimension::Linear(d) => d.rotation, diff --git a/src/modules/parametric/dim_constraint.rs b/src/modules/parametric/dim_constraint.rs index a4ccb007d..522119203 100644 --- a/src/modules/parametric/dim_constraint.rs +++ b/src/modules/parametric/dim_constraint.rs @@ -390,6 +390,70 @@ impl CadCommand for DimConstraintCommand { } } +/// `DIMCONSTRAINT`: the reference's option front end for the dimensional +/// constraint family; each choice runs the focused command. +pub struct DimConstraintMenuCommand; + +impl DimConstraintMenuCommand { + fn dispatch(keyword: &str) -> Option<&'static str> { + Some(match keyword { + "L" | "LINEAR" => "DCLINEAR", + "H" | "HORIZONTAL" => "DCHORIZONTAL", + "V" | "VERTICAL" => "DCVERTICAL", + "A" | "ALIGNED" => "DCALIGNED", + "AN" | "ANGULAR" => "DCANGULAR", + "R" | "RADIAL" | "RADIUS" => "DCRADIUS", + "D" | "DIAMETER" => "DCDIAMETER", + "C" | "CONVERT" => "DCCONVERT", + _ => return None, + }) + } +} + +impl CadCommand for DimConstraintMenuCommand { + fn name(&self) -> &'static str { + "DIMCONSTRAINT" + } + + fn prompt(&self) -> String { + "DIMCONSTRAINT Enter dimensional constraint option [Linear/Horizontal/Vertical/Aligned/ANgular/Radial/Diameter/Convert] :".to_string() + } + + fn options(&self) -> Vec { + vec![ + CmdOption::new("Linear", "L"), + CmdOption::new("Horizontal", "H"), + CmdOption::new("Vertical", "V"), + CmdOption::new("Aligned", "A"), + CmdOption::new("ANgular", "AN"), + CmdOption::new("Radial", "R"), + CmdOption::new("Diameter", "D"), + CmdOption::new("Convert", "C"), + ] + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + Self::dispatch(&keyword).map(|command| CmdResult::Dispatch(command.to_string())) + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch("DCALIGNED".to_string()) + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 44ad59d44..6353d3672 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -26,7 +26,7 @@ mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use concentric::ConcentricConstraintCommand; -pub use dim_constraint::{DimConstraintAxis, DimConstraintCommand}; +pub use dim_constraint::{DimConstraintAxis, DimConstraintCommand, DimConstraintMenuCommand}; pub use constraint_bar::ConstraintBarOptionCommand; pub use equal_command::EqualConstraintCommand; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; @@ -158,7 +158,7 @@ inventory::submit!(crate::command::CommandRegistration { names: &[ "AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "GCSMOOTH", "GCSHOW", "GCHIDE", "GCRESET", "GCSHOWALL", "GCHIDEALL", "DCSHOW", "DCHIDE", "DCSHOWALL", "DCHIDEALL", - "DCCONVERT", "DELCONSTRAINT", + "DCCONVERT", "DELCONSTRAINT", "DIMCONSTRAINT", ] }); diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index e36ca0c6d..dc8174bf5 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -2,7 +2,8 @@ use acadrust::objects::{ Assoc2dConstraintGroup, AssocAction, AssocActionDependency, AssocConstraintNode, - AssocConstraintNodeData, AssocDependency, AssocEvalValue, AssocEvalVariant, + AssocConstraintNodeData, AssocDependency, AssocDimDependencyBody, AssocEvalValue, + AssocEvalVariant, AssocGeomDependency, AssocNetwork, AssocPersistentSubentId, AssocValueDependency, AssocVariable, AssociativeData, AssociativeObject, ObjectType, }; @@ -1291,6 +1292,67 @@ impl Allocator<'_> { } handle } + + /// The `AcDbAssocDependency` + `AcDbAssocDimDependencyBody` pair that + /// ties a dimensional constraint to the dynamic dimension showing it. + fn dimension_dependency( + &mut self, + group_handle: Handle, + dimension: Handle, + name: &str, + dependency_id: i32, + ) -> Handle { + let handle = self.document.allocate_handle(); + let body = self.insert_associative( + handle, + "ACDBASSOCDIMDEPENDENCYBODY", + "AcDbAssocDimDependencyBody", + AssociativeData::DimDependencyBody(AssocDimDependencyBody { + dependency_body_version: 1, + base_version: 1, + name: name.to_string(), + class_version: 1, + }), + ); + self.insert_associative_at( + handle, + group_handle, + "ACDBASSOCDEPENDENCY", + "AcDbAssocDependency", + AssociativeData::Dependency(AssocDependency { + class_version: 2, + is_read_dependency: true, + is_write_dependency: true, + is_attached_to_object: true, + is_delegating_to_owning_action: true, + dependent_on: dimension, + dependency_body: body, + dependency_body_id: dependency_id, + ..Default::default() + }), + ); + // Earlier materializations' dependencies are gone by now; drop + // their reactor entries before adding this one. + let live: Vec = self + .document + .get_entity(dimension) + .map(|entity| { + entity + .common() + .reactors + .iter() + .copied() + .filter(|reactor| self.document.objects.contains_key(reactor)) + .collect() + }) + .unwrap_or_default(); + if let Some(entity) = self.document.get_entity_mut(dimension) { + let reactors = &mut entity.common_mut().reactors; + *reactors = live; + reactors.push(handle); + } + handle + } } /// Sets the dependency on whole-geometry nodes. Implicit points retain a @@ -1355,6 +1417,27 @@ fn set_value_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { } } +/// Patches a dimensional-constraint node's `dimension_dependency` field. +fn set_dimension_dependency(data: &mut AssocConstraintNodeData, handle: Handle) { + match data { + AssocConstraintNodeData::Distance { + dimension_dependency, + .. + } + | AssocConstraintNodeData::Angle { + dimension_dependency, + .. + } + | AssocConstraintNodeData::RadiusDiameter { + dimension_dependency, + .. + } => { + *dimension_dependency = handle; + } + _ => {} + } +} + /// Associative classes that must have real class numbers in DWG/DXF. const ASSOC_CLASSES: &[(&str, &str, i32)] = &[ ( @@ -1366,6 +1449,8 @@ const ASSOC_CLASSES: &[(&str, &str, i32)] = &[ ("ACDBASSOCVARIABLE", "AcDbAssocVariable", 45), ("ACDBASSOCGEOMDEPENDENCY", "AcDbAssocGeomDependency", 29), ("ACDBASSOCVALUEDEPENDENCY", "AcDbAssocValueDependency", 29), + ("ACDBASSOCDEPENDENCY", "AcDbAssocDependency", 29), + ("ACDBASSOCDIMDEPENDENCYBODY", "AcDbAssocDimDependencyBody", 45), ]; fn ensure_associative_classes_registered(document: &mut CadDocument) { @@ -1806,6 +1891,19 @@ fn driving_value( } } +/// The dynamic dimension an `AcDbAssocDependency` ties to a dimensional +/// constraint node. +fn dependency_dimension(document: &CadDocument, dependency: Handle) -> Option { + let ObjectType::Associative(object) = document.objects.get(&dependency)? else { + return None; + }; + let AssociativeData::Dependency(dependency) = &object.data else { + return None; + }; + let target = dependency.dependent_on; + matches!(document.get_entity(target)?, EntityType::Dimension(_)).then_some(target) +} + fn owning_composite(data: &AssocConstraintNodeData) -> Option { match data { AssocConstraintNodeData::Geometrical { owner_id, .. } @@ -2250,7 +2348,25 @@ pub(super) fn native_constraint_set( { continue; } - set.add(kind, targets, driving); + let dimension = match &node.data { + AssocConstraintNodeData::Angle { + dimension_dependency, + .. + } + | AssocConstraintNodeData::Distance { + dimension_dependency, + .. + } + | AssocConstraintNodeData::RadiusDiameter { + dimension_dependency, + .. + } => dependency_dimension(document, *dimension_dependency), + _ => None, + }; + let id = set.add(kind, targets, driving); + if let Some(dimension) = dimension { + set.dimensions.insert(id, dimension); + } if let Some(constraint) = set.constraints.last_mut() { constraint.enabled = enabled; constraint.rigid_points = rigid_points; @@ -2429,15 +2545,21 @@ fn materialize_scope( // Pass 1 (read-only): constraint-node shapes. let mut needs_value_dependency = Vec::new(); + let mut dimension_links = Vec::new(); let (mut nodes, entities, referenced_entities) = { let mut builder = GroupBuilder::new(document); for constraint in &set.constraints { - constraint_node( + let node_id = constraint_node( &mut builder, document, constraint, &mut needs_value_dependency, ); + if let Some((node_id, dimension)) = node_id.zip(set.dimensions.get(&constraint.id)) { + if document.get_entity(*dimension).is_some() { + dimension_links.push((node_id, *dimension)); + } + } } let GroupBuilder { nodes, @@ -2527,6 +2649,7 @@ fn materialize_scope( } let mut group_dependencies = geometry_dependencies; + let mut node_names: FxHashMap = FxHashMap::default(); for (node_id, driving) in needs_value_dependency { let Some(driving) = driving else { continue }; let Ok(resolved) = driving.resolve(parameters) else { @@ -2542,6 +2665,7 @@ fn materialize_scope( } DrivingValue::Literal(_) => (format!("d{node_id}"), resolved.to_string()), }; + node_names.insert(node_id, name.clone()); let variable_handle = allocator.variable(network_handle, &name, &formula, resolved); let dependency_id = group_dependencies.len() as i32 + 1; let dep_handle = @@ -2551,6 +2675,18 @@ fn materialize_scope( set_value_dependency(&mut node.data, dep_handle); } } + for (node_id, dimension) in dimension_links { + let Some(name) = node_names.get(&node_id) else { + continue; + }; + let dependency_id = group_dependencies.len() as i32 + 1; + let dep_handle = + allocator.dimension_dependency(group_handle?, dimension, name, dependency_id); + group_dependencies.push(dep_handle); + if let Some(node) = nodes.iter_mut().find(|n| n.node_id == node_id) { + set_dimension_dependency(&mut node.data, dep_handle); + } + } if let Some(group_handle) = group_handle { let group = Assoc2dConstraintGroup { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 2c2d105df..0fdc9a53d 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1402,6 +1402,9 @@ impl Scene { } if self.object_isolation.hides(common.handle) || (!include_preview_hidden && self.preview_hidden.contains(&common.handle)) + || self.hidden_dynamic_dimensions.contains(&common.handle) + // A dynamic dimension never plots. + || (plot_only && self.is_dynamic_dimension(common.handle)) { return false; } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 60821cba1..bf4dec17d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2298,6 +2298,10 @@ pub struct Scene { /// CONSTRAINTNAMEFORMAT: what a dynamic dimension's text shows — /// 0 the parameter name, 1 the value, 2 `name=value`. pub constraint_name_format: u8, + /// DYNCONSTRAINTDISPLAY: whether dynamic dimensions draw at all. + pub dynamic_constraint_display: bool, + /// Dynamic dimensions DCHIDE or DYNCONSTRAINTDISPLAY 0 keep off screen. + hidden_dynamic_dimensions: HashSet, /// Session-only visibility overrides for constraint glyphs. hidden_parametric_constraints: HashSet<( parametric_constraints::ParametricScope, @@ -2629,6 +2633,8 @@ impl Scene { associative_hatch_source_cache: RefCell::new(None), parametric_constraints: Vec::new(), constraint_name_format: 2, + dynamic_constraint_display: true, + hidden_dynamic_dimensions: HashSet::default(), hidden_parametric_constraints: HashSet::default(), shown_parametric_constraints: HashSet::default(), named_parameters: named_parameters::ParameterTable::new(), @@ -5538,6 +5544,7 @@ impl Scene { self.object_isolation.hides(handle) || self.preview_hidden.contains(&handle) || self.command_preview_hidden.contains(&handle) + || self.hidden_dynamic_dimensions.contains(&handle) } /// Replace the command-owned source hide set and refresh only handles whose diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 4f9317a75..eb8e2c0f6 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -361,6 +361,9 @@ pub(crate) fn next_dimensional_parameter_name( .unwrap_or_else(|| "d1".to_string()) } +/// The constraint-bar label that draws a dynamic dimension's lock mark. +pub const DYNAMIC_DIMENSION_GLYPH: &str = "\u{1F512}"; + /// The text a dynamic dimension shows: CONSTRAINTNAMEFORMAT 0 = name, /// 1 = value, 2 = name=value. pub(crate) fn dynamic_dimension_text(name: &str, value: f64, format: u8) -> String { @@ -1561,9 +1564,45 @@ impl super::Scene { self.shown_parametric_constraints.remove(&(scope, *id)); } } + self.refresh_hidden_dynamic_dimensions(); ids.len() } + /// True for a dimension that shows a dimensional constraint. + pub(crate) fn is_dynamic_dimension(&self, handle: Handle) -> bool { + self.parametric_constraints + .iter() + .any(|set| set.dimensions.values().any(|dimension| *dimension == handle)) + } + + /// Re-derives which dynamic dimensions stay off screen (DCHIDE, or + /// DYNCONSTRAINTDISPLAY 0 for all of them) and redraws the ones that + /// changed. + pub(crate) fn refresh_hidden_dynamic_dimensions(&mut self) { + let desired: rustc_hash::FxHashSet = self + .parametric_constraints + .iter() + .flat_map(|set| { + set.dimensions.iter().filter_map(|(id, dimension)| { + (!self.dynamic_constraint_display + || !self.is_parametric_constraint_visible(set.scope, *id)) + .then_some(*dimension) + }) + }) + .collect(); + if desired == self.hidden_dynamic_dimensions { + return; + } + let changes: Vec<_> = self + .hidden_dynamic_dimensions + .symmetric_difference(&desired) + .copied() + .map(|handle| (handle, super::ChangeKind::Modified)) + .collect(); + self.hidden_dynamic_dimensions = desired; + self.bump_entities(&changes); + } + /// Infers relations already present in the selected geometry. pub fn inferred_parametric_constraints( &self, @@ -1937,7 +1976,8 @@ impl super::Scene { let cam = self.camera.borrow(); (cam.view_proj_rte(bounds), cam.eye()) }; - set.constraints + let mut placements = set + .constraints .iter() // A dynamic dimension is its constraint's whole display. .filter(|c| c.enabled && !set.dimensions.contains_key(&c.id)) @@ -2031,7 +2071,59 @@ impl super::Scene { }) .collect::>() }) - .collect() + .collect::>(); + // A dynamic dimension carries the reference's lock mark at the + // start of its text instead of a constraint bar. + for (id, dimension) in &set.dimensions { + let Some(constraint) = set.get(*id) else { + continue; + }; + if !constraint.enabled || self.entity_temporarily_hidden(*dimension) { + continue; + } + let Some(acadrust::EntityType::Dimension(entity)) = self.document.get_entity(*dimension) + else { + continue; + }; + let Some((anchor, outward)) = crate::entities::dimension::dynamic_dimension_lock_anchor( + &self.document, + entity, + self.annotation_scale as f64, + ) else { + continue; + }; + let project = |point: Vector3| { + crate::scene::pick::grip::project_rte( + glam::DVec3::new(point.x, point.y, point.z), + view_rot, + eye, + bounds, + ) + }; + let (Some(screen), Some(outward_screen)) = ( + project(anchor), + project(Vector3::new( + anchor.x + outward.x, + anchor.y + outward.y, + anchor.z + outward.z, + )), + ) else { + continue; + }; + let direction = (outward_screen - screen).normalize_or(glam::Vec2::NEG_X); + let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); + if point.x.is_finite() && point.y.is_finite() { + placements.push(( + *id, + point, + direction.to_array(), + DYNAMIC_DIMENSION_GLYPH.to_string(), + false, + Vec::new(), + )); + } + } + placements } /// Hit-tests screen point `p` (same coordinate space as `p_full` in the diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 59a5d30ca..c337bf2e7 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -2441,7 +2441,31 @@ fn solve_scope( }) }) .flatten(); - let pinned = axis_pin.unwrap_or(*reference); + // A transformed entity whose own end points a driving dimension + // measures keeps only the first constraint point where the transform + // put it; the dimension pulls the other end back to its value (a + // scaled line keeps its d1), as in the reference. + let dimension_pin = (reference.marker.is_none() && axis_pin.is_none()) + .then(|| { + constraints.iter().find_map(|c| { + (c.enabled + && c.driving_param.is_some() + && matches!( + c.kind, + ConstraintKind::Distance + | ConstraintKind::DistanceX + | ConstraintKind::DistanceY + | ConstraintKind::DistanceDirected + ) + && c.refs.len() >= 2 + && c.refs + .iter() + .all(|r| r.entity == reference.entity && r.marker.is_some())) + .then(|| c.refs[0]) + }) + }) + .flatten(); + let pinned = axis_pin.or(dimension_pin).unwrap_or(*reference); // The re-aligned entity keeps the length the transform gave it (a // scaled vertical line stays scaled), not its pre-edit length. if axis_pin.is_some() { diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 74a828a09..fbf49514c 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -39,6 +39,11 @@ fn is_fixed_glyph(label: &str) -> bool { label == "F" || label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH } +/// The lock mark a dynamic dimension carries (`DYNAMIC_DIMENSION_GLYPH`). +fn is_dynamic_dimension_glyph(label: &str) -> bool { + label == crate::scene::parametric_constraints::DYNAMIC_DIMENSION_GLYPH +} + /// Vertical's two axis-mark labels (`parametric_constraints::vertical_glyph_label`). fn is_vertical_glyph(label: &str) -> bool { label == "│" || label == crate::scene::parametric_constraints::VERTICAL_POINTS_GLYPH @@ -48,7 +53,11 @@ 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²" || is_fixed_glyph(label) || is_vertical_glyph(label) { + if label == "G²" + || is_fixed_glyph(label) + || is_vertical_glyph(label) + || is_dynamic_dimension_glyph(label) + { let side = CONSTRAINT_GLYPH_SIZE + CONSTRAINT_GLYPH_PAD_Y * 2.0; return Size::new(side, side); } @@ -109,15 +118,10 @@ fn draw_concentric_constraint_glyph( fn draw_fixed_constraint_glyph( frame: &mut canvas::Frame, center: Point, - text: Color, + lock: 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 { @@ -2114,7 +2118,10 @@ impl canvas::Program for SelectionCanvas { }) .into(), ); - frame.fill(&pill, bg); + // A dynamic dimension's lock sits bare on the drawing. + if !is_dynamic_dimension_glyph(label) { + frame.fill(&pill, bg); + } if *is_selected { frame.stroke( &pill, @@ -2132,14 +2139,18 @@ 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_dynamic_dimension_glyph(label) { + draw_fixed_constraint_glyph(&mut frame, glyph_center, fg, bg, false); } else if is_fixed_glyph(label) { - draw_fixed_constraint_glyph( - &mut frame, - glyph_center, - fg, - bg, - label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH, - ); + let point = + label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH; + // The object-mode lock reads red, the point-mode one white. + let lock = if point { + fg + } else { + Color::from_rgb8(214, 76, 76) + }; + draw_fixed_constraint_glyph(&mut frame, glyph_center, lock, bg, point); } else if is_vertical_glyph(label) { draw_vertical_constraint_glyph( &mut frame, From 52d766b48889ee7c407ca67a6c6a00650dddd3eb Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 17:28:09 +0800 Subject: [PATCH 06/21] fix(plot): scale wide polyline band width into paper units through viewports `viewport_content_wires` projects model-space wires into paper coordinates for plotting: the points and the linetype stations are multiplied by the viewport scale, but `world_width` (the band width of a wide polyline) was copied unchanged. The PDF / print exporter strokes that band at `world_width` in paper millimetres, so a 15-unit-wide bus bar seen through a 1:100 viewport came out as a ~21 mm black blob instead of 0.15 mm, and every single-line diagram sheet plotted as solid black shapes. Scale `world_width` by the viewport scale alongside the points, and keep the per-point taper widths in step (or drop them when clipping changed the vertex count, so they cannot misalign). Negative (fixed pixel) and zero (lineweight) widths are left untouched. Adds a regression test that plots a 15-unit-wide polyline through a 1:10 viewport and expects a 1.5-unit band in the projected plot wires. Co-Authored-By: Claude Opus 5 (1M context) --- src/scene/project.rs | 16 ++++++ tests/viewport_wide_polyline_plot_width.rs | 60 ++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 tests/viewport_wide_polyline_plot_width.rs diff --git a/src/scene/project.rs b/src/scene/project.rs index 4d67327ac..fa8902a94 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -321,6 +321,22 @@ impl Scene { } let mut out = wire.clone(); out.points = clipped; + // A wide polyline's band width is a geometric width in model + // units; the points were just projected into paper units, so + // the band (and any per-point taper) must follow the same + // scale or the PDF / print exporter strokes a 15-unit bus bar + // as 15 mm of paper. Negative values are fixed pixel widths + // and zero means "use the lineweight" — both stay as they are. + if out.world_width > 0.0 { + out.world_width = wire.world_width * scale; + if wire.taper_widths.len() == out.points.len() { + out.taper_widths = wire.taper_widths.iter().map(|w| w * scale).collect(); + } else { + // Clipping changed the vertex count, so the per-point + // widths no longer line up; fall back to a constant band. + out.taper_widths.clear(); + } + } if let Some(source_length) = source_length { for station in &mut clipped_stations { *station *= scale; diff --git a/tests/viewport_wide_polyline_plot_width.rs b/tests/viewport_wide_polyline_plot_width.rs new file mode 100644 index 000000000..b527ee5ea --- /dev/null +++ b/tests/viewport_wide_polyline_plot_width.rs @@ -0,0 +1,60 @@ +//! A wide polyline seen through a paper-space viewport must carry its band +//! width in PAPER units once projected for plotting. `viewport_content_wires` +//! scales the points and the linetype stations by the viewport scale but used +//! to leave `world_width` in model units, so the PDF / print exporter stroked a +//! 15-unit-wide bus bar at 15 mm of paper (a 1:100 viewport should give +//! 0.15 mm): every single-line diagram plotted as black blobs. +#![cfg(not(target_arch = "wasm32"))] + +use acadrust::entities::{LwPolyline, LwVertex, Viewport}; +use acadrust::types::{Vector2, Vector3}; +use acadrust::EntityType; +use OpenCADStudio::scene::Scene; + +#[test] +fn viewport_projection_scales_wide_polyline_band_width() { + // A fresh `Scene::new()` has no paper layout block yet; reuse the small + // fixture the image-export test plots through, which defines `Layout1`. + let mut scene = Scene::new(); + scene.document = OpenCADStudio::io::load_bytes( + "embedded-image-print.dxf", + include_bytes!("fixtures/embedded-image-print.dxf").to_vec(), + ) + .expect("fixture DXF with a Layout1"); + scene.set_current_layout("Model".into()); + + // Model space: a 15-unit-wide bus bar 100 units long. + let mut bar = LwPolyline::new(); + bar.vertices = vec![ + LwVertex::new(Vector2::new(0.0, 0.0)), + LwVertex::new(Vector2::new(100.0, 0.0)), + ]; + bar.constant_width = 15.0; + scene.add_entity(EntityType::LwPolyline(bar)); + + // Paper space: a 20 x 20 mm viewport showing 200 model units of height, + // i.e. a 1:10 view (0.1 paper units per model unit). + scene.set_current_layout("Layout1".into()); + let mut viewport = Viewport::new(); + viewport.center = Vector3::new(50.0, 50.0, 0.0); + viewport.width = 20.0; + viewport.height = 20.0; + viewport.view_height = 200.0; + viewport.view_center = Vector3::new(50.0, 0.0, 0.0); + viewport.id = 2; + viewport.status.is_on = true; + scene.add_entity(EntityType::Viewport(viewport)); + + let (_paper, model) = scene.plot_wire_groups(None); + let band = model + .iter() + .find(|w| w.world_width > 0.0) + .expect("the wide polyline must reach the plot wire set with a band width"); + + // 15 model units * 0.1 = 1.5 paper units. Before the fix this stayed 15.0. + assert!( + (band.world_width - 1.5).abs() < 1e-3, + "band width must be scaled into paper units: got {}", + band.world_width + ); +} From 0b522ceb862c1ba7ba515f64054643c8073c593f Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 18:14:26 +0800 Subject: [PATCH 07/21] fix(text): size TrueType fallback ideographs by the em box, like a big font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a text style's SHX / stroke font lacks a character, the glyph comes from a system TrueType font through `ttf_glyph::fallback_glyph`. That substitute was normalised to the Latin cap height (9 units = text height), which is right for Latin, Greek or Cyrillic stand-ins but wrong for CJK: an ideograph fills the whole em square, and 1 em is 1.3–1.5 cap heights in every common CJK font (DFKai-SB has no OS/2 cap height at all, so it fell to the 0.7 em guess). Every substituted Chinese character therefore came out ~40 % taller and wider than the big font (`chineset.shx`, `hztxt.shx`, …) the drawing was authored with, and whole lines of notes ran past their frames. A big font draws an ideograph one text height tall and one text height wide, so size full-width / ideographic fallback glyphs by their em box instead. Other scripts keep the cap-height normalisation. Real TrueType styles (`Face::Ttf`, shaped runs) are unchanged. Adds a unit test that a fallback `中` advances by about one text height and its ink fits the text height (skipped on machines without a CJK font). Co-Authored-By: Claude Opus 5 (1M context) --- src/scene/text/ttf_glyph.rs | 80 ++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index 2c76b291c..0024034b2 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -176,6 +176,42 @@ pub fn glyph(family: &str, ch: char) -> Option> { built } +/// Font-unit → 9-unit-em-box factor: the whole em square maps onto the text +/// height. This is how an SHX big font (`chineset.shx`, `hztxt.shx`, …) +/// sizes its ideographs — a CJK glyph is as tall as the text height and one +/// text height wide — so a TrueType glyph standing in for a missing big-font +/// glyph must use the same box, not the Latin cap height. Cap-height scaling +/// makes ideographs ~1.3–1.5× too big (1 em ≈ 1.3–1.5 cap heights in CJK +/// fonts), so every line of substituted Chinese text ran past its frame. +fn em_scale(face: &ttf_parser::Face) -> f32 { + CAP_UNITS / face.units_per_em().max(1) as f32 +} + +/// Ideographic / full-width characters: the ones a big font would supply and +/// that sit on an em box rather than the Latin cap height. +pub(crate) fn is_full_width(ch: char) -> bool { + matches!( + ch as u32, + 0x1100..=0x11FF // Hangul Jamo + | 0x2E80..=0x2FDF // CJK / Kangxi radicals + | 0x2FF0..=0x303F // ideographic description, CJK symbols & punctuation + | 0x3040..=0x30FF // Hiragana, Katakana + | 0x3100..=0x312F // Bopomofo + | 0x3130..=0x318F // Hangul compatibility Jamo + | 0x3190..=0x31FF // Kanbun, Bopomofo ext., CJK strokes, Katakana ext. + | 0x3200..=0x33FF // enclosed CJK, CJK compatibility + | 0x3400..=0x4DBF // CJK ext. A + | 0x4E00..=0x9FFF // CJK unified ideographs + | 0xA960..=0xA97F // Hangul Jamo ext. A + | 0xAC00..=0xD7FF // Hangul syllables, Jamo ext. B + | 0xF900..=0xFAFF // CJK compatibility ideographs + | 0xFE30..=0xFE4F // CJK compatibility forms + | 0xFF01..=0xFF60 // full-width ASCII variants + | 0xFFE0..=0xFFE6 // full-width symbols + | 0x20000..=0x3FFFF // CJK ext. B–H + ) +} + /// Font-unit → 9-unit-cap-height factor for a parsed face. Cap height comes /// from the OS/2 table; absent, we approximate it as 0.7 × units-per-em. fn cap_scale(face: &ttf_parser::Face) -> f32 { @@ -379,7 +415,16 @@ fn build_fallback(ch: char) -> Option> { let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0); let font = fs.get_font(g.font_id, g.font_weight)?; let face = ttf_parser::Face::parse(font.data(), face_index).ok()?; - let k = cap_scale(&face); + // A fallback glyph stands in for a stroke / SHX font that lacks + // the character. For an ideograph that font would have been a + // big font, whose glyphs fill the text height — so size the + // substitute by its em box. Everything else keeps the cap-height + // normalisation that lines it up with the Latin stroke glyphs. + let k = if is_full_width(ch) { + em_scale(&face) + } else { + cap_scale(&face) + }; let gid = ttf_parser::GlyphId(g.glyph_id); let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k; let mut fl = OutlineFlattener::new(k); @@ -609,6 +654,39 @@ mod tests { assert!(!run.glyphs.is_empty()); } + #[test] + fn fallback_ideograph_fills_the_text_height_like_a_big_font() { + // An SHX big font draws an ideograph one text height tall and one + // text height wide. The TrueType stand-in must match that box (9 + // units), not the ~12–14 units the Latin cap-height normalisation + // gives a CJK em square — that overrun pushed every substituted + // Chinese line past its frame. Tolerated when the machine has no + // CJK font at all. + let Some(g) = fallback_glyph('中') else { + eprintln!("no CJK system font; skipping"); + return; + }; + assert!( + (g.advance - CAP_UNITS).abs() < 0.6, + "ideograph advance must be about one text height: got {}", + g.advance + ); + let ink_h = g + .strokes + .iter() + .flatten() + .map(|p| p[1]) + .fold((f32::MAX, f32::MIN), |(lo, hi), y| (lo.min(y), hi.max(y))); + assert!( + ink_h.1 - ink_h.0 <= CAP_UNITS * 1.05, + "ideograph ink must fit the text height: {:?}", + ink_h + ); + // Latin fallback keeps the cap-height convention (shares a baseline + // with the stroke glyphs around it). + assert!(!is_full_width('A') && is_full_width('中') && is_full_width(',')); + } + #[test] fn shape_run_positions_glyphs() { let fams = sysfont::families(); From 0b5c6863718abc0c44daf832f38331c4f542e0ab Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:36:01 +0300 Subject: [PATCH 08/21] Dynamic dimensions: constant screen size, transform keeps the second constraint point Every dynamic dimension gets a DIMSCALE override for 12 px text, refreshed per zoom band, at creation and after load. A SCALE/ROTATE of an entity whose own end points a driving dimension measures now pins its second constraint point so the dimension moves the first end, as the reference does. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 1 + src/app/update/viewport.rs | 2 + src/scene/dwg_native_constraints.rs | 3 ++ src/scene/mod.rs | 4 ++ src/scene/parametric_constraints.rs | 60 +++++++++++++++++++++++++++++ src/scene/parametric_solve.rs | 9 +++-- 6 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1c435fd22..a1a093528 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3688,6 +3688,7 @@ impl OpenCADStudio { ); // DYNCONSTRAINTDISPLAY 0 also keeps a new dynamic dimension off screen. self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); + self.tabs[i].scene.refresh_dynamic_dimension_scales(true); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c5279e5a8..dee93d74c 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -785,6 +785,8 @@ impl OpenCADStudio { pub(super) fn on_tick(&mut self, t: Instant) -> Task { let i = self.active_tab; self.tabs[i].scene.update(t - self.start); + // Dynamic dimensions keep their screen size across zoom bands. + self.tabs[i].scene.refresh_dynamic_dimension_scales(false); // If the camera moved since we last synced, write it back to // the document and mark the file dirty. diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index dc8174bf5..f1e4a208d 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -2810,6 +2810,9 @@ impl Scene { pub(crate) fn load_parametric_constraints_from_document(&mut self) { self.parametric_constraints.clear(); + // Dynamic dimensions read from the file get their screen size on the + // first tick after the view is known. + self.dynamic_dimension_band = 0.0; let owners: Vec<_> = self .document .block_records diff --git a/src/scene/mod.rs b/src/scene/mod.rs index bf4dec17d..aae7b130d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2302,6 +2302,9 @@ pub struct Scene { pub dynamic_constraint_display: bool, /// Dynamic dimensions DCHIDE or DYNCONSTRAINTDISPLAY 0 keep off screen. hidden_dynamic_dimensions: HashSet, + /// The zoom band (`quantize_wpp`) the dynamic dimensions were last + /// scaled for; 0 until the first scaling. + dynamic_dimension_band: f32, /// Session-only visibility overrides for constraint glyphs. hidden_parametric_constraints: HashSet<( parametric_constraints::ParametricScope, @@ -2635,6 +2638,7 @@ impl Scene { constraint_name_format: 2, dynamic_constraint_display: true, hidden_dynamic_dimensions: HashSet::default(), + dynamic_dimension_band: 0.0, hidden_parametric_constraints: HashSet::default(), shown_parametric_constraints: HashSet::default(), named_parameters: named_parameters::ParameterTable::new(), diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index eb8e2c0f6..a34b1989b 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -364,6 +364,9 @@ pub(crate) fn next_dimensional_parameter_name( /// The constraint-bar label that draws a dynamic dimension's lock mark. pub const DYNAMIC_DIMENSION_GLYPH: &str = "\u{1F512}"; +/// The on-screen text height of a dynamic dimension, in pixels. +pub const DYNAMIC_DIMENSION_TEXT_PX: f32 = 12.0; + /// The text a dynamic dimension shows: CONSTRAINTNAMEFORMAT 0 = name, /// 1 = value, 2 = name=value. pub(crate) fn dynamic_dimension_text(name: &str, value: f64, format: u8) -> String { @@ -1575,6 +1578,63 @@ impl super::Scene { .any(|set| set.dimensions.values().any(|dimension| *dimension == handle)) } + /// Gives every dynamic dimension a DIMSCALE override that keeps its + /// text, arrows and offsets at a screen size (the reference draws them + /// at a constant pixel size whatever the zoom). Runs once per zoom band; + /// `force` re-applies after a dimension was created or loaded. + pub fn refresh_dynamic_dimension_scales(&mut self, force: bool) { + let Some(band) = super::Scene::quantize_wpp(self.world_per_pixel()) else { + return; + }; + if !force && band == self.dynamic_dimension_band { + return; + } + self.dynamic_dimension_band = band; + let handles: Vec = self + .parametric_constraints + .iter() + .flat_map(|set| set.dimensions.values().copied()) + .collect(); + let mut changes = Vec::new(); + for handle in handles { + let Some(acadrust::EntityType::Dimension(dimension)) = self.document.get_entity(handle) + else { + continue; + }; + let style_name = dimension.base().style_name.clone(); + let text_height = self + .document + .dim_styles + .iter() + .find(|style| { + style.name.eq_ignore_ascii_case(&style_name) + || (style_name.trim().is_empty() + && style.name.eq_ignore_ascii_case("Standard")) + }) + .map(|style| style.dimtxt) + .filter(|height| *height > 1e-9) + .unwrap_or(0.18); + let scale = f64::from(band) * f64::from(DYNAMIC_DIMENSION_TEXT_PX) / text_height; + let current = crate::entities::dim_override::real( + &dimension.base().common.extended_data, + crate::entities::dim_override::DIMSCALE, + ); + if current.is_some_and(|value| (value - scale).abs() < 1e-9) { + continue; + } + crate::entities::dim_override::set( + &mut self.document, + handle, + crate::entities::dim_override::DIMSCALE, + Some(acadrust::xdata::XDataValue::Real(scale)), + ); + changes.push((handle, super::ChangeKind::Modified)); + } + if !changes.is_empty() { + self.bump_entities(&changes); + } + } + /// Re-derives which dynamic dimensions stay off screen (DCHIDE, or /// DYNCONSTRAINTDISPLAY 0 for all of them) and redraws the ones that /// changed. diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index c337bf2e7..3101a2103 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -2442,9 +2442,10 @@ fn solve_scope( }) .flatten(); // A transformed entity whose own end points a driving dimension - // measures keeps only the first constraint point where the transform - // put it; the dimension pulls the other end back to its value (a - // scaled line keeps its d1), as in the reference. + // measures keeps only its second constraint point where the transform + // put it; the dimension pulls the first end back to its value (a line + // scaled 2x about its start keeps its d1 by moving that start), as in + // the reference. let dimension_pin = (reference.marker.is_none() && axis_pin.is_none()) .then(|| { constraints.iter().find_map(|c| { @@ -2461,7 +2462,7 @@ fn solve_scope( && c.refs .iter() .all(|r| r.entity == reference.entity && r.marker.is_some())) - .then(|| c.refs[0]) + .then(|| c.refs[1]) }) }) .flatten(); From aa51765a62224eefe0af2ee650f374b594cb25b9 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:57:05 +0300 Subject: [PATCH 09/21] Dynamic dimensions: reference text format, horizontal text, lock after the text A plain number shows as typed (d1=100), an expression under the fx: prefix (fx: d2=d1*2); the text stays horizontal through DIMTIH/DIMTOH overrides and the lock mark follows the text. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 1 + src/entities/dimension.rs | 12 ++---- src/scene/parametric_constraints.rs | 64 +++++++++++++++++++++-------- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index a1a093528..84b3f3b6d 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3611,6 +3611,7 @@ impl OpenCADStudio { }; let text = dynamic_dimension_text( &name, + &expression, value, self.tabs[i].scene.constraint_name_format, ); diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 906dea36d..6f7efb264 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -6295,8 +6295,8 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool } } -/// Where a dynamic dimension's lock mark sits: just before the text on its -/// baseline, with the direction that points away from the text. +/// Where a dynamic dimension's lock mark sits: just after the (horizontal) +/// text, with the direction that points away from the text. pub(crate) fn dynamic_dimension_lock_anchor( document: &CadDocument, dim: &Dimension, @@ -6317,13 +6317,7 @@ pub(crate) fn dynamic_dimension_lock_anchor( let stack_scale = style.map(dimtfac_or_one).unwrap_or(1.0); let half_width = text_cells(&value, stack_scale) * text_height * CELL_WIDTH * 0.5; let pos = dimension_text_pos_f64(dim, style, text_height, dim_scale); - let rot = if dim.base().text_rotation.abs() > 1e-9 { - dim.base().text_rotation - } else { - dimension_text_natural_rotation(dim) - }; - let (sr, cr) = rot.sin_cos(); - let outward = Vector3::new(-cr, -sr, 0.0); + let outward = Vector3::new(1.0, 0.0, 0.0); Some(( Vector3::new( pos.x + outward.x * half_width, diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index a34b1989b..579eaa90b 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -368,12 +368,23 @@ pub const DYNAMIC_DIMENSION_GLYPH: &str = "\u{1F512}"; pub const DYNAMIC_DIMENSION_TEXT_PX: f32 = 12.0; /// The text a dynamic dimension shows: CONSTRAINTNAMEFORMAT 0 = name, -/// 1 = value, 2 = name=value. -pub(crate) fn dynamic_dimension_text(name: &str, value: f64, format: u8) -> String { +/// 1 = value, 2 = name=value. A plain number shows as typed (`d1=100`); an +/// expression shows the expression under the reference's `fx:` prefix +/// (`fx: d2=d1*2`). +pub(crate) fn dynamic_dimension_text(name: &str, source: &str, value: f64, format: u8) -> String { + let source = source.trim(); + let literal = source.parse::().is_ok(); + let shown = if literal { + source.to_string() + } else { + measured_expression(value) + }; match format { 0 => name.to_string(), - 1 => format!("{value:.4}"), - _ => format!("{name}={value:.4}"), + 1 if literal => shown, + 1 => format!("fx: {shown}"), + _ if literal => format!("{name}={shown}"), + _ => format!("fx: {name}={source}"), } } @@ -1477,14 +1488,24 @@ impl super::Scene { let Some(constraint) = set.get(*id) else { continue; }; - let (name, value) = match &constraint.driving_param { - Some(DrivingValue::Named(name)) => { - (name.clone(), table.resolve(name).unwrap_or(f64::NAN)) + let (name, source, value) = match &constraint.driving_param { + Some(DrivingValue::Named(name)) => ( + name.clone(), + table + .get(name) + .map(|parameter| parameter.source.clone()) + .unwrap_or_default(), + table.resolve(name).unwrap_or(f64::NAN), + ), + Some(DrivingValue::Literal(value)) => { + (String::new(), measured_expression(*value), *value) } - Some(DrivingValue::Literal(value)) => (String::new(), *value), None => continue, }; - updates.push((*dimension, dynamic_dimension_text(&name, value, format))); + updates.push(( + *dimension, + dynamic_dimension_text(&name, &source, value, format), + )); } } for (handle, text) in updates { @@ -1615,19 +1636,30 @@ impl super::Scene { .filter(|height| *height > 1e-9) .unwrap_or(0.18); let scale = f64::from(band) * f64::from(DYNAMIC_DIMENSION_TEXT_PX) / text_height; - let current = crate::entities::dim_override::real( - &dimension.base().common.extended_data, - crate::entities::dim_override::DIMSCALE, - ); - if current.is_some_and(|value| (value - scale).abs() < 1e-9) { + use crate::entities::dim_override as ov; + let xdata = &dimension.base().common.extended_data; + let current = ov::real(xdata, ov::DIMSCALE); + // The reference draws dynamic dimension text horizontally. + let horizontal = ov::int(xdata, ov::DIMTIH) == Some(1) && ov::int(xdata, ov::DIMTOH) == Some(1); + if current.is_some_and(|value| (value - scale).abs() < 1e-9) && horizontal { continue; } - crate::entities::dim_override::set( + ov::set( &mut self.document, handle, - crate::entities::dim_override::DIMSCALE, + ov::DIMSCALE, Some(acadrust::xdata::XDataValue::Real(scale)), ); + if !horizontal { + for code in [ov::DIMTIH, ov::DIMTOH] { + ov::set( + &mut self.document, + handle, + code, + Some(acadrust::xdata::XDataValue::Integer16(1)), + ); + } + } changes.push((handle, super::ChangeKind::Modified)); } if !changes.is_empty() { From 6969c197466e72c8e68a3230594f299ea30dae9c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:52:37 +0300 Subject: [PATCH 10/21] Aligned dimensional constraint: Point & line, 2Lines, value and property parity DCALIGNED opens with `[Object/Point & line/2Lines] `. Point & line asks `Specify constraint point or [Line] :` then `Select line:` (or the line first and `Select constraint point:`), measures perpendicular to the line from the constraint point to the line end nearest to it, and stores a directed distance with the line as direction reference. 2Lines asks `Select first line:` and `Select second line to make parallel:`, adds a Parallel constraint solved with the first line pinned, then measures on the solved geometry between the nearest ends. Unsupported line picks report the reference's message; the same measurement again reports `The constraint already exists on the selected objects.` and ends the command; an invalid expression reports both reference lines and asks again. The offered default and seeded expression carry twelve significant digits; a typed value moves the second point (or the other object) while the first point and its measured line stay, negative values keep their sign in the parameter and use their magnitude, zero folds the points together, and the dynamic dimension's origins follow every solve. The lock mark sits after the text, a selected dynamic dimension shows triangle grips at its points and the text square, and its Properties show the Constraint section (Name and Expression editable) plus Text rotation. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 230 ++++++++++++++-- src/app/properties.rs | 120 ++++++++ src/app/update/command.rs | 10 + src/command.rs | 20 ++ src/entities/dimension.rs | 12 +- src/modules/parametric/dim_constraint.rs | 332 +++++++++++++++++++++-- src/scene/parametric_constraints.rs | 179 +++++++++++- src/scene/parametric_solve.rs | 16 +- 8 files changed, 855 insertions(+), 64 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1c435fd22..29a1bbd02 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1324,7 +1324,8 @@ impl OpenCADStudio { /// table, so unlike that whole-table Apply this can cheaply scope the /// re-solve to just the entities that actually reference it. fn resolve_named_parameter_edit(&mut self, i: usize, name: &str) { - let touched: Vec = self.tabs[i] + let readers: Vec<&crate::scene::parametric_constraints::ParametricConstraint> = self + .tabs[i] .scene .parametric_constraints .iter() @@ -1333,8 +1334,24 @@ impl OpenCADStudio { c.enabled && matches!(&c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(n)) if n == name) }) + .collect(); + let touched: Vec = readers + .iter() .flat_map(|c| c.refs.iter().map(|r| r.entity)) .collect(); + // A changed value moves a dimensional constraint's second point; its + // first point (and the line it measures perpendicular to) stays, as + // in the reference. + let anchors: Vec = readers + .iter() + .flat_map(|c| { + crate::scene::parametric_constraints::dimensional_anchor_refs( + &self.tabs[i].scene.document, + &c.refs, + ) + }) + .filter(|reference| reference.marker.is_some()) + .collect(); self.tabs[i].dirty = true; if touched.is_empty() { self.tabs[i].scene.sync_native_parametric_graph(); @@ -1344,7 +1361,9 @@ impl OpenCADStudio { .into_iter() .map(|h| (h, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i] + .scene + .bump_entities_with_parametric_policy(&changes, &anchors, false); } /// Commits one field of one Parameters-section row (Properties panel) — @@ -1481,6 +1500,52 @@ impl OpenCADStudio { Task::none() } + /// Commits a dynamic dimension's Name or Expression row: the typed text + /// goes to the Parameters-section row of the constraint's parameter. + pub(super) fn on_dynamic_dimension_field_commit( + &mut self, + field: &'static str, + param_field: crate::ui::window::named_parameters::ParamField, + ) -> Task { + use crate::scene::named_parameters::DrivingValue; + use crate::scene::parametric_constraints::dynamic_dimension_constraint; + use crate::ui::properties::FieldKey; + let i = self.active_tab; + self.tabs[i].properties.active_field = None; + let Some(typed) = self.tabs[i] + .properties + .edit_buf + .remove(&FieldKey::Geom(field)) + else { + return Task::none(); + }; + let handles = self.property_target_handles(i); + let name = handles.first().and_then(|handle| { + let (_, constraint) = + dynamic_dimension_constraint(&self.tabs[i].scene.parametric_constraints, *handle)?; + match &constraint.driving_param { + Some(DrivingValue::Named(name)) => Some(name.clone()), + _ => None, + } + }); + let index = name.and_then(|name| { + self.tabs[i] + .scene + .named_parameters() + .iter() + .position(|parameter| parameter.name == name) + }); + let Some(index) = index else { + self.refresh_properties(); + return Task::none(); + }; + self.tabs[i] + .properties + .edit_buf + .insert(FieldKey::Param(index, param_field), typed); + self.on_prop_param_commit(index, param_field) + } + /// Removes Parameters-section row `index` immediately. Any /// constraint that referenced it gets the same "undefined reference" /// resolve-failure treatment a formula's own bad reference already gets @@ -3572,26 +3637,62 @@ impl OpenCADStudio { second_point, location, axis, + direction, name, expression, label, } => { use crate::modules::annotate::{aligned_dim, linear_dim}; use crate::scene::named_parameters::{is_valid_name, DrivingValue}; - use crate::scene::parametric_constraints::{dynamic_dimension_text, ConstraintKind}; + use crate::scene::parametric_constraints::{ + distance_direction_type, dynamic_dimension_text, ConstraintKind, + }; let scope = self.tabs[i].current_parametric_scope(); - let refs = vec![first, second]; + let mut refs = vec![first, second]; + refs.extend(direction); + // The same measurement between the same references is + // refused after the value, and the command ends. + let duplicate = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|c| { + c.enabled + && c.kind == kind + && c.refs.len() == refs.len() + && c.refs[..2].contains(&first) + && c.refs[..2].contains(&second) + && c.refs.get(2) == refs.get(2) + }) + }); + if duplicate { + self.command_line + .push_output("The constraint already exists on the selected objects."); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + return Task::none(); + } // A refused value keeps the prompt open, as the reference's - // editor does. + // editor does. A directed distance validates as the plain + // two-point distance it also is. + let (validate_kind, validate_refs) = if kind == ConstraintKind::DistanceDirected { + (ConstraintKind::Distance, &refs[..2]) + } else { + (kind, &refs[..]) + }; if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( - kind, - &refs, + validate_kind, + validate_refs, Some(&DrivingValue::Literal(1.0)), ) { self.reprompt_active_command(i, message); return Task::none(); } + let anchors = crate::scene::parametric_constraints::dimensional_anchor_refs( + &self.tabs[i].scene.document, + &refs, + ); let name = name.trim().to_string(); if !is_valid_name(&name) { self.reprompt_active_command(i, "Invalid parameter name."); @@ -3605,7 +3706,11 @@ impl OpenCADStudio { let value = match table.resolve(&name) { Ok(value) if value.is_finite() => value, _ => { - self.reprompt_active_command(i, "Invalid expression."); + self.command_line.push_error("Invalid expression."); + self.reprompt_active_command( + i, + "The parameter is used in an expression which results in an invalid value for a dimensional constraint.", + ); return Task::none(); } }; @@ -3653,8 +3758,10 @@ impl OpenCADStudio { crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) }); let mut touched = vec![first.entity]; - if second.entity != first.entity { - touched.push(second.entity); + for reference in [Some(second), direction].into_iter().flatten() { + if !touched.contains(&reference.entity) { + touched.push(reference.entity); + } } let pending = self.begin_undo(i, label, touched.len() + 1, false); self.tabs[i] @@ -3665,6 +3772,12 @@ impl OpenCADStudio { let dimension = self.tabs[i].scene.add_entity(entity); let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); let id = set.add(kind, refs, Some(DrivingValue::Named(name))); + if direction.is_some() { + if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) { + constraint.distance_direction_type = + distance_direction_type::PERPENDICULAR_TO_LINE; + } + } set.dimensions.insert(id, dimension); self.tabs[i].scene.note_parametric_constraint_applied( scope, @@ -3679,13 +3792,13 @@ impl OpenCADStudio { .into_iter() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - // The first point stays; a value other than the measured one - // moves the second, as in the reference. - self.tabs[i].scene.bump_entities_with_parametric_policy( - &changes, - &[first], - self.constraint_solve_mode, - ); + // The first point (and a line it belongs to that the distance + // runs perpendicular to) stays; a value other than the + // measured one moves the second, as in the reference. Not + // `retain_size`: the typed value is the new length. + self.tabs[i] + .scene + .bump_entities_with_parametric_policy(&changes, &anchors, false); // DYNCONSTRAINTDISPLAY 0 also keeps a new dynamic dimension off screen. self.tabs[i].scene.refresh_hidden_dynamic_dimensions(); self.tabs[i].dirty = true; @@ -3696,6 +3809,89 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::MakeParallel { + first_line, + first_ends, + second_line, + second_ends, + } => { + use crate::scene::parametric_constraints::{resolve_point, ConstraintKind}; + let scope = self.tabs[i].current_parametric_scope(); + let refs = [first_line, second_line]; + if let Err(message) = + self.tabs[i] + .scene + .validate_parametric_constraint(ConstraintKind::Parallel, &refs, None) + { + return self.apply_cmd_result(CmdResult::ReportError(message.to_string())); + } + let already = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|c| { + c.enabled + && c.kind == ConstraintKind::Parallel + && c.refs.contains(&first_line) + && c.refs.contains(&second_line) + }) + }); + if !already { + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) + }); + let pending = self.begin_undo(i, "Parallel constraint", 1, true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + let id = set.add(ConstraintKind::Parallel, refs.to_vec(), None); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + // The first line stays; the second turns parallel to it. + self.tabs[i].scene.bump_entities_with_parametric_policy( + &[(second_line.entity, crate::scene::ChangeKind::Modified)], + &first_ends, + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + let ends = { + let document = &self.tabs[i].scene.document; + document.get_entity(second_line.entity).and_then(|entity| { + let world = |reference: crate::scene::parametric_constraints::ParametricRef| { + let point = resolve_point(entity, reference.marker?)?; + Some((reference, glam::DVec3::new(point.x, point.y, point.z))) + }; + Some([world(second_ends[0])?, world(second_ends[1])?]) + }) + }; + let Some(ends) = ends else { + return self.apply_cmd_result(CmdResult::ReportError( + crate::modules::parametric::DimConstraintCommand::NO_OBJECT.to_string(), + )); + }; + let result = self.tabs[i] + .active_cmd + .as_mut() + .map(|command| command.accept_parallel_line(ends)); + return match result { + Some(result) => self.apply_cmd_result(result), + None => Task::none(), + }; + } CmdResult::AddFixedConstraint(pick) => { use crate::modules::parametric::FixConstraintCommand; use crate::scene::parametric_constraints::{ diff --git a/src/app/properties.rs b/src/app/properties.rs index b96b37e04..185a0ff2a 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2292,6 +2292,28 @@ impl OpenCADStudio { } _ => entity_type_label(entity), }; + // A dynamic dimension shows only its constraint and text + // rotation, as the reference does. + let title = match crate::scene::parametric_constraints::dynamic_dimension_constraint( + &self.tabs[i].scene.parametric_constraints, + handle, + ) { + Some((set, constraint)) => { + sections = dynamic_dimension_sections( + &self.tabs[i].scene, + set, + constraint, + sections, + ); + match entity { + acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Aligned(_), + ) => t!("Aligned Dimensional Constraint").into_owned(), + _ => t!("Linear Dimensional Constraint").into_owned(), + } + } + None => title, + }; ui::PropertiesPanel { choice_combos: sections .iter() @@ -2739,6 +2761,26 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} } } } + if self.tabs[i].scene.is_dynamic_dimension(handle) { + // A dynamic dimension shows the reference's grips: a + // triangle at each constraint point aimed at the other + // one, and the text square — no dimension line grip. + let points: Vec = entity_grips + .iter() + .filter(|grip| grip.id <= 1) + .map(|grip| grip.world) + .collect(); + entity_grips.retain(|grip| grip.id != 2); + for grip in &mut entity_grips { + if grip.id <= 1 { + let other = points.get(1 - grip.id).copied().unwrap_or(grip.world); + let dir = (other - grip.world).normalize_or(glam::DVec3::X); + *grip = crate::entities::common::oriented_triangle_grip( + grip.id, grip.world, dir, + ); + } + } + } entity_grips.extend(crate::scene::model::solid_history::primitive_grips( &self.tabs[i].scene.document, handle, @@ -3572,6 +3614,84 @@ fn set_row(sections: &mut [crate::scene::model::object::PropSection], field: &st /// Replace a row's value with an arbitrary control (editable field, dropdown, /// colour picker …) rather than plain read-only text. +/// A dynamic dimension's Properties as the reference shows them: the +/// Constraint rows, then only the text rotation of the dimension itself. +fn dynamic_dimension_sections( + scene: &crate::scene::Scene, + set: &crate::scene::parametric_constraints::ParametricConstraintSet, + constraint: &crate::scene::parametric_constraints::ParametricConstraint, + sections: Vec, +) -> Vec { + use crate::scene::model::object::{PropSection, PropValue, Property}; + use crate::scene::named_parameters::DrivingValue; + let table = if set.local_parameters.is_empty() { + scene.named_parameters() + } else { + &set.local_parameters + }; + let (name, expression, value) = match &constraint.driving_param { + Some(DrivingValue::Named(name)) => ( + name.clone(), + table + .iter() + .find(|parameter| ¶meter.name == name) + .map(|parameter| parameter.source.clone()) + .unwrap_or_default(), + table.resolve(name).ok(), + ), + Some(DrivingValue::Literal(value)) => (String::new(), format!("{value}"), Some(*value)), + None => (String::new(), String::new(), None), + }; + let row = |label: &str, field: &'static str, value: PropValue| Property { + label: label.to_string(), + field, + value, + }; + let text_rotation = sections + .iter() + .flat_map(|section| section.props.iter()) + .find(|property| property.field == "text_rotation") + .cloned(); + let mut result = vec![PropSection { + title: t!("Constraint").into_owned(), + props: vec![ + row( + t!("Constraint Form").as_ref(), + "dyn_constraint_form", + PropValue::ReadOnly("Dynamic".to_string()), + ), + row( + t!("Reference").as_ref(), + "dyn_constraint_reference", + PropValue::ReadOnly(t!("No").into_owned()), + ), + row(t!("Name").as_ref(), "dyn_constraint_name", PropValue::EditText(name)), + row( + t!("Expression").as_ref(), + "dyn_constraint_expression", + PropValue::EditText(expression), + ), + row( + t!("Value").as_ref(), + "dyn_constraint_value", + PropValue::ReadOnly(value.map(|v| format!("{v:.4}")).unwrap_or_default()), + ), + row( + t!("Description").as_ref(), + "dyn_constraint_description", + PropValue::ReadOnly(String::new()), + ), + ], + }]; + if let Some(text_rotation) = text_rotation { + result.push(PropSection { + title: t!("Text").into_owned(), + props: vec![text_rotation], + }); + } + result +} + fn set_row_value( sections: &mut [crate::scene::model::object::PropSection], field: &str, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 94502699b..5e2a18016 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -3259,6 +3259,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } pub(super) fn on_prop_geom_commit(&mut self, field: &'static str) -> Task { + use crate::ui::window::named_parameters::ParamField; + // A dynamic dimension's Name/Expression rows edit its parameter. + let dynamic_field = match field { + "dyn_constraint_name" => Some(ParamField::Name), + "dyn_constraint_expression" => Some(ParamField::Formula), + _ => None, + }; + if let Some(param_field) = dynamic_field { + return self.on_dynamic_dimension_field_commit(field, param_field); + } let i = self.active_tab; self.tabs[i].properties.active_field = None; let handles = self.property_target_handles(i); diff --git a/src/command.rs b/src/command.rs index b1b9d48e9..bcd1f968f 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1625,10 +1625,22 @@ pub enum CmdResult { second_point: DVec3, location: DVec3, axis: DVec3, + /// Aligned's Point & line / 2Lines: the line the distance is + /// measured perpendicular to. + direction: Option, name: String, expression: String, label: &'static str, }, + /// Aligned's 2Lines: makes `second_line` parallel to `first_line` (whose + /// ends stay put), then hands the second line's solved ends back through + /// `CadCommand::accept_parallel_line`. + MakeParallel { + first_line: crate::scene::parametric_constraints::ParametricRef, + first_ends: [crate::scene::parametric_constraints::ParametricRef; 2], + second_line: crate::scene::parametric_constraints::ParametricRef, + second_ends: [crate::scene::parametric_constraints::ParametricRef; 2], + }, /// Adds a point or object symmetry relation around a picked line. The /// first reference and axis remain fixed during initial placement. AddSymmetricConstraint { @@ -2482,6 +2494,14 @@ pub trait CadCommand: Send { CmdResult::NeedPoint } + /// The second line's ends after a `MakeParallel` solve. + fn accept_parallel_line( + &mut self, + _ends: [(crate::scene::parametric_constraints::ParametricRef, DVec3); 2], + ) -> CmdResult { + CmdResult::NeedPoint + } + /// Include filled hatch / DXF SOLID regions in the entity hit-test. /// /// Most entity-pick commands operate on curve geometry and intentionally diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 906dea36d..e55e4d92b 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -6295,7 +6295,7 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool } } -/// Where a dynamic dimension's lock mark sits: just before the text on its +/// Where a dynamic dimension's lock mark sits: just after the text on its /// baseline, with the direction that points away from the text. pub(crate) fn dynamic_dimension_lock_anchor( document: &CadDocument, @@ -6323,13 +6323,11 @@ pub(crate) fn dynamic_dimension_lock_anchor( dimension_text_natural_rotation(dim) }; let (sr, cr) = rot.sin_cos(); - let outward = Vector3::new(-cr, -sr, 0.0); + let outward = Vector3::new(cr, sr, 0.0); + // A glyph-sized gap keeps the lock clear of the last digit. + let reach = half_width + text_height * 0.9; Some(( - Vector3::new( - pos.x + outward.x * half_width, - pos.y + outward.y * half_width, - pos.z, - ), + Vector3::new(pos.x + outward.x * reach, pos.y + outward.y * reach, pos.z), outward, )) } diff --git a/src/modules/parametric/dim_constraint.rs b/src/modules/parametric/dim_constraint.rs index 522119203..a5c11d32e 100644 --- a/src/modules/parametric/dim_constraint.rs +++ b/src/modules/parametric/dim_constraint.rs @@ -16,6 +16,29 @@ pub enum DimConstraintAxis { Aligned, } +/// A picked line (a line entity or one polyline segment) an Aligned +/// constraint measures perpendicular to. +#[derive(Clone, Copy)] +struct LineTarget { + line: ParametricRef, + /// Unit direction from its start to its end. + dir: DVec3, + /// Its two ends and where they are. + ends: [(ParametricRef, DVec3); 2], +} + +impl LineTarget { + /// The end nearest to `point` — the end the reference measures from. + fn nearest_end(&self, point: DVec3) -> (ParametricRef, DVec3) { + let [a, b] = self.ends; + if (b.1 - point).length_squared() < (a.1 - point).length_squared() { + b + } else { + a + } + } +} + #[derive(Clone, Copy)] enum Step { /// `Specify first constraint point or [Object] :` @@ -27,12 +50,37 @@ enum Step { first: ParametricRef, first_point: DVec3, }, + /// Aligned's Point & line: `Specify constraint point or [Line] :` + PointLinePoint, + /// `Select line:` after the constraint point. + PointLineLine { + point: ParametricRef, + point_pos: DVec3, + }, + /// `Select line:` first (the Line option). + LineFirst, + /// `Select constraint point:` after the line. + LinePoint { line: LineTarget }, + /// Aligned's 2Lines: `Select first line:` + TwoLinesFirst, + /// `Select second line to make parallel:` + TwoLinesSecond { line: LineTarget }, + /// The host is making the second line parallel; `accept_parallel_line` + /// brings its solved ends. + TwoLinesParallel { + first: LineTarget, + second: LineTarget, + /// Where the second line was picked. + pick: DVec3, + }, /// `Specify dimension line location:` Location { first: ParametricRef, second: ParametricRef, first_point: DVec3, second_point: DVec3, + /// The line the distance is measured perpendicular to. + direction: Option, }, /// `Enter value or name and value :` Value { @@ -44,6 +92,7 @@ enum Step { axis: DVec3, kind: ConstraintKind, measured: f64, + direction: Option, }, } @@ -63,6 +112,7 @@ impl DimConstraintCommand { pub const NO_POINT: &'static str = "No valid constraint point found."; pub const SAME_POINT: &'static str = "The object or point is already selected. Select a different object or constraint point."; + pub const INVALID_LINE: &'static str = "Invalid selection for Aligned. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc."; pub fn new(axis: DimConstraintAxis, default_name: String) -> Self { Self { @@ -127,9 +177,72 @@ impl DimConstraintCommand { Some(DVec3::new(point.x, point.y, point.z)) } + /// The line a Point & line or 2Lines pick measures against: a line + /// entity as a whole, or the picked polyline segment. + fn line_target(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + let (line, first, second) = match entity { + EntityType::Line(_) => ( + ParametricRef::whole(handle), + ParametricRef::point(handle, 0), + ParametricRef::point(handle, 1), + ), + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let (source, _, _) = + crate::scene::centerline::picked_source(entity, handle, point)?; + let index = source.segment_index; + if index < 0 { + return None; + } + let end = if resolve_point(entity, index + 1).is_some() { + index + 1 + } else { + 0 + }; + ( + ParametricRef::segment(handle, index as usize), + ParametricRef::point(handle, index), + ParametricRef::point(handle, end), + ) + } + _ => return None, + }; + let start = Self::world(entity, first)?; + let finish = Self::world(entity, second)?; + let dir = (finish - start).try_normalize()?; + Some(LineTarget { + line, + dir, + ends: [(first, start), (second, finish)], + }) + } + + /// A picked line for the current line step, or the message to show. + fn pick_line(&mut self, handle: Handle, point: DVec3) -> Result { + if handle.is_null() { + return Err(Self::NO_OBJECT); + } + let Some(entity) = self.picked_entity.take() else { + return Err(""); + }; + Self::line_target(&entity, handle, point).ok_or(Self::INVALID_LINE) + } + /// The measured kind and axis: Linear reads the dimension line location - /// the way a linear dimension does, the others are fixed. - fn decide(&self, first: DVec3, second: DVec3, location: DVec3) -> (ConstraintKind, DVec3) { + /// the way a linear dimension does, the others are fixed; a Point & line + /// or 2Lines distance runs perpendicular to its line. + fn decide( + &self, + first: DVec3, + second: DVec3, + location: DVec3, + direction: Option, + ) -> (ConstraintKind, DVec3) { + if let Some(line) = direction { + return ( + ConstraintKind::DistanceDirected, + DVec3::new(line.dir.y, -line.dir.x, 0.0), + ); + } match self.axis { DimConstraintAxis::Linear => { let axis = @@ -149,10 +262,11 @@ impl DimConstraintCommand { } } - fn measured(kind: ConstraintKind, first: DVec3, second: DVec3) -> f64 { + fn measured(kind: ConstraintKind, first: DVec3, second: DVec3, axis: DVec3) -> f64 { match kind { ConstraintKind::DistanceX => (second.x - first.x).abs(), ConstraintKind::DistanceY => (second.y - first.y).abs(), + ConstraintKind::DistanceDirected => (second - first).dot(axis).abs(), _ => (second - first).length(), } } @@ -166,6 +280,7 @@ impl DimConstraintCommand { location, axis, kind, + direction, .. } = self.step else { @@ -182,6 +297,7 @@ impl DimConstraintCommand { second_point, location, axis, + direction: direction.map(|line| line.line), name, expression, label: match self.axis { @@ -202,11 +318,23 @@ impl CadCommand for DimConstraintCommand { fn prompt(&self) -> String { let name = self.command_name(); match self.step { + Step::First if self.axis == DimConstraintAxis::Aligned => format!( + "{name} Specify first constraint point or [Object/Point & line/2Lines] :" + ), Step::First => { format!("{name} Specify first constraint point or [Object] :") } Step::Object => format!("{name} Select object:"), Step::Second { .. } => format!("{name} Specify second constraint point:"), + Step::PointLinePoint => { + format!("{name} Specify constraint point or [Line] :") + } + Step::PointLineLine { .. } | Step::LineFirst => format!("{name} Select line:"), + Step::LinePoint { .. } => format!("{name} Select constraint point:"), + Step::TwoLinesFirst => format!("{name} Select first line:"), + Step::TwoLinesSecond { .. } | Step::TwoLinesParallel { .. } => { + format!("{name} Select second line to make parallel:") + } Step::Location { .. } => format!("{name} Specify dimension line location:"), Step::Value { measured, .. } => format!( "{name} Enter value or name and value <{}={}>:", @@ -217,9 +345,16 @@ impl CadCommand for DimConstraintCommand { } fn options(&self) -> Vec { - matches!(self.step, Step::First) - .then(|| vec![CmdOption::new("Object", "O")]) - .unwrap_or_default() + match self.step { + Step::First if self.axis == DimConstraintAxis::Aligned => vec![ + CmdOption::new("Object", "O"), + CmdOption::new("Point & line", "P"), + CmdOption::new("2Lines", "2L"), + ], + Step::First => vec![CmdOption::new("Object", "O")], + Step::PointLinePoint => vec![CmdOption::new("Line", "L")], + _ => Vec::new(), + } } fn wants_text_input(&self) -> bool { @@ -227,15 +362,31 @@ impl CadCommand for DimConstraintCommand { } fn point_step_accepts_keywords(&self) -> bool { - matches!(self.step, Step::First | Step::Value { .. }) + matches!( + self.step, + Step::First | Step::PointLinePoint | Step::Value { .. } + ) } fn on_text_input(&mut self, text: &str) -> Option { match self.step { Step::First => { let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); - if matches!(keyword.as_str(), "O" | "OBJECT") { - self.step = Step::Object; + let aligned = self.axis == DimConstraintAxis::Aligned; + match keyword.as_str() { + "O" | "OBJECT" => self.step = Step::Object, + "P" | "POINT" | "POINT & LINE" | "POINT&LINE" if aligned => { + self.step = Step::PointLinePoint + } + "2L" | "2LINES" if aligned => self.step = Step::TwoLinesFirst, + _ => return None, + } + Some(CmdResult::NeedPoint) + } + Step::PointLinePoint => { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + if matches!(keyword.as_str(), "L" | "LINE") { + self.step = Step::LineFirst; return Some(CmdResult::NeedPoint); } None @@ -253,7 +404,18 @@ impl CadCommand for DimConstraintCommand { } fn needs_entity_pick(&self) -> bool { - matches!(self.step, Step::First | Step::Object | Step::Second { .. }) + matches!( + self.step, + Step::First + | Step::Object + | Step::Second { .. } + | Step::PointLinePoint + | Step::PointLineLine { .. } + | Step::LineFirst + | Step::LinePoint { .. } + | Step::TwoLinesFirst + | Step::TwoLinesSecond { .. } + ) } fn entity_pick_accepts_points(&self) -> bool { @@ -280,11 +442,72 @@ impl CadCommand for DimConstraintCommand { match self.step { // The host resolves the constraint point and hands it back // through `accept_constraint_point`. - Step::First | Step::Second { .. } => CmdResult::CheckConstraintPoint(CoincidentPick { - handle: (!handle.is_null()).then_some(handle), - point, - whole_curve: false, - }), + Step::First | Step::Second { .. } | Step::PointLinePoint | Step::LinePoint { .. } => { + CmdResult::CheckConstraintPoint(CoincidentPick { + handle: (!handle.is_null()).then_some(handle), + point, + whole_curve: false, + }) + } + Step::PointLineLine { point: first, point_pos } => { + match self.pick_line(handle, point) { + Ok(line) => { + if line.line.entity == first.entity { + return Self::report(Self::SAME_POINT); + } + let (second, second_point) = line.nearest_end(point_pos); + self.step = Step::Location { + first, + second, + first_point: point_pos, + second_point, + direction: Some(line), + }; + CmdResult::NeedPoint + } + Err("") => CmdResult::NeedPoint, + Err(message) => Self::report(message), + } + } + Step::LineFirst => match self.pick_line(handle, point) { + Ok(line) => { + self.step = Step::LinePoint { line }; + CmdResult::NeedPoint + } + Err("") => CmdResult::NeedPoint, + Err(message) => Self::report(message), + }, + Step::TwoLinesFirst => match self.pick_line(handle, point) { + Ok(line) => { + self.step = Step::TwoLinesSecond { line }; + CmdResult::NeedPoint + } + Err("") => CmdResult::NeedPoint, + Err(message) => Self::report(message), + }, + Step::TwoLinesSecond { line: first_line } => match self.pick_line(handle, point) { + Ok(second_line) => { + if second_line.line == first_line.line { + return Self::report(Self::SAME_POINT); + } + // The host makes the second line parallel first; the + // distance is measured on the solved geometry. + self.step = Step::TwoLinesParallel { + first: first_line, + second: second_line, + pick: point, + }; + CmdResult::MakeParallel { + first_line: first_line.line, + first_ends: [first_line.ends[0].0, first_line.ends[1].0], + second_line: second_line.line, + second_ends: [second_line.ends[0].0, second_line.ends[1].0], + } + } + Err("") => CmdResult::NeedPoint, + Err(message) => Self::report(message), + }, + Step::TwoLinesParallel { .. } => CmdResult::NeedPoint, Step::Object => { if handle.is_null() { return Self::report(Self::NO_OBJECT); @@ -308,6 +531,7 @@ impl CadCommand for DimConstraintCommand { second, first_point, second_point, + direction: None, }; CmdResult::NeedPoint } @@ -315,6 +539,32 @@ impl CadCommand for DimConstraintCommand { } } + fn accept_parallel_line(&mut self, ends: [(ParametricRef, DVec3); 2]) -> CmdResult { + let Step::TwoLinesParallel { first, second, pick } = self.step else { + return CmdResult::NeedPoint; + }; + let Some(dir) = (ends[1].1 - ends[0].1).try_normalize() else { + return Self::report(Self::NO_POINT); + }; + let second = LineTarget { + line: second.line, + dir, + ends, + }; + // The first line's end nearest the second pick, then the second + // line's end nearest that end. + let (first_ref, first_point) = first.nearest_end(pick); + let (second_ref, second_point) = second.nearest_end(first_point); + self.step = Step::Location { + first: first_ref, + second: second_ref, + first_point, + second_point, + direction: Some(first), + }; + CmdResult::NeedPoint + } + fn accept_constraint_point(&mut self, reference: ParametricRef, point: DVec3) -> CmdResult { match self.step { Step::First => { @@ -333,6 +583,28 @@ impl CadCommand for DimConstraintCommand { second: reference, first_point, second_point: point, + direction: None, + }; + CmdResult::NeedPoint + } + Step::PointLinePoint => { + self.step = Step::PointLineLine { + point: reference, + point_pos: point, + }; + CmdResult::NeedPoint + } + Step::LinePoint { line } => { + if line.line.entity == reference.entity { + return Self::report(Self::SAME_POINT); + } + let (first, first_point) = line.nearest_end(point); + self.step = Step::Location { + first, + second: reference, + first_point, + second_point: point, + direction: Some(line), }; CmdResult::NeedPoint } @@ -342,20 +614,27 @@ impl CadCommand for DimConstraintCommand { fn on_point(&mut self, point: DVec3) -> CmdResult { match self.step { - Step::First | Step::Second { .. } => CmdResult::CheckConstraintPoint(CoincidentPick { - handle: None, - point, - whole_curve: false, - }), - Step::Object => Self::report(Self::NO_OBJECT), + Step::First | Step::Second { .. } | Step::PointLinePoint | Step::LinePoint { .. } => { + CmdResult::CheckConstraintPoint(CoincidentPick { + handle: None, + point, + whole_curve: false, + }) + } + Step::Object + | Step::PointLineLine { .. } + | Step::LineFirst + | Step::TwoLinesFirst + | Step::TwoLinesSecond { .. } => Self::report(Self::NO_OBJECT), Step::Location { first, second, first_point, second_point, + direction, } => { - let (kind, axis) = self.decide(first_point, second_point, point); - let measured = Self::measured(kind, first_point, second_point); + let (kind, axis) = self.decide(first_point, second_point, point, direction); + let measured = Self::measured(kind, first_point, second_point, axis); self.step = Step::Value { first, second, @@ -365,10 +644,11 @@ impl CadCommand for DimConstraintCommand { axis, kind, measured, + direction, }; CmdResult::ReportMeasurement(format!("Dimension text = {measured:.4}")) } - Step::Value { .. } => CmdResult::NeedPoint, + Step::TwoLinesParallel { .. } | Step::Value { .. } => CmdResult::NeedPoint, } } @@ -378,6 +658,10 @@ impl CadCommand for DimConstraintCommand { self.step = Step::Object; CmdResult::NeedPoint } + Step::PointLinePoint => { + self.step = Step::LineFirst; + CmdResult::NeedPoint + } Step::Value { measured, .. } => self .build(self.default_name.clone(), measured_expression(measured)) .unwrap_or(CmdResult::Cancel), diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index eb8e2c0f6..1dd7f4b3d 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -374,17 +374,145 @@ pub(crate) fn dynamic_dimension_text(name: &str, value: f64, format: u8) -> Stri } } -/// The expression a measured value seeds a parameter with (`100`, `111.8034`). +/// The expression a measured value seeds a parameter with: twelve +/// significant digits with the trailing zeros dropped (`100`, +/// `111.803398875`), as the reference stores it. pub(crate) fn measured_expression(value: f64) -> String { - let text = format!("{value:.4}"); - let text = text.trim_end_matches('0').trim_end_matches('.'); - if text.is_empty() || text == "-" { + if !value.is_finite() { + return "0".to_string(); + } + // Solver noise (24.9999999047) reads as the round value it means. + let snapped = (value * 1.0e4).round() / 1.0e4; + let value = if (value - snapped).abs() < 1.0e-6 { + snapped + } else { + value + }; + let magnitude = value.abs(); + let integer_digits = if magnitude < 1.0 { + 1 + } else { + magnitude.log10().floor() as i32 + 1 + }; + let decimals = (12 - integer_digits).clamp(0, 12) as usize; + let text = format!("{value:.decimals$}"); + let text = if text.contains('.') { + text.trim_end_matches('0').trim_end_matches('.') + } else { + text.as_str() + }; + if text.is_empty() || text == "-" || text == "-0" { "0".to_string() } else { text.to_string() } } +/// Moves a dynamic dimension's extension origins to `first`/`second`, +/// keeping its dimension line where it was; true when anything moved. +fn dynamic_dimension_follow_points( + dimension: &mut acadrust::entities::Dimension, + first: Vector3, + second: Vector3, +) -> bool { + use acadrust::entities::Dimension; + let same = |a: Vector3, b: Vector3| (a - b).length_squared() < 1.0e-16; + let to_dvec = |p: Vector3| glam::DVec3::new(p.x, p.y, p.z); + let (current_first, current_second, definition, axis) = match dimension { + Dimension::Aligned(d) => (d.first_point, d.second_point, d.definition_point, None), + Dimension::Linear(d) => ( + d.first_point, + d.second_point, + d.definition_point, + Some(glam::DVec3::new(d.rotation.cos(), d.rotation.sin(), 0.0)), + ), + _ => return false, + }; + if same(current_first, first) && same(current_second, second) { + return false; + } + let text = dimension.base().user_text.clone(); + let rebuilt = match axis { + None => crate::modules::annotate::aligned_dim::aligned_dimension_entity( + to_dvec(first), + to_dvec(second), + to_dvec(definition), + text, + ), + Some(axis) => crate::modules::annotate::linear_dim::linear_dimension_entity( + to_dvec(first), + to_dvec(second), + to_dvec(definition), + axis, + text, + ), + }; + let acadrust::EntityType::Dimension(mut rebuilt) = rebuilt else { + return false; + }; + // Everything but the geometry stays: identity, layer, color, style, xdata. + let base = dimension.base(); + let target = rebuilt.base_mut(); + target.common = base.common.clone(); + target.style_name = base.style_name.clone(); + target.text_rotation = base.text_rotation; + target.attachment_point = base.attachment_point; + target.line_spacing_style = base.line_spacing_style; + target.line_spacing_factor = base.line_spacing_factor; + *dimension = rebuilt; + true +} + +/// What stays put when a dimensional constraint's value changes: its first +/// point, and — when the distance runs perpendicular to a line that owns +/// that point (2Lines, line-first Point & line) — the whole line, so the +/// other object moves, as in the reference. +pub(crate) fn dimensional_anchor_refs( + document: &acadrust::CadDocument, + refs: &[ParametricRef], +) -> Vec { + let Some(&first) = refs.first() else { + return Vec::new(); + }; + let mut anchors = vec![first]; + if let Some(&line) = refs.get(2).filter(|line| line.entity == first.entity) { + let ends = match line.segment_index() { + Some(index) => { + let index = index as i32; + let end = document + .get_entity(line.entity) + .and_then(|entity| resolve_point(entity, index + 1)) + .map_or(0, |_| index + 1); + [ + ParametricRef::point(line.entity, index), + ParametricRef::point(line.entity, end), + ] + } + None => [ + ParametricRef::point(line.entity, 0), + ParametricRef::point(line.entity, 1), + ], + }; + anchors.extend(ends.into_iter().filter(|end| *end != first)); + } + anchors +} + +/// The constraint a dynamic dimension shows, with the table its parameter +/// lives in. +pub(crate) fn dynamic_dimension_constraint( + sets: &[ParametricConstraintSet], + handle: Handle, +) -> Option<(&ParametricConstraintSet, &ParametricConstraint)> { + sets.iter().find_map(|set| { + let id = set + .dimensions + .iter() + .find_map(|(id, dimension)| (*dimension == handle).then_some(*id))?; + Some((set, set.get(id)?)) + }) +} + /// 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( @@ -1460,10 +1588,11 @@ impl super::Scene { /// Rewrites every dynamic dimension's text from its constraint's /// parameter name and current value, in the CONSTRAINTNAMEFORMAT - /// reading `constraint_name_format` selects. + /// reading `constraint_name_format` selects, and moves its extension + /// origins to where the solve left the constraint points. pub(crate) fn refresh_dynamic_dimension_texts(&mut self) { let format = self.constraint_name_format; - let mut updates: Vec<(Handle, String)> = Vec::new(); + let mut updates: Vec<(Handle, String, Option<(Vector3, Vector3)>)> = Vec::new(); for set in &self.parametric_constraints { let table = if set.local_parameters.is_empty() { &self.named_parameters @@ -1481,23 +1610,43 @@ impl super::Scene { Some(DrivingValue::Literal(value)) => (String::new(), *value), None => continue, }; - updates.push((*dimension, dynamic_dimension_text(&name, value, format))); + let world = |reference: &ParametricRef| { + let entity = self.document.get_entity(reference.entity)?; + resolve_point(entity, reference.marker?) + }; + let points = match constraint.refs.as_slice() { + [first, second, ..] => world(first).zip(world(second)), + _ => None, + }; + updates.push(( + *dimension, + dynamic_dimension_text(&name, value, format), + points, + )); } } - for (handle, text) in updates { + for (handle, text, points) in updates { let Some(acadrust::EntityType::Dimension(mut dimension)) = self.document.get_entity(handle).cloned() else { continue; }; - if dimension.base().user_text.as_deref() == Some(text.as_str()) { - continue; + let mut changed = false; + if let Some((first, second)) = points { + if dynamic_dimension_follow_points(&mut dimension, first, second) { + changed = true; + } + } + if dimension.base().user_text.as_deref() != Some(text.as_str()) { + crate::entities::dimension::set_dimension_text_override( + dimension.base_mut(), + Some(text), + ); + changed = true; + } + if changed { + self.update_entity(acadrust::EntityType::Dimension(dimension)); } - crate::entities::dimension::set_dimension_text_override( - dimension.base_mut(), - Some(text), - ); - self.update_entity(acadrust::EntityType::Dimension(dimension)); } } diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index c337bf2e7..61753cb4a 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -993,9 +993,15 @@ fn resolved_target(params: &ParameterTable, constraint: &ParametricConstraint) - if !value.is_finite() { return None; } + // A dimensional distance keeps its sign in the parameter but measures + // its magnitude, as the reference does (`d1=-50` shortens the line to + // 50; `0` folds the points together). + if constraint.kind == ConstraintKind::Distance { + return Some(value.abs()); + } let must_be_positive = matches!( constraint.kind, - ConstraintKind::Distance | ConstraintKind::Radius | ConstraintKind::Diameter + ConstraintKind::Radius | ConstraintKind::Diameter ); (!must_be_positive || value > 0.0).then_some(value) } @@ -1590,6 +1596,14 @@ fn build_constraint( let Some(resolved) = resolved_target(params, c) else { return Vec::new(); }; + // A zero distance folds the points together; the distance + // residual has no gradient there, the coordinate equalities do. + if resolved.abs() <= f64::EPSILON { + return vec![ + Rc::new(Equal::new(pa.x, pb.x, 1.0)), + Rc::new(Equal::new(pa.y, pb.y, 1.0)), + ]; + } let target = sys.add_param(resolved, true); vec![Rc::new(P2PDistance::new(pa, pb, target))] } From 3e28604752160135694fb5a222916ebc3d35c529 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:08:44 +0300 Subject: [PATCH 11/21] Dimensional constraints: DCFORM, DIMCONSTRAINT options, -PARAMETERS, value prompt, save without display overrides DCFORM sets the annotational or dynamic form and continues into the DIMCONSTRAINT option prompt, whose wording and last-used default match the reference. -PARAMETERS manages parameters from the command line; a double-clicked constraint dimension opens its value prompt. Dynamic dimensions rescale on every camera change and their display overrides stay out of the saved file. A driving dimension between an entity's own ends no longer has its length retained, so a new value moves the second point along the dimension axis; parameter edits hold the first constraint point. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 274 ++++++++++++++++++++++- src/app/commands/draw.rs | 138 +++++++++++- src/app/mod.rs | 6 + src/app/update/file.rs | 6 +- src/app/update/viewport.rs | 9 + src/modules/parametric/dim_constraint.rs | 155 ++++++++++++- src/modules/parametric/mod.rs | 9 +- src/modules/parametric/parameters_cli.rs | 146 ++++++++++++ src/scene/dwg_native_constraints.rs | 2 +- src/scene/mod.rs | 7 +- src/scene/parametric_constraints.rs | 77 ++++++- src/scene/parametric_solve.rs | 15 ++ 12 files changed, 810 insertions(+), 34 deletions(-) create mode 100644 src/modules/parametric/parameters_cli.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 84b3f3b6d..7e96ea001 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3,6 +3,28 @@ use crate::command::{CmdResult, SelectionEntity, StepInput}; use acadrust::Handle; use iced::Task; +/// `old` as a whole identifier in `source` becomes `new`. +fn replace_identifier(source: &str, old: &str, new: &str) -> String { + let mut out = String::with_capacity(source.len()); + let mut token = String::new(); + let flush = |token: &mut String, out: &mut String| { + if !token.is_empty() { + out.push_str(if token == old { new } else { token.as_str() }); + token.clear(); + } + }; + for c in source.chars() { + if c.is_alphanumeric() || c == '_' { + token.push(c); + } else { + flush(&mut token, &mut out); + out.push(c); + } + } + flush(&mut token, &mut out); + out +} + impl OpenCADStudio { pub(super) fn reject_locked_edit(&mut self, i: usize, handle: Handle) -> bool { let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) else { @@ -1147,6 +1169,213 @@ impl OpenCADStudio { } } + /// `Dynamic` or `Annotational`, the DCFORM setting. + pub(crate) fn constraint_form_name(&self) -> &'static str { + if self.constraint_form_annotational { + "Annotational" + } else { + "Dynamic" + } + } + + /// The value prompt a double-clicked constraint dimension opens. + pub(crate) fn dynamic_dimension_value_command( + &self, + i: usize, + handle: Handle, + ) -> Option { + use crate::scene::named_parameters::DrivingValue; + let scene = &self.tabs[i].scene; + for set in &scene.parametric_constraints { + let Some((id, _)) = set + .dimensions + .iter() + .find(|(_, dimension)| **dimension == handle) + else { + continue; + }; + let constraint = set.get(*id)?; + let Some(DrivingValue::Named(name)) = &constraint.driving_param else { + return None; + }; + let table = if set.local_parameters.is_empty() { + scene.named_parameters() + } else { + &set.local_parameters + }; + let current = table + .get(name) + .map(|parameter| parameter.source.trim().to_string()) + .unwrap_or_default(); + return Some(crate::modules::parametric::DimensionValueCommand::new( + name.clone(), + current, + )); + } + None + } + + /// Applies `expression` or `newname=expression` to parameter `name`: + /// the dimension value prompt and -PARAMETERS Edit. + pub(crate) fn apply_parameter_input(&mut self, i: usize, name: &str, input: &str) { + let input = input.trim(); + let (target, expression) = match input.split_once('=') { + Some((new_name, expression)) => { + (new_name.trim().to_string(), expression.trim().to_string()) + } + None => (name.to_string(), input.to_string()), + }; + if expression.is_empty() { + return; + } + if target != name { + if let Err(error) = self.rename_parameter(i, name, &target) { + self.command_line.push_error(&error); + return; + } + } + let mut table = self.tabs[i].scene.named_parameters().clone(); + if let Err(error) = table.set(&target, &expression) { + self.command_line.push_error(&error.to_string()); + return; + } + if !table + .resolve(&target) + .is_ok_and(|value| value.is_finite()) + { + self.command_line.push_error("Invalid expression."); + return; + } + let pending = self.begin_undo(i, "Edit parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = table; + self.resolve_named_parameter_edit(i, &target); + self.tabs[i].scene.refresh_dynamic_dimension_texts(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + /// -PARAMETERS New. + pub(crate) fn create_parameter(&mut self, i: usize, name: &str, expression: &str) { + if self.tabs[i].scene.named_parameters().contains(name) { + self.command_line + .push_error(&format!("Parameter {name} already exists.")); + return; + } + let mut table = self.tabs[i].scene.named_parameters().clone(); + if let Err(error) = table.set(name, expression) { + self.command_line.push_error(&error.to_string()); + return; + } + let pending = self.begin_undo(i, "New parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = table; + self.tabs[i].scene.sync_native_parametric_graph(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + + /// Renames a parameter everywhere: the table, the expressions that use + /// it and the constraints it drives. + pub(crate) fn rename_parameter(&mut self, i: usize, old: &str, new: &str) -> Result<(), String> { + use crate::scene::named_parameters::{is_reserved_name, is_valid_name, DrivingValue}; + let table = self.tabs[i].scene.named_parameters().clone(); + let Some(source) = table.get(old).map(|parameter| parameter.source.clone()) else { + return Err(format!("Parameter {old} not found.")); + }; + if !is_valid_name(new) || is_reserved_name(new) || table.contains(new) { + return Err(format!("Invalid parameter name {new}.")); + } + let mut renamed = table.clone(); + renamed.set(new, &source).map_err(|error| error.to_string())?; + for parameter in table.iter() { + if parameter.name == old { + continue; + } + let rewritten = replace_identifier(¶meter.source, old, new); + if rewritten != parameter.source { + renamed + .set(¶meter.name, &rewritten) + .map_err(|error| error.to_string())?; + } + } + renamed.remove(old); + let pending = self.begin_undo(i, "Rename parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters = renamed; + let scopes: Vec<_> = self.tabs[i] + .scene + .parametric_constraints + .iter() + .map(|set| set.scope) + .collect(); + for scope in scopes { + let before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .filter(|set| { + set.constraints.iter().any(|constraint| { + matches!(&constraint.driving_param, Some(DrivingValue::Named(name)) if name == old) + }) + }) + .cloned(); + let Some(before) = before else { + continue; + }; + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + for constraint in &mut set.constraints { + if matches!(&constraint.driving_param, Some(DrivingValue::Named(name)) if name == old) { + constraint.driving_param = Some(DrivingValue::Named(new.to_string())); + } + } + } + self.tabs[i].scene.refresh_dynamic_dimension_texts(); + self.tabs[i].scene.sync_native_parametric_graph(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + Ok(()) + } + + /// -PARAMETERS Delete: a parameter no constraint drives with. + pub(crate) fn delete_parameter(&mut self, i: usize, name: &str) { + use crate::scene::named_parameters::DrivingValue; + if !self.tabs[i].scene.named_parameters().contains(name) { + self.command_line + .push_error(&format!("Parameter {name} not found.")); + return; + } + let in_use = self.tabs[i] + .scene + .parametric_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .any(|constraint| { + matches!(&constraint.driving_param, Some(DrivingValue::Named(used)) if used == name) + }); + if in_use { + self.command_line + .push_error(&format!("Parameter {name} is used by a constraint.")); + return; + } + let pending = self.begin_undo(i, "Delete parameter", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i].scene.named_parameters_mut().remove(name); + self.tabs[i].scene.sync_native_parametric_graph(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + fn remove_parametric_constraint( &mut self, id: crate::scene::parametric_constraints::ConstraintId, @@ -1344,7 +1573,25 @@ impl OpenCADStudio { .into_iter() .map(|h| (h, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + // A changed value moves a dimensional constraint's second point; its + // first point stays, as in the reference. + let anchors: Vec = self.tabs[i] + .scene + .parametric_constraints + .iter() + .flat_map(|set| set.constraints.iter()) + .filter(|c| { + c.enabled + && matches!(&c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(n)) if n == name) + }) + .filter_map(|c| c.refs.first().copied()) + .filter(|reference| reference.marker.is_some()) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &anchors, + self.constraint_solve_mode, + ); } /// Commits one field of one Parameters-section row (Properties panel) — @@ -3609,9 +3856,17 @@ impl OpenCADStudio { return Task::none(); } }; + // DCFORM Annotational: an ordinary plotted dimension that shows + // the value at dimension precision. + let annotational = self.constraint_form_annotational; + let source = if annotational { + format!("{value:.4}") + } else { + expression.clone() + }; let text = dynamic_dimension_text( &name, - &expression, + &source, value, self.tabs[i].scene.constraint_name_format, ); @@ -3641,11 +3896,13 @@ impl OpenCADStudio { } // A dynamic dimension draws in the constraint gray, not the // current color. - entity.common_mut().color = acadrust::types::Color::Rgb { - r: 103, - g: 109, - b: 118, - }; + if !annotational { + entity.common_mut().color = acadrust::types::Color::Rgb { + r: 103, + g: 109, + b: 118, + }; + } let constraints_before = self.tabs[i] .scene .parametric_constraint_set(scope) @@ -3664,6 +3921,9 @@ impl OpenCADStudio { self.tabs[i].scene.record_undo_named_parameters_before(); self.tabs[i].scene.named_parameters = table; let dimension = self.tabs[i].scene.add_entity(entity); + if annotational { + self.tabs[i].scene.mark_dimension_annotational(dimension); + } let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); let id = set.add(kind, refs, Some(DrivingValue::Named(name))); set.dimensions.insert(id, dimension); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index cf9d4686d..aa6d28f11 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1310,6 +1310,7 @@ impl OpenCADStudio { } "DCCONVERT" => { + self.dim_constraint_last = "Convert"; let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1508,14 +1509,129 @@ impl OpenCADStudio { "DIMCONSTRAINT" => { use crate::modules::parametric::DimConstraintMenuCommand; - // Only the dynamic form exists here. - self.command_line - .push_output("Current settings: Constraint form = Dynamic"); - let new_cmd = DimConstraintMenuCommand; + self.command_line.push_output(&format!( + "Current settings: Constraint form = {}", + self.constraint_form_name() + )); + let new_cmd = DimConstraintMenuCommand::new(self.dim_constraint_last); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "DCFORM" => { + use crate::modules::parametric::ConstraintFormCommand; + let new_cmd = ConstraintFormCommand::new(self.constraint_form_annotational); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + // DCFORM's answer, then on into DIMCONSTRAINT's options as the + // reference does. + cmd if cmd.starts_with("DCFORM_SET ") => { + use crate::modules::parametric::DimConstraintMenuCommand; + self.constraint_form_annotational = cmd + .trim_start_matches("DCFORM_SET ") + .trim() + .eq_ignore_ascii_case("Annotational"); + self.command_line.push_output(&format!( + "Current settings: Constraint form = {}", + self.constraint_form_name() + )); + let new_cmd = DimConstraintMenuCommand::new(self.dim_constraint_last); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + // A dynamic dimension's value prompt (double-click). + cmd if cmd.starts_with("DCVALUE ") => { + let rest = cmd.trim_start_matches("DCVALUE ").trim(); + if let Some((name, input)) = rest.split_once(' ') { + self.apply_parameter_input(i, name.trim(), input); + } + } + + "-PARAMETERS" => { + use crate::modules::parametric::ParametersCliCommand; + let new_cmd = ParametersCliCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + cmd if cmd.starts_with("-PARAMETERS ") => { + let rest = cmd.trim_start_matches("-PARAMETERS ").trim(); + let (op, args) = rest.split_once(' ').unwrap_or((rest, "")); + match op { + "LIST" => { + let rule = "-".repeat(74); + let rows: Vec = { + let table = self.tabs[i].scene.named_parameters(); + table + .iter() + .map(|parameter| { + let value = table + .resolve(¶meter.name) + .map(|value| format!("{value:.4}")) + .unwrap_or_else(|_| "**".to_string()); + format!( + "Parameter: {:<12} Expression: {:<21} Value: {value}", + parameter.name, parameter.source + ) + }) + .collect() + }; + self.command_line.push_output(&rule); + for row in rows { + self.command_line.push_output(&row); + } + self.command_line.push_output(&rule); + } + "NEW" => { + if let Some((name, expression)) = args.split_once(' ') { + self.create_parameter(i, name.trim(), expression.trim()); + } + } + "EDIT" => { + use crate::modules::parametric::ParametersCliCommand; + let name = args.trim().to_string(); + let old = { + let table = self.tabs[i].scene.named_parameters(); + table.get(&name).map(|parameter| { + let value = table + .resolve(&name) + .map(|value| format!("{value:.4}")) + .unwrap_or_else(|_| "**".to_string()); + format!("Old Expression = {}, Value = {value}", parameter.source) + }) + }; + match old { + Some(line) => { + self.command_line.push_output(&line); + let new_cmd = ParametersCliCommand::edit_expression(name); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + None => self + .command_line + .push_error(&format!("Parameter {name} not found.")), + } + } + "SET" => { + if let Some((name, expression)) = args.split_once(' ') { + self.apply_parameter_input(i, name.trim(), expression.trim()); + } + } + "RENAME" => { + if let Some((old, new)) = args.split_once(' ') { + if let Err(error) = self.rename_parameter(i, old.trim(), new.trim()) { + self.command_line.push_error(&error); + } + } + } + "DELETE" => self.delete_parameter(i, args.trim()), + _ => {} + } + } + "EDCONSTRAINT" => { use crate::modules::parametric::EqualDistanceConstraintCommand; let new_cmd = EqualDistanceConstraintCommand::new(); @@ -1765,6 +1881,12 @@ impl OpenCADStudio { "DCALIGNED" => DimConstraintAxis::Aligned, _ => DimConstraintAxis::Linear, }; + self.dim_constraint_last = match cmd { + "DCHORIZONTAL" => "Horizontal", + "DCVERTICAL" => "Vertical", + "DCALIGNED" => "Aligned", + _ => "Linear", + }; let name = next_dimensional_parameter_name(self.tabs[i].scene.named_parameters()); // Both constraint points are picked inside the command. self.tabs[i].scene.deselect_all(); @@ -1774,6 +1896,11 @@ impl OpenCADStudio { } "DCONSTRAINT" | "DCRADIUS" | "DCDIAMETER" => { + if cmd == "DCRADIUS" { + self.dim_constraint_last = "Radius"; + } else if cmd == "DCDIAMETER" { + self.dim_constraint_last = "Diameter"; + } let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1813,6 +1940,9 @@ impl OpenCADStudio { } "ACONSTRAINT" | "DCANGULAR" => { + if cmd == "DCANGULAR" { + self.dim_constraint_last = "ANgular"; + } let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; diff --git a/src/app/mod.rs b/src/app/mod.rs index fd1c73551..674622972 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -612,6 +612,10 @@ pub(super) struct OpenCADStudio { pub constraint_solve_mode: bool, pub constraint_infer: bool, pub constraint_bar_display: i16, + /// DCFORM: new dimensional constraints use the annotational form. + pub constraint_form_annotational: bool, + /// The dimensional constraint DIMCONSTRAINT offers by default: the last one used. + pub dim_constraint_last: &'static str, pub constraint_bar_mode: i16, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// 0 disables autosave. @@ -3949,6 +3953,8 @@ impl OpenCADStudio { constraint_solve_mode: true, constraint_infer: false, constraint_bar_display: 3, + constraint_form_annotational: false, + dim_constraint_last: "Aligned", constraint_bar_mode: 4095, savetime_min: 10, default_bg_color: None, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bb43488b2..dd93a679d 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1464,7 +1464,7 @@ impl OpenCADStudio { self.prepare_native_save(i); let version = self.tabs[i].scene.document.version; - let snapshot = self.tabs[i].scene.document.clone(); + let snapshot = self.tabs[i].scene.document_for_save(); crate::io::save_owned_as_version_atomic( snapshot, &path, @@ -1893,7 +1893,7 @@ impl OpenCADStudio { path: std::path::PathBuf, ) -> Task { let i = self.active_tab; - let document = self.tabs[i].scene.document.clone(); + let document = self.tabs[i].scene.document_for_save(); let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); let worker_name = block_name.clone(); let worker_path = path.clone(); @@ -2298,7 +2298,7 @@ impl OpenCADStudio { crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID) }); let clone_started = iced::time::Instant::now(); - let mut snapshot = self.tabs[i].scene.document.clone(); + let mut snapshot = self.tabs[i].scene.document_for_save(); // Save-As across folders: rebase relative reference paths onto the // new base dir inside the snapshot only (live strings are untouched). if purpose == crate::app::SavePurpose::SaveAs { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index dee93d74c..4e9d2006e 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -5060,6 +5060,15 @@ properties={:.1}ms picked={}", { return Task::none(); } + // A constraint dimension edits its parameter from the + // command line, standing in for the reference's in-place + // value editor. + if let Some(prompt) = self.dynamic_dimension_value_command(i, handle) { + use crate::command::CadCommand; + self.command_line.push_info(&prompt.prompt()); + self.tabs[i].active_cmd = Some(Box::new(prompt)); + return Task::none(); + } // Any text-bearing entity opens its in-place editor // (plain box or rich MText editor, per type). A // Leader resolves to the entity it annotates. diff --git a/src/modules/parametric/dim_constraint.rs b/src/modules/parametric/dim_constraint.rs index 522119203..d3e5dadf4 100644 --- a/src/modules/parametric/dim_constraint.rs +++ b/src/modules/parametric/dim_constraint.rs @@ -392,9 +392,16 @@ impl CadCommand for DimConstraintCommand { /// `DIMCONSTRAINT`: the reference's option front end for the dimensional /// constraint family; each choice runs the focused command. -pub struct DimConstraintMenuCommand; +pub struct DimConstraintMenuCommand { + /// The option Enter takes: the dimensional constraint used last. + default: &'static str, +} impl DimConstraintMenuCommand { + pub fn new(default: &'static str) -> Self { + Self { default } + } + fn dispatch(keyword: &str) -> Option<&'static str> { Some(match keyword { "L" | "LINEAR" => "DCLINEAR", @@ -404,10 +411,24 @@ impl DimConstraintMenuCommand { "AN" | "ANGULAR" => "DCANGULAR", "R" | "RADIAL" | "RADIUS" => "DCRADIUS", "D" | "DIAMETER" => "DCDIAMETER", + "F" | "FORM" => "DCFORM", "C" | "CONVERT" => "DCCONVERT", _ => return None, }) } + + fn command_for_label(label: &str) -> &'static str { + match label { + "Linear" => "DCLINEAR", + "Horizontal" => "DCHORIZONTAL", + "Vertical" => "DCVERTICAL", + "ANgular" => "DCANGULAR", + "Radius" => "DCRADIUS", + "Diameter" => "DCDIAMETER", + "Convert" => "DCCONVERT", + _ => "DCALIGNED", + } + } } impl CadCommand for DimConstraintMenuCommand { @@ -416,7 +437,10 @@ impl CadCommand for DimConstraintMenuCommand { } fn prompt(&self) -> String { - "DIMCONSTRAINT Enter dimensional constraint option [Linear/Horizontal/Vertical/Aligned/ANgular/Radial/Diameter/Convert] :".to_string() + format!( + "DIMCONSTRAINT Enter a dimensional constraint option [Linear/Horizontal/Vertical/Aligned/ANgular/Radius/Diameter/Form/Convert] <{}>:", + self.default + ) } fn options(&self) -> Vec { @@ -426,8 +450,9 @@ impl CadCommand for DimConstraintMenuCommand { CmdOption::new("Vertical", "V"), CmdOption::new("Aligned", "A"), CmdOption::new("ANgular", "AN"), - CmdOption::new("Radial", "R"), + CmdOption::new("Radius", "R"), CmdOption::new("Diameter", "D"), + CmdOption::new("Form", "F"), CmdOption::new("Convert", "C"), ] } @@ -446,7 +471,129 @@ impl CadCommand for DimConstraintMenuCommand { } fn on_enter(&mut self) -> CmdResult { - CmdResult::Dispatch("DCALIGNED".to_string()) + CmdResult::Dispatch(Self::command_for_label(self.default).to_string()) + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +/// `DCFORM`: `Enter constraint form [Annotational/Dynamic] :`; +/// the host records the answer and continues into DIMCONSTRAINT's option +/// prompt, as the reference does. +pub struct ConstraintFormCommand { + annotational: bool, +} + +impl ConstraintFormCommand { + pub fn new(annotational: bool) -> Self { + Self { annotational } + } + + fn current(&self) -> &'static str { + if self.annotational { + "Annotational" + } else { + "Dynamic" + } + } +} + +impl CadCommand for ConstraintFormCommand { + fn name(&self) -> &'static str { + "DCFORM" + } + + fn prompt(&self) -> String { + format!( + "DCFORM Enter constraint form [Annotational/Dynamic] <{}>:", + self.current() + ) + } + + fn options(&self) -> Vec { + vec![ + CmdOption::new("Annotational", "A"), + CmdOption::new("Dynamic", "D"), + ] + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + let form = match keyword.as_str() { + "A" | "ANNOTATIONAL" => "Annotational", + "D" | "DYNAMIC" => "Dynamic", + _ => return None, + }; + Some(CmdResult::Dispatch(format!("DCFORM_SET {form}"))) + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch(format!("DCFORM_SET {}", self.current())) + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +/// The value prompt a double-clicked dynamic dimension opens, standing in +/// for the reference's in-place editor; `name=expression` also renames. +pub struct DimensionValueCommand { + name: String, + current: String, +} + +impl DimensionValueCommand { + pub fn new(name: String, current: String) -> Self { + Self { name, current } + } +} + +impl CadCommand for DimensionValueCommand { + fn name(&self) -> &'static str { + "DCVALUE" + } + + fn prompt(&self) -> String { + format!( + "DCVALUE Enter value or name and value <{}={}>:", + self.name, self.current + ) + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let text = text.trim(); + (!text.is_empty()).then(|| CmdResult::Dispatch(format!("DCVALUE {} {text}", self.name))) + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel } fn on_escape(&mut self) -> CmdResult { diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 6353d3672..c064008d9 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -5,6 +5,7 @@ mod coincident; mod concentric; mod dim_constraint; +mod parameters_cli; mod constraint_bar; mod equal_distance; #[path = "equal.rs"] @@ -26,7 +27,11 @@ mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; pub use concentric::ConcentricConstraintCommand; -pub use dim_constraint::{DimConstraintAxis, DimConstraintCommand, DimConstraintMenuCommand}; +pub use dim_constraint::{ + ConstraintFormCommand, DimConstraintAxis, DimConstraintCommand, DimConstraintMenuCommand, + DimensionValueCommand, +}; +pub use parameters_cli::ParametersCliCommand; pub use constraint_bar::ConstraintBarOptionCommand; pub use equal_command::EqualConstraintCommand; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; @@ -158,7 +163,7 @@ inventory::submit!(crate::command::CommandRegistration { names: &[ "AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "GCSMOOTH", "GCSHOW", "GCHIDE", "GCRESET", "GCSHOWALL", "GCHIDEALL", "DCSHOW", "DCHIDE", "DCSHOWALL", "DCHIDEALL", - "DCCONVERT", "DELCONSTRAINT", "DIMCONSTRAINT", + "DCCONVERT", "DELCONSTRAINT", "DIMCONSTRAINT", "DCFORM", "-PARAMETERS", ] }); diff --git a/src/modules/parametric/parameters_cli.rs b/src/modules/parametric/parameters_cli.rs new file mode 100644 index 000000000..ad6e8986e --- /dev/null +++ b/src/modules/parametric/parameters_cli.rs @@ -0,0 +1,146 @@ +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult}; + +/// `-PARAMETERS`: the reference's command-line parameter manager. One run +/// performs one operation; the host answers the `LIST`, `NEW`, `EDIT`, +/// `SET`, `RENAME` and `DELETE` dispatches. +pub struct ParametersCliCommand { + step: Step, +} + +#[derive(Clone)] +enum Step { + /// `Enter a parameter option [New/Edit/Rename/Delete/?]:` + Option, + /// `Enter name for new user parameter:` + NewName, + /// `Enter expression:` + NewExpression { name: String }, + /// `Enter parameter name:` + EditName, + /// `Enter expression:` after the host printed the old expression. + EditExpression { name: String }, + /// `Enter old parameter name:` + RenameOld, + /// `Enter new parameter name:` + RenameNew { old: String }, + /// `Enter parameter name to delete:` + DeleteName, +} + +impl ParametersCliCommand { + pub fn new() -> Self { + Self { step: Step::Option } + } + + /// The expression prompt the host opens after printing the old expression. + pub fn edit_expression(name: String) -> Self { + Self { + step: Step::EditExpression { name }, + } + } +} + +impl Default for ParametersCliCommand { + fn default() -> Self { + Self::new() + } +} + +impl CadCommand for ParametersCliCommand { + fn name(&self) -> &'static str { + "-PARAMETERS" + } + + fn prompt(&self) -> String { + let text = match &self.step { + Step::Option => "Enter a parameter option [New/Edit/Rename/Delete/?]:", + Step::NewName => "Enter name for new user parameter:", + Step::NewExpression { .. } | Step::EditExpression { .. } => "Enter expression:", + Step::EditName => "Enter parameter name:", + Step::RenameOld => "Enter old parameter name:", + Step::RenameNew { .. } => "Enter new parameter name:", + Step::DeleteName => "Enter parameter name to delete:", + }; + format!("-PARAMETERS {text}") + } + + fn options(&self) -> Vec { + match self.step { + Step::Option => vec![ + CmdOption::new("New", "N"), + CmdOption::new("Edit", "E"), + CmdOption::new("Rename", "R"), + CmdOption::new("Delete", "D"), + CmdOption::new("?", "?"), + ], + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let text = text.trim(); + if text.is_empty() { + return None; + } + match self.step.clone() { + Step::Option => { + let keyword = text.trim_start_matches('_').to_ascii_uppercase(); + let next = match keyword.as_str() { + "N" | "NEW" => Step::NewName, + "E" | "EDIT" => Step::EditName, + "R" | "RENAME" => Step::RenameOld, + "D" | "DELETE" => Step::DeleteName, + "?" => return Some(CmdResult::Dispatch("-PARAMETERS LIST".to_string())), + _ => return None, + }; + self.step = next; + Some(CmdResult::NeedPoint) + } + Step::NewName => { + self.step = Step::NewExpression { + name: text.to_string(), + }; + Some(CmdResult::NeedPoint) + } + Step::NewExpression { name } => { + Some(CmdResult::Dispatch(format!("-PARAMETERS NEW {name} {text}"))) + } + Step::EditName => Some(CmdResult::Dispatch(format!("-PARAMETERS EDIT {text}"))), + Step::EditExpression { name } => { + Some(CmdResult::Dispatch(format!("-PARAMETERS SET {name} {text}"))) + } + Step::RenameOld => { + self.step = Step::RenameNew { + old: text.to_string(), + }; + Some(CmdResult::NeedPoint) + } + Step::RenameNew { old } => { + Some(CmdResult::Dispatch(format!("-PARAMETERS RENAME {old} {text}"))) + } + Step::DeleteName => Some(CmdResult::Dispatch(format!("-PARAMETERS DELETE {text}"))), + } + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index f1e4a208d..bf5901e94 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -2812,7 +2812,7 @@ impl Scene { self.parametric_constraints.clear(); // Dynamic dimensions read from the file get their screen size on the // first tick after the view is known. - self.dynamic_dimension_band = 0.0; + self.dynamic_dimension_camera_gen = None; let owners: Vec<_> = self .document .block_records diff --git a/src/scene/mod.rs b/src/scene/mod.rs index aae7b130d..d3e6b5c95 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2302,9 +2302,8 @@ pub struct Scene { pub dynamic_constraint_display: bool, /// Dynamic dimensions DCHIDE or DYNCONSTRAINTDISPLAY 0 keep off screen. hidden_dynamic_dimensions: HashSet, - /// The zoom band (`quantize_wpp`) the dynamic dimensions were last - /// scaled for; 0 until the first scaling. - dynamic_dimension_band: f32, + /// The camera generation the dynamic dimensions were last scaled for. + dynamic_dimension_camera_gen: Option, /// Session-only visibility overrides for constraint glyphs. hidden_parametric_constraints: HashSet<( parametric_constraints::ParametricScope, @@ -2638,7 +2637,7 @@ impl Scene { constraint_name_format: 2, dynamic_constraint_display: true, hidden_dynamic_dimensions: HashSet::default(), - dynamic_dimension_band: 0.0, + dynamic_dimension_camera_gen: None, hidden_parametric_constraints: HashSet::default(), shown_parametric_constraints: HashSet::default(), named_parameters: named_parameters::ParameterTable::new(), diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 579eaa90b..6a72eb76b 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -367,6 +367,9 @@ pub const DYNAMIC_DIMENSION_GLYPH: &str = "\u{1F512}"; /// The on-screen text height of a dynamic dimension, in pixels. pub const DYNAMIC_DIMENSION_TEXT_PX: f32 = 12.0; +/// The xdata application that marks an annotational constraint dimension. +pub const ANNOTATIONAL_DIMENSION_APP: &str = "OCS_CONSTRAINT_FORM"; + /// The text a dynamic dimension shows: CONSTRAINTNAMEFORMAT 0 = name, /// 1 = value, 2 = name=value. A plain number shows as typed (`d1=100`); an /// expression shows the expression under the reference's `fx:` prefix @@ -1502,6 +1505,13 @@ impl super::Scene { } None => continue, }; + // An annotational dimension shows the value at dimension + // precision, as the reference does. + let source = if self.dimension_is_annotational(*dimension) { + format!("{value:.4}") + } else { + source + }; updates.push(( *dimension, dynamic_dimension_text(&name, &source, value, format), @@ -1592,25 +1602,69 @@ impl super::Scene { ids.len() } - /// True for a dimension that shows a dimensional constraint. + /// True for a dynamic dimension of a dimensional constraint (not an + /// annotational one, which is an ordinary plotted dimension). pub(crate) fn is_dynamic_dimension(&self, handle: Handle) -> bool { self.parametric_constraints .iter() .any(|set| set.dimensions.values().any(|dimension| *dimension == handle)) + && !self.dimension_is_annotational(handle) + } + + /// True when a dimensional constraint's dimension uses the annotational + /// form: drawn with its style, plotted, never rescaled to the screen. + pub(crate) fn dimension_is_annotational(&self, handle: Handle) -> bool { + self.document.get_entity(handle).is_some_and(|entity| { + entity + .common() + .extended_data + .get_record(ANNOTATIONAL_DIMENSION_APP) + .is_some() + }) + } + + /// Marks a constraint dimension as annotational (DCFORM Annotational). + pub(crate) fn mark_dimension_annotational(&mut self, handle: Handle) { + crate::scene::view::dispatch::set_entity_xdata( + &mut self.document, + handle, + ANNOTATIONAL_DIMENSION_APP, + Some(vec![acadrust::xdata::XDataValue::String( + "ANNOTATIONAL".to_string(), + )]), + ); + } + + /// The document as written to a file: a dynamic dimension's screen-size + /// overrides are a display matter and stay out of the file. + pub(crate) fn document_for_save(&self) -> acadrust::CadDocument { + use crate::entities::dim_override as ov; + let mut document = self.document.clone(); + for set in &self.parametric_constraints { + for handle in set.dimensions.values().copied() { + if self.dimension_is_annotational(handle) { + continue; + } + for code in [ov::DIMSCALE, ov::DIMTIH, ov::DIMTOH] { + ov::set(&mut document, handle, code, None); + } + } + } + document } /// Gives every dynamic dimension a DIMSCALE override that keeps its /// text, arrows and offsets at a screen size (the reference draws them - /// at a constant pixel size whatever the zoom). Runs once per zoom band; - /// `force` re-applies after a dimension was created or loaded. + /// at a constant pixel size whatever the zoom). Runs whenever the camera + /// changed; `force` re-applies after a dimension was created or loaded. pub fn refresh_dynamic_dimension_scales(&mut self, force: bool) { - let Some(band) = super::Scene::quantize_wpp(self.world_per_pixel()) else { + let Some(wpp) = self.world_per_pixel() else { return; }; - if !force && band == self.dynamic_dimension_band { + if !force && self.dynamic_dimension_camera_gen == Some(self.camera_generation) { return; } - self.dynamic_dimension_band = band; + self.dynamic_dimension_camera_gen = Some(self.camera_generation); let handles: Vec = self .parametric_constraints .iter() @@ -1622,6 +1676,10 @@ impl super::Scene { else { continue; }; + // An annotational dimension keeps its style's size. + if self.dimension_is_annotational(handle) { + continue; + } let style_name = dimension.base().style_name.clone(); let text_height = self .document @@ -1635,7 +1693,7 @@ impl super::Scene { .map(|style| style.dimtxt) .filter(|height| *height > 1e-9) .unwrap_or(0.18); - let scale = f64::from(band) * f64::from(DYNAMIC_DIMENSION_TEXT_PX) / text_height; + let scale = f64::from(wpp) * f64::from(DYNAMIC_DIMENSION_TEXT_PX) / text_height; use crate::entities::dim_override as ov; let xdata = &dimension.base().common.extended_data; let current = ov::real(xdata, ov::DIMSCALE); @@ -1676,8 +1734,9 @@ impl super::Scene { .iter() .flat_map(|set| { set.dimensions.iter().filter_map(|(id, dimension)| { - (!self.dynamic_constraint_display - || !self.is_parametric_constraint_visible(set.scope, *id)) + (!self.dimension_is_annotational(*dimension) + && (!self.dynamic_constraint_display + || !self.is_parametric_constraint_visible(set.scope, *id))) .then_some(*dimension) }) }) diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 3101a2103..0f1f4e671 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -2766,6 +2766,21 @@ fn solve_scope( constraint.refs.get(2).is_some_and(|axis| axis.entity == *handle) }) || equal_followers.iter().any(|follower| follower.entity == *handle) + // A driving dimension between an entity's own ends is its size; + // a retained length would contradict a new value. + || constraints.iter().any(|c| { + c.enabled + && c.driving_param.is_some() + && matches!( + c.kind, + ConstraintKind::Distance + | ConstraintKind::DistanceX + | ConstraintKind::DistanceY + | ConstraintKind::DistanceDirected + ) + && c.refs.len() >= 2 + && c.refs.iter().all(|r| r.entity == *handle && r.marker.is_some()) + }) { continue; } From ff4981ffe6a649a7edfcc63b2e12aa19bc080f7f Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 21:01:05 +0800 Subject: [PATCH 12/21] feat(text): read unifont and big-font .shx files; pair a style's big font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `shx.rs` only accepted the classic `AutoCAD-86 shapes 1.x` container, so every modern Autodesk font (`txt.shx`, `simplex.shx`, `romans.shx`, … are `unifont 1.0`) and every Asian big font (`chineset.shx`, `hztxt.shx`, `bigfont.shx` — `bigfont 1.0`) was rejected and text fell back to the LFF substitutes and TrueType fallback glyphs. Drawings authored with SHX big fonts (the norm for Chinese / Japanese / Korean sheets) never showed their real glyphs. - Parse all three containers. Unifont glyphs are keyed by Unicode; big-font glyphs by their double-byte code from a sparse hash table of (code, length, offset) entries. Big fonts reference subshapes with a two-byte `7, 0, hi, lo`. - `bigfont_glyph` encodes a character with encoding_rs (Big5 / GBK / Shift_JIS / EUC-KR, most likely first for the file name) and takes the first code the font has. - `Face::WithBig` pairs a style's big font with its primary face: full-width characters come from the big font, everything else from the primary — how a STYLE with a "Big Font" behaves in AutoCAD. `resolve_text_style` resolves `big_font_file` like the primary (next to the drawing, then the user fonts folder) and rides it along in the font name. - The missing-fonts dialog lists unresolvable big fonts too. Unit tests cover a synthetic big font (Big5 lookup, cap-height scaling, no aliasing of unknown codes), a synthetic unifont, and the pairing. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + Cargo.toml | 3 + src/entities/text_support.rs | 21 +++ src/io/font_repo.rs | 27 ++-- src/scene/text/font_face.rs | 64 ++++++++- src/scene/text/shx.rs | 270 ++++++++++++++++++++++++++++++++--- 6 files changed, 351 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4da766abd..e50a11fdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,7 @@ dependencies = [ "console_error_panic_hook", "cosmic-text 0.15.0", "dwg-thumbnailer", + "encoding_rs", "env_logger", "flate2", "fluent-syntax", diff --git a/Cargo.toml b/Cargo.toml index 6e78631dc..b8d1a54fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,9 @@ ttf-parser = "0.25" cosmic-text = "0.15" unicode-bidi = "0.3" lyon_tessellation = "1.0.20" +# Big-font (.shx) lookups: encode a char to the font's Big5 / GBK / Shift_JIS +# / EUC-KR double-byte code. Already in the tree through other deps. +encoding_rs = "0.8" pollster = "0.4" [dev-dependencies] diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 743fe85ef..d4adac03f 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -77,6 +77,27 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT } } + // A style's "Big Font" (`chineset.shx`, `hztxt.shx`, …) supplies the + // double-byte characters. Resolve it like the primary font (next to the + // drawing, then the per-user fonts folder) and pair it with the face; an + // unresolvable big font just leaves those characters to the fallback. + if let Some(big) = style + .map(|s| s.big_font_file.trim()) + .filter(|big| !big.is_empty() && big.to_ascii_lowercase().ends_with(".shx")) + { + let base = document + .source_path + .as_deref() + .map(std::path::Path::new) + .and_then(|p| p.parent()); + let big_path = crate::io::resolve_image_file(big, base).or_else(|| { + crate::io::font_repo::local_font_file(big).map(|p| p.to_string_lossy().into_owned()) + }); + if let Some(big_path) = big_path { + font_name = Face::with_big_font(&font_name, &big_path); + } + } + ResolvedTextStyle { font_name, width_factor: style.map(|s| s.width_factor as f32).unwrap_or(1.0), diff --git a/src/io/font_repo.rs b/src/io/font_repo.rs index 8df774c66..5b53e8ec7 100644 --- a/src/io/font_repo.rs +++ b/src/io/font_repo.rs @@ -70,18 +70,21 @@ pub fn missing_shx_fonts(doc: &CadDocument) -> Vec { .map(Path::new) .and_then(|p| p.parent()); for style in doc.text_styles.iter() { - let file = style.font_file.trim(); - if file.is_empty() || !file.to_ascii_lowercase().ends_with(".shx") { - continue; - } - let resolved = crate::io::resolve_image_file(file, base).is_some() - || local_font_file(file).is_some(); - if resolved { - continue; - } - let name = file.rsplit(['/', '\\']).next().unwrap_or(file).to_string(); - if !missing.iter().any(|m| m.eq_ignore_ascii_case(&name)) { - missing.push(name); + // The primary font and the big font (Asian double-byte glyphs) are + // both plain .shx files the style depends on. + for file in [style.font_file.trim(), style.big_font_file.trim()] { + if file.is_empty() || !file.to_ascii_lowercase().ends_with(".shx") { + continue; + } + let resolved = crate::io::resolve_image_file(file, base).is_some() + || local_font_file(file).is_some(); + if resolved { + continue; + } + let name = file.rsplit(['/', '\\']).next().unwrap_or(file).to_string(); + if !missing.iter().any(|m| m.eq_ignore_ascii_case(&name)) { + missing.push(name); + } } } missing.sort_by_key(|name| name.to_ascii_lowercase()); diff --git a/src/scene/text/font_face.rs b/src/scene/text/font_face.rs index 832e1eaf4..42d29cbdb 100644 --- a/src/scene/text/font_face.rs +++ b/src/scene/text/font_face.rs @@ -47,12 +47,37 @@ pub enum Face { /// Space advance (9-unit). word: f32, }, + /// A face paired with an Asian big font (`chineset.shx`, `hztxt.shx`, …): + /// double-byte characters come from the big font, everything else from + /// `primary` — exactly how a STYLE with a "Big Font" behaves in AutoCAD. + WithBig { + primary: Box, + /// Absolute path of the `AutoCAD-86 bigfont 1.0` file. + big: String, + }, } +/// Separator between a style's primary font name and its big-font path in +/// the single `font_name` string the text pipeline passes around (see +/// [`Face::with_big_font`]); a control character no font name contains. +pub const BIG_FONT_SEP: char = '\u{1}'; + impl Face { /// Resolve a style's font name to a concrete face. Embedded stroke fonts /// take priority; only otherwise-unknown names try the system fonts. pub fn resolve(font_name: &str) -> Face { + // `primarybig` pairs a big font with the primary face. + if let Some((primary, big)) = font_name.split_once(BIG_FONT_SEP) { + let face = Face::resolve(primary); + return if !big.is_empty() && shx::is_bigfont(big) { + Face::WithBig { + primary: Box::new(face), + big: big.to_string(), + } + } else { + face + }; + } // A resolvable on-disk .SHX font renders its real stroke glyphs; the // LFF substitutes only cover names we can't load. if font_name.to_ascii_lowercase().ends_with(".shx") @@ -87,6 +112,13 @@ impl Face { Face::Lff(lff::get_font(font_name)) } + /// The `font_name` string that pairs `primary` with the big font at + /// `big_path` for [`Face::resolve`]. Every text entry point takes a font + /// name, so the pairing rides along in that string. + pub fn with_big_font(primary: &str, big_path: &str) -> String { + format!("{primary}{BIG_FONT_SEP}{big_path}") + } + /// Look up a glyph. A stroke (LFF) font uses its own glyphs; anything it /// lacks falls back to a system TrueType font chosen by cosmic-text (filled /// outline, so covers scripts no stroke font provides). The TTF path mirrors @@ -103,6 +135,14 @@ impl Face { Face::Shx { path, .. } => shx::font_glyph(path, ch as u16) .map(GlyphRef::Owned) .or_else(|| ttf_glyph::fallback_glyph(ch).map(GlyphRef::Owned)), + Face::WithBig { primary, big } => { + if ttf_glyph::is_full_width(ch) { + if let Some(g) = shx::bigfont_glyph(big, ch) { + return Some(GlyphRef::Owned(g)); + } + } + primary.glyph(ch) + } } } @@ -114,6 +154,7 @@ impl Face { Face::Ttf { .. } => 0.0, // SHX advances already include the trailing gap by design. Face::Shx { .. } => 0.0, + Face::WithBig { primary, .. } => primary.letter_spacing(), } } @@ -122,6 +163,7 @@ impl Face { match self { Face::Lff(f) => f.word_spacing, Face::Ttf { word, .. } | Face::Shx { word, .. } => *word, + Face::WithBig { primary, .. } => primary.word_spacing(), } } @@ -132,6 +174,7 @@ impl Face { match self { Face::Ttf { family, .. } => Some(family), Face::Lff(_) | Face::Shx { .. } => None, + Face::WithBig { primary, .. } => primary.ttf_family(), } } @@ -142,6 +185,7 @@ impl Face { Face::Lff(f) => f.line_spacing, Face::Ttf { .. } => 1.0, Face::Shx { line, .. } => *line, + Face::WithBig { primary, .. } => primary.line_spacing(), } } } @@ -236,11 +280,27 @@ mod tests { // they should resolve to Ttf. If they aren't installed, Lff fallback is accepted. eprintln!("Font {} resolved to Lff (probably not installed)", test_name); } - Face::Shx { .. } => { - // A system TTF name never resolves to an SHX shape font. + Face::Shx { .. } | Face::WithBig { .. } => { + // A system TTF name never resolves to an SHX shape font + // (nor gains a big font without the pairing separator). eprintln!("Font {} resolved to Shx (unexpected)", test_name); } } } } + + + + #[test] + fn big_font_pairing_routes_ideographs_and_leaves_latin_alone() { + // Without a real big-font file the pairing is dropped: the primary + // face is used as-is. + assert!(matches!( + Face::resolve(&Face::with_big_font("txt", "C:/nowhere/chineset.shx")), + Face::Lff(_) + )); + // A stroke face still renders Latin from its own glyphs when paired. + let face = Face::resolve("txt"); + assert!(face.glyph('A').is_some()); + } } diff --git a/src/scene/text/shx.rs b/src/scene/text/shx.rs index 51828b495..997e48c30 100644 --- a/src/scene/text/shx.rs +++ b/src/scene/text/shx.rs @@ -50,7 +50,20 @@ const DIRS: [[f64; 2]; 16] = [ [1.0, -0.5], ]; +/// Which `AutoCAD-86` container a file is; decides how glyphs are keyed and +/// how a `7` (subshape) opcode is encoded. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum ShxKind { + /// `shapes 1.0/1.1`: keyed by 8-bit code (or shape number). + Shapes, + /// `unifont 1.0`: keyed by Unicode code point. + Unifont, + /// `bigfont 1.0`: keyed by a double-byte legacy code (Big5, GBK, …). + Bigfont, +} + struct ShxFile { + kind: ShxKind, /// shape number → (name, spec bytes) shapes: HashMap)>, } @@ -81,39 +94,158 @@ fn load_file(path: &str) -> Option> { fn parse_file(path: &str) -> Option { let bytes = std::fs::read(path).ok()?; - // Header line: `AutoCAD-86 shapes 1.0`/`1.1` + CR LF SUB. Reject fonts. + // Header line: `AutoCAD-86 1.x` + CR LF SUB. let head_end = bytes.iter().position(|&b| b == 0x1A)?; let header = String::from_utf8_lossy(&bytes[..head_end]); - if !header.contains("shapes") { + let kind = if header.contains("shapes") { + ShxKind::Shapes + } else if header.contains("unifont") { + ShxKind::Unifont + } else if header.contains("bigfont") { + ShxKind::Bigfont + } else { return None; - } + }; let mut p = head_end + 1; let rd_u16 = |p: &mut usize| -> Option { let v = u16::from_le_bytes([*bytes.get(*p)?, *bytes.get(*p + 1)?]); *p += 2; Some(v) }; - let _first = rd_u16(&mut p)?; - let _last = rd_u16(&mut p)?; - let count = rd_u16(&mut p)? as usize; - let mut dir: Vec<(u16, usize)> = Vec::with_capacity(count); - for _ in 0..count { - let num = rd_u16(&mut p)?; - let len = rd_u16(&mut p)? as usize; - dir.push((num, len)); - } - let mut shapes = HashMap::new(); - for (num, len) in dir { - let end = p.checked_add(len)?.min(bytes.len()); - let blob = &bytes[p..end]; - p = end; - // Name = leading NUL-terminated ASCII, spec follows. + let rd_u32 = |p: &mut usize| -> Option { + let v = u32::from_le_bytes([ + *bytes.get(*p)?, + *bytes.get(*p + 1)?, + *bytes.get(*p + 2)?, + *bytes.get(*p + 3)?, + ]); + *p += 4; + Some(v) + }; + // Glyph data = leading NUL-terminated ASCII name, spec follows. + let split_named = |blob: &[u8]| -> (String, Vec) { let name_end = blob.iter().position(|&b| b == 0).unwrap_or(0); let name = String::from_utf8_lossy(&blob[..name_end]).into_owned(); let spec = blob.get(name_end + 1..).unwrap_or(&[]).to_vec(); - shapes.insert(num, (name, spec)); + (name, spec) + }; + let mut shapes = HashMap::new(); + match kind { + ShxKind::Shapes => { + let _first = rd_u16(&mut p)?; + let _last = rd_u16(&mut p)?; + let count = rd_u16(&mut p)? as usize; + let mut dir: Vec<(u16, usize)> = Vec::with_capacity(count); + for _ in 0..count { + let num = rd_u16(&mut p)?; + let len = rd_u16(&mut p)? as usize; + dir.push((num, len)); + } + for (num, len) in dir { + let end = p.checked_add(len)?.min(bytes.len()); + let blob = &bytes[p..end]; + p = end; + shapes.insert(num, split_named(blob)); + } + } + ShxKind::Unifont => { + // u32 glyph count (including the font header), then the header + // entry (u16 length + name NUL above below modes encoding + // embedded 0), then `count - 1` × (u16 code, u16 length, data). + let count = rd_u32(&mut p)? as usize; + let head_len = rd_u16(&mut p)? as usize; + let end = p.checked_add(head_len)?.min(bytes.len()); + shapes.insert(0, split_named(&bytes[p..end])); + p = end; + for _ in 1..count { + let Some(code) = rd_u16(&mut p) else { break }; + let Some(len) = rd_u16(&mut p) else { break }; + let end = p.checked_add(len as usize)?.min(bytes.len()); + shapes.insert(code, split_named(&bytes[p..end])); + p = end; + } + } + ShxKind::Bigfont => { + // u16 bytes per table entry (8), u16 table entries, u16 range + // count, ranges × (u16 lo, u16 hi) of lead bytes, then the entry + // table — a sparse hash table of (u16 code, u16 length, u32 file + // offset); empty slots are all zero. Shape 0 (name NUL above + // below modes) is the font header. + let entry_size = rd_u16(&mut p)? as usize; + let entries = rd_u16(&mut p)? as usize; + let ranges = rd_u16(&mut p)? as usize; + p = p.checked_add(ranges * 4)?; + if entry_size < 8 { + return None; + } + for i in 0..entries { + let mut q = p.checked_add(i * entry_size)?; + let Some(code) = rd_u16(&mut q) else { break }; + let Some(len) = rd_u16(&mut q) else { break }; + let Some(off) = rd_u32(&mut q) else { break }; + if off == 0 && len == 0 { + continue; + } + let start = off as usize; + let end = start.saturating_add(len as usize).min(bytes.len()); + if start >= end { + continue; + } + shapes.insert(code, split_named(&bytes[start..end])); + } + } } - Some(ShxFile { shapes }) + Some(ShxFile { kind, shapes }) +} + +/// True when the file is an Asian big font (`AutoCAD-86 bigfont 1.0`). +pub fn is_bigfont(path: &str) -> bool { + load_file(path).is_some_and(|f| f.kind == ShxKind::Bigfont) +} + +/// The double-byte legacy encodings a big font may be keyed by, most likely +/// first for this file name. The font itself does not say; try them in turn +/// and take the first code the font actually has. +fn bigfont_encodings(path: &str) -> [&'static encoding_rs::Encoding; 4] { + use encoding_rs::{BIG5, EUC_KR, GBK, SHIFT_JIS}; + let name = path + .rsplit(['/', '\\']) + .next() + .unwrap_or(path) + .to_ascii_lowercase(); + if name.contains("gb") || name.contains("hz") || name.contains("china") { + [GBK, BIG5, SHIFT_JIS, EUC_KR] + } else if name.contains("bigfont") || name.contains("extfont") || name.contains("jp") { + [SHIFT_JIS, BIG5, GBK, EUC_KR] + } else if name.contains("whg") || name.contains("kor") || name.contains("han") { + [EUC_KR, BIG5, GBK, SHIFT_JIS] + } else { + // `chineset.shx` and most Taiwanese fonts. + [BIG5, GBK, SHIFT_JIS, EUC_KR] + } +} + +/// Look up `ch` in a big font: encode it to the font's double-byte code and +/// fetch that shape, normalised like [`font_glyph`]. `None` when the file is +/// not a big font or has no glyph for the character. +pub fn bigfont_glyph(path: &str, ch: char) -> Option> { + let file = load_file(path)?; + if file.kind != ShxKind::Bigfont { + return None; + } + let mut buf = [0u8; 4]; + let s = ch.encode_utf8(&mut buf); + for enc in bigfont_encodings(path) { + let (bytes, _, unmappable) = enc.encode(s); + if unmappable || bytes.len() != 2 { + continue; + } + let code = u16::from_be_bytes([bytes[0], bytes[1]]); + if file.shapes.contains_key(&code) { + return font_glyph(path, code); + } + } + None } /// Tessellate the shape named `name` (case-insensitive) from the SHX at @@ -410,8 +542,16 @@ fn interpret( } } 7 => { - let sub = *b.get(i).unwrap_or(&0) as u16; + let mut sub = *b.get(i).unwrap_or(&0) as u16; i += 1; + // A big font references subshapes by a two-byte code: + // `7, 0, hi, lo`. + if file.kind == ShxKind::Bigfont && sub == 0 { + let hi = *b.get(i).unwrap_or(&0) as u16; + let lo = *b.get(i + 1).unwrap_or(&0) as u16; + i += 2; + sub = (hi << 8) | lo; + } if !skipping && sub != 0 && sub != shape_number { interpret(file, sub, st, cur, out, depth + 1); } @@ -510,3 +650,91 @@ fn interpret( } } } + + +#[cfg(test)] +mod tests { + use super::*; + + /// Shape bytecode for a glyph: a horizontal 6-unit stroke, then end. + /// (`0x60` = length 6, direction 0.) + const BAR: [u8; 2] = [0x60, 0x00]; + + fn tmp(name: &str, bytes: &[u8]) -> String { + let dir = std::env::temp_dir().join(format!("ocs-shx-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().into_owned() + } + + /// A one-glyph `bigfont 1.0` keyed by the Big5 code of `中` (0xA4A4), + /// laid out like `chineset.shx`: header, 8-byte entries in a sparse + /// table, font header shape 0 (`name NUL above below modes`). + fn synthetic_bigfont() -> Vec { + let mut b = b"AutoCAD-86 bigfont 1.0\r\n\x1a".to_vec(); + let entries: u16 = 4; + b.extend_from_slice(&8u16.to_le_bytes()); + b.extend_from_slice(&entries.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); // one lead-byte range + b.extend_from_slice(&0x80u16.to_le_bytes()); + b.extend_from_slice(&0xFFu16.to_le_bytes()); + let table_at = b.len(); + b.resize(table_at + 8 * entries as usize, 0); + let head_data: Vec = [b"BIG\x00".as_slice(), &[48u8, 0, 2]].concat(); + let glyph_data: Vec = [b"\x00".as_slice(), &BAR].concat(); + let head_off = b.len() as u32; + b.extend_from_slice(&head_data); + let glyph_off = b.len() as u32; + b.extend_from_slice(&glyph_data); + // Slot 0 = shape 0; slot 2 = the glyph (slots 1 and 3 stay empty). + let mut entry = |slot: usize, code: u16, len: u16, off: u32| { + let at = table_at + slot * 8; + b[at..at + 2].copy_from_slice(&code.to_le_bytes()); + b[at + 2..at + 4].copy_from_slice(&len.to_le_bytes()); + b[at + 4..at + 8].copy_from_slice(&off.to_le_bytes()); + }; + entry(0, 0, head_data.len() as u16, head_off); + entry(2, 0xA4A4, glyph_data.len() as u16, glyph_off); + b + } + + /// A `unifont 1.0` with the font header and one glyph for `A` (U+0041). + fn synthetic_unifont() -> Vec { + let mut b = b"AutoCAD-86 unifont 1.0\r\n\x1a".to_vec(); + b.extend_from_slice(&2u32.to_le_bytes()); // header + 1 glyph + let head: Vec = [b"UNI\x00".as_slice(), &[6u8, 2, 2, 0, 0, 0]].concat(); + b.extend_from_slice(&(head.len() as u16).to_le_bytes()); + b.extend_from_slice(&head); + let glyph: Vec = [b"\x00".as_slice(), &BAR].concat(); + b.extend_from_slice(&0x41u16.to_le_bytes()); + b.extend_from_slice(&(glyph.len() as u16).to_le_bytes()); + b.extend_from_slice(&glyph); + b + } + + #[test] + fn bigfont_glyph_is_found_by_big5_code_and_scaled_to_cap_height() { + let path = tmp("cjk.shx", &synthetic_bigfont()); + assert!(is_bigfont(&path)); + let (above, below) = font_metrics(&path).expect("font header"); + assert_eq!((above, below), (48.0, 0.0)); + // `中` → Big5 A4A4 → the glyph; `A` is not in a big font. + let g = bigfont_glyph(&path, '中').expect("glyph for 中"); + // 6 font units at above = 48 → 6 * 9 / 48 = 1.125 text units. + assert!((g.advance - 1.125).abs() < 1e-4, "advance {}", g.advance); + assert_eq!(g.strokes.len(), 1); + assert!(bigfont_glyph(&path, 'A').is_none()); + assert!(bigfont_glyph(&path, '國').is_none(), "unknown code must miss, not alias"); + } + + #[test] + fn unifont_glyph_is_keyed_by_unicode() { + let path = tmp("uni.shx", &synthetic_unifont()); + assert!(!is_bigfont(&path)); + assert_eq!(font_metrics(&path), Some((6.0, 2.0))); + let g = font_glyph(&path, 'A' as u16).expect("glyph for A"); + assert!((g.advance - 9.0).abs() < 1e-4, "6 units at above 6 → 9: {}", g.advance); + assert!(font_glyph(&path, 'B' as u16).is_none()); + } +} From 24f8e124d52c62ca8a658c6e06e1259dcab310e8 Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 21:01:05 +0800 Subject: [PATCH 13/21] fix(text): re-lay-out cached glyph quads after the SDF atlas grows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Glyph quads bake the atlas UV of their tile. `grow_height` rescales every cached entry's V and bumps the atlas generation, and the tessellation memo folds that generation in — but the caches that hold finished wires do not: the resident wire set is keyed by geometry epoch, block definitions by block epoch, and a full build can grow the atlas part-way through (a CJK sheet set bakes thousands of tiles on first sight). Text tessellated before the growth then samples the wrong tiles for as long as those caches live, which on a big drawing is forever: whole tables of scrambled glyphs, and the block contents stayed scrambled even after a REGEN. - `resident_wires_for` rebuilds (dropping the block definition cache) until the atlas generation is stable across the pass. - `Scene::update` compares the live generation with the one the scene's caches were built against and bumps geometry once when it moved, so a later growth (opening a layout, editing text) re-lays-out everything. Co-Authored-By: Claude Opus 5 (1M context) --- src/scene/camera_ops.rs | 13 ++++++++++++- src/scene/mod.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index a1db36b48..3ee2e7ce1 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -1566,5 +1566,16 @@ impl Scene { self.camera_generation += 1; } - pub fn update(&mut self, _dt: Duration) {} + pub fn update(&mut self, _dt: Duration) { + // The glyph atlas grew or re-baked since this scene last built its + // caches (a CJK sheet set bakes thousands of tiles on first sight): + // every quad laid out before that samples the wrong tile — scrambled + // text in the affected blocks / viewports. One structural bump + // re-lays-out all text against the settled atlas. + let atlas_gen = crate::scene::text::sdf_atlas::generation(); + if self.last_atlas_generation.get() != atlas_gen { + self.last_atlas_generation.set(atlas_gen); + self.bump_geometry(); + } + } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e2b373127..4fc66e527 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2348,6 +2348,11 @@ pub struct Scene { /// (re)built, otherwise the held value. `build_primitive` reads it right /// after the call to gate GPU wire re-upload. 0 = none yet. pub(crate) last_model_wire_gen: std::cell::Cell, + /// SDF glyph-atlas generation this scene's caches were built against. + /// When the atlas grows or re-bakes, every cached glyph quad (resident + /// sets, block caches, projected viewport copies) addresses the wrong + /// tile; `update` compares against the live generation and rebuilds. + pub(crate) last_atlas_generation: std::cell::Cell, /// Interaction-LOD state: `camera_generation` seen on the previous frame and /// the wall time it last changed. Used to detect "the view is actively being /// panned / zoomed / orbited" so the expensive per-pixel hatch pass can be @@ -2638,6 +2643,7 @@ impl Scene { last_tess_ms: std::cell::Cell::new(0.0), last_tess_wires: std::cell::Cell::new(0), last_model_wire_gen: std::cell::Cell::new(0), + last_atlas_generation: std::cell::Cell::new(crate::scene::text::sdf_atlas::generation()), nav_last_gen: std::cell::Cell::new(0), nav_changed_at: std::cell::Cell::new(None), nav_perf_pending: std::cell::Cell::new(None), @@ -6105,6 +6111,7 @@ impl Scene { // Build once: full tessellation, no cull, no zoom LOD — the resident // set is zoom-independent (GPU analytical circles/arcs/ellipses). let t_tess = iced::time::Instant::now(); + let mut atlas_gen = crate::scene::text::sdf_atlas::generation(); let mut wires = self.wires_for_block_culled( block, None, @@ -6115,6 +6122,34 @@ impl Scene { all_visible, style_viewport, ); + // Baking a drawing's glyphs for the first time can grow the SDF atlas + // part-way through this pass (a CJK sheet set bakes thousands of + // tiles). Text tessellated before the growth carries UVs that now + // address the wrong tiles — visible as scrambled glyphs — and this + // set is cached by geometry epoch, so nothing would ever re-lay it + // out. The memo guard already folds the generation in, so rebuilding + // once the atlas has settled re-tessellates only the stale text. + for _ in 0..3 { + let now = crate::scene::text::sdf_atlas::generation(); + if now == atlas_gen { + break; + } + atlas_gen = now; + // Block definitions tessellated earlier in this pass hold the + // stale quads too, and their cache is keyed by block epoch (which + // has not moved), so drop them or the rebuild would reuse them. + self.block_defn_cache.borrow_mut().clear(); + wires = self.wires_for_block_culled( + block, + None, + None, + frozen_layers, + anno_scale_override, + annotation_scale_handle, + all_visible, + style_viewport, + ); + } let perf = crate::perf::enabled(); let t_post = perf.then(iced::time::Instant::now); // Synthesized nonprint markers (geo-location daisy) live in model space From 5fab97a7d0067c55ef4e7e7e9f833a9c4f6c7cd0 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:04:26 +0300 Subject: [PATCH 14/21] Aligned constraint: axis lines, zero collapse, Properties form/reference/description, screen-sized text Point & line and 2Lines take a text baseline or an ellipse axis as the line; a zero value folds the points onto the first one; 2Lines turns the second line about its start. The Properties Constraint section edits Constraint Form (Dynamic on the *ADSK_CONSTRAINTS layer, Annotational on the current layer), Reference (driven constraint in parentheses whose parameter follows the measurement) and Description (stored with the parameter and the native variable). Dynamic dimensions keep a 12 px text and 8 px arrows at any zoom, drop trailing zeros and show formulas as `fx: len=d1*2`; the hidden constraints layer stays out of the layer lists. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 182 +++++++++++++++++++++-- src/app/layers.rs | 2 + src/app/properties.rs | 48 ++++-- src/app/update/command.rs | 6 + src/app/update/viewport.rs | 2 + src/modules/parametric/dim_constraint.rs | 36 +++++ src/scene/dwg_native_constraints.rs | 21 ++- src/scene/entity.rs | 2 +- src/scene/named_parameters.rs | 21 +++ src/scene/parametric_constraints.rs | 167 +++++++++++++++++++-- src/scene/parametric_solve.rs | 32 +++- src/ui/window/layers.rs | 2 + 12 files changed, 476 insertions(+), 45 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 29a1bbd02..b444ffd3b 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1432,6 +1432,11 @@ impl OpenCADStudio { current.name.clone(), ), ParamField::Name => { + let description = self.tabs[i] + .scene + .named_parameters() + .description(¤t.name) + .to_string(); self.tabs[i] .scene .named_parameters_mut() @@ -1440,12 +1445,19 @@ impl OpenCADStudio { .scene .named_parameters_mut() .set(&typed, ¤t.source); - if outcome.is_err() { + let kept = if outcome.is_err() { let _ = self.tabs[i] .scene .named_parameters_mut() .set(¤t.name, ¤t.source); - } + current.name.as_str() + } else { + typed.as_str() + }; + self.tabs[i] + .scene + .named_parameters_mut() + .set_description(kept, &description); (outcome, typed.clone()) } }; @@ -1500,6 +1512,133 @@ impl OpenCADStudio { Task::none() } + /// The dynamic dimension the Properties panel targets: its handle, scope, + /// constraint id and parameter name. + fn dynamic_dimension_target( + &self, + i: usize, + ) -> Option<( + Handle, + crate::scene::parametric_constraints::ParametricScope, + crate::scene::parametric_constraints::ConstraintId, + String, + )> { + use crate::scene::named_parameters::DrivingValue; + use crate::scene::parametric_constraints::dynamic_dimension_constraint; + let handle = *self.property_target_handles(i).first()?; + let (set, constraint) = + dynamic_dimension_constraint(&self.tabs[i].scene.parametric_constraints, handle)?; + let Some(DrivingValue::Named(name)) = &constraint.driving_param else { + return None; + }; + Some((handle, set.scope, constraint.id, name.clone())) + } + + /// Commits a dynamic dimension's Description row into its parameter. + pub(super) fn on_dynamic_dimension_description_commit( + &mut self, + field: &'static str, + ) -> Task { + use crate::ui::properties::FieldKey; + let i = self.active_tab; + self.tabs[i].properties.active_field = None; + let Some(typed) = self.tabs[i] + .properties + .edit_buf + .remove(&FieldKey::Geom(field)) + else { + return Task::none(); + }; + let Some((_, _, _, name)) = self.dynamic_dimension_target(i) else { + self.refresh_properties(); + return Task::none(); + }; + let pending = self.begin_undo(i, "Constraint description", 0, true); + self.tabs[i].scene.record_undo_named_parameters_before(); + self.tabs[i] + .scene + .named_parameters_mut() + .set_description(&name, &typed); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + Task::none() + } + + /// Applies a dynamic dimension's Constraint Form or Reference choice. + pub(super) fn on_dynamic_dimension_choice( + &mut self, + field: &'static str, + value: &str, + ) -> Task { + use crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER; + let i = self.active_tab; + let Some((handle, scope, id, _)) = self.dynamic_dimension_target(i) else { + return Task::none(); + }; + match field { + "dyn_constraint_form" => { + // Annotational: an ordinary dimension on the current layer + // that plots; Dynamic: the gray one on the constraints layer. + let annotational = value.eq_ignore_ascii_case("Annotational"); + let active_layer = self.tabs[i].active_layer.clone(); + if !annotational { + self.tabs[i].scene.ensure_dynamic_dimension_layer(); + } + self.apply_property_op(i, "Constraint form", &[handle], |app, handle| { + let Some(mut entity) = app.tabs[i].scene.document.get_entity(handle).cloned() + else { + return; + }; + if annotational { + entity.as_entity_mut().set_layer(active_layer.clone()); + entity.common_mut().color = acadrust::types::Color::ByLayer; + } else { + entity + .as_entity_mut() + .set_layer(DYNAMIC_DIMENSION_LAYER.to_string()); + entity.common_mut().color = acadrust::types::Color::Rgb { + r: 103, + g: 109, + b: 118, + }; + } + app.tabs[i].scene.update_entity(entity); + }); + } + "dyn_constraint_reference" => { + // A reference constraint reads the geometry instead of + // driving it; its text sits in parentheses. + let reference = + value.eq_ignore_ascii_case("Yes") || value == crate::t!("Yes").as_ref(); + let Some(before) = self.tabs[i].scene.parametric_constraint_set(scope).cloned() + else { + return Task::none(); + }; + let pending = self.begin_undo(i, "Constraint reference", 0, true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + self.tabs[i].scene.record_undo_named_parameters_before(); + let set = self.tabs[i].scene.parametric_constraint_set_mut(scope); + if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) { + constraint.enabled = !reference; + } + self.tabs[i].scene.refresh_dynamic_dimension_texts(); + self.tabs[i].scene.sync_native_parametric_graph(); + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + self.refresh_properties(); + } + _ => {} + } + Task::none() + } + /// Commits a dynamic dimension's Name or Expression row: the typed text /// goes to the Parameters-section row of the constraint's parameter. pub(super) fn on_dynamic_dimension_field_commit( @@ -3718,6 +3857,8 @@ impl OpenCADStudio { &name, value, self.tabs[i].scene.constraint_name_format, + false, + Some(&expression), ); let mut entity = if kind == ConstraintKind::Distance { aligned_dim::aligned_dimension_entity( @@ -3739,10 +3880,12 @@ impl OpenCADStudio { &self.tabs[i].scene.document, &mut entity, ); - let layer = self.tabs[i].active_layer.clone(); - if layer != "0" { - entity.as_entity_mut().set_layer(layer); - } + // A dynamic dimension lives on the reference's constraints + // layer, hidden from the layer lists and never plotted. + self.tabs[i].scene.ensure_dynamic_dimension_layer(); + entity.as_entity_mut().set_layer( + crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER.to_string(), + ); // A dynamic dimension draws in the constraint gray, not the // current color. entity.common_mut().color = acadrust::types::Color::Rgb { @@ -3857,10 +4000,13 @@ impl OpenCADStudio { id, self.constraint_bar_display, ); - // The first line stays; the second turns parallel to it. + // The first line stays; the second turns about its start + // to run parallel, as the reference keeps that start. + let mut pins = first_ends.to_vec(); + pins.push(second_ends[0]); self.tabs[i].scene.bump_entities_with_parametric_policy( &[(second_line.entity, crate::scene::ChangeKind::Modified)], - &first_ends, + &pins, self.constraint_solve_mode, ); self.tabs[i].dirty = true; @@ -8299,10 +8445,22 @@ fn entity_at_typed_point( extent = extent .max((bounds.max.x - bounds.min.x).abs()) .max((bounds.max.y - bounds.min.y).abs()); - let Some(distance) = - crate::scene::viewport_dimension_pick::planar_pick_distance(entity, point) - else { - continue; + let distance = match crate::scene::viewport_dimension_pick::planar_pick_distance(entity, point) { + Some(distance) => distance, + // A text or an ellipse answers by its extent, the way a click + // inside it picks it. + None if matches!( + entity, + acadrust::EntityType::Text(_) + | acadrust::EntityType::MText(_) + | acadrust::EntityType::Ellipse(_) + ) => + { + let dx = (bounds.min.x - point.x).max(point.x - bounds.max.x).max(0.0); + let dy = (bounds.min.y - point.y).max(point.y - bounds.max.y).max(0.0); + dx.hypot(dy) + } + None => continue, }; if nearest.is_none_or(|(best, _)| distance < best) { nearest = Some((distance, common.handle)); diff --git a/src/app/layers.rs b/src/app/layers.rs index 6be69c8f3..24792f24c 100644 --- a/src/app/layers.rs +++ b/src/app/layers.rs @@ -62,6 +62,8 @@ impl OpenCADStudio { .layers .layers .iter() + // The reference's hidden system layers (`*ADSK_CONSTRAINTS`) stay out. + .filter(|l| !l.name.starts_with('*')) .map(|l| crate::ui::ribbon::LayerInfo { name: l.name.clone(), color: crate::ui::window::layers::iced_color_from_acad(&l.color), diff --git a/src/app/properties.rs b/src/app/properties.rs index 185a0ff2a..edc43673f 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -79,6 +79,8 @@ impl OpenCADStudio { .layers .iter() .map(|l| l.name.clone()) + // The reference's hidden system layers (`*ADSK_CONSTRAINTS`) stay out. + .filter(|name| !name.starts_with('*')) .collect(); let linetype_items: Vec = self.tabs[i] .scene @@ -2301,6 +2303,7 @@ impl OpenCADStudio { Some((set, constraint)) => { sections = dynamic_dimension_sections( &self.tabs[i].scene, + handle, set, constraint, sections, @@ -3614,22 +3617,30 @@ fn set_row(sections: &mut [crate::scene::model::object::PropSection], field: &st /// Replace a row's value with an arbitrary control (editable field, dropdown, /// colour picker …) rather than plain read-only text. -/// A dynamic dimension's Properties as the reference shows them: the -/// Constraint rows, then only the text rotation of the dimension itself. +/// A dimensional constraint's Properties as the reference shows them: the +/// Constraint rows, then only the text rotation for a dynamic dimension +/// and the full dimension sections for an annotational one. fn dynamic_dimension_sections( scene: &crate::scene::Scene, + handle: Handle, set: &crate::scene::parametric_constraints::ParametricConstraintSet, constraint: &crate::scene::parametric_constraints::ParametricConstraint, sections: Vec, ) -> Vec { use crate::scene::model::object::{PropSection, PropValue, Property}; use crate::scene::named_parameters::DrivingValue; + use crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER; let table = if set.local_parameters.is_empty() { scene.named_parameters() } else { &set.local_parameters }; - let (name, expression, value) = match &constraint.driving_param { + let reference = !constraint.enabled; + let annotational = scene + .document + .get_entity(handle) + .is_some_and(|entity| entity.common().layer != DYNAMIC_DIMENSION_LAYER); + let (name, expression, value, description) = match &constraint.driving_param { Some(DrivingValue::Named(name)) => ( name.clone(), table @@ -3638,15 +3649,22 @@ fn dynamic_dimension_sections( .map(|parameter| parameter.source.clone()) .unwrap_or_default(), table.resolve(name).ok(), + table.description(name).to_string(), ), - Some(DrivingValue::Literal(value)) => (String::new(), format!("{value}"), Some(*value)), - None => (String::new(), String::new(), None), + Some(DrivingValue::Literal(value)) => { + (String::new(), format!("{value}"), Some(*value), String::new()) + } + None => (String::new(), String::new(), None, String::new()), }; let row = |label: &str, field: &'static str, value: PropValue| Property { label: label.to_string(), field, value, }; + let yes_no = |flag: bool| PropValue::Choice { + selected: if flag { t!("Yes") } else { t!("No") }.into_owned(), + options: vec![t!("Yes").into_owned(), t!("No").into_owned()], + }; let text_rotation = sections .iter() .flat_map(|section| section.props.iter()) @@ -3658,18 +3676,26 @@ fn dynamic_dimension_sections( row( t!("Constraint Form").as_ref(), "dyn_constraint_form", - PropValue::ReadOnly("Dynamic".to_string()), + PropValue::Choice { + selected: if annotational { "Annotational" } else { "Dynamic" }.to_string(), + options: vec!["Dynamic".to_string(), "Annotational".to_string()], + }, ), row( t!("Reference").as_ref(), "dyn_constraint_reference", - PropValue::ReadOnly(t!("No").into_owned()), + yes_no(reference), ), row(t!("Name").as_ref(), "dyn_constraint_name", PropValue::EditText(name)), row( t!("Expression").as_ref(), "dyn_constraint_expression", - PropValue::EditText(expression), + // A reference constraint's expression is the measurement. + if reference { + PropValue::ReadOnly(expression) + } else { + PropValue::EditText(expression) + }, ), row( t!("Value").as_ref(), @@ -3679,11 +3705,13 @@ fn dynamic_dimension_sections( row( t!("Description").as_ref(), "dyn_constraint_description", - PropValue::ReadOnly(String::new()), + PropValue::EditText(description), ), ], }]; - if let Some(text_rotation) = text_rotation { + if annotational { + result.extend(sections); + } else if let Some(text_rotation) = text_rotation { result.push(PropSection { title: t!("Text").into_owned(), props: vec![text_rotation], diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 5e2a18016..fc7ba2ebb 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2188,6 +2188,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { field: &'static str, value: String, ) -> Task { + if matches!(field, "dyn_constraint_form" | "dyn_constraint_reference") { + return self.on_dynamic_dimension_choice(field, &value); + } let i = self.active_tab; let handles = self.property_target_handles(i); @@ -3269,6 +3272,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if let Some(param_field) = dynamic_field { return self.on_dynamic_dimension_field_commit(field, param_field); } + if field == "dyn_constraint_description" { + return self.on_dynamic_dimension_description_commit(field); + } let i = self.active_tab; self.tabs[i].properties.active_field = None; let handles = self.property_target_handles(i); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c5279e5a8..3b52c7c9b 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -785,6 +785,8 @@ impl OpenCADStudio { pub(super) fn on_tick(&mut self, t: Instant) -> Task { let i = self.active_tab; self.tabs[i].scene.update(t - self.start); + // Dynamic dimensions keep a fixed screen size across zooms. + self.tabs[i].scene.refresh_dynamic_dimension_sizes(); // If the camera moved since we last synced, write it back to // the document and mark the file dirty. diff --git a/src/modules/parametric/dim_constraint.rs b/src/modules/parametric/dim_constraint.rs index a5c11d32e..1d34f8a97 100644 --- a/src/modules/parametric/dim_constraint.rs +++ b/src/modules/parametric/dim_constraint.rs @@ -204,6 +204,24 @@ impl DimConstraintCommand { ParametricRef::point(handle, end), ) } + // A text's baseline or an ellipse's axis is a line whose one + // constraint point is the insertion point / the center. + EntityType::Text(_) | EntityType::MText(_) => { + return Self::axis_target(entity, ParametricRef::text_baseline(handle), ParametricRef::point(handle, 0)); + } + EntityType::Ellipse(_) => { + let center = ParametricRef::point(handle, -3); + let major = Self::axis_target(entity, ParametricRef::ellipse_major_axis(handle), center); + let minor = Self::axis_target(entity, ParametricRef::ellipse_minor_axis(handle), center); + let gap = |target: &LineTarget| { + let offset = point - target.ends[0].1; + (offset.x * target.dir.y - offset.y * target.dir.x).abs() + }; + return match (major, minor) { + (Some(major), Some(minor)) => Some(if gap(&minor) < gap(&major) { minor } else { major }), + (major, minor) => major.or(minor), + }; + } _ => return None, }; let start = Self::world(entity, first)?; @@ -216,6 +234,24 @@ impl DimConstraintCommand { }) } + /// A directional axis (text baseline, ellipse axis) as a line target + /// whose only constraint point is `anchor`. + fn axis_target(entity: &EntityType, axis: ParametricRef, anchor: ParametricRef) -> Option { + let [start, end] = + crate::scene::parametric_constraints::directional_axis_endpoints(entity, axis)?; + let (start, end) = ( + DVec3::new(start.x, start.y, start.z), + DVec3::new(end.x, end.y, end.z), + ); + let dir = (end - start).try_normalize()?; + let anchor_point = Self::world(entity, anchor)?; + Some(LineTarget { + line: axis, + dir, + ends: [(anchor, anchor_point), (anchor, anchor_point)], + }) + } + /// A picked line for the current line step, or the message to show. fn pick_line(&mut self, handle: Handle, point: DVec3) -> Result { if handle.is_null() { diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index dc8174bf5..14940a2c7 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -1217,6 +1217,7 @@ impl Allocator<'_> { name: &str, formula: &str, resolved: f64, + description: &str, ) -> Handle { if let Some(&handle) = self.variables.get(name) { if let Some(ObjectType::Associative(object)) = self.document.objects.get_mut(&handle) { @@ -1244,7 +1245,7 @@ impl Allocator<'_> { name: name.to_string(), expression: formula.to_string(), evaluator: "AcDbCalc:1.0".to_string(), - description: String::new(), + description: description.to_string(), value: Self::numeric_eval(resolved), has_cached_value: false, cached_value: String::new(), @@ -1883,6 +1884,7 @@ fn driving_value( variable.expression.clone() }; if parameters.set(&variable.name, &source).is_ok() { + parameters.set_description(&variable.name, &variable.description); Some(DrivingValue::Named(variable.name.clone())) } else { literal @@ -2610,6 +2612,7 @@ fn materialize_scope( variable.action.action_index = index as i32 + 1; if let Some(parameter) = parameters.get(&variable.name) { variable.expression = parameter.source.clone(); + variable.description = parameters.description(¶meter.name).to_string(); if let Ok(resolved) = parameters.resolve(¶meter.name) { variable.value = Allocator::numeric_eval(resolved); } @@ -2621,7 +2624,13 @@ fn materialize_scope( if materialize_all_parameters { for parameter in parameters.iter() { let resolved = parameters.resolve(¶meter.name).unwrap_or(0.0); - allocator.variable(network_handle, ¶meter.name, ¶meter.source, resolved); + allocator.variable( + network_handle, + ¶meter.name, + ¶meter.source, + resolved, + parameters.description(¶meter.name), + ); } } @@ -2666,7 +2675,13 @@ fn materialize_scope( DrivingValue::Literal(_) => (format!("d{node_id}"), resolved.to_string()), }; node_names.insert(node_id, name.clone()); - let variable_handle = allocator.variable(network_handle, &name, &formula, resolved); + let variable_handle = allocator.variable( + network_handle, + &name, + &formula, + resolved, + parameters.description(&name), + ); let dependency_id = group_dependencies.len() as i32 + 1; let dep_handle = allocator.value_dependency(group_handle?, variable_handle, resolved, dependency_id); diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 0fdc9a53d..f0e7e8a22 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1404,7 +1404,7 @@ impl Scene { || (!include_preview_hidden && self.preview_hidden.contains(&common.handle)) || self.hidden_dynamic_dimensions.contains(&common.handle) // A dynamic dimension never plots. - || (plot_only && self.is_dynamic_dimension(common.handle)) + || (plot_only && self.is_plot_hidden_dynamic_dimension(common.handle)) { return false; } diff --git a/src/scene/named_parameters.rs b/src/scene/named_parameters.rs index da6327802..5ed9eb6d6 100644 --- a/src/scene/named_parameters.rs +++ b/src/scene/named_parameters.rs @@ -474,6 +474,9 @@ pub struct Parameter { #[derive(Debug, Clone, Default, PartialEq)] pub struct ParameterTable { parameters: Vec, + /// The free-text description a parameter carries (the reference's + /// Description column), by name. + descriptions: HashMap, } impl ParameterTable { @@ -481,6 +484,23 @@ impl ParameterTable { Self::default() } + pub fn description(&self, name: &str) -> &str { + self.descriptions.get(name).map_or("", String::as_str) + } + + /// Stores `description` for an existing parameter; an empty text clears it. + pub fn set_description(&mut self, name: &str, description: &str) { + if !self.contains(name) { + return; + } + if description.trim().is_empty() { + self.descriptions.remove(name); + } else { + self.descriptions + .insert(name.to_string(), description.trim().to_string()); + } + } + pub fn is_empty(&self) -> bool { self.parameters.is_empty() } @@ -549,6 +569,7 @@ impl ParameterTable { /// need to invent (a future UI stage may still want to warn before /// deleting — that's a UI-layer decision, not a data-layer one). pub fn remove(&mut self, name: &str) -> bool { + self.descriptions.remove(name); match self.index_of(name) { Some(i) => { self.parameters.remove(i); diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 1dd7f4b3d..edc8aa387 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -364,16 +364,59 @@ pub(crate) fn next_dimensional_parameter_name( /// The constraint-bar label that draws a dynamic dimension's lock mark. pub const DYNAMIC_DIMENSION_GLYPH: &str = "\u{1F512}"; +/// The layer the reference keeps dynamic dimensions on; an annotational +/// dimensional constraint sits on an ordinary layer instead. +pub(crate) const DYNAMIC_DIMENSION_LAYER: &str = "*ADSK_CONSTRAINTS"; + /// The text a dynamic dimension shows: CONSTRAINTNAMEFORMAT 0 = name, -/// 1 = value, 2 = name=value. -pub(crate) fn dynamic_dimension_text(name: &str, value: f64, format: u8) -> String { - match format { +/// 1 = value, 2 = name=value, the value with its trailing zeros dropped +/// (`d9=80`, `d1=111.8034`). A parameter defined by a formula shows the +/// formula behind `fx:` (`fx: len=d1*2`); a reference (driven) constraint's +/// text sits in parentheses. +pub(crate) fn dynamic_dimension_text( + name: &str, + value: f64, + format: u8, + reference: bool, + expression: Option<&str>, +) -> String { + let formula = expression + .map(str::trim) + .filter(|source| !source.is_empty() && source.parse::().is_err()); + let shown = match formula { + Some(source) => source.to_string(), + None => { + let text = format!("{value:.4}"); + let text = text.trim_end_matches('0').trim_end_matches('.'); + if text.is_empty() || text == "-" { + "0".to_string() + } else { + text.to_string() + } + } + }; + let text = match format { 0 => name.to_string(), - 1 => format!("{value:.4}"), - _ => format!("{name}={value:.4}"), + 1 => shown, + _ => format!("{name}={shown}"), + }; + let text = if formula.is_some() { + format!("fx: {text}") + } else { + text + }; + if reference { + format!("({text})") + } else { + text } } +/// Screen height of a dynamic dimension's text and its arrows, in pixels — +/// the reference keeps them readable at any zoom. +const DYNAMIC_TEXT_PX: f32 = 12.0; +const DYNAMIC_ARROW_PX: f32 = 8.0; + /// The expression a measured value seeds a parameter with: twelve /// significant digits with the trailing zeros dropped (`100`, /// `111.803398875`), as the reference stores it. @@ -1593,8 +1636,11 @@ impl super::Scene { pub(crate) fn refresh_dynamic_dimension_texts(&mut self) { let format = self.constraint_name_format; let mut updates: Vec<(Handle, String, Option<(Vector3, Vector3)>)> = Vec::new(); + // A reference (driven) constraint's parameter follows the geometry. + let mut followed: Vec<(String, f64)> = Vec::new(); for set in &self.parametric_constraints { - let table = if set.local_parameters.is_empty() { + let model_table = set.local_parameters.is_empty(); + let table = if model_table { &self.named_parameters } else { &set.local_parameters @@ -1603,13 +1649,6 @@ impl super::Scene { let Some(constraint) = set.get(*id) else { continue; }; - let (name, value) = match &constraint.driving_param { - Some(DrivingValue::Named(name)) => { - (name.clone(), table.resolve(name).unwrap_or(f64::NAN)) - } - Some(DrivingValue::Literal(value)) => (String::new(), *value), - None => continue, - }; let world = |reference: &ParametricRef| { let entity = self.document.get_entity(reference.entity)?; resolve_point(entity, reference.marker?) @@ -1618,13 +1657,56 @@ impl super::Scene { [first, second, ..] => world(first).zip(world(second)), _ => None, }; + let reference = !constraint.enabled; + let measured = points.map(|(first, second)| { + let delta = second - first; + let axis = match self.document.get_entity(*dimension) { + Some(acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Linear(linear), + )) => Some((linear.rotation.cos(), linear.rotation.sin())), + _ => None, + }; + match (constraint.kind, axis) { + (ConstraintKind::DistanceX, _) => delta.x.abs(), + (ConstraintKind::DistanceY, _) => delta.y.abs(), + (ConstraintKind::DistanceDirected, Some((ax, ay))) => { + (delta.x * ax + delta.y * ay).abs() + } + _ => delta.length(), + } + }); + let (name, value, source) = match &constraint.driving_param { + Some(DrivingValue::Named(name)) => { + let value = match (reference, measured) { + (true, Some(measured)) => { + if model_table { + followed.push((name.clone(), measured)); + } + measured + } + _ => table.resolve(name).unwrap_or(f64::NAN), + }; + let source = (!reference) + .then(|| table.get(name).map(|parameter| parameter.source.clone())) + .flatten(); + (name.clone(), value, source) + } + Some(DrivingValue::Literal(value)) => (String::new(), *value, None), + None => continue, + }; updates.push(( *dimension, - dynamic_dimension_text(&name, value, format), + dynamic_dimension_text(&name, value, format, reference, source.as_deref()), points, )); } } + for (name, measured) in followed { + let source = measured_expression(measured); + if self.named_parameters.get(&name).map(|p| p.source.as_str()) != Some(source.as_str()) { + let _ = self.named_parameters.set(&name, &source); + } + } for (handle, text, points) in updates { let Some(acadrust::EntityType::Dimension(mut dimension)) = self.document.get_entity(handle).cloned() @@ -1717,6 +1799,63 @@ impl super::Scene { ids.len() } + /// True for a dynamic-form dimensional constraint dimension — the ones + /// on the reference's constraints layer, which never plot; an + /// annotational one on an ordinary layer plots like any dimension. + pub(crate) fn is_plot_hidden_dynamic_dimension(&self, handle: Handle) -> bool { + self.is_dynamic_dimension(handle) + && self + .document + .get_entity(handle) + .is_some_and(|entity| entity.common().layer == DYNAMIC_DIMENSION_LAYER) + } + + /// Keeps dynamic dimensions readable at any zoom: their text and arrows + /// measure a fixed screen size, as the reference draws them. Runs every + /// tick and touches an entity only when the zoom moved it off size. + pub(crate) fn refresh_dynamic_dimension_sizes(&mut self) { + use crate::entities::dim_override as dov; + let Some(wpp) = self.world_per_pixel() else { + return; + }; + let text_height = (DYNAMIC_TEXT_PX * wpp) as f64; + let arrow = (DYNAMIC_ARROW_PX * wpp) as f64; + let handles: Vec = self + .parametric_constraints + .iter() + .flat_map(|set| set.dimensions.values().copied()) + .collect(); + for handle in handles { + let Some(entity) = self.document.get_entity(handle) else { + continue; + }; + // An annotational dimension keeps its style's sizes. + if entity.common().layer != DYNAMIC_DIMENSION_LAYER { + continue; + } + let current = dov::real(&entity.common().extended_data, dov::DIMTXT); + if current.is_some_and(|height| (height - text_height).abs() <= text_height * 0.02) { + continue; + } + let mut entity = entity.clone(); + dov::set_on_entity(&mut entity, dov::DIMSCALE, Some(acadrust::xdata::XDataValue::Real(1.0))); + dov::set_on_entity(&mut entity, dov::DIMTXT, Some(acadrust::xdata::XDataValue::Real(text_height))); + dov::set_on_entity(&mut entity, dov::DIMASZ, Some(acadrust::xdata::XDataValue::Real(arrow))); + self.update_entity(entity); + } + } + + /// Makes sure the reference's dynamic dimension layer exists. + pub(crate) fn ensure_dynamic_dimension_layer(&mut self) { + if self.document.layers.contains(DYNAMIC_DIMENSION_LAYER) { + return; + } + let mut layer = acadrust::tables::Layer::new(DYNAMIC_DIMENSION_LAYER); + layer.color = acadrust::types::Color::from_index(7); + layer.handle = self.document.allocate_handle(); + let _ = self.document.layers.add(layer); + } + /// True for a dimension that shows a dimensional constraint. pub(crate) fn is_dynamic_dimension(&self, handle: Handle) -> bool { self.parametric_constraints diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 61753cb4a..ba7660548 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1625,10 +1625,13 @@ fn build_constraint( distance_direction_type::PARALLEL_TO_LINE | distance_direction_type::PERPENDICULAR_TO_LINE ) { - if let Some(line) = direction_ref - .first() - .and_then(|reference| whole_line(sys, cache, *reference)) - { + if let Some(line) = direction_ref.first().and_then(|reference| { + // A text baseline or an ellipse axis directs the + // distance the same way a line does. + whole_line(sys, cache, *reference).or_else(|| { + directional_line(sys, cache, *reference).map(|(line, _)| line) + }) + }) { let current = { let store = sys.store(); let delta = [ @@ -3808,6 +3811,24 @@ impl Scene { self.parametric_constraints[i].dof = None; self.parametric_constraints[i].conflicts.clear(); } + // A dimensional constraint set to zero means the collapse. + let zero_collapse: HashSet = { + let set = &self.parametric_constraints[i]; + let params = if set.local_parameters.is_empty() { + &self.named_parameters + } else { + &set.local_parameters + }; + set.constraints + .iter() + .filter(|c| { + c.enabled + && c.kind == ConstraintKind::Distance + && resolved_target(params, c).is_some_and(|v| v.abs() <= f64::EPSILON) + }) + .flat_map(|c| c.refs.iter().map(|r| r.entity)) + .collect() + }; for (handle, new_entity) in solved { // An edit the constraints can only satisfy by collapsing the // entity (a rotated line whose start is fixed and direction @@ -3816,7 +3837,8 @@ impl Scene { let new_entity = match originals.get(&handle) { Some(original) if collapsed_by_solve(&new_entity) - && !collapsed_by_solve(original.as_ref()) => + && !collapsed_by_solve(original.as_ref()) + && !zero_collapse.contains(&handle) => { original.as_ref().clone() } diff --git a/src/ui/window/layers.rs b/src/ui/window/layers.rs index bf2ee1d9f..b1aba4906 100644 --- a/src/ui/window/layers.rs +++ b/src/ui/window/layers.rs @@ -206,6 +206,8 @@ impl LayerPanel { self.layers = doc_layers .iter() + // The reference's hidden system layers (`*ADSK_CONSTRAINTS`) stay out. + .filter(|l| !l.name.starts_with('*')) .map(|l| { let layer_handle = l.handle; let vp_frozen = vp_info From 8e83b2f8432742403fbe7e684fd1bae899441edd Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:38:26 +0300 Subject: [PATCH 15/21] Aligned constraint: 2Lines turns the second line with the plain Parallel solve Only the first line's ends are pinned while the second line is made parallel, the way the Parallel constraint itself moves it; the distance is then measured on the solved geometry. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index b444ffd3b..1af8134cf 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -4000,13 +4000,11 @@ impl OpenCADStudio { id, self.constraint_bar_display, ); - // The first line stays; the second turns about its start - // to run parallel, as the reference keeps that start. - let mut pins = first_ends.to_vec(); - pins.push(second_ends[0]); + // The first line stays; the second turns parallel the way the + // Parallel constraint itself moves it. self.tabs[i].scene.bump_entities_with_parametric_policy( &[(second_line.entity, crate::scene::ChangeKind::Modified)], - &pins, + &first_ends, self.constraint_solve_mode, ); self.tabs[i].dirty = true; From 876513d009f8c7a69d7b42b9649eb29eecda9861 Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 22:02:13 +0800 Subject: [PATCH 16/21] fix(mtext): wrap between ideographs; paragraph indents and tabs in text-height units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two layout defects that together broke every numbered note in a Chinese drawing: - Words were split on whitespace only, so a Chinese sentence — which has no spaces — was one unbreakable word. When it did not fit after "7." the whole sentence dropped to the next line and still ran past the column. Ideographic text may break between any two characters except before a closing mark or after an opening one (the basic kinsoku rule); the atom splitter now emits those break opportunities. - `\pxi…,l…,r…,t…;` paragraph indents and tab stops are multiples of the text height, and the first-line indent is relative to the left indent (a hanging list is `i-1.5,l1.5`). They were applied as raw drawing units, i.e. a fraction of a unit, so wrapped lines snapped back to the margin and the number/tab column collapsed. Scale them by the entity height and resolve the first line from the left indent. Adds a layout test: a 40-ideograph paragraph in a 30-unit column wraps into several lines, none wider than the column. Co-Authored-By: Claude Opus 5 (1M context) --- src/entities/text_support.rs | 96 ++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index d4adac03f..57e865093 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -560,11 +560,18 @@ pub fn adapt_mtext_paragraphs( } let (align, indent_first, indent_left, indent_right, tab_stops) = if has_own_props(props) || carried.is_none() { + // `\pxi…,l…,r…,t…;` values are multiples of the text height, + // not drawing units, and the first-line indent is relative to + // the left indent (a numbered list's hanging indent is + // `i-1.5,l1.5`: number at the margin, wrapped lines 1.5 h in). + // Taken raw they were a fraction of a unit — no indent at all — + // so every wrapped list line snapped back to the margin. + let left = props.left_margin.unwrap_or(0.0) as f32 * entity_height; let v = ( props.alignment.and_then(map_align), - props.first_line_indent.unwrap_or(0.0) as f32, - props.left_margin.unwrap_or(0.0) as f32, - props.right_margin.unwrap_or(0.0) as f32, + left + props.first_line_indent.unwrap_or(0.0) as f32 * entity_height, + left, + props.right_margin.unwrap_or(0.0) as f32 * entity_height, props .tab_stops .iter() @@ -577,7 +584,7 @@ pub fn adapt_mtext_paragraphs( ATab::Decimal(_) => TabKind::Decimal, }; TabStop { - position: ts.position() as f32, + position: ts.position() as f32 * entity_height, kind, } }) @@ -900,6 +907,20 @@ pub fn resolve_font<'a>(state: &'a RunState, base: &'a str) -> std::borrow::Cow< } } +/// Whether a line may break between `prev` and `next`. Ideographic text has +/// no spaces, so a break is allowed on either side of a full-width character, +/// except before a closing mark (`,。、)」…`) or after an opening one +/// (`(「《…`) — the basic kinsoku rule. +pub(crate) fn cjk_break_between(prev: char, next: char) -> bool { + use crate::scene::text::ttf_glyph::is_full_width; + const CLOSING: &str = ",。、;:?!)」』】〕》〉』﹚﹞.~…‥、〞”’,.;:!?)]}%"; + const OPENING: &str = "(「『【〔《〈﹙﹝“‘([{"; + if !(is_full_width(prev) || is_full_width(next)) { + return false; + } + !CLOSING.contains(next) && !OPENING.contains(prev) +} + pub fn measure_word( text: &str, state: &RunState, @@ -1404,6 +1425,21 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { doc_char_offset += 1; word_start = doc_char_offset; } else { + // CJK text carries no spaces: every ideograph is a + // wrap opportunity (bar the usual "no line starts + // with a closing mark / ends with an opening one" + // rule), so a long Chinese sentence must not stay + // one unbreakable word that overshoots its column. + if let Some(prev) = word.chars().last() { + if cjk_break_between(prev, ch) { + atoms.push(LayoutAtom { + kind: AtomKind::Word(std::mem::take(&mut word)), + state: run.state.clone(), + char_offset: word_start, + }); + word_start = doc_char_offset; + } + } word.push(ch); doc_char_offset += 1; } @@ -2997,6 +3033,58 @@ mod v_anchor_tests { assert_eq!(reordered6, vec!["שלום", " ", "«x"]); } + + #[test] + fn cjk_paragraph_wraps_between_ideographs() { + use crate::entities::text_support::{layout_mtext, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle}; + // Breaks are allowed between ideographs but not before a closing mark + // or after an opening one. + assert!(cjk_break_between('工', '程')); + assert!(cjk_break_between('程', 'A')); + assert!(!cjk_break_between('工', ',')); + assert!(!cjk_break_between('(', '工')); + assert!(!cjk_break_between('a', 'b')); + + let style = ResolvedTextStyle { + font_name: "Standard".to_string(), + width_factor: 1.0, + oblique_angle: 0.0, + is_backward: false, + is_upside_down: false, + is_vertical: false, + }; + // 40 ideographs with no spaces in a column ~12 characters wide used + // to stay one unbreakable word: a single line overshooting the box. + let value = "本工程之各種平面圖均須相互配合如有不符或須更正處均按工程慣例施工至完整並請於估價前提出"; + let opts = MTextRenderOpts { + columns: Default::default(), + value, + insertion: [0.0, 0.0, 0.0], + height: 2.5, + rect_w: 30.0, + rotation: 0.0, + style: &style, + attach_h_anchor: 0.0, + v_anchor: MTextVAnchor::Top, + line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, + vertical_text: false, + want_glyph_boxes: true, + }; + let layout = layout_mtext(&opts); + assert!( + layout.line_count >= 3, + "a 40-ideograph paragraph in a 30-unit column must wrap (got {} line(s))", + layout.line_count + ); + assert!( + layout.line_widths.iter().all(|w| *w <= 30.0 + 1e-3), + "no wrapped line may overshoot the column: {:?}", + layout.line_widths + ); + } + #[test] fn test_mtext_arabic_bidi_layout() { use crate::entities::text_support::{layout_mtext, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle}; From 4826cd79897108d63370d7a313b816d4641abb07 Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 22:02:13 +0800 Subject: [PATCH 17/21] fix(pdf): Flate-compress the exported PDF streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit printpdf 0.9's `PdfSaveOptions::optimize` is a no-op — its `doc.compress()` call is commented out — so every page's content stream (megabytes of vector operators for a CAD sheet) was written uncompressed: a 16-sheet drawing set exported as 280 MB. Serialise through `printpdf::to_lopdf_doc` and run lopdf's `compress()` before writing; the same export is now 49 MB and readers see ordinary FlateDecode streams. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + Cargo.toml | 2 ++ src/io/pdf_export.rs | 11 ++++++++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index e50a11fdc..191ee67f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,7 @@ dependencies = [ "js-sys", "libloading", "log", + "lopdf", "lyon_tessellation", "meshopt", "naga 27.0.3", diff --git a/Cargo.toml b/Cargo.toml index b8d1a54fd..f65b42ce0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,6 +93,8 @@ i18n-embed = { version = "0.16", features = ["desktop-requester"] } rayon = "1" open = "5" printpdf = { version = "0.9.1", default-features = false, features = ["images"] } +# The lopdf printpdf builds on: used to Flate-compress the exported PDF streams. +lopdf = { version = "0.39.0", default-features = false, features = ["time"] } ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 774ec9afa..4b505bc93 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -424,7 +424,16 @@ fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) .map_err(|error| format!("Page {}: {error}", index + 1))?; } let mut warnings = Vec::new(); - Ok(doc.save(&PdfSaveOptions::default(), &mut warnings)) + // printpdf 0.9's `optimize` is a no-op (its `doc.compress()` is commented + // out), so every page's content stream — megabytes of vector operators + // for a CAD sheet — went out uncompressed: a 16-sheet set weighed 280 MB. + // Serialise through lopdf ourselves and Flate-compress the streams. + let mut lo = printpdf::to_lopdf_doc(&doc, &PdfSaveOptions::default(), &mut warnings); + lo.compress(); + let mut bytes = Vec::new(); + lo.save_to(&mut bytes) + .map_err(|error| format!("PDF serialisation failed: {error}"))?; + Ok(bytes) } #[cfg(not(target_arch = "wasm32"))] From ececb0ef8c53dd5a3b5a26b6e26bc523c3703804 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 21 Sep 2026 22:21:19 +0300 Subject: [PATCH 18/21] ci: run the workspace tests on every change; drop the unused flake - .github/workflows/ci.yml runs `cargo test --workspace --locked` on pushes to main and on pull requests, with the Linux libraries the release build installs. No formatter check: the tree is not rustfmt-clean and stays that way. - flake.nix, flake.lock and the direnv hook are removed. Nothing used them, and their pinned dependency hashes were already stale after the codec and kernel bumps. - Under cfg(test) the "system" language resolves to en-US. The unit tests compare English text through one process-wide loader, and every apply_config of a default config asked for the system language, so a Turkish locale failed 22 tests and parallel tests raced each other through a language switch (the thickness-row test found its section under one language and looked it up under another). - The arc grip test follows #1352: the start, end and midpoint grips drive the whole arc. Co-Authored-By: Claude Fable 5.1 --- .envrc | 1 - .github/workflows/ci.yml | 44 ++++++++ .gitignore | 3 - flake.lock | 48 --------- flake.nix | 156 ---------------------------- src/i18n.rs | 7 ++ src/scene/parametric_constraints.rs | 22 ++-- 7 files changed, 63 insertions(+), 218 deletions(-) delete mode 100644 .envrc create mode 100644 .github/workflows/ci.yml delete mode 100644 flake.lock delete mode 100644 flake.nix diff --git a/.envrc b/.envrc deleted file mode 100644 index 3550a30f2..000000000 --- a/.envrc +++ /dev/null @@ -1 +0,0 @@ -use flake diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..55066aa72 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: Tests + +# Until now nothing ran the test suite on a change: the release workflows +# build the app, web-check.yml type-checks the wasm target, and pull requests +# reported their own local runs. Run the workspace tests on every push to +# main and on every pull request. There is deliberately no formatter check: +# the tree is not rustfmt-clean and is not going to be reformatted. + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + # The same libraries the Linux release build needs (windowing, GL, + # fonts); the packaging tools are left out. + - name: System libraries + run: | + sudo apt-get update + sudo apt-get install -y \ + libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ + libxkbcommon-dev libwayland-dev libfontconfig1-dev libfreetype6-dev + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + # LC_ALL=C: a few tests compare English message text. + - name: cargo test + env: + LC_ALL: C + run: cargo test --workspace --locked diff --git a/.gitignore b/.gitignore index 3684cd6dc..262043fce 100644 --- a/.gitignore +++ b/.gitignore @@ -36,9 +36,6 @@ __pycache__/ # Claude Code local preview/dev-server config .claude/launch.json -# nix-direnv -.direnv/ - # Worktrees created for isolated runs .claude/worktrees/ diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 95a880e21..000000000 --- a/flake.lock +++ /dev/null @@ -1,48 +0,0 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1789149629, - "narHash": "sha256-H6GwaZzZf+4npqv0tph94w9tZddSjFjmQrVsW0z78uk=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "eaad089433ca2bb662274377d33df3d0e51ef28b", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" - } - }, - "rust-overlay": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1789196581, - "narHash": "sha256-yJr1Bt4fKkKIpPYbsKGqJ0VFdoDnURgRwuffmBQ2WzY=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "228ecefb6329d5a531b77b46b581a2f0c26ee056", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 89610a0b6..000000000 --- a/flake.nix +++ /dev/null @@ -1,156 +0,0 @@ -{ - description = "Open CAD Studio - 2D drafting and 3D modeling in Rust"; - - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - # rust-overlay ships upstream Rust binaries rather than building them, so - # following nixpkgs costs it no binary cache. - rust-overlay = { - url = "github:oxalica/rust-overlay"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; - - outputs = { self, nixpkgs, rust-overlay }: - let - systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; - forAllSystems = f: - nixpkgs.lib.genAttrs systems (system: f (import nixpkgs { - inherit system; - overlays = [ rust-overlay.overlays.default ]; - })); - in - { - packages = forAllSystems (pkgs: - let - inherit (pkgs) lib stdenv; - - toolchain = pkgs.rust-bin.stable.latest.default.override { - targets = [ "wasm32-unknown-unknown" ]; - }; - - # Build with the overlay's toolchain rather than the nixpkgs default, - # so the package and the devShell compile with the same rustc. - rustPlatform = pkgs.makeRustPlatform { - cargo = toolchain; - rustc = toolchain; - }; - - # winit/wgpu dlopen these at run time, so they must also be on the - # wrapped binary's LD_LIBRARY_PATH, not just at link time. - runtimeLibs = with pkgs; [ - libGL - libxkbcommon - vulkan-loader - wayland - xorg.libX11 - xorg.libXcursor - xorg.libXi - xorg.libXrandr - ]; - in - rec { - default = opencadstudio; - - opencadstudio = rustPlatform.buildRustPackage { - pname = "opencadstudio"; - version = (lib.importTOML ./Cargo.toml).package.version; - src = self; - - cargoLock = { - lockFile = ./Cargo.lock; - # One entry per distinct git source in Cargo.lock, keyed by any - # one package from it. Two of these are transitive and easy to - # miss by reading Cargo.toml alone: cryoglyph and dpi arrive - # through iced, which pins its own forks. Regenerate the list - # from the lock rather than the manifest: - # grep -B2 'source = "git+' Cargo.lock - # Each hash must be refreshed whenever the matching rev moves. - outputHashes = { - "acadrust-0.5.5" = "sha256-rYtJaJsb+lZZIDBtPTCfWRVyA1Gfinn3UqJlfzkhhGk="; - "cadkernel-0.1.0" = "sha256-7smcL9oo5mJwoCLYUbD4+lA9Xcm+/qpVswYxzoGjTDk="; - "cryoglyph-0.1.0" = "sha256-5BOJNDcjhpt17/XSri3JGtX8NwC7db18bddp5wanMes="; - "dpi-0.1.1" = "sha256-pQn1lCFSJMkjUfHoggEzMHnm5k+Chnzi5JEDjahnjUA="; - "iced-0.15.0-dev" = "sha256-Atg+nr1VgXwW5I7LiMAMqHemz/9ZiehTc2EWuSFEGHI="; - "iced_aw-0.14.1" = "sha256-OynSIjv8SgSoxpw1S4wH58vDdqga/YojOR5A9ZLKrd8="; - }; - }; - - nativeBuildInputs = with pkgs; [ - pkg-config - makeWrapper - ]; - - buildInputs = with pkgs; [ - fontconfig - freetype - ] ++ runtimeLibs - ++ lib.optionals stdenv.hostPlatform.isDarwin [ darwin.apple_sdk.frameworks.AppKit ]; - - # build.rs asks git for a revision and falls back to "unknown" - # when the repo or the binary is absent, which it is in the - # sandbox. Nothing to do but let it take the fallback. - doCheck = false; - - postInstall = lib.optionalString stdenv.hostPlatform.isLinux '' - wrapProgram $out/bin/OpenCADStudio \ - --prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath runtimeLibs} - ''; - - meta = { - description = "Open-source 2D drafting and 3D modeling, built with Rust"; - homepage = "https://www.opencadstudio.com"; - license = lib.licenses.gpl3Only; - mainProgram = "OpenCADStudio"; - platforms = systems; - }; - }; - }); - - devShells = forAllSystems (pkgs: - let - toolchain = pkgs.rust-bin.stable.latest.default.override { - extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ]; - targets = [ "wasm32-unknown-unknown" ]; - }; - runtimeLibs = with pkgs; [ - libGL - libxkbcommon - vulkan-loader - wayland - xorg.libX11 - xorg.libXcursor - xorg.libXi - xorg.libXrandr - ]; - in - { - default = pkgs.mkShell { - packages = with pkgs; [ - toolchain - pkg-config - fontconfig - freetype - git - # Web build: `trunk build` per Trunk.toml. wasm-bindgen-cli must - # match the wasm-bindgen version Cargo.lock resolves, the same - # constraint .github/workflows/pages.yml:83 reads out of the lock. - trunk - wasm-bindgen-cli - # Rasterises assets/logo.svg for packaging, as release.yml does. - librsvg - ] ++ runtimeLibs; - - # A dev binary is run straight out of ./target, so it never gets - # the wrapper the package build applies. - LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs; - }; - }); - - checks = forAllSystems (pkgs: { - default = self.packages.${pkgs.stdenv.hostPlatform.system}.opencadstudio; - }); - - formatter = forAllSystems (pkgs: pkgs.nixpkgs-fmt); - }; -} diff --git a/src/i18n.rs b/src/i18n.rs index 73b64c48c..1463aef6b 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -158,6 +158,13 @@ impl std::fmt::Display for Language { } fn system_languages() -> Vec { + // The unit tests compare English prompts and messages through one + // process-wide loader, and every `apply_config` of a default config asks + // for the system language, so the machine's locale must never reach + // them — not even from a test that runs in parallel. + #[cfg(test)] + return vec!["en-US".parse().expect("en-US is a valid language identifier")]; + #[cfg(target_arch = "wasm32")] let mut requested = WebLanguageRequester::requested_languages(); #[cfg(not(target_arch = "wasm32"))] diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 0ab068550..f9c7f4aa8 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -2371,7 +2371,10 @@ mod tests { } #[test] - fn arc_grips_drive_center_start_and_end_but_not_midpoint() { + /// The center grip drives the center; the start, end and midpoint grips + /// reshape the arc, so they drive the whole curve and a paired Equal or + /// Symmetric follows the new radius and sweep. + fn arc_grips_drive_center_or_the_whole_arc() { let handle = h(8); let arc = acadrust::EntityType::Arc(acadrust::entities::Arc::from_coords( 0.0, @@ -2386,15 +2389,14 @@ mod tests { grip_solve_anchor_refs(&arc, handle, 0), vec![ParametricRef::center(handle)] ); - assert_eq!( - grip_solve_anchor_refs(&arc, handle, 1), - vec![ParametricRef::point(handle, 0)] - ); - assert_eq!( - grip_solve_anchor_refs(&arc, handle, 2), - vec![ParametricRef::point(handle, 1)] - ); - assert_eq!(grip_solve_anchor_refs(&arc, handle, 3), Vec::new()); + for grip in 1..=3 { + assert_eq!( + grip_solve_anchor_refs(&arc, handle, grip), + vec![ParametricRef::whole(handle)], + "grip {grip}" + ); + } + assert_eq!(grip_solve_anchor_refs(&arc, handle, 4), Vec::new()); } #[test] From e537a495c655ec1edd11d8e881abd667d6931400 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 21 Sep 2026 22:21:19 +0300 Subject: [PATCH 19/21] fix(constraints): point symmetry converges from any start Symmetric held the connecting direction perpendicular to the axis with the kernel's angle-based Perpendicular, which locks its turn side at construction: a point starting far from its mirror image had to swing the pair through the axis direction, the residual wrapped, and the solver stalled with the point unmoved. The perpendicularity is now the projection of the pair onto the axis direction being zero, a polynomial residual the solver follows from anywhere. Circle centers and ellipse centers use the same equation. tests/parametric_constraints_solve.rs symmetric_constraint_mirrors_one_circles_center_across_the_axis_line passes again. Co-Authored-By: Claude Fable 5.1 --- src/scene/parametric_solve.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 59a5d30ca..eb800105c 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1466,13 +1466,23 @@ fn build_constraint( let Some(mirror) = whole_line(sys, cache, *m) else { return Vec::new(); }; + // The connecting direction is held perpendicular to the axis as a + // projection onto the axis direction being zero: a polynomial + // residual the solver can follow from any start. The angle form + // (`Perpendicular`) locks its turn side at construction and stalls + // when the pair has to swing through the axis direction — a point + // starting far from its mirror image never arrived. + let across_axis = |sys: &mut System, pa: GPoint, pb: GPoint| -> Rc { + let zero = sys.add_param(0.0, true); + Rc::new(ProjectedDistanceAlongLine::new(pa, pb, zero, mirror, false)) + }; if let (Some(pa), Some(pb)) = (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) { let pair = GLine { p1: pa, p2: pb }; return vec![ Rc::new(MidpointOnLine::new(pair, mirror)), - Rc::new(PerpendicularConstraint::new(sys.store(), pair, mirror)), + across_axis(sys, pa, pb), ]; } let (Some(first), Some(second)) = ( @@ -1500,11 +1510,7 @@ fn build_constraint( }; vec![ Rc::new(MidpointOnLine::new(centers, mirror)), - Rc::new(PerpendicularConstraint::new( - sys.store(), - centers, - mirror, - )), + across_axis(sys, first.center, second.center), Rc::new(Equal::new(second.rad, first.rad, 1.0)), ] } @@ -1523,11 +1529,7 @@ fn build_constraint( }; vec![ Rc::new(MidpointOnLine::new(centers, mirror)), - Rc::new(PerpendicularConstraint::new( - sys.store(), - centers, - mirror, - )), + across_axis(sys, first.center, second.center), Rc::new(SymmetricLineDirections::new( sys.store(), first_axis, From 4f12087539d7baf0b1da79663242bdc1b9ad9937 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 21 Sep 2026 22:49:31 +0300 Subject: [PATCH 20/21] test(constraints): a negative distance value validates by its magnitude #1415 made a dimensional Distance keep a negative value's sign in the parameter while the geometry uses its magnitude (and a zero fold the points together), but left the value-domain test asserting the old refusal. Assert the new reading, and keep the radius refusal covered. Co-Authored-By: Claude Fable 5.1 --- src/scene/parametric_solve.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 00ae2ae85..f1daeb8a1 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -4140,12 +4140,25 @@ mod tests { Vector3::new(1.0, 0.0, 0.0), ))); let refs = [ParametricRef::point(line, 0), ParametricRef::point(line, 1)]; + // A distance keeps a negative value's sign in the parameter and + // measures its magnitude (`d1=-50`); a radius has no such reading. assert!(scene .validate_parametric_constraint( ConstraintKind::Distance, &refs, Some(&DrivingValue::Literal(-1.0)), ) + .is_ok()); + let circle = scene.add_entity(EntityType::Circle(acadrust::entities::Circle::from_center_radius( + Vector3::new(0.0, 0.0, 0.0), + 1.0, + ))); + assert!(scene + .validate_parametric_constraint( + ConstraintKind::Radius, + &[ParametricRef::whole(circle)], + Some(&DrivingValue::Literal(-1.0)), + ) .is_err()); assert!(scene .validate_parametric_constraint( From baba5bccd3cf51eee36616ef97e78604baad2583 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 21 Sep 2026 22:55:04 +0300 Subject: [PATCH 21/21] build: reach lopdf and encoding_rs through printpdf and the codec #1418 added both as direct dependencies although each was already in the tree: printpdf's to_lopdf_doc returns the lopdf document whose compress and save_to are plain methods, and the codec's encoding_from_code_page hands back the Big5 / GBK / Shift_JIS / EUC-KR encodings by their DXF code-page names. Cargo.toml keeps its dependency list. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 2 -- Cargo.toml | 5 ----- src/scene/text/shx.rs | 22 ++++++++++++---------- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 191ee67f3..4da766abd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,7 +18,6 @@ dependencies = [ "console_error_panic_hook", "cosmic-text 0.15.0", "dwg-thumbnailer", - "encoding_rs", "env_logger", "flate2", "fluent-syntax", @@ -38,7 +37,6 @@ dependencies = [ "js-sys", "libloading", "log", - "lopdf", "lyon_tessellation", "meshopt", "naga 27.0.3", diff --git a/Cargo.toml b/Cargo.toml index f65b42ce0..6e78631dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,9 +55,6 @@ ttf-parser = "0.25" cosmic-text = "0.15" unicode-bidi = "0.3" lyon_tessellation = "1.0.20" -# Big-font (.shx) lookups: encode a char to the font's Big5 / GBK / Shift_JIS -# / EUC-KR double-byte code. Already in the tree through other deps. -encoding_rs = "0.8" pollster = "0.4" [dev-dependencies] @@ -93,8 +90,6 @@ i18n-embed = { version = "0.16", features = ["desktop-requester"] } rayon = "1" open = "5" printpdf = { version = "0.9.1", default-features = false, features = ["images"] } -# The lopdf printpdf builds on: used to Flate-compress the exported PDF streams. -lopdf = { version = "0.39.0", default-features = false, features = ["time"] } ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/src/scene/text/shx.rs b/src/scene/text/shx.rs index 997e48c30..2e067b144 100644 --- a/src/scene/text/shx.rs +++ b/src/scene/text/shx.rs @@ -203,25 +203,24 @@ pub fn is_bigfont(path: &str) -> bool { load_file(path).is_some_and(|f| f.kind == ShxKind::Bigfont) } -/// The double-byte legacy encodings a big font may be keyed by, most likely -/// first for this file name. The font itself does not say; try them in turn -/// and take the first code the font actually has. -fn bigfont_encodings(path: &str) -> [&'static encoding_rs::Encoding; 4] { - use encoding_rs::{BIG5, EUC_KR, GBK, SHIFT_JIS}; +/// The code pages a big font's double-byte codes may be in, most likely +/// first, guessed from the file name. The codec maps a name to its encoding +/// (`ansi_950` Big5, `ansi_936` GBK, `ansi_932` Shift_JIS, `ansi_949` EUC-KR). +fn bigfont_code_pages(path: &str) -> [&'static str; 4] { let name = path .rsplit(['/', '\\']) .next() .unwrap_or(path) .to_ascii_lowercase(); if name.contains("gb") || name.contains("hz") || name.contains("china") { - [GBK, BIG5, SHIFT_JIS, EUC_KR] + ["ansi_936", "ansi_950", "ansi_932", "ansi_949"] } else if name.contains("bigfont") || name.contains("extfont") || name.contains("jp") { - [SHIFT_JIS, BIG5, GBK, EUC_KR] + ["ansi_932", "ansi_950", "ansi_936", "ansi_949"] } else if name.contains("whg") || name.contains("kor") || name.contains("han") { - [EUC_KR, BIG5, GBK, SHIFT_JIS] + ["ansi_949", "ansi_950", "ansi_936", "ansi_932"] } else { // `chineset.shx` and most Taiwanese fonts. - [BIG5, GBK, SHIFT_JIS, EUC_KR] + ["ansi_950", "ansi_936", "ansi_932", "ansi_949"] } } @@ -235,7 +234,10 @@ pub fn bigfont_glyph(path: &str, ch: char) -> Option