forked from 1b-agent/opencadstudio-upstream
Merge pull request #1656 from gianlucafiore/fix/layer-hidden-interaction
Fix small drafting UX regressions affecting workflow speed
This commit is contained in:
7 files changed
+335
-62
No files matched your search
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user