From 73b224c1918f3b2a1d3f1c353d3a8f00265a7169 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 31 Mar 2026 23:52:18 +0300 Subject: [PATCH] Feat: viewport display lock, extended properties, VPLAYER and VPORTS commands - Enforce status.locked in pan_active_viewport and zoom_active_viewport: locked viewports now reject pan/zoom inputs entirely - Add perspective, render_mode, shade_plot_mode, hide_plot, ucs_icon_visible fields to the viewport property panel with appropriate pickers/toggles - Add VPORTS command: lists all user viewports in the current layout (id, size, position, scale, on/locked state) in the command line - Add VPLAYER command: per-viewport layer freeze/thaw from the command line (F to freeze, T to thaw, Enter to exit) - Add CmdResult::VpLayerUpdate variant and its handler in apply_cmd_result Co-Authored-By: Claude Sonnet 4.6 --- src/app/cmd_result.rs | 53 +++++++++++++++++++ src/app/commands.rs | 69 +++++++++++++++++++++++++ src/command/mod.rs | 6 +++ src/entities/viewport.rs | 96 ++++++++++++++++++++++++++++++++++- src/modules/layout/mod.rs | 1 + src/modules/layout/vplayer.rs | 85 +++++++++++++++++++++++++++++++ src/scene/mod.rs | 2 + 7 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 src/modules/layout/vplayer.rs diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index 9ed62c502..a840f0815 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -285,6 +285,59 @@ impl H7CAD { self.command_line.push_info("No groups found for selected objects."); } } + CmdResult::VpLayerUpdate { vp_handle, freeze, thaw } => { + // Resolve layer names → handles, then update frozen_layers on the viewport. + let freeze_handles: Vec = freeze.iter() + .filter_map(|name| { + self.tabs[i].scene.document.layers.iter() + .find(|l| l.name.eq_ignore_ascii_case(name)) + .map(|l| l.handle) + }) + .collect(); + let thaw_handles: Vec = thaw.iter() + .filter_map(|name| { + self.tabs[i].scene.document.layers.iter() + .find(|l| l.name.eq_ignore_ascii_case(name)) + .map(|l| l.handle) + }) + .collect(); + + let mut frozen_count = 0usize; + let mut thawed_count = 0usize; + + if let Some(acadrust::EntityType::Viewport(vp)) = + self.tabs[i].scene.document.get_entity_mut(vp_handle) + { + for h in &freeze_handles { + if !vp.frozen_layers.contains(h) { + vp.frozen_layers.push(*h); + frozen_count += 1; + } + } + for h in &thaw_handles { + let before = vp.frozen_layers.len(); + vp.frozen_layers.retain(|fh| fh != h); + if vp.frozen_layers.len() < before { thawed_count += 1; } + } + } + + if frozen_count > 0 || thawed_count > 0 { + self.push_undo_snapshot(i, "VPLAYER"); + self.tabs[i].dirty = true; + if frozen_count > 0 { + self.command_line.push_info(&format!("VPLAYER: {frozen_count} layer(s) frozen in viewport.")); + } + if thawed_count > 0 { + self.command_line.push_info(&format!("VPLAYER: {thawed_count} layer(s) thawed in viewport.")); + } + } + + // Show updated prompt (command stays active for more operations). + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + } } // Focus the command-line input while a command is active; blur it when the command ends. if self.tabs[i].active_cmd.is_some() { diff --git a/src/app/commands.rs b/src/app/commands.rs index 90fb8fd77..a8905d98b 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -909,6 +909,75 @@ impl H7CAD { return Task::done(Message::PspaceCommand); } + // ── VPORTS — list viewports in current layout ───────────────── + "VPORTS" => { + let scene = &self.tabs[i].scene; + if scene.current_layout == "Model" { + self.command_line.push_error("VPORTS: switch to a paper space layout first."); + } else { + let layout_block = scene.current_layout_block_handle_pub(); + let viewports: Vec<_> = scene.document.entities() + .filter_map(|e| { + if let acadrust::EntityType::Viewport(vp) = e { + if vp.id > 1 && vp.common.owner_handle == layout_block { + Some((vp.id, vp.center.clone(), vp.width, vp.height, vp.custom_scale, vp.status.is_on, vp.status.locked)) + } else { None } + } else { None } + }) + .collect(); + if viewports.is_empty() { + self.command_line.push_info("No viewports in current layout. Use MVIEW to create one."); + } else { + self.command_line.push_output(&format!("{} viewport(s) in layout \"{}\":", viewports.len(), scene.current_layout)); + for (id, center, w, h, scale, is_on, locked) in &viewports { + let state = match (is_on, locked) { + (true, true) => "On, Locked", + (true, false) => "On", + (false, _) => "Off", + }; + self.command_line.push_output(&format!( + " VP #{id}: {w:.1}×{h:.1} @ ({:.1},{:.1}) scale={scale:.4} [{state}]", + center.x, center.y + )); + } + } + } + } + + // ── VPLAYER — per-viewport layer freeze/thaw ────────────────── + "VPLAYER" => { + let scene = &self.tabs[i].scene; + if scene.current_layout == "Model" { + self.command_line.push_error("VPLAYER: switch to a paper space layout first."); + } else if scene.active_viewport.is_none() { + self.command_line.push_error("VPLAYER: enter a viewport first (double-click or MS)."); + } else { + use crate::modules::layout::vplayer::VplayerCommand; + let vp_handle = scene.active_viewport.unwrap(); + // Collect current frozen layer names for display. + let frozen_names: Vec = { + if let Some(acadrust::EntityType::Viewport(vp)) = + scene.document.get_entity(vp_handle) + { + vp.frozen_layers.iter().filter_map(|h| { + scene.document.layers.iter().find(|l| l.handle == *h).map(|l| l.name.clone()) + }).collect() + } else { vec![] } + }; + if frozen_names.is_empty() { + self.command_line.push_info("VPLAYER: no frozen layers in active viewport."); + } else { + self.command_line.push_info(&format!( + "VPLAYER: frozen layers: {}", + frozen_names.join(", ") + )); + } + let new_cmd = VplayerCommand::new(vp_handle); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + } + // ── Plot / Page Setup ────────────────────────────────────────── "PRINT"|"PLOT"|"EXPORT" => { return Task::done(Message::PlotExport); diff --git a/src/command/mod.rs b/src/command/mod.rs index 3a7b0b051..fb5aeca24 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -83,6 +83,12 @@ pub enum CmdResult { CreateGroup { handles: Vec, name: String }, /// Dissolve all groups that contain any of the given handles; end command. DeleteGroups { handles: Vec }, + /// Freeze or thaw layers by name in the given viewport; command stays active. + VpLayerUpdate { + vp_handle: Handle, + freeze: Vec, + thaw: Vec, + }, /// Paste clipboard entities translated so their centroid lands at `base_pt`; end command. PasteClipboard { base_pt: Vec3 }, } diff --git a/src/entities/viewport.rs b/src/entities/viewport.rs index 3d91fb447..be325d03f 100644 --- a/src/entities/viewport.rs +++ b/src/entities/viewport.rs @@ -1,4 +1,4 @@ -use acadrust::entities::Viewport; +use acadrust::entities::{Viewport, ViewportRenderMode}; use glam::Vec3; use crate::command::EntityTransform; @@ -32,6 +32,33 @@ fn scale_label(scale: f64) -> String { format!("{:.6}", scale).trim_end_matches('0').trim_end_matches('.').to_string() } +// ── Render mode options ─────────────────────────────────────────────────── + +const RENDER_MODES: &[(&str, ViewportRenderMode)] = &[ + ("2D Wireframe", ViewportRenderMode::Wireframe2D), + ("3D Wireframe", ViewportRenderMode::Wireframe3D), + ("Hidden Line", ViewportRenderMode::HiddenLine), + ("Flat Shaded", ViewportRenderMode::FlatShaded), + ("Gouraud Shaded", ViewportRenderMode::GouraudShaded), + ("Flat Shaded + Edges", ViewportRenderMode::FlatShadedWithEdges), + ("Gouraud Shaded + Edges", ViewportRenderMode::GouraudShadedWithEdges), +]; + +fn render_mode_label(mode: &ViewportRenderMode) -> &'static str { + for (label, m) in RENDER_MODES { + if m == mode { return label; } + } + "2D Wireframe" +} + +// ── Shade plot mode labels ──────────────────────────────────────────────── + +const SHADE_PLOT_LABELS: &[&str] = &["As Displayed", "Wireframe", "Hidden", "Rendered"]; + +fn shade_plot_label(mode: i16) -> &'static str { + SHADE_PLOT_LABELS.get(mode as usize).copied().unwrap_or("As Displayed") +} + // ── Standard view options ───────────────────────────────────────────────── const STD_VIEWS: &[&str] = &[ @@ -61,6 +88,12 @@ fn properties(vp: &Viewport) -> PropSection { let view_opts: Vec = STD_VIEWS.iter().map(|s| s.to_string()).collect(); let current_view = viewport_view_label(vp); + let render_opts: Vec = RENDER_MODES.iter().map(|(s, _)| s.to_string()).collect(); + let current_render = render_mode_label(&vp.render_mode).to_string(); + + let shade_opts: Vec = SHADE_PLOT_LABELS.iter().map(|s| s.to_string()).collect(); + let current_shade = shade_plot_label(vp.shade_plot_mode).to_string(); + PropSection { title: "Geometry".into(), props: vec![ @@ -89,6 +122,24 @@ fn properties(vp: &Viewport) -> PropSection { options: view_opts, }, }, + // Render mode picker. + Property { + label: "Render Mode".into(), + field: "vp_render", + value: PropValue::Choice { + selected: current_render, + options: render_opts, + }, + }, + // Shade plot mode picker. + Property { + label: "Shade Plot".into(), + field: "vp_shade_plot", + value: PropValue::Choice { + selected: current_shade, + options: shade_opts, + }, + }, // Display state toggles. Property { label: "Locked".into(), @@ -100,6 +151,21 @@ fn properties(vp: &Viewport) -> PropSection { field: "vp_on", value: PropValue::BoolToggle { field: "vp_on", value: vp.status.is_on }, }, + Property { + label: "Perspective".into(), + field: "vp_perspective", + value: PropValue::BoolToggle { field: "vp_perspective", value: vp.status.perspective }, + }, + Property { + label: "Hide Plot".into(), + field: "vp_hide_plot", + value: PropValue::BoolToggle { field: "vp_hide_plot", value: vp.status.hide_plot }, + }, + Property { + label: "UCS Icon".into(), + field: "vp_ucs_icon", + value: PropValue::BoolToggle { field: "vp_ucs_icon", value: vp.ucs_icon_visible }, + }, edit("Target X", "vtgt_x", vp.view_target.x), edit("Target Z", "vtgt_z", vp.view_target.z), ], @@ -140,6 +206,18 @@ fn apply_geom_prop(vp: &mut Viewport, field: &str, value: &str) { vp.status.is_on = if value == "toggle" { !vp.status.is_on } else { value == "true" }; return; } + "vp_perspective" => { + vp.status.perspective = if value == "toggle" { !vp.status.perspective } else { value == "true" }; + return; + } + "vp_hide_plot" => { + vp.status.hide_plot = if value == "toggle" { !vp.status.hide_plot } else { value == "true" }; + return; + } + "vp_ucs_icon" => { + vp.ucs_icon_visible = if value == "toggle" { !vp.ucs_icon_visible } else { value == "true" }; + return; + } _ => {} } @@ -154,6 +232,22 @@ fn apply_geom_prop(vp: &mut Viewport, field: &str, value: &str) { return; } + // Render mode picker. + if field == "vp_render" { + if let Some(&(_, mode)) = RENDER_MODES.iter().find(|(label, _)| *label == value) { + vp.render_mode = mode; + } + return; + } + + // Shade plot mode picker. + if field == "vp_shade_plot" { + if let Some(idx) = SHADE_PLOT_LABELS.iter().position(|&s| s == value) { + vp.shade_plot_mode = idx as i16; + } + return; + } + // Standard view direction picker. if field == "vp_view" { let dir: Option<(f64, f64, f64)> = match value { diff --git a/src/modules/layout/mod.rs b/src/modules/layout/mod.rs index 753f0a9cd..3acda001b 100644 --- a/src/modules/layout/mod.rs +++ b/src/modules/layout/mod.rs @@ -2,6 +2,7 @@ // This tab is only shown when the active layout is not "Model". pub mod mview; +pub mod vplayer; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, ToolDef}; diff --git a/src/modules/layout/vplayer.rs b/src/modules/layout/vplayer.rs new file mode 100644 index 000000000..e005f836c --- /dev/null +++ b/src/modules/layout/vplayer.rs @@ -0,0 +1,85 @@ +// VPLAYER — per-viewport layer freeze/thaw command. +// +// Usage (command line): +// VPLAYER +// > F → freeze layer in active viewport +// > T → thaw layer in active viewport +// > Enter → exit +// +// Layer names are case-insensitive. Multiple space-separated names are accepted. + +use acadrust::Handle; +use glam::Vec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::scene::wire_model::WireModel; + +pub struct VplayerCommand { + vp_handle: Handle, +} + +impl VplayerCommand { + pub fn new(vp_handle: Handle) -> Self { + Self { vp_handle } + } +} + +impl CadCommand for VplayerCommand { + fn name(&self) -> &'static str { + "VPLAYER" + } + + fn prompt(&self) -> String { + "VPLAYER F = Freeze | T = Thaw | Enter = Exit".to_string() + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let text = text.trim(); + if text.is_empty() { + return Some(CmdResult::Cancel); + } + + let mut parts = text.splitn(2, char::is_whitespace); + let op = parts.next().unwrap_or("").to_uppercase(); + let rest = parts.next().unwrap_or("").trim(); + + let layer_names: Vec = rest + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + + if layer_names.is_empty() { + return None; // no layer name given — ignore and re-prompt + } + + match op.as_str() { + "F" | "FREEZE" => Some(CmdResult::VpLayerUpdate { + vp_handle: self.vp_handle, + freeze: layer_names, + thaw: vec![], + }), + "T" | "THAW" => Some(CmdResult::VpLayerUpdate { + vp_handle: self.vp_handle, + freeze: vec![], + thaw: layer_names, + }), + _ => None, // unknown op — ignore + } + } + + fn on_point(&mut self, _pt: Vec3) -> CmdResult { + CmdResult::Cancel + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_mouse_move(&mut self, _pt: Vec3) -> Option { + None + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 1b8467dfd..1050b1616 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -767,6 +767,7 @@ impl Scene { if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) { + if vp.status.locked { return; } let scale = if vp.custom_scale.abs() > 1e-9 { vp.custom_scale } else if vp.view_height.abs() > 1e-9 { @@ -796,6 +797,7 @@ impl Scene { if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) { + if vp.status.locked { return; } // Zoom in = shrink view_height → higher scale → objects appear larger. let factor = (1.0_f64 - 0.15 * steps as f64).clamp(0.1, 10.0);