diff --git a/src/modules/home/draw/hatch.rs b/src/modules/home/draw/hatch.rs index aed204280..0e861bafe 100644 --- a/src/modules/home/draw/hatch.rs +++ b/src/modules/home/draw/hatch.rs @@ -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); } diff --git a/src/scene/hatch_model.rs b/src/scene/hatch_model.rs index dfbbadb25..16ea149ca 100644 --- a/src/scene/hatch_model.rs +++ b/src/scene/hatch_model.rs @@ -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]>, } diff --git a/src/scene/image_model.rs b/src/scene/image_model.rs index 86a01e483..c84d36124 100644 --- a/src/scene/image_model.rs +++ b/src/scene/image_model.rs @@ -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, }) } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 36a8a7dd1..3d4df4cbb 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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, } } diff --git a/src/scene/pipeline/hatch_gpu.rs b/src/scene/pipeline/hatch_gpu.rs index d0b766e0f..1f2e045da 100644 --- a/src/scene/pipeline/hatch_gpu.rs +++ b/src/scene/pipeline/hatch_gpu.rs @@ -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, diff --git a/src/scene/pipeline/image_gpu.rs b/src/scene/pipeline/image_gpu.rs index 37ba0149d..1fb28dc06 100644 --- a/src/scene/pipeline/image_gpu.rs +++ b/src/scene/pipeline/image_gpu.rs @@ -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, diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index ff33fe8ff..295a6214d 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -57,9 +57,15 @@ pub struct Pipeline { /// Ghost copies (25% alpha) of selected wires for the X-ray depth pass. gpu_selected_wires: Vec, gpu_hatches: Vec, + /// Pixel scissor rects [x, y, w, h] for viewport-clipped hatches. Recomputed each frame. + hatch_pixel_scissors: Vec>, /// Wipeout fills — rendered after wires in a separate pass. gpu_wipeouts: Vec, + /// Pixel scissor rects [x, y, w, h] for viewport-clipped wipeouts. Recomputed each frame. + wipeout_pixel_scissors: Vec>, gpu_images: Vec, + /// Pixel scissor rects [x, y, w, h] for viewport-clipped images. Recomputed each frame. + image_pixel_scissors: Vec>, gpu_meshes: Vec, /// Batched 3DFACE fill (all faces in one buffer) and edges (merged wire). gpu_face3d_fill: Option, @@ -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 = corners.iter().map(|c| (c.x + 1.0) * 0.5 * w).collect(); - let py: Vec = 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 = corners.iter().map(|c| (c.x + 1.0) * 0.5 * w).collect(); + let py: Vec = 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) -> wgpu::Texture { device.create_texture(&wgpu::TextureDescriptor { label: Some("viewer.depth_texture"), diff --git a/src/scene/render.rs b/src/scene/render.rs index c132465f8..86cc10613 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -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,