From 7b7aa0d42968b2698f6b7fef4b17f99cd5bd0dd9 Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Wed, 7 Oct 2026 19:22:44 -0300 Subject: [PATCH 1/4] fix: prevent interaction with hidden layers --- src/scene/mod.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index a99f241a0..56f5be5c3 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -9652,12 +9652,28 @@ impl Scene { } } } + let block = self.interaction_block_handle(); + let frozen: Option> = self + .interaction_viewport_frozen_layers() + .map(|layers| layers.iter().copied().collect()); + let annotation_scale_handle = self.displayed_annotation_scale_handle(); + let all_visible = self.annotation_all_visible(); + let changed_live: Vec = changes .iter() .filter_map(|(handle, kind)| { - (!matches!(kind, ChangeKind::Removed) - && !self.entity_temporarily_hidden(*handle) - && self.document.get_entity(*handle).is_some()) + if matches!(kind, ChangeKind::Removed) { + return None; + } + + let entity = self.document.get_entity(*handle)?; + self.resident_entity_visible( + entity, + block, + frozen.as_ref(), + annotation_scale_handle, + all_visible, + ) .then_some(*handle) }) .collect(); @@ -11957,6 +11973,34 @@ vis_index={:.1} visible_probe={:.1}", /// block users re-expand, since their definitions bake the children's /// visibility. Nothing is recoloured. pub fn invalidate_layer_visibility(&mut self, names: &[String]) { + // Turning a layer off or freezing it removes its entities from the + // interactive scene as well as from rendering. Drop any entities that + // were already selected before the visibility change, otherwise a + // subsequent MOVE/ERASE/etc. could still modify invisible geometry. + let block = self.interaction_block_handle(); + let frozen: Option> = self + .interaction_viewport_frozen_layers() + .map(|layers| layers.iter().copied().collect()); + let annotation_scale_handle = self.displayed_annotation_scale_handle(); + let all_visible = self.annotation_all_visible(); + + let visible_selection: Vec = self + .selected_handles_in_order() + .into_iter() + .filter(|handle| { + self.document.get_entity(*handle).is_some_and(|entity| { + self.resident_entity_visible( + entity, + block, + frozen.as_ref(), + annotation_scale_handle, + all_visible, + ) + }) + }) + .collect(); + self.replace_selection_exact(&visible_selection); + let targets = self.dependency_targets(DependencyKind::Layer, names); if targets.render_handles.is_empty() { return; From 6df70538eab572b583cf53dff00b99599700c732 Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Wed, 7 Oct 2026 19:50:47 -0300 Subject: [PATCH 2/4] fix: paste entities into active block editor --- src/app/command_driver/clipboard.rs | 79 ++++++++++++++++++++++++++--- src/modules/insert/wblock.rs | 2 +- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/app/command_driver/clipboard.rs b/src/app/command_driver/clipboard.rs index 74738f0f7..151c683ae 100644 --- a/src/app/command_driver/clipboard.rs +++ b/src/app/command_driver/clipboard.rs @@ -308,11 +308,13 @@ impl OpenCADStudio { // populate_missing_meshes_from_document / bumps … CALLER-SIDE let clipboard = self.clipboard.clone(); let deps = self.clipboard_deps.clone(); + let owner_override = self.tabs[i].scene.block_edit_block; let (by_index, handle_map) = paste_entities_kernel( &mut self.tabs[i].scene.document, &clipboard, &deps, translate.as_ref(), + owner_override, ); // Batched equivalent of `add_entity`'s per-add bump (same pattern as // `Scene::add_entities`): fresh top-level solids tessellate once here @@ -477,6 +479,7 @@ pub(crate) fn paste_entities_kernel( entities: &[codec::EntityType], deps: &crate::app::ClipboardDeps, translate: Option<&crate::command::EntityTransform>, + owner_override: Option, ) -> ( Vec, rustc_hash::FxHashMap, @@ -517,7 +520,7 @@ pub(crate) fn paste_entities_kernel( } } } - add_entity_clone_doc(doc, entity) + add_entity_clone_doc(doc, entity, owner_override) }) .collect(); @@ -583,7 +586,11 @@ pub(crate) fn paste_entities_kernel( /// two lines are mirrored here), NULL top-level/owner handles, then /// `doc.add_entity`. The `entity_mode` default (Model vs paper space) and all /// tessellation/render caching stay caller-side in the Scene shell. -fn add_entity_clone_doc(doc: &mut codec::CadDocument, mut entity: codec::EntityType) -> Handle { +fn add_entity_clone_doc( + doc: &mut codec::CadDocument, + mut entity: codec::EntityType, + owner_override: Option, +) -> Handle { match &mut entity { codec::EntityType::Insert(ins) => { for att in ins.attributes.iter_mut() { @@ -598,7 +605,7 @@ fn add_entity_clone_doc(doc: &mut codec::CadDocument, mut entity: codec::EntityT _ => {} } entity.common_mut().handle = Handle::NULL; - entity.common_mut().owner_handle = Handle::NULL; + entity.common_mut().owner_handle = owner_override.unwrap_or(Handle::NULL); doc.add_entity(entity).unwrap_or(Handle::NULL) } @@ -1154,7 +1161,7 @@ mod paste_entities_kernel_tests { let delta = glam::DVec3::new(5.0, 7.0, 0.0); let translate = crate::command::EntityTransform::Translate(delta); let (handles, handle_map) = - paste_entities_kernel(&mut dst, &entities, &deps, Some(&translate)); + paste_entities_kernel(&mut dst, &entities, &deps, Some(&translate), None); assert_eq!(handles.len(), 1); let pasted = handles[0]; @@ -1187,7 +1194,7 @@ mod paste_entities_kernel_tests { let (entities, deps) = copy_to_clipboard_kernel(&seed, &[leader_handle, note]); let mut dst = codec::CadDocument::new(); - let (handles, _) = paste_entities_kernel(&mut dst, &entities, &deps, None); + let (handles, _) = paste_entities_kernel(&mut dst, &entities, &deps, None, None); assert_eq!(handles.len(), 2); let pasted_leader = match dst.get_entity(handles[0]) { @@ -1230,7 +1237,7 @@ mod paste_entities_kernel_tests { let mut dst = codec::CadDocument::new(); let before: Vec = dst.objects.keys().copied().collect(); - let (handles, _) = paste_entities_kernel(&mut dst, &entities, &deps, None); + let (handles, _) = paste_entities_kernel(&mut dst, &entities, &deps, None, None); assert_eq!(handles.len(), 1); let pasted = handles[0]; @@ -1296,7 +1303,7 @@ mod headless_reuse_proof_tests { let translate = crate::command::EntityTransform::Translate(glam::DVec3::new(5.0, 7.0, 0.0)); let (handles, handle_map) = - paste_entities_kernel(&mut doc_b, &entities, &deps, Some(&translate)); + paste_entities_kernel(&mut doc_b, &entities, &deps, Some(&translate), None); assert_eq!(handles.len(), 1); let pasted = handles[0]; @@ -1352,4 +1359,62 @@ mod headless_reuse_proof_tests { other => panic!("expected matched Line, got {other:?}"), } } + #[test] + fn paste_owner_override_routes_entity_into_block_record() { + let mut source = codec::CadDocument::new(); + + let line = codec::entities::Line::from_points( + codec::types::Vector3::new(0.0, 0.0, 0.0), + codec::types::Vector3::new(10.0, 0.0, 0.0), + ); + let source_handle = source + .add_entity(codec::EntityType::Line(line)) + .expect("source line"); + + let (entities, deps) = + copy_to_clipboard_kernel(&source, &[source_handle]); + + let mut target = codec::CadDocument::new(); + + let block_handle = target.allocate_handle(); + let mut block = codec::tables::BlockRecord::new("BEDIT_TARGET"); + block.handle = block_handle; + target + .block_records + .add(block) + .expect("target block record"); + + let (handles, _) = paste_entities_kernel( + &mut target, + &entities, + &deps, + None, + Some(block_handle), + ); + + assert_eq!(handles.len(), 1); + let pasted = handles[0]; + assert!(!pasted.is_null()); + + let entity = target + .get_entity(pasted) + .expect("pasted entity must exist"); + + assert_eq!( + entity.common().owner_handle, + block_handle, + "paste inside BEDIT must belong to the edited block record" + ); + + let record = target + .block_records + .get("BEDIT_TARGET") + .expect("block record must still exist"); + + assert!( + record.entity_handles.contains(&pasted), + "pasted entity must be registered as a member of the edited block" + ); + } + } diff --git a/src/modules/insert/wblock.rs b/src/modules/insert/wblock.rs index 74aa8207d..f325cbd31 100644 --- a/src/modules/insert/wblock.rs +++ b/src/modules/insert/wblock.rs @@ -114,7 +114,7 @@ fn copy_with_dependencies( }) .collect(); let (entities, deps) = crate::app::copy_to_clipboard_kernel(src, &handles); - crate::app::paste_entities_kernel(out, &entities, &deps, translate); + crate::app::paste_entities_kernel(out, &entities, &deps, translate, None); } /// Translate every entity of `out` so the overall bounds minimum lands on From 99fff30c535de2fc6d5165bd51a9e26e13a98b1d Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Wed, 7 Oct 2026 20:02:09 -0300 Subject: [PATCH 3/4] fix: preserve perpendicular tracking after typed distance --- src/app/update/command.rs | 6 ++- src/app/update/dynamic.rs | 1 + src/snap.rs | 106 +++++++++++++++++++++++++++++++++++++- 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 3a2b0e6d6..96d4c6ea9 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -977,7 +977,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // A tracking ray carries its own base, so it needs no anchor: that is // how OTRACK and Extension accepted a bare distance for the *first* // point of a command, before any anchor exists. - let pt = if let Some((base, dir)) = self.active_distance_ray(i) { + let distance_ray = self.active_distance_ray(i); + let pt = if let Some((base, dir)) = distance_ray { base + dir * dist } else { let anchor = self.tabs[i] @@ -1010,6 +1011,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.dyn_coord_absolute = false; self.sync_dyn_fields(); self.reset_tracking_after_point(); + if let Some((_, dir)) = distance_ray { + self.snapper.remember_distance_reference(dir); + } self.push_ucs_to_cmd(i); let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt))?; diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 8ac3e1241..c9bf42f23 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -679,6 +679,7 @@ impl OpenCADStudio { self.dyn_coord_absolute = false; self.sync_dyn_fields(); self.reset_tracking_after_point(); + self.snapper.remember_distance_reference(dir); self.push_ucs_to_cmd(i); let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt)); let task = result.map(|r| self.apply_cmd_result(r))?; diff --git a/src/snap.rs b/src/snap.rs index ab59a5f59..78d11b6ac 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -237,6 +237,11 @@ pub struct Snapper { /// consumed by Extension snap and extended-intersection logic. These rays /// belong only to OTRACK when Perpendicular OSNAP is enabled. (#695) pub tracking_perp_dirs: Vec>, + /// Perpendicular direction carried from a point placed by typed distance + /// along an Extension/OTRACK ray. It survives only until the next point + /// commit, so the source ray can still drive a perpendicular construction + /// through the newly placed point. + post_distance_perp_dir: Option, /// Last snap world position (for dwell detection). pub last_snap_world: Option, /// When the cursor first rested near `last_snap_world`. @@ -301,6 +306,7 @@ impl Default for Snapper { tracking_points: Vec::new(), tracking_dirs: Vec::new(), tracking_perp_dirs: Vec::new(), + post_distance_perp_dir: None, last_snap_world: None, dwell_since: None, dwell_acquired: false, @@ -751,7 +757,18 @@ impl Snapper { ucs_x: DVec3, ucs_y: DVec3, ) -> Option { - if !self.otrack_enabled || self.tracking_points.is_empty() { + // A point created by typed distance along Extension/OTRACK carries + // that source direction for exactly one following point. Perpendicular + // OSNAP may use it even when F11 tracking itself is off. + let carried_perp = if self.is_on(SnapType::Perpendicular) { + self.post_distance_perp_dir.zip(last_point) + } else { + None + }; + + if (!self.otrack_enabled || self.tracking_points.is_empty()) + && carried_perp.is_none() + { return None; } @@ -861,6 +878,18 @@ impl Snapper { } } } + // Typed distance along an Extension/OTRACK ray creates a new point + // that still knows the direction it came from. Offer exactly one + // perpendicular ray through that new point for the following pick. + if let Some((dir, origin)) = carried_perp { + rays.push(Ray { + origin, + dir, + group: self.tracking_points.len(), + kind: TrackingKind::Perpendicular, + }); + } + // OTRACK rays come first; the auxiliary rays appended below (polar from // last_point, ortho axis from last_point) only participate in // intersection locking, never in single-ray fallback. @@ -1006,11 +1035,21 @@ impl Snapper { best.map(|(_, h)| h) } + /// Carry the direction of a reference ray used by typed-distance entry + /// into the next point, as its perpendicular construction direction. + pub fn remember_distance_reference(&mut self, dir: DVec3) { + let planar = DVec3::new(dir.x, dir.y, 0.0); + self.post_distance_perp_dir = planar + .try_normalize() + .map(|unit| DVec3::new(-unit.y, unit.x, 0.0)); + } + /// Clear all acquired tracking points (e.g. when command ends). pub fn clear_tracking(&mut self) { self.tracking_points.clear(); self.tracking_dirs.clear(); self.tracking_perp_dirs.clear(); + self.post_distance_perp_dir = None; self.parallel_ref = None; self.parallel_dwell = None; self.last_snap_world = None; @@ -1152,6 +1191,7 @@ impl Snapper { tracking_points: Vec::new(), tracking_dirs: Vec::new(), tracking_perp_dirs: Vec::new(), + post_distance_perp_dir: None, last_snap_world: None, dwell_since: None, dwell_acquired: false, @@ -4165,6 +4205,70 @@ mod ext_tests { ); } + #[test] + fn typed_distance_reference_offers_perpendicular_for_next_point() { + let mut s = Snapper::default(); + s.enabled.insert(SnapType::Perpendicular); + // This must not depend on F11: the carried construction belongs to + // Perpendicular OSNAP itself. + s.otrack_enabled = false; + s.osnap_radius_px = 10.0; + + // The first point was placed along a horizontal Extension ray. + s.remember_distance_reference(DVec3::X); + let base = DVec3::new(2_000.0, 3_000.0, 0.0); + + let view_rot = Mat4::from_scale(Vec3::splat(0.0001)); + let eye = DVec3::ZERO; + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 1000.0, + height: 1000.0, + }; + + // Pull almost vertically from the newly placed point. The preserved + // horizontal reference must produce a vertical perpendicular guide. + let cursor = base + DVec3::Y * 5_000.0 + DVec3::X * 0.05; + let hit = s + .otrack_snap( + cursor, + view_rot, + eye, + bounds, + None, + Some(base), + None, + false, + DVec3::X, + DVec3::Y, + ) + .expect("carried Extension direction should offer perpendicular"); + + assert_eq!(hit.kind, TrackingKind::Perpendicular); + assert!((hit.base - base).length() < 1e-12); + assert!((hit.aligned.x - base.x).abs() < 1e-12); + assert!((hit.aligned.y - (base.y + 5_000.0)).abs() < 1e-9); + + // It is deliberately one-step state: the next accepted point clears it. + s.clear_tracking(); + assert!( + s.otrack_snap( + cursor, + view_rot, + eye, + bounds, + None, + Some(base), + None, + false, + DVec3::X, + DVec3::Y, + ) + .is_none() + ); + } + #[test] fn tangent_points_are_perpendicular_to_the_radius() { let c = Vec3::new(0.0, 0.0, 0.0); From 3ecf1951c013762a1b37240d01be12abca007a1d Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Wed, 7 Oct 2026 20:14:08 -0300 Subject: [PATCH 4/4] fix: render command previews above wipeouts --- src/scene/pipeline/mod.rs | 153 ++++++++++++++++++++++++++------------ 1 file changed, 104 insertions(+), 49 deletions(-) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 772bfe134..91ea7a2c9 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -4818,44 +4818,6 @@ analytic={:.1} regular={:.1} blocks={:.1}", } } } - // Live overlay wires (command preview / interim / grip drag) always - // on top: the xray pipeline (depth_compare=Always, no depth write) - // keeps them visible through any occluding geometry — a 3D solid, or - // 2D geometry drawn in front — so a command preview is never hidden. - // No scissor. - if self.gpu_preview_wires.iter().any(|pw| pw.instance_count > 0) { - pass.set_pipeline(&self.wire_xray_pipeline); - for pw in &self.gpu_preview_wires { - if pw.instance_count > 0 { - if let Some(bg) = &pw.const_bind_group { - pass.set_bind_group(1, bg.as_ref(), &[]); - } - pass.set_vertex_buffer(0, pw.instance_buffer.slice(..)); - pass.draw( - 0..6, - pw.first_instance..pw.first_instance + pw.instance_count, - ); - } - } - } - if self.gpu_preview_circles.iter().any(|cg| cg.instance_count > 0) { - pass.set_pipeline(&self.circle_xray_pipeline); - for cg in &self.gpu_preview_circles { - if cg.instance_count > 0 { - pass.set_vertex_buffer(0, cg.instance_buffer.slice(..)); - pass.draw(0..6, 0..cg.instance_count); - } - } - } - if self.gpu_preview_ellipses.iter().any(|eg| eg.instance_count > 0) { - pass.set_pipeline(&self.ellipse_xray_pipeline); - for eg in &self.gpu_preview_ellipses { - if eg.instance_count > 0 { - pass.set_vertex_buffer(0, eg.instance_buffer.slice(..)); - pass.draw(0..6, 0..eg.instance_count); - } - } - } } // ── Pass 5c: SDF text quads (drawn over wires) ──────────────────── @@ -4864,9 +4826,7 @@ analytic={:.1} regular={:.1} blocks={:.1}", if let Some(atlas) = &self.text_atlas_gpu { let have_base = !self.text_gpu.is_empty(); let have_blocks = !self.block_text_gpu.is_empty(); - let have_preview = - !self.text_preview_gpu.is_empty(); - if have_base || have_blocks || have_preview { + if have_base || have_blocks { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("text.render_pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -4907,14 +4867,6 @@ analytic={:.1} regular={:.1} blocks={:.1}", pass.draw(0..text.vertex_count, 0..text.instance_count); } } - // Grip-drag / command-preview glyphs, drawn over the base text. - if have_preview { - pass.set_pipeline(&self.text_pipeline); - for text in &self.text_preview_gpu { - pass.set_vertex_buffer(0, text.vertex_buffer.slice(..)); - pass.draw(0..text.vertex_count, 0..1); - } - } } } @@ -4958,6 +4910,109 @@ analytic={:.1} regular={:.1} blocks={:.1}", } } + // ── Pass 6b: live command/grip preview overlay ──────────────────── + // Wipeouts intentionally mask resident drawing geometry, but they must + // never mask temporary construction geometry belonging to an active + // command or grip edit. Draw those previews after the wipeout pass. + // + // The xray variants keep previews independent of resident depth while + // the shared content stencil still respects viewport/XCLIP clipping. + let have_preview_overlay = + self.gpu_preview_wires.iter().any(|wire| wire.instance_count > 0) + || self.gpu_preview_circles.iter().any(|circle| circle.instance_count > 0) + || self.gpu_preview_ellipses.iter().any(|ellipse| ellipse.instance_count > 0) + || !self.text_preview_gpu.is_empty(); + + if have_preview_overlay { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("preview_overlay.render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: msaa, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + + pass.set_viewport( + raster.x, + raster.y, + raster.width, + raster.height, + 0.0, + 1.0, + ); + pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_stencil_reference(stencil_ref); + + if self.gpu_preview_wires.iter().any(|wire| wire.instance_count > 0) { + pass.set_pipeline(&self.wire_xray_pipeline); + for wire in &self.gpu_preview_wires { + if wire.instance_count == 0 { + continue; + } + if let Some(bg) = &wire.const_bind_group { + pass.set_bind_group(1, bg.as_ref(), &[]); + } + pass.set_vertex_buffer(0, wire.instance_buffer.slice(..)); + pass.draw( + 0..6, + wire.first_instance..wire.first_instance + wire.instance_count, + ); + } + } + + if self.gpu_preview_circles.iter().any(|circle| circle.instance_count > 0) { + pass.set_pipeline(&self.circle_xray_pipeline); + for circle in &self.gpu_preview_circles { + if circle.instance_count > 0 { + pass.set_vertex_buffer(0, circle.instance_buffer.slice(..)); + pass.draw(0..6, 0..circle.instance_count); + } + } + } + + if self.gpu_preview_ellipses.iter().any(|ellipse| ellipse.instance_count > 0) { + pass.set_pipeline(&self.ellipse_xray_pipeline); + for ellipse in &self.gpu_preview_ellipses { + if ellipse.instance_count > 0 { + pass.set_vertex_buffer(0, ellipse.instance_buffer.slice(..)); + pass.draw(0..6, 0..ellipse.instance_count); + } + } + } + + if let Some(atlas) = &self.text_atlas_gpu { + if !self.text_preview_gpu.is_empty() { + // Same glyph shader as ordinary text, but depth-independent: + // previews are temporary UI geometry, not resident drawing. + pass.set_pipeline(&self.text_highlight_pipeline); + pass.set_bind_group(1, &atlas.bind_group, &[]); + for text in &self.text_preview_gpu { + pass.set_vertex_buffer(0, text.vertex_buffer.slice(..)); + pass.draw(0..text.vertex_count, 0..1); + } + } + } + } + // ── Pass 7: selection overlay pass ─────────────────────────────── // Redraws selected wires and text with depth_compare=Always so both // appear on top of all other geometry at full brightness — unless