From c0b254950f93b35baff05a6327b4ea7085bff673 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:44:10 +0300 Subject: [PATCH 1/3] Restore editable extrusion surface grips --- src/app/command_driver.rs | 34 ++++++++++++++++++++++++++++---- src/app/model_ops.rs | 18 +++++++++++++++++ src/scene/model/solid_history.rs | 5 ++++- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 11b618695..1f1156798 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3756,10 +3756,36 @@ impl OpenCADStudio { let direction = direction .or(path_direction) .unwrap_or(glam::DVec3::ZERO); - self.add_surface_model( - empty_extruded_surface(direction, taper_angle), - body, - ) + let history = path + .is_none() + .then(|| { + sweep_model::extrusion_history( + &entity, + None, + direction.to_array(), + taper_angle, + profile.plane.origin, + ) + }) + .flatten(); + let mut surface = empty_extruded_surface(direction, taper_angle); + if let ( + Some(acadrust::objects::SolidHistoryOperation::Extrusion(value)), + acadrust::EntityType::Surface(entity), + ) = (&history, &mut surface) + { + if let Some(data) = + crate::scene::model::solid_history::extrusion_surface_data(value) + { + entity.surface_data = data; + } + } + match history { + Some(history) => { + self.add_surface_model_with_history(surface, body, history) + } + None => self.add_surface_model(surface, body), + } } else { let direction = direction.unwrap_or(glam::DVec3::ZERO); let history = path diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 4bb5d2041..3d8b57541 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -402,6 +402,24 @@ impl super::OpenCADStudio { self.add_surface_model_inner(entity, surface, true) } + pub(super) fn add_surface_model_with_history( + &mut self, + entity: EntityType, + surface: Body, + history: SolidHistoryOperation, + ) -> Handle { + let i = self.active_tab; + let handle = self.add_surface_model_inner(entity, surface, true); + if handle.is_null() { + return handle; + } + if !self.tabs[i].scene.create_solid_history(handle, history) { + self.tabs[i].scene.rollback_new_entities(&[handle]); + return Handle::NULL; + } + handle + } + fn add_surface_model_preserving_style( &mut self, entity: EntityType, diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index f9cc88bb6..a11274ea4 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -3307,6 +3307,7 @@ pub fn primitive_grips( if let SolidHistoryOperation::Chamfer(value) = operation { return chamfer_distance_grips(document, handle, value); } + let is_surface = matches!(document.get_entity(handle), Some(EntityType::Surface(_))); let mut grips = Vec::new(); let mut add = |id, transform, point, shape, axis: Option<[f64; 3]>| { if let Some(world) = world_point(transform, point) { @@ -3528,7 +3529,9 @@ pub fn primitive_grips( } SolidHistoryOperation::Extrusion(value) => { let (profile_grips, profile_center) = extrusion_profile_grips(value); - grips.extend(profile_grips); + if !is_surface { + grips.extend(profile_grips); + } let direction = [value.direction.x, value.direction.y, value.direction.z]; if value.path_entity.is_none() { if let (Some(center), Some(base)) = (profile_center, matrix(value.base.transform)) { From a24d1e610887fe64a5be47c312891b10d43367c4 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:53:48 +0300 Subject: [PATCH 2/3] Fix live extrusion surface grip editing --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ocs_doc_api/Cargo.toml | 2 +- src/app/update/viewport.rs | 53 +++++++++++++++++++++++++------ src/modules/model/edge_cmd.rs | 2 +- src/scene/convert/acis_kernel.rs | 4 ++- src/scene/convert/solid3d_tess.rs | 8 +++++ src/scene/mod.rs | 5 +++ src/scene/model/solid_history.rs | 19 ++++++++--- src/scene/model/solid_model.rs | 41 +++++++++++++++++++----- src/scene/modify.rs | 10 +++++- 11 files changed, 121 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 10aaeed9d..d77046e7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dfd0de176a878c20a1aeb8d23b0b8265197b5f5c#dfd0de176a878c20a1aeb8d23b0b8265197b5f5c" +source = "git+https://github.com/ramox81/cadkernel.git?rev=c584d19dc45b3bef5146d6cf2e3dae79b72e9468#c584d19dc45b3bef5146d6cf2e3dae79b72e9468" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 49870b58e..f7a8295a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dfd0de176a878c20a1aeb8d23b0b8265197b5f5c", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "c584d19dc45b3bef5146d6cf2e3dae79b72e9468", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 592effe98..4c902ed91 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -15,7 +15,7 @@ thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dfd0de176a878c20a1aeb8d23b0b8265197b5f5c", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "c584d19dc45b3bef5146d6cf2e3dae79b72e9468", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index df4c7a11d..96eeff041 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1535,15 +1535,30 @@ impl OpenCADStudio { } if let Some(axis) = grip.axis { - snapped = cursor_on_projected_axis( - p, - bounds, - view_rot, - eye, - grip.origin_world, - axis, - ) - .unwrap_or(snapped); + let acquired_point = otrack_hit.is_some() + || snap_hit.is_some_and(|hit| { + hit.snap_type != crate::snap::SnapType::Grid + }); + if acquired_point { + if let Some(axis) = axis.try_normalize() { + // An axis grip still moves on its construction axis, but + // an acquired object point supplies the exact coordinate + // along that axis. This lets height and draft arrows seat + // back onto existing surface/solid geometry. + snapped = grip.origin_world + + axis * (snapped - grip.origin_world).dot(axis); + } + } else { + snapped = cursor_on_projected_axis( + p, + bounds, + view_rot, + eye, + grip.origin_world, + axis, + ) + .unwrap_or(snapped); + } } let snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0; @@ -1751,6 +1766,26 @@ impl OpenCADStudio { let preview_ms = preview_started.elapsed().as_secs_f64() * 1000.0; let geometry_ms = grip_started.elapsed().as_secs_f64() * 1000.0; self.refresh_selected_grips(); + // A history rebuild can clamp a requested value or move a derived + // construction handle non-linearly. Rebase each active target onto + // the freshly rebuilt grip so the hot arrow remains attached to the + // live surface and reverses immediately from a geometric limit. + let refreshed_targets: Vec<_> = self.tabs[i] + .selected_grip_handles + .iter() + .copied() + .zip(self.tabs[i].selected_grips.iter()) + .map(|(handle, grip)| ((handle, grip.id), grip.world)) + .collect(); + if let Some(active) = self.tabs[i].active_grip.as_mut() { + for target in &mut active.targets { + if let Some((_, world)) = refreshed_targets.iter().find(|((handle, id), _)| { + *handle == target.handle && *id == target.grip_id + }) { + target.last_world = *world; + } + } + } let grips_ms = grip_started.elapsed().as_secs_f64() * 1000.0 - geometry_ms; // Properties are refreshed when the grip is committed or cancelled. // Rebuilding the inspector on every pointer event adds no drawing diff --git a/src/modules/model/edge_cmd.rs b/src/modules/model/edge_cmd.rs index ae7cb1303..59a8a9c4b 100644 --- a/src/modules/model/edge_cmd.rs +++ b/src/modules/model/edge_cmd.rs @@ -210,7 +210,7 @@ impl SolidEdgeCommand { ) .ok()?, }; - let mut wires = crate::scene::model::solid_model::grip_preview_wires(&result, handle); + let mut wires = crate::scene::model::solid_model::grip_preview_wires(&result, handle, 0); for wire in &mut wires { wire.color = self.preview_color; wire.name = format!("{}-{}-PREVIEW", handle.value(), self.name()); diff --git a/src/scene/convert/acis_kernel.rs b/src/scene/convert/acis_kernel.rs index c21c69364..4edc63268 100644 --- a/src/scene/convert/acis_kernel.rs +++ b/src/scene/convert/acis_kernel.rs @@ -25,6 +25,7 @@ pub fn tessellate_sat( facet_res: f64, chordal_deflection: Option, isolines: usize, + planar_isolines: bool, ) -> Option { let (bodies, loss) = lift(document); if bodies.is_empty() { @@ -114,7 +115,8 @@ pub fn tessellate_sat( max_angle, source_fit * placement_scale, ) - .with_isolines(isolines); + .with_isolines(isolines) + .with_planar_isolines(planar_isolines); if let Some(deflection) = chordal_deflection { tolerance = tolerance.with_chordal_deflection(deflection); } diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 598627e8c..3015b4a7f 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -11,6 +11,7 @@ fn tessellate_acis( facet_res: f64, chordal_deflection: Option, isolines: usize, + planar_isolines: bool, ) -> Option { crate::scene::convert::acis_kernel::tessellate_sat( sat, @@ -19,6 +20,7 @@ fn tessellate_acis( facet_res, chordal_deflection, isolines, + planar_isolines, ) } @@ -219,6 +221,7 @@ fn finish( facet_res: f64, chordal_deflection: Option, isolines: usize, + planar_isolines: bool, acis: &acadrust::entities::AcisData, ) -> Option { let mut set = tessellate_acis( @@ -228,6 +231,7 @@ fn finish( facet_res, chordal_deflection, isolines, + planar_isolines, )?; remap_acis_material_bindings(&mut set, acis); Some(set) @@ -252,6 +256,7 @@ pub fn tessellate_region( facet_res, chordal_deflection, isolines, + false, ®ion.acis_data, ) } @@ -275,6 +280,7 @@ pub fn tessellate_body( facet_res, chordal_deflection, isolines, + false, &body.acis_data, ) } @@ -298,6 +304,7 @@ pub fn tessellate_surface( facet_res, chordal_deflection, isolines, + true, &surface.acis_data, ) } @@ -321,6 +328,7 @@ pub fn tessellate_solid3d( facet_res, chordal_deflection, isolines, + false, &solid.acis_data, ) } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index f136d16e3..bd2cf4fb0 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3083,6 +3083,10 @@ impl Scene { let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_resolution); let isolines = self.document.header.isolines.max(0) as usize; + let planar_isolines = matches!( + self.document.get_entity(handle), + Some(EntityType::Surface(_)) + ); let (mut set, wires, center) = crate::scene::model::solid_model::display_from_solid( solid, @@ -3090,6 +3094,7 @@ impl Scene { facet_resolution, chordal_deflection, isolines, + planar_isolines, )?; let name = handle.value().to_string(); for mesh in &mut set.lods { diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index a11274ea4..ba6027255 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -3537,21 +3537,32 @@ pub fn primitive_grips( if let (Some(center), Some(base)) = (profile_center, matrix(value.base.transform)) { let direction = base.transform_vector3(glam::DVec3::from_array(direction)); if direction.length_squared() > 1e-12 { - grips.push(grip( + let mut height_grip = grip( GRIP_HEIGHT, center + direction, GripShape::Triangle, Some(direction.normalize()), - )); + ); + if is_surface { + // Surface construction arrows keep one stable screen + // orientation while their world-space axis continues to + // constrain the edit. + height_grip.dir = None; + } + grips.push(height_grip); } } if let Some((world, _, radial, _, _)) = extrusion_draft_grip(value) { - grips.push(grip( + let mut draft_grip = grip( GRIP_DRAFT, world, GripShape::Triangle, Some(radial), - )); + ); + if is_surface { + draft_grip.dir = None; + } + grips.push(draft_grip); } } } diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index fedae9365..fadc09389 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -23,6 +23,7 @@ fn display_tessellation( facet_resolution: f64, chordal_deflection: Option, isolines: usize, + planar_isolines: bool, ) -> brep::mesh::BodyMesh { let resolution = if facet_resolution.is_finite() && facet_resolution > 0.0 { facet_resolution.clamp(0.01, 10.0) @@ -34,7 +35,8 @@ fn display_tessellation( |_| cadkernel::tessellation::display_angle_for_resolution(resolution), ); let mut tolerance = brep::mesh::TessellationTolerance::new(max_angle, TOL) - .with_isolines(isolines); + .with_isolines(isolines) + .with_planar_isolines(planar_isolines); if let Some(deflection) = chordal_deflection { tolerance = tolerance.with_chordal_deflection(deflection); } @@ -267,20 +269,41 @@ pub fn edge_wires(body: &Body) -> Vec { .collect() } -/// White wireframe used while a solid-history grip is hot. +/// White wireframe used while a solid-history grip is hot. Surface previews +/// include the requested construction isolines so their cage follows the edit. /// /// This deliberately does not touch the resident solid mesh or its entity /// wires: the selected source stays visible in blue while the candidate body /// is presented as a separate, non-pickable outline until placement. -pub fn grip_preview_wires(body: &Body, handle: acadrust::Handle) -> Vec { - tessellation(body) +pub fn grip_preview_wires( + body: &Body, + handle: acadrust::Handle, + isolines: usize, +) -> Vec { + let tessellation = brep::mesh::tessellate( + body, + brep::mesh::TessellationTolerance::new( + cadkernel::tessellation::DEFAULT_ANGLE, + TOL, + ) + .with_isolines(isolines) + .with_planar_isolines(isolines > 0), + ); + tessellation .edges .into_iter() - .filter(|edge| edge.positions.len() >= 2) - .map(|edge| { + .map(|edge| edge.positions) + .chain( + tessellation + .isolines + .into_iter() + .map(|isoline| isoline.positions), + ) + .filter(|positions| positions.len() >= 2) + .map(|positions| { WireModel::solid_f64( format!("{}-GRIP-PREVIEW", handle.value()), - edge.positions, + positions, WireModel::WHITE, false, ) @@ -454,6 +477,7 @@ pub fn display_from_solid( facet_resolution: f64, chordal_deflection: Option, isolines: usize, + planar_isolines: bool, ) -> Option<(MeshLodSet, Vec, [f64; 3])> { use acadrust::types::Vector3; let tessellation = display_tessellation( @@ -461,6 +485,7 @@ pub fn display_from_solid( facet_resolution, chordal_deflection, isolines, + planar_isolines, ); let center = mesh_center(&tessellation.mesh)?; let wires = tessellation @@ -541,7 +566,7 @@ mod tests { use super::*; fn tri_count(body: &Body) -> usize { - display_from_solid(body, [0.7, 0.7, 0.7, 1.0], 1.0, None, 0) + display_from_solid(body, [0.7, 0.7, 0.7, 1.0], 1.0, None, 0, false) .map(|(m, _, _)| m.lods[0].indices.len() / 3) .unwrap_or(0) } diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 0b9fdc6a3..edd7195d5 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -1043,7 +1043,15 @@ impl Scene { } self.solid_history_preview_wires.insert( handle, - crate::scene::model::solid_model::grip_preview_wires(&body, handle), + crate::scene::model::solid_model::grip_preview_wires( + &body, + handle, + if matches!(self.document.get_entity(handle), Some(EntityType::Surface(_))) { + self.document.header.isolines.max(0) as usize + } else { + 0 + }, + ), ); true } From 11057cc5e980284d587b8da9a7963860a7501193 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:07:05 +0300 Subject: [PATCH 3/3] Fix extrusion surface properties and snapping --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ocs_doc_api/Cargo.toml | 2 +- src/app/properties.rs | 33 +++-- src/app/update/command.rs | 43 ++++++- src/entities/solid3d.rs | 84 ++++++++++++- src/modules/insert/solid3d_cmds.rs | 2 + src/modules/model/edge_cmd.rs | 7 +- src/scene/convert/acis_kernel.rs | 4 +- src/scene/convert/solid3d_tess.rs | 15 +-- src/scene/mod.rs | 12 +- src/scene/model/solid_history.rs | 190 ++++++++++++++++++++++++++--- src/scene/model/solid_model.rs | 15 +-- src/scene/modify.rs | 14 ++- src/snap.rs | 13 ++ 15 files changed, 383 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d77046e7b..21c353fd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=c584d19dc45b3bef5146d6cf2e3dae79b72e9468#c584d19dc45b3bef5146d6cf2e3dae79b72e9468" +source = "git+https://github.com/ramox81/cadkernel.git?rev=d7431a6ab8600454ab3ad78ad7674576cbc2d5db#d7431a6ab8600454ab3ad78ad7674576cbc2d5db" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index f7a8295a2..325321206 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "c584d19dc45b3bef5146d6cf2e3dae79b72e9468", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "d7431a6ab8600454ab3ad78ad7674576cbc2d5db", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 4c902ed91..e673dc1fb 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -15,7 +15,7 @@ thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "c584d19dc45b3bef5146d6cf2e3dae79b72e9468", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "d7431a6ab8600454ab3ad78ad7674576cbc2d5db", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/src/app/properties.rs b/src/app/properties.rs index 8fd1faec1..61742e50f 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2142,6 +2142,17 @@ impl OpenCADStudio { entity_type_label(entity) } } + acadrust::EntityType::Surface(_) + if matches!( + crate::scene::model::solid_history::primitive_property_operation( + &self.tabs[i].scene.document, + handle, + ), + Some(acadrust::objects::SolidHistoryOperation::Extrusion(_)) + ) => + { + format!("{} ({})", t!("Surface"), t!("Extrusion")) + } _ => entity_type_label(entity), }; ui::PropertiesPanel { @@ -2249,23 +2260,22 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}", ); } } - if local_refs.iter().all(|(handle, _)| { + let compact_solids = local_refs.iter().all(|(handle, _)| { crate::scene::model::solid_history::has_compact_solid_properties( &self.tabs[i].scene.document, *handle, ) - }) { - sections.retain(|section| { - !section.props.iter().any(|property| { - property.field.starts_with("acis_") - || property.field.starts_with("s3d_") - }) - }); + }); + if compact_solids { + retain_compact_solid_sections(&mut sections); } sections.extend(aggregate_solid_history_sections( &self.tabs[i].scene.document, &local_refs.iter().map(|(handle, _)| *handle).collect::>(), )); + if compact_solids { + retain_compact_solid_sections(&mut sections); + } ui::PropertiesPanel { choice_combos: sections .iter() @@ -3205,6 +3215,10 @@ fn retain_compact_solid_sections( sections: &mut Vec, ) { sections.iter_mut().for_each(|section| { + let internal_surface_section = section + .props + .iter() + .any(|property| property.field == "srf_kind"); section.props.retain(|property| { matches!( property.field, @@ -3217,7 +3231,8 @@ fn retain_compact_solid_sections( | "transparency" | "hyperlink" | "material" - ) || crate::scene::model::solid_history::is_specialized_property(property.field) + ) || (!internal_surface_section + && crate::scene::model::solid_history::is_specialized_property(property.field)) }); }); sections.retain(|section| !section.props.is_empty()); diff --git a/src/app/update/command.rs b/src/app/update/command.rs index c91e0f3e6..4b37b2e42 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2002,7 +2002,48 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } self.push_undo_snapshot(i, "CHPROP"); - if crate::scene::model::solid_history::is_loft_geometry_choice(field) { + if crate::scene::model::solid_history::is_surface_property_choice(field) { + use crate::scene::model::solid_history::{ + PROP_SURFACE_MAINTAIN_ASSOCIATIVITY, PROP_SURFACE_SHOW_ASSOCIATIVITY, + PROP_SURFACE_WIREFRAME_TYPE, + }; + for &handle in &handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + let Some(mut state) = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| match entity { + acadrust::EntityType::Surface(surface) => Some( + crate::entities::solid3d::surface_property_state(surface), + ), + _ => None, + }) + else { + continue; + }; + match field { + PROP_SURFACE_WIREFRAME_TYPE => { + state.isolines = !value.eq_ignore_ascii_case("Isoparms") + } + PROP_SURFACE_MAINTAIN_ASSOCIATIVITY => { + state.maintain_associativity = value.eq_ignore_ascii_case("Yes") + } + PROP_SURFACE_SHOW_ASSOCIATIVITY => { + state.show_associativity = value.eq_ignore_ascii_case("Yes") + } + _ => continue, + } + crate::scene::view::dispatch::set_entity_xdata( + &mut self.tabs[i].scene.document, + handle, + crate::entities::solid3d::SURFACE_PROPERTIES_APP, + Some(crate::entities::solid3d::surface_property_xdata_values(state)), + ); + } + } else if crate::scene::model::solid_history::is_loft_geometry_choice(field) { // These choices change the generated body, not just history // flags. Use the same transactional rebuild as numeric edits. for &handle in &handles { diff --git a/src/entities/solid3d.rs b/src/entities/solid3d.rs index ce06f0b5a..8cf4f1924 100644 --- a/src/entities/solid3d.rs +++ b/src/entities/solid3d.rs @@ -3,6 +3,7 @@ // Shared grips and properties for modeler entities. use acadrust::entities::{Body, Region, Solid3D, Surface}; +use acadrust::xdata::{ExtendedDataRecord, XDataValue}; use cadkernel::space::polygon; use crate::t; use crate::command::EntityTransform; @@ -49,6 +50,71 @@ fn yes_no(value: bool) -> &'static str { if value { "Yes" } else { "No" } } +pub(crate) const SURFACE_PROPERTIES_APP: &str = "OCS_SURFACE_PROPERTIES"; + +#[derive(Clone, Copy)] +pub(crate) struct SurfacePropertyState { + pub isolines: bool, + pub maintain_associativity: bool, + pub show_associativity: bool, +} + +pub(crate) fn surface_property_state(surface: &Surface) -> SurfacePropertyState { + let defaults = SurfacePropertyState { + isolines: true, + maintain_associativity: surface.history_handle.is_some(), + show_associativity: false, + }; + let Some(record) = surface + .common + .extended_data + .get_record(SURFACE_PROPERTIES_APP) + else { + return defaults; + }; + let mut values = record.values.iter().filter_map(|value| match value { + XDataValue::Integer16(value) => Some(*value), + _ => None, + }); + let Some(version) = values.next() else { + return defaults; + }; + if version != 1 { + return defaults; + } + SurfacePropertyState { + isolines: values.next().map_or(defaults.isolines, |value| value == 0), + maintain_associativity: values + .next() + .map_or(defaults.maintain_associativity, |value| value != 0), + show_associativity: values + .next() + .map_or(defaults.show_associativity, |value| value != 0), + } +} + +pub(crate) fn surface_property_xdata_values(state: SurfacePropertyState) -> Vec { + vec![ + XDataValue::Integer16(1), + XDataValue::Integer16(if state.isolines { 0 } else { 1 }), + XDataValue::Integer16(state.maintain_associativity as i16), + XDataValue::Integer16(state.show_associativity as i16), + ] +} + +fn write_surface_property_state(surface: &mut Surface, state: SurfacePropertyState) { + let mut record = ExtendedDataRecord::new(SURFACE_PROPERTIES_APP); + record.values = surface_property_xdata_values(state); + surface.common.extended_data.upsert_record(record); +} + +pub(crate) fn surface_isoline_counts(surface: &Surface) -> [usize; 2] { + [ + surface.u_isolines.max(0) as usize, + surface.v_isolines.max(0) as usize, + ] +} + fn handle_text(handle: Option) -> String { handle .filter(|handle| handle.is_valid()) @@ -700,6 +766,21 @@ impl PropertyEditable for Surface { fn apply_geom_prop(&mut self, field: &str, value: &str) { match field { + "srf_wireframe_type" => { + let mut state = surface_property_state(self); + state.isolines = !value.eq_ignore_ascii_case("Isoparms"); + write_surface_property_state(self, state); + } + "srf_maintain_associativity" => { + let mut state = surface_property_state(self); + state.maintain_associativity = value.eq_ignore_ascii_case("Yes"); + write_surface_property_state(self, state); + } + "srf_show_associativity" => { + let mut state = surface_property_state(self); + state.show_associativity = value.eq_ignore_ascii_case("Yes"); + write_surface_property_state(self, state); + } "srf_u_isolines" => { if let Some(value) = parse_f64(value) { self.u_isolines = (value.round() as i16).max(0); @@ -822,7 +903,8 @@ pub fn tessellate_volume( color, facet_res, chordal_deflection, - isolines, + surface_isoline_counts(s), + surface_property_state(s).isolines, ), EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) => { crate::entities::mesh::tessellate_shaded_mesh(e, color) diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 3f5788237..5e4d5d129 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -2778,6 +2778,8 @@ pub fn empty_extruded_surface(direction: DVec3, taper_angle: f64) -> EntityType use acadrust::types::Vector3; let mut surface = Surface::new(SurfaceKind::Extruded); + surface.u_isolines = 6; + surface.v_isolines = 6; if let SurfaceData::Extruded { options, sweep_vector, diff --git a/src/modules/model/edge_cmd.rs b/src/modules/model/edge_cmd.rs index 59a8a9c4b..08dfda16e 100644 --- a/src/modules/model/edge_cmd.rs +++ b/src/modules/model/edge_cmd.rs @@ -210,7 +210,12 @@ impl SolidEdgeCommand { ) .ok()?, }; - let mut wires = crate::scene::model::solid_model::grip_preview_wires(&result, handle, 0); + let mut wires = crate::scene::model::solid_model::grip_preview_wires( + &result, + handle, + [0; 2], + false, + ); for wire in &mut wires { wire.color = self.preview_color; wire.name = format!("{}-{}-PREVIEW", handle.value(), self.name()); diff --git a/src/scene/convert/acis_kernel.rs b/src/scene/convert/acis_kernel.rs index 4edc63268..29fb8b613 100644 --- a/src/scene/convert/acis_kernel.rs +++ b/src/scene/convert/acis_kernel.rs @@ -24,7 +24,7 @@ pub fn tessellate_sat( color: [f32; 4], facet_res: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], planar_isolines: bool, ) -> Option { let (bodies, loss) = lift(document); @@ -115,7 +115,7 @@ pub fn tessellate_sat( max_angle, source_fit * placement_scale, ) - .with_isolines(isolines) + .with_uv_isolines(isolines[0], isolines[1]) .with_planar_isolines(planar_isolines); if let Some(deflection) = chordal_deflection { tolerance = tolerance.with_chordal_deflection(deflection); diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 3015b4a7f..682bfd3c0 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -10,7 +10,7 @@ fn tessellate_acis( color: [f32; 4], facet_res: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], planar_isolines: bool, ) -> Option { crate::scene::convert::acis_kernel::tessellate_sat( @@ -220,7 +220,7 @@ fn finish( color: [f32; 4], facet_res: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], planar_isolines: bool, acis: &acadrust::entities::AcisData, ) -> Option { @@ -255,7 +255,7 @@ pub fn tessellate_region( color, facet_res, chordal_deflection, - isolines, + [isolines; 2], false, ®ion.acis_data, ) @@ -279,7 +279,7 @@ pub fn tessellate_body( color, facet_res, chordal_deflection, - isolines, + [isolines; 2], false, &body.acis_data, ) @@ -290,7 +290,8 @@ pub fn tessellate_surface( color: [f32; 4], facet_res: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], + planar_isolines: bool, ) -> Option { let sat = parse_acis( || surface.parse_sat(), @@ -304,7 +305,7 @@ pub fn tessellate_surface( facet_res, chordal_deflection, isolines, - true, + planar_isolines, &surface.acis_data, ) } @@ -327,7 +328,7 @@ pub fn tessellate_solid3d( color, facet_res, chordal_deflection, - isolines, + [isolines; 2], false, &solid.acis_data, ) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index bd2cf4fb0..715e6bb5d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3082,11 +3082,13 @@ impl Scene { let facet_resolution = self.document.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_resolution); - let isolines = self.document.header.isolines.max(0) as usize; - let planar_isolines = matches!( - self.document.get_entity(handle), - Some(EntityType::Surface(_)) - ); + let (isolines, planar_isolines) = match self.document.get_entity(handle) { + Some(EntityType::Surface(surface)) => ( + crate::entities::solid3d::surface_isoline_counts(surface), + crate::entities::solid3d::surface_property_state(surface).isolines, + ), + _ => ([self.document.header.isolines.max(0) as usize; 2], false), + }; let (mut set, wires, center) = crate::scene::model::solid_model::display_from_solid( solid, diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index ba6027255..45eafe9e7 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -94,6 +94,14 @@ pub const PROP_LOFT_CLOSED: &str = "solid_history_loft_closed"; pub const PROP_LOFT_PERIODIC: &str = "solid_history_loft_periodic"; pub const PROP_HISTORY: &str = "solid_history_record"; pub const PROP_SHOW_HISTORY: &str = "solid_history_show"; +pub const PROP_SURFACE_TYPE: &str = "surface_type"; +pub const PROP_SURFACE_WIREFRAME_TYPE: &str = "srf_wireframe_type"; +pub const PROP_SURFACE_U_ISOLINES: &str = "srf_u_isolines"; +pub const PROP_SURFACE_V_ISOLINES: &str = "srf_v_isolines"; +pub const PROP_SURFACE_MAINTAIN_ASSOCIATIVITY: &str = "srf_maintain_associativity"; +pub const PROP_SURFACE_SHOW_ASSOCIATIVITY: &str = "srf_show_associativity"; +pub const PROP_SURFACE_TRIMMED: &str = "srf_trimmed"; +pub const PROP_SURFACE_TRIMMING_EDGES: &str = "srf_trimming_edges"; fn history_prop(label: &str, field: &'static str, value: impl ToString) -> Property { Property { @@ -103,6 +111,23 @@ fn history_prop(label: &str, field: &'static str, value: impl ToString) -> Prope } } +fn compact_surface_number(mut value: String) -> String { + if value.ends_with('°') { + value.pop(); + } + if let Some(decimal) = value.rfind('.') { + if value[decimal + 1..].chars().all(|character| character.is_ascii_digit()) { + while value.ends_with('0') { + value.pop(); + } + if value.ends_with('.') { + value.pop(); + } + } + } + value +} + fn history_flags( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -239,9 +264,14 @@ pub fn has_compact_solid_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, ) -> bool { - document - .get_entity(handle) - .is_some_and(|entity| matches!(entity, acadrust::EntityType::Solid3D(_))) + match document.get_entity(handle) { + Some(acadrust::EntityType::Solid3D(_)) => true, + Some(acadrust::EntityType::Surface(_)) => matches!( + primitive_property_operation(document, handle), + Some(SolidHistoryOperation::Extrusion(_)) + ), + _ => false, + } } pub fn reference_point(operation: &SolidHistoryOperation) -> Option { @@ -403,10 +433,6 @@ fn extrusion_properties( |direction| crate::entities::common::format_length(direction[axis]), )), }; - let (record_history, object_show_history, show_history_mode) = - history_flags(document, handle).unwrap_or((false, false, 1)); - let (show_history, show_history_editable) = - displayed_history_state(object_show_history, show_history_mode); let path_length = sweep_path_length(value); let height_value = path_length.unwrap_or(value.end_draft_distance); let height = if value.path_entity.is_some() { @@ -419,6 +445,134 @@ fn extrusion_properties( } else { PropValue::EditText(crate::entities::common::format_angle(value.draft_angle)) }; + if let Some(EntityType::Surface(surface)) = document.get_entity(handle) { + let state = crate::entities::solid3d::surface_property_state(surface); + let surface_height = if value.path_entity.is_some() { + PropValue::ReadOnly(compact_surface_number( + crate::entities::common::format_length(height_value), + )) + } else { + PropValue::EditText(compact_surface_number( + crate::entities::common::format_length(height_value), + )) + }; + let surface_taper = if value.path_entity.is_some() { + PropValue::ReadOnly(compact_surface_number( + crate::entities::common::format_angle(value.draft_angle), + )) + } else { + PropValue::EditText(compact_surface_number( + crate::entities::common::format_angle(value.draft_angle), + )) + }; + let surface_direction_property = + |label: &str, field: &'static str, axis: usize| Property { + label: label.to_string(), + field, + value: PropValue::ReadOnly(direction.map_or_else( + || t!("Unavailable").into_owned(), + |direction| { + compact_surface_number(crate::entities::common::format_length( + direction[axis], + )) + }, + )), + }; + let yes_no_choice = |selected: bool| PropValue::Choice { + selected: if selected { "Yes" } else { "No" }.to_string(), + options: vec!["Yes".to_string(), "No".to_string()], + }; + return vec![ + PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + Property { + label: t!("Surface Type").into_owned(), + field: PROP_SURFACE_TYPE, + value: PropValue::ReadOnly(t!("Extrusion").into_owned()), + }, + Property { + label: t!("Height").into_owned(), + field: PROP_EXTRUSION_HEIGHT, + value: surface_height, + }, + Property { + label: t!("Taper angle").into_owned(), + field: PROP_TAPER_ANGLE, + value: surface_taper, + }, + surface_direction_property( + t!("Direction X").as_ref(), + PROP_EXTRUSION_DIRECTION_X, + 0, + ), + surface_direction_property( + t!("Direction Y").as_ref(), + PROP_EXTRUSION_DIRECTION_Y, + 1, + ), + surface_direction_property( + t!("Direction Z").as_ref(), + PROP_EXTRUSION_DIRECTION_Z, + 2, + ), + Property { + label: t!("Wireframe type").into_owned(), + field: PROP_SURFACE_WIREFRAME_TYPE, + value: PropValue::Choice { + selected: if state.isolines { "Isolines" } else { "Isoparms" } + .to_string(), + options: vec!["Isolines".to_string(), "Isoparms".to_string()], + }, + }, + Property { + label: t!("U isolines").into_owned(), + field: PROP_SURFACE_U_ISOLINES, + value: PropValue::EditText(surface.u_isolines.max(0).to_string()), + }, + Property { + label: t!("V isolines").into_owned(), + field: PROP_SURFACE_V_ISOLINES, + value: PropValue::EditText(surface.v_isolines.max(0).to_string()), + }, + ], + }, + PropSection { + title: t!("Surface Associativity").into_owned(), + props: vec![ + Property { + label: t!("Maintain associativity").into_owned(), + field: PROP_SURFACE_MAINTAIN_ASSOCIATIVITY, + value: yes_no_choice(state.maintain_associativity), + }, + Property { + label: t!("Show associativity").into_owned(), + field: PROP_SURFACE_SHOW_ASSOCIATIVITY, + value: yes_no_choice(state.show_associativity), + }, + ], + }, + PropSection { + title: t!("Trims").into_owned(), + props: vec![ + Property { + label: t!("Trimmed surface").into_owned(), + field: PROP_SURFACE_TRIMMED, + value: PropValue::ReadOnly("No".to_string()), + }, + Property { + label: t!("Trimming edges").into_owned(), + field: PROP_SURFACE_TRIMMING_EDGES, + value: PropValue::ReadOnly("0".to_string()), + }, + ], + }, + ]; + } + let (record_history, object_show_history, show_history_mode) = + history_flags(document, handle).unwrap_or((false, false, 1)); + let (show_history, show_history_editable) = + displayed_history_state(object_show_history, show_history_mode); vec![ PropSection { title: t!("Geometry").into_owned(), @@ -1531,6 +1685,15 @@ pub fn is_history_choice(field: &str) -> bool { matches!(field, PROP_HISTORY | PROP_SHOW_HISTORY) } +pub fn is_surface_property_choice(field: &str) -> bool { + matches!( + field, + PROP_SURFACE_WIREFRAME_TYPE + | PROP_SURFACE_MAINTAIN_ASSOCIATIVITY + | PROP_SURFACE_SHOW_ASSOCIATIVITY + ) +} + pub fn is_loft_geometry_choice(field: &str) -> bool { matches!(field, PROP_LOFT_NORMALS | PROP_LOFT_CLOSED | PROP_LOFT_PERIODIC) } @@ -1538,7 +1701,11 @@ pub fn is_loft_geometry_choice(field: &str) -> bool { pub fn is_specialized_property(field: &str) -> bool { matches!(field, "solid_history_type" | PROP_BANK | PROP_SWEEP_LENGTH | PROP_LOFT_TYPE | PROP_LOFT_SECTION_COUNT - | PROP_EXTRUSION_DIRECTION_X | PROP_EXTRUSION_DIRECTION_Y | PROP_EXTRUSION_DIRECTION_Z) + | PROP_EXTRUSION_DIRECTION_X | PROP_EXTRUSION_DIRECTION_Y | PROP_EXTRUSION_DIRECTION_Z + | PROP_SURFACE_TYPE | PROP_SURFACE_WIREFRAME_TYPE + | PROP_SURFACE_U_ISOLINES | PROP_SURFACE_V_ISOLINES + | PROP_SURFACE_MAINTAIN_ASSOCIATIVITY | PROP_SURFACE_SHOW_ASSOCIATIVITY + | PROP_SURFACE_TRIMMED | PROP_SURFACE_TRIMMING_EDGES) || is_primitive_property(field) || is_history_choice(field) } @@ -3018,11 +3185,6 @@ fn extrusion_draft_grip( if !inverse.is_finite() { return None; } - let profile = value.sweep_entity.as_ref().and_then(embedded_entity)?; - let normal = crate::entities::curve::entity_curve(&profile)?.plane.normal()?; - let normal = transform - .transform_vector3(glam::DVec3::from_array(normal)) - .try_normalize()?; let center_local = inverse.transform_point3(center); let anchor = profile_grips .into_iter() @@ -3057,7 +3219,7 @@ fn extrusion_draft_grip( value.direction.y, value.direction.z, )); - let height = direction.dot(normal).abs(); + let height = direction.length(); if !direction.is_finite() || !height.is_finite() || height <= 1e-6 { return None; } diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index fadc09389..948f1e652 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -22,7 +22,7 @@ fn display_tessellation( body: &Body, facet_resolution: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], planar_isolines: bool, ) -> brep::mesh::BodyMesh { let resolution = if facet_resolution.is_finite() && facet_resolution > 0.0 { @@ -35,7 +35,7 @@ fn display_tessellation( |_| cadkernel::tessellation::display_angle_for_resolution(resolution), ); let mut tolerance = brep::mesh::TessellationTolerance::new(max_angle, TOL) - .with_isolines(isolines) + .with_uv_isolines(isolines[0], isolines[1]) .with_planar_isolines(planar_isolines); if let Some(deflection) = chordal_deflection { tolerance = tolerance.with_chordal_deflection(deflection); @@ -278,7 +278,8 @@ pub fn edge_wires(body: &Body) -> Vec { pub fn grip_preview_wires( body: &Body, handle: acadrust::Handle, - isolines: usize, + isolines: [usize; 2], + planar_isolines: bool, ) -> Vec { let tessellation = brep::mesh::tessellate( body, @@ -286,8 +287,8 @@ pub fn grip_preview_wires( cadkernel::tessellation::DEFAULT_ANGLE, TOL, ) - .with_isolines(isolines) - .with_planar_isolines(isolines > 0), + .with_uv_isolines(isolines[0], isolines[1]) + .with_planar_isolines(planar_isolines), ); tessellation .edges @@ -476,7 +477,7 @@ pub fn display_from_solid( color: [f32; 4], facet_resolution: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], planar_isolines: bool, ) -> Option<(MeshLodSet, Vec, [f64; 3])> { use acadrust::types::Vector3; @@ -566,7 +567,7 @@ mod tests { use super::*; fn tri_count(body: &Body) -> usize { - display_from_solid(body, [0.7, 0.7, 0.7, 1.0], 1.0, None, 0, false) + display_from_solid(body, [0.7, 0.7, 0.7, 1.0], 1.0, None, [0; 2], false) .map(|(m, _, _)| m.lods[0].indices.len() / 3) .unwrap_or(0) } diff --git a/src/scene/modify.rs b/src/scene/modify.rs index edd7195d5..a3c9434bd 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -1041,16 +1041,20 @@ impl Scene { { return false; } + let (isoline_counts, planar_isolines) = match self.document.get_entity(handle) { + Some(EntityType::Surface(surface)) => ( + crate::entities::solid3d::surface_isoline_counts(surface), + crate::entities::solid3d::surface_property_state(surface).isolines, + ), + _ => ([0; 2], false), + }; self.solid_history_preview_wires.insert( handle, crate::scene::model::solid_model::grip_preview_wires( &body, handle, - if matches!(self.document.get_entity(handle), Some(EntityType::Surface(_))) { - self.document.header.isolines.max(0) as usize - } else { - 0 - }, + isoline_counts, + planar_isolines, ), ); true diff --git a/src/snap.rs b/src/snap.rs index b3021cc22..8969eff91 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1023,12 +1023,25 @@ impl Snapper { f32::MAX } }; + let flat_ortho = view_rot.z_axis.x.abs() < 1e-9 + && view_rot.z_axis.y.abs() < 1e-9 + && (view_rot.w_axis.w - 1.0).abs() < 1e-6; // Returns false when the wire's AABB does not overlap the snap circle — // safe to skip all vertex work for this wire. // UNBOUNDED_AABB (±infinity) passes through automatically without a // special-case branch because the arithmetic is exact for infinities. let wire_in_range = |wire: &WireModel| -> bool { + // In a tilted or perspective view `cursor_world` lies on the active + // construction plane, while the visible vertex may be anywhere on + // the same view ray. A world-XY AABB comparison can therefore reject + // a 3-D solid corner that is directly under the cursor. The scene's + // screen-space interaction index already performs the broad phase + // for those views; small unindexed drawings are cheap enough to let + // the exact screen-aperture checks below decide. + if !flat_ortho { + return true; + } // The AABB is stored in f32, so at UTM-scale coordinates each bound // is quantized by up to ~1 ulp (≈ coord × 2⁻²³ ≈ 0.7 m at 5.7e6). // When zoomed in hard the snap radius shrinks below that, so the