forked from 1b-agent/opencadstudio-upstream
Since the stored text point became authoritative (85619f49), new linear
and aligned dimensions drew their text at the placeholder the commands
stored (the middle of the dimension line), so DIMTAD Above/Below and the
text offset had no effect; a style or override edit left the stale point
in place too.
reset_automatic_text_position clears the point of text the user did not
place, so it is laid out from the current settings. DIMLINEAR, DIMALIGNED,
DIMBASELINE, DIMCONTINUE and QDIM call it on creation; a DIMSTYLE edit, a
dimension override row and a style change call it for the dimensions they
touch. Loaded, untouched dimensions keep their pictured position.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
489 lines
16 KiB
Rust
489 lines
16 KiB
Rust
// DIMALIGNED command — aligned dimension (measures true distance between two points).
|
||
|
||
use codec::entities::{Dimension, DimensionAligned};
|
||
use codec::types::{Handle, Vector3};
|
||
use codec::EntityType;
|
||
use glam::DVec3;
|
||
|
||
use crate::command::{
|
||
CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionPreview, InputKind,
|
||
WorkingPlane,
|
||
};
|
||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||
use crate::scene::model::wire_model::WireModel;
|
||
use crate::t;
|
||
|
||
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/dim_aligned.svg"));
|
||
|
||
pub fn tool() -> ToolDef {
|
||
ToolDef {
|
||
id: "DIMALIGNED",
|
||
label: "Aligned",
|
||
icon: ICON,
|
||
event: ModuleEvent::Command("DIMALIGNED".to_string()),
|
||
}
|
||
}
|
||
|
||
enum Step {
|
||
First,
|
||
Second(DVec3),
|
||
DimLine { p1: DVec3, p2: DVec3 },
|
||
}
|
||
|
||
pub struct AlignedDimensionCommand {
|
||
step: Step,
|
||
plane: WorkingPlane,
|
||
/// Optional text that replaces the measured value (None = measurement).
|
||
text_override: Option<String>,
|
||
/// True while the next typed line is captured as the text override.
|
||
awaiting_text: bool,
|
||
/// Explicit text rotation in radians (None = follow the UCS/style).
|
||
text_angle: Option<f64>,
|
||
/// True while the next typed value is captured as the text angle.
|
||
awaiting_angle: bool,
|
||
selecting_object: bool,
|
||
picked_entity: Option<EntityType>,
|
||
source_handle: Option<Handle>,
|
||
mtext_override: bool,
|
||
}
|
||
|
||
impl AlignedDimensionCommand {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
step: Step::First,
|
||
plane: WorkingPlane::default(),
|
||
text_override: None,
|
||
awaiting_text: false,
|
||
text_angle: None,
|
||
awaiting_angle: false,
|
||
selecting_object: false,
|
||
picked_entity: None,
|
||
source_handle: None,
|
||
mtext_override: false,
|
||
}
|
||
}
|
||
|
||
fn build_dimension(&self, first: DVec3, second: DVec3, point: DVec3) -> EntityType {
|
||
let first = self.plane.to_local(first);
|
||
let second = self.plane.to_local(second);
|
||
let point = self.plane.to_local(point);
|
||
let mut entity = aligned_dimension_entity(first, second, point, self.text_override.clone());
|
||
// An explicit text angle overrides the default rotation.
|
||
if let (Some(angle), EntityType::Dimension(dimension)) = (self.text_angle, &mut entity) {
|
||
dimension.base_mut().text_rotation = angle;
|
||
}
|
||
self.plane.place_entity(entity)
|
||
}
|
||
}
|
||
|
||
/// An aligned dimension between two points of the working plane, its
|
||
/// dimension line through `point` — what DIMALIGNED places and what an
|
||
/// aligned dimensional constraint draws.
|
||
pub(crate) fn aligned_dimension_entity(
|
||
first: DVec3,
|
||
second: DVec3,
|
||
point: DVec3,
|
||
text_override: Option<String>,
|
||
) -> EntityType {
|
||
let mut dim = DimensionAligned::new(v3(first), v3(second));
|
||
// The placement point is stored so commit matches the preview.
|
||
dim.definition_point = v3(point);
|
||
dim.base.definition_point = v3(point);
|
||
let (d1, d2) = dim_line_endpoints(first, second, point);
|
||
dim.base.text_middle_point = v3((d1 + d2) * 0.5);
|
||
dim.base.insertion_point = dim.base.text_middle_point;
|
||
crate::entities::dimension::reset_automatic_text_position(&mut dim.base);
|
||
dim.base.actual_measurement = dim.measurement();
|
||
crate::entities::dimension::set_dimension_text_override(&mut dim.base, text_override);
|
||
EntityType::Dimension(Dimension::Aligned(dim))
|
||
}
|
||
|
||
impl CadCommand for AlignedDimensionCommand {
|
||
fn set_working_plane(&mut self, plane: WorkingPlane) {
|
||
self.plane = plane;
|
||
}
|
||
|
||
fn name(&self) -> &'static str {
|
||
"DIMALIGNED"
|
||
}
|
||
|
||
fn prompt(&self) -> String {
|
||
if self.awaiting_text {
|
||
return if self.mtext_override {
|
||
t!("DIMALIGNED Enter formatted dimension text (blank = measured value):")
|
||
.into_owned()
|
||
} else {
|
||
t!("DIMALIGNED Enter dimension text (blank = measured value):").into_owned()
|
||
};
|
||
}
|
||
if self.awaiting_angle {
|
||
return t!("DIMALIGNED Specify text angle (degrees):").into_owned();
|
||
}
|
||
if self.selecting_object {
|
||
return t!("DIMALIGNED Select object to dimension:").into_owned();
|
||
}
|
||
match self.step {
|
||
Step::First => t!(
|
||
"DIMALIGNED Specify first extension line origin or press Enter to select object:"
|
||
)
|
||
.into_owned(),
|
||
Step::Second(_) => {
|
||
t!("DIMALIGNED Specify second extension line origin:").into_owned()
|
||
}
|
||
Step::DimLine { .. } => {
|
||
t!("DIMALIGNED Specify dimension line location [Mtext/Text/Angle]:").into_owned()
|
||
}
|
||
}
|
||
}
|
||
|
||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||
match self.step {
|
||
Step::First => {
|
||
self.step = Step::Second(pt);
|
||
CmdResult::NeedPoint
|
||
}
|
||
Step::Second(p1) => {
|
||
if pt.distance_squared(p1) <= 1e-24 {
|
||
return CmdResult::NeedPoint;
|
||
}
|
||
self.step = Step::DimLine { p1, p2: pt };
|
||
CmdResult::NeedPoint
|
||
}
|
||
Step::DimLine { p1, p2 } => {
|
||
CmdResult::CommitDimension {
|
||
entity: self.build_dimension(p1, p2, pt),
|
||
association: DimensionAssociationInput::Infer(self.source_handle),
|
||
preserve_base_style: false,
|
||
continue_command: false,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn on_enter(&mut self) -> CmdResult {
|
||
// A bare Enter while entering override text/angle accepts the default.
|
||
if self.awaiting_text {
|
||
self.awaiting_text = false;
|
||
return CmdResult::NeedPoint;
|
||
}
|
||
if self.awaiting_angle {
|
||
self.awaiting_angle = false;
|
||
return CmdResult::NeedPoint;
|
||
}
|
||
if matches!(self.step, Step::First) {
|
||
self.selecting_object = true;
|
||
return CmdResult::NeedPoint;
|
||
}
|
||
CmdResult::Cancel
|
||
}
|
||
|
||
/// Points and object picks may come through a paper-space viewport;
|
||
/// the committed dimension then reports the model measurement.
|
||
fn measures_through_viewports(&self) -> bool {
|
||
true
|
||
}
|
||
|
||
fn dimension_acquired_points(&self) -> Vec<DVec3> {
|
||
match self.step {
|
||
Step::First => vec![],
|
||
Step::Second(p) => vec![p],
|
||
Step::DimLine { p1, p2 } => vec![p1, p2],
|
||
}
|
||
}
|
||
|
||
fn dimension_placement_pending(&self) -> bool {
|
||
matches!(self.step, Step::DimLine { .. })
|
||
}
|
||
|
||
fn input_kind(&self) -> InputKind {
|
||
if self.awaiting_text {
|
||
InputKind::FreeText
|
||
} else if self.awaiting_angle {
|
||
InputKind::SingleToken
|
||
} else {
|
||
InputKind::Point
|
||
}
|
||
}
|
||
|
||
fn options(&self) -> Vec<CmdOption> {
|
||
if !matches!(self.step, Step::DimLine { .. }) || self.awaiting_text || self.awaiting_angle {
|
||
return Vec::new();
|
||
}
|
||
vec![
|
||
CmdOption::new("MText", "MTEXT"),
|
||
CmdOption::new("Text", "TEXT"),
|
||
CmdOption::new("Angle", "ANGLE"),
|
||
]
|
||
}
|
||
|
||
fn point_step_accepts_keywords(&self) -> bool {
|
||
// While entering the override text or angle it is a value, not a point
|
||
// step.
|
||
!self.awaiting_text && !self.awaiting_angle
|
||
}
|
||
|
||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||
if self.awaiting_text {
|
||
let t = text.trim();
|
||
// Blank (or the "<>" placeholder) keeps the measured value.
|
||
self.text_override = if t.is_empty() || t == "<>" {
|
||
None
|
||
} else {
|
||
Some(t.to_string())
|
||
};
|
||
self.awaiting_text = false;
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
if self.awaiting_angle {
|
||
let t = text.trim();
|
||
// Blank clears any explicit angle (follow the default again).
|
||
self.text_angle = if t.is_empty() {
|
||
None
|
||
} else {
|
||
crate::entities::common::parse_typed_angle(t)
|
||
};
|
||
self.awaiting_angle = false;
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
if !matches!(self.step, Step::DimLine { .. }) {
|
||
return None;
|
||
}
|
||
match text.trim().to_uppercase().as_str() {
|
||
"T" | "TEXT" => {
|
||
self.mtext_override = false;
|
||
self.awaiting_text = true;
|
||
Some(CmdResult::NeedPoint)
|
||
}
|
||
"M" | "MTEXT" => {
|
||
self.mtext_override = true;
|
||
self.awaiting_text = true;
|
||
Some(CmdResult::NeedPoint)
|
||
}
|
||
"A" | "ANGLE" => {
|
||
self.awaiting_angle = true;
|
||
Some(CmdResult::NeedPoint)
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn needs_entity_pick(&self) -> bool {
|
||
self.selecting_object
|
||
}
|
||
|
||
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 {
|
||
let Some(entity) = self.picked_entity.take() else {
|
||
return CmdResult::NeedPoint;
|
||
};
|
||
let Some((p1, p2)) = super::linear_dim::dimension_source_points(&entity, point) else {
|
||
return CmdResult::NeedPoint;
|
||
};
|
||
if p1.distance_squared(p2) <= 1e-24 {
|
||
return CmdResult::NeedPoint;
|
||
}
|
||
self.source_handle = Some(handle);
|
||
self.selecting_object = false;
|
||
self.step = Step::DimLine { p1, p2 };
|
||
CmdResult::NeedPoint
|
||
}
|
||
|
||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
|
||
let (p1, p2) = match self.step {
|
||
Step::First => return None,
|
||
Step::Second(p1) => (p1, pt),
|
||
Step::DimLine { p1, p2 } => {
|
||
let p1 = self.plane.to_local(p1);
|
||
let p2 = self.plane.to_local(p2);
|
||
let pt = self.plane.to_local(pt);
|
||
let mut preview = preview_aligned(p1, p2, pt);
|
||
preview.points = preview
|
||
.points
|
||
.iter()
|
||
.map(|point| {
|
||
if point[0].is_nan() {
|
||
*point
|
||
} else {
|
||
self.plane
|
||
.to_world(DVec3::new(
|
||
point[0] as f64,
|
||
point[1] as f64,
|
||
point[2] as f64,
|
||
))
|
||
.as_vec3()
|
||
.to_array()
|
||
}
|
||
})
|
||
.collect();
|
||
return Some(preview);
|
||
}
|
||
};
|
||
// Preview WireModel points are screen/GPU-side: downcast to f32.
|
||
let p1 = p1.as_vec3();
|
||
let p2 = p2.as_vec3();
|
||
Some(WireModel {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: "dimaligned_preview".into(),
|
||
points: vec![[p1.x, p1.y, p1.z], [p2.x, p2.y, p2.z]],
|
||
points_low: Vec::new(),
|
||
color: WireModel::CYAN,
|
||
selected: false,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px: 1.0,
|
||
snap_pts: vec![],
|
||
tangent_geoms: vec![],
|
||
aci: 0,
|
||
key_vertices: vec![],
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
})
|
||
}
|
||
|
||
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
|
||
if !matches!(self.step, Step::DimLine { .. }) || self.awaiting_text || self.awaiting_angle {
|
||
return None;
|
||
}
|
||
Some(crate::command::DynSpec {
|
||
anchor: crate::command::DynAnchor::LastPoint,
|
||
fields: Vec::new(),
|
||
guide: crate::command::DynGuide::None,
|
||
ref_point: None,
|
||
})
|
||
}
|
||
|
||
fn dimension_preview(&self, cursor: DVec3) -> Option<Vec<DimensionPreview>> {
|
||
if self.selecting_object {
|
||
return None;
|
||
}
|
||
let (first, second) = match self.step {
|
||
Step::First => return None,
|
||
Step::Second(first) => (first, cursor),
|
||
Step::DimLine { p1, p2 } => (p1, p2),
|
||
};
|
||
if first.distance_squared(second) <= 1e-24 {
|
||
return Some(Vec::new());
|
||
}
|
||
Some(vec![DimensionPreview::current_style(
|
||
self.build_dimension(first, second, cursor),
|
||
)])
|
||
}
|
||
}
|
||
|
||
fn v3(p: DVec3) -> Vector3 {
|
||
Vector3::new(p.x, p.y, p.z)
|
||
}
|
||
|
||
/// Dimension-line endpoints: the baseline `p1`–`p2` shifted to pass through
|
||
/// the cursor's perpendicular projection. Uses the XY-plane perpendicular so
|
||
/// it matches the committed entity's renderer (and DIMLINEAR). The old
|
||
/// preview used an XZ-plane perpendicular, drawing the offset in the wrong
|
||
/// spatial direction. (#150)
|
||
fn dim_line_endpoints(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> (DVec3, DVec3) {
|
||
let axis = (p2 - p1).normalize_or_zero();
|
||
let perp = DVec3::new(-axis.y, axis.x, 0.0);
|
||
let offset = (dim_pt - p1).dot(perp);
|
||
(p1 + perp * offset, p2 + perp * offset)
|
||
}
|
||
|
||
fn preview_aligned(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> WireModel {
|
||
let (d1, d2) = dim_line_endpoints(p1, p2, dim_pt);
|
||
let axis = (d2 - d1).normalize_or_zero();
|
||
let perp = DVec3::new(-axis.y, axis.x, 0.0);
|
||
let nan = [f32::NAN, 0.0, 0.0];
|
||
let arrow = 0.22;
|
||
let text = (d1 + d2) * 0.5 + perp * 0.15;
|
||
let half_width = ((p2 - p1).length().log10().max(0.0) + 1.0) * 0.18;
|
||
let half_height = 0.16;
|
||
let to_point = |point: DVec3| point.as_vec3().to_array();
|
||
WireModel {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: "dimaligned_preview".into(),
|
||
points: vec![
|
||
to_point(p1),
|
||
to_point(d1),
|
||
nan,
|
||
to_point(p2),
|
||
to_point(d2),
|
||
nan,
|
||
to_point(d1),
|
||
to_point(d2),
|
||
nan,
|
||
to_point(d1 + axis * arrow + perp * arrow * 0.45),
|
||
to_point(d1),
|
||
to_point(d1 + axis * arrow - perp * arrow * 0.45),
|
||
nan,
|
||
to_point(d2 - axis * arrow + perp * arrow * 0.45),
|
||
to_point(d2),
|
||
to_point(d2 - axis * arrow - perp * arrow * 0.45),
|
||
nan,
|
||
to_point(text - axis * half_width - perp * half_height),
|
||
to_point(text + axis * half_width - perp * half_height),
|
||
to_point(text + axis * half_width + perp * half_height),
|
||
to_point(text - axis * half_width + perp * half_height),
|
||
to_point(text - axis * half_width - perp * half_height),
|
||
],
|
||
points_low: Vec::new(),
|
||
color: WireModel::CYAN,
|
||
selected: false,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px: 1.0,
|
||
snap_pts: vec![],
|
||
tangent_geoms: vec![],
|
||
aci: 0,
|
||
key_vertices: vec![],
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
}
|
||
}
|
||
|
||
|
||
// ── Autocomplete registry ─────────────────────────────────
|
||
inventory::submit!(crate::command::CommandRegistration { names: &["DIMALIGNED"] }); // AlignedDimensionCommand
|