diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 370a4d5e9..23de79c98 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -384,6 +384,225 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::MviewCreate { + viewport, + preserve_view, + } => { + let saved_view = preserve_view.then(|| { + ( + viewport.view_target.clone(), + viewport.view_direction.clone(), + viewport.view_center.clone(), + viewport.view_height, + viewport.custom_scale, + viewport.lens_length, + viewport.twist_angle, + viewport.status.perspective, + ) + }); + let label = self.history_label_from_active_cmd(i, "MVIEW"); + let pending = self.begin_undo(i, label, 1, false); + let handle = self.commit_entity_handle( + acadrust::EntityType::Viewport(viewport), + ); + if let (Some(handle), Some(saved)) = (handle, saved_view) { + if let Some(acadrust::EntityType::Viewport(viewport)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + viewport.view_target = saved.0; + viewport.view_direction = saved.1; + viewport.view_center = saved.2; + viewport.view_height = saved.3; + viewport.custom_scale = saved.4; + viewport.lens_length = saved.5; + viewport.twist_angle = saved.6; + viewport.status.perspective = saved.7; + } + self.tabs[i].scene.camera_generation += 1; + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + } + CmdResult::MviewCreateClipped { + boundary, + boundary_handle, + } => { + if boundary.is_none() { + let scene = &self.tabs[i].scene; + let valid = scene + .entity_belongs_to_current_layout(boundary_handle) + && scene + .document + .get_entity(boundary_handle) + .is_some_and(|entity| { + match entity { + acadrust::EntityType::Circle(_) => true, + acadrust::EntityType::Ellipse(ellipse) => ellipse.is_full(), + acadrust::EntityType::LwPolyline(polyline) => { + polyline.is_closed + } + acadrust::EntityType::Polyline(polyline) => { + polyline.is_closed() + } + acadrust::EntityType::Polyline2D(polyline) => { + polyline.is_closed() + } + acadrust::EntityType::Polyline3D(polyline) => { + polyline.flags.closed + } + _ => false, + } + }); + if !valid { + self.command_line.push_error( + "MVIEW Object: select a closed paper-space circle, ellipse, or polyline.", + ); + if let Some(prompt) = + self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) + { + self.command_line.push_info(&prompt); + } + return Task::none(); + } + } + + let created_boundary = boundary.is_some(); + let touched = 2; + let label = self.history_label_from_active_cmd(i, "MVIEW"); + let pending = self.begin_undo(i, label, touched, false); + 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; + match self.commit_entity_handle(boundary) { + Some(handle) => handle, + None => { + self.tabs[i].active_cmd = None; + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + return Task::none(); + } + } + } + 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 { + self.command_line + .push_error("MVIEW: the clipping boundary has no usable area."); + self.tabs[i].active_cmd = None; + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + return Task::none(); + }; + if max_x - min_x < 1e-6 || max_y - min_y < 1e-6 { + self.command_line + .push_error("MVIEW: the clipping boundary has no usable area."); + self.tabs[i].active_cmd = None; + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + return Task::none(); + } + + let mut viewport = acadrust::entities::Viewport::new(); + viewport.center = acadrust::types::Vector3::new( + (min_x + max_x) / 2.0, + (min_y + max_y) / 2.0, + 0.0, + ); + viewport.width = max_x - min_x; + viewport.height = max_y - min_y; + viewport.id = 2; + viewport.clip_boundary_handle = clip_handle; + let viewport_handle = self.commit_entity_handle( + acadrust::EntityType::Viewport(viewport), + ); + if let Some(viewport_handle) = viewport_handle { + if !created_boundary { + let before = self.tabs[i] + .scene + .document + .get_entity(clip_handle) + .cloned() + .map(std::sync::Arc::new); + self.tabs[i] + .scene + .record_undo_before(clip_handle, before); + } + 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); + } + } + self.tabs[i].scene.bump_entities(&[( + clip_handle, + crate::scene::ChangeKind::Modified, + )]); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + } + CmdResult::MviewSwitchLayout(layout) => { + let task = self.on_layout_switch(layout); + if let Some(prompt) = + self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) + { + self.command_line.push_info(&prompt); + } + return task; + } + CmdResult::MviewCancelToLayout(layout) => { + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + return self.on_layout_switch(layout); + } CmdResult::TransformSelected(handles, transform) => { let label = self.history_label_from_active_cmd(i, "MOVE"); // A move/rotate/scale/mirror mutates only the selected entities diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 8402e5183..0ebe151d9 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -412,7 +412,14 @@ impl OpenCADStudio { .push_error("MVIEW: switch to a paper space layout first."); } else { use crate::modules::layout::mview::MviewCommand; - let new_cmd = MviewCommand::new(); + let scene = &self.tabs[i].scene; + let layout = scene.current_layout.clone(); + let paper_bounds = scene + .printable_area_limits() + .or_else(|| scene.paper_limits()) + .unwrap_or(((0.0, 0.0), (297.0, 210.0))); + let views = scene.document.views.iter().cloned().collect(); + let new_cmd = MviewCommand::new(layout, paper_bounds, views); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 99b81d9c8..e1c61a499 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3675,7 +3675,7 @@ impl OpenCADStudio { )) } - pub(super) fn on_layout_switch(&mut self, name: String) -> Task { + pub(crate) fn on_layout_switch(&mut self, name: String) -> Task { let i = self.active_tab; if self.tabs[i].is_start { self.command_line diff --git a/src/command.rs b/src/command.rs index 24b78fdc0..34017a537 100644 --- a/src/command.rs +++ b/src/command.rs @@ -906,6 +906,23 @@ pub enum CmdResult { }, /// Set the plot window on the active layout's PlotSettings. SetPlotWindow { p1: DVec3, p2: DVec3 }, + /// Create a paper-space viewport. `preserve_view` keeps an explicitly + /// selected/defined view instead of applying the normal model-extents fit. + MviewCreate { + viewport: acadrust::entities::Viewport, + preserve_view: bool, + }, + /// Create a viewport clipped by either a new polygon boundary or an + /// existing closed paper-space entity. + MviewCreateClipped { + boundary: Option, + boundary_handle: Handle, + }, + /// Temporarily switch between paper and Model while MVIEW defines a new + /// model-space window, keeping the command active. + MviewSwitchLayout(String), + /// Cancel MVIEW's temporary Model-space step and return to its layout. + MviewCancelToLayout(String), /// Quick-print the bounding box of the given selected entities to a PDF. QuickPrint(Vec), /// Replace the text content of a Text/MText entity in-place. diff --git a/src/entities/viewport.rs b/src/entities/viewport.rs index 5baf8d364..5660b925a 100644 --- a/src/entities/viewport.rs +++ b/src/entities/viewport.rs @@ -458,6 +458,12 @@ impl crate::entities::traits::FallbackTess for Viewport { fn fallback_geometry( &self, ) -> crate::scene::convert::tess_util::FallbackGeometry { + // A clipped viewport uses its linked boundary entity as its visible + // frame. Drawing the viewport's rectangular extents as well leaves an + // incorrect box around polygonal/Object MVIEW results. + if !self.clip_boundary_handle.is_null() { + return (vec![], vec![], vec![], vec![]); + } let cx = self.center.x; let cy = self.center.y; let cz = self.center.z; diff --git a/src/modules/draw/draw/polyline.rs b/src/modules/draw/draw/polyline.rs index e4da716e7..d60f3f460 100644 --- a/src/modules/draw/draw/polyline.rs +++ b/src/modules/draw/draw/polyline.rs @@ -138,7 +138,7 @@ impl PlineCommand { /// 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. -fn compute_bulge(a: DVec2, tangent: DVec2, b: DVec2) -> f64 { +pub(crate) fn compute_bulge(a: DVec2, tangent: DVec2, b: DVec2) -> f64 { let d = b - a; let len_sq = d.length_squared(); if len_sq < 1e-10 { @@ -178,7 +178,7 @@ fn compute_bulge(a: DVec2, tangent: DVec2, b: DVec2) -> f64 { /// the chord direction rotated by half the arc sweep (the chord bisects the /// entry/exit tangents of a bulge arc). Used to restore tangent continuity /// after Undo pops a segment. -fn seg_exit_tangent(a: DVec3, b: DVec3, bulge: f64) -> Option { +pub(crate) fn seg_exit_tangent(a: DVec3, b: DVec3, bulge: f64) -> Option { let d = DVec2::new(b.x - a.x, b.y - a.y); if d.length_squared() < 1e-10 { return None; @@ -189,7 +189,7 @@ fn seg_exit_tangent(a: DVec3, b: DVec3, bulge: f64) -> Option { } /// Update `tangent` after an arc segment described by `bulge` from `a` to `b`. -fn update_tangent_after_arc(tangent: &mut Option, bulge: f64) { +pub(crate) fn update_tangent_after_arc(tangent: &mut Option, bulge: f64) { let Some(t) = *tangent else { return; }; @@ -203,7 +203,7 @@ fn update_tangent_after_arc(tangent: &mut Option, bulge: f64) { /// Sample a circular arc defined by bulge into `n` line-segment points. /// Returns the sampled [x, y, z] points (uses `z` from `a`). -fn arc_sample_points(a: Vec3, bulge: f64, b: Vec3, n: usize) -> Vec<[f32; 3]> { +pub(crate) fn arc_sample_points(a: Vec3, bulge: f64, b: Vec3, n: usize) -> Vec<[f32; 3]> { let ax = a.x as f64; let ay = a.y as f64; let bx = b.x as f64; diff --git a/src/modules/layout/mview.rs b/src/modules/layout/mview.rs index e493b3212..bbecc1856 100644 --- a/src/modules/layout/mview.rs +++ b/src/modules/layout/mview.rs @@ -1,17 +1,17 @@ // MVIEW — interactive paper-space viewport creation. -// -// Two clicks define opposite corners of a new viewport rectangle. -// The created Viewport entity is routed to add_entity_to_layout by apply_cmd_result -// because we're in paper space. -use acadrust::entities::Viewport; -use acadrust::types::Vector3; -use acadrust::EntityType; +use acadrust::entities::{LwPolyline, LwVertex, Viewport}; +use acadrust::tables::View; +use acadrust::types::{Vector2, Vector3}; +use acadrust::{EntityType, Handle}; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdOption, CmdResult}; +use crate::modules::draw::draw::polyline::{ + arc_sample_points, compute_bulge, seg_exit_tangent, update_tangent_after_arc, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; -use glam::DVec3; +use glam::{DVec2, DVec3, Vec2}; // ── Ribbon definition ───────────────────────────────────────────────────── @@ -26,13 +26,237 @@ pub fn tool() -> ToolDef { // ── Command ─────────────────────────────────────────────────────────────── +#[derive(Clone, Copy, PartialEq)] +enum Step { + RectangleFirst, + RectangleSecond, + Polygon, + Object, + ChooseView, + DefineNewFirst, + DefineNewSecond, + PlaceView, +} + +#[derive(Clone, Copy, PartialEq)] +enum PolygonMode { + Line, + Arc, +} + pub struct MviewCommand { - corner1: Option, + step: Step, + first: Option, + polygon: Vec, + polygon_bulges: Vec, + polygon_mode: PolygonMode, + polygon_last_tangent: Option, + view: Option, + views: Vec, + paper_bounds: ((f64, f64), (f64, f64)), + original_layout: String, } impl MviewCommand { - pub fn new() -> Self { - Self { corner1: None } + pub fn new( + original_layout: String, + paper_bounds: ((f64, f64), (f64, f64)), + views: Vec, + ) -> Self { + Self { + step: Step::RectangleFirst, + first: None, + polygon: Vec::new(), + polygon_bulges: Vec::new(), + polygon_mode: PolygonMode::Line, + polygon_last_tangent: None, + view: None, + views, + paper_bounds, + original_layout, + } + } + + fn viewport_from_corners(a: DVec3, b: DVec3) -> Option { + let width = (b.x - a.x).abs(); + let height = (b.y - a.y).abs(); + if width < 1e-6 || height < 1e-6 { + return None; + } + let mut viewport = Viewport::new(); + viewport.center = Vector3::new( + (a.x + b.x) / 2.0, + (a.y + b.y) / 2.0, + a.z, + ); + viewport.width = width; + viewport.height = height; + viewport.id = 2; + Some(viewport) + } + + fn fit_viewport(&self) -> Option { + let ((x0, y0), (x1, y1)) = self.paper_bounds; + Self::viewport_from_corners( + DVec3::new(x0, y0, 0.0), + DVec3::new(x1, y1, 0.0), + ) + } + + fn placed_viewport(&self, center: DVec3) -> Option { + let view = self.view.as_ref()?; + let source_width = view.width.abs().max(1e-6); + let source_height = view.height.abs().max(1e-6); + let ((x0, y0), (x1, y1)) = self.paper_bounds; + let max_width = ((x1 - x0).abs() * 0.5).max(1e-6); + let max_height = ((y1 - y0).abs() * 0.5).max(1e-6); + let aspect = source_width / source_height; + let (width, height) = if max_width / max_height > aspect { + (max_height * aspect, max_height) + } else { + (max_width, max_width / aspect) + }; + + let mut viewport = Viewport::new(); + viewport.center = Vector3::new(center.x, center.y, center.z); + viewport.width = width; + viewport.height = height; + viewport.id = 2; + viewport.view_target = view.target.clone(); + viewport.view_direction = view.direction.clone(); + viewport.view_height = source_height; + viewport.custom_scale = height / source_height; + viewport.lens_length = view.lens_length; + viewport.twist_angle = view.twist_angle; + viewport.status.perspective = view.perspective; + Some(viewport) + } + + fn polygon_boundary(&self) -> Option { + if self.polygon.len() < 3 { + return None; + } + let mut polyline = LwPolyline::new(); + polyline.is_closed = true; + polyline.elevation = self.polygon[0].z; + polyline.vertices = self + .polygon + .iter() + .zip(self.polygon_bulges.iter()) + .map(|(point, bulge)| { + let mut vertex = LwVertex::new(Vector2::new(point.x, point.y)); + vertex.bulge = *bulge; + vertex + }) + .collect(); + Some(EntityType::LwPolyline(polyline)) + } + + fn finish_polygon(&self) -> CmdResult { + match self.polygon_boundary() { + Some(boundary) => CmdResult::MviewCreateClipped { + boundary: Some(boundary), + boundary_handle: Handle::NULL, + }, + None => CmdResult::Cancel, + } + } + + fn undo_polygon(&mut self) -> CmdResult { + self.polygon.pop(); + self.polygon_bulges.pop(); + let count = self.polygon.len(); + self.polygon_last_tangent = if count >= 2 { + seg_exit_tangent( + self.polygon[count - 2], + self.polygon[count - 1], + self.polygon_bulges[count - 2], + ) + } else { + None + }; + CmdResult::NeedPoint + } + + fn polygon_preview(&self, cursor: DVec3) -> Option { + let last = self.polygon.last()?.as_vec3(); + let mut points: Vec<[f32; 3]> = Vec::new(); + + for index in 0..self.polygon.len().saturating_sub(1) { + let start = self.polygon[index].as_vec3(); + let end = self.polygon[index + 1].as_vec3(); + let bulge = self.polygon_bulges[index]; + if bulge.abs() < 1e-10 { + if points.is_empty() { + points.push([start.x, start.y, start.z]); + } + points.push([end.x, end.y, end.z]); + } else { + let sampled = arc_sample_points(start, bulge, end, 16); + if points.is_empty() { + points.extend_from_slice(&sampled); + } else { + points.extend_from_slice(&sampled[1..]); + } + } + } + + let cursor = cursor.as_vec3(); + match self.polygon_mode { + PolygonMode::Line => { + if points.is_empty() { + points.push([last.x, last.y, last.z]); + } + points.push([cursor.x, cursor.y, cursor.z]); + } + PolygonMode::Arc => { + let tangent = self + .polygon_last_tangent + .map(|value| value.as_dvec2()) + .unwrap_or(DVec2::new(1.0, 0.0)); + let bulge = compute_bulge( + DVec2::new(last.x as f64, last.y as f64), + tangent, + DVec2::new(cursor.x as f64, cursor.y as f64), + ); + let sampled = arc_sample_points(last, bulge, cursor, 16); + if points.is_empty() { + points.extend_from_slice(&sampled); + } else { + points.extend_from_slice(&sampled[1..]); + } + } + } + + Some(WireModel::solid( + "mview_preview".to_string(), + points, + WireModel::CYAN, + false, + )) + } + + fn select_view(&mut self, name: &str) -> Option { + let view = self + .views + .iter() + .find(|view| view.name.eq_ignore_ascii_case(name.trim()))? + .clone(); + self.view = Some(view); + self.step = Step::PlaceView; + Some(CmdResult::NeedPoint) + } + + fn preview(points: Vec) -> Option { + if points.len() < 2 { + return None; + } + Some(WireModel::solid_f64( + "mview_preview".to_string(), + points.iter().map(|point| [point.x, point.y, point.z]).collect(), + WireModel::CYAN, + false, + )) } } @@ -42,77 +266,305 @@ impl CadCommand for MviewCommand { } fn prompt(&self) -> String { - if self.corner1.is_none() { - "MVIEW Specify first corner:".to_string() - } else { - "MVIEW Specify opposite corner:".to_string() + match self.step { + Step::RectangleFirst => { + "MVIEW Specify corner of viewport or [Polygonal/Object/Fit/Insert view]:".into() + } + Step::RectangleSecond => "MVIEW Specify opposite corner:".into(), + Step::Polygon if self.polygon.is_empty() => { + "MVIEW Polygonal Specify start point:".into() + } + Step::Polygon => { + let mode = match self.polygon_mode { + PolygonMode::Line => "Line", + PolygonMode::Arc => "Arc", + }; + format!( + "MVIEW Polygonal [{mode}] Specify next point or [Arc/Line/Close/Undo] ({} points):", + self.polygon.len() + ) + } + Step::Object => { + "MVIEW Object Select a circle, full ellipse, or closed polyline:".into() + } + Step::ChooseView if self.views.is_empty() => { + "MVIEW Insert view No named views; choose [New]:".into() + } + Step::ChooseView => "MVIEW Insert view Choose a named view or [New]:".into(), + Step::DefineNewFirst => "MVIEW New view Specify first model-space corner:".into(), + Step::DefineNewSecond => "MVIEW New view Specify opposite model-space corner:".into(), + Step::PlaceView => "MVIEW Insert view Specify placement point:".into(), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::RectangleFirst => vec![ + CmdOption::new("Polygonal", "POLYGONAL"), + CmdOption::new("Object", "OBJECT"), + CmdOption::new("Fit", "FIT"), + CmdOption::new("Insert view", "INSERT"), + ], + Step::Polygon if !self.polygon.is_empty() => vec![ + CmdOption::new("Arc", "ARC"), + CmdOption::new("Line", "LINE"), + CmdOption::new("Close", "CLOSE"), + CmdOption::new("Undo", "UNDO"), + CmdOption::enter("Done"), + ], + Step::ChooseView => { + let mut options = vec![CmdOption::new("New", "NEW")]; + options.extend( + self.views + .iter() + .map(|view| CmdOption::new(&view.name, &view.name)), + ); + options + } + _ => Vec::new(), } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - if let Some(c1) = self.corner1 { - let w = (pt.x - c1.x).abs(); - let h = (pt.y - c1.y).abs(); - if w < 1.0 || h < 1.0 { - return CmdResult::Cancel; + match self.step { + Step::RectangleFirst => { + self.first = Some(pt); + self.step = Step::RectangleSecond; + CmdResult::NeedPoint } - let cx = (c1.x + pt.x) / 2.0; - let cy = (c1.y + pt.y) / 2.0; - let cz = c1.z; + Step::RectangleSecond => match self + .first + .and_then(|first| Self::viewport_from_corners(first, pt)) + { + Some(viewport) => CmdResult::MviewCreate { + viewport, + preserve_view: false, + }, + None => CmdResult::NeedPoint, + }, + Step::Polygon => { + if let Some(last) = self.polygon.last().copied() { + let last_index = self.polygon.len() - 1; + let bulge = match self.polygon_mode { + PolygonMode::Line => { + let direction = DVec2::new(pt.x - last.x, pt.y - last.y); + if direction.length_squared() > 1e-10 { + self.polygon_last_tangent = + Some(direction.normalize().as_vec2()); + } + 0.0 + } + PolygonMode::Arc => { + let tangent = self + .polygon_last_tangent + .map(|value| value.as_dvec2()) + .unwrap_or(DVec2::new(1.0, 0.0)); + let bulge = compute_bulge( + DVec2::new(last.x, last.y), + tangent, + DVec2::new(pt.x, pt.y), + ); + update_tangent_after_arc( + &mut self.polygon_last_tangent, + bulge, + ); + bulge + } + }; + self.polygon_bulges[last_index] = bulge; + } - let mut vp = Viewport::new(); - vp.center = Vector3::new(cx, cy, cz); - vp.width = w; - vp.height = h; - vp.id = 2; // user viewport (id > 1) + if let Some(first) = self.polygon.first() { + let distance_squared = + (pt.x - first.x).powi(2) + (pt.y - first.y).powi(2); + if self.polygon.len() >= 3 && distance_squared < 1e-12 { + return self.finish_polygon(); + } + } - CmdResult::CommitAndExit(EntityType::Viewport(vp)) - } else { - self.corner1 = Some(pt); - CmdResult::NeedPoint + self.polygon.push(pt); + self.polygon_bulges.push(0.0); + CmdResult::NeedPoint + } + Step::DefineNewFirst => { + self.first = Some(pt); + self.step = Step::DefineNewSecond; + CmdResult::NeedPoint + } + Step::DefineNewSecond => { + let Some(first) = self.first else { + return CmdResult::NeedPoint; + }; + let width = (pt.x - first.x).abs(); + let height = (pt.y - first.y).abs(); + if width < 1e-6 || height < 1e-6 { + return CmdResult::NeedPoint; + } + let mut view = View::new(""); + view.width = width; + view.height = height; + view.target = Vector3::new( + (first.x + pt.x) / 2.0, + (first.y + pt.y) / 2.0, + (first.z + pt.z) / 2.0, + ); + self.view = Some(view); + self.step = Step::PlaceView; + CmdResult::MviewSwitchLayout(self.original_layout.clone()) + } + Step::PlaceView => match self.placed_viewport(pt) { + Some(viewport) => CmdResult::MviewCreate { + viewport, + preserve_view: true, + }, + None => CmdResult::Cancel, + }, + Step::Object | Step::ChooseView => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + Step::Polygon if self.polygon.len() >= 3 => self.finish_polygon(), + Step::DefineNewFirst | Step::DefineNewSecond => { + CmdResult::MviewCancelToLayout(self.original_layout.clone()) + } + _ => CmdResult::Cancel, + } + } + + fn on_escape(&mut self) -> CmdResult { + match self.step { + Step::DefineNewFirst | Step::DefineNewSecond => { + CmdResult::MviewCancelToLayout(self.original_layout.clone()) + } + _ => CmdResult::Cancel, + } + } + + fn needs_entity_pick(&self) -> bool { + self.step == Step::Object + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + CmdResult::MviewCreateClipped { + boundary: None, + boundary_handle: handle, + } + } + + fn wants_text_input(&self) -> bool { + self.step == Step::RectangleFirst + || self.step == Step::ChooseView + || (self.step == Step::Polygon && !self.polygon.is_empty()) + } + + fn wants_text_with_spaces(&self) -> bool { + self.step == Step::ChooseView + } + + fn point_step_accepts_keywords(&self) -> bool { + self.step == Step::RectangleFirst + || (self.step == Step::Polygon && !self.polygon.is_empty()) + } + + fn window_corner_pick(&self) -> bool { + matches!(self.step, Step::RectangleSecond | Step::DefineNewSecond) + } + + fn window_first_corner(&self) -> Option { + self.window_corner_pick().then_some(self.first).flatten() + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim(); + let upper = keyword.to_ascii_uppercase(); + match self.step { + Step::RectangleFirst => match upper.as_str() { + "P" | "POLYGONAL" => { + self.step = Step::Polygon; + Some(CmdResult::NeedPoint) + } + "O" | "OBJECT" => { + self.step = Step::Object; + Some(CmdResult::NeedPoint) + } + "F" | "FIT" => self.fit_viewport().map(|viewport| CmdResult::MviewCreate { + viewport, + preserve_view: false, + }), + "I" | "INSERT" | "INSERTVIEW" | "INSERT VIEW" => { + self.step = Step::ChooseView; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + Step::Polygon => match upper.as_str() { + "A" | "ARC" if !self.polygon.is_empty() => { + self.polygon_mode = PolygonMode::Arc; + Some(CmdResult::NeedPoint) + } + "L" | "LINE" if !self.polygon.is_empty() => { + self.polygon_mode = PolygonMode::Line; + Some(CmdResult::NeedPoint) + } + "C" | "CLOSE" if self.polygon.len() >= 3 => Some(self.finish_polygon()), + "U" | "UNDO" if !self.polygon.is_empty() => { + Some(self.undo_polygon()) + } + _ => None, + }, + Step::ChooseView => { + if matches!(upper.as_str(), "N" | "NEW") { + self.first = None; + self.step = Step::DefineNewFirst; + Some(CmdResult::MviewSwitchLayout("Model".to_string())) + } else { + self.select_view(keyword) + } + } + _ => None, + } + } + + fn on_undo_step(&mut self) -> Option { + if self.step == Step::Polygon && !self.polygon.is_empty() { + Some(self.undo_polygon()) + } else { + None + } } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let pt = pt.as_vec3(); - let c1 = self.corner1?; - Some(WireModel { - taper_widths: Vec::new(), - world_width: 0.0, - depth_override: None, - fill_is_3d: false, - pick_tris: Vec::new(), - pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), - name: "mview_preview".to_string(), - points: vec![ - [c1.x as f32, c1.y as f32, c1.z as f32], - [pt.x, c1.y as f32, c1.z as f32], - [pt.x, pt.y, c1.z as f32], - [c1.x as f32, pt.y, c1.z as f32], - [c1.x as f32, c1.y as f32, c1.z as f32], - ], - points_low: Vec::new(), - color: WireModel::CYAN, - selected: false, - pattern_length: 0.0, - pattern: [0.0; 8], - line_weight_px: 1.0, - snap_pts: vec![], - tangent_geoms: vec![], - aci: 0, - key_vertices: vec![], - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - fill_tris: vec![], - fill_tris_low: Vec::new(), - }) + match self.step { + Step::RectangleSecond => { + let first = self.first?; + Self::preview(vec![ + first, + DVec3::new(pt.x, first.y, first.z), + DVec3::new(pt.x, pt.y, first.z), + DVec3::new(first.x, pt.y, first.z), + first, + ]) + } + Step::Polygon => self.polygon_preview(pt), + Step::PlaceView => { + let viewport = self.placed_viewport(pt)?; + let half_width = viewport.width / 2.0; + let half_height = viewport.height / 2.0; + Self::preview(vec![ + DVec3::new(pt.x - half_width, pt.y - half_height, pt.z), + DVec3::new(pt.x + half_width, pt.y - half_height, pt.z), + DVec3::new(pt.x + half_width, pt.y + half_height, pt.z), + DVec3::new(pt.x - half_width, pt.y + half_height, pt.z), + DVec3::new(pt.x - half_width, pt.y - half_height, pt.z), + ]) + } + _ => None, + } } } diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 513c44477..7ad3bf40d 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -1386,8 +1386,36 @@ pub fn tessellate( } // ── Fallback for Viewport / Insert / Hatch / Ole2Frame ──────────────── - let (points_f64, snap_pts, tangent_geoms, key_vertices) = + let (mut points_f64, snap_pts, tangent_geoms, mut key_vertices) = fallback_geometry(entity); + let clipped_viewport_polygon = match entity { + EntityType::Viewport(viewport) if !viewport.clip_boundary_handle.is_null() => { + let polygon = crate::scene::project::clip_boundary_polygon_for_document( + document, + viewport.clip_boundary_handle, + viewport.center.z as f32, + ); + if polygon.len() >= 3 { + let polygon: Vec<[f64; 3]> = polygon + .into_iter() + .map(|point| { + [ + point[0] as f64, + point[1] as f64, + point[2] as f64, + ] + }) + .collect(); + points_f64 = polygon.clone(); + points_f64.push(polygon[0]); + key_vertices = polygon.clone(); + Some(polygon) + } else { + None + } + } + _ => None, + }; // `points_f64` are absolute world coords; split into the double-single // high/low pair so the outline reconstructs to f64 precision at UTM scale // (a NaN separator stays NaN in both buffers). @@ -1427,14 +1455,18 @@ pub fn tessellate( }; let (pick_tris, pick_tris_low) = match entity { EntityType::Viewport(vp) if !is_sheet_vp(vp) => { - let (cx, cy, cz) = (vp.center.x, vp.center.y, vp.center.z); - let (hw, hh) = (vp.width / 2.0, vp.height / 2.0); - points_to_ds(crate::entities::common::quad_pick_tris(&[ - [cx - hw, cy - hh, cz], - [cx + hw, cy - hh, cz], - [cx + hw, cy + hh, cz], - [cx - hw, cy + hh, cz], - ])) + if let Some(polygon) = clipped_viewport_polygon.as_ref() { + points_to_ds(crate::entities::mesh::triangulate_planar(polygon)) + } else { + let (cx, cy, cz) = (vp.center.x, vp.center.y, vp.center.z); + let (hw, hh) = (vp.width / 2.0, vp.height / 2.0); + points_to_ds(crate::entities::common::quad_pick_tris(&[ + [cx - hw, cy - hh, cz], + [cx + hw, cy - hh, cz], + [cx + hw, cy + hh, cz], + [cx - hw, cy + hh, cz], + ])) + } } _ => (Vec::new(), Vec::new()), }; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index de1c42edf..7053240a9 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2710,6 +2710,21 @@ impl Scene { self.current_layout_block_handle() } + /// True when an entity belongs to the active layout, including imported + /// DXF entities whose owner handle is NULL but whose BlockRecord still + /// lists the entity. Keep command validation aligned with the same + /// ownership fallback used by rendering and hit-testing. + pub(crate) fn entity_belongs_to_current_layout(&self, handle: Handle) -> bool { + let Some(entity) = self.document.get_entity(handle) else { + return false; + }; + self.belongs_to_visible_block( + handle, + entity.common().owner_handle, + self.current_layout_block_handle(), + ) + } + /// Returns the block-record handle for `current_layout`. /// /// Primary path: the Layout object's `block_record` field (set correctly @@ -3066,6 +3081,46 @@ impl Scene { .or(Some(((0.0, 0.0), (297.0, 210.0)))) } + /// Printable rectangle of the current paper layout, in paper-space units. + /// Falls back to the whole sheet when the layout has no usable margins. + pub fn printable_area_limits(&self) -> Option<((f64, f64), (f64, f64))> { + let ((x0, y0), (x1, y1)) = self.paper_limits()?; + let margins = self.document.objects.values().find_map(|object| { + if let ObjectType::Layout(layout) = object { + if layout.name == self.current_layout { + return Some(( + layout.plot_margin_left, + layout.plot_margin_bottom, + layout.plot_margin_right, + layout.plot_margin_top, + layout.plot_rotation, + )); + } + } + None + }); + let Some((left, bottom, right, top, rotation)) = margins else { + return Some(((x0, y0), (x1, y1))); + }; + let (left, bottom, right, top) = match rotation { + 1 | 3 => (bottom, left, top, right), + 2 => (right, top, left, bottom), + _ => (left, bottom, right, top), + }; + let factor = self.paper_space_unit_factor(); + let printable = ( + (x0 + left * factor, y0 + bottom * factor), + (x1 - right * factor, y1 - top * factor), + ); + if printable.1.0 - printable.0.0 < 1e-6 + || printable.1.1 - printable.0.1 < 1e-6 + { + Some(((x0, y0), (x1, y1))) + } else { + Some(printable) + } + } + /// Scale of the first user viewport (id > 1) in the current paper layout, /// used for the status-bar display. Returns `None` in Model space or if /// no user viewport exists. diff --git a/src/scene/project.rs b/src/scene/project.rs index 948c0e30e..645fdaa79 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -418,65 +418,180 @@ impl Scene { } /// Tessellated outline of a non-rectangular viewport / XCLIP clip-boundary - /// entity, in paper coordinates, by reusing the entity's own `to_truck` - /// tessellation (Lines). NaN segment breaks are dropped so the result is a - /// single ordered ring suitable for a stencil triangle-fan. - pub(super) fn clip_boundary_polygon(&self, handle: Handle, z: f32) -> Vec<[f32; 3]> { - use std::f64::consts::TAU; - let Some(entity) = self - .document - .entities() - .find(|e| e.common().handle == handle) - else { - return vec![]; - }; - // Circles and ellipses tessellate directly — their `to_truck` returns a - // parametric TruckObject (not `Lines`), so extracting a polygon there - // would come back empty. Everything else (splines, polylines, …) reuses - // the entity's own `Lines` tessellation. - const N: usize = 64; - match entity { - EntityType::Circle(c) => (0..N) + /// entity, in paper coordinates. Closed polylines are sampled directly + /// because their normal tessellation is a `Contour`, not `Lines`. + pub(crate) fn clip_boundary_polygon(&self, handle: Handle, z: f32) -> Vec<[f32; 3]> { + clip_boundary_polygon_for_document(&self.document, handle, z) + } +} + +pub(crate) fn clip_boundary_polygon_for_document( + document: &CadDocument, + handle: Handle, + z: f32, +) -> Vec<[f32; 3]> { + use std::f64::consts::TAU; + let Some(entity) = document + .entities() + .find(|e| e.common().handle == handle) + else { + return vec![]; + }; + // Circles and ellipses tessellate directly — their `to_truck` returns a + // parametric TruckObject (not `Lines`), so extracting a polygon there + // would come back empty. Everything else (splines, polylines, …) reuses + // the entity's own `Lines` tessellation. + const N: usize = 64; + match entity { + EntityType::Circle(c) => (0..N) + .map(|i| { + let a = i as f64 * TAU / N as f64; + [ + (c.center.x + a.cos() * c.radius) as f32, + (c.center.y + a.sin() * c.radius) as f32, + z, + ] + }) + .collect(), + EntityType::Ellipse(el) => { + // major_axis = center → major endpoint; minor = perp(major) × ratio. + let (mx, my) = (el.major_axis.x, el.major_axis.y); + let r = el.minor_axis_ratio; + (0..N) .map(|i| { - let a = i as f64 * TAU / N as f64; - [ - (c.center.x + a.cos() * c.radius) as f32, - (c.center.y + a.sin() * c.radius) as f32, - z, - ] + let t = i as f64 * TAU / N as f64; + let px = mx * t.cos() - my * r * t.sin(); + let py = my * t.cos() + mx * r * t.sin(); + [(el.center.x + px) as f32, (el.center.y + py) as f32, z] }) - .collect(), - EntityType::Ellipse(el) => { - // major_axis = center → major endpoint; minor = perp(major) × ratio. - let (mx, my) = (el.major_axis.x, el.major_axis.y); - let r = el.minor_axis_ratio; - (0..N) - .map(|i| { - let t = i as f64 * TAU / N as f64; - let px = mx * t.cos() - my * r * t.sin(); - let py = my * t.cos() + mx * r * t.sin(); - [(el.center.x + px) as f32, (el.center.y + py) as f32, z] - }) + .collect() + } + EntityType::LwPolyline(polyline) if polyline.is_closed => { + let vertices: Vec<([f64; 2], f64)> = polyline + .vertices + .iter() + .map(|vertex| { + ( + [vertex.location.x, vertex.location.y], + vertex.bulge, + ) + }) + .collect(); + sample_polyline_clip_boundary( + &vertices, + polyline.elevation, + ( + polyline.normal.x, + polyline.normal.y, + polyline.normal.z, + ), + z, + ) + } + EntityType::Polyline2D(polyline) if polyline.is_closed() => { + let vertices: Vec<([f64; 2], f64)> = polyline + .vertices + .iter() + .map(|vertex| { + ( + [vertex.location.x, vertex.location.y], + vertex.bulge, + ) + }) + .collect(); + sample_polyline_clip_boundary( + &vertices, + polyline.elevation, + ( + polyline.normal.x, + polyline.normal.y, + polyline.normal.z, + ), + z, + ) + } + EntityType::Polyline(polyline) if polyline.is_closed() => polyline + .vertices + .iter() + .map(|vertex| { + [ + vertex.location.x as f32, + vertex.location.y as f32, + z, + ] + }) + .collect(), + EntityType::Polyline3D(polyline) if polyline.flags.closed => polyline + .vertices + .iter() + .map(|vertex| { + [ + vertex.position.x as f32, + vertex.position.y as f32, + z, + ] + }) + .collect(), + _ => { + use crate::entities::traits::EntityTypeOps; + let Some(te) = entity.to_truck_entity(document) else { + return vec![]; + }; + if let crate::scene::convert::acad_to_truck::TruckObject::Lines(pts) = te.object { + pts.into_iter() + .filter(|p| p[0].is_finite() && p[1].is_finite()) + .map(|p| [p[0] as f32, p[1] as f32, z]) .collect() - } - _ => { - use crate::entities::traits::EntityTypeOps; - let Some(te) = entity.to_truck_entity(&self.document) else { - return vec![]; - }; - if let crate::scene::convert::acad_to_truck::TruckObject::Lines(pts) = te.object { - pts.into_iter() - .filter(|p| p[0].is_finite() && p[1].is_finite()) - .map(|p| [p[0] as f32, p[1] as f32, z]) - .collect() - } else { - vec![] - } + } else { + vec![] } } } } +fn sample_polyline_clip_boundary( + vertices: &[([f64; 2], f64)], + elevation: f64, + normal: (f64, f64, f64), + z: f32, +) -> Vec<[f32; 3]> { + if vertices.len() < 3 { + return Vec::new(); + } + let to_wcs = |point: [f64; 2]| { + crate::scene::view::transform::ocs_point_to_wcs( + (point[0], point[1], elevation), + normal, + ) + }; + let mut output = Vec::new(); + let first = to_wcs(vertices[0].0); + output.push([first.0 as f32, first.1 as f32, z]); + for index in 0..vertices.len() { + let (start, bulge) = vertices[index]; + let end_index = (index + 1) % vertices.len(); + let end = vertices[end_index].0; + if let Some(arc) = + crate::entities::common::BulgeArc::from_bulge(start, end, bulge) + { + let steps = ((arc.sweep.abs() / std::f64::consts::TAU * 64.0).ceil() + as usize) + .clamp(4, 64); + for step in 1..=steps { + if end_index == 0 && step == steps { + break; + } + let point = to_wcs(arc.sample(step as f64 / steps as f64)); + output.push([point.0 as f32, point.1 as f32, z]); + } + } else if end_index != 0 { + let point = to_wcs(end); + output.push([point.0 as f32, point.1 as f32, z]); + } + } + output +} + // ── Paper boundary wire ──────────────────────────────────────────────────── // ── Cohen-Sutherland line clipping ───────────────────────────────────────