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 }