feat: introduce ViewportPane widget for unified model/paper rendering

- Remove Scene: shader::Program<Msg> impl; all GPU rendering now goes
  through ViewportPane which supports three modes:
    Model      — full model-space view (unchanged behaviour)
    PaperSheet — paper-space entities only, using the paper camera
    Paper      — model content through a specific viewport's own camera

- Add Scene helper methods (render.rs):
    build_primitive / build_paper_sheet_primitive / build_viewport_primitive
    update_viewcube_state / viewcube_mouse_interaction

- Add Scene helpers (mod.rs):
    paper_sheet_wires()        — paper entities without viewport projection
    camera_for_viewport()      — derive Camera from Viewport entity data
    model_wires_for_viewport() — model wires filtered by per-viewport frozen layers
    viewport_screen_rect()     — paper-space → pixel rect mapping

- Add paper_canvas_view() in view.rs:
    Stack of PaperSheet + one Paper widget per Viewport entity,
    positioned with Space offsets from viewport_screen_rect()

- Update VIEWPORT_WIDGET_PLAN.md: mark Steps 1–4 done, document remaining work

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Sonnet 4.6 committed 2026-04-23 02:18:41 +03:00
1 parent de04b7e351
commit da9fafc0f8
5 files changed
+594 -81

No files matched your search

+68 -1
View File
@@ -4,6 +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::ui::overlay;
use iced::widget::{button, column, container, mouse_area, row, shader, stack, text, Row, Space};
use iced::window;
@@ -103,7 +104,14 @@ impl H7CAD {
let i = self.active_tab;
let tab = &self.tabs[i];
let is_paper = tab.scene.current_layout != "Model";
let viewport_3d = shader(&tab.scene).width(Fill).height(Fill);
let viewport_3d: Element<'_, Message> = if is_paper {
paper_canvas_view(tab)
} else {
shader(ViewportPane::model(&tab.scene))
.width(Fill)
.height(Fill)
.into()
};
let selection_overlay = {
let sel = tab.scene.selection.borrow().clone();
@@ -447,6 +455,65 @@ impl H7CAD {
}
}
// ── Paper canvas ──────────────────────────────────────────────────────────
//
// In paper space, the main canvas is composed of:
// 1. A full-size PaperSheet layer — renders paper-space entities (title blocks,
// frames, borders) with the paper-space camera.
// 2. One ViewportPane::Paper widget per Viewport entity, positioned at its
// paper-space coordinates and sized to its paper-space dimensions.
// Each renders model-space content through its own viewport camera.
// 3. The existing selection_overlay / viewport_mouse / nav stack on top
// (unchanged — mouse event routing stays the same).
fn paper_canvas_view<'a>(tab: &'a super::document::DocumentTab) -> Element<'a, Message> {
let scene = &tab.scene;
// Layer 1: paper-space entities (title blocks, frames, paper boundary).
let paper_sheet = shader(ViewportPane::paper_sheet(scene))
.width(Fill)
.height(Fill);
// Use the last-rendered canvas size to compute pixel positions.
// On the very first frame this is (0,0); correct positions appear from
// the second frame onward (imperceptible in practice).
let (canvas_w, canvas_h) = scene.selection.borrow().vp_size;
let vp_list = scene.viewport_list();
let mut layers = stack![paper_sheet];
for (handle, _, _) in &vp_list {
let Some(rect) = scene.viewport_screen_rect(*handle, (canvas_w, canvas_h)) else {
continue;
};
// Clamp to avoid zero-size or negative widgets.
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);
// Layer 2+: one shader widget per viewport, positioned with Space offsets.
let vp_widget = shader(ViewportPane::paper(scene, *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);
layers = layers.push(positioned);
}
layers.width(Fill).height(Fill).into()
}
// ── Document tab bar ───────────────────────────────────────────────────────
pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Element<'a, Message> {
+151
View File
@@ -18,6 +18,7 @@ pub mod solid3d_tess;
pub mod tessellate;
pub mod transform;
pub mod truck_tess;
pub mod viewport_pane;
pub mod wire_model;
use camera::Camera;
@@ -2095,6 +2096,156 @@ impl Scene {
}
pub fn update(&mut self, _dt: Duration) {}
// ── Paper-space coordinate helpers ───────────────────────────────────
/// Convert a paper-space Viewport entity's position/size into a pixel
/// `Rectangle` relative to the top-left of the paper canvas.
///
/// `canvas_px` — the pixel dimensions of the area that shows the paper.
/// Paper limits from the current layout define the paper-space extent;
/// the viewport is mapped linearly into that area (Y-axis flipped).
/// Returns `None` if the viewport handle is invalid or paper limits are missing.
pub fn viewport_screen_rect(
&self,
vp_handle: Handle,
canvas_px: (f32, f32),
) -> Option<iced::Rectangle> {
let ((px0, py0), (px1, py1)) = self.paper_limits()?;
let paper_w = (px1 - px0) as f32;
let paper_h = (py1 - py0) as f32;
if paper_w < 1e-6 || paper_h < 1e-6 {
return None;
}
let vp = match self.document.get_entity(vp_handle) {
Some(EntityType::Viewport(vp)) => vp,
_ => return None,
};
let (canvas_w, canvas_h) = canvas_px;
let sx = canvas_w / paper_w;
let sy = canvas_h / paper_h;
let cx = vp.center.x as f32;
let cy = vp.center.y as f32;
let hw = (vp.width / 2.0) as f32;
let hh = (vp.height / 2.0) as f32;
// Map paper coords (origin = paper min, Y up) → screen (Y down).
let screen_x = (cx - hw - px0 as f32) * sx;
let screen_y = (py1 as f32 - (cy + hh)) * sy;
Some(iced::Rectangle {
x: screen_x,
y: screen_y,
width: vp.width as f32 * sx,
height: vp.height as f32 * sy,
})
}
// ── ViewportPane helpers ──────────────────────────────────────────────
/// Paper-space entity wires only (title blocks, frames, borders).
/// Does NOT include viewport content projection — that is handled by
/// individual ViewportPane::Paper widgets layered on top.
pub(super) fn paper_sheet_wires(&self) -> Vec<WireModel> {
let layout_block = self.current_layout_block_handle();
let mut wires = self.wires_for_block(layout_block);
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
}
/// Build a Camera oriented and scaled to match a paper-space Viewport entity.
/// Used by `ViewportPane::Paper` to render model-space content through the
/// viewport's own view direction and scale.
fn camera_for_viewport(&self, vp_handle: Handle) -> Option<camera::Camera> {
let vp = match self.document.get_entity(vp_handle) {
Some(EntityType::Viewport(vp)) => vp,
_ => return None,
};
let vd = glam::Vec3::new(
vp.view_direction.x as f32,
vp.view_direction.y as f32,
vp.view_direction.z as f32,
)
.normalize_or(glam::Vec3::Z);
let pitch = vd.z.clamp(-0.999, 0.999).asin();
let yaw = vd.x.atan2(vd.y);
let target = glam::Vec3::new(
vp.view_target.x as f32,
vp.view_target.y as f32,
vp.view_target.z as f32,
);
let fov_y = 45.0_f32.to_radians();
let view_height = if vp.view_height.abs() > 1e-9 {
vp.view_height as f32
} else {
vp.height as f32
};
// ortho_size = distance * tan(fov_y/2) => distance = view_height/2 / tan(fov_y/2)
let distance = ((view_height / 2.0) / (fov_y * 0.5).tan()).max(0.001);
Some(camera::Camera {
target,
rotation: camera::yaw_pitch_to_quat(yaw, pitch),
distance,
fov_y,
projection: camera::Projection::Orthographic,
yaw,
pitch,
})
}
/// Collect model-space WireModels visible through `vp_handle`, respecting
/// global layer visibility and the viewport's per-viewport layer freeze list.
fn model_wires_for_viewport(&self, vp_handle: Handle) -> Vec<WireModel> {
use std::collections::HashSet as HSet;
let frozen: HSet<Handle> = match self.document.get_entity(vp_handle) {
Some(EntityType::Viewport(vp)) => vp.frozen_layers.iter().cloned().collect(),
_ => HSet::new(),
};
let model_block = self.model_space_block_handle();
self.document
.entities()
.filter(|e| {
let c = e.common();
if c.invisible || matches!(e, EntityType::Viewport(_)) {
return false;
}
if !self.belongs_to_visible_block(c.handle, c.owner_handle, model_block) {
return false;
}
if self
.document
.layers
.get(&c.layer)
.map(|l| l.flags.off || l.flags.frozen)
.unwrap_or(false)
{
return false;
}
if !frozen.is_empty() {
if let Some(lh) = self.document.layers.get(&c.layer).map(|l| l.handle) {
if frozen.contains(&lh) {
return false;
}
}
}
true
})
.flat_map(|e| self.tessellate_one(e))
.collect()
}
}
impl Default for Scene {
+138 -80
View File
@@ -3,11 +3,11 @@
use acadrust::tables::LineType;
use acadrust::types::{Color as AcadColor, LineWeight};
use acadrust::EntityType;
use acadrust::{EntityType, Handle};
use glam::Mat4;
use iced::mouse;
use iced::widget::shader::{self, Viewport};
use iced::{Event, Rectangle, Size};
use iced::{Rectangle, Size};
use super::pipeline::viewcube::{hover_id, VIEWCUBE_PX};
use super::pipeline::Pipeline;
@@ -40,84 +40,6 @@ pub struct Primitive {
pub(super) bg_color: [f32; 4],
}
// ── shader::Program impl ──────────────────────────────────────────────────
impl<Msg: std::fmt::Debug + Clone> shader::Program<Msg> for Scene {
type State = CameraState;
type Primitive = Primitive;
fn draw(
&self,
state: &Self::State,
_cursor: mouse::Cursor,
bounds: Rectangle,
) -> Self::Primitive {
let cam = self.camera.borrow();
self.selection.borrow_mut().vp_size = (bounds.width, bounds.height);
let mut all_wires = self.entity_wires();
if let Some(iw) = &self.interim_wire {
all_wires.push(iw.clone());
}
all_wires.extend(self.preview_wires.iter().cloned());
let bg_color = if self.current_layout == "Model" {
self.bg_color
} else {
self.paper_bg_color
};
Primitive {
wires: all_wires,
hatches: self.synced_hatch_models(),
wipeout_hatches: self.wipeout_models(),
images: self.images.values().cloned().collect(),
meshes: self.meshes.values().cloned().collect(),
uniforms: Uniforms::new(&cam, bounds),
cam_rotation: cam.view_rotation_mat(),
hover_region: state.hover_region,
bg_color,
}
}
fn update(
&self,
state: &mut Self::State,
event: &Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<iced::widget::Action<Msg>> {
let pos = cursor.position_in(bounds);
let cam_rotation = { self.camera.borrow().view_rotation_mat() };
if let Some(p) = pos {
state.hover_region = hover_id(
p.x,
p.y,
bounds.width,
bounds.height,
cam_rotation,
VIEWCUBE_PX,
);
} else {
state.hover_region = None;
}
let _ = event;
None
}
fn mouse_interaction(
&self,
state: &Self::State,
_b: Rectangle,
_c: mouse::Cursor,
) -> mouse::Interaction {
if state.hover_region.is_some() {
return mouse::Interaction::Pointer;
}
mouse::Interaction::default()
}
}
// ── shader::Primitive impl ────────────────────────────────────────────────
impl shader::Primitive for Primitive {
@@ -236,6 +158,142 @@ impl Scene {
}
}
// ── 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<usize>,
bounds: Rectangle,
) -> Primitive {
let cam = self.camera.borrow();
self.selection.borrow_mut().vp_size = (bounds.width, bounds.height);
let mut all_wires = self.entity_wires();
if let Some(iw) = &self.interim_wire {
all_wires.push(iw.clone());
}
all_wires.extend(self.preview_wires.iter().cloned());
let bg_color = if self.current_layout == "Model" {
self.bg_color
} else {
self.paper_bg_color
};
Primitive {
wires: all_wires,
hatches: self.synced_hatch_models(),
wipeout_hatches: self.wipeout_models(),
images: self.images.values().cloned().collect(),
meshes: self.meshes.values().cloned().collect(),
uniforms: Uniforms::new(&cam, bounds),
cam_rotation: cam.view_rotation_mat(),
hover_region,
bg_color,
}
}
/// Build a Primitive for the paper-sheet background: paper-space entities
/// (title blocks, frames, borders) using the paper-space camera.
/// Viewport content is NOT included — it is rendered by separate
/// `ViewportPane::Paper` widgets stacked on top.
pub(super) fn build_paper_sheet_primitive(
&self,
hover_region: Option<usize>,
bounds: Rectangle,
) -> Primitive {
let cam = self.camera.borrow();
self.selection.borrow_mut().vp_size = (bounds.width, bounds.height);
let mut wires = self.paper_sheet_wires();
if let Some(iw) = &self.interim_wire {
wires.push(iw.clone());
}
wires.extend(self.preview_wires.iter().cloned());
Primitive {
wires,
hatches: self.synced_hatch_models(),
wipeout_hatches: self.wipeout_models(),
images: self.images.values().cloned().collect(),
meshes: self.meshes.values().cloned().collect(),
uniforms: Uniforms::new(&cam, bounds),
cam_rotation: cam.view_rotation_mat(),
hover_region,
bg_color: self.paper_bg_color,
}
}
/// Build a Primitive that renders model-space content through a specific
/// paper-space viewport's camera, applying its layer-freeze list.
pub(super) fn build_viewport_primitive(
&self,
vp_handle: Handle,
hover_region: Option<usize>,
bounds: Rectangle,
) -> Primitive {
let cam = match self.camera_for_viewport(vp_handle) {
Some(c) => c,
None => return self.build_primitive(hover_region, bounds),
};
let mut all_wires = self.model_wires_for_viewport(vp_handle);
if let Some(iw) = &self.interim_wire {
all_wires.push(iw.clone());
}
all_wires.extend(self.preview_wires.iter().cloned());
Primitive {
wires: all_wires,
hatches: self.synced_hatch_models(),
wipeout_hatches: self.wipeout_models(),
images: self.images.values().cloned().collect(),
meshes: self.meshes.values().cloned().collect(),
uniforms: Uniforms::new(&cam, bounds),
cam_rotation: cam.view_rotation_mat(),
hover_region,
bg_color: self.bg_color,
}
}
/// Update viewcube hover state from cursor position within `bounds`.
pub(super) fn update_viewcube_state(
&self,
state: &mut CameraState,
bounds: Rectangle,
cursor: mouse::Cursor,
) {
let pos = cursor.position_in(bounds);
let cam_rotation = self.camera.borrow().view_rotation_mat();
if let Some(p) = pos {
state.hover_region = hover_id(
p.x,
p.y,
bounds.width,
bounds.height,
cam_rotation,
VIEWCUBE_PX,
);
} else {
state.hover_region = None;
}
}
pub(super) fn viewcube_mouse_interaction(
&self,
state: &CameraState,
) -> mouse::Interaction {
if state.hover_region.is_some() {
mouse::Interaction::Pointer
} else {
mouse::Interaction::default()
}
}
}
// ── Linetype pattern helper ───────────────────────────────────────────────
pub(super) fn resolve_pattern(
+94
View File
@@ -0,0 +1,94 @@
use super::render::{CameraState, Primitive};
use super::Scene;
use acadrust::Handle;
use iced::widget::shader;
use iced::{mouse, Event, Rectangle};
// ── Mode ──────────────────────────────────────────────────────────────────
pub enum ViewportPaneMode {
/// Full model space — fills whatever bounds Iced assigns.
Model,
/// Paper-space entities only (title blocks, frames, borders) using the
/// paper-space camera. No viewport content projection.
PaperSheet,
/// Model-space content seen through a specific paper-space Viewport entity.
Paper { handle: Handle },
}
// ── Widget struct ─────────────────────────────────────────────────────────
pub struct ViewportPane<'a> {
pub scene: &'a Scene,
pub mode: ViewportPaneMode,
}
impl<'a> ViewportPane<'a> {
pub fn model(scene: &'a Scene) -> Self {
Self { scene, mode: ViewportPaneMode::Model }
}
/// Paper-sheet layer: paper-space entities rendered with the paper camera.
pub fn paper_sheet(scene: &'a Scene) -> Self {
Self { scene, mode: ViewportPaneMode::PaperSheet }
}
/// One paper-space viewport: model content rendered through its own camera.
pub fn paper(scene: &'a Scene, handle: Handle) -> Self {
Self { scene, mode: ViewportPaneMode::Paper { handle } }
}
}
// ── shader::Program impl ──────────────────────────────────────────────────
impl<'a, Msg: std::fmt::Debug + Clone> shader::Program<Msg> for ViewportPane<'a> {
type State = CameraState;
type Primitive = Primitive;
fn draw(
&self,
state: &Self::State,
_cursor: mouse::Cursor,
bounds: Rectangle,
) -> Self::Primitive {
match &self.mode {
ViewportPaneMode::Model => {
self.scene.build_primitive(state.hover_region, bounds)
}
ViewportPaneMode::PaperSheet => {
self.scene.build_paper_sheet_primitive(state.hover_region, bounds)
}
ViewportPaneMode::Paper { handle } => {
self.scene.build_viewport_primitive(*handle, state.hover_region, bounds)
}
}
}
fn update(
&self,
state: &mut Self::State,
event: &Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<iced::widget::Action<Msg>> {
// ViewCube hover only makes sense in the full model-space view.
if matches!(self.mode, ViewportPaneMode::Model | ViewportPaneMode::PaperSheet) {
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 {
if matches!(self.mode, ViewportPaneMode::Model | ViewportPaneMode::PaperSheet) {
self.scene.viewcube_mouse_interaction(state)
} else {
mouse::Interaction::default()
}
}
}