Merge pull request #1656 from gianlucafiore/fix/layer-hidden-interaction

Fix small drafting UX regressions affecting workflow speed
This commit is contained in:
gianlucafiore authored and GitHub committed 2026-10-07 20:29:26 -03:00
commit 5318609db0
7 files changed
+335 -62

No files matched your search

+72 -7
View File
@@ -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<Handle>,
) -> (
Vec<Handle>,
rustc_hash::FxHashMap<Handle, Handle>,
@@ -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>,
) -> 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<Handle> = 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"
);
}
}
+5 -1
View File
@@ -977,7 +977,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
// 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<Message> {
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))?;
+1
View File
@@ -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))?;
+1 -1
View File
@@ -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
+47 -3
View File
@@ -9652,12 +9652,28 @@ impl Scene {
}
}
}
let block = self.interaction_block_handle();
let frozen: Option<HashSet<Handle>> = 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<Handle> = 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<HashSet<Handle>> = 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<Handle> = 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;
+104 -49
View File
@@ -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
+105 -1
View File
@@ -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<Vec<DVec3>>,
/// 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<DVec3>,
/// Last snap world position (for dwell detection).
pub last_snap_world: Option<DVec3>,
/// 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<OtrackHit> {
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);