//! `dynamic` arms and helpers, split out of the original `update.rs` (#mechanical decomposition). #![allow(unused_imports)] use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind, }; use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS}; use crate::modules::ModuleEvent; use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit}; use crate::scene::model::object::GripApply; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; use crate::ui::PropertiesPanel; use acadrust::types::Color as AcadColor; use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; impl OpenCADStudio { /// Rebuild the active tab's dynamic-input field set to match what the /// command is currently asking for. Called on cursor move and after /// command-state changes. The field set only changes shape when the /// command's `dyn_field()` or the presence of a base point changes; /// existing typed buffers survive an unchanged shape. pub(in crate::app) fn sync_dyn_fields(&mut self) { use crate::app::document::{DynComponent, DynFieldEntry}; let i = self.active_tab; if !self.dyn_input || self.tabs[i].active_cmd.is_none() { self.tabs[i].dyn_fields.clear(); self.tabs[i].dyn_active = 0; return; } // A command may describe its step explicitly via `dyn_spec()` — that // takes full control of the boxes, guide and anchor. Otherwise fall // back to the legacy `dyn_field()` shaping below. if let Some(spec) = self.tabs[i].active_cmd.as_ref().and_then(|c| c.dyn_spec()) { self.apply_dyn_spec(i, spec); return; } let field = self.tabs[i] .active_cmd .as_ref() .map(|c| c.dyn_field()) .unwrap_or(crate::command::DynField::Point); // A text-input step reads a single scalar. The overlay shows one box // the user types into (or, when the command supplies a live value, // sets by moving the cursor); on commit the host routes it to // `on_text_input` instead of `on_point`. A distance prompt keeps the // `Distance` box (so a perpendicular-distance live value reads // naturally); everything else uses the typed-only `Scalar` box. let wants_text = self.tabs[i] .active_cmd .as_ref() .map(|c| c.wants_text_input()) .unwrap_or(false); // A point step that also accepts keyword letters (PLINE A/L/C…) keeps // its polar boxes: only letters reach the command line, digits stay // coordinates. So such a step is NOT treated as a text-only prompt. let point_keywords = self.tabs[i] .active_cmd .as_ref() .map(|c| c.point_step_accepts_keywords()) .unwrap_or(false); let wants_text = wants_text && !point_keywords; // A step that hit-tests for an object (entity / structure pick) has no // coordinate to enter — clicks select, they don't place a point. Show // no coordinate box so the cursor stays clean and typed option keywords // (e.g. FILLET's "R") reach the command line instead of an X/Y field. let picks_object = self.tabs[i] .active_cmd .as_ref() .map(|c| c.needs_entity_pick() || c.needs_structure_point_pick()) .unwrap_or(false); let has_base = self.last_point.is_some(); // While aligned to an OTRACK ray, the point step reads a single // distance along the ray (issue #69) — show one Distance box. let otrack_dist = self.otrack_active.is_some() && !wants_text && matches!(field, crate::command::DynField::Point); let default: Vec = match field { _ if otrack_dist => vec![DynComponent::Distance], crate::command::DynField::Distance => vec![DynComponent::Distance], crate::command::DynField::Angle => vec![DynComponent::Angle], crate::command::DynField::Scalar => vec![DynComponent::Scalar], // A text prompt with the default `Point` field reads free text / // a name / a keyword (which may itself contain digits) from the // command line — show no scalar box, so digits reach the command // line instead of being captured into a dyn buffer. crate::command::DynField::Point if wants_text || picks_object => vec![], crate::command::DynField::Point if has_base => { vec![DynComponent::Distance, DynComponent::Angle] } crate::command::DynField::Point => vec![DynComponent::X, DynComponent::Y], }; // Multiple shapes can satisfy the same command request — e.g. a // `Point` is happy with either `[Distance, Angle]` (polar) or // `[X, Y]` / `[X, Y, Z]` (cartesian). If the user already // reshaped via `,` (see #35) the existing set is still a valid // Point configuration and must not be reverted on every mouse // move. let current: Vec = self.tabs[i] .dyn_fields .iter() .map(|f| f.component) .collect(); // Only treat a cartesian / polar variant as "good enough to keep" // when the user explicitly reshaped via `,`. Otherwise we follow // the command's default so e.g. clicking the first point of LINE // flips a stale `[X, Y]` (from before there was a base) over to // the polar `[Distance, Angle]` the prompt actually wants. let current_is_acceptable = if self.dyn_user_reshaped && !wants_text && !picks_object { match field { crate::command::DynField::Distance => { matches!(current.as_slice(), [DynComponent::Distance]) } crate::command::DynField::Angle => { matches!(current.as_slice(), [DynComponent::Angle]) } crate::command::DynField::Scalar => { matches!(current.as_slice(), [DynComponent::Scalar]) } crate::command::DynField::Point => matches!( current.as_slice(), [DynComponent::Distance, DynComponent::Angle] | [DynComponent::X, DynComponent::Y] | [DynComponent::X, DynComponent::Y, DynComponent::Z] ), } } else { current == default }; if !current_is_acceptable { self.tabs[i].dyn_fields = default.into_iter().map(DynFieldEntry::new).collect(); self.tabs[i].dyn_active = 0; } // Derive the guide + anchor for the legacy field set so the overlay // draws the right construction without each command opting in. let comps: Vec = self.tabs[i].dyn_fields.iter().map(|f| f.component).collect(); self.tabs[i].dyn_guide = match comps.as_slice() { [DynComponent::Distance, DynComponent::Angle] | [DynComponent::Angle] => { crate::command::DynGuide::Polar } [DynComponent::Distance] => crate::command::DynGuide::Radius, _ => crate::command::DynGuide::None, }; self.tabs[i].dyn_anchor = self.last_point; self.tabs[i].dyn_ref = None; } /// Apply an explicit per-step [`DynSpec`](crate::command::DynSpec): rebuild /// the boxes from its roles (preserving typed buffers when the role set is /// unchanged), and set the guide + anchor. pub(in crate::app) fn apply_dyn_spec(&mut self, i: usize, spec: crate::command::DynSpec) { use crate::app::document::DynFieldEntry; let new_roles: Vec = spec.fields.iter().map(|f| f.role).collect(); let cur_roles: Vec = self.tabs[i].dyn_fields.iter().map(|f| f.role).collect(); if cur_roles != new_roles { self.tabs[i].dyn_fields = spec.fields.iter().map(|f| DynFieldEntry::from_role(f.role)).collect(); self.tabs[i].dyn_active = 0; } self.tabs[i].dyn_guide = spec.guide; self.tabs[i].dyn_anchor = match spec.anchor { crate::command::DynAnchor::LastPoint => self.last_point, crate::command::DynAnchor::Point(p) => Some(p.as_vec3()), }; self.tabs[i].dyn_ref = spec.ref_point.map(|v| v.as_vec3()); } /// Track cursor dwell over a selected entity's grip. Sets /// `grip_hover` while the cursor sits within `GRIP_THRESHOLD_PX` of /// a grip and opens `grip_popup` once the dwell exceeds the /// threshold. Cursor drift clears both. /// After the active Model tile changes, mirror its stored visual style /// into the tab so the picker shows it and the tile renders with it /// (the active tile draws with the tab's live render mode). /// Resolve the world point implied by the current dynamic-input field /// values. Locked fields use their typed buffer; the rest fall back to /// the live cursor-derived value. Returns `None` when the field set /// isn't one we know how to turn into a point. /// Hand the active command the current UCS (as a UCS→wire affine) so /// axis-aligned constructions build square to the user's coordinate system. /// No-op for commands that don't override `set_ucs`. pub(in crate::app) fn push_ucs_to_cmd(&mut self, i: usize) { let ucs = self.tabs[i].ucs_wire_affine(); if let Some(c) = self.tabs[i].active_cmd.as_mut() { c.set_ucs(ucs); } } pub(in crate::app) fn dyn_resolve_point(&self) -> Option { use crate::app::document::DynComponent; let i = self.active_tab; let fields = &self.tabs[i].dyn_fields; if fields.is_empty() { return None; } let w = self.tabs[i].last_cursor_world; let base = self.tabs[i] .dyn_anchor .or(self.last_point) .unwrap_or(glam::Vec3::ZERO); // Buffer value parsed as f32 (de-scaled by the role so a typed diameter // becomes a radius), or the supplied geometric live value. Width/Height // are shown unsigned, so a typed value takes the sign of the cursor's // delta on that axis (`live` is the signed delta in the cartesian arms). let val = |idx: usize, live: f32| -> f32 { match fields[idx] .buffer .as_ref() .map(|s| s.trim().replace(',', ".")) .and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32)) .map(|v| v / fields[idx].role.value_scale()) { Some(v) => { if matches!( fields[idx].role, crate::command::DynRole::Width | crate::command::DynRole::Height ) { v.abs().copysign(live) } else { v } } None => live, } }; // Work in the active UCS frame: the cursor delta from the base is // rotated into UCS, so typed cartesian/polar values are interpreted in // the user's coordinate system and mapped back to world on return. // (The delta is offset-invariant, so only the rotation matters.) let xf = self.tabs[i].ucs_xform(); let d_ucs = xf.vec_to_ucs(w - base); let dx = d_ucs.x; let dy = d_ucs.y; let dz = d_ucs.z; let live_d = (dx * dx + dy * dy).sqrt(); let live_a = dy.atan2(dx); // radians, in the UCS plane // A typed angle is shown unsigned (0..180); give it the sign of the // cursor's current side so an entry made below the X axis sweeps // downward to match the arc instead of mirroring up. Untyped → live. let angle_rad = |idx: usize| -> f32 { match fields[idx] .buffer .as_ref() .map(|s| s.trim().replace(',', ".")) .and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32)) { Some(mag) => mag.abs().to_radians().copysign(dy), None => live_a, } }; let comps: Vec = fields.iter().map(|f| f.component).collect(); // Perpendicular offset: a single distance measured square to the // reference line (anchor → dyn_ref). The committed point lies on the // perpendicular through the anchor at that offset; the command projects // it. Untyped tracks the cursor's signed offset; typed takes the // cursor's side. if let (Some(ref_pt), [DynComponent::Distance]) = (self.tabs[i].dyn_ref, comps.as_slice()) { let axis = (ref_pt - base).normalize_or_zero(); let perp = glam::Vec3::new(-axis.y, axis.x, 0.0); let signed = (w - base).dot(perp); let typed = fields[0] .buffer .as_ref() .map(|s| s.trim().replace(',', ".")) .and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32)); let h = match typed { Some(v) => v.abs().copysign(signed), None => signed, }; return Some(base + perp * h); } // DYN-on defaults to RELATIVE coordinates when a base point is set // (see #26 / #35). The live cartesian fallback is the cursor // position relative to base; typed values are relative deltas. let has_base = self.last_point.is_some(); // Relative result: base + the typed UCS-frame offset mapped to world. let rel = |off_ucs: glam::Vec3| base + xf.vec_to_wcs(off_ucs); match comps.as_slice() { [DynComponent::X, DynComponent::Y] if has_base => { Some(rel(glam::Vec3::new(val(0, dx), val(1, dy), 0.0))) } [DynComponent::X, DynComponent::Y] => { Some(glam::Vec3::new(val(0, w.x), val(1, w.y), base.z)) } [DynComponent::X, DynComponent::Y, DynComponent::Z] if has_base => { Some(rel(glam::Vec3::new(val(0, dx), val(1, dy), val(2, dz)))) } [DynComponent::X, DynComponent::Y, DynComponent::Z] => { Some(glam::Vec3::new(val(0, w.x), val(1, w.y), val(2, base.z))) } [DynComponent::Distance, DynComponent::Angle] => { let d = val(0, live_d); let a = angle_rad(1); Some(rel(glam::Vec3::new(d * a.cos(), d * a.sin(), 0.0))) } [DynComponent::Distance] => { // Keep the cursor's direction (in UCS), override the magnitude. let dir = glam::Vec3::new(dx, dy, 0.0).normalize_or(glam::Vec3::X); Some(rel(dir * val(0, live_d))) } [DynComponent::Angle] => { // Standalone angle (e.g. ROTATE): the typed value is an // absolute CCW angle in the UCS plane, not a cursor-signed // magnitude — keep it literal. Only the polar Distance+Angle // pair uses the cursor-signed `angle_rad`. let a = val(0, live_a.to_degrees()).to_radians(); Some(rel(glam::Vec3::new(live_d * a.cos(), live_d * a.sin(), 0.0))) } _ => None, } } /// Handle `,` while a dynamic-input field set is showing. Locks the /// current field's buffer if it has one, then either advances within /// the existing field set or reshapes it: a polar `[Distance, Angle]` /// configuration becomes cartesian `[X(buf), Y]`, and a cartesian /// `[X, Y]` configuration extends to `[X, Y, Z]`. Default fallthrough /// is "advance to next field", matching `Tab`. See #35. pub(in crate::app) fn dyn_comma_advance(&mut self) { use crate::app::document::{DynComponent, DynFieldEntry}; let i = self.active_tab; if self.tabs[i].dyn_fields.is_empty() { return; } // The user picked a shape — `sync_dyn_fields` preserves it until // the next commit / command-start clears the flag. self.dyn_user_reshaped = true; let active = self.tabs[i] .dyn_active .min(self.tabs[i].dyn_fields.len() - 1); let comps: Vec = self.tabs[i] .dyn_fields .iter() .map(|f| f.component) .collect(); let cur_buf = self.tabs[i].dyn_fields[active].buffer.clone(); match (comps.as_slice(), active) { // First polar field — `,` switches to cartesian, locking the // typed value as X. ([DynComponent::Distance, DynComponent::Angle], 0) | ([DynComponent::Distance], 0) => { let mut x_field = DynFieldEntry::new(DynComponent::X); x_field.buffer = cur_buf; self.tabs[i].dyn_fields = vec![x_field, DynFieldEntry::new(DynComponent::Y)]; self.tabs[i].dyn_active = 1; } // Already cartesian X (first field) — just advance to Y. ([DynComponent::X, DynComponent::Y], 0) | ([DynComponent::X, DynComponent::Y, DynComponent::Z], 0) => { self.tabs[i].dyn_active = 1; } // Cartesian Y — extend to 3-D by appending Z. ([DynComponent::X, DynComponent::Y], 1) => { self.tabs[i] .dyn_fields .push(DynFieldEntry::new(DynComponent::Z)); self.tabs[i].dyn_active = 2; } // Cartesian Y in the 3-D set — advance to Z. ([DynComponent::X, DynComponent::Y, DynComponent::Z], 1) => { self.tabs[i].dyn_active = 2; } // Z, Angle, or any singleton: nothing further to advance to. _ => {} } } /// If dynamic input has at least one locked (typed) field, resolve the /// implied point, feed it to the active command as a point pick, reset /// the field buffers, and return the resulting task. Returns `None` /// when there is nothing typed, so the caller falls back to its normal /// Enter handling. /// Give a bare typed angle the sign of the cursor's side relative to the /// step's reference direction, so a commit-as-text angle rotates/sweeps the /// way the cursor is dragging. Only applies to steps with an `Angle` field; /// an explicit `+`/`-` is left untouched. Returns the (possibly re-signed) /// text to feed `on_text_input`. pub(in crate::app) fn dyn_sign_angle_text(&self, i: usize, text: String) -> String { let has_angle = self.tabs[i] .dyn_fields .iter() .any(|f| f.role == crate::command::DynRole::Angle); let t = text.trim(); if !has_angle || t.is_empty() || t.starts_with('-') || t.starts_with('+') { return text; } if t.parse::().is_err() { return text; } let anchor = self.tabs[i] .dyn_anchor .or(self.last_point) .unwrap_or(glam::Vec3::ZERO); let cur = self.tabs[i].last_cursor_world; let a_cur = (cur.y - anchor.y).atan2(cur.x - anchor.x); let a_ref = self.tabs[i] .dyn_ref .map(|r| (r.y - anchor.y).atan2(r.x - anchor.x)) .unwrap_or(0.0); let mut d = a_cur - a_ref; while d > std::f32::consts::PI { d -= std::f32::consts::TAU; } while d <= -std::f32::consts::PI { d += std::f32::consts::TAU; } if d < 0.0 { format!("-{t}") } else { text } } pub(in crate::app) fn try_dyn_commit(&mut self) -> Option> { let i = self.active_tab; if !self.dyn_input || self.tabs[i].active_cmd.is_none() || self.tabs[i].dyn_fields.is_empty() || !self.tabs[i].dyn_fields.iter().any(|f| f.locked()) { return None; } // OTRACK: while aligned to a tracking ray, a typed value is a distance // along the ray from the tracking point (issue #69). if let Some((base, dir)) = self.otrack_active { let wants_text = self.tabs[i] .active_cmd .as_ref() .map(|c| c.wants_text_input()) .unwrap_or(false); if !wants_text { if let Some(text) = self.tabs[i] .dyn_fields .iter() .find_map(|f| f.buffer.clone()) { if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) { let pt = base + dir * dist as f32; self.last_point = Some(pt); for f in self.tabs[i].dyn_fields.iter_mut() { f.buffer = None; } self.tabs[i].dyn_active = 0; self.dyn_user_reshaped = false; self.sync_dyn_fields(); self.reset_tracking_after_point(); self.push_ucs_to_cmd(i); let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt.as_dvec3())); let task = result.map(|r| self.apply_cmd_result(r))?; self.refresh_active_cmd_preview(i); return Some(task); } } } } // A text-input step reads its single box as a string and commits via // `on_text_input` (a count, radius, distance) rather than resolving a // point. Only the typed buffer matters here — a mouse-driven live // value commits through the viewport click, not Enter. // A point-with-keywords step (PLINE) commits a typed distance/angle as // a point, not as text, so it is excluded here. let wants_text = self.tabs[i] .active_cmd .as_ref() .map(|c| { (c.wants_text_input() && !c.point_step_accepts_keywords()) || c.dyn_commit_as_text() }) .unwrap_or(false); if wants_text { let text = self.tabs[i] .dyn_fields .iter() .find_map(|f| f.buffer.clone()) .unwrap_or_default(); let text = crate::app::expr_eval::eval_to_string(text.trim()); // Shared rule: a bare angle typed into a commit-as-text step takes // the sign of the cursor's side relative to the reference, so the // committed direction matches the drag (the box shows magnitude // only). Commands receive an already-signed string. let text = self.dyn_sign_angle_text(i, text); let result = self.tabs[i] .active_cmd .as_mut() .and_then(|c| c.on_text_input(&text)); for f in self.tabs[i].dyn_fields.iter_mut() { f.buffer = None; } self.tabs[i].dyn_active = 0; self.sync_dyn_fields(); let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { self.command_line.push_info(&p); } self.refresh_active_cmd_preview(i); return Some(match result { Some(r) => self.apply_cmd_result(r), None => self.focus_cmd_input(), }); } let pt = self.dyn_resolve_point()?; self.last_point = Some(pt); self.dyn_user_reshaped = false; self.sync_dyn_fields(); self.reset_tracking_after_point(); self.push_ucs_to_cmd(i); let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt.as_dvec3())); for f in self.tabs[i].dyn_fields.iter_mut() { f.buffer = None; } self.tabs[i].dyn_active = 0; let task = result.map(|r| self.apply_cmd_result(r))?; // Match the command-line path: refresh the rubber-band preview // so the next segment immediately starts from the new // last_point even though no mouse-move fires after a typed // coordinate. See #32. self.refresh_active_cmd_preview(i); Some(task) } /// Mutable access to the currently selected table style. /// Re-run the active command's preview hook against the current /// cursor world position. Keyboard-driven point commits (typed /// coordinates in the command line or dynamic input) don't fire a /// mouse-move event, so without this the rubber-band preview keeps /// dangling from the previous `last_point` until the user actually /// moves the mouse. See #32. pub(in crate::app) fn refresh_active_cmd_preview(&mut self, i: usize) { if self.tabs[i].active_cmd.is_none() { return; } let cur = self.tabs[i].last_cursor_world; let previews = self.tabs[i] .active_cmd .as_mut() .map(|c| c.on_preview_wires(cur.as_dvec3())) .unwrap_or_default(); self.tabs[i].scene.set_preview_wires(previews); } }