forked from 1b-agent/opencadstudio-upstream
value toggle, and redesigned constraint-ribbon icons Properties panel: - New "Parameters" section (shown with no selection) listing every named parameter with inline name/formula editing, add, and delete, backed by new PropValue::ParamRow/ParamAddRow and command_driver handlers (resolve_named_parameter_edit, on_prop_param_commit/_delete/_add_new). A fresh parameter's formula now defaults to "1", not "0" — a 0-length seed value is a genuine solver singularity for a Distance constraint driven by that parameter, with no defined direction to grow back out of. - New "Constraints" section (shown for a single selected entity) listing every constraint touching it as a clickable PropValue::EntityLink that selects the linked entities in the viewport, danger-tinted when the constraint conflicts. Constraint pills: - New Options > General toggle, "Show values and parameter names on constraint markers" (on by default, persisted). Off, every pill shows just its bare glyph instead of glyph+driven-value/parameter-name — the glyph alone is enough to see a constraint is present, without the value text covering nearby geometry on a dense sketch. Constraint-ribbon icons: - Replaced all 18 unicode/letter glyph icons (including the plain "F", "L", "M", "S" letters) with proper SVGs under assets/icons/constrain/, matching the Modify section's two-color style (#B4B6B9 white / #6DB7ED blue, consistent stroke width). Tangent reuses the Draw section's white-arc-plus-blue-dots motif. Co-Authored-By: Claude Sonnet 5 <[email protected]>
463 lines
19 KiB
Rust
463 lines
19 KiB
Rust
//! DCONSTRAINT / ACONSTRAINT — constraints that need a typed target value
|
|
//! (a distance or an angle), unlike the plain select-and-click constraints
|
|
//! in `mod.rs`.
|
|
//!
|
|
//! Persistent (design doc `docs/parametric_system_design.md` §6.1): these
|
|
//! 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
|
|
//! 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
|
|
//! submits) is still computed from a one-time read of current geometry —
|
|
//! that part doesn't change.
|
|
|
|
use acadrust::entities::EntityType;
|
|
use acadrust::types::Handle;
|
|
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::Scene;
|
|
|
|
/// Recognizes a typed prompt token as either a numeric literal or a
|
|
/// reference to an existing named parameter (`docs/
|
|
/// named_parameters_design.md` stage 4's "a way to pick a named parameter
|
|
/// instead of typing a literal" — the same prompt takes either, mirroring
|
|
/// how AutoCAD's own dimensional-constraint prompts accept an expression in
|
|
/// place of a bare value). `known_names` is a one-time snapshot taken at
|
|
/// command construction (`DistanceConstraintCommand`/`AngleConstraintCommand
|
|
/// ::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
|
|
/// this project's "no incremental/cached resolution" approach throughout.
|
|
fn parse_driving_value(text: &str, known_names: &[String]) -> Option<DrivingValue> {
|
|
let text = text.trim();
|
|
if let Ok(value) = text.parse::<f64>() {
|
|
return Some(DrivingValue::Literal(value));
|
|
}
|
|
// Case-insensitive: the command line uppercases `SingleToken` input
|
|
// (InputKind::SingleToken) before it reaches this parser, so a typed
|
|
// lowercase parameter name like "hole_dia" arrives here as "HOLE_DIA".
|
|
// Matching case-insensitively and returning the canonically-cased name
|
|
// keeps the driving link pointed at the actual parameter.
|
|
known_names
|
|
.iter()
|
|
.find(|n| n.eq_ignore_ascii_case(text))
|
|
.map(|n| DrivingValue::Named(n.clone()))
|
|
}
|
|
|
|
pub mod distance_tool {
|
|
use super::*;
|
|
pub fn tool() -> ToolDef {
|
|
ToolDef {
|
|
id: "DCONSTRAINT",
|
|
label: "Distance",
|
|
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/distance.svg")),
|
|
event: ModuleEvent::Command("DCONSTRAINT".to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub mod angle_tool {
|
|
use super::*;
|
|
pub fn tool() -> ToolDef {
|
|
ToolDef {
|
|
id: "ACONSTRAINT",
|
|
label: "Angle",
|
|
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/constrain/angle.svg")),
|
|
event: ModuleEvent::Command("ACONSTRAINT".to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Which flavor of dimensional constraint the typed value ultimately
|
|
/// drives — selectable via a prompt keyword before the value itself is
|
|
/// typed, mirroring how real AutoCAD's own `DCCONSTRAINT` offers
|
|
/// `[Xdistance/Ydistance]` or `[Diameter]` as options on the same command
|
|
/// rather than as separate tools.
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum DistanceMode {
|
|
/// Line length (two-point distance) or a circle/arc's radius —
|
|
/// whichever `is_circle` selects.
|
|
Auto,
|
|
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.
|
|
pub struct DistanceConstraintCommand {
|
|
handle: Handle,
|
|
/// Whether `handle` is a circle/arc (Radius/Diameter) or a line
|
|
/// (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,
|
|
/// 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>,
|
|
}
|
|
|
|
impl DistanceConstraintCommand {
|
|
/// `None` if `handle` isn't a Line, Circle, or Arc.
|
|
pub fn new(scene: &Scene, handle: Handle) -> Option<Self> {
|
|
let entity = scene.document.get_entity(handle)?;
|
|
let (is_circle, is_arc, default_value, 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(), 0.0)
|
|
}
|
|
EntityType::Circle(c) => (true, false, c.radius, 0.0),
|
|
EntityType::Arc(a) => (true, true, a.radius, a.arc_length()),
|
|
_ => return None,
|
|
};
|
|
let known_param_names = scene.named_parameters().iter().map(|p| p.name.clone()).collect();
|
|
Some(Self { handle, is_circle, is_arc, mode: DistanceMode::Auto, default_value, default_arc_length, known_param_names })
|
|
}
|
|
|
|
fn build(&self, target: DrivingValue) -> Option<CmdResult> {
|
|
if let DrivingValue::Literal(v) = target {
|
|
if v <= 0.0 {
|
|
return None;
|
|
}
|
|
}
|
|
let (kind, refs, label) = match (self.is_circle, self.mode) {
|
|
(true, DistanceMode::Diameter) => (ConstraintKind::Diameter, vec![SketchRef::whole(self.handle)], "Diameter constraint"),
|
|
(true, DistanceMode::ArcLength) if self.is_arc => {
|
|
(ConstraintKind::ArcLength, vec![SketchRef::whole(self.handle)], "Arc length constraint")
|
|
}
|
|
(true, _) => (ConstraintKind::Radius, vec![SketchRef::whole(self.handle)], "Radius constraint"),
|
|
(false, DistanceMode::X) => (
|
|
ConstraintKind::DistanceX,
|
|
vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)],
|
|
"DistanceX constraint",
|
|
),
|
|
(false, DistanceMode::Y) => (
|
|
ConstraintKind::DistanceY,
|
|
vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)],
|
|
"DistanceY constraint",
|
|
),
|
|
(false, _) => (
|
|
ConstraintKind::Distance,
|
|
vec![SketchRef::point(self.handle, 0), SketchRef::point(self.handle, 1)],
|
|
"Distance constraint",
|
|
),
|
|
};
|
|
Some(CmdResult::AddSketchConstraint { kind, refs, driving_param: Some(target), label })
|
|
}
|
|
}
|
|
|
|
impl CadCommand for DistanceConstraintCommand {
|
|
fn name(&self) -> &'static str {
|
|
"DCONSTRAINT"
|
|
}
|
|
|
|
fn prompt(&self) -> String {
|
|
match (self.is_circle, self.mode) {
|
|
(true, DistanceMode::Diameter) => format!("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, _) if self.is_arc => format!("Specify distance <{:.4}> or [Diameter/Arclength]: ", self.default_value),
|
|
(true, _) => format!("Specify distance <{:.4}> or [Diameter]: ", self.default_value),
|
|
(false, DistanceMode::X) => format!("Specify X distance <{:.4}>: ", self.default_value),
|
|
(false, DistanceMode::Y) => format!("Specify Y distance <{:.4}>: ", self.default_value),
|
|
(false, _) => format!("Specify distance <{:.4}> or [Xdistance/Ydistance]: ", self.default_value),
|
|
}
|
|
}
|
|
|
|
fn input_kind(&self) -> InputKind {
|
|
InputKind::SingleToken
|
|
}
|
|
|
|
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
|
CmdResult::NeedPoint
|
|
}
|
|
|
|
fn on_enter(&mut self) -> CmdResult {
|
|
let default = match self.mode {
|
|
DistanceMode::Diameter => self.default_value * 2.0,
|
|
DistanceMode::ArcLength if self.is_arc => self.default_arc_length,
|
|
_ => self.default_value,
|
|
};
|
|
self.build(DrivingValue::Literal(default)).unwrap_or(CmdResult::Cancel)
|
|
}
|
|
|
|
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
|
// 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
|
|
// `T`/`B`/`F` keyword handling). Defensively uppercased same as
|
|
// `trim.rs` even though `SingleToken` input already arrives
|
|
// uppercased from the command line.
|
|
let keyword = text.trim().to_uppercase();
|
|
match (self.is_circle, keyword.as_str()) {
|
|
(true, "D" | "DIAMETER") => {
|
|
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::Auto;
|
|
return Some(CmdResult::NeedPoint);
|
|
}
|
|
(false, "X" | "XDISTANCE") => {
|
|
self.mode = DistanceMode::X;
|
|
return Some(CmdResult::NeedPoint);
|
|
}
|
|
(false, "Y" | "YDISTANCE") => {
|
|
self.mode = DistanceMode::Y;
|
|
return Some(CmdResult::NeedPoint);
|
|
}
|
|
_ => {}
|
|
}
|
|
let value = parse_driving_value(text, &self.known_param_names)?;
|
|
self.build(value)
|
|
}
|
|
|
|
fn on_escape(&mut self) -> CmdResult {
|
|
CmdResult::Cancel
|
|
}
|
|
}
|
|
|
|
/// Constrains the angle (in degrees) from `fixed`'s direction to `moving`'s.
|
|
pub struct AngleConstraintCommand {
|
|
fixed_handle: Handle,
|
|
moving_handle: Handle,
|
|
default_value: 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>,
|
|
}
|
|
|
|
impl AngleConstraintCommand {
|
|
/// `None` unless both `fixed` and `moving` are lines.
|
|
pub fn new(scene: &Scene, fixed: Handle, moving: Handle) -> Option<Self> {
|
|
let fixed_entity = scene.document.get_entity(fixed)?;
|
|
let moving_entity = scene.document.get_entity(moving)?;
|
|
let (EntityType::Line(f), EntityType::Line(m)) = (fixed_entity, moving_entity) else {
|
|
return None;
|
|
};
|
|
let a1 = (f.end.y - f.start.y).atan2(f.end.x - f.start.x);
|
|
let a2 = (m.end.y - m.start.y).atan2(m.end.x - m.start.x);
|
|
let default_value = (a2 - a1).to_degrees();
|
|
let known_param_names = scene.named_parameters().iter().map(|p| p.name.clone()).collect();
|
|
Some(Self { fixed_handle: fixed, moving_handle: moving, default_value, known_param_names })
|
|
}
|
|
|
|
fn build(&self, target: DrivingValue) -> Option<CmdResult> {
|
|
Some(CmdResult::AddSketchConstraint {
|
|
kind: ConstraintKind::Angle,
|
|
refs: vec![SketchRef::whole(self.fixed_handle), SketchRef::whole(self.moving_handle)],
|
|
driving_param: Some(target),
|
|
label: "Angle constraint",
|
|
})
|
|
}
|
|
}
|
|
|
|
impl CadCommand for AngleConstraintCommand {
|
|
fn name(&self) -> &'static str {
|
|
"ACONSTRAINT"
|
|
}
|
|
|
|
fn prompt(&self) -> String {
|
|
format!("Specify angle in degrees <{:.4}>: ", self.default_value)
|
|
}
|
|
|
|
fn input_kind(&self) -> InputKind {
|
|
InputKind::SingleToken
|
|
}
|
|
|
|
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
|
CmdResult::NeedPoint
|
|
}
|
|
|
|
fn on_enter(&mut self) -> CmdResult {
|
|
self.build(DrivingValue::Literal(self.default_value)).unwrap_or(CmdResult::Cancel)
|
|
}
|
|
|
|
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
|
let value = parse_driving_value(text, &self.known_param_names)?;
|
|
self.build(value)
|
|
}
|
|
|
|
fn on_escape(&mut self) -> CmdResult {
|
|
CmdResult::Cancel
|
|
}
|
|
}
|
|
|
|
// ── Autocomplete registry ─────────────────────────────────
|
|
inventory::submit!(crate::command::CommandRegistration { names: &["DCONSTRAINT", "ACONSTRAINT"] });
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_driving_value_prefers_a_numeric_literal() {
|
|
let known = vec!["hole_dia".to_string()];
|
|
assert_eq!(parse_driving_value("12.5", &known), Some(DrivingValue::Literal(12.5)));
|
|
// Distance's own `build` rejects a non-positive target later; the
|
|
// parse step itself accepts any number, negative included.
|
|
assert_eq!(parse_driving_value("-3", &known), Some(DrivingValue::Literal(-3.0)));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_driving_value_recognizes_a_known_parameter_name() {
|
|
let known = vec!["hole_dia".to_string(), "plate_len".to_string()];
|
|
assert_eq!(parse_driving_value("hole_dia", &known), Some(DrivingValue::Named("hole_dia".to_string())));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_driving_value_rejects_an_unknown_token() {
|
|
let known = vec!["hole_dia".to_string()];
|
|
assert_eq!(parse_driving_value("bogus", &known), None);
|
|
assert_eq!(parse_driving_value("", &known), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_driving_value_matches_a_known_parameter_regardless_of_typed_case() {
|
|
// The command line uppercases `SingleToken` input before it reaches
|
|
// here, so a lowercase-named parameter must still resolve — and
|
|
// resolve to its canonically-cased name, not the uppercased token.
|
|
let known = vec!["hole_dia".to_string()];
|
|
assert_eq!(parse_driving_value("HOLE_DIA", &known), Some(DrivingValue::Named("hole_dia".to_string())));
|
|
assert_eq!(parse_driving_value("Hole_Dia", &known), Some(DrivingValue::Named("hole_dia".to_string())));
|
|
}
|
|
|
|
fn add_line(scene: &mut Scene) -> Handle {
|
|
scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
|
|
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
|
|
acadrust::types::Vector3::new(6.0, 8.0, 0.0),
|
|
)))
|
|
}
|
|
|
|
fn add_circle(scene: &mut Scene) -> Handle {
|
|
scene.add_entity(EntityType::Circle(acadrust::entities::Circle::from_center_radius(
|
|
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
|
|
3.0,
|
|
)))
|
|
}
|
|
|
|
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();
|
|
let line = add_line(&mut scene);
|
|
let mut cmd = DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT");
|
|
|
|
// The keyword itself only switches mode and re-prompts — it must
|
|
// not build a constraint yet.
|
|
assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint)));
|
|
assert!(cmd.prompt().contains("X distance"), "prompt should reflect the new mode: {}", cmd.prompt());
|
|
|
|
match cmd.on_text_input("10") {
|
|
Some(CmdResult::AddSketchConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. }) => {
|
|
assert_eq!(kind, ConstraintKind::DistanceX);
|
|
assert_eq!(v, 10.0);
|
|
}
|
|
_ => panic!("expected an AddSketchConstraint{{DistanceX}}, got a different result"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn d_keyword_switches_a_circles_command_to_diameter_mode() {
|
|
let mut scene = Scene::new();
|
|
let circle = add_circle(&mut scene);
|
|
let mut cmd = DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
|
|
|
|
assert!(matches!(cmd.on_text_input("D"), Some(CmdResult::NeedPoint)));
|
|
assert!(cmd.prompt().contains("diameter"), "prompt should reflect Diameter mode: {}", cmd.prompt());
|
|
|
|
match cmd.on_text_input("16") {
|
|
Some(CmdResult::AddSketchConstraint { kind, driving_param: Some(DrivingValue::Literal(v)), .. }) => {
|
|
assert_eq!(kind, ConstraintKind::Diameter);
|
|
assert_eq!(v, 16.0);
|
|
}
|
|
_ => panic!("expected an AddSketchConstraint{{Diameter}}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn default_mode_still_builds_a_plain_radius_constraint() {
|
|
let mut scene = Scene::new();
|
|
let circle = add_circle(&mut scene);
|
|
let mut cmd = DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
|
|
|
|
match cmd.on_text_input("5") {
|
|
Some(CmdResult::AddSketchConstraint { kind, .. }) => assert_eq!(kind, ConstraintKind::Radius),
|
|
_ => panic!("expected an AddSketchConstraint{{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());
|
|
}
|
|
}
|