diff --git a/src/app/command_driver/transform.rs b/src/app/command_driver/transform.rs index dc4f070e3..1647e1d2f 100644 --- a/src/app/command_driver/transform.rs +++ b/src/app/command_driver/transform.rs @@ -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 = 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 { diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 421b8c94c..045ba0450 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -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 diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 5aae19817..d8ada18e6 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -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 { diff --git a/src/command.rs b/src/command.rs index f381dc489..3407f2005 100644 --- a/src/command.rs +++ b/src/command.rs @@ -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 { + 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. diff --git a/src/modules/draw/modify/stretch.rs b/src/modules/draw/modify/stretch.rs index 027b4d8eb..9e1886556 100644 --- a/src/modules/draw/modify/stretch.rs +++ b/src/modules/draw/modify/stretch.rs @@ -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 { + 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 { 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 = 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![], } diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index 2c0c7f12f..71b2a9f79 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -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)