diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 92a6bcf9f..422f8cd65 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -39,6 +39,8 @@ const MSAA_SAMPLES: u32 = 4; pub struct Pipeline { wire_pipeline: wgpu::RenderPipeline, + /// Stamps clip-boundary polygons into the stencil buffer (viewports + XCLIP). + clip_mask_pipeline: wgpu::RenderPipeline, /// Black-fragment variant of `wire_pipeline` for 3D mesh outline edges in /// filled render modes. wire_black_pipeline: wgpu::RenderPipeline, @@ -169,6 +171,12 @@ pub struct Pipeline { pub(crate) wire_arena_id: u64, /// Pixel scissor rects [x, y, w, h] for viewport-clipped wires. Recomputed each frame. wire_pixel_scissors: Vec>, + /// This content viewport's non-rectangular clip boundary as a triangle-fan + /// vertex buffer in the render target's normalized device coords (`None` = + /// rectangular / unclipped, where the viewport's own render rectangle does + /// the clipping). Stamped into the stencil once per frame; every content + /// pass then draws with stencil reference 1 so only the interior survives. + clip_boundary: Option<(wgpu::Buffer, u32)>, /// Ghost copies (25% alpha) of selected wires for the X-ray depth pass. gpu_selected_wires: Vec, /// Command-preview / interim / grip-drag overlay wires. Re-uploaded every @@ -350,6 +358,25 @@ impl Pipeline { ))), }); + // Stencil test shared by every paper content pipeline: draw only where + // the stencil equals the bound reference. Non-clipped content binds + // reference 0 (matching the frame's stencil, cleared to 0); a clip + // region's content binds reference 1 after its boundary has been stamped + // into the stencil by the clip-mask pipeline. In Model space nothing + // masks the stencil, so it stays 0 and reference 0 always passes. + let content_stencil_face = wgpu::StencilFaceState { + compare: wgpu::CompareFunction::Equal, + fail_op: wgpu::StencilOperation::Keep, + depth_fail_op: wgpu::StencilOperation::Keep, + pass_op: wgpu::StencilOperation::Keep, + }; + let content_stencil = wgpu::StencilState { + front: content_stencil_face, + back: content_stencil_face, + read_mask: 0xff, + write_mask: 0x00, + }; + let wire_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("wire.pipeline"), layout: Some(&wire_layout), @@ -365,10 +392,10 @@ impl Pipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), + stencil: content_stencil.clone(), bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState { @@ -390,6 +417,85 @@ impl Pipeline { cache: None, }); + // ── Clip-mask pipeline ───────────────────────────────────────────── + // Stamps a viewport / XCLIP boundary polygon into the stencil buffer: + // no colour, no depth, stencil `Invert` (even-odd fill → interior marked + // for any polygon, convex or not). The boundary is drawn as a triangle + // fan in paper coordinates and transformed exactly like wires. + let clip_mask_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("clip_mask.shader"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( + "../../shaders/clip_mask.wgsl" + ))), + }); + let clip_mask_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("clip_mask.pipeline_layout"), + bind_group_layouts: &[], + push_constant_ranges: &[], + }); + let clip_mask_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("clip_mask.pipeline"), + layout: Some(&clip_mask_layout), + vertex: wgpu::VertexState { + module: &clip_mask_shader, + entry_point: Some("vs_main"), + buffers: &[wgpu::VertexBufferLayout { + array_stride: 8, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x2, + }], + }], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::Always, + stencil: wgpu::StencilState { + front: wgpu::StencilFaceState { + compare: wgpu::CompareFunction::Always, + fail_op: wgpu::StencilOperation::Keep, + depth_fail_op: wgpu::StencilOperation::Keep, + pass_op: wgpu::StencilOperation::Invert, + }, + back: wgpu::StencilFaceState { + compare: wgpu::CompareFunction::Always, + fail_op: wgpu::StencilOperation::Keep, + depth_fail_op: wgpu::StencilOperation::Keep, + pass_op: wgpu::StencilOperation::Invert, + }, + read_mask: 0xff, + write_mask: 0xff, + }, + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: MSAA_SAMPLES, + mask: !0, + alpha_to_coverage_enabled: false, + }, + fragment: Some(wgpu::FragmentState { + module: &clip_mask_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::empty(), + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + multiview: None, + cache: None, + }); + // Black variant of `wire_pipeline` — same geometry/depth, black fragment // — for 3D mesh outline edges in filled modes (see the wire draw loop). let wire_black_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { @@ -407,10 +513,10 @@ impl Pipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), + stencil: content_stencil.clone(), bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState { @@ -450,10 +556,10 @@ impl Pipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: false, depth_compare: wgpu::CompareFunction::Always, - stencil: wgpu::StencilState::default(), + stencil: content_stencil.clone(), bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState { @@ -529,10 +635,10 @@ impl Pipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), + stencil: content_stencil.clone(), bias: wgpu::DepthBiasState { constant: 1, slope_scale: 1.0, @@ -596,10 +702,10 @@ impl Pipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), + stencil: content_stencil.clone(), bias: wgpu::DepthBiasState { constant: 1, slope_scale: 1.0, @@ -667,10 +773,10 @@ impl Pipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), + stencil: content_stencil.clone(), bias: wgpu::DepthBiasState { constant: 1, slope_scale: 1.0, @@ -727,10 +833,10 @@ impl Pipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), + stencil: content_stencil.clone(), bias: wgpu::DepthBiasState { constant: 1, slope_scale: 1.0, @@ -776,10 +882,10 @@ impl Pipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: false, depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), + stencil: content_stencil.clone(), bias: wgpu::DepthBiasState { constant: 1, slope_scale: 1.0, @@ -824,10 +930,10 @@ impl Pipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: false, depth_compare: wgpu::CompareFunction::Always, - stencil: wgpu::StencilState::default(), + stencil: content_stencil.clone(), bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState { @@ -872,10 +978,10 @@ impl Pipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), + stencil: content_stencil.clone(), bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState { @@ -919,10 +1025,10 @@ impl Pipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), + stencil: content_stencil.clone(), bias: wgpu::DepthBiasState { constant: 1, slope_scale: 1.0, @@ -977,10 +1083,10 @@ impl Pipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), + stencil: content_stencil.clone(), bias: wgpu::DepthBiasState { constant: 1, slope_scale: 1.0, @@ -1024,10 +1130,10 @@ impl Pipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), + stencil: content_stencil.clone(), bias: wgpu::DepthBiasState { constant: 1, slope_scale: 1.0, @@ -1118,10 +1224,10 @@ impl Pipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), + stencil: content_stencil.clone(), bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState { @@ -1278,6 +1384,7 @@ impl Pipeline { Self { wire_pipeline, + clip_mask_pipeline, wire_black_pipeline, wire_xray_pipeline, #[cfg(not(target_arch = "wasm32"))] @@ -1336,6 +1443,7 @@ impl Pipeline { #[cfg(not(target_arch = "wasm32"))] wire_arena_id: u64::MAX, wire_pixel_scissors: vec![], + clip_boundary: None, gpu_selected_wires: vec![], gpu_preview_wires: vec![], gpu_hatch: None, @@ -1750,6 +1858,37 @@ impl Pipeline { .collect(); } + /// Upload this content viewport's non-rectangular clip boundary as a + /// triangle-fan vertex buffer in render-target NDC. The boundary is already + /// projected (paper shape → the same visible-sub-rect crop as the content), + /// so the mask pipeline stamps it straight into the stencil with `Invert` + /// (even-odd fill → interior marked, any convexity). Empty input clears the + /// boundary so the viewport renders unclipped (its render rectangle clips). + pub fn upload_clip_boundary(&mut self, device: &wgpu::Device, boundary_ndc: &[[f32; 2]]) { + use wgpu::util::DeviceExt; + if boundary_ndc.len() < 3 { + self.clip_boundary = None; + return; + } + // Triangle fan (p0, pi, pi+1). + let mut verts: Vec = Vec::with_capacity((boundary_ndc.len() - 2) * 6); + for i in 1..boundary_ndc.len() - 1 { + for &p in &[boundary_ndc[0], boundary_ndc[i], boundary_ndc[i + 1]] { + verts.extend_from_slice(&[p[0], p[1]]); + } + } + if verts.is_empty() { + self.clip_boundary = None; + return; + } + let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("clip_boundary.vbuf"), + contents: bytemuck::cast_slice(&verts), + usage: wgpu::BufferUsages::VERTEX, + }); + self.clip_boundary = Some((vbuf, (verts.len() / 2) as u32)); + } + /// Per-frame visibility refresh for the batched hatch path. /// Combines Phase 3.3 sub-pixel LOD skip with Phase 2.3 frustum /// cull and pushes the resulting 0/1 mask through to the GPU @@ -2065,6 +2204,13 @@ impl Pipeline { b: b as f64, a: a as f64, }; + // Non-rectangular viewport clip: the boundary is stamped into the + // just-cleared (0x00) stencil with `Invert`, so an odd (interior) + // coverage becomes 0xFF. Every content pass then draws with reference + // 0xFF so only the interior survives. Rectangular / unclipped viewports + // leave the stencil at 0 and draw with reference 0 (the viewport's own + // render rectangle does the clipping). + let stencil_ref: u32 = if self.clip_boundary.is_some() { 0xFF } else { 0 }; // Scene-render cache: when `prepare` found this frame pixel-identical // to the last (unchanged view / geometry / selection / preview), the @@ -2093,13 +2239,26 @@ impl Pipeline { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store, }), - stencil_ops: None, + // Clip stencil starts at 0 (= "unclipped", passes content + // bound to reference 0); clip masks stamp 1 into interiors. + stencil_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(0), + store: wgpu::StoreOp::Store, + }), }), timestamp_writes: None, occlusion_query_set: None, }); // MSAA texture is clip-bounds-sized, so viewport starts at (0, 0). pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); + // Stamp the viewport clip boundary into the just-cleared stencil + // (interior → 1) before any content draws, so every pass below can + // clip to the shape with reference 1. + if let Some((vbuf, vcount)) = &self.clip_boundary { + pass.set_pipeline(&self.clip_mask_pipeline); + pass.set_vertex_buffer(0, vbuf.slice(..)); + pass.draw(0..*vcount, 0..1); + } // Phase 4-B — single batched draw covers every hatch. // Vertex shader culls per-instance via the `visibility` // buffer (sub-pixel LOD + frustum cull written each frame @@ -2113,6 +2272,7 @@ impl Pipeline { // hatch pass dominates the GPU frame on hatch-heavy drawings. if !self.skip_hatch_frame { pass.set_pipeline(pipeline); + pass.set_stencil_reference(stencil_ref); pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_bind_group(1, &batch.bind_group, &[]); pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..)); @@ -2127,6 +2287,7 @@ impl Pipeline { if !self.skip_hatch_frame { pass.set_pipeline(&self.hatch_web_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_stencil_reference(stencil_ref); for hatch in &self.gpu_hatches_web { pass.set_bind_group(1, &hatch.bind_group, &[]); pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..)); @@ -2154,7 +2315,7 @@ impl Pipeline { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }), - stencil_ops: None, + stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }), }), timestamp_writes: None, occlusion_query_set: None, @@ -2162,6 +2323,7 @@ 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, &[]); + pass.set_stencil_reference(stencil_ref); let mut scissor_active = false; for (i, img) in self.gpu_images.iter().enumerate() { match self.image_pixel_scissors.get(i) { @@ -2203,13 +2365,14 @@ impl Pipeline { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }), - stencil_ops: None, + stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }), }), timestamp_writes: None, occlusion_query_set: None, }); pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_stencil_reference(stencil_ref); // Four draw paths share this pass: // - Solid: `mesh_pipeline` + triangle index buf. // - Wireframe: `mesh_wireframe_pipeline` + the @@ -2382,13 +2545,14 @@ impl Pipeline { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }), - stencil_ops: None, + stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }), }), timestamp_writes: None, occlusion_query_set: None, }); pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_stencil_reference(stencil_ref); if !fill.chunks_3d.is_empty() { if hidden_line { pass.set_pipeline(&self.face3d_depth_pipeline); @@ -2431,7 +2595,7 @@ impl Pipeline { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }), - stencil_ops: None, + stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }), }), timestamp_writes: None, occlusion_query_set: None, @@ -2439,6 +2603,7 @@ 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.wire_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_stencil_reference(stencil_ref); for edges in &self.gpu_face3d_edges { if edges.instance_count > 0 { if let Some(bg) = &edges.const_bind_group { @@ -2469,7 +2634,7 @@ impl Pipeline { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }), - stencil_ops: None, + stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }), }), timestamp_writes: None, occlusion_query_set: None, @@ -2484,6 +2649,7 @@ impl Pipeline { let want_solid_with_edges = !hidden_line && !mesh_wireframe && show_3d_edges; let mut scissor_active = false; let mut black_active = false; + pass.set_stencil_reference(stencil_ref); for (i, wire) in self.gpu_wires.iter().enumerate() { if wire.instance_count == 0 { continue; @@ -2498,6 +2664,7 @@ impl Pipeline { continue; } let use_black = want_solid_with_edges && wire.is_3d_mesh_edge; + if use_black != black_active { pass.set_pipeline(if use_black { &self.wire_black_pipeline @@ -2571,7 +2738,7 @@ impl Pipeline { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }), - stencil_ops: None, + stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }), }), timestamp_writes: None, occlusion_query_set: None, @@ -2579,6 +2746,7 @@ 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.text_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_stencil_reference(stencil_ref); pass.set_bind_group(1, &atlas.bind_group, &[]); if let Some(vbuf) = &self.text_vbuf { if self.text_vcount > 0 { @@ -2621,7 +2789,7 @@ impl Pipeline { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }), - stencil_ops: None, + stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }), }), timestamp_writes: None, occlusion_query_set: None, @@ -2629,6 +2797,7 @@ 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.wipeout_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_stencil_reference(stencil_ref); let mut scissor_active = false; for (i, wipeout) in self.gpu_wipeouts.iter().enumerate() { if self.wipeout_skip_flags.get(i).copied().unwrap_or(false) { @@ -2675,7 +2844,7 @@ impl Pipeline { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }), - stencil_ops: None, + stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }), }), timestamp_writes: None, occlusion_query_set: None, @@ -2683,6 +2852,7 @@ 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.wire_xray_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_stencil_reference(stencil_ref); for wire in &self.gpu_selected_wires { if wire.instance_count > 0 { if let Some(bg) = &wire.const_bind_group { @@ -2997,7 +3167,7 @@ fn create_depth_texture(device: &wgpu::Device, size: Size) -> wgpu::Texture mip_level_count: 1, sample_count: MSAA_SAMPLES, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }) diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index c18510eea..3b039e2d2 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -293,7 +293,7 @@ pub fn create_pipeline( ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::LessEqual, stencil: wgpu::StencilState::default(), diff --git a/src/scene/pipeline/viewcube.rs b/src/scene/pipeline/viewcube.rs index f5d0e3c38..2105ea23f 100644 --- a/src/scene/pipeline/viewcube.rs +++ b/src/scene/pipeline/viewcube.rs @@ -1187,7 +1187,7 @@ impl ViewCubePipeline { ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, depth_write_enabled: true, depth_compare: wgpu::CompareFunction::Less, stencil: wgpu::StencilState::default(), @@ -1319,7 +1319,7 @@ fn create_depth_texture(device: &wgpu::Device, size: Size) -> wgpu::Texture mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Depth32Float, + format: wgpu::TextureFormat::Depth24PlusStencil8, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }) diff --git a/src/scene/project.rs b/src/scene/project.rs index 375925f67..0645d74c0 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -347,6 +347,127 @@ impl Scene { result } + + /// Clip regions for every on content viewport in `paper_block`: the paper + /// bounding rect (matching the wires' `vp_scissor`) plus the boundary + /// polygon whose interior the GPU keeps via the stencil buffer. A plain + /// rectangular viewport contributes its four corners; a viewport with a + /// non-rectangular clip (`clip_boundary_handle`) contributes that entity's + /// tessellated outline (circle / ellipse / spline / polyline), reusing the + /// entity's own `to_truck` tessellation. + /// A content viewport's non-rectangular clip boundary, projected into that + /// viewport's render-target normalized device coords. Returns an empty vec + /// for a rectangular viewport (no `clip_boundary_handle`) — those clip via + /// their own render rectangle and need no stencil mask. + /// + /// `uo/vo/us/vs` are the visible-sub-rect crop the content camera applies + /// (see `crop_view_proj`); the same crop is applied here so the boundary + /// lines up with the content even when the viewport hangs off-canvas. + pub(super) fn viewport_clip_boundary_ndc( + &self, + vp_handle: Handle, + uo: f32, + vo: f32, + us: f32, + vs: f32, + ) -> Vec<[f32; 2]> { + use acadrust::entities::Viewport; + let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) else { + return vec![]; + }; + let vp: &Viewport = vp; + if vp.clip_boundary_handle.is_null() { + return vec![]; + } + let pcx = vp.center.x as f32; + let pcy = vp.center.y as f32; + let z = vp.center.z as f32; + let hw = (vp.width / 2.0) as f32; + let hh = (vp.height / 2.0) as f32; + if hw.abs() < 1e-6 || hh.abs() < 1e-6 { + return vec![]; + } + let poly = self.clip_boundary_polygon(vp.clip_boundary_handle, z); + if poly.len() < 3 { + return vec![]; + } + // Paper point → full-rectangle NDC ([-1, 1] across the viewport rect, + // Y up, matching the content camera + `viewport_screen_rect`), then the + // same crop the content camera uses to map its visible sub-rect back to + // NDC [-1, 1]. + let us = us.max(1e-6); + let vs = vs.max(1e-6); + let sx = 1.0 / us; + let sy = 1.0 / vs; + let tx = (1.0 - 2.0 * uo - us) / us; + let ty = -(1.0 - 2.0 * vo - vs) / vs; + poly.iter() + .map(|p| { + let nx = (p[0] - pcx) / hw; + let ny = (p[1] - pcy) / hh; + [sx * nx + tx, sy * ny + ty] + }) + .collect() + } + + /// Tessellated outline of a non-rectangular viewport / XCLIP clip-boundary + /// entity, in paper coordinates, by reusing the entity's own `to_truck` + /// tessellation (Lines). NaN segment breaks are dropped so the result is a + /// single ordered ring suitable for a stencil triangle-fan. + pub(super) fn clip_boundary_polygon(&self, handle: Handle, z: f32) -> Vec<[f32; 3]> { + use std::f64::consts::TAU; + let Some(entity) = self + .document + .entities() + .find(|e| e.common().handle == handle) + else { + return vec![]; + }; + // Circles and ellipses tessellate directly — their `to_truck` returns a + // parametric TruckObject (not `Lines`), so extracting a polygon there + // would come back empty. Everything else (splines, polylines, …) reuses + // the entity's own `Lines` tessellation. + const N: usize = 64; + match entity { + EntityType::Circle(c) => (0..N) + .map(|i| { + let a = i as f64 * TAU / N as f64; + [ + (c.center.x + a.cos() * c.radius) as f32, + (c.center.y + a.sin() * c.radius) as f32, + z, + ] + }) + .collect(), + EntityType::Ellipse(el) => { + // major_axis = center → major endpoint; minor = perp(major) × ratio. + let (mx, my) = (el.major_axis.x, el.major_axis.y); + let r = el.minor_axis_ratio; + (0..N) + .map(|i| { + let t = i as f64 * TAU / N as f64; + let px = mx * t.cos() - my * r * t.sin(); + let py = my * t.cos() + mx * r * t.sin(); + [(el.center.x + px) as f32, (el.center.y + py) as f32, z] + }) + .collect() + } + _ => { + use crate::entities::traits::EntityTypeOps; + let Some(te) = entity.to_truck_entity(&self.document) else { + return vec![]; + }; + if let crate::scene::convert::acad_to_truck::TruckObject::Lines(pts) = te.object { + pts.into_iter() + .filter(|p| p[0].is_finite() && p[1].is_finite()) + .map(|p| [p[0] as f32, p[1] as f32, z]) + .collect() + } else { + vec![] + } + } + } + } } // ── Paper boundary wire ──────────────────────────────────────────────────── diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index e0efb973d..e6c264253 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -37,6 +37,11 @@ pub struct ViewportData { /// reused by a different viewport and reset its (index-addressed) caches. pub(in crate::scene) instance_id: u64, pub(in crate::scene) wires: Arc>, + /// This content viewport's non-rectangular clip boundary (paper layouts + /// only), as a polygon already projected into the viewport's render-target + /// NDC. The GPU stamps it into the stencil so content is clipped to the + /// shape. Empty for rectangular viewports, the paper sheet, and Model space. + pub(in crate::scene) clip_boundary_ndc: Arc>, /// Live command-preview / interim / grip-drag overlay wires. Kept out of /// the main `wires` buffer so a drag re-uploads only this small set each /// frame, never the resident base buffer. Drawn on top in the wire pass. @@ -570,6 +575,7 @@ impl shader::Primitive for Primitive { } else { inner.upload_silhouettes(device, &[], vp.view_dir); } + inner.upload_clip_boundary(device, &vp.clip_boundary_ndc); inner.compute_wire_scissors(view_rot, eye, clip_size.width, clip_size.height); inner.compute_wipeout_scissors(view_rot, eye, clip_size.width, clip_size.height); inner.compute_image_scissors(view_rot, eye, clip_size.width, clip_size.height); @@ -1352,9 +1358,24 @@ impl Scene { } else { 0x3000_0000_0000_0000 | inst.handle.value() }; + // A paper content viewport with a non-rectangular clip entity gets its + // boundary projected into this render target's NDC (paper shape mapped + // through the same visible-sub-rect crop as the content). Rectangular + // viewports, the paper sheet and Model tiles clip via their own render + // rectangle and need no stencil boundary. + let clip_boundary_ndc = if !inst.paper_sheet + && inst.tile_idx.is_none() + && inst.handle != acadrust::Handle::NULL + && self.current_layout != "Model" + { + Arc::new(self.viewport_clip_boundary_ndc(inst.handle, uo, vo, us, vs)) + } else { + Arc::new(vec![]) + }; Some(ViewportData { instance_id, wires: all_wires, + clip_boundary_ndc, preview_wires, face3d_wires, text_verts, diff --git a/src/shaders/clip_mask.wgsl b/src/shaders/clip_mask.wgsl new file mode 100644 index 000000000..1bd994ea4 --- /dev/null +++ b/src/shaders/clip_mask.wgsl @@ -0,0 +1,25 @@ +// Viewport / XCLIP stencil-mask shader. +// +// Draws a clip-boundary polygon (as a triangle fan) into the stencil buffer so +// a paper content viewport can clip its render to an arbitrary shape. It writes +// no colour and no depth — only stencil (the pipeline uses `Invert`, so an +// even-odd fill marks the polygon interior regardless of convexity). +// +// The boundary arrives already projected into the content viewport's normalized +// device coordinates (paper-space shape mapped through the same visible-sub-rect +// crop as the content), so the vertex stage is a pure pass-through — the mask +// is fixed to the viewport frame and never moves with the model camera. + +struct VIn { + @location(0) ndc: vec2, +} + +@vertex fn vs_main(in: VIn) -> @builtin(position) vec4 { + return vec4(in.ndc, 0.0, 1.0); +} + +// Colour is write-masked off in the pipeline; this only satisfies the +// fragment-output requirement. +@fragment fn fs_main() -> @location(0) vec4 { + return vec4(0.0, 0.0, 0.0, 0.0); +}