STRETCH: arcs keep their height; splines stretch; real preview

An arc moved only when its centre was in the window, so stretching a
rounded end left the arc where it was or blew it up. Its ends now decide:
both in move it, one in moves that end while the arc keeps its height
(sagitta) over the new chord. Polyline arc segments keep their height the
same way instead of their bulge, spline control and fit points in the
window move, and 3D polylines and points stretch too. Old-style polylines
were tested in X/Z.

The preview moved the tessellated points that happened to be in the
window, which bent arcs out of shape; it now draws the stretched entities
through one shared function the commit also uses, via a new
preview_entities hook, and hides the originals while it shows.

Closes #1851

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-16 22:40:02 +03:00
1 parent 519d6605f9
commit 907fa8d760
6 files changed
+372 -288

No files matched your search

+4 -196
View File
@@ -293,10 +293,6 @@ impl OpenCADStudio {
})
};
let dx = delta.x as f64;
let dy = delta.y as f64; // drawing plane is world XY
let dz = delta.z as f64;
// Dimensions whose points moved — their baked *D block is
// stale afterwards and must be dropped (see #398 / #372).
let mut stretched_dims: Vec<codec::Handle> = Vec::new();
@@ -343,198 +339,10 @@ impl OpenCADStudio {
let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) else {
continue;
};
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 {
continue;
};
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 {
*p = world;
}
}
codec::EntityType::Polyline2D(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::Polyline(p) => {
for v in &mut p.vertices {
if in_win(v.location.x, v.location.z) {
v.location.x += dx;
v.location.z += dy;
stretched = true;
}
}
}
codec::EntityType::Arc(a) => {
if in_win(a.center.x, a.center.y) {
a.center.x += dx;
a.center.y += dy;
a.center.z += dz;
stretched = true;
}
}
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();
stretched_dims.push(*handle);
}
}
_ => {
// Generic: move entire entity (treat as block-level)
stretched = false; // skip generic types
}
let stretched =
crate::modules::draw::modify::stretch::stretch_entity(entity, &windows, delta);
if stretched && matches!(entity, codec::EntityType::Dimension(_)) {
stretched_dims.push(*handle);
}
if stretched {
if let Some(before) = before {
+8 -2
View File
@@ -798,10 +798,16 @@ impl OpenCADStudio {
}
}
let cur = self.tabs[i].last_cursor_world;
let previews = self.tabs[i]
let tab = &mut self.tabs[i];
let previews = tab
.active_cmd
.as_mut()
.map(|c| c.on_preview_wires(cur))
.map(|c| {
let mut wires = c.on_preview_wires(cur);
let entities = c.preview_entities(&tab.scene.document, cur);
wires.extend(tab.scene.wires_for_entities(&entities));
wires
})
.unwrap_or_default();
let preview_hidden = self.tabs[i]
.active_cmd
+4 -1
View File
@@ -2974,8 +2974,11 @@ impl OpenCADStudio {
None
}
});
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
let tab = &mut self.tabs[i];
if let Some(cmd) = tab.active_cmd.as_mut() {
p.extend(cmd.on_preview_wires_with_tangent(effective, live_tangent));
let entities = cmd.preview_entities(&tab.scene.document, effective);
p.extend(tab.scene.wires_for_entities(&entities));
}
p
} else if needs_entity && deferred_command_hover {
+7
View File
@@ -2974,6 +2974,13 @@ pub trait CadCommand: Send {
self.on_preview_wires(pt)
}
/// Entities the host draws as the live preview, tessellated like the
/// drawing's own (arcs, text, linetypes and all). Commands whose preview
/// changes an entity's shape return edited copies of it here.
fn preview_entities(&mut self, _document: &codec::CadDocument, _pt: DVec3) -> Vec<EntityType> {
Vec::new()
}
/// Source entities replaced by the current live preview. The host removes
/// these from the resident render until the command commits or cancels.
/// Commands such as COPY keep their sources visible and use the default.
+349 -20
View File
@@ -11,9 +11,11 @@
// 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.
// 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.
// All others : move the whole entity if any point is inside.
// Spline : control and fit points inside move.
// All others : left as they are.
use crate::t;
use codec::Handle;
@@ -23,6 +25,325 @@ 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.
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 {
@@ -247,28 +568,38 @@ impl CadCommand for StretchCommand {
}
}
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 } => {
let delta = pt - *base;
let windows: Vec<_> = self
.windows
.iter()
.map(|(win_min, win_max)| (win_min.as_vec3(), win_max.as_vec3()))
.collect();
let mut out: Vec<WireModel> = self
.wire_models
.iter()
.map(|wire| wire.stretched_windows(&windows, delta.as_vec3()))
.collect();
out.push(WireModel::solid(
vec![WireModel::solid(
"rubber_band".into(),
vec![
[base.x as f32, base.y as f32, base.z as f32],
@@ -276,9 +607,7 @@ impl CadCommand for StretchCommand {
],
WireModel::CYAN,
false,
));
out
)]
}
_ => vec![],
}
-69
View File
@@ -1085,75 +1085,6 @@ 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 {
self.stretched_windows(&[(win_min, win_max)], delta)
}
/// Return a clone for a multi-window STRETCH preview. A point moves exactly
/// once when it lies inside any of the crossing windows.
pub fn stretched_windows(
&self,
windows: &[(glam::Vec3, glam::Vec3)],
delta: glam::Vec3,
) -> Self {
let mut out = self.clone();
out.name = format!("preview_{}", self.name);
out.color = Self::CYAN;
out.selected = false;
let inside = |x: f32, y: f32| {
windows.iter().any(|(win_min, win_max)| {
x >= win_min.x && x <= win_max.x && y >= win_min.y && y <= win_max.y
})
};
if let Some(marker) = out.point_marker {
if inside(marker.origin.x as f32, marker.origin.y as f32) {
map_points(&mut out.points, &mut out.points_low, |point| {
point + delta.as_dvec3()
});
if let Some(target) = &mut out.point_marker {
target.origin += delta.as_dvec3();
}
}
} else {
for p in &mut out.points {
if inside(p[0], p[1]) {
p[0] += delta.x;
p[1] += delta.y;
p[2] += delta.z;
}
}
}
if !out.text_verts.is_empty() {
let (dx, dy, dz) = (delta.x as f64, delta.y as f64, delta.z as f64);
out.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
let inside = windows.iter().any(|(win_min, win_max)| {
x >= win_min.x as f64
&& x <= win_max.x as f64
&& y >= win_min.y as f64
&& y <= win_max.y as f64
});
if inside {
(x + dx, y + dy, z + dz)
} else {
(x, y, z)
}
});
}
out.searchable_text.clear();
out
}
/// Return a clone mirrored across the line through `p1`→`p2`.
pub fn mirrored(&self, p1: glam::Vec3, p2: glam::Vec3) -> Self {
self.mirrored_in_plane(p1, p2, glam::Vec3::Z)