Merge remote-tracking branch 'origin/main' into feat/viewport-dimensions

# Conflicts:
#	src/app/command_driver.rs
#	src/scene/mod.rs
This commit is contained in:
Christopher Overbeck committed 2026-09-15 13:53:12 -05:00
commit 369e0c9fc5
89 files changed
+27311 -11037

No files matched your search

+39 -1
View File
@@ -42,7 +42,7 @@ impl CadModule for AnnotateModule {
}
fn ribbon_groups(&self) -> &[RibbonGroup] {
use crate::modules::draw::draw::{revcloud, wipeout};
use crate::modules::draw::draw::{centerline, dimcenter, revcloud, wipeout};
static GROUPS: std::sync::OnceLock<Vec<RibbonGroup>> = std::sync::OnceLock::new();
GROUPS.get_or_init(|| {
@@ -148,6 +148,44 @@ impl CadModule for AnnotateModule {
},
],
},
// ── Centerlines ───────────────────────────────────────────────
RibbonGroup {
title: "Centerlines",
tools: vec![
RibbonItem::LargeTool(dimcenter::tool()),
RibbonItem::LargeTool(centerline::tool()),
RibbonItem::Dropdown {
id: "ANNOTATE_CENTER_ASSOCIATIVITY",
icon: crate::modules::IconKind::Svg(include_bytes!(
"../../../assets/icons/line.svg"
)),
items: vec![
(
"CENTERREASSOCIATE",
"Reassociate Center Object",
crate::modules::IconKind::Svg(include_bytes!(
"../../../assets/icons/line.svg"
)),
),
(
"CENTERDISASSOCIATE",
"Disassociate Center Object",
crate::modules::IconKind::Svg(include_bytes!(
"../../../assets/icons/line.svg"
)),
),
(
"CENTERRESET",
"Reset Center Object",
crate::modules::IconKind::Svg(include_bytes!(
"../../../assets/icons/line.svg"
)),
),
],
default: "CENTERREASSOCIATE",
},
],
},
// ── Leaders ──────────────────────────────────────────────────
RibbonGroup {
title: "Leaders",
+8
View File
@@ -304,6 +304,14 @@ impl AlignCommand {
}
inventory::submit!(crate::command::CommandRegistration { names: &["ALIGN"] }); // AlignCommand
// ALIGNLEFT/ALIGNHCENTER/ALIGNRIGHT/ALIGNTOP/ALIGNVCENTER/ALIGNBOTTOM: one-shot
// bounding-box alignment, dispatched directly (no CadCommand of their own) by
// `OpenCADStudio::align_selected_bounds` in `src/app/commands/inquiry.rs`.
inventory::submit!(crate::command::CommandRegistration {
names: &[
"ALIGNLEFT", "ALIGNHCENTER", "ALIGNRIGHT", "ALIGNTOP", "ALIGNVCENTER", "ALIGNBOTTOM",
],
});
#[cfg(test)]
mod tests {
+65
View File
@@ -927,6 +927,19 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec<EntityType> {
.collect();
}
}
if let Some((local, normal)) = crate::entities::dimension::linear_dimension_in_ocs(dim) {
let (x_axis, y_axis) =
crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z));
let plane = WorkingPlane::new(
DVec3::ZERO,
DVec3::new(x_axis.0, x_axis.1, x_axis.2),
DVec3::new(y_axis.0, y_axis.1, y_axis.2),
);
return explode_dimension(&local, doc)
.into_iter()
.map(|entity| plane.place_entity(entity))
.collect();
}
let base = dim.base();
let met = dim_metrics(dim, doc);
@@ -1659,6 +1672,58 @@ mod tests {
assert_eq!(solids, 2, "expected 2 filled (SOLID) arrowheads, got {solids}");
}
/// The line segments EXPLODE turns the dimension into.
fn exploded_segments(dim: &Dimension, doc: &CadDocument) -> Vec<(DVec3, DVec3)> {
let point = |v: Vector3| DVec3::new(v.x, v.y, v.z);
explode_dimension(dim, doc)
.into_iter()
.filter_map(|entity| match entity {
EntityType::Line(line) => Some((point(line.start), point(line.end))),
_ => None,
})
.collect()
}
/// EXPLODE of a linear dimension in a working plane off world Z leaves
/// its lines in that plane. Turned 90° about X, the dimension has to
/// explode into the same lines turned with it; its offset, now along Z,
/// used to be read in world XY only.
#[test]
fn exploding_a_linear_dimension_off_world_z_keeps_its_plane() {
use crate::command::EntityTransform;
let doc = CadDocument::new();
let mut d = DimensionLinear::new(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);
let flat = Dimension::Linear(d);
let turn = glam::DQuat::from_axis_angle(DVec3::X, std::f64::consts::FRAC_PI_2);
let expected: Vec<(DVec3, DVec3)> = exploded_segments(&flat, &doc)
.into_iter()
.map(|(a, b)| (turn * a, turn * b))
.collect();
assert!(!expected.is_empty(), "the dimension explodes into no lines");
let mut turned = EntityType::Dimension(flat);
crate::scene::view::dispatch::apply_transform(
&mut turned,
&EntityTransform::Rotate {
center: DVec3::ZERO,
axis: DVec3::X,
angle_rad: std::f64::consts::FRAC_PI_2,
},
);
let EntityType::Dimension(turned) = turned else {
panic!("dimension expected");
};
let lines = exploded_segments(&turned, &doc);
assert_eq!(lines.len(), expected.len(), "line count");
for (index, (got, want)) in lines.iter().zip(&expected).enumerate() {
assert!(
got.0.abs_diff_eq(want.0, 1e-9) && got.1.abs_diff_eq(want.1, 1e-9),
"line {index}: expected {want:?}, got {got:?}"
);
}
}
// An angular dimension must bake its swept ARC (not just two rays), else a
// saved+reloaded angular dim collapses to a V. The block should carry the
// two extension lines plus many arc chords.
+35 -2
View File
@@ -34,6 +34,8 @@ pub struct SelectObjectsCommand {
/// Whether the polygon being picked takes what it merely touches. Ignored
/// by a fence, which has no inside to speak of.
pick_crossing: bool,
/// Add the command-specific Settings keyword used by Auto Constrain.
auto_constrain_settings: bool,
}
impl SelectObjectsCommand {
@@ -47,6 +49,7 @@ impl SelectObjectsCommand {
show_options: true,
pick: None,
pick_crossing: true,
auto_constrain_settings: false,
}
}
@@ -61,6 +64,7 @@ impl SelectObjectsCommand {
show_options: false,
pick: None,
pick_crossing: true,
auto_constrain_settings: false,
}
}
@@ -75,8 +79,15 @@ impl SelectObjectsCommand {
show_options: false,
pick: None,
pick_crossing: true,
auto_constrain_settings: false,
}
}
pub fn auto_constrain(pending_cmd: &str) -> Self {
let mut command = Self::new(pending_cmd);
command.auto_constrain_settings = true;
command
}
}
impl CadCommand for SelectObjectsCommand {
@@ -101,6 +112,14 @@ impl CadCommand for SelectObjectsCommand {
.into_owned();
}
if self.commit_on_enter && !self.handles.is_empty() {
if self.auto_constrain_settings {
return t!(
"%{cmd} Select objects or [Settings] (%{count} selected, Enter to apply):",
cmd = self.prompt_cmd,
count = self.handles.len()
)
.into_owned();
}
t!(
"%{cmd} Select objects (%{count} selected, Enter to apply):",
cmd = self.prompt_cmd,
@@ -108,6 +127,13 @@ impl CadCommand for SelectObjectsCommand {
)
.into_owned()
} else {
if self.auto_constrain_settings {
return t!(
"%{cmd} Select objects or [Settings]:",
cmd = self.prompt_cmd
)
.into_owned();
}
t!("%{cmd} Select objects:", cmd = self.prompt_cmd).into_owned()
}
}
@@ -130,7 +156,10 @@ impl CadCommand for SelectObjectsCommand {
// the on-screen keyboard-less case needs.
fn options(&self) -> Vec<CmdOption> {
if self.commit_on_enter && self.show_options {
vec![
if self.auto_constrain_settings {
return vec![CmdOption::new(t!("Settings").as_ref(), "S")];
}
let options = vec![
CmdOption::new(t!("Window").as_ref(), "W"),
CmdOption::new(t!("Crossing").as_ref(), "C"),
CmdOption::new(t!("Fence").as_ref(), "F"),
@@ -141,7 +170,8 @@ impl CadCommand for SelectObjectsCommand {
CmdOption::new(t!("Remove").as_ref(), "R"),
CmdOption::new(t!("Previous").as_ref(), "P"),
CmdOption::new(t!("Last").as_ref(), "L"),
]
];
options
} else {
Vec::new()
}
@@ -181,6 +211,9 @@ impl CadCommand for SelectObjectsCommand {
return None;
}
match text.trim().to_uppercase().as_str() {
"S" | "SETTINGS" if self.auto_constrain_settings => {
Some(CmdResult::OpenAutoConstrainSettings)
}
"F" | "FENCE" => {
self.pick = Some(FencePick::fence());
Some(CmdResult::NeedPoint)
+304 -29
View File
@@ -1,20 +1,10 @@
//! CCONSTRAINT — manual Coincident constraint input.
//! unlike the rest of this module's plain select-and-click constraints,
//! Coincident addresses a *point* on an entity (an endpoint, a circle/arc
//! center), not the whole entity, so it needs two point picks instead of a
//! selection.
//!
//! A `CadCommand` has no document access (see `value.rs`'s module doc
//! comment for the same constraint on `DCONSTRAINT`/`ACONSTRAINT`), so this
//! command only accumulates the two raw points `on_point` receives and hands
//! them back as `CmdResult::AddCoincidentConstraint` — the host resolves
//! each point to a real `SketchRef` (`sketch_constraints::nearest_sketch_point`)
//! and adds the constraint, the same split `ReassociateCenterMark` already
//! uses for a similar reason.
//! Interactive Coincident input: ordered point/curve picks, repeated
//! point-to-curve placement, and Coincident-only automatic inference.
use acadrust::{EntityType, Handle};
use glam::DVec3;
use crate::command::{CadCommand, CmdResult};
use crate::command::{CadCommand, CmdOption, CmdResult, CoincidentPick};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
pub mod coincident_tool {
@@ -31,45 +21,272 @@ pub mod coincident_tool {
}
}
#[derive(Default)]
#[derive(Clone)]
enum Step {
First,
FirstObject,
Second(CoincidentPick),
PointsOnCurve {
curve: CoincidentPick,
multiple: bool,
},
AutoConstrain {
handles: Vec<Handle>,
},
}
pub struct CoincidentConstraintCommand {
first: Option<DVec3>,
step: Step,
picked_entity: Option<EntityType>,
}
impl CoincidentConstraintCommand {
pub fn new() -> Self {
Self::default()
Self {
step: Step::First,
picked_entity: None,
}
}
fn point_pick(handle: Option<Handle>, point: DVec3) -> CoincidentPick {
CoincidentPick {
handle,
point,
whole_curve: false,
}
}
fn curve_pick(handle: Handle, point: DVec3) -> CoincidentPick {
CoincidentPick {
handle: Some(handle),
point,
whole_curve: true,
}
}
fn picked_constraint_point(entity: &EntityType, point: DVec3) -> bool {
crate::scene::parametric_constraints::is_parametric_point_near(
entity,
acadrust::types::Vector3::new(point.x, point.y, point.z),
)
}
fn valid_curve(entity: &EntityType) -> bool {
matches!(
entity,
EntityType::Line(_)
| EntityType::LwPolyline(_)
| EntityType::Polyline2D(_)
| EntityType::Circle(_)
| EntityType::Arc(_)
| EntityType::Ellipse(_)
| EntityType::Spline(_)
)
}
}
impl CadCommand for CoincidentConstraintCommand {
fn name(&self) -> &'static str {
"CCONSTRAINT"
"GCCOINCIDENT"
}
fn prompt(&self) -> String {
if self.first.is_none() {
"COINCIDENT Specify first point:".to_string()
} else {
"COINCIDENT Specify second point:".to_string()
match self.step.clone() {
Step::First => {
"COINCIDENT Select first point or [Object/Autoconstrain] <Object>:".to_string()
}
Step::FirstObject => "COINCIDENT Select object:".to_string(),
Step::Second(_) => "COINCIDENT Select second point or object:".to_string(),
Step::PointsOnCurve { multiple: false, .. } => {
"COINCIDENT Select point or [Multiple]:".to_string()
}
Step::PointsOnCurve { multiple: true, .. } => {
"COINCIDENT Select points to coincide with the first object (Enter = done):"
.to_string()
}
Step::AutoConstrain { handles } if handles.is_empty() => {
"COINCIDENT Select objects:".to_string()
}
Step::AutoConstrain { handles } => format!(
"COINCIDENT Select objects ({} selected, Enter = apply):",
handles.len()
),
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match self.first {
None => {
self.first = Some(pt);
match &self.step {
Step::First => {
self.step = Step::Second(Self::point_pick(None, pt));
CmdResult::NeedPoint
}
Some(first) => CmdResult::AddCoincidentConstraint {
point_a: first,
point_b: pt,
Step::FirstObject => CmdResult::NeedPoint,
Step::Second(first) => CmdResult::AddCoincidentConstraint {
first: *first,
second: Self::point_pick(None, pt),
multiple: false,
label: "Coincident constraint",
},
Step::PointsOnCurve { curve, multiple } => CmdResult::AddCoincidentConstraint {
first: *curve,
second: Self::point_pick(None, pt),
multiple: *multiple,
label: "Coincident constraint",
},
Step::AutoConstrain { .. } => CmdResult::NeedPoint,
}
}
fn options(&self) -> Vec<CmdOption> {
match self.step.clone() {
Step::First => vec![
CmdOption::new("Object", "O"),
CmdOption::new("Autoconstrain", "A"),
],
Step::PointsOnCurve { multiple: false, .. } => vec![
CmdOption::new("Point", "P"),
CmdOption::new("Multiple", "M"),
],
Step::PointsOnCurve { multiple: true, .. } => vec![CmdOption::enter("Done")],
_ => Vec::new(),
}
}
fn wants_text_input(&self) -> bool {
true
}
fn point_step_accepts_keywords(&self) -> bool {
!matches!(&self.step, Step::AutoConstrain { .. })
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let keyword = text
.trim()
.trim_start_matches('_')
.to_ascii_uppercase();
match (&mut self.step, keyword.as_str()) {
(Step::First, "O" | "OBJECT") => {
self.step = Step::FirstObject;
Some(CmdResult::NeedPoint)
}
(Step::First, "A" | "AUTOCONSTRAIN") => {
self.step = Step::AutoConstrain { handles: Vec::new() };
Some(CmdResult::NeedPoint)
}
(Step::PointsOnCurve { multiple, .. }, "M" | "MULTIPLE") => {
*multiple = true;
Some(CmdResult::NeedPoint)
}
(Step::PointsOnCurve { .. }, "P" | "POINT") => Some(CmdResult::NeedPoint),
_ => None,
}
}
fn needs_entity_pick(&self) -> bool {
!matches!(&self.step, Step::AutoConstrain { .. })
}
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 CmdResult::NeedPoint;
}
let is_point = self
.picked_entity
.as_ref()
.is_some_and(|entity| Self::picked_constraint_point(entity, point));
let is_curve = self
.picked_entity
.as_ref()
.is_some_and(Self::valid_curve);
self.picked_entity = None;
match self.step.clone() {
Step::FirstObject if is_curve => {
self.step = Step::PointsOnCurve {
curve: Self::curve_pick(handle, point),
multiple: false,
};
CmdResult::NeedPoint
}
Step::First if is_point => {
self.step = Step::Second(Self::point_pick(Some(handle), point));
CmdResult::NeedPoint
}
Step::First if is_curve => {
self.step = Step::PointsOnCurve {
curve: Self::curve_pick(handle, point),
multiple: false,
};
CmdResult::NeedPoint
}
Step::Second(first) if is_point => CmdResult::AddCoincidentConstraint {
first,
second: Self::point_pick(Some(handle), point),
multiple: false,
label: "Coincident constraint",
},
Step::Second(first) if is_curve => CmdResult::AddCoincidentConstraint {
first,
second: Self::curve_pick(handle, point),
multiple: false,
label: "Coincident constraint",
},
Step::PointsOnCurve { curve, multiple } if is_point => {
CmdResult::AddCoincidentConstraint {
first: curve,
second: Self::point_pick(Some(handle), point),
multiple,
label: "Coincident constraint",
}
}
_ => CmdResult::NeedPoint,
}
}
fn is_selection_gathering(&self) -> bool {
matches!(&self.step, Step::AutoConstrain { .. })
}
fn selection_forces_add(&self) -> bool {
true
}
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
if let Step::AutoConstrain { handles: selected } = &mut self.step {
*selected = handles;
}
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
if matches!(&self.step, Step::First) {
self.step = Step::FirstObject;
return CmdResult::NeedPoint;
}
if let Step::AutoConstrain { handles } = &self.step {
if !handles.is_empty() {
return CmdResult::AddAutoCoincidentConstraints {
handles: handles.clone(),
};
}
}
CmdResult::Cancel
}
@@ -80,5 +297,63 @@ impl CadCommand for CoincidentConstraintCommand {
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration {
names: &["CCONSTRAINT"]
names: &["CCONSTRAINT", "GCCOINCIDENT"]
});
#[cfg(test)]
mod tests {
use super::*;
use acadrust::types::Vector3;
#[test]
fn multiple_keeps_the_first_curve_for_repeated_points() {
let mut command = CoincidentConstraintCommand::new();
assert!(matches!(
command.on_text_input("Object"),
Some(CmdResult::NeedPoint)
));
command.inject_picked_entity(EntityType::Line(
acadrust::entities::Line::from_points(Vector3::ZERO, Vector3::UNIT_X),
));
let handle = Handle::new(7);
assert!(matches!(
command.on_entity_pick(handle, DVec3::ZERO),
CmdResult::NeedPoint
));
assert!(matches!(
command.on_text_input("Multiple"),
Some(CmdResult::NeedPoint)
));
assert!(matches!(
command.on_point(DVec3::X),
CmdResult::AddCoincidentConstraint {
first: CoincidentPick {
handle: Some(found),
whole_curve: true,
..
},
multiple: true,
..
} if found == handle
));
}
#[test]
fn automatic_mode_returns_the_completed_selection() {
let mut command = CoincidentConstraintCommand::new();
assert!(matches!(
command.on_text_input("Autoconstrain"),
Some(CmdResult::NeedPoint)
));
let handles = vec![Handle::new(3), Handle::new(5)];
assert!(matches!(
command.on_selection_complete(handles.clone()),
CmdResult::NeedPoint
));
assert!(matches!(
command.on_enter(),
CmdResult::AddAutoCoincidentConstraints { handles: found } if found == handles
));
}
}
+105
View File
@@ -0,0 +1,105 @@
use glam::DVec3;
use crate::command::{CadCommand, CmdOption, CmdResult};
/// Command-line front end for the standard geometric-constraint family.
/// Each choice is handed to the existing focused command so ribbon and typed
/// entry use one implementation.
pub struct GeomConstraintCommand;
impl GeomConstraintCommand {
pub fn new() -> Self {
Self
}
fn dispatch(keyword: &str) -> Option<&'static str> {
Some(match keyword {
"H" | "HORIZONTAL" => "HCONSTRAINT",
"V" | "VERTICAL" => "VCONSTRAINT",
"P" | "PERPENDICULAR" => "QCONSTRAINT",
"PA" | "PARALLEL" => "PCONSTRAINT",
"T" | "TANGENT" => "TCONSTRAINT",
"SM" | "SMOOTH" => "SMOOTHCONSTRAINT",
"C" | "COINCIDENT" => "GCCOINCIDENT",
"CON" | "CONCENTRIC" => "NCONSTRAINT",
"COL" | "COLLINEAR" | "COLINEAR" => "LCONSTRAINT",
"SY" | "SYMMETRIC" => "SYCONSTRAINT",
"E" | "EQUAL" => "ECONSTRAINT",
"F" | "FIX" => "FXCONSTRAINT",
_ => return None,
})
}
}
impl CadCommand for GeomConstraintCommand {
fn name(&self) -> &'static str {
"GEOMCONSTRAINT"
}
fn prompt(&self) -> String {
"GEOMCONSTRAINT Enter constraint type [Horizontal/Vertical/Perpendicular/Parallel/Tangent/Smooth/Coincident/Concentric/Collinear/Symmetric/Equal/Fix] <Coincident>:".to_string()
}
fn options(&self) -> Vec<CmdOption> {
vec![
CmdOption::new("Horizontal", "H"),
CmdOption::new("Vertical", "V"),
CmdOption::new("Perpendicular", "P"),
CmdOption::new("Parallel", "PA"),
CmdOption::new("Tangent", "T"),
CmdOption::new("Smooth", "SM"),
CmdOption::new("Coincident", "C"),
CmdOption::new("Concentric", "CON"),
CmdOption::new("Collinear", "COL"),
CmdOption::new("Symmetric", "SY"),
CmdOption::new("Equal", "E"),
CmdOption::new("Fix", "F"),
]
}
fn wants_text_input(&self) -> bool {
true
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let keyword = text
.trim()
.trim_start_matches('_')
.to_ascii_uppercase();
Self::dispatch(&keyword).map(|command| CmdResult::Dispatch(command.to_string()))
}
fn on_point(&mut self, _point: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Dispatch("GCCOINCIDENT".to_string())
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
inventory::submit!(crate::command::CommandRegistration {
names: &["GEOMCONSTRAINT"]
});
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chooser_dispatches_an_existing_constraint_command() {
let mut command = GeomConstraintCommand::new();
assert!(matches!(
command.on_text_input("Parallel"),
Some(CmdResult::Dispatch(found)) if found == "PCONSTRAINT"
));
assert!(matches!(
command.on_enter(),
CmdResult::Dispatch(found) if found == "GCCOINCIDENT"
));
}
}
+3 -1
View File
@@ -4,11 +4,13 @@
mod coincident;
mod equal_distance;
mod geom_constraint;
mod point_on_entity;
mod tools;
mod value;
pub use coincident::{coincident_tool, CoincidentConstraintCommand};
pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand};
pub use geom_constraint::GeomConstraintCommand;
pub use point_on_entity::{
center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand,
};
@@ -128,7 +130,7 @@ impl CadModule for ParametricModule {
inventory::submit!(crate::command::CommandRegistration {
names: &[
"AUTOCONSTRAIN", "SMOOTHCONSTRAINT", "GCSHOW", "GCHIDE", "GCRESET",
"AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "SMOOTHCONSTRAINT", "GCSHOW", "GCHIDE", "GCRESET",
"GCSHOWALL", "GCHIDEALL", "DCSHOW", "DCHIDE", "DCSHOWALL", "DCHIDEALL",
"DCCONVERT", "DELCONSTRAINT",
]
+2 -2
View File
@@ -7,7 +7,7 @@
//! endpoint) so it doesn't need interactive picking; the point side does
//! (which of a line's two endpoints, or some other addressable point), so
//! this command accumulates just that one point pick and hands it back for
//! the host to resolve via `nearest_sketch_point` — the same split
//! the host to resolve via `nearest_parametric_point` — the same split
//! `CoincidentConstraintCommand` uses and for the same reason (`CadCommand`
//! has no document access).
@@ -15,7 +15,7 @@ use glam::DVec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::sketch_constraints::ConstraintKind;
use crate::scene::parametric_constraints::ConstraintKind;
use acadrust::types::Handle;
pub mod center_point_tool {
+4 -10
View File
@@ -64,9 +64,7 @@ pub mod equal {
ToolDef {
id: "ECONSTRAINT",
label: "Equal",
icon: IconKind::Svg(include_bytes!(
"../../../assets/icons/constrain/equal.svg"
)),
icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/equal.svg")),
event: ModuleEvent::Command("ECONSTRAINT".to_string()),
}
}
@@ -105,7 +103,7 @@ pub mod concentric {
/// "the line passes through the circle's center" (a circle's radius is
/// always normal to its own tangent), so it's built from the same
/// `PointOnLine` primitive `PointOnCurve` already uses for a point-on-line
/// case (`sketch_solve.rs`). Distinct from `Perpendicular`, which only
/// case (`parametric_solve.rs`). Distinct from `Perpendicular`, which only
/// covers line-to-line.
pub mod normal {
use super::*;
@@ -113,9 +111,7 @@ pub mod normal {
ToolDef {
id: "NRCONSTRAINT",
label: "Normal",
icon: IconKind::Svg(include_bytes!(
"../../../assets/icons/constrain/normal.svg"
)),
icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/normal.svg")),
event: ModuleEvent::Command("NRCONSTRAINT".to_string()),
}
}
@@ -141,9 +137,7 @@ pub mod fixed {
ToolDef {
id: "FXCONSTRAINT",
label: "Fixed",
icon: IconKind::Svg(include_bytes!(
"../../../assets/icons/constrain/fixed.svg"
)),
icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/fixed.svg")),
event: ModuleEvent::Command("FXCONSTRAINT".to_string()),
}
}
+140 -155
View File
@@ -4,8 +4,8 @@
//!
//! These commands don't solve anything themselves. A `CadCommand`'s `on_text_input` only
//! gets `&mut self` — no document access — so it can't add a
//! `SketchConstraint` to the scene directly; instead these commands hand the
//! typed value back as `CmdResult::AddSketchConstraint`, and the host (which
//! `ParametricConstraint` to the scene directly; instead these commands hand the
//! typed value back as `CmdResult::AddParametricConstraint`, and the host (which
//! does have `&mut Scene`) adds the record and solves it via the same
//! `Scene::bump_entities` path any later edit to these entities will use too.
//! `default_value` (what the prompt shows in `<...>`, and what a bare Enter
@@ -19,7 +19,7 @@ use glam::DVec3;
use crate::command::{CadCommand, CmdResult, InputKind};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::named_parameters::DrivingValue;
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef};
use crate::scene::Scene;
/// Recognizes a typed prompt token as either a numeric literal or a
@@ -28,7 +28,7 @@ use crate::scene::Scene;
/// ::new`, which already has `&Scene`) since `on_text_input` itself has no
/// document access — the same constraint `default_value` already works
/// around. Only *existence* is checked here; the actual value is resolved
/// fresh at solve time (`sketch_solve::build_constraint`), consistent with
/// fresh at solve time (`parametric_solve::build_constraint`), consistent with
/// this project's "no incremental/cached resolution" approach throughout.
fn parse_driving_value(text: &str, known_names: &[String]) -> Option<DrivingValue> {
let text = text.trim();
@@ -64,17 +64,70 @@ pub mod dimensional_tools {
use super::*;
fn command(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef {
ToolDef { id, label, icon: IconKind::Svg(icon), event: ModuleEvent::Command(id.to_string()) }
ToolDef {
id,
label,
icon: IconKind::Svg(icon),
event: ModuleEvent::Command(id.to_string()),
}
}
pub fn linear() -> ToolDef { command("DCLINEAR", "Linear", include_bytes!("../../../assets/icons/dim_linear.svg")) }
pub fn horizontal() -> ToolDef { command("DCHORIZONTAL", "Horizontal", include_bytes!("../../../assets/icons/constrain/distance_x.svg")) }
pub fn vertical() -> ToolDef { command("DCVERTICAL", "Vertical", include_bytes!("../../../assets/icons/constrain/distance_y.svg")) }
pub fn aligned() -> ToolDef { command("DCALIGNED", "Aligned", include_bytes!("../../../assets/icons/dim_aligned.svg")) }
pub fn angular() -> ToolDef { command("DCANGULAR", "Angular", include_bytes!("../../../assets/icons/dim_angular.svg")) }
pub fn radius() -> ToolDef { command("DCRADIUS", "Radius", include_bytes!("../../../assets/icons/dim_radius.svg")) }
pub fn diameter() -> ToolDef { command("DCDIAMETER", "Diameter", include_bytes!("../../../assets/icons/dim_diameter.svg")) }
pub fn convert() -> ToolDef { command("DCCONVERT", "Convert", include_bytes!("../../../assets/icons/constrain/convert.svg")) }
pub fn linear() -> ToolDef {
command(
"DCLINEAR",
"Linear",
include_bytes!("../../../assets/icons/dim_linear.svg"),
)
}
pub fn horizontal() -> ToolDef {
command(
"DCHORIZONTAL",
"Horizontal",
include_bytes!("../../../assets/icons/constrain/distance_x.svg"),
)
}
pub fn vertical() -> ToolDef {
command(
"DCVERTICAL",
"Vertical",
include_bytes!("../../../assets/icons/constrain/distance_y.svg"),
)
}
pub fn aligned() -> ToolDef {
command(
"DCALIGNED",
"Aligned",
include_bytes!("../../../assets/icons/dim_aligned.svg"),
)
}
pub fn angular() -> ToolDef {
command(
"DCANGULAR",
"Angular",
include_bytes!("../../../assets/icons/dim_angular.svg"),
)
}
pub fn radius() -> ToolDef {
command(
"DCRADIUS",
"Radius",
include_bytes!("../../../assets/icons/dim_radius.svg"),
)
}
pub fn diameter() -> ToolDef {
command(
"DCDIAMETER",
"Diameter",
include_bytes!("../../../assets/icons/dim_diameter.svg"),
)
}
pub fn convert() -> ToolDef {
command(
"DCCONVERT",
"Convert",
include_bytes!("../../../assets/icons/constrain/convert.svg"),
)
}
}
pub mod angle_tool {
@@ -83,9 +136,7 @@ pub mod angle_tool {
ToolDef {
id: "ACONSTRAINT",
label: "Angle",
icon: IconKind::Svg(include_bytes!(
"../../../assets/icons/constrain/angle.svg"
)),
icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/angle.svg")),
event: ModuleEvent::Command("ACONSTRAINT".to_string()),
}
}
@@ -104,14 +155,10 @@ pub enum DistanceMode {
X,
Y,
Diameter,
/// Arc length — only offered when `is_arc` (not a plain Circle, which
/// has no "arc length" distinct from its circumference).
ArcLength,
}
/// Constrains a single line's length (or its X/Y-only component) between
/// its two endpoints, or a circle/arc's radius (or diameter, or — for an
/// arc specifically — its swept arc length), to a typed target value.
/// its two endpoints, or a circle/arc's radius or diameter, to a typed target value.
pub struct DistanceConstraintCommand {
command_name: &'static str,
handle: Handle,
@@ -119,16 +166,10 @@ pub struct DistanceConstraintCommand {
/// (Distance/DistanceX/DistanceY) — decided once at construction from
/// the entity's type.
is_circle: bool,
/// Whether `handle` is specifically an Arc (as opposed to a full
/// Circle) — gates the `[Arclength]` prompt option.
is_arc: bool,
mode: DistanceMode,
default_value: f64,
default_x: f64,
default_y: f64,
/// The entity's current arc length, for `ArcLength` mode's `<default>`
/// — meaningless (left at 0) unless `is_arc`.
default_arc_length: f64,
/// Snapshot of `Scene::named_parameters`' names at construction time —
/// see `parse_driving_value`'s doc comment for why a snapshot.
known_param_names: Vec<String>,
@@ -147,17 +188,16 @@ impl DistanceConstraintCommand {
requested_mode: DistanceMode,
) -> Option<Self> {
let entity = scene.document.get_entity(handle)?;
let (is_circle, is_arc, default_value, default_x, default_y, default_arc_length) =
match entity {
EntityType::Line(l) => {
let dx = l.end.x - l.start.x;
let dy = l.end.y - l.start.y;
(false, false, (dx * dx + dy * dy).sqrt(), dx, dy, 0.0)
}
EntityType::Circle(c) => (true, false, c.radius, 0.0, 0.0, 0.0),
EntityType::Arc(a) => (true, true, a.radius, 0.0, 0.0, a.arc_length()),
_ => return None,
};
let (is_circle, default_value, default_x, default_y) = match entity {
EntityType::Line(l) => {
let dx = l.end.x - l.start.x;
let dy = l.end.y - l.start.y;
(false, (dx * dx + dy * dy).sqrt(), dx, dy)
}
EntityType::Circle(c) => (true, c.radius, 0.0, 0.0),
EntityType::Arc(a) => (true, a.radius, 0.0, 0.0),
_ => return None,
};
let known_param_names = scene
.named_parameters()
.iter()
@@ -165,7 +205,11 @@ impl DistanceConstraintCommand {
.collect();
let mode = match requested_mode {
DistanceMode::Linear if !is_circle => {
if default_x.abs() >= default_y.abs() { DistanceMode::X } else { DistanceMode::Y }
if default_x.abs() >= default_y.abs() {
DistanceMode::X
} else {
DistanceMode::Y
}
}
DistanceMode::Aligned if !is_circle => DistanceMode::Aligned,
DistanceMode::Radius if is_circle => DistanceMode::Radius,
@@ -178,12 +222,10 @@ impl DistanceConstraintCommand {
command_name,
handle,
is_circle,
is_arc,
mode,
default_value,
default_x,
default_y,
default_arc_length,
known_param_names,
})
}
@@ -198,46 +240,41 @@ impl DistanceConstraintCommand {
let (kind, refs, label) = match (self.is_circle, self.mode) {
(true, DistanceMode::Diameter) => (
ConstraintKind::Diameter,
vec![SketchRef::whole(self.handle)],
vec![ParametricRef::whole(self.handle)],
"Diameter constraint",
),
(true, DistanceMode::ArcLength) if self.is_arc => (
ConstraintKind::ArcLength,
vec![SketchRef::whole(self.handle)],
"Arc length constraint",
),
(true, DistanceMode::Auto | DistanceMode::Radius) => (
ConstraintKind::Radius,
vec![SketchRef::whole(self.handle)],
vec![ParametricRef::whole(self.handle)],
"Radius constraint",
),
(false, DistanceMode::X) => (
ConstraintKind::DistanceX,
vec![
SketchRef::point(self.handle, 0),
SketchRef::point(self.handle, 1),
ParametricRef::point(self.handle, 0),
ParametricRef::point(self.handle, 1),
],
"DistanceX constraint",
),
(false, DistanceMode::Y) => (
ConstraintKind::DistanceY,
vec![
SketchRef::point(self.handle, 0),
SketchRef::point(self.handle, 1),
ParametricRef::point(self.handle, 0),
ParametricRef::point(self.handle, 1),
],
"DistanceY constraint",
),
(false, DistanceMode::Auto | DistanceMode::Aligned | DistanceMode::Linear) => (
ConstraintKind::Distance,
vec![
SketchRef::point(self.handle, 0),
SketchRef::point(self.handle, 1),
ParametricRef::point(self.handle, 0),
ParametricRef::point(self.handle, 1),
],
"Distance constraint",
),
_ => return None,
};
Some(CmdResult::AddSketchConstraint {
Some(CmdResult::AddParametricConstraint {
kind,
refs,
driving_param: Some(target),
@@ -257,16 +294,6 @@ impl CadCommand for DistanceConstraintCommand {
"Specify diameter <{:.4}> or [Radius]: ",
self.default_value * 2.0
),
(true, DistanceMode::ArcLength) if self.is_arc => {
format!(
"Specify arc length <{:.4}> or [Radius]: ",
self.default_arc_length
)
}
(true, DistanceMode::Auto | DistanceMode::Radius) if self.is_arc => format!(
"Specify distance <{:.4}> or [Diameter/Arclength]: ",
self.default_value
),
(true, DistanceMode::Auto | DistanceMode::Radius) => format!(
"Specify distance <{:.4}> or [Diameter]: ",
self.default_value
@@ -292,16 +319,24 @@ impl CadCommand for DistanceConstraintCommand {
fn on_enter(&mut self) -> CmdResult {
let default = match self.mode {
DistanceMode::Diameter => self.default_value * 2.0,
DistanceMode::ArcLength => self.default_arc_length,
DistanceMode::X => self.default_x,
DistanceMode::Y => self.default_y,
DistanceMode::Auto | DistanceMode::Aligned | DistanceMode::Linear | DistanceMode::Radius => self.default_value,
DistanceMode::Auto
| DistanceMode::Aligned
| DistanceMode::Linear
| DistanceMode::Radius => self.default_value,
};
self.build(DrivingValue::Literal(default))
.unwrap_or(CmdResult::Cancel)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
// An existing parameter name wins over a mode keyword with the same spelling.
if let Some(value @ DrivingValue::Named(_)) =
parse_driving_value(text, &self.known_param_names)
{
return self.build(value);
}
// Mode-switch keywords re-prompt instead of building — consumed
// inputs return `Some(NeedPoint)` so the driver doesn't re-offer
// the same token a second time (matches e.g. `trim.rs`'s
@@ -314,10 +349,6 @@ impl CadCommand for DistanceConstraintCommand {
self.mode = DistanceMode::Diameter;
return Some(CmdResult::NeedPoint);
}
(true, "A" | "ARCLENGTH") if self.is_arc => {
self.mode = DistanceMode::ArcLength;
return Some(CmdResult::NeedPoint);
}
(true, "R" | "RADIUS") => {
self.mode = DistanceMode::Radius;
return Some(CmdResult::NeedPoint);
@@ -390,11 +421,11 @@ impl AngleConstraintCommand {
if matches!(&target, DrivingValue::Literal(value) if !value.is_finite()) {
return None;
}
Some(CmdResult::AddSketchConstraint {
Some(CmdResult::AddParametricConstraint {
kind: ConstraintKind::Angle,
refs: vec![
SketchRef::whole(self.fixed_handle),
SketchRef::whole(self.moving_handle),
ParametricRef::whole(self.fixed_handle),
ParametricRef::whole(self.moving_handle),
],
driving_param: Some(target),
label: "Angle constraint",
@@ -437,8 +468,15 @@ impl CadCommand for AngleConstraintCommand {
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration {
names: &[
"DCONSTRAINT", "ACONSTRAINT", "DCLINEAR", "DCHORIZONTAL", "DCVERTICAL",
"DCALIGNED", "DCANGULAR", "DCRADIUS", "DCDIAMETER",
"DCONSTRAINT",
"ACONSTRAINT",
"DCLINEAR",
"DCHORIZONTAL",
"DCVERTICAL",
"DCALIGNED",
"DCANGULAR",
"DCRADIUS",
"DCDIAMETER",
]
});
@@ -509,17 +547,6 @@ mod tests {
))
}
fn add_arc(scene: &mut Scene) -> Handle {
scene.add_entity(EntityType::Arc(
acadrust::entities::Arc::from_center_radius_angles(
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
3.0,
0.0,
std::f64::consts::FRAC_PI_2,
),
))
}
#[test]
fn x_keyword_switches_mode_and_then_builds_a_distance_x_constraint() {
let mut scene = Scene::new();
@@ -537,7 +564,7 @@ mod tests {
);
match cmd.on_text_input("10") {
Some(CmdResult::AddSketchConstraint {
Some(CmdResult::AddParametricConstraint {
kind,
driving_param: Some(DrivingValue::Literal(v)),
..
@@ -545,7 +572,7 @@ mod tests {
assert_eq!(kind, ConstraintKind::DistanceX);
assert_eq!(v, 10.0);
}
_ => panic!("expected an AddSketchConstraint{{DistanceX}}, got a different result"),
_ => panic!("expected an AddParametricConstraint{{DistanceX}}, got a different result"),
}
}
@@ -558,7 +585,7 @@ mod tests {
assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint)));
assert!(matches!(
cmd.on_text_input("-3"),
Some(CmdResult::AddSketchConstraint {
Some(CmdResult::AddParametricConstraint {
kind: ConstraintKind::DistanceX,
driving_param: Some(DrivingValue::Literal(-3.0)),
..
@@ -576,7 +603,7 @@ mod tests {
assert!(cmd.prompt().contains("<8.0000>"));
assert!(matches!(
cmd.on_enter(),
CmdResult::AddSketchConstraint {
CmdResult::AddParametricConstraint {
kind: ConstraintKind::DistanceY,
driving_param: Some(DrivingValue::Literal(8.0)),
..
@@ -584,6 +611,24 @@ mod tests {
));
}
#[test]
fn a_parameter_named_like_a_mode_keyword_remains_usable() {
let mut scene = Scene::new();
let circle = add_circle(&mut scene);
scene.named_parameters_mut().set("r", "5").unwrap();
let mut cmd =
DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
assert!(matches!(
cmd.on_text_input("R"),
Some(CmdResult::AddParametricConstraint {
kind: ConstraintKind::Radius,
driving_param: Some(DrivingValue::Named(name)),
..
}) if name == "r"
));
}
#[test]
fn ordinary_distances_reject_nonpositive_and_nonfinite_values() {
let mut scene = Scene::new();
@@ -609,7 +654,7 @@ mod tests {
);
match cmd.on_text_input("16") {
Some(CmdResult::AddSketchConstraint {
Some(CmdResult::AddParametricConstraint {
kind,
driving_param: Some(DrivingValue::Literal(v)),
..
@@ -617,7 +662,7 @@ mod tests {
assert_eq!(kind, ConstraintKind::Diameter);
assert_eq!(v, 16.0);
}
_ => panic!("expected an AddSketchConstraint{{Diameter}}"),
_ => panic!("expected an AddParametricConstraint{{Diameter}}"),
}
}
@@ -629,70 +674,10 @@ mod tests {
DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
match cmd.on_text_input("5") {
Some(CmdResult::AddSketchConstraint { kind, .. }) => {
Some(CmdResult::AddParametricConstraint { kind, .. }) => {
assert_eq!(kind, ConstraintKind::Radius)
}
_ => panic!("expected an AddSketchConstraint{{Radius}}"),
_ => panic!("expected an AddParametricConstraint{{Radius}}"),
}
}
#[test]
fn a_keyword_switches_an_arcs_command_to_arc_length_mode() {
let mut scene = Scene::new();
let arc = add_arc(&mut scene);
let mut cmd =
DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT");
assert!(matches!(cmd.on_text_input("A"), Some(CmdResult::NeedPoint)));
assert!(
cmd.prompt().contains("arc length"),
"prompt should reflect ArcLength mode: {}",
cmd.prompt()
);
match cmd.on_text_input("8") {
Some(CmdResult::AddSketchConstraint {
kind,
driving_param: Some(DrivingValue::Literal(v)),
..
}) => {
assert_eq!(kind, ConstraintKind::ArcLength);
assert_eq!(v, 8.0);
}
_ => panic!("expected an AddSketchConstraint{{ArcLength}}"),
}
}
#[test]
fn arc_length_keyword_is_not_offered_for_a_plain_circle() {
let mut scene = Scene::new();
let circle = add_circle(&mut scene);
let mut cmd =
DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
assert!(
!cmd.prompt().contains("Arclength"),
"a plain circle has no arc length option: {}",
cmd.prompt()
);
// "A" isn't a recognized keyword here and doesn't parse as a
// number or a named parameter either, so it's rejected outright.
assert!(cmd.on_text_input("A").is_none());
}
#[test]
fn arc_length_default_is_the_arcs_current_swept_length() {
let mut scene = Scene::new();
let arc = add_arc(&mut scene); // radius 3, 90° sweep -> length = 3 * pi/2
let mut cmd =
DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT");
cmd.on_text_input("A");
let expected = 3.0 * std::f64::consts::FRAC_PI_2;
assert!(
cmd.prompt().contains(&format!("{expected:.4}")),
"prompt should default to the arc's current length: {}",
cmd.prompt()
);
}
}