feat(input): scalar entry via dynamic input

Text-input steps (OFFSET distance, POLYGON sides, radii, counts) were
swallowed by the dynamic-input overlay: typed digits landed in the field
buffer and Enter committed a point pick, so the value never reached
on_text_input (issue #61). Now each text step shows one scalar box whose
value routes to on_text_input on commit; non-numeric prompts (names,
keywords) keep going to the command line so mixed text like "Bolt12"
isn't split.

OFFSET reworked: object first, then through-mode by default — the offset
follows the cursor at the perpendicular distance to the object. Typing a
number locks the magnitude (cursor then only picks the side); a value
typed and clicked without Enter is applied before the point pick.

The dynamic-input overlay and the command line now show the active
step's prompt and hold it until the step completes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-09 18:32:03 +03:00
1 parent c9c6e45793
commit 4b878b88c4
15 files changed
+405 -70

No files matched your search

+8
View File
@@ -781,6 +781,14 @@ impl CadCommand for CircleTTRCommand {
matches!(self.step, StepTTR::Radius { .. })
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(self.step, StepTTR::Radius { .. }) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn prompt(&self) -> String {
match &self.step {
StepTTR::First => "CIRCLE TTR Select first tangent object:".into(),
+8
View File
@@ -55,6 +55,14 @@ impl CadCommand for DonutCommand {
matches!(self.state, DonutState::AskInner | DonutState::AskOuter)
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(self.state, DonutState::AskInner | DonutState::AskOuter) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let val: f64 = text
.trim()
+24
View File
@@ -351,6 +351,14 @@ impl CadCommand for PolyCommand {
self.step == 0
}
fn dyn_field(&self) -> crate::command::DynField {
if self.step == 0 {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Ok(n) = text.trim().parse::<u32>() {
if (3..=1024).contains(&n) {
@@ -443,6 +451,14 @@ impl CadCommand for PolyCCommand {
self.step == 0
}
fn dyn_field(&self) -> crate::command::DynField {
if self.step == 0 {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Ok(n) = text.trim().parse::<u32>() {
if (3..=1024).contains(&n) {
@@ -544,6 +560,14 @@ impl CadCommand for PolyECommand {
self.step == 0
}
fn dyn_field(&self) -> crate::command::DynField {
if self.step == 0 {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Ok(n) = text.trim().parse::<u32>() {
if (3..=1024).contains(&n) {
+16
View File
@@ -56,6 +56,14 @@ impl CadCommand for DivideCommand {
self.waiting_for_n
}
fn dyn_field(&self) -> crate::command::DynField {
if self.waiting_for_n {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let n: usize = text.trim().parse().ok().filter(|&n| n >= 2)?;
let handle = self.target?;
@@ -117,6 +125,14 @@ impl CadCommand for MeasureCommand {
self.waiting_for_dist
}
fn dyn_field(&self) -> crate::command::DynField {
if self.waiting_for_dist {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let dist: f64 = text
.trim()
+24
View File
@@ -122,6 +122,10 @@ impl CadCommand for ArrayRectCommand {
true
}
fn dyn_field(&self) -> crate::command::DynField {
crate::command::DynField::Scalar
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim().replace(',', ".");
let t = t.as_str();
@@ -279,6 +283,14 @@ impl CadCommand for ArrayPolarCommand {
matches!(self.step, PolarStep::Count { .. } | PolarStep::Angle { .. })
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(self.step, PolarStep::Count { .. } | PolarStep::Angle { .. }) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim().replace(',', ".");
let t = t.as_str();
@@ -601,6 +613,14 @@ impl CadCommand for ArrayPathCommand {
matches!(self.step, PathStep::Count { .. })
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(self.step, PathStep::Count { .. }) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult {
if handle.is_null() || self.handles.contains(&handle) {
return CmdResult::NeedPoint;
@@ -811,6 +831,10 @@ impl CadCommand for Array3DCommand {
true
}
fn dyn_field(&self) -> crate::command::DynField {
crate::command::DynField::Scalar
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim().replace(',', ".");
let t = t.as_str();
+19
View File
@@ -1235,6 +1235,14 @@ impl CadCommand for FilletCommand {
matches!(self.step, FilletStep::WaitingForRadius)
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(self.step, FilletStep::WaitingForRadius) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
match self.step {
FilletStep::WaitingForRadius => {
@@ -1574,6 +1582,17 @@ impl CadCommand for ChamferCommand {
)
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(
self.step,
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2
) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
match self.step {
ChamferStep::WaitingForDist1 => {
+8
View File
@@ -68,6 +68,14 @@ impl CadCommand for LengthenCommand {
matches!(self.state, LenState::PickOption { .. })
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(self.state, LenState::PickOption { .. }) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let (handle, pick_pt) = match &self.state {
LenState::PickOption { handle, pick_pt } => (*handle, *pick_pt),
+107 -55
View File
@@ -381,6 +381,44 @@ fn compute_offset(entity: &EntityType, dist: f64, side_pt: Vec3) -> Option<Entit
}
}
// ── Through-mode distance ─────────────────────────────────────────────────
//
// Nearest distance from the cursor to the entity outline, used by "through"
// mode so the offset copy passes through the cursor. Measured against the
// tessellated wire (point-to-segment), which approximates the perpendicular
// distance for every supported entity type.
fn perp_distance(entity: &EntityType, pt: Vec3) -> f64 {
let pts = entity_wire_pts(entity);
if pts.len() < 2 {
return 0.0;
}
let px = pt.x as f64;
let py = pt.y as f64;
let mut best = f64::INFINITY;
for w in pts.windows(2) {
let ax = w[0][0] as f64;
let ay = w[0][1] as f64;
let bx = w[1][0] as f64;
let by = w[1][1] as f64;
let dx = bx - ax;
let dy = by - ay;
let len2 = dx * dx + dy * dy;
let t = if len2 < 1e-12 {
0.0
} else {
(((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0)
};
let cx = ax + t * dx;
let cy = ay + t * dy;
let d = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt();
if d < best {
best = d;
}
}
best
}
// ── Wire preview points ─────────────────────────────────────────────────────
fn entity_wire_pts(e: &EntityType) -> Vec<[f32; 3]> {
@@ -546,29 +584,27 @@ fn lwpolyline_pts(p: &LwPolyline) -> Vec<[f32; 3]> {
// ── Command implementation ─────────────────────────────────────────────────
enum Step {
Distance,
SelectObject {
dist: f64,
},
SelectObject,
// `locked == None` is "through" mode: the offset magnitude follows the
// cursor (perpendicular distance to the picked object). Typing a value
// locks the magnitude; the cursor then only chooses the side.
PickSide {
dist: f64,
#[allow(dead_code)]
handle: Handle,
entity: EntityType,
locked: Option<f64>,
},
}
pub struct OffsetCommand {
default_dist: f64,
step: Step,
all_entities: Vec<EntityType>,
}
impl OffsetCommand {
pub fn new(last_dist: f32, all_entities: Vec<EntityType>) -> Self {
pub fn new(all_entities: Vec<EntityType>) -> Self {
Self {
default_dist: last_dist as f64,
step: Step::Distance,
step: Step::SelectObject,
all_entities,
}
}
@@ -581,56 +617,26 @@ impl CadCommand for OffsetCommand {
fn prompt(&self) -> String {
match &self.step {
Step::Distance => format!(
"OFFSET Specify offset distance <{:.4}>:",
self.default_dist
Step::SelectObject => "OFFSET Select object to offset:".into(),
Step::PickSide {
locked: Some(d), ..
} => format!("OFFSET Click side [distance {d:.4}, type to change]:"),
Step::PickSide { locked: None, .. } => format!(
"OFFSET Click through point, or type a distance (last {:.4}):",
defaults::get_offset_dist()
),
Step::SelectObject { dist } => {
format!("OFFSET Select object to offset [dist={dist:.4}]:")
}
Step::PickSide { dist, .. } => {
format!("OFFSET Specify side to offset [dist={dist:.4}]:")
}
}
}
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::Distance)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Step::Distance = self.step {
let dist = if text.trim().is_empty() {
self.default_dist
} else {
text.trim()
.replace(',', ".")
.parse::<f64>()
.unwrap_or(self.default_dist)
.abs()
.max(1e-9)
};
defaults::set_offset_dist(dist as f32);
self.default_dist = dist;
self.step = Step::SelectObject { dist };
}
None
}
fn needs_entity_pick(&self) -> bool {
matches!(self.step, Step::SelectObject { .. })
matches!(self.step, Step::SelectObject)
}
fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult {
if handle.is_null() {
if handle.is_null() || !matches!(self.step, Step::SelectObject) {
return CmdResult::NeedPoint;
}
let dist = match &self.step {
Step::SelectObject { dist } => *dist,
_ => return CmdResult::NeedPoint,
};
let entity = self
.all_entities
.iter()
@@ -643,9 +649,9 @@ impl CadCommand for OffsetCommand {
| Some(e @ EntityType::Arc(_))
| Some(e @ EntityType::LwPolyline(_)) => {
self.step = Step::PickSide {
dist,
handle,
entity: e,
locked: None,
};
CmdResult::NeedPoint
}
@@ -653,6 +659,44 @@ impl CadCommand for OffsetCommand {
}
}
// The side step accepts an optional typed magnitude — through mode by
// default, a fixed distance once the user types one.
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::PickSide { .. })
}
fn dyn_field(&self) -> crate::command::DynField {
match self.step {
Step::PickSide { .. } => crate::command::DynField::Scalar,
_ => crate::command::DynField::Point,
}
}
fn dyn_live_value(&self, cursor: Vec3) -> Option<f64> {
match &self.step {
Step::PickSide { entity, locked, .. } => {
Some(locked.unwrap_or_else(|| perp_distance(entity, cursor)))
}
_ => None,
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Step::PickSide { locked, .. } = &mut self.step {
let t = text.trim().replace(',', ".");
if !t.is_empty() {
if let Ok(d) = t.parse::<f64>() {
let d = d.abs().max(1e-9);
defaults::set_offset_dist(d as f32);
*locked = Some(d);
}
}
// Stay on the side step — the click chooses which side.
return Some(CmdResult::NeedPoint);
}
None
}
fn on_hover_entity(&mut self, handle: Handle, _pt: Vec3) -> Vec<WireModel> {
if handle.is_null() {
return vec![];
@@ -676,24 +720,32 @@ impl CadCommand for OffsetCommand {
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
let (dist, entity) = match &self.step {
Step::PickSide { dist, entity, .. } => (*dist, entity.clone()),
let (locked, entity) = match &self.step {
Step::PickSide { locked, entity, .. } => (*locked, entity.clone()),
_ => return CmdResult::NeedPoint,
};
let mag = locked.unwrap_or_else(|| perp_distance(&entity, pt));
if mag < 1e-9 {
return CmdResult::NeedPoint;
}
match compute_offset(&entity, dist, pt) {
match compute_offset(&entity, mag, pt) {
Some(new_entity) => CmdResult::CommitAndExit(new_entity),
None => CmdResult::NeedPoint,
}
}
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
let (dist, entity) = match &self.step {
Step::PickSide { dist, entity, .. } => (*dist, entity.clone()),
let (locked, entity) = match &self.step {
Step::PickSide { locked, entity, .. } => (*locked, entity.clone()),
_ => return vec![],
};
let mag = locked.unwrap_or_else(|| perp_distance(&entity, pt));
if mag < 1e-9 {
return vec![];
}
if let Some(result) = compute_offset(&entity, dist, pt) {
if let Some(result) = compute_offset(&entity, mag, pt) {
let pts = entity_wire_pts(&result);
if !pts.is_empty() {
return vec![WireModel::solid(