diff --git a/Cargo.lock b/Cargo.lock
index 3df83216f..bfb39bf61 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -921,7 +921,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadkernel"
version = "0.1.0"
-source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=35aa7a5#35aa7a541ab7c020cd23f43978373337523e25df"
+source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=d2a288b#d2a288b5f397ad7941719119f216a048d6cdcc6f"
dependencies = [
"acadrust",
"cavalier_contours",
@@ -932,7 +932,7 @@ dependencies = [
[[package]]
name = "cadkernel-constraints"
version = "0.1.0"
-source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=35aa7a5#35aa7a541ab7c020cd23f43978373337523e25df"
+source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=d2a288b#d2a288b5f397ad7941719119f216a048d6cdcc6f"
dependencies = [
"nalgebra",
]
diff --git a/Cargo.toml b/Cargo.toml
index fb9fbd71d..062ce7acb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -34,8 +34,8 @@ clap = { version = "4", features = ["derive", "string"] }
env_logger = "0.11"
log = "0.4"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "8a28c21", features = ["serde"] }
-cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "35aa7a5", features = ["acis", "offset"] }
-cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "35aa7a5" }
+cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "d2a288b", features = ["acis", "offset"] }
+cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "d2a288b" }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
diff --git a/assets/icons/constrain/perpendicular.svg b/assets/icons/constrain/perpendicular.svg
index 520becabb..c4d2ac1a1 100644
--- a/assets/icons/constrain/perpendicular.svg
+++ b/assets/icons/constrain/perpendicular.svg
@@ -1,4 +1,4 @@
diff --git a/assets/icons/constrain/smooth.svg b/assets/icons/constrain/smooth.svg
index 5986ce9d0..7f5425a8a 100644
--- a/assets/icons/constrain/smooth.svg
+++ b/assets/icons/constrain/smooth.svg
@@ -1,5 +1,5 @@
diff --git a/docs/screenshots/dimension-layout/after-diameter.png b/docs/screenshots/dimension-layout/after-diameter.png
new file mode 100644
index 000000000..c517f24fc
Binary files /dev/null and b/docs/screenshots/dimension-layout/after-diameter.png differ
diff --git a/docs/screenshots/dimension-layout/after-linear-fit.png b/docs/screenshots/dimension-layout/after-linear-fit.png
new file mode 100644
index 000000000..22fac4c4b
Binary files /dev/null and b/docs/screenshots/dimension-layout/after-linear-fit.png differ
diff --git a/docs/screenshots/dimension-layout/after-linear-radius.png b/docs/screenshots/dimension-layout/after-linear-radius.png
new file mode 100644
index 000000000..bbe802e0e
Binary files /dev/null and b/docs/screenshots/dimension-layout/after-linear-radius.png differ
diff --git a/docs/screenshots/dimension-layout/after-radial-leaders.png b/docs/screenshots/dimension-layout/after-radial-leaders.png
new file mode 100644
index 000000000..61f56aa6e
Binary files /dev/null and b/docs/screenshots/dimension-layout/after-radial-leaders.png differ
diff --git a/docs/screenshots/dimension-layout/before-diameter.png b/docs/screenshots/dimension-layout/before-diameter.png
new file mode 100644
index 000000000..39e5cd8a1
Binary files /dev/null and b/docs/screenshots/dimension-layout/before-diameter.png differ
diff --git a/docs/screenshots/dimension-layout/before-linear-fit.png b/docs/screenshots/dimension-layout/before-linear-fit.png
new file mode 100644
index 000000000..deaaca293
Binary files /dev/null and b/docs/screenshots/dimension-layout/before-linear-fit.png differ
diff --git a/docs/screenshots/dimension-layout/before-linear-radius.png b/docs/screenshots/dimension-layout/before-linear-radius.png
new file mode 100644
index 000000000..87e8370ed
Binary files /dev/null and b/docs/screenshots/dimension-layout/before-linear-radius.png differ
diff --git a/docs/screenshots/dimension-layout/before-radial-leaders.png b/docs/screenshots/dimension-layout/before-radial-leaders.png
new file mode 100644
index 000000000..f220ea410
Binary files /dev/null and b/docs/screenshots/dimension-layout/before-radial-leaders.png differ
diff --git a/src/app/automation.rs b/src/app/automation.rs
index 9ca7e8c73..ff0bc2776 100644
--- a/src/app/automation.rs
+++ b/src/app/automation.rs
@@ -294,7 +294,7 @@ impl OpenCADStudio {
match req["op"].as_str().unwrap_or("") {
"new" => {
let i = self.active_tab;
- self.tabs[i].scene.clear();
+ self.tabs[i].scene.reset_to_new_drawing();
self.tabs[i].scene.material_base_dir = None;
self.tabs[i].current_path = None;
// The headless session starts on the welcome (Start) tab, which
diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs
index 2f8a5d3f9..6de0b3768 100644
--- a/src/app/command_driver.rs
+++ b/src/app/command_driver.rs
@@ -3182,9 +3182,24 @@ impl OpenCADStudio {
&refs,
driving_param.as_ref(),
) {
- self.tabs[i].active_cmd = None;
+ let keep_smooth_selection = kind
+ == crate::scene::parametric_constraints::ConstraintKind::Smooth
+ && self.tabs[i]
+ .active_cmd
+ .as_ref()
+ .is_some_and(|command| command.name() == "GCSMOOTH");
+ if !keep_smooth_selection {
+ self.tabs[i].active_cmd = None;
+ }
self.tabs[i].snap_result = None;
self.command_line.push_error(message);
+ if keep_smooth_selection {
+ if let Some(prompt) =
+ self.tabs[i].active_cmd.as_ref().map(|command| command.prompt())
+ {
+ self.command_line.push_info(&prompt);
+ }
+ }
return Task::none();
}
let scope = self.tabs[i].current_parametric_scope();
@@ -3229,6 +3244,150 @@ impl OpenCADStudio {
self.commit_undo_delta(i, pd);
}
}
+ CmdResult::AddHorizontalConstraint {
+ selection,
+ direction,
+ label,
+ } => {
+ use crate::command::HorizontalConstraintSelection;
+ use crate::scene::parametric_constraints::{
+ nearest_parametric_point, nearest_parametric_point_on_entity, ConstraintKind,
+ DirectionalAxis, ParametricRef,
+ };
+
+ let scope = self.tabs[i].current_parametric_scope();
+ let to_world = |point: glam::DVec3| {
+ acadrust::types::Vector3::new(point.x, point.y, point.z)
+ };
+ let (refs, initial_fixed) = match selection {
+ HorizontalConstraintSelection::Reference(reference) => {
+ let initial_fixed = match reference.directional_axis() {
+ Some(DirectionalAxis::EllipseMajor | DirectionalAxis::EllipseMinor) => {
+ vec![ParametricRef::center(reference.entity)]
+ }
+ Some(DirectionalAxis::TextBaseline) => {
+ vec![ParametricRef::point(reference.entity, 0)]
+ }
+ None => Vec::new(),
+ };
+ (vec![reference], initial_fixed)
+ }
+ HorizontalConstraintSelection::Points(first, second) => {
+ let resolve = |pick: crate::command::CoincidentPick| {
+ if let Some(handle) = pick.handle {
+ nearest_parametric_point_on_entity(
+ &self.tabs[i].scene.document,
+ scope,
+ handle,
+ to_world(pick.point),
+ )
+ } else {
+ nearest_parametric_point(
+ &self.tabs[i].scene.document,
+ scope,
+ to_world(pick.point),
+ None,
+ )
+ }
+ };
+ let (Some(first_ref), Some(second_ref)) =
+ (resolve(first), resolve(second))
+ else {
+ self.command_line.push_error(
+ "Horizontal: select supported endpoints, centers, midpoints, or vertices.",
+ );
+ return Task::none();
+ };
+ if first_ref == second_ref {
+ self.command_line
+ .push_error("Horizontal: select two different points.");
+ return Task::none();
+ }
+ (vec![first_ref, second_ref], vec![first_ref])
+ }
+ };
+ let axis_length = direction.x.hypot(direction.y);
+ if axis_length <= 1.0e-12 {
+ self.command_line
+ .push_error("Horizontal: the current UCS X axis is not supported.");
+ return Task::none();
+ }
+ let direction = acadrust::types::Vector3::new(
+ direction.x / axis_length,
+ direction.y / axis_length,
+ 0.0,
+ );
+ if let Err(message) = self.tabs[i].scene.validate_parametric_constraint(
+ ConstraintKind::Horizontal,
+ &refs,
+ None,
+ ) {
+ self.command_line.push_error(message);
+ return Task::none();
+ }
+ if self
+ .tabs[i]
+ .scene
+ .parametric_constraint_set(scope)
+ .is_some_and(|set| {
+ set.contains_axis_constraint(
+ ConstraintKind::Horizontal,
+ &refs,
+ direction,
+ )
+ })
+ {
+ self.tabs[i].active_cmd = None;
+ self.tabs[i].snap_result = None;
+ self.command_line
+ .push_error("Horizontal constraint is already applied.");
+ return Task::none();
+ }
+ 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::new();
+ for reference in &refs {
+ if !touched.contains(&reference.entity) {
+ touched.push(reference.entity);
+ }
+ }
+ let pending = self.begin_undo(i, label, touched.len(), true);
+ self.tabs[i]
+ .scene
+ .record_undo_parametric_constraints_before(scope, constraints_before);
+ let id = self.tabs[i]
+ .scene
+ .parametric_constraint_set_mut(scope)
+ .add_axis_constraint(ConstraintKind::Horizontal, refs, direction);
+ self.tabs[i].scene.note_parametric_constraint_applied(
+ scope,
+ id,
+ self.constraint_bar_display,
+ );
+ let changes = touched
+ .into_iter()
+ .map(|handle| (handle, crate::scene::ChangeKind::Modified))
+ .collect::>();
+ self.tabs[i].scene.bump_entities_with_parametric_policy(
+ &changes,
+ &initial_fixed,
+ self.constraint_solve_mode,
+ );
+ self.tabs[i].dirty = true;
+ self.tabs[i].active_cmd = None;
+ self.tabs[i].snap_result = None;
+ self.command_line
+ .push_output("Horizontal constraint applied.");
+ self.refresh_properties();
+ if let Some(pd) = pending {
+ self.commit_undo_delta(i, pd);
+ }
+ }
CmdResult::AddPerpendicularConstraint {
first,
second,
@@ -3280,7 +3439,7 @@ impl OpenCADStudio {
.into_iter()
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect::>();
- self.tabs[i].scene.bump_entities_with_parametric_policy(
+ self.tabs[i].scene.bump_entities_with_initial_parametric_policy(
&changes,
&[first_fixed, second_start],
true,
@@ -3357,6 +3516,88 @@ impl OpenCADStudio {
self.commit_undo_delta(i, pd);
}
}
+ CmdResult::AddConcentricConstraint {
+ first,
+ second,
+ label,
+ } => {
+ use crate::scene::parametric_constraints::ConstraintKind;
+
+ let refs = vec![first, second];
+ if let Err(message) = self.tabs[i].scene.validate_parametric_constraint(
+ ConstraintKind::Concentric,
+ &refs,
+ None,
+ ) {
+ self.tabs[i].active_cmd = None;
+ self.tabs[i].snap_result = None;
+ self.command_line.push_error(message);
+ return Task::none();
+ }
+ let scope = self.tabs[i].current_parametric_scope();
+ let duplicate = self.tabs[i]
+ .scene
+ .parametric_constraint_set(scope)
+ .is_some_and(|set| {
+ set.constraints.iter().any(|constraint| {
+ constraint.enabled
+ && constraint.kind == ConstraintKind::Concentric
+ && matches!(constraint.refs.as_slice(), [a, b]
+ if (*a == first && *b == second)
+ || (*a == second && *b == first))
+ })
+ });
+ if duplicate {
+ self.tabs[i].active_cmd = None;
+ self.tabs[i].snap_result = None;
+ self.command_line
+ .push_output("The Concentric constraint already exists.");
+ return Task::none();
+ }
+ let constraints_before = self.tabs[i]
+ .scene
+ .parametric_constraint_set(scope)
+ .cloned()
+ .unwrap_or_else(|| {
+ crate::scene::parametric_constraints::ParametricConstraintSet::new(scope)
+ });
+ let touched = if first.entity == second.entity {
+ vec![first.entity]
+ } else {
+ vec![first.entity, second.entity]
+ };
+ let pending = self.begin_undo(i, label, touched.len(), true);
+ self.tabs[i]
+ .scene
+ .record_undo_parametric_constraints_before(scope, constraints_before);
+ let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add(
+ ConstraintKind::Concentric,
+ refs,
+ None,
+ );
+ self.tabs[i].scene.note_parametric_constraint_applied(
+ scope,
+ id,
+ self.constraint_bar_display,
+ );
+ let changes = touched
+ .into_iter()
+ .map(|handle| (handle, crate::scene::ChangeKind::Modified))
+ .collect::>();
+ self.tabs[i].scene.bump_entities_with_initial_parametric_policy(
+ &changes,
+ &[first],
+ true,
+ );
+ self.tabs[i].dirty = true;
+ self.tabs[i].active_cmd = None;
+ self.tabs[i].snap_result = None;
+ self.command_line.push_output("Constraint applied.");
+ self.refresh_properties();
+ if let Some(pd) = pending {
+ self.commit_undo_delta(i, pd);
+ }
+ }
CmdResult::AddCoincidentConstraint {
first,
second,
@@ -8450,6 +8691,159 @@ mod parametric_constraint_undo_tests {
);
}
+ #[test]
+ fn perpendicular_initial_solve_rotates_parallel_second_line_in_kernel() {
+ let mut app = OpenCADStudio::new_for_test();
+ let _ = app.automation_op(r#"{"op":"new"}"#);
+ let first = add_line(&mut app, 0.0, 0.0, 10.0, 0.0);
+ let second = add_line(&mut app, 20.0, 0.0, 30.0, 0.0);
+
+ let _ = app.apply_cmd_result(CmdResult::AddPerpendicularConstraint {
+ first: ParametricRef::whole(first),
+ second: ParametricRef::whole(second),
+ first_fixed: ParametricRef::whole(first),
+ second_start: ParametricRef::point(second, 0),
+ label: "Perpendicular constraint",
+ });
+
+ let line = |handle| match app.tabs[app.active_tab].scene.document.get_entity(handle) {
+ Some(acadrust::EntityType::Line(line)) => line.clone(),
+ other => panic!("expected a Line, got {other:?}"),
+ };
+ let fixed = line(first);
+ let moving = line(second);
+ assert_eq!(fixed.start, acadrust::types::Vector3::new(0.0, 0.0, 0.0));
+ assert_eq!(fixed.end, acadrust::types::Vector3::new(10.0, 0.0, 0.0));
+ assert!((moving.start - acadrust::types::Vector3::new(20.0, 0.0, 0.0)).length() < 1.0e-9);
+ assert!((moving.length() - 10.0).abs() < 1.0e-7);
+ assert!(
+ (fixed.end - fixed.start)
+ .dot(&(moving.end - moving.start))
+ .abs()
+ < 1.0e-7,
+ "fixed={fixed:?}, moving={moving:?}"
+ );
+ }
+
+ #[test]
+ fn horizontal_initial_solve_uses_the_captured_axis_in_kernel() {
+ let mut app = OpenCADStudio::new_for_test();
+ let _ = app.automation_op(r#"{"op":"new"}"#);
+ let handle = add_line(&mut app, 0.0, 0.0, 5.0, 2.0);
+ let (original_start, original_end, original_length) = match app.tabs[app.active_tab]
+ .scene
+ .document
+ .get_entity(handle)
+ {
+ Some(acadrust::EntityType::Line(line)) => (line.start, line.end, line.length()),
+ other => panic!("expected a Line, got {other:?}"),
+ };
+ let direction = acadrust::types::Vector3::new(3.0, 4.0, 0.0).normalize();
+
+ let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint {
+ selection: crate::command::HorizontalConstraintSelection::Reference(
+ ParametricRef::whole(handle),
+ ),
+ direction,
+ label: "Horizontal constraint",
+ });
+
+ let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) {
+ Some(acadrust::EntityType::Line(line)) => line,
+ other => panic!("expected a Line, got {other:?}"),
+ };
+ let solved = (line.end - line.start).normalize();
+ assert!(solved.cross(&direction).length() < 1.0e-7);
+ assert!((line.length() - original_length).abs() < 1.0e-7);
+ let constraint = &app.tabs[app.active_tab]
+ .scene
+ .parametric_constraint_set(ParametricScope::ModelSpace)
+ .unwrap()
+ .constraints[0];
+ assert_eq!(constraint.axis_direction, Some(direction));
+
+ app.undo_steps(1);
+ let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) {
+ Some(acadrust::EntityType::Line(line)) => line,
+ other => panic!("expected a Line after undo, got {other:?}"),
+ };
+ assert_eq!((line.start, line.end), (original_start, original_end));
+ assert_eq!(
+ app.tabs[app.active_tab]
+ .scene
+ .parametric_constraint_set(ParametricScope::ModelSpace)
+ .map(|set| set.constraints.len())
+ .unwrap_or(0),
+ 0
+ );
+
+ app.redo_steps(1);
+ let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) {
+ Some(acadrust::EntityType::Line(line)) => line,
+ other => panic!("expected a Line after redo, got {other:?}"),
+ };
+ assert!((line.end - line.start).normalize().cross(&direction).length() < 1.0e-7);
+ }
+
+ #[test]
+ fn horizontal_two_point_solve_keeps_the_first_point_fixed() {
+ let mut app = OpenCADStudio::new_for_test();
+ let _ = app.automation_op(r#"{"op":"new"}"#);
+ let first = add_line(&mut app, 0.0, 0.0, 0.0, 5.0);
+ let second = add_line(&mut app, 8.0, 3.0, 8.0, 7.0);
+ let pick = |handle, point| crate::command::CoincidentPick {
+ handle: Some(handle),
+ point,
+ whole_curve: false,
+ };
+
+ let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint {
+ selection: crate::command::HorizontalConstraintSelection::Points(
+ pick(first, glam::DVec3::ZERO),
+ pick(second, glam::DVec3::new(8.0, 3.0, 0.0)),
+ ),
+ direction: acadrust::types::Vector3::UNIT_X,
+ label: "Horizontal constraint",
+ });
+
+ let line = |handle| match app.tabs[app.active_tab].scene.document.get_entity(handle) {
+ Some(acadrust::EntityType::Line(line)) => line,
+ other => panic!("expected a Line, got {other:?}"),
+ };
+ assert_eq!(line(first).start, acadrust::types::Vector3::ZERO);
+ assert!(line(second).start.y.abs() < 1.0e-8);
+ }
+
+ #[test]
+ fn horizontal_minor_axis_rotates_an_ellipse_around_its_center() {
+ let mut app = OpenCADStudio::new_for_test();
+ let _ = app.automation_op(r#"{"op":"new"}"#);
+ let mut ellipse = acadrust::entities::Ellipse::default();
+ ellipse.center = acadrust::types::Vector3::new(3.0, 7.0, 0.0);
+ ellipse.major_axis = acadrust::types::Vector3::new(3.0, 4.0, 0.0);
+ ellipse.minor_axis_ratio = 0.4;
+ let handle = app.tabs[app.active_tab]
+ .scene
+ .add_entity(acadrust::EntityType::Ellipse(ellipse));
+
+ let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint {
+ selection: crate::command::HorizontalConstraintSelection::Reference(
+ ParametricRef::ellipse_minor_axis(handle),
+ ),
+ direction: acadrust::types::Vector3::UNIT_X,
+ label: "Horizontal constraint",
+ });
+
+ let ellipse = match app.tabs[app.active_tab].scene.document.get_entity(handle) {
+ Some(acadrust::EntityType::Ellipse(ellipse)) => ellipse,
+ other => panic!("expected an Ellipse, got {other:?}"),
+ };
+ assert!((ellipse.center - acadrust::types::Vector3::new(3.0, 7.0, 0.0)).length() < 1.0e-9);
+ assert!(ellipse.major_axis.x.abs() < 1.0e-7);
+ assert!((ellipse.major_axis.length() - 5.0).abs() < 1.0e-7);
+ assert!((ellipse.minor_axis_ratio - 0.4).abs() < 1.0e-9);
+ }
+
/// Drawing commands must not create parametric constraints implicitly.
#[test]
fn drawing_a_line_onto_an_existing_endpoint_does_not_add_a_constraint() {
diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs
index 67a06ab81..521dd44d4 100644
--- a/src/app/commands/draw.rs
+++ b/src/app/commands/draw.rs
@@ -1016,6 +1016,26 @@ impl OpenCADStudio {
}
// ── Persistent constraints ────────────────────────────────────
+ "CONSTRAINTBAR" | "CONSTRAINTBAR_OPTIONS" => {
+ use crate::modules::parametric::ConstraintBarOptionCommand;
+
+ let handles = self.tabs[i].scene.selected_handles_in_order();
+ if handles.is_empty() && cmd == "CONSTRAINTBAR" {
+ use crate::modules::draw::select::SelectObjectsCommand;
+ let command =
+ SelectObjectsCommand::routed("CONSTRAINTBAR", "CONSTRAINTBAR_OPTIONS");
+ self.command_line.push_info(&command.prompt());
+ self.tabs[i].active_cmd = Some(Box::new(command));
+ } else if handles.is_empty() {
+ self.command_line.push_output("No objects selected.");
+ } else {
+ let command = ConstraintBarOptionCommand::new(handles);
+ self.command_line.push_info(&command.prompt());
+ self.command_line.set_step_options(command.options());
+ self.tabs[i].active_cmd = Some(Box::new(command));
+ }
+ }
+
"GCSHOW" | "GCHIDE" | "DCSHOW" | "DCHIDE" => {
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
@@ -1048,6 +1068,24 @@ impl OpenCADStudio {
.push_output(format!("{} constraint indicator(s) reset.", count).as_str());
}
+ "CONSTRAINTBAR_RESET" => {
+ let handles = self.tabs[i].scene.selected_handles_in_order();
+ if handles.is_empty() {
+ self.command_line.push_output("No objects selected.");
+ } else {
+ let scope = self.tabs[i].current_parametric_scope();
+ let count = self.tabs[i].scene.set_parametric_constraint_visibility(
+ scope,
+ Some(&handles),
+ false,
+ true,
+ );
+ self.command_line.push_output(
+ format!("{} constraint bar(s) reset.", count).as_str(),
+ );
+ }
+ }
+
"GCSHOWALL" | "GCHIDEALL" | "DCSHOWALL" | "DCHIDEALL" => {
let scope = self.tabs[i].current_parametric_scope();
let dimensional = cmd.starts_with("DC");
@@ -1206,17 +1244,38 @@ impl OpenCADStudio {
}
}
- "SMOOTHCONSTRAINT" => {
+ "GCSMOOTH" => {
+ use crate::modules::parametric::SmoothConstraintCommand;
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
- use crate::modules::draw::select::SelectObjectsCommand;
- let sel = SelectObjectsCommand::new(cmd);
- self.command_line.push_info(&sel.prompt());
- self.tabs[i].active_cmd = Some(Box::new(sel));
+ let command = SmoothConstraintCommand::new();
+ self.command_line.push_info(&command.prompt());
+ self.tabs[i].active_cmd = Some(Box::new(command));
+ } else if handles.len() != 2 {
+ self.tabs[i].scene.deselect_all();
+ self.command_line.push_error(
+ "Smooth requires an open spline first and a target curve second.",
+ );
+ let command = SmoothConstraintCommand::new();
+ self.command_line.push_info(&command.prompt());
+ self.tabs[i].active_cmd = Some(Box::new(command));
} else {
- let Some(refs) = self.tabs[i].scene.smooth_constraint_refs(&handles) else {
- self.command_line
- .push_output("Select one open spline and one open target curve.");
+ let first = self.tabs[i].scene.document.get_entity(handles[0]);
+ let second = self.tabs[i].scene.document.get_entity(handles[1]);
+ let refs = first.zip(second).and_then(|(first, second)| {
+ SmoothConstraintCommand::preselected_refs(
+ (first, handles[0]),
+ (second, handles[1]),
+ )
+ });
+ let Some(refs) = refs else {
+ self.tabs[i].scene.deselect_all();
+ self.command_line.push_error(
+ "Smooth requires an open spline first and a line, arc, polyline segment or open spline second.",
+ );
+ let command = SmoothConstraintCommand::new();
+ self.command_line.push_info(&command.prompt());
+ self.tabs[i].active_cmd = Some(Box::new(command));
return None;
};
use crate::command::CmdResult;
@@ -1318,7 +1377,50 @@ impl OpenCADStudio {
}
}
- "HCONSTRAINT" | "VCONSTRAINT" | "FXCONSTRAINT" => {
+ "GCHORIZONTAL" => {
+ use crate::command::{CmdResult, HorizontalConstraintSelection};
+ use crate::modules::parametric::HorizontalConstraintCommand;
+
+ let handles = self.tabs[i].scene.selected_handles_in_order();
+ if handles.is_empty() {
+ let command = HorizontalConstraintCommand::new();
+ self.command_line.push_info(&command.prompt());
+ self.tabs[i].active_cmd = Some(Box::new(command));
+ } else if handles.len() != 1 {
+ self.command_line
+ .push_error("Horizontal: select exactly one compatible object.");
+ } else {
+ let handle = handles[0];
+ let reference = self.tabs[i]
+ .scene
+ .document
+ .get_entity(handle)
+ .and_then(|entity| {
+ HorizontalConstraintCommand::preselected_reference(entity, handle)
+ });
+ if let Some(reference) = reference {
+ let direction = self.tabs[i].ucs_xform().working_plane().x;
+ return Some(self.apply_cmd_result(CmdResult::AddHorizontalConstraint {
+ selection: HorizontalConstraintSelection::Reference(reference),
+ direction: acadrust::types::Vector3::new(
+ direction.x,
+ direction.y,
+ direction.z,
+ ),
+ label: "Horizontal constraint",
+ }));
+ }
+ self.tabs[i].scene.deselect_all();
+ self.command_line.push_error(
+ "Horizontal: select a line, straight polyline segment, text, MText, or an ellipse axis.",
+ );
+ let command = HorizontalConstraintCommand::new();
+ self.command_line.push_info(&command.prompt());
+ self.tabs[i].active_cmd = Some(Box::new(command));
+ }
+ }
+
+ "VCONSTRAINT" | "FXCONSTRAINT" => {
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
@@ -1331,10 +1433,10 @@ impl OpenCADStudio {
} else {
use crate::command::CmdResult;
use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef};
- let (kind, label) = match cmd {
- "HCONSTRAINT" => (ConstraintKind::Horizontal, "Horizontal constraint"),
- "VCONSTRAINT" => (ConstraintKind::Vertical, "Vertical constraint"),
- _ => (ConstraintKind::Fixed, "Fixed constraint"),
+ let (kind, label) = if cmd == "VCONSTRAINT" {
+ (ConstraintKind::Vertical, "Vertical constraint")
+ } else {
+ (ConstraintKind::Fixed, "Fixed constraint")
};
return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint {
kind,
@@ -1475,29 +1577,41 @@ impl OpenCADStudio {
}
}
- "NCONSTRAINT" => {
+ "GCCONCENTRIC" => {
+ use crate::command::CmdResult;
+ use crate::modules::parametric::ConcentricConstraintCommand;
+
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
- use crate::modules::draw::select::SelectObjectsCommand;
- let sel = SelectObjectsCommand::new(cmd);
- self.command_line.push_info(&sel.prompt());
- self.tabs[i].active_cmd = Some(Box::new(sel));
- } else if handles.len() != 2 {
- self.command_line.push_output(
- "Select exactly two circles/arcs, then run this constraint again.",
- );
+ let command = ConcentricConstraintCommand::new();
+ self.command_line.push_info(&command.prompt());
+ self.tabs[i].active_cmd = Some(Box::new(command));
} else {
- use crate::command::CmdResult;
- use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef};
- return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint {
- kind: ConstraintKind::Concentric,
- refs: vec![
- ParametricRef::center(handles[0]),
- ParametricRef::center(handles[1]),
- ],
- driving_param: None,
- label: "Concentric constraint",
- }));
+ let refs = handles
+ .iter()
+ .filter_map(|handle| {
+ let entity = self.tabs[i].scene.document.get_entity(*handle)?;
+ ConcentricConstraintCommand::preselected_reference(entity, *handle)
+ })
+ .collect::>();
+ if handles.len() == 2 && refs.len() == 2 && refs[0] != refs[1] {
+ return Some(self.apply_cmd_result(CmdResult::AddConcentricConstraint {
+ first: refs[0],
+ second: refs[1],
+ label: "Concentric constraint",
+ }));
+ }
+ self.tabs[i].scene.deselect_all();
+ self.command_line.push_error(
+ "Invalid selection for Concentric. Select a circle, arc, ellipse or polyline arc segment.",
+ );
+ let command = refs
+ .first()
+ .copied()
+ .map(ConcentricConstraintCommand::with_first)
+ .unwrap_or_else(ConcentricConstraintCommand::new);
+ self.command_line.push_info(&command.prompt());
+ self.tabs[i].active_cmd = Some(Box::new(command));
}
}
diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs
index f3dd496cd..ec766449a 100644
--- a/src/app/commands/styleprops.rs
+++ b/src/app/commands/styleprops.rs
@@ -1051,6 +1051,7 @@ impl OpenCADStudio {
| "CONSTRAINTSOLVEMODE"
| "CONSTRAINTINFER"
| "CONSTRAINTBARDISPLAY"
+ | "CONSTRAINTBARMODE"
) =>
{
return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i);
@@ -1073,7 +1074,7 @@ impl OpenCADStudio {
let value = it.next().map(|s| s.trim().to_string());
if name.is_empty() || name == "?" {
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 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 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" {
@@ -1269,15 +1270,23 @@ impl OpenCADStudio {
}
if matches!(
name.as_str(),
- "CONSTRAINTSOLVEMODE" | "CONSTRAINTINFER" | "CONSTRAINTBARDISPLAY"
+ "CONSTRAINTSOLVEMODE"
+ | "CONSTRAINTINFER"
+ | "CONSTRAINTBARDISPLAY"
+ | "CONSTRAINTBARMODE"
) {
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,
_ => unreachable!(),
};
- let maximum = if name == "CONSTRAINTBARDISPLAY" { 3 } else { 1 };
+ let maximum = match name.as_str() {
+ "CONSTRAINTBARDISPLAY" => 3,
+ "CONSTRAINTBARMODE" => 4095,
+ _ => 1,
+ };
if let Some(value) = &value {
match value
.parse::()
@@ -1293,6 +1302,7 @@ impl OpenCADStudio {
"CONSTRAINTBARDISPLAY" => {
self.constraint_bar_display = mode
}
+ "CONSTRAINTBARMODE" => self.constraint_bar_mode = mode,
_ => unreachable!(),
}
self.persist_settings_if_changed();
diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs
index b0da52d1e..3d44a7fb2 100644
--- a/src/app/control/mod.rs
+++ b/src/app/control/mod.rs
@@ -220,6 +220,25 @@ fn command_category(name: &str) -> &'static str {
"view"
} else if name.starts_with("DIM") || ["LEADER", "MLEADER", "TOLERANCE"].contains(&name) {
"annotate"
+ } else if [
+ "GEOMCONSTRAINT",
+ "GCCOINCIDENT",
+ "GCCOLLINEAR",
+ "GCPERPENDICULAR",
+ "QCONSTRAINT",
+ "PCONSTRAINT",
+ "GCHORIZONTAL",
+ "VCONSTRAINT",
+ "ECONSTRAINT",
+ "TCONSTRAINT",
+ "GCCONCENTRIC",
+ "NRCONSTRAINT",
+ "LCONSTRAINT",
+ "DELCONSTRAINT",
+ ]
+ .contains(&name)
+ {
+ "parametric"
} else if [
"BOX",
"CYLINDER",
diff --git a/src/app/document.rs b/src/app/document.rs
index 50f80fd09..e7f9041ed 100644
--- a/src/app/document.rs
+++ b/src/app/document.rs
@@ -1,5 +1,4 @@
use crate::command::CadCommand;
-use crate::io::linetypes;
use crate::modules::draw::modify::block_edit::BlockEditSession;
use crate::modules::draw::modify::refedit::RefEditSession;
use crate::scene::pick::grip::GripEdit;
@@ -593,24 +592,7 @@ impl DocumentTab {
pub(super) fn new_drawing(n: usize) -> Self {
let mut scene = Scene::new();
- linetypes::populate_document(&mut scene.document);
- // Paper layouts start as A4 landscape.
- for obj in scene.document.objects.values_mut() {
- if let acadrust::objects::ObjectType::Layout(l) = obj {
- if l.name != "Model" {
- l.min_limits = (0.0, 0.0);
- l.max_limits = (297.0, 210.0);
- l.min_extents = (0.0, 0.0, 0.0);
- l.max_extents = (297.0, 210.0, 0.0);
- l.paper_width = 297.0;
- l.paper_height = 210.0;
- l.plot_paper_units = 1;
- l.plot_scale_numerator = 1.0;
- l.plot_scale_denominator = 1.0;
- l.paper_size = "ISO_A4_(297.00_x_210.00_MM)".into();
- }
- }
- }
+ scene.populate_new_drawing_defaults();
Self {
id: NEXT_DOCUMENT_TAB_ID.fetch_add(1, Ordering::Relaxed),
scene,
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 281daca5b..a33e7e081 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -602,6 +602,7 @@ pub(super) struct OpenCADStudio {
pub constraint_solve_mode: bool,
pub constraint_infer: bool,
pub constraint_bar_display: i16,
+ pub constraint_bar_mode: i16,
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command);
/// 0 disables autosave.
pub savetime_min: i32,
@@ -3906,6 +3907,7 @@ impl OpenCADStudio {
constraint_solve_mode: true,
constraint_infer: false,
constraint_bar_display: 3,
+ constraint_bar_mode: 4095,
savetime_min: 10,
default_bg_color: None,
default_paper_bg_color: None,
diff --git a/src/app/settings.rs b/src/app/settings.rs
index 0be12cc9c..2ddc3c39f 100644
--- a/src/app/settings.rs
+++ b/src/app/settings.rs
@@ -465,6 +465,9 @@ pub struct UserSettings {
/// Constraint bar display bit mask: 1 after applying, 2 on selection.
#[serde(default = "default_constraint_bar_display")]
pub constraint_bar_display: i16,
+ /// Geometric constraint type bit mask (1..2048, combined; default all).
+ #[serde(default = "default_constraint_bar_mode")]
+ pub constraint_bar_mode: i16,
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command).
/// 0 disables autosave.
pub savetime_min: i32,
@@ -595,6 +598,10 @@ fn default_constraint_bar_display() -> i16 {
3
}
+fn default_constraint_bar_mode() -> i16 {
+ 4095
+}
+
fn deserialize_clipromptlines<'de, D>(de: D) -> Result
where
D: serde::Deserializer<'de>,
@@ -662,6 +669,7 @@ impl Default for UserSettings {
constraint_solve_mode: true,
constraint_infer: false,
constraint_bar_display: 3,
+ constraint_bar_mode: 4095,
savetime_min: 10,
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
pick_add: true,
diff --git a/src/app/update/command.rs b/src/app/update/command.rs
index ec238814d..c2e7715eb 100644
--- a/src/app/update/command.rs
+++ b/src/app/update/command.rs
@@ -1739,15 +1739,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task {
.get_entity(popup.handle)
.is_some_and(|entity| match (item.action, entity) {
(GripMenuAction::ShowFit, acadrust::EntityType::Spline(spline)) => {
- !spline.cv_frame_visible
- && crate::entities::spline::uses_fit_method(spline)
+ crate::entities::spline::shows_fit_points(spline)
}
(
GripMenuAction::ShowControlVertices,
acadrust::EntityType::Spline(spline),
) => {
- spline.cv_frame_visible
- || !crate::entities::spline::uses_fit_method(spline)
+ crate::entities::spline::shows_control_vertices(spline)
}
_ => false,
});
diff --git a/src/app/update/file.rs b/src/app/update/file.rs
index 5d560690d..b2044180c 100644
--- a/src/app/update/file.rs
+++ b/src/app/update/file.rs
@@ -152,6 +152,96 @@ fn paper_name_for_settings(
}
}
+/// The stored rotation for the dialog's sheet on a medium that is itself
+/// `natural`-oriented: a quarter turn when the requested orientation differs
+/// from the medium's own, and "upside-down" adds a half turn on top. This is
+/// how the commercial application writes it — a system printer's portrait
+/// `A3` plotted landscape carries 90°, while its PDF driver's landscape
+/// `ISO_A4_(297.00_x_210.00_MM)` medium plotted landscape carries 0°.
+fn plot_dialog_rotation(
+ d: &crate::ui::window::plot::PlotDialogState,
+ natural: crate::io::paper_catalog::Orientation,
+) -> acadrust::objects::PlotRotation {
+ use acadrust::objects::PlotRotation;
+ match (plot_dialog_orientation(d) != natural, d.upside_down) {
+ (false, false) => PlotRotation::None,
+ (true, false) => PlotRotation::Degrees90,
+ (false, true) => PlotRotation::Degrees180,
+ (true, true) => PlotRotation::Degrees270,
+ }
+}
+
+/// Millimetres in the paper unit a page setup counts in.
+fn plot_paper_unit_mm(units: acadrust::objects::PlotPaperUnits) -> f64 {
+ match units {
+ acadrust::objects::PlotPaperUnits::Inches => 25.4,
+ _ => 1.0,
+ }
+}
+
+/// The factor the commercial application stores next to the scale (DXF 147)
+/// is the `paper : drawing` ratio times 25.4 on millimetre page setups and
+/// the plain ratio on inch ones — observed in its files, whatever the
+/// published description says. Both directions of that convention live here.
+fn stored_scale_factor(ratio: f64, units: acadrust::objects::PlotPaperUnits) -> f64 {
+ match units {
+ acadrust::objects::PlotPaperUnits::Millimeters => ratio * 25.4,
+ _ => ratio,
+ }
+}
+
+fn ratio_from_stored_scale_factor(factor: f64, units: acadrust::objects::PlotPaperUnits) -> f64 {
+ match units {
+ acadrust::objects::PlotPaperUnits::Millimeters => factor / 25.4,
+ _ => factor,
+ }
+}
+
+/// The file format's standard scale for a `paper : drawing` ratio, when it
+/// has one. Only the metric ratios exist as standard codes here; an
+/// architectural scale is stored as a custom ratio with the same value.
+fn standard_scale_for_ratio(paper: f64, drawing: f64) -> Option {
+ use acadrust::objects::ScaledType as S;
+ const STANDARD: &[(f64, f64, S)] = &[
+ (1.0, 1.0, S::OneToOne),
+ (1.0, 2.0, S::OneToTwo),
+ (1.0, 4.0, S::OneToFour),
+ (1.0, 8.0, S::OneToEight),
+ (1.0, 10.0, S::OneToTen),
+ (1.0, 16.0, S::OneToSixteen),
+ (1.0, 20.0, S::OneToTwenty),
+ (1.0, 30.0, S::OneToThirty),
+ (1.0, 40.0, S::OneToForty),
+ (1.0, 50.0, S::OneToFifty),
+ (1.0, 100.0, S::OneToHundred),
+ (2.0, 1.0, S::TwoToOne),
+ (4.0, 1.0, S::FourToOne),
+ (8.0, 1.0, S::EightToOne),
+ (10.0, 1.0, S::TenToOne),
+ (100.0, 1.0, S::HundredToOne),
+ ];
+ let ratio = paper / drawing;
+ STANDARD
+ .iter()
+ .find(|(p, d, _)| (p / d - ratio).abs() <= 1e-9 * ratio.abs().max(1.0))
+ .map(|(_, _, scale)| *scale)
+}
+
+/// A scale-list name for a plot scale the drawing has no name for, spelled as
+/// the ratio it is (`1:250`, `25.4:1`) so the value survives in the picker
+/// instead of collapsing to 1:1.
+fn ratio_scale_name(factor: f64) -> String {
+ let trim = |value: f64| {
+ let text = format!("{value:.4}");
+ text.trim_end_matches('0').trim_end_matches('.').to_string()
+ };
+ if factor >= 1.0 {
+ format!("{}:1", trim(factor))
+ } else {
+ format!("1:{}", trim(1.0 / factor))
+ }
+}
+
/// The plot device the dialog currently targets.
fn plot_dialog_device(d: &crate::ui::window::plot::PlotDialogState) -> crate::io::plot_device::PlotDevice {
use crate::io::plot_device::PlotDevice;
@@ -521,6 +611,7 @@ impl OpenCADStudio {
constraint_solve_mode: self.constraint_solve_mode,
constraint_infer: self.constraint_infer,
constraint_bar_display: self.constraint_bar_display,
+ constraint_bar_mode: self.constraint_bar_mode,
savetime_min: self.savetime_min,
default_save_format: self.default_save_format.clone(),
pick_add: self.pick_add,
@@ -624,6 +715,7 @@ impl OpenCADStudio {
self.constraint_solve_mode = s.constraint_solve_mode;
self.constraint_infer = s.constraint_infer;
self.constraint_bar_display = s.constraint_bar_display.clamp(0, 3);
+ self.constraint_bar_mode = s.constraint_bar_mode.clamp(0, 4095);
self.savetime_min = s.savetime_min;
self.default_save_format =
crate::io::canonical_save_format(&s.default_save_format).to_string();
@@ -3004,127 +3096,209 @@ impl OpenCADStudio {
iced::exit()
}
- /// Write the given plot page settings into the active layout.
- /// No-op on the Model tab (which has no paper layout). Marks the tab dirty
- /// and re-tessellates the sheet. Called by the Plot dialog's Set current action.
- #[allow(clippy::too_many_arguments)]
- pub(super) fn apply_plot_page_settings(
- &mut self,
- w: f64,
- h: f64,
- plot_area: &str,
- center: bool,
- offset_x: f64,
- offset_y: f64,
- rotation: i16,
- ) {
- let i = self.active_tab;
- let dialog = self.plot_dialog.clone();
- let layout_name = self.tabs[i].scene.current_layout.clone();
- let plot_window = self.plot_window;
- if layout_name != "Model" {
- let w: f64 = w.max(1.0);
- let h: f64 = h.max(1.0);
- use acadrust::objects::{
- PlotRotation, PlotSettings, PlotType, ScaledType, ShadePlotMode,
- ShadePlotResolutionLevel,
- };
- let mut ps = self
- .plot_setup_template
- .clone()
- .or_else(|| self.tabs[i].scene.plot_settings_for(&layout_name))
- .unwrap_or_else(|| PlotSettings::new(""));
- // Names and margins are decided against the stored settings before
- // any field is overwritten.
- let paper_size = paper_name_for_settings(&dialog, &ps);
- let printer_name = device_name_for_settings(&dialog, &ps);
- let margins = margins_for_settings(&dialog, &ps);
- ps.paper_size = paper_size;
- ps.printer_name = printer_name;
- ps.margins = margins;
- ps.paper_width = w;
- ps.paper_height = h;
- ps.plot_type = match plot_area {
- "Window" => PlotType::Window,
- "Display" => PlotType::LastScreenDisplay,
- "Extents" => PlotType::Extents,
- "Limits" => PlotType::Limits,
- area if area.starts_with("View: ") => PlotType::View,
- _ => PlotType::Layout,
- };
- ps.plot_view_name = plot_area.strip_prefix("View: ").unwrap_or("").to_string();
- if plot_area == "Window" {
- if let Some((x0, y0, x1, y1)) = plot_window {
- ps.set_plot_window(x0, y0, x1, y1);
- }
- }
- ps.flags.plot_centered = center && plot_area != "Layout";
- ps.origin_x = if plot_area == "Layout" { 0.0 } else { offset_x };
- ps.origin_y = if plot_area == "Layout" { 0.0 } else { offset_y };
- ps.rotation = match rotation {
- 90 => PlotRotation::Degrees90,
- 180 => PlotRotation::Degrees180,
- 270 => PlotRotation::Degrees270,
- _ => PlotRotation::None,
- };
- if dialog.fit_to_paper && plot_area != "Layout" {
- ps.set_scale_to_fit();
- } else if plot_area == "Layout" {
- ps.set_standard_scale(ScaledType::OneToOne);
- ps.standard_scale_factor = 1.0;
- } else {
- let factor = plot_dialog_scale_factor(&dialog);
- ps.scale_type = ScaledType::CustomScale;
- ps.scale_numerator = factor;
- ps.scale_denominator = 1.0;
- ps.standard_scale_factor = factor;
- ps.flags.use_standard_scale = false;
- }
- ps.current_style_sheet = dialog.style_name.clone();
- ps.flags.scale_lineweights = dialog.scale_lw;
- ps.flags.print_lineweights = dialog.lineweights;
- ps.flags.plot_plot_styles = dialog.apply_plot_styles && !dialog.style_name.is_empty();
- ps.flags.show_plot_styles = dialog.show_plot_styles && !dialog.style_name.is_empty();
- ps.flags.draw_viewports_first = dialog.paperspace_last;
- ps.flags.plot_hidden = dialog.shade == "Hidden Line";
- ps.shade_plot_mode = match dialog.shade.as_str() {
- "2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe,
- "Hidden Line" => ShadePlotMode::Hidden,
- "As displayed" => ShadePlotMode::AsDisplayed,
- _ => ShadePlotMode::Rendered,
- };
- ps.shade_plot_resolution = match dialog.quality.as_str() {
- "Low" => ShadePlotResolutionLevel::Draft,
- "High" => ShadePlotResolutionLevel::Presentation,
- _ => ShadePlotResolutionLevel::Normal,
- };
- ps.shade_plot_dpi = 300;
+ /// The settings the dialog edits on top of: the named page setup it was
+ /// loaded from, else the active layout's own, else a fresh metric sheet.
+ /// Everything the dialog has no control for (viewport-border and
+ /// paper-update flags, the plot view handle, the paper unit that fixes
+ /// the layout's coordinate system) is carried over from here untouched.
+ fn dialog_base_settings(&self) -> acadrust::objects::PlotSettings {
+ use acadrust::objects::{PlotPaperUnits, PlotSettings};
+ let scene = &self.tabs[self.active_tab].scene;
+ self.plot_setup_template
+ .clone()
+ .or_else(|| scene.plot_settings_for(&scene.current_layout))
+ .unwrap_or_else(|| {
+ let mut settings = PlotSettings::new("");
+ settings.paper_units = PlotPaperUnits::Millimeters;
+ settings
+ })
+ }
- self.tabs[i]
- .scene
- .set_layout_plot_settings(&layout_name, &ps);
- for obj in self.tabs[i].scene.document.objects.values_mut() {
- if let acadrust::objects::ObjectType::Layout(layout) = obj {
- if layout.name == layout_name {
- layout.min_limits = (0.0, 0.0);
- layout.max_limits = (w, h);
- layout.min_extents = (0.0, 0.0, 0.0);
- layout.max_extents = (w, h, 0.0);
- break;
+ /// The page setup the dialog describes, written the way the file format
+ /// expects it: the portrait medium with the orientation in `rotation`,
+ /// standard scales by their code, custom ones as a readable ratio in the
+ /// layout's own paper unit. Named page setups and the layout's embedded
+ /// settings both come from here so the two can never drift apart.
+ fn plot_settings_from_dialog(
+ &self,
+ mut ps: acadrust::objects::PlotSettings,
+ ) -> acadrust::objects::PlotSettings {
+ use crate::io::paper_catalog::Orientation;
+ use acadrust::objects::{PlotType, ScaledType, ShadePlotMode, ShadePlotResolutionLevel};
+ let d = &self.plot_dialog;
+ let scene = &self.tabs[self.active_tab].scene;
+ // Names and margins are decided against the stored settings before
+ // any field is overwritten.
+ let paper_size = paper_name_for_settings(d, &ps);
+ let printer_name = device_name_for_settings(d, &ps);
+ let margins = margins_for_settings(d, &ps);
+ let same_medium = paper_unchanged(d, &ps) && ps.paper_width > 0.0 && ps.paper_height > 0.0;
+ ps.paper_size = paper_size;
+ ps.printer_name = printer_name;
+ ps.margins = margins;
+ // An unchanged sheet keeps the medium exactly as the drawing stores
+ // it — its driver's precise dimensions in the orientation the driver
+ // defines it (a PDF driver lists landscape media of its own) — and
+ // only the rotation says how the sheet is plotted on it. A new
+ // selection is written as the catalogue's portrait medium.
+ let natural = if same_medium {
+ if ps.paper_width > ps.paper_height {
+ Orientation::Landscape
+ } else {
+ Orientation::Portrait
+ }
+ } else {
+ let (width, height) = plot_dialog_paper(d).portrait_mm();
+ ps.paper_width = width.max(1.0);
+ ps.paper_height = height.max(1.0);
+ Orientation::Portrait
+ };
+ ps.rotation = plot_dialog_rotation(d, natural);
+ let layout_area = d.area == "Layout";
+ ps.plot_type = match d.area.as_str() {
+ "Window" => PlotType::Window,
+ "Layout" => PlotType::Layout,
+ "Display" => PlotType::LastScreenDisplay,
+ "Limits" => PlotType::Limits,
+ area if area.starts_with("View: ") => PlotType::View,
+ _ => PlotType::Extents,
+ };
+ ps.plot_view_name = d.area.strip_prefix("View: ").unwrap_or("").to_string();
+ if d.area == "Window" {
+ if let Some((x0, y0, x1, y1)) = self.plot_window {
+ ps.set_plot_window(x0, y0, x1, y1);
+ }
+ }
+ ps.flags.plot_centered = d.center && !layout_area;
+ ps.origin_x = if layout_area {
+ 0.0
+ } else {
+ d.offset_x.parse::().unwrap_or(0.0)
+ };
+ ps.origin_y = if layout_area {
+ 0.0
+ } else {
+ d.offset_y.parse::().unwrap_or(0.0)
+ };
+ if d.fit_to_paper && !layout_area {
+ ps.set_scale_to_fit();
+ // The commercial application marks fitted plots with the
+ // "standard scale" bit and leaves the ratio it computed.
+ ps.flags.use_standard_scale = true;
+ } else {
+ // The dialog's factor is per the drawing's scale-family unit
+ // (inches for an imperial drawing, else millimetres) and already
+ // includes the drawing-unit factor; the file stores it per the
+ // layout's paper unit. A named ratio is kept as written when no
+ // drawing-unit factor hides inside it — a metre drawing plotting
+ // "1:100" really plots 10 mm per unit, which is what the file
+ // must say — and its standard code is kept even across a
+ // paper-unit conversion, the way the commercial application
+ // writes "1:1" on an inch page setup over a millimetre paper
+ // space as `1 in = 25.4 units`. A layout plot goes through the
+ // same path: the dialog holds it at 1:1, which the conversion
+ // turns into whatever keeps the paper space's unit intact.
+ let factor = plot_dialog_scale_factor(d);
+ let conversion = scene.scale_family_unit_mm() / plot_paper_unit_mm(ps.paper_units);
+ let named = scene.scale_ratio(&d.scale).filter(|(paper, drawing)| {
+ (paper / drawing - factor).abs() <= 1e-9 * factor.abs().max(1.0)
+ });
+ let standard =
+ named.and_then(|(paper, drawing)| standard_scale_for_ratio(paper, drawing));
+ let (numerator, denominator) = match named {
+ Some((paper, drawing)) if (conversion - 1.0).abs() <= 1e-12 => (paper, drawing),
+ _ => {
+ let per_unit = (factor * conversion).max(1e-9);
+ if per_unit >= 1.0 {
+ (per_unit, 1.0)
+ } else {
+ (1.0, 1.0 / per_unit)
}
}
- }
-
- self.tabs[i].dirty = true;
- self.tabs[i].scene.bump_geometry_no_blocks();
- self.command_line.push_info(
- crate::tf!(
- "Page setup: {w:.1}×{h:.1} mm area={plot_area} \
- center={center} rot={rotation}°"
- )
- .as_ref(),
- );
+ };
+ // The commercial application leaves the "standard scale" bit
+ // clear even for a standard code and keeps the ratio in the
+ // numerator/denominator pair; readers that trust the bit would
+ // otherwise take the stored factor as the ratio itself.
+ ps.scale_type = standard.unwrap_or(ScaledType::CustomScale);
+ ps.flags.use_standard_scale = false;
+ ps.scale_numerator = numerator;
+ ps.scale_denominator = denominator;
+ ps.standard_scale_factor =
+ stored_scale_factor(numerator / denominator, ps.paper_units);
}
+ ps.current_style_sheet = d.style_name.clone();
+ ps.flags.scale_lineweights = d.scale_lw;
+ ps.flags.print_lineweights = d.lineweights;
+ ps.flags.plot_plot_styles = d.apply_plot_styles;
+ ps.flags.show_plot_styles = d.show_plot_styles;
+ ps.flags.draw_viewports_first = d.paperspace_last;
+ ps.flags.plot_hidden = d.shade == "Hidden Line";
+ ps.shade_plot_mode = match d.shade.as_str() {
+ "2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe,
+ "Hidden Line" => ShadePlotMode::Hidden,
+ "As displayed" => ShadePlotMode::AsDisplayed,
+ _ => ShadePlotMode::Rendered,
+ };
+ ps.shade_plot_resolution = match d.quality.as_str() {
+ "Low" => ShadePlotResolutionLevel::Draft,
+ "High" => ShadePlotResolutionLevel::Presentation,
+ _ => ShadePlotResolutionLevel::Normal,
+ };
+ ps.shade_plot_dpi = 300;
+ ps
+ }
+
+ /// Write the dialog's page setup into the active layout. No-op on the
+ /// Model tab (which has no paper layout). Marks the tab dirty and
+ /// re-tessellates the sheet. Called by the Plot dialog's Set current action.
+ pub(super) fn apply_plot_page_settings(&mut self) {
+ let i = self.active_tab;
+ let layout_name = self.tabs[i].scene.current_layout.clone();
+ if layout_name == "Model" {
+ return;
+ }
+ let ps = self.plot_settings_from_dialog(self.dialog_base_settings());
+ let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog);
+ let plot_area = self.plot_dialog.area.clone();
+ let center = ps.flags.plot_centered;
+ let rotation = ps.rotation.to_degrees() as i32;
+ self.tabs[i]
+ .scene
+ .set_layout_plot_settings(&layout_name, &ps);
+ // Layout limits are the sheet in paper-space units, which the
+ // settings just written define, placed the way the commercial
+ // application places it: the printable area's corner is the paper
+ // space origin, so the sheet starts at minus its displayed margins.
+ let units_per_mm = self.tabs[i].scene.paper_space_unit_factor().max(1e-9);
+ let m = ps.margins;
+ let (left, bottom, ..) = crate::scene::rotated_margins(
+ (m.left, m.bottom, m.right, m.top),
+ ps.rotation.to_code(),
+ );
+ let (x0, y0) = (-left * units_per_mm, -bottom * units_per_mm);
+ let (lw, lh) = (w * units_per_mm, h * units_per_mm);
+ for obj in self.tabs[i].scene.document.objects.values_mut() {
+ if let acadrust::objects::ObjectType::Layout(layout) = obj {
+ if layout.name == layout_name {
+ layout.min_limits = (x0, y0);
+ layout.max_limits = (x0 + lw, y0 + lh);
+ layout.min_extents = (x0, y0, 0.0);
+ layout.max_extents = (x0 + lw, y0 + lh, 0.0);
+ break;
+ }
+ }
+ }
+
+ self.tabs[i].dirty = true;
+ self.tabs[i].scene.bump_geometry_no_blocks();
+ self.command_line.push_info(
+ crate::tf!(
+ "Page setup: {w:.1}×{h:.1} mm area={plot_area} \
+ center={center} rot={rotation}°"
+ )
+ .as_ref(),
+ );
}
pub(in crate::app) fn on_plot_export_path_some(
@@ -4552,94 +4726,9 @@ impl OpenCADStudio {
/// Build a `PlotSettings` from the current dialog fields (for saving a named
/// page setup).
+ /// The dialog's values as a named page setup.
fn dialog_to_plotsettings(&self) -> acadrust::objects::PlotSettings {
- use acadrust::objects::{
- PlotPaperUnits, PlotRotation, PlotSettings, PlotType, ScaledType, ShadePlotMode,
- ShadePlotResolutionLevel,
- };
- let d = &self.plot_dialog;
- let (w, h) = plot_dialog_sheet_mm(d);
- let mut ps = match self.plot_setup_template.clone() {
- Some(settings) => settings,
- None => {
- let mut settings = PlotSettings::new("");
- settings.paper_units = PlotPaperUnits::Millimeters;
- settings
- }
- };
- let paper_size = paper_name_for_settings(d, &ps);
- let printer_name = device_name_for_settings(d, &ps);
- let margins = margins_for_settings(d, &ps);
- ps.paper_size = paper_size;
- ps.printer_name = printer_name;
- ps.margins = margins;
- ps.paper_width = w;
- ps.paper_height = h;
- ps.plot_type = match d.area.as_str() {
- "Window" => PlotType::Window,
- "Layout" => PlotType::Layout,
- "Display" => PlotType::LastScreenDisplay,
- "Limits" => PlotType::Limits,
- area if area.starts_with("View: ") => PlotType::View,
- _ => PlotType::Extents,
- };
- ps.plot_view_name = d.area.strip_prefix("View: ").unwrap_or("").to_string();
- if d.area == "Window" {
- if let Some((x0, y0, x1, y1)) = self.plot_window {
- ps.set_plot_window(x0, y0, x1, y1);
- }
- }
- ps.flags.plot_centered = d.center && d.area != "Layout";
- ps.origin_x = if d.area == "Layout" {
- 0.0
- } else {
- d.offset_x.parse::().unwrap_or(0.0)
- };
- ps.origin_y = if d.area == "Layout" {
- 0.0
- } else {
- d.offset_y.parse::().unwrap_or(0.0)
- };
- let rot: i16 = if d.upside_down { 180 } else { 0 };
- ps.rotation = match rot {
- 90 => PlotRotation::Degrees90,
- 180 => PlotRotation::Degrees180,
- 270 => PlotRotation::Degrees270,
- _ => PlotRotation::None,
- };
- if d.area == "Layout" {
- ps.set_standard_scale(ScaledType::OneToOne);
- ps.standard_scale_factor = 1.0;
- } else if d.fit_to_paper {
- ps.set_scale_to_fit();
- } else {
- let factor = plot_dialog_scale_factor(d);
- ps.scale_type = ScaledType::CustomScale;
- ps.scale_numerator = factor;
- ps.scale_denominator = 1.0;
- ps.standard_scale_factor = factor;
- ps.flags.use_standard_scale = false;
- }
- ps.current_style_sheet = d.style_name.clone();
- ps.flags.scale_lineweights = d.scale_lw;
- ps.flags.print_lineweights = d.lineweights;
- ps.flags.plot_plot_styles = d.apply_plot_styles && !d.style_name.is_empty();
- ps.flags.show_plot_styles = d.show_plot_styles && !d.style_name.is_empty();
- ps.flags.draw_viewports_first = d.paperspace_last;
- ps.flags.plot_hidden = d.shade == "Hidden Line";
- ps.shade_plot_mode = match d.shade.as_str() {
- "2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe,
- "Hidden Line" => ShadePlotMode::Hidden,
- "As displayed" => ShadePlotMode::AsDisplayed,
- _ => ShadePlotMode::Rendered,
- };
- ps.shade_plot_resolution = match d.quality.as_str() {
- "Low" => ShadePlotResolutionLevel::Draft,
- "High" => ShadePlotResolutionLevel::Presentation,
- _ => ShadePlotResolutionLevel::Normal,
- };
- ps.shade_plot_dpi = 300;
- ps
+ self.plot_settings_from_dialog(self.dialog_base_settings())
}
/// Load a `PlotSettings` into the dialog editor fields.
@@ -4700,6 +4789,7 @@ impl OpenCADStudio {
"Portrait"
}
.to_string();
+ let family_unit_mm = self.tabs[self.active_tab].scene.scale_family_unit_mm();
let d = &mut self.plot_dialog;
d.paper = paper.canonical.to_string();
d.paper_width_mm = ps.paper_width.max(1.0);
@@ -4735,22 +4825,39 @@ impl OpenCADStudio {
}
d.upside_down = matches!(deg, 180 | 270);
d.fit_to_paper = d.area != "Layout" && ps.is_scale_to_fit();
- let target_factor = if d.area == "Layout" {
- 1.0
- } else if ps.flags.use_standard_scale {
- if ps.standard_scale_factor.is_finite() && ps.standard_scale_factor > 0.0 {
- ps.standard_scale_factor
- } else {
- ps.scale_type.scale_factor()
- }
- } else if ps.scale_denominator.abs() > 1e-9 {
- ps.scale_numerator / ps.scale_denominator
+ let ratio = ps.scale_numerator / ps.scale_denominator;
+ let stored_factor = if ratio.is_finite() && ratio > 0.0 {
+ ratio
+ } else if ps.flags.use_standard_scale
+ && ps.standard_scale_factor.is_finite()
+ && ps.standard_scale_factor > 0.0
+ {
+ ratio_from_stored_scale_factor(ps.standard_scale_factor, ps.paper_units)
+ } else {
+ ps.scale_type.scale_factor()
+ };
+ // The file's factor is per the layout's paper unit; the picker's are
+ // per the drawing's scale-family unit (see `plot_settings_from_dialog`).
+ let target_factor = if stored_factor.is_finite() && stored_factor > 0.0 {
+ stored_factor * plot_paper_unit_mm(ps.paper_units) / family_unit_mm
} else {
1.0
};
- if !d.fit_to_paper || !d.scales.iter().any(|(name, _)| name == &d.scale) {
- d.scale = scale_name_for_factor(&d.scales, target_factor)
- .or_else(|| scale_name_for_factor(&d.scales, 1.0))
+ if !d.fit_to_paper {
+ d.scale = scale_name_for_factor(&d.scales, target_factor).unwrap_or_else(|| {
+ // A scale the drawing has no name for stays what it is rather
+ // than snapping to 1:1; the ratio entry disappears again with
+ // the next scale-list refresh once it is no longer selected.
+ let name = ratio_scale_name(target_factor);
+ d.scales.push((name.clone(), target_factor));
+ d.scales
+ .sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
+ name
+ });
+ } else if !d.scales.iter().any(|(name, _)| name == &d.scale) {
+ // A fitted plot's stored ratio is what the fit computed, not a
+ // choice; the picker just needs a valid entry behind it.
+ d.scale = scale_name_for_factor(&d.scales, 1.0)
.or_else(|| d.scales.first().map(|(name, _)| name.clone()))
.unwrap_or_else(|| "1:1".into());
}
@@ -4806,12 +4913,7 @@ impl OpenCADStudio {
fn apply_dialog_to_layout(&mut self) {
self.sync_dialog_plot_runtime();
- let d = self.plot_dialog.clone();
- let (sheet_w, sheet_h) = plot_dialog_sheet_mm(&d);
- let rotation: i16 = if d.upside_down { 180 } else { 0 };
- let off_x = d.offset_x.parse::().unwrap_or(0.0);
- let off_y = d.offset_y.parse::().unwrap_or(0.0);
- self.apply_plot_page_settings(sheet_w, sheet_h, &d.area, d.center, off_x, off_y, rotation);
+ self.apply_plot_page_settings();
}
/// Open a preview PDF, export a PDF, or send the job to the chosen printer.
@@ -5239,16 +5341,394 @@ mod plot_paper_tests {
ps.paper_size = "A4".into();
ps.paper_width = 297.0;
ps.paper_height = 210.0;
+ // Such drivers spell the orientation in the dimensions, not the rotation.
+ ps.rotation = acadrust::objects::PlotRotation::None;
assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
app
}
fn layout_paper(app: &OpenCADStudio) -> (String, f64, f64) {
- let ps = app.tabs[app.active_tab]
+ let ps = layout_settings(app);
+ (ps.paper_size, ps.paper_width, ps.paper_height)
+ }
+
+ fn layout_settings(app: &OpenCADStudio) -> acadrust::objects::PlotSettings {
+ app.tabs[app.active_tab]
.scene
.plot_settings_for("Layout1")
- .unwrap();
- (ps.paper_size, ps.paper_width, ps.paper_height)
+ .unwrap()
+ }
+
+ fn layout_rotation(app: &OpenCADStudio) -> acadrust::objects::PlotRotation {
+ layout_settings(app).rotation
+ }
+
+ /// Store the drawing's plot settings for `Layout1` after `edit` touched them.
+ fn edit_layout_settings(
+ app: &mut OpenCADStudio,
+ edit: impl FnOnce(&mut acadrust::objects::PlotSettings),
+ ) {
+ let i = app.active_tab;
+ let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
+ edit(&mut ps);
+ assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps));
+ }
+
+ #[test]
+ fn a_new_drawing_starts_with_a_compatible_default_layout() {
+ use acadrust::objects::{PlotRotation, PlotType, ScaledType};
+ let mut app = OpenCADStudio::new_for_test();
+ app.automation_op(r#"{"op":"new"}"#);
+ let _ = app.update(Message::LayoutSwitch("Layout1".into()));
+ let ps = layout_settings(&app);
+ assert_eq!(ps.paper_size, "ISO_A4_(210.00_x_297.00_MM)");
+ assert_eq!((ps.paper_width, ps.paper_height), (210.0, 297.0));
+ assert_eq!(ps.rotation, PlotRotation::Degrees90);
+ assert_eq!(ps.printer_name, "none_device");
+ assert_eq!(ps.plot_type, PlotType::Layout);
+ assert_eq!(ps.scale_type, ScaledType::OneToOne);
+ assert!(ps.flags.use_standard_scale && ps.flags.print_lineweights);
+ // The sheet on screen is the landscape A4 the rotation describes.
+ let ((x0, y0), (x1, y1)) = app.tabs[app.active_tab].scene.paper_limits().unwrap();
+ assert_eq!((x1 - x0, y1 - y0), (297.0, 210.0));
+ let mut app = OpenCADStudio::new_for_test();
+ app.automation_op(r#"{"op":"new"}"#);
+ let _ = app.on_plot_dialog_open();
+ assert_eq!(app.plot_dialog.orientation, "Landscape");
+ assert!(!app.plot_dialog.upside_down);
+ }
+
+ #[test]
+ fn orientation_is_stored_as_a_rotation_of_the_medium() {
+ use acadrust::objects::PlotRotation as R;
+ use crate::ui::window::plot::PlotFlag;
+ // A newly picked sheet is the catalogue's portrait medium…
+ let portrait_medium = [
+ ("Portrait", false, R::None),
+ ("Landscape", false, R::Degrees90),
+ ("Portrait", true, R::Degrees180),
+ ("Landscape", true, R::Degrees270),
+ ];
+ // …while the drawing's own landscape `A4` medium is kept, so the
+ // same choices rotate relative to it.
+ let landscape_medium = [
+ ("Landscape", false, R::None),
+ ("Portrait", false, R::Degrees90),
+ ("Landscape", true, R::Degrees180),
+ ("Portrait", true, R::Degrees270),
+ ];
+ for (pick_a3, cases) in [(true, portrait_medium), (false, landscape_medium)] {
+ for (orientation, upside_down, rotation) in cases {
+ let mut app = app_with_printer_named_sheet();
+ let _ = app.on_plot_dialog_open();
+ if pick_a3 {
+ let a3 = "ISO_A3_(297.00_x_420.00_MM)";
+ let _ = app.on_plot_dlg(PlotDlgMsg::Paper(a3.into()));
+ }
+ let _ = app.on_plot_dlg(PlotDlgMsg::Orientation(orientation.into()));
+ if upside_down {
+ let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::UpsideDown));
+ }
+ let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
+ let ps = layout_settings(&app);
+ let medium = if pick_a3 { (297.0, 420.0) } else { (297.0, 210.0) };
+ assert_eq!((ps.paper_width, ps.paper_height), medium, "{orientation}");
+ assert_eq!(ps.rotation, rotation, "{orientation} upside-down={upside_down}");
+ // The next dialog reads the same choices back out of the rotation.
+ let _ = app.on_plot_dialog_open();
+ assert_eq!(app.plot_dialog.orientation, orientation);
+ assert_eq!(app.plot_dialog.upside_down, upside_down);
+ let sheet = super::plot_dialog_sheet_mm(&app.plot_dialog);
+ let long_side = medium.0.max(medium.1);
+ assert_eq!(sheet.0 > sheet.1, orientation == "Landscape", "{sheet:?}");
+ assert_eq!(sheet.0.max(sheet.1), long_side);
+ }
+ }
+ }
+
+ #[test]
+ fn standard_scales_are_written_by_code_and_others_as_ratios() {
+ use acadrust::objects::ScaledType;
+ let mut app = app_with_printer_named_sheet();
+ let _ = app.on_plot_dialog_open();
+ let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
+ let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper));
+ assert!(!app.plot_dialog.fit_to_paper);
+ let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into()));
+ let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
+ let ps = layout_settings(&app);
+ assert_eq!(ps.scale_type, ScaledType::OneToHundred);
+ assert!(
+ !ps.flags.use_standard_scale,
+ "the bit stays clear, as the source application writes it"
+ );
+ assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 100.0));
+ // The stored factor follows the millimetre convention: ratio × 25.4.
+ assert!((ps.standard_scale_factor - 0.254).abs() < 1e-12);
+ let _ = app.on_plot_dialog_open();
+ assert_eq!(app.plot_dialog.scale, "1:100");
+ // A ratio the format has no code for is a custom scale, spelled as the ratio.
+ let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:3".into()));
+ let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
+ let ps = layout_settings(&app);
+ assert_eq!(ps.scale_type, ScaledType::CustomScale);
+ assert!(!ps.flags.use_standard_scale);
+ assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 3.0));
+ // …and survives a reopen even though the drawing's scale list lacks it.
+ let _ = app.on_plot_dialog_open();
+ assert_eq!(app.plot_dialog.scale, "1:3");
+ assert!((super::plot_dialog_scale_factor(&app.plot_dialog) - 1.0 / 3.0).abs() < 1e-12);
+ // Fit to paper is its own scale type and carries the bit; a Layout
+ // plot goes back to the 1:1 the dialog holds it at.
+ let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper));
+ let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
+ let ps = layout_settings(&app);
+ assert_eq!(ps.scale_type, ScaledType::ScaleToFit);
+ assert!(ps.flags.use_standard_scale);
+ let _ = app.on_plot_dlg(PlotDlgMsg::Area("Layout".into()));
+ let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
+ let ps = layout_settings(&app);
+ assert_eq!(ps.scale_type, ScaledType::OneToOne);
+ assert!(!ps.flags.use_standard_scale);
+ assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 1.0));
+ assert!((ps.standard_scale_factor - 25.4).abs() < 1e-12);
+ }
+
+ #[test]
+ fn a_metre_drawing_stores_the_millimetres_it_really_plots_per_unit() {
+ use acadrust::objects::ScaledType;
+ let mut app = app_with_printer_named_sheet();
+ // Insertion units: metres. "1:100" then plots 10 mm per drawing unit.
+ app.tabs[app.active_tab].scene.document.header.insertion_units = 6;
+ let _ = app.on_plot_dialog_open();
+ let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
+ let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper));
+ let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into()));
+ assert!((super::plot_dialog_scale_factor(&app.plot_dialog) - 10.0).abs() < 1e-9);
+ let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
+ let ps = layout_settings(&app);
+ assert_eq!(ps.scale_type, ScaledType::CustomScale, "not the format's 1:100");
+ assert_eq!((ps.scale_numerator, ps.scale_denominator), (10.0, 1.0));
+ assert!((ps.standard_scale_factor - 254.0).abs() < 1e-9);
+ let _ = app.on_plot_dialog_open();
+ assert_eq!(app.plot_dialog.scale, "1:100");
+ }
+
+ #[test]
+ fn an_inch_layout_keeps_its_unit_and_converts_the_scale() {
+ use acadrust::objects::{PlotPaperUnits, ScaledType};
+ let mut app = app_with_printer_named_sheet();
+ edit_layout_settings(&mut app, |ps| ps.paper_units = PlotPaperUnits::Inches);
+ let _ = app.on_plot_dialog_open();
+ let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
+ let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper));
+ let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into()));
+ let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
+ let ps = layout_settings(&app);
+ assert_eq!(
+ ps.paper_units,
+ PlotPaperUnits::Inches,
+ "the layout's unit is not ours to change"
+ );
+ // 1 mm = 100 units is 1 inch = 2540 units on an inch page setup; the
+ // standard code still names the choice, as the source application does.
+ assert_eq!(ps.scale_type, ScaledType::OneToHundred);
+ assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 2540.0));
+ assert!((ps.standard_scale_factor - 1.0 / 2540.0).abs() < 1e-15);
+ let _ = app.on_plot_dialog_open();
+ assert_eq!(app.plot_dialog.scale, "1:100");
+ // A layout plot keeps the paper space in millimetres: "1:1" is
+ // `1 in = 25.4 units`.
+ let _ = app.on_plot_dlg(PlotDlgMsg::Area("Layout".into()));
+ let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
+ let ps = layout_settings(&app);
+ assert_eq!(ps.scale_type, ScaledType::OneToOne);
+ assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 25.4));
+ assert!((ps.standard_scale_factor - 1.0 / 25.4).abs() < 1e-15);
+ }
+
+ #[test]
+ fn a_named_setup_and_the_layout_settings_are_written_alike() {
+ let mut app = app_with_printer_named_sheet();
+ let _ = app.on_plot_dialog_open();
+ let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into()));
+ let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
+ let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into()));
+ let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper));
+ let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:50".into()));
+ let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::UpsideDown));
+ let named = app.dialog_to_plotsettings();
+ let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
+ let layout = layout_settings(&app);
+ assert_eq!(layout.paper_size, named.paper_size);
+ assert_eq!(layout.printer_name, named.printer_name);
+ assert_eq!(layout.margins, named.margins);
+ assert_eq!(
+ (layout.paper_width, layout.paper_height),
+ (named.paper_width, named.paper_height)
+ );
+ assert_eq!(layout.rotation, named.rotation);
+ assert_eq!(layout.plot_type, named.plot_type);
+ assert_eq!((layout.origin_x, layout.origin_y), (named.origin_x, named.origin_y));
+ assert_eq!(layout.scale_type, named.scale_type);
+ assert_eq!(
+ (layout.scale_numerator, layout.scale_denominator),
+ (named.scale_numerator, named.scale_denominator)
+ );
+ assert_eq!(layout.standard_scale_factor, named.standard_scale_factor);
+ assert_eq!(layout.flags, named.flags);
+ assert_eq!(layout.shade_plot_mode, named.shade_plot_mode);
+ assert_eq!(layout.shade_plot_resolution, named.shade_plot_resolution);
+ assert_eq!(named.rotation, acadrust::objects::PlotRotation::Degrees270);
+ assert_eq!(named.scale_type, acadrust::objects::ScaledType::OneToFifty);
+ }
+
+ #[test]
+ fn flags_the_dialog_does_not_edit_survive_a_round_trip() {
+ let mut app = app_with_printer_named_sheet();
+ edit_layout_settings(&mut app, |ps| {
+ ps.flags.plot_viewport_borders = true;
+ ps.flags.update_paper = true;
+ ps.flags.zoom_to_paper_on_update = true;
+ ps.flags.unknown_bits = 0x8000;
+ ps.paper_image_origin_x = 3.5;
+ });
+ let _ = app.on_plot_dialog_open();
+ let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
+ let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
+ let ps = layout_settings(&app);
+ assert!(ps.flags.plot_viewport_borders);
+ assert!(ps.flags.update_paper && ps.flags.zoom_to_paper_on_update);
+ assert_eq!(ps.flags.unknown_bits, 0x8000);
+ assert_eq!(ps.paper_image_origin_x, 3.5);
+ }
+
+ /// Open the metric page-setup fixture written by the commercial
+ /// application (see `tests/fixtures/plot/README.md`).
+ #[cfg(not(target_arch = "wasm32"))]
+ fn app_with_fixture() -> OpenCADStudio {
+ let mut app = OpenCADStudio::new_for_test();
+ let path = concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/tests/fixtures/plot/page-setups-metric.dxf"
+ );
+ let reply = app.automation_op(&format!(r#"{{"op":"open","path":"{path}"}}"#));
+ assert_eq!(reply["ok"], true, "{reply}");
+ app
+ }
+
+ /// The page-setup fields the file format stores, minus handles.
+ fn stored_fields(ps: &acadrust::objects::PlotSettings) -> String {
+ format!(
+ "{} | {} | {:?} | {:.6} {:.6} | {:.6} {:.6} {:.6} {:.6} | {:.6} {:.6} | {} {} \
+ | {:.6} / {:.6} | {:.9} | {:?} | {:?} | {:?} {:?} {}",
+ ps.printer_name,
+ ps.paper_size,
+ ps.paper_units,
+ ps.paper_width,
+ ps.paper_height,
+ ps.margins.left,
+ ps.margins.bottom,
+ ps.margins.right,
+ ps.margins.top,
+ ps.origin_x,
+ ps.origin_y,
+ ps.rotation.to_code(),
+ ps.plot_type.to_code(),
+ ps.scale_numerator,
+ ps.scale_denominator,
+ ps.standard_scale_factor,
+ ps.scale_type,
+ ps.flags,
+ ps.shade_plot_mode,
+ ps.shade_plot_resolution,
+ ps.shade_plot_dpi
+ )
+ }
+
+ /// Every layout of the fixture survives "open the dialog, apply" without
+ /// a single stored field changing — the dialog reads the source
+ /// application's conventions and writes them back the same way.
+ #[cfg(not(target_arch = "wasm32"))]
+ #[test]
+ fn fixture_page_setups_round_trip_through_the_dialog_unchanged() {
+ use acadrust::objects::{PlotRotation, ScaledType};
+ let mut app = app_with_fixture();
+ let a3_medium = (297.0106506347656, 419.9889831542968);
+ let arch_d_medium = (914.4000244140626, 609.5999755859375);
+ let expectations = [
+ ("A4 1-100", PlotRotation::None, (297.0, 210.0), true),
+ ("A3 - plotter", PlotRotation::Degrees90, a3_medium, false),
+ ("ARCH D - PLOTTER", PlotRotation::None, arch_d_medium, true),
+ ];
+ for (layout, rotation, medium, pdf) in expectations {
+ let orientation = "Landscape";
+ let _ = app.update(Message::LayoutSwitch(layout.into()));
+ let i = app.active_tab;
+ let before = app.tabs[i].scene.plot_settings_for(layout).unwrap();
+ assert_eq!(before.rotation, rotation, "{layout}");
+ assert_eq!((before.paper_width, before.paper_height), medium, "{layout}");
+ assert_eq!(before.scale_type, ScaledType::OneToOne, "{layout}");
+ let _ = app.on_plot_dialog_open();
+ let d = &app.plot_dialog;
+ assert_eq!(d.orientation, orientation, "{layout}");
+ assert!(!d.upside_down);
+ assert_eq!(d.area, "Layout");
+ assert_eq!(d.scale, "1:1", "{layout}: a 1:1 plot in either paper unit");
+ assert_eq!(d.to_file, pdf, "{layout}");
+ assert_eq!(super::plot_dialog_sheet_mm(d).0 > super::plot_dialog_sheet_mm(d).1, true);
+ assert!(d.lineweights && d.apply_plot_styles && d.paperspace_last, "{layout}");
+ let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
+ let after = app.tabs[i].scene.plot_settings_for(layout).unwrap();
+ assert_eq!(stored_fields(&after), stored_fields(&before), "{layout}");
+ }
+ // The system printer's sheet keeps its driver's margins and lands on
+ // the layout the way the source application places it: the medium's
+ // top margin on the left, its left margin at the bottom.
+ let _ = app.update(Message::LayoutSwitch("A3 - plotter".into()));
+ let ((x0, y0), (x1, y1)) = app.tabs[app.active_tab].scene.paper_limits().unwrap();
+ assert!((x0 + 20.02365112304687).abs() < 1e-9 && (y0 + 4.995333194732666).abs() < 1e-9);
+ assert!((x1 - 399.96533203125).abs() < 1e-6 && (y1 - 292.015317440033).abs() < 1e-6);
+ }
+
+ /// Picking a new sheet on the fixture's PDF layout writes our canonical
+ /// portrait medium with the orientation in the rotation and the PDF
+ /// driver's margins for that medium.
+ #[cfg(not(target_arch = "wasm32"))]
+ #[test]
+ fn fixture_pdf_layout_takes_a_new_sheet_in_canonical_form() {
+ use acadrust::objects::{PaperMargin, PlotRotation};
+ let mut app = app_with_fixture();
+ let _ = app.update(Message::LayoutSwitch("A4 1-100".into()));
+ let _ = app.on_plot_dialog_open();
+ let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into()));
+ let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
+ let ps = app.tabs[app.active_tab].scene.plot_settings_for("A4 1-100").unwrap();
+ assert_eq!(ps.printer_name, "DWG To PDF.pc3");
+ assert_eq!(ps.paper_size, "ISO_A3_(297.00_x_420.00_MM)");
+ assert_eq!((ps.paper_width, ps.paper_height), (297.0, 420.0));
+ assert_eq!(ps.rotation, PlotRotation::Degrees90);
+ assert_eq!(ps.margins, PaperMargin::new(5.0, 17.0, 6.0, 18.0));
+ // The sheet sits at minus its displayed margins: top on the left,
+ // left at the bottom.
+ let ((x0, y0), _) = app.tabs[app.active_tab].scene.paper_limits().unwrap();
+ assert!((x0 + 18.0).abs() < 1e-9 && (y0 + 5.0).abs() < 1e-9, "{x0} {y0}");
+ }
+
+ #[test]
+ fn scale_names_parse_to_their_ratios() {
+ use crate::scene::Scene;
+ assert_eq!(Scene::parse_scale_name_ratio("1:250"), Some((1.0, 250.0)));
+ assert_eq!(Scene::parse_scale_name_ratio("2:1"), Some((2.0, 1.0)));
+ assert_eq!(Scene::parse_scale_name_ratio("1/8\" = 1'-0\""), Some((0.125, 12.0)));
+ assert_eq!(Scene::parse_scale_name_ratio("3/32\" = 1'-0\""), Some((0.09375, 12.0)));
+ assert_eq!(Scene::parse_scale_name_ratio("1'-0\" = 1'-0\""), Some((12.0, 12.0)));
+ assert_eq!(Scene::parse_scale_name_ratio("6\" = 1'-0\""), Some((6.0, 12.0)));
+ assert_eq!(Scene::parse_scale_name_ratio("Fit"), None);
+ assert_eq!(Scene::parse_scale_name_ratio("1:0"), None);
+ assert_eq!(super::ratio_scale_name(0.004), "1:250");
+ assert_eq!(super::ratio_scale_name(25.4), "25.4:1");
+ assert_eq!(super::ratio_scale_name(1.0 / 3.0), "1:3");
}
#[test]
@@ -5267,7 +5747,9 @@ mod plot_paper_tests {
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let (name, w, h) = layout_paper(&app);
assert_eq!(name, "A4", "the driver's own spelling must survive the dialog");
+ // …and so must the driver's landscape medium: unrotated, as stored.
assert_eq!((w, h), (297.0, 210.0));
+ assert_eq!(layout_rotation(&app), acadrust::objects::PlotRotation::None);
}
#[test]
@@ -5279,7 +5761,8 @@ mod plot_paper_tests {
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let (name, w, h) = layout_paper(&app);
assert_eq!(name, "ISO_A3_(297.00_x_420.00_MM)");
- assert_eq!((w, h), (420.0, 297.0));
+ assert_eq!((w, h), (297.0, 420.0));
+ assert_eq!(layout_rotation(&app), acadrust::objects::PlotRotation::Degrees90);
// The next dialog remembers the sheet the user chose.
assert_eq!(app.plot_paper.canonical, "ISO_A3_(297.00_x_420.00_MM)");
}
@@ -5346,7 +5829,7 @@ mod plot_paper_tests {
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0));
- assert_eq!((ps.paper_width, ps.paper_height), (420.0, 297.0));
+ assert_eq!((ps.paper_width, ps.paper_height), (297.0, 420.0));
}
#[test]
@@ -5394,7 +5877,8 @@ mod plot_paper_tests {
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap();
assert_eq!(ps.paper_size, "Roll_24_(609.60_x_1500.00_MM)");
- assert_eq!((ps.paper_width, ps.paper_height), (1500.0, 609.6));
+ assert_eq!((ps.paper_width, ps.paper_height), (609.6, 1500.0));
+ assert_eq!(ps.rotation, acadrust::objects::PlotRotation::Degrees90);
assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(3.0, 17.0, 6.0, 4.0));
// Re-adding the same size replaces the definition instead of duplicating it.
let _ = app.on_plot_dlg(custom(C::Open));
@@ -5507,7 +5991,11 @@ cupsPrintQuality/Print Quality: *Normal High\n";
assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)");
assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (1500.0, 609.6));
let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent);
- assert_eq!(layout_paper(&app).0, "Roll_24_(609.60_x_1500.00_MM)");
+ let (name, w, h) = layout_paper(&app);
+ assert_eq!(name, "Roll_24_(609.60_x_1500.00_MM)");
+ // The drawing's own landscape medium stays as it was stored.
+ assert_eq!((w, h), (1500.0, 609.6));
+ assert_eq!(layout_rotation(&app), acadrust::objects::PlotRotation::None);
}
}
diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs
index 3573234d5..480608dcd 100644
--- a/src/app/update/mod.rs
+++ b/src/app/update/mod.rs
@@ -1508,8 +1508,7 @@ impl OpenCADStudio {
Message::ClearScene => {
let i = self.active_tab;
self.push_undo_snapshot(i, "CLEAR");
- self.tabs[i].scene.clear();
- crate::io::linetypes::populate_document(&mut self.tabs[i].scene.document);
+ self.tabs[i].scene.reset_to_new_drawing();
self.tabs[i].properties = PropertiesPanel::empty();
let doc_layers = self.tabs[i].scene.document.layers.clone();
let vp_info = self.tabs[i].scene.viewport_list();
diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs
index d508a0537..6241fde5b 100644
--- a/src/app/update/viewport.rs
+++ b/src/app/update/viewport.rs
@@ -3051,6 +3051,7 @@ impl OpenCADStudio {
vp_size,
self.show_constraint_values,
self.constraint_bar_display,
+ self.constraint_bar_mode,
cursor,
)?;
let set = self.tabs[i].scene.parametric_constraint_set(scope)?;
@@ -4577,6 +4578,7 @@ properties={:.1}ms picked={}",
canvas_sz,
self.show_constraint_values,
self.constraint_bar_display,
+ self.constraint_bar_mode,
p_full,
)
})
@@ -6031,24 +6033,7 @@ properties={:.1}ms picked={}",
if let acadrust::objects::ObjectType::Layout(l) = obj {
if l.name == new_name {
l.flags = layout_flags;
- l.min_limits = (0.0, 0.0);
- l.max_limits = (297.0, 210.0);
- l.min_extents = (0.0, 0.0, 0.0);
- l.max_extents = (297.0, 210.0, 0.0);
- l.paper_width = 297.0;
- l.paper_height = 210.0;
- l.plot_paper_units = 1;
- l.plot_scale_numerator = 1.0;
- l.plot_scale_denominator = 1.0;
- l.plot_scale_type = 16;
- l.plot_scale_factor = 1.0;
- l.plot_type = 5;
- l.plot_flags.use_standard_scale = true;
- l.plot_flags.print_lineweights = true;
- l.plot_flags.plot_plot_styles = !plot_style.is_empty();
- l.plot_flags.show_plot_styles = !plot_style.is_empty();
- l.plot_style_sheet = plot_style;
- l.paper_size = "ISO_A4_(297.00_x_210.00_MM)".into();
+ crate::scene::apply_default_page_setup(l, &plot_style);
break;
}
}
@@ -6683,6 +6668,7 @@ mod selection_preview_tests {
(800.0, 600.0),
true,
3,
+ 4095,
)[0].1;
let point = (-30..=30).find_map(|y| {
(-30..=30).find_map(|x| {
diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs
index 7fefbba3c..52d4e18cb 100644
--- a/src/app/view/mod.rs
+++ b/src/app/view/mod.rs
@@ -611,7 +611,8 @@ bg={bg_ms:.1}ms n={view_count}"
};
let control_polygon = tab.selected_handle.and_then(|handle| {
let spline = match tab.scene.document.get_entity(handle) {
- Some(acadrust::EntityType::Spline(spline)) if spline.cv_frame_visible => spline,
+ Some(acadrust::EntityType::Spline(spline))
+ if crate::entities::spline::shows_control_vertices(spline) => spline,
_ => return None,
};
if tab
@@ -833,6 +834,23 @@ bg={bg_ms:.1}ms n={view_count}"
.active_cmd
.as_ref()
.is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering());
+ let constraint_cursor_badge = if is_paper {
+ None
+ } else {
+ tab.scene.hover_highlight.and_then(|handle| {
+ tab.scene
+ .parametric_constraint_set(tab.current_parametric_scope())
+ .and_then(|set| {
+ set.constraints_touching(handle)
+ .find(|constraint| {
+ constraint.kind
+ == crate::scene::parametric_constraints::ConstraintKind::Concentric
+ })
+ .or_else(|| set.constraints_touching(handle).next())
+ })
+ .map(|constraint| constraint.kind.glyph_symbol().to_string())
+ })
+ };
let constraint_glyphs: Vec<(
iced::Point,
[f32; 2],
@@ -850,6 +868,7 @@ bg={bg_ms:.1}ms n={view_count}"
sel_ref.vp_size,
self.show_constraint_values,
self.constraint_bar_display,
+ self.constraint_bar_mode,
)
.into_iter()
.map(|(id, point, direction, label, is_conflicting, hover_points)| {
@@ -910,6 +929,7 @@ bg={bg_ms:.1}ms n={view_count}"
constraint_glyphs,
self.constraint_glyph_tooltip
.map(|kind| crate::t!(kind.label()).into_owned()),
+ constraint_cursor_badge,
)
};
diff --git a/src/command.rs b/src/command.rs
index fb7138b02..44852bd88 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -1304,6 +1304,16 @@ pub struct CoincidentPick {
pub whole_curve: bool,
}
+/// The two input forms accepted by the Horizontal geometric constraint.
+/// Object picks are resolved while the entity snapshot is available; point
+/// picks are resolved by the host against the live document so two points on
+/// the same entity remain distinguishable.
+#[derive(Clone, Copy, Debug)]
+pub enum HorizontalConstraintSelection {
+ Reference(crate::scene::parametric_constraints::ParametricRef),
+ Points(CoincidentPick, CoincidentPick),
+}
+
/// Construction options shared by SWEEP creation and its live preview.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SweepOptions {
@@ -1550,6 +1560,14 @@ pub enum CmdResult {
/// Undo-history label, e.g. `"Horizontal constraint"`.
label: &'static str,
},
+ /// Adds a Horizontal relation against the UCS X direction captured when
+ /// the command starts. The direction is persisted with the constraint so
+ /// later edits and save/reopen do not silently fall back to world X.
+ AddHorizontalConstraint {
+ selection: HorizontalConstraintSelection,
+ direction: acadrust::types::Vector3,
+ label: &'static str,
+ },
/// Adds an ordered perpendicular relation. The first picked direction and
/// the second direction's start point stay fixed during the initial solve;
/// those temporary anchors are not persisted as geometric constraints.
@@ -1568,6 +1586,13 @@ pub enum CmdResult {
second: crate::scene::parametric_constraints::ParametricRef,
label: &'static str,
},
+ /// Adds an ordered concentric relation. The first picked center remains
+ /// fixed during the initial solve while the second curve moves rigidly.
+ AddConcentricConstraint {
+ first: crate::scene::parametric_constraints::ParametricRef,
+ second: crate::scene::parametric_constraints::ParametricRef,
+ label: &'static str,
+ },
/// Opens the Auto Constrain settings dialog from the selection prompt.
OpenAutoConstrainSettings,
/// Adds an ordered Coincident relation. Point/point selections create a
@@ -2826,14 +2851,14 @@ mod constraint_registry_tests {
"CPCONSTRAINT",
"MPCONSTRAINT",
"OCCONSTRAINT",
- "HCONSTRAINT",
+ "GCHORIZONTAL",
"VCONSTRAINT",
"PCONSTRAINT",
"QCONSTRAINT",
"GCPERPENDICULAR",
"ECONSTRAINT",
"TCONSTRAINT",
- "NCONSTRAINT",
+ "GCCONCENTRIC",
"NRCONSTRAINT",
"LCONSTRAINT",
"FXCONSTRAINT",
diff --git a/src/entities/arc.rs b/src/entities/arc.rs
index 3c354c20c..c8315524a 100644
--- a/src/entities/arc.rs
+++ b/src/entities/arc.rs
@@ -212,9 +212,9 @@ fn properties(arc: &Arc) -> Vec {
ro(t!("Total angle").as_ref(), "total_angle", format_angle(total_angle.to_radians())),
ro(t!("Arc length").as_ref(), "arc_length", format_length(arc_length)),
ro(t!("Area").as_ref(), "area", format_area(area)),
- ro(t!("Normal X").as_ref(), "normal_x", format!("{:.4}", arc.normal.x)),
- ro(t!("Normal Y").as_ref(), "normal_y", format!("{:.4}", arc.normal.y)),
- ro(t!("Normal Z").as_ref(), "normal_z", format!("{:.4}", arc.normal.z)),
+ edit(t!("Normal X").as_ref(), "normal_x", arc.normal.x),
+ edit(t!("Normal Y").as_ref(), "normal_y", arc.normal.y),
+ edit(t!("Normal Z").as_ref(), "normal_z", arc.normal.z),
],
}]
}
@@ -244,6 +244,24 @@ fn apply_geom_prop(arc: &mut Arc, field: &str, value: &str) {
"radius" if v > 0.0 => arc.radius = v,
"start_angle" => arc.start_angle = v.to_radians(),
"end_angle" => arc.end_angle = v.to_radians(),
+ "normal_x" | "normal_y" | "normal_z" => {
+ let center = arc.center_wcs();
+ let mut normal = cadkernel::space::Vec3::new(arc.normal.x, arc.normal.y, arc.normal.z);
+ match field {
+ "normal_x" => normal.x = v,
+ "normal_y" => normal.y = v,
+ "normal_z" => normal.z = v,
+ _ => {}
+ }
+ if let Some(normal) = normal.normalize() {
+ arc.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z);
+ let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs(
+ (center.x, center.y, center.z),
+ (normal.x, normal.y, normal.z),
+ );
+ arc.center = acadrust::types::Vector3::new(x, y, z);
+ }
+ }
_ => {}
}
}
diff --git a/src/entities/circle.rs b/src/entities/circle.rs
index 7d596506e..5cef35386 100644
--- a/src/entities/circle.rs
+++ b/src/entities/circle.rs
@@ -3,7 +3,7 @@ use crate::t;
use crate::command::EntityTransform;
use crate::entities::common::{
- center_grip, edit_prop as edit, parse_f64, ro_prop as ro, square_grip,
+ center_grip, edit_prop as edit, parse_f64, square_grip,
};
use crate::entities::traits::RenderConvertible;
use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject};
@@ -169,9 +169,9 @@ fn properties(circle: &Circle) -> Vec {
edit(t!("Diameter").as_ref(), "diameter", r * 2.0),
edit(t!("Circumference").as_ref(), "circumference", 2.0 * PI * r),
edit(t!("Area").as_ref(), "area", PI * r * r),
- ro(t!("Normal X").as_ref(), "normal_x", format!("{:.4}", circle.normal.x)),
- ro(t!("Normal Y").as_ref(), "normal_y", format!("{:.4}", circle.normal.y)),
- ro(t!("Normal Z").as_ref(), "normal_z", format!("{:.4}", circle.normal.z)),
+ edit(t!("Normal X").as_ref(), "normal_x", circle.normal.x),
+ edit(t!("Normal Y").as_ref(), "normal_y", circle.normal.y),
+ edit(t!("Normal Z").as_ref(), "normal_z", circle.normal.z),
],
}]
}
@@ -202,6 +202,28 @@ fn apply_geom_prop(circle: &mut Circle, field: &str, value: &str) {
"diameter" if v > 0.0 => circle.radius = v / 2.0,
"circumference" if v > 0.0 => circle.radius = v / (2.0 * PI),
"area" if v > 0.0 => circle.radius = (v / PI).sqrt(),
+ "normal_x" | "normal_y" | "normal_z" => {
+ let center = circle.center_wcs();
+ let mut normal = cadkernel::space::Vec3::new(
+ circle.normal.x,
+ circle.normal.y,
+ circle.normal.z,
+ );
+ match field {
+ "normal_x" => normal.x = v,
+ "normal_y" => normal.y = v,
+ "normal_z" => normal.z = v,
+ _ => {}
+ }
+ if let Some(normal) = normal.normalize() {
+ circle.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z);
+ let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs(
+ (center.x, center.y, center.z),
+ (normal.x, normal.y, normal.z),
+ );
+ circle.center = acadrust::types::Vector3::new(x, y, z);
+ }
+ }
_ => {}
}
}
@@ -358,6 +380,19 @@ impl crate::entities::traits::MassPropsCalc for Circle {
mod tests {
use super::*;
+ #[test]
+ fn editing_normal_preserves_world_center() {
+ let mut circle = Circle::from_coords(2.0, 3.0, 4.0, 5.0);
+ let center = circle.center_wcs();
+
+ apply_geom_prop(&mut circle, "normal_x", "1");
+
+ assert!((circle.center_wcs() - center).length() < 1.0e-9);
+ let normal_length =
+ (circle.normal.x.powi(2) + circle.normal.y.powi(2) + circle.normal.z.powi(2)).sqrt();
+ assert!((normal_length - 1.0).abs() < 1.0e-12);
+ }
+
#[test]
fn quadrant_grips_offer_interactive_radius() {
use crate::entities::traits::Grippable;
diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs
index 3f18b4bbb..6cf37e0a5 100644
--- a/src/entities/dimension.rs
+++ b/src/entities/dimension.rs
@@ -1668,16 +1668,38 @@ pub(crate) fn resolved_dimension_style(
}
}
- // Negative DIMLFAC applies only in paper space; preserve its sign on export.
+ // Negative DIMLFAC applies only in paper space, and there only to a
+ // dimension that measures model space through a viewport. The associative
+ // record stores the applied factor; zero marks paper-space geometry whose
+ // dimension reads its sheet distance.
if style.dimlfac < 0.0 {
- style.dimlfac = crate::scene::viewport_ref::MeasurementScale::user_lfac_for_space(
- style.dimlfac,
- dimension_in_paper_space(dimension, document),
- );
+ style.dimlfac = match calculated_dimlfac(dimension) {
+ Some(calculated) if calculated.abs() < 1e-12 => 1.0,
+ Some(calculated) => calculated.abs(),
+ None => crate::scene::viewport_ref::MeasurementScale::user_lfac_for_space(
+ style.dimlfac,
+ dimension_in_paper_space(dimension, document),
+ ),
+ };
}
style
}
+/// The linear factor recorded when the dimension was last regenerated.
+fn calculated_dimlfac(dimension: &Dimension) -> Option {
+ dimension
+ .base()
+ .common
+ .extended_data
+ .get_record("ACAD_DIMASSOC_CALC_DIMLFAC")
+ .and_then(|record| {
+ record.values.iter().find_map(|value| match value {
+ acadrust::xdata::XDataValue::Real(factor) if factor.is_finite() => Some(*factor),
+ _ => None,
+ })
+ })
+}
+
/// Whether the dimension's owner is paper space, even without a Layout object.
pub(crate) fn dimension_in_paper_space(dimension: &Dimension, document: &CadDocument) -> bool {
let owner = dimension.base().common.owner_handle;
@@ -3747,6 +3769,10 @@ fn tessellate_dimension_inner(
horizontal_text: text_layout.horizontal,
text_movement: style.map(|s| s.dimtmove).unwrap_or(0),
text_break: text_layout.break_box,
+ leader_anchor: style
+ .filter(|s| s.dimtmove == 1)
+ .and_then(|_| stored_text_point(dim))
+ .map(vec3_local),
},
SuppressFlags {
ext1: dimse1,
@@ -3799,7 +3825,7 @@ fn tessellate_dimension_inner(
&mut geom.dim_lines,
point,
vec3_local(dim.base().normal),
- dim_txt as f32 * 0.6,
+ dim_txt as f32 * CELL_WIDTH as f32,
jog_angle,
);
}
@@ -4411,11 +4437,11 @@ fn text_fill_rect(
}
let pos = dimension_text_pos_f64(dim, style, text_height, dim_scale);
let dimgap = style.map(|s| s.dimgap.abs()).unwrap_or(0.0) * dim_scale;
- // ~0.6 × text_height per character; matches average glyph aspect for
- // the bundled stick fonts. Inflate by 1 DIMGAP on each side.
+ // CELL_WIDTH × text_height per character approximates the average glyph
+ // aspect of the bundled stick fonts. Inflate by 1 DIMGAP on each side.
let stack_scale = style.map(dimtfac_or_one).unwrap_or(1.0);
- let approx_w = text_cells(&value, stack_scale) * text_height * 0.6 + dimgap * 2.0;
- let approx_h = dimension_text_block_height(style, text_height) + dimgap * 2.0;
+ let approx_w = text_cells(&value, stack_scale) * text_height * CELL_WIDTH + dimgap * 2.0;
+ let approx_h = dimension_text_extent_height(dim, style, text_height) + dimgap * 2.0;
let rot = if dim.base().text_rotation.abs() > 1e-9 {
dim.base().text_rotation
} else {
@@ -4458,7 +4484,7 @@ fn arc_length_symbol_points(
let rotation = dimension_text_rotation(dim, style);
let (sin_rotation, cos_rotation) = rotation.sin_cos();
let stack_scale = style.map(dimtfac_or_one).unwrap_or(1.0);
- let text_width = text_cells(&value, stack_scale) * text_height * 0.6;
+ let text_width = text_cells(&value, stack_scale) * text_height * CELL_WIDTH;
let symbol_width = text_height * 0.62;
let symbol_height = text_height * 0.20;
let (center_x, center_y) = if symbol_position == 1 {
@@ -4524,7 +4550,7 @@ fn dimension_text_layout(
.map(|style| (style.dimgap.abs() * dim_scale) as f32)
.unwrap_or(0.09);
let width = dimension_text_cells(dimension, style)
- .map(|cells| cells as f32 * text_height as f32 * 0.6 + gap * 2.0)
+ .map(|cells| cells as f32 * text_height as f32 * CELL_WIDTH as f32 + gap * 2.0)
.unwrap_or(0.0);
let position = vec3_local(dimension_text_pos_f64(
dimension,
@@ -4536,7 +4562,9 @@ fn dimension_text_layout(
let break_box = (bare_width > 0.0).then_some(TextBreak {
center: position,
half_width: bare_width * 0.5,
- half_height: text_height as f32 * 0.5,
+ // A limits stack is two lines tall; the break and the arrow fit both
+ // have to clear the whole block.
+ half_height: dimension_text_extent_height(dimension, style, text_height) as f32 * 0.5,
padding: gap,
rotation: dimension_text_rotation(dimension, style),
});
@@ -4572,6 +4600,9 @@ struct DimLineParams {
horizontal_text: bool,
text_movement: i16,
text_break: Option,
+ /// DIMTMOVE 1: the point the file stores is where the leader's hook starts,
+ /// so the leader is drawn to it exactly and only the text is estimated.
+ leader_anchor: Option,
}
fn dimension_geometry(
dim: &Dimension,
@@ -4644,43 +4675,22 @@ fn dimension_geometry(
add_segment(&mut g.dim_lines, a, b);
add_segment(&mut g.dim_lines, b, point);
}
- } else if !suppress.dim2 {
- add_segment(&mut g.dim_lines, center, point);
}
let radius = (point - center).length();
let text_is_outside = text.distance(center) > radius + 1e-5;
+ // Text inside the arc: the dimension line runs from the arc point
+ // to the centre. Text outside: only a leader from the arc point to
+ // the text, unless DIMTOFL asks for the inside line as well.
+ if !jogged && !suppress.dim2 && (!text_is_outside || params.dimtofl) {
+ add_segment(&mut g.dim_lines, center, point);
+ }
if text_is_outside && !suppress.dim2 {
- let radial = normalized_or(point - center, Vec3::X);
- let angle_from_horizontal = radial.y.abs().atan2(radial.x.abs());
- if params.horizontal_text
- && angle_from_horizontal > 15.0_f32.to_radians()
- && radial.y.abs() > 1e-6
- {
- let travel = (text.y - point.y) / radial.y;
- if travel > 0.0 {
- let elbow = point + radial * travel;
- let landing_gap = params.text_width * 0.5 + params.arrow_len;
- let landing = Vec3::new(
- text.x - radial.x.signum() * landing_gap,
- text.y,
- text.z,
- );
- add_segment(&mut g.dim_lines, point, elbow);
- add_segment(&mut g.dim_lines, elbow, landing);
- } else {
- add_segment(&mut g.dim_lines, point, text);
- }
- } else {
- add_segment(&mut g.dim_lines, point, text);
- }
+ append_radial_leader(&mut g, point, text, ¶ms);
}
if !suppress.dim2 {
- append_arrow(
- &mut g,
- point,
- normalized_or(center - point, Vec3::X),
- arrow1,
- );
+ // The arrowhead sits on the arc with its body toward the text.
+ let body = if text_is_outside { point - center } else { center - point };
+ append_arrow(&mut g, point, normalized_or(body, Vec3::X), arrow1);
}
if text_is_outside {
append_center_mark(&mut g, center, params.dimcen, radius);
@@ -5026,17 +5036,42 @@ fn append_linear_dimension(
let d2_out = d2 + dir_d1_to_d2 * dle;
// When text plus arrows do not fit, DIMATFIT decides which component moves
- // first: 0=both, 1=arrows, 2=text, 3=best fit.
+ // first: 0=both, 1=arrows, 2=text, 3=best fit. The text is measured along
+ // the dimension line where it is drawn: horizontal text on a vertical
+ // dimension takes up its height, and text dragged toward one end runs into
+ // that arrow long before the sum of widths says the gap is full.
let gap = (d2 - d1).length();
+ let text_span = params.text_break.map(|text_break| {
+ let axis_angle = dir_d1_to_d2.y.atan2(dir_d1_to_d2.x) as f64;
+ let (sin, cos) = (text_break.rotation - axis_angle).sin_cos();
+ let half = (text_break.half_width as f64 * cos).abs()
+ + (text_break.half_height as f64 * sin).abs()
+ + text_break.padding as f64;
+ let along = (text_break.center - d1).dot(dir_d1_to_d2) as f64;
+ (along - half, along + half)
+ });
+ let text_extent = text_span
+ .map(|(lo, hi)| (hi - lo) as f32)
+ .unwrap_or(params.text_width);
+ // Horizontal text beside a non-horizontal dimension is reached by a leg
+ // and a hook instead of the dimension line itself.
+ let hooks_out_horizontally =
+ params.horizontal_text && !params.ticks && dir_d1_to_d2.y.abs() > 1e-3;
+ let text_hits_an_arrow = text_span.is_some_and(|(lo, hi)| {
+ let center = (lo + hi) * 0.5;
+ center > 0.0
+ && center < gap as f64
+ && (lo < params.arrow_len as f64 || hi > (gap - params.arrow_len) as f64)
+ });
let arrows_outside = if params.ticks || params.arrow_len <= 1e-6 {
false
- } else if gap < 2.0 * params.arrow_len {
+ } else if gap < 2.0 * params.arrow_len || text_hits_an_arrow {
true
- } else if gap < params.text_width + 2.0 * params.arrow_len {
+ } else if gap < text_extent + 2.0 * params.arrow_len {
match params.dimatfit {
0 | 1 => true,
2 => false,
- _ => params.text_width <= gap,
+ _ => text_extent <= gap,
}
} else {
false
@@ -5057,13 +5092,22 @@ fn append_linear_dimension(
if draw_inside_line && !suppress.dim2 && line_len - split > 1e-6 {
add_segment_with_text_break(&mut g.dim_lines, split_point, d2_out, params.text_break);
}
- if arrows_outside && !params.dimsoxd {
- let stub = params.arrow_len * 2.0;
- if !suppress.dim1 {
- add_segment(&mut g.dim_lines, d1 - dir_d1_to_d2 * stub, d1);
+ // Outside the extension lines the dimension line runs two arrow lengths,
+ // or on to the text when the text sits further out along the line.
+ let reach = |default: f32, toward_text: Option| toward_text.map_or(default, |t| t.max(default));
+ let stub = params.arrow_len * 2.0;
+ let text_beyond_d1 = text_span
+ .filter(|(_, hi)| *hi < 0.0 && !hooks_out_horizontally)
+ .map(|(_, hi)| -hi as f32);
+ let text_beyond_d2 = text_span
+ .filter(|(lo, _)| *lo > gap as f64 && !hooks_out_horizontally)
+ .map(|(lo, _)| (lo - gap as f64) as f32);
+ if (arrows_outside && !params.dimsoxd) || text_beyond_d1.is_some() || text_beyond_d2.is_some() {
+ if !suppress.dim1 && (arrows_outside || text_beyond_d1.is_some()) {
+ add_segment(&mut g.dim_lines, d1 - dir_d1_to_d2 * reach(stub, text_beyond_d1), d1);
}
- if !suppress.dim2 {
- add_segment(&mut g.dim_lines, d2, d2 + dir_d1_to_d2 * stub);
+ if !suppress.dim2 && (arrows_outside || text_beyond_d2.is_some()) {
+ add_segment(&mut g.dim_lines, d2, d2 + dir_d1_to_d2 * reach(stub, text_beyond_d2));
}
}
@@ -5075,6 +5119,61 @@ fn append_linear_dimension(
append_arrow(g, d1, normalized_or(d2 - d1, axis), arrow1);
append_arrow(g, d2, normalized_or(d1 - d2, -axis), arrow2);
}
+
+ // Horizontal text outside the extension lines of a dimension that is not
+ // horizontal itself: the dimension line runs on to the text's height and
+ // turns a hook one arrow long toward the text, which sits against it.
+ if params.horizontal_text && !params.ticks && dir_d1_to_d2.y.abs() > 1e-3 {
+ if let Some(text_break) = params.text_break {
+ let along = (text_break.center - d1).dot(dir_d1_to_d2);
+ let outside = if along < 0.0 {
+ Some((d1, -dir_d1_to_d2, suppress.dim1))
+ } else if along > gap {
+ Some((d2, dir_d1_to_d2, suppress.dim2))
+ } else {
+ None
+ };
+ if let Some((end, outward, suppressed)) = outside {
+ let travel = (text_break.center.y - end.y) / outward.y;
+ if travel > 0.0 && !suppressed {
+ let elbow = end + outward * travel;
+ let side = if text_break.center.x >= elbow.x { 1.0 } else { -1.0 };
+ let hook_end = Vec3::new(elbow.x + side * params.arrow_len, elbow.y, elbow.z);
+ add_segment(&mut g.dim_lines, end, elbow);
+ add_segment(&mut g.dim_lines, elbow, hook_end);
+ }
+ }
+ }
+ }
+}
+
+/// Leader from a point on the circle to text outside it. Horizontal text gets a
+/// hook one arrow long that the text sits against; aligned text is reached by a
+/// straight leader that stops a gap short of it. `text_width` already carries
+/// a gap on each side.
+fn append_radial_leader(g: &mut DimGeom, tip: Vec3, text: Vec3, params: &DimLineParams) {
+ if params.horizontal_text {
+ let side = if text.x >= tip.x { 1.0 } else { -1.0 };
+ let (hook_start, text_edge) = match params.leader_anchor {
+ Some(anchor) => (anchor, anchor + Vec3::X * (side * params.arrow_len)),
+ None => {
+ let edge = Vec3::new(text.x - side * params.text_width * 0.5, text.y, text.z);
+ (edge - Vec3::X * (side * params.arrow_len), edge)
+ }
+ };
+ let slope = (hook_start - tip).y.abs().atan2((hook_start - tip).x.abs());
+ // A leader already within 15° of horizontal runs straight to the text.
+ if slope > 15.0_f32.to_radians() {
+ add_segment(&mut g.dim_lines, tip, hook_start);
+ add_segment(&mut g.dim_lines, hook_start, text_edge);
+ } else {
+ add_segment(&mut g.dim_lines, tip, text_edge);
+ }
+ } else {
+ let toward = normalized_or(text - tip, Vec3::X);
+ let reach = ((text - tip).length() - params.text_width * 0.5).max(0.0);
+ add_segment(&mut g.dim_lines, tip, tip + toward * reach);
+ }
}
fn append_diameter_dimension(
@@ -5092,6 +5191,28 @@ fn append_diameter_dimension(
if diameter <= 1e-6 {
return;
}
+ let center = (chord + far_chord) * 0.5;
+ let text_is_outside = params.text_position.distance(center) > diameter * 0.5 + 1e-5;
+ if text_is_outside && !params.dimtofl {
+ // Outside text without DIMTOFL: no line across the circle, just a
+ // leader from the near side with the arrowhead on the circle and its
+ // body toward the text. DIMTMOVE 2 keeps the arrowhead and drops the
+ // leader.
+ let (tip, suppressed) = if params.text_position.distance_squared(chord)
+ <= params.text_position.distance_squared(far_chord)
+ {
+ (chord, suppress.dim1)
+ } else {
+ (far_chord, suppress.dim2)
+ };
+ if !suppressed {
+ if params.text_movement != 2 {
+ append_radial_leader(g, tip, params.text_position, ¶ms);
+ }
+ append_arrow(g, tip, normalized_or(tip - center, axis), arrow1);
+ }
+ return;
+ }
// Text projected outside the diameter requires inward-pointing arrowheads.
let text_along = (params.text_position - chord).dot(axis);
let text_outside = text_along < 0.0 || text_along > diameter;
@@ -6048,8 +6169,8 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool
return false;
};
if let Some((vertex, start, end, radius)) = angular_dimension_frame(dim) {
- if dim.base().text_user_positioned {
- let text = vec3_local(dim.base().text_middle_point);
+ if let Some(point) = stored_text_point(dim) {
+ let text = vec3_local(point);
let angle = (text.y - vertex.y).atan2(text.x - vertex.x);
return (angle - start).rem_euclid(std::f32::consts::TAU)
> end - start + 1.0e-6;
@@ -6061,7 +6182,7 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool
let height = style.dimtxt * scale;
let gap = style.dimgap.abs() * scale;
let text_width = dimension_text_cells(dim, Some(style))
- .map(|cells| cells * height * 0.6 + gap * 2.0)
+ .map(|cells| cells * height * CELL_WIDTH + gap * 2.0)
.unwrap_or(0.0);
let arrow = style.dimasz * scale;
let span = radius as f64 * (end - start).abs() as f64;
@@ -6075,11 +6196,8 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool
}
if let Dimension::LargeRadial(radial) = dim {
let radius = radial.definition_point.distance(&radial.chord_point);
- if dim.base().text_user_positioned {
- return radial
- .definition_point
- .distance(&dim.base().text_middle_point)
- > radius + 1.0e-9;
+ if let Some(point) = stored_text_point(dim) {
+ return radial.definition_point.distance(&point) > radius + 1.0e-9;
}
if style.dimtix {
return false;
@@ -6093,7 +6211,7 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool
let height = style.dimtxt * scale;
let gap = style.dimgap.abs() * scale;
let text_width = dimension_text_cells(dim, Some(style))
- .map(|cells| cells * height * 0.6 + gap * 2.0)
+ .map(|cells| cells * height * CELL_WIDTH + gap * 2.0)
.unwrap_or(0.0);
let arrow = style.dimasz * scale;
let insufficient = text_width + arrow > available;
@@ -6108,8 +6226,7 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool
let dx = radius.definition_point.x - radius.angle_vertex.x;
let dy = radius.definition_point.y - radius.angle_vertex.y;
let available = dx.hypot(dy);
- if dim.base().text_user_positioned {
- let text = dim.base().text_middle_point;
+ if let Some(text) = stored_text_point(dim) {
return (text.x - radius.angle_vertex.x)
.hypot(text.y - radius.angle_vertex.y)
> available + 1e-9;
@@ -6121,7 +6238,7 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool
let height = style.dimtxt * scale;
let gap = style.dimgap.abs() * scale;
let text_width = dimension_text_cells(dim, Some(style))
- .map(|cells| cells * height * 0.6 + gap * 2.0)
+ .map(|cells| cells * height * CELL_WIDTH + gap * 2.0)
.unwrap_or(0.0);
let arrow = style.dimasz * scale;
let insufficient = text_width + arrow > available;
@@ -6154,8 +6271,7 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool
let second_axis = second.x * axis.x + second.y * axis.y;
let lo = first_axis.min(second_axis);
let hi = first_axis.max(second_axis);
- if dim.base().text_user_positioned {
- let point = dim.base().text_middle_point;
+ if let Some(point) = stored_text_point(dim) {
let position = point.x * axis.x + point.y * axis.y;
return position < lo || position > hi;
}
@@ -6166,7 +6282,7 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool
let height = style.dimtxt * scale;
let gap = style.dimgap.abs() * scale;
let text_width = dimension_text_cells(dim, Some(style))
- .map(|cells| cells * height * 0.6 + gap * 2.0)
+ .map(|cells| cells * height * CELL_WIDTH + gap * 2.0)
.unwrap_or(0.0);
let arrow = style.dimasz * scale;
let span = hi - lo;
@@ -6250,7 +6366,9 @@ fn dimension_text_parts(
};
let primary_raw = match dim {
Dimension::Radius(_) | Dimension::LargeRadial(_) => format!("R{}", value),
- Dimension::Diameter(_) => format!("Ø{}", value),
+ // U+2205 is the native diameter sign and what `%%c` resolves to; the
+ // bundled dimension fonts carry it where Latin Ø is missing.
+ Dimension::Diameter(_) => format!("∅{}", value),
_ => value,
};
@@ -6528,13 +6646,13 @@ fn dimension_tolerance_entity(
};
// Approximate widths from glyph counts (~0.6 × cell size per char).
- let primary_w = primary_value_len as f64 * primary_height * 0.6;
+ let primary_w = primary_value_len as f64 * primary_height * CELL_WIDTH;
let tol_visible_chars = tol
.strip_prefix("\\S")
.and_then(|value| value.strip_suffix(';'))
.map(|value| value.split('^').map(str::len).max().unwrap_or(0))
.unwrap_or_else(|| tol.chars().count());
- let tol_w = tol_visible_chars as f64 * tol_height * 0.6;
+ let tol_w = tol_visible_chars as f64 * tol_height * CELL_WIDTH;
let gap = primary_height * 0.2;
let dx_local = primary_w * 0.5 + tol_w * 0.5 + gap;
let dy_local = match s.dimtolj {
@@ -6595,6 +6713,12 @@ fn effective_dimlfac(style: Option<&DimStyle>) -> f64 {
}
}
+/// Average advance of one character cell as a fraction of the text height.
+/// Measured on the bundled stroke fonts by rendering the text of 106 saved
+/// dimension pictures: median 0.66, where the old guess of 0.60 left every
+/// fit and hook estimate ten percent narrow.
+const CELL_WIDTH: f64 = 0.66;
+
/// DIMTFAC with the unset value 0 read as 1.
fn dimtfac_or_one(s: &DimStyle) -> f64 {
if s.dimtfac.abs() < 1e-12 {
@@ -6604,15 +6728,28 @@ fn dimtfac_or_one(s: &DimStyle) -> f64 {
}
}
+/// Vertical extent of a dimension's whole value: the first line (or limits
+/// stack) plus any `\P` continuation lines at MText's default spacing.
+fn dimension_text_extent_height(dim: &Dimension, style: Option<&DimStyle>, text_height: f64) -> f64 {
+ let lines = dimension_text_value(dim, style)
+ .map(|value| text_lines(&value))
+ .unwrap_or(1);
+ dimension_text_block_height(style, text_height) + (lines - 1) as f64 * text_height * 5.0 / 3.0
+}
+
/// Vertical extent of the rendered value: one line, or a DIMLIM stack whose
/// halves each sit at DIMTFAC × DIMTXT.
fn dimension_text_block_height(style: Option<&DimStyle>, text_height: f64) -> f64 {
match style {
Some(s) if s.dimlim => {
+ // From the lower half's baseline to the upper half's cap, as the
+ // MText stack lays them out.
text_height
* dimtfac_or_one(s)
- * 2.0
- * f64::from(crate::entities::text_support::STACK_HALF_SCALE)
+ * f64::from(
+ crate::entities::text_support::STACK_RAISE
+ + crate::entities::text_support::STACK_HALF_SCALE,
+ )
}
_ => text_height,
}
@@ -6625,22 +6762,55 @@ pub(crate) fn dimension_text_cells(dim: &Dimension, style: Option<&DimStyle>) ->
Some(text_cells(&value, style.map(dimtfac_or_one).unwrap_or(1.0)))
}
+/// Width in cells of the widest line; `\P` starts a new line.
fn text_cells(value: &str, stack_scale: f64) -> f64 {
+ value
+ .split(r"\P")
+ .map(|line| line_cells(line, stack_scale))
+ .fold(0.0, f64::max)
+}
+
+/// Number of lines the value renders as.
+fn text_lines(value: &str) -> usize {
+ value.split(r"\P").count().max(1)
+}
+
+/// Visible cells of one line: MText format codes (`\\f...;`, `\\H...;`, `\\C...;`,
+/// braces, underline toggles) take no room, a stack is as wide as its wider half.
+fn line_cells(value: &str, stack_scale: f64) -> f64 {
let mut cells = 0.0;
- let mut rest = value;
- while let Some(start) = rest.find("\\S") {
- cells += rest[..start].chars().count() as f64;
- let body = &rest[start + 2..];
- let end = body.find(';').unwrap_or(body.len());
- let widest = body[..end]
- .split(['^', '/', '#'])
- .map(|half| half.chars().count())
- .max()
- .unwrap_or(0);
- cells += widest as f64 * stack_scale;
- rest = &body[(end + 1).min(body.len())..];
+ let mut chars = value.chars().peekable();
+ while let Some(c) = chars.next() {
+ match c {
+ '{' | '}' => {}
+ '\\' => match chars.next() {
+ Some('S') => {
+ let body: String = chars.by_ref().take_while(|c| *c != ';').collect();
+ let widest = body
+ .split(['^', '/', '#'])
+ .map(|half| half.chars().count())
+ .max()
+ .unwrap_or(0);
+ cells += widest as f64 * stack_scale;
+ }
+ Some('~') => cells += 1.0,
+ // Underline, overline and strike toggles take no room; an
+ // escaped backslash or brace is one visible character.
+ Some('L' | 'l' | 'O' | 'o' | 'K' | 'k') => {}
+ Some('\\' | '{' | '}') => cells += 1.0,
+ Some(_) => {
+ for c in chars.by_ref() {
+ if c == ';' {
+ break;
+ }
+ }
+ }
+ None => {}
+ },
+ _ => cells += 1.0,
+ }
}
- cells + rest.chars().count() as f64
+ cells
}
/// Format a linear measurement honouring DIMLFAC, DIMRND, DIMDEC, DIMZIN, DIMDSEP, DIMLUNIT.
@@ -7018,6 +7188,7 @@ fn text_on_dim_line(
dimtix: bool,
dimatfit: i16,
tad: i16,
+ horizontal_outside: bool,
) -> Vector3 {
// The perpendicular "up" side: the perpendicular of the dimension line
// normalised so the text reads left-to-right (or bottom-to-top when
@@ -7070,7 +7241,24 @@ fn text_on_dim_line(
_ => text_w > span,
};
if text_outside {
- along = hi + arrow + text_w * 0.5;
+ // Past the two-arrow stub the dimension line grows outside the
+ // extension line, then the gap that `text_w` already carries.
+ along = hi + 2.0 * arrow + text_w * 0.5;
+ if horizontal_outside && ay.abs() > 1e-3 {
+ // Horizontal text beside a hook: the line runs out two arrow
+ // lengths, the hook turns one arrow toward the text-up side
+ // and the text sits against it, centred on the hook.
+ let elbow_along = hi + 2.0 * arrow;
+ let side = if px >= 0.0 { 1.0 } else { -1.0 };
+ let elbow_x = defpt.x + ax * elbow_along;
+ let elbow_y = defpt.y + ay * elbow_along;
+ let vertical = if tad == 0 { 0.0 } else { perp_off * perp_sign };
+ return Vector3::new(
+ elbow_x + side * (arrow + text_w * 0.5),
+ elbow_y + vertical,
+ defpt.z,
+ );
+ }
}
}
let bx = defpt.x + ax * along;
@@ -7124,7 +7312,7 @@ fn text_on_single_arrow_dim_line(
_ => text_width > available,
};
let along = if text_outside {
- high + arrow + text_width * 0.5
+ high + 2.0 * arrow + text_width * 0.5
} else {
(first_along + second_along) * 0.5
};
@@ -7135,6 +7323,30 @@ fn text_on_single_arrow_dim_line(
)
}
+/// The stored text middle point (group 11) applies to automatic and hand-placed
+/// text alike. A regenerated dimension keeps any non-zero point; only a cleared
+/// point is placed from the style.
+fn stored_text_point(dim: &Dimension) -> Option {
+ let p = dim.base().text_middle_point;
+ (p.x * p.x + p.y * p.y + p.z * p.z > 1e-16).then_some(p)
+}
+
+/// The point on the circle a radial leader leaves from: the arc point of a
+/// radius, or whichever end of a diameter's chord is nearer the text.
+fn radial_leader_tip(dim: &Dimension, text: Vector3) -> Vector3 {
+ match dim {
+ Dimension::Radius(d) => d.definition_point,
+ Dimension::Diameter(d) => {
+ if d.angle_vertex.distance(&text) <= d.definition_point.distance(&text) {
+ d.angle_vertex
+ } else {
+ d.definition_point
+ }
+ }
+ _ => dim.base().text_middle_point,
+ }
+}
+
fn dimension_text_pos_f64(
dim: &Dimension,
style: Option<&DimStyle>,
@@ -7160,52 +7372,54 @@ fn dimension_text_pos_f64(
let perp_off = if dimtad == 0 {
dimtvp * text_height
} else {
- dimension_text_block_height(style, text_height) * 0.5 + dimgap
+ dimension_text_extent_height(dim, style, text_height) * 0.5 + dimgap
};
// Rough text width + arrow allowance, used to decide text-outside fit.
let text_w = dimension_text_cells(dim, style)
- .map(|cells| cells * text_height * 0.6 + 2.0 * dimgap)
+ .map(|cells| cells * text_height * CELL_WIDTH + 2.0 * dimgap)
.unwrap_or(0.0);
- let arrow = if matches!(dim, Dimension::LargeRadial(_)) {
- style
- .map(|style| style.dimasz * dim_scale)
- .unwrap_or(text_height)
- } else {
- text_height
- };
+ let arrow = style
+ .map(|style| style.dimasz * dim_scale)
+ .filter(|arrow| *arrow > 1e-9)
+ .unwrap_or(text_height);
+ // DIMTOH: text that lands outside reads horizontally.
+ let horizontal_outside = style.is_some_and(|style| style.dimtoh);
- // Explicit per-entity override (text dragged to a custom location): the
- // saved point wins. Otherwise the dimension style governs placement.
- let use_saved = base.text_user_positioned && {
- let p = base.text_middle_point;
- p.x * p.x + p.y * p.y + p.z * p.z > 1e-16
- };
- if use_saved {
- if let Dimension::Diameter(d) = dim {
- let dx = d.definition_point.x - d.angle_vertex.x;
- let dy = d.definition_point.y - d.angle_vertex.y;
- let len = (dx * dx + dy * dy).sqrt().max(1e-12);
-
- // Match the readable orientation used by text_on_dim_line().
- let (mut nx, mut ny) = (dx / len, dy / len);
- if nx < 0.0 || (nx == 0.0 && ny < 0.0) {
- nx = -nx;
- ny = -ny;
+ if let Some(point) = stored_text_point(dim) {
+ return match dim {
+ // DIMTMOVE 1 stores where the leader's hook starts: one arrow of
+ // hook, a gap, then the text, all running away from the arc.
+ // Measured on 39 such dimensions the stored point sits 2.4 text
+ // heights before the text, which is DIMASZ plus DIMGAP.
+ Dimension::Radius(_) | Dimension::Diameter(_)
+ if style.is_some_and(|s| s.dimtmove == 1) =>
+ {
+ let tip = radial_leader_tip(dim, point);
+ let side = if point.x >= tip.x { 1.0 } else { -1.0 };
+ Vector3::new(point.x + side * (arrow + text_w * 0.5), point.y, point.z)
}
-
- let px = -ny;
- let py = nx;
-
- let perp_sign = if dimtad == 4 { -1.0 } else { 1.0 };
-
- return Vector3::new(
- base.text_middle_point.x + px * perp_off * perp_sign,
- base.text_middle_point.y + py * perp_off * perp_sign,
- base.text_middle_point.z,
- );
- }
-
- return base.text_middle_point;
+ // A diameter whose text the user dragged keeps the grip point on
+ // the leader and lifts the text by the DIMTAD offset (#1323).
+ Dimension::Diameter(d) if base.text_user_positioned && dimtad != 0 => {
+ let dx = d.definition_point.x - d.angle_vertex.x;
+ let dy = d.definition_point.y - d.angle_vertex.y;
+ let len = (dx * dx + dy * dy).sqrt().max(1e-12);
+ // Match the readable orientation used by text_on_dim_line().
+ let (mut nx, mut ny) = (dx / len, dy / len);
+ if nx < 0.0 || (nx == 0.0 && ny < 0.0) {
+ nx = -nx;
+ ny = -ny;
+ }
+ let (px, py) = (-ny, nx);
+ let perp_sign = if dimtad == 4 { -1.0 } else { 1.0 };
+ Vector3::new(
+ point.x + px * perp_off * perp_sign,
+ point.y + py * perp_off * perp_sign,
+ point.z,
+ )
+ }
+ _ => point,
+ };
}
match dim {
@@ -7225,6 +7439,7 @@ fn dimension_text_pos_f64(
dimtix,
dimatfit,
dimtad,
+ horizontal_outside,
)
}
Dimension::Aligned(d) => {
@@ -7244,6 +7459,7 @@ fn dimension_text_pos_f64(
dimtix,
dimatfit,
dimtad,
+ horizontal_outside,
)
}
Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => {
@@ -7333,6 +7549,7 @@ fn dimension_text_pos_f64(
dimtix,
dimatfit,
dimtad,
+ horizontal_outside,
)
}
Dimension::Ordinate(d) => {
@@ -7433,6 +7650,7 @@ pub(crate) fn baked_large_radial_geometry(
horizontal_text: text.horizontal,
text_movement: style.map(|style| style.dimtmove).unwrap_or(0),
text_break: text.break_box,
+ leader_anchor: None,
},
SuppressFlags {
ext1: style.is_some_and(|style| style.dimse1),
@@ -7553,13 +7771,9 @@ pub(crate) fn dimension_text_grip_position(
document: &CadDocument,
anno_scale: f64,
) -> Option {
- // Once the user has moved the text, its saved point is authoritative.
- let base = dim.base();
- if base.text_user_positioned {
- let p = base.text_middle_point;
- if p.x * p.x + p.y * p.y + p.z * p.z > 1e-16 {
- return Some(p);
- }
+ // A stored text point is where the text is drawn; the grip goes there.
+ if let Some(p) = stored_text_point(dim) {
+ return Some(p);
}
// For automatic text, use the same text-building path used by the
@@ -7587,11 +7801,11 @@ mod dimtad_tests {
fn above_is_up_regardless_of_direction() {
let (first, second, defpt) = (v(0.0, 10.0), v(20.0, 10.0), v(0.0, 0.0));
let perp = 5.0;
- let fwd = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 1);
- let rev = text_on_dim_line(first, second, defpt, -1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 1);
+ let fwd = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 1, false);
+ let rev = text_on_dim_line(first, second, defpt, -1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 1, false);
assert!(fwd.y > 0.0, "above must be +Y, got {}", fwd.y);
assert!(rev.y > 0.0, "above must be +Y even reversed, got {}", rev.y);
- let below = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 4);
+ let below = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 4, false);
assert!(below.y < 0.0, "below must be -Y, got {}", below.y);
}
@@ -7614,7 +7828,8 @@ mod dimtad_tests {
false,
3,
2,
- );
+ false,
+ );
assert!(
above.y > 10.0,
"outside must clear the object side, got {}",
@@ -7634,7 +7849,8 @@ mod dimtad_tests {
false,
3,
2,
- );
+ false,
+ );
assert!(
below.y < -10.0,
"outside must clear the object side, got {}",
@@ -8262,6 +8478,14 @@ mod limits_format_tests {
assert_eq!(text_cells("10.000", 0.5), 6.0);
assert_eq!(text_cells(r"R\S1.020^0.98;", 0.5), 1.0 + 5.0 * 0.5);
assert_eq!(text_cells(r"\S10.020^9.950; REF", 1.0), 6.0 + 4.0);
+ assert_eq!(text_cells(r"\S1.020^0.980;\PCutouts", 1.0), 7.0, "widest line, not the sum");
+ assert_eq!(text_lines(r"4X <>\PNominal"), 2);
+ assert_eq!(
+ text_cells(r"{\fArial|b0|i1|c0|p34;Cutouts}", 1.0),
+ 7.0,
+ "format codes and braces take no room"
+ );
+ assert_eq!(text_cells(r"\H0.7x;10\L Ref\l", 1.0), 6.0);
}
// A three-point angular dimension whose first extension point coincides
@@ -8326,7 +8550,259 @@ mod limits_format_tests {
assert_eq!(dimension_text_block_height(Some(&s), 2.0), 2.0);
s.dimlim = true;
s.dimtfac = 0.5;
- let expected = 2.0 * 0.5 * 2.0 * f64::from(crate::entities::text_support::STACK_HALF_SCALE);
+ let expected = 2.0
+ * 0.5
+ * f64::from(
+ crate::entities::text_support::STACK_RAISE
+ + crate::entities::text_support::STACK_HALF_SCALE,
+ );
assert_eq!(dimension_text_block_height(Some(&s), 2.0), expected);
}
}
+
+#[cfg(test)]
+mod layout_parity_tests {
+ use super::*;
+ use acadrust::entities::{DimensionDiameter, DimensionLinear, DimensionRadius};
+ use glam::DVec3;
+
+ /// A document whose Standard style has imperial sizes and horizontal text.
+ fn document() -> CadDocument {
+ let mut document = CadDocument::new();
+ let style = document
+ .dim_styles
+ .get_mut("Standard")
+ .expect("Standard style");
+ style.dimasz = 0.18;
+ style.dimtxt = 0.18;
+ style.dimgap = 0.09;
+ style.dimtih = true;
+ style.dimtoh = true;
+ style.dimtad = 0;
+ style.dimdec = 2;
+ document
+ }
+
+ fn style(document: &CadDocument) -> DimStyle {
+ document.dim_styles.get("Standard").expect("Standard style").clone()
+ }
+
+ /// Stroke endpoints and arrowhead triangle count of the drawn dimension.
+ fn drawn(document: &CadDocument, dim: &Dimension) -> (Vec, usize) {
+ let wires = dim.tessellate(
+ document,
+ Handle::new(0x40),
+ false,
+ [1.0; 4],
+ 1.0,
+ 1.0,
+ &rustc_hash::FxHashSet::default(),
+ None,
+ [0.0, 0.0, 0.0, 1.0],
+ None,
+ None,
+ );
+ let mut points = Vec::new();
+ let mut fills = 0;
+ for wire in &wires {
+ for p in wire.points.iter().filter(|p| p[0].is_finite()) {
+ points.push(DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64));
+ }
+ fills += wire.fill_tris.len() / 3;
+ }
+ (points, fills)
+ }
+
+ fn vertical(top: f64) -> DimensionLinear {
+ let mut d = DimensionLinear::default();
+ d.first_point = Vector3::new(0.0, 0.0, 0.0);
+ d.second_point = Vector3::new(0.0, top, 0.0);
+ d.definition_point = Vector3::new(-0.5, top, 0.0);
+ d.rotation = std::f64::consts::FRAC_PI_2;
+ d
+ }
+
+ // Group 11 is authoritative whenever present, whether or not the user moved
+ // the text.
+ #[test]
+ fn stored_text_point_places_automatic_text() {
+ let document = document();
+ let mut d = DimensionLinear::horizontal(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0));
+ d.definition_point = Vector3::new(0.0, 5.0, 0.0);
+ d.base.text_middle_point = Vector3::new(7.0, 5.2, 0.0);
+ d.base.text_user_positioned = false;
+ let dim = Dimension::Linear(d);
+ let position = dimension_text_pos_f64(&dim, Some(&style(&document)), 0.18, 1.0);
+ assert_eq!((position.x, position.y), (7.0, 5.2));
+ }
+
+ // Text pushed outside by a narrow gap sits past the two-arrow stub and a
+ // gap, not directly against the arrowhead.
+ #[test]
+ fn outside_text_clears_the_stub_and_a_gap() {
+ let document = document();
+ let mut d = DimensionLinear::horizontal(Vector3::new(0.0, 0.0, 0.0), Vector3::new(0.2, 0.0, 0.0));
+ d.definition_point = Vector3::new(0.0, 1.0, 0.0);
+ let dim = Dimension::Linear(d);
+ let s = style(&document);
+ let position = dimension_text_pos_f64(&dim, Some(&s), 0.18, 1.0);
+ let half_width = dimension_text_cells(&dim, Some(&s)).unwrap() * 0.18 * CELL_WIDTH * 0.5;
+ let text_start = position.x - half_width;
+ let stub_end = 0.2 + 2.0 * 0.18;
+ assert!(
+ (text_start - (stub_end + 0.09)).abs() < 1e-6,
+ "text starts at {text_start}, expected {}",
+ stub_end + 0.09
+ );
+ }
+
+ // Horizontal text near the top of a vertical dimension runs into the top
+ // arrowhead, so both arrowheads flip outside and stubs grow past the
+ // extension lines. Text in the middle leaves them inside.
+ #[test]
+ fn text_that_hits_an_arrow_moves_the_arrows_outside() {
+ let document = document();
+ let top = 1.12;
+ let mut near_top = vertical(top);
+ near_top.base.text_middle_point = Vector3::new(-0.5, 0.98, 0.0);
+ let (points, _) = drawn(&document, &Dimension::Linear(near_top));
+ assert!(
+ points.iter().any(|p| p.y > top + 0.1) && points.iter().any(|p| p.y < -0.1),
+ "arrows should be outside with stubs past both extension lines"
+ );
+
+ let mut centred = vertical(top);
+ centred.base.text_middle_point = Vector3::new(-0.5, top * 0.5, 0.0);
+ let (points, _) = drawn(&document, &Dimension::Linear(centred));
+ assert!(
+ points.iter().all(|p| p.y <= top + 0.01 && p.y >= -0.01),
+ "centred text leaves the arrows inside"
+ );
+ }
+
+ // A negative DIMLFAC scales only a paper-space dimension that measures
+ // model space through a viewport. The association record carries the
+ // applied factor; zero means the dimension reads its sheet distance.
+ #[test]
+ fn negative_dimlfac_follows_the_recorded_association_factor() {
+ use acadrust::xdata::{ExtendedDataRecord, XDataValue};
+ let document = CadDocument::new();
+ let mut source = style(&document);
+ source.dimlfac = -4.0;
+ let mut d = DimensionLinear::horizontal(Vector3::new(0.0, 0.0, 0.0), Vector3::new(4.457, 0.0, 0.0));
+ d.definition_point = Vector3::new(0.0, 1.0, 0.0);
+ let with_factor = |factor: f64| {
+ let mut d = d.clone();
+ let mut record = ExtendedDataRecord::new("ACAD_DIMASSOC_CALC_DIMLFAC");
+ record.add_value(XDataValue::Real(factor));
+ d.base.common.extended_data.add_record(record);
+ Dimension::Linear(d)
+ };
+ let sheet = resolved_dimension_style(&source, &with_factor(0.0), &document);
+ assert_eq!(sheet.dimlfac, 1.0, "no factor applied: sheet distance");
+ let through_viewport = resolved_dimension_style(&source, &with_factor(-4.0), &document);
+ assert_eq!(through_viewport.dimlfac, 4.0, "the recorded factor, made positive");
+ let unknown = resolved_dimension_style(&source, &Dimension::Linear(d.clone()), &document);
+ assert_eq!(unknown.dimlfac, 1.0, "model space without a record: unscaled");
+ let invalid = resolved_dimension_style(&source, &with_factor(f64::NAN), &document);
+ assert_eq!(invalid.dimlfac, 1.0, "invalid recorded factor: unscaled");
+ }
+
+ // DIMTMOVE 1: the stored point is where the hook starts. The hook runs
+ // one arrow toward the text, and the text begins a gap beyond it.
+ #[test]
+ fn dimtmove_one_anchors_the_hook_start_at_the_stored_point() {
+ let mut document = document();
+ document.dim_styles.get_mut("Standard").unwrap().dimtmove = 1;
+ let mut d = DimensionRadius::default();
+ d.angle_vertex = Vector3::new(0.0, 0.0, 0.0);
+ d.definition_point = Vector3::new(1.0, 0.0, 0.0);
+ d.base.text_middle_point = Vector3::new(3.0, 1.5, 0.0);
+ let dim = Dimension::Radius(d);
+ let (points, _) = drawn(&document, &dim);
+ let hook: Vec = points.iter().filter(|p| (p.y - 1.5).abs() < 1e-6).map(|p| p.x).collect();
+ let (lo, hi) = (hook.iter().cloned().fold(f64::MAX, f64::min), hook.iter().cloned().fold(f64::MIN, f64::max));
+ assert!((lo - 3.0).abs() < 1e-6 && (hi - 3.18).abs() < 1e-6, "hook from the stored point one arrow toward the text: {lo}..{hi}");
+ let s = style(&document);
+ let position = dimension_text_pos_f64(&dim, Some(&s), 0.18, 1.0);
+ let half = dimension_text_cells(&dim, Some(&s)).unwrap() * 0.18 * CELL_WIDTH * 0.5;
+ assert!((position.x - half - 0.09 - 3.18).abs() < 1e-6, "text starts a gap past the hook end, got {}", position.x - half);
+ }
+
+ // Text stored well past the second extension line of a horizontal
+ // dimension: the dimension line runs out to the text, not just two arrow
+ // lengths.
+ #[test]
+ fn outside_text_on_the_axis_pulls_the_line_out_to_it() {
+ let document = document();
+ let mut d = DimensionLinear::horizontal(Vector3::new(0.0, 0.0, 0.0), Vector3::new(0.8, 0.0, 0.0));
+ d.definition_point = Vector3::new(0.0, 2.0, 0.0);
+ d.base.text_middle_point = Vector3::new(2.5, 2.0, 0.0);
+ let (points, _) = drawn(&document, &Dimension::Linear(d));
+ let furthest = points.iter().map(|p| p.x).fold(f64::MIN, f64::max);
+ assert!(
+ furthest > 2.0 && furthest < 2.5,
+ "line reaches the text's near edge, got {furthest}"
+ );
+ }
+
+ // Horizontal text stored above a vertical dimension: the dimension line
+ // runs up to the text's height and hooks one arrow toward it.
+ #[test]
+ fn horizontal_outside_text_gets_a_leg_and_a_hook() {
+ let document = document();
+ let top = 0.3;
+ let mut d = vertical(top);
+ d.base.text_middle_point = Vector3::new(-0.5 + 0.9, 1.0, 0.0);
+ let (points, _) = drawn(&document, &Dimension::Linear(d));
+ assert!(
+ points.iter().any(|p| (p.y - 1.0).abs() < 1e-6 && (p.x + 0.5).abs() < 1e-6),
+ "the dimension line reaches the text height at the extension line"
+ );
+ assert!(
+ points.iter().any(|p| (p.y - 1.0).abs() < 1e-6 && (p.x - (-0.5 + 0.18)).abs() < 1e-6),
+ "a hook one arrow long turns toward the text"
+ );
+ }
+
+ // Radius text outside the arc: no line to the centre, a leader from the arc
+ // point to a hook the text sits against, arrowhead body pointing outward.
+ #[test]
+ fn radius_text_outside_draws_a_hooked_leader_only() {
+ let document = document();
+ let mut d = DimensionRadius::default();
+ d.angle_vertex = Vector3::new(0.0, 0.0, 0.0);
+ d.definition_point = Vector3::new(1.0, 0.0, 0.0);
+ d.base.text_middle_point = Vector3::new(3.0, 0.8, 0.0);
+ let (points, fills) = drawn(&document, &Dimension::Radius(d));
+ assert!(
+ !points.iter().any(|p| p.length() < 1e-6),
+ "no dimension line reaches the centre"
+ );
+ let hook: Vec<_> = points.iter().filter(|p| (p.y - 0.8).abs() < 1e-6).collect();
+ assert!(hook.len() >= 2, "a horizontal hook at the text height: {hook:?}");
+ assert!(
+ points.iter().any(|p| p.x > 1.05 && p.x < 1.3 && p.y > 0.0),
+ "arrowhead body lies outside the arc along the leader"
+ );
+ assert_eq!(fills, 1);
+ }
+
+ // Diameter text outside the circle without DIMTOFL: a single leader from
+ // the near side, one arrowhead, and no line across the circle.
+ #[test]
+ fn diameter_text_outside_draws_one_leader_and_no_chord() {
+ let document = document();
+ let mut d = DimensionDiameter::default();
+ d.angle_vertex = Vector3::new(-1.0, 0.0, 0.0);
+ d.definition_point = Vector3::new(1.0, 0.0, 0.0);
+ d.base.text_middle_point = Vector3::new(3.0, 0.8, 0.0);
+ let (points, fills) = drawn(&document, &Dimension::Diameter(d));
+ assert!(
+ !points.iter().any(|p| p.x < -0.5),
+ "nothing is drawn on the far side of the circle"
+ );
+ assert_eq!(fills, 1, "one arrowhead");
+ assert!(points.iter().any(|p| (p.y - 0.8).abs() < 1e-6), "hook at the text height");
+ }
+}
diff --git a/src/entities/ellipse.rs b/src/entities/ellipse.rs
index 47ef9d58e..09560fd5c 100644
--- a/src/entities/ellipse.rs
+++ b/src/entities/ellipse.rs
@@ -187,9 +187,9 @@ fn properties(ell: &Ellipse) -> Vec {
ro(t!("Start parameter").as_ref(), "start_param", format!("{:.4}", ell.start_parameter)),
ro(t!("End parameter").as_ref(), "end_param", format!("{:.4}", ell.end_parameter)),
ro(t!("Length").as_ref(), "length", format_length(props.perimeter)),
- ro(t!("Normal X").as_ref(), "normal_x", format!("{:.4}", ell.normal.x)),
- ro(t!("Normal Y").as_ref(), "normal_y", format!("{:.4}", ell.normal.y)),
- ro(t!("Normal Z").as_ref(), "normal_z", format!("{:.4}", ell.normal.z)),
+ edit(t!("Normal X").as_ref(), "normal_x", ell.normal.x),
+ edit(t!("Normal Y").as_ref(), "normal_y", ell.normal.y),
+ edit(t!("Normal Z").as_ref(), "normal_z", ell.normal.z),
],
}]
}
@@ -220,6 +220,26 @@ fn apply_geom_prop(ell: &mut Ellipse, field: &str, value: &str) {
"ratio" if v > 0.0 => ell.minor_axis_ratio = v,
"start_angle" => ell.start_parameter = v.to_radians(),
"end_angle" => ell.end_parameter = v.to_radians(),
+ "normal_x" | "normal_y" | "normal_z" => {
+ let mut normal = [ell.normal.x, ell.normal.y, ell.normal.z];
+ match field {
+ "normal_x" => normal[0] = v,
+ "normal_y" => normal[1] = v,
+ "normal_z" => normal[2] = v,
+ _ => {}
+ }
+ let Some(normal) = cadkernel::space::Vec3::from(normal).normalize() else {
+ return;
+ };
+ let Some(major) = cadkernel::space::reorient_axis_to_plane(
+ [ell.major_axis.x, ell.major_axis.y, ell.major_axis.z],
+ normal.to_array(),
+ ) else {
+ return;
+ };
+ ell.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z);
+ ell.major_axis = acadrust::types::Vector3::new(major[0], major[1], major[2]);
+ }
_ => {}
}
}
@@ -424,6 +444,19 @@ mod grip_tests {
(v.major_axis.x * v.major_axis.x + v.major_axis.y * v.major_axis.y).sqrt()
}
+ #[test]
+ fn editing_normal_reorients_major_axis_in_kernel() {
+ let mut e = ell(10.0, 0.5);
+
+ apply_geom_prop(&mut e, "normal_x", "1");
+
+ let normal = DVec3::new(e.normal.x, e.normal.y, e.normal.z);
+ let major = DVec3::new(e.major_axis.x, e.major_axis.y, e.major_axis.z);
+ assert!((normal.length() - 1.0).abs() < 1.0e-12);
+ assert!((major.length() - 10.0).abs() < 1.0e-9);
+ assert!(normal.dot(major).abs() < 1.0e-9);
+ }
+
#[test]
fn minor_grip_below_major_updates_ratio_only() {
let mut e = ell(10.0, 0.5);
diff --git a/src/entities/spline.rs b/src/entities/spline.rs
index 5a8813561..efbbc7e35 100644
--- a/src/entities/spline.rs
+++ b/src/entities/spline.rs
@@ -18,7 +18,13 @@ pub(crate) fn uses_fit_method(spline: &Spline) -> bool {
}
pub(crate) fn shows_fit_points(spline: &Spline) -> bool {
- uses_fit_method(spline) && (!spline.cv_frame_visible || spline.flags.periodic)
+ !spline.fit_points.is_empty() && (!spline.cv_frame_visible || spline.flags.periodic)
+}
+
+pub(crate) fn shows_control_vertices(spline: &Spline) -> bool {
+ !shows_fit_points(spline)
+ && !(uses_fit_method(spline) && spline.flags.periodic)
+ && control_vertices(spline).len() >= 2
}
pub(crate) fn fit_nurbs3(spline: &Spline) -> Option {
@@ -105,12 +111,21 @@ pub(crate) fn spline_knot_points(spline: &Spline) -> Vec {
}
pub(crate) fn replace_with_nurbs(spline: &mut Spline, curve: &NurbsCurve3) {
+ let mut fit_points = spline.fit_points.clone();
spline.degree = curve.degree() as i32;
spline.knots = curve.knots().to_vec();
spline.control_points = curve.control_points().iter()
.map(|point| acadrust::types::Vector3::new(point[0], point[1], point[2])).collect();
spline.weights = curve.weights().to_vec();
- spline.fit_points.clear();
+ if !fit_points.is_empty() {
+ if let (Some(first), Some(point)) = (fit_points.first_mut(), spline.control_points.first()) {
+ *first = *point;
+ }
+ if let (Some(last), Some(point)) = (fit_points.last_mut(), spline.control_points.last()) {
+ *last = *point;
+ }
+ }
+ spline.fit_points = fit_points;
spline.begin_tangent = acadrust::types::Vector3::ZERO;
spline.end_tangent = acadrust::types::Vector3::ZERO;
spline.flags.rational = spline.weights.windows(2)
@@ -615,7 +630,15 @@ fn index_prop(label: &str, field: &'static str, index: usize, count: usize) -> P
fn convert_to_control_method(spline: &mut Spline) -> bool {
if !uses_fit_method(spline) {
- return true;
+ let valid = spline.control_points.len() >= 2;
+ if valid {
+ spline.fit_points.clear();
+ spline.begin_tangent = acadrust::types::Vector3::ZERO;
+ spline.end_tangent = acadrust::types::Vector3::ZERO;
+ spline.dwg_flags1 &= !1;
+ spline.dxf_flags &= !(32 | 1024);
+ }
+ return valid;
}
let Some(planar) = crate::entities::curve::spline_curve(spline) else {
return false;
@@ -647,18 +670,74 @@ fn convert_to_control_method(spline: &mut Spline) -> bool {
}
fn convert_to_fit_method(spline: &mut Spline) -> bool {
- uses_fit_method(spline)
+ if uses_fit_method(spline) {
+ return true;
+ }
+ if spline.fit_points.len() < 2 {
+ return false;
+ }
+ spline.control_points.clear();
+ spline.knots.clear();
+ spline.weights.clear();
+ spline.flags.rational = false;
+ spline.dwg_flags1 |= 1;
+ spline.dxf_flags |= 32 | 1024;
+ true
+}
+
+fn derived_fit_points(spline: &Spline) -> Vec {
+ if spline.flags.closed || spline.flags.periodic {
+ return Vec::new();
+ }
+ let Some(curve) = nurbs3(spline) else {
+ return Vec::new();
+ };
+ let (start, end) = curve.domain();
+ if !(start.is_finite() && end.is_finite() && end > start) {
+ return Vec::new();
+ }
+ let tolerance = (end - start).abs().max(1.0) * 1.0e-12;
+ let mut parameters = vec![start];
+ for knot in curve.knots().iter().copied() {
+ if knot > start + tolerance
+ && knot < end - tolerance
+ && parameters
+ .last()
+ .is_none_or(|previous| (knot - *previous).abs() > tolerance)
+ {
+ parameters.push(knot);
+ }
+ }
+ parameters.push(end);
+ parameters
+ .into_iter()
+ .map(|parameter| {
+ let point = curve.point_at_knot(parameter);
+ acadrust::types::Vector3::new(point[0], point[1], point[2])
+ })
+ .collect()
+}
+
+fn can_show_fit_points(spline: &Spline) -> bool {
+ spline.fit_points.len() >= 2 || derived_fit_points(spline).len() >= 2
+}
+
+fn prepare_fit_point_view(spline: &mut Spline) -> bool {
+ if spline.fit_points.len() < 2 {
+ spline.fit_points = derived_fit_points(spline);
+ }
+ spline.fit_points.len() >= 2
}
fn grips(spline: &Spline) -> Vec {
- let fit_method = uses_fit_method(spline);
let derived_control = control_vertices(spline);
- let show_control_vertices = (spline.cv_frame_visible || !fit_method)
- && !(fit_method && spline.flags.periodic)
+ let show_fit = shows_fit_points(spline);
+ let show_control_vertices = !show_fit
+ && !(uses_fit_method(spline) && spline.flags.periodic)
&& derived_control.len() >= 2;
let source = if show_control_vertices {
derived_control
- } else if fit_method {
+ } else if show_fit {
spline.fit_points.clone()
} else {
spline.control_points.clone()
@@ -885,7 +964,7 @@ fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) {
match field {
"spline_method" => {
if value == "Fit" {
- if convert_to_fit_method(spline) {
+ if prepare_fit_point_view(spline) {
spline.cv_frame_visible = false;
}
} else if value == "Control Vertices" {
@@ -896,6 +975,12 @@ fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) {
return;
}
"knot_param" => {
+ if shows_fit_points(spline)
+ && !uses_fit_method(spline)
+ && !convert_to_fit_method(spline)
+ {
+ return;
+ }
spline.knot_parameterization = match value {
"Chord" => 0,
"Square Root" => 1,
@@ -930,9 +1015,30 @@ fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) {
{
return;
}
- if matches!(field, "ctrl_pt_x" | "ctrl_pt_y" | "ctrl_pt_z" | "weight")
- && uses_fit_method(spline)
- && !convert_to_control_method(spline)
+ if matches!(field, "ctrl_pt_x" | "ctrl_pt_y" | "ctrl_pt_z" | "weight") {
+ if !shows_fit_points(spline) && !spline.fit_points.is_empty() {
+ if !convert_to_control_method(spline) {
+ return;
+ }
+ } else if uses_fit_method(spline) && !convert_to_control_method(spline) {
+ return;
+ }
+ }
+ if shows_fit_points(spline)
+ && !uses_fit_method(spline)
+ && matches!(
+ field,
+ "fit_pt_x"
+ | "fit_pt_y"
+ | "fit_pt_z"
+ | "start_tan_x"
+ | "start_tan_y"
+ | "start_tan_z"
+ | "end_tan_x"
+ | "end_tan_y"
+ | "end_tan_z"
+ )
+ && !convert_to_fit_method(spline)
{
return;
}
@@ -1015,16 +1121,23 @@ fn apply_grip(spline: &mut Spline, grip_id: usize, apply: GripApply) {
return;
}
// First CV edit makes the displayed kernel controls authoritative.
- let fit_method = uses_fit_method(spline);
- if spline.cv_frame_visible && fit_method && !spline.flags.periodic {
+ let editing_fit = shows_fit_points(spline);
+ if editing_fit && !uses_fit_method(spline) {
+ if !convert_to_fit_method(spline) {
+ return;
+ }
+ } else if !editing_fit
+ && (uses_fit_method(spline) || !spline.fit_points.is_empty())
+ && !spline.flags.periodic
+ {
if !convert_to_control_method(spline) {
return;
}
}
- let target = if (spline.cv_frame_visible && !spline.flags.periodic) || !fit_method {
- spline.control_points.get_mut(grip_id)
- } else {
+ let target = if editing_fit {
spline.fit_points.get_mut(grip_id)
+ } else {
+ spline.control_points.get_mut(grip_id)
};
if let Some(cp) = target {
match apply {
@@ -1088,9 +1201,10 @@ impl crate::entities::traits::Grippable for Spline {
let fit_method = uses_fit_method(self);
let can_show_control = control_vertices(self).len() >= 2
&& !(fit_method && self.flags.periodic);
+ let can_show_fit = can_show_fit_points(self);
return if !shows_fit_points(self) {
vec![
- (can_show_control && fit_method).then_some(GripMenuItem {
+ can_show_fit.then_some(GripMenuItem {
label: "Fit",
action: GripMenuAction::ShowFit,
}),
@@ -1131,7 +1245,7 @@ impl crate::entities::traits::Grippable for Spline {
use crate::scene::model::object::GripMenuAction as A;
match action {
A::ShowFit => {
- if convert_to_fit_method(self) {
+ if prepare_fit_point_view(self) {
self.cv_frame_visible = false;
return;
}
@@ -1276,4 +1390,30 @@ mod tests {
acadrust::types::Vector3::new(0.0, 2.5, 0.0)
);
}
+
+ #[test]
+ fn showing_derived_fit_points_does_not_change_the_curve() {
+ let mut spline = Spline::default();
+ spline.degree = 3;
+ spline.control_points = vec![
+ acadrust::types::Vector3::new(0.0, 0.0, 0.0),
+ acadrust::types::Vector3::new(1.0, 3.0, 0.0),
+ acadrust::types::Vector3::new(4.0, 3.0, 0.0),
+ acadrust::types::Vector3::new(5.0, 0.0, 0.0),
+ ];
+ spline.knots = cadkernel::space::clamped_uniform_knots(3, 4);
+ let before = nurbs3(&spline).unwrap().point_at(0.37);
+
+ assert!(prepare_fit_point_view(&mut spline));
+ spline.cv_frame_visible = false;
+
+ assert!(shows_fit_points(&spline));
+ assert!(!uses_fit_method(&spline));
+ let after = nurbs3(&spline).unwrap().point_at(0.37);
+ assert!(
+ cadkernel::space::Vec3::from(before)
+ .distance(cadkernel::space::Vec3::from(after))
+ < 1.0e-12
+ );
+ }
}
diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs
index f26f7d52a..9ffc247da 100644
--- a/src/entities/text_support.rs
+++ b/src/entities/text_support.rs
@@ -197,7 +197,7 @@ pub fn text_local_bounds(
/// Expand DXF `%%x` special-character sequences that appear in both TEXT and MTEXT values:
/// - `%%d` / `%%D` → `°`
/// - `%%p` / `%%P` → `±`
-/// - `%%c` / `%%C` → `⌀`
+/// - `%%c` / `%%C` → `∅` (U+2205, the native diameter sign)
/// - `%%u` / `%%U` → underline toggle (stripped — not renderable with stroke fonts)
/// - `%%o` / `%%O` → overline toggle (stripped)
/// - `%%%%` → `%`
@@ -224,7 +224,7 @@ pub fn resolve_dxf_special_chars(s: &str) -> String {
}
Some('c') => {
chars.next();
- out.push('⌀');
+ out.push('∅');
}
Some('u') | Some('o') => {
chars.next();
diff --git a/src/modules/draw/select.rs b/src/modules/draw/select.rs
index d695ce6d7..94f78b3bc 100644
--- a/src/modules/draw/select.rs
+++ b/src/modules/draw/select.rs
@@ -68,6 +68,21 @@ impl SelectObjectsCommand {
}
}
+ /// Standard selection behavior routed to a private apply command while
+ /// retaining the public command name in the visible prompt.
+ pub fn routed(prompt_cmd: &str, pending_cmd: &str) -> Self {
+ Self {
+ prompt_cmd: prompt_cmd.to_string(),
+ pending_cmd: pending_cmd.to_string(),
+ handles: Vec::new(),
+ commit_on_enter: true,
+ show_options: true,
+ pick: None,
+ pick_crossing: true,
+ auto_constrain_settings: false,
+ }
+ }
+
/// Single-object variant: the first completed selection action applies
/// immediately, with no Enter (used by commands that act on one object).
pub fn instant(pending_cmd: &str) -> Self {
diff --git a/src/modules/parametric/concentric.rs b/src/modules/parametric/concentric.rs
new file mode 100644
index 000000000..448c3227c
--- /dev/null
+++ b/src/modules/parametric/concentric.rs
@@ -0,0 +1,185 @@
+use acadrust::{EntityType, Handle};
+use glam::DVec3;
+
+use crate::command::{CadCommand, CmdResult};
+use crate::scene::parametric_constraints::ParametricRef;
+use crate::t;
+
+pub struct ConcentricConstraintCommand {
+ first: Option,
+ picked_entity: Option,
+}
+
+impl ConcentricConstraintCommand {
+ pub fn new() -> Self {
+ Self {
+ first: None,
+ picked_entity: None,
+ }
+ }
+
+ pub fn with_first(first: ParametricRef) -> Self {
+ Self {
+ first: Some(first),
+ picked_entity: None,
+ }
+ }
+
+ fn invalid_selection() -> CmdResult {
+ CmdResult::ReportError(
+ t!("Invalid selection for Concentric. Select a circle, arc, ellipse or polyline arc segment.")
+ .into_owned(),
+ )
+ }
+
+ fn polyline_segment_is_arc(entity: &EntityType, index: usize) -> bool {
+ match entity {
+ EntityType::LwPolyline(polyline) => polyline
+ .vertices
+ .get(index)
+ .is_some_and(|vertex| vertex.bulge.abs() > 1.0e-12),
+ EntityType::Polyline2D(polyline) => polyline
+ .vertices
+ .get(index)
+ .is_some_and(|vertex| vertex.bulge.abs() > 1.0e-12),
+ _ => false,
+ }
+ }
+
+ fn picked_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option {
+ match entity {
+ EntityType::Circle(_) | EntityType::Arc(_) | EntityType::Ellipse(_) => {
+ Some(ParametricRef::center(handle))
+ }
+ EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => {
+ let planar = crate::entities::curve::entity_curve(entity)?;
+ let local = planar.plane.project(point.to_array())?;
+ let segments = planar.curve.segments();
+ let (index, _) = cadkernel::geom2d::nearest_of(segments.iter(), local)?;
+ matches!(segments.get(index), Some(cadkernel::geom2d::Curve::Arc(_)))
+ .then(|| ParametricRef::segment_center(handle, index))
+ }
+ _ => None,
+ }
+ }
+
+ pub fn preselected_reference(entity: &EntityType, handle: Handle) -> Option {
+ match entity {
+ EntityType::Circle(_) | EntityType::Arc(_) | EntityType::Ellipse(_) => {
+ Some(ParametricRef::center(handle))
+ }
+ EntityType::LwPolyline(polyline) => {
+ let segment_count = if polyline.is_closed {
+ polyline.vertices.len()
+ } else {
+ polyline.vertices.len().saturating_sub(1)
+ };
+ (segment_count == 1 && Self::polyline_segment_is_arc(entity, 0))
+ .then(|| ParametricRef::segment_center(handle, 0))
+ }
+ EntityType::Polyline2D(polyline) => {
+ let segment_count = if polyline.is_closed() {
+ polyline.vertices.len()
+ } else {
+ polyline.vertices.len().saturating_sub(1)
+ };
+ (segment_count == 1 && Self::polyline_segment_is_arc(entity, 0))
+ .then(|| ParametricRef::segment_center(handle, 0))
+ }
+ _ => None,
+ }
+ }
+}
+
+impl CadCommand for ConcentricConstraintCommand {
+ fn name(&self) -> &'static str {
+ "GCCONCENTRIC"
+ }
+
+ fn prompt(&self) -> String {
+ if self.first.is_some() {
+ t!("GCCONCENTRIC Select second object:").into_owned()
+ } else {
+ t!("GCCONCENTRIC Select first object:").into_owned()
+ }
+ }
+
+ fn needs_entity_pick(&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 {
+ if handle.is_null() {
+ return CmdResult::NeedPoint;
+ }
+ let Some(entity) = self.picked_entity.take() else {
+ return CmdResult::NeedPoint;
+ };
+ let Some(reference) = Self::picked_reference(&entity, handle, point) else {
+ return Self::invalid_selection();
+ };
+ let Some(first) = self.first else {
+ self.first = Some(reference);
+ return CmdResult::NeedPoint;
+ };
+ if first == reference {
+ return Self::invalid_selection();
+ }
+ CmdResult::AddConcentricConstraint {
+ first,
+ second: reference,
+ label: "Concentric constraint",
+ }
+ }
+
+ 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
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn polyline_pick_keeps_the_selected_arc_center() {
+ let handle = Handle::new(13);
+ let mut polyline = acadrust::entities::LwPolyline::new();
+ polyline.vertices = vec![
+ acadrust::entities::LwVertex::from_coords(0.0, 0.0),
+ acadrust::entities::LwVertex::with_bulge(
+ acadrust::types::Vector2::new(5.0, 0.0),
+ 1.0,
+ ),
+ acadrust::entities::LwVertex::from_coords(10.0, 0.0),
+ ];
+
+ let reference = ConcentricConstraintCommand::picked_reference(
+ &EntityType::LwPolyline(polyline),
+ handle,
+ DVec3::new(8.0, 2.0, 0.0),
+ )
+ .expect("arc segment");
+ assert_eq!(reference, ParametricRef::segment_center(handle, 1));
+ }
+}
diff --git a/src/modules/parametric/constraint_bar.rs b/src/modules/parametric/constraint_bar.rs
new file mode 100644
index 000000000..76ae1d0a3
--- /dev/null
+++ b/src/modules/parametric/constraint_bar.rs
@@ -0,0 +1,102 @@
+use acadrust::Handle;
+use glam::DVec3;
+
+use crate::command::{CadCommand, CmdOption, CmdResult};
+
+pub struct ConstraintBarOptionCommand {
+ handles: Vec,
+}
+
+impl ConstraintBarOptionCommand {
+ pub fn new(handles: Vec) -> Self {
+ Self { handles }
+ }
+
+ fn finish(&mut self, command: &str) -> CmdResult {
+ CmdResult::Relaunch(command.to_string(), std::mem::take(&mut self.handles))
+ }
+}
+
+impl CadCommand for ConstraintBarOptionCommand {
+ fn name(&self) -> &'static str {
+ "CONSTRAINTBAR"
+ }
+
+ fn prompt(&self) -> String {
+ "CONSTRAINTBAR Enter an option [Show/Hide/Reset] :".to_string()
+ }
+
+ fn options(&self) -> Vec {
+ vec![
+ CmdOption::new("Show", "S"),
+ CmdOption::new("Hide", "H"),
+ CmdOption::new("Reset", "R"),
+ ]
+ }
+
+ 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();
+ match keyword.as_str() {
+ "S" | "SHOW" => Some(self.finish("GCSHOW")),
+ "H" | "HIDE" => Some(self.finish("GCHIDE")),
+ "R" | "RESET" => Some(self.finish("CONSTRAINTBAR_RESET")),
+ _ => None,
+ }
+ }
+
+ fn on_point(&mut self, _point: DVec3) -> CmdResult {
+ CmdResult::NeedPoint
+ }
+
+ fn on_enter(&mut self) -> CmdResult {
+ self.finish("GCSHOW")
+ }
+
+ fn on_escape(&mut self) -> CmdResult {
+ CmdResult::Cancel
+ }
+}
+
+inventory::submit!(crate::command::CommandRegistration {
+ names: &["CONSTRAINTBAR"]
+});
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn relaunch(result: CmdResult) -> (String, Vec) {
+ let CmdResult::Relaunch(command, handles) = result else {
+ panic!("expected relaunch");
+ };
+ (command, handles)
+ }
+
+ #[test]
+ fn enter_defaults_to_show_and_keeps_the_selection() {
+ let handles = vec![Handle::new(2), Handle::new(5)];
+ let mut command = ConstraintBarOptionCommand::new(handles.clone());
+
+ assert_eq!(relaunch(command.on_enter()), ("GCSHOW".to_string(), handles));
+ }
+
+ #[test]
+ fn hide_and_reset_route_to_their_apply_commands() {
+ let handle = Handle::new(7);
+ let mut hide = ConstraintBarOptionCommand::new(vec![handle]);
+ assert_eq!(
+ relaunch(hide.on_text_input("hide").unwrap()),
+ ("GCHIDE".to_string(), vec![handle])
+ );
+
+ let mut reset = ConstraintBarOptionCommand::new(vec![handle]);
+ assert_eq!(
+ relaunch(reset.on_text_input("r").unwrap()),
+ ("CONSTRAINTBAR_RESET".to_string(), vec![handle])
+ );
+ }
+}
diff --git a/src/modules/parametric/geom_constraint.rs b/src/modules/parametric/geom_constraint.rs
index 199912c87..88a8dc499 100644
--- a/src/modules/parametric/geom_constraint.rs
+++ b/src/modules/parametric/geom_constraint.rs
@@ -14,14 +14,14 @@ impl GeomConstraintCommand {
fn dispatch(keyword: &str) -> Option<&'static str> {
Some(match keyword {
- "H" | "HORIZONTAL" => "HCONSTRAINT",
+ "H" | "HORIZONTAL" => "GCHORIZONTAL",
"V" | "VERTICAL" => "VCONSTRAINT",
"P" | "PERPENDICULAR" => "GCPERPENDICULAR",
"PA" | "PARALLEL" => "PCONSTRAINT",
"T" | "TANGENT" => "TCONSTRAINT",
- "SM" | "SMOOTH" => "SMOOTHCONSTRAINT",
+ "SM" | "SMOOTH" => "GCSMOOTH",
"C" | "COINCIDENT" => "GCCOINCIDENT",
- "CON" | "CONCENTRIC" => "NCONSTRAINT",
+ "CON" | "CONCENTRIC" => "GCCONCENTRIC",
"COL" | "COLLINEAR" | "COLINEAR" => "LCONSTRAINT",
"SY" | "SYMMETRIC" => "SYCONSTRAINT",
"E" | "EQUAL" => "ECONSTRAINT",
diff --git a/src/modules/parametric/horizontal.rs b/src/modules/parametric/horizontal.rs
new file mode 100644
index 000000000..2b1b9f4ba
--- /dev/null
+++ b/src/modules/parametric/horizontal.rs
@@ -0,0 +1,343 @@
+use acadrust::{EntityType, Handle};
+use glam::DVec3;
+
+use crate::command::{
+ CadCommand, CmdOption, CmdResult, CoincidentPick, HorizontalConstraintSelection, WorkingPlane,
+};
+use crate::scene::parametric_constraints::{
+ directional_axis_endpoints, is_parametric_point_near, ParametricRef,
+};
+
+#[derive(Clone, Copy)]
+enum Step {
+ ObjectOrTwoPoints,
+ FirstPoint,
+ SecondPoint(CoincidentPick),
+}
+
+/// Interactive front end for the Horizontal geometric constraint.
+pub struct HorizontalConstraintCommand {
+ step: Step,
+ picked_entity: Option,
+ plane: WorkingPlane,
+}
+
+impl HorizontalConstraintCommand {
+ pub fn new() -> Self {
+ Self {
+ step: Step::ObjectOrTwoPoints,
+ picked_entity: None,
+ plane: WorkingPlane::default(),
+ }
+ }
+
+ fn point_pick(handle: Option, point: DVec3) -> CoincidentPick {
+ CoincidentPick {
+ handle,
+ point,
+ whole_curve: false,
+ }
+ }
+
+ fn direction(&self) -> acadrust::types::Vector3 {
+ let direction = self.plane.x.normalize_or(DVec3::X);
+ acadrust::types::Vector3::new(direction.x, direction.y, direction.z)
+ }
+
+ fn invalid_object() -> CmdResult {
+ CmdResult::ReportError(
+ "Horizontal: select a line, straight polyline segment, text, MText, or an ellipse axis."
+ .to_string(),
+ )
+ }
+
+ fn invalid_point() -> CmdResult {
+ CmdResult::ReportError(
+ "Horizontal: select an endpoint, center, midpoint, or polyline vertex.".to_string(),
+ )
+ }
+
+ fn segment_is_straight(entity: &EntityType, index: usize) -> bool {
+ match entity {
+ EntityType::LwPolyline(polyline) => polyline
+ .vertices
+ .get(index)
+ .is_some_and(|vertex| vertex.bulge.abs() <= 1.0e-12),
+ EntityType::Polyline2D(polyline) => polyline
+ .vertices
+ .get(index)
+ .is_some_and(|vertex| vertex.bulge.abs() <= 1.0e-12),
+ _ => false,
+ }
+ }
+
+ fn distance_to_axis(point: DVec3, endpoints: [acadrust::types::Vector3; 2]) -> f64 {
+ cadkernel::space::Vec3::from(point.to_array())
+ .distance_to_line(
+ cadkernel::space::Vec3::new(endpoints[0].x, endpoints[0].y, endpoints[0].z),
+ cadkernel::space::Vec3::new(endpoints[1].x, endpoints[1].y, endpoints[1].z),
+ )
+ .unwrap_or(f64::INFINITY)
+ }
+
+ fn ellipse_reference(
+ entity: &EntityType,
+ handle: Handle,
+ point: DVec3,
+ ) -> Option {
+ let major = ParametricRef::ellipse_major_axis(handle);
+ let minor = ParametricRef::ellipse_minor_axis(handle);
+ let major_distance =
+ Self::distance_to_axis(point, directional_axis_endpoints(entity, major)?);
+ let minor_distance =
+ Self::distance_to_axis(point, directional_axis_endpoints(entity, minor)?);
+ Some(if minor_distance < major_distance {
+ minor
+ } else {
+ major
+ })
+ }
+
+ fn picked_reference(
+ entity: &EntityType,
+ handle: Handle,
+ point: DVec3,
+ ) -> Option {
+ match entity {
+ EntityType::Line(_) | EntityType::Ray(_) | EntityType::XLine(_) => {
+ Some(ParametricRef::whole(handle))
+ }
+ EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => {
+ let (source, _, _) =
+ crate::scene::centerline::picked_source(entity, handle, point)?;
+ let index = usize::try_from(source.segment_index).ok()?;
+ Self::segment_is_straight(entity, index)
+ .then_some(ParametricRef::segment(handle, index))
+ }
+ EntityType::Text(_) | EntityType::MText(_) => {
+ Some(ParametricRef::text_baseline(handle))
+ }
+ EntityType::Ellipse(_) => Self::ellipse_reference(entity, handle, point),
+ _ => None,
+ }
+ }
+
+ pub fn preselected_reference(entity: &EntityType, handle: Handle) -> Option {
+ match entity {
+ EntityType::Line(_) | EntityType::Ray(_) | EntityType::XLine(_) => {
+ Some(ParametricRef::whole(handle))
+ }
+ EntityType::Text(_) | EntityType::MText(_) => {
+ Some(ParametricRef::text_baseline(handle))
+ }
+ EntityType::Ellipse(_) => Some(ParametricRef::ellipse_major_axis(handle)),
+ EntityType::LwPolyline(polyline) => {
+ let count = if polyline.is_closed {
+ polyline.vertices.len()
+ } else {
+ polyline.vertices.len().saturating_sub(1)
+ };
+ (count == 1 && Self::segment_is_straight(entity, 0))
+ .then_some(ParametricRef::segment(handle, 0))
+ }
+ EntityType::Polyline2D(polyline) => {
+ let count = if polyline.is_closed() {
+ polyline.vertices.len()
+ } else {
+ polyline.vertices.len().saturating_sub(1)
+ };
+ (count == 1 && Self::segment_is_straight(entity, 0))
+ .then_some(ParametricRef::segment(handle, 0))
+ }
+ _ => None,
+ }
+ }
+
+ fn finish_points(&self, first: CoincidentPick, second: CoincidentPick) -> CmdResult {
+ CmdResult::AddHorizontalConstraint {
+ selection: HorizontalConstraintSelection::Points(first, second),
+ direction: self.direction(),
+ label: "Horizontal constraint",
+ }
+ }
+}
+
+impl CadCommand for HorizontalConstraintCommand {
+ fn name(&self) -> &'static str {
+ "GCHORIZONTAL"
+ }
+
+ fn prompt(&self) -> String {
+ match self.step {
+ Step::ObjectOrTwoPoints => {
+ "GCHORIZONTAL Select an object or [2Points] <2Points>:".to_string()
+ }
+ Step::FirstPoint => "GCHORIZONTAL Select first point:".to_string(),
+ Step::SecondPoint(_) => "GCHORIZONTAL Select second point:".to_string(),
+ }
+ }
+
+ fn options(&self) -> Vec {
+ matches!(self.step, Step::ObjectOrTwoPoints)
+ .then(|| vec![CmdOption::new("2Points", "2P")])
+ .unwrap_or_default()
+ }
+
+ fn wants_text_input(&self) -> bool {
+ true
+ }
+
+ fn point_step_accepts_keywords(&self) -> bool {
+ matches!(self.step, Step::ObjectOrTwoPoints)
+ }
+
+ fn on_text_input(&mut self, text: &str) -> Option {
+ let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase();
+ if matches!(self.step, Step::ObjectOrTwoPoints)
+ && matches!(keyword.as_str(), "2" | "2P" | "2POINT" | "2POINTS")
+ {
+ self.step = Step::FirstPoint;
+ Some(CmdResult::NeedPoint)
+ } else {
+ None
+ }
+ }
+
+ fn set_working_plane(&mut self, plane: WorkingPlane) {
+ self.plane = plane;
+ }
+
+ fn needs_entity_pick(&self) -> bool {
+ true
+ }
+
+ fn entity_pick_accepts_points(&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 {
+ if handle.is_null() {
+ return self.on_point(point);
+ }
+ let Some(entity) = self.picked_entity.take() else {
+ return CmdResult::NeedPoint;
+ };
+ match self.step {
+ Step::ObjectOrTwoPoints => {
+ let Some(reference) = Self::picked_reference(&entity, handle, point) else {
+ return Self::invalid_object();
+ };
+ CmdResult::AddHorizontalConstraint {
+ selection: HorizontalConstraintSelection::Reference(reference),
+ direction: self.direction(),
+ label: "Horizontal constraint",
+ }
+ }
+ Step::FirstPoint | Step::SecondPoint(_) => {
+ let world = acadrust::types::Vector3::new(point.x, point.y, point.z);
+ if !is_parametric_point_near(&entity, world) {
+ return Self::invalid_point();
+ }
+ let pick = Self::point_pick(Some(handle), point);
+ match self.step {
+ Step::FirstPoint => {
+ self.step = Step::SecondPoint(pick);
+ CmdResult::NeedPoint
+ }
+ Step::SecondPoint(first) => self.finish_points(first, pick),
+ Step::ObjectOrTwoPoints => unreachable!(),
+ }
+ }
+ }
+ }
+
+ fn on_point(&mut self, point: DVec3) -> CmdResult {
+ let pick = Self::point_pick(None, point);
+ match self.step {
+ Step::ObjectOrTwoPoints | Step::FirstPoint => {
+ self.step = Step::SecondPoint(pick);
+ CmdResult::NeedPoint
+ }
+ Step::SecondPoint(first) => self.finish_points(first, pick),
+ }
+ }
+
+ fn on_enter(&mut self) -> CmdResult {
+ if matches!(self.step, Step::ObjectOrTwoPoints) {
+ self.step = Step::FirstPoint;
+ CmdResult::NeedPoint
+ } else {
+ 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_captures_the_working_x_direction() {
+ let handle = Handle::new(7);
+ let mut command = HorizontalConstraintCommand::new();
+ command.set_working_plane(WorkingPlane::new(DVec3::ZERO, DVec3::Y, DVec3::NEG_X));
+ command.inject_picked_entity(EntityType::Line(Line::from_points(
+ Vector3::ZERO,
+ Vector3::new(2.0, 3.0, 0.0),
+ )));
+
+ let CmdResult::AddHorizontalConstraint {
+ selection: HorizontalConstraintSelection::Reference(reference),
+ direction,
+ ..
+ } = command.on_entity_pick(handle, DVec3::ZERO)
+ else {
+ panic!("object pick must create the constraint");
+ };
+ assert_eq!(reference, ParametricRef::whole(handle));
+ assert!((direction - Vector3::UNIT_Y).length() < 1.0e-12);
+ }
+
+ #[test]
+ fn enter_starts_the_two_point_flow() {
+ let mut command = HorizontalConstraintCommand::new();
+ assert!(matches!(command.on_enter(), CmdResult::NeedPoint));
+ assert!(matches!(
+ command.on_point(DVec3::new(1.0, 2.0, 0.0)),
+ CmdResult::NeedPoint
+ ));
+ let CmdResult::AddHorizontalConstraint {
+ selection: HorizontalConstraintSelection::Points(first, second),
+ direction,
+ ..
+ } = command.on_point(DVec3::new(4.0, 5.0, 0.0))
+ else {
+ panic!("second point must create the constraint");
+ };
+ assert_eq!(first.point, DVec3::new(1.0, 2.0, 0.0));
+ assert_eq!(second.point, DVec3::new(4.0, 5.0, 0.0));
+ assert!((direction - Vector3::UNIT_X).length() < 1.0e-12);
+ }
+}
+
+inventory::submit!(crate::command::CommandRegistration {
+ names: &["GCHORIZONTAL"]
+});
diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs
index 056c17686..f40701cef 100644
--- a/src/modules/parametric/mod.rs
+++ b/src/modules/parametric/mod.rs
@@ -3,26 +3,35 @@
//! resulting constraints through the geometry kernel.
mod coincident;
+mod concentric;
+mod constraint_bar;
mod equal_distance;
mod geom_constraint;
+#[path = "horizontal.rs"]
+mod horizontal_command;
#[path = "perpendicular.rs"]
mod perpendicular_command;
mod point_on_entity;
+mod smooth;
#[path = "tangent.rs"]
mod tangent_command;
mod tools;
mod value;
pub use coincident::{coincident_tool, CoincidentConstraintCommand};
+pub use concentric::ConcentricConstraintCommand;
+pub use constraint_bar::ConstraintBarOptionCommand;
pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand};
pub use geom_constraint::GeomConstraintCommand;
+pub use horizontal_command::HorizontalConstraintCommand;
pub use perpendicular_command::{PerpendicularConstraintCommand, PerpendicularPick};
pub use point_on_entity::{
center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand,
};
+pub use smooth::SmoothConstraintCommand;
pub use tangent_command::TangentConstraintCommand;
pub use tools::{
- colinear, concentric, equal, fixed, horizontal, normal, parallel, perpendicular, symmetric,
- tangent, vertical,
+ colinear, concentric as concentric_tool, equal, fixed, horizontal, normal, parallel,
+ perpendicular, symmetric, tangent, vertical,
};
pub use value::{
angle_tool, dimensional_tools, distance_tool, AngleConstraintCommand,
@@ -62,10 +71,10 @@ impl CadModule for ParametricModule {
RibbonItem::LargeTool(colinear::tool()),
RibbonItem::LargeTool(perpendicular::tool()),
RibbonItem::LargeTool(command(
- "SMOOTHCONSTRAINT", "Smooth",
+ "GCSMOOTH", "Smooth",
include_bytes!("../../../assets/icons/constrain/smooth.svg"),
)),
- RibbonItem::LargeTool(concentric::tool()),
+ RibbonItem::LargeTool(concentric_tool::tool()),
RibbonItem::LargeTool(horizontal::tool()),
RibbonItem::LargeTool(symmetric::tool()),
RibbonItem::LargeTool(fixed::tool()),
@@ -136,7 +145,7 @@ impl CadModule for ParametricModule {
inventory::submit!(crate::command::CommandRegistration {
names: &[
- "AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "SMOOTHCONSTRAINT", "GCSHOW", "GCHIDE", "GCRESET",
+ "AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "GCSMOOTH", "GCSHOW", "GCHIDE", "GCRESET",
"GCSHOWALL", "GCHIDEALL", "DCSHOW", "DCHIDE", "DCSHOWALL", "DCHIDEALL",
"DCCONVERT", "DELCONSTRAINT",
]
@@ -168,8 +177,8 @@ mod tests {
groups[0].tools.iter().map(item_id).collect::>(),
[
"AUTOCONSTRAIN", "CCONSTRAINT", "PCONSTRAINT", "TCONSTRAINT",
- "LCONSTRAINT", "QCONSTRAINT", "SMOOTHCONSTRAINT", "NCONSTRAINT",
- "HCONSTRAINT", "SYCONSTRAINT", "FXCONSTRAINT", "VCONSTRAINT",
+ "LCONSTRAINT", "QCONSTRAINT", "GCSMOOTH", "GCCONCENTRIC",
+ "GCHORIZONTAL", "SYCONSTRAINT", "FXCONSTRAINT", "VCONSTRAINT",
"ECONSTRAINT", "GCVISIBILITY", "GCSHOWALL", "GCHIDEALL",
]
);
diff --git a/src/modules/parametric/perpendicular.rs b/src/modules/parametric/perpendicular.rs
index b69b5d7d2..19f4d99ba 100644
--- a/src/modules/parametric/perpendicular.rs
+++ b/src/modules/parametric/perpendicular.rs
@@ -33,24 +33,37 @@ impl PerpendicularConstraintCommand {
}
fn distance_to_axis(point: DVec3, endpoints: [acadrust::types::Vector3; 2]) -> f64 {
- let start = DVec3::new(endpoints[0].x, endpoints[0].y, endpoints[0].z);
- let end = DVec3::new(endpoints[1].x, endpoints[1].y, endpoints[1].z);
- let direction = end - start;
- if direction.length_squared() <= 1.0e-24 {
- return f64::INFINITY;
- }
- (point - start).cross(direction.normalize()).length()
+ cadkernel::space::Vec3::from(point.to_array())
+ .distance_to_line(
+ cadkernel::space::Vec3::new(endpoints[0].x, endpoints[0].y, endpoints[0].z),
+ cadkernel::space::Vec3::new(endpoints[1].x, endpoints[1].y, endpoints[1].z),
+ )
+ .unwrap_or(f64::INFINITY)
}
- fn ellipse_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option {
+ fn ellipse_reference(
+ entity: &EntityType,
+ handle: Handle,
+ point: DVec3,
+ ) -> Option {
let major = ParametricRef::ellipse_major_axis(handle);
let minor = ParametricRef::ellipse_minor_axis(handle);
- let major_distance = Self::distance_to_axis(point, directional_axis_endpoints(entity, major)?);
- let minor_distance = Self::distance_to_axis(point, directional_axis_endpoints(entity, minor)?);
- Some(if minor_distance < major_distance { minor } else { major })
+ let major_distance =
+ Self::distance_to_axis(point, directional_axis_endpoints(entity, major)?);
+ let minor_distance =
+ Self::distance_to_axis(point, directional_axis_endpoints(entity, minor)?);
+ Some(if minor_distance < major_distance {
+ minor
+ } else {
+ major
+ })
}
- fn picked_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option {
+ fn picked_reference(
+ entity: &EntityType,
+ handle: Handle,
+ point: DVec3,
+ ) -> Option {
let (reference, fixed_reference, start_reference) = match entity {
EntityType::Line(_) => (
ParametricRef::whole(handle),
@@ -58,7 +71,8 @@ impl PerpendicularConstraintCommand {
ParametricRef::point(handle, 0),
),
EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => {
- let (source, _, _) = crate::scene::centerline::picked_source(entity, handle, point)?;
+ let (source, _, _) =
+ crate::scene::centerline::picked_source(entity, handle, point)?;
let index = usize::try_from(source.segment_index).ok()?;
(
ParametricRef::segment(handle, index),
@@ -101,12 +115,13 @@ impl PerpendicularConstraintCommand {
} else {
polyline.vertices.len().saturating_sub(1)
};
- (segment_count == 1 && polyline.vertices.first()?.bulge.abs() <= 1.0e-12)
- .then_some(PerpendicularPick {
- reference: ParametricRef::segment(handle, 0),
- fixed_reference: ParametricRef::segment(handle, 0),
- start_reference: ParametricRef::point(handle, 0),
- })
+ let index = (0..segment_count)
+ .find(|index| polyline.vertices[*index].bulge.abs() <= 1.0e-12)?;
+ Some(PerpendicularPick {
+ reference: ParametricRef::segment(handle, index),
+ fixed_reference: ParametricRef::segment(handle, index),
+ start_reference: ParametricRef::point(handle, index as i32),
+ })
}
EntityType::Polyline2D(polyline) => {
let segment_count = if polyline.is_closed() {
@@ -114,12 +129,13 @@ impl PerpendicularConstraintCommand {
} else {
polyline.vertices.len().saturating_sub(1)
};
- (segment_count == 1 && polyline.vertices.first()?.bulge.abs() <= 1.0e-12)
- .then_some(PerpendicularPick {
- reference: ParametricRef::segment(handle, 0),
- fixed_reference: ParametricRef::segment(handle, 0),
- start_reference: ParametricRef::point(handle, 0),
- })
+ let index = (0..segment_count)
+ .find(|index| polyline.vertices[*index].bulge.abs() <= 1.0e-12)?;
+ Some(PerpendicularPick {
+ reference: ParametricRef::segment(handle, index),
+ fixed_reference: ParametricRef::segment(handle, index),
+ start_reference: ParametricRef::point(handle, index as i32),
+ })
}
_ => None,
}
@@ -232,4 +248,23 @@ mod tests {
assert_eq!(first_fixed, ParametricRef::whole(first));
assert_eq!(second_start, ParametricRef::point(second, 0));
}
+
+ #[test]
+ fn preselection_uses_the_first_straight_polyline_segment() {
+ let handle = Handle::new(11);
+ let mut polyline = acadrust::entities::LwPolyline::new();
+ polyline.vertices = vec![
+ acadrust::entities::LwVertex::with_bulge(acadrust::types::Vector2::new(0.0, 0.0), 1.0),
+ acadrust::entities::LwVertex::from_coords(4.0, 0.0),
+ acadrust::entities::LwVertex::from_coords(8.0, 0.0),
+ ];
+
+ let pick = PerpendicularConstraintCommand::preselected_reference(
+ &EntityType::LwPolyline(polyline),
+ handle,
+ )
+ .expect("straight segment");
+ assert_eq!(pick.reference, ParametricRef::segment(handle, 1));
+ assert_eq!(pick.start_reference, ParametricRef::point(handle, 1));
+ }
}
diff --git a/src/modules/parametric/smooth.rs b/src/modules/parametric/smooth.rs
new file mode 100644
index 000000000..e5a9c0dd3
--- /dev/null
+++ b/src/modules/parametric/smooth.rs
@@ -0,0 +1,261 @@
+use acadrust::{EntityType, Handle};
+use glam::DVec3;
+
+use crate::command::{CadCommand, CmdResult};
+use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef};
+use crate::t;
+
+#[derive(Clone, Copy)]
+struct SmoothTarget {
+ endpoint: ParametricRef,
+ curve: ParametricRef,
+}
+
+pub struct SmoothConstraintCommand {
+ source: Option,
+ picked_entity: Option,
+}
+
+impl SmoothConstraintCommand {
+ pub fn new() -> Self {
+ Self {
+ source: None,
+ picked_entity: None,
+ }
+ }
+
+ fn point(point: acadrust::types::Vector3) -> DVec3 {
+ DVec3::new(point.x, point.y, point.z)
+ }
+
+ fn nearest_marker(points: &[acadrust::types::Vector3], point: DVec3) -> Option {
+ points
+ .iter()
+ .enumerate()
+ .min_by(|(_, left), (_, right)| {
+ (Self::point(**left) - point)
+ .length_squared()
+ .total_cmp(&(Self::point(**right) - point).length_squared())
+ })
+ .map(|(index, _)| index as i32)
+ }
+
+ fn source_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option {
+ let EntityType::Spline(spline) = entity else {
+ return None;
+ };
+ if spline.flags.closed || spline.flags.periodic {
+ return None;
+ }
+ let points = crate::scene::dimension_assoc::source_points(entity);
+ let endpoints = [*points.first()?, *points.last()?];
+ let marker = Self::nearest_marker(&endpoints, point)?;
+ Some(ParametricRef::point(handle, marker))
+ }
+
+ fn target_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option {
+ match entity {
+ EntityType::Line(_) | EntityType::Arc(_) => {
+ let points = crate::scene::dimension_assoc::source_points(entity);
+ let endpoints = [*points.first()?, *points.last()?];
+ let marker = Self::nearest_marker(&endpoints, point)?;
+ Some(SmoothTarget {
+ endpoint: ParametricRef::point(handle, marker),
+ curve: ParametricRef::whole(handle),
+ })
+ }
+ EntityType::Spline(spline) if !spline.flags.closed && !spline.flags.periodic => {
+ let points = crate::scene::dimension_assoc::source_points(entity);
+ let endpoints = [*points.first()?, *points.last()?];
+ let marker = Self::nearest_marker(&endpoints, point)?;
+ Some(SmoothTarget {
+ endpoint: ParametricRef::point(handle, marker),
+ curve: ParametricRef::whole(handle),
+ })
+ }
+ EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => {
+ let planar = crate::entities::curve::entity_curve(entity)?;
+ let local = planar.plane.project(point.to_array())?;
+ let segments = planar.curve.segments();
+ let (segment, _) = cadkernel::geom2d::nearest_of(segments.iter(), local)?;
+ let points = crate::scene::dimension_assoc::source_points(entity);
+ let closed = match entity {
+ EntityType::LwPolyline(polyline) => polyline.is_closed,
+ EntityType::Polyline2D(polyline) => polyline.is_closed(),
+ _ => false,
+ };
+ let end = if segment + 1 < points.len() {
+ segment + 1
+ } else if closed {
+ 0
+ } else {
+ return None;
+ };
+ let candidates = [points.get(segment).copied()?, points.get(end).copied()?];
+ let side = Self::nearest_marker(&candidates, point)? as usize;
+ let vertex = if side == 0 { segment } else { end };
+ Some(SmoothTarget {
+ endpoint: ParametricRef::point(handle, vertex as i32),
+ curve: ParametricRef::segment(handle, segment),
+ })
+ }
+ _ => None,
+ }
+ }
+
+ pub fn preselected_refs(
+ first: (&EntityType, Handle),
+ second: (&EntityType, Handle),
+ ) -> Option> {
+ let source_points = crate::scene::dimension_assoc::source_points(first.0);
+ let source_ends = [*source_points.first()?, *source_points.last()?];
+ let source_point = source_ends
+ .iter()
+ .copied()
+ .min_by(|left, right| {
+ let target_points = crate::scene::dimension_assoc::source_points(second.0);
+ let distance = |source: acadrust::types::Vector3| {
+ target_points
+ .iter()
+ .map(|target| (source - *target).length_squared())
+ .fold(f64::INFINITY, f64::min)
+ };
+ distance(*left).total_cmp(&distance(*right))
+ })?;
+ let source = Self::source_reference(first.0, first.1, Self::point(source_point))?;
+
+ let target_points = crate::scene::dimension_assoc::source_points(second.0);
+ let target_point = target_points
+ .iter()
+ .copied()
+ .min_by(|left, right| {
+ (Self::point(*left) - Self::point(source_point))
+ .length_squared()
+ .total_cmp(
+ &(Self::point(*right) - Self::point(source_point)).length_squared(),
+ )
+ })?;
+ let target = Self::target_reference(second.0, second.1, Self::point(target_point))?;
+ let mut refs = vec![source, target.endpoint];
+ if target.curve.marker.is_some() {
+ refs.push(target.curve);
+ }
+ Some(refs)
+ }
+
+ fn invalid_source() -> CmdResult {
+ CmdResult::ReportError(
+ t!("Invalid first selection for Smooth. Select an endpoint of an open spline.")
+ .into_owned(),
+ )
+ }
+
+ fn invalid_target() -> CmdResult {
+ CmdResult::ReportError(
+ t!("Invalid second selection for Smooth. Select an endpoint of a line, arc, polyline segment or open spline.")
+ .into_owned(),
+ )
+ }
+}
+
+impl CadCommand for SmoothConstraintCommand {
+ fn name(&self) -> &'static str {
+ "GCSMOOTH"
+ }
+
+ fn prompt(&self) -> String {
+ if self.source.is_some() {
+ t!("GCSMOOTH Select second curve endpoint:").into_owned()
+ } else {
+ t!("GCSMOOTH Select first spline curve endpoint:").into_owned()
+ }
+ }
+
+ fn needs_entity_pick(&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 {
+ if handle.is_null() {
+ return CmdResult::NeedPoint;
+ }
+ let Some(entity) = self.picked_entity.take() else {
+ return CmdResult::NeedPoint;
+ };
+ let Some(source) = self.source else {
+ let Some(source) = Self::source_reference(&entity, handle, point) else {
+ return Self::invalid_source();
+ };
+ self.source = Some(source);
+ return CmdResult::NeedPoint;
+ };
+ let Some(target) = Self::target_reference(&entity, handle, point) else {
+ return Self::invalid_target();
+ };
+ if source.entity == target.endpoint.entity {
+ return Self::invalid_target();
+ }
+ let mut refs = vec![source, target.endpoint];
+ if target.curve.marker.is_some() {
+ refs.push(target.curve);
+ }
+ CmdResult::AddParametricConstraint {
+ kind: ConstraintKind::Smooth,
+ refs,
+ driving_param: None,
+ label: "Smooth constraint",
+ }
+ }
+
+ 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
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn polyline_target_keeps_the_picked_arc_segment_and_endpoint() {
+ let handle = Handle::new(7);
+ let mut polyline = acadrust::entities::LwPolyline::new();
+ polyline.vertices = vec![
+ acadrust::entities::LwVertex::from_coords(0.0, 0.0),
+ acadrust::entities::LwVertex::with_bulge(
+ acadrust::types::Vector2::new(5.0, 0.0),
+ 1.0,
+ ),
+ acadrust::entities::LwVertex::from_coords(10.0, 0.0),
+ ];
+
+ let target = SmoothConstraintCommand::target_reference(
+ &EntityType::LwPolyline(polyline),
+ handle,
+ DVec3::new(10.0, 0.0, 0.0),
+ )
+ .expect("arc endpoint");
+ assert_eq!(target.curve, ParametricRef::segment(handle, 1));
+ assert_eq!(target.endpoint, ParametricRef::point(handle, 2));
+ }
+}
diff --git a/src/modules/parametric/tools.rs b/src/modules/parametric/tools.rs
index 49f211727..4d2e8fd94 100644
--- a/src/modules/parametric/tools.rs
+++ b/src/modules/parametric/tools.rs
@@ -6,12 +6,12 @@ pub mod horizontal {
use super::*;
pub fn tool() -> ToolDef {
ToolDef {
- id: "HCONSTRAINT",
+ id: "GCHORIZONTAL",
label: "Horizontal",
icon: IconKind::Svg(include_bytes!(
"../../../assets/icons/constrain/horizontal.svg"
)),
- event: ModuleEvent::Command("HCONSTRAINT".to_string()),
+ event: ModuleEvent::Command("GCHORIZONTAL".to_string()),
}
}
}
@@ -88,12 +88,12 @@ pub mod concentric {
use super::*;
pub fn tool() -> ToolDef {
ToolDef {
- id: "NCONSTRAINT",
+ id: "GCCONCENTRIC",
label: "Concentric",
icon: IconKind::Svg(include_bytes!(
"../../../assets/icons/constrain/concentric.svg"
)),
- event: ModuleEvent::Command("NCONSTRAINT".to_string()),
+ event: ModuleEvent::Command("GCCONCENTRIC".to_string()),
}
}
}
@@ -160,14 +160,14 @@ pub mod symmetric {
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration {
names: &[
- "HCONSTRAINT",
+ "GCHORIZONTAL",
"VCONSTRAINT",
"PCONSTRAINT",
"QCONSTRAINT",
"GCPERPENDICULAR",
"ECONSTRAINT",
"TCONSTRAINT",
- "NCONSTRAINT",
+ "GCCONCENTRIC",
"NRCONSTRAINT",
"LCONSTRAINT",
"FXCONSTRAINT",
diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs
index 829b20cf1..485b192e9 100644
--- a/src/scene/convert/tessellate.rs
+++ b/src/scene/convert/tessellate.rs
@@ -3110,6 +3110,75 @@ mod tests {
assert_eq!(line_wire.world_width, 10.0);
}
+ #[test]
+ fn split_mixed_polyline_keeps_exact_line_intersections() {
+ let tangents = vec![
+ TangentGeom::Line {
+ p1: [-10.0, 0.0, 0.0],
+ p2: [0.0, 0.0, 0.0],
+ },
+ TangentGeom::Arc {
+ center: [0.0, 4.0, 0.0],
+ axis_x: [1.0, 0.0, 0.0],
+ axis_y: [0.0, 1.0, 0.0],
+ radius: 4.0,
+ start_angle: -std::f64::consts::FRAC_PI_2,
+ end_angle: std::f64::consts::FRAC_PI_2,
+ },
+ TangentGeom::Line {
+ p1: [0.0, 8.0, 0.0],
+ p2: [10.0, 8.0, 0.0],
+ },
+ ];
+ let vertices = vec![
+ [-10.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0],
+ [0.0, 8.0, 0.0],
+ [10.0, 8.0, 0.0],
+ ];
+ let wires = split_mixed_polyline(
+ &tangents,
+ &vertices,
+ "mixed",
+ [1.0; 4],
+ false,
+ 0.0,
+ [0.0; 8],
+ 1.0,
+ Vec::new(),
+ None,
+ true,
+ &[(0.0, 0.0); 3],
+ 0.0,
+ Vec::new(),
+ Vec::new(),
+ );
+ let line_wire = wires
+ .iter()
+ .find(|wire| {
+ wire.tangent_geoms
+ .iter()
+ .all(|geom| matches!(geom, TangentGeom::Line { .. }))
+ })
+ .expect("straight-segment wire");
+ let circle = WireModel {
+ tangent_geoms: vec![TangentGeom::PlanarCircle {
+ center: [5.0, 4.0, 0.0],
+ axis_x: [1.0, 0.0, 0.0],
+ axis_y: [0.0, 1.0, 0.0],
+ radius: 5.0,
+ }],
+ ..Default::default()
+ };
+
+ let mut points = crate::snap::exact_curve_intersections(line_wire, &circle)
+ .expect("analytical intersections");
+ points.sort_by(|left, right| left.x.total_cmp(&right.x));
+ assert_eq!(points.len(), 2, "unexpected intersections: {points:?}");
+ assert!((points[0] - glam::DVec3::new(2.0, 8.0, 0.0)).length() < 1.0e-9);
+ assert!((points[1] - glam::DVec3::new(8.0, 8.0, 0.0)).length() < 1.0e-9);
+ }
+
#[test]
fn test_split_mixed_polyline_zero_width_segments_after_tapered_segment() {
let tg_line = TangentGeom::Line {
diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs
index f4d755674..e36ca0c6d 100644
--- a/src/scene/dwg_native_constraints.rs
+++ b/src/scene/dwg_native_constraints.rs
@@ -56,8 +56,6 @@ struct GroupBuilder<'a> {
/// Real entity handles that ended up with a geometry node — what
/// `AssocGeomDependency` objects get created for, in first-touch order.
referenced_entities: Vec,
- horizontal_datum: Option,
- vertical_datum: Option,
}
impl<'a> GroupBuilder<'a> {
@@ -68,8 +66,6 @@ impl<'a> GroupBuilder<'a> {
next_node_id: FIRST_NODE_ID,
entities: FxHashMap::default(),
referenced_entities: Vec::new(),
- horizontal_datum: None,
- vertical_datum: None,
}
}
@@ -454,11 +450,17 @@ impl<'a> GroupBuilder<'a> {
return Some(*existing);
}
let segment_midpoint = ParametricRef::point(handle, marker).segment_midpoint_index();
+ let segment_center = ParametricRef::point(handle, marker).segment_center_index();
let polyline = matches!(
self.document.get_entity(handle),
Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_))
);
- let (curve_id, point_type) = if let Some(segment) = segment_midpoint {
+ let (curve_id, point_type) = if let Some(segment) = segment_center {
+ (
+ self.segment_node(handle, segment)?,
+ implicit_point_type::CENTER,
+ )
+ } else if let Some(segment) = segment_midpoint {
(
self.segment_node(handle, segment)?,
implicit_point_type::MID,
@@ -551,6 +553,9 @@ impl<'a> GroupBuilder<'a> {
if r.segment_midpoint_index().is_some() {
return self.point_node(r.entity, r.marker?);
}
+ if r.segment_center_index().is_some() {
+ return self.point_node(r.entity, r.marker?);
+ }
if let Some(segment) = r.segment_index() {
return self.segment_node(r.entity, segment);
}
@@ -653,15 +658,7 @@ impl<'a> GroupBuilder<'a> {
Some(node_id)
}
- fn datum_line(&mut self, horizontal: bool) -> i32 {
- let cached = if horizontal {
- self.horizontal_datum
- } else {
- self.vertical_datum
- };
- if let Some(node_id) = cached {
- return node_id;
- }
+ fn datum_line(&mut self, direction: Vector3) -> i32 {
let node_id = self.alloc_node_id();
self.push_node(
node_id,
@@ -670,18 +667,13 @@ impl<'a> GroupBuilder<'a> {
geometry_dependency: Handle::NULL,
geometry_node_id: node_id,
point: Vector3::ZERO,
- direction: if horizontal {
- Vector3::UNIT_X
+ direction: if direction.length_squared() > 1.0e-24 {
+ direction.normalize()
} else {
- Vector3::UNIT_Y
+ Vector3::UNIT_X
},
},
);
- if horizontal {
- self.horizontal_datum = Some(node_id);
- } else {
- self.vertical_datum = Some(node_id);
- }
node_id
}
@@ -771,7 +763,13 @@ fn constraint_node(
) -> Option {
let refs: &[ParametricRef] = &constraint.refs;
if constraint.kind == ConstraintKind::Smooth {
- let [first, second] = refs else { return None };
+ let (first, second, second_curve_ref) = match refs {
+ [first, second] => (*first, *second, ParametricRef::whole(second.entity)),
+ [first, second, second_curve] if second.entity == second_curve.entity => {
+ (*first, *second, *second_curve)
+ }
+ _ => return None,
+ };
let endpoint_parameter = |reference: ParametricRef| {
let entity = document.get_entity(reference.entity)?;
let EntityType::Spline(spline) = entity else {
@@ -791,15 +789,15 @@ fn constraint_node(
}?;
Some(Some(value))
};
- let first_parameter = endpoint_parameter(*first)?;
- let second_parameter = endpoint_parameter(*second)?;
+ let first_parameter = endpoint_parameter(first)?;
+ let second_parameter = endpoint_parameter(second)?;
if first_parameter.is_none() && second_parameter.is_none() {
return None;
}
let first_curve = builder.geometry_node(first.entity)?;
- let second_curve = builder.geometry_node(second.entity)?;
- let first_point = builder.ref_node(*first)?;
- let second_point = builder.ref_node(*second)?;
+ let second_curve = builder.ref_node(second_curve_ref)?;
+ let first_point = builder.ref_node(first)?;
+ let second_point = builder.ref_node(second)?;
let coincidence = builder.alloc_node_id();
builder.push_node(
@@ -917,7 +915,12 @@ fn constraint_node(
.map(|reference| builder.ref_node(*reference))
.collect::