Fix ribbon active state, polar snap, FILLET pick mode, and arc preview plane

- Deactivate ribbon tool highlight when command ends (issue #1)
- Reset last_point on new command dispatch to fix polar snap offset (issue #2)
- FILLET/CHAMFER: disable text input during entity pick so clicks reach viewport (issue #3)
- Dropdown icon buttons now use RibbonToolClick so they highlight blue when active
- FILLET/CHAMFER: AutoCAD-style sub-step UX — typing R/D + Enter prompts for value
- Fix FILLET arc preview plane: arc was rendering in XZ instead of XY
- Bump version to 0.1.1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-03-31 00:31:28 +03:00
commit a88542a69a
6 changed files with 162 additions and 44 deletions

View file

@ -1,6 +1,6 @@
[package]
name = "H7CAD"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
build = "build.rs"

View file

@ -290,6 +290,7 @@ impl H7CAD {
if self.tabs[i].active_cmd.is_some() {
self.focus_cmd_input()
} else {
self.ribbon.deactivate_tool();
self.blur_cmd_input()
}
}

View file

@ -12,6 +12,9 @@ impl H7CAD {
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
}
// Reset the last committed point so the first click of the new command
// is not constrained by ortho/polar relative to a previous command's endpoint.
self.last_point = None;
if let Some(path_str) = cmd.strip_prefix("OPEN_RECENT:") {
let path = PathBuf::from(path_str);

View file

@ -263,7 +263,7 @@ fn line_pts(l: &LineEnt) -> Vec<[f32; 3]> {
]
}
fn arc_pts(cx: f64, cy: f64, r: f64, a0_deg: f64, a1_deg: f64, y: f64) -> Vec<[f32; 3]> {
fn arc_pts(cx: f64, cy: f64, r: f64, a0_deg: f64, a1_deg: f64, z: f64) -> Vec<[f32; 3]> {
use std::f64::consts::TAU;
let fn_norm = |a: f64| -> f64 { ((a % TAU) + TAU) % TAU };
let a0 = a0_deg.to_radians();
@ -282,8 +282,8 @@ fn arc_pts(cx: f64, cy: f64, r: f64, a0_deg: f64, a1_deg: f64, y: f64) -> Vec<[f
let ang = fn_norm(a0) + span * (i as f64 / steps as f64);
[
(cx + r * ang.cos()) as f32,
y as f32,
(cy + r * ang.sin()) as f32,
z as f32,
]
})
.collect()
@ -298,7 +298,7 @@ fn entity_pts(e: &EntityType) -> Vec<[f32; 3]> {
a.radius,
a.start_angle,
a.end_angle,
a.center.y,
a.center.z,
),
_ => vec![],
}
@ -364,6 +364,7 @@ fn compute_chamfer(
enum FilletStep {
First,
WaitingForRadius,
Second {
h1: Handle,
l1: LineEnt,
@ -395,7 +396,11 @@ impl CadCommand for FilletCommand {
fn prompt(&self) -> String {
match &self.step {
FilletStep::First => format!(
"FILLET Select first line [R={:.4} | type R <val> to change]:",
"FILLET Select first line [R={:.4} | type R to change]:",
self.radius
),
FilletStep::WaitingForRadius => format!(
"FILLET Enter fillet radius <{:.4}>:",
self.radius
),
FilletStep::Second { .. } => {
@ -405,27 +410,58 @@ impl CadCommand for FilletCommand {
}
fn wants_text_input(&self) -> bool {
matches!(self.step, FilletStep::First)
matches!(self.step, FilletStep::WaitingForRadius)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim();
let body = if t.to_uppercase().starts_with('R') {
t[1..].trim()
} else {
t
};
if let Ok(v) = body.replace(',', ".").parse::<f64>() {
if v >= 0.0 {
self.radius = v;
defaults::set_fillet_radius(v as f32);
match self.step {
FilletStep::WaitingForRadius => {
let t = text.trim();
if t.is_empty() {
// Keep current radius, return to First
self.step = FilletStep::First;
return Some(CmdResult::NeedPoint);
}
if let Ok(v) = t.replace(',', ".").parse::<f64>() {
if v >= 0.0 {
self.radius = v;
defaults::set_fillet_radius(v as f32);
}
self.step = FilletStep::First;
return Some(CmdResult::NeedPoint);
}
// Invalid — stay and re-prompt
Some(CmdResult::NeedPoint)
}
FilletStep::First | FilletStep::Second { .. } => {
let t = text.trim();
let upper = t.to_uppercase();
// "R" alone → enter sub-step to collect radius
if upper == "R" {
self.step = FilletStep::WaitingForRadius;
return Some(CmdResult::NeedPoint);
}
// "R 5.0" inline shorthand
if upper.starts_with('R') {
let body = t[1..].trim();
if let Ok(v) = body.replace(',', ".").parse::<f64>() {
if v >= 0.0 {
self.radius = v;
defaults::set_fillet_radius(v as f32);
}
return Some(CmdResult::NeedPoint);
}
// "R" + invalid body → enter sub-step
self.step = FilletStep::WaitingForRadius;
return Some(CmdResult::NeedPoint);
}
None
}
}
None
}
fn needs_entity_pick(&self) -> bool {
true
!matches!(self.step, FilletStep::WaitingForRadius)
}
fn on_entity_pick(&mut self, handle: Handle, pt: Vec3) -> CmdResult {
@ -435,6 +471,7 @@ impl CadCommand for FilletCommand {
let click = [pt.x as f64, pt.y as f64];
match &self.step {
FilletStep::WaitingForRadius => return CmdResult::NeedPoint,
FilletStep::First => {
// Must be a line
let l1 = self
@ -510,6 +547,7 @@ impl CadCommand for FilletCommand {
let click = [pt.x as f64, pt.y as f64];
match &self.step {
FilletStep::WaitingForRadius => vec![],
FilletStep::First => {
// Highlight hovered line in cyan
let pts = self
@ -602,6 +640,8 @@ impl CadCommand for FilletCommand {
enum ChamferStep {
First,
WaitingForDist1,
WaitingForDist2,
Second {
h1: Handle,
l1: LineEnt,
@ -635,9 +675,17 @@ impl CadCommand for ChamferCommand {
fn prompt(&self) -> String {
match &self.step {
ChamferStep::First => format!(
"CHAMFER Select first line [D1={:.4} D2={:.4} | type D <d1> <d2>]:",
"CHAMFER Select first line [D1={:.4} D2={:.4} | type D to change]:",
self.dist1, self.dist2
),
ChamferStep::WaitingForDist1 => format!(
"CHAMFER Enter first chamfer distance <{:.4}>:",
self.dist1
),
ChamferStep::WaitingForDist2 => format!(
"CHAMFER Enter second chamfer distance <{:.4}>:",
self.dist2
),
ChamferStep::Second { .. } => format!(
"CHAMFER Select second line [D1={:.4} D2={:.4}]:",
self.dist1, self.dist2
@ -646,36 +694,89 @@ impl CadCommand for ChamferCommand {
}
fn wants_text_input(&self) -> bool {
matches!(self.step, ChamferStep::First)
matches!(
self.step,
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2
)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim();
let body = if t.to_uppercase().starts_with('D') {
t[1..].trim()
} else {
t
};
let parts: Vec<f64> = body
.split_whitespace()
.filter_map(|s| s.replace(',', ".").parse::<f64>().ok())
.collect();
if let Some(&v) = parts.first() {
self.dist1 = v.max(0.0);
defaults::set_chamfer_dist1(self.dist1 as f32);
match self.step {
ChamferStep::WaitingForDist1 => {
let t = text.trim();
if t.is_empty() {
// Keep current dist1, move on to dist2
self.step = ChamferStep::WaitingForDist2;
return Some(CmdResult::NeedPoint);
}
if let Ok(v) = t.replace(',', ".").parse::<f64>() {
self.dist1 = v.max(0.0);
defaults::set_chamfer_dist1(self.dist1 as f32);
self.step = ChamferStep::WaitingForDist2;
return Some(CmdResult::NeedPoint);
}
// Invalid — stay and re-prompt
Some(CmdResult::NeedPoint)
}
ChamferStep::WaitingForDist2 => {
let t = text.trim();
if t.is_empty() {
// Keep current dist2, return to First
self.step = ChamferStep::First;
return Some(CmdResult::NeedPoint);
}
if let Ok(v) = t.replace(',', ".").parse::<f64>() {
self.dist2 = v.max(0.0);
defaults::set_chamfer_dist2(self.dist2 as f32);
self.step = ChamferStep::First;
return Some(CmdResult::NeedPoint);
}
// Invalid — stay and re-prompt
Some(CmdResult::NeedPoint)
}
ChamferStep::First | ChamferStep::Second { .. } => {
let t = text.trim();
let upper = t.to_uppercase();
// "D" alone → enter sub-step to collect distances
if upper == "D" {
self.step = ChamferStep::WaitingForDist1;
return Some(CmdResult::NeedPoint);
}
// "D 5.0" or "D 5.0 3.0" inline shorthand
if upper.starts_with('D') {
let body = t[1..].trim();
let parts: Vec<f64> = body
.split_whitespace()
.filter_map(|s| s.replace(',', ".").parse::<f64>().ok())
.collect();
if !parts.is_empty() {
if let Some(&v) = parts.first() {
self.dist1 = v.max(0.0);
defaults::set_chamfer_dist1(self.dist1 as f32);
}
if let Some(&v) = parts.get(1) {
self.dist2 = v.max(0.0);
defaults::set_chamfer_dist2(self.dist2 as f32);
} else {
self.dist2 = self.dist1;
defaults::set_chamfer_dist2(self.dist2 as f32);
}
return Some(CmdResult::NeedPoint);
}
// "D" + invalid body → enter sub-step
self.step = ChamferStep::WaitingForDist1;
return Some(CmdResult::NeedPoint);
}
None
}
}
if let Some(&v) = parts.get(1) {
self.dist2 = v.max(0.0);
defaults::set_chamfer_dist2(self.dist2 as f32);
} else if parts.len() == 1 {
self.dist2 = self.dist1;
defaults::set_chamfer_dist2(self.dist2 as f32);
}
None
}
fn needs_entity_pick(&self) -> bool {
true
!matches!(
self.step,
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2
)
}
fn on_entity_pick(&mut self, handle: Handle, pt: Vec3) -> CmdResult {
@ -685,6 +786,9 @@ impl CadCommand for ChamferCommand {
let click = [pt.x as f64, pt.y as f64];
match &self.step {
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 => {
return CmdResult::NeedPoint;
}
ChamferStep::First => {
let l1 = self
.all_entities
@ -750,6 +854,7 @@ impl CadCommand for ChamferCommand {
let click = [pt.x as f64, pt.y as f64];
match &self.step {
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 => return vec![],
ChamferStep::First => {
let pts = self
.all_entities

View file

@ -103,6 +103,9 @@ impl Ribbon {
pub fn activate_tool(&mut self, id: &str) {
self.active_tool = Some(id.to_string());
}
pub fn deactivate_tool(&mut self) {
self.active_tool = None;
}
pub fn set_wireframe(&mut self, w: bool) {
self.wireframe = w;
}

View file

@ -211,7 +211,10 @@ pub(super) fn render_small<'a>(
let tip_text = format!("{}\nCommand: {}", cur_label, last);
let icon_btn = button(make_icon(cur_icon, SMALL_ICON))
.on_press(Message::Command(last.to_string()))
.on_press(Message::RibbonToolClick {
tool_id: last.to_string(),
event: ModuleEvent::Command(last.to_string()),
})
.style(move |_: &Theme, status| tool_btn_style(active, status))
.width(Length::Fixed(SMALL_W))
.height(ROW_H)
@ -332,7 +335,10 @@ pub(super) fn render_large<'a>(
.align_x(iced::Center)
.spacing(3),
)
.on_press(Message::Command(last.to_string()))
.on_press(Message::RibbonToolClick {
tool_id: last.to_string(),
event: ModuleEvent::Command(last.to_string()),
})
.style(move |_: &Theme, status| tool_btn_style(active, status))
.width(Length::Fixed(LARGE_W))
.height(Fill)