// MVIEW — interactive paper-space viewport creation. use acadrust::entities::{LwPolyline, LwVertex, Viewport}; use acadrust::tables::View; use acadrust::types::{Vector2, Vector3}; use acadrust::{EntityType, Handle}; use crate::t; 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::{DVec2, DVec3, Vec2}; // ── Ribbon definition ───────────────────────────────────────────────────── pub fn tool() -> ToolDef { ToolDef { id: "MVIEW", label: "Viewport", icon: IconKind::Svg(include_bytes!("../../../assets/icons/viewport.svg")), event: ModuleEvent::Command("MVIEW".to_string()), } } // ── 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 { 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( 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, )) } } impl CadCommand for MviewCommand { fn name(&self) -> &'static str { "MVIEW" } fn prompt(&self) -> String { match self.step { Step::RectangleFirst => t!( "MVIEW Specify corner of viewport or [Polygonal/Object/Fit/Insert view]:" ) .into_owned(), Step::RectangleSecond => t!("MVIEW Specify opposite corner:").into_owned(), Step::Polygon if self.polygon.is_empty() => { t!("MVIEW Polygonal Specify start point:").into_owned() } Step::Polygon => { let mode = match self.polygon_mode { PolygonMode::Line => t!("Line"), PolygonMode::Arc => t!("Arc"), }; t!( "MVIEW Polygonal [%{mode}] Specify next point or [Arc/Line/Close/Undo] (%{count} points):", mode = mode, count = self.polygon.len() ) .into_owned() } Step::Object => { t!("MVIEW Object Select a circle, full ellipse, or closed polyline:").into_owned() } Step::ChooseView if self.views.is_empty() => { t!("MVIEW Insert view No named views; choose [New]:").into_owned() } Step::ChooseView => t!("MVIEW Insert view Choose a named view or [New]:").into_owned(), Step::DefineNewFirst => { t!("MVIEW New view Specify first model-space corner:").into_owned() } Step::DefineNewSecond => { t!("MVIEW New view Specify opposite model-space corner:").into_owned() } Step::PlaceView => t!("MVIEW Insert view Specify placement point:").into_owned(), } } fn options(&self) -> Vec { match self.step { Step::RectangleFirst => vec![ CmdOption::new(t!("Polygonal").as_ref(), "POLYGONAL"), CmdOption::new(t!("Object").as_ref(), "OBJECT"), CmdOption::new(t!("Fit").as_ref(), "FIT"), CmdOption::new(t!("Insert view").as_ref(), "INSERT"), ], Step::Polygon if !self.polygon.is_empty() => vec![ CmdOption::new(t!("Arc").as_ref(), "ARC"), CmdOption::new(t!("Line").as_ref(), "LINE"), CmdOption::new(t!("Close").as_ref(), "CLOSE"), CmdOption::new(t!("Undo").as_ref(), "UNDO"), CmdOption::enter(t!("Done").as_ref()), ], Step::ChooseView => { let mut options = vec![CmdOption::new(t!("New").as_ref(), "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 { match self.step { Step::RectangleFirst => { self.first = Some(pt); self.step = Step::RectangleSecond; CmdResult::NeedPoint } 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; } 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(); } } 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 { 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 { 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, } } } // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["MVIEW"] }); // MviewCommand