fix(dimension): harden jogged radius workflow

This commit is contained in:
Hakan Seven committed 2026-08-27 08:09:02 +03:00
commit bc750c6d76
71 files changed
+5834 -860

No files matched your search

+623
View File
@@ -0,0 +1,623 @@
use acadrust::entities::{Dimension, DimensionArc};
use acadrust::types::{Handle, Vector3};
use acadrust::EntityType;
use cadkernel::geom2d::tessellate::{arc, DEFAULT_SEGMENTS_PER_RADIAN};
use glam::DVec3;
use crate::command::{
CadCommand, CmdOption, CmdResult, DimensionAssociationInput,
DimensionAssociationSource, WorkingPlane,
};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::dimension_assoc::{
polyline_arc_point_marker, RadialSourceGeometry,
ARC_DIMENSION_POINT_MARKER, POLYLINE_ARC_CENTER_MARKER,
};
use crate::scene::model::wire_model::WireModel;
pub const ICON: IconKind =
IconKind::Svg(include_bytes!("../../../assets/icons/dim_angular.svg"));
pub fn tool() -> ToolDef {
ToolDef {
id: "DIMARC",
label: "Arc Length",
icon: ICON,
event: ModuleEvent::Command("DIMARC".to_string()),
}
}
#[derive(Clone, Copy)]
enum SourceBinding {
Arc,
PolylineSegment(i32),
}
#[derive(Clone, Copy)]
struct ArcSelection {
source: RadialSourceGeometry,
binding: SourceBinding,
start_angle: f64,
end_angle: f64,
is_partial: bool,
}
impl ArcSelection {
fn new(source: RadialSourceGeometry, binding: SourceBinding) -> Option<Self> {
let sweep = positive_sweep(source.start_angle, source.end_angle);
(source.limited && source.radius > 1.0e-12 && sweep > 1.0e-12).then_some(Self {
source,
binding,
start_angle: source.start_angle,
end_angle: source.start_angle + sweep,
is_partial: false,
})
}
fn sweep(self) -> f64 {
positive_sweep(self.start_angle, self.end_angle)
}
fn point_at(self, angle: f64) -> DVec3 {
dvec(self.source.point_at_angle(angle))
}
fn center(self) -> DVec3 {
dvec(self.source.center_world())
}
fn project(self, point: DVec3) -> Option<DVec3> {
let point = self.source.plane.project(point.to_array())?;
Some(DVec3::from_array(self.source.plane.point_at(point)))
}
fn plane(self) -> WorkingPlane {
WorkingPlane::new(
DVec3::from_array(self.source.plane.origin),
DVec3::from_array(self.source.plane.x_axis),
DVec3::from_array(self.source.plane.y_axis),
)
}
fn clamped_angle(self, point: DVec3) -> f64 {
let raw = self.source.angle_at(point.to_array());
let relative = (raw - self.start_angle).rem_euclid(std::f64::consts::TAU);
let sweep = self.sweep();
if relative <= sweep + 1.0e-10 {
return self.start_angle + relative.min(sweep);
}
let start_distance = relative.min(std::f64::consts::TAU - relative);
let end_relative = (raw - self.end_angle).rem_euclid(std::f64::consts::TAU);
let end_distance = end_relative.min(std::f64::consts::TAU - end_relative);
if start_distance <= end_distance {
self.start_angle
} else {
self.end_angle
}
}
fn with_partial(self, first: f64, second: f64) -> Option<Self> {
let first = (first - self.start_angle)
.rem_euclid(std::f64::consts::TAU)
.clamp(0.0, self.sweep());
let second = (second - self.start_angle)
.rem_euclid(std::f64::consts::TAU)
.clamp(0.0, self.sweep());
let (start, end) = if first <= second {
(first, second)
} else {
(second, first)
};
(end - start > 1.0e-12).then_some(Self {
start_angle: self.start_angle + start,
end_angle: self.start_angle + end,
is_partial: true,
..self
})
}
fn association_sources(self, handle: Handle) -> Vec<Option<DimensionAssociationSource>> {
let source_sweep = positive_sweep(self.source.start_angle, self.source.end_angle);
let parameter = |angle: f64| {
((angle - self.source.start_angle) / source_sweep).clamp(0.0, 1.0)
};
match self.binding {
SourceBinding::Arc => vec![
Some(DimensionAssociationSource::explicit(handle, -3, 0.0)),
Some(DimensionAssociationSource::explicit(
handle,
ARC_DIMENSION_POINT_MARKER,
parameter(self.start_angle),
)),
Some(DimensionAssociationSource::explicit(
handle,
ARC_DIMENSION_POINT_MARKER,
parameter(self.end_angle),
)),
],
SourceBinding::PolylineSegment(segment) => {
let point_marker = polyline_arc_point_marker(segment);
vec![
Some(DimensionAssociationSource::explicit(
handle,
POLYLINE_ARC_CENTER_MARKER,
segment as f64,
)),
Some(DimensionAssociationSource::explicit(
handle,
point_marker,
parameter(self.start_angle),
)),
Some(DimensionAssociationSource::explicit(
handle,
point_marker,
parameter(self.end_angle),
)),
]
}
}
}
}
#[derive(Clone, Copy)]
enum Step {
SelectObject,
DimLine(ArcSelection),
PartialFirst(ArcSelection),
PartialSecond {
selection: ArcSelection,
first_angle: f64,
},
}
pub struct ArcLengthDimensionCommand {
step: Step,
picked_entity: Option<EntityType>,
source_handle: Option<Handle>,
text_override: Option<String>,
awaiting_text: bool,
text_angle: Option<f64>,
awaiting_angle: bool,
leader_enabled: bool,
}
impl ArcLengthDimensionCommand {
pub fn new() -> Self {
Self {
step: Step::SelectObject,
picked_entity: None,
source_handle: None,
text_override: None,
awaiting_text: false,
text_angle: None,
awaiting_angle: false,
leader_enabled: false,
}
}
fn editor_anchor(&self) -> DVec3 {
match self.step {
Step::SelectObject => DVec3::ZERO,
Step::DimLine(selection)
| Step::PartialFirst(selection)
| Step::PartialSecond { selection, .. } => {
selection.point_at(selection.start_angle + selection.sweep() * 0.5)
}
}
}
fn commit_dimension(&self, selection: ArcSelection, point: DVec3) -> CmdResult {
let plane = selection.plane();
let center = plane.to_local(selection.center());
let first = plane.to_local(selection.point_at(selection.start_angle));
let second = plane.to_local(selection.point_at(selection.end_angle));
let Some(point) = selection.project(point) else {
return CmdResult::NeedPoint;
};
let picked = plane.to_local(point);
let picked_radius = (picked - center).truncate().length();
if !picked_radius.is_finite() || picked_radius <= 1.0e-12 {
return CmdResult::NeedPoint;
}
let mut dimension = DimensionArc::default();
dimension.center_point = v3(center);
dimension.first_extension_point = v3(first);
dimension.second_extension_point = v3(second);
dimension.definition_point = v3(picked);
dimension.is_partial = selection.is_partial;
dimension.arc_start_parameter = selection.start_angle;
dimension.arc_end_parameter = selection.end_angle;
dimension.base.definition_point = dimension.definition_point;
dimension.base.text_middle_point = dimension.definition_point;
dimension.base.insertion_point = dimension.definition_point;
dimension.has_leader = self.leader_enabled && selection.sweep() > std::f64::consts::FRAC_PI_2;
if dimension.has_leader {
let middle = selection.start_angle + selection.sweep() * 0.5;
let anchor = DVec3::new(
center.x + picked_radius * middle.cos(),
center.y + picked_radius * middle.sin(),
picked.z,
);
dimension.first_leader_point = v3(anchor);
dimension.second_leader_point = v3(picked);
dimension.definition_point = v3(anchor);
dimension.base.definition_point = dimension.definition_point;
dimension.base.text_user_positioned = true;
}
dimension.base.actual_measurement = dimension.measurement();
crate::entities::dimension::set_dimension_text_override(
&mut dimension.base,
self.text_override.clone(),
);
if let Some(angle) = self.text_angle {
dimension.base.text_rotation = angle;
}
let association = self.source_handle.map_or_else(
|| DimensionAssociationInput::Explicit(Vec::new()),
|handle| {
DimensionAssociationInput::Explicit(
selection.association_sources(handle),
)
},
);
CmdResult::CommitDimension {
entity: plane.place_entity(EntityType::Dimension(Dimension::Arc(dimension))),
association,
}
}
}
impl CadCommand for ArcLengthDimensionCommand {
fn set_working_plane(&mut self, _plane: WorkingPlane) {}
fn name(&self) -> &'static str {
"DIMARC"
}
fn prompt(&self) -> String {
if self.awaiting_text {
return "DIMARC Enter dimension text (blank = measured value):".to_string();
}
if self.awaiting_angle {
return "DIMARC Specify text angle (degrees):".to_string();
}
match self.step {
Step::SelectObject => {
"DIMARC Select arc or polyline arc segment:".to_string()
}
Step::DimLine(selection) => {
let leader_option = if selection.sweep() > std::f64::consts::FRAC_PI_2 {
if self.leader_enabled { "/No Leader" } else { "/Leader" }
} else {
""
};
format!(
"DIMARC Specify arc length dimension location [Mtext/Text/Angle/Partial{leader_option}]:"
)
}
Step::PartialFirst(_) => {
"DIMARC Specify first point of partial arc:".to_string()
}
Step::PartialSecond { .. } => {
"DIMARC Specify second point of partial arc:".to_string()
}
}
}
fn on_point(&mut self, point: DVec3) -> CmdResult {
match self.step {
Step::SelectObject => CmdResult::NeedPoint,
Step::DimLine(selection) => self.commit_dimension(selection, point),
Step::PartialFirst(selection) => {
self.step = Step::PartialSecond {
selection,
first_angle: selection.clamped_angle(point),
};
CmdResult::NeedPoint
}
Step::PartialSecond {
selection,
first_angle,
} => {
let second_angle = selection.clamped_angle(point);
let Some(partial) = selection.with_partial(first_angle, second_angle) else {
return CmdResult::NeedPoint;
};
self.leader_enabled = false;
self.step = Step::DimLine(partial);
CmdResult::NeedPoint
}
}
}
fn on_enter(&mut self) -> CmdResult {
if self.awaiting_text {
self.awaiting_text = false;
return CmdResult::NeedPoint;
}
if self.awaiting_angle {
self.awaiting_angle = false;
return CmdResult::NeedPoint;
}
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn wants_text_input(&self) -> bool {
true
}
fn point_step_accepts_keywords(&self) -> bool {
!self.awaiting_text && !self.awaiting_angle
}
fn wants_text_with_spaces(&self) -> bool {
self.awaiting_text
}
fn options(&self) -> Vec<CmdOption> {
let Step::DimLine(selection) = self.step else {
return Vec::new();
};
if self.awaiting_text || self.awaiting_angle {
return Vec::new();
}
let mut options = vec![
CmdOption::new("MText", "MTEXT"),
CmdOption::new("Text", "TEXT"),
CmdOption::new("Angle", "ANGLE"),
CmdOption::new("Partial", "PARTIAL"),
];
if selection.sweep() > std::f64::consts::FRAC_PI_2 {
options.push(if self.leader_enabled {
CmdOption::new("No Leader", "NOLEADER")
} else {
CmdOption::new("Leader", "LEADER")
});
}
options
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.awaiting_text {
let value = text.trim();
self.text_override = if value.is_empty() || value == "<>" {
None
} else {
Some(value.to_string())
};
self.awaiting_text = false;
return Some(CmdResult::NeedPoint);
}
if self.awaiting_angle {
let value = text.trim();
self.text_angle = if value.is_empty() {
None
} else {
crate::entities::common::parse_typed_angle(value)
};
self.awaiting_angle = false;
return Some(CmdResult::NeedPoint);
}
let Step::DimLine(selection) = self.step else {
return None;
};
match text.trim().to_ascii_uppercase().as_str() {
"M" | "MTEXT" => Some(CmdResult::SuspendForMTextInput {
pos: self.editor_anchor(),
initial: self.text_override.clone().unwrap_or_default(),
height: 2.5,
}),
"T" | "TEXT" => {
self.awaiting_text = true;
Some(CmdResult::NeedPoint)
}
"A" | "ANGLE" => {
self.awaiting_angle = true;
Some(CmdResult::NeedPoint)
}
"P" | "PARTIAL" => {
self.step = Step::PartialFirst(selection);
Some(CmdResult::NeedPoint)
}
"L" | "LEADER" if selection.sweep() > std::f64::consts::FRAC_PI_2 => {
self.leader_enabled = true;
Some(CmdResult::NeedPoint)
}
"N" | "NOLEADER" => {
self.leader_enabled = false;
Some(CmdResult::NeedPoint)
}
_ => None,
}
}
fn on_editor_text(&mut self, value: String) {
let value = value.trim();
self.text_override = if value.is_empty() || value == "<>" {
None
} else {
Some(value.to_string())
};
}
fn on_editor_closed(&mut self, _committed: bool) -> CmdResult {
CmdResult::NeedPoint
}
fn needs_entity_pick(&self) -> bool {
matches!(self.step, Step::SelectObject)
}
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(source) = crate::scene::dimension_assoc::radial_source_at(
&entity,
Vector3::new(point.x, point.y, point.z),
) else {
return CmdResult::NeedPoint;
};
if !source.limited {
return CmdResult::NeedPoint;
}
let binding = match entity {
EntityType::Arc(_) => SourceBinding::Arc,
EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => {
SourceBinding::PolylineSegment(source.marker)
}
_ => return CmdResult::NeedPoint,
};
let Some(selection) = ArcSelection::new(source, binding) else {
return CmdResult::NeedPoint;
};
self.source_handle = Some(handle);
self.step = Step::DimLine(selection);
CmdResult::NeedPoint
}
fn on_mouse_move(&mut self, point: DVec3) -> Option<WireModel> {
match self.step {
Step::SelectObject | Step::PartialFirst(_) => None,
Step::DimLine(selection) => Some(dimension_preview(
selection,
point,
self.leader_enabled,
)),
Step::PartialSecond {
selection,
first_angle,
} => {
let second_angle = selection.clamped_angle(point);
let partial = selection.with_partial(first_angle, second_angle)?;
Some(partial_preview(partial))
}
}
}
}
fn positive_sweep(start: f64, end: f64) -> f64 {
let raw = end - start;
let mut sweep = raw.rem_euclid(std::f64::consts::TAU);
if sweep <= 1.0e-12 && raw.abs() > 1.0e-12 {
sweep = std::f64::consts::TAU;
}
sweep
}
fn dimension_preview(
selection: ArcSelection,
point: DVec3,
leader: bool,
) -> WireModel {
let plane = selection.plane();
let center = plane.to_local(selection.center());
let first = plane.to_local(selection.point_at(selection.start_angle));
let second = plane.to_local(selection.point_at(selection.end_angle));
let Some(point) = selection.project(point) else {
return preview_wire(Vec::new(), "dimarc_preview");
};
let point = plane.to_local(point);
let radius = (point - center).truncate().length();
if radius <= 1.0e-12 {
return preview_wire(Vec::new(), "dimarc_preview");
}
let start_land = DVec3::new(
center.x + radius * selection.start_angle.cos(),
center.y + radius * selection.start_angle.sin(),
point.z,
);
let end_land = DVec3::new(
center.x + radius * selection.end_angle.cos(),
center.y + radius * selection.end_angle.sin(),
point.z,
);
let mut points = vec![first, start_land, nan(), second, end_land, nan()];
points.extend(
arc(
[center.x, center.y],
radius,
selection.start_angle,
selection.end_angle,
point.z,
DEFAULT_SEGMENTS_PER_RADIAN,
)
.into_iter()
.map(DVec3::from_array),
);
if leader && selection.sweep() > std::f64::consts::FRAC_PI_2 {
let middle = selection.start_angle + selection.sweep() * 0.5;
points.extend([
nan(),
DVec3::new(
center.x + radius * middle.cos(),
center.y + radius * middle.sin(),
point.z,
),
point,
]);
}
preview_wire(
points
.into_iter()
.map(|value| if value.is_nan() { value } else { plane.to_world(value) })
.collect(),
"dimarc_preview",
)
}
fn partial_preview(selection: ArcSelection) -> WireModel {
let points = arc(
selection.source.center,
selection.source.radius,
selection.start_angle,
selection.end_angle,
0.0,
DEFAULT_SEGMENTS_PER_RADIAN,
)
.into_iter()
.map(|point| DVec3::from_array(selection.source.plane.point_at([point[0], point[1]])))
.collect();
preview_wire(points, "dimarc_partial_preview")
}
fn v3(point: DVec3) -> Vector3 {
Vector3::new(point.x, point.y, point.z)
}
fn dvec(point: Vector3) -> DVec3 {
DVec3::new(point.x, point.y, point.z)
}
fn nan() -> DVec3 {
DVec3::splat(f64::NAN)
}
fn preview_wire(points: Vec<DVec3>, name: &str) -> WireModel {
WireModel::solid_f64(
name.to_string(),
points.into_iter().map(|point| point.to_array()).collect(),
WireModel::CYAN,
false,
)
}
inventory::submit!(crate::command::CommandRegistration { names: &["DIMARC"] });
+6 -3
View File
@@ -54,9 +54,12 @@ pub struct JoggedRadiusDimensionCommand {
impl JoggedRadiusDimensionCommand {
pub fn new(defaults: DimensionCreationDefaults, annotation_multiplier: f64) -> Self {
let editor_height = defaults.text_height
* defaults.scale
* annotation_multiplier.max(f64::EPSILON);
let display_scale = if defaults.annotative {
annotation_multiplier.max(f64::EPSILON)
} else {
defaults.scale
};
let editor_height = defaults.text_height * display_scale;
Self {
step: Step::SelectObject,
defaults,
+6
View File
@@ -1,6 +1,7 @@
// Annotate module — dimension, text, leader, table, and markup tools.
pub mod aligned_dim;
pub mod arc_length_dim;
pub mod angular_dim;
pub mod data_extract;
pub mod data_link;
@@ -99,6 +100,11 @@ impl CadModule for AnnotateModule {
angular_dim::tool().label,
angular_dim::tool().icon,
),
(
arc_length_dim::tool().id,
arc_length_dim::tool().label,
arc_length_dim::tool().icon,
),
(
radius_dim::tool().id,
radius_dim::tool().label,
+358 -20
View File
@@ -1,7 +1,11 @@
use crate::command::{CadCommand, CmdResult};
use acadrust::entities::mtext::AttachmentPoint;
use acadrust::types::Vector3;
use acadrust::MText;
use glam::DVec3;
use crate::command::{CadCommand, CmdOption, CmdResult, DynField, WorkingPlane};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
use glam::DVec3;
use crate::t;
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/mtext.svg"));
@@ -15,20 +19,180 @@ pub fn tool() -> ToolDef {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Step {
InsertPoint,
FirstCorner,
OppositeCorner,
Height,
Justify,
LineSpacing,
Rotation,
Style,
Width,
ColumnMode,
ColumnCount,
ColumnWidth,
ColumnGutter,
}
pub struct MTextCommand {
step: Step,
first: Option<DVec3>,
plane: WorkingPlane,
height: f64,
style: String,
attachment: AttachmentPoint,
line_spacing: f64,
rotation: f64,
width: Option<f64>,
column_type: i16,
column_count: i32,
column_width: f64,
column_gutter: f64,
}
impl MTextCommand {
pub fn with_height(height: f64) -> Self {
pub fn with_defaults(height: f64, style: String) -> Self {
Self {
step: Step::InsertPoint,
height,
step: Step::FirstCorner,
first: None,
plane: WorkingPlane::default(),
height: height.max(1e-6),
style,
attachment: AttachmentPoint::TopLeft,
line_spacing: 1.0,
rotation: 0.0,
width: None,
column_type: 0,
column_count: 2,
column_width: 0.0,
column_gutter: 0.0,
}
}
fn resume_point_step(&mut self) {
self.step = if self.first.is_some() {
Step::OppositeCorner
} else {
Step::FirstCorner
};
}
fn point_options() -> Vec<CmdOption> {
vec![
CmdOption::new("Height", "HEIGHT"),
CmdOption::new("Justify", "JUSTIFY"),
CmdOption::new("Line spacing", "LINESPACING"),
CmdOption::new("Rotation", "ROTATION"),
CmdOption::new("Style", "STYLE"),
CmdOption::new("Width", "WIDTH"),
CmdOption::new("Columns", "COLUMNS"),
]
}
fn begin_option(&mut self, keyword: &str) -> bool {
self.step = match keyword {
"H" | "HEIGHT" => Step::Height,
"J" | "JUSTIFY" => Step::Justify,
"L" | "LINESPACING" | "LINE SPACING" => Step::LineSpacing,
"R" | "ROTATION" => Step::Rotation,
"S" | "STYLE" => Step::Style,
"W" | "WIDTH" => Step::Width,
"C" | "COLUMNS" => Step::ColumnMode,
_ => return false,
};
true
}
fn boundary_geometry(&self, opposite: DVec3) -> (DVec3, f64, f64) {
let first = self.first.unwrap_or(opposite);
let local = self.plane.vector_to_local(opposite - first);
let (sin, cos) = self.rotation.sin_cos();
let picked_horizontal = local.x * cos + local.y * sin;
let vertical = -local.x * sin + local.y * cos;
let width = self.width.unwrap_or(picked_horizontal.abs());
let horizontal = if self.width.is_some() {
width.copysign(if picked_horizontal.abs() > 1e-12 {
picked_horizontal
} else {
1.0
})
} else {
picked_horizontal
};
let (xmin, xmax) = (horizontal.min(0.0), horizontal.max(0.0));
let (ymin, ymax) = (vertical.min(0.0), vertical.max(0.0));
let horizontal_anchor = match self.attachment {
AttachmentPoint::TopLeft
| AttachmentPoint::MiddleLeft
| AttachmentPoint::BottomLeft => 0.0,
AttachmentPoint::TopCenter
| AttachmentPoint::MiddleCenter
| AttachmentPoint::BottomCenter => 0.5,
AttachmentPoint::TopRight
| AttachmentPoint::MiddleRight
| AttachmentPoint::BottomRight => 1.0,
};
let vertical_anchor = match self.attachment {
AttachmentPoint::TopLeft
| AttachmentPoint::TopCenter
| AttachmentPoint::TopRight => 1.0,
AttachmentPoint::MiddleLeft
| AttachmentPoint::MiddleCenter
| AttachmentPoint::MiddleRight => 0.5,
AttachmentPoint::BottomLeft
| AttachmentPoint::BottomCenter
| AttachmentPoint::BottomRight => 0.0,
};
let anchor_x = xmin + (xmax - xmin) * horizontal_anchor;
let anchor_y = ymin + (ymax - ymin) * vertical_anchor;
let offset = DVec3::new(
anchor_x * cos - anchor_y * sin,
anchor_x * sin + anchor_y * cos,
0.0,
);
(
first + self.plane.vector_to_world(offset),
width,
vertical.abs(),
)
}
fn open_editor(&self, opposite: DVec3) -> CmdResult {
let (insertion, width, boundary_height) = self.boundary_geometry(opposite);
let mut template = MText::default();
template.insertion_point = Vector3::new(insertion.x, insertion.y, insertion.z);
template.height = self.height;
template.rectangle_width = width.max(0.0);
template.rectangle_height = (boundary_height > 1e-9).then_some(boundary_height);
template.rotation = self.rotation;
template.style = self.style.clone();
template.attachment_point = self.attachment;
template.line_spacing_factor = self.line_spacing;
template.column_data.column_type = self.column_type;
if self.column_type != 0 {
template.column_data.column_count =
crate::entities::text_support::clamp_mtext_column_count(self.column_count);
template.column_data.auto_height = self.column_type == 2;
template.column_data.width = if self.column_width > 0.0 {
self.column_width
} else if width > 0.0 {
width
} else {
self.height * 10.0
};
template.column_data.gutter = if self.column_gutter > 0.0 {
self.column_gutter
} else {
self.height
};
}
CmdResult::OpenMTextEditor {
pos: insertion,
handle: None,
initial: String::new(),
height: self.height,
template: Some(Box::new(template)),
}
}
}
@@ -38,34 +202,208 @@ impl CadCommand for MTextCommand {
"MTEXT"
}
fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane;
}
fn prompt(&self) -> String {
match &self.step {
Step::InsertPoint => t!("MTEXT Specify insertion point:").into_owned(),
match self.step {
Step::FirstCorner => t!("MTEXT Specify first corner:").into_owned(),
Step::OppositeCorner => t!("MTEXT Specify opposite corner:").into_owned(),
Step::Height => crate::tf!("MTEXT Specify text height <{}>:", self.height).into_owned(),
Step::Justify => t!("MTEXT Enter justification [TL / TC / TR / ML / MC / MR / BL / BC / BR] <TL>:").into_owned(),
Step::LineSpacing => crate::tf!("MTEXT Enter line spacing factor (0.25-4.00) <{}>:", self.line_spacing).into_owned(),
Step::Rotation => crate::tf!("MTEXT Specify rotation angle <{}>:", self.rotation.to_degrees()).into_owned(),
Step::Style => crate::tf!("MTEXT Enter text style <{}>:", self.style).into_owned(),
Step::Width => crate::tf!("MTEXT Specify boundary width <{}>:", self.width.unwrap_or(0.0)).into_owned(),
Step::ColumnMode => t!("MTEXT Columns [None / Static / Dynamic] <None>:").into_owned(),
Step::ColumnCount => crate::tf!("MTEXT Enter column count <{}>:", self.column_count).into_owned(),
Step::ColumnWidth => crate::tf!("MTEXT Enter column width <{}>:", self.column_width).into_owned(),
Step::ColumnGutter => crate::tf!("MTEXT Enter column gutter <{}>:", self.column_gutter).into_owned(),
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
// Hand off to the in-place editor (toolbar + text area + live preview).
CmdResult::OpenMTextEditor {
pos: pt,
handle: None,
initial: String::new(),
height: self.height,
fn options(&self) -> Vec<CmdOption> {
match self.step {
Step::FirstCorner | Step::OppositeCorner => Self::point_options(),
Step::Justify => ["TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"]
.into_iter()
.map(|value| CmdOption::new(value, value))
.collect(),
Step::ColumnMode => vec![
CmdOption::new("None", "NONE"),
CmdOption::new("Static", "STATIC"),
CmdOption::new("Dynamic", "DYNAMIC"),
],
_ => Vec::new(),
}
}
fn point_step_accepts_keywords(&self) -> bool {
matches!(self.step, Step::FirstCorner | Step::OppositeCorner)
}
fn wants_text_input(&self) -> bool {
true
}
fn wants_text_with_spaces(&self) -> bool {
matches!(self.step, Step::Style)
}
fn dyn_field(&self) -> DynField {
match self.step {
Step::Rotation => DynField::Angle,
Step::Height
| Step::LineSpacing
| Step::Width
| Step::ColumnCount
| Step::ColumnWidth
| Step::ColumnGutter => DynField::Scalar,
_ => DynField::Point,
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let trimmed = text.trim();
let upper = trimmed.to_ascii_uppercase();
if matches!(self.step, Step::FirstCorner | Step::OppositeCorner)
&& self.begin_option(&upper)
{
return Some(CmdResult::NeedPoint);
}
match self.step {
Step::Height => {
let value = crate::entities::common::parse_typed_length(trimmed)?;
if value <= 0.0 {
return None;
}
self.height = value;
self.resume_point_step();
}
Step::Justify => {
self.attachment = match upper.as_str() {
"TL" => AttachmentPoint::TopLeft,
"TC" => AttachmentPoint::TopCenter,
"TR" => AttachmentPoint::TopRight,
"ML" => AttachmentPoint::MiddleLeft,
"MC" => AttachmentPoint::MiddleCenter,
"MR" => AttachmentPoint::MiddleRight,
"BL" => AttachmentPoint::BottomLeft,
"BC" => AttachmentPoint::BottomCenter,
"BR" => AttachmentPoint::BottomRight,
_ => return None,
};
self.resume_point_step();
}
Step::LineSpacing => {
let value = trimmed.replace(',', ".").parse::<f64>().ok()?;
if !(0.25..=4.0).contains(&value) {
return None;
}
self.line_spacing = value;
self.resume_point_step();
}
Step::Rotation => {
self.rotation = crate::entities::common::parse_typed_direction(trimmed)?;
self.resume_point_step();
}
Step::Style => {
if trimmed.is_empty() {
return None;
}
self.style = trimmed.to_string();
self.resume_point_step();
}
Step::Width => {
let value = crate::entities::common::parse_typed_length(trimmed)?;
if value < 0.0 {
return None;
}
self.width = Some(value);
self.resume_point_step();
}
Step::ColumnMode => {
self.column_type = match upper.as_str() {
"N" | "NONE" => 0,
"S" | "STATIC" => 1,
"D" | "DYNAMIC" => 2,
_ => return None,
};
if self.column_type == 0 {
self.resume_point_step();
} else {
self.step = Step::ColumnCount;
}
}
Step::ColumnCount => {
let value = trimmed.parse::<i32>().ok()?;
if !(1..=crate::entities::text_support::MAX_MTEXT_COLUMNS).contains(&value) {
return None;
}
self.column_count = value;
self.step = Step::ColumnWidth;
}
Step::ColumnWidth => {
let value = crate::entities::common::parse_typed_length(trimmed)?;
if value <= 0.0 {
return None;
}
self.column_width = value;
self.step = Step::ColumnGutter;
}
Step::ColumnGutter => {
let value = crate::entities::common::parse_typed_length(trimmed)?;
if value < 0.0 {
return None;
}
self.column_gutter = value;
self.resume_point_step();
}
Step::FirstCorner | Step::OppositeCorner => return None,
}
Some(CmdResult::NeedPoint)
}
fn on_point(&mut self, point: DVec3) -> CmdResult {
match self.step {
Step::FirstCorner => {
self.first = Some(point);
self.step = Step::OppositeCorner;
CmdResult::NeedPoint
}
Step::OppositeCorner => self.open_editor(point),
_ => CmdResult::NeedPoint,
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
match self.step {
Step::Height
| Step::Justify
| Step::LineSpacing
| Step::Rotation
| Step::Style
| Step::Width
| Step::ColumnMode
| Step::ColumnCount
| Step::ColumnWidth
| Step::ColumnGutter => {
self.resume_point_step();
CmdResult::NeedPoint
}
_ => CmdResult::Cancel,
}
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_mouse_move(&mut self, _pt: DVec3) -> Option<WireModel> {
fn on_mouse_move(&mut self, _point: DVec3) -> Option<WireModel> {
None
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] }); // MTextCommand
inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] });
+406 -22
View File
@@ -1,7 +1,14 @@
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
use acadrust::entities::{
Text, TextHorizontalAlignment as HA, TextVerticalAlignment as VA,
};
use acadrust::tables::TextStyle;
use acadrust::types::Vector3;
use glam::DVec3;
use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::creation_style::TextCreationDefaults;
use crate::scene::model::wire_model::WireModel;
use crate::t;
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/text.svg"));
@@ -15,57 +22,434 @@ pub fn tool() -> ToolDef {
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Step {
InsertPoint,
Start,
Justification,
Style,
Height,
Rotation,
SecondPoint,
}
pub struct TextCommand {
step: Step,
plane: WorkingPlane,
first_point: Option<DVec3>,
second_point: Option<DVec3>,
horizontal: HA,
vertical: VA,
style_name: String,
styles: Vec<TextStyle>,
height: f64,
rotation: f64,
width_factor: f64,
oblique_angle: f64,
fixed_height: bool,
annotative: bool,
annotation_multiplier: f64,
last_display_height: Option<f64>,
last_entity: Option<Text>,
}
impl TextCommand {
pub fn with_height(height: f64) -> Self {
Self {
step: Step::InsertPoint,
height,
pub fn with_defaults(
defaults: TextCreationDefaults,
styles: Vec<TextStyle>,
annotation_multiplier: f64,
) -> Self {
let current_height = defaults.height;
let mut command = Self {
step: Step::Start,
plane: WorkingPlane::default(),
first_point: None,
second_point: None,
horizontal: HA::Left,
vertical: VA::Baseline,
style_name: defaults.style_name,
styles,
height: defaults.height,
rotation: 0.0,
width_factor: defaults.width_factor,
oblique_angle: defaults.oblique_angle,
fixed_height: false,
annotative: false,
annotation_multiplier: annotation_multiplier.max(1.0e-9),
last_display_height: None,
last_entity: None,
};
let style = command.style_name.clone();
command.select_style(&style);
if !command.fixed_height {
command.height = current_height;
}
command
}
fn select_style(&mut self, name: &str) -> bool {
let Some(style) = self
.styles
.iter()
.find(|style| style.name.eq_ignore_ascii_case(name))
.cloned()
else {
return false;
};
self.style_name = style.name;
self.fixed_height = style.height > 1.0e-9;
if self.fixed_height {
self.height = style.height;
} else if style.last_height > 1.0e-9 {
self.height = style.last_height;
}
self.width_factor = style.width_factor.max(0.01);
self.oblique_angle = style.oblique_angle.clamp(
-85.0_f64.to_radians(),
85.0_f64.to_radians(),
);
self.annotative = style.annotative;
true
}
fn set_justification(&mut self, value: &str) -> bool {
let normalized = value.trim().to_ascii_uppercase().replace([' ', '-'], "");
let alignment = match normalized.as_str() {
"L" | "LEFT" => (HA::Left, VA::Baseline),
"C" | "CENTER" => (HA::Center, VA::Baseline),
"R" | "RIGHT" => (HA::Right, VA::Baseline),
"A" | "ALIGNED" | "ALIGN" => (HA::Aligned, VA::Baseline),
"M" | "MIDDLE" => (HA::Middle, VA::Baseline),
"F" | "FIT" => (HA::Fit, VA::Baseline),
"TL" | "TOPLEFT" => (HA::Left, VA::Top),
"TC" | "TOPCENTER" => (HA::Center, VA::Top),
"TR" | "TOPRIGHT" => (HA::Right, VA::Top),
"ML" | "MIDDLELEFT" => (HA::Left, VA::Middle),
"MC" | "MIDDLECENTER" => (HA::Center, VA::Middle),
"MR" | "MIDDLERIGHT" => (HA::Right, VA::Middle),
"BL" | "BOTTOMLEFT" => (HA::Left, VA::Bottom),
"BC" | "BOTTOMCENTER" => (HA::Center, VA::Bottom),
"BR" | "BOTTOMRIGHT" => (HA::Right, VA::Bottom),
_ => return false,
};
self.horizontal = alignment.0;
self.vertical = alignment.1;
true
}
fn is_two_point(&self) -> bool {
matches!(self.horizontal, HA::Aligned | HA::Fit)
}
fn after_first_point(&mut self) -> CmdResult {
if self.is_two_point() {
self.step = Step::SecondPoint;
} else if self.fixed_height {
self.step = Step::Rotation;
} else {
self.step = Step::Height;
}
CmdResult::NeedPoint
}
fn after_second_point(&mut self) -> CmdResult {
if matches!(self.horizontal, HA::Fit) && !self.fixed_height {
self.step = Step::Height;
CmdResult::NeedPoint
} else {
self.open_editor()
}
}
fn make_entity(&self) -> Option<Text> {
let first = self.plane.to_local(self.first_point?);
let mut text = Text::with_value("", Vector3::new(first.x, first.y, first.z))
.with_height(self.height.max(1.0e-9));
text.style = self.style_name.clone();
text.width_factor = self.width_factor.max(0.01);
text.oblique_angle = self.oblique_angle;
text.rotation = self.rotation;
text.horizontal_alignment = self.horizontal;
text.vertical_alignment = self.vertical;
text.alignment_point = if self.is_two_point() {
let second = self.plane.to_local(self.second_point?);
Some(Vector3::new(second.x, second.y, second.z))
} else if matches!((self.horizontal, self.vertical), (HA::Left, VA::Baseline)) {
None
} else {
Some(Vector3::new(first.x, first.y, first.z))
};
Some(text)
}
fn open_editor(&mut self) -> CmdResult {
let Some(entity) = self.make_entity() else {
return CmdResult::NeedPoint;
};
let pos = self.first_point.unwrap_or(DVec3::ZERO);
self.last_display_height = None;
self.last_entity = Some(entity.clone());
CmdResult::SuspendForTextInput { pos, entity }
}
fn open_next_line(&mut self) -> CmdResult {
let Some(mut entity) = self.last_entity.take() else {
return CmdResult::Cancel;
};
let angle = if matches!(entity.horizontal_alignment, HA::Aligned | HA::Fit) {
entity.alignment_point.map_or(entity.rotation, |point| {
(point.y - entity.insertion_point.y)
.atan2(point.x - entity.insertion_point.x)
})
} else {
entity.rotation
};
let fallback_height = entity.height.max(1.0e-9)
* if self.annotative {
self.annotation_multiplier
} else {
1.0
};
let spacing = self
.last_display_height
.take()
.unwrap_or(fallback_height)
.max(1.0e-9)
* 1.666_666_666_7;
let delta = Vector3::new(angle.sin() * spacing, -angle.cos() * spacing, 0.0);
entity.insertion_point = entity.insertion_point + delta;
if let Some(point) = entity.alignment_point.as_mut() {
*point = *point + delta;
}
entity.value.clear();
let local = DVec3::new(
entity.insertion_point.x,
entity.insertion_point.y,
entity.insertion_point.z,
);
let pos = self.plane.to_world(local);
self.last_entity = Some(entity.clone());
CmdResult::SuspendForTextInput { pos, entity }
}
}
impl CadCommand for TextCommand {
fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane;
}
fn name(&self) -> &'static str {
"TEXT"
}
fn prompt(&self) -> String {
match &self.step {
Step::InsertPoint => t!("TEXT Specify insertion point:").into_owned(),
match self.step {
Step::Start => format!(
"{}\n{}",
crate::tf!(
"TEXT Current style: {}, Height: {}, Annotative: {}",
self.style_name,
self.height,
if self.annotative { "Yes" } else { "No" }
),
t!("TEXT Specify start point or [Justify/Style]:")
),
Step::Justification => t!(
"TEXT Enter justification [Left/Center/Right/Aligned/Middle/Fit/TL/TC/TR/ML/MC/MR/BL/BC/BR]:"
)
.into_owned(),
Step::Style => crate::tf!("TEXT Enter style name <{}>:", self.style_name).into_owned(),
Step::Height if self.annotative => {
crate::tf!("TEXT Specify paper text height <{}>:", self.height).into_owned()
}
Step::Height => crate::tf!("TEXT Specify height <{}>:", self.height).into_owned(),
Step::Rotation => crate::tf!(
"TEXT Specify rotation angle <{}>:",
self.rotation.to_degrees()
)
.into_owned(),
Step::SecondPoint => t!("TEXT Specify second endpoint:").into_owned(),
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
// Hand off to the in-place plain-text editor anchored at the click.
CmdResult::OpenTextEditor {
pos: pt,
handle: None,
initial: String::new(),
height: self.height,
fn options(&self) -> Vec<CmdOption> {
match self.step {
Step::Start => vec![
CmdOption::new(t!("Justify").as_ref(), "J"),
CmdOption::new(t!("Style").as_ref(), "ST"),
],
Step::Justification => [
("Left", "L"), ("Center", "C"), ("Right", "R"),
("Aligned", "A"), ("Middle", "M"), ("Fit", "F"),
("TL", "TL"), ("TC", "TC"), ("TR", "TR"),
("ML", "ML"), ("MC", "MC"), ("MR", "MR"),
("BL", "BL"), ("BC", "BC"), ("BR", "BR"),
]
.into_iter()
.map(|(label, keyword)| CmdOption::new(label, keyword))
.collect(),
Step::Style => self
.styles
.iter()
.map(|style| CmdOption::new(&style.name, &style.name))
.collect(),
_ => Vec::new(),
}
}
fn wants_text_input(&self) -> bool {
matches!(
self.step,
Step::Justification | Step::Style | Step::Height | Step::Rotation
)
}
fn point_step_accepts_keywords(&self) -> bool {
matches!(self.step, Step::Start)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let token = text.trim();
let upper = token.to_ascii_uppercase();
match self.step {
Step::Start => match upper.as_str() {
"J" | "JUSTIFY" | "JUSTIFICATION" => self.step = Step::Justification,
"S" | "ST" | "STYLE" => self.step = Step::Style,
_ => return None,
},
Step::Justification => {
if !self.set_justification(token) {
return None;
}
self.step = Step::Start;
}
Step::Style => {
if !self.select_style(token) {
return None;
}
self.step = Step::Start;
}
Step::Height => {
let value = token.replace(',', ".").parse::<f64>().ok()?;
if !value.is_finite() || value <= 1.0e-9 {
return None;
}
self.height = value;
if self.is_two_point() {
return Some(self.open_editor());
}
self.step = Step::Rotation;
}
Step::Rotation => {
let value = token.replace(',', ".").parse::<f64>().ok()?;
if !value.is_finite() {
return None;
}
self.rotation = value.to_radians();
return Some(self.open_editor());
}
Step::SecondPoint => return None,
}
Some(CmdResult::NeedPoint)
}
fn on_point(&mut self, point: DVec3) -> CmdResult {
match self.step {
Step::Start => {
self.first_point = Some(point);
self.second_point = None;
self.after_first_point()
}
Step::Height => {
let Some(first) = self.first_point else {
return CmdResult::NeedPoint;
};
let value = self
.plane
.vector_to_local(point - first)
.truncate()
.length();
if value <= 1.0e-9 {
return CmdResult::NeedPoint;
}
self.height = value;
if self.is_two_point() {
self.open_editor()
} else {
self.step = Step::Rotation;
CmdResult::NeedPoint
}
}
Step::Rotation => {
let Some(first) = self.first_point else {
return CmdResult::NeedPoint;
};
let Some(angle) = self.plane.angle(first, point) else {
return CmdResult::NeedPoint;
};
self.rotation = angle;
self.open_editor()
}
Step::SecondPoint => {
let Some(first) = self.first_point else {
return CmdResult::NeedPoint;
};
if self
.plane
.vector_to_local(point - first)
.truncate()
.length()
<= 1.0e-9
{
return CmdResult::NeedPoint;
}
self.second_point = Some(point);
self.after_second_point()
}
Step::Justification | Step::Style => CmdResult::NeedPoint,
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
match self.step {
Step::Start => CmdResult::Cancel,
Step::Justification | Step::Style => {
self.step = Step::Start;
CmdResult::NeedPoint
}
Step::Height => {
if self.is_two_point() {
self.open_editor()
} else {
self.step = Step::Rotation;
CmdResult::NeedPoint
}
}
Step::Rotation => self.open_editor(),
Step::SecondPoint => CmdResult::NeedPoint,
}
}
fn on_editor_closed(&mut self, committed: bool) -> CmdResult {
if committed {
self.open_next_line()
} else {
CmdResult::Cancel
}
}
fn on_editor_display_height(&mut self, height: f64) {
if height.is_finite() && height > 1.0e-9 {
self.last_display_height = Some(height);
}
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_mouse_move(&mut self, _pt: DVec3) -> Option<WireModel> {
fn on_mouse_move(&mut self, _point: DVec3) -> Option<WireModel> {
None
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] }); // TextCommand
inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] });
+24 -49
View File
@@ -1,8 +1,6 @@
// TOLERANCE command — place a GD&T (geometric dimensioning & tolerancing) frame.
//
// Workflow:
// 1. Text: Enter tolerance string (e.g. "%%v0.05|A" or plain text)
// 2. Point: Click insertion point
// The structured editor prepares the frame; this command places it.
use acadrust::entities::Tolerance;
use acadrust::types::Vector3;
@@ -25,20 +23,17 @@ pub fn tool() -> ToolDef {
}
}
enum Step {
Text,
Insertion { text: String },
}
pub struct ToleranceCommand {
step: Step,
text: String,
preview_strokes: Vec<Vec<[f32; 2]>>,
plane: WorkingPlane,
}
impl ToleranceCommand {
pub fn new() -> Self {
pub fn with_text(text: String, preview_strokes: Vec<Vec<[f32; 2]>>) -> Self {
Self {
step: Step::Text,
text,
preview_strokes,
plane: WorkingPlane::default(),
}
}
@@ -54,36 +49,18 @@ impl CadCommand for ToleranceCommand {
}
fn prompt(&self) -> String {
match &self.step {
Step::Text => t!("TOLERANCE Enter tolerance text:").into_owned(),
Step::Insertion { text } => {
t!("TOLERANCE Specify insertion point [%{text}]:", text = text).into_owned()
}
}
t!("TOLERANCE Specify insertion point:").into_owned()
}
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::Text)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim().to_string();
if t.is_empty() {
return Some(CmdResult::Cancel);
}
self.step = Step::Insertion { text: t };
Some(CmdResult::NeedPoint)
false
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
if let Step::Insertion { text } = &self.step {
let point = self.plane.to_local(pt);
let ins = Vector3::new(point.x, point.y, point.z);
let tol = Tolerance::with_text(ins, text.clone());
CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Tolerance(tol)))
} else {
CmdResult::NeedPoint
}
let point = self.plane.to_local(pt);
let ins = Vector3::new(point.x, point.y, point.z);
let tol = Tolerance::with_text(ins, self.text.clone());
CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Tolerance(tol)))
}
fn on_enter(&mut self) -> CmdResult {
@@ -91,17 +68,18 @@ impl CadCommand for ToleranceCommand {
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
if !matches!(self.step, Step::Insertion { .. }) {
return None;
let mut points = Vec::new();
for stroke in &self.preview_strokes {
if stroke.len() < 2 {
continue;
}
if !points.is_empty() {
points.push(DVec3::splat(f64::NAN));
}
points.extend(stroke.iter().map(|[x, y]| {
pt + self.plane.x * *x as f64 + self.plane.y * *y as f64
}));
}
let d = 0.15;
let points = [
pt - self.plane.x * d,
pt + self.plane.x * d,
DVec3::splat(f64::NAN),
pt - self.plane.y * d,
pt + self.plane.y * d,
];
Some(WireModel {
point_marker: None,
taper_widths: Vec::new(),
@@ -119,10 +97,7 @@ impl CadCommand for ToleranceCommand {
dash_align_end: None,
text_verts: Vec::new(),
name: "tolerance_preview".into(),
points: points
.iter()
.map(|point| point.as_vec3().to_array())
.collect(),
points: points.iter().map(|point| point.as_vec3().to_array()).collect(),
points_low: Vec::new(),
color: WireModel::CYAN,
selected: false,