forked from 1b-agent/opencadstudio-upstream
582 lines
20 KiB
Rust
582 lines
20 KiB
Rust
// 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<DVec3>,
|
|
polygon: Vec<DVec3>,
|
|
polygon_bulges: Vec<f64>,
|
|
polygon_mode: PolygonMode,
|
|
polygon_last_tangent: Option<Vec2>,
|
|
view: Option<View>,
|
|
views: Vec<View>,
|
|
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<View>,
|
|
) -> 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<Viewport> {
|
|
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<Viewport> {
|
|
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<Viewport> {
|
|
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<EntityType> {
|
|
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<WireModel> {
|
|
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<CmdResult> {
|
|
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<DVec3>) -> Option<WireModel> {
|
|
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<CmdOption> {
|
|
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<DVec3> {
|
|
self.window_corner_pick().then_some(self.first).flatten()
|
|
}
|
|
|
|
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
|
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<CmdResult> {
|
|
if self.step == Step::Polygon && !self.polygon.is_empty() {
|
|
Some(self.undo_polygon())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
|
|
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
|