From 0ef99ff2fc2839f62a58acfde36ea34a96f224c9 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 2 Jun 2026 20:05:51 +0300 Subject: [PATCH] feat(paper): render the sheet as a top-locked GPU viewport Paper space now renders its sheet through the unified shader, the same path as model space: a full-canvas "sheet" ViewportInstance draws the layout's own entities + viewport borders + interim/preview wires, with a camera locked to the top/plan orientation (paper is 2-D and never orbits). Floating content viewports overlay it. - PaperCanvas no longer draws entity wires (still paints the desk, sheet and hatch/wipeout/image fills; full removal is the next step). - Right-drag on the paper sheet no longer orbits the camera (it would corrupt the frame and skew subsequent pans). - Fix floating-viewport orbit oscillation: orbit_active_viewport wrote view_direction.y negated while camera_for_viewport reads it un-negated, so each drag step read back a Y-mirrored camera and flipped the model between a rotation and its opposite every frame. Store eye directly, matching snap_active_viewport_to_direction and the reader. Co-Authored-By: Claude Opus 4.8 --- src/app/update.rs | 8 +++++- src/scene/mod.rs | 42 +++++++++++++++++++++++++--- src/scene/paper_canvas.rs | 59 ++------------------------------------- src/scene/render.rs | 20 +++++++++++-- 4 files changed, 65 insertions(+), 64 deletions(-) diff --git a/src/app/update.rs b/src/app/update.rs index 8af59488d..09da24f9c 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1951,12 +1951,18 @@ impl OpenCADStudio { drop(sel); self.tabs[i].scene.orbit_active_viewport(dx, dy); return Task::none(); - } else { + } else if self.tabs[i].scene.current_layout == "Model" { sel.right_last_pos = Some(p); drop(sel); self.tabs[i].scene.camera.borrow_mut().orbit(dx, dy); self.tabs[i].scene.camera_generation += 1; return Task::none(); + } else { + // Paper sheet is top-locked: right-drag never + // orbits it (orbiting would corrupt the camera + // frame and skew subsequent pans). + sel.right_last_pos = Some(p); + return Task::none(); } } else { sel.right_last_pos = Some(p); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 84e6245fc..1372c3252 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -436,6 +436,10 @@ pub struct ViewportInstance { pub render_mode: acadrust::entities::ViewportRenderMode, /// `true` when this is the viewport receiving cursor input. pub active: bool, + /// `true` for the full-canvas paper "sheet" viewport — the layout's own + /// view (paper-space entities, top-locked), the paper equivalent of the + /// Model view. Floating content viewports overlay it. + pub paper_sheet: bool, } /// One pane of the Model-space tiled viewport layout: the normalized screen @@ -2691,16 +2695,18 @@ impl Scene { }; cam.orbit(delta_x, delta_y); // yaw_pitch_to_quat(y,p)*Z = (cos(p)*sin(y), -cos(p)*cos(y), sin(p)) - // but view_direction convention (matching snap_active_viewport_to_angles) is - // (cos(p)*sin(y), +cos(p)*cos(y), sin(p)) ← Y has opposite sign. - // Negate Y when writing back so camera_for_viewport round-trips correctly. + // `camera_for_viewport` reconstructs the rotation so that + // `rotation * Z == view_direction` exactly (its `yaw = atan2(x, -y)` + // cancels the sign). Store `eye` directly so the orbit round-trips — + // negating Y here made each drag step read back a Y-mirrored camera, + // flipping the model between a rotation and its opposite every frame. let eye = cam.rotation * glam::Vec3::Z; if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) { if vp.status.locked { return; } vp.view_direction.x = eye.x as f64; - vp.view_direction.y = -eye.y as f64; + vp.view_direction.y = eye.y as f64; vp.view_direction.z = eye.z as f64; } } @@ -5699,12 +5705,37 @@ impl Scene { camera, render_mode: model_mode, active: i == active, + paper_sheet: false, } }) .collect(); } let layout_block = self.current_layout_block_handle(); let mut out: Vec = Vec::new(); + // The full-canvas sheet viewport renders the paper-space entities + // themselves — the layout's own view, drawn first so the floating + // content viewports overlay it. Its camera keeps the paper pan/zoom + // (target + ortho size) but is LOCKED to the top/plan orientation: + // paper is 2-D, so the sheet never orbits. + let mut sheet_cam = self.camera.borrow().clone(); + sheet_cam.yaw = 0.0; + sheet_cam.pitch = std::f32::consts::FRAC_PI_2; + sheet_cam.rotation = camera::yaw_pitch_to_quat(0.0, std::f32::consts::FRAC_PI_2, 0.0); + sheet_cam.projection = camera::Projection::Orthographic; + out.push(ViewportInstance { + handle: Handle::NULL, + tile_idx: None, + screen_rect: iced::Rectangle { + x: 0.0, + y: 0.0, + width: canvas_w, + height: canvas_h, + }, + camera: sheet_cam, + render_mode: acadrust::entities::ViewportRenderMode::Wireframe2D, + active: false, + paper_sheet: true, + }); for e in self.document.entities() { let EntityType::Viewport(vp) = e else { continue; @@ -5728,6 +5759,7 @@ impl Scene { camera, render_mode: vp.render_mode, active: self.active_viewport == Some(h), + paper_sheet: false, }); } out @@ -5796,6 +5828,7 @@ impl Scene { /// All wires needed to render the paper-space canvas (2D widget path). /// Includes paper entities, paper boundary, inactive viewport projections /// (excluding the active MSPACE viewport), plus interim/preview wires. + #[allow(dead_code)] // superseded by the GPU sheet viewport; removed in Stage 2. pub fn paper_canvas_wires(&self) -> Arc> { { let cache = self.paper_canvas_cache.borrow(); @@ -5919,6 +5952,7 @@ impl Scene { Arc::new(models) } + #[allow(dead_code)] // superseded by the GPU sheet viewport; removed in Stage 2. pub(super) fn paper_sheet_wires(&self) -> Vec { (*self.paper_sheet_wires_arc()).clone() } diff --git a/src/scene/paper_canvas.rs b/src/scene/paper_canvas.rs index 360c631e4..f2972e43b 100644 --- a/src/scene/paper_canvas.rs +++ b/src/scene/paper_canvas.rs @@ -100,62 +100,9 @@ impl<'a> canvas::Program for PaperCanvas<'a> { draw_hatch(&mut frame, hatch, &to_px); } - // px-per-world-unit scale for linetype dash lengths. - let world_to_px_scale = if half_w > 0.0 { - bounds.width / (2.0 * half_w) - } else { - 1.0 - }; - - // ── Wires (entity lines + inactive viewport projections) ────────────── - for wire in self.scene.paper_canvas_wires().iter() { - let [r, g, b, a] = wire.color; - let color = Color { r, g, b, a }; - - let path = canvas::Path::new(|b| { - let mut started = false; - for &[wx, wy, _] in &wire.points { - if wx.is_nan() || wy.is_nan() { - started = false; - continue; - } - let p = to_px(wx, wy); - if started { - b.line_to(p); - } else { - b.move_to(p); - started = true; - } - } - }); - - // Convert WireModel linetype pattern (world units) to pixel lengths. - // Keep the Vec alive for the duration of frame.stroke(). - let dash_segments: Vec = if wire.pattern_length > 0.0 { - wire.pattern - .iter() - .take_while(|&&v| v != 0.0) - .map(|&v| v.abs() * world_to_px_scale) - .collect() - } else { - vec![] - }; - - frame.stroke( - &path, - canvas::Stroke { - style: canvas::Style::Solid(color), - width: wire.line_weight_px.max(1.0), - line_cap: canvas::LineCap::Square, - line_join: canvas::LineJoin::Miter, - line_dash: canvas::LineDash { - segments: &dash_segments, - offset: 0, - }, - }, - ); - } - + // Entity lines, viewport borders and interim/preview wires are now + // rendered by the GPU shader's full-canvas paper "sheet" viewport, so + // the 2-D canvas only paints the desk + sheet + fills beneath it. vec![frame.into_geometry()] } } diff --git a/src/scene/render.rs b/src/scene/render.rs index 9b0257801..f13d16ef1 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -597,6 +597,11 @@ impl Scene { 1.0 }; self.model_tile_wires_arc(tile_idx, &inst.camera, aspect, full.height) + } else if inst.paper_sheet { + // The sheet renders the paper block's own entities + viewport + // borders — NOT the projected viewport content (the GPU content + // viewports draw that themselves). + self.paper_sheet_wires_arc() } else if inst.handle == acadrust::Handle::NULL { self.entity_wires_arc() } else { @@ -648,13 +653,22 @@ impl Scene { height: visible_h / canvas.1, }; + // Stage 1: the paper sheet instance contributes only entity wires; its + // hatch / wipeout / image fills are still drawn by PaperCanvas beneath + // the shader. (Stage 2 moves those into the shader and drops PaperCanvas.) + let (hatches, wipeout_hatches, images) = if inst.paper_sheet { + (Arc::new(Vec::new()), Arc::new(Vec::new()), Arc::new(Vec::new())) + } else { + (self.hatch_models_arc(), self.wipeout_models_arc(), self.images_arc()) + }; + Some(ViewportData { wires: all_wires, face3d_wires: Arc::new(face3d_wires), draw_depths: self.draw_depth_map(), - hatches: self.hatch_models_arc(), - wipeout_hatches: self.wipeout_models_arc(), - images: self.images_arc(), + hatches, + wipeout_hatches, + images, meshes: self.meshes_arc(), uniforms, cam_rotation: inst.camera.view_rotation_mat(),