diff --git a/src/app/view.rs b/src/app/view.rs index 58848b1d7..928001d0f 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -5,7 +5,7 @@ use super::document::{DynComponent, DynFieldEntry}; use super::{Message, OpenCADStudio}; use crate::scene::grip::{grips_to_screen, grips_to_screen_paper}; use crate::scene::paper_canvas::PaperCanvas; -use crate::scene::viewport_pane::{PaperViewportPane, ViewportPane}; +use crate::scene::viewport_pane::ViewportPane; use crate::scene::{VIEWCUBE_DRAW_PX, VIEWCUBE_PAD}; use crate::ui::overlay; use iced::widget::{ @@ -253,10 +253,13 @@ impl OpenCADStudio { // Surrounding chrome (tab bar, status bar) stays; the welcome widget // returned here also flags the rest of `view` to skip drawing-only // overlays via `tab.is_start`. + // Unified GPU widget for both layouts. In a paper layout the + // PaperCanvas (2-D sheet + paper entities + viewport borders) is + // layered underneath this shader so the area outside floating + // viewports shows the sheet; the shader only blits inside each + // content viewport's scissor rect. let viewport_3d: Element<'_, Message> = if tab.is_start { start_page_view() - } else if is_paper { - paper_canvas_view(tab) } else { shader(ViewportPane::model( &tab.scene, @@ -457,6 +460,21 @@ impl OpenCADStudio { .height(Fill)] .width(Fill) .height(Fill) + } else if is_paper { + // Paper layout: 2-D sheet + paper entities + viewport borders + // underneath, unified GPU shader on top (only paints inside + // each content viewport's scissor rect so the sheet shows + // through outside them). + let paper_sheet: Element<'_, Message> = + canvas(PaperCanvas::new(&tab.scene)).width(Fill).height(Fill).into(); + stack![ + paper_sheet, + container(viewport_3d).width(Fill).height(Fill), + selection_overlay, + viewport_mouse, + ] + .width(Fill) + .height(Fill) } else { stack![ container(viewport_3d) @@ -559,6 +577,35 @@ impl OpenCADStudio { if let Some(rect) = active_vp_rect { let x = rect.x.max(0.0); let y = rect.y.max(0.0); + // Highlight the active viewport with a 2-px border so its + // boundary is always visible over the GPU shader. + const VP_BORDER: Color = Color { + r: 0.18, + g: 0.52, + b: 0.95, + a: 1.0, + }; + let border_frame = container( + Space::new() + .width(iced::Length::Fixed(rect.width.max(1.0))) + .height(iced::Length::Fixed(rect.height.max(1.0))), + ) + .style(move |_: &Theme| container::Style { + border: iced::Border { + color: VP_BORDER, + width: 2.0, + radius: 0.0.into(), + }, + ..Default::default() + }); + let border_layer = column![ + Space::new().height(iced::Length::Fixed(y)), + row![Space::new().width(iced::Length::Fixed(x)), border_frame,], + ] + .width(Fill) + .height(Fill); + viewport_stack = viewport_stack.push(border_layer); + let vp_mode = tab .scene .active_viewport_render_mode() @@ -932,89 +979,6 @@ impl OpenCADStudio { } } -// ── Paper canvas ────────────────────────────────────────────────────────── -// -// PSPACE: single full-canvas PaperSheet widget — renders paper entities plus -// model content of all viewports via CPU projection. -// -// MSPACE (active viewport): PaperSheet widget (excludes the active viewport -// from its CPU projection) + a PaperViewportPane widget overlaid at the -// active viewport's screen-space position. PaperViewportPane uses a -// distinct pipeline type (PaperViewportPipeline) so Iced's per-type storage -// keeps the two prepare() calls from overwriting each other. - -fn paper_canvas_view<'a>(tab: &'a super::document::DocumentTab) -> Element<'a, Message> { - let scene = &tab.scene; - - // 2-D canvas for the paper sheet — paper entities, viewport borders, and - // inactive viewport projections are rendered as vector paths. This lets - // users select/edit paper-space entities directly without entering MSPACE. - let paper_sheet = canvas(PaperCanvas::new(scene)).width(Fill).height(Fill); - - if let Some(vp_handle) = scene.active_viewport { - let (canvas_w, canvas_h) = scene.selection.borrow().vp_size; - if let Some(rect) = scene.viewport_screen_rect(vp_handle, (canvas_w, canvas_h)) { - // Clamp to canvas bounds so Space widgets never get negative size. - let x = rect.x.max(0.0).min(canvas_w); - let y = rect.y.max(0.0).min(canvas_h); - 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)) - .width(iced::Length::Fixed(w)) - .height(iced::Length::Fixed(h)); - - let positioned = column![ - Space::new().height(iced::Length::Fixed(y)), - row![Space::new().width(iced::Length::Fixed(x)), vp_widget,], - ] - .width(Fill) - .height(Fill); - - // Blue border drawn on top of the 3-D overlay so the viewport - // boundary is always visible even when the shader fills the area. - const VP_BORDER: Color = Color { - r: 0.18, - g: 0.52, - b: 0.95, - a: 1.0, - }; - let border_frame = container( - Space::new() - .width(iced::Length::Fixed(w)) - .height(iced::Length::Fixed(h)), - ) - .style(move |_: &Theme| container::Style { - border: iced::Border { - color: VP_BORDER, - width: 2.0, - radius: 0.0.into(), - }, - ..Default::default() - }); - - let border_layer = column![ - Space::new().height(iced::Length::Fixed(y)), - row![Space::new().width(iced::Length::Fixed(x)), border_frame,], - ] - .width(Fill) - .height(Fill); - - // The render-mode picker and ViewCube hit area for the active - // viewport are layered in the main view stack (above the - // viewport mouse_area) so they're clickable — see - // `view()`. Here we only draw the sheet, the 3-D content, and - // the highlight border. - return stack![paper_sheet, positioned, border_layer] - .width(Fill) - .height(Fill) - .into(); - } - } - - paper_sheet.into() -} - // ── Document tab bar ─────────────────────────────────────────────────────── pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Element<'a, Message> { diff --git a/src/scene/paper_canvas.rs b/src/scene/paper_canvas.rs index fb529f90b..360c631e4 100644 --- a/src/scene/paper_canvas.rs +++ b/src/scene/paper_canvas.rs @@ -4,9 +4,10 @@ //! entities (title blocks, annotations, viewport borders) can be interacted //! with directly — no need to enter MSPACE just to click on them. //! -//! When MSPACE is active, a separate `PaperViewportPane` shader widget is -//! stacked on top of this canvas for the active viewport's 3-D content -//! (see `view.rs::paper_canvas_view()`). +//! A single unified `ViewportPane` shader widget is stacked on top of this +//! canvas to render every content viewport's 3-D model content into its own +//! scissor rectangle — outside those rectangles the shader does not paint, +//! so the paper sheet drawn here shows through. use iced::widget::canvas; use iced::{mouse, Color, Point, Rectangle}; diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index de96f06ce..cac0acd30 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -772,7 +772,12 @@ impl Pipeline { entry_point: Some("fs_main"), targets: &[Some(wgpu::ColorTargetState { format, - blend: None, + // Alpha-blend the resolve texture onto the surface so a + // transparent shader clear (alpha=0 outside any drawn + // geometry) leaves whatever the underlying widget + // painted — model container bg in a model layout, + // PaperCanvas sheet in a paper layout — visible. + blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], compilation_options: wgpu::PipelineCompilationOptions::default(), diff --git a/src/scene/render.rs b/src/scene/render.rs index eaa7ff9cb..4d945c29c 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -16,59 +16,6 @@ use super::pipeline::MultiPipeline; use super::tess_util; use super::{HatchModel, ImageModel, MeshLodSet, Scene, Uniforms, ViewportInstance, WireModel}; -// ── PaperViewportPipeline / PaperViewportPrimitive ──────────────────────── -// -// Newtype wrappers around MultiPipeline / Primitive so that the active-MSPACE -// viewport widget gets its own Iced storage entry (keyed by TypeId of the -// Pipeline type). This prevents the shared-pipeline prepare() overwrite -// that occurs when PaperSheet and the viewport widget both use the same type. - -/// Dedicated pipeline for the MSPACE active-viewport shader widget. -pub struct PaperViewportPipeline(pub(super) MultiPipeline); - -impl iced::widget::shader::Pipeline for PaperViewportPipeline { - fn new( - device: &iced::wgpu::Device, - queue: &iced::wgpu::Queue, - format: iced::wgpu::TextureFormat, - ) -> Self { - Self(::new( - device, queue, format, - )) - } -} - -/// Primitive returned by `PaperViewportPane`; delegates everything to the -/// inner `Primitive` via the dedicated `PaperViewportPipeline`. -#[derive(Debug)] -pub struct PaperViewportPrimitive(pub(super) Primitive); - -impl shader::Primitive for PaperViewportPrimitive { - type Pipeline = PaperViewportPipeline; - - fn prepare( - &self, - pipeline: &mut PaperViewportPipeline, - device: &iced::wgpu::Device, - queue: &iced::wgpu::Queue, - bounds: &Rectangle, - viewport: &Viewport, - ) { - self.0 - .prepare(&mut pipeline.0, device, queue, bounds, viewport); - } - - fn render( - &self, - pipeline: &PaperViewportPipeline, - encoder: &mut iced::wgpu::CommandEncoder, - target: &iced::wgpu::TextureView, - clip: &Rectangle, - ) { - self.0.render(&pipeline.0, encoder, target, clip); - } -} - // ── Camera hover state (shader::Program::State) ─────────────────────────── #[derive(Clone, Default)] @@ -78,15 +25,6 @@ pub struct CameraState { // ── GPU primitive ───────────────────────────────────────────────────────── -/// Normalized rectangle covering the whole widget — used for a single -/// full-window viewport (Model view or active paper viewport). -const FULL_VIEWPORT_RECT: Rectangle = Rectangle { - x: 0.0, - y: 0.0, - width: 1.0, - height: 1.0, -}; - /// Everything needed to render one viewport: its geometry, camera, render /// mode, and the screen rectangle it occupies. The unified renderer carries /// a `Vec` (one per tiled / floating viewport); each gets its @@ -482,74 +420,6 @@ pub(crate) fn adapt_to_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] { // ── Primitive builder helpers (called by ViewportPane's shader::Program impl) ── impl Scene { - /// Build a full-scene Primitive for the model or paper view (the camera - /// stored in `self.camera` is used as-is). - pub(super) fn build_primitive( - &self, - 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 - // a correct culling rectangle before entity_wires_arc() runs. - if bounds.height > 0.0 { - self.set_render_aspect(bounds.width / bounds.height); - self.set_render_pixel_scale(bounds.width, bounds.height); - } - - let entity_arc = self.entity_wires_arc(); - let (face3d_wires, other_wires) = split_face3d_wires(&entity_arc, &self.document); - let all_wires = if self.interim_wire.is_none() && self.preview_wires.is_empty() { - Arc::new(other_wires) - } else { - let mut v = other_wires; - if let Some(iw) = &self.interim_wire { - v.push(iw.clone()); - } - v.extend(self.preview_wires.iter().cloned()); - Arc::new(v) - }; - - let bg_color = if self.current_layout == "Model" { - self.bg_color - } else { - self.paper_bg_color - }; - - let mut uniforms = Uniforms::new(&cam, bounds, self.document.header.lineweight_display); - uniforms.flat_shade = if flags.flat_shade { 1.0 } else { 0.0 }; - - let data = ViewportData { - wires: all_wires, - face3d_wires: Arc::new(face3d_wires), - hatches: self.hatch_models_arc(), - wipeout_hatches: self.wipeout_models_arc(), - images: self.images_arc(), - meshes: self.meshes_arc(), - uniforms, - cam_rotation: cam.view_rotation_mat(), - hover_region, - show_viewcube, - fill_mode: self.document.header.fill_mode, - view_wireframe, - mesh_fill: flags.mesh_fill, - show_3d_edges: flags.show_3d_edges, - hidden_line: flags.hidden_line, - geometry_epoch: self.geometry_epoch, - camera_generation: self.camera_generation, - screen_rect: FULL_VIEWPORT_RECT, - }; - Primitive { - viewports: vec![data], - bg_color, - } - } - /// Build the unified multi-viewport `Primitive` for the current layout. /// Model layout → one full-window viewport (more once tiled); paper /// layout → one viewport per floating content viewport. Each entry is @@ -571,40 +441,20 @@ impl Scene { } let canvas = (bounds.width.max(1.0), bounds.height.max(1.0)); let instances = self.active_viewports(canvas.0, canvas.1, model_render_mode); - let bg_color = if self.current_layout == "Model" { - self.bg_color - } else { - self.paper_bg_color - }; + // Transparent clear — outside drawn geometry the resolve texture + // stays at alpha=0, so the alpha-blended blit reveals whatever the + // underlying widget painted (model layout: container bg; paper + // layout: PaperCanvas sheet — including inside content viewport + // rectangles). + let bg_color = [0.0, 0.0, 0.0, 0.0]; let viewports: Vec = instances .iter() .map(|inst| self.viewport_data_for(inst, canvas, hover_region)) .collect(); - Primitive { - viewports: if viewports.is_empty() { - // Paper layout with no content viewports still needs a - // (blank) pass so the widget clears to the sheet colour. - vec![self.viewport_data_for( - &ViewportInstance { - handle: acadrust::Handle::NULL, - screen_rect: Rectangle { - x: 0.0, - y: 0.0, - width: canvas.0, - height: canvas.1, - }, - camera: self.camera.borrow().clone(), - render_mode: model_render_mode, - active: false, - }, - canvas, - None, - )] - } else { - viewports - }, - bg_color, - } + // Empty viewports → blit nothing. The widget container's + // background (model bg or the PaperCanvas widget below) stays + // visible. This happens in paper layouts without content viewports. + Primitive { viewports, bg_color } } /// Build one `ViewportData` from a `ViewportInstance`: gathers the @@ -678,89 +528,6 @@ impl Scene { } } - /// Build a Primitive that renders model-space content through a specific - /// paper-space viewport's camera, applying its layer-freeze list. The - /// render mode is read from the viewport entity itself (each paper-space - /// viewport carries its own visual style) — the model-space pick_list - /// only governs the Model layout. - pub(super) fn build_viewport_primitive( - &self, - vp_handle: Handle, - _hover_region: Option, - bounds: Rectangle, - show_viewcube: bool, - ) -> Primitive { - // See `build_viewports` — the cube hit-area overlay shadows the - // shader, so hover is sourced from the scene cell. - let hover_region = self.viewcube_hover.get(); - let render_mode = match self.document.get_entity(vp_handle) { - Some(EntityType::Viewport(vp)) => vp.render_mode, - _ => acadrust::entities::ViewportRenderMode::Wireframe2D, - }; - 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, render_mode), - }; - - let base_arc = self.model_wires_for_viewport_arc(vp_handle); - let (face3d_wires, other_wires) = split_face3d_wires(&base_arc, &self.document); - let all_wires = if self.interim_wire.is_none() && self.preview_wires.is_empty() { - Arc::new(other_wires) - } else { - let mut v = other_wires; - if let Some(iw) = &self.interim_wire { - v.push(iw.clone()); - } - v.extend(self.preview_wires.iter().cloned()); - Arc::new(v) - }; - - let mut uniforms = Uniforms::new(&cam, bounds, self.document.header.lineweight_display); - uniforms.flat_shade = if flags.flat_shade { 1.0 } else { 0.0 }; - - let data = ViewportData { - wires: all_wires, - face3d_wires: Arc::new(face3d_wires), - hatches: self.hatch_models_arc(), - wipeout_hatches: self.wipeout_models_arc(), - images: self.images_arc(), - meshes: self.meshes_arc(), - uniforms, - cam_rotation: cam.view_rotation_mat(), - hover_region, - show_viewcube, - fill_mode: self.document.header.fill_mode, - view_wireframe, - mesh_fill: flags.mesh_fill, - show_3d_edges: flags.show_3d_edges, - hidden_line: flags.hidden_line, - geometry_epoch: self.geometry_epoch, - camera_generation: self.camera_generation, - screen_rect: FULL_VIEWPORT_RECT, - }; - Primitive { - viewports: vec![data], - bg_color: self.bg_color, - } - } - - /// Wrap `build_viewport_primitive()` in `PaperViewportPrimitive` for use - /// by `PaperViewportPane`, which needs its own dedicated pipeline type. - pub(super) fn build_active_viewport_primitive( - &self, - vp_handle: Handle, - hover_region: Option, - bounds: Rectangle, - ) -> PaperViewportPrimitive { - // The active (double-clicked) viewport shows the ViewCube gizmo, - // mirroring the model-space view. - PaperViewportPrimitive( - self.build_viewport_primitive(vp_handle, hover_region, bounds, true), - ) - } - /// Update viewcube hover state from cursor position within `bounds`. /// /// The cube draws in the top-right of the *active model tile* (which fills diff --git a/src/scene/viewport_pane.rs b/src/scene/viewport_pane.rs index 146d182c6..5f5eded56 100644 --- a/src/scene/viewport_pane.rs +++ b/src/scene/viewport_pane.rs @@ -1,29 +1,23 @@ -use super::render::{CameraState, PaperViewportPrimitive, Primitive}; -use super::{Scene, VIEWCUBE_PX}; -use crate::app::Message; -use acadrust::Handle; +//! Unified GPU shader widget for both model and paper layouts. +//! +//! There is one `shader::Program` for the whole canvas. `Scene::build_viewports` +//! decides what to render — model layout produces one viewport per tile +//! (`active_viewports` iterates `model_tiles`), paper layout produces one per +//! content viewport entity. Each one is drawn into its own scissor rect by a +//! dedicated inner `Pipeline` (kept in the `MultiPipeline` outer). + +use super::render::{CameraState, Primitive}; +use super::Scene; use iced::widget::shader; use iced::{mouse, Event, Rectangle}; -// ── Mode ────────────────────────────────────────────────────────────────── - -pub enum ViewportPaneMode { - /// Full model space — fills whatever bounds Iced assigns. - Model, - /// Model-space content rendered through a specific viewport's 3-D camera. - #[allow(dead_code)] - Paper { handle: Handle }, -} - // ── Widget struct ───────────────────────────────────────────────────────── pub struct ViewportPane<'a> { pub scene: &'a Scene, - pub mode: ViewportPaneMode, pub show_viewcube: bool, - /// Render mode for the **Model** layout view. Paper-space viewports - /// read their own `render_mode` field from the viewport entity, so - /// this value is ignored when `mode` is `Paper`. + /// Render mode applied to the Model layout's tiles. Paper-space content + /// viewports use the render mode stored on their own viewport entity. pub render_mode: acadrust::entities::ViewportRenderMode, } @@ -35,100 +29,13 @@ impl<'a> ViewportPane<'a> { ) -> Self { Self { scene, - mode: ViewportPaneMode::Model, show_viewcube, render_mode, } } - - /// One paper-space viewport: model content rendered through its own camera. - /// See [`ViewportPaneMode::Paper`] for why this is currently unused. - #[allow(dead_code)] - pub fn paper(scene: &'a Scene, handle: Handle) -> Self { - Self { - scene, - mode: ViewportPaneMode::Paper { handle }, - show_viewcube: false, - render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D, - } - } } -// ── PaperViewportPane ───────────────────────────────────────────────────── -// -// A shader widget for the MSPACE active viewport. Uses PaperViewportPrimitive -// (and therefore PaperViewportPipeline) so it gets its own Iced storage entry, -// separate from the ViewportPane/PaperSheet pipeline. - -pub struct PaperViewportPane<'a> { - pub scene: &'a Scene, - pub handle: Handle, -} - -impl<'a> PaperViewportPane<'a> { - pub fn new(scene: &'a Scene, handle: Handle) -> Self { - Self { scene, handle } - } -} - -impl<'a> shader::Program for PaperViewportPane<'a> { - type State = CameraState; - type Primitive = PaperViewportPrimitive; - - fn draw( - &self, - state: &Self::State, - _cursor: mouse::Cursor, - bounds: Rectangle, - ) -> Self::Primitive { - self.scene - .build_active_viewport_primitive(self.handle, state.hover_region, bounds) - } - - fn update( - &self, - state: &mut Self::State, - event: &Event, - bounds: Rectangle, - cursor: mouse::Cursor, - ) -> Option> { - self.scene.update_viewcube_state(state, bounds, cursor); - // A left click on the ViewCube snaps the viewport's view. The - // hit-test runs in bounds-relative coordinates so it matches the - // gizmo drawn in this viewport's top-right corner. Clicks outside - // the cube return None, so they fall through to the paper sheet - // for normal selection / drawing. - if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) = event { - if let Some(pos) = cursor.position_in(bounds) { - let rot = self.scene.active_view_rotation_mat(); - if let Some(region) = super::hit_test( - pos.x, - pos.y, - bounds.width, - bounds.height, - rot, - VIEWCUBE_PX, - ) { - return Some(iced::widget::Action::publish(Message::ViewCubeSnap( - region, - ))); - } - } - } - None - } - - fn mouse_interaction( - &self, - state: &Self::State, - _b: Rectangle, - _c: mouse::Cursor, - ) -> mouse::Interaction { - self.scene.viewcube_mouse_interaction(state) - } -} - -// ── ViewportPane shader::Program impl ──────────────────────────────────── +// ── shader::Program impl ────────────────────────────────────────────────── impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> { type State = CameraState; @@ -140,19 +47,8 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> _cursor: mouse::Cursor, bounds: Rectangle, ) -> Self::Primitive { - match &self.mode { - // Unified multi-viewport build: Model layout yields one - // full-window viewport (more once tiled); the camera, render - // mode and ViewCube come from `active_viewports`. - ViewportPaneMode::Model => { - self.scene - .build_viewports(bounds, self.render_mode, state.hover_region) - } - ViewportPaneMode::Paper { handle } => { - self.scene - .build_viewport_primitive(*handle, state.hover_region, bounds, false) - } - } + self.scene + .build_viewports(bounds, self.render_mode, state.hover_region) } fn update( @@ -162,8 +58,11 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { - // ViewCube hover only makes sense in the full model-space view. - if matches!(self.mode, ViewportPaneMode::Model) && self.show_viewcube { + // ViewCube hover is also driven from the app-level CursorMoved / + // ViewportMove handlers (the cube hit-area overlay shadows this + // widget for those events). Keeping the call here gives a fallback + // path while the cursor is over the bare shader. + if self.show_viewcube { self.scene.update_viewcube_state(state, bounds, cursor); } else { state.hover_region = None; @@ -178,7 +77,7 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> _b: Rectangle, _c: mouse::Cursor, ) -> mouse::Interaction { - if matches!(self.mode, ViewportPaneMode::Model) && self.show_viewcube { + if self.show_viewcube { self.scene.viewcube_mouse_interaction(state) } else { mouse::Interaction::default()