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 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-02 20:05:51 +03:00
1 parent 568422679d
commit 0ef99ff2fc
4 files changed
+65 -64

No files matched your search

+7 -1
View File
@@ -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);
+38 -4
View File
@@ -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<ViewportInstance> = 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<Vec<WireModel>> {
{
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<WireModel> {
(*self.paper_sheet_wires_arc()).clone()
}
+3 -56
View File
@@ -100,62 +100,9 @@ impl<'a> canvas::Program<Message> 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<f32> = 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()]
}
}
+17 -3
View File
@@ -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(),