Files
OneCAD-Draft/src/app/update/dynamic.rs
T
Hakan SevenandClaude Fable 5 a7fd9ecb45 fix(dyn): an explicit minus keeps its sign in the angle field
The dynamic-input angle field stripped the typed sign and re-signed
the value from the cursor's side of the X axis, so a negative angle
could not be entered at all — 330 had to stand in for -30 (#417). An
explicit -/+ prefix is now taken literally; unsigned entries keep the
cursor-side behaviour.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 11:55:17 +03:00

593 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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<DynComponent> = 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<DynComponent> = 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<DynComponent> =
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<crate::command::DynRole> =
spec.fields.iter().map(|f| f.role).collect();
let cur_roles: Vec<crate::command::DynRole> =
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),
};
self.tabs[i].dyn_ref = spec.ref_point;
}
/// 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);
}
}
/// Push the live Ctrl state into tab `i`'s active command before it builds a
/// preview/commit (arc-direction flip on `ARC_CONT`, etc.).
pub(in crate::app) fn push_ctrl_to_cmd(&mut self, i: usize) {
let ctrl = self.ctrl_down;
if let Some(c) = self.tabs[i].active_cmd.as_mut() {
c.set_ctrl(ctrl);
}
}
/// Cache the endpoint + exit tangent of a just-committed line/arc so a
/// following `ARC_CONT` starts tangentially from where drawing ended. Set to
/// `None` for any other entity kind. Uses `last_point` (the final pick) to
/// pick the endpoint the pen actually finished on.
pub(in crate::app) fn update_cont_anchor(&mut self, entity: &acadrust::EntityType) {
let last = self.last_point;
self.cont_anchor = crate::modules::draw::draw::arc::continue_anchor(entity, last);
}
pub(in crate::app) fn dyn_resolve_point(&self) -> Option<glam::DVec3> {
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::DVec3::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 normally takes the sign of the
// cursor's delta on that axis (`live` is the signed delta in the
// cartesian arms). But once the user separates the two values with `,`
// — e.g. the rectangle opposite corner entered as "50,50" — the entry
// is a cartesian coordinate pair, not cursor-signed W/H dimensions, so
// the typed sign must stay literal. `,` is the only thing that sets
// `dyn_user_reshaped`, so it cleanly distinguishes comma-cartesian entry
// from Tab-separated dimension entry (#269).
let comma_cartesian = self.dyn_user_reshaped;
let val = |idx: usize, live: f64| -> f64 {
match fields[idx]
.buffer
.as_ref()
.map(|s| s.trim().replace(',', "."))
.and_then(|s| crate::app::expr_eval::eval_number(&s))
.map(|v| v / fields[idx].role.value_scale() as f64)
{
Some(v) => {
if matches!(
fields[idx].role,
crate::command::DynRole::Width | crate::command::DynRole::Height
) && !comma_cartesian
{
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. An EXPLICIT
// `-`/`+` prefix keeps its literal sign though — "-30" must mean
// −30° (= 330°) regardless of where the cursor sits (#417).
// Untyped → live.
let angle_rad = |idx: usize| -> f64 {
let raw = fields[idx]
.buffer
.as_ref()
.map(|s| s.trim().replace(',', "."));
let explicit_sign = raw
.as_deref()
.is_some_and(|s| s.starts_with('-') || s.starts_with('+'));
match raw.and_then(|s| crate::app::expr_eval::eval_number(&s)) {
Some(v) if explicit_sign => v.to_radians(),
Some(mag) => mag.abs().to_radians().copysign(dy),
None => live_a,
}
};
let comps: Vec<DynComponent> = 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::DVec3::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));
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::DVec3| base + xf.vec_to_wcs(off_ucs);
match comps.as_slice() {
[DynComponent::X, DynComponent::Y] if has_base => {
Some(rel(glam::DVec3::new(val(0, dx), val(1, dy), 0.0)))
}
[DynComponent::X, DynComponent::Y] => {
Some(glam::DVec3::new(val(0, w.x), val(1, w.y), base.z))
}
[DynComponent::X, DynComponent::Y, DynComponent::Z] if has_base => {
Some(rel(glam::DVec3::new(val(0, dx), val(1, dy), val(2, dz))))
}
[DynComponent::X, DynComponent::Y, DynComponent::Z] => {
Some(glam::DVec3::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::DVec3::new(d * a.cos(), d * a.sin(), 0.0)))
}
[DynComponent::Distance] => {
// Keep the cursor's direction (in UCS), override the magnitude.
let dir = glam::DVec3::new(dx, dy, 0.0).normalize_or(glam::DVec3::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::DVec3::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<DynComponent> = 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::<f32>().is_err() {
return text;
}
let anchor = self.tabs[i]
.dyn_anchor
.or(self.last_point)
.unwrap_or(glam::DVec3::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::f64::consts::PI {
d -= std::f64::consts::TAU;
}
while d <= -std::f64::consts::PI {
d += std::f64::consts::TAU;
}
if d < 0.0 {
format!("-{t}")
} else {
text
}
}
pub(in crate::app) fn try_dyn_commit(&mut self) -> Option<Task<Message>> {
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()) {
// Distance is typed, so resolve in f64: the tracking ray
// origin/dir come from the screen-space snapper (f32) but
// the magnitude the user entered stays exact.
let pt = base.as_dvec3() + dir.as_dvec3() * dist;
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));
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));
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))
.unwrap_or_default();
self.tabs[i].scene.set_preview_wires(previews);
}
}