feat(stretch): live ghost preview while dragging the new point

STRETCH now shows a live ghost where vertices inside the crossing window
follow the cursor and the rest stay anchored, instead of only a rubber-band
line. Add WireModel::stretched and pass the selection's wires into the
command.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-15 23:43:07 +03:00
1 parent 8ab9bdfbe1
commit e2be8197b4
3 files changed
+50 -23

No files matched your search

+2 -1
View File
@@ -1567,7 +1567,8 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(cmd));
} else {
use crate::modules::home::modify::stretch::StretchCommand;
let new_cmd = StretchCommand::new(handles);
let wires = self.tabs[i].scene.wire_models_for(&handles);
let new_cmd = StretchCommand::new(handles, wires);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
+28 -22
View File
@@ -52,13 +52,15 @@ enum Step {
pub struct StretchCommand {
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
step: Step,
}
impl StretchCommand {
pub fn new(handles: Vec<Handle>) -> Self {
pub fn new(handles: Vec<Handle>, wire_models: Vec<WireModel>) -> Self {
Self {
handles,
wire_models,
step: Step::WindowCorner1,
}
}
@@ -128,7 +130,7 @@ impl CadCommand for StretchCommand {
CmdResult::Cancel
}
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
match &self.step {
Step::WindowCorner2(c1) => {
// Show crossing-window rectangle preview (dashed green)
@@ -140,31 +142,35 @@ impl CadCommand for StretchCommand {
[c1.x, pt.y, pt.z],
[c1.x, c1.y, c1.z],
];
Some(WireModel::solid(
vec![WireModel::solid(
"stretch_window".into(),
pts,
[0.3, 1.0, 0.3, 0.7],
false,
))
)]
}
Step::Target { base, .. } => Some(WireModel {
name: "rubber_band".into(),
points: vec![[base.x, base.y, base.z], [pt.x, pt.y, pt.z]],
color: WireModel::CYAN,
selected: false,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
aci: 0,
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
}),
_ => None,
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.
let mut out: Vec<WireModel> = self
.wire_models
.iter()
.map(|w| w.stretched(*win_min, *win_max, delta))
.collect();
out.push(WireModel::solid(
"rubber_band".into(),
vec![[base.x, base.y, base.z], [pt.x, pt.y, pt.z]],
WireModel::CYAN,
false,
));
out
}
_ => vec![],
}
}
}
+20
View File
@@ -155,6 +155,26 @@ impl WireModel {
out
}
/// Return a clone for a stretch preview: every point whose XY lies inside
/// the crossing window `[win_min, win_max]` is translated by `delta`; points
/// outside stay put. Exact for line/polyline vertices (the primary stretch
/// targets); curve tessellation points may deform where a window edge cuts
/// through them, matching the per-vertex nature of the operation.
pub fn stretched(&self, win_min: glam::Vec3, win_max: glam::Vec3, delta: glam::Vec3) -> Self {
let mut out = self.clone();
out.name = format!("preview_{}", self.name);
out.color = Self::CYAN;
out.selected = false;
for p in &mut out.points {
if p[0] >= win_min.x && p[0] <= win_max.x && p[1] >= win_min.y && p[1] <= win_max.y {
p[0] += delta.x;
p[1] += delta.y;
p[2] += delta.z;
}
}
out
}
/// Return a clone mirrored across the line through `p1`→`p2`.
pub fn mirrored(&self, p1: glam::Vec3, p2: glam::Vec3) -> Self {
let ax = p2.x - p1.x;