From 25166b950825eb935633a841cd52d097e49fadcc Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 28 Jul 2026 20:07:10 +0300 Subject: [PATCH] fix(hatch): preview pattern grip drag --- src/app/update/viewport.rs | 6 +++ src/scene/mod.rs | 5 +++ src/scene/pipeline/mod.rs | 54 ++++++++++++++++++++++++++ src/scene/preview.rs | 55 +++++++++++++++++++++++++++ src/scene/view/render.rs | 77 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 0b05d82a9..9d7b2abfe 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -865,6 +865,12 @@ impl OpenCADStudio { self.tabs[i] .scene .apply_grip(grip.handle, grip.grip_id, apply); + if matches!( + self.tabs[i].scene.document.get_entity(grip.handle), + Some(acadrust::EntityType::Hatch(_)) + ) { + self.tabs[i].scene.set_preview_hatch(grip.handle); + } self.tabs[i].dirty = true; self.tabs[i].active_grip.as_mut().unwrap().last_world = snapped; let apply_ms = apply_started.elapsed().as_secs_f64() * 1000.0; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 2dacb4dd2..dd21f0bea 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1536,6 +1536,10 @@ pub struct Scene { pub selection_filter: HashSet, /// In-progress preview wires while a command is active (rubber-band + object ghosts). pub preview_wires: Vec, + /// Live hatch fill used by interactive edits such as dragging the pattern + /// origin grip. Kept separate from the resident hatch set so moving one + /// pattern never re-uploads every hatch in a large drawing. + pub preview_hatches: Arc>, /// In-progress preview SDF glyph quads (grip drag / command preview). Rides /// a per-frame GPU buffer separate from the epoch-cached base text so the /// dragged text stays visible while it's hidden from the base set (#316). @@ -1911,6 +1915,7 @@ impl Scene { transparency_display: true, selection_filter: HashSet::default(), preview_wires: vec![], + preview_hatches: Arc::new(Vec::new()), preview_text: vec![], interim_wire: None, camera_generation: 0, diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 0c84cc171..3bbbe823d 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -257,6 +257,10 @@ pub struct Pipeline { gpu_hatch: Option, /// Compatibility hatch fills — one texture-backed GPU object per hatch. gpu_hatches_compat: Vec, + /// Tiny live hatch batch used by grip previews. Separate from the resident + /// batch so an interactive edit uploads only the hatch being changed. + gpu_preview_hatch: Option, + gpu_preview_hatches_compat: Vec, /// Wipeout masks — solid fills rendered after wires in a separate pass via /// the legacy per-primitive `WipeoutGpu` renderer. gpu_wipeouts: Vec, @@ -293,6 +297,7 @@ pub struct Pipeline { /// Arc makes pointer identity ABA-safe: an unchanged category reuses the /// same Arc even when an unrelated entity advances `geometry_epoch`. pub cached_hatch_source: Option>>, + pub cached_preview_hatch_source: Option>>, pub cached_wipeout_source: Option>>, pub cached_image_source: Option>>, pub cached_text_source: Option>>, @@ -1827,6 +1832,8 @@ impl Pipeline { gpu_preview_wires: vec![], gpu_hatch: None, gpu_hatches_compat: vec![], + gpu_preview_hatch: None, + gpu_preview_hatches_compat: vec![], gpu_wipeouts: vec![], wipeout_skip_flags: vec![], gpu_images: vec![], @@ -1842,6 +1849,7 @@ impl Pipeline { gpu_face3d_edges: vec![], viewcube, cached_hatch_source: None, + cached_preview_hatch_source: None, cached_wipeout_source: None, cached_image_source: None, cached_text_source: None, @@ -2877,6 +2885,35 @@ impl Pipeline { } } + pub fn upload_preview_hatches( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + hatches: &[HatchModel], + ) { + if let Some(bgl1) = &self.hatch_compat_bgl1 { + self.gpu_preview_hatches_compat = hatches + .iter() + .filter(|h| h.boundary.len() >= 3) + .map(|h| hatch_web_gpu::HatchWebGpu::new(device, queue, h, bgl1)) + .collect(); + self.gpu_preview_hatch = None; + return; + } + + self.gpu_preview_hatches_compat.clear(); + let Some(bgl1) = &self.hatch_bgl1 else { + self.gpu_preview_hatch = None; + return; + }; + let renderable: Vec = hatches + .iter() + .filter(|h| h.boundary.len() >= 3) + .cloned() + .collect(); + self.gpu_preview_hatch = hatch_gpu::HatchGpu::build(device, bgl1, &renderable); + } + pub fn upload_wipeouts(&mut self, device: &wgpu::Device, wipeouts: &[HatchModel]) { self.gpu_wipeouts = wipeouts .iter() @@ -3063,6 +3100,18 @@ impl Pipeline { pass.draw(0..batch.vertex_count, 0..1); } } + if let (Some(batch), Some(pipeline)) = + (&self.gpu_preview_hatch, &self.hatch_pipeline) + { + 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(..)); + pass.draw(0..batch.vertex_count, 0..1); + } + } // Storage-free compatibility path. Draw before wires so outlines // remain on top, matching the batched path. @@ -3076,6 +3125,11 @@ impl Pipeline { pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..)); pass.draw(0..6, 0..1); } + for hatch in &self.gpu_preview_hatches_compat { + pass.set_bind_group(1, &hatch.bind_group, &[]); + pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..)); + pass.draw(0..6, 0..1); + } } } } diff --git a/src/scene/preview.rs b/src/scene/preview.rs index 9084c7692..7c10a4640 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -14,6 +14,58 @@ impl Scene { self.preview_wires = wires; } + /// Publish the current cached hatch as a one-entity live fill overlay. + /// The edited hatch is hidden from the resident set during a grip drag; + /// this keeps its pattern visible without rebuilding the full hatch batch. + pub fn set_preview_hatch(&mut self, handle: Handle) { + let Some(mut model) = self.hatches.get(&handle).cloned() else { + self.preview_hatches = std::sync::Arc::new(Vec::new()); + return; + }; + let Some(entity) = self.document.get_entity(handle) else { + self.preview_hatches = std::sync::Arc::new(Vec::new()); + return; + }; + + if !matches!( + model.pattern, + crate::scene::model::hatch_model::HatchPattern::Gradient { .. } + ) { + model.color = self.render_style(entity).0; + } + if self.selected.contains(&handle) { + model.color = [0.15, 0.55, 1.00, model.color[3]]; + } + model.draw_depth = self + .draw_depth_map() + .get(&handle.value()) + .map_or(0.0, |depth| depth[0]); + + let mut models = Vec::with_capacity(2); + if let EntityType::Hatch(hatch) = entity { + if let Some(background) = crate::entities::hatch::background_color(hatch) { + let mut backdrop = model.clone(); + backdrop.pattern = + crate::scene::model::hatch_model::HatchPattern::Solid; + backdrop.color = match background { + acadrust::types::Color::ByLayer => { + crate::scene::view::render::layer_render_style( + &self.document, + &hatch.common.layer, + ) + .color + } + acadrust::types::Color::ByBlock => self.render_style(entity).0, + other => crate::scene::convert::tess_util::aci_to_rgba(&other), + }; + backdrop.name = "SOLID".into(); + models.push(backdrop); + } + } + models.push(model); + self.preview_hatches = std::sync::Arc::new(models); + } + pub fn set_preview_text(&mut self, verts: Vec) { // Overlay glyphs — same reasoning as `set_preview_wires`: no geometry // bump. Uploaded to a dedicated per-frame text buffer in `prepare`. @@ -25,6 +77,9 @@ impl Scene { // flips the wire content id back to the base tessellation id, which // re-uploads the base wires (without the preview) on the next frame. self.preview_wires = vec![]; + if !self.preview_hatches.is_empty() { + self.preview_hatches = std::sync::Arc::new(Vec::new()); + } self.preview_text = vec![]; self.interim_wire = None; // Drop any point-picked window marquee (STRETCH) so it doesn't linger diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index cda55df62..5ba3770d9 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -52,6 +52,9 @@ pub struct ViewportData { /// 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. pub(in crate::scene) preview_wires: Arc>, + /// One/few live hatch models for grip editing. Uploaded through a separate + /// tiny GPU batch so the resident hatch buffer remains untouched. + pub(in crate::scene) preview_hatches: Arc>, /// 3DFACE entity wires — separated so they are uploaded to the dedicated /// face3d pipeline (fill + batched edges) instead of N individual WireGpu. pub(in crate::scene) face3d_wires: Arc>, @@ -268,6 +271,7 @@ impl shader::Primitive for Primitive { inner.cached_mesh_content_id = u64::MAX; inner.cached_face3d_key = (u64::MAX, false); inner.cached_hatch_source = None; + inner.cached_preview_hatch_source = None; inner.cached_wipeout_source = None; inner.cached_image_source = None; inner.cached_text_source = None; @@ -371,6 +375,23 @@ impl shader::Primitive for Primitive { ); inner.cached_hatch_source = Some(Arc::clone(&vp.hatches)); } + let preview_hatch_changed = inner + .cached_preview_hatch_source + .as_ref() + .map_or(true, |source| !Arc::ptr_eq(source, &vp.preview_hatches)); + if preview_hatch_changed || fill_changed { + inner.upload_preview_hatches( + device, + queue, + if fill_mode { + &vp.preview_hatches[..] + } else { + &[] + }, + ); + inner.cached_preview_hatch_source = + Some(Arc::clone(&vp.preview_hatches)); + } if wipeout_changed || fill_changed { inner.upload_wipeouts( device, @@ -1100,6 +1121,56 @@ fn render_signature(vp: &ViewportData, clip_w: u32, clip_h: u32) -> u64 { p[2].to_bits().hash(&mut h); } } + // Live hatch preview. Pattern-origin grip drags keep the boundary fixed and + // move only the family anchors, so hash both geometry and pattern data. + vp.preview_hatches.len().hash(&mut h); + for model in vp.preview_hatches.iter() { + model.world_origin[0].to_bits().hash(&mut h); + model.world_origin[1].to_bits().hash(&mut h); + model.boundary.len().hash(&mut h); + for point in model.boundary.iter() { + point[0].to_bits().hash(&mut h); + point[1].to_bits().hash(&mut h); + } + for component in model.color { + component.to_bits().hash(&mut h); + } + model.angle_offset.to_bits().hash(&mut h); + model.scale.to_bits().hash(&mut h); + match &model.pattern { + crate::scene::model::hatch_model::HatchPattern::Solid => { + 0_u8.hash(&mut h); + } + crate::scene::model::hatch_model::HatchPattern::Pattern(families) => { + 1_u8.hash(&mut h); + families.len().hash(&mut h); + for family in families { + family.angle_deg.to_bits().hash(&mut h); + family.x0.to_bits().hash(&mut h); + family.y0.to_bits().hash(&mut h); + family.dx.to_bits().hash(&mut h); + family.dy.to_bits().hash(&mut h); + for dash in &family.dashes { + dash.to_bits().hash(&mut h); + } + } + } + crate::scene::model::hatch_model::HatchPattern::Gradient { + angle_deg, + color2, + kind, + invert, + } => { + 2_u8.hash(&mut h); + angle_deg.to_bits().hash(&mut h); + for component in color2 { + component.to_bits().hash(&mut h); + } + kind.shader_kind().hash(&mut h); + invert.hash(&mut h); + } + } + } // Grip-drag / command-preview glyph quads (issue #316). A pure-text slide // leaves `preview_wires` empty and moves ONLY these, so a signature that // ignored them would let the scene-render cache freeze the dragged text at @@ -2015,6 +2086,11 @@ impl Scene { v.extend(self.preview_wires.iter().cloned()); Arc::new(v) }; + let preview_hatches = if show_live_overlay { + Arc::clone(&self.preview_hatches) + } else { + Arc::new(Vec::new()) + }; // Build the camera at the *full* viewport's aspect so the ortho // frustum matches what the viewport entity stores, then post- @@ -2198,6 +2274,7 @@ impl Scene { wires: Arc::downgrade(&all_wires), clip_boundary_ndc, preview_wires, + preview_hatches, face3d_wires, text_verts, preview_text_verts,