Files
OneCAD-Draft/src/modules/draw/modify/stretch.rs
T
Karim Jerbi 042f26a4cc feat(blocks): wire Stretch actions and independent reference evaluation
Author a native Stretch action from a Linear, Polar or XY parameter, a two-corner stretch frame, an object selection and a marker. Preview and retain its frame in BEDIT, with selectable and movable action markers.

Freeze line and planar lightweight-polyline vertex bindings, preserve curved-segment height, move wholly selected objects, and evaluate independent saved INSERTs from their definition. Retain native DWG/DXF data, copied-reference isolation and undo/redo.

Validate with synthetic frame, topology, parameter, history, Test Block, drawing-reference, COPY and roundtrip regressions: 340 block tests pass (6 existing opt-in ignored), plus 41 dock and 27 viewport tests. Other entity types, Polar Stretch, custom multipliers and chaining remain unsupported.
2026-10-17 10:03:59 +01:00

622 lines
23 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 : ends inside move; a lone end keeps the arc's height.
// Circle : move the whole entity if its center is inside the window.
// Insert : move the whole entity if its insertion point is inside.
// Spline : control and fit points inside move.
// All others : left as they are.
use crate::t;
use codec::Handle;
use glam::DVec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
// ── Stretching one entity ──────────────────────────────────────────────────
/// Stretch `entity` by `delta`: the points of it that lie in one of the
/// crossing `windows` (world XY) move, the rest stay. Arcs and polyline arc
/// segments keep their height (sagitta) as the chord under them changes.
/// Returns whether anything moved. The command's preview and its commit
/// both go through here.
pub fn stretch_entity(
entity: &mut codec::EntityType,
windows: &[(DVec3, DVec3)],
delta: DVec3,
) -> bool {
let in_win = |x: f64, y: f64| -> bool {
windows.iter().any(|(win_min, win_max)| {
x >= win_min.x && x <= win_max.x && y >= win_min.y && y <= win_max.y
})
};
let (dx, dy, dz) = (delta.x, delta.y, delta.z);
let mut stretched = false;
match entity {
codec::EntityType::Line(l) => {
let s_in = in_win(l.start.x, l.start.y);
let e_in = in_win(l.end.x, l.end.y);
if s_in {
l.start.x += dx;
l.start.y += dy;
l.start.z += dz;
stretched = true;
}
if e_in {
l.end.x += dx;
l.end.y += dy;
l.end.z += dz;
stretched = true;
}
}
codec::EntityType::LwPolyline(p) => {
let Some(mut world) = crate::entities::curve::lwpolyline_world_xy(p) else {
return false;
};
let before: Vec<[f64; 2]> =
world.vertices.iter().map(|v| [v.location.x, v.location.y]).collect();
for v in &mut world.vertices {
if in_win(v.location.x, v.location.y) {
v.location.x += dx;
v.location.y += dy;
stretched = true;
}
}
if stretched {
let after: Vec<[f64; 2]> =
world.vertices.iter().map(|v| [v.location.x, v.location.y]).collect();
let closed = world.is_closed;
for (k, v) in world.vertices.iter_mut().enumerate() {
v.bulge = held_bulge(v.bulge, &before, &after, k, closed);
}
*p = world;
}
}
codec::EntityType::Polyline2D(p) => {
let before: Vec<[f64; 2]> =
p.vertices.iter().map(|v| [v.location.x, v.location.y]).collect();
for v in &mut p.vertices {
if in_win(v.location.x, v.location.y) {
v.location.x += dx;
v.location.y += dy;
stretched = true;
}
}
if stretched {
let after: Vec<[f64; 2]> =
p.vertices.iter().map(|v| [v.location.x, v.location.y]).collect();
let closed = p.is_closed();
for (k, v) in p.vertices.iter_mut().enumerate() {
v.bulge = held_bulge(v.bulge, &before, &after, k, closed);
}
}
}
codec::EntityType::Polyline(p) => {
for v in &mut p.vertices {
if in_win(v.location.x, v.location.y) {
v.location.x += dx;
v.location.y += dy;
stretched = true;
}
}
}
codec::EntityType::Arc(a) => stretched = stretch_arc(a, &in_win, delta),
codec::EntityType::Circle(c) => {
if in_win(c.center.x, c.center.y) {
c.center.x += dx;
c.center.y += dy;
c.center.z += dz;
stretched = true;
}
}
codec::EntityType::Ellipse(e) => {
if in_win(e.center.x, e.center.y) {
e.center.x += dx;
e.center.y += dy;
e.center.z += dz;
stretched = true;
}
}
codec::EntityType::Insert(ins) => {
if in_win(ins.insert_point.x, ins.insert_point.y) {
ins.insert_point.x += dx;
ins.insert_point.y += dy;
ins.insert_point.z += dz;
stretched = true;
}
}
codec::EntityType::Text(t) => {
if in_win(t.insertion_point.x, t.insertion_point.y) {
t.insertion_point.x += dx;
t.insertion_point.y += dy;
t.insertion_point.z += dz;
stretched = true;
}
}
codec::EntityType::MText(t) => {
if in_win(t.insertion_point.x, t.insertion_point.y) {
t.insertion_point.x += dx;
t.insertion_point.y += dy;
t.insertion_point.z += dz;
stretched = true;
}
}
codec::EntityType::Viewport(vp) => {
stretched = windows.iter().any(|(win_min, win_max)| {
crate::entities::viewport::stretch(vp, *win_min, *win_max, delta)
});
}
codec::EntityType::Dimension(dim) => {
use codec::entities::Dimension;
// Move every definition point that falls inside the
// window — the same points the grips expose — then
// refresh the stored measurement so the value tracks
// the stretched geometry.
let mv = |p: &mut codec::types::Vector3| {
if in_win(p.x, p.y) {
p.x += dx;
p.y += dy;
p.z += dz;
true
} else {
false
}
};
match &mut **dim {
Dimension::Linear(d) => {
stretched |= mv(&mut d.first_point);
stretched |= mv(&mut d.second_point);
stretched |= mv(&mut d.definition_point);
}
Dimension::Aligned(d) => {
stretched |= mv(&mut d.first_point);
stretched |= mv(&mut d.second_point);
stretched |= mv(&mut d.definition_point);
}
Dimension::Radius(d) => {
stretched |= mv(&mut d.angle_vertex);
stretched |= mv(&mut d.definition_point);
}
Dimension::Diameter(d) => {
stretched |= mv(&mut d.angle_vertex);
stretched |= mv(&mut d.definition_point);
}
Dimension::Angular2Ln(d) => {
stretched |= mv(&mut d.angle_vertex);
stretched |= mv(&mut d.first_point);
stretched |= mv(&mut d.second_point);
stretched |= mv(&mut d.definition_point);
}
Dimension::Angular3Pt(d) => {
stretched |= mv(&mut d.angle_vertex);
stretched |= mv(&mut d.first_point);
stretched |= mv(&mut d.second_point);
stretched |= mv(&mut d.definition_point);
}
Dimension::Ordinate(d) => {
stretched |= mv(&mut d.feature_location);
stretched |= mv(&mut d.leader_endpoint);
}
Dimension::Arc(d) => {
stretched |= mv(&mut d.definition_point);
stretched |= mv(&mut d.first_extension_point);
stretched |= mv(&mut d.second_extension_point);
stretched |= mv(&mut d.center_point);
if d.has_leader {
stretched |= mv(&mut d.first_leader_point);
stretched |= mv(&mut d.second_leader_point);
}
}
Dimension::LargeRadial(d) => {
stretched |= mv(&mut d.definition_point);
stretched |= mv(&mut d.chord_point);
stretched |= mv(&mut d.override_center);
stretched |= mv(&mut d.jog_point);
}
}
// Pinned text follows too; the zero sentinel means
// "auto placement" and must not be captured by a
// window that happens to cover the origin.
let t = dim.base().text_middle_point;
if t.x * t.x + t.y * t.y + t.z * t.z > 1e-16 {
let mut t = t;
if mv(&mut t) {
dim.base_mut().text_middle_point = t;
stretched = true;
}
}
if stretched {
dim.base_mut().actual_measurement = dim.measurement();
}
}
codec::EntityType::Polyline3D(p) => {
for v in &mut p.vertices {
if in_win(v.position.x, v.position.y) {
v.position.x += dx;
v.position.y += dy;
v.position.z += dz;
stretched = true;
}
}
}
// The control and fit points in the window move; the rest stay.
codec::EntityType::Spline(s) => {
for p in s.control_points.iter_mut().chain(s.fit_points.iter_mut()) {
if in_win(p.x, p.y) {
p.x += dx;
p.y += dy;
p.z += dz;
stretched = true;
}
}
}
codec::EntityType::Point(p) => {
if in_win(p.location.x, p.location.y) {
p.location.x += dx;
p.location.y += dy;
p.location.z += dz;
stretched = true;
}
}
_ => {}
}
stretched
}
/// The bulge of the segment from vertex `k` once its ends moved from
/// `before` to `after`: the arc keeps its height, bulge · chord / 2.
pub(crate) fn held_bulge(
bulge: f64,
before: &[[f64; 2]],
after: &[[f64; 2]],
k: usize,
closed: bool,
) -> f64 {
let next = if k + 1 < before.len() {
k + 1
} else if closed {
0
} else {
return bulge;
};
let chord = |p: &[[f64; 2]]| (p[next][0] - p[k][0]).hypot(p[next][1] - p[k][1]);
let (old, new) = (chord(before), chord(after));
if bulge == 0.0 || old <= 1e-12 || new <= 1e-12 {
bulge
} else {
bulge * old / new
}
}
/// Stretch an arc by its ends: both in the window move it whole; one moves
/// that end while the arc keeps its height over the new chord.
fn stretch_arc(arc: &mut codec::entities::Arc, in_win: &dyn Fn(f64, f64) -> bool, delta: DVec3) -> bool {
use codec::types::{Matrix3, Vector3};
let ocs = Matrix3::arbitrary_axis(arc.normal);
let end = |angle: f64| {
Vector3::new(
arc.center.x + arc.radius * angle.cos(),
arc.center.y + arc.radius * angle.sin(),
arc.center.z,
)
};
let (start, finish) = (end(arc.start_angle), end(arc.end_angle));
let (start_w, finish_w) = (ocs * start, ocs * finish);
let (start_in, finish_in) = (in_win(start_w.x, start_w.y), in_win(finish_w.x, finish_w.y));
if !start_in && !finish_in {
return false;
}
let local = ocs.transpose() * Vector3::new(delta.x, delta.y, delta.z);
if start_in && finish_in {
arc.center = arc.center + local;
return true;
}
let sweep = (arc.end_angle - arc.start_angle).rem_euclid(std::f64::consts::TAU);
let shift = |p: Vector3, moved: bool| if moved { p + local } else { p };
let (a, b) = (shift(start, start_in), shift(finish, finish_in));
let old_chord = (finish.x - start.x).hypot(finish.y - start.y);
let new_chord = (b.x - a.x).hypot(b.y - a.y);
if old_chord <= 1e-12 || new_chord <= 1e-12 {
return false;
}
let bulge = (sweep / 4.0).tan() * old_chord / new_chord;
// The arc through `a` and `b` with that bulge, counter-clockwise from
// `a`: its centre sits off the chord's midpoint by the apothem.
let sweep = 4.0 * bulge.atan();
let radius = new_chord / (2.0 * (sweep / 2.0).sin());
let apothem = radius * (sweep / 2.0).cos();
let (ux, uy) = ((b.x - a.x) / new_chord, (b.y - a.y) / new_chord);
let centre = Vector3::new(
(a.x + b.x) / 2.0 - uy * apothem,
(a.y + b.y) / 2.0 + ux * apothem,
arc.center.z,
);
arc.center = centre;
arc.radius = radius.abs();
arc.start_angle = (a.y - centre.y).atan2(a.x - centre.x);
arc.end_angle = (b.y - centre.y).atan2(b.x - centre.x);
true
}
// ── 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 corner of another crossing window.
WindowCorner1,
/// Waiting for the opposite corner.
WindowCorner2(DVec3),
/// Selection is complete; waiting for the displacement base point.
Base,
/// Waiting for the displacement target.
Target { base: DVec3 },
}
pub struct StretchCommand {
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
/// Independent crossing windows accumulated during the selection stage.
windows: Vec<(DVec3, DVec3)>,
step: Step,
}
impl StretchCommand {
pub fn new(handles: Vec<Handle>, wire_models: Vec<WireModel>) -> Self {
Self {
handles,
wire_models,
windows: Vec::new(),
step: Step::WindowCorner1,
}
}
/// A selection made by a crossing window before the command: that window
/// marks what moves, so the command goes straight to the base point.
pub fn preselected(
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
window: (DVec3, DVec3),
) -> Self {
Self {
handles,
wire_models,
windows: vec![window],
step: Step::Base,
}
}
/// Continue gathering crossing windows after the host has resolved the
/// entities touched by the latest window.
pub fn with_windows(
handles: Vec<Handle>,
wire_models: Vec<WireModel>,
windows: Vec<(DVec3, DVec3)>,
) -> Self {
Self {
handles,
wire_models,
windows,
step: Step::WindowCorner1,
}
}
/// 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.windows.is_empty() && self.handles.is_empty() {
t!("STRETCH Specify first corner of crossing window:").into_owned()
} else {
t!(
"STRETCH Specify first corner of another crossing window, or press Enter to continue:"
)
.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);
let mut windows = self.windows.clone();
windows.push((win_min, win_max));
// Hand the accumulated selection back to the host. The host resolves
// the entities touched by this window and relaunches STRETCH still in
// the selection stage, so another crossing window can be drawn.
CmdResult::StretchWindow {
handles: self.handles.clone(),
windows,
}
}
Step::Base => {
self.step = Step::Target { base: pt };
CmdResult::NeedPoint
}
Step::Target { base } => {
let delta = pt - *base;
CmdResult::StretchEntities {
handles: self.handles.clone(),
windows: self.windows.clone(),
delta,
}
}
}
}
fn on_enter(&mut self) -> CmdResult {
if let Step::WindowCorner1 = self.step {
// Preserve the existing preselection behaviour: if STRETCH started
// from a selected set and no explicit crossing window was drawn,
// treat its complete bounds as the stretch window.
if self.windows.is_empty() {
if let Some((win_min, win_max)) = self.selection_bounds() {
self.windows.push((win_min, win_max));
}
}
if !self.handles.is_empty() && !self.windows.is_empty() {
self.step = Step::Base;
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 selects_by_window(&self) -> bool {
// Crossing windows select what stretches; dynamic input has nothing
// to enter there (#657).
self.window_corner_pick()
}
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 preview_entities(&mut self, document: &codec::CadDocument, pt: DVec3) -> Vec<codec::EntityType> {
let Step::Target { base } = &self.step else {
return Vec::new();
};
let delta = pt - *base;
self.handles
.iter()
.filter_map(|handle| document.get_entity(*handle))
.map(|entity| {
let mut entity = entity.clone();
stretch_entity(&mut entity, &self.windows, delta);
entity
})
.collect()
}
fn preview_hidden_handles(&self) -> &[Handle] {
match self.step {
Step::Target { .. } => &self.handles,
_ => &[],
}
}
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![],
// The stretched entities themselves come from `preview_entities`.
Step::Target { base } => {
vec![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,
)]
}
_ => vec![],
}
}
}