feat: render active MSPACE viewport as a true 3D widget in paper space

When a paper-space viewport is activated (double-click / MSPACE command),
it is now rendered through its own dedicated PaperViewportPipeline rather
than the CPU-projection approach used for inactive viewports.

Architecture:
- PaperSheet widget (full-canvas): renders paper entities + CPU-projected
  content of all viewports *except* the active one.
- PaperViewportPane widget (Fixed w×h, positioned at viewport screen rect):
  renders the active viewport with a true 3D camera derived from the
  viewport's view_direction/view_target/view_height.

PaperViewportPipeline is a newtype of Pipeline.  Having a distinct type
gives it its own Iced storage entry (keyed by TypeId), preventing the
shared prepare() overwrite that broke the earlier per-viewport approach.

Changes:
- render.rs: PaperViewportPipeline, PaperViewportPrimitive newtypes;
  build_active_viewport_primitive(); build_paper_sheet_primitive() now
  excludes active_viewport from CPU projection.
- viewport_pane.rs: PaperViewportPane<'a> with shader::Program impl.
- mod.rs: viewport_content_wires() gains exclude_vp parameter;
  viewport_screen_rect() is no longer dead code.
- view.rs: paper_canvas_view() overlays PaperViewportPane when MSPACE
  is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-23 03:19:15 +03:00
commit 97dbd80c93
4 changed files with 171 additions and 21 deletions

View file

@ -4,7 +4,7 @@ use super::history::history_dropdown_labels;
use super::helpers::grid_plane_from_camera;
use crate::scene::{VIEWCUBE_DRAW_PX, VIEWCUBE_PAD};
use crate::scene::grip::grips_to_screen;
use crate::scene::viewport_pane::ViewportPane;
use crate::scene::viewport_pane::{PaperViewportPane, ViewportPane};
use crate::ui::overlay;
use iced::widget::{button, column, container, mouse_area, row, shader, stack, text, Row, Space};
use iced::window;
@ -457,22 +457,52 @@ impl H7CAD {
// ── Paper canvas ──────────────────────────────────────────────────────────
//
// Renders the full paper canvas as a single full-size shader widget using the
// PaperSheet mode, which includes both paper-space entities (title blocks,
// frames, borders) and model-space content projected through each viewport's
// view matrix into paper-space coordinates.
// PSPACE: single full-canvas PaperSheet widget — renders paper entities plus
// model content of all viewports via CPU projection.
//
// Note: A per-viewport widget approach (ViewportPane::Paper) was attempted but
// does not work correctly because Iced 0.14 batches all shader prepare() calls
// before any render() calls, causing widgets that share the same Pipeline type
// to overwrite each other's GPU buffers. The CPU-projection approach used here
// is the correct solution within Iced's shader framework.
// 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> {
shader(ViewportPane::paper_sheet(&tab.scene))
let scene = &tab.scene;
let paper_sheet = shader(ViewportPane::paper_sheet(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)) {
let w = rect.width.max(1.0);
let h = rect.height.max(1.0);
let x = rect.x.max(0.0);
let y = rect.y.max(0.0);
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);
return stack![paper_sheet, positioned]
.width(Fill)
.height(Fill)
.into()
.into();
}
}
paper_sheet.into()
}
// ── Document tab bar ───────────────────────────────────────────────────────

View file

@ -351,7 +351,7 @@ impl Scene {
if let Some(((x0, y0), (x1, y1))) = self.paper_limits() {
wires.insert(0, paper_boundary_wire(x0 as f32, y0 as f32, x1 as f32, y1 as f32));
}
wires.extend(self.viewport_content_wires(layout_block, None));
wires.extend(self.viewport_content_wires(layout_block, None, None));
}
wires
}
@ -375,7 +375,7 @@ impl Scene {
}
Some(vp_handle) => {
// MSPACE: only model content visible through the active viewport
self.viewport_content_wires(layout_block, Some(vp_handle))
self.viewport_content_wires(layout_block, Some(vp_handle), None)
}
}
}
@ -667,7 +667,12 @@ impl Scene {
/// Collect model-space wires projected into paper space for all (or one specific)
/// user viewports. `only_vp = Some(h)` restricts output to that viewport.
fn viewport_content_wires(&self, paper_block: Handle, only_vp: Option<Handle>) -> Vec<WireModel> {
fn viewport_content_wires(
&self,
paper_block: Handle,
only_vp: Option<Handle>,
exclude_vp: Option<Handle>,
) -> Vec<WireModel> {
use acadrust::entities::Viewport;
use std::collections::HashSet as HSet;
@ -682,6 +687,7 @@ impl Scene {
&& vp.common.owner_handle == paper_block
&& vp.status.is_on
&& only_vp.map_or(true, |h| vp.common.handle == h)
&& exclude_vp.map_or(true, |h| vp.common.handle != h)
})
.collect();
@ -2104,8 +2110,7 @@ impl Scene {
///
/// `canvas_px` — the pixel dimensions of the area that shows the paper.
/// Map a paper-space viewport into screen-pixel coordinates.
/// Needed when per-viewport shader widgets are revived (see `ViewportPaneMode::Paper`).
#[allow(dead_code)]
/// Used to position the active-viewport widget in MSPACE paper space.
pub fn viewport_screen_rect(
&self,
vp_handle: Handle,

View file

@ -14,6 +14,56 @@ use super::pipeline::Pipeline;
use super::tessellate;
use super::{HatchModel, ImageModel, MeshModel, Scene, Uniforms, WireModel};
// ── PaperViewportPipeline / PaperViewportPrimitive ────────────────────────
//
// Newtype wrappers around Pipeline / 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 `Pipeline`.
/// Dedicated pipeline for the MSPACE active-viewport shader widget.
pub struct PaperViewportPipeline(pub(super) Pipeline);
impl iced::widget::shader::Pipeline for PaperViewportPipeline {
fn new(
device: &iced::wgpu::Device,
queue: &iced::wgpu::Queue,
format: iced::wgpu::TextureFormat,
) -> Self {
Self(Pipeline::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<u32>,
) {
self.0.render(&pipeline.0, encoder, target, clip);
}
}
// ── Camera hover state (shader::Program::State) ───────────────────────────
#[derive(Clone, Default)]
@ -209,7 +259,9 @@ impl Scene {
let layout_block = self.current_layout_block_handle();
let mut wires = self.paper_sheet_wires();
wires.extend(self.viewport_content_wires(layout_block, None));
// When MSPACE is active, exclude that viewport from the CPU projection —
// it is rendered in 3D by the separate PaperViewportPane widget.
wires.extend(self.viewport_content_wires(layout_block, None, self.active_viewport));
if let Some(iw) = &self.interim_wire {
wires.push(iw.clone());
}
@ -260,6 +312,17 @@ impl Scene {
}
}
/// 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<usize>,
bounds: Rectangle,
) -> PaperViewportPrimitive {
PaperViewportPrimitive(self.build_viewport_primitive(vp_handle, hover_region, bounds))
}
/// Update viewcube hover state from cursor position within `bounds`.
pub(super) fn update_viewcube_state(
&self,

View file

@ -1,4 +1,4 @@
use super::render::{CameraState, Primitive};
use super::render::{CameraState, PaperViewportPrimitive, Primitive};
use super::Scene;
use acadrust::Handle;
use iced::widget::shader;
@ -48,7 +48,59 @@ impl<'a> ViewportPane<'a> {
}
}
// ── shader::Program impl ──────────────────────────────────────────────────
// ── 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, Msg: std::fmt::Debug + Clone> shader::Program<Msg> 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<iced::widget::Action<Msg>> {
self.scene.update_viewcube_state(state, bounds, cursor);
let _ = event;
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 ────────────────────────────────────
impl<'a, Msg: std::fmt::Debug + Clone> shader::Program<Msg> for ViewportPane<'a> {
type State = CameraState;