diff --git a/src/app/document.rs b/src/app/document.rs index 6f2ce0086..322b72c31 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -71,6 +71,8 @@ pub(super) struct DocumentTab { pub(super) active_grip: Option, pub(super) selected_grips: Vec, pub(super) selected_handle: Option, + /// Dynamic-block visibility grip for the current single selection. + pub(super) visibility_grip: Option, pub(super) wireframe: bool, pub(super) render_mode: acadrust::entities::ViewportRenderMode, pub(super) visual_style: String, @@ -134,6 +136,7 @@ impl DocumentTab { active_grip: None, selected_grips: vec![], selected_handle: None, + visibility_grip: None, wireframe: false, render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D, visual_style: "Wireframe 2D".into(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 9a5d6886b..f68b0eb14 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -14,6 +14,7 @@ mod style_ops; mod text_inline; mod update; mod view; +mod visibility; pub use style_ops::StyleKind; @@ -240,6 +241,8 @@ pub(super) struct OpenCADStudio { /// until dismissed (click outside, ESC, cursor leaves the grip). grip_popup: Option, grip_pending: Option, + /// Open dynamic-block visibility-state dropdown. + visibility_popup: Option, /// A leader line just added via the "Add Leader" grip menu whose arrow is /// being placed (follows the cursor). `(entity handle, new-arrow grip id)`. /// Esc before the placement click removes it again. @@ -748,6 +751,9 @@ pub enum Message { /// User picked an item in the multi-functional grip popup menu — /// the index is into `grip_popup.items`. GripMenuPick(usize), + /// User picked a dynamic-block visibility state — index into the + /// visibility dropdown's items. + VisibilityPick(usize), /// Timer pulse while the cursor is dwelling on a grip; drives the /// dwell-to-popup transition without requiring further mouse motion. GripDwellTick, @@ -1324,6 +1330,7 @@ impl OpenCADStudio { grip_hover: None, grip_popup: None, grip_pending: None, + visibility_popup: None, grip_add_provisional: None, grip_preview_handle: None, grip_original: None, diff --git a/src/app/properties.rs b/src/app/properties.rs index b90424c64..a393afed0 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -453,6 +453,9 @@ impl OpenCADStudio { }; self.tabs[i].selected_handle = new_handle; self.tabs[i].selected_grips = new_grips; + // Append the dynamic-block visibility (lookup) grip, if the lone + // selection is a visibility-parametric block reference. + self.refresh_visibility_grip(wo); } pub(super) fn property_target_handles(&self, i: usize) -> Vec { diff --git a/src/app/update.rs b/src/app/update.rs index 84072f9c7..17356b946 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1391,6 +1391,9 @@ impl OpenCADStudio { self.grip_hover = None; return Task::none(); } + if self.visibility_popup.take().is_some() { + return Task::none(); + } if self.grip_pending.take().is_some() { self.command_line.input.clear(); return Task::none(); @@ -2691,6 +2694,11 @@ impl OpenCADStudio { self.grip_hover = None; return Task::none(); } + // Outside-click dismiss for the visibility-state dropdown + // (its buttons sit above this mouse_area). + if self.visibility_popup.take().is_some() { + return Task::none(); + } // Same dismiss-on-outside-click for the right-click // context menu: its panel is opaque, so a press that // reaches here is outside the menu. @@ -2805,6 +2813,14 @@ impl OpenCADStudio { find_hit_grip(p, &self.tabs[i].selected_grips, vp_mat, bounds) }; if let Some((grip_id, is_translate, world)) = grip_hit { + // The visibility (lookup) grip opens a state + // dropdown instead of starting a stretch drag. + if grip_id == super::visibility::VIS_GRIP_ID { + self.open_visibility_popup(p); + self.grip_hover = None; + self.grip_popup = None; + return Task::none(); + } self.tabs[i].active_grip = Some(GripEdit { handle, grip_id, @@ -3803,6 +3819,13 @@ impl OpenCADStudio { Task::none() } + Message::VisibilityPick(idx) => { + if let Some(popup) = self.visibility_popup.take() { + self.apply_visibility_state(popup.insert_handle, idx); + } + Task::none() + } + Message::GripMenuPick(idx) => { let i = self.active_tab; let Some(popup) = self.grip_popup.take() else { @@ -7359,8 +7382,11 @@ impl OpenCADStudio { } else if let Some(h) = self.grip_hover.as_mut() { h.screen = p; } - // Open popup once dwell crosses the threshold. + // Open popup once dwell crosses the threshold. The visibility + // grip has its own click-to-open dropdown, so it gets no + // hover grip-menu. if self.grip_popup.is_none() + && grip_id != super::visibility::VIS_GRIP_ID && self .grip_hover .as_ref() diff --git a/src/app/view.rs b/src/app/view.rs index c134a6337..b4642a472 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1052,6 +1052,73 @@ impl OpenCADStudio { } } + // Dynamic-block visibility-state dropdown. + if let Some(popup) = self.visibility_popup.as_ref() { + if !tab.is_start { + let max_len = popup + .items + .iter() + .map(|s| s.chars().count()) + .max() + .unwrap_or(4) as f32; + // +2 chars for the leading "✓ " / " " marker column. + let row_w = (max_len + 2.0) * 7.0 + 24.0; + let mut col = column![].spacing(0).width(iced::Length::Fixed(row_w)); + for (idx, name) in popup.items.iter().enumerate() { + let is_cur = popup.current == Some(idx); + let label = format!("{} {}", if is_cur { "✓" } else { " " }, name); + let btn = button(text(label).size(12).color(Color::WHITE)) + .on_press(Message::VisibilityPick(idx)) + .padding([3, 10]) + .width(Fill) + .style(move |_: &Theme, status| iced::widget::button::Style { + background: Some(Background::Color(match status { + iced::widget::button::Status::Hovered => Color { + r: 0.20, + g: 0.45, + b: 0.95, + a: 1.0, + }, + _ => Color::TRANSPARENT, + })), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 0.0.into(), + }, + text_color: Color::WHITE, + ..Default::default() + }); + col = col.push(btn); + } + let panel = container(iced::widget::scrollable(col).height(iced::Length::Shrink)) + .max_height(360.0) + .padding(2) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color { + r: 0.10, + g: 0.10, + b: 0.10, + a: 0.95, + })), + border: Border { + color: Color { + r: 0.40, + g: 0.40, + b: 0.40, + a: 1.0, + }, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }); + let anchor = iced::Point::new(popup.anchor.x + 12.0, popup.anchor.y + 12.0); + viewport_stack = + viewport_stack.push(position_canvas_overlay(anchor, panel.into())); + } + } + // Quick Properties: compact floating property panel on selection, // anchored at the canvas top-left so it doesn't track the cursor. if self.quick_properties && !tab.is_start { diff --git a/src/app/visibility.rs b/src/app/visibility.rs new file mode 100644 index 000000000..4cc5ff026 --- /dev/null +++ b/src/app/visibility.rs @@ -0,0 +1,214 @@ +//! Dynamic-block visibility-state UI. +//! +//! A dynamic block with a visibility parameter shows a small "lookup" grip; +//! clicking it opens a dropdown of named states. Picking one toggles which of +//! the inserted (anonymous) block's member entities are visible. +//! +//! This is kept separate from the static `GripMenuAction` grip-menu system: +//! the state list is data-driven (owned strings from the file) and the apply +//! step mutates *other* entities (the anonymous block's members), which the +//! per-entity `apply_grip_menu` path can't reach. + +use std::collections::HashSet; + +use acadrust::objects::BlockVisibilityParameter; +use acadrust::types::Vector3; +use acadrust::{EntityType, Handle}; + +use crate::scene::object::{GripDef, GripShape}; + +use super::OpenCADStudio; + +/// Sentinel grip id for the visibility (lookup) grip, distinct from any +/// entity's own grip ids so the click handler routes it to the dropdown. +pub(super) const VIS_GRIP_ID: usize = usize::MAX; + +/// Resolved visibility info for the active tab's selected dynamic-block INSERT. +#[derive(Clone, Debug)] +pub(super) struct VisibilityGrip { + pub insert_handle: Handle, + pub state_names: Vec, + pub current: Option, +} + +/// The open visibility-state dropdown. +#[derive(Clone, Debug)] +pub struct VisibilityPopup { + pub insert_handle: Handle, + pub anchor: iced::Point, + pub items: Vec, + pub current: Option, +} + +/// Block-local entity-handle lists for a dynamic definition and the anonymous +/// block actually inserted, plus the state's visible-by-index set. +struct StateMapping { + /// Anonymous (inserted) block member handles, in definition order. + anon_handles: Vec, + /// Indices (into `anon_handles`) that the state makes visible. + visible_idx: HashSet, +} + +impl OpenCADStudio { + /// Compute the index sets needed to apply `state` of `param` (governing the + /// dynamic definition `def_block`) to the anonymous block `anon_name`. + /// + /// The anonymous block is an evaluated clone of the definition with a + /// parallel member order, so a member visible in the definition maps to the + /// same position in the anonymous block. + fn state_mapping( + doc: &acadrust::CadDocument, + def_block: Handle, + anon_name: &str, + state_idx: usize, + param: &BlockVisibilityParameter, + ) -> Option { + let def_handles: Vec = doc + .block_records + .iter() + .find(|b| b.handle == def_block)? + .entity_handles + .clone(); + let anon_handles: Vec = doc + .block_records + .iter() + .find(|b| b.name == anon_name)? + .entity_handles + .clone(); + let state = param.states.get(state_idx)?; + let visible: HashSet = state.visible_blocks.iter().map(|h| h.value()).collect(); + let visible_idx: HashSet = def_handles + .iter() + .enumerate() + .filter(|(_, h)| visible.contains(&h.value())) + .map(|(i, _)| i) + .collect(); + Some(StateMapping { + anon_handles, + visible_idx, + }) + } + + /// Which state (if any) matches the anonymous block's current per-member + /// invisibility flags. + fn current_visibility_state( + doc: &acadrust::CadDocument, + def_block: Handle, + anon_name: &str, + param: &BlockVisibilityParameter, + ) -> Option { + // Currently-visible member positions in the anonymous block. + let anon = doc.block_records.iter().find(|b| b.name == anon_name)?; + let cur_visible: HashSet = anon + .entity_handles + .iter() + .enumerate() + .filter(|(_, h)| { + doc.get_entity(**h) + .map(|e| !e.common().invisible) + .unwrap_or(false) + }) + .map(|(i, _)| i) + .collect(); + (0..param.states.len()).find(|&si| { + Self::state_mapping(doc, def_block, anon_name, si, param) + .map(|m| m.visible_idx == cur_visible) + .unwrap_or(false) + }) + } + + /// Recompute the visibility grip for the active tab's single selection and + /// append it (as a Triangle grip) to `selected_grips`. Clears it when the + /// selection is not a dynamic-block reference. `wo` is the world offset + /// already subtracted from the other grips. + pub(super) fn refresh_visibility_grip(&mut self, wo: [f64; 3]) { + let i = self.active_tab; + self.tabs[i].visibility_grip = None; + + let Some(handle) = self.tabs[i].selected_handle else { + return; + }; + let doc = &self.tabs[i].scene.document; + let Some(EntityType::Insert(ins)) = doc.get_entity(handle) else { + return; + }; + let Some((def_block, param)) = doc.dynamic_visibility_for_insert(handle) else { + return; + }; + + let wp = ins.get_transform().apply(Vector3::new( + param.def_point.x, + param.def_point.y, + param.def_point.z, + )); + let state_names: Vec = param.states.iter().map(|s| s.name.clone()).collect(); + let anon_name = ins.block_name.clone(); + let current = Self::current_visibility_state(doc, def_block, &anon_name, param); + + self.tabs[i].selected_grips.push(GripDef { + id: VIS_GRIP_ID, + world: glam::DVec3::new(wp.x - wo[0], wp.y - wo[1], wp.z - wo[2]), + is_midpoint: false, + shape: GripShape::Triangle, + dir: None, + }); + self.tabs[i].visibility_grip = Some(VisibilityGrip { + insert_handle: handle, + state_names, + current, + }); + } + + /// Open the visibility dropdown at `anchor` for the active tab's grip. + pub(super) fn open_visibility_popup(&mut self, anchor: iced::Point) { + let i = self.active_tab; + if let Some(vg) = &self.tabs[i].visibility_grip { + self.visibility_popup = Some(VisibilityPopup { + insert_handle: vg.insert_handle, + anchor, + items: vg.state_names.clone(), + current: vg.current, + }); + } + } + + /// Apply visibility `state_idx` to the dynamic-block reference: set each + /// anonymous-block member visible/invisible per the state, then rebuild. + pub(super) fn apply_visibility_state(&mut self, insert_handle: Handle, state_idx: usize) { + let i = self.active_tab; + + // Resolve everything against an immutable borrow first. + let mapping = { + let doc = &self.tabs[i].scene.document; + let Some(EntityType::Insert(ins)) = doc.get_entity(insert_handle) else { + return; + }; + let anon_name = ins.block_name.clone(); + let Some((def_block, param)) = doc.dynamic_visibility_for_insert(insert_handle) else { + return; + }; + Self::state_mapping(doc, def_block, &anon_name, state_idx, param) + }; + let Some(mapping) = mapping else { + return; + }; + + self.push_undo_snapshot(i, "Visibility State"); + + // Apply invisibility flags to the anonymous block's members. + { + let doc = &mut self.tabs[i].scene.document; + for (idx, h) in mapping.anon_handles.iter().enumerate() { + let visible = mapping.visible_idx.contains(&idx); + if let Some(e) = doc.get_entity_mut(*h) { + e.common_mut().invisible = !visible; + } + } + } + + self.visibility_popup = None; + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.refresh_selected_grips(); + } +}