diff --git a/src/app/document.rs b/src/app/document.rs index 8edc0e1d0..42696d307 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -27,6 +27,7 @@ pub(super) struct DocumentTab { pub(super) selected_grips: Vec, pub(super) selected_handle: Option, pub(super) wireframe: bool, + pub(super) render_mode: acadrust::entities::ViewportRenderMode, pub(super) visual_style: String, pub(super) last_cursor_world: glam::Vec3, pub(super) last_cursor_screen: iced::Point, @@ -80,7 +81,8 @@ impl DocumentTab { selected_grips: vec![], selected_handle: None, wireframe: false, - visual_style: "Shaded".into(), + render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D, + visual_style: "Wireframe 2D".into(), last_cursor_world: glam::Vec3::ZERO, last_cursor_screen: iced::Point::ORIGIN, history: HistoryState::default(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 7832cca19..cdc60ee39 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -308,6 +308,10 @@ pub enum Message { SaveDialogCancel, ClearScene, SetWireframe(bool), + /// Set the active tab's render mode (one of acadrust's seven visual + /// styles). Replaces the binary `SetWireframe` over time; the older + /// message stays for ribbon/CLI back-compat and forwards. + SetRenderMode(acadrust::entities::ViewportRenderMode), /// Switch camera projection: true = Orthographic, false = Perspective. SetProjection(bool), /// Select a ribbon module tab by index. diff --git a/src/app/update.rs b/src/app/update.rs index d3afe3e8c..e579a1424 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -654,19 +654,41 @@ impl OpenCADStudio { } Message::SetWireframe(w) => { - let i = self.active_tab; - self.tabs[i].wireframe = w; - self.ribbon.set_wireframe(w); - self.tabs[i].visual_style = if w { - "Wireframe".into() + // Back-compat shim: forward to the new render-mode path so + // the ribbon button + WIREFRAME / SOLID command line still + // work without duplicating the rendering plumbing. + let mode = if w { + acadrust::entities::ViewportRenderMode::Wireframe2D } else { - "Shaded".into() + acadrust::entities::ViewportRenderMode::FlatShaded }; - self.command_line.push_output(if w { - "Visual style: Wireframe" - } else { - "Visual style: Shaded" - }); + Task::done(Message::SetRenderMode(mode)) + } + + Message::SetRenderMode(mode) => { + use acadrust::entities::ViewportRenderMode as M; + let i = self.active_tab; + self.tabs[i].render_mode = mode; + // Keep the legacy `wireframe` bool synced — both wireframe + // modes set it, everything else clears it. + let wf = matches!(mode, M::Wireframe2D | M::Wireframe3D); + self.tabs[i].wireframe = wf; + self.ribbon.set_wireframe(wf); + let label = match mode { + M::Wireframe2D => "Wireframe 2D", + M::Wireframe3D => "Wireframe 3D", + M::HiddenLine => "Hidden Line", + M::FlatShaded => "Flat Shaded", + M::GouraudShaded => "Gouraud Shaded", + M::FlatShadedWithEdges => "Flat Shaded + Edges", + M::GouraudShadedWithEdges => "Gouraud Shaded + Edges", + }; + self.tabs[i].visual_style = label.into(); + // Re-upload face3d fills on the next frame — the render + // pipeline keys its upload cache off `geometry_epoch`. + self.tabs[i].scene.bump_geometry(); + self.command_line + .push_output(&format!("Visual style: {label}")); Task::none() } diff --git a/src/app/view.rs b/src/app/view.rs index 80f8f79ee..8f7c926cf 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -16,6 +16,27 @@ use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Tas const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_DRAW_PX; +/// `pick_list` requires its items to implement `Display`; acadrust's +/// `ViewportRenderMode` enum carries the raw DXF integers, not a label, +/// so wrap it locally with a friendly name renderer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct RenderModeChoice(pub acadrust::entities::ViewportRenderMode); + +impl std::fmt::Display for RenderModeChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use acadrust::entities::ViewportRenderMode as M; + f.write_str(match self.0 { + M::Wireframe2D => "Wireframe 2D", + M::Wireframe3D => "Wireframe 3D", + M::HiddenLine => "Hidden Line", + M::FlatShaded => "Flat Shaded", + M::GouraudShaded => "Gouraud Shaded", + M::FlatShadedWithEdges => "Flat Shaded + Edges", + M::GouraudShadedWithEdges => "Gouraud Shaded + Edges", + }) + } +} + impl OpenCADStudio { pub fn view(&self, window_id: window::Id) -> Element<'_, Message> { // ── Floating panel windows ───────────────────────────────────────── @@ -236,7 +257,11 @@ impl OpenCADStudio { } else if is_paper { paper_canvas_view(tab) } else { - shader(ViewportPane::model(&tab.scene, self.show_viewcube)) + shader(ViewportPane::model( + &tab.scene, + self.show_viewcube, + tab.render_mode, + )) .width(Fill) .height(Fill) .into() @@ -357,15 +382,53 @@ impl OpenCADStudio { ) }; - let info = container(overlay::info_bar( - if is_paper { - &tab.scene.current_layout - } else { - "Custom View" + // Render-mode picker, top-left of the viewport. Drives whether + // 3D face / mesh fills and edges are rendered for the active + // tab. Hatch fills are deliberately *not* gated by this — the + // document's FILLMODE still owns 2D fill state. + let render_modes: Vec = vec![ + RenderModeChoice(acadrust::entities::ViewportRenderMode::Wireframe2D), + RenderModeChoice(acadrust::entities::ViewportRenderMode::Wireframe3D), + RenderModeChoice(acadrust::entities::ViewportRenderMode::HiddenLine), + RenderModeChoice(acadrust::entities::ViewportRenderMode::FlatShaded), + RenderModeChoice(acadrust::entities::ViewportRenderMode::GouraudShaded), + RenderModeChoice(acadrust::entities::ViewportRenderMode::FlatShadedWithEdges), + RenderModeChoice(acadrust::entities::ViewportRenderMode::GouraudShadedWithEdges), + ]; + let info = container( + iced::widget::pick_list( + render_modes, + Some(RenderModeChoice(tab.render_mode)), + |c| Message::SetRenderMode(c.0), + ) + .text_size(11), + ) + .style(|_: &Theme| iced::widget::container::Style { + background: Some(iced::Background::Color(Color { + r: 0.10, + g: 0.10, + b: 0.10, + a: 0.75, + })), + border: iced::Border { + color: Color { + r: 0.35, + g: 0.35, + b: 0.35, + a: 1.0, + }, + width: 1.0, + radius: 4.0.into(), }, - &tab.visual_style, - )) - .padding([4, 6]); + text_color: Some(Color { + r: 0.85, + g: 0.85, + b: 0.85, + a: 1.0, + }), + ..Default::default() + }) + .padding([4, 8]); let viewport_mouse = mouse_area(container( iced::widget::Space::new().width(Fill).height(Fill), @@ -443,20 +506,35 @@ impl OpenCADStudio { }) .width(Fill) .height(Fill), - container(info).width(Fill).height(Fill), selection_overlay, viewport_mouse, + // Toggle sits ABOVE the viewport mouse_area so clicks + // inside the toggle's bounds reach it instead of the + // shader behind it; `opaque` blocks the same clicks from + // bubbling further down. Outside the toggle's footprint + // the outer Fill container is transparent — viewport + // drawing / selection stays unaffected. + container(iced::widget::opaque(info)) + .width(Fill) + .height(Fill) + .align_x(iced::alignment::Horizontal::Left) + .align_y(iced::alignment::Vertical::Top), ] .width(Fill) .height(Fill) }; if self.show_navbar && !tab.is_start { + // Anchor the nav toolbar to the vertical centre of the + // viewport area, flush against the right edge with a small + // gutter, so it stays aligned no matter how tall the + // window is. let nav = container(overlay::nav_toolbar()) .align_right(Fill) - .align_top(Fill) + .align_y(iced::alignment::Vertical::Center) + .height(Fill) .padding(iced::Padding { - top: 148.0, + top: 0.0, right: 8.0, bottom: 0.0, left: 0.0, @@ -805,7 +883,7 @@ fn paper_canvas_view<'a>(tab: &'a super::document::DocumentTab) -> Element<'a, M let w = rect.width.clamp(1.0, canvas_w - x); let h = rect.height.clamp(1.0, canvas_h - y); - let vp_widget = shader(PaperViewportPane::new(scene, vp_handle)) + let vp_widget = shader(PaperViewportPane::new(scene, vp_handle, tab.render_mode)) .width(iced::Length::Fixed(w)) .height(iced::Length::Fixed(h)); diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 87b6f1e89..4e57d1a8a 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -784,16 +784,25 @@ impl Pipeline { device: &wgpu::Device, face3d_wires: &[WireModel], all_wires: &[WireModel], + wireframe_only: bool, ) { - let has_fills = - !face3d_wires.is_empty() || all_wires.iter().any(|w| !w.fill_tris.is_empty()); + // Edge buffer is always built from `face3d_wires`, so 3DFACE + // outlines stay on the screen regardless of mode. + self.gpu_face3d_edges = WireGpu::from_batch(device, face3d_wires); + // Fill buffer: in wireframe mode we drop the 3DFACE quads + // (those come from `face3d_wires.key_vertices`) but keep + // `all_wires.fill_tris` — that channel carries 2D content like + // text-LOD greek rectangles and PolyfaceMesh face triangles + // which shouldn't disappear with the toggle. + let face3d_for_fill: &[WireModel] = if wireframe_only { &[] } else { face3d_wires }; + let has_fills = !face3d_for_fill.is_empty() + || all_wires.iter().any(|w| !w.fill_tris.is_empty()); if !has_fills { self.gpu_face3d_fill = None; - self.gpu_face3d_edges = vec![]; - return; + } else { + self.gpu_face3d_fill = + Some(Face3DGpu::from_wires(device, face3d_for_fill, all_wires)); } - self.gpu_face3d_fill = Some(Face3DGpu::from_wires(device, face3d_wires, all_wires)); - self.gpu_face3d_edges = WireGpu::from_batch(device, face3d_wires); } pub fn upload_meshes(&mut self, device: &wgpu::Device, meshes: &[MeshLodSet]) { diff --git a/src/scene/render.rs b/src/scene/render.rs index 706de0df8..6d594c334 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -98,6 +98,22 @@ pub struct Primitive { /// Header.fill_mode (FILLMODE): when false, hatch / wipeout / face3d-fill /// uploads short-circuit so the renderer draws only wireframe. pub(super) fill_mode: bool, + /// Per-view "Wireframe vs Solid" toggle. When `true`, 3D face fills + /// are dropped on the upload path so 3D faces draw as edges only. + /// Hatch / wipeout uploads are deliberately *not* gated by this flag — + /// the user toggle should only affect 3D solids, not 2D fills. + pub(super) view_wireframe: bool, + /// Whether the active render mode wants 3D mesh fills uploaded. Off + /// in `Wireframe2D` / `Wireframe3D`; on for every shaded variant. Set + /// at the same point `view_wireframe` is computed so the two stay in + /// lock-step for the gating logic in `prepare()`. + #[allow(dead_code)] + pub(super) mesh_fill: bool, + /// Whether the active render mode wants 3D mesh / face edges + /// rendered on top of fills. Most shaded modes turn this off; the + /// `*WithEdges` variants and the pure wireframes leave it on. + #[allow(dead_code)] + pub(super) show_3d_edges: bool, pub(super) geometry_epoch: u64, /// Camera generation captured when this Primitive was assembled. Paired /// with `geometry_epoch` so the wire buffers re-upload when the view @@ -105,6 +121,48 @@ pub struct Primitive { pub(super) camera_generation: u64, } +/// Flags the render pipeline consumes, derived from +/// [`acadrust::entities::ViewportRenderMode`]. Each shaded variant fills +/// 3D faces and meshes; the pure wireframes drop the fill and keep only +/// edges. `*WithEdges` variants render both. `HiddenLine` is currently +/// rendered as plain solid (until Phase 2 wires up the depth-only +/// prepass); `FlatShaded` vs `GouraudShaded` differ in shader uniform +/// only and produce identical fill flags here. +#[derive(Clone, Copy, Debug)] +pub struct RenderModeFlags { + pub face3d_fill: bool, + pub mesh_fill: bool, + pub show_3d_edges: bool, +} + +pub fn render_mode_flags( + mode: acadrust::entities::ViewportRenderMode, +) -> RenderModeFlags { + use acadrust::entities::ViewportRenderMode as M; + match mode { + M::Wireframe2D | M::Wireframe3D => RenderModeFlags { + face3d_fill: false, + mesh_fill: false, + show_3d_edges: true, + }, + M::HiddenLine => RenderModeFlags { + face3d_fill: true, + mesh_fill: true, + show_3d_edges: true, + }, + M::FlatShaded | M::GouraudShaded => RenderModeFlags { + face3d_fill: true, + mesh_fill: true, + show_3d_edges: false, + }, + M::FlatShadedWithEdges | M::GouraudShadedWithEdges => RenderModeFlags { + face3d_fill: true, + mesh_fill: true, + show_3d_edges: true, + }, + } +} + // ── shader::Primitive impl ──────────────────────────────────────────────── impl shader::Primitive for Primitive { @@ -132,6 +190,11 @@ impl shader::Primitive for Primitive { pipeline.upload_uniforms(queue, &self.uniforms); let cur_key = (self.geometry_epoch, self.camera_generation); let fill_mode = self.fill_mode; + // 3D face fill requires *both* the doc-level FILLMODE *and* the + // per-view Solid toggle. Hatches / wipeouts deliberately ignore + // the view toggle so 2D fills stay on even when the user picks + // the Wireframe overlay style. + let face3d_fill_active = fill_mode && !self.view_wireframe; if cur_key != pipeline.cached_epoch { // Static buffers (hatches/images/meshes) only need refresh on a // real geometry change, not on every camera tick. @@ -149,12 +212,15 @@ impl shader::Primitive for Primitive { // Wires re-upload on every camera change because the visible // subset shifts under frustum culling. pipeline.upload_wires(device, &self.wires[..]); - if fill_mode { - pipeline.upload_face3d(device, &self.face3d_wires[..], &self.wires[..]); - } else { - // Edges still need to draw, but no fill_tris are forwarded. - pipeline.upload_face3d(device, &self.face3d_wires[..], &[]); - } + // `wireframe_only=true` keeps the face3d edge buffer but + // drops the fill — that's the on-screen result of toggling + // the render mode to Wireframe2D / Wireframe3D. + pipeline.upload_face3d( + device, + &self.face3d_wires[..], + &self.wires[..], + !face3d_fill_active, + ); pipeline.cached_epoch = cur_key; } pipeline.compute_wire_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height); @@ -337,7 +403,10 @@ impl Scene { hover_region: Option, bounds: Rectangle, show_viewcube: bool, + render_mode: acadrust::entities::ViewportRenderMode, ) -> Primitive { + let flags = render_mode_flags(render_mode); + let view_wireframe = !flags.face3d_fill; let cam = self.camera.borrow(); self.selection.borrow_mut().vp_size = (bounds.width, bounds.height); // Record the active widget's aspect so view_world_aabb() can compute @@ -379,6 +448,9 @@ impl Scene { bg_color, show_viewcube, fill_mode: self.document.header.fill_mode, + view_wireframe, + mesh_fill: flags.mesh_fill, + show_3d_edges: flags.show_3d_edges, geometry_epoch: self.geometry_epoch, camera_generation: self.camera_generation, } @@ -391,10 +463,13 @@ impl Scene { vp_handle: Handle, hover_region: Option, bounds: Rectangle, + render_mode: acadrust::entities::ViewportRenderMode, ) -> Primitive { + let flags = render_mode_flags(render_mode); + let view_wireframe = !flags.face3d_fill; let cam = match self.camera_for_viewport(vp_handle) { Some(c) => c, - None => return self.build_primitive(hover_region, bounds, false), + None => return self.build_primitive(hover_region, bounds, false, render_mode), }; let base_arc = self.model_wires_for_viewport_arc(vp_handle); @@ -423,6 +498,9 @@ impl Scene { bg_color: self.bg_color, show_viewcube: false, fill_mode: self.document.header.fill_mode, + view_wireframe, + mesh_fill: flags.mesh_fill, + show_3d_edges: flags.show_3d_edges, geometry_epoch: self.geometry_epoch, camera_generation: self.camera_generation, } @@ -435,8 +513,11 @@ impl Scene { vp_handle: Handle, hover_region: Option, bounds: Rectangle, + render_mode: acadrust::entities::ViewportRenderMode, ) -> PaperViewportPrimitive { - PaperViewportPrimitive(self.build_viewport_primitive(vp_handle, hover_region, bounds)) + PaperViewportPrimitive( + self.build_viewport_primitive(vp_handle, hover_region, bounds, render_mode), + ) } /// Update viewcube hover state from cursor position within `bounds`. diff --git a/src/scene/viewport_pane.rs b/src/scene/viewport_pane.rs index ce40e45e3..a5be3fc3e 100644 --- a/src/scene/viewport_pane.rs +++ b/src/scene/viewport_pane.rs @@ -20,14 +20,20 @@ pub struct ViewportPane<'a> { pub scene: &'a Scene, pub mode: ViewportPaneMode, pub show_viewcube: bool, + pub render_mode: acadrust::entities::ViewportRenderMode, } impl<'a> ViewportPane<'a> { - pub fn model(scene: &'a Scene, show_viewcube: bool) -> Self { + pub fn model( + scene: &'a Scene, + show_viewcube: bool, + render_mode: acadrust::entities::ViewportRenderMode, + ) -> Self { Self { scene, mode: ViewportPaneMode::Model, show_viewcube, + render_mode, } } @@ -39,6 +45,7 @@ impl<'a> ViewportPane<'a> { scene, mode: ViewportPaneMode::Paper { handle }, show_viewcube: false, + render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D, } } } @@ -52,11 +59,20 @@ impl<'a> ViewportPane<'a> { pub struct PaperViewportPane<'a> { pub scene: &'a Scene, pub handle: Handle, + pub render_mode: acadrust::entities::ViewportRenderMode, } impl<'a> PaperViewportPane<'a> { - pub fn new(scene: &'a Scene, handle: Handle) -> Self { - Self { scene, handle } + pub fn new( + scene: &'a Scene, + handle: Handle, + render_mode: acadrust::entities::ViewportRenderMode, + ) -> Self { + Self { + scene, + handle, + render_mode, + } } } @@ -71,7 +87,12 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for PaperViewportPan bounds: Rectangle, ) -> Self::Primitive { self.scene - .build_active_viewport_primitive(self.handle, state.hover_region, bounds) + .build_active_viewport_primitive( + self.handle, + state.hover_region, + bounds, + self.render_mode, + ) } fn update( @@ -109,14 +130,18 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> bounds: Rectangle, ) -> Self::Primitive { match &self.mode { - ViewportPaneMode::Model => { - self.scene - .build_primitive(state.hover_region, bounds, self.show_viewcube) - } - ViewportPaneMode::Paper { handle } => { - self.scene - .build_viewport_primitive(*handle, state.hover_region, bounds) - } + ViewportPaneMode::Model => self.scene.build_primitive( + state.hover_region, + bounds, + self.show_viewcube, + self.render_mode, + ), + ViewportPaneMode::Paper { handle } => self.scene.build_viewport_primitive( + *handle, + state.hover_region, + bounds, + self.render_mode, + ), } } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 48e34d823..167d93bb2 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1,10 +1,9 @@ //! Viewport overlay widgets: -//! • info_bar() — top-left bracket buttons showing view name / style //! • nav_toolbar() — vertical orbit/pan/zoom buttons on the right use glam::{Mat4, Vec3}; use iced::mouse; -use iced::widget::{button, canvas, column, container, row, text}; +use iced::widget::{button, canvas, column, container, text}; use iced::{Background, Border, Color, Element, Length, Point, Size, Theme}; use crate::app::Message; @@ -30,43 +29,6 @@ pub struct GripMarker { pub is_hot: bool, } -// ── Info bar ───────────────────────────────────────────────────────────── - -pub fn info_bar<'a>(view_name: &'a str, visual_style: &'a str) -> Element<'a, Message> { - let bracket = |s: &'a str| -> Element<'a, Message> { - container(text(s).size(10).color(Color { - r: 0.78, - g: 0.78, - b: 0.78, - a: 1.0, - })) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: 0.75, - })), - border: Border { - color: Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, - }, - width: 1.0, - radius: 0.0.into(), - }, - ..Default::default() - }) - .padding([2, 6]) - .into() - }; - row![bracket("[-]"), bracket(view_name), bracket(visual_style)] - .spacing(0) - .into() -} - // ── Nav toolbar ─────────────────────────────────────────────────────────── pub fn nav_toolbar<'a>() -> Element<'a, Message> {