From bd17e7e0d4e002968bc5dbb988a534b744c9d95a Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:33:49 +0300 Subject: [PATCH 1/3] CLIP on raster images and viewports, IMAGECLIP, VPCLIP, clip depth rules - CLIP continues as IMAGECLIP on a raster image and as VPCLIP on a layout viewport, with the reference prompts and stored data. - Image clip boundaries sit on pixel centres, OFF only clears the display flag, and an inverted boundary cuts the image. - VPCLIP polygon (Arc/Length/Close/Undo), clipping object and Delete; the viewport shrinks to the boundary's exact extents, view kept. - XCLIP Clipdepth: Remove clears only the plane asked, a front plane behind the back one is rejected with the reference message. - Attached and imported images carry the reference display flags. Co-Authored-By: Claude Opus 5.5 --- src/app/command_driver/mod.rs | 5 +- src/app/command_driver/viewport.rs | 142 ++++++++++++++++++--- src/app/commands/blocks.rs | 12 ++ src/app/commands/xclip.rs | 5 +- src/command.rs | 3 + src/entities/raster_image.rs | 92 +++++++++----- src/modules/draw/draw/raster_image.rs | 1 + src/modules/insert/pdf_clip.rs | 146 ++++++++++++++++++--- src/modules/insert/xclip.rs | 44 ++++++- src/modules/layout/mview.rs | 176 +++++++++++++++++++++++++- src/scene/model/image_model.rs | 40 ++---- src/scene/pick/xclip.rs | 15 ++- 12 files changed, 569 insertions(+), 112 deletions(-) diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 70b07c23c..b3bf543d7 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -471,8 +471,11 @@ impl OpenCADStudio { CmdResult::MviewCreateClipped { boundary, boundary_handle, + target, } => { - if let Some(task) = self.handle_mview_create_clipped(boundary, boundary_handle) { + if !target.is_null() { + self.handle_vpclip(target, boundary, boundary_handle); + } else if let Some(task) = self.handle_mview_create_clipped(boundary, boundary_handle) { return task; } } diff --git a/src/app/command_driver/viewport.rs b/src/app/command_driver/viewport.rs index 1e915b65f..e4f548c96 100644 --- a/src/app/command_driver/viewport.rs +++ b/src/app/command_driver/viewport.rs @@ -43,6 +43,101 @@ impl OpenCADStudio { } } + /// VPCLIP: clip `viewport` to a new polygon (`boundary`) or a picked closed + /// paper-space object (`boundary_handle`), or — with neither — delete its + /// clip. The viewport shrinks to the boundary's extents and its view + /// moves with it, so the model stays where it was on paper; the old + /// boundary is erased. One undo step. + pub(super) fn handle_vpclip( + &mut self, + viewport: Handle, + boundary: Option, + boundary_handle: Handle, + ) { + let i = self.active_tab; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + let Some(codec::EntityType::Viewport(vp)) = self.tabs[i].scene.document.get_entity(viewport).cloned() + else { + return; + }; + if boundary.is_none() && !boundary_handle.is_null() { + let scene = &self.tabs[i].scene; + let closed = scene.entity_belongs_to_current_layout(boundary_handle) + && scene.document.get_entity(boundary_handle).is_some_and(|entity| match entity { + codec::EntityType::Circle(_) => true, + codec::EntityType::Ellipse(ellipse) => ellipse.is_full(), + codec::EntityType::LwPolyline(polyline) => polyline.is_closed, + codec::EntityType::Polyline(polyline) => polyline.is_closed(), + codec::EntityType::Polyline2D(polyline) => polyline.is_closed(), + _ => false, + }); + if !closed { + self.command_line.push_error("Object is not a closed curve."); + return; + } + } + self.push_undo_snapshot(i, "VPCLIP"); + let old = vp.clip_boundary_handle; + let scene = &mut self.tabs[i].scene; + let clip = match boundary { + Some(mut entity) => { + entity.common_mut().invisible = true; + scene.add_entity(entity) + } + None => boundary_handle, + }; + let mut vp = vp; + if clip.is_null() { + vp.clip_boundary_handle = Handle::NULL; + } else { + let (lo, hi) = clip_boundary_extents(scene, clip) + .map(|(lo, hi)| (glam::DVec2::from(lo), glam::DVec2::from(hi))) + .unwrap_or((glam::DVec2::ZERO, glam::DVec2::ZERO)); + if hi.x - lo.x < 1e-6 || hi.y - lo.y < 1e-6 { + self.command_line + .push_error(crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref()); + return; + } + // Model units per paper unit; the view centre follows the paper + // centre so the model does not move on the sheet. + let scale = if vp.height.abs() > 1e-12 { vp.view_height / vp.height } else { 1.0 }; + let center = (lo + hi) * 0.5; + let (dx, dy) = (center.x - vp.center.x, center.y - vp.center.y); + let (s, c) = (-vp.twist_angle).sin_cos(); + vp.view_center.x += (dx * c - dy * s) * scale; + vp.view_center.y += (dx * s + dy * c) * scale; + vp.center.x = center.x; + vp.center.y = center.y; + vp.width = hi.x - lo.x; + vp.height = hi.y - lo.y; + vp.view_height = vp.height * scale; + vp.clip_boundary_handle = clip; + if let Some(entity) = scene.document.get_entity_mut(clip) { + let common = entity.common_mut(); + common.invisible = true; + if !common.reactors.contains(&viewport) { + common.reactors.push(viewport); + } + } + } + if let Some(entity) = scene.document.get_entity_mut(viewport) { + *entity = codec::EntityType::Viewport(vp); + } + if !old.is_null() && old != clip { + scene.erase_entities(&[old]); + } + let mut changes = vec![(viewport, crate::scene::ChangeKind::Modified)]; + if !clip.is_null() { + changes.push((clip, crate::scene::ChangeKind::Modified)); + } + scene.bump_entities(&changes); + scene.camera_generation += 1; + self.tabs[i].dirty = true; + self.restore_pre_cmd_tangent(); + } + pub(super) fn handle_mview_create_clipped( &mut self, boundary: Option, @@ -107,27 +202,8 @@ impl OpenCADStudio { } None => boundary_handle, }; - let polygon = self.tabs[i].scene.clip_boundary_polygon(clip_handle, 0.0); - let bounds: Option<(f64, f64, f64, f64)> = polygon.iter().fold(None, |bounds, point| { - if !point[0].is_finite() || !point[1].is_finite() { - return bounds; - } - Some(match bounds { - Some((min_x, min_y, max_x, max_y)) => ( - min_x.min(point[0] as f64), - min_y.min(point[1] as f64), - max_x.max(point[0] as f64), - max_y.max(point[1] as f64), - ), - None => ( - point[0] as f64, - point[1] as f64, - point[0] as f64, - point[1] as f64, - ), - }) - }); - let Some((min_x, min_y, max_x, max_y)) = bounds else { + let bounds = clip_boundary_extents(&self.tabs[i].scene, clip_handle); + let Some(([min_x, min_y], [max_x, max_y])) = bounds else { self.command_line .push_error(crate::t!("MVIEW: the clipping boundary has no usable area.").as_ref()); self.tabs[i].active_cmd = None; @@ -412,3 +488,27 @@ impl OpenCADStudio { self.on_quick_print_handles(handles) } } + +/// Paper-space extents of a viewport clip boundary: exact for lines, arcs +/// and circles (bulges included), else from its outline. +fn clip_boundary_extents(scene: &crate::scene::Scene, clip: Handle) -> Option<([f64; 2], [f64; 2])> { + let exact = scene + .document + .get_entity(clip) + .and_then(crate::entities::curve::entity_curve_xy) + .and_then(|curve| kernel::geom2d::analytic_curve_bounds(&[curve])); + if exact.is_some() { + return exact; + } + let polygon = scene.clip_boundary_polygon(clip, 0.0); + polygon + .iter() + .filter(|p| p[0].is_finite() && p[1].is_finite()) + .fold(None, |bounds: Option<([f64; 2], [f64; 2])>, p| { + let (x, y) = (p[0] as f64, p[1] as f64); + Some(match bounds { + Some((lo, hi)) => ([lo[0].min(x), lo[1].min(y)], [hi[0].max(x), hi[1].max(y)]), + None => ([x, y], [x, y]), + }) + }) +} diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 148af74df..52bce9863 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -715,6 +715,18 @@ impl OpenCADStudio { self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } + "IMAGECLIP" => { + use crate::command::CadCommand; + let command = crate::modules::insert::pdf_clip::PdfClipCommand::image(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "VPCLIP" => { + use crate::command::CadCommand; + let command = crate::modules::layout::mview::MviewCommand::vpclip_select(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } "CLIP" => { use crate::command::CadCommand; let command = crate::modules::insert::xclip::ClipCommand::new(); diff --git a/src/app/commands/xclip.rs b/src/app/commands/xclip.rs index 98409ada1..88398f953 100644 --- a/src/app/commands/xclip.rs +++ b/src/app/commands/xclip.rs @@ -37,6 +37,9 @@ impl OpenCADStudio { inserts: Vec, action: XclipAction, ) { + if let XclipAction::Depth { rejected: true, .. } = action { + self.command_line.push_error(crate::modules::insert::xclip::DEPTH_REJECTED); + } self.push_undo_snapshot(i, "XCLIP"); let extents: Vec<_> = inserts.iter().map(|h| self.insert_extents(i, *h)).collect(); let current_layer = self.tabs[i].scene.document.header.current_layer_name.clone(); @@ -51,7 +54,7 @@ impl OpenCADStudio { XclipAction::Delete => { clip::remove_insert_clip(doc, *insert); } - XclipAction::Depth { front, back } => { + XclipAction::Depth { front, back, .. } => { clip::set_insert_clip_depth(doc, *insert, *front, *back); } XclipAction::New { boundary, inverted } => { diff --git a/src/command.rs b/src/command.rs index a22dc018a..e15c4c0ac 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1938,6 +1938,9 @@ pub enum CmdResult { MviewCreateClipped { boundary: Option, boundary_handle: Handle, + /// VPCLIP: the viewport to clip instead of creating one (NULL for + /// MVIEW). With neither boundary nor handle its clip is deleted. + target: Handle, }, /// Create a wipeout from an existing closed polyline in the active space. /// `erase_source` controls whether the source boundary is consumed. diff --git a/src/entities/raster_image.rs b/src/entities/raster_image.rs index 6c8cbe89f..d7fdcd8b0 100644 --- a/src/entities/raster_image.rs +++ b/src/entities/raster_image.rs @@ -63,6 +63,53 @@ fn image_wire(corners: [[f64; 3]; 4], with_x: bool) -> Vec<[f64; 3]> { pts } +/// The clip boundary of a raster image in drawing pixel units (x along the +/// u-vector, y along the v-vector, 0 at the insertion corner), or `None` +/// when the image shows unclipped. The stored boundary is in raster space: +/// pixel centres at whole numbers, row 0 at the top — the frame edge is at +/// -0.5, so a stored `(x, y)` sits at `(x + 0.5, h - y - 0.5)`. Clipping +/// shows only while both the clip state and the display flag are on (the +/// OFF option clears the flag and keeps the state). +pub(crate) fn image_clip_polygon(img: &RasterImage) -> Option> { + use codec::entities::{ClipType, ImageDisplayFlags}; + if !img.clipping_enabled || !img.flags.contains(ImageDisplayFlags::USE_CLIPPING_BOUNDARY) { + return None; + } + let h = img.size.y; + let at = |x: f64, y: f64| [x + 0.5, h - y - 0.5]; + let cb = &img.clip_boundary; + match cb.clip_type { + ClipType::Rectangular if cb.vertices.len() >= 2 => { + let (a, b) = (cb.vertices[0], cb.vertices[1]); + Some(vec![at(a.x, a.y), at(b.x, a.y), at(b.x, b.y), at(a.x, b.y)]) + } + ClipType::Polygonal if cb.vertices.len() >= 3 => { + let mut poly: Vec<[f64; 2]> = cb.vertices.iter().map(|v| at(v.x, v.y)).collect(); + // The stored polygon repeats its first vertex at the end. + if poly.len() > 3 && poly.first() == poly.last() { + poly.pop(); + } + Some(poly) + } + _ => None, + } +} + +/// World point → raster clip space (the inverse of `image_clip_polygon`'s +/// mapping), for the clip commands. +pub(crate) fn image_world_to_clip(img: &RasterImage, p: [f64; 3]) -> codec::types::Vector2 { + let (u, v) = (&img.u_vector, &img.v_vector); + let d = [p[0] - img.insertion_point.x, p[1] - img.insertion_point.y]; + // Solve d = a·u + b·v in the image plane (x/y components). + let det = u.x * v.y - u.y * v.x; + let (a, b) = if det.abs() > 1e-300 { + ((d[0] * v.y - d[1] * v.x) / det, (u.x * d[1] - u.y * d[0]) / det) + } else { + (0.0, 0.0) + }; + codec::types::Vector2::new(a - 0.5, img.size.y - b - 0.5) +} + fn reflect_vec3(vx: &mut f64, vy: &mut f64, ax: f64, ay: f64, len2: f64) { let dot = *vx * ax + *vy * ay; *vx = 2.0 * dot * ax / len2 - *vx; @@ -93,43 +140,28 @@ impl RenderConvertible for RasterImage { ] }; - // Clip-boundary Y is in image raster space (row 0 = top, Y down); the - // image's v-vector points up, so flip each vertex's Y (`ih - y`) to - // place the boundary where AutoCAD draws it. Must match the raster's - // own clip triangulation in `ImageModel` so outline and pixels align. - let ih = self.size.y; // Diagonals are the BROKEN-reference placeholder; a resolvable image // draws its pixels inside the frame, so the X would scribble over it. let path_probe = self.file_path.trim(); let resolvable = path_probe.is_empty() || crate::scene::model::image_model::resolve_image(path_probe).is_some(); - let pts = if self.clipping_enabled { - let cb = &self.clip_boundary; - match cb.clip_type { - codec::entities::ClipType::Polygonal if cb.vertices.len() >= 3 => { - let mut poly: Vec<[f64; 3]> = - cb.vertices.iter().map(|v| px_to_world(v.x, ih - v.y)).collect(); - if let Some(&first) = poly.first() { - poly.push(first); - } - poly + // Outline: the clip boundary, or — when it hides its inside — the + // frame with the boundary as a hole. Must match the raster's own clip + // triangulation in `ImageModel` so outline and pixels align. + let pts = match image_clip_polygon(self) { + Some(poly) => { + let mut ring: Vec<[f64; 3]> = poly.iter().map(|p| px_to_world(p[0], p[1])).collect(); + ring.push(ring[0]); + if self.clip_boundary.clip_mode == codec::entities::ClipMode::Inside { + let mut frame = image_wire(corners, false); + frame.push([f64::NAN; 3]); + frame.extend(ring); + frame + } else { + ring } - codec::entities::ClipType::Rectangular if cb.vertices.len() >= 2 => { - let v0 = &cb.vertices[0]; - let v1 = &cb.vertices[1]; - let (xa, xb) = (v0.x.min(v1.x), v0.x.max(v1.x)); - let (y0, y1) = (ih - v0.y, ih - v1.y); - let (ya, yb) = (y0.min(y1), y0.max(y1)); - let c0 = px_to_world(xa, ya); - let c1 = px_to_world(xb, ya); - let c2 = px_to_world(xb, yb); - let c3 = px_to_world(xa, yb); - vec![c0, c1, c2, c3, c0] - } - _ => image_wire(corners, !resolvable), } - } else { - image_wire(corners, !resolvable) + None => image_wire(corners, !resolvable), }; // A raster OCS can display renders its pixels (built separately) inside diff --git a/src/modules/draw/draw/raster_image.rs b/src/modules/draw/draw/raster_image.rs index 67e2dd6be..eb5b62959 100644 --- a/src/modules/draw/draw/raster_image.rs +++ b/src/modules/draw/draw/raster_image.rs @@ -96,6 +96,7 @@ impl ImageCommand { world_height, ); img.flags = codec::entities::ImageDisplayFlags::SHOW_IMAGE + | codec::entities::ImageDisplayFlags::SHOW_NOT_ALIGNED | codec::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY; self.plane.place_entity(EntityType::RasterImage(img)) } diff --git a/src/modules/insert/pdf_clip.rs b/src/modules/insert/pdf_clip.rs index 2093d5546..981d8a3a4 100644 --- a/src/modules/insert/pdf_clip.rs +++ b/src/modules/insert/pdf_clip.rs @@ -1,17 +1,23 @@ -// PDFCLIP — clip a PDF underlay to a boundary (CLIP continues here for a -// PDF underlay). +// PDFCLIP / IMAGECLIP — clip a PDF underlay or a raster image to a boundary +// (CLIP continues here for either). // -// Select PDF to clip: +// Select PDF to clip: (Select image to clip:) // Enter PDF clipping option [ON/OFF/Delete/New boundary] : -// Delete old boundary? [Yes/No] : (when one exists) +// (Enter image clipping option [ON/OFF/Delete/New boundary] :) +// Delete old boundary? [Yes/No] : (when one exists; [No/Yes] for +// an image) // Outside mode - Objects outside boundary will be hidden. // Specify clipping boundary or select invert option: // [Select polyline/Polygonal/Rectangular/Invert clip] : // -// The boundary is stored in underlay units; the clip-inside bit follows the -// Invert choice. +// The boundary is stored in underlay units (image: raster pixels, a +// rectangle as its two corners); the clip-inside bit follows the Invert +// choice. -use codec::entities::{Underlay, UnderlayDisplayFlags, UnderlayType}; +use codec::entities::{ + ClipBoundary, ClipMode, ImageDisplayFlags, RasterImage, Underlay, UnderlayDisplayFlags, + UnderlayType, +}; use codec::types::{Handle, Vector2}; use codec::EntityType; use glam::DVec3; @@ -35,6 +41,9 @@ pub struct PdfClipCommand { step: Step, handle: Handle, underlay: Option, + /// IMAGECLIP: the image being clipped (`underlay` stays empty). + image: Option, + image_mode: bool, picked: Option, inverted: bool, points: Vec, @@ -50,12 +59,90 @@ impl PdfClipCommand { step: Step::Select, handle: Handle::NULL, underlay: None, + image: None, + image_mode: false, picked: None, inverted: false, points: Vec::new(), } } + /// IMAGECLIP: asks for an image. + pub fn image() -> Self { + Self { image_mode: true, ..Self::new() } + } + + /// An image CLIP picked: straight to the clipping option. + pub fn for_image(handle: Handle, image: RasterImage) -> Self { + let mut command = Self::image(); + command.handle = handle; + command.image = Some(image); + command.step = Step::Option; + command + } + + fn finish_image(&self, image: RasterImage) -> CmdResult { + CmdResult::UpdateEntityAndFinish { + handle: self.handle, + entity: EntityType::RasterImage(image), + } + } + + /// Image options: ON / OFF switch only the display flag (the boundary + /// stays), Delete drops the boundary back to the whole image, New asks + /// to replace a boundary that exists. + fn image_option(&mut self, token: &str) -> CmdResult { + let Some(mut image) = self.image.clone() else { + return CmdResult::Cancel; + }; + match token { + "ON" => image.flags |= ImageDisplayFlags::USE_CLIPPING_BOUNDARY, + "OFF" => image.flags -= ImageDisplayFlags::USE_CLIPPING_BOUNDARY, + "D" | "DELETE" => { + let mode = image.clip_boundary.clip_mode; + image.clip_boundary = ClipBoundary::full_image(image.size.x, image.size.y); + image.clip_boundary.clip_mode = mode; + image.clipping_enabled = false; + } + "" | "N" | "NEW" | "NEW BOUNDARY" if image.clipping_enabled => { + self.step = Step::DeleteOld; + return CmdResult::NeedPoint; + } + "" | "N" | "NEW" | "NEW BOUNDARY" => return self.enter_mode(), + _ => return CmdResult::ReportError("Invalid option keyword.".to_string()), + } + self.finish_image(image) + } + + /// A rectangle keeps its two corners (lowest and highest raster + /// coordinates); a polygon is closed by repeating its first vertex. + fn apply_image_boundary(&self, world: &[DVec3], rectangle: bool) -> CmdResult { + let Some(mut image) = self.image.clone() else { + return CmdResult::Cancel; + }; + let raster: Vec = world + .iter() + .map(|p| crate::entities::raster_image::image_world_to_clip(&image, p.to_array())) + .collect(); + let mut boundary = if rectangle { + let (mut lo, mut hi) = (raster[0], raster[0]); + for p in &raster { + lo = Vector2::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Vector2::new(hi.x.max(p.x), hi.y.max(p.y)); + } + ClipBoundary::rectangular(lo, hi) + } else { + let mut ring = raster; + ring.push(ring[0]); + ClipBoundary::polygonal(ring) + }; + boundary.clip_mode = if self.inverted { ClipMode::Inside } else { ClipMode::Outside }; + image.clip_boundary = boundary; + image.clipping_enabled = true; + image.flags |= ImageDisplayFlags::USE_CLIPPING_BOUNDARY; + self.finish_image(image) + } + /// A new boundary for an underlay already chosen (the underlay tab's /// Create Clipping Boundary): the New boundary branch at once. Returns the /// command and the result of its first step. @@ -99,7 +186,10 @@ impl PdfClipCommand { Vector2::new((dx * c + dy * s) / sx, (-dx * s + dy * c) / sy) } - fn apply_boundary(&self, world: &[DVec3]) -> CmdResult { + fn apply_boundary(&self, world: &[DVec3], rectangle: bool) -> CmdResult { + if self.image.is_some() { + return self.apply_image_boundary(world, rectangle); + } let Some(mut underlay) = self.underlay.clone() else { return CmdResult::Cancel; }; @@ -110,6 +200,9 @@ impl PdfClipCommand { } fn option(&mut self, text: &str) -> CmdResult { + if self.image.is_some() { + return self.image_option(&text.trim().to_ascii_uppercase()); + } let Some(mut underlay) = self.underlay.clone() else { return CmdResult::Cancel; }; @@ -174,15 +267,20 @@ impl PdfClipCommand { impl CadCommand for PdfClipCommand { fn name(&self) -> &'static str { - "PDFCLIP" + if self.image_mode { "IMAGECLIP" } else { "PDFCLIP" } } fn prompt(&self) -> String { match self.step { + Step::Select if self.image_mode => "Select image to clip:".to_string(), Step::Select => "Select PDF to clip:".to_string(), + Step::Option if self.image_mode => { + "Enter image clipping option [ON/OFF/Delete/New boundary] :".to_string() + } Step::Option => { "Enter PDF clipping option [ON/OFF/Delete/New boundary] :".to_string() } + Step::DeleteOld if self.image_mode => "Delete old boundary? [No/Yes] :".to_string(), Step::DeleteOld => "Delete old boundary? [Yes/No] :".to_string(), Step::Mode => { "[Select polyline/Polygonal/Rectangular/Invert clip] :".to_string() @@ -206,6 +304,9 @@ impl CadCommand for PdfClipCommand { CmdOption::new("Delete", "D"), CmdOption::new("New boundary", "N"), ], + Step::DeleteOld if self.image_mode => { + vec![CmdOption::new("No", "N"), CmdOption::new("Yes", "Y")] + } Step::DeleteOld => vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")], Step::Mode => vec![ CmdOption::new("Select polyline", "S"), @@ -247,6 +348,16 @@ impl CadCommand for PdfClipCommand { fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { let picked = self.picked.take(); match self.step { + Step::Select if self.image_mode => match picked { + Some(EntityType::RasterImage(image)) => { + self.handle = handle; + self.image = Some(image); + self.step = Step::Option; + CmdResult::NeedPoint + } + // Anything else: the prompt again, as the reference does. + _ => CmdResult::NeedPoint, + }, Step::Select => match picked { Some(EntityType::Underlay(u)) if u.underlay_type == UnderlayType::Pdf => { self.handle = handle; @@ -264,7 +375,7 @@ impl CadCommand for PdfClipCommand { .iter() .map(|v| DVec3::new(v.location.x, v.location.y, z)) .collect(); - self.apply_boundary(&world) + self.apply_boundary(&world, false) } _ => CmdResult::ReportError("Invalid object selected.".to_string()), }, @@ -281,7 +392,7 @@ impl CadCommand for PdfClipCommand { } Step::RectSecond => { let first = self.points[0]; - self.apply_boundary(&Self::rectangle(first, pt)) + self.apply_boundary(&Self::rectangle(first, pt), true) } Step::PolyPoints => { self.points.push(pt); @@ -296,9 +407,10 @@ impl CadCommand for PdfClipCommand { Step::Option => self.option(text), Step::DeleteOld => match text.trim().to_ascii_uppercase().as_str() { "" | "Y" | "YES" => self.enter_mode(), - "N" | "NO" => match self.underlay.clone() { - Some(underlay) => self.finish(underlay), - None => CmdResult::Cancel, + "N" | "NO" => match (self.image.clone(), self.underlay.clone()) { + (Some(image), _) => self.finish_image(image), + (None, Some(underlay)) => self.finish(underlay), + _ => CmdResult::Cancel, }, _ => CmdResult::ReportError("Invalid option keyword.".to_string()), }, @@ -310,7 +422,7 @@ impl CadCommand for PdfClipCommand { } "C" | "CLOSE" if self.points.len() >= 3 => { let points = self.points.clone(); - self.apply_boundary(&points) + self.apply_boundary(&points, false) } _ => return None, }, @@ -325,7 +437,7 @@ impl CadCommand for PdfClipCommand { Step::Mode => self.mode(""), Step::PolyPoints if self.points.len() >= 3 => { let points = self.points.clone(); - self.apply_boundary(&points) + self.apply_boundary(&points, false) } Step::PolyPoints => CmdResult::NeedPoint, _ => CmdResult::Cancel, @@ -349,5 +461,5 @@ impl CadCommand for PdfClipCommand { } inventory::submit!(crate::command::CommandRegistration { - names: &["PDFCLIP"] + names: &["PDFCLIP", "IMAGECLIP"] }); diff --git a/src/modules/insert/xclip.rs b/src/modules/insert/xclip.rs index f1ea06b28..e0c7206dc 100644 --- a/src/modules/insert/xclip.rs +++ b/src/modules/insert/xclip.rs @@ -13,7 +13,8 @@ // Specify front clip point or [Distance/Remove]: // // CLIP asks for one object: a block reference continues as XCLIP, a PDF -// underlay as PDFCLIP, anything else is "*Invalid selection*". +// underlay as PDFCLIP, a raster image as IMAGECLIP, a layout viewport as +// VPCLIP, anything else is "*Invalid selection*". use codec::entities::UnderlayType; use codec::types::Handle; @@ -22,6 +23,7 @@ use glam::DVec3; use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; use crate::modules::insert::pdf_clip::PdfClipCommand; +use crate::modules::layout::mview::MviewCommand; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -43,8 +45,9 @@ pub enum XclipAction { Polyline, /// Boundary in WCS (two points = rectangle corners). New { boundary: Vec<[f64; 2]>, inverted: bool }, - /// Front and back clipping planes; `None` removes one. - Depth { front: Option, back: Option }, + /// Front and back clipping planes: `Some(None)` removes one, `None` + /// leaves it. `rejected` prints that the front plane was not applied. + Depth { front: Option>, back: Option>, rejected: bool }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -76,9 +79,12 @@ pub struct XclipCommand { inverted: bool, points: Vec, picked: Option, + /// The front plane answered: a distance, or `None` for Remove. front: Option, } +pub const DEPTH_REJECTED: &str = "Error: Front clipping plane behind back clipping plane. Clipdepth not applied."; + impl XclipCommand { pub fn new() -> Self { Self { @@ -178,7 +184,19 @@ impl XclipCommand { fn depth(&mut self, text: &str) -> CmdResult { match (self.step, text.trim().to_ascii_uppercase().as_str()) { - (_, "R" | "REMOVE") => self.act(XclipAction::Depth { front: None, back: None }), + // Remove clears only the plane being asked; the back one is still asked. + (Step::Front, "R" | "REMOVE") => { + self.front = None; + self.step = Step::Back; + CmdResult::NeedPoint + } + // Back Remove after a front distance: the back plane goes, the + // front is not applied. + (Step::Back, "R" | "REMOVE") => self.act(XclipAction::Depth { + front: self.front.is_none().then_some(None), + back: Some(None), + rejected: self.front.is_some(), + }), (Step::Front, "D" | "DISTANCE") => { self.step = Step::FrontDistance; CmdResult::NeedPoint @@ -202,7 +220,13 @@ impl XclipCommand { self.step = Step::Back; CmdResult::NeedPoint } - _ => self.act(XclipAction::Depth { front: self.front, back: Some(d) }), + // A front plane behind the back one changes nothing. + _ if self.front.is_some_and(|front| front < d) => self.act(XclipAction::Depth { + front: None, + back: None, + rejected: true, + }), + _ => self.act(XclipAction::Depth { front: Some(self.front), back: Some(Some(d)), rejected: false }), } } @@ -458,6 +482,16 @@ impl CadCommand for ClipCommand { self.inner = Some(Box::new(PdfClipCommand::for_underlay(handle, u))); CmdResult::NeedPoint } + Some(EntityType::RasterImage(image)) => { + self.inner = Some(Box::new(PdfClipCommand::for_image(handle, image))); + CmdResult::NeedPoint + } + // The layout's own paper-space viewport (id 1) is not clippable. + Some(EntityType::Viewport(vp)) if vp.id != 1 => { + let clipped = !vp.clip_boundary_handle.is_null(); + self.inner = Some(Box::new(MviewCommand::vpclip(handle, clipped))); + CmdResult::NeedPoint + } _ => CmdResult::ReportError("*Invalid selection*".to_string()), } } diff --git a/src/modules/layout/mview.rs b/src/modules/layout/mview.rs index 925ca3a2b..46ec2d9e4 100644 --- a/src/modules/layout/mview.rs +++ b/src/modules/layout/mview.rs @@ -1,4 +1,16 @@ // MVIEW — interactive paper-space viewport creation. +// +// VPCLIP (and CLIP on a layout viewport) reuses the polygon and object +// steps to clip an existing viewport: +// Select viewport to clip: +// Select clipping object or [Polygonal] : (Delete also taken +// when clipped) +// Specify start point: +// Specify next point or [Arc/Length/Undo]: +// Specify next point or [Arc/Close/Length/Undo]: +// Enter an arc boundary option +// [Angle/CEnter/CLose/Direction/Line/Radius/Second pt/Undo/Endpoint of arc] : +// Specify length of line: use codec::entities::{LwPolyline, LwVertex, Viewport}; use codec::tables::View; @@ -37,6 +49,12 @@ enum Step { DefineNewFirst, DefineNewSecond, PlaceView, + /// VPCLIP: the viewport to clip. + ClipSelect, + /// VPCLIP: a clipping object, or Polygonal / Delete. + ClipChoice, + /// VPCLIP Length: a line of that length along the last direction. + ClipLength, } #[derive(Clone, Copy, PartialEq)] @@ -56,6 +74,10 @@ pub struct MviewCommand { views: Vec, paper_bounds: ((f64, f64), (f64, f64)), original_layout: String, + /// VPCLIP: the viewport being clipped (NULL until chosen) and whether it + /// already has a boundary. + clip: Option<(Handle, bool)>, + picked: Option, } impl MviewCommand { @@ -75,6 +97,77 @@ impl MviewCommand { views, paper_bounds, original_layout, + clip: None, + picked: None, + } + } + + /// VPCLIP asking for the viewport. + pub fn vpclip_select() -> Self { + let mut command = Self::new(String::new(), ((0.0, 0.0), (0.0, 0.0)), Vec::new()); + command.clip = Some((Handle::NULL, false)); + command.step = Step::ClipSelect; + command + } + + /// VPCLIP on a chosen viewport. + pub fn vpclip(viewport: Handle, clipped: bool) -> Self { + let mut command = Self::vpclip_select(); + command.clip = Some((viewport, clipped)); + command.step = Step::ClipChoice; + command + } + + fn clip_target(&self) -> Handle { + self.clip.map_or(Handle::NULL, |(handle, _)| handle) + } + + fn clip_text(&mut self, upper: &str) -> Option { + match self.step { + Step::ClipChoice => match upper { + "" | "P" | "POLYGONAL" => { + self.step = Step::Polygon; + Some(CmdResult::NeedPoint) + } + "D" | "DELETE" if self.clip.is_some_and(|(_, clipped)| clipped) => { + Some(CmdResult::MviewCreateClipped { + boundary: None, + boundary_handle: Handle::NULL, + target: self.clip_target(), + }) + } + _ => None, + }, + Step::ClipLength => { + let length = upper.parse::().ok()?; + let last = *self.polygon.last()?; + let direction = self.polygon_last_tangent.map_or(DVec2::X, |t| t.as_dvec2()); + self.step = Step::Polygon; + Some(self.on_point(last + DVec3::new(direction.x, direction.y, 0.0) * length)) + } + Step::Polygon if self.polygon_mode == PolygonMode::Arc => match upper { + "CL" | "CLOSE" if self.polygon.len() >= 2 => Some(self.finish_polygon()), + "L" | "LINE" => { + self.polygon_mode = PolygonMode::Line; + Some(CmdResult::NeedPoint) + } + "U" | "UNDO" if !self.polygon.is_empty() => Some(self.undo_polygon()), + _ => None, + }, + Step::Polygon => match upper { + "A" | "ARC" if !self.polygon.is_empty() => { + self.polygon_mode = PolygonMode::Arc; + Some(CmdResult::NeedPoint) + } + "C" | "CLOSE" if self.polygon.len() >= 3 => Some(self.finish_polygon()), + "L" | "LENGTH" if !self.polygon.is_empty() => { + self.step = Step::ClipLength; + Some(CmdResult::NeedPoint) + } + "U" | "UNDO" if !self.polygon.is_empty() => Some(self.undo_polygon()), + _ => None, + }, + _ => None, } } @@ -158,6 +251,7 @@ impl MviewCommand { Some(boundary) => CmdResult::MviewCreateClipped { boundary: Some(boundary), boundary_handle: Handle::NULL, + target: self.clip_target(), }, None => CmdResult::Cancel, } @@ -263,10 +357,24 @@ impl MviewCommand { impl CadCommand for MviewCommand { fn name(&self) -> &'static str { - "MVIEW" + if self.clip.is_some() { "VPCLIP" } else { "MVIEW" } } fn prompt(&self) -> String { + if self.clip.is_some() { + return match self.step { + Step::ClipSelect => "Select viewport to clip:", + Step::ClipChoice => "Select clipping object or [Polygonal] :", + Step::ClipLength => "Specify length of line:", + Step::Polygon if self.polygon.is_empty() => "Specify start point:", + Step::Polygon if self.polygon_mode == PolygonMode::Arc => { + "Enter an arc boundary option\n[Angle/CEnter/CLose/Direction/Line/Radius/Second pt/Undo/Endpoint of arc] :" + } + Step::Polygon if self.polygon.len() < 3 => "Specify next point or [Arc/Length/Undo]:", + _ => "Specify next point or [Arc/Close/Length/Undo]:", + } + .to_string(); + } match self.step { Step::RectangleFirst => t!( "MVIEW Specify corner of viewport or [Polygonal/Object/Fit/Insert view]:" @@ -302,10 +410,31 @@ impl CadCommand for MviewCommand { t!("MVIEW New view Specify opposite model-space corner:").into_owned() } Step::PlaceView => t!("MVIEW Insert view Specify placement point:").into_owned(), + Step::ClipSelect | Step::ClipChoice | Step::ClipLength => String::new(), } } fn options(&self) -> Vec { + if self.clip.is_some() { + return match self.step { + Step::ClipChoice => vec![CmdOption::new("Polygonal", "P")], + Step::Polygon if self.polygon_mode == PolygonMode::Arc => vec![ + CmdOption::new("CLose", "CL"), + CmdOption::new("Line", "L"), + CmdOption::new("Undo", "U"), + ], + Step::Polygon if !self.polygon.is_empty() => { + let mut options = vec![CmdOption::new("Arc", "A")]; + if self.polygon.len() >= 3 { + options.push(CmdOption::new("Close", "C")); + } + options.push(CmdOption::new("Length", "L")); + options.push(CmdOption::new("Undo", "U")); + options + } + _ => Vec::new(), + }; + } match self.step { Step::RectangleFirst => vec![ CmdOption::new(t!("Polygonal").as_ref(), "POLYGONAL"), @@ -427,12 +556,19 @@ impl CadCommand for MviewCommand { }, None => CmdResult::Cancel, }, - Step::Object | Step::ChooseView => CmdResult::NeedPoint, + Step::Object | Step::ChooseView | Step::ClipSelect | Step::ClipChoice => { + CmdResult::NeedPoint + } + Step::ClipLength => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { match self.step { + Step::ClipChoice => { + self.step = Step::Polygon; + CmdResult::NeedPoint + } Step::Polygon if self.polygon.len() >= 3 => self.finish_polygon(), Step::DefineNewFirst | Step::DefineNewSecond => { CmdResult::MviewCancelToLayout(self.original_layout.clone()) @@ -451,23 +587,45 @@ impl CadCommand for MviewCommand { } fn needs_entity_pick(&self) -> bool { - self.step == Step::Object + matches!(self.step, Step::Object | Step::ClipSelect | Step::ClipChoice) + } + + fn inject_before_entity_pick(&self) -> bool { + self.clip.is_some() + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = Some(entity); } fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } + if self.step == Step::ClipSelect { + return match self.picked.take() { + Some(EntityType::Viewport(vp)) if vp.id != 1 => { + self.clip = Some((handle, !vp.clip_boundary_handle.is_null())); + self.step = Step::ClipChoice; + CmdResult::NeedPoint + } + _ => CmdResult::ReportError("Object selected was not a viewport\n.".to_string()), + }; + } CmdResult::MviewCreateClipped { boundary: None, boundary_handle: handle, + target: self.clip_target(), } } fn input_kind(&self) -> InputKind { - if self.step == Step::ChooseView { + if self.step == Step::ClipLength { + InputKind::FreeText + } else if self.step == Step::ChooseView { InputKind::FreeText } else if self.step == Step::RectangleFirst + || self.step == Step::ClipChoice || (self.step == Step::Polygon && !self.polygon.is_empty()) { InputKind::SingleToken @@ -478,6 +636,7 @@ impl CadCommand for MviewCommand { fn point_step_accepts_keywords(&self) -> bool { self.step == Step::RectangleFirst + || self.step == Step::ClipChoice || (self.step == Step::Polygon && !self.polygon.is_empty()) } @@ -492,6 +651,9 @@ impl CadCommand for MviewCommand { fn on_text_input(&mut self, text: &str) -> Option { let keyword = text.trim(); let upper = keyword.to_ascii_uppercase(); + if self.clip.is_some() { + return self.clip_text(&upper); + } match self.step { Step::RectangleFirst => match upper.as_str() { "P" | "POLYGONAL" => { @@ -541,6 +703,10 @@ impl CadCommand for MviewCommand { } fn on_undo_step(&mut self) -> Option { + if self.clip.is_some() && self.step == Step::ClipLength { + self.step = Step::Polygon; + return Some(CmdResult::NeedPoint); + } if self.step == Step::Polygon && !self.polygon.is_empty() { Some(self.undo_polygon()) } else { @@ -580,4 +746,4 @@ impl CadCommand for MviewCommand { // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["MVIEW"] }); // MviewCommand +inventory::submit!(crate::command::CommandRegistration { names: &["MVIEW", "VPCLIP"] }); // MviewCommand diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index e99743fd4..6490e31d1 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -33,10 +33,10 @@ fn quad_verts(corners: &[[f32; 3]; 4], corners_low: &[[f32; 3]; 4]) -> Vec Vec<[f64; 2]> { - use codec::entities::{ClipMode, ClipType}; + use codec::entities::ClipMode; let w = img.size.x; let h = img.size.y; let quad = || { @@ -45,34 +45,16 @@ fn clip_triangles_px(img: &codec::entities::RasterImage) -> Vec<[f64; 2]> { [0.0, 0.0], [w, h], [0.0, h], ] }; - if !img.clipping_enabled { + let Some(poly) = crate::entities::raster_image::image_clip_polygon(img) else { return quad(); - } - let cb = &img.clip_boundary; - if cb.clip_mode == ClipMode::Inside { - return quad(); - } - // Clip-boundary Y is in image raster space (row 0 = top, Y increasing - // downward), whereas this pixel space matches the image's v-vector (Y up - // from the insertion corner). Flip each vertex's Y (`h - y`) so the clip - // lands where AutoCAD draws it and samples the matching texels. - let tris: Vec<[f64; 2]> = match cb.clip_type { - ClipType::Rectangular if cb.vertices.len() >= 2 => { - let (v0, v1) = (cb.vertices[0], cb.vertices[1]); - let (xa, xb) = (v0.x.min(v1.x), v0.x.max(v1.x)); - let (y0, y1) = (h - v0.y, h - v1.y); - let (ya, yb) = (y0.min(y1), y0.max(y1)); - vec![[xa, ya], [xb, ya], [xb, yb], [xa, ya], [xb, yb], [xa, yb]] - } - ClipType::Polygonal if cb.vertices.len() >= 3 => { - let poly: Vec<[f64; 3]> = cb.vertices.iter().map(|v| [v.x, h - v.y, 0.0]).collect(); - crate::entities::mesh::triangulate_planar(&poly) - .into_iter() - .map(|p| [p[0], p[1]]) - .collect() - } - _ => quad(), }; + // Inside mode shows the image with the boundary cut out. + let (points, triangles) = if img.clip_boundary.clip_mode == ClipMode::Inside { + kernel::geom2d::triangulate(&[[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]], &[poly]) + } else { + kernel::geom2d::triangulate(&poly, &[]) + }; + let tris: Vec<[f64; 2]> = triangles.into_iter().flat_map(|t| t.map(|i| points[i])).collect(); if tris.is_empty() { quad() } else { diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index 95f3c4d5b..c1199f1ad 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -169,13 +169,22 @@ pub fn set_insert_clip_enabled(doc: &mut CadDocument, insert: Handle, on: bool) } /// Front and back clipping planes (None = off) of an insert's clip. -pub fn set_insert_clip_depth(doc: &mut CadDocument, insert: Handle, front: Option, back: Option) -> bool { +pub fn set_insert_clip_depth( + doc: &mut CadDocument, + insert: Handle, + front: Option>, + back: Option>, +) -> bool { let Some(spatial) = filter_handle(doc, insert) else { return false; }; if let Some(ObjectType::SpatialFilter(f)) = doc.objects.get_mut(&spatial) { - f.front_clip = front; - f.back_clip = back; + if let Some(front) = front { + f.front_clip = front; + } + if let Some(back) = back { + f.back_clip = back; + } } true } From 2e397aec99b3b16096a6bd00b127419b45eb00f5 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:22:44 +0300 Subject: [PATCH 2/3] VPCLIP arc options, visible linked clip boundaries, XCLIP depth rejection - VPCLIP arc mode takes Angle, CEnter, Direction, Radius and Second pt with the reference prompts, and CLose closes with a tangent arc; the arc math is shared with PLINE. - Viewport clip boundaries from MVIEW and VPCLIP stay visible; picking or erasing a boundary takes its viewport along. - XCLIP Clipdepth: a rejected front plane keeps the old front plane and clears the back plane. Co-Authored-By: Claude Opus 5.5 --- src/app/command_driver/viewport.rs | 18 +- src/modules/draw/draw/polyline.rs | 295 +++++++++++++++-------------- src/modules/insert/xclip.rs | 5 +- src/modules/layout/mview.rs | 161 +++++++++++++++- src/scene/selection.rs | 13 ++ 5 files changed, 329 insertions(+), 163 deletions(-) diff --git a/src/app/command_driver/viewport.rs b/src/app/command_driver/viewport.rs index e4f548c96..327d1256b 100644 --- a/src/app/command_driver/viewport.rs +++ b/src/app/command_driver/viewport.rs @@ -82,10 +82,7 @@ impl OpenCADStudio { let old = vp.clip_boundary_handle; let scene = &mut self.tabs[i].scene; let clip = match boundary { - Some(mut entity) => { - entity.common_mut().invisible = true; - scene.add_entity(entity) - } + Some(entity) => scene.add_entity(entity), None => boundary_handle, }; let mut vp = vp; @@ -116,7 +113,6 @@ impl OpenCADStudio { vp.clip_boundary_handle = clip; if let Some(entity) = scene.document.get_entity_mut(clip) { let common = entity.common_mut(); - common.invisible = true; if !common.reactors.contains(&viewport) { common.reactors.push(viewport); } @@ -182,13 +178,10 @@ impl OpenCADStudio { let label = self.history_label_from_active_cmd(i, "MVIEW"); let pending = self.begin_undo(i, label, touched, true); let clip_handle = match boundary { - Some(mut boundary) => { - // A non-rectangular viewport owns a helper boundary - // entity through `clip_boundary_handle`. Keep that - // helper in the document for DWG compatibility and - // stencil clipping, but do not expose it as a separate - // selectable polyline. - boundary.common_mut().invisible = true; + Some(boundary) => { + // A non-rectangular viewport owns its boundary through + // `clip_boundary_handle`: a visible paper-space object that + // is picked and erased together with the viewport. match self.commit_entity_handle(boundary) { Some(handle) => handle, None => { @@ -242,7 +235,6 @@ impl OpenCADStudio { } if let Some(boundary) = self.tabs[i].scene.document.get_entity_mut(clip_handle) { let common = boundary.common_mut(); - common.invisible = true; if !common.reactors.contains(&viewport_handle) { common.reactors.push(viewport_handle); } diff --git a/src/modules/draw/draw/polyline.rs b/src/modules/draw/draw/polyline.rs index d507a6089..bd87604cc 100644 --- a/src/modules/draw/draw/polyline.rs +++ b/src/modules/draw/draw/polyline.rs @@ -47,7 +47,7 @@ enum SegMode { /// sub-step returns to `None` once it has produced a vertex, or when Undo / /// Enter / Escape backs out of it. #[derive(Clone, Copy, PartialEq, Debug)] -enum Sub { +pub(crate) enum Sub { None, /// Width / Halfwidth: starting width, then ending width. `half` scales the /// prompt and the typed value by two. @@ -81,7 +81,7 @@ enum Sub { impl Sub { /// The step reads a single typed value (width, length, angle, radius). - fn is_scalar(self) -> bool { + pub(crate) fn is_scalar(self) -> bool { matches!( self, Sub::WidthStart { .. } @@ -97,7 +97,7 @@ impl Sub { } /// The typed value is an angle (degrees) rather than a length. - fn is_angle(self) -> bool { + pub(crate) fn is_angle(self) -> bool { matches!( self, Sub::ArcAngle | Sub::ArcCenterAngle { .. } | Sub::ArcRadiusAngle { .. } @@ -249,149 +249,8 @@ impl PlineCommand { .unwrap_or(DVec2::X) } - /// Sign for an arc whose direction is not fixed by its definition - /// (CEnter / Radius endpoints, chord length): bend the way the previous - /// segment was heading so the polyline flows on, else counter-clockwise. - fn free_arc_sign(&self, a: DVec2, e: DVec2) -> f64 { - let chord = e - a; - let cross = match self.last_tangent { - Some(t) => t.as_dvec2().perp_dot(chord), - None => 1.0, - }; - if cross < 0.0 { - -1.0 - } else { - 1.0 - } - } - - /// Bulge of the arc from `a` to `e` through `s` (3-point arc). `None` when - /// the points are collinear or coincident. - fn bulge_through(a: DVec2, s: DVec2, e: DVec2) -> Option { - let d = 2.0 * (a.x * (s.y - e.y) + s.x * (e.y - a.y) + e.x * (a.y - s.y)); - if d.abs() < 1e-12 { - return None; - } - let a2 = a.length_squared(); - let s2 = s.length_squared(); - let e2 = e.length_squared(); - let cx = (a2 * (s.y - e.y) + s2 * (e.y - a.y) + e2 * (a.y - s.y)) / d; - let cy = (a2 * (e.x - s.x) + s2 * (a.x - e.x) + e2 * (s.x - a.x)) / d; - let c = DVec2::new(cx, cy); - let start = (a - c).y.atan2((a - c).x); - let end = (e - c).y.atan2((e - c).x); - // The arc bends towards `s`: with `s` right of the chord a → e the - // arc runs counter-clockwise, left of it clockwise. - let ccw = (e - a).perp_dot(s - a) < 0.0; - let mut sweep = end - start; - if ccw { - if sweep <= 0.0 { - sweep += std::f64::consts::TAU; - } - } else if sweep >= 0.0 { - sweep -= std::f64::consts::TAU; - } - Some((sweep / 4.0).tan()) - } - - /// Endpoint of the arc about `c` starting at `a` with signed `sweep`. - fn rotate_about(c: DVec2, a: DVec2, sweep: f64) -> DVec2 { - let (sn, cs) = sweep.sin_cos(); - let v = a - c; - c + DVec2::new(v.x * cs - v.y * sn, v.x * sn + v.y * cs) - } - - /// Resolve the arc a sub-step describes for a candidate `cursor`/point: - /// `(endpoint, bulge)`. `None` when the geometry is degenerate for that - /// input (e.g. a chord longer than the diameter). fn arc_for(&self, sub: Sub, a: DVec2, p: DVec2) -> Option<(DVec2, f64)> { - let sweep_ok = |s: f64| s.abs() > 1e-9 && s.abs() < std::f64::consts::TAU; - match sub { - Sub::ArcAngleEnd { angle } if sweep_ok(angle) => { - if (p - a).length_squared() < 1e-12 { - return None; - } - Some((p, (angle / 4.0).tan())) - } - Sub::ArcAngleCenter { angle } if sweep_ok(angle) => { - if (a - p).length_squared() < 1e-12 { - return None; - } - Some((Self::rotate_about(p, a, angle), (angle / 4.0).tan())) - } - Sub::ArcAngleRadiusDir { angle, r } if sweep_ok(angle) && r > 0.0 => { - let dir = (p - a).try_normalize()?; - let chord = 2.0 * r * (angle.abs() * 0.5).sin(); - Some((a + dir * chord, (angle / 4.0).tan())) - } - Sub::ArcCenterEnd { c } => { - let r = (a - c).length(); - if r < 1e-9 { - return None; - } - let dir = (p - c).try_normalize()?; - let e = c + dir * r; - let start = (a - c).y.atan2((a - c).x); - let end = (e - c).y.atan2((e - c).x); - let sign = self.free_arc_sign(a, e); - let mut sweep = (end - start).rem_euclid(std::f64::consts::TAU); - if sign < 0.0 { - sweep -= std::f64::consts::TAU; - } - if !sweep_ok(sweep) { - return None; - } - Some((e, (sweep / 4.0).tan())) - } - Sub::ArcCenterAngle { c } => { - // `p` carries the typed angle in `x`. - let angle = p.x; - if (a - c).length_squared() < 1e-12 || !sweep_ok(angle) { - return None; - } - Some((Self::rotate_about(c, a, angle), (angle / 4.0).tan())) - } - Sub::ArcCenterLength { c } => { - // `p` carries the typed chord length in `x`. - let len = p.x; - let r = (a - c).length(); - if r < 1e-9 || len <= 0.0 || len > 2.0 * r { - return None; - } - let sweep = 2.0 * (len / (2.0 * r)).asin(); - // Minor arc; bend on the side the previous segment favours. - let e_ccw = Self::rotate_about(c, a, sweep); - let sign = self.free_arc_sign(a, e_ccw); - let sweep = sweep * sign; - Some((Self::rotate_about(c, a, sweep), (sweep / 4.0).tan())) - } - Sub::ArcDirectionEnd { dir } => { - if (p - a).length_squared() < 1e-12 { - return None; - } - Some((p, compute_bulge(a, dir, p))) - } - Sub::ArcRadiusEnd { r } if r > 0.0 => { - let chord = (p - a).length(); - if chord < 1e-9 || chord > 2.0 * r { - return None; - } - let sweep = 2.0 * (chord / (2.0 * r)).asin() * self.free_arc_sign(a, p); - Some((p, (sweep / 4.0).tan())) - } - Sub::ArcRadiusAngleDir { r, angle } if r > 0.0 && sweep_ok(angle) => { - let dir = (p - a).try_normalize()?; - let chord = 2.0 * r * (angle.abs() * 0.5).sin(); - Some((a + dir * chord, (angle / 4.0).tan())) - } - Sub::ArcSecondEnd { s } => { - if (p - a).length_squared() < 1e-12 { - return None; - } - Some((p, Self::bulge_through(a, s, p)?)) - } - _ => None, - } + arc_for(sub, a, p, self.last_tangent) } /// Land a segment ending at local `e` with `bulge`, publish it, and @@ -582,6 +441,152 @@ impl PlineCommand { // ── Arc geometry helpers ─────────────────────────────────────────────────── +/// Sign for an arc whose direction is not fixed by its definition +/// (CEnter / Radius endpoints, chord length): bend the way the previous +/// segment was heading so the polyline flows on, else counter-clockwise. +fn free_arc_sign(last_tangent: Option, a: DVec2, e: DVec2) -> f64 { + let chord = e - a; + let cross = match last_tangent { + Some(t) => t.as_dvec2().perp_dot(chord), + None => 1.0, + }; + if cross < 0.0 { + -1.0 + } else { + 1.0 + } +} + +/// Bulge of the arc from `a` to `e` through `s` (3-point arc). `None` when +/// the points are collinear or coincident. +fn bulge_through(a: DVec2, s: DVec2, e: DVec2) -> Option { + let d = 2.0 * (a.x * (s.y - e.y) + s.x * (e.y - a.y) + e.x * (a.y - s.y)); + if d.abs() < 1e-12 { + return None; + } + let a2 = a.length_squared(); + let s2 = s.length_squared(); + let e2 = e.length_squared(); + let cx = (a2 * (s.y - e.y) + s2 * (e.y - a.y) + e2 * (a.y - s.y)) / d; + let cy = (a2 * (e.x - s.x) + s2 * (a.x - e.x) + e2 * (s.x - a.x)) / d; + let c = DVec2::new(cx, cy); + let start = (a - c).y.atan2((a - c).x); + let end = (e - c).y.atan2((e - c).x); + // The arc bends towards `s`: with `s` right of the chord a → e the + // arc runs counter-clockwise, left of it clockwise. + let ccw = (e - a).perp_dot(s - a) < 0.0; + let mut sweep = end - start; + if ccw { + if sweep <= 0.0 { + sweep += std::f64::consts::TAU; + } + } else if sweep >= 0.0 { + sweep -= std::f64::consts::TAU; + } + Some((sweep / 4.0).tan()) +} + +/// Endpoint of the arc about `c` starting at `a` with signed `sweep`. +fn rotate_about(c: DVec2, a: DVec2, sweep: f64) -> DVec2 { + let (sn, cs) = sweep.sin_cos(); + let v = a - c; + c + DVec2::new(v.x * cs - v.y * sn, v.x * sn + v.y * cs) +} + +/// Resolve the arc an arc sub-step describes (PLINE, VPCLIP) from `a` for a +/// candidate `cursor`/point `p`, after a segment leaving along +/// `last_tangent`: `(endpoint, bulge)`. `None` when the geometry is +/// degenerate for that input (e.g. a chord longer than the diameter). +pub(crate) fn arc_for(sub: Sub, a: DVec2, p: DVec2, last_tangent: Option) -> Option<(DVec2, f64)> { + let sweep_ok = |s: f64| s.abs() > 1e-9 && s.abs() < std::f64::consts::TAU; + match sub { + Sub::ArcAngleEnd { angle } if sweep_ok(angle) => { + if (p - a).length_squared() < 1e-12 { + return None; + } + Some((p, (angle / 4.0).tan())) + } + Sub::ArcAngleCenter { angle } if sweep_ok(angle) => { + if (a - p).length_squared() < 1e-12 { + return None; + } + Some((rotate_about(p, a, angle), (angle / 4.0).tan())) + } + Sub::ArcAngleRadiusDir { angle, r } if sweep_ok(angle) && r > 0.0 => { + let dir = (p - a).try_normalize()?; + let chord = 2.0 * r * (angle.abs() * 0.5).sin(); + Some((a + dir * chord, (angle / 4.0).tan())) + } + Sub::ArcCenterEnd { c } => { + let r = (a - c).length(); + if r < 1e-9 { + return None; + } + let dir = (p - c).try_normalize()?; + let e = c + dir * r; + let start = (a - c).y.atan2((a - c).x); + let end = (e - c).y.atan2((e - c).x); + let sign = free_arc_sign(last_tangent, a, e); + let mut sweep = (end - start).rem_euclid(std::f64::consts::TAU); + if sign < 0.0 { + sweep -= std::f64::consts::TAU; + } + if !sweep_ok(sweep) { + return None; + } + Some((e, (sweep / 4.0).tan())) + } + Sub::ArcCenterAngle { c } => { + // `p` carries the typed angle in `x`. + let angle = p.x; + if (a - c).length_squared() < 1e-12 || !sweep_ok(angle) { + return None; + } + Some((rotate_about(c, a, angle), (angle / 4.0).tan())) + } + Sub::ArcCenterLength { c } => { + // `p` carries the typed chord length in `x`. + let len = p.x; + let r = (a - c).length(); + if r < 1e-9 || len <= 0.0 || len > 2.0 * r { + return None; + } + let sweep = 2.0 * (len / (2.0 * r)).asin(); + // Minor arc; bend on the side the previous segment favours. + let e_ccw = rotate_about(c, a, sweep); + let sign = free_arc_sign(last_tangent, a, e_ccw); + let sweep = sweep * sign; + Some((rotate_about(c, a, sweep), (sweep / 4.0).tan())) + } + Sub::ArcDirectionEnd { dir } => { + if (p - a).length_squared() < 1e-12 { + return None; + } + Some((p, compute_bulge(a, dir, p))) + } + Sub::ArcRadiusEnd { r } if r > 0.0 => { + let chord = (p - a).length(); + if chord < 1e-9 || chord > 2.0 * r { + return None; + } + let sweep = 2.0 * (chord / (2.0 * r)).asin() * free_arc_sign(last_tangent, a, p); + Some((p, (sweep / 4.0).tan())) + } + Sub::ArcRadiusAngleDir { r, angle } if r > 0.0 && sweep_ok(angle) => { + let dir = (p - a).try_normalize()?; + let chord = 2.0 * r * (angle.abs() * 0.5).sin(); + Some((a + dir * chord, (angle / 4.0).tan())) + } + Sub::ArcSecondEnd { s } => { + if (p - a).length_squared() < 1e-12 { + return None; + } + Some((p, bulge_through(a, s, p)?)) + } + _ => None, + } +} + /// Compute the bulge for the arc from `a` to `b` that is tangent to `tangent` at `a`. /// Returns 0.0 if the points are coincident or the tangent is parallel to the chord. pub(crate) fn compute_bulge(a: DVec2, tangent: DVec2, b: DVec2) -> f64 { diff --git a/src/modules/insert/xclip.rs b/src/modules/insert/xclip.rs index e0c7206dc..dcbca18e1 100644 --- a/src/modules/insert/xclip.rs +++ b/src/modules/insert/xclip.rs @@ -220,10 +220,11 @@ impl XclipCommand { self.step = Step::Back; CmdResult::NeedPoint } - // A front plane behind the back one changes nothing. + // A front plane behind the back one is not applied; the back + // plane is cleared and the old front one kept. _ if self.front.is_some_and(|front| front < d) => self.act(XclipAction::Depth { front: None, - back: None, + back: Some(None), rejected: true, }), _ => self.act(XclipAction::Depth { front: Some(self.front), back: Some(Some(d)), rejected: false }), diff --git a/src/modules/layout/mview.rs b/src/modules/layout/mview.rs index 46ec2d9e4..99dffedef 100644 --- a/src/modules/layout/mview.rs +++ b/src/modules/layout/mview.rs @@ -11,6 +11,8 @@ // Enter an arc boundary option // [Angle/CEnter/CLose/Direction/Line/Radius/Second pt/Undo/Endpoint of arc] : // Specify length of line: +// and the arc options' prompts (Angle, CEnter, Direction, Radius, Second pt) +// as the reference words them; CLose in arc mode closes with a tangent arc. use codec::entities::{LwPolyline, LwVertex, Viewport}; use codec::tables::View; @@ -20,7 +22,7 @@ use crate::t; use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; use crate::modules::draw::draw::polyline::{ - arc_sample_points, compute_bulge, seg_exit_tangent, update_tangent_after_arc, + arc_for, arc_sample_points, compute_bulge, seg_exit_tangent, update_tangent_after_arc, Sub, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -78,6 +80,8 @@ pub struct MviewCommand { /// already has a boundary. clip: Option<(Handle, bool)>, picked: Option, + /// VPCLIP arc mode: the arc option being answered. + arc_sub: Sub, } impl MviewCommand { @@ -99,6 +103,7 @@ impl MviewCommand { original_layout, clip: None, picked: None, + arc_sub: Sub::None, } } @@ -122,7 +127,129 @@ impl MviewCommand { self.clip.map_or(Handle::NULL, |(handle, _)| handle) } + /// VPCLIP: an arc segment from the last vertex to `e`. + fn push_arc(&mut self, e: DVec2, bulge: f64) -> CmdResult { + let Some(last) = self.polygon.last().copied() else { + return CmdResult::NeedPoint; + }; + let end = DVec3::new(e.x, e.y, last.z); + let index = self.polygon.len() - 1; + self.polygon_bulges[index] = bulge; + self.polygon_last_tangent = seg_exit_tangent(last, end, bulge); + self.arc_sub = Sub::None; + self.polygon.push(end); + self.polygon_bulges.push(0.0); + CmdResult::NeedPoint + } + + fn last2(&self) -> Option { + self.polygon.last().map(|p| DVec2::new(p.x, p.y)) + } + + /// VPCLIP arc option answered with a typed value. + fn arc_value(&mut self, value: f64) -> Option { + let a = self.last2()?; + let tangent = self.polygon_last_tangent; + let angle_ok = |r: f64| r.abs() > 1e-9 && r.abs() < std::f64::consts::TAU; + let next = match self.arc_sub { + Sub::ArcAngle if angle_ok(value.to_radians()) => Sub::ArcAngleEnd { angle: value.to_radians() }, + Sub::ArcAngleRadius { angle } if value > 0.0 => Sub::ArcAngleRadiusDir { angle, r: value }, + Sub::ArcRadius if value > 0.0 => Sub::ArcRadiusEnd { r: value }, + Sub::ArcRadiusAngle { r } if angle_ok(value.to_radians()) => { + Sub::ArcRadiusAngleDir { r, angle: value.to_radians() } + } + sub @ Sub::ArcCenterAngle { .. } => { + let (e, bulge) = arc_for(sub, a, DVec2::new(value.to_radians(), 0.0), tangent)?; + return Some(self.push_arc(e, bulge)); + } + sub @ Sub::ArcCenterLength { .. } => { + let (e, bulge) = arc_for(sub, a, DVec2::new(value, 0.0), tangent)?; + return Some(self.push_arc(e, bulge)); + } + _ => return None, + }; + self.arc_sub = next; + Some(CmdResult::NeedPoint) + } + + /// VPCLIP arc option answered with a point. + fn arc_point(&mut self, pt: DVec3) -> CmdResult { + let Some(a) = self.last2() else { + return CmdResult::NeedPoint; + }; + let p = DVec2::new(pt.x, pt.y); + let sub = self.arc_sub; + if sub.is_scalar() { + let value = if sub.is_angle() { (p - a).y.atan2((p - a).x).to_degrees() } else { (p - a).length() }; + return self.arc_value(value).unwrap_or(CmdResult::NeedPoint); + } + match sub { + Sub::ArcCenter if (p - a).length_squared() > 1e-12 => self.arc_sub = Sub::ArcCenterEnd { c: p }, + Sub::ArcDirection => { + if let Some(dir) = (p - a).try_normalize() { + self.arc_sub = Sub::ArcDirectionEnd { dir }; + } + } + Sub::ArcSecond if (p - a).length_squared() > 1e-12 => self.arc_sub = Sub::ArcSecondEnd { s: p }, + _ => { + if let Some((e, bulge)) = arc_for(sub, a, p, self.polygon_last_tangent) { + return self.push_arc(e, bulge); + } + } + } + CmdResult::NeedPoint + } + + /// VPCLIP CLose in arc mode: a tangent arc back to the start. + fn close_with_arc(&mut self) -> CmdResult { + let (Some(last), Some(first)) = (self.polygon.last().copied(), self.polygon.first().copied()) else { + return CmdResult::NeedPoint; + }; + let tangent = self.polygon_last_tangent.map_or(DVec2::X, |t| t.as_dvec2()); + let index = self.polygon.len() - 1; + self.polygon_bulges[index] = compute_bulge(DVec2::new(last.x, last.y), tangent, DVec2::new(first.x, first.y)); + self.finish_polygon() + } + + fn arc_prompt(&self) -> Option<&'static str> { + Some(match self.arc_sub { + Sub::ArcAngle | Sub::ArcCenterAngle { .. } | Sub::ArcRadiusAngle { .. } => "Specify included angle:", + Sub::ArcAngleEnd { .. } => "Specify endpoint of arc (hold Ctrl to switch direction) or [CEnter/Radius]:", + Sub::ArcCenter | Sub::ArcAngleCenter { .. } => "Specify center point of arc:", + Sub::ArcCenterEnd { .. } => "Specify endpoint of arc (hold Ctrl to switch direction) or [Angle/Length]:", + Sub::ArcCenterLength { .. } => "Specify length of chord:", + Sub::ArcDirection => "Specify the tangent direction for the start point of arc:", + Sub::ArcDirectionEnd { .. } => "Specify endpoint of the arc (hold Ctrl to switch direction):", + Sub::ArcRadius | Sub::ArcAngleRadius { .. } => "Specify radius of arc:", + Sub::ArcRadiusEnd { .. } => "Specify endpoint of arc (hold Ctrl to switch direction) or [Angle]:", + Sub::ArcAngleRadiusDir { .. } | Sub::ArcRadiusAngleDir { .. } => "Specify direction of chord for arc:", + Sub::ArcSecond => "Specify second point on arc:", + Sub::ArcSecondEnd { .. } => "Specify end point of arc:", + _ => return None, + }) + } + fn clip_text(&mut self, upper: &str) -> Option { + if self.step == Step::Polygon && self.arc_sub != Sub::None { + let next = match (self.arc_sub, upper) { + (Sub::ArcAngleEnd { angle }, "CE" | "CENTER" | "CENTRE") => Sub::ArcAngleCenter { angle }, + (Sub::ArcAngleEnd { angle }, "R" | "RADIUS") => Sub::ArcAngleRadius { angle }, + (Sub::ArcCenterEnd { c }, "A" | "ANGLE") => Sub::ArcCenterAngle { c }, + (Sub::ArcCenterEnd { c }, "L" | "LENGTH") => Sub::ArcCenterLength { c }, + (Sub::ArcRadiusEnd { r }, "A" | "ANGLE") => Sub::ArcRadiusAngle { r }, + (sub, text) if sub.is_scalar() => { + let value = if sub.is_angle() { + crate::entities::common::parse_typed_angle(text).map(f64::to_degrees) + } else { + crate::entities::common::parse_typed_length(text) + }; + return value.and_then(|v| self.arc_value(v)); + } + _ => return None, + }; + self.arc_sub = next; + return Some(CmdResult::NeedPoint); + } match self.step { Step::ClipChoice => match upper { "" | "P" | "POLYGONAL" => { @@ -146,7 +273,18 @@ impl MviewCommand { Some(self.on_point(last + DVec3::new(direction.x, direction.y, 0.0) * length)) } Step::Polygon if self.polygon_mode == PolygonMode::Arc => match upper { - "CL" | "CLOSE" if self.polygon.len() >= 2 => Some(self.finish_polygon()), + "CL" | "CLOSE" if self.polygon.len() >= 2 => Some(self.close_with_arc()), + "A" | "ANGLE" | "CE" | "CENTER" | "CENTRE" | "D" | "DIRECTION" | "R" | "RADIUS" | "S" + | "SECOND" | "SECOND PT" => { + self.arc_sub = match upper { + "A" | "ANGLE" => Sub::ArcAngle, + "CE" | "CENTER" | "CENTRE" => Sub::ArcCenter, + "D" | "DIRECTION" => Sub::ArcDirection, + "R" | "RADIUS" => Sub::ArcRadius, + _ => Sub::ArcSecond, + }; + Some(CmdResult::NeedPoint) + } "L" | "LINE" => { self.polygon_mode = PolygonMode::Line; Some(CmdResult::NeedPoint) @@ -367,6 +505,7 @@ impl CadCommand for MviewCommand { Step::ClipChoice => "Select clipping object or [Polygonal] :", Step::ClipLength => "Specify length of line:", Step::Polygon if self.polygon.is_empty() => "Specify start point:", + Step::Polygon if self.arc_prompt().is_some() => self.arc_prompt().unwrap_or_default(), Step::Polygon if self.polygon_mode == PolygonMode::Arc => { "Enter an arc boundary option\n[Angle/CEnter/CLose/Direction/Line/Radius/Second pt/Undo/Endpoint of arc] :" } @@ -418,9 +557,20 @@ impl CadCommand for MviewCommand { if self.clip.is_some() { return match self.step { Step::ClipChoice => vec![CmdOption::new("Polygonal", "P")], + Step::Polygon if self.arc_sub != Sub::None => match self.arc_sub { + Sub::ArcAngleEnd { .. } => vec![CmdOption::new("CEnter", "CE"), CmdOption::new("Radius", "R")], + Sub::ArcCenterEnd { .. } => vec![CmdOption::new("Angle", "A"), CmdOption::new("Length", "L")], + Sub::ArcRadiusEnd { .. } => vec![CmdOption::new("Angle", "A")], + _ => Vec::new(), + }, Step::Polygon if self.polygon_mode == PolygonMode::Arc => vec![ + CmdOption::new("Angle", "A"), + CmdOption::new("CEnter", "CE"), CmdOption::new("CLose", "CL"), + CmdOption::new("Direction", "D"), CmdOption::new("Line", "L"), + CmdOption::new("Radius", "R"), + CmdOption::new("Second pt", "S"), CmdOption::new("Undo", "U"), ], Step::Polygon if !self.polygon.is_empty() => { @@ -479,6 +629,7 @@ impl CadCommand for MviewCommand { }, None => CmdResult::NeedPoint, }, + Step::Polygon if self.arc_sub != Sub::None => self.arc_point(pt), Step::Polygon => { if let Some(last) = self.polygon.last().copied() { let last_index = self.polygon.len() - 1; @@ -620,7 +771,7 @@ impl CadCommand for MviewCommand { } fn input_kind(&self) -> InputKind { - if self.step == Step::ClipLength { + if self.step == Step::ClipLength || (self.step == Step::Polygon && self.arc_sub.is_scalar()) { InputKind::FreeText } else if self.step == Step::ChooseView { InputKind::FreeText @@ -707,6 +858,10 @@ impl CadCommand for MviewCommand { self.step = Step::Polygon; return Some(CmdResult::NeedPoint); } + if self.arc_sub != Sub::None { + self.arc_sub = Sub::None; + return Some(CmdResult::NeedPoint); + } if self.step == Step::Polygon && !self.polygon.is_empty() { Some(self.undo_polygon()) } else { diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 942593097..097c28bc3 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -121,6 +121,19 @@ impl Scene { handles: &[Handle], ) -> Vec { let mut expanded = self.expanded_with_leaders(handles); + // A viewport's clip boundary goes with its viewport: picking the + // boundary selects both, erasing it erases the viewport. + let viewports: Vec = expanded + .iter() + .filter_map(|h| self.document.get_entity(*h).map(|e| (*h, e))) + .flat_map(|(h, e)| { + e.common().reactors.iter().copied().filter(move |r| { + matches!(self.document.get_entity(*r), + Some(EntityType::Viewport(vp)) if vp.clip_boundary_handle == h) + }) + }) + .collect(); + expanded.extend(viewports); expanded.sort_unstable_by_key(|handle| handle.value()); expanded.dedup(); expanded From 3f1b236c0a1ec6b786bbb84cfb43c1e29743e7cf Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:51:11 +0300 Subject: [PATCH 3/3] VPCLIP: chord direction and nested arc option prompts The chord direction step takes a typed angle and defaults to the last segment's direction on Enter; CEnter's Angle and Length prompts carry the direction switch hint as the reference words them. Co-Authored-By: Claude Opus 5.5 --- src/modules/layout/mview.rs | 55 ++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/src/modules/layout/mview.rs b/src/modules/layout/mview.rs index 99dffedef..e752d2574 100644 --- a/src/modules/layout/mview.rs +++ b/src/modules/layout/mview.rs @@ -211,25 +211,55 @@ impl MviewCommand { self.finish_polygon() } - fn arc_prompt(&self) -> Option<&'static str> { + /// Direction of the last segment, degrees: the chord direction default. + fn tangent_degrees(&self) -> f64 { + let t = self.polygon_last_tangent.map_or(DVec2::X, |t| t.as_dvec2()); + t.y.atan2(t.x).to_degrees().rem_euclid(360.0) + } + + /// VPCLIP chord direction (degrees) for the arc option being answered. + fn arc_chord(&mut self, degrees: f64) -> Option { + let a = self.last2()?; + let p = a + DVec2::from_angle(degrees.to_radians()); + let (e, bulge) = arc_for(self.arc_sub, a, p, self.polygon_last_tangent)?; + Some(self.push_arc(e, bulge)) + } + + fn chord_step(&self) -> bool { + matches!(self.arc_sub, Sub::ArcAngleRadiusDir { .. } | Sub::ArcRadiusAngleDir { .. }) + } + + fn arc_prompt(&self) -> Option { + if self.chord_step() { + let default = format!("{:.4}", self.tangent_degrees()); + let default = default.trim_end_matches('0').trim_end_matches('.'); + return Some(format!( + "Specify direction of chord for arc (hold Ctrl to switch direction) <{default}>:" + )); + } Some(match self.arc_sub { - Sub::ArcAngle | Sub::ArcCenterAngle { .. } | Sub::ArcRadiusAngle { .. } => "Specify included angle:", + Sub::ArcCenterAngle { .. } => "Specify included angle (hold Ctrl to switch direction):", + Sub::ArcAngle | Sub::ArcRadiusAngle { .. } => "Specify included angle:", Sub::ArcAngleEnd { .. } => "Specify endpoint of arc (hold Ctrl to switch direction) or [CEnter/Radius]:", Sub::ArcCenter | Sub::ArcAngleCenter { .. } => "Specify center point of arc:", Sub::ArcCenterEnd { .. } => "Specify endpoint of arc (hold Ctrl to switch direction) or [Angle/Length]:", - Sub::ArcCenterLength { .. } => "Specify length of chord:", + Sub::ArcCenterLength { .. } => "Specify length of chord (hold Ctrl to switch direction):", Sub::ArcDirection => "Specify the tangent direction for the start point of arc:", Sub::ArcDirectionEnd { .. } => "Specify endpoint of the arc (hold Ctrl to switch direction):", Sub::ArcRadius | Sub::ArcAngleRadius { .. } => "Specify radius of arc:", Sub::ArcRadiusEnd { .. } => "Specify endpoint of arc (hold Ctrl to switch direction) or [Angle]:", - Sub::ArcAngleRadiusDir { .. } | Sub::ArcRadiusAngleDir { .. } => "Specify direction of chord for arc:", Sub::ArcSecond => "Specify second point on arc:", Sub::ArcSecondEnd { .. } => "Specify end point of arc:", _ => return None, - }) + } + .to_string()) } fn clip_text(&mut self, upper: &str) -> Option { + if self.step == Step::Polygon && self.chord_step() { + let degrees = crate::entities::common::parse_typed_angle(upper)?.to_degrees(); + return self.arc_chord(degrees); + } if self.step == Step::Polygon && self.arc_sub != Sub::None { let next = match (self.arc_sub, upper) { (Sub::ArcAngleEnd { angle }, "CE" | "CENTER" | "CENTRE") => Sub::ArcAngleCenter { angle }, @@ -499,13 +529,17 @@ impl CadCommand for MviewCommand { } fn prompt(&self) -> String { + if self.clip.is_some() && self.step == Step::Polygon { + if let Some(prompt) = self.arc_prompt() { + return prompt; + } + } if self.clip.is_some() { return match self.step { Step::ClipSelect => "Select viewport to clip:", Step::ClipChoice => "Select clipping object or [Polygonal] :", Step::ClipLength => "Specify length of line:", Step::Polygon if self.polygon.is_empty() => "Specify start point:", - Step::Polygon if self.arc_prompt().is_some() => self.arc_prompt().unwrap_or_default(), Step::Polygon if self.polygon_mode == PolygonMode::Arc => { "Enter an arc boundary option\n[Angle/CEnter/CLose/Direction/Line/Radius/Second pt/Undo/Endpoint of arc] :" } @@ -716,6 +750,11 @@ impl CadCommand for MviewCommand { fn on_enter(&mut self) -> CmdResult { match self.step { + // The chord direction defaults to the last segment's direction. + Step::Polygon if self.chord_step() => { + let degrees = self.tangent_degrees(); + self.arc_chord(degrees).unwrap_or(CmdResult::NeedPoint) + } Step::ClipChoice => { self.step = Step::Polygon; CmdResult::NeedPoint @@ -771,7 +810,9 @@ impl CadCommand for MviewCommand { } fn input_kind(&self) -> InputKind { - if self.step == Step::ClipLength || (self.step == Step::Polygon && self.arc_sub.is_scalar()) { + if self.step == Step::ClipLength + || (self.step == Step::Polygon && (self.arc_sub.is_scalar() || self.chord_step())) + { InputKind::FreeText } else if self.step == Step::ChooseView { InputKind::FreeText