forked from 1b-agent/opencadstudio-upstream
STRETCH ended itself when a crossing window caught nothing, so a missed aim meant restarting the command to try again. A missed aim is not a decision to stop: say what happened and ask for the corner afresh, the way a selection that picks nothing leaves MOVE still asking. Enter now ends the selection stage too. With objects already picked the window is only there to narrow them, so there is nothing left to choose — Enter takes a window containing all of them, which stretches every vertex, which is the whole selection moving. That is what a pick-selected stretch means. With nothing picked there is nothing to end, so Enter still cancels. The prompt says so. Not changed: with a prior selection, a window that misses every vertex still goes on to ask for the base point. The command holds wires, not entities, and the entity rules do not follow the wires — a small window inside a circle catches its centre, and so the whole circle, while no tessellated vertex is inside. Guarding on the wires would refuse that stretch. Closes #676 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
271 lines
9.8 KiB
Rust
271 lines
9.8 KiB
Rust
// Stretch tool — ribbon definition + interactive command.
|
|
//
|
|
// Command: STRETCH (SS)
|
|
// Workflow:
|
|
// 1. Pick first corner of the crossing window (right-to-left = crossing).
|
|
// 2. Pick second corner.
|
|
// 3. Pick base point.
|
|
// 4. Pick new point → stretches only vertices inside the crossing window.
|
|
//
|
|
// Entity behaviour:
|
|
// Line : move start if inside, move end if inside, move both if both inside.
|
|
// LwPolyline : move each vertex independently.
|
|
// Polyline/P2D: move each vertex independently.
|
|
// Arc / Circle: move the whole entity if its center is inside the window.
|
|
// Insert : move the whole entity if its insertion point is inside.
|
|
// All others : move the whole entity if any point is inside.
|
|
|
|
use acadrust::Handle;
|
|
use glam::DVec3;
|
|
use crate::t;
|
|
|
|
use crate::command::{CadCommand, CmdResult};
|
|
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
|
use crate::scene::model::wire_model::WireModel;
|
|
|
|
// ── Ribbon definition ──────────────────────────────────────────────────────
|
|
|
|
pub fn tool() -> ToolDef {
|
|
ToolDef {
|
|
id: "STRETCH",
|
|
label: "Stretch",
|
|
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/stretch.svg")),
|
|
event: ModuleEvent::Command("STRETCH".to_string()),
|
|
}
|
|
}
|
|
|
|
// ── Command implementation ─────────────────────────────────────────────────
|
|
|
|
enum Step {
|
|
/// Waiting for the first crossing-window corner.
|
|
WindowCorner1,
|
|
/// Waiting for the second corner; `c1` is the first corner.
|
|
WindowCorner2(DVec3),
|
|
/// Crossing window defined; waiting for base point.
|
|
Base { win_min: DVec3, win_max: DVec3 },
|
|
/// Waiting for target point.
|
|
Target {
|
|
win_min: DVec3,
|
|
win_max: DVec3,
|
|
base: DVec3,
|
|
},
|
|
}
|
|
|
|
pub struct StretchCommand {
|
|
handles: Vec<Handle>,
|
|
wire_models: Vec<WireModel>,
|
|
step: Step,
|
|
}
|
|
|
|
impl StretchCommand {
|
|
pub fn new(handles: Vec<Handle>, wire_models: Vec<WireModel>) -> Self {
|
|
Self {
|
|
handles,
|
|
wire_models,
|
|
step: Step::WindowCorner1,
|
|
}
|
|
}
|
|
|
|
/// Restart at the base-point step with the window already known — the
|
|
/// implicit-selection flow: the host resolved which entities the crossing
|
|
/// window touches and hands them in together with the window. (#338)
|
|
pub fn with_window(
|
|
handles: Vec<Handle>,
|
|
wire_models: Vec<WireModel>,
|
|
win_min: DVec3,
|
|
win_max: DVec3,
|
|
) -> Self {
|
|
Self {
|
|
handles,
|
|
wire_models,
|
|
step: Step::Base { win_min, win_max },
|
|
}
|
|
}
|
|
|
|
/// A window enclosing every vertex of the current selection, or `None`
|
|
/// when nothing is selected. Padded so a vertex exactly on the boundary
|
|
/// counts as inside rather than depending on the comparison's edge.
|
|
fn selection_bounds(&self) -> Option<(DVec3, DVec3)> {
|
|
let mut min = DVec3::splat(f64::INFINITY);
|
|
let mut max = DVec3::splat(f64::NEG_INFINITY);
|
|
let mut any = false;
|
|
for wire in &self.wire_models {
|
|
for index in 0..wire.points.len() {
|
|
let point = wire_point(wire, index);
|
|
if !point.is_finite() {
|
|
continue;
|
|
}
|
|
min = min.min(point);
|
|
max = max.max(point);
|
|
any = true;
|
|
}
|
|
}
|
|
if !any {
|
|
return None;
|
|
}
|
|
let pad = (max - min).length().max(1.0) * 1e-6;
|
|
Some((min - DVec3::splat(pad), max + DVec3::splat(pad)))
|
|
}
|
|
}
|
|
|
|
/// A wire vertex rebuilt from its double-single halves.
|
|
fn wire_point(wire: &WireModel, index: usize) -> DVec3 {
|
|
let high = wire.points[index];
|
|
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
|
|
DVec3::new(
|
|
high[0] as f64 + low[0] as f64,
|
|
high[1] as f64 + low[1] as f64,
|
|
high[2] as f64 + low[2] as f64,
|
|
)
|
|
}
|
|
|
|
impl CadCommand for StretchCommand {
|
|
fn name(&self) -> &'static str {
|
|
"STRETCH"
|
|
}
|
|
|
|
fn prompt(&self) -> String {
|
|
match &self.step {
|
|
Step::WindowCorner1 => {
|
|
if self.handles.is_empty() {
|
|
// Implicit mode: the window both selects the objects and
|
|
// marks which of their points move.
|
|
t!("STRETCH Specify first corner of crossing window:").into_owned()
|
|
} else {
|
|
// The window narrows a selection that already exists, so
|
|
// it is optional — Enter takes the whole of it, the way a
|
|
// selection ends in MOVE or COPY. (#676)
|
|
t!(
|
|
"STRETCH Specify first corner of crossing window, or press Enter to stretch all [%{count} objects]:",
|
|
count = self.handles.len()
|
|
)
|
|
.into_owned()
|
|
}
|
|
}
|
|
Step::WindowCorner2(_) => t!("STRETCH Specify opposite corner:").into_owned(),
|
|
Step::Base { .. } => t!("STRETCH Specify base point:").into_owned(),
|
|
Step::Target { base, .. } => {
|
|
let bx = format!("{:.3}", base.x);
|
|
let bz = format!("{:.3}", base.z);
|
|
t!(
|
|
"STRETCH Specify new point [base %{bx},%{bz}]:",
|
|
bx = bx,
|
|
bz = bz
|
|
)
|
|
.into_owned()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
|
match &self.step {
|
|
Step::WindowCorner1 => {
|
|
self.step = Step::WindowCorner2(pt);
|
|
CmdResult::NeedPoint
|
|
}
|
|
Step::WindowCorner2(c1) => {
|
|
let win_min = c1.min(pt);
|
|
let win_max = c1.max(pt);
|
|
if self.handles.is_empty() {
|
|
// Implicit mode: hand the window to the host, which
|
|
// resolves the touched entities and restarts the command
|
|
// at the base-point step via `with_window`. (#338)
|
|
return CmdResult::StretchWindow { win_min, win_max };
|
|
}
|
|
self.step = Step::Base { win_min, win_max };
|
|
CmdResult::NeedPoint
|
|
}
|
|
Step::Base { win_min, win_max } => {
|
|
let (wmin, wmax) = (*win_min, *win_max);
|
|
self.step = Step::Target {
|
|
win_min: wmin,
|
|
win_max: wmax,
|
|
base: pt,
|
|
};
|
|
CmdResult::NeedPoint
|
|
}
|
|
Step::Target {
|
|
win_min,
|
|
win_max,
|
|
base,
|
|
} => {
|
|
let delta = pt - *base;
|
|
CmdResult::StretchEntities {
|
|
handles: self.handles.clone(),
|
|
win_min: *win_min,
|
|
win_max: *win_max,
|
|
delta,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_enter(&mut self) -> CmdResult {
|
|
// Enter ends the selection stage, as it does in MOVE and COPY. With
|
|
// objects already picked there is nothing left to choose: a window that
|
|
// contains all of them stretches every vertex, which is the whole
|
|
// selection moving — what a pick-selected stretch means. Without a
|
|
// selection there is nothing to end, so Enter still cancels. (#676)
|
|
if let Step::WindowCorner1 = self.step {
|
|
if let Some((win_min, win_max)) = self.selection_bounds() {
|
|
self.step = Step::Base { win_min, win_max };
|
|
return CmdResult::NeedPoint;
|
|
}
|
|
}
|
|
CmdResult::Cancel
|
|
}
|
|
fn on_escape(&mut self) -> CmdResult {
|
|
CmdResult::Cancel
|
|
}
|
|
|
|
fn window_corner_pick(&self) -> bool {
|
|
// The two crossing-window corners are free points; Ortho/Polar must not
|
|
// pin the opposite corner to an axis or the window becomes a line (#291).
|
|
matches!(self.step, Step::WindowCorner1 | Step::WindowCorner2(_))
|
|
}
|
|
|
|
fn window_first_corner(&self) -> Option<DVec3> {
|
|
// Expose the first corner so the host draws a filled crossing marquee to
|
|
// the cursor, matching a normal box selection instead of a bare outline.
|
|
match &self.step {
|
|
Step::WindowCorner2(c1) => Some(*c1),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn on_preview_wires(&mut self, pt: DVec3) -> Vec<WireModel> {
|
|
match &self.step {
|
|
// The crossing-window rectangle is drawn as a filled selection
|
|
// marquee by the host (via window_first_corner) so it matches a
|
|
// normal box selection — nothing to draw here. (#291)
|
|
Step::WindowCorner2(_) => vec![],
|
|
Step::Target {
|
|
win_min,
|
|
win_max,
|
|
base,
|
|
} => {
|
|
let delta = pt - *base;
|
|
// Live ghost: vertices inside the crossing window follow the
|
|
// cursor, the rest stay anchored. This is the preview/GPU path,
|
|
// so downcast to f32 only at the WireModel boundary.
|
|
let mut out: Vec<WireModel> = self
|
|
.wire_models
|
|
.iter()
|
|
.map(|w| w.stretched((*win_min).as_vec3(), (*win_max).as_vec3(), delta.as_vec3()))
|
|
.collect();
|
|
out.push(WireModel::solid(
|
|
"rubber_band".into(),
|
|
vec![
|
|
[base.x as f32, base.y as f32, base.z as f32],
|
|
[pt.x as f32, pt.y as f32, pt.z as f32],
|
|
],
|
|
WireModel::CYAN,
|
|
false,
|
|
));
|
|
out
|
|
}
|
|
_ => vec![],
|
|
}
|
|
}
|
|
}
|