// Offset tool — ribbon definition + interactive command. // // Command: OFFSET (O) // OFFSET: Creates a parallel copy of an object (line, arc, circle, // or lwpolyline) at a specified distance on the chosen side. // // Steps: // 1. Text input: "Specify offset distance :" → enter float or Enter for default // 2. Pick object to offset (Line, Arc, Circle, LwPolyline) // 3. Pick a point on the side to offset toward, or choose Multiple // to keep offsetting the newly created result at the same distance // // With objects already selected, the command opens at step 3 instead, the // preview following the cursor. At step 3 a typed distance places the // offset at once on the cursor's side; a negative one, on the other side. use crate::entities::curve::{entity_curve, lwpolyline_world_xy}; use codec::entities::LwVertex; use codec::entities::{ Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, Spline as SplineEnt, XLine as XLineEnt, }; use codec::{EntityType, Handle}; // Polyline offsetting, and the angle normalisation that goes with it, come // from the kernel; only the entity conversion stays here. use kernel::geom2d::{ offset_polyline, Polyline as KernelPolyline, PolylineVertex as KernelVertex, }; use glam::{DVec3, Vec3}; use crate::t; use crate::command::{CadCommand, CmdResult}; use crate::modules::draw::defaults; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use super::entity_index::ModifyEntityIndex; // ── Ribbon definition ────────────────────────────────────────────────────── pub fn tool() -> ToolDef { ToolDef { id: "OFFSET", label: "Offset", icon: IconKind::Svg(include_bytes!("../../../../assets/icons/offset.svg")), event: ModuleEvent::Command("OFFSET".to_string()), } } // ── Line offset ──────────────────────────────────────────────────────────── fn offset_line(l: &LineEnt, dist: f64, side_pt: Vec3) -> Option { let dx = l.end.x - l.start.x; let dy = l.end.y - l.start.y; let len = (dx * dx + dy * dy).sqrt(); if len < 1e-12 { return None; } let nx = -dy / len; // left-perpendicular let ny = dx / len; let vx = side_pt.x as f64 - l.start.x; let vy = side_pt.y as f64 - l.start.y; let cross = dx * vy - dy * vx; let sign = if cross >= 0.0 { 1.0 } else { -1.0 }; let ox = sign * nx * dist; let oy = sign * ny * dist; let mut new_l = l.clone(); new_l.common.handle = Handle::NULL; new_l.start.x += ox; new_l.start.y += oy; new_l.end.x += ox; new_l.end.y += oy; Some(EntityType::Line(new_l)) } // ── XLine (infinite construction line) offset ──────────────────────────────── // A parallel infinite line: same direction, base point shifted perpendicular by // `dist` toward the side the cursor is on. (#296) fn offset_xline(x: &XLineEnt, dist: f64, side_pt: Vec3) -> Option { let dx = x.direction.x; let dy = x.direction.y; let len = (dx * dx + dy * dy).sqrt(); if len < 1e-12 { return None; } let nx = -dy / len; // left-perpendicular let ny = dx / len; let vx = side_pt.x as f64 - x.base_point.x; let vy = side_pt.y as f64 - x.base_point.y; let cross = dx * vy - dy * vx; let sign = if cross >= 0.0 { 1.0 } else { -1.0 }; let mut new_x = x.clone(); new_x.common.handle = Handle::NULL; new_x.base_point.x += sign * nx * dist; new_x.base_point.y += sign * ny * dist; Some(EntityType::XLine(new_x)) } // ── Circle offset ────────────────────────────────────────────────────────── fn offset_circle(c: &CircleEnt, dist: f64, side_pt: Vec3) -> Option { let plane = crate::entities::curve::circle_curve(c).plane; let [px, py] = plane.project(side_pt.as_dvec3().to_array())?; let dc = ((px - c.center.x).powi(2) + (py - c.center.y).powi(2)).sqrt(); let new_r = if dc < c.radius { c.radius - dist } else { c.radius + dist }; if new_r <= 1e-9 { return None; } let mut new_c = c.clone(); new_c.common.handle = Handle::NULL; new_c.radius = new_r; Some(EntityType::Circle(new_c)) } // ── Arc offset ───────────────────────────────────────────────────────────── fn offset_arc(a: &ArcEnt, dist: f64, side_pt: Vec3) -> Option { let plane = crate::entities::curve::arc_curve(a).plane; let [px, py] = plane.project(side_pt.as_dvec3().to_array())?; let dc = ((px - a.center.x).powi(2) + (py - a.center.y).powi(2)).sqrt(); let new_r = if dc < a.radius { a.radius - dist } else { a.radius + dist }; if new_r <= 1e-9 { return None; } let mut new_a = a.clone(); new_a.common.handle = Handle::NULL; new_a.radius = new_r; Some(EntityType::Arc(new_a)) } // ── LwPolyline offset ────────────────────────────────────────────────────── // // A raw exact line/arc offset can fold over itself at concave corners or when // the distance is larger than a narrow part of the polyline. The selected // algorithm splits that raw curve at every intersection, rejects slices whose // distance to the source is below the requested offset, and stitches the // remaining slices. This can legitimately return several disconnected // polylines. /// Convert an acad LWPOLYLINE to the line/arc representation used by the /// topology pass. Coordinates are translated and divided by `dist`, so the /// offset passed to the algorithm is always ±1. This avoids fixed-epsilon /// failures on tiny drawings and on UTM-scale coordinates. fn offset_lwpolylines(p: &LwPolyline, dist: f64, side_pt: Vec3) -> Vec { let Some(p) = lwpolyline_world_xy(p) else { return Vec::new(); }; let source = KernelPolyline { closed: p.is_closed, vertices: p .vertices .iter() .map(|v| KernelVertex { position: [v.location.x, v.location.y], bulge: if v.bulge.is_finite() { v.bulge } else { 0.0 }, }) .collect(), }; offset_polyline(&source, dist, [side_pt.x as f64, side_pt.y as f64]) .into_iter() .map(|result| { let mut new_polyline = p.clone(); new_polyline.common.handle = Handle::NULL; new_polyline.is_closed = result.closed; new_polyline.vertices = result .vertices .iter() .map(|vertex| { let mut output = LwVertex::from_coords(vertex.position[0], vertex.position[1]); output.bulge = vertex.bulge; output }) .collect(); EntityType::LwPolyline(new_polyline) }) .collect() } #[cfg(test)] fn offset_lwpolyline(p: &LwPolyline, dist: f64, side_pt: Vec3) -> Option { offset_lwpolylines(p, dist, side_pt).into_iter().next() } // ── Ellipse and spline offset ────────────────────────────────────────────── // // Neither has a parallel of its own kind — an ellipse's is not an ellipse — // so both come back as a spline fitted through true offset points, within a // millionth of the curve's size, by the kernel. The work happens in the // curve's own plane, so a tilted or extruded one offsets in place. fn offset_ellipse(e: &EllipseEnt, dist: f64, side_pt: Vec3) -> Option { let source = EntityType::Ellipse(e.clone()); let mut template = SplineEnt::new(); template.common = e.common.clone(); offset_curved(&source, template, dist, side_pt) } fn offset_spline(spl: &SplineEnt, dist: f64, side_pt: Vec3) -> Option { offset_curved(&EntityType::Spline(spl.clone()), spl.clone(), dist, side_pt) } fn offset_curved( source: &EntityType, mut template: SplineEnt, dist: f64, side_pt: Vec3, ) -> Option { let planar = entity_curve(source)?; let side = planar .plane .project([side_pt.x as f64, side_pt.y as f64, side_pt.z as f64])?; // Left of the curve's direction of travel is the kernel's positive side. let curve = &planar.curve; let at = curve.parameter_at(side); let step = 1e-6; let (before, after) = if curve.is_closed() { (curve.point_at((at - step).rem_euclid(1.0)), curve.point_at((at + step).rem_euclid(1.0))) } else { (curve.point_at((at - step).max(0.0)), curve.point_at((at + step).min(1.0))) }; let foot = curve.point_at(at); let along = [after[0] - before[0], after[1] - before[1]]; let toward = [side[0] - foot[0], side[1] - foot[1]]; let left = along[0] * toward[1] - along[1] * toward[0] > 0.0; let signed = if left { dist.abs() } else { -dist.abs() }; let tolerance = (curve.length() * 1e-6).max(1e-9); let kernel::geom2d::Curve::Nurbs(parallel) = kernel::geom2d::offset_curve(curve, signed, tolerance)? else { return None; }; template.common.handle = Handle::NULL; template.degree = parallel.degree() as i32; template.knots = parallel.knots().to_vec(); template.control_points = parallel .control_points() .iter() .map(|uv| { let [x, y, z] = planar.plane.point_at(*uv); codec::types::Vector3::new(x, y, z) }) .collect(); template.weights = if parallel.is_rational() { parallel.weights().to_vec() } else { Vec::new() }; template.flags.rational = parallel.is_rational(); template.flags.periodic = false; template.flags.closed = curve.is_closed(); if let Some([x, y, z]) = planar.plane.normal() { template.normal = codec::types::Vector3::new(x, y, z); } // A parallel is no longer the curve through the source's fit points. template.fit_points.clear(); template.begin_tangent = codec::types::Vector3::new(0.0, 0.0, 0.0); template.end_tangent = codec::types::Vector3::new(0.0, 0.0, 0.0); Some(EntityType::Spline(template)) } // ── Dispatch ─────────────────────────────────────────────────────────────── fn compute_offsets(entity: &EntityType, dist: f64, side_pt: Vec3) -> Vec { match entity { EntityType::Line(l) => offset_line(l, dist, side_pt).into_iter().collect(), EntityType::Circle(c) => offset_circle(c, dist, side_pt).into_iter().collect(), EntityType::Arc(a) => offset_arc(a, dist, side_pt).into_iter().collect(), EntityType::LwPolyline(p) => offset_lwpolylines(p, dist, side_pt), EntityType::Ellipse(e) => offset_ellipse(e, dist, side_pt).into_iter().collect(), EntityType::Spline(s) => offset_spline(s, dist, side_pt).into_iter().collect(), EntityType::XLine(x) => offset_xline(x, dist, side_pt).into_iter().collect(), _ => Vec::new(), } } // ── Through-mode distance ───────────────────────────────────────────────── // // Nearest distance from the cursor to the entity outline, used by "through" // mode so the offset copy passes through the cursor. Measured against the // tessellated wire (point-to-segment), which approximates the perpendicular // distance for every supported entity type. fn perp_distance(entity: &EntityType, pt: Vec3) -> f64 { closest_on_wire(entity, pt).map_or(0.0, |(_, d)| d) } /// Nearest point of the entity outline to `pt`, and its distance. fn closest_on_wire(entity: &EntityType, pt: Vec3) -> Option<([f64; 2], f64)> { let pts = entity_wire_pts(entity); if pts.len() < 2 { return None; } let px = pt.x as f64; let py = pt.y as f64; let mut best = ([px, py], f64::INFINITY); for w in pts.windows(2) { let ax = w[0][0] as f64; let ay = w[0][1] as f64; let bx = w[1][0] as f64; let by = w[1][1] as f64; let dx = bx - ax; let dy = by - ay; let len2 = dx * dx + dy * dy; let t = if len2 < 1e-12 { 0.0 } else { (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0) }; let cx = ax + t * dx; let cy = ay + t * dy; let d = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt(); if d < best.1 { best = ([cx, cy], d); } } Some(best) } // ── Opposite side ───────────────────────────────────────────────────────── // // A negative typed distance offsets away from the cursor. Rather than teach // every offset function a sign, the cursor is mirrored through its nearest // point on the outline, which puts it on the other side for every type: the // other half-plane of a line, inside instead of outside a circle or closed // loop. The mirror stops at half the offset distance so that, in a narrow // part of a shape, it cannot cross the far wall and come back to the // cursor's own side. fn far_side(entity: &EntityType, pt: Vec3, dist: f64) -> Option { let ([qx, qy], d) = closest_on_wire(entity, pt)?; if d < 1e-9 { // On the outline itself: there is no side to be opposite to. return None; } let step = d.min(dist * 0.5) / d; Some(Vec3::new( (qx + (qx - pt.x as f64) * step) as f32, (qy + (qy - pt.y as f64) * step) as f32, pt.z, )) } // ── Wire preview points ───────────────────────────────────────────────────── /// Preview density, matching the figure the drawn wires use. const PREVIEW_SEGMENTS_PER_RADIAN: f64 = kernel::geom2d::DEFAULT_SEGMENTS_PER_RADIAN; /// Preview points for an entity, from its own curve. /// /// One tessellation rather than a case per type. What that case-per-type /// version had accumulated: an arc and a circle read their `center.x/.y` as /// world coordinates when the entity stores them in its OCS, so an extruded /// one previewed on the wrong side of the drawing; every curved kind was cut /// at a fixed twenty segments per radian whatever its size; and an ellipse /// rebuilt its own axis frame from the major axis' X and Y alone, dropping /// the Z the entity actually stores. /// /// The narrowing to `f32` stays here rather than in the kernel: a preview /// wire leaves `points_low` empty and accepts the loss, while the resident /// render path splits the `f64` into a high/low pair instead. Only a caller /// knows which of the two it is. fn entity_wire_pts(e: &EntityType) -> Vec<[f32; 3]> { // The two kinds with no finite point list of their own, so how far to run // them is a decision rather than a measurement. Long enough to read as // infinite at any working zoom; the committed entity renders true. A ray // runs one way only. const HL: f64 = 1.0e6; let unbounded = match e { EntityType::XLine(x) => Some((x.base_point, x.direction, -HL)), EntityType::Ray(r) => Some((r.base_point, r.direction, 0.0)), _ => None, }; if let Some((base, direction, back)) = unbounded { let at = |k: f64| { [ (base.x + direction.x * k) as f32, (base.y + direction.y * k) as f32, (base.z + direction.z * k) as f32, ] }; return vec![at(back), at(HL)]; } let Some(curve) = entity_curve(e) else { return vec![]; }; curve .tessellate(PREVIEW_SEGMENTS_PER_RADIAN) .into_iter() .map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]) .collect::>() } // ── Command implementation ───────────────────────────────────────────────── enum Step { /// Type the offset distance, accept the previous value with Enter, /// choose Through, or pick the first point of a reference distance. Distance, /// First reference point has been picked; the second point defines /// the offset distance. ReferenceSecond { first: DVec3 }, SelectObject { locked: Option }, PickSide { targets: Vec, locked: Option, multiple: bool, }, } pub struct OffsetCommand { step: Step, all_entities: Vec, entity_index: ModifyEntityIndex, /// Live entity supplied by the scene before an object-pick is handled. /// Unlike the command's opening snapshot, this also includes objects /// created by earlier offsets while the command remains active. picked: Option, /// Last cursor position the preview saw. A distance typed at the side /// step places the offset relative to it. cursor: Option, /// The command opened on pre-selected objects (#422) and has made nothing /// yet: Enter takes the last distance, and Erase / Layer, which the /// skipped distance step would have offered, are offered here. drag_start: bool, /// A Yes/No or Current/Source answer is being collected for this option. awaiting: Option, /// Erase option: replace the source object with its offset. erase_source: bool, /// Layer option: put offsets on the current layer instead of the source's. layer_current: bool, /// The side step's targets are document objects (not chained offsets), so /// Erase can replace them. targets_are_sources: bool, /// Offsets committed by this command that Undo can still take back. made: usize, } /// Which option prompt is waiting for an answer. #[derive(Clone, Copy, PartialEq)] enum Await { Erase, Layer, } /// The entity types `compute_offsets` can offset. pub fn is_offsettable(e: &EntityType) -> bool { matches!( e, EntityType::Line(_) | EntityType::Circle(_) | EntityType::Arc(_) | EntityType::LwPolyline(_) | EntityType::Ellipse(_) | EntityType::Spline(_) | EntityType::XLine(_) ) } impl OffsetCommand { pub fn new(all_entities: Vec) -> Self { let entity_index = ModifyEntityIndex::build(&all_entities); Self { step: Step::Distance, all_entities, entity_index, picked: None, cursor: None, drag_start: false, awaiting: None, erase_source: false, layer_current: false, targets_are_sources: false, made: 0, } } /// Pick-first flow (#422): the objects are already chosen, so the command /// opens at the side step with the preview following the cursor. Clicking /// offsets through the cursor; typing a distance places it at that /// distance; Enter repeats the last distance (#418). pub fn with_selection(all_entities: Vec, targets: Vec) -> Self { let mut cmd = Self::new(all_entities); if !targets.is_empty() { cmd.step = Step::PickSide { targets, locked: None, multiple: false, }; cmd.targets_are_sources = true; cmd.drag_start = true; } cmd } /// Leave the distance step with the given mode (Some = locked distance, /// None = through) for the object-pick loop. fn advance_from_distance(&mut self, locked: Option) -> CmdResult { self.step = Step::SelectObject { locked }; CmdResult::NeedPoint } /// Undo option: take back the last offset and return to the object pick. fn undo_last(&mut self) -> Option { if self.made == 0 { return None; } self.made -= 1; let locked = match &self.step { Step::SelectObject { locked } | Step::PickSide { locked, .. } => *locked, _ => return None, }; self.step = Step::SelectObject { locked }; Some(CmdResult::UndoDocument) } fn accept_distance(&mut self, distance: f64) -> CmdResult { let distance = distance.abs().max(1e-9); defaults::set_offset_dist(distance); self.advance_from_distance(Some(distance)); let d = format!("{:.4}", distance); CmdResult::ReportMeasurement( t!("OFFSET distance = %{d}", d = d).into_owned() ) } /// Offset the side step's targets with `pt` choosing the side: toward it, /// or away from it when `flip` is set. Each target offsets by its own /// through-distance, or by the locked magnitude. fn place(&mut self, pt: DVec3, flip: bool) -> CmdResult { let (locked, targets, multiple) = match &self.step { Step::PickSide { locked, targets, multiple, } => (*locked, targets.clone(), *multiple), _ => return CmdResult::NeedPoint, }; let per_target: Vec<(Handle, Vec)> = targets .iter() .filter_map(|entity| { let mag = locked.unwrap_or_else(|| perp_distance(entity, pt.as_vec3())); if mag < 1e-9 { return None; } let side = if flip { far_side(entity, pt.as_vec3(), mag)? } else { pt.as_vec3() }; let offsets = compute_offsets(entity, mag, side); (!offsets.is_empty()).then(|| (entity.common().handle, offsets)) }) .collect(); let mut news: Vec = per_target .iter() .flat_map(|(_, offsets)| offsets.iter().cloned()) .collect(); if news.is_empty() { return CmdResult::NeedPoint; } self.made += 1; self.drag_start = false; // Erase option: the offset replaces its source object. Only the // objects picked from the drawing qualify — a Multiple chain's // targets are the offsets just made, which are not replaced again. let erase = self.erase_source && self.targets_are_sources; self.targets_are_sources = false; if multiple { // Multiple mode chains from the result just created, matching // OFFSET's repeated fixed-distance behavior (parallel/concentric // series instead of duplicate copies at the first offset). self.step = Step::PickSide { targets: news.clone(), locked, multiple: true, }; } else { // Classic loop (#418): commit this offset and go back to the object // pick at the same distance, until Enter / Esc finishes. self.step = Step::SelectObject { locked }; } if erase { return CmdResult::ReplaceManyContinue(per_target); } if news.len() == 1 { CmdResult::CommitEntity(news.pop().unwrap()) } else { CmdResult::CommitEntities(news) } } } impl CadCommand for OffsetCommand { fn preserve_commit_layer(&self) -> bool { !self.layer_current } fn name(&self) -> &'static str { "OFFSET" } fn prompt(&self) -> String { match self.awaiting { Some(Await::Erase) => { return t!("OFFSET Erase source object after offsetting? [Yes/No] :").into_owned() } Some(Await::Layer) => { return t!("OFFSET Enter layer option for offset objects [Current/Source] :") .into_owned() } None => {} } match &self.step { Step::Distance => { let d = format!("{:.4}", defaults::get_offset_dist()); t!( "OFFSET Specify offset distance or first reference point [Through] <%{d}>:", d = d ) .into_owned() } Step::ReferenceSecond { .. } => { t!("OFFSET Specify second reference point:").into_owned() } Step::SelectObject { .. } => { t!("OFFSET Select object to offset (Enter to finish):").into_owned() } Step::PickSide { targets, locked, multiple, .. } => { let n: std::borrow::Cow<'_, str> = if targets.len() > 1 { t!(" (%{count} objects)", count = targets.len()) } else { std::borrow::Cow::Borrowed("") }; if self.drag_start { let d = format!("{:.4}", defaults::get_offset_dist()); return if *multiple { t!( "OFFSET%{n} Multiple Click through point or type distance, negative for the other side [Erase/Layer] <%{d}>:", n = n, d = d ) } else { t!( "OFFSET%{n} Click through point or type distance, negative for the other side [Multiple/Erase/Layer] <%{d}>:", n = n, d = d ) } .into_owned(); } match (locked, multiple) { (Some(d), false) => { let d = format!("{:.4}", d); t!( "OFFSET%{n} Click side or [Multiple] [distance %{d}]:", n = n, d = d ) .into_owned() } (Some(d), true) => { let d = format!("{:.4}", d); t!( "OFFSET%{n} Multiple Click next side [distance %{d}]:", n = n, d = d ) .into_owned() } (None, false) => { t!("OFFSET%{n} Click through point or [Multiple]:", n = n).into_owned() } (None, true) => { t!("OFFSET%{n} Multiple Click next through point:", n = n).into_owned() } } } } } fn options(&self) -> Vec { use crate::command::CmdOption; match self.awaiting { Some(Await::Erase) => { return vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")] } Some(Await::Layer) => { return vec![CmdOption::new("Current", "C"), CmdOption::new("Source", "S")] } None => {} } match &self.step { Step::Distance => vec![ CmdOption::new("Through", "T"), CmdOption::new("Erase", "E"), CmdOption::new("Layer", "L"), CmdOption::enter(&format!("{:.4}", defaults::get_offset_dist())), ], Step::SelectObject { .. } => { let mut opts = Vec::new(); if self.made > 0 { opts.push(CmdOption::new("Undo", "U")); } opts.push(CmdOption::enter("Exit")); opts } Step::PickSide { multiple, .. } if self.drag_start => { let mut opts = Vec::new(); if !multiple { opts.push(CmdOption::new("Multiple", "M")); } opts.push(CmdOption::new("Erase", "E")); opts.push(CmdOption::new("Layer", "L")); opts.push(CmdOption::enter(&format!("{:.4}", defaults::get_offset_dist()))); opts } Step::PickSide { multiple, .. } => { let mut opts = Vec::new(); if !multiple { opts.push(CmdOption::new("Multiple", "M")); } if self.made > 0 { opts.push(CmdOption::new("Undo", "U")); } opts.push(CmdOption::enter("Exit")); opts } Step::ReferenceSecond { .. } => Vec::new(), } } fn on_undo_step(&mut self) -> Option { self.undo_last() } fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::SelectObject { .. }) } fn inject_before_entity_pick(&self) -> bool { true } fn inject_picked_entity(&mut self, entity: EntityType) { self.picked = Some(entity); } fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { let locked = match &self.step { Step::SelectObject { locked } => *locked, _ => return CmdResult::NeedPoint, }; if handle.is_null() { return CmdResult::NeedPoint; } let entity = self.picked.take().or_else(|| { self.entity_index .get(&self.all_entities, handle) .cloned() }); // Accept every type compute_offsets can offset — including XLine (#296), // and Ellipse/Spline whose offset functions existed but weren't reachable. match entity { Some(e) if is_offsettable(&e) => { self.step = Step::PickSide { targets: vec![e], locked, multiple: false, }; self.targets_are_sources = true; CmdResult::NeedPoint } _ => CmdResult::NeedPoint, } } // The distance step takes a typed magnitude; the side step accepts one // too, re-locking the distance mid-command. The object pick takes Undo. fn wants_text_input(&self) -> bool { matches!(self.step, Step::Distance | Step::PickSide { .. }) } fn dyn_field(&self) -> crate::command::DynField { match self.step { Step::Distance | Step::PickSide { .. } => crate::command::DynField::Scalar, Step::ReferenceSecond { .. } | Step::SelectObject { .. } => crate::command::DynField::Point, } } fn dyn_live_value(&self, cursor: DVec3) -> Option { match &self.step { Step::Distance => Some(defaults::get_offset_dist()), Step::PickSide { targets, locked, .. } => Some(locked.unwrap_or_else(|| { targets .first() .map(|e| perp_distance(e, cursor.as_vec3())) .unwrap_or(0.0) })), _ => None, } } fn on_text_input(&mut self, text: &str) -> Option { let t = text.trim().replace(',', "."); let upper = t.to_uppercase(); match self.awaiting { Some(Await::Erase) => { match upper.as_str() { "Y" | "YES" => self.erase_source = true, "N" | "NO" | "" => self.erase_source = false, _ => return Some(CmdResult::NeedPoint), } self.awaiting = None; return Some(CmdResult::NeedPoint); } Some(Await::Layer) => { match upper.as_str() { "C" | "CURRENT" => self.layer_current = true, "S" | "SOURCE" | "" => self.layer_current = false, _ => return Some(CmdResult::NeedPoint), } self.awaiting = None; return Some(CmdResult::NeedPoint); } None => {} } if matches!(upper.as_str(), "U" | "UNDO") && matches!(self.step, Step::SelectObject { .. } | Step::PickSide { .. }) { return self.undo_last(); } // Erase / Layer belong to the distance step, or to the side step when // a pre-selection skipped the distance step. if matches!(self.step, Step::Distance) || self.drag_start { if upper == "E" || upper == "ERASE" { self.awaiting = Some(Await::Erase); return Some(CmdResult::NeedPoint); } if upper == "L" || upper == "LAYER" { self.awaiting = Some(Await::Layer); return Some(CmdResult::NeedPoint); } } match &mut self.step { Step::Distance => { if t.eq_ignore_ascii_case("t") || t.eq_ignore_ascii_case("through") { return Some(self.advance_from_distance(None)); } if let Some(d) = crate::entities::common::parse_typed_length(&t) { return Some(self.accept_distance(d)); } Some(CmdResult::NeedPoint) } Step::PickSide { locked, multiple, .. } => { if t.eq_ignore_ascii_case("m") || t.eq_ignore_ascii_case("multiple") { *multiple = true; return Some(CmdResult::NeedPoint); } let Some(d) = crate::entities::common::parse_typed_length(&t) else { return Some(CmdResult::NeedPoint); }; let mag = d.abs().max(1e-9); defaults::set_offset_dist(mag); *locked = Some(mag); // The cursor has already chosen the side, so the value places // the offset now: on the cursor's side, or the other side when // negative. Before the cursor has been seen, the click still // chooses. match self.cursor { Some(cursor) => Some(self.place(cursor, d < 0.0)), None => Some(CmdResult::NeedPoint), } } _ => None, } } fn on_entities_committed(&mut self, entities: &[EntityType]) { self.all_entities.extend(entities.iter().cloned()); self.entity_index = ModifyEntityIndex::build(&self.all_entities); } fn on_hover_entity(&mut self, handle: Handle, _pt: DVec3) -> Vec { if handle.is_null() || !matches!(self.step, Step::SelectObject { .. }) { return vec![]; } if let Some(entity) = self .entity_index.get(&self.all_entities, handle) { let pts = entity_wire_pts(entity); if !pts.is_empty() { return vec![WireModel::solid( "offset_hover".into(), pts, WireModel::CYAN, false, )]; } } vec![] } fn on_point(&mut self, pt: DVec3) -> CmdResult { match &self.step { Step::Distance => { self.step = Step::ReferenceSecond { first: pt }; return CmdResult::NeedPoint; } Step::ReferenceSecond { first } => { let distance = first.distance(pt); if distance <= 1e-9 { return CmdResult::NeedPoint; } return self.accept_distance(distance); } _ => {} } self.place(pt, false) } fn on_preview_wires(&mut self, pt: DVec3) -> Vec { self.cursor = Some(pt); if let Step::ReferenceSecond { first } = &self.step { return vec![WireModel::solid( "offset_reference_distance".into(), vec![ [ first.x as f32, first.y as f32, first.z as f32, ], [ pt.x as f32, pt.y as f32, pt.z as f32, ], ], WireModel::CYAN, false, )]; } let (locked, targets) = match &self.step { Step::PickSide { locked, targets, .. } => (*locked, targets.clone()), _ => return vec![], }; let mut wires = Vec::new(); for (target_index, entity) in targets.iter().enumerate() { let mag = locked.unwrap_or_else(|| perp_distance(entity, pt.as_vec3())); if mag < 1e-9 { continue; } for (result_index, result) in compute_offsets(entity, mag, pt.as_vec3()) .into_iter() .enumerate() { let pts = entity_wire_pts(&result); if !pts.is_empty() { wires.push(WireModel::solid( format!("offset_preview_{target_index}_{result_index}"), pts, WireModel::CYAN, false, )); } } } wires } fn on_enter(&mut self) -> CmdResult { match &self.step { // Enter / Space on the distance step accepts the last distance — // the "repeat the same value with just Space" flow (#418). Step::Distance => { let d = defaults::get_offset_dist(); self.accept_distance(d) } // Opened on a pre-selection, the same Enter offsets by the last // distance on the cursor's side. Step::PickSide { .. } if self.drag_start => { let Some(cursor) = self.cursor else { return CmdResult::NeedPoint; }; if let Step::PickSide { locked, .. } = &mut self.step { *locked = Some(defaults::get_offset_dist()); } self.place(cursor, false) } _ => CmdResult::Cancel, } } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } } // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["OFFSET"] }); // OffsetCommand #[cfg(test)] mod offset_tests { use super::*; use codec::types::Vector2; fn rect(corners: &[[f64; 2]]) -> LwPolyline { LwPolyline { vertices: corners .iter() .map(|&[x, y]| LwVertex::new(Vector2::new(x, y))) .collect(), is_closed: true, ..Default::default() } } /// Offset `corners` by 10 toward `side` and return the result's XY bounds. fn offset_bbox(corners: &[[f64; 2]], side: Vec3) -> [f64; 4] { let out = offset_lwpolyline(&rect(corners), 10.0, side); let Some(EntityType::LwPolyline(r)) = out else { panic!("offset did not return an lwpolyline"); }; let (mut minx, mut miny, mut maxx, mut maxy) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); for v in &r.vertices { minx = minx.min(v.location.x); miny = miny.min(v.location.y); maxx = maxx.max(v.location.x); maxy = maxy.max(v.location.y); } [minx, miny, maxx, maxy] } fn approx(a: [f64; 4], b: [f64; 4]) -> bool { a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) } // A pick *outside* the loop must offset outward and a pick *inside* must // offset inward, regardless of the rectangle's winding. Regression for the // first-segment sign heuristic, which sent a CCW rectangle inward when the // outward pick sat beside it (issue 166). #[test] fn rect_offset_direction_is_winding_independent() { let ccw = [[0.0, 0.0], [100.0, 0.0], [100.0, 60.0], [0.0, 60.0]]; let cw = [[0.0, 0.0], [0.0, 60.0], [100.0, 60.0], [100.0, 0.0]]; let out = [-10.0, -10.0, 110.0, 70.0]; let inn = [10.0, 10.0, 90.0, 50.0]; // Pick beside the rectangle (outside, mid-height) → outward, both windings. assert!(approx(offset_bbox(&ccw, Vec3::new(-10.0, 30.0, 0.0)), out)); assert!(approx(offset_bbox(&cw, Vec3::new(-10.0, 30.0, 0.0)), out)); // Pick inside → inward, both windings. assert!(approx(offset_bbox(&ccw, Vec3::new(50.0, 30.0, 0.0)), inn)); assert!(approx(offset_bbox(&cw, Vec3::new(50.0, 30.0, 0.0)), inn)); // Pick clearly outside below → outward (the case that worked before). assert!(approx(offset_bbox(&ccw, Vec3::new(50.0, -10.0, 0.0)), out)); } } #[cfg(test)] mod option_tests { use super::*; fn line(x1: f64, y1: f64, x2: f64, y2: f64, handle: u64) -> EntityType { let mut line = codec::entities::Line::from_coords(x1, y1, 0.0, x2, y2, 0.0); line.common.handle = Handle::new(handle); EntityType::Line(line) } fn keywords(cmd: &OffsetCommand) -> Vec { cmd.options().into_iter().map(|o| o.keyword).collect() } #[test] fn erase_replaces_the_source_object() { let mut cmd = OffsetCommand::new(vec![line(0.0, 0.0, 10.0, 0.0, 1)]); assert_eq!(keywords(&cmd), ["T", "E", "L", ""]); assert!(matches!(cmd.on_text_input("E"), Some(CmdResult::NeedPoint))); assert_eq!(keywords(&cmd), ["Y", "N"]); assert!(matches!(cmd.on_text_input("Y"), Some(CmdResult::NeedPoint))); assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::ReportMeasurement(_)))); assert!(matches!(cmd.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0)), CmdResult::NeedPoint)); match cmd.on_point(DVec3::new(5.0, 5.0, 0.0)) { CmdResult::ReplaceManyContinue(replacements) => { assert_eq!(replacements.len(), 1); assert_eq!(replacements[0].0, Handle::new(1)); assert_eq!(replacements[0].1.len(), 1); } _ => panic!("erase should replace the source with its offset"), } // Back at the object pick, with Undo now available. assert_eq!(keywords(&cmd), ["U", ""]); assert!(matches!(cmd.on_text_input("U"), Some(CmdResult::UndoDocument))); assert_eq!(keywords(&cmd), [""]); } #[test] fn layer_current_drops_source_layer_preservation() { let mut cmd = OffsetCommand::new(vec![line(0.0, 0.0, 10.0, 0.0, 1)]); assert!(cmd.preserve_commit_layer()); cmd.on_text_input("L"); assert_eq!(keywords(&cmd), ["C", "S"]); cmd.on_text_input("C"); assert!(!cmd.preserve_commit_layer()); assert!(matches!(cmd.step, Step::Distance)); cmd.on_text_input("2"); cmd.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0)); assert!(matches!(cmd.on_point(DVec3::new(5.0, 5.0, 0.0)), CmdResult::CommitEntity(_))); } } #[cfg(test)] mod drag_tests { use super::*; use codec::types::Vector2; fn line_y0() -> EntityType { let mut line = codec::entities::Line::from_coords(0.0, 0.0, 0.0, 10.0, 0.0, 0.0); line.common.handle = Handle::new(1); EntityType::Line(line) } /// OFFSET opened on `target`, with the cursor hovering at `cursor`. fn dragging(target: EntityType, cursor: [f64; 2]) -> OffsetCommand { let mut cmd = OffsetCommand::with_selection(vec![target.clone()], vec![target]); cmd.on_preview_wires(DVec3::new(cursor[0], cursor[1], 0.0)); cmd } fn committed_line_y(result: Option) -> f64 { match result { Some(CmdResult::CommitEntity(EntityType::Line(l))) => l.start.y, _ => panic!("expected one committed line"), } } #[test] fn preselection_opens_on_the_live_preview() { let mut cmd = OffsetCommand::with_selection(vec![line_y0()], vec![line_y0()]); assert!(matches!(cmd.step, Step::PickSide { locked: None, .. })); assert_eq!(cmd.options().into_iter().map(|o| o.keyword).collect::>(), ["M", "E", "L", ""]); // The preview passes through the cursor before anything is typed. assert_eq!(cmd.on_preview_wires(DVec3::new(5.0, 3.0, 0.0)).len(), 1); assert_eq!(cmd.dyn_live_value(DVec3::new(5.0, 3.0, 0.0)), Some(3.0)); // A click still offsets through the cursor. assert!(matches!(cmd.on_point(DVec3::new(5.0, 3.0, 0.0)), CmdResult::CommitEntity(_))); } #[test] fn typed_distance_places_on_the_cursor_side() { let mut cmd = dragging(line_y0(), [5.0, 3.0]); assert!((committed_line_y(cmd.on_text_input("2")) - 2.0).abs() < 1e-9); // The loop continues at the typed distance. assert!(matches!(cmd.step, Step::SelectObject { locked: Some(d) } if d == 2.0)); } #[test] fn negative_distance_places_on_the_other_side() { let mut cmd = dragging(line_y0(), [5.0, 3.0]); assert!((committed_line_y(cmd.on_text_input("-2")) + 2.0).abs() < 1e-9); assert_eq!(defaults::get_offset_dist(), 2.0); } #[test] fn negative_distance_inside_a_circle_grows_it() { let circle = EntityType::Circle(CircleEnt { center: codec::types::Vector3::new(0.0, 0.0, 0.0), radius: 10.0, ..Default::default() }); let mut cmd = dragging(circle.clone(), [3.0, 0.0]); match cmd.on_text_input("-1") { Some(CmdResult::CommitEntity(EntityType::Circle(c))) => assert!((c.radius - 11.0).abs() < 1e-9), _ => panic!("expected one committed circle"), } let mut cmd = dragging(circle, [3.0, 0.0]); match cmd.on_text_input("1") { Some(CmdResult::CommitEntity(EntityType::Circle(c))) => assert!((c.radius - 9.0).abs() < 1e-9), _ => panic!("expected one committed circle"), } } // A U whose arms sit 1 apart. From inside the left arm the nearest wall // faces the gap; mirroring the cursor its full 1.9 across would land in // the right arm, back inside the loop. -0.25 must still go outward. #[test] fn negative_distance_does_not_mirror_across_a_gap() { let u = EntityType::LwPolyline(LwPolyline { vertices: [ [0.0, 0.0], [9.0, 0.0], [9.0, 10.0], [5.0, 10.0], [5.0, 2.0], [4.0, 2.0], [4.0, 10.0], [0.0, 10.0], ] .iter() .map(|&[x, y]| LwVertex::new(Vector2::new(x, y))) .collect(), is_closed: true, ..Default::default() }); let mut cmd = dragging(u, [2.1, 6.0]); match cmd.on_text_input("-0.25") { Some(CmdResult::CommitEntity(EntityType::LwPolyline(p))) => { let min_x = p.vertices.iter().map(|v| v.location.x).fold(f64::INFINITY, f64::min); assert!((min_x + 0.25).abs() < 1e-6, "offset went inward: min x {min_x}"); } _ => panic!("expected one committed polyline"), } } #[test] fn enter_repeats_the_last_distance_on_the_cursor_side() { defaults::set_offset_dist(4.0); let mut cmd = dragging(line_y0(), [5.0, -1.0]); match cmd.on_enter() { CmdResult::CommitEntity(EntityType::Line(l)) => assert!((l.start.y + 4.0).abs() < 1e-9), _ => panic!("expected one committed line"), } // After that, Enter finishes as before. assert!(matches!(cmd.on_enter(), CmdResult::Cancel)); } #[test] fn erase_is_offered_without_the_distance_step() { let mut cmd = dragging(line_y0(), [5.0, 3.0]); cmd.on_text_input("E"); cmd.on_text_input("Y"); assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::ReplaceManyContinue(_)))); } #[test] fn without_preselection_the_distance_comes_first() { let cmd = OffsetCommand::new(vec![line_y0()]); assert!(matches!(cmd.step, Step::Distance)); } }