perf(scene): extend viewport scissor to hatch / image / wipeout passes (Phase 1.4)

Wire pass already uses `WireModel.vp_scissor` to clip viewport-projected
geometry to its paper-space viewport rect; hatch / image / wipeout
passes drew across the whole canvas. When a future paper-space path
projects fills/images into a viewport, they would bleed past the
viewport frame.

Add `vp_scissor: Option<[f32; 4]>` to HatchModel and ImageModel
(mirroring WireModel), forward it through HatchGpu / ImageGpu, and add
per-pass `compute_*_scissors` / `set_scissor_rect` machinery. Factored
the wire-side scissor-projection math into a shared `project_scissor`
helper. Field defaults to None at every constructor site — no producer
sets it yet, so this is plumbing for the paper-viewport fill projection
that Phase 1.4 unblocks without changing current visual output.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-15 23:13:16 +03:00
1 parent 9a4531bd8e
commit fbbac423d4
8 files changed
+145 -24

No files matched your search

+3
View File
@@ -119,6 +119,7 @@ impl HatchCommand {
color: [0.75, 0.75, 0.75, 0.85],
angle_offset: 0.0,
scale: 1.0,
vp_scissor: None,
}
}
}
@@ -254,6 +255,7 @@ impl GradientCommand {
color: [0.30, 0.60, 0.95, 0.80],
angle_offset: 0.0,
scale: 1.0,
vp_scissor: None,
}
}
}
@@ -402,6 +404,7 @@ impl CadCommand for BoundaryCommand {
color: [0.45, 0.45, 0.45, 0.60],
angle_offset: 0.0,
scale: 1.0,
vp_scissor: None,
};
return CmdResult::CommitHatch(model);
}
+6
View File
@@ -58,4 +58,10 @@ pub struct HatchModel {
pub angle_offset: f32,
/// Pattern scale multiplier (from DXF `pattern_scale`).
pub scale: f32,
/// Optional world-space XY rect [x0, y0, x1, y1] for paper-space
/// viewport clipping. When `Some`, the pipeline translates it into
/// a per-frame pixel scissor and clips this hatch's draw call to it,
/// preventing viewport-projected content from bleeding past the
/// viewport frame. Mirrors `WireModel.vp_scissor`.
pub vp_scissor: Option<[f32; 4]>,
}
+5
View File
@@ -23,6 +23,10 @@ pub struct ImageModel {
/// [2] origin + U*W + V*H (top-right)
/// [3] origin + V*H (top-left)
pub corners: [[f32; 3]; 4],
/// Optional world-space XY rect [x0, y0, x1, y1] for paper-space
/// viewport clipping. Mirrors `WireModel.vp_scissor` /
/// `HatchModel.vp_scissor`.
pub vp_scissor: Option<[f32; 4]>,
}
impl ImageModel {
@@ -56,6 +60,7 @@ impl ImageModel {
height,
opacity,
corners,
vp_scissor: None,
})
}
}
+4
View File
@@ -2315,6 +2315,7 @@ impl Scene {
color,
angle_offset: 0.0,
scale: 1.0,
vp_scissor: None,
});
}
}
@@ -2367,6 +2368,7 @@ impl Scene {
color: fill_color,
angle_offset: 0.0,
scale: 1.0,
vp_scissor: None,
});
}
}
@@ -2658,6 +2660,7 @@ impl Scene {
color,
angle_offset: dxf.pattern_angle as f32,
scale: dxf.pattern_scale as f32,
vp_scissor: None,
})
}
@@ -2806,6 +2809,7 @@ impl Scene {
color,
angle_offset: 0.0,
scale: 1.0,
vp_scissor: None,
}
}
+5
View File
@@ -102,6 +102,10 @@ pub struct FamilyBatchData {
pub struct HatchGpu {
pub vertex_buffer: wgpu::Buffer,
pub bind_group: wgpu::BindGroup,
/// Mirrors `HatchModel.vp_scissor`. Forwarded to the pipeline so each
/// frame's `compute_hatch_scissors` can translate the world rect into
/// a pixel scissor.
pub vp_scissor: Option<[f32; 4]>,
_uniform_buf: wgpu::Buffer,
_boundary_buf: wgpu::Buffer,
_family_buf: wgpu::Buffer,
@@ -268,6 +272,7 @@ impl HatchGpu {
Self {
vertex_buffer,
bind_group,
vp_scissor: model.vp_scissor,
_uniform_buf,
_boundary_buf,
_family_buf,
+3
View File
@@ -53,6 +53,8 @@ struct ImageParams {
pub struct ImageGpu {
pub vertex_buffer: wgpu::Buffer,
pub bind_group: wgpu::BindGroup,
/// Mirrors `ImageModel.vp_scissor` — see `HatchGpu.vp_scissor`.
pub vp_scissor: Option<[f32; 4]>,
_texture: wgpu::Texture,
_sampler: wgpu::Sampler,
_params_buf: wgpu::Buffer,
@@ -183,6 +185,7 @@ impl ImageGpu {
Some(Self {
vertex_buffer,
bind_group,
vp_scissor: model.vp_scissor,
_texture: texture,
_sampler,
_params_buf,
+116 -24
View File
@@ -57,9 +57,15 @@ pub struct Pipeline {
/// Ghost copies (25% alpha) of selected wires for the X-ray depth pass.
gpu_selected_wires: Vec<WireGpu>,
gpu_hatches: Vec<HatchGpu>,
/// Pixel scissor rects [x, y, w, h] for viewport-clipped hatches. Recomputed each frame.
hatch_pixel_scissors: Vec<Option<[u32; 4]>>,
/// Wipeout fills — rendered after wires in a separate pass.
gpu_wipeouts: Vec<HatchGpu>,
/// Pixel scissor rects [x, y, w, h] for viewport-clipped wipeouts. Recomputed each frame.
wipeout_pixel_scissors: Vec<Option<[u32; 4]>>,
gpu_images: Vec<ImageGpu>,
/// Pixel scissor rects [x, y, w, h] for viewport-clipped images. Recomputed each frame.
image_pixel_scissors: Vec<Option<[u32; 4]>>,
gpu_meshes: Vec<MeshGpu>,
/// Batched 3DFACE fill (all faces in one buffer) and edges (merged wire).
gpu_face3d_fill: Option<Face3DGpu>,
@@ -590,8 +596,11 @@ impl Pipeline {
wire_pixel_scissors: vec![],
gpu_selected_wires: vec![],
gpu_hatches: vec![],
hatch_pixel_scissors: vec![],
gpu_wipeouts: vec![],
wipeout_pixel_scissors: vec![],
gpu_images: vec![],
image_pixel_scissors: vec![],
gpu_meshes: vec![],
gpu_face3d_fill: None,
gpu_face3d_edges: vec![],
@@ -615,30 +624,37 @@ impl Pipeline {
/// Recompute pixel scissor rects for viewport-clipped wires from the current view_proj.
/// Called every frame from prepare() because scissor pixels shift with pan/zoom.
pub fn compute_wire_scissors(&mut self, view_proj: glam::Mat4, clip_w: u32, clip_h: u32) {
let w = clip_w as f32;
let h = clip_h as f32;
self.wire_pixel_scissors = self
.gpu_wires
.iter()
.map(|wire| {
let [x0, y0, x1, y1] = wire.vp_scissor?;
let corners = [
view_proj.project_point3(glam::Vec3::new(x0, y0, 0.0)),
view_proj.project_point3(glam::Vec3::new(x1, y0, 0.0)),
view_proj.project_point3(glam::Vec3::new(x0, y1, 0.0)),
view_proj.project_point3(glam::Vec3::new(x1, y1, 0.0)),
];
let px: Vec<f32> = corners.iter().map(|c| (c.x + 1.0) * 0.5 * w).collect();
let py: Vec<f32> = corners.iter().map(|c| (1.0 - c.y) * 0.5 * h).collect();
let sx0 = px.iter().cloned().fold(f32::INFINITY, f32::min).max(0.0) as u32;
let sy0 = py.iter().cloned().fold(f32::INFINITY, f32::min).max(0.0) as u32;
let sx1 = (px.iter().cloned().fold(f32::NEG_INFINITY, f32::max) as u32).min(clip_w);
let sy1 = (py.iter().cloned().fold(f32::NEG_INFINITY, f32::max) as u32).min(clip_h);
if sx1 <= sx0 || sy1 <= sy0 {
return None;
}
Some([sx0, sy0, sx1 - sx0, sy1 - sy0])
})
.map(|w| project_scissor(w.vp_scissor, view_proj, clip_w, clip_h))
.collect();
}
/// Recompute pixel scissor rects for viewport-clipped hatches.
pub fn compute_hatch_scissors(&mut self, view_proj: glam::Mat4, clip_w: u32, clip_h: u32) {
self.hatch_pixel_scissors = self
.gpu_hatches
.iter()
.map(|h| project_scissor(h.vp_scissor, view_proj, clip_w, clip_h))
.collect();
}
/// Recompute pixel scissor rects for viewport-clipped wipeouts.
pub fn compute_wipeout_scissors(&mut self, view_proj: glam::Mat4, clip_w: u32, clip_h: u32) {
self.wipeout_pixel_scissors = self
.gpu_wipeouts
.iter()
.map(|h| project_scissor(h.vp_scissor, view_proj, clip_w, clip_h))
.collect();
}
/// Recompute pixel scissor rects for viewport-clipped raster images.
pub fn compute_image_scissors(&mut self, view_proj: glam::Mat4, clip_w: u32, clip_h: u32) {
self.image_pixel_scissors = self
.gpu_images
.iter()
.map(|i| project_scissor(i.vp_scissor, view_proj, clip_w, clip_h))
.collect();
}
@@ -749,11 +765,26 @@ impl Pipeline {
if !self.gpu_hatches.is_empty() {
pass.set_pipeline(&self.hatch_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for hatch in &self.gpu_hatches {
let mut scissor_active = false;
for (i, hatch) in self.gpu_hatches.iter().enumerate() {
match self.hatch_pixel_scissors.get(i) {
Some(Some([x, y, w, h])) => {
pass.set_scissor_rect(*x, *y, *w, *h);
scissor_active = true;
}
_ if scissor_active => {
pass.set_scissor_rect(0, 0, vp.width, vp.height);
scissor_active = false;
}
_ => {}
}
pass.set_bind_group(1, &hatch.bind_group, &[]);
pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..));
pass.draw(0..6, 0..1);
}
if scissor_active {
pass.set_scissor_rect(0, 0, vp.width, vp.height);
}
}
}
@@ -784,11 +815,26 @@ impl Pipeline {
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
pass.set_pipeline(&self.image_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for img in &self.gpu_images {
let mut scissor_active = false;
for (i, img) in self.gpu_images.iter().enumerate() {
match self.image_pixel_scissors.get(i) {
Some(Some([x, y, w, h])) => {
pass.set_scissor_rect(*x, *y, *w, *h);
scissor_active = true;
}
_ if scissor_active => {
pass.set_scissor_rect(0, 0, vp.width, vp.height);
scissor_active = false;
}
_ => {}
}
pass.set_bind_group(1, &img.bind_group, &[]);
pass.set_vertex_buffer(0, img.vertex_buffer.slice(..));
pass.draw(0..6, 0..1);
}
if scissor_active {
pass.set_scissor_rect(0, 0, vp.width, vp.height);
}
}
// ── Pass 4: solid meshes ──────────────────────────────────────────
@@ -973,11 +1019,26 @@ impl Pipeline {
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
pass.set_pipeline(&self.hatch_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for wipeout in &self.gpu_wipeouts {
let mut scissor_active = false;
for (i, wipeout) in self.gpu_wipeouts.iter().enumerate() {
match self.wipeout_pixel_scissors.get(i) {
Some(Some([x, y, w, h])) => {
pass.set_scissor_rect(*x, *y, *w, *h);
scissor_active = true;
}
_ if scissor_active => {
pass.set_scissor_rect(0, 0, vp.width, vp.height);
scissor_active = false;
}
_ => {}
}
pass.set_bind_group(1, &wipeout.bind_group, &[]);
pass.set_vertex_buffer(0, wipeout.vertex_buffer.slice(..));
pass.draw(0..6, 0..1);
}
if scissor_active {
pass.set_scissor_rect(0, 0, vp.width, vp.height);
}
}
// ── Pass 7: selected wire overlay pass ───────────────────────────
@@ -1100,6 +1161,37 @@ impl Pipeline {
}
}
/// Project a world-space XY scissor rect through `view_proj` into the four
/// pixel-space corners and return the smallest aligned-rect that bounds them,
/// clamped to the clip viewport. Returns `None` when the rect is missing or
/// the projection collapses (off-screen, behind camera).
fn project_scissor(
rect: Option<[f32; 4]>,
view_proj: glam::Mat4,
clip_w: u32,
clip_h: u32,
) -> Option<[u32; 4]> {
let [x0, y0, x1, y1] = rect?;
let w = clip_w as f32;
let h = clip_h as f32;
let corners = [
view_proj.project_point3(glam::Vec3::new(x0, y0, 0.0)),
view_proj.project_point3(glam::Vec3::new(x1, y0, 0.0)),
view_proj.project_point3(glam::Vec3::new(x0, y1, 0.0)),
view_proj.project_point3(glam::Vec3::new(x1, y1, 0.0)),
];
let px: Vec<f32> = corners.iter().map(|c| (c.x + 1.0) * 0.5 * w).collect();
let py: Vec<f32> = corners.iter().map(|c| (1.0 - c.y) * 0.5 * h).collect();
let sx0 = px.iter().cloned().fold(f32::INFINITY, f32::min).max(0.0) as u32;
let sy0 = py.iter().cloned().fold(f32::INFINITY, f32::min).max(0.0) as u32;
let sx1 = (px.iter().cloned().fold(f32::NEG_INFINITY, f32::max) as u32).min(clip_w);
let sy1 = (py.iter().cloned().fold(f32::NEG_INFINITY, f32::max) as u32).min(clip_h);
if sx1 <= sx0 || sy1 <= sy0 {
return None;
}
Some([sx0, sy0, sx1 - sx0, sy1 - sy0])
}
fn create_depth_texture(device: &wgpu::Device, size: Size<u32>) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("viewer.depth_texture"),
+3
View File
@@ -144,6 +144,9 @@ impl shader::Primitive for Primitive {
pipeline.cached_epoch = cur_key;
}
pipeline.compute_wire_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height);
pipeline.compute_hatch_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height);
pipeline.compute_wipeout_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height);
pipeline.compute_image_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height);
if self.show_viewcube {
pipeline.viewcube.upload(
queue,