diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index df4c7a11d..e35c6ea97 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3049,7 +3049,11 @@ impl OpenCADStudio { return Task::none(); }; let grip_shape = self.tabs[i].selected_grips[grip_index].shape; - if grip_shape == crate::scene::model::object::GripShape::Dropdown { + if matches!( + grip_shape, + crate::scene::model::object::GripShape::Dropdown + | crate::scene::model::object::GripShape::DropdownAdjacent + ) { use crate::entities::traits::EntityTypeOps; let items = self.tabs[i] .scene diff --git a/src/entities/extended.rs b/src/entities/extended.rs index 38fe46ddd..f82a5e261 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -13,13 +13,19 @@ use crate::entities::common::{ use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; use crate::scene::model::object::{ - GripApply, GripDef, PropSection, PropValue, Property, + GripApply, GripDef, GripMenuAction, GripMenuItem, PropSection, PropValue, Property, }; use crate::scene::model::wire_model::SnapHint; const NAN: [f64; 3] = [f64::NAN; 3]; const SECTION_SLICE_APP: &str = "IsSlice"; const SECTION_THICKNESS_APP: &str = "ThicknessDepth"; +const SECTION_GRIP_LEFT: usize = 100_000; +const SECTION_GRIP_RIGHT: usize = 100_001; +const SECTION_GRIP_TOP: usize = 100_002; +const SECTION_GRIP_BOTTOM: usize = 100_003; +const SECTION_GRIP_NORMAL: usize = 100_004; +const SECTION_GRIP_STATE: usize = 100_005; pub(crate) fn section_is_slice(entity: &ExtendedEntity) -> bool { entity @@ -1563,15 +1569,56 @@ fn move_extents( fn grips(entity: &ExtendedEntity) -> Vec { match &entity.data { - ExtendedEntityData::SectionObject(data) => data - .vertices - .iter() - .chain(data.back_line_vertices.iter()) - .enumerate() - .map(|(index, point)| { - square_grip(index, glam::DVec3::new(point.x, point.y, point.z)) - }) - .collect(), + ExtendedEntityData::SectionObject(data) => { + let mut grips: Vec = if section_kind(entity, data) == "Plane" + && data.vertices.len() == 2 + && data.back_line_vertices.is_empty() + { + section_plane_edge_grips(data) + } else { + data.vertices + .iter() + .chain(data.back_line_vertices.iter()) + .enumerate() + .map(|(index, point)| { + square_grip(index, glam::DVec3::new(point.x, point.y, point.z)) + }) + .collect() + }; + if let Some(right) = data.vertices.last() { + let vertical = normalized(data.vertical_direction, Vector3::UNIT_Z); + let edge_center_offset = (data.top_height - data.bottom_height) * 0.5; + let anchor = *right + vertical * edge_center_offset; + grips.push(GripDef { + id: SECTION_GRIP_STATE, + world: glam::DVec3::new(anchor.x, anchor.y, anchor.z), + is_midpoint: false, + shape: crate::scene::model::object::GripShape::DropdownAdjacent, + dir: None, + axis: None, + }); + } + if let Some(center) = section_center(data) { + let normal = section_viewing_direction(data); + let span = data + .vertices + .first() + .zip(data.vertices.last()) + .map_or(1.0, |(first, last)| (*last - *first).length()); + let offset = (span * 0.08).max(1.0e-6); + let normal_world = glam::DVec3::new(normal.x, normal.y, normal.z); + grips.push(GripDef { + id: SECTION_GRIP_NORMAL, + world: glam::DVec3::new(center.x, center.y, center.z) + - normal_world * offset, + is_midpoint: false, + shape: crate::scene::model::object::GripShape::Triangle, + dir: Some(-normal_world), + axis: Some(normal_world), + }); + } + grips + } ExtendedEntityData::ArcAlignedText(data) => vec![center_grip( 0, glam::DVec3::new(data.center.x, data.center.y, data.center.z), @@ -1643,7 +1690,41 @@ fn grips(entity: &ExtendedEntity) -> Vec { fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) { match &mut entity.data { ExtendedEntityData::SectionObject(data) => { - if grip_id < data.vertices.len() { + if matches!( + grip_id, + SECTION_GRIP_LEFT + | SECTION_GRIP_RIGHT + | SECTION_GRIP_TOP + | SECTION_GRIP_BOTTOM + ) { + apply_section_plane_edge_grip(data, grip_id, apply); + } else if grip_id == SECTION_GRIP_NORMAL { + let Some(center) = section_center(data) else { + return; + }; + let normal = section_viewing_direction(data); + let span = data + .vertices + .first() + .zip(data.vertices.last()) + .map_or(1.0, |(first, last)| (*last - *first).length()); + let offset = (span * 0.08).max(1.0e-6); + let current = center - normal * offset; + let delta = match apply { + GripApply::Translate(delta) => Vector3::new(delta.x, delta.y, delta.z), + GripApply::Absolute(position) => { + Vector3::new(position.x, position.y, position.z) - current + } + }; + let constrained = normal * delta.dot(&normal); + for point in data + .vertices + .iter_mut() + .chain(data.back_line_vertices.iter_mut()) + { + *point = *point + constrained; + } + } else if grip_id < data.vertices.len() { apply_point(&mut data.vertices[grip_id], apply); } else if let Some(point) = data .back_line_vertices @@ -1704,6 +1785,115 @@ fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) { } } +fn section_center(data: &SectionObjectData) -> Option { + let (first, last) = data.vertices.first().zip(data.vertices.last())?; + Some((*first + *last) * 0.5) +} + +fn section_plane_edge_grips(data: &SectionObjectData) -> Vec { + let Some((first, last)) = data.vertices.first().zip(data.vertices.last()) else { + return Vec::new(); + }; + let center = (*first + *last) * 0.5; + let tangent = section_tangent(data); + let vertical = normalized(data.vertical_direction, Vector3::UNIT_Z); + let edge_center_offset = (data.top_height - data.bottom_height) * 0.5; + let definitions = [ + ( + SECTION_GRIP_LEFT, + *first + vertical * edge_center_offset, + vertical, + None, + ), + ( + SECTION_GRIP_RIGHT, + *last + vertical * edge_center_offset, + vertical, + None, + ), + ( + SECTION_GRIP_TOP, + center + vertical * data.top_height, + tangent, + Some(vertical), + ), + ( + SECTION_GRIP_BOTTOM, + center - vertical * data.bottom_height, + tangent, + Some(vertical), + ), + ]; + definitions + .into_iter() + .map(|(id, world, edge, axis)| GripDef { + id, + world: glam::DVec3::new(world.x, world.y, world.z), + is_midpoint: false, + shape: crate::scene::model::object::GripShape::Rectangle, + dir: Some(glam::DVec3::new(edge.x, edge.y, edge.z)), + axis: axis.map(|axis| glam::DVec3::new(axis.x, axis.y, axis.z)), + }) + .collect() +} + +fn apply_section_plane_edge_grip( + data: &mut SectionObjectData, + grip_id: usize, + apply: GripApply, +) { + let Some((first, last)) = data.vertices.first().zip(data.vertices.last()) else { + return; + }; + let center = (*first + *last) * 0.5; + let tangent = section_tangent(data); + let vertical = normalized(data.vertical_direction, Vector3::UNIT_Z); + let edge_center_offset = (data.top_height - data.bottom_height) * 0.5; + let current = match grip_id { + SECTION_GRIP_LEFT => *first + vertical * edge_center_offset, + SECTION_GRIP_RIGHT => *last + vertical * edge_center_offset, + SECTION_GRIP_TOP => center + vertical * data.top_height, + SECTION_GRIP_BOTTOM => center - vertical * data.bottom_height, + _ => return, + }; + let delta = match apply { + GripApply::Translate(delta) => Vector3::new(delta.x, delta.y, delta.z), + GripApply::Absolute(position) => { + Vector3::new(position.x, position.y, position.z) - current + } + }; + let span = (*last - *first).length(); + let minimum_span = 1.0e-6; + let in_plane_vertical = normalized( + vertical - tangent * vertical.dot(&tangent), + vertical, + ); + let vertical_change = in_plane_vertical * delta.dot(&in_plane_vertical); + match grip_id { + SECTION_GRIP_LEFT => { + let tangent_change = delta + .dot(&tangent) + .min((span - minimum_span).max(0.0)); + data.vertices[0] = data.vertices[0] + tangent * tangent_change + vertical_change; + } + SECTION_GRIP_RIGHT => { + let last = data.vertices.len() - 1; + let tangent_change = delta + .dot(&tangent) + .max((-span + minimum_span).min(0.0)); + data.vertices[last] = + data.vertices[last] + tangent * tangent_change + vertical_change; + } + SECTION_GRIP_TOP => { + data.top_height = (data.top_height + delta.dot(&vertical)).max(0.0); + } + SECTION_GRIP_BOTTOM => { + data.bottom_height = (data.bottom_height - delta.dot(&vertical)).max(0.0); + } + _ => {} + } +} + fn entity_transform(transform: &EntityTransform) -> Transform { match transform { EntityTransform::Translate(delta) => { @@ -1943,6 +2133,54 @@ impl Grippable for ExtendedEntity { fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { apply_grip(self, grip_id, apply); } + + fn grip_menu(&self, grip_id: usize) -> Vec { + if grip_id != SECTION_GRIP_STATE { + return Vec::new(); + } + let ExtendedEntityData::SectionObject(data) = &self.data else { + return Vec::new(); + }; + let current = section_kind(self, data); + vec![ + GripMenuItem { + label: if current == "Plane" { "✓ Plane" } else { "Plane" }, + action: GripMenuAction::SectionPlane, + }, + GripMenuItem { + label: if current == "Slice" { "✓ Slice" } else { "Slice" }, + action: GripMenuAction::SectionSlice, + }, + GripMenuItem { + label: if current == "Boundary" { + "✓ Boundary" + } else { + "Boundary" + }, + action: GripMenuAction::SectionBoundary, + }, + GripMenuItem { + label: if current == "Volume" { "✓ Volume" } else { "Volume" }, + action: GripMenuAction::SectionVolume, + }, + ] + } + + fn apply_grip_menu(&mut self, grip_id: usize, action: GripMenuAction) { + if grip_id != SECTION_GRIP_STATE + || !matches!(self.data, ExtendedEntityData::SectionObject(_)) + { + return; + } + let value = match action { + GripMenuAction::SectionPlane => "Plane", + GripMenuAction::SectionSlice => "Slice", + GripMenuAction::SectionBoundary => "Boundary", + GripMenuAction::SectionVolume => "Volume", + _ => return, + }; + apply_section_prop(self, "ext_section_state", value); + } } impl PropertyEditable for ExtendedEntity { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index f136d16e3..ac87040ed 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -5410,8 +5410,12 @@ impl Scene { // changed entities into it, instead of re-cloning + re-sorting the whole // set. Falls back to the full build below when it can't (no cache entry, // journal un-replayable, or a structural assumption violated). - if let Some(arc) = - self.try_resident_patch( + // A live section changes the displayed wires of every intersected + // solid, even when the delta journal contains only the section entity. + // Rebuild the resident set in that case so an incremental patch cannot + // retain the source solid's uncut edge cache. + if self.active_live_section(block).is_none() { + if let Some(arc) = self.try_resident_patch( key, block, bg, @@ -5420,9 +5424,9 @@ impl Scene { all_visible, frozen_layers, style_viewport, - ) - { - return arc; + ) { + return arc; + } } // Build once: full tessellation, no cull, no zoom LOD — the resident // set is zoom-independent (GPU analytical circles/arcs/ellipses). @@ -6996,6 +7000,48 @@ impl Scene { Self::section_body(&body, §ion.data, section.slice_depth, None) } + /// Build a temporary entity whose wire data matches the live-section body. + /// The document entity stays unchanged so disabling the section restores + /// the complete source geometry and saving never persists the clipped body. + fn sectioned_wire_entity( + &self, + handle: Handle, + section: &LiveSection, + ) -> Result, ()> { + let Some(body) = self.sectioned_body(handle, section)? else { + return Ok(None); + }; + let (_, wires, center) = self + .prepare_solid_model_display(handle, &body) + .ok_or(())?; + let mut entity = self.document.get_entity(handle).cloned().ok_or(())?; + let reference = acadrust::types::Vector3::new(center[0], center[1], center[2]); + match &mut entity { + EntityType::Solid3D(solid) => { + solid.point_of_reference = reference; + solid.wires = wires; + solid.silhouettes.clear(); + } + EntityType::Surface(surface) => { + surface.point_of_reference = reference; + surface.wires = wires; + surface.silhouettes.clear(); + } + EntityType::Region(region) => { + region.point_of_reference = reference; + region.wires = wires; + region.silhouettes.clear(); + } + EntityType::Body(body) => { + body.point_of_reference = reference; + body.wires = wires; + body.silhouettes.clear(); + } + _ => return Err(()), + } + Ok(Some(entity)) + } + fn section_source_body( &self, handle: Handle, @@ -9709,6 +9755,45 @@ impl Scene { memo_misses = visible_count; out }; + + // Normal Wireframe renders the persisted entity wires, whereas shaded + // and 3D Wireframe modes render the temporary sectioned mesh. Replace + // only the displayed wire run with one tessellated from that same + // temporary body so every visual style shows the identical live cut. + if let Some(section) = self.active_live_section(block_handle) { + let handles: Vec = self.meshes.keys().copied().collect(); + for handle in handles { + let Some(source) = self.document.get_entity(handle) else { + continue; + }; + if !visibility_ok(source) { + continue; + } + let replacement = match self.sectioned_wire_entity(handle, §ion) { + Ok(Some(entity)) => Some(tessellate_entity( + doc, + sel, + avp, + bg, + anno, + annotation_scale_handle, + &entity, + Some(blk_ref), + view_aabb, + wpp, + paper, + )), + Ok(None) => Some(Vec::new()), + Err(()) => None, + }; + let Some(replacement) = replacement else { + continue; + }; + let name = handle.value().to_string(); + wires.retain(|wire| wire.name != name); + wires.extend(replacement); + } + } let build_ms = crate::perf::elapsed_ms(t_build); // Resolve display colours for the live background. Memo hits were diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 2d0eee684..f24423b91 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -98,6 +98,8 @@ pub enum GripShape { Circle, /// Screen-offset menu selector. Dropdown, + /// Menu selector placed immediately beside its anchor grip. + DropdownAdjacent, } /// Describes one grip point for an entity. @@ -180,6 +182,10 @@ pub enum GripMenuAction { RefineVertices, ShowFit, ShowControlVertices, + SectionPlane, + SectionSlice, + SectionBoundary, + SectionVolume, MoveWithText, StackText, UnstackText, diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index e9cdb1d25..e8ff3e72f 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -13,11 +13,18 @@ pub const GRIP_HALF_PX: f32 = 5.0; /// Screen offset for dropdown selectors anchored to a vertex. pub const GRIP_DROPDOWN_OFFSET_X_PX: f32 = 24.0; pub const GRIP_DROPDOWN_OFFSET_Y_PX: f32 = 27.0; +pub const GRIP_ADJACENT_DROPDOWN_OFFSET_X_PX: f32 = 12.0; fn marker_screen_position(mut point: Point, shape: GripShape) -> Point { - if shape == GripShape::Dropdown { - point.x += GRIP_DROPDOWN_OFFSET_X_PX; - point.y += GRIP_DROPDOWN_OFFSET_Y_PX; + match shape { + GripShape::Dropdown => { + point.x += GRIP_DROPDOWN_OFFSET_X_PX; + point.y += GRIP_DROPDOWN_OFFSET_Y_PX; + } + GripShape::DropdownAdjacent => { + point.x += GRIP_ADJACENT_DROPDOWN_OFFSET_X_PX; + } + _ => {} } point } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 19b7af2a4..3a47c1732 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -781,7 +781,7 @@ fn draw_grip_marker( b.close(); }), GripShape::Circle => canvas::Path::circle(Point::new(sp.x, sp.y), h), - GripShape::Dropdown => canvas::Path::new(|b| { + GripShape::Dropdown | GripShape::DropdownAdjacent => canvas::Path::new(|b| { b.move_to(Point::new(sp.x - h, sp.y - h * 0.5)); b.line_to(Point::new(sp.x + h, sp.y - h * 0.5)); b.line_to(Point::new(sp.x, sp.y + h)); @@ -831,7 +831,10 @@ fn draw_grip_marker( } else { palette.primary.base.color }; - let fill = if grip.shape == GripShape::Dropdown { + let fill = if matches!( + grip.shape, + GripShape::Dropdown | GripShape::DropdownAdjacent + ) { color } else { palette.background.base.color.scale_alpha(0.7) @@ -3797,4 +3800,3 @@ mod selection_visual_color_tests { assert!((selection_fill_alpha(100.0, true) - 0.45).abs() < 1e-5); } } -