use super::*; use super::super::document::{DynComponent, DynFieldEntry}; use super::super::Message; use iced::widget::{button, canvas, column, container, mouse_area, row, text, tooltip}; use iced::{Background, Border, Color, Element, Length, Point, Rectangle, Theme}; use std::time::Duration; fn viewport_tooltip<'a>( control: impl Into>, title: String, command: &'static str, ) -> Element<'a, Message> { let text = format!("{title}\n{} {command}", crate::t!("Command:")); tooltip( control, crate::ui::ribbon::tooltip_content(text), tooltip::Position::Bottom, ) .gap(6.0) .delay(Duration::from_millis(400)) .style(crate::ui::ribbon::tooltip_style) .into() } #[derive(Clone, Copy)] struct RenderModePreview { mode: acadrust::entities::ViewportRenderMode, } impl canvas::Program for RenderModePreview { type State = (); fn draw( &self, _state: &(), renderer: &iced::Renderer, theme: &Theme, bounds: Rectangle, _cursor: iced::mouse::Cursor, ) -> Vec { use acadrust::entities::ViewportRenderMode as M; let mut frame = canvas::Frame::new(renderer, bounds.size()); let palette = theme.palette(); let ink = palette.background.base.text.scale_alpha(0.86); let faint = ink.scale_alpha(0.34); let accent = palette.primary.base.color; let face = palette.primary.weak.color.scale_alpha(0.72); let face_alt = palette.primary.strong.color.scale_alpha(0.64); let edge = if self.mode == M::Wireframe3D { accent } else { ink }; let shaded = matches!( self.mode, M::FlatShaded | M::GouraudShaded | M::FlatShadedWithEdges | M::GouraudShadedWithEdges ); let flat = matches!(self.mode, M::FlatShaded | M::FlatShadedWithEdges); let smooth = matches!(self.mode, M::GouraudShaded | M::GouraudShadedWithEdges); let with_edges = matches!( self.mode, M::Wireframe2D | M::Wireframe3D | M::HiddenLine | M::FlatShadedWithEdges | M::GouraudShadedWithEdges ); // A small cube makes hidden/back edges and the shaded-edge variants // immediately distinguishable without depending on the open drawing. let a = Point::new(25.0, 35.0); let b = Point::new(87.0, 35.0); let c = Point::new(87.0, 94.0); let d = Point::new(25.0, 94.0); let off = iced::Vector::new(22.0, -17.0); let e = a + off; let f = b + off; let g = c + off; let h = d + off; let quad = |points: [Point; 4]| { canvas::Path::new(|path| { path.move_to(points[0]); path.line_to(points[1]); path.line_to(points[2]); path.line_to(points[3]); path.close(); }) }; if shaded || self.mode == M::HiddenLine { frame.fill(&quad([a, b, f, e]), face_alt); frame.fill(&quad([b, c, g, f]), face.scale_alpha(0.82)); frame.fill(&quad([a, d, c, b]), face); } let stroke_line = |frame: &mut canvas::Frame, from: Point, to: Point, color: Color| { frame.stroke( &canvas::Path::line(from, to), canvas::Stroke::default().with_color(color).with_width(1.2), ); }; if matches!(self.mode, M::Wireframe2D | M::Wireframe3D) { for (from, to) in [(e, f), (f, g), (g, h), (h, e)] { stroke_line(&mut frame, from, to, faint); } } if with_edges { for (from, to) in [ (a, b), (b, c), (c, d), (d, a), (a, e), (b, f), (c, g), (d, h), (e, f), (f, g), ] { stroke_line(&mut frame, from, to, edge); } } // Curved sample: faceted tones for Flat, concentric highlight for // Gouraud, and latitude/longitude lines for the wireframe modes. let center = Point::new((bounds.width - 55.0).max(145.0), 59.0); let radius = 32.0; if flat { for i in 0..12 { let a0 = i as f32 * std::f32::consts::TAU / 12.0; let a1 = (i + 1) as f32 * std::f32::consts::TAU / 12.0; let wedge = canvas::Path::new(|path| { path.move_to(center); path.line_to(Point::new(center.x + radius * a0.cos(), center.y + radius * a0.sin())); path.line_to(Point::new(center.x + radius * a1.cos(), center.y + radius * a1.sin())); path.close(); }); frame.fill(&wedge, if i % 3 == 0 { face_alt } else { face }); } } else if smooth { frame.fill(&canvas::Path::circle(center, radius), face_alt); frame.fill( &canvas::Path::circle(Point::new(center.x - 7.0, center.y - 8.0), radius * 0.72), face, ); frame.fill( &canvas::Path::circle(Point::new(center.x - 12.0, center.y - 13.0), radius * 0.34), palette.primary.weak.text.scale_alpha(0.38), ); } if with_edges || self.mode == M::HiddenLine { frame.stroke( &canvas::Path::circle(center, radius), canvas::Stroke::default().with_color(edge).with_width(1.2), ); } if matches!(self.mode, M::Wireframe2D | M::Wireframe3D) { for scale in [0.38_f32, 0.72] { let y = radius * scale; let half = (radius * radius - y * y).sqrt(); stroke_line( &mut frame, Point::new(center.x - half, center.y - y), Point::new(center.x + half, center.y - y), faint, ); stroke_line( &mut frame, Point::new(center.x - half, center.y + y), Point::new(center.x + half, center.y + y), faint, ); } stroke_line( &mut frame, Point::new(center.x, center.y - radius), Point::new(center.x, center.y + radius), faint, ); } // Bottom samples show the intentional Wireframe 2D/3D distinction: // the legacy planar solid loses only its interior in the 3D style, // while a hatch-like pattern remains visible in both. let solid = quad([ Point::new(18.0, 118.0), Point::new(74.0, 112.0), Point::new(69.0, 140.0), Point::new(23.0, 143.0), ]); if self.mode != M::Wireframe3D { frame.fill(&solid, accent.scale_alpha(0.48)); } frame.stroke( &solid, canvas::Stroke::default().with_color(edge).with_width(1.0), ); let hatch_box = canvas::Path::rectangle(Point::new(105.0, 112.0), iced::Size::new(82.0, 31.0)); frame.stroke( &hatch_box, canvas::Stroke::default().with_color(ink).with_width(1.0), ); for x in (94..188).step_by(9) { let x = x as f32; stroke_line( &mut frame, Point::new(x.max(105.0), 143.0), Point::new((x + 24.0).min(187.0), 112.0), faint, ); } vec![frame.into_geometry()] } } pub(super) fn viewport_controls<'a>( render_mode: acadrust::entities::ViewportRenderMode, show_grid: bool, snap_on: bool, include_split: bool, tile_count: usize, render_mode_menu_open: bool, render_mode_preview: Option, ) -> Element<'a, Message> { let render_modes: Vec = crate::modules::view::visual_style::VISUAL_STYLES .iter() .map(|style| RenderModeChoice(style.mode)) .collect(); let danger_btn = move |bytes: &'static [u8], msg: Message, title: String, command: &'static str| { let button = button(crate::ui::icons::themed_danger(bytes, 15.0)) .on_press(msg) .padding([4, 6]) .style(move |theme: &Theme, status| iced::widget::button::Style { background: matches!( status, iced::widget::button::Status::Hovered | iced::widget::button::Status::Pressed ) .then_some(Background::Color(theme.palette().danger.weak.color)), border: Border { radius: 3.0.into(), ..Default::default() }, text_color: theme.palette().danger.base.color, ..Default::default() }); viewport_tooltip(button, title, command) }; // Borderless icon button; an `active` toggle gets an accent tint + fill. let icon_btn = move |bytes: &'static [u8], active: bool, msg: Message, title: String, command: &'static str| { let icon = if active { crate::ui::icons::themed_primary(bytes, 15.0) } else { crate::ui::icons::themed(bytes, 15.0) }; let button = button(icon) .on_press(msg) .padding([4, 6]) .style(move |theme: &Theme, status| { let palette = theme.palette(); let pair = match (active, status) { (_, iced::widget::button::Status::Hovered) => { Some(palette.background.strong) } (true, _) => Some(palette.primary.weak), (false, _) => None, }; iced::widget::button::Style { background: pair.map(|p| Background::Color(p.color)), border: Border { radius: 3.0.into(), ..Default::default() }, text_color: pair .map(|p| p.text) .unwrap_or(palette.background.base.text), ..Default::default() } }); viewport_tooltip(button, title, command) }; // The standard picker cannot show a live sample beside its rows. This // flyout keeps the current style unchanged while hovering; a click commits. let picker_head = button( row![ text(RenderModeChoice(render_mode).to_string()).size(11), crate::ui::icons::themed_arrow_toggle(render_mode_menu_open, 9.0), ] .spacing(6) .align_y(iced::Center), ) .on_press(Message::ToggleRenderModeMenu(render_mode)) .padding([4, 6]) .style(move |theme: &Theme, status| { let palette = theme.palette(); let active = render_mode_menu_open || matches!(status, button::Status::Hovered | button::Status::Pressed); button::Style { background: active.then_some(Background::Color(palette.background.strong.color)), text_color: if active { palette.background.strong.text } else { palette.background.base.text }, border: Border { radius: 3.0.into(), ..Default::default() }, ..Default::default() } }); let picker_head = viewport_tooltip( picker_head, crate::t!("Visual Style").into_owned(), "VISUALSTYLES", ); let preview_mode = render_mode_preview.unwrap_or(render_mode); let mut choices = column![].spacing(2).width(Length::Fixed(174.0)); for choice in render_modes { let highlighted = choice.0 == preview_mode; let selected = choice.0 == render_mode; let option = container(text(choice.to_string()).size(11).width(Length::Fill)) .padding([6, 8]) .width(Length::Fill) .style(move |theme: &Theme| { let palette = theme.palette(); let active = highlighted; container::Style { background: active.then_some(Background::Color( if selected { palette.primary.weak.color } else { palette.background.strong.color }, )), text_color: if selected { Some(palette.primary.weak.text) } else if active { Some(palette.background.strong.text) } else { Some(palette.background.base.text) }, border: Border { radius: 3.0.into(), ..Default::default() }, ..Default::default() } }); choices = choices.push(mouse_area(option) .interaction(iced::mouse::Interaction::Pointer) .on_enter(Message::PreviewRenderMode(choice.0)) .on_press(Message::SetRenderMode(choice.0))); } let preview = container( column![ container( text(RenderModeChoice(preview_mode).to_string()) .size(12) .wrapping(iced::advanced::text::Wrapping::None), ) .width(Length::Fixed(215.0)) .height(Length::Fixed(16.0)) .align_y(iced::alignment::Vertical::Center), canvas(RenderModePreview { mode: preview_mode }) .width(Length::Fixed(215.0)) .height(Length::Fixed(154.0)), ] .spacing(6), ) .padding(8) .style(|theme: &Theme| { let palette = theme.palette(); container::Style { background: Some(Background::Color(palette.background.base.color)), border: Border { color: palette.background.neutral.color, width: 1.0, radius: 3.0.into(), }, text_color: Some(palette.background.base.text), ..Default::default() } }); let popup = container( row![choices, preview] .spacing(6) .align_y(iced::alignment::Vertical::Top), ) .padding(6) .style(|theme: &Theme| { let palette = theme.palette(); container::Style { background: Some(Background::Color(palette.background.weak.color)), border: Border { color: palette.background.neutral.color, width: 1.0, radius: 4.0.into(), }, text_color: Some(palette.background.weak.text), ..Default::default() } }); let picker: Element<'a, Message> = iced_aw::DropDown::new( picker_head, popup, render_mode_menu_open, ) .alignment(iced_aw::drop_down::Alignment::Bottom) .offset(3.0) .on_dismiss(Message::DismissRenderModeMenu) .into(); // Thin vertical divider between control groups. let sep = || { container(iced::widget::Space::new().width(1.0).height(16.0)).style(|theme: &Theme| { iced::widget::container::Style { background: Some(Background::Color( theme.palette().background.neutral.color.scale_alpha(0.7) )), ..Default::default() } }) }; let mut bar = row![] .spacing(3) .align_y(iced::alignment::Vertical::Center); bar = bar .push(icon_btn( crate::ui::icons::GRID, show_grid, Message::ToggleGrid, crate::t!("Toggle Grid").into_owned(), "GRID", )) .push(sep()) .push(icon_btn( crate::ui::icons::SNAP, snap_on, Message::ToggleGridSnap, crate::t!("Toggle Grid Snap").into_owned(), "SNAP", )) .push(sep()) .push(picker); if include_split { bar = bar .push(sep()) .push(icon_btn( crate::ui::icons::SPLIT_V, false, Message::SplitModelViewport(false), crate::tr!("viewport-split-vertical"), "VPORTS 2V", )) .push(sep()) .push(icon_btn( crate::ui::icons::SPLIT_H, false, Message::SplitModelViewport(true), crate::tr!("viewport-split-horizontal"), "VPORTS 2H", )); // Drag handle + close: only meaningful with more than one model tile. // The handle is a `mouse_area` (not a button) so it fires on press-DOWN, // letting the drag continue onto the target pane to swap them (a button // would only fire on release). Placed just left of Close. if tile_count > 1 { let drag = mouse_area( container(crate::ui::icons::themed_success(crate::ui::icons::MOVE, 15.0)) .padding([4, 6]) .style(|_: &Theme| iced::widget::container::Style { border: Border { radius: 3.0.into(), ..Default::default() }, ..Default::default() }), ) .interaction(iced::mouse::Interaction::Grab) .on_press(Message::PaneMoveStart); let drag = viewport_tooltip(drag, crate::tr!("viewport-move"), "VPORTS"); bar = bar .push(sep()) .push(drag) .push(sep()) .push(danger_btn( crate::ui::icons::CLOSE, Message::CloseModelViewport, crate::tr!("viewport-close"), "VPORTS SINGLE", )); } } container(bar) .padding(2) .style(|theme: &Theme| { let palette = theme.palette(); iced::widget::container::Style { background: Some(Background::Color( palette.background.weak.color.scale_alpha(0.92) )), border: Border { color: palette.background.neutral.color, width: 1.0, radius: 4.0.into(), }, ..Default::default() } }) .into() } // ── Dynamic-input field formatting ───────────────────────────────────────── /// Short prefix shown before a dynamic-input box's value. /// The string shown inside a dynamic-input box: the typed buffer when the /// field is locked, otherwise the live value derived from the cursor /// world position (and the base point for polar quantities). pub(super) fn dyn_component_value( f: &DynFieldEntry, w: glam::DVec3, base: Option, xf: &super::super::helpers::UcsXform, comma_cartesian: bool, absolute: bool, ) -> String { if let Some(b) = &f.buffer { return b.clone(); } let b = base.unwrap_or(glam::DVec3::ZERO); let p = xf.to_ucs(w); // Relative deltas and the polar angle read in the active UCS plane. The // delta is offset-invariant, so only the axis rotation matters (identity // xf reproduces the world-frame deltas). let d = xf.vec_to_ucs(w - b); let dx = d.x as f64; let dy = d.y as f64; // When a base point exists (DYN-on after the first pick) the cartesian // fields show relative deltas — matching the typed-value convention // in `dyn_resolve_point` so the live preview and the committed // coordinate use the same frame. See #35. let relative = base.is_some() && !absolute; // Width / Height read as unsigned magnitudes (the sign is taken from the // cursor side on commit), matching the rectangle's two-edge entry. But once // the user separates the values with `,` the entry is a cartesian // coordinate pair, so the fields read as signed X/Y deltas to match the // committed point (#269). let wh = matches!(f.role, crate::command::DynRole::Width | crate::command::DynRole::Height) && relative && !comma_cartesian; match f.component { DynComponent::X if relative => format!("{:.4}", if wh { dx.abs() } else { dx }), DynComponent::Y if relative => format!("{:.4}", if wh { dy.abs() } else { dy }), DynComponent::Z if relative => "0.0000".to_string(), DynComponent::X => format!("{:.4}", p.x), DynComponent::Y => format!("{:.4}", p.y), DynComponent::Z => format!("{:.4}", p.z), // Scaled by the role so a diameter box reads twice the radius. DynComponent::Distance => { format!("{:.4}", (dx * dx + dy * dy).sqrt() * f.role.value_scale() as f64) } // Shared rule: unsigned magnitude of the short angle, so CW (below the // reference axis) reads positive (e.g. 30°, not -30°/330°). The // committed value stays signed (see dyn_resolve_point). DynComponent::Angle => { format!("{:.1}", crate::command::dyn_display_angle_deg(dy.atan2(dx) as f32)) } // Typed-only scalar — no geometric value to track when empty. DynComponent::Scalar => String::new(), } }